From 002ea91be89f5d1630a0774cd403f900009da2af Mon Sep 17 00:00:00 2001 From: "Julien G." <66769052+jubnl@users.noreply.github.com> Date: Thu, 23 Apr 2026 19:57:50 +0200 Subject: [PATCH] Align dev (#869) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: bump version to 3.0.0 [skip ci] * fix: resolve dead wiki links across install and config pages * fix(reservations): restore correct day assignment for non-transport bookings v3.0.0 switched the planner from rendering reservations by reservation_time to rendering them by day_id (commit 3f61e1c), but migration 110 only backfilled day_id for transport types. Tours, restaurants, events and 'other' bookings kept whatever day_id was stored in the DB — often the trip's first day, from older code paths that defaulted it there — so after the upgrade those rows all show up on day 1 regardless of their actual reservation_time. - Migration 122: for every non-hotel reservation, null out any day_id / end_day_id that does not match the reservation's time, then backfill it from reservation_time / reservation_end_time. Idempotent; leaves already-correct rows alone. - reservationService.createReservation / updateReservation now derive day_id / end_day_id from reservation_time / reservation_end_time when the client didn't send one explicitly, so the mismatch cannot reappear on new or edited bookings. Hotels are skipped because they store their date range on the linked day_accommodation. * chore: bump version to 3.0.1 [skip ci] * fix(oidc): normalize discovery doc issuer before comparison Trailing slash in doc.issuer (e.g. Authentik) caused a mismatch against the already-normalized configured issuer, breaking OIDC login entirely. Closes #834 * test(systemNotices): exclude v3 upgrade notices from login_count-only tests Tests that expect an empty notice list were using first_seen_version='0.0.0' (DB default), which matches the existingUserBeforeVersion('3.0.0') condition now that the app is at 3.0.1. Set first_seen_version='3.0.0' so only the firstLogin condition controls visibility in these tests. * chore: bump version to 3.0.2 [skip ci] * fix(oidc): normalize id_token iss claim before issuer comparison (#837) jwt.verify does an exact string match on the issuer. Providers like Authentik include a trailing slash in the id_token iss claim while the configured issuer is already normalized (no trailing slash), causing every login attempt to fail with jwt issuer invalid. Move the issuer check out of jwt.verify options and apply the same trailing-slash normalization used in the discovery doc validation. Also adds OIDC-SVC-033–036 unit tests covering exact match, trailing slash, wrong issuer, and wrong audience cases. Closes #834 * chore: bump version to 3.0.3 [skip ci] * fix(oidc,ui): restore Authentik login and fix mobile delete dialog (#845) OIDC: when OIDC_DISCOVERY_URL is explicitly set, trust the discovery doc's issuer for id_token comparison instead of rejecting a path mismatch as an error. Authentik (and similar realm-path providers) return a canonical issuer like /application/o// that differs from the operator's base OIDC_ISSUER. Strict equality blocked login in 3.x despite working in v2. Default discovery (no custom URL) keeps the strict check. Adds OIDC-SVC-037/038/039. UI: ConfirmDialog and CopyTripDialog lacked the --bottom-nav-h paddingBottom offset that other overlays already use. On mobile portrait the action buttons were hidden behind the sticky bottom nav bar. Closes #843 Closes #844 * chore: bump version to 3.0.4 [skip ci] * fix(files): open attachments only in new tab (#840) window.open with noreferrer returns null, which triggered the popup-blocked download fallback in addition to the new-tab open. Use a target=_blank anchor click instead. * chore: bump version to 3.0.5 [skip ci] * fix(journey,pdf): journey reorder sort_order + PDF multi-day transport (#848) * fix(journey): make sort_order authoritative for within-day entry ordering Reorder buttons appeared broken because the server ORDER BY put entry_time before sort_order, so entries synced from trip places with differing times would always sort by time regardless of sort_order writes. The client store mirrored the same comparator, making even the optimistic update invisible. - Change ORDER BY to (entry_date, sort_order, id) in getJourneyFull and listEntries - Fix syncTripPlaces and onPlaceCreated to assign MAX+1 sort_order per day instead of day_number/0 - Update client store comparator to match - Add DB migration to backfill sort_order using old effective key (entry_time, id) so existing journeys retain their visual order - Add tests: JOURNEY-SVC-089–093, FE-STORE-JOURNEY-018–019 Closes #846 * fix(pdf): include multi-day transport return/arrival in PDF itinerary (#847) Reservations were matched to days by pickup date only, so the end-day card (e.g. car Return, flight Arrival) was silently dropped from the PDF. Add span-aware helpers mirroring DayPlanSidebar logic: match by day_id/end_day_id span, show reservation_end_time on end days, prefix title with phase label (Return/Arrival/etc.), and use per-day position for sort order. * test(pdf): add missing day_id to transport reservation fixture * chore: bump version to 3.0.6 [skip ci] * [Snyk] Security upgrade uuid from 9.0.1 to 14.0.0 (#849) * fix: server/package.json & server/package-lock.json to reduce vulnerabilities The following vulnerabilities are fixed with an upgrade: - https://snyk.io/vuln/SNYK-JS-UUID-16133035 * fix: bump fast-xml-parser version --------- Co-authored-by: snyk-bot Co-authored-by: jubnl * chore: bump version to 3.0.7 [skip ci] * fix: hot fixes 23-04-2026 (#856) * fix(packing): resolve avatar URL path in bag and category assignees (#854) packingService was returning raw avatar filenames from the DB instead of the full /uploads/avatars/ path, causing broken profile images for users with uploaded avatars. * fix(budget): use Map.get() to fix category rename no-op (#855) * fix(security): relax Referrer-Policy and document HSTS_INCLUDE_SUBDOMAINS (#862) (#863) - Change Helmet default from no-referrer to strict-origin-when-cross-origin so browsers send the origin on cross-origin requests, allowing Google Maps API key restrictions by HTTP referrer to work correctly - Document HSTS_INCLUDE_SUBDOMAINS in all deployment artifacts: .env.example, docker-compose.yml, README.md, unraid-template.xml, charts/values.yaml, charts/configmap.yaml, wiki/Environment-Variables.md * fix(planner): prefetch budget items on trip page mount (#864) Loads budgetItems alongside reservations when TripPlannerPage mounts so the Budget category dropdown in ReservationModal and TransportModal shows pre-existing categories on first open, regardless of whether the Budget tab has been visited. Closes #861 * fix(reservations): prevent Invalid Date when end time is set without end date (#866) When reservation_end_time held a bare time string ("HH:MM"), fmtDate() produced Invalid Date on the reservation card. - Modal: when end date is blank but end time is filled, construct a same-day ISO datetime using the start date (prevents time-only strings from ever being persisted) - Panel: derive endDatePart via regex so date-only end values ("YYYY-MM-DD") still show the multi-day range, while bare time strings are skipped and handled correctly by the existing time column logic Closes #860 * fix(planner): format reservation end time instead of rendering raw ISO string (#867) Closes #859 * fix(planner): wire Route toggle into mobile day sidebar (#850) (#868) The per-booking Route icon was missing on mobile because the mobile DayPlanSidebar invocation in TripPlannerPage didn't pass visibleConnectionIds or onToggleConnection. Mobile PWA users couldn't activate reservation map overlays without forcing desktop mode. Also corrects the Map-Features wiki: fixes the setting name ("Booking route labels" not "Show connection labels"), documents the route_calculation requirement for travel-time pills, and explains that overlays are off by default and must be toggled per reservation. * chore: bump version to 3.0.8 [skip ci] --------- Co-authored-by: Maurice <61554723+mauriceboe@users.noreply.github.com> Co-authored-by: github-actions[bot] Co-authored-by: Maurice Co-authored-by: Xre0uS <36565320+Xre0uS@users.noreply.github.com> Co-authored-by: snyk-bot --- .github/workflows/wiki.yml | 2 +- README.md | 1 + charts/trek/Chart.yaml | 4 +- charts/trek/templates/configmap.yaml | 3 + charts/trek/values.yaml | 2 + client/package-lock.json | 4 +- client/package.json | 2 +- client/src/components/Budget/BudgetPanel.tsx | 2 +- client/src/components/PDF/TripPDF.test.ts | 1 + client/src/components/PDF/TripPDF.tsx | 55 ++++++-- .../src/components/Planner/DayDetailPanel.tsx | 6 +- .../src/components/Planner/DayPlanSidebar.tsx | 5 +- .../components/Planner/ReservationModal.tsx | 2 + .../components/Planner/ReservationsPanel.tsx | 11 +- .../src/components/shared/ConfirmDialog.tsx | 2 +- .../src/components/shared/CopyTripDialog.tsx | 2 +- client/src/pages/TripPlannerPage.tsx | 7 +- client/src/store/journeyStore.test.ts | 31 +++++ client/src/store/journeyStore.ts | 6 +- client/src/utils/fileDownload.ts | 33 ++++- client/tests/unit/utils/fileDownload.test.ts | 69 +++++++--- docker-compose.yml | 1 + server/.env.example | 1 + server/package-lock.json | 89 ++++--------- server/package.json | 4 +- server/src/app.ts | 1 + server/src/db/migrations.ts | 87 +++++++++++++ server/src/routes/oidc.ts | 2 +- server/src/services/journeyService.ts | 23 +++- server/src/services/oidcService.ts | 28 ++++- server/src/services/packingService.ts | 18 ++- server/src/services/reservationService.ts | 74 +++++++++-- .../tests/integration/systemNotices.test.ts | 7 +- .../unit/services/journeyService.test.ts | 106 ++++++++++++++++ .../tests/unit/services/oidcService.test.ts | 119 ++++++++++++++++++ unraid-template.xml | 1 + wiki/Environment-Variables.md | 15 +-- wiki/Install-Docker-Compose.md | 10 +- wiki/Install-Docker.md | 12 +- wiki/Install-Helm.md | 4 +- wiki/Install-Unraid.md | 4 +- wiki/Map-Features.md | 8 +- wiki/Quick-Start.md | 8 +- wiki/Reverse-Proxy.md | 6 +- wiki/Updating.md | 10 +- 45 files changed, 711 insertions(+), 177 deletions(-) diff --git a/.github/workflows/wiki.yml b/.github/workflows/wiki.yml index 440b6b7a..3526e38e 100644 --- a/.github/workflows/wiki.yml +++ b/.github/workflows/wiki.yml @@ -23,4 +23,4 @@ jobs: - name: Publish to GitHub wiki uses: Andrew-Chen-Wang/github-wiki-action@v5 with: - strategy: init + strategy: clone diff --git a/README.md b/README.md index 3e90a0e8..f3dfae0f 100644 --- a/README.md +++ b/README.md @@ -400,6 +400,7 @@ Caddy handles TLS and WebSockets automatically. | `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` | | `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin | | `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` | +| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` | | `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto | | `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` | | `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` | diff --git a/charts/trek/Chart.yaml b/charts/trek/Chart.yaml index 71765000..7ce3bafe 100644 --- a/charts/trek/Chart.yaml +++ b/charts/trek/Chart.yaml @@ -1,5 +1,5 @@ apiVersion: v2 name: trek -version: 2.9.14 +version: 3.0.8 description: Minimal Helm chart for TREK app -appVersion: "2.9.14" +appVersion: "3.0.8" diff --git a/charts/trek/templates/configmap.yaml b/charts/trek/templates/configmap.yaml index af3a7182..33efce0c 100644 --- a/charts/trek/templates/configmap.yaml +++ b/charts/trek/templates/configmap.yaml @@ -22,6 +22,9 @@ data: {{- if .Values.env.FORCE_HTTPS }} FORCE_HTTPS: {{ .Values.env.FORCE_HTTPS | quote }} {{- end }} + {{- if .Values.env.HSTS_INCLUDE_SUBDOMAINS }} + HSTS_INCLUDE_SUBDOMAINS: {{ .Values.env.HSTS_INCLUDE_SUBDOMAINS | quote }} + {{- end }} {{- if .Values.env.COOKIE_SECURE }} COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }} {{- end }} diff --git a/charts/trek/values.yaml b/charts/trek/values.yaml index 42c86b1f..0f19d230 100644 --- a/charts/trek/values.yaml +++ b/charts/trek/values.yaml @@ -30,6 +30,8 @@ env: # Also used as the base URL for links in email notifications and other external links. # FORCE_HTTPS: "false" # Optional. When "true": HTTPS redirect, HSTS, CSP upgrade-insecure-requests, secure cookies. Only behind a TLS proxy. Requires TRUST_PROXY. + # HSTS_INCLUDE_SUBDOMAINS: "false" + # When "true": adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave "false" if sibling subdomains still run over plain HTTP. # COOKIE_SECURE: "true" # Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production. # TRUST_PROXY: "1" diff --git a/client/package-lock.json b/client/package-lock.json index 1763c702..6dd3d7b8 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "trek-client", - "version": "2.9.14", + "version": "3.0.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "trek-client", - "version": "2.9.14", + "version": "3.0.8", "dependencies": { "@react-pdf/renderer": "^4.3.2", "axios": "^1.6.7", diff --git a/client/package.json b/client/package.json index 9efbb68c..3e508bb2 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "trek-client", - "version": "2.9.14", + "version": "3.0.8", "private": true, "type": "module", "scripts": { diff --git a/client/src/components/Budget/BudgetPanel.tsx b/client/src/components/Budget/BudgetPanel.tsx index e442a0ff..a91a977e 100644 --- a/client/src/components/Budget/BudgetPanel.tsx +++ b/client/src/components/Budget/BudgetPanel.tsx @@ -634,7 +634,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro } const handleRenameCategory = async (oldName, newName) => { if (!newName.trim() || newName.trim() === oldName) return - const items = grouped[oldName] || [] + const items = grouped.get(oldName) || [] for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() }) } const handleAddCategory = () => { diff --git a/client/src/components/PDF/TripPDF.test.ts b/client/src/components/PDF/TripPDF.test.ts index 46549188..77e33eac 100644 --- a/client/src/components/PDF/TripPDF.test.ts +++ b/client/src/components/PDF/TripPDF.test.ts @@ -78,6 +78,7 @@ const transportReservation = { id: 400, title: 'Flight to Rome', type: 'flight', + day_id: 10, reservation_time: '2025-06-01T14:30:00', confirmation_number: 'ABC123', metadata: JSON.stringify({ airline: 'Air Italia', flight_number: 'AI123', departure_airport: 'CDG', arrival_airport: 'FCO' }), diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx index 040bb711..1a5a3316 100644 --- a/client/src/components/PDF/TripPDF.tsx +++ b/client/src/components/PDF/TripPDF.tsx @@ -140,23 +140,58 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor const totalCost = Object.values(assignments || {}) .flatMap(a => a).reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0) + // Span helpers for multi-day transport (mirrors DayPlanSidebar logic) + const pdfGetDayOrder = (d: Day) => d.day_number + const pdfGetSpanPhase = (r: any, dayId: number): 'single' | 'start' | 'middle' | 'end' => { + const startId = r.day_id + const endId = r.end_day_id ?? startId + if (!startId || startId === endId) return 'single' + if (dayId === startId) return 'start' + if (dayId === endId) return 'end' + return 'middle' + } + const pdfGetDisplayTime = (r: any, dayId: number): string | null => { + const phase = pdfGetSpanPhase(r, dayId) + if (phase === 'end') return r.reservation_end_time || null + if (phase === 'middle') return null + return r.reservation_time || null + } + const pdfGetSpanLabel = (r: any, phase: string): string | null => { + if (phase === 'single') return null + if (r.type === 'flight') return tr(`reservations.span.${phase === 'start' ? 'departure' : phase === 'end' ? 'arrival' : 'inTransit'}`) + if (r.type === 'car') return tr(`reservations.span.${phase === 'start' ? 'pickup' : phase === 'end' ? 'return' : 'active'}`) + return tr(`reservations.span.${phase === 'start' ? 'start' : phase === 'end' ? 'end' : 'ongoing'}`) + } + const pdfGetTransportForDay = (dayId: number) => (reservations || []).filter(r => { + if (r.type === 'hotel') return false + const startId = r.day_id + const endId = r.end_day_id ?? startId + if (startId == null) return false + if (endId !== startId) { + const startDay = sorted.find(d => d.id === startId) + const endDay = sorted.find(d => d.id === endId) + const thisDay = sorted.find(d => d.id === dayId) + if (!startDay || !endDay || !thisDay) return false + return pdfGetDayOrder(thisDay) >= pdfGetDayOrder(startDay) && pdfGetDayOrder(thisDay) <= pdfGetDayOrder(endDay) + } + return startId === dayId + }) + // Build day HTML const daysHtml = sorted.map((day, di) => { const assigned = assignments[String(day.id)] || [] const notes = (dayNotes || []).filter(n => n.day_id === day.id) const cost = dayCost(assignments, day.id, loc) - // Reservations for this day (hotel rendered via accommodations block) - const dayReservations = (reservations || []).filter(r => { - if (!r.reservation_time || r.type === 'hotel') return false - return day.date && r.reservation_time.split('T')[0] === day.date - }) + // Reservations for this day (hotel rendered via accommodations block; car middle-phase rendered in sidebar header only) + const dayReservations = pdfGetTransportForDay(day.id) + .filter(r => !(r.type === 'car' && pdfGetSpanPhase(r, day.id) === 'middle')) const merged = [] assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a })) notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n })) dayReservations.forEach(r => { - const pos = r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5) + const pos = r.day_positions?.[day.id] ?? r.day_positions?.[String(day.id)] ?? r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5) merged.push({ type: 'reservation', k: pos, data: r }) }) merged.sort((a, b) => a.k - b.k) @@ -177,13 +212,17 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ') else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ') const locationLine = r.location || meta.location || '' - const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : '' + const phase = pdfGetSpanPhase(r, day.id) + const spanLabel = pdfGetSpanLabel(r, phase) + const displayTime = pdfGetDisplayTime(r, day.id) + const time = displayTime?.includes('T') ? displayTime.split('T')[1]?.substring(0, 5) : '' + const titleHtml = `${spanLabel ? escHtml(spanLabel) + ': ' : ''}${escHtml(r.title)}` return `
${icon}
-
${escHtml(r.title)}${time ? ` ${time}` : ''}
+
${titleHtml}${time ? ` ${time}` : ''}
${subtitle ? `
${escHtml(subtitle)}
` : ''} ${locationLine ? `
${escHtml(locationLine)}
` : ''} ${r.confirmation_number ? `
Code: ${escHtml(r.confirmation_number)}
` : ''} diff --git a/client/src/components/Planner/DayDetailPanel.tsx b/client/src/components/Planner/DayDetailPanel.tsx index 8ef2282b..9487f402 100644 --- a/client/src/components/Planner/DayDetailPanel.tsx +++ b/client/src/components/Planner/DayDetailPanel.tsx @@ -66,7 +66,11 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit' const is12h = useSettingsStore(s => s.settings.time_format) === '12h' const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes) - const fmtTime = (v) => formatTime12(v, is12h) + const fmtTime = (v) => { + if (!v) return v + if (v.includes('T')) return new Date(v).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h }) + return formatTime12(v, is12h) + } const unit = isFahrenheit ? '°F' : '°C' const collapsed = collapsedProp const toggleCollapse = () => onToggleCollapse?.() diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 64049e89..b1eda2d3 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -1576,7 +1576,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ {res.reservation_time?.includes('T') && ( {new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })} - {res.reservation_end_time && ` – ${res.reservation_end_time}`} + {res.reservation_end_time && ` – ${(() => { + const endStr = res.reservation_end_time.includes('T') ? res.reservation_end_time : (res.reservation_time.split('T')[0] + 'T' + res.reservation_end_time) + return new Date(endStr).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' }) + })()}`} )} {(() => { diff --git a/client/src/components/Planner/ReservationModal.tsx b/client/src/components/Planner/ReservationModal.tsx index 7fa8a7e8..f5ec6f13 100644 --- a/client/src/components/Planner/ReservationModal.tsx +++ b/client/src/components/Planner/ReservationModal.tsx @@ -182,6 +182,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p let combinedEndTime = form.reservation_end_time if (form.end_date) { combinedEndTime = form.reservation_end_time ? `${form.end_date}T${form.reservation_end_time}` : form.end_date + } else if (form.reservation_end_time && form.reservation_time) { + combinedEndTime = `${form.reservation_time.split('T')[0]}T${form.reservation_end_time}` } if (isBudgetEnabled) { if (form.price) metadata.price = form.price diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx index 00c105c2..770d36a5 100644 --- a/client/src/components/Planner/ReservationsPanel.tsx +++ b/client/src/components/Planner/ReservationsPanel.tsx @@ -236,7 +236,16 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
{t('reservations.date')}
{fmtDate(r.reservation_time)} - {r.reservation_end_time && (r.reservation_end_time.includes('T') ? r.reservation_end_time.split('T')[0] : r.reservation_end_time) !== r.reservation_time.split('T')[0] && ( + {(() => { + const endDatePart = r.reservation_end_time + ? r.reservation_end_time.includes('T') + ? r.reservation_end_time.split('T')[0] + : /^\d{4}-\d{2}-\d{2}$/.test(r.reservation_end_time) + ? r.reservation_end_time + : null + : null + return endDatePart && endDatePart !== r.reservation_time.split('T')[0] + })() && ( <> – {fmtDate(r.reservation_end_time)} )}
diff --git a/client/src/components/shared/ConfirmDialog.tsx b/client/src/components/shared/ConfirmDialog.tsx index 31cd9295..d75ad190 100644 --- a/client/src/components/shared/ConfirmDialog.tsx +++ b/client/src/components/shared/ConfirmDialog.tsx @@ -41,7 +41,7 @@ export default function ConfirmDialog({ return (
{ - if (tripId) tripActions.loadReservations(tripId) + if (tripId) { + tripActions.loadReservations(tripId) + tripActions.loadBudgetItems?.(tripId) + } }, [tripId]) useTripWebSocket(tripId) @@ -1106,7 +1109,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
{mobileSidebarOpen === 'left' - ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} /> + ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} /> : { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} /> }
diff --git a/client/src/store/journeyStore.test.ts b/client/src/store/journeyStore.test.ts index 564969ec..d5c7a2a3 100644 --- a/client/src/store/journeyStore.test.ts +++ b/client/src/store/journeyStore.test.ts @@ -355,6 +355,37 @@ describe('journeyStore', () => { expect(useJourneyStore.getState().loading).toBe(false); }); + // ── reorderEntries ─────────────────────────────────────────────────────── + + it('FE-STORE-JOURNEY-018: reorderEntries reorders by sort_order not entry_time', async () => { + const a = buildEntry({ id: 201, entry_date: '2026-04-01', entry_time: '09:00', sort_order: 0 }); + const b = buildEntry({ id: 202, entry_date: '2026-04-01', entry_time: '11:00', sort_order: 1 }); + const c = buildEntry({ id: 203, entry_date: '2026-04-01', entry_time: '14:00', sort_order: 2 }); + const detail = buildJourneyDetail({ id: 55, entries: [a, b, c] }); + useJourneyStore.setState({ current: detail }); + + server.use( + http.put('/api/journeys/55/entries/reorder', () => HttpResponse.json({ success: true })) + ); + await useJourneyStore.getState().reorderEntries(55, [202, 201, 203]); + const ids = useJourneyStore.getState().current?.entries.map(e => e.id); + expect(ids).toEqual([202, 201, 203]); + }); + + it('FE-STORE-JOURNEY-019: reorderEntries rolls back on API failure', async () => { + const a = buildEntry({ id: 211, entry_date: '2026-04-01', sort_order: 0 }); + const b = buildEntry({ id: 212, entry_date: '2026-04-01', sort_order: 1 }); + const detail = buildJourneyDetail({ id: 56, entries: [a, b] }); + useJourneyStore.setState({ current: detail }); + + server.use( + http.put('/api/journeys/56/entries/reorder', () => HttpResponse.json({}, { status: 403 })) + ); + await expect(useJourneyStore.getState().reorderEntries(56, [212, 211])).rejects.toBeTruthy(); + const ids = useJourneyStore.getState().current?.entries.map(e => e.id); + expect(ids).toEqual([211, 212]); + }); + // ── clear ──────────────────────────────────────────────────────────────── it('FE-STORE-JOURNEY-015: clear resets state', () => { diff --git a/client/src/store/journeyStore.ts b/client/src/store/journeyStore.ts index 47b75971..c2edfa69 100644 --- a/client/src/store/journeyStore.ts +++ b/client/src/store/journeyStore.ts @@ -223,10 +223,8 @@ export const useJourneyStore = create((set, get) => ({ ) entries.sort((a, b) => { if (a.entry_date !== b.entry_date) return a.entry_date.localeCompare(b.entry_date) - const atime = a.entry_time || '' - const btime = b.entry_time || '' - if (atime !== btime) return atime.localeCompare(btime) - return (a.sort_order || 0) - (b.sort_order || 0) + if (a.sort_order !== b.sort_order) return (a.sort_order || 0) - (b.sort_order || 0) + return a.id - b.id }) return { current: { ...s.current, entries } } }) diff --git a/client/src/utils/fileDownload.ts b/client/src/utils/fileDownload.ts index b9904472..10e05fd0 100644 --- a/client/src/utils/fileDownload.ts +++ b/client/src/utils/fileDownload.ts @@ -32,6 +32,13 @@ function triggerAnchorDownload(blobUrl: string, filename?: string): void { setTimeout(() => { URL.revokeObjectURL(blobUrl); a.remove() }, 100) } +// navigator.standalone is true only on iOS when running as an +// add-to-home-screen PWA. In that context, target="_blank" hands off to +// Safari, which cannot access blob URLs sandboxed to the WebView. +function isIosStandalone(): boolean { + return (navigator as any).standalone === true +} + /** * Fetches a protected file using cookie auth (credentials: include) and * triggers a browser download. Works inside PWA standalone mode because the @@ -56,7 +63,13 @@ export async function downloadFile(url: string, filename?: string): Promise click rather + * than window.open(). window.open() called with the "noreferrer"/"noopener" + * window feature returns null per spec, which previously made the popup-block + * fallback trigger a download in the *current* tab on top of the new-tab open + * — i.e. the file opened twice. The anchor approach avoids that ambiguity: + * the new tab is opened by the browser's normal link-handling path, and no + * spurious in-page download is triggered. */ export async function openFile(url: string, filename?: string): Promise { assertRelativeUrl(url) @@ -71,11 +84,19 @@ export async function openFile(url: string, filename?: string): Promise { return } - const win = window.open(blobUrl, '_blank', 'noreferrer') - if (win) { - setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000) - } else { - // Popup blocked — fall back to download + // iOS PWA: target="_blank" would open Safari, which can't access the blob + if (isIosStandalone()) { triggerAnchorDownload(blobUrl, filename) + return } + + const a = document.createElement('a') + a.href = blobUrl + a.target = '_blank' + a.rel = 'noopener noreferrer' + document.body.appendChild(a) + a.click() + // Keep the blob URL alive long enough for the new tab to load it, then + // clean up the DOM node and revoke the URL. + setTimeout(() => { URL.revokeObjectURL(blobUrl); a.remove() }, 30_000) } diff --git a/client/tests/unit/utils/fileDownload.test.ts b/client/tests/unit/utils/fileDownload.test.ts index 89632017..b5a8833c 100644 --- a/client/tests/unit/utils/fileDownload.test.ts +++ b/client/tests/unit/utils/fileDownload.test.ts @@ -74,32 +74,42 @@ describe('downloadFile', () => { }) describe('openFile', () => { - it('fetches with credentials:include and opens blob URL in new tab', async () => { + it('fetches with credentials:include and opens blob URL via target=_blank anchor', async () => { vi.stubGlobal('fetch', makeFetchMock(200)) - const mockWin = { closed: false } - const openSpy = vi.spyOn(window, 'open').mockReturnValue(mockWin as Window) + const openSpy = vi.spyOn(window, 'open').mockReturnValue(null) + const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) await openFile('/uploads/files/doc.pdf') expect(window.fetch).toHaveBeenCalledWith('/uploads/files/doc.pdf', { credentials: 'include' }) expect(URL.createObjectURL).toHaveBeenCalled() - expect(openSpy).toHaveBeenCalledWith('blob:mock-url', '_blank', 'noreferrer') + // Must NOT call window.open — that path returns null when noreferrer is + // set, which previously caused the file to also open in the current tab. + expect(openSpy).not.toHaveBeenCalled() + expect(clickSpy).toHaveBeenCalledTimes(1) + + // The anchor used to open the new tab must be target=_blank, must NOT + // carry a `download` attribute (otherwise it would download in-page + // instead of opening), and must use rel=noopener noreferrer. + const appendCalls = (document.body.appendChild as ReturnType).mock.calls + const anchor = appendCalls[0]?.[0] as HTMLAnchorElement + expect(anchor.target).toBe('_blank') + expect(anchor.rel).toBe('noopener noreferrer') + expect(anchor.hasAttribute('download')).toBe(false) // Revoke happens after 30s timeout vi.runAllTimers() expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url') }) - it('falls back to anchor download when popup is blocked', async () => { + it('does not trigger a second in-page action for safe inline types (regression: no double-open)', async () => { vi.stubGlobal('fetch', makeFetchMock(200)) - vi.spyOn(window, 'open').mockReturnValue(null) const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) - await openFile('/uploads/files/doc.pdf') + await openFile('/uploads/files/doc.pdf', 'doc.pdf') - expect(clickSpy).toHaveBeenCalled() - vi.runAllTimers() - expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url') + // Exactly ONE anchor click — opening the new tab. No fallback download. + expect(clickSpy).toHaveBeenCalledTimes(1) }) it('throws on 401 response', async () => { @@ -108,28 +118,55 @@ describe('openFile', () => { expect(URL.createObjectURL).not.toHaveBeenCalled() }) - it('forces download for unsafe MIME types (HTML, SVG) instead of opening inline', async () => { + it('forces download for unsafe MIME types (HTML) instead of opening inline', async () => { const htmlBlob = new Blob([''], { type: 'text/html' }) vi.stubGlobal('fetch', makeFetchMock(200, htmlBlob)) const openSpy = vi.spyOn(window, 'open').mockReturnValue({} as Window) const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) - await openFile('/uploads/files/malicious.html') + await openFile('/uploads/files/malicious.html', 'malicious.html') // Must NOT open inline — download anchor clicked instead expect(openSpy).not.toHaveBeenCalled() - expect(clickSpy).toHaveBeenCalled() + expect(clickSpy).toHaveBeenCalledTimes(1) + + const appendCalls = (document.body.appendChild as ReturnType).mock.calls + const anchor = appendCalls[0]?.[0] as HTMLAnchorElement + expect(anchor.download).toBe('malicious.html') }) it('forces download for SVG MIME type', async () => { const svgBlob = new Blob([''], { type: 'image/svg+xml' }) vi.stubGlobal('fetch', makeFetchMock(200, svgBlob)) - vi.spyOn(window, 'open').mockReturnValue({} as Window) + const openSpy = vi.spyOn(window, 'open').mockReturnValue({} as Window) const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) await openFile('/uploads/files/malicious.svg') - expect(window.open).not.toHaveBeenCalled() - expect(clickSpy).toHaveBeenCalled() + expect(openSpy).not.toHaveBeenCalled() + expect(clickSpy).toHaveBeenCalledTimes(1) + }) + + it('falls back to download in iOS PWA standalone mode (blob URL inaccessible to Safari)', async () => { + vi.stubGlobal('fetch', makeFetchMock(200)) + const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + // Simulate iOS PWA (Add-to-Home-Screen) context + Object.defineProperty(navigator, 'standalone', { configurable: true, value: true }) + + try { + await openFile('/uploads/files/doc.pdf', 'doc.pdf') + + // Single anchor click — and it must be a DOWNLOAD anchor (no target=_blank), + // because target="_blank" in iOS PWA would hand off to Safari which cannot + // read the in-WebView blob URL. + expect(clickSpy).toHaveBeenCalledTimes(1) + const appendCalls = (document.body.appendChild as ReturnType).mock.calls + const anchor = appendCalls[0]?.[0] as HTMLAnchorElement + expect(anchor.target).toBe('') + expect(anchor.download).toBe('doc.pdf') + } finally { + // Clean up the non-standard iOS-only property we forced above. + delete (navigator as any).standalone + } }) }) diff --git a/docker-compose.yml b/docker-compose.yml index e0d84418..a72cbecd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,7 @@ services: # - DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links # - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy +# - HSTS_INCLUDE_SUBDOMAINS=false # When true: adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave false if sibling subdomains still run over plain HTTP. # - COOKIE_SECURE=false # Escape hatch: force session cookies over plain HTTP even in production. Not recommended. # - TRUST_PROXY=1 # Trusted proxy count for X-Forwarded-For / X-Forwarded-Proto. Required for FORCE_HTTPS to work. # - ALLOW_INTERNAL_NETWORK=false # Set to true if Immich or other services are hosted on your local network (RFC-1918 IPs). Loopback and link-local addresses remain blocked regardless. diff --git a/server/.env.example b/server/.env.example index ba9da901..7fb96267 100644 --- a/server/.env.example +++ b/server/.env.example @@ -13,6 +13,7 @@ LOG_LEVEL=info # info = concise user actions; debug = verbose admin-level detail ALLOWED_ORIGINS=https://trek.example.com # Comma-separated origins for CORS and email links FORCE_HTTPS=false # Optional. When true: HTTPS redirect + HSTS + CSP upgrade-insecure-requests + secure cookies. Only behind a TLS proxy. +# HSTS_INCLUDE_SUBDOMAINS=false # When true: adds includeSubDomains to the HSTS header. Only effective when HSTS is active (FORCE_HTTPS=true or NODE_ENV=production). Leave false if you run other services on sibling subdomains over plain HTTP. COOKIE_SECURE=true # Auto-derived (true when NODE_ENV=production or FORCE_HTTPS=true). Set false to force cookies over plain HTTP. TRUST_PROXY=1 # Trusted proxy hops (parseInt or 1). Active in production by default; off in dev unless set. Needed for FORCE_HTTPS. ALLOW_INTERNAL_NETWORK=false # Allow outbound requests to private/RFC1918 IPs (e.g. Immich hosted on your LAN). Loopback and link-local addresses are always blocked. diff --git a/server/package-lock.json b/server/package-lock.json index 7eb10679..6510f142 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "trek-server", - "version": "2.9.14", + "version": "3.0.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "trek-server", - "version": "2.9.14", + "version": "3.0.8", "dependencies": { "@modelcontextprotocol/sdk": "^1.28.0", "archiver": "^6.0.1", @@ -30,7 +30,7 @@ "typescript": "^6.0.2", "undici": "^7.0.0", "unzipper": "^0.12.3", - "uuid": "^9.0.0", + "uuid": "^14.0.0", "ws": "^8.19.0", "zod": "^4.3.6" }, @@ -663,9 +663,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -682,9 +679,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -701,9 +695,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -720,9 +711,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -739,9 +727,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -758,9 +743,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -777,9 +759,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -796,9 +775,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -815,9 +791,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -840,9 +813,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -865,9 +835,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -890,9 +857,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -915,9 +879,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -940,9 +901,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -965,9 +923,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -990,9 +945,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1551,6 +1503,18 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@otplib/core": { "version": "12.0.1", "resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz", @@ -3767,9 +3731,9 @@ "license": "BSD-3-Clause" }, "node_modules/fast-xml-builder": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", - "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz", + "integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==", "funding": [ { "type": "github", @@ -3782,9 +3746,9 @@ } }, "node_modules/fast-xml-parser": { - "version": "5.5.12", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.12.tgz", - "integrity": "sha512-nUR0q8PPfoA/svPM43Gup7vLOZWppaNrYgGmrVqrAVJa7cOH4hMG6FX9M4mQ8dZA1/ObGZHzES7Ed88hxEBSJg==", + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.1.tgz", + "integrity": "sha512-8Cc3f8GUGUULg34pBch/KGyPLglS+OFs05deyOlY7fL2MTagYPKrVQNmR1fLF/yJ9PH5ZSTd3YDF6pnmeZU+zA==", "funding": [ { "type": "github", @@ -3793,7 +3757,8 @@ ], "license": "MIT", "dependencies": { - "fast-xml-builder": "^1.1.4", + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.5", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, @@ -6481,16 +6446,16 @@ } }, "node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/vary": { diff --git a/server/package.json b/server/package.json index 7ae1cec8..b061a193 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "trek-server", - "version": "2.9.14", + "version": "3.0.8", "main": "src/index.ts", "scripts": { "start": "node --import tsx src/index.ts", @@ -35,7 +35,7 @@ "typescript": "^6.0.2", "undici": "^7.0.0", "unzipper": "^0.12.3", - "uuid": "^9.0.0", + "uuid": "^14.0.0", "ws": "^8.19.0", "zod": "^4.3.6" }, diff --git a/server/src/app.ts b/server/src/app.ts index 9bca8fcb..d21c0d3c 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -124,6 +124,7 @@ export function createApp(): express.Application { }, crossOriginEmbedderPolicy: false, hsts: hstsActive ? { maxAge: 31536000, includeSubDomains: hstsIncludeSubdomains } : false, + referrerPolicy: { policy: 'strict-origin-when-cross-origin' }, })); if (shouldForceHttps) { diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 410e67f8..29640339 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -2043,6 +2043,93 @@ function runMigrations(db: Database.Database): void { db.exec('CREATE INDEX IF NOT EXISTS idx_journey_entry_photos_entry ON journey_entry_photos(entry_id)'); db.exec('CREATE INDEX IF NOT EXISTS idx_journey_entry_photos_photo ON journey_entry_photos(journey_photo_id)'); }, + // Migration 122: Correct stale day_id / end_day_id on non-transport + // reservations. Migration 110 only backfilled transport types; tours, + // restaurants, events and "other" bookings kept a stale day_id from + // older code paths that often defaulted to the first day of the trip. + // Starting with v3.0.0 the planner renders reservations by day_id + // instead of reservation_time, so those stale rows show up on the + // wrong day. This migration nulls out day_id / end_day_id values that + // don't match the reservation's time and then backfills them from + // reservation_time / reservation_end_time. + () => { + db.exec(` + UPDATE reservations + SET day_id = NULL + WHERE reservation_time IS NOT NULL + AND day_id IS NOT NULL + AND type != 'hotel' + AND NOT EXISTS ( + SELECT 1 FROM days d + WHERE d.id = reservations.day_id + AND d.date = substr(reservations.reservation_time, 1, 10) + ) + `); + + db.exec(` + UPDATE reservations + SET end_day_id = NULL + WHERE reservation_end_time IS NOT NULL + AND end_day_id IS NOT NULL + AND type != 'hotel' + AND NOT EXISTS ( + SELECT 1 FROM days d + WHERE d.id = reservations.end_day_id + AND d.date = substr(reservations.reservation_end_time, 1, 10) + ) + `); + + db.exec(` + UPDATE reservations + SET day_id = ( + SELECT d.id FROM days d + WHERE d.trip_id = reservations.trip_id + AND d.date = substr(reservations.reservation_time, 1, 10) + LIMIT 1 + ) + WHERE type != 'hotel' + AND reservation_time IS NOT NULL + AND day_id IS NULL + `); + + db.exec(` + UPDATE reservations + SET end_day_id = ( + SELECT d.id FROM days d + WHERE d.trip_id = reservations.trip_id + AND d.date = substr(reservations.reservation_end_time, 1, 10) + LIMIT 1 + ) + WHERE type != 'hotel' + AND reservation_end_time IS NOT NULL + AND end_day_id IS NULL + AND substr(reservations.reservation_end_time, 1, 10) + != substr(reservations.reservation_time, 1, 10) + `); + }, + // #846: make sort_order authoritative within a day. Previous ORDER BY put + // entry_time before sort_order, silently ignoring reorder clicks when two + // same-date entries had different times. Backfill renumbers using the old + // effective key (entry_time ASC, id ASC) so existing journeys retain their + // current visual order. + () => { + db.exec(` + WITH ranked AS ( + SELECT id, + ROW_NUMBER() OVER ( + PARTITION BY journey_id, entry_date + ORDER BY entry_time ASC, id ASC + ) - 1 AS rn + FROM journey_entries + ) + UPDATE journey_entries + SET sort_order = (SELECT rn FROM ranked WHERE ranked.id = journey_entries.id) + `); + db.exec( + 'CREATE INDEX IF NOT EXISTS idx_journey_entries_order ' + + 'ON journey_entries(journey_id, entry_date, sort_order)' + ); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/routes/oidc.ts b/server/src/routes/oidc.ts index dcd21a78..4a86a340 100644 --- a/server/src/routes/oidc.ts +++ b/server/src/routes/oidc.ts @@ -112,7 +112,7 @@ router.get('/callback', async (req: Request, res: Response) => { tokenData.id_token, doc, config.clientId, - config.issuer, + (doc.issuer ?? '').replace(/\/+$/, '') || config.issuer, ); if (idVerify.ok !== true) { const reason = 'error' in idVerify ? idVerify.error : 'unknown'; diff --git a/server/src/services/journeyService.ts b/server/src/services/journeyService.ts index a03d1fea..afa6c7b3 100644 --- a/server/src/services/journeyService.ts +++ b/server/src/services/journeyService.ts @@ -120,7 +120,7 @@ export function getJourneyFull(journeyId: number, userId: number) { if (!journey) return null; const entries = db.prepare( - 'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, entry_time ASC, sort_order ASC' + 'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, sort_order ASC, id ASC' ).all(journeyId) as JourneyEntry[]; const photos = db.prepare( @@ -306,12 +306,21 @@ export function syncTripPlaces(journeyId: number, tripId: number, authorId: numb ).all(journeyId, tripId) as { source_place_id: number }[]; const existingPlaceIds = new Set(existing.map(e => e.source_place_id)); + // Track next sort_order per date so synced skeletons get unique, sequential positions. + const dateMaxOrder = new Map(); + const maxRows = db.prepare( + 'SELECT entry_date, COALESCE(MAX(sort_order), -1) AS m FROM journey_entries WHERE journey_id = ? GROUP BY entry_date' + ).all(journeyId) as { entry_date: string; m: number }[]; + for (const row of maxRows) dateMaxOrder.set(row.entry_date, row.m); + for (const place of places) { if (existingPlaceIds.has(place.id)) continue; existingPlaceIds.add(place.id); const entryDate = place.day_date || new Date().toISOString().split('T')[0]; const entryTime = place.assignment_time || place.place_time || null; + const nextOrder = (dateMaxOrder.get(entryDate) ?? -1) + 1; + dateMaxOrder.set(entryDate, nextOrder); db.prepare(` INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, entry_time, location_name, location_lat, location_lng, sort_order, created_at, updated_at) @@ -320,7 +329,7 @@ export function syncTripPlaces(journeyId: number, tripId: number, authorId: numb journeyId, tripId, place.id, authorId, place.name, entryDate, entryTime, place.address || place.name, place.lat || null, place.lng || null, - place.day_number || 0, now, now + nextOrder, now, now ); } } @@ -367,15 +376,19 @@ export function onPlaceCreated(tripId: number, placeId: number) { const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(link.journey_id) as { user_id: number }; const entryDate = place.day_date; + const maxOrder = db.prepare( + 'SELECT MAX(sort_order) AS m FROM journey_entries WHERE journey_id = ? AND entry_date = ?' + ).get(link.journey_id, entryDate) as { m: number | null }; + const nextOrder = (maxOrder?.m ?? -1) + 1; db.prepare(` INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, entry_time, location_name, location_lat, location_lng, sort_order, created_at, updated_at) - VALUES (?, ?, ?, ?, 'skeleton', ?, ?, ?, ?, ?, ?, 0, ?, ?) + VALUES (?, ?, ?, ?, 'skeleton', ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( link.journey_id, tripId, placeId, journey.user_id, place.name, entryDate, place.assignment_time || place.place_time || null, place.address || place.name, place.lat || null, place.lng || null, - now, now + nextOrder, now, now ); } } @@ -451,7 +464,7 @@ export function listEntries(journeyId: number, userId: number) { if (!canAccessJourney(journeyId, userId)) return null; const entries = db.prepare( - 'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, entry_time ASC, sort_order ASC' + 'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, sort_order ASC, id ASC' ).all(journeyId) as JourneyEntry[]; const photos = db.prepare( diff --git a/server/src/services/oidcService.ts b/server/src/services/oidcService.ts index db0ef8ad..42c0c5cd 100644 --- a/server/src/services/oidcService.ts +++ b/server/src/services/oidcService.ts @@ -140,11 +140,21 @@ export async function discover(issuer: string, discoveryUrl?: string | null): Pr const res = await fetch(url); if (!res.ok) throw new Error('Failed to fetch OIDC discovery document'); const doc = (await res.json()) as OidcDiscoveryDoc; - // Validate that the discovery doc's issuer matches the operator-configured - // one. A MITM or compromised doc could otherwise supply a crafted issuer - // that passes jwt.verify() because we used doc.issuer as the expected value. - if (doc.issuer && doc.issuer !== issuer) { - throw new Error(`OIDC discovery issuer mismatch: expected "${issuer}", got "${doc.issuer}"`); + // Validate that the discovery doc's issuer matches the operator-configured one. + // When no custom discoveryUrl is set, a mismatch signals a MITM or misconfiguration + // and we reject. When the operator explicitly overrides the discovery URL (e.g. + // Authentik realm paths), the discovery doc's issuer is the canonical value — + // trust it and warn rather than blocking login. + const docIssuer = doc.issuer?.replace(/\/+$/, '') ?? ''; + if (docIssuer && docIssuer !== issuer) { + if (discoveryUrl) { + console.warn( + `[OIDC] Discovery doc issuer "${doc.issuer}" differs from configured OIDC_ISSUER "${issuer}". ` + + `Using discovery doc issuer for id_token verification (custom OIDC_DISCOVERY_URL is set).`, + ); + } else { + throw new Error(`OIDC discovery issuer mismatch: expected "${issuer}", got "${doc.issuer}"`); + } } doc._issuer = url; discoveryCache = doc; @@ -313,7 +323,6 @@ export async function verifyIdToken( try { const verified = jwt.verify(idToken, publicKey, { algorithms: [alg as jwt.Algorithm], - issuer: expectedIssuer, audience: clientId, }); claims = typeof verified === 'string' ? {} : (verified as Record); @@ -322,6 +331,13 @@ export async function verifyIdToken( return { ok: false, error: `signature_or_claim_mismatch: ${msg}` }; } + // Normalize trailing slash before issuer comparison — some IdPs (e.g. Authentik) + // include a trailing slash in the id_token iss claim. + const tokenIssuer = typeof claims['iss'] === 'string' ? claims['iss'].replace(/\/+$/, '') : ''; + if (tokenIssuer !== expectedIssuer) { + return { ok: false, error: `signature_or_claim_mismatch: jwt issuer invalid. expected: ${expectedIssuer}` }; + } + return { ok: true, claims }; } diff --git a/server/src/services/packingService.ts b/server/src/services/packingService.ts index a9eddb25..19fa54d9 100644 --- a/server/src/services/packingService.ts +++ b/server/src/services/packingService.ts @@ -1,4 +1,5 @@ import { db, canAccessTrip } from '../db/database'; +import { avatarUrl } from './authService'; const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b']; @@ -131,7 +132,10 @@ export function listBags(tripId: string | number) { if (!membersByBag.has(m.bag_id)) membersByBag.set(m.bag_id, []); membersByBag.get(m.bag_id)!.push(m); } - return bags.map(b => ({ ...b, members: membersByBag.get(b.id) || [] })); + return bags.map(b => ({ + ...b, + members: (membersByBag.get(b.id) || []).map(m => ({ ...m, avatar: avatarUrl(m) })), + })); } export function setBagMembers(tripId: string | number, bagId: string | number, userIds: number[]) { @@ -140,11 +144,12 @@ export function setBagMembers(tripId: string | number, bagId: string | number, u db.prepare('DELETE FROM packing_bag_members WHERE bag_id = ?').run(bagId); const ins = db.prepare('INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)'); for (const uid of userIds) ins.run(bagId, uid); - return db.prepare(` + const rows = db.prepare(` SELECT bm.user_id, u.username, u.avatar FROM packing_bag_members bm JOIN users u ON bm.user_id = u.id WHERE bm.bag_id = ? - `).all(bagId); + `).all(bagId) as { user_id: number; username: string; avatar: string | null }[]; + return rows.map(m => ({ ...m, avatar: avatarUrl(m) })); } export function createBag(tripId: string | number, data: { name: string; color?: string }) { @@ -260,7 +265,7 @@ export function getCategoryAssignees(tripId: string | number) { const assignees: Record = {}; for (const row of rows as any[]) { if (!assignees[row.category_name]) assignees[row.category_name] = []; - assignees[row.category_name].push({ user_id: row.user_id, username: row.username, avatar: row.avatar }); + assignees[row.category_name].push({ user_id: row.user_id, username: row.username, avatar: avatarUrl(row) }); } return assignees; @@ -274,12 +279,13 @@ export function updateCategoryAssignees(tripId: string | number, categoryName: s for (const uid of userIds) insert.run(tripId, categoryName, uid); } - return db.prepare(` + const updated = db.prepare(` SELECT pca.user_id, u.username, u.avatar FROM packing_category_assignees pca JOIN users u ON pca.user_id = u.id WHERE pca.trip_id = ? AND pca.category_name = ? - `).all(tripId, categoryName); + `).all(tripId, categoryName) as { user_id: number; username: string; avatar: string | null }[]; + return updated.map(m => ({ ...m, avatar: avatarUrl(m) })); } // ── Reorder ──────────────────────────────────────────────────────────────── diff --git a/server/src/services/reservationService.ts b/server/src/services/reservationService.ts index 4410c36d..d95aadd5 100644 --- a/server/src/services/reservationService.ts +++ b/server/src/services/reservationService.ts @@ -43,6 +43,24 @@ function loadEndpoints(reservationId: number): ReservationEndpoint[] { ).all(reservationId) as ReservationEndpoint[]; } +// Resolve the day row whose date matches the date portion of an ISO-ish +// timestamp. Used to keep `day_id` / `end_day_id` in sync with +// `reservation_time` / `reservation_end_time` so non-transport bookings +// (tours, restaurants, events, ...) end up on the right day in the UI, +// which now filters by day_id instead of reservation_time. +function resolveDayIdFromTime( + tripId: string | number, + time: string | null | undefined, +): number | null { + if (!time) return null; + const datePart = time.slice(0, 10); + if (!/^\d{4}-\d{2}-\d{2}$/.test(datePart)) return null; + const row = db + .prepare('SELECT id FROM days WHERE trip_id = ? AND date = ? LIMIT 1') + .get(tripId, datePart) as { id: number } | undefined; + return row?.id ?? null; +} + const saveEndpoints = db.transaction((reservationId: number, endpoints: EndpointInput[]) => { db.prepare('DELETE FROM reservation_endpoints WHERE reservation_id = ?').run(reservationId); const insert = db.prepare(` @@ -160,13 +178,26 @@ export function createReservation(tripId: string | number, data: CreateReservati } } + // Derive day_id / end_day_id from reservation_time when the client + // didn't explicitly set them (non-hotel bookings only — hotels store + // their date range on the linked day_accommodation). + const resolvedType = type || 'other'; + let resolvedDayId: number | null = day_id ?? null; + if (resolvedDayId == null && resolvedType !== 'hotel' && reservation_time) { + resolvedDayId = resolveDayIdFromTime(tripId, reservation_time); + } + let resolvedEndDayId: number | null = end_day_id ?? null; + if (resolvedEndDayId == null && resolvedType !== 'hotel' && reservation_end_time) { + resolvedEndDayId = resolveDayIdFromTime(tripId, reservation_end_time); + } + const result = db.prepare(` INSERT INTO reservations (trip_id, day_id, end_day_id, place_id, assignment_id, title, reservation_time, reservation_end_time, location, confirmation_number, notes, status, type, accommodation_id, metadata, needs_review) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( tripId, - day_id || null, - end_day_id ?? null, + resolvedDayId, + resolvedEndDayId, place_id || null, assignment_id || null, title, @@ -176,7 +207,7 @@ export function createReservation(tripId: string | number, data: CreateReservati confirmation_number || null, notes || null, status || 'pending', - type || 'other', + resolvedType, resolvedAccommodationId, metadata ? JSON.stringify(metadata) : null, needs_review ? 1 : 0 @@ -290,6 +321,35 @@ export function updateReservation(id: string | number, tripId: string | number, } } + const resolvedType = (type ?? current.type) || 'other'; + const nextReservationTime = resolvedType === 'hotel' + ? null + : (reservation_time !== undefined ? (reservation_time || null) : current.reservation_time); + const nextReservationEndTime = resolvedType === 'hotel' + ? null + : (reservation_end_time !== undefined ? (reservation_end_time || null) : current.reservation_end_time); + + // day_id / end_day_id: honour an explicit value from the client, + // otherwise derive from the (possibly updated) reservation_time so the + // planner renders the booking on the correct day. + let nextDayId: number | null; + if (day_id !== undefined) { + nextDayId = day_id || null; + } else if (reservation_time !== undefined && resolvedType !== 'hotel') { + nextDayId = resolveDayIdFromTime(tripId, nextReservationTime); + } else { + nextDayId = current.day_id ?? null; + } + + let nextEndDayId: number | null; + if (end_day_id !== undefined) { + nextEndDayId = end_day_id ?? null; + } else if (reservation_end_time !== undefined && resolvedType !== 'hotel') { + nextEndDayId = resolveDayIdFromTime(tripId, nextReservationEndTime); + } else { + nextEndDayId = (current as any).end_day_id ?? null; + } + db.prepare(` UPDATE reservations SET title = COALESCE(?, title), @@ -310,13 +370,13 @@ export function updateReservation(id: string | number, tripId: string | number, WHERE id = ? `).run( title || null, - (type ?? current.type) === 'hotel' ? null : (reservation_time !== undefined ? (reservation_time || null) : current.reservation_time), - (type ?? current.type) === 'hotel' ? null : (reservation_end_time !== undefined ? (reservation_end_time || null) : current.reservation_end_time), + nextReservationTime, + nextReservationEndTime, location !== undefined ? (location || null) : current.location, confirmation_number !== undefined ? (confirmation_number || null) : current.confirmation_number, notes !== undefined ? (notes || null) : current.notes, - day_id !== undefined ? (day_id || null) : current.day_id, - end_day_id !== undefined ? (end_day_id ?? null) : (current as any).end_day_id ?? null, + nextDayId, + nextEndDayId, place_id !== undefined ? (place_id || null) : current.place_id, assignment_id !== undefined ? (assignment_id || null) : current.assignment_id, status || null, diff --git a/server/tests/integration/systemNotices.test.ts b/server/tests/integration/systemNotices.test.ts index 0fcd6a33..40179329 100644 --- a/server/tests/integration/systemNotices.test.ts +++ b/server/tests/integration/systemNotices.test.ts @@ -84,8 +84,9 @@ describe('GET /api/system-notices/active', () => { it('returns empty array for non-first-login user with no applicable notices', async () => { const { user } = createUser(testDb); - // login_count > 1 means firstLogin condition does not match for any notice - testDb.prepare('UPDATE users SET login_count = 5 WHERE id = ?').run(user.id); + // login_count > 1 means firstLogin condition does not match for any notice; + // first_seen_version >= 3.0.0 means existingUserBeforeVersion('3.0.0') also does not match + testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id); const res = await request(app) .get('/api/system-notices/active') .set('Cookie', authCookie(user.id)); @@ -122,7 +123,7 @@ describe('GET /api/system-notices/active', () => { SYSTEM_NOTICES.push(TEST_NOTICE); try { const { user } = createUser(testDb); - testDb.prepare('UPDATE users SET login_count = 5 WHERE id = ?').run(user.id); + testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id); const res = await request(app) .get('/api/system-notices/active') diff --git a/server/tests/unit/services/journeyService.test.ts b/server/tests/unit/services/journeyService.test.ts index 950eff04..82b14d55 100644 --- a/server/tests/unit/services/journeyService.test.ts +++ b/server/tests/unit/services/journeyService.test.ts @@ -68,6 +68,7 @@ import { removeContributor, getSuggestions, syncTripPlaces, + reorderEntries, onPlaceCreated, onPlaceUpdated, onPlaceDeleted, @@ -1465,3 +1466,108 @@ describe('addProviderPhoto — passphrase', () => { expect(row?.passphrase).not.toBe('secret-pp'); }); }); + +// -- reorderEntries (#846) ---------------------------------------------------- + +function insertEntry(journeyId: number, authorId: number, opts: { entry_date: string; entry_time?: string | null; sort_order?: number }): { id: number } { + const now = Date.now(); + const res = testDb.prepare(` + INSERT INTO journey_entries (journey_id, author_id, type, entry_date, entry_time, sort_order, visibility, created_at, updated_at) + VALUES (?, ?, 'entry', ?, ?, ?, 'private', ?, ?) + `).run(journeyId, authorId, opts.entry_date, opts.entry_time ?? null, opts.sort_order ?? 0, now, now); + return { id: Number(res.lastInsertRowid) }; +} + +describe('reorderEntries', () => { + it('JOURNEY-SVC-089: reorder persists and listEntries returns requested order regardless of entry_time', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + const e1 = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', entry_time: '09:00', sort_order: 0 }); + const e2 = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', entry_time: '14:00', sort_order: 1 }); + + const ok = reorderEntries(journey.id, user.id, [e2.id, e1.id]); + expect(ok).toBe(true); + + const entries = listEntries(journey.id, user.id)!; + const dayEntries = entries.filter(e => e.entry_date === '2026-08-01'); + expect(dayEntries.map(e => e.id)).toEqual([e2.id, e1.id]); + }); + + it('JOURNEY-SVC-090: reorderEntries rejects ids from another journey', () => { + const { user } = createUser(testDb); + const j1 = createJourney(testDb, user.id); + const j2 = createJourney(testDb, user.id); + const entry = createJourneyEntry(testDb, j2.id, user.id, { entry_date: '2026-08-02' }); + + const ok = reorderEntries(j1.id, user.id, [entry.id]); + expect(ok).toBe(false); + }); + + it('JOURNEY-SVC-091: reorderEntries does not affect entries on other days', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + const day1a = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', sort_order: 0 }); + const day1b = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', sort_order: 1 }); + const day2 = insertEntry(journey.id, user.id, { entry_date: '2026-08-02', sort_order: 0 }); + + reorderEntries(journey.id, user.id, [day1b.id, day1a.id]); + + const entries = listEntries(journey.id, user.id)!; + const day2Entry = entries.find(e => e.id === day2.id)!; + expect(day2Entry.sort_order).toBe(0); + }); +}); + +describe('syncTripPlaces sort_order', () => { + it('JOURNEY-SVC-092: assigns unique sequential sort_order per date for same-day places', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + const trip = createTrip(testDb, user.id, { + title: 'Order Trip', + start_date: '2026-09-01', + end_date: '2026-09-02', + }); + const day = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number }; + const p1 = createPlace(testDb, trip.id, { name: 'Place A' }); + const p2 = createPlace(testDb, trip.id, { name: 'Place B' }); + const p3 = createPlace(testDb, trip.id, { name: 'Place C' }); + createDayAssignment(testDb, day.id, p1.id); + createDayAssignment(testDb, day.id, p2.id); + createDayAssignment(testDb, day.id, p3.id); + + syncTripPlaces(journey.id, trip.id, user.id); + + const rows = testDb.prepare( + 'SELECT sort_order FROM journey_entries WHERE journey_id = ? ORDER BY sort_order ASC' + ).all(journey.id) as { sort_order: number }[]; + const orders = rows.map(r => r.sort_order); + expect(new Set(orders).size).toBe(orders.length); + expect(orders).toEqual([0, 1, 2]); + }); +}); + +describe('onPlaceCreated sort_order', () => { + it('JOURNEY-SVC-093: assigns MAX+1 sort_order when entries already exist on the target date', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + const trip = createTrip(testDb, user.id, { + title: 'Append Trip', + start_date: '2026-10-01', + end_date: '2026-10-02', + }); + addTripToJourney(journey.id, trip.id, user.id); + + const day = testDb.prepare('SELECT id, date FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number; date: string }; + insertEntry(journey.id, user.id, { entry_date: day.date, sort_order: 5 }); + + const place = createPlace(testDb, trip.id, { name: 'Late Addition' }); + createDayAssignment(testDb, day.id, place.id); + onPlaceCreated(trip.id, place.id); + + const newEntry = testDb.prepare( + 'SELECT sort_order FROM journey_entries WHERE journey_id = ? AND source_place_id = ?' + ).get(journey.id, place.id) as { sort_order: number } | undefined; + expect(newEntry).toBeDefined(); + expect(newEntry!.sort_order).toBe(6); + }); +}); diff --git a/server/tests/unit/services/oidcService.test.ts b/server/tests/unit/services/oidcService.test.ts index eca92065..5de82a4b 100644 --- a/server/tests/unit/services/oidcService.test.ts +++ b/server/tests/unit/services/oidcService.test.ts @@ -4,6 +4,8 @@ * discover caching, and the ReDoS-sensitive issuer trailing-slash regex. */ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest'; +import { generateKeyPairSync } from 'crypto'; +import jwtLib from 'jsonwebtoken'; // ── DB setup ────────────────────────────────────────────────────────────────── @@ -50,6 +52,7 @@ import { frontendUrl, findOrCreateUser, discover, + verifyIdToken, } from '../../../src/services/oidcService'; const MOCK_CONFIG = { @@ -216,6 +219,59 @@ describe('discover', () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false })); await expect(discover('https://bad-issuer.example.com')).rejects.toThrow(); }); + + it('OIDC-SVC-037: accepts mismatched doc issuer when discoveryUrl is explicit', async () => { + const doc = { + issuer: 'https://auth.example.com/application/o/myapp/', + authorization_endpoint: 'https://auth.example.com/application/o/myapp/authorize/', + token_endpoint: 'https://auth.example.com/application/o/token/', + userinfo_endpoint: 'https://auth.example.com/application/o/userinfo/', + }; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => doc })); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const result = await discover( + 'https://auth.example.com', + 'https://auth.example.com/application/o/myapp/.well-known/openid-configuration', + ); + + expect(result.issuer).toBe(doc.issuer); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('differs from configured OIDC_ISSUER')); + warnSpy.mockRestore(); + }); + + it('OIDC-SVC-038: throws on mismatched doc issuer when discoveryUrl is omitted', async () => { + const doc = { + issuer: 'https://evil.example.com', + authorization_endpoint: 'https://unique-2.example.com/auth', + token_endpoint: 'https://unique-2.example.com/token', + userinfo_endpoint: 'https://unique-2.example.com/userinfo', + }; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => doc })); + + await expect(discover('https://unique-2.example.com')).rejects.toThrow( + 'OIDC discovery issuer mismatch', + ); + }); + + it('OIDC-SVC-039: trailing-slash-only mismatch with explicit discoveryUrl does not warn', async () => { + const doc = { + issuer: 'https://auth.example.com/', + authorization_endpoint: 'https://auth.example.com/auth', + token_endpoint: 'https://auth.example.com/token', + userinfo_endpoint: 'https://auth.example.com/userinfo', + }; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => doc })); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await discover( + 'https://auth.example.com', + 'https://auth.example.com/.well-known/openid-configuration', + ); + + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); }); // ── issuer trailing-slash regex (ReDoS guard) ───────────────────────────────── @@ -460,3 +516,66 @@ describe('getUserInfo', () => { expect(fetchCall[1].headers.Authorization).toBe('Bearer access-token-123'); }); }); + +// ── verifyIdToken ───────────────────────────────────────────────────────────── + +describe('verifyIdToken', () => { + const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + const jwk = publicKey.export({ format: 'jwk' }) as Record; + const ISSUER = 'https://auth.example.com/application/o/trek'; + const CLIENT_ID = 'trek-client'; + const JWKS_URI = 'https://auth.example.com/.well-known/jwks.json'; + + function mockJwks() { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ keys: [jwk] }), + })); + } + + function makeToken(iss: string, overrides: object = {}) { + return jwtLib.sign( + { sub: 'user-sub', email: 'user@example.com', ...overrides }, + privateKey, + { algorithm: 'RS256', audience: CLIENT_ID, issuer: iss, expiresIn: '1h' } + ); + } + + const doc = { jwks_uri: JWKS_URI } as any; + + afterEach(() => { vi.unstubAllGlobals(); }); + + it('OIDC-SVC-033: accepts token whose iss matches expectedIssuer exactly', async () => { + mockJwks(); + const token = makeToken(ISSUER); + const result = await verifyIdToken(token, doc, CLIENT_ID, ISSUER); + expect(result.ok).toBe(true); + }); + + it('OIDC-SVC-034: accepts token whose iss has a trailing slash (Authentik)', async () => { + mockJwks(); + const token = makeToken(ISSUER + '/'); + const result = await verifyIdToken(token, doc, CLIENT_ID, ISSUER); + expect(result.ok).toBe(true); + }); + + it('OIDC-SVC-035: rejects token with wrong issuer', async () => { + mockJwks(); + const token = makeToken('https://evil.example.com'); + const result = await verifyIdToken(token, doc, CLIENT_ID, ISSUER); + expect(result.ok).toBe(false); + expect((result as any).error).toMatch('jwt issuer invalid'); + }); + + it('OIDC-SVC-036: rejects token with wrong audience', async () => { + mockJwks(); + const token = makeToken(ISSUER, {}); + const wrongAudToken = jwtLib.sign( + { sub: 'user-sub', iss: ISSUER }, + privateKey, + { algorithm: 'RS256', audience: 'wrong-client', expiresIn: '1h' } + ); + const result = await verifyIdToken(wrongAudToken, doc, CLIENT_ID, ISSUER); + expect(result.ok).toBe(false); + }); +}); diff --git a/unraid-template.xml b/unraid-template.xml index 69ca38f6..b9c48442 100644 --- a/unraid-template.xml +++ b/unraid-template.xml @@ -37,6 +37,7 @@ false + false true 1 false diff --git a/wiki/Environment-Variables.md b/wiki/Environment-Variables.md index 9f1e658f..7ea29c5a 100644 --- a/wiki/Environment-Variables.md +++ b/wiki/Environment-Variables.md @@ -48,11 +48,12 @@ Verified in `server/src/config.ts` (line 107): ## HTTPS / Proxy -These three variables work together behind a TLS-terminating reverse proxy. See [Reverse-Proxy] for the full explanation. +These three variables work together behind a TLS-terminating reverse proxy. See [Reverse-Proxy](Reverse-Proxy) for the full explanation. | Variable | Description | Default | |---|---|---| | `FORCE_HTTPS` | When `true`: 301-redirects HTTP→HTTPS, sends HSTS (`max-age=31536000`), adds CSP `upgrade-insecure-requests`, forces cookie `secure` flag. Only useful behind a TLS proxy. Requires `TRUST_PROXY`. | `false` | +| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` | | `TRUST_PROXY` | Number of trusted proxy hops. Tells Express to read the real client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` automatically in production. Required for `FORCE_HTTPS` to detect the forwarded protocol. | `1` (production) | | `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived as `true` when `NODE_ENV=production` or `FORCE_HTTPS=true`. Set to `false` only as an escape hatch for LAN testing without TLS — not recommended in production. | auto | @@ -62,7 +63,7 @@ These three variables work together behind a TLS-terminating reverse proxy. See ## OIDC / SSO -For setup instructions, see [OIDC-SSO]. +For setup instructions, see [OIDC-SSO](OIDC-SSO). | Variable | Description | Default | |---|---|---| @@ -110,7 +111,7 @@ Both variables must be set together. If either is omitted, the account is create ## MCP -For setup instructions, see [MCP-Overview]. +For setup instructions, see [MCP-Overview](MCP-Overview). | Variable | Description | Default | |---|---|---| @@ -129,7 +130,7 @@ For setup instructions, see [MCP-Overview]. ## Related Pages -- [Reverse-Proxy] — HTTPS proxy setup and the `FORCE_HTTPS` / `TRUST_PROXY` / `COOKIE_SECURE` trio -- [OIDC-SSO] — complete OIDC configuration guide -- [MCP-Overview] — MCP server setup and rate limiting -- [Encryption-Key-Rotation] — rotating the `ENCRYPTION_KEY` without losing data +- [Reverse-Proxy](Reverse-Proxy) — HTTPS proxy setup and the `FORCE_HTTPS` / `TRUST_PROXY` / `COOKIE_SECURE` trio +- [OIDC-SSO](OIDC-SSO) — complete OIDC configuration guide +- [MCP-Overview](MCP-Overview) — MCP server setup and rate limiting +- [Encryption-Key-Rotation](Encryption-Key-Rotation) — rotating the `ENCRYPTION_KEY` without losing data diff --git a/wiki/Install-Docker-Compose.md b/wiki/Install-Docker-Compose.md index f9344617..16b72821 100644 --- a/wiki/Install-Docker-Compose.md +++ b/wiki/Install-Docker-Compose.md @@ -93,7 +93,7 @@ ALLOWED_ORIGINS=https://trek.example.com APP_URL=https://trek.example.com ``` -Uncomment and fill in the OIDC, initial setup, or MCP variables as needed. For a full description of every variable, see [Environment-Variables]. +Uncomment and fill in the OIDC, initial setup, or MCP variables as needed. For a full description of every variable, see [Environment-Variables](Environment-Variables). ## Start TREK @@ -111,10 +111,10 @@ docker compose logs -f This compose file is designed for deployments where a reverse proxy (nginx, Caddy, Traefik) terminates TLS in front of TREK. To enable HTTPS redirects and secure cookies, uncomment `FORCE_HTTPS=true` and `TRUST_PROXY=1`. -See [Reverse-Proxy] for complete proxy configuration examples. +See [Reverse-Proxy](Reverse-Proxy) for complete proxy configuration examples. ## Next Steps -- [Environment-Variables] — full variable reference -- [Reverse-Proxy] — HTTPS configuration -- [Updating] — how to pull a new image +- [Environment-Variables](Environment-Variables) — full variable reference +- [Reverse-Proxy](Reverse-Proxy) — HTTPS configuration +- [Updating](Updating) — how to pull a new image diff --git a/wiki/Install-Docker.md b/wiki/Install-Docker.md index 17dd3983..62ddbf8d 100644 --- a/wiki/Install-Docker.md +++ b/wiki/Install-Docker.md @@ -32,7 +32,7 @@ Pass additional `-e` flags for timezone and CORS/email link support: -e ALLOWED_ORIGINS=https://trek.example.com \ ``` -See [Environment-Variables] for the full list. +See [Environment-Variables](Environment-Variables) for the full list. ## Volume Reference @@ -66,11 +66,11 @@ docker logs trek ## Limitations of `docker run` -A bare `docker run` command has no built-in secret management and is harder to reproduce after a system reboot. For production, see [Install-Docker-Compose], which adds security hardening (`read_only`, `cap_drop`, `cap_add`, `no-new-privileges`, `tmpfs`) and makes it easy to manage environment variables through a `.env` file. +A bare `docker run` command has no built-in secret management and is harder to reproduce after a system reboot. For production, see [Install-Docker-Compose](Install-Docker-Compose), which adds security hardening (`read_only`, `cap_drop`, `cap_add`, `no-new-privileges`, `tmpfs`) and makes it easy to manage environment variables through a `.env` file. ## Next Steps -- [Reverse-Proxy] — HTTPS is required for PWA install and the `trek_session` cookie `secure` flag -- [Install-Docker-Compose] — recommended for production -- [Environment-Variables] — full list of configurable variables -- [Updating] — how to pull a new image without losing data +- [Reverse-Proxy](Reverse-Proxy) — HTTPS is required for PWA install and the `trek_session` cookie `secure` flag +- [Install-Docker-Compose](Install-Docker-Compose) — recommended for production +- [Environment-Variables](Environment-Variables) — full list of configurable variables +- [Updating](Updating) — how to pull a new image without losing data diff --git a/wiki/Install-Helm.md b/wiki/Install-Helm.md index d0fca6db..1a320a09 100644 --- a/wiki/Install-Helm.md +++ b/wiki/Install-Helm.md @@ -191,5 +191,5 @@ See the [`charts/README.md`](https://github.com/mauriceboe/TREK/blob/main/charts ## Next Steps -- [Environment-Variables] — full variable reference -- [Reverse-Proxy] — proxy configuration for non-Kubernetes deployments +- [Environment-Variables](Environment-Variables) — full variable reference +- [Reverse-Proxy](Reverse-Proxy) — proxy configuration for non-Kubernetes deployments diff --git a/wiki/Install-Unraid.md b/wiki/Install-Unraid.md index 83e1706f..0edf49d6 100644 --- a/wiki/Install-Unraid.md +++ b/wiki/Install-Unraid.md @@ -69,5 +69,5 @@ On first boot, TREK automatically creates an admin account. The credentials are ## Next Steps -- [Environment-Variables] — complete variable reference -- [Updating] — how to pull a new image on Unraid +- [Environment-Variables](Environment-Variables) — complete variable reference +- [Updating](Updating) — how to pull a new image on Unraid diff --git a/wiki/Map-Features.md b/wiki/Map-Features.md index 878976eb..3d8e170e 100644 --- a/wiki/Map-Features.md +++ b/wiki/Map-Features.md @@ -36,18 +36,20 @@ When you have a day selected, a dark dashed line connects consecutive places in At zoom level 12 or higher, small pill-shaped labels appear between consecutive places and show the estimated **walking time** and **driving time** for each segment. Below zoom 12 they are hidden to keep the map clean. +> **Requires:** Settings → Display → **Route calculation** must be ON. When this setting is OFF, TREK never queries the routing service, so no pills are calculated or drawn at any zoom level. + ## Reservation and transport overlay -Flights, trains, cars, and cruises are drawn as overlays between their endpoint places: +Flights, trains, cars, and cruises can be drawn as overlays between their endpoint places. Overlays are **off by default** — activate each reservation individually by clicking the small **Route** icon next to the booking row in the day sidebar. The selection is remembered per trip in your browser. Click the icon again to hide it. - **Flights and cruises** — geodesic great-circle arcs - **Trains and cars** — straight lines - **Antimeridian crossings** — arcs that would cross the date line are split into sub-arcs to avoid wrapping across the map - **Endpoint markers** — pill-shaped labels with the transport icon and the endpoint code (e.g. IATA airport code) or location name -- **Flight stats** — a floating label on the arc shows departure code → arrival code and, when times are available, the duration and great-circle distance. Stats labels are only rendered for flights. +- **Flight stats** — a floating label on the arc shows departure code → arrival code and, when times are available, the duration and great-circle distance. Stats labels are only rendered for flights and require Settings → Display → **Route calculation** to be ON. - **Confirmed reservations** — solid line; **Pending** — dashed line -> **Admin:** Whether endpoint labels appear is controlled by the **Show connection labels** setting (`map_booking_labels`). +> **Admin:** Whether endpoint text labels appear on the endpoint markers is controlled by the **Booking route labels** setting in Settings → Display (`map_booking_labels`). ## Location button diff --git a/wiki/Quick-Start.md b/wiki/Quick-Start.md index 786af51d..17e7b970 100644 --- a/wiki/Quick-Start.md +++ b/wiki/Quick-Start.md @@ -60,7 +60,7 @@ You will be prompted to change the password on first login. ## Next Steps -- [Install-Docker-Compose] — production setup with security hardening -- [Reverse-Proxy] — put TREK behind HTTPS (required for PWA install and secure cookies) -- [Environment-Variables] — full configuration reference -- [Admin-Panel-Overview] — explore what the admin panel can do +- [Install-Docker-Compose](Install-Docker-Compose) — production setup with security hardening +- [Reverse-Proxy](Reverse-Proxy) — put TREK behind HTTPS (required for PWA install and secure cookies) +- [Environment-Variables](Environment-Variables) — full configuration reference +- [Admin-Panel-Overview](Admin-Panel-Overview) — explore what the admin panel can do diff --git a/wiki/Reverse-Proxy.md b/wiki/Reverse-Proxy.md index a9993960..526472ee 100644 --- a/wiki/Reverse-Proxy.md +++ b/wiki/Reverse-Proxy.md @@ -98,9 +98,9 @@ Four variables control how TREK behaves behind a proxy. They work as a group: If you access TREK directly on `http://:3000` without a proxy, leave `FORCE_HTTPS` unset and do not set `TRUST_PROXY`. -See [Environment-Variables] for full documentation of these and all other variables. +See [Environment-Variables](Environment-Variables) for full documentation of these and all other variables. ## Next Steps -- [Environment-Variables] — full variable reference including OIDC -- [Install-Docker-Compose] — production compose file with proxy-ready env vars +- [Environment-Variables](Environment-Variables) — full variable reference including OIDC +- [Install-Docker-Compose](Install-Docker-Compose) — production compose file with proxy-ready env vars diff --git a/wiki/Updating.md b/wiki/Updating.md index 46e45ba9..2e63c91d 100644 --- a/wiki/Updating.md +++ b/wiki/Updating.md @@ -4,7 +4,7 @@ How to update TREK to a newer version without losing data. ## Before You Update -Back up your data first. Go to Admin Panel → Backups and create a manual backup, or copy your `./data` and `./uploads` directories to a safe location. See [Backups] for details. +Back up your data first. Go to Admin Panel → Backups and create a manual backup, or copy your `./data` and `./uploads` directories to a safe location. See [Backups](Backups) for details. ## Docker Compose (Recommended) @@ -42,7 +42,7 @@ TREK runs any pending database migrations automatically at startup. No manual mi If you are upgrading from a version that predates the dedicated `ENCRYPTION_KEY` (i.e. you have no `ENCRYPTION_KEY` environment variable set), TREK automatically falls back to `./data/.jwt_secret` on startup and immediately promotes it to `./data/.encryption_key`. No manual steps are required — the transition is handled at first boot after the upgrade. -If you want to rotate to a new key at any point (not required for a normal update), see [Encryption-Key-Rotation] for the full procedure. +If you want to rotate to a new key at any point (not required for a normal update), see [Encryption-Key-Rotation](Encryption-Key-Rotation) for the full procedure. ## Unraid @@ -50,6 +50,6 @@ In the Unraid Docker tab, click the TREK container and select **Update**. Unraid ## Next Steps -- [Backups] — schedule automatic backups so you always have a restore point before updates -- [Encryption-Key-Rotation] — if you need to rotate or migrate the encryption key -- [Install-Docker-Compose] — switch to Compose for easier future updates +- [Backups](Backups) — schedule automatic backups so you always have a restore point before updates +- [Encryption-Key-Rotation](Encryption-Key-Rotation) — if you need to rotate or migrate the encryption key +- [Install-Docker-Compose](Install-Docker-Compose) — switch to Compose for easier future updates