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:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
72
db/routes/plugin_system.go
Normal file
72
db/routes/plugin_system.go
Normal 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
|
||||
}
|
||||
222
db/routes/plugin_system_auth.go
Normal file
222
db/routes/plugin_system_auth.go
Normal 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},
|
||||
)
|
||||
}
|
||||
242
db/routes/plugin_system_category_remap.go
Normal file
242
db/routes/plugin_system_category_remap.go
Normal 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
|
||||
}
|
||||
42
db/routes/plugin_system_config.go
Normal file
42
db/routes/plugin_system_config.go
Normal 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")
|
||||
}
|
||||
147
db/routes/plugin_system_policy.go
Normal file
147
db/routes/plugin_system_policy.go
Normal 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
|
||||
}
|
||||
55
db/routes/plugin_system_policy_test.go
Normal file
55
db/routes/plugin_system_policy_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
223
db/routes/plugin_system_send.go
Normal file
223
db/routes/plugin_system_send.go
Normal 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
|
||||
}
|
||||
119
db/routes/plugin_system_session_auth.go
Normal file
119
db/routes/plugin_system_session_auth.go
Normal 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)
|
||||
}
|
||||
619
db/routes/plugin_system_sync.go
Normal file
619
db/routes/plugin_system_sync.go
Normal 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"
|
||||
}
|
||||
35
db/routes/plugin_system_sync_test.go
Normal file
35
db/routes/plugin_system_sync_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user