feat: add plugin system (#1034)

* feat: add plugin system

* fix db docker build

* fix hammerhead readme, add strava subscription news to docs

* fixes and sdk improvements

* fix: reduce Meilisearch load, debounce federation sync (#1012)

* optimize meili trail index

* several fixes

---------

Co-authored-by: Flomp <Flomp@users.noreply.github.com>

* Bump svelte from 5.55.5 to 5.56.0 in /docs (#1032)

Bumps [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) from 5.55.5 to 5.56.0.
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/commits/svelte@5.56.0/packages/svelte)

---
updated-dependencies:
- dependency-name: svelte
  dependency-version: 5.56.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Flomp <Flomp@users.noreply.github.com>

* Release v0.19.2 (#1035)

* chore: release v0.19.2

* add changelog

---------

Co-authored-by: Flomp <26000991+Flomp@users.noreply.github.com>
Co-authored-by: Christian Beutel <>

* speed up plugin sync and several small fixes

* concepts for security improvements and process stability

* improve concept

* security concept implemented

* remove insecure TLS

* worker concept implemented

* fixes and cleanup

* fixes

* docu

* mermaid, namings

* WASM plugin host improvements, plugin logging

* fix db migration

* Improve plugin config and category mapping UI

* fixes

* further fixes

* remove manual test sync

* fix db migration and strava mapping

* type added, UI improvements

* fix plugin card toggle clickable area

* optimize synch status card layout

* plugin type 'trails' instead of 'integration'

* session auth validation in UI

* fix komoot date and waypoints

* improve category mapping

* fix send to hammerhead: trail name

* plugin setup error handling improved

* fix review findings

* re-mapping added

* rename remote_category

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Flomp <Flomp@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Flomp <26000991+Flomp@users.noreply.github.com>
This commit is contained in:
slothful-vassal
2026-06-22 15:00:44 +02:00
committed by GitHub
parent 2e3d8537b8
commit 485ec53f6d
196 changed files with 20947 additions and 5454 deletions

View File

@@ -1,81 +0,0 @@
package routes
import (
"encoding/json"
"net/http"
"os"
"pocketbase/integrations/hammerhead"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/security"
)
func IntegrationHammerheadUpload(e *core.RequestEvent) error {
h, err := loginHammerhead(e)
if err != nil {
return err
}
if err := h.UploadActivities(e); err != nil {
return err
}
return e.JSON(http.StatusOK, nil)
}
func IntegrationHammerheadLogin(e *core.RequestEvent) error {
_, err := loginHammerhead(e)
if err != nil {
return err
}
return e.JSON(http.StatusOK, nil)
}
func loginHammerhead(e *core.RequestEvent) (*hammerhead.HammerheadApi, error) {
encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")
if len(encryptionKey) == 0 {
return nil, apis.NewBadRequestError("POCKETBASE_ENCRYPTION_KEY not set", nil)
}
userId := ""
if e.Auth != nil {
userId = e.Auth.Id
} else {
return nil, e.UnauthorizedError("authentication required", nil)
}
integrations, err := e.App.FindAllRecords("integrations", dbx.NewExp("user = {:id}", dbx.Params{"id": userId}))
if err != nil {
return nil, err
}
if len(integrations) == 0 {
return nil, apis.NewBadRequestError("user has no integration", nil)
}
integration := integrations[0]
hammerheadString := integration.GetString("hammerhead")
if len(hammerheadString) == 0 {
return nil, apis.NewBadRequestError("hammerhead integration missing", nil)
}
var hammerheadIntegration hammerhead.HammerheadIntegration
err = json.Unmarshal([]byte(hammerheadString), &hammerheadIntegration)
if err != nil {
return nil, err
}
decryptedPassword, err := security.Decrypt(hammerheadIntegration.Password, encryptionKey)
if err != nil {
return nil, err
}
k := &hammerhead.HammerheadApi{}
err = k.Login(hammerheadIntegration.Email, string(decryptedPassword))
if err != nil {
return nil, apis.NewUnauthorizedError("invalid credentials", nil)
}
return k, e.JSON(http.StatusOK, nil)
}

View File

@@ -1,58 +0,0 @@
package routes
import (
"encoding/json"
"net/http"
"os"
"pocketbase/integrations/komoot"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/security"
)
func IntegrationKommotLogin(e *core.RequestEvent) error {
encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")
if len(encryptionKey) == 0 {
return apis.NewBadRequestError("POCKETBASE_ENCRYPTION_KEY not set", nil)
}
userId := ""
if e.Auth != nil {
userId = e.Auth.Id
} else {
return e.UnauthorizedError("authentication required", nil)
}
integrations, err := e.App.FindAllRecords("integrations", dbx.NewExp("user = {:id}", dbx.Params{"id": userId}))
if err != nil {
return err
}
if len(integrations) == 0 {
return apis.NewBadRequestError("user has no integration", nil)
}
integration := integrations[0]
komootString := integration.GetString("komoot")
if len(komootString) == 0 {
return apis.NewBadRequestError("komoot integration missing", nil)
}
var komootIntegration komoot.KomootIntegration
err = json.Unmarshal([]byte(komootString), &komootIntegration)
if err != nil {
return err
}
decryptedPassword, err := security.Decrypt(komootIntegration.Password, encryptionKey)
if err != nil {
return err
}
k := &komoot.KomootApi{}
err = k.Login(komootIntegration.Email, string(decryptedPassword))
if err != nil {
return apis.NewUnauthorizedError("invalid credentials", nil)
}
return e.JSON(http.StatusOK, nil)
}

View File

@@ -1,87 +0,0 @@
package routes
import (
"encoding/json"
"net/http"
"os"
"pocketbase/integrations/strava"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/security"
)
func IntegrationStravaToken(e *core.RequestEvent) error {
encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")
if len(encryptionKey) == 0 {
return apis.NewBadRequestError("POCKETBASE_ENCRYPTION_KEY not set", nil)
}
var data strava.TokenRequest
if err := e.BindBody(&data); err != nil {
return apis.NewBadRequestError("Failed to read request data", err)
}
userId := ""
if e.Auth != nil {
userId = e.Auth.Id
} else {
return e.UnauthorizedError("authentication required", nil)
}
integrations, err := e.App.FindAllRecords("integrations", dbx.NewExp("user = {:id}", dbx.Params{"id": userId}))
if err != nil {
return err
}
if len(integrations) == 0 {
return apis.NewBadRequestError("user has no integration", nil)
}
integration := integrations[0]
stravaString := integration.GetString("strava")
if len(stravaString) == 0 {
return apis.NewBadRequestError("strava integration missing", nil)
}
var stravaIntegration strava.StravaIntegration
err = json.Unmarshal([]byte(stravaString), &stravaIntegration)
if err != nil {
return err
}
decryptedSecret, err := security.Decrypt(stravaIntegration.ClientSecret, encryptionKey)
if err != nil {
return err
}
request := strava.TokenRequest{
ClientID: stravaIntegration.ClientID,
ClientSecret: string(decryptedSecret),
Code: data.Code,
GrantType: "authorization_code",
}
r, err := strava.GetStravaToken(request)
if err != nil {
return err
}
if r.AccessToken != "" {
stravaIntegration.AccessToken = r.AccessToken
}
if r.RefreshToken != "" {
stravaIntegration.RefreshToken = r.RefreshToken
}
if r.AccessToken != "" {
stravaIntegration.ExpiresAt = r.ExpiresAt
}
stravaIntegration.Active = true
b, err := json.Marshal(stravaIntegration)
if err != nil {
return err
}
integration.Set("strava", string(b))
err = e.App.Save(integration)
if err != nil {
return err
}
return e.JSON(http.StatusOK, nil)
}

View File

@@ -0,0 +1,72 @@
package routes
import (
"net/http"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"pocketbase/pluginsystem"
)
// PluginSystemPluginsList refreshes the installed plugin cache and returns the
// plugins that are available from the local runtime directory.
func PluginSystemPluginsList(e *core.RequestEvent) error {
if e.Auth == nil && !e.HasSuperuserAuth() {
return apis.NewUnauthorizedError("authentication required", nil)
}
manager := pluginsystem.NewManager(e.App, "")
if err := manager.SyncInstalledPlugins(e.Request.Context()); err != nil {
return err
}
plugins, err := manager.ListLocalPlugins(e.Request.Context())
if err != nil {
return err
}
if !e.HasSuperuserAuth() {
for i := range plugins {
plugins[i].Path = ""
}
}
return e.JSON(http.StatusOK, map[string]any{"items": plugins})
}
// localPlugin resolves an installed plugin from the cached installed_plugins
// record, with disk manifest fallback handled inside pluginsystem.
func localPlugin(app core.App, pluginID string) (pluginsystem.LocalPlugin, error) {
plugin, err := pluginsystem.LoadInstalledPlugin(app, "", pluginID)
if err != nil {
return pluginsystem.LocalPlugin{}, apis.NewBadRequestError("unknown plugin", err)
}
return plugin, nil
}
// pluginCapability returns the manifest entry for a concrete capability/version
// pair so the host can call the export declared by the plugin.
func pluginCapability(plugin pluginsystem.LocalPlugin, name string, version string) (pluginsystem.CapabilityManifest, error) {
for _, capability := range plugin.Manifest.Capabilities {
if capability.Name == name && capability.Version == version {
return capability, nil
}
}
return pluginsystem.CapabilityManifest{}, apis.NewBadRequestError("plugin capability is not available", map[string]string{
"name": name,
"version": version,
})
}
// localPluginCapability resolves an installed plugin and verifies that it
// declares the requested capability.
func localPluginCapability(app core.App, pluginID string, name string, version string) (pluginsystem.LocalPlugin, pluginsystem.CapabilityManifest, error) {
plugin, err := localPlugin(app, pluginID)
if err != nil {
return pluginsystem.LocalPlugin{}, pluginsystem.CapabilityManifest{}, err
}
capability, err := pluginCapability(plugin, name, version)
if err != nil {
return pluginsystem.LocalPlugin{}, pluginsystem.CapabilityManifest{}, err
}
return plugin, capability, nil
}

View File

@@ -0,0 +1,222 @@
package routes
import (
"net/http"
"net/url"
"strings"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"pocketbase/pluginsystem"
)
type pluginOAuthStartRequest struct {
PluginID string `json:"pluginId"`
InstanceID string `json:"instanceId"`
AuthContext string `json:"authContext,omitempty"`
RedirectURI string `json:"redirectUri"`
}
type pluginOAuthCallbackRequest struct {
InstanceID string `json:"instanceId"`
Code string `json:"code"`
State string `json:"state"`
}
type pluginOAuthRevokeRequest struct {
InstanceID string `json:"instanceId"`
}
func PluginSystemOAuthStart(e *core.RequestEvent) error {
if e.Auth == nil {
return apis.NewUnauthorizedError("authentication required", nil)
}
var data pluginOAuthStartRequest
if err := e.BindBody(&data); err != nil {
return apis.NewBadRequestError("failed to read request data", err)
}
if data.PluginID == "" || data.RedirectURI == "" {
return apis.NewBadRequestError("pluginId and redirectUri are required", nil)
}
if err := pluginsystem.ValidateOAuthRedirectURI(data.RedirectURI); err != nil {
return apis.NewBadRequestError("redirectUri is not allowed", err)
}
plugin, err := localPlugin(e.App, data.PluginID)
if err != nil {
return err
}
contextName, authContext, err := pluginsystem.OAuthContext(plugin, data.AuthContext)
if err != nil {
return apis.NewBadRequestError("plugin has no oauth auth context", err)
}
instance, err := pluginAuthInstance(e.App, e.Auth.Id, data.PluginID, data.InstanceID)
if err != nil {
return err
}
auth, err := decryptedInstanceAuth(instance)
if err != nil {
return err
}
clientID := pluginsystem.StringFromAny(auth["clientId"])
if clientID == "" {
return apis.NewBadRequestError("oauth clientId is required", nil)
}
state := pluginsystem.NewOAuthState(32)
auth[pluginsystem.AuthFieldOAuthContext] = contextName
auth[pluginsystem.AuthFieldOAuthState] = state
auth[pluginsystem.AuthFieldOAuthRedirectURI] = data.RedirectURI
values := url.Values{}
values.Set("response_type", "code")
values.Set("client_id", clientID)
values.Set("redirect_uri", data.RedirectURI)
values.Set("state", state)
if len(authContext.Scopes) > 0 {
separator := authContext.ScopeSeparator
if separator == "" {
separator = " "
}
values.Set("scope", strings.Join(authContext.Scopes, separator))
}
for key, value := range authContext.AuthorizationParams {
values.Set(key, value)
}
if authContext.PKCE {
verifier := pluginsystem.NewOAuthCodeVerifier(64)
auth[pluginsystem.AuthFieldOAuthCodeVerifier] = verifier
values.Set("code_challenge_method", "S256")
values.Set("code_challenge", pluginsystem.PKCEChallenge(verifier))
}
instance.Set("auth", auth)
instance.Set("status", "needs_auth")
if err := e.App.Save(instance); err != nil {
return err
}
authURL, err := url.Parse(authContext.AuthorizationURL)
if err != nil {
return err
}
query := authURL.Query()
for key, value := range values {
query[key] = value
}
authURL.RawQuery = query.Encode()
return e.JSON(http.StatusOK, map[string]any{
"url": authURL.String(),
"state": state,
"instanceId": instance.Id,
})
}
func PluginSystemOAuthCallback(e *core.RequestEvent) error {
if e.Auth == nil {
return apis.NewUnauthorizedError("authentication required", nil)
}
var data pluginOAuthCallbackRequest
if err := e.BindBody(&data); err != nil {
return apis.NewBadRequestError("failed to read request data", err)
}
if data.InstanceID == "" || data.Code == "" || data.State == "" {
return apis.NewBadRequestError("instanceId, code and state are required", nil)
}
instance, err := e.App.FindRecordById("plugin_instances", data.InstanceID)
if err != nil || instance.GetString("user") != e.Auth.Id {
return apis.NewNotFoundError("plugin instance not found", nil)
}
plugin, err := localPlugin(e.App, instance.GetString("plugin_id"))
if err != nil {
return err
}
auth, err := decryptedInstanceAuth(instance)
if err != nil {
return err
}
if data.State != pluginsystem.StringFromAny(auth[pluginsystem.AuthFieldOAuthState]) {
return apis.NewBadRequestError("invalid oauth state", nil)
}
contextName := pluginsystem.StringFromAny(auth[pluginsystem.AuthFieldOAuthContext])
_, authContext, err := pluginsystem.OAuthContext(plugin, contextName)
if err != nil {
return apis.NewBadRequestError("plugin has no oauth auth context", err)
}
token, err := pluginsystem.ExchangeOAuthToken(e.Request.Context(), plugin.Manifest, authContext, auth, map[string]string{
"grant_type": "authorization_code",
"code": data.Code,
"redirect_uri": pluginsystem.StringFromAny(auth[pluginsystem.AuthFieldOAuthRedirectURI]),
"code_verifier": pluginsystem.StringFromAny(
auth[pluginsystem.AuthFieldOAuthCodeVerifier],
),
})
if err != nil {
return apis.NewBadRequestError("oauth token exchange failed", err)
}
pluginsystem.StoreOAuthToken(auth, contextName, token)
for _, field := range pluginsystem.InternalOAuthTransientFields() {
delete(auth, field)
}
instance.Set("auth", auth)
instance.Set("status", "configured")
instance.Set("last_error", map[string]any{})
if err := e.App.Save(instance); err != nil {
return err
}
return e.JSON(http.StatusOK, map[string]any{"ok": true})
}
func PluginSystemOAuthRevoke(e *core.RequestEvent) error {
if e.Auth == nil {
return apis.NewUnauthorizedError("authentication required", nil)
}
var data pluginOAuthRevokeRequest
if err := e.BindBody(&data); err != nil {
return apis.NewBadRequestError("failed to read request data", err)
}
if data.InstanceID == "" {
return apis.NewBadRequestError("instanceId is required", nil)
}
instance, err := e.App.FindRecordById("plugin_instances", data.InstanceID)
if err != nil || instance.GetString("user") != e.Auth.Id {
return apis.NewNotFoundError("plugin instance not found", nil)
}
auth, err := decryptedInstanceAuth(instance)
if err != nil {
return err
}
pluginsystem.ClearOAuthToken(auth)
instance.Set("auth", auth)
instance.Set("status", "needs_auth")
if err := e.App.Save(instance); err != nil {
return err
}
return e.JSON(http.StatusOK, map[string]any{"ok": true})
}
func pluginAuthInstance(app core.App, userID string, pluginID string, instanceID string) (*core.Record, error) {
if instanceID != "" {
instance, err := app.FindRecordById("plugin_instances", instanceID)
if err != nil || instance.GetString("user") != userID || instance.GetString("plugin_id") != pluginID {
return nil, apis.NewNotFoundError("plugin instance not found", nil)
}
return instance, nil
}
return app.FindFirstRecordByFilter(
"plugin_instances",
"user={:user} && plugin_id={:plugin_id}",
dbx.Params{"user": userID, "plugin_id": pluginID},
)
}

View File

@@ -0,0 +1,242 @@
package routes
import (
"net/http"
"strings"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"pocketbase/plugins/importer"
)
type pluginCategoryRemapRequest struct {
InstanceID string `json:"instanceId"`
Config map[string]any `json:"config,omitempty"`
}
type pluginCategoryRemapResponse struct {
Count int `json:"count"`
BackfilledSinceMapping int `json:"backfilledSinceMapping,omitempty"`
Remapped int `json:"remapped,omitempty"`
}
type pluginCategoryRemapCandidate struct {
Trail *core.Record
CategoryID string
}
type pluginCategoryTrailReference struct {
Ref *core.Record
Trail *core.Record
ExternalID string
}
// PluginSystemCategoryRemapPreview counts imported trails whose stored provider
// category can be mapped with the current plugin instance configuration.
func PluginSystemCategoryRemapPreview(e *core.RequestEvent) error {
instance, mapping, err := pluginCategoryRemapInput(e)
if err != nil {
return err
}
refs, err := pluginCategoryTrailReferences(e.App, e.Auth.Id, instance.GetString("plugin_id"))
if err != nil {
return err
}
candidates := pluginCategoryRemapCandidatesFromRefs(e.App, refs, mapping)
backfilledSinceMapping := pluginCategoryBackfilledSinceMappingCountFromRefs(e.App, instance, refs, mapping)
return e.JSON(http.StatusOK, pluginCategoryRemapResponse{
Count: len(candidates),
BackfilledSinceMapping: backfilledSinceMapping,
})
}
// PluginSystemCategoryRemapApply updates the local category of imported trails
// whose stored provider category matches the current plugin instance mapping.
func PluginSystemCategoryRemapApply(e *core.RequestEvent) error {
instance, mapping, err := pluginCategoryRemapInput(e)
if err != nil {
return err
}
candidates, err := pluginCategoryRemapCandidates(e.App, e.Auth.Id, instance.GetString("plugin_id"), mapping)
if err != nil {
return err
}
remapped := 0
if err := e.App.RunInTransaction(func(txApp core.App) error {
for _, candidate := range candidates {
trail, err := txApp.FindRecordById("trails", candidate.Trail.Id)
if err != nil {
return err
}
trail.Set("category", candidate.CategoryID)
if err := txApp.Save(trail); err != nil {
return err
}
remapped++
}
return nil
}); err != nil {
return err
}
return e.JSON(http.StatusOK, pluginCategoryRemapResponse{Count: len(candidates), Remapped: remapped})
}
func pluginCategoryRemapInput(e *core.RequestEvent) (*core.Record, map[string]string, error) {
if e.Auth == nil {
return nil, nil, apis.NewUnauthorizedError("authentication required", nil)
}
var data pluginCategoryRemapRequest
if err := e.BindBody(&data); err != nil {
return nil, nil, apis.NewBadRequestError("Failed to read request data", err)
}
if data.InstanceID == "" {
return nil, nil, apis.NewBadRequestError("instanceId is required", nil)
}
instance, err := e.App.FindRecordById("plugin_instances", data.InstanceID)
if err != nil || instance.GetString("user") != e.Auth.Id {
return nil, nil, apis.NewNotFoundError("plugin instance not found", err)
}
config := effectivePluginConfig(e.App, instance.GetString("plugin_id"), instance)
if data.Config != nil {
config = data.Config
}
return instance, categoryMapping(pluginHostConfig(config)), nil
}
func pluginCategoryRemapCandidates(app core.App, userID string, pluginID string, mapping map[string]string) ([]pluginCategoryRemapCandidate, error) {
if userID == "" || pluginID == "" || len(mapping) == 0 {
return nil, nil
}
refs, err := pluginCategoryTrailReferences(app, userID, pluginID)
if err != nil || len(refs) == 0 {
return nil, err
}
return pluginCategoryRemapCandidatesFromRefs(app, refs, mapping), nil
}
func pluginCategoryRemapCandidatesFromRefs(app core.App, refs []pluginCategoryTrailReference, mapping map[string]string) []pluginCategoryRemapCandidate {
if len(refs) == 0 || len(mapping) == 0 {
return nil
}
candidates := make([]pluginCategoryRemapCandidate, 0, len(refs))
for _, ref := range refs {
providerCategory := strings.TrimSpace(ref.Ref.GetString("provider_category"))
categoryID, matched := importer.CategoryFromProviderMapping(app, providerCategory, mapping)
if !matched || categoryID == "" || ref.Trail.GetString("category") == categoryID {
continue
}
candidates = append(candidates, pluginCategoryRemapCandidate{
Trail: ref.Trail,
CategoryID: categoryID,
})
}
return candidates
}
func pluginCategoryBackfilledSinceMappingCountFromRefs(app core.App, instance *core.Record, refs []pluginCategoryTrailReference, mapping map[string]string) int {
mappingUpdatedAt := categoryMappingUpdatedAt(app, instance)
if mappingUpdatedAt.IsZero() || len(refs) == 0 || len(mapping) == 0 {
return 0
}
count := 0
for _, ref := range refs {
checkedAt := ref.Ref.GetDateTime("provider_category_checked_at")
if checkedAt.IsZero() || !checkedAt.Time().After(mappingUpdatedAt) {
continue
}
providerCategory := strings.TrimSpace(ref.Ref.GetString("provider_category"))
categoryID, matched := importer.CategoryFromProviderMapping(app, providerCategory, mapping)
if matched && categoryID != "" && ref.Trail.GetString("category") != categoryID {
count++
}
}
return count
}
func categoryMappingUpdatedAt(app core.App, instance *core.Record) time.Time {
if instance == nil {
return time.Time{}
}
config := effectivePluginConfig(app, instance.GetString("plugin_id"), instance)
raw, _ := pluginHostConfig(config)["categoryMappingUpdatedAt"].(string)
if raw == "" {
return time.Time{}
}
parsed, err := time.Parse(time.RFC3339Nano, raw)
if err != nil {
return time.Time{}
}
return parsed
}
func pluginCategoryTrailReferences(app core.App, userID string, pluginID string) ([]pluginCategoryTrailReference, error) {
if userID == "" || pluginID == "" {
return nil, nil
}
refs, err := app.FindRecordsByFilter(
"trail_external_reference",
"user={:user} && plugin_id={:plugin_id}",
"",
-1,
0,
dbx.Params{"user": userID, "plugin_id": pluginID},
)
if err != nil || len(refs) == 0 {
return nil, err
}
trailIDs := make([]string, 0, len(refs))
seen := map[string]bool{}
for _, ref := range refs {
trailID := ref.GetString("trail")
if trailID == "" || seen[trailID] {
continue
}
seen[trailID] = true
trailIDs = append(trailIDs, trailID)
}
if len(trailIDs) == 0 {
return nil, nil
}
trails, err := app.FindRecordsByIds("trails", trailIDs)
if err != nil {
return nil, err
}
trailsByID := make(map[string]*core.Record, len(trails))
for _, trail := range trails {
trailsByID[trail.Id] = trail
}
result := make([]pluginCategoryTrailReference, 0, len(trails))
seen = map[string]bool{}
for _, ref := range refs {
trailID := ref.GetString("trail")
if trailID == "" || seen[trailID] {
continue
}
trail := trailsByID[trailID]
if trail == nil {
continue
}
seen[trailID] = true
result = append(result, pluginCategoryTrailReference{
Ref: ref,
Trail: trail,
ExternalID: ref.GetString("external_id"),
})
}
return result, nil
}

View File

@@ -0,0 +1,42 @@
package routes
import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"pocketbase/pluginsystem"
)
func effectivePluginConfig(app core.App, pluginID string, instance *core.Record) map[string]any {
config := installedPluginConfig(app, pluginID)
pluginsystem.MergePluginConfig(config, pluginsystem.JSONMapFromRecord(instance, "config"))
return config
}
func pluginRuntimeConfig(config map[string]any) map[string]any {
return configSection(config, "plugin")
}
func pluginHostConfig(config map[string]any) map[string]any {
return configSection(config, "host")
}
func configSection(config map[string]any, key string) map[string]any {
raw, ok := config[key].(map[string]any)
if !ok || raw == nil {
return map[string]any{}
}
return raw
}
func installedPluginConfig(app core.App, pluginID string) map[string]any {
record, _ := app.FindFirstRecordByFilter(
"installed_plugins",
"plugin_id={:plugin_id}",
dbx.Params{"plugin_id": pluginID},
)
if record == nil {
return map[string]any{}
}
return pluginsystem.JSONMapFromRecord(record, "config")
}

View File

@@ -0,0 +1,147 @@
package routes
import (
"encoding/base64"
"fmt"
"strings"
"pocketbase/pluginsystem"
)
func pluginInstancePolicy(plugin pluginsystem.LocalPlugin, config map[string]any) pluginsystem.RequestPolicyContext {
connectors := map[string]pluginsystem.ResolvedConnectorTarget{}
hostConfig := pluginHostConfig(config)
hostConnectors := configMap(configMap(hostConfig, "connectors"), "")
for _, manifestConnector := range plugin.Manifest.Permissions.Network.Connectors {
target, err := resolveConnectorTarget(manifestConnector, hostConnectors)
if err != nil {
continue
}
connectors[manifestConnector.Name] = target
}
return pluginsystem.RequestPolicyContext{Connectors: connectors}
}
func resolveConnectorTarget(manifest pluginsystem.ConnectorTargetPermission, hostConnectors map[string]any) (pluginsystem.ResolvedConnectorTarget, error) {
target := pluginsystem.ResolvedConnectorTarget{
Name: manifest.Name,
Type: manifest.Type,
AllowedPathPrefixes: manifest.AllowedPathPrefixes,
Auth: manifest.Auth,
SupportsMediaAuth: manifest.SupportsMediaAuth,
SupportsStorageRedirects: manifest.SupportsStorageRedirects,
SupportsCustomTLS: manifest.SupportsCustomTLS,
TLS: pluginsystem.ConnectorTLSConfig{Mode: pluginsystem.TLSModeSystem},
StorageOrigins: map[string]pluginsystem.ResolvedConnectorOrigin{},
}
switch manifest.Type {
case pluginsystem.ConnectorTypePublicAPI:
baseURL, basePath, err := pluginsystem.NormalizeConnectorBase(manifest.FixedBaseURL, "")
if err != nil {
return target, err
}
target.BaseURL = baseURL
target.BasePath = basePath
target.AllowPrivate = false
case pluginsystem.ConnectorTypeConfigured:
rawConfig := configMap(hostConnectors, manifest.ConfigKey)
if len(rawConfig) == 0 {
return target, fmt.Errorf("configured connector %q has no host config", manifest.Name)
}
baseURL := stringConfig(rawConfig, "baseURL")
basePath := stringConfig(rawConfig, "basePath")
normalizedBaseURL, normalizedBasePath, err := pluginsystem.NormalizeConnectorBase(baseURL, basePath)
if err != nil {
return target, err
}
target.BaseURL = normalizedBaseURL
target.BasePath = normalizedBasePath
target.AllowPrivate = boolConfig(rawConfig, "allowPrivate")
target.TLS = tlsConfig(rawConfig, manifest.SupportsCustomTLS)
if manifest.SupportsStorageRedirects {
target.StorageOrigins = storageOrigins(rawConfig)
}
default:
return target, fmt.Errorf("unsupported connector type %q", manifest.Type)
}
return target, nil
}
func storageOrigins(rawConfig map[string]any) map[string]pluginsystem.ResolvedConnectorOrigin {
rawOrigins := configMap(rawConfig, "storageOrigins")
origins := map[string]pluginsystem.ResolvedConnectorOrigin{}
for name, raw := range rawOrigins {
originMap, ok := raw.(map[string]any)
if !ok {
continue
}
baseURL, basePath, err := pluginsystem.NormalizeConnectorBase(
stringConfig(originMap, "baseURL"),
stringConfig(originMap, "basePath"),
)
if err != nil {
continue
}
origins[name] = pluginsystem.ResolvedConnectorOrigin{
Name: name,
BaseURL: baseURL,
BasePath: basePath,
AllowPrivate: boolConfig(originMap, "allowPrivate"),
TLS: tlsConfig(originMap, true),
}
}
return origins
}
func tlsConfig(raw map[string]any, customAllowed bool) pluginsystem.ConnectorTLSConfig {
rawTLS := configMap(raw, "tls")
mode := stringConfig(rawTLS, "mode")
if mode == "" {
mode = pluginsystem.TLSModeSystem
}
if mode != pluginsystem.TLSModeSystem && mode != pluginsystem.TLSModeCustomCA {
mode = pluginsystem.TLSModeSystem
}
if !customAllowed && mode != pluginsystem.TLSModeSystem {
mode = pluginsystem.TLSModeSystem
}
cfg := pluginsystem.ConnectorTLSConfig{Mode: mode}
if mode == pluginsystem.TLSModeCustomCA {
ca := stringConfig(rawTLS, "caBundle")
if decoded, err := base64.StdEncoding.DecodeString(ca); err == nil {
cfg.CABundle = decoded
} else {
cfg.CABundle = []byte(ca)
}
}
return cfg
}
func configMap(raw map[string]any, key string) map[string]any {
if key == "" {
return raw
}
value, ok := raw[key]
if !ok {
return map[string]any{}
}
switch typed := value.(type) {
case map[string]any:
return typed
default:
return map[string]any{}
}
}
func stringConfig(raw map[string]any, key string) string {
value, _ := raw[key].(string)
return strings.TrimSpace(value)
}
func boolConfig(raw map[string]any, key string) bool {
value, _ := raw[key].(bool)
return value
}

View File

@@ -0,0 +1,55 @@
package routes
import (
"testing"
"pocketbase/pluginsystem"
)
func TestPluginInstancePolicyUsesHostConnectorConfig(t *testing.T) {
plugin := pluginsystem.LocalPlugin{Manifest: pluginsystem.Manifest{
Permissions: pluginsystem.PermissionManifest{
Network: pluginsystem.NetworkPermissions{
Connectors: []pluginsystem.ConnectorTargetPermission{{
Name: "media",
Type: pluginsystem.ConnectorTypeConfigured,
ConfigKey: "immich",
SupportsCustomTLS: true,
}},
},
},
}}
config := map[string]any{
"plugin": map[string]any{
"after": "2026-01-01",
},
"host": map[string]any{
"connectors": map[string]any{
"immich": map[string]any{
"baseURL": "https://photos.example.test",
"basePath": "/immich",
"allowPrivate": true,
"tls": map[string]any{
"mode": pluginsystem.TLSModeCustomCA,
"caBundle": "test-ca",
},
},
},
},
}
policy := pluginInstancePolicy(plugin, config)
connector, ok := policy.Connectors["media"]
if !ok {
t.Fatal("expected configured connector to be resolved from host config")
}
if connector.BaseURL != "https://photos.example.test" || connector.BasePath != "/immich" {
t.Fatalf("unexpected connector base: %#v", connector)
}
if !connector.AllowPrivate {
t.Fatal("expected allowPrivate from host connector config")
}
if connector.TLS.Mode != pluginsystem.TLSModeCustomCA || string(connector.TLS.CABundle) != "test-ca" {
t.Fatalf("unexpected TLS config: %#v", connector.TLS)
}
}

View File

@@ -0,0 +1,223 @@
package routes
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/security"
"pocketbase/pluginsystem"
"pocketbase/util"
)
type pluginSystemTrailSendRequest struct {
PluginID string `json:"pluginId"`
TrailID string `json:"trailId"`
Share string `json:"share,omitempty"`
}
type pluginSystemTrailSendInput struct {
Instance pluginsystem.InstanceRef `json:"instance"`
Auth map[string]any `json:"auth,omitempty"`
Config map[string]any `json:"config,omitempty"`
Name string `json:"name,omitempty"`
Trail pluginsystem.Track `json:"trail"`
}
// PluginSystemTrailSend asks a plugin to prepare a trail send request for an
// existing trail and then executes that request through the host policy layer.
func PluginSystemTrailSend(e *core.RequestEvent) error {
if e.Auth == nil {
return apis.NewUnauthorizedError("authentication required", nil)
}
var data pluginSystemTrailSendRequest
if err := e.BindBody(&data); err != nil {
return apis.NewBadRequestError("Failed to read request data", err)
}
if data.PluginID == "" || data.TrailID == "" {
return apis.NewBadRequestError("pluginId and trailId are required", nil)
}
instance, err := e.App.FindFirstRecordByFilter(
"plugin_instances",
"user={:user} && plugin_id={:plugin_id} && enabled=true",
dbx.Params{"user": e.Auth.Id, "plugin_id": data.PluginID},
)
if err != nil {
return apis.NewBadRequestError("no enabled plugin instance configured for this plugin", nil)
}
plugin, capability, err := localPluginCapability(e.App, data.PluginID, "prepare_trail_send", "v1")
if err != nil {
return err
}
trail, err := e.App.FindRecordById("trails", data.TrailID)
if err != nil {
return apis.NewNotFoundError("trail not found", nil)
}
if !util.TrailViewableByUser(e.App, trail, e.Auth.Id, data.Share) {
return apis.NewForbiddenError("not allowed to send this trail", nil)
}
gpx, err := readTrailGPX(e.App, trail)
if err != nil {
return err
}
if len(gpx) == 0 {
return apis.NewBadRequestError("trail has no GPX track", nil)
}
auth, err := decryptedInstanceAuth(instance)
if err != nil {
return err
}
input := pluginSystemTrailSendInput{
Instance: pluginsystem.InstanceRef{
ID: instance.Id,
PluginID: instance.GetString("plugin_id"),
},
Auth: pluginsystem.PluginInputAuth(plugin, auth),
Name: trail.GetString("name"),
Trail: pluginsystem.Track{
Format: "gpx",
ContentBase64: base64.StdEncoding.EncodeToString(gpx),
},
}
config := effectivePluginConfig(e.App, plugin.Manifest.ID, instance)
pluginConfig := pluginRuntimeConfig(config)
policy := pluginInstancePolicy(plugin, config)
input.Config = pluginConfig
inputBytes, err := json.Marshal(input)
if err != nil {
return err
}
runtime, err := pluginsystem.NewRuntimeRegistry().RuntimeFor(plugin)
if err != nil {
return err
}
session, err := runtime.OpenSession(e.Request.Context(), plugin, policy.WithHostAuth(auth))
if err != nil {
return err
}
defer func() {
_ = session.Close(context.Background())
}()
output, err := session.Call(e.Request.Context(), capability.Export, inputBytes)
if err != nil {
return err
}
var plan pluginsystem.TrailSendPlan
if err := json.Unmarshal(output, &plan); err != nil {
return apis.NewBadRequestError("plugin returned an invalid send plan", err)
}
if plan.Request.Method == "" {
return apis.NewBadRequestError("plugin returned an empty send request", nil)
}
if err := pluginsystem.ValidateHostRequestSpec(plugin.Manifest, plan.Request, policy); err != nil {
return apis.NewBadRequestError("plugin send request is not permitted by manifest", err)
}
if err := pluginsystem.InjectHostRequestAuth(e.Request.Context(), pluginsystem.AuthInjectionInput{
App: e.App,
Runtime: runtime,
Session: session,
Plugin: plugin,
Instance: instance,
Auth: auth,
Config: pluginConfig,
Spec: &plan.Request,
Policy: policy,
}); err != nil {
return apis.NewBadRequestError("plugin auth injection failed", err)
}
// Auth is fully resolved above (including OAuth refresh and plugin session
// refresh). Clearing the reference makes this handler the sole injector so the
// executor's policy-based injection becomes a no-op instead of re-injecting
// against an empty policy.HostAuth.
plan.Request.Auth = ""
if err := executeHostRequest(e.Request.Context(), plugin.Manifest, policy, plan.Request, gpx); err != nil {
return err
}
return e.JSON(http.StatusOK, map[string]any{"ok": true})
}
// executeHostRequest runs a plugin send plan through the shared host request
// executor and maps provider failures to API errors.
func executeHostRequest(ctx context.Context, manifest pluginsystem.Manifest, policy pluginsystem.RequestPolicyContext, spec pluginsystem.HostRequestSpec, gpx []byte) error {
resp, err := pluginsystem.ExecuteHostRequest(ctx, manifest, policy, spec, pluginsystem.HostRequestOptions{
Trail: gpx,
})
if err != nil {
return err
}
if resp.Status < 200 || resp.Status >= 300 {
return apis.NewBadRequestError(
fmt.Sprintf("provider request failed: %d", resp.Status),
strings.TrimSpace(string(resp.Body)),
)
}
return nil
}
// readTrailGPX loads the trail GPX file that can be inserted into a plugin's
// multipart send plan.
func readTrailGPX(app core.App, trail *core.Record) ([]byte, error) {
gpxPath := trail.GetString("gpx")
if gpxPath == "" {
return nil, nil
}
fsys, err := app.NewFilesystem()
if err != nil {
return nil, err
}
defer fsys.Close()
reader, err := fsys.GetReader(trail.BaseFilesPath() + "/" + gpxPath)
if err != nil {
return nil, err
}
defer reader.Close()
return io.ReadAll(reader)
}
// decryptedInstanceAuth returns auth fields in the shape expected by host-side
// auth injection and plugin input preparation.
func decryptedInstanceAuth(instance *core.Record) (map[string]any, error) {
auth := pluginsystem.JSONMapFromRecord(instance, "auth")
if len(auth) == 0 {
return map[string]any{}, nil
}
encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")
if encryptionKey == "" {
return nil, apis.NewBadRequestError("POCKETBASE_ENCRYPTION_KEY not set", nil)
}
for key, value := range auth {
secret, ok := value.(string)
if !ok || secret == "" || !util.CanDecryptSecret(secret) {
continue
}
decrypted, err := security.Decrypt(secret, encryptionKey)
if err != nil {
return nil, fmt.Errorf("decrypt %s: %w", key, err)
}
auth[key] = string(decrypted)
}
return auth, nil
}

View File

@@ -0,0 +1,119 @@
package routes
import (
"encoding/json"
"net/http"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"pocketbase/pluginsystem"
)
type pluginSessionAuthValidateRequest struct {
PluginID string `json:"pluginId"`
InstanceID string `json:"instanceId,omitempty"`
AuthContext string `json:"authContext,omitempty"`
Auth map[string]any `json:"auth,omitempty"`
}
type pluginSessionAuthRefreshInput struct {
Instance pluginsystem.InstanceRef `json:"instance"`
Auth map[string]any `json:"auth,omitempty"`
}
func PluginSystemSessionAuthValidate(e *core.RequestEvent) error {
if e.Auth == nil {
return apis.NewUnauthorizedError("authentication required", nil)
}
var data pluginSessionAuthValidateRequest
if err := e.BindBody(&data); err != nil {
return apis.NewBadRequestError("failed to read request data", err)
}
if data.PluginID == "" {
return apis.NewBadRequestError("pluginId is required", nil)
}
plugin, err := localPlugin(e.App, data.PluginID)
if err != nil {
return err
}
contextName, authContext, err := sessionAuthContext(plugin, data.AuthContext)
if err != nil {
return apis.NewBadRequestError("plugin has no session auth context", err)
}
if authContext.Refresh == nil || authContext.Refresh.Function == "" {
return apis.NewBadRequestError("plugin session auth context has no refresh function", nil)
}
auth := map[string]any{}
instanceID := data.InstanceID
if instanceID != "" {
instance, err := pluginAuthInstance(e.App, e.Auth.Id, data.PluginID, instanceID)
if err != nil {
return err
}
instanceID = instance.Id
auth, err = decryptedInstanceAuth(instance)
if err != nil {
return err
}
}
for key, value := range data.Auth {
if value == "" {
continue
}
auth[key] = value
}
inputBytes, err := json.Marshal(pluginSessionAuthRefreshInput{
Instance: pluginsystem.InstanceRef{
ID: instanceID,
PluginID: plugin.Manifest.ID,
},
Auth: pluginsystem.AuthForPluginRefresh(auth, authContext),
})
if err != nil {
return err
}
runtime, err := pluginsystem.NewRuntimeRegistry().RuntimeFor(plugin)
if err != nil {
return err
}
// TODO: accept and merge plugin instance config here before supporting
// session-auth plugins with configured connectors. The current validation
// path is sufficient for public_api session plugins such as komoot and
// hammerhead, but configured connectors need host config for policy
// resolution and refresh input parity with production auth injection.
policy := pluginInstancePolicy(plugin, map[string]any{}).WithHostAuth(auth)
output, err := runtime.Call(e.Request.Context(), plugin, authContext.Refresh.Function, inputBytes, policy)
if err != nil {
return apis.NewBadRequestError("plugin credentials validation failed", err)
}
if err := pluginsystem.ValidatePluginSessionRefreshOutput(output); err != nil {
return apis.NewBadRequestError("plugin credentials validation failed", err)
}
return e.JSON(http.StatusOK, map[string]any{
"ok": true,
"authContext": contextName,
})
}
func sessionAuthContext(plugin pluginsystem.LocalPlugin, requested string) (string, pluginsystem.AuthContext, error) {
if requested != "" {
authContext, ok := plugin.Manifest.Auth.Contexts[requested]
if !ok || authContext.Type != pluginsystem.AuthTypeSession {
return "", pluginsystem.AuthContext{}, apis.NewBadRequestError("unknown session auth context", nil)
}
return requested, authContext, nil
}
for name, authContext := range plugin.Manifest.Auth.Contexts {
if authContext.Type == pluginsystem.AuthTypeSession {
return name, authContext, nil
}
}
return "", pluginsystem.AuthContext{}, apis.NewBadRequestError("session auth context not found", nil)
}

View File

@@ -0,0 +1,619 @@
package routes
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/meilisearch/meilisearch-go"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"pocketbase/plugins/importer"
"pocketbase/pluginsystem"
"pocketbase/services/trailmerge"
"pocketbase/util"
)
const (
defaultPluginSyncBatchLimit = 50
defaultPluginSyncMaxBatches = 100
defaultPluginProviderCategoryBackfillLimit = 10
)
var syncCapabilityDescriptors = []syncCapabilityDescriptor{
{
OptionKey: "planned",
CapabilityName: "list_routes",
DetailName: "get_route_detail",
Version: "v1",
},
{
OptionKey: "completed",
CapabilityName: "list_activities",
DetailName: "get_activity_detail",
Version: "v1",
},
}
type syncCapabilityDescriptor struct {
OptionKey string
CapabilityName string
DetailName string
Version string
}
type pluginSystemListInput struct {
Instance pluginsystem.InstanceRef `json:"instance"`
Auth map[string]any `json:"auth,omitempty"`
State map[string]any `json:"state,omitempty"`
Options map[string]any `json:"options,omitempty"`
Limits pluginSystemSyncLimits `json:"limits,omitempty"`
}
type pluginSystemSyncLimits struct {
MaxItems int `json:"maxItems,omitempty"`
}
type pluginSystemListOutput struct {
Items []pluginsystem.TrailSummary `json:"items"`
State map[string]any `json:"state,omitempty"`
HasMore bool `json:"hasMore"`
Error *pluginsystem.PluginError `json:"error,omitempty"`
}
type pluginSystemDetailInput struct {
Instance pluginsystem.InstanceRef `json:"instance"`
Auth map[string]any `json:"auth,omitempty"`
Options map[string]any `json:"options,omitempty"`
Summary pluginsystem.TrailSummary `json:"summary"`
}
type pluginSystemDetailOutput struct {
Item pluginsystem.TrailImport `json:"item"`
Error *pluginsystem.PluginError `json:"error,omitempty"`
}
type pluginSystemSyncResult struct {
PluginID string `json:"pluginId"`
Imported int `json:"imported"`
Skipped int `json:"skipped"`
}
// PluginSystemSyncConfigured is the cron entrypoint. It refreshes plugin
// metadata, finds enabled instances, skips instances in backoff, and syncs each
// configured import capability.
func PluginSystemSyncConfigured(ctx context.Context, app core.App, client meilisearch.ServiceManager) error {
app.Logger().Info("plugin sync cron started")
manager := pluginsystem.NewManager(app, "")
if err := manager.SyncInstalledPlugins(ctx); err != nil {
return err
}
plugins, err := pluginsystem.LoadInstalledPlugins(app, "")
if err != nil {
return err
}
app.Logger().Info("plugin sync discovered installed plugins", "count", len(plugins))
var syncErr error
for _, plugin := range plugins {
if !pluginHasAnySyncCapability(plugin) {
app.Logger().Info("plugin sync skipping plugin without sync capability", "plugin", plugin.Manifest.ID)
continue
}
instances, err := pluginInstances(app, plugin.Manifest.ID)
if err != nil {
return err
}
app.Logger().Info("plugin sync found enabled instances", "plugin", plugin.Manifest.ID, "count", len(instances))
for _, instance := range instances {
if err := ctx.Err(); err != nil {
return err
}
if shouldSkipPluginInstance(instance) {
app.Logger().Info("plugin sync skipping instance due to retry delay", "plugin", plugin.Manifest.ID, "instance", instance.Id, "retry_not_before", instance.GetString("retry_not_before"))
continue
}
app.Logger().Info("plugin instance sync started", "plugin", plugin.Manifest.ID, "instance", instance.Id)
result, err := syncPluginInstance(ctx, app, client, plugin, instance)
if err != nil {
app.Logger().Warn("plugin instance sync failed", "plugin", plugin.Manifest.ID, "instance", instance.Id, "error", err)
syncErr = err
continue
}
app.Logger().Info("plugin instance sync completed", "plugin", result.PluginID, "instance", instance.Id, "imported", result.Imported, "skipped", result.Skipped)
}
}
app.Logger().Info("plugin sync cron completed")
return syncErr
}
func pluginInstances(app core.App, pluginID string) ([]*core.Record, error) {
return app.FindRecordsByFilter(
"plugin_instances",
"plugin_id={:plugin_id} && enabled=true",
"",
-1,
0,
dbx.Params{"plugin_id": pluginID},
)
}
// syncPluginInstance prepares one plugin instance for import: it resolves the
// actor, creates the runtime, decrypts/refreshes auth, and dispatches every
// enabled sync capability.
func syncPluginInstance(ctx context.Context, app core.App, client meilisearch.ServiceManager, plugin pluginsystem.LocalPlugin, instance *core.Record) (*pluginSystemSyncResult, error) {
actor, err := app.FindFirstRecordByData("activitypub_actors", "user", instance.GetString("user"))
if err != nil {
setPluginInstanceStatus(app, instance, "error", "invalid_request", "activitypub actor not found")
return nil, err
}
auth, err := decryptedInstanceAuth(instance)
if err != nil {
setPluginInstanceStatus(app, instance, "needs_reauth", "auth_failed", err.Error())
return nil, err
}
auth, err = pluginsystem.RefreshOAuthAuthIfNeeded(ctx, app, plugin, instance, auth)
if err != nil {
setPluginInstanceStatus(app, instance, "needs_reauth", "auth_failed", err.Error())
return nil, err
}
config := effectivePluginConfig(app, plugin.Manifest.ID, instance)
pluginConfig := pluginRuntimeConfig(config)
hostConfig := pluginHostConfig(config)
defaultPublic := userDefaultPublic(app, instance.GetString("user"))
createSummitLog := boolOption(hostConfig, "createSummitLogForCompleted", true)
runtime, err := pluginsystem.NewRuntimeRegistry().RuntimeFor(plugin)
if err != nil {
setPluginInstanceStatusForError(app, instance, err)
return nil, err
}
sessions := &pluginSyncRuntimeSession{
runtime: runtime,
plugin: plugin,
policy: pluginInstancePolicy(plugin, config).WithHostAuth(auth),
}
if err := sessions.open(ctx); err != nil {
setPluginInstanceStatusForError(app, instance, err)
return nil, err
}
defer func() {
_ = sessions.close(context.Background())
}()
instance.Set("status", "syncing")
if err := app.Save(instance); err != nil {
return nil, err
}
result := &pluginSystemSyncResult{PluginID: plugin.Manifest.ID}
for _, descriptor := range syncCapabilityDescriptors {
if !boolOption(hostConfig, descriptor.OptionKey, true) {
app.Logger().Info("plugin sync skipping disabled capability", "plugin", plugin.Manifest.ID, "instance", instance.Id, "capability", descriptor.CapabilityName, "option", descriptor.OptionKey)
continue
}
if !pluginHasCapability(plugin, descriptor.CapabilityName, descriptor.Version) {
app.Logger().Info("plugin sync skipping unavailable capability", "plugin", plugin.Manifest.ID, "instance", instance.Id, "capability", descriptor.CapabilityName, "version", descriptor.Version)
continue
}
if !pluginHasCapability(plugin, descriptor.DetailName, descriptor.Version) {
app.Logger().Warn("plugin sync skipping list capability because matching detail capability is unavailable", "plugin", plugin.Manifest.ID, "instance", instance.Id, "capability", descriptor.CapabilityName, "detail_capability", descriptor.DetailName, "version", descriptor.Version)
continue
}
capability, err := pluginCapability(plugin, descriptor.CapabilityName, descriptor.Version)
if err != nil {
return nil, err
}
detailCapability, err := pluginCapability(plugin, descriptor.DetailName, descriptor.Version)
if err != nil {
return nil, err
}
app.Logger().Info("plugin capability sync started", "plugin", plugin.Manifest.ID, "instance", instance.Id, "capability", capability.Name, "version", capability.Version, "export", capability.Export)
capResult, err := syncPluginCapability(ctx, app, client, sessions, plugin, capability, detailCapability, instance, actor, auth, pluginConfig, hostConfig, defaultPublic, createSummitLog)
if err != nil {
setPluginInstanceStatusForError(app, instance, err)
return nil, err
}
app.Logger().Info("plugin capability sync completed", "plugin", plugin.Manifest.ID, "instance", instance.Id, "capability", capability.Name, "imported", capResult.Imported, "skipped", capResult.Skipped)
result.Imported += capResult.Imported
result.Skipped += capResult.Skipped
}
instance.Set("state", map[string]any{})
instance.Set("last_sync_at", time.Now())
instance.Set("last_error", map[string]any{})
instance.Set("retry_not_before", "")
instance.Set("status", "configured")
if err := app.Save(instance); err != nil {
return nil, err
}
return result, nil
}
// shouldSkipPluginInstance applies retry delay from the last sync error.
func shouldSkipPluginInstance(instance *core.Record) bool {
retryNotBefore := instance.GetDateTime("retry_not_before")
return !retryNotBefore.IsZero() && retryNotBefore.Time().After(time.Now())
}
type capabilitySyncResult struct {
Imported int
Skipped int
}
type pluginSyncRuntimeSession struct {
runtime pluginsystem.Runtime
plugin pluginsystem.LocalPlugin
policy pluginsystem.RequestPolicyContext
session pluginsystem.RuntimeSession
}
func (s *pluginSyncRuntimeSession) open(ctx context.Context) error {
session, err := s.runtime.OpenSession(ctx, s.plugin, s.policy)
if err != nil {
return err
}
s.session = session
return nil
}
func (s *pluginSyncRuntimeSession) reopen(ctx context.Context) error {
_ = s.close(context.Background())
return s.open(ctx)
}
func (s *pluginSyncRuntimeSession) close(ctx context.Context) error {
if s.session == nil {
return nil
}
err := s.session.Close(ctx)
s.session = nil
return err
}
// syncPluginCapability calls one plugin export such as list_routes_v1, imports
// the returned trail items, and carries transient page state only within this
// sync run. The page cursor is intentionally not persisted across runs.
func syncPluginCapability(ctx context.Context, app core.App, client meilisearch.ServiceManager, sessions *pluginSyncRuntimeSession, plugin pluginsystem.LocalPlugin, capability pluginsystem.CapabilityManifest, detailCapability pluginsystem.CapabilityManifest, instance *core.Record, actor *core.Record, auth map[string]any, pluginConfig map[string]any, hostConfig map[string]any, defaultPublic bool, createSummitLog bool) (*capabilitySyncResult, error) {
result := &capabilitySyncResult{}
state := map[string]any{}
hasMore := true
policy := sessions.policy
providerCategoryBackfillsRemaining := 0
if hasUsableCategoryMapping(categoryMapping(hostConfig)) {
providerCategoryBackfillsRemaining = defaultPluginProviderCategoryBackfillLimit
}
for batch := 0; hasMore && batch < defaultPluginSyncMaxBatches; batch++ {
input := pluginSystemListInput{
Instance: pluginsystem.InstanceRef{
ID: instance.Id,
PluginID: instance.GetString("plugin_id"),
},
Auth: pluginsystem.PluginInputAuth(plugin, auth),
State: state,
Options: pluginConfig,
Limits: pluginSystemSyncLimits{MaxItems: defaultPluginSyncBatchLimit},
}
inputBytes, err := json.Marshal(input)
if err != nil {
return nil, err
}
outputBytes, err := sessions.session.Call(ctx, capability.Export, inputBytes)
if err != nil {
return nil, err
}
var output pluginSystemListOutput
if err := json.Unmarshal(outputBytes, &output); err != nil {
return nil, fmt.Errorf("plugin returned invalid %s output: %w", capability.Export, err)
}
if output.Error != nil {
return nil, pluginsystem.PluginCapabilityError{Err: output.Error}
}
app.Logger().Info("plugin capability batch returned items", "plugin", plugin.Manifest.ID, "instance", instance.Id, "capability", capability.Name, "batch", batch, "items", len(output.Items), "has_more", output.HasMore)
summaries := output.Items
externalIDsByProvider := map[string][]string{}
for i := range summaries {
if summaries[i].Source.Provider == "" {
summaries[i].Source.Provider = plugin.Manifest.ID
}
if summaries[i].Source.ExternalID == "" {
continue
}
externalIDsByProvider[summaries[i].Source.Provider] = append(externalIDsByProvider[summaries[i].Source.Provider], summaries[i].Source.ExternalID)
}
existingIDsByProvider := map[string]map[string]bool{}
providerCategoryBackfillCandidatesByProvider := map[string]map[string]*core.Record{}
for provider, externalIDs := range externalIDsByProvider {
existingIDs, err := util.FindExistingExternalReferenceIDsForUser(app, instance.GetString("user"), provider, externalIDs)
if err != nil {
return nil, err
}
existingIDsByProvider[provider] = existingIDs
if providerCategoryBackfillsRemaining > 0 && len(existingIDs) > 0 {
candidates, err := providerCategoryBackfillCandidatesForSync(app, instance.GetString("user"), provider, externalIDs, providerCategoryBackfillsRemaining)
if err != nil {
return nil, err
}
providerCategoryBackfillCandidatesByProvider[provider] = candidates
}
}
for _, summary := range summaries {
if summary.Source.ExternalID == "" {
continue
}
if existingIDsByProvider[summary.Source.Provider][summary.Source.ExternalID] {
result.Skipped++
if providerCategoryBackfillsRemaining > 0 {
ref := providerCategoryBackfillCandidatesByProvider[summary.Source.Provider][summary.Source.ExternalID]
attempted, err := backfillProviderCategoryDuringSync(ctx, app, sessions, plugin, detailCapability, instance, auth, pluginConfig, summary, ref)
if err != nil {
return nil, err
}
if attempted {
providerCategoryBackfillsRemaining--
}
}
continue
}
item, err := pluginDetail(ctx, sessions.session, plugin, detailCapability, instance, auth, pluginConfig, summary)
if err != nil {
result.Skipped++
app.Logger().Warn("skipping plugin item after detail fetch failed", "plugin", plugin.Manifest.ID, "instance", instance.Id, "capability", capability.Name, "provider", summary.Source.Provider, "external_id", summary.Source.ExternalID, "error", err)
if pluginsystem.IsRuntimeSessionFatalError(err) {
if reopenErr := sessions.reopen(ctx); reopenErr != nil {
return nil, reopenErr
}
}
continue
}
applyHostPolicy(&item, hostConfig)
imported, err := importer.ImportTrail(ctx, app, item, importer.Options{
UserID: instance.GetString("user"),
ActorID: actor.Id,
DefaultPublic: defaultPublic,
CreateSummitLogForCompleted: createSummitLog,
CategoryMapping: categoryMapping(hostConfig),
Manifest: plugin.Manifest,
Policy: policy,
Auth: auth,
})
if err != nil {
return nil, err
}
if imported.Created {
result.Imported++
app.Logger().Info("imported plugin trail", "provider", item.Source.Provider, "external_id", item.Source.ExternalID, "trail", imported.TrailID)
if autoMergeEnabled(hostConfig) {
settings := trailmerge.DefaultPluginAutoMergeSettings()
settings.Enabled = true
if err := trailmerge.TryAutoMergeImportedTrail(app, client, ctx, actor, imported.TrailID, settings); err != nil {
app.Logger().Warn("unable to auto-merge imported plugin trail", "provider", item.Source.Provider, "external_id", item.Source.ExternalID, "trail", imported.TrailID, "error", err)
}
}
}
if imported.Skipped {
result.Skipped++
}
}
state = output.State
if state == nil {
state = map[string]any{}
}
hasMore = output.HasMore
}
if hasMore {
return nil, fmt.Errorf("sync stopped after %d batches", defaultPluginSyncMaxBatches)
}
return result, nil
}
func providerCategoryBackfillCandidatesForSync(app core.App, userID string, provider string, externalIDs []string, limit int) (map[string]*core.Record, error) {
candidates := map[string]*core.Record{}
if userID == "" || provider == "" || len(externalIDs) == 0 || limit <= 0 {
return candidates, nil
}
params := dbx.Params{
"user": userID,
"provider": provider,
}
seenExternalIDs := map[string]bool{}
idFilters := make([]string, 0, len(externalIDs))
for _, externalID := range externalIDs {
if externalID == "" || seenExternalIDs[externalID] {
continue
}
seenExternalIDs[externalID] = true
paramName := fmt.Sprintf("external_id_%d", len(idFilters))
params[paramName] = externalID
idFilters = append(idFilters, "external_id={:"+paramName+"}")
}
if len(idFilters) == 0 {
return candidates, nil
}
filter := "user={:user} && provider={:provider} && (" + strings.Join(idFilters, " || ") + ")"
refs, err := app.FindRecordsByFilter("trail_external_reference", filter, "", len(idFilters), 0, params)
if err != nil || len(refs) == 0 {
return candidates, err
}
for _, ref := range refs {
if len(candidates) >= limit {
break
}
if ref.GetString("provider_category") != "" || !ref.GetDateTime("provider_category_checked_at").IsZero() {
continue
}
candidates[ref.GetString("external_id")] = ref
}
return candidates, nil
}
func backfillProviderCategoryDuringSync(ctx context.Context, app core.App, sessions *pluginSyncRuntimeSession, plugin pluginsystem.LocalPlugin, detailCapability pluginsystem.CapabilityManifest, instance *core.Record, auth map[string]any, pluginConfig map[string]any, summary pluginsystem.TrailSummary, ref *core.Record) (bool, error) {
if ref == nil {
return false, nil
}
item, err := pluginDetail(ctx, sessions.session, plugin, detailCapability, instance, auth, pluginConfig, summary)
if err != nil {
app.Logger().Warn("skipping provider category backfill after detail fetch failed", "plugin", plugin.Manifest.ID, "instance", instance.Id, "provider", summary.Source.Provider, "external_id", summary.Source.ExternalID, "error", err)
if pluginsystem.IsRuntimeSessionFatalError(err) {
if reopenErr := sessions.reopen(ctx); reopenErr != nil {
return true, reopenErr
}
}
return true, nil
}
ref.Set("provider_category", importer.ProviderCategoryFromImport(item))
ref.Set("provider_category_checked_at", time.Now())
if err := app.Save(ref); err != nil {
return false, err
}
return true, nil
}
func pluginDetail(ctx context.Context, session pluginsystem.RuntimeSession, plugin pluginsystem.LocalPlugin, capability pluginsystem.CapabilityManifest, instance *core.Record, auth map[string]any, pluginConfig map[string]any, summary pluginsystem.TrailSummary) (pluginsystem.TrailImport, error) {
input := pluginSystemDetailInput{
Instance: pluginsystem.InstanceRef{
ID: instance.Id,
PluginID: instance.GetString("plugin_id"),
},
Auth: pluginsystem.PluginInputAuth(plugin, auth),
Options: pluginConfig,
Summary: summary,
}
inputBytes, err := json.Marshal(input)
if err != nil {
return pluginsystem.TrailImport{}, err
}
outputBytes, err := session.Call(ctx, capability.Export, inputBytes)
if err != nil {
return pluginsystem.TrailImport{}, err
}
var output pluginSystemDetailOutput
if err := json.Unmarshal(outputBytes, &output); err != nil {
return pluginsystem.TrailImport{}, fmt.Errorf("plugin returned invalid %s output: %w", capability.Export, err)
}
if output.Error != nil {
return pluginsystem.TrailImport{}, pluginsystem.PluginCapabilityError{Err: output.Error}
}
return output.Item, nil
}
func pluginHasCapability(plugin pluginsystem.LocalPlugin, name string, version string) bool {
for _, capability := range plugin.Manifest.Capabilities {
if capability.Name == name && capability.Version == version {
return true
}
}
return false
}
func pluginHasAnySyncCapability(plugin pluginsystem.LocalPlugin) bool {
for _, descriptor := range syncCapabilityDescriptors {
if pluginHasCapability(plugin, descriptor.CapabilityName, descriptor.Version) {
return true
}
}
return false
}
func setPluginInstanceStatus(app core.App, instance *core.Record, status string, code string, message string) {
instance.Set("status", status)
instance.Set("last_error", map[string]any{
"code": code,
"message": message,
})
if err := app.Save(instance); err != nil {
app.Logger().Warn("failed to update plugin instance status", "instance", instance.Id, "error", err)
}
}
func setPluginInstanceStatusForError(app core.App, instance *core.Record, err error) {
update := pluginsystem.InstanceStatusForError(err, time.Now())
instance.Set("status", update.Status)
instance.Set("last_error", map[string]any{
"code": update.Code,
"message": update.Message,
})
if update.RetryNotBefore != nil {
instance.Set("retry_not_before", *update.RetryNotBefore)
} else {
instance.Set("retry_not_before", "")
}
if saveErr := app.Save(instance); saveErr != nil {
app.Logger().Warn("failed to update plugin instance status", "instance", instance.Id, "error", saveErr)
}
}
func applyHostPolicy(item *pluginsystem.TrailImport, config map[string]any) {
privacyMode, ok := config["privacy"].(string)
if !ok || privacyMode == "" {
privacyMode = "original"
}
if privacyMode != "original" {
item.Privacy = nil
}
}
func autoMergeEnabled(config map[string]any) bool {
merge, ok := config["merge"].(map[string]any)
return ok && boolOption(merge, "available", true) && boolOption(merge, "enabled", false)
}
func boolOption(config map[string]any, key string, fallback bool) bool {
value, ok := config[key].(bool)
if !ok {
return fallback
}
return value
}
func categoryMapping(config map[string]any) map[string]string {
raw, ok := config["categoryMapping"].(map[string]any)
if !ok {
return nil
}
result := make(map[string]string, len(raw))
for key, value := range raw {
category, ok := value.(string)
if ok {
result[key] = category
}
}
return result
}
func hasUsableCategoryMapping(mapping map[string]string) bool {
for _, category := range mapping {
if strings.TrimSpace(category) != "" {
return true
}
}
return false
}
func userDefaultPublic(app core.App, userID string) bool {
settings, err := app.FindFirstRecordByData("settings", "user", userID)
if err != nil || settings == nil {
return false
}
privacySettings := struct {
Trails string `json:"trails"`
}{}
if err := settings.UnmarshalJSONField("privacy", &privacySettings); err != nil {
return false
}
return privacySettings.Trails == "public"
}

View File

@@ -0,0 +1,35 @@
package routes
import "testing"
func TestCategoryMappingPreservesExplicitEmptyMap(t *testing.T) {
mapping := categoryMapping(map[string]any{
"categoryMapping": map[string]any{},
})
if mapping == nil {
t.Fatal("expected explicit empty category mapping to be preserved")
}
if len(mapping) != 0 {
t.Fatalf("expected empty category mapping, got %#v", mapping)
}
}
func TestCategoryMappingNilWhenMissing(t *testing.T) {
if mapping := categoryMapping(map[string]any{}); mapping != nil {
t.Fatalf("expected missing category mapping to be nil, got %#v", mapping)
}
}
func TestCategoryMappingPreservesBlankProviderMapping(t *testing.T) {
mapping := categoryMapping(map[string]any{
"categoryMapping": map[string]any{
"Ride": "",
},
})
if mapping == nil {
t.Fatal("expected category mapping")
}
if value, ok := mapping["Ride"]; !ok || value != "" {
t.Fatalf("expected blank provider mapping to be preserved, got %#v", mapping)
}
}