Files
wanderer/db/migrations/1780000002_plugin_instances.go
slothful-vassal 485ec53f6d 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>
2026-06-22 15:00:44 +02:00

537 lines
14 KiB
Go

package migrations
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/types"
)
func init() {
m.Register(func(app core.App) error {
// Create plugin_instances collection
jsonData := `{
"createRule": "@request.auth.id = user.id",
"deleteRule": "@request.auth.id = user.id",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text430001001",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": true,
"collectionId": "_pb_users_auth_",
"hidden": false,
"id": "relation430001002",
"maxSelect": 1,
"minSelect": 0,
"name": "user",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text430001003",
"max": 64,
"min": 1,
"name": "plugin_id",
"pattern": "^[a-z0-9][a-z0-9_-]*$",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "bool430001004",
"name": "enabled",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
},
{
"hidden": false,
"id": "json430001005",
"maxSize": 2000000,
"name": "auth",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json430001006",
"maxSize": 2000000,
"name": "config",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json430001007",
"maxSize": 2000000,
"name": "state",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "select430001008",
"maxSelect": 1,
"name": "status",
"presentable": false,
"required": true,
"system": false,
"type": "select",
"values": [
"configured",
"needs_auth",
"needs_reauth",
"syncing",
"rate_limited",
"unavailable",
"unsupported_protocol",
"error",
"disabled"
]
},
{
"hidden": false,
"id": "json430001009",
"maxSize": 2000000,
"name": "last_error",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "date430001010",
"max": "",
"min": "",
"name": "last_sync_at",
"presentable": false,
"required": false,
"system": false,
"type": "date"
},
{
"hidden": false,
"id": "date430001011",
"max": "",
"min": "",
"name": "retry_not_before",
"presentable": false,
"required": false,
"system": false,
"type": "date"
},
{
"hidden": false,
"id": "autodate430001012",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate430001013",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"id": "pbc_430001000",
"indexes": [
"CREATE UNIQUE INDEX ` + "`" + `idx_plugin_instances_user_plugin_id` + "`" + ` ON ` + "`" + `plugin_instances` + "`" + ` (` + "`" + `user` + "`" + `, ` + "`" + `plugin_id` + "`" + `)"
],
"listRule": "@request.auth.id = user.id",
"name": "plugin_instances",
"system": false,
"type": "base",
"updateRule": "@request.auth.id = user.id",
"viewRule": "@request.auth.id = user.id"
}`
if _, err := app.FindCollectionByNameOrId("pbc_430001000"); err != nil {
collection := &core.Collection{}
if err := json.Unmarshal([]byte(jsonData), collection); err != nil {
return err
}
if err := app.Save(collection); err != nil {
return err
}
}
if err := migrateLegacyIntegrationsToPluginInstances(app); err != nil {
return err
}
// Remove the previous hard-coded provider settings collection after
// migrating its configuration into plugin_instances. The migration is
// data-only and does not require the corresponding plugin bundles to be
// installed.
if legacyCollection, err := app.FindCollectionByNameOrId("integrations"); err == nil {
if err := app.Delete(legacyCollection); err != nil {
return err
}
}
// Add user field to trail_external_reference and update index to be user-scoped
refCollection, err := app.FindCollectionByNameOrId("trail_external_reference")
if err != nil {
return err
}
if refCollection.Fields.GetByName("user") == nil {
if err := refCollection.Fields.AddMarshaledJSONAt(2, []byte(`{
"cascadeDelete": true,
"collectionId": "_pb_users_auth_",
"hidden": false,
"id": "relation430002001",
"maxSelect": 1,
"minSelect": 0,
"name": "user",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
}`)); err != nil {
return err
}
// Replace the global unique (provider, external_id) index with a
// user-scoped one so the same external trail can be imported by
// multiple users. This must be managed via the collection metadata
// (not a raw DROP INDEX), otherwise app.Save would recreate the old
// index from the still-present metadata entry.
keptIndexes := refCollection.Indexes[:0]
for _, idx := range refCollection.Indexes {
if strings.Contains(idx, "idx_trail_external_reference_provider_external_id") {
continue
}
keptIndexes = append(keptIndexes, idx)
}
refCollection.Indexes = append(keptIndexes,
"CREATE UNIQUE INDEX `idx_trail_external_reference_user_provider_external_id` ON `trail_external_reference` (`user`, `provider`, `external_id`)",
)
if err := app.Save(refCollection); err != nil {
return err
}
refs, err := app.FindAllRecords("trail_external_reference")
if err != nil {
return err
}
for _, ref := range refs {
trailID := ref.GetString("trail")
if trailID == "" {
continue
}
trail, err := app.FindRecordById("trails", trailID)
if err != nil {
continue
}
actor, err := app.FindRecordById("activitypub_actors", trail.GetString("author"))
if err != nil {
continue
}
userID := actor.GetString("user")
if userID == "" {
continue
}
ref.Set("user", userID)
if err := app.Save(ref); err != nil {
return err
}
}
}
return nil
}, nil)
}
func migrateLegacyIntegrationsToPluginInstances(app core.App) error {
if _, err := app.FindCollectionByNameOrId("integrations"); err != nil {
return nil
}
records, err := app.FindAllRecords("integrations")
if err != nil {
return err
}
for _, record := range records {
userID := record.GetString("user")
if userID == "" {
continue
}
if raw := legacyJSONObject(record.GetString("strava")); legacyHasValue(raw["clientId"]) {
auth := legacyPick(raw, "clientId", "clientSecret", "accessToken", "refreshToken", "expiresAt", "tokenType", "scope")
legacyNormalizeStravaAuth(auth)
hostConfig := legacyPick(raw, "privacy", "merge")
hostConfig["planned"] = legacyBool(raw["routes"])
hostConfig["completed"] = legacyBool(raw["activities"])
config := legacyNamespacedPluginConfig(
legacyPick(raw, "after"),
hostConfig,
)
if err := saveLegacyMappedPluginInstance(app, userID, "strava", auth, config, raw); err != nil {
return err
}
}
if raw := legacyJSONObject(record.GetString("komoot")); legacyHasValue(raw["email"]) {
auth := legacyPick(raw, "email", "password")
config := legacyNamespacedPluginConfig(
legacyPick(raw, "after"),
legacyPick(raw, "planned", "completed", "privacy", "merge"),
)
if err := saveLegacyMappedPluginInstance(app, userID, "komoot", auth, config, raw); err != nil {
return err
}
}
if raw := legacyJSONObject(record.GetString("hammerhead")); legacyHasValue(raw["email"]) {
auth := legacyPick(raw, "email", "password")
config := legacyNamespacedPluginConfig(
legacyPick(raw, "after"),
legacyPick(raw, "planned", "completed", "privacy", "merge"),
)
if err := saveLegacyMappedPluginInstance(app, userID, "hammerhead", auth, config, raw); err != nil {
return err
}
}
}
return nil
}
func legacyNamespacedPluginConfig(pluginConfig map[string]any, hostConfig map[string]any) map[string]any {
return map[string]any{
"plugin": nilMap(pluginConfig),
"host": nilMap(hostConfig),
}
}
func saveLegacyMappedPluginInstance(app core.App, userID string, pluginID string, auth map[string]any, config map[string]any, raw map[string]any) error {
enabled := legacyBool(raw["active"]) && legacyPluginAuthComplete(pluginID, auth)
return saveLegacyPluginInstance(app, legacyPluginInstance{
UserID: userID,
PluginID: pluginID,
Enabled: enabled,
Auth: auth,
Config: config,
State: map[string]any{},
Status: legacyPluginInstanceStatus(pluginID, auth, enabled, ""),
LastError: map[string]any{},
})
}
type legacyPluginInstance struct {
UserID string
PluginID string
Enabled bool
Auth map[string]any
Config map[string]any
State map[string]any
Status string
LastError map[string]any
LastSyncAt string
RetryNotBefore string
}
func saveLegacyPluginInstance(app core.App, instance legacyPluginInstance) error {
if instance.UserID == "" || instance.PluginID == "" {
return nil
}
existing, _ := app.FindFirstRecordByFilter(
"plugin_instances",
"user={:user} && plugin_id={:plugin_id}",
dbx.Params{"user": instance.UserID, "plugin_id": instance.PluginID},
)
if existing != nil {
return nil
}
authJSON, err := json.Marshal(nilMap(instance.Auth))
if err != nil {
return err
}
configJSON, err := json.Marshal(nilMap(instance.Config))
if err != nil {
return err
}
stateJSON, err := json.Marshal(nilMap(instance.State))
if err != nil {
return err
}
lastErrorJSON, err := json.Marshal(nilMap(instance.LastError))
if err != nil {
return err
}
status := instance.Status
if status == "" {
status = legacyPluginInstanceStatus(instance.PluginID, instance.Auth, instance.Enabled, "")
}
now := types.NowDateTime().String()
_, err = app.DB().Insert("plugin_instances", dbx.Params{
"id": security.RandomStringWithAlphabet(15, "abcdefghijklmnopqrstuvwxyz0123456789"),
"user": instance.UserID,
"plugin_id": instance.PluginID,
"enabled": instance.Enabled,
"auth": string(authJSON),
"config": string(configJSON),
"state": string(stateJSON),
"status": status,
"last_error": string(lastErrorJSON),
"last_sync_at": instance.LastSyncAt,
"retry_not_before": instance.RetryNotBefore,
"created": now,
"updated": now,
}).Execute()
return err
}
func legacyPluginInstanceStatus(pluginID string, auth map[string]any, enabled bool, previous string) string {
if !legacyPluginAuthComplete(pluginID, auth) {
return "needs_auth"
}
if !enabled {
return "disabled"
}
switch previous {
case "configured", "needs_reauth", "syncing", "rate_limited", "unavailable", "unsupported_protocol", "error":
return previous
default:
return "configured"
}
}
func legacyPluginAuthComplete(pluginID string, auth map[string]any) bool {
switch pluginID {
case "strava":
return legacyHasValue(auth["clientId"]) && legacyHasValue(auth["clientSecret"]) && legacyHasValue(auth["refreshToken"])
case "komoot", "hammerhead":
return legacyHasValue(auth["email"]) && legacyHasValue(auth["password"])
default:
return false
}
}
func legacyNormalizeStravaAuth(auth map[string]any) {
legacyStringAuthFields(auth, "clientId", "clientSecret", "accessToken", "refreshToken", "tokenType", "scope")
switch value := auth["expiresAt"].(type) {
case float64:
if value > 0 {
auth["expiresAt"] = time.Unix(int64(value), 0).UTC().Format(time.RFC3339)
}
case int64:
if value > 0 {
auth["expiresAt"] = time.Unix(value, 0).UTC().Format(time.RFC3339)
}
case int:
if value > 0 {
auth["expiresAt"] = time.Unix(int64(value), 0).UTC().Format(time.RFC3339)
}
}
}
func legacyStringAuthFields(auth map[string]any, keys ...string) {
for _, key := range keys {
switch value := auth[key].(type) {
case string:
// already normalized
case float64:
auth[key] = strconv.FormatFloat(value, 'f', -1, 64)
case int64:
auth[key] = strconv.FormatInt(value, 10)
case int:
auth[key] = strconv.Itoa(value)
case nil:
// leave absent/null values untouched so completeness checks still fail
default:
auth[key] = fmt.Sprint(value)
}
}
}
func legacyJSONObject(raw string) map[string]any {
if raw == "" {
return map[string]any{}
}
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil || data == nil {
return map[string]any{}
}
return data
}
func legacyPick(src map[string]any, keys ...string) map[string]any {
out := map[string]any{}
for _, key := range keys {
if value, ok := src[key]; ok && value != nil {
out[key] = value
}
}
return out
}
func legacyHasValue(value any) bool {
switch v := value.(type) {
case nil:
return false
case string:
return strings.TrimSpace(v) != ""
default:
return true
}
}
func legacyBool(value any) bool {
b, _ := value.(bool)
return b
}
func nilMap(value map[string]any) map[string]any {
if value == nil {
return map[string]any{}
}
return value
}