diff --git a/.gitattributes b/.gitattributes index b88429c87..36b00d92f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -26,3 +26,5 @@ *.eot binary *.pdf binary *.zip binary +# A GPX fixture whose byte-order mark and CRLF line endings are what it tests +server/tests/fixtures/gpx/bom-crlf.gpx -text diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6bd407d47..4d7a54e81 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -51,6 +51,12 @@ on: - 'package-lock.json' - 'sonar-project.properties' - '.github/workflows/test.yml' + # The image's start-up shape is pinned by a unit test + # (server/tests/unit/docker-entrypoint.test.ts), so a change to the + # Dockerfile or to the entrypoint script has to run the suite — that is + # exactly the change the test exists to catch. + - 'Dockerfile' + - 'server/scripts/**' # Docs inside the workspaces (CLAUDE.md, READMEs, PATTERN.md) affect # neither the tests nor the scan — no test reads checked-in markdown # and SonarCloud has no markdown analyzer. Negations must stay last. @@ -69,6 +75,12 @@ on: - 'package-lock.json' - 'sonar-project.properties' - '.github/workflows/test.yml' + # The image's start-up shape is pinned by a unit test + # (server/tests/unit/docker-entrypoint.test.ts), so a change to the + # Dockerfile or to the entrypoint script has to run the suite — that is + # exactly the change the test exists to catch. + - 'Dockerfile' + - 'server/scripts/**' # Docs inside the workspaces (CLAUDE.md, READMEs, PATTERN.md) affect # neither the tests nor the scan — no test reads checked-in markdown # and SonarCloud has no markdown analyzer. Negations must stay last. diff --git a/Dockerfile b/Dockerfile index d11fdb0dd..9a23437a1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -62,7 +62,8 @@ RUN apt-get update && \ rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx && \ chown -R node:node /app -# gosu rebuilt with a current Go toolchain (stage 0) — used by CMD to drop to node. +# gosu rebuilt with a current Go toolchain (stage 0) — the entrypoint script uses +# it to drop to node. COPY --from=gosu-build /out/gosu /usr/local/bin/gosu ENV XDG_CACHE_HOME=/tmp/kf6-cache @@ -117,9 +118,14 @@ EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ CMD wget -qO- http://localhost:3000/api/health || exit 1 +# Start-up lives in a script, not an inline `sh -c` string: container management +# UIs re-tokenise Config.Cmd when you edit a container in place, and a command +# carrying quotes does not survive that round-trip (the container then dies with +# a shell syntax error before Node starts). A CMD of one bare token does. +COPY server/scripts/entrypoint.sh /usr/local/bin/trek-entrypoint +# Root-owned on purpose — this is what calls gosu, so it runs before the drop to +# node. chmod is explicit because a checkout on Windows carries no exec bit. +RUN chmod 0755 /usr/local/bin/trek-entrypoint + ENTRYPOINT ["dumb-init", "--"] -# Preflight: if the app code is missing, a volume was almost certainly mounted -# over /app (it hides the image's node_modules + dist). Fail with actionable -# guidance instead of a cryptic "Cannot find module 'tsconfig-paths/register'". -# cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly. -CMD ["sh", "-c", "if [ ! -f /app/server/dist/index.js ] || [ ! -d /app/node_modules/tsconfig-paths ]; then echo 'FATAL: TREK application files are missing from the image.'; echo 'A volume is likely mounted over /app, which hides the app code.'; echo 'Mount ONLY your data and uploads dirs: -v ./data:/app/data -v ./uploads:/app/uploads'; echo 'Do NOT mount a volume at /app. See the Troubleshooting section of the README.'; exit 1; fi; chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec gosu node node --require tsconfig-paths/register dist/index.js"] +CMD ["/usr/local/bin/trek-entrypoint"] diff --git a/charts/README.md b/charts/README.md index 876d7b824..32f981f5e 100644 --- a/charts/README.md +++ b/charts/README.md @@ -49,6 +49,7 @@ See `values.yaml` for more options. - Liveness and readiness probes are configurable under `probes`. Overrides are merged over the defaults, so `--set probes.liveness.initialDelaySeconds=60` keeps the `httpGet` handler. When switching to `exec` or `tcpSocket`, clear `httpGet` in the same override. Set `probes.liveness: null` to remove a probe. - If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically. - Set `env.ALLOW_INTERNAL_NETWORK: "true"` if Immich or other integrated services are hosted on a private/RFC-1918 address (e.g. a pod on the same cluster or a NAS on your LAN). Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) remain blocked regardless. +- Set `env.ALLOW_LINK_LOCAL_IPS` to a single link-local address (e.g. `"169.254.1.2"`) only when a service TREK needs sits behind one, such as a host gateway. Cloud metadata addresses (`169.254.169.x`, `169.254.170.x`) cannot be listed. - `FORCE_HTTPS` is optional. Set `env.FORCE_HTTPS: "true"` only when ingress (or another proxy) terminates TLS. It enables HTTPS redirects, HSTS, CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Requires `TRUST_PROXY` to be set. - Set `env.TRUST_PROXY: "1"` (or the number of proxy hops) when running behind ingress or a load balancer. Required for `FORCE_HTTPS` to detect the forwarded protocol correctly. In production it defaults to `1` automatically. - `COOKIE_SECURE` is auto-derived (on when `NODE_ENV=production` or `FORCE_HTTPS=true`). Set `env.COOKIE_SECURE: "false"` only during local testing without TLS. **Not recommended for production.** diff --git a/charts/trek/Chart.yaml b/charts/trek/Chart.yaml index 3611650a0..65ee7d17c 100644 --- a/charts/trek/Chart.yaml +++ b/charts/trek/Chart.yaml @@ -1,5 +1,5 @@ apiVersion: v2 name: trek -version: 4.2.0 +version: 4.3.0 description: Minimal Helm chart for TREK app -appVersion: "4.2.0" +appVersion: "4.3.0" diff --git a/charts/trek/templates/configmap.yaml b/charts/trek/templates/configmap.yaml index 4e514acf3..a7397e76f 100644 --- a/charts/trek/templates/configmap.yaml +++ b/charts/trek/templates/configmap.yaml @@ -43,6 +43,9 @@ data: {{- if .Values.env.ALLOW_INTERNAL_NETWORK }} ALLOW_INTERNAL_NETWORK: {{ .Values.env.ALLOW_INTERNAL_NETWORK | quote }} {{- end }} + {{- if .Values.env.ALLOW_LINK_LOCAL_IPS }} + ALLOW_LINK_LOCAL_IPS: {{ .Values.env.ALLOW_LINK_LOCAL_IPS | quote }} + {{- end }} {{- if .Values.env.OIDC_ISSUER }} OIDC_ISSUER: {{ .Values.env.OIDC_ISSUER | quote }} {{- end }} @@ -82,3 +85,15 @@ data: {{- if .Values.env.OVERPASS_TIMEOUT_MS }} OVERPASS_TIMEOUT_MS: {{ .Values.env.OVERPASS_TIMEOUT_MS | quote }} {{- end }} + {{- /* Tested for presence rather than truth: the one value this switch gets set to is + false, and an unquoted false (or --set env.TREK_PLACES_ENABLED=false) would + otherwise drop the key and leave the index on. */}} + {{- if not (kindIs "invalid" .Values.env.TREK_PLACES_ENABLED) }} + TREK_PLACES_ENABLED: {{ .Values.env.TREK_PLACES_ENABLED | quote }} + {{- end }} + {{- if .Values.env.TREK_PLACES_URL }} + TREK_PLACES_URL: {{ .Values.env.TREK_PLACES_URL | quote }} + {{- end }} + {{- if .Values.env.AMAP_API_BASE }} + AMAP_API_BASE: {{ .Values.env.AMAP_API_BASE | quote }} + {{- end }} diff --git a/charts/trek/templates/deployment.yaml b/charts/trek/templates/deployment.yaml index 7b24b98dd..24e67325c 100644 --- a/charts/trek/templates/deployment.yaml +++ b/charts/trek/templates/deployment.yaml @@ -75,6 +75,18 @@ spec: name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }} key: UNSPLASH_ACCESS_KEY optional: true + - name: AMAP_API_KEY + valueFrom: + secretKeyRef: + name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }} + key: AMAP_API_KEY + optional: true + - name: AMAP_API_SECRET + valueFrom: + secretKeyRef: + name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }} + key: AMAP_API_SECRET + optional: true volumeMounts: - name: data mountPath: /app/data diff --git a/charts/trek/templates/secret.yaml b/charts/trek/templates/secret.yaml index 08d81cabd..c465e8e34 100644 --- a/charts/trek/templates/secret.yaml +++ b/charts/trek/templates/secret.yaml @@ -20,6 +20,12 @@ data: {{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }} UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY | b64enc | quote }} {{- end }} + {{- if .Values.secretEnv.AMAP_API_KEY }} + AMAP_API_KEY: {{ .Values.secretEnv.AMAP_API_KEY | b64enc | quote }} + {{- end }} + {{- if .Values.secretEnv.AMAP_API_SECRET }} + AMAP_API_SECRET: {{ .Values.secretEnv.AMAP_API_SECRET | b64enc | quote }} + {{- end }} {{- end }} {{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }} @@ -50,4 +56,10 @@ stringData: {{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }} UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY }} {{- end }} + {{- if .Values.secretEnv.AMAP_API_KEY }} + AMAP_API_KEY: {{ .Values.secretEnv.AMAP_API_KEY }} + {{- end }} + {{- if .Values.secretEnv.AMAP_API_SECRET }} + AMAP_API_SECRET: {{ .Values.secretEnv.AMAP_API_SECRET }} + {{- end }} {{- end }} diff --git a/charts/trek/values.yaml b/charts/trek/values.yaml index 83996f19b..0d4bb1aeb 100644 --- a/charts/trek/values.yaml +++ b/charts/trek/values.yaml @@ -54,6 +54,9 @@ env: # ALLOW_INTERNAL_NETWORK: "false" # Set to "true" if Immich or other integrated services are hosted on a private/RFC-1918 network address. # Loopback (127.x) and link-local/metadata addresses (169.254.x) are always blocked. + # ALLOW_LINK_LOCAL_IPS: "" + # Comma-separated single link-local addresses TREK may reach after all, e.g. "169.254.1.2" for a host gateway. + # 169.254.169.x and 169.254.170.x (cloud metadata) cannot be listed; an invalid entry aborts startup. # OIDC_ISSUER: "" # OpenID Connect provider URL. # OIDC_CLIENT_ID: "" @@ -87,8 +90,24 @@ env: # Custom Overpass endpoint(s) for the map POI "explore" search, comma-separated. When set, REPLACES the bundled # public mirrors — point it at an internal/self-hosted Overpass instance when the public mirrors are unreachable # from the cluster (e.g. locked-down egress). Non-http(s) entries are ignored. - # OVERPASS_TIMEOUT_MS: "12000" - # Per-endpoint timeout (ms) for Overpass POI requests. Raise it for a slow self-hosted Overpass instance. Defaults to 12000. + # OVERPASS_TIMEOUT_MS: "25000" + # Per-endpoint timeout (ms) for Overpass POI requests. The query itself gives Overpass 20 seconds of work, so a + # lower value hangs up on answers that were still coming. Raise it for a slow self-hosted Overpass instance. + # Defaults to 25000. + # TREK_PLACES_ENABLED: "false" + # Place search (suggestions, full search, category buttons, Road trip search, import geocoding, offline downloads) + # asks the TREK Places API at https://places.liketrek.com by default, so the pod makes outbound requests to that + # host. Set "false" to search the way it did before 4.3.0 (OpenStreetMap, or your Google/Amap key) and send + # nothing there. There is no switch in the admin panel on purpose: whether searches leave the instance is decided + # here. Defaults to on. + # TREK_PLACES_URL: "" + # Base URL of a copy of the Places API you run yourself; it has to answer the same /v1 API. Read from + # configuration rather than typed by a user, so an in-cluster address works without ALLOW_INTERNAL_NETWORK. + # Defaults to https://places.liketrek.com. + # AMAP_API_BASE: "" + # Send the Amap (高德地图) calls through an egress proxy, a cache or a gateway that speaks the same API instead of + # restapi.amap.com directly. The key itself is a credential and goes under secretEnv.AMAP_API_KEY, or into + # Admin > Settings > API Keys. Defaults to https://restapi.amap.com. # Secret environment variables stored in a Kubernetes Secret. @@ -114,6 +133,15 @@ secretEnv: # Get a free key at https://unsplash.com/developers. Can also be set per-admin # in Admin > Settings; this value overrides that. Leave empty to disable. UNSPLASH_ACCESS_KEY: "" + # Optional Amap (高德地图) Web 服务 key for place search and geocoding inside mainland + # China. Overrides the key set in Admin > Settings > API Keys. A Web 端 (JS API) key is + # a different credential and is rejected with INVALID_USER_KEY. Leave empty to use the + # key from the admin panel, or none. + AMAP_API_KEY: "" + # The private secret (数字签名) of that key. Required exactly when the key was created + # with signing enabled, because such a key rejects every unsigned request. Leave empty + # otherwise. The endpoint itself is not a secret and is set under env.AMAP_API_BASE. + AMAP_API_SECRET: "" # If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades generateEncryptionKey: false diff --git a/client/package.json b/client/package.json index 0cda4972f..ca4e6e836 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@trek/client", - "version": "4.2.0", + "version": "4.3.0", "private": true, "type": "module", "scripts": { diff --git a/client/src/api/client.surface.test.ts b/client/src/api/client.surface.test.ts index 474845ec1..be5aee90b 100644 --- a/client/src/api/client.surface.test.ts +++ b/client/src/api/client.surface.test.ts @@ -1,4 +1,4 @@ -// FE-APISURF-001 to FE-APISURF-054 +// FE-APISURF-001 to FE-APISURF-057 import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import type { AxiosResponse } from 'axios' import { http, HttpResponse } from 'msw' @@ -10,6 +10,7 @@ import { mapsApi, airportsApi, budgetApi, filesApi, reservationsApi, healthApi, weatherApi, configApi, helpApi, settingsApi, accommodationsApi, dayNotesApi, collabApi, backupApi, shareApi, transitApi, tripInviteApi, notificationsApi, inAppNotificationsApi, memoriesApi, + docsyncApi, DOCSYNC_RUN_TIMEOUT_MS, DOCSYNC_UPSTREAM_TIMEOUT_MS, } from './client' interface Recorded { method: string; url: string; body: unknown } @@ -665,6 +666,12 @@ describe('client > request payloads', () => { expect(rec.method).toBe('DELETE') expect(rec.body).toEqual({ password: 'hunter2' }) }) + + it('FE-APISURF-056: docsyncApi.createScope names the connection in the path only', async () => { + const rec = await traceOne(() => docsyncApi.createScope(1, 5, 'Norway')) + expect(`${rec.method} ${rec.url}`).toBe('POST /api/trips/1/docsync/connections/5/scopes') + expect(rec.body).toEqual({ name: 'Norway' }) + }) }) describe('client > query parameters', () => { @@ -895,4 +902,47 @@ describe('client > multipart uploads', () => { expect(call[2]).toMatchObject({ timeout: 40000 }) } }) + + it('FE-APISURF-057: every document-sync call that reaches the store outlives the 8s global timeout', async () => { + const post = spyPost() + const get = vi.spyOn(apiClient, 'get').mockResolvedValue({ data: { ok: true } } as unknown as AxiosResponse) + const del = vi.spyOn(apiClient, 'delete').mockResolvedValue({ data: { ok: true } } as unknown as AxiosResponse) + + // The server allows each request to a store 15 s, and a run waits for the + // whole listing and every transfer; cut off at 8 s the browser reported a + // failure while the server carried on and finished. + await docsyncApi.testConnection(1, { providerId: 'paperless' }) + await docsyncApi.createScope(1, 5, 'Norway') + await docsyncApi.createLink(1, { connectionId: 5 }) + await docsyncApi.listScopes(1, 5) + await docsyncApi.deleteLink(1, 9) + + expect(post.mock.calls.map(c => c[0])).toEqual([ + '/trips/1/docsync/connections/test', + '/trips/1/docsync/connections/5/scopes', + '/trips/1/docsync/links', + ]) + for (const call of post.mock.calls) { + expect(call[2]).toMatchObject({ timeout: DOCSYNC_UPSTREAM_TIMEOUT_MS }) + } + expect(get.mock.calls[0][0]).toBe('/trips/1/docsync/connections/5/scopes') + expect(get.mock.calls[0][1]).toMatchObject({ timeout: DOCSYNC_UPSTREAM_TIMEOUT_MS }) + expect(del.mock.calls[0][0]).toBe('/trips/1/docsync/links/9') + expect(del.mock.calls[0][1]).toMatchObject({ timeout: DOCSYNC_UPSTREAM_TIMEOUT_MS }) + expect(DOCSYNC_UPSTREAM_TIMEOUT_MS).toBeGreaterThan(8000) + + // A run, and a conflict choice that runs the binding afterwards. + post.mockClear() + await docsyncApi.syncNow(1, 9) + await docsyncApi.resolve(1, 4, 'trek') + + expect(post.mock.calls.map(c => c[0])).toEqual([ + '/trips/1/docsync/links/9/sync', + '/trips/1/docsync/items/4/resolve', + ]) + for (const call of post.mock.calls) { + expect(call[2]).toMatchObject({ timeout: DOCSYNC_RUN_TIMEOUT_MS }) + } + expect(DOCSYNC_RUN_TIMEOUT_MS).toBeGreaterThan(DOCSYNC_UPSTREAM_TIMEOUT_MS) + }) }) diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 4f7a8ab63..4d5809557 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -1050,7 +1050,10 @@ const MEMORIES_TIMEOUT = 0 export const memoriesApi = { status: (provider: string): Promise<{ connected: boolean }> => apiClient.get(`/integrations/memories/${provider}/status`, { timeout: MEMORIES_TIMEOUT }).then(r => r.data), - search: (provider: string, body: { from: string; to: string; page: number; size: number }, signal?: AbortSignal) => + // utc_offset_minutes says which 24 hours from/to name. It is NOT the Synology + // `offset`, which is rows to skip: one body goes to whichever provider, so the + // two names have to stay apart (#2336). + search: (provider: string, body: { from: string; to: string; page: number; size: number; utc_offset_minutes?: number }, signal?: AbortSignal) => apiClient.post(`/integrations/memories/${provider}/search`, body, { timeout: MEMORIES_TIMEOUT, signal }).then(r => r.data), albums: (provider: string) => apiClient.get(`/integrations/memories/${provider}/albums`, { timeout: MEMORIES_TIMEOUT }).then(r => r.data), @@ -1358,6 +1361,64 @@ export const filesApi = { getLinks: (tripId: number | string, fileId: number) => apiClient.get(`/trips/${tripId}/files/${fileId}/links`).then(r => r.data), } +/** + * How long the browser waits for a document-sync call that makes the server go + * and ask the store. + * + * The shared 8 s on `apiClient` is right for TREK's own routes and wrong for + * these, for the reason `dawarich.ts` gives: the server allows each request to + * a store 15 s by itself (20 s for Papra), a connection test may probe more + * than once, and binding or unbinding first registers or removes a webhook at + * the store. Cut off at 8 s, the browser reported a failure while the server + * carried on and finished, so a listing that took twelve seconds read as a + * broken store. + */ +export const DOCSYNC_UPSTREAM_TIMEOUT_MS = 60_000 + +/** + * A run is longer again. The server answers only once the whole of it is + * through: a listing of up to 40 pages, then up to 25 transfers at up to 120 s + * each, and Paperless-ngx holds every push until its consume task has run. + * Resolving a conflict runs the binding afterwards, so it waits just as long. + * Ten minutes covers a full transfer budget of ordinary documents; a run that + * outlasts it still finishes on the server, and the `docsync:changed` ping + * refreshes the dialog when it does. + */ +export const DOCSYNC_RUN_TIMEOUT_MS = 600_000 + +const docsyncUpstream = { timeout: DOCSYNC_UPSTREAM_TIMEOUT_MS } +const docsyncRun = { timeout: DOCSYNC_RUN_TIMEOUT_MS } + +/** + * Document sync: one provider connection per trip, shared by every member. + * + * Trip-scoped rather than user-scoped on purpose: a per-user connection would + * make a document's visibility depend on whose credentials fetched it, which is + * the opposite of what a shared trip needs. + */ +export const docsyncApi = { + providers: (tripId: number | string) => apiClient.get(`/trips/${tripId}/docsync/providers`).then(r => r.data), + status: (tripId: number | string) => apiClient.get(`/trips/${tripId}/docsync/status`).then(r => r.data), + listConnections: (tripId: number | string) => apiClient.get(`/trips/${tripId}/docsync/connections`).then(r => r.data), + saveConnection: (tripId: number | string, data: unknown) => apiClient.put(`/trips/${tripId}/docsync/connections`, data).then(r => r.data), + testConnection: (tripId: number | string, data: unknown) => apiClient.post(`/trips/${tripId}/docsync/connections/test`, data, docsyncUpstream).then(r => r.data), + deleteConnection: (tripId: number | string, connectionId: number) => apiClient.delete(`/trips/${tripId}/docsync/connections/${connectionId}`).then(r => r.data), + listScopes: (tripId: number | string, connectionId: number, q?: string) => + apiClient.get(`/trips/${tripId}/docsync/connections/${connectionId}/scopes`, { params: q ? { q } : {}, ...docsyncUpstream }).then(r => r.data), + createScope: (tripId: number | string, connectionId: number, name: string) => + apiClient.post(`/trips/${tripId}/docsync/connections/${connectionId}/scopes`, { name }, docsyncUpstream).then(r => r.data), + listLinks: (tripId: number | string) => apiClient.get(`/trips/${tripId}/docsync/links`).then(r => r.data), + createLink: (tripId: number | string, data: unknown) => apiClient.post(`/trips/${tripId}/docsync/links`, data, docsyncUpstream).then(r => r.data), + updateLink: (tripId: number | string, linkId: number, data: unknown) => apiClient.patch(`/trips/${tripId}/docsync/links/${linkId}`, data).then(r => r.data), + deleteLink: (tripId: number | string, linkId: number) => apiClient.delete(`/trips/${tripId}/docsync/links/${linkId}`, docsyncUpstream).then(r => r.data), + syncNow: (tripId: number | string, linkId: number, full = false) => + apiClient.post(`/trips/${tripId}/docsync/links/${linkId}/sync`, { full }, docsyncRun).then(r => r.data), + items: (tripId: number | string, state?: string) => + apiClient.get(`/trips/${tripId}/docsync/items`, { params: state ? { state } : {} }).then(r => r.data), + resolve: (tripId: number | string, itemId: number, keep: 'trek' | 'provider' | 'both') => + apiClient.post(`/trips/${tripId}/docsync/items/${itemId}/resolve`, { keep }, docsyncRun).then(r => r.data), +} + export const reservationsApi = { list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/reservations`).then(r => r.data), upcoming: () => apiClient.get('/reservations/upcoming').then(r => r.data), diff --git a/client/src/api/collections.test.ts b/client/src/api/collections.test.ts index dd0ff7f34..65ca3e974 100644 --- a/client/src/api/collections.test.ts +++ b/client/src/api/collections.test.ts @@ -1,4 +1,4 @@ -// FE-API-COLLECTIONS-001 to FE-API-COLLECTIONS-032 +// FE-API-COLLECTIONS-001 to FE-API-COLLECTIONS-034 // // The Collections addon wrapper is thin, but every method encodes a URL, a verb and a // request-body shape that the server contract depends on. These tests drive each method @@ -342,4 +342,23 @@ describe('collectionsApi', () => { expect(requestBody).toEqual({ label_ids: [3], place_ids: [10] }) expect(res.changed).toBe(1) }) + + it('FE-API-COLLECTIONS-033: exportGpx() reads the GPX of a list by id', async () => { + server.use(http.get(`${BASE}/:id/export/gpx`, record({ name: 'Tokyo', gpx: '', waypoints: 0, omitted: 2 }))) + + const res = await collectionsApi.exportGpx(1) + + expect(requestUrl).toContain(`${BASE}/1/export/gpx`) + expect(res).toEqual({ name: 'Tokyo', gpx: '', waypoints: 0, omitted: 2 }) + }) + + it('FE-API-COLLECTIONS-034: readGpx() posts the document and its file name', async () => { + const file = { format: 'trek.collection', version: 1, name: 'Tokyo', places: [] } + server.use(http.post(`${BASE}/gpx/read`, record({ file, skipped: 0, track_points: 12 }))) + + const res = await collectionsApi.readGpx({ gpx: '', file_name: 'tokyo.gpx' }) + + expect(requestBody).toEqual({ gpx: '', file_name: 'tokyo.gpx' }) + expect(res).toEqual({ file, skipped: 0, track_points: 12 }) + }) }) diff --git a/client/src/api/collections.ts b/client/src/api/collections.ts index 9ed4a56af..71f1ed8dd 100644 --- a/client/src/api/collections.ts +++ b/client/src/api/collections.ts @@ -12,7 +12,11 @@ import type { CollectionImportablesResponse, CollectionFile, CollectionImportRequest, + CollectionImportIntoRequest, CollectionImportResult, + CollectionGpxExport, + CollectionGpxReadRequest, + CollectionGpxReadResult, CollectionPlaceUpdateRequest, CollectionCopyToTripRequest, CollectionInviteRequest, @@ -62,6 +66,15 @@ export const collectionsApi = { ax.get(`${base}/${id}/export`).then((r: AxiosResponse) => r.data), importFile: (body: CollectionImportRequest): Promise => ax.post(`${base}/import`, body satisfies CollectionImportRequest).then((r: AxiosResponse) => r.data), + /** The same file into a list that already exists: it only ever adds to it. */ + importFileInto: (id: number, body: CollectionImportIntoRequest): Promise => + ax.post(`${base}/${id}/import`, body satisfies CollectionImportIntoRequest).then((r: AxiosResponse) => r.data), + // The same list as GPX (#2301). A GPX is read by the server into a list file, + // which then goes through importFile above like any other. + exportGpx: (id: number): Promise => + ax.get(`${base}/${id}/export/gpx`).then((r: AxiosResponse) => r.data), + readGpx: (body: CollectionGpxReadRequest): Promise => + ax.post(`${base}/gpx/read`, body satisfies CollectionGpxReadRequest).then((r: AxiosResponse) => r.data), // Both answer with the bare collection, not a { collection } envelope — the // controller returns the service's `Collection` straight through, and the e2e // suite pins that shape. Declaring the envelope made createCollection() resolve diff --git a/client/src/api/wsEventPolicy.ts b/client/src/api/wsEventPolicy.ts index 268083586..504b8bd38 100644 --- a/client/src/api/wsEventPolicy.ts +++ b/client/src/api/wsEventPolicy.ts @@ -81,6 +81,12 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [ // who has the book open, and where their pointers are) 'journey:book:peers', 'journey:book:cursor', + // Document sync: components/Files/docsync/useDocSync re-reads the bindings + // and their counts, and components/Files/docsync/useDocSyncOffered asks + // again whether the Files screen offers the sync button. Content-free like + // the bag ping: what a run changed is per-binding server state, and the + // files it moved arrive as file:* events. + 'docsync:changed', // Booking import — BackgroundTasks/BackgroundTasksWidget ('import:' prefix listener) 'import:progress', 'import:done', diff --git a/client/src/components/Admin/AddonManager.test.tsx b/client/src/components/Admin/AddonManager.test.tsx index ad5f1ccb0..1b86b373c 100644 --- a/client/src/components/Admin/AddonManager.test.tsx +++ b/client/src/components/Admin/AddonManager.test.tsx @@ -1,4 +1,4 @@ -// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-025 +// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-037 import { render, screen, waitFor } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { delay, http, HttpResponse } from 'msw'; @@ -418,7 +418,102 @@ describe('AddonManager', () => { expect(isOn(addonToggle('Todo List'))).toBe(true); }); - it('FE-ADMIN-ADDON-018: the collab sub-features render their state and report the toggled key', async () => { + it('FE-ADMIN-ADDON-033: document providers hang off Documents with their glyphs and stay out of the count', async () => { + server.use(addonsRoute([ + buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: true }), + buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: false }), + buildAddon({ id: 'paperless', name: 'Paperless-ngx', description: 'Document archive', type: 'document_provider', enabled: true }), + buildAddon({ id: 'nextcloud', name: 'Nextcloud', description: 'WebDAV files', type: 'document_provider', enabled: false }), + ])); + render(); + + await screen.findByText('Paperless-ngx'); + const paperlessRow = screen.getByText('Paperless-ngx').closest('li') as HTMLElement; + expect(paperlessRow.querySelector('svg')).toBeInTheDocument(); + expect(isOn(subToggle('Paperless-ngx'))).toBe(true); + expect(isOn(subToggle('Nextcloud'))).toBe(false); + // Documents and Journey are the addons; an enabled provider is a shelf row + // and must not turn 1/2 into 2/2. + expect(screen.getByText('1/2')).toBeInTheDocument(); + }); + + it('FE-ADMIN-ADDON-034: document providers stay hidden while Documents itself is off', async () => { + server.use(addonsRoute([ + buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: false }), + buildAddon({ id: 'paperless', name: 'Paperless-ngx', type: 'document_provider', enabled: true }), + ])); + render(); + + await screen.findByText('Documents'); + expect(screen.queryByText('Paperless-ngx')).not.toBeInTheDocument(); + }); + + it('FE-ADMIN-ADDON-035: switching Documents off and on again shows the cascaded providers as off', async () => { + const user = userEvent.setup(); + let loads = 0; + let documentsOn = true; + let paperlessOn = true; + server.use( + http.get('/api/admin/addons', () => { + loads += 1; + return HttpResponse.json({ + addons: [ + buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: documentsOn }), + buildAddon({ id: 'paperless', name: 'Paperless-ngx', type: 'document_provider', enabled: paperlessOn }), + ], + }); + }), + http.put('/api/admin/addons/documents', async ({ request }) => { + documentsOn = (await request.json() as { enabled: boolean }).enabled; + if (!documentsOn) paperlessOn = false; + return HttpResponse.json({ success: true }); + }), + ); + render(<>); + await screen.findByText('Paperless-ngx'); + + await user.click(addonToggle('Documents')); + await screen.findByText('Addon updated'); + expect(loads).toBe(2); + expect(screen.queryByText('Paperless-ngx')).not.toBeInTheDocument(); + + await user.click(addonToggle('Documents')); + + await screen.findByText('Paperless-ngx'); + expect(isOn(subToggle('Paperless-ngx'))).toBe(false); + }); + + it('FE-ADMIN-ADDON-036: toggling a document provider persists it without re-reading the list', async () => { + const user = userEvent.setup(); + let loads = 0; + let body: unknown = null; + server.use( + http.get('/api/admin/addons', () => { + loads += 1; + return HttpResponse.json({ + addons: [ + buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: true }), + buildAddon({ id: 'papra', name: 'Papra', type: 'document_provider', enabled: false }), + ], + }); + }), + http.put('/api/admin/addons/papra', async ({ request }) => { + body = await request.json(); + return HttpResponse.json({ success: true }); + }), + ); + render(<>); + await screen.findByText('Papra'); + + await user.click(subToggle('Papra')); + + await screen.findByText('Addon updated'); + expect(body).toEqual({ enabled: true }); + expect(loads).toBe(1); + expect(isOn(subToggle('Papra'))).toBe(true); + }); + + it('FE-ADMIN-ADDON-018:the collab sub-features render their state and report the toggled key', async () => { const user = userEvent.setup(); const onToggleCollabFeature = vi.fn(); server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })])); @@ -448,6 +543,22 @@ describe('AddonManager', () => { expect(screen.queryByText('Polls')).not.toBeInTheDocument(); }); + it('FE-ADMIN-ADDON-037: the links row describes the feature, not an empty list', async () => { + // The row's tooltip is its description. The links tab's empty state read as a + // status here, and a status that never changes reads as a broken feature. + server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })])); + render( + , + ); + + const links = await screen.findByText('Links'); + expect(links).toHaveAttribute('title'); + expect(links.getAttribute('title')).not.toBe('No shared links yet'); + }); + it('FE-ADMIN-ADDON-020: a disabled AI-parsing addon renders the row without its config block', async () => { server.use(addonsRoute([{ ...llmAddon({ provider: 'local' }), enabled: false }])); render(); diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index 2deda0596..94755412a 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -9,6 +9,7 @@ import CustomSelect from '../shared/CustomSelect' import EmptyState from '../shared/EmptyState' import DawarichIcon from '../shared/DawarichIcon' import AirTrailIcon from '../shared/AirTrailIcon' +import { DOCUMENT_PROVIDER_ICONS } from '../shared/DocumentProviderIcons' import AddonTile from './AddonTile' import AddonSubRow from './AddonSubRow' @@ -41,6 +42,9 @@ function SynologyIcon({ size = 14 }: { size?: number }) { const PROVIDER_ICONS: Record> = { immich: ImmichIcon, synologyphotos: SynologyIcon, + // The document providers' own marks, single-colour so they read as glyphs in + // a row of lucide icons and invert with the theme. + ...DOCUMENT_PROVIDER_ICONS, } interface Addon { @@ -95,7 +99,7 @@ interface CollabFeatures { chat: boolean; notes: boolean; links?: boolean; polls const COLLAB_SUB_FEATURES = [ { key: 'chat', icon: MessageCircle, titleKey: 'admin.collab.chat.title', subtitleKey: 'admin.collab.chat.subtitle' }, { key: 'notes', icon: StickyNote, titleKey: 'admin.collab.notes.title', subtitleKey: 'admin.collab.notes.subtitle' }, - { key: 'links', icon: Link2, titleKey: 'collab.tabs.links', subtitleKey: 'collab.links.empty' }, + { key: 'links', icon: Link2, titleKey: 'collab.tabs.links', subtitleKey: 'admin.collab.links.subtitle' }, { key: 'polls', icon: BarChart3, titleKey: 'admin.collab.polls.title', subtitleKey: 'admin.collab.polls.subtitle' }, { key: 'whatsnext', icon: Sparkles, titleKey: 'admin.collab.whatsnext.title', subtitleKey: 'admin.collab.whatsnext.subtitle' }, ] as const @@ -137,7 +141,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, // Journey off disables every photo provider with it, and the response carries // only Journey. Without re-reading, switching Journey back on brings the shelf // up with providers the database has long since turned off. - if (addon.id === 'journey') await loadAddons() + // Same for Documents: switching it off disables every document provider in + // the database, and the response carries only the addon itself. + if (addon.id === 'journey' || addon.id === 'documents') await loadAddons() toast.success(t('admin.addons.toast.updated')) } @@ -145,12 +151,17 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, return addon.type === 'photo_provider' } + const isDocumentProviderAddon = (addon: Addon) => { + return addon.type === 'document_provider' + } + const isPhotosAddon = (addon: Addon) => { const haystack = `${addon.id} ${addon.name} ${addon.description}`.toLowerCase() return addon.type === 'trip' && (addon.icon === 'Image' || haystack.includes('photo') || haystack.includes('memories')) } const photoProviderAddons = addons.filter(isPhotoProviderAddon) + const documentProviderAddons = addons.filter(isDocumentProviderAddon) const tripAddons = addons.filter(a => a.type === 'trip' && !isPhotosAddon(a)) const globalAddons = addons.filter(a => a.type === 'global') const integrationAddons = addons.filter(a => a.type === 'integration') @@ -161,6 +172,13 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, enabled: provider.enabled, toggle: () => handleToggle(provider), })) + const documentProviderOptions: ProviderOption[] = documentProviderAddons.map((provider) => ({ + key: provider.id, + label: provider.name, + description: provider.description, + enabled: provider.enabled, + toggle: () => handleToggle(provider), + })) if (loading) { return ( @@ -200,6 +218,25 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, ) }) } + // Document providers are the Documents tile's shelf, the way photo providers + // are Journey's. Unlike those they carry no credential form here: a document + // connection belongs to a trip, not to a user, so it is entered in the trip's + // file manager. The admin decides only whether a provider may be offered. + if (addon.id === 'documents' && addon.enabled && documentProviderOptions.length > 0) { + return documentProviderOptions.map(provider => { + const ProviderIcon = PROVIDER_ICONS[provider.key] + return ( + : undefined} + title={provider.label} + description={provider.description} + enabled={provider.enabled} + onToggle={provider.toggle} + /> + ) + }) + } if (addon.id === 'journey' && addon.enabled && providerOptions.length > 0) { return providerOptions.map(provider => { const ProviderIcon = PROVIDER_ICONS[provider.key] @@ -248,7 +285,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, { key: 'global', addons: globalAddons }, { key: 'integration', addons: integrationAddons }, ].filter(g => g.addons.length > 0) - const enabledCount = addons.filter(a => a.type !== 'photo_provider' && a.enabled).length + const enabledCount = addons.filter(a => a.type !== 'photo_provider' && a.type !== 'document_provider' && a.enabled).length const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length return ( diff --git a/client/src/components/Collab/CollabChat.test.tsx b/client/src/components/Collab/CollabChat.test.tsx index 2f818b142..2ed6657d6 100644 --- a/client/src/components/Collab/CollabChat.test.tsx +++ b/client/src/components/Collab/CollabChat.test.tsx @@ -708,4 +708,31 @@ describe('CollabChat', () => { { timeout: 3000 } ); }); + + // The strip shows the four pictures that will go out; the two the cap turned away + // vanish without a word otherwise, and the sender believes all six were sent. + it('FE-COMP-CHAT-037: picking more images than fit on one message says so, and says it once', async () => { + const addToast = vi.fn(); + window.__addToast = addToast as unknown as typeof window.__addToast; + let minted = 0; + const createObjURL = vi.spyOn(URL, 'createObjectURL').mockImplementation(() => `blob:mock/${++minted}`); + const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + try { + render(); + await screen.findByText('Start the conversation'); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + const files = Array.from({ length: 6 }, (_, i) => new File([new Uint8Array(8)], `${i}.png`, { type: 'image/png' })); + + fireEvent.change(input, { target: { files } }); + + await waitFor(() => expect(document.querySelectorAll('img[src^="blob:mock/"]')).toHaveLength(4)); + expect(addToast).toHaveBeenCalledTimes(1); + const [message, type] = addToast.mock.calls[0]; + expect(type).toBe('error'); + expect(message).not.toBe('Only JPEG, PNG, GIF and WebP images up to 10 MB are allowed'); + } finally { + delete window.__addToast; + createObjURL.mockRestore(); revokeObjURL.mockRestore(); + } + }); }); diff --git a/client/src/components/Collab/CollabChat.tsx b/client/src/components/Collab/CollabChat.tsx index f3cc80526..a1badfd38 100644 --- a/client/src/components/Collab/CollabChat.tsx +++ b/client/src/components/Collab/CollabChat.tsx @@ -48,8 +48,8 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) { )} - {imagePreviews.length > 0 &&
{imagePreviews.map((url, i) =>
)}
} - {uploadProgress > 0 && uploadProgress < 100 &&
Uploading {uploadProgress}%
} + {imagePreviews.length > 0 &&
{imagePreviews.map((url, i) =>
)}
} + {uploadProgress > 0 && uploadProgress < 100 &&
{t('collab.chat.uploading', { percent: uploadProgress })}
}
{/* Emoji button */} {canEdit && ( @@ -63,7 +63,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) { )} - {canEdit && <> { if (e.target.files) addImageFiles(e.target.files); e.currentTarget.value = '' }} />} + {canEdit && <> { if (e.target.files) addImageFiles(e.target.files); e.currentTarget.value = '' }} />}