Files
silo-server/internal/api/handlers/plugins.go
T
2c714e4ef2 feat(requests): pluginize request fulfillment behind request_router.v1 (#104)
* docs: design spec for pluginizing requests fulfillment

Pluginize the requests fulfillment backend behind an agnostic
request_router.v1 capability (high seam: whole-request fulfiller).
Host keeps lifecycle/quota/policy/quality-governance and a generic
two-tier connection registry; plugins own routing+submission+status.
First plugin extracts multi-instance Sonarr/Radarr; Seerr follows in
a separate spec. Preserves autoscan reuse of arr connection rows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: implementation plan for requests pluginization

Three-phase plan: (1) request_router.v1 SDK capability, (2) new
silo-plugin-requests-arr plugin extracting multi-instance Sonarr/Radarr,
(3) host refactor routing fulfillment through the plugin while keeping
quality governance, target records, and autoscan connection reuse host-side.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(db): generalize request_integrations into a two-tier connection registry

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(requests): add generic connection fields to Integration + repo mapping

* feat(pluginhost): typed RequestRouter capability client + resolver

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(requests): plugin-backed RequestRouterProvider seam

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(requests): route fulfillment through RequestRouterProvider; host keeps quality governance

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(requests): base auto-approve gate on router connection model

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(api): wire plugin-backed request router at both service sites

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(requests): remove in-host Sonarr/Radarr fulfillment code

* test(autoscan): lock request-integration reuse after connection generalization

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): plugin-driven request integration config form

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(api): echo router connection fields in integration response

* fix(requests): retry dropped qualities, contain to one router installation, dedupe targets

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(requests): harden plugin trust boundary (validate targets, contain bad connections, media-type routing)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(requests): tighten auto-approve gate, restore default/4k validation, propagate config-encode error, drop itoa wrapper, test status/options translation

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(requests): resolve integrations/settings/secrets once per reconcile cycle

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): dedupe config helpers, preserve zero profile id, stabilize installation default, drop redundant options write

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: design spec for schema-driven plugin config form

Extends AdminFormDescriptor into a full form-description language (dynamic
options, multi-select, conditional visibility, sections, validation) + a
plugin Validate RPC, rendered by one reusable SchemaForm engine. Retires the
bespoke arr connection form and integrationOptionsFromRouter so any
request_router backend renders its config UI from manifest data with zero
host changes. Addresses code-review finding #9.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: implementation plan for schema-driven plugin config form

Six phases: SDK AdminFormDescriptor extensions + Validate RPC; reusable
SchemaForm renderer (refactor PluginConfigForm onto it); host Validate
plumbing + generic options + legacy-column derivation + retire
integrationOptionsFromRouter; requests admin page swap to SchemaForm with
per-plugin grouping; arr manifest enrichment + Validate impl; verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): extend plugin admin-form TS types (sections, conditions, validation, multi-select)

* feat(web): schema-form pure utils (show_when, validation, value coercion)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): SchemaForm renderer (controls, sections, show_when, dynamic options, errors)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(web): render PluginConfigForm via the shared SchemaForm engine

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(requests): RequestRouter Validate client + provider seam

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(requests): plugin Validate on save, generic options, derive legacy columns from plugin_config

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(api): generic options response + 400 field_errors on plugin validation failure

* feat(web): generic request-integration options type + surface validation field_errors

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): render request connections via SchemaForm; per-plugin grouping; retire bespoke arr form

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): silent connection-options probe with inline failure status (no toast spam)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(api): serialize admin_form sections/show_when/dynamic_options/validation to the client

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): drop show_when-hidden fields from buildSchemaValues payload

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(requests): pass requester user id as int64 (no truncation)

* refactor(requests): drop legacy arr columns; plugin_config is sole source of truth

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(requests): backfill api key in plugin validate; centralize validation 400; drop duplicate host cross-field check; guard admin-form serializer

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(requests): refuse stored api key reuse when base_url changes (security hardening)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): SchemaForm regex-guard, default_value, type-driven coercion, validity callback

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): connection-options latest-wins + narrowed deps + clear stale errors; auto-select; type-driven persist; reuse types

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: design spec for silo-plugin-requests-seerr (request_router.v1 backend)

* docs: implementation plan for silo-plugin-requests-seerr

* docs(spec): FindExistingRequest uses /api/v1/request (carries request id)

* docs(spec): seerr hardening — id-recovery, 404 terminal, media-status, sort pin, single missing-tmdb message

