From 485ec53f6d16c10a3cc63b56eb11a92eb01354d8 Mon Sep 17 00:00:00 2001 From: slothful-vassal <89943360+slothful-vassal@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:00:44 +0200 Subject: [PATCH] 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 * 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] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Flomp * 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] Co-authored-by: Flomp 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> --- .github/workflows/go.yml | 13 + .github/workflows/release.yaml | 21 +- .gitignore | 6 +- Makefile | 32 + db/.dockerignore | 2 + db/go.mod | 9 + db/go.sum | 18 + db/hooks/integrations.go | 146 --- db/hooks/plugin_instances.go | 271 +++++ db/integrations/hammerhead/hammerhead.go | 927 ------------------ db/integrations/hammerhead/models.go | 209 ---- db/integrations/komoot/komoot.go | 510 ---------- db/integrations/komoot/models.go | 399 -------- db/integrations/strava/models.go | 391 -------- db/integrations/strava/strava.go | 761 -------------- db/main.go | 95 +- db/migrations/1780000002_plugin_instances.go | 536 ++++++++++ db/migrations/1780000004_plugin_system.go | 198 ++++ .../1780000005_add_other_category.go | 46 + ..._trail_external_reference_provider_text.go | 262 +++++ db/migrations/initial_data/other.jpg | Bin 0 -> 442890 bytes db/plugins/importer/importer.go | 912 +++++++++++++++++ db/plugins/importer/importer_test.go | 432 ++++++++ db/pluginsystem/auth_fields.go | 38 + db/pluginsystem/auth_injection.go | 349 +++++++ db/pluginsystem/auth_injection_test.go | 288 ++++++ db/pluginsystem/host_http.go | 410 ++++++++ db/pluginsystem/host_http_test.go | 407 ++++++++ db/pluginsystem/import_types.go | 75 ++ db/pluginsystem/installed.go | 98 ++ db/pluginsystem/json.go | 70 ++ db/pluginsystem/json_test.go | 81 ++ db/pluginsystem/manager.go | 419 ++++++++ db/pluginsystem/manager_test.go | 21 + db/pluginsystem/manifest.go | 305 ++++++ db/pluginsystem/manifest_test.go | 222 +++++ db/pluginsystem/oauth.go | 335 +++++++ db/pluginsystem/policy.go | 417 ++++++++ db/pluginsystem/policy_test.go | 169 ++++ db/pluginsystem/protocol.go | 212 ++++ db/pluginsystem/runtime.go | 64 ++ db/pluginsystem/status.go | 89 ++ db/pluginsystem/status_test.go | 59 ++ db/pluginsystem/worker.go | 507 ++++++++++ db/pluginsystem/worker_process.go | 285 ++++++ db/pluginsystem/worker_rpc.go | 149 +++ db/pluginsystem/worker_test.go | 369 +++++++ db/routes/integration_hammerhead.go | 81 -- db/routes/integration_komoot.go | 58 -- db/routes/integration_strava.go | 87 -- db/routes/plugin_system.go | 72 ++ db/routes/plugin_system_auth.go | 222 +++++ db/routes/plugin_system_category_remap.go | 242 +++++ db/routes/plugin_system_config.go | 42 + db/routes/plugin_system_policy.go | 147 +++ db/routes/plugin_system_policy_test.go | 55 ++ db/routes/plugin_system_send.go | 223 +++++ db/routes/plugin_system_session_auth.go | 119 +++ db/routes/plugin_system_sync.go | 619 ++++++++++++ db/routes/plugin_system_sync_test.go | 35 + .../{integration_merge.go => plugin_merge.go} | 4 +- db/services/trailmerge/service.go | 8 +- db/util/network_test.go | 68 ++ db/util/safe_fetch.go | 226 +++++ db/util/trail_access.go | 45 + db/util/trail_external_reference.go | 141 ++- docker-compose.yml | 1 + docs/astro.config.mjs | 9 +- .../src/content/docs/develop/plugin-system.md | 923 +++++++++++++++++ .../docs/run/environment-configuration.md | 29 +- .../content/docs/run/installation/plugins.md | 59 ++ docs/src/content/docs/use/integrations.md | 59 -- docs/src/content/docs/use/merge-trails.md | 8 +- docs/src/content/docs/use/plugins.md | 83 ++ plugins/README.md | 518 ++++++++++ plugins/hammerhead/Makefile | 16 + plugins/hammerhead/README.md | 29 + plugins/hammerhead/assets/icon.svg | 15 + plugins/hammerhead/assets/icon_dark.svg | 15 + plugins/hammerhead/go.mod | 9 + plugins/hammerhead/go.sum | 2 + plugins/hammerhead/gpx.go | 66 ++ plugins/hammerhead/hammerhead.go | 163 +++ plugins/hammerhead/hammerhead_test.go | 97 ++ plugins/hammerhead/jwt.go | 28 + plugins/hammerhead/main.go | 350 +++++++ plugins/hammerhead/plugin.json | 131 +++ plugins/hammerhead/send.go | 24 + plugins/hammerhead/types.go | 106 ++ plugins/komoot/Makefile | 15 + plugins/komoot/README.md | 11 + plugins/komoot/assets/icon.svg | 197 ++++ plugins/komoot/auth.go | 24 + plugins/komoot/go.mod | 9 + plugins/komoot/go.sum | 2 + plugins/komoot/komoot.go | 301 ++++++ plugins/komoot/komoot_test.go | 53 + plugins/komoot/locale.go | 19 + plugins/komoot/main.go | 104 ++ plugins/komoot/main_stub.go | 5 + plugins/komoot/mapper.go | 226 +++++ plugins/komoot/mapper_test.go | 134 +++ plugins/komoot/options.go | 27 + plugins/komoot/plugin.json | 310 ++++++ plugins/komoot/types.go | 197 ++++ plugins/schema/plugin.schema.json | 481 +++++++++ plugins/sdk/README.md | 63 ++ plugins/sdk/cmd/manifestcheck/main.go | 19 + plugins/sdk/go.mod | 5 + plugins/sdk/go.sum | 2 + plugins/sdk/gpx/gpx.go | 58 ++ plugins/sdk/gpx/gpx_test.go | 39 + plugins/sdk/host_http.go | 154 +++ plugins/sdk/manifestcheck/manifestcheck.go | 26 + plugins/sdk/polyline/polyline.go | 125 +++ plugins/sdk/polyline/polyline_test.go | 40 + plugins/sdk/sync.go | 83 ++ plugins/sdk/types.go | 209 ++++ plugins/strava/Makefile | 15 + plugins/strava/README.md | 11 + plugins/strava/assets/icon.svg | 3 + plugins/strava/go.mod | 9 + plugins/strava/go.sum | 2 + plugins/strava/main.go | 126 +++ plugins/strava/mapper.go | 228 +++++ plugins/strava/options.go | 40 + plugins/strava/plugin.json | 541 ++++++++++ plugins/strava/strava.go | 199 ++++ plugins/strava/types.go | 95 ++ web/src/app.html | 20 +- web/src/css/components.css | 2 +- web/src/lib/components/base/select.svelte | 12 + .../lib/components/base/single_select.svelte | 257 +++++ web/src/lib/components/base/text_field.svelte | 2 +- web/src/lib/components/confirm_modal.svelte | 20 +- .../hammerhead_settings_modal.svelte | 128 --- .../integrations/integration_card.svelte | 39 - .../integrations/komoot_settings_modal.svelte | 130 --- .../integrations/strava_settings_modal.svelte | 154 --- .../settings/plugins/plugin_card.svelte | 111 +++ .../plugin_instance_settings_modal.svelte | 753 ++++++++++++++ .../plugin_merge_settings.svelte} | 8 +- .../components/trail/trail_dropdown.svelte | 132 +-- .../components/trail/trail_send_modal.svelte | 153 ++- web/src/lib/i18n/locales/cs.json | 26 +- web/src/lib/i18n/locales/de.json | 77 +- web/src/lib/i18n/locales/en.json | 73 +- web/src/lib/i18n/locales/es.json | 26 +- web/src/lib/i18n/locales/eu.json | 26 +- web/src/lib/i18n/locales/fr.json | 26 +- web/src/lib/i18n/locales/hu.json | 26 +- web/src/lib/i18n/locales/it.json | 26 +- web/src/lib/i18n/locales/nl.json | 26 +- web/src/lib/i18n/locales/no.json | 28 +- web/src/lib/i18n/locales/pl.json | 26 +- web/src/lib/i18n/locales/pt.json | 26 +- web/src/lib/i18n/locales/ru.json | 26 +- web/src/lib/i18n/locales/zh.json | 26 +- web/src/lib/models/api/integration_schema.ts | 53 - web/src/lib/models/api/openapi_schemas.ts | 100 -- .../lib/models/api/plugin_instance_schema.ts | 59 ++ web/src/lib/models/integration.ts | 55 -- web/src/lib/models/plugin_instance.ts | 25 + web/src/lib/models/plugin_provider.ts | 57 ++ web/src/lib/models/plugin_system.ts | 45 + web/src/lib/models/trail.ts | 1 - web/src/lib/stores/category_store.ts | 4 +- web/src/lib/stores/integration_store.ts | 90 -- web/src/lib/stores/plugin_instance_store.ts | 183 ++++ web/src/lib/stores/plugin_store.ts | 125 +++ web/src/lib/stores/theme_store.ts | 33 +- web/src/lib/util/api_util.ts | 2 +- web/src/lib/util/plugin_error_i18n.ts | 112 +++ web/src/lib/util/plugin_i18n.ts | 86 ++ web/src/routes/+layout.svelte | 4 + web/src/routes/api/v1/integration/+server.ts | 88 -- .../routes/api/v1/integration/[id]/+server.ts | 114 --- .../integration/hammerhead/login/+server.ts | 31 - .../v1/integration/komoot/login/+server.ts | 31 - .../routes/api/v1/plugin-instance/+server.ts | 111 +++ .../api/v1/plugin-instance/[id]/+server.ts | 138 +++ .../v1/plugin-system/auth/validate/+server.ts | 63 ++ .../category-remap/apply/+server.ts | 48 + .../category-remap/preview/+server.ts | 48 + .../plugin-system/oauth/callback/+server.ts | 58 ++ .../oauth/revoke}/+server.ts | 40 +- .../v1/plugin-system/oauth/start/+server.ts | 66 ++ .../api/v1/plugin-system/plugins/+server.ts | 60 ++ .../v1/plugin-system/trail-send/+server.ts | 62 ++ web/src/routes/settings/+layout.svelte | 2 +- .../routes/settings/integrations/+page.svelte | 278 ------ web/src/routes/settings/integrations/+page.ts | 7 - .../callback/strava/+page.server.ts | 41 - web/src/routes/settings/plugins/+page.svelte | 561 +++++++++++ web/src/routes/settings/plugins/+page.ts | 13 + .../plugins/oauth/callback/+page.svelte | 47 + 196 files changed, 20947 insertions(+), 5454 deletions(-) delete mode 100644 db/hooks/integrations.go create mode 100644 db/hooks/plugin_instances.go delete mode 100644 db/integrations/hammerhead/hammerhead.go delete mode 100644 db/integrations/hammerhead/models.go delete mode 100644 db/integrations/komoot/komoot.go delete mode 100644 db/integrations/komoot/models.go delete mode 100644 db/integrations/strava/models.go delete mode 100644 db/integrations/strava/strava.go create mode 100644 db/migrations/1780000002_plugin_instances.go create mode 100644 db/migrations/1780000004_plugin_system.go create mode 100644 db/migrations/1780000005_add_other_category.go create mode 100644 db/migrations/1780000006_trail_external_reference_provider_text.go create mode 100644 db/migrations/initial_data/other.jpg create mode 100644 db/plugins/importer/importer.go create mode 100644 db/plugins/importer/importer_test.go create mode 100644 db/pluginsystem/auth_fields.go create mode 100644 db/pluginsystem/auth_injection.go create mode 100644 db/pluginsystem/auth_injection_test.go create mode 100644 db/pluginsystem/host_http.go create mode 100644 db/pluginsystem/host_http_test.go create mode 100644 db/pluginsystem/import_types.go create mode 100644 db/pluginsystem/installed.go create mode 100644 db/pluginsystem/json.go create mode 100644 db/pluginsystem/json_test.go create mode 100644 db/pluginsystem/manager.go create mode 100644 db/pluginsystem/manager_test.go create mode 100644 db/pluginsystem/manifest.go create mode 100644 db/pluginsystem/manifest_test.go create mode 100644 db/pluginsystem/oauth.go create mode 100644 db/pluginsystem/policy.go create mode 100644 db/pluginsystem/policy_test.go create mode 100644 db/pluginsystem/protocol.go create mode 100644 db/pluginsystem/runtime.go create mode 100644 db/pluginsystem/status.go create mode 100644 db/pluginsystem/status_test.go create mode 100644 db/pluginsystem/worker.go create mode 100644 db/pluginsystem/worker_process.go create mode 100644 db/pluginsystem/worker_rpc.go create mode 100644 db/pluginsystem/worker_test.go delete mode 100644 db/routes/integration_hammerhead.go delete mode 100644 db/routes/integration_komoot.go delete mode 100644 db/routes/integration_strava.go create mode 100644 db/routes/plugin_system.go create mode 100644 db/routes/plugin_system_auth.go create mode 100644 db/routes/plugin_system_category_remap.go create mode 100644 db/routes/plugin_system_config.go create mode 100644 db/routes/plugin_system_policy.go create mode 100644 db/routes/plugin_system_policy_test.go create mode 100644 db/routes/plugin_system_send.go create mode 100644 db/routes/plugin_system_session_auth.go create mode 100644 db/routes/plugin_system_sync.go create mode 100644 db/routes/plugin_system_sync_test.go rename db/services/trailmerge/{integration_merge.go => plugin_merge.go} (91%) create mode 100644 db/util/network_test.go create mode 100644 db/util/safe_fetch.go create mode 100644 db/util/trail_access.go create mode 100644 docs/src/content/docs/develop/plugin-system.md create mode 100644 docs/src/content/docs/run/installation/plugins.md delete mode 100644 docs/src/content/docs/use/integrations.md create mode 100644 docs/src/content/docs/use/plugins.md create mode 100644 plugins/README.md create mode 100644 plugins/hammerhead/Makefile create mode 100644 plugins/hammerhead/README.md create mode 100644 plugins/hammerhead/assets/icon.svg create mode 100644 plugins/hammerhead/assets/icon_dark.svg create mode 100644 plugins/hammerhead/go.mod create mode 100644 plugins/hammerhead/go.sum create mode 100644 plugins/hammerhead/gpx.go create mode 100644 plugins/hammerhead/hammerhead.go create mode 100644 plugins/hammerhead/hammerhead_test.go create mode 100644 plugins/hammerhead/jwt.go create mode 100644 plugins/hammerhead/main.go create mode 100644 plugins/hammerhead/plugin.json create mode 100644 plugins/hammerhead/send.go create mode 100644 plugins/hammerhead/types.go create mode 100644 plugins/komoot/Makefile create mode 100644 plugins/komoot/README.md create mode 100644 plugins/komoot/assets/icon.svg create mode 100644 plugins/komoot/auth.go create mode 100644 plugins/komoot/go.mod create mode 100644 plugins/komoot/go.sum create mode 100644 plugins/komoot/komoot.go create mode 100644 plugins/komoot/komoot_test.go create mode 100644 plugins/komoot/locale.go create mode 100644 plugins/komoot/main.go create mode 100644 plugins/komoot/main_stub.go create mode 100644 plugins/komoot/mapper.go create mode 100644 plugins/komoot/mapper_test.go create mode 100644 plugins/komoot/options.go create mode 100644 plugins/komoot/plugin.json create mode 100644 plugins/komoot/types.go create mode 100644 plugins/schema/plugin.schema.json create mode 100644 plugins/sdk/README.md create mode 100644 plugins/sdk/cmd/manifestcheck/main.go create mode 100644 plugins/sdk/go.mod create mode 100644 plugins/sdk/go.sum create mode 100644 plugins/sdk/gpx/gpx.go create mode 100644 plugins/sdk/gpx/gpx_test.go create mode 100644 plugins/sdk/host_http.go create mode 100644 plugins/sdk/manifestcheck/manifestcheck.go create mode 100644 plugins/sdk/polyline/polyline.go create mode 100644 plugins/sdk/polyline/polyline_test.go create mode 100644 plugins/sdk/sync.go create mode 100644 plugins/sdk/types.go create mode 100644 plugins/strava/Makefile create mode 100644 plugins/strava/README.md create mode 100644 plugins/strava/assets/icon.svg create mode 100644 plugins/strava/go.mod create mode 100644 plugins/strava/go.sum create mode 100644 plugins/strava/main.go create mode 100644 plugins/strava/mapper.go create mode 100644 plugins/strava/options.go create mode 100644 plugins/strava/plugin.json create mode 100644 plugins/strava/strava.go create mode 100644 plugins/strava/types.go create mode 100644 web/src/lib/components/base/single_select.svelte delete mode 100644 web/src/lib/components/settings/integrations/hammerhead_settings_modal.svelte delete mode 100644 web/src/lib/components/settings/integrations/integration_card.svelte delete mode 100644 web/src/lib/components/settings/integrations/komoot_settings_modal.svelte delete mode 100644 web/src/lib/components/settings/integrations/strava_settings_modal.svelte create mode 100644 web/src/lib/components/settings/plugins/plugin_card.svelte create mode 100644 web/src/lib/components/settings/plugins/plugin_instance_settings_modal.svelte rename web/src/lib/components/settings/{integrations/integration_merge_settings.svelte => plugins/plugin_merge_settings.svelte} (64%) delete mode 100644 web/src/lib/models/api/integration_schema.ts create mode 100644 web/src/lib/models/api/plugin_instance_schema.ts delete mode 100644 web/src/lib/models/integration.ts create mode 100644 web/src/lib/models/plugin_instance.ts create mode 100644 web/src/lib/models/plugin_provider.ts create mode 100644 web/src/lib/models/plugin_system.ts delete mode 100644 web/src/lib/stores/integration_store.ts create mode 100644 web/src/lib/stores/plugin_instance_store.ts create mode 100644 web/src/lib/stores/plugin_store.ts create mode 100644 web/src/lib/util/plugin_error_i18n.ts create mode 100644 web/src/lib/util/plugin_i18n.ts delete mode 100644 web/src/routes/api/v1/integration/+server.ts delete mode 100644 web/src/routes/api/v1/integration/[id]/+server.ts delete mode 100644 web/src/routes/api/v1/integration/hammerhead/login/+server.ts delete mode 100644 web/src/routes/api/v1/integration/komoot/login/+server.ts create mode 100644 web/src/routes/api/v1/plugin-instance/+server.ts create mode 100644 web/src/routes/api/v1/plugin-instance/[id]/+server.ts create mode 100644 web/src/routes/api/v1/plugin-system/auth/validate/+server.ts create mode 100644 web/src/routes/api/v1/plugin-system/category-remap/apply/+server.ts create mode 100644 web/src/routes/api/v1/plugin-system/category-remap/preview/+server.ts create mode 100644 web/src/routes/api/v1/plugin-system/oauth/callback/+server.ts rename web/src/routes/api/v1/{integration/hammerhead/upload => plugin-system/oauth/revoke}/+server.ts (51%) create mode 100644 web/src/routes/api/v1/plugin-system/oauth/start/+server.ts create mode 100644 web/src/routes/api/v1/plugin-system/plugins/+server.ts create mode 100644 web/src/routes/api/v1/plugin-system/trail-send/+server.ts delete mode 100644 web/src/routes/settings/integrations/+page.svelte delete mode 100644 web/src/routes/settings/integrations/+page.ts delete mode 100644 web/src/routes/settings/integrations/callback/strava/+page.server.ts create mode 100644 web/src/routes/settings/plugins/+page.svelte create mode 100644 web/src/routes/settings/plugins/+page.ts create mode 100644 web/src/routes/settings/plugins/oauth/callback/+page.svelte diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index b27543f2..ab5c9ed9 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -6,19 +6,30 @@ on: paths: - '.github/**' - 'db/**' + - 'plugins/**' + - 'Makefile' pull_request: paths: - '.github/**' - 'db/**' + - 'plugins/**' + - 'Makefile' jobs: db-test: runs-on: ubuntu-latest + env: + TINYGO_VERSION: '0.39.0' steps: - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: go-version: '1.25' + - name: Install TinyGo + run: | + curl -fsSL "https://github.com/tinygo-org/tinygo/releases/download/v${TINYGO_VERSION}/tinygo_${TINYGO_VERSION}_amd64.deb" -o /tmp/tinygo.deb + sudo dpkg -i /tmp/tinygo.deb + tinygo version - run: make db-fmt - name: Ensure formatting @@ -31,3 +42,5 @@ jobs: working-directory: db - run: make db-vet - run: make db-test + - run: make plugins-test + - run: make plugins-build diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index a519e147..d93f171f 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -41,7 +41,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v6 with: - go-version: '1.22' + go-version: '1.25' - name: Setup Node.js uses: actions/setup-node@v6 @@ -74,8 +74,24 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + env: + TINYGO_VERSION: '0.39.0' steps: - uses: actions/checkout@v6 + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version: '1.25' + + - name: Install TinyGo + run: | + curl -fsSL "https://github.com/tinygo-org/tinygo/releases/download/v${TINYGO_VERSION}/tinygo_${TINYGO_VERSION}_amd64.deb" -o /tmp/tinygo.deb + sudo dpkg -i /tmp/tinygo.deb + tinygo version + + - name: Build Plugin Release Assets + run: make plugins-package - name: Extract release notes id: changelog @@ -93,5 +109,8 @@ jobs: with: tag_name: ${{ needs.publish.outputs.version }} body: ${{ steps.changelog.outputs.changelog }} + files: | + plugin_dist/*.tar.gz + plugin_dist/SHA256SUMS draft: false prerelease: false diff --git a/.gitignore b/.gitignore index d6962a7a..a348c2f0 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,10 @@ build*.sh start*.* data*/ + +plugins/*/dist/ +plugin_dist/ + .planning/ .claude/ -CLAUDE.md \ No newline at end of file +CLAUDE.md diff --git a/Makefile b/Makefile index 4763583b..981719ae 100644 --- a/Makefile +++ b/Makefile @@ -41,3 +41,35 @@ web-test: .PHONY: web-build-docker web-build-docker: docker buildx build web/ --no-cache -t flomp/wanderer-web:latest + +## Plugins + +.PHONY: plugins-test +plugins-test: + cd plugins/sdk && go test ./... + cd plugins/hammerhead && go test ./... + cd plugins/komoot && go test ./... + cd plugins/strava && go test ./... + +.PHONY: plugins-build +plugins-build: + cd plugins/hammerhead && XDG_CACHE_HOME=$${XDG_CACHE_HOME:-/tmp/wanderer-tinygo-cache} make build + cd plugins/komoot && XDG_CACHE_HOME=$${XDG_CACHE_HOME:-/tmp/wanderer-tinygo-cache} make build + cd plugins/strava && XDG_CACHE_HOME=$${XDG_CACHE_HOME:-/tmp/wanderer-tinygo-cache} make build + +.PHONY: plugins-install-local +plugins-install-local: plugins-build + mkdir -p data/plugins + rm -rf data/plugins/hammerhead data/plugins/komoot data/plugins/strava + cp -a plugins/hammerhead/dist/hammerhead data/plugins/ + cp -a plugins/komoot/dist/komoot data/plugins/ + cp -a plugins/strava/dist/strava data/plugins/ + +.PHONY: plugins-package +plugins-package: plugins-build + rm -rf plugin_dist + mkdir -p plugin_dist + tar -C plugins/hammerhead/dist -czf plugin_dist/wanderer-plugin-hammerhead.tar.gz hammerhead + tar -C plugins/komoot/dist -czf plugin_dist/wanderer-plugin-komoot.tar.gz komoot + tar -C plugins/strava/dist -czf plugin_dist/wanderer-plugin-strava.tar.gz strava + cd plugin_dist && sha256sum *.tar.gz > SHA256SUMS diff --git a/db/.dockerignore b/db/.dockerignore index 9f2708ac..aa9f4fbe 100644 --- a/db/.dockerignore +++ b/db/.dockerignore @@ -6,6 +6,8 @@ !integrations !main.go !migrations +!plugins +!pluginsystem !routes !templates !services diff --git a/db/go.mod b/db/go.mod index 392bdc65..35113081 100644 --- a/db/go.mod +++ b/db/go.mod @@ -3,6 +3,8 @@ module pocketbase go 1.25.0 require ( + github.com/doyensec/safeurl v0.2.3 + github.com/extism/go-sdk v1.7.1 github.com/go-ap/jsonld v0.0.0-20250905102310-8480b0fe24d9 github.com/meilisearch/meilisearch-go v0.36.2 github.com/pocketbase/dbx v1.12.0 @@ -13,12 +15,19 @@ require ( require ( git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078 // indirect github.com/aymerick/douceur v0.2.0 // indirect + github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/go-ap/errors v0.0.0-20250905102357-4480b47a00c4 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect + github.com/gobwas/glob v0.2.3 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/gorilla/css v1.0.1 // indirect + github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca // indirect + github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect + github.com/tetratelabs/wazero v1.9.0 // indirect github.com/valyala/fastjson v1.6.10 // indirect + go.opentelemetry.io/proto/otlp v1.3.1 // indirect + google.golang.org/protobuf v1.34.2 // indirect ) require ( diff --git a/db/go.sum b/db/go.sum index b24c2e91..e23a8ad8 100644 --- a/db/go.sum +++ b/db/go.sum @@ -17,9 +17,15 @@ github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1 github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8= github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c= +github.com/doyensec/safeurl v0.2.3 h1:KJZHxTUMI17yUSy5umKmDLtzYBUxN6MkdSIyRI81DvY= +github.com/doyensec/safeurl v0.2.3/go.mod h1:3H0cgRpPYPSpgxRRn5yGD35Ns/LgGX/BVWSBbzUqXtY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dvyukov/go-fuzz v0.0.0-20200318091601-be3528f3a813/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw= +github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a h1:UwSIFv5g5lIvbGgtf3tVwC7Ky9rmMFBp0RMs+6f6YqE= +github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q= +github.com/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw= +github.com/extism/go-sdk v1.7.1/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -43,6 +49,8 @@ github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRi github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -56,6 +64,8 @@ github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca h1:T54Ema1DU8ngI+aef9ZhAhNGQhcRTrWxVeG07F+c/Rw= +github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -97,6 +107,10 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 h1:ZF+QBjOI+tILZjBaFj3HgFonKXUcwgJ4djLb6i42S3Q= +github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834/go.mod h1:m9ymHTgNSEjuxvw8E7WWe4Pl4hZQHXONY8wE6dMLaRk= +github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= +github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= github.com/tkrajina/gpxgo v1.4.0 h1:cSD5uSwy3VZuNFieTEZLyRnuIwhonQEkGPkPGW4XNag= github.com/tkrajina/gpxgo v1.4.0/go.mod h1:BXSMfUAvKiEhMEXAFM2NvNsbjsSvp394mOvdcNjettg= github.com/twpayne/go-polyline v1.1.1 h1:/tSF1BR7rN4HWj4XKqvRUNrCiYVMCvywxTFVofvDV0w= @@ -105,6 +119,8 @@ github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADT github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= +go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= @@ -142,6 +158,8 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/db/hooks/integrations.go b/db/hooks/integrations.go deleted file mode 100644 index 7395d711..00000000 --- a/db/hooks/integrations.go +++ /dev/null @@ -1,146 +0,0 @@ -package hooks - -import ( - "encoding/json" - "os" - "pocketbase/util" - - "github.com/pocketbase/pocketbase/apis" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/tools/security" -) - -func ListIntegrationHandler() func(e *core.RecordsListRequestEvent) error { - return func(e *core.RecordsListRequestEvent) error { - if e.HasSuperuserAuth() { - return e.Next() - } - for _, r := range e.Records { - - err := censorIntegrationSecrets(r) - if err != nil { - return err - } - } - - return e.Next() - } -} - -func CreateIntegrationHandler() func(e *core.RecordEvent) error { - return func(e *core.RecordEvent) error { - err := encryptIntegrationSecrets(e.App, e.Record) - if err != nil { - return err - } - - return e.Next() - } -} - -func CreateUpdateIntegrationSuccessHandler() func(e *core.RecordEvent) error { - return func(e *core.RecordEvent) error { - err := censorIntegrationSecrets(e.Record) - if err != nil { - return err - } - return e.Next() - } -} - -func UpdateIntegrationHandler() func(e *core.RecordEvent) error { - return func(e *core.RecordEvent) error { - err := encryptIntegrationSecrets(e.App, e.Record) - if err != nil { - return err - } - - return e.Next() - } -} - -func censorIntegrationSecrets(r *core.Record) error { - secrets := map[string][]string{ - "strava": {"clientSecret", "refreshToken", "accessToken", "expiresAt"}, - "komoot": {"password"}, - "hammerhead": {"password"}, - } - for key, secretKeys := range secrets { - if integrationString := r.GetString(key); integrationString != "" { - var integration map[string]interface{} - if err := json.Unmarshal([]byte(integrationString), &integration); err != nil { - return err - } - if integration == nil { - continue - } - for _, secretKey := range secretKeys { - integration[secretKey] = "" - } - b, err := json.Marshal(integration) - if err != nil { - return err - } - r.Set(key, string(b)) - } - } - - return nil -} - -func encryptIntegrationSecrets(app core.App, r *core.Record) error { - encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY") - if len(encryptionKey) == 0 { - return apis.NewBadRequestError("POCKETBASE_ENCRYPTION_KEY not set", nil) - } - - secrets := map[string][]string{ - "strava": {"clientSecret", "refreshToken", "accessToken", "expiresAt"}, - "komoot": {"password"}, - "hammerhead": {"password"}, - } - - original, _ := app.FindRecordById("integrations", r.Id) - - for key, secretKeys := range secrets { - if integrationString := r.GetString(key); integrationString != "" { - var integration map[string]interface{} - if err := json.Unmarshal([]byte(integrationString), &integration); err != nil { - return err - } - - for _, secretKey := range secretKeys { - // If the secret is already encrypted, we don't re-encrypt it. - // TODO: This is a bit of a hack, we should handle this in a more robust way (e.g. - // storing flag on the record or prefixing encrypted strings with enc: or smilar). - // Doing that would also potentially allow us to support key rotation in the future. - if secret, ok := integration[secretKey].(string); ok && len(secret) > 0 && !util.CanDecryptSecret(secret) { - encryptedSecret, err := security.Encrypt([]byte(secret), encryptionKey) - if err != nil { - return err - } - integration[secretKey] = encryptedSecret - } else if original != nil { - - originalString := original.GetString(key) - var originalIntegration map[string]interface{} - if err := json.Unmarshal([]byte(originalString), &originalIntegration); err != nil { - return err - } - if integration == nil { - continue - } - integration[secretKey] = originalIntegration[secretKey] - } - } - - b, err := json.Marshal(integration) - if err != nil { - return err - } - r.Set(key, string(b)) - } - } - - return nil -} diff --git a/db/hooks/plugin_instances.go b/db/hooks/plugin_instances.go new file mode 100644 index 00000000..51b518ca --- /dev/null +++ b/db/hooks/plugin_instances.go @@ -0,0 +1,271 @@ +package hooks + +import ( + "encoding/json" + "os" + + "github.com/pocketbase/dbx" + "pocketbase/util" + + "github.com/pocketbase/pocketbase/apis" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/tools/security" + + "pocketbase/pluginsystem" +) + +// ListPluginInstanceHandler censors auth values before plugin instances leave +// the API. The database keeps encrypted secrets, but normal users never receive +// the encrypted payload either. +func ListPluginInstanceHandler() func(e *core.RecordsListRequestEvent) error { + return func(e *core.RecordsListRequestEvent) error { + if e.HasSuperuserAuth() { + return e.Next() + } + for _, r := range e.Records { + censorPluginInstanceAuth(e.App, r) + } + + return e.Next() + } +} + +// ViewPluginInstanceHandler applies the same auth censoring for single-record +// reads that ListPluginInstanceHandler applies for list reads. +func ViewPluginInstanceHandler() func(e *core.RecordRequestEvent) error { + return func(e *core.RecordRequestEvent) error { + if e.HasSuperuserAuth() { + return e.Next() + } + censorPluginInstanceAuth(e.App, e.Record) + + return e.Next() + } +} + +// CreatePluginInstanceHandler normalizes initial status and encrypts submitted +// auth fields before a plugin instance is persisted. +func CreatePluginInstanceHandler() func(e *core.RecordEvent) error { + return func(e *core.RecordEvent) error { + ensurePluginInstanceStatus(e.Record) + mergePluginInstanceDefaultConfig(e.App, e.Record) + if err := encryptPluginInstanceAuth(e.App, e.Record); err != nil { + return err + } + + return e.Next() + } +} + +// CreateUpdatePluginInstanceSuccessHandler censors auth values in the response +// body after PocketBase has stored the encrypted values. +func CreateUpdatePluginInstanceSuccessHandler() func(e *core.RecordEvent) error { + return func(e *core.RecordEvent) error { + censorPluginInstanceAuth(e.App, e.Record) + return e.Next() + } +} + +// UpdatePluginInstanceHandler re-applies status defaults and encrypts any +// changed auth fields before the update is persisted. +func UpdatePluginInstanceHandler() func(e *core.RecordEvent) error { + return func(e *core.RecordEvent) error { + ensurePluginInstanceStatus(e.Record) + mergePluginInstanceDefaultConfig(e.App, e.Record) + if err := encryptPluginInstanceAuth(e.App, e.Record); err != nil { + return err + } + + return e.Next() + } +} + +func mergePluginInstanceDefaultConfig(app core.App, r *core.Record) { + defaults := installedPluginDefaultConfig(app, r.GetString("plugin_id")) + if len(defaults) == 0 { + return + } + merged := pluginsystem.CloneJSONMap(defaults) + pluginsystem.MergePluginConfig(merged, pluginsystem.JSONMapFromRecord(r, "config")) + r.Set("config", merged) +} + +func installedPluginDefaultConfig(app core.App, pluginID string) map[string]any { + if pluginID == "" { + return 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") +} + +func censorPluginInstanceAuth(app core.App, r *core.Record) { + if authString := r.GetString("auth"); authString != "" { + var auth map[string]any + if err := json.Unmarshal([]byte(authString), &auth); err != nil { + r.Set("auth", "{}") + return + } + + secretFields := pluginInstanceSecretFields(app, r.GetString("plugin_id")) + encryptAll := len(secretFields) == 0 + for key := range auth { + if encryptAll || secretFields[key] { + auth[key] = "" + } + } + + b, err := json.Marshal(auth) + if err != nil { + r.Set("auth", "{}") + return + } + r.Set("auth", string(b)) + } +} + +func ensurePluginInstanceStatus(r *core.Record) { + if r.GetString("status") != "" { + return + } + if r.GetString("auth") == "" { + r.Set("status", "needs_auth") + return + } + if r.GetBool("enabled") { + r.Set("status", "configured") + return + } + r.Set("status", "disabled") +} + +func encryptPluginInstanceAuth(app core.App, r *core.Record) error { + encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY") + if len(encryptionKey) == 0 { + return apis.NewBadRequestError("POCKETBASE_ENCRYPTION_KEY not set", nil) + } + + authString := r.GetString("auth") + if authString == "" { + return nil + } + + var auth map[string]any + if err := json.Unmarshal([]byte(authString), &auth); err != nil { + return err + } + if auth == nil { + return nil + } + + var originalAuth map[string]any + if original, _ := app.FindRecordById("plugin_instances", r.Id); original != nil { + originalString := original.GetString("auth") + if originalString != "" { + _ = json.Unmarshal([]byte(originalString), &originalAuth) + } + } + + secretFields := pluginInstanceSecretFields(app, r.GetString("plugin_id")) + encryptAll := len(secretFields) == 0 + if originalAuth != nil { + for key, value := range originalAuth { + if _, ok := auth[key]; ok { + continue + } + if encryptAll || secretFields[key] { + auth[key] = value + } + } + } + + for key, value := range auth { + secret, ok := value.(string) + if !ok { + continue + } + if secret == "" { + if originalAuth != nil { + if restored, ok := originalAuth[key].(string); ok && restored != "" { + secret = restored + } + } + if secret == "" { + continue + } + } + if !encryptAll && !secretFields[key] { + auth[key] = secret + continue + } + if util.CanDecryptSecret(secret) { + auth[key] = secret + continue + } + encryptedSecret, err := security.Encrypt([]byte(secret), encryptionKey) + if err != nil { + return err + } + auth[key] = encryptedSecret + } + + b, err := json.Marshal(auth) + if err != nil { + return err + } + r.Set("auth", string(b)) + + return nil +} + +func pluginInstanceSecretFields(app core.App, pluginID string) map[string]bool { + manifest, ok := pluginInstancePluginManifest(app, pluginID) + if !ok { + return nil + } + + fields := map[string]bool{} + for _, field := range pluginsystem.InternalAuthSecretFields() { + fields[field] = true + } + for _, context := range manifest.Auth.Contexts { + if context.SecretField != "" { + fields[context.SecretField] = true + } + for _, field := range context.SecretFields { + fields[field] = true + } + } + return fields +} + +func pluginInstancePluginManifest(app core.App, pluginID string) (pluginsystem.Manifest, bool) { + record, _ := app.FindFirstRecordByFilter( + "installed_plugins", + "plugin_id={:plugin_id}", + dbx.Params{"plugin_id": pluginID}, + ) + if record != nil { + var manifest pluginsystem.Manifest + if err := record.UnmarshalJSONField("manifest", &manifest); err == nil && manifest.ID != "" { + return manifest, true + } + } + + plugins, err := pluginsystem.LoadLocalPlugins("") + if err != nil { + return pluginsystem.Manifest{}, false + } + for _, plugin := range plugins { + if plugin.Manifest.ID == pluginID { + return plugin.Manifest, true + } + } + return pluginsystem.Manifest{}, false +} diff --git a/db/integrations/hammerhead/hammerhead.go b/db/integrations/hammerhead/hammerhead.go deleted file mode 100644 index dd855946..00000000 --- a/db/integrations/hammerhead/hammerhead.go +++ /dev/null @@ -1,927 +0,0 @@ -package hammerhead - -import ( - "bytes" - "context" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "mime/multipart" - "net/http" - - "math" - "os" - "slices" - "strings" - "time" - - "github.com/meilisearch/meilisearch-go" - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/apis" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/tools/filesystem" - "github.com/pocketbase/pocketbase/tools/security" - "github.com/tkrajina/gpxgo/gpx" - - "pocketbase/services/trailmerge" - "pocketbase/util" -) - -func SyncHammerhead(app core.App, client meilisearch.ServiceManager) error { - integrations, err := app.FindAllRecords("integrations", dbx.NewExp("true")) - if err != nil { - return err - } - - for _, i := range integrations { - encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY") - if len(encryptionKey) == 0 { - return errors.New("POCKETBASE_ENCRYPTION_KEY not set") - } - - userId := i.GetString("user") - actor, err := app.FindFirstRecordByData("activitypub_actors", "user", userId) - if err != nil { - warning := fmt.Sprintf("no actor found for user: %s\n", userId) - fmt.Print(warning) - app.Logger().Warn(warning) - continue - } - - ctx, err := util.GetSafeActorContext(nil, actor) - if err != nil { - continue - } - - hammerheadString := i.GetString("hammerhead") - hammerheadIntegration := HammerheadIntegration{ - Planned: true, - Completed: true, - Merge: trailmerge.DefaultIntegrationAutoMergeSettings(), - } - json.Unmarshal([]byte(hammerheadString), &hammerheadIntegration) - - if !hammerheadIntegration.Active || hammerheadIntegration.Email == "" || hammerheadIntegration.Password == "" { - continue - } - h := &HammerheadApi{} - - decryptedPassword, err := security.Decrypt(hammerheadIntegration.Password, encryptionKey) - if err != nil { - warning := fmt.Sprintf("unable to decrypt password: %v\n", err) - fmt.Print(warning) - app.Logger().Warn(warning) - continue - } - - err = h.Login(hammerheadIntegration.Email, string(decryptedPassword)) - if err != nil { - warning := fmt.Sprintf("Hammerhead login failed: %v\n", err) - fmt.Print(warning) - app.Logger().Warn(warning) - continue - } - - page := 0 - totalPages := 0 - stopped := false - - var after int64 = 0 - if hammerheadIntegration.After != "" { - t, err := time.Parse("2006-01-02", hammerheadIntegration.After) - if err != nil { - return err - } - t = t.UTC() - - after = t.Unix() - } - - if hammerheadIntegration.Planned { - page = 0 - totalPages = 0 - stopped = false - - for page <= totalPages && !stopped { - curTotalPages := totalPages - tours, curTotalPages, err := h.fetchTours(page) - if err != nil { - warning := fmt.Sprintf("error fetching tours from Hammerhead: %v\n", err) - fmt.Print(warning) - app.Logger().Warn(warning) - break - } - - if curTotalPages > totalPages { - totalPages = curTotalPages - } - - err, stopped = syncTrailWithTours(app, client, ctx, h, actor, hammerheadIntegration, tours, after) - if err != nil { - warning := fmt.Sprintf("error syncing Hammerhead tours with trails: %v\n", err) - fmt.Print(warning) - app.Logger().Warn(warning) - break - } - - page += 1 - } - } - - if hammerheadIntegration.Completed { - page = 0 - totalPages = 0 - stopped = false - - for page <= totalPages && !stopped { - curTotalPages := totalPages - tours, curTotalPages, err := h.fetchActivities(page) - if err != nil { - warning := fmt.Sprintf("error fetching tours from Hammerhead: %v\n", err) - fmt.Print(warning) - app.Logger().Warn(warning) - break - } - - if curTotalPages > totalPages { - totalPages = curTotalPages - } - - err, stopped = syncTrailWithActivities(app, client, ctx, h, actor, hammerheadIntegration, tours, after) - if err != nil { - warning := fmt.Sprintf("error syncing Hammerhead tours with trails: %v\n", err) - fmt.Print(warning) - app.Logger().Warn(warning) - break - } - - page += 1 - } - } - } - - return nil -} - -type BasicAuthToken struct { - Key string - Value string -} - -func (b BasicAuthToken) Apply(req *http.Request) { - req.Header.Set("Authorization", "Bearer "+b.Value) -} - -type HammerheadApi struct { - UserID string - Token string -} - -func (h *HammerheadApi) buildHeader() *BasicAuthToken { - if h.UserID != "" && h.Token != "" { - return &BasicAuthToken{h.UserID, h.Token} - } - return nil -} - -func getToken(uri string, auth *BasicAuthToken) ([]byte, error) { - client := &http.Client{} - - var jsonStr = []byte(`{"grant_type": "password", "username": "` + auth.Key + `", "password": "` + auth.Value + `"}`) - - req, err := http.NewRequest("POST", uri, bytes.NewBuffer(jsonStr)) - if err != nil { - return nil, err - } - - req.Header.Set("Content-Type", "application/json") - - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("error retrieving auth token from Hammerhead (%d): %s", resp.StatusCode, string(body)) - } - - return io.ReadAll(resp.Body) -} - -func (h *HammerheadApi) UploadActivities(e *core.RequestEvent) error { - files, err := e.FindUploadedFiles("file") - if err != nil { - if errors.Is(err, http.ErrMissingFile) { - return apis.NewBadRequestError("file field is required", err) - } - return apis.NewBadRequestError("invalid multipart payload", err) - } - - if len(files) == 0 { - return apis.NewBadRequestError("file field is required", nil) - } - - fileToUpload := files[0] - reader, err := fileToUpload.Reader.Open() - if err != nil { - return err - } - defer reader.Close() - - var buf bytes.Buffer - writer := multipart.NewWriter(&buf) - - part, err := writer.CreateFormFile("file", fileToUpload.OriginalName) - if err != nil { - return err - } - - if _, err := io.Copy(part, reader); err != nil { - return err - } - - contentType := writer.FormDataContentType() - - if err := writer.Close(); err != nil { - return err - } - - currentURI := fmt.Sprintf("https://dashboard.hammerhead.io/v1/users/%s/routes/import/file", h.UserID) - - if _, err := sendPostRequest(currentURI, &buf, contentType, h.buildHeader()); err != nil { - return err - } - - return nil -} - -func sendPostRequest(url string, body io.Reader, contentType string, auth *BasicAuthToken) ([]byte, error) { - client := &http.Client{} - req, err := http.NewRequest("POST", url, body) - if err != nil { - return nil, err - } - - if contentType != "" { - req.Header.Set("Content-Type", contentType) - } - - if auth != nil { - auth.Apply(req) - } - - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("error sending request to Hammerhead (%d): %s", resp.StatusCode, string(body)) - } - - return io.ReadAll(resp.Body) -} - -func sendGetRequest(url string, auth *BasicAuthToken) ([]byte, error) { - client := &http.Client{} - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return nil, err - } - - if auth != nil { - auth.Apply(req) - } - - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("error sending request to Hammerhead (%d): %s", resp.StatusCode, string(body)) - } - - return io.ReadAll(resp.Body) -} - -func (h *HammerheadApi) Login(email, password string) error { - url := "https://dashboard.hammerhead.io/v1/auth/token" - - body, err := getToken(url, &BasicAuthToken{email, password}) - if err != nil { - return err - } - - var data LoginResponse - json.Unmarshal(body, &data) - - h.Token = data.Token - derivedUserID, err := extractUserIDFromToken(data.Token) - if err != nil { - return fmt.Errorf("unable to determine Hammerhead user id automatically: %w", err) - } - h.UserID = derivedUserID - - return nil -} - -func extractUserIDFromToken(token string) (string, error) { - parts := strings.Split(token, ".") - if len(parts) < 2 { - return "", errors.New("token is not a JWT") - } - - payload, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - return "", fmt.Errorf("unable to decode JWT payload: %w", err) - } - - var claims map[string]any - if err := json.Unmarshal(payload, &claims); err != nil { - return "", fmt.Errorf("unable to decode JWT claims: %w", err) - } - - if value, ok := claims["sub"].(string); ok && value != "" { - return value, nil - } - - return "", errors.New("no sub claim found in token") -} - -func (h *HammerheadApi) fetchActivities(page int) ([]HammerheadActivityResponse, int, error) { - - currentUri := fmt.Sprintf("https://dashboard.hammerhead.io/v1/users/%s/activities?perPage=50&page=%d&search=&orderBy=NEWEST&ascending=true", h.UserID, page) - - body, err := sendGetRequest(currentUri, h.buildHeader()) - if err != nil { - return nil, 0, err - } - - var data HammerheadActivitiesResponse - json.Unmarshal(body, &data) - - tours := data.Tours - - return tours, data.Pages, nil -} - -func (h *HammerheadApi) fetchTours(page int) ([]HammerheadTourResponse, int, error) { - - currentUri := fmt.Sprintf("https://dashboard.hammerhead.io/v1/users/%s/routes?perPage=50&page=%d&search=&orderBy=NEWEST&ascending=true&exclude=archive", h.UserID, page) - body, err := sendGetRequest(currentUri, h.buildHeader()) - if err != nil { - return nil, 0, err - } - - var data HammerheadToursResponse - json.Unmarshal(body, &data) - - tours := data.Data - - return tours, data.TotalPages, nil -} - -func (h *HammerheadApi) fetchDetailedActivity(tour HammerheadActivityResponse) (*HammerheadActivity, error) { - - url := fmt.Sprintf("https://dashboard.hammerhead.io/v1/users/%s/activities/%s/details", h.UserID, tour.ID) - body, err := sendGetRequest(url, h.buildHeader()) - if err != nil { - return nil, err - } - - var data *HammerheadActivity - json.Unmarshal(body, &data) - return data, nil -} - -func (h *HammerheadApi) fetchDetailedTour(tour HammerheadTourResponse) (*HammerheadTour, error) { - - url := fmt.Sprintf("https://dashboard.hammerhead.io/v1/users/%s/routes/%s", h.UserID, tour.ID) - body, err := sendGetRequest(url, h.buildHeader()) - if err != nil { - return nil, err - } - - var data *HammerheadTour - json.Unmarshal(body, &data) - return data, nil -} - -func syncTrailWithTours(app core.App, client meilisearch.ServiceManager, ctx context.Context, k *HammerheadApi, actor *core.Record, integration HammerheadIntegration, tours []HammerheadTourResponse, after int64) (error, bool) { - for _, tour := range tours { - existingTrail, err := util.FindTrailByExternalReference(app, "hammerhead", tour.ID) - if err != nil { - return err, true - } - if existingTrail != nil { - continue - } - - detailedTour, err := k.fetchDetailedTour(tour) - if err != nil { - app.Logger().Warn(fmt.Sprintf("Unable to fetch details for tour '%s': %v", tour.Name, err)) - continue - } - - if detailedTour.CreatedAt.Unix() < after { - return nil, true - } - - if detailedTour.Distance <= 0 { - app.Logger().Warn(fmt.Sprintf("Skipping Hammerhead tour '%s' with zero distance", tour.Name)) - continue - } - - gpx, err := generateTourGPX(detailedTour) - if err != nil { - app.Logger().Warn(fmt.Sprintf("Unable to generate GPX for tour '%s': %v", tour.Name, err)) - continue - } - - trailID, err := createTrailFromTour(app, detailedTour, gpx, actor.Id) - if err != nil { - app.Logger().Warn(fmt.Sprintf("Unable to create trail for tour '%s': %v", tour.Name, err)) - continue - } - if err := trailmerge.TryAutoMergeImportedTrail(app, client, ctx, actor, trailID, integration.Merge); err != nil { - app.Logger().Warn(fmt.Sprintf("Unable to auto-merge imported Hammerhead tour '%s': %v", tour.Name, err)) - } - } - - return nil, false -} - -func syncTrailWithActivities(app core.App, client meilisearch.ServiceManager, ctx context.Context, k *HammerheadApi, actor *core.Record, integration HammerheadIntegration, tours []HammerheadActivityResponse, after int64) (error, bool) { - for _, tour := range tours { - existingTrail, err := util.FindTrailByExternalReference(app, "hammerhead", tour.ID) - if err != nil { - return err, true - } - if existingTrail != nil { - continue - } - - detailedTour, err := k.fetchDetailedActivity(tour) - if err != nil { - app.Logger().Warn(fmt.Sprintf("Unable to fetch details for tour '%s': %v", tour.Name, err)) - continue - } - - if detailedTour.ActivityData.CreatedAt.Unix() < after { - return nil, true - } - - distance, ok := activityDistance(detailedTour) - if !ok || distance <= 0 { - app.Logger().Warn(fmt.Sprintf("Skipping Hammerhead activity '%s' with zero distance", tour.Name)) - continue - } - - gpx, err := generateActivityGPX(detailedTour) - if err != nil { - app.Logger().Warn(fmt.Sprintf("Unable to generate GPX for tour '%s': %v", tour.Name, err)) - continue - } - - trailID, err := createTrailFromActivity(app, detailedTour, gpx, actor.Id) - if err != nil { - app.Logger().Warn(fmt.Sprintf("Unable to create trail for tour '%s': %v", tour.Name, err)) - continue - } - if err := trailmerge.TryAutoMergeImportedTrail(app, client, ctx, actor, trailID, integration.Merge); err != nil { - app.Logger().Warn(fmt.Sprintf("Unable to auto-merge imported Hammerhead activity '%s': %v", tour.Name, err)) - } - } - - return nil, false -} - -func activityDistance(detailedTour *HammerheadActivity) (float64, bool) { - idDistance := slices.IndexFunc(detailedTour.ActivityData.ActivityInfo, func(c HammerheadInfo) bool { return c.Key == "TYPE_DISTANCE_ID" }) - if idDistance < 0 { - return 0, false - } - - return detailedTour.ActivityData.ActivityInfo[idDistance].Value.Value, true -} - -func createTrailFromActivity(app core.App, detailedTour *HammerheadActivity, gpx *filesystem.File, actor string) (string, error) { - trailid := security.RandomStringWithAlphabet(core.DefaultIdLength, core.DefaultIdAlphabet) - - collection, err := app.FindCollectionByNameOrId("trails") - if err != nil { - return "", err - } - - record := core.NewRecord(collection) - - category, _ := app.FindFirstRecordByData("categories", "name", "Biking" /*ToDo: Mapping*/) - categoryId := "" - if category != nil { - categoryId = category.Id - } - - diffculty := "easy" // ToDo: calculate difficulty - - idDistance := slices.IndexFunc(detailedTour.ActivityData.ActivityInfo, func(c HammerheadInfo) bool { return c.Key == "TYPE_DISTANCE_ID" }) - idElevationGain := slices.IndexFunc(detailedTour.ActivityData.ActivityInfo, func(c HammerheadInfo) bool { return c.Key == "TYPE_ELEVATION_GAIN_ID" }) - idElevationLoss := slices.IndexFunc(detailedTour.ActivityData.ActivityInfo, func(c HammerheadInfo) bool { return c.Key == "TYPE_ELEVATION_LOSS_ID" }) - - duration := 0 - for _, lap := range detailedTour.ActivityData.Laps { - duration += lap.ActiveTime - } - - startLat := float64(0) - startLng := float64(0) - for i, lat := range detailedTour.RecordData.Lat { - if lat != float64(0) { - startLat = lat - startLng = detailedTour.RecordData.Lng[i] - break - } - } - - record.Load(map[string]any{ - "id": trailid, - "name": detailedTour.ActivityData.Name, - "public": false, - "completed": true, - "distance": detailedTour.ActivityData.ActivityInfo[idDistance].Value.Value, - "elevation_gain": detailedTour.ActivityData.ActivityInfo[idElevationGain].Value.Value, - "elevation_loss": detailedTour.ActivityData.ActivityInfo[idElevationLoss].Value.Value, - "duration": duration / 1000, - "date": detailedTour.ActivityData.CreatedAt, - "external_provider": "hammerhead", - "external_id": detailedTour.ActivityData.ID, - "lat": startLat, - "lon": startLng, - "difficulty": diffculty, - "category": categoryId, - "author": actor, - }) - - if gpx != nil { - record.Set("gpx", gpx) - } - - if err := app.Save(record); err != nil { - return "", err - } - if err := util.EnsureTrailExternalReference(app, trailid, "hammerhead", detailedTour.ActivityData.ID); err != nil { - return "", err - } - - collection, err = app.FindCollectionByNameOrId("summit_logs") - if err != nil { - return "", err - } - - summitLogRecord := core.NewRecord(collection) - summitLogRecord.Load(map[string]any{ - "distance": detailedTour.ActivityData.ActivityInfo[idDistance].Value.Value, - "elevation_gain": detailedTour.ActivityData.ActivityInfo[idElevationGain].Value.Value, - "elevation_loss": detailedTour.ActivityData.ActivityInfo[idElevationLoss].Value.Value, - "duration": duration / 1000, - "date": detailedTour.ActivityData.CreatedAt, - "author": actor, - "trail": trailid, - }) - if err := app.Save(summitLogRecord); err != nil { - return "", err - } - - return trailid, nil -} - -func createTrailFromTour(app core.App, detailedTour *HammerheadTour, gpx *filesystem.File, actor string) (string, error) { - trailid := security.RandomStringWithAlphabet(core.DefaultIdLength, core.DefaultIdAlphabet) - - collection, err := app.FindCollectionByNameOrId("trails") - if err != nil { - return "", err - } - - record := core.NewRecord(collection) - - category, _ := app.FindFirstRecordByData("categories", "name", "Biking" /*ToDo: Mapping*/) - categoryId := "" - if category != nil { - categoryId = category.Id - } - - diffculty := "easy" // ToDo: calculate difficulty - - record.Load(map[string]any{ - "id": trailid, - "name": detailedTour.Name, - "public": detailedTour.IsPublic, - "distance": detailedTour.Distance, - "elevation_gain": detailedTour.Elevation.Gain, - "elevation_loss": detailedTour.Elevation.Loss, - "date": detailedTour.CreatedAt, - "lat": detailedTour.StartLocation.Lat, - "lon": detailedTour.StartLocation.Lng, - "difficulty": diffculty, - "category": categoryId, - "author": actor, - }) - - if gpx != nil { - record.Set("gpx", gpx) - } - - if err := app.Save(record); err != nil { - return "", err - } - if err := util.EnsureTrailExternalReference(app, trailid, "hammerhead", detailedTour.ID); err != nil { - return "", err - } - - return trailid, nil -} - -func generateActivityGPX(detailedTour *HammerheadActivity) (*filesystem.File, error) { - times := len(detailedTour.RecordData.Timestamp) - if times == 0 { - return nil, nil - } - - var points []gpx.GPXPoint - const zeroEps = 1e-4 - - // iterate over timestamps and only add points when lat/lng exist for the same index - for i := 0; i < times; i++ { - // ensure we have latitude and longitude for this index - if i < len(detailedTour.RecordData.Lat) && i < len(detailedTour.RecordData.Lng) { - lat := detailedTour.RecordData.Lat[i] - lng := detailedTour.RecordData.Lng[i] - - // exclude near (0,0) garbage points - if math.Abs(lat) < zeroEps && math.Abs(lng) < zeroEps { - continue - } - - t := detailedTour.RecordData.Timestamp[i] - - elevation := float64(0) - if i < len(detailedTour.RecordData.Elevation) { - elevation = detailedTour.RecordData.Elevation[i] / 1000.0 - } - - points = append(points, gpx.GPXPoint{ - Point: gpx.Point{ - Latitude: lat, - Longitude: lng, - Elevation: *gpx.NewNullableFloat64(elevation), - }, - Timestamp: time.Unix(int64(t), 0), - }) - } - } - - if len(points) == 0 { - return nil, nil - } - - gpxData := &gpx.GPX{ - Version: "1.1", - Creator: "Hammerhead GPX Exporter", - Tracks: []gpx.GPXTrack{ - { - Name: detailedTour.ActivityData.Name, - Segments: []gpx.GPXTrackSegment{ - { - Points: points, - }, - }, - }, - }, - } - gpxAsXML, err := gpxData.ToXml(gpx.ToXmlParams{Version: "1.1", Indent: true}) - if err != nil { - return nil, err - } - - gpxFile, err := filesystem.NewFileFromBytes(gpxAsXML, detailedTour.ActivityData.Name+".gpx") - if err != nil { - return nil, err - } - - return gpxFile, nil -} - -func generateTourGPX(detailedTour *HammerheadTour) (*filesystem.File, error) { - - poly := detailedTour.RoutePolyline - coords, err := decodePolyline(poly) - if err != nil { - return nil, fmt.Errorf("decode polyline: %w", err) - } - if len(coords) == 0 { - return nil, nil - } - - // try to get elevation polyline (adjust field path if your struct differs) - elevations := []float64{} - // precision 100 is common for Valhalla elevation encodings; change if needed - if decoded, err := decodeElevations(detailedTour.Elevation.Polyline, 100000); err == nil { - elevations = decoded - } - - // Heuristic: detect if coords are (lng,lat) instead of (lat,lng). - // Count how many points look valid in each orientation and pick the best. - validAsLat := 0 - validAsLng := 0 - for _, c := range coords { - // treat c[0] as lat, c[1] as lng - if c[0] >= -90 && c[0] <= 90 && c[1] >= -180 && c[1] <= 180 { - validAsLat++ - } - // treat c[1] as lat, c[0] as lng (swapped) - if c[1] >= -90 && c[1] <= 90 && c[0] >= -180 && c[0] <= 180 { - validAsLng++ - } - } - swap := false - if validAsLng > validAsLat { - swap = true - } - - var points []gpx.GPXPoint - for i, c := range coords { - lat := c[0] - lng := c[1] - if swap { - lat, lng = c[1], c[0] - } - - // choose elevation: - elevation := 0.0 - if len(elevations) == len(coords) { - elevation = elevations[i] - } else if len(elevations) > 0 { - // map index proportionally if lengths differ - j := int(math.Round(float64(i) * float64(len(elevations)-1) / float64(len(coords)-1))) - if j < 0 { - j = 0 - } - if j >= len(elevations) { - j = len(elevations) - 1 - } - elevation = elevations[j] - } - - points = append(points, gpx.GPXPoint{ - Point: gpx.Point{ - Latitude: lat, - Longitude: lng, - Elevation: *gpx.NewNullableFloat64(elevation), - }, - }) - } - - gpxData := &gpx.GPX{ - Version: "1.1", - Creator: "Hammerhead GPX Exporter", - Tracks: []gpx.GPXTrack{ - { - Name: detailedTour.Name, - Segments: []gpx.GPXTrackSegment{ - { - Points: points, - }, - }, - }, - }, - } - gpxAsXML, err := gpxData.ToXml(gpx.ToXmlParams{Version: "1.1", Indent: true}) - if err != nil { - return nil, err - } - - gpxFile, err := filesystem.NewFileFromBytes(gpxAsXML, detailedTour.Name+".gpx") - if err != nil { - return nil, err - } - - return gpxFile, nil -} - -// decodePolyline decodes an encoded polyline string (Google Polyline Algorithm) -// returns slice of [lat, lng] pairs (precision 1e5). -func decodePolyline(s string) ([][2]float64, error) { - if s == "" { - return nil, nil - } - var coords [][2]float64 - index := 0 - lat := 0 - lng := 0 - for index < len(s) { - // decode latitude - result := 0 - shift := uint(0) - for { - if index >= len(s) { - return nil, fmt.Errorf("invalid polyline encoding") - } - b := int(s[index]) - 63 - index++ - result |= (b & 0x1F) << shift - shift += 5 - if b < 0x20 { - break - } - } - dlat := (result >> 1) ^ (-(result & 1)) - lat += dlat - - // decode longitude - result = 0 - shift = 0 - for { - if index >= len(s) { - return nil, fmt.Errorf("invalid polyline encoding") - } - b := int(s[index]) - 63 - index++ - result |= (b & 0x1F) << shift - shift += 5 - if b < 0x20 { - break - } - } - dlng := (result >> 1) ^ (-(result & 1)) - lng += dlng - - coords = append(coords, [2]float64{float64(lat) / 1e5, float64(lng) / 1e5}) - } - - // Auto-normalize scale if values are out of realistic lat/lon ranges. - // Some providers use different precision/scales; repeatedly divide by 10 - // until all values fit into valid ranges. - if len(coords) > 0 { - maxLat := 0.0 - maxLng := 0.0 - for _, c := range coords { - if abs := math.Abs(c[0]); abs > maxLat { - maxLat = abs - } - if abs := math.Abs(c[1]); abs > maxLng { - maxLng = abs - } - } - // If values are too large (e.g. > 90 lat or > 180 lon), rescale down. - for (maxLat > 90.0 || maxLng > 180.0) && (maxLat > 0 && maxLng > 0) { - for i := range coords { - coords[i][0] /= 10.0 - coords[i][1] /= 10.0 - } - maxLat /= 10.0 - maxLng /= 10.0 - } - } - - return coords, nil -} - -// decodeElevations decodes a single-dimension delta-encoded polyline string. -// precision is the divisor (e.g. 100 for centi-meters -> meters). Returns elevation values in same units as precision (meters if precision=100). -func decodeElevations(s string, precision float64) ([]float64, error) { - if s == "" { - return nil, nil - } - var elevs []float64 - index := 0 - val := 0 - for index < len(s) { - result := 0 - shift := uint(0) - for { - if index >= len(s) { - return nil, fmt.Errorf("invalid elevation encoding") - } - b := int(s[index]) - 63 - index++ - result |= (b & 0x1F) << shift - shift += 5 - if b < 0x20 { - break - } - } - d := (result >> 1) ^ (-(result & 1)) - val += d - elevs = append(elevs, float64(val)/precision) - } - return elevs, nil -} diff --git a/db/integrations/hammerhead/models.go b/db/integrations/hammerhead/models.go deleted file mode 100644 index 1cb8054e..00000000 --- a/db/integrations/hammerhead/models.go +++ /dev/null @@ -1,209 +0,0 @@ -package hammerhead - -import ( - "time" - - "pocketbase/services/trailmerge" -) - -type HammerheadToursResponse struct { - TotalItems int `json:"totalItems"` - TotalPages int `json:"totalPages"` - PerPage int `json:"perPage"` - CurrentPage int `json:"currentPage"` - Data []HammerheadTourResponse `json:"data"` -} -type HammerheadTourResponse struct { - StartLocationName string `json:"startLocationName"` - IsAutoImported bool `json:"isAutoImported"` - SummaryPolyline string `json:"summaryPolyline"` - IsStarred bool `json:"isStarred"` - IsPublic bool `json:"isPublic"` - Collections any `json:"collections"` - Gain int `json:"gain"` - Distance float64 `json:"distance"` - Name string `json:"name"` - RoutingType string `json:"routingType"` - ID string `json:"id"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - Source string `json:"source"` -} - -type HammerheadTourElevation struct { - Gain float64 `json:"gain"` - Loss float64 `json:"loss"` - Min float64 `json:"min"` - Max float64 `json:"max"` - Source string `json:"source"` - Polyline string `json:"polyline"` -} -type HammerheadLocation struct { - Lat float64 `json:"lat"` - Lng float64 `json:"lng"` -} -type HammerheadWaypoint struct { - Lat float64 `json:"lat"` - Lng float64 `json:"lng"` - WaypointType string `json:"waypointType"` - PolylineIndex int `json:"polylineIndex"` -} - -type HammerheadTour struct { - ID string `json:"id"` - CreatedAt time.Time `json:"createdAt"` - Name string `json:"name"` - Distance float64 `json:"distance"` - Elevation HammerheadTourElevation `json:"elevation"` - IsStarred bool `json:"isStarred"` - StartLocationName string `json:"startLocationName"` - EndLocationName string `json:"endLocationName"` - StartLocation HammerheadLocation `json:"startLocation"` - EndLocation HammerheadLocation `json:"endLocation"` - Waypoints []HammerheadWaypoint `json:"waypoints"` - Collections []string `json:"collections"` - RoutePolyline string `json:"routePolyline"` - SummaryPolyline string `json:"summaryPolyline"` - Source string `json:"source"` - SourceID string `json:"sourceId"` - IsPublic bool `json:"isPublic"` - ImageVersion string `json:"imageVersion"` - IsAutoImported bool `json:"isAutoImported"` - UpdatedAt time.Time `json:"updatedAt"` - Bounds []HammerheadLocation `json:"bounds"` -} - -type HammerheadIntegration struct { - Active bool `json:"active"` - Email string `json:"email"` - Password string `json:"password"` - Planned bool `json:"planned"` - Completed bool `json:"completed"` - After string `json:"after,omitempty"` - Merge trailmerge.IntegrationAutoMergeSettings `json:"merge"` -} - -type LoginResponse struct { - Token string `json:"access_token"` - Type string `json:"token_type"` - Expires int `json:"expires_in"` -} - -type HammerheadActivitiesResponse struct { - Items int `json:"totalItems"` - Pages int `json:"totalPages"` - PerPage int `json:"perPage"` - Tours []HammerheadActivityResponse `json:"data"` -} - -type HammerheadActivityResponse struct { - ID string `json:"id"` - CreatedAt time.Time `json:"createdAt"` - Name string `json:"name"` - Client string `json:"client"` - ActiveTime int `json:"activeTime"` - Duration HammerheadTourDuration `json:"duration"` - Sync HammerheadSync `json:"partners"` - ActivityInfo []HammerheadInfo `json:"activityInfo"` -} -type HammerheadInfoValue struct { - Format string `json:"format"` - Value float64 `json:"value"` -} -type HammerheadInfo struct { - Key string `json:"key"` - Value HammerheadInfoValue `json:"value"` -} -type HammerheadPartner struct { - Partner string `json:"partner"` - NeedsUpload bool `json:"needsUpload"` - ExternalID string `json:"externalId"` - Attempts int `json:"attempts"` - UploadedAt time.Time `json:"uploadedAt"` -} -type HammerheadSync struct { - Description string `json:"description"` - Tags []any `json:"tags"` - Synced bool `json:"synced"` - Partners []HammerheadPartner `json:"partners"` -} -type HammerheadTourDuration struct { - ElapsedTime int `json:"elapsedTime"` - StartTime time.Time `json:"startTime"` - EndTime time.Time `json:"endTime"` -} - -type HammerheadActivity struct { - ActivityData HammerheadActivityData `json:"activityData"` - SessionData HammerheadSessionData `json:"sessionData"` - RecordData HammerheadRecordData `json:"recordData"` - ShiftData HammerheadShiftData `json:"shiftData"` - LapData HammerheadLapData `json:"lapData"` - DeviceBatteryData HammerheadDeviceBatteryData `json:"deviceBatteryData"` -} -type HammerheadDuration struct { - ElapsedTime int `json:"elapsedTime"` - StartTime time.Time `json:"startTime"` - EndTime time.Time `json:"endTime"` -} -type HammerheadLapDetail struct { - ActiveTime int `json:"activeTime"` - Duration HammerheadDuration `json:"duration"` - LapNumber int `json:"lapNumber"` - Pauses []HammerheadDuration `json:"pauses"` - LapInfo []HammerheadInfo `json:"lapInfo"` - Trigger string `json:"trigger"` -} -type HammerheadActivityData struct { - ID string `json:"id"` - Name string `json:"name"` - BikeID string `json:"bikeId"` - Client string `json:"client"` - ActiveTime int `json:"activeTime"` - Duration HammerheadDuration `json:"duration"` - ActivityInfo []HammerheadInfo `json:"activityInfo"` - Laps []HammerheadLapDetail `json:"laps"` - Polyline string `json:"polyline"` - Sync HammerheadSync `json:"sync"` - ActivityType string `json:"activityType"` - Climbs []HammerheadClimb `json:"climbs"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} -type HammerheadClimb struct { - StartDistance float64 `json:"startDistance"` - EndDistance float64 `json:"endDistance"` - Distance float64 `json:"distance"` -} -type HammerheadSessionData struct { - ThresholdPower int `json:"thresholdPower"` - FrontGears []int `json:"frontGears"` - RearGears []int `json:"rearGears"` -} -type HammerheadRecordData struct { - Distance []float64 `json:"distance"` - Timestamp []int `json:"timestamp"` - Elevation []float64 `json:"elevation"` - Grade []float64 `json:"grade"` - Lat []float64 `json:"lat"` - Lng []float64 `json:"lng"` - Speed []float64 `json:"speed"` - Power []any `json:"power"` - Temperature []int `json:"temperature"` -} -type HammerheadShiftData struct { - Timestamp []int `json:"timestamp"` - FrontChange []bool `json:"frontChange"` - FrontGear []int `json:"frontGear"` - RearGear []int `json:"rearGear"` - FrontGearNum []int `json:"frontGearNum"` - RearGearNum []int `json:"rearGearNum"` -} -type HammerheadLapData struct { - Timestamp []int `json:"timestamp"` - Trigger []string `json:"trigger"` -} -type HammerheadDeviceBatteryData struct { - Timestamp []int `json:"timestamp"` - DeviceBattery []int `json:"deviceBattery"` -} diff --git a/db/integrations/komoot/komoot.go b/db/integrations/komoot/komoot.go deleted file mode 100644 index 6c0e0cb1..00000000 --- a/db/integrations/komoot/komoot.go +++ /dev/null @@ -1,510 +0,0 @@ -package komoot - -import ( - "context" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "os" - "strconv" - "strings" - "time" - - "github.com/meilisearch/meilisearch-go" - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/tools/filesystem" - "github.com/pocketbase/pocketbase/tools/security" - "github.com/tkrajina/gpxgo/gpx" - - "pocketbase/services/trailmerge" - "pocketbase/util" -) - -func SyncKomoot(app core.App, client meilisearch.ServiceManager) error { - integrations, err := app.FindAllRecords("integrations", dbx.NewExp("true")) - if err != nil { - return err - } - - for _, i := range integrations { - encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY") - if len(encryptionKey) == 0 { - return errors.New("POCKETBASE_ENCRYPTION_KEY not set") - } - - userId := i.GetString("user") - actor, err := app.FindFirstRecordByData("activitypub_actors", "user", userId) - if err != nil { - warning := fmt.Sprintf("no actor found for user: %s\n", userId) - fmt.Print(warning) - app.Logger().Warn(warning) - continue - } - - ctx, err := util.GetSafeActorContext(nil, actor) - if err != nil { - continue - } - - komootString := i.GetString("komoot") - komootIntegration := KomootIntegration{ - Planned: true, - Completed: true, - Merge: trailmerge.DefaultIntegrationAutoMergeSettings(), - } - json.Unmarshal([]byte(komootString), &komootIntegration) - - if !komootIntegration.Active || komootIntegration.Email == "" || komootIntegration.Password == "" { - continue - } - k := &KomootApi{} - - decryptedPassword, err := security.Decrypt(komootIntegration.Password, encryptionKey) - if err != nil { - warning := fmt.Sprintf("unable to decrypt password: %v\n", err) - fmt.Print(warning) - app.Logger().Warn(warning) - continue - } - - err = k.Login(komootIntegration.Email, string(decryptedPassword)) - if err != nil { - warning := fmt.Sprintf("komoot login failed: %v\n", err) - fmt.Print(warning) - app.Logger().Warn(warning) - continue - } - totalPages := 1 - for page := 0; page < totalPages; page++ { - tours, tp, err := k.fetchTours(page) - if err != nil { - warning := fmt.Sprintf("error fetching tours from komoot (page %d): %v\n", page, err) - fmt.Print(warning) - app.Logger().Warn(warning) - break - } - totalPages = tp - - allAlreadySynced, err := syncTrailWithTours(app, client, ctx, k, komootIntegration, userId, actor, tours) - if err != nil { - warning := fmt.Sprintf("error syncing komoot tours with trails: %v\n", err) - fmt.Print(warning) - app.Logger().Warn(warning) - break - } - if allAlreadySynced { - break - } - } - } - - return nil -} - -type BasicAuthToken struct { - Key string - Value string -} - -func (b BasicAuthToken) Apply(req *http.Request) { - authStr := "Basic " + base64.StdEncoding.EncodeToString([]byte(b.Key+":"+b.Value)) - req.Header.Set("Authorization", authStr) -} - -type KomootApi struct { - UserID string - Token string -} - -func (k *KomootApi) buildHeader() *BasicAuthToken { - if k.UserID != "" && k.Token != "" { - return &BasicAuthToken{k.UserID, k.Token} - } - return nil -} - -func sendRequest(url string, auth *BasicAuthToken) ([]byte, error) { - client := &http.Client{} - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return nil, err - } - - if auth != nil { - auth.Apply(req) - } - - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("error sending request to komoot (%d): %s", resp.StatusCode, string(body)) - } - - return io.ReadAll(resp.Body) -} - -func (k *KomootApi) Login(email, password string) error { - url := fmt.Sprintf("https://api.komoot.de/v006/account/email/%s/", email) - - body, err := sendRequest(url, &BasicAuthToken{email, password}) - if err != nil { - return err - } - - var data LoginResponse - json.Unmarshal(body, &data) - - k.UserID = data.Username - k.Token = data.Password - - return nil -} -func (k *KomootApi) fetchTours(page int) ([]KomootTour, int, error) { - currentUri := fmt.Sprintf("https://api.komoot.de/v007/users/%s/tours/?page=%d&sort_field=date&sort_direction=desc&limit=30", k.UserID, page) - - body, err := sendRequest(currentUri, k.buildHeader()) - if err != nil { - return nil, 0, err - } - - var data KomootToursResponse - json.Unmarshal(body, &data) - - return data.Embedded.Tours, data.Page.TotalPages, nil -} - -func (k *KomootApi) fetchDetailedTour(tour KomootTour) (*DetailedKomootTour, error) { - url := fmt.Sprintf("https://api.komoot.de/v007/tours/%d?_embedded=coordinates,way_types,surfaces,directions,participants,timeline,cover_images&directions=v2&fields=timeline&format=coordinate_array&timeline_highlights_fields=tips,recommenders&page=2", tour.ID) - body, err := sendRequest(url, k.buildHeader()) - if err != nil { - return nil, err - } - - var data *DetailedKomootTour - json.Unmarshal(body, &data) - return data, nil -} - -// syncTrailWithTours imports tours not yet in the DB. Returns allAlreadySynced=true -// when every tour on this page was already imported, so the caller can stop paginating -// early during incremental syncs. Tours skipped due to type filters do NOT count as -// synced - only tours already present in the DB do. -func syncTrailWithTours(app core.App, client meilisearch.ServiceManager, ctx context.Context, k *KomootApi, i KomootIntegration, user string, actor *core.Record, tours []KomootTour) (bool, error) { - allAlreadySynced := true - for _, tour := range tours { - existingTrail, err := util.FindTrailByExternalReference(app, "komoot", strconv.Itoa(int(tour.ID))) - if err != nil { - return false, err - } - if existingTrail != nil { - continue - } - // Tour is not yet in the DB - we must keep paginating regardless of type filter - allAlreadySynced = false - if (tour.Type == "tour_planned" && !i.Planned) || (tour.Type == "tour_recorded" && !i.Completed) { - continue - } - detailedTour, err := k.fetchDetailedTour(tour) - if err != nil { - app.Logger().Warn(fmt.Sprintf("Unable to fetch details for tour '%s': %v", tour.Name, err)) - continue - } - gpx, err := generateTourGPX(detailedTour) - if err != nil { - app.Logger().Warn(fmt.Sprintf("Unable to generate GPX for tour '%s': %v", tour.Name, err)) - continue - } - trailid, err := createTrailFromTour(app, k, detailedTour, gpx, user, actor.Id, i.Privacy) - if err != nil { - app.Logger().Warn(fmt.Sprintf("Unable to create trail for tour '%s': %v", tour.Name, err)) - continue - } - err = createWaypointsFromTour(app, detailedTour, actor.Id, trailid) - if err != nil { - app.Logger().Warn(fmt.Sprintf("Unable to create waypoints for tour '%s': %v", tour.Name, err)) - continue - } - if err := trailmerge.TryAutoMergeImportedTrail(app, client, ctx, actor, trailid, i.Merge); err != nil { - app.Logger().Warn(fmt.Sprintf("Unable to auto-merge imported komoot tour '%s': %v", tour.Name, err)) - } - - } - return allAlreadySynced, nil -} - -func createTrailFromTour(app core.App, k *KomootApi, detailedTour *DetailedKomootTour, gpx *filesystem.File, user string, actor string, privacy string) (string, error) { - trailid := security.RandomStringWithAlphabet(core.DefaultIdLength, core.DefaultIdAlphabet) - - collection, err := app.FindCollectionByNameOrId("trails") - if err != nil { - return "", err - } - - record := core.NewRecord(collection) - - categoryMap := map[string]string{ - "hike": "Hiking", - "touringbicycle": "Biking", - "mtb": "Biking", - "racebike": "Biking", - "jogging": "Walking", - "mtb_easy": "Workout", - "mtb_advanced": "Walking", - "mountaineering": "Hiking", - } - - category, _ := app.FindFirstRecordByData("categories", "name", categoryMap[detailedTour.Sport]) - categoryId := "" - if category != nil { - categoryId = category.Id - } - - var photos []*filesystem.File - if len(detailedTour.Embedded.CoverImages.Embedded.Items) > 0 { - photos, err = fetchRoutePhotos(k, detailedTour) - if err != nil { - return "", err - } - } else { - photo, err := fetchPhoto(detailedTour.MapImage.Src, "", "") - if err != nil { - return "", err - } - photos = append(photos, photo) - } - - diffculty := detailedTour.Difficulty.Grade - if diffculty == "" { - diffculty = "easy" - } - - public := detailedTour.Status == "public" - if privacy == "settings" { - privacySettings := struct { - Trails string `json:"trails"` - }{} - - settings, _ := app.FindFirstRecordByData("settings", "user", user) - err = settings.UnmarshalJSONField("privacy", &privacySettings) - if err != nil { - return "", err - } - public = privacySettings.Trails == "public" - } - - record.Load(map[string]any{ - "id": trailid, - "name": detailedTour.Name, - "public": public, - "completed": detailedTour.Type == "tour_recorded", - "distance": detailedTour.Distance, - "elevation_gain": detailedTour.ElevationUp, - "elevation_loss": detailedTour.ElevationDown, - "duration": detailedTour.Duration, - "date": detailedTour.Date, - "external_provider": "komoot", - "external_id": strconv.Itoa(detailedTour.ID), - "lat": detailedTour.StartPoint.Lat, - "lon": detailedTour.StartPoint.Lng, - "difficulty": diffculty, - "category": categoryId, - "author": actor, - }) - - if photos != nil { - record.Set("photos", photos) - } - if gpx != nil { - record.Set("gpx", gpx) - } - - if err := app.Save(record); err != nil { - return "", err - } - if err := util.EnsureTrailExternalReference(app, trailid, "komoot", strconv.Itoa(detailedTour.ID)); err != nil { - return "", err - } - - if detailedTour.Type == "tour_recorded" { - collection, err := app.FindCollectionByNameOrId("summit_logs") - if err != nil { - return "", err - } - - summitLogRecord := core.NewRecord(collection) - summitLogRecord.Load(map[string]any{ - "distance": detailedTour.Distance, - "elevation_gain": detailedTour.ElevationUp, - "elevation_loss": detailedTour.ElevationDown, - "duration": detailedTour.Duration, - "date": detailedTour.Date, - "author": actor, - "trail": trailid, - }) - if err := app.Save(summitLogRecord); err != nil { - return "", err - } - } - - return trailid, nil -} - -func createWaypointsFromTour(app core.App, tour *DetailedKomootTour, actor string, trailid string) error { - collection, err := app.FindCollectionByNameOrId("waypoints") - if err != nil { - return err - } - - for _, wp := range tour.Embedded.Timeline.Embedded.Items { - photos, err := fetchWaypointPhotos(wp) - if err != nil { - return err - } - record := core.NewRecord(collection) - - wpDescription := "" - if len(wp.Embedded.Reference.Embedded.Tips.Embedded.Items) > 0 { - wpDescription = wp.Embedded.Reference.Embedded.Tips.Embedded.Items[0].Text - } - - wpLat := wp.Embedded.Reference.StartPoint.Lat - if wpLat == 0 { - wpLat = tour.StartPoint.Lat - } - - wpLon := wp.Embedded.Reference.StartPoint.Lng - if wpLon == 0 { - wpLon = tour.StartPoint.Lng - } - - record.Load(map[string]any{ - "name": wp.Embedded.Reference.Name, - "description": wpDescription, - "lat": wpLat, - "lon": wpLon, - "icon": "circle", - "author": actor, - "distance_from_start": 0, - "trail": trailid, - }) - - if photos != nil { - record.Set("photos", photos) - } - - if err := app.Save(record); err != nil { - return err - } - } - - return nil -} - -func fetchRoutePhotos(k *KomootApi, tour *DetailedKomootTour) ([]*filesystem.File, error) { - url := fmt.Sprintf("https://api.komoot.de/v007/tours/%d/cover_images/", tour.ID) - body, err := sendRequest(url, k.buildHeader()) - if err != nil { - return nil, err - } - - var data *CoverImages - err = json.Unmarshal(body, &data) - if err != nil { - return nil, err - } - - photos := make([]*filesystem.File, 0, len(data.Embedded.Items)) - - for _, img := range data.Embedded.Items { - photo, err := fetchPhoto(img.Src, "", "") - if err != nil { - return nil, err - } - if strings.HasSuffix(photo.Name, ".gif") { - continue - } - photos = append(photos, photo) - - //TODO: komoot photos can have location data. Maybe we should create a waypoint for those photos? - } - - return photos, nil -} - -func fetchWaypointPhotos(wp Item) ([]*filesystem.File, error) { - - photos := make([]*filesystem.File, 0, len(wp.Embedded.Reference.Embedded.Images.Embedded.Items)) - - for _, img := range wp.Embedded.Reference.Embedded.Images.Embedded.Items { - photo, err := fetchPhoto(img.Src, "", "") - if err != nil { - return nil, err - } - if strings.HasSuffix(photo.Name, ".gif") { - continue - } - photos = append(photos, photo) - } - - return photos, nil -} - -func fetchPhoto(url string, width string, height string) (*filesystem.File, error) { - url = strings.Replace(url, "{crop}", "false", 1) - url = strings.Replace(url, "{width}", width, 1) - url = strings.Replace(url, "{height}", height, 1) - - bytes, err := sendRequest(url, nil) - if err != nil { - return nil, err - } - - return filesystem.NewFileFromBytes(bytes, "photo") -} - -func generateTourGPX(detailedTour *DetailedKomootTour) (*filesystem.File, error) { - var points []gpx.GPXPoint - - for _, item := range detailedTour.Embedded.Coordinates.Items { - t := detailedTour.Date.Unix() + int64(item.T/1000) - - points = append(points, gpx.GPXPoint{ - Point: gpx.Point{Latitude: item.Lat, Longitude: item.Lng, Elevation: *gpx.NewNullableFloat64(item.Alt)}, - Timestamp: time.Unix(t, 0)}) - } - - gpxData := &gpx.GPX{ - Version: "1.1", - Creator: "komoot GPX Exporter", - Tracks: []gpx.GPXTrack{ - { - Name: detailedTour.Name, - Segments: []gpx.GPXTrackSegment{ - { - Points: points, - }, - }, - }, - }, - } - gpxAsXML, err := gpxData.ToXml(gpx.ToXmlParams{Version: "1.1", Indent: true}) - if err != nil { - return nil, err - } - - gpxFile, err := filesystem.NewFileFromBytes(gpxAsXML, detailedTour.Name+".gpx") - if err != nil { - return nil, err - } - - return gpxFile, nil -} diff --git a/db/integrations/komoot/models.go b/db/integrations/komoot/models.go deleted file mode 100644 index 7ee15e61..00000000 --- a/db/integrations/komoot/models.go +++ /dev/null @@ -1,399 +0,0 @@ -package komoot - -import ( - "time" - - "pocketbase/services/trailmerge" -) - -type KomootIntegration struct { - Active bool `json:"active"` - Email string `json:"email"` - Password string `json:"password"` - Planned bool `json:"planned"` - Completed bool `json:"completed"` - Privacy string `json:"privacy"` - Merge trailmerge.IntegrationAutoMergeSettings `json:"merge"` -} - -type LoginResponse struct { - Email string `json:"email"` - Password string `json:"password"` - User User `json:"user"` - Username string `json:"username"` -} - -type Content struct { - HasImage bool `json:"hasImage"` -} - -type Fitness struct { - Personalised bool `json:"personalised"` -} - -type User struct { - Content Content `json:"content"` - CreatedAt string `json:"createdAt"` - Displayname string `json:"displayname"` - Fitness Fitness `json:"fitness"` - ImageURL string `json:"imageUrl"` - Locale string `json:"locale"` - Metric bool `json:"metric"` - Newsletter bool `json:"newsletter"` - State string `json:"state"` - Username string `json:"username"` - WelcomeMails bool `json:"welcomeMails"` -} - -type KomootToursResponse struct { - Embedded Embedded `json:"_embedded"` - Links ResponseLinks `json:"_links"` - Page Page `json:"page"` -} -type StartPoint struct { - Lat float64 `json:"lat"` - Lng float64 `json:"lng"` - Alt float64 `json:"alt"` -} -type Surfaces struct { - Type string `json:"type"` - Amount float64 `json:"amount"` -} -type WayTypes struct { - Type string `json:"type"` - Amount float64 `json:"amount"` -} -type Summary struct { - Surfaces []Surfaces `json:"surfaces"` - WayTypes []WayTypes `json:"way_types"` -} -type Difficulty struct { - Grade string `json:"grade"` - ExplanationTechnical string `json:"explanation_technical"` - ExplanationFitness string `json:"explanation_fitness"` -} -type Location struct { - Lat float64 `json:"lat"` - Lng float64 `json:"lng"` -} -type Path struct { - Location Location `json:"location"` - Index int `json:"index"` - Reference string `json:"reference,omitempty"` - EndIndex int `json:"end_index,omitempty"` - SegmentType string `json:"segment_type,omitempty"` -} -type Segments struct { - Type string `json:"type"` - From int `json:"from"` - To int `json:"to"` -} -type MapImage struct { - Src string `json:"src"` - Templated bool `json:"templated"` - Type string `json:"type"` - Attribution string `json:"attribution"` -} -type MapImagePreview struct { - Src string `json:"src"` - Templated bool `json:"templated"` - Type string `json:"type"` - Attribution string `json:"attribution"` -} -type VectorMapImage struct { - Src string `json:"src"` - Templated bool `json:"templated"` - Type string `json:"type"` - Attribution string `json:"attribution"` -} -type VectorMapImagePreview struct { - Src string `json:"src"` - Templated bool `json:"templated"` - Type string `json:"type"` - Attribution string `json:"attribution"` -} - -type Relation struct { - Href string `json:"href"` - Templated bool `json:"templated"` -} -type CreatorLinks struct { - Relation Relation `json:"relation"` -} - -type LinksEmbedded struct { - Creator Creator `json:"creator"` -} -type LinksCreator struct { - Href string `json:"href"` -} -type LinksCoordinates struct { - Href string `json:"href"` -} -type LinksTourLine struct { - Href string `json:"href"` -} -type LinksParticipants struct { - Href string `json:"href"` -} -type LinksWayTypes struct { - Href string `json:"href"` -} -type LinksSurfaces struct { - Href string `json:"href"` -} -type LinksDirections struct { - Href string `json:"href"` -} -type LinksTimeline struct { - Href string `json:"href"` -} -type LinksTranslations struct { - Href string `json:"href"` -} -type LinksCoverImages struct { - Href string `json:"href"` -} -type LinksTourRating struct { - Href string `json:"href"` -} -type TourLinks struct { - Creator LinksCreator `json:"creator"` - Coordinates LinksCoordinates `json:"coordinates"` - TourLine LinksTourLine `json:"tour_line"` - Participants LinksParticipants `json:"participants"` - WayTypes LinksWayTypes `json:"way_types"` - Surfaces LinksSurfaces `json:"surfaces"` - Directions LinksDirections `json:"directions"` - Timeline LinksTimeline `json:"timeline"` - Translations LinksTranslations `json:"translations"` - CoverImages LinksCoverImages `json:"cover_images"` - TourRating LinksTourRating `json:"tour_rating"` -} -type KomootTour struct { - ID int `json:"id"` - Type string `json:"type"` - Name string `json:"name"` - Source string `json:"source"` - RoutingVersion string `json:"routing_version"` - Status string `json:"status"` - Date time.Time `json:"date"` - KcalActive int `json:"kcal_active"` - KcalResting int `json:"kcal_resting"` - StartPoint StartPoint `json:"start_point"` - Distance float64 `json:"distance"` - Duration int `json:"duration"` - ElevationUp float64 `json:"elevation_up"` - ElevationDown float64 `json:"elevation_down"` - Sport string `json:"sport"` - Query string `json:"query"` - Constitution int `json:"constitution"` - Summary Summary `json:"summary"` - Difficulty Difficulty `json:"difficulty"` - TourInformation []any `json:"tour_information"` - Path []Path `json:"path"` - Segments []Segments `json:"segments"` - ChangedAt time.Time `json:"changed_at"` - MapImage MapImage `json:"map_image"` - MapImagePreview MapImagePreview `json:"map_image_preview"` - VectorMapImage VectorMapImage `json:"vector_map_image"` - VectorMapImagePreview VectorMapImagePreview `json:"vector_map_image_preview"` - PotentialRouteUpdate bool `json:"potential_route_update"` - Embedded Embedded `json:"_embedded"` - Links TourLinks `json:"_links"` -} -type Embedded struct { - Tours []KomootTour `json:"tours"` -} -type Next struct { - Href string `json:"href"` -} -type ResponseLinks struct { - Next Next `json:"next"` -} -type Page struct { - Size int `json:"size"` - TotalElements int `json:"totalElements"` - TotalPages int `json:"totalPages"` - Number int `json:"number"` -} - -type DetailedKomootTour struct { - ID int `json:"id"` - Type string `json:"type"` - Name string `json:"name"` - Status string `json:"status"` - Date time.Time `json:"date"` - KcalActive float64 `json:"kcal_active"` - KcalResting float64 `json:"kcal_resting"` - StartPoint StartPoint `json:"start_point"` - Distance float64 `json:"distance"` - Duration int `json:"duration"` - ElevationUp float64 `json:"elevation_up"` - ElevationDown float64 `json:"elevation_down"` - Sport string `json:"sport"` - MapImage MapImage `json:"map_image"` - Difficulty Difficulty `json:"difficulty"` - ChangedAt time.Time `json:"changed_at"` - Embedded DetailedTourEmbedded `json:"_embedded"` -} - -type Items struct { - Lat float64 `json:"lat"` - Lng float64 `json:"lng"` - Alt float64 `json:"alt"` - T int `json:"t"` -} - -type Coordinates struct { - Items []Items `json:"items"` -} - -type DetailedTourEmbedded struct { - Coordinates Coordinates `json:"coordinates"` - Timeline Timeline `json:"timeline"` - CoverImages CoverImages `json:"cover_images"` -} - -type CoverImages struct { - Embedded CoverImagesEmbedded `json:"_embedded"` - Links Links `json:"_links"` - Page Page `json:"page"` -} - -type CoverImagesEmbedded struct { - Items []ImageItem `json:"items"` -} - -type Timeline struct { - Embedded TimelineEmbedded `json:"_embedded"` - Links Links `json:"_links"` - Page Page `json:"page"` -} - -type TimelineEmbedded struct { - Items []Item `json:"items"` -} - -type Item struct { - Index int `json:"index"` - Cover int `json:"cover"` - Type string `json:"type"` - Embedded TimelineItemEmbedded `json:"_embedded"` -} - -type TimelineItemEmbedded struct { - Reference Reference `json:"reference"` -} - -type Reference struct { - ID int `json:"id"` - Type string `json:"type"` - BaseName string `json:"base_name"` - Name string `json:"name"` - CreatedAt time.Time `json:"created_at"` - ChangedAt time.Time `json:"changed_at"` - Sport string `json:"sport"` - Routable bool `json:"routable"` - StartPoint Point `json:"start_point"` - MidPoint Point `json:"mid_point"` - EndPoint Point `json:"end_point"` - Distance float64 `json:"distance"` - ElevationUp float64 `json:"elevation_up"` - ElevationDown float64 `json:"elevation_down"` - Score float64 `json:"score"` - WikiPOIID string `json:"wiki_poi_id"` - PoorQuality bool `json:"poor_quality"` - Categories []string `json:"categories"` - Flagged bool `json:"flagged"` - Links Links `json:"_links"` - Embedded SubEmbedded `json:"_embedded"` -} - -type Point struct { - Lat float64 `json:"lat"` - Lng float64 `json:"lng"` - Alt float64 `json:"alt"` -} - -type Links struct { - Self Link `json:"self"` -} - -type Link struct { - Href string `json:"href"` - Templated bool `json:"templated,omitempty"` -} - -type SubEmbedded struct { - Creator Creator `json:"creator"` - Images Images `json:"images"` - Tips Tips `json:"tips"` -} - -type Creator struct { - Username string `json:"username"` - Avatar Avatar `json:"avatar"` - Status string `json:"status"` - Links Links `json:"_links"` - DisplayName string `json:"display_name"` - IsPremium bool `json:"is_premium"` -} - -type Avatar struct { - Src string `json:"src"` - Templated bool `json:"templated"` - Type string `json:"type"` -} - -type Images struct { - Embedded ImagesEmbedded `json:"_embedded"` - Links Links `json:"_links"` - Page Page `json:"page"` -} - -type ImagesEmbedded struct { - Items []ImageItem `json:"items"` -} - -type ImageItem struct { - ID int `json:"id"` - Src string `json:"src"` - Rating Rating `json:"rating"` - Templated bool `json:"templated"` - HighlightID int `json:"highlight_id"` - ClientHash string `json:"client_hash,omitempty"` - Location Location `json:"location"` - Type string `json:"type"` - Links Links `json:"_links"` - Embedded SubEmbedded `json:"_embedded"` -} - -type Rating struct { - Up int `json:"up"` - Down int `json:"down"` -} - -type Tips struct { - Embedded TipsEmbedded `json:"_embedded"` - Links Links `json:"_links"` - Page Page `json:"page"` -} - -type TipsEmbedded struct { - Items []TipItem `json:"items"` -} - -type TipItem struct { - ID int `json:"id"` - Text string `json:"text"` - Rating Rating `json:"rating"` - CreatedAt time.Time `json:"created_at"` - TextLanguage string `json:"text_language"` - TranslatedText string `json:"translated_text"` - TranslatedTextLanguage string `json:"translated_text_language"` - Attribution string `json:"attribution"` - HighlightID int `json:"highlight_id"` - Links Links `json:"_links"` - Embedded SubEmbedded `json:"_embedded"` -} diff --git a/db/integrations/strava/models.go b/db/integrations/strava/models.go deleted file mode 100644 index c26f1012..00000000 --- a/db/integrations/strava/models.go +++ /dev/null @@ -1,391 +0,0 @@ -package strava - -import ( - "time" - - "pocketbase/services/trailmerge" -) - -type TokenRequest struct { - ClientID int32 `json:"client_id"` - ClientSecret string `json:"client_secret"` - Code string `json:"code"` - GrantType string `json:"grant_type"` -} - -type RefreshTokenRequest struct { - ClientID int32 `json:"client_id"` - ClientSecret string `json:"client_secret"` - RefreshToken string `json:"refresh_token"` - GrantType string `json:"grant_type"` -} -type RefreshTokenResponse struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - ExpiresAt int64 `json:"expires_at"` -} -type StravaIntegration struct { - Active bool `json:"active"` - Routes bool `json:"routes"` - Activities bool `json:"activities"` - ClientID int32 `json:"clientId"` - ClientSecret string `json:"clientSecret"` - AccessToken string `json:"accessToken,omitempty"` - RefreshToken string `json:"refreshToken,omitempty"` - ExpiresAt int64 `json:"expiresAt,omitempty"` - Privacy string `json:"privacy"` - After string `json:"after,omitempty"` - Merge trailmerge.IntegrationAutoMergeSettings `json:"merge"` -} -type StravaRoute struct { - Athlete Athlete `json:"athlete"` - Description string `json:"description"` - Distance float32 `json:"distance"` - ElevationGain float32 `json:"elevation_gain"` - ID int64 `json:"id"` - IDStr string `json:"id_str"` - Map Map `json:"map"` - Name string `json:"name"` - Private bool `json:"private"` - Starred bool `json:"starred"` - Timestamp int `json:"timestamp"` - Type int `json:"type"` - SubType int `json:"sub_type"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - EstimatedMovingTime int `json:"estimated_moving_time"` - Segments []Segments `json:"segments"` - Waypoints []Waypoints `json:"waypoints"` -} - -type Athlete struct { - ID int64 `json:"id"` - ResourceState int `json:"resource_state"` - Firstname string `json:"firstname"` - Lastname string `json:"lastname"` - ProfileMedium string `json:"profile_medium"` - Profile string `json:"profile"` - City string `json:"city"` - State string `json:"state"` - Country string `json:"country"` - Sex string `json:"sex"` - Premium bool `json:"premium"` - Summit bool `json:"summit"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type Map struct { - ID string `json:"id"` - Polyline string `json:"polyline"` - SummaryPolyline string `json:"summary_polyline"` -} - -type AthletePrEffort struct { - PrActivityID int64 `json:"pr_activity_id"` - PrElapsedTime int `json:"pr_elapsed_time"` - PrDate time.Time `json:"pr_date"` - EffortCount int `json:"effort_count"` -} - -type AthleteSegmentStats struct { - ID int `json:"id"` - ActivityID int `json:"activity_id"` - ElapsedTime int `json:"elapsed_time"` - StartDate time.Time `json:"start_date"` - StartDateLocal time.Time `json:"start_date_local"` - Distance float32 `json:"distance"` - IsKom bool `json:"is_kom"` -} - -type Segments struct { - ID int64 `json:"id"` - Name string `json:"name"` - ActivityType string `json:"activity_type"` - Distance float32 `json:"distance"` - AverageGrade float32 `json:"average_grade"` - MaximumGrade float32 `json:"maximum_grade"` - ElevationHigh float32 `json:"elevation_high"` - ElevationLow float32 `json:"elevation_low"` - StartLatlng []float32 `json:"start_latlng"` - EndLatlng []float32 `json:"end_latlng"` - ClimbCategory int `json:"climb_category"` - City string `json:"city"` - State string `json:"state"` - Country string `json:"country"` - Private bool `json:"private"` - AthletePrEffort AthletePrEffort `json:"athlete_pr_effort"` - AthleteSegmentStats AthleteSegmentStats `json:"athlete_segment_stats"` -} - -type Waypoints struct { - Latlng []float32 `json:"latlng"` - TargetLatlng []float32 `json:"target_latlng"` - Categories []string `json:"categories"` - Title string `json:"title"` - Description string `json:"description"` - DistanceIntoRoute float64 `json:"distance_into_route"` -} - -type StravaActivity struct { - ResourceState int `json:"resource_state"` - Athlete Athlete `json:"athlete"` - Name string `json:"name"` - Distance float64 `json:"distance"` - MovingTime int `json:"moving_time"` - ElapsedTime int `json:"elapsed_time"` - TotalElevationGain float64 `json:"total_elevation_gain"` - Type string `json:"type"` - SportType string `json:"sport_type"` - WorkoutType any `json:"workout_type"` - ID int64 `json:"id"` - ExternalID string `json:"external_id"` - UploadID int64 `json:"upload_id"` - StartDate time.Time `json:"start_date"` - StartDateLocal time.Time `json:"start_date_local"` - Timezone string `json:"timezone"` - StartLatlng any `json:"start_latlng"` - EndLatlng any `json:"end_latlng"` - LocationCity any `json:"location_city"` - LocationState any `json:"location_state"` - LocationCountry string `json:"location_country"` - AchievementCount int `json:"achievement_count"` - KudosCount int `json:"kudos_count"` - CommentCount int `json:"comment_count"` - AthleteCount int `json:"athlete_count"` - PhotoCount int `json:"photo_count"` - Map Map `json:"map"` - Trainer bool `json:"trainer"` - Commute bool `json:"commute"` - Manual bool `json:"manual"` - Private bool `json:"private"` - Flagged bool `json:"flagged"` - GearID string `json:"gear_id"` - FromAcceptedTag bool `json:"from_accepted_tag"` - AverageSpeed float64 `json:"average_speed"` - MaxSpeed float64 `json:"max_speed"` - AverageCadence float64 `json:"average_cadence"` - AverageWatts float64 `json:"average_watts"` - WeightedAverageWatts int `json:"weighted_average_watts"` - Kilojoules float64 `json:"kilojoules"` - DeviceWatts bool `json:"device_watts"` - HasHeartrate bool `json:"has_heartrate"` - AverageHeartrate float64 `json:"average_heartrate"` - MaxHeartrate float64 `json:"max_heartrate"` - MaxWatts int `json:"max_watts"` - PrCount int `json:"pr_count"` - TotalPhotoCount int `json:"total_photo_count"` - HasKudoed bool `json:"has_kudoed"` - SufferScore float64 `json:"suffer_score"` -} - -type DetailedStravaActivity struct { - ID int64 `json:"id"` - ResourceState int `json:"resource_state"` - ExternalID string `json:"external_id"` - UploadID int64 `json:"upload_id"` - Athlete Athlete `json:"athlete"` - Name string `json:"name"` - Distance float64 `json:"distance"` - MovingTime int `json:"moving_time"` - ElapsedTime int `json:"elapsed_time"` - TotalElevationGain float64 `json:"total_elevation_gain"` - Type string `json:"type"` - SportType string `json:"sport_type"` - StartDate time.Time `json:"start_date"` - StartDateLocal time.Time `json:"start_date_local"` - Timezone string `json:"timezone"` - StartLatlng []float64 `json:"start_latlng"` - EndLatlng []float64 `json:"end_latlng"` - AchievementCount int `json:"achievement_count"` - KudosCount int `json:"kudos_count"` - CommentCount int `json:"comment_count"` - AthleteCount int `json:"athlete_count"` - PhotoCount int `json:"photo_count"` - Map Map `json:"map"` - Trainer bool `json:"trainer"` - Commute bool `json:"commute"` - Manual bool `json:"manual"` - Private bool `json:"private"` - Flagged bool `json:"flagged"` - GearID string `json:"gear_id"` - FromAcceptedTag bool `json:"from_accepted_tag"` - AverageSpeed float64 `json:"average_speed"` - MaxSpeed float64 `json:"max_speed"` - AverageCadence float64 `json:"average_cadence"` - AverageTemp int `json:"average_temp"` - AverageWatts float64 `json:"average_watts"` - WeightedAverageWatts int `json:"weighted_average_watts"` - Kilojoules float64 `json:"kilojoules"` - DeviceWatts bool `json:"device_watts"` - HasHeartrate bool `json:"has_heartrate"` - MaxWatts int `json:"max_watts"` - ElevHigh float64 `json:"elev_high"` - ElevLow float64 `json:"elev_low"` - PrCount int `json:"pr_count"` - TotalPhotoCount int `json:"total_photo_count"` - HasKudoed bool `json:"has_kudoed"` - WorkoutType int `json:"workout_type"` - SufferScore float64 `json:"suffer_score"` - Description string `json:"description"` - Calories float64 `json:"calories"` - SegmentEfforts []SegmentEfforts `json:"segment_efforts"` - SplitsMetric []SplitsMetric `json:"splits_metric"` - Laps []Laps `json:"laps"` - Gear Gear `json:"gear"` - PartnerBrandTag any `json:"partner_brand_tag"` - Photos Photos `json:"photos"` - HighlightedKudosers []HighlightedKudosers `json:"highlighted_kudosers"` - HideFromHome bool `json:"hide_from_home"` - DeviceName string `json:"device_name"` - EmbedToken string `json:"embed_token"` - SegmentLeaderboardOptOut bool `json:"segment_leaderboard_opt_out"` - LeaderboardOptOut bool `json:"leaderboard_opt_out"` -} - -type SegmentActivity struct { - ID int64 `json:"id"` - ResourceState int `json:"resource_state"` -} - -type Segment struct { - ID int64 `json:"id"` - ResourceState int `json:"resource_state"` - Name string `json:"name"` - ActivityType string `json:"activity_type"` - Distance float64 `json:"distance"` - AverageGrade float64 `json:"average_grade"` - MaximumGrade float64 `json:"maximum_grade"` - ElevationHigh float64 `json:"elevation_high"` - ElevationLow float64 `json:"elevation_low"` - StartLatlng []float64 `json:"start_latlng"` - EndLatlng []float64 `json:"end_latlng"` - ClimbCategory int `json:"climb_category"` - City string `json:"city"` - State string `json:"state"` - Country string `json:"country"` - Private bool `json:"private"` - Hazardous bool `json:"hazardous"` - Starred bool `json:"starred"` -} - -type SegmentEfforts struct { - ID int64 `json:"id"` - ResourceState int `json:"resource_state"` - Name string `json:"name"` - Activity SegmentActivity `json:"activity"` - Athlete Athlete `json:"athlete"` - ElapsedTime int `json:"elapsed_time"` - MovingTime int `json:"moving_time"` - StartDate time.Time `json:"start_date"` - StartDateLocal time.Time `json:"start_date_local"` - Distance float64 `json:"distance"` - StartIndex int `json:"start_index"` - EndIndex int `json:"end_index"` - AverageCadence float64 `json:"average_cadence"` - DeviceWatts bool `json:"device_watts"` - AverageWatts float64 `json:"average_watts"` - Segment Segment `json:"segment"` - KomRank any `json:"kom_rank"` - PrRank any `json:"pr_rank"` - Achievements []any `json:"achievements"` - Hidden bool `json:"hidden"` -} - -type SplitsMetric struct { - Distance float64 `json:"distance"` - ElapsedTime int `json:"elapsed_time"` - ElevationDifference float64 `json:"elevation_difference"` - MovingTime int `json:"moving_time"` - Split int `json:"split"` - AverageSpeed float64 `json:"average_speed"` - PaceZone int `json:"pace_zone"` -} - -type Laps struct { - ID int64 `json:"id"` - ResourceState int `json:"resource_state"` - Name string `json:"name"` - Activity SegmentActivity `json:"activity"` - Athlete Athlete `json:"athlete"` - ElapsedTime int `json:"elapsed_time"` - MovingTime int `json:"moving_time"` - StartDate time.Time `json:"start_date"` - StartDateLocal time.Time `json:"start_date_local"` - Distance float64 `json:"distance"` - StartIndex int `json:"start_index"` - EndIndex int `json:"end_index"` - TotalElevationGain float64 `json:"total_elevation_gain"` - AverageSpeed float64 `json:"average_speed"` - MaxSpeed float64 `json:"max_speed"` - AverageCadence float64 `json:"average_cadence"` - DeviceWatts bool `json:"device_watts"` - AverageWatts float64 `json:"average_watts"` - LapIndex int `json:"lap_index"` - Split int `json:"split"` -} - -type Gear struct { - ID string `json:"id"` - Primary bool `json:"primary"` - Name string `json:"name"` - ResourceState int `json:"resource_state"` - Distance int `json:"distance"` -} - -type Urls struct { - Num100 string `json:"100"` - Num600 string `json:"600"` -} - -type Primary struct { - ID any `json:"id"` - UniqueID string `json:"unique_id"` - Urls Urls `json:"urls"` - Source int `json:"source"` -} - -type Photos struct { - Primary Primary `json:"primary"` - UsePrimaryPhoto bool `json:"use_primary_photo"` - Count int `json:"count"` -} - -type StravaActivityPhoto struct { - UniqueID string `json:"unique_id"` - Urls Urls `json:"urls"` -} - -type HighlightedKudosers struct { - DestinationURL string `json:"destination_url"` - DisplayName string `json:"display_name"` - AvatarURL string `json:"avatar_url"` - ShowName bool `json:"show_name"` -} - -type ActivityStreamResponse struct { - LatLng LatLngStream `json:"latlng"` - Altitude AltitudeStream `json:"altitude"` - Time TimeStream `json:"time"` -} - -type ActivityStream struct { - OriginalSize int `json:"original_size"` - Resolution string `json:"resolution"` - SeriesType string `json:"series_type"` -} - -type TimeStream struct { - ActivityStream - Data []int `json:"data"` -} - -type LatLngStream struct { - ActivityStream - Data [][]float64 `json:"data"` -} - -type AltitudeStream struct { - ActivityStream - Data []float64 `json:"data"` -} diff --git a/db/integrations/strava/strava.go b/db/integrations/strava/strava.go deleted file mode 100644 index 25605f7a..00000000 --- a/db/integrations/strava/strava.go +++ /dev/null @@ -1,761 +0,0 @@ -package strava - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "os" - "strconv" - "time" - - "github.com/meilisearch/meilisearch-go" - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/tools/filesystem" - "github.com/pocketbase/pocketbase/tools/security" - "github.com/tkrajina/gpxgo/gpx" - "github.com/twpayne/go-polyline" - - "pocketbase/services/trailmerge" - "pocketbase/util" -) - -type StravaApi struct { - AceessToken string -} - -func SyncStrava(app core.App, client meilisearch.ServiceManager) error { - integrations, err := app.FindAllRecords("integrations", dbx.NewExp("true")) - if err != nil { - return err - } - - for _, i := range integrations { - encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY") - if len(encryptionKey) == 0 { - return errors.New("POCKETBASE_ENCRYPTION_KEY not set") - } - - userId := i.GetString("user") - actor, err := app.FindFirstRecordByData("activitypub_actors", "user", userId) - if err != nil { - warning := fmt.Sprintf("no actor found for user: %s\n", userId) - fmt.Print(warning) - app.Logger().Warn(warning) - continue - } - - ctx, err := util.GetSafeActorContext(nil, actor) - if err != nil { - continue - } - - stravaString := i.GetString("strava") - var stravaIntegration StravaIntegration - err = json.Unmarshal([]byte(stravaString), &stravaIntegration) - if err != nil { - return err - } - - if !stravaIntegration.Active || stravaIntegration.RefreshToken == "" { - continue - } - - decryptedSecret, err := security.Decrypt(stravaIntegration.ClientSecret, encryptionKey) - if err != nil { - return err - } - - decryptedRefreshToken, err := security.Decrypt(stravaIntegration.RefreshToken, encryptionKey) - if err != nil { - return err - } - - request := RefreshTokenRequest{ - ClientID: stravaIntegration.ClientID, - ClientSecret: string(decryptedSecret), - RefreshToken: string(decryptedRefreshToken), - GrantType: "refresh_token", - } - r, err := GetStravaToken(request) - if err != nil { - warning := fmt.Sprintf("error refreshing strava access token: %v\n", err) - fmt.Print(warning) - app.Logger().Warn(warning) - continue - } - if r.AccessToken != "" { - stravaIntegration.AccessToken = r.AccessToken - } - if r.RefreshToken != "" { - stravaIntegration.RefreshToken = r.RefreshToken - } - if r.AccessToken != "" { - stravaIntegration.ExpiresAt = r.ExpiresAt - } - - if stravaIntegration.Routes { - page := 1 - hasMore := true - for hasMore { - routes, err := fetchStravaRoutes(r.AccessToken, page) - hasMore = len(routes) > 0 - page += 1 - if err != nil { - warning := fmt.Sprintf("error fetching routes from strava: %v\n", err) - fmt.Print(warning) - app.Logger().Warn(warning) - break - } - err = syncTrailsWithRoutes(app, client, ctx, stravaIntegration, r.AccessToken, userId, actor, routes) - if err != nil { - warning := fmt.Sprintf("error syncing strava routes with trails: %v\n", err) - fmt.Print(warning) - app.Logger().Warn(warning) - continue - } - } - } - if stravaIntegration.Activities { - page := 1 - hasMore := true - for hasMore { - var after int64 = 0 - if stravaIntegration.After != "" { - t, err := time.Parse("2006-01-02", stravaIntegration.After) - if err != nil { - return err - } - t = t.UTC() - - after = t.Unix() - } - activities, err := fetchStravaActivities(r.AccessToken, page, after) - hasMore = len(activities) > 0 - page += 1 - if err != nil { - warning := fmt.Sprintf("error fetching activities from strava: %v", err) - fmt.Print(warning) - app.Logger().Warn(warning) - break - } - err = syncTrailsWithActivities(app, client, ctx, stravaIntegration, r.AccessToken, userId, actor, activities) - - if err != nil { - warning := fmt.Sprintf("error syncing strava activities with trails: %v", err) - fmt.Print(warning) - app.Logger().Warn(warning) - continue - } - } - - } - - b, err := json.Marshal(stravaIntegration) - if err != nil { - return err - } - i.Set("strava", string(b)) - err = app.Save(i) - if err != nil { - return err - } - } - - return nil -} - -func GetStravaToken(request any) (*RefreshTokenResponse, error) { - const stravaTokenURL = "https://www.strava.com/oauth/token" - - requestBody, err := json.Marshal(request) - if err != nil { - return nil, err - } - - req, err := http.NewRequest("POST", stravaTokenURL, bytes.NewBuffer(requestBody)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to get token: received status %d", resp.StatusCode) - } - - var tokenResponse RefreshTokenResponse - if err := json.NewDecoder(resp.Body).Decode(&tokenResponse); err != nil { - return nil, err - } - - return &tokenResponse, nil -} - -func fetchStravaRoutes(accessToken string, page int) ([]StravaRoute, error) { - stravaRoutesURL := fmt.Sprintf("https://www.strava.com/api/v3/athlete/routes?page=%d", page) - - req, err := http.NewRequest("GET", stravaRoutesURL, nil) - if err != nil { - return nil, err - } - req.Header.Set("Authorization", "Bearer "+accessToken) - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to fetch routes: received status %d", resp.StatusCode) - } - - var routes []StravaRoute - if err := json.NewDecoder(resp.Body).Decode(&routes); err != nil { - return nil, err - } - - return routes, nil -} - -func fetchStravaActivities(accessToken string, page int, after int64) ([]StravaActivity, error) { - stravaRoutesURL := fmt.Sprintf("https://www.strava.com/api/v3/athlete/activities?page=%d&after=%d", page, after) - req, err := http.NewRequest("GET", stravaRoutesURL, nil) - if err != nil { - return nil, err - } - req.Header.Set("Authorization", "Bearer "+accessToken) - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to fetch activities: received status %d", resp.StatusCode) - } - - var activities []StravaActivity - if err := json.NewDecoder(resp.Body).Decode(&activities); err != nil { - return nil, err - } - - return activities, nil -} - -func syncTrailsWithRoutes(app core.App, client meilisearch.ServiceManager, ctx context.Context, i StravaIntegration, accessToken string, user string, actor *core.Record, routes []StravaRoute) error { - for _, route := range routes { - existingTrail, err := util.FindTrailByExternalReference(app, "strava", route.IDStr) - if err != nil { - return err - } - if existingTrail != nil { - continue - } - gpx, err := fetchRouteGPX(route, accessToken) - if err != nil { - app.Logger().Warn(fmt.Sprintf("Unable to fetch GPX for route '%s': %v", route.Name, err)) - continue - } - trailid, err := createTrailFromRoute(app, route, gpx, user, actor.Id, i.Privacy) - if err != nil { - app.Logger().Warn(fmt.Sprintf("Unable to create trail for route '%s': %v", route.Name, err)) - continue - } - err = createWaypointsFromRoute(app, route, actor.Id, trailid) - if err != nil { - app.Logger().Warn(fmt.Sprintf("Unable to create waypoints for route '%s': %v", route.Name, err)) - continue - } - if err := trailmerge.TryAutoMergeImportedTrail(app, client, ctx, actor, trailid, i.Merge); err != nil { - app.Logger().Warn(fmt.Sprintf("Unable to auto-merge imported Strava route '%s': %v", route.Name, err)) - } - } - - return nil -} - -func fetchRouteGPX(route StravaRoute, accessToken string) (*filesystem.File, error) { - url := fmt.Sprintf("https://www.strava.com/api/v3/routes/%s/export_gpx", route.IDStr) - - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return nil, err - } - req.Header.Set("Authorization", "Bearer "+accessToken) - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer func() { - if resp.Body != nil { - resp.Body.Close() - } - }() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to fetch GPX: received status %d", resp.StatusCode) - } - - var buf bytes.Buffer - _, err = io.Copy(&buf, resp.Body) - if err != nil { - return nil, err - } - - gpxFile, err := filesystem.NewFileFromBytes(buf.Bytes(), route.Name+".gpx") - if err != nil { - return nil, err - } - - return gpxFile, nil -} - -func createTrailFromRoute(app core.App, route StravaRoute, gpx *filesystem.File, user string, actor string, privacy string) (string, error) { - trailid := security.RandomStringWithAlphabet(core.DefaultIdLength, core.DefaultIdAlphabet) - - collection, err := app.FindCollectionByNameOrId("trails") - if err != nil { - return "", err - } - - record := core.NewRecord(collection) - - buf := []byte(route.Map.SummaryPolyline) - coords, _, _ := polyline.DecodeCoords(buf) - - var lat, lon float64 - if len(coords) > 0 && len(coords[0]) >= 2 { - lat = coords[0][0] - lon = coords[0][1] - } else { - app.Logger().Warn("Warning: No coordinates available, setting lat/lon to 0") - lat, lon = 0, 0 - } - - bikeCategory, _ := app.FindFirstRecordByData("categories", "name", "Biking") - hikeCategory, _ := app.FindFirstRecordByData("categories", "name", "Walking") - - category := "" - - if route.Type == 1 && bikeCategory != nil { - category = bikeCategory.Id - } else if route.Type == 2 && hikeCategory != nil { - category = hikeCategory.Id - } - - public := !route.Private - - if privacy == "settings" { - privacySettings := struct { - Trails string `json:"trails"` - }{} - - settings, _ := app.FindFirstRecordByData("settings", "user", user) - err = settings.UnmarshalJSONField("privacy", &privacySettings) - if err != nil { - return "", err - } - - public = privacySettings.Trails == "public" - } - - record.Load(map[string]any{ - "id": trailid, - "name": route.Name, - "description": route.Description, - "public": public, - "distance": route.Distance, - "elevation_gain": route.ElevationGain, - "duration": route.EstimatedMovingTime, - "date": time.Unix(int64(route.Timestamp), 0), - "lat": lat, - "lon": lon, - "difficulty": "easy", - "category": category, - "author": actor, - }) - - if gpx != nil { - record.Set("gpx", gpx) - } - - if err := app.Save(record); err != nil { - return "", err - } - if err := util.EnsureTrailExternalReference(app, trailid, "strava", route.IDStr); err != nil { - return "", err - } - - return trailid, err -} - -func createWaypointsFromRoute(app core.App, route StravaRoute, actor string, trailid string) error { - collection, err := app.FindCollectionByNameOrId("waypoints") - if err != nil { - return err - } - - for i, wp := range route.Waypoints { - record := core.NewRecord(collection) - - record.Set("name", strconv.Itoa(i)) - record.Set("description", wp.Description) - record.Set("lat", wp.Latlng[0]) - record.Set("lon", wp.Latlng[1]) - record.Set("icon", "circle") - record.Set("author", actor) - record.Set("distance_from_start", wp.DistanceIntoRoute) - record.Set("trail", trailid) - - if err := app.Save(record); err != nil { - return err - } - - } - - return nil -} - -func syncTrailsWithActivities(app core.App, client meilisearch.ServiceManager, ctx context.Context, i StravaIntegration, accessToken string, user string, actor *core.Record, activities []StravaActivity) error { - for _, activity := range activities { - existingTrail, err := util.FindTrailByExternalReference(app, "strava", strconv.Itoa(int(activity.ID))) - if err != nil { - return err - } - if existingTrail != nil { - continue - } - detailedActivity, err := fetchDetailedActivity(activity, accessToken) - if err != nil { - app.Logger().Warn(fmt.Sprintf("Unable to fetch detailed activity '%s': %v", activity.Name, err)) - continue - } - gpx, err := generateActivityGPX(detailedActivity, accessToken) - if err != nil { - app.Logger().Warn(fmt.Sprintf("Unable to fetch GPX for activity '%s': %v", activity.Name, err)) - continue - } - trailID, err := createTrailFromActivity(app, detailedActivity, gpx, user, actor.Id, i.Privacy, accessToken) - if err != nil { - app.Logger().Warn(fmt.Sprintf("Unable to create trail from activity '%s': %v", activity.Name, err)) - continue - } - if err := trailmerge.TryAutoMergeImportedTrail(app, client, ctx, actor, trailID, i.Merge); err != nil { - app.Logger().Warn(fmt.Sprintf("Unable to auto-merge imported Strava activity '%s': %v", activity.Name, err)) - } - } - - return nil -} - -func fetchDetailedActivity(activity StravaActivity, accessToken string) (*DetailedStravaActivity, error) { - url := fmt.Sprintf("https://www.strava.com/api/v3/activities/%d", activity.ID) - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return nil, err - } - req.Header.Set("Authorization", "Bearer "+accessToken) - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to fetch activity: received status %d", resp.StatusCode) - } - - var detailedActivity DetailedStravaActivity - if err := json.NewDecoder(resp.Body).Decode(&detailedActivity); err != nil { - return nil, err - } - - return &detailedActivity, nil -} - -func createTrailFromActivity(app core.App, activity *DetailedStravaActivity, gpx *filesystem.File, user string, actor string, privacy string, accessToken string) (string, error) { - if len(activity.StartLatlng) < 2 { - return "", nil - } - - collection, err := app.FindCollectionByNameOrId("trails") - if err != nil { - return "", err - } - - var photos []*filesystem.File - if activity.Photos.Count > 0 { - photos, err = fetchActivityPhotos(activity.ID, accessToken) - if err != nil { - app.Logger().Warn(fmt.Sprintf("Failed to fetch activity photos for activity %d: %v", activity.ID, err)) - } - } - - // Fallback to primary photo if no photos were fetched but primary URL is available - if len(photos) == 0 && len(activity.Photos.Primary.Urls.Num600) > 0 { - photo, err := fetchPhotoFromURL(activity.Photos.Primary.Urls.Num600) - if err == nil { - photos = []*filesystem.File{photo} - } - } - - record := core.NewRecord(collection) - - activityMap := map[string]string{ - "AlpineSki": "Skiing", - "BackcountrySki": "Skiing", - "Canoeing": "Canoeing", - "Crossfit": "Workout", - "EBikeRide": "Biking", - "Elliptical": "Workout", - "Golf": "Walking", - "Handcycle": "Biking", - "Hike": "Hiking", - "IceSkate": "Skiing", - "InlineSkate": "Biking", - "Kayaking": "Canoeing", - "Kitesurf": "Canoeing", - "NordicSki": "Skiing", - "Ride": "Biking", - "RockClimbing": "Climbing", - "RollerSki": "Skiing", - "Rowing": "Canoeing", - "Run": "Walking", - "Sail": "Canoeing", - "Skateboard": "Walking", - "Snowboard": "Skiing", - "Snowshoe": "Hiking", - "Soccer": "Workout", - "StairStepper": "Workout", - "StandUpPaddling": "Canoeing", - "Surfing": "Canoeing", - "Swim": "Workout", - "Velomobile": "Biking", - "VirtualRide": "Biking", - "VirtualRun": "Walking", - "Walk": "Walking", - "WeightTraining": "Workout", - "Wheelchair": "Walking", - "Windsurf": "Canoeing", - "Workout": "Workout", - "Yoga": "Workout", - } - - category, _ := app.FindFirstRecordByData("categories", "name", activityMap[activity.Type]) - categoryId := "" - if category != nil { - categoryId = category.Id - } - - public := !activity.Private - - if privacy == "settings" { - privacySettings := struct { - Trails string `json:"trails"` - }{} - - settings, _ := app.FindFirstRecordByData("settings", "user", user) - err = settings.UnmarshalJSONField("privacy", &privacySettings) - if err != nil { - return "", err - } - - public = privacySettings.Trails == "public" - } - - record.Load(map[string]any{ - "name": activity.Name, - "description": activity.Description, - "public": public, - "distance": activity.Distance, - "elevation_gain": activity.TotalElevationGain, - "duration": activity.ElapsedTime, - "date": activity.StartDate, - "lat": activity.StartLatlng[0], - "lon": activity.StartLatlng[1], - "difficulty": "easy", - "category": categoryId, - "author": actor, - }) - - if len(photos) > 0 { - record.Set("photos", photos) - } - - if gpx != nil { - record.Set("gpx", gpx) - } - - if err := app.Save(record); err != nil { - return "", err - } - if err := util.EnsureTrailExternalReference(app, record.Id, "strava", strconv.Itoa(int(activity.ID))); err != nil { - return "", err - } - - return record.Id, nil -} - -func fetchPhotoFromURL(url string) (*filesystem.File, error) { - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return nil, err - } - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to fetch photo: received status %d", resp.StatusCode) - } - - var buf bytes.Buffer - _, err = io.Copy(&buf, resp.Body) - if err != nil { - return nil, err - } - - photo, err := filesystem.NewFileFromBytes(buf.Bytes(), "photo") - if err != nil { - return nil, err - } - - return photo, nil -} - -func fetchActivityPhotos(activityID int64, accessToken string) ([]*filesystem.File, error) { - url := fmt.Sprintf("https://www.strava.com/api/v3/activities/%d/photos?size=600", activityID) - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return nil, err - } - req.Header.Set("Authorization", "Bearer "+accessToken) - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to fetch activity photos: received status %d", resp.StatusCode) - } - - var apiPhotos []StravaActivityPhoto - if err := json.NewDecoder(resp.Body).Decode(&apiPhotos); err != nil { - return nil, err - } - - photos := make([]*filesystem.File, 0, len(apiPhotos)) - for _, apiPhoto := range apiPhotos { - photoURL := apiPhoto.Urls.Num600 - if photoURL == "" { - photoURL = apiPhoto.Urls.Num100 - } - if photoURL == "" { - continue - } - - photo, err := fetchPhotoFromURL(photoURL) - if err != nil { - continue - } - photos = append(photos, photo) - } - - return photos, nil -} - -func generateActivityGPX(activity *DetailedStravaActivity, accessToken string) (*filesystem.File, error) { - url := fmt.Sprintf("https://www.strava.com/api/v3/activities/%d/streams?keys=latlng,time,altitude&key_by_type=true", activity.ID) - - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return nil, err - } - req.Header.Set("Authorization", "Bearer "+accessToken) - - client := &http.Client{} - - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to fetch activity: %s", resp.Status) - } - - var streamResponse ActivityStreamResponse - if err := json.NewDecoder(resp.Body).Decode(&streamResponse); err != nil { - return nil, err - } - - latLngStream := streamResponse.LatLng - timeStream := streamResponse.Time - altitudeStream := streamResponse.Altitude - - var points []gpx.GPXPoint - - for i, latlng := range latLngStream.Data { - lat := latlng[0] - lon := latlng[1] - alt := altitudeStream.Data[i] - t := activity.StartDate.Unix() + int64(timeStream.Data[i]) - - points = append(points, gpx.GPXPoint{ - Point: gpx.Point{Latitude: lat, Longitude: lon, Elevation: *gpx.NewNullableFloat64(alt)}, - Timestamp: time.Unix(t, 0)}) - } - - gpxData := &gpx.GPX{ - Version: "1.1", - Creator: "Strava GPX Exporter", - Tracks: []gpx.GPXTrack{ - { - Name: activity.Name, - Segments: []gpx.GPXTrackSegment{ - { - Points: points, - }, - }, - }, - }, - } - gpxAsXML, err := gpxData.ToXml(gpx.ToXmlParams{Version: "1.1", Indent: true}) - if err != nil { - return nil, err - } - - gpxFile, err := filesystem.NewFileFromBytes(gpxAsXML, activity.Name+".gpx") - if err != nil { - return nil, err - } - - return gpxFile, nil -} diff --git a/db/main.go b/db/main.go index 1bc335ba..08cc9e92 100644 --- a/db/main.go +++ b/db/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "fmt" "log" "os" @@ -15,9 +16,7 @@ import ( "pocketbase/commands" "pocketbase/hooks" - "pocketbase/integrations/hammerhead" - "pocketbase/integrations/komoot" - "pocketbase/integrations/strava" + "pocketbase/pluginsystem" "pocketbase/routes" _ "pocketbase/migrations" @@ -56,6 +55,9 @@ func verifySettings(app core.App) { } func main() { + if len(os.Args) > 1 && os.Args[1] == "plugin-worker" { + os.Exit(pluginsystem.RunPluginWorker(context.Background(), os.Stdin, os.Stdout, os.Stderr)) + } app := pocketbase.New() client := initializeMeilisearch() @@ -124,11 +126,12 @@ func setupEventHandlers(app *pocketbase.PocketBase, client meilisearch.ServiceMa app.OnRecordCreateRequest("follows").BindFunc(hooks.CreateFollowHandler()) app.OnRecordDeleteRequest("follows").BindFunc(hooks.DeleteFollowHandler()) - app.OnRecordsListRequest("integrations").BindFunc(hooks.ListIntegrationHandler()) - app.OnRecordCreate("integrations").BindFunc(hooks.CreateIntegrationHandler()) - app.OnRecordAfterCreateSuccess("integrations").BindFunc(hooks.CreateUpdateIntegrationSuccessHandler()) - app.OnRecordUpdate("integrations").BindFunc(hooks.UpdateIntegrationHandler()) - app.OnRecordAfterUpdateSuccess("integrations").BindFunc(hooks.CreateUpdateIntegrationSuccessHandler()) + app.OnRecordsListRequest("plugin_instances").BindFunc(hooks.ListPluginInstanceHandler()) + app.OnRecordViewRequest("plugin_instances").BindFunc(hooks.ViewPluginInstanceHandler()) + app.OnRecordCreate("plugin_instances").BindFunc(hooks.CreatePluginInstanceHandler()) + app.OnRecordAfterCreateSuccess("plugin_instances").BindFunc(hooks.CreateUpdatePluginInstanceSuccessHandler()) + app.OnRecordUpdate("plugin_instances").BindFunc(hooks.UpdatePluginInstanceHandler()) + app.OnRecordAfterUpdateSuccess("plugin_instances").BindFunc(hooks.CreateUpdatePluginInstanceSuccessHandler()) app.OnRecordsListRequest("feed", "profile_feed").BindFunc(hooks.ListFeedHandler()) @@ -169,10 +172,14 @@ func registerRoutes(se *core.ServeEvent, client meilisearch.ServiceManager) { se.Router.GET("/search/token", routes.SearchToken(client)) - se.Router.POST("/integration/strava/token", routes.IntegrationStravaToken) - se.Router.POST("/integration/hammerhead/upload", routes.IntegrationHammerheadUpload) - se.Router.GET("/integration/hammerhead/login", routes.IntegrationHammerheadLogin) - se.Router.GET("/integration/komoot/login", routes.IntegrationKommotLogin) + se.Router.GET("/plugins", routes.PluginSystemPluginsList) + se.Router.POST("/plugins/trail-send", routes.PluginSystemTrailSend) + se.Router.POST("/plugins/auth/validate", routes.PluginSystemSessionAuthValidate) + se.Router.POST("/plugins/category-remap/preview", routes.PluginSystemCategoryRemapPreview) + se.Router.POST("/plugins/category-remap/apply", routes.PluginSystemCategoryRemapApply) + se.Router.POST("/plugins/oauth/start", routes.PluginSystemOAuthStart) + se.Router.POST("/plugins/oauth/callback", routes.PluginSystemOAuthCallback) + se.Router.POST("/plugins/oauth/revoke", routes.PluginSystemOAuthRevoke) se.Router.POST("/activitypub/activity/process", routes.ActivitypubActivityProcess) se.Router.GET("/activitypub/actor", routes.ActivitypubActor) @@ -195,22 +202,9 @@ func registerCronJobs(app core.App, client meilisearch.ServiceManager) { schedule = "0 2 * * *" } - app.Cron().MustAdd("integrations", schedule, func() { - err := strava.SyncStrava(app, client) - if err != nil { - warning := fmt.Sprintf("Error syncing with strava: %v", err) - fmt.Println(warning) - app.Logger().Error(warning) - } - err = komoot.SyncKomoot(app, client) - if err != nil { - warning := fmt.Sprintf("Error syncing with komoot: %v", err) - fmt.Println(warning) - app.Logger().Error(warning) - } - err = hammerhead.SyncHammerhead(app, client) - if err != nil { - warning := fmt.Sprintf("Error syncing with hammerhead: %v", err) + app.Cron().MustAdd("plugin-sync", schedule, func() { + if err := routes.PluginSystemSyncConfigured(context.Background(), app, client); err != nil { + warning := fmt.Sprintf("Error syncing with WASM plugins: %v", err) fmt.Println(warning) app.Logger().Error(warning) } @@ -219,6 +213,7 @@ func registerCronJobs(app core.App, client meilisearch.ServiceManager) { func initData(app core.App, client meilisearch.ServiceManager) error { initCategories(app) + initPlugins(app) initMeilisearchConfig(client) go func() { backfillPolylines(app) @@ -227,6 +222,15 @@ func initData(app core.App, client meilisearch.ServiceManager) error { return nil } +func initPlugins(app core.App) { + manager := pluginsystem.NewManager(app, "") + if err := manager.SyncInstalledPlugins(context.Background()); err != nil { + warning := fmt.Sprintf("Error discovering WASM plugins: %v", err) + fmt.Println(warning) + app.Logger().Error(warning) + } +} + func backfillPolylines(app core.App) { const pageSize int64 = 100 var lastID string @@ -276,23 +280,28 @@ func initCategories(app core.App) error { if err := query.All(&records); err != nil { return err } - if len(records) == 0 { - collection, _ := app.FindCollectionByNameOrId("categories") + if len(records) != 0 { + return nil + } - categories := []string{"Hiking", "Walking", "Climbing", "Skiing", "Canoeing", "Biking"} - for _, element := range categories { - record := core.NewRecord(collection) - record.Set("name", element) - record.Set("settings", map[string]any{ - "wp_merge_enabled": true, - "wp_merge_radius": 50, - }) - f, _ := filesystem.NewFileFromPath("migrations/initial_data/" + strings.ToLower(element) + ".jpg") + collection, err := app.FindCollectionByNameOrId("categories") + if err != nil { + return err + } + + categories := []string{"Hiking", "Walking", "Climbing", "Skiing", "Canoeing", "Biking", "Other"} + for _, element := range categories { + record := core.NewRecord(collection) + record.Set("name", element) + record.Set("settings", map[string]any{ + "wp_merge_enabled": true, + "wp_merge_radius": 50, + }) + if f, err := filesystem.NewFileFromPath("migrations/initial_data/" + strings.ToLower(element) + ".jpg"); err == nil { record.Set("img", f) - err := app.Save(record) - if err != nil { - return err - } + } + if err := app.Save(record); err != nil { + return err } } return nil diff --git a/db/migrations/1780000002_plugin_instances.go b/db/migrations/1780000002_plugin_instances.go new file mode 100644 index 00000000..7441dcde --- /dev/null +++ b/db/migrations/1780000002_plugin_instances.go @@ -0,0 +1,536 @@ +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 +} diff --git a/db/migrations/1780000004_plugin_system.go b/db/migrations/1780000004_plugin_system.go new file mode 100644 index 00000000..605c2b90 --- /dev/null +++ b/db/migrations/1780000004_plugin_system.go @@ -0,0 +1,198 @@ +package migrations + +import ( + "encoding/json" + + "github.com/pocketbase/pocketbase/core" + m "github.com/pocketbase/pocketbase/migrations" +) + +func init() { + m.Register(func(app core.App) error { + return createInstalledPluginsCollection(app) + }, func(app core.App) error { + if collection, err := app.FindCollectionByNameOrId("installed_plugins"); err == nil { + if err := app.Delete(collection); err != nil { + return err + } + } + return nil + }) +} + +func createInstalledPluginsCollection(app core.App) error { + if _, err := app.FindCollectionByNameOrId("installed_plugins"); err == nil { + return nil + } + + jsonData := `{ + "createRule": null, + "deleteRule": null, + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "textplginsid01", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "hidden": false, + "id": "textplginpid1", + "max": 128, + "min": 1, + "name": "plugin_id", + "pattern": "^[a-z0-9][a-z0-9_-]*$", + "presentable": false, + "required": true, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "textplginname", + "max": 256, + "min": 1, + "name": "name", + "pattern": "", + "presentable": true, + "required": true, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "selectplgtype", + "maxSelect": 1, + "name": "type", + "presentable": false, + "required": true, + "system": false, + "type": "select", + "values": ["trails"] + }, + { + "hidden": false, + "id": "textplginvers", + "max": 64, + "min": 1, + "name": "version", + "pattern": "", + "presentable": false, + "required": true, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "textplginrunt", + "max": 32, + "min": 1, + "name": "runtime", + "pattern": "", + "presentable": false, + "required": true, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "textplginpath", + "max": 0, + "min": 0, + "name": "path", + "pattern": "", + "presentable": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "jsonplginman", + "maxSize": 2000000, + "name": "manifest", + "presentable": false, + "required": true, + "system": false, + "type": "json" + }, + { + "hidden": false, + "id": "jsonplgincfg", + "maxSize": 2000000, + "name": "config", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "hidden": false, + "id": "selectplginst", + "maxSelect": 1, + "name": "status", + "presentable": false, + "required": true, + "system": false, + "type": "select", + "values": ["available", "disabled", "error"] + }, + { + "hidden": false, + "id": "textplginerr", + "max": 0, + "min": 0, + "name": "error", + "pattern": "", + "presentable": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autoplgcreate", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autoplgupdate", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "id": "pbc_430002000", + "indexes": [ + "CREATE UNIQUE INDEX ` + "`" + `idx_installed_plugins_plugin_id` + "`" + ` ON ` + "`" + `installed_plugins` + "`" + ` (` + "`" + `plugin_id` + "`" + `)" + ], + "listRule": null, + "name": "installed_plugins", + "system": false, + "type": "base", + "updateRule": null, + "viewRule": null + }` + + collection := &core.Collection{} + if err := json.Unmarshal([]byte(jsonData), collection); err != nil { + return err + } + return app.Save(collection) +} diff --git a/db/migrations/1780000005_add_other_category.go b/db/migrations/1780000005_add_other_category.go new file mode 100644 index 00000000..edf3a2e8 --- /dev/null +++ b/db/migrations/1780000005_add_other_category.go @@ -0,0 +1,46 @@ +package migrations + +import ( + "github.com/pocketbase/pocketbase/core" + m "github.com/pocketbase/pocketbase/migrations" + "github.com/pocketbase/pocketbase/tools/filesystem" +) + +func init() { + m.Register(func(app core.App) error { + categories, err := app.FindAllRecords("categories") + if err != nil { + return err + } + if len(categories) == 0 { + return nil + } + + existing, _ := app.FindFirstRecordByData("categories", "name", "Other") + if existing != nil { + return nil + } + + collection, err := app.FindCollectionByNameOrId("categories") + if err != nil { + return err + } + + record := core.NewRecord(collection) + record.Set("name", "Other") + record.Set("settings", map[string]any{ + "wp_merge_enabled": true, + "wp_merge_radius": 50, + }) + if file, err := filesystem.NewFileFromPath("migrations/initial_data/other.jpg"); err == nil { + record.Set("img", file) + } + return app.Save(record) + }, func(app core.App) error { + record, _ := app.FindFirstRecordByData("categories", "name", "Other") + if record == nil { + return nil + } + return app.Delete(record) + }) +} diff --git a/db/migrations/1780000006_trail_external_reference_provider_text.go b/db/migrations/1780000006_trail_external_reference_provider_text.go new file mode 100644 index 00000000..c12a73e6 --- /dev/null +++ b/db/migrations/1780000006_trail_external_reference_provider_text.go @@ -0,0 +1,262 @@ +package migrations + +import ( + "strings" + + "github.com/pocketbase/pocketbase/core" + m "github.com/pocketbase/pocketbase/migrations" +) + +const providerBackupColumn1780000006 = "provider_backup_1780000006" +const userPluginIndex1780000006 = "CREATE INDEX `idx_trail_external_reference_user_plugin_id` ON `trail_external_reference` (`user`, `plugin_id`)" + +func init() { + m.Register(func(app core.App) error { + if err := backupProviderColumn1780000006(app); err != nil { + return err + } + + collection, err := app.FindCollectionByNameOrId("trail_external_reference") + if err != nil { + return err + } + + // Drop+re-add (with a new id) blanks the provider column, so the + // provider-scoped unique indexes must not be rebuilt until the values + // have been restored, otherwise a cross-provider external_id clash would + // fail index creation and abort the migration. + removedIndexes := stripProviderIndexes1780000006(collection) + + collection.Fields.RemoveByName("provider") + if err := collection.Fields.AddMarshaledJSONAt(3, []byte(`{ + "autogeneratePattern": "", + "hidden": false, + "id": "text420001002", + "max": 128, + "min": 1, + "name": "provider", + "pattern": "^[a-z0-9][a-z0-9_-]*$", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }`)); err != nil { + return err + } + if collection.Fields.GetByName("plugin_id") == nil { + if err := collection.Fields.AddMarshaledJSONAt(4, []byte(`{ + "autogeneratePattern": "", + "hidden": false, + "id": "textpluginref", + "max": 64, + "min": 0, + "name": "plugin_id", + "pattern": "^[a-z0-9][a-z0-9_-]*$", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }`)); err != nil { + return err + } + } + if collection.Fields.GetByName("provider_category") == nil { + if err := collection.Fields.AddMarshaledJSONAt(6, []byte(`{ + "autogeneratePattern": "", + "hidden": false, + "id": "txtrmtecat01", + "max": 255, + "min": 0, + "name": "provider_category", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }`)); err != nil { + return err + } + } + if collection.Fields.GetByName("provider_category_checked_at") == nil { + if err := collection.Fields.AddMarshaledJSONAt(7, []byte(`{ + "hidden": false, + "id": "datermtecat1", + "max": "", + "min": "", + "name": "provider_category_checked_at", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }`)); err != nil { + return err + } + } + + if err := app.Save(collection); err != nil { + return err + } + + if err := restoreProviderColumn1780000006(app); err != nil { + return err + } + + collection.Indexes = append(collection.Indexes, removedIndexes...) + if !hasIndex1780000006(collection, userPluginIndex1780000006) { + collection.Indexes = append(collection.Indexes, userPluginIndex1780000006) + } + if err := app.Save(collection); err != nil { + return err + } + + refs, err := app.FindAllRecords("trail_external_reference") + if err != nil { + return err + } + for _, ref := range refs { + if ref.GetString("plugin_id") != "" { + continue + } + ref.Set("plugin_id", ref.GetString("provider")) + if err := app.Save(ref); err != nil { + return err + } + } + return nil + }, func(app core.App) error { + if err := backupProviderColumn1780000006(app); err != nil { + return err + } + + collection, err := app.FindCollectionByNameOrId("trail_external_reference") + if err != nil { + return err + } + + removedIndexes := stripProviderIndexes1780000006(collection) + removeIndex1780000006(collection, userPluginIndex1780000006) + + collection.Fields.RemoveByName("provider_category_checked_at") + collection.Fields.RemoveByName("provider_category") + collection.Fields.RemoveByName("plugin_id") + collection.Fields.RemoveByName("provider") + if err := collection.Fields.AddMarshaledJSONAt(3, []byte(`{ + "hidden": false, + "id": "select420001002", + "maxSelect": 1, + "name": "provider", + "presentable": false, + "required": true, + "system": false, + "type": "select", + "values": [ + "strava", + "komoot", + "hammerhead" + ] + }`)); err != nil { + return err + } + + if err := app.Save(collection); err != nil { + return err + } + + if err := restoreProviderColumn1780000006(app); err != nil { + return err + } + + collection.Indexes = append(collection.Indexes, removedIndexes...) + return app.Save(collection) + }) +} + +// stripProviderIndexes1780000006 removes the provider-scoped indexes from the +// collection metadata and returns them so they can be re-added once the +// provider values have been restored. PocketBase rebuilds indexes from the +// collection metadata on every save; leaving the provider indexes in place +// while the column is transiently empty risks a unique-constraint failure. +func stripProviderIndexes1780000006(collection *core.Collection) []string { + kept := make([]string, 0, len(collection.Indexes)) + removed := make([]string, 0) + for _, idx := range collection.Indexes { + if strings.Contains(idx, "`provider`") { + removed = append(removed, idx) + continue + } + kept = append(kept, idx) + } + collection.Indexes = kept + return removed +} + +func hasIndex1780000006(collection *core.Collection, index string) bool { + for _, existing := range collection.Indexes { + if existing == index { + return true + } + } + return false +} + +func removeIndex1780000006(collection *core.Collection, index string) { + indexes := collection.Indexes[:0] + for _, existing := range collection.Indexes { + if existing == index { + continue + } + indexes = append(indexes, existing) + } + collection.Indexes = indexes +} + +func backupProviderColumn1780000006(app core.App) error { + exists, err := columnExists1780000006(app, providerBackupColumn1780000006) + if err != nil { + return err + } + + if exists { + return nil + } + + if _, err := app.DB(). + NewQuery("ALTER TABLE trail_external_reference ADD COLUMN " + providerBackupColumn1780000006 + " TEXT DEFAULT '' NOT NULL"). + Execute(); err != nil { + return err + } + + _, err = app.DB(). + NewQuery("UPDATE trail_external_reference SET " + providerBackupColumn1780000006 + " = provider"). + Execute() + return err +} + +func restoreProviderColumn1780000006(app core.App) error { + if _, err := app.DB(). + NewQuery("UPDATE trail_external_reference SET provider = " + providerBackupColumn1780000006). + Execute(); err != nil { + return err + } + + _, err := app.DB().DropColumn("trail_external_reference", providerBackupColumn1780000006).Execute() + return err +} + +func columnExists1780000006(app core.App, column string) (bool, error) { + columns, err := app.TableColumns("trail_external_reference") + if err != nil { + return false, err + } + + for _, existing := range columns { + if existing == column { + return true, nil + } + } + + return false, nil +} diff --git a/db/migrations/initial_data/other.jpg b/db/migrations/initial_data/other.jpg new file mode 100644 index 0000000000000000000000000000000000000000..167a70d52706ec3c5935c6dccde9b6b0a63cd796 GIT binary patch literal 442890 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%0Di>&tMd1~M+3A^MeXAbre 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

~EIkrZf4jTip_5eAqDRg;2}xC$UO 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

E?(1QGI2 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 ZAA~p4{OGZ2aSq#Pc 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 n%d_C^>cy=zI{%I56`#r_gx<`ZC#_M+f0B`@ 0 { + record.Set("photos", photos) + } + + if err := app.Save(record); err != nil { + return nil, err + } + + if err := util.EnsureTrailExternalReference(app, record.Id, item.Source.Provider, item.Source.ExternalID, opts.Manifest.ID, ProviderCategoryFromImport(item)); err != nil { + return nil, err + } + + if err := createWaypoints(ctx, app, item.Waypoints, opts, mediaBudget, record.Id, trackIndex); err != nil { + return nil, err + } + + if opts.CreateSummitLogForCompleted && item.Kind == "completed" { + if err := createSummitLog(app, record.Id, opts.ActorID, date, metrics); err != nil { + return nil, err + } + } + + return &Result{TrailID: record.Id, Created: true}, nil +} + +type trailMetrics struct { + Distance float64 + ElevationGain float64 + ElevationLoss float64 + Duration float64 + StartLat float64 + StartLon float64 + StartTime time.Time +} + +type geoPoint struct { + Lat float64 + Lon float64 +} + +type trackDistanceIndex struct { + points []indexedTrackPoint + segments []indexedTrackSegment +} + +type indexedTrackPoint struct { + point geoPoint + distance float64 +} + +type indexedTrackSegment struct { + start geoPoint + end geoPoint + startDistance float64 + length float64 +} + +const maxProviderStartDistanceMeters = 1000 + +// decodeAndParseGPX keeps the importer strict for now: plugins must return GPX +// as base64 so the host can compute canonical trail metrics itself. +func decodeAndParseGPX(track pluginsystem.Track) ([]byte, *gpx.GPX, error) { + if track.Format != "gpx" { + return nil, nil, fmt.Errorf("unsupported track format %q", track.Format) + } + if track.ContentBase64 == "" { + return nil, nil, fmt.Errorf("track contentBase64 is required") + } + + content, err := base64.StdEncoding.DecodeString(track.ContentBase64) + if err != nil { + return nil, nil, fmt.Errorf("decode GPX: %w", err) + } + + parsed, err := gpx.Parse(bytes.NewReader(content)) + if err != nil { + return nil, nil, fmt.Errorf("parse GPX: %w", err) + } + + return content, parsed, nil +} + +// metricsFromGPX derives fallback trail fields from the GPX. Provider metadata +// may override summary metrics and, when plausible, the displayed start point. +func metricsFromGPX(gpxData *gpx.GPX) trailMetrics { + uphillDownhill := gpxData.UphillDownhill() + movingData := gpxData.MovingData() + timeBounds := gpxData.TimeBounds() + + metrics := trailMetrics{ + Distance: gpxData.Length2D(), + ElevationGain: uphillDownhill.Uphill, + ElevationLoss: uphillDownhill.Downhill, + Duration: movingData.MovingTime + movingData.StoppedTime, + StartTime: timeBounds.StartTime, + } + + for _, track := range gpxData.Tracks { + for _, segment := range track.Segments { + if len(segment.Points) == 0 { + continue + } + metrics.StartLat = segment.Points[0].Latitude + metrics.StartLon = segment.Points[0].Longitude + return metrics + } + } + + return metrics +} + +// applyProviderStart lets providers correct the displayed trail start when the +// provider's intended start is close to the imported GPX track. Implausible +// starts are ignored so broken metadata does not move trails off their geometry. +func applyProviderStart(metrics *trailMetrics, trackIndex trackDistanceIndex, metadata map[string]any) { + if metrics == nil || len(metadata) == 0 { + return + } + start, ok := providerStartFromMetadata(metadata) + if !ok || !providerStartNearTrack(trackIndex, start) { + return + } + metrics.StartLat = start.Lat + metrics.StartLon = start.Lon +} + +func providerStartFromMetadata(metadata map[string]any) (geoPoint, bool) { + raw, ok := metadata["providerStart"] + if !ok { + return geoPoint{}, false + } + values, ok := raw.(map[string]any) + if !ok { + return geoPoint{}, false + } + lat, ok := floatMetadata(values, "lat") + if !ok { + lat, ok = floatMetadata(values, "latitude") + } + if !ok { + return geoPoint{}, false + } + lon, ok := floatMetadata(values, "lon") + if !ok { + lon, ok = floatMetadata(values, "longitude") + } + if !ok || lat < -90 || lat > 90 || lon < -180 || lon > 180 { + return geoPoint{}, false + } + return geoPoint{Lat: lat, Lon: lon}, true +} + +func providerStartNearTrack(trackIndex trackDistanceIndex, start geoPoint) bool { + distance, ok := trackIndex.nearest(start) + return ok && distance.offTrack <= maxProviderStartDistanceMeters +} + +type trackDistance struct { + fromStart float64 + offTrack float64 +} + +func trackDistanceIndexFromGPX(gpxData *gpx.GPX) trackDistanceIndex { + index := trackDistanceIndex{} + if gpxData == nil { + return index + } + totalDistance := 0.0 + for _, track := range gpxData.Tracks { + for _, segment := range track.Segments { + var previous geoPoint + hasPrevious := false + for _, point := range segment.Points { + current := geoPoint{Lat: point.Latitude, Lon: point.Longitude} + if !hasPrevious { + index.points = append(index.points, indexedTrackPoint{ + point: current, + distance: totalDistance, + }) + previous = current + hasPrevious = true + continue + } + length := util.HaversineDistanceMeters(previous.Lat, previous.Lon, current.Lat, current.Lon) + if length > 0 { + index.segments = append(index.segments, indexedTrackSegment{ + start: previous, + end: current, + startDistance: totalDistance, + length: length, + }) + totalDistance += length + } + index.points = append(index.points, indexedTrackPoint{ + point: current, + distance: totalDistance, + }) + previous = current + } + } + } + return index +} + +func (index trackDistanceIndex) nearest(point geoPoint) (trackDistance, bool) { + var nearest trackDistance + found := false + for _, candidate := range index.points { + offTrack := util.HaversineDistanceMeters(point.Lat, point.Lon, candidate.point.Lat, candidate.point.Lon) + if !found || offTrack < nearest.offTrack { + nearest = trackDistance{fromStart: candidate.distance, offTrack: offTrack} + found = true + } + } + for _, segment := range index.segments { + offTrack, t := pointToSegmentProjectionMeters(point, segment.start, segment.end) + fromStart := segment.startDistance + segment.length*t + if !found || offTrack < nearest.offTrack { + nearest = trackDistance{fromStart: fromStart, offTrack: offTrack} + found = true + } + } + return nearest, found +} + +func pointToSegmentProjectionMeters(point geoPoint, start geoPoint, end geoPoint) (float64, float64) { + const earthRadius = 6371000.0 + latRad := point.Lat * math.Pi / 180 + toXY := func(p geoPoint) (float64, float64) { + x := (p.Lon - point.Lon) * math.Pi / 180 * math.Cos(latRad) * earthRadius + y := (p.Lat - point.Lat) * math.Pi / 180 * earthRadius + return x, y + } + + startX, startY := toXY(start) + endX, endY := toXY(end) + dx := endX - startX + dy := endY - startY + lengthSquared := dx*dx + dy*dy + if lengthSquared == 0 { + return math.Hypot(startX, startY), 0 + } + t := -(startX*dx + startY*dy) / lengthSquared + if t < 0 { + t = 0 + } else if t > 1 { + t = 1 + } + closestX := startX + t*dx + closestY := startY + t*dy + return math.Hypot(closestX, closestY), t +} + +// applyProviderMetrics lets plugins preserve provider-provided summary metrics +// where those values are more authoritative than values recalculated from a +// simplified/import GPX. GPX parsing remains mandatory and provides fallback +// metrics plus the start coordinate. +func applyProviderMetrics(metrics *trailMetrics, metadata map[string]any) { + if metrics == nil || len(metadata) == 0 { + return + } + if value, ok := positiveFloatMetadata(metadata, "distance"); ok { + metrics.Distance = value + } + if value, ok := positiveFloatMetadata(metadata, "elevationGain"); ok { + metrics.ElevationGain = value + } + if value, ok := positiveFloatMetadata(metadata, "elevationLoss"); ok { + metrics.ElevationLoss = value + } + if value, ok := positiveFloatMetadata(metadata, "duration"); ok { + metrics.Duration = value + } +} + +func positiveFloatMetadata(metadata map[string]any, key string) (float64, bool) { + value, ok := floatMetadata(metadata, key) + return value, ok && value > 0 +} + +func floatMetadata(metadata map[string]any, key string) (float64, bool) { + switch value := metadata[key].(type) { + case float64: + return value, true + case float32: + floatValue := float64(value) + return floatValue, true + case int: + floatValue := float64(value) + return floatValue, true + case int64: + floatValue := float64(value) + return floatValue, true + case int32: + floatValue := float64(value) + return floatValue, true + case json.Number: + parsed, err := value.Float64() + return parsed, err == nil + default: + return 0, false + } +} + +// publicFromPrivacy respects explicit provider privacy when present and falls +// back to the user's wanderer default when the plugin leaves privacy unset. +func publicFromPrivacy(privacy *string, defaultPublic bool) bool { + if privacy == nil || *privacy == "" { + return defaultPublic + } + return *privacy == "public" +} + +// dateFromImport chooses the best available trail date: provider start time, +// GPX start time, then the import time. +func dateFromImport(item pluginsystem.TrailImport, metrics trailMetrics) time.Time { + if item.StartedAt != nil { + return *item.StartedAt + } + if !metrics.StartTime.IsZero() { + return metrics.StartTime + } + return time.Now() +} + +// createWaypoints persists plugin-provided waypoints after the trail exists so +// they can reference the imported trail record. +func createWaypoints(ctx context.Context, app core.App, waypoints []pluginsystem.Waypoint, opts Options, mediaBudget *pluginMediaBudget, trailID string, trackIndex trackDistanceIndex) error { + if len(waypoints) == 0 { + return nil + } + if err := ctx.Err(); err != nil { + return err + } + + collection, err := app.FindCollectionByNameOrId("waypoints") + if err != nil { + return err + } + + for _, waypoint := range waypoints { + record := core.NewRecord(collection) + icon := waypoint.Icon + if icon == "" { + icon = "circle" + } + distanceFromStart := 0.0 + if distance, ok := trackIndex.nearest(geoPoint{Lat: waypoint.Lat, Lon: waypoint.Lon}); ok { + distanceFromStart = distance.fromStart + } + photos := photoFiles(ctx, app, waypoint.Photos, opts, mediaBudget) + record.Load(map[string]any{ + "name": waypoint.Name, + "description": waypoint.Description, + "lat": waypoint.Lat, + "lon": waypoint.Lon, + "icon": icon, + "author": opts.ActorID, + "distance_from_start": distanceFromStart, + "trail": trailID, + }) + if len(photos) > 0 { + record.Set("photos", photos) + } + if err := app.Save(record); err != nil { + return err + } + } + + return nil +} + +// photoFiles converts plugin photo descriptors into PocketBase file objects. +// Individual photo failures are logged and skipped so one broken media URL does +// not fail the whole trail import. +type pluginMediaBudget struct { + items int + bytes int64 +} + +func (b *pluginMediaBudget) remainingBytes() int64 { + remaining := util.DefaultPluginMaxImportMediaBytes - b.bytes + if remaining < util.DefaultPluginMediaMaxBytes { + return remaining + } + return util.DefaultPluginMediaMaxBytes +} + +func photoFiles(ctx context.Context, app core.App, photos []pluginsystem.Photo, opts Options, budget *pluginMediaBudget) []*filesystem.File { + if len(photos) == 0 { + return nil + } + + files := make([]*filesystem.File, 0, len(photos)) + now := time.Now() + for _, photo := range photos { + if budget.items >= util.DefaultPluginMaxImportMediaItems { + app.Logger().Warn("skipping plugin photo because media item limit was reached", "limit", util.DefaultPluginMaxImportMediaItems) + continue + } + if err := ctx.Err(); err != nil { + app.Logger().Warn("skipping plugin photo because import context was cancelled", "error", err) + return files + } + if photo.Source.ExpiresAt != nil && photo.Source.ExpiresAt.Before(now) { + app.Logger().Warn("skipping expired plugin photo", "external_id", photo.ExternalID) + continue + } + maxBytes := budget.remainingBytes() + if maxBytes <= 0 { + app.Logger().Warn("skipping plugin photo because aggregate media byte limit was reached", "external_id", photo.ExternalID, "limit", util.DefaultPluginMaxImportMediaBytes) + continue + } + + file, bytesRead, err := photoFile(ctx, photo, opts, maxBytes) + if err != nil { + app.Logger().Warn("skipping plugin photo", "external_id", photo.ExternalID, "error", err) + continue + } + if file != nil { + files = append(files, file) + budget.items++ + budget.bytes += bytesRead + } + } + + return files +} + +// photoFile fetches one plugin-provided photo source. URL sources are validated +// before PocketBase performs the server-side download. +func photoFile(ctx context.Context, photo pluginsystem.Photo, opts Options, maxBytes int64) (*filesystem.File, int64, error) { + switch photo.Source.Type { + case "url": + if photo.Source.URL == "" { + return nil, 0, fmt.Errorf("photo URL is empty") + } + if err := validateRemoteMediaURLSyntax(photo.Source.URL); err != nil { + return nil, 0, err + } + fetched, err := util.FetchPublicURL(ctx, photo.Source.URL, maxBytes) + if err != nil { + return nil, 0, err + } + file, err := filesystem.NewFileFromBytes(fetched.Body, safeMediaFileName(photo.Filename, urlPathBase(fetched.FinalURL), fetched.ContentType, photo.ContentType)) + return file, int64(len(fetched.Body)), err + case "connector": + fetched, err := fetchConnectorMedia(ctx, photo, opts, maxBytes) + if err != nil { + return nil, 0, err + } + file, err := filesystem.NewFileFromBytes(fetched.Body, safeMediaFileName(photo.Filename, urlPathBase(fetched.FinalURL), fetched.ContentType, photo.ContentType)) + return file, int64(len(fetched.Body)), err + default: + return nil, 0, fmt.Errorf("unsupported photo source type %q", photo.Source.Type) + } +} + +func fetchConnectorMedia(ctx context.Context, photo pluginsystem.Photo, opts Options, maxBytes int64) (*util.SafeFetchResult, error) { + if photo.Source.MediaRef == nil { + return nil, fmt.Errorf("connector mediaRef is required") + } + ref := *photo.Source.MediaRef + if ref.AssetID != "" && ref.Path == "" { + return nil, fmt.Errorf("mediaRef.assetId is metadata only; path is required") + } + target := pluginsystem.RequestTarget{ + Type: "connector", + Connector: ref.Connector, + Path: ref.Path, + Query: ref.Query, + } + resolved, err := pluginsystem.ResolveRequestTarget(opts.Manifest, target, opts.Policy) + if err != nil { + return nil, err + } + if ref.Auth != "" { + if !resolved.Connector.SupportsMediaAuth { + return nil, fmt.Errorf("connector %q does not support media auth", ref.Connector) + } + if len(resolved.Connector.Auth) > 0 && !slices.Contains(resolved.Connector.Auth, ref.Auth) { + return nil, fmt.Errorf("auth context %q is not permitted for connector %q", ref.Auth, ref.Connector) + } + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, resolved.URL.String(), nil) + if err != nil { + return nil, err + } + if err := pluginsystem.InjectRequestAuthForContext(opts.Manifest, opts.Auth, ref.Auth, req); err != nil { + return nil, err + } + var storageRedirect *storageRedirectTarget + client, err := util.ConnectorHTTPClient(util.ConnectorHTTPPolicy{ + BaseURL: resolved.Connector.BaseURL, + AllowPrivate: resolved.Connector.AllowPrivate, + TLSMode: resolved.Connector.TLS.Mode, + TLSCABundle: resolved.Connector.TLS.CABundle, + }, func(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return fmt.Errorf("too many redirects") + } + previous := resolved.URL + if len(via) > 0 { + previous = via[len(via)-1].URL + } + if err := pluginsystem.ValidateConnectorRedirect(resolved.Connector, previous, req.URL); err == nil { + return nil + } + origin, err := pluginsystem.ConnectorStorageRedirectOrigin(resolved.Connector, previous, req.URL) + if err != nil { + return err + } + stripConnectorAuth(req, opts.Manifest, ref.Auth) + storageRedirect = &storageRedirectTarget{ + URL: req.URL.String(), + Origin: origin, + } + return http.ErrUseLastResponse + }) + if err != nil { + return nil, err + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if storageRedirect != nil && resp.StatusCode >= 300 && resp.StatusCode < 400 { + return fetchStorageRedirectMedia(ctx, *storageRedirect, maxBytes) + } + body, err := util.ReadBoundedForPlugin(resp.Body, maxBytes) + if err != nil { + return nil, err + } + return &util.SafeFetchResult{Body: body, ContentType: resp.Header.Get("Content-Type"), FinalURL: resp.Request.URL.String()}, nil +} + +type storageRedirectTarget struct { + URL string + Origin pluginsystem.ResolvedConnectorOrigin +} + +func fetchStorageRedirectMedia(ctx context.Context, redirect storageRedirectTarget, maxBytes int64) (*util.SafeFetchResult, error) { + storageConnector := pluginsystem.ResolvedConnectorTarget{ + Name: redirect.Origin.Name, + BaseURL: redirect.Origin.BaseURL, + BasePath: redirect.Origin.BasePath, + AllowPrivate: redirect.Origin.AllowPrivate, + TLS: redirect.Origin.TLS, + AllowedPathPrefixes: []string{"/"}, + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, redirect.URL, nil) + if err != nil { + return nil, err + } + client, err := util.ConnectorHTTPClient(util.ConnectorHTTPPolicy{ + BaseURL: redirect.Origin.BaseURL, + AllowPrivate: redirect.Origin.AllowPrivate, + TLSMode: redirect.Origin.TLS.Mode, + TLSCABundle: redirect.Origin.TLS.CABundle, + }, func(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return fmt.Errorf("too many redirects") + } + previous := req.URL + if len(via) > 0 { + previous = via[len(via)-1].URL + } + return pluginsystem.ValidateConnectorRedirect(storageConnector, previous, req.URL) + }) + if err != nil { + return nil, err + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, err := util.ReadBoundedForPlugin(resp.Body, maxBytes) + if err != nil { + return nil, err + } + return &util.SafeFetchResult{Body: body, ContentType: resp.Header.Get("Content-Type"), FinalURL: resp.Request.URL.String()}, nil +} + +func stripConnectorAuth(req *http.Request, manifest pluginsystem.Manifest, authName string) { + req.Header.Del(pluginsystem.AuthHeaderAuthorization) + if authName == "" { + return + } + authContext, ok := manifest.Auth.Contexts[authName] + if !ok { + return + } + if authContext.Name != "" { + req.Header.Del(authContext.Name) + req.URL.RawQuery = removeRawQueryParamOrdered(req.URL.RawQuery, authContext.Name) + } + if authContext.SecretField != "" { + req.Header.Del(authContext.SecretField) + req.URL.RawQuery = removeRawQueryParamOrdered(req.URL.RawQuery, authContext.SecretField) + } +} + +func validateRemoteMediaURLSyntax(rawURL string) error { + parsed, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("invalid media URL: %w", err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return fmt.Errorf("unsupported media URL scheme %q", parsed.Scheme) + } + host := parsed.Hostname() + if host == "" { + return fmt.Errorf("media URL has no host") + } + return nil +} + +func urlPathBase(rawURL string) string { + parsed, err := url.Parse(rawURL) + if err != nil { + return "" + } + return urlpath.Base(parsed.Path) +} + +func removeRawQueryParamOrdered(rawQuery string, name string) string { + if rawQuery == "" || name == "" { + return rawQuery + } + parts := strings.Split(rawQuery, "&") + kept := make([]string, 0, len(parts)) + for _, part := range parts { + if part == "" { + continue + } + rawName := part + if idx := strings.Index(rawName, "="); idx >= 0 { + rawName = rawName[:idx] + } + decodedName, err := url.QueryUnescape(rawName) + if err == nil && decodedName == name { + continue + } + kept = append(kept, part) + } + return strings.Join(kept, "&") +} + +// createSummitLog mirrors completed imported trails into summit_logs when the +// user has enabled that compatibility option. +func createSummitLog(app core.App, trailID string, actorID string, date time.Time, metrics trailMetrics) error { + collection, err := app.FindCollectionByNameOrId("summit_logs") + if err != nil { + return err + } + + record := core.NewRecord(collection) + record.Load(map[string]any{ + "distance": metrics.Distance, + "elevation_gain": metrics.ElevationGain, + "elevation_loss": metrics.ElevationLoss, + "duration": metrics.Duration, + "date": date, + "author": actorID, + "trail": trailID, + }) + + return app.Save(record) +} + +func categoryIDForImport(app core.App, item pluginsystem.TrailImport, mapping map[string]string) string { + if category, matched := CategoryFromProviderMapping(app, ProviderCategoryFromImport(item), mapping); matched { + return category + } + return categoryIDForActivityType(app, item.ActivityType) +} + +func ProviderCategoryFromImport(item pluginsystem.TrailImport) string { + value, _ := item.Metadata["providerCategory"].(string) + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + value, _ = item.Metadata["sourceSport"].(string) + return strings.TrimSpace(value) +} + +func CategoryFromProviderMapping(app core.App, providerCategory string, mapping map[string]string) (string, bool) { + providerCategory = strings.TrimSpace(providerCategory) + if providerCategory == "" || len(mapping) == 0 { + return "", false + } + rawTarget, matched := mapping[providerCategory] + if !matched { + return "", false + } + target := strings.TrimSpace(rawTarget) + if target == "" { + return "", true + } + if category, err := app.FindRecordById("categories", target); err == nil && category != nil { + return category.Id, true + } + category, _ := app.FindFirstRecordByData("categories", "name", target) + if category == nil { + return "", false + } + return category.Id, true +} + +// categoryIDForActivityType maps common provider activity labels to wanderer's +// built-in categories. Unknown labels intentionally leave the category empty. +func categoryIDForActivityType(app core.App, activityType string) string { + categoryMap := map[string]string{ + "hiking": "Hiking", + "hike": "Hiking", + "walking": "Walking", + "walk": "Walking", + "running": "Walking", + "run": "Walking", + "biking": "Biking", + "cycling": "Biking", + "ride": "Biking", + "mtb": "Biking", + "skiing": "Skiing", + "canoeing": "Canoeing", + "climbing": "Climbing", + } + + name := categoryMap[strings.ToLower(activityType)] + if name == "" { + return "" + } + + category, _ := app.FindFirstRecordByData("categories", "name", name) + if category == nil { + return "" + } + return category.Id +} + +func fallbackName(name string) string { + if strings.TrimSpace(name) != "" { + return name + } + return "Imported trail" +} + +// safeGPXFileName turns provider trail names into filesystem-safe GPX filenames. +func safeGPXFileName(name string) string { + name = strings.TrimSpace(name) + if name == "" { + name = "imported-trail" + } + name = filepath.Base(name) + name = strings.TrimSuffix(name, filepath.Ext(name)) + name = strings.Map(func(r rune) rune { + switch r { + case '/', '\\', ':', '*', '?', '"', '<', '>', '|': + return '-' + default: + return r + } + }, name) + return name + ".gpx" +} + +// safeMediaFileName picks the first safe candidate filename and adds a best +// effort extension when providers only expose a content type. +func safeMediaFileName(candidates ...string) string { + filename := "" + for _, candidate := range candidates { + candidate = strings.TrimSpace(candidate) + if candidate == "" || strings.Contains(candidate, "/") { + continue + } + base := filepath.Base(candidate) + if base == "." || base == ".." { + continue + } + filename = candidate + break + } + if filename == "" { + filename = "photo" + } + filename = filepath.Base(filename) + filename = strings.Map(func(r rune) rune { + switch r { + case '/', '\\', ':', '*', '?', '"', '<', '>', '|': + return '-' + default: + return r + } + }, filename) + if ext := filepath.Ext(filename); ext == "" || ext == "." { + filename += extensionFromContentTypes(candidates...) + } + return filename +} + +func extensionFromContentTypes(candidates ...string) string { + for _, candidate := range candidates { + if extensions, err := mime.ExtensionsByType(strings.TrimSpace(candidate)); err == nil && len(extensions) > 0 { + return extensions[0] + } + } + return ".jpg" +} diff --git a/db/plugins/importer/importer_test.go b/db/plugins/importer/importer_test.go new file mode 100644 index 00000000..ce08dc67 --- /dev/null +++ b/db/plugins/importer/importer_test.go @@ -0,0 +1,432 @@ +package importer + +import ( + "context" + "encoding/base64" + "strings" + "testing" + "time" + + pluginsystem "pocketbase/pluginsystem" + "pocketbase/util" +) + +const sampleGPX = ` + + + 100 + 120 + +` + +func gpxTrack() pluginsystem.Track { + return pluginsystem.Track{ + Format: "gpx", + ContentBase64: base64.StdEncoding.EncodeToString([]byte(sampleGPX)), + } +} + +func TestDecodeAndParseGPX(t *testing.T) { + t.Run("valid", func(t *testing.T) { + raw, parsed, err := decodeAndParseGPX(gpxTrack()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if parsed == nil { + t.Fatal("expected parsed gpx") + } + if string(raw) != sampleGPX { + t.Fatal("decoded bytes do not match input") + } + }) + + t.Run("unsupported format", func(t *testing.T) { + if _, _, err := decodeAndParseGPX(pluginsystem.Track{Format: "tcx", ContentBase64: "x"}); err == nil { + t.Fatal("expected error for unsupported format") + } + }) + + t.Run("empty content", func(t *testing.T) { + if _, _, err := decodeAndParseGPX(pluginsystem.Track{Format: "gpx"}); err == nil { + t.Fatal("expected error for empty content") + } + }) + + t.Run("invalid base64", func(t *testing.T) { + if _, _, err := decodeAndParseGPX(pluginsystem.Track{Format: "gpx", ContentBase64: "!!!not-base64"}); err == nil { + t.Fatal("expected error for invalid base64") + } + }) + + t.Run("invalid gpx", func(t *testing.T) { + track := pluginsystem.Track{Format: "gpx", ContentBase64: base64.StdEncoding.EncodeToString([]byte("not gpx"))} + if _, _, err := decodeAndParseGPX(track); err == nil { + t.Fatal("expected error for invalid gpx") + } + }) +} + +func TestMetricsFromGPX(t *testing.T) { + _, parsed, err := decodeAndParseGPX(gpxTrack()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + metrics := metricsFromGPX(parsed) + if metrics.StartLat != 46.0 || metrics.StartLon != 8.0 { + t.Fatalf("unexpected start point: %v, %v", metrics.StartLat, metrics.StartLon) + } + if metrics.Distance <= 0 { + t.Fatalf("expected positive distance, got %v", metrics.Distance) + } + if metrics.ElevationGain <= 0 { + t.Fatalf("expected positive elevation gain, got %v", metrics.ElevationGain) + } + if !metrics.StartTime.Equal(time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC)) { + t.Fatalf("unexpected start time: %v", metrics.StartTime) + } +} + +func TestApplyProviderMetrics(t *testing.T) { + metrics := trailMetrics{ + Distance: 1, + ElevationGain: 2, + ElevationLoss: 3, + Duration: 4, + StartLat: 46, + StartLon: 8, + } + + applyProviderMetrics(&metrics, map[string]any{ + "distance": 1234.5, + "elevationGain": 234.5, + "elevationLoss": 45.5, + "duration": 3600, + }) + + if metrics.Distance != 1234.5 { + t.Fatalf("distance = %v", metrics.Distance) + } + if metrics.ElevationGain != 234.5 { + t.Fatalf("elevation gain = %v", metrics.ElevationGain) + } + if metrics.ElevationLoss != 45.5 { + t.Fatalf("elevation loss = %v", metrics.ElevationLoss) + } + if metrics.Duration != 3600 { + t.Fatalf("duration = %v", metrics.Duration) + } + if metrics.StartLat != 46 || metrics.StartLon != 8 { + t.Fatalf("provider metadata must not override start point") + } +} + +func TestApplyProviderStart(t *testing.T) { + _, parsed, err := decodeAndParseGPX(gpxTrack()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + trackIndex := trackDistanceIndexFromGPX(parsed) + + t.Run("uses plausible provider start", func(t *testing.T) { + metrics := metricsFromGPX(parsed) + applyProviderStart(&metrics, trackIndex, map[string]any{ + "providerStart": map[string]any{ + "lat": 45.9995, + "lon": 7.9995, + }, + }) + + if metrics.StartLat != 45.9995 || metrics.StartLon != 7.9995 { + t.Fatalf("unexpected provider start: %v, %v", metrics.StartLat, metrics.StartLon) + } + }) + + t.Run("ignores distant provider start", func(t *testing.T) { + metrics := metricsFromGPX(parsed) + applyProviderStart(&metrics, trackIndex, map[string]any{ + "providerStart": map[string]any{ + "lat": 47.0, + "lon": 8.0, + }, + }) + + if metrics.StartLat != 46.0 || metrics.StartLon != 8.0 { + t.Fatalf("distant provider start should be ignored: %v, %v", metrics.StartLat, metrics.StartLon) + } + }) + + t.Run("ignores invalid provider start", func(t *testing.T) { + metrics := metricsFromGPX(parsed) + applyProviderStart(&metrics, trackIndex, map[string]any{ + "providerStart": map[string]any{ + "lat": 91.0, + "lon": 8.0, + }, + }) + + if metrics.StartLat != 46.0 || metrics.StartLon != 8.0 { + t.Fatalf("invalid provider start should be ignored: %v, %v", metrics.StartLat, metrics.StartLon) + } + }) +} + +func TestTrackDistanceIndexNearest(t *testing.T) { + _, parsed, err := decodeAndParseGPX(gpxTrack()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + trackIndex := trackDistanceIndexFromGPX(parsed) + total := util.HaversineDistanceMeters(46.0, 8.0, 46.001, 8.001) + + t.Run("start point", func(t *testing.T) { + distance, ok := trackIndex.nearest(geoPoint{Lat: 46.0, Lon: 8.0}) + if !ok { + t.Fatal("expected nearest distance") + } + if distance.fromStart != 0 { + t.Fatalf("got %v, want 0", distance.fromStart) + } + }) + + t.Run("mid segment projection", func(t *testing.T) { + distance, ok := trackIndex.nearest(geoPoint{Lat: 46.0005, Lon: 8.0005}) + if !ok { + t.Fatal("expected nearest distance") + } + if distance.fromStart < total*0.45 || distance.fromStart > total*0.55 { + t.Fatalf("got %v, want about half of %v", distance.fromStart, total) + } + }) + + t.Run("end point", func(t *testing.T) { + distance, ok := trackIndex.nearest(geoPoint{Lat: 46.001, Lon: 8.001}) + if !ok { + t.Fatal("expected nearest distance") + } + if distance.fromStart < total-0.001 || distance.fromStart > total+0.001 { + t.Fatalf("got %v, want %v", distance.fromStart, total) + } + }) +} + +func TestApplyProviderMetricsIgnoresEmptyValues(t *testing.T) { + metrics := trailMetrics{ + Distance: 1, + ElevationGain: 2, + ElevationLoss: 3, + Duration: 4, + } + + applyProviderMetrics(&metrics, map[string]any{ + "distance": 0, + "elevationGain": -1, + "elevationLoss": "", + "duration": nil, + }) + + if metrics.Distance != 1 || metrics.ElevationGain != 2 || metrics.ElevationLoss != 3 || metrics.Duration != 4 { + t.Fatalf("unexpected metrics after empty metadata: %#v", metrics) + } +} + +func TestPublicFromPrivacy(t *testing.T) { + public := "public" + private := "private" + empty := "" + + cases := []struct { + name string + privacy *string + defaultPublic bool + want bool + }{ + {"nil keeps default true", nil, true, true}, + {"nil keeps default false", nil, false, false}, + {"explicit public", &public, false, true}, + {"explicit private", &private, true, false}, + {"empty keeps default", &empty, true, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := publicFromPrivacy(tc.privacy, tc.defaultPublic); got != tc.want { + t.Fatalf("got %v, want %v", got, tc.want) + } + }) + } +} + +func TestCategoryIDForImportDoesNotFallbackWhenProviderMappingIsBlank(t *testing.T) { + item := pluginsystem.TrailImport{ + ActivityType: "biking", + Metadata: map[string]any{ + "providerCategory": " Ride ", + }, + } + + if got := categoryIDForImport(nil, item, map[string]string{"Ride": ""}); got != "" { + t.Fatalf("expected blank provider mapping to suppress activity fallback, got %q", got) + } +} + +func TestProviderCategoryFromImport(t *testing.T) { + if got := ProviderCategoryFromImport(pluginsystem.TrailImport{ + Metadata: map[string]any{"providerCategory": " Ride "}, + }); got != "Ride" { + t.Fatalf("got %q", got) + } + if got := ProviderCategoryFromImport(pluginsystem.TrailImport{ + Metadata: map[string]any{"sourceSport": " hiking "}, + }); got != "hiking" { + t.Fatalf("got %q", got) + } +} + +func TestDateFromImport(t *testing.T) { + started := time.Date(2025, 6, 1, 8, 0, 0, 0, time.UTC) + + t.Run("uses StartedAt", func(t *testing.T) { + item := pluginsystem.TrailImport{StartedAt: &started} + if got := dateFromImport(item, trailMetrics{}); !got.Equal(started) { + t.Fatalf("got %v, want %v", got, started) + } + }) + + t.Run("falls back to metrics start time", func(t *testing.T) { + metricStart := time.Date(2024, 1, 2, 3, 0, 0, 0, time.UTC) + if got := dateFromImport(pluginsystem.TrailImport{}, trailMetrics{StartTime: metricStart}); !got.Equal(metricStart) { + t.Fatalf("got %v, want %v", got, metricStart) + } + }) + + t.Run("falls back to now", func(t *testing.T) { + got := dateFromImport(pluginsystem.TrailImport{}, trailMetrics{}) + if time.Since(got) > time.Minute { + t.Fatalf("expected ~now, got %v", got) + } + }) +} + +func TestFallbackName(t *testing.T) { + if got := fallbackName("My Trail"); got != "My Trail" { + t.Fatalf("got %q", got) + } + if got := fallbackName(""); got != "Imported trail" { + t.Fatalf("got %q", got) + } + if got := fallbackName(" "); got != "Imported trail" { + t.Fatalf("got %q", got) + } +} + +func TestSafeGPXFileName(t *testing.T) { + cases := map[string]string{ + "track.gpx": "track.gpx", + "My Trip": "My Trip.gpx", + "": "imported-trail.gpx", + "../../etc/passwd": "passwd.gpx", + "a:b*c?": "a-b-c-.gpx", + } + for in, want := range cases { + if got := safeGPXFileName(in); got != want { + t.Fatalf("safeGPXFileName(%q) = %q, want %q", in, got, want) + } + } +} + +func TestSafeMediaFileName(t *testing.T) { + t.Run("keeps valid filename", func(t *testing.T) { + if got := safeMediaFileName("photo.jpg"); got != "photo.jpg" { + t.Fatalf("got %q", got) + } + }) + t.Run("skips empty and slashed candidates", func(t *testing.T) { + if got := safeMediaFileName("", "a/b.jpg", "c.png"); got != "c.png" { + t.Fatalf("got %q", got) + } + }) + t.Run("falls back to photo.jpg when no candidate", func(t *testing.T) { + if got := safeMediaFileName(""); got != "photo.jpg" { + t.Fatalf("got %q", got) + } + }) + t.Run("rejects slashed traversal candidate", func(t *testing.T) { + // Candidates containing "/" are rejected outright (not stripped), so a + // path-traversal candidate falls back to the safe default name. + if got := safeMediaFileName("../../x.png"); got != "photo.jpg" { + t.Fatalf("got %q", got) + } + }) + t.Run("rejects dotdot candidate", func(t *testing.T) { + if got := safeMediaFileName(".."); got != "photo.jpg" { + t.Fatalf("got %q", got) + } + }) +} + +func TestExtensionFromContentTypes(t *testing.T) { + if got := extensionFromContentTypes("application/x-unknown-xyz"); got != ".jpg" { + t.Fatalf("expected .jpg fallback, got %q", got) + } + if got := extensionFromContentTypes("image/png"); !strings.HasPrefix(got, ".") { + t.Fatalf("expected an extension, got %q", got) + } +} + +func TestValidateRemoteMediaURLSyntax(t *testing.T) { + t.Run("rejects non-http scheme", func(t *testing.T) { + if err := validateRemoteMediaURLSyntax("ftp://example.com/x"); err == nil { + t.Fatal("expected error for ftp scheme") + } + }) + t.Run("rejects missing host", func(t *testing.T) { + if err := validateRemoteMediaURLSyntax("http://"); err == nil { + t.Fatal("expected error for missing host") + } + }) + t.Run("allows http syntax", func(t *testing.T) { + if err := validateRemoteMediaURLSyntax("https://8.8.8.8/photo.jpg"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) +} + +func TestPhotoFile(t *testing.T) { + ctx := context.Background() + + t.Run("empty url", func(t *testing.T) { + photo := pluginsystem.Photo{Source: pluginsystem.MediaSource{Type: "url"}} + if _, _, err := photoFile(ctx, photo, Options{}, 1024); err == nil { + t.Fatal("expected error for empty url") + } + }) + + t.Run("unsupported type", func(t *testing.T) { + photo := pluginsystem.Photo{Source: pluginsystem.MediaSource{Type: "carrier"}} + if _, _, err := photoFile(ctx, photo, Options{}, 1024); err == nil { + t.Fatal("expected error for unsupported source type") + } + }) +} + +func TestPluginMediaBudgetRemainingBytes(t *testing.T) { + budget := &pluginMediaBudget{} + if got := budget.remainingBytes(); got != util.DefaultPluginMediaMaxBytes { + t.Fatalf("got %d, want per-file limit %d", got, util.DefaultPluginMediaMaxBytes) + } + budget.bytes = util.DefaultPluginMaxImportMediaBytes - 10 + if got := budget.remainingBytes(); got != 10 { + t.Fatalf("got %d, want remaining aggregate budget", got) + } + budget.bytes = util.DefaultPluginMaxImportMediaBytes + if got := budget.remainingBytes(); got != 0 { + t.Fatalf("got %d, want exhausted budget", got) + } +} + +func TestRemoveRawQueryParamOrdered(t *testing.T) { + raw := "z=last&api_key=secret&a=first&api_key=second" + if got := removeRawQueryParamOrdered(raw, "api_key"); got != "z=last&a=first" { + t.Fatalf("unexpected query: %q", got) + } +} diff --git a/db/pluginsystem/auth_fields.go b/db/pluginsystem/auth_fields.go new file mode 100644 index 00000000..98909021 --- /dev/null +++ b/db/pluginsystem/auth_fields.go @@ -0,0 +1,38 @@ +package pluginsystem + +const ( + AuthFieldAccessToken = "accessToken" + AuthFieldRefreshToken = "refreshToken" + AuthFieldClientSecret = "clientSecret" + AuthFieldOAuthState = "oauthState" + AuthFieldOAuthCodeVerifier = "oauthCodeVerifier" + AuthFieldOAuthRedirectURI = "oauthRedirectURI" +) + +func InternalAuthSecretFields() []string { + return []string{ + AuthFieldAccessToken, + AuthFieldRefreshToken, + AuthFieldClientSecret, + AuthFieldOAuthState, + AuthFieldOAuthCodeVerifier, + } +} + +func InternalOAuthTransientFields() []string { + return []string{ + AuthFieldOAuthState, + AuthFieldOAuthCodeVerifier, + AuthFieldOAuthRedirectURI, + } +} + +func PluginInputAuthBlockedFields() []string { + return []string{ + AuthFieldRefreshToken, + AuthFieldClientSecret, + AuthFieldOAuthState, + AuthFieldOAuthCodeVerifier, + AuthFieldOAuthRedirectURI, + } +} diff --git a/db/pluginsystem/auth_injection.go b/db/pluginsystem/auth_injection.go new file mode 100644 index 00000000..332c0515 --- /dev/null +++ b/db/pluginsystem/auth_injection.go @@ -0,0 +1,349 @@ +package pluginsystem + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/pocketbase/pocketbase/core" +) + +type AuthInjectionInput struct { + App core.App + Runtime Runtime + Session RuntimeSession + Plugin LocalPlugin + Instance *core.Record + Auth map[string]any + Config map[string]any + Spec *HostRequestSpec + Policy RequestPolicyContext +} + +func InjectRequestAuthForContext(manifest Manifest, auth map[string]any, contextName string, req *http.Request) error { + if contextName == "" { + return nil + } + if err := ValidateAuthReference(manifest, contextName); err != nil { + return err + } + authContext, ok := manifest.Auth.Contexts[contextName] + if !ok { + return fmt.Errorf("plugin requested unknown auth context") + } + switch authContext.Type { + case AuthTypeOAuth2: + token := StringFromAny(auth[AuthFieldAccessToken]) + if token == "" { + return fmt.Errorf("oauth access token is missing") + } + scheme := StringFromAny(auth[AuthFieldTokenType]) + if scheme == "" { + scheme = AuthSchemeBearer + } + req.Header.Set(AuthHeaderAuthorization, scheme+" "+token) + case AuthTypeAPIKey: + secret := StringFromAny(auth[authContext.SecretField]) + if secret == "" { + return fmt.Errorf("api key is missing") + } + name := authContext.Name + if name == "" { + name = authContext.SecretField + } + if authContext.Placement == AuthPlacementQuery { + req.URL.RawQuery = setRawQueryParamOrdered(req.URL.RawQuery, name, secret) + } else { + req.Header.Set(name, secret) + } + case AuthTypeBearer: + secret := StringFromAny(auth[authContext.SecretField]) + if secret == "" { + return fmt.Errorf("bearer token is missing") + } + req.Header.Set(AuthHeaderAuthorization, AuthSchemeBearer+" "+secret) + default: + return fmt.Errorf("auth context %q is not supported for media requests", contextName) + } + return nil +} + +func InjectHostRequestAuthFromPolicy(manifest Manifest, auth map[string]any, spec *HostRequestSpec) error { + if spec == nil || spec.Auth == "" { + return nil + } + if err := ValidateAuthReference(manifest, spec.Auth); err != nil { + return err + } + authContext, ok := manifest.Auth.Contexts[spec.Auth] + if !ok { + return fmt.Errorf("plugin requested unknown auth context") + } + switch authContext.Type { + case AuthTypeOAuth2: + token := StringFromAny(auth[AuthFieldAccessToken]) + if token == "" { + return fmt.Errorf("oauth access token is missing") + } + scheme := StringFromAny(auth[AuthFieldTokenType]) + if scheme == "" { + scheme = AuthSchemeBearer + } + setAuthHeader(spec, scheme+" "+token) + case AuthTypeAPIKey: + return injectAPIKeyAuth(authContext, auth, spec) + case AuthTypeBearer: + return injectBearerAuth(authContext, auth, spec) + case AuthTypeSession: + return fmt.Errorf("session auth requires handler-managed injection") + default: + return fmt.Errorf("auth context is not supported for host requests") + } + return nil +} + +type pluginSessionResponse struct { + Token string `json:"token"` + Scheme string `json:"scheme,omitempty"` + Expires string `json:"expiresAt,omitempty"` +} + +// ValidateAuthContext checks that a manifest auth context contains enough data +// for the host to own OAuth/API key/session injection safely. +func ValidateAuthContext(name string, context AuthContext) error { + switch context.Type { + case AuthTypeOAuth2: + if context.AuthorizationURL == "" || context.TokenURL == "" { + return fmt.Errorf("oauth2 auth context %s requires authorizationUrl and tokenUrl", name) + } + if _, err := url.ParseRequestURI(context.AuthorizationURL); err != nil { + return fmt.Errorf("auth context %s authorizationUrl: %w", name, err) + } + if _, err := url.ParseRequestURI(context.TokenURL); err != nil { + return fmt.Errorf("auth context %s tokenUrl: %w", name, err) + } + if context.Refresh == nil || context.Refresh.Mode != AuthRefreshModeHost { + return fmt.Errorf("oauth2 auth context %s must use host refresh", name) + } + case AuthTypeAPIKey, AuthTypeBearer: + if context.SecretField == "" { + return fmt.Errorf("%s auth context %s requires secretField", context.Type, name) + } + case AuthTypeSession: + if context.Refresh == nil || context.Refresh.Mode != AuthRefreshModePlugin || context.Refresh.Function == "" { + return fmt.Errorf("session auth context %s requires plugin refresh function", name) + } + if len(context.SecretFields) == 0 { + return fmt.Errorf("session auth context %s requires secretFields", name) + } + default: + return fmt.Errorf("auth context %s has unsupported type %q", name, context.Type) + } + return nil +} + +// InjectHostRequestAuth resolves the auth reference from a HostRequestSpec and +// mutates the request with the provider-specific header/query/session token. +func InjectHostRequestAuth(ctx context.Context, input AuthInjectionInput) error { + if input.Spec == nil { + return fmt.Errorf("host request spec is required") + } + if input.Spec.Auth == "" { + return nil + } + if err := ValidateAuthReference(input.Plugin.Manifest, input.Spec.Auth); err != nil { + return err + } + authContext, ok := input.Plugin.Manifest.Auth.Contexts[input.Spec.Auth] + if !ok { + return fmt.Errorf("plugin requested unknown auth context") + } + + switch authContext.Type { + case AuthTypeOAuth2: + return injectOAuthAuth(ctx, input, input.Spec.Auth) + case AuthTypeAPIKey: + return injectAPIKeyAuth(authContext, input.Auth, input.Spec) + case AuthTypeBearer: + return injectBearerAuth(authContext, input.Auth, input.Spec) + case AuthTypeSession: + return injectSessionAuth(ctx, input, authContext) + default: + return fmt.Errorf("auth context is not supported for route sending") + } +} + +func injectOAuthAuth(ctx context.Context, input AuthInjectionInput, contextName string) error { + if input.Instance == nil { + return fmt.Errorf("plugin instance is required") + } + auth := input.Auth + if OAuthNeedsRefresh(auth) { + refreshed, err := RefreshOAuthToken(ctx, input.App, input.Plugin, input.Instance, auth, contextName) + if err != nil { + return fmt.Errorf("oauth token refresh failed: %w", err) + } + auth = refreshed + } + token := StringFromAny(auth[AuthFieldAccessToken]) + if token == "" { + return fmt.Errorf("oauth access token is missing") + } + scheme := StringFromAny(auth[AuthFieldTokenType]) + if scheme == "" { + scheme = AuthSchemeBearer + } + setAuthHeader(input.Spec, scheme+" "+token) + return nil +} + +func injectAPIKeyAuth(authContext AuthContext, auth map[string]any, spec *HostRequestSpec) error { + secret := StringFromAny(auth[authContext.SecretField]) + if secret == "" { + return fmt.Errorf("api key is missing") + } + if authContext.Placement == AuthPlacementQuery { + name := authContext.Name + if name == "" { + name = authContext.SecretField + } + query := make([]QueryParam, 0, len(spec.Target.Query)+1) + for _, param := range spec.Target.Query { + if param.Name != name { + query = append(query, param) + } + } + query = append(query, QueryParam{Name: name, Value: secret}) + spec.Target.Query = query + return nil + } + name := authContext.Name + if name == "" { + name = AuthHeaderAuthorization + } + if spec.Headers == nil { + spec.Headers = map[string]string{} + } + spec.Headers[name] = secret + return nil +} + +func injectBearerAuth(authContext AuthContext, auth map[string]any, spec *HostRequestSpec) error { + secret := StringFromAny(auth[authContext.SecretField]) + if secret == "" { + return fmt.Errorf("bearer token is missing") + } + setAuthHeader(spec, AuthSchemeBearer+" "+secret) + return nil +} + +func injectSessionAuth(ctx context.Context, input AuthInjectionInput, authContext AuthContext) error { + if authContext.Refresh == nil || authContext.Refresh.Mode != AuthRefreshModePlugin { + return fmt.Errorf("session auth context is not supported") + } + if input.Instance == nil { + return fmt.Errorf("plugin instance is required") + } + + pluginInput := map[string]any{ + "instance": InstanceRef{ + ID: input.Instance.Id, + PluginID: input.Instance.GetString("plugin_id"), + }, + "auth": AuthForPluginRefresh(input.Auth, authContext), + "config": input.Config, + } + inputBytes, err := json.Marshal(pluginInput) + if err != nil { + return err + } + var output []byte + if input.Session != nil { + output, err = input.Session.Call(ctx, authContext.Refresh.Function, inputBytes) + } else { + output, err = input.Runtime.Call(ctx, input.Plugin, authContext.Refresh.Function, inputBytes, input.Policy) + } + if err != nil { + return err + } + var session pluginSessionResponse + if err := validatePluginSessionRefreshOutput(output, &session); err != nil { + return err + } + scheme := session.Scheme + if scheme == "" { + scheme = AuthSchemeBearer + } + setAuthHeader(input.Spec, scheme+" "+session.Token) + return nil +} + +func ValidatePluginSessionRefreshOutput(output []byte) error { + var session pluginSessionResponse + return validatePluginSessionRefreshOutput(output, &session) +} + +func validatePluginSessionRefreshOutput(output []byte, session *pluginSessionResponse) error { + if err := json.Unmarshal(output, session); err != nil { + return fmt.Errorf("plugin returned an invalid session: %w", err) + } + if session.Token == "" { + return fmt.Errorf("plugin returned an empty session token") + } + return nil +} + +func setAuthHeader(spec *HostRequestSpec, value string) { + if spec.Headers == nil { + spec.Headers = map[string]string{} + } + spec.Headers[AuthHeaderAuthorization] = value +} + +func setRawQueryParamOrdered(rawQuery string, name string, value string) string { + encoded := url.QueryEscape(name) + "=" + url.QueryEscape(value) + if rawQuery == "" { + return encoded + } + parts := strings.Split(rawQuery, "&") + kept := make([]string, 0, len(parts)+1) + for _, part := range parts { + if part == "" { + continue + } + rawName := part + if idx := strings.Index(rawName, "="); idx >= 0 { + rawName = rawName[:idx] + } + decodedName, err := url.QueryUnescape(rawName) + if err == nil && decodedName == name { + continue + } + kept = append(kept, part) + } + kept = append(kept, encoded) + return strings.Join(kept, "&") +} + +func AuthForPluginRefresh(auth map[string]any, authContext AuthContext) map[string]any { + filtered := map[string]any{} + for _, field := range authContext.Fields { + if value, ok := auth[field]; ok { + filtered[field] = value + } + } + for _, field := range authContext.SecretFields { + if value, ok := auth[field]; ok { + filtered[field] = value + } + } + if authContext.SecretField != "" { + if value, ok := auth[authContext.SecretField]; ok { + filtered[authContext.SecretField] = value + } + } + return filtered +} diff --git a/db/pluginsystem/auth_injection_test.go b/db/pluginsystem/auth_injection_test.go new file mode 100644 index 00000000..7ea7b6e9 --- /dev/null +++ b/db/pluginsystem/auth_injection_test.go @@ -0,0 +1,288 @@ +package pluginsystem + +import ( + "context" + "net/http" + "testing" +) + +func TestValidateAuthContext(t *testing.T) { + tests := []struct { + name string + context AuthContext + wantErr bool + }{ + { + name: "oauth2", + context: AuthContext{ + Type: AuthTypeOAuth2, + AuthorizationURL: "https://example.com/oauth/authorize", + TokenURL: "https://example.com/oauth/token", + Refresh: &AuthRefresh{Mode: AuthRefreshModeHost}, + }, + }, + { + name: "missing bearer secret", + context: AuthContext{Type: AuthTypeBearer}, + wantErr: true, + }, + { + name: "session", + context: AuthContext{ + Type: AuthTypeSession, + SecretFields: []string{"email", "password"}, + Refresh: &AuthRefresh{Mode: AuthRefreshModePlugin, Function: "refresh_session_v1"}, + }, + }, + { + name: "unsupported", + context: AuthContext{Type: "mtls"}, + wantErr: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := ValidateAuthContext("default", test.context) + if test.wantErr && err == nil { + t.Fatal("expected error") + } + if !test.wantErr && err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestInjectHostRequestAuthWithBearer(t *testing.T) { + spec := HostRequestSpec{Auth: "account"} + + err := InjectHostRequestAuth(context.Background(), AuthInjectionInput{ + Plugin: LocalPlugin{Manifest: Manifest{ + Auth: AuthManifest{Contexts: map[string]AuthContext{ + "account": { + Type: AuthTypeBearer, + SecretField: "token", + }, + }}, + Permissions: PermissionManifest{Auth: []string{"account"}}, + }}, + Auth: map[string]any{"token": "abc123"}, + Spec: &spec, + }) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := spec.Headers[AuthHeaderAuthorization]; got != AuthSchemeBearer+" abc123" { + t.Fatalf("unexpected authorization header: %q", got) + } +} + +func TestInjectHostRequestAuthWithAPIKeyQuery(t *testing.T) { + spec := HostRequestSpec{ + Auth: "account", + Target: RequestTarget{ + Type: "connector", + Connector: "api", + Path: "/upload", + Query: []QueryParam{{Name: "existing", Value: "true"}}, + }, + } + + err := InjectHostRequestAuth(context.Background(), AuthInjectionInput{ + Plugin: LocalPlugin{Manifest: Manifest{ + Auth: AuthManifest{Contexts: map[string]AuthContext{ + "account": { + Type: AuthTypeAPIKey, + SecretField: "apiKey", + Placement: AuthPlacementQuery, + Name: "key", + }, + }}, + Permissions: PermissionManifest{Auth: []string{"account"}}, + }}, + Auth: map[string]any{"apiKey": "secret"}, + Spec: &spec, + }) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(spec.Target.Query) != 2 || spec.Target.Query[1].Name != "key" || spec.Target.Query[1].Value != "secret" { + t.Fatalf("unexpected query: %#v", spec.Target.Query) + } +} + +func TestInjectHostRequestAuthFromPolicyWithBearer(t *testing.T) { + spec := HostRequestSpec{ + Auth: "account", + Headers: map[string]string{AuthHeaderAuthorization: "plugin supplied"}, + } + manifest := Manifest{ + Auth: AuthManifest{Contexts: map[string]AuthContext{ + "account": {Type: AuthTypeBearer, SecretField: "token"}, + }}, + Permissions: PermissionManifest{Auth: []string{"account"}}, + } + + err := InjectHostRequestAuthFromPolicy(manifest, map[string]any{"token": "host-secret"}, &spec) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := spec.Headers[AuthHeaderAuthorization]; got != AuthSchemeBearer+" host-secret" { + t.Fatalf("unexpected authorization header: %q", got) + } +} + +func TestInjectHostRequestAuthFromPolicyFailsWithEmptyAuth(t *testing.T) { + spec := HostRequestSpec{ + Auth: "account", + Headers: map[string]string{AuthHeaderAuthorization: "plugin supplied"}, + } + manifest := Manifest{ + Auth: AuthManifest{Contexts: map[string]AuthContext{ + "account": {Type: AuthTypeBearer, SecretField: "token"}, + }}, + Permissions: PermissionManifest{Auth: []string{"account"}}, + } + + err := InjectHostRequestAuthFromPolicy(manifest, map[string]any{}, &spec) + if err == nil { + t.Fatal("expected error") + } + if err.Error() != "bearer token is missing" { + t.Fatalf("unexpected error: %v", err) + } + if got := spec.Headers[AuthHeaderAuthorization]; got != "plugin supplied" { + t.Fatalf("unexpected authorization header mutation: %q", got) + } +} + +func TestInjectHostRequestAuthFromPolicyRejectsSessionAuth(t *testing.T) { + spec := HostRequestSpec{Auth: "account"} + manifest := Manifest{ + Auth: AuthManifest{Contexts: map[string]AuthContext{ + "account": { + Type: AuthTypeSession, + SecretFields: []string{"password"}, + Refresh: &AuthRefresh{Mode: AuthRefreshModePlugin, Function: "refresh_session_v1"}, + }, + }}, + Permissions: PermissionManifest{Auth: []string{"account"}}, + } + + err := InjectHostRequestAuthFromPolicy(manifest, map[string]any{"password": "secret"}, &spec) + if err == nil { + t.Fatal("expected error") + } + if err.Error() != "session auth requires handler-managed injection" { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestInjectHostRequestAuthFromPolicyWithAPIKeyQuery(t *testing.T) { + spec := HostRequestSpec{ + Auth: "account", + Target: RequestTarget{ + Type: "connector", + Path: "/assets", + Query: []QueryParam{{Name: "api_key", Value: "plugin"}}, + }, + } + manifest := Manifest{ + Auth: AuthManifest{Contexts: map[string]AuthContext{ + "account": { + Type: AuthTypeAPIKey, + SecretField: "apiKey", + Placement: AuthPlacementQuery, + Name: "api_key", + }, + }}, + Permissions: PermissionManifest{Auth: []string{"account"}}, + } + + err := InjectHostRequestAuthFromPolicy(manifest, map[string]any{"apiKey": "host-secret"}, &spec) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(spec.Target.Query) != 1 || spec.Target.Query[0].Value != "host-secret" { + t.Fatalf("unexpected query: %#v", spec.Target.Query) + } +} + +func TestInjectHostRequestAuthRequiresSpec(t *testing.T) { + err := InjectHostRequestAuth(context.Background(), AuthInjectionInput{}) + if err == nil { + t.Fatal("expected error") + } + if err.Error() != "host request spec is required" { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestInjectHostRequestAuthValidatesPermission(t *testing.T) { + spec := HostRequestSpec{Auth: "account"} + err := InjectHostRequestAuth(context.Background(), AuthInjectionInput{ + Plugin: LocalPlugin{Manifest: Manifest{ + Auth: AuthManifest{Contexts: map[string]AuthContext{ + "account": {Type: AuthTypeBearer, SecretField: "token"}, + }}, + }}, + Auth: map[string]any{"token": "abc123"}, + Spec: &spec, + }) + if err == nil { + t.Fatal("expected error") + } + if err.Error() != `auth context "account" is not permitted` { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestInjectRequestAuthForContextPreservesQueryOrder(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "https://example.test/media?z=last&api_key=plugin&a=first", nil) + if err != nil { + t.Fatal(err) + } + manifest := Manifest{ + Auth: AuthManifest{Contexts: map[string]AuthContext{ + "account": { + Type: AuthTypeAPIKey, + SecretField: "apiKey", + Placement: AuthPlacementQuery, + Name: "api_key", + }, + }}, + Permissions: PermissionManifest{Auth: []string{"account"}}, + } + + err = InjectRequestAuthForContext(manifest, map[string]any{"apiKey": "host-secret"}, "account", req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if req.URL.RawQuery != "z=last&a=first&api_key=host-secret" { + t.Fatalf("unexpected raw query: %q", req.URL.RawQuery) + } +} + +func TestAuthForPluginRefresh(t *testing.T) { + filtered := AuthForPluginRefresh(map[string]any{ + "email": "user@example.com", + "password": "secret", + "accessToken": "token", + }, AuthContext{ + Fields: []string{"email", "password"}, + SecretFields: []string{"password"}, + }) + + if len(filtered) != 2 { + t.Fatalf("unexpected filtered auth: %#v", filtered) + } + if filtered["email"] != "user@example.com" || filtered["password"] != "secret" { + t.Fatalf("unexpected filtered auth: %#v", filtered) + } + if _, ok := filtered["accessToken"]; ok { + t.Fatalf("unexpected access token in plugin refresh auth: %#v", filtered) + } +} diff --git a/db/pluginsystem/host_http.go b/db/pluginsystem/host_http.go new file mode 100644 index 00000000..91ab9674 --- /dev/null +++ b/db/pluginsystem/host_http.go @@ -0,0 +1,410 @@ +package pluginsystem + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "mime" + "mime/multipart" + "net/http" + "net/url" + "strings" + + "pocketbase/util" + + extism "github.com/extism/go-sdk" +) + +type hostHTTPResponse struct { + Status int `json:"status"` + HeaderValues map[string][]string `json:"headerValues,omitempty"` + BodyBase64 string `json:"bodyBase64,omitempty"` + Error *PluginError `json:"error,omitempty"` +} + +type HostRequestOptions struct { + Trail []byte +} + +type HostResponse struct { + Status int + HeaderValues map[string][]string + Body []byte +} + +var newConnectorHTTPClient = util.ConnectorHTTPClient + +const maxHostLogPayloadBytes = 8 * 1024 + +// extismHostFunctions exposes the host APIs that WASM plugins may call. Each +// function must delegate to the same policy-controlled host implementation that +// backend handlers use. +func extismHostFunctions(manifest Manifest, policy RequestPolicyContext) []extism.HostFunction { + httpFn := extism.NewHostFunctionWithStack( + "http_request", + func(ctx context.Context, plugin *extism.CurrentPlugin, stack []uint64) { + requestBytes, err := plugin.ReadBytes(stack[0]) + if err != nil { + writeHostHTTPResponse(ctx, plugin, stack, hostHTTPResponse{ + Error: &PluginError{Code: "invalid_request", Message: err.Error()}, + }) + return + } + response := executeHostHTTPRequest(ctx, manifest, policy, requestBytes) + writeHostHTTPResponse(ctx, plugin, stack, response) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) + httpFn.SetNamespace("wanderer") + + logFn := extism.NewHostFunctionWithStack( + "log", + func(ctx context.Context, plugin *extism.CurrentPlugin, stack []uint64) { + message, err := readBoundedHostLogPayload(plugin, stack[0]) + if err != nil { + plugin.Log(extism.LogLevelError, "read host log message: "+err.Error()) + return + } + entry, err := parseHostLogEntry(message) + if err != nil { + plugin.Log(extism.LogLevelError, "invalid host log message: "+err.Error()) + return + } + log.Printf("plugin log [%s]: %s", entry.Level, entry.Message) + _ = ctx + }, + []extism.ValueType{extism.ValueTypePTR}, + nil, + ) + logFn.SetNamespace("wanderer") + + return []extism.HostFunction{httpFn, logFn} +} + +func readBoundedHostLogPayload(plugin *extism.CurrentPlugin, offset uint64) ([]byte, error) { + length, err := plugin.Length(offset) + if err != nil { + return nil, err + } + if length > maxHostLogPayloadBytes { + return nil, fmt.Errorf("log message exceeds maximum size") + } + return plugin.ReadBytes(offset) +} + +func parseHostLogEntry(message []byte) (HostLogEntry, error) { + if len(message) > maxHostLogPayloadBytes { + return HostLogEntry{}, fmt.Errorf("log message exceeds maximum size") + } + var entry HostLogEntry + if err := json.Unmarshal(message, &entry); err != nil { + return HostLogEntry{}, fmt.Errorf("decode log entry: %w", err) + } + level, err := normalizeHostLogLevel(entry.Level) + if err != nil { + return HostLogEntry{}, err + } + entry.Level = level + entry.Message = sanitizeHostLogMessage(entry.Message) + if entry.Message == "" { + return HostLogEntry{}, fmt.Errorf("log message is required") + } + return entry, nil +} + +func sanitizeHostLogMessage(message string) string { + return strings.TrimSpace(strings.Map(func(r rune) rune { + if r < 0x20 || r == 0x7f { + return ' ' + } + return r + }, message)) +} + +func normalizeHostLogLevel(level string) (string, error) { + switch strings.ToLower(strings.TrimSpace(level)) { + case "debug": + return "debug", nil + case "info": + return "info", nil + case "warn": + return "warn", nil + case "error": + return "error", nil + default: + return "", fmt.Errorf("unsupported log level %q", level) + } +} + +// executeHostHTTPRequest turns a raw plugin http_request payload into the +// hostHTTPResponse that the plugin reads back. It is the single source of truth +// for the request/response contract shared by the in-process runtime +// (extismHostFunctions) and the worker process (handleHostHTTPRequest), so the +// two paths cannot drift on error codes or response shape. +func executeHostHTTPRequest(ctx context.Context, manifest Manifest, policy RequestPolicyContext, requestBytes []byte) hostHTTPResponse { + var spec HostRequestSpec + if err := json.Unmarshal(requestBytes, &spec); err != nil { + return hostHTTPResponse{ + Error: &PluginError{Code: "invalid_request", Message: "invalid host request: " + err.Error()}, + } + } + executed, err := ExecuteHostRequest(ctx, manifest, policy, spec, HostRequestOptions{}) + if err != nil { + return hostHTTPResponse{ + Error: &PluginError{Code: "provider_unavailable", Message: err.Error()}, + } + } + return hostHTTPResponse{ + Status: executed.Status, + HeaderValues: executed.HeaderValues, + BodyBase64: base64.StdEncoding.EncodeToString(executed.Body), + } +} + +func writeHostHTTPResponse(ctx context.Context, plugin *extism.CurrentPlugin, stack []uint64, response hostHTTPResponse) { + responseBytes, err := json.Marshal(response) + if err != nil { + responseBytes, _ = json.Marshal(hostHTTPResponse{ + Error: &PluginError{Code: "internal_error", Message: err.Error()}, + }) + } + offset, err := plugin.WriteBytes(responseBytes) + if err != nil { + plugin.Log(extism.LogLevelError, "write host http response: "+err.Error()) + stack[0] = 0 + return + } + stack[0] = offset + _ = ctx +} + +// ExecuteHostRequest is the single network chokepoint for plugin-controlled +// HTTP. It validates manifest policy, builds optional request bodies, enforces +// upload/response limits, follows only permitted redirects, and returns the +// bounded provider response. +func ExecuteHostRequest(ctx context.Context, manifest Manifest, policy RequestPolicyContext, spec HostRequestSpec, options HostRequestOptions) (HostResponse, error) { + if err := InjectHostRequestAuthFromPolicy(manifest, policy.HostAuth, &spec); err != nil { + return HostResponse{}, err + } + resolved, err := ValidateAndResolveHostRequestSpec(manifest, spec, policy) + if err != nil { + return HostResponse{}, err + } + + body, contentType, bodySize, err := hostRequestBody(spec, options) + if err != nil { + return HostResponse{}, err + } + if err := validateHostRequestUpload(manifest, spec, contentType, bodySize); err != nil { + return HostResponse{}, err + } + req, err := http.NewRequestWithContext(ctx, spec.Method, resolved.URL.String(), body) + if err != nil { + return HostResponse{}, err + } + for key, value := range spec.Headers { + req.Header.Set(key, value) + } + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + if req.Header.Get("Accept") == "" { + req.Header.Set("Accept", "application/json") + } + + client, err := newConnectorHTTPClient(util.ConnectorHTTPPolicy{ + BaseURL: resolved.Connector.BaseURL, + AllowPrivate: resolved.Connector.AllowPrivate, + TLSMode: resolved.Connector.TLS.Mode, + TLSCABundle: resolved.Connector.TLS.CABundle, + }, func(req *http.Request, via []*http.Request) error { + if spec.FollowRedirects != nil && !*spec.FollowRedirects { + return http.ErrUseLastResponse + } + if len(via) >= 10 { + return fmt.Errorf("too many redirects") + } + previous := resolved.URL + if len(via) > 0 { + previous = via[len(via)-1].URL + } + return ValidateConnectorRedirect(resolved.Connector, previous, req.URL) + }) + if err != nil { + return HostResponse{}, err + } + resp, err := client.Do(req) + if err != nil { + return HostResponse{}, err + } + defer resp.Body.Close() + + if err := validateHostHTTPResponse(manifest, spec, resp); err != nil { + return HostResponse{}, err + } + maxBytes := effectiveResponseMaxBytes(manifest, spec) + limit := maxBytes + if limit <= 0 { + limit = 1 << 20 + } + bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, limit+1)) + if err != nil { + return HostResponse{}, err + } + if maxBytes > 0 && int64(len(bodyBytes)) > maxBytes { + return HostResponse{}, fmt.Errorf("provider response exceeds maximum size") + } + if maxBytes <= 0 && int64(len(bodyBytes)) > limit { + return HostResponse{}, fmt.Errorf("provider response exceeds default maximum size") + } + + headerValues := map[string][]string{} + for key, values := range resp.Header { + if len(values) > 0 { + headerValues[key] = append([]string{}, values...) + } + } + return HostResponse{ + Status: resp.StatusCode, + HeaderValues: headerValues, + Body: bodyBytes, + }, nil +} + +func hostRequestBody(spec HostRequestSpec, options HostRequestOptions) (io.Reader, string, int64, error) { + if spec.Body == nil { + return nil, "", 0, nil + } + switch spec.Body.Type { + case HostRequestBodyTypeJSON: + body, err := json.Marshal(spec.Body.JSON) + if err != nil { + return nil, "", 0, err + } + return bytes.NewReader(body), "application/json", int64(len(body)), nil + case HostRequestBodyTypeForm: + body, err := formURLEncodedBody(spec.Body.Form) + if err != nil { + return nil, "", 0, err + } + return strings.NewReader(body), "application/x-www-form-urlencoded", int64(len(body)), nil + case HostRequestBodyTypeMultipart: + var body bytes.Buffer + writer := multipart.NewWriter(&body) + for _, part := range spec.Body.Parts { + if part.Source == MultipartSourceTrail || part.Source == MultipartSourceTrailGPX { + if len(options.Trail) == 0 { + return nil, "", 0, fmt.Errorf("multipart part %q requires trail content", part.Name) + } + filename := part.Filename + if filename == "" { + filename = MultipartTrailFilename + } + partWriter, err := writer.CreateFormFile(part.Name, filename) + if err != nil { + return nil, "", 0, err + } + if _, err := partWriter.Write(options.Trail); err != nil { + return nil, "", 0, err + } + continue + } + if part.JSON != nil { + data, err := json.Marshal(part.JSON) + if err != nil { + return nil, "", 0, err + } + if err := writer.WriteField(part.Name, string(data)); err != nil { + return nil, "", 0, err + } + } + } + if err := writer.Close(); err != nil { + return nil, "", 0, err + } + return &body, writer.FormDataContentType(), int64(body.Len()), nil + default: + return nil, "", 0, fmt.Errorf("unsupported host request body type %q", spec.Body.Type) + } +} + +func formURLEncodedBody(fields []FormField) (string, error) { + encoded := make([]string, 0, len(fields)) + for _, field := range fields { + if field.Name == "" { + return "", fmt.Errorf("form field name must not be empty") + } + if hasControl(field.Name) || hasControl(field.Value) { + return "", fmt.Errorf("form fields must not contain control characters") + } + encoded = append(encoded, url.QueryEscape(field.Name)+"="+url.QueryEscape(field.Value)) + } + return strings.Join(encoded, "&"), nil +} + +func validateHostRequestUpload(manifest Manifest, spec HostRequestSpec, contentType string, bodySize int64) error { + if spec.Body == nil { + return nil + } + if manifest.Permissions.Uploads.MaxBytes > 0 && bodySize > manifest.Permissions.Uploads.MaxBytes { + return fmt.Errorf("host request upload exceeds manifest upload limit") + } + if contentType == "" || len(manifest.Permissions.Uploads.ContentTypes) == 0 { + return nil + } + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil { + return fmt.Errorf("host request upload has invalid content type") + } + for _, allowed := range manifest.Permissions.Uploads.ContentTypes { + if strings.EqualFold(mediaType, allowed) { + return nil + } + } + return fmt.Errorf("host request upload content type %q is not allowed", mediaType) +} + +func validateHostHTTPResponse(manifest Manifest, spec HostRequestSpec, resp *http.Response) error { + allowedContentTypes := effectiveResponseContentTypes(manifest, spec) + if resp.StatusCode >= 200 && resp.StatusCode < 300 && len(allowedContentTypes) > 0 { + contentType := resp.Header.Get("Content-Type") + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil || mediaType == "" { + return fmt.Errorf("provider response has invalid content type") + } + allowed := false + for _, expected := range allowedContentTypes { + if strings.EqualFold(mediaType, expected) { + allowed = true + break + } + } + if !allowed { + return fmt.Errorf("provider response content type %q is not allowed", mediaType) + } + } + maxBytes := effectiveResponseMaxBytes(manifest, spec) + if maxBytes > 0 && resp.ContentLength > maxBytes { + return fmt.Errorf("provider response exceeds maximum size") + } + return nil +} + +func effectiveResponseContentTypes(manifest Manifest, spec HostRequestSpec) []string { + if len(spec.Expect.ContentTypes) > 0 { + return spec.Expect.ContentTypes + } + return manifest.Permissions.Downloads.ContentTypes +} + +func effectiveResponseMaxBytes(manifest Manifest, spec HostRequestSpec) int64 { + if spec.Expect.MaxBytes > 0 { + return spec.Expect.MaxBytes + } + return manifest.Permissions.Downloads.MaxBytes +} diff --git a/db/pluginsystem/host_http_test.go b/db/pluginsystem/host_http_test.go new file mode 100644 index 00000000..ac375860 --- /dev/null +++ b/db/pluginsystem/host_http_test.go @@ -0,0 +1,407 @@ +package pluginsystem + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "pocketbase/util" +) + +func TestParseHostLogEntry(t *testing.T) { + payload, err := json.Marshal(HostLogEntry{Level: "warn", Message: " slow request "}) + if err != nil { + t.Fatal(err) + } + entry, err := parseHostLogEntry(payload) + if err != nil { + t.Fatal(err) + } + if entry.Level != "warn" || entry.Message != "slow request" { + t.Fatalf("unexpected structured entry: %#v", entry) + } + + if _, err := parseHostLogEntry([]byte(" plain message ")); err == nil { + t.Fatal("expected plain log message to fail") + } + + if _, err := parseHostLogEntry([]byte(`{"level":"verbose","message":"hello"}`)); err == nil { + t.Fatal("expected unsupported log level to fail") + } + + if _, err := parseHostLogEntry([]byte(`{"level":"info","message":" "}`)); err == nil { + t.Fatal("expected empty log message to fail") + } +} + +func TestParseHostLogEntrySanitizesMessage(t *testing.T) { + entry, err := parseHostLogEntry([]byte(`{"level":"info","message":"first\nsecond\rthird\tfourth"}`)) + if err != nil { + t.Fatal(err) + } + if entry.Message != "first second third fourth" { + t.Fatalf("unexpected sanitized message: %q", entry.Message) + } +} + +func TestParseHostLogEntryRejectsOversizedPayload(t *testing.T) { + payload := []byte(`{"level":"info","message":"` + strings.Repeat("x", maxHostLogPayloadBytes) + `"}`) + if _, err := parseHostLogEntry(payload); err == nil { + t.Fatal("expected oversized log payload to fail") + } +} + +func TestExecuteHostRequestRejectsRedirectToUndeclaredHost(t *testing.T) { + useUnsafeTestHTTPClient(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "https://evil.example.test/v1/upload", http.StatusFound) + })) + defer server.Close() + + _, err := ExecuteHostRequest(context.Background(), testHostManifest(t, server.URL), testHostPolicy(t, server.URL), HostRequestSpec{ + Method: "GET", + Target: RequestTarget{ + Type: "connector", + Connector: "api", + Path: "/v1", + }, + }, HostRequestOptions{}) + if err == nil { + t.Fatal("expected redirect policy error") + } +} + +func TestExecuteHostRequestRejectsRedirectOutsidePathScope(t *testing.T) { + useUnsafeTestHTTPClient(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/admin", http.StatusFound) + })) + defer server.Close() + + _, err := ExecuteHostRequest(context.Background(), testHostManifest(t, server.URL), testHostPolicy(t, server.URL), HostRequestSpec{ + Method: "GET", + Target: RequestTarget{Type: "connector", Connector: "api", Path: "/v1"}, + }, HostRequestOptions{}) + if err == nil { + t.Fatal("expected redirect policy error") + } +} + +func TestExecuteHostRequestEnforcesResponseLimit(t *testing.T) { + useUnsafeTestHTTPClient(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"too":"large"}`)) + })) + defer server.Close() + + _, err := ExecuteHostRequest(context.Background(), testHostManifest(t, server.URL), testHostPolicy(t, server.URL), HostRequestSpec{ + Method: "GET", + Target: RequestTarget{Type: "connector", Connector: "api", Path: "/v1"}, + Expect: ResponseExpect{ + ContentTypes: []string{"application/json"}, + MaxBytes: 4, + }, + }, HostRequestOptions{}) + if err == nil { + t.Fatal("expected maxBytes error") + } +} + +func TestExecuteHostRequestAllowsErrorResponseWithoutContentType(t *testing.T) { + useUnsafeTestHTTPClient(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`missing credentials`)) + })) + defer server.Close() + + resp, err := ExecuteHostRequest(context.Background(), testHostManifest(t, server.URL), testHostPolicy(t, server.URL), HostRequestSpec{ + Method: "GET", + Target: RequestTarget{Type: "connector", Connector: "api", Path: "/v1"}, + Expect: ResponseExpect{ + ContentTypes: []string{"application/json"}, + MaxBytes: 1024, + }, + }, HostRequestOptions{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Status != http.StatusUnauthorized || string(resp.Body) != "missing credentials" { + t.Fatalf("unexpected response: %#v body=%q", resp, string(resp.Body)) + } +} + +func TestExecuteHostRequestInjectsAPIKeyQueryBeforeBuildingURL(t *testing.T) { + useUnsafeTestHTTPClient(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("api_key"); got != "host-secret" { + t.Fatalf("api_key = %q, want host-secret; raw query %q", got, r.URL.RawQuery) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + manifest := testHostManifest(t, server.URL) + manifest.Auth = AuthManifest{Contexts: map[string]AuthContext{ + "account": { + Type: AuthTypeAPIKey, + SecretField: "apiKey", + Placement: AuthPlacementQuery, + Name: "api_key", + }, + }} + manifest.Permissions.Auth = []string{"account"} + manifest.Permissions.Network.Connectors[0].Auth = []string{"account"} + policy := testHostPolicy(t, server.URL).WithHostAuth(map[string]any{"apiKey": "host-secret"}) + policy.Connectors["api"] = ResolvedConnectorTarget{ + Name: "api", + Type: ConnectorTypePublicAPI, + BaseURL: policy.Connectors["api"].BaseURL, + BasePath: "/", + AllowPrivate: true, + AllowedPathPrefixes: []string{"/v1"}, + Auth: []string{"account"}, + } + + resp, err := ExecuteHostRequest(context.Background(), manifest, policy, HostRequestSpec{ + Method: "GET", + Auth: "account", + Target: RequestTarget{ + Type: "connector", + Connector: "api", + Path: "/v1", + Query: []QueryParam{{Name: "existing", Value: "1"}}, + }, + Expect: ResponseExpect{ + ContentTypes: []string{"application/json"}, + MaxBytes: 1024, + }, + }, HostRequestOptions{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Status != http.StatusOK { + t.Fatalf("unexpected status %d", resp.Status) + } +} + +func TestExecuteHostRequestBuildsMultipartTrailSend(t *testing.T) { + useUnsafeTestHTTPClient(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if mediaType := strings.Split(r.Header.Get("Content-Type"), ";")[0]; mediaType != "multipart/form-data" { + t.Fatalf("unexpected content type %q", r.Header.Get("Content-Type")) + } + file, header, err := r.FormFile("file") + if err != nil { + t.Fatalf("expected file part: %v", err) + } + defer file.Close() + if header.Filename != "My Route.gpx" { + t.Fatalf("unexpected filename %q", header.Filename) + } + data, _ := io.ReadAll(file) + if string(data) != "" { + t.Fatalf("unexpected trail body %q", string(data)) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + resp, err := ExecuteHostRequest(context.Background(), testHostManifest(t, server.URL), testHostPolicy(t, server.URL), HostRequestSpec{ + Method: "POST", + Target: RequestTarget{Type: "connector", Connector: "api", Path: "/v1/upload"}, + Body: &HostRequestBody{ + Type: HostRequestBodyTypeMultipart, + Parts: []MultipartPart{{ + Name: "file", + Source: MultipartSourceTrail, + Filename: "My Route.gpx", + }}, + }, + Expect: ResponseExpect{ + ContentTypes: []string{"application/json"}, + MaxBytes: 1024, + }, + }, HostRequestOptions{Trail: []byte("")}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Status != http.StatusOK { + t.Fatalf("unexpected status %d", resp.Status) + } +} + +func TestExecuteHostRequestBuildsFormURLEncodedBody(t *testing.T) { + useUnsafeTestHTTPClient(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Content-Type"); got != "application/x-www-form-urlencoded" { + t.Fatalf("unexpected content type %q", got) + } + if err := r.ParseForm(); err != nil { + t.Fatalf("parse form: %v", err) + } + if got := r.Form.Get("person[login_identity]"); got != "user@example.test" { + t.Fatalf("login_identity = %q", got) + } + if got := r.Form.Get("person[password]"); got != "secret" { + t.Fatalf("password = %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + manifest := testHostManifest(t, server.URL) + manifest.Permissions.Uploads.ContentTypes = append(manifest.Permissions.Uploads.ContentTypes, "application/x-www-form-urlencoded") + resp, err := ExecuteHostRequest(context.Background(), manifest, testHostPolicy(t, server.URL), HostRequestSpec{ + Method: "POST", + Target: RequestTarget{Type: "connector", Connector: "api", Path: "/v1/login"}, + Body: &HostRequestBody{ + Type: HostRequestBodyTypeForm, + Form: []FormField{ + {Name: "person[login_identity]", Value: "user@example.test"}, + {Name: "person[password]", Value: "secret"}, + }, + }, + Expect: ResponseExpect{ + ContentTypes: []string{"application/json"}, + MaxBytes: 1024, + }, + }, HostRequestOptions{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Status != http.StatusOK { + t.Fatalf("unexpected status %d", resp.Status) + } +} + +func TestExecuteHostRequestCanReturnRedirectResponse(t *testing.T) { + useUnsafeTestHTTPClient(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/v1/next", http.StatusFound) + })) + defer server.Close() + + followRedirects := false + resp, err := ExecuteHostRequest(context.Background(), testHostManifest(t, server.URL), testHostPolicy(t, server.URL), HostRequestSpec{ + Method: "GET", + Target: RequestTarget{Type: "connector", Connector: "api", Path: "/v1/start"}, + FollowRedirects: &followRedirects, + }, HostRequestOptions{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Status != http.StatusFound { + t.Fatalf("unexpected status %d", resp.Status) + } + if got := resp.HeaderValues["Location"]; len(got) != 1 || got[0] != "/v1/next" { + t.Fatalf("Location = %#v", got) + } +} + +func TestExecuteHostRequestReturnsMultiValueHeaders(t *testing.T) { + useUnsafeTestHTTPClient(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Set-Cookie", "session=abc; Path=/") + w.Header().Add("Set-Cookie", "device=full; Path=/") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + resp, err := ExecuteHostRequest(context.Background(), testHostManifest(t, server.URL), testHostPolicy(t, server.URL), HostRequestSpec{ + Method: "GET", + Target: RequestTarget{Type: "connector", Connector: "api", Path: "/v1"}, + Expect: ResponseExpect{ + ContentTypes: []string{"application/json"}, + MaxBytes: 1024, + }, + }, HostRequestOptions{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := resp.HeaderValues["Set-Cookie"]; len(got) != 2 || got[0] != "session=abc; Path=/" || got[1] != "device=full; Path=/" { + t.Fatalf("Set-Cookie values = %#v", got) + } +} + +func useUnsafeTestHTTPClient(t *testing.T) { + t.Helper() + original := newConnectorHTTPClient + newConnectorHTTPClient = func(policy util.ConnectorHTTPPolicy, checkRedirect func(req *http.Request, via []*http.Request) error) (*http.Client, error) { + return &http.Client{ + Timeout: 60 * time.Second, + CheckRedirect: checkRedirect, + }, nil + } + t.Cleanup(func() { + newConnectorHTTPClient = original + }) +} + +func testHostManifest(t *testing.T, rawURL string) Manifest { + t.Helper() + return Manifest{ + ManifestVersion: ManifestVersion, + ID: "test", + Type: PluginTypeTrails, + Name: "Test", + Version: "0.1.0", + Runtime: RuntimeManifest{ + Type: RuntimeWASM, + Entrypoint: "plugin.wasm", + }, + Capabilities: []CapabilityManifest{{ + Name: "test", + Version: "v1", + Export: "test_v1", + }}, + Permissions: PermissionManifest{ + Network: NetworkPermissions{ + Connectors: []ConnectorTargetPermission{{ + Name: "api", + Type: ConnectorTypePublicAPI, + FixedBaseURL: rawURL, + AllowedPathPrefixes: []string{"/v1"}, + }}, + }, + Downloads: DownloadPermissions{ + MaxBytes: 1024, + ContentTypes: []string{"application/json"}, + }, + Uploads: UploadPermissions{ + MaxBytes: 1024, + ContentTypes: []string{"multipart/form-data"}, + }, + }, + } +} + +func testHostPolicy(t *testing.T, rawURL string) RequestPolicyContext { + t.Helper() + parsed, err := url.Parse(rawURL) + if err != nil { + t.Fatal(err) + } + parsed.Path = "" + return RequestPolicyContext{Connectors: map[string]ResolvedConnectorTarget{ + "api": { + Name: "api", + Type: ConnectorTypePublicAPI, + BaseURL: parsed.String(), + BasePath: "/", + AllowPrivate: true, + AllowedPathPrefixes: []string{"/v1"}, + }, + }} +} diff --git a/db/pluginsystem/import_types.go b/db/pluginsystem/import_types.go new file mode 100644 index 00000000..5c7c1574 --- /dev/null +++ b/db/pluginsystem/import_types.go @@ -0,0 +1,75 @@ +package pluginsystem + +import "time" + +type InstanceRef struct { + ID string `json:"id"` + PluginID string `json:"pluginId"` +} + +type TrailImport struct { + Source TrailImportSource `json:"source"` + Kind string `json:"kind,omitempty"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + StartedAt *time.Time `json:"startedAt,omitempty"` + ActivityType string `json:"activityType,omitempty"` + Privacy *string `json:"privacy,omitempty"` + Track Track `json:"track"` + Waypoints []Waypoint `json:"waypoints,omitempty"` + Photos []Photo `json:"photos,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type TrailSummary struct { + Source TrailImportSource `json:"source"` + Kind string `json:"kind,omitempty"` +} + +type TrailImportSource struct { + Provider string `json:"provider"` + ExternalID string `json:"externalId"` + URL string `json:"url,omitempty"` +} + +type Track struct { + Format string `json:"format"` + ContentBase64 string `json:"contentBase64"` +} + +type Waypoint struct { + ExternalID string `json:"externalId,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + Ele *float64 `json:"ele,omitempty"` + Time *time.Time `json:"time,omitempty"` + Icon string `json:"icon,omitempty"` + Photos []Photo `json:"photos,omitempty"` +} + +type Photo struct { + ExternalID string `json:"externalId,omitempty"` + Filename string `json:"filename,omitempty"` + ContentType string `json:"contentType,omitempty"` + TakenAt *time.Time `json:"takenAt,omitempty"` + Lat *float64 `json:"lat,omitempty"` + Lon *float64 `json:"lon,omitempty"` + Source MediaSource `json:"source"` +} + +type MediaSource struct { + Type string `json:"type"` + URL string `json:"url,omitempty"` + MediaRef *MediaRef `json:"mediaRef,omitempty"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` +} + +type MediaRef struct { + Connector string `json:"connector"` + Auth string `json:"auth,omitempty"` + Path string `json:"path,omitempty"` + Query []QueryParam `json:"query,omitempty"` + AssetID string `json:"assetId,omitempty"` +} diff --git a/db/pluginsystem/installed.go b/db/pluginsystem/installed.go new file mode 100644 index 00000000..98b1ab00 --- /dev/null +++ b/db/pluginsystem/installed.go @@ -0,0 +1,98 @@ +package pluginsystem + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/core" +) + +// LoadInstalledPlugin resolves one plugin from the installed_plugins cache. If +// the cache record is missing or stale, it falls back to the local plugin +// directory so newly copied bundles can still be discovered. +func LoadInstalledPlugin(app core.App, dir string, pluginID string) (LocalPlugin, error) { + if pluginID == "" { + return LocalPlugin{}, fmt.Errorf("plugin id is required") + } + record, _ := app.FindFirstRecordByFilter( + "installed_plugins", + "plugin_id={:plugin_id}", + dbx.Params{"plugin_id": pluginID}, + ) + if record != nil { + plugin, err := localPluginFromRecord(record) + if err == nil { + return plugin, nil + } + } + + if dir == "" { + dir = PluginDir() + } + plugins, err := LoadLocalPlugins(dir) + if err != nil { + return LocalPlugin{}, err + } + for _, plugin := range plugins { + if plugin.Manifest.ID == pluginID { + return plugin, nil + } + } + return LocalPlugin{}, fmt.Errorf("unknown plugin") +} + +// LoadInstalledPlugins returns the cached installed plugin manifests used by +// request hot paths, with disk discovery as a bootstrap fallback. +func LoadInstalledPlugins(app core.App, dir string) ([]LocalPlugin, error) { + records, err := app.FindRecordsByFilter("installed_plugins", "", "", -1, 0) + if err != nil { + return nil, err + } + + plugins := make([]LocalPlugin, 0, len(records)) + for _, record := range records { + plugin, err := localPluginFromRecord(record) + if err != nil { + continue + } + plugins = append(plugins, plugin) + } + if len(plugins) > 0 { + return plugins, nil + } + if dir == "" { + dir = PluginDir() + } + return LoadLocalPlugins(dir) +} + +func localPluginFromRecord(record *core.Record) (LocalPlugin, error) { + var manifest Manifest + if err := record.UnmarshalJSONField("manifest", &manifest); err != nil { + return LocalPlugin{}, err + } + if err := ValidateManifest(manifest); err != nil { + return LocalPlugin{}, err + } + + dir := strings.TrimSpace(record.GetString("path")) + if dir == "" { + return LocalPlugin{}, fmt.Errorf("installed plugin path is empty") + } + entrypoint := filepath.Clean(manifest.Runtime.Entrypoint) + if filepath.IsAbs(entrypoint) || entrypoint == ".." || strings.HasPrefix(entrypoint, ".."+string(filepath.Separator)) { + return LocalPlugin{}, fmt.Errorf("runtime entrypoint must be relative to plugin directory") + } + wasmPath := filepath.Join(dir, entrypoint) + if _, err := os.Stat(wasmPath); err != nil { + return LocalPlugin{}, fmt.Errorf("runtime entrypoint: %w", err) + } + return LocalPlugin{ + Manifest: manifest, + Dir: dir, + WASMPath: wasmPath, + }, nil +} diff --git a/db/pluginsystem/json.go b/db/pluginsystem/json.go new file mode 100644 index 00000000..a7f11313 --- /dev/null +++ b/db/pluginsystem/json.go @@ -0,0 +1,70 @@ +package pluginsystem + +import ( + "encoding/json" + + "github.com/pocketbase/pocketbase/core" +) + +// JSONMapFromRecord reads a PocketBase JSON field into a map. Invalid, empty, +// or null values are treated as an empty object because plugin config/state/auth +// fields should be tolerant of partially edited records. +func JSONMapFromRecord(record *core.Record, field string) map[string]any { + if record == nil { + return map[string]any{} + } + value := record.GetString(field) + if value == "" { + return map[string]any{} + } + var result map[string]any + if err := json.Unmarshal([]byte(value), &result); err != nil || result == nil { + return map[string]any{} + } + return result +} + +// DeepMergeConfig recursively overlays src onto dst and clones JSON-like values +// so caller-owned config maps cannot be mutated through shared references. +func DeepMergeConfig(dst map[string]any, src map[string]any) { + DeepMergeConfigWithReplaceKeys(dst, src, nil) +} + +// DeepMergeConfigWithReplaceKeys behaves like DeepMergeConfig, but map values +// whose key is listed in replaceKeys replace the destination map instead of +// being recursively merged. +func DeepMergeConfigWithReplaceKeys(dst map[string]any, src map[string]any, replaceKeys map[string]bool) { + for key, value := range src { + srcMap, srcIsMap := value.(map[string]any) + dstMap, dstIsMap := dst[key].(map[string]any) + if srcIsMap && dstIsMap { + if replaceKeys[key] { + dst[key] = CloneJSONMap(srcMap) + continue + } + DeepMergeConfigWithReplaceKeys(dstMap, srcMap, replaceKeys) + continue + } + dst[key] = CloneJSONValue(value) + } +} + +func CloneJSONMap(values map[string]any) map[string]any { + cloned := make(map[string]any, len(values)) + for key, value := range values { + cloned[key] = CloneJSONValue(value) + } + return cloned +} + +func CloneJSONValue(value any) any { + data, err := json.Marshal(value) + if err != nil { + return value + } + var cloned any + if err := json.Unmarshal(data, &cloned); err != nil { + return value + } + return cloned +} diff --git a/db/pluginsystem/json_test.go b/db/pluginsystem/json_test.go new file mode 100644 index 00000000..02f45304 --- /dev/null +++ b/db/pluginsystem/json_test.go @@ -0,0 +1,81 @@ +package pluginsystem + +import "testing" + +func TestMergePluginConfigEmptyCategoryMappingOverridesDefaultMap(t *testing.T) { + dst := map[string]any{ + "host": map[string]any{ + "categoryMapping": map[string]any{ + "hike": "hiking", + "bike": "biking", + }, + "privacy": "public", + }, + } + src := map[string]any{ + "host": map[string]any{ + "categoryMapping": map[string]any{}, + }, + } + + MergePluginConfig(dst, src) + + host := dst["host"].(map[string]any) + mapping := host["categoryMapping"].(map[string]any) + if len(mapping) != 0 { + t.Fatalf("expected empty category mapping override, got %#v", mapping) + } + if host["privacy"] != "public" { + t.Fatalf("expected sibling defaults to remain, got %#v", host) + } +} + +func TestMergePluginConfigCategoryMappingReplacesDefaultMap(t *testing.T) { + dst := map[string]any{ + "host": map[string]any{ + "categoryMapping": map[string]any{ + "hike": "hiking", + "bike": "biking", + }, + }, + } + src := map[string]any{ + "host": map[string]any{ + "categoryMapping": map[string]any{ + "hike": "custom", + }, + }, + } + + MergePluginConfig(dst, src) + + mapping := dst["host"].(map[string]any)["categoryMapping"].(map[string]any) + if len(mapping) != 1 || mapping["hike"] != "custom" { + t.Fatalf("expected category mapping to replace defaults, got %#v", mapping) + } +} + +func TestDeepMergeConfigNonEmptyMapStillMergesByDefault(t *testing.T) { + dst := map[string]any{ + "host": map[string]any{ + "categoryMapping": map[string]any{ + "hike": "hiking", + "bike": "biking", + }, + }, + } + src := map[string]any{ + "host": map[string]any{ + "categoryMapping": map[string]any{ + "hike": "custom", + }, + }, + } + + DeepMergeConfig(dst, src) + + mapping := dst["host"].(map[string]any)["categoryMapping"].(map[string]any) + if mapping["hike"] != "custom" || mapping["bike"] != "biking" { + t.Fatalf("expected generic merge to keep sibling defaults, got %#v", mapping) + } +} diff --git a/db/pluginsystem/manager.go b/db/pluginsystem/manager.go new file mode 100644 index 00000000..6b9901a1 --- /dev/null +++ b/db/pluginsystem/manager.go @@ -0,0 +1,419 @@ +package pluginsystem + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "hash/fnv" + "os" + "path/filepath" + "strings" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/core" +) + +// Manager coordinates local plugin discovery with the installed_plugins cache. +// It is intentionally small: request hot paths should read cached manifests, +// while list/cron entrypoints refresh the cache from data/plugins first. +type Manager struct { + App core.App + Dir string +} + +// PluginInfo is the UI-facing view of an installed plugin. It combines the +// static manifest with runtime availability and embedded icon data. +type PluginInfo struct { + ID string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + DisplayName string `json:"displayName,omitempty"` + Description string `json:"description,omitempty"` + Icon string `json:"icon,omitempty"` + IconDark string `json:"iconDark,omitempty"` + Version string `json:"version"` + Runtime string `json:"runtime"` + Path string `json:"path"` + Capabilities []string `json:"capabilities"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + Manifest Manifest `json:"manifest"` +} + +// NewManager creates a manager for the configured plugin directory. Tests can +// pass a custom dir; production callers use the resolved runtime plugin +// directory. +func NewManager(app core.App, dir string) *Manager { + if dir == "" { + dir = PluginDir() + } + return &Manager{App: app, Dir: dir} +} + +// ListLocalPlugins returns installed plugins in the shape consumed by the +// settings UI. It reads from installed_plugins first so listing does not need to +// parse every manifest from disk after the cache has been refreshed. +func (m *Manager) ListLocalPlugins(context.Context) ([]PluginInfo, error) { + plugins, err := LoadInstalledPlugins(m.App, m.Dir) + if err != nil { + return nil, err + } + infos := make([]PluginInfo, 0, len(plugins)) + infoByPath := map[string]int{} + for _, plugin := range plugins { + status := "available" + record, _ := m.App.FindFirstRecordByFilter( + "installed_plugins", + "plugin_id={:plugin_id}", + dbx.Params{"plugin_id": plugin.Manifest.ID}, + ) + if record != nil && record.GetString("status") != "" { + status = record.GetString("status") + } + errorMessage := "" + if record != nil { + errorMessage = record.GetString("error") + } + icon, iconDark := pluginIcons(plugin) + infos = append(infos, PluginInfo{ + ID: plugin.Manifest.ID, + Type: plugin.Manifest.Type, + Name: plugin.Manifest.Name, + DisplayName: stringMetadata(plugin.Manifest.Metadata, "displayName"), + Description: plugin.Manifest.Description, + Icon: icon, + IconDark: iconDark, + Version: plugin.Manifest.Version, + Runtime: plugin.Manifest.Runtime.Type, + Path: plugin.Dir, + Capabilities: capabilityNames(plugin.Manifest.Capabilities), + Status: status, + Error: errorMessage, + Manifest: plugin.Manifest, + }) + infoByPath[filepath.Clean(plugin.Dir)] = len(infos) - 1 + } + + _, issues, err := DiscoverLocalPlugins(m.Dir) + if err != nil { + return nil, err + } + for _, issue := range issues { + if index, ok := infoByPath[filepath.Clean(issue.Dir)]; ok { + infos[index].Status = "error" + infos[index].Error = issue.Error + continue + } + infos = append(infos, PluginInfo{ + ID: issue.ID, + Type: PluginTypeTrails, + Name: issue.Name, + Path: issue.Dir, + Status: "error", + Error: issue.Error, + Runtime: RuntimeWASM, + Manifest: Manifest{ + ID: issue.ID, + Type: PluginTypeTrails, + Name: issue.Name, + Runtime: RuntimeManifest{ + Type: RuntimeWASM, + }, + }, + }) + } + return infos, nil +} + +// pluginIcons embeds optional light/dark icon files from the plugin bundle as +// data URLs so the frontend does not need direct filesystem access. +func pluginIcons(plugin LocalPlugin) (string, string) { + icons, _ := plugin.Manifest.Metadata["icons"].(map[string]any) + return pluginIcon(plugin.Dir, stringMetadata(icons, "light")), pluginIcon(plugin.Dir, stringMetadata(icons, "dark")) +} + +func stringMetadata(values map[string]any, key string) string { + value, _ := values[key].(string) + return value +} + +func pluginIcon(pluginDir string, iconPath string) string { + iconPath = strings.TrimSpace(iconPath) + if iconPath == "" { + return "" + } + cleanPath := filepath.Clean(iconPath) + if filepath.IsAbs(cleanPath) || cleanPath == ".." || strings.HasPrefix(cleanPath, ".."+string(filepath.Separator)) { + return "" + } + fullPath := filepath.Join(pluginDir, cleanPath) + data, err := os.ReadFile(fullPath) + if err != nil { + return "" + } + contentType := "image/svg+xml" + switch strings.ToLower(filepath.Ext(fullPath)) { + case ".png": + contentType = "image/png" + case ".jpg", ".jpeg": + contentType = "image/jpeg" + case ".webp": + contentType = "image/webp" + } + return "data:" + contentType + ";base64," + base64.StdEncoding.EncodeToString(data) +} + +// SyncInstalledPlugins scans the runtime plugin directory and upserts +// installed_plugins records. +// This keeps the manifest snapshot available even when later code paths should +// avoid repeated disk IO. +func (m *Manager) SyncInstalledPlugins(ctx context.Context) error { + plugins, issues, err := DiscoverLocalPlugins(m.Dir) + if err != nil { + return err + } + collection, err := m.App.FindCollectionByNameOrId("installed_plugins") + if err != nil { + return err + } + activePaths := activePluginPaths(plugins, issues) + if err := m.deleteStaleInstalledPlugins(ctx, activePaths); err != nil { + return err + } + for _, issue := range issues { + if err := ctx.Err(); err != nil { + return err + } + m.App.Logger().Warn("plugin setup error", "plugin", issue.ID, "path", issue.Dir, "error", issue.Error) + if err := m.savePluginIssue(collection, issue); err != nil { + return err + } + } + for _, plugin := range plugins { + if err := ctx.Err(); err != nil { + return err + } + record, err := m.findPluginRecord(collection, plugin) + if err != nil { + return err + } + record.Set("plugin_id", plugin.Manifest.ID) + record.Set("name", plugin.Manifest.Name) + record.Set("type", plugin.Manifest.Type) + record.Set("version", plugin.Manifest.Version) + record.Set("runtime", plugin.Manifest.Runtime.Type) + record.Set("path", plugin.Dir) + record.Set("status", "available") + record.Set("error", "") + manifestJSON, err := marshalManifest(plugin.Manifest) + if err != nil { + return fmt.Errorf("encode installed plugin %s manifest: %w", plugin.Manifest.ID, err) + } + record.Set("manifest", manifestJSON) + record.Set("config", mergeDefaultConfig(defaultConfig(plugin.Manifest), JSONMapFromRecord(record, "config"))) + if err := m.App.Save(record); err != nil { + return fmt.Errorf("save installed plugin %s: %w", plugin.Manifest.ID, err) + } + } + return nil +} + +func activePluginPaths(plugins []LocalPlugin, issues []LocalPluginIssue) map[string]bool { + paths := make(map[string]bool, len(plugins)+len(issues)) + for _, plugin := range plugins { + if plugin.Dir != "" { + paths[filepath.Clean(plugin.Dir)] = true + } + } + for _, issue := range issues { + if issue.Dir != "" { + paths[filepath.Clean(issue.Dir)] = true + } + } + return paths +} + +func (m *Manager) deleteStaleInstalledPlugins(ctx context.Context, activePaths map[string]bool) error { + records, err := m.App.FindRecordsByFilter("installed_plugins", "", "", -1, 0) + if err != nil { + return err + } + for _, record := range records { + if err := ctx.Err(); err != nil { + return err + } + path := strings.TrimSpace(record.GetString("path")) + if path != "" && activePaths[filepath.Clean(path)] { + continue + } + if err := m.App.Delete(record); err != nil { + return fmt.Errorf("delete stale installed plugin %s: %w", record.GetString("plugin_id"), err) + } + } + return nil +} + +func (m *Manager) findPluginRecord(collection *core.Collection, plugin LocalPlugin) (*core.Record, error) { + recordByID, _ := m.App.FindFirstRecordByFilter( + "installed_plugins", + "plugin_id={:plugin_id}", + dbx.Params{"plugin_id": plugin.Manifest.ID}, + ) + var recordByPath *core.Record + if plugin.Dir != "" { + recordByPath, _ = m.App.FindFirstRecordByFilter( + "installed_plugins", + "path={:path}", + dbx.Params{"path": plugin.Dir}, + ) + } + if recordByID != nil && recordByPath != nil && recordByID.Id != recordByPath.Id { + if err := m.App.Delete(recordByPath); err != nil { + return nil, fmt.Errorf("delete superseded installed plugin %s: %w", recordByPath.GetString("plugin_id"), err) + } + } + if recordByID != nil { + return recordByID, nil + } + if recordByPath != nil { + return recordByPath, nil + } + return core.NewRecord(collection), nil +} + +func (m *Manager) savePluginIssue(collection *core.Collection, issue LocalPluginIssue) error { + recordID := pluginIssueRecordID(issue) + record, _ := m.App.FindFirstRecordByFilter( + "installed_plugins", + "plugin_id={:plugin_id}", + dbx.Params{"plugin_id": recordID}, + ) + if record == nil && issue.Dir != "" { + record, _ = m.App.FindFirstRecordByFilter( + "installed_plugins", + "path={:path}", + dbx.Params{"path": issue.Dir}, + ) + } + if record == nil { + record = core.NewRecord(collection) + record.Set("plugin_id", recordID) + } + record.Set("name", issue.Name) + record.Set("type", PluginTypeTrails) + record.Set("version", "unknown") + record.Set("runtime", RuntimeWASM) + record.Set("path", issue.Dir) + record.Set("manifest", map[string]any{ + "id": record.GetString("plugin_id"), + "type": PluginTypeTrails, + "name": issue.Name, + }) + record.Set("status", "error") + record.Set("error", issue.Error) + if err := m.App.Save(record); err != nil { + return fmt.Errorf("save plugin setup error %s: %w", issue.ID, err) + } + return nil +} + +func pluginIssueRecordID(issue LocalPluginIssue) string { + originalID := strings.TrimSpace(issue.ID) + id := strings.ToLower(originalID) + var builder strings.Builder + for _, r := range id { + switch { + case r >= 'a' && r <= 'z': + builder.WriteRune(r) + case r >= '0' && r <= '9': + builder.WriteRune(r) + case r == '_' || r == '-': + builder.WriteRune(r) + default: + builder.WriteRune('-') + } + } + result := strings.Trim(builder.String(), "-_") + if result == "" { + result = "plugin-setup-error" + } + if originalID != result || !pluginIDPattern.MatchString(result) { + result = strings.Trim(result, "-_") + if result == "" { + result = "plugin-setup-error" + } + result = result + "-" + pluginIssueHash(issue) + } + if len(result) > 128 { + hash := pluginIssueHash(issue) + prefixLength := 128 - len(hash) - 1 + result = strings.Trim(result[:prefixLength], "-_") + "-" + hash + } + if pluginIDPattern.MatchString(result) { + return result + } + return "plugin-setup-error-" + pluginIssueHash(issue) +} + +func pluginIssueHash(issue LocalPluginIssue) string { + hash := fnv.New32a() + _, _ = hash.Write([]byte(issue.Dir)) + _, _ = hash.Write([]byte{0}) + _, _ = hash.Write([]byte(issue.ID)) + return fmt.Sprintf("%08x", hash.Sum32()) +} + +func marshalManifest(manifest Manifest) (map[string]any, error) { + data, err := json.Marshal(manifest) + if err != nil { + return nil, err + } + var result map[string]any + if err := json.Unmarshal(data, &result); err != nil { + return nil, err + } + return result, nil +} + +func defaultConfig(manifest Manifest) map[string]any { + hostConfig, _ := CloneJSONValue(manifest.HostConfig).(map[string]any) + if hostConfig == nil { + hostConfig = map[string]any{} + } + config := map[string]any{ + "host": hostConfig, + } + pluginConfig := map[string]any{} + for _, field := range manifest.ConfigSchema { + if field.Key == "" || field.Default == nil { + continue + } + pluginConfig[field.Key] = CloneJSONValue(field.Default) + } + config["plugin"] = pluginConfig + return config +} + +func mergeDefaultConfig(defaults map[string]any, current map[string]any) map[string]any { + if len(defaults) == 0 { + return current + } + merged := CloneJSONMap(defaults) + MergePluginConfig(merged, current) + return merged +} + +func MergePluginConfig(dst map[string]any, src map[string]any) { + DeepMergeConfigWithReplaceKeys(dst, src, map[string]bool{ + "categoryMapping": true, + }) +} + +func capabilityNames(capabilities []CapabilityManifest) []string { + names := make([]string, 0, len(capabilities)) + for _, capability := range capabilities { + names = append(names, capability.Name+"."+capability.Version) + } + return names +} diff --git a/db/pluginsystem/manager_test.go b/db/pluginsystem/manager_test.go new file mode 100644 index 00000000..9d06e0de --- /dev/null +++ b/db/pluginsystem/manager_test.go @@ -0,0 +1,21 @@ +package pluginsystem + +import "testing" + +func TestPluginIssueRecordID(t *testing.T) { + valid := pluginIssueRecordID(LocalPluginIssue{ID: "komoot", Dir: "/plugins/komoot"}) + if valid != "komoot" { + t.Fatalf("pluginIssueRecordID(valid) = %q, want komoot", valid) + } + + first := pluginIssueRecordID(LocalPluginIssue{ID: "@@@", Dir: "/plugins/@@@"}) + second := pluginIssueRecordID(LocalPluginIssue{ID: "***", Dir: "/plugins/***"}) + if first == second { + t.Fatalf("invalid plugin issue ids collided: %q", first) + } + for _, got := range []string{first, second} { + if !pluginIDPattern.MatchString(got) { + t.Fatalf("pluginIssueRecordID() = %q, not a valid plugin id", got) + } + } +} diff --git a/db/pluginsystem/manifest.go b/db/pluginsystem/manifest.go new file mode 100644 index 00000000..4d35448f --- /dev/null +++ b/db/pluginsystem/manifest.go @@ -0,0 +1,305 @@ +package pluginsystem + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +const ( + DefaultPluginDir = "/data/plugins" +) + +var pluginIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) + +var ErrUnsupportedPluginType = errors.New("unsupported plugin type") + +type LocalPlugin struct { + Manifest Manifest `json:"manifest"` + Dir string `json:"dir"` + WASMPath string `json:"wasmPath"` +} + +// PluginDir resolves the runtime plugin directory. Production containers mount +// plugins at /data/plugins; source checkouts usually stage them at data/plugins +// and may start PocketBase either from the repo root or from db/. +func PluginDir() string { + for _, candidate := range []string{ + DefaultPluginDir, + "data/plugins", + filepath.Join("..", "data", "plugins"), + } { + if info, err := os.Stat(candidate); err == nil && info.IsDir() { + return candidate + } + } + return DefaultPluginDir +} + +// LoadLocalPlugins reads direct child directories from the plugin directory and +// returns every valid bundle. Invalid direct children are ignored by this +// compatibility helper; callers that need UI-visible errors should use +// DiscoverLocalPlugins. +func LoadLocalPlugins(dir string) ([]LocalPlugin, error) { + plugins, _, err := DiscoverLocalPlugins(dir) + return plugins, err +} + +type LocalPluginIssue struct { + ID string + Name string + Dir string + Error string +} + +// DiscoverLocalPlugins reads direct child directories from the plugin directory +// and returns valid bundles plus per-directory load issues. +func DiscoverLocalPlugins(dir string) ([]LocalPlugin, []LocalPluginIssue, error) { + if dir == "" { + dir = PluginDir() + } + if _, err := os.Stat(dir); err != nil { + if os.IsNotExist(err) { + return []LocalPlugin{}, nil, nil + } + return nil, nil, err + } + + plugins := make([]LocalPlugin, 0) + issues := make([]LocalPluginIssue, 0) + seen := map[string]bool{} + entries, err := os.ReadDir(dir) + if err != nil { + return nil, nil, err + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + pluginDir := filepath.Join(dir, entry.Name()) + plugin, err := LoadLocalPlugin(pluginDir) + if err != nil { + if errors.Is(err, ErrUnsupportedPluginType) { + continue + } + issues = append(issues, LocalPluginIssue{ + ID: entry.Name(), + Name: entry.Name(), + Dir: pluginDir, + Error: fmt.Sprintf("%s: %v", entry.Name(), err), + }) + continue + } + if seen[plugin.Manifest.ID] { + continue + } + seen[plugin.Manifest.ID] = true + plugins = append(plugins, *plugin) + } + + return plugins, issues, nil +} + +// LoadLocalPlugin reads one plugin bundle, validates its manifest, and resolves +// the WASM entrypoint relative to the plugin directory. +func LoadLocalPlugin(dir string) (*LocalPlugin, error) { + manifestPath := filepath.Join(dir, "plugin.json") + data, err := os.ReadFile(manifestPath) + if err != nil { + return nil, err + } + + var manifest Manifest + if err := json.Unmarshal(data, &manifest); err != nil { + return nil, fmt.Errorf("parse plugin.json: %w", err) + } + if err := ValidateManifest(manifest); err != nil { + return nil, err + } + + entrypoint := filepath.Clean(manifest.Runtime.Entrypoint) + if filepath.IsAbs(entrypoint) || strings.HasPrefix(entrypoint, ".."+string(filepath.Separator)) || entrypoint == ".." { + return nil, fmt.Errorf("runtime entrypoint must be relative to plugin directory") + } + wasmPath := filepath.Join(dir, entrypoint) + if _, err := os.Stat(wasmPath); err != nil { + return nil, fmt.Errorf("runtime entrypoint: %w", err) + } + + return &LocalPlugin{ + Manifest: manifest, + Dir: dir, + WASMPath: wasmPath, + }, nil +} + +// ValidateManifest checks the static contract that is trusted by install, +// runtime policy enforcement, auth handling, and the UI. +func ValidateManifest(manifest Manifest) error { + if manifest.ManifestVersion == "" { + return fmt.Errorf("manifestVersion is required") + } + if majorVersion(manifest.ManifestVersion) != majorVersion(ManifestVersion) { + return fmt.Errorf("unsupported manifestVersion %q", manifest.ManifestVersion) + } + if !pluginIDPattern.MatchString(manifest.ID) { + return fmt.Errorf("id must match %s", pluginIDPattern.String()) + } + if manifest.Type != PluginTypeTrails { + return fmt.Errorf("%w: type must be %q", ErrUnsupportedPluginType, PluginTypeTrails) + } + if strings.TrimSpace(manifest.Name) == "" { + return fmt.Errorf("name is required") + } + if strings.TrimSpace(manifest.Version) == "" { + return fmt.Errorf("version is required") + } + if manifest.Runtime.Type != RuntimeWASM { + return fmt.Errorf("runtime.type must be %q", RuntimeWASM) + } + if strings.TrimSpace(manifest.Runtime.Entrypoint) == "" { + return fmt.Errorf("runtime.entrypoint is required") + } + if len(manifest.Capabilities) == 0 { + return fmt.Errorf("at least one capability is required") + } + if err := validateCapabilities(manifest.Capabilities); err != nil { + return err + } + if err := validateAuth(manifest.Auth); err != nil { + return err + } + if err := validatePermissions(manifest.Permissions, manifest.Auth); err != nil { + return err + } + return nil +} + +func validateCapabilities(capabilities []CapabilityManifest) error { + seen := map[string]bool{} + for _, capability := range capabilities { + if strings.TrimSpace(capability.Name) == "" { + return fmt.Errorf("capability name is required") + } + if strings.TrimSpace(capability.Version) == "" { + return fmt.Errorf("capability %s version is required", capability.Name) + } + if strings.TrimSpace(capability.Export) == "" { + return fmt.Errorf("capability %s export is required", capability.Name) + } + key := capability.Name + "." + capability.Version + if seen[key] { + return fmt.Errorf("duplicate capability %s", key) + } + seen[key] = true + } + return nil +} + +func validateAuth(auth AuthManifest) error { + for name, context := range auth.Contexts { + if strings.TrimSpace(name) == "" { + return fmt.Errorf("auth context name is required") + } + if err := ValidateAuthContext(name, context); err != nil { + return err + } + } + return nil +} + +func validatePermissions(permissions PermissionManifest, auth AuthManifest) error { + authContexts := map[string]bool{} + for name := range auth.Contexts { + authContexts[name] = true + } + for _, authRef := range permissions.Auth { + if !authContexts[authRef] { + return fmt.Errorf("permission references unknown auth context %q", authRef) + } + } + if err := validateConnectors(permissions.Network.Connectors, authContexts); err != nil { + return err + } + for _, host := range permissions.Network.Redirects.Hosts { + if err := validateHost(host); err != nil { + return err + } + } + if permissions.Network.Redirects.Mode != "" && permissions.Network.Redirects.Mode != "declared_hosts_only" { + return fmt.Errorf("unsupported redirect mode %q", permissions.Network.Redirects.Mode) + } + if permissions.Downloads.MaxBytes < 0 || permissions.Uploads.MaxBytes < 0 { + return fmt.Errorf("maxBytes must not be negative") + } + return nil +} + +func validateConnectors(connectors []ConnectorTargetPermission, authContexts map[string]bool) error { + seen := map[string]bool{} + for _, connector := range connectors { + if strings.TrimSpace(connector.Name) == "" { + return fmt.Errorf("connector name is required") + } + if seen[connector.Name] { + return fmt.Errorf("duplicate connector %q", connector.Name) + } + seen[connector.Name] = true + switch connector.Type { + case ConnectorTypePublicAPI: + if strings.TrimSpace(connector.FixedBaseURL) == "" { + return fmt.Errorf("public_api connector %q requires fixedBaseURL", connector.Name) + } + if strings.TrimSpace(connector.ConfigKey) != "" { + return fmt.Errorf("public_api connector %q must not declare configKey", connector.Name) + } + if _, _, err := NormalizeConnectorBase(connector.FixedBaseURL, ""); err != nil { + return fmt.Errorf("connector %q fixedBaseURL: %w", connector.Name, err) + } + case ConnectorTypeConfigured: + if strings.TrimSpace(connector.ConfigKey) == "" { + return fmt.Errorf("configured connector %q requires configKey", connector.Name) + } + if strings.TrimSpace(connector.FixedBaseURL) != "" { + return fmt.Errorf("configured connector %q must not declare fixedBaseURL", connector.Name) + } + default: + return fmt.Errorf("connector %q has unsupported type %q", connector.Name, connector.Type) + } + for _, authRef := range connector.Auth { + if !authContexts[authRef] { + return fmt.Errorf("connector %q references unknown auth context %q", connector.Name, authRef) + } + } + for _, prefix := range connector.AllowedPathPrefixes { + if _, err := CanonicalURLPath(prefix); err != nil { + return fmt.Errorf("connector %q path prefix %q: %w", connector.Name, prefix, err) + } + } + } + return nil +} + +func validateHost(host string) error { + host = strings.TrimSpace(host) + if host == "" { + return fmt.Errorf("network host must not be empty") + } + if strings.Contains(host, "://") || strings.Contains(host, "/") { + return fmt.Errorf("network host %q must be a hostname, not a URL", host) + } + return nil +} + +func majorVersion(version string) string { + for i, r := range version { + if r == '.' { + return version[:i] + } + } + return version +} diff --git a/db/pluginsystem/manifest_test.go b/db/pluginsystem/manifest_test.go new file mode 100644 index 00000000..e95d0679 --- /dev/null +++ b/db/pluginsystem/manifest_test.go @@ -0,0 +1,222 @@ +package pluginsystem + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestValidateManifestAcceptsHammerheadShape(t *testing.T) { + manifest := hammerheadManifestForTest() + if err := ValidateManifest(manifest); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateManifestRejectsUnknownAuthPermission(t *testing.T) { + manifest := hammerheadManifestForTest() + manifest.Permissions.Auth = []string{"missing"} + + if err := ValidateManifest(manifest); err == nil { + t.Fatal("expected error") + } +} + +func TestLoadLocalPluginRequiresRelativeEntrypoint(t *testing.T) { + dir := t.TempDir() + manifest := hammerheadManifestForTest() + manifest.Runtime.Entrypoint = "/tmp/plugin.wasm" + writeManifest(t, dir, manifest) + + if _, err := LoadLocalPlugin(dir); err == nil { + t.Fatal("expected error") + } +} + +func TestLoadLocalPluginsSkipsMissingPluginDir(t *testing.T) { + plugins, err := LoadLocalPlugins(filepath.Join(t.TempDir(), "missing")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(plugins) != 0 { + t.Fatalf("got %d plugins, want 0", len(plugins)) + } +} + +func TestLoadLocalPluginsFindsDirectChildPlugins(t *testing.T) { + root := t.TempDir() + writePluginDir(t, root, "hammerhead") + writePluginDir(t, root, "komoot") + + plugins, err := LoadLocalPlugins(root) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(plugins) != 2 { + t.Fatalf("got %d plugins, want 2", len(plugins)) + } +} + +func TestDiscoverLocalPluginsReportsMissingManifest(t *testing.T) { + root := t.TempDir() + brokenDir := filepath.Join(root, "komoot") + if err := os.MkdirAll(brokenDir, 0o700); err != nil { + t.Fatalf("mkdir plugin dir: %v", err) + } + + plugins, issues, err := DiscoverLocalPlugins(root) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(plugins) != 0 { + t.Fatalf("got %d plugins, want 0", len(plugins)) + } + if len(issues) != 1 { + t.Fatalf("got %d issues, want 1", len(issues)) + } + if issues[0].ID != "komoot" || issues[0].Name != "komoot" || issues[0].Dir != brokenDir { + t.Fatalf("unexpected issue: %#v", issues[0]) + } + if issues[0].Error == "" || !strings.Contains(issues[0].Error, "plugin.json") { + t.Fatalf("expected useful plugin.json error, got %#v", issues[0]) + } +} + +func TestLoadLocalPluginsIgnoresMissingManifestForCompatibility(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "komoot"), 0o700); err != nil { + t.Fatalf("mkdir plugin dir: %v", err) + } + + plugins, err := LoadLocalPlugins(root) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(plugins) != 0 { + t.Fatalf("got %d plugins, want 0", len(plugins)) + } +} + +func TestLoadLocalPluginsSkipsUnsupportedPluginTypes(t *testing.T) { + root := t.TempDir() + writePluginDir(t, root, "hammerhead") + + assetsDir := filepath.Join(root, "immich") + if err := os.MkdirAll(assetsDir, 0o700); err != nil { + t.Fatalf("mkdir plugin dir: %v", err) + } + manifest := hammerheadManifestForTest() + manifest.ID = "immich" + manifest.Name = "Immich" + manifest.Type = "assets" + writeManifest(t, assetsDir, manifest) + if err := os.WriteFile(filepath.Join(assetsDir, "plugin.wasm"), []byte("wasm"), 0o600); err != nil { + t.Fatalf("write wasm: %v", err) + } + + plugins, err := LoadLocalPlugins(root) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(plugins) != 1 { + t.Fatalf("got %d plugins, want 1", len(plugins)) + } + if plugins[0].Manifest.ID != "hammerhead" { + t.Fatalf("got plugin %q, want hammerhead", plugins[0].Manifest.ID) + } +} + +func TestLoadLocalPluginsDoesNotSearchRecursively(t *testing.T) { + root := t.TempDir() + writePluginDir(t, filepath.Join(root, "nested"), "hammerhead") + + plugins, err := LoadLocalPlugins(root) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(plugins) != 0 { + t.Fatalf("got %d plugins, want 0", len(plugins)) + } +} + +func hammerheadManifestForTest() Manifest { + return Manifest{ + ManifestVersion: ManifestVersion, + ID: "hammerhead", + Type: PluginTypeTrails, + Name: "Hammerhead", + Version: "0.1.0", + Runtime: RuntimeManifest{ + Type: RuntimeWASM, + Entrypoint: "plugin.wasm", + }, + Capabilities: []CapabilityManifest{ + {Name: "prepare_trail_send", Version: "v1", Export: "prepare_trail_send_v1"}, + }, + Auth: AuthManifest{ + Contexts: map[string]AuthContext{ + "provider_session": { + Type: AuthTypeSession, + SecretFields: []string{"email", "password"}, + Refresh: &AuthRefresh{ + Mode: AuthRefreshModePlugin, + Function: "refresh_session_v1", + }, + }, + }, + }, + Permissions: PermissionManifest{ + Network: NetworkPermissions{ + Connectors: []ConnectorTargetPermission{{ + Name: "api", + Type: ConnectorTypePublicAPI, + FixedBaseURL: "https://dashboard.hammerhead.io", + AllowedPathPrefixes: []string{"/v1"}, + Auth: []string{"provider_session"}, + }}, + }, + Auth: []string{"provider_session"}, + Uploads: UploadPermissions{ + MaxBytes: 10 << 20, + ContentTypes: []string{"application/gpx+xml", "application/xml"}, + }, + }, + } +} + +func writeManifest(t *testing.T, dir string, manifest Manifest) { + t.Helper() + data := []byte(`{ + "manifestVersion": "1.0", + "id": "` + manifest.ID + `", + "type": "` + manifest.Type + `", + "name": "` + manifest.Name + `", + "version": "` + manifest.Version + `", + "runtime": { + "type": "` + manifest.Runtime.Type + `", + "entrypoint": "` + manifest.Runtime.Entrypoint + `" + }, + "capabilities": [ + {"name": "prepare_trail_send", "version": "v1", "export": "prepare_trail_send_v1"} + ] + }`) + if err := os.WriteFile(filepath.Join(dir, "plugin.json"), data, 0o600); err != nil { + t.Fatalf("write manifest: %v", err) + } +} + +func writePluginDir(t *testing.T, root string, id string) { + t.Helper() + dir := filepath.Join(root, id) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatalf("mkdir plugin dir: %v", err) + } + manifest := hammerheadManifestForTest() + manifest.ID = id + manifest.Name = id + writeManifest(t, dir, manifest) + if err := os.WriteFile(filepath.Join(dir, "plugin.wasm"), []byte("wasm"), 0o600); err != nil { + t.Fatalf("write wasm: %v", err) + } +} diff --git a/db/pluginsystem/oauth.go b/db/pluginsystem/oauth.go new file mode 100644 index 00000000..491187ee --- /dev/null +++ b/db/pluginsystem/oauth.go @@ -0,0 +1,335 @@ +package pluginsystem + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "slices" + "strings" + "time" + + "github.com/pocketbase/pocketbase/core" +) + +const ( + AuthFieldOAuthContext = "oauthContext" + AuthFieldTokenType = "tokenType" + AuthFieldExpiresAt = "expiresAt" + AuthFieldScope = "scope" +) + +type OAuthTokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token,omitempty"` + TokenType string `json:"token_type,omitempty"` + ExpiresIn int `json:"expires_in,omitempty"` + Scope string `json:"scope,omitempty"` + Raw json.RawMessage `json:"-"` +} + +// OAuthContext selects the OAuth auth context declared by a plugin. When the UI +// does not request a specific context, the first context by name is used. +func OAuthContext(plugin LocalPlugin, requested string) (string, AuthContext, error) { + names := make([]string, 0, len(plugin.Manifest.Auth.Contexts)) + for name := range plugin.Manifest.Auth.Contexts { + names = append(names, name) + } + slices.Sort(names) + for _, name := range names { + context := plugin.Manifest.Auth.Contexts[name] + if requested != "" && requested != name { + continue + } + if context.Type == AuthTypeOAuth2 { + return name, context, nil + } + } + return "", AuthContext{}, fmt.Errorf("plugin has no oauth auth context") +} + +// ValidateOAuthRedirectURI accepts only the frontend plugin OAuth callback and, +// when ORIGIN is configured, requires the same external origin. +func ValidateOAuthRedirectURI(raw string) error { + redirectURL, err := url.Parse(raw) + if err != nil { + return err + } + if redirectURL.Scheme != "http" && redirectURL.Scheme != "https" { + return fmt.Errorf("redirect uri scheme must be http or https") + } + if redirectURL.Host == "" { + return fmt.Errorf("redirect uri must be absolute") + } + if redirectURL.Path != "/settings/plugins/oauth/callback" { + return fmt.Errorf("redirect uri path is not allowed") + } + if origin := strings.TrimRight(os.Getenv("ORIGIN"), "/"); origin != "" { + originURL, err := url.Parse(origin) + if err != nil { + return err + } + if !strings.EqualFold(redirectURL.Scheme, originURL.Scheme) || !strings.EqualFold(redirectURL.Host, originURL.Host) { + return fmt.Errorf("redirect uri origin does not match ORIGIN") + } + } + return nil +} + +func NewOAuthState(size int) string { + return randomURLToken(size) +} + +func NewOAuthCodeVerifier(size int) string { + return randomURLToken(size) +} + +func PKCEChallenge(verifier string) string { + hash := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(hash[:]) +} + +// ExchangeOAuthToken performs the host-owned OAuth token exchange or refresh. +// The token endpoint must be allowed by the plugin manifest network policy. +func ExchangeOAuthToken(ctx context.Context, manifest Manifest, authContext AuthContext, auth map[string]any, values map[string]string) (*OAuthTokenResponse, error) { + tokenURL, err := url.Parse(authContext.TokenURL) + if err != nil { + return nil, err + } + if tokenURL.Scheme != "http" && tokenURL.Scheme != "https" { + return nil, fmt.Errorf("oauth token url scheme must be http or https") + } + if !OAuthTokenURLAllowed(manifest, tokenURL) { + return nil, fmt.Errorf("oauth token host %q is not allowed by manifest permissions", tokenURL.Hostname()) + } + + clientID := StringFromAny(auth["clientId"]) + clientSecret := StringFromAny(auth[AuthFieldClientSecret]) + if clientID == "" { + return nil, fmt.Errorf("clientId is required") + } + + bodyValues := url.Values{} + for key, value := range values { + if value != "" { + bodyValues.Set(key, value) + } + } + bodyValues.Set("client_id", clientID) + if authContext.TokenAuth == "" || authContext.TokenAuth == TokenAuthClientSecretPost { + if clientSecret != "" { + bodyValues.Set("client_secret", clientSecret) + } + } + + var body []byte + contentType := "application/x-www-form-urlencoded" + if authContext.TokenRequestFormat == TokenRequestFormatJSON { + jsonBody := map[string]string{} + for key, value := range bodyValues { + if len(value) > 0 { + jsonBody[key] = value[0] + } + } + var err error + body, err = json.Marshal(jsonBody) + if err != nil { + return nil, err + } + contentType = "application/json" + } else { + body = []byte(bodyValues.Encode()) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL.String(), bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", contentType) + req.Header.Set("Accept", "application/json") + if authContext.TokenAuth == TokenAuthClientSecretBasic && clientSecret != "" { + req.SetBasicAuth(clientID, clientSecret) + } + + client := &http.Client{ + Timeout: 30 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(respBody))) + } + var token OAuthTokenResponse + token.Raw = append([]byte{}, respBody...) + if err := json.Unmarshal(respBody, &token); err != nil { + return nil, err + } + if token.AccessToken == "" { + return nil, fmt.Errorf("oauth token response has no access_token") + } + return &token, nil +} + +func OAuthTokenURLAllowed(manifest Manifest, tokenURL *url.URL) bool { + for _, connector := range manifest.Permissions.Network.Connectors { + if connector.Type != ConnectorTypePublicAPI { + continue + } + baseURL, basePath, err := NormalizeConnectorBase(connector.FixedBaseURL, "") + if err != nil { + continue + } + target := ResolvedConnectorTarget{ + Name: connector.Name, + Type: connector.Type, + BaseURL: baseURL, + BasePath: basePath, + AllowedPathPrefixes: connector.AllowedPathPrefixes, + } + if err := ValidateConnectorURL(target, tokenURL); err == nil { + return true + } + } + return false +} + +// RefreshOAuthToken uses the stored refresh token, persists the refreshed auth +// map, and keeps the plugin instance configured when refresh succeeds. +func RefreshOAuthToken(ctx context.Context, app core.App, plugin LocalPlugin, instance *core.Record, auth map[string]any, contextName string) (map[string]any, error) { + _, authContext, err := OAuthContext(plugin, contextName) + if err != nil { + return auth, err + } + grantType := "refresh_token" + if authContext.Refresh != nil && authContext.Refresh.GrantType != "" { + grantType = authContext.Refresh.GrantType + } + refreshToken := StringFromAny(auth[AuthFieldRefreshToken]) + if refreshToken == "" { + return auth, fmt.Errorf("refreshToken is missing") + } + token, err := ExchangeOAuthToken(ctx, plugin.Manifest, authContext, auth, map[string]string{ + "grant_type": grantType, + "refresh_token": refreshToken, + }) + if err != nil { + return auth, err + } + if token.RefreshToken == "" { + token.RefreshToken = refreshToken + } + StoreOAuthToken(auth, contextName, token) + instance.Set("auth", auth) + instance.Set("status", "configured") + if err := app.Save(instance); err != nil { + return auth, err + } + return auth, nil +} + +// StoreOAuthToken normalizes provider token responses into the plugin instance +// auth map used by host injection and future refreshes. +func StoreOAuthToken(auth map[string]any, contextName string, token *OAuthTokenResponse) { + auth[AuthFieldOAuthContext] = contextName + auth[AuthFieldAccessToken] = token.AccessToken + if token.RefreshToken != "" { + auth[AuthFieldRefreshToken] = token.RefreshToken + } + if token.TokenType != "" { + auth[AuthFieldTokenType] = token.TokenType + } + if token.Scope != "" { + auth[AuthFieldScope] = token.Scope + } + if token.ExpiresIn > 0 { + auth[AuthFieldExpiresAt] = time.Now().Add(time.Duration(token.ExpiresIn) * time.Second).UTC().Format(time.RFC3339) + } +} + +// ClearOAuthToken removes persisted OAuth token material and transient OAuth +// flow fields from an auth map. +func ClearOAuthToken(auth map[string]any) { + for _, key := range []string{ + AuthFieldAccessToken, + AuthFieldRefreshToken, + AuthFieldTokenType, + AuthFieldExpiresAt, + AuthFieldScope, + AuthFieldOAuthState, + AuthFieldOAuthCodeVerifier, + AuthFieldOAuthRedirectURI, + } { + delete(auth, key) + } +} + +// PluginInputAuth returns the auth payload visible to plugin exports. OAuth +// token material is intentionally removed because provider requests should go +// through host auth injection instead. +func PluginInputAuth(plugin LocalPlugin, auth map[string]any) map[string]any { + out := map[string]any{} + for key, value := range auth { + out[key] = value + } + for _, context := range plugin.Manifest.Auth.Contexts { + if context.Type == AuthTypeOAuth2 { + for _, key := range PluginInputAuthBlockedFields() { + delete(out, key) + } + } + } + return out +} + +// RefreshOAuthAuthIfNeeded refreshes host-managed OAuth before a sync run if no +// access token exists or the current token is close to expiry. +func RefreshOAuthAuthIfNeeded(ctx context.Context, app core.App, plugin LocalPlugin, instance *core.Record, auth map[string]any) (map[string]any, error) { + for name, authContext := range plugin.Manifest.Auth.Contexts { + if authContext.Type != AuthTypeOAuth2 { + continue + } + if StringFromAny(auth[AuthFieldAccessToken]) == "" || OAuthNeedsRefresh(auth) { + return RefreshOAuthToken(ctx, app, plugin, instance, auth, name) + } + } + return auth, nil +} + +func OAuthNeedsRefresh(auth map[string]any) bool { + expiresAt := StringFromAny(auth[AuthFieldExpiresAt]) + if expiresAt == "" { + return false + } + parsed, err := time.Parse(time.RFC3339, expiresAt) + if err != nil { + return false + } + return time.Until(parsed) < time.Minute +} + +func StringFromAny(value any) string { + text, _ := value.(string) + return strings.TrimSpace(text) +} + +func randomURLToken(size int) string { + data := make([]byte, size) + if _, err := rand.Read(data); err != nil { + panic(err) + } + return base64.RawURLEncoding.EncodeToString(data) +} diff --git a/db/pluginsystem/policy.go b/db/pluginsystem/policy.go new file mode 100644 index 00000000..f02f2f83 --- /dev/null +++ b/db/pluginsystem/policy.go @@ -0,0 +1,417 @@ +package pluginsystem + +import ( + "fmt" + "net/url" + "path" + "slices" + "strings" +) + +const ( + ConnectorTypePublicAPI = "public_api" + ConnectorTypeConfigured = "configured" + TLSModeSystem = "system" + TLSModeCustomCA = "customCA" +) + +type RequestPolicyContext struct { + Connectors map[string]ResolvedConnectorTarget + HostAuth map[string]any +} + +func (p RequestPolicyContext) WithHostAuth(auth map[string]any) RequestPolicyContext { + p.HostAuth = auth + return p +} + +type ResolvedConnectorTarget struct { + Name string + Type string + BaseURL string + BasePath string + AllowPrivate bool + TLS ConnectorTLSConfig + StorageOrigins map[string]ResolvedConnectorOrigin + AllowedPathPrefixes []string + Auth []string + SupportsMediaAuth bool + SupportsStorageRedirects bool + SupportsCustomTLS bool +} + +type ConnectorTLSConfig struct { + Mode string + CABundle []byte +} + +type ResolvedConnectorOrigin struct { + Name string + BaseURL string + BasePath string + AllowPrivate bool + TLS ConnectorTLSConfig +} + +type ResolvedRequestTarget struct { + URL *url.URL + Connector ResolvedConnectorTarget +} + +// ValidateHostRequestSpec checks the static manifest policy before the host +// performs any plugin-controlled HTTP request. Provider traffic must use a +// connector target; plugins no longer hand the host absolute API URLs. +func ValidateHostRequestSpec(manifest Manifest, spec HostRequestSpec, policy RequestPolicyContext) error { + _, err := ValidateAndResolveHostRequestSpec(manifest, spec, policy) + return err +} + +func ValidateAndResolveHostRequestSpec(manifest Manifest, spec HostRequestSpec, policy RequestPolicyContext) (*ResolvedRequestTarget, error) { + if strings.TrimSpace(spec.Method) == "" { + return nil, fmt.Errorf("method is required") + } + resolved, err := ResolveRequestTarget(manifest, spec.Target, policy) + if err != nil { + return nil, err + } + if spec.Auth != "" { + if err := ValidateAuthReference(manifest, spec.Auth); err != nil { + return nil, err + } + if len(resolved.Connector.Auth) > 0 && !slices.Contains(resolved.Connector.Auth, spec.Auth) { + return nil, fmt.Errorf("auth context %q is not permitted for connector %q", spec.Auth, resolved.Connector.Name) + } + } + if err := validateExpectedResponse(spec.Expect, manifest.Permissions.Downloads); err != nil { + return nil, err + } + return resolved, nil +} + +func ValidateAuthReference(manifest Manifest, auth string) error { + if _, ok := manifest.Auth.Contexts[auth]; !ok { + return fmt.Errorf("auth context %q is not declared", auth) + } + if !slices.Contains(manifest.Permissions.Auth, auth) { + return fmt.Errorf("auth context %q is not permitted", auth) + } + return nil +} + +func ResolveRequestTarget(manifest Manifest, target RequestTarget, policy RequestPolicyContext) (*ResolvedRequestTarget, error) { + if target.Type != "connector" { + return nil, fmt.Errorf("request target type must be connector") + } + connector, ok := policy.Connectors[target.Connector] + if !ok { + return nil, fmt.Errorf("connector %q is not configured", target.Connector) + } + manifestConnector, ok := manifestConnector(manifest, target.Connector) + if !ok { + return nil, fmt.Errorf("connector %q is not declared by manifest", target.Connector) + } + connector.AllowedPathPrefixes = canonicalConnectorPrefixes(manifestConnector.AllowedPathPrefixes) + connector.Auth = manifestConnector.Auth + connector.SupportsMediaAuth = manifestConnector.SupportsMediaAuth + connector.SupportsStorageRedirects = manifestConnector.SupportsStorageRedirects + connector.SupportsCustomTLS = manifestConnector.SupportsCustomTLS + + built, err := BuildConnectorURL(connector, target.Path, target.Query) + if err != nil { + return nil, err + } + if err := ValidateConnectorURL(connector, built); err != nil { + return nil, err + } + return &ResolvedRequestTarget{URL: built, Connector: connector}, nil +} + +func manifestConnector(manifest Manifest, name string) (ConnectorTargetPermission, bool) { + for _, connector := range manifest.Permissions.Network.Connectors { + if connector.Name == name { + return connector, true + } + } + return ConnectorTargetPermission{}, false +} + +func BuildConnectorURL(connector ResolvedConnectorTarget, relPath string, query []QueryParam) (*url.URL, error) { + base, err := url.Parse(connector.BaseURL) + if err != nil || base.Scheme == "" || base.Host == "" { + return nil, fmt.Errorf("connector %q has invalid baseURL", connector.Name) + } + if base.RawQuery != "" || base.Fragment != "" { + return nil, fmt.Errorf("connector %q baseURL must not include query or fragment", connector.Name) + } + if base.Scheme != "http" && base.Scheme != "https" { + return nil, fmt.Errorf("connector %q scheme must be http or https", connector.Name) + } + base.Path = "" + base.RawPath = "" + + cleanBase, err := CanonicalURLPath(connector.BasePath) + if err != nil { + return nil, fmt.Errorf("connector %q basePath: %w", connector.Name, err) + } + cleanRel, err := CanonicalRelativeURLPath(relPath) + if err != nil { + return nil, err + } + fullPath := joinURLPaths(cleanBase, cleanRel) + if strings.HasSuffix(cleanRel, "/") && fullPath != "/" { + fullPath += "/" + } + base.Path = fullPath + + encodedQuery := make([]string, 0, len(query)) + for _, param := range query { + if hasControl(param.Name) || hasControl(param.Value) { + return nil, fmt.Errorf("query parameters must not contain control characters") + } + if param.Name == "" { + return nil, fmt.Errorf("query parameter name must not be empty") + } + encodedQuery = append(encodedQuery, url.QueryEscape(param.Name)+"="+url.QueryEscape(param.Value)) + } + base.RawQuery = strings.Join(encodedQuery, "&") + return base, nil +} + +func ValidateConnectorURL(connector ResolvedConnectorTarget, candidate *url.URL) error { + base, err := url.Parse(connector.BaseURL) + if err != nil { + return err + } + if !strings.EqualFold(candidate.Scheme, base.Scheme) { + return fmt.Errorf("connector request scheme escaped scope") + } + if !strings.EqualFold(candidate.Hostname(), base.Hostname()) { + return fmt.Errorf("connector request host escaped scope") + } + if effectivePort(candidate) != effectivePort(base) { + return fmt.Errorf("connector request port escaped scope") + } + + candidatePath, err := CanonicalURLPath(candidate.EscapedPath()) + if err != nil { + return err + } + basePath, err := CanonicalURLPath(connector.BasePath) + if err != nil { + return err + } + if !pathInPrefix(candidatePath, subtreePrefix(basePath)) { + return fmt.Errorf("connector request escaped base path") + } + prefixes := connector.AllowedPathPrefixes + if len(prefixes) == 0 { + prefixes = []string{"/"} + } + for _, prefix := range canonicalConnectorPrefixes(prefixes) { + fullPrefix := subtreePrefix(joinURLPaths(basePath, prefix)) + if pathInPrefix(candidatePath, fullPrefix) { + return nil + } + } + return fmt.Errorf("connector request path is not allowed") +} + +func ValidateConnectorRedirect(connector ResolvedConnectorTarget, initial *url.URL, redirected *url.URL) error { + if initial.Scheme == "https" && redirected.Scheme == "http" { + return fmt.Errorf("connector redirect downgrades https to http") + } + return ValidateConnectorURL(connector, redirected) +} + +func ValidateConnectorStorageRedirect(connector ResolvedConnectorTarget, initial *url.URL, redirected *url.URL) error { + _, err := ConnectorStorageRedirectOrigin(connector, initial, redirected) + return err +} + +func ConnectorStorageRedirectOrigin(connector ResolvedConnectorTarget, initial *url.URL, redirected *url.URL) (ResolvedConnectorOrigin, error) { + if !connector.SupportsStorageRedirects { + return ResolvedConnectorOrigin{}, fmt.Errorf("connector storage redirects are not supported") + } + if initial.Scheme == "https" && redirected.Scheme == "http" { + return ResolvedConnectorOrigin{}, fmt.Errorf("connector storage redirect downgrades https to http") + } + for _, origin := range connector.StorageOrigins { + target := ResolvedConnectorTarget{ + Name: origin.Name, + BaseURL: origin.BaseURL, + BasePath: origin.BasePath, + AllowPrivate: origin.AllowPrivate, + TLS: origin.TLS, + AllowedPathPrefixes: []string{"/"}, + } + if err := ValidateConnectorURL(target, redirected); err == nil { + return origin, nil + } + } + return ResolvedConnectorOrigin{}, fmt.Errorf("connector storage redirect target is not allowed") +} + +func NormalizeConnectorBase(rawURL string, extraBasePath string) (string, string, error) { + parsed, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "", "", fmt.Errorf("connector baseURL is invalid") + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", "", fmt.Errorf("connector baseURL scheme must be http or https") + } + if parsed.RawQuery != "" || parsed.Fragment != "" || parsed.User != nil { + return "", "", fmt.Errorf("connector baseURL must not include credentials, query, or fragment") + } + basePath := parsed.EscapedPath() + if extraBasePath != "" { + basePath = joinURLPaths(basePath, extraBasePath) + } + cleanPath, err := CanonicalURLPath(basePath) + if err != nil { + return "", "", err + } + parsed.Path = "" + parsed.RawPath = "" + return parsed.String(), cleanPath, nil +} + +func CanonicalRelativeURLPath(rawPath string) (string, error) { + if strings.TrimSpace(rawPath) == "" { + return "/", nil + } + if strings.HasPrefix(rawPath, "http://") || strings.HasPrefix(rawPath, "https://") || strings.HasPrefix(rawPath, "//") { + return "", fmt.Errorf("connector path must be relative") + } + cleaned, err := CanonicalURLPath("/" + strings.TrimLeft(rawPath, "/")) + if err != nil { + return "", err + } + if strings.HasSuffix(rawPath, "/") && cleaned != "/" { + cleaned += "/" + } + return cleaned, nil +} + +func CanonicalURLPath(rawPath string) (string, error) { + if rawPath == "" { + rawPath = "/" + } + if hasControl(rawPath) { + return "", fmt.Errorf("path must not contain control characters") + } + lower := strings.ToLower(rawPath) + if strings.Contains(lower, "%2f") || strings.Contains(lower, "%5c") { + return "", fmt.Errorf("encoded path separators are not allowed") + } + decoded, err := url.PathUnescape(rawPath) + if err != nil { + return "", fmt.Errorf("path has invalid escapes") + } + if strings.Contains(decoded, "\\") { + return "", fmt.Errorf("backslash is not allowed in URL paths") + } + if hasDangerousSecondEscape(decoded) { + return "", fmt.Errorf("ambiguous encoded path is not allowed") + } + cleaned := path.Clean("/" + strings.TrimLeft(decoded, "/")) + if cleaned == "." { + cleaned = "/" + } + return cleaned, nil +} + +func canonicalConnectorPrefixes(prefixes []string) []string { + if len(prefixes) == 0 { + return nil + } + canonical := make([]string, 0, len(prefixes)) + for _, prefix := range prefixes { + cleaned, err := CanonicalURLPath(prefix) + if err == nil { + canonical = append(canonical, cleaned) + } + } + return canonical +} + +func joinURLPaths(left string, right string) string { + if left == "" { + left = "/" + } + if right == "" { + right = "/" + } + joined := path.Join(left, right) + if joined == "." { + return "/" + } + if !strings.HasPrefix(joined, "/") { + joined = "/" + joined + } + return joined +} + +func subtreePrefix(prefix string) string { + if prefix == "/" { + return "/" + } + return strings.TrimRight(prefix, "/") + "/" +} + +func pathInPrefix(candidate string, prefix string) bool { + if prefix == "/" { + return true + } + candidate = subtreePrefix(candidate) + return strings.HasPrefix(candidate, prefix) +} + +func effectivePort(u *url.URL) string { + if port := u.Port(); port != "" { + return port + } + switch u.Scheme { + case "http": + return "80" + case "https": + return "443" + default: + return "" + } +} + +func hasControl(value string) bool { + for _, r := range value { + if r < 0x20 || r == 0x7f { + return true + } + } + return false +} + +func hasDangerousSecondEscape(value string) bool { + lower := strings.ToLower(value) + for _, marker := range []string{"%2f", "%5c", "%2e"} { + if strings.Contains(lower, marker) { + return true + } + } + return false +} + +// validateExpectedResponse lets a plugin request stricter response checks for a +// specific call while preventing it from exceeding manifest download limits. +func validateExpectedResponse(expect ResponseExpect, permissions DownloadPermissions) error { + if expect.MaxBytes < 0 { + return fmt.Errorf("expect.maxBytes must not be negative") + } + if permissions.MaxBytes > 0 && expect.MaxBytes > permissions.MaxBytes { + return fmt.Errorf("expect.maxBytes exceeds manifest download limit") + } + for _, contentType := range expect.ContentTypes { + if len(permissions.ContentTypes) > 0 && !slices.Contains(permissions.ContentTypes, contentType) { + return fmt.Errorf("content type %q is not allowed by manifest permissions", contentType) + } + } + return nil +} diff --git a/db/pluginsystem/policy_test.go b/db/pluginsystem/policy_test.go new file mode 100644 index 00000000..edc80543 --- /dev/null +++ b/db/pluginsystem/policy_test.go @@ -0,0 +1,169 @@ +package pluginsystem + +import ( + "net/url" + "testing" +) + +func TestValidateHostRequestSpecAcceptsConnectorAndAuthReference(t *testing.T) { + manifest := hammerheadManifestForTest() + spec := HostRequestSpec{ + Method: "POST", + Target: RequestTarget{ + Type: "connector", + Connector: "api", + Path: "/v1/users/123/routes/import/file", + }, + Auth: "provider_session", + Expect: ResponseExpect{ + ContentTypes: []string{"application/json"}, + MaxBytes: 1024, + }, + } + manifest.Permissions.Downloads.ContentTypes = append(manifest.Permissions.Downloads.ContentTypes, "application/json") + manifest.Permissions.Downloads.MaxBytes = 2048 + + if err := ValidateHostRequestSpec(manifest, spec, testPolicy()); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateHostRequestSpecRejectsUnknownConnector(t *testing.T) { + manifest := hammerheadManifestForTest() + spec := HostRequestSpec{ + Method: "GET", + Target: RequestTarget{Type: "connector", Connector: "evil", Path: "/v1"}, + } + + if err := ValidateHostRequestSpec(manifest, spec, testPolicy()); err == nil { + t.Fatal("expected error") + } +} + +func TestValidateHostRequestSpecRejectsPathScopeEscape(t *testing.T) { + manifest := hammerheadManifestForTest() + spec := HostRequestSpec{ + Method: "GET", + Target: RequestTarget{Type: "connector", Connector: "api", Path: "/v1-evil"}, + } + + if err := ValidateHostRequestSpec(manifest, spec, testPolicy()); err == nil { + t.Fatal("expected error") + } +} + +func TestValidateHostRequestSpecRejectsLimitExpansion(t *testing.T) { + manifest := hammerheadManifestForTest() + manifest.Permissions.Downloads.MaxBytes = 100 + spec := HostRequestSpec{ + Method: "GET", + Target: RequestTarget{Type: "connector", Connector: "api", Path: "/v1/users"}, + Expect: ResponseExpect{MaxBytes: 101}, + } + + if err := ValidateHostRequestSpec(manifest, spec, testPolicy()); err == nil { + t.Fatal("expected error") + } +} + +func TestBuildConnectorURLPreservesBasePathAndQueryOrder(t *testing.T) { + target := ResolvedConnectorTarget{ + Name: "immich", + BaseURL: "https://photos.example.test:8443", + BasePath: "/immich", + AllowedPathPrefixes: []string{"/api"}, + } + u, err := BuildConnectorURL(target, "/api/assets/1/original", []QueryParam{ + {Name: "z", Value: "last"}, + {Name: "key", Value: "a"}, + {Name: "key", Value: "b"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if u.String() != "https://photos.example.test:8443/immich/api/assets/1/original?z=last&key=a&key=b" { + t.Fatalf("unexpected url: %s", u.String()) + } + if err := ValidateConnectorURL(target, u); err != nil { + t.Fatalf("unexpected scope error: %v", err) + } +} + +func TestBuildConnectorURLPreservesTrailingSlash(t *testing.T) { + target := ResolvedConnectorTarget{ + Name: "komoot", + BaseURL: "https://api.komoot.de", + BasePath: "/", + AllowedPathPrefixes: []string{"/v006"}, + } + u, err := BuildConnectorURL(target, "/v006/account/email/user%40example.test/", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if u.String() != "https://api.komoot.de/v006/account/email/user@example.test/" { + t.Fatalf("unexpected url: %s", u.String()) + } +} + +func TestConnectorPathNormalizationRejectsAmbiguousEscapes(t *testing.T) { + for _, candidate := range []string{"/api%2fadmin", "/api/%252e%252e/admin", "/api/../admin"} { + t.Run(candidate, func(t *testing.T) { + target := ResolvedConnectorTarget{ + Name: "api", + BaseURL: "https://example.test", + BasePath: "/", + AllowedPathPrefixes: []string{"/api"}, + } + u, err := BuildConnectorURL(target, candidate, nil) + if err == nil { + err = ValidateConnectorURL(target, u) + } + if err == nil { + t.Fatal("expected scope error") + } + }) + } +} + +func TestConnectorStorageRedirectOriginReturnsMatchedOriginPolicy(t *testing.T) { + connector := ResolvedConnectorTarget{ + Name: "immich", + BaseURL: "https://photos.example.test", + BasePath: "/immich", + SupportsStorageRedirects: true, + StorageOrigins: map[string]ResolvedConnectorOrigin{ + "minio": { + Name: "minio", + BaseURL: "https://storage.example.test:9443", + BasePath: "/assets", + AllowPrivate: true, + TLS: ConnectorTLSConfig{Mode: TLSModeCustomCA, CABundle: []byte("ca")}, + }, + }, + } + initial, _ := BuildConnectorURL(connector, "/api/assets/1/original", nil) + redirected, _ := url.Parse("https://storage.example.test:9443/assets/bucket/photo.jpg") + + origin, err := ConnectorStorageRedirectOrigin(connector, initial, redirected) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if origin.Name != "minio" || !origin.AllowPrivate || origin.TLS.Mode != TLSModeCustomCA { + t.Fatalf("unexpected origin policy: %#v", origin) + } +} + +func testPolicy() RequestPolicyContext { + return RequestPolicyContext{ + Connectors: map[string]ResolvedConnectorTarget{ + "api": { + Name: "api", + Type: ConnectorTypePublicAPI, + BaseURL: "https://dashboard.hammerhead.io", + BasePath: "/", + AllowedPathPrefixes: []string{"/v1"}, + Auth: []string{"provider_session"}, + }, + }, + } +} diff --git a/db/pluginsystem/protocol.go b/db/pluginsystem/protocol.go new file mode 100644 index 00000000..62971e1c --- /dev/null +++ b/db/pluginsystem/protocol.go @@ -0,0 +1,212 @@ +package pluginsystem + +const ( + ManifestVersion = "1.0" + RuntimeWASM = "wasm" + + PluginTypeTrails = "trails" + + AuthTypeOAuth2 = "oauth2" + AuthTypeAPIKey = "api_key" + AuthTypeBearer = "bearer" + AuthTypeSession = "session" + + AuthRefreshModeHost = "host" + AuthRefreshModePlugin = "plugin" + + AuthPlacementQuery = "query" + AuthHeaderAuthorization = "Authorization" + AuthSchemeBearer = "Bearer" + TokenRequestFormatJSON = "json" + TokenAuthClientSecretPost = "client_secret_post" + TokenAuthClientSecretBasic = "client_secret_basic" + + HostRequestBodyTypeJSON = "json" + HostRequestBodyTypeForm = "form" + HostRequestBodyTypeMultipart = "multipart" + MultipartSourceTrail = "trail" + MultipartSourceTrailGPX = "trail.gpx" + MultipartTrailFilename = "trail.gpx" +) + +type Manifest struct { + ManifestVersion string `json:"manifestVersion"` + ID string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Version string `json:"version"` + Runtime RuntimeManifest `json:"runtime"` + Capabilities []CapabilityManifest `json:"capabilities"` + Auth AuthManifest `json:"auth,omitempty"` + Permissions PermissionManifest `json:"permissions,omitempty"` + ConfigSchema []ConfigField `json:"configSchema,omitempty"` + HostConfig map[string]any `json:"hostConfig,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type RuntimeManifest struct { + Type string `json:"type"` + Entrypoint string `json:"entrypoint"` +} + +type CapabilityManifest struct { + Name string `json:"name"` + Version string `json:"version"` + Export string `json:"export"` + RequiredFunctions []string `json:"requiredHostFunctions,omitempty"` + Job string `json:"job,omitempty"` +} + +type ConfigField struct { + Key string `json:"key"` + Type string `json:"type"` + Label string `json:"label,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + Description string `json:"description,omitempty"` + Descriptions map[string]string `json:"descriptions,omitempty"` + Options []ConfigFieldOption `json:"options,omitempty"` + Default any `json:"default,omitempty"` + Required bool `json:"required,omitempty"` + Hidden bool `json:"hidden,omitempty"` +} + +type ConfigFieldOption struct { + Value string `json:"value"` + Label string `json:"label,omitempty"` + Labels map[string]string `json:"labels,omitempty"` +} + +type AuthManifest struct { + Contexts map[string]AuthContext `json:"contexts,omitempty"` +} + +type AuthContext struct { + Type string `json:"type"` + Fields []string `json:"fields,omitempty"` + AuthorizationURL string `json:"authorizationUrl,omitempty"` + TokenURL string `json:"tokenUrl,omitempty"` + Scopes []string `json:"scopes,omitempty"` + ScopeSeparator string `json:"scopeSeparator,omitempty"` + PKCE bool `json:"pkce,omitempty"` + TokenRequestFormat string `json:"tokenRequestFormat,omitempty"` + TokenAuth string `json:"tokenAuth,omitempty"` + AuthorizationParams map[string]string `json:"authorizationParams,omitempty"` + Refresh *AuthRefresh `json:"refresh,omitempty"` + Placement string `json:"placement,omitempty"` + Name string `json:"name,omitempty"` + SecretField string `json:"secretField,omitempty"` + SecretFields []string `json:"secretFields,omitempty"` +} + +type AuthRefresh struct { + Mode string `json:"mode"` + GrantType string `json:"grantType,omitempty"` + Function string `json:"function,omitempty"` +} + +type PermissionManifest struct { + Network NetworkPermissions `json:"network,omitempty"` + Auth []string `json:"auth,omitempty"` + Downloads DownloadPermissions `json:"downloads,omitempty"` + Uploads UploadPermissions `json:"uploads,omitempty"` +} + +type NetworkPermissions struct { + Connectors []ConnectorTargetPermission `json:"connectors,omitempty"` + Redirects RedirectPermissions `json:"redirects,omitempty"` +} + +type ConnectorTargetPermission struct { + Name string `json:"name"` + Type string `json:"type"` + FixedBaseURL string `json:"fixedBaseURL,omitempty"` + ConfigKey string `json:"configKey,omitempty"` + AllowedPathPrefixes []string `json:"allowedPathPrefixes,omitempty"` + Auth []string `json:"auth,omitempty"` + SupportsMediaAuth bool `json:"supportsMediaAuth,omitempty"` + SupportsStorageRedirects bool `json:"supportsStorageRedirects,omitempty"` + SupportsCustomTLS bool `json:"supportsCustomTLS,omitempty"` +} + +type RedirectPermissions struct { + Mode string `json:"mode,omitempty"` + Hosts []string `json:"hosts,omitempty"` +} + +type DownloadPermissions struct { + MaxBytes int64 `json:"maxBytes,omitempty"` + ContentTypes []string `json:"contentTypes,omitempty"` +} + +type UploadPermissions struct { + MaxBytes int64 `json:"maxBytes,omitempty"` + ContentTypes []string `json:"contentTypes,omitempty"` +} + +type HostRequestSpec struct { + Method string `json:"method"` + Target RequestTarget `json:"target"` + Auth string `json:"auth,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + Body *HostRequestBody `json:"body,omitempty"` + Expect ResponseExpect `json:"expect,omitempty"` + FollowRedirects *bool `json:"followRedirects,omitempty"` +} + +type RequestTarget struct { + Type string `json:"type"` + Connector string `json:"connector,omitempty"` + Path string `json:"path,omitempty"` + Query []QueryParam `json:"query,omitempty"` +} + +type QueryParam struct { + Name string `json:"name"` + Value string `json:"value"` +} + +type HostRequestBody struct { + Type string `json:"type"` + JSON any `json:"json,omitempty"` + Form []FormField `json:"form,omitempty"` + Parts []MultipartPart `json:"parts,omitempty"` +} + +type FormField struct { + Name string `json:"name"` + Value string `json:"value"` +} + +type MultipartPart struct { + Name string `json:"name"` + Source string `json:"source,omitempty"` + Filename string `json:"filename,omitempty"` + ContentType string `json:"contentType,omitempty"` + JSON any `json:"json,omitempty"` +} + +type ResponseExpect struct { + ContentTypes []string `json:"contentTypes,omitempty"` + MaxBytes int64 `json:"maxBytes,omitempty"` +} + +type TrackTransferPlan struct { + Format string `json:"format"` + Transfer HostRequestSpec `json:"transfer"` +} + +type TrailSendPlan struct { + Request HostRequestSpec `json:"request"` +} + +type PluginError struct { + Code string `json:"code"` + Message string `json:"message,omitempty"` + RetryAfterSeconds *int `json:"retryAfterSeconds,omitempty"` +} + +type HostLogEntry struct { + Level string `json:"level"` + Message string `json:"message"` +} diff --git a/db/pluginsystem/runtime.go b/db/pluginsystem/runtime.go new file mode 100644 index 00000000..33cc3b98 --- /dev/null +++ b/db/pluginsystem/runtime.go @@ -0,0 +1,64 @@ +package pluginsystem + +import ( + "context" + "errors" + "fmt" +) + +var ErrRuntimeUnavailable = errors.New("plugin runtime is not available") + +type Runtime interface { + Call(ctx context.Context, plugin LocalPlugin, export string, input []byte, policy RequestPolicyContext) ([]byte, error) + OpenSession(ctx context.Context, plugin LocalPlugin, policy RequestPolicyContext) (RuntimeSession, error) +} + +type RuntimeSession interface { + Call(ctx context.Context, export string, input []byte) ([]byte, error) + Close(ctx context.Context) error +} + +type RuntimeRegistry struct { + wasm Runtime +} + +// NewRuntimeRegistry wires available runtime implementations behind the common +// Runtime interface. +func NewRuntimeRegistry() *RuntimeRegistry { + return &RuntimeRegistry{ + wasm: NewWorkerRuntime(), + } +} + +// RuntimeFor selects the runtime declared by a plugin manifest. +func (r *RuntimeRegistry) RuntimeFor(plugin LocalPlugin) (Runtime, error) { + switch plugin.Manifest.Runtime.Type { + case RuntimeWASM: + return r.wasm, nil + default: + return nil, ErrRuntimeUnavailable + } +} + +type UnavailableRuntime struct{} + +func (UnavailableRuntime) Call(context.Context, LocalPlugin, string, []byte, RequestPolicyContext) ([]byte, error) { + return nil, ErrRuntimeUnavailable +} + +func (UnavailableRuntime) OpenSession(context.Context, LocalPlugin, RequestPolicyContext) (RuntimeSession, error) { + return nil, ErrRuntimeUnavailable +} + +type PluginCallError struct { + PluginID string + Export string + PluginError PluginError +} + +func (e PluginCallError) Error() string { + if e.PluginError.Message == "" { + return fmt.Sprintf("call %s.%s: %s", e.PluginID, e.Export, e.PluginError.Code) + } + return fmt.Sprintf("call %s.%s: %s: %s", e.PluginID, e.Export, e.PluginError.Code, e.PluginError.Message) +} diff --git a/db/pluginsystem/status.go b/db/pluginsystem/status.go new file mode 100644 index 00000000..d08e9242 --- /dev/null +++ b/db/pluginsystem/status.go @@ -0,0 +1,89 @@ +package pluginsystem + +import ( + "errors" + "fmt" + "strings" + "time" +) + +// PluginCapabilityError wraps a plugin-reported error returned inside a +// successful export response, so status mapping can treat it like runtime +// PluginCallError failures. +type PluginCapabilityError struct { + Err *PluginError +} + +func (e PluginCapabilityError) Error() string { + if e.Err == nil { + return "plugin error" + } + if e.Err.Message == "" { + return fmt.Sprintf("plugin error %s", e.Err.Code) + } + return fmt.Sprintf("plugin error %s: %s", e.Err.Code, e.Err.Message) +} + +// InstanceStatusUpdate contains the normalized status fields that are written +// back to plugin_instances after a failed sync. +type InstanceStatusUpdate struct { + Status string + Code string + Message string + RetryNotBefore *time.Time +} + +// InstanceStatusForError converts sync/runtime errors into the persisted +// plugin_instances status fields used by the UI and cron backoff logic. +func InstanceStatusForError(err error, now time.Time) InstanceStatusUpdate { + var capabilityErr PluginCapabilityError + var callErr PluginCallError + if errors.As(err, &capabilityErr) && capabilityErr.Err != nil { + return InstanceStatusForPluginError(*capabilityErr.Err, now) + } + if errors.As(err, &callErr) { + return InstanceStatusForPluginError(callErr.PluginError, now) + } + return InstanceStatusUpdate{ + Status: "error", + Code: "provider_unavailable", + Message: err.Error(), + } +} + +// InstanceStatusForPluginError maps the stable plugin error codes from the ABI +// to host instance states. retryAfterSeconds wins over default retry windows. +func InstanceStatusForPluginError(pluginErr PluginError, now time.Time) InstanceStatusUpdate { + code := strings.TrimSpace(pluginErr.Code) + if code == "" { + code = "provider_unavailable" + } + message := strings.TrimSpace(pluginErr.Message) + if message == "" { + message = code + } + + status := "error" + switch code { + case "auth_failed", "invalid_grant", "unauthorized": + status = "needs_reauth" + case "rate_limited": + status = "rate_limited" + case "provider_unavailable", "temporary_unavailable": + status = "unavailable" + } + + update := InstanceStatusUpdate{ + Status: status, + Code: code, + Message: message, + } + if pluginErr.RetryAfterSeconds != nil && *pluginErr.RetryAfterSeconds > 0 { + retryNotBefore := now.Add(time.Duration(*pluginErr.RetryAfterSeconds) * time.Second) + update.RetryNotBefore = &retryNotBefore + } else if code == "rate_limited" { + retryNotBefore := now.Add(time.Hour) + update.RetryNotBefore = &retryNotBefore + } + return update +} diff --git a/db/pluginsystem/status_test.go b/db/pluginsystem/status_test.go new file mode 100644 index 00000000..1c3f0a45 --- /dev/null +++ b/db/pluginsystem/status_test.go @@ -0,0 +1,59 @@ +package pluginsystem + +import ( + "errors" + "testing" + "time" +) + +func TestInstanceStatusForPluginCapabilityError(t *testing.T) { + now := time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC) + retryAfter := 120 + + update := InstanceStatusForError(PluginCapabilityError{Err: &PluginError{ + Code: "rate_limited", + Message: "try later", + RetryAfterSeconds: &retryAfter, + }}, now) + + if update.Status != "rate_limited" { + t.Fatalf("expected status rate_limited, got %q", update.Status) + } + if update.Code != "rate_limited" || update.Message != "try later" { + t.Fatalf("unexpected error fields: %#v", update) + } + if update.RetryNotBefore == nil || !update.RetryNotBefore.Equal(now.Add(120*time.Second)) { + t.Fatalf("unexpected retry time: %#v", update.RetryNotBefore) + } +} + +func TestInstanceStatusForPluginCallError(t *testing.T) { + update := InstanceStatusForError(PluginCallError{ + PluginID: "strava", + Export: "list_activities_v1", + PluginError: PluginError{ + Code: "invalid_grant", + }, + }, time.Now()) + + if update.Status != "needs_reauth" { + t.Fatalf("expected status needs_reauth, got %q", update.Status) + } + if update.Code != "invalid_grant" || update.Message != "invalid_grant" { + t.Fatalf("unexpected error fields: %#v", update) + } + if update.RetryNotBefore != nil { + t.Fatalf("did not expect retry time: %#v", update.RetryNotBefore) + } +} + +func TestInstanceStatusForGenericError(t *testing.T) { + update := InstanceStatusForError(errors.New("network unavailable"), time.Now()) + + if update.Status != "error" { + t.Fatalf("expected status error, got %q", update.Status) + } + if update.Code != "provider_unavailable" || update.Message != "network unavailable" { + t.Fatalf("unexpected error fields: %#v", update) + } +} diff --git a/db/pluginsystem/worker.go b/db/pluginsystem/worker.go new file mode 100644 index 00000000..2dea885e --- /dev/null +++ b/db/pluginsystem/worker.go @@ -0,0 +1,507 @@ +package pluginsystem + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + "sync" + "time" + + "golang.org/x/sync/semaphore" +) + +const ( + defaultWorkerExportTimeout = 2 * time.Minute + defaultWorkerSessionTimeout = 15 * time.Minute + defaultWorkerSlotAcquireTimeout = 30 * time.Second + defaultWorkerCapturedStderrBytes = 64 * 1024 +) + +var ( + workerSlotsMu sync.Mutex + workerSlots *semaphore.Weighted +) + +type WorkerRuntime struct { + Executable string +} + +type RuntimeSessionFatalError struct { + Err error +} + +func (e RuntimeSessionFatalError) Error() string { + return e.Err.Error() +} + +func (e RuntimeSessionFatalError) Unwrap() error { + return e.Err +} + +func IsRuntimeSessionFatalError(err error) bool { + var fatal RuntimeSessionFatalError + return errors.As(err, &fatal) +} + +func NewWorkerRuntime() WorkerRuntime { + return WorkerRuntime{} +} + +func (r WorkerRuntime) Call(ctx context.Context, plugin LocalPlugin, export string, input []byte, policy RequestPolicyContext) ([]byte, error) { + session, err := r.OpenSession(ctx, plugin, policy) + if err != nil { + return nil, err + } + defer func() { + _ = session.Close(context.Background()) + }() + return session.Call(ctx, export, input) +} + +func (r WorkerRuntime) OpenSession(ctx context.Context, plugin LocalPlugin, policy RequestPolicyContext) (RuntimeSession, error) { + slot, err := acquireWorkerSlot(ctx) + if err != nil { + return nil, err + } + releaseSlot := true + defer func() { + if releaseSlot { + slot.Release(1) + } + }() + + executable := r.Executable + if executable == "" { + if configured := strings.TrimSpace(os.Getenv("WANDERER_PLUGIN_WORKER_BIN")); configured != "" { + executable = configured + } else { + var err error + executable, err = os.Executable() + if err != nil { + return nil, err + } + } + } + + cmd := exec.Command(executable, "plugin-worker") + cmd.Env = childEnvWithout("EXTISM_ENABLE_WASI_OUTPUT") + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, err + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + stderr := &boundedWorkerBuffer{limit: envInt("WANDERER_PLUGIN_WORKER_STDERR_BYTES", defaultWorkerCapturedStderrBytes)} + cmd.Stderr = stderr + + if err := cmd.Start(); err != nil { + return nil, err + } + + session := &workerRuntimeSession{ + plugin: plugin, + policy: policy, + sessionID: newWorkerSessionID(plugin.Manifest.ID), + cmd: cmd, + stdin: stdin, + stdout: stdout, + stderr: stderr, + requestMaxBytes: envInt("WANDERER_PLUGIN_WORKER_REQUEST_BYTES", defaultWorkerRequestMaxBytes), + responseMaxBytes: envInt("WANDERER_PLUGIN_WORKER_RESPONSE_BYTES", defaultWorkerResponseMaxBytes), + exportTimeout: envDuration("WANDERER_PLUGIN_WORKER_EXPORT_TIMEOUT", defaultWorkerExportTimeout), + slot: slot, + } + session.sessionTimer = time.AfterFunc(envDuration("WANDERER_PLUGIN_WORKER_SESSION_TIMEOUT", defaultWorkerSessionTimeout), func() { + session.markFatal("worker session timeout") + session.kill() + }) + + releaseSlot = false + return session, nil +} + +type workerRuntimeSession struct { + plugin LocalPlugin + policy RequestPolicyContext + sessionID string + cmd *exec.Cmd + stdin io.WriteCloser + stdout io.ReadCloser + stderr *boundedWorkerBuffer + requestMaxBytes int + responseMaxBytes int + exportTimeout time.Duration + sessionTimer *time.Timer + slot *semaphore.Weighted + + mu sync.Mutex + callMu sync.Mutex + waitMu sync.Mutex + waited bool + waitErr error + closed bool + fatal bool + fatalMsg string +} + +func (s *workerRuntimeSession) Call(ctx context.Context, export string, input []byte) ([]byte, error) { + s.callMu.Lock() + defer s.callMu.Unlock() + + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return nil, fmt.Errorf("worker session is closed") + } + if s.fatal { + msg := s.fatalMsg + s.mu.Unlock() + return nil, RuntimeSessionFatalError{Err: fmt.Errorf("worker session is invalid: %s", msg)} + } + s.mu.Unlock() + + callCtx, cancel := context.WithTimeout(ctx, s.exportTimeout) + defer cancel() + + result := make(chan workerCallOutcome, 1) + go func() { + result <- s.call(callCtx, export, input) + }() + + select { + case outcome := <-result: + if outcome.err != nil { + return nil, outcome.err + } + return outcome.output, nil + case <-callCtx.Done(): + s.markFatal("worker export timeout") + s.kill() + outcome := <-result + if outcome.err != nil && !errors.Is(outcome.err, io.EOF) { + return nil, RuntimeSessionFatalError{Err: fmt.Errorf("worker export timeout: %w", outcome.err)} + } + return nil, RuntimeSessionFatalError{Err: callCtx.Err()} + } +} + +type workerCallOutcome struct { + output []byte + err error +} + +func (s *workerRuntimeSession) call(ctx context.Context, export string, input []byte) workerCallOutcome { + msg, err := workerMessageWithData(workerMessageCallExport, workerCallExport{ + WASMPath: s.plugin.WASMPath, + Export: export, + InputBase64: encodeWorkerBytes(input), + SessionID: s.sessionID, + }) + if err != nil { + return workerCallOutcome{err: err} + } + if err := writeWorkerMessage(s.stdin, s.requestMaxBytes, msg); err != nil { + s.markFatal("write worker call_export failed") + s.kill() + return workerCallOutcome{err: RuntimeSessionFatalError{Err: err}} + } + + for { + msg, err := readWorkerMessage(s.stdout, s.responseMaxBytes) + if err != nil { + s.markFatal("read worker message failed") + s.kill() + return workerCallOutcome{err: RuntimeSessionFatalError{Err: s.withStderr(err)}} + } + switch msg.Type { + case workerMessageHostHTTPRequest: + if err := s.handleHostHTTPRequest(ctx, msg); err != nil { + s.markFatal("host http rpc failed") + s.kill() + return workerCallOutcome{err: RuntimeSessionFatalError{Err: s.withStderr(err)}} + } + case workerMessageHostLog: + s.handleHostLog(msg) + case workerMessageCallResult: + result, err := workerData[workerCallResult](msg) + if err != nil { + s.markFatal("invalid call_result payload") + s.kill() + return workerCallOutcome{err: RuntimeSessionFatalError{Err: err}} + } + if result.PluginError != nil { + return workerCallOutcome{err: PluginCallError{ + PluginID: s.plugin.Manifest.ID, + Export: export, + PluginError: *result.PluginError, + }} + } + output, err := decodeWorkerBytes(result.OutputBase64) + if err != nil { + s.markFatal("invalid call_result output") + s.kill() + return workerCallOutcome{err: RuntimeSessionFatalError{Err: err}} + } + return workerCallOutcome{output: output} + case workerMessageError: + payload, _ := workerData[workerError](msg) + s.markFatal(payload.Message) + s.kill() + if payload.Message == "" { + payload.Message = "worker returned fatal error" + } + return workerCallOutcome{err: RuntimeSessionFatalError{Err: s.withStderr(fmt.Errorf("%s", payload.Message))}} + default: + s.markFatal("unexpected worker message") + s.kill() + return workerCallOutcome{err: RuntimeSessionFatalError{Err: fmt.Errorf("unexpected worker message %q", msg.Type)}} + } + } +} + +func (s *workerRuntimeSession) handleHostLog(msg workerMessage) { + entry, err := workerData[workerHostLog](msg) + if err != nil { + log.Printf("plugin log invalid: session %s: %v", s.sessionID, err) + return + } + if entry.SessionID == "" { + entry.SessionID = s.sessionID + } + level, err := normalizeHostLogLevel(entry.Level) + if err != nil { + log.Printf("plugin log invalid: session %s: %v", s.sessionID, err) + return + } + message := sanitizeHostLogMessage(entry.Message) + if message == "" { + log.Printf("plugin log invalid: session %s: log message is required", s.sessionID) + return + } + log.Printf("plugin log [%s]: session %s: %s", level, entry.SessionID, message) +} + +func (s *workerRuntimeSession) handleHostHTTPRequest(ctx context.Context, msg workerMessage) error { + request, err := workerData[workerHostHTTPRequest](msg) + if err != nil { + return err + } + requestBytes, err := decodeWorkerBytes(request.RequestBase64) + if err != nil { + return err + } + response := executeHostHTTPRequest(ctx, s.plugin.Manifest, s.policy, requestBytes) + responseBytes, err := json.Marshal(response) + if err != nil { + return err + } + reply, err := workerMessageWithData(workerMessageHostHTTPResponse, workerHostHTTPResponse{ + ResponseBase64: encodeWorkerBytes(responseBytes), + }) + if err != nil { + return err + } + return writeWorkerMessage(s.stdin, s.responseMaxBytes, reply) +} + +func (s *workerRuntimeSession) Close(ctx context.Context) error { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return nil + } + s.closed = true + fatal := s.fatal + s.mu.Unlock() + + if s.sessionTimer != nil { + s.sessionTimer.Stop() + } + if !fatal { + _ = writeWorkerMessage(s.stdin, s.requestMaxBytes, workerMessage{Type: workerMessageShutdown}) + } + _ = s.stdin.Close() + + wait := make(chan error, 1) + go func() { + wait <- s.wait() + }() + + select { + case err := <-wait: + s.slot.Release(1) + if err != nil && !fatal { + return s.withStderr(err) + } + return nil + case <-ctx.Done(): + s.kill() + err := <-wait + s.slot.Release(1) + if err != nil { + return s.withStderr(err) + } + return ctx.Err() + } +} + +func (s *workerRuntimeSession) wait() error { + s.waitMu.Lock() + defer s.waitMu.Unlock() + if s.waited { + return s.waitErr + } + s.waited = true + s.waitErr = s.cmd.Wait() + return s.waitErr +} + +func (s *workerRuntimeSession) markFatal(msg string) { + s.mu.Lock() + defer s.mu.Unlock() + s.fatal = true + if s.fatalMsg == "" { + s.fatalMsg = msg + } +} + +func (s *workerRuntimeSession) kill() { + if s.cmd != nil && s.cmd.Process != nil { + _ = s.cmd.Process.Kill() + } + _ = s.stdin.Close() + _ = s.stdout.Close() +} + +func (s *workerRuntimeSession) withStderr(err error) error { + if err == nil { + return nil + } + stderr := strings.TrimSpace(s.stderr.String()) + if stderr == "" { + return err + } + return fmt.Errorf("%w: worker stderr: %s", err, stderr) +} + +func acquireWorkerSlot(ctx context.Context) (*semaphore.Weighted, error) { + timeout := envDuration("WANDERER_PLUGIN_WORKER_SLOT_TIMEOUT", defaultWorkerSlotAcquireTimeout) + acquireCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + slot := workerSemaphore() + if err := slot.Acquire(acquireCtx, 1); err != nil { + return nil, fmt.Errorf("acquire plugin worker slot: %w", err) + } + return slot, nil +} + +func workerSemaphore() *semaphore.Weighted { + limit := int64(envInt("WANDERER_PLUGIN_WORKER_MAX", maxInt(2, runtime.NumCPU()))) + workerSlotsMu.Lock() + defer workerSlotsMu.Unlock() + if workerSlots == nil { + workerSlots = semaphore.NewWeighted(limit) + } + return workerSlots +} + +func envInt(key string, fallback int) int { + raw := strings.TrimSpace(os.Getenv(key)) + if raw == "" { + return fallback + } + value, err := strconv.Atoi(raw) + if err != nil || value <= 0 { + return fallback + } + return value +} + +func envDuration(key string, fallback time.Duration) time.Duration { + raw := strings.TrimSpace(os.Getenv(key)) + if raw == "" { + return fallback + } + if value, err := time.ParseDuration(raw); err == nil && value > 0 { + return value + } + seconds, err := strconv.Atoi(raw) + if err != nil || seconds <= 0 { + return fallback + } + return time.Duration(seconds) * time.Second +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + +func childEnvWithout(keys ...string) []string { + blocked := map[string]bool{} + for _, key := range keys { + blocked[key] = true + } + env := os.Environ() + filtered := make([]string, 0, len(env)) + for _, entry := range env { + key := entry + if idx := strings.IndexByte(entry, '='); idx >= 0 { + key = entry[:idx] + } + if blocked[key] { + continue + } + filtered = append(filtered, entry) + } + return filtered +} + +func newWorkerSessionID(pluginID string) string { + var random [8]byte + if _, err := rand.Read(random[:]); err != nil { + return pluginID + "-" + strconv.FormatInt(time.Now().UnixNano(), 10) + } + return pluginID + "-" + hex.EncodeToString(random[:]) +} + +type boundedWorkerBuffer struct { + mu sync.Mutex + limit int + data []byte +} + +func (b *boundedWorkerBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + if b.limit <= 0 || len(b.data) >= b.limit { + return len(p), nil + } + remaining := b.limit - len(b.data) + if len(p) > remaining { + b.data = append(b.data, p[:remaining]...) + return len(p), nil + } + b.data = append(b.data, p...) + return len(p), nil +} + +func (b *boundedWorkerBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return string(b.data) +} diff --git a/db/pluginsystem/worker_process.go b/db/pluginsystem/worker_process.go new file mode 100644 index 00000000..a74d53d6 --- /dev/null +++ b/db/pluginsystem/worker_process.go @@ -0,0 +1,285 @@ +package pluginsystem + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + + extism "github.com/extism/go-sdk" +) + +// RunPluginWorker runs the stdio worker process. It is called by the main +// binary's plugin-worker subcommand before PocketBase is initialized. +func RunPluginWorker(ctx context.Context, stdin io.Reader, stdout io.Writer, stderr io.Writer) int { + _ = os.Unsetenv("EXTISM_ENABLE_WASI_OUTPUT") + + worker := &pluginWorkerProcess{ + ctx: ctx, + stdin: stdin, + stdout: stdout, + stderr: stderr, + requestMaxBytes: envInt("WANDERER_PLUGIN_WORKER_REQUEST_BYTES", defaultWorkerRequestMaxBytes), + responseMaxBytes: envInt("WANDERER_PLUGIN_WORKER_RESPONSE_BYTES", defaultWorkerResponseMaxBytes), + } + if err := worker.run(); err != nil { + _, _ = fmt.Fprintf(stderr, "plugin worker: %v\n", err) + return 1 + } + return 0 +} + +type pluginWorkerProcess struct { + ctx context.Context + stdin io.Reader + stdout io.Writer + stderr io.Writer + requestMaxBytes int + responseMaxBytes int + wasmPath string + sessionID string + instance *extism.Plugin + fatalErr error +} + +func (w *pluginWorkerProcess) run() error { + defer func() { + if w.instance != nil { + _ = w.instance.Close(w.ctx) + } + }() + + for { + msg, err := readWorkerMessage(w.stdin, w.requestMaxBytes) + if err != nil { + // A clean io.EOF means the parent closed stdin without a + // shutdown frame (e.g. it crashed); exit quietly. An + // io.ErrUnexpectedEOF means stdin was cut mid-frame, which is a + // truncated/corrupt frame and should surface as an error. + if err == io.EOF { + return nil + } + return err + } + + switch msg.Type { + case workerMessageShutdown: + return nil + case workerMessageCallExport: + if err := w.handleCallExport(msg); err != nil { + _ = w.sendError(err.Error()) + return err + } + default: + err := fmt.Errorf("unexpected worker message %q", msg.Type) + _ = w.sendError(err.Error()) + return err + } + } +} + +func (w *pluginWorkerProcess) handleCallExport(msg workerMessage) error { + call, err := workerData[workerCallExport](msg) + if err != nil { + return err + } + if call.WASMPath == "" || call.Export == "" { + return fmt.Errorf("call_export requires wasmPath and export") + } + // The session ID is set by the parent once per worker process and reused + // for every call. It carries no routing semantics here (a worker serves a + // single wasm path) but is threaded into errors so captured stderr can be + // tied back to a specific session during diagnosis. + w.sessionID = call.SessionID + if w.instance == nil { + if err := w.openPlugin(call.WASMPath); err != nil { + return w.errCtx(call.Export, err) + } + } else if call.WASMPath != w.wasmPath { + return w.errCtx(call.Export, fmt.Errorf("worker session cannot switch wasm path (have %q, got %q)", w.wasmPath, call.WASMPath)) + } + + input, err := decodeWorkerBytes(call.InputBase64) + if err != nil { + return w.errCtx(call.Export, fmt.Errorf("decode call input: %w", err)) + } + w.fatalErr = nil + code, output, err := w.instance.CallWithContext(w.ctx, call.Export, input) + if w.fatalErr != nil { + return w.errCtx(call.Export, w.fatalErr) + } + if err != nil { + return w.errCtx(call.Export, fmt.Errorf("call %s: %w", call.Export, err)) + } + if code != 0 { + pluginErr := pluginErrorForCode(call.Export, code, w.instance.GetErrorWithContext(w.ctx)) + return w.sendCallResult(workerCallResult{PluginError: &pluginErr}) + } + return w.sendCallResult(workerCallResult{OutputBase64: encodeWorkerBytes(output)}) +} + +// pluginErrorForCode maps a non-zero export return code into the PluginError +// reported to the parent. It prefers the structured error JSON the plugin set +// via the host error API, and falls back to a generic plugin_error when that +// payload is missing, malformed, or has no code. +func pluginErrorForCode(export string, code uint32, rawErr string) PluginError { + var parsed PluginError + if rawErr == "" || json.Unmarshal([]byte(rawErr), &parsed) != nil || parsed.Code == "" { + return PluginError{ + Code: "plugin_error", + Message: fmt.Sprintf("call %s failed with code %d", export, code), + } + } + return parsed +} + +func (w *pluginWorkerProcess) openPlugin(wasmPath string) error { + manifest := extism.Manifest{ + Wasm: []extism.Wasm{ + extism.WasmFile{Path: wasmPath}, + }, + } + instance, err := extism.NewPlugin(w.ctx, manifest, extism.PluginConfig{ + EnableWasi: true, + }, w.hostFunctions()) + if err != nil { + return fmt.Errorf("create wasm plugin: %w", err) + } + w.wasmPath = wasmPath + w.instance = instance + return nil +} + +func (w *pluginWorkerProcess) hostFunctions() []extism.HostFunction { + httpFn := extism.NewHostFunctionWithStack( + "http_request", + func(ctx context.Context, plugin *extism.CurrentPlugin, stack []uint64) { + requestBytes, err := plugin.ReadBytes(stack[0]) + if err != nil { + writeHostHTTPResponse(ctx, plugin, stack, hostHTTPResponse{ + Error: &PluginError{Code: "invalid_request", Message: err.Error()}, + }) + return + } + msg, err := workerMessageWithData(workerMessageHostHTTPRequest, workerHostHTTPRequest{ + RequestBase64: encodeWorkerBytes(requestBytes), + }) + if err != nil { + writeHostHTTPResponse(ctx, plugin, stack, hostHTTPResponse{ + Error: &PluginError{Code: "internal_error", Message: err.Error()}, + }) + return + } + if err := writeWorkerMessage(w.stdout, w.responseMaxBytes, msg); err != nil { + w.failHostRPC(stack, fmt.Errorf("write host http request: %w", err)) + return + } + responseMsg, err := readWorkerMessage(w.stdin, w.responseMaxBytes) + if err != nil { + w.failHostRPC(stack, fmt.Errorf("read host http response: %w", err)) + return + } + if responseMsg.Type != workerMessageHostHTTPResponse { + w.failHostRPC(stack, fmt.Errorf("unexpected host http response message %q", responseMsg.Type)) + return + } + response, err := workerData[workerHostHTTPResponse](responseMsg) + if err != nil { + w.failHostRPC(stack, fmt.Errorf("decode host http response: %w", err)) + return + } + responseBytes, err := decodeWorkerBytes(response.ResponseBase64) + if err != nil { + w.failHostRPC(stack, fmt.Errorf("decode host http response bytes: %w", err)) + return + } + offset, err := plugin.WriteBytes(responseBytes) + if err != nil { + plugin.Log(extism.LogLevelError, "write host http response: "+err.Error()) + stack[0] = 0 + return + } + stack[0] = offset + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) + httpFn.SetNamespace("wanderer") + + logFn := extism.NewHostFunctionWithStack( + "log", + func(ctx context.Context, plugin *extism.CurrentPlugin, stack []uint64) { + message, err := readBoundedHostLogPayload(plugin, stack[0]) + if err != nil { + plugin.Log(extism.LogLevelError, "read host log message: "+err.Error()) + return + } + entry, err := parseHostLogEntry(message) + if err != nil { + _, _ = fmt.Fprintf(w.stderr, "plugin log invalid: session %s: %v\n", w.sessionID, err) + return + } + msg, err := workerMessageWithData(workerMessageHostLog, workerHostLog{ + Level: entry.Level, + Message: entry.Message, + SessionID: w.sessionID, + }) + if err != nil { + _, _ = fmt.Fprintf(w.stderr, "plugin log encode failed: session %s: %v\n", w.sessionID, err) + return + } + if err := writeWorkerMessage(w.stdout, w.responseMaxBytes, msg); err != nil { + _, _ = fmt.Fprintf(w.stderr, "plugin log write failed: session %s: %v\n", w.sessionID, err) + } + _ = ctx + }, + []extism.ValueType{extism.ValueTypePTR}, + nil, + ) + logFn.SetNamespace("wanderer") + + return []extism.HostFunction{httpFn, logFn} +} + +func (w *pluginWorkerProcess) failHostRPC(stack []uint64, err error) { + w.fatalErr = err + stack[0] = 0 +} + +// errCtx annotates a fatal worker error with the active session and export so +// the message that the parent captures from stderr can be tied back to a +// specific call during diagnosis. +func (w *pluginWorkerProcess) errCtx(export string, err error) error { + if err == nil { + return nil + } + return fmt.Errorf("session %s export %s: %w", w.sessionID, export, err) +} + +// Worker error channels follow a strict convention: +// +// - sendCallResult with a PluginError reports a business-level rejection from +// the plugin (a bad call code). The session stays alive and reusable; the +// parent surfaces it as a PluginCallError. +// - sendError reports a broken protocol or runtime (corrupt frame, host RPC +// failure, unexpected message). The parent treats it as fatal and tears the +// session down. +// +// Keep new failure paths on the correct channel: recoverable plugin outcomes +// use sendCallResult, anything that invalidates the session uses sendError. +func (w *pluginWorkerProcess) sendCallResult(result workerCallResult) error { + msg, err := workerMessageWithData(workerMessageCallResult, result) + if err != nil { + return err + } + return writeWorkerMessage(w.stdout, w.responseMaxBytes, msg) +} + +func (w *pluginWorkerProcess) sendError(message string) error { + msg, err := workerMessageWithData(workerMessageError, workerError{Message: message}) + if err != nil { + return err + } + return writeWorkerMessage(w.stdout, w.responseMaxBytes, msg) +} diff --git a/db/pluginsystem/worker_rpc.go b/db/pluginsystem/worker_rpc.go new file mode 100644 index 00000000..d28d596b --- /dev/null +++ b/db/pluginsystem/worker_rpc.go @@ -0,0 +1,149 @@ +package pluginsystem + +import ( + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "io" +) + +const ( + workerMessageCallExport = "call_export" + workerMessageShutdown = "shutdown" + workerMessageHostHTTPResponse = "host_http_response" + workerMessageHostHTTPRequest = "host_http_request" + workerMessageHostLog = "host_log" + workerMessageCallResult = "call_result" + workerMessageError = "error" + + defaultWorkerRequestMaxBytes = 32 * 1024 * 1024 + defaultWorkerResponseMaxBytes = 64 * 1024 * 1024 +) + +// workerMessage is one framed RPC message on the worker stdio protocol. The +// protocol is strictly synchronous (one call_export in flight at a time, with +// host HTTP RPC nested synchronously), so messages carry no correlation ID. +type workerMessage struct { + Type string `json:"type"` + Data json.RawMessage `json:"data,omitempty"` +} + +type workerCallExport struct { + WASMPath string `json:"wasmPath"` + Export string `json:"export"` + InputBase64 string `json:"inputBase64,omitempty"` + SessionID string `json:"sessionId,omitempty"` +} + +type workerCallResult struct { + OutputBase64 string `json:"outputBase64,omitempty"` + PluginError *PluginError `json:"pluginError,omitempty"` +} + +type workerHostHTTPRequest struct { + RequestBase64 string `json:"requestBase64"` +} + +type workerHostHTTPResponse struct { + ResponseBase64 string `json:"responseBase64"` +} + +type workerHostLog struct { + Level string `json:"level"` + Message string `json:"message"` + SessionID string `json:"sessionId,omitempty"` +} + +type workerError struct { + Message string `json:"message"` +} + +func writeWorkerMessage(w io.Writer, maxBytes int, msg workerMessage) error { + payload, err := json.Marshal(msg) + if err != nil { + return err + } + if len(payload) > maxBytes { + return fmt.Errorf("worker rpc frame too large: %d > %d", len(payload), maxBytes) + } + var header [4]byte + binary.BigEndian.PutUint32(header[:], uint32(len(payload))) + if err := writeAll(w, header[:]); err != nil { + return err + } + return writeAll(w, payload) +} + +func readWorkerMessage(r io.Reader, maxBytes int) (workerMessage, error) { + var header [4]byte + if _, err := io.ReadFull(r, header[:]); err != nil { + return workerMessage{}, err + } + size := binary.BigEndian.Uint32(header[:]) + if size == 0 { + return workerMessage{}, fmt.Errorf("worker rpc frame is empty") + } + if int(size) > maxBytes { + return workerMessage{}, fmt.Errorf("worker rpc frame too large: %d > %d", size, maxBytes) + } + payload := make([]byte, int(size)) + if _, err := io.ReadFull(r, payload); err != nil { + return workerMessage{}, err + } + var msg workerMessage + if err := json.Unmarshal(payload, &msg); err != nil { + return workerMessage{}, err + } + if msg.Type == "" { + return workerMessage{}, fmt.Errorf("worker rpc message type is empty") + } + return msg, nil +} + +func encodeWorkerBytes(data []byte) string { + if len(data) == 0 { + return "" + } + return base64.StdEncoding.EncodeToString(data) +} + +func decodeWorkerBytes(encoded string) ([]byte, error) { + if encoded == "" { + return nil, nil + } + return base64.StdEncoding.DecodeString(encoded) +} + +func workerData[T any](msg workerMessage) (T, error) { + var value T + if len(msg.Data) == 0 { + return value, nil + } + if err := json.Unmarshal(msg.Data, &value); err != nil { + return value, err + } + return value, nil +} + +func workerMessageWithData[T any](typ string, data T) (workerMessage, error) { + raw, err := json.Marshal(data) + if err != nil { + return workerMessage{}, err + } + return workerMessage{Type: typ, Data: raw}, nil +} + +func writeAll(w io.Writer, data []byte) error { + for len(data) > 0 { + n, err := w.Write(data) + if err != nil { + return err + } + if n == 0 { + return io.ErrShortWrite + } + data = data[n:] + } + return nil +} diff --git a/db/pluginsystem/worker_test.go b/db/pluginsystem/worker_test.go new file mode 100644 index 00000000..b7c1bec3 --- /dev/null +++ b/db/pluginsystem/worker_test.go @@ -0,0 +1,369 @@ +package pluginsystem + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "os" + "strings" + "testing" + + extism "github.com/extism/go-sdk" + "github.com/pocketbase/pocketbase/core" +) + +func TestWorkerRPCFrameRoundTrip(t *testing.T) { + msg, err := workerMessageWithData(workerMessageCallExport, workerCallExport{ + WASMPath: "/tmp/plugin.wasm", + Export: "list_routes_v1", + InputBase64: encodeWorkerBytes([]byte(`{"ok":true}`)), + }) + if err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + if err := writeWorkerMessage(&buf, 1024, msg); err != nil { + t.Fatalf("write message: %v", err) + } + got, err := readWorkerMessage(&buf, 1024) + if err != nil { + t.Fatalf("read message: %v", err) + } + if got.Type != workerMessageCallExport { + t.Fatalf("unexpected type: %q", got.Type) + } + payload, err := workerData[workerCallExport](got) + if err != nil { + t.Fatalf("decode payload: %v", err) + } + input, err := decodeWorkerBytes(payload.InputBase64) + if err != nil { + t.Fatalf("decode input: %v", err) + } + if string(input) != `{"ok":true}` { + t.Fatalf("unexpected input: %s", input) + } +} + +func TestWorkerRPCRejectsOversizedFrameBeforePayloadRead(t *testing.T) { + msg, err := workerMessageWithData(workerMessageError, workerError{Message: "too large"}) + if err != nil { + t.Fatal(err) + } + var buf bytes.Buffer + if err := writeWorkerMessage(&buf, 1024, msg); err != nil { + t.Fatalf("write message: %v", err) + } + + if _, err := readWorkerMessage(&buf, 4); err == nil { + t.Fatal("expected oversized frame error") + } +} + +func TestPluginWorkerExitsOnStdinEOF(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + code := RunPluginWorker(context.Background(), bytes.NewReader(nil), &stdout, &stderr) + if code != 0 { + t.Fatalf("unexpected exit code %d, stderr %q", code, stderr.String()) + } + if stdout.Len() != 0 { + t.Fatalf("unexpected stdout: %q", stdout.String()) + } +} + +func TestPluginWorkerTruncatedFrameReturnsError(t *testing.T) { + var header [4]byte + binary.BigEndian.PutUint32(header[:], 100) + stdin := bytes.NewReader(append(header[:], []byte("partial")...)) + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := RunPluginWorker(context.Background(), stdin, &stdout, &stderr) + if code == 0 { + t.Fatal("expected non-zero exit code for truncated frame") + } + if stderr.Len() == 0 { + t.Fatal("expected truncated frame error on stderr") + } +} + +func TestPluginWorkerUnexpectedMessageTypeFails(t *testing.T) { + msg, err := workerMessageWithData(workerMessageHostHTTPResponse, workerHostHTTPResponse{}) + if err != nil { + t.Fatal(err) + } + var stdin bytes.Buffer + if err := writeWorkerMessage(&stdin, defaultWorkerRequestMaxBytes, msg); err != nil { + t.Fatal(err) + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := RunPluginWorker(context.Background(), &stdin, &stdout, &stderr) + if code == 0 { + t.Fatal("expected non-zero exit code for unexpected message type") + } + reply, err := readWorkerMessage(&stdout, defaultWorkerResponseMaxBytes) + if err != nil { + t.Fatalf("read worker reply: %v", err) + } + if reply.Type != workerMessageError { + t.Fatalf("expected error reply, got %q", reply.Type) + } +} + +func TestHandleCallExportRejectsWasmPathSwitch(t *testing.T) { + worker := &pluginWorkerProcess{ + ctx: context.Background(), + instance: &extism.Plugin{}, + wasmPath: "/plugins/a.wasm", + } + msg, err := workerMessageWithData(workerMessageCallExport, workerCallExport{ + WASMPath: "/plugins/b.wasm", + Export: "list_routes_v1", + SessionID: "sess-1", + }) + if err != nil { + t.Fatal(err) + } + + err = worker.handleCallExport(msg) + if err == nil { + t.Fatal("expected error when switching wasm path") + } + for _, want := range []string{"sess-1", "list_routes_v1", "/plugins/a.wasm", "/plugins/b.wasm"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error %q missing %q", err.Error(), want) + } + } +} + +func TestHandleCallExportRejectsInvalidInput(t *testing.T) { + worker := &pluginWorkerProcess{ + ctx: context.Background(), + instance: &extism.Plugin{}, + wasmPath: "/plugins/a.wasm", + } + msg, err := workerMessageWithData(workerMessageCallExport, workerCallExport{ + WASMPath: "/plugins/a.wasm", + Export: "list_routes_v1", + InputBase64: "!!!not-base64!!!", + SessionID: "sess-2", + }) + if err != nil { + t.Fatal(err) + } + + err = worker.handleCallExport(msg) + if err == nil { + t.Fatal("expected error for invalid input base64") + } + if !strings.Contains(err.Error(), "sess-2") || !strings.Contains(err.Error(), "decode call input") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestWorkerHostLogFrameRoundTrip(t *testing.T) { + msg, err := workerMessageWithData(workerMessageHostLog, workerHostLog{ + Level: "info", + Message: "detail fetch took 1s", + SessionID: "sess-log", + }) + if err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + if err := writeWorkerMessage(&buf, 1024, msg); err != nil { + t.Fatalf("write message: %v", err) + } + got, err := readWorkerMessage(&buf, 1024) + if err != nil { + t.Fatalf("read worker message: %v", err) + } + if got.Type != workerMessageHostLog { + t.Fatalf("expected host_log, got %q", got.Type) + } + payload, err := workerData[workerHostLog](got) + if err != nil { + t.Fatalf("decode host log: %v", err) + } + if payload.Level != "info" || payload.Message != "detail fetch took 1s" || payload.SessionID != "sess-log" { + t.Fatalf("unexpected host log payload: %#v", payload) + } +} + +func TestPluginErrorForCode(t *testing.T) { + t.Run("falls back when raw error is empty", func(t *testing.T) { + got := pluginErrorForCode("list_routes_v1", 7, "") + if got.Code != "plugin_error" || !strings.Contains(got.Message, "code 7") { + t.Fatalf("unexpected fallback error: %#v", got) + } + }) + t.Run("falls back when raw error is malformed", func(t *testing.T) { + got := pluginErrorForCode("list_routes_v1", 1, "{not json") + if got.Code != "plugin_error" { + t.Fatalf("expected fallback for malformed json, got %#v", got) + } + }) + t.Run("falls back when code is empty", func(t *testing.T) { + got := pluginErrorForCode("list_routes_v1", 1, `{"message":"boom"}`) + if got.Code != "plugin_error" { + t.Fatalf("expected fallback for missing code, got %#v", got) + } + }) + t.Run("passes through structured error", func(t *testing.T) { + got := pluginErrorForCode("list_routes_v1", 1, `{"code":"rate_limited","message":"slow down"}`) + if got.Code != "rate_limited" || got.Message != "slow down" { + t.Fatalf("expected structured error, got %#v", got) + } + }) +} + +func TestExecuteHostHTTPRequestRejectsInvalidPayload(t *testing.T) { + response := executeHostHTTPRequest(context.Background(), Manifest{}, RequestPolicyContext{}, []byte("not json")) + if response.Error == nil || response.Error.Code != "invalid_request" { + t.Fatalf("expected invalid_request error, got %#v", response) + } +} + +func TestPluginWorkerHostRPCFatalSetsClearError(t *testing.T) { + worker := &pluginWorkerProcess{} + stack := []uint64{123} + + worker.failHostRPC(stack, errors.New("host RPC read failed")) + + if stack[0] != 0 { + t.Fatalf("expected null response pointer, got %d", stack[0]) + } + if worker.fatalErr == nil || worker.fatalErr.Error() != "host RPC read failed" { + t.Fatalf("unexpected fatal error: %v", worker.fatalErr) + } +} + +func TestRuntimeSessionFatalErrorIsDetectableThroughWrapping(t *testing.T) { + err := fmt.Errorf("outer: %w", RuntimeSessionFatalError{Err: errors.New("worker died")}) + if !IsRuntimeSessionFatalError(err) { + t.Fatal("expected fatal session error") + } + if IsRuntimeSessionFatalError(errors.New("plugin error")) { + t.Fatal("unexpected fatal session error") + } +} + +func TestChildEnvWithoutStripsKeys(t *testing.T) { + t.Setenv("EXTISM_ENABLE_WASI_OUTPUT", "1") + t.Setenv("WANDERER_TEST_KEEP", "yes") + + env := childEnvWithout("EXTISM_ENABLE_WASI_OUTPUT") + for _, entry := range env { + if entry == "EXTISM_ENABLE_WASI_OUTPUT=1" { + t.Fatalf("unexpected stripped env entry in %#v", env) + } + } + if os.Getenv("EXTISM_ENABLE_WASI_OUTPUT") != "1" { + t.Fatal("childEnvWithout should not mutate the current process env") + } +} + +func TestInjectHostRequestAuthUsesExistingSessionForRefresh(t *testing.T) { + spec := HostRequestSpec{Auth: "session"} + session := &fakeRuntimeSession{ + output: []byte(`{"token":"session-token"}`), + } + err := InjectHostRequestAuth(context.Background(), AuthInjectionInput{ + Session: session, + Plugin: LocalPlugin{Manifest: Manifest{ + Auth: AuthManifest{Contexts: map[string]AuthContext{ + "session": { + Type: AuthTypeSession, + SecretFields: []string{"email", "password"}, + Refresh: &AuthRefresh{Mode: AuthRefreshModePlugin, Function: "refresh_session_v1"}, + }, + }}, + Permissions: PermissionManifest{Auth: []string{"session"}}, + }}, + Instance: testPluginInstance("inst1", "plugin.test"), + Auth: map[string]any{"email": "user@example.com", "password": "secret"}, + Spec: &spec, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if session.export != "refresh_session_v1" { + t.Fatalf("unexpected export: %q", session.export) + } + if got := spec.Headers[AuthHeaderAuthorization]; got != AuthSchemeBearer+" session-token" { + t.Fatalf("unexpected auth header: %q", got) + } + + var input map[string]any + if err := json.Unmarshal(session.input, &input); err != nil { + t.Fatalf("invalid refresh input: %v", err) + } + auth, ok := input["auth"].(map[string]any) + if !ok { + t.Fatalf("missing refresh auth: %#v", input) + } + if _, ok := auth["accessToken"]; ok { + t.Fatalf("refresh auth leaked access token: %#v", auth) + } +} + +type fakeRuntimeSession struct { + export string + input []byte + output []byte + err error +} + +func (s *fakeRuntimeSession) Call(_ context.Context, export string, input []byte) ([]byte, error) { + s.export = export + s.input = append([]byte(nil), input...) + if s.err != nil { + return nil, s.err + } + return s.output, nil +} + +func (s *fakeRuntimeSession) Close(context.Context) error { + return nil +} + +func TestInjectHostRequestAuthDoesNotRequireRuntimeWhenSessionProvided(t *testing.T) { + spec := HostRequestSpec{Auth: "session"} + session := &fakeRuntimeSession{err: errors.New("session failed")} + err := InjectHostRequestAuth(context.Background(), AuthInjectionInput{ + Session: session, + Plugin: LocalPlugin{Manifest: Manifest{ + Auth: AuthManifest{Contexts: map[string]AuthContext{ + "session": { + Type: AuthTypeSession, + SecretFields: []string{"email"}, + Refresh: &AuthRefresh{Mode: AuthRefreshModePlugin, Function: "refresh_session_v1"}, + }, + }}, + Permissions: PermissionManifest{Auth: []string{"session"}}, + }}, + Instance: testPluginInstance("inst1", "plugin.test"), + Auth: map[string]any{"email": "user@example.com"}, + Spec: &spec, + }) + if err == nil || err.Error() != "session failed" { + t.Fatalf("unexpected error: %v", err) + } +} + +func testPluginInstance(id string, pluginID string) *core.Record { + collection := core.NewBaseCollection("plugin_instances") + collection.Fields.Add(&core.TextField{Name: "plugin_id"}) + record := core.NewRecord(collection) + record.Id = id + record.Set("plugin_id", pluginID) + return record +} diff --git a/db/routes/integration_hammerhead.go b/db/routes/integration_hammerhead.go deleted file mode 100644 index f1b29287..00000000 --- a/db/routes/integration_hammerhead.go +++ /dev/null @@ -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) -} diff --git a/db/routes/integration_komoot.go b/db/routes/integration_komoot.go deleted file mode 100644 index 7fc4dc89..00000000 --- a/db/routes/integration_komoot.go +++ /dev/null @@ -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) -} diff --git a/db/routes/integration_strava.go b/db/routes/integration_strava.go deleted file mode 100644 index c8771cb3..00000000 --- a/db/routes/integration_strava.go +++ /dev/null @@ -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) -} diff --git a/db/routes/plugin_system.go b/db/routes/plugin_system.go new file mode 100644 index 00000000..3b525d1f --- /dev/null +++ b/db/routes/plugin_system.go @@ -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 +} diff --git a/db/routes/plugin_system_auth.go b/db/routes/plugin_system_auth.go new file mode 100644 index 00000000..a89509b2 --- /dev/null +++ b/db/routes/plugin_system_auth.go @@ -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}, + ) +} diff --git a/db/routes/plugin_system_category_remap.go b/db/routes/plugin_system_category_remap.go new file mode 100644 index 00000000..70794183 --- /dev/null +++ b/db/routes/plugin_system_category_remap.go @@ -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 +} diff --git a/db/routes/plugin_system_config.go b/db/routes/plugin_system_config.go new file mode 100644 index 00000000..75cce981 --- /dev/null +++ b/db/routes/plugin_system_config.go @@ -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") +} diff --git a/db/routes/plugin_system_policy.go b/db/routes/plugin_system_policy.go new file mode 100644 index 00000000..869e397f --- /dev/null +++ b/db/routes/plugin_system_policy.go @@ -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 +} diff --git a/db/routes/plugin_system_policy_test.go b/db/routes/plugin_system_policy_test.go new file mode 100644 index 00000000..c1e8f506 --- /dev/null +++ b/db/routes/plugin_system_policy_test.go @@ -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) + } +} diff --git a/db/routes/plugin_system_send.go b/db/routes/plugin_system_send.go new file mode 100644 index 00000000..27209acf --- /dev/null +++ b/db/routes/plugin_system_send.go @@ -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 +} diff --git a/db/routes/plugin_system_session_auth.go b/db/routes/plugin_system_session_auth.go new file mode 100644 index 00000000..2cf3a5f1 --- /dev/null +++ b/db/routes/plugin_system_session_auth.go @@ -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) +} diff --git a/db/routes/plugin_system_sync.go b/db/routes/plugin_system_sync.go new file mode 100644 index 00000000..7c5cb471 --- /dev/null +++ b/db/routes/plugin_system_sync.go @@ -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" +} diff --git a/db/routes/plugin_system_sync_test.go b/db/routes/plugin_system_sync_test.go new file mode 100644 index 00000000..62f87aaf --- /dev/null +++ b/db/routes/plugin_system_sync_test.go @@ -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) + } +} diff --git a/db/services/trailmerge/integration_merge.go b/db/services/trailmerge/plugin_merge.go similarity index 91% rename from db/services/trailmerge/integration_merge.go rename to db/services/trailmerge/plugin_merge.go index 7754b0a5..910ebd93 100644 --- a/db/services/trailmerge/integration_merge.go +++ b/db/services/trailmerge/plugin_merge.go @@ -13,7 +13,7 @@ func TryAutoMergeImportedTrail( ctx context.Context, actor *core.Record, sourceTrailID string, - settings IntegrationAutoMergeSettings, + settings PluginAutoMergeSettings, ) error { if actor == nil || sourceTrailID == "" || !settings.Enabled { return nil @@ -43,5 +43,5 @@ func TryAutoMergeImportedTrail( return nil } - return Merge(app, client, ctx, actor, sourceTrailID, targetTrailID, DefaultIntegrationAutoMergeMergeSettings()) + return Merge(app, client, ctx, actor, sourceTrailID, targetTrailID, DefaultPluginAutoMergeMergeSettings()) } diff --git a/db/services/trailmerge/service.go b/db/services/trailmerge/service.go index 52211657..e845f8fb 100644 --- a/db/services/trailmerge/service.go +++ b/db/services/trailmerge/service.go @@ -46,7 +46,7 @@ type MergeSettings struct { Likes bool `json:"likes"` } -type IntegrationAutoMergeSettings struct { +type PluginAutoMergeSettings struct { Enabled bool `json:"enabled"` } @@ -132,13 +132,13 @@ type targetSelectionResult struct { Stats map[string]targetSelectionStats } -func DefaultIntegrationAutoMergeSettings() IntegrationAutoMergeSettings { - return IntegrationAutoMergeSettings{ +func DefaultPluginAutoMergeSettings() PluginAutoMergeSettings { + return PluginAutoMergeSettings{ Enabled: false, } } -func DefaultIntegrationAutoMergeMergeSettings() MergeSettings { +func DefaultPluginAutoMergeMergeSettings() MergeSettings { return MergeSettings{ SummitLog: true, Photos: true, diff --git a/db/util/network_test.go b/db/util/network_test.go new file mode 100644 index 00000000..c0b4b6a7 --- /dev/null +++ b/db/util/network_test.go @@ -0,0 +1,68 @@ +package util + +import ( + "bytes" + "context" + "net" + "testing" +) + +func TestFetchPublicURLRejectsUnsafeInputs(t *testing.T) { + tests := []string{ + "ftp://example.com/file.jpg", + "http://user:pass@example.com/file.jpg", + "http://127.0.0.1/file.jpg", + "http://localhost/file.jpg", + "http://10.0.0.1/file.jpg", + "http://169.254.169.254/latest/meta-data", + "http://[::1]/file.jpg", + "http://[fc00::1]/file.jpg", + "http://example.com:8080/file.jpg", + } + for _, rawURL := range tests { + t.Run(rawURL, func(t *testing.T) { + if _, err := FetchPublicURL(context.Background(), rawURL, 1024); err == nil { + t.Fatal("expected error") + } + }) + } +} + +func TestReadBoundedForPlugin(t *testing.T) { + if _, err := ReadBoundedForPlugin(bytes.NewReader([]byte("1234")), 4); err != nil { + t.Fatalf("unexpected exact-limit error: %v", err) + } + if _, err := ReadBoundedForPlugin(bytes.NewReader([]byte("12345")), 4); err == nil { + t.Fatal("expected oversized response error") + } +} + +func TestConnectorTLSConfigRejectsInsecureMode(t *testing.T) { + if _, err := connectorTLSConfig("insecure", nil); err == nil { + t.Fatal("expected insecure TLS mode to be rejected") + } +} + +func TestConnectorIPAllowed(t *testing.T) { + tests := []struct { + ip string + allowPrivate bool + want bool + }{ + {ip: "8.8.8.8", want: true}, + {ip: "10.0.0.1", want: false}, + {ip: "10.0.0.1", allowPrivate: true, want: true}, + {ip: "fc00::1", allowPrivate: true, want: true}, + {ip: "127.0.0.1", allowPrivate: true, want: false}, + {ip: "169.254.1.1", allowPrivate: true, want: false}, + {ip: "100.64.0.1", allowPrivate: true, want: false}, + {ip: "192.0.2.1", allowPrivate: true, want: false}, + } + for _, test := range tests { + t.Run(test.ip, func(t *testing.T) { + if got := connectorIPAllowed(net.ParseIP(test.ip), test.allowPrivate); got != test.want { + t.Fatalf("got %v, want %v", got, test.want) + } + }) + } +} diff --git a/db/util/safe_fetch.go b/db/util/safe_fetch.go new file mode 100644 index 00000000..4764cdb7 --- /dev/null +++ b/db/util/safe_fetch.go @@ -0,0 +1,226 @@ +package util + +import ( + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "io" + "net" + "net/http" + "net/netip" + "net/url" + "time" + + "github.com/doyensec/safeurl" +) + +const ( + DefaultPluginMediaMaxBytes int64 = 50 << 20 + DefaultPluginMaxImportMediaItems = 20 + DefaultPluginMaxImportMediaBytes int64 = 200 << 20 +) + +type SafeFetchResult struct { + Body []byte + ContentType string + FinalURL string +} + +type ConnectorHTTPPolicy struct { + BaseURL string + AllowPrivate bool + TLSMode string + TLSCABundle []byte +} + +func FetchPublicURL(ctx context.Context, rawURL string, maxBytes int64) (*SafeFetchResult, error) { + if maxBytes <= 0 { + maxBytes = DefaultPluginMediaMaxBytes + } + parsed, err := url.Parse(rawURL) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return nil, fmt.Errorf("invalid public URL") + } + if parsed.User != nil { + return nil, fmt.Errorf("public URL must not include credentials") + } + config := safeurl.GetConfigBuilder(). + SetTimeout(60*time.Second). + SetAllowedSchemes("http", "https"). + SetAllowedPorts(80, 443). + EnableIPv6(true). + AllowSendingCredentials(false). + SetCheckRedirect(publicMediaRedirectPolicy). + Build() + client := safeurl.Client(config) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := ReadBoundedForPlugin(resp.Body, maxBytes) + if err != nil { + return nil, err + } + return &SafeFetchResult{ + Body: body, + ContentType: resp.Header.Get("Content-Type"), + FinalURL: resp.Request.URL.String(), + }, nil +} + +func publicMediaRedirectPolicy(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return fmt.Errorf("too many redirects") + } + if req.URL.User != nil { + return fmt.Errorf("redirect URL must not include credentials") + } + if req.URL.Scheme != "http" && req.URL.Scheme != "https" { + return fmt.Errorf("redirect scheme must be http or https") + } + if len(via) > 0 && via[len(via)-1].URL.Scheme == "https" && req.URL.Scheme == "http" { + return fmt.Errorf("redirect downgrades https to http") + } + return nil +} + +func ConnectorHTTPClient(policy ConnectorHTTPPolicy, checkRedirect func(req *http.Request, via []*http.Request) error) (*http.Client, error) { + base, err := url.Parse(policy.BaseURL) + if err != nil || base.Scheme == "" || base.Host == "" { + return nil, fmt.Errorf("invalid connector baseURL") + } + tlsConfig, err := connectorTLSConfig(policy.TLSMode, policy.TLSCABundle) + if err != nil { + return nil, err + } + dialer := &net.Dialer{Timeout: 30 * time.Second} + transport := &http.Transport{ + TLSClientConfig: tlsConfig, + DialContext: func(ctx context.Context, network string, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host) + if err != nil || len(ips) == 0 { + return nil, fmt.Errorf("failed to resolve connector host: %w", err) + } + var selected net.IP + for _, ip := range ips { + if connectorIPAllowed(ip, policy.AllowPrivate) { + selected = ip + break + } + } + if selected == nil { + return nil, fmt.Errorf("connector host resolved outside allowed IP policy") + } + return dialer.DialContext(ctx, network, net.JoinHostPort(selected.String(), port)) + }, + } + return &http.Client{ + Timeout: 60 * time.Second, + Transport: transport, + CheckRedirect: checkRedirect, + }, nil +} + +func connectorTLSConfig(mode string, caBundle []byte) (*tls.Config, error) { + switch mode { + case "", "system": + return nil, nil + case "customCA": + roots, err := x509.SystemCertPool() + if err != nil || roots == nil { + roots = x509.NewCertPool() + } + if len(caBundle) == 0 || !roots.AppendCertsFromPEM(caBundle) { + return nil, fmt.Errorf("connector customCA bundle is invalid") + } + return &tls.Config{RootCAs: roots}, nil + default: + return nil, fmt.Errorf("unsupported connector TLS mode %q", mode) + } +} + +func connectorIPAllowed(ip net.IP, allowPrivate bool) bool { + addr, ok := netip.AddrFromSlice(ip) + if !ok { + return false + } + if addr.Is4In6() { + addr = addr.Unmap() + } + if addr.IsLoopback() || addr.IsLinkLocalUnicast() || addr.IsLinkLocalMulticast() || + addr.IsMulticast() || addr.IsUnspecified() { + return false + } + if isSpecialPurposeIP(addr) { + return false + } + if addr.IsPrivate() { + return allowPrivate + } + return true +} + +func isSpecialPurposeIP(addr netip.Addr) bool { + for _, prefix := range specialPurposePrefixes { + if prefix.Contains(addr) { + return true + } + } + return false +} + +var specialPurposePrefixes = mustPrefixes( + "0.0.0.0/8", + "100.64.0.0/10", + "127.0.0.0/8", + "169.254.0.0/16", + "192.0.0.0/24", + "192.0.2.0/24", + "198.18.0.0/15", + "198.51.100.0/24", + "203.0.113.0/24", + "224.0.0.0/4", + "240.0.0.0/4", + "::/128", + "::1/128", + "64:ff9b::/96", + "100::/64", + "2001:db8::/32", + "fe80::/10", + "ff00::/8", +) + +func mustPrefixes(values ...string) []netip.Prefix { + prefixes := make([]netip.Prefix, 0, len(values)) + for _, value := range values { + prefix, err := netip.ParsePrefix(value) + if err != nil { + panic(err) + } + prefixes = append(prefixes, prefix) + } + return prefixes +} + +func ReadBoundedForPlugin(reader io.Reader, maxBytes int64) ([]byte, error) { + body, err := io.ReadAll(io.LimitReader(reader, maxBytes+1)) + if err != nil { + return nil, err + } + if int64(len(body)) > maxBytes { + return nil, fmt.Errorf("response exceeds maximum size") + } + return body, nil +} diff --git a/db/util/trail_access.go b/db/util/trail_access.go new file mode 100644 index 00000000..f20f5d4b --- /dev/null +++ b/db/util/trail_access.go @@ -0,0 +1,45 @@ +package util + +import ( + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/core" +) + +// TrailViewableByUser mirrors the trails view/read rule for custom backend +// routes that load a trail server-side and therefore bypass PocketBase's normal +// collection API permission checks. +func TrailViewableByUser(app core.App, trail *core.Record, userID string, shareToken string) bool { + if trail == nil || userID == "" { + return false + } + if trail.GetBool("public") { + return true + } + + actor, err := app.FindFirstRecordByData("activitypub_actors", "user", userID) + if err != nil { + return false + } + if trail.GetString("author") == actor.Id { + return true + } + + share, err := app.FindFirstRecordByFilter( + "trail_share", + "trail={:trail} && actor={:actor}", + dbx.Params{"trail": trail.Id, "actor": actor.Id}, + ) + if err == nil && share != nil { + return true + } + + if shareToken == "" { + return false + } + linkShare, err := app.FindFirstRecordByFilter( + "trail_link_share", + "trail={:trail} && token={:token}", + dbx.Params{"trail": trail.Id, "token": shareToken}, + ) + return err == nil && linkShare != nil +} diff --git a/db/util/trail_external_reference.go b/db/util/trail_external_reference.go index 9a64f7e5..8001cea2 100644 --- a/db/util/trail_external_reference.go +++ b/db/util/trail_external_reference.go @@ -1,30 +1,38 @@ package util import ( + "database/sql" + "errors" "fmt" + "strings" + "time" "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/core" ) -func FindTrailByExternalReference(app core.App, provider string, externalID string) (*core.Record, error) { - if provider == "" || externalID == "" { +func FindTrailByExternalReferenceForUser(app core.App, userID string, provider string, externalID string) (*core.Record, error) { + if userID == "" || provider == "" || externalID == "" { return nil, nil } refs, err := app.FindRecordsByFilter( "trail_external_reference", - "provider={:provider} && external_id={:external_id}", + "user={:user} && provider={:provider} && external_id={:external_id}", "+created", 1, 0, dbx.Params{ + "user": userID, "provider": provider, "external_id": externalID, }, ) if err != nil || len(refs) == 0 { - return nil, err + if err != nil { + return nil, err + } + return nil, nil } trailID := refs[0].GetString("trail") @@ -32,21 +40,105 @@ func FindTrailByExternalReference(app core.App, provider string, externalID stri return nil, nil } - return app.FindRecordById("trails", trailID) + trail, err := app.FindRecordById("trails", trailID) + if err == nil { + return trail, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return nil, err + } + + if deleteErr := app.Delete(refs[0]); deleteErr != nil { + return nil, fmt.Errorf("delete orphaned trail external reference: %w", deleteErr) + } + app.Logger().Warn("deleted orphaned trail external reference", "provider", provider, "external_id", externalID, "trail", trailID) + return nil, nil } -func EnsureTrailExternalReference(app core.App, trailID string, provider string, externalID string) error { +func FindExistingExternalReferenceIDsForUser(app core.App, userID string, provider string, externalIDs []string) (map[string]bool, error) { + existingIDs := map[string]bool{} + if userID == "" || provider == "" || len(externalIDs) == 0 { + return existingIDs, nil + } + + params := dbx.Params{ + "user": userID, + "provider": provider, + } + seen := map[string]bool{} + idFilters := make([]string, 0, len(externalIDs)) + for _, externalID := range externalIDs { + if externalID == "" || seen[externalID] { + continue + } + seen[externalID] = true + paramName := fmt.Sprintf("external_id_%d", len(idFilters)) + params[paramName] = externalID + idFilters = append(idFilters, "external_id={:"+paramName+"}") + } + if len(idFilters) == 0 { + return existingIDs, 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 existingIDs, err + } + + trailIDs := make([]string, 0, len(refs)) + for _, ref := range refs { + if trailID := ref.GetString("trail"); trailID != "" { + trailIDs = append(trailIDs, trailID) + } + } + var trails []*core.Record + if len(trailIDs) > 0 { + trails, err = app.FindRecordsByIds("trails", trailIDs) + if err != nil { + return nil, err + } + } + trailsByID := make(map[string]bool, len(trails)) + for _, trail := range trails { + trailsByID[trail.Id] = true + } + + for _, ref := range refs { + trailID := ref.GetString("trail") + if trailID != "" && trailsByID[trailID] { + existingIDs[ref.GetString("external_id")] = true + continue + } + if deleteErr := app.Delete(ref); deleteErr != nil { + return nil, fmt.Errorf("delete orphaned trail external reference: %w", deleteErr) + } + app.Logger().Warn("deleted orphaned trail external reference", "provider", provider, "external_id", ref.GetString("external_id"), "trail", trailID) + } + return existingIDs, nil +} + +func EnsureTrailExternalReference(app core.App, trailID string, provider string, externalID string, pluginID string, providerCategory string) error { if trailID == "" || provider == "" || externalID == "" { return nil } + userID, err := externalReferenceUserID(app, trailID) + if err != nil { + return err + } + if userID == "" { + app.Logger().Warn("skipping trail external reference without local user", "provider", provider, "external_id", externalID, "trail", trailID) + return nil + } refs, err := app.FindRecordsByFilter( "trail_external_reference", - "provider={:provider} && external_id={:external_id}", + "user={:user} && provider={:provider} && external_id={:external_id}", "", 1, 0, dbx.Params{ + "user": userID, "provider": provider, "external_id": externalID, }, @@ -56,6 +148,19 @@ func EnsureTrailExternalReference(app core.App, trailID string, provider string, } if len(refs) > 0 { if refs[0].GetString("trail") == trailID { + changed := false + if pluginID != "" && refs[0].GetString("plugin_id") == "" { + refs[0].Set("plugin_id", pluginID) + changed = true + } + if refs[0].GetDateTime("provider_category_checked_at").IsZero() { + refs[0].Set("provider_category", providerCategory) + refs[0].Set("provider_category_checked_at", time.Now()) + changed = true + } + if changed { + return app.Save(refs[0]) + } return nil } return fmt.Errorf("trail external reference already exists for another trail") @@ -68,14 +173,30 @@ func EnsureTrailExternalReference(app core.App, trailID string, provider string, record := core.NewRecord(collection) record.Load(map[string]any{ - "trail": trailID, - "provider": provider, - "external_id": externalID, + "trail": trailID, + "user": userID, + "provider": provider, + "external_id": externalID, + "plugin_id": pluginID, + "provider_category": providerCategory, + "provider_category_checked_at": time.Now(), }) return app.Save(record) } +func externalReferenceUserID(app core.App, trailID string) (string, error) { + trail, err := app.FindRecordById("trails", trailID) + if err != nil { + return "", err + } + actor, err := app.FindRecordById("activitypub_actors", trail.GetString("author")) + if err != nil { + return "", err + } + return actor.GetString("user"), nil +} + func ReassignTrailExternalReferences(app core.App, sourceTrailID string, targetTrailID string) error { if sourceTrailID == "" || targetTrailID == "" || sourceTrailID == targetTrailID { return nil diff --git a/docker-compose.yml b/docker-compose.yml index 4df742ad..fa8790f9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,6 +41,7 @@ services: restart: unless-stopped volumes: - ./data/pb_data:/pb_data + - ./data/plugins:/data/plugins healthcheck: test: ["CMD", "/curl", "--fail", "http://localhost:8090/health"] interval: 15s diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 131b0306..aeed090d 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -83,8 +83,8 @@ export default defineConfig({ link: '/use/import-export/' }, { - label: 'Integrations', - link: '/use/integrations/' + label: 'Plugins', + link: '/use/plugins/' }, ] }, @@ -97,6 +97,7 @@ export default defineConfig({ { label: 'Quickstart', link: '/run/installation/quick' }, { label: 'Manual Docker Setup', link: '/run/installation/docker' }, { label: 'Install from Source', link: '/run/installation/from-source' }, + { label: 'Plugin installation', link: '/run/installation/plugins' }, ] }, { @@ -137,6 +138,10 @@ export default defineConfig({ label: 'Federation', link: '/develop/federation/' }, + { + label: 'Plugin System', + link: '/develop/plugin-system/' + }, ] }, ...openAPISidebarGroups, diff --git a/docs/src/content/docs/develop/plugin-system.md b/docs/src/content/docs/develop/plugin-system.md new file mode 100644 index 00000000..c299ad1e --- /dev/null +++ b/docs/src/content/docs/develop/plugin-system.md @@ -0,0 +1,923 @@ +--- +title: Plugin System +description: Build, install, and run WASM provider plugins in wanderer +--- + +Plugins let wanderer connect to external providers such as Strava, komoot, and +Hammerhead without adding provider-specific API code to the core application. + +A plugin is a local directory with a `plugin.json` manifest and a WASM +entrypoint: + +```text +data/plugins/ + strava/ + plugin.json + plugin.wasm + icon.svg +``` + +wanderer discovers plugins from direct child directories of `data/plugins`. +Plugin configuration, credentials, sync state, and status are stored per user in +`plugin_instances`. + +## Quickstart + +Use an existing first-party plugin as a starting point: + +- [Hammerhead plugin source](https://github.com/open-wanderer/wanderer/tree/main/plugins/hammerhead) +- [komoot plugin source](https://github.com/open-wanderer/wanderer/tree/main/plugins/komoot) +- [Strava plugin source](https://github.com/open-wanderer/wanderer/tree/main/plugins/strava) + +For local development: + +```sh +make plugins-build +make plugins-install-local +``` + +Start wanderer and open the plugin settings page. The plugin should appear once +its bundle exists at: + +```text +data/plugins//plugin.json +data/plugins//plugin.wasm +``` + +## 1st-party plugins + +First-party plugin source lives in the repository under `plugins/`: + +```text +plugins/ + hammerhead/ + komoot/ + strava/ + sdk/ +``` + +Build all bundled plugins: + +```sh +make plugins-build +``` + +Build and install them into the local runtime directory: + +```sh +make plugins-install-local +``` + +Package release archives: + +```sh +make plugins-package +``` + +Release archives are published as separate GitHub release assets. The database +Docker image does not contain provider plugins. + +## Plugin layout + +A provider plugin should use this layout: + +```text +plugins// + go.mod + plugin.json + main.go + assets/icon.svg + Makefile +``` + +Generated runtime files are written to `dist//` and are ignored by +git: + +```text +plugins/strava/dist/strava/ + plugin.json + plugin.wasm + icon.svg +``` + +The generated `dist/` directory is the directory users install below +`data/plugins`. + +Icons are referenced from `plugin.json` metadata and copied from `assets/` into +the dist directory by the plugin `Makefile`: + +```json +{ + "metadata": { + "icons": { + "light": "icon.svg", + "dark": "icon_dark.svg" + } + } +} +``` + +`dark` is optional. + +## Go SDK + +Go/TinyGo plugins should import the plugin SDK: + +```go +import "github.com/open-wanderer/wanderer/plugins/sdk" +``` + +The SDK contains plugin-side protocol types and host-function helpers. It does +not depend on wanderer core or PocketBase. + +Most plugins use: + +- `sdk.HostRequest` for provider API calls through `wanderer.http_request` +- `sdk.Get` and `sdk.PostJSON` convenience helpers +- `sdk.HostRequestSpec`, `sdk.ResponseExpect`, and multipart body constants +- auth/header constants such as `sdk.AuthHeaderAuthorization` + +## Manifest + +Each plugin must define a static `plugin.json` manifest. The manifest is the +security and capability contract used by the host. + +The repository includes a JSON Schema at +`plugins/schema/plugin.schema.json`. Add a `$schema` field in source manifests +to get editor completion and inline validation: + +```json +{ + "$schema": "../schema/plugin.schema.json" +} +``` + +Minimal shape: + +```json +{ + "manifestVersion": "1.0", + "id": "example", + "type": "trails", + "name": "Example", + "version": "0.1.0", + "runtime": { + "type": "wasm", + "entrypoint": "plugin.wasm" + }, + "capabilities": [ + { + "name": "list_routes", + "version": "v1", + "export": "list_routes_v1" + }, + { + "name": "get_route_detail", + "version": "v1", + "export": "get_route_detail_v1" + } + ], + "permissions": { + "network": { + "connectors": [ + { + "name": "api", + "type": "public_api", + "fixedBaseURL": "https://api.example.com", + "allowedPathPrefixes": ["/v1"] + } + ] + }, + "downloads": { + "maxBytes": 1048576, + "contentTypes": ["application/json"] + } + } +} +``` + +Important rules: + +- `type` is the functional plugin category. Currently only `trails` is supported. +- `runtime.entrypoint` must be relative to the plugin directory. +- `id` must match the installed directory name by convention. +- `capabilities[].export` names the WASM export the runtime calls. +- `permissions.network.connectors` declares every provider target the plugin may + request through the host. +- per-request limits may narrow manifest limits, but never expand them. +- `configSchema[].required` marks plugin-owned settings that the settings UI + must collect before saving. + +### Network connectors + +Provider HTTP is connector-based. Plugins do not send absolute provider URLs to +the host; they name a connector and a relative path. The host resolves that +connector to a concrete base URL, validates the path scope, injects auth, and +executes the request. + +Connector types: + +| Type | Purpose | +| --- | --- | +| `public_api` | Fixed public provider API declared in the manifest. Use this for SaaS APIs such as Strava, komoot, or Hammerhead. | +| `configured` | Provider target configured by the host under `config.host.connectors`. Use this for self-hosted services. | + +`public_api` connectors must declare `fixedBaseURL`: + +```json +{ + "name": "api", + "type": "public_api", + "fixedBaseURL": "https://api.example.com", + "allowedPathPrefixes": ["/v1"], + "auth": ["oauth_access_token"] +} +``` + +`configured` connectors must declare `configKey`; the host supplies the concrete +base URL and trust settings: + +```json +{ + "name": "media", + "type": "configured", + "configKey": "immich", + "allowedPathPrefixes": ["/api"], + "auth": ["api_key"], + "supportsMediaAuth": true, + "supportsStorageRedirects": true, + "supportsCustomTLS": true +} +``` + +Connector fields: + +| Field | Meaning | +| --- | --- | +| `name` | Connector identifier used by `HostRequestSpec.target.connector` and `MediaRef.connector`. | +| `type` | `public_api` or `configured`. | +| `fixedBaseURL` | Fixed URL for public APIs. Must not include credentials, query, or fragment. | +| `configKey` | Host config key for configured connectors. | +| `allowedPathPrefixes` | Relative provider paths the plugin may request. Defaults to `/` when empty. | +| `auth` | Auth contexts allowed for this connector. | +| `supportsMediaAuth` | Allows connector media downloads to reference an auth context. | +| `supportsStorageRedirects` | Allows connector media downloads to redirect to configured storage origins. | +| `supportsCustomTLS` | Allows the host to attach a custom CA bundle to this connector. | + +The host validates scheme, host, effective port, base path, path prefixes, +redirect targets, TLS policy, and IP policy. `allowPrivate`, custom CA bundles, +and storage origins are host-owned settings; plugin output can never enable +private-network access. + +## Capabilities + +Implemented sync/send capabilities: + +| Capability | Export Example | Purpose | +| --- | --- | --- | +| `list_routes.v1` | `list_routes_v1` | List planned route IDs | +| `get_route_detail.v1` | `get_route_detail_v1` | Return one planned route import | +| `list_activities.v1` | `list_activities_v1` | List completed activity IDs | +| `get_activity_detail.v1` | `get_activity_detail_v1` | Return one completed activity import | +| `prepare_trail_send.v1` | `prepare_trail_send_v1` | Prepare sending a trail | + +Import sync is a two-step protocol. A plugin that declares `list_routes.v1` +must also declare `get_route_detail.v1`; a plugin that declares +`list_activities.v1` must also declare `get_activity_detail.v1`. If the matching +detail capability is missing, the host skips that list capability and logs a +warning. This is a breaking change from older one-step sync plugins whose +`list_*` exports returned full trail imports. + +Session-based plugins may also export an auth refresh function declared by the +manifest, for example: + +```json +{ + "auth": { + "contexts": { + "provider_session": { + "type": "session", + "fields": ["email", "password"], + "secretFields": ["password"], + "refresh": { + "mode": "plugin", + "function": "refresh_session_v1" + } + } + } + } +} +``` + +## Sync input + +`list_routes_v1` and `list_activities_v1` receive JSON input: + +```json +{ + "instance": { + "id": "abc123", + "pluginId": "strava" + }, + "auth": {}, + "state": {}, + "options": { + "after": "2026-01-01" + }, + "limits": { + "maxItems": 50 + } +} +``` + +`auth` contains only values the host is allowed to pass to the plugin. For +OAuth plugins, refresh tokens and client secrets are not included in normal sync +capability input. Depending on the auth model, `auth` may contain values such +as: + +```json +{ + "accessToken": "short-lived-token" +} +``` + +or, for session-based providers: + +```json +{ + "email": "user@example.com", + "password": "encrypted-at-rest-but-decrypted-for-plugin-login" +} +``` + +## List output + +List capabilities return lightweight summaries plus capability-local state. The +host uses `source.provider` and `source.externalId` for deduplication and calls +the matching detail capability only for new items. + +```json +{ + "items": [ + { + "source": { + "provider": "strava", + "externalId": "123", + "url": "https://provider.example/routes/123" + }, + "kind": "planned" + } + ], + "state": { + "page": 2 + }, + "hasMore": true +} +``` + +State returned by a plugin is first fed back into the next batch of the same +sync run. Only persistent provider cursors belong in `plugin_instances.state`. +Transient batch cursors such as `page` are not stored in the database. + +## Detail input + +`get_route_detail_v1` and `get_activity_detail_v1` receive the summary selected +by the host: + +```json +{ + "instance": { + "id": "abc123", + "pluginId": "strava" + }, + "auth": {}, + "options": { + "after": "2026-01-01" + }, + "summary": { + "source": { + "provider": "strava", + "externalId": "123" + }, + "kind": "planned" + } +} +``` + +## Detail output + +Detail capabilities return the full trail import: + +```json +{ + "item": { + "source": { + "provider": "strava", + "externalId": "123", + "url": "https://provider.example/routes/123" + }, + "kind": "planned", + "name": "Morning Ride", + "track": { + "format": "gpx", + "contentBase64": "..." + }, + "waypoints": [ + { + "name": "Viewpoint", + "lat": 47.3769, + "lon": 8.5417, + "photos": [ + { + "filename": "viewpoint.jpg", + "contentType": "image/jpeg", + "source": { + "type": "url", + "url": "https://provider.example/photo.jpg" + } + } + ] + } + ], + "metadata": { + "distance": 12345.6, + "elevationGain": 320.5, + "elevationLoss": 318.1, + "duration": 4567, + "providerCategory": "Ride" + } + } + } +} +``` + +The host imports the trails, writes PocketBase records, applies visibility +rules, deduplicates by provider/external ID, and stores the returned state. +Trail photos are attached to the imported trail. Waypoint photos are attached to +the corresponding waypoint records. Waypoint `distance_from_start` is derived +by the host from the nearest position on the imported GPX track. + +Media sources have two trust models: + +| Source type | Meaning | +| --- | --- | +| `url` | Public external media URL. The host fetches it with public-only SSRF protections and bounded size limits. | +| `connector` | Provider-owned media fetched through a declared connector, optional host-injected auth, connector TLS/IP policy, and connector-scoped redirects. | + +Public media example: + +```json +{ + "filename": "cover.jpg", + "contentType": "image/jpeg", + "source": { + "type": "url", + "url": "https://cdn.example.com/photos/cover.jpg" + } +} +``` + +Connector media example: + +```json +{ + "filename": "original.jpg", + "contentType": "image/jpeg", + "source": { + "type": "connector", + "mediaRef": { + "connector": "media", + "auth": "api_key", + "path": "/api/assets/123/original", + "query": [ + { "name": "size", "value": "preview" } + ], + "assetId": "123" + } + } +} +``` + +`mediaRef.path` is required for connector downloads. `assetId` is metadata only +for now; the host does not resolve `assetId` into a URL. + +Plugins should return GPX as the canonical track. If the provider exposes +authoritative summary metrics, the plugin may additionally return them in +`metadata`: + +| Metadata key | Unit | Meaning | +| --- | --- | --- | +| `distance` | meters | Provider-reported trail distance. | +| `elevationGain` | meters | Provider-reported positive elevation gain. | +| `elevationLoss` | meters | Provider-reported negative elevation loss. | +| `duration` | seconds | Provider-reported elapsed duration. | +| `providerStart` | object | Provider-reported intended start coordinate, for example `{ "lat": 47.123, "lon": 8.456 }`. | +| `providerCategory` | string | Raw provider activity/category value used by host category mapping. | + +The host uses positive provider metrics when present and falls back to GPX +derived metrics otherwise. Start location comes from the GPX unless +`providerStart` is present and close enough to the imported GPX track to be +plausible. Plugins should not map `providerCategory` to local category IDs; the +host owns that mapping. + +## Host config + +Plugin manifests may suggest defaults for host-owned settings with +`hostConfig`. These values are stored in `installed_plugins.config.host` and can +be overridden per plugin instance with `plugin_instances.config.host`. Host +config is never passed to plugin exports. + +Supported host fields: + +| Field | Type | Used by | Meaning | +| --- | --- | --- | --- | +| `planned` | boolean | `list_routes.v1` | Enables planned route sync for the instance. | +| `completed` | boolean | `list_activities.v1` | Enables completed activity sync for the instance. | +| `privacy` | string | Trail import | `original` keeps provider visibility; `settings` uses the local user trail privacy setting. | +| `merge.enabled` | boolean | Trail import | Runs auto-merge after creating imported trails. | +| `createSummitLogForCompleted` | boolean | Trail import | Creates summit logs for completed imported trails. Defaults to `true`. | +| `categoryMapping` | object | Trail import | Maps plugin-provided `metadata.providerCategory` values to local category IDs or category names. | +| `connectors` | object | Host request/media policy | Concrete settings for configured connectors. | + +The settings UI lets users edit `categoryMapping` per plugin instance for trail +import plugins. Unknown or empty provider categories still fall back to the +host's activity-type mapping. + +Example: + +```json +{ + "hostConfig": { + "categoryMapping": { + "Ride": "Biking", + "Hike": "Hiking" + } + }, + "metadata": { + "providerCategories": { + "Ride": { + "labels": { + "de": "Radfahren", + "en": "Ride" + } + }, + "Hike": { + "labels": { + "de": "Wandern", + "en": "Hike" + } + } + } + } +} +``` + +`metadata.providerCategories` is display-only metadata for provider-owned +category values. The `categoryMapping` keys still use the raw values emitted as +`metadata.providerCategory`. + +Configured connector host config shape: + +```json +{ + "hostConfig": { + "connectors": { + "immich": { + "baseURL": "https://photos.example.com", + "basePath": "/immich", + "allowPrivate": false, + "tls": { + "mode": "system" + }, + "storageOrigins": { + "object-storage": { + "baseURL": "https://storage.example.com", + "basePath": "/assets", + "allowPrivate": false, + "tls": { + "mode": "system" + } + } + } + } + } + } +} +``` + +`tls.mode` supports `system` and `customCA`. Custom CA bundles are trusted only +when the manifest connector declares `supportsCustomTLS`; certificate +verification is not disabled. + +The host defines the semantics of these fields. Plugins only provide defaults +or hints; custom plugin settings belong in `configSchema` and are passed to the +plugin under `options`. + +Plugin errors should use the structured error format: + +```json +{ + "error": { + "code": "rate_limited", + "message": "Provider rate limit exceeded", + "retryAfterSeconds": 3600 + } +} +``` + +Supported status-relevant error codes include: + +```text +auth_failed +invalid_grant +unauthorized +rate_limited +provider_unavailable +temporary_unavailable +``` + +## Host requests + +Plugins cannot perform arbitrary provider I/O. They ask the host to execute +provider requests through the WASM host function `wanderer.http_request`. +Absolute provider URLs are not part of the request ABI. + +The request shape is `HostRequestSpec`: + +```json +{ + "method": "GET", + "target": { + "type": "connector", + "connector": "api", + "path": "/routes", + "query": [ + { "name": "page", "value": "1" } + ] + }, + "auth": "oauth_access_token", + "headers": { + "accept": "application/json" + }, + "expect": { + "contentTypes": ["application/json"], + "maxBytes": 1048576 + } +} +``` + +The host validates: + +- connector identity, scheme, host, effective port, base path, and path scope +- auth context reference and connector-specific auth allowance +- manifest network permissions +- response content type +- response size +- redirect target scope + +The shared Go SDK wraps this host function: + +```go +response, body, err := sdk.HostRequest(sdk.HostRequestSpec{ + Method: "GET", + Target: sdk.RequestTarget{ + Type: "connector", + Connector: "api", + Path: "/routes", + Query: []sdk.QueryParam{{Name: "page", Value: "1"}}, + }, + Expect: sdk.ResponseExpect{ + ContentTypes: []string{"application/json"}, + MaxBytes: 1048576, + }, +}) +``` + +Auth referenced by `HostRequestSpec.auth` is injected by the host. OAuth, +bearer, and API-key contexts are supported for plugin-initiated host requests. +Session auth requires handler-managed injection; if a plugin calls +`wanderer.http_request` with a session auth context, the host rejects the +request instead of silently sending it unauthenticated. + +## Sending trails + +`prepare_trail_send_v1` receives the trail GPX from wanderer and returns a +send plan. The plugin prepares the provider-specific request; the host +executes it. + +Input: + +```json +{ + "instance": { + "id": "abc123", + "pluginId": "hammerhead" + }, + "auth": {}, + "config": {}, + "name": "Lunch Loop", + "trail": { + "format": "gpx", + "contentBase64": "..." + } +} +``` + +`config` contains the saved plugin instance configuration, for example sync +modes, an `after` date, or provider-specific options. `auth` follows the same +rules as sync input. + +Output: + +```json +{ + "request": { + "method": "POST", + "target": { + "type": "connector", + "connector": "api", + "path": "/routes" + }, + "auth": "provider_session", + "body": { + "type": "multipart", + "parts": [ + { + "name": "file", + "source": "trail" + } + ] + }, + "expect": { + "contentTypes": ["application/json"], + "maxBytes": 1048576 + } + } +} +``` + +Supported multipart trail sources: + +```text +trail +trail.gpx +``` + +## Auth + +Auth contexts are declared in the manifest and referenced by name from +`HostRequestSpec.auth`. + +### OAuth2 + +OAuth is declarative. The host runs authorization, token exchange, token +storage, and refresh: + +```json +{ + "auth": { + "contexts": { + "oauth_access_token": { + "type": "oauth2", + "fields": ["clientId", "clientSecret"], + "secretFields": ["clientSecret", "accessToken", "refreshToken"], + "authorizationUrl": "https://provider.example/oauth/authorize", + "tokenUrl": "https://provider.example/oauth/token", + "scopes": ["activity:read_all"], + "scopeSeparator": ",", + "tokenRequestFormat": "json", + "tokenAuth": "client_secret_post", + "refresh": { + "mode": "host", + "grantType": "refresh_token" + } + } + } + } +} +``` + +The plugin may receive the short-lived access token in normal capability input. +It does not receive refresh tokens or client secrets during normal sync. +OAuth token endpoints must be covered by a fixed `public_api` connector in the +manifest. Token exchange does not use user-configured connector origins. + +### Session + +Session auth is for providers that require plugin-mediated login: + +```json +{ + "auth": { + "contexts": { + "provider_session": { + "type": "session", + "fields": ["email", "password"], + "secretFields": ["password"], + "refresh": { + "mode": "plugin", + "function": "refresh_session_v1" + } + } + } + } +} +``` + +The host passes only the declared secret fields to the refresh export. The +returned session token is stored encrypted and injected by the host into future +handler-managed host-executed requests that reference the auth context, such as +`prepare_trail_send.v1` send plans. Plugin-initiated `wanderer.http_request` +calls cannot refresh session auth themselves. + +### API key and bearer + +API key and bearer contexts use a configured secret field: + +```json +{ + "auth": { + "contexts": { + "api_key": { + "type": "api_key", + "placement": "header", + "name": "x-api-key", + "secretField": "apiKey" + } + } + } +} +``` + +## Runtime isolation + +WASM plugins run in a separate worker process for each sync or trail-upload job. +All exports within that job share the same worker session and are called +sequentially. If a plugin calls `wanderer.http_request`, the worker forwards the +request bytes back to the backend; the backend remains the only process that +holds connector policy, decrypted host auth, custom CA bundles, and HTTP +execution logic. + +The worker boundary protects the backend from plugin crashes and hangs and +enforces request/response frame limits and timeouts. It is not an OS-level +sandbox for outbound network access; plugin-controlled provider traffic must +still go through the host request API. + +## Plugin state + +User plugin configuration is stored in `plugin_instances`: + +```text +plugin_instances + user + plugin_id + enabled + auth + config + state + status + last_error + last_sync_at + retry_not_before +``` + +`auth` is encrypted by PocketBase hooks. `config.plugin` stores settings passed +to the plugin, such as an `after` date. `config.host` stores host-owned settings +such as enabled capabilities, privacy handling, merge settings, and category +mapping. `state` stores per-capability provider cursors. It should only contain +values that remain valid across separate sync runs, such as provider sync tokens +or delta cursors. Batch-local cursors such as `page` are discarded before the +instance is saved. + +The host also caches discovered plugin manifests in `installed_plugins`. +Installed plugins and user plugin instances are intentionally separate: +`installed_plugins.config` stores admin defaults, while +`plugin_instances.config` stores per-instance overrides. A user configuration +can exist even if the plugin bundle is not currently installed. + +## Release and installation + +The release workflow builds plugin archives: + +```text +wanderer-plugin-hammerhead.tar.gz +wanderer-plugin-komoot.tar.gz +wanderer-plugin-strava.tar.gz +SHA256SUMS +``` + +Users install a plugin by extracting the archive below `data/plugins`: + +```text +data/plugins/hammerhead/plugin.json +data/plugins/hammerhead/plugin.wasm +``` + +Docker deployments mount the runtime directory into the DB container: + +```yaml +services: + db: + volumes: + - ./data/plugins:/data/plugins +``` diff --git a/docs/src/content/docs/run/environment-configuration.md b/docs/src/content/docs/run/environment-configuration.md index a912cbdc..b7d4a7cd 100644 --- a/docs/src/content/docs/run/environment-configuration.md +++ b/docs/src/content/docs/run/environment-configuration.md @@ -21,18 +21,22 @@ Since we use an unmodified installation of meilisearch you can use all variables | MEILI_NO_ANALYTICS | Disable meilisearch telemetry | true | ## Pocketbase -| Environment Variable | Description | Default | -| ----------------------------- | ----------------------------------------------------------------------------------------------------------------- | --------------------- | -| ORIGIN | Public IP or hostname (including the port) of your wanderer frontend (must be the same as in the frontend config) | http://localhost:3000 | -| POCKETBASE_ENCRYPTION_KEY | Valid 32 character AES key. Used to encrypt secrets | | -| POCKETBASE_CRON_SYNC_SCHEDULE | Valid cron expression. Sets how often trails are synced from 3rd party integrations | 0 2 * * * | -| POCKETBASE_SMTP_ENABLED | Enables or disables SMTP functionality. Accepted values are true or false | false | +| Environment Variable | Description | Default | +| ------------------------------ | ----------------------------------------------------------------------------------------------------------------- | --------------------- | +| ORIGIN | Public IP or hostname (including the port) of your wanderer frontend (must be the same as in the frontend config) | | +| POCKETBASE_ENCRYPTION_KEY | Valid 32 character AES key. Used to encrypt secrets | | +| POCKETBASE_CRON_SYNC_SCHEDULE | Valid cron expression. Sets how often installed plugins are synced | 0 2 ** * | +| POCKETBASE_SMTP_ENABLED | Enables or disables SMTP functionality. Accepted values are true or false | false | | POCKETBASE_SMTP_SENDER_ADDRESS | The email address used as the "From" address in outgoing emails | | -| POCKETBASE_SMTP_SENDER_NAME | The display name shown as the sender in outgoing emails | | -| POCKETBASE_SMTP_HOST | The hostname or IP address of the SMTP server | | -| POCKETBASE_SMTP_PORT | The port number used to connect to the SMTP server | | -| POCKETBASE_SMTP_USERNAME | The username used to authenticate with the SMTP server | | -| POCKETBASE_SMTP_PASSWORD | The password used to authenticate with the SMTP server | | +| POCKETBASE_SMTP_SENDER_NAME | The display name shown as the sender in outgoing emails | | +| POCKETBASE_SMTP_HOST | The hostname or IP address of the SMTP server | | +| POCKETBASE_SMTP_PORT | The port number used to connect to the SMTP server | | +| POCKETBASE_SMTP_USERNAME | The username used to authenticate with the SMTP server | | +| POCKETBASE_SMTP_PASSWORD | The password used to authenticate with the SMTP server | | + +Plugins are not configured through an environment variable. See +[Plugin installation](/run/installation/plugins) for installing runtime plugin +bundles and configuring self-hosted connector trust settings. ## Frontend @@ -78,3 +82,6 @@ services: volumes: - ./certs/ca.pem:/etc/ssl/private-ca/ca.pem:ro ``` + +Provider plugin connector CAs are configured per connector when a plugin +supports custom TLS. They are not read from `NODE_EXTRA_CA_CERTS`. diff --git a/docs/src/content/docs/run/installation/plugins.md b/docs/src/content/docs/run/installation/plugins.md new file mode 100644 index 00000000..4a6ce3d7 --- /dev/null +++ b/docs/src/content/docs/run/installation/plugins.md @@ -0,0 +1,59 @@ +--- +title: Plugin installation +description: How to install and operate provider plugins +--- + +Provider integrations are installed as local WASM plugin bundles. A runtime +plugin bundle is a directory with at least: + +```text +plugin.json +plugin.wasm +``` + +Install each extracted bundle as a direct child directory of `data/plugins`: + +```text +data/plugins/strava/plugin.json +data/plugins/strava/plugin.wasm +``` + +wanderer discovers plugins from `data/plugins//plugin.json`. After +discovery, the plugin appears in the plugin settings page. + +## Installing release bundles + +Official Docker images do not include provider plugins. Download plugin bundle +archives from the GitHub release assets, extract them, and copy the extracted +plugin directory into the mounted `./data/plugins` directory. + +There is no built-in plugin store. Community plugins can be installed the same +way, but only install plugin bundles from sources you trust. + +## Source checkout + +When running from a source checkout, first-party plugin source lives under the +repository's `plugins/` directory. That source directory is not the runtime +install location. + +Build and install the bundled plugins into `data/plugins` with: + +```sh +make plugins-install-local +``` + +Use this after a fresh checkout or after changing first-party plugin code. + +## Runtime and network model + +Plugins run as local WASM modules in a separate worker process. Provider API and +media requests are still executed by the backend through the plugin manifest's +network policy; plugins do not get unrestricted access to your server network. + +Self-hosted provider plugins may expose connector settings such as a base URL, +private-network access, storage redirect origins, or a custom CA bundle. Treat +those settings as administrator trust decisions: only enable private-network +access or custom CAs for plugin bundles and endpoints you trust. + +Provider plugin connector CAs are configured per connector when a plugin +supports custom TLS. They are not read from `NODE_EXTRA_CA_CERTS`. diff --git a/docs/src/content/docs/use/integrations.md b/docs/src/content/docs/use/integrations.md deleted file mode 100644 index d5a0e894..00000000 --- a/docs/src/content/docs/use/integrations.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -title: Integrations -description: How to set up third-party integrations with wanderer. ---- - -You can automatically sync trails to wanderer at regular intervals using the third-party integration feature. Currently, we support three providers: **Strava**, **komoot** and **hammerhead**. - -It is important to note that synchronization only works from the provider to wanderer and not the other way around. Additionally, if a trail has already been synced to wanderer, subsequent changes made in the provider will not be transferred unless the trail is deleted in wanderer. Hammerhead also supports manual uploads from a trail's action menu, which is separate from the nightly sync. - -## Strava Integration - -### Creating an App in Strava - -Before integrating Strava with wanderer, you need to create an API application in Strava. Visit [Strava's API settings](https://www.strava.com/settings/api) and follow the steps to create a new API application. Your setup should resemble the following: - -![Strava API Application](../../../assets/guides/strava_api_app.png) - -### Setting Up the Integration - -1. Copy the **Client ID** and **Client Secret**. -2. Go to the integrations page in wanderer's settings. -3. Click the settings button for the Strava integration. -4. Enter your **Client ID** and **Client Secret**. -5. Choose whether you want to sync routes, activities, or both. - -![wanderer Strava Integration](../../../assets/guides/wanderer_integration_strava.png) - -6. Save the settings and toggle the integration on. -7. You will be redirected to Strava's authorization page. Keep all checkboxes selected and click **Authorize**. -8. You will then be redirected back to wanderer. The Strava integration is now active. - -## komoot Integration - -The komoot integration requires only your komoot username and password: - -1. Open the komoot settings from the integrations menu. -2. Enter your komoot credentials. -3. Save the settings. -4. Toggle the integration on. It will become active immediately. - -Your planned and completed trails will now sync with wanderer. - -## Hammerhead Integration - -The Hammerhead integration requires your Hammerhead account details: - -1. Open the Hammerhead settings from the integrations menu. -2. Enter your Hammerhead email and password. -3. Choose whether you want to sync planned tours, completed tours, or both. -4. (Optional) Set an "ignore trails before" date to avoid syncing duplicates if your Hammerhead account is already connected to other services. -5. Save the settings and toggle the integration on. It will become active immediately after a successful login. - -## Sync Interval - -By default, trails are synced every night at **02:00 AM**. You can modify this schedule using the `POCKETBASE_CRON_SYNC_SCHEDULE` [environment variable](/run/environment-configuration#pocketbase). - -:::note -Please set a reasonable sync interval. Both Strava and komoot impose usage limits on their APIs. Exceeding these limits may result in rejected requests or account suspension. -::: diff --git a/docs/src/content/docs/use/merge-trails.md b/docs/src/content/docs/use/merge-trails.md index 3adc81af..682377b3 100644 --- a/docs/src/content/docs/use/merge-trails.md +++ b/docs/src/content/docs/use/merge-trails.md @@ -28,7 +28,7 @@ Before the merge is executed, wanderer The target suggestion currently considers: - existing summit logs -- external references from integrations +- external references from plugins - content richness such as comments, photos, waypoints and descriptions - how centrally the trail geometry fits within the candidate set - trail age as a deterministic fallback @@ -50,11 +50,11 @@ The maintenance page groups potentially repeated or duplicate trails so that you This page is especially useful after large imports or when you want to consolidate older data. -## Integrations +## Plugins -Integrations can optionally auto-merge imported trails, but only when the backend finds exactly one clear target candidate. This keeps imports conservative and avoids accidentally merging different routes. +Plugins can optionally auto-merge imported trails, but only when the backend finds exactly one clear target candidate. This keeps imports conservative and avoids accidentally merging different routes. -External references from integrations are preserved during merges, so future imports can still recognize already-linked trails correctly. +External references from plugins are preserved during merges, so future imports can still recognize already-linked trails correctly. ## What Happens During a Merge diff --git a/docs/src/content/docs/use/plugins.md b/docs/src/content/docs/use/plugins.md new file mode 100644 index 00000000..5bbbc90a --- /dev/null +++ b/docs/src/content/docs/use/plugins.md @@ -0,0 +1,83 @@ +--- +title: Plugins +description: How to set up third-party provider plugins with wanderer. +--- + +Plugins add optional functionality that is not built into the core application. +Once an administrator has installed a plugin, it appears in the plugin settings +page where users can configure and enable it. + +Plugin installation and self-hosted connector trust settings are administrator +tasks. See [Plugin installation](/run/installation/plugins) for runtime bundle +and connector details. + +## Strava Plugin + +:::caution[A Strava subscription is required] +With Strava's June 2026 Developer Program update, accessing the Strava API as a +"Standard Tier" developer requires an active Strava subscription. Because each +wanderer user connects with their own +Client ID and Client Secret, everyone using this plugin counts as a Standard +Tier developer and is subject to this requirement. + +- **New developers:** subscription required since **June 1, 2026**. +- **Existing developers:** subscription required from **June 30, 2026**. +- Active developers without a subscription are granted **3 months free** to + transition — redeem the offer from your + [Strava API settings dashboard](https://www.strava.com/settings/api). + +Your personal data export and device/wearable integrations are **not** affected; +only programmatic API access is. A free (non-subscriber) Strava account can no +longer use this plugin once the transition period ends. For details see Strava's +[Developer Program update](https://communityhub.strava.com/insider-journal-9/an-update-to-our-developer-program-13428) +and [API FAQ](https://communityhub.strava.com/developers-knowledge-base-14/strava-api-faq-12906). +::: + +### Creating an App in Strava + +Before integrating Strava with wanderer, you need to create an API application in Strava. Visit [Strava's API settings](https://www.strava.com/settings/api) and follow the steps to create a new API application. Your setup should resemble the following: + +![Strava API Application](../../../assets/guides/strava_api_app.png) + +### Setting Up the Plugin + +1. Copy the **Client ID** and **Client Secret**. +2. Go to the plugins page in wanderer's settings. +3. Click the settings button for the Strava plugin. +4. Enter your **Client ID** and **Client Secret**. +5. Choose whether you want to sync routes, activities, or both. + +![wanderer Strava Plugin](../../../assets/guides/wanderer_integration_strava.png) + +6. Click **Save & connect**. +7. You will be redirected to Strava's authorization page. Keep all checkboxes selected and click **Authorize**. +8. You will then be redirected back to wanderer. +9. Toggle the plugin on. It is now active. + +If you later change the Client ID or Client Secret, reconnect the plugin. Other +settings can be saved without repeating the OAuth flow. + +## komoot Plugin + +The komoot plugin requires only your komoot username and password: + +1. Open the komoot settings from the plugins menu. +2. Enter your komoot credentials. +3. Save the settings. +4. Toggle the plugin on. It will become active immediately. + +Your planned and completed trails will now sync with wanderer. + +## Hammerhead Plugin + +The Hammerhead plugin requires your Hammerhead account details: + +1. Open the Hammerhead settings from the plugins menu. +2. Enter your Hammerhead email and password. +3. Choose whether you want to sync planned tours, completed tours, or both. +4. (Optional) Set an "ignore trails before" date to avoid syncing duplicates if your Hammerhead account is already connected to other services. +5. Save the settings and toggle the plugin on. It will become active immediately after a successful login. + +:::note +This page still describes provider setup at a high level. Provider-specific details depend on the installed plugin's manifest and capabilities. +::: diff --git a/plugins/README.md b/plugins/README.md new file mode 100644 index 00000000..724262e3 --- /dev/null +++ b/plugins/README.md @@ -0,0 +1,518 @@ +# wanderer plugins + +This directory contains first-party WASM provider plugins. + +Each plugin is a standalone Go/TinyGo module with: + +- `plugin.json` as the source manifest +- `plugins/schema/plugin.schema.json` for editor completion and manifest help +- `go run github.com/open-wanderer/wanderer/plugins/sdk/cmd/manifestcheck` for normalized dist manifest output +- ignored `dist//plugin.json` and `dist//plugin.wasm` build output for runtime discovery + +Build the dist bundles before running from a fresh checkout: + +```sh +make plugins-build +``` + +The runtime loads plugins from direct child directories of `data/plugins`, for example `data/plugins/strava/plugin.json`. To build and install the bundled plugins into that gitignored local runtime directory, run: + +```sh +make plugins-install-local +``` + +To rebuild a single plugin, install TinyGo and run: + +```sh +cd plugins/strava +make build +``` + +Repeat for `hammerhead` and `komoot` as needed. + +Release builds create plugin bundle archives in CI. The database Docker image does not include plugins; users install release bundles into `data/plugins`. + +Plugin authors can reference the manifest schema from a source manifest: + +```json +{ + "$schema": "../schema/plugin.schema.json", + "manifestVersion": "1.0", + "type": "trails" +} +``` + +## Runtime flows + +This section maps the main runtime flows for debugging and maintenance. The diagrams use readable step names instead of every exact function name, but they point at the backend paths involved when the host invokes plugin capabilities, host requests, OAuth, and trail sending. The code these flows reference lives in the core backend under `db/` (PocketBase handlers, sync manager, host functions), not in this `plugins/` directory. + +### Sync overview + +```mermaid +flowchart TD + subgraph Host[Host backend] + Manual[Manual sync] + Cron[Scheduled sync] + Discover[Refresh plugin cache] + LoadPlugin[Load plugin] + Instance[Plugin instance] + Actor[Find actor] + Auth[Refresh auth] + Config[Resolve config] + Session[Open WASM session] + Dedupe[Skip known trails] + Import[Import trail] + Records[(trails waypoints photos)] + Merge{Auto-merge?} + AutoMerge[Try auto-merge] + Done[Update sync status] + end + + subgraph Plugin[WASM plugin] + ListExport([List provider trails]) + DetailExport([Get trail details]) + Summaries[/Trail summaries/] + TrailImport[/Trail import payload/] + end + + Cron --> Discover + Manual --> Discover + Discover --> LoadPlugin + LoadPlugin --> Instance + Instance --> Actor + Instance --> Auth + Instance --> Config + Actor --> Session + Auth --> Session + Config --> Session + Session --> ListExport + ListExport --> Summaries + Summaries --> Dedupe + Dedupe --> DetailExport + DetailExport --> TrailImport + TrailImport --> Import + Import --> Records + Records --> Merge + Merge -->|yes| AutoMerge + Merge -->|no| Done + AutoMerge --> Done +``` + +### User vs actor IDs + +Plugin sync starts from `plugin_instances.user`, the local wanderer user that owns the plugin instance. The importer keeps that user ID for user-scoped host decisions, but writes imported record ownership through the user's local ActivityPub actor. + +| ID | Used for | +| --- | --- | +| `plugin_instances.user` | Deduplicating provider imports for that user and applying user privacy defaults. | +| `activitypub_actors.id` found by `user` | Writing `trails.author`, `waypoints.author`, and `summit_logs.author`. | + +### Host request boundary + +Plugins cannot open provider connections themselves. They send a request spec to the host; the host resolves the connector, enforces policy, injects allowed auth, executes the HTTP request, and returns a bounded response. Host request failures after request decoding are returned to the plugin as `HostResponse.error` with the `provider_unavailable` code. + +Host request bodies may be JSON, `application/x-www-form-urlencoded`, or +multipart, subject to the manifest upload limits and content-type allow-list. +Here "uploads" means plugin-to-provider request bodies, including login forms, +not only media/file uploads. +Redirect following is enabled by default; plugins can set `followRedirects` to +`false` to receive a 3xx response directly and handle provider login flows +step-by-step. `HostResponse.headerValues` preserves all values for headers such +as `Set-Cookie` and is the only response-header representation exposed to +plugins. + +Plugins can emit host-visible diagnostics through the `wanderer:log` host +function. The payload is a JSON object with a strict `level` (`debug`, `info`, +`warn`, or `error`) and a non-empty `message`. The Go SDK exposes this as +`sdk.LogDebug`, `sdk.LogInfo`, `sdk.LogWarn`, and `sdk.LogError`. +Log messages are written to the host logs. Keep them short and never include +secrets, credentials, cookies, tokens, authorization codes, or full URLs with +query parameters. + +```go +sdk.LogInfo("provider detail fetch took 420ms externalID=abc") +sdk.LogWarn("provider returned an optional photo without a URL") +``` + +Declare host functions used by a capability in `requiredHostFunctions`, for +example `["http_request", "log"]`. + +```mermaid +sequenceDiagram + box WASM plugin + participant Plugin as Plugin code + end + box Plugin worker + participant Worker as http_request host function + end + box Host backend + participant Host as Host HTTP executor + end + box Provider API + participant Provider as Provider API + end + + Plugin->>Worker: HostRequestSpec + Worker->>Host: http_request RPC + Host->>Host: Resolve connector + Host->>Host: Validate manifest policy + alt denied + Host-->>Worker: HostResponse.error provider_unavailable + Worker-->>Plugin: HostResponse.error + else allowed + Host->>Host: Inject auth and apply limits + Host->>Provider: Scoped HTTP request + Provider-->>Host: HTTP response + Host->>Host: Validate response + Host-->>Worker: HostResponse + Worker-->>Plugin: HostResponse + end +``` + +### Plugin discovery + +Used when the backend refreshes the list of plugin bundles installed on disk and caches their manifests in PocketBase. + +```mermaid +flowchart TD + subgraph Host[Host backend] + Refresh[Refresh plugin cache] + Scan[Scan data/plugins] + Load[Load bundle] + Validate[Validate manifest] + Store[(installed_plugins)] + end + + subgraph Disk[Plugin directory] + Bundle[(Plugin bundle)] + end + + Refresh --> Scan + Scan --> Bundle + Bundle --> Load + Load --> Validate + Validate --> Store +``` + +Manifest `configSchema` defines plugin-owned settings that are passed to plugin exports. Host-owned settings are documented by the host and are not passed to plugins. A manifest may only suggest host defaults via `hostConfig`; the current host fields are: + +| Field | Purpose | +| --- | --- | +| `planned` | Enables `list_routes.v1` sync. | +| `completed` | Enables `list_activities.v1` sync. | +| `privacy` | Chooses provider visibility or local user privacy settings. | +| `merge.available` | Controls whether the UI offers auto-merge for this plugin. Defaults to `true`. | +| `merge.enabled` | Runs auto-merge after trail import. | +| `createSummitLogForCompleted` | Creates summit logs for completed imports. | +| `categoryMapping` | Maps `metadata.providerCategory` to local category IDs or names. | +| `connectors` | Provides host-owned base URL, TLS, private-network, and storage redirect settings for configured connectors. | + +The settings UI lets users edit `categoryMapping` per plugin instance for trail import plugins. +Plugins may describe provider-owned category values for the settings UI with +`metadata.providerCategories`. This is display-only metadata; `categoryMapping` +keys still use the raw provider category values emitted as +`metadata.providerCategory`. + +Trail import plugins should keep provider-specific category values in `metadata.providerCategory`. They may also provide provider summary metrics in `metadata.distance`, `metadata.elevationGain`, `metadata.elevationLoss`, and `metadata.duration`; the host uses those positive values instead of GPX-derived summary metrics and falls back to GPX when a value is missing. Plugins may provide an intended start coordinate in `metadata.providerStart` as `{ "lat": 47.123, "lon": 8.456 }`; the host uses it only when it is close enough to the imported GPX track to be plausible. + +Photo descriptors may be returned either on the imported trail or on individual waypoints. The host downloads those media files and stores them on the corresponding PocketBase records. + +### List plugins + +Used by the settings UI to show locally available plugins, their metadata, icons, capabilities, and current availability status. + +Plugins may provide optional UI metadata through `manifest.metadata`: + +| Field | Purpose | +| --- | --- | +| `displayName` | Human-facing provider name shown in the UI. Falls back to manifest `name`. | +| `displayNames` | Optional localized provider names keyed by locale, e.g. `de` or `de-CH`. Falls back to `displayName` and `name`. | +| `descriptions` | Optional localized plugin descriptions keyed by locale. Falls back to manifest `description`. | +| `providerCategories` | Optional metadata for provider-owned category values. The settings UI uses `providerCategories.*.labels` for localized category mapping labels. | +| `icons.light` | Light-theme icon path inside the plugin bundle. | +| `icons.dark` | Dark-theme icon path inside the plugin bundle. | + +Config schema fields may also localize plugin-owned UI text. The simple +`label` and `description` strings remain valid fallbacks; optional `labels` +and `descriptions` maps override them for matching locales. Select options can +use `label` and `labels` in the same way. Fields with `"required": true` are +validated in the settings modal. Fields with `"hidden": true` are not rendered +in the settings modal, but their saved values are preserved and still passed to +plugin exports. + +Locale lookup uses the exact locale first, then the language, then `en`, then +the simple fallback string. + +```json +{ + "description": "Imports public hike suggestions from Schweizer Wanderwege.", + "metadata": { + "displayName": "Schweizer Wanderwege", + "displayNames": { + "de": "Schweizer Wanderwege", + "en": "Swiss Hiking Trails" + }, + "descriptions": { + "de": "Importiert öffentliche Wandervorschläge der Schweizer Wanderwege.", + "en": "Imports public hike suggestions from Swiss Hiking Trails." + } + }, + "configSchema": [ + { + "key": "maxPhotos", + "type": "text", + "label": "Max photos", + "labels": { + "de": "Max. Fotos", + "en": "Max photos" + }, + "description": "Maximum photos to import per hike. Use 0 for none or -1 for all.", + "descriptions": { + "de": "Maximale Anzahl Fotos pro Wanderung. 0 importiert keine Fotos, -1 alle.", + "en": "Maximum photos to import per hike. Use 0 for none or -1 for all." + }, + "required": true + } + ] +} +``` + +```mermaid +flowchart TD + subgraph UI[Settings UI] + Request[GET /plugins] + Response[/PluginInfo list/] + end + + subgraph Host[Host backend] + Handler[PluginSystemPluginsList] + Refresh[Refresh plugin cache] + Load[Load installed plugins] + Icons[Attach icons] + end + + Request --> Handler + Handler --> Refresh + Refresh --> Load + Load --> Icons + Icons --> Response +``` + +### Save plugin instance + +Used whenever a user creates or updates their personal plugin configuration. This path is where auth values are encrypted and default status is assigned. + +```mermaid +flowchart TD + subgraph UI[Settings UI] + Save[Save plugin instance] + end + + subgraph Host[Host backend] + Hook[create/update hook] + Manifest[Load manifest] + Status[Set status] + Secrets[Find secret fields] + Encrypt[Encrypt auth] + Instance[(plugin_instances)] + end + + Save --> Hook + Hook --> Manifest + Manifest --> Status + Manifest --> Secrets + Secrets --> Encrypt + Status --> Instance + Encrypt --> Instance +``` + +### OAuth connection + +Used when the UI connects a plugin instance to an OAuth provider. Start and callback are separate HTTP endpoints, but together they form one browser redirect flow. The host exchanges the authorization code and stores tokens encrypted on the plugin instance. + +```mermaid +sequenceDiagram + box Settings UI + participant UI as Settings UI + end + box Host backend + participant Start as OAuth start handler + participant DB as plugin_instances + participant Callback as OAuth callback handler + end + box OAuth provider + participant Provider as OAuth provider + end + + UI->>Start: Start OAuth + Start->>Start: Load plugin and OAuth context + Start->>Start: Decrypt auth and validate redirect + Start->>DB: Store state and PKCE verifier + Start-->>UI: Authorization URL + UI->>Provider: Browser redirect + Provider-->>Callback: Redirect with code + Callback->>Callback: Load plugin + Callback->>DB: Load encrypted auth and OAuth state + Callback->>Provider: Exchange code at token endpoint + Provider-->>Callback: Access and refresh tokens + Callback->>DB: Store tokens encrypted + Callback->>DB: Clear transient OAuth fields +``` + +### Cron sync + +Used by the scheduled background sync. It refreshes installed plugin metadata and syncs enabled plugin instances. + +```mermaid +flowchart TD + subgraph Host[Host backend] + Cron[Scheduled sync] + Refresh[Refresh plugin cache] + Load[Load plugins] + Instances[Enabled instances] + Sync[Sync instance] + Next[Next instance] + end + + Cron --> Refresh + Refresh --> Load + Load --> Instances + Instances --> Sync + Sync --> Next + Next --> Instances +``` + +### Sync retry handling + +Used when a previous sync failed with a retry delay. Cron skips the instance until `retry_not_before` is reached. A successful sync clears `retry_not_before`. + +```mermaid +flowchart TD + subgraph Host[Host backend] + Instance[Plugin instance] + Retry{Retry delayed?} + Skip[Skip for now] + Sync[Sync instance] + Error{Needs retry?} + Store[Store retry_not_before] + Clear[Clear retry_not_before] + end + + Instance --> Retry + Retry -->|yes| Skip + Retry -->|no| Sync + Sync --> Error + Error -->|yes| Store + Error -->|no| Clear +``` + +### Sync one instance + +Used to prepare one user/plugin instance for sync: actor lookup, runtime selection, auth decryption, OAuth refresh, and capability dispatch. + +```mermaid +flowchart TD + subgraph Host[Host backend] + Instance[Plugin instance] + Actor[Find actor] + Runtime[Select runtime] + Auth[Decrypt auth] + Refresh[Refresh OAuth] + Session[Open WASM session] + Sync[Sync capabilities] + Close[Close session] + end + + subgraph Plugin[WASM plugin] + Worker([Worker session]) + end + + Instance --> Actor + Instance --> Runtime + Instance --> Auth + Auth --> Refresh + Actor --> Session + Runtime --> Session + Refresh --> Session + Session --> Worker + Worker --> Sync + Sync --> Close +``` + +### Capabilities + +Every plugin capability is declared as a manifest capability. The runtime flow depends on what the capability does: importing trails uses a list/detail pair, while sending a trail asks the plugin for a provider request plan. + +#### Capability: Trail import + +Used for one import capability pair such as `list_routes.v1` with `get_route_detail.v1`, or `list_activities.v1` with `get_activity_detail.v1`. This is where provider summaries become imported trails. + +```mermaid +flowchart TD + subgraph Host[Host backend] + Start[Trail import sync] + ListCall[Ask plugin for trails] + Dedupe[Skip known trails] + Import[Import trail] + end + + subgraph Plugin[WASM plugin] + ListExport([List provider trails]) + Summaries[/Trail summaries/] + DetailExport([Get trail details]) + TrailImport[/Trail import payload/] + end + + Start --> ListCall + ListCall --> ListExport + ListExport --> Summaries + Summaries --> Dedupe + Dedupe --> DetailExport + DetailExport --> TrailImport + TrailImport --> Import +``` + +#### Capability: Send trail + +Used when a user sends an existing wanderer trail to an external provider. + +```mermaid +flowchart TD + subgraph UI[Trail UI] + Send[Send trail] + end + + subgraph Host[Host backend] + Handler[Send trail handler] + Capability[Load send capability] + Access[Check access] + GPX[Read GPX] + Session[Open WASM session] + Validate[Validate send plan] + Auth[Inject auth] + Execute[Execute request] + Close[Close session] + end + + subgraph Plugin[WASM plugin] + Prepare([Prepare send]) + TrailSendPlan[/TrailSendPlan/] + end + + subgraph Provider[Provider API] + ProviderSend[Send trail] + end + + Send --> Handler + Handler --> Capability + Capability --> Access + Access --> GPX + GPX --> Session + Session --> Prepare + Prepare --> TrailSendPlan + TrailSendPlan --> Validate + Validate --> Auth + Auth --> Execute + Execute --> ProviderSend + ProviderSend --> Close +``` diff --git a/plugins/hammerhead/Makefile b/plugins/hammerhead/Makefile new file mode 100644 index 00000000..d45469f4 --- /dev/null +++ b/plugins/hammerhead/Makefile @@ -0,0 +1,16 @@ +PLUGIN_ID := hammerhead +DIST_DIR := dist/$(PLUGIN_ID) + +.PHONY: build manifest clean + +build: manifest + tinygo build -target=wasi -scheduler=none -no-debug -o $(DIST_DIR)/plugin.wasm . + +manifest: + mkdir -p $(DIST_DIR) + go run github.com/open-wanderer/wanderer/plugins/sdk/cmd/manifestcheck > $(DIST_DIR)/plugin.json + cp assets/icon.svg $(DIST_DIR)/icon.svg + cp assets/icon_dark.svg $(DIST_DIR)/icon_dark.svg + +clean: + rm -rf dist diff --git a/plugins/hammerhead/README.md b/plugins/hammerhead/README.md new file mode 100644 index 00000000..fc01d7d8 --- /dev/null +++ b/plugins/hammerhead/README.md @@ -0,0 +1,29 @@ +# wanderer Hammerhead WASM plugin + +WASM/Extism version of the Hammerhead provider for wanderer. + +This plugin exports the wanderer plugin-system ABI: + +- `list_routes_v1` +- `list_activities_v1` +- `refresh_session_v1` +- `prepare_trail_send_v1` + +## Build + +Install TinyGo, then run: + +```sh +make build +``` + +The plugin bundle is written to `dist/hammerhead/`. Copy it below +`data/plugins` or run `make plugins-install-local` from the repository root to +install all bundled plugins locally. + +## Development + +```sh +GOCACHE=/tmp/wanderer-go-cache go test ./... +make manifest +``` diff --git a/plugins/hammerhead/assets/icon.svg b/plugins/hammerhead/assets/icon.svg new file mode 100644 index 00000000..3f00afb0 --- /dev/null +++ b/plugins/hammerhead/assets/icon.svg @@ -0,0 +1,15 @@ + + + + + + + + + \ No newline at end of file diff --git a/plugins/hammerhead/assets/icon_dark.svg b/plugins/hammerhead/assets/icon_dark.svg new file mode 100644 index 00000000..59d98b0c --- /dev/null +++ b/plugins/hammerhead/assets/icon_dark.svg @@ -0,0 +1,15 @@ + + + + + + + + + \ No newline at end of file diff --git a/plugins/hammerhead/go.mod b/plugins/hammerhead/go.mod new file mode 100644 index 00000000..ef94ec4e --- /dev/null +++ b/plugins/hammerhead/go.mod @@ -0,0 +1,9 @@ +module github.com/open-wanderer/wanderer/plugins/hammerhead + +go 1.25.0 + +require github.com/extism/go-pdk v1.1.3 + +require github.com/open-wanderer/wanderer/plugins/sdk v0.0.0 + +replace github.com/open-wanderer/wanderer/plugins/sdk => ../sdk diff --git a/plugins/hammerhead/go.sum b/plugins/hammerhead/go.sum new file mode 100644 index 00000000..c15d3829 --- /dev/null +++ b/plugins/hammerhead/go.sum @@ -0,0 +1,2 @@ +github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ= +github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= diff --git a/plugins/hammerhead/gpx.go b/plugins/hammerhead/gpx.go new file mode 100644 index 00000000..6760bf63 --- /dev/null +++ b/plugins/hammerhead/gpx.go @@ -0,0 +1,66 @@ +package main + +import ( + "math" + "time" + + sdkgpx "github.com/open-wanderer/wanderer/plugins/sdk/gpx" + "github.com/open-wanderer/wanderer/plugins/sdk/polyline" +) + +func activityGPX(activity *activity) ([]byte, error) { + points := make([]sdkgpx.Point, 0, len(activity.RecordData.Timestamp)) + const zeroEps = 1e-4 + for i, timestamp := range activity.RecordData.Timestamp { + if i >= len(activity.RecordData.Lat) || i >= len(activity.RecordData.Lng) { + continue + } + lat := activity.RecordData.Lat[i] + lng := activity.RecordData.Lng[i] + if math.Abs(lat) < zeroEps && math.Abs(lng) < zeroEps { + continue + } + elevation := 0.0 + if i < len(activity.RecordData.Elevation) { + elevation = activity.RecordData.Elevation[i] / 1000.0 + } + pointTime := time.Unix(int64(timestamp), 0).UTC() + points = append(points, sdkgpx.Point{ + Lat: lat, + Lon: lng, + Elevation: &elevation, + Time: &pointTime, + }) + } + return sdkgpx.Track("wanderer Hammerhead plugin", activity.ActivityData.Name, points) +} + +func tourGPX(tour *tour) ([]byte, error) { + coords, err := polyline.Decode(tour.RoutePolyline, 1e5) + if err != nil { + return nil, err + } + polyline.NormalizeCoordinateScale(coords) + elevations, _ := polyline.DecodeValues(tour.Elevation.Polyline, 100000) + points := make([]sdkgpx.Point, 0, len(coords)) + swap := polyline.ShouldSwapCoordinates(coords) + for i, coord := range coords { + lat := coord[0] + lon := coord[1] + if swap { + lat, lon = coord[1], coord[0] + } + var elevation *float64 + if len(elevations) == len(coords) { + elevation = &elevations[i] + } else if len(elevations) > 0 { + elevation = &elevations[polyline.ProportionalIndex(i, len(coords), len(elevations))] + } + points = append(points, sdkgpx.Point{ + Lat: lat, + Lon: lon, + Elevation: elevation, + }) + } + return sdkgpx.Track("wanderer Hammerhead plugin", tour.Name, points) +} diff --git a/plugins/hammerhead/hammerhead.go b/plugins/hammerhead/hammerhead.go new file mode 100644 index 00000000..69737d31 --- /dev/null +++ b/plugins/hammerhead/hammerhead.go @@ -0,0 +1,163 @@ +//go:build tinygo + +package main + +import ( + "encoding/json" + "fmt" + "strconv" + + "github.com/extism/go-pdk" + "github.com/open-wanderer/wanderer/plugins/sdk" +) + +type hammerheadClient struct { + userID string + token string +} + +func login(email string, password string) (string, error) { + spec := sdk.HostRequestSpec{ + Method: "POST", + Target: sdk.RequestTarget{ + Type: "connector", + Connector: "api", + Path: "/v1/auth/token", + }, + Headers: map[string]string{ + "Accept": "application/json", + }, + Body: &sdk.HostRequestBody{ + Type: sdk.HostRequestBodyTypeJSON, + JSON: map[string]string{ + "grant_type": "password", + "username": email, + "password": password, + }, + }, + Expect: sdk.ResponseExpect{ + ContentTypes: []string{"application/json"}, + MaxBytes: 1048576, + }, + } + response, body, err := sdk.HostRequest(spec) + if err != nil { + return "", err + } + if response.Status != 200 { + return "", fmt.Errorf("hammerhead login failed (%d): %s", response.Status, string(body)) + } + + var parsed loginResponse + if err := json.Unmarshal(body, &parsed); err != nil { + return "", err + } + if parsed.Token == "" { + return "", fmt.Errorf("hammerhead login returned no access token") + } + + pdk.SetVar("hammerhead_access_token", []byte(parsed.Token)) + return parsed.Token, nil +} + +func loginClient(auth map[string]any) (hammerheadClient, error) { + email := sdk.StringField(auth, "email") + password := sdk.StringField(auth, "password") + if email == "" || password == "" { + return hammerheadClient{}, fmt.Errorf("email and password are required") + } + token, err := login(email, password) + if err != nil { + return hammerheadClient{}, err + } + userID, err := userIDFromJWT(token) + if err != nil { + return hammerheadClient{}, err + } + return hammerheadClient{userID: userID, token: token}, nil +} + +func (c hammerheadClient) get(path string, query []sdk.QueryParam, out any) error { + response, body, err := sdk.HostRequest(sdk.HostRequestSpec{ + Method: "GET", + Target: sdk.RequestTarget{ + Type: "connector", + Connector: "api", + Path: "/v1/users/" + c.userID + path, + Query: query, + }, + Headers: map[string]string{ + sdk.AuthHeaderAuthorization: sdk.AuthSchemeBearer + " " + c.token, + "Accept": "application/json", + }, + Expect: sdk.ResponseExpect{ + ContentTypes: []string{"application/json"}, + MaxBytes: 1048576, + }, + }) + if err != nil { + return err + } + if response.Status != 200 { + return fmt.Errorf("hammerhead request failed (%d): %s", response.Status, string(body)) + } + return json.Unmarshal(body, out) +} + +func (c hammerheadClient) activities(page int, perPage int) ([]activityResponse, int, error) { + var data activitiesResponse + err := c.get("/activities", hammerheadListQuery(page, perPage), &data) + return data.Data, data.TotalPages, err +} + +func (c hammerheadClient) tours(page int, perPage int) ([]tourResponse, int, error) { + var data toursResponse + err := c.get("/routes", hammerheadListQuery(page, perPage), &data) + return data.Data, data.TotalPages, err +} + +func (c hammerheadClient) activity(id string) (*activity, error) { + var data activity + err := c.get("/activities/"+id+"/details", nil, &data) + return &data, err +} + +func (c hammerheadClient) tour(id string) (*tour, error) { + var data tour + err := c.get("/routes/"+id, nil, &data) + return &data, err +} + +func hammerheadListQuery(page int, perPage int) []sdk.QueryParam { + return []sdk.QueryParam{ + {Name: "page", Value: strconv.Itoa(page)}, + {Name: "perPage", Value: strconv.Itoa(perPage)}, + {Name: "orderBy", Value: "NEWEST"}, + {Name: "ascending", Value: "true"}, + } +} + +func userIDForUpload(auth map[string]any) (string, error) { + token := string(pdk.GetVar("hammerhead_access_token")) + if token == "" { + email := sdk.StringField(auth, "email") + password := sdk.StringField(auth, "password") + if email == "" || password == "" { + return "", fmt.Errorf("email and password are required") + } + var err error + token, err = login(email, password) + if err != nil { + return "", err + } + } + return userIDFromJWT(token) +} + +func userIDFromSession() (string, error) { + token := string(pdk.GetVar("hammerhead_access_token")) + if token == "" { + return "", fmt.Errorf("session token is not available") + } + return userIDFromJWT(token) +} diff --git a/plugins/hammerhead/hammerhead_test.go b/plugins/hammerhead/hammerhead_test.go new file mode 100644 index 00000000..32d91a64 --- /dev/null +++ b/plugins/hammerhead/hammerhead_test.go @@ -0,0 +1,97 @@ +package main + +import ( + "strings" + "testing" + + sdkgpx "github.com/open-wanderer/wanderer/plugins/sdk/gpx" + "github.com/open-wanderer/wanderer/plugins/sdk/polyline" +) + +func TestUserIDFromJWT(t *testing.T) { + token := "header.eyJzdWIiOiJ1c2VyLTEyMyJ9.signature" + got, err := userIDFromJWT(token) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "user-123" { + t.Fatalf("got %q", got) + } +} + +func TestUserIDFromJWTRejectsInvalidToken(t *testing.T) { + if _, err := userIDFromJWT("not-a-jwt"); err == nil { + t.Fatal("expected error") + } +} + +func TestDecodePolyline(t *testing.T) { + points, err := polyline.Decode("_p~iF~ps|U_ulLnnqC_mqNvxq`@", 1e5) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(points) != 3 { + t.Fatalf("expected 3 points, got %d", len(points)) + } + if points[0][0] != 38.5 || points[0][1] != -120.2 { + t.Fatalf("unexpected first point: %#v", points[0]) + } +} + +func TestDecodePolylineNormalizesOutOfRangeScale(t *testing.T) { + points, err := polyline.Decode("_p~iF~ps|U", 1e5) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + points[0][0] *= 10 + points[0][1] *= 10 + polyline.NormalizeCoordinateScale(points) + if points[0][0] != 38.5 || points[0][1] != -120.2 { + t.Fatalf("expected normalized point, got %#v", points[0]) + } +} + +func TestShouldSwapCoordinates(t *testing.T) { + coords := [][2]float64{{120.2, 38.5}, {121.0, 39.0}} + if !polyline.ShouldSwapCoordinates(coords) { + t.Fatal("expected coordinates to be detected as swapped") + } +} + +func TestProportionalIndex(t *testing.T) { + if got := polyline.ProportionalIndex(2, 5, 3); got != 1 { + t.Fatalf("got %d, want 1", got) + } + if got := polyline.ProportionalIndex(4, 5, 3); got != 2 { + t.Fatalf("got %d, want 2", got) + } +} + +func TestGPXBytesEscapesTrackName(t *testing.T) { + data, err := sdkgpx.Track("wanderer Hammerhead plugin", "A & B", []sdkgpx.Point{{Lat: 46.1, Lon: 8.2}}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + gpx := string(data) + if !strings.Contains(gpx, "A & B") { + t.Fatalf("expected escaped name, got %s", gpx) + } + if !strings.Contains(gpx, `lat="46.10000000" lon="8.20000000"`) { + t.Fatalf("expected track point, got %s", gpx) + } +} + +func TestTrailGPXFilename(t *testing.T) { + tests := map[string]string{ + "": "trail.gpx", + "My Route": "My Route.gpx", + "My Route.gpx": "My Route.gpx", + "../Bad/Route\\Name ": "Bad-Route-Name.gpx", + } + + for input, want := range tests { + if got := trailGPXFilename(input); got != want { + t.Fatalf("trailGPXFilename(%q) = %q, want %q", input, got, want) + } + } +} diff --git a/plugins/hammerhead/jwt.go b/plugins/hammerhead/jwt.go new file mode 100644 index 00000000..84b1623b --- /dev/null +++ b/plugins/hammerhead/jwt.go @@ -0,0 +1,28 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "strings" +) + +func userIDFromJWT(token string) (string, error) { + parts := strings.Split(token, ".") + if len(parts) < 2 { + return "", fmt.Errorf("token is not a JWT") + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return "", err + } + var claims map[string]any + if err := json.Unmarshal(payload, &claims); err != nil { + return "", err + } + sub, _ := claims["sub"].(string) + if sub == "" { + return "", fmt.Errorf("token has no sub claim") + } + return sub, nil +} diff --git a/plugins/hammerhead/main.go b/plugins/hammerhead/main.go new file mode 100644 index 00000000..a4d8c85a --- /dev/null +++ b/plugins/hammerhead/main.go @@ -0,0 +1,350 @@ +//go:build tinygo + +package main + +import ( + "encoding/base64" + "encoding/json" + "fmt" + + "github.com/extism/go-pdk" + "github.com/open-wanderer/wanderer/plugins/sdk" +) + +func main() {} + +//export list_routes_v1 +func listRoutesV1() int32 { + var input listInput + if err := pdk.InputJSON(&input); err != nil { + return fail("invalid_request", "invalid list_routes input: "+err.Error()) + } + client, err := loginClient(input.Auth) + if err != nil { + return fail("auth_failed", err.Error()) + } + output, err := listRoutes(client, input) + if err != nil { + return fail("provider_unavailable", err.Error()) + } + if err := pdk.OutputJSON(output); err != nil { + return fail("internal_error", err.Error()) + } + return 0 +} + +//export list_activities_v1 +func listActivitiesV1() int32 { + var input listInput + if err := pdk.InputJSON(&input); err != nil { + return fail("invalid_request", "invalid list_activities input: "+err.Error()) + } + client, err := loginClient(input.Auth) + if err != nil { + return fail("auth_failed", err.Error()) + } + output, err := listActivities(client, input) + if err != nil { + return fail("provider_unavailable", err.Error()) + } + if err := pdk.OutputJSON(output); err != nil { + return fail("internal_error", err.Error()) + } + return 0 +} + +//export get_route_detail_v1 +func getRouteDetailV1() int32 { + return getTrailDetail("planned") +} + +//export get_activity_detail_v1 +func getActivityDetailV1() int32 { + return getTrailDetail("completed") +} + +//export refresh_session_v1 +func refreshSessionV1() int32 { + var input refreshSessionInput + if err := pdk.InputJSON(&input); err != nil { + return fail("invalid_request", "invalid refresh_session input: "+err.Error()) + } + + email := sdk.StringField(input.Auth, "email") + password := sdk.StringField(input.Auth, "password") + if email == "" || password == "" { + return fail("auth_failed", "email and password are required") + } + + token, err := login(email, password) + if err != nil { + return fail("auth_failed", err.Error()) + } + + if err := pdk.OutputJSON(refreshSessionOutput{ + Token: token, + Scheme: sdk.AuthSchemeBearer, + }); err != nil { + return fail("internal_error", err.Error()) + } + return 0 +} + +func getTrailDetail(kind string) int32 { + var input detailInput + if err := pdk.InputJSON(&input); err != nil { + return fail("invalid_request", "invalid detail input: "+err.Error()) + } + client, err := loginClient(input.Auth) + if err != nil { + return fail("auth_failed", err.Error()) + } + var item trailImport + switch kind { + case "planned": + detail, err := client.tour(input.Summary.Source.ExternalID) + if err != nil { + return fail("provider_unavailable", err.Error()) + } + item, err = tourImport(detail) + if err != nil { + return fail("provider_unavailable", err.Error()) + } + case "completed": + detail, err := client.activity(input.Summary.Source.ExternalID) + if err != nil { + return fail("provider_unavailable", err.Error()) + } + item, err = activityImport(detail) + if err != nil { + return fail("provider_unavailable", err.Error()) + } + default: + return fail("invalid_request", "unsupported detail kind") + } + if err := pdk.OutputJSON(detailOutput{Item: item}); err != nil { + return fail("internal_error", err.Error()) + } + return 0 +} + +//export prepare_trail_send_v1 +func prepareTrailSendV1() int32 { + var input trailSendInput + if err := pdk.InputJSON(&input); err != nil { + return fail("invalid_request", "invalid prepare_trail_send input: "+err.Error()) + } + if input.Trail.Format != "gpx" || input.Trail.ContentBase64 == "" { + return fail("invalid_request", "a GPX trail is required") + } + + userID, err := userIDForUpload(input.Auth) + if err != nil { + return fail("auth_failed", err.Error()) + } + + plan := trailSendPlan{ + Request: sdk.HostRequestSpec{ + Method: "POST", + Target: sdk.RequestTarget{ + Type: "connector", + Connector: "api", + Path: fmt.Sprintf("/v1/users/%s/routes/import/file", userID), + }, + Auth: "provider_session", + Body: &sdk.HostRequestBody{ + Type: sdk.HostRequestBodyTypeMultipart, + Parts: []sdk.MultipartPart{ + { + Name: "file", + Source: sdk.MultipartSourceTrail, + Filename: trailGPXFilename(input.Name), + ContentType: "application/gpx+xml", + }, + }, + }, + Expect: sdk.ResponseExpect{ + ContentTypes: []string{"application/json"}, + MaxBytes: 1048576, + }, + }, + } + if err := pdk.OutputJSON(plan); err != nil { + return fail("internal_error", err.Error()) + } + return 0 +} + +func fail(code string, message string) int32 { + data, err := json.Marshal(pluginError{Code: code, Message: message}) + if err != nil { + pdk.SetErrorString(message) + return 1 + } + pdk.SetErrorString(string(data)) + return 1 +} + +func listRoutes(client hammerheadClient, input listInput) (listOutput, error) { + page := sdk.IntState(input.State, "page", 1) + if page <= 0 { + page = 1 + } + limit := sdk.SyncLimit(input) + rows, totalPages, err := client.tours(page, limit) + if err != nil { + return listOutput{}, err + } + + after := sdk.StringField(input.Options, "after") + items := make([]trailSummary, 0, min(limit, len(rows))) + for _, row := range rows { + if after != "" && row.CreatedAt < after { + return listOutput{Items: items}, nil + } + items = append(items, trailSummary{ + Source: trailImportSource{Provider: "hammerhead", ExternalID: row.ID}, + Kind: "planned", + }) + if len(items) >= limit { + break + } + } + + nextPage := page + 1 + hasMore := nextPage <= totalPages + return listOutput{ + Items: items, + State: sdk.NextPageState(nextPage, hasMore), + HasMore: hasMore, + }, nil +} + +func listActivities(client hammerheadClient, input listInput) (listOutput, error) { + page := sdk.IntState(input.State, "page", 1) + if page <= 0 { + page = 1 + } + limit := sdk.SyncLimit(input) + rows, totalPages, err := client.activities(page, limit) + if err != nil { + return listOutput{}, err + } + + after := sdk.StringField(input.Options, "after") + items := make([]trailSummary, 0, min(limit, len(rows))) + for _, row := range rows { + if after != "" && row.CreatedAt < after { + return listOutput{Items: items}, nil + } + items = append(items, trailSummary{ + Source: trailImportSource{Provider: "hammerhead", ExternalID: row.ID}, + Kind: "completed", + }) + if len(items) >= limit { + break + } + } + + nextPage := page + 1 + hasMore := nextPage <= totalPages + return listOutput{ + Items: items, + State: sdk.NextPageState(nextPage, hasMore), + HasMore: hasMore, + }, nil +} + +func tourImport(tour *tour) (trailImport, error) { + gpxData, err := tourGPX(tour) + if err != nil { + return trailImport{}, err + } + privacy := privacyFromPublic(tour.IsPublic) + return trailImport{ + Source: trailImportSource{ + Provider: "hammerhead", + ExternalID: tour.ID, + }, + Kind: "planned", + Name: tour.Name, + StartedAt: tour.CreatedAt, + ActivityType: "biking", + Privacy: &privacy, + Track: track{ + Format: "gpx", + ContentBase64: base64.StdEncoding.EncodeToString(gpxData), + }, + Metadata: map[string]any{ + "distance": tour.Distance, + "elevationGain": tour.Elevation.Gain, + "elevationLoss": tour.Elevation.Loss, + "providerCategory": "biking", + }, + }, nil +} + +func activityImport(activity *activity) (trailImport, error) { + gpxData, err := activityGPX(activity) + if err != nil { + return trailImport{}, err + } + privacy := "private" + return trailImport{ + Source: trailImportSource{ + Provider: "hammerhead", + ExternalID: activity.ActivityData.ID, + }, + Kind: "completed", + Name: activity.ActivityData.Name, + StartedAt: activity.ActivityData.CreatedAt, + ActivityType: "biking", + Privacy: &privacy, + Track: track{ + Format: "gpx", + ContentBase64: base64.StdEncoding.EncodeToString(gpxData), + }, + Metadata: map[string]any{ + "distance": infoValueOrZero(activity, "TYPE_DISTANCE_ID"), + "elevationGain": infoValueOrZero(activity, "TYPE_ELEVATION_GAIN_ID"), + "elevationLoss": infoValueOrZero(activity, "TYPE_ELEVATION_LOSS_ID"), + "duration": activityDurationSeconds(activity), + "providerCategory": "biking", + }, + }, nil +} + +func privacyFromPublic(public bool) string { + if public { + return "public" + } + return "private" +} + +func activityInfoValue(activity *activity, key string) (float64, bool) { + for _, info := range activity.ActivityData.ActivityInfo { + if info.Key == key { + return info.Value.Value, true + } + } + return 0, false +} + +func infoValueOrZero(activity *activity, key string) float64 { + value, _ := activityInfoValue(activity, key) + return value +} + +func activityDurationSeconds(activity *activity) float64 { + var total int + for _, lap := range activity.ActivityData.Laps { + total += lap.ActiveTime + } + if total > 0 { + return float64(total) / 1000 + } + if activity.ActivityData.Duration.ElapsedTime > 0 { + return float64(activity.ActivityData.Duration.ElapsedTime) / 1000 + } + return 0 +} diff --git a/plugins/hammerhead/plugin.json b/plugins/hammerhead/plugin.json new file mode 100644 index 00000000..da0a5fef --- /dev/null +++ b/plugins/hammerhead/plugin.json @@ -0,0 +1,131 @@ +{ + "manifestVersion": "1.0", + "id": "hammerhead", + "type": "trails", + "name": "Hammerhead", + "description": "Imports Hammerhead routes and activities, and can send wanderer routes to Hammerhead.", + "version": "0.1.0", + "runtime": { + "type": "wasm", + "entrypoint": "plugin.wasm" + }, + "capabilities": [ + { + "name": "list_routes", + "version": "v1", + "export": "list_routes_v1" + }, + { + "name": "get_route_detail", + "version": "v1", + "export": "get_route_detail_v1" + }, + { + "name": "list_activities", + "version": "v1", + "export": "list_activities_v1" + }, + { + "name": "get_activity_detail", + "version": "v1", + "export": "get_activity_detail_v1" + }, + { + "name": "prepare_trail_send", + "version": "v1", + "export": "prepare_trail_send_v1" + } + ], + "auth": { + "contexts": { + "provider_session": { + "type": "session", + "fields": [ + "email", + "password" + ], + "secretFields": [ + "password" + ], + "refresh": { + "mode": "plugin", + "function": "refresh_session_v1" + } + } + } + }, + "permissions": { + "network": { + "connectors": [ + { + "name": "api", + "type": "public_api", + "fixedBaseURL": "https://dashboard.hammerhead.io", + "allowedPathPrefixes": [ + "/v1" + ], + "auth": [ + "provider_session" + ] + } + ] + }, + "auth": [ + "provider_session" + ], + "uploads": { + "maxBytes": 25000000, + "contentTypes": [ + "application/json", + "multipart/form-data" + ] + }, + "downloads": { + "maxBytes": 1048576, + "contentTypes": [ + "application/json" + ] + } + }, + "configSchema": [ + { + "key": "after", + "type": "date", + "label": "Start date", + "labels": { + "de": "Startdatum", + "en": "Start date" + }, + "description": "Ignore routes and activities before this date.", + "descriptions": { + "de": "Wenn Ihr Hammerhead Konto bereits mit anderen Trail-Datenbanken wie komoot oder Strava synchronisiert ist, kann die zusätzliche Synchronisierung Ihrer Hammerhead-Daten zu Duplikaten führen. Um dies zu vermeiden, können Sie unten ein Startdatum festlegen, sodass nur Aktivitäten synchronisiert werden, die nach diesem Datum aufgezeichnet wurden.", + "en": "If your hammerhead account is already synced with other trail databases, such as komoot or Strava, start syncing your Hammerhead data may result in duplicates. To avoid this, you can set an start date below, meaning only activities recorded after this date will be synced.", + "no": "Hvis Hammerhead-kontoen din allerede er synkronisert med andre stidatabaser, som Komoot eller Strava, kan synkronisering av Hammerhead-data føre til duplikater. For å unngå dette kan du angi en startdato nedenfor, slik at bare aktiviteter registrert etter denne datoen vil bli synkronisert." + } + } + ], + "hostConfig": { + "categoryMapping": { + "biking": "Biking" + } + }, + "metadata": { + "descriptions": { + "de": "Importiert Hammerhead-Routen und Aktivitäten und kann wanderer-Routen an Hammerhead senden.", + "en": "Imports Hammerhead routes and activities, and can send wanderer routes to Hammerhead.", + "no": "Synkroniserer Hammerhead-turene dine med Wanderer med jevne mellomrom." + }, + "icons": { + "light": "icon.svg", + "dark": "icon_dark.svg" + }, + "providerCategories": { + "biking": { + "labels": { + "de": "Radfahren", + "en": "Biking" + } + } + } + } +} diff --git a/plugins/hammerhead/send.go b/plugins/hammerhead/send.go new file mode 100644 index 00000000..15dd1eb8 --- /dev/null +++ b/plugins/hammerhead/send.go @@ -0,0 +1,24 @@ +package main + +import "strings" + +func trailGPXFilename(name string) string { + name = strings.TrimSpace(name) + if name == "" { + return "trail.gpx" + } + name = strings.Map(func(r rune) rune { + if r < 32 || r == '/' || r == '\\' { + return '-' + } + return r + }, name) + name = strings.Trim(name, ". -") + if name == "" { + return "trail.gpx" + } + if strings.HasSuffix(strings.ToLower(name), ".gpx") { + return name + } + return name + ".gpx" +} diff --git a/plugins/hammerhead/types.go b/plugins/hammerhead/types.go new file mode 100644 index 00000000..a3becb28 --- /dev/null +++ b/plugins/hammerhead/types.go @@ -0,0 +1,106 @@ +package main + +import "github.com/open-wanderer/wanderer/plugins/sdk" + +type instanceRef = sdk.InstanceRef +type refreshSessionInput = sdk.RefreshSessionInput +type refreshSessionOutput = sdk.RefreshSessionOutput +type trailSendInput = sdk.TrailSendInput +type listInput = sdk.ListInput +type listOutput = sdk.ListOutput +type detailInput = sdk.DetailInput +type detailOutput = sdk.DetailOutput +type trailSummary = sdk.TrailSummary +type trailImport = sdk.TrailImport +type trailImportSource = sdk.TrailImportSource +type track = sdk.Track +type trailSendPlan = sdk.TrailSendPlan + +type loginResponse struct { + Token string `json:"access_token"` +} + +type toursResponse struct { + TotalPages int `json:"totalPages"` + Data []tourResponse `json:"data"` +} + +type tourResponse struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt string `json:"createdAt"` +} + +type activitiesResponse struct { + TotalPages int `json:"totalPages"` + Data []activityResponse `json:"data"` +} + +type activityResponse struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt string `json:"createdAt"` +} + +type tour struct { + ID string `json:"id"` + CreatedAt string `json:"createdAt"` + Name string `json:"name"` + Distance float64 `json:"distance"` + Elevation elevation `json:"elevation"` + StartLocation location `json:"startLocation"` + RoutePolyline string `json:"routePolyline"` + IsPublic bool `json:"isPublic"` +} + +type elevation struct { + Gain float64 `json:"gain"` + Loss float64 `json:"loss"` + Polyline string `json:"polyline"` +} + +type location struct { + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` +} + +type activity struct { + ActivityData activityData `json:"activityData"` + RecordData recordData `json:"recordData"` +} + +type activityData struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt string `json:"createdAt"` + Duration duration `json:"duration"` + ActivityInfo []info `json:"activityInfo"` + Laps []lapDetail `json:"laps"` + ActivityType string `json:"activityType"` +} + +type duration struct { + ElapsedTime int `json:"elapsedTime"` +} + +type info struct { + Key string `json:"key"` + Value infoValue `json:"value"` +} + +type infoValue struct { + Value float64 `json:"value"` +} + +type lapDetail struct { + ActiveTime int `json:"activeTime"` +} + +type recordData struct { + Timestamp []int `json:"timestamp"` + Elevation []float64 `json:"elevation"` + Lat []float64 `json:"lat"` + Lng []float64 `json:"lng"` +} + +type pluginError = sdk.PluginError diff --git a/plugins/komoot/Makefile b/plugins/komoot/Makefile new file mode 100644 index 00000000..b9340202 --- /dev/null +++ b/plugins/komoot/Makefile @@ -0,0 +1,15 @@ +PLUGIN_ID := komoot +DIST_DIR := dist/$(PLUGIN_ID) + +.PHONY: build manifest clean + +build: manifest + tinygo build -target=wasi -scheduler=none -no-debug -o $(DIST_DIR)/plugin.wasm . + +manifest: + mkdir -p $(DIST_DIR) + go run github.com/open-wanderer/wanderer/plugins/sdk/cmd/manifestcheck > $(DIST_DIR)/plugin.json + cp assets/icon.svg $(DIST_DIR)/icon.svg + +clean: + rm -rf dist diff --git a/plugins/komoot/README.md b/plugins/komoot/README.md new file mode 100644 index 00000000..c8bff4ac --- /dev/null +++ b/plugins/komoot/README.md @@ -0,0 +1,11 @@ +# wanderer Komoot WASM Plugin + +Komoot provider for the wanderer WASM plugin system. + +```sh +make build +``` + +The build output is written to `dist/komoot`. Copy it below `data/plugins` or +run `make plugins-install-local` from the repository root to install all bundled +plugins locally. diff --git a/plugins/komoot/assets/icon.svg b/plugins/komoot/assets/icon.svg new file mode 100644 index 00000000..03a352d3 --- /dev/null +++ b/plugins/komoot/assets/icon.svg @@ -0,0 +1,197 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/komoot/auth.go b/plugins/komoot/auth.go new file mode 100644 index 00000000..1eabf759 --- /dev/null +++ b/plugins/komoot/auth.go @@ -0,0 +1,24 @@ +package main + +import ( + "encoding/base64" + + "github.com/open-wanderer/wanderer/plugins/sdk" +) + +func (c *komootClient) requestHeaders(connector string) map[string]string { + headers := map[string]string{ + "Accept": "application/hal+json", + } + if connector == "api" { + headers[sdk.AuthHeaderAuthorization] = basicAuth(c.userID, c.token) + } + if language := acceptLanguage(c.locale); language != "" { + headers["Accept-Language"] = language + } + return headers +} + +func basicAuth(username string, password string) string { + return "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password)) +} diff --git a/plugins/komoot/go.mod b/plugins/komoot/go.mod new file mode 100644 index 00000000..36d9c7d4 --- /dev/null +++ b/plugins/komoot/go.mod @@ -0,0 +1,9 @@ +module github.com/open-wanderer/wanderer/plugins/komoot + +go 1.25.0 + +require github.com/extism/go-pdk v1.1.3 + +require github.com/open-wanderer/wanderer/plugins/sdk v0.0.0 + +replace github.com/open-wanderer/wanderer/plugins/sdk => ../sdk diff --git a/plugins/komoot/go.sum b/plugins/komoot/go.sum new file mode 100644 index 00000000..c15d3829 --- /dev/null +++ b/plugins/komoot/go.sum @@ -0,0 +1,2 @@ +github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ= +github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= diff --git a/plugins/komoot/komoot.go b/plugins/komoot/komoot.go new file mode 100644 index 00000000..52d02dd3 --- /dev/null +++ b/plugins/komoot/komoot.go @@ -0,0 +1,301 @@ +//go:build tinygo + +package main + +import ( + "encoding/json" + "errors" + "fmt" + "net/url" + "strconv" + + "github.com/open-wanderer/wanderer/plugins/sdk" +) + +const komootJSONMaxBytes int64 = 16 * 1024 * 1024 +const komootMaxHighlightTipRequests = 20 + +var komootJSONContentTypes = []string{"application/json", "application/hal+json"} + +var errTourKindMismatch = errors.New("tour kind mismatch") + +func login(email string, password string) (*komootClient, error) { + response, body, err := sdk.HostRequest(sdk.HostRequestSpec{ + Method: "GET", + Target: sdk.RequestTarget{ + Type: "connector", + Connector: "api", + Path: "/v006/account/email/" + url.PathEscape(email) + "/", + }, + Headers: map[string]string{ + sdk.AuthHeaderAuthorization: basicAuth(email, password), + "Accept": "application/hal+json", + }, + Expect: sdk.ResponseExpect{ + ContentTypes: komootJSONContentTypes, + MaxBytes: komootJSONMaxBytes, + }, + }) + if err != nil { + return nil, err + } + if response.Status != 200 { + return nil, fmt.Errorf("komoot login failed (%d): %s", response.Status, string(body)) + } + + var parsed loginResponse + if err := json.Unmarshal(body, &parsed); err != nil { + return nil, err + } + if parsed.Username == "" || parsed.Password == "" { + return nil, fmt.Errorf("komoot login response did not contain credentials") + } + client := &komootClient{userID: parsed.Username, token: parsed.Password, locale: parsed.Locale} + if client.locale == "" { + client.locale = client.profileLocale() + } + return client, nil +} + +func loginClient(auth map[string]any) (*komootClient, error) { + email := sdk.StringField(auth, "email") + password := sdk.StringField(auth, "password") + if email == "" || password == "" { + return nil, fmt.Errorf("email and password are required") + } + return login(email, password) +} + +func (c *komootClient) get(path string, query []sdk.QueryParam, out any) error { + body, err := c.getRawFromConnector("api", path, query) + if err != nil { + return err + } + return json.Unmarshal(body, out) +} + +func (c *komootClient) getFromConnector(connector string, path string, query []sdk.QueryParam, out any) error { + body, err := c.getRawFromConnector(connector, path, query) + if err != nil { + return err + } + return json.Unmarshal(body, out) +} + +func (c *komootClient) getRawFromConnector(connector string, path string, query []sdk.QueryParam) ([]byte, error) { + headers := c.requestHeaders(connector) + response, body, err := sdk.HostRequest(sdk.HostRequestSpec{ + Method: "GET", + Target: sdk.RequestTarget{ + Type: "connector", + Connector: connector, + Path: path, + Query: query, + }, + Headers: headers, + Expect: sdk.ResponseExpect{ + ContentTypes: komootJSONContentTypes, + MaxBytes: komootJSONMaxBytes, + }, + }) + if err != nil { + return nil, err + } + if response.Status != 200 { + return body, fmt.Errorf("komoot request failed (%d): %s", response.Status, string(body)) + } + return body, nil +} + +func (c *komootClient) profileLocale() string { + var data userProfile + if err := c.get("/v007/users/"+url.PathEscape(c.userID), nil, &data); err != nil { + return "" + } + return data.Locale +} + +func (c *komootClient) tours(page int, limit int) ([]tour, int, error) { + var data toursResponse + err := c.get("/v007/users/"+url.PathEscape(c.userID)+"/tours/", []sdk.QueryParam{ + {Name: "page", Value: strconv.Itoa(page)}, + {Name: "sort_field", Value: "date"}, + {Name: "sort_direction", Value: "desc"}, + {Name: "limit", Value: strconv.Itoa(limit)}, + }, &data) + return data.Embedded.Tours, data.Page.TotalPages, err +} + +func (c *komootClient) detailedTour(id int64) (*detailedTour, error) { + var data detailedTour + err := c.get(fmt.Sprintf("/v007/tours/%d", id), []sdk.QueryParam{ + {Name: "_embedded", Value: "coordinates,way_types,surfaces,directions,participants,timeline,cover_images"}, + {Name: "directions", Value: "v2"}, + {Name: "fields", Value: "timeline"}, + {Name: "format", Value: "coordinate_array"}, + {Name: "timeline_highlights_fields", Value: "tips,recommenders"}, + {Name: "page", Value: "2"}, + }, &data) + if err != nil { + return &data, err + } + if len(data.Embedded.WayPoints.Embedded.Items) == 0 && len(data.Embedded.Timeline.Embedded.Items) == 0 { + if timeline, err := c.webTimeline(id); err == nil { + data.Embedded.WayPoints = timeline + } + } + return &data, nil +} + +func (c *komootClient) webTimeline(id int64) (timeline, error) { + var data timeline + token := c.shareToken(id) + var query []sdk.QueryParam + if token != "" { + query = []sdk.QueryParam{{Name: "share_token", Value: token}} + } + err := c.getFromConnector("web", fmt.Sprintf("/webapi/v007/tours/%d/timeline/", id), query, &data) + if err != nil { + return data, err + } + c.addHighlightTips(data.Embedded.Items) + return data, nil +} + +func (c *komootClient) shareToken(id int64) string { + token, err := c.shareTokenWithQuery(id, nil) + if err == nil && token != "" { + return token + } + token, _ = c.shareTokenWithQuery(id, []sdk.QueryParam{{Name: "token_name", Value: "invite"}}) + return token +} + +func (c *komootClient) shareTokenWithQuery(id int64, query []sdk.QueryParam) (string, error) { + body, err := c.getRawFromConnector("api", fmt.Sprintf("/v007/tours/%d/share_token", id), query) + if err != nil { + return "", err + } + var value any + if err := json.Unmarshal(body, &value); err != nil { + return "", err + } + if token, ok := value.(string); ok { + return token, nil + } + return findShareToken(value), nil +} + +func findShareToken(value any) string { + switch typed := value.(type) { + case map[string]any: + for _, key := range []string{"token", "share_token", "shareToken"} { + if token, ok := typed[key].(string); ok { + return token + } + } + for _, nested := range typed { + if token := findShareToken(nested); token != "" { + return token + } + } + case []any: + for _, nested := range typed { + if token := findShareToken(nested); token != "" { + return token + } + } + } + return "" +} + +func (c *komootClient) addHighlightTips(items []timelineItem) { + requests := 0 + for i := range items { + if items[i].Type != "highlight" { + continue + } + ref := &items[i].Embedded.Reference + if ref.ID.String() == "" || len(ref.Embedded.Tips.Embedded.Items) > 0 { + continue + } + if requests >= komootMaxHighlightTipRequests { + return + } + requests++ + var data tips + if err := c.get(fmt.Sprintf("/v007/highlights/%s/tips/", url.PathEscape(ref.ID.String())), nil, &data); err == nil { + ref.Embedded.Tips = data + } + } +} + +func (c *komootClient) coverImages(id int64) ([]imageItem, error) { + var data coverImages + err := c.get(fmt.Sprintf("/v007/tours/%d/cover_images/", id), nil, &data) + return data.Embedded.Items, err +} + +func syncTours(client *komootClient, input listInput, wantKind string) (listOutput, error) { + page := sdk.IntState(input.State, "page", 0) + maxItems := sdk.SyncLimit(input) + rows, totalPages, err := client.tours(page, maxItems) + if err != nil { + return listOutput{}, err + } + + items := make([]trailSummary, 0, maxItems) + for _, row := range rows { + if !tourDateAfter(row.Date, sdk.StringOption(input.Options, "after")) { + continue + } + if wantKind == "planned" && row.Type != "tour_planned" { + continue + } + if wantKind == "completed" && row.Type != "tour_recorded" { + continue + } + + items = append(items, trailSummary{ + Source: trailImportSource{Provider: "komoot", ExternalID: strconv.FormatInt(row.ID, 10)}, + Kind: kindFromType(row.Type), + }) + if len(items) >= maxItems { + break + } + } + + nextPage := page + 1 + hasMore := nextPage < totalPages + return listOutput{ + Items: items, + State: sdk.NextPageState(nextPage, hasMore), + HasMore: hasMore, + }, nil +} + +func tourDetail(client *komootClient, externalID string, wantKind string) (trailImport, error) { + id, err := strconv.ParseInt(externalID, 10, 64) + if err != nil { + return trailImport{}, fmt.Errorf("invalid tour external id") + } + detail, err := client.detailedTour(id) + if err != nil { + return trailImport{}, fmt.Errorf("fetch tour %d details: %w", id, err) + } + if wantKind == "planned" && detail.Type != "tour_planned" { + return trailImport{}, fmt.Errorf("%w: tour %d is not planned", errTourKindMismatch, id) + } + if wantKind == "completed" && detail.Type != "tour_recorded" { + return trailImport{}, fmt.Errorf("%w: tour %d is not completed", errTourKindMismatch, id) + } + var routeImages []imageItem + if len(detail.Embedded.CoverImages.Embedded.Items) > 0 { + routeImages, _ = client.coverImages(detail.ID) + } + item, err := tourImport(detail, routeImages) + if err != nil { + return trailImport{}, fmt.Errorf("map tour %d: %w", id, err) + } + return item, nil +} diff --git a/plugins/komoot/komoot_test.go b/plugins/komoot/komoot_test.go new file mode 100644 index 00000000..54b4e4a4 --- /dev/null +++ b/plugins/komoot/komoot_test.go @@ -0,0 +1,53 @@ +package main + +import ( + "testing" + + "github.com/open-wanderer/wanderer/plugins/sdk" +) + +func TestAcceptLanguageFromLocale(t *testing.T) { + tests := []struct { + name string + locale string + want string + }{ + {name: "empty", locale: "", want: ""}, + {name: "language only", locale: "de", want: "de"}, + {name: "underscore region", locale: "de_CH", want: "de-CH,de;q=0.9"}, + {name: "hyphen region", locale: "en-US", want: "en-US,en;q=0.9"}, + {name: "trim space", locale: " fr_FR ", want: "fr-FR,fr;q=0.9"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := acceptLanguage(test.locale); got != test.want { + t.Fatalf("acceptLanguage(%q) = %q, want %q", test.locale, got, test.want) + } + }) + } +} + +func TestRequestHeadersOnlySendAuthToAPIConnector(t *testing.T) { + client := &komootClient{ + userID: "user", + token: "token", + locale: "de_CH", + } + + apiHeaders := client.requestHeaders("api") + if apiHeaders[sdk.AuthHeaderAuthorization] == "" { + t.Fatalf("expected api connector authorization header") + } + if apiHeaders["Accept-Language"] != "de-CH,de;q=0.9" { + t.Fatalf("unexpected api accept language: %#v", apiHeaders) + } + + webHeaders := client.requestHeaders("web") + if webHeaders[sdk.AuthHeaderAuthorization] != "" { + t.Fatalf("expected no web connector authorization header, got %#v", webHeaders) + } + if webHeaders["Accept-Language"] != "de-CH,de;q=0.9" { + t.Fatalf("unexpected web accept language: %#v", webHeaders) + } +} diff --git a/plugins/komoot/locale.go b/plugins/komoot/locale.go new file mode 100644 index 00000000..2afe7c3d --- /dev/null +++ b/plugins/komoot/locale.go @@ -0,0 +1,19 @@ +package main + +import "strings" + +func acceptLanguage(locale string) string { + locale = strings.TrimSpace(locale) + if locale == "" { + return "" + } + primary := locale + if index := strings.IndexAny(primary, "_-"); index >= 0 { + primary = primary[:index] + } + locale = strings.ReplaceAll(locale, "_", "-") + if primary == "" || primary == locale { + return locale + } + return locale + "," + primary + ";q=0.9" +} diff --git a/plugins/komoot/main.go b/plugins/komoot/main.go new file mode 100644 index 00000000..65236f73 --- /dev/null +++ b/plugins/komoot/main.go @@ -0,0 +1,104 @@ +//go:build tinygo + +package main + +import ( + "encoding/json" + "errors" + + "github.com/extism/go-pdk" +) + +func main() {} + +//export list_routes_v1 +func listRoutesV1() int32 { + return listTours("planned") +} + +//export list_activities_v1 +func listActivitiesV1() int32 { + return listTours("completed") +} + +//export get_route_detail_v1 +func getRouteDetailV1() int32 { + return getTourDetail("planned") +} + +//export get_activity_detail_v1 +func getActivityDetailV1() int32 { + return getTourDetail("completed") +} + +//export refresh_session_v1 +func refreshSessionV1() int32 { + var input refreshSessionInput + if err := pdk.InputJSON(&input); err != nil { + return fail("invalid_request", "invalid refresh_session input: "+err.Error()) + } + + client, err := loginClient(input.Auth) + if err != nil { + return fail("auth_failed", err.Error()) + } + + if err := pdk.OutputJSON(refreshSessionOutput{ + Token: client.token, + Scheme: "Basic", + }); err != nil { + return fail("internal_error", err.Error()) + } + return 0 +} + +func getTourDetail(kind string) int32 { + var input detailInput + if err := pdk.InputJSON(&input); err != nil { + return fail("invalid_request", "invalid detail input: "+err.Error()) + } + client, err := loginClient(input.Auth) + if err != nil { + return fail("auth_failed", err.Error()) + } + item, err := tourDetail(client, input.Summary.Source.ExternalID, kind) + if err != nil { + if errors.Is(err, errTourKindMismatch) { + return fail("not_importable", err.Error()) + } + return fail("provider_unavailable", err.Error()) + } + if err := pdk.OutputJSON(detailOutput{Item: item}); err != nil { + return fail("internal_error", err.Error()) + } + return 0 +} + +func listTours(kind string) int32 { + var input listInput + if err := pdk.InputJSON(&input); err != nil { + return fail("invalid_request", "invalid list input: "+err.Error()) + } + client, err := loginClient(input.Auth) + if err != nil { + return fail("auth_failed", err.Error()) + } + output, err := syncTours(client, input, kind) + if err != nil { + return fail("provider_unavailable", err.Error()) + } + if err := pdk.OutputJSON(output); err != nil { + return fail("internal_error", err.Error()) + } + return 0 +} + +func fail(code string, message string) int32 { + data, err := json.Marshal(pluginError{Code: code, Message: message}) + if err != nil { + pdk.SetErrorString(message) + return 1 + } + pdk.SetErrorString(string(data)) + return 1 +} diff --git a/plugins/komoot/main_stub.go b/plugins/komoot/main_stub.go new file mode 100644 index 00000000..1ad4d241 --- /dev/null +++ b/plugins/komoot/main_stub.go @@ -0,0 +1,5 @@ +//go:build !tinygo + +package main + +func main() {} diff --git a/plugins/komoot/mapper.go b/plugins/komoot/mapper.go new file mode 100644 index 00000000..0e879483 --- /dev/null +++ b/plugins/komoot/mapper.go @@ -0,0 +1,226 @@ +package main + +import ( + "encoding/base64" + "fmt" + "strconv" + "strings" + "time" + + sdkgpx "github.com/open-wanderer/wanderer/plugins/sdk/gpx" +) + +func tourImport(tour *detailedTour, routeImages []imageItem) (trailImport, error) { + gpxData, err := tourGPX(tour) + if err != nil { + return trailImport{}, err + } + + privacy := privacyFromStatus(tour.Status) + return trailImport{ + Source: trailImportSource{ + Provider: "komoot", + ExternalID: strconv.FormatInt(tour.ID, 10), + }, + Kind: kindFromType(tour.Type), + Name: tour.Name, + Description: tour.Description, + StartedAt: tour.Date, + ActivityType: activityType(tour.Sport), + Privacy: &privacy, + Track: track{ + Format: "gpx", + ContentBase64: base64.StdEncoding.EncodeToString(gpxData), + }, + Waypoints: waypoints(tour), + Photos: photos(tour, routeImages), + Metadata: map[string]any{ + "distance": tour.Distance, + "elevationGain": tour.ElevationUp, + "elevationLoss": tour.ElevationDown, + "duration": tour.Duration, + "providerCategory": tour.Sport, + "sourceSport": tour.Sport, + "difficulty": tour.Difficulty.Grade, + }, + }, nil +} + +func tourGPX(tour *detailedTour) ([]byte, error) { + items := tour.Embedded.Coordinates.Items + points := make([]sdkgpx.Point, 0, len(items)) + startedAt, _ := time.Parse(time.RFC3339, tour.Date) + for _, item := range items { + elevation := item.Alt + point := sdkgpx.Point{ + Lat: item.Lat, + Lon: item.Lng, + Elevation: &elevation, + } + if !startedAt.IsZero() { + pointTime := startedAt.Add(time.Duration(item.T) * time.Millisecond).UTC() + point.Time = &pointTime + } + points = append(points, point) + } + return sdkgpx.Track("wanderer Komoot plugin", tour.Name, points) +} + +func waypoints(tour *detailedTour) []waypoint { + result := make([]waypoint, 0, len(tour.Embedded.WayPoints.Embedded.Items)+len(tour.Embedded.Timeline.Embedded.Items)) + seen := map[string]bool{} + result = appendWaypoints(result, seen, tour.Embedded.WayPoints.Embedded.Items) + result = appendWaypoints(result, seen, tour.Embedded.Timeline.Embedded.Items) + return result +} + +func appendWaypoints(result []waypoint, seen map[string]bool, items []timelineItem) []waypoint { + for _, item := range items { + ref := item.Embedded.Reference + point, ok := waypointPoint(ref) + if ref.Name == "" || !ok { + continue + } + key := ref.ID.String() + if key == "" { + key = fmt.Sprintf("%s:%0.7f:%0.7f", ref.Name, point.Lat, point.Lng) + } + if seen[key] { + continue + } + seen[key] = true + + description := "" + if len(ref.Embedded.Tips.Embedded.Items) > 0 { + description = ref.Embedded.Tips.Embedded.Items[0].Text + } + ele := point.Alt + result = append(result, waypoint{ + ExternalID: ref.ID.String(), + Name: ref.Name, + Description: description, + Lat: point.Lat, + Lon: point.Lng, + Ele: &ele, + Icon: "circle", + Photos: waypointPhotos(item), + }) + } + return result +} + +func waypointPoint(ref waypointReference) (point, bool) { + if ref.StartPoint.Lat != 0 || ref.StartPoint.Lng != 0 { + return ref.StartPoint, true + } + if ref.Location.Lat != 0 || ref.Location.Lng != 0 { + return ref.Location, true + } + return point{}, false +} + +func photos(tour *detailedTour, routeImages []imageItem) []photo { + images := routeImages + if len(images) == 0 { + images = tour.Embedded.CoverImages.Embedded.Items + } + if len(images) == 0 && tour.MapImage.Src != "" { + images = []imageItem{{Src: tour.MapImage.Src, Type: "image/jpeg"}} + } + return photosFromImages(images, "komoot-photo.jpg") +} + +func waypointPhotos(item timelineItem) []photo { + ref := item.Embedded.Reference + images := ref.Embedded.Images.Embedded.Items + if ref.Embedded.FrontImage.Src != "" { + images = append([]imageItem{ref.Embedded.FrontImage}, images...) + } + return photosFromImages(images, "komoot-waypoint-photo.jpg") +} + +func photosFromImages(images []imageItem, fallbackFilename string) []photo { + result := make([]photo, 0, len(images)) + seen := map[string]bool{} + for _, image := range images { + source := expandImageURL(image.Src) + if source == "" || strings.HasSuffix(strings.ToLower(source), ".gif") { + continue + } + key := image.ID.String() + if key == "" { + key = source + } + if seen[key] { + continue + } + seen[key] = true + result = append(result, photo{ + ExternalID: image.ID.String(), + Filename: filenameForImage(image.ID, fallbackFilename), + ContentType: contentType(image.Type), + Lat: optionalCoordinate(image.Location.Lat), + Lon: optionalCoordinate(image.Location.Lng), + Source: mediaSource{ + Type: "url", + URL: source, + }, + }) + } + return result +} + +func expandImageURL(source string) string { + source = strings.ReplaceAll(source, "{crop}", "false") + source = strings.ReplaceAll(source, "{width}", "") + source = strings.ReplaceAll(source, "{height}", "") + return source +} + +func filenameForImage(id flexibleID, fallback string) string { + if id.String() == "" { + return fallback + } + return fmt.Sprintf("komoot-%s.jpg", id.String()) +} + +func contentType(value string) string { + if strings.HasPrefix(value, "image/") { + return value + } + return "image/jpeg" +} + +func optionalCoordinate(value float64) *float64 { + if value == 0 { + return nil + } + return &value +} + +func kindFromType(value string) string { + if value == "tour_recorded" { + return "completed" + } + return "planned" +} + +func privacyFromStatus(value string) string { + if value == "public" { + return "public" + } + return "private" +} + +func activityType(sport string) string { + switch sport { + case "hike", "mountaineering": + return "hiking" + case "jogging": + return "running" + case "touringbicycle", "mtb", "racebike", "mtb_easy", "mtb_advanced": + return "biking" + default: + return sport + } +} diff --git a/plugins/komoot/mapper_test.go b/plugins/komoot/mapper_test.go new file mode 100644 index 00000000..e552de27 --- /dev/null +++ b/plugins/komoot/mapper_test.go @@ -0,0 +1,134 @@ +package main + +import "testing" + +func TestWaypointsFromEmbeddedWayPoints(t *testing.T) { + tour := &detailedTour{ + Embedded: detailedTourEmbedded{ + WayPoints: timeline{ + Embedded: timelineEmbedded{ + Items: []timelineItem{{ + Embedded: timelineItemEmbedded{ + Reference: waypointReference{ + ID: flexibleID("2355158"), + Name: "Ruedertaler Hofglace Rastplatz", + Location: point{ + Lat: 47.280262, + Lng: 8.046906, + Alt: 476.7, + }, + StartPoint: point{ + Lat: 47.280262, + Lng: 8.046906, + Alt: 476.7, + }, + }, + }, + }}, + }, + }, + }, + } + + points := waypoints(tour) + if len(points) != 1 { + t.Fatalf("expected 1 waypoint, got %d", len(points)) + } + if points[0].ExternalID != "2355158" || points[0].Name != "Ruedertaler Hofglace Rastplatz" { + t.Fatalf("unexpected waypoint identity: %#v", points[0]) + } + if points[0].Lat != 47.280262 || points[0].Lon != 8.046906 || points[0].Ele == nil || *points[0].Ele != 476.7 { + t.Fatalf("unexpected waypoint coordinates: %#v", points[0]) + } +} + +func TestWaypointsDeduplicateWayPointsAndTimeline(t *testing.T) { + item := timelineItem{ + Embedded: timelineItemEmbedded{ + Reference: waypointReference{ + ID: flexibleID("8277503"), + Name: "Aarebruecke bei Aarburg", + StartPoint: point{Lat: 47.320204, Lng: 7.897589}, + }, + }, + } + tour := &detailedTour{ + Embedded: detailedTourEmbedded{ + WayPoints: timeline{Embedded: timelineEmbedded{Items: []timelineItem{item}}}, + Timeline: timeline{Embedded: timelineEmbedded{Items: []timelineItem{item}}}, + }, + } + + points := waypoints(tour) + if len(points) != 1 { + t.Fatalf("expected duplicate waypoint to be collapsed, got %d", len(points)) + } +} + +func TestWaypointsIncludeFrontImage(t *testing.T) { + tour := &detailedTour{ + Embedded: detailedTourEmbedded{ + WayPoints: timeline{ + Embedded: timelineEmbedded{ + Items: []timelineItem{{ + Embedded: timelineItemEmbedded{ + Reference: waypointReference{ + ID: flexibleID("4266004"), + Name: "Blick auf die Solothurner Altstadt und die St.-Ursen-Kathedrale", + StartPoint: point{Lat: 47.205925, Lng: 7.535326, Alt: 424.6}, + Embedded: waypointSubEmbedded{ + FrontImage: imageItem{ + ID: flexibleID("48446190"), + Src: "https://example.test/image.jpg", + Type: "image/*", + }, + }, + }, + }, + }}, + }, + }, + }, + } + + points := waypoints(tour) + if len(points) != 1 { + t.Fatalf("expected 1 waypoint, got %d", len(points)) + } + if len(points[0].Photos) != 1 { + t.Fatalf("expected 1 waypoint photo, got %d", len(points[0].Photos)) + } + if points[0].Photos[0].ExternalID != "48446190" || points[0].Photos[0].Source.URL != "https://example.test/image.jpg" { + t.Fatalf("unexpected waypoint photo: %#v", points[0].Photos[0]) + } +} + +func TestWaypointPhotosDeduplicateFrontImage(t *testing.T) { + item := timelineItem{ + Embedded: timelineItemEmbedded{ + Reference: waypointReference{ + Embedded: waypointSubEmbedded{ + FrontImage: imageItem{ + ID: flexibleID("48446190"), + Src: "https://example.test/front.jpg", + Type: "image/*", + }, + Images: coverImages{ + Embedded: imagesEmbedded{ + Items: []imageItem{{ + ID: flexibleID("48446190"), + Src: "https://example.test/front.jpg", + Type: "image/*", + }}, + }, + }, + }, + }, + }, + } + + photos := waypointPhotos(item) + if len(photos) != 1 { + t.Fatalf("expected duplicate front image to be collapsed, got %d", len(photos)) + } +} diff --git a/plugins/komoot/options.go b/plugins/komoot/options.go new file mode 100644 index 00000000..274a4f70 --- /dev/null +++ b/plugins/komoot/options.go @@ -0,0 +1,27 @@ +package main + +import ( + "time" +) + +func tourDateAfter(tourDate string, after string) bool { + if after == "" { + return true + } + limit, err := time.Parse("2006-01-02", after) + if err != nil { + return true + } + date, err := parseKomootDate(tourDate) + if err != nil { + return true + } + return !date.Before(limit) +} + +func parseKomootDate(value string) (time.Time, error) { + if parsed, err := time.Parse(time.RFC3339, value); err == nil { + return parsed, nil + } + return time.Parse("2006-01-02", value) +} diff --git a/plugins/komoot/plugin.json b/plugins/komoot/plugin.json new file mode 100644 index 00000000..7bff07e1 --- /dev/null +++ b/plugins/komoot/plugin.json @@ -0,0 +1,310 @@ +{ + "manifestVersion": "1.0", + "id": "komoot", + "type": "trails", + "name": "komoot", + "description": "Imports planned and completed komoot tours, including photos and waypoints, into wanderer.", + "version": "0.1.0", + "runtime": { + "type": "wasm", + "entrypoint": "plugin.wasm" + }, + "capabilities": [ + { + "name": "list_routes", + "version": "v1", + "export": "list_routes_v1" + }, + { + "name": "get_route_detail", + "version": "v1", + "export": "get_route_detail_v1" + }, + { + "name": "list_activities", + "version": "v1", + "export": "list_activities_v1" + }, + { + "name": "get_activity_detail", + "version": "v1", + "export": "get_activity_detail_v1" + } + ], + "auth": { + "contexts": { + "provider_session": { + "type": "session", + "fields": [ + "email", + "password" + ], + "secretFields": [ + "password" + ], + "refresh": { + "mode": "plugin", + "function": "refresh_session_v1" + } + } + } + }, + "permissions": { + "network": { + "connectors": [ + { + "name": "api", + "type": "public_api", + "fixedBaseURL": "https://api.komoot.de", + "allowedPathPrefixes": [ + "/v006", + "/v007" + ] + }, + { + "name": "web", + "type": "public_api", + "fixedBaseURL": "https://www.komoot.com", + "allowedPathPrefixes": [ + "/webapi/v007" + ] + } + ] + }, + "auth": [ + "provider_session" + ], + "downloads": { + "maxBytes": 16777216, + "contentTypes": [ + "application/json", + "application/hal+json" + ] + } + }, + "configSchema": [ + { + "key": "after", + "type": "date", + "label": "Start date", + "labels": { + "de": "Startdatum", + "en": "Start date" + }, + "description": "Ignore tours before this date.", + "descriptions": { + "de": "Touren vor diesem Datum ignorieren.", + "en": "Ignore tours before this date." + } + } + ], + "hostConfig": { + "categoryMapping": { + "hike": "Hiking", + "mountaineering": "Hiking", + "racebike": "Biking", + "e_racebike": "Biking", + "touringbicycle": "Biking", + "e_touringbicycle": "Biking", + "mtb": "Biking", + "e_mtb": "Biking", + "mtb_easy": "Biking", + "e_mtb_easy": "Biking", + "mtb_advanced": "Biking", + "e_mtb_advanced": "Biking", + "downhillbike": "Biking", + "unicycle": "Biking", + "citybike": "Biking", + "jogging": "Walking", + "nordicwalking": "Walking", + "skaten": "Walking", + "other": "Walking", + "climbing": "Climbing", + "nordic": "Skiing", + "skialpin": "Skiing", + "skitour": "Skiing", + "sled": "Skiing", + "snowboard": "Skiing", + "snowshoe": "Skiing" + } + }, + "metadata": { + "descriptions": { + "cs": "Synchronizuje vaše trasy z aplikace Komoot s Wandererem v pravidelných intervalech.", + "de": "Importiert geplante und abgeschlossene komoot-Touren inklusive Fotos und Wegpunkten in wanderer.", + "en": "Imports planned and completed komoot tours, including photos and waypoints, into wanderer.", + "es": "Sincroniza tus recorridos de Komoot con Wanderer en intervalos regulares.", + "eu": "Zure komooteko ibilbideak wandererekin sinkronizatzen ditu aldian behin.", + "fr": "Synchronisez vos Tours Komoot avec wanderer à intervalles réguliers.", + "it": "Syncs your komoot tours with wanderer in regular intervals.", + "hu": "Syncs your komoot tours with wanderer in regular intervals.", + "nl": "Synchroniseert je Komoot-tochten met Wanderer op regelmatige tijdstippen.", + "no": "Synkroniserer dine Komoot-turer med Wanderer med jevne mellomrom.", + "pl": "Synchronizuje trasy kamoot z wanderer w równych odstępach.", + "pt": "Syncs your komoot tours with wanderer in regular intervals.", + "ru": "Синхронизирует ваши данные с Komoot.", + "zh": "定期与komoot同步您的wanderer。" + }, + "icons": { + "light": "icon.svg" + }, + "providerCategories": { + "hike": { + "labels": { + "de": "Wandern", + "en": "Hiking" + } + }, + "mountaineering": { + "labels": { + "de": "Bergsteigen", + "en": "Mountaineering" + } + }, + "racebike": { + "labels": { + "de": "Rennrad", + "en": "Road bike" + } + }, + "e_racebike": { + "labels": { + "de": "E-Rennrad", + "en": "E-road bike" + } + }, + "touringbicycle": { + "labels": { + "de": "Tourenrad", + "en": "Touring bike" + } + }, + "e_touringbicycle": { + "labels": { + "de": "E-Tourenrad", + "en": "E-touring bike" + } + }, + "mtb": { + "labels": { + "de": "Mountainbike", + "en": "Mountain bike" + } + }, + "e_mtb": { + "labels": { + "de": "E-Mountainbike", + "en": "E-mountain bike" + } + }, + "mtb_easy": { + "labels": { + "de": "Mountainbike einfach", + "en": "Easy mountain bike" + } + }, + "e_mtb_easy": { + "labels": { + "de": "E-Mountainbike einfach", + "en": "Easy e-mountain bike" + } + }, + "mtb_advanced": { + "labels": { + "de": "Mountainbike anspruchsvoll", + "en": "Advanced mountain bike" + } + }, + "e_mtb_advanced": { + "labels": { + "de": "E-Mountainbike anspruchsvoll", + "en": "Advanced e-mountain bike" + } + }, + "downhillbike": { + "labels": { + "de": "Downhill-Bike", + "en": "Downhill bike" + } + }, + "unicycle": { + "labels": { + "de": "Einrad", + "en": "Unicycle" + } + }, + "citybike": { + "labels": { + "de": "Citybike", + "en": "City bike" + } + }, + "jogging": { + "labels": { + "de": "Joggen", + "en": "Jogging" + } + }, + "nordicwalking": { + "labels": { + "de": "Nordic Walking", + "en": "Nordic walking" + } + }, + "skaten": { + "labels": { + "de": "Skaten", + "en": "Skating" + } + }, + "other": { + "labels": { + "de": "Sonstiges", + "en": "Other" + } + }, + "climbing": { + "labels": { + "de": "Klettern", + "en": "Climbing" + } + }, + "nordic": { + "labels": { + "de": "Langlauf", + "en": "Cross-country skiing" + } + }, + "skialpin": { + "labels": { + "de": "Ski alpin", + "en": "Alpine skiing" + } + }, + "skitour": { + "labels": { + "de": "Skitour", + "en": "Ski touring" + } + }, + "sled": { + "labels": { + "de": "Schlitten", + "en": "Sledding" + } + }, + "snowboard": { + "labels": { + "de": "Snowboard", + "en": "Snowboard" + } + }, + "snowshoe": { + "labels": { + "de": "Schneeschuhwandern", + "en": "Snowshoeing" + } + } + } + } +} diff --git a/plugins/komoot/types.go b/plugins/komoot/types.go new file mode 100644 index 00000000..d7bf3e53 --- /dev/null +++ b/plugins/komoot/types.go @@ -0,0 +1,197 @@ +package main + +import ( + "encoding/json" + + "github.com/open-wanderer/wanderer/plugins/sdk" +) + +type instanceRef = sdk.InstanceRef +type refreshSessionInput = sdk.RefreshSessionInput +type refreshSessionOutput = sdk.RefreshSessionOutput +type listInput = sdk.ListInput +type listOutput = sdk.ListOutput +type detailInput = sdk.DetailInput +type detailOutput = sdk.DetailOutput +type trailSummary = sdk.TrailSummary +type trailImport = sdk.TrailImport +type trailImportSource = sdk.TrailImportSource +type track = sdk.Track +type waypoint = sdk.Waypoint +type photo = sdk.Photo +type mediaSource = sdk.MediaSource + +type pluginError = sdk.PluginError + +type komootClient struct { + userID string + token string + locale string +} + +type flexibleID string + +func (id *flexibleID) UnmarshalJSON(data []byte) error { + var stringValue string + if err := json.Unmarshal(data, &stringValue); err == nil { + *id = flexibleID(stringValue) + return nil + } + var numberValue json.Number + if err := json.Unmarshal(data, &numberValue); err != nil { + return err + } + *id = flexibleID(numberValue.String()) + return nil +} + +func (id flexibleID) String() string { + return string(id) +} + +type loginResponse struct { + Password string `json:"password"` + Username string `json:"username"` + Locale string `json:"locale"` +} + +type userProfile struct { + Locale string `json:"locale"` +} + +type toursResponse struct { + Embedded toursEmbedded `json:"_embedded"` + Page page `json:"page"` +} + +type toursEmbedded struct { + Tours []tour `json:"tours"` +} + +type page struct { + TotalPages int `json:"totalPages"` +} + +type tour struct { + ID int64 `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description"` + Status string `json:"status"` + Date string `json:"date"` + Sport string `json:"sport"` + ChangedAt string `json:"changed_at"` +} + +type detailedTour struct { + ID int64 `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description"` + Status string `json:"status"` + Date string `json:"date"` + Sport string `json:"sport"` + Distance float64 `json:"distance"` + Duration int `json:"duration"` + ElevationUp float64 `json:"elevation_up"` + ElevationDown float64 `json:"elevation_down"` + MapImage mapImage `json:"map_image"` + Difficulty difficulty `json:"difficulty"` + ChangedAt string `json:"changed_at"` + Embedded detailedTourEmbedded `json:"_embedded"` +} + +type difficulty struct { + Grade string `json:"grade"` +} + +type mapImage struct { + Src string `json:"src"` +} + +type detailedTourEmbedded struct { + Coordinates coordinates `json:"coordinates"` + Timeline timeline `json:"timeline"` + WayPoints timeline `json:"way_points"` + CoverImages coverImages `json:"cover_images"` +} + +type coordinates struct { + Items []coordinate `json:"items"` +} + +type coordinate struct { + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` + Alt float64 `json:"alt"` + T int `json:"t"` +} + +type timeline struct { + Embedded timelineEmbedded `json:"_embedded"` +} + +type timelineEmbedded struct { + Items []timelineItem `json:"items"` +} + +type timelineItem struct { + Type string `json:"type"` + Embedded timelineItemEmbedded `json:"_embedded"` +} + +type timelineItemEmbedded struct { + Reference waypointReference `json:"reference"` +} + +type waypointReference struct { + ID flexibleID `json:"id"` + Name string `json:"name"` + StartPoint point `json:"start_point"` + Location point `json:"location"` + Embedded waypointSubEmbedded `json:"_embedded"` +} + +type point struct { + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` + Alt float64 `json:"alt"` +} + +type waypointSubEmbedded struct { + Tips tips `json:"tips"` + Images coverImages `json:"images"` + FrontImage imageItem `json:"front_image"` +} + +type tips struct { + Embedded tipsEmbedded `json:"_embedded"` +} + +type tipsEmbedded struct { + Items []tipItem `json:"items"` +} + +type tipItem struct { + Text string `json:"text"` +} + +type coverImages struct { + Embedded imagesEmbedded `json:"_embedded"` +} + +type imagesEmbedded struct { + Items []imageItem `json:"items"` +} + +type imageItem struct { + ID flexibleID `json:"id"` + Src string `json:"src"` + Location location `json:"location"` + Type string `json:"type"` +} + +type location struct { + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` +} diff --git a/plugins/schema/plugin.schema.json b/plugins/schema/plugin.schema.json new file mode 100644 index 00000000..02d671d6 --- /dev/null +++ b/plugins/schema/plugin.schema.json @@ -0,0 +1,481 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema#", + "$id": "https://open-wanderer.github.io/wanderer/schemas/plugin.schema.json", + "title": "wanderer plugin manifest", + "description": "Schema for wanderer plugin.json manifests.", + "type": "object", + "additionalProperties": false, + "required": [ + "manifestVersion", + "id", + "type", + "name", + "version", + "runtime", + "capabilities" + ], + "properties": { + "$schema": { + "type": "string", + "description": "Optional editor schema reference." + }, + "manifestVersion": { + "type": "string", + "const": "1.0" + }, + "id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9_-]*$" + }, + "type": { + "type": "string", + "enum": ["trails"] + }, + "name": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "version": { + "type": "string", + "minLength": 1 + }, + "runtime": { + "$ref": "#/definitions/runtime" + }, + "capabilities": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/capability" + } + }, + "auth": { + "$ref": "#/definitions/auth" + }, + "permissions": { + "$ref": "#/definitions/permissions" + }, + "configSchema": { + "type": "array", + "items": { + "$ref": "#/definitions/configField" + } + }, + "hostConfig": { + "$ref": "#/definitions/hostConfig" + }, + "metadata": { + "$ref": "#/definitions/metadata" + } + }, + "definitions": { + "runtime": { + "type": "object", + "additionalProperties": false, + "required": ["type", "entrypoint"], + "properties": { + "type": { + "type": "string", + "const": "wasm" + }, + "entrypoint": { + "type": "string", + "minLength": 1 + } + } + }, + "capability": { + "type": "object", + "additionalProperties": false, + "required": ["name", "version", "export"], + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "version": { + "type": "string", + "minLength": 1 + }, + "export": { + "type": "string", + "minLength": 1 + }, + "requiredHostFunctions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "job": { + "type": "string", + "minLength": 1 + } + } + }, + "auth": { + "type": "object", + "additionalProperties": false, + "properties": { + "contexts": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/authContext" + } + } + } + }, + "authContext": { + "type": "object", + "additionalProperties": false, + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["oauth2", "api_key", "bearer", "session"] + }, + "fields": { + "$ref": "#/definitions/stringList" + }, + "authorizationUrl": { + "type": "string", + "format": "uri" + }, + "tokenUrl": { + "type": "string", + "format": "uri" + }, + "scopes": { + "$ref": "#/definitions/stringList" + }, + "scopeSeparator": { + "type": "string" + }, + "pkce": { + "type": "boolean" + }, + "tokenRequestFormat": { + "type": "string", + "enum": ["json", "form"] + }, + "tokenAuth": { + "type": "string", + "enum": ["client_secret_post", "client_secret_basic"] + }, + "authorizationParams": { + "$ref": "#/definitions/stringMap" + }, + "refresh": { + "$ref": "#/definitions/authRefresh" + }, + "placement": { + "type": "string", + "enum": ["query"] + }, + "name": { + "type": "string", + "minLength": 1 + }, + "secretField": { + "type": "string", + "minLength": 1 + }, + "secretFields": { + "$ref": "#/definitions/stringList" + } + } + }, + "authRefresh": { + "type": "object", + "additionalProperties": false, + "required": ["mode"], + "properties": { + "mode": { + "type": "string", + "enum": ["host", "plugin"] + }, + "grantType": { + "type": "string", + "minLength": 1 + }, + "function": { + "type": "string", + "minLength": 1 + } + } + }, + "permissions": { + "type": "object", + "additionalProperties": false, + "properties": { + "network": { + "$ref": "#/definitions/networkPermissions" + }, + "auth": { + "$ref": "#/definitions/stringList" + }, + "downloads": { + "$ref": "#/definitions/transferPermissions" + }, + "uploads": { + "$ref": "#/definitions/transferPermissions" + } + } + }, + "networkPermissions": { + "type": "object", + "additionalProperties": false, + "properties": { + "connectors": { + "type": "array", + "items": { + "$ref": "#/definitions/connector" + } + }, + "redirects": { + "$ref": "#/definitions/redirects" + } + } + }, + "connector": { + "type": "object", + "additionalProperties": false, + "required": ["name", "type"], + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "enum": ["public_api", "configured"] + }, + "fixedBaseURL": { + "type": "string", + "format": "uri" + }, + "configKey": { + "type": "string", + "minLength": 1 + }, + "allowedPathPrefixes": { + "$ref": "#/definitions/stringList" + }, + "auth": { + "$ref": "#/definitions/stringList" + }, + "supportsMediaAuth": { + "type": "boolean" + }, + "supportsStorageRedirects": { + "type": "boolean" + }, + "supportsCustomTLS": { + "type": "boolean" + } + }, + "allOf": [ + { + "if": { + "properties": { "type": { "const": "public_api" } } + }, + "then": { + "required": ["fixedBaseURL"], + "not": { "required": ["configKey"] } + } + }, + { + "if": { + "properties": { "type": { "const": "configured" } } + }, + "then": { + "required": ["configKey"], + "not": { "required": ["fixedBaseURL"] } + } + } + ] + }, + "redirects": { + "type": "object", + "additionalProperties": false, + "properties": { + "mode": { + "type": "string", + "enum": ["declared_hosts_only"] + }, + "hosts": { + "$ref": "#/definitions/stringList" + } + } + }, + "transferPermissions": { + "type": "object", + "additionalProperties": false, + "properties": { + "maxBytes": { + "type": "integer", + "minimum": 0 + }, + "contentTypes": { + "$ref": "#/definitions/stringList" + } + } + }, + "configField": { + "type": "object", + "additionalProperties": false, + "required": ["key", "type"], + "properties": { + "key": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "enum": ["boolean", "date", "select", "text", "url"] + }, + "label": { + "type": "string" + }, + "labels": { + "$ref": "#/definitions/stringMap" + }, + "description": { + "type": "string" + }, + "descriptions": { + "$ref": "#/definitions/stringMap" + }, + "options": { + "type": "array", + "items": { + "$ref": "#/definitions/configFieldOption" + } + }, + "default": {}, + "required": { + "type": "boolean" + }, + "hidden": { + "type": "boolean" + } + } + }, + "configFieldOption": { + "type": "object", + "additionalProperties": false, + "required": ["value"], + "properties": { + "value": { + "type": "string" + }, + "label": { + "type": "string" + }, + "labels": { + "$ref": "#/definitions/stringMap" + } + } + }, + "hostConfig": { + "type": "object", + "additionalProperties": true, + "properties": { + "planned": { + "type": "boolean" + }, + "completed": { + "type": "boolean" + }, + "privacy": { + "type": "string", + "enum": ["original", "settings"] + }, + "merge": { + "type": "object", + "additionalProperties": true, + "properties": { + "available": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + } + } + }, + "createSummitLogForCompleted": { + "type": "boolean" + }, + "categoryMapping": { + "$ref": "#/definitions/stringMap" + }, + "connectors": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + } + }, + "metadata": { + "type": "object", + "additionalProperties": true, + "properties": { + "displayName": { + "type": "string" + }, + "displayNames": { + "$ref": "#/definitions/stringMap" + }, + "descriptions": { + "$ref": "#/definitions/stringMap" + }, + "providerCategories": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/providerCategoryMetadata" + } + }, + "icons": { + "type": "object", + "additionalProperties": true, + "properties": { + "light": { + "type": "string" + }, + "dark": { + "type": "string" + } + } + } + } + }, + "stringList": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "stringMap": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "providerCategoryMetadata": { + "type": "object", + "additionalProperties": true, + "properties": { + "labels": { + "$ref": "#/definitions/stringMap" + } + } + } + } +} diff --git a/plugins/sdk/README.md b/plugins/sdk/README.md new file mode 100644 index 00000000..a6b18110 --- /dev/null +++ b/plugins/sdk/README.md @@ -0,0 +1,63 @@ +# Wanderer Plugin SDK for Go + +TinyGo-compatible helpers for Wanderer WASM plugins. + +```go +import "github.com/open-wanderer/wanderer/plugins/sdk" +``` + +The SDK contains only plugin-side protocol types and host-function helpers. It +does not depend on Wanderer core or PocketBase. + +Common protocol types: + +- `ListInput`, `ListOutput`, `TrailImport`, `Track`, `Waypoint`, `Photo` +- `RefreshSessionInput`, `RefreshSessionOutput` +- `TrailSendInput`, `TrailSendPlan` +- `HostRequestSpec`, `HostResponse`, `PluginError` + +Provider HTTP requests use connector targets. Plugins provide a connector name, +a relative path, and ordered query parameters; the host owns the final base URL, +path scope, redirects, TLS, and private-network policy. Public external media +URLs remain available only through `MediaSource{Type: "url"}`. + +Host HTTP request bodies support JSON, `application/x-www-form-urlencoded`, and +multipart. Use `PostJSON` for JSON and `PostForm` for ordered form fields. Any +request body, including a login form POST, is governed by manifest +`permissions.uploads.maxBytes` and `permissions.uploads.contentTypes`; in this +contract "uploads" means plugin-to-provider request bodies, not only media/file +uploads. + +Set `HostRequestSpec.FollowRedirects` to `sdk.Bool(false)` when a plugin needs +to inspect a redirect response itself, for example to collect `Location` and +`Set-Cookie` during a provider login flow. `HostResponse.HeaderValues` is the +only response-header representation and preserves all values. Prefer +`FirstHeader` for scalar headers and `HeaderValuesFor` for headers that can +appear more than once. + +Plugins can emit host-visible logs with `LogDebug`, `LogInfo`, `LogWarn`, and +`LogError`. Log levels are strict (`debug`, `info`, `warn`, `error`) and +messages must be non-empty. Use logs for short diagnostics and timing markers; +they are best-effort and should not be part of plugin control flow. + +Small sync helpers are included for the repeated mechanics that every provider +needs: + +- `StringField` / `StringOption` +- `IntState` +- `IntOption` +- `KnownIDs` +- `SyncLimit` +- `NextPageState` + +Additional TinyGo-compatible helper packages: + +```go +import sdkgpx "github.com/open-wanderer/wanderer/plugins/sdk/gpx" +import "github.com/open-wanderer/wanderer/plugins/sdk/polyline" +``` + +- `gpx` writes simple GPX 1.1 track documents from provider track points. +- `polyline` decodes Google-style encoded polylines and provides small helpers + for coordinate scale normalization, coordinate swap detection, and mapping + shorter elevation arrays onto track points. diff --git a/plugins/sdk/cmd/manifestcheck/main.go b/plugins/sdk/cmd/manifestcheck/main.go new file mode 100644 index 00000000..929b24d4 --- /dev/null +++ b/plugins/sdk/cmd/manifestcheck/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "fmt" + "os" + + "github.com/open-wanderer/wanderer/plugins/sdk/manifestcheck" +) + +func main() { + path := "plugin.json" + if len(os.Args) > 1 { + path = os.Args[1] + } + if err := manifestcheck.PrintFile(os.Stdout, path); err != nil { + fmt.Fprintf(os.Stderr, "manifestcheck: %v\n", err) + os.Exit(1) + } +} diff --git a/plugins/sdk/go.mod b/plugins/sdk/go.mod new file mode 100644 index 00000000..2e30a2fe --- /dev/null +++ b/plugins/sdk/go.mod @@ -0,0 +1,5 @@ +module github.com/open-wanderer/wanderer/plugins/sdk + +go 1.25.0 + +require github.com/extism/go-pdk v1.1.3 diff --git a/plugins/sdk/go.sum b/plugins/sdk/go.sum new file mode 100644 index 00000000..c15d3829 --- /dev/null +++ b/plugins/sdk/go.sum @@ -0,0 +1,2 @@ +github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ= +github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= diff --git a/plugins/sdk/gpx/gpx.go b/plugins/sdk/gpx/gpx.go new file mode 100644 index 00000000..237037d9 --- /dev/null +++ b/plugins/sdk/gpx/gpx.go @@ -0,0 +1,58 @@ +package gpx + +import ( + "bytes" + "encoding/xml" + "fmt" + "strconv" + "time" +) + +type Point struct { + Lat float64 + Lon float64 + Elevation *float64 + Time *time.Time +} + +func Track(creator string, name string, points []Point) ([]byte, error) { + if len(points) == 0 { + return nil, fmt.Errorf("track has no points") + } + if creator == "" { + creator = "wanderer plugin" + } + + var buf bytes.Buffer + buf.WriteString(xml.Header) + buf.WriteString(``) + buf.WriteString("") + buf.WriteString("") + _ = xml.EscapeText(&buf, []byte(name)) + buf.WriteString("") + buf.WriteString("") + for _, point := range points { + buf.WriteString(``) + if point.Elevation != nil { + buf.WriteString("") + buf.WriteString(strconv.FormatFloat(*point.Elevation, 'f', 2, 64)) + buf.WriteString("") + } + if point.Time != nil { + buf.WriteString("") + } + buf.WriteString("") + } + buf.WriteString("") + buf.WriteString("") + buf.WriteString("") + return buf.Bytes(), nil +} diff --git a/plugins/sdk/gpx/gpx_test.go b/plugins/sdk/gpx/gpx_test.go new file mode 100644 index 00000000..e4c75dec --- /dev/null +++ b/plugins/sdk/gpx/gpx_test.go @@ -0,0 +1,39 @@ +package gpx + +import ( + "strings" + "testing" + "time" +) + +func TestTrackEscapesFields(t *testing.T) { + elevation := 123.456 + timestamp := time.Date(2026, 6, 1, 10, 30, 0, 0, time.UTC) + data, err := Track("creator & test", "A & B", []Point{{ + Lat: 46.1, + Lon: 8.2, + Elevation: &elevation, + Time: ×tamp, + }}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + gpx := string(data) + for _, want := range []string{ + `creator="creator & test"`, + "A & B", + `lat="46.10000000" lon="8.20000000"`, + "123.46", + "", + } { + if !strings.Contains(gpx, want) { + t.Fatalf("expected %q in %s", want, gpx) + } + } +} + +func TestTrackRejectsEmptyPoints(t *testing.T) { + if _, err := Track("", "empty", nil); err == nil { + t.Fatal("expected error") + } +} diff --git a/plugins/sdk/host_http.go b/plugins/sdk/host_http.go new file mode 100644 index 00000000..b3b14211 --- /dev/null +++ b/plugins/sdk/host_http.go @@ -0,0 +1,154 @@ +//go:build tinygo + +package sdk + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "strings" + + "github.com/extism/go-pdk" +) + +//go:wasmimport wanderer http_request +func wandererHTTPRequest(uint64) uint64 + +//go:wasmimport wanderer log +func wandererLog(uint64) + +func Log(level LogLevel, message string) { + entry := HostLogEntry{ + Level: level, + Message: message, + } + memory, err := pdk.AllocateJSON(entry) + if err != nil { + return + } + defer memory.Free() + wandererLog(memory.Offset()) +} + +func LogDebug(message string) { + Log(LogLevelDebug, message) +} + +func LogInfo(message string) { + Log(LogLevelInfo, message) +} + +func LogWarn(message string) { + Log(LogLevelWarn, message) +} + +func LogError(message string) { + Log(LogLevelError, message) +} + +func HostRequest(spec HostRequestSpec) (HostResponse, []byte, error) { + requestMemory, err := pdk.AllocateJSON(spec) + if err != nil { + return HostResponse{}, nil, err + } + defer requestMemory.Free() + + responsePointer := wandererHTTPRequest(requestMemory.Offset()) + if responsePointer == 0 { + return HostResponse{}, nil, fmt.Errorf("host http request returned no response") + } + responseMemory := pdk.FindMemory(responsePointer) + var response HostResponse + if err := json.Unmarshal(responseMemory.ReadBytes(), &response); err != nil { + return HostResponse{}, nil, err + } + if response.Error != nil { + return response, nil, fmt.Errorf("%s: %s", response.Error.Code, response.Error.Message) + } + body, err := base64.StdEncoding.DecodeString(response.BodyBase64) + if err != nil { + return response, nil, err + } + return response, body, nil +} + +func ConnectorRequest(method string, connector string, path string, query []QueryParam, headers map[string]string, expect ResponseExpect) (HostResponse, []byte, error) { + return HostRequest(HostRequestSpec{ + Method: method, + Target: RequestTarget{ + Type: "connector", + Connector: connector, + Path: path, + Query: query, + }, + Headers: headers, + Expect: expect, + }) +} + +func Get(connector string, path string, query []QueryParam, headers map[string]string, expect ResponseExpect) (HostResponse, []byte, error) { + return ConnectorRequest("GET", connector, path, query, headers, expect) +} + +func PostJSON(connector string, path string, query []QueryParam, headers map[string]string, body any, expect ResponseExpect) (HostResponse, []byte, error) { + return HostRequest(HostRequestSpec{ + Method: "POST", + Target: RequestTarget{ + Type: "connector", + Connector: connector, + Path: path, + Query: query, + }, + Headers: headers, + Body: &HostRequestBody{ + Type: HostRequestBodyTypeJSON, + JSON: body, + }, + Expect: expect, + }) +} + +func PostForm(connector string, path string, query []QueryParam, headers map[string]string, form []FormField, expect ResponseExpect) (HostResponse, []byte, error) { + return HostRequest(HostRequestSpec{ + Method: "POST", + Target: RequestTarget{ + Type: "connector", + Connector: connector, + Path: path, + Query: query, + }, + Headers: headers, + Body: &HostRequestBody{ + Type: HostRequestBodyTypeForm, + Form: form, + }, + Expect: expect, + }) +} + +func (r HostResponse) FirstHeader(name string) string { + values := r.HeaderValuesFor(name) + if len(values) == 0 { + return "" + } + return values[0] +} + +func (r HostResponse) HeaderValuesFor(name string) []string { + if r.HeaderValues == nil { + return nil + } + if values, ok := r.HeaderValues[name]; ok { + return values + } + for key, values := range r.HeaderValues { + if strings.EqualFold(key, name) { + return values + } + } + return nil +} + +func Bool(value bool) *bool { + return &value +} diff --git a/plugins/sdk/manifestcheck/manifestcheck.go b/plugins/sdk/manifestcheck/manifestcheck.go new file mode 100644 index 00000000..8ae79d35 --- /dev/null +++ b/plugins/sdk/manifestcheck/manifestcheck.go @@ -0,0 +1,26 @@ +package manifestcheck + +import ( + "encoding/json" + "fmt" + "io" + "os" +) + +func PrintFile(w io.Writer, path string) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + + var manifest map[string]any + if err := json.Unmarshal(data, &manifest); err != nil { + return err + } + encoded, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return err + } + _, err = fmt.Fprintln(w, string(encoded)) + return err +} diff --git a/plugins/sdk/polyline/polyline.go b/plugins/sdk/polyline/polyline.go new file mode 100644 index 00000000..4fa3943b --- /dev/null +++ b/plugins/sdk/polyline/polyline.go @@ -0,0 +1,125 @@ +package polyline + +import ( + "fmt" + "math" +) + +func Decode(encoded string, precision float64) ([][2]float64, error) { + if precision == 0 { + return nil, fmt.Errorf("precision must not be zero") + } + var coords [][2]float64 + index := 0 + lat := 0 + lon := 0 + for index < len(encoded) { + dlat, next, err := decodeValue(encoded, index) + if err != nil { + return nil, err + } + index = next + dlon, next, err := decodeValue(encoded, index) + if err != nil { + return nil, err + } + index = next + lat += dlat + lon += dlon + coords = append(coords, [2]float64{float64(lat) / precision, float64(lon) / precision}) + } + return coords, nil +} + +func DecodeValues(encoded string, precision float64) ([]float64, error) { + if precision == 0 { + return nil, fmt.Errorf("precision must not be zero") + } + var values []float64 + index := 0 + value := 0 + for index < len(encoded) { + delta, next, err := decodeValue(encoded, index) + if err != nil { + return nil, err + } + index = next + value += delta + values = append(values, float64(value)/precision) + } + return values, nil +} + +func NormalizeCoordinateScale(coords [][2]float64) { + if len(coords) == 0 { + return + } + maxLat := 0.0 + maxLon := 0.0 + for _, coord := range coords { + if abs := math.Abs(coord[0]); abs > maxLat { + maxLat = abs + } + if abs := math.Abs(coord[1]); abs > maxLon { + maxLon = abs + } + } + for (maxLat > 90 || maxLon > 180) && maxLat > 0 && maxLon > 0 { + for i := range coords { + coords[i][0] /= 10 + coords[i][1] /= 10 + } + maxLat /= 10 + maxLon /= 10 + } +} + +func ShouldSwapCoordinates(coords [][2]float64) bool { + validAsLat := 0 + validAsLon := 0 + for _, coord := range coords { + if validLatLon(coord[0], coord[1]) { + validAsLat++ + } + if validLatLon(coord[1], coord[0]) { + validAsLon++ + } + } + return validAsLon > validAsLat +} + +func ProportionalIndex(i int, sourceLen int, targetLen int) int { + if targetLen <= 1 || sourceLen <= 1 { + return 0 + } + j := int(math.Round(float64(i) * float64(targetLen-1) / float64(sourceLen-1))) + if j < 0 { + return 0 + } + if j >= targetLen { + return targetLen - 1 + } + return j +} + +func validLatLon(lat float64, lon float64) bool { + return lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180 +} + +func decodeValue(encoded string, index int) (int, int, error) { + result := 0 + shift := uint(0) + for { + if index >= len(encoded) { + return 0, index, fmt.Errorf("invalid polyline encoding") + } + b := int(encoded[index]) - 63 + index++ + result |= (b & 0x1F) << shift + shift += 5 + if b < 0x20 { + break + } + } + return (result >> 1) ^ (-(result & 1)), index, nil +} diff --git a/plugins/sdk/polyline/polyline_test.go b/plugins/sdk/polyline/polyline_test.go new file mode 100644 index 00000000..d27f7a02 --- /dev/null +++ b/plugins/sdk/polyline/polyline_test.go @@ -0,0 +1,40 @@ +package polyline + +import "testing" + +func TestDecode(t *testing.T) { + points, err := Decode("_p~iF~ps|U_ulLnnqC_mqNvxq`@", 1e5) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(points) != 3 { + t.Fatalf("expected 3 points, got %d", len(points)) + } + if points[0][0] != 38.5 || points[0][1] != -120.2 { + t.Fatalf("unexpected first point: %#v", points[0]) + } +} + +func TestNormalizeCoordinateScale(t *testing.T) { + points := [][2]float64{{385, -1202}} + NormalizeCoordinateScale(points) + if points[0][0] != 38.5 || points[0][1] != -120.2 { + t.Fatalf("expected normalized point, got %#v", points[0]) + } +} + +func TestShouldSwapCoordinates(t *testing.T) { + coords := [][2]float64{{120.2, 38.5}, {121.0, 39.0}} + if !ShouldSwapCoordinates(coords) { + t.Fatal("expected coordinates to be detected as swapped") + } +} + +func TestProportionalIndex(t *testing.T) { + if got := ProportionalIndex(2, 5, 3); got != 1 { + t.Fatalf("got %d, want 1", got) + } + if got := ProportionalIndex(4, 5, 3); got != 2 { + t.Fatalf("got %d, want 2", got) + } +} diff --git a/plugins/sdk/sync.go b/plugins/sdk/sync.go new file mode 100644 index 00000000..33948bfc --- /dev/null +++ b/plugins/sdk/sync.go @@ -0,0 +1,83 @@ +package sdk + +import ( + "encoding/json" + "strconv" + "strings" +) + +func StringField(values map[string]any, key string) string { + value, _ := values[key].(string) + return strings.TrimSpace(value) +} + +func StringOption(options map[string]any, key string) string { + return StringField(options, key) +} + +func IntOption(options map[string]any, key string, fallback int) int { + return intValue(options, key, fallback) +} + +func BoolOption(options map[string]any, key string, fallback bool) bool { + return boolValue(options, key, fallback) +} + +func IntState(state map[string]any, key string, fallback int) int { + return intValue(state, key, fallback) +} + +func intValue(values map[string]any, key string, fallback int) int { + switch value := values[key].(type) { + case float64: + return int(value) + case int: + return value + case json.Number: + parsed, err := value.Int64() + if err == nil { + return int(parsed) + } + case string: + parsed, err := strconv.Atoi(value) + if err == nil { + return parsed + } + } + return fallback +} + +func boolValue(values map[string]any, key string, fallback bool) bool { + switch value := values[key].(type) { + case bool: + return value + case string: + parsed, err := strconv.ParseBool(strings.TrimSpace(value)) + if err == nil { + return parsed + } + } + return fallback +} + +func KnownIDs(ids []string) map[string]bool { + known := make(map[string]bool, len(ids)) + for _, id := range ids { + known[id] = true + } + return known +} + +func SyncLimit(input ListInput) int { + if input.Limits.MaxItems > 0 { + return input.Limits.MaxItems + } + return 10 +} + +func NextPageState(nextPage int, hasMore bool) map[string]any { + if !hasMore { + return nil + } + return map[string]any{"page": nextPage} +} diff --git a/plugins/sdk/types.go b/plugins/sdk/types.go new file mode 100644 index 00000000..d1c5b493 --- /dev/null +++ b/plugins/sdk/types.go @@ -0,0 +1,209 @@ +package sdk + +const ( + HostRequestBodyTypeJSON = "json" + HostRequestBodyTypeForm = "form" + HostRequestBodyTypeMultipart = "multipart" + MultipartSourceTrail = "trail" + MultipartSourceTrailGPX = "trail.gpx" + + AuthHeaderAuthorization = "Authorization" + AuthSchemeBearer = "Bearer" +) + +type HostRequestSpec struct { + Method string `json:"method"` + Target RequestTarget `json:"target"` + Auth string `json:"auth,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + Body *HostRequestBody `json:"body,omitempty"` + Expect ResponseExpect `json:"expect,omitempty"` + FollowRedirects *bool `json:"followRedirects,omitempty"` +} + +type RequestTarget struct { + Type string `json:"type"` + Connector string `json:"connector,omitempty"` + Path string `json:"path,omitempty"` + Query []QueryParam `json:"query,omitempty"` +} + +type QueryParam struct { + Name string `json:"name"` + Value string `json:"value"` +} + +type HostRequestBody struct { + Type string `json:"type"` + JSON any `json:"json,omitempty"` + Form []FormField `json:"form,omitempty"` + Parts []MultipartPart `json:"parts,omitempty"` +} + +type FormField struct { + Name string `json:"name"` + Value string `json:"value"` +} + +type MultipartPart struct { + Name string `json:"name"` + Source string `json:"source,omitempty"` + Filename string `json:"filename,omitempty"` + ContentType string `json:"contentType,omitempty"` + JSON any `json:"json,omitempty"` +} + +type ResponseExpect struct { + ContentTypes []string `json:"contentTypes,omitempty"` + MaxBytes int64 `json:"maxBytes,omitempty"` +} + +type HostResponse struct { + Status int `json:"status"` + HeaderValues map[string][]string `json:"headerValues,omitempty"` + BodyBase64 string `json:"bodyBase64,omitempty"` + Error *PluginError `json:"error,omitempty"` +} + +type PluginError struct { + Code string `json:"code"` + Message string `json:"message,omitempty"` +} + +type LogLevel string + +const ( + LogLevelDebug LogLevel = "debug" + LogLevelInfo LogLevel = "info" + LogLevelWarn LogLevel = "warn" + LogLevelError LogLevel = "error" +) + +type HostLogEntry struct { + Level LogLevel `json:"level"` + Message string `json:"message"` +} + +type InstanceRef struct { + ID string `json:"id"` + PluginID string `json:"pluginId"` +} + +type RefreshSessionInput struct { + Instance InstanceRef `json:"instance"` + Auth map[string]any `json:"auth,omitempty"` + Config map[string]any `json:"config,omitempty"` +} + +type RefreshSessionOutput struct { + Token string `json:"token"` + Scheme string `json:"scheme,omitempty"` + ExpiresAt string `json:"expiresAt,omitempty"` +} + +type SyncLimits struct { + MaxItems int `json:"maxItems,omitempty"` +} + +type ListInput struct { + Instance 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 SyncLimits `json:"limits,omitempty"` +} + +type ListOutput struct { + Items []TrailSummary `json:"items"` + State map[string]any `json:"state,omitempty"` + HasMore bool `json:"hasMore"` + Error *PluginError `json:"error,omitempty"` +} + +type DetailInput struct { + Instance InstanceRef `json:"instance"` + Auth map[string]any `json:"auth,omitempty"` + Options map[string]any `json:"options,omitempty"` + Summary TrailSummary `json:"summary"` +} + +type DetailOutput struct { + Item TrailImport `json:"item"` + Error *PluginError `json:"error,omitempty"` +} + +type TrailSummary struct { + Source TrailImportSource `json:"source"` + Kind string `json:"kind,omitempty"` +} + +type TrailImport struct { + Source TrailImportSource `json:"source"` + Kind string `json:"kind,omitempty"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + StartedAt string `json:"startedAt,omitempty"` + ActivityType string `json:"activityType,omitempty"` + Privacy *string `json:"privacy,omitempty"` + Track Track `json:"track"` + Waypoints []Waypoint `json:"waypoints,omitempty"` + Photos []Photo `json:"photos,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type TrailImportSource struct { + Provider string `json:"provider"` + ExternalID string `json:"externalId"` + URL string `json:"url,omitempty"` +} + +type Track struct { + Format string `json:"format"` + ContentBase64 string `json:"contentBase64"` +} + +type Waypoint struct { + ExternalID string `json:"externalId,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + Ele *float64 `json:"ele,omitempty"` + Icon string `json:"icon,omitempty"` + Photos []Photo `json:"photos,omitempty"` +} + +type Photo struct { + ExternalID string `json:"externalId,omitempty"` + Filename string `json:"filename,omitempty"` + ContentType string `json:"contentType,omitempty"` + Lat *float64 `json:"lat,omitempty"` + Lon *float64 `json:"lon,omitempty"` + Source MediaSource `json:"source"` +} + +type MediaSource struct { + Type string `json:"type"` + URL string `json:"url,omitempty"` + MediaRef *MediaRef `json:"mediaRef,omitempty"` +} + +type MediaRef struct { + Connector string `json:"connector"` + Auth string `json:"auth,omitempty"` + Path string `json:"path,omitempty"` + Query []QueryParam `json:"query,omitempty"` + AssetID string `json:"assetId,omitempty"` +} + +type TrailSendInput struct { + Instance InstanceRef `json:"instance"` + Auth map[string]any `json:"auth,omitempty"` + Config map[string]any `json:"config,omitempty"` + Name string `json:"name,omitempty"` + Trail Track `json:"trail"` +} + +type TrailSendPlan struct { + Request HostRequestSpec `json:"request"` +} diff --git a/plugins/strava/Makefile b/plugins/strava/Makefile new file mode 100644 index 00000000..701ca2e3 --- /dev/null +++ b/plugins/strava/Makefile @@ -0,0 +1,15 @@ +PLUGIN_ID := strava +DIST_DIR := dist/$(PLUGIN_ID) + +.PHONY: build manifest clean + +build: manifest + tinygo build -target=wasi -scheduler=none -no-debug -o $(DIST_DIR)/plugin.wasm . + +manifest: + mkdir -p $(DIST_DIR) + go run github.com/open-wanderer/wanderer/plugins/sdk/cmd/manifestcheck > $(DIST_DIR)/plugin.json + cp assets/icon.svg $(DIST_DIR)/icon.svg + +clean: + rm -rf dist diff --git a/plugins/strava/README.md b/plugins/strava/README.md new file mode 100644 index 00000000..2e2de356 --- /dev/null +++ b/plugins/strava/README.md @@ -0,0 +1,11 @@ +# wanderer Strava WASM Plugin + +Strava provider for the wanderer WASM plugin system. + +```sh +make build +``` + +The build output is written to `dist/strava`. Copy it below `data/plugins` or +run `make plugins-install-local` from the repository root to install all bundled +plugins locally. diff --git a/plugins/strava/assets/icon.svg b/plugins/strava/assets/icon.svg new file mode 100644 index 00000000..29b28cbe --- /dev/null +++ b/plugins/strava/assets/icon.svg @@ -0,0 +1,3 @@ + diff --git a/plugins/strava/go.mod b/plugins/strava/go.mod new file mode 100644 index 00000000..d731faeb --- /dev/null +++ b/plugins/strava/go.mod @@ -0,0 +1,9 @@ +module github.com/open-wanderer/wanderer/plugins/strava + +go 1.25.0 + +require github.com/extism/go-pdk v1.1.3 + +require github.com/open-wanderer/wanderer/plugins/sdk v0.0.0 + +replace github.com/open-wanderer/wanderer/plugins/sdk => ../sdk diff --git a/plugins/strava/go.sum b/plugins/strava/go.sum new file mode 100644 index 00000000..c15d3829 --- /dev/null +++ b/plugins/strava/go.sum @@ -0,0 +1,2 @@ +github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ= +github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= diff --git a/plugins/strava/main.go b/plugins/strava/main.go new file mode 100644 index 00000000..04bea045 --- /dev/null +++ b/plugins/strava/main.go @@ -0,0 +1,126 @@ +//go:build tinygo + +package main + +import ( + "encoding/json" + "strconv" + + "github.com/extism/go-pdk" +) + +func main() {} + +//export list_routes_v1 +func listRoutesV1() int32 { + var input listInput + if err := pdk.InputJSON(&input); err != nil { + return fail("invalid_request", "invalid list_routes input: "+err.Error()) + } + client, err := newClient(input.Auth) + if err != nil { + return fail("auth_failed", err.Error()) + } + output, err := syncRoutes(client, input) + if err != nil { + return fail("provider_unavailable", err.Error()) + } + if err := pdk.OutputJSON(output); err != nil { + return fail("internal_error", err.Error()) + } + return 0 +} + +//export list_activities_v1 +func listActivitiesV1() int32 { + var input listInput + if err := pdk.InputJSON(&input); err != nil { + return fail("invalid_request", "invalid list_activities input: "+err.Error()) + } + client, err := newClient(input.Auth) + if err != nil { + return fail("auth_failed", err.Error()) + } + output, err := syncActivities(client, input) + if err != nil { + return fail("provider_unavailable", err.Error()) + } + if err := pdk.OutputJSON(output); err != nil { + return fail("internal_error", err.Error()) + } + return 0 +} + +//export get_route_detail_v1 +func getRouteDetailV1() int32 { + return getTrailDetail("planned") +} + +//export get_activity_detail_v1 +func getActivityDetailV1() int32 { + return getTrailDetail("completed") +} + +func getTrailDetail(kind string) int32 { + var input detailInput + if err := pdk.InputJSON(&input); err != nil { + return fail("invalid_request", "invalid detail input: "+err.Error()) + } + client, err := newClient(input.Auth) + if err != nil { + return fail("auth_failed", err.Error()) + } + var item trailImport + switch kind { + case "planned": + route, err := client.route(input.Summary.Source.ExternalID) + if err != nil { + return fail("provider_unavailable", err.Error()) + } + gpxData, err := client.routeGPX(input.Summary.Source.ExternalID) + if err != nil { + return fail("provider_unavailable", err.Error()) + } + item, err = routeImport(*route, gpxData) + if err != nil { + return fail("provider_unavailable", err.Error()) + } + case "completed": + id, err := strconv.ParseInt(input.Summary.Source.ExternalID, 10, 64) + if err != nil { + return fail("invalid_request", "invalid activity external id") + } + detail, err := client.activity(id) + if err != nil { + return fail("provider_unavailable", err.Error()) + } + var photos []activityPhoto + if detail.Photos.Count > 0 { + photos, _ = client.activityPhotos(id) + } + streams, err := client.activityStreams(id) + if err != nil { + return fail("provider_unavailable", err.Error()) + } + item, err = activityImport(detail, streams, photos) + if err != nil { + return fail("provider_unavailable", err.Error()) + } + default: + return fail("invalid_request", "unsupported detail kind") + } + if err := pdk.OutputJSON(detailOutput{Item: item}); err != nil { + return fail("internal_error", err.Error()) + } + return 0 +} + +func fail(code string, message string) int32 { + data, err := json.Marshal(pluginError{Code: code, Message: message}) + if err != nil { + pdk.SetErrorString(message) + return 1 + } + pdk.SetErrorString(string(data)) + return 1 +} diff --git a/plugins/strava/mapper.go b/plugins/strava/mapper.go new file mode 100644 index 00000000..3ef2445f --- /dev/null +++ b/plugins/strava/mapper.go @@ -0,0 +1,228 @@ +//go:build tinygo + +package main + +import ( + "encoding/base64" + "fmt" + "strconv" + "strings" + "time" + + sdkgpx "github.com/open-wanderer/wanderer/plugins/sdk/gpx" +) + +func routeImport(route route, gpxData []byte) (trailImport, error) { + if len(gpxData) == 0 { + return trailImport{}, fmt.Errorf("route GPX is empty") + } + privacy := privacyFromPrivate(route.Private) + startedAt := time.Unix(route.Timestamp, 0).UTC().Format(time.RFC3339) + return trailImport{ + Source: trailImportSource{ + Provider: "strava", + ExternalID: route.IDStr, + }, + Kind: "planned", + Name: route.Name, + Description: route.Description, + StartedAt: startedAt, + ActivityType: activityTypeForRoute(route.Type), + Privacy: &privacy, + Track: track{ + Format: "gpx", + ContentBase64: base64.StdEncoding.EncodeToString(gpxData), + }, + Waypoints: routeWaypoints(route), + Metadata: map[string]any{ + "distance": route.Distance, + "elevationGain": route.ElevationGain, + "duration": route.EstimatedMovingTime, + "providerCategory": routeCategory(route.Type), + "estimatedMovingTime": route.EstimatedMovingTime, + }, + }, nil +} + +func activityImport(activity *detailedActivity, streams *activityStreamResponse, photos []activityPhoto) (trailImport, error) { + if len(activity.StartLatlng) < 2 { + return trailImport{}, fmt.Errorf("activity has no start coordinate") + } + gpxData, err := activityGPX(activity, streams) + if err != nil { + return trailImport{}, err + } + privacy := privacyFromPrivate(activity.Private) + return trailImport{ + Source: trailImportSource{ + Provider: "strava", + ExternalID: strconv.FormatInt(activity.ID, 10), + }, + Kind: "completed", + Name: activity.Name, + Description: activity.Description, + StartedAt: activity.StartDate, + ActivityType: activityType(activity), + Privacy: &privacy, + Track: track{ + Format: "gpx", + ContentBase64: base64.StdEncoding.EncodeToString(gpxData), + }, + Photos: activityPhotos(activity, photos), + Metadata: map[string]any{ + "distance": activity.Distance, + "elevationGain": activity.TotalElevationGain, + "duration": activity.ElapsedTime, + "providerCategory": providerActivityType(activity), + }, + }, nil +} + +func routeWaypoints(route route) []waypoint { + points := make([]waypoint, 0, len(route.Waypoints)) + for i, wp := range route.Waypoints { + if len(wp.Latlng) < 2 { + continue + } + name := wp.Title + if name == "" { + name = strconv.Itoa(i) + } + points = append(points, waypoint{ + Name: name, + Description: wp.Description, + Lat: wp.Latlng[0], + Lon: wp.Latlng[1], + Icon: "circle", + }) + } + return points +} + +func activityPhotos(activity *detailedActivity, apiPhotos []activityPhoto) []photo { + photos := make([]photo, 0, len(apiPhotos)) + seen := make(map[string]bool, len(apiPhotos)) + for _, apiPhoto := range apiPhotos { + url := apiPhoto.Urls.Num600 + if url == "" { + url = apiPhoto.Urls.Num100 + } + if url == "" { + continue + } + externalID := apiPhoto.UniqueID + if externalID == "" { + externalID = url + } + if seen[externalID] { + continue + } + seen[externalID] = true + photos = append(photos, photo{ + ExternalID: externalID, + Filename: fmt.Sprintf("strava-%s.jpg", safePhotoID(externalID)), + Source: mediaSource{ + Type: "url", + URL: url, + }, + }) + } + if len(photos) > 0 { + return photos + } + if activity.Photos.Primary.Urls.Num600 == "" { + return nil + } + externalID := strconv.FormatInt(activity.Photos.Primary.ID, 10) + return []photo{{ + ExternalID: externalID, + Filename: fmt.Sprintf("strava-%s.jpg", externalID), + Source: mediaSource{ + Type: "url", + URL: activity.Photos.Primary.Urls.Num600, + }, + }} +} + +func safePhotoID(value string) string { + replacer := strings.NewReplacer("/", "-", "\\", "-", ":", "-", "?", "-", "&", "-", "=", "-") + return replacer.Replace(value) +} + +func activityGPX(activity *detailedActivity, streams *activityStreamResponse) ([]byte, error) { + if streams == nil || len(streams.LatLng.Data) == 0 { + return nil, fmt.Errorf("activity has no latlng stream") + } + startedAt, _ := time.Parse(time.RFC3339, activity.StartDate) + + points := make([]sdkgpx.Point, 0, len(streams.LatLng.Data)) + for i, latlng := range streams.LatLng.Data { + if len(latlng) < 2 || i >= len(streams.Time.Data) { + continue + } + elevation := 0.0 + if i < len(streams.Altitude.Data) { + elevation = streams.Altitude.Data[i] + } + point := sdkgpx.Point{ + Lat: latlng[0], + Lon: latlng[1], + Elevation: &elevation, + } + if !startedAt.IsZero() { + pointTime := startedAt.Add(time.Duration(streams.Time.Data[i]) * time.Second).UTC() + point.Time = &pointTime + } + points = append(points, point) + } + return sdkgpx.Track("wanderer Strava plugin", activity.Name, points) +} + +func privacyFromPrivate(private bool) string { + if private { + return "private" + } + return "public" +} + +func activityTypeForRoute(routeType int) string { + switch routeType { + case 1: + return "biking" + case 2: + return "walking" + default: + return "" + } +} + +func routeCategory(routeType int) string { + return fmt.Sprintf("route:%d", routeType) +} + +func providerActivityType(activity *detailedActivity) string { + if activity.SportType != "" { + return activity.SportType + } + return activity.Type +} + +func activityType(activity *detailedActivity) string { + value := providerActivityType(activity) + switch value { + case "AlpineSki", "BackcountrySki", "IceSkate", "NordicSki", "RollerSki", "Snowboard": + return "skiing" + case "Canoeing", "Kayaking", "Kitesurf", "Rowing", "Sail", "StandUpPaddling", "Surfing", "Windsurf": + return "canoeing" + case "Hike", "Snowshoe": + return "hiking" + case "Run", "VirtualRun", "Walk", "Golf", "Skateboard", "Wheelchair": + return "walking" + case "Ride", "EBikeRide", "Handcycle", "InlineSkate", "Velomobile", "VirtualRide": + return "biking" + case "RockClimbing": + return "climbing" + default: + return value + } +} diff --git a/plugins/strava/options.go b/plugins/strava/options.go new file mode 100644 index 00000000..76ce8274 --- /dev/null +++ b/plugins/strava/options.go @@ -0,0 +1,40 @@ +//go:build tinygo + +package main + +import ( + "strings" + "time" +) + +func dateOption(options map[string]any, key string) string { + value, _ := options[key].(string) + return strings.TrimSpace(value) +} + +func unixAfter(options map[string]any) int64 { + after := dateOption(options, "after") + if after == "" { + return 0 + } + parsed, err := time.Parse("2006-01-02", after) + if err != nil { + return 0 + } + return parsed.UTC().Unix() +} + +func timeAfterDate(value string, after string) bool { + if after == "" { + return true + } + limit, err := time.Parse("2006-01-02", after) + if err != nil { + return true + } + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + return true + } + return !parsed.Before(limit) +} diff --git a/plugins/strava/plugin.json b/plugins/strava/plugin.json new file mode 100644 index 00000000..e05c1cfa --- /dev/null +++ b/plugins/strava/plugin.json @@ -0,0 +1,541 @@ +{ + "manifestVersion": "1.0", + "id": "strava", + "type": "trails", + "name": "Strava", + "description": "Imports Strava routes, activities and photos into wanderer.", + "version": "0.1.0", + "runtime": { + "type": "wasm", + "entrypoint": "plugin.wasm" + }, + "capabilities": [ + { + "name": "list_routes", + "version": "v1", + "export": "list_routes_v1" + }, + { + "name": "get_route_detail", + "version": "v1", + "export": "get_route_detail_v1" + }, + { + "name": "list_activities", + "version": "v1", + "export": "list_activities_v1" + }, + { + "name": "get_activity_detail", + "version": "v1", + "export": "get_activity_detail_v1" + } + ], + "auth": { + "contexts": { + "oauth_access_token": { + "type": "oauth2", + "fields": [ + "clientId", + "clientSecret" + ], + "secretFields": [ + "clientSecret", + "accessToken", + "refreshToken" + ], + "authorizationUrl": "https://www.strava.com/oauth/authorize", + "tokenUrl": "https://www.strava.com/oauth/token", + "scopes": [ + "read_all", + "activity:read_all" + ], + "scopeSeparator": ",", + "tokenRequestFormat": "json", + "tokenAuth": "client_secret_post", + "authorizationParams": { + "approval_prompt": "auto" + }, + "refresh": { + "mode": "host", + "grantType": "refresh_token" + } + } + } + }, + "permissions": { + "network": { + "connectors": [ + { + "name": "api", + "type": "public_api", + "fixedBaseURL": "https://www.strava.com/api/v3", + "allowedPathPrefixes": [ + "/" + ], + "auth": [ + "oauth_access_token" + ] + }, + { + "name": "api_next", + "type": "public_api", + "fixedBaseURL": "https://www.api-v3.strava.com", + "allowedPathPrefixes": [ + "/" + ], + "auth": [ + "oauth_access_token" + ] + }, + { + "name": "oauth", + "type": "public_api", + "fixedBaseURL": "https://www.strava.com/oauth", + "allowedPathPrefixes": [ + "/" + ] + } + ] + }, + "auth": [ + "oauth_access_token" + ], + "downloads": { + "maxBytes": 1048576, + "contentTypes": [ + "application/json", + "application/gpx+xml", + "application/xml", + "text/xml", + "application/octet-stream" + ] + } + }, + "configSchema": [ + { + "key": "after", + "type": "date", + "label": "Start date", + "labels": { + "de": "Startdatum", + "en": "Start date" + }, + "description": "Ignore routes and activities before this date.", + "descriptions": { + "cs": "Pokud váš účet obsahuje velké množství aktivit, můžete narazit na limit API služby Strava, což znemožní synchronizaci všech aktivit najednou. Tomuto problému předejdete nastavením data \"Od\" a dále - synchronizují se tak pouze aktivity zaznamenané po tomto datu.", + "de": "Wenn Ihr Konto eine große Anzahl von Aktivitäten enthält, kann es vorkommen, dass Sie aufgrund der API-Restriktionen von Strava nicht alle Aktivitäten auf einmal synchronisieren können. Um dieses Problem zu umgehen, können Sie unten ein Startdatum festlegen, sodass nur Aktivitäten synchronisiert werden, die nach diesem Datum aufgezeichnet wurden.", + "en": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an start date below so that only activities that were recorded after this date are synced.", + "es": "Si tu cuenta tiene una gran cantidad de actividades, es posible que alcances el límite de peticiones de la API de Strava, lo que impedirá la sincronización de todas las actividades a la vez. Para mitigar este problema, puedes establecer una fecha \"Posterior a\" a continuación, de modo que solo se sincronicen las actividades que se registraron después de esa fecha.", + "eu": "Zure kontuak ekintza esko baditu Stravaren APIaren mugekin topo egin dezakezu eta agian ezingo dituzu zure ekintza guztiak aldi berean inportatu. Horretarako data jakin batetik aurrerako ekintzak sinkronizatzeko aukera duzu.", + "fr": "Si votre compte a une grande quantité d'activités, vous pouvez rencontrer la limite d'utilisation de l'API de Strava vous empêchant de synchroniser toutes les activités en même temps. Pour atténuer ce problème, vous pouvez définir une date \"Après-\" ci-dessous afin que seules les activités qui ont été enregistrées après cette date soient synchronisées.", + "it": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced.", + "hu": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced.", + "nl": "Als uw account een grote hoeveelheid activiteiten heeft, kunt u op de API-limiet van Strava botsen voorkomend dat u alle activiteiten tegelijk synchroniseert. Om dit probleem te omzeilen kunt u een \"Later\" datum hieronder instellen, zodat alleen activiteiten die na deze datum werden opgenomen worden gesynchroniseerd.", + "no": "Hvis kontoen din har en stor mengde aktiviteter kan du støte på Stravas API-hastighetsgrense som hindrer deg i å synkronisere alle aktiviteter samtidig. For å redusere dette problemet kan du sette en \"Etter\" dato nedenfor slik at bare aktiviteter som ble registrert etter denne datoen blir synkronisert.", + "pl": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced.", + "pt": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced.", + "ru": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced.", + "zh": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced." + } + } + ], + "hostConfig": { + "categoryMapping": { + "route:1": "Biking", + "route:2": "Walking", + "AlpineSki": "Skiing", + "BackcountrySki": "Skiing", + "Badminton": "Other", + "Canoeing": "Canoeing", + "Crossfit": "Workout", + "EBikeRide": "Biking", + "EMountainBikeRide": "Biking", + "Elliptical": "Workout", + "Golf": "Other", + "GravelRide": "Biking", + "Handcycle": "Biking", + "HighIntensityIntervalTraining": "Other", + "Hike": "Hiking", + "IceSkate": "Skiing", + "InlineSkate": "Walking", + "Kayaking": "Canoeing", + "Kitesurf": "Canoeing", + "MountainBikeRide": "Biking", + "NordicSki": "Skiing", + "Pickleball": "Other", + "Pilates": "Other", + "Racquetball": "Other", + "Ride": "Biking", + "RockClimbing": "Climbing", + "RollerSki": "Skiing", + "Rowing": "Canoeing", + "Run": "Walking", + "Sail": "Canoeing", + "Skateboard": "Walking", + "Snowboard": "Skiing", + "Snowshoe": "Hiking", + "Soccer": "Other", + "Squash": "Other", + "StairStepper": "Workout", + "StandUpPaddling": "Canoeing", + "Surfing": "Canoeing", + "Swim": "Other", + "TableTennis": "Other", + "Tennis": "Other", + "TrailRun": "Other", + "Training": "Other", + "Velomobile": "Biking", + "VirtualRide": "Biking", + "VirtualRow": "Other", + "VirtualRun": "Walking", + "Walk": "Walking", + "WeightTraining": "Workout", + "Wheelchair": "Walking", + "Windsurf": "Canoeing", + "Workout": "Workout", + "Yoga": "Workout" + } + }, + "metadata": { + "descriptions": { + "cs": "Synchronizuje vaše trasy a aktivity z aplikace Strava s Wandererem v pravidelných intervalech.", + "de": "Importiert Strava-Routen, Aktivitäten und Fotos in wanderer.", + "en": "Imports Strava routes, activities and photos into wanderer.", + "es": "Sincroniza tus recorridos y actividades de Strava con Wanderer en intervalos regulares.", + "eu": "Zure stravako ibilbideak wandererekin sinkronizatzen ditu aldian behin.", + "fr": "Synchronisez vos itinéraires et vos activités Strava avec wanderer à intervalles réguliers.", + "it": "Syncs your strava routes & activities with wanderer in regular intervals.", + "hu": "Syncs your strava routes & activities with wanderer in regular intervals.", + "nl": "Synchroniseert je Strava-routes en -activiteiten met Wanderer op regelmatige tijdstippen.", + "no": "Synkroniserer dine Strava-ruter og aktiviteter med Wanderer med jevne mellomrom.", + "pl": "Synchronizuje trasy i aktywność z wanderer w równych odstępach.", + "pt": "Syncs your strava routes & activities with wanderer in regular intervals.", + "ru": "Синхронизирует ваши данные со Strava.", + "zh": "定期与strava同步您的wanderer路线和活动。" + }, + "icons": { + "light": "icon.svg" + }, + "providerCategories": { + "route:1": { + "labels": { + "de": "Strava-Route: Radfahren", + "en": "Strava route: cycling" + } + }, + "route:2": { + "labels": { + "de": "Strava-Route: Laufen", + "en": "Strava route: running" + } + }, + "AlpineSki": { + "labels": { + "de": "Ski alpin", + "en": "Alpine ski" + } + }, + "BackcountrySki": { + "labels": { + "de": "Skitour", + "en": "Backcountry ski" + } + }, + "Badminton": { + "labels": { + "de": "Badminton", + "en": "Badminton" + } + }, + "Canoeing": { + "labels": { + "de": "Kanufahren", + "en": "Canoeing" + } + }, + "Crossfit": { + "labels": { + "de": "Crossfit", + "en": "Crossfit" + } + }, + "EBikeRide": { + "labels": { + "de": "E-Bike-Fahrt", + "en": "E-bike ride" + } + }, + "EMountainBikeRide": { + "labels": { + "de": "E-Mountainbike-Fahrt", + "en": "E-mountain bike ride" + } + }, + "Elliptical": { + "labels": { + "de": "Crosstrainer", + "en": "Elliptical" + } + }, + "Golf": { + "labels": { + "de": "Golf", + "en": "Golf" + } + }, + "GravelRide": { + "labels": { + "de": "Gravel-Fahrt", + "en": "Gravel ride" + } + }, + "Handcycle": { + "labels": { + "de": "Handbike", + "en": "Handcycle" + } + }, + "HighIntensityIntervalTraining": { + "labels": { + "de": "HIIT", + "en": "High-intensity interval training" + } + }, + "Hike": { + "labels": { + "de": "Wandern", + "en": "Hike" + } + }, + "IceSkate": { + "labels": { + "de": "Schlittschuhlaufen", + "en": "Ice skate" + } + }, + "InlineSkate": { + "labels": { + "de": "Inlineskaten", + "en": "Inline skate" + } + }, + "Kayaking": { + "labels": { + "de": "Kajakfahren", + "en": "Kayaking" + } + }, + "Kitesurf": { + "labels": { + "de": "Kitesurfen", + "en": "Kitesurf" + } + }, + "MountainBikeRide": { + "labels": { + "de": "Mountainbike-Fahrt", + "en": "Mountain bike ride" + } + }, + "NordicSki": { + "labels": { + "de": "Langlauf", + "en": "Nordic ski" + } + }, + "Pickleball": { + "labels": { + "de": "Pickleball", + "en": "Pickleball" + } + }, + "Pilates": { + "labels": { + "de": "Pilates", + "en": "Pilates" + } + }, + "Racquetball": { + "labels": { + "de": "Racquetball", + "en": "Racquetball" + } + }, + "Ride": { + "labels": { + "de": "Radfahren", + "en": "Ride" + } + }, + "RockClimbing": { + "labels": { + "de": "Felsklettern", + "en": "Rock climbing" + } + }, + "RollerSki": { + "labels": { + "de": "Rollski", + "en": "Roller ski" + } + }, + "Rowing": { + "labels": { + "de": "Rudern", + "en": "Rowing" + } + }, + "Run": { + "labels": { + "de": "Laufen", + "en": "Run" + } + }, + "Sail": { + "labels": { + "de": "Segeln", + "en": "Sail" + } + }, + "Skateboard": { + "labels": { + "de": "Skateboard", + "en": "Skateboard" + } + }, + "Snowboard": { + "labels": { + "de": "Snowboard", + "en": "Snowboard" + } + }, + "Snowshoe": { + "labels": { + "de": "Schneeschuhwandern", + "en": "Snowshoe" + } + }, + "Soccer": { + "labels": { + "de": "Fussball", + "en": "Soccer" + } + }, + "Squash": { + "labels": { + "de": "Squash", + "en": "Squash" + } + }, + "StairStepper": { + "labels": { + "de": "Stepper", + "en": "Stair stepper" + } + }, + "StandUpPaddling": { + "labels": { + "de": "Stand-up-Paddling", + "en": "Stand-up paddling" + } + }, + "Surfing": { + "labels": { + "de": "Surfen", + "en": "Surfing" + } + }, + "Swim": { + "labels": { + "de": "Schwimmen", + "en": "Swim" + } + }, + "TableTennis": { + "labels": { + "de": "Tischtennis", + "en": "Table tennis" + } + }, + "Tennis": { + "labels": { + "de": "Tennis", + "en": "Tennis" + } + }, + "TrailRun": { + "labels": { + "de": "Trailrun", + "en": "Trail run" + } + }, + "Training": { + "labels": { + "de": "Training", + "en": "Training" + } + }, + "Velomobile": { + "labels": { + "de": "Velomobil", + "en": "Velomobile" + } + }, + "VirtualRide": { + "labels": { + "de": "Virtuelle Radfahrt", + "en": "Virtual ride" + } + }, + "VirtualRow": { + "labels": { + "de": "Virtuelles Rudern", + "en": "Virtual row" + } + }, + "VirtualRun": { + "labels": { + "de": "Virtueller Lauf", + "en": "Virtual run" + } + }, + "Walk": { + "labels": { + "de": "Gehen", + "en": "Walk" + } + }, + "WeightTraining": { + "labels": { + "de": "Krafttraining", + "en": "Weight training" + } + }, + "Wheelchair": { + "labels": { + "de": "Rollstuhl", + "en": "Wheelchair" + } + }, + "Windsurf": { + "labels": { + "de": "Windsurfen", + "en": "Windsurf" + } + }, + "Workout": { + "labels": { + "de": "Training", + "en": "Workout" + } + }, + "Yoga": { + "labels": { + "de": "Yoga", + "en": "Yoga" + } + } + } + } +} diff --git a/plugins/strava/strava.go b/plugins/strava/strava.go new file mode 100644 index 00000000..be049b34 --- /dev/null +++ b/plugins/strava/strava.go @@ -0,0 +1,199 @@ +//go:build tinygo + +package main + +import ( + "encoding/json" + "fmt" + "net/url" + "strconv" + "time" + + "github.com/open-wanderer/wanderer/plugins/sdk" +) + +// Strava is migrating its API host: the new host "https://www.api-v3.strava.com" +// is available from 2027-01-04 and the old one is retired on 2027-06-01 (June +// 2026 Developer Program update). We cut over on 2027-03-01 — after the new host +// has had time to stabilize, well before the old one disappears — so no manual +// change or release is needed at the deadline. +func stravaConnector() string { + return pickStravaConnector(time.Now()) +} + +func pickStravaConnector(now time.Time) string { + cutover := time.Date(2027, 3, 1, 0, 0, 0, 0, time.UTC) + if now.Before(cutover) { + return "api" + } + return "api_next" +} + +type stravaClient struct { + accessToken string +} + +func newClient(auth map[string]any) (*stravaClient, error) { + token := sdk.StringField(auth, "accessToken") + if token == "" { + return nil, fmt.Errorf("accessToken is required") + } + return &stravaClient{accessToken: token}, nil +} + +func (c *stravaClient) routes(page int, perPage int) ([]route, error) { + var routes []route + err := c.getJSON("/athlete/routes", []sdk.QueryParam{ + {Name: "page", Value: strconv.Itoa(page)}, + {Name: "per_page", Value: strconv.Itoa(perPage)}, + }, &routes) + return routes, err +} + +func (c *stravaClient) route(id string) (*route, error) { + var route route + err := c.getJSON("/routes/"+url.PathEscape(id), nil, &route) + return &route, err +} + +func (c *stravaClient) routeGPX(id string) ([]byte, error) { + return c.getBytes("/routes/" + url.PathEscape(id) + "/export_gpx") +} + +func (c *stravaClient) activities(page int, perPage int, after int64) ([]activity, error) { + var activities []activity + err := c.getJSON("/athlete/activities", []sdk.QueryParam{ + {Name: "page", Value: strconv.Itoa(page)}, + {Name: "per_page", Value: strconv.Itoa(perPage)}, + {Name: "after", Value: strconv.FormatInt(after, 10)}, + }, &activities) + return activities, err +} + +func (c *stravaClient) activity(id int64) (*detailedActivity, error) { + var activity detailedActivity + err := c.getJSON(fmt.Sprintf("/activities/%d", id), nil, &activity) + return &activity, err +} + +func (c *stravaClient) activityStreams(id int64) (*activityStreamResponse, error) { + var streams activityStreamResponse + err := c.getJSON(fmt.Sprintf("/activities/%d/streams", id), []sdk.QueryParam{ + {Name: "keys", Value: "latlng,time,altitude"}, + {Name: "key_by_type", Value: "true"}, + }, &streams) + return &streams, err +} + +func (c *stravaClient) activityPhotos(id int64) ([]activityPhoto, error) { + var photos []activityPhoto + err := c.getJSON(fmt.Sprintf("/activities/%d/photos", id), []sdk.QueryParam{{Name: "size", Value: "600"}}, &photos) + return photos, err +} + +func (c *stravaClient) getJSON(path string, query []sdk.QueryParam, out any) error { + response, body, err := c.request(path, query, []string{"application/json"}) + if err != nil { + return err + } + if response.Status < 200 || response.Status >= 300 { + return fmt.Errorf("strava request failed (%d): %s", response.Status, string(body)) + } + return json.Unmarshal(body, out) +} + +func (c *stravaClient) getBytes(path string) ([]byte, error) { + response, body, err := c.request(path, nil, []string{"application/gpx+xml", "application/octet-stream", "text/xml", "application/xml"}) + if err != nil { + return nil, err + } + if response.Status < 200 || response.Status >= 300 { + return nil, fmt.Errorf("strava request failed (%d): %s", response.Status, string(body)) + } + return body, nil +} + +func (c *stravaClient) request(path string, query []sdk.QueryParam, contentTypes []string) (sdk.HostResponse, []byte, error) { + accept := "application/json" + if len(contentTypes) > 0 { + accept = contentTypes[0] + } + return sdk.HostRequest(sdk.HostRequestSpec{ + Method: "GET", + Target: sdk.RequestTarget{ + Type: "connector", + Connector: stravaConnector(), + Path: path, + Query: query, + }, + Headers: map[string]string{ + sdk.AuthHeaderAuthorization: sdk.AuthSchemeBearer + " " + c.accessToken, + "Accept": accept, + }, + Expect: sdk.ResponseExpect{ + ContentTypes: contentTypes, + MaxBytes: 1048576, + }, + }) +} + +func syncRoutes(client *stravaClient, input listInput) (listOutput, error) { + page := sdk.IntState(input.State, "page", 1) + if page <= 0 { + page = 1 + } + rows, err := client.routes(page, sdk.SyncLimit(input)) + if err != nil { + return listOutput{}, err + } + after := dateOption(input.Options, "after") + items := make([]trailSummary, 0, sdk.SyncLimit(input)) + for _, row := range rows { + if !timeAfterDate(row.CreatedAt, after) { + continue + } + items = append(items, trailSummary{ + Source: trailImportSource{Provider: "strava", ExternalID: row.IDStr}, + Kind: "planned", + }) + if len(items) >= sdk.SyncLimit(input) { + break + } + } + nextPage := page + 1 + hasMore := len(rows) >= sdk.SyncLimit(input) + return listOutput{ + Items: items, + State: sdk.NextPageState(nextPage, hasMore), + HasMore: hasMore, + }, nil +} + +func syncActivities(client *stravaClient, input listInput) (listOutput, error) { + page := sdk.IntState(input.State, "page", 1) + if page <= 0 { + page = 1 + } + rows, err := client.activities(page, sdk.SyncLimit(input), unixAfter(input.Options)) + if err != nil { + return listOutput{}, err + } + items := make([]trailSummary, 0, sdk.SyncLimit(input)) + for _, row := range rows { + externalID := strconv.FormatInt(row.ID, 10) + items = append(items, trailSummary{ + Source: trailImportSource{Provider: "strava", ExternalID: externalID}, + Kind: "completed", + }) + if len(items) >= sdk.SyncLimit(input) { + break + } + } + nextPage := page + 1 + hasMore := len(rows) >= sdk.SyncLimit(input) + return listOutput{ + Items: items, + State: sdk.NextPageState(nextPage, hasMore), + HasMore: hasMore, + }, nil +} diff --git a/plugins/strava/types.go b/plugins/strava/types.go new file mode 100644 index 00000000..94759de0 --- /dev/null +++ b/plugins/strava/types.go @@ -0,0 +1,95 @@ +package main + +import "github.com/open-wanderer/wanderer/plugins/sdk" + +type instanceRef = sdk.InstanceRef +type listInput = sdk.ListInput +type listOutput = sdk.ListOutput +type detailInput = sdk.DetailInput +type detailOutput = sdk.DetailOutput +type trailSummary = sdk.TrailSummary +type trailImport = sdk.TrailImport +type trailImportSource = sdk.TrailImportSource +type track = sdk.Track +type waypoint = sdk.Waypoint +type photo = sdk.Photo +type mediaSource = sdk.MediaSource + +type pluginError = sdk.PluginError + +type route struct { + Description string `json:"description"` + Distance float64 `json:"distance"` + ElevationGain float64 `json:"elevation_gain"` + IDStr string `json:"id_str"` + Name string `json:"name"` + Private bool `json:"private"` + Timestamp int64 `json:"timestamp"` + Type int `json:"type"` + CreatedAt string `json:"created_at"` + EstimatedMovingTime int `json:"estimated_moving_time"` + Waypoints []routeWaypoint `json:"waypoints"` +} + +type routeWaypoint struct { + Latlng []float64 `json:"latlng"` + Title string `json:"title"` + Description string `json:"description"` +} + +type activity struct { + ID int64 `json:"id"` +} + +type detailedActivity struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Distance float64 `json:"distance"` + ElapsedTime int `json:"elapsed_time"` + TotalElevationGain float64 `json:"total_elevation_gain"` + Private bool `json:"private"` + StartDate string `json:"start_date"` + StartLatlng []float64 `json:"start_latlng"` + Type string `json:"type"` + SportType string `json:"sport_type"` + Photos photos `json:"photos"` +} + +type photos struct { + Count int `json:"count"` + Primary primaryPhoto `json:"primary"` +} + +type primaryPhoto struct { + ID int64 `json:"id"` + Urls photoURLs `json:"urls"` +} + +type photoURLs struct { + Num100 string `json:"100"` + Num600 string `json:"600"` +} + +type activityPhoto struct { + UniqueID string `json:"unique_id"` + Urls photoURLs `json:"urls"` +} + +type activityStreamResponse struct { + LatLng streamLatLng `json:"latlng"` + Time streamInt `json:"time"` + Altitude streamFloat64 `json:"altitude"` +} + +type streamLatLng struct { + Data [][]float64 `json:"data"` +} + +type streamInt struct { + Data []int `json:"data"` +} + +type streamFloat64 struct { + Data []float64 `json:"data"` +} diff --git a/web/src/app.html b/web/src/app.html index 6ec39f1b..c8c45e3f 100644 --- a/web/src/app.html +++ b/web/src/app.html @@ -7,15 +7,15 @@ %sveltekit.head% @@ -24,4 +24,4 @@

%sveltekit.body%
- \ No newline at end of file + diff --git a/web/src/css/components.css b/web/src/css/components.css index b7b70871..dc1ea2dc 100644 --- a/web/src/css/components.css +++ b/web/src/css/components.css @@ -129,4 +129,4 @@ .mention { @apply bg-blue-100 dark:bg-slate-700 rounded-md text-sm; padding: 0.1rem 0.3rem; -} \ No newline at end of file +} diff --git a/web/src/lib/components/base/select.svelte b/web/src/lib/components/base/select.svelte index a45cf08e..30f5887f 100644 --- a/web/src/lib/components/base/select.svelte +++ b/web/src/lib/components/base/select.svelte @@ -6,11 +6,14 @@ + + + +
+ {#if label.length} + + {/if} + + + {#if open} +
    + {#each items as item, i} +
  • { + event.preventDefault(); + selectItem(item); + }} + > + {item.text} + {#if item.value === value} + + {/if} +
  • + {/each} +
+ {/if} +
diff --git a/web/src/lib/components/base/text_field.svelte b/web/src/lib/components/base/text_field.svelte index d0a2475b..79bafca3 100644 --- a/web/src/lib/components/base/text_field.svelte +++ b/web/src/lib/components/base/text_field.svelte @@ -11,7 +11,7 @@ error?: string | string[] | null; icon?: string; extraClasses?: string; - type?: "text" | "password" | "search"; + type?: "text" | "password" | "search" | "url"; autocomplete?: "on" | "off"; onchange?: ChangeEventHandler; oninput?: FormEventHandler; diff --git a/web/src/lib/components/confirm_modal.svelte b/web/src/lib/components/confirm_modal.svelte index 72e83f96..5970b252 100644 --- a/web/src/lib/components/confirm_modal.svelte +++ b/web/src/lib/components/confirm_modal.svelte @@ -7,9 +7,11 @@ text: string; action?: string; deny?: string; + alternative?: string; id?: string; onconfirm?: () => void oncancel?: () => void + onalternative?: () => void } let { @@ -17,9 +19,11 @@ text, action = "delete", deny ="cancel", + alternative, id = "confirm-modal", onconfirm, - oncancel + oncancel, + onalternative }: Props = $props(); let modal: Modal; @@ -29,13 +33,18 @@ } function cancel() { - oncancel?.(); modal.closeModal!(); + oncancel?.(); + } + + function alternativeAction() { + modal.closeModal!(); + onalternative?.(); } function confirm() { - onconfirm?.() modal.closeModal!(); + onconfirm?.() } @@ -48,6 +57,11 @@ + {#if alternative} + + {/if} - - - - - {/snippet} - {#snippet footer()} -
- - -
- {/snippet} diff --git a/web/src/lib/components/settings/integrations/integration_card.svelte b/web/src/lib/components/settings/integrations/integration_card.svelte deleted file mode 100644 index ebf556f7..00000000 --- a/web/src/lib/components/settings/integrations/integration_card.svelte +++ /dev/null @@ -1,39 +0,0 @@ - -
- integration logo -
-
{title}
-

- {description} -

-
-
- - -
-
diff --git a/web/src/lib/components/settings/integrations/komoot_settings_modal.svelte b/web/src/lib/components/settings/integrations/komoot_settings_modal.svelte deleted file mode 100644 index c15b538f..00000000 --- a/web/src/lib/components/settings/integrations/komoot_settings_modal.svelte +++ /dev/null @@ -1,130 +0,0 @@ - - - - {#snippet content()} -
- - -
- - -
- - -

- {#if $d.privacy == "original"} - {$_("integration-privacy-hint-original")} - {:else} - {$_("integration-privacy-hint-user")} - {/if} -

- - - - {/snippet} - {#snippet footer()} -
- - -
- {/snippet}
diff --git a/web/src/lib/components/settings/integrations/strava_settings_modal.svelte b/web/src/lib/components/settings/integrations/strava_settings_modal.svelte deleted file mode 100644 index cd246af4..00000000 --- a/web/src/lib/components/settings/integrations/strava_settings_modal.svelte +++ /dev/null @@ -1,154 +0,0 @@ - - - - {#snippet content()} -
- - -
- - -
- - -

- {#if $formData.privacy == "original"} - {$_("integration-privacy-hint-original")} - {:else} - {$_("integration-privacy-hint-user")} - {/if} -

-
- - -
-

- {$_("strava-integration-after-date-hint")} -

- - - - {/snippet} - {#snippet footer()} -
- - -
- {/snippet}
diff --git a/web/src/lib/components/settings/plugins/plugin_card.svelte b/web/src/lib/components/settings/plugins/plugin_card.svelte new file mode 100644 index 00000000..58a87cd7 --- /dev/null +++ b/web/src/lib/components/settings/plugins/plugin_card.svelte @@ -0,0 +1,111 @@ + + +
+
+ {#if img} + plugin logo + {:else} + + {/if} +
+
{title}
+ {#if description} +

{description}

+ {/if} +
+
+
+
+ +
+ +
+
+
+ {#if lastSyncAt} + + {#if error} + + {:else} + + {/if} + {formatLastSyncAt(lastSyncAt)} + + {:else if error} + + + {$_("plugin-setup-error")} + + {/if} +
+
+
diff --git a/web/src/lib/components/settings/plugins/plugin_instance_settings_modal.svelte b/web/src/lib/components/settings/plugins/plugin_instance_settings_modal.svelte new file mode 100644 index 00000000..be1e10de --- /dev/null +++ b/web/src/lib/components/settings/plugins/plugin_instance_settings_modal.svelte @@ -0,0 +1,753 @@ + + + + {#snippet content()} +
{ + event.preventDefault(); + submit(); + }} + > + {#each authFields as field} + + {/each} + + {#if isOAuthPlugin} +

+ {#if isConnected} + {$_("plugin-oauth-connected-hint")} + {:else} + {$_("plugin-oauth-needs-connect-hint")} + {/if} +

+ {/if} + + {#if hasTourKindChoice} +
+ + +
+ {/if} + + {#if supportsSourcePrivacy} + +

+ {#if privacy == "original"} + {$_("plugin-privacy-hint-original")} + {:else} + {$_("plugin-privacy-hint-user")} + {/if} +

+ {/if} + + {#each visibleConfigSchema as field} + {#if field.type === "select"} + + {:else if field.type === "boolean"} + + {@const hint = fieldHint(field)} + {#if hint} +

{hint}

+ {/if} + {:else if field.type === "date"} + {@const hint = fieldHint(field)} + {#if hint} +

+ {hint} +

+ {/if} +
+ + +
+ {:else if field.type === "text" || field.type === "url"} + + {/if} + {/each} + + {#if supportsCategoryMapping && categorySelectItems.length > 0} +
+
+
+

{$_("category-mapping")}

+

+ {$_("category-mapping-help")} +

+
+ +
+ {#if categoryMappingRows.length > 0} + +
6} + class:overflow-y-auto={categoryMappingRows.length > 6} + class:overflow-y-visible={categoryMappingRows.length <= 6} + > + {#each categoryMappingRows as row, i} + {@const providerItems = providerCategoryItemsForRow(i)} +
+ + + +
+ {/each} +
+ {/if} +
+ {/if} + + {#if mergeAvailable} + + {/if} + + {/snippet} + {#snippet footer()} +
+ + {#if isOAuthPlugin} + {#if needsOAuthConnect} + + {:else} + + {/if} + {:else} + + {/if} +
+ {/snippet} +
diff --git a/web/src/lib/components/settings/integrations/integration_merge_settings.svelte b/web/src/lib/components/settings/plugins/plugin_merge_settings.svelte similarity index 64% rename from web/src/lib/components/settings/integrations/integration_merge_settings.svelte rename to web/src/lib/components/settings/plugins/plugin_merge_settings.svelte index d687d02e..297f9337 100644 --- a/web/src/lib/components/settings/integrations/integration_merge_settings.svelte +++ b/web/src/lib/components/settings/plugins/plugin_merge_settings.svelte @@ -4,17 +4,19 @@ interface Props { prefix: string; + value?: boolean; } - let { prefix }: Props = $props(); + let { prefix, value = $bindable(false) }: Props = $props();

- {$_("integration-auto-merge-hint")} + {$_("plugin-auto-merge-hint")}

diff --git a/web/src/lib/components/trail/trail_dropdown.svelte b/web/src/lib/components/trail/trail_dropdown.svelte index 8bfb051c..2dc9fb5e 100644 --- a/web/src/lib/components/trail/trail_dropdown.svelte +++ b/web/src/lib/components/trail/trail_dropdown.svelte @@ -3,11 +3,6 @@ import { page } from "$app/state"; import type { List } from "$lib/models/list"; import type { Trail } from "$lib/models/trail"; - import { - integrations, - integrations_index, - uploadGpx, - } from "$lib/stores/integration_store"; import { lists_add_trail, lists_index, @@ -25,9 +20,8 @@ import { trail2gpx } from "$lib/util/gpx_util"; import { gpx } from "$lib/vendor/toGeoJSON/toGeoJSON"; import JSZip from "jszip"; - import { onMount, type Snippet } from "svelte"; + import { type Snippet } from "svelte"; import { _ } from "svelte-i18n"; - import { get } from "svelte/store"; import Dropdown, { type DropdownItem } from "../base/dropdown.svelte"; import ConfirmModal from "../confirm_modal.svelte"; import ListSearchModal from "../list/list_search_modal.svelte"; @@ -43,6 +37,7 @@ import type { MergeSelection, MergeSettings } from "./trail_merge_modal.svelte"; import MergeDialog from "$lib/components/trail/trail_merge_dialog.svelte"; import { trail_merge } from "$lib/stores/trail_merge_api"; + import { hasSendCapablePlugin } from "$lib/stores/plugin_store"; export interface MergeResult { targetTrail: Trail; @@ -65,58 +60,11 @@ let confirmModal: ConfirmModal; let listSelectModal: ListSearchModal; let trailExportModal: TrailExportModal; + let trailSendModal: TrailSendModal; let trailShareModal: TrailShareModal; let trailMergeModal: TrailMergeModal; - let trailSendModal: TrailSendModal; - - const hammerheadIntegration = $derived( - $integrations.find((integration) => - Boolean(integration.hammerhead?.active) - ) - ); let lists: List[] = $state([]); - let integrationsLoading = false; - let integrationsLoadedForUser: string | undefined; - - onMount(() => { - const unsubscribe = currentUser.subscribe(async (user) => { - if (!user) { - integrationsLoadedForUser = undefined; - return; - } - - const existing = get(integrations); - if ( - existing.length && - existing[0]?.user === user.id - ) { - integrationsLoadedForUser = user.id; - return; - } - - if ( - integrationsLoading || - integrationsLoadedForUser === user.id - ) { - return; - } - - integrationsLoading = true; - try { - await integrations_index(); - integrationsLoadedForUser = user.id; - } catch (error) { - console.error("Failed to load integrations", error); - } finally { - integrationsLoading = false; - } - }); - - return () => { - unsubscribe(); - }; - }); let loading: boolean = $state(false); @@ -214,6 +162,16 @@ return !isMultiselectMode(); } + function allowSend(): boolean { + return ( + hasTrail() && + !isMultiselectMode() && + hasGpx() && + Boolean($currentUser) && + $hasSendCapablePlugin + ); + } + function allowPublish(): boolean { if (mode !== "multi-select") return false; @@ -240,7 +198,7 @@ }); const allowListManagement = isFromCurrentUser(); const allowShareSingleTrail = !isMultiselectMode() && isFromCurrentUser(); - const allowSingleOutput = canExport() || (!isMultiselectMode() && hammerheadIntegration && canExport()); + const allowSingleOutput = canExport(); if (isMultiselectMode()) { return [ @@ -389,7 +347,7 @@ }, ] : []), - ...(!isMultiselectMode() && hammerheadIntegration && canExport() + ...(allowSend() ? [ { text: $_("send-to"), @@ -528,6 +486,8 @@ } } else if (ddVal == "share") { trailShareModal.openModal(); + } else if (ddVal == "send-to") { + trailSendModal.openModal(); } else if (ddVal == "download") { trailExportModal.openModal(); } else if (ddVal == "edit") { @@ -548,8 +508,6 @@ if (trail()) { await trailMergeModal.openSimilarTrailsModal(trail()!); } - } else if (item.value == "send-to") { - trailSendModal.openModal(); } } @@ -605,49 +563,6 @@ await trail_merge(trailSource.id, trailTarget.id, settings); onProgress?.(1); } - async function uploadToHammerhead() { - if (!hammerheadIntegration || !hasTrail()) { - console.error("No Hammerhead integration found."); - return; - } - - for (const uTrail of trails!) { - try { - if (uTrail.gpx) { - const gpxData = await trail2gpx(uTrail, $currentUser); - const formData = new FormData(); - const gpxFile = new File( - [gpxData], - `${uTrail.name || "trail"}.gpx`, - { type: "application/gpx+xml" }, - ); - formData.append("file", gpxFile); - - await uploadGpx("hammerhead", gpxFile); - - show_toast({ - type: "success", - icon: "check", - text: $_("uploaded-trail-to-hammerhead"), - }); - } else { - show_toast({ - type: "error", - icon: "close", - text: $_("trail-has-no-gpx"), - }); - } - } catch (e) { - console.error(e); - show_toast({ - type: "error", - icon: "close", - text: $_("error-uploading-trail-to-hammerhead"), - }); - } - } - } - async function updateTrailsVisibility() { const newVisibility = !majorityOfSelectedTrailsArePublic(); @@ -926,17 +841,14 @@ onsave={handleShareUpdate} bind:this={trailShareModal} > + mergeTrails(settings, selection)} > - { - if (settings.integrationName === "hammerhead") { - await uploadToHammerhead(); - } - }} -> diff --git a/web/src/lib/components/trail/trail_send_modal.svelte b/web/src/lib/components/trail/trail_send_modal.svelte index 839a6a23..60ce7a32 100644 --- a/web/src/lib/components/trail/trail_send_modal.svelte +++ b/web/src/lib/components/trail/trail_send_modal.svelte @@ -1,42 +1,145 @@ - + {#snippet content()} -
- -
+ {#if loading} +
+
+
+ {:else if eligible.length === 0} +

{$_("no-send-plugins")}

+
{$_("plugins")} + {:else} +
+ {#each eligible as plugin (plugin.id)} + + {/each} +
+ {/if} {/snippet} {#snippet footer()}
@@ -44,5 +147,5 @@ >{$_("cancel")}
- {/snippet} + {/snippet} + diff --git a/web/src/lib/i18n/locales/cs.json b/web/src/lib/i18n/locales/cs.json index 1db9629b..202d2feb 100644 --- a/web/src/lib/i18n/locales/cs.json +++ b/web/src/lib/i18n/locales/cs.json @@ -3,6 +3,7 @@ "Canoeing": "Kanoistika", "Climbing": "Horolezectví", "Hiking": "Turistika", + "Other": "Ostatní", "Skiing": "", "Walking": "Chůze", "about": "O aplikaci", @@ -144,7 +145,7 @@ "error-copying-trail": "", "error-creating-user": "Chyba při vytváření uživatele", "error-deleting-token": "", - "error-disabling-strava-integration": "Chyba při deaktivaci integrace strava", + "error-disabling-strava-plugin": "Chyba při deaktivaci pluginu strava", "error-during-login": "Během přihlášení došlo k chybě", "error-during-password-reset": "Nelze odeslat e-mail pro obnovu hesla", "error-exporting-trail": "Chyba při exportování trasy", @@ -157,11 +158,11 @@ "error-reading-file": "Soubor nelze načíst", "error-saving-list": "Chyba při ukládání seznamu", "error-saving-trail": "Chyba při ukládání trasy", - "error-setting-up-integration": "Chyba během nastavování {provider} integrace", - "error-updating-hammerhead-integration": "", - "error-updating-komoot-integration": "", + "error-setting-up-plugin": "Chyba během nastavování pluginu {provider}", + "error-updating-hammerhead-plugin": "", + "error-updating-komoot-plugin": "", "error-updating-password": "Chyba během aktualizace hesla", - "error-updating-strava-integration": "Chyba při aktualizaci Komoot integrace", + "error-updating-strava-plugin": "Chyba při aktualizaci pluginu Strava", "error-uploading-trail-to-hammerhead": "", "est-duration": "Odhadovaná doba trvání", "everyone-with-the-link": "Každý, kdo má odkaz", @@ -202,7 +203,6 @@ "get-started": "Začněte", "grid": "Mřížka", "grocery-store": "Potraviny", - "hammerhead-integration-after-date-hint": "", "heading": "Nadpis", "height": "Výška", "help": "Nápověda", @@ -224,14 +224,11 @@ "import-hint": "Vyberte nebo sem přetáhněte soubory GPX, FIT, KML, či TCX...", "include-description": "Zahrnout popis", "include-waypoints": "Zahrnout body trasy", - "integration-description-hammerhead": "", - "integration-description-komoot": "Synchronizuje vaše trasy z aplikace Komoot s Wandererem v pravidelných intervalech.", - "integration-description-strava": "Synchronizuje vaše trasy a aktivity z aplikace Strava s Wandererem v pravidelných intervalech.", - "integration-disabled": "integrace zakázána", - "integration-enabled": "integrace povolena", - "integration-privacy-hint-original": "", - "integration-privacy-hint-user": "", - "integrations": "Integrace", + "plugin-disabled": "plugin zakázán", + "plugin-enabled": "plugin povolen", + "plugin-privacy-hint-original": "", + "plugin-privacy-hint-user": "", + "plugins": "Pluginy", "invalid-date": "Neplatné datum", "invalid-username": "Neplatné uživatelské jméno", "italian": "Italština", @@ -434,7 +431,6 @@ "statistics": "Statistiky", "stop-drawing": "Ukončit kreslení", "stop-editing": "Ukončit úpravy", - "strava-integration-after-date-hint": "Pokud váš účet obsahuje velké množství aktivit, můžete narazit na limit API služby Strava, což znemožní synchronizaci všech aktivit najednou. Tomuto problému předejdete nastavením data \"Od\" a dále - synchronizují se tak pouze aktivity zaznamenané po tomto datu.", "subway-stop": "Vstup do metra", "summit": "Vrchol", "summit-book": "Vrcholová kniha", diff --git a/web/src/lib/i18n/locales/de.json b/web/src/lib/i18n/locales/de.json index be42eaee..105966f2 100644 --- a/web/src/lib/i18n/locales/de.json +++ b/web/src/lib/i18n/locales/de.json @@ -3,6 +3,7 @@ "Canoeing": "Kanufahren", "Climbing": "Klettern", "Hiking": "Wandern", + "Other": "Sonstiges", "Skiing": "Skifahren", "Walking": "Laufen", "about": "Über", @@ -30,7 +31,7 @@ "append-waypoint-description": "Kommentar anhängen", "append-waypoint-photos": "Fotos hinzufügen", "append-waypoint-title": "Titel anhängen", - "apply-user-settings": "", + "apply-user-settings": "Benutzereinstellungen anwenden", "attraction": "Sehenswürdigkeit", "author": "Autor", "avatar": "Avatar", @@ -69,11 +70,14 @@ "card": "{n, plural, =1 {Karte} other {Karten}}", "categories": "Kategorien", "category": "Kategorie", + "category-mapping": "Kategorie-Zuordnung", + "category-mapping-help": "Entfernte Provider-Kategorien werden bewusst nicht zugeordnet und erhalten beim Import keine Kategorie.", "change": "Ändern", "change-email": "Email ändern", "change-password": "Passwort ändern", "changelog": "Änderungshistorie", "chinese": "Chinesisch (vereinfacht)", + "clear": "Leeren", "clear-all": "Alle ausblenden", "dismiss": "Schließen", "climbing": "Klettern", @@ -158,7 +162,7 @@ "error-copying-trail": "Fehler beim Kopieren der Route", "error-creating-user": "Fehler beim Erstellen des Nutzers", "error-deleting-token": "", - "error-disabling-strava-integration": "Fehler beim Deaktivieren der Stravaintegration", + "error-disabling-strava-plugin": "Fehler beim Deaktivieren des Strava-Plugins", "error-during-login": "Fehler beim Login", "error-during-password-reset": "Email zum Zurücksetzen des Passworts konnte nicht versandt werden", "error-exporting-trail": "Fehler beim Exportieren der Route", @@ -172,11 +176,12 @@ "error-reading-file": "Fehler beim Lesen der Datei", "error-saving-list": "Fehler beim Speichern der Liste", "error-saving-trail": "Fehler beim Speichern der Route", - "error-setting-up-integration": "Fehler beim Einrichten der {provider}-Integration", - "error-updating-hammerhead-integration": "Fehler bei Aktualisierung der Hammerhead-Integration", - "error-updating-komoot-integration": "Fehler bei Aktualisierung der komoot-Integration", + "error-setting-up-plugin": "Fehler beim Einrichten des {provider}-Plugins", + "error-starting-oauth": "Fehler beim Starten der OAuth-Verbindung", + "error-updating-hammerhead-plugin": "Fehler bei Aktualisierung des Hammerhead-Plugins", + "error-updating-komoot-plugin": "Fehler bei Aktualisierung des komoot-Plugins", "error-updating-password": "Fehler beim Aktualisieren des Passworts", - "error-updating-strava-integration": "Fehler bei Aktualisierung der Strava-Integration", + "error-updating-strava-plugin": "Fehler bei Aktualisierung des Strava-Plugins", "error-uploading-trail-to-hammerhead": "Fehler beim Hochladen der Route zu Hammerhead", "est-duration": "Gesch. Dauer", "everyone-with-the-link": "Jeder mit dem Link", @@ -218,7 +223,6 @@ "get-started": "Los geht’s", "grid": "Gitter", "grocery-store": "Lebensmittelgeschäft", - "hammerhead-integration-after-date-hint": "Wenn Ihr Hammerhead Konto bereits mit anderen Trail-Datenbanken wie komoot oder Strava synchronisiert ist, kann die zusätzliche Synchronisierung Ihrer Hammerhead-Daten zu Duplikaten führen. Um dies zu vermeiden, können Sie unten ein Startdatum festlegen, sodass nur Aktivitäten synchronisiert werden, die nach diesem Datum aufgezeichnet wurden.", "heading": "Überschrift", "height": "Höhe", "help": "Hilfe", @@ -240,23 +244,46 @@ "import-hint": "GPX, FIT, KML oder TCX Dateien auswählen oder hierher ziehen...", "include-description": "Beschreibung übernehmen", "include-waypoints": "Wegpunkte einbeziehen", - "integration-description-hammerhead": "Synchronisiert Deine Hammerhead-Touren regelmäßig mit wanderer.", - "integration-description-komoot": "Synchronisiert Deine komoot-Touren regelmäßig mit wanderer.", - "integration-description-strava": "Synchronisiert Deine Strava-Routen und -Aktivitäten regelmäßig mit wanderer.", - "integration-auto-merge-label": "Automatisch mergen", - "integration-auto-merge-hint": "Nach dem Import wird nur dann automatisch gemergt, wenn ein eindeutiger Treffer gefunden wird. Der importierte Trail wird in diesem Fall nicht separat gespeichert.", - "integration-disabled": "Integration deaktiviert", - "integration-enabled": "Integration aktiviert", - "integration-privacy-hint-original": "", - "integration-privacy-hint-user": "", - "integrations": "Integrationen", + "plugin-auto-merge-label": "Automatisch mergen", + "plugin-auto-merge-hint": "Nach dem Import wird nur dann automatisch gemergt, wenn ein eindeutiger Treffer gefunden wird. Der importierte Trail wird in diesem Fall nicht separat gespeichert.", + "plugin-after-date-hint": "Lege ein Startdatum fest, um nur Routen zu synchronisieren, die nach diesem Datum aufgezeichnet wurden. Das kann helfen, Duplikate zu vermeiden, wenn ältere Routen bereits in wanderer vorhanden sind.", + "plugin-category-remap-action": "Kategorien aktualisieren", + "plugin-category-remap-back-to-settings": "Zurück zu den Einstellungen", + "plugin-category-remap-backfilled-confirm": "Für {count} importierte Routen wurde die Provider-Kategorie erst nach deiner letzten Änderung der wanderer Kategorie-Zuordnung ermittelt. Soll die Zuordnung für diese Routen jetzt nachgeholt werden? Andere Routendaten bleiben unverändert.", + "plugin-category-remap-confirm": "{count} Routen sind von den Zuordnungsanpassungen betroffen. Soll die aktualisierte Zuordnung jetzt auf diese Routen angewendet werden? Andere Routendaten bleiben unverändert.", + "plugin-category-remap-confirm-unspecified": "Die Kategorie-Zuordnung wurde geändert. Aktuell sind keine importierten Routen bekannt, die sofort aktualisiert werden können. Änderungen speichern?", + "plugin-category-remap-continue-without-remap": "Ohne Remapping fortfahren", + "plugin-category-remap-dismiss-error": "Remap-Erinnerung konnte nicht gespeichert werden", + "plugin-category-remap-error": "Kategorien importierter Routen konnten nicht aktualisiert werden", + "plugin-category-remap-ignore": "Ignorieren", + "plugin-category-remap-preview-error": "Importierte Routen konnten nicht auf Kategorie-Updates geprüft werden", + "plugin-category-remap-success": "Kategorien für {count} importierte Routen aktualisiert", + "plugin-category-remap-sync-hint": "Einige dieser Treffer wurden erst nach der letzten Änderung der Zuordnung durch eine Synchronisierung gefunden.", + "plugin-category-remap-title": "Kategorien importierter Routen aktualisieren?", + "plugin-connect-before-enabling": "Verbinde dieses Plugin, bevor du es aktivierst.", + "plugin-disabled": "Plugin deaktiviert", + "plugin-enabled": "Plugin aktiviert", + "plugins-empty-title": "Keine Plugins installiert", + "plugins-empty-description": "Auf diesem Server sind noch keine Plugins verfügbar. Kontaktiere deinen Administrator oder lies die Installationsanleitung.", + "plugins-empty-docs-link": "Plugins installieren", + "plugin-oauth-connected-hint": "Dieses Plugin ist verbunden. Wenn du die OAuth-Zugangsdaten änderst, verbinde es erneut, bevor du die Synchronisierung wieder aktivierst.", + "plugin-oauth-needs-connect-hint": "Speichere und verbinde dieses Plugin, bevor du die Synchronisierung aktivierst.", + "plugin-privacy-hint-original": "Importierte Routen behalten dieselbe Sichtbarkeit wie auf der externen Plattform. Wenn die ursprüngliche Route öffentlich war, ist sie auch in wanderer öffentlich, selbst wenn Routen laut deinen Privatsphäre-Einstellungen standardmäßig privat sind.", + "plugin-privacy-hint-user": "Die ursprüngliche Sichtbarkeit der Route wird verworfen. Stattdessen werden die lokalen Privatsphäre-Einstellungen für Routen auf alle importierten Routen angewendet.", + "plugin-setup-error": "Setup-Fehler", + "plugin-error-rate-limited": "Der Anbieter begrenzt gerade Anfragen. Versuche es später erneut.", + "plugin-error-provider-unavailable": "Der Anbieter ist momentan nicht erreichbar. Versuche es später erneut.", + "plugin-error-invalid-request": "Das Plugin hat eine ungültige Anfrage gesendet.", + "plugin-error-internal": "Das Plugin ist unerwartet fehlgeschlagen.", + "plugin-error-reconnect-required": "Die Plugin-Autorisierung ist abgelaufen. Verbinde das Plugin erneut.", "invalid-date": "Ungültiges Datum", "invalid-username": "Ungültiger Nutzername", "italian": "Italienisch", "joined": "Beigetreten", - "keep-original": "", + "keep-original": "Original beibehalten", "keep-private": "Ohne Veröffentlichung fortfahren", "language": "Sprache", + "last-sync": "Letzte Synchronisierung", "last-used": "", "latitude": "Breitengrad", "layer": "{n, plural, =1 {Ebene} other {Ebenen}}", @@ -378,6 +405,7 @@ "privacy": "Privatsphäre", "private": "Privat", "profile": "Profil", + "provider-category": "Provider-Kategorie", "public": "Öffentlich", "public-access": "Öffentlicher Zugriff", "public-share-everyone": "Jeder im Internet mit dem Link kann diese Route sehen", @@ -393,6 +421,7 @@ "remote-users-cannot-edit": "Remote-Benutzer können nicht bearbeiten", "removed-trail-from": "Route entfernt aus", "removed-trails-from": "Routen entfernt aus", + "remove": "Entfernen", "required": "Pflichtfeld", "reset": "Zurücksetzen", "reset-password": "Passwort zurücksetzen", @@ -414,8 +443,16 @@ "search-places": "Orte suchen", "search-trails": "Route suchen", "select-list": "Liste auswählen", + "select-category": "Kategorie auswählen", + "select-provider-category": "Provider-Kategorie auswählen", "selected": "ausgewählt", "send-to": "Senden an...", + "trail-sent": "Trail gesendet", + "error-sending-trail": "Fehler beim Senden des Trails", + "no-send-plugins": "Kein verbundenes Plugin unterstützt das Senden von Trails. Verbinde zuerst eines in den Plugin-Einstellungen.", + "plugins": "Plugins", + "plugin-type-trails": "Trails", + "plugin-type-trails-description": "Trail-Plugins verbinden wanderer mit externen Diensten und synchronisieren Routen oder Aktivitäten automatisch im Hintergrund.", "set-private": "Verbergen", "set-public": "Veröffentlichen", "settings": "Einstellungen", @@ -437,6 +474,9 @@ "settings-privacy-trails-private": "Deine Routen sind standardmäßig privat. Niemand außer Dir kann sie sehen. Du kannst diese Einstellung jederzeit für einzelne Routen ändern.", "settings-privacy-trails-public": "Deine Trails sind standardmäßig öffentlich. Jeder kann sie sehen. Du kannst diese Einstellung jederzeit für einzelne Routen ändern.", "settings-saved": "Einstellungen gespeichert", + "save-and-connect": "Speichern & verbinden", + "save-and-reconnect": "Speichern & neu verbinden", + "save-and-validate": "Speichern & prüfen", "share": "Teilen", "share-profile": "Profil teilen", "share-this-list": "Diese Liste teilen", @@ -462,7 +502,6 @@ "statistics": "Statistiken", "stop-drawing": "Zeichnen beenden", "stop-editing": "Bearbeiten beenden", - "strava-integration-after-date-hint": "Wenn Ihr Konto eine große Anzahl von Aktivitäten enthält, kann es vorkommen, dass Sie aufgrund der API-Restriktionen von Strava nicht alle Aktivitäten auf einmal synchronisieren können. Um dieses Problem zu umgehen, können Sie unten ein Startdatum festlegen, sodass nur Aktivitäten synchronisiert werden, die nach diesem Datum aufgezeichnet wurden.", "subway-stop": "U-Bahn Eingang", "summit": "Gipfel", "summit-book": "Gipfelbuch", diff --git a/web/src/lib/i18n/locales/en.json b/web/src/lib/i18n/locales/en.json index cf5bde1d..f49ea388 100644 --- a/web/src/lib/i18n/locales/en.json +++ b/web/src/lib/i18n/locales/en.json @@ -3,6 +3,7 @@ "Canoeing": "Canoeing", "Climbing": "Climbing", "Hiking": "Hiking", + "Other": "Other", "Skiing": "", "Walking": "Walking", "about": "About", @@ -69,11 +70,14 @@ "card": "{n, plural, =1 {Card} other {Cards}}", "categories": "Categories", "category": "Category", + "category-mapping": "Category mapping", + "category-mapping-help": "Removed provider categories are intentionally left unmapped and will be imported without a category.", "change": "Change", "change-email": "Change email", "change-password": "Change password", "changelog": "Changelog", "chinese": "Chinese (simplified)", + "clear": "Clear", "clear-all": "Clear all", "dismiss": "Dismiss", "climbing": "Climbing", @@ -158,7 +162,7 @@ "error-copying-trail": "Error copying trail", "error-creating-user": "Error creating user", "error-deleting-token": "Error deleting token", - "error-disabling-strava-integration": "Error disabling strava integration", + "error-disabling-strava-plugin": "Error disabling strava plugin", "error-during-login": "Error during login", "error-during-password-reset": "Unable to send password reset email", "error-exporting-trail": "Error exporting trail", @@ -172,11 +176,12 @@ "error-reading-file": "Error reading file", "error-saving-list": "Error saving list", "error-saving-trail": "Error saving trail", - "error-setting-up-integration": "Error setting up {provider} integration", - "error-updating-hammerhead-integration": "Error updating Hammerhead integration", - "error-updating-komoot-integration": "Error updating komoot integration", + "error-setting-up-plugin": "Error setting up {provider} plugin", + "error-starting-oauth": "Error starting OAuth connection", + "error-updating-hammerhead-plugin": "Error updating Hammerhead plugin", + "error-updating-komoot-plugin": "Error updating komoot plugin", "error-updating-password": "Error updating password", - "error-updating-strava-integration": "Error updating Strava integration", + "error-updating-strava-plugin": "Error updating Strava plugin", "error-uploading-trail-to-hammerhead": "Error uploading trail to Hammerhead", "est-duration": "Est. duration", "everyone-with-the-link": "Everyone with the link", @@ -218,7 +223,6 @@ "get-started": "Get started", "grid": "Grid", "grocery-store": "Grocery store", - "hammerhead-integration-after-date-hint": "If your hammerhead account is already synced with other trail databases, such as komoot or Strava, start syncing your Hammerhead data may result in duplicates. To avoid this, you can set an start date below, meaning only activities recorded after this date will be synced.", "heading": "Heading", "height": "Height", "help": "Help", @@ -240,16 +244,38 @@ "import-hint": "Select or drag GPX, FIT, KML or TCX files here...", "include-description": "Include description", "include-waypoints": "Include waypoints", - "integration-description-hammerhead": "Syncs your Hammerhead tours with wanderer in regular intervals.", - "integration-description-komoot": "Syncs your komoot tours with wanderer in regular intervals.", - "integration-description-strava": "Syncs your Strava routes & activities with wanderer in regular intervals.", - "integration-auto-merge-label": "Auto-merge", - "integration-auto-merge-hint": "Imported trails are only merged automatically when exactly one clear match is found. The imported trail is always deleted afterwards.", - "integration-disabled": "integration disabled", - "integration-enabled": "integration enabled", - "integration-privacy-hint-original": "Imported trails will maintain the same visibility they have on the external platform. For example, if the original trail was public, it will be public in wanderer, even if trails are private by default according to your privacy settings.", - "integration-privacy-hint-user": "The original trail's visibility is discarded. Instead, the local privacy settings for trails are applied to all imported trails.", - "integrations": "Integrations", + "plugin-auto-merge-label": "Auto-merge", + "plugin-auto-merge-hint": "Imported trails are only merged automatically when exactly one clear match is found. The imported trail is always deleted afterwards.", + "plugin-after-date-hint": "Set a start date to sync only trails recorded after that date. This can help avoid duplicates when older trails are already available in wanderer.", + "plugin-category-remap-action": "Update categories", + "plugin-category-remap-back-to-settings": "Back to settings", + "plugin-category-remap-backfilled-confirm": "For {count} imported trails, the provider category was detected only after you last changed the wanderer category mapping. Apply the mapping to these trails now? Other trail data stays unchanged.", + "plugin-category-remap-confirm": "{count} trails are affected by the category mapping changes. Apply the updated mapping to these trails now? Other trail data stays unchanged.", + "plugin-category-remap-confirm-unspecified": "The category mapping was changed. There are currently no imported trails that can be updated immediately. Save changes?", + "plugin-category-remap-continue-without-remap": "Continue without remapping", + "plugin-category-remap-dismiss-error": "Could not save remap reminder preference", + "plugin-category-remap-error": "Could not update imported trail categories", + "plugin-category-remap-ignore": "Ignore", + "plugin-category-remap-preview-error": "Could not check imported trails for category updates", + "plugin-category-remap-success": "Updated categories for {count} imported trails", + "plugin-category-remap-sync-hint": "Some of these matches were found during a sync after the mapping was last changed.", + "plugin-category-remap-title": "Update imported trail categories?", + "plugin-connect-before-enabling": "Connect this plugin before enabling it.", + "plugin-disabled": "plugin disabled", + "plugin-enabled": "plugin enabled", + "plugins-empty-title": "No plugins installed", + "plugins-empty-description": "No plugins are available on this server yet. Contact your administrator or read the installation guide.", + "plugins-empty-docs-link": "How to install plugins", + "plugin-oauth-connected-hint": "This plugin is connected. If you change the OAuth credentials, reconnect it before enabling sync again.", + "plugin-oauth-needs-connect-hint": "Save and connect this plugin before enabling sync.", + "plugin-privacy-hint-original": "Imported trails will maintain the same visibility they have on the external platform. For example, if the original trail was public, it will be public in wanderer, even if trails are private by default according to your privacy settings.", + "plugin-privacy-hint-user": "The original trail's visibility is discarded. Instead, the local privacy settings for trails are applied to all imported trails.", + "plugin-setup-error": "Setup error", + "plugin-error-rate-limited": "Provider rate limit reached. Try again later.", + "plugin-error-provider-unavailable": "Provider is currently unavailable. Try again later.", + "plugin-error-invalid-request": "Plugin sent an invalid request.", + "plugin-error-internal": "Plugin failed unexpectedly.", + "plugin-error-reconnect-required": "Plugin authorization expired. Reconnect the plugin.", "invalid-date": "Invalid Date", "invalid-username": "Invalid username", "italian": "Italian", @@ -257,6 +283,7 @@ "keep-original": "Keep original", "keep-private": "Keep private", "language": "Language", + "last-sync": "Last sync", "last-used": "Last used", "latitude": "Latitude", "layer": "{n, plural, =1 {Layer} other {Layers}}", @@ -378,6 +405,7 @@ "privacy": "Privacy", "private": "Private", "profile": "Profile", + "provider-category": "Provider category", "public": "Public", "public-access": "Public access", "public-share-everyone": "Everyone on the internet with the link can see this trail", @@ -393,6 +421,7 @@ "remote-users-cannot-edit": "Remote users cannot edit", "removed-trail-from": "Removed trail from", "removed-trails-from": "Removed trails from", + "remove": "Remove", "required": "Required", "reset": "Reset", "reset-password": "Reset Password", @@ -414,8 +443,16 @@ "search-places": "Search places", "search-trails": "Search trails", "select-list": "Select List", + "select-category": "Select category", + "select-provider-category": "Select provider category", "selected": "selected", "send-to": "Send to...", + "trail-sent": "Trail sent", + "error-sending-trail": "Error sending trail", + "no-send-plugins": "No connected plugin supports sending trails. Connect one in the plugin settings first.", + "plugins": "Plugins", + "plugin-type-trails": "Trails", + "plugin-type-trails-description": "Trail plugins connect wanderer with external services and automatically sync routes or activities in the background.", "set-private": "Set private", "set-public": "Set public", "settings": "Settings", @@ -437,6 +474,9 @@ "settings-privacy-trails-private": "Your trails are private by default. No one except you will be able to see them. You can change this setting at any point for individual trails.", "settings-privacy-trails-public": "Your trails are public by default. Everyone will be able to see them. You can change this setting at any point for individual trails.", "settings-saved": "Settings saved", + "save-and-connect": "Save & connect", + "save-and-reconnect": "Save & reconnect", + "save-and-validate": "Save & validate", "share": "Share", "share-profile": "Share profile", "share-this-list": "Share this list", @@ -462,7 +502,6 @@ "statistics": "Statistics", "stop-drawing": "Stop drawing", "stop-editing": "Stop editing", - "strava-integration-after-date-hint": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an start date below so that only activities that were recorded after this date are synced.", "subway-stop": "Subway entrance", "summit": "Summit", "summit-book": "Summit Book", diff --git a/web/src/lib/i18n/locales/es.json b/web/src/lib/i18n/locales/es.json index 36f1b825..9db41f8c 100644 --- a/web/src/lib/i18n/locales/es.json +++ b/web/src/lib/i18n/locales/es.json @@ -3,6 +3,7 @@ "Canoeing": "Remo", "Climbing": "Escalada", "Hiking": "Senderismo", + "Other": "Otro", "Skiing": "", "Walking": "Paseo", "about": "Sobre", @@ -144,7 +145,7 @@ "error-copying-trail": "", "error-creating-user": "Error creando el usuario", "error-deleting-token": "", - "error-disabling-strava-integration": "Error al desactivar la integración de strava", + "error-disabling-strava-plugin": "Error al desactivar el plugin de Strava", "error-during-login": "Error durante el acceso", "error-during-password-reset": "Imposible enviar la contraseña de restablecimiento al correo electrónico", "error-exporting-trail": "Error exportando la ruta", @@ -157,11 +158,11 @@ "error-reading-file": "Error leyendo el archivo", "error-saving-list": "Error guardando la lista", "error-saving-trail": "Error guardando la ruta", - "error-setting-up-integration": "Error al configurar la integración con {provider}", - "error-updating-hammerhead-integration": "", - "error-updating-komoot-integration": "", + "error-setting-up-plugin": "Error al configurar el plugin {provider}", + "error-updating-hammerhead-plugin": "", + "error-updating-komoot-plugin": "", "error-updating-password": "Error actualizando la contraseña", - "error-updating-strava-integration": "Error al actualizar la integración con komoot", + "error-updating-strava-plugin": "Error al actualizar el plugin Strava", "error-uploading-trail-to-hammerhead": "", "est-duration": "Duración estimada", "everyone-with-the-link": "Cualquier persona con el enlace", @@ -202,7 +203,6 @@ "get-started": "Iniciar", "grid": "Cuadricula", "grocery-store": "Supermercado", - "hammerhead-integration-after-date-hint": "", "heading": "Título", "height": "Altura", "help": "Ayuda", @@ -224,14 +224,11 @@ "import-hint": "Selecciona o arrastra aquí archivos GPX, FIT, KML o TCX...", "include-description": "Incluir descripción", "include-waypoints": "Incluir puntos de interés", - "integration-description-hammerhead": "", - "integration-description-komoot": "Sincroniza tus recorridos de Komoot con Wanderer en intervalos regulares.", - "integration-description-strava": "Sincroniza tus recorridos y actividades de Strava con Wanderer en intervalos regulares.", - "integration-disabled": "integración desactivada", - "integration-enabled": "integración activada", - "integration-privacy-hint-original": "", - "integration-privacy-hint-user": "", - "integrations": "Integraciones", + "plugin-disabled": "plugin desactivado", + "plugin-enabled": "plugin activado", + "plugin-privacy-hint-original": "", + "plugin-privacy-hint-user": "", + "plugins": "Plugins", "invalid-date": "Fecha no válida", "invalid-username": "Usuario no válido", "italian": "Italiano", @@ -434,7 +431,6 @@ "statistics": "Estadísticas", "stop-drawing": "Parar de diseñar", "stop-editing": "Parar de editar", - "strava-integration-after-date-hint": "Si tu cuenta tiene una gran cantidad de actividades, es posible que alcances el límite de peticiones de la API de Strava, lo que impedirá la sincronización de todas las actividades a la vez. Para mitigar este problema, puedes establecer una fecha \"Posterior a\" a continuación, de modo que solo se sincronicen las actividades que se registraron después de esa fecha.", "subway-stop": "Entrada de metro", "summit": "Cumbre", "summit-book": "Libro de ascensos", diff --git a/web/src/lib/i18n/locales/eu.json b/web/src/lib/i18n/locales/eu.json index 0611b5b8..25678ca3 100644 --- a/web/src/lib/i18n/locales/eu.json +++ b/web/src/lib/i18n/locales/eu.json @@ -3,6 +3,7 @@ "Canoeing": "Kanoa", "Climbing": "Eskalada", "Hiking": "Mendi-ibilaldia", + "Other": "Besteak", "Skiing": "", "Walking": "Oinez", "about": "Honi buruz", @@ -144,7 +145,7 @@ "error-copying-trail": "", "error-creating-user": "Errorea erabiltzailea sortzen", "error-deleting-token": "", - "error-disabling-strava-integration": "Errorea stravarekin integrazioa desaktibatzean", + "error-disabling-strava-plugin": "Errorea strava plugina desaktibatzean", "error-during-login": "Errorea sartzean", "error-during-password-reset": "Ezin izan da pasahitza berrezartzeko mezua bidali", "error-exporting-trail": "Errorea ibilbidea esportatzean", @@ -157,11 +158,11 @@ "error-reading-file": "Errorea fitxategia irakurtzean", "error-saving-list": "Errorea zerrenda gordetzean", "error-saving-trail": "Errorea ibilbidea gordetzean", - "error-setting-up-integration": "Errorea {provider} integrazioa egitean", - "error-updating-hammerhead-integration": "", - "error-updating-komoot-integration": "", + "error-setting-up-plugin": "Errorea {provider} plugina konfiguratzean", + "error-updating-hammerhead-plugin": "", + "error-updating-komoot-plugin": "", "error-updating-password": "Errorea pasahitza eguneratzean", - "error-updating-strava-integration": "Errorea komoot integrazioa eguneratzean", + "error-updating-strava-plugin": "Errorea Strava plugina eguneratzean", "error-uploading-trail-to-hammerhead": "", "est-duration": "Ustezko iraupena", "everyone-with-the-link": "Esteka duen edonor", @@ -202,7 +203,6 @@ "get-started": "Hasi", "grid": "Sareta", "grocery-store": "Janari-denda", - "hammerhead-integration-after-date-hint": "", "heading": "Goiburukoa", "height": "Altuera", "help": "Laguntza", @@ -224,14 +224,11 @@ "import-hint": "Aukeratu edo arrastatu hona GPX, FIT, KML edo TCX fitxategiak...", "include-description": "Gehitu deskribapena", "include-waypoints": "Gehitu bidepuntuak", - "integration-description-hammerhead": "", - "integration-description-komoot": "Zure komooteko ibilbideak wandererekin sinkronizatzen ditu aldian behin.", - "integration-description-strava": "Zure stravako ibilbideak wandererekin sinkronizatzen ditu aldian behin.", - "integration-disabled": "integrazioa desaktibatuta", - "integration-enabled": "integrazioa aktibatuta", - "integration-privacy-hint-original": "", - "integration-privacy-hint-user": "", - "integrations": "Integrazioak", + "plugin-disabled": "plugina desaktibatuta", + "plugin-enabled": "plugina aktibatuta", + "plugin-privacy-hint-original": "", + "plugin-privacy-hint-user": "", + "plugins": "Pluginak", "invalid-date": "Data ez da zuzena", "invalid-username": "Erabiltzailea ez da zuzena", "italian": "Italiera", @@ -434,7 +431,6 @@ "statistics": "Estatistikak", "stop-drawing": "Utzi marrazteari", "stop-editing": "Utzi editatzeari", - "strava-integration-after-date-hint": "Zure kontuak ekintza esko baditu Stravaren APIaren mugekin topo egin dezakezu eta agian ezingo dituzu zure ekintza guztiak aldi berean inportatu. Horretarako data jakin batetik aurrerako ekintzak sinkronizatzeko aukera duzu.", "subway-stop": "Metro sarbidea", "summit": "Gailurra", "summit-book": "Igoeren liburua", diff --git a/web/src/lib/i18n/locales/fr.json b/web/src/lib/i18n/locales/fr.json index d4e3b623..6b488f64 100644 --- a/web/src/lib/i18n/locales/fr.json +++ b/web/src/lib/i18n/locales/fr.json @@ -3,6 +3,7 @@ "Canoeing": "Canoë", "Climbing": "Escalade", "Hiking": "Randonnée", + "Other": "Autre", "Skiing": "", "Walking": "Marche", "about": "Informations", @@ -144,7 +145,7 @@ "error-copying-trail": "", "error-creating-user": "Erreur durant la création de l'utilisateur", "error-deleting-token": "", - "error-disabling-strava-integration": "Erreur lors de la désactivation de l'intégration Strava", + "error-disabling-strava-plugin": "Erreur lors de la désactivation du plugin Strava", "error-during-login": "Erreur durant la connexion", "error-during-password-reset": "Impossible d'envoyer l'e-mail de réinitialisation du mot de passe", "error-exporting-trail": "Erreur lors de l'export de l'itinéraire", @@ -157,11 +158,11 @@ "error-reading-file": "Erreur de lecture du fichier", "error-saving-list": "Erreur lors de l'enregistrement de la liste", "error-saving-trail": "Erreur lors de l'enregistrement de l'itinéraire", - "error-setting-up-integration": "Error setting up strava integration", - "error-updating-hammerhead-integration": "", - "error-updating-komoot-integration": "", + "error-setting-up-plugin": "Erreur lors de la configuration du plugin {provider}", + "error-updating-hammerhead-plugin": "", + "error-updating-komoot-plugin": "", "error-updating-password": "Erreur lors de la mise à jour du mot de passe", - "error-updating-strava-integration": "Erreur lors de la mise à jour de l'intégration Komoot", + "error-updating-strava-plugin": "Erreur lors de la mise à jour du plugin Strava", "error-uploading-trail-to-hammerhead": "", "est-duration": "Temps estimé", "everyone-with-the-link": "Tout le monde avec ce lien", @@ -202,7 +203,6 @@ "get-started": "C'est parti", "grid": "Grille", "grocery-store": "Épicerie", - "hammerhead-integration-after-date-hint": "", "heading": "Titre", "height": "Hauteur", "help": "Aide", @@ -224,14 +224,11 @@ "import-hint": "Sélectionnez ou glissez des fichiers GPX, FIT, KML ou TCX ici...", "include-description": "Inclure la description", "include-waypoints": "Inclure les points de passage", - "integration-description-hammerhead": "", - "integration-description-komoot": "Synchronisez vos Tours Komoot avec wanderer à intervalles réguliers.", - "integration-description-strava": "Synchronisez vos itinéraires et vos activités Strava avec wanderer à intervalles réguliers.", - "integration-disabled": "Intégration désactivée", - "integration-enabled": "Intégration activée", - "integration-privacy-hint-original": "", - "integration-privacy-hint-user": "", - "integrations": "Intégrations", + "plugin-disabled": "Plugin désactivé", + "plugin-enabled": "Plugin activé", + "plugin-privacy-hint-original": "", + "plugin-privacy-hint-user": "", + "plugins": "Plugins", "invalid-date": "Date invalide", "invalid-username": "Nom d'utilisateur invalide", "italian": "Italien", @@ -434,7 +431,6 @@ "statistics": "Statistiques", "stop-drawing": "Arrêter de tracer", "stop-editing": "Arrêter la modification", - "strava-integration-after-date-hint": "Si votre compte a une grande quantité d'activités, vous pouvez rencontrer la limite d'utilisation de l'API de Strava vous empêchant de synchroniser toutes les activités en même temps. Pour atténuer ce problème, vous pouvez définir une date \"Après-\" ci-dessous afin que seules les activités qui ont été enregistrées après cette date soient synchronisées.", "subway-stop": "Bouche de métro", "summit": "Sommet", "summit-book": "Liste des ascensions", diff --git a/web/src/lib/i18n/locales/hu.json b/web/src/lib/i18n/locales/hu.json index 0a40e85a..8e4517dd 100644 --- a/web/src/lib/i18n/locales/hu.json +++ b/web/src/lib/i18n/locales/hu.json @@ -3,6 +3,7 @@ "Canoeing": "Canoeing", "Climbing": "Climbing", "Hiking": "Hiking", + "Other": "Egyéb", "Skiing": "", "Walking": "Walking", "about": "A programról", @@ -144,7 +145,7 @@ "error-copying-trail": "", "error-creating-user": "Hiba felhasználó hozzáadása közben", "error-deleting-token": "", - "error-disabling-strava-integration": "Error disabling strava integration", + "error-disabling-strava-plugin": "Error disabling strava plugin", "error-during-login": "Hiba bejelentkezés közben", "error-during-password-reset": "Unable to send password reset email", "error-exporting-trail": "Error exporting trail", @@ -157,11 +158,11 @@ "error-reading-file": "Hiba a fájl olvasása közben", "error-saving-list": "Error saving list", "error-saving-trail": "Error saving trail", - "error-setting-up-integration": "Error setting up strava integration", - "error-updating-hammerhead-integration": "", - "error-updating-komoot-integration": "", + "error-setting-up-plugin": "Error setting up {provider} plugin", + "error-updating-hammerhead-plugin": "", + "error-updating-komoot-plugin": "", "error-updating-password": "Error updating password", - "error-updating-strava-integration": "Error updating komoot integration", + "error-updating-strava-plugin": "Error updating Strava plugin", "error-uploading-trail-to-hammerhead": "", "est-duration": "Becsült időtartam", "everyone-with-the-link": "Everyone with the link", @@ -202,7 +203,6 @@ "get-started": "Get started", "grid": "Grid", "grocery-store": "Grocery store", - "hammerhead-integration-after-date-hint": "", "heading": "Heading", "height": "Height", "help": "Help", @@ -224,14 +224,11 @@ "import-hint": "Select or drag GPX, FIT, KML or TCX files here...", "include-description": "Include description", "include-waypoints": "Útpontok hozzáadása", - "integration-description-hammerhead": "", - "integration-description-komoot": "Syncs your komoot tours with wanderer in regular intervals.", - "integration-description-strava": "Syncs your strava routes & activities with wanderer in regular intervals.", - "integration-disabled": "integration disabled", - "integration-enabled": "integration enabled", - "integration-privacy-hint-original": "", - "integration-privacy-hint-user": "", - "integrations": "Integrations", + "plugin-disabled": "plugin disabled", + "plugin-enabled": "plugin enabled", + "plugin-privacy-hint-original": "", + "plugin-privacy-hint-user": "", + "plugins": "Bővítmények", "invalid-date": "Érvénytelen dátum", "invalid-username": "Érvénytelen felhasználó", "italian": "Olasz", @@ -434,7 +431,6 @@ "statistics": "Statistics", "stop-drawing": "Stop drawing", "stop-editing": "Stop editing", - "strava-integration-after-date-hint": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced.", "subway-stop": "Subway entrance", "summit": "Summit", "summit-book": "Csúcspont könyv", diff --git a/web/src/lib/i18n/locales/it.json b/web/src/lib/i18n/locales/it.json index 57d4d55a..9428e300 100644 --- a/web/src/lib/i18n/locales/it.json +++ b/web/src/lib/i18n/locales/it.json @@ -3,6 +3,7 @@ "Canoeing": "Canoa", "Climbing": "Arrampicata", "Hiking": "Escursionismo", + "Other": "Altro", "Skiing": "", "Walking": "Camminare", "about": "Su di noi", @@ -144,7 +145,7 @@ "error-copying-trail": "", "error-creating-user": "Errore nella creazione dell'utente", "error-deleting-token": "", - "error-disabling-strava-integration": "Error disabling strava integration", + "error-disabling-strava-plugin": "Error disabling strava plugin", "error-during-login": "Errore durante il login", "error-during-password-reset": "Impossibile inviare email per ripristinare la password", "error-exporting-trail": "Errore durante l'esportazione del percorso", @@ -157,11 +158,11 @@ "error-reading-file": "Errore durante la lettura del file", "error-saving-list": "Errore salvando la lista", "error-saving-trail": "Errore nel salvataggio del percorso", - "error-setting-up-integration": "Error setting up strava integration", - "error-updating-hammerhead-integration": "", - "error-updating-komoot-integration": "", + "error-setting-up-plugin": "Errore durante la configurazione del plugin {provider}", + "error-updating-hammerhead-plugin": "", + "error-updating-komoot-plugin": "", "error-updating-password": "Errore nell'aggiornamento della password", - "error-updating-strava-integration": "Error updating komoot integration", + "error-updating-strava-plugin": "Errore durante l'aggiornamento del plugin Strava", "error-uploading-trail-to-hammerhead": "", "est-duration": "Durata stimata", "everyone-with-the-link": "Everyone with the link", @@ -202,7 +203,6 @@ "get-started": "Get started", "grid": "Griglia", "grocery-store": "Grocery store", - "hammerhead-integration-after-date-hint": "", "heading": "Heading", "height": "Height", "help": "Aiuto", @@ -224,14 +224,11 @@ "import-hint": "Seleziona o trascina qui i file GPX, FIT, KML o TCX...", "include-description": "Adotta descrizione", "include-waypoints": "Includi waypoint", - "integration-description-hammerhead": "", - "integration-description-komoot": "Syncs your komoot tours with wanderer in regular intervals.", - "integration-description-strava": "Syncs your strava routes & activities with wanderer in regular intervals.", - "integration-disabled": "integration disabled", - "integration-enabled": "integration enabled", - "integration-privacy-hint-original": "", - "integration-privacy-hint-user": "", - "integrations": "Integrations", + "plugin-disabled": "plugin disattivato", + "plugin-enabled": "plugin attivato", + "plugin-privacy-hint-original": "", + "plugin-privacy-hint-user": "", + "plugins": "Plugin", "invalid-date": "Data non valida", "invalid-username": "Nome utente non valido", "italian": "Italiano", @@ -434,7 +431,6 @@ "statistics": "Statistiche", "stop-drawing": "Smettere di disegnare", "stop-editing": "Stop editing", - "strava-integration-after-date-hint": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced.", "subway-stop": "Subway entrance", "summit": "Summit", "summit-book": "Libro di vetta", diff --git a/web/src/lib/i18n/locales/nl.json b/web/src/lib/i18n/locales/nl.json index 7ce6cec0..3e633add 100644 --- a/web/src/lib/i18n/locales/nl.json +++ b/web/src/lib/i18n/locales/nl.json @@ -3,6 +3,7 @@ "Canoeing": "Kanoën", "Climbing": "Klimmen", "Hiking": "Hiken", + "Other": "Overig", "Skiing": "", "Walking": "Wandelen", "about": "Over", @@ -144,7 +145,7 @@ "error-copying-trail": "", "error-creating-user": "Fout bij aanmaken gebruiker", "error-deleting-token": "", - "error-disabling-strava-integration": "Fout bij het uitschakelen van Strava-integratie", + "error-disabling-strava-plugin": "Fout bij het uitschakelen van Strava-plug-in", "error-during-login": "Het inloggen is mislukt", "error-during-password-reset": "Kan geen e-mail voor wachtwoordherstel verzenden", "error-exporting-trail": "Fout bij exporteren van parcours", @@ -157,11 +158,11 @@ "error-reading-file": "Fout bij inlezen bestand", "error-saving-list": "Fout bij bewaren van lijst", "error-saving-trail": "Fout bij bewaren van route", - "error-setting-up-integration": "Fout bij opzetten {provider} integratie", - "error-updating-hammerhead-integration": "", - "error-updating-komoot-integration": "", + "error-setting-up-plugin": "Fout bij instellen van {provider}-plug-in", + "error-updating-hammerhead-plugin": "", + "error-updating-komoot-plugin": "", "error-updating-password": "Fout bij bijwerken van wachtwoord", - "error-updating-strava-integration": "Fout bij bijwerken van Komoot integratie", + "error-updating-strava-plugin": "Fout bij bijwerken van Strava-plug-in", "error-uploading-trail-to-hammerhead": "", "est-duration": "Geschatte duur", "everyone-with-the-link": "Iedereen met de link", @@ -202,7 +203,6 @@ "get-started": "Aan de slag", "grid": "Rooster", "grocery-store": "Kruidenier", - "hammerhead-integration-after-date-hint": "", "heading": "Titel", "height": "Hoogte", "help": "Help", @@ -224,14 +224,11 @@ "import-hint": "Selecteer of sleep GPX-, FIT-, KML- of TCX-bestanden hierheen...", "include-description": "Inclusief beschrijving", "include-waypoints": "Waypoints toevoegen", - "integration-description-hammerhead": "", - "integration-description-komoot": "Synchroniseert je Komoot-tochten met Wanderer op regelmatige tijdstippen.", - "integration-description-strava": "Synchroniseert je Strava-routes en -activiteiten met Wanderer op regelmatige tijdstippen.", - "integration-disabled": "Integratie uitgeschakeld", - "integration-enabled": "Integratie ingeschakeld", - "integration-privacy-hint-original": "", - "integration-privacy-hint-user": "", - "integrations": "Integraties", + "plugin-disabled": "Plug-in uitgeschakeld", + "plugin-enabled": "Plug-in ingeschakeld", + "plugin-privacy-hint-original": "", + "plugin-privacy-hint-user": "", + "plugins": "Plug-ins", "invalid-date": "Ongeldige datum", "invalid-username": "Ongeldige gebruikersnaam", "italian": "Italiaans", @@ -434,7 +431,6 @@ "statistics": "Statistieken", "stop-drawing": "Stop met tekenen", "stop-editing": "Stop met bewerken", - "strava-integration-after-date-hint": "Als uw account een grote hoeveelheid activiteiten heeft, kunt u op de API-limiet van Strava botsen voorkomend dat u alle activiteiten tegelijk synchroniseert. Om dit probleem te omzeilen kunt u een \"Later\" datum hieronder instellen, zodat alleen activiteiten die na deze datum werden opgenomen worden gesynchroniseerd.", "subway-stop": "Metro toegang", "summit": "Top", "summit-book": "Bergtopboek", diff --git a/web/src/lib/i18n/locales/no.json b/web/src/lib/i18n/locales/no.json index ab9360e3..b4a1943b 100644 --- a/web/src/lib/i18n/locales/no.json +++ b/web/src/lib/i18n/locales/no.json @@ -3,6 +3,7 @@ "Canoeing": "Padling", "Climbing": "Klatring", "Hiking": "Vandring", + "Other": "Annet", "Skiing": "Skisport", "Walking": "Gåtur", "about": "Om", @@ -144,7 +145,7 @@ "error-copying-trail": "Feil ved kopiering av sti", "error-creating-user": "Feil ved oppretting av bruker", "error-deleting-token": "Feil ved sletting av token", - "error-disabling-strava-integration": "Feil ved deaktivering av Strava-integrasjon", + "error-disabling-strava-plugin": "Feil ved deaktivering av Strava-programtillegg", "error-during-login": "Feil under innlogging", "error-during-password-reset": "Kunne ikke sende e-post for tilbakestilling av passord", "error-exporting-trail": "Feil ved eksport av sti", @@ -157,11 +158,11 @@ "error-reading-file": "Feil ved lesing av fil", "error-saving-list": "Feil ved lagring av liste", "error-saving-trail": "Feil ved lagring av sti", - "error-setting-up-integration": "Feil ved oppsett av {provider}-integrasjon", - "error-updating-hammerhead-integration": "Feil ved oppdatering av Hammerhead-integrasjon", - "error-updating-komoot-integration": "Feil ved oppdatering av Komoot-integrasjon", + "error-setting-up-plugin": "Feil ved oppsett av {provider}-programtillegg", + "error-updating-hammerhead-plugin": "Feil ved oppdatering av Hammerhead-programtillegg", + "error-updating-komoot-plugin": "Feil ved oppdatering av Komoot-programtillegg", "error-updating-password": "Feil ved oppdatering av passord", - "error-updating-strava-integration": "Feil ved oppdatering av Komoot-integrasjon", + "error-updating-strava-plugin": "Feil ved oppdatering av Strava-programtillegg", "error-uploading-trail-to-hammerhead": "Feil ved opplasting av sti til Hammerhead", "est-duration": "Est. varighet", "everyone-with-the-link": "Alle med lenken", @@ -202,7 +203,6 @@ "get-started": "Kom i gang", "grid": "Rutenett", "grocery-store": "Dagligvarebutikk", - "hammerhead-integration-after-date-hint": "Hvis Hammerhead-kontoen din allerede er synkronisert med andre stidatabaser, som Komoot eller Strava, kan synkronisering av Hammerhead-data føre til duplikater. For å unngå dette kan du angi en startdato nedenfor, slik at bare aktiviteter registrert etter denne datoen vil bli synkronisert.", "heading": "Overskrift", "height": "Høyde", "help": "Hjelp", @@ -224,14 +224,13 @@ "import-hint": "Velg eller dra GPX, FIT, KML eller TCX-filer hit...", "include-description": "Inkluder beskrivelse", "include-waypoints": "Inkluder veipunkter", - "integration-description-hammerhead": "Synkroniserer Hammerhead-turene dine med Wanderer med jevne mellomrom.", - "integration-description-komoot": "Synkroniserer dine Komoot-turer med Wanderer med jevne mellomrom.", - "integration-description-strava": "Synkroniserer dine Strava-ruter og aktiviteter med Wanderer med jevne mellomrom.", - "integration-disabled": "integrasjon deaktivert", - "integration-enabled": "integrasjon aktivert", - "integration-privacy-hint-original": "Importerte stier vil beholde samme synlighet som de har på den eksterne plattformen. For eksempel, hvis den opprinnelige stien var offentlig, vil den være offentlig i Wanderer, selv om stier er private som standard i henhold til personverninnstillingene dine.", - "integration-privacy-hint-user": "Den opprinnelige stiens synlighet blir forkastet. I stedet blir de lokale personverninnstillingene for stier brukt på alle importerte stier.", - "integrations": "Integrasjoner", + "plugin-disabled": "programtillegg deaktivert", + "plugin-enabled": "programtillegg aktivert", + "plugin-privacy-hint-original": "Importerte stier vil beholde samme synlighet som de har på den eksterne plattformen. For eksempel, hvis den opprinnelige stien var offentlig, vil den være offentlig i Wanderer, selv om stier er private som standard i henhold til personverninnstillingene dine.", + "plugin-privacy-hint-user": "Den opprinnelige stiens synlighet blir forkastet. I stedet blir de lokale personverninnstillingene for stier brukt på alle importerte stier.", + "plugins": "Programtillegg", + "plugin-type-trails": "Trails", + "plugin-type-trails-description": "Trail-programtillegg kobler Wanderer til eksterne tjenester og synkroniserer ruter eller aktiviteter automatisk i bakgrunnen.", "invalid-date": "Ugyldig dato", "invalid-username": "Ugyldig brukernavn", "italian": "Italiensk", @@ -434,7 +433,6 @@ "statistics": "Statistikk", "stop-drawing": "Stopp tegning", "stop-editing": "Stopp redigering", - "strava-integration-after-date-hint": "Hvis kontoen din har en stor mengde aktiviteter kan du støte på Stravas API-hastighetsgrense som hindrer deg i å synkronisere alle aktiviteter samtidig. For å redusere dette problemet kan du sette en \"Etter\" dato nedenfor slik at bare aktiviteter som ble registrert etter denne datoen blir synkronisert.", "subway-stop": "T-baneinngang", "summit": "Topp", "summit-book": "Toppbok", diff --git a/web/src/lib/i18n/locales/pl.json b/web/src/lib/i18n/locales/pl.json index 8f8dfd9c..88cb2ce6 100644 --- a/web/src/lib/i18n/locales/pl.json +++ b/web/src/lib/i18n/locales/pl.json @@ -3,6 +3,7 @@ "Canoeing": "Kajak", "Climbing": "Wspinaczka", "Hiking": "Wędrówka", + "Other": "Inne", "Skiing": "", "Walking": "Spacer", "about": "Na temat", @@ -144,7 +145,7 @@ "error-copying-trail": "", "error-creating-user": "Błąd tworzenia użytkownika", "error-deleting-token": "", - "error-disabling-strava-integration": "Błąd przy wyłączaniu integracji strava", + "error-disabling-strava-plugin": "Błąd przy wyłączaniu wtyczki strava", "error-during-login": "Błąd podczas logowania", "error-during-password-reset": "Nie udało się wysłać e-maila z resetowaniem hasła", "error-exporting-trail": "Błąd podczas eksportowania szlaku", @@ -157,11 +158,11 @@ "error-reading-file": "Błąd wczytywania pliku", "error-saving-list": "Błąd przy zapisywaniu listy", "error-saving-trail": "Błąd podczas zapisywania szlaku", - "error-setting-up-integration": "Error setting up strava integration", - "error-updating-hammerhead-integration": "", - "error-updating-komoot-integration": "", + "error-setting-up-plugin": "Błąd konfiguracji wtyczki {provider}", + "error-updating-hammerhead-plugin": "", + "error-updating-komoot-plugin": "", "error-updating-password": "Błąd podczas aktualizacji hasła", - "error-updating-strava-integration": "Błąd aktualizacji integracji kamoot", + "error-updating-strava-plugin": "Błąd aktualizacji wtyczki Strava", "error-uploading-trail-to-hammerhead": "", "est-duration": "Szacowany czas", "everyone-with-the-link": "Everyone with the link", @@ -202,7 +203,6 @@ "get-started": "Get started", "grid": "Siatka", "grocery-store": "Grocery store", - "hammerhead-integration-after-date-hint": "", "heading": "Heading", "height": "Wysokość", "help": "Pomoc", @@ -224,14 +224,11 @@ "import-hint": "Wybierz lub przeciągnij tutaj plik GPX, FIT, KML lub TCX...", "include-description": "Dołącz opis", "include-waypoints": "Uwzględnij punkty trasy", - "integration-description-hammerhead": "", - "integration-description-komoot": "Synchronizuje trasy kamoot z wanderer w równych odstępach.", - "integration-description-strava": "Synchronizuje trasy i aktywność z wanderer w równych odstępach.", - "integration-disabled": "integracja wyłączona", - "integration-enabled": "integracja włączona", - "integration-privacy-hint-original": "", - "integration-privacy-hint-user": "", - "integrations": "Integracje", + "plugin-disabled": "wtyczka wyłączona", + "plugin-enabled": "wtyczka włączona", + "plugin-privacy-hint-original": "", + "plugin-privacy-hint-user": "", + "plugins": "Wtyczki", "invalid-date": "Nieprawidłowa data", "invalid-username": "Błędna nazwa użytkownika", "italian": "Włoski", @@ -434,7 +431,6 @@ "statistics": "Statystyki", "stop-drawing": "Przestań rysować", "stop-editing": "Zakończ edycję", - "strava-integration-after-date-hint": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced.", "subway-stop": "Subway entrance", "summit": "Summit", "summit-book": "Logbook", diff --git a/web/src/lib/i18n/locales/pt.json b/web/src/lib/i18n/locales/pt.json index 36f7af45..03a2d1c9 100644 --- a/web/src/lib/i18n/locales/pt.json +++ b/web/src/lib/i18n/locales/pt.json @@ -3,6 +3,7 @@ "Canoeing": "Canoagem", "Climbing": "Escalada", "Hiking": "Montanhismo", + "Other": "Outro", "Skiing": "", "Walking": "Caminhada", "about": "Sobre", @@ -144,7 +145,7 @@ "error-copying-trail": "", "error-creating-user": "Erro ao criar utilizador", "error-deleting-token": "", - "error-disabling-strava-integration": "Error disabling strava integration", + "error-disabling-strava-plugin": "Error disabling strava plugin", "error-during-login": "Erro durante o ‘login’", "error-during-password-reset": "Unable to send password reset email", "error-exporting-trail": "Erro na exportação do percurso", @@ -157,11 +158,11 @@ "error-reading-file": "Erro ao ler o arquivo", "error-saving-list": "Erro ao gravar lista", "error-saving-trail": "Erro ao gravar percurso", - "error-setting-up-integration": "Error setting up strava integration", - "error-updating-hammerhead-integration": "", - "error-updating-komoot-integration": "", + "error-setting-up-plugin": "Error setting up {provider} plugin", + "error-updating-hammerhead-plugin": "", + "error-updating-komoot-plugin": "", "error-updating-password": "Erro ao atualizar password", - "error-updating-strava-integration": "Error updating komoot integration", + "error-updating-strava-plugin": "Error updating Strava plugin", "error-uploading-trail-to-hammerhead": "", "est-duration": "Duração prevista", "everyone-with-the-link": "Everyone with the link", @@ -202,7 +203,6 @@ "get-started": "Get started", "grid": "Grelha", "grocery-store": "Grocery store", - "hammerhead-integration-after-date-hint": "", "heading": "Heading", "height": "Height", "help": "Ajuda", @@ -224,14 +224,11 @@ "import-hint": "Selecionar ou arrastar ficheiros GPX, FIT, KML ou TCX para aqui...", "include-description": "Incluir descrição", "include-waypoints": "Incluir pontos de passagem", - "integration-description-hammerhead": "", - "integration-description-komoot": "Syncs your komoot tours with wanderer in regular intervals.", - "integration-description-strava": "Syncs your strava routes & activities with wanderer in regular intervals.", - "integration-disabled": "integration disabled", - "integration-enabled": "integration enabled", - "integration-privacy-hint-original": "", - "integration-privacy-hint-user": "", - "integrations": "Integrations", + "plugin-disabled": "plugin disabled", + "plugin-enabled": "plugin enabled", + "plugin-privacy-hint-original": "", + "plugin-privacy-hint-user": "", + "plugins": "Plugins", "invalid-date": "Data inválida", "invalid-username": "Nome de usuário inválido", "italian": "Italiano", @@ -434,7 +431,6 @@ "statistics": "Statistics", "stop-drawing": "Parar desenho", "stop-editing": "Stop editing", - "strava-integration-after-date-hint": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced.", "subway-stop": "Subway entrance", "summit": "Summit", "summit-book": "Livro da cimeira", diff --git a/web/src/lib/i18n/locales/ru.json b/web/src/lib/i18n/locales/ru.json index 371fe72a..4da97669 100644 --- a/web/src/lib/i18n/locales/ru.json +++ b/web/src/lib/i18n/locales/ru.json @@ -3,6 +3,7 @@ "Canoeing": "Каякинг", "Climbing": "Скалолазание", "Hiking": "Пеший туризм", + "Other": "Другое", "Skiing": "", "Walking": "Прогулка", "about": "О", @@ -144,7 +145,7 @@ "error-copying-trail": "", "error-creating-user": "Ошибка создания пользователя", "error-deleting-token": "", - "error-disabling-strava-integration": "Ошибка отключения Strava", + "error-disabling-strava-plugin": "Ошибка отключения Strava", "error-during-login": "Ошибка входа", "error-during-password-reset": "Не удалось отправить email сброса пароля", "error-exporting-trail": "Ошибка экспорта трека", @@ -157,11 +158,11 @@ "error-reading-file": "Ошибка чтения файла", "error-saving-list": "Ошибка сохранения списка", "error-saving-trail": "Ошибка сохранения трека", - "error-setting-up-integration": "Ошибка настройки {provider}", - "error-updating-hammerhead-integration": "", - "error-updating-komoot-integration": "", + "error-setting-up-plugin": "Ошибка настройки плагина {provider}", + "error-updating-hammerhead-plugin": "", + "error-updating-komoot-plugin": "", "error-updating-password": "Ошибка изменения пароля", - "error-updating-strava-integration": "Ошибка обновления Strava", + "error-updating-strava-plugin": "Ошибка обновления плагина Strava", "error-uploading-trail-to-hammerhead": "", "est-duration": "Продолжительность", "everyone-with-the-link": "Everyone with the link", @@ -202,7 +203,6 @@ "get-started": "Get started", "grid": "Сетка", "grocery-store": "Продуктовый магазин", - "hammerhead-integration-after-date-hint": "", "heading": "Heading", "height": "Высота", "help": "Помощь", @@ -224,14 +224,11 @@ "import-hint": "Перетащите GPX, FIT, KML или TCX файлы сюда...", "include-description": "Добавить описание", "include-waypoints": "Include waypoints", - "integration-description-hammerhead": "", - "integration-description-komoot": "Синхронизирует ваши данные с Komoot.", - "integration-description-strava": "Синхронизирует ваши данные со Strava.", - "integration-disabled": "интеграция отключена", - "integration-enabled": "интеграция включена", - "integration-privacy-hint-original": "", - "integration-privacy-hint-user": "", - "integrations": "Интеграции", + "plugin-disabled": "плагин отключен", + "plugin-enabled": "плагин включен", + "plugin-privacy-hint-original": "", + "plugin-privacy-hint-user": "", + "plugins": "Плагины", "invalid-date": "Неверная дата", "invalid-username": "Некорректное имя пользователя", "italian": "Итальянский", @@ -434,7 +431,6 @@ "statistics": "Статистика", "stop-drawing": "Закончить рисование", "stop-editing": "Закончить редактирование", - "strava-integration-after-date-hint": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced.", "subway-stop": "Вход в метро", "summit": "Вершина", "summit-book": "История поездок", diff --git a/web/src/lib/i18n/locales/zh.json b/web/src/lib/i18n/locales/zh.json index e6181333..edc4e6a6 100644 --- a/web/src/lib/i18n/locales/zh.json +++ b/web/src/lib/i18n/locales/zh.json @@ -3,6 +3,7 @@ "Canoeing": "划艇", "Climbing": "攀岩", "Hiking": "徒步", + "Other": "其他", "Skiing": "", "Walking": "步行", "about": "关于", @@ -144,7 +145,7 @@ "error-copying-trail": "", "error-creating-user": "创建用户错误", "error-deleting-token": "", - "error-disabling-strava-integration": "禁用strava集成时出错", + "error-disabling-strava-plugin": "禁用strava 插件时出错", "error-during-login": "登录错误", "error-during-password-reset": "无法发送密码重置邮件", "error-exporting-trail": "导出路线失败", @@ -157,11 +158,11 @@ "error-reading-file": "读取文件错误", "error-saving-list": "保存列表失败", "error-saving-trail": "保存路线失败", - "error-setting-up-integration": "Error setting up strava integration", - "error-updating-hammerhead-integration": "", - "error-updating-komoot-integration": "", + "error-setting-up-plugin": "设置 {provider} 插件时出错", + "error-updating-hammerhead-plugin": "", + "error-updating-komoot-plugin": "", "error-updating-password": "更新密码失败", - "error-updating-strava-integration": "更新 komoot 集成出错", + "error-updating-strava-plugin": "更新 Strava 插件时出错", "error-uploading-trail-to-hammerhead": "", "est-duration": "预计时长", "everyone-with-the-link": "Everyone with the link", @@ -202,7 +203,6 @@ "get-started": "Get started", "grid": "网格", "grocery-store": "杂货店", - "hammerhead-integration-after-date-hint": "", "heading": "标题", "height": "高度", "help": "帮助", @@ -224,14 +224,11 @@ "import-hint": "在此选择或拖拽GPX、FIT、KML或TCX文件...", "include-description": "包含描述", "include-waypoints": "包括途径点", - "integration-description-hammerhead": "", - "integration-description-komoot": "定期与komoot同步您的wanderer。", - "integration-description-strava": "定期与strava同步您的wanderer路线和活动。", - "integration-disabled": "整合已停用", - "integration-enabled": "整合已启用", - "integration-privacy-hint-original": "", - "integration-privacy-hint-user": "", - "integrations": "整合", + "plugin-disabled": "插件已停用", + "plugin-enabled": "插件已启用", + "plugin-privacy-hint-original": "", + "plugin-privacy-hint-user": "", + "plugins": "插件", "invalid-date": "无效日期", "invalid-username": "无效用户名", "italian": "意大利语", @@ -434,7 +431,6 @@ "statistics": "统计", "stop-drawing": "停止绘制", "stop-editing": "停止编辑", - "strava-integration-after-date-hint": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced.", "subway-stop": "地铁入口", "summit": "山峰", "summit-book": "详细日程", diff --git a/web/src/lib/models/api/integration_schema.ts b/web/src/lib/models/api/integration_schema.ts deleted file mode 100644 index 8ad8a765..00000000 --- a/web/src/lib/models/api/integration_schema.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { z, ZodType } from "zod"; -import type { Integration } from "../integration"; - -const IntegrationMergeSchema = z.object({ - enabled: z.boolean(), -}) - -const StravaSchema = z.object({ - clientId: z.number({ coerce: true }).int().nonnegative(), - clientSecret: z.string().length(40).optional().or(z.literal('')), - routes: z.boolean(), - activities: z.boolean(), - active: z.boolean(), - after: z.string().date().optional(), - privacy: z.enum(["original", "settings"]), - merge: IntegrationMergeSchema, -}) - -const KomootSchema = z.object({ - email: z.string().email(), - password: z.string(), - completed: z.boolean(), - planned: z.boolean(), - active: z.boolean(), - privacy: z.enum(["original", "settings"]), - merge: IntegrationMergeSchema, -}) - -const HammerheadSchema = z.object({ - email: z.string().email(), - password: z.string(), - completed: z.boolean(), - planned: z.boolean(), - active: z.boolean(), - after: z.string().date().optional(), - merge: IntegrationMergeSchema, -}) - -const IntegrationCreateSchema = z.object({ - user: z.string().length(15), - strava: StravaSchema.optional(), - komoot: KomootSchema.optional(), - hammerhead: HammerheadSchema.optional(), - -}) satisfies ZodType - -const IntegrationUpdateSchema = z.object({ - strava: StravaSchema.optional().nullable(), - komoot: KomootSchema.optional().nullable(), - hammerhead: HammerheadSchema.optional().nullable(), -}) satisfies ZodType> - -export { StravaSchema, IntegrationCreateSchema, IntegrationUpdateSchema, KomootSchema, HammerheadSchema }; diff --git a/web/src/lib/models/api/openapi_schemas.ts b/web/src/lib/models/api/openapi_schemas.ts index bfb8585f..aa2a218a 100644 --- a/web/src/lib/models/api/openapi_schemas.ts +++ b/web/src/lib/models/api/openapi_schemas.ts @@ -918,106 +918,6 @@ * type: string * description: Followee user ID (15 chars) * - * Integration: - * type: object - * required: - * - id - * - user - * properties: - * id: - * type: string - * description: Integration ID (15 chars) - * user: - * type: string - * description: User ID (15 chars) - * strava: - * type: object - * properties: - * clientId: - * type: integer - * clientSecret: - * type: string - * routes: - * type: boolean - * activities: - * type: boolean - * active: - * type: boolean - * after: - * type: string - * format: date - * privacy: - * type: string - * enum: [original, settings] - * komoot: - * type: object - * properties: - * email: - * type: string - * format: email - * password: - * type: string - * completed: - * type: boolean - * planned: - * type: boolean - * active: - * type: boolean - * privacy: - * type: string - * enum: [original, settings] - * hammerhead: - * type: object - * properties: - * email: - * type: string - * format: email - * password: - * type: string - * completed: - * type: boolean - * planned: - * type: boolean - * active: - * type: boolean - * after: - * type: string - * format: date - * created: - * type: string - * format: date-time - * updated: - * type: string - * format: date-time - * - * IntegrationInput: - * type: object - * required: - * - user - * properties: - * user: - * type: string - * description: User ID (15 chars) - * strava: - * type: object - * komoot: - * type: object - * hammerhead: - * type: object - * - * IntegrationUpdateInput: - * type: object - * properties: - * strava: - * type: object - * nullable: true - * komoot: - * type: object - * nullable: true - * hammerhead: - * type: object - * nullable: true - * * Notification: * type: object * required: diff --git a/web/src/lib/models/api/plugin_instance_schema.ts b/web/src/lib/models/api/plugin_instance_schema.ts new file mode 100644 index 00000000..74d797c4 --- /dev/null +++ b/web/src/lib/models/api/plugin_instance_schema.ts @@ -0,0 +1,59 @@ +import { z } from "zod"; + +const PluginInstanceStatusSchema = z.enum([ + "configured", + "needs_auth", + "needs_reauth", + "syncing", + "rate_limited", + "unavailable", + "unsupported_protocol", + "error", + "disabled", +]); + +const OptionalJsonRecordSchema = z.preprocess( + (value) => (value === null ? undefined : value), + z.record(z.string(), z.unknown()).optional(), +); +const OptionalAuthSchema = z.preprocess( + (value) => { + if (value === null) { + return undefined; + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + return value; + } + return Object.fromEntries( + Object.entries(value).map(([key, fieldValue]) => [ + key, + fieldValue == null ? "" : String(fieldValue), + ]), + ); + }, + z.record(z.string(), z.string()).optional(), +); + +const PluginInstanceCreateSchema = z.object({ + user: z.string().length(15), + plugin_id: z.string().min(1).max(64).regex(/^[a-z0-9][a-z0-9_-]*$/), + enabled: z.boolean().default(false), + auth: OptionalAuthSchema, + config: OptionalJsonRecordSchema, + state: OptionalJsonRecordSchema, + status: PluginInstanceStatusSchema.optional(), +}); + +const PluginInstanceUpdateSchema = z.object({ + enabled: z.boolean().optional(), + auth: OptionalAuthSchema, + config: OptionalJsonRecordSchema, + state: OptionalJsonRecordSchema, + status: PluginInstanceStatusSchema.optional(), +}); + +export { + PluginInstanceCreateSchema, + PluginInstanceUpdateSchema, + PluginInstanceStatusSchema, +}; diff --git a/web/src/lib/models/integration.ts b/web/src/lib/models/integration.ts deleted file mode 100644 index fdbf389e..00000000 --- a/web/src/lib/models/integration.ts +++ /dev/null @@ -1,55 +0,0 @@ - -export interface BaseIntegration { - active: boolean -} - -export interface IntegrationMergeSettings { - enabled: boolean; -} - -export interface StravaIntegration extends BaseIntegration { - clientId: string | number; - clientSecret?: string; - routes: boolean; - activities: boolean; - accessToken?: string; - refreshToken?: string; - expiresAt?: number; - after?: string - privacy: "original" | "settings" - merge: IntegrationMergeSettings; -} - -export interface KomootIntegration extends BaseIntegration { - email: string, - password: string, - completed: boolean, - planned: boolean - privacy: "original" | "settings" - merge: IntegrationMergeSettings; -} - -export interface HammerheadIntegration extends BaseIntegration { - email: string, - password: string, - completed: boolean, - planned: boolean, - after?: string - merge: IntegrationMergeSettings; -} - - -export class Integration { - id?: string; - user: string; - strava?: StravaIntegration | null; - komoot?: KomootIntegration | null; - hammerhead?: HammerheadIntegration | null; - - constructor(user: string, strava?: StravaIntegration, komoot?: KomootIntegration, hammerhead?: HammerheadIntegration) { - this.user = user; - this.strava = strava; - this.komoot = komoot; - this.hammerhead = hammerhead; - } -} diff --git a/web/src/lib/models/plugin_instance.ts b/web/src/lib/models/plugin_instance.ts new file mode 100644 index 00000000..e060dd36 --- /dev/null +++ b/web/src/lib/models/plugin_instance.ts @@ -0,0 +1,25 @@ +export interface PluginInstance { + id?: string; + user: string; + plugin_id: string; + enabled: boolean; + auth?: Record; + config?: Record; + state?: Record; + status: + | "configured" + | "needs_auth" + | "needs_reauth" + | "syncing" + | "rate_limited" + | "unavailable" + | "unsupported_protocol" + | "error" + | "disabled"; + last_error?: { + code?: string; + message?: string; + }; + last_sync_at?: string; + retry_not_before?: string; +} diff --git a/web/src/lib/models/plugin_provider.ts b/web/src/lib/models/plugin_provider.ts new file mode 100644 index 00000000..7527cb12 --- /dev/null +++ b/web/src/lib/models/plugin_provider.ts @@ -0,0 +1,57 @@ +export type LocalizedTextMap = Record; + +export interface ConfigFieldOption { + value: string; + label?: string; + labels?: LocalizedTextMap; +} + +export interface ConfigField { + key: string; + type: "boolean" | "date" | "select" | "text" | "url"; + label?: string; + labels?: LocalizedTextMap; + description?: string; + descriptions?: LocalizedTextMap; + options?: ConfigFieldOption[]; + default?: unknown; + required?: boolean; + hidden?: boolean; +} + +export interface PluginProvider { + id: string; + type: "trails"; + name: string; + displayName?: string; + displayNames?: LocalizedTextMap; + description?: string; + descriptions?: LocalizedTextMap; + icon?: string; + iconDark?: string; + version?: string; + protocolVersion?: string; + risk?: string; + auth: { + type: string; + fields?: string[]; + secretFields?: string[]; + authorizationUrl?: string; + tokenUrl?: string; + tokenRequestFormat?: "json" | "form"; + scopes?: string[]; + scopeSeparator?: string; + authorizationParams?: Record; + pkce?: boolean; + tokenAuth?: string; + }; + configSchema?: ConfigField[]; + hostConfig?: Record; + metadata?: Record; + capabilities?: string[]; + limits?: { + recommendedBatchSize?: number; + }; + status: "available" | "disabled" | "error"; + error?: string; +} diff --git a/web/src/lib/models/plugin_system.ts b/web/src/lib/models/plugin_system.ts new file mode 100644 index 00000000..2f0dc66f --- /dev/null +++ b/web/src/lib/models/plugin_system.ts @@ -0,0 +1,45 @@ +export interface PluginSystemCapability { + name: string; + version: string; + export: string; + requiredHostFunctions?: string[]; + job?: string; +} + +export interface PluginSystemManifest { + manifestVersion: string; + id: string; + type: "trails"; + name: string; + displayName?: string; + description?: string; + icon?: string; + iconDark?: string; + version: string; + runtime: { + type: string; + entrypoint: string; + }; + capabilities: PluginSystemCapability[]; + auth?: Record; + permissions?: Record; + configSchema?: unknown[]; + hostConfig?: Record; + metadata?: Record; +} + +export interface PluginSystemPlugin { + id: string; + type: "trails"; + name: string; + displayName?: string; + description?: string; + icon?: string; + iconDark?: string; + version: string; + runtime: string; + capabilities: string[]; + status: "available" | "disabled" | "error"; + error?: string; + manifest: PluginSystemManifest; +} diff --git a/web/src/lib/models/trail.ts b/web/src/lib/models/trail.ts index e1811d69..d1c7f58b 100644 --- a/web/src/lib/models/trail.ts +++ b/web/src/lib/models/trail.ts @@ -256,4 +256,3 @@ export const defaultTrailSearchAttributes = [ export { Trail }; export type { TrailBoundingBox, TrailFilter, TrailFilterValues, TrailSearchResult }; - diff --git a/web/src/lib/stores/category_store.ts b/web/src/lib/stores/category_store.ts index 64aa9fd5..5caae7df 100644 --- a/web/src/lib/stores/category_store.ts +++ b/web/src/lib/stores/category_store.ts @@ -6,7 +6,7 @@ import { writable, type Writable } from "svelte/store"; export const categories: Writable = writable([]) export async function categories_index(f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch) { - const r = await f('/api/v1/category', { + const r = await f('/api/v1/category?perPage=-1&sort=name', { method: 'GET', }) if (!r.ok) { @@ -19,4 +19,4 @@ export async function categories_index(f: (url: RequestInfo | URL, config?: Requ categories.set(response.items); return response.items as Category[]; -} \ No newline at end of file +} diff --git a/web/src/lib/stores/integration_store.ts b/web/src/lib/stores/integration_store.ts deleted file mode 100644 index 12f188db..00000000 --- a/web/src/lib/stores/integration_store.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { Integration } from "$lib/models/integration"; -import { APIError } from "$lib/util/api_util"; -import { type ListResult } from "pocketbase"; -import { get, writable, type Writable } from "svelte/store"; -import { currentUser } from "./user_store"; - -export const integrations: Writable = writable([]) - -export async function integrations_index(f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch) { - let r = await f('/api/v1/integration' + new URLSearchParams({ - }), { - method: 'GET', - }) - - if (!r.ok) { - const response = await r.json(); - throw new APIError(r.status, response.message, response.detail) - } - - const fetchedIntegrations: ListResult = await r.json(); - - integrations.set(fetchedIntegrations.items); - - return fetchedIntegrations.items; -} - -export async function integrations_create(integration: Integration) { - const user = get(currentUser) - if (!user) { - throw Error("Unauthenticated") - } - - integration.user = user.id; - - let r = await fetch('/api/v1/integration', { - method: 'PUT', - body: JSON.stringify(integration), - }) - - if (!r.ok) { - const response = await r.json(); - throw new APIError(r.status, response.message, response.detail) - } - - const model: Integration = await r.json(); - - return model; -} - -export async function uploadGpx(integrationName: string, file: File) { - const formData = new FormData(); - formData.append('file', file); - - let r = await fetch(`/api/v1/integration/${integrationName}/upload`, { - method: 'POST', - body: formData, - }) - - if (!r.ok) { - const response = await r.json(); - throw new APIError(r.status, response.message, response.detail) - } -} - -export async function integrations_update(integration: Integration, f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch) { - let r = await f('/api/v1/integration/' + integration.id, { - method: 'POST', - body: JSON.stringify(integration), - }) - - if (!r.ok) { - const response = await r.json(); - throw new APIError(r.status, response.message, response.detail) - } - - const model: Integration = await r.json(); - - return model; -} - -export async function integrations_delete(integration: Integration) { - const r = await fetch('/api/v1/integration/' + integration.id, { - method: 'DELETE', - }) - - if (!r.ok) { - const response = await r.json(); - throw new APIError(r.status, response.message, response.detail) - } -} \ No newline at end of file diff --git a/web/src/lib/stores/plugin_instance_store.ts b/web/src/lib/stores/plugin_instance_store.ts new file mode 100644 index 00000000..83fe6edd --- /dev/null +++ b/web/src/lib/stores/plugin_instance_store.ts @@ -0,0 +1,183 @@ +import type { PluginInstance } from "$lib/models/plugin_instance"; +import { APIError } from "$lib/util/api_util"; +import type { ListResult } from "pocketbase"; +import { get, writable, type Writable } from "svelte/store"; +import { currentUser } from "./user_store"; + +export const pluginInstances: Writable = writable([]); + +export async function plugin_instances_index( + f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch, +) { + const r = await f("/api/v1/plugin-instance?perPage=-1", { + method: "GET", + }); + + if (!r.ok) { + const response = await r.json(); + throw new APIError(r.status, response.message, response.detail); + } + + const fetchedInstances: ListResult = await r.json(); + pluginInstances.set(fetchedInstances.items); + + return fetchedInstances.items; +} + +export async function plugin_instances_create( + instance: Partial, +) { + const user = get(currentUser); + if (!user) { + throw Error("Unauthenticated"); + } + + const r = await fetch("/api/v1/plugin-instance", { + method: "PUT", + body: JSON.stringify({ + ...instance, + user: user.id, + }), + }); + + if (!r.ok) { + const response = await r.json(); + throw new APIError(r.status, response.message, response.detail); + } + + return (await r.json()) as PluginInstance; +} + +export async function plugin_instances_update( + instance: PluginInstance, + f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch, +) { + if (!instance.id) { + throw Error("Plugin instance has no id"); + } + + const r = await f("/api/v1/plugin-instance/" + instance.id, { + method: "POST", + body: JSON.stringify(instance), + }); + + if (!r.ok) { + const response = await r.json(); + throw new APIError(r.status, response.message, response.detail); + } + + return (await r.json()) as PluginInstance; +} + +export async function plugin_oauth_start( + data: { + pluginId: string; + instanceId?: string; + authContext?: string; + redirectUri: string; + }, + f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch, +) { + const r = await f("/api/v1/plugin-system/oauth/start", { + method: "POST", + body: JSON.stringify(data), + }); + + if (!r.ok) { + const response = await r.json(); + throw new APIError(r.status, response.message, response.detail); + } + + return (await r.json()) as { url: string; state: string; instanceId: string }; +} + +export async function plugin_auth_validate( + data: { + pluginId: string; + instanceId?: string; + authContext?: string; + auth: Record; + }, + f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch, +) { + const r = await f("/api/v1/plugin-system/auth/validate", { + method: "POST", + body: JSON.stringify(data), + }); + + if (!r.ok) { + const response = await r.json(); + throw new APIError(r.status, response.message, response.detail); + } + + return (await r.json()) as { ok: boolean; authContext?: string }; +} + +export async function plugin_oauth_callback( + data: { instanceId: string; code: string; state: string }, + f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch, +) { + const r = await f("/api/v1/plugin-system/oauth/callback", { + method: "POST", + body: JSON.stringify(data), + }); + + if (!r.ok) { + const response = await r.json(); + throw new APIError(r.status, response.message, response.detail); + } + + return (await r.json()) as { ok: boolean }; +} + +export async function plugin_oauth_revoke( + instanceId: string, + f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch, +) { + const r = await f("/api/v1/plugin-system/oauth/revoke", { + method: "POST", + body: JSON.stringify({ instanceId }), + }); + + if (!r.ok) { + const response = await r.json(); + throw new APIError(r.status, response.message, response.detail); + } + + return (await r.json()) as { ok: boolean }; +} + +export async function plugin_category_remap_preview( + instanceId: string, + config?: Record, + f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch, +) { + const r = await f("/api/v1/plugin-system/category-remap/preview", { + method: "POST", + body: JSON.stringify({ instanceId, config }), + }); + + if (!r.ok) { + const response = await r.json(); + throw new APIError(r.status, response.message, response.detail); + } + + return (await r.json()) as { count: number; backfilledSinceMapping?: number }; +} + +export async function plugin_category_remap_apply( + instanceId: string, + f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch, +) { + const r = await f("/api/v1/plugin-system/category-remap/apply", { + method: "POST", + body: JSON.stringify({ instanceId }), + }); + + if (!r.ok) { + const response = await r.json(); + throw new APIError(r.status, response.message, response.detail); + } + + return (await r.json()) as { count: number; remapped?: number }; +} diff --git a/web/src/lib/stores/plugin_store.ts b/web/src/lib/stores/plugin_store.ts new file mode 100644 index 00000000..9a28acfe --- /dev/null +++ b/web/src/lib/stores/plugin_store.ts @@ -0,0 +1,125 @@ +import type { LocalizedTextMap, PluginProvider } from "$lib/models/plugin_provider"; +import type { PluginSystemPlugin } from "$lib/models/plugin_system"; +import { APIError } from "$lib/util/api_util"; +import { derived, writable, type Readable, type Writable } from "svelte/store"; +import { + pluginInstances, + plugin_instances_index, +} from "./plugin_instance_store"; + +export const pluginProviders: Writable = writable([]); + +// True when the user has at least one enabled plugin instance whose plugin +// advertises the trail send capability. Used to gate the trail "send to" action. +export const hasSendCapablePlugin: Readable = derived( + [pluginProviders, pluginInstances], + ([$plugins, $instances]) => { + const enabledProviders = new Set( + $instances.filter((a) => a.enabled).map((a) => a.plugin_id), + ); + return $plugins.some( + (p) => + p.status === "available" && + (p.capabilities ?? []).includes("prepare_trail_send.v1") && + enabledProviders.has(p.id), + ); + }, +); + +let pluginDataLoaded = false; + +// Loads plugins and instances once per session so the derived gating +// stores are populated (e.g. for the trail "send to" action). Safe to call +// repeatedly; only the first call performs the requests. +export async function load_plugin_data_once() { + if (pluginDataLoaded) { + return; + } + pluginDataLoaded = true; + try { + await Promise.all([ + plugins_index(), + plugin_instances_index(), + ]); + } catch (e) { + pluginDataLoaded = false; + console.error(e); + } +} + +export async function plugins_index( + f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch, +) { + const r = await f("/api/v1/plugin-system/plugins", { + method: "GET", + }); + + if (!r.ok) { + const response = await r.json(); + throw new APIError(r.status, response.message, response.detail); + } + + const data: { items: PluginSystemPlugin[] } = await r.json(); + const items = data.items.map(pluginSystemToPluginProvider); + pluginProviders.set(items); + + return items; +} + +function pluginSystemToPluginProvider(plugin: PluginSystemPlugin): PluginProvider { + const contexts = Object.entries( + (plugin.manifest.auth?.contexts ?? {}) as Record, + ); + const primaryAuth = contexts[0]?.[1] ?? {}; + const fields = + primaryAuth.fields ?? + primaryAuth.secretFields ?? + (primaryAuth.secretField ? [primaryAuth.secretField] : []); + const metadata = plugin.manifest.metadata ?? {}; + + return { + id: plugin.id, + type: plugin.type ?? plugin.manifest.type ?? "trails", + name: plugin.name, + displayName: plugin.displayName, + displayNames: localizedMetadata(metadata, "displayNames"), + description: plugin.description, + descriptions: localizedMetadata(metadata, "descriptions"), + icon: plugin.icon, + iconDark: plugin.iconDark, + version: plugin.version, + auth: { + type: primaryAuth.type ?? "none", + fields, + secretFields: primaryAuth.secretFields ?? fields, + authorizationUrl: primaryAuth.authorizationUrl, + tokenUrl: primaryAuth.tokenUrl, + tokenRequestFormat: primaryAuth.tokenRequestFormat, + scopes: primaryAuth.scopes, + scopeSeparator: primaryAuth.scopeSeparator, + authorizationParams: primaryAuth.authorizationParams, + pkce: primaryAuth.pkce, + tokenAuth: primaryAuth.tokenAuth, + }, + configSchema: plugin.manifest.configSchema as PluginProvider["configSchema"], + hostConfig: plugin.manifest.hostConfig, + metadata, + capabilities: plugin.capabilities, + status: plugin.status, + error: plugin.error, + }; +} + +function localizedMetadata( + metadata: Record, + key: string, +): LocalizedTextMap | undefined { + const value = metadata[key]; + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + const entries = Object.entries(value as Record).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ); + return entries.length ? Object.fromEntries(entries) : undefined; +} diff --git a/web/src/lib/stores/theme_store.ts b/web/src/lib/stores/theme_store.ts index bcff9011..31267104 100644 --- a/web/src/lib/stores/theme_store.ts +++ b/web/src/lib/stores/theme_store.ts @@ -1,31 +1,34 @@ import { browser } from "$app/environment"; import { get, writable, type Writable } from "svelte/store"; -type Theme = "dark" | "light" +type Theme = "dark" | "light"; export const theme: Writable = writable(getDefaultTheme()); function getDefaultTheme(): Theme { - if (browser) { - if (localStorage.getItem("theme")) { - return localStorage.getItem("theme") as Theme; - } else if (document.documentElement.classList.contains("light")) { - return "light" - } else if (document.documentElement.classList.contains("dark")) { - return "dark"; - } + if (!browser) { + return "light"; } - return "light"; - + const stored = localStorage.getItem("theme"); + if (stored === "dark" || stored === "light") { + return stored; + } + if (document.documentElement.classList.contains("dark")) { + return "dark"; + } + if (document.documentElement.classList.contains("light")) { + return "light"; + } + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; } export function toggleTheme() { const currentTheme = get(theme); const newTheme = currentTheme === "light" ? "dark" : "light"; - document.documentElement.classList.remove(currentTheme) - document.documentElement.classList.add(newTheme) - document.querySelector("meta[name='color-scheme']" )?.setAttribute("content", newTheme) - theme.set(newTheme) + document.documentElement.classList.remove(currentTheme); + document.documentElement.classList.add(newTheme); + document.querySelector("meta[name='color-scheme']")?.setAttribute("content", newTheme); + theme.set(newTheme); localStorage.setItem("theme", newTheme); } diff --git a/web/src/lib/util/api_util.ts b/web/src/lib/util/api_util.ts index bf1128d8..a13aeebd 100644 --- a/web/src/lib/util/api_util.ts +++ b/web/src/lib/util/api_util.ts @@ -25,7 +25,7 @@ export enum Collection { comments = "comments", feed = "feed", follows = "follows", - integrations = "integrations", + plugin_instances = "plugin_instances", list_share = "list_share", lists = "lists", notifications = "notifications", diff --git a/web/src/lib/util/plugin_error_i18n.ts b/web/src/lib/util/plugin_error_i18n.ts new file mode 100644 index 00000000..0132a00a --- /dev/null +++ b/web/src/lib/util/plugin_error_i18n.ts @@ -0,0 +1,112 @@ +import { get } from "svelte/store"; +import { _ } from "svelte-i18n"; +import { APIError } from "$lib/util/api_util"; + +type PluginErrorLike = { + code?: unknown; + message?: unknown; +}; + +const credentialErrorCodes = new Set(["auth_failed"]); +const reconnectErrorCodes = new Set(["invalid_grant", "unauthorized"]); +const unavailableErrorCodes = new Set(["provider_unavailable", "temporary_unavailable"]); +const internalErrorCodes = new Set(["internal_error", "plugin_error"]); + +const authErrorHints = [ + "auth_failed", + "login failed", + "email and password are required", +]; + +export function translatePluginError(code?: string, message?: string): string { + const normalizedCode = code?.trim(); + + if (normalizedCode && credentialErrorCodes.has(normalizedCode)) { + return get(_)("wrong-username-or-password"); + } + if (normalizedCode && reconnectErrorCodes.has(normalizedCode)) { + return get(_)("plugin-error-reconnect-required"); + } + if (normalizedCode === "rate_limited") { + return get(_)("plugin-error-rate-limited"); + } + if (normalizedCode && unavailableErrorCodes.has(normalizedCode)) { + return get(_)("plugin-error-provider-unavailable"); + } + if (normalizedCode === "invalid_request") { + return get(_)("plugin-error-invalid-request"); + } + if (normalizedCode && internalErrorCodes.has(normalizedCode)) { + return get(_)("plugin-error-internal"); + } + + return message?.trim() || get(_)("plugin-setup-error"); +} + +export function translatePluginAPIError(error: unknown, fallback: string): string { + if (error instanceof APIError) { + const pluginError = extractPluginError(error.detail); + if (pluginError?.code) { + return translatePluginError(String(pluginError.code), stringValue(pluginError.message)); + } + + const raw = `${error.message} ${stringValue(error.detail)}`.toLowerCase(); + if (authErrorHints.some((hint) => raw.includes(hint))) { + return get(_)("wrong-username-or-password"); + } + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return fallback; +} + +function extractPluginError(value: unknown): PluginErrorLike | undefined { + if (!value) { + return undefined; + } + + if (typeof value === "string") { + return extractPluginErrorFromString(value); + } + + if (typeof value !== "object") { + return undefined; + } + + const record = value as Record; + if (typeof record.code === "string") { + return record; + } + + for (const nested of Object.values(record)) { + const parsed = extractPluginError(nested); + if (parsed) { + return parsed; + } + } + + return undefined; +} + +function extractPluginErrorFromString(value: string): PluginErrorLike | undefined { + try { + const parsed = JSON.parse(value); + return extractPluginError(parsed); + } catch { + const match = value.match(/"code"\s*:\s*"([^"]+)"/); + return match ? { code: match[1] } : undefined; + } +} + +function stringValue(value: unknown): string | undefined { + if (typeof value === "string") { + return value; + } + if (value == null) { + return undefined; + } + return JSON.stringify(value); +} diff --git a/web/src/lib/util/plugin_i18n.ts b/web/src/lib/util/plugin_i18n.ts new file mode 100644 index 00000000..b26d30ed --- /dev/null +++ b/web/src/lib/util/plugin_i18n.ts @@ -0,0 +1,86 @@ +import type { ConfigField, ConfigFieldOption, LocalizedTextMap, PluginProvider } from "$lib/models/plugin_provider"; + +export function localizedText( + texts: LocalizedTextMap | undefined, + currentLocale: string | null | undefined, + fallback = "", +): string { + if (!texts) { + return fallback; + } + + const locale = normalizeLocale(currentLocale); + const language = locale.split("-")[0]; + const candidates = [locale, language, "en"]; + for (const candidate of candidates) { + const value = texts[candidate]?.trim(); + if (value) { + return value; + } + } + + const trimmedFallback = fallback.trim(); + if (trimmedFallback) { + return trimmedFallback; + } + + return ""; +} + +export function pluginTitle(plugin: PluginProvider, currentLocale: string | null | undefined): string { + return localizedText(plugin.displayNames, currentLocale, plugin.displayName || plugin.name); +} + +export function pluginDescription(plugin: PluginProvider, currentLocale: string | null | undefined): string { + return localizedText(plugin.descriptions, currentLocale, plugin.description ?? ""); +} + +export function configFieldLabel( + field: ConfigField, + currentLocale: string | null | undefined, + fallback: string, +): string { + return localizedText(field.labels, currentLocale, field.label || fallback); +} + +export function configFieldDescription( + field: ConfigField, + currentLocale: string | null | undefined, +): string | undefined { + return localizedText(field.descriptions, currentLocale, field.description ?? "") || undefined; +} + +export function configFieldOptionLabel( + option: ConfigFieldOption, + currentLocale: string | null | undefined, + fallback: string, +): string { + return localizedText(option.labels, currentLocale, option.label || fallback); +} + +export function providerCategoryLabel( + plugin: PluginProvider, + providerCategory: string, + currentLocale: string | null | undefined, +): string { + const providerCategories = plugin.metadata?.providerCategories; + if (!providerCategories || typeof providerCategories !== "object" || Array.isArray(providerCategories)) { + return providerCategory; + } + + const category = (providerCategories as Record)[providerCategory]; + if (!category || typeof category !== "object" || Array.isArray(category)) { + return providerCategory; + } + + const labels = (category as Record).labels; + if (!labels || typeof labels !== "object" || Array.isArray(labels)) { + return providerCategory; + } + + return localizedText(labels as LocalizedTextMap, currentLocale, providerCategory); +} + +function normalizeLocale(value: string | null | undefined): string { + return (value || "en").trim().toLowerCase().replace("_", "-"); +} diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 1f24fc39..cfed87bb 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -14,6 +14,7 @@ import PageLoadingBar from "$lib/components/page_loading_bar.svelte"; import UploadDialog from "$lib/components/settings/upload_dialog.svelte"; import { currentUser } from "$lib/stores/user_store"; + import { load_plugin_data_once } from "$lib/stores/plugin_store"; import { isRouteProtected } from "$lib/util/authorization_util"; import { onMount, type Snippet } from "svelte"; import { slide } from "svelte/transition"; @@ -42,6 +43,9 @@ if (page.data.origin != location.origin) { showWarning = true; } + if (data.user) { + load_plugin_data_once(); + } }); let hideDemoHint = $state(false); diff --git a/web/src/routes/api/v1/integration/+server.ts b/web/src/routes/api/v1/integration/+server.ts deleted file mode 100644 index 47d1180a..00000000 --- a/web/src/routes/api/v1/integration/+server.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { IntegrationCreateSchema } from "$lib/models/api/integration_schema"; -import type { Integration } from "$lib/models/integration"; -import { Collection, create, handleError, list } from "$lib/util/api_util"; -import { json, type RequestEvent } from "@sveltejs/kit"; - -/** - * @swagger - * /api/v1/integration: - * get: - * summary: List integrations - * tags: - * - Integrations - * parameters: - * - in: query - * name: page - * schema: - * type: integer - * - in: query - * name: perPage - * schema: - * type: integer - * - in: query - * name: sort - * schema: - * type: string - * - in: query - * name: filter - * schema: - * type: string - * - in: query - * name: expand - * schema: - * type: string - * responses: - * 200: - * description: List of integrations - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ListResult' - * 400: - * description: Bad Request - * 500: - * description: Internal Server Error - */ -export async function GET(event: RequestEvent) { - try { - const r = await list(event, Collection.integrations); - - return json(r) - } catch (e) { - return handleError(e) - } -} - -/** - * @swagger - * /api/v1/integration: - * put: - * summary: Create integration - * tags: - * - Integrations - * requestBody: - * required: true - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/IntegrationInput' - * responses: - * 201: - * description: Integration created - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Integration' - * 400: - * description: Bad Request - * 500: - * description: Internal Server Error - */ -export async function PUT(event: RequestEvent) { - try { - const r = await create(event, IntegrationCreateSchema, Collection.integrations) - return json(r); - } catch (e) { - return handleError(e) - } -} \ No newline at end of file diff --git a/web/src/routes/api/v1/integration/[id]/+server.ts b/web/src/routes/api/v1/integration/[id]/+server.ts deleted file mode 100644 index 2c620b98..00000000 --- a/web/src/routes/api/v1/integration/[id]/+server.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { IntegrationUpdateSchema } from "$lib/models/api/integration_schema"; -import type { Integration } from "$lib/models/integration"; -import { Collection, handleError, remove, show, update } from "$lib/util/api_util"; -import { json, type RequestEvent } from "@sveltejs/kit"; - -/** - * @swagger - * /api/v1/integration/{id}: - * get: - * summary: Get integration - * tags: - * - Integrations - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: string - * - in: query - * name: expand - * schema: - * type: string - * responses: - * 200: - * description: Integration details - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Integration' - * 404: - * description: Not Found - * 500: - * description: Internal Server Error - */ -export async function GET(event: RequestEvent) { - try { - const r = await show(event, Collection.integrations) - return json(r) - } catch (e: any) { - return handleError(e) - } -} - -/** - * @swagger - * /api/v1/integration/{id}: - * post: - * summary: Update integration - * tags: - * - Integrations - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: string - * requestBody: - * required: true - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/IntegrationUpdateInput' - * responses: - * 200: - * description: Integration updated - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Integration' - * 400: - * description: Bad Request - * 404: - * description: Not Found - * 500: - * description: Internal Server Error - */ -export async function POST(event: RequestEvent) { - try { - const r = await update(event, IntegrationUpdateSchema, Collection.integrations) - return json(r); - } catch (e: any) { - return handleError(e) - } -} - -/** - * @swagger - * /api/v1/integration/{id}: - * delete: - * summary: Delete integration - * tags: - * - Integrations - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Integration deleted - * 404: - * description: Not Found - * 500: - * description: Internal Server Error - */ -export async function DELETE(event: RequestEvent) { - try { - const r = await remove(event, Collection.integrations) - return json(r); - } catch (e: any) { - return handleError(e) - } -} diff --git a/web/src/routes/api/v1/integration/hammerhead/login/+server.ts b/web/src/routes/api/v1/integration/hammerhead/login/+server.ts deleted file mode 100644 index fc74a531..00000000 --- a/web/src/routes/api/v1/integration/hammerhead/login/+server.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { handleError } from "$lib/util/api_util"; -import { json, type RequestEvent } from "@sveltejs/kit"; - -/** - * @swagger - * /api/v1/integration/hammerhead/login: - * get: - * summary: Get Hammerhead login endpoint - * description: Proxies to backend to get Hammerhead login configuration - * tags: - * - Integrations - * responses: - * 200: - * description: Hammerhead login endpoint - * content: - * application/json: - * schema: - * type: object - * 500: - * description: Internal Server Error - */ -export async function GET(event: RequestEvent) { - try { - const r = await event.locals.pb.send("/integration/hammerhead/login", { - method: "GET", - }); - return json(r); - } catch (e: any) { - return handleError(e) - } -} \ No newline at end of file diff --git a/web/src/routes/api/v1/integration/komoot/login/+server.ts b/web/src/routes/api/v1/integration/komoot/login/+server.ts deleted file mode 100644 index 000e0975..00000000 --- a/web/src/routes/api/v1/integration/komoot/login/+server.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { handleError } from "$lib/util/api_util"; -import { json, type RequestEvent } from "@sveltejs/kit"; - -/** - * @swagger - * /api/v1/integration/komoot/login: - * get: - * summary: Get Komoot login endpoint - * description: Proxies to backend to get Komoot login configuration - * tags: - * - Integrations - * responses: - * 200: - * description: Komoot login endpoint - * content: - * application/json: - * schema: - * type: object - * 500: - * description: Internal Server Error - */ -export async function GET(event: RequestEvent) { - try { - const r = await event.locals.pb.send("/integration/komoot/login", { - method: "GET", - }); - return json(r); - } catch (e: any) { - return handleError(e) - } -} \ No newline at end of file diff --git a/web/src/routes/api/v1/plugin-instance/+server.ts b/web/src/routes/api/v1/plugin-instance/+server.ts new file mode 100644 index 00000000..7d35f7c0 --- /dev/null +++ b/web/src/routes/api/v1/plugin-instance/+server.ts @@ -0,0 +1,111 @@ +import { + PluginInstanceCreateSchema, +} from "$lib/models/api/plugin_instance_schema"; +import type { PluginInstance } from "$lib/models/plugin_instance"; +import { Collection, create, handleError, list } from "$lib/util/api_util"; +import { json, type RequestEvent } from "@sveltejs/kit"; + +/** + * @swagger + * /api/v1/plugin-instance: + * get: + * summary: List plugin instances + * description: Retrieves the authenticated user's configured plugin instances. + * tags: + * - Plugins + * parameters: + * - in: query + * name: page + * schema: + * type: integer + * - in: query + * name: perPage + * schema: + * type: integer + * - in: query + * name: sort + * schema: + * type: string + * - in: query + * name: filter + * schema: + * type: string + * responses: + * 200: + * description: ListResult + * 400: + * description: Bad Request + * 401: + * description: Unauthorized + * 500: + * description: Internal Server Error + */ +export async function GET(event: RequestEvent) { + try { + const r = await list(event, Collection.plugin_instances); + return json(r); + } catch (e) { + return handleError(e); + } +} + +/** + * @swagger + * /api/v1/plugin-instance: + * put: + * summary: Create plugin instance + * description: Creates a plugin instance for the authenticated user. + * tags: + * - Plugins + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - user + * - plugin_id + * properties: + * user: + * type: string + * description: User record ID + * plugin_id: + * type: string + * enabled: + * type: boolean + * auth: + * type: object + * additionalProperties: + * type: string + * config: + * type: object + * additionalProperties: true + * state: + * type: object + * additionalProperties: true + * status: + * type: string + * enum: [configured, needs_auth, needs_reauth, syncing, rate_limited, unavailable, unsupported_protocol, error, disabled] + * responses: + * 200: + * description: Plugin instance created + * 400: + * description: Bad Request + * 401: + * description: Unauthorized + * 500: + * description: Internal Server Error + */ +export async function PUT(event: RequestEvent) { + try { + const r = await create( + event, + PluginInstanceCreateSchema, + Collection.plugin_instances, + ); + return json(r); + } catch (e) { + return handleError(e); + } +} diff --git a/web/src/routes/api/v1/plugin-instance/[id]/+server.ts b/web/src/routes/api/v1/plugin-instance/[id]/+server.ts new file mode 100644 index 00000000..5e0fd868 --- /dev/null +++ b/web/src/routes/api/v1/plugin-instance/[id]/+server.ts @@ -0,0 +1,138 @@ +import { + PluginInstanceUpdateSchema, +} from "$lib/models/api/plugin_instance_schema"; +import type { PluginInstance } from "$lib/models/plugin_instance"; +import { Collection, handleError, remove, show, update } from "$lib/util/api_util"; +import { json, type RequestEvent } from "@sveltejs/kit"; + +/** + * @swagger + * /api/v1/plugin-instance/{id}: + * get: + * summary: Get plugin instance + * description: Retrieves a plugin instance by ID. + * tags: + * - Plugins + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: Plugin instance record ID + * responses: + * 200: + * description: Plugin instance details + * 400: + * description: Bad Request + * 401: + * description: Unauthorized + * 404: + * description: Not Found + * 500: + * description: Internal Server Error + */ +export async function GET(event: RequestEvent) { + try { + const r = await show(event, Collection.plugin_instances); + return json(r); + } catch (e: any) { + return handleError(e); + } +} + +/** + * @swagger + * /api/v1/plugin-instance/{id}: + * post: + * summary: Update plugin instance + * description: Updates plugin instance settings, auth, state, or status. + * tags: + * - Plugins + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: Plugin instance record ID + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * enabled: + * type: boolean + * auth: + * type: object + * additionalProperties: + * type: string + * config: + * type: object + * additionalProperties: true + * state: + * type: object + * additionalProperties: true + * status: + * type: string + * enum: [configured, needs_auth, needs_reauth, syncing, rate_limited, unavailable, unsupported_protocol, error, disabled] + * responses: + * 200: + * description: Plugin instance updated + * 400: + * description: Bad Request + * 401: + * description: Unauthorized + * 404: + * description: Not Found + * 500: + * description: Internal Server Error + */ +export async function POST(event: RequestEvent) { + try { + const r = await update( + event, + PluginInstanceUpdateSchema, + Collection.plugin_instances, + ); + return json(r); + } catch (e: any) { + return handleError(e); + } +} + +/** + * @swagger + * /api/v1/plugin-instance/{id}: + * delete: + * summary: Delete plugin instance + * description: Deletes a configured plugin instance. + * tags: + * - Plugins + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: Plugin instance record ID + * responses: + * 200: + * description: Success + * 401: + * description: Unauthorized + * 404: + * description: Not Found + * 500: + * description: Internal Server Error + */ +export async function DELETE(event: RequestEvent) { + try { + const r = await remove(event, Collection.plugin_instances); + return json(r); + } catch (e: any) { + return handleError(e); + } +} diff --git a/web/src/routes/api/v1/plugin-system/auth/validate/+server.ts b/web/src/routes/api/v1/plugin-system/auth/validate/+server.ts new file mode 100644 index 00000000..b981a65f --- /dev/null +++ b/web/src/routes/api/v1/plugin-system/auth/validate/+server.ts @@ -0,0 +1,63 @@ +import { handleError } from "$lib/util/api_util"; +import { json, type RequestEvent } from "@sveltejs/kit"; + +/** + * @swagger + * /api/v1/plugin-system/auth/validate: + * post: + * summary: Validate plugin session auth + * description: Validates session-style plugin credentials before saving them. + * tags: + * - Plugins + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - pluginId + * - auth + * properties: + * pluginId: + * type: string + * instanceId: + * type: string + * authContext: + * type: string + * auth: + * type: object + * additionalProperties: + * type: string + * responses: + * 200: + * description: Auth validation result + * content: + * application/json: + * schema: + * type: object + * properties: + * ok: + * type: boolean + * authContext: + * type: string + * 400: + * description: Bad Request + * 401: + * description: Unauthorized + * 500: + * description: Internal Server Error + */ +export async function POST(event: RequestEvent) { + try { + const body = await event.request.json(); + const r = await event.locals.pb.send("/plugins/auth/validate", { + method: "POST", + body, + fetch: event.fetch, + }); + return json(r); + } catch (e) { + return handleError(e); + } +} diff --git a/web/src/routes/api/v1/plugin-system/category-remap/apply/+server.ts b/web/src/routes/api/v1/plugin-system/category-remap/apply/+server.ts new file mode 100644 index 00000000..c13833e0 --- /dev/null +++ b/web/src/routes/api/v1/plugin-system/category-remap/apply/+server.ts @@ -0,0 +1,48 @@ +import { handleError } from "$lib/util/api_util"; +import { json, type RequestEvent } from "@sveltejs/kit"; + +/** + * @swagger + * /api/v1/plugin-system/category-remap/apply: + * post: + * summary: Apply plugin category remap + * description: Updates imported trails to the category configured for their stored provider category in the current plugin instance mapping. + * tags: + * - Plugins + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - instanceId + * properties: + * instanceId: + * type: string + * description: Plugin instance record ID + * responses: + * 200: + * description: Remap result + * 400: + * description: Bad Request + * 401: + * description: Unauthorized + * 404: + * description: Not Found + * 500: + * description: Internal Server Error + */ +export async function POST(event: RequestEvent) { + try { + const body = await event.request.json(); + const r = await event.locals.pb.send("/plugins/category-remap/apply", { + method: "POST", + body, + fetch: event.fetch, + }); + return json(r); + } catch (e: any) { + return handleError(e); + } +} diff --git a/web/src/routes/api/v1/plugin-system/category-remap/preview/+server.ts b/web/src/routes/api/v1/plugin-system/category-remap/preview/+server.ts new file mode 100644 index 00000000..80031802 --- /dev/null +++ b/web/src/routes/api/v1/plugin-system/category-remap/preview/+server.ts @@ -0,0 +1,48 @@ +import { handleError } from "$lib/util/api_util"; +import { json, type RequestEvent } from "@sveltejs/kit"; + +/** + * @swagger + * /api/v1/plugin-system/category-remap/preview: + * post: + * summary: Preview plugin category remap + * description: Counts imported trails whose stored provider category can be mapped by the current plugin instance category mapping. + * tags: + * - Plugins + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - instanceId + * properties: + * instanceId: + * type: string + * description: Plugin instance record ID + * responses: + * 200: + * description: Remap preview + * 400: + * description: Bad Request + * 401: + * description: Unauthorized + * 404: + * description: Not Found + * 500: + * description: Internal Server Error + */ +export async function POST(event: RequestEvent) { + try { + const body = await event.request.json(); + const r = await event.locals.pb.send("/plugins/category-remap/preview", { + method: "POST", + body, + fetch: event.fetch, + }); + return json(r); + } catch (e: any) { + return handleError(e); + } +} diff --git a/web/src/routes/api/v1/plugin-system/oauth/callback/+server.ts b/web/src/routes/api/v1/plugin-system/oauth/callback/+server.ts new file mode 100644 index 00000000..fb3a6a5a --- /dev/null +++ b/web/src/routes/api/v1/plugin-system/oauth/callback/+server.ts @@ -0,0 +1,58 @@ +import { handleError } from "$lib/util/api_util"; +import { json, type RequestEvent } from "@sveltejs/kit"; + +/** + * @swagger + * /api/v1/plugin-system/oauth/callback: + * post: + * summary: Complete plugin OAuth flow + * description: Exchanges an OAuth authorization code and stores the resulting plugin credentials. + * tags: + * - Plugins + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - instanceId + * - code + * - state + * properties: + * instanceId: + * type: string + * code: + * type: string + * state: + * type: string + * responses: + * 200: + * description: OAuth callback handled + * content: + * application/json: + * schema: + * type: object + * properties: + * ok: + * type: boolean + * 400: + * description: Bad Request + * 401: + * description: Unauthorized + * 500: + * description: Internal Server Error + */ +export async function POST(event: RequestEvent) { + try { + const body = await event.request.json(); + const r = await event.locals.pb.send("/plugins/oauth/callback", { + method: "POST", + body, + fetch: event.fetch, + }); + return json(r); + } catch (e) { + return handleError(e); + } +} diff --git a/web/src/routes/api/v1/integration/hammerhead/upload/+server.ts b/web/src/routes/api/v1/plugin-system/oauth/revoke/+server.ts similarity index 51% rename from web/src/routes/api/v1/integration/hammerhead/upload/+server.ts rename to web/src/routes/api/v1/plugin-system/oauth/revoke/+server.ts index c558d2cc..fd4e4299 100644 --- a/web/src/routes/api/v1/integration/hammerhead/upload/+server.ts +++ b/web/src/routes/api/v1/plugin-system/oauth/revoke/+server.ts @@ -3,52 +3,52 @@ import { json, type RequestEvent } from "@sveltejs/kit"; /** * @swagger - * /api/v1/integration/hammerhead/upload: + * /api/v1/plugin-system/oauth/revoke: * post: - * summary: Upload via Hammerhead integration - * description: Proxies file upload to backend Hammerhead integration + * summary: Revoke plugin OAuth credentials + * description: Revokes OAuth credentials stored for a plugin instance. * tags: - * - Integrations + * - Plugins * requestBody: * required: true * content: - * multipart/form-data: + * application/json: * schema: * type: object * required: - * - file + * - instanceId * properties: - * file: + * instanceId: + * type: string + * authContext: * type: string - * format: binary * responses: * 200: - * description: Upload result + * description: OAuth credentials revoked * content: * application/json: * schema: * type: object + * properties: + * ok: + * type: boolean * 400: * description: Bad Request + * 401: + * description: Unauthorized * 500: * description: Internal Server Error */ export async function POST(event: RequestEvent) { try { - const formData = await event.request.formData(); - const file = formData.get("file"); - - if (!(file instanceof Blob)) { - return json({ message: "missing_file" }, { status: 400 }); - } - - const r = await event.locals.pb.send("/integration/hammerhead/upload", { + const body = await event.request.json(); + const r = await event.locals.pb.send("/plugins/oauth/revoke", { method: "POST", - body: formData, + body, fetch: event.fetch, }); return json(r); - } catch (e: any) { - return handleError(e) + } catch (e) { + return handleError(e); } } diff --git a/web/src/routes/api/v1/plugin-system/oauth/start/+server.ts b/web/src/routes/api/v1/plugin-system/oauth/start/+server.ts new file mode 100644 index 00000000..d5db33b3 --- /dev/null +++ b/web/src/routes/api/v1/plugin-system/oauth/start/+server.ts @@ -0,0 +1,66 @@ +import { handleError } from "$lib/util/api_util"; +import { json, type RequestEvent } from "@sveltejs/kit"; + +/** + * @swagger + * /api/v1/plugin-system/oauth/start: + * post: + * summary: Start plugin OAuth flow + * description: Creates an OAuth authorization URL for a plugin instance. + * tags: + * - Plugins + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - pluginId + * - instanceId + * - redirectUri + * properties: + * pluginId: + * type: string + * instanceId: + * type: string + * authContext: + * type: string + * redirectUri: + * type: string + * format: uri + * responses: + * 200: + * description: OAuth authorization URL + * content: + * application/json: + * schema: + * type: object + * properties: + * url: + * type: string + * format: uri + * state: + * type: string + * instanceId: + * type: string + * 400: + * description: Bad Request + * 401: + * description: Unauthorized + * 500: + * description: Internal Server Error + */ +export async function POST(event: RequestEvent) { + try { + const body = await event.request.json(); + const r = await event.locals.pb.send("/plugins/oauth/start", { + method: "POST", + body, + fetch: event.fetch, + }); + return json(r); + } catch (e) { + return handleError(e); + } +} diff --git a/web/src/routes/api/v1/plugin-system/plugins/+server.ts b/web/src/routes/api/v1/plugin-system/plugins/+server.ts new file mode 100644 index 00000000..59c293c9 --- /dev/null +++ b/web/src/routes/api/v1/plugin-system/plugins/+server.ts @@ -0,0 +1,60 @@ +import { handleError } from "$lib/util/api_util"; +import { json, type RequestEvent } from "@sveltejs/kit"; + +/** + * @swagger + * /api/v1/plugin-system/plugins: + * get: + * summary: List installed plugins + * description: Refreshes the plugin cache and lists locally installed plugin providers with runtime status and manifest metadata. + * tags: + * - Plugins + * responses: + * 200: + * description: Installed plugin providers + * content: + * application/json: + * schema: + * type: object + * properties: + * items: + * type: array + * items: + * type: object + * properties: + * id: + * type: string + * type: + * type: string + * name: + * type: string + * version: + * type: string + * runtime: + * type: string + * capabilities: + * type: array + * items: + * type: string + * status: + * type: string + * enum: [available, disabled, error] + * error: + * type: string + * manifest: + * type: object + * 401: + * description: Unauthorized + * 500: + * description: Internal Server Error + */ +export async function GET(event: RequestEvent) { + try { + const r = await event.locals.pb.send("/plugins", { + method: "GET", + }); + return json(r); + } catch (e: any) { + return handleError(e); + } +} diff --git a/web/src/routes/api/v1/plugin-system/trail-send/+server.ts b/web/src/routes/api/v1/plugin-system/trail-send/+server.ts new file mode 100644 index 00000000..8bd17352 --- /dev/null +++ b/web/src/routes/api/v1/plugin-system/trail-send/+server.ts @@ -0,0 +1,62 @@ +import { handleError } from "$lib/util/api_util"; +import { json, type RequestEvent } from "@sveltejs/kit"; + +/** + * @swagger + * /api/v1/plugin-system/trail-send: + * post: + * summary: Send trail through plugin + * description: Sends an existing trail to an enabled plugin provider that supports trail transfer. + * tags: + * - Plugins + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - pluginId + * - trailId + * properties: + * pluginId: + * type: string + * trailId: + * type: string + * share: + * type: string + * description: Optional share token used to authorize sending a shared trail. + * responses: + * 200: + * description: Trail sent + * content: + * application/json: + * schema: + * type: object + * properties: + * ok: + * type: boolean + * 400: + * description: Bad Request + * 401: + * description: Unauthorized + * 403: + * description: Forbidden + * 404: + * description: Not Found + * 500: + * description: Internal Server Error + */ +export async function POST(event: RequestEvent) { + try { + const body = await event.request.json(); + const r = await event.locals.pb.send("/plugins/trail-send", { + method: "POST", + body, + fetch: event.fetch, + }); + return json(r); + } catch (e: any) { + return handleError(e); + } +} diff --git a/web/src/routes/settings/+layout.svelte b/web/src/routes/settings/+layout.svelte index 5791d7e4..d79fb789 100644 --- a/web/src/routes/settings/+layout.svelte +++ b/web/src/routes/settings/+layout.svelte @@ -21,7 +21,7 @@ }, { text: $_("notifications"), value: "/settings/notifications" }, { text: $_("map"), value: "/settings/map" }, - { text: $_("integrations"), value: "/settings/integrations" }, + { text: $_("plugins"), value: "/settings/plugins" }, { text: $_("similar-trails"), value: "/settings/maintenance/similar-trails" }, { text: `${$_("import")}/${$_("export")}`, value: "/settings/export" }, { diff --git a/web/src/routes/settings/integrations/+page.svelte b/web/src/routes/settings/integrations/+page.svelte deleted file mode 100644 index 88a3d16f..00000000 --- a/web/src/routes/settings/integrations/+page.svelte +++ /dev/null @@ -1,278 +0,0 @@ - - - - {$_("settings")} | wanderer - - -

{$_("integrations")}

-
- -
- stravaSettingsModal.openModal()} - ontoggle={onStravaToggle} - > - komootSettingsModal.openModal()} - ontoggle={onKomootToggle} - > - hammerheadSettingsModal.openModal()} - ontoggle={onHammerheadToggle} - > -
- - onSettingsSave(form, "strava")} -> - - onSettingsSave(form, "komoot")} -> - - onSettingsSave(form, "hammerhead")} -> diff --git a/web/src/routes/settings/integrations/+page.ts b/web/src/routes/settings/integrations/+page.ts deleted file mode 100644 index 866b53bb..00000000 --- a/web/src/routes/settings/integrations/+page.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { integrations_index } from "$lib/stores/integration_store"; -import { type Load } from "@sveltejs/kit"; - -export const load: Load = async ({ params, fetch }) => { - const integrations = await integrations_index(fetch) - return { integration: integrations.at(0) } -}; \ No newline at end of file diff --git a/web/src/routes/settings/integrations/callback/strava/+page.server.ts b/web/src/routes/settings/integrations/callback/strava/+page.server.ts deleted file mode 100644 index b7853de9..00000000 --- a/web/src/routes/settings/integrations/callback/strava/+page.server.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { error, redirect, type ServerLoad } from "@sveltejs/kit"; -import { ClientResponseError } from "pocketbase"; - -export const load: ServerLoad = async ({ url, fetch, locals }) => { - const oauthError = url.searchParams.get('error'); - if (oauthError) { - // user cancelled - if (oauthError == "access_denied") { - return redirect(302, '/settings/integrations') - } - return error(400, { - message: oauthError - }); - } - const code = url.searchParams.get('code'); - if (!code) { - return error(400, { - message: "No code provided" - }); - } - - try { - await locals.pb.send("/integration/strava/token", { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - code, - grant_type: 'authorization_code' - }) - }); - } catch (e) { - console.error(e) - - if (e instanceof ClientResponseError) { - return error(e.status, e.message); - - } - throw e - } - return redirect(302, '/settings/integrations') -} \ No newline at end of file diff --git a/web/src/routes/settings/plugins/+page.svelte b/web/src/routes/settings/plugins/+page.svelte new file mode 100644 index 00000000..afd1d669 --- /dev/null +++ b/web/src/routes/settings/plugins/+page.svelte @@ -0,0 +1,561 @@ + + + + {$_("settings")} | wanderer + + +

{$_("plugins")}

+
+ +{#if pluginGroups.length === 0} +
+ +

{$_("plugins-empty-title")}

+

+ {$_("plugins-empty-description")} +

+ + + {$_("plugins-empty-docs-link")} + +
+{:else} +
+ {#each pluginGroups as group (group.type)} +
+
+

{pluginTypeTitle(group.type)}

+

+ {pluginTypeDescription(group.type)} +

+
+
+ {#each group.plugins as plugin (plugin.id)} + {@const instance = instanceForPlugin(plugin)} + {@const settingsDisabled = plugin.status != "available"} + openPluginSettings(plugin)} + ontoggle={(value) => onPluginToggle(plugin, instance, value)} + > + {/each} +
+
+ {/each} +
+{/if} + +{#if pendingCategoryRemap} + +{/if} + +{#if selectedPlugin} + {#key pluginSettingsModalKey(selectedPlugin)} + + {/key} +{/if} diff --git a/web/src/routes/settings/plugins/+page.ts b/web/src/routes/settings/plugins/+page.ts new file mode 100644 index 00000000..0956b4fe --- /dev/null +++ b/web/src/routes/settings/plugins/+page.ts @@ -0,0 +1,13 @@ +import { plugin_instances_index } from "$lib/stores/plugin_instance_store"; +import { plugins_index } from "$lib/stores/plugin_store"; +import { categories_index } from "$lib/stores/category_store"; +import { type Load } from "@sveltejs/kit"; + +export const load: Load = async ({ fetch }) => { + const [pluginInstances, pluginProviders, categories] = await Promise.all([ + plugin_instances_index(fetch), + plugins_index(fetch), + categories_index(fetch), + ]); + return { pluginInstances, pluginProviders, categories }; +}; diff --git a/web/src/routes/settings/plugins/oauth/callback/+page.svelte b/web/src/routes/settings/plugins/oauth/callback/+page.svelte new file mode 100644 index 00000000..47635ff0 --- /dev/null +++ b/web/src/routes/settings/plugins/oauth/callback/+page.svelte @@ -0,0 +1,47 @@ + + + + {$_("plugins")} | wanderer + + +
+ {#if error} +

{$_("error")}

+

{error}

+ {$_("plugins")} + {:else} +

{$_("plugins")}

+ {/if} +