package pluginhost import ( "context" "encoding/json" "fmt" "strings" pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1" "golang.org/x/time/rate" "github.com/Silo-Server/silo-server/internal/events" ) // EventPublisher is the subset of *events.Hub required by RuntimeHostServer. // Defined as an interface so tests can supply a fake. type EventPublisher interface { Publish(ctx context.Context, env events.Envelope) error } // LibraryRecord is the wire shape for libraries returned to plugins. Mirrors // the proto Library message. type LibraryRecord struct { ID string Name string MediaType string // movie | tv | mixed } // LibraryLister returns libraries visible to a user (or all when userID is ""). type LibraryLister interface { ListLibraries(ctx context.Context, userID string) ([]LibraryRecord, error) } // LibraryPresenceRecord is a single host catalog match returned by a // CatalogPresenceLookup. The plugin sees it as proto MediaPresence. type LibraryPresenceRecord struct { ExternalID string MediaID string LibraryID string Title string } // CatalogPresenceLookup answers "which of these external IDs do we already // have?" for a batch of IDs. v1 supports provider "tmdb" only; other values // should return the empty result without error. type CatalogPresenceLookup interface { LookupByExternalIDs(ctx context.Context, provider, mediaType string, ids []string) ([]LibraryPresenceRecord, error) } // InstalledPluginRecord is the host-side shape returned to plugins for peer // discovery. type InstalledPluginRecord struct { InstallationID int PluginID string Version string Enabled bool Capabilities []*pluginv1.CapabilityDescriptor } // InstalledPluginLister returns installed plugins and their capability // descriptors for RuntimeHost.ListInstalledPlugins. type InstalledPluginLister interface { ListInstalledPlugins(ctx context.Context) ([]InstalledPluginRecord, error) } // InstalledPluginListerFunc adapts a plain function to InstalledPluginLister. type InstalledPluginListerFunc func(ctx context.Context) ([]InstalledPluginRecord, error) func (f InstalledPluginListerFunc) ListInstalledPlugins(ctx context.Context) ([]InstalledPluginRecord, error) { return f(ctx) } // GlobalConfigSetter persists a global config entry for a plugin installation. type GlobalConfigSetter interface { SetGlobalConfigEntry(ctx context.Context, installationID int, key string, value map[string]any) error } // GlobalConfigSetterFunc adapts a plain function to GlobalConfigSetter. type GlobalConfigSetterFunc func(ctx context.Context, installationID int, key string, value map[string]any) error func (f GlobalConfigSetterFunc) SetGlobalConfigEntry(ctx context.Context, installationID int, key string, value map[string]any) error { return f(ctx, installationID, key, value) } // DefaultPublishEventRatePerSec is the default maximum number of events a // plugin may publish per second. It also serves as the burst size so a plugin // can fire a short burst at a higher rate before being throttled. const DefaultPublishEventRatePerSec = 100 // RuntimeHostServer is the gRPC server that plugins call back into via the // go-plugin broker. Each plugin instance gets its own RuntimeHostServer so // the pluginID is fixed at construction. type RuntimeHostServer struct { pluginv1.UnimplementedRuntimeHostServer publisher EventPublisher libs LibraryLister catalog CatalogPresenceLookup pluginID string limiter *rate.Limiter installedPlugins InstalledPluginLister configSetter GlobalConfigSetter installationID int } // NewRuntimeHostServer constructs a RuntimeHostServer bound to the given // pluginID. The pluginID is server-stamped on all published events so plugins // cannot forge core event names. func NewRuntimeHostServer(publisher EventPublisher, libs LibraryLister, pluginID string) *RuntimeHostServer { return &RuntimeHostServer{ publisher: publisher, libs: libs, pluginID: pluginID, limiter: rate.NewLimiter(rate.Limit(DefaultPublishEventRatePerSec), DefaultPublishEventRatePerSec), } } // NewRuntimeHostServerWithRate is like NewRuntimeHostServer but installs a // caller-specified rate limit (events/sec, also used as burst). Pass perSec<=0 // to fall back to DefaultPublishEventRatePerSec. func NewRuntimeHostServerWithRate(publisher EventPublisher, libs LibraryLister, pluginID string, perSec int) *RuntimeHostServer { if perSec <= 0 { perSec = DefaultPublishEventRatePerSec } return &RuntimeHostServer{ publisher: publisher, libs: libs, pluginID: pluginID, limiter: rate.NewLimiter(rate.Limit(perSec), perSec), } } // NewRuntimeHostServerWithCatalog is like NewRuntimeHostServer but also // accepts a CatalogPresenceLookup. Use this when the host can answer // CheckMediaPresence; passing nil makes presence queries return empty. func NewRuntimeHostServerWithCatalog(publisher EventPublisher, libs LibraryLister, catalog CatalogPresenceLookup, pluginID string) *RuntimeHostServer { s := NewRuntimeHostServer(publisher, libs, pluginID) s.catalog = catalog return s } // NewRuntimeHostServerWithServices is like NewRuntimeHostServerWithCatalog but // also enables peer discovery and plugin-owned config persistence. func NewRuntimeHostServerWithServices( publisher EventPublisher, libs LibraryLister, catalog CatalogPresenceLookup, installedPlugins InstalledPluginLister, configSetter GlobalConfigSetter, pluginID string, installationID int, ) *RuntimeHostServer { s := NewRuntimeHostServerWithCatalog(publisher, libs, catalog, pluginID) s.installedPlugins = installedPlugins s.configSetter = configSetter s.installationID = installationID return s } // PublishEvent auto-prefixes the plugin's event name with "plugin.." // and forwards to the EventPublisher on events.ChannelPlugins. The plugin ID // is server-stamped (not taken from the request) so plugins cannot forge // core event names by crafting a malicious event_name. func (s *RuntimeHostServer) PublishEvent(ctx context.Context, req *pluginv1.PublishEventRequest) (*pluginv1.PublishEventResponse, error) { if s.limiter != nil && !s.limiter.Allow() { return nil, fmt.Errorf("rate limit exceeded for plugin %q", s.pluginID) } name := strings.TrimSpace(req.GetEventName()) if name == "" { return nil, fmt.Errorf("event_name is required") } if s.pluginID == "" { return nil, fmt.Errorf("server: plugin id not bound") } if s.publisher == nil { return nil, fmt.Errorf("server: event publisher not configured") } var payload json.RawMessage if p := req.GetPayload(); p != nil { raw, err := p.MarshalJSON() if err != nil { return nil, fmt.Errorf("encode payload: %w", err) } payload = raw } prefixed := "plugin." + s.pluginID + "." + name env := events.Envelope{ Channel: events.ChannelPlugins, Event: prefixed, Data: payload, } if err := s.publisher.Publish(ctx, env); err != nil { return nil, fmt.Errorf("publish: %w", err) } return &pluginv1.PublishEventResponse{}, nil } // PublishEventTo is like PublishEvent but restricts delivery to subscribers // belonging to the target plugin_id. func (s *RuntimeHostServer) PublishEventTo(ctx context.Context, req *pluginv1.PublishEventToRequest) (*pluginv1.PublishEventToResponse, error) { if s.limiter != nil && !s.limiter.Allow() { return nil, fmt.Errorf("rate limit exceeded for plugin %q", s.pluginID) } name := strings.TrimSpace(req.GetEventName()) if name == "" { return nil, fmt.Errorf("event_name is required") } targetPluginID := strings.TrimSpace(req.GetTargetPluginId()) if targetPluginID == "" { return nil, fmt.Errorf("target_plugin_id is required") } if s.pluginID == "" { return nil, fmt.Errorf("server: plugin id not bound") } if s.publisher == nil { return nil, fmt.Errorf("server: event publisher not configured") } var payload json.RawMessage if p := req.GetPayload(); p != nil { raw, err := p.MarshalJSON() if err != nil { return nil, fmt.Errorf("encode payload: %w", err) } payload = raw } env := events.Envelope{ Channel: events.ChannelPlugins, Event: "plugin." + s.pluginID + "." + name, Data: payload, TargetPluginID: targetPluginID, } if err := s.publisher.Publish(ctx, env); err != nil { return nil, fmt.Errorf("publish: %w", err) } return &pluginv1.PublishEventToResponse{}, nil } // ListLibraries delegates to the LibraryLister, mapping the userID from the // request through to the underlying data source. func (s *RuntimeHostServer) ListLibraries(ctx context.Context, req *pluginv1.ListLibrariesRequest) (*pluginv1.ListLibrariesResponse, error) { if s.libs == nil { return &pluginv1.ListLibrariesResponse{}, nil } rows, err := s.libs.ListLibraries(ctx, req.GetUserId()) if err != nil { return nil, fmt.Errorf("list libraries: %w", err) } resp := &pluginv1.ListLibrariesResponse{Libraries: make([]*pluginv1.Library, 0, len(rows))} for _, r := range rows { resp.Libraries = append(resp.Libraries, &pluginv1.Library{ Id: r.ID, Name: r.Name, MediaType: r.MediaType, }) } return resp, nil } // ListInstalledPlugins returns installed plugins and their advertised // capabilities for peer discovery. func (s *RuntimeHostServer) ListInstalledPlugins(ctx context.Context, _ *pluginv1.ListInstalledPluginsRequest) (*pluginv1.ListInstalledPluginsResponse, error) { if s.installedPlugins == nil { return &pluginv1.ListInstalledPluginsResponse{}, nil } rows, err := s.installedPlugins.ListInstalledPlugins(ctx) if err != nil { return nil, fmt.Errorf("list installed plugins: %w", err) } resp := &pluginv1.ListInstalledPluginsResponse{Plugins: make([]*pluginv1.InstalledPlugin, 0, len(rows))} for _, row := range rows { resp.Plugins = append(resp.Plugins, &pluginv1.InstalledPlugin{ InstallationId: int64(row.InstallationID), PluginId: row.PluginID, Version: row.Version, Enabled: row.Enabled, Capabilities: row.Capabilities, }) } return resp, nil } // SetGlobalConfigEntry persists a plugin-owned global config entry for this // plugin installation. func (s *RuntimeHostServer) SetGlobalConfigEntry(ctx context.Context, req *pluginv1.SetGlobalConfigEntryRequest) (*pluginv1.SetGlobalConfigEntryResponse, error) { key := strings.TrimSpace(req.GetKey()) if key == "" { return nil, fmt.Errorf("key is required") } if s.installationID == 0 { return nil, fmt.Errorf("server: installation id not bound") } if s.configSetter == nil { return nil, fmt.Errorf("server: config setter not configured") } value := map[string]any{} if req.GetValue() != nil { value = req.GetValue().AsMap() } if err := s.configSetter.SetGlobalConfigEntry(ctx, s.installationID, key, value); err != nil { return nil, fmt.Errorf("set global config entry: %w", err) } return &pluginv1.SetGlobalConfigEntryResponse{}, nil } // CheckMediaPresence delegates to the configured CatalogPresenceLookup. // Returns the empty list when no catalog is configured. func (s *RuntimeHostServer) CheckMediaPresence(ctx context.Context, req *pluginv1.CheckMediaPresenceRequest) (*pluginv1.CheckMediaPresenceResponse, error) { if len(req.GetIds()) > 100 { return nil, fmt.Errorf("ids: too many (%d), max 100", len(req.GetIds())) } if s.catalog == nil { return &pluginv1.CheckMediaPresenceResponse{}, nil } rows, err := s.catalog.LookupByExternalIDs(ctx, req.GetProvider(), req.GetMediaType(), req.GetIds()) if err != nil { return nil, fmt.Errorf("lookup: %w", err) } resp := &pluginv1.CheckMediaPresenceResponse{ Present: make([]*pluginv1.MediaPresence, 0, len(rows)), } for _, r := range rows { resp.Present = append(resp.Present, &pluginv1.MediaPresence{ ExternalId: r.ExternalID, MediaId: r.MediaID, LibraryId: r.LibraryID, Title: r.Title, }) } return resp, nil }