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