* docs: design spec for shared plugin-platform SDK helpers (code-review #10)

* docs: plan for plugin-platform SDK helpers (#10) + spec fix (inline broker wiring, no import cycle)

* docs: design spec for typed 4K quality-tier signal (code-review #9)

* docs: implementation plan for typed 4K quality-tier signal (#9)

* feat(requests): stamp is4k per quality (host owns the 4K-tier fact)

* fix(requests): store capability sub-id, not the type, in request_integrations

request_integrations.capability_id carried the capability TYPE
("request_router.v1") instead of the capability sub-id ("arr"/"seerr").
The host resolves a router plugin via
requireCapability("request_router.v1", id), which keys on (type, id), so
storing the type resolved to no capability: every save/options/fulfill
500'd ("Request operation failed" / "no fulfillment backend configured")
in ~1ms, before the arr/Seerr API was ever contacted. The path was
internally split-brained (the fulfillment filter matched the type while
the dispatcher needed the sub-id), so it never worked end-to-end; the
unit tests hid it behind a fake provider that skips requireCapability.

Align capability_id with the scan_source/metadata convention (sub-id):
- validateInstance: require a non-empty sub-id; drop the default-to-type
  and the "!= request_router.v1" reject.
- resolveRouterConnections / integrationConfigured / unbound-guidance:
  match on a non-empty capability, not type equality.
- repository: persist capability_id verbatim (never default to the type).
- web AdminRequests: send the selected plugin's capability.id in both the
  options probe and the save payload (was a hardcoded type constant).
- migration 20260608131649: backfill capability_id from each bound
  installation's request_router.v1 capability and drop the column's
  misleading default. Unbound legacy rows are left for admin re-save.

Tests: validateInstance now requires the sub-id, and the selected sub-id
must reach the plugin Validate RPC (fakeRouterProvider records it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): polish request connection cards (grouped toggles + option loading states)

The schema-driven connection cards rendered each boolean as its own
bordered, double-labeled box and showed dynamic SELECTs (root folder,
quality profile, tags) as empty controls with a single "Loading options…"
line while the host probed the service.

- Toggles render as a cohesive settings list: consecutive switches collapse
  into one bordered, divided container; each row is toggle-first with the
  label + description hugging beside it (no stranded whitespace between a
  short label and its switch). Honors show_when, so conditional toggles
  still group correctly.
- Dynamic SELECT/MULTI_SELECT fields show a per-field spinner + shimmer
  skeleton while options load, and only when there's nothing to show yet —
  a background re-probe never flashes over the operator's current value.
- Sections get a softer surface and clearer titles; the card's enable
  switch is labeled Enabled/Disabled; the options-load failure is a proper
  inline alert with retry guidance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(requests): treat "Any"/no-cap playback ceiling as 4K-allowed

allowedQualities decided whether to also request 2160p with
`CompareQuality(ceiling, PlaybackQuality4K) >= 0`. But an "Any" max
playback quality resolves to an empty ceiling ("no cap"), and in
qualityRank "" is the LOWEST rank (0) — so CompareQuality("", "2160p")
returns -1 and 4K was dropped. A requester with unlimited playback quality
only got a 1080p request, never the 4K one.

Use access.QualityAllowed(PlaybackQuality4K, ceiling), which already
encodes "empty ceiling == no cap == allows everything". Now:
- "" / "Any"  -> 1080p + 2160p
- "2160p"     -> 1080p + 2160p
- "1080p"     -> 1080p only
- resolver error still fails safe to the HD ceiling.

Tests: add an "any/no-cap ceiling adds 2160p" case; the unknown-quality,
status-coercion, dedup, and per-quality-idempotency submit tests now pin
an explicit HD ceiling (they relied on the old empty-default == HD-only
behavior and were not about 4K entitlement).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: design spec for collapsible Library + anime gate/nesting (request card UI, Spec A)

Spec A of two for the request connection card UX: Library section becomes
collapsible/collapsed (auto-expanding on validation errors) and the anime
override fields move into a single gated section below Library instead of
popping out as a detached sibling card. Single-default enforcement is Spec B.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: implementation plan for collapsible Library + anime gate/nesting (Spec A)

Task-by-task TDD plan: SchemaForm auto-expand-on-error + nested-field
affordance (silo-server), arr manifest regroup (collapsible Library, anime
gate section), then build/deploy/reinstall + manual verify.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): auto-expand collapsible schema sections that have validation errors

SchemaFormSection now accepts a forceOpen prop; when any field in the section
has a mergedError (client validation or server error), the section expands
automatically so required-field setup can never be hidden behind a collapsed
accordion. The operator's manual toggle is preserved via a nullable userOpen
state that only takes effect when forceOpen is false.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): indent show_when-revealed schema fields to read as nested

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: design spec for schema-driven single-default exclusivity enforcement (Spec B)

At most one connection per service_kind may be the HD default (is_default) or
4K default (is_default_4k). Generic exclusivity: a new AdminFormField
exclusive_group_field declares the rule, the plugin Validate enforces it
against host-supplied siblings (config only, no creds), and the admin UI
auto-clears conflicts as you toggle. Host stays plugin-agnostic. Forward-only;
no migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: implementation plan for single-default exclusivity enforcement (Spec B)

Five TDD tasks across 3 repos: SDK proto (siblings + exclusive_group_field)
+ buf regen; arr Validate cross-sibling + manifest; host gathers siblings
(config-only) into Validate; frontend generic mutual-exclusion helper; then
re-vendor/rebuild/redeploy + plugininstall.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(requests): pass sibling connections to plugin Validate for cross-connection rules

Adds siblings []ResolvedRouterConnection to RequestRouterProvider.Validate so
the plugin can enforce cross-connection invariants (e.g. one default per
service_kind) without the host resolving sibling credentials. The new
siblingConnections helper gathers other connections on the same installation,
carrying only ID + PluginConfig. Vendor updated to the Task 1 SDK version that
carries ValidateRequest.Siblings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(web): auto-clear mutually-exclusive defaults across request connection cards

Adds generic applyExclusivity helper and wires it into updateCardConfig so
turning on a field with exclusive_group_field proactively clears the same
field on sibling cards sharing the same group value, matching server-side
enforcement with a proactive UX.

* docs: design spec for single-flighting plugin client launch (cold-start herd fix)

Concurrent ensureClient calls for a cold installation each spawn a redundant
plugin process (Host.Start releases its lock during launch). Wrap ensureClient
in a per-installation singleflight.Group so concurrent first-use collapses to
one launch. Host-only fix; surfaced while testing the request-router feature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: implementation plan for single-flighting plugin client launch

TDD: concurrency tests (herd collapses to one launch, warm-cache reuse,
distinct installations stay parallel, failed launch propagates) + the
singleflight wrapper around ensureClient; then rebuild/redeploy + verify.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(plugins): single-flight ensureClient to prevent cold-start launch herd

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(requests): harden capability containment + dedupe eligibility; UI/migration cleanups

Addresses /code-review high findings on the previously-unreviewed commits:
- resolveRouterConnections contains fulfillment to the first chosen
  (installation, capability) and locks only after a connection's key resolves,
  so a plugin exposing >1 request_router capability never mixes connections and
  a skipped bad-key connection never pins the capability (+ test).
- extract eligibleRouterConnection, shared by resolveRouterConnections and
  integrationConfigured so the auto-approval gate and fulfillment filter can't
  drift.
- SchemaForm: shared FieldDescription helper (field/switch/section); key switch
  groups by position so a show_when reveal doesn't remount the group (focus loss).
- migration backfill uses a deterministic correlated subquery instead of a join
  cross-product when an installation exposes multiple request_router capabilities.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: design spec for opt-in Seerr per-user requester mapping

Per-connection requester_mode (admin default | mapped). In mapped mode the host
pushes the requester email/username into the Fulfill descriptor and the seerr
plugin resolves/creates the matching Seerr user by email with operator-chosen
default permissions, attributing the request (and gating Seerr-side approval via
the auto-approve permission). Spans SDK (descriptor fields), host (extend
UserIdentityLookup with email + a requester resolver), and the seerr plugin
(Seerr user API + mapping). Fallback to admin on any failure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: implementation plan for Seerr per-user requester mapping

Five TDD tasks across 3 repos: SDK descriptor fields (requester_email/username);
host resolves identity (UserIdentityLookup+email, RequesterIdentityResolver,
populate descriptor at both Fulfill sites); seerr config+user API (find/create
by email, exported PermissionBits); seerr Fulfill mapping + admin_form; then
re-vendor/rebuild/redeploy + plugininstall (installation 6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: make Seerr unmapped-requester behavior a toggle (admin fallback | fail request)

Per user feedback: require_mapped_user switch (default off = admin fallback,
on = fail the request). Updates spec + plan Tasks 3/4 (config field, Fulfill
honoring the toggle via a mapFailed signal, a new test, and the manifest switch).

* feat(requests): resolve requester email/username into the Fulfill descriptor

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: design spec for simplified Seerr mapped-user permissions

Reduce the 5 permission toggles to two (request_4k_all + auto_approve);
1080p always granted; remove manage_requests; 4K eligibility per-user from the
request's qualities (host-decided, same as arr) with a blanket override toggle.
Seerr-plugin-only; permission-only override (host still gates 4K requests).

* docs: implementation plan for simplified Seerr mapped-user permissions

Two tasks (seerr-plugin-only): replace the 5 perm toggles with request_4k_all +
auto_approve (1080p always; 4K from request qualities via userPermissions;
remove PermManageRequests/PermissionBits; manifest + json_schema), then rebuild
+ reinstall (installation 6). No host/SDK change.

* docs: design spec for host rebase onto main + #95 credential-model adoption

Per-commit rebase of our 68 request-router commits onto the force-pushed
origin/main (drops 188 patch-equivalent). At the credential-path conflicts, adopt
#95's inline secret.Cipher model: keep our plugin columns + #95's encrypt/decrypt
in repository.go; drop our SecretResolver and read in.APIKeyRef directly in
service.go; wire NewRepository(pool, dataCipher). #39-area conflicts take ours
(our pluginization supersedes it). Security review + SECRET_KEY deploy note.

* docs: implementation plan for host rebase + #95 credential adoption

Four tasks: (1) guided per-commit rebase onto origin/main, take-ours on
credential files so it builds; (2) TDD integration commit adopting #95's
secret.Cipher (encrypt/decrypt in repository.go, drop SecretResolver, read
APIKeyRef directly, wire NewRepository(pool, cipher)); (3) security review;
(4) pin published SDK v0.6.0, push fork, open host PR with SECRET_KEY deploy note.

* chore(rebase): restore scan-source service methods + temp requests-repo arity

Post-rebase conflict fixups: take-ours on internal/plugins/service.go dropped
origin's ScanSourceClientByPluginID (independent upstream capability) — restored.
mediarequests.NewRepository temporarily 1-arg to match our pre-#95 repo; Task 2
restores the cipher arg when adopting #95's at-rest credential model.

* feat(requests): adopt at-rest credential cipher (#95) for plugin api keys; drop SecretResolver

* build: pin published silo-plugin-sdk v0.6.0 (drop local replace)

* test(requests): guard at-rest cipher round-trip + empty-key auto-approval (code-review)

Max-effort code review of the #95 credential integration. Fixes the actionable
findings:
- TestEncryptAPIKeyRoundTripAndAAD: pins encryptAPIKey<->DecryptIfEncrypted
  inversion, the id-bound apiKeyAAD == secret.RowAAD(...) match (so #95's backfill
  rows decrypt), the blank-key "" sentinel, and row-bound AAD — the security-
  critical invariants had no automated guard (no DB harness for scanIntegration).
- TestCreateRequestAutoApprovalEmptyKeyTreatedAsUnconfigured: pins that a keyless
  connection reads as unconfigured (request stays pending, never submitted), so
  integrationConfigured and resolveRouterConnections can't drift.
- Fix stale fulfillContext comment (referenced a resolved-API-key cache removed
  with SecretResolver).

Assessed-not-changed (documented): decrypt-error-fails-closed and failed-backfill
behaviors are origin/main #95 design we adopt; nil-cipher is unreachable in prod
and matches the codebase-wide no-guard pattern.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* build: drop stale machine-local SDK replace comment from go.mod

The replace directive was already removed when v0.6.0 was pinned (3410df7);
this leftover comment falsely claimed a local replace still existed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(web): prettier-format schema-form utils to 100-col width

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: drop internal superpowers specs/plans from PR

These design specs and implementation plans are internal development
artifacts; keep them out of the upstream PR diff.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(metadata): exclude providers from content levels they don't declare

ResolveChain falls back to every enabled metadata provider when a library
+ content-level has no enabled chain entry. That fallback was media-type
blind: a provider declaring default_priority only for an unrelated level
(e.g. an audiobook provider declaring {"audiobook": N}) was kept in the
list (merely sorted last) and invoked for video content levels.

In production this made silo.audiobook-metadata hammer external audiobook
APIs with anime/movie/series titles every scheduled enrichment pass
(MatchWorker, 30s) for the season/episode levels that had no enabled chain
entry. Disabling the chain entries did not help because the fallback never
consults them; only disabling the installation removed it from the global
set.

Treat a non-empty default_priority map as the provider enumerating the
content levels it supports: in resolveEnabledProvidersByPriority, exclude
providers whose declared map omits the requested level instead of ranking
them last. Providers that declare no default_priority make no claim and
stay eligible everywhere (legacy behavior).

Fixes #105

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(plugins): isolate singleflight launch from leader ctx cancellation

The deduped ensureClient launch ran doEnsureClient under the leader caller's
ctx, so if that caller's request was canceled/timed out mid-launch the shared
plugin start was torn down and the error propagated to every waiter. Run the
launch under context.WithoutCancel so a single caller cannot cancel work the
other waiters depend on (values preserved for tracing/auth). (CodeRabbit)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(api): nil-guard request-router wiring

RequestRouterClient dereferenced a.Svc unconditionally and AttachRequestRouter
called SetRouterProvider even with nil deps, so a build without the plugin
service would panic instead of degrading. Guard both: the adapter returns a
controlled error and AttachRequestRouter no-ops, leaving fulfillment to fail
with the existing "no backend configured" path. (CodeRabbit)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): correct value coercion + track capability sub-id in request form

- schemaForm: Boolean("false") was true; parse string booleans explicitly.
  array:num now coerces decimals ("1.5"), array:int stays integer-only.
- AdminRequests: track capability_id alongside installation_id (composite
  <Select> value) so a multi-capability installation resolves the exact
  backend; reset pluginConfig when the selected plugin changes so plugin A's
  keys never reach plugin B's options probe/save. (CodeRabbit)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(requests): address request-router review findings

* fix(requests): handle router review edge cases

* fix(web): resolve schema form build casing

* fix(requests): skip unconfigured 4k fulfillment targets

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-09 13:00:48 -04:00

1616 lines
54 KiB
Go

package handlers
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/metadata"
"github.com/Silo-Server/silo-server/internal/pluginhost"
"github.com/Silo-Server/silo-server/internal/plugins"
"github.com/Silo-Server/silo-server/internal/uploads"
)
const (
maxPluginUploadSize = 256 << 20
maxPluginUploadChunkSize = 1 << 20
defaultPluginChunkSize = 512 << 10
)
type PluginHandler struct {
repositories *plugins.RepositoryStore
installations *plugins.InstallationStore
configs *plugins.RuntimeConfigStore
service *plugins.Service
userConfig *plugins.UserConfigStore
proxy *plugins.HTTPProxy
chainRepo *metadata.ChainRepository
imageResolver *metadata.PluginImageResolver
uploads *uploads.Manager
}
func NewPluginHandler(
repositories *plugins.RepositoryStore,
installations *plugins.InstallationStore,
configs *plugins.RuntimeConfigStore,
service *plugins.Service,
userConfig *plugins.UserConfigStore,
proxy *plugins.HTTPProxy,
chainRepo *metadata.ChainRepository,
imageResolver *metadata.PluginImageResolver,
) *PluginHandler {
return &PluginHandler{
repositories: repositories,
installations: installations,
configs: configs,
service: service,
userConfig: userConfig,
proxy: proxy,
chainRepo: chainRepo,
imageResolver: imageResolver,
uploads: uploads.NewManager(uploads.ManagerOptions{
MaxSize: maxPluginUploadSize,
MaxChunkSize: maxPluginUploadChunkSize,
}),
}
}
type pluginRepositoryRequest struct {
URL string `json:"url"`
DisplayName string `json:"display_name"`
Enabled *bool `json:"enabled,omitempty"`
}
type pluginInstallationCreateRequest struct {
RepositoryID *int `json:"repository_id,omitempty"`
PluginID string `json:"plugin_id,omitempty"`
Version string `json:"version,omitempty"`
ArchiveURL string `json:"archive_url,omitempty"`
}
type pluginInstallationUpdateRequest struct {
Enabled *bool `json:"enabled,omitempty"`
UpdatePolicy *string `json:"update_policy,omitempty"`
}
type pluginConfigRequest struct {
Key string `json:"key"`
Value map[string]any `json:"value"`
}
type pluginAuthBindingRequest struct {
CapabilityID string `json:"capability_id"`
Enabled bool `json:"enabled"`
DisplayOrder int `json:"display_order"`
AutoProvision bool `json:"auto_provision"`
DefaultLogin bool `json:"default_login"`
}
type pluginTaskBindingRequest struct {
Enabled bool `json:"enabled"`
Trigger map[string]any `json:"trigger"`
}
type userPluginSettingsRequest struct {
Values map[string]string `json:"values"`
}
type pluginChunkedUploadCreateRequest struct {
Filename string `json:"filename"`
SizeBytes int64 `json:"size_bytes"`
ChunkSize int64 `json:"chunk_size,omitempty"`
}
type pluginRepositoryResponse struct {
ID int `json:"id"`
URL string `json:"url"`
DisplayName string `json:"display_name"`
Enabled bool `json:"enabled"`
LastFetchedAt *time.Time `json:"last_fetched_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type pluginCatalogResponse struct {
RepositoryID int `json:"repository_id"`
PluginID string `json:"plugin_id"`
Version string `json:"version"`
ArchiveURL string `json:"archive_url"`
Capabilities []pluginCapabilityJSON `json:"capabilities"`
GlobalConfigSchema []pluginConfigSchemaJSON `json:"global_config_schema"`
UserConfigSchema []pluginConfigSchemaJSON `json:"user_config_schema"`
Routes []pluginRouteJSON `json:"routes"`
Assets []pluginAssetJSON `json:"assets"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type pluginInstallationResponse struct {
ID int `json:"id"`
RepositoryID *int `json:"repository_id,omitempty"`
PluginID string `json:"plugin_id"`
Version string `json:"version"`
InstallPath string `json:"install_path"`
Enabled bool `json:"enabled"`
UpdatePolicy string `json:"update_policy"`
AvailableVersion *string `json:"available_version,omitempty"`
Capabilities []pluginCapabilityJSON `json:"capabilities"`
GlobalConfigSchema []pluginConfigSchemaJSON `json:"global_config_schema"`
UserConfigSchema []pluginConfigSchemaJSON `json:"user_config_schema"`
Routes []pluginRouteJSON `json:"routes"`
Assets []pluginAssetJSON `json:"assets"`
Metadata map[string]any `json:"metadata,omitempty"`
GlobalConfigs []pluginConfigValueJSON `json:"global_configs"`
AuthBindings []pluginAuthBindingJSON `json:"auth_bindings"`
TaskBindings []pluginTaskBindingJSON `json:"task_bindings"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type pluginConfigSchemaJSON struct {
Key string `json:"key"`
Title string `json:"title"`
Description string `json:"description"`
JSONSchema string `json:"json_schema"`
Required bool `json:"required"`
AdminForm *pluginAdminFormJSON `json:"admin_form,omitempty"`
}
type pluginAdminFormJSON struct {
Fields []pluginAdminFormFieldJSON `json:"fields"`
SubmitLabel string `json:"submit_label,omitempty"`
Sections []pluginAdminFormSectionJSON `json:"sections,omitempty"`
}
type pluginAdminFormFieldJSON struct {
Key string `json:"key"`
Label string `json:"label"`
Description string `json:"description,omitempty"`
Control string `json:"control"`
Placeholder string `json:"placeholder,omitempty"`
Required bool `json:"required"`
Secret bool `json:"secret"`
Multiline bool `json:"multiline"`
DefaultValue any `json:"default_value,omitempty"`
Options []pluginAdminFormOptionJSON `json:"options,omitempty"`
Rows int32 `json:"rows,omitempty"`
DynamicOptions bool `json:"dynamic_options,omitempty"`
ShowWhen []pluginAdminFormConditionJSON `json:"show_when,omitempty"`
Validation *pluginAdminFormValidationJSON `json:"validation,omitempty"`
ExclusiveGroupField string `json:"exclusive_group_field,omitempty"`
}
type pluginAdminFormOptionJSON struct {
Value string `json:"value"`
Label string `json:"label"`
Description string `json:"description,omitempty"`
}
type pluginAdminFormConditionJSON struct {
Field string `json:"field"`
Equals []string `json:"equals"`
}
type pluginAdminFormValidationJSON struct {
HasMin bool `json:"has_min,omitempty"`
Min float64 `json:"min,omitempty"`
HasMax bool `json:"has_max,omitempty"`
Max float64 `json:"max,omitempty"`
Pattern string `json:"pattern,omitempty"`
MinLength int32 `json:"min_length,omitempty"`
MaxLength int32 `json:"max_length,omitempty"`
}
type pluginAdminFormSectionJSON struct {
Key string `json:"key"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
Collapsible bool `json:"collapsible"`
CollapsedDefault bool `json:"collapsed_default"`
FieldKeys []string `json:"field_keys"`
ShowWhen []pluginAdminFormConditionJSON `json:"show_when,omitempty"`
}
type pluginCapabilityJSON struct {
Type string `json:"type"`
ID string `json:"id"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
Subscriptions []string `json:"subscriptions,omitempty"`
ConfigSchema []pluginConfigSchemaJSON `json:"config_schema,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type pluginRouteJSON struct {
ID string `json:"id"`
Method string `json:"method"`
Path string `json:"path"`
Access string `json:"access"`
Navigable bool `json:"navigable"`
NavigationLabel string `json:"navigation_label"`
NavigationKind string `json:"navigation_kind"`
StaticAsset bool `json:"static_asset"`
}
type pluginAssetJSON struct {
Path string `json:"path"`
ContentType string `json:"content_type"`
Integrity string `json:"integrity"`
}
type pluginConfigValueJSON struct {
Key string `json:"key"`
Value map[string]any `json:"value"`
}
type pluginAuthBindingJSON struct {
CapabilityID string `json:"capability_id"`
Enabled bool `json:"enabled"`
DisplayOrder int `json:"display_order"`
AutoProvision bool `json:"auto_provision"`
DefaultLogin bool `json:"default_login"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type pluginTaskBindingJSON struct {
CapabilityID string `json:"capability_id"`
Enabled bool `json:"enabled"`
Trigger map[string]any `json:"trigger"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type pluginUserSettingsSummary struct {
ID int `json:"id"`
PluginID string `json:"plugin_id"`
Version string `json:"version"`
UserConfigSchema []pluginConfigSchemaJSON `json:"user_config_schema"`
Routes []pluginRouteJSON `json:"routes"`
Assets []pluginAssetJSON `json:"assets"`
}
type pluginUserSettingsListResponse struct {
Installations []pluginUserSettingsSummary `json:"installations"`
}
type pluginUserSettingsDetailResponse struct {
Installation pluginUserSettingsSummary `json:"installation"`
Values map[string]string `json:"values"`
}
type pluginTaskBindingUpdateResponse struct {
RestartRequired bool `json:"restart_required"`
}
type pluginChunkedUploadSessionResponse struct {
UploadID string `json:"upload_id"`
Filename string `json:"filename"`
SizeBytes int64 `json:"size_bytes"`
ChunkSize int64 `json:"chunk_size"`
TotalChunks int `json:"total_chunks"`
ReceivedChunks int `json:"received_chunks"`
ReceivedBytes int64 `json:"received_bytes"`
Complete bool `json:"complete"`
ExpiresAt time.Time `json:"expires_at"`
}
func (h *PluginHandler) HandleListRepositories(w http.ResponseWriter, r *http.Request) {
repositories, err := h.repositories.List(r.Context())
if err != nil {
slog.Error("listing plugin repositories", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list plugin repositories")
return
}
response := make([]pluginRepositoryResponse, 0, len(repositories))
for _, repository := range repositories {
response = append(response, toPluginRepositoryResponse(repository))
}
writeJSON(w, http.StatusOK, response)
}
func (h *PluginHandler) HandleCreateRepository(w http.ResponseWriter, r *http.Request) {
var req pluginRepositoryRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if strings.TrimSpace(req.URL) == "" || strings.TrimSpace(req.DisplayName) == "" {
writeError(w, http.StatusBadRequest, "bad_request", "url and display_name are required")
return
}
repository, err := h.repositories.Create(r.Context(), plugins.CreateRepositoryInput{
URL: req.URL,
DisplayName: req.DisplayName,
Enabled: req.Enabled,
})
if err != nil {
slog.Error("creating plugin repository", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create plugin repository")
return
}
writeJSON(w, http.StatusCreated, toPluginRepositoryResponse(repository))
}
func (h *PluginHandler) HandleUpdateRepository(w http.ResponseWriter, r *http.Request) {
id, err := parseNamedIDParam(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid repository ID")
return
}
var req pluginRepositoryRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
input := plugins.UpdateRepositoryInput{
Enabled: req.Enabled,
}
if strings.TrimSpace(req.URL) != "" {
input.URL = &req.URL
}
if strings.TrimSpace(req.DisplayName) != "" {
input.DisplayName = &req.DisplayName
}
if err := h.repositories.Update(r.Context(), id, input); err != nil {
if errors.Is(err, plugins.ErrRepositoryNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Plugin repository not found")
return
}
slog.Error("updating plugin repository", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update plugin repository")
return
}
repository, err := h.repositories.GetByID(r.Context(), id)
if err != nil {
slog.Error("loading updated plugin repository", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load plugin repository")
return
}
writeJSON(w, http.StatusOK, toPluginRepositoryResponse(repository))
}
func (h *PluginHandler) HandleDeleteRepository(w http.ResponseWriter, r *http.Request) {
id, err := parseNamedIDParam(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid repository ID")
return
}
if err := h.repositories.Delete(r.Context(), id); err != nil {
if errors.Is(err, plugins.ErrRepositoryNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Plugin repository not found")
return
}
slog.Error("deleting plugin repository", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete plugin repository")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *PluginHandler) HandleCatalog(w http.ResponseWriter, r *http.Request) {
entries, err := h.service.FetchCatalog(r.Context())
if err != nil {
slog.Error("fetching plugin catalog", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch plugin catalog")
return
}
response := make([]pluginCatalogResponse, 0, len(entries))
for _, entry := range entries {
response = append(response, pluginCatalogResponse{
RepositoryID: entry.RepositoryID,
PluginID: entry.Manifest.GetPluginId(),
Version: entry.Manifest.GetVersion(),
ArchiveURL: entry.ArchiveURL,
Capabilities: capabilitiesToJSON(entry.Manifest.GetCapabilities()),
GlobalConfigSchema: configSchemasToJSON(entry.Manifest.GetGlobalConfigSchema()),
UserConfigSchema: configSchemasToJSON(entry.Manifest.GetUserConfigSchema()),
Routes: routesToJSON(entry.Manifest.GetHttpRoutes()),
Assets: assetsToJSON(entry.Manifest.GetAssets()),
Metadata: structToMap(entry.Manifest.GetMetadata()),
})
}
writeJSON(w, http.StatusOK, response)
}
func (h *PluginHandler) HandleListInstallations(w http.ResponseWriter, r *http.Request) {
installations, err := h.installations.List(r.Context())
if err != nil {
slog.Error("listing plugin installations", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list plugin installations")
return
}
response, err := h.buildInstallationResponses(r.Context(), installations)
if err != nil {
slog.Error("building plugin installations response", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to build plugin installation response")
return
}
writeJSON(w, http.StatusOK, response)
}
func (h *PluginHandler) HandleCreateInstallation(w http.ResponseWriter, r *http.Request) {
var req pluginInstallationCreateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
hasRepositoryFields := req.RepositoryID != nil || strings.TrimSpace(req.PluginID) != "" || strings.TrimSpace(req.Version) != ""
var (
result *plugins.InstallResult
err error
)
switch {
case hasRepositoryFields:
if req.RepositoryID == nil || strings.TrimSpace(req.PluginID) == "" || strings.TrimSpace(req.Version) == "" {
writeError(w, http.StatusBadRequest, "bad_request", "repository_id, plugin_id, and version are required")
return
}
if strings.TrimSpace(req.ArchiveURL) != "" {
writeError(w, http.StatusBadRequest, "bad_request", "archive_url cannot be combined with repository install fields")
return
}
result, err = h.service.InstallCatalog(r.Context(), plugins.InstallCatalogRequest{
RepositoryID: *req.RepositoryID,
PluginID: req.PluginID,
Version: req.Version,
})
default:
if strings.TrimSpace(req.ArchiveURL) == "" {
writeError(w, http.StatusBadRequest, "bad_request", "archive_url is required")
return
}
result, err = h.service.InstallRemote(r.Context(), plugins.InstallArchiveRequest{
ArchiveURL: req.ArchiveURL,
RepositoryID: req.RepositoryID,
})
}
if err != nil {
slog.Error("installing plugin archive", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to install plugin")
return
}
h.syncMetadataProviders(r.Context(), result.Installation)
h.syncImageResolvers(r.Context(), result.Installation)
response, err := h.buildInstallationResponse(r.Context(), result.Installation, result.Manifest)
if err != nil {
slog.Error("building installed plugin response", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to build plugin installation response")
return
}
writeJSON(w, http.StatusCreated, response)
}
func (h *PluginHandler) HandleUploadInstallation(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxPluginUploadSize)
if err := r.ParseMultipartForm(maxPluginUploadSize); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid plugin upload")
return
}
file, header, err := r.FormFile("archive")
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "archive upload is required")
return
}
defer file.Close()
tempFile, err := os.CreateTemp("", "silo-plugin-*.zip")
if err != nil {
slog.Error("creating temp plugin upload file", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to process plugin upload")
return
}
tempPath := tempFile.Name()
defer func() {
_ = tempFile.Close()
_ = os.Remove(tempPath)
}()
if _, err := io.Copy(tempFile, file); err != nil {
slog.Error("writing temp plugin upload file", "filename", header.Filename, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to process plugin upload")
return
}
if err := tempFile.Close(); err != nil {
slog.Error("closing temp plugin upload file", "filename", header.Filename, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to process plugin upload")
return
}
result, err := h.installUploadedPlugin(r.Context(), tempPath)
if err != nil {
slog.Error("installing uploaded plugin", "filename", header.Filename, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to install uploaded plugin")
return
}
h.writeUploadedPluginResponse(w, r, result)
}
func (h *PluginHandler) HandleCreateChunkedUpload(w http.ResponseWriter, r *http.Request) {
var req pluginChunkedUploadCreateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if req.ChunkSize == 0 {
req.ChunkSize = defaultPluginChunkSize
}
session, err := h.uploads.Create(uploads.CreateRequest{
Filename: req.Filename,
SizeBytes: req.SizeBytes,
ChunkSize: req.ChunkSize,
})
if err != nil {
status, message := uploadErrorResponse(err)
writeError(w, status, "upload_error", message)
return
}
writeJSON(w, http.StatusCreated, toPluginChunkedUploadSessionResponse(session))
}
func (h *PluginHandler) HandleUploadChunk(w http.ResponseWriter, r *http.Request) {
uploadID := chi.URLParam(r, "upload_id")
chunkIndex, err := strconv.Atoi(chi.URLParam(r, "chunk_index"))
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid chunk index")
return
}
r.Body = http.MaxBytesReader(w, r.Body, h.uploads.MaxChunkSize()+1)
defer r.Body.Close()
session, err := h.uploads.PutChunk(r.Context(), uploadID, chunkIndex, r.Body, r.ContentLength)
if err != nil {
status, message := uploadErrorResponse(err)
writeError(w, status, "upload_error", message)
return
}
writeJSON(w, http.StatusOK, toPluginChunkedUploadSessionResponse(session))
}
func (h *PluginHandler) HandleCompleteChunkedUpload(w http.ResponseWriter, r *http.Request) {
uploadID := chi.URLParam(r, "upload_id")
upload, err := h.uploads.Complete(uploadID)
if err != nil {
status, message := uploadErrorResponse(err)
writeError(w, status, "upload_error", message)
return
}
defer upload.Cleanup()
result, err := h.installUploadedPlugin(r.Context(), upload.Path)
if err != nil {
slog.Error("installing chunked plugin upload",
"filename", upload.Filename,
"upload_id", upload.ID,
"error", err,
)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to install uploaded plugin")
return
}
h.writeUploadedPluginResponse(w, r, result)
}
func (h *PluginHandler) HandleCancelChunkedUpload(w http.ResponseWriter, r *http.Request) {
if err := h.uploads.Cancel(chi.URLParam(r, "upload_id")); err != nil && !errors.Is(err, uploads.ErrNotFound) {
status, message := uploadErrorResponse(err)
writeError(w, status, "upload_error", message)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *PluginHandler) installUploadedPlugin(ctx context.Context, path string) (*plugins.InstallResult, error) {
zipUpload, err := isZipUploadFile(path)
if err != nil {
return nil, err
}
if zipUpload {
return h.service.InstallLocal(ctx, plugins.InstallArchiveRequest{
ArchivePath: path,
})
}
uploadData, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read uploaded plugin binary: %w", err)
}
return h.service.InstallBinaryUpload(ctx, uploadData)
}
func (h *PluginHandler) writeUploadedPluginResponse(w http.ResponseWriter, r *http.Request, result *plugins.InstallResult) {
h.syncMetadataProviders(r.Context(), result.Installation)
h.syncImageResolvers(r.Context(), result.Installation)
response, err := h.buildInstallationResponse(r.Context(), result.Installation, result.Manifest)
if err != nil {
slog.Error("building uploaded plugin response", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to build plugin installation response")
return
}
writeJSON(w, http.StatusCreated, response)
}
// syncMetadataProviders appends any metadata_provider.v1 capabilities from the
// given installation to all existing library chains.
func (h *PluginHandler) syncMetadataProviders(ctx context.Context, installation *plugins.Installation) {
if h.chainRepo == nil {
return
}
caps, err := h.installations.ListCapabilities(ctx, installation.ID)
if err != nil {
slog.Error("listing capabilities for metadata provider sync",
"installation_id", installation.ID, "error", err)
return
}
for _, cap := range caps {
if cap.Type != "metadata_provider.v1" {
continue
}
if err := h.chainRepo.AppendProviderToAllChains(ctx, installation.ID, cap.ID, func(level string) int {
return metadata.LookupDefaultPriority(ctx, h.chainRepo.Pool(), installation.ID, cap.ID, level)
}); err != nil {
slog.Warn("failed to append provider to library chains",
"installation_id", installation.ID,
"capability_id", cap.ID,
"error", err)
}
}
}
// syncImageResolvers registers image resolver sources for any metadata_provider.v1
// capabilities from the given installation so that plugin-prefixed image URLs
// (e.g. "tmdb://poster/abc.jpg") can be resolved at runtime.
func (h *PluginHandler) syncImageResolvers(ctx context.Context, installation *plugins.Installation) {
if h.imageResolver == nil || h.service == nil {
return
}
caps, err := h.installations.ListCapabilities(ctx, installation.ID)
if err != nil {
slog.Error("listing capabilities for image resolver sync",
"installation_id", installation.ID, "error", err)
return
}
for _, cap := range caps {
if cap.Type != "metadata_provider.v1" {
continue
}
source := metadata.NewPluginClientSource(installation.ID, cap.ID, func(
ctx context.Context, installationID int, capabilityID string,
) (metadata.PluginMetadataClient, error) {
return h.service.MetadataProviderClient(ctx, installationID, capabilityID)
})
h.imageResolver.RegisterSource(cap.ID, source)
slog.Info("registered plugin image resolver", "capability_id", cap.ID, "installation_id", installation.ID)
}
}
func isZipUpload(data []byte) bool {
if len(data) < 4 {
return false
}
return bytes.Equal(data[:4], []byte("PK\x03\x04")) ||
bytes.Equal(data[:4], []byte("PK\x05\x06")) ||
bytes.Equal(data[:4], []byte("PK\x07\x08"))
}
func isZipUploadFile(path string) (bool, error) {
file, err := os.Open(path)
if err != nil {
return false, fmt.Errorf("open uploaded plugin file: %w", err)
}
defer file.Close()
var header [4]byte
n, err := io.ReadFull(file, header[:])
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
return false, fmt.Errorf("read uploaded plugin header: %w", err)
}
return isZipUpload(header[:n]), nil
}
func toPluginChunkedUploadSessionResponse(session uploads.SessionInfo) pluginChunkedUploadSessionResponse {
return pluginChunkedUploadSessionResponse{
UploadID: session.ID,
Filename: session.Filename,
SizeBytes: session.SizeBytes,
ChunkSize: session.ChunkSize,
TotalChunks: session.TotalChunks,
ReceivedChunks: session.ReceivedChunks,
ReceivedBytes: session.ReceivedBytes,
Complete: session.Complete,
ExpiresAt: session.ExpiresAt,
}
}
func uploadErrorResponse(err error) (int, string) {
var maxBytesErr *http.MaxBytesError
switch {
case errors.As(err, &maxBytesErr), errors.Is(err, uploads.ErrTooLarge):
return http.StatusRequestEntityTooLarge, "Upload exceeds the maximum allowed size"
case errors.Is(err, uploads.ErrNotFound):
return http.StatusNotFound, "Upload session not found"
case errors.Is(err, uploads.ErrExpired):
return http.StatusGone, "Upload session expired"
case errors.Is(err, uploads.ErrIncomplete):
return http.StatusConflict, "Upload session is incomplete"
case errors.Is(err, uploads.ErrAlreadyCompleted):
return http.StatusConflict, "Upload session is already complete"
case errors.Is(err, uploads.ErrInvalidChunk), errors.Is(err, uploads.ErrInvalidRequest):
return http.StatusBadRequest, err.Error()
default:
return http.StatusInternalServerError, "Failed to process upload"
}
}
func (h *PluginHandler) HandleUpdateInstallation(w http.ResponseWriter, r *http.Request) {
id, err := parseNamedIDParam(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid installation ID")
return
}
var req pluginInstallationUpdateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
currentInstallation, err := h.installations.GetByID(r.Context(), id)
if err != nil {
if errors.Is(err, plugins.ErrInstallationNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Plugin installation not found")
return
}
slog.Error("loading current plugin installation", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load plugin installation")
return
}
if req.Enabled != nil && !*req.Enabled && currentInstallation.Enabled && h.service != nil {
if err := h.service.Stop(id); err != nil && !errors.Is(err, pluginhost.ErrClientNotFound) {
slog.Error("stopping plugin before disable", "installation_id", id, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to disable plugin installation")
return
}
}
if err := h.installations.Update(r.Context(), id, plugins.UpdateInstallationInput{
Enabled: req.Enabled,
UpdatePolicy: req.UpdatePolicy,
}); err != nil {
if errors.Is(err, plugins.ErrInstallationNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Plugin installation not found")
return
}
slog.Error("updating plugin installation", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update plugin installation")
return
}
// Rebuild the event dispatcher's capability-subscriber index whenever the
// enabled state changes (enable or disable).
if req.Enabled != nil && h.service != nil {
h.service.OnLifecycleChange(r.Context())
}
installation, err := h.installations.GetByID(r.Context(), id)
if err != nil {
slog.Error("loading updated plugin installation", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load plugin installation")
return
}
// When a plugin is being enabled, register its image resolvers.
if req.Enabled != nil && *req.Enabled && !currentInstallation.Enabled {
h.syncImageResolvers(r.Context(), installation)
}
response, err := h.buildInstallationResponse(r.Context(), installation, nil)
if err != nil {
slog.Error("building updated plugin installation response", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to build plugin installation response")
return
}
writeJSON(w, http.StatusOK, response)
}
func (h *PluginHandler) HandleApplyUpdate(w http.ResponseWriter, r *http.Request) {
id, err := parseNamedIDParam(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid installation ID")
return
}
installation, err := h.service.UpdateToAvailableVersion(r.Context(), id)
if err != nil {
if errors.Is(err, plugins.ErrInstallationNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Plugin installation not found")
return
}
slog.Error("apply plugin update", "installation_id", id, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update plugin")
return
}
h.syncMetadataProviders(r.Context(), installation)
h.syncImageResolvers(r.Context(), installation)
response, err := h.buildInstallationResponse(r.Context(), installation, nil)
if err != nil {
slog.Error("building updated plugin installation response", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to build response")
return
}
writeJSON(w, http.StatusOK, response)
}
func (h *PluginHandler) HandlePutInstallationConfig(w http.ResponseWriter, r *http.Request) {
id, err := parseNamedIDParam(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid installation ID")
return
}
var req pluginConfigRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if h.service == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Plugin service not configured")
return
}
if err := h.service.SetGlobalConfig(r.Context(), id, req.Key, req.Value); err != nil {
var validationErr *plugins.ConfigValidationError
switch {
case errors.As(err, &validationErr):
writeError(w, http.StatusBadRequest, "bad_request", validationErr.Error())
case errors.Is(err, plugins.ErrInstallationNotFound):
writeError(w, http.StatusNotFound, "not_found", "Plugin installation not found")
default:
slog.Error("setting plugin global config", "installation_id", id, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save plugin config")
}
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *PluginHandler) HandleTestInstallationConfig(w http.ResponseWriter, r *http.Request) {
id, err := parseNamedIDParam(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid installation ID")
return
}
if h.service == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Plugin service not configured")
return
}
var req pluginConfigRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if strings.TrimSpace(req.Key) == "" {
writeError(w, http.StatusBadRequest, "bad_request", "key is required")
return
}
if err := h.service.TestGlobalConfig(r.Context(), id, req.Key, req.Value); err != nil {
if errors.Is(err, plugins.ErrInstallationNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Plugin installation not found")
return
}
var connectionErr *plugins.ConnectionTestError
if errors.As(err, &connectionErr) {
writeJSON(w, http.StatusOK, connectionCheckResponse{
Success: false,
Message: connectionErr.Error(),
})
return
}
slog.Error("testing plugin config", "installation_id", id, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to test plugin config")
return
}
writeJSON(w, http.StatusOK, connectionCheckResponse{
Success: true,
Message: "Connection successful.",
})
}
func (h *PluginHandler) HandlePutAuthBinding(w http.ResponseWriter, r *http.Request) {
id, err := parseNamedIDParam(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid installation ID")
return
}
var req pluginAuthBindingRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if strings.TrimSpace(req.CapabilityID) == "" {
writeError(w, http.StatusBadRequest, "bad_request", "capability_id is required")
return
}
if err := h.configs.UpsertAuthBinding(r.Context(), plugins.AuthBinding{
InstallationID: id,
CapabilityID: req.CapabilityID,
Enabled: req.Enabled,
DisplayOrder: req.DisplayOrder,
AutoProvision: req.AutoProvision,
DefaultLogin: req.DefaultLogin,
}); err != nil {
slog.Error("saving plugin auth binding", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save auth binding")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *PluginHandler) HandlePutTaskBinding(w http.ResponseWriter, r *http.Request) {
id, err := parseNamedIDParam(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid installation ID")
return
}
capabilityID := chi.URLParam(r, "capability_id")
if strings.TrimSpace(capabilityID) == "" {
writeError(w, http.StatusBadRequest, "bad_request", "capability_id is required")
return
}
var req pluginTaskBindingRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if err := h.configs.UpsertTaskBinding(r.Context(), plugins.TaskBinding{
InstallationID: id,
CapabilityID: capabilityID,
Enabled: req.Enabled,
Trigger: req.Trigger,
}); err != nil {
slog.Error("saving plugin task binding", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save task binding")
return
}
writeJSON(w, http.StatusOK, pluginTaskBindingUpdateResponse{RestartRequired: true})
}
func (h *PluginHandler) HandleDeleteInstallation(w http.ResponseWriter, r *http.Request) {
id, err := parseNamedIDParam(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid installation ID")
return
}
if err := h.installations.Delete(r.Context(), id); err != nil {
if errors.Is(err, plugins.ErrInstallationNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Plugin installation not found")
return
}
slog.Error("deleting plugin installation", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete plugin installation")
return
}
// Rebuild the event dispatcher's capability-subscriber index after removal.
if h.service != nil {
h.service.OnLifecycleChange(r.Context())
}
w.WriteHeader(http.StatusNoContent)
}
// manifestHasUserNavigableRoute returns true if the manifest declares any
// navigable HTTP route with navigation_kind="user". Such plugins should
// appear in the user-facing plugin list (and therefore in the user sidebar)
// even if they expose no user_config_schema.
func manifestHasUserNavigableRoute(manifest *pluginv1.PluginManifest) bool {
for _, r := range manifest.GetHttpRoutes() {
if r.GetNavigable() && r.GetNavigationKind() == "user" {
return true
}
}
return false
}
func (h *PluginHandler) HandleListUserPluginSettings(w http.ResponseWriter, r *http.Request) {
installations, err := h.installations.ListEnabled(r.Context())
if err != nil {
slog.Error("listing enabled plugin installations", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list plugin settings")
return
}
response := pluginUserSettingsListResponse{
Installations: make([]pluginUserSettingsSummary, 0, len(installations)),
}
for _, installation := range installations {
manifest, err := plugins.LoadManifestFile(plugins.InstalledManifestPath(installation.InstallPath))
if err != nil {
slog.Error("loading plugin manifest", "installation_id", installation.ID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load plugin settings")
return
}
if len(manifest.GetUserConfigSchema()) == 0 && !manifestHasUserNavigableRoute(manifest) {
continue
}
response.Installations = append(response.Installations, toUserPluginSettingsSummary(installation, manifest))
}
writeJSON(w, http.StatusOK, response)
}
func (h *PluginHandler) HandleGetUserPluginSettings(w http.ResponseWriter, r *http.Request) {
id, err := parseNamedIDParam(r, "installation_id")
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid installation ID")
return
}
installation, manifest, err := h.loadUserConfigInstallation(w, r, id)
if err != nil {
return
}
userID := apimw.GetUserID(r.Context())
values, err := h.userConfig.Get(r.Context(), userID, id)
if err != nil {
slog.Error("loading plugin user config", "installation_id", id, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load plugin settings")
return
}
writeJSON(w, http.StatusOK, pluginUserSettingsDetailResponse{
Installation: toUserPluginSettingsSummary(installation, manifest),
Values: values,
})
}
func (h *PluginHandler) HandlePutUserPluginSettings(w http.ResponseWriter, r *http.Request) {
id, err := parseNamedIDParam(r, "installation_id")
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid installation ID")
return
}
if _, _, err := h.loadUserConfigInstallation(w, r, id); err != nil {
return
}
var req userPluginSettingsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
userID := apimw.GetUserID(r.Context())
if err := h.userConfig.Set(r.Context(), userID, id, req.Values); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *PluginHandler) loadUserConfigInstallation(
w http.ResponseWriter,
r *http.Request,
installationID int,
) (*plugins.Installation, *pluginv1.PluginManifest, error) {
installation, err := h.installations.GetByID(r.Context(), installationID)
if err != nil {
if errors.Is(err, plugins.ErrInstallationNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Plugin installation not found")
return nil, nil, err
}
slog.Error("loading plugin installation", "installation_id", installationID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load plugin installation")
return nil, nil, err
}
if !installation.Enabled {
writeError(w, http.StatusNotFound, "not_found", "Plugin installation not found")
return nil, nil, plugins.ErrInstallationNotFound
}
manifest, err := h.loadInstallationManifest(r.Context(), installation)
if err != nil {
slog.Error("loading plugin manifest", "installation_id", installationID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load plugin settings")
return nil, nil, err
}
if len(manifest.GetUserConfigSchema()) == 0 && !manifestHasUserNavigableRoute(manifest) {
writeError(w, http.StatusNotFound, "not_found", "Plugin installation does not expose user settings")
return nil, nil, plugins.ErrInstallationNotFound
}
return installation, manifest, nil
}
func (h *PluginHandler) buildInstallationResponses(
ctx context.Context,
installations []*plugins.Installation,
) ([]pluginInstallationResponse, error) {
authBindings, err := h.configs.ListAuthBindings(ctx)
if err != nil {
return nil, err
}
taskBindings, err := h.configs.ListTaskBindings(ctx)
if err != nil {
return nil, err
}
response := make([]pluginInstallationResponse, 0, len(installations))
for _, installation := range installations {
item, err := h.buildInstallationResponseWithBindings(
ctx,
installation,
nil,
authBindings,
taskBindings,
)
if err != nil {
return nil, err
}
response = append(response, item)
}
return response, nil
}
func (h *PluginHandler) buildInstallationResponse(
ctx context.Context,
installation *plugins.Installation,
manifest *pluginv1.PluginManifest,
) (pluginInstallationResponse, error) {
authBindings, err := h.configs.ListAuthBindings(ctx)
if err != nil {
return pluginInstallationResponse{}, err
}
taskBindings, err := h.configs.ListTaskBindings(ctx)
if err != nil {
return pluginInstallationResponse{}, err
}
return h.buildInstallationResponseWithBindings(
ctx,
installation,
manifest,
authBindings,
taskBindings,
)
}
func (h *PluginHandler) buildInstallationResponseWithBindings(
ctx context.Context,
installation *plugins.Installation,
manifest *pluginv1.PluginManifest,
authBindings []*plugins.AuthBinding,
taskBindings []*plugins.TaskBinding,
) (pluginInstallationResponse, error) {
if manifest == nil {
var err error
manifest, err = h.loadInstallationManifest(ctx, installation)
if err != nil && !errors.Is(err, plugins.ErrArchiveNotFound) {
return pluginInstallationResponse{}, err
}
}
capabilities, err := h.loadInstallationCapabilities(ctx, installation, manifest)
if err != nil {
return pluginInstallationResponse{}, err
}
configs, err := h.configs.ListGlobalConfigs(ctx, installation.ID)
if err != nil {
return pluginInstallationResponse{}, err
}
var (
globalConfigSchema []pluginConfigSchemaJSON
userConfigSchema []pluginConfigSchemaJSON
routes []pluginRouteJSON
assets []pluginAssetJSON
metadata map[string]any
)
if manifest != nil {
globalConfigSchema = configSchemasToJSON(manifest.GetGlobalConfigSchema())
userConfigSchema = configSchemasToJSON(manifest.GetUserConfigSchema())
routes = routesToJSON(manifest.GetHttpRoutes())
assets = assetsToJSON(manifest.GetAssets())
metadata = structToMap(manifest.GetMetadata())
}
return pluginInstallationResponse{
ID: installation.ID,
RepositoryID: installation.RepositoryID,
PluginID: installation.PluginID,
Version: installation.Version,
InstallPath: installation.InstallPath,
Enabled: installation.Enabled,
UpdatePolicy: installation.UpdatePolicy,
AvailableVersion: installation.AvailableVersion,
Capabilities: capabilities,
GlobalConfigSchema: globalConfigSchema,
UserConfigSchema: userConfigSchema,
Routes: routes,
Assets: assets,
Metadata: metadata,
GlobalConfigs: configValuesToJSON(configs),
AuthBindings: authBindingsForInstallation(installation.ID, authBindings),
TaskBindings: taskBindingsForInstallation(installation.ID, taskBindings),
CreatedAt: installation.CreatedAt,
UpdatedAt: installation.UpdatedAt,
}, nil
}
func toPluginRepositoryResponse(repository *plugins.Repository) pluginRepositoryResponse {
return pluginRepositoryResponse{
ID: repository.ID,
URL: repository.URL,
DisplayName: repository.DisplayName,
Enabled: repository.Enabled,
LastFetchedAt: repository.LastFetchedAt,
CreatedAt: repository.CreatedAt,
UpdatedAt: repository.UpdatedAt,
}
}
func toUserPluginSettingsSummary(
installation *plugins.Installation,
manifest *pluginv1.PluginManifest,
) pluginUserSettingsSummary {
return pluginUserSettingsSummary{
ID: installation.ID,
PluginID: installation.PluginID,
Version: installation.Version,
UserConfigSchema: configSchemasToJSON(manifest.GetUserConfigSchema()),
Routes: routesToJSON(manifest.GetHttpRoutes()),
Assets: assetsToJSON(manifest.GetAssets()),
}
}
func configSchemasToJSON(schemas []*pluginv1.ConfigSchema) []pluginConfigSchemaJSON {
response := make([]pluginConfigSchemaJSON, 0, len(schemas))
for _, schema := range schemas {
if schema == nil {
continue
}
response = append(response, pluginConfigSchemaJSON{
Key: schema.GetKey(),
Title: schema.GetTitle(),
Description: schema.GetDescription(),
JSONSchema: schema.GetJsonSchema(),
Required: schema.GetRequired(),
AdminForm: adminFormToJSON(schema.GetAdminForm()),
})
}
return response
}
func adminFormToJSON(form *pluginv1.AdminFormDescriptor) *pluginAdminFormJSON {
if form == nil {
return nil
}
fields := make([]pluginAdminFormFieldJSON, 0, len(form.GetFields()))
for _, field := range form.GetFields() {
if field == nil {
continue
}
options := make([]pluginAdminFormOptionJSON, 0, len(field.GetOptions()))
for _, option := range field.GetOptions() {
if option == nil {
continue
}
options = append(options, pluginAdminFormOptionJSON{
Value: option.GetValue(),
Label: option.GetLabel(),
Description: option.GetDescription(),
})
}
var defaultValue any
if field.GetDefaultValue() != nil {
defaultValue = field.GetDefaultValue().AsInterface()
}
var validation *pluginAdminFormValidationJSON
if v := field.GetValidation(); v != nil {
validation = &pluginAdminFormValidationJSON{
HasMin: v.GetHasMin(),
Min: v.GetMin(),
HasMax: v.GetHasMax(),
Max: v.GetMax(),
Pattern: v.GetPattern(),
MinLength: v.GetMinLength(),
MaxLength: v.GetMaxLength(),
}
}
fields = append(fields, pluginAdminFormFieldJSON{
Key: field.GetKey(),
Label: field.GetLabel(),
Description: field.GetDescription(),
Control: strings.TrimPrefix(field.GetControl().String(), "ADMIN_FORM_CONTROL_"),
Placeholder: field.GetPlaceholder(),
Required: field.GetRequired(),
Secret: field.GetSecret(),
Multiline: field.GetMultiline(),
DefaultValue: defaultValue,
Options: options,
Rows: field.GetRows(),
DynamicOptions: field.GetDynamicOptions(),
ShowWhen: adminFormConditionsToJSON(field.GetShowWhen()),
Validation: validation,
ExclusiveGroupField: field.GetExclusiveGroupField(),
})
}
sections := make([]pluginAdminFormSectionJSON, 0, len(form.GetSections()))
for _, section := range form.GetSections() {
if section == nil {
continue
}
sections = append(sections, pluginAdminFormSectionJSON{
Key: section.GetKey(),
Title: section.GetTitle(),
Description: section.GetDescription(),
Collapsible: section.GetCollapsible(),
CollapsedDefault: section.GetCollapsedDefault(),
FieldKeys: append([]string(nil), section.GetFieldKeys()...),
ShowWhen: adminFormConditionsToJSON(section.GetShowWhen()),
})
}
return &pluginAdminFormJSON{
Fields: fields,
SubmitLabel: form.GetSubmitLabel(),
Sections: sections,
}
}
func adminFormConditionsToJSON(conditions []*pluginv1.AdminFormCondition) []pluginAdminFormConditionJSON {
if len(conditions) == 0 {
return nil
}
out := make([]pluginAdminFormConditionJSON, 0, len(conditions))
for _, condition := range conditions {
if condition == nil {
continue
}
out = append(out, pluginAdminFormConditionJSON{
Field: condition.GetField(),
Equals: append([]string(nil), condition.GetEquals()...),
})
}
if len(out) == 0 {
return nil
}
return out
}
func capabilitiesToJSON(descriptors []*pluginv1.CapabilityDescriptor) []pluginCapabilityJSON {
response := make([]pluginCapabilityJSON, 0, len(descriptors))
for _, descriptor := range descriptors {
if descriptor == nil {
continue
}
response = append(response, pluginCapabilityJSON{
Type: descriptor.GetType(),
ID: descriptor.GetId(),
DisplayName: descriptor.GetDisplayName(),
Description: descriptor.GetDescription(),
Subscriptions: append([]string(nil), descriptor.GetSubscriptions()...),
ConfigSchema: configSchemasToJSON(descriptor.GetConfigSchema()),
Metadata: structToMap(descriptor.GetMetadata()),
})
}
return response
}
func routesToJSON(routes []*pluginv1.HttpRouteDescriptor) []pluginRouteJSON {
response := make([]pluginRouteJSON, 0, len(routes))
for _, route := range routes {
if route == nil {
continue
}
response = append(response, pluginRouteJSON{
ID: route.GetId(),
Method: route.GetMethod(),
Path: route.GetPath(),
Access: route.GetAccess(),
Navigable: route.GetNavigable(),
NavigationLabel: route.GetNavigationLabel(),
NavigationKind: route.GetNavigationKind(),
StaticAsset: route.GetStaticAsset(),
})
}
return response
}
func assetsToJSON(assets []*pluginv1.PackagedAsset) []pluginAssetJSON {
response := make([]pluginAssetJSON, 0, len(assets))
for _, asset := range assets {
if asset == nil {
continue
}
response = append(response, pluginAssetJSON{
Path: asset.GetPath(),
ContentType: asset.GetContentType(),
Integrity: asset.GetIntegrity(),
})
}
return response
}
func configValuesToJSON(configs []*plugins.RuntimeConfig) []pluginConfigValueJSON {
response := make([]pluginConfigValueJSON, 0, len(configs))
for _, config := range configs {
if config == nil {
continue
}
response = append(response, pluginConfigValueJSON{
Key: config.Key,
Value: config.Value,
})
}
return response
}
func authBindingsForInstallation(installationID int, bindings []*plugins.AuthBinding) []pluginAuthBindingJSON {
response := make([]pluginAuthBindingJSON, 0)
for _, binding := range bindings {
if binding == nil || binding.InstallationID != installationID {
continue
}
response = append(response, pluginAuthBindingJSON{
CapabilityID: binding.CapabilityID,
Enabled: binding.Enabled,
DisplayOrder: binding.DisplayOrder,
AutoProvision: binding.AutoProvision,
DefaultLogin: binding.DefaultLogin,
CreatedAt: binding.CreatedAt,
UpdatedAt: binding.UpdatedAt,
})
}
return response
}
func taskBindingsForInstallation(installationID int, bindings []*plugins.TaskBinding) []pluginTaskBindingJSON {
response := make([]pluginTaskBindingJSON, 0)
for _, binding := range bindings {
if binding == nil || binding.InstallationID != installationID {
continue
}
response = append(response, pluginTaskBindingJSON{
CapabilityID: binding.CapabilityID,
Enabled: binding.Enabled,
Trigger: binding.Trigger,
CreatedAt: binding.CreatedAt,
UpdatedAt: binding.UpdatedAt,
})
}
return response
}
func (h *PluginHandler) loadInstallationManifest(
ctx context.Context,
installation *plugins.Installation,
) (*pluginv1.PluginManifest, error) {
if installation == nil {
return nil, errors.New("plugin installation is required")
}
if h.service != nil {
manifest, err := h.service.ManifestForInstallation(ctx, installation.ID)
if err == nil {
return manifest, nil
}
if errors.Is(err, plugins.ErrArchiveNotFound) {
return nil, plugins.ErrArchiveNotFound
}
if !errors.Is(err, plugins.ErrArchiveNotFound) {
return nil, err
}
}
manifest, err := loadPluginManifest(installation)
if err != nil && errors.Is(err, os.ErrNotExist) {
return nil, plugins.ErrArchiveNotFound
}
return manifest, err
}
func (h *PluginHandler) loadInstallationCapabilities(
ctx context.Context,
installation *plugins.Installation,
manifest *pluginv1.PluginManifest,
) ([]pluginCapabilityJSON, error) {
if manifest != nil {
return capabilitiesToJSON(manifest.GetCapabilities()), nil
}
records, err := h.installations.ListCapabilities(ctx, installation.ID)
if err != nil {
return nil, err
}
response := make([]pluginCapabilityJSON, 0, len(records))
for _, record := range records {
descriptor, err := plugins.DecodeCapability(record)
if err != nil {
return nil, err
}
response = append(response, capabilitiesToJSON([]*pluginv1.CapabilityDescriptor{descriptor})...)
}
return response, nil
}
func loadPluginManifest(installation *plugins.Installation) (*pluginv1.PluginManifest, error) {
return plugins.LoadManifestFile(plugins.InstalledManifestPath(installation.InstallPath))
}
func structToMap(value interface{ AsMap() map[string]any }) map[string]any {
if value == nil {
return nil
}
return value.AsMap()
}
func parseNamedIDParam(r *http.Request, name string) (int, error) {
return strconv.Atoi(chi.URLParam(r, name))
}