* 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>
224 lines
6.6 KiB
Go
224 lines
6.6 KiB
Go
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
|
|
}
|