mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8342cf3010 | |||
| 2a37eeccb3 | |||
| ae0e59d9f1 | |||
| 50bb7573fd | |||
| b852317c84 | |||
| 4436b6f673 | |||
| 311647fd46 | |||
| 28dbd86d03 | |||
| 842d9760df | |||
| 58218ff5f6 | |||
| 83be5fc92a | |||
| 7798d2a3fd |
@@ -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` |
|
| `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 |
|
| `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` |
|
| `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 |
|
| `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` |
|
| `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` |
|
| `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` |
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
apiVersion: v2
|
apiVersion: v2
|
||||||
name: trek
|
name: trek
|
||||||
version: 3.0.2
|
version: 3.0.8
|
||||||
description: Minimal Helm chart for TREK app
|
description: Minimal Helm chart for TREK app
|
||||||
appVersion: "3.0.2"
|
appVersion: "3.0.8"
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ data:
|
|||||||
{{- if .Values.env.FORCE_HTTPS }}
|
{{- if .Values.env.FORCE_HTTPS }}
|
||||||
FORCE_HTTPS: {{ .Values.env.FORCE_HTTPS | quote }}
|
FORCE_HTTPS: {{ .Values.env.FORCE_HTTPS | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
{{- if .Values.env.HSTS_INCLUDE_SUBDOMAINS }}
|
||||||
|
HSTS_INCLUDE_SUBDOMAINS: {{ .Values.env.HSTS_INCLUDE_SUBDOMAINS | quote }}
|
||||||
|
{{- end }}
|
||||||
{{- if .Values.env.COOKIE_SECURE }}
|
{{- if .Values.env.COOKIE_SECURE }}
|
||||||
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
|
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ env:
|
|||||||
# Also used as the base URL for links in email notifications and other external links.
|
# Also used as the base URL for links in email notifications and other external links.
|
||||||
# FORCE_HTTPS: "false"
|
# FORCE_HTTPS: "false"
|
||||||
# Optional. When "true": HTTPS redirect, HSTS, CSP upgrade-insecure-requests, secure cookies. Only behind a TLS proxy. Requires TRUST_PROXY.
|
# 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"
|
# 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.
|
# 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"
|
# TRUST_PROXY: "1"
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-client",
|
"name": "trek-client",
|
||||||
"version": "3.0.2",
|
"version": "3.0.8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "trek-client",
|
"name": "trek-client",
|
||||||
"version": "3.0.2",
|
"version": "3.0.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-pdf/renderer": "^4.3.2",
|
"@react-pdf/renderer": "^4.3.2",
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-client",
|
"name": "trek-client",
|
||||||
"version": "3.0.2",
|
"version": "3.0.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -634,7 +634,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
}
|
}
|
||||||
const handleRenameCategory = async (oldName, newName) => {
|
const handleRenameCategory = async (oldName, newName) => {
|
||||||
if (!newName.trim() || newName.trim() === oldName) return
|
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() })
|
for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() })
|
||||||
}
|
}
|
||||||
const handleAddCategory = () => {
|
const handleAddCategory = () => {
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ const transportReservation = {
|
|||||||
id: 400,
|
id: 400,
|
||||||
title: 'Flight to Rome',
|
title: 'Flight to Rome',
|
||||||
type: 'flight',
|
type: 'flight',
|
||||||
|
day_id: 10,
|
||||||
reservation_time: '2025-06-01T14:30:00',
|
reservation_time: '2025-06-01T14:30:00',
|
||||||
confirmation_number: 'ABC123',
|
confirmation_number: 'ABC123',
|
||||||
metadata: JSON.stringify({ airline: 'Air Italia', flight_number: 'AI123', departure_airport: 'CDG', arrival_airport: 'FCO' }),
|
metadata: JSON.stringify({ airline: 'Air Italia', flight_number: 'AI123', departure_airport: 'CDG', arrival_airport: 'FCO' }),
|
||||||
|
|||||||
@@ -140,23 +140,58 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
const totalCost = Object.values(assignments || {})
|
const totalCost = Object.values(assignments || {})
|
||||||
.flatMap(a => a).reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
|
.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
|
// Build day HTML
|
||||||
const daysHtml = sorted.map((day, di) => {
|
const daysHtml = sorted.map((day, di) => {
|
||||||
const assigned = assignments[String(day.id)] || []
|
const assigned = assignments[String(day.id)] || []
|
||||||
const notes = (dayNotes || []).filter(n => n.day_id === day.id)
|
const notes = (dayNotes || []).filter(n => n.day_id === day.id)
|
||||||
const cost = dayCost(assignments, day.id, loc)
|
const cost = dayCost(assignments, day.id, loc)
|
||||||
|
|
||||||
// Reservations for this day (hotel rendered via accommodations block)
|
// Reservations for this day (hotel rendered via accommodations block; car middle-phase rendered in sidebar header only)
|
||||||
const dayReservations = (reservations || []).filter(r => {
|
const dayReservations = pdfGetTransportForDay(day.id)
|
||||||
if (!r.reservation_time || r.type === 'hotel') return false
|
.filter(r => !(r.type === 'car' && pdfGetSpanPhase(r, day.id) === 'middle'))
|
||||||
return day.date && r.reservation_time.split('T')[0] === day.date
|
|
||||||
})
|
|
||||||
|
|
||||||
const merged = []
|
const merged = []
|
||||||
assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a }))
|
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 }))
|
notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
|
||||||
dayReservations.forEach(r => {
|
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.push({ type: 'reservation', k: pos, data: r })
|
||||||
})
|
})
|
||||||
merged.sort((a, b) => a.k - b.k)
|
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 === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
|
||||||
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
|
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
|
||||||
const locationLine = r.location || meta.location || ''
|
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 `
|
return `
|
||||||
<div class="note-card" style="border-left: 3px solid ${color};">
|
<div class="note-card" style="border-left: 3px solid ${color};">
|
||||||
<div class="note-line" style="background: ${color};"></div>
|
<div class="note-line" style="background: ${color};"></div>
|
||||||
<span class="note-icon">${icon}</span>
|
<span class="note-icon">${icon}</span>
|
||||||
<div class="note-body">
|
<div class="note-body">
|
||||||
<div class="note-text" style="font-weight: 600;">${escHtml(r.title)}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div>
|
<div class="note-text" style="font-weight: 600;">${titleHtml}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div>
|
||||||
${subtitle ? `<div class="note-time">${escHtml(subtitle)}</div>` : ''}
|
${subtitle ? `<div class="note-time">${escHtml(subtitle)}</div>` : ''}
|
||||||
${locationLine ? `<div class="note-time">${escHtml(locationLine)}</div>` : ''}
|
${locationLine ? `<div class="note-time">${escHtml(locationLine)}</div>` : ''}
|
||||||
${r.confirmation_number ? `<div class="note-time" style="font-size:9px;">Code: ${escHtml(r.confirmation_number)}</div>` : ''}
|
${r.confirmation_number ? `<div class="note-time" style="font-size:9px;">Code: ${escHtml(r.confirmation_number)}</div>` : ''}
|
||||||
|
|||||||
@@ -66,7 +66,11 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
|
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
|
||||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||||
const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
|
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 unit = isFahrenheit ? '°F' : '°C'
|
||||||
const collapsed = collapsedProp
|
const collapsed = collapsedProp
|
||||||
const toggleCollapse = () => onToggleCollapse?.()
|
const toggleCollapse = () => onToggleCollapse?.()
|
||||||
|
|||||||
@@ -1576,7 +1576,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
{res.reservation_time?.includes('T') && (
|
{res.reservation_time?.includes('T') && (
|
||||||
<span style={{ fontWeight: 400 }}>
|
<span style={{ fontWeight: 400 }}>
|
||||||
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
{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' })
|
||||||
|
})()}`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{(() => {
|
{(() => {
|
||||||
|
|||||||
@@ -182,6 +182,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
let combinedEndTime = form.reservation_end_time
|
let combinedEndTime = form.reservation_end_time
|
||||||
if (form.end_date) {
|
if (form.end_date) {
|
||||||
combinedEndTime = form.reservation_end_time ? `${form.end_date}T${form.reservation_end_time}` : 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 (isBudgetEnabled) {
|
||||||
if (form.price) metadata.price = form.price
|
if (form.price) metadata.price = form.price
|
||||||
|
|||||||
@@ -236,7 +236,16 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
|
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
|
||||||
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
||||||
{fmtDate(r.reservation_time)}
|
{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)}</>
|
<> – {fmtDate(r.reservation_end_time)}</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export default function ConfirmDialog({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter"
|
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter"
|
||||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)' }}
|
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingBottom: 'var(--bottom-nav-h)' }}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export default function CopyTripDialog({ isOpen, tripTitle, onClose, onConfirm }
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter"
|
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter"
|
||||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)' }}
|
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingBottom: 'var(--bottom-nav-h)' }}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -343,7 +343,10 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
}, [tripId])
|
}, [tripId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tripId) tripActions.loadReservations(tripId)
|
if (tripId) {
|
||||||
|
tripActions.loadReservations(tripId)
|
||||||
|
tripActions.loadBudgetItems?.(tripId)
|
||||||
|
}
|
||||||
}, [tripId])
|
}, [tripId])
|
||||||
|
|
||||||
useTripWebSocket(tripId)
|
useTripWebSocket(tripId)
|
||||||
@@ -1106,7 +1109,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||||
{mobileSidebarOpen === 'left'
|
{mobileSidebarOpen === 'left'
|
||||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { 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} />
|
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { 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} />
|
||||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { 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} />
|
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { 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} />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -355,6 +355,37 @@ describe('journeyStore', () => {
|
|||||||
expect(useJourneyStore.getState().loading).toBe(false);
|
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 ────────────────────────────────────────────────────────────────
|
// ── clear ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('FE-STORE-JOURNEY-015: clear resets state', () => {
|
it('FE-STORE-JOURNEY-015: clear resets state', () => {
|
||||||
|
|||||||
@@ -223,10 +223,8 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
|
|||||||
)
|
)
|
||||||
entries.sort((a, b) => {
|
entries.sort((a, b) => {
|
||||||
if (a.entry_date !== b.entry_date) return a.entry_date.localeCompare(b.entry_date)
|
if (a.entry_date !== b.entry_date) return a.entry_date.localeCompare(b.entry_date)
|
||||||
const atime = a.entry_time || ''
|
if (a.sort_order !== b.sort_order) return (a.sort_order || 0) - (b.sort_order || 0)
|
||||||
const btime = b.entry_time || ''
|
return a.id - b.id
|
||||||
if (atime !== btime) return atime.localeCompare(btime)
|
|
||||||
return (a.sort_order || 0) - (b.sort_order || 0)
|
|
||||||
})
|
})
|
||||||
return { current: { ...s.current, entries } }
|
return { current: { ...s.current, entries } }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ function triggerAnchorDownload(blobUrl: string, filename?: string): void {
|
|||||||
setTimeout(() => { URL.revokeObjectURL(blobUrl); a.remove() }, 100)
|
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
|
* Fetches a protected file using cookie auth (credentials: include) and
|
||||||
* triggers a browser download. Works inside PWA standalone mode because the
|
* triggers a browser download. Works inside PWA standalone mode because the
|
||||||
@@ -56,7 +63,13 @@ export async function downloadFile(url: string, filename?: string): Promise<void
|
|||||||
* (including text/html and image/svg+xml which can execute script) are forced
|
* (including text/html and image/svg+xml which can execute script) are forced
|
||||||
* to download so that an uploaded file cannot run code in the TREK origin.
|
* to download so that an uploaded file cannot run code in the TREK origin.
|
||||||
*
|
*
|
||||||
* Falls back to a download trigger if the popup is blocked.
|
* Uses a synthetic <a target="_blank" rel="noopener noreferrer"> 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<void> {
|
export async function openFile(url: string, filename?: string): Promise<void> {
|
||||||
assertRelativeUrl(url)
|
assertRelativeUrl(url)
|
||||||
@@ -71,11 +84,19 @@ export async function openFile(url: string, filename?: string): Promise<void> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const win = window.open(blobUrl, '_blank', 'noreferrer')
|
// iOS PWA: target="_blank" would open Safari, which can't access the blob
|
||||||
if (win) {
|
if (isIosStandalone()) {
|
||||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000)
|
|
||||||
} else {
|
|
||||||
// Popup blocked — fall back to download
|
|
||||||
triggerAnchorDownload(blobUrl, filename)
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,32 +74,42 @@ describe('downloadFile', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('openFile', () => {
|
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))
|
vi.stubGlobal('fetch', makeFetchMock(200))
|
||||||
const mockWin = { closed: false }
|
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||||
const openSpy = vi.spyOn(window, 'open').mockReturnValue(mockWin as Window)
|
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||||
|
|
||||||
await openFile('/uploads/files/doc.pdf')
|
await openFile('/uploads/files/doc.pdf')
|
||||||
|
|
||||||
expect(window.fetch).toHaveBeenCalledWith('/uploads/files/doc.pdf', { credentials: 'include' })
|
expect(window.fetch).toHaveBeenCalledWith('/uploads/files/doc.pdf', { credentials: 'include' })
|
||||||
expect(URL.createObjectURL).toHaveBeenCalled()
|
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<typeof vi.fn>).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
|
// Revoke happens after 30s timeout
|
||||||
vi.runAllTimers()
|
vi.runAllTimers()
|
||||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
|
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.stubGlobal('fetch', makeFetchMock(200))
|
||||||
vi.spyOn(window, 'open').mockReturnValue(null)
|
|
||||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
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()
|
// Exactly ONE anchor click — opening the new tab. No fallback download.
|
||||||
vi.runAllTimers()
|
expect(clickSpy).toHaveBeenCalledTimes(1)
|
||||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('throws on 401 response', async () => {
|
it('throws on 401 response', async () => {
|
||||||
@@ -108,28 +118,55 @@ describe('openFile', () => {
|
|||||||
expect(URL.createObjectURL).not.toHaveBeenCalled()
|
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(['<script>alert(1)</script>'], { type: 'text/html' })
|
const htmlBlob = new Blob(['<script>alert(1)</script>'], { type: 'text/html' })
|
||||||
vi.stubGlobal('fetch', makeFetchMock(200, htmlBlob))
|
vi.stubGlobal('fetch', makeFetchMock(200, htmlBlob))
|
||||||
const openSpy = vi.spyOn(window, 'open').mockReturnValue({} as Window)
|
const openSpy = vi.spyOn(window, 'open').mockReturnValue({} as Window)
|
||||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
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
|
// Must NOT open inline — download anchor clicked instead
|
||||||
expect(openSpy).not.toHaveBeenCalled()
|
expect(openSpy).not.toHaveBeenCalled()
|
||||||
expect(clickSpy).toHaveBeenCalled()
|
expect(clickSpy).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
const appendCalls = (document.body.appendChild as ReturnType<typeof vi.fn>).mock.calls
|
||||||
|
const anchor = appendCalls[0]?.[0] as HTMLAnchorElement
|
||||||
|
expect(anchor.download).toBe('malicious.html')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('forces download for SVG MIME type', async () => {
|
it('forces download for SVG MIME type', async () => {
|
||||||
const svgBlob = new Blob(['<svg><script>alert(1)</script></svg>'], { type: 'image/svg+xml' })
|
const svgBlob = new Blob(['<svg><script>alert(1)</script></svg>'], { type: 'image/svg+xml' })
|
||||||
vi.stubGlobal('fetch', makeFetchMock(200, svgBlob))
|
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(() => {})
|
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||||
|
|
||||||
await openFile('/uploads/files/malicious.svg')
|
await openFile('/uploads/files/malicious.svg')
|
||||||
|
|
||||||
expect(window.open).not.toHaveBeenCalled()
|
expect(openSpy).not.toHaveBeenCalled()
|
||||||
expect(clickSpy).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<typeof vi.fn>).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
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
# - 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
|
- 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
|
# - 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.
|
# - 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.
|
# - 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.
|
# - 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.
|
||||||
|
|||||||
@@ -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
|
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.
|
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.
|
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.
|
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.
|
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.
|
||||||
|
|||||||
Generated
+27
-62
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-server",
|
"name": "trek-server",
|
||||||
"version": "3.0.2",
|
"version": "3.0.8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "trek-server",
|
"name": "trek-server",
|
||||||
"version": "3.0.2",
|
"version": "3.0.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||||
"archiver": "^6.0.1",
|
"archiver": "^6.0.1",
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"undici": "^7.0.0",
|
"undici": "^7.0.0",
|
||||||
"unzipper": "^0.12.3",
|
"unzipper": "^0.12.3",
|
||||||
"uuid": "^9.0.0",
|
"uuid": "^14.0.0",
|
||||||
"ws": "^8.19.0",
|
"ws": "^8.19.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
@@ -663,9 +663,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -682,9 +679,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -701,9 +695,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -720,9 +711,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -739,9 +727,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -758,9 +743,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -777,9 +759,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -796,9 +775,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -815,9 +791,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -840,9 +813,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -865,9 +835,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -890,9 +857,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -915,9 +879,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -940,9 +901,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -965,9 +923,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -990,9 +945,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1551,6 +1503,18 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"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": {
|
"node_modules/@otplib/core": {
|
||||||
"version": "12.0.1",
|
"version": "12.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz",
|
||||||
@@ -3767,9 +3731,9 @@
|
|||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
"node_modules/fast-xml-builder": {
|
"node_modules/fast-xml-builder": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz",
|
||||||
"integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==",
|
"integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -3782,9 +3746,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fast-xml-parser": {
|
"node_modules/fast-xml-parser": {
|
||||||
"version": "5.5.12",
|
"version": "5.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.12.tgz",
|
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.1.tgz",
|
||||||
"integrity": "sha512-nUR0q8PPfoA/svPM43Gup7vLOZWppaNrYgGmrVqrAVJa7cOH4hMG6FX9M4mQ8dZA1/ObGZHzES7Ed88hxEBSJg==",
|
"integrity": "sha512-8Cc3f8GUGUULg34pBch/KGyPLglS+OFs05deyOlY7fL2MTagYPKrVQNmR1fLF/yJ9PH5ZSTd3YDF6pnmeZU+zA==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -3793,7 +3757,8 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-xml-builder": "^1.1.4",
|
"@nodable/entities": "^2.1.0",
|
||||||
|
"fast-xml-builder": "^1.1.5",
|
||||||
"path-expression-matcher": "^1.5.0",
|
"path-expression-matcher": "^1.5.0",
|
||||||
"strnum": "^2.2.3"
|
"strnum": "^2.2.3"
|
||||||
},
|
},
|
||||||
@@ -6481,16 +6446,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/uuid": {
|
"node_modules/uuid": {
|
||||||
"version": "9.0.1",
|
"version": "14.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz",
|
||||||
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
|
"integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
"https://github.com/sponsors/broofa",
|
"https://github.com/sponsors/broofa",
|
||||||
"https://github.com/sponsors/ctavan"
|
"https://github.com/sponsors/ctavan"
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"uuid": "dist/bin/uuid"
|
"uuid": "dist-node/bin/uuid"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vary": {
|
"node_modules/vary": {
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-server",
|
"name": "trek-server",
|
||||||
"version": "3.0.2",
|
"version": "3.0.8",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node --import tsx src/index.ts",
|
"start": "node --import tsx src/index.ts",
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"undici": "^7.0.0",
|
"undici": "^7.0.0",
|
||||||
"unzipper": "^0.12.3",
|
"unzipper": "^0.12.3",
|
||||||
"uuid": "^9.0.0",
|
"uuid": "^14.0.0",
|
||||||
"ws": "^8.19.0",
|
"ws": "^8.19.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ export function createApp(): express.Application {
|
|||||||
},
|
},
|
||||||
crossOriginEmbedderPolicy: false,
|
crossOriginEmbedderPolicy: false,
|
||||||
hsts: hstsActive ? { maxAge: 31536000, includeSubDomains: hstsIncludeSubdomains } : false,
|
hsts: hstsActive ? { maxAge: 31536000, includeSubDomains: hstsIncludeSubdomains } : false,
|
||||||
|
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (shouldForceHttps) {
|
if (shouldForceHttps) {
|
||||||
|
|||||||
@@ -2107,6 +2107,29 @@ function runMigrations(db: Database.Database): void {
|
|||||||
!= substr(reservations.reservation_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) {
|
if (currentVersion < migrations.length) {
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ router.get('/callback', async (req: Request, res: Response) => {
|
|||||||
tokenData.id_token,
|
tokenData.id_token,
|
||||||
doc,
|
doc,
|
||||||
config.clientId,
|
config.clientId,
|
||||||
config.issuer,
|
(doc.issuer ?? '').replace(/\/+$/, '') || config.issuer,
|
||||||
);
|
);
|
||||||
if (idVerify.ok !== true) {
|
if (idVerify.ok !== true) {
|
||||||
const reason = 'error' in idVerify ? idVerify.error : 'unknown';
|
const reason = 'error' in idVerify ? idVerify.error : 'unknown';
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
|
|||||||
if (!journey) return null;
|
if (!journey) return null;
|
||||||
|
|
||||||
const entries = db.prepare(
|
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[];
|
).all(journeyId) as JourneyEntry[];
|
||||||
|
|
||||||
const photos = db.prepare(
|
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 }[];
|
).all(journeyId, tripId) as { source_place_id: number }[];
|
||||||
const existingPlaceIds = new Set(existing.map(e => e.source_place_id));
|
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<string, number>();
|
||||||
|
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) {
|
for (const place of places) {
|
||||||
if (existingPlaceIds.has(place.id)) continue;
|
if (existingPlaceIds.has(place.id)) continue;
|
||||||
existingPlaceIds.add(place.id);
|
existingPlaceIds.add(place.id);
|
||||||
|
|
||||||
const entryDate = place.day_date || new Date().toISOString().split('T')[0];
|
const entryDate = place.day_date || new Date().toISOString().split('T')[0];
|
||||||
const entryTime = place.assignment_time || place.place_time || null;
|
const entryTime = place.assignment_time || place.place_time || null;
|
||||||
|
const nextOrder = (dateMaxOrder.get(entryDate) ?? -1) + 1;
|
||||||
|
dateMaxOrder.set(entryDate, nextOrder);
|
||||||
|
|
||||||
db.prepare(`
|
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)
|
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,
|
journeyId, tripId, place.id, authorId,
|
||||||
place.name, entryDate, entryTime,
|
place.name, entryDate, entryTime,
|
||||||
place.address || place.name, place.lat || null, place.lng || null,
|
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 journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(link.journey_id) as { user_id: number };
|
||||||
const entryDate = place.day_date;
|
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(`
|
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)
|
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(
|
`).run(
|
||||||
link.journey_id, tripId, placeId, journey.user_id,
|
link.journey_id, tripId, placeId, journey.user_id,
|
||||||
place.name, entryDate, place.assignment_time || place.place_time || null,
|
place.name, entryDate, place.assignment_time || place.place_time || null,
|
||||||
place.address || place.name, place.lat || null, place.lng || 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;
|
if (!canAccessJourney(journeyId, userId)) return null;
|
||||||
|
|
||||||
const entries = db.prepare(
|
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[];
|
).all(journeyId) as JourneyEntry[];
|
||||||
|
|
||||||
const photos = db.prepare(
|
const photos = db.prepare(
|
||||||
|
|||||||
@@ -140,11 +140,21 @@ export async function discover(issuer: string, discoveryUrl?: string | null): Pr
|
|||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
if (!res.ok) throw new Error('Failed to fetch OIDC discovery document');
|
if (!res.ok) throw new Error('Failed to fetch OIDC discovery document');
|
||||||
const doc = (await res.json()) as OidcDiscoveryDoc;
|
const doc = (await res.json()) as OidcDiscoveryDoc;
|
||||||
// Validate that the discovery doc's issuer matches the operator-configured
|
// Validate that the discovery doc's issuer matches the operator-configured one.
|
||||||
// one. A MITM or compromised doc could otherwise supply a crafted issuer
|
// When no custom discoveryUrl is set, a mismatch signals a MITM or misconfiguration
|
||||||
// that passes jwt.verify() because we used doc.issuer as the expected value.
|
// and we reject. When the operator explicitly overrides the discovery URL (e.g.
|
||||||
if (doc.issuer && doc.issuer.replace(/\/+$/, '') !== issuer) {
|
// Authentik realm paths), the discovery doc's issuer is the canonical value —
|
||||||
throw new Error(`OIDC discovery issuer mismatch: expected "${issuer}", got "${doc.issuer}"`);
|
// 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;
|
doc._issuer = url;
|
||||||
discoveryCache = doc;
|
discoveryCache = doc;
|
||||||
@@ -313,7 +323,6 @@ export async function verifyIdToken(
|
|||||||
try {
|
try {
|
||||||
const verified = jwt.verify(idToken, publicKey, {
|
const verified = jwt.verify(idToken, publicKey, {
|
||||||
algorithms: [alg as jwt.Algorithm],
|
algorithms: [alg as jwt.Algorithm],
|
||||||
issuer: expectedIssuer,
|
|
||||||
audience: clientId,
|
audience: clientId,
|
||||||
});
|
});
|
||||||
claims = typeof verified === 'string' ? {} : (verified as Record<string, unknown>);
|
claims = typeof verified === 'string' ? {} : (verified as Record<string, unknown>);
|
||||||
@@ -322,6 +331,13 @@ export async function verifyIdToken(
|
|||||||
return { ok: false, error: `signature_or_claim_mismatch: ${msg}` };
|
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 };
|
return { ok: true, claims };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { db, canAccessTrip } from '../db/database';
|
import { db, canAccessTrip } from '../db/database';
|
||||||
|
import { avatarUrl } from './authService';
|
||||||
|
|
||||||
const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b'];
|
const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b'];
|
||||||
|
|
||||||
@@ -131,7 +132,10 @@ export function listBags(tripId: string | number) {
|
|||||||
if (!membersByBag.has(m.bag_id)) membersByBag.set(m.bag_id, []);
|
if (!membersByBag.has(m.bag_id)) membersByBag.set(m.bag_id, []);
|
||||||
membersByBag.get(m.bag_id)!.push(m);
|
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[]) {
|
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);
|
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 (?, ?)');
|
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);
|
for (const uid of userIds) ins.run(bagId, uid);
|
||||||
return db.prepare(`
|
const rows = db.prepare(`
|
||||||
SELECT bm.user_id, u.username, u.avatar
|
SELECT bm.user_id, u.username, u.avatar
|
||||||
FROM packing_bag_members bm JOIN users u ON bm.user_id = u.id
|
FROM packing_bag_members bm JOIN users u ON bm.user_id = u.id
|
||||||
WHERE bm.bag_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 }) {
|
export function createBag(tripId: string | number, data: { name: string; color?: string }) {
|
||||||
@@ -260,7 +265,7 @@ export function getCategoryAssignees(tripId: string | number) {
|
|||||||
const assignees: Record<string, { user_id: number; username: string; avatar: string | null }[]> = {};
|
const assignees: Record<string, { user_id: number; username: string; avatar: string | null }[]> = {};
|
||||||
for (const row of rows as any[]) {
|
for (const row of rows as any[]) {
|
||||||
if (!assignees[row.category_name]) assignees[row.category_name] = [];
|
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;
|
return assignees;
|
||||||
@@ -274,12 +279,13 @@ export function updateCategoryAssignees(tripId: string | number, categoryName: s
|
|||||||
for (const uid of userIds) insert.run(tripId, categoryName, uid);
|
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
|
SELECT pca.user_id, u.username, u.avatar
|
||||||
FROM packing_category_assignees pca
|
FROM packing_category_assignees pca
|
||||||
JOIN users u ON pca.user_id = u.id
|
JOIN users u ON pca.user_id = u.id
|
||||||
WHERE pca.trip_id = ? AND pca.category_name = ?
|
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 ────────────────────────────────────────────────────────────────
|
// ── Reorder ────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ import {
|
|||||||
removeContributor,
|
removeContributor,
|
||||||
getSuggestions,
|
getSuggestions,
|
||||||
syncTripPlaces,
|
syncTripPlaces,
|
||||||
|
reorderEntries,
|
||||||
onPlaceCreated,
|
onPlaceCreated,
|
||||||
onPlaceUpdated,
|
onPlaceUpdated,
|
||||||
onPlaceDeleted,
|
onPlaceDeleted,
|
||||||
@@ -1465,3 +1466,108 @@ describe('addProviderPhoto — passphrase', () => {
|
|||||||
expect(row?.passphrase).not.toBe('secret-pp');
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
* discover caching, and the ReDoS-sensitive issuer trailing-slash regex.
|
* discover caching, and the ReDoS-sensitive issuer trailing-slash regex.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
||||||
|
import { generateKeyPairSync } from 'crypto';
|
||||||
|
import jwtLib from 'jsonwebtoken';
|
||||||
|
|
||||||
// ── DB setup ──────────────────────────────────────────────────────────────────
|
// ── DB setup ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -50,6 +52,7 @@ import {
|
|||||||
frontendUrl,
|
frontendUrl,
|
||||||
findOrCreateUser,
|
findOrCreateUser,
|
||||||
discover,
|
discover,
|
||||||
|
verifyIdToken,
|
||||||
} from '../../../src/services/oidcService';
|
} from '../../../src/services/oidcService';
|
||||||
|
|
||||||
const MOCK_CONFIG = {
|
const MOCK_CONFIG = {
|
||||||
@@ -216,6 +219,59 @@ describe('discover', () => {
|
|||||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
|
||||||
await expect(discover('https://bad-issuer.example.com')).rejects.toThrow();
|
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) ─────────────────────────────────
|
// ── issuer trailing-slash regex (ReDoS guard) ─────────────────────────────────
|
||||||
@@ -460,3 +516,66 @@ describe('getUserInfo', () => {
|
|||||||
expect(fetchCall[1].headers.Authorization).toBe('Bearer access-token-123');
|
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<string, unknown>;
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
<Config Name="ALLOWED_ORIGINS" Target="ALLOWED_ORIGINS" Default="" Mode="" Description="Comma-separated origins allowed for CORS and used as base URL in email notification links (e.g. https://trek.example.com)." Type="Variable" Display="always" Required="false" Mask="false"/>
|
<Config Name="ALLOWED_ORIGINS" Target="ALLOWED_ORIGINS" Default="" Mode="" Description="Comma-separated origins allowed for CORS and used as base URL in email notification links (e.g. https://trek.example.com)." Type="Variable" Display="always" Required="false" Mask="false"/>
|
||||||
<Config Name="APP_URL" Target="APP_URL" Default="" Mode="" Description="Public base URL of this instance (e.g. https://trek.example.com). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as base URL for email notification links." Type="Variable" Display="always" Required="false" Mask="false"/>
|
<Config Name="APP_URL" Target="APP_URL" Default="" Mode="" Description="Public base URL of this instance (e.g. https://trek.example.com). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as base URL for email notification links." Type="Variable" Display="always" Required="false" Mask="false"/>
|
||||||
<Config Name="FORCE_HTTPS" Target="FORCE_HTTPS" Default="false" Mode="" Description="Optional. When true: HTTPS redirect, HSTS header, CSP upgrade-insecure-requests, and secure cookies. Only useful behind a TLS-terminating proxy. Requires TRUST_PROXY." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config>
|
<Config Name="FORCE_HTTPS" Target="FORCE_HTTPS" Default="false" Mode="" Description="Optional. When true: HTTPS redirect, HSTS header, CSP upgrade-insecure-requests, and secure cookies. Only useful behind a TLS-terminating proxy. Requires TRUST_PROXY." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config>
|
||||||
|
<Config Name="HSTS_INCLUDE_SUBDOMAINS" Target="HSTS_INCLUDE_SUBDOMAINS" Default="false" Mode="" Description="When true: adds includeSubDomains 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." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config>
|
||||||
<Config Name="COOKIE_SECURE" Target="COOKIE_SECURE" Default="true" Mode="" Description="Auto-derived (true in production or when FORCE_HTTPS=true). Set to false to force session cookies over plain HTTP. Not recommended for production." Type="Variable" Display="advanced" Required="false" Mask="false">true</Config>
|
<Config Name="COOKIE_SECURE" Target="COOKIE_SECURE" Default="true" Mode="" Description="Auto-derived (true in production or when FORCE_HTTPS=true). Set to false to force session cookies over plain HTTP. Not recommended for production." Type="Variable" Display="advanced" Required="false" Mask="false">true</Config>
|
||||||
<Config Name="TRUST_PROXY" Target="TRUST_PROXY" Default="1" Mode="" Description="Trusted proxy hops for X-Forwarded-For/X-Forwarded-Proto. Defaults to 1 in production; off in development unless set. Required for FORCE_HTTPS." Type="Variable" Display="advanced" Required="false" Mask="false">1</Config>
|
<Config Name="TRUST_PROXY" Target="TRUST_PROXY" Default="1" Mode="" Description="Trusted proxy hops for X-Forwarded-For/X-Forwarded-Proto. Defaults to 1 in production; off in development unless set. Required for FORCE_HTTPS." Type="Variable" Display="advanced" Required="false" Mask="false">1</Config>
|
||||||
<Config Name="ALLOW_INTERNAL_NETWORK" Target="ALLOW_INTERNAL_NETWORK" Default="false" Mode="" Description="Allow outbound requests to private/RFC-1918 IP addresses. Set to true if Immich or other integrated services are hosted on your local network." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config>
|
<Config Name="ALLOW_INTERNAL_NETWORK" Target="ALLOW_INTERNAL_NETWORK" Default="false" Mode="" Description="Allow outbound requests to private/RFC-1918 IP addresses. Set to true if Immich or other integrated services are hosted on your local network." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config>
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ These three variables work together behind a TLS-terminating reverse proxy. See
|
|||||||
| Variable | Description | Default |
|
| 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` |
|
| `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) |
|
| `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 |
|
| `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 |
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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
|
## 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
|
- **Flights and cruises** — geodesic great-circle arcs
|
||||||
- **Trains and cars** — straight lines
|
- **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
|
- **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
|
- **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
|
- **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
|
## Location button
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user