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