mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 06:11:45 +00:00
Release 3.1.0 (#1185)
* Phase 0 — NestJS + Zod foundation harness (F1–F8) (#1050) Co-hosted NestJS app behind the existing Express server via a strangler-fig dispatcher, sharing the same better-sqlite3 connection and JWT httpOnly cookie. Additive and dormant: default routing stays on Express, Nest only serves its own /api/_nest diagnostics until a module opts in. F1 @trek/shared Zod contract package; F2 Nest bootstrap co-hosted (fall-through, single Dockerfile/port); F3 shared better-sqlite3 provider; F4 JWT cookie auth guard (+ @CurrentUser, admin guard); F5 Zod validation pipe + error-envelope parity; F6 Nest test + coverage gates; F7 per-prefix strangler toggle (env, default Express); F8 CI build/typecheck/test/coverage. Remaining F4/F6/F8 checklist items (trip-access + permission levels + MFA policy, e2e harness/seed + 80% gate, Nest↔Express parity test, Playwright PR-comment workflow) are tracked on the first consuming module cards (L1/A1/C1). * feat(weather): migrate /api/weather to the NestJS pilot module (L1) (#1053) First strangler migration (L1): /api/weather is served by a NestJS module. - @trek/shared/weather Zod contract; Nest controller byte-identical to the legacy Express route (paths, query params, status codes, { error } bodies, lang default, ApiError/500 passthrough). Service reuses getWeather/getDetailedWeather (+ shared cache; MCP tools unchanged). - Strangler routes /api/weather to Nest by default; the legacy Express route + its migration-time parity test were decommissioned in this PR. - Frontend (FE2): weatherApi typed against the @trek/shared WeatherResult contract. - Harness: reusable Nest-vs-Express parity harness, e2e harness (temp SQLite + seed/cookie helpers, real JwtAuthGuard), src/nest coverage gate raised to >=80%, src/nest test guide. - Verified end-to-end on a prod mirror (dev1): 401/400/200 via Nest with real Open-Meteo data, Express route gone. * fix(packing): multiply item weight by quantity in bag/total weight calcs (#898) Quantity now counts toward bag and total weights. Generalised to an itemWeight() helper used by every weight sum (bag totals + max, unassigned, grand total; sidebar + bag modal) with unit tests. * feat(i18n): add Korean (ko) translation (#977) Korean translation by @ppuassi, topped up to full en.ts key parity. Language registration follows separately. * feat(i18n): add Japanese (ja) translation (#829) Japanese translation by @soma3978, at full en.ts key parity, registered in supportedLanguages + TranslationContext. * Add Turkish (tr) translation + language registry (#1029) Turkish translation by @SkyLostTR, at full en.ts key parity, registered in supportedLanguages + TranslationContext. * i18n: register Korean + add Ukrainian translation (#1055) Korean translation by @ppuassi (#977) — now registered. Ukrainian by @JeffyOLOLO (#902) — lifted onto a clean branch. Both at full en.ts key parity (2258 keys). * chore: fix monorepo build pipeline and migrate shared to built package (#1056) * chore: fix monorepo build pipeline and migrate shared to built package - Root package.json: add workspace scripts (dev, build, test, test:cov, test:e2e) that delegate to actual scripts in shared/server/client workspaces - shared: add tsup build step (CJS + ESM dual output, .d.ts); consumers now import from the built dist instead of raw TS source via path aliases - server: replace tsc-alias with tsconfig-paths (tsc-alias mangled node_modules paths); fix MCP SDK path aliases to point to root node_modules (../node_modules) - server/scripts/dev.mjs: delay node --watch until tsc -w signals first-pass done, eliminating the spurious restart on every dev startup - client/vite.config.js + vitest.config.ts: remove @trek/shared path alias (no longer needed now that shared is a proper package) - Consolidate package-lock.json at the workspace root; drop per-workspace lock files * chore: fix test script to reflect root package.json * chore: add missing lint and prettier script in root package.json * fix(ci): build shared before tests; fix vitest MCP SDK alias paths vitest.config.ts aliases pointed at ./node_modules/ (server-local) but packages are hoisted to the root node_modules/ in the npm workspace — changed to ../node_modules/. CI jobs now install and build shared before running server/client tests so that @trek/shared's dist/ exists when vitest resolves the package. * fix(docker): update Dockerfile and CI for monorepo workspace structure Dockerfile: - Add shared-builder stage that produces @trek/shared dist before client and server stages need it - Each build stage carries root package.json + package-lock.json so npm can resolve @trek/shared as a workspace dependency - Production stage installs via workspace context (npm ci --workspace=server --omit=dev) so node_modules/@trek/shared symlinks to shared/dist correctly - Copy server/tsconfig.json into the image so tsconfig-paths/register can find the MCP SDK path aliases at runtime - CMD cds into /app/server before starting node so tsconfig-paths baseUrl resolves and ../node_modules points to /app/node_modules - Remove mkdir for /app/server (now a real dir); keep symlinks for uploads/data docker.yml version-bump: - Replace manual per-workspace cd+npm-version calls with single: npm version --workspaces --include-workspace-root --no-git-tag-version (mirrors the version:* scripts in root package.json) - git add now references root package-lock.json; adds shared/package.json .dockerignore: add shared/dist package.json: fix version:prerelease preid (alpha → pre) * fix(tests): use in-memory SQLite per worker in test mode vitest pool:forks spawns parallel worker processes that all called initDb() on the same data/travel.db, causing SQLite "database is locked" and "duplicate column name" races. When NODE_ENV=test each fork now gets an isolated :memory: DB so migrations run independently with no file contention. * chore(ci): add ACT guards to skip DockerHub steps in local act runs act sets ACT=true automatically. Guards added: - docker login: if: ${{ !env.ACT }} - build outputs: type=docker (local load) when ACT, push-by-digest when CI - digest export/upload: if: ${{ !env.ACT }} - merge job: if: ${{ !env.ACT }} - release-helm job (docker.yml): if: ${{ !env.ACT }} - version-bump git push (docker.yml): wrapped in [ -z "$ACT" ] shell guard Run locally with: ./bin/act -j build -W .github/workflows/docker.yml \ -P ubuntu-latest=catthehacker/ubuntu:act-latest * fix(ci): move ACT guards to step level; add guards to security.yml env context is invalid in job-level if conditions — moved all ACT guards down to individual steps. Also guards docker login + scout in security.yml so act can run the build-only part of that workflow. * fix(ci): skip git fetch and tag logic in act (no remote access in local containers) * Revert "fix(ci): skip git fetch and tag logic in act (no remote access in local containers)" This reverts commit67cf290cda. * Revert "fix(ci): move ACT guards to step level; add guards to security.yml" This reverts commitf92b95e054. * Revert "chore(ci): add ACT guards to skip DockerHub steps in local act runs" This reverts commit797183de08. * fix(docker): add musl optional deps so alpine builds find native rollup/sharp binaries npm prunes libc-constrained optional deps to the host libc (glibc) when generating the lockfile, leaving no musl entry for Alpine containers. Declaring the x64/arm64 musl variants as explicit root optionalDependencies forces them into the lockfile so npm ci on Alpine can install them. Covers shared-builder (tsup/rollup) and client-builder (vite/rollup + sharp icon generation) for both linux/amd64 and linux/arm64 CI targets. * fix(docker): copy client dist into server/public so the server resolves static files correctly The server runs from /app/server and serves static files relative to that directory, so the client build output must land at /app/server/public, not /app/public. * feat(planner): real road routes (OSRM) with travel-time connectors (#1060) * feat(planner): real road routes (OSRM) with travel-time connectors Replace the straight-line "as the crow flies" route with real OSRM road geometry (FOSSGIS routed-car/-foot) and an Apple-Maps style render (blue casing under a lighter core) on both the Leaflet and Mapbox GL maps. Routes are off by default and toggled per session, with a driving/walking mode switch in the day footer. Each day shows per-segment travel time/distance connectors between places, computed from the OSRM legs and split at transport bookings. Also redesigns the day header for visual consistency: vertical number+weather capsule, name with a divider before the date, subtle hotel/rental pills that stay on one line, and a hover-revealed 2x2 action square (edit / add transport / add note / collapse). Drops the Google Maps button. * test(planner): update route hook tests for calculateRouteWithLegs * remove route_calculation setting, always use OSRM routing (#1064) The per-user route_calculation toggle was a second, hidden on/off layer on top of the day footer's show-route button, and made it easy to end up with straight-line routes for no obvious reason. Drop the setting entirely: routing is always on, the footer toggle stays the single switch. Old stored values are simply ignored (settings are key-value, no migration needed). * chore: move i18n to shared package (#1066) * chore: move i18n to shared package * chore: move server translations to shared package and apply linter and prettier on entire shared package * feat(dashboard): upcoming reservations endpoint + travel-stats country/distance Adds GET /api/reservations/upcoming for the dashboard widget, switches travel-stats to the same country source as Atlas (manual + place-derived, ISO codes), and a distance service for flown km. * i18n(dashboard): dashboard keys across locales * feat(dashboard): boarding-pass hero, atlas row, live widgets + modal portal fix Reworked dashboard layout: boarding-pass hero with hover + days-left countdown, atlas stats row with real flags, searchable currency widget, editable timezone widget, new-trip FAB. Modals now portal to document.body to avoid inheriting dashboard-scoped button/font styles. * i18n(dashboard): sync all locales to one key set + German copy-dialog strings Brings every locale's dashboard namespace to the same 149-key set (missing keys backfilled from English) and translates the previously English-only copy-trip dialog into German. * refactor(dashboard): replace hardcoded strings with i18n keys Hero, atlas row, trip cards, filters, currency and timezone widgets now resolve all visible copy through t() instead of hardcoded English/German. * feat(i18n): add Greek translation (#1061) * i18n: complete Turkish (tr) translation (#1075) Fill in the remaining ~2100 UI strings in shared/src/i18n/tr so Turkish matches the English catalog. Brand names, URLs, and technical placeholders are left untranslated by design. * chore: prettier + lint * chore: enforce prettier & lint on shared package * feat: Updated border of map markers to reflect category color. (#1062) * feat(dashboard): mobile layout, glass UI, context bottom nav + OIDC PKCE (#1079) * feat(dashboard): mobile layout, glass tiles, plain-text countdown, place photos - Rework the mobile dashboard: cover hero, separate boarding-pass card, trimmed atlas (trips + days only), stacked widgets - New floating bottom tab bar with a centred context-aware + button (new trip / place / journey / entry depending on the page) - Move profile + notifications into a small top strip on the dashboard - Desktop: glassmorphic tiles (light + dark), neutral dark palette, plain-text countdown module, real place photos in the boarding pass * i18n(dashboard): translate new dashboard keys across all locales Fill the dashboard-rework keys (hero, atlas, fx, tz, upcoming, copy dialog, aria labels, countdown) that were left as English placeholders, plus the new startsIn/aria keys, for all 19 languages. * feat(oidc): send PKCE (S256) in the OIDC login flow The OIDC client now generates a code_verifier per login, sends the S256 code_challenge on the authorize request and the code_verifier on the token exchange. Works whether the provider has PKCE optional or required (fixes login against providers that require PKCE, e.g. Pocket ID). * Migrate TREK 3 to NestJS + React 19 (shared Zod contracts) (#1087) * Migrate TREK 3 to NestJS + React 19 with a shared Zod contract layer Brownfield strangler migration of the backend onto NestJS modules (auth, trips, days, places, assignments, packing, todo, budget, reservations, collab, files, photos, journey, share, settings, backup, oidc, oauth, admin, atlas, vacay, weather, airports, maps, categories, tags, notifications, system-notices) served through a per-prefix dispatcher, keeping the existing SQLite/better-sqlite3 DB and JWT httpOnly cookie auth, with behavioural parity for every route. Client: React 19 upgrade, "page = wiring container + data hook" pattern across all pages, per-domain Zustand stores bound to @trek/shared contracts, and decomposition of the large components (DayPlanSidebar, PackingListPanel, CollabNotes, FileManager, MemoriesPanel, PlacesSidebar, CollabChat, SystemNoticeModal, BudgetPanel, PlaceFormModal, ...) into focused render units backed by in-file hooks. Apply the shared global request pipeline (helmet/CSP, CORS, HSTS, forced HTTPS, the global MFA policy and request logging) to the NestJS instance as well, so a migrated route is protected identically to the legacy fallback rather than bypassing it. * Finish the NestJS migration — drop the legacy Express app NestJS now serves the whole surface: every /api domain plus the platform routes (uploads, /mcp, the OAuth/MCP SDK + /.well-known metadata and the production SPA fallback). Removed server/src/app.ts, all of server/src/routes/* and the strangler dispatcher; index.ts and the integration suite share a single buildApp() bootstrap so prod and tests can't drift. - Platform/transport routes extracted to nest/platform/platform.routes.ts and mounted before app.init() — Nest's router answers an unmatched request with a 404, so a route registered after init is never reached. The SPA fallback is a NotFoundException filter and the catch-all uses a RegExp (Express 5's path-to-regexp rejects a bare '*'). - New modules: memories (/api/integrations/memories — the Journey gallery's Immich/Synology proxy), addons (GET /api/addons) and the cross-trip GET /api/reservations/upcoming. - TrekExceptionFilter reproduces the old multer / err.statusCode handling so upload rejections keep their 400/413 { error } body and non-ASCII filenames survive (defParamCharset). - addTripToJourney and the MCP get_journey_share_link tool gained the trip-access check they were missing. - Re-pointed the 34 integration tests + the websocket test onto the Nest app; removed the now-meaningless Express-vs-Nest parity tests and a few orphaned client components. * Restore the reset-password rate limit and fix copyTrip reservation links Two correctness/security gaps the NestJS migration introduced: - POST /api/auth/reset-password lost its per-IP rate limiter. Restore it (5 attempts / 15 min on a dedicated bucket, same as the old resetLimiter) so reset tokens can't be brute-forced unthrottled. Covered by AUTH-019. - copyTripById did not copy reservations.end_day_id (a day reference — now remapped through dayMap like day_id) or needs_review, so a duplicated trip lost multi-day transport end-day links and reset the review flag. * Clean up dead code, dedupe helpers, fix the reset-password contract - Remove server exports orphaned by the Express removal: the immich album-link helpers, seven route-only service exports, getFileByIdFull; de-export internal-only helpers (utcSuffix). - De-duplicate verifyTripAccess (9 identical copies -> services/tripAccess.ts) and avatarUrl (3 -> services/avatarUrl.ts); name the bcrypt cost (BCRYPT_COST) and the email regex (EMAIL_REGEX). Public API unchanged. - resetPasswordRequestSchema declared `password`, but the client sends and the service reads `new_password` — rename it so the contract matches and the client types resolve. - Make ATLAS-013 deterministic: stub the admin-1 GeoJSON download instead of fetching ~4600 features from GitHub during the test (it hung the suite). * Make the client typecheck runnable (vitest/vite ambient types) The client had no `typecheck` script and tsc couldn't even start (the baseUrl deprecation errored out, same as server/shared already silence). Add `ignoreDeprecations: "6.0"` to match the other workspaces, a `typecheck` npm script, and a src/vite-env.d.ts referencing vite/client + vitest/globals so tsc knows the test globals (describe/it/expect/vi). This turns ~3600 phantom "Cannot find name" errors into a real, measurable count (~590 actual type errors remain, to be worked down). Type-only; no runtime change. * Derive client domain types from the shared schema contracts Add entity/response Zod schemas to @trek/shared (place, trip, assignment, day, budget, packing, reservation), each matched against the producing server service, and re-export them from client types.ts instead of the hand-written duplicates that had drifted (name/title, amount/total_price, owner_id/user_id, cover_url/cover_image, ...). Updates the call sites and test fixtures the corrected types surfaced; type-only, no runtime behaviour change. * chore(db): log swallowed errors in addon-disable migration + guard against destructive migrations The migration that disables the legacy "memories" addon swallowed any error in an empty catch, as did ~30 other catch blocks in the migration runner (column adds, the journey rebuild, index probes). Replace each silent catch with the existing console.warn('[migrations] ...') log so failures are visible. Control flow is unchanged: every step stays non-fatal, nothing new is thrown. Add a static guardrail test that scans the migration source and fails when a new destructive statement (DROP TABLE / DROP COLUMN / TRUNCATE / DELETE FROM / ALTER ... DROP) appears outside a reviewed allowlist, and when an empty/silent catch block is reintroduced. The existing destructive statements are all legitimate table rebuilds or bounded cleanups and are recorded in the allowlist with a reason. * Re-check SSRF on every redirect hop when resolving short links Replace the one-shot checkSsrf + fetch(redirect:'follow') in the maps and place short-link resolvers with safeFetchFollow, which follows redirects manually and re-runs checkSsrf against the DNS-pinned IP of each hop (max 5). A redirect to an internal/loopback address is now blocked even when the initial URL is public, while legitimate cross-host redirects (goo.gl -> maps.google.com) still resolve. * Reject WebSocket tokens minted before a password change Stamp the user's password_version onto the ephemeral ws token and verify it on connect, closing the socket (4001) when it no longer matches, so a token issued before a password reset can't be replayed. Tokens minted without a version are treated as version 0, matching the JWT pv-claim semantics. * fix(i18n): guard locale key parity and finish the OAuth consent page strings Every non-en locale now exposes the exact same flat key set as en. Keys that had drifted out of sync are backfilled with the English source value (tagged en-fallback) so t() resolves a real string instead of relying on the silent runtime fallback; no existing translation was touched and no key was removed. Add a parity test that imports each aggregated locale bundle and asserts its key set matches en, with a diagnostic listing of any missing/extra keys. This complements the file-level check in shared/scripts by guarding the merged export the app actually serves. Finish internationalising OAuthAuthorizePage: the ~15 remaining hardcoded English chrome strings now go through oauth.authorize.* keys (English source in en, en-fallback placeholders elsewhere). Markup and behaviour are unchanged. * Add semantic theme color tokens to Tailwind Map the CSS theme variables from src/index.css (:root light / .dark dark) to named Tailwind utilities — bg-surface, text-content, border-edge, bg-accent and their variants. This gives components a Tailwind-native target for the theme colors so we can replace inline `style={{ ... 'var(--...)' }}` with utility classes without changing the rendered values. * Surface silent store failures to the user and validate API responses in dev Reservation toggle, todo/packing toggle and budget reorder were swallowing API errors after rolling back, so the user saw the change silently snap back with no explanation. Route those failures through the existing toast channel (new store/notify.ts bridges to window.__addToast, the same channel SystemNoticeBanner uses); the reservation toggle re-throws so ReservationsPanel's own translated toast finally fires. Also wire the existing parseInDev/checkInDev response validation into the maps and notification-test endpoints to catch contract drift in dev. * Migrate static theme inline styles to Tailwind utilities and extract page sub-components Replace the static, color-only inline `style={{ ... 'var(--bg-primary)' ... }}` props with the new semantic Tailwind utilities (bg-surface, text-content, border-edge, ...) wherever the result is byte-identical; dynamic/conditional theme styles and hardcoded status colors are left inline. Extract the Atlas country-search autocomplete, the Admin update banner, and two Journey dialogs into their own presentational components to shrink the oversized page files, keeping behaviour and markup identical. * Remove the unrouted photos page and its dead photo components PhotosPage was never wired into the router and its usePhotos hook read a tripStore photos slice that was never implemented; the Photos gallery, lightbox and upload components were only reachable through it. Per-trip photos now live in the Journey gallery (Immich/Synology). Removed the dead page, hook and components — the live Journey PhotoLightbox is a separate component and stays. * Resolve the remaining client type errors and the trip.title navbar bug Drive the client typecheck to zero without any/ts-ignore: convert the tripId route param to a number once at the page boundary so it matches the numeric props and store actions it feeds, fix trip.name -> trip.title (the wire field is title, so the old read rendered blank in the files/offline views), and tighten the scattered handler-arity, DOM-cast and untyped-payload sites. No runtime behaviour change. * Convert the remaining dynamic and hardcoded inline styles to Tailwind utilities Second styling pass over the components and pages: move conditional theme colors into className ternaries (bg-accent / bg-surface-hover etc.), turn reused CSSProperties constants into className constants, and express static hardcoded hex/rgba colors as Tailwind arbitrary values so the exact rendered colour is preserved. Truly dynamic styling (computed geometry, gradients, multi-part shadows, data-driven colours, the undefined --sidebar/--nav layout vars) stays inline as it cannot be expressed as a static class. Updated three component tests that asserted the old inline active-state styles to assert the equivalent utility class instead. Verified: client typecheck 0, full client suite green, and a live light/dark render check in the dev server confirms the semantic theme tokens resolve correctly (the earlier 'transparent popups' were a stale dev server that pre-dated the tailwind.config token addition, not a code issue). * Add eslint flat-config for client and server and gate typecheck, lint and pages in CI client and server had lint scripts but no eslint config (only shared was linted in CI). Add flat configs mirroring shared's stack (js + typescript-eslint recommended + eslint-config-prettier) plus the client's react-hooks/react-refresh plugins. Pre-existing patterns in this never-linted code (explicit any, require() in the CommonJS server, empty catches, exhaustive-deps) are set to 'warn' rather than 'error' so the gate passes at 0 errors without a repo-wide reformat — these can be ratcheted to errors over time. Wire blocking typecheck + lint + lint:pages steps into the client and server CI jobs (now that both typechecks are clean) and promote the server typecheck from informational to blocking. * Decompose the remaining God Components into hooks, helpers and sub-components FE6: split the oversized page and panel components into thin layout shells plus colocated use<Component> hooks, .constants.ts, .helpers.ts (with tests) and presentational sub-components, following the established 'logic in a hook, render in slices' pattern. Behaviour, markup, classes and effect order are unchanged. Largest reductions: PackingListPanel 1598->42, FileManager 1055->36, AdminPage 1525->167, BudgetPanel 1266->146, JourneyDetailPage 2822->547, PlacesSidebar 945->66, CollabChat 861->106, CollabNotes 1417->532. DayPlanSidebar's drag-and-drop render body was left intact (ref-identity sensitive) and only its toolbar/modals/constants were extracted. * Fix duplicate React keys in the file-assign place list When a place is assigned to the same day more than once it appeared twice in a day's list, so the place-button key={p.id} collided and React warned about duplicate keys. Key by place id + render index so siblings stay unique. Pre-existing in the old FileManager; behaviour unchanged. * Format the shared package and drop an unused import to satisfy the lint gate The i18n and schema changes added code that wasn't prettier-formatted, and place.schema.ts imported categorySchema without using it. Run prettier over shared and remove the import so 'npm run lint' + 'format:check' pass. * Install all workspaces in the server CI job so SWC's native binary is present The server vitest config transforms via unplugin-swc, which needs @swc/core's platform-specific native binary. A workspace-scoped 'npm ci --workspace server' skips that optional dependency, so vitest failed to load the config on the Linux runner. Use a full 'npm ci'. * Re-resolve dependencies with npm install in the server CI job for SWC Full 'npm ci' still skipped @swc/core's Linux native binary because the committed lockfile was generated on Windows and lacks the Linux optional-dep install metadata. 'npm install' re-resolves and fetches the platform-matching binary, which the server's unplugin-swc transform needs to load vitest.config.ts. * Install @swc/core's Linux binary explicitly in the server CI job Neither npm ci nor npm install fetched @swc/core-linux-x64-gnu on the Linux runner because the lockfile was generated on Windows and lacks the Linux optional-dep metadata. Add a step that installs the matching @swc/core-linux-x64-gnu version (no-save, no-lockfile) so unplugin-swc can load the server's vitest config. * Use legacy-peer-deps when installing the SWC Linux binary in CI The explicit @swc/core-linux-x64-gnu install re-resolved the tree and hit the pre-existing lucide-react/react-19 peer conflict that the lockfile was generated around. Add --legacy-peer-deps so the step matches the project's resolution and installs the binary. * Keep the lockfile when installing the SWC binary so other deps stay pinned Dropping --no-package-lock made npm re-resolve the whole tree and upgrade eslint, whose newer recommended config flagged no-useless-assignment as an error in the server lint step. Keep the lockfile so only @swc/core-linux-x64-gnu is added and every other dependency (incl. eslint) stays at its locked version. * Fix a batch of reported bugs: Atlas regions, planner overlays, imports, Safari modals (#1094) * Start the Journey date picker week on Monday (#1078) The Journey entry date picker started the week on Sunday (firstDow = getDay(), headers Su-first) while every other picker (CustomDateTimePicker, VacayCalendar) starts on Monday. Align it: Monday-first leading offset ((getDay()+6)%7) and Mo-first weekday headers. * Fix Taiwan resolving to CN-TW in the Atlas country search (#1049) natural-earth gives Taiwan ISO_A2='CN-TW' (a subdivision-style value) with ADM0_A3='TWN'. The dynamic A2_TO_A3 augmentation added 'CN-TW'->'TWN', which then overwrote the legitimate TWN->TW entry in the reverse map, so Taiwan's country option resolved to 'CN-TW' — unresolvable by Intl.DisplayNames (no name, broken flag, not searchable). Only augment A2_TO_A3 with real 2-letter codes. * Drop empty leftover dateless days when a trip gets a shorter dated range (#1083) generateDays kept all unused dateless placeholder days after switching to an explicit (shorter) date range, so day_count (COUNT(*) FROM days) stayed inflated. Delete the empty leftovers (no assignments/notes/accommodations) like the dateless path already does, while preserving any that still hold content. Adds TRIP-SVC-017. * Render GPX and route overlays once the Mapbox style has loaded (#1036) The GPX and route geojson effects ran before the map 'load' event had attached their sources, so on the first paint they hit the early return and never re-ran. Add mapReady to their dependencies so they fire again the moment the sources exist. * Convert HEIC trip and journey covers to JPEG before upload (#1085) HEIC/HEIF covers coming straight off an iPhone could not be rendered in the preview or stored as a usable image. Route both cover pickers through normalizeImageFile, the same conversion the journal entry editor already uses, so the file becomes a JPEG before it leaves the browser. * Name GPX routes and tracks after their source file so multiple imports stick (#1054) Unnamed routes and tracks all fell back to the same generic 'GPX Route' / 'GPX Track' label, so the name-based import dedup dropped every one after the first - importing several files (or one file with several tracks) only kept a single place. Derive the default name from the source filename with an index suffix when a file holds more than one geometry, thread the filename down through the controller, and let the import modal take more than one file at a time. Adds PLACE-SVC-037/038. * Namespace the modal backdrop class so content blockers stop hiding it (#1027) Generic class names like .modal-backdrop sit on the cosmetic filter lists that content blockers (1Blocker, EasyList Annoyances) ship, and get hidden with display:none. The shared Modal - used by New Trip and Add Place - carried that class, so Safari users running such a blocker saw the modal silently fail to open with no error and no network request. Rename it to .trek-modal-backdrop. * Highlight GB regions by resolving England/Scotland/Wales/NI to finer admin-1 codes (#1067) A zoom-8 reverse geocode of a UK place only resolves to the constituent country (GB-ENG/SCT/WLS/NIR), but Natural Earth's admin-1 polygons for GB are counties and boroughs (GB-LND, GB-MAN, GB-CON, ...). Those four codes match no polygon, so places in England never highlighted in the Atlas while CH/IT/NL/etc. worked. When a GB lookup lands on a constituent country, re-resolve it at a finer zoom where Nominatim exposes the county/borough code the polygons actually carry. Other countries keep the exact zoom-8 behaviour. Adds ATLAS-UNIT-021. * Surface the real place-search error instead of a generic toast (#1092) When a place search or detail lookup fails, the backend already forwards the upstream reason - including descriptive Google Places API messages such as 'Places API (New) has not been used in project ... or it is disabled'. The planner discarded it and always showed 'Place search failed', so a key that is mis-enabled, unbilled, or pointed at the legacy API instead of Places API (New) looked like an unexplained silent failure. Show the server-provided message when present, and stop the Atlas bucket-list search from swallowing its error without a trace. * Await the async cover normalization in the TripFormModal paste test (#1085) handleCoverSelect now normalizes the pasted file before previewing it, so URL.createObjectURL is called a microtask later. The assertion moves into waitFor; a non-HEIC file still passes through unchanged. * fix(pwa): removed orientation from the manifest (#1058) * fix(journey): raise PhotoLightbox z-index above MobileEntryView (#1101) * feat(transport): add bus, taxi, bicycle, ferry and other transport types (#1105) Closes #718. Adds five new transport reservation types alongside the existing flight/train/car/cruise: bus, taxi, bicycle, ferry and a generic 'transport_other' catch-all. The new types are treated as first-class transports everywhere — the transport modal, day plan, route calculation, map overlays, file grouping and the PDF export — and are translated across all 20 locales. A dedicated 'transport_other' value is used for the catch-all so existing 'other' bookings are not reclassified as transport. * feat(reservations): native booking-confirmation import via KDE KItinerary (#1102) * feat(reservations): native booking-confirmation import via KDE KItinerary Adds a two-step preview → confirm flow for importing booking emails, PDFs, PKPass and HTML confirmations. The server invokes the KDE kitinerary-extractor binary, maps JSON-LD schema.org output to TREK reservation shapes, and persists via the existing createReservation pipeline (accommodations, budget, places, WebSocket broadcasts). - NestJS BookingImportModule: preview + confirm endpoints under /api/trips/:tripId/reservations/import/booking{,/confirm} - KitineraryExtractorService: spawns the binary, filters stderr noise, handles QDateTime (@value) timezone-aware datetimes - kitinerary-mapper: FlightReservation, TrainReservation, BusReservation, BoatReservation, LodgingReservation, FoodEstablishmentReservation, RentalCarReservation, EventReservation → typed preview items - BookingImportService: auto-creates place rows; geocodes venues without coordinates via Nominatim (name+address → address → name fallback); resolves day IDs for accommodation linking - BookingImportModal: drag-and-drop multi-file upload, preview cards with type icons, per-item exclude toggle, confirm step - Shared Zod contracts: BookingImportPreviewItem, PreviewResponse, ConfirmRequest, ConfirmResponse — consumed by controller, service, API client and modal - Dockerfile: node:24-trixie-slim runtime; amd64 downloads KDE static binary + locales; arm64 installs libkitinerary-bin + symlinks to fixed path; ENV KITINERARY_EXTRACTOR_PATH set for both arches - /api/health/features exposes { bookingImport: boolean } so the UI hides the Import button when the binary is absent - i18n keys (English), wiki docs, API.md, README one-liner * i18n: add booking import translations for all 19 non-English locales Adds 17 reservations.import.* keys and undo.importBooking to ar, br, cs, de, es, fr, gr, hu, id, it, ja, ko, nl, pl, ru, tr, uk, zh, zh-TW. * chore: enforce i18n parity * docs(wiki): add KItinerary local setup instructions to dev environment guide * feat(costs): rework Budget into Costs — Splitwise-style, multi-currency, mobile (#1106) * fix(journey): authorize reads of the journey share link GET /api/journeys/:id/share-link now requires journey access (canAccessJourney), matching the create/delete share-link routes and the get_journey_share_link MCP tool. Returns no link when the caller lacks access to the journey. * feat(costs): rework Budget into Costs — Splitwise-style, multi-currency, mobile Renames the Budget addon to "Costs" (UI only) and reworks it into a Tricount/ Splitwise-style cost tracker: multiple payers per expense, equal split across chosen members, settle-up with persisted history + undo, 12 fixed categories, per-expense currency with live FX conversion to a user-set display currency (Settings -> Display), and locale-correct money formatting. Adds a desktop and a dedicated mobile layout. A migration backfills existing budget items (single payer, split members, currency). Closes #551 (per-expense currency). Also switches the app font to self-hosted Poppins (Geist for secondary subtext), replacing the Google Fonts CDN dependency. * fix(costs): neutral dashboard dark palette + liquid glass, full page width, entry-count badge - Dark mode used a warm oklch palette that read brownish; switch to the neutral zinc tokens used by the dashboard (#121215 bg, #f4f4f5 ink) and add a subtle backdrop-blur glass on cards. - Costs now uses the full available page width on desktop instead of a 1280px cap. - Render the expense count next to the Expenses title as a badge. - Adapt budget/journey unit tests to the new payer-based settlement model and the Costs rename (category default 'other', Costs tab/CostsPanel). * fix(costs): drop the entry-count badge, always show row edit/delete actions Removes the count badge next to the Expenses title and makes the per-row edit/delete actions permanently visible (no longer hover-only) on desktop too. * feat(costs): currency-native money formatting, custom select/date, rename addon to Costs - Format every amount in its own currency convention (symbol position, grouping and decimal separators) regardless of app language, via a currency->locale map (EUR -> '12,00 €', USD -> '$12.00', JPY -> '¥12', ...). Previously Intl used the app locale, so EUR showed the symbol in front under an English UI. - Use TREK's CustomSelect (searchable, with symbols) and CustomDatePicker in the add/edit expense modal instead of the native <select>/<input type=date>. - Rename the 'Budget Planner' add-on to 'Costs' in the admin list (display only; id/tables/permissions/MCP stay 'budget') via seed + a migration for existing DBs. * feat(auth): configurable session duration via SESSION_DURATION Adds a SESSION_DURATION env var (ms-style strings: 1h, 7d, 30d, ...) controlling how long a session stays valid before re-login. It drives both the trek_session JWT exp claim and the cookie maxAge from one source, so they never drift. Invalid values warn at startup and fall back to the default (24h — unchanged). The MFA challenge token and MCP OAuth tokens keep their own TTL. Implements the request from discussion #946. Documented in the env-var wiki page, .env.example and docker-compose.yml. * feat: Passkey (WebAuthn) login (#1111) * feat(auth): passkey (WebAuthn) login — server endpoints, schema + admin toggle Add @simplewebauthn/server registration and primary (discoverable) login ceremonies under /api/auth/passkey, a webauthn_credentials + single-use webauthn_challenges schema (migration), the instance-wide passkey_login toggle (default off) enforced before auth by a guard, and require_mfa satisfaction via a verified passkey. RP ID/origin come only from server config (webauthn_rp_id/origins -> APP_URL), never request headers. * feat(auth): passkey enrolment, login button + admin settings UI PasskeysSection in account settings (add/rename/remove with a current-password step-up), a 'Sign in with a passkey' button on the login page, the admin enable + RP-ID/origins controls, and a per-user admin reset action. * i18n(auth): passkey strings across all locales Add login/settings/admin passkey keys to en and all 19 translated locales. * chore: update kitinerary version * Backend/frontend hardening & consistency cleanups (#1113) * refactor(auth): session token validation and password-change consistency * refactor(journey): entry field allow-list and public share-link consistency * refactor(mcp): align tool authorization with the REST permission checks * chore: input validation and sanitisation touch-ups (uploads, pdf, maps, backup, csp) * feat: optimize routes around accommodation, confirm note deletions (#1123) Optimize day routes around the accommodation When a day has an accommodation set, the route optimizer now treats it as the day's home base: it optimizes a loop that leaves the hotel and returns to it, so the stop nearest the hotel comes first. On a transfer day - checking out of one hotel and into another - the route runs from the first hotel to the second instead. The optimizer also gained a 2-opt pass on top of the nearest-neighbor ordering, which removes the crossings the greedy pass used to leave behind. A new display setting ("optimize route from accommodation", on by default) lets you turn the anchoring off. Confirm before deleting notes Deleting a plan note or a collab note now asks for confirmation first. On phones and tablets the edit and delete icons sit close together and were easy to mis-tap, which deleted notes with no way back. * fix: miscellaneous bug fixes (#1139) * fix(share): serve place thumbnails in shared trip links (#1100) Google-sourced place photos are stored as image_url pointing at the JWT-guarded /api/maps/place-photo/:placeId/bytes endpoint, so they 401 for an unauthenticated shared-trip viewer and render as broken images. Rewrite place image_url values in the shared payload to a public, token-scoped proxy (/api/shared/:token/place-photo/:placeId/bytes) and add an unguarded SharedController route that validates the token and that the place belongs to its trip before streaming the cached bytes. Mirrors the existing JourneyPublicController precedent. No client changes needed. * fix(atlas): replace Natural Earth with geoBoundaries for up-to-date regions (#1119) Atlas sourced country and sub-national boundaries from Natural Earth's GitHub `master` at runtime. That data is stale (e.g. it still shows Norway's pre-2020 counties such as Oppland/Hordaland) and depicts some contested territory in unwanted ways (nvkelso/natural-earth-vector#391), so Natural Earth is dropped entirely. - Country borders (admin0) now come from the geoBoundaries CGAZ composite; sub-national regions (admin1) from per-country gbOpen, which carries ISO 3166-2 codes. A new script (server/scripts/build-atlas-geo.mjs) normalizes and quantizes them into committed gzipped bundles under server/assets/atlas, read server-side at runtime (no network at boot, no GitHub CSP allowlist entry). - New GET /addons/atlas/countries/geo serves the country layer; the client fetches it from the API instead of GitHub. - A migration reconciles manually-marked visited_regions against the new bundle (valid code -> keep; region name still matches -> re-code; curated merge crosswalk for renamed reforms; else leave intact), with UNIQUE-safe dedup. bucket_list and visited_countries hold only invariant alpha-2 country codes, so they are untouched. - Attribution added (NOTICE.md + README) per geoBoundaries CC BY 4.0. Closes #1119 * fix(packing): make templates admin-only to create, usable by members Creating a packing-list template was gated only by trip access, so any trip member could create one from the Lists feature, while applying a template silently failed for non-admins because the apply dropdown was populated from the AdminGuard-protected /api/admin/packing-templates endpoint. - save-as-template now returns 403 for non-admins; the Save-as-Template button is hidden unless the user is an admin (both the TripPlanner toolbar and the inline packing header). - add member-accessible GET /api/trips/:tripId/packing/templates so the apply dropdown lists templates for any trip member; client fetches from it instead of the admin endpoint. Closes #1120 Closes #1121 * fix(packing): show bag tracking to non-admin members The global Bag Tracking toggle was only readable via the admin-gated GET /api/admin/bag-tracking, so non-admin trip members got 403 and the weight fields, bag circles, and BAGS sidebar never rendered (#1124). Surface the flag through the already-authenticated GET /api/addons (loaded into the client addon store on app start for every user); the packing hook reads it from the store instead of the admin endpoint. The admin write path stays admin-gated and unchanged. * Fix a batch of reported bugs (#1145) * fix(maps): fall back to OSM/Wikipedia for place photos and normalize non-standard language codes (#1137) * fix(auth): refuse password reset for OIDC/SSO-linked accounts (#1129) * fix(docker): ship server/assets (airports + atlas geo) in the runtime image (#1133, #1119) * fix(unraid): point the template at a PNG icon Unraid can render (#1073) * fix(offline): serve cached file blobs when offline or on network failure (#1046, #1069) * fix(map): centre the selected pin in the visible map area above the bottom panel (#1125) * fix(pdf): render persisted place-photo proxy URLs as images (#1130) * fix(planner): show the selected place category in the edit form (#1134) * fix(dashboard): collapse list-view trip cards to a compact row on mobile (#1132) * Support multi-leg (layover) flights (#1146) * feat(transport): support multi-leg (layover) flights in the booking form A flight booking can now hold an ordered chain of airports (e.g. FRA -> BER -> HND) instead of a single departure/arrival pair. The route is entered as a list of waypoints with a '+ add stop' button; each stop carries its own arrival and departure time plus the airline/flight number of the segment leaving it, while the whole booking keeps one price. Stored without a schema change: the existing reservation_endpoints rows carry the ordered waypoints (from/stop/to by sequence) and a metadata.legs array holds the per-leg detail. Top-level metadata (departure_airport/arrival_airport/airline/ flight_number) mirrors the first and last leg, so a single-leg flight persists exactly as before and legacy readers keep working. * feat(planner): show each flight leg as its own day-plan entry, ordered by time A multi-leg flight now expands into one entry per leg (BER -> FRA, then FRA -> HND), each on its own day with its own times, instead of a single span. Each leg is an addressable slot (reservation id + leg index) so places and notes can be dropped into the layover gap between legs; the per-leg position is persisted in metadata.legs[i].day_positions and survives a reload. Day-plan items are now ordered chronologically: anything with a time (a place's time, a flight leg, a timed note) sorts by that time, and untimed items inherit the time of the item before them so they stay where they were placed. * feat(planner): show the full multi-stop route in the bookings panel The route row now lists every waypoint (FRA -> BER -> HND) by sequence instead of just the first and last airport. * feat(map): draw multi-leg flights as connected legs with a marker per airport Both the Leaflet and Mapbox overlays now render a flight over all its waypoints: one great-circle arc per leg and a marker at every airport, with the label showing the full route and the summed distance. A single-leg flight is unchanged. Also drops the floating stats badge that was drawn on transport arcs. * fix(map): centre a clicked place above the bottom inspector panel Selecting a place panned/flew it to the dead centre of the screen, where it sat behind the detail card. Both overlays now bias the target into the visible area above the bottom panel (Leaflet offsets the pan by the inspector inset; Mapbox passes the padding to flyTo). * feat: show the full multi-stop flight route in PDF and calendar export The PDF day list and the ICS export now render the whole route (FRA → BER → HND) for a multi-leg flight instead of just the first and last airport, falling back to the flat metadata for single-leg flights. The ICS keeps a single event per booking. * feat(import): group connecting flight legs into one multi-leg booking When a booking confirmation contains several flight legs sharing a PNR that connect at the same airport with a short layover (under 24h), they are now imported as a single multi-leg booking (from/stop/to endpoints + metadata.legs) instead of one booking per leg. A round trip (same PNR, multi-day gap) stays two separate bookings, and a single flight is unchanged. * i18n: translate the new flight-route strings into all languages * i18n: translate the Costs page into every language The Budget → Costs rework left the new costs.* strings untranslated in every non-English locale (they fell back to English). Translate them across all supported languages. * Revert "fix(map): centre a clicked place above the bottom inspector panel" This reverts commit0936103f04. * Explore places on the map, planner route fixes, and instance-wide Mapbox (#1147) * feat(maps): add an OSM POI search endpoint (category within a viewport) New /api/maps/pois queries OpenStreetMap via Overpass for places of a category (restaurants, cafes, hotels, sights, …) inside a bounding box. OSM-only by design — it never calls Google, even when a Google key is configured. * feat(map): explore nearby places on the trip map (OSM category pill) A floating, icon-only pill over the planner map lets you toggle a POI category and see those OpenStreetMap places in the current view; clicking a marker opens the add-place form pre-filled (name, address, website, phone). Single-select with a 'search this area' action after the map moves. Renders on both the Leaflet and Mapbox maps, and can be turned off in settings (discussion #841). * fix(planner): anchor timed places when optimising and route transports by location - The day optimiser no longer reshuffles places that have a set time — they stay anchored to their time, like locked places. - The route now uses a transport's departure/arrival location as a waypoint when it has one (e.g. a flight's airport), instead of breaking the route at every booking; transports without a location are ignored for routing but still show their leg's distance/duration under the booking. * feat(admin): instance-wide Mapbox defaults in default user settings Admins can set a shared Mapbox token (plus style, 3D and quality) as instance defaults, so the whole instance can use Mapbox without each user pasting their own key. Users without their own value inherit it via the existing admin-defaults merge; the shared token is stored encrypted (discussion #920). * Reorder whole days and insert a day (#589) (#1148) * feat(days): reorder whole days and insert a day at a position Adds reorderDays + insertDay to the day service and a PUT /days/reorder route (plus an optional position on create). Day rows stay stable so a day's assignments, notes, bookings and accommodations ride along by id; on a dated trip the calendar dates stay pinned to their slots while the content moves across them, and each booking's date is re-stamped onto its day's new date (time-of-day preserved) so day_id stays consistent. Renumbering uses the two-phase write to avoid the UNIQUE(trip_id, day_number) collision, and a move that would invert an accommodation's check-in/out span is rejected. * feat(planner): reorder days from a toolbar popup, and add days A new toolbar button opens a popup listing the days; drag a row by its grip or use the up/down arrows to reorder, and add a day from there. Reorders apply optimistically with rollback and sync over WebSocket; the day headers are left untouched, so the existing place drop-targets are unaffected. * i18n: add day-reorder strings across all languages * Map/planner/dashboard polish and small community features (#1155) * feat(planner): reorder days in a modal instead of a dropdown The day-reorder control opened a small anchored dropdown; move it into the shared Modal (portal, dimmed backdrop, Esc/backdrop close) so it matches the Add activity dialog. Drag handles, up/down arrows and the day badges are unchanged. * feat(map): explore reliability, Mapbox popups + compass, region-biased search POI explore: clamp oversized viewports, query the Overpass mirrors in parallel (first valid response wins) with a per-request timeout and a short-lived cache, and surface a retry when every mirror fails - so it returns results at any zoom instead of timing out. Mapbox renderer: add the place/POI hover popups (name, category, address, photo) the Leaflet map already had, plus a compass pill next to the explore pill that resets the view to north. /api/maps/search: accept an optional locationBias to fix foreign-region bias and expose Google's place types in the result. * feat(dashboard): list-view and mobile polish Use the Archived status label for the filter and show Open dates for trips without dates; drop the unused settings button next to the view toggle. Desktop list view renders the date as a stat-style block separated from the counts. Mobile list rows are stacked (slim cover banner + centred date), trip actions stay visible (touch has no hover), and the hero card's hover lift is disabled on touch; small spacing fix under the sidebar. * feat: small community-requested options Raise the plan-note subtitle limit to 250 characters and add more note icons. Expose is_archived and cover_image on the update_trip MCP tool. Add place coordinates to the PDF export. Allow creating a category from an existing to-do, and add a show/hide toggle on the admin password fields. * test(shared): bump day-note subtitle limit assertion to 250 * test: align specs with the new search param order and archive label Keep lang as the 3rd positional arg of the maps search controller so the existing unit test stays valid, and forward locationBias as the 4th. Add the now-used Popup to the MapViewGL mapbox mock, switch the dashboard archive-filter query to the Archived label, and expect the 4-arg search call. * fix(packing): add more bag colors so sub-bags stop repeating (#1156) The auto-assigned bag palette only had 8 colors, so the 9th bag reused the first one. Double it to 16 (keeping the existing 8 and their order) and keep the server and client lists in sync - both cycle BAG_COLORS[count % length]. * fix(packing): respect per-item quantity in bulk import (#1157) * AirTrail integration: import flights & two-way sync (#214) (#1158) * feat(admin): register AirTrail as an integration addon Off by default; toggle lives in Admin -> Addons with a Plane icon. The per-user connection (URL + API key) follows in integration settings. * feat(integrations): add per-user AirTrail connection Settings -> Integrations gains an AirTrail section: instance URL + Bearer API key (encrypted at rest via apiKeyCrypto), a self-signed-TLS opt-in and a test-connection check. Served by a small Nest controller under /api/integrations/airtrail, gated on the airtrail addon and SSRF-guarded. The key is per-user, so it only ever returns that user's own flights. * feat(transport): import flights from AirTrail Adds an AirTrail Import button next to Manual Transport that lists the user's AirTrail flights and highlights the ones inside the trip dates. Selected flights become reservations linked to their AirTrail origin (external_* columns), deduped against flights already in the trip, then broadcast to every member. The mapping resolves airports, airport-local times and flight metadata; the linkage is what the two-way sync rides on. * feat(transport): badge AirTrail-linked flights as synced Linked reservations show an 'AirTrail synced' badge, or 'no longer synced' once the flight is gone from AirTrail. * feat(transport): keep TREK and AirTrail flights in sync both ways A scheduled poll reconciles each connected owner's flights: field edits (detected by snapshot hash, since AirTrail has no updated_at) flow into the linked reservation and broadcast live; a flight deleted in AirTrail keeps the TREK row but stops syncing. Editing a linked flight in TREK pushes back to AirTrail under the importer's credentials, preserving the existing seat manifest; if the owner disconnected the link detaches so the poll can't revert the local edit. Deleting in TREK never touches AirTrail. * i18n(airtrail): add AirTrail strings across all locales * test(airtrail): cover flight mapping, timezones and snapshot hashing * fix(airtrail): reduce airline/aircraft objects to codes The flight list/get response returns airline and aircraft as joined objects ({icao, iata, name, ...}), not bare codes. Mapping them straight through produced '[object Object]' titles and stored objects in metadata, which crashed reservation rendering. Extract the ICAO/IATA code instead, and title flights by their flight number. * fix(airtrail): clear error on non-JSON responses, tolerate /api in URL A misconfigured instance URL made AirTrail serve its SPA/login HTML, and the raw JSON.parse failure surfaced as 'Unexpected token <'. Surface an actionable message instead, and strip a pasted trailing /api so the base URL still resolves. * feat(transport): sync AirTrail edits on trip open, not just on the poll Add a per-user on-demand sync (POST /integrations/airtrail/sync) triggered when a connected user opens a trip, so AirTrail-side edits appear right away instead of waiting up to a full poll cycle. Lower the background poll from 15 to 5 minutes as a safety net. * fix(transport): refresh imported AirTrail flights without a reload loadTrip doesn't fetch reservations, so a freshly imported flight only appeared after a full page reload — use loadReservations instead. Also show flight dates in the user's locale format (e.g. 13.06.2026) rather than the raw ISO string. * style(settings): align AirTrail connection with the photo-provider layout Match the Immich section: stacked URL/key fields, a ToggleSwitch for self-signed TLS, and a Save / Test-connection row with a status badge. * feat(transport): add a seat field when editing flights The transport editor only offered a seat field for trains; flights had none even though imports store metadata.seat. Show and persist a seat for flights too. * style(transport): match the AirTrail button height to Manual Transport * feat(transport): put the flight seat next to flight number and sync it to AirTrail Move the seat from a standalone row to the per-leg flight details (beside the flight number), stored per leg in metadata.legs[].seat with the first leg mirrored to metadata.seat. On push, set the seat number on the user's own AirTrail seat (the one with a userId), leaving co-passengers untouched; import/poll read that same seat back. * refactor(planner): move the AirTrail trip-open sync into useTripPlanner Page containers must not own state/effects (lint:pages). Same logic, relocated from the page into its data hook. * test(db): pin the region-reconciliation test to its schema version The test re-ran 'the last migration' assuming the reconciliation is last; it no longer is once later migrations are appended. Pin to version 135 and re-run from there (the appended migrations are idempotent). * Various fixes: 2FA autofocus, viewer-timezone times, duplicate place guard (#1159) * fix(auth): autofocus the 2FA code input when the MFA step appears (#767) * fix(notifications): show notification and admin times in the viewer timezone (#1149) SQLite CURRENT_TIMESTAMP is UTC but the string has no Z, so the client parsed it as local time. Normalize in-app notification created_at to ISO-UTC, and stop forcing the admin user table to render in the server timezone. * fix(places): warn before adding a duplicate place (#1152) Manually adding a place did not check the existing pool, so the same POI could land in Unplanned twice. Flag a likely duplicate by Google Place ID, name or near-identical coordinates and require a confirming second click to add anyway. * fix(planner): make route tools reachable in mobile day plan sheet (#1142) * wiki: update dev env * wiki: small precision in dev env * fix(planner): make route tools reachable in mobile day plan sheet On mobile, selecting a day closes the plan sheet immediately, so the route tools footer (Route toggle / Optimize / routing profile) - gated on the selected day - was never reachable. Desktop was unaffected. - Add showRouteToolsWhenExpanded prop to DayPlanSidebar: when set, route tools render on any expanded day with 2+ assigned places - Make handleOptimize accept an explicit dayId (defaulting to selectedDayId, preserving desktop behavior) - Keep the distance/duration pill gated on the selected day, since routeInfo belongs to the selected day's calculated route - Enable the prop on the mobile plan sheet in TripPlannerPage * fix(planner): correct route-tools prop doc and dev-environment wiki - Reword the showRouteToolsWhenExpanded JSDoc to list the controls the footer actually renders (Route toggle / Optimize / travel profile); there is no "Open in Google Maps" action in that block. - Wiki: drop the non-existent server test:parity script, document the real shared i18n:parity checks, and fix the i18n note (the translation layer already lives in @trek/shared, it is not "upcoming"). --------- Co-authored-by: jubnl <jgunther021@gmail.com> Co-authored-by: Maurice <mauriceboe@icloud.com> * feat(places): enrich list-imported places via the Places API (#886) (#1161) * feat(places): enrich list-imported places via the Places API (#886) Google/Naver list imports only carry a name and coordinates, so the places open as bare pins — the Maps tab jumps to coordinates, with no photo, address or open/closed. Add an opt-in "Enrich places via Google" toggle to the list-import dialog, shown only when a Google Maps key is configured. When enabled, after the (fast, unchanged) import the server runs a background pass that re-resolves each place by name — biased to and validated against the imported coordinates so a common-name search cannot overwrite the wrong place — and fills the empty address/website/phone/photo columns plus the resolved google_place_id, pushing each row over the live sync. Opening hours and the proper Maps link then work on demand from the stored id. Enrichment only fills empty fields, runs detached so a long list never blocks the import, and no-ops when no key is configured. * fix(places): use the ToggleSwitch component for the enrich toggle Match the rest of the app — the import-enrichment opt-in used a raw checkbox; swap it for the shared ToggleSwitch (text left, switch right) like the settings toggles. * fix(maps): bound place-photo cache growth (Wikimedia + Google) (#1174) The place-photo cache (uploads/photos/google) grew unbounded: a Wikimedia geosearch path cached full-res originals despite requesting a 400px thumb, the writer applied no size guard, nothing reclaimed orphaned files, and backups archived the whole re-derivable cache verbatim. - Prefer the scaled `thumburl` over the full-res `info.url` in the Commons geosearch fallback. - Downscale any cached image to <=800px JPEG via the existing jimp dep, with a safe fallback to the original bytes on decode failure. - Add sweepOrphans() (orphaned meta rows + stray files) wired into the scheduler (startup + nightly), and removeIfUnreferenced() called on place delete for prompt reclamation. - Exclude the re-derivable photo/trek caches from backups; restores self-heal as the cache dirs are recreated at startup. * fix(sync): remap temp ids, prevent id collisions, surface failed mutations (#1175) Closes three offline BLOCKERs from the PWA audit: - B1: offline edits/deletes of an offline-created entity were lost. The negative temp id was baked into the PUT/DELETE url and never rewritten after the CREATE returned a real id, so dependents 404'd and were dropped. Dependents now carry a {id} placeholder + tempEntityId; flush builds a tempId->realId map and durably rewrites still-queued dependents on CREATE success (survives flush boundaries / reloads). - B2: tempId = -(Date.now()) collided within a millisecond, overwriting an optimistic row. Replaced with a monotonic nextTempId() minter. - B3: any 4xx marked the mutation failed with no rollback and no signal, and the badge ignored failed rows. Terminal failures now roll back the phantom optimistic CREATE; 401/408/425/429 are treated as retryable; failedCount() is surfaced in OfflineBanner (red pill) and OfflineTab. * fix(maps): make offline tiles cover real trips (cap coherence + zoom-clamp) (#1177) Closes BLOCKER B5 — the offline map was blank for most real trips: - The Workbox 'map-tiles' cache held only 1000 entries while the prefetcher budgeted ~3413, so prefetched tiles were evicted on arrival. Both caps are now a coherent 12288 (~180 MB), kept in sync with cross-referencing comments. - prefetchTilesForTrip skipped a trip entirely when its all-zooms estimate exceeded the cap, so region/road-trip bboxes got no tiles. Removed the all-or-nothing guard; prefetchTiles already fills zooms low→high and stops at the budget, so large trips now cache the zooms that fit instead of nothing. * fix(security): stop cross-user offline data leak on shared devices (#1176) Closes BLOCKER B4 — three reinforcing paths could serve one account's cached data to the next user on a shared device: - The Workbox 'api-data' cache keyed trip/user-scoped GETs by URL only (cookie-blind). Changed to NetworkOnly; offline reads come from the per-user IndexedDB cache via the repo layer instead. - IndexedDB had no per-user scoping. The Dexie connection is now scoped per user (trek-offline-u<id>) behind a Proxy so the ~19 importers keep a stable binding; login opens the user DB, logout deletes it and returns to the anonymous DB. - logout() was fire-and-forget and racy: background flush/syncAll could re-seed the DB after the wipe. It is now async and ordered — close an auth gate, unregister sync triggers, disconnect, clear caches, delete the user DB — and flush()/syncAll() bail when the gate is closed. * fix(db): scope, evict, and cap the offline blob cache (H3) (#1178) Blob cache previously leaked forever: clearTripData omitted it, entries had no trip discriminator, and there was no size/count bound, so file blobs survived trip eviction and could starve the map-tile cache for quota. - BlobCacheEntry gains tripId + bytes; Dexie v3 adds a tripId index with a backfill upgrade (legacy rows -> tripId -1, bytes from blob.size) - clearTripData purges the trip's blobs in-transaction - enforceBlobBudget() evicts oldest-by-cachedAt past 200 entries / 100 MB - tripSyncManager threads tripId/bytes into puts and enforces the budget * fix(repo): fall back to Dexie when a network read fails (H2) (#1179) Repos gated reads on raw navigator.onLine and the online branch had no try/catch, so a captive portal or connected-but-no-internet (navigator.onLine lying "true") threw a network error instead of serving the good cached copy — blanking the trip even though Dexie held it. - new onlineThenCache(onlineFn, cacheFn) helper: reads the cache when offline, and on a network-level failure (Axios error with no HTTP response). A genuine HTTP error (4xx/5xx — the server responded) is rethrown so callers still set error state / navigate, not masked by a stale cache. - gates only on navigator.onLine, NOT the connectivity probe: the probe is a coarse global flag and one failed health check would otherwise divert every read to the (possibly empty) cache even when the request would succeed. - every repo list/get read path routed through it (reads only — writes still go through the mutation queue so failures surface) - tests: captive-portal fallback, HTTP-error rethrow, non-Axios rethrow * fix(store): reset and uniformly hydrate trip-scoped slices in loadTrip (H4, H5) (#1180) loadTrip only replaced the first slice group, so budget/reservations/files from a previous trip stayed visible after switching trips (data exposure on a shared screen). Those three also loaded via separate tab-gated effects, so they never hydrated offline for an unopened tab. - resetTrip() clears every trip-scoped slice (keeps global tags/categories) and runs at the top of loadTrip, so a switch can't leak the prior trip's data - loadTrip now hydrates budget/reservations/files through their repos alongside the rest (non-fatal catches), making offline hydration uniform - useTripPlanner drops the redundant loadFiles + reservations/budget effects; tab-gated lazy reloads stay as on-demand refresh - tests: cross-trip no-leak, uniform hydration, resetTrip * fix(sync): re-hydrate active trip store on reconnect/online (H1) (#1181) setRefetchCallback was dead code, so on reconnect the queue flushed and Dexie re-seeded but the open trip's Zustand store was never refreshed — a collaborator's edits made while we were offline didn't appear until navigating away and back. - new tripStore.hydrateActiveTrip(): silent refresh of the active trip's collaborative state (days/places/packing/todo/budget/reservations/files), no resetTrip and no isLoading toggle so there's no splash on reconnect - syncTriggers wires setRefetchCallback to it (WS layer awaits the flush hook first) and re-hydrates open trips after the online-event syncAll; cleared on unregister - websocket exposes getActiveTrips() for the online-event path - tests: refetch wiring + ordering, silent hydrate without reset/splash * fix(server): lengthen idempotency key TTL to survive multi-day offline (H6) (#1182) The nightly cleanup deleted idempotency keys older than 24h. The TREK client replays queued mutations with their X-Idempotency-Key on reconnect, so a device offline longer than a day had its keys GC'd before it returned — the replayed POST was then treated as new and created a duplicate. - raise the TTL to 30 days (DEFAULT_IDEMPOTENCY_TTL_SECONDS), overridable via IDEMPOTENCY_TTL_SECONDS - extract purgeExpiredIdempotencyKeys(now, ttl, db) (mirrors cleanupOldBackups) with an injectable db, and have the cron job call it - tests: 30-day default eviction, 25-day key retained (was dropped at 24h), env override H7 (exactly-once across the lost-response window) is deferred: a correct fix must store the response in the same DB transaction as the entity write. Doing it in the generic interceptor (reserve-before-handler) cannot store the real response body for the crash case, which would break the client's temp->real id remapping on replay (mutationQueue.flush relies on the entity in the body). It needs a per-service change and is tracked separately. * fix(realtime): correct assignment:created echo dedup (H11) (#1183) When X-Idempotency/X-Socket-Id let an own-echo through, the assignment:created dedup had two bugs: it keyed on place id, so (1) a legitimate second assignment of a place already on the day was silently dropped, and (2) the temp-version reconciliation matched place?.id === placeId, letting undefined === undefined collapse place-less rows onto each other. - dedup now keys on assignment id (exact-id duplicate -> no-op) - temp (negative-id) optimistic rows are reconciled only when a real placeId matches, replacing just that row; a sibling temp of another place is untouched - everything else appends, including a genuine 2nd assignment of the same place - tests: 2nd-of-same-place kept, correct temp picked among siblings, place-less rows don't collapse Note: the broader own-echo suppression relies on X-Socket-Id being sent; this fixes the client-side fallback when an echo slips through. * fix(pwa): persist offline storage + Mapbox offline policy (H8, H9) (#1184) H8: prefetched tiles and file blobs could be evicted under storage pressure (worsened by opaque tile responses inflating the quota ~7MB each), blanking the offline map right when a traveler needs it. Request persistent storage at app init so the browser exempts our caches from eviction. We deliberately keep tile requests no-cors (a cors switch would break self-hosted/custom tile providers without CORS headers), so persistence is the safe mitigation rather than de-opaquing responses. H9: Mapbox GL users had no offline map at all — no runtimeCaching matched the Mapbox hosts. Add a StaleWhileRevalidate rule for api.mapbox.com / *.tiles.mapbox.com so visited areas are available offline (best-effort; full pre-download still requires the Leaflet renderer, now documented). - new sync/persistentStorage.ts requestPersistentStorage(), called from main.tsx - vite.config: mapbox-tiles SW cache rule - MapViewAuto / tilePrefetcher comments document the offline-maps policy - tests for the persist helper (granted / already-persisted / absent / rejects) * ci(security): only fail Docker Scout on fixable CVEs Add only-fixed so the scan no longer fails on vulnerabilities with no upstream fix available (e.g. base-image OS packages), and only flags actionable, fixable findings. * build(docker): rebuild gosu with a current Go toolchain Debian's apt gosu ships an old Go stdlib that the image CVE scan flags (1 critical + several high, all in golang/stdlib). Build gosu from source with a current Go toolchain and copy the static binary in instead; the runtime behaviour is unchanged — gosu still drops root to node at startup. * build(deps): bump tsx's esbuild to 0.28.1 (GHSA-gv7w-rqvm-qjhr) The production image's last image-scan finding was esbuild 0.28.0, pulled in transitively by tsx. Pin tsx's esbuild to 0.28.1 (within tsx's ~0.28.0 range) to clear GHSA-gv7w-rqvm-qjhr. Lockfile-only; no runtime change. * feat(auth): add "Remember me" checkbox to extend session lifetime (#1189) Adds a "Remember me" checkbox to the login form (single responsive page, covers mobile + desktop). Unchecked (default) issues the existing SESSION_DURATION JWT with a browser-session cookie (no maxAge); checked issues a longer-lived JWT plus a persistent cookie sized by the new SESSION_DURATION_REMEMBER env var (default 30d). The choice is threaded through the MFA verify leg so it survives the step-up. Register/demo logins keep their current persistent behaviour. * chore(ssrf): include lookup error code in error message * fix(backup): restore from Docker, fail-fast on shadowed /app, bundle encryption key (#1193) (#1197) * fix(backup): restore uploads through symlinked dir and bundle encryption key (#1193) Restoring a backup inside Docker threw ERR_FS_CP_DIR_TO_NON_DIR because /app/server/uploads is a symlink to the mounted /app/uploads volume and cpSync (dereference:false) refuses to overwrite the symlink node with a directory. The DB was swapped before this failing copy, so users saw restored data but missing upload files (trip covers). Resolve the symlink with realpathSync before copying so the merge targets the real directory; no-op on a plain dir, so non-Docker behavior is unchanged. Also bundle the at-rest encryption key (data/.encryption_key) into the backup so a restore onto a different install can decrypt stored secrets (API keys, MFA, SMTP/OIDC). Skipped when ENCRYPTION_KEY is provided via env (the file is not the source of truth then). On restore the key is swapped back if the archive carries one; a restart is required for the in-memory key to take effect. * fix(docker): fail fast when a volume shadows /app (#1193) Mounting an old volume at /app hides the image's node_modules and dist, so startup crashed with a cryptic "Cannot find module 'tsconfig-paths/register'". Add a CMD preflight that detects the missing app files and exits with actionable guidance. Document in the README that only /app/data and /app/uploads should be mounted, never /app. * fix: ssrf test * fix(places): fall back to search when autocomplete details lookup fails (#1192) (#1198) Clicking an auto-suggest dropdown item did a second /maps/details lookup that could fail (details kill-switch off, an overloaded OSM Overpass mirror behind a proxy, or any upstream error), dead-ending on "Place search failed" while the search button stayed reliable. handleSelectSuggestion now treats a missing or coordinate-less details result (or a thrown error) as a miss and falls back to the text-search path the search button uses, applying the first result. The error toast only fires if the fallback also returns nothing. Adds tests for the previously untested suggestion-click path. * fix(planner): scroll long place description/notes on mobile (#1195) (#1199) The place details card (PlaceInspector) clipped long description/notes with no way to scroll. The content area is a flex column whose children (description/notes) had the default flex-shrink: 1, so once the card hit its maxHeight cap they compressed to fit and their overflow:hidden clipped the text instead of overflowing into a scroll region. - Make the content area a bounded scroll region (flex: 1 1 auto, minHeight: 0, overflowY: auto, momentum + overscroll containment). - Pin description/notes with flexShrink: 0 so they keep natural height and the card overflows into the scroll instead of clipping. - Pin header/footer with flexShrink: 0 so they stay fixed while scrolling. - Add wordBreak/overflowWrap to the description div to fix horizontal clip. * Day plan: hotel travel times at start/end + login toggle polish (#1206) * fix(login): use the shared toggle for the stay-signed-in option * feat(planner): show hotel travel times at the start and end of a day * fix(login): give the stay-signed-in toggle an accessible name and fix its test * fix(trips): keep the day-count field empty when cleared and validate it (#1204) (#1207) * docs(readme): refresh dashboard, costs and trip screenshots (#1208) * docs(readme): refresh dashboard, costs and trip screenshots * docs(readme): correct outdated info (React 19, NestJS, 20 languages, Costs rename, passkeys, AirTrail, notifications) * chore: update all dependencies (#1209) * chore: update all dependencies * chore: remove lint errors * fix(client): restore typecheck after dependency bump vitest 4 types vi.fn() as Mock<Procedure | Constructable>, which no longer assigns to the strictly-typed onUpdate prop; type the mock explicitly. TS6 + the new transitive @types/node 25 stopped auto- including node builtin module types, so import('node:buffer') failed; add @types/node as a direct client devDependency and a scoped node type reference in the one test that needs it. * test: fix constructor mocks for vitest 4 Reflect.construct semantics vitest 4 resolves new-invoked mocks via Reflect.construct, which rejects arrow-function implementations (including mockReturnValue sugar) as non-constructable. Convert mapbox-gl and better-sqlite3 mocks that the code instantiates with new to regular function implementations. * fix(planner): only route to multi-day transport endpoints on their pickup/drop-off days (#1210) (#1212) * chore: move to Frankfurter API for exchange rate (#1214) * Restore nest coverage to >=80% after the #1209 dep bump (istanbul provider + branch tests) (#1213) * fix(server): set oxc:false in vitest so the SWC transform survives the Vite 8 bump * fix(server): switch coverage to the istanbul provider (v8 under-reports branches on Vite 8 + Vitest 4) * test(nest): cover controller/service branches to clear the 80% coverage gate * fix(planner): correct transfer-day hotel legs and connect them to transports (#1215) When you change hotels on a day, the morning bookend leg showed the hotel you check into instead of the one you slept in whenever the morning stay didn't end exactly on that day — both bookends collapsed onto the arriving hotel. The morning hotel is now picked by "checked in earlier and still in range" rather than "checks out today", which also fixes the route optimizer's start anchor for the same case. The bookend legs now connect to the first/last located waypoint of the day — a place or a transport endpoint (a car return, a taxi or train arrival) — so the hotel-to-transport drives are included too. * feat(transports): add kitinerary import-from-file button to Transports tab * docs(config): document SESSION_DURATION_REMEMBER across deployment artifacts Add SESSION_DURATION_REMEMBER to docker-compose, .env.example, README env table, Helm chart (values + configmap passthrough), the Unraid template, and the Unraid install guide. Where the base SESSION_DURATION was also absent (README, charts, Unraid) add the pair so the Remember-me variable has context. --------- Co-authored-by: gzor <risenbrowser@web.de> Co-authored-by: ppuassi <34529179+ppuassi@users.noreply.github.com> Co-authored-by: sss3978 <106522699+soma3978@users.noreply.github.com> Co-authored-by: SkyLostTR <onurluerin@gmail.com> Co-authored-by: Julien G. <66769052+jubnl@users.noreply.github.com> Co-authored-by: Dimitris Kafetzis <39215021+Dkafetzis@users.noreply.github.com> Co-authored-by: Ahmet Yılmaz <70577707+sharkpaw@users.noreply.github.com> Co-authored-by: jubnl <jgunther021@gmail.com> Co-authored-by: jufy111 <40817638+jufy111@users.noreply.github.com> Co-authored-by: Larinel <bodink7@gmail.com> Co-authored-by: rossanorbr <48014819+rossanorbr@users.noreply.github.com>
This commit is contained in:
@@ -16,7 +16,10 @@ import { resolveAuthToggles } from './authService';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function utcSuffix(ts: string | null | undefined): string | null {
|
||||
// bcrypt cost factor for user passwords — kept in sync with authService.
|
||||
const BCRYPT_COST = 12;
|
||||
|
||||
function utcSuffix(ts: string | null | undefined): string | null {
|
||||
if (!ts) return null;
|
||||
return ts.endsWith('Z') ? ts : ts.replace(' ', 'T') + 'Z';
|
||||
}
|
||||
@@ -94,7 +97,7 @@ export function createUser(data: { username: string; email: string; password: st
|
||||
const existingEmail = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
|
||||
if (existingEmail) return { error: 'Email already taken', status: 409 };
|
||||
|
||||
const passwordHash = bcrypt.hashSync(password, 12);
|
||||
const passwordHash = bcrypt.hashSync(password, BCRYPT_COST);
|
||||
|
||||
const result = db.prepare(
|
||||
'INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)'
|
||||
@@ -136,7 +139,7 @@ export function updateUser(id: string, data: { username?: string; email?: string
|
||||
const pwCheck = validatePassword(password);
|
||||
if (!pwCheck.ok) return { error: pwCheck.reason, status: 400 };
|
||||
}
|
||||
const passwordHash = password ? bcrypt.hashSync(password, 12) : null;
|
||||
const passwordHash = password ? bcrypt.hashSync(password, BCRYPT_COST) : null;
|
||||
|
||||
db.prepare(`
|
||||
UPDATE users SET
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { safeFetch } from '../../utils/ssrfGuard';
|
||||
|
||||
/**
|
||||
* Thin HTTP client for the AirTrail REST API (github.com/johanohly/AirTrail).
|
||||
* This is the ONLY place that talks to a user's AirTrail instance.
|
||||
*
|
||||
* Verified against AirTrail source:
|
||||
* - Auth: `Authorization: Bearer <key>`; a key maps to exactly one user.
|
||||
* - GET /api/flight/list — defaults to scope=mine. We NEVER send a scope
|
||||
* param so the key only ever returns its owner's own flights (isolation
|
||||
* holds even if an admin key is pasted).
|
||||
* - GET /api/flight/get/{id}
|
||||
* - POST /api/flight/save — `id` present => update, else create. seats[] is
|
||||
* required (>=1). A seat with userId '<USER_ID>' is attributed to the key
|
||||
* owner server-side, so we never need the caller's AirTrail user id.
|
||||
* - There is no webhook and no updated_at on a flight, so change detection is
|
||||
* snapshot-hash based (see airtrailSync).
|
||||
*/
|
||||
|
||||
const TIMEOUT_MS = 12000;
|
||||
|
||||
export interface AirtrailCreds {
|
||||
/** Instance origin without a trailing /api. */
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
allowInsecureTls: boolean;
|
||||
}
|
||||
|
||||
export class AirtrailAuthError extends Error {
|
||||
constructor(message = 'AirTrail rejected the API key') {
|
||||
super(message);
|
||||
this.name = 'AirtrailAuthError';
|
||||
}
|
||||
}
|
||||
|
||||
export class AirtrailRequestError extends Error {
|
||||
status?: number;
|
||||
constructor(message: string, status?: number) {
|
||||
super(message);
|
||||
this.name = 'AirtrailRequestError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AirtrailAirport {
|
||||
id: number;
|
||||
icao: string | null;
|
||||
iata: string | null;
|
||||
name: string | null;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
tz: string | null;
|
||||
country: string | null;
|
||||
}
|
||||
|
||||
export interface AirtrailSeat {
|
||||
userId: string | null;
|
||||
guestName: string | null;
|
||||
seat: string | null;
|
||||
seatNumber: string | null;
|
||||
seatClass: string | null;
|
||||
}
|
||||
|
||||
/** Airline/aircraft come back as joined objects (not bare codes) on a flight. */
|
||||
export interface AirtrailNamedCode {
|
||||
id?: number;
|
||||
icao?: string | null;
|
||||
iata?: string | null;
|
||||
name?: string | null;
|
||||
}
|
||||
|
||||
/** A flight as returned by list/get (the fields TREK consumes). */
|
||||
export interface AirtrailFlightRaw {
|
||||
id: number;
|
||||
from: AirtrailAirport | null;
|
||||
to: AirtrailAirport | null;
|
||||
date: string | null;
|
||||
datePrecision: string | null;
|
||||
departure: string | null;
|
||||
arrival: string | null;
|
||||
airline: AirtrailNamedCode | null;
|
||||
flightNumber: string | null;
|
||||
aircraft: AirtrailNamedCode | null;
|
||||
aircraftReg: string | null;
|
||||
flightReason: string | null;
|
||||
note: string | null;
|
||||
seats: AirtrailSeat[];
|
||||
}
|
||||
|
||||
/** Write shape accepted by POST /flight/save (airports/airline/aircraft as codes). */
|
||||
export interface AirtrailSavePayload {
|
||||
id?: number;
|
||||
from: string;
|
||||
to: string;
|
||||
departure: string;
|
||||
departureTime?: string | null;
|
||||
arrival?: string | null;
|
||||
arrivalTime?: string | null;
|
||||
datePrecision?: string;
|
||||
airline?: string | null;
|
||||
flightNumber?: string | null;
|
||||
aircraft?: string | null;
|
||||
aircraftReg?: string | null;
|
||||
flightReason?: string | null;
|
||||
note?: string | null;
|
||||
seats: Array<{
|
||||
userId: string | null;
|
||||
guestName: string | null;
|
||||
seat: string | null;
|
||||
seatNumber: string | null;
|
||||
seatClass: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
function apiBase(baseUrl: string): string {
|
||||
// Tolerate a pasted trailing slash or '/api' suffix so we never build '/api/api'.
|
||||
const origin = baseUrl.trim().replace(/\/+$/, '').replace(/\/api$/i, '');
|
||||
return origin + '/api';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a response as JSON, but turn the cryptic "Unexpected token '<'" that a
|
||||
* misconfigured URL produces (AirTrail serving its SPA / an auth-proxy login
|
||||
* page) into an actionable message.
|
||||
*/
|
||||
async function parseJson<T>(resp: Response): Promise<T> {
|
||||
const text = await resp.text();
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
throw new AirtrailRequestError(
|
||||
'AirTrail returned a non-JSON response. Check the URL is your AirTrail base URL (e.g. https://airtrail.example.com, without /api) and that the instance is reachable without a separate login.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function request(creds: AirtrailCreds, path: string, init: RequestInit): Promise<Response> {
|
||||
const url = apiBase(creds.baseUrl) + path;
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await safeFetch(
|
||||
url,
|
||||
{
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Bearer ${creds.apiKey}`,
|
||||
Accept: 'application/json',
|
||||
...(init.headers || {}),
|
||||
},
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS) as any,
|
||||
},
|
||||
{ rejectUnauthorized: !creds.allowInsecureTls },
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
throw new AirtrailRequestError(err instanceof Error ? err.message : 'Could not reach AirTrail');
|
||||
}
|
||||
if (resp.status === 401 || resp.status === 403) {
|
||||
throw new AirtrailAuthError();
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
export async function listFlights(creds: AirtrailCreds): Promise<AirtrailFlightRaw[]> {
|
||||
const resp = await request(creds, '/flight/list', { method: 'GET' });
|
||||
if (!resp.ok) throw new AirtrailRequestError(`AirTrail list failed (HTTP ${resp.status})`, resp.status);
|
||||
const data = await parseJson<{ flights?: AirtrailFlightRaw[] }>(resp);
|
||||
return data.flights ?? [];
|
||||
}
|
||||
|
||||
export async function getFlight(creds: AirtrailCreds, id: number): Promise<AirtrailFlightRaw | null> {
|
||||
const resp = await request(creds, `/flight/get/${id}`, { method: 'GET' });
|
||||
if (resp.status === 404) return null;
|
||||
if (!resp.ok) throw new AirtrailRequestError(`AirTrail get failed (HTTP ${resp.status})`, resp.status);
|
||||
const data = await parseJson<{ flight?: AirtrailFlightRaw }>(resp);
|
||||
return data.flight ?? null;
|
||||
}
|
||||
|
||||
export async function saveFlight(creds: AirtrailCreds, payload: AirtrailSavePayload): Promise<{ id?: number }> {
|
||||
const resp = await request(creds, '/flight/save', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
let msg = `AirTrail save failed (HTTP ${resp.status})`;
|
||||
try {
|
||||
const body = (await resp.json()) as { message?: string; errors?: unknown };
|
||||
if (body?.message) msg = body.message;
|
||||
else if (body?.errors) msg = JSON.stringify(body.errors);
|
||||
} catch {
|
||||
/* keep the generic message */
|
||||
}
|
||||
throw new AirtrailRequestError(msg, resp.status);
|
||||
}
|
||||
const data = await parseJson<{ id?: number }>(resp);
|
||||
return { id: data.id };
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { AirtrailImportResult } from '@trek/shared';
|
||||
import { db } from '../../db/database';
|
||||
import { broadcast } from '../../websocket';
|
||||
import { createReservation } from '../reservationService';
|
||||
import { getAirtrailCredentials } from './airtrailService';
|
||||
import { AirtrailRequestError, listFlights } from './airtrailClient';
|
||||
import { canonicalHash, mapFlightToReservation } from './airtrailMapper';
|
||||
|
||||
interface ExistingFlightRow {
|
||||
id: number;
|
||||
reservation_time: string | null;
|
||||
metadata: string | null;
|
||||
from_code: string | null;
|
||||
to_code: string | null;
|
||||
}
|
||||
|
||||
function depDate(t: string | null): string | null {
|
||||
return t && /^\d{4}-\d{2}-\d{2}/.test(t) ? t.slice(0, 10) : null;
|
||||
}
|
||||
|
||||
/** A loose "same physical flight" key: flight number + date, else route + date. */
|
||||
function softSignature(
|
||||
date: string | null,
|
||||
flightNumber: string | null,
|
||||
fromCode: string | null,
|
||||
toCode: string | null,
|
||||
): string | null {
|
||||
if (!date) return null;
|
||||
if (flightNumber) return `fn:${flightNumber.toUpperCase()}@${date}`;
|
||||
if (fromCode && toCode) return `rt:${fromCode.toUpperCase()}-${toCode.toUpperCase()}@${date}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import the given AirTrail flights into a trip as reservations (type:'flight'),
|
||||
* recording the AirTrail linkage for two-way sync and broadcasting each one live.
|
||||
*
|
||||
* Dedup: a flight already linked to this trip is skipped ('already-imported'); a
|
||||
* flight that looks like one already in the trip — e.g. the same flight another
|
||||
* member already imported from their own AirTrail — is skipped ('already-in-trip').
|
||||
* The server re-fetches the flights by id with the caller's own key, so the client
|
||||
* cannot inject arbitrary flight data.
|
||||
*/
|
||||
export async function importAirtrailFlights(
|
||||
tripId: string | number,
|
||||
userId: number,
|
||||
flightIds: string[],
|
||||
socketId: string | undefined,
|
||||
): Promise<AirtrailImportResult> {
|
||||
const creds = getAirtrailCredentials(userId);
|
||||
if (!creds) throw new AirtrailRequestError('AirTrail is not connected', 400);
|
||||
|
||||
const wanted = new Set(flightIds.map(String));
|
||||
const selected = (await listFlights(creds)).filter(f => wanted.has(String(f.id)));
|
||||
|
||||
const result: AirtrailImportResult = { imported: [], skipped: [] };
|
||||
|
||||
const linkedIds = new Set(
|
||||
(db.prepare("SELECT external_id FROM reservations WHERE trip_id = ? AND external_source = 'airtrail'").all(tripId) as {
|
||||
external_id: string | null;
|
||||
}[])
|
||||
.map(r => r.external_id)
|
||||
.filter((v): v is string => !!v),
|
||||
);
|
||||
|
||||
const existing = db
|
||||
.prepare(
|
||||
`SELECT r.id, r.reservation_time, r.metadata,
|
||||
(SELECT code FROM reservation_endpoints WHERE reservation_id = r.id AND role = 'from' LIMIT 1) AS from_code,
|
||||
(SELECT code FROM reservation_endpoints WHERE reservation_id = r.id AND role = 'to' LIMIT 1) AS to_code
|
||||
FROM reservations r WHERE r.trip_id = ? AND r.type = 'flight'`,
|
||||
)
|
||||
.all(tripId) as ExistingFlightRow[];
|
||||
|
||||
const existingSigs = new Set<string>();
|
||||
for (const row of existing) {
|
||||
let fn: string | null = null;
|
||||
try {
|
||||
fn = row.metadata ? (JSON.parse(row.metadata).flight_number ?? null) : null;
|
||||
} catch {
|
||||
/* malformed metadata — ignore */
|
||||
}
|
||||
const sig = softSignature(depDate(row.reservation_time), fn, row.from_code, row.to_code);
|
||||
if (sig) existingSigs.add(sig);
|
||||
}
|
||||
|
||||
for (const flight of selected) {
|
||||
const fid = String(flight.id);
|
||||
if (linkedIds.has(fid)) {
|
||||
result.skipped.push({ flightId: fid, reason: 'already-imported' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const mapped = mapFlightToReservation(flight);
|
||||
const sig = softSignature(
|
||||
depDate(mapped.reservation_time),
|
||||
(mapped.metadata.flight_number as string) ?? null,
|
||||
mapped.endpoints.find(e => e.role === 'from')?.code ?? null,
|
||||
mapped.endpoints.find(e => e.role === 'to')?.code ?? null,
|
||||
);
|
||||
if (sig && existingSigs.has(sig)) {
|
||||
result.skipped.push({ flightId: fid, reason: 'already-in-trip', detail: mapped.title });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const { reservation } = createReservation(tripId, mapped as any);
|
||||
const now = new Date().toISOString();
|
||||
db.prepare(
|
||||
`UPDATE reservations SET external_source = 'airtrail', external_id = ?, external_owner_user_id = ?,
|
||||
sync_enabled = 1, external_hash = ?, external_synced_at = ? WHERE id = ?`,
|
||||
).run(fid, userId, canonicalHash(flight), now, reservation.id);
|
||||
|
||||
// Carry the linkage on the broadcast payload so members see the badge live.
|
||||
reservation.external_source = 'airtrail';
|
||||
reservation.external_id = fid;
|
||||
reservation.external_owner_user_id = userId;
|
||||
reservation.sync_enabled = 1;
|
||||
reservation.external_synced_at = now;
|
||||
|
||||
broadcast(tripId, 'reservation:created', { reservation }, socketId);
|
||||
if (sig) existingSigs.add(sig);
|
||||
linkedIds.add(fid);
|
||||
result.imported.push(fid);
|
||||
} catch (err) {
|
||||
console.error('[airtrail-import] failed to import flight', fid, err instanceof Error ? err.message : err);
|
||||
result.skipped.push({ flightId: fid, reason: 'invalid', detail: err instanceof Error ? err.message : undefined });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import * as crypto from 'node:crypto';
|
||||
import type { AirtrailAirport, AirtrailFlightRaw, AirtrailNamedCode } from './airtrailClient';
|
||||
import type { AirtrailFlight } from '@trek/shared';
|
||||
|
||||
/** Preferred display/lookup code for an airport. */
|
||||
function airportCode(a: AirtrailAirport | null): string | null {
|
||||
return a?.iata || a?.icao || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Airline/aircraft arrive as joined objects ({icao, iata, name, ...}); reduce
|
||||
* them to a single code (ICAO preferred, matching AirTrail's save shape).
|
||||
*/
|
||||
function entityCode(e: AirtrailNamedCode | null | undefined): string | null {
|
||||
return e?.icao || e?.iata || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local calendar date + clock time for an instant at a given IANA zone.
|
||||
* AirTrail stores `departure`/`arrival` as instants (ISO w/ offset) plus a local
|
||||
* `date`; the airport-local wall time is what TREK shows and files days by.
|
||||
*/
|
||||
function localParts(iso: string | null, tz: string | null): { date: string | null; time: string | null } {
|
||||
if (!iso) return { date: null, time: null };
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return { date: null, time: null };
|
||||
const fmt = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: tz || 'UTC',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
const parts = fmt.formatToParts(d);
|
||||
const get = (t: string) => parts.find(p => p.type === t)?.value ?? '';
|
||||
const date = `${get('year')}-${get('month')}-${get('day')}`;
|
||||
let hh = get('hour');
|
||||
if (hh === '24') hh = '00'; // some ICU builds emit 24:00 for midnight
|
||||
const time = `${hh}:${get('minute')}`;
|
||||
return { date: /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null, time };
|
||||
} catch {
|
||||
return { date: null, time: null };
|
||||
}
|
||||
}
|
||||
|
||||
/** Raw AirTrail flight → the normalized shape the import picker consumes. */
|
||||
export function normalizeFlight(raw: AirtrailFlightRaw): AirtrailFlight {
|
||||
return {
|
||||
id: String(raw.id),
|
||||
fromCode: airportCode(raw.from),
|
||||
fromName: raw.from?.name ?? null,
|
||||
toCode: airportCode(raw.to),
|
||||
toName: raw.to?.name ?? null,
|
||||
date: raw.date ?? null,
|
||||
departure: raw.departure ?? null,
|
||||
arrival: raw.arrival ?? null,
|
||||
airline: entityCode(raw.airline),
|
||||
flightNumber: raw.flightNumber ?? null,
|
||||
aircraft: entityCode(raw.aircraft),
|
||||
seatClass: (raw.seats?.find(s => s.userId) ?? raw.seats?.[0])?.seatClass ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export interface MappedEndpoint {
|
||||
role: 'from' | 'to' | 'stop';
|
||||
sequence: number;
|
||||
name: string;
|
||||
code: string | null;
|
||||
lat: number;
|
||||
lng: number;
|
||||
timezone: string | null;
|
||||
local_time: string | null;
|
||||
local_date: string | null;
|
||||
}
|
||||
|
||||
export interface MappedReservation {
|
||||
title: string;
|
||||
type: 'flight';
|
||||
status: 'confirmed';
|
||||
reservation_time: string | null;
|
||||
reservation_end_time: string | null;
|
||||
notes: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
endpoints: MappedEndpoint[];
|
||||
needs_review: number;
|
||||
}
|
||||
|
||||
function hasCoords(a: AirtrailAirport | null): a is AirtrailAirport & { lat: number; lng: number } {
|
||||
return !!a && typeof a.lat === 'number' && typeof a.lon === 'number';
|
||||
}
|
||||
|
||||
/** Raw AirTrail flight → the data createReservation() expects (type:'flight'). */
|
||||
export function mapFlightToReservation(raw: AirtrailFlightRaw): MappedReservation {
|
||||
const dep = localParts(raw.departure, raw.from?.tz ?? null);
|
||||
const arr = localParts(raw.arrival, raw.to?.tz ?? null);
|
||||
|
||||
const fromCode = airportCode(raw.from);
|
||||
const toCode = airportCode(raw.to);
|
||||
const datePrefix = raw.date || dep.date;
|
||||
const reservation_time = datePrefix ? `${datePrefix}T${dep.time ?? '00:00'}` : null;
|
||||
const reservation_end_time = arr.date ? `${arr.date}T${arr.time ?? '00:00'}` : null;
|
||||
|
||||
const endpoints: MappedEndpoint[] = [];
|
||||
let needsReview = raw.datePrecision && raw.datePrecision !== 'day' ? 1 : 0;
|
||||
|
||||
if (hasCoords(raw.from)) {
|
||||
endpoints.push({
|
||||
role: 'from',
|
||||
sequence: 0,
|
||||
name: raw.from.name || fromCode || 'Departure',
|
||||
code: fromCode,
|
||||
lat: raw.from.lat,
|
||||
lng: raw.from.lon,
|
||||
timezone: raw.from.tz,
|
||||
local_time: dep.time,
|
||||
local_date: datePrefix,
|
||||
});
|
||||
} else {
|
||||
needsReview = 1;
|
||||
}
|
||||
|
||||
if (hasCoords(raw.to)) {
|
||||
endpoints.push({
|
||||
role: 'to',
|
||||
sequence: 1,
|
||||
name: raw.to.name || toCode || 'Arrival',
|
||||
code: toCode,
|
||||
lat: raw.to.lat,
|
||||
lng: raw.to.lon,
|
||||
timezone: raw.to.tz,
|
||||
local_time: arr.time,
|
||||
local_date: arr.date,
|
||||
});
|
||||
} else {
|
||||
needsReview = 1;
|
||||
}
|
||||
|
||||
const seat = raw.seats?.find(s => s.userId) ?? raw.seats?.[0];
|
||||
const airlineCode = entityCode(raw.airline);
|
||||
const aircraftCode = entityCode(raw.aircraft);
|
||||
const metadata: Record<string, unknown> = {};
|
||||
if (airlineCode) metadata.airline = airlineCode;
|
||||
if (raw.flightNumber) metadata.flight_number = raw.flightNumber;
|
||||
if (aircraftCode) metadata.aircraft = aircraftCode;
|
||||
if (raw.aircraftReg) metadata.aircraft_reg = raw.aircraftReg;
|
||||
if (raw.flightReason) metadata.flight_reason = raw.flightReason;
|
||||
if (seat?.seatNumber || seat?.seatClass) metadata.seat = seat.seatNumber || seat.seatClass;
|
||||
|
||||
// The flight number already carries the airline prefix (e.g. "SAS983"), so it
|
||||
// makes the clearest title; fall back to the route.
|
||||
const title = raw.flightNumber?.trim() || `${fromCode || '?'} → ${toCode || '?'}`;
|
||||
|
||||
return {
|
||||
title,
|
||||
type: 'flight',
|
||||
status: 'confirmed',
|
||||
reservation_time,
|
||||
reservation_end_time,
|
||||
notes: raw.note ?? null,
|
||||
metadata,
|
||||
endpoints,
|
||||
needs_review: needsReview,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable snapshot hash of an AirTrail flight, used by the sync engine to detect
|
||||
* remote changes (AirTrail exposes no updated_at/etag) and to suppress TREK's own
|
||||
* writes from re-triggering a pull. Only fields that can meaningfully change are
|
||||
* included, in a fixed key order.
|
||||
*/
|
||||
export function canonicalHash(raw: AirtrailFlightRaw): string {
|
||||
const snapshot = {
|
||||
from: airportCode(raw.from),
|
||||
to: airportCode(raw.to),
|
||||
date: raw.date ?? null,
|
||||
datePrecision: raw.datePrecision ?? 'day',
|
||||
departure: raw.departure ?? null,
|
||||
arrival: raw.arrival ?? null,
|
||||
airline: entityCode(raw.airline),
|
||||
flightNumber: raw.flightNumber ?? null,
|
||||
aircraft: entityCode(raw.aircraft),
|
||||
aircraftReg: raw.aircraftReg ?? null,
|
||||
flightReason: raw.flightReason ?? null,
|
||||
note: raw.note ?? null,
|
||||
seats: (raw.seats ?? [])
|
||||
.map(s => ({
|
||||
userId: s.userId ?? null,
|
||||
guestName: s.guestName ?? null,
|
||||
seat: s.seat ?? null,
|
||||
seatNumber: s.seatNumber ?? null,
|
||||
seatClass: s.seatClass ?? null,
|
||||
}))
|
||||
.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))),
|
||||
};
|
||||
return crypto.createHash('sha256').update(JSON.stringify(snapshot)).digest('hex');
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { AirtrailFlight } from '@trek/shared';
|
||||
import { db } from '../../db/database';
|
||||
import { maybe_encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto';
|
||||
import { checkSsrf } from '../../utils/ssrfGuard';
|
||||
import { writeAudit } from '../auditLog';
|
||||
import { AirtrailAuthError, AirtrailCreds, AirtrailRequestError, listFlights } from './airtrailClient';
|
||||
import { normalizeFlight } from './airtrailMapper';
|
||||
|
||||
const KEY_MASK = '••••••••';
|
||||
|
||||
interface UserConnRow {
|
||||
airtrail_url?: string | null;
|
||||
airtrail_api_key?: string | null;
|
||||
airtrail_allow_insecure_tls?: number | null;
|
||||
}
|
||||
|
||||
function readRow(userId: number): UserConnRow | undefined {
|
||||
return db
|
||||
.prepare('SELECT airtrail_url, airtrail_api_key, airtrail_allow_insecure_tls FROM users WHERE id = ?')
|
||||
.get(userId) as UserConnRow | undefined;
|
||||
}
|
||||
|
||||
/** Decrypted creds for outbound calls, or null when the user has no connection. */
|
||||
export function getAirtrailCredentials(userId: number): AirtrailCreds | null {
|
||||
const row = readRow(userId);
|
||||
if (!row?.airtrail_url || !row?.airtrail_api_key) return null;
|
||||
const apiKey = decrypt_api_key(row.airtrail_api_key);
|
||||
if (!apiKey) return null;
|
||||
return {
|
||||
baseUrl: row.airtrail_url,
|
||||
apiKey,
|
||||
allowInsecureTls: !!row.airtrail_allow_insecure_tls,
|
||||
};
|
||||
}
|
||||
|
||||
/** Settings as shown in the UI — the key is never echoed, only masked. */
|
||||
export function getConnectionSettings(userId: number) {
|
||||
const row = readRow(userId);
|
||||
return {
|
||||
url: row?.airtrail_url || '',
|
||||
apiKeyMasked: row?.airtrail_api_key ? KEY_MASK : '',
|
||||
allowInsecureTls: !!row?.airtrail_allow_insecure_tls,
|
||||
connected: !!(row?.airtrail_url && row?.airtrail_api_key),
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveSettings(
|
||||
userId: number,
|
||||
url: string | undefined,
|
||||
apiKey: string | undefined,
|
||||
allowInsecureTls: boolean,
|
||||
clientIp: string | null,
|
||||
): Promise<{ success: boolean; warning?: string; error?: string }> {
|
||||
const trimmedUrl = (url || '').trim();
|
||||
let warning: string | undefined;
|
||||
|
||||
if (trimmedUrl) {
|
||||
const ssrf = await checkSsrf(trimmedUrl);
|
||||
// Reject only genuinely unusable URLs (malformed, unresolvable, non-http,
|
||||
// loopback). Private/LAN instances are the common self-hosted case, so we
|
||||
// persist them with a warning rather than blocking — the outbound calls
|
||||
// still need ALLOW_INTERNAL_NETWORK=true to actually reach them.
|
||||
if (!ssrf.allowed && !ssrf.isPrivate) {
|
||||
return { success: false, error: ssrf.error ?? 'Invalid AirTrail URL' };
|
||||
}
|
||||
if (ssrf.isPrivate) {
|
||||
writeAudit({
|
||||
userId,
|
||||
action: 'airtrail.private_ip_configured',
|
||||
ip: clientIp,
|
||||
details: { airtrail_url: trimmedUrl, resolved_ip: ssrf.resolvedIp },
|
||||
});
|
||||
warning = `AirTrail URL resolves to a private IP (${ssrf.resolvedIp}). Make sure this is intentional — the server may need ALLOW_INTERNAL_NETWORK=true to reach it.`;
|
||||
}
|
||||
}
|
||||
|
||||
// Only overwrite the stored key when a genuinely new value is supplied;
|
||||
// a blank field or the mask means "keep the existing key".
|
||||
const provided = (apiKey || '').trim();
|
||||
const newKey = provided && provided !== KEY_MASK ? maybe_encrypt_api_key(provided) : undefined;
|
||||
|
||||
if (newKey !== undefined) {
|
||||
db.prepare(
|
||||
'UPDATE users SET airtrail_url = ?, airtrail_api_key = ?, airtrail_allow_insecure_tls = ? WHERE id = ?',
|
||||
).run(trimmedUrl || null, newKey, allowInsecureTls ? 1 : 0, userId);
|
||||
} else {
|
||||
db.prepare(
|
||||
'UPDATE users SET airtrail_url = ?, airtrail_allow_insecure_tls = ? WHERE id = ?',
|
||||
).run(trimmedUrl || null, allowInsecureTls ? 1 : 0, userId);
|
||||
// Clearing the URL with no key left makes the connection meaningless — drop the key too.
|
||||
if (!trimmedUrl) {
|
||||
db.prepare('UPDATE users SET airtrail_api_key = NULL WHERE id = ?').run(userId);
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, warning };
|
||||
}
|
||||
|
||||
async function probe(creds: AirtrailCreds): Promise<{ connected: boolean; flightCount?: number; error?: string }> {
|
||||
try {
|
||||
const flights = await listFlights(creds);
|
||||
return { connected: true, flightCount: flights.length };
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof AirtrailAuthError) return { connected: false, error: 'Invalid API key' };
|
||||
return { connected: false, error: err instanceof Error ? err.message : 'Connection failed' };
|
||||
}
|
||||
}
|
||||
|
||||
/** Live check using the stored connection. */
|
||||
export async function getConnectionStatus(
|
||||
userId: number,
|
||||
): Promise<{ connected: boolean; flightCount?: number; error?: string }> {
|
||||
const creds = getAirtrailCredentials(userId);
|
||||
if (!creds) return { connected: false, error: 'Not configured' };
|
||||
return probe(creds);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Test connection" from the settings form. Uses the typed URL/key when given;
|
||||
* falls back to the stored key when the key field still shows the mask.
|
||||
*/
|
||||
export async function testConnection(
|
||||
userId: number,
|
||||
url: string | undefined,
|
||||
apiKey: string | undefined,
|
||||
allowInsecureTls: boolean,
|
||||
): Promise<{ connected: boolean; flightCount?: number; error?: string }> {
|
||||
const trimmedUrl = (url || '').trim();
|
||||
const provided = (apiKey || '').trim();
|
||||
|
||||
const stored = getAirtrailCredentials(userId);
|
||||
const effectiveUrl = trimmedUrl || stored?.baseUrl;
|
||||
const effectiveKey = provided && provided !== KEY_MASK ? provided : stored?.apiKey;
|
||||
|
||||
if (!effectiveUrl || !effectiveKey) {
|
||||
return { connected: false, error: 'URL and API key required' };
|
||||
}
|
||||
|
||||
const ssrf = await checkSsrf(effectiveUrl);
|
||||
if (!ssrf.allowed && !ssrf.isPrivate) {
|
||||
return { connected: false, error: ssrf.error ?? 'Invalid AirTrail URL' };
|
||||
}
|
||||
|
||||
return probe({ baseUrl: effectiveUrl, apiKey: effectiveKey, allowInsecureTls });
|
||||
}
|
||||
|
||||
/** The user's AirTrail flights, normalized for the import picker. */
|
||||
export async function getFlightsForPicker(userId: number): Promise<AirtrailFlight[]> {
|
||||
const creds = getAirtrailCredentials(userId);
|
||||
if (!creds) throw new AirtrailRequestError('AirTrail is not connected', 400);
|
||||
const raw = await listFlights(creds);
|
||||
return raw.map(normalizeFlight);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { db } from '../../db/database';
|
||||
import { broadcast } from '../../websocket';
|
||||
import { isAddonEnabled } from '../adminService';
|
||||
import { logError, logInfo } from '../auditLog';
|
||||
import { getReservation, getReservationWithJoins, updateReservation } from '../reservationService';
|
||||
import {
|
||||
AirtrailAuthError,
|
||||
AirtrailCreds,
|
||||
AirtrailFlightRaw,
|
||||
AirtrailSavePayload,
|
||||
getFlight,
|
||||
listFlights,
|
||||
saveFlight,
|
||||
} from './airtrailClient';
|
||||
import { canonicalHash, mapFlightToReservation } from './airtrailMapper';
|
||||
import { getAirtrailCredentials } from './airtrailService';
|
||||
|
||||
/** Global on/off: the addon must be enabled and sync not explicitly turned off. */
|
||||
export function syncGloballyEnabled(): boolean {
|
||||
if (!isAddonEnabled(ADDON_IDS.AIRTRAIL)) return false;
|
||||
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'airtrail_sync_enabled'").get() as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
return row?.value !== 'false';
|
||||
}
|
||||
|
||||
function broadcastUpdated(tripId: number, reservationId: number): void {
|
||||
try {
|
||||
const reservation = getReservationWithJoins(reservationId);
|
||||
if (reservation) broadcast(tripId, 'reservation:updated', { reservation });
|
||||
} catch {
|
||||
/* broadcast failure is non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
function detach(tripId: number, reservationId: number): void {
|
||||
db.prepare('UPDATE reservations SET sync_enabled = 0 WHERE id = ?').run(reservationId);
|
||||
broadcastUpdated(tripId, reservationId);
|
||||
}
|
||||
|
||||
// ── AirTrail → TREK (poll) ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reconcile one owner's linked reservations against their current AirTrail
|
||||
* flights: apply field changes (detected by snapshot hash, since AirTrail has no
|
||||
* updated_at) and, when a flight is gone from AirTrail, keep the TREK row but
|
||||
* stop syncing it. Only already-imported flights are touched — new AirTrail
|
||||
* flights are never auto-added to a trip. Returns how many rows changed.
|
||||
*/
|
||||
async function syncOwner(uid: number): Promise<number> {
|
||||
const creds = getAirtrailCredentials(uid);
|
||||
if (!creds) return 0; // owner disconnected — leave their linked rows as-is
|
||||
|
||||
let flights: AirtrailFlightRaw[];
|
||||
try {
|
||||
flights = await listFlights(creds);
|
||||
} catch (err) {
|
||||
if (err instanceof AirtrailAuthError) logError(`AirTrail sync: invalid API key for user ${uid}`);
|
||||
return 0;
|
||||
}
|
||||
const byId = new Map(flights.map((f) => [String(f.id), f]));
|
||||
|
||||
const linked = db
|
||||
.prepare(
|
||||
"SELECT id, trip_id, external_id, external_hash FROM reservations WHERE external_source = 'airtrail' AND sync_enabled = 1 AND external_owner_user_id = ?",
|
||||
)
|
||||
.all(uid) as { id: number; trip_id: number; external_id: string; external_hash: string | null }[];
|
||||
|
||||
let changed = 0;
|
||||
for (const row of linked) {
|
||||
const flight = byId.get(String(row.external_id));
|
||||
if (!flight) {
|
||||
detach(row.trip_id, row.id); // deleted in AirTrail → keep row, stop syncing
|
||||
changed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const hash = canonicalHash(flight);
|
||||
if (hash === row.external_hash) continue;
|
||||
|
||||
const current = getReservation(row.id, row.trip_id);
|
||||
if (!current) continue;
|
||||
try {
|
||||
updateReservation(row.id, row.trip_id, mapFlightToReservation(flight) as any, current as any);
|
||||
db.prepare('UPDATE reservations SET external_hash = ?, external_synced_at = ? WHERE id = ?').run(
|
||||
hash,
|
||||
new Date().toISOString(),
|
||||
row.id,
|
||||
);
|
||||
broadcastUpdated(row.trip_id, row.id);
|
||||
changed++;
|
||||
} catch (err) {
|
||||
logError(`AirTrail sync: failed to update reservation ${row.id}: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
let running = false;
|
||||
|
||||
/** Background poll across every connected owner (scheduler). */
|
||||
export async function runAirtrailSync(): Promise<void> {
|
||||
if (running) return;
|
||||
if (!syncGloballyEnabled()) return;
|
||||
running = true;
|
||||
let changed = 0;
|
||||
try {
|
||||
const owners = db
|
||||
.prepare(
|
||||
"SELECT DISTINCT external_owner_user_id AS uid FROM reservations WHERE external_source = 'airtrail' AND sync_enabled = 1 AND external_owner_user_id IS NOT NULL",
|
||||
)
|
||||
.all() as { uid: number }[];
|
||||
for (const { uid } of owners) changed += await syncOwner(uid);
|
||||
if (changed > 0) logInfo(`AirTrail sync: applied ${changed} change(s)`);
|
||||
} catch (err) {
|
||||
logError(`AirTrail sync failed: ${err instanceof Error ? err.message : err}`);
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On-demand sync of just this user's linked flights — called when the user opens
|
||||
* a trip so AirTrail-side edits show up immediately instead of waiting for the
|
||||
* background poll.
|
||||
*/
|
||||
export async function runAirtrailSyncForUser(userId: number): Promise<{ changed: number }> {
|
||||
if (!syncGloballyEnabled()) return { changed: 0 };
|
||||
try {
|
||||
return { changed: await syncOwner(userId) };
|
||||
} catch (err) {
|
||||
logError(`AirTrail sync (user ${userId}) failed: ${err instanceof Error ? err.message : err}`);
|
||||
return { changed: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// ── TREK → AirTrail (push) ───────────────────────────────────────────────────
|
||||
|
||||
function splitLocal(dt: string | null | undefined): { date: string | null; time: string | null } {
|
||||
if (!dt) return { date: null, time: null };
|
||||
const date = dt.slice(0, 10);
|
||||
const m = dt.slice(10).match(/(\d{2}:\d{2})/);
|
||||
return { date: /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null, time: m ? m[1] : null };
|
||||
}
|
||||
|
||||
function buildSavePayload(reservation: any, existing: AirtrailFlightRaw): AirtrailSavePayload | null {
|
||||
let meta: Record<string, any>;
|
||||
try {
|
||||
meta = reservation.metadata ? JSON.parse(reservation.metadata) : {};
|
||||
} catch {
|
||||
meta = {};
|
||||
}
|
||||
const endpoints: any[] = reservation.endpoints || [];
|
||||
const fromEp = endpoints.find((e) => e.role === 'from');
|
||||
const toEp = endpoints.find((e) => e.role === 'to');
|
||||
const fromCode = fromEp?.code || existing.from?.iata || existing.from?.icao || null;
|
||||
const toCode = toEp?.code || existing.to?.iata || existing.to?.icao || null;
|
||||
if (!fromCode || !toCode) return null;
|
||||
|
||||
const dep = splitLocal(reservation.reservation_time);
|
||||
const arr = splitLocal(reservation.reservation_end_time);
|
||||
if (!dep.date) return null;
|
||||
|
||||
// Preserve the existing seat manifest (an update replaces all seats); fall back
|
||||
// to the key-owner placeholder so AirTrail attributes it to the connecting user.
|
||||
const seats = (existing.seats ?? []).map((s) => ({
|
||||
userId: s.userId,
|
||||
guestName: s.guestName,
|
||||
seat: s.seat,
|
||||
seatNumber: s.seatNumber,
|
||||
seatClass: s.seatClass,
|
||||
}));
|
||||
if (seats.length === 0) {
|
||||
seats.push({ userId: '<USER_ID>', guestName: null, seat: null, seatNumber: null, seatClass: null });
|
||||
}
|
||||
|
||||
// Push the seat the user set in TREK onto their own AirTrail seat (the one with
|
||||
// a userId), leaving any co-passenger seats untouched.
|
||||
const seatNumber = typeof meta.seat === 'string' && meta.seat.trim() ? meta.seat.trim() : null;
|
||||
if (seatNumber) {
|
||||
const ownSeat = seats.find((s) => s.userId) ?? seats[0];
|
||||
if (ownSeat) ownSeat.seatNumber = seatNumber;
|
||||
}
|
||||
|
||||
return {
|
||||
id: Number(reservation.external_id),
|
||||
from: fromCode,
|
||||
to: toCode,
|
||||
departure: dep.date,
|
||||
departureTime: dep.time,
|
||||
arrival: arr.date,
|
||||
arrivalTime: arr.time,
|
||||
airline: meta.airline ?? null,
|
||||
flightNumber: meta.flight_number ?? null,
|
||||
aircraft: meta.aircraft ?? null,
|
||||
aircraftReg: meta.aircraft_reg ?? null,
|
||||
flightReason: meta.flight_reason ?? null,
|
||||
note: reservation.notes ?? null,
|
||||
seats,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a locally-edited linked reservation back to AirTrail using the importer's
|
||||
* (owner's) credentials — even if a different member made the edit. If the owner
|
||||
* is gone or the flight no longer exists in AirTrail, the link is detached so the
|
||||
* next pull's AirTrail-wins policy can't silently revert the local edit.
|
||||
*/
|
||||
export async function pushReservationToAirtrail(reservationId: number, tripId: number): Promise<void> {
|
||||
if (!syncGloballyEnabled()) return;
|
||||
|
||||
const row = db
|
||||
.prepare(
|
||||
"SELECT id, trip_id, external_id, external_owner_user_id, sync_enabled FROM reservations WHERE id = ? AND external_source = 'airtrail'",
|
||||
)
|
||||
.get(reservationId) as
|
||||
| { id: number; trip_id: number; external_id: string; external_owner_user_id: number | null; sync_enabled: number }
|
||||
| undefined;
|
||||
if (!row || !row.sync_enabled) return;
|
||||
|
||||
const creds: AirtrailCreds | null = row.external_owner_user_id
|
||||
? getAirtrailCredentials(row.external_owner_user_id)
|
||||
: null;
|
||||
if (!creds) {
|
||||
detach(tripId, row.id); // owner disconnected — cannot push, so stop syncing
|
||||
return;
|
||||
}
|
||||
|
||||
let existing: AirtrailFlightRaw | null;
|
||||
try {
|
||||
existing = await getFlight(creds, Number(row.external_id));
|
||||
} catch (err) {
|
||||
if (err instanceof AirtrailAuthError) detach(tripId, row.id);
|
||||
else logError(`AirTrail push: get failed for reservation ${row.id}: ${err instanceof Error ? err.message : err}`);
|
||||
return;
|
||||
}
|
||||
if (!existing) {
|
||||
detach(tripId, row.id); // gone in AirTrail → treat like a remote delete
|
||||
return;
|
||||
}
|
||||
|
||||
const reservation = getReservationWithJoins(row.id);
|
||||
if (!reservation) return;
|
||||
|
||||
const payload = buildSavePayload(reservation, existing);
|
||||
if (!payload) return;
|
||||
|
||||
try {
|
||||
await saveFlight(creds, payload);
|
||||
// Self-write suppression: re-read the saved flight and store its hash so the
|
||||
// next poll doesn't treat our own write as an inbound change.
|
||||
const saved = await getFlight(creds, Number(row.external_id));
|
||||
if (saved) {
|
||||
db.prepare('UPDATE reservations SET external_hash = ?, external_synced_at = ? WHERE id = ?').run(
|
||||
canonicalHash(saved),
|
||||
new Date().toISOString(),
|
||||
row.id,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logError(`AirTrail push failed for reservation ${row.id}: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,45 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import zlib from 'zlib';
|
||||
import { db } from '../db/database';
|
||||
import { Trip, Place } from '../types';
|
||||
|
||||
// ── Admin-1 GeoJSON cache (sub-national regions) ─────────────────────────
|
||||
// ── Bundled boundary GeoJSON (admin-0 countries + admin-1 regions) ─────────
|
||||
//
|
||||
// Sourced from geoBoundaries (CC BY 4.0), normalized + quantized offline by
|
||||
// scripts/build-atlas-geo.mjs into gzipped FeatureCollections under server/assets.
|
||||
// They are read + decompressed once and cached in memory — no network at runtime.
|
||||
// (Replaces the previous runtime fetch of Natural Earth, which was stale for recent
|
||||
// sub-national reforms and depicts some contested borders in unwanted ways.)
|
||||
//
|
||||
// __dirname is server/dist/services at runtime and server/src/services under vitest;
|
||||
// both resolve ../../assets to server/assets.
|
||||
|
||||
let admin1GeoCache: any = null;
|
||||
let admin1GeoLoading: Promise<any> | null = null;
|
||||
const geoBundleCache = new Map<string, any>();
|
||||
|
||||
async function loadAdmin1Geo(): Promise<any> {
|
||||
if (admin1GeoCache) return admin1GeoCache;
|
||||
if (admin1GeoLoading) return admin1GeoLoading;
|
||||
admin1GeoLoading = fetch(
|
||||
'https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_admin_1_states_provinces.geojson',
|
||||
{ headers: { 'User-Agent': 'TREK Travel Planner' } }
|
||||
).then(r => r.json()).then(geo => {
|
||||
admin1GeoCache = geo;
|
||||
admin1GeoLoading = null;
|
||||
console.log(`[Atlas] Cached admin-1 GeoJSON: ${geo.features?.length || 0} features`);
|
||||
return geo;
|
||||
}).catch(err => {
|
||||
admin1GeoLoading = null;
|
||||
console.error('[Atlas] Failed to load admin-1 GeoJSON:', err);
|
||||
return null;
|
||||
});
|
||||
return admin1GeoLoading;
|
||||
function loadGeoBundle(name: 'admin0' | 'admin1'): any {
|
||||
const cached = geoBundleCache.get(name);
|
||||
if (cached) return cached;
|
||||
const file = path.join(__dirname, '..', '..', 'assets', 'atlas', `${name}.geojson.gz`);
|
||||
if (!fs.existsSync(file)) {
|
||||
console.warn(`[Atlas] ${name}.geojson.gz missing — run \`node scripts/build-atlas-geo.mjs\``);
|
||||
const empty = { type: 'FeatureCollection', features: [] };
|
||||
geoBundleCache.set(name, empty);
|
||||
return empty;
|
||||
}
|
||||
const geo = JSON.parse(zlib.gunzipSync(fs.readFileSync(file)).toString('utf8'));
|
||||
geoBundleCache.set(name, geo);
|
||||
console.log(`[Atlas] Loaded ${name} GeoJSON: ${geo.features?.length || 0} features`);
|
||||
return geo;
|
||||
}
|
||||
|
||||
/** Full admin-0 country-border FeatureCollection (for the client map's country layer). */
|
||||
export function getCountryGeo(): any {
|
||||
return loadGeoBundle('admin0');
|
||||
}
|
||||
|
||||
export async function getRegionGeo(countryCodes: string[]): Promise<any> {
|
||||
const geo = await loadAdmin1Geo();
|
||||
const geo = loadGeoBundle('admin1');
|
||||
if (!geo) return { type: 'FeatureCollection', features: [] };
|
||||
const codes = new Set(countryCodes.map(c => c.toUpperCase()));
|
||||
const features = geo.features.filter((f: any) => codes.has(f.properties?.iso_a2?.toUpperCase()));
|
||||
@@ -534,13 +547,18 @@ const geocodingInFlight = new Set<number>();
|
||||
|
||||
const regionCache = new Map<string, RegionInfo | null>();
|
||||
|
||||
async function reverseGeocodeRegion(lat: number, lng: number): Promise<RegionInfo | null> {
|
||||
const key = roundKey(lat, lng);
|
||||
if (regionCache.has(key)) return regionCache.get(key)!;
|
||||
// A zoom-8 reverse geocode of a GB place only resolves to the constituent country
|
||||
// (England/Scotland/Wales/Northern Ireland). Natural Earth's admin-1 polygons for GB
|
||||
// are counties and boroughs, so those four codes match no polygon and never highlight.
|
||||
const GB_CONSTITUENT_CODES = new Set(['GB-ENG', 'GB-SCT', 'GB-WLS', 'GB-NIR']);
|
||||
|
||||
// Returns the OSM address object, {} for an "ok but empty" response (so it is cached as
|
||||
// a definitive miss), or null for a transient failure (so it is retried next time).
|
||||
async function fetchNominatimAddress(lat: number, lng: number, zoom: number): Promise<Record<string, string> | null> {
|
||||
await throttleNominatim();
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&zoom=8&accept-language=en`,
|
||||
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&zoom=${zoom}&accept-language=en`,
|
||||
{
|
||||
headers: { 'User-Agent': 'TREK Travel Planner (https://github.com/mauriceboe/TREK)' },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
@@ -548,27 +566,52 @@ async function reverseGeocodeRegion(lat: number, lng: number): Promise<RegionInf
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json() as { address?: Record<string, string> };
|
||||
const countryCode = data.address?.country_code?.toUpperCase() || null;
|
||||
// Try finest ISO level first (lvl6 = departments/provinces), then lvl5, then lvl4 (states/regions)
|
||||
let regionCode = data.address?.['ISO3166-2-lvl6'] || data.address?.['ISO3166-2-lvl5'] || data.address?.['ISO3166-2-lvl4'] || null;
|
||||
// Normalize: FR-75C → FR-75 (strip trailing letter suffixes for GeoJSON compatibility)
|
||||
if (regionCode && /^[A-Z]{2}-\d+[A-Z]$/i.test(regionCode)) {
|
||||
regionCode = regionCode.replace(/[A-Z]$/i, '');
|
||||
}
|
||||
const regionName = data.address?.state || data.address?.province || data.address?.region || data.address?.county || data.address?.city || null;
|
||||
if (!countryCode || !regionName) { regionCache.set(key, null); return null; }
|
||||
const info: RegionInfo = {
|
||||
country_code: countryCode,
|
||||
region_code: regionCode || `${countryCode}-${regionName.substring(0, 3).toUpperCase()}`,
|
||||
region_name: regionName,
|
||||
};
|
||||
regionCache.set(key, info);
|
||||
return info;
|
||||
return data.address ?? {};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildRegionInfo(address: Record<string, string>, preferFinest: boolean): RegionInfo | null {
|
||||
const countryCode = address.country_code?.toUpperCase() || null;
|
||||
// Coarse path (almost every country) lands on the admin-1 level that matches Natural
|
||||
// Earth directly; the finest path is used only to rescue codes that are too broad.
|
||||
let regionCode = preferFinest
|
||||
? (address['ISO3166-2-lvl8'] || address['ISO3166-2-lvl7'] || address['ISO3166-2-lvl6'] || address['ISO3166-2-lvl5'] || null)
|
||||
: (address['ISO3166-2-lvl6'] || address['ISO3166-2-lvl5'] || address['ISO3166-2-lvl4'] || null);
|
||||
// Normalize: FR-75C → FR-75 (strip trailing letter suffixes for GeoJSON compatibility)
|
||||
if (regionCode && /^[A-Z]{2}-\d+[A-Z]$/i.test(regionCode)) {
|
||||
regionCode = regionCode.replace(/[A-Z]$/i, '');
|
||||
}
|
||||
const regionName = preferFinest
|
||||
? (address.city || address.county || address.state_district || address.borough || address.state || address.province || address.region || null)
|
||||
: (address.state || address.province || address.region || address.county || address.city || null);
|
||||
if (!countryCode || !regionName) return null;
|
||||
return {
|
||||
country_code: countryCode,
|
||||
region_code: regionCode || `${countryCode}-${regionName.substring(0, 3).toUpperCase()}`,
|
||||
region_name: regionName,
|
||||
};
|
||||
}
|
||||
|
||||
async function reverseGeocodeRegion(lat: number, lng: number): Promise<RegionInfo | null> {
|
||||
const key = roundKey(lat, lng);
|
||||
if (regionCache.has(key)) return regionCache.get(key)!;
|
||||
const address = await fetchNominatimAddress(lat, lng, 8);
|
||||
if (!address) return null; // transient failure — leave uncached so a later call retries
|
||||
let info = buildRegionInfo(address, false);
|
||||
// GB constituent-country codes map to no admin-1 polygon, so re-resolve them at a finer
|
||||
// zoom where Nominatim exposes the county/borough code (GB-LND, GB-MAN, GB-CON, …) that
|
||||
// the polygons actually carry.
|
||||
if (info && info.country_code === 'GB' && GB_CONSTITUENT_CODES.has(info.region_code)) {
|
||||
const finerAddress = await fetchNominatimAddress(lat, lng, 10);
|
||||
const finer = finerAddress ? buildRegionInfo(finerAddress, true) : null;
|
||||
if (finer && !GB_CONSTITUENT_CODES.has(finer.region_code)) info = finer;
|
||||
}
|
||||
regionCache.set(key, info);
|
||||
return info;
|
||||
}
|
||||
|
||||
export async function getVisitedRegions(userId: number): Promise<{ regions: Record<string, { code: string; name: string; placeCount: number }[]> }> {
|
||||
const trips = getUserTrips(userId);
|
||||
const tripIds = trips.map(t => t.id);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { authenticator } from 'otplib';
|
||||
import QRCode from 'qrcode';
|
||||
import { randomBytes, createHash } from 'crypto';
|
||||
import { db } from '../db/database';
|
||||
import { JWT_SECRET } from '../config';
|
||||
import { JWT_SECRET, SESSION_DURATION_SECONDS, SESSION_DURATION_REMEMBER_SECONDS } from '../config';
|
||||
import { validatePassword } from './passwordPolicy';
|
||||
import { encryptMfaSecret, decryptMfaSecret } from './mfaCrypto';
|
||||
import { getAllPermissions } from './permissions';
|
||||
@@ -16,9 +16,14 @@ import { createEphemeralToken } from './ephemeralTokens';
|
||||
import { revokeUserSessions } from '../mcp';
|
||||
import { startTripReminders } from '../scheduler';
|
||||
import { deleteUserCompletely } from './userCleanupService';
|
||||
import { getFlightDistanceKm } from './distanceService';
|
||||
import { verifyJwtAndLoadUser } from '../middleware/auth';
|
||||
import { User } from '../types';
|
||||
import { DEMO_EMAIL_PRIMARY, isDemoEmail } from './demo';
|
||||
import { avatarUrl } from './avatarUrl';
|
||||
import { isPasskeyConfigured } from './webauthnConfig';
|
||||
|
||||
export { avatarUrl };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
@@ -26,10 +31,16 @@ import { DEMO_EMAIL_PRIMARY, isDemoEmail } from './demo';
|
||||
|
||||
authenticator.options = { window: 1 };
|
||||
|
||||
// bcrypt cost factor for user passwords. Shared by register/changePassword/
|
||||
// resetPassword and the dummy-hash timing equaliser below — must stay in sync.
|
||||
const BCRYPT_COST = 12;
|
||||
|
||||
// Shape check for email input on register and profile update.
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
// Pre-computed bcrypt hash to equalise timing of "unknown email" and
|
||||
// "OIDC-only account" branches with the real verification path (CWE-208).
|
||||
// Cost factor 12 matches register/changePassword/resetPassword — must stay in sync.
|
||||
const DUMMY_PASSWORD_HASH = bcrypt.hashSync('__trek_no_such_user__', 12);
|
||||
const DUMMY_PASSWORD_HASH = bcrypt.hashSync('__trek_no_such_user__', BCRYPT_COST);
|
||||
|
||||
const MFA_SETUP_TTL_MS = 15 * 60 * 1000;
|
||||
const mfaSetupPending = new Map<number, { secret: string; exp: number }>();
|
||||
@@ -41,6 +52,7 @@ const ADMIN_SETTINGS_KEYS = [
|
||||
'notification_channels', 'admin_webhook_url', 'admin_ntfy_server', 'admin_ntfy_topic', 'admin_ntfy_token',
|
||||
'notify_trip_reminder',
|
||||
'password_login', 'password_registration', 'oidc_login', 'oidc_registration',
|
||||
'passkey_login', 'webauthn_rp_id', 'webauthn_origins',
|
||||
];
|
||||
|
||||
const avatarDir = path.join(__dirname, '../../uploads/avatars');
|
||||
@@ -113,19 +125,22 @@ export function mask_stored_api_key(key: string | null | undefined): string | nu
|
||||
return maskKey(plain);
|
||||
}
|
||||
|
||||
export function avatarUrl(user: { avatar?: string | null }): string | null {
|
||||
return user.avatar ? `/uploads/avatars/${user.avatar}` : null;
|
||||
}
|
||||
|
||||
export function resolveAuthToggles(): {
|
||||
password_login: boolean;
|
||||
password_registration: boolean;
|
||||
oidc_login: boolean;
|
||||
oidc_registration: boolean;
|
||||
passkey_login: boolean;
|
||||
} {
|
||||
const get = (key: string) =>
|
||||
(db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value ?? null;
|
||||
|
||||
// Passkey login is independent of the password/OIDC "new keys" probe, so it
|
||||
// must be resolved OUTSIDE the branch below — otherwise on a fresh install
|
||||
// that never touched the password/OIDC toggles it would silently read false
|
||||
// even after an admin enabled it. Default OFF (opt-in).
|
||||
const passkey_login = get('passkey_login') === 'true';
|
||||
|
||||
const hasNewKeys = ['password_login', 'password_registration', 'oidc_login', 'oidc_registration']
|
||||
.some(k => get(k) !== null);
|
||||
|
||||
@@ -135,6 +150,7 @@ export function resolveAuthToggles(): {
|
||||
password_registration: get('password_registration') !== 'false',
|
||||
oidc_login: get('oidc_login') !== 'false',
|
||||
oidc_registration: get('oidc_registration') !== 'false',
|
||||
passkey_login,
|
||||
};
|
||||
if (process.env.OIDC_ONLY?.toLowerCase() === 'true') {
|
||||
result.password_login = false;
|
||||
@@ -157,6 +173,7 @@ export function resolveAuthToggles(): {
|
||||
password_registration: !oidcOnly && allowReg,
|
||||
oidc_login: true,
|
||||
oidc_registration: allowReg,
|
||||
passkey_login,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -164,14 +181,17 @@ export function isOidcOnlyMode(): boolean {
|
||||
return !resolveAuthToggles().password_login;
|
||||
}
|
||||
|
||||
export function generateToken(user: { id: number | bigint; password_version?: number }) {
|
||||
export function generateToken(user: { id: number | bigint; password_version?: number }, rememberMe = false) {
|
||||
const pv = typeof user.password_version === 'number'
|
||||
? user.password_version
|
||||
: ((db.prepare('SELECT password_version FROM users WHERE id = ?').get(user.id) as { password_version?: number } | undefined)?.password_version ?? 0);
|
||||
// "Remember me" extends the JWT lifetime to match the persistent cookie maxAge;
|
||||
// the cookie service decides session-vs-persistent off the same flag.
|
||||
const expiresIn = rememberMe ? SESSION_DURATION_REMEMBER_SECONDS : SESSION_DURATION_SECONDS;
|
||||
return jwt.sign(
|
||||
{ id: user.id, pv },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '24h', algorithm: 'HS256' }
|
||||
{ expiresIn, algorithm: 'HS256' }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -293,6 +313,12 @@ export function getAppConfig(authenticatedUser: { id: number } | null) {
|
||||
password_registration: isDemo ? false : toggles.password_registration,
|
||||
oidc_login: toggles.oidc_login,
|
||||
oidc_registration: isDemo ? false : toggles.oidc_registration,
|
||||
// Passkey login: the instance toggle + whether a usable RP ID resolves for
|
||||
// this deployment. The login page shows the passkey button only when both
|
||||
// are true. `passkey_configured` stays a pure boolean — it never leaks the
|
||||
// resolved RP ID / origin / APP_URL on this unauthenticated endpoint.
|
||||
passkey_login: toggles.passkey_login,
|
||||
passkey_configured: isPasskeyConfigured(),
|
||||
env_override_oidc_only: process.env.OIDC_ONLY === 'true',
|
||||
has_users: userCount > 0,
|
||||
setup_complete: setupComplete,
|
||||
@@ -376,8 +402,7 @@ export function registerUser(body: {
|
||||
const pwCheck = validatePassword(password);
|
||||
if (!pwCheck.ok) return { error: pwCheck.reason, status: 400 };
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
if (!EMAIL_REGEX.test(email)) {
|
||||
return { error: 'Invalid email format', status: 400 };
|
||||
}
|
||||
|
||||
@@ -386,7 +411,7 @@ export function registerUser(body: {
|
||||
return { error: 'Registration failed. Please try different credentials.', status: 409 };
|
||||
}
|
||||
|
||||
const password_hash = bcrypt.hashSync(password, 12);
|
||||
const password_hash = bcrypt.hashSync(password, BCRYPT_COST);
|
||||
const isFirstUser = userCount === 0;
|
||||
const role = isFirstUser ? 'admin' : 'user';
|
||||
|
||||
@@ -421,6 +446,7 @@ export function registerUser(body: {
|
||||
export function loginUser(body: {
|
||||
email?: string;
|
||||
password?: string;
|
||||
remember_me?: boolean;
|
||||
}): {
|
||||
error?: string;
|
||||
status?: number;
|
||||
@@ -428,6 +454,7 @@ export function loginUser(body: {
|
||||
user?: Record<string, unknown>;
|
||||
mfa_required?: boolean;
|
||||
mfa_token?: string;
|
||||
remember?: boolean;
|
||||
auditUserId?: number | null;
|
||||
auditAction?: string;
|
||||
auditDetails?: Record<string, unknown>;
|
||||
@@ -436,7 +463,8 @@ export function loginUser(body: {
|
||||
return { error: 'Password authentication is disabled. Please sign in with SSO.', status: 403 };
|
||||
}
|
||||
|
||||
const { email, password } = body;
|
||||
const { email, password, remember_me } = body;
|
||||
const remember = remember_me === true;
|
||||
if (!email || !password) {
|
||||
return { error: 'Email and password are required', status: 400 };
|
||||
}
|
||||
@@ -468,8 +496,9 @@ export function loginUser(body: {
|
||||
}
|
||||
|
||||
if (user.mfa_enabled === 1 || user.mfa_enabled === true) {
|
||||
const pv = (user as User & { password_version?: number }).password_version ?? 0;
|
||||
const mfa_token = jwt.sign(
|
||||
{ id: Number(user.id), purpose: 'mfa_login' },
|
||||
{ id: Number(user.id), purpose: 'mfa_login', pv },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '5m', algorithm: 'HS256' }
|
||||
);
|
||||
@@ -477,12 +506,13 @@ export function loginUser(body: {
|
||||
}
|
||||
|
||||
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?').run(user.id);
|
||||
const token = generateToken(user);
|
||||
const token = generateToken(user, remember);
|
||||
const userSafe = stripUserForClient(user) as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
token,
|
||||
user: { ...userSafe, avatar_url: avatarUrl(user) },
|
||||
remember,
|
||||
auditUserId: Number(user.id),
|
||||
auditAction: 'user.login',
|
||||
auditDetails: { email },
|
||||
@@ -493,13 +523,15 @@ export function loginUser(body: {
|
||||
// Session
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getCurrentUser(userId: number) {
|
||||
export function getCurrentUser(
|
||||
userId: number
|
||||
): (Record<string, unknown> & Pick<User, 'id' | 'username' | 'email' | 'role'> & { avatar_url: string }) | null {
|
||||
const user = db.prepare(
|
||||
'SELECT id, username, email, role, avatar, oidc_issuer, created_at, mfa_enabled, must_change_password FROM users WHERE id = ?'
|
||||
).get(userId) as User | undefined;
|
||||
if (!user) return null;
|
||||
const base = stripUserForClient(user as User) as Record<string, unknown>;
|
||||
return { ...base, avatar_url: avatarUrl(user) };
|
||||
return { ...base, id: user.id, username: user.username, email: user.email, role: user.role, avatar_url: avatarUrl(user) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -510,7 +542,7 @@ export function changePassword(
|
||||
userId: number,
|
||||
userEmail: string,
|
||||
body: { current_password?: string; new_password?: string }
|
||||
): { error?: string; status?: number; success?: boolean } {
|
||||
): { error?: string; status?: number; success?: boolean; token?: string } {
|
||||
if (isOidcOnlyMode()) {
|
||||
return { error: 'Password authentication is disabled.', status: 403 };
|
||||
}
|
||||
@@ -525,14 +557,32 @@ export function changePassword(
|
||||
const pwCheck = validatePassword(new_password);
|
||||
if (!pwCheck.ok) return { error: pwCheck.reason, status: 400 };
|
||||
|
||||
const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(userId) as { password_hash: string } | undefined;
|
||||
const user = db.prepare('SELECT password_hash, password_version FROM users WHERE id = ?').get(userId) as { password_hash: string; password_version?: number } | undefined;
|
||||
if (!user || !bcrypt.compareSync(current_password, user.password_hash)) {
|
||||
return { error: 'Current password is incorrect', status: 401 };
|
||||
}
|
||||
|
||||
const hash = bcrypt.hashSync(new_password, 12);
|
||||
db.prepare('UPDATE users SET password_hash = ?, must_change_password = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(hash, userId);
|
||||
return { success: true };
|
||||
const hash = bcrypt.hashSync(new_password, BCRYPT_COST);
|
||||
const newPv = (user.password_version ?? 0) + 1;
|
||||
|
||||
db.transaction(() => {
|
||||
db.prepare('UPDATE users SET password_hash = ?, must_change_password = 0, password_version = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(hash, newPv, userId);
|
||||
// A password change rotates the user's sessions: bumping password_version
|
||||
// invalidates existing JWT cookie sessions, and the separate MCP static
|
||||
// token and OAuth bearer-token stores are pruned to match (same set the
|
||||
// password-reset path already revokes).
|
||||
db.prepare('DELETE FROM mcp_tokens WHERE user_id = ?').run(userId);
|
||||
try {
|
||||
db.prepare("UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE user_id = ? AND revoked_at IS NULL").run(userId);
|
||||
} catch { /* oauth_tokens table may not exist in very old installs */ }
|
||||
})();
|
||||
|
||||
try { revokeUserSessions?.(userId); } catch { /* best-effort */ }
|
||||
|
||||
// Re-issue a session bound to the new password_version so the current device
|
||||
// stays logged in while other existing sessions are rotated out by the pv gate.
|
||||
const token = generateToken({ id: userId, password_version: newPv });
|
||||
return { success: true, token };
|
||||
}
|
||||
|
||||
export function deleteAccount(userId: number, userEmail: string, userRole: string): { error?: string; status?: number; success?: boolean } {
|
||||
@@ -605,8 +655,7 @@ export function updateSettings(
|
||||
|
||||
if (email !== undefined) {
|
||||
const trimmed = email.trim();
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!trimmed || !emailRegex.test(trimmed)) {
|
||||
if (!trimmed || !EMAIL_REGEX.test(trimmed)) {
|
||||
return { error: 'Invalid email format', status: 400 };
|
||||
}
|
||||
const conflict = db.prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?) AND id != ?').get(trimmed, userId);
|
||||
@@ -806,9 +855,12 @@ export function updateAppSettings(
|
||||
const { require_mfa } = body;
|
||||
if (require_mfa === true || require_mfa === 'true') {
|
||||
const adminMfa = db.prepare('SELECT mfa_enabled FROM users WHERE id = ?').get(userId) as { mfa_enabled: number } | undefined;
|
||||
if (!(adminMfa?.mfa_enabled === 1)) {
|
||||
// A user-verified passkey satisfies the MFA policy, so an admin who secured
|
||||
// their own account with a passkey may enable it too (not only TOTP).
|
||||
const adminHasPasskey = !!db.prepare('SELECT 1 FROM webauthn_credentials WHERE user_id = ? LIMIT 1').get(userId);
|
||||
if (!(adminMfa?.mfa_enabled === 1) && !adminHasPasskey) {
|
||||
return {
|
||||
error: 'Enable two-factor authentication on your own account before requiring it for all users.',
|
||||
error: 'Secure your own account with two-factor authentication or a passkey before requiring it for all users.',
|
||||
status: 400,
|
||||
};
|
||||
}
|
||||
@@ -892,7 +944,6 @@ export function getTravelStats(userId: number) {
|
||||
WHERE (t.user_id = ? OR tm.user_id = ?) AND t.is_archived = 0
|
||||
`).get(userId, userId) as { trips: number; days: number } | undefined;
|
||||
|
||||
const countries = new Set<string>();
|
||||
const cities = new Set<string>();
|
||||
const coords: { lat: number; lng: number }[] = [];
|
||||
|
||||
@@ -900,21 +951,37 @@ export function getTravelStats(userId: number) {
|
||||
if (p.lat && p.lng) coords.push({ lat: p.lat, lng: p.lng });
|
||||
if (p.address) {
|
||||
const parts = p.address.split(',').map(s => s.trim().replace(/\d{3,}/g, '').trim());
|
||||
for (const part of parts) {
|
||||
if (KNOWN_COUNTRIES.has(part)) { countries.add(part); break; }
|
||||
}
|
||||
const cityPart = parts.find(s => !KNOWN_COUNTRIES.has(s) && /^[A-Za-z\u00C0-\u00FF\s-]{2,}$/.test(s));
|
||||
if (cityPart) cities.add(cityPart);
|
||||
}
|
||||
});
|
||||
|
||||
// Visited countries \u2014 same source the Atlas page uses: ISO-2 codes from
|
||||
// auto-resolved place regions plus countries the user marked manually.
|
||||
const countryCodes = new Set<string>();
|
||||
const manualCountries = db.prepare(
|
||||
'SELECT country_code FROM visited_countries WHERE user_id = ?'
|
||||
).all(userId) as { country_code: string }[];
|
||||
manualCountries.forEach(m => { if (m.country_code) countryCodes.add(m.country_code.toUpperCase()); });
|
||||
|
||||
const placeRegionCodes = db.prepare(`
|
||||
SELECT DISTINCT pr.country_code
|
||||
FROM place_regions pr
|
||||
JOIN places p ON p.id = pr.place_id
|
||||
JOIN trips t ON p.trip_id = t.id
|
||||
LEFT JOIN trip_members tm ON t.id = tm.trip_id
|
||||
WHERE (t.user_id = ? OR tm.user_id = ?) AND pr.country_code IS NOT NULL
|
||||
`).all(userId, userId) as { country_code: string }[];
|
||||
placeRegionCodes.forEach(r => { if (r.country_code) countryCodes.add(r.country_code.toUpperCase()); });
|
||||
|
||||
return {
|
||||
countries: [...countries],
|
||||
countries: [...countryCodes],
|
||||
cities: [...cities],
|
||||
coords,
|
||||
totalTrips: tripStats?.trips || 0,
|
||||
totalDays: tripStats?.days || 0,
|
||||
totalPlaces: places.length,
|
||||
totalDistanceKm: getFlightDistanceKm(userId),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1006,14 +1073,17 @@ export function disableMfa(
|
||||
export function verifyMfaLogin(body: {
|
||||
mfa_token?: string;
|
||||
code?: string;
|
||||
remember_me?: boolean;
|
||||
}): {
|
||||
error?: string;
|
||||
status?: number;
|
||||
token?: string;
|
||||
user?: Record<string, unknown>;
|
||||
remember?: boolean;
|
||||
auditUserId?: number;
|
||||
} {
|
||||
const { mfa_token, code } = body;
|
||||
const { mfa_token, code, remember_me } = body;
|
||||
const remember = remember_me === true;
|
||||
if (!mfa_token || !code) {
|
||||
return { error: 'Verification token and code are required', status: 400 };
|
||||
}
|
||||
@@ -1044,11 +1114,12 @@ export function verifyMfaLogin(body: {
|
||||
);
|
||||
}
|
||||
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?').run(user.id);
|
||||
const sessionToken = generateToken(user);
|
||||
const sessionToken = generateToken(user, remember);
|
||||
const userSafe = stripUserForClient(user) as Record<string, unknown>;
|
||||
return {
|
||||
token: sessionToken,
|
||||
user: { ...userSafe, avatar_url: avatarUrl(user) },
|
||||
remember,
|
||||
auditUserId: Number(user.id),
|
||||
};
|
||||
} catch {
|
||||
@@ -1134,9 +1205,13 @@ export function requestPasswordReset(rawEmail: string, createdIp: string | null)
|
||||
if (!user) {
|
||||
return { tokenForDelivery: null, userId: null, userEmail: null, reason: 'no_user' };
|
||||
}
|
||||
// OIDC-only account (no local password) — we can't reset what isn't there.
|
||||
// SSO-linked account — refuse a reset. OIDC users are created with a random
|
||||
// bcrypt hash (so password_hash is never empty), which is why we must key off
|
||||
// oidc_sub rather than a missing hash. Letting the reset proceed would set a
|
||||
// local password and revoke session/credential state, which breaks the SSO
|
||||
// login; admins (or the user, with their current password) can still set one.
|
||||
// The client still gets the generic "if that email exists…" response.
|
||||
if (!user.password_hash && user.oidc_sub) {
|
||||
if (user.oidc_sub) {
|
||||
return { tokenForDelivery: null, userId: user.id, userEmail: user.email, reason: 'oidc_only' };
|
||||
}
|
||||
|
||||
@@ -1231,7 +1306,7 @@ export function resetPassword(body: {
|
||||
}
|
||||
}
|
||||
|
||||
const newHash = bcrypt.hashSync(new_password, 12);
|
||||
const newHash = bcrypt.hashSync(new_password, BCRYPT_COST);
|
||||
const newPv = (user.password_version ?? 0) + 1;
|
||||
|
||||
db.transaction(() => {
|
||||
@@ -1314,7 +1389,10 @@ export function deleteMcpToken(userId: number, tokenId: string): { error?: strin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createWsToken(userId: number): { error?: string; status?: number; token?: string } {
|
||||
const token = createEphemeralToken(userId, 'ws');
|
||||
// Bind the ws-token to the user's current password_version so a token minted
|
||||
// before a password reset is rejected on connect (defence-in-depth session gate).
|
||||
const pv = (db.prepare('SELECT password_version FROM users WHERE id = ?').get(userId) as { password_version?: number } | undefined)?.password_version ?? 0;
|
||||
const token = createEphemeralToken(userId, 'ws', { pv });
|
||||
if (!token) return { error: 'Service unavailable', status: 503 };
|
||||
return { token };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function avatarUrl(user: { avatar?: string | null }): string | null {
|
||||
return user.avatar ? `/uploads/avatars/${user.avatar}` : null;
|
||||
}
|
||||
@@ -15,7 +15,10 @@ const dataDir = path.join(__dirname, '../../data');
|
||||
const backupsDir = path.join(dataDir, 'backups');
|
||||
const uploadsDir = path.join(__dirname, '../../uploads');
|
||||
|
||||
export const MAX_BACKUP_UPLOAD_SIZE = 500 * 1024 * 1024; // 500 MB
|
||||
export const MAX_BACKUP_UPLOAD_SIZE = 500 * 1024 * 1024; // 500 MB compressed
|
||||
// Upper bound on the TOTAL decompressed size of a restore archive (the upload
|
||||
// limit only caps the compressed bytes). Generous enough for any real backup.
|
||||
export const MAX_BACKUP_DECOMPRESSED_SIZE = 5 * 1024 * 1024 * 1024; // 5 GB
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@@ -152,8 +155,26 @@ export async function createBackup(): Promise<BackupInfo> {
|
||||
archive.file(dbPath, { name: 'travel.db' });
|
||||
}
|
||||
|
||||
// Bundle the at-rest encryption key so the backup is self-contained: the
|
||||
// DB stores secrets (API keys, MFA, SMTP/OIDC) encrypted with this key, so
|
||||
// a restore onto a different install would otherwise be unable to decrypt
|
||||
// them. NOTE: this makes the backup file as sensitive as the key itself —
|
||||
// store/transfer it securely. Skipped when ENCRYPTION_KEY is provided via
|
||||
// env, since in that case the file is not the source of truth.
|
||||
const encKeyPath = path.join(dataDir, '.encryption_key');
|
||||
if (!process.env.ENCRYPTION_KEY && fs.existsSync(encKeyPath)) {
|
||||
archive.file(encKeyPath, { name: '.encryption_key' });
|
||||
}
|
||||
|
||||
if (fs.existsSync(uploadsDir)) {
|
||||
archive.directory(uploadsDir, 'uploads');
|
||||
// Exclude the place-photo and trek-memory caches: both are re-derivable
|
||||
// (re-fetched on demand, keyed on stable ids) and would otherwise dominate
|
||||
// backup size. Restores self-heal — the cache dirs are recreated at startup.
|
||||
archive.glob(
|
||||
'**/*',
|
||||
{ cwd: uploadsDir, ignore: ['photos/google/**', 'photos/trek/**'], nodir: true, dot: true },
|
||||
{ prefix: 'uploads' },
|
||||
);
|
||||
}
|
||||
|
||||
archive.finalize();
|
||||
@@ -185,7 +206,16 @@ export interface RestoreResult {
|
||||
|
||||
export async function restoreFromZip(zipPath: string): Promise<RestoreResult> {
|
||||
const extractDir = path.join(dataDir, `restore-${Date.now()}`);
|
||||
let reinitFailed: unknown = null;
|
||||
try {
|
||||
// Check the declared uncompressed size from the central directory and bail
|
||||
// if it exceeds the cap, before extracting anything.
|
||||
const directory = await unzipper.Open.file(zipPath);
|
||||
const claimedSize = directory.files.reduce((sum, f) => sum + (f.uncompressedSize || 0), 0);
|
||||
if (claimedSize > MAX_BACKUP_DECOMPRESSED_SIZE) {
|
||||
return { success: false, error: 'Backup exceeds the maximum decompressed size.', status: 400 };
|
||||
}
|
||||
|
||||
await fs.createReadStream(zipPath)
|
||||
.pipe(unzipper.Extract({ path: extractDir }))
|
||||
.promise();
|
||||
@@ -233,6 +263,16 @@ export async function restoreFromZip(zipPath: string): Promise<RestoreResult> {
|
||||
}
|
||||
fs.copyFileSync(extractedDb, dbDest);
|
||||
|
||||
// Restore the bundled at-rest encryption key (if the archive carries one)
|
||||
// so the restored DB's encrypted secrets can be decrypted. Only the file
|
||||
// is swapped here; the in-memory key was read at startup, so a restart is
|
||||
// required for it to take effect (and an explicit ENCRYPTION_KEY env var
|
||||
// still overrides the file).
|
||||
const extractedEncKey = path.join(extractDir, '.encryption_key');
|
||||
if (fs.existsSync(extractedEncKey)) {
|
||||
fs.copyFileSync(extractedEncKey, path.join(dataDir, '.encryption_key'));
|
||||
}
|
||||
|
||||
const extractedUploads = path.join(extractDir, 'uploads');
|
||||
if (fs.existsSync(extractedUploads)) {
|
||||
for (const sub of fs.readdirSync(uploadsDir)) {
|
||||
@@ -243,10 +283,24 @@ export async function restoreFromZip(zipPath: string): Promise<RestoreResult> {
|
||||
}
|
||||
}
|
||||
}
|
||||
fs.cpSync(extractedUploads, uploadsDir, { recursive: true, force: true });
|
||||
// Copy into the real directory behind uploadsDir. In Docker, uploadsDir
|
||||
// (/app/server/uploads) is a symlink to the mounted /app/uploads volume;
|
||||
// cpSync(dereference:false) would otherwise try to overwrite the symlink
|
||||
// node with a directory and throw ERR_FS_CP_DIR_TO_NON_DIR. realpathSync
|
||||
// is a no-op when uploadsDir is a plain directory (dev/non-Docker).
|
||||
fs.cpSync(extractedUploads, fs.realpathSync(uploadsDir), { recursive: true, force: true });
|
||||
}
|
||||
} finally {
|
||||
reinitialize();
|
||||
// Reopening the DB must always run (even if the copy above threw) so the
|
||||
// process is never left without a connection. Capture a reopen failure
|
||||
// instead of letting it propagate as a generic error — a backup whose
|
||||
// files already landed on disk but whose connection failed to reopen
|
||||
// needs to be reported as "restart required", not swallowed.
|
||||
try {
|
||||
reinitialize();
|
||||
} catch (reinitErr) {
|
||||
reinitFailed = reinitErr;
|
||||
}
|
||||
// The restored DB has different permission-override rows from
|
||||
// the pre-restore DB, but our process-local permissions cache
|
||||
// still holds the pre-restore state. Any request using a cached
|
||||
@@ -256,6 +310,10 @@ export async function restoreFromZip(zipPath: string): Promise<RestoreResult> {
|
||||
}
|
||||
|
||||
fs.rmSync(extractDir, { recursive: true, force: true });
|
||||
if (reinitFailed) {
|
||||
console.error('Restore: database reopen failed after file swap:', reinitFailed);
|
||||
return { success: false, error: 'Backup files were restored but the database connection could not be reopened. Restart the server to finish the restore.', status: 500 };
|
||||
}
|
||||
return { success: true };
|
||||
} catch (err: unknown) {
|
||||
console.error('Restore error:', err);
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import { db, canAccessTrip } from '../db/database';
|
||||
import { BudgetItem, BudgetItemMember } from '../types';
|
||||
import { db } from '../db/database';
|
||||
import { BudgetItem, BudgetItemMember, BudgetItemPayer } from '../types';
|
||||
import { avatarUrl } from './avatarUrl';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function avatarUrl(user: { avatar?: string | null }): string | null {
|
||||
return user.avatar ? `/uploads/avatars/${user.avatar}` : null;
|
||||
}
|
||||
|
||||
export function verifyTripAccess(tripId: string | number, userId: number) {
|
||||
return canAccessTrip(tripId, userId);
|
||||
}
|
||||
export { avatarUrl };
|
||||
export { verifyTripAccess } from './tripAccess';
|
||||
|
||||
function loadItemMembers(itemId: number | string) {
|
||||
const rows = db.prepare(`
|
||||
@@ -23,6 +19,30 @@ function loadItemMembers(itemId: number | string) {
|
||||
return rows.map(m => ({ ...m, avatar_url: avatarUrl(m) }));
|
||||
}
|
||||
|
||||
function loadItemPayers(itemId: number | string) {
|
||||
const rows = db.prepare(`
|
||||
SELECT bp.user_id, bp.amount, u.username, u.avatar
|
||||
FROM budget_item_payers bp
|
||||
JOIN users u ON bp.user_id = u.id
|
||||
WHERE bp.budget_item_id = ?
|
||||
`).all(itemId) as BudgetItemPayer[];
|
||||
return rows.map(p => ({ ...p, avatar_url: avatarUrl(p) }));
|
||||
}
|
||||
|
||||
/** Replace the payer rows of an item and keep total_price = sum of payer amounts. */
|
||||
function writeItemPayers(itemId: number | string, payers: { user_id: number; amount: number }[]) {
|
||||
db.prepare('DELETE FROM budget_item_payers WHERE budget_item_id = ?').run(itemId);
|
||||
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_payers (budget_item_id, user_id, amount) VALUES (?, ?, ?)');
|
||||
let total = 0;
|
||||
for (const p of payers) {
|
||||
if (!(p.amount > 0)) continue;
|
||||
insert.run(itemId, p.user_id, p.amount);
|
||||
total += p.amount;
|
||||
}
|
||||
db.prepare('UPDATE budget_items SET total_price = ? WHERE id = ?').run(total, itemId);
|
||||
return total;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -54,20 +74,45 @@ export function listBudgetItems(tripId: string | number) {
|
||||
}
|
||||
}
|
||||
|
||||
items.forEach(item => { item.members = membersByItem[item.id] || []; });
|
||||
const payersByItem: Record<number, (BudgetItemPayer & { avatar_url: string | null })[]> = {};
|
||||
if (itemIds.length > 0) {
|
||||
const allPayers = db.prepare(`
|
||||
SELECT bp.budget_item_id, bp.user_id, bp.amount, u.username, u.avatar
|
||||
FROM budget_item_payers bp
|
||||
JOIN users u ON bp.user_id = u.id
|
||||
WHERE bp.budget_item_id IN (${itemIds.map(() => '?').join(',')})
|
||||
`).all(...itemIds) as (BudgetItemPayer & { budget_item_id: number })[];
|
||||
|
||||
for (const p of allPayers) {
|
||||
if (!payersByItem[p.budget_item_id]) payersByItem[p.budget_item_id] = [];
|
||||
payersByItem[p.budget_item_id].push({
|
||||
user_id: p.user_id, amount: p.amount, username: p.username, avatar_url: avatarUrl(p),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
items.forEach(item => {
|
||||
item.members = membersByItem[item.id] || [];
|
||||
item.payers = payersByItem[item.id] || [];
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
export function createBudgetItem(
|
||||
tripId: string | number,
|
||||
data: { category?: string; name: string; total_price?: number; persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null },
|
||||
data: {
|
||||
category?: string; name: string; total_price?: number;
|
||||
currency?: string | null; exchange_rate?: number;
|
||||
payers?: { user_id: number; amount: number }[]; member_ids?: number[];
|
||||
persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null;
|
||||
},
|
||||
) {
|
||||
const maxOrder = db.prepare(
|
||||
'SELECT MAX(sort_order) as max FROM budget_items WHERE trip_id = ?'
|
||||
).get(tripId) as { max: number | null };
|
||||
const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
|
||||
|
||||
const cat = data.category || 'Other';
|
||||
const cat = data.category || 'other';
|
||||
|
||||
// Ensure category has a sort_order entry
|
||||
const catExists = db.prepare('SELECT 1 FROM budget_category_order WHERE trip_id = ? AND category = ?').get(tripId, cat);
|
||||
@@ -77,22 +122,37 @@ export function createBudgetItem(
|
||||
db.prepare('INSERT OR IGNORE INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?)').run(tripId, cat, catOrder);
|
||||
}
|
||||
|
||||
// total_price is derived from explicit payers when given; otherwise the caller
|
||||
// value (planning entries, or a bill no one has paid yet).
|
||||
const payerTotal = (data.payers || []).reduce((a, p) => a + (p.amount > 0 ? p.amount : 0), 0);
|
||||
const total = data.payers && data.payers.length > 0 ? payerTotal : (data.total_price || 0);
|
||||
|
||||
const result = db.prepare(
|
||||
'INSERT INTO budget_items (trip_id, category, name, total_price, persons, days, note, sort_order, expense_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
'INSERT INTO budget_items (trip_id, category, name, total_price, currency, exchange_rate, persons, days, note, sort_order, expense_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(
|
||||
tripId,
|
||||
cat,
|
||||
data.name,
|
||||
data.total_price || 0,
|
||||
data.persons != null ? data.persons : null,
|
||||
total,
|
||||
data.currency || null,
|
||||
data.exchange_rate != null ? data.exchange_rate : 1,
|
||||
data.member_ids ? data.member_ids.length : (data.persons != null ? data.persons : null),
|
||||
data.days !== undefined && data.days !== null ? data.days : null,
|
||||
data.note || null,
|
||||
sortOrder,
|
||||
data.expense_date || null,
|
||||
);
|
||||
|
||||
const item = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(result.lastInsertRowid) as BudgetItem & { members?: BudgetItemMember[] };
|
||||
item.members = [];
|
||||
const itemId = result.lastInsertRowid as number;
|
||||
if (data.payers && data.payers.length > 0) writeItemPayers(itemId, data.payers);
|
||||
if (data.member_ids && data.member_ids.length > 0) {
|
||||
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0)');
|
||||
for (const uid of data.member_ids) insert.run(itemId, uid);
|
||||
}
|
||||
|
||||
const item = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(itemId) as BudgetItem;
|
||||
item.members = loadItemMembers(itemId);
|
||||
item.payers = loadItemPayers(itemId);
|
||||
return item;
|
||||
}
|
||||
|
||||
@@ -110,7 +170,12 @@ export function linkBudgetItemToReservation(
|
||||
export function updateBudgetItem(
|
||||
id: string | number,
|
||||
tripId: string | number,
|
||||
data: { category?: string; name?: string; total_price?: number; persons?: number | null; days?: number | null; note?: string | null; sort_order?: number; expense_date?: string | null },
|
||||
data: {
|
||||
category?: string; name?: string; total_price?: number;
|
||||
currency?: string | null; exchange_rate?: number;
|
||||
payers?: { user_id: number; amount: number }[]; member_ids?: number[];
|
||||
persons?: number | null; days?: number | null; note?: string | null; sort_order?: number; expense_date?: string | null;
|
||||
},
|
||||
) {
|
||||
const item = db.prepare('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||
if (!item) return null;
|
||||
@@ -120,6 +185,8 @@ export function updateBudgetItem(
|
||||
category = COALESCE(?, category),
|
||||
name = COALESCE(?, name),
|
||||
total_price = CASE WHEN ? IS NOT NULL THEN ? ELSE total_price END,
|
||||
currency = CASE WHEN ? THEN ? ELSE currency END,
|
||||
exchange_rate = CASE WHEN ? IS NOT NULL THEN ? ELSE exchange_rate END,
|
||||
persons = CASE WHEN ? IS NOT NULL THEN ? ELSE persons END,
|
||||
days = CASE WHEN ? THEN ? ELSE days END,
|
||||
note = CASE WHEN ? THEN ? ELSE note END,
|
||||
@@ -130,6 +197,8 @@ export function updateBudgetItem(
|
||||
data.category || null,
|
||||
data.name || null,
|
||||
data.total_price !== undefined ? 1 : null, data.total_price !== undefined ? data.total_price : 0,
|
||||
data.currency !== undefined ? 1 : 0, data.currency !== undefined ? (data.currency || null) : null,
|
||||
data.exchange_rate !== undefined ? 1 : null, data.exchange_rate !== undefined ? data.exchange_rate : 1,
|
||||
data.persons !== undefined ? 1 : null, data.persons !== undefined ? data.persons : null,
|
||||
data.days !== undefined ? 1 : 0, data.days !== undefined ? data.days : null,
|
||||
data.note !== undefined ? 1 : 0, data.note !== undefined ? data.note : null,
|
||||
@@ -138,6 +207,15 @@ export function updateBudgetItem(
|
||||
id,
|
||||
);
|
||||
|
||||
// Optional inline payer/member replacement (the edit modal saves all at once).
|
||||
if (data.payers !== undefined) writeItemPayers(id, data.payers);
|
||||
if (data.member_ids !== undefined) {
|
||||
db.prepare('DELETE FROM budget_item_members WHERE budget_item_id = ?').run(id);
|
||||
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0)');
|
||||
for (const uid of data.member_ids) insert.run(id, uid);
|
||||
db.prepare('UPDATE budget_items SET persons = ? WHERE id = ?').run(data.member_ids.length || null, id);
|
||||
}
|
||||
|
||||
// If category changed, update category order table
|
||||
if (data.category) {
|
||||
const catExists = db.prepare('SELECT 1 FROM budget_category_order WHERE trip_id = ? AND category = ?').get(tripId, data.category);
|
||||
@@ -148,8 +226,23 @@ export function updateBudgetItem(
|
||||
}
|
||||
}
|
||||
|
||||
const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id) as BudgetItem & { members?: BudgetItemMember[] };
|
||||
const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id) as BudgetItem;
|
||||
updated.members = loadItemMembers(id);
|
||||
updated.payers = loadItemPayers(id);
|
||||
return updated;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Payers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function setItemPayers(id: string | number, tripId: string | number, payers: { user_id: number; amount: number }[]) {
|
||||
const item = db.prepare('SELECT id FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||
if (!item) return null;
|
||||
writeItemPayers(id, payers);
|
||||
const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id) as BudgetItem;
|
||||
updated.members = loadItemMembers(id);
|
||||
updated.payers = loadItemPayers(id);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -187,7 +280,11 @@ export function updateMembers(id: string | number, tripId: string | number, user
|
||||
return { members, item: updated };
|
||||
}
|
||||
|
||||
export function toggleMemberPaid(id: string | number, userId: string | number, paid: boolean) {
|
||||
export function toggleMemberPaid(id: string | number, tripId: string | number, userId: string | number, paid: boolean) {
|
||||
// Resolve the item within the caller's trip before updating.
|
||||
const item = db.prepare('SELECT id FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||
if (!item) return null;
|
||||
|
||||
db.prepare('UPDATE budget_item_members SET paid = ? WHERE budget_item_id = ? AND user_id = ?')
|
||||
.run(paid ? 1 : 0, id, userId);
|
||||
|
||||
@@ -224,37 +321,65 @@ export function getPerPersonSummary(tripId: string | number) {
|
||||
// Settlement calculation (greedy debt matching)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function calculateSettlement(tripId: string | number) {
|
||||
export function calculateSettlement(
|
||||
tripId: string | number,
|
||||
opts: { base?: string; rates?: Record<string, number> | null; tripCurrency?: string } = {},
|
||||
) {
|
||||
const base = (opts.base || opts.tripCurrency || 'EUR').toUpperCase();
|
||||
const tripCurrency = (opts.tripCurrency || base).toUpperCase();
|
||||
const rates = opts.rates ?? null;
|
||||
// Amount in some currency → base. Pre-rework rows store currency = NULL, which
|
||||
// means "the trip's own currency". rates[X] = units of X per 1 base.
|
||||
const toBase = (amount: number, itemCurrency: string | null | undefined): number => {
|
||||
const cur = (itemCurrency || tripCurrency).toUpperCase();
|
||||
if (cur === base || !rates) return amount;
|
||||
const r = rates[cur];
|
||||
return r && r > 0 ? amount / r : amount;
|
||||
};
|
||||
|
||||
const items = db.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(tripId) as BudgetItem[];
|
||||
const allMembers = db.prepare(`
|
||||
SELECT bm.budget_item_id, bm.user_id, bm.paid, u.username, u.avatar
|
||||
SELECT bm.budget_item_id, bm.user_id, u.username, u.avatar
|
||||
FROM budget_item_members bm
|
||||
JOIN users u ON bm.user_id = u.id
|
||||
WHERE bm.budget_item_id IN (SELECT id FROM budget_items WHERE trip_id = ?)
|
||||
`).all(tripId) as (BudgetItemMember & { budget_item_id: number })[];
|
||||
const allPayers = db.prepare(`
|
||||
SELECT bp.budget_item_id, bp.user_id, bp.amount, u.username, u.avatar
|
||||
FROM budget_item_payers bp
|
||||
JOIN users u ON bp.user_id = u.id
|
||||
WHERE bp.budget_item_id IN (SELECT id FROM budget_items WHERE trip_id = ?)
|
||||
`).all(tripId) as (BudgetItemPayer & { budget_item_id: number })[];
|
||||
|
||||
// Calculate net balance per user: positive = is owed money, negative = owes money
|
||||
// Net balance per user, in the requested base currency: positive = is owed
|
||||
// money, negative = owes money. Each expense's amounts are converted from their
|
||||
// own currency to the base with live rates, so mixed-currency trips net correctly.
|
||||
const balances: Record<number, { user_id: number; username: string; avatar_url: string | null; balance: number }> = {};
|
||||
const ensure = (id: number, src: { username?: string; avatar?: string | null }) => {
|
||||
if (!balances[id]) balances[id] = { user_id: id, username: src.username || '', avatar_url: avatarUrl(src), balance: 0 };
|
||||
return balances[id];
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
const members = allMembers.filter(m => m.budget_item_id === item.id);
|
||||
if (members.length === 0) continue;
|
||||
const payers = allPayers.filter(p => p.budget_item_id === item.id);
|
||||
if (members.length === 0) continue; // planning-only entry → doesn't affect balances
|
||||
|
||||
const payers = members.filter(m => m.paid);
|
||||
if (payers.length === 0) continue; // no one marked as paid
|
||||
const paidBase = payers.reduce((a, p) => a + toBase(p.amount > 0 ? p.amount : 0, item.currency), 0);
|
||||
const sharePerMember = paidBase / members.length;
|
||||
|
||||
const sharePerMember = item.total_price / members.length;
|
||||
const paidPerPayer = item.total_price / payers.length;
|
||||
// Payers are credited what they actually paid (converted to base)…
|
||||
for (const p of payers) ensure(p.user_id, p).balance += toBase(p.amount > 0 ? p.amount : 0, item.currency);
|
||||
// …and every split participant owes an equal share of the base total.
|
||||
for (const m of members) ensure(m.user_id, m).balance -= sharePerMember;
|
||||
}
|
||||
|
||||
for (const m of members) {
|
||||
if (!balances[m.user_id]) {
|
||||
balances[m.user_id] = { user_id: m.user_id, username: m.username, avatar_url: avatarUrl(m), balance: 0 };
|
||||
}
|
||||
// Everyone owes their share
|
||||
balances[m.user_id].balance -= sharePerMember;
|
||||
// Payers get credited what they paid
|
||||
if (m.paid) balances[m.user_id].balance += paidPerPayer;
|
||||
}
|
||||
// Persisted settle-up transfers already moved money: the payer's debt shrinks,
|
||||
// the receiver's credit shrinks, so the corresponding flow disappears.
|
||||
const settlements = listSettlements(tripId);
|
||||
for (const s of settlements) {
|
||||
if (balances[s.from_user_id]) balances[s.from_user_id].balance += s.amount;
|
||||
if (balances[s.to_user_id]) balances[s.to_user_id].balance -= s.amount;
|
||||
}
|
||||
|
||||
// Calculate optimized payment flows (greedy algorithm)
|
||||
@@ -287,9 +412,52 @@ export function calculateSettlement(tripId: string | number) {
|
||||
return {
|
||||
balances: Object.values(balances).map(b => ({ ...b, balance: Math.round(b.balance * 100) / 100 })),
|
||||
flows,
|
||||
settlements,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settlements (persisted settle-up transfers — history + undo)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function listSettlements(tripId: string | number) {
|
||||
const rows = db.prepare(`
|
||||
SELECT s.id, s.trip_id, s.from_user_id, s.to_user_id, s.amount, s.created_at, s.created_by_user_id,
|
||||
fu.username AS from_username, fu.avatar AS from_avatar,
|
||||
tu.username AS to_username, tu.avatar AS to_avatar
|
||||
FROM budget_settlements s
|
||||
JOIN users fu ON s.from_user_id = fu.id
|
||||
JOIN users tu ON s.to_user_id = tu.id
|
||||
WHERE s.trip_id = ?
|
||||
ORDER BY s.created_at DESC, s.id DESC
|
||||
`).all(tripId) as any[];
|
||||
return rows.map(r => ({
|
||||
id: r.id, trip_id: r.trip_id,
|
||||
from_user_id: r.from_user_id, to_user_id: r.to_user_id,
|
||||
amount: r.amount, created_at: r.created_at, created_by_user_id: r.created_by_user_id,
|
||||
from_username: r.from_username, from_avatar_url: avatarUrl({ avatar: r.from_avatar }),
|
||||
to_username: r.to_username, to_avatar_url: avatarUrl({ avatar: r.to_avatar }),
|
||||
}));
|
||||
}
|
||||
|
||||
export function createSettlement(
|
||||
tripId: string | number,
|
||||
data: { from_user_id: number; to_user_id: number; amount: number },
|
||||
createdByUserId?: number,
|
||||
) {
|
||||
const result = db.prepare(
|
||||
'INSERT INTO budget_settlements (trip_id, from_user_id, to_user_id, amount, created_by_user_id) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(tripId, data.from_user_id, data.to_user_id, Math.round(data.amount * 100) / 100, createdByUserId ?? null);
|
||||
return listSettlements(tripId).find(s => s.id === Number(result.lastInsertRowid)) || null;
|
||||
}
|
||||
|
||||
export function deleteSettlement(id: string | number, tripId: string | number): boolean {
|
||||
const row = db.prepare('SELECT id FROM budget_settlements WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||
if (!row) return false;
|
||||
db.prepare('DELETE FROM budget_settlements WHERE id = ?').run(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reorder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { db, canAccessTrip } from '../db/database';
|
||||
import { db } from '../db/database';
|
||||
import { CollabNote, CollabPoll, CollabMessage, TripFile } from '../types';
|
||||
import { checkSsrf, createPinnedDispatcher } from '../utils/ssrfGuard';
|
||||
import { avatarUrl } from './avatarUrl';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Internal row types */
|
||||
@@ -48,13 +49,8 @@ export interface LinkPreviewResult {
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function avatarUrl(user: { avatar?: string | null }): string | null {
|
||||
return user.avatar ? `/uploads/avatars/${user.avatar}` : null;
|
||||
}
|
||||
|
||||
export function verifyTripAccess(tripId: string | number, userId: number) {
|
||||
return canAccessTrip(tripId, userId);
|
||||
}
|
||||
export { avatarUrl };
|
||||
export { verifyTripAccess } from './tripAccess';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Reactions */
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { SESSION_DURATION_MS, SESSION_DURATION_REMEMBER_MS } from '../config';
|
||||
|
||||
const COOKIE_NAME = 'trek_session';
|
||||
|
||||
/**
|
||||
* Controls the cookie lifetime for a login:
|
||||
* - `undefined` → persistent `maxAge: SESSION_DURATION_MS` (the historical
|
||||
* default, used by register/demo and anything that doesn't opt in).
|
||||
* - `true` → persistent `maxAge: SESSION_DURATION_REMEMBER_MS` ("Remember me").
|
||||
* - `false` → no `maxAge` — a browser-session cookie cleared on browser close.
|
||||
*/
|
||||
export type RememberOption = boolean | undefined;
|
||||
|
||||
/**
|
||||
* Decide whether the session cookie should carry the `Secure` flag.
|
||||
*
|
||||
@@ -17,27 +27,35 @@ const COOKIE_NAME = 'trek_session';
|
||||
* on the outermost hop, the cookie is `Secure`. `COOKIE_SECURE=false`
|
||||
* remains the explicit escape hatch for plain-HTTP LAN testing.
|
||||
*/
|
||||
export function cookieOptions(clear = false, req?: Request) {
|
||||
export function cookieOptions(clear = false, req?: Request, remember?: RememberOption) {
|
||||
if (process.env.COOKIE_SECURE?.toLowerCase() === 'false') {
|
||||
return buildOptions(clear, false);
|
||||
return buildOptions(clear, false, remember);
|
||||
}
|
||||
const envSecure = process.env.NODE_ENV?.toLowerCase() === 'production' || process.env.FORCE_HTTPS?.toLowerCase() === 'true';
|
||||
const requestSecure = req?.secure === true;
|
||||
return buildOptions(clear, envSecure || requestSecure);
|
||||
return buildOptions(clear, envSecure || requestSecure, remember);
|
||||
}
|
||||
|
||||
function buildOptions(clear: boolean, secure: boolean) {
|
||||
function resolveMaxAge(remember: RememberOption): { maxAge: number } | Record<string, never> {
|
||||
// false → session cookie (omit maxAge); true → the longer "remember me"
|
||||
// window; undefined → the historical default. Each maxAge matches the JWT exp.
|
||||
if (remember === false) return {};
|
||||
if (remember === true) return { maxAge: SESSION_DURATION_REMEMBER_MS };
|
||||
return { maxAge: SESSION_DURATION_MS };
|
||||
}
|
||||
|
||||
function buildOptions(clear: boolean, secure: boolean, remember?: RememberOption) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure,
|
||||
sameSite: 'lax' as const,
|
||||
path: '/',
|
||||
...(clear ? {} : { maxAge: 24 * 60 * 60 * 1000 }), // 24h — matches JWT expiry
|
||||
...(clear ? {} : resolveMaxAge(remember)),
|
||||
};
|
||||
}
|
||||
|
||||
export function setAuthCookie(res: Response, token: string, req?: Request): void {
|
||||
res.cookie(COOKIE_NAME, token, cookieOptions(false, req));
|
||||
export function setAuthCookie(res: Response, token: string, req?: Request, remember?: RememberOption): void {
|
||||
res.cookie(COOKIE_NAME, token, cookieOptions(false, req, remember));
|
||||
}
|
||||
|
||||
export function clearAuthCookie(res: Response, req?: Request): void {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { db, canAccessTrip } from '../db/database';
|
||||
import { db } from '../db/database';
|
||||
import { DayNote } from '../types';
|
||||
|
||||
export function verifyTripAccess(tripId: string | number, userId: number) {
|
||||
return canAccessTrip(tripId, userId);
|
||||
}
|
||||
export { verifyTripAccess } from './tripAccess';
|
||||
|
||||
export function listNotes(dayId: string | number, tripId: string | number) {
|
||||
return db.prepare(
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { db, canAccessTrip } from '../db/database';
|
||||
import { db } from '../db/database';
|
||||
import { loadTagsByPlaceIds, loadParticipantsByAssignmentIds, formatAssignmentWithPlace } from './queryHelpers';
|
||||
import { AssignmentRow, Day, DayNote } from '../types';
|
||||
|
||||
export function verifyTripAccess(tripId: string | number, userId: number) {
|
||||
return canAccessTrip(tripId, userId);
|
||||
}
|
||||
export { verifyTripAccess } from './tripAccess';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Day assignment helpers
|
||||
@@ -159,6 +157,220 @@ export function deleteDay(id: string | number) {
|
||||
db.prepare('DELETE FROM days WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Day reorder / insert (#589)
|
||||
//
|
||||
// Reordering keeps every day ROW stable (so assignments, notes, accommodations,
|
||||
// photos and multi-day reservation positions ride along by id) and only changes
|
||||
// each row's day_number — its position. On a dated trip the calendar dates stay
|
||||
// pinned to their slots (position i keeps the i-th date) and the day's content
|
||||
// moves across them. Because a booking's day is derived from the date part of
|
||||
// reservation_time, every booking on a day whose date changed gets that date
|
||||
// re-stamped onto the day's new date (time-of-day preserved), so day_id stays
|
||||
// consistent and the booking moves with its day.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
function addDays(date: string, n: number): string {
|
||||
const [y, m, d] = date.split('-').map(Number);
|
||||
const t = Date.UTC(y, m - 1, d) + n * MS_PER_DAY;
|
||||
const dt = new Date(t);
|
||||
const yyyy = dt.getUTCFullYear();
|
||||
const mm = String(dt.getUTCMonth() + 1).padStart(2, '0');
|
||||
const dd = String(dt.getUTCDate()).padStart(2, '0');
|
||||
return `${yyyy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
function dayDelta(from: string, to: string): number {
|
||||
const [fy, fm, fd] = from.split('-').map(Number);
|
||||
const [ty, tm, td] = to.split('-').map(Number);
|
||||
return Math.round((Date.UTC(ty, tm - 1, td) - Date.UTC(fy, fm - 1, fd)) / MS_PER_DAY);
|
||||
}
|
||||
|
||||
/** Replace the date part of an ISO-ish timestamp, keeping any time suffix. */
|
||||
function withDatePart(timestamp: string, date: string): string {
|
||||
return date + (timestamp.length > 10 ? timestamp.slice(10) : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* After day dates have been re-pinned, re-stamp the date of every booking on a
|
||||
* moved day so reservation_time/reservation_end_time follow their day's new
|
||||
* date (time-of-day preserved). Transport endpoints (flight legs) shift by the
|
||||
* same per-booking day delta so multi-leg timing stays internally consistent.
|
||||
*/
|
||||
function restampReservationDates(
|
||||
tripId: string | number,
|
||||
oldDateById: Map<number, string | null>,
|
||||
newDateById: Map<number, string | null>,
|
||||
): void {
|
||||
const reservations = db.prepare(
|
||||
'SELECT id, day_id, end_day_id, reservation_time, reservation_end_time FROM reservations WHERE trip_id = ?'
|
||||
).all(tripId) as {
|
||||
id: number; day_id: number | null; end_day_id: number | null;
|
||||
reservation_time: string | null; reservation_end_time: string | null;
|
||||
}[];
|
||||
|
||||
const setTime = db.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?');
|
||||
const setEndTime = db.prepare('UPDATE reservations SET reservation_end_time = ? WHERE id = ?');
|
||||
const endpoints = db.prepare('SELECT id, local_date FROM reservation_endpoints WHERE reservation_id = ?');
|
||||
const setEndpointDate = db.prepare('UPDATE reservation_endpoints SET local_date = ? WHERE id = ?');
|
||||
|
||||
for (const r of reservations) {
|
||||
if (r.day_id != null && r.reservation_time) {
|
||||
const oldDate = oldDateById.get(r.day_id);
|
||||
const newDate = newDateById.get(r.day_id);
|
||||
if (oldDate && newDate && oldDate !== newDate) {
|
||||
setTime.run(withDatePart(r.reservation_time, newDate), r.id);
|
||||
// Shift each transport leg's local_date by the same number of days.
|
||||
const delta = dayDelta(oldDate, newDate);
|
||||
if (delta !== 0) {
|
||||
for (const ep of endpoints.all(r.id) as { id: number; local_date: string | null }[]) {
|
||||
if (ep.local_date) setEndpointDate.run(addDays(ep.local_date, delta), ep.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (r.end_day_id != null && r.reservation_end_time) {
|
||||
const oldDate = oldDateById.get(r.end_day_id);
|
||||
const newDate = newDateById.get(r.end_day_id);
|
||||
if (oldDate && newDate && oldDate !== newDate) {
|
||||
setEndTime.run(withDatePart(r.reservation_end_time, newDate), r.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A stay must not end before it begins after a reorder/insert. */
|
||||
function assertNoInvertedAccommodation(tripId: string | number): void {
|
||||
const spans = db.prepare(`
|
||||
SELECT a.id, s.day_number AS start_no, e.day_number AS end_no
|
||||
FROM day_accommodations a
|
||||
JOIN days s ON a.start_day_id = s.id
|
||||
JOIN days e ON a.end_day_id = e.id
|
||||
WHERE a.trip_id = ?
|
||||
`).all(tripId) as { id: number; start_no: number; end_no: number }[];
|
||||
for (const span of spans) {
|
||||
if (span.start_no > span.end_no) {
|
||||
throw new DayReorderError('This move would make an accommodation end before it starts.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown for invalid reorder/insert requests; mapped to HTTP 400 by the controller. */
|
||||
export class DayReorderError extends Error {}
|
||||
|
||||
/**
|
||||
* Reorder whole days. `orderedIds` is the desired full sequence of this trip's
|
||||
* day ids (a permutation of the current ids).
|
||||
*/
|
||||
export function reorderDays(tripId: string | number, orderedIds: number[]) {
|
||||
const rows = db.prepare(
|
||||
'SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number'
|
||||
).all(tripId) as { id: number; day_number: number; date: string | null }[];
|
||||
|
||||
const existingIds = new Set(rows.map(r => r.id));
|
||||
if (orderedIds.length !== rows.length || !orderedIds.every(id => existingIds.has(id))) {
|
||||
throw new DayReorderError('orderedIds must be a permutation of the trip day ids.');
|
||||
}
|
||||
|
||||
const oldDateById = new Map(rows.map(r => [r.id, r.date]));
|
||||
// Dates stay pinned to slots: position i keeps the i-th date (ascending).
|
||||
const sortedDates = rows.map(r => r.date).filter((d): d is string => !!d).sort();
|
||||
const isDated = sortedDates.length > 0;
|
||||
|
||||
const setDayNumber = db.prepare('UPDATE days SET day_number = ? WHERE id = ?');
|
||||
const setDayNumberAndDate = db.prepare('UPDATE days SET day_number = ?, date = ? WHERE id = ?');
|
||||
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
// Two-phase renumber to dodge UNIQUE(trip_id, day_number) collisions.
|
||||
orderedIds.forEach((id, i) => setDayNumber.run(-(i + 1), id));
|
||||
const newDateById = new Map<number, string | null>();
|
||||
orderedIds.forEach((id, i) => {
|
||||
const date = isDated ? (sortedDates[i] ?? null) : null;
|
||||
setDayNumberAndDate.run(i + 1, date, id);
|
||||
newDateById.set(id, date);
|
||||
});
|
||||
|
||||
if (isDated) restampReservationDates(tripId, oldDateById, newDateById);
|
||||
assertNoInvertedAccommodation(tripId);
|
||||
|
||||
db.exec('COMMIT');
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK');
|
||||
throw e;
|
||||
}
|
||||
|
||||
return listDays(tripId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a new empty day at a 1-based position (default: append at the end).
|
||||
* On a dated trip the trip gains one calendar day: dates re-pin so the slots
|
||||
* stay contiguous, the trip's end_date extends by one day, and bookings on
|
||||
* shifted days have their dates re-stamped (same rules as reorderDays).
|
||||
*/
|
||||
export function insertDay(tripId: string | number, position?: number) {
|
||||
const rows = db.prepare(
|
||||
'SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number'
|
||||
).all(tripId) as { id: number; day_number: number; date: string | null }[];
|
||||
const n = rows.length;
|
||||
const pos = Math.min(Math.max(position ?? n + 1, 1), n + 1);
|
||||
const datedRows = rows.filter(r => r.date) as { id: number; day_number: number; date: string }[];
|
||||
const isDated = datedRows.length > 0;
|
||||
|
||||
const setDayNumber = db.prepare('UPDATE days SET day_number = ? WHERE id = ?');
|
||||
|
||||
if (!isDated) {
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
const toShift = rows.filter(r => r.day_number >= pos);
|
||||
toShift.forEach(r => setDayNumber.run(-r.day_number, r.id));
|
||||
const result = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, NULL)').run(tripId, pos);
|
||||
toShift.forEach(r => setDayNumber.run(r.day_number + 1, r.id));
|
||||
db.exec('COMMIT');
|
||||
const day = db.prepare('SELECT * FROM days WHERE id = ?').get(result.lastInsertRowid) as Day;
|
||||
return { ...day, assignments: [], notes_items: [] };
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Dated trip: rebuild N+1 contiguous dates from the earliest date.
|
||||
const start = datedRows.map(r => r.date).sort()[0];
|
||||
const dates = Array.from({ length: n + 1 }, (_, i) => addDays(start, i));
|
||||
const oldDateById = new Map(rows.map(r => [r.id, r.date]));
|
||||
const setDayNumberAndDate = db.prepare('UPDATE days SET day_number = ?, date = ? WHERE id = ?');
|
||||
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
rows.forEach((r, i) => setDayNumber.run(-(i + 1), r.id));
|
||||
const result = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)').run(tripId, pos, dates[pos - 1]);
|
||||
const newId = Number(result.lastInsertRowid);
|
||||
|
||||
const orderedIds = rows.map(r => r.id);
|
||||
orderedIds.splice(pos - 1, 0, newId);
|
||||
const newDateById = new Map<number, string | null>();
|
||||
orderedIds.forEach((id, i) => {
|
||||
setDayNumberAndDate.run(i + 1, dates[i], id);
|
||||
newDateById.set(id, dates[i]);
|
||||
});
|
||||
|
||||
restampReservationDates(tripId, oldDateById, newDateById);
|
||||
assertNoInvertedAccommodation(tripId);
|
||||
db.prepare('UPDATE trips SET end_date = ? WHERE id = ?').run(dates[dates.length - 1], tripId);
|
||||
|
||||
db.exec('COMMIT');
|
||||
const day = db.prepare('SELECT * FROM days WHERE id = ?').get(newId) as Day;
|
||||
return { ...day, assignments: [], notes_items: [] };
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Accommodation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { db } from '../db/database';
|
||||
|
||||
// Great-circle distance between two points in kilometres.
|
||||
function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
||||
const R = 6371;
|
||||
const toRad = (deg: number) => (deg * Math.PI) / 180;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLng = toRad(lng2 - lng1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
/**
|
||||
* Total flight distance a user has covered, summed across every non-cancelled
|
||||
* flight reservation in their trips. Each flight stores its waypoints in
|
||||
* reservation_endpoints (from → stops → to, ordered by sequence); we add up the
|
||||
* legs between consecutive points so multi-stop flights count correctly.
|
||||
*/
|
||||
export function getFlightDistanceKm(userId: number): number {
|
||||
const rows = db.prepare(`
|
||||
SELECT re.reservation_id, re.lat, re.lng
|
||||
FROM reservation_endpoints re
|
||||
JOIN reservations r ON r.id = re.reservation_id
|
||||
JOIN trips t ON t.id = r.trip_id
|
||||
LEFT JOIN trip_members tm ON tm.trip_id = t.id AND tm.user_id = ?
|
||||
WHERE (t.user_id = ? OR tm.user_id IS NOT NULL)
|
||||
AND r.type = 'flight'
|
||||
AND r.status != 'cancelled'
|
||||
ORDER BY re.reservation_id, re.sequence
|
||||
`).all(userId, userId) as { reservation_id: number; lat: number; lng: number }[];
|
||||
|
||||
let total = 0;
|
||||
let prev: { id: number; lat: number; lng: number } | null = null;
|
||||
for (const point of rows) {
|
||||
if (prev && prev.id === point.reservation_id) {
|
||||
total += haversineKm(prev.lat, prev.lng, point.lat, point.lng);
|
||||
}
|
||||
prev = { id: point.reservation_id, lat: point.lat, lng: point.lng };
|
||||
}
|
||||
return Math.round(total);
|
||||
}
|
||||
@@ -11,15 +11,31 @@ interface TokenEntry {
|
||||
userId: number;
|
||||
purpose: string;
|
||||
expiresAt: number;
|
||||
/**
|
||||
* Snapshot of the user's `password_version` at mint time, used for the
|
||||
* defence-in-depth session gate on WebSocket connects. `undefined` for
|
||||
* tokens minted without a version (legacy/other purposes), which callers
|
||||
* treat as version 0 — mirroring the JWT `pv` claim semantics.
|
||||
*/
|
||||
pv?: number;
|
||||
}
|
||||
|
||||
export interface EphemeralTokenMeta {
|
||||
/** Bind the token to the user's current password_version (session gate). */
|
||||
pv?: number;
|
||||
}
|
||||
|
||||
const store = new Map<string, TokenEntry>();
|
||||
|
||||
export function createEphemeralToken(userId: number, purpose: string): string | null {
|
||||
export function createEphemeralToken(
|
||||
userId: number,
|
||||
purpose: string,
|
||||
meta?: EphemeralTokenMeta,
|
||||
): string | null {
|
||||
if (store.size >= MAX_STORE_SIZE) return null;
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const ttl = TTL[purpose] ?? 60_000;
|
||||
store.set(token, { userId, purpose, expiresAt: Date.now() + ttl });
|
||||
store.set(token, { userId, purpose, expiresAt: Date.now() + ttl, pv: meta?.pv });
|
||||
return token;
|
||||
}
|
||||
|
||||
@@ -31,6 +47,22 @@ export function consumeEphemeralToken(token: string, purpose: string): number |
|
||||
return entry.userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `consumeEphemeralToken`, but also returns the `password_version` the
|
||||
* token was minted with. Used by the WebSocket handshake so a token issued
|
||||
* before a password change can be rejected even within its short TTL.
|
||||
*/
|
||||
export function consumeEphemeralTokenWithMeta(
|
||||
token: string,
|
||||
purpose: string,
|
||||
): { userId: number; pv?: number } | null {
|
||||
const entry = store.get(token);
|
||||
if (!entry) return null;
|
||||
store.delete(token);
|
||||
if (entry.purpose !== purpose || Date.now() > entry.expiresAt) return null;
|
||||
return { userId: entry.userId, pv: entry.pv };
|
||||
}
|
||||
|
||||
let cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
export function startTokenCleanup(): void {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Live exchange rates for the Costs/Budget money conversion.
|
||||
*
|
||||
* Fetches from api.frankfurter.dev (no key, already CSP-allowlisted for the
|
||||
* dashboard widget) and caches per base currency in-memory for a few hours so a
|
||||
* settlement request never hammers the upstream. Rates are "units of X per 1
|
||||
* base", so an amount in currency C converts to base as `amount / rates[C]`.
|
||||
*
|
||||
* Everything degrades gracefully: if the fetch fails (offline, upstream down),
|
||||
* callers get `null`/identity conversion and amounts are treated as already in
|
||||
* the base currency rather than throwing.
|
||||
*/
|
||||
|
||||
const TTL_MS = 6 * 60 * 60 * 1000; // 6h
|
||||
const cache = new Map<string, { rates: Record<string, number>; ts: number }>();
|
||||
const inflight = new Map<string, Promise<Record<string, number> | null>>();
|
||||
|
||||
async function fetchRates(base: string): Promise<Record<string, number> | null> {
|
||||
try {
|
||||
const res = await fetch(`https://api.frankfurter.dev/v2/rates?base=${encodeURIComponent(base)}`);
|
||||
if (!res.ok) return null;
|
||||
// Frankfurter returns an array of { date, base, quote, rate } and omits the
|
||||
// base's own self-rate, so seed the map with `base = 1` then index by quote.
|
||||
const data = (await res.json()) as Array<{ quote?: string; rate?: number }>;
|
||||
if (!Array.isArray(data)) return null;
|
||||
const rates: Record<string, number> = { [base.toUpperCase()]: 1 };
|
||||
for (const r of data) {
|
||||
if (r && typeof r.quote === 'string' && typeof r.rate === 'number') rates[r.quote] = r.rate;
|
||||
}
|
||||
return Object.keys(rates).length > 1 ? rates : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Rates map for `base` (cached). Returns null if unavailable. */
|
||||
export async function getRates(base: string): Promise<Record<string, number> | null> {
|
||||
const key = (base || 'EUR').toUpperCase();
|
||||
const hit = cache.get(key);
|
||||
const now = Date.now();
|
||||
if (hit && now - hit.ts < TTL_MS) return hit.rates;
|
||||
|
||||
// Coalesce concurrent fetches for the same base.
|
||||
let p = inflight.get(key);
|
||||
if (!p) {
|
||||
p = fetchRates(key).then(rates => {
|
||||
if (rates) cache.set(key, { rates, ts: Date.now() });
|
||||
inflight.delete(key);
|
||||
return rates;
|
||||
});
|
||||
inflight.set(key, p);
|
||||
}
|
||||
const rates = await p;
|
||||
// On failure fall back to the last cached value if we have one.
|
||||
if (!rates && hit) return hit.rates;
|
||||
return rates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert `amount` from `from` currency into `base` using a rates map obtained
|
||||
* from getRates(base). Identity when same currency or the rate is missing.
|
||||
*/
|
||||
export function convertWithRates(
|
||||
amount: number,
|
||||
from: string | null | undefined,
|
||||
base: string,
|
||||
rates: Record<string, number> | null,
|
||||
): number {
|
||||
const fromCur = (from || base).toUpperCase();
|
||||
const baseCur = base.toUpperCase();
|
||||
if (fromCur === baseCur || !rates) return amount;
|
||||
const r = rates[fromCur];
|
||||
if (!r || r <= 0) return amount;
|
||||
return amount / r;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import type { Request } from 'express';
|
||||
import { db, canAccessTrip } from '../db/database';
|
||||
import { db } from '../db/database';
|
||||
import { consumeEphemeralToken } from './ephemeralTokens';
|
||||
import { verifyJwtAndLoadUser } from '../middleware/auth';
|
||||
import { TripFile } from '../types';
|
||||
@@ -30,9 +30,7 @@ export const filesDir = path.join(__dirname, '../../uploads/files');
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function verifyTripAccess(tripId: string | number, userId: number) {
|
||||
return canAccessTrip(tripId, userId);
|
||||
}
|
||||
export { verifyTripAccess } from './tripAccess';
|
||||
|
||||
export function getAllowedExtensions(): string {
|
||||
try {
|
||||
@@ -48,7 +46,7 @@ const FILE_SELECT = `
|
||||
LEFT JOIN users u ON f.uploaded_by = u.id
|
||||
`;
|
||||
|
||||
export function formatFile(file: TripFile & { trip_id?: number }) {
|
||||
export function formatFile(file: TripFile & { trip_id?: number; uploaded_by_avatar?: string | null }) {
|
||||
const tripId = file.trip_id;
|
||||
return {
|
||||
...file,
|
||||
@@ -113,10 +111,6 @@ export function getFileById(id: string | number, tripId: string | number): TripF
|
||||
return db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId) as TripFile | undefined;
|
||||
}
|
||||
|
||||
export function getFileByIdFull(id: string | number): TripFile {
|
||||
return db.prepare(`${FILE_SELECT} WHERE f.id = ?`).get(id) as TripFile;
|
||||
}
|
||||
|
||||
export function getDeletedFile(id: string | number, tripId: string | number): TripFile | undefined {
|
||||
return db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ? AND deleted_at IS NOT NULL').get(id, tripId) as TripFile | undefined;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,13 @@ import { broadcastToUser } from '../websocket';
|
||||
import { getAction } from './inAppNotificationActions';
|
||||
import { isEnabledForEvent, type NotifEventType } from './notificationPreferencesService';
|
||||
|
||||
// SQLite's CURRENT_TIMESTAMP is UTC but the string ('YYYY-MM-DD HH:MM:SS') has
|
||||
// no 'T'/'Z', so `new Date(...)` parses it as LOCAL time. Normalize to ISO-UTC
|
||||
// so the client renders notification times in the viewer's own timezone (#1149).
|
||||
function toUtcIso(ts: string): string {
|
||||
return ts.endsWith('Z') ? ts : ts.replace(' ', 'T') + 'Z';
|
||||
}
|
||||
|
||||
type NotificationType = 'simple' | 'boolean' | 'navigate';
|
||||
type NotificationScope = 'trip' | 'user' | 'admin';
|
||||
type NotificationResponse = 'positive' | 'negative';
|
||||
@@ -218,6 +225,7 @@ export function createNotificationForRecipient(
|
||||
type: 'notification:new',
|
||||
notification: {
|
||||
...row,
|
||||
created_at: toUtcIso(row.created_at),
|
||||
sender_username: sender?.username ?? null,
|
||||
sender_avatar: sender?.avatar ? `/uploads/avatars/${sender.avatar}` : null,
|
||||
},
|
||||
@@ -251,6 +259,7 @@ function getNotifications(
|
||||
|
||||
const mapped = rows.map(r => ({
|
||||
...r,
|
||||
created_at: toUtcIso(r.created_at),
|
||||
sender_avatar: r.sender_avatar ? `/uploads/avatars/${r.sender_avatar}` : null,
|
||||
}));
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -84,10 +84,8 @@ export function validateShareTokenForAsset(token: string, assetId: string): { ow
|
||||
JOIN trek_photos tkp ON tkp.id = gp.photo_id
|
||||
WHERE tkp.asset_id = ? AND gp.journey_id = ?
|
||||
`).get(assetId, row.journey_id) as any;
|
||||
if (!photo) {
|
||||
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
|
||||
return journey ? { ownerId: journey.user_id } : null;
|
||||
}
|
||||
// Only resolve assets that actually belong to this shared journey.
|
||||
if (!photo) return null;
|
||||
return { ownerId: photo.owner_id };
|
||||
}
|
||||
|
||||
@@ -137,13 +135,45 @@ export function getPublicJourney(token: string) {
|
||||
photos: photosByEntry[e.id] || [],
|
||||
}));
|
||||
|
||||
// Stats
|
||||
// Stats are derived from the full data so the overview pills stay accurate
|
||||
// even when a section is hidden.
|
||||
const stats = {
|
||||
entries: entries.length,
|
||||
photos: gallery.length,
|
||||
places: new Set(entries.filter(e => e.location_name).map(e => e.location_name)).size,
|
||||
};
|
||||
|
||||
const shareTimeline = !!row.share_timeline;
|
||||
const shareGallery = !!row.share_gallery;
|
||||
const shareMap = !!row.share_map;
|
||||
|
||||
// Honour the share flags server-side so the API only returns the sections the
|
||||
// owner enabled (the client gates these too, but it must not rely on that).
|
||||
let publicEntries: Record<string, unknown>[] = [];
|
||||
if (shareTimeline) {
|
||||
// Include the full entry, but drop GPS unless the map is shared and inline
|
||||
// photos unless the gallery is shared.
|
||||
publicEntries = enrichedEntries.map(e => {
|
||||
const projected: Record<string, unknown> = { ...e };
|
||||
if (!shareMap) { projected.location_lat = null; projected.location_lng = null; }
|
||||
if (!shareGallery) projected.photos = [];
|
||||
return projected;
|
||||
});
|
||||
} else if (shareMap) {
|
||||
// Map-only share: just enough to plot markers, no story/photos/mood.
|
||||
publicEntries = enrichedEntries.map(e => ({
|
||||
id: e.id,
|
||||
journey_id: e.journey_id,
|
||||
type: e.type,
|
||||
entry_date: e.entry_date,
|
||||
title: e.title,
|
||||
location_name: e.location_name,
|
||||
location_lat: e.location_lat,
|
||||
location_lng: e.location_lng,
|
||||
sort_order: e.sort_order,
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
journey: {
|
||||
title: journey.title,
|
||||
@@ -151,13 +181,13 @@ export function getPublicJourney(token: string) {
|
||||
cover_image: journey.cover_image,
|
||||
status: journey.status,
|
||||
},
|
||||
entries: enrichedEntries,
|
||||
gallery,
|
||||
entries: publicEntries,
|
||||
gallery: shareGallery ? gallery : [],
|
||||
stats,
|
||||
permissions: {
|
||||
share_timeline: !!row.share_timeline,
|
||||
share_gallery: !!row.share_gallery,
|
||||
share_map: !!row.share_map,
|
||||
share_timeline: shareTimeline,
|
||||
share_gallery: shareGallery,
|
||||
share_map: shareMap,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { db } from '../db/database';
|
||||
import { decrypt_api_key } from './apiKeyCrypto';
|
||||
import { checkSsrf } from '../utils/ssrfGuard';
|
||||
import { safeFetchFollow, SsrfBlockedError } from '../utils/ssrfGuard';
|
||||
import { getAppUrl } from './notifications';
|
||||
|
||||
// ── Google API call counter ───────────────────────────────────────────────────
|
||||
|
||||
let googleApiCallCount = 0;
|
||||
|
||||
export function getGoogleApiCallCount(): number { return googleApiCallCount; }
|
||||
export function resetGoogleApiCallCount(): void { googleApiCallCount = 0; }
|
||||
|
||||
function googleFetch(endpoint: string, label: string, init?: RequestInit): Promise<Response> {
|
||||
googleApiCallCount++;
|
||||
console.debug(`[Google API] #${googleApiCallCount} ${label} → ${endpoint}`);
|
||||
@@ -36,7 +33,7 @@ interface OverpassElement {
|
||||
}
|
||||
|
||||
interface WikiCommonsPage {
|
||||
imageinfo?: { url?: string; extmetadata?: { Artist?: { value?: string } } }[];
|
||||
imageinfo?: { url?: string; thumburl?: string; extmetadata?: { Artist?: { value?: string } } }[];
|
||||
}
|
||||
|
||||
interface GooglePlaceResult {
|
||||
@@ -73,6 +70,24 @@ interface GooglePlaceDetails extends GooglePlaceResult {
|
||||
|
||||
const UA = 'TREK Travel Planner (https://github.com/mauriceboe/TREK)';
|
||||
|
||||
// TREK's internal language codes mostly coincide with valid BCP-47 codes, but a
|
||||
// couple don't: 'br' is Brazilian Portuguese here (BCP-47 'pt-BR'; bare 'br' is
|
||||
// Breton) and 'gr' is Greek (BCP-47 'el'). Outbound geo APIs (Google Places,
|
||||
// Nominatim) expect BCP-47, so normalise before sending — otherwise names and
|
||||
// opening hours come back in the wrong language. Codes not listed here pass
|
||||
// through unchanged (they are already valid), as do locale forms the client
|
||||
// sometimes sends (e.g. 'pt-BR').
|
||||
const API_LANG_OVERRIDES: Record<string, string> = {
|
||||
br: 'pt-BR',
|
||||
gr: 'el',
|
||||
'el-GR': 'el',
|
||||
};
|
||||
function toApiLang(lang: string | undefined, fallback = 'en'): string {
|
||||
const code = (lang || '').trim();
|
||||
if (!code) return fallback;
|
||||
return API_LANG_OVERRIDES[code] ?? code;
|
||||
}
|
||||
|
||||
// ── Photo cache (disk-backed) ────────────────────────────────────────────────
|
||||
import * as placePhotoCache from './placePhotoCache';
|
||||
|
||||
@@ -118,7 +133,7 @@ export async function searchNominatim(query: string, lang?: string) {
|
||||
format: 'json',
|
||||
addressdetails: '1',
|
||||
limit: '10',
|
||||
'accept-language': lang || 'en',
|
||||
'accept-language': toApiLang(lang),
|
||||
});
|
||||
const response = await fetch(`https://nominatim.openstreetmap.org/search?${params}`, {
|
||||
headers: { 'User-Agent': UA },
|
||||
@@ -151,7 +166,7 @@ export async function lookupNominatim(osmType: string, osmId: string, lang?: str
|
||||
const params = new URLSearchParams({
|
||||
osm_ids: `${typePrefix}${osmId}`,
|
||||
format: 'json',
|
||||
'accept-language': lang || 'en',
|
||||
'accept-language': toApiLang(lang),
|
||||
});
|
||||
try {
|
||||
const res = await fetch(`https://nominatim.openstreetmap.org/lookup?${params}`, {
|
||||
@@ -189,6 +204,210 @@ export async function fetchOverpassDetails(osmType: string, osmId: string): Prom
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// ── Overpass POI search (by category within a viewport bbox) ─────────────────
|
||||
// Powers the "explore places on the map" pill. OSM-ONLY by design — this never
|
||||
// calls Google, even when a Google key is configured.
|
||||
|
||||
export interface OverpassPoi {
|
||||
osm_id: string; // 'node:123' | 'way:123' | 'relation:123' (matches the placeId format elsewhere)
|
||||
name: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
category: string; // the requested pill category key, e.g. 'restaurant'
|
||||
poi_type: string; // the raw OSM tag that matched, e.g. 'amenity=restaurant'
|
||||
address: string | null;
|
||||
website: string | null;
|
||||
phone: string | null;
|
||||
opening_hours: string | null;
|
||||
cuisine: string | null;
|
||||
source: 'openstreetmap';
|
||||
}
|
||||
|
||||
// Each pill category → the OSM tag selectors it searches. Keys here are the
|
||||
// contract with the client's POI_CATEGORIES (same keys, label/icon/colour live
|
||||
// client-side).
|
||||
const CATEGORY_OSM_FILTERS: Record<string, string[]> = {
|
||||
restaurant: ['amenity=restaurant', 'amenity=fast_food'],
|
||||
cafe: ['amenity=cafe'],
|
||||
bar: ['amenity=bar', 'amenity=pub', 'amenity=nightclub'],
|
||||
hotel: ['tourism=hotel', 'tourism=hostel', 'tourism=guest_house', 'tourism=apartment', 'tourism=motel'],
|
||||
sights: ['tourism=attraction', 'tourism=viewpoint', 'historic=monument', 'historic=castle', 'historic=memorial', 'historic=ruins'],
|
||||
museum: ['tourism=museum', 'tourism=gallery', 'tourism=artwork', 'amenity=theatre'],
|
||||
nature: ['leisure=park', 'leisure=garden', 'natural=beach', 'natural=peak'],
|
||||
activity: ['tourism=theme_park', 'tourism=zoo', 'tourism=aquarium', 'leisure=water_park'],
|
||||
shopping: ['shop=mall', 'shop=department_store', 'amenity=marketplace'],
|
||||
supermarket: ['shop=supermarket', 'shop=convenience'],
|
||||
};
|
||||
|
||||
export const POI_CATEGORY_KEYS = Object.keys(CATEGORY_OSM_FILTERS);
|
||||
|
||||
interface OverpassPoiElement {
|
||||
type: string;
|
||||
id: number;
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
center?: { lat: number; lon: number };
|
||||
tags?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface PoiSearchResult {
|
||||
pois: OverpassPoi[];
|
||||
source: 'openstreetmap';
|
||||
truncated: boolean;
|
||||
// True when the requested viewport was too large and got shrunk to a centred
|
||||
// window before querying — the results then cover the middle of the view only.
|
||||
clamped: boolean;
|
||||
}
|
||||
|
||||
// Public Overpass mirrors, queried in PARALLEL (first valid response wins).
|
||||
// Reachability and load vary a lot by network/region — the canonical instance is
|
||||
// frequently overloaded (504s) and some community mirrors are unreachable from
|
||||
// certain networks. Racing them means whichever mirror is fastest-reachable for
|
||||
// this user answers, and an overloaded or blocked one never blocks the others.
|
||||
const OVERPASS_MIRRORS = [
|
||||
'https://overpass-api.de/api/interpreter',
|
||||
'https://maps.mail.ru/osm/tools/overpass/api/interpreter',
|
||||
'https://overpass.kumi.systems/api/interpreter',
|
||||
'https://overpass.private.coffee/api/interpreter',
|
||||
];
|
||||
// Per-mirror cap. Because mirrors race in parallel this is also the worst-case
|
||||
// total wait before every mirror is given up on and a 502 is returned.
|
||||
const OVERPASS_TIMEOUT_MS = 12000;
|
||||
// Largest viewport side we send to Overpass. A country/continent-sized bbox makes
|
||||
// Overpass scan millions of elements and time out; clamping to a centred window
|
||||
// keeps the query cheap so the explore pill returns fast at ANY zoom level.
|
||||
const MAX_BBOX_SPAN_DEG = 0.5;
|
||||
|
||||
// Short-lived cache so panning back over / re-toggling the same area doesn't
|
||||
// re-hit Overpass. Keyed by category + rounded (post-clamp) bbox.
|
||||
const POI_CACHE = new Map<string, { at: number; value: PoiSearchResult }>();
|
||||
const POI_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
// Cap the number of cached areas so panning across the globe can't grow the map
|
||||
// without bound (entries are evicted oldest-first once the cap is reached).
|
||||
const POI_CACHE_MAX = 500;
|
||||
|
||||
// POST the query to all mirrors at once and return the first one that answers with
|
||||
// valid JSON. Throws {status:502} only if every mirror fails. Racing (rather than
|
||||
// trying one-by-one) keeps latency at the fastest reachable mirror instead of the
|
||||
// sum of every dead mirror's timeout.
|
||||
async function overpassFetch(query: string): Promise<OverpassPoiElement[]> {
|
||||
const body = `data=${encodeURIComponent(query)}`;
|
||||
const controllers: AbortController[] = [];
|
||||
|
||||
const attempt = async (url: string): Promise<OverpassPoiElement[]> => {
|
||||
const ctrl = new AbortController();
|
||||
controllers.push(ctrl);
|
||||
const timer = setTimeout(() => ctrl.abort(), OVERPASS_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'User-Agent': UA, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
if (!res.ok) throw new Error(`Overpass ${res.status} @ ${url}`);
|
||||
const data = await res.json() as { elements?: OverpassPoiElement[]; remark?: string };
|
||||
// Overpass signals an internal timeout / runtime error via `remark` while
|
||||
// still answering HTTP 200 — often fast, with an empty or partial element
|
||||
// set. Treat that as a failed attempt so a healthy mirror wins the race
|
||||
// instead of this fast-but-empty answer, and so the all-mirrors-failed path
|
||||
// still surfaces a real error to the client instead of a silent "no places".
|
||||
if (data.remark) throw new Error(`Overpass remark @ ${url}: ${data.remark}`);
|
||||
if (!Array.isArray(data.elements)) throw new Error(`Overpass non-OSM body @ ${url}`);
|
||||
return data.elements;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// Promise.any resolves with the first mirror to return valid JSON, and only
|
||||
// rejects (AggregateError) once every mirror has failed.
|
||||
return await Promise.any(OVERPASS_MIRRORS.map(attempt));
|
||||
} catch {
|
||||
throw Object.assign(new Error('Overpass request failed'), { status: 502 });
|
||||
} finally {
|
||||
// Cancel the slower/losing requests — we already have (or have given up on) a result.
|
||||
controllers.forEach(c => { try { c.abort(); } catch { /* noop */ } });
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchOverpassPois(
|
||||
category: string,
|
||||
bbox: { south: number; west: number; north: number; east: number },
|
||||
limit = 60,
|
||||
): Promise<PoiSearchResult> {
|
||||
const filters = CATEGORY_OSM_FILTERS[category];
|
||||
if (!filters) throw Object.assign(new Error('Unknown POI category'), { status: 400 });
|
||||
|
||||
// Clamp an oversized viewport to a centred window so the query stays cheap and
|
||||
// returns fast at any zoom, instead of timing out / 502-ing on a huge area.
|
||||
let { south, west, north, east } = bbox;
|
||||
let clamped = false;
|
||||
if (north - south > MAX_BBOX_SPAN_DEG) {
|
||||
const c = (north + south) / 2;
|
||||
south = c - MAX_BBOX_SPAN_DEG / 2;
|
||||
north = c + MAX_BBOX_SPAN_DEG / 2;
|
||||
clamped = true;
|
||||
}
|
||||
if (east - west > MAX_BBOX_SPAN_DEG) {
|
||||
const c = (east + west) / 2;
|
||||
west = c - MAX_BBOX_SPAN_DEG / 2;
|
||||
east = c + MAX_BBOX_SPAN_DEG / 2;
|
||||
clamped = true;
|
||||
}
|
||||
|
||||
// Serve repeat pans/toggles of the same area straight from the cache.
|
||||
const cacheKey = `${category}|${south.toFixed(2)},${west.toFixed(2)},${north.toFixed(2)},${east.toFixed(2)}|${limit}`;
|
||||
const cached = POI_CACHE.get(cacheKey);
|
||||
if (cached && Date.now() - cached.at < POI_CACHE_TTL_MS) return cached.value;
|
||||
if (cached) POI_CACHE.delete(cacheKey); // expired — drop it before refetching
|
||||
|
||||
// Overpass wants the box as (south,west,north,east) = (minLat,minLng,maxLat,maxLng).
|
||||
const box = `(${south},${west},${north},${east})`;
|
||||
const selectors = filters.map(f => {
|
||||
const [k, v] = f.split('=');
|
||||
return ` nwr["${k}"="${v}"]${box};`;
|
||||
}).join('\n');
|
||||
// `out center tags <n>` returns ways/relations with a computed center and caps
|
||||
// the result count in one round-trip.
|
||||
const query = `[out:json][timeout:20];\n(\n${selectors}\n);\nout center tags ${limit + 25};`;
|
||||
|
||||
const elements = await overpassFetch(query);
|
||||
|
||||
const pois: OverpassPoi[] = [];
|
||||
for (const el of elements) {
|
||||
const tags = el.tags || {};
|
||||
const name = tags.name || tags['name:en'] || tags.brand || null;
|
||||
if (!name) continue; // unnamed POIs aren't useful to add to a plan
|
||||
const lat = el.lat ?? el.center?.lat;
|
||||
const lng = el.lon ?? el.center?.lon;
|
||||
if (lat == null || lng == null) continue;
|
||||
const matched = filters.find(f => { const [k, v] = f.split('='); return tags[k] === v; }) || filters[0];
|
||||
const addr = [tags['addr:street'], tags['addr:housenumber'], tags['addr:postcode'], tags['addr:city']].filter(Boolean).join(' ') || null;
|
||||
pois.push({
|
||||
osm_id: `${el.type}:${el.id}`,
|
||||
name,
|
||||
lat,
|
||||
lng,
|
||||
category,
|
||||
poi_type: matched,
|
||||
address: addr,
|
||||
website: tags.website || tags['contact:website'] || null,
|
||||
phone: tags.phone || tags['contact:phone'] || null,
|
||||
opening_hours: tags.opening_hours || null,
|
||||
cuisine: tags.cuisine || null,
|
||||
source: 'openstreetmap',
|
||||
});
|
||||
}
|
||||
const truncated = pois.length > limit;
|
||||
const value: PoiSearchResult = { pois: pois.slice(0, limit), source: 'openstreetmap', truncated, clamped };
|
||||
// FIFO eviction: a Map preserves insertion order, so the first key is the oldest.
|
||||
if (POI_CACHE.size >= POI_CACHE_MAX) POI_CACHE.delete(POI_CACHE.keys().next().value as string);
|
||||
POI_CACHE.set(cacheKey, { at: Date.now(), value });
|
||||
return value;
|
||||
}
|
||||
|
||||
// ── Opening hours parsing ────────────────────────────────────────────────────
|
||||
|
||||
export function parseOpeningHours(ohString: string): { weekdayDescriptions: string[]; openNow: boolean | null } {
|
||||
@@ -318,7 +537,9 @@ export async function fetchWikimediaPhoto(lat: number, lng: number, name?: strin
|
||||
const mime = (info as { mime?: string })?.mime || '';
|
||||
if (info?.url && (mime.startsWith('image/jpeg') || mime.startsWith('image/png'))) {
|
||||
const attribution = info.extmetadata?.Artist?.value?.replace(/<[^>]+>/g, '').trim() || null;
|
||||
return { photoUrl: info.url, attribution };
|
||||
// iiurlwidth=400 makes Commons also return a scaled thumburl. Prefer it —
|
||||
// info.url is the full-resolution original (multi-megapixel camera exports).
|
||||
return { photoUrl: info.thumburl ?? info.url, attribution };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -327,7 +548,7 @@ export async function fetchWikimediaPhoto(lat: number, lng: number, name?: strin
|
||||
|
||||
// ── Search places (Google or Nominatim fallback) ─────────────────────────────
|
||||
|
||||
export async function searchPlaces(userId: number, query: string, lang?: string): Promise<{ places: Record<string, unknown>[]; source: string }> {
|
||||
export async function searchPlaces(userId: number, query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }): Promise<{ places: Record<string, unknown>[]; source: string }> {
|
||||
const apiKey = getMapsKey(userId);
|
||||
|
||||
if (!apiKey) {
|
||||
@@ -335,6 +556,18 @@ export async function searchPlaces(userId: number, query: string, lang?: string)
|
||||
return { places, source: 'openstreetmap' };
|
||||
}
|
||||
|
||||
const searchBody: Record<string, unknown> = { textQuery: query, languageCode: toApiLang(lang) };
|
||||
// Bias results toward the caller's area when supplied — without it Google Text
|
||||
// Search falls back to the API key's billing region, which skews foreign-region queries.
|
||||
if (locationBias) {
|
||||
searchBody.locationBias = {
|
||||
circle: {
|
||||
center: { latitude: locationBias.lat, longitude: locationBias.lng },
|
||||
radius: locationBias.radius ?? 50000,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const response = await googleFetch('https://places.googleapis.com/v1/places:searchText', 'searchText', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -342,7 +575,7 @@ export async function searchPlaces(userId: number, query: string, lang?: string)
|
||||
'X-Goog-Api-Key': apiKey,
|
||||
'X-Goog-FieldMask': 'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types',
|
||||
},
|
||||
body: JSON.stringify({ textQuery: query, languageCode: lang || 'en' }),
|
||||
body: JSON.stringify(searchBody),
|
||||
});
|
||||
|
||||
const data = await response.json() as { places?: GooglePlaceResult[]; error?: { message?: string } };
|
||||
@@ -362,6 +595,7 @@ export async function searchPlaces(userId: number, query: string, lang?: string)
|
||||
rating: p.rating || null,
|
||||
website: p.websiteUri || null,
|
||||
phone: p.nationalPhoneNumber || null,
|
||||
types: p.types || [],
|
||||
source: 'google',
|
||||
}));
|
||||
|
||||
@@ -384,7 +618,7 @@ export async function autocompletePlaces(
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
input,
|
||||
languageCode: lang || 'en',
|
||||
languageCode: toApiLang(lang),
|
||||
};
|
||||
if (locationBias) {
|
||||
body.locationBias = {
|
||||
@@ -475,7 +709,7 @@ export async function getPlaceDetails(userId: number, placeId: string, lang?: st
|
||||
}
|
||||
|
||||
// Google details
|
||||
const langKey = lang || 'de';
|
||||
const langKey = toApiLang(lang, 'de');
|
||||
const apiKey = getMapsKey(userId);
|
||||
if (!apiKey) {
|
||||
throw Object.assign(new Error('Google Maps API key not configured'), { status: 400 });
|
||||
@@ -535,7 +769,7 @@ export async function getPlaceDetails(userId: number, placeId: string, lang?: st
|
||||
}
|
||||
|
||||
export async function getPlaceDetailsExpanded(userId: number, placeId: string, lang?: string, refresh = false): Promise<{ place: Record<string, unknown> }> {
|
||||
const langKey = lang || 'de';
|
||||
const langKey = toApiLang(lang, 'de');
|
||||
const apiKey = getMapsKey(userId);
|
||||
if (!apiKey) throw Object.assign(new Error('Google Maps API key not configured'), { status: 400 });
|
||||
|
||||
@@ -631,90 +865,93 @@ export async function getPlacePhoto(
|
||||
const apiKey = getMapsKey(userId);
|
||||
const isCoordLookup = placeId.startsWith('coords:');
|
||||
|
||||
// No Google key or coordinate-only lookup → try Wikimedia (URL-based, not byte-cached)
|
||||
if (!apiKey || isCoordLookup) {
|
||||
if (!isNaN(lat) && !isNaN(lng)) {
|
||||
try {
|
||||
const wiki = await fetchWikimediaPhoto(lat, lng, name);
|
||||
if (wiki) {
|
||||
// Wikimedia photos: fetch bytes and cache to disk
|
||||
const ssrf = await checkSsrf(wiki.photoUrl, true);
|
||||
if (!ssrf.allowed) throw Object.assign(new Error('Photo URL blocked'), { status: 403 });
|
||||
const imgRes = await fetch(wiki.photoUrl);
|
||||
if (imgRes.ok) {
|
||||
const bytes = Buffer.from(await imgRes.arrayBuffer());
|
||||
const cached = await placePhotoCache.put(placeId, bytes, wiki.attribution);
|
||||
return { filePath: cached.filePath, attribution: cached.attribution };
|
||||
}
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
// Coordinate-based Wikipedia/Wikimedia lookup. Used for coordinate-only
|
||||
// (right-click) places and as a fallback when a Google place yields no photo,
|
||||
// so a place added via search still gets a marker image when Google returns
|
||||
// nothing. Returns null (without marking an error) so the caller decides.
|
||||
const fetchWikimediaFallback = async (): Promise<{ filePath: string; attribution: string | null } | null> => {
|
||||
if (isNaN(lat) || isNaN(lng)) return null;
|
||||
try {
|
||||
const wiki = await fetchWikimediaPhoto(lat, lng, name);
|
||||
if (!wiki) return null;
|
||||
// Follow redirects manually so each hop (the image URL can 3xx to a CDN
|
||||
// host) is re-validated against the SSRF guard, not just the first URL.
|
||||
const imgRes = await safeFetchFollow(wiki.photoUrl, undefined, { bypassInternalIpAllowed: true });
|
||||
if (!imgRes.ok) return null;
|
||||
const bytes = Buffer.from(await imgRes.arrayBuffer());
|
||||
const cached = await placePhotoCache.put(placeId, bytes, wiki.attribution);
|
||||
return { filePath: cached.filePath, attribution: cached.attribution };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
placePhotoCache.markError(placeId);
|
||||
return null;
|
||||
};
|
||||
|
||||
// Google Places photo for a Google place_id. Returns null (without marking an
|
||||
// error) on any miss — no key, URL-shaped id, request rejected, no photos, or
|
||||
// a failed media download — so the caller can fall back to Wikimedia.
|
||||
const fetchGooglePhoto = async (): Promise<{ filePath: string; attribution: string | null } | null> => {
|
||||
// URL-shaped placeIds aren't Google IDs — legacy DBs may store raw photo URLs in image_url
|
||||
if (!apiKey || /^https?:\/\//i.test(placeId)) return null;
|
||||
|
||||
// Fetch details to get the photo name
|
||||
const detailsRes = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}`, `getPlacePhoto/details(${placeId})`, {
|
||||
headers: {
|
||||
'X-Goog-Api-Key': apiKey,
|
||||
'X-Goog-FieldMask': 'photos',
|
||||
},
|
||||
});
|
||||
const body = await detailsRes.text();
|
||||
if (!detailsRes.ok) {
|
||||
console.error('Google Places photo details error:', detailsRes.status, body.slice(0, 200));
|
||||
return null;
|
||||
}
|
||||
let details: GooglePlaceDetails & { error?: { message?: string } };
|
||||
try { details = body ? JSON.parse(body) : { photos: [] }; }
|
||||
catch { return null; }
|
||||
if (!details.photos?.length) return null;
|
||||
|
||||
const photo = details.photos[0];
|
||||
const photoName = photo.name;
|
||||
const attribution = photo.authorAttributions?.[0]?.displayName || null;
|
||||
|
||||
// Fetch actual image bytes
|
||||
const mediaRes = await googleFetch(
|
||||
`https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=400`,
|
||||
`getPlacePhoto/media(${placeId})`,
|
||||
{ headers: { 'X-Goog-Api-Key': apiKey } }
|
||||
);
|
||||
if (!mediaRes.ok) return null;
|
||||
|
||||
const bytes = Buffer.from(await mediaRes.arrayBuffer());
|
||||
if (!bytes.length) return null;
|
||||
|
||||
const cached = await placePhotoCache.put(placeId, bytes, attribution);
|
||||
|
||||
// Persist stable proxy URL to database
|
||||
try {
|
||||
db.prepare(
|
||||
'UPDATE places SET image_url = ?, updated_at = CURRENT_TIMESTAMP WHERE google_place_id = ? AND (image_url IS NULL OR image_url = \'\')'
|
||||
).run(cached.photoUrl, placeId);
|
||||
} catch (dbErr) {
|
||||
console.error('Failed to persist photo URL to database:', dbErr);
|
||||
}
|
||||
|
||||
return { filePath: cached.filePath, attribution };
|
||||
};
|
||||
|
||||
// Prefer the Google photo (higher quality); if Google yields nothing, fall
|
||||
// back to the same coordinate-based Wikipedia/OSM lookup that right-click
|
||||
// places use. Coordinate-only ids skip Google entirely.
|
||||
if (!isCoordLookup) {
|
||||
const googlePhoto = await fetchGooglePhoto();
|
||||
if (googlePhoto) return googlePhoto;
|
||||
}
|
||||
|
||||
// Reject URL-shaped placeIds — legacy DBs may store raw photo URLs in image_url
|
||||
if (/^https?:\/\//i.test(placeId)) {
|
||||
placePhotoCache.markError(placeId);
|
||||
return null;
|
||||
}
|
||||
const fallback = await fetchWikimediaFallback();
|
||||
if (fallback) return fallback;
|
||||
|
||||
// Google Photos — fetch details to get photo name
|
||||
const detailsRes = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}`, `getPlacePhoto/details(${placeId})`, {
|
||||
headers: {
|
||||
'X-Goog-Api-Key': apiKey,
|
||||
'X-Goog-FieldMask': 'photos',
|
||||
},
|
||||
});
|
||||
const body = await detailsRes.text();
|
||||
if (!detailsRes.ok) {
|
||||
console.error('Google Places photo details error:', detailsRes.status, body.slice(0, 200));
|
||||
placePhotoCache.markError(placeId);
|
||||
return null;
|
||||
}
|
||||
let details: GooglePlaceDetails & { error?: { message?: string } };
|
||||
try { details = body ? JSON.parse(body) : { photos: [] }; }
|
||||
catch { placePhotoCache.markError(placeId); return null; }
|
||||
|
||||
if (!details.photos?.length) {
|
||||
placePhotoCache.markError(placeId);
|
||||
return null;
|
||||
}
|
||||
|
||||
const photo = details.photos[0];
|
||||
const photoName = photo.name;
|
||||
const attribution = photo.authorAttributions?.[0]?.displayName || null;
|
||||
|
||||
// Fetch actual image bytes
|
||||
const mediaRes = await googleFetch(
|
||||
`https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=400`,
|
||||
`getPlacePhoto/media(${placeId})`,
|
||||
{ headers: { 'X-Goog-Api-Key': apiKey } }
|
||||
);
|
||||
|
||||
if (!mediaRes.ok) {
|
||||
placePhotoCache.markError(placeId);
|
||||
return null;
|
||||
}
|
||||
|
||||
const bytes = Buffer.from(await mediaRes.arrayBuffer());
|
||||
if (!bytes.length) {
|
||||
placePhotoCache.markError(placeId);
|
||||
return null;
|
||||
}
|
||||
|
||||
const cached = await placePhotoCache.put(placeId, bytes, attribution);
|
||||
|
||||
// Persist stable proxy URL to database
|
||||
try {
|
||||
db.prepare(
|
||||
'UPDATE places SET image_url = ?, updated_at = CURRENT_TIMESTAMP WHERE google_place_id = ? AND (image_url IS NULL OR image_url = \'\')'
|
||||
).run(cached.photoUrl, placeId);
|
||||
} catch (dbErr) {
|
||||
console.error('Failed to persist photo URL to database:', dbErr);
|
||||
}
|
||||
|
||||
return { filePath: cached.filePath, attribution };
|
||||
placePhotoCache.markError(placeId);
|
||||
return null;
|
||||
} finally {
|
||||
releasePhotoFetchSlot();
|
||||
}
|
||||
@@ -732,7 +969,7 @@ export async function getPlacePhoto(
|
||||
export async function reverseGeocode(lat: string, lng: string, lang?: string): Promise<{ name: string | null; address: string | null }> {
|
||||
const params = new URLSearchParams({
|
||||
lat, lon: lng, format: 'json', addressdetails: '1', zoom: '18',
|
||||
'accept-language': lang || 'en',
|
||||
'accept-language': toApiLang(lang),
|
||||
});
|
||||
const response = await fetch(`https://nominatim.openstreetmap.org/reverse?${params}`, {
|
||||
headers: { 'User-Agent': UA },
|
||||
@@ -749,13 +986,25 @@ export async function reverseGeocode(lat: string, lng: string, lang?: string): P
|
||||
export async function resolveGoogleMapsUrl(url: string): Promise<{ lat: number; lng: number; name: string | null; address: string | null }> {
|
||||
let resolvedUrl = url;
|
||||
|
||||
// Follow redirects for short URLs (goo.gl, maps.app.goo.gl) with SSRF protection
|
||||
// Follow redirects for short URLs (goo.gl, maps.app.goo.gl) with SSRF protection.
|
||||
// Redirects are followed manually so every hop is re-checked — a short link
|
||||
// that 302s to an internal IP is blocked, while a legitimate cross-host
|
||||
// redirect (goo.gl → maps.google.com) still resolves.
|
||||
const parsed = new URL(url);
|
||||
if (['goo.gl', 'maps.app.goo.gl'].includes(parsed.hostname)) {
|
||||
const ssrf = await checkSsrf(url, true);
|
||||
if (!ssrf.allowed) throw Object.assign(new Error('URL blocked by SSRF check'), { status: 403 });
|
||||
const redirectRes = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(10000) });
|
||||
resolvedUrl = redirectRes.url;
|
||||
try {
|
||||
const redirectRes = await safeFetchFollow(
|
||||
url,
|
||||
{ signal: AbortSignal.timeout(10000) },
|
||||
{ bypassInternalIpAllowed: true },
|
||||
);
|
||||
resolvedUrl = redirectRes.url;
|
||||
} catch (err) {
|
||||
if (err instanceof SsrfBlockedError) {
|
||||
throw Object.assign(new Error('URL blocked by SSRF check'), { status: 403 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract coordinates from Google Maps URL patterns:
|
||||
|
||||
@@ -349,42 +349,6 @@ export async function getAlbumPhotos(
|
||||
}
|
||||
}
|
||||
|
||||
export function listAlbumLinks(tripId: string) {
|
||||
return db.prepare(`
|
||||
SELECT tal.*, u.username
|
||||
FROM trip_album_links tal
|
||||
JOIN users u ON tal.user_id = u.id
|
||||
WHERE tal.trip_id = ?
|
||||
ORDER BY tal.created_at ASC
|
||||
`).all(tripId);
|
||||
}
|
||||
|
||||
export function createAlbumLink(
|
||||
tripId: string,
|
||||
userId: number,
|
||||
albumId: string,
|
||||
albumName: string
|
||||
): { success: boolean; error?: string } {
|
||||
try {
|
||||
db.prepare(
|
||||
"INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, album_id, album_name, provider) VALUES (?, ?, ?, ?, 'immich')"
|
||||
).run(tripId, userId, albumId, albumName || '');
|
||||
return { success: true };
|
||||
} catch {
|
||||
return { success: false, error: 'Album already linked' };
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteAlbumLink(linkId: string, tripId: string, userId: number) {
|
||||
db.transaction(() => {
|
||||
const link = db.prepare('SELECT id FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?').get(linkId, tripId, userId);
|
||||
if (link) {
|
||||
db.prepare('DELETE FROM trip_photos WHERE trip_id = ? AND album_link_id = ?').run(tripId, linkId);
|
||||
db.prepare('DELETE FROM trip_album_links WHERE id = ?').run(linkId);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
export async function syncAlbumAssets(
|
||||
tripId: string,
|
||||
linkId: string,
|
||||
|
||||
@@ -230,10 +230,3 @@ export function deleteTrekPhotoIfOrphan(photoId: number): void {
|
||||
db.prepare("DELETE FROM trek_photos WHERE id = ? AND provider != 'local'").run(photoId);
|
||||
}
|
||||
|
||||
// ── Delete local file for a trek_photo ──────────────────────────────────
|
||||
|
||||
export function getTrekPhotoFilePath(photoId: number): string | null {
|
||||
const photo = resolveTrekPhoto(photoId);
|
||||
if (!photo || photo.provider !== 'local' || !photo.file_path) return null;
|
||||
return path.join(__dirname, '../../../uploads', photo.file_path);
|
||||
}
|
||||
|
||||
@@ -458,11 +458,12 @@ export async function listSynologyAlbums(userId: number): Promise<ServiceResult<
|
||||
|
||||
const addAlbums = (result: PromiseSettledResult<ServiceResult<any[]>>, extractPassphrase: (a: any) => string | undefined) => {
|
||||
if (result.status === 'rejected') return;
|
||||
if (!result.value.success) {
|
||||
console.warn('[Synology] album list partial failure:', (result.value as any).error?.message);
|
||||
const value = result.value;
|
||||
if ('error' in value) {
|
||||
console.warn('[Synology] album list partial failure:', value.error.message);
|
||||
return;
|
||||
}
|
||||
for (const album of result.value.data ?? []) {
|
||||
for (const album of value.data ?? []) {
|
||||
const id = String(album.id);
|
||||
const passphrase = extractPassphrase(album);
|
||||
map.set(id, { id, albumName: album.name || '', assetCount: album.item_count || 0, passphrase });
|
||||
|
||||
@@ -299,7 +299,7 @@ async function _notifySharedTripPhotos(
|
||||
actorUserId: number,
|
||||
added: number,
|
||||
): Promise<ServiceResult<void>> {
|
||||
if (added <= 0) return fail('No photos shared, skipping notifications', 200);
|
||||
if (added <= 0) return success(undefined);
|
||||
|
||||
try {
|
||||
const actorRow = db.prepare('SELECT username, email FROM users WHERE id = ?').get(actorUserId) as { username: string | null, email: string | null };
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import { db } from '../db/database';
|
||||
import { decrypt_api_key } from './apiKeyCrypto';
|
||||
|
||||
@@ -187,8 +188,8 @@ function setAdminGlobalPref(event: NotifEventType, channel: 'email' | 'webhook'
|
||||
function applyUserChannelPrefs(
|
||||
userId: number,
|
||||
prefs: Partial<Record<string, Partial<Record<string, boolean>>>>,
|
||||
upsert: ReturnType<typeof db.prepare>,
|
||||
del: ReturnType<typeof db.prepare>
|
||||
upsert: Database.Statement<unknown[]>,
|
||||
del: Database.Statement<unknown[]>
|
||||
): void {
|
||||
for (const [eventType, channels] of Object.entries(prefs)) {
|
||||
if (!channels) continue;
|
||||
|
||||
@@ -7,6 +7,13 @@ import { checkSsrf, createPinnedDispatcher } from '../utils/ssrfGuard';
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
import type { NotifEventType } from './notificationPreferencesService';
|
||||
import { EMAIL_I18N as I18N, EVENT_TEXTS, PASSWORD_RESET_I18N } from '@trek/shared/i18n/externalNotifications';
|
||||
import type { EmailStrings, EventText, PasswordResetStrings, NotificationEventKey } from '@trek/shared/i18n/externalNotifications';
|
||||
|
||||
// Compile-time guard: shared NotificationEventKey and server NotifEventType must stay in sync.
|
||||
type _EvtFwd = NotifEventType extends NotificationEventKey ? true : never
|
||||
type _EvtBwd = NotificationEventKey extends NotifEventType ? true : never
|
||||
const _eventKeyDriftGuard: [_EvtFwd, _EvtBwd] = [true, true]
|
||||
|
||||
interface SmtpConfig {
|
||||
host: string;
|
||||
@@ -103,208 +110,9 @@ export function getAdminWebhookUrl(): string | null {
|
||||
return value ? decrypt_api_key(value) : null;
|
||||
}
|
||||
|
||||
// ── Email i18n strings ─────────────────────────────────────────────────────
|
||||
// ── Email i18n strings — imported from @trek/shared/i18n/externalNotifications ──
|
||||
|
||||
interface EmailStrings { footer: string; manage: string; madeWith: string; openTrek: string }
|
||||
|
||||
const I18N: Record<string, EmailStrings> = {
|
||||
en: { footer: 'You received this because you have notifications enabled in TREK.', manage: 'Manage preferences in Settings', madeWith: 'Made with', openTrek: 'Open TREK' },
|
||||
de: { footer: 'Du erhältst diese E-Mail, weil du Benachrichtigungen in TREK aktiviert hast.', manage: 'Einstellungen verwalten', madeWith: 'Made with', openTrek: 'TREK öffnen' },
|
||||
fr: { footer: 'Vous recevez cet e-mail car les notifications sont activées dans TREK.', manage: 'Gérer les préférences', madeWith: 'Made with', openTrek: 'Ouvrir TREK' },
|
||||
es: { footer: 'Recibiste esto porque tienes las notificaciones activadas en TREK.', manage: 'Gestionar preferencias', madeWith: 'Made with', openTrek: 'Abrir TREK' },
|
||||
nl: { footer: 'Je ontvangt dit omdat je meldingen hebt ingeschakeld in TREK.', manage: 'Voorkeuren beheren', madeWith: 'Made with', openTrek: 'TREK openen' },
|
||||
ru: { footer: 'Вы получили это, потому что у вас включены уведомления в TREK.', manage: 'Управление настройками', madeWith: 'Made with', openTrek: 'Открыть TREK' },
|
||||
zh: { footer: '您收到此邮件是因为您在 TREK 中启用了通知。', manage: '管理偏好设置', madeWith: 'Made with', openTrek: '打开 TREK' },
|
||||
'zh-TW': { footer: '您收到這封郵件是因為您在 TREK 中啟用了通知。', manage: '管理偏好設定', madeWith: 'Made with', openTrek: '開啟 TREK' },
|
||||
ar: { footer: 'تلقيت هذا لأنك قمت بتفعيل الإشعارات في TREK.', manage: 'إدارة التفضيلات', madeWith: 'Made with', openTrek: 'فتح TREK' },
|
||||
id: { footer: 'Anda menerima ini karena Anda telah mengaktifkan notifikasi di TREK.', manage: 'Kelola preferensi di Pengaturan', madeWith: 'Dibuat dengan', openTrek: 'Buka TREK' },
|
||||
};
|
||||
|
||||
// Translated notification texts per event type
|
||||
interface EventText { title: string; body: string }
|
||||
type EventTextFn = (params: Record<string, string>) => EventText
|
||||
|
||||
const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
|
||||
en: {
|
||||
trip_invite: p => ({ title: `Trip invite: "${p.trip}"`, body: `${p.actor} invited ${p.invitee || 'a member'} to the trip "${p.trip}".` }),
|
||||
booking_change: p => ({ title: `New booking: ${p.booking}`, body: `${p.actor} added a new ${p.type} "${p.booking}" to "${p.trip}".` }),
|
||||
trip_reminder: p => ({ title: `Trip reminder: ${p.trip}`, body: `Your trip "${p.trip}" is coming up soon!` }),
|
||||
todo_due: p => ({ title: `To-do due: ${p.todo}`, body: `"${p.todo}" in "${p.trip}" is due on ${p.due}.` }),
|
||||
vacay_invite: p => ({ title: 'Vacay Fusion Invite', body: `${p.actor} invited you to fuse vacation plans. Open TREK to accept or decline.` }),
|
||||
photos_shared: p => ({ title: `${p.count} photos shared`, body: `${p.actor} shared ${p.count} photo(s) in "${p.trip}".` }),
|
||||
collab_message: p => ({ title: `New message in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Packing: ${p.category}`, body: `${p.actor} assigned you to the "${p.category}" packing category in "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'New TREK version available', body: `TREK ${p.version} is now available. Visit the admin panel to update.` }),
|
||||
synology_session_cleared: () => ({ title: 'Synology session cleared', body: 'Your Synology account or URL changed. You have been logged out of Synology Photos.' }),
|
||||
},
|
||||
de: {
|
||||
trip_invite: p => ({ title: `Einladung zu "${p.trip}"`, body: `${p.actor} hat ${p.invitee || 'ein Mitglied'} zur Reise "${p.trip}" eingeladen.` }),
|
||||
booking_change: p => ({ title: `Neue Buchung: ${p.booking}`, body: `${p.actor} hat eine neue Buchung "${p.booking}" (${p.type}) zu "${p.trip}" hinzugefügt.` }),
|
||||
trip_reminder: p => ({ title: `Reiseerinnerung: ${p.trip}`, body: `Deine Reise "${p.trip}" steht bald an!` }),
|
||||
todo_due: p => ({ title: `Aufgabe fällig: ${p.todo}`, body: `"${p.todo}" in "${p.trip}" ist am ${p.due} fällig.` }),
|
||||
vacay_invite: p => ({ title: 'Vacay Fusion-Einladung', body: `${p.actor} hat dich eingeladen, Urlaubspläne zu fusionieren. Öffne TREK um anzunehmen oder abzulehnen.` }),
|
||||
photos_shared: p => ({ title: `${p.count} Fotos geteilt`, body: `${p.actor} hat ${p.count} Foto(s) in "${p.trip}" geteilt.` }),
|
||||
collab_message: p => ({ title: `Neue Nachricht in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Packliste: ${p.category}`, body: `${p.actor} hat dich der Kategorie "${p.category}" in der Packliste von "${p.trip}" zugewiesen.` }),
|
||||
version_available: p => ({ title: 'Neue TREK-Version verfügbar', body: `TREK ${p.version} ist jetzt verfügbar. Besuche das Admin-Panel zum Aktualisieren.` }),
|
||||
synology_session_cleared: () => ({ title: 'Synology-Sitzung beendet', body: 'Dein Synology-Konto oder die URL hat sich geändert. Du wurdest von Synology Photos abgemeldet.' }),
|
||||
},
|
||||
fr: {
|
||||
trip_invite: p => ({ title: `Invitation à "${p.trip}"`, body: `${p.actor} a invité ${p.invitee || 'un membre'} au voyage "${p.trip}".` }),
|
||||
booking_change: p => ({ title: `Nouvelle réservation : ${p.booking}`, body: `${p.actor} a ajouté une réservation "${p.booking}" (${p.type}) à "${p.trip}".` }),
|
||||
trip_reminder: p => ({ title: `Rappel de voyage : ${p.trip}`, body: `Votre voyage "${p.trip}" approche !` }),
|
||||
todo_due: p => ({ title: `Tâche à échéance : ${p.todo}`, body: `"${p.todo}" dans "${p.trip}" est due le ${p.due}.` }),
|
||||
vacay_invite: p => ({ title: 'Invitation Vacay Fusion', body: `${p.actor} vous invite à fusionner les plans de vacances. Ouvrez TREK pour accepter ou refuser.` }),
|
||||
photos_shared: p => ({ title: `${p.count} photos partagées`, body: `${p.actor} a partagé ${p.count} photo(s) dans "${p.trip}".` }),
|
||||
collab_message: p => ({ title: `Nouveau message dans "${p.trip}"`, body: `${p.actor} : ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Bagages : ${p.category}`, body: `${p.actor} vous a assigné à la catégorie "${p.category}" dans "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'Nouvelle version TREK disponible', body: `TREK ${p.version} est maintenant disponible. Rendez-vous dans le panneau d'administration pour mettre à jour.` }),
|
||||
synology_session_cleared: () => ({ title: 'Session Synology effacée', body: 'Votre compte ou URL Synology a changé. Vous avez été déconnecté de Synology Photos.' }),
|
||||
},
|
||||
es: {
|
||||
trip_invite: p => ({ title: `Invitación a "${p.trip}"`, body: `${p.actor} invitó a ${p.invitee || 'un miembro'} al viaje "${p.trip}".` }),
|
||||
booking_change: p => ({ title: `Nueva reserva: ${p.booking}`, body: `${p.actor} añadió una reserva "${p.booking}" (${p.type}) a "${p.trip}".` }),
|
||||
trip_reminder: p => ({ title: `Recordatorio: ${p.trip}`, body: `¡Tu viaje "${p.trip}" se acerca!` }),
|
||||
todo_due: p => ({ title: `Tarea pendiente: ${p.todo}`, body: `"${p.todo}" en "${p.trip}" vence el ${p.due}.` }),
|
||||
vacay_invite: p => ({ title: 'Invitación Vacay Fusion', body: `${p.actor} te invitó a fusionar planes de vacaciones. Abre TREK para aceptar o rechazar.` }),
|
||||
photos_shared: p => ({ title: `${p.count} fotos compartidas`, body: `${p.actor} compartió ${p.count} foto(s) en "${p.trip}".` }),
|
||||
collab_message: p => ({ title: `Nuevo mensaje en "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Equipaje: ${p.category}`, body: `${p.actor} te asignó a la categoría "${p.category}" en "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'Nueva versión de TREK disponible', body: `TREK ${p.version} ya está disponible. Visita el panel de administración para actualizar.` }),
|
||||
synology_session_cleared: () => ({ title: 'Sesión de Synology cerrada', body: 'Tu cuenta o URL de Synology ha cambiado. Has cerrado sesión en Synology Photos.' }),
|
||||
},
|
||||
nl: {
|
||||
trip_invite: p => ({ title: `Uitnodiging voor "${p.trip}"`, body: `${p.actor} heeft ${p.invitee || 'een lid'} uitgenodigd voor de reis "${p.trip}".` }),
|
||||
booking_change: p => ({ title: `Nieuwe boeking: ${p.booking}`, body: `${p.actor} heeft een boeking "${p.booking}" (${p.type}) toegevoegd aan "${p.trip}".` }),
|
||||
trip_reminder: p => ({ title: `Reisherinnering: ${p.trip}`, body: `Je reis "${p.trip}" komt eraan!` }),
|
||||
todo_due: p => ({ title: `Taak verloopt: ${p.todo}`, body: `"${p.todo}" in "${p.trip}" verloopt op ${p.due}.` }),
|
||||
vacay_invite: p => ({ title: 'Vacay Fusion uitnodiging', body: `${p.actor} nodigt je uit om vakantieplannen te fuseren. Open TREK om te accepteren of af te wijzen.` }),
|
||||
photos_shared: p => ({ title: `${p.count} foto's gedeeld`, body: `${p.actor} heeft ${p.count} foto('s) gedeeld in "${p.trip}".` }),
|
||||
collab_message: p => ({ title: `Nieuw bericht in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Paklijst: ${p.category}`, body: `${p.actor} heeft je toegewezen aan de categorie "${p.category}" in "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'Nieuwe TREK-versie beschikbaar', body: `TREK ${p.version} is nu beschikbaar. Bezoek het beheerderspaneel om bij te werken.` }),
|
||||
synology_session_cleared: () => ({ title: 'Synology-sessie gewist', body: 'Je Synology-account of URL is gewijzigd. Je bent uitgelogd bij Synology Photos.' }),
|
||||
},
|
||||
ru: {
|
||||
trip_invite: p => ({ title: `Приглашение в "${p.trip}"`, body: `${p.actor} пригласил ${p.invitee || 'участника'} в поездку "${p.trip}".` }),
|
||||
booking_change: p => ({ title: `Новое бронирование: ${p.booking}`, body: `${p.actor} добавил бронирование "${p.booking}" (${p.type}) в "${p.trip}".` }),
|
||||
trip_reminder: p => ({ title: `Напоминание: ${p.trip}`, body: `Ваша поездка "${p.trip}" скоро начнётся!` }),
|
||||
todo_due: p => ({ title: `Задача к сроку: ${p.todo}`, body: `"${p.todo}" в поездке "${p.trip}" — срок ${p.due}.` }),
|
||||
vacay_invite: p => ({ title: 'Приглашение Vacay Fusion', body: `${p.actor} приглашает вас объединить планы отпуска. Откройте TREK для подтверждения.` }),
|
||||
photos_shared: p => ({ title: `${p.count} фото`, body: `${p.actor} поделился ${p.count} фото в "${p.trip}".` }),
|
||||
collab_message: p => ({ title: `Новое сообщение в "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Список вещей: ${p.category}`, body: `${p.actor} назначил вас в категорию "${p.category}" в "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'Доступна новая версия TREK', body: `TREK ${p.version} теперь доступен. Перейдите в панель администратора для обновления.` }),
|
||||
synology_session_cleared: () => ({ title: 'Сессия Synology сброшена', body: 'Ваш аккаунт или URL Synology изменился. Вы вышли из Synology Photos.' }),
|
||||
},
|
||||
zh: {
|
||||
trip_invite: p => ({ title: `邀请加入"${p.trip}"`, body: `${p.actor} 邀请了 ${p.invitee || '成员'} 加入旅行"${p.trip}"。` }),
|
||||
booking_change: p => ({ title: `新预订:${p.booking}`, body: `${p.actor} 在"${p.trip}"中添加了预订"${p.booking}"(${p.type})。` }),
|
||||
trip_reminder: p => ({ title: `旅行提醒:${p.trip}`, body: `你的旅行"${p.trip}"即将开始!` }),
|
||||
todo_due: p => ({ title: `待办事项即将到期:${p.todo}`, body: `"${p.trip}" 中的"${p.todo}"将于 ${p.due} 到期。` }),
|
||||
vacay_invite: p => ({ title: 'Vacay 融合邀请', body: `${p.actor} 邀请你合并假期计划。打开 TREK 接受或拒绝。` }),
|
||||
photos_shared: p => ({ title: `${p.count} 张照片已分享`, body: `${p.actor} 在"${p.trip}"中分享了 ${p.count} 张照片。` }),
|
||||
collab_message: p => ({ title: `"${p.trip}"中的新消息`, body: `${p.actor}:${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `行李清单:${p.category}`, body: `${p.actor} 将你分配到"${p.trip}"中的"${p.category}"类别。` }),
|
||||
version_available: p => ({ title: '新版 TREK 可用', body: `TREK ${p.version} 现已可用。请前往管理面板进行更新。` }),
|
||||
synology_session_cleared: () => ({ title: 'Synology 会话已清除', body: '您的 Synology 账户或 URL 已更改,您已退出 Synology Photos。' }),
|
||||
},
|
||||
'zh-TW': {
|
||||
trip_invite: p => ({ title: `邀請加入「${p.trip}」`, body: `${p.actor} 邀請了 ${p.invitee || '成員'} 加入行程「${p.trip}」。` }),
|
||||
booking_change: p => ({ title: `新預訂:${p.booking}`, body: `${p.actor} 在「${p.trip}」中新增了預訂「${p.booking}」(${p.type})。` }),
|
||||
trip_reminder: p => ({ title: `行程提醒:${p.trip}`, body: `您的行程「${p.trip}」即將開始!` }),
|
||||
todo_due: p => ({ title: `待辦事項即將到期:${p.todo}`, body: `「${p.trip}」中的「${p.todo}」將於 ${p.due} 到期。` }),
|
||||
vacay_invite: p => ({ title: 'Vacay 融合邀請', body: `${p.actor} 邀請您合併假期計畫。開啟 TREK 以接受或拒絕。` }),
|
||||
photos_shared: p => ({ title: `已分享 ${p.count} 張照片`, body: `${p.actor} 在「${p.trip}」中分享了 ${p.count} 張照片。` }),
|
||||
collab_message: p => ({ title: `「${p.trip}」中的新訊息`, body: `${p.actor}:${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `打包清單:${p.category}`, body: `${p.actor} 已將您指派到「${p.trip}」中的「${p.category}」分類。` }),
|
||||
version_available: p => ({ title: '新版 TREK 可用', body: `TREK ${p.version} 現已可用。請前往管理面板進行更新。` }),
|
||||
synology_session_cleared: () => ({ title: 'Synology 工作階段已清除', body: '您的 Synology 帳戶或 URL 已變更,您已登出 Synology Photos。' }),
|
||||
},
|
||||
ar: {
|
||||
trip_invite: p => ({ title: `دعوة إلى "${p.trip}"`, body: `${p.actor} دعا ${p.invitee || 'عضو'} إلى الرحلة "${p.trip}".` }),
|
||||
booking_change: p => ({ title: `حجز جديد: ${p.booking}`, body: `${p.actor} أضاف حجز "${p.booking}" (${p.type}) إلى "${p.trip}".` }),
|
||||
trip_reminder: p => ({ title: `تذكير: ${p.trip}`, body: `رحلتك "${p.trip}" تقترب!` }),
|
||||
todo_due: p => ({ title: `مهمة مستحقة: ${p.todo}`, body: `"${p.todo}" في "${p.trip}" مستحقة في ${p.due}.` }),
|
||||
vacay_invite: p => ({ title: 'دعوة دمج الإجازة', body: `${p.actor} يدعوك لدمج خطط الإجازة. افتح TREK للقبول أو الرفض.` }),
|
||||
photos_shared: p => ({ title: `${p.count} صور مشتركة`, body: `${p.actor} شارك ${p.count} صورة في "${p.trip}".` }),
|
||||
collab_message: p => ({ title: `رسالة جديدة في "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `قائمة التعبئة: ${p.category}`, body: `${p.actor} عيّنك في فئة "${p.category}" في "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'إصدار TREK جديد متاح', body: `TREK ${p.version} متاح الآن. تفضل بزيارة لوحة الإدارة للتحديث.` }),
|
||||
synology_session_cleared: () => ({ title: 'تمت إعادة تعيين جلسة Synology', body: 'تغيّر حسابك أو رابط Synology. تم تسجيل خروجك من Synology Photos.' }),
|
||||
},
|
||||
br: {
|
||||
trip_invite: p => ({ title: `Convite para "${p.trip}"`, body: `${p.actor} convidou ${p.invitee || 'um membro'} para a viagem "${p.trip}".` }),
|
||||
booking_change: p => ({ title: `Nova reserva: ${p.booking}`, body: `${p.actor} adicionou uma reserva "${p.booking}" (${p.type}) em "${p.trip}".` }),
|
||||
trip_reminder: p => ({ title: `Lembrete: ${p.trip}`, body: `Sua viagem "${p.trip}" está chegando!` }),
|
||||
todo_due: p => ({ title: `Tarefa com vencimento: ${p.todo}`, body: `"${p.todo}" em "${p.trip}" vence em ${p.due}.` }),
|
||||
vacay_invite: p => ({ title: 'Convite Vacay Fusion', body: `${p.actor} convidou você para fundir planos de férias. Abra o TREK para aceitar ou recusar.` }),
|
||||
photos_shared: p => ({ title: `${p.count} fotos compartilhadas`, body: `${p.actor} compartilhou ${p.count} foto(s) em "${p.trip}".` }),
|
||||
collab_message: p => ({ title: `Nova mensagem em "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Bagagem: ${p.category}`, body: `${p.actor} atribuiu você à categoria "${p.category}" em "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'Nova versão do TREK disponível', body: `O TREK ${p.version} está disponível. Acesse o painel de administração para atualizar.` }),
|
||||
synology_session_cleared: () => ({ title: 'Sessão Synology encerrada', body: 'Sua conta ou URL do Synology foi alterada. Você foi desconectado do Synology Photos.' }),
|
||||
},
|
||||
cs: {
|
||||
trip_invite: p => ({ title: `Pozvánka do "${p.trip}"`, body: `${p.actor} pozval ${p.invitee || 'člena'} na výlet "${p.trip}".` }),
|
||||
booking_change: p => ({ title: `Nová rezervace: ${p.booking}`, body: `${p.actor} přidal rezervaci "${p.booking}" (${p.type}) k "${p.trip}".` }),
|
||||
trip_reminder: p => ({ title: `Připomínka výletu: ${p.trip}`, body: `Váš výlet "${p.trip}" se blíží!` }),
|
||||
todo_due: p => ({ title: `Úkol se blíží: ${p.todo}`, body: `"${p.todo}" ve výletě "${p.trip}" má termín ${p.due}.` }),
|
||||
vacay_invite: p => ({ title: 'Pozvánka Vacay Fusion', body: `${p.actor} vás pozval ke spojení dovolenkových plánů. Otevřete TREK pro přijetí nebo odmítnutí.` }),
|
||||
photos_shared: p => ({ title: `${p.count} sdílených fotek`, body: `${p.actor} sdílel ${p.count} foto v "${p.trip}".` }),
|
||||
collab_message: p => ({ title: `Nová zpráva v "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Balení: ${p.category}`, body: `${p.actor} vás přiřadil do kategorie "${p.category}" v "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'Nová verze TREK dostupná', body: `TREK ${p.version} je nyní dostupný. Navštivte administrátorský panel pro aktualizaci.` }),
|
||||
synology_session_cleared: () => ({ title: 'Relace Synology byla zrušena', body: 'Váš účet nebo URL Synology se změnil. Byli jste odhlášeni ze Synology Photos.' }),
|
||||
},
|
||||
hu: {
|
||||
trip_invite: p => ({ title: `Meghívó a(z) "${p.trip}" utazásra`, body: `${p.actor} meghívta ${p.invitee || 'egy tagot'} a(z) "${p.trip}" utazásra.` }),
|
||||
booking_change: p => ({ title: `Új foglalás: ${p.booking}`, body: `${p.actor} hozzáadott egy "${p.booking}" (${p.type}) foglalást a(z) "${p.trip}" utazáshoz.` }),
|
||||
trip_reminder: p => ({ title: `Utazás emlékeztető: ${p.trip}`, body: `A(z) "${p.trip}" utazás hamarosan kezdődik!` }),
|
||||
todo_due: p => ({ title: `Teendő esedékes: ${p.todo}`, body: `"${p.todo}" (${p.trip}) határideje: ${p.due}.` }),
|
||||
vacay_invite: p => ({ title: 'Vacay Fusion meghívó', body: `${p.actor} meghívott a nyaralási tervek összevonásához. Nyissa meg a TREK-et az elfogadáshoz vagy elutasításhoz.` }),
|
||||
photos_shared: p => ({ title: `${p.count} fotó megosztva`, body: `${p.actor} ${p.count} fotót osztott meg a(z) "${p.trip}" utazásban.` }),
|
||||
collab_message: p => ({ title: `Új üzenet a(z) "${p.trip}" utazásban`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Csomagolás: ${p.category}`, body: `${p.actor} hozzárendelte Önt a "${p.category}" csomagolási kategóriához a(z) "${p.trip}" utazásban.` }),
|
||||
version_available: p => ({ title: 'Új TREK verzió érhető el', body: `A TREK ${p.version} elérhető. Látogasson el az adminisztrációs panelre a frissítéshez.` }),
|
||||
synology_session_cleared: () => ({ title: 'Synology munkamenet törölve', body: 'A Synology fiókja vagy URL-je megváltozott. Kijelentkeztek a Synology Photos-ból.' }),
|
||||
},
|
||||
it: {
|
||||
trip_invite: p => ({ title: `Invito a "${p.trip}"`, body: `${p.actor} ha invitato ${p.invitee || 'un membro'} al viaggio "${p.trip}".` }),
|
||||
booking_change: p => ({ title: `Nuova prenotazione: ${p.booking}`, body: `${p.actor} ha aggiunto una prenotazione "${p.booking}" (${p.type}) a "${p.trip}".` }),
|
||||
trip_reminder: p => ({ title: `Promemoria viaggio: ${p.trip}`, body: `Il tuo viaggio "${p.trip}" si avvicina!` }),
|
||||
todo_due: p => ({ title: `Attività in scadenza: ${p.todo}`, body: `"${p.todo}" in "${p.trip}" scade il ${p.due}.` }),
|
||||
vacay_invite: p => ({ title: 'Invito Vacay Fusion', body: `${p.actor} ti ha invitato a fondere i piani vacanza. Apri TREK per accettare o rifiutare.` }),
|
||||
photos_shared: p => ({ title: `${p.count} foto condivise`, body: `${p.actor} ha condiviso ${p.count} foto in "${p.trip}".` }),
|
||||
collab_message: p => ({ title: `Nuovo messaggio in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Bagagli: ${p.category}`, body: `${p.actor} ti ha assegnato alla categoria "${p.category}" in "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'Nuova versione TREK disponibile', body: `TREK ${p.version} è ora disponibile. Visita il pannello di amministrazione per aggiornare.` }),
|
||||
synology_session_cleared: () => ({ title: 'Sessione Synology rimossa', body: 'Il tuo account o URL Synology è cambiato. Sei stato disconnesso da Synology Photos.' }),
|
||||
},
|
||||
pl: {
|
||||
trip_invite: p => ({ title: `Zaproszenie do "${p.trip}"`, body: `${p.actor} zaprosił ${p.invitee || 'członka'} do podróży "${p.trip}".` }),
|
||||
booking_change: p => ({ title: `Nowa rezerwacja: ${p.booking}`, body: `${p.actor} dodał rezerwację "${p.booking}" (${p.type}) do "${p.trip}".` }),
|
||||
trip_reminder: p => ({ title: `Przypomnienie o podróży: ${p.trip}`, body: `Twoja podróż "${p.trip}" zbliża się!` }),
|
||||
todo_due: p => ({ title: `Zadanie z terminem: ${p.todo}`, body: `"${p.todo}" w "${p.trip}" — termin ${p.due}.` }),
|
||||
vacay_invite: p => ({ title: 'Zaproszenie Vacay Fusion', body: `${p.actor} zaprosił Cię do połączenia planów urlopowych. Otwórz TREK, aby zaakceptować lub odrzucić.` }),
|
||||
photos_shared: p => ({ title: `${p.count} zdjęć udostępnionych`, body: `${p.actor} udostępnił ${p.count} zdjęcie/zdjęcia w "${p.trip}".` }),
|
||||
collab_message: p => ({ title: `Nowa wiadomość w "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Pakowanie: ${p.category}`, body: `${p.actor} przypisał Cię do kategorii "${p.category}" w "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'Nowa wersja TREK dostępna', body: `TREK ${p.version} jest teraz dostępny. Odwiedź panel administracyjny, aby zaktualizować.` }),
|
||||
synology_session_cleared: () => ({ title: 'Sesja Synology wyczyszczona', body: 'Twoje konto lub URL Synology uległo zmianie. Zostałeś wylogowany z Synology Photos.' }),
|
||||
},
|
||||
id: {
|
||||
trip_invite: p => ({ title: `Undangan perjalanan: "${p.trip}"`, body: `${p.actor} mengundang ${p.invitee || 'seorang anggota'} ke perjalanan "${p.trip}".` }),
|
||||
booking_change: p => ({ title: `Pemesanan baru: ${p.booking}`, body: `${p.actor} menambahkan "${p.booking}" (${p.type}) baru ke "${p.trip}".` }),
|
||||
trip_reminder: p => ({ title: `Pengingat perjalanan: ${p.trip}`, body: `Perjalanan Anda "${p.trip}" akan segera tiba!` }),
|
||||
todo_due: p => ({ title: `Tugas jatuh tempo: ${p.todo}`, body: `"${p.todo}" di "${p.trip}" jatuh tempo pada ${p.due}.` }),
|
||||
vacay_invite: p => ({ title: 'Undangan Penggabungan Vacay', body: `${p.actor} mengundang Anda untuk menggabungkan rencana liburan. Buka TREK untuk menerima atau menolak.` }),
|
||||
photos_shared: p => ({ title: `${p.count} foto dibagikan`, body: `${p.actor} membagikan ${p.count} foto di "${p.trip}".` }),
|
||||
collab_message: p => ({ title: `Pesan baru di "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Pengepakan: ${p.category}`, body: `${p.actor} menugaskan Anda ke kategori "${p.category}" di "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'Versi TREK baru tersedia', body: `TREK ${p.version} sekarang tersedia. Kunjungi panel admin untuk memperbarui.` }),
|
||||
},
|
||||
};
|
||||
// EVENT_TEXTS imported from @trek/shared/i18n/externalNotifications
|
||||
|
||||
// Get localized event text
|
||||
export function getEventText(lang: string, event: NotifEventType, params: Record<string, string>): EventText {
|
||||
@@ -362,24 +170,7 @@ export function buildEmailHtml(subject: string, body: string, lang: string, navi
|
||||
|
||||
// ── Password reset email ───────────────────────────────────────────────────
|
||||
|
||||
interface PasswordResetStrings { subject: string; greeting: string; body: string; ctaIntro: string; expiry: string; ignore: string }
|
||||
|
||||
const PASSWORD_RESET_I18N: Record<string, PasswordResetStrings> = {
|
||||
en: { subject: 'Reset your password', greeting: 'Hi', body: 'We received a request to reset the password for your TREK account. Click the button below to set a new password.', ctaIntro: 'Reset password', expiry: 'This link expires in 60 minutes.', ignore: "If you didn't request this, you can safely ignore this email — your password won't change." },
|
||||
de: { subject: 'Passwort zurücksetzen', greeting: 'Hallo', body: 'Wir haben eine Anfrage erhalten, das Passwort für dein TREK-Konto zurückzusetzen. Klicke auf den Button unten, um ein neues Passwort festzulegen.', ctaIntro: 'Passwort zurücksetzen', expiry: 'Dieser Link ist 60 Minuten gültig.', ignore: 'Wenn du das nicht warst, ignoriere diese E-Mail — dein Passwort bleibt unverändert.' },
|
||||
fr: { subject: 'Réinitialisez votre mot de passe', greeting: 'Bonjour', body: 'Nous avons reçu une demande de réinitialisation du mot de passe de votre compte TREK. Cliquez sur le bouton ci-dessous pour définir un nouveau mot de passe.', ctaIntro: 'Réinitialiser le mot de passe', expiry: 'Ce lien expire dans 60 minutes.', ignore: "Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail — votre mot de passe ne changera pas." },
|
||||
es: { subject: 'Restablecer tu contraseña', greeting: 'Hola', body: 'Recibimos una solicitud para restablecer la contraseña de tu cuenta de TREK. Haz clic en el botón de abajo para establecer una nueva contraseña.', ctaIntro: 'Restablecer contraseña', expiry: 'Este enlace caduca en 60 minutos.', ignore: 'Si no solicitaste esto, puedes ignorar este correo — tu contraseña no cambiará.' },
|
||||
it: { subject: 'Reimposta la tua password', greeting: 'Ciao', body: 'Abbiamo ricevuto una richiesta di reimpostazione della password per il tuo account TREK. Clicca il pulsante qui sotto per impostare una nuova password.', ctaIntro: 'Reimposta password', expiry: 'Questo link scade tra 60 minuti.', ignore: 'Se non hai richiesto questa operazione, ignora questa email — la tua password non cambierà.' },
|
||||
nl: { subject: 'Reset je wachtwoord', greeting: 'Hallo', body: 'We hebben een verzoek ontvangen om het wachtwoord voor je TREK-account te resetten. Klik op de knop hieronder om een nieuw wachtwoord in te stellen.', ctaIntro: 'Wachtwoord resetten', expiry: 'Deze link verloopt over 60 minuten.', ignore: 'Als jij dit niet hebt aangevraagd, kun je deze e-mail negeren — je wachtwoord blijft ongewijzigd.' },
|
||||
ru: { subject: 'Сброс пароля', greeting: 'Здравствуйте', body: 'Мы получили запрос на сброс пароля вашего аккаунта TREK. Нажмите кнопку ниже, чтобы установить новый пароль.', ctaIntro: 'Сбросить пароль', expiry: 'Ссылка действительна 60 минут.', ignore: 'Если вы не запрашивали сброс — просто проигнорируйте это письмо, пароль останется прежним.' },
|
||||
zh: { subject: '重置您的密码', greeting: '您好', body: '我们收到了重置您的 TREK 账户密码的请求。点击下方按钮设置新密码。', ctaIntro: '重置密码', expiry: '此链接将在 60 分钟后失效。', ignore: '如果这不是您本人的请求,可以忽略本邮件 — 您的密码不会改变。' },
|
||||
'zh-TW': { subject: '重設您的密碼', greeting: '您好', body: '我們收到了重設您 TREK 帳號密碼的請求。點擊下方按鈕以設定新密碼。', ctaIntro: '重設密碼', expiry: '此連結將於 60 分鐘後失效。', ignore: '若非您本人發起的請求,請忽略此郵件 — 您的密碼不會變更。' },
|
||||
hu: { subject: 'Jelszó visszaállítása', greeting: 'Szia', body: 'Kérést kaptunk a TREK-fiókod jelszavának visszaállítására. Kattints az alábbi gombra az új jelszó beállításához.', ctaIntro: 'Jelszó visszaállítása', expiry: 'Ez a link 60 perc után lejár.', ignore: 'Ha nem te kérted ezt, nyugodtan hagyd figyelmen kívül ezt az e-mailt — a jelszavad változatlan marad.' },
|
||||
ar: { subject: 'إعادة تعيين كلمة المرور', greeting: 'مرحبا', body: 'تلقينا طلبًا لإعادة تعيين كلمة المرور لحسابك في TREK. انقر على الزر أدناه لتعيين كلمة مرور جديدة.', ctaIntro: 'إعادة تعيين كلمة المرور', expiry: 'تنتهي صلاحية هذا الرابط خلال 60 دقيقة.', ignore: 'إذا لم تطلب هذا، يمكنك تجاهل هذه الرسالة — لن تتغير كلمة المرور الخاصة بك.' },
|
||||
br: { subject: 'Redefinir sua senha', greeting: 'Olá', body: 'Recebemos um pedido para redefinir a senha da sua conta TREK. Clique no botão abaixo para definir uma nova senha.', ctaIntro: 'Redefinir senha', expiry: 'Este link expira em 60 minutos.', ignore: 'Se você não solicitou isto, pode ignorar este e-mail — sua senha não será alterada.' },
|
||||
cs: { subject: 'Obnovení hesla', greeting: 'Ahoj', body: 'Obdrželi jsme žádost o obnovení hesla k tvému účtu TREK. Klikni na tlačítko níže a nastav nové heslo.', ctaIntro: 'Obnovit heslo', expiry: 'Odkaz vyprší za 60 minut.', ignore: 'Pokud jsi o obnovení nežádal/a, tento e-mail ignoruj — heslo zůstane beze změny.' },
|
||||
pl: { subject: 'Zresetuj hasło', greeting: 'Cześć', body: 'Otrzymaliśmy prośbę o zresetowanie hasła do Twojego konta TREK. Kliknij przycisk poniżej, aby ustawić nowe hasło.', ctaIntro: 'Zresetuj hasło', expiry: 'Link wygaśnie za 60 minut.', ignore: 'Jeśli to nie Ty, zignoruj tę wiadomość — Twoje hasło pozostanie bez zmian.' },
|
||||
};
|
||||
// PASSWORD_RESET_I18N imported from @trek/shared/i18n/externalNotifications
|
||||
|
||||
function buildPasswordResetHtml(subject: string, strings: PasswordResetStrings, recipient: string, resetUrl: string, lang: string): string {
|
||||
const safeGreeting = escapeHtml(`${strings.greeting}, ${recipient}`);
|
||||
@@ -641,15 +432,6 @@ export function resolveNtfyUrl(adminCfg: NtfyConfig, userCfg: NtfyConfig | null)
|
||||
return `${base}/${encodeURIComponent(topic)}`;
|
||||
}
|
||||
|
||||
export function isNtfyConfiguredForUser(userId: number): boolean {
|
||||
const cfg = getUserNtfyConfig(userId);
|
||||
return !!(cfg?.topic);
|
||||
}
|
||||
|
||||
export function isNtfyConfiguredAdmin(): boolean {
|
||||
return !!(getAppSetting('admin_ntfy_topic'));
|
||||
}
|
||||
|
||||
function encodeHeaderValue(value: string): string {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
if (value.charCodeAt(i) > 0xFF) {
|
||||
|
||||
@@ -118,16 +118,6 @@ export function listOAuthClients(userId: number): Record<string, unknown>[] {
|
||||
}));
|
||||
}
|
||||
|
||||
/** Returns true if the URI is a valid OAuth redirect target (HTTPS or localhost). */
|
||||
export function isValidRedirectUri(uri: string): boolean {
|
||||
try {
|
||||
const url = new URL(uri);
|
||||
return url.protocol === 'https:' || url.hostname === 'localhost' || url.hostname === '127.0.0.1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function createOAuthClient(
|
||||
userId: number | null,
|
||||
name: string,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import crypto from 'crypto';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { db } from '../db/database';
|
||||
import { JWT_SECRET } from '../config';
|
||||
import { JWT_SECRET, SESSION_DURATION_SECONDS } from '../config';
|
||||
import { User } from '../types';
|
||||
import { decrypt_api_key } from './apiKeyCrypto';
|
||||
import { resolveAuthToggles } from './authService';
|
||||
@@ -28,6 +28,8 @@ export interface OidcTokenResponse {
|
||||
export interface OidcUserInfo {
|
||||
sub: string;
|
||||
email?: string;
|
||||
// Standard OIDC claim. Some IdPs send it as the string "true"/"false".
|
||||
email_verified?: boolean | string;
|
||||
name?: string;
|
||||
preferred_username?: string;
|
||||
groups?: string[];
|
||||
@@ -57,7 +59,7 @@ const DISCOVERY_TTL = 60 * 60 * 1000; // 1 hour
|
||||
// State management – pending OIDC states
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const pendingStates = new Map<string, { createdAt: number; redirectUri: string; inviteToken?: string }>();
|
||||
const pendingStates = new Map<string, { createdAt: number; redirectUri: string; inviteToken?: string; codeVerifier: string }>();
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
@@ -66,10 +68,19 @@ setInterval(() => {
|
||||
}
|
||||
}, STATE_CLEANUP);
|
||||
|
||||
export function createState(redirectUri: string, inviteToken?: string): string {
|
||||
function base64url(buf: Buffer): string {
|
||||
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
// Creates the login state and a matching PKCE pair. The verifier stays server
|
||||
// side (in pendingStates); the S256 challenge goes to the provider so PKCE-
|
||||
// required setups (e.g. Pocket ID with PKCE = required) work.
|
||||
export function createState(redirectUri: string, inviteToken?: string): { state: string; codeChallenge: string } {
|
||||
const state = crypto.randomBytes(32).toString('hex');
|
||||
pendingStates.set(state, { createdAt: Date.now(), redirectUri, inviteToken });
|
||||
return state;
|
||||
const codeVerifier = base64url(crypto.randomBytes(32));
|
||||
const codeChallenge = base64url(crypto.createHash('sha256').update(codeVerifier).digest());
|
||||
pendingStates.set(state, { createdAt: Date.now(), redirectUri, inviteToken, codeVerifier });
|
||||
return { state, codeChallenge };
|
||||
}
|
||||
|
||||
export function consumeState(state: string) {
|
||||
@@ -191,7 +202,11 @@ export function frontendUrl(path: string): string {
|
||||
}
|
||||
|
||||
export function generateToken(user: { id: number }): string {
|
||||
return jwt.sign({ id: user.id }, JWT_SECRET, { expiresIn: '24h', algorithm: 'HS256' });
|
||||
// Embed the current password_version so an OIDC-issued session is invalidated
|
||||
// by a password change/reset exactly like a password-login session (the auth
|
||||
// middleware compares this `pv` against users.password_version).
|
||||
const pv = (db.prepare('SELECT password_version FROM users WHERE id = ?').get(user.id) as { password_version?: number } | undefined)?.password_version ?? 0;
|
||||
return jwt.sign({ id: user.id, pv }, JWT_SECRET, { expiresIn: SESSION_DURATION_SECONDS, algorithm: 'HS256' });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -204,17 +219,20 @@ export async function exchangeCodeForToken(
|
||||
redirectUri: string,
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
codeVerifier?: string,
|
||||
): Promise<OidcTokenResponse & { _ok: boolean; _status: number }> {
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
});
|
||||
if (codeVerifier) body.set('code_verifier', codeVerifier);
|
||||
const tokenRes = await fetch(doc.token_endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
}),
|
||||
body,
|
||||
});
|
||||
const tokenData = (await tokenRes.json()) as OidcTokenResponse;
|
||||
return { ...tokenData, _ok: tokenRes.ok, _status: tokenRes.status };
|
||||
@@ -353,8 +371,14 @@ export function findOrCreateUser(
|
||||
}
|
||||
|
||||
if (user) {
|
||||
// Link OIDC identity if not yet linked
|
||||
// Reaching here without an oidc_sub means we matched an existing local
|
||||
// account by email. Only auto-link the OIDC identity when the IdP asserts
|
||||
// the email is verified; an unverified email must not auto-link.
|
||||
if (!user.oidc_sub) {
|
||||
const emailVerified = userInfo.email_verified === true || userInfo.email_verified === 'true';
|
||||
if (!emailVerified) {
|
||||
return { error: 'email_not_verified' };
|
||||
}
|
||||
db.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?').run(sub, config.issuer, user.id);
|
||||
}
|
||||
// Update role based on OIDC claims on every login (if claim mapping is configured)
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { db, canAccessTrip } from '../db/database';
|
||||
import { db } from '../db/database';
|
||||
import { avatarUrl } from './authService';
|
||||
|
||||
const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b'];
|
||||
const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b', '#3b82f6', '#84cc16', '#d946ef', '#14b8a6', '#f43f5e', '#a855f7', '#eab308', '#64748b'];
|
||||
|
||||
export function verifyTripAccess(tripId: string | number, userId: number) {
|
||||
return canAccessTrip(tripId, userId);
|
||||
}
|
||||
export { verifyTripAccess } from './tripAccess';
|
||||
|
||||
// ── Items ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -78,13 +76,14 @@ interface ImportItem {
|
||||
category?: string;
|
||||
weight_grams?: string | number;
|
||||
bag?: string;
|
||||
quantity?: number;
|
||||
}
|
||||
|
||||
export function bulkImport(tripId: string | number, items: ImportItem[]) {
|
||||
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?').get(tripId) as { max: number | null };
|
||||
let sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
|
||||
|
||||
const stmt = db.prepare('INSERT INTO packing_items (trip_id, name, checked, category, weight_grams, bag_id, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)');
|
||||
const stmt = db.prepare('INSERT INTO packing_items (trip_id, name, checked, category, weight_grams, bag_id, sort_order, quantity) VALUES (?, ?, ?, ?, ?, ?, ?, ?)');
|
||||
const created: any[] = [];
|
||||
|
||||
const insertAll = db.transaction(() => {
|
||||
@@ -107,7 +106,8 @@ export function bulkImport(tripId: string | number, items: ImportItem[]) {
|
||||
}
|
||||
}
|
||||
|
||||
const result = stmt.run(tripId, item.name.trim(), checked, item.category?.trim() || 'Other', weight, bagId, sortOrder++);
|
||||
const qty = Math.max(1, Math.min(999, Number(item.quantity) || 1));
|
||||
const result = stmt.run(tripId, item.name.trim(), checked, item.category?.trim() || 'Other', weight, bagId, sortOrder++, qty);
|
||||
created.push(db.prepare('SELECT * FROM packing_items WHERE id = ?').get(result.lastInsertRowid));
|
||||
}
|
||||
});
|
||||
@@ -193,6 +193,22 @@ export function deleteBag(tripId: string | number, bagId: string | number) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── List Templates ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read-only template list for trip members (name + item count), so non-admins
|
||||
* can pick a template to apply. Management (create/edit/delete) stays admin-only
|
||||
* under /api/admin/packing-templates.
|
||||
*/
|
||||
export function listTemplates() {
|
||||
return db.prepare(`
|
||||
SELECT pt.id, pt.name,
|
||||
(SELECT COUNT(*) FROM packing_template_items ti JOIN packing_template_categories tc ON ti.category_id = tc.id WHERE tc.template_id = pt.id) as item_count
|
||||
FROM packing_templates pt
|
||||
ORDER BY pt.created_at DESC
|
||||
`).all() as { id: number; name: string; item_count: number }[];
|
||||
}
|
||||
|
||||
// ── Apply Template ─────────────────────────────────────────────────────────
|
||||
|
||||
export function applyTemplate(tripId: string | number, templateId: string | number) {
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import {
|
||||
generateRegistrationOptions,
|
||||
verifyRegistrationResponse,
|
||||
generateAuthenticationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
type AuthenticatorTransportFuture,
|
||||
} from '@simplewebauthn/server';
|
||||
import { db } from '../db/database';
|
||||
import { resolveWebauthnConfig } from './webauthnConfig';
|
||||
import { generateToken, stripUserForClient, avatarUrl } from './authService';
|
||||
import type { User } from '../types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Short single-use challenge lifetime — a ceremony is a few seconds of user
|
||||
// interaction. Kept tight so a stray row can't be replayed and the table can't
|
||||
// accumulate. Mirrors the spirit of the OIDC state TTL.
|
||||
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
// Pinned COSE algorithms: EdDSA (-8), ES256 (-7), RS256 (-257). We never want a
|
||||
// future library default to silently widen what we accept.
|
||||
const SUPPORTED_ALGORITHM_IDS = [-8, -7, -257];
|
||||
|
||||
const NOT_CONFIGURED = { error: 'Passkey login is not configured for this server.', status: 400 } as const;
|
||||
// One generic message for every authentication failure so the endpoint can't be
|
||||
// used to tell "no such credential" apart from "bad signature" (CWE-203).
|
||||
const AUTH_FAILED = { error: 'Authentication failed', status: 401 } as const;
|
||||
|
||||
interface CredentialRow {
|
||||
id: number;
|
||||
user_id: number;
|
||||
credential_id: string;
|
||||
public_key: Buffer;
|
||||
counter: number;
|
||||
transports: string | null;
|
||||
device_type: string | null;
|
||||
backed_up: number;
|
||||
name: string | null;
|
||||
aaguid: string | null;
|
||||
created_at: string;
|
||||
last_used_at: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Challenge store (DB-backed, single-use, TTL'd)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function purgeExpiredChallenges(now: number): void {
|
||||
db.prepare('DELETE FROM webauthn_challenges WHERE expires_at < ?').run(now);
|
||||
}
|
||||
|
||||
function storeChallenge(challenge: string, userId: number | null, type: 'registration' | 'authentication', now: number): void {
|
||||
db.prepare('INSERT INTO webauthn_challenges (challenge, user_id, type, expires_at) VALUES (?, ?, ?, ?)')
|
||||
.run(challenge, userId, type, now + CHALLENGE_TTL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically claim a challenge by its EXACT bytes + type. This is a single
|
||||
* DELETE ... RETURNING statement that runs BEFORE any async verification, so a
|
||||
* concurrent double-submit of the same assertion can never spend one challenge
|
||||
* twice (the replay window a SELECT→await→DELETE ordering would open).
|
||||
*/
|
||||
function claimChallenge(challenge: string, type: 'registration' | 'authentication', now: number): { user_id: number | null } | null {
|
||||
const row = db.prepare(
|
||||
'DELETE FROM webauthn_challenges WHERE challenge = ? AND type = ? AND expires_at > ? RETURNING user_id',
|
||||
).get(challenge, type, now) as { user_id: number | null } | undefined;
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/** Decode the challenge the authenticator echoed back inside clientDataJSON. */
|
||||
function challengeFromResponse(resp: unknown): string | null {
|
||||
try {
|
||||
const cdj = (resp as { response?: { clientDataJSON?: unknown } })?.response?.clientDataJSON;
|
||||
if (typeof cdj !== 'string') return null;
|
||||
const parsed = JSON.parse(Buffer.from(cdj, 'base64url').toString('utf8')) as { challenge?: unknown };
|
||||
return typeof parsed.challenge === 'string' ? parsed.challenge : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseTransports(raw: string | null): AuthenticatorTransportFuture[] | undefined {
|
||||
if (!raw) return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? (parsed as AuthenticatorTransportFuture[]) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeName(raw: unknown): string | null {
|
||||
if (typeof raw !== 'string') return null;
|
||||
const trimmed = raw.trim().slice(0, 60);
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
function defaultCredentialName(deviceType: string | undefined): string {
|
||||
return deviceType === 'multiDevice' ? 'Passkey (synced)' : 'Passkey';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registration (authenticated — from Settings, password re-auth required)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function passkeyRegisterOptions(
|
||||
userId: number,
|
||||
password: string | undefined,
|
||||
): Promise<{ error?: string; status?: number; options?: Awaited<ReturnType<typeof generateRegistrationOptions>> }> {
|
||||
const cfg = resolveWebauthnConfig();
|
||||
if (!cfg) return { ...NOT_CONFIGURED };
|
||||
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId) as User | undefined;
|
||||
if (!user) return { error: 'User not found', status: 404 };
|
||||
|
||||
// Re-authentication: a hijacked session must not be able to silently plant an
|
||||
// attacker-controlled passkey. Require the current password (parity with the
|
||||
// change-password / disable-MFA step-up).
|
||||
if (!password || !user.password_hash || !bcrypt.compareSync(password, user.password_hash)) {
|
||||
return { error: 'Incorrect password', status: 401 };
|
||||
}
|
||||
|
||||
const existing = db.prepare('SELECT credential_id, transports FROM webauthn_credentials WHERE user_id = ?')
|
||||
.all(userId) as { credential_id: string; transports: string | null }[];
|
||||
|
||||
const now = Date.now();
|
||||
purgeExpiredChallenges(now);
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: cfg.rpName,
|
||||
rpID: cfg.rpID,
|
||||
userName: user.email,
|
||||
userDisplayName: user.username,
|
||||
userID: new TextEncoder().encode(String(user.id)),
|
||||
attestationType: 'none',
|
||||
// Stop the same authenticator from enrolling twice on this account.
|
||||
excludeCredentials: existing.map((c) => ({ id: c.credential_id, transports: parseTransports(c.transports) })),
|
||||
authenticatorSelection: { residentKey: 'preferred', userVerification: 'required' },
|
||||
supportedAlgorithmIDs: SUPPORTED_ALGORITHM_IDS,
|
||||
});
|
||||
|
||||
storeChallenge(options.challenge, userId, 'registration', now);
|
||||
return { options };
|
||||
}
|
||||
|
||||
export async function passkeyRegisterVerify(
|
||||
userId: number,
|
||||
body: { attestationResponse?: unknown; name?: unknown },
|
||||
): Promise<{ error?: string; status?: number; success?: boolean; credential?: unknown }> {
|
||||
const cfg = resolveWebauthnConfig();
|
||||
if (!cfg) return { ...NOT_CONFIGURED };
|
||||
|
||||
const resp = body?.attestationResponse;
|
||||
if (!resp) return { error: 'Invalid registration response', status: 400 };
|
||||
|
||||
const challenge = challengeFromResponse(resp);
|
||||
if (!challenge) return { error: 'Invalid registration response', status: 400 };
|
||||
|
||||
const now = Date.now();
|
||||
const claimed = claimChallenge(challenge, 'registration', now);
|
||||
if (!claimed || claimed.user_id !== userId) {
|
||||
return { error: 'Registration challenge expired. Please try again.', status: 400 };
|
||||
}
|
||||
|
||||
let verification;
|
||||
try {
|
||||
verification = await verifyRegistrationResponse({
|
||||
response: resp as Parameters<typeof verifyRegistrationResponse>[0]['response'],
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: cfg.origins,
|
||||
expectedRPID: cfg.rpID,
|
||||
requireUserVerification: true,
|
||||
});
|
||||
} catch {
|
||||
return { error: 'Could not register this passkey.', status: 400 };
|
||||
}
|
||||
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
return { error: 'Could not register this passkey.', status: 400 };
|
||||
}
|
||||
|
||||
// Persist ONLY the values the verifier vouches for — never anything parsed
|
||||
// from the raw client payload.
|
||||
const { credential, credentialDeviceType, credentialBackedUp, aaguid } = verification.registrationInfo;
|
||||
|
||||
if (db.prepare('SELECT id FROM webauthn_credentials WHERE credential_id = ?').get(credential.id)) {
|
||||
return { error: 'This passkey is already registered.', status: 409 };
|
||||
}
|
||||
|
||||
const name = sanitizeName(body?.name) || defaultCredentialName(credentialDeviceType);
|
||||
try {
|
||||
db.prepare(
|
||||
`INSERT INTO webauthn_credentials
|
||||
(user_id, credential_id, public_key, counter, transports, device_type, backed_up, name, aaguid, last_used_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)`,
|
||||
).run(
|
||||
userId,
|
||||
credential.id,
|
||||
Buffer.from(credential.publicKey),
|
||||
credential.counter ?? 0,
|
||||
credential.transports ? JSON.stringify(credential.transports) : null,
|
||||
credentialDeviceType ?? null,
|
||||
credentialBackedUp ? 1 : 0,
|
||||
name,
|
||||
aaguid ?? null,
|
||||
);
|
||||
} catch {
|
||||
return { error: 'Could not register this passkey.', status: 400 };
|
||||
}
|
||||
|
||||
const created = db.prepare(
|
||||
'SELECT id, name, device_type, backed_up, created_at, last_used_at FROM webauthn_credentials WHERE credential_id = ?',
|
||||
).get(credential.id) as { backed_up: number } & Record<string, unknown>;
|
||||
return { success: true, credential: { ...created, backed_up: created.backed_up === 1 } };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Authentication (public — primary, discoverable-credential login)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function passkeyLoginOptions(): Promise<{
|
||||
error?: string;
|
||||
status?: number;
|
||||
options?: Awaited<ReturnType<typeof generateAuthenticationOptions>>;
|
||||
}> {
|
||||
const cfg = resolveWebauthnConfig();
|
||||
if (!cfg) return { ...NOT_CONFIGURED };
|
||||
|
||||
const now = Date.now();
|
||||
purgeExpiredChallenges(now);
|
||||
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: cfg.rpID,
|
||||
userVerification: 'required',
|
||||
// Empty allowCredentials → discoverable flow. The server never echoes which
|
||||
// accounts have passkeys, so the endpoint can't be used to enumerate users.
|
||||
});
|
||||
|
||||
storeChallenge(options.challenge, null, 'authentication', now);
|
||||
return { options };
|
||||
}
|
||||
|
||||
export async function passkeyLoginVerify(body: { assertionResponse?: unknown }): Promise<{
|
||||
error?: string;
|
||||
status?: number;
|
||||
token?: string;
|
||||
user?: Record<string, unknown>;
|
||||
auditUserId?: number | null;
|
||||
auditAction?: string;
|
||||
}> {
|
||||
const cfg = resolveWebauthnConfig();
|
||||
if (!cfg) return { ...NOT_CONFIGURED };
|
||||
|
||||
const resp = body?.assertionResponse;
|
||||
if (!resp) return { ...AUTH_FAILED };
|
||||
|
||||
const challenge = challengeFromResponse(resp);
|
||||
if (!challenge) return { ...AUTH_FAILED };
|
||||
|
||||
// Claim the challenge (single-use) BEFORE looking anything up or verifying.
|
||||
const now = Date.now();
|
||||
if (!claimChallenge(challenge, 'authentication', now)) return { ...AUTH_FAILED };
|
||||
|
||||
const credId = (resp as { id?: unknown; rawId?: unknown }).id ?? (resp as { rawId?: unknown }).rawId;
|
||||
if (typeof credId !== 'string') return { ...AUTH_FAILED };
|
||||
|
||||
const cred = db.prepare('SELECT * FROM webauthn_credentials WHERE credential_id = ?').get(credId) as CredentialRow | undefined;
|
||||
if (!cred) return { ...AUTH_FAILED };
|
||||
|
||||
let verification;
|
||||
try {
|
||||
verification = await verifyAuthenticationResponse({
|
||||
response: resp as Parameters<typeof verifyAuthenticationResponse>[0]['response'],
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: cfg.origins,
|
||||
expectedRPID: cfg.rpID,
|
||||
requireUserVerification: true,
|
||||
credential: {
|
||||
id: cred.credential_id,
|
||||
publicKey: new Uint8Array(cred.public_key),
|
||||
counter: cred.counter,
|
||||
transports: parseTransports(cred.transports),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return { ...AUTH_FAILED };
|
||||
}
|
||||
|
||||
if (!verification.verified) return { ...AUTH_FAILED };
|
||||
|
||||
const { newCounter } = verification.authenticationInfo;
|
||||
// Clone detection only makes sense for authenticators that actually increment.
|
||||
// Synced passkeys legitimately report a counter that stays 0 — never treat
|
||||
// that as a clone. A regression from a previously NON-ZERO counter rejects
|
||||
// THIS assertion (and is audited) but does not disable the credential.
|
||||
if (cred.counter > 0 && newCounter <= cred.counter) {
|
||||
return { ...AUTH_FAILED, auditUserId: cred.user_id, auditAction: 'user.passkey_clone_suspected' };
|
||||
}
|
||||
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(cred.user_id) as User | undefined;
|
||||
if (!user) return { ...AUTH_FAILED };
|
||||
|
||||
// Persist the new counter + last-used and bump login bookkeeping atomically.
|
||||
db.transaction(() => {
|
||||
db.prepare('UPDATE webauthn_credentials SET counter = ?, last_used_at = CURRENT_TIMESTAMP WHERE id = ?').run(newCounter, cred.id);
|
||||
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?').run(user.id);
|
||||
})();
|
||||
|
||||
// A user-verified passkey is phishing-resistant and inherently two-factor
|
||||
// (device possession + biometric/PIN), so it mints the real session directly
|
||||
// — the SAME path as password and OIDC login (no new token shape).
|
||||
const token = generateToken(user);
|
||||
const userSafe = stripUserForClient(user) as Record<string, unknown>;
|
||||
return { token, user: { ...userSafe, avatar_url: avatarUrl(user) }, auditUserId: Number(user.id) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Management (authenticated, owner-scoped)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function listPasskeys(userId: number): Array<Record<string, unknown>> {
|
||||
const rows = db.prepare(
|
||||
'SELECT id, name, device_type, backed_up, created_at, last_used_at FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at DESC',
|
||||
).all(userId) as Array<{ backed_up: number } & Record<string, unknown>>;
|
||||
return rows.map((r) => ({ ...r, backed_up: r.backed_up === 1 }));
|
||||
}
|
||||
|
||||
export function renamePasskey(userId: number, id: string, name: unknown): { error?: string; status?: number; success?: boolean } {
|
||||
const cleanName = sanitizeName(name);
|
||||
if (!cleanName) return { error: 'Name is required', status: 400 };
|
||||
// Ownership enforced in SQL (404 on miss, never a 403 that leaks existence).
|
||||
const result = db.prepare('UPDATE webauthn_credentials SET name = ? WHERE id = ? AND user_id = ?').run(cleanName, Number(id), userId);
|
||||
if (result.changes === 0) return { error: 'Passkey not found', status: 404 };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export function deletePasskey(
|
||||
userId: number,
|
||||
id: string,
|
||||
password: string | undefined,
|
||||
): { error?: string; status?: number; success?: boolean } {
|
||||
// Re-auth before removing a credential (a hijacked session must not be able to
|
||||
// strip the victim's passkeys). Deleting is always allowed because every
|
||||
// account keeps a usable password as recovery fallback — losing all passkeys
|
||||
// can never lock anyone out.
|
||||
const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(userId) as { password_hash: string } | undefined;
|
||||
if (!user || !user.password_hash || !password || !bcrypt.compareSync(password, user.password_hash)) {
|
||||
return { error: 'Incorrect password', status: 401 };
|
||||
}
|
||||
const result = db.prepare('DELETE FROM webauthn_credentials WHERE id = ? AND user_id = ?').run(Number(id), userId);
|
||||
if (result.changes === 0) return { error: 'Passkey not found', status: 404 };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/** Admin: clear all of a user's passkeys (e.g. on suspected compromise). */
|
||||
export function adminResetPasskeys(targetUserId: number): { error?: string; status?: number; success?: boolean; deleted?: number; email?: string } {
|
||||
const target = db.prepare('SELECT id, email FROM users WHERE id = ?').get(targetUserId) as { id: number; email: string } | undefined;
|
||||
if (!target) return { error: 'User not found', status: 404 };
|
||||
const result = db.prepare('DELETE FROM webauthn_credentials WHERE user_id = ?').run(targetUserId);
|
||||
return { success: true, deleted: result.changes, email: target.email };
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { db, getPlaceWithTags } from '../db/database';
|
||||
import { broadcast } from '../websocket';
|
||||
import { getMapsKey, searchPlaces, getPlacePhoto } from './mapsService';
|
||||
|
||||
/**
|
||||
* Background enrichment for list-imported places (#886).
|
||||
*
|
||||
* Google/Naver list imports only carry name + coordinates, so the imported
|
||||
* places open as bare pins (the Maps tab jumps to coordinates, no photo, no
|
||||
* open/closed). When the importer opts in and a Google Maps key is configured,
|
||||
* we re-resolve each place by name — biased to and validated against the
|
||||
* imported coordinates — to a real Google place, then fill in the empty fields
|
||||
* and persist the resolved `google_place_id` (which is what powers on-demand
|
||||
* opening hours / the proper Maps link going forward).
|
||||
*
|
||||
* This runs detached from the import request (fire-and-forget) so a long list
|
||||
* never blocks the response, and pushes each enriched row over the websocket so
|
||||
* the sidebar fills in progressively. It only ever fills EMPTY columns, so it
|
||||
* can never clobber data the import already captured (e.g. a Naver address).
|
||||
*/
|
||||
|
||||
/** A place the import produced — only the fields enrichment reads/writes. */
|
||||
export interface EnrichablePlace {
|
||||
id: number;
|
||||
name: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
google_place_id?: string | null;
|
||||
address?: string | null;
|
||||
website?: string | null;
|
||||
phone?: string | null;
|
||||
image_url?: string | null;
|
||||
}
|
||||
|
||||
/** How close a search hit must be to the imported coordinates to be trusted. */
|
||||
const MATCH_RADIUS_METERS = 250;
|
||||
/** Bias the text search to roughly the imported area. */
|
||||
const SEARCH_BIAS_RADIUS_METERS = 2000;
|
||||
/** Concurrent enrichment lookups — small, to stay friendly to the Maps quota. */
|
||||
const ENRICH_CONCURRENCY = 3;
|
||||
|
||||
function haversineMeters(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
|
||||
const R = 6371000;
|
||||
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||
const dLat = toRad(b.lat - a.lat);
|
||||
const dLng = toRad(b.lng - a.lng);
|
||||
const lat1 = toRad(a.lat);
|
||||
const lat2 = toRad(b.lat);
|
||||
const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.sqrt(h));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the search result that is the same place as the import: it must be a
|
||||
* Google result (have a google_place_id) with coordinates within
|
||||
* MATCH_RADIUS_METERS of the imported point. Returns the closest such hit, or
|
||||
* null when nothing is close enough — in which case the place is left as
|
||||
* imported rather than risking a wrong-place overwrite (common-name / romanized
|
||||
* lists). Exported for unit testing.
|
||||
*/
|
||||
export function pickEnrichmentMatch(
|
||||
candidates: Record<string, unknown>[],
|
||||
target: { lat: number; lng: number },
|
||||
maxMeters: number = MATCH_RADIUS_METERS,
|
||||
): Record<string, unknown> | null {
|
||||
let best: { c: Record<string, unknown>; dist: number } | null = null;
|
||||
for (const c of candidates || []) {
|
||||
const gpid = c.google_place_id;
|
||||
const lat = c.lat;
|
||||
const lng = c.lng;
|
||||
if (typeof gpid !== 'string' || !gpid) continue;
|
||||
if (typeof lat !== 'number' || typeof lng !== 'number') continue;
|
||||
const dist = haversineMeters(target, { lat, lng });
|
||||
if (dist > maxMeters) continue;
|
||||
if (!best || dist < best.dist) best = { c, dist };
|
||||
}
|
||||
return best?.c ?? null;
|
||||
}
|
||||
|
||||
async function mapWithConcurrency<T>(items: T[], limit: number, fn: (item: T) => Promise<void>): Promise<void> {
|
||||
let cursor = 0;
|
||||
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||
while (cursor < items.length) {
|
||||
const item = items[cursor++];
|
||||
await fn(item);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
}
|
||||
|
||||
const str = (v: unknown): string | null => (typeof v === 'string' && v.trim() ? v.trim() : null);
|
||||
|
||||
async function enrichOne(tripId: string, userId: number, place: EnrichablePlace, lang?: string): Promise<void> {
|
||||
// Already linked (shouldn't happen for list imports) — nothing to resolve.
|
||||
if (place.google_place_id) return;
|
||||
if (typeof place.lat !== 'number' || typeof place.lng !== 'number') return;
|
||||
|
||||
const { places: results } = await searchPlaces(userId, place.name, lang, {
|
||||
lat: place.lat,
|
||||
lng: place.lng,
|
||||
radius: SEARCH_BIAS_RADIUS_METERS,
|
||||
});
|
||||
const match = pickEnrichmentMatch(results, { lat: place.lat, lng: place.lng });
|
||||
if (!match) return;
|
||||
|
||||
const gpid = str(match.google_place_id);
|
||||
if (!gpid) return;
|
||||
|
||||
// COALESCE so enrichment only fills empty columns — never overwrites data the
|
||||
// import already captured (e.g. Naver's address) or anything the user edited.
|
||||
db.prepare(
|
||||
`UPDATE places
|
||||
SET google_place_id = COALESCE(google_place_id, ?),
|
||||
address = COALESCE(address, ?),
|
||||
website = COALESCE(website, ?),
|
||||
phone = COALESCE(phone, ?),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND trip_id = ?`,
|
||||
).run(gpid, str(match.address), str(match.website), str(match.phone), place.id, tripId);
|
||||
|
||||
// Photo is best-effort: Google often has none, and getPlacePhoto throws 404 in
|
||||
// that case — a missing photo must never abort the rest of the enrichment.
|
||||
try {
|
||||
const photo = await getPlacePhoto(userId, gpid, place.lat, place.lng, place.name);
|
||||
if (photo?.photoUrl) {
|
||||
db.prepare(
|
||||
'UPDATE places SET image_url = COALESCE(image_url, ?), updated_at = CURRENT_TIMESTAMP WHERE id = ? AND trip_id = ?',
|
||||
).run(photo.photoUrl, place.id, tripId);
|
||||
}
|
||||
} catch {
|
||||
/* no photo — leave image_url as-is */
|
||||
}
|
||||
|
||||
// Push the enriched row to every connected client (no socket exclusion: the
|
||||
// importer's own client should also receive the late update).
|
||||
const updated = getPlaceWithTags(place.id);
|
||||
if (updated) broadcast(tripId, 'place:updated', { place: updated }, undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich a batch of just-imported places in the background. Never throws —
|
||||
* any per-place failure is swallowed so one bad lookup can't take down the
|
||||
* detached task or the process. No-ops when no Google Maps key is configured.
|
||||
*/
|
||||
export async function enrichImportedPlaces(
|
||||
tripId: string,
|
||||
userId: number,
|
||||
places: EnrichablePlace[],
|
||||
lang?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!places.length) return;
|
||||
if (!getMapsKey(userId)) return;
|
||||
await mapWithConcurrency(places, ENRICH_CONCURRENCY, async (place) => {
|
||||
try {
|
||||
await enrichOne(tripId, userId, place, lang);
|
||||
} catch (err) {
|
||||
console.error(`[Places] enrichment failed for place ${place.id}:`, err instanceof Error ? err.message : err);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Places] import enrichment pass failed:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,22 @@
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import fsPromises from 'node:fs/promises';
|
||||
import crypto from 'node:crypto';
|
||||
import { db } from '../db/database';
|
||||
|
||||
const GOOGLE_PHOTO_DIR = path.join(__dirname, '../../uploads/photos/google');
|
||||
import { Jimp, JimpMime } from 'jimp';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import fsPromises from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
// Overridable for tests (mirrors the TREK_DB_FILE seam) so the suite never touches
|
||||
// the real uploads tree.
|
||||
const GOOGLE_PHOTO_DIR = process.env.TREK_PLACE_PHOTO_DIR || path.join(__dirname, '../../uploads/photos/google');
|
||||
const ERROR_TTL = 5 * 60 * 1000;
|
||||
|
||||
// Marker photos are displayed tiny — cap stored images so an oversized source
|
||||
// (e.g. a Wikimedia Commons full-res original) can't bloat the cache. Matches
|
||||
// THUMB_MAX/THUMB_QUALITY in memories/thumbnailService.ts.
|
||||
const MAX_DIM = 800;
|
||||
const JPEG_QUALITY = 80;
|
||||
|
||||
// In-flight dedup — prevents stampedes when multiple requests hit the same uncached placeId simultaneously
|
||||
const inFlight = new Map<string, Promise<{ filePath: string; attribution: string | null } | null>>();
|
||||
|
||||
@@ -17,7 +27,9 @@ const knownOnDisk = new Set<string>();
|
||||
// Ensure upload dir exists once at startup — avoids sync FS calls inside put() on every write.
|
||||
try {
|
||||
fs.mkdirSync(GOOGLE_PHOTO_DIR, { recursive: true });
|
||||
} catch { /* already exists */ }
|
||||
} catch {
|
||||
/* already exists */
|
||||
}
|
||||
|
||||
function filePath(placeId: string): string {
|
||||
// Hash to avoid filename collisions — coords:lat:lng pseudo-IDs contain characters that
|
||||
@@ -37,9 +49,9 @@ interface CachedPhoto {
|
||||
}
|
||||
|
||||
export function get(placeId: string): CachedPhoto | null {
|
||||
const row = db.prepare(
|
||||
'SELECT attribution FROM google_place_photo_meta WHERE place_id = ? AND error_at IS NULL'
|
||||
).get(placeId) as { attribution: string | null } | undefined;
|
||||
const row = db
|
||||
.prepare('SELECT attribution FROM google_place_photo_meta WHERE place_id = ? AND error_at IS NULL')
|
||||
.get(placeId) as { attribution: string | null } | undefined;
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
@@ -59,9 +71,9 @@ export function get(placeId: string): CachedPhoto | null {
|
||||
}
|
||||
|
||||
export function getErrored(placeId: string): boolean {
|
||||
const row = db.prepare(
|
||||
'SELECT error_at FROM google_place_photo_meta WHERE place_id = ? AND error_at IS NOT NULL'
|
||||
).get(placeId) as { error_at: number } | undefined;
|
||||
const row = db
|
||||
.prepare('SELECT error_at FROM google_place_photo_meta WHERE place_id = ? AND error_at IS NOT NULL')
|
||||
.get(placeId) as { error_at: number } | undefined;
|
||||
|
||||
if (!row) return false;
|
||||
return Date.now() - row.error_at < ERROR_TTL;
|
||||
@@ -70,35 +82,58 @@ export function getErrored(placeId: string): boolean {
|
||||
export function markError(placeId: string): void {
|
||||
knownOnDisk.delete(placeId);
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, NULL, ?, ?)'
|
||||
'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, NULL, ?, ?)',
|
||||
).run(placeId, Date.now(), Date.now());
|
||||
}
|
||||
|
||||
// Downscale oversized images to MAX_DIM before caching, re-encoding to JPEG.
|
||||
// Defense-in-depth: keeps the cache small regardless of what the fetch path hands
|
||||
// us. Jimp auto-applies EXIF orientation on read. Falls back to the original bytes
|
||||
// on any failure (corrupt/unsupported format) so behaviour is never worse than before.
|
||||
async function downscale(bytes: Buffer): Promise<Buffer> {
|
||||
try {
|
||||
const img = await Jimp.read(bytes);
|
||||
if (img.bitmap.width <= MAX_DIM && img.bitmap.height <= MAX_DIM) return bytes;
|
||||
img.scaleToFit({ w: MAX_DIM, h: MAX_DIM });
|
||||
return await img.getBuffer(JimpMime.jpeg, { quality: JPEG_QUALITY });
|
||||
} catch {
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
|
||||
export async function put(placeId: string, bytes: Buffer, attribution: string | null): Promise<CachedPhoto> {
|
||||
const fp = filePath(placeId);
|
||||
const tmp = fp + '.tmp';
|
||||
|
||||
await fsPromises.writeFile(tmp, bytes);
|
||||
const resized = await downscale(bytes);
|
||||
await fsPromises.writeFile(tmp, resized);
|
||||
await fsPromises.rename(tmp, fp);
|
||||
|
||||
knownOnDisk.add(placeId);
|
||||
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, ?, ?, NULL)'
|
||||
'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, ?, ?, NULL)',
|
||||
).run(placeId, attribution, Date.now());
|
||||
|
||||
return { photoUrl: proxyUrl(placeId), filePath: fp, attribution };
|
||||
}
|
||||
|
||||
export function getInFlight(placeId: string): Promise<{ filePath: string; attribution: string | null } | null> | undefined {
|
||||
export function getInFlight(
|
||||
placeId: string,
|
||||
): Promise<{ filePath: string; attribution: string | null } | null> | undefined {
|
||||
return inFlight.get(placeId);
|
||||
}
|
||||
|
||||
export function setInFlight(placeId: string, promise: Promise<{ filePath: string; attribution: string | null } | null>): void {
|
||||
export function setInFlight(
|
||||
placeId: string,
|
||||
promise: Promise<{ filePath: string; attribution: string | null } | null>,
|
||||
): void {
|
||||
inFlight.set(placeId, promise);
|
||||
promise
|
||||
.finally(() => inFlight.delete(placeId))
|
||||
.catch(() => { /* awaiter logs; this .catch only prevents unhandledRejection */ });
|
||||
.catch(() => {
|
||||
/* awaiter logs; this .catch only prevents unhandledRejection */
|
||||
});
|
||||
}
|
||||
|
||||
export function serveFilePath(placeId: string): string | null {
|
||||
@@ -108,3 +143,67 @@ export function serveFilePath(placeId: string): string | null {
|
||||
knownOnDisk.add(placeId);
|
||||
return fp;
|
||||
}
|
||||
|
||||
// A cache entry is "referenced" while any place still points at it — either by the
|
||||
// Google place_id (the dedup key) or by the stable proxy URL stored in image_url
|
||||
// (covers coords: pseudo-ids, which never have a google_place_id).
|
||||
function isReferenced(placeId: string): boolean {
|
||||
const row = db
|
||||
.prepare('SELECT 1 FROM places WHERE google_place_id = ? OR image_url = ? LIMIT 1')
|
||||
.get(placeId, proxyUrl(placeId));
|
||||
return !!row;
|
||||
}
|
||||
|
||||
function deleteEntry(placeId: string): void {
|
||||
try {
|
||||
fs.unlinkSync(filePath(placeId));
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
db.prepare('DELETE FROM google_place_photo_meta WHERE place_id = ?').run(placeId);
|
||||
knownOnDisk.delete(placeId);
|
||||
}
|
||||
|
||||
// Drop a cache entry if no place references it anymore. Called after a place delete
|
||||
// for prompt reclamation; the nightly sweep is the catch-all for every other path.
|
||||
export function removeIfUnreferenced(placeId: string): void {
|
||||
if (isReferenced(placeId)) return;
|
||||
deleteEntry(placeId);
|
||||
}
|
||||
|
||||
// Reclaim orphaned cache files + meta rows. Runs on startup and nightly (scheduler).
|
||||
// Two passes: (1) meta rows no place references; (2) stray .jpg files with no meta row.
|
||||
export function sweepOrphans(): number {
|
||||
let removed = 0;
|
||||
|
||||
const rows = db.prepare('SELECT place_id FROM google_place_photo_meta').all() as { place_id: string }[];
|
||||
const keepFiles = new Set<string>();
|
||||
for (const { place_id } of rows) {
|
||||
if (isReferenced(place_id)) {
|
||||
keepFiles.add(`${crypto.createHash('sha1').update(place_id).digest('hex')}.jpg`);
|
||||
} else {
|
||||
deleteEntry(place_id);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: files on disk that no surviving meta row maps to (e.g. left over from a
|
||||
// crash between writeFile and the DB upsert, or a meta row deleted out-of-band).
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = fs.readdirSync(GOOGLE_PHOTO_DIR);
|
||||
} catch {
|
||||
entries = [];
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.endsWith('.jpg') || keepFiles.has(entry)) continue;
|
||||
try {
|
||||
fs.unlinkSync(path.join(GOOGLE_PHOTO_DIR, entry));
|
||||
removed++;
|
||||
} catch {
|
||||
/* race */
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { XMLParser, XMLValidator } from 'fast-xml-parser';
|
||||
import unzipper from 'unzipper';
|
||||
import { db, getPlaceWithTags } from '../db/database';
|
||||
import { loadTagsByPlaceIds } from './queryHelpers';
|
||||
import { checkSsrf } from '../utils/ssrfGuard';
|
||||
import { checkSsrf, safeFetchFollow, SsrfBlockedError } from '../utils/ssrfGuard';
|
||||
import { Place } from '../types';
|
||||
import {
|
||||
buildCategoryNameLookup,
|
||||
@@ -13,6 +13,28 @@ import {
|
||||
resolveCategoryIdForFolder,
|
||||
type KmlImportSummary,
|
||||
} from './kmlImport';
|
||||
import { enrichImportedPlaces, type EnrichablePlace } from './placeEnrichment';
|
||||
import * as placePhotoCache from './placePhotoCache';
|
||||
|
||||
// Reclaim a deleted place's cached marker photo if nothing else references it.
|
||||
// The cache key is the Google place_id, or — for coordinate-only places — the
|
||||
// pseudo-id embedded in the stored proxy URL (/api/maps/place-photo/{id}/bytes).
|
||||
function reclaimPhotoCache(googlePlaceId: string | null, imageUrl: string | null): void {
|
||||
const candidates = new Set<string>();
|
||||
if (googlePlaceId) candidates.add(googlePlaceId);
|
||||
const m = imageUrl?.match(/^\/api\/maps\/place-photo\/(.+)\/bytes$/);
|
||||
if (m) { try { candidates.add(decodeURIComponent(m[1])); } catch { /* malformed url */ } }
|
||||
for (const id of candidates) {
|
||||
try { placePhotoCache.removeIfUnreferenced(id); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
/** Opt-in Places-API enrichment for list imports (#886). */
|
||||
export interface ListImportOptions {
|
||||
enrich?: boolean;
|
||||
userId?: number;
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
interface PlaceWithCategory extends Place {
|
||||
category_name: string | null;
|
||||
@@ -234,25 +256,33 @@ export function updatePlace(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function deletePlace(tripId: string, placeId: string): boolean {
|
||||
const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId);
|
||||
const place = db.prepare(
|
||||
'SELECT google_place_id, image_url FROM places WHERE id = ? AND trip_id = ?'
|
||||
).get(placeId, tripId) as { google_place_id: string | null; image_url: string | null } | undefined;
|
||||
if (!place) return false;
|
||||
db.prepare('DELETE FROM places WHERE id = ?').run(placeId);
|
||||
reclaimPhotoCache(place.google_place_id, place.image_url);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function deletePlacesMany(tripId: string, ids: number[]): number[] {
|
||||
if (ids.length === 0) return [];
|
||||
const selectStmt = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?');
|
||||
const selectStmt = db.prepare('SELECT google_place_id, image_url FROM places WHERE id = ? AND trip_id = ?');
|
||||
const deleteStmt = db.prepare('DELETE FROM places WHERE id = ?');
|
||||
const deleted: number[] = [];
|
||||
const reclaimable: { google_place_id: string | null; image_url: string | null }[] = [];
|
||||
const run = db.transaction((list: number[]) => {
|
||||
for (const id of list) {
|
||||
if (!selectStmt.get(id, tripId)) continue;
|
||||
const row = selectStmt.get(id, tripId) as { google_place_id: string | null; image_url: string | null } | undefined;
|
||||
if (!row) continue;
|
||||
deleteStmt.run(id);
|
||||
deleted.push(id);
|
||||
reclaimable.push(row);
|
||||
}
|
||||
});
|
||||
run(ids);
|
||||
// Reclaim after the transaction commits so isReferenced() sees the final place set.
|
||||
for (const row of reclaimable) reclaimPhotoCache(row.google_place_id, row.image_url);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
@@ -346,6 +376,8 @@ export interface GpxImportOptions {
|
||||
importWaypoints?: boolean;
|
||||
importRoutes?: boolean;
|
||||
importTracks?: boolean;
|
||||
/** Source filename used to name unnamed routes/tracks (keeps multiple imports distinct). */
|
||||
defaultName?: string;
|
||||
}
|
||||
|
||||
export interface KmlImportOptions {
|
||||
@@ -354,7 +386,7 @@ export interface KmlImportOptions {
|
||||
}
|
||||
|
||||
export function importGpx(tripId: string, fileBuffer: Buffer, opts: GpxImportOptions = {}) {
|
||||
const { importWaypoints = true, importRoutes = true, importTracks = true } = opts;
|
||||
const { importWaypoints = true, importRoutes = true, importTracks = true, defaultName } = opts;
|
||||
|
||||
const parsed = gpxParser.parse(fileBuffer.toString('utf-8'));
|
||||
const gpx = parsed?.gpx;
|
||||
@@ -363,6 +395,20 @@ export function importGpx(tripId: string, fileBuffer: Buffer, opts: GpxImportOpt
|
||||
const str = (v: unknown) => (v != null ? String(v).trim() : null);
|
||||
const num = (v: unknown) => { const n = parseFloat(String(v)); return isNaN(n) ? null : n; };
|
||||
|
||||
// Routes and tracks rarely carry their own <name>. Without one they all fall back to the
|
||||
// same generic label, so name-based dedup drops every import after the first. Derive a
|
||||
// base from the source filename (the requested behaviour) and suffix an index so multiple
|
||||
// geometries from one file stay distinct.
|
||||
const rawName = str(defaultName);
|
||||
const baseName = rawName ? rawName.replace(/\.[^.]+$/, '').trim() || rawName : null;
|
||||
let geoSeq = 0;
|
||||
const geoName = (explicit: string | null, fallback: string): string => {
|
||||
if (explicit) return explicit;
|
||||
geoSeq++;
|
||||
const base = baseName || fallback;
|
||||
return geoSeq === 1 ? base : `${base} ${geoSeq}`;
|
||||
};
|
||||
|
||||
type WaypointEntry = { name: string; lat: number; lng: number; description: string | null; routeGeometry?: string };
|
||||
const waypoints: WaypointEntry[] = [];
|
||||
|
||||
@@ -385,7 +431,7 @@ export function importGpx(tripId: string, fileBuffer: Buffer, opts: GpxImportOpt
|
||||
if (pts.length === 0) continue;
|
||||
const hasAllEle = pts.every(p => p.ele !== null);
|
||||
const routeGeometry = pts.map(p => hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]);
|
||||
waypoints.push({ lat: pts[0].lat, lng: pts[0].lng, name: str(rte.name) || 'GPX Route', description: str(rte.desc), routeGeometry: JSON.stringify(routeGeometry) });
|
||||
waypoints.push({ lat: pts[0].lat, lng: pts[0].lng, name: geoName(str(rte.name), 'GPX Route'), description: str(rte.desc), routeGeometry: JSON.stringify(routeGeometry) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,7 +451,7 @@ export function importGpx(tripId: string, fileBuffer: Buffer, opts: GpxImportOpt
|
||||
const start = trackPoints[0];
|
||||
const hasAllEle = trackPoints.every(p => p.ele !== null);
|
||||
const routeGeometry = trackPoints.map(p => hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]);
|
||||
waypoints.push({ lat: start.lat, lng: start.lng, name: str(trk.name) || 'GPX Track', description: str(trk.desc), routeGeometry: JSON.stringify(routeGeometry) });
|
||||
waypoints.push({ lat: start.lat, lng: start.lng, name: geoName(str(trk.name), 'GPX Track'), description: str(trk.desc), routeGeometry: JSON.stringify(routeGeometry) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,7 +625,7 @@ export async function importMapFile(tripId: string, fileBuffer: Buffer, filename
|
||||
// Import Google Maps list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function importGoogleList(tripId: string, url: string) {
|
||||
export async function importGoogleList(tripId: string, url: string, opts?: ListImportOptions) {
|
||||
let listId: string | null = null;
|
||||
let resolvedUrl = url;
|
||||
|
||||
@@ -587,10 +633,18 @@ export async function importGoogleList(tripId: string, url: string) {
|
||||
const ssrf = await checkSsrf(url);
|
||||
if (!ssrf.allowed) return { error: 'URL is not allowed', status: 400 };
|
||||
|
||||
// Follow redirects for short URLs (maps.app.goo.gl, goo.gl)
|
||||
// Follow redirects for short URLs (maps.app.goo.gl, goo.gl). Redirects are
|
||||
// followed manually so every hop is re-checked against the SSRF guard — a
|
||||
// short link that 302s to an internal IP is blocked even though the initial
|
||||
// host is public.
|
||||
if (url.includes('goo.gl') || url.includes('maps.app')) {
|
||||
const redirectRes = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(10000) });
|
||||
resolvedUrl = redirectRes.url;
|
||||
try {
|
||||
const redirectRes = await safeFetchFollow(url, { signal: AbortSignal.timeout(10000) });
|
||||
resolvedUrl = redirectRes.url;
|
||||
} catch (err) {
|
||||
if (err instanceof SsrfBlockedError) return { error: 'URL is not allowed', status: 400 };
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern: /placelists/list/{ID}
|
||||
@@ -673,6 +727,10 @@ export async function importGoogleList(tripId: string, url: string) {
|
||||
});
|
||||
insertAll();
|
||||
|
||||
if (opts?.enrich && opts.userId && created.length) {
|
||||
void enrichImportedPlaces(tripId, opts.userId, created as EnrichablePlace[], opts.lang);
|
||||
}
|
||||
|
||||
return { places: created, listName, skipped };
|
||||
}
|
||||
|
||||
@@ -683,7 +741,8 @@ export async function importGoogleList(tripId: string, url: string) {
|
||||
export async function importNaverList(
|
||||
tripId: string,
|
||||
url: string,
|
||||
): Promise<{ places: any[]; listName: string } | { error: string; status: number }> {
|
||||
opts?: ListImportOptions,
|
||||
): Promise<{ places: any[]; listName: string; skipped: number } | { error: string; status: number }> {
|
||||
let resolvedUrl = url;
|
||||
const limit = 20;
|
||||
|
||||
@@ -692,11 +751,18 @@ export async function importNaverList(
|
||||
if (!ssrf.allowed) return { error: 'URL is not allowed', status: 400 };
|
||||
|
||||
// Resolve naver.me short links to the canonical map.naver.com folder URL.
|
||||
// Redirects are followed manually so each hop is re-validated against the
|
||||
// SSRF guard (a short link could otherwise 302 to an internal address).
|
||||
let parsedUrl: URL;
|
||||
try { parsedUrl = new URL(url); } catch { return { error: 'Invalid URL', status: 400 }; }
|
||||
if (parsedUrl.hostname === 'naver.me') {
|
||||
const redirectRes = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(10000) });
|
||||
resolvedUrl = redirectRes.url;
|
||||
try {
|
||||
const redirectRes = await safeFetchFollow(url, { signal: AbortSignal.timeout(10000) });
|
||||
resolvedUrl = redirectRes.url;
|
||||
} catch (err) {
|
||||
if (err instanceof SsrfBlockedError) return { error: 'URL is not allowed', status: 400 };
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const folderMatch = resolvedUrl.match(/favorite\/myPlace\/folder\/([A-Za-z0-9_-]+)/i);
|
||||
@@ -795,6 +861,10 @@ export async function importNaverList(
|
||||
});
|
||||
insertAll();
|
||||
|
||||
if (opts?.enrich && opts.userId && created.length) {
|
||||
void enrichImportedPlaces(tripId, opts.userId, created as EnrichablePlace[], opts.lang);
|
||||
}
|
||||
|
||||
return { places: created, listName, skipped };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { db, canAccessTrip } from '../db/database';
|
||||
import { db } from '../db/database';
|
||||
import { Reservation } from '../types';
|
||||
|
||||
export { verifyTripAccess } from './tripAccess';
|
||||
|
||||
export interface ReservationEndpoint {
|
||||
id?: number;
|
||||
reservation_id?: number;
|
||||
@@ -17,10 +19,6 @@ export interface ReservationEndpoint {
|
||||
|
||||
type EndpointInput = Omit<ReservationEndpoint, 'id' | 'reservation_id' | 'sequence'> & { sequence?: number };
|
||||
|
||||
export function verifyTripAccess(tripId: string | number, userId: number) {
|
||||
return canAccessTrip(tripId, userId);
|
||||
}
|
||||
|
||||
function loadEndpointsByTrip(tripId: string | number): Map<number, ReservationEndpoint[]> {
|
||||
const rows = db.prepare(`
|
||||
SELECT e.* FROM reservation_endpoints e
|
||||
@@ -117,6 +115,40 @@ export function listReservations(tripId: string | number) {
|
||||
return reservations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upcoming reservations across all of a user's active trips, soonest first.
|
||||
* Used by the dashboard's "Upcoming reservations" widget. A reservation counts
|
||||
* as upcoming when its own time is in the future, or — for timeless entries —
|
||||
* when its day falls on or after today. Cancelled bookings are skipped.
|
||||
*/
|
||||
export function getUpcomingReservations(userId: number, limit = 6) {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const reservations = db.prepare(`
|
||||
SELECT r.id, r.trip_id, r.title, r.type, r.status, r.location,
|
||||
r.reservation_time, r.confirmation_number,
|
||||
t.title as trip_title, t.cover_image as trip_cover,
|
||||
d.date as day_date, p.name as place_name, p.image_url as place_image
|
||||
FROM reservations r
|
||||
JOIN trips t ON t.id = r.trip_id
|
||||
LEFT JOIN trip_members tm ON tm.trip_id = t.id AND tm.user_id = ?
|
||||
LEFT JOIN days d ON r.day_id = d.id
|
||||
LEFT JOIN places p ON r.place_id = p.id
|
||||
WHERE (t.user_id = ? OR tm.user_id IS NOT NULL)
|
||||
AND t.is_archived = 0
|
||||
AND r.status != 'cancelled'
|
||||
AND (
|
||||
(r.reservation_time IS NOT NULL AND r.reservation_time >= ?)
|
||||
OR (r.reservation_time IS NULL AND d.date IS NOT NULL AND d.date >= ?)
|
||||
)
|
||||
ORDER BY COALESCE(r.reservation_time, d.date) ASC
|
||||
LIMIT ?
|
||||
`).all(userId, userId, now, today, limit) as any[];
|
||||
|
||||
return reservations;
|
||||
}
|
||||
|
||||
export function getReservationWithJoins(id: string | number) {
|
||||
const row = db.prepare(`
|
||||
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id,
|
||||
@@ -264,15 +296,6 @@ export function updatePositions(tripId: string | number, positions: { id: number
|
||||
}
|
||||
}
|
||||
|
||||
export function getDayPositions(tripId: string | number, dayId: number | string) {
|
||||
return db.prepare(`
|
||||
SELECT rdp.reservation_id, rdp.position
|
||||
FROM reservation_day_positions rdp
|
||||
JOIN reservations r ON rdp.reservation_id = r.id
|
||||
WHERE r.trip_id = ? AND rdp.day_id = ?
|
||||
`).all(tripId, dayId) as { reservation_id: number; position: number }[];
|
||||
}
|
||||
|
||||
export function getReservation(id: string | number, tripId: string | number) {
|
||||
return db.prepare('SELECT * FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId) as Reservation | undefined;
|
||||
}
|
||||
|
||||
@@ -10,9 +10,15 @@ export const DEFAULTABLE_USER_SETTING_KEYS = [
|
||||
'temperature_unit',
|
||||
'dark_mode',
|
||||
'time_format',
|
||||
'route_calculation',
|
||||
'blur_booking_codes',
|
||||
'map_tile_url',
|
||||
// Instance-wide Mapbox defaults: an admin can set a shared token + style so the
|
||||
// whole instance uses Mapbox without each user pasting their own key (#920).
|
||||
'map_provider',
|
||||
'mapbox_access_token',
|
||||
'mapbox_style',
|
||||
'mapbox_3d_enabled',
|
||||
'mapbox_quality_mode',
|
||||
] as const;
|
||||
|
||||
type DefaultableKey = typeof DEFAULTABLE_USER_SETTING_KEYS[number];
|
||||
@@ -21,9 +27,10 @@ const VALID_VALUES: Partial<Record<DefaultableKey, unknown[]>> = {
|
||||
temperature_unit: ['fahrenheit', 'celsius'],
|
||||
time_format: ['12h', '24h'],
|
||||
dark_mode: [true, false, 'light', 'dark', 'auto'],
|
||||
map_provider: ['leaflet', 'mapbox-gl'],
|
||||
};
|
||||
|
||||
const BOOLEAN_KEYS = new Set<DefaultableKey>(['route_calculation', 'blur_booking_codes']);
|
||||
const BOOLEAN_KEYS = new Set<DefaultableKey>(['blur_booking_codes', 'mapbox_3d_enabled', 'mapbox_quality_mode']);
|
||||
|
||||
function parseValue(raw: string): unknown {
|
||||
try { return JSON.parse(raw); } catch { return raw; }
|
||||
@@ -36,7 +43,11 @@ export function getAdminUserDefaults(): Record<string, unknown> {
|
||||
const defaults: Record<string, unknown> = {};
|
||||
for (const row of rows) {
|
||||
const settingKey = row.key.slice('default_user_setting_'.length);
|
||||
defaults[settingKey] = parseValue(row.value);
|
||||
if (ENCRYPTED_SETTING_KEYS.has(settingKey)) {
|
||||
defaults[settingKey] = row.value ? (decrypt_api_key(row.value) ?? '') : '';
|
||||
} else {
|
||||
defaults[settingKey] = parseValue(row.value);
|
||||
}
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
@@ -71,7 +82,12 @@ export function setAdminUserDefaults(partial: Record<string, unknown>): void {
|
||||
throw new Error(`Invalid value for ${key}: ${value}`);
|
||||
}
|
||||
|
||||
upsert.run(appKey, JSON.stringify(value));
|
||||
// Encrypt sensitive defaults (the shared Mapbox token) at rest, like the
|
||||
// per-user equivalents; everything else is stored as plain JSON.
|
||||
const stored = ENCRYPTED_SETTING_KEYS.has(key)
|
||||
? (maybe_encrypt_api_key(String(value)) ?? String(value))
|
||||
: JSON.stringify(value);
|
||||
upsert.run(appKey, stored);
|
||||
}
|
||||
db.exec('COMMIT');
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
import { db, canAccessTrip } from '../db/database';
|
||||
import crypto from 'crypto';
|
||||
import { loadTagsByPlaceIds } from './queryHelpers';
|
||||
import { serveFilePath } from './placePhotoCache';
|
||||
|
||||
const PLACE_PHOTO_PROXY_PREFIX = '/api/maps/place-photo/';
|
||||
|
||||
/**
|
||||
* Place photo proxy URLs (`/api/maps/place-photo/<id>/bytes`) are served by the
|
||||
* JWT-guarded MapsController, so they 401 for an unauthenticated shared-trip
|
||||
* viewer. Rewrite them to the public, token-scoped equivalent
|
||||
* (`/api/shared/<token>/place-photo/<id>/bytes`) so thumbnails load in a shared
|
||||
* link. A simple prefix swap keeps the already-encoded placeId segment intact, so
|
||||
* the URL round-trips. Non-proxy URLs (data:, /uploads/, null) pass through.
|
||||
*/
|
||||
function rewritePlacePhotoUrl(url: string | null | undefined, token: string): string | null {
|
||||
if (typeof url === 'string' && url.startsWith(PLACE_PHOTO_PROXY_PREFIX)) {
|
||||
return `/api/shared/${token}/place-photo/${url.slice(PLACE_PHOTO_PROXY_PREFIX.length)}`;
|
||||
}
|
||||
return url ?? null;
|
||||
}
|
||||
|
||||
interface SharePermissions {
|
||||
share_map?: boolean;
|
||||
@@ -129,7 +147,7 @@ export function getSharedTripData(token: string): Record<string, any> | null {
|
||||
id: a.place_id, name: a.place_name, description: a.place_description,
|
||||
lat: a.lat, lng: a.lng, address: a.address, category_id: a.category_id,
|
||||
price: a.price, place_time: a.place_time, end_time: a.end_time,
|
||||
image_url: a.image_url, transport_mode: a.transport_mode,
|
||||
image_url: rewritePlacePhotoUrl(a.image_url, token), transport_mode: a.transport_mode,
|
||||
category: a.category_id ? { id: a.category_id, name: a.category_name, color: a.category_color, icon: a.category_icon } : null,
|
||||
tags: tagsByPlace[a.place_id] || [],
|
||||
}
|
||||
@@ -147,11 +165,11 @@ export function getSharedTripData(token: string): Record<string, any> | null {
|
||||
}
|
||||
|
||||
// Places
|
||||
const places = db.prepare(`
|
||||
const places = (db.prepare(`
|
||||
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM places p LEFT JOIN categories c ON p.category_id = c.id
|
||||
WHERE p.trip_id = ? ORDER BY p.created_at DESC
|
||||
`).all(tripId);
|
||||
`).all(tripId) as any[]).map((p) => ({ ...p, image_url: rewritePlacePhotoUrl(p.image_url, token) }));
|
||||
|
||||
// Reservations — include per-day positions so the client can render the same order as the planner
|
||||
const reservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ? ORDER BY reservation_time ASC').all(tripId) as any[];
|
||||
@@ -210,3 +228,26 @@ export function getSharedTripData(token: string): Record<string, any> | null {
|
||||
collab: collabMessages,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the on-disk path for a cached place photo requested through a public
|
||||
* share link. Validates that the token is valid + unexpired and that the place
|
||||
* actually belongs to that token's trip (matched via the stored proxy URL, which
|
||||
* covers both Google `placeId` and Wikimedia `coords:` pseudo-IDs without
|
||||
* depending on google_place_id). Returns null — never throws — so the caller
|
||||
* answers a plain 404, mirroring the authenticated bytes endpoint.
|
||||
*/
|
||||
export function getSharedPlacePhotoPath(token: string, placeId: string): string | null {
|
||||
const shareRow = db.prepare(
|
||||
"SELECT trip_id FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))"
|
||||
).get(token) as { trip_id: string } | undefined;
|
||||
if (!shareRow) return null;
|
||||
|
||||
const expectedUrl = `${PLACE_PHOTO_PROXY_PREFIX}${encodeURIComponent(placeId)}/bytes`;
|
||||
const place = db.prepare(
|
||||
'SELECT 1 FROM places WHERE trip_id = ? AND image_url = ?'
|
||||
).get(shareRow.trip_id, expectedUrl);
|
||||
if (!place) return null;
|
||||
|
||||
return serveFilePath(placeId);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { db, canAccessTrip } from '../db/database';
|
||||
import { db } from '../db/database';
|
||||
|
||||
export function verifyTripAccess(tripId: string | number, userId: number) {
|
||||
return canAccessTrip(tripId, userId);
|
||||
}
|
||||
export { verifyTripAccess } from './tripAccess';
|
||||
|
||||
// ── Items ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { canAccessTrip } from '../db/database';
|
||||
|
||||
/**
|
||||
* Returns the trip row if the user is the owner or a member, otherwise undefined.
|
||||
* Shared by the domain services so each one exposes the same access check.
|
||||
*/
|
||||
export function verifyTripAccess(tripId: string | number, userId: number) {
|
||||
return canAccessTrip(tripId, userId);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { db, canAccessTrip, isOwner } from '../db/database';
|
||||
import { db, isOwner } from '../db/database';
|
||||
import { Trip, User } from '../types';
|
||||
import { listDays, listAccommodations } from './dayService';
|
||||
import { listBudgetItems } from './budgetService';
|
||||
@@ -25,10 +25,7 @@ export const TRIP_SELECT = `
|
||||
|
||||
// ── Access helpers ────────────────────────────────────────────────────────
|
||||
|
||||
export function verifyTripAccess(tripId: string | number, userId: number) {
|
||||
return canAccessTrip(tripId, userId);
|
||||
}
|
||||
|
||||
export { verifyTripAccess } from './tripAccess';
|
||||
export { isOwner };
|
||||
|
||||
// ── Day generation ────────────────────────────────────────────────────────
|
||||
@@ -125,12 +122,26 @@ export function generateDays(tripId: number | bigint | string, startDate: string
|
||||
del.run(dated[i].id);
|
||||
}
|
||||
|
||||
// Any remaining unused dateless days: keep as dateless, just renumber.
|
||||
// Any remaining unused dateless days: drop the empty placeholders so day_count
|
||||
// reflects the dated range, but keep ones that still hold content (assignments,
|
||||
// notes, accommodations) — mirrors the dateless-path trimming above (#1083).
|
||||
// Base must be max(targetDates.length, dated.length) to avoid colliding with
|
||||
// positives already assigned by the main loop or the overflow loop above.
|
||||
const isEmptyDay = db.prepare(
|
||||
`SELECT NOT EXISTS (SELECT 1 FROM day_assignments da WHERE da.day_id = @id)
|
||||
AND NOT EXISTS (SELECT 1 FROM day_notes dn WHERE dn.day_id = @id)
|
||||
AND NOT EXISTS (SELECT 1 FROM day_accommodations dac WHERE dac.start_day_id = @id OR dac.end_day_id = @id) AS empty`
|
||||
);
|
||||
const maxAssigned = Math.max(targetDates.length, dated.length);
|
||||
let keptDateless = 0;
|
||||
for (let i = datelessIdx; i < dateless.length; i++) {
|
||||
setDayNumber.run(maxAssigned + (i - datelessIdx) + 1, dateless[i].id);
|
||||
const empty = (isEmptyDay.get({ id: dateless[i].id }) as { empty: number }).empty;
|
||||
if (empty) {
|
||||
del.run(dateless[i].id);
|
||||
} else {
|
||||
setDayNumber.run(maxAssigned + keptDateless + 1, dateless[i].id);
|
||||
keptDateless++;
|
||||
}
|
||||
}
|
||||
|
||||
// Final renumber to compact and eliminate any gaps/negatives
|
||||
@@ -189,7 +200,7 @@ export function getTrip(tripId: string | number, userId: number) {
|
||||
${TRIP_SELECT}
|
||||
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
|
||||
WHERE t.id = :tripId AND (t.user_id = :userId OR m.user_id IS NOT NULL)
|
||||
`).get({ userId, tripId });
|
||||
`).get({ userId, tripId }) as Trip | undefined;
|
||||
}
|
||||
|
||||
interface UpdateTripData {
|
||||
@@ -307,10 +318,12 @@ export function deleteTrip(tripId: string | number, userId: number, userRole: st
|
||||
|
||||
export function deleteOldCover(coverImage: string | null | undefined) {
|
||||
if (!coverImage) return;
|
||||
const oldPath = path.join(__dirname, '../../', coverImage.replace(/^\//, ''));
|
||||
const resolvedPath = path.resolve(oldPath);
|
||||
const uploadsDir = path.resolve(__dirname, '../../uploads');
|
||||
if (resolvedPath.startsWith(uploadsDir) && fs.existsSync(resolvedPath)) {
|
||||
// cover_image is client-supplied, so treat it as untrusted: covers live in
|
||||
// uploads/covers as a flat filename — use basename() and confine the unlink
|
||||
// to that directory.
|
||||
const coversDir = path.resolve(__dirname, '../../uploads/covers');
|
||||
const resolvedPath = path.resolve(path.join(coversDir, path.basename(coverImage)));
|
||||
if (resolvedPath.startsWith(coversDir + path.sep) && fs.existsSync(resolvedPath)) {
|
||||
fs.unlinkSync(resolvedPath);
|
||||
}
|
||||
}
|
||||
@@ -530,8 +543,14 @@ export function exportICS(tripId: string | number): { ics: string; filename: str
|
||||
if (r.confirmation_number) desc += `\nConfirmation: ${r.confirmation_number}`;
|
||||
if (meta.airline) desc += `\nAirline: ${meta.airline}`;
|
||||
if (meta.flight_number) desc += `\nFlight: ${meta.flight_number}`;
|
||||
if (meta.departure_airport) desc += `\nFrom: ${meta.departure_airport}`;
|
||||
if (meta.arrival_airport) desc += `\nTo: ${meta.arrival_airport}`;
|
||||
if (Array.isArray(meta.legs) && meta.legs.length > 1) {
|
||||
// Multi-leg flight: show the whole route (FRA → BER → HND) on one event.
|
||||
const stops = [meta.legs[0]?.from, ...meta.legs.map((l: { to?: string }) => l.to)].filter(Boolean);
|
||||
if (stops.length) desc += `\nRoute: ${stops.join(' → ')}`;
|
||||
} else {
|
||||
if (meta.departure_airport) desc += `\nFrom: ${meta.departure_airport}`;
|
||||
if (meta.arrival_airport) desc += `\nTo: ${meta.arrival_airport}`;
|
||||
}
|
||||
if (meta.train_number) desc += `\nTrain: ${meta.train_number}`;
|
||||
if (r.notes) desc += `\n${r.notes}`;
|
||||
if (desc) ics += `DESCRIPTION:${esc(desc)}\r\n`;
|
||||
@@ -636,19 +655,22 @@ export function copyTripById(sourceTripId: string | number, newOwnerId: number,
|
||||
|
||||
const oldReservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ?').all(sourceTripId) as any[];
|
||||
const insertReservation = db.prepare(`
|
||||
INSERT INTO reservations (trip_id, day_id, place_id, assignment_id, accommodation_id, title, reservation_time, reservation_end_time,
|
||||
location, confirmation_number, notes, status, type, metadata, day_plan_position)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO reservations (trip_id, day_id, end_day_id, place_id, assignment_id, accommodation_id, title, reservation_time, reservation_end_time,
|
||||
location, confirmation_number, notes, status, type, metadata, day_plan_position, needs_review)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const r of oldReservations) {
|
||||
insertReservation.run(newTripId,
|
||||
r.day_id ? (dayMap.get(r.day_id) ?? null) : null,
|
||||
// end_day_id is a day reference too (multi-day transport) — remap it like
|
||||
// day_id, otherwise the duplicated trip loses the reservation's end-day link.
|
||||
r.end_day_id ? (dayMap.get(r.end_day_id) ?? null) : null,
|
||||
r.place_id ? (placeMap.get(r.place_id) ?? null) : null,
|
||||
r.assignment_id ? (assignmentMap.get(r.assignment_id) ?? null) : null,
|
||||
r.accommodation_id ? (accomMap.get(r.accommodation_id) ?? null) : null,
|
||||
r.title, r.reservation_time, r.reservation_end_time,
|
||||
r.location, r.confirmation_number, r.notes, r.status, r.type,
|
||||
r.metadata, r.day_plan_position);
|
||||
r.metadata, r.day_plan_position, r.needs_review ?? 0);
|
||||
}
|
||||
|
||||
const oldBudget = db.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(sourceTripId) as any[];
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { db } from '../db/database';
|
||||
import { getAppUrl } from './notifications';
|
||||
|
||||
/**
|
||||
* Resolves the WebAuthn Relying Party ID + allowed origins for this deployment.
|
||||
*
|
||||
* SECURITY: the RP ID and the allowed origins are derived ONLY from server-side
|
||||
* configuration — the `webauthn_rp_id` / `webauthn_origins` admin settings (or
|
||||
* the matching env vars), falling back to APP_URL. They are NEVER taken from the
|
||||
* request `Host` / `X-Forwarded-Host` header: a forged forwarded host would
|
||||
* otherwise let an attacker bind credentials to a domain they control, or brick
|
||||
* every enrolled user. This mirrors how OIDC derives its redirect URI from
|
||||
* APP_URL (oidc.controller.ts) rather than from request input.
|
||||
*
|
||||
* Returns null when no usable RP ID can be resolved (bare IP host, or nothing
|
||||
* configured) — the feature then reports itself as "not configured" and stays
|
||||
* disabled so nobody can enrol a credential bound to the wrong origin.
|
||||
*/
|
||||
|
||||
function getSetting(key: string): string | null {
|
||||
const raw = (db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value;
|
||||
const trimmed = raw?.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function hostOf(url: string): string | null {
|
||||
try {
|
||||
return new URL(url).hostname || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** WebAuthn RP IDs must be registrable domains — never bare IP literals. */
|
||||
function isIpHost(host: string): boolean {
|
||||
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return true; // IPv4
|
||||
if (host.includes(':')) return true; // IPv6 (hostname keeps the colons)
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface WebauthnConfig {
|
||||
rpID: string;
|
||||
rpName: string;
|
||||
/** Exact allowed origins (scheme + host + port). One in prod; localhost dev adds the Vite/API ports. */
|
||||
origins: string[];
|
||||
}
|
||||
|
||||
export function resolveWebauthnConfig(): WebauthnConfig | null {
|
||||
// 1. Explicit operator config always wins.
|
||||
const explicitRpId = (process.env.WEBAUTHN_RP_ID || getSetting('webauthn_rp_id'))?.trim() || null;
|
||||
const explicitOrigins = (process.env.WEBAUTHN_ORIGINS || getSetting('webauthn_origins') || '')
|
||||
.split(',')
|
||||
.map((o) => o.trim().replace(/\/+$/, ''))
|
||||
.filter(Boolean);
|
||||
|
||||
const appUrl = getAppUrl();
|
||||
const appHost = hostOf(appUrl);
|
||||
|
||||
// 2. Derive the RP ID from APP_URL when not explicitly set.
|
||||
let rpID = explicitRpId;
|
||||
if (!rpID && appHost && !isIpHost(appHost)) {
|
||||
rpID = appHost; // a real domain, or "localhost"
|
||||
}
|
||||
if (!rpID) return null; // bare IP / unresolved → WebAuthn cannot be used here
|
||||
|
||||
// 3. Resolve the allowed origins. Explicit list wins verbatim (operator's
|
||||
// responsibility). Otherwise derive a SINGLE origin from APP_URL — we never
|
||||
// silently union dev localhost origins into a production allow-list.
|
||||
let origins = explicitOrigins;
|
||||
if (origins.length === 0) {
|
||||
if (appHost) origins = [appUrl.replace(/\/+$/, '')];
|
||||
if (rpID === 'localhost') {
|
||||
// Dev: the browser origin is the Vite dev server (:5173), not the API port.
|
||||
origins = Array.from(new Set([...origins, 'http://localhost:5173', 'http://localhost:3001']));
|
||||
}
|
||||
}
|
||||
if (origins.length === 0) return null;
|
||||
|
||||
return { rpID, rpName: 'TREK', origins };
|
||||
}
|
||||
|
||||
/** True when a usable RP ID resolves for this deployment (exposed as a pure boolean on app-config). */
|
||||
export function isPasskeyConfigured(): boolean {
|
||||
return resolveWebauthnConfig() !== null;
|
||||
}
|
||||
Reference in New Issue
Block a user