Compare commits

..

1 Commits

180 changed files with 247 additions and 7173 deletions
-2
View File
@@ -6,8 +6,6 @@ on:
paths-ignore:
- 'docs/**'
- '**/*.md'
- 'wiki/**'
- '.github/workflows/wiki.yml'
workflow_dispatch:
inputs:
bump:
-26
View File
@@ -1,26 +0,0 @@
name: Deploy Wiki
on:
push:
branches: [main]
paths:
- 'wiki/**'
- '.github/workflows/wiki.yml'
workflow_dispatch:
permissions:
contents: write
concurrency:
group: wiki-deploy
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Publish to GitHub wiki
uses: Andrew-Chen-Wang/github-wiki-action@v5
with:
strategy: init
+1 -3
View File
@@ -58,6 +58,4 @@ coverage
*.tgz
.scannerwork
test-data
.run
test-data
+1 -1
View File
@@ -31,7 +31,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
<div align="center">
<img src="https://github.com/mauriceboe/trek-media/releases/download/readme-assets/TREK1.gif" alt="TREK — 60-second tour" width="100%" />
<img src="https://github.com/mauriceboe/test/releases/download/readme-assets/TREK1.gif" alt="TREK — 60-second tour" width="100%" />
</div>
+2 -9
View File
@@ -62,20 +62,13 @@ apiClient.interceptors.request.use(
(error) => Promise.reject(error)
)
export function isAuthPublicPath(pathname: string): boolean {
const publicPaths = ['/login', '/register', '/forgot-password', '/reset-password']
const publicPrefixes = ['/shared/', '/public/']
return publicPaths.includes(pathname) || publicPrefixes.some((p) => pathname.startsWith(p))
}
// Response interceptor - handle 401, 403 MFA, 429 rate limit
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
const { pathname } = window.location
if (!isAuthPublicPath(pathname)) {
const currentPath = pathname + window.location.search
if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register') && !window.location.pathname.startsWith('/shared/') && !window.location.pathname.startsWith('/public/')) {
const currentPath = window.location.pathname + window.location.search
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
}
}
+22 -23
View File
@@ -900,30 +900,29 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<td style={td}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
{canEdit && (
<div draggable onDragStart={e => { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; setDragItem(item.id); setDragItemCat(cat) }}
onDragEnd={() => { setDragItem(null); setDragOverItem(null); setDragItemCat(null) }}
style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0 }}>
<GripVertical size={12} />
</div>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<InlineEditCell value={item.name} onSave={v => handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={item.reservation_id ? t('budget.linkedToReservation') : t('budget.editTooltip')} readOnly={!canEdit || !!item.reservation_id} />
{hasMultipleMembers && (
<div className="sm:hidden" style={{ marginTop: 4 }}>
<BudgetMemberChips
members={item.members || []}
tripMembers={tripMembers}
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
compact={false}
readOnly={!canEdit}
/>
</div>
)}
<td style={{ ...td, display: 'flex', alignItems: 'center', gap: 4 }}>
{canEdit && (
<div draggable onDragStart={e => { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; setDragItem(item.id); setDragItemCat(cat) }}
onDragEnd={() => { setDragItem(null); setDragOverItem(null); setDragItemCat(null) }}
style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0 }}>
<GripVertical size={12} />
</div>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<InlineEditCell value={item.name} onSave={v => handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={item.reservation_id ? t('budget.linkedToReservation') : t('budget.editTooltip')} readOnly={!canEdit || !!item.reservation_id} />
{/* Mobile: larger chips under name since Persons column is hidden */}
{hasMultipleMembers && (
<div className="sm:hidden" style={{ marginTop: 4 }}>
<BudgetMemberChips
members={item.members || []}
tripMembers={tripMembers}
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
compact={false}
readOnly={!canEdit}
/>
</div>
)}
</div>
</td>
<td style={{ ...td, textAlign: 'center' }}>
+1 -61
View File
@@ -8,10 +8,9 @@ import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '..
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup'
import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox'
import { ReservationMapboxOverlay } from './reservationsMapbox'
import LocationButton from './LocationButton'
import { useGeolocation } from '../../hooks/useGeolocation'
import type { Place, Reservation } from '../../types'
import type { Place } from '../../types'
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
@@ -45,10 +44,6 @@ interface Props {
rightWidth?: number
hasInspector?: boolean
hasDayDetail?: boolean
reservations?: Reservation[]
visibleConnectionIds?: number[]
showReservationStats?: boolean
onReservationClick?: (reservationId: number) => void
}
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
@@ -144,28 +139,17 @@ export function MapViewGL({
rightWidth = 0,
hasInspector = false,
hasDayDetail = false,
reservations = [],
visibleConnectionIds = [],
showReservationStats = false,
onReservationClick,
}: Props) {
const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false)
const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true)
const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) !== false
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
const [mapReady, setMapReady] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<mapboxgl.Map | null>(null)
const markersRef = useRef<Map<number, mapboxgl.Marker>>(new Map())
const locationMarkerRef = useRef<LocationMarkerHandle | null>(null)
const reservationOverlayRef = useRef<ReservationMapboxOverlay | null>(null)
// Refs so the reservation overlay always sees the latest callback /
// options without forcing a full overlay rebuild on every prop change.
const onReservationClickRef = useRef(onReservationClick)
onReservationClickRef.current = onReservationClick
const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation()
const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu })
onClickRefs.current.marker = onMarkerClick
@@ -244,10 +228,6 @@ export function MapViewGL({
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
}
// Signal that sources/layers are attached so overlay effects can
// safely add their own sources. Style rebuilds reset this via the
// cleanup below.
setMapReady(true)
})
map.on('click', (e) => {
@@ -319,17 +299,12 @@ export function MapViewGL({
canvas.removeEventListener('auxclick', onAuxClick)
markersRef.current.forEach(m => m.remove())
markersRef.current.clear()
if (reservationOverlayRef.current) {
reservationOverlayRef.current.destroy()
reservationOverlayRef.current = null
}
if (locationMarkerRef.current) {
locationMarkerRef.current.destroy()
locationMarkerRef.current = null
}
try { map.remove() } catch { /* noop */ }
mapRef.current = null
setMapReady(false)
}
}, [mapboxStyle, mapboxToken, mapbox3d]) // rebuild on style changes only
@@ -459,41 +434,6 @@ export function MapViewGL({
src.setData({ type: 'FeatureCollection', features })
}, [places])
// Reservation overlay — mirrors the Leaflet ReservationOverlay: great-
// circle arcs for flights/cruises, straight lines for trains/cars,
// clickable endpoint badges, rotating mid-arc stats label for flights.
// The overlay is a small imperative manager that owns its own source,
// layer, and HTML markers; it lives next to the map for the map's
// lifetime and is rebuilt when the style/token/3d effect rebuilds.
//
// `visibleConnectionIds` is driven by the per-reservation toggle in
// DayPlanSidebar — nothing is rendered until the user enables a
// booking's route, matching the Leaflet MapView's behaviour.
const visibleReservations = useMemo(() => {
if (!visibleConnectionIds || visibleConnectionIds.length === 0) return []
const set = new Set(visibleConnectionIds)
return reservations.filter(r => set.has(r.id))
}, [reservations, visibleConnectionIds])
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady) return
if (!reservationOverlayRef.current) {
reservationOverlayRef.current = new ReservationMapboxOverlay(map, {
showConnections: true,
showStats: showReservationStats,
showEndpointLabels,
onEndpointClick: (id) => onReservationClickRef.current?.(id),
})
}
reservationOverlayRef.current.update(visibleReservations, {
showConnections: true,
showStats: showReservationStats,
showEndpointLabels,
onEndpointClick: (id) => onReservationClickRef.current?.(id),
})
}, [visibleReservations, showReservationStats, showEndpointLabels, mapReady])
// Fit bounds on fitKey change — matches the Leaflet BoundsController
const paddingOpts = useMemo(() => {
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
@@ -1,388 +0,0 @@
// Mapbox GL counterpart to ReservationOverlay.tsx.
//
// react-leaflet is component-driven, mapbox-gl is imperative — so instead of
// a React component, this exports a small manager class the MapViewGL wires
// up next to its other sources/layers. The geometry logic (great-circle arcs,
// antimeridian split, duration math) mirrors the Leaflet overlay so both
// renderers produce the same visual result on the globe or a flat projection.
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import mapboxgl from 'mapbox-gl'
import { Plane, Train, Ship, Car } from 'lucide-react'
import type { Reservation, ReservationEndpoint } from '../../types'
export const RESERVATION_SOURCE_ID = 'trek-reservations'
export const RESERVATION_LINE_LAYER_ID = 'trek-reservations-lines'
type TransportType = 'flight' | 'train' | 'cruise' | 'car'
const TRANSPORT_TYPES: TransportType[] = ['flight', 'train', 'cruise', 'car']
const TRANSPORT_COLOR = '#3b82f6'
const TYPE_META: Record<TransportType, { icon: typeof Plane; geodesic: boolean }> = {
flight: { icon: Plane, geodesic: true },
train: { icon: Train, geodesic: false },
cruise: { icon: Ship, geodesic: true },
car: { icon: Car, geodesic: false },
}
// ── geometry helpers (ported from ReservationOverlay.tsx) ────────────────
const toRad = (d: number) => d * Math.PI / 180
const toDeg = (r: number) => r * 180 / Math.PI
function greatCircle(a: [number, number], b: [number, number], steps = 256): [number, number][] {
const [lat1, lng1] = [toRad(a[0]), toRad(a[1])]
const [lat2, lng2] = [toRad(b[0]), toRad(b[1])]
const d = 2 * Math.asin(Math.sqrt(Math.sin((lat2 - lat1) / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin((lng2 - lng1) / 2) ** 2))
if (d === 0) return [a, b]
const pts: [number, number][] = []
for (let i = 0; i <= steps; i++) {
const f = i / steps
const A = Math.sin((1 - f) * d) / Math.sin(d)
const B = Math.sin(f * d) / Math.sin(d)
const x = A * Math.cos(lat1) * Math.cos(lng1) + B * Math.cos(lat2) * Math.cos(lng2)
const y = A * Math.cos(lat1) * Math.sin(lng1) + B * Math.cos(lat2) * Math.sin(lng2)
const z = A * Math.sin(lat1) + B * Math.sin(lat2)
const lat = Math.atan2(z, Math.sqrt(x * x + y * y))
const lng = Math.atan2(y, x)
pts.push([toDeg(lat), toDeg(lng)])
}
return pts
}
function splitAntimeridian(points: [number, number][]): [number, number][][] {
const segments: [number, number][][] = []
let cur: [number, number][] = []
for (let i = 0; i < points.length; i++) {
if (i > 0 && Math.abs(points[i][1] - points[i - 1][1]) > 180) {
if (cur.length > 1) segments.push(cur)
cur = []
}
cur.push(points[i])
}
if (cur.length > 1) segments.push(cur)
return segments
}
function haversineKm(a: [number, number], b: [number, number]): number {
const R = 6371
const dLat = toRad(b[0] - a[0])
const dLng = toRad(b[1] - a[1])
const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a[0])) * Math.cos(toRad(b[0])) * Math.sin(dLng / 2) ** 2
return 2 * R * Math.asin(Math.sqrt(h))
}
function parseInTz(isoLocal: string, tz: string): number {
const [datePart, timePart] = isoLocal.split('T')
const [y, mo, d] = datePart.split('-').map(Number)
const [h, mi] = (timePart || '00:00').split(':').map(Number)
const guess = Date.UTC(y, mo - 1, d, h, mi)
const fmt = new Intl.DateTimeFormat('en-US', {
timeZone: tz, hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
})
const parts = Object.fromEntries(fmt.formatToParts(new Date(guess)).filter(p => p.type !== 'literal').map(p => [p.type, p.value]))
const asUtc = Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour) % 24, Number(parts.minute), Number(parts.second))
return guess - (asUtc - guess)
}
function computeDuration(from: ReservationEndpoint, to: ReservationEndpoint, fallbackStart: string | null, fallbackEnd: string | null): string | null {
let start = from.local_date && from.local_time ? `${from.local_date}T${from.local_time}` : fallbackStart
let end = to.local_date && to.local_time ? `${to.local_date}T${to.local_time}` : fallbackEnd
if (!start || !end) return null
if (!start.includes('T') && end.includes('T')) start = `${end.split('T')[0]}T${start}`
if (!end.includes('T') && start.includes('T')) end = `${start.split('T')[0]}T${end}`
if (!start.includes('T') || !end.includes('T')) return null
const fromTz = from.timezone || to.timezone
const toTz = to.timezone || fromTz
let startMs: number, endMs: number
if (fromTz && toTz) {
startMs = parseInTz(start, fromTz)
endMs = parseInTz(end, toTz)
} else {
startMs = new Date(start).getTime()
endMs = new Date(end).getTime()
}
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return null
if (endMs <= startMs) endMs += 24 * 60 * 60000
const minutes = Math.round((endMs - startMs) / 60000)
if (minutes <= 0 || minutes > 48 * 60) return null
const h = Math.floor(minutes / 60)
const m = minutes % 60
return h > 0 ? `${h}h ${m}m` : `${m}m`
}
const cleanName = (name: string) => name.replace(/\s*\([^)]*\)/g, '').trim()
// ── item building ─────────────────────────────────────────────────────────
interface TransportItem {
res: Reservation
from: ReservationEndpoint
to: ReservationEndpoint
type: TransportType
arcs: [number, number][][]
primaryArc: [number, number][]
mainLabel: string | null
subLabel: string | null
}
function buildItems(reservations: Reservation[]): TransportItem[] {
const out: TransportItem[] = []
for (const r of reservations) {
if (!TRANSPORT_TYPES.includes(r.type as TransportType)) continue
const eps = r.endpoints || []
const from = eps.find(e => e.role === 'from')
const to = eps.find(e => e.role === 'to')
if (!from || !to) continue
const type = r.type as TransportType
const isGeo = TYPE_META[type].geodesic
const arcs = isGeo
? splitAntimeridian(greatCircle([from.lat, from.lng], [to.lat, to.lng]))
: [[[from.lat, from.lng], [to.lat, to.lng]] as [number, number][]]
const primaryIdx = arcs.reduce((best, seg, idx, all) => seg.length > all[best].length ? idx : best, 0)
const primaryArc = arcs[primaryIdx] ?? []
const duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null)
const distance = `${Math.round(haversineKm([from.lat, from.lng], [to.lat, to.lng]))} km`
const mainLabel = from.code && to.code ? `${from.code}${to.code}` : null
const subParts = [duration, distance].filter(Boolean) as string[]
const subLabel = subParts.length > 0 ? subParts.join(' · ') : null
out.push({ res: r, from, to, type, arcs, primaryArc, mainLabel, subLabel })
}
return out
}
// ── DOM helpers for HTML markers ──────────────────────────────────────────
function endpointMarkerHtml(type: TransportType, label: string | null): string {
const { icon: IconCmp } = TYPE_META[type]
const svg = renderToStaticMarkup(createElement(IconCmp, { size: 13, color: 'white', strokeWidth: 2.5 }))
const labelHtml = label ? `<span style="display:inline-flex;align-items:center;line-height:1">${label}</span>` : ''
return `<div style="
display:inline-flex;align-items:center;justify-content:center;gap:4px;
padding:0 8px;border-radius:999px;
background:${TRANSPORT_COLOR};box-shadow:0 2px 6px rgba(0,0,0,0.25);
border:1.5px solid #fff;color:#fff;
font-family:-apple-system,system-ui,sans-serif;font-size:11px;font-weight:600;letter-spacing:0.3px;line-height:1;
box-sizing:border-box;height:22px;white-space:nowrap;cursor:pointer;
"><span style="display:inline-flex;align-items:center;">${svg}</span>${labelHtml}</div>`
}
function buildStatsHtml(mainLabel: string | null, subLabel: string | null): { html: string; width: number; height: number } {
const estWidth = Math.max(
mainLabel ? mainLabel.length * 6.5 : 0,
subLabel ? subLabel.length * 5.5 : 0,
) + 22
const hasBoth = !!mainLabel && !!subLabel
const height = hasBoth ? 36 : 22
const main = mainLabel ? `<span style="font-size:12px;font-weight:700;line-height:1;display:block">${mainLabel}</span>` : ''
const sub = subLabel ? `<span style="font-size:10px;font-weight:500;line-height:1;opacity:0.85;display:block${hasBoth ? ';margin-top:4px' : ''}">${subLabel}</span>` : ''
const html = `<div class="trek-stats-inner" style="
display:flex;flex-direction:column;align-items:center;justify-content:center;
width:100%;height:100%;
padding:0 11px;border-radius:999px;
background:rgba(17,24,39,0.92);color:#fff;
box-shadow:0 2px 6px rgba(0,0,0,0.25);
border:1px solid ${TRANSPORT_COLOR}aa;
font-family:-apple-system,system-ui,'SF Pro Text',sans-serif;
white-space:nowrap;box-sizing:border-box;pointer-events:none;
transform-origin:center;will-change:transform;
">${main}${sub}</div>`
return { html, width: estWidth, height }
}
// ── overlay manager ──────────────────────────────────────────────────────
export interface ReservationOverlayOptions {
showConnections: boolean
showStats: boolean
showEndpointLabels: boolean
onEndpointClick?: (reservationId: number) => void
}
export class ReservationMapboxOverlay {
private map: mapboxgl.Map
private items: TransportItem[] = []
private opts: ReservationOverlayOptions
private endpointMarkers: mapboxgl.Marker[] = []
private statsMarkers: { marker: mapboxgl.Marker; arc: [number, number][] }[] = []
private rerender: () => void
private destroyed = false
constructor(map: mapboxgl.Map, opts: ReservationOverlayOptions) {
this.map = map
this.opts = opts
this.rerender = () => { if (!this.destroyed) this.render() }
this.setupLayer()
map.on('zoomend', this.rerender)
map.on('moveend', this.rerender)
map.on('render', this.updateStatsRotation)
}
update(reservations: Reservation[], opts: ReservationOverlayOptions) {
this.opts = opts
this.items = buildItems(reservations)
this.render()
}
destroy() {
this.destroyed = true
this.map.off('zoomend', this.rerender)
this.map.off('moveend', this.rerender)
this.map.off('render', this.updateStatsRotation)
this.endpointMarkers.forEach(m => m.remove())
this.endpointMarkers = []
this.statsMarkers.forEach(s => s.marker.remove())
this.statsMarkers = []
try {
if (this.map.getLayer(RESERVATION_LINE_LAYER_ID)) this.map.removeLayer(RESERVATION_LINE_LAYER_ID)
if (this.map.getSource(RESERVATION_SOURCE_ID)) this.map.removeSource(RESERVATION_SOURCE_ID)
} catch { /* map already gone */ }
}
private setupLayer() {
const map = this.map
if (map.getSource(RESERVATION_SOURCE_ID)) return
map.addSource(RESERVATION_SOURCE_ID, { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
map.addLayer({
id: RESERVATION_LINE_LAYER_ID,
type: 'line',
source: RESERVATION_SOURCE_ID,
paint: {
'line-color': TRANSPORT_COLOR,
'line-width': 2.5,
// Confirmed = solid + 0.75; pending = dashed + 0.55.
'line-opacity': ['case', ['==', ['get', 'status'], 'confirmed'], 0.75, 0.55] as any,
'line-dasharray': ['case', ['==', ['get', 'status'], 'confirmed'], ['literal', [1, 0]], ['literal', [3, 3]]] as any,
},
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
}
private render() {
const map = this.map
if (!this.map.getSource(RESERVATION_SOURCE_ID)) return
const show = this.opts.showConnections
// Visible filter: require the on-screen pixel distance between
// endpoints to exceed a type-specific minimum, same as the Leaflet
// overlay, so tiny no-op transport lines don't clutter the map.
const visibleItems = show ? this.items.filter(item => {
try {
const fromPx = map.project([item.from.lng, item.from.lat])
const toPx = map.project([item.to.lng, item.to.lat])
const dx = fromPx.x - toPx.x, dy = fromPx.y - toPx.y
const dist = Math.sqrt(dx * dx + dy * dy)
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 150 : item.type === 'car' ? 80 : 200
return dist >= minPx
} catch { return true }
}) : []
// Label visibility threshold is higher than line visibility, to keep
// endpoint text from overlapping on very short lines.
const labelVisibleIds = new Set<number>()
if (show) {
for (const item of visibleItems) {
try {
const fromPx = map.project([item.from.lng, item.from.lat])
const toPx = map.project([item.to.lng, item.to.lat])
const dx = fromPx.x - toPx.x, dy = fromPx.y - toPx.y
const dist = Math.sqrt(dx * dx + dy * dy)
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 300 : item.type === 'car' ? 150 : 400
if (dist >= minPx) labelVisibleIds.add(item.res.id)
} catch { /* ignore */ }
}
}
// ── line features ───────────────────────────────────────────────
const features = visibleItems.flatMap(item => item.arcs.map(seg => ({
type: 'Feature' as const,
properties: {
resId: item.res.id,
type: item.type,
status: item.res.status ?? 'pending',
},
geometry: {
type: 'LineString' as const,
coordinates: seg.map(([lat, lng]) => [lng, lat]),
},
})))
const src = map.getSource(RESERVATION_SOURCE_ID) as mapboxgl.GeoJSONSource | undefined
src?.setData({ type: 'FeatureCollection', features })
// ── endpoint markers ────────────────────────────────────────────
this.endpointMarkers.forEach(m => m.remove())
this.endpointMarkers = []
if (show) {
for (const item of visibleItems) {
const showLabel = this.opts.showEndpointLabels && labelVisibleIds.has(item.res.id)
for (const ep of [item.from, item.to]) {
const label = showLabel ? (ep.code || cleanName(ep.name)) : null
const el = document.createElement('div')
el.innerHTML = endpointMarkerHtml(item.type, label)
const inner = el.firstElementChild as HTMLElement | null
const node = inner ?? el
node.title = ep.name || ''
if (this.opts.onEndpointClick) {
node.addEventListener('click', (ev) => {
ev.stopPropagation()
this.opts.onEndpointClick?.(item.res.id)
})
}
const marker = new mapboxgl.Marker({ element: node, anchor: 'center' })
.setLngLat([ep.lng, ep.lat])
.addTo(map)
this.endpointMarkers.push(marker)
}
}
}
// ── stats label (flights only) ──────────────────────────────────
this.statsMarkers.forEach(s => s.marker.remove())
this.statsMarkers = []
if (show && this.opts.showStats) {
for (const item of visibleItems) {
if (item.type !== 'flight') continue
if (!labelVisibleIds.has(item.res.id)) continue
if (!item.mainLabel && !item.subLabel) continue
const arc = item.primaryArc
if (arc.length < 2) continue
const mid = arc[Math.floor(arc.length / 2)]!
const { html, width, height } = buildStatsHtml(item.mainLabel, item.subLabel)
const el = document.createElement('div')
el.style.cssText = `width:${width}px;height:${height}px;pointer-events:none;`
el.innerHTML = html
const marker = new mapboxgl.Marker({ element: el, anchor: 'center' })
.setLngLat([mid[1], mid[0]])
.addTo(map)
this.statsMarkers.push({ marker, arc })
}
}
// Prime rotation once so labels don't flash horizontal on first paint.
this.updateStatsRotation()
}
// Match the Leaflet overlay's "rotate the label along the arc" look.
// We pick a short segment straddling the arc midpoint, measure the
// screen angle between those two projected points, and clamp it to
// [-90°, 90°] so text never renders upside-down.
private updateStatsRotation = () => {
if (this.destroyed) return
for (const entry of this.statsMarkers) {
const { marker, arc } = entry
if (arc.length < 2) continue
const midIdx = Math.floor(arc.length / 2)
const a = arc[Math.max(0, midIdx - 2)]!
const b = arc[Math.min(arc.length - 1, midIdx + 2)]!
try {
const pa = this.map.project([a[1], a[0]])
const pb = this.map.project([b[1], b[0]])
let angle = Math.atan2(pb.y - pa.y, pb.x - pa.x) * 180 / Math.PI
if (angle > 90) angle -= 180
if (angle < -90) angle += 180
const el = marker.getElement()
const inner = el.querySelector('.trek-stats-inner') as HTMLElement | null
if (inner) inner.style.transform = `rotate(${angle}deg)`
} catch { /* map not ready / projection failure */ }
}
}
}
@@ -1,7 +1,6 @@
import React, { useEffect, useRef, useState } from 'react'
import { Package } from 'lucide-react'
import { adminApi, packingApi } from '../../api/client'
import { useTripStore } from '../../store/tripStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
@@ -44,9 +43,9 @@ export default function ApplyTemplateButton({ tripId, style, className }: ApplyT
setApplying(true)
try {
const data = await packingApi.applyTemplate(tripId, templateId)
useTripStore.setState(s => ({ packingItems: [...s.packingItems, ...(data.items || [])] }))
toast.success(t('packing.templateApplied', { count: data.count }))
setOpen(false)
window.location.reload()
} catch {
toast.error(t('packing.templateError'))
} finally {
@@ -959,9 +959,10 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
setApplyingTemplate(true)
try {
const data = await packingApi.applyTemplate(tripId, templateId)
useTripStore.setState(s => ({ packingItems: [...s.packingItems, ...(data.items || [])] }))
toast.success(t('packing.templateApplied', { count: data.count }))
setShowTemplateDropdown(false)
// Reload packing items
window.location.reload()
} catch {
toast.error(t('packing.templateError'))
} finally {
@@ -1019,10 +1020,10 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
if (parsed.length === 0) { toast.error(t('packing.importEmpty')); return }
try {
const result = await packingApi.bulkImport(tripId, parsed)
useTripStore.setState(s => ({ packingItems: [...s.packingItems, ...(result.items || [])] }))
toast.success(t('packing.importSuccess', { count: result.count }))
setImportText('')
setShowImportModal(false)
window.location.reload()
} catch { toast.error(t('packing.importError')) }
}
@@ -336,10 +336,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
return () => document.removeEventListener('dragend', cleanup)
}, [])
// Initialize missing transport positions outside of render to avoid setState-during-render
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { days.forEach(day => initTransportPositions(day.id)) }, [days, reservations])
const toggleDay = (dayId, e) => {
e.stopPropagation()
setExpandedDays(prev => {
@@ -494,6 +490,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const dn = (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order)
const transport = getTransportForDay(dayId)
// Initialize positions for transports that don't have one yet
if (transport.some(r => r.day_plan_position == null)) {
initTransportPositions(dayId)
}
// All places keep their order_index — untimed can be freely moved, timed auto-sort when time is set
const baseItems = [
...da.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })),
@@ -1116,7 +1117,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
</div>
{/* Tagesliste */}
<div className={`scroll-container${draggingId ? '' : ' trek-stagger'}`} style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
<div className="scroll-container trek-stagger" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
{days.map((day, index) => {
const isSelected = selectedDayId === day.id
const isExpanded = expandedDays.has(day.id)
@@ -1134,7 +1135,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
{/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */}
<div
onClick={() => { onSelectDay(day.id); if (onDayDetail) onDayDetail(day) }}
onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }}
onDragOver={e => { e.preventDefault(); setDragOverDayId(day.id) }}
onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOverDayId(null) }}
onDrop={e => handleDropOnDay(e, day.id)}
style={{
@@ -1235,9 +1236,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const border = isCheckOut && !isCheckIn ? 'rgba(239,68,68,0.2)' : isCheckIn ? 'rgba(34,197,94,0.2)' : 'var(--border-primary)'
const iconColor = isCheckOut && !isCheckIn ? '#ef4444' : isCheckIn ? '#22c55e' : 'var(--text-muted)'
return (
<span key={acc.id} onClick={e => { e.stopPropagation(); if ((acc as any).place_id) onPlaceClick((acc as any).place_id) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '2px 7px', borderRadius: 5, background: bg, border: `1px solid ${border}`, flexShrink: 1, minWidth: 0, maxWidth: '40%', cursor: (acc as any).place_id ? 'pointer' : 'default' }}>
<span key={acc.id} onClick={e => { e.stopPropagation(); onPlaceClick(acc.place_id) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '2px 7px', borderRadius: 5, background: bg, border: `1px solid ${border}`, flexShrink: 1, minWidth: 0, maxWidth: '40%', cursor: 'pointer' }}>
<Hotel size={8} style={{ color: iconColor, flexShrink: 0 }} />
<span style={{ fontSize: 9, color: 'var(--text-muted)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{(acc as any).place_name || (acc as any).reservation_title}</span>
<span style={{ fontSize: 9, color: 'var(--text-muted)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{acc.place_name}</span>
</span>
)
})
@@ -1348,7 +1349,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
>
{merged.length === 0 && !dayNoteUi ? (
<div
onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }}
onDragOver={e => { e.preventDefault(); setDragOverDayId(day.id) }}
onDrop={e => handleDropOnDay(e, day.id)}
style={{ padding: '16px', textAlign: 'center', borderRadius: 8,
background: dragOverDayId === day.id ? 'rgba(17,24,39,0.05)' : 'transparent',
@@ -1408,6 +1409,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
return (
<React.Fragment key={`place-${assignment.id}`}>
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
<div
draggable={canEditDays}
onDragStart={e => {
@@ -1497,7 +1499,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
borderLeft: lockedIds.has(assignment.id)
? '3px solid #dc2626'
: '3px solid transparent',
borderTop: showDropLine ? '2px solid var(--text-primary)' : undefined,
transition: 'background 0.15s, border-color 0.15s',
opacity: isDraggingThis ? 0.4 : 1,
}}
@@ -1721,6 +1722,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
return (
<React.Fragment key={`transport-${res.id}-${day.id}`}>
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
<div
onClick={() => canEditDays && onEditTransport?.(res)}
onDragOver={e => {
@@ -1769,8 +1771,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
margin: '1px 8px',
borderRadius: 6,
border: `1px solid ${color}33`,
borderTop: showDropLine ? '2px solid var(--text-primary)' : undefined,
borderBottom: showDropLineAfter ? '2px solid var(--text-primary)' : undefined,
background: `${color}08`,
cursor: canEditDays && onEditTransport ? 'pointer' : 'default', userSelect: 'none',
transition: 'background 0.1s',
@@ -1844,6 +1844,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
)
})()}
</div>
{showDropLineAfter && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
</React.Fragment>
)
}
@@ -1854,6 +1855,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const noteIdx = idx
return (
<React.Fragment key={`note-${note.id}`}>
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
<div
draggable={canEditDays}
onDragStart={e => { if (!canEditDays) { e.preventDefault(); return } e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }}
@@ -1909,7 +1911,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
margin: '1px 8px',
borderRadius: 6,
border: '1px solid var(--border-faint)',
borderTop: showDropLine ? '2px solid var(--text-primary)' : undefined,
background: 'var(--bg-hover)',
opacity: draggingId === `note-${note.id}` ? 0.4 : 1,
transition: 'background 0.1s', cursor: 'grab', userSelect: 'none',
@@ -143,18 +143,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
}
}, [reservation, isOpen, selectedDayId, defaultAssignmentId])
// Re-hydrate hotel day range when the accommodations prop arrives after the modal opens
// (race: tripAccommodations fetch may complete after isOpen fires, leaving hotel fields empty)
useEffect(() => {
if (!isOpen || !reservation || reservation.type !== 'hotel' || !reservation.accommodation_id) return
const acc = accommodations.find(a => a.id == reservation.accommodation_id)
if (!acc) return
setForm(prev => {
if (prev.hotel_place_id !== '' || prev.hotel_start_day !== '' || prev.hotel_end_day !== '') return prev
return { ...prev, hotel_place_id: acc.place_id, hotel_start_day: acc.start_day_id, hotel_end_day: acc.end_day_id }
})
}, [accommodations, isOpen, reservation])
const set = (field, value) => setForm(prev => ({ ...prev, [field]: value }))
const isEndBeforeStart = (() => {
@@ -205,9 +193,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
: { total_price: 0 }
}
if (form.type === 'hotel' && form.hotel_start_day && form.hotel_end_day) {
if (form.type === 'hotel' && form.hotel_place_id && form.hotel_start_day && form.hotel_end_day) {
saveData.create_accommodation = {
place_id: form.hotel_place_id || null,
place_id: form.hotel_place_id,
start_day_id: form.hotel_start_day,
end_day_id: form.hotel_end_day,
check_in: form.meta_check_in_time || null,
@@ -25,7 +25,6 @@ const EVENT_LABEL_KEYS: Record<string, string> = {
trip_invite: 'settings.notifyTripInvite',
booking_change: 'settings.notifyBookingChange',
trip_reminder: 'settings.notifyTripReminder',
todo_due: 'settings.notifyTodoDue',
vacay_invite: 'settings.notifyVacayInvite',
photos_shared: 'settings.notifyPhotosShared',
collab_message: 'settings.notifyCollabMessage',
-3
View File
@@ -204,7 +204,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyTripInvite': 'دعوات الرحلات',
'settings.notifyBookingChange': 'تغييرات الحجز',
'settings.notifyTripReminder': 'تذكيرات الرحلات',
'settings.notifyTodoDue': 'مهمة مستحقة',
'settings.notifyVacayInvite': 'دعوات دمج الإجازات',
'settings.notifyPhotosShared': 'صور مشتركة (Immich)',
'settings.notifyCollabMessage': 'رسائل الدردشة (Collab)',
@@ -1996,8 +1995,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'notif.booking_change.text': '{actor} حدّث حجزاً في {trip}',
'notif.trip_reminder.title': 'تذكير بالرحلة',
'notif.trip_reminder.text': 'رحلتك {trip} تقترب!',
'notif.todo_due.title': 'مهمة مستحقة',
'notif.todo_due.text': '{todo} في {trip} مستحقة في {due}',
'notif.vacay_invite.title': 'دعوة دمج الإجازة',
'notif.vacay_invite.text': '{actor} يدعوك لدمج خطط الإجازة',
'notif.photos_shared.title': 'تمت مشاركة الصور',
-3
View File
@@ -199,7 +199,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyTripInvite': 'Convites de viagem',
'settings.notifyBookingChange': 'Alterações de reserva',
'settings.notifyTripReminder': 'Lembretes de viagem',
'settings.notifyTodoDue': 'Tarefa com vencimento',
'settings.notifyVacayInvite': 'Convites de fusão Vacay',
'settings.notifyPhotosShared': 'Fotos compartilhadas (Immich)',
'settings.notifyCollabMessage': 'Mensagens de chat (Colab)',
@@ -1936,8 +1935,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'notif.booking_change.text': '{actor} atualizou uma reserva em {trip}',
'notif.trip_reminder.title': 'Lembrete de viagem',
'notif.trip_reminder.text': 'Sua viagem {trip} está chegando!',
'notif.todo_due.title': 'Tarefa com vencimento',
'notif.todo_due.text': '{todo} em {trip} vence em {due}',
'notif.vacay_invite.title': 'Convite Vacay Fusion',
'notif.vacay_invite.text': '{actor} convidou você para fundir planos de férias',
'notif.photos_shared.title': 'Fotos compartilhadas',
-3
View File
@@ -200,7 +200,6 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyTripInvite': 'Pozvánky na cesty',
'settings.notifyBookingChange': 'Změny rezervací',
'settings.notifyTripReminder': 'Připomínky cest',
'settings.notifyTodoDue': 'Úkol se blíží',
'settings.notifyVacayInvite': 'Pozvánky k propojení Vacay',
'settings.notifyPhotosShared': 'Sdílené fotky (Immich)',
'settings.notifyCollabMessage': 'Zprávy v chatu (Collab)',
@@ -1941,8 +1940,6 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'notif.booking_change.text': '{actor} aktualizoval rezervaci v {trip}',
'notif.trip_reminder.title': 'Připomínka výletu',
'notif.trip_reminder.text': 'Váš výlet {trip} se blíží!',
'notif.todo_due.title': 'Úkol se blíží',
'notif.todo_due.text': '{todo} ve výletě {trip} má termín {due}',
'notif.vacay_invite.title': 'Pozvánka Vacay Fusion',
'notif.vacay_invite.text': '{actor} vás pozval ke spojení dovolenkových plánů',
'notif.photos_shared.title': 'Fotky sdíleny',
-3
View File
@@ -204,7 +204,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyTripInvite': 'Trip-Einladungen',
'settings.notifyBookingChange': 'Buchungsänderungen',
'settings.notifyTripReminder': 'Trip-Erinnerungen',
'settings.notifyTodoDue': 'Aufgabe bald fällig',
'settings.notifyVacayInvite': 'Vacay Fusion-Einladungen',
'settings.notifyPhotosShared': 'Geteilte Fotos (Immich)',
'settings.notifyCollabMessage': 'Chat-Nachrichten (Collab)',
@@ -1946,8 +1945,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'notif.booking_change.text': '{actor} hat eine Buchung in {trip} aktualisiert',
'notif.trip_reminder.title': 'Reiseerinnerung',
'notif.trip_reminder.text': 'Deine Reise {trip} steht bald an!',
'notif.todo_due.title': 'Aufgabe fällig',
'notif.todo_due.text': '{todo} in {trip} ist am {due} fällig',
'notif.vacay_invite.title': 'Vacay Fusion-Einladung',
'notif.vacay_invite.text': '{actor} hat dich zum Fusionieren von Urlaubsplänen eingeladen',
'notif.photos_shared.title': 'Fotos geteilt',
-3
View File
@@ -204,7 +204,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyTripInvite': 'Trip invitations',
'settings.notifyBookingChange': 'Booking changes',
'settings.notifyTripReminder': 'Trip reminders',
'settings.notifyTodoDue': 'Todo due soon',
'settings.notifyVacayInvite': 'Vacay fusion invitations',
'settings.notifyPhotosShared': 'Shared photos (Immich)',
'settings.notifyCollabMessage': 'Chat messages (Collab)',
@@ -1949,8 +1948,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'notif.booking_change.text': '{actor} updated a booking in {trip}',
'notif.trip_reminder.title': 'Trip Reminder',
'notif.trip_reminder.text': 'Your trip {trip} is coming up soon!',
'notif.todo_due.title': 'To-do due',
'notif.todo_due.text': '{todo} in {trip} is due on {due}',
'notif.vacay_invite.title': 'Vacay Fusion Invite',
'notif.vacay_invite.text': '{actor} invited you to fuse vacation plans',
'notif.photos_shared.title': 'Photos Shared',
-3
View File
@@ -200,7 +200,6 @@ const es: Record<string, string> = {
'settings.notifyTripInvite': 'Invitaciones de viaje',
'settings.notifyBookingChange': 'Cambios en reservas',
'settings.notifyTripReminder': 'Recordatorios de viaje',
'settings.notifyTodoDue': 'Tarea próxima',
'settings.notifyVacayInvite': 'Invitaciones de fusión Vacay',
'settings.notifyPhotosShared': 'Fotos compartidas (Immich)',
'settings.notifyCollabMessage': 'Mensajes de chat (Collab)',
@@ -1946,8 +1945,6 @@ const es: Record<string, string> = {
'notif.booking_change.text': '{actor} actualizó una reserva en {trip}',
'notif.trip_reminder.title': 'Recordatorio de viaje',
'notif.trip_reminder.text': '¡Tu viaje {trip} se acerca!',
'notif.todo_due.title': 'Tarea pendiente',
'notif.todo_due.text': '{todo} en {trip} vence el {due}',
'notif.vacay_invite.title': 'Invitación Vacay Fusion',
'notif.vacay_invite.text': '{actor} te invitó a fusionar planes de vacaciones',
'notif.photos_shared.title': 'Fotos compartidas',
-3
View File
@@ -199,7 +199,6 @@ const fr: Record<string, string> = {
'settings.notifyTripInvite': 'Invitations de voyage',
'settings.notifyBookingChange': 'Modifications de réservation',
'settings.notifyTripReminder': 'Rappels de voyage',
'settings.notifyTodoDue': 'Tâche à échéance',
'settings.notifyVacayInvite': 'Invitations de fusion Vacay',
'settings.notifyPhotosShared': 'Photos partagées (Immich)',
'settings.notifyCollabMessage': 'Messages de chat (Collab)',
@@ -1940,8 +1939,6 @@ const fr: Record<string, string> = {
'notif.booking_change.text': '{actor} a mis à jour une réservation dans {trip}',
'notif.trip_reminder.title': 'Rappel de voyage',
'notif.trip_reminder.text': 'Votre voyage {trip} approche !',
'notif.todo_due.title': 'Tâche à échéance',
'notif.todo_due.text': '{todo} dans {trip} est due le {due}',
'notif.vacay_invite.title': 'Invitation Vacay Fusion',
'notif.vacay_invite.text': '{actor} vous invite à fusionner les plans de vacances',
'notif.photos_shared.title': 'Photos partagées',
-3
View File
@@ -199,7 +199,6 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyTripInvite': 'Utazási meghívók',
'settings.notifyBookingChange': 'Foglalási változások',
'settings.notifyTripReminder': 'Utazási emlékeztetők',
'settings.notifyTodoDue': 'Teendő esedékes',
'settings.notifyVacayInvite': 'Vacay összevonási meghívók',
'settings.notifyPhotosShared': 'Megosztott fotók (Immich)',
'settings.notifyCollabMessage': 'Csevegés üzenetek (Collab)',
@@ -1938,8 +1937,6 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'notif.booking_change.text': '{actor} frissített egy foglalást a(z) {trip} utazásban',
'notif.trip_reminder.title': 'Utazás emlékeztető',
'notif.trip_reminder.text': 'A(z) {trip} utazás hamarosan kezdődik!',
'notif.todo_due.title': 'Teendő esedékes',
'notif.todo_due.text': '{todo} ({trip}) határideje: {due}',
'notif.vacay_invite.title': 'Vacay Fusion meghívó',
'notif.vacay_invite.text': '{actor} meghívott a nyaralási tervek összevonásához',
'notif.photos_shared.title': 'Fotók megosztva',
-3
View File
@@ -202,7 +202,6 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyTripInvite': 'Undangan perjalanan',
'settings.notifyBookingChange': 'Perubahan pemesanan',
'settings.notifyTripReminder': 'Pengingat perjalanan',
'settings.notifyTodoDue': 'Tugas jatuh tempo',
'settings.notifyVacayInvite': 'Undangan Vacay fusion',
'settings.notifyPhotosShared': 'Foto dibagikan (Immich)',
'settings.notifyCollabMessage': 'Pesan chat (Collab)',
@@ -1947,8 +1946,6 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'notif.booking_change.text': '{actor} memperbarui pemesanan di {trip}',
'notif.trip_reminder.title': 'Pengingat Perjalanan',
'notif.trip_reminder.text': 'Perjalananmu {trip} akan segera dimulai!',
'notif.todo_due.title': 'Tugas jatuh tempo',
'notif.todo_due.text': '{todo} di {trip} jatuh tempo pada {due}',
'notif.vacay_invite.title': 'Undangan Vacay Fusion',
'notif.vacay_invite.text': '{actor} mengundangmu untuk menggabungkan rencana liburan',
'notif.photos_shared.title': 'Foto Dibagikan',
-3
View File
@@ -199,7 +199,6 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyTripInvite': 'Inviti di viaggio',
'settings.notifyBookingChange': 'Modifiche alle prenotazioni',
'settings.notifyTripReminder': 'Promemoria di viaggio',
'settings.notifyTodoDue': 'Attività in scadenza',
'settings.notifyVacayInvite': 'Inviti fusione Vacay',
'settings.notifyPhotosShared': 'Foto condivise (Immich)',
'settings.notifyCollabMessage': 'Messaggi chat (Collab)',
@@ -1941,8 +1940,6 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'notif.booking_change.text': '{actor} ha aggiornato una prenotazione in {trip}',
'notif.trip_reminder.title': 'Promemoria viaggio',
'notif.trip_reminder.text': 'Il tuo viaggio {trip} si avvicina!',
'notif.todo_due.title': 'Attività in scadenza',
'notif.todo_due.text': '{todo} in {trip} scade il {due}',
'notif.vacay_invite.title': 'Invito Vacay Fusion',
'notif.vacay_invite.text': '{actor} ti ha invitato a fondere i piani vacanza',
'notif.photos_shared.title': 'Foto condivise',
-3
View File
@@ -199,7 +199,6 @@ const nl: Record<string, string> = {
'settings.notifyTripInvite': 'Reisuitnodigingen',
'settings.notifyBookingChange': 'Boekingswijzigingen',
'settings.notifyTripReminder': 'Reisherinneringen',
'settings.notifyTodoDue': 'Taak verloopt',
'settings.notifyVacayInvite': 'Vacay-fusieuitnodigingen',
'settings.notifyPhotosShared': 'Gedeelde foto\'s (Immich)',
'settings.notifyCollabMessage': 'Chatberichten (Collab)',
@@ -1940,8 +1939,6 @@ const nl: Record<string, string> = {
'notif.booking_change.text': '{actor} heeft een boeking bijgewerkt in {trip}',
'notif.trip_reminder.title': 'Reisherinnering',
'notif.trip_reminder.text': 'Je reis {trip} komt eraan!',
'notif.todo_due.title': 'Taak verloopt',
'notif.todo_due.text': '{todo} in {trip} verloopt op {due}',
'notif.vacay_invite.title': 'Vacay Fusion-uitnodiging',
'notif.vacay_invite.text': '{actor} nodigt je uit om vakantieplannen te fuseren',
'notif.photos_shared.title': 'Foto\'s gedeeld',
-3
View File
@@ -182,7 +182,6 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyTripInvite': 'Zaproszenia do podróży',
'settings.notifyBookingChange': 'Zmiany w rezerwacjach',
'settings.notifyTripReminder': 'Przypomnienia o podróżach',
'settings.notifyTodoDue': 'Zadanie z terminem',
'settings.notifyVacayInvite': 'Zaproszenia do połączenia kalendarzy',
'settings.notifyPhotosShared': 'Udostępnione zdjęcia (Immich)',
'settings.notifyCollabMessage': 'Wiadomości czatu (Collab)',
@@ -1930,8 +1929,6 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'notif.booking_change.text': '{actor} zaktualizował rezerwację w {trip}',
'notif.trip_reminder.title': 'Przypomnienie o podróży',
'notif.trip_reminder.text': 'Twoja podróż {trip} zbliża się!',
'notif.todo_due.title': 'Zadanie z terminem',
'notif.todo_due.text': '{todo} w {trip} — termin {due}',
'notif.vacay_invite.title': 'Zaproszenie Vacay Fusion',
'notif.vacay_invite.text': '{actor} zaprosił Cię do połączenia planów urlopowych',
'notif.photos_shared.title': 'Zdjęcia udostępnione',
-3
View File
@@ -199,7 +199,6 @@ const ru: Record<string, string> = {
'settings.notifyTripInvite': 'Приглашения в поездку',
'settings.notifyBookingChange': 'Изменения бронирований',
'settings.notifyTripReminder': 'Напоминания о поездке',
'settings.notifyTodoDue': 'Задача к сроку',
'settings.notifyVacayInvite': 'Приглашения слияния Vacay',
'settings.notifyPhotosShared': 'Общие фото (Immich)',
'settings.notifyCollabMessage': 'Сообщения чата (Collab)',
@@ -1937,8 +1936,6 @@ const ru: Record<string, string> = {
'notif.booking_change.text': '{actor} обновил бронирование в {trip}',
'notif.trip_reminder.title': 'Напоминание о поездке',
'notif.trip_reminder.text': 'Ваша поездка {trip} скоро начнётся!',
'notif.todo_due.title': 'Задача к сроку',
'notif.todo_due.text': '{todo} в {trip} — срок {due}',
'notif.vacay_invite.title': 'Приглашение Vacay Fusion',
'notif.vacay_invite.text': '{actor} приглашает вас объединить планы отпуска',
'notif.photos_shared.title': 'Фото опубликованы',
-3
View File
@@ -199,7 +199,6 @@ const zh: Record<string, string> = {
'settings.notifyTripInvite': '旅行邀请',
'settings.notifyBookingChange': '预订变更',
'settings.notifyTripReminder': '旅行提醒',
'settings.notifyTodoDue': '待办事项即将到期',
'settings.notifyVacayInvite': 'Vacay 融合邀请',
'settings.notifyPhotosShared': '共享照片 (Immich)',
'settings.notifyCollabMessage': '聊天消息 (Collab)',
@@ -1937,8 +1936,6 @@ const zh: Record<string, string> = {
'notif.booking_change.text': '{actor} 更新了 {trip} 中的预订',
'notif.trip_reminder.title': '旅行提醒',
'notif.trip_reminder.text': '您的旅行 {trip} 即将开始!',
'notif.todo_due.title': '待办事项即将到期',
'notif.todo_due.text': '{trip} 中的 {todo} 将于 {due} 到期',
'notif.vacay_invite.title': 'Vacay 融合邀请',
'notif.vacay_invite.text': '{actor} 邀请您合并假期计划',
'notif.photos_shared.title': '照片已分享',
-3
View File
@@ -199,7 +199,6 @@ const zhTw: Record<string, string> = {
'settings.notifyTripInvite': '旅行邀請',
'settings.notifyBookingChange': '預訂變更',
'settings.notifyTripReminder': '旅行提醒',
'settings.notifyTodoDue': '待辦事項即將到期',
'settings.notifyVacayInvite': 'Vacay 融合邀請',
'settings.notifyPhotosShared': '共享照片 (Immich)',
'settings.notifyCollabMessage': '聊天訊息 (Collab)',
@@ -2196,8 +2195,6 @@ const zhTw: Record<string, string> = {
'notif.booking_change.text': '{actor} 更新了 {trip} 中的預訂',
'notif.trip_reminder.title': '旅行提醒',
'notif.trip_reminder.text': '你的旅行 {trip} 即將開始!',
'notif.todo_due.title': '待辦事項即將到期',
'notif.todo_due.text': '{trip} 中的 {todo} 將於 {due} 到期',
'notif.vacay_invite.title': 'Vacay Fusion 邀請',
'notif.vacay_invite.text': '{actor} 邀請你合併假期計畫',
'notif.photos_shared.title': '照片已分享',
+4 -12
View File
@@ -595,11 +595,7 @@ export default function JourneyDetailPage() {
</div>
{entries.map((entry, idx) => {
// Skeletons are just "suggested" places pulled
// from the linked trip — they aren't real
// journey entries until the user edits them,
// so reordering them does not make sense.
const canReorder = !isMobile && canEditEntries && entries.length > 1 && entry.type !== 'skeleton'
const canReorder = !isMobile && canEditEntries && entries.length > 1
const move = (direction: -1 | 1) => {
if (!current) return
const target = idx + direction
@@ -2316,10 +2312,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
)}
</div>
{/* Gallery picker directly below buttons. Safari collapses
`aspect-square` items inside an overflow-scroll grid, so
the square is enforced with a padding-top spacer + an
absolutely positioned image (works across all browsers). */}
{/* Gallery picker — directly below buttons */}
{showGalleryPick && (
<div className="mt-2 border border-zinc-200 dark:border-zinc-700 rounded-xl p-3 bg-zinc-50 dark:bg-zinc-800/50">
<div className="grid grid-cols-5 sm:grid-cols-6 gap-1.5 max-h-[160px] overflow-y-auto">
@@ -2337,10 +2330,9 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
setPhotos(prev => [...prev, gp])
}
}}
className="relative w-full rounded-lg overflow-hidden cursor-pointer hover:ring-2 hover:ring-zinc-900 dark:hover:ring-white hover:ring-offset-1 dark:hover:ring-offset-zinc-900 transition-all"
style={{ paddingTop: '100%' }}
className="aspect-square rounded-lg overflow-hidden cursor-pointer hover:ring-2 hover:ring-zinc-900 dark:hover:ring-white hover:ring-offset-1 dark:hover:ring-offset-zinc-900 transition-all"
>
<img src={photoUrl(gp)} alt="" className="absolute inset-0 w-full h-full object-cover" loading="lazy" onError={e => { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
<img src={photoUrl(gp)} alt="" className="w-full h-full object-cover" loading="lazy" onError={e => { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
</div>
))}
{galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id)).length === 0 && (
+5 -16
View File
@@ -36,8 +36,8 @@ interface PublicPhoto {
caption?: string | null
}
function photoUrl(p: PublicPhoto, shareToken: string, kind: 'thumbnail' | 'original' = 'original'): string {
return `/api/public/journey/${shareToken}/photos/${p.photo_id}/${kind}`
function photoUrl(p: PublicPhoto, shareToken: string): string {
return `/api/public/journey/${shareToken}/photos/${p.photo_id}/original`
}
function formatDate(d: string): { weekday: string; month: string; day: number } {
@@ -84,20 +84,9 @@ export default function JourneyPublicPage() {
const journey = data?.journey || {}
const stats = data?.stats || {}
// `[Trip Photos]` and `Gallery` are synthetic photo-only containers
// produced by the trip→journey sync. They have no story and no
// location, and the owner view strips them from the timeline the
// same way (JourneyDetailPage.tsx). Gallery keeps their photos.
const timelineEntries = useMemo(
() => entries.filter(e => e.title !== '[Trip Photos]' && e.title !== 'Gallery'),
[entries],
)
const groupedEntries = useMemo(() => groupByDate(timelineEntries), [timelineEntries])
const groupedEntries = useMemo(() => groupByDate(entries), [entries])
const sortedDates = useMemo(() => [...groupedEntries.keys()].sort(), [groupedEntries])
const mapEntries = useMemo(
() => timelineEntries.filter(e => e.location_lat && e.location_lng),
[timelineEntries],
)
const mapEntries = useMemo(() => entries.filter(e => e.location_lat && e.location_lng), [entries])
const allPhotos = useMemo(() => entries.flatMap(e => (e.photos || []).map(p => ({ photo: p, entry: e }))), [entries])
// Set default view based on permissions
@@ -323,7 +312,7 @@ export default function JourneyPublicPage() {
className="aspect-square rounded-lg overflow-hidden cursor-pointer"
onClick={() => setLightbox({ photos: allPhotos.map(({ photo: p }) => ({ id: String(p.id), src: photoUrl(p, token!), caption: p.caption })), index: idx })}
>
<img src={photoUrl(photo, token!, 'thumbnail')} className="w-full h-full object-cover hover:scale-105 transition-transform" alt="" loading="lazy" />
<img src={photoUrl(photo, token!)} className="w-full h-full object-cover hover:scale-105 transition-transform" alt="" loading="lazy" />
</div>
))}
</div>
@@ -1,71 +0,0 @@
// FE-CLIENT-INTERCEPTOR-001 to FE-CLIENT-INTERCEPTOR-012
import { describe, it, expect } from 'vitest'
import { isAuthPublicPath } from '../../../src/api/client'
describe('FE-CLIENT-INTERCEPTOR: 401 AUTH_REQUIRED redirect allowlist', () => {
describe('exact-match public paths — no redirect', () => {
it('FE-CLIENT-INTERCEPTOR-001: /login', () => {
expect(isAuthPublicPath('/login')).toBe(true)
})
it('FE-CLIENT-INTERCEPTOR-002: /register', () => {
expect(isAuthPublicPath('/register')).toBe(true)
})
it('FE-CLIENT-INTERCEPTOR-003: /forgot-password', () => {
expect(isAuthPublicPath('/forgot-password')).toBe(true)
})
it('FE-CLIENT-INTERCEPTOR-004: /reset-password', () => {
expect(isAuthPublicPath('/reset-password')).toBe(true)
})
})
describe('prefix-match public paths — no redirect', () => {
it('FE-CLIENT-INTERCEPTOR-005: /shared/:token', () => {
expect(isAuthPublicPath('/shared/abc123token')).toBe(true)
})
it('FE-CLIENT-INTERCEPTOR-006: /public/journey/:token', () => {
expect(isAuthPublicPath('/public/journey/xyz789')).toBe(true)
})
})
describe('paths that matched via includes() before fix — must redirect', () => {
it('FE-CLIENT-INTERCEPTOR-007: /admin/login', () => {
expect(isAuthPublicPath('/admin/login')).toBe(false)
})
it('FE-CLIENT-INTERCEPTOR-008: /admin/register', () => {
expect(isAuthPublicPath('/admin/register')).toBe(false)
})
it('FE-CLIENT-INTERCEPTOR-009: /some-login-page', () => {
expect(isAuthPublicPath('/some-login-page')).toBe(false)
})
})
describe('paths that matched via loose startsWith before fix — must redirect', () => {
it('FE-CLIENT-INTERCEPTOR-010: /reset-password-extra', () => {
expect(isAuthPublicPath('/reset-password-extra')).toBe(false)
})
it('FE-CLIENT-INTERCEPTOR-011: /forgot-password-extra', () => {
expect(isAuthPublicPath('/forgot-password-extra')).toBe(false)
})
})
describe('private app paths — must redirect', () => {
it('FE-CLIENT-INTERCEPTOR-012: /dashboard', () => {
expect(isAuthPublicPath('/dashboard')).toBe(false)
})
it('FE-CLIENT-INTERCEPTOR-013: /trips/123', () => {
expect(isAuthPublicPath('/trips/123')).toBe(false)
})
it('FE-CLIENT-INTERCEPTOR-014: / (root)', () => {
expect(isAuthPublicPath('/')).toBe(false)
})
})
})
+7
View File
@@ -0,0 +1,7 @@
# Release Notes
## v2.9.11
### Bug Fixes
- **OIDC-only mode: resolved login/logout loop** — When password authentication is disabled, logging out no longer triggers an immediate re-authentication loop. After logout, users land on the login page and must manually click "Sign in with {provider}" to start the OIDC flow. Also fixed a secondary loop that could occur on the OIDC callback page under React 18 StrictMode, where the auth code exchange would be interrupted before completing, causing the app to redirect back to the identity provider instead of landing on the dashboard. (#491)
+4 -35
View File
@@ -237,8 +237,8 @@ async function main() {
}
db.transaction(() => {
// --- app_settings: oidc_client_secret, smtp_pass, admin_webhook_url, admin_ntfy_token ---
for (const key of ['oidc_client_secret', 'smtp_pass', 'admin_webhook_url', 'admin_ntfy_token']) {
// --- app_settings: oidc_client_secret, smtp_pass ---
for (const key of ['oidc_client_secret', 'smtp_pass']) {
const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined;
if (!row?.value) continue;
const newVal = migrateApiKeyValue(row.value, `app_settings.${key}`);
@@ -247,8 +247,8 @@ async function main() {
}
}
// --- users: api key columns + synology credentials ---
const apiKeyColumns = ['maps_api_key', 'openweather_api_key', 'immich_api_key', 'synology_password', 'synology_sid', 'synology_did'];
// --- users: maps_api_key, openweather_api_key, immich_api_key ---
const apiKeyColumns = ['maps_api_key', 'openweather_api_key', 'immich_api_key'];
const users = db.prepare('SELECT id FROM users').all() as { id: number }[];
for (const user of users) {
@@ -271,37 +271,6 @@ async function main() {
}
}
}
// --- settings: per-user encrypted keys ---
const encryptedSettingKeys = ['webhook_url', 'ntfy_token', 'mapbox_access_token'];
const settingRows = db.prepare(
`SELECT user_id, key, value FROM settings WHERE key IN (${encryptedSettingKeys.map(() => '?').join(', ')})`
).all(...encryptedSettingKeys) as { user_id: number; key: string; value: string }[];
for (const row of settingRows) {
if (!row.value) continue;
const newVal = migrateApiKeyValue(row.value, `settings[user=${row.user_id}].${row.key}`);
if (newVal !== null) {
db.prepare('UPDATE settings SET value = ? WHERE user_id = ? AND key = ?').run(newVal, row.user_id, row.key);
}
}
// --- trip_album_links: passphrase ---
const albumLinks = db.prepare('SELECT id, passphrase FROM trip_album_links WHERE passphrase IS NOT NULL').all() as { id: number; passphrase: string }[];
for (const row of albumLinks) {
const newVal = migrateApiKeyValue(row.passphrase, `trip_album_links[${row.id}].passphrase`);
if (newVal !== null) {
db.prepare('UPDATE trip_album_links SET passphrase = ? WHERE id = ?').run(newVal, row.id);
}
}
// --- trek_photos: passphrase ---
const photos = db.prepare('SELECT id, passphrase FROM trek_photos WHERE passphrase IS NOT NULL').all() as { id: number; passphrase: string }[];
for (const row of photos) {
const newVal = migrateApiKeyValue(row.passphrase, `trek_photos[${row.id}].passphrase`);
if (newVal !== null) {
db.prepare('UPDATE trek_photos SET passphrase = ? WHERE id = ?').run(newVal, row.id);
}
}
})();
db.close();
+13 -56
View File
@@ -5,9 +5,11 @@ import cookieParser from 'cookie-parser';
import path from 'node:path';
import fs from 'node:fs';
import jwt from 'jsonwebtoken';
import { JWT_SECRET } from './config';
import { logDebug, logWarn, logError } from './services/auditLog';
import { enforceGlobalMfaPolicy } from './middleware/mfaPolicy';
import { authenticate, verifyJwtAndLoadUser } from './middleware/auth';
import { authenticate } from './middleware/auth';
import { db } from './db/database';
import authRoutes from './routes/auth';
@@ -74,19 +76,6 @@ export function createApp(): express.Application {
}
const shouldForceHttps = process.env.FORCE_HTTPS === 'true';
// HSTS is worth enabling any time we're serving production traffic,
// not only when FORCE_HTTPS is set. Self-hosters behind Traefik /
// Caddy / Cloudflare Tunnel typically leave FORCE_HTTPS unset (the
// proxy handles the redirect for them), and the previous "HSTS off by
// default" meant those instances never advertised HSTS at all.
//
// `includeSubDomains` stays OFF by default on purpose: an instance
// running on an apex domain would otherwise force HTTPS on every
// sibling subdomain the same operator may still be running over plain
// HTTP. Operators who want the stricter policy opt in with
// `HSTS_INCLUDE_SUBDOMAINS=true`.
const hstsActive = shouldForceHttps || process.env.NODE_ENV === 'production';
const hstsIncludeSubdomains = process.env.HSTS_INCLUDE_SUBDOMAINS === 'true';
// RFC 8414 / RFC 9728: discovery docs are world-readable — open CORS regardless of deployment config
app.use(
@@ -123,7 +112,7 @@ export function createApp(): express.Application {
}
},
crossOriginEmbedderPolicy: false,
hsts: hstsActive ? { maxAge: 31536000, includeSubDomains: hstsIncludeSubdomains } : false,
hsts: shouldForceHttps ? { maxAge: 31536000, includeSubDomains: false } : false,
}));
if (shouldForceHttps) {
@@ -172,33 +161,12 @@ export function createApp(): express.Application {
});
}
// Static: avatars, covers, and journey photos.
//
// Security model (audit SEC-M9): these paths are unauthenticated by
// design. All filenames are server-chosen UUID v4 (see `uuid()` in
// the multer storage config for avatars / covers / journey uploads),
// which gives each asset >122 bits of namespace entropy — not
// guessable via enumeration. An attacker would need to have already
// seen the URL (email, shared journey, etc.) to request the file.
//
// Moving these behind auth would also break:
// - Unauthenticated trip-card rendering on public share links
// - Journey public-share pages (/public/journey/:token)
// - Email-embedded avatars
//
// The `/uploads/photos/...` route below is DIFFERENT: photo URLs are
// not embedded in unauthenticated UI contexts, so that endpoint IS
// gated (session JWT with pv, or a share token scoped to the photo's
// trip).
// Static: avatars, covers, and journey photos
app.use('/uploads/avatars', express.static(path.join(__dirname, '../uploads/avatars')));
app.use('/uploads/covers', express.static(path.join(__dirname, '../uploads/covers')));
app.use('/uploads/journey', express.static(path.join(__dirname, '../uploads/journey')));
// Photos require either a valid logged-in session (via JWT with the
// password_version gate) OR a share token that covers the SPECIFIC
// photo's trip. Previously any share token for any trip could request
// any photo filename by UUID — fine in practice because UUIDs are
// unguessable, but the auth model was wrong.
// Photos require auth or valid share token
app.get('/uploads/photos/:filename', (req: Request, res: Response) => {
const safeName = path.basename(req.params.filename);
const filePath = path.join(__dirname, '../uploads/photos', safeName);
@@ -206,28 +174,17 @@ export function createApp(): express.Application {
if (!resolved.startsWith(path.resolve(__dirname, '../uploads/photos'))) {
return res.status(403).send('Forbidden');
}
// existsSync here is cheap and avoids a sendFile error frame; kept
// sync because the handler is already short-lived.
if (!fs.existsSync(resolved)) return res.status(404).send('Not found');
const authHeader = req.headers.authorization;
const rawToken = (req.query.token as string) || (authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null);
if (!rawToken) return res.status(401).send('Authentication required');
const token = (req.query.token as string) || (authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null);
if (!token) return res.status(401).send('Authentication required');
// JWT session path (with pv check).
const user = verifyJwtAndLoadUser(rawToken);
if (user) return res.sendFile(resolved);
// Share-token path: require the token to cover the exact trip the
// photo belongs to. Expired tokens fall through to 401.
const photo = db.prepare('SELECT trip_id FROM photos WHERE filename = ?').get(safeName) as { trip_id: number } | undefined;
if (!photo) return res.status(401).send('Authentication required');
const share = db.prepare(
"SELECT trip_id FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))"
).get(rawToken) as { trip_id: number } | undefined;
if (!share || share.trip_id !== photo.trip_id) {
return res.status(401).send('Authentication required');
try {
jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
} catch {
const shareRow = db.prepare('SELECT id FROM share_tokens WHERE token = ?').get(token);
if (!shareRow) return res.status(401).send('Authentication required');
}
res.sendFile(resolved);
});
+9 -9
View File
@@ -35,6 +35,15 @@ function initDb(): void {
initDb();
if (process.env.DEMO_MODE === 'true') {
try {
const { seedDemoData } = require('../demo/demo-seed');
seedDemoData(_db);
} catch (err: unknown) {
console.error('[Demo] Seed error:', err instanceof Error ? err.message : err);
}
}
const db = new Proxy({} as Database.Database, {
get(_, prop: string | symbol) {
if (!_db) throw new Error('Database connection is not available (restore in progress?)');
@@ -47,15 +56,6 @@ const db = new Proxy({} as Database.Database, {
},
});
if (process.env.DEMO_MODE === 'true') {
try {
const { seedDemoData } = require('../demo/demo-seed');
seedDemoData(_db);
} catch (err: unknown) {
console.error('[Demo] Seed error:', err instanceof Error ? err.message : err);
}
}
function closeDb(): void {
if (_db) {
try { _db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {}
-114
View File
@@ -1792,120 +1792,6 @@ function runMigrations(db: Database.Database): void {
CREATE INDEX IF NOT EXISTS idx_prt_hash ON password_reset_tokens(token_hash);
`);
},
// Migration: todo due-date reminders — track when we last sent a
// reminder for each todo so we don't spam the same notification
// every day the scheduler runs.
() => {
try { db.exec('ALTER TABLE todo_items ADD COLUMN reminded_at DATETIME'); }
catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
},
// Migration: security audit batch 1 — columns + indexes required
// by several fixes bundled into one PR.
// - share_tokens.expires_at: public share links now get a 90-day
// TTL by default; existing rows stay NULL (= no expiry) to avoid
// silently breaking already-published links.
// - Missing indexes on high-cardinality query paths (see PERF-H1
// in the audit): every listTrips() used to full-scan trips on
// user_id, and notifications/photos/reservations had similar
// gaps.
() => {
try { db.exec('ALTER TABLE share_tokens ADD COLUMN expires_at TEXT'); }
catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
db.exec(`
CREATE INDEX IF NOT EXISTS idx_trips_user_id ON trips(user_id);
CREATE INDEX IF NOT EXISTS idx_trips_created_at ON trips(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_photos_day_id ON photos(day_id);
CREATE INDEX IF NOT EXISTS idx_photos_place_id ON photos(place_id);
CREATE INDEX IF NOT EXISTS idx_reservations_day_id ON reservations(day_id);
CREATE INDEX IF NOT EXISTS idx_share_tokens_token ON share_tokens(token);
`);
try {
// day_accommodations may have either start_day_id/end_day_id or a
// single day_id depending on how far the schema has evolved;
// build whichever index makes sense for the live columns.
const cols = db.prepare("PRAGMA table_info('day_accommodations')").all() as Array<{ name: string }>;
const names = new Set(cols.map((c) => c.name));
if (names.has('start_day_id')) db.exec('CREATE INDEX IF NOT EXISTS idx_day_accommodations_start_day_id ON day_accommodations(start_day_id)');
if (names.has('end_day_id')) db.exec('CREATE INDEX IF NOT EXISTS idx_day_accommodations_end_day_id ON day_accommodations(end_day_id)');
} catch { /* table may not exist on very old installs */ }
try {
// notifications schema has varied; probe before indexing.
const cols = db.prepare("PRAGMA table_info('notifications')").all() as Array<{ name: string }>;
const names = new Set(cols.map((c) => c.name));
if (names.has('target') && names.has('scope')) {
db.exec('CREATE INDEX IF NOT EXISTS idx_notifications_target_scope ON notifications(target, scope)');
}
} catch { /* notifications table may not exist on very old installs */ }
},
// Migration: widen idempotency_keys primary key to (key, user_id,
// method, path). The middleware lookup was widened in the same audit
// batch so a reused X-Idempotency-Key against a different endpoint
// does not replay the cached body of an unrelated request. The old
// PK was only (key, user_id), so the `INSERT OR IGNORE` on the
// second endpoint silently skipped — the cache never stored request
// B's response and replays re-executed the handler. Rebuild the
// table with the widened PK, preserving existing rows (the old PK
// guarantees no conflicts in the new, strictly looser unique key).
() => {
const hasTable = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'idempotency_keys'").get();
if (!hasTable) return;
db.exec(`
CREATE TABLE idempotency_keys_new (
key TEXT NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
method TEXT NOT NULL,
path TEXT NOT NULL,
status_code INTEGER NOT NULL,
response_body TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
PRIMARY KEY (key, user_id, method, path)
);
INSERT INTO idempotency_keys_new (key, user_id, method, path, status_code, response_body, created_at)
SELECT key, user_id, method, path, status_code, response_body, created_at FROM idempotency_keys;
DROP TABLE idempotency_keys;
ALTER TABLE idempotency_keys_new RENAME TO idempotency_keys;
CREATE INDEX IF NOT EXISTS idx_idempotency_keys_created ON idempotency_keys(created_at);
`);
},
// SEC-H6: revoke all OAuth tokens issued before audience binding was
// enforced. mcp/index.ts now unconditionally checks audience; tokens
// with audience=null would be permanently rejected by the check, so
// removing them here avoids leaving dead rows and makes the intent clear.
() => {
const hasCol = db.prepare("SELECT name FROM pragma_table_info('oauth_tokens') WHERE name = 'audience'").get();
if (!hasCol) return;
db.prepare('DELETE FROM oauth_tokens WHERE audience IS NULL').run();
},
// Remove NOT NULL constraint on day_accommodations.place_id so hotel
// reservations created from the Bookings tab without a linked place can
// still persist their date range. Change ON DELETE CASCADE → SET NULL so
// deleting a place orphans the accommodation row instead of cascading.
() => {
db.exec(`
CREATE TABLE day_accommodations_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
place_id INTEGER REFERENCES places(id) ON DELETE SET NULL,
start_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
end_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
check_in TEXT,
check_in_end TEXT,
check_out TEXT,
confirmation TEXT,
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO day_accommodations_new
SELECT id, trip_id, place_id, start_day_id, end_day_id,
check_in, check_in_end, check_out, confirmation, notes, created_at
FROM day_accommodations;
DROP TABLE day_accommodations;
ALTER TABLE day_accommodations_new RENAME TO day_accommodations;
CREATE INDEX IF NOT EXISTS idx_day_accommodations_trip_id ON day_accommodations(trip_id);
CREATE INDEX IF NOT EXISTS idx_day_accommodations_start_day_id ON day_accommodations(start_day_id);
CREATE INDEX IF NOT EXISTS idx_day_accommodations_end_day_id ON day_accommodations(end_day_id);
`);
},
];
if (currentVersion < migrations.length) {
+1 -1
View File
@@ -344,7 +344,7 @@ function createTables(db: Database.Database): void {
CREATE TABLE IF NOT EXISTS day_accommodations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
place_id INTEGER REFERENCES places(id) ON DELETE SET NULL,
place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE,
start_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
end_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
check_in TEXT,
-1
View File
@@ -46,7 +46,6 @@ const server = app.listen(PORT, () => {
}
scheduler.start();
scheduler.startTripReminders();
scheduler.startTodoReminders();
scheduler.startVersionCheck();
scheduler.startDemoReset();
scheduler.startIdempotencyCleanup();
+5 -4
View File
@@ -180,10 +180,11 @@ function verifyToken(authHeader: string | undefined): VerifyTokenResult | null {
if (token.startsWith('trekoa_')) {
const result = getUserByAccessToken(token);
if (!result) return null;
// RFC 8707: audience must always match this resource endpoint.
// Pre-audit tokens with audience=null are revoked by the SEC-H6 migration.
const expected = `${(getAppUrl() || '').replace(/\/+$/, '')}/mcp`;
if (result.audience !== expected) return null;
// RFC 8707: if the token carries an audience, it must match this resource endpoint
if (result.audience !== null) {
const expected = `${(getAppUrl() || '').replace(/\/+$/, '')}/mcp`;
if (result.audience !== expected) return null;
}
return { user: result.user, scopes: result.scopes, clientId: result.clientId, isStaticToken: false };
}
+3 -15
View File
@@ -4,7 +4,6 @@ import { db } from '../db/database';
import { JWT_SECRET } from '../config';
import { AuthRequest, OptionalAuthRequest, User } from '../types';
import { applyIdempotency } from './idempotency';
import { isDemoEmail } from '../services/demo';
export function extractToken(req: Request): string | null {
// Prefer httpOnly cookie; fall back to Authorization: Bearer (MCP, API clients)
@@ -14,18 +13,7 @@ export function extractToken(req: Request): string | null {
return (authHeader && authHeader.split(' ')[1]) || null;
}
/**
* Verify a JWT and load its user, enforcing the password_version gate.
*
* Exported so every auth surface in the codebase (MCP bearer tokens,
* file download query tokens, the photo-serving route) goes through the
* same check. A password reset bumps `users.password_version`, which
* invalidates every JWT that embedded the prior value but only if
* every verify path actually compares the claim. Previously several
* paths called `jwt.verify` directly and skipped the DB lookup, so a
* stolen token kept working after the victim reset.
*/
export function verifyJwtAndLoadUser(token: string): User | null {
function verifyJwtAndLoadUser(token: string): User | null {
try {
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number; pv?: number };
const row = db.prepare(
@@ -105,8 +93,8 @@ const adminOnly = (req: Request, res: Response, next: NextFunction): void => {
const demoUploadBlock = (req: Request, res: Response, next: NextFunction): void => {
const authReq = req as AuthRequest;
if (process.env.DEMO_MODE === 'true' && isDemoEmail(authReq.user?.email)) {
res.status(403).json({ error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' });
if (process.env.DEMO_MODE === 'true' && authReq.user?.email === 'demo@nomad.app') {
res.status(403).json({ error: 'Uploads are disabled in demo mode. Self-host NOMAD for full functionality.' });
return;
}
next();
+8 -29
View File
@@ -2,13 +2,6 @@ import { Request, Response, NextFunction } from 'express';
import { db } from '../db/database';
const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
// Reject pathological client-supplied keys outright instead of hashing
// everything — 128 chars is plenty for any realistic UUID / ULID / nonce.
const MAX_KEY_LENGTH = 128;
// Responses larger than this are not worth caching — a backup-restore
// endpoint could otherwise store a megabyte-sized JSON body per request
// key and, with mass key creation, blow up idempotency_keys.
const MAX_CACHED_BODY_BYTES = 256 * 1024;
interface IdempotencyRow {
status_code: number;
@@ -19,14 +12,9 @@ interface IdempotencyRow {
* Called from within `authenticate` after req.user is set.
*
* For mutating requests carrying X-Idempotency-Key:
* - If (key, userId, method, path) already stored: replays the cached response.
* - If (key, userId) already stored: replays the cached response.
* - Otherwise: wraps res.json to capture and store a successful response.
*
* The lookup is scoped by method + path as well as user so the same key
* replayed against a different endpoint doesn't return the cached body
* of an unrelated request. Key length is capped and the cached body is
* skipped when it exceeds `MAX_CACHED_BODY_BYTES`.
*
* Storing happens in idempotency_keys (24h TTL, cleaned by scheduler).
*/
export function applyIdempotency(req: Request, res: Response, next: NextFunction, userId: number): void {
@@ -40,17 +28,11 @@ export function applyIdempotency(req: Request, res: Response, next: NextFunction
next();
return;
}
if (key.length > MAX_KEY_LENGTH) {
res.status(400).json({ error: 'X-Idempotency-Key exceeds maximum length of 128 characters' });
return;
}
// Return cached response only if the same key was seen for the same
// user AND the same method+path — avoids a POST's cached body leaking
// into an unrelated PATCH that reused the idempotency-key string.
// Return cached response if key already processed for this user
const existing = db.prepare(
'SELECT status_code, response_body FROM idempotency_keys WHERE key = ? AND user_id = ? AND method = ? AND path = ?'
).get(key, userId, req.method, req.path) as IdempotencyRow | undefined;
'SELECT status_code, response_body FROM idempotency_keys WHERE key = ? AND user_id = ?'
).get(key, userId) as IdempotencyRow | undefined;
if (existing) {
res.status(existing.status_code).json(JSON.parse(existing.response_body));
@@ -62,13 +44,10 @@ export function applyIdempotency(req: Request, res: Response, next: NextFunction
res.json = function (body: unknown): Response {
if (res.statusCode >= 200 && res.statusCode < 300) {
try {
const serialized = JSON.stringify(body);
if (serialized.length <= MAX_CACHED_BODY_BYTES) {
db.prepare(
`INSERT OR IGNORE INTO idempotency_keys (key, user_id, method, path, status_code, response_body, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`
).run(key, userId, req.method, req.path, res.statusCode, serialized, Math.floor(Date.now() / 1000));
}
db.prepare(
`INSERT OR IGNORE INTO idempotency_keys (key, user_id, method, path, status_code, response_body, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`
).run(key, userId, req.method, req.path, res.statusCode, JSON.stringify(body), Math.floor(Date.now() / 1000));
} catch {
// Non-fatal: if storage fails, the request still succeeds
}
+17 -18
View File
@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { db } from '../db/database';
import { extractToken, verifyJwtAndLoadUser } from './auth';
import { DEMO_EMAILS } from '../services/demo';
import { JWT_SECRET } from '../config';
/** Paths that never require MFA (public or pre-auth). */
export function isPublicApiPath(method: string, pathNoQuery: string): boolean {
@@ -42,25 +42,21 @@ export function enforceGlobalMfaPolicy(req: Request, res: Response, next: NextFu
return;
}
// Accept both the httpOnly session cookie (regular SPA users) and the
// Authorization header (MCP / API clients). Previously this only looked
// at the header so every normal cookie-authenticated session sailed
// past `require_mfa` unchecked.
const token = extractToken(req);
const authHeader = req.headers.authorization;
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
next();
return;
}
// Use the shared verify helper so the `password_version` gate applies
// here too — a JWT stolen before a password reset would otherwise
// continue to satisfy this middleware until its natural 24h expiry.
const verified = verifyJwtAndLoadUser(token);
if (!verified) {
let userId: number;
try {
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number };
userId = decoded.id;
} catch {
next();
return;
}
const userId = verified.id;
const requireRow = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined;
if (requireRow?.value !== 'true') {
@@ -68,13 +64,16 @@ export function enforceGlobalMfaPolicy(req: Request, res: Response, next: NextFu
return;
}
if (process.env.DEMO_MODE === 'true' && verified.email && DEMO_EMAILS.has(verified.email)) {
next();
return;
if (process.env.DEMO_MODE === 'true') {
const demo = db.prepare('SELECT email FROM users WHERE id = ?').get(userId) as { email: string } | undefined;
if (demo?.email === 'demo@trek.app' || demo?.email === 'demo@nomad.app') {
next();
return;
}
}
const row = db.prepare('SELECT mfa_enabled FROM users WHERE id = ?').get(userId) as
| { mfa_enabled: number | boolean }
const row = db.prepare('SELECT mfa_enabled, role FROM users WHERE id = ?').get(userId) as
| { mfa_enabled: number | boolean; role: string }
| undefined;
if (!row) {
next();
+17 -18
View File
@@ -39,7 +39,7 @@ import {
requestPasswordReset,
resetPassword,
} from '../services/authService';
import { sendPasswordResetEmail, getAppUrl } from '../services/notifications';
import { sendPasswordResetEmail } from '../services/notifications';
const router = express.Router();
@@ -127,10 +127,10 @@ router.get('/app-config', optionalAuth, (req: Request, res: Response) => {
res.json(getAppConfig(user));
});
router.post('/demo-login', (req: Request, res: Response) => {
router.post('/demo-login', (_req: Request, res: Response) => {
const result = demoLogin();
if (result.error) return res.status(result.status!).json({ error: result.error });
setAuthCookie(res, result.token!, req);
setAuthCookie(res, result.token!);
res.json({ token: result.token, user: result.user });
});
@@ -144,7 +144,7 @@ router.post('/register', authLimiter, (req: Request, res: Response) => {
const result = registerUser(req.body);
if (result.error) return res.status(result.status!).json({ error: result.error });
writeAudit({ userId: result.auditUserId!, action: 'user.register', ip: getClientIp(req), details: result.auditDetails });
setAuthCookie(res, result.token!, req);
setAuthCookie(res, result.token!);
res.status(201).json({ token: result.token, user: result.user });
});
@@ -155,7 +155,7 @@ router.post('/login', authLimiter, (req: Request, res: Response) => {
}
if (result.error) return res.status(result.status!).json({ error: result.error });
if (result.mfa_required) return res.json({ mfa_required: true, mfa_token: result.mfa_token });
setAuthCookie(res, result.token!, req);
setAuthCookie(res, result.token!);
res.json({ token: result.token, user: result.user });
});
@@ -178,12 +178,11 @@ router.post('/forgot-password', forgotLimiter, async (req: Request, res: Respons
const outcome = requestPasswordReset(rawEmail, ip);
if (outcome.reason === 'issued' && outcome.tokenForDelivery && outcome.userEmail) {
// Build the reset URL from the server-side canonical APP_URL (or
// first ALLOWED_ORIGINS entry) — never from request headers. A
// crafted `Origin` / `Host` / `Referer` would otherwise put an
// attacker-controlled domain into the emailed reset link while the
// token itself is still legitimate.
const origin = getAppUrl();
// Build the reset URL from the incoming request origin so dev /
// prod both work without extra config.
const origin = (req.headers['origin'] as string | undefined)
|| (req.headers['referer'] ? new URL(req.headers['referer'] as string).origin : undefined)
|| `${req.protocol}://${req.get('host')}`;
const url = `${origin.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(outcome.tokenForDelivery)}`;
// Audit the REQUEST always — even for "no user" — so abuse is visible.
@@ -232,8 +231,8 @@ router.get('/me', authenticate, (req: Request, res: Response) => {
res.json({ user });
});
router.post('/logout', (req: Request, res: Response) => {
clearAuthCookie(res, req);
router.post('/logout', (_req: Request, res: Response) => {
clearAuthCookie(res);
res.json({ success: true });
});
@@ -277,15 +276,15 @@ router.get('/me/settings', authenticate, (req: Request, res: Response) => {
res.json({ settings: result.settings });
});
router.post('/avatar', authenticate, demoUploadBlock, avatarUpload.single('avatar'), async (req: Request, res: Response) => {
router.post('/avatar', authenticate, demoUploadBlock, avatarUpload.single('avatar'), (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!req.file) return res.status(400).json({ error: 'No image uploaded' });
res.json(await saveAvatar(authReq.user.id, req.file.filename));
res.json(saveAvatar(authReq.user.id, req.file.filename));
});
router.delete('/avatar', authenticate, async (req: Request, res: Response) => {
router.delete('/avatar', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
res.json(await deleteAvatar(authReq.user.id));
res.json(deleteAvatar(authReq.user.id));
});
router.get('/users', authenticate, (req: Request, res: Response) => {
@@ -330,7 +329,7 @@ router.post('/mfa/verify-login', mfaLimiter, (req: Request, res: Response) => {
const result = verifyMfaLogin(req.body);
if (result.error) return res.status(result.status!).json({ error: result.error });
writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { mfa: true } });
setAuthCookie(res, result.token!, req);
setAuthCookie(res, result.token!);
res.json({ token: result.token, user: result.user });
});
+2 -5
View File
@@ -9,7 +9,6 @@ import { validateStringLengths } from '../middleware/validate';
import { checkPermission } from '../services/permissions';
import { AuthRequest } from '../types';
import { db } from '../db/database';
import { BLOCKED_EXTENSIONS } from '../services/fileService';
import {
verifyTripAccess,
listNotes,
@@ -42,10 +41,8 @@ const noteUpload = multer({
defParamCharset: 'utf8',
fileFilter: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
// Share the single BLOCKED_EXTENSIONS list from fileService so
// executable/script attachments can't sneak in via collab when the
// main uploader already rejects them.
if (BLOCKED_EXTENSIONS.includes(ext) || file.mimetype.includes('svg') || file.mimetype.includes('html') || file.mimetype.includes('javascript')) {
const BLOCKED = ['.svg', '.html', '.htm', '.xml', '.xhtml', '.js', '.jsx', '.ts', '.exe', '.bat', '.sh', '.cmd', '.msi', '.dll', '.com', '.vbs', '.ps1', '.php'];
if (BLOCKED.includes(ext) || file.mimetype.includes('svg') || file.mimetype.includes('html') || file.mimetype.includes('javascript')) {
const err: Error & { statusCode?: number } = new Error('File type not allowed');
err.statusCode = 400;
return cb(err);
+4 -4
View File
@@ -210,7 +210,7 @@ router.post('/:id/restore', authenticate, (req: Request, res: Response) => {
});
// Permanently delete from trash
router.delete('/:id/permanent', authenticate, async (req: Request, res: Response) => {
router.delete('/:id/permanent', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
@@ -222,13 +222,13 @@ router.delete('/:id/permanent', authenticate, async (req: Request, res: Response
const file = getDeletedFile(id, tripId);
if (!file) return res.status(404).json({ error: 'File not found in trash' });
await permanentDeleteFile(file);
permanentDeleteFile(file);
res.json({ success: true });
broadcast(tripId, 'file:deleted', { fileId: Number(id) }, req.headers['x-socket-id'] as string);
});
// Empty entire trash
router.delete('/trash/empty', authenticate, async (req: Request, res: Response) => {
router.delete('/trash/empty', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
@@ -237,7 +237,7 @@ router.delete('/trash/empty', authenticate, async (req: Request, res: Response)
if (!checkPermission('file_delete', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const deleted = await emptyTrash(tripId);
const deleted = emptyTrash(tripId);
res.json({ success: true, deleted });
});
-31
View File
@@ -205,37 +205,6 @@ oauthPublicRouter.post('/oauth/register', dcrLimiter, (req: Request, res: Respon
if (redirectUris.length === 0) {
return res.status(400).json({ error: 'invalid_redirect_uri', error_description: 'redirect_uris is required and must be a non-empty array' });
}
// OAuth 2.1 + RFC 8252: confidential web apps need HTTPS; public
// clients (MCP, native) are limited to loopback or a reverse-DNS
// private-use scheme. This rejects `http://evil.example` DCR payloads
// that today would otherwise be accepted since we previously only
// checked shape. Dangerous URL schemes (`javascript:`, `data:` etc.)
// are explicitly rejected — the authorize flow later 302s the
// browser to this URI, which with `javascript:` would execute
// attacker-controlled script under our redirect origin's context.
const DANGEROUS_SCHEMES = new Set([
'javascript:', 'data:', 'vbscript:', 'file:', 'blob:', 'about:', 'chrome:', 'chrome-extension:',
]);
const allowed = redirectUris.every((u) => {
try {
const url = new URL(u);
if (DANGEROUS_SCHEMES.has(url.protocol)) return false;
if (url.protocol === 'https:') return true;
if (url.protocol === 'http:' && (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]')) return true;
// RFC 8252 §7.1 private-use scheme: must be a reverse-DNS name
// (e.g. `com.example.myapp:/callback`). Requiring a dot in the
// scheme is a cheap heuristic that rules out bare `myapp:` and
// `x:` one-off schemes the spec explicitly discourages.
const schemeBody = url.protocol.slice(0, -1);
if (/^[a-z][a-z0-9+.-]*$/i.test(schemeBody) && schemeBody.includes('.')) return true;
return false;
} catch {
return false;
}
});
if (!allowed) {
return res.status(400).json({ error: 'invalid_redirect_uri', error_description: 'redirect_uris must be HTTPS, loopback HTTP, or a private custom scheme' });
}
const rawName = typeof body.client_name === 'string' ? body.client_name.trim().slice(0, 100) : '';
const clientName = rawName || 'MCP Client';
+1 -32
View File
@@ -9,7 +9,6 @@ import {
consumeAuthCode,
exchangeCodeForToken,
getUserInfo,
verifyIdToken,
findOrCreateUser,
touchLastLogin,
generateToken,
@@ -98,40 +97,10 @@ router.get('/callback', async (req: Request, res: Response) => {
return res.redirect(frontendUrl('/login?oidc_error=token_failed'));
}
// Strict id_token verification: signature via JWKS + iss + aud.
// Previously only the access_token was used to hit userinfo, so a
// compromised provider or MITM could supply a crafted userinfo
// response the server would blindly trust. When the id_token is
// missing from the token response (non-compliant provider) we still
// reject — an Authorization Code flow MUST return one per OIDC Core.
if (!tokenData.id_token) {
console.error('[OIDC] Token response missing id_token — refusing login');
return res.redirect(frontendUrl('/login?oidc_error=no_id_token'));
}
const idVerify = await verifyIdToken(
tokenData.id_token,
doc,
config.clientId,
config.issuer,
);
if (idVerify.ok !== true) {
const reason = 'error' in idVerify ? idVerify.error : 'unknown';
console.error('[OIDC] id_token verification failed:', reason);
return res.redirect(frontendUrl('/login?oidc_error=id_token_invalid'));
}
const userInfo = await getUserInfo(doc.userinfo_endpoint, tokenData.access_token);
if (!userInfo.email) {
return res.redirect(frontendUrl('/login?oidc_error=no_email'));
}
// Cross-check: the userinfo response must be for the same subject
// the id_token signed. Catches a compromised userinfo endpoint that
// speaks for a different principal than the id_token's claim.
const tokenSub = idVerify.claims.sub;
if (typeof tokenSub === 'string' && userInfo.sub && userInfo.sub !== tokenSub) {
console.error('[OIDC] userinfo.sub does not match id_token.sub — refusing login');
return res.redirect(frontendUrl('/login?oidc_error=subject_mismatch'));
}
const result = findOrCreateUser(userInfo, config, pending.inviteToken);
if ('error' in result) {
@@ -157,7 +126,7 @@ router.get('/exchange', (req: Request, res: Response) => {
const result = consumeAuthCode(code);
if ('error' in result) return res.status(400).json({ error: result.error });
setAuthCookie(res, result.token, req);
setAuthCookie(res, result.token);
res.json({ token: result.token });
});
+1 -76
View File
@@ -207,81 +207,6 @@ function startTripReminders(): void {
}, { timezone: tz });
}
// Todo due-date reminders: daily check at 9 AM for unchecked todos
// whose due_date falls within the next TODO_REMINDER_LEAD_DAYS days.
// Each todo gets reminded at most once per 24 h (tracked via
// todo_items.reminded_at) so the scheduler doesn't spam the user every
// morning leading up to the deadline.
const TODO_REMINDER_LEAD_DAYS = 3;
let todoReminderTask: ScheduledTask | null = null;
function startTodoReminders(): void {
if (todoReminderTask) { todoReminderTask.stop(); todoReminderTask = null; }
const { db } = require('./db/database');
const getSetting = (key: string) => (db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value;
const enabled = getSetting('notify_todo_due') !== 'false';
if (!enabled) {
const { logInfo: li } = require('./services/auditLog');
li('Todo due reminders: disabled in settings');
return;
}
const { logInfo: liSetup } = require('./services/auditLog');
liSetup(`Todo due reminders: enabled (lead ${TODO_REMINDER_LEAD_DAYS}d)`);
const tz = process.env.TZ || 'UTC';
todoReminderTask = cron.schedule('0 9 * * *', async () => {
try {
const { send } = require('./services/notificationService');
// Select unchecked todos with a due date inside the lead window
// that haven't been reminded in the last 24 hours. `due_date` is
// stored as a YYYY-MM-DD text; SQLite date() handles it directly.
const todos = db.prepare(`
SELECT ti.id, ti.trip_id, ti.name, ti.due_date, ti.assigned_user_id,
t.title AS trip_title, t.user_id AS trip_owner_id
FROM todo_items ti
JOIN trips t ON t.id = ti.trip_id
WHERE ti.checked = 0
AND ti.due_date IS NOT NULL
AND ti.due_date <> ''
AND date(ti.due_date) <= date('now', '+' || ? || ' days')
AND date(ti.due_date) >= date('now')
AND (ti.reminded_at IS NULL OR ti.reminded_at <= datetime('now', '-20 hours'))
`).all(TODO_REMINDER_LEAD_DAYS) as {
id: number; trip_id: number; name: string; due_date: string;
assigned_user_id: number | null; trip_title: string; trip_owner_id: number;
}[];
for (const todo of todos) {
const targetScope: 'user' | 'trip' = todo.assigned_user_id ? 'user' : 'trip';
const targetId = todo.assigned_user_id ?? todo.trip_id;
await send({
event: 'todo_due',
actorId: null,
scope: targetScope,
targetId,
params: {
todo: todo.name,
trip: todo.trip_title,
tripId: String(todo.trip_id),
due: todo.due_date,
},
}).catch(() => {});
db.prepare('UPDATE todo_items SET reminded_at = CURRENT_TIMESTAMP WHERE id = ?').run(todo.id);
}
const { logInfo: li } = require('./services/auditLog');
if (todos.length > 0) {
li(`Todo reminders sent for ${todos.length} item(s)`);
}
} catch (err: unknown) {
const { logError: le } = require('./services/auditLog');
le(`Todo reminder check failed: ${err instanceof Error ? err.message : err}`);
}
}, { timezone: tz });
}
// Version check: daily at 9 AM — notify admins if a new TREK release is available
let versionCheckTask: ScheduledTask | null = null;
@@ -355,4 +280,4 @@ function stop(): void {
if (trekPhotoCacheTask) { trekPhotoCacheTask.stop(); trekPhotoCacheTask = null; }
}
export { start, stop, startDemoReset, startTripReminders, startTodoReminders, startVersionCheck, startIdempotencyCleanup, startTrekPhotoCacheCleanup, loadSettings, saveSettings, VALID_INTERVALS };
export { start, stop, startDemoReset, startTripReminders, startVersionCheck, startIdempotencyCleanup, startTrekPhotoCacheCleanup, loadSettings, saveSettings, VALID_INTERVALS };
+23 -76
View File
@@ -15,9 +15,7 @@ import { decrypt_api_key, maybe_encrypt_api_key, encrypt_api_key } from './apiKe
import { createEphemeralToken } from './ephemeralTokens';
import { revokeUserSessions } from '../mcp';
import { startTripReminders } from '../scheduler';
import { verifyJwtAndLoadUser } from '../middleware/auth';
import { User } from '../types';
import { DEMO_EMAIL_PRIMARY, isDemoEmail } from './demo';
// ---------------------------------------------------------------------------
// Constants
@@ -177,46 +175,10 @@ export function normalizeBackupCode(input: string): string {
return String(input || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
}
// Legacy SHA-256 hex hash. Kept so existing stored hashes (from before
// the bcrypt migration) can still be verified in `matchBackupCode`
// without forcing every user to re-enrol their MFA device. New hashes
// are produced by `hashBackupCodeBcrypt` below.
export function hashBackupCode(input: string): string {
return crypto.createHash('sha256').update(normalizeBackupCode(input)).digest('hex');
}
const BCRYPT_BACKUP_COST = 10;
/**
* Hash a backup code with bcrypt for at-rest storage. Backup codes only
* have ~40 bits of entropy (8 hex chars) so a plain SHA-256 rainbow
* table cracks them in minutes if the DB ever leaks. bcrypt with a
* moderate cost raises that cost by ~3-4 orders of magnitude.
*/
export function hashBackupCodeBcrypt(input: string): string {
return bcrypt.hashSync(normalizeBackupCode(input), BCRYPT_BACKUP_COST);
}
/**
* Constant-time match of a plaintext backup code against a stored hash
* in either format (bcrypt or legacy SHA-256 hex). Used by login and
* password-reset flows; callers that need to CONSUME the matching
* entry should use this to find the index, then splice it out.
*/
export function matchBackupCode(plaintext: string, storedHash: string): boolean {
if (!storedHash) return false;
if (storedHash.startsWith('$2')) {
// bcrypt hash — compareSync is constant-time internally.
try { return bcrypt.compareSync(normalizeBackupCode(plaintext), storedHash); }
catch { return false; }
}
// Legacy SHA-256 hex. Compare the SHA-256 of the input against the
// stored hex with a constant-time comparator so timing can't leak.
const candidate = hashBackupCode(plaintext);
if (candidate.length !== storedHash.length) return false;
return crypto.timingSafeEqual(Buffer.from(candidate), Buffer.from(storedHash));
}
export function generateBackupCodes(count = MFA_BACKUP_CODE_COUNT): string[] {
const codes: string[] = [];
while (codes.length < count) {
@@ -298,7 +260,7 @@ export function getAppConfig(authenticatedUser: { id: number } | null) {
require_mfa: requireMfaRow?.value === 'true',
allowed_file_types: (db.prepare("SELECT value FROM app_settings WHERE key = 'allowed_file_types'").get() as { value: string } | undefined)?.value || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv',
demo_mode: isDemo,
demo_email: isDemo ? DEMO_EMAIL_PRIMARY : undefined,
demo_email: isDemo ? 'demo@trek.app' : undefined,
demo_password: isDemo ? 'demo12345' : undefined,
timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
notification_channel: notifChannel,
@@ -321,7 +283,7 @@ export function demoLogin(): { error?: string; status?: number; token?: string;
if (process.env.DEMO_MODE !== 'true') {
return { error: 'Not found', status: 404 };
}
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(DEMO_EMAIL_PRIMARY) as User | undefined;
const user = db.prepare('SELECT * FROM users WHERE email = ?').get('demo@trek.app') as User | undefined;
if (!user) return { error: 'Demo user not found', status: 500 };
const token = generateToken(user);
const safe = stripUserForClient(user) as Record<string, unknown>;
@@ -496,7 +458,7 @@ export function changePassword(
if (isOidcOnlyMode()) {
return { error: 'Password authentication is disabled.', status: 403 };
}
if (process.env.DEMO_MODE === 'true' && isDemoEmail(userEmail)) {
if (process.env.DEMO_MODE === 'true' && userEmail === 'demo@trek.app') {
return { error: 'Password change is disabled in demo mode.', status: 403 };
}
@@ -518,7 +480,7 @@ export function changePassword(
}
export function deleteAccount(userId: number, userEmail: string, userRole: string): { error?: string; status?: number; success?: boolean } {
if (process.env.DEMO_MODE === 'true' && isDemoEmail(userEmail)) {
if (process.env.DEMO_MODE === 'true' && userEmail === 'demo@trek.app') {
return { error: 'Account deletion is disabled in demo mode.', status: 403 };
}
if (userRole === 'admin') {
@@ -638,13 +600,11 @@ export function getSettings(userId: number): { error?: string; status?: number;
// Avatar
// ---------------------------------------------------------------------------
export async function saveAvatar(userId: number, filename: string) {
export function saveAvatar(userId: number, filename: string) {
const current = db.prepare('SELECT avatar FROM users WHERE id = ?').get(userId) as { avatar: string | null } | undefined;
if (current && current.avatar) {
// Fire-and-forget: leftover files are harmless; the DB update is
// the source of truth for which avatar is current.
const oldPath = path.join(avatarDir, current.avatar);
await fs.promises.rm(oldPath, { force: true }).catch(() => {});
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
}
db.prepare('UPDATE users SET avatar = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(filename, userId);
@@ -653,11 +613,11 @@ export async function saveAvatar(userId: number, filename: string) {
return { success: true, avatar_url: avatarUrl(updated || {}) };
}
export async function deleteAvatar(userId: number) {
export function deleteAvatar(userId: number) {
const current = db.prepare('SELECT avatar FROM users WHERE id = ?').get(userId) as { avatar: string | null } | undefined;
if (current && current.avatar) {
const filePath = path.join(avatarDir, current.avatar);
await fs.promises.rm(filePath, { force: true }).catch(() => {});
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
}
db.prepare('UPDATE users SET avatar = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(userId);
return { success: true };
@@ -905,7 +865,7 @@ export function getTravelStats(userId: number) {
// ---------------------------------------------------------------------------
export function setupMfa(userId: number, userEmail: string): { error?: string; status?: number; secret?: string; otpauth_url?: string; qrPromise?: Promise<string> } {
if (process.env.DEMO_MODE === 'true' && isDemoEmail(userEmail)) {
if (process.env.DEMO_MODE === 'true' && userEmail === 'demo@nomad.app') {
return { error: 'MFA is not available in demo mode.', status: 403 };
}
const row = db.prepare('SELECT mfa_enabled FROM users WHERE id = ?').get(userId) as { mfa_enabled: number } | undefined;
@@ -938,7 +898,7 @@ export function enableMfa(userId: number, code?: string): { error?: string; stat
return { error: 'Invalid verification code', status: 401 };
}
const backupCodes = generateBackupCodes();
const backupHashes = backupCodes.map(hashBackupCodeBcrypt);
const backupHashes = backupCodes.map(hashBackupCode);
const enc = encryptMfaSecret(pending);
db.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, mfa_backup_codes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(
enc,
@@ -954,7 +914,7 @@ export function disableMfa(
userEmail: string,
body: { password?: string; code?: string }
): { error?: string; status?: number; success?: boolean; mfa_enabled?: boolean } {
if (process.env.DEMO_MODE === 'true' && isDemoEmail(userEmail)) {
if (process.env.DEMO_MODE === 'true' && userEmail === 'demo@nomad.app') {
return { error: 'MFA cannot be changed in demo mode.', status: 403 };
}
const policy = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined;
@@ -1013,9 +973,8 @@ export function verifyMfaLogin(body: {
const okTotp = authenticator.verify({ token: tokenStr.replace(/\s/g, ''), secret });
if (!okTotp) {
const hashes = parseBackupCodeHashes(user.mfa_backup_codes);
// matchBackupCode handles both bcrypt and legacy SHA-256 hashes;
// any store older than the bcrypt migration keeps working.
const idx = hashes.findIndex((h) => matchBackupCode(tokenStr, h));
const candidateHash = hashBackupCode(tokenStr);
const idx = hashes.findIndex(h => h === candidateHash);
if (idx === -1) {
return { error: 'Invalid verification code', status: 401 };
}
@@ -1207,7 +1166,8 @@ export function resetPassword(body: {
const okTotp = authenticator.verify({ token: supplied.replace(/\s/g, ''), secret });
if (!okTotp) {
const hashes = parseBackupCodeHashes(user.mfa_backup_codes);
const idx = hashes.findIndex((h) => matchBackupCode(supplied, h));
const candidateHash = hashBackupCode(supplied);
const idx = hashes.findIndex(h => h === candidateHash);
if (idx === -1) return { error: 'Invalid MFA code', status: 401 };
backupCodeConsumedIndex = idx;
}
@@ -1233,16 +1193,6 @@ export function resetPassword(body: {
hashes.splice(backupCodeConsumedIndex, 1);
db.prepare('UPDATE users SET mfa_backup_codes = ? WHERE id = ?').run(JSON.stringify(hashes), user.id);
}
// Revoke every other credential class the user had. The
// password_version bump alone invalidates JWT cookie sessions, but
// MCP static tokens and OAuth 2.1 bearer tokens are separate stores
// that survive the bump unless we prune them here.
db.prepare('DELETE FROM mcp_tokens WHERE user_id = ?').run(user.id);
try {
db.prepare(
"UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE user_id = ? AND revoked_at IS NULL"
).run(user.id);
} catch { /* oauth_tokens table may not exist in very old installs */ }
})();
// Kick off any MCP/WS session cleanup — same hook the account-delete path uses.
@@ -1317,7 +1267,7 @@ export function createResourceToken(userId: number, purpose?: string): { error?:
export function isDemoUser(userId: number): boolean {
if (process.env.DEMO_MODE !== 'true') return false;
const user = db.prepare('SELECT email FROM users WHERE id = ?').get(userId) as { email: string } | undefined;
return isDemoEmail(user?.email);
return user?.email === 'demo@nomad.app';
}
export function verifyMcpToken(rawToken: string): User | null {
@@ -1335,15 +1285,12 @@ export function verifyMcpToken(rawToken: string): User | null {
return null;
}
/**
* Verify a JWT the same way `middleware/auth.ts#verifyJwtAndLoadUser`
* does including the `password_version` check so that stolen tokens
* lose access the moment the victim resets their password.
*
* This is the single entry point every non-cookie JWT verification path
* (MCP bearer, WebSocket handshake, file-download query tokens, photo
* route) should go through.
*/
export function verifyJwtToken(token: string): User | null {
return verifyJwtAndLoadUser(token);
try {
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number };
const user = db.prepare('SELECT id, username, email, role FROM users WHERE id = ?').get(decoded.id) as User | undefined;
return user || null;
} catch {
return null;
}
}
-14
View File
@@ -5,7 +5,6 @@ import fs from 'fs';
import Database from 'better-sqlite3';
import { db, closeDb, reinitialize } from '../db/database';
import * as scheduler from '../scheduler';
import { invalidatePermissionsCache } from './permissions';
// ---------------------------------------------------------------------------
// Paths
@@ -247,12 +246,6 @@ export async function restoreFromZip(zipPath: string): Promise<RestoreResult> {
}
} finally {
reinitialize();
// The restored DB has different permission-override rows from
// the pre-restore DB, but our process-local permissions cache
// still holds the pre-restore state. Any request using a cached
// permission would decide against the wrong grants until the
// next restart. Dropping the cache forces a fresh read.
invalidatePermissionsCache();
}
fs.rmSync(extractDir, { recursive: true, force: true });
@@ -260,13 +253,6 @@ export async function restoreFromZip(zipPath: string): Promise<RestoreResult> {
} catch (err: unknown) {
console.error('Restore error:', err);
if (fs.existsSync(extractDir)) fs.rmSync(extractDir, { recursive: true, force: true });
// Belt-and-braces: the inner `finally` already drops the permissions
// cache after a successful swap, but if the extraction/copy step
// itself threw before the DB swap even started, the cache wasn't
// stale anyway. Invalidating here too costs nothing and guarantees
// we never serve cached permissions that don't match the DB state
// we leave the process in after a failed restore.
try { invalidatePermissionsCache(); } catch { /* best-effort */ }
throw err;
}
}
+7 -30
View File
@@ -1,32 +1,9 @@
import { Request, Response } from 'express';
import { Response } from 'express';
const COOKIE_NAME = 'trek_session';
/**
* Decide whether the session cookie should carry the `Secure` flag.
*
* We previously only derived this from `NODE_ENV=production` or
* `FORCE_HTTPS=true`. That left behind a common self-host setup:
* TREK running behind Traefik / Caddy / Cloudflare Tunnel with
* `NODE_ENV=development` locally and no `FORCE_HTTPS` the cookie
* went out without `Secure`, even though the public leg was https.
*
* Now we also honour `req.secure`, which Express derives from
* `X-Forwarded-Proto` once `trust proxy` is set (TREK sets it to `1`
* in production automatically). If Express sees the request was TLS
* on the outermost hop, the cookie is `Secure`. `COOKIE_SECURE=false`
* remains the explicit escape hatch for plain-HTTP LAN testing.
*/
export function cookieOptions(clear = false, req?: Request) {
if (process.env.COOKIE_SECURE === 'false') {
return buildOptions(clear, false);
}
const envSecure = process.env.NODE_ENV === 'production' || process.env.FORCE_HTTPS === 'true';
const requestSecure = req?.secure === true;
return buildOptions(clear, envSecure || requestSecure);
}
function buildOptions(clear: boolean, secure: boolean) {
export function cookieOptions(clear = false) {
const secure = process.env.COOKIE_SECURE !== 'false' && (process.env.NODE_ENV === 'production' || process.env.FORCE_HTTPS === 'true');
return {
httpOnly: true,
secure,
@@ -36,10 +13,10 @@ function buildOptions(clear: boolean, secure: boolean) {
};
}
export function setAuthCookie(res: Response, token: string, req?: Request): void {
res.cookie(COOKIE_NAME, token, cookieOptions(false, req));
export function setAuthCookie(res: Response, token: string): void {
res.cookie(COOKIE_NAME, token, cookieOptions());
}
export function clearAuthCookie(res: Response, req?: Request): void {
res.clearCookie(COOKIE_NAME, cookieOptions(true, req));
export function clearAuthCookie(res: Response): void {
res.clearCookie(COOKIE_NAME, cookieOptions(true));
}
+4 -6
View File
@@ -166,7 +166,7 @@ export function deleteDay(id: string | number) {
export interface DayAccommodation {
id: number;
trip_id: number;
place_id: number | null;
place_id: number;
start_day_id: number;
end_day_id: number;
check_in: string | null;
@@ -180,7 +180,7 @@ function getAccommodationWithPlace(id: number | bigint) {
return db.prepare(`
SELECT a.*, p.name as place_name, p.address as place_address, p.image_url as place_image, p.lat as place_lat, p.lng as place_lng
FROM day_accommodations a
LEFT JOIN places p ON a.place_id = p.id
JOIN places p ON a.place_id = p.id
WHERE a.id = ?
`).get(id);
}
@@ -191,11 +191,9 @@ function getAccommodationWithPlace(id: number | bigint) {
export function listAccommodations(tripId: string | number) {
return db.prepare(`
SELECT a.*, p.name as place_name, p.address as place_address, p.image_url as place_image, p.lat as place_lat, p.lng as place_lng,
r.title as reservation_title
SELECT a.*, p.name as place_name, p.address as place_address, p.image_url as place_image, p.lat as place_lat, p.lng as place_lng
FROM day_accommodations a
LEFT JOIN places p ON a.place_id = p.id
LEFT JOIN reservations r ON r.accommodation_id = a.id
JOIN places p ON a.place_id = p.id
WHERE a.trip_id = ?
ORDER BY a.created_at ASC
`).all(tripId);
-24
View File
@@ -1,24 +0,0 @@
// Central registry of demo-user email addresses.
//
// Historical: the demo account was seeded as "demo@trek.app" (see
// authService.demoLogin), but several guards — demoUploadBlock in
// middleware/auth.ts, the MFA/backup-code bypasses in authService —
// were still checking the pre-rename "demo@nomad.app" string, so they
// either never fired or silently diverged between call sites. Routing
// every check through this constant keeps them aligned.
export const DEMO_EMAIL_PRIMARY = 'demo@trek.app';
/**
* All email addresses that should be treated as the demo account.
* Includes the historical `demo@nomad.app` identifier so instances that
* upgraded in place without resetting the DB still hit demo-mode guards.
*/
export const DEMO_EMAILS: ReadonlySet<string> = new Set([
DEMO_EMAIL_PRIMARY,
'demo@nomad.app',
]);
export function isDemoEmail(email: string | null | undefined): boolean {
return !!email && DEMO_EMAILS.has(email);
}
+18 -46
View File
@@ -1,8 +1,9 @@
import path from 'path';
import fs from 'fs';
import jwt from 'jsonwebtoken';
import { JWT_SECRET } from '../config';
import { db, canAccessTrip } from '../db/database';
import { consumeEphemeralToken } from './ephemeralTokens';
import { verifyJwtAndLoadUser } from '../middleware/auth';
import { TripFile } from '../types';
// ---------------------------------------------------------------------------
@@ -11,18 +12,7 @@ import { TripFile } from '../types';
export const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
export const DEFAULT_ALLOWED_EXTENSIONS = 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv,pkpass';
// Single authoritative blocklist for every file-upload surface (main
// file manager + collab attachments). When the admin setting
// `allowed_file_types` is `*`, this list is still enforced so the
// wildcard doesn't silently admit executables/scripts.
export const BLOCKED_EXTENSIONS = [
// Server-rendered / scripted content that could XSS a viewer
'.svg', '.html', '.htm', '.xml', '.xhtml',
// Scripts
'.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.php', '.py', '.rb', '.pl',
// Executables
'.exe', '.bat', '.sh', '.cmd', '.msi', '.dll', '.com', '.vbs', '.ps1', '.app',
];
export const BLOCKED_EXTENSIONS = ['.svg', '.html', '.htm', '.xml'];
export const filesDir = path.join(__dirname, '../../uploads/files');
// ---------------------------------------------------------------------------
@@ -78,12 +68,12 @@ export function authenticateDownload(bearerToken: string | undefined, queryToken
}
if (bearerToken) {
// Use the shared helper so the password_version gate applies here too;
// previously this bypassed the check and stolen download tokens stayed
// valid across a password reset.
const user = verifyJwtAndLoadUser(bearerToken);
if (!user) return { error: 'Invalid or expired token', status: 401 };
return { userId: user.id };
try {
const decoded = jwt.verify(bearerToken, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number };
return { userId: decoded.id };
} catch {
return { error: 'Invalid or expired token', status: 401 };
}
}
const uid = consumeEphemeralToken(queryToken!, 'download');
@@ -203,42 +193,24 @@ export function restoreFile(id: string | number) {
return formatFile(restored);
}
export async function permanentDeleteFile(file: TripFile): Promise<void> {
export function permanentDeleteFile(file: TripFile) {
const { resolved } = resolveFilePath(file.filename);
// `force: true` swallows ENOENT, replacing the prior existsSync+unlink
// double-call that blocked the event loop twice per deletion. Only
// drop the DB row when the on-disk unlink either succeeded or the
// file was already gone — otherwise a permission / ENOSPC failure
// would orphan the bytes on disk with no DB pointer left to clean it.
try {
await fs.promises.rm(resolved, { force: true });
} catch (e) {
console.error(`[files] unlink failed for ${file.filename}, keeping DB row:`, e);
throw e;
if (fs.existsSync(resolved)) {
try { fs.unlinkSync(resolved); } catch (e) { console.error('Error deleting file:', e); }
}
db.prepare('DELETE FROM trip_files WHERE id = ?').run(file.id);
}
export async function emptyTrash(tripId: string | number): Promise<number> {
export function emptyTrash(tripId: string | number): number {
const trashed = db.prepare('SELECT * FROM trip_files WHERE trip_id = ? AND deleted_at IS NOT NULL').all(tripId) as TripFile[];
// Collect successful IDs separately so we only DELETE rows whose disk
// content was actually removed — failing unlinks keep their DB row
// and a retry via the single-file delete path can try again.
const successfullyUnlinked: number[] = [];
await Promise.all(trashed.map(async (file) => {
for (const file of trashed) {
const { resolved } = resolveFilePath(file.filename);
try {
await fs.promises.rm(resolved, { force: true });
successfullyUnlinked.push(Number(file.id));
} catch (e) {
console.error(`[files] unlink failed for ${file.filename}, keeping DB row:`, e);
if (fs.existsSync(resolved)) {
try { fs.unlinkSync(resolved); } catch (e) { console.error('Error deleting file:', e); }
}
}));
if (successfullyUnlinked.length > 0) {
const placeholders = successfullyUnlinked.map(() => '?').join(',');
db.prepare(`DELETE FROM trip_files WHERE id IN (${placeholders})`).run(...successfullyUnlinked);
}
return successfullyUnlinked.length;
db.prepare('DELETE FROM trip_files WHERE trip_id = ? AND deleted_at IS NOT NULL').run(tripId);
return trashed.length;
}
// ---------------------------------------------------------------------------
@@ -679,10 +679,8 @@ export async function streamSynologyAsset(
//size: 'sm' 240px| 'm' 320px| 'xl' 1280px| 'preview' ?
// Use Thumbnail API for both thumbnail and original — avoids serving raw HEIC files
// (original uses xl size to get a full-resolution JPEG-compatible render).
// Thumbnail default is 'm' (~320px) — 'sm' (240px) looked pixelated on
// the journey grid on retina screens.
const resolvedSize = kind === 'original' ? 'xl' : (size || 'm');
// (original uses xl size to get a full-resolution JPEG-compatible render)
const resolvedSize = kind === 'original' ? 'xl' : (size || 'sm');
const params = new URLSearchParams({
api: 'SYNO.Foto.Thumbnail',
method: 'get',
@@ -9,7 +9,6 @@ export type NotifEventType =
| 'trip_invite'
| 'booking_change'
| 'trip_reminder'
| 'todo_due'
| 'vacay_invite'
| 'photos_shared'
| 'collab_message'
@@ -30,7 +29,6 @@ const IMPLEMENTED_COMBOS: Record<NotifEventType, NotifChannel[]> = {
trip_invite: ['inapp', 'email', 'webhook', 'ntfy'],
booking_change: ['inapp', 'email', 'webhook', 'ntfy'],
trip_reminder: ['inapp', 'email', 'webhook', 'ntfy'],
todo_due: ['inapp', 'email', 'webhook', 'ntfy'],
vacay_invite: ['inapp', 'email', 'webhook', 'ntfy'],
photos_shared: ['inapp', 'email', 'webhook', 'ntfy'],
collab_message: ['inapp', 'email', 'webhook', 'ntfy'],
@@ -82,13 +82,6 @@ const EVENT_NOTIFICATION_CONFIG: Record<string, EventNotifConfig> = {
navigateTextKey: 'notif.action.view_trip',
navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null),
},
todo_due: {
inAppType: 'navigate',
titleKey: 'notif.todo_due.title',
textKey: 'notif.todo_due.text',
navigateTextKey: 'notif.action.view_trip',
navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null),
},
vacay_invite: {
inAppType: 'navigate',
titleKey: 'notif.vacay_invite.title',
-15
View File
@@ -100,7 +100,6 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
trip_invite: p => ({ title: `Trip invite: "${p.trip}"`, body: `${p.actor} invited ${p.invitee || 'a member'} to the trip "${p.trip}".` }),
booking_change: p => ({ title: `New booking: ${p.booking}`, body: `${p.actor} added a new ${p.type} "${p.booking}" to "${p.trip}".` }),
trip_reminder: p => ({ title: `Trip reminder: ${p.trip}`, body: `Your trip "${p.trip}" is coming up soon!` }),
todo_due: p => ({ title: `To-do due: ${p.todo}`, body: `"${p.todo}" in "${p.trip}" is due on ${p.due}.` }),
vacay_invite: p => ({ title: 'Vacay Fusion Invite', body: `${p.actor} invited you to fuse vacation plans. Open TREK to accept or decline.` }),
photos_shared: p => ({ title: `${p.count} photos shared`, body: `${p.actor} shared ${p.count} photo(s) in "${p.trip}".` }),
collab_message: p => ({ title: `New message in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
@@ -112,7 +111,6 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
trip_invite: p => ({ title: `Einladung zu "${p.trip}"`, body: `${p.actor} hat ${p.invitee || 'ein Mitglied'} zur Reise "${p.trip}" eingeladen.` }),
booking_change: p => ({ title: `Neue Buchung: ${p.booking}`, body: `${p.actor} hat eine neue Buchung "${p.booking}" (${p.type}) zu "${p.trip}" hinzugefügt.` }),
trip_reminder: p => ({ title: `Reiseerinnerung: ${p.trip}`, body: `Deine Reise "${p.trip}" steht bald an!` }),
todo_due: p => ({ title: `Aufgabe fällig: ${p.todo}`, body: `"${p.todo}" in "${p.trip}" ist am ${p.due} fällig.` }),
vacay_invite: p => ({ title: 'Vacay Fusion-Einladung', body: `${p.actor} hat dich eingeladen, Urlaubspläne zu fusionieren. Öffne TREK um anzunehmen oder abzulehnen.` }),
photos_shared: p => ({ title: `${p.count} Fotos geteilt`, body: `${p.actor} hat ${p.count} Foto(s) in "${p.trip}" geteilt.` }),
collab_message: p => ({ title: `Neue Nachricht in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
@@ -124,7 +122,6 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
trip_invite: p => ({ title: `Invitation à "${p.trip}"`, body: `${p.actor} a invité ${p.invitee || 'un membre'} au voyage "${p.trip}".` }),
booking_change: p => ({ title: `Nouvelle réservation : ${p.booking}`, body: `${p.actor} a ajouté une réservation "${p.booking}" (${p.type}) à "${p.trip}".` }),
trip_reminder: p => ({ title: `Rappel de voyage : ${p.trip}`, body: `Votre voyage "${p.trip}" approche !` }),
todo_due: p => ({ title: `Tâche à échéance : ${p.todo}`, body: `"${p.todo}" dans "${p.trip}" est due le ${p.due}.` }),
vacay_invite: p => ({ title: 'Invitation Vacay Fusion', body: `${p.actor} vous invite à fusionner les plans de vacances. Ouvrez TREK pour accepter ou refuser.` }),
photos_shared: p => ({ title: `${p.count} photos partagées`, body: `${p.actor} a partagé ${p.count} photo(s) dans "${p.trip}".` }),
collab_message: p => ({ title: `Nouveau message dans "${p.trip}"`, body: `${p.actor} : ${p.preview}` }),
@@ -136,7 +133,6 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
trip_invite: p => ({ title: `Invitación a "${p.trip}"`, body: `${p.actor} invitó a ${p.invitee || 'un miembro'} al viaje "${p.trip}".` }),
booking_change: p => ({ title: `Nueva reserva: ${p.booking}`, body: `${p.actor} añadió una reserva "${p.booking}" (${p.type}) a "${p.trip}".` }),
trip_reminder: p => ({ title: `Recordatorio: ${p.trip}`, body: `¡Tu viaje "${p.trip}" se acerca!` }),
todo_due: p => ({ title: `Tarea pendiente: ${p.todo}`, body: `"${p.todo}" en "${p.trip}" vence el ${p.due}.` }),
vacay_invite: p => ({ title: 'Invitación Vacay Fusion', body: `${p.actor} te invitó a fusionar planes de vacaciones. Abre TREK para aceptar o rechazar.` }),
photos_shared: p => ({ title: `${p.count} fotos compartidas`, body: `${p.actor} compartió ${p.count} foto(s) en "${p.trip}".` }),
collab_message: p => ({ title: `Nuevo mensaje en "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
@@ -148,7 +144,6 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
trip_invite: p => ({ title: `Uitnodiging voor "${p.trip}"`, body: `${p.actor} heeft ${p.invitee || 'een lid'} uitgenodigd voor de reis "${p.trip}".` }),
booking_change: p => ({ title: `Nieuwe boeking: ${p.booking}`, body: `${p.actor} heeft een boeking "${p.booking}" (${p.type}) toegevoegd aan "${p.trip}".` }),
trip_reminder: p => ({ title: `Reisherinnering: ${p.trip}`, body: `Je reis "${p.trip}" komt eraan!` }),
todo_due: p => ({ title: `Taak verloopt: ${p.todo}`, body: `"${p.todo}" in "${p.trip}" verloopt op ${p.due}.` }),
vacay_invite: p => ({ title: 'Vacay Fusion uitnodiging', body: `${p.actor} nodigt je uit om vakantieplannen te fuseren. Open TREK om te accepteren of af te wijzen.` }),
photos_shared: p => ({ title: `${p.count} foto's gedeeld`, body: `${p.actor} heeft ${p.count} foto('s) gedeeld in "${p.trip}".` }),
collab_message: p => ({ title: `Nieuw bericht in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
@@ -160,7 +155,6 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
trip_invite: p => ({ title: `Приглашение в "${p.trip}"`, body: `${p.actor} пригласил ${p.invitee || 'участника'} в поездку "${p.trip}".` }),
booking_change: p => ({ title: `Новое бронирование: ${p.booking}`, body: `${p.actor} добавил бронирование "${p.booking}" (${p.type}) в "${p.trip}".` }),
trip_reminder: p => ({ title: `Напоминание: ${p.trip}`, body: `Ваша поездка "${p.trip}" скоро начнётся!` }),
todo_due: p => ({ title: `Задача к сроку: ${p.todo}`, body: `"${p.todo}" в поездке "${p.trip}" — срок ${p.due}.` }),
vacay_invite: p => ({ title: 'Приглашение Vacay Fusion', body: `${p.actor} приглашает вас объединить планы отпуска. Откройте TREK для подтверждения.` }),
photos_shared: p => ({ title: `${p.count} фото`, body: `${p.actor} поделился ${p.count} фото в "${p.trip}".` }),
collab_message: p => ({ title: `Новое сообщение в "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
@@ -172,7 +166,6 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
trip_invite: p => ({ title: `邀请加入"${p.trip}"`, body: `${p.actor} 邀请了 ${p.invitee || '成员'} 加入旅行"${p.trip}"。` }),
booking_change: p => ({ title: `新预订:${p.booking}`, body: `${p.actor} 在"${p.trip}"中添加了预订"${p.booking}"${p.type})。` }),
trip_reminder: p => ({ title: `旅行提醒:${p.trip}`, body: `你的旅行"${p.trip}"即将开始!` }),
todo_due: p => ({ title: `待办事项即将到期:${p.todo}`, body: `"${p.trip}" 中的"${p.todo}"将于 ${p.due} 到期。` }),
vacay_invite: p => ({ title: 'Vacay 融合邀请', body: `${p.actor} 邀请你合并假期计划。打开 TREK 接受或拒绝。` }),
photos_shared: p => ({ title: `${p.count} 张照片已分享`, body: `${p.actor} 在"${p.trip}"中分享了 ${p.count} 张照片。` }),
collab_message: p => ({ title: `"${p.trip}"中的新消息`, body: `${p.actor}${p.preview}` }),
@@ -184,7 +177,6 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
trip_invite: p => ({ title: `邀請加入「${p.trip}`, body: `${p.actor} 邀請了 ${p.invitee || '成員'} 加入行程「${p.trip}」。` }),
booking_change: p => ({ title: `新預訂:${p.booking}`, body: `${p.actor} 在「${p.trip}」中新增了預訂「${p.booking}」(${p.type})。` }),
trip_reminder: p => ({ title: `行程提醒:${p.trip}`, body: `您的行程「${p.trip}」即將開始!` }),
todo_due: p => ({ title: `待辦事項即將到期:${p.todo}`, body: `${p.trip}」中的「${p.todo}」將於 ${p.due} 到期。` }),
vacay_invite: p => ({ title: 'Vacay 融合邀請', body: `${p.actor} 邀請您合併假期計畫。開啟 TREK 以接受或拒絕。` }),
photos_shared: p => ({ title: `已分享 ${p.count} 張照片`, body: `${p.actor} 在「${p.trip}」中分享了 ${p.count} 張照片。` }),
collab_message: p => ({ title: `${p.trip}」中的新訊息`, body: `${p.actor}${p.preview}` }),
@@ -196,7 +188,6 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
trip_invite: p => ({ title: `دعوة إلى "${p.trip}"`, body: `${p.actor} دعا ${p.invitee || 'عضو'} إلى الرحلة "${p.trip}".` }),
booking_change: p => ({ title: `حجز جديد: ${p.booking}`, body: `${p.actor} أضاف حجز "${p.booking}" (${p.type}) إلى "${p.trip}".` }),
trip_reminder: p => ({ title: `تذكير: ${p.trip}`, body: `رحلتك "${p.trip}" تقترب!` }),
todo_due: p => ({ title: `مهمة مستحقة: ${p.todo}`, body: `"${p.todo}" في "${p.trip}" مستحقة في ${p.due}.` }),
vacay_invite: p => ({ title: 'دعوة دمج الإجازة', body: `${p.actor} يدعوك لدمج خطط الإجازة. افتح TREK للقبول أو الرفض.` }),
photos_shared: p => ({ title: `${p.count} صور مشتركة`, body: `${p.actor} شارك ${p.count} صورة في "${p.trip}".` }),
collab_message: p => ({ title: `رسالة جديدة في "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
@@ -208,7 +199,6 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
trip_invite: p => ({ title: `Convite para "${p.trip}"`, body: `${p.actor} convidou ${p.invitee || 'um membro'} para a viagem "${p.trip}".` }),
booking_change: p => ({ title: `Nova reserva: ${p.booking}`, body: `${p.actor} adicionou uma reserva "${p.booking}" (${p.type}) em "${p.trip}".` }),
trip_reminder: p => ({ title: `Lembrete: ${p.trip}`, body: `Sua viagem "${p.trip}" está chegando!` }),
todo_due: p => ({ title: `Tarefa com vencimento: ${p.todo}`, body: `"${p.todo}" em "${p.trip}" vence em ${p.due}.` }),
vacay_invite: p => ({ title: 'Convite Vacay Fusion', body: `${p.actor} convidou você para fundir planos de férias. Abra o TREK para aceitar ou recusar.` }),
photos_shared: p => ({ title: `${p.count} fotos compartilhadas`, body: `${p.actor} compartilhou ${p.count} foto(s) em "${p.trip}".` }),
collab_message: p => ({ title: `Nova mensagem em "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
@@ -220,7 +210,6 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
trip_invite: p => ({ title: `Pozvánka do "${p.trip}"`, body: `${p.actor} pozval ${p.invitee || 'člena'} na výlet "${p.trip}".` }),
booking_change: p => ({ title: `Nová rezervace: ${p.booking}`, body: `${p.actor} přidal rezervaci "${p.booking}" (${p.type}) k "${p.trip}".` }),
trip_reminder: p => ({ title: `Připomínka výletu: ${p.trip}`, body: `Váš výlet "${p.trip}" se blíží!` }),
todo_due: p => ({ title: `Úkol se blíží: ${p.todo}`, body: `"${p.todo}" ve výletě "${p.trip}" má termín ${p.due}.` }),
vacay_invite: p => ({ title: 'Pozvánka Vacay Fusion', body: `${p.actor} vás pozval ke spojení dovolenkových plánů. Otevřete TREK pro přijetí nebo odmítnutí.` }),
photos_shared: p => ({ title: `${p.count} sdílených fotek`, body: `${p.actor} sdílel ${p.count} foto v "${p.trip}".` }),
collab_message: p => ({ title: `Nová zpráva v "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
@@ -232,7 +221,6 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
trip_invite: p => ({ title: `Meghívó a(z) "${p.trip}" utazásra`, body: `${p.actor} meghívta ${p.invitee || 'egy tagot'} a(z) "${p.trip}" utazásra.` }),
booking_change: p => ({ title: `Új foglalás: ${p.booking}`, body: `${p.actor} hozzáadott egy "${p.booking}" (${p.type}) foglalást a(z) "${p.trip}" utazáshoz.` }),
trip_reminder: p => ({ title: `Utazás emlékeztető: ${p.trip}`, body: `A(z) "${p.trip}" utazás hamarosan kezdődik!` }),
todo_due: p => ({ title: `Teendő esedékes: ${p.todo}`, body: `"${p.todo}" (${p.trip}) határideje: ${p.due}.` }),
vacay_invite: p => ({ title: 'Vacay Fusion meghívó', body: `${p.actor} meghívott a nyaralási tervek összevonásához. Nyissa meg a TREK-et az elfogadáshoz vagy elutasításhoz.` }),
photos_shared: p => ({ title: `${p.count} fotó megosztva`, body: `${p.actor} ${p.count} fotót osztott meg a(z) "${p.trip}" utazásban.` }),
collab_message: p => ({ title: `Új üzenet a(z) "${p.trip}" utazásban`, body: `${p.actor}: ${p.preview}` }),
@@ -244,7 +232,6 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
trip_invite: p => ({ title: `Invito a "${p.trip}"`, body: `${p.actor} ha invitato ${p.invitee || 'un membro'} al viaggio "${p.trip}".` }),
booking_change: p => ({ title: `Nuova prenotazione: ${p.booking}`, body: `${p.actor} ha aggiunto una prenotazione "${p.booking}" (${p.type}) a "${p.trip}".` }),
trip_reminder: p => ({ title: `Promemoria viaggio: ${p.trip}`, body: `Il tuo viaggio "${p.trip}" si avvicina!` }),
todo_due: p => ({ title: `Attività in scadenza: ${p.todo}`, body: `"${p.todo}" in "${p.trip}" scade il ${p.due}.` }),
vacay_invite: p => ({ title: 'Invito Vacay Fusion', body: `${p.actor} ti ha invitato a fondere i piani vacanza. Apri TREK per accettare o rifiutare.` }),
photos_shared: p => ({ title: `${p.count} foto condivise`, body: `${p.actor} ha condiviso ${p.count} foto in "${p.trip}".` }),
collab_message: p => ({ title: `Nuovo messaggio in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
@@ -256,7 +243,6 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
trip_invite: p => ({ title: `Zaproszenie do "${p.trip}"`, body: `${p.actor} zaprosił ${p.invitee || 'członka'} do podróży "${p.trip}".` }),
booking_change: p => ({ title: `Nowa rezerwacja: ${p.booking}`, body: `${p.actor} dodał rezerwację "${p.booking}" (${p.type}) do "${p.trip}".` }),
trip_reminder: p => ({ title: `Przypomnienie o podróży: ${p.trip}`, body: `Twoja podróż "${p.trip}" zbliża się!` }),
todo_due: p => ({ title: `Zadanie z terminem: ${p.todo}`, body: `"${p.todo}" w "${p.trip}" — termin ${p.due}.` }),
vacay_invite: p => ({ title: 'Zaproszenie Vacay Fusion', body: `${p.actor} zaprosił Cię do połączenia planów urlopowych. Otwórz TREK, aby zaakceptować lub odrzucić.` }),
photos_shared: p => ({ title: `${p.count} zdjęć udostępnionych`, body: `${p.actor} udostępnił ${p.count} zdjęcie/zdjęcia w "${p.trip}".` }),
collab_message: p => ({ title: `Nowa wiadomość w "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
@@ -268,7 +254,6 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
trip_invite: p => ({ title: `Undangan perjalanan: "${p.trip}"`, body: `${p.actor} mengundang ${p.invitee || 'seorang anggota'} ke perjalanan "${p.trip}".` }),
booking_change: p => ({ title: `Pemesanan baru: ${p.booking}`, body: `${p.actor} menambahkan "${p.booking}" (${p.type}) baru ke "${p.trip}".` }),
trip_reminder: p => ({ title: `Pengingat perjalanan: ${p.trip}`, body: `Perjalanan Anda "${p.trip}" akan segera tiba!` }),
todo_due: p => ({ title: `Tugas jatuh tempo: ${p.todo}`, body: `"${p.todo}" di "${p.trip}" jatuh tempo pada ${p.due}.` }),
vacay_invite: p => ({ title: 'Undangan Penggabungan Vacay', body: `${p.actor} mengundang Anda untuk menggabungkan rencana liburan. Buka TREK untuk menerima atau menolak.` }),
photos_shared: p => ({ title: `${p.count} foto dibagikan`, body: `${p.actor} membagikan ${p.count} foto di "${p.trip}".` }),
collab_message: p => ({ title: `Pesan baru di "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
+3 -9
View File
@@ -582,16 +582,10 @@ export function validateAuthorizeRequest(
return { valid: false, error: 'invalid_redirect_uri', error_description: 'redirect_uri does not match any registered URI' };
}
// RFC 8707 resource indicator: if provided, must identify the TREK
// MCP endpoint exactly. If the client didn't supply `resource`, we
// bind the token to the MCP endpoint by default — previously this
// left `audience = null`, and the audience-bind check on MCP requests
// then treated a null audience as "valid for any resource".
// RFC 8707 resource indicator: if provided, must identify the TREK MCP endpoint exactly
const mcpResource = `${(getAppUrl() || '').replace(/\/+$/, '')}/mcp`;
const resource = params.resource
? params.resource.replace(/\/+$/, '')
: mcpResource;
if (resource !== mcpResource) {
const resource = params.resource ? params.resource.replace(/\/+$/, '') : null;
if (resource !== null && resource !== mcpResource) {
return { valid: false, error: 'invalid_target', error_description: 'Requested resource must be the TREK MCP endpoint' };
}
+13 -130
View File
@@ -14,8 +14,6 @@ export interface OidcDiscoveryDoc {
authorization_endpoint: string;
token_endpoint: string;
userinfo_endpoint: string;
jwks_uri?: string;
issuer?: string;
_issuer?: string;
}
@@ -140,12 +138,6 @@ export async function discover(issuer: string, discoveryUrl?: string | null): Pr
const res = await fetch(url);
if (!res.ok) throw new Error('Failed to fetch OIDC discovery document');
const doc = (await res.json()) as OidcDiscoveryDoc;
// Validate that the discovery doc's issuer matches the operator-configured
// one. A MITM or compromised doc could otherwise supply a crafted issuer
// that passes jwt.verify() because we used doc.issuer as the expected value.
if (doc.issuer && doc.issuer !== issuer) {
throw new Error(`OIDC discovery issuer mismatch: expected "${issuer}", got "${doc.issuer}"`);
}
doc._issuer = url;
discoveryCache = doc;
discoveryCacheTime = Date.now();
@@ -229,102 +221,6 @@ export async function getUserInfo(userinfoEndpoint: string, accessToken: string)
return (await res.json()) as OidcUserInfo;
}
// ---------------------------------------------------------------------------
// id_token verification (signature + iss + aud + exp)
// ---------------------------------------------------------------------------
// 5 minute JWKS cache — short enough to pick up key rotation within a
// reasonable window, long enough that normal login flow doesn't fetch
// JWKS on every callback.
const JWKS_TTL_MS = 5 * 60 * 1000;
type JwksEntry = { keys: Array<Record<string, unknown>>; fetchedAt: number };
const jwksCache = new Map<string, JwksEntry>();
async function fetchJwks(jwksUri: string): Promise<Array<Record<string, unknown>>> {
const cached = jwksCache.get(jwksUri);
if (cached && Date.now() - cached.fetchedAt < JWKS_TTL_MS) return cached.keys;
const res = await fetch(jwksUri);
if (!res.ok) throw new Error(`JWKS fetch failed: HTTP ${res.status}`);
const json = (await res.json()) as { keys?: Array<Record<string, unknown>> };
const keys = json.keys ?? [];
jwksCache.set(jwksUri, { keys, fetchedAt: Date.now() });
return keys;
}
function base64UrlDecode(input: string): Buffer {
const padded = input.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (input.length % 4)) % 4);
return Buffer.from(padded, 'base64');
}
/**
* Verify an OIDC id_token end-to-end: signature against the provider's
* JWKS, issuer match, audience match, and exp/nbf. Does NOT verify a
* nonce the server doesn't currently send one in the auth request;
* when that's added, pass the expected nonce here and check `claims.nonce`.
*
* Returning the claims lets callers cross-check `sub` / `email` against
* the userinfo response. A mismatch would mean the provider's userinfo
* endpoint is speaking for a different subject than the id_token a
* classic IdP-side compromise signal worth refusing login over.
*/
export async function verifyIdToken(
idToken: string,
doc: OidcDiscoveryDoc,
clientId: string,
expectedIssuer: string,
): Promise<{ ok: true; claims: Record<string, unknown> } | { ok: false; error: string }> {
if (!doc.jwks_uri) return { ok: false, error: 'no_jwks_uri' };
const parts = idToken.split('.');
if (parts.length !== 3) return { ok: false, error: 'malformed_token' };
let header: { kid?: string; alg?: string };
try { header = JSON.parse(base64UrlDecode(parts[0]!).toString('utf8')); }
catch { return { ok: false, error: 'bad_header' }; }
const alg = header.alg;
if (!alg || !/^(RS256|RS384|RS512|ES256|ES384|ES512|PS256|PS384|PS512)$/.test(alg)) {
return { ok: false, error: 'unsupported_alg' };
}
let keys: Array<Record<string, unknown>>;
try { keys = await fetchJwks(doc.jwks_uri); }
catch (e) { return { ok: false, error: 'jwks_fetch_failed' }; }
// When the token carries a `kid`, refuse to fall back to any other
// key in the JWKS — a mismatch means the token was signed with a key
// the provider no longer publishes, and we should reject rather than
// mask the failure by trying another key.
const jwk = header.kid
? keys.find((k) => k['kid'] === header.kid)
: keys[0];
if (!jwk) return { ok: false, error: 'no_matching_key' };
let publicKey;
try {
// Node 16+ understands JWK directly; no PEM conversion library needed.
// Node's crypto accepts a JWK object directly as `{ key, format: 'jwk' }`.
// The type signature isn't strict on our TS config so we cast through any.
publicKey = crypto.createPublicKey({ key: jwk as any, format: 'jwk' });
} catch {
return { ok: false, error: 'key_import_failed' };
}
let claims: Record<string, unknown>;
try {
const verified = jwt.verify(idToken, publicKey, {
algorithms: [alg as jwt.Algorithm],
issuer: expectedIssuer,
audience: clientId,
});
claims = typeof verified === 'string' ? {} : (verified as Record<string, unknown>);
} catch (err) {
const msg = err instanceof Error ? err.message : 'verify_failed';
return { ok: false, error: `signature_or_claim_mismatch: ${msg}` };
}
return { ok: true, claims };
}
// ---------------------------------------------------------------------------
// Find or create user by OIDC sub / email
// ---------------------------------------------------------------------------
@@ -390,34 +286,21 @@ export function findOrCreateUser(
const existing = db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)').get(username);
if (existing) username = `${username}_${Date.now() % 10000}`;
// Atomic registration: if an invite was presented, the increment IS
// the capacity check — UPDATE matches zero rows the moment another
// concurrent callback wins the last slot, and the transaction aborts
// the user INSERT. Without this, two parallel OIDC callbacks could
// both pass the earlier SELECT-based check and each create a user.
const inviteRaceError = new Error('invite_exhausted');
try {
const createUser = db.transaction(() => {
if (validInvite) {
const updated = db.prepare(
'UPDATE invite_tokens SET used_count = used_count + 1 WHERE id = ? AND (max_uses = 0 OR used_count < max_uses)',
).run(validInvite.id);
if (updated.changes === 0) throw inviteRaceError;
}
return db.prepare(
'INSERT INTO users (username, email, password_hash, role, oidc_sub, oidc_issuer, first_seen_version, login_count) VALUES (?, ?, ?, ?, ?, ?, ?, 0)',
).run(username, email, hash, role, sub, config.issuer, process.env.APP_VERSION || '0.0.0');
});
const result = createUser() as { lastInsertRowid: number | bigint };
user = { id: Number(result.lastInsertRowid), username, email, role } as User;
return { user };
} catch (err) {
if (err === inviteRaceError) {
console.warn(`[OIDC] Invite token ${inviteToken?.slice(0, 8)}... exhausted — concurrent callback won the last slot`);
return { error: 'registration_disabled' };
const result = db.prepare(
'INSERT INTO users (username, email, password_hash, role, oidc_sub, oidc_issuer, first_seen_version, login_count) VALUES (?, ?, ?, ?, ?, ?, ?, 0)',
).run(username, email, hash, role, sub, config.issuer, process.env.APP_VERSION || '0.0.0');
if (validInvite) {
const updated = db.prepare(
'UPDATE invite_tokens SET used_count = used_count + 1 WHERE id = ? AND (max_uses = 0 OR used_count < max_uses)',
).run(validInvite.id);
if (updated.changes === 0) {
console.warn(`[OIDC] Invite token ${inviteToken?.slice(0, 8)}... exceeded max_uses (race condition)`);
}
throw err;
}
user = { id: Number(result.lastInsertRowid), username, email, role } as User;
return { user };
}
// ---------------------------------------------------------------------------
+6 -11
View File
@@ -149,10 +149,10 @@ export function createReservation(tripId: string | number, data: CreateReservati
let resolvedAccommodationId: number | null = accommodation_id || null;
if (type === 'hotel' && !resolvedAccommodationId && create_accommodation) {
const { place_id: accPlaceId, start_day_id, end_day_id, check_in, check_out, confirmation: accConf } = create_accommodation;
if (start_day_id && end_day_id) {
if (accPlaceId && start_day_id && end_day_id) {
const accResult = db.prepare(
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(tripId, accPlaceId || null, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null);
).run(tripId, accPlaceId, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null);
resolvedAccommodationId = Number(accResult.lastInsertRowid);
accommodationCreated = true;
}
@@ -274,16 +274,11 @@ export function updateReservation(id: string | number, tripId: string | number,
}
if (type === 'hotel' && create_accommodation) {
const { place_id: accPlaceId, start_day_id, end_day_id, check_in, check_out, confirmation: accConf } = create_accommodation;
if (start_day_id && end_day_id) {
if (accPlaceId && start_day_id && end_day_id) {
if (resolvedAccId) {
if (accPlaceId) {
db.prepare('UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ? WHERE id = ?')
.run(accPlaceId, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null, resolvedAccId);
} else {
db.prepare('UPDATE day_accommodations SET start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ? WHERE id = ?')
.run(start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null, resolvedAccId);
}
} else if (accPlaceId) {
db.prepare('UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ? WHERE id = ?')
.run(accPlaceId, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null, resolvedAccId);
} else {
const accResult = db.prepare(
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(tripId, accPlaceId, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null);
+3 -10
View File
@@ -1,10 +1,7 @@
import { db } from '../db/database';
import { decrypt_api_key, maybe_encrypt_api_key } from './apiKeyCrypto';
import { maybe_encrypt_api_key } from './apiKeyCrypto';
const ENCRYPTED_SETTING_KEYS = new Set(['webhook_url', 'ntfy_token', 'mapbox_access_token']);
// Encrypted keys that are masked (••••••••) when returned to the client.
// Keys not in this set but in ENCRYPTED_SETTING_KEYS are decrypted and returned.
const MASKED_SETTING_KEYS = new Set(['webhook_url', 'ntfy_token']);
const ENCRYPTED_SETTING_KEYS = new Set(['webhook_url', 'ntfy_token']);
export const DEFAULTABLE_USER_SETTING_KEYS = [
'temperature_unit',
@@ -86,12 +83,8 @@ export function getUserSettings(userId: number): Record<string, unknown> {
const rows = db.prepare('SELECT key, value FROM settings WHERE user_id = ?').all(userId) as { key: string; value: string }[];
const userSettings: Record<string, unknown> = {};
for (const row of rows) {
if (MASKED_SETTING_KEYS.has(row.key)) {
userSettings[row.key] = row.value ? '••••••••' : '';
continue;
}
if (ENCRYPTED_SETTING_KEYS.has(row.key)) {
userSettings[row.key] = row.value ? (decrypt_api_key(row.value) ?? '') : '';
userSettings[row.key] = row.value ? '••••••••' : '';
continue;
}
try {
+3 -10
View File
@@ -44,14 +44,9 @@ export function createOrUpdateShareLink(
return { token: existing.token, created: false };
}
// New share links default to a 90-day TTL. Existing tokens that were
// created before the expires_at migration keep NULL here and remain
// valid indefinitely until the owner rotates them; that preserves
// behaviour for anyone who's already sharing a link.
const token = crypto.randomBytes(24).toString('base64url');
const expiresAt = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString();
db.prepare('INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
.run(tripId, token, createdBy, share_map ? 1 : 0, share_bookings ? 1 : 0, share_packing ? 1 : 0, share_budget ? 1 : 0, share_collab ? 1 : 0, expiresAt);
db.prepare('INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
.run(tripId, token, createdBy, share_map ? 1 : 0, share_bookings ? 1 : 0, share_packing ? 1 : 0, share_budget ? 1 : 0, share_collab ? 1 : 0);
return { token, created: true };
}
@@ -84,9 +79,7 @@ export function deleteShareLink(tripId: string): void {
* permission flags. Returns null if the token is invalid or the trip is gone.
*/
export function getSharedTripData(token: string): Record<string, any> | null {
const shareRow = db.prepare(
"SELECT * FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))"
).get(token) as any;
const shareRow = db.prepare('SELECT * FROM share_tokens WHERE token = ?').get(token) as any;
if (!shareRow) return null;
const tripId = shareRow.trip_id;
+2 -55
View File
@@ -44,11 +44,6 @@ vi.mock('../../src/services/oidcService', async (importOriginal) => {
discover: vi.fn(),
exchangeCodeForToken: vi.fn(),
getUserInfo: vi.fn(),
// Bypass real JWKS fetch + signature verification in tests. Callers
// that exercise the security of verifyIdToken should unit-test the
// function directly instead; integration tests here focus on the
// callback flow, not the crypto.
verifyIdToken: vi.fn(),
};
});
@@ -63,7 +58,6 @@ import * as oidcService from '../../src/services/oidcService';
const mockDiscover = vi.mocked(oidcService.discover);
const mockExchangeCode = vi.mocked(oidcService.exchangeCodeForToken);
const mockGetUserInfo = vi.mocked(oidcService.getUserInfo);
const mockVerifyIdToken = vi.mocked(oidcService.verifyIdToken);
const MOCK_DISCOVERY_DOC = {
authorization_endpoint: 'https://oidc.example.com/auth',
@@ -148,11 +142,9 @@ describe('GET /api/auth/oidc/callback', () => {
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
mockExchangeCode.mockResolvedValueOnce({
access_token: 'test-access-token',
id_token: 'fake.id.token',
_ok: true,
_status: 200,
});
mockVerifyIdToken.mockResolvedValueOnce({ ok: true, claims: { sub: 'sub-alice-123' } });
mockGetUserInfo.mockResolvedValueOnce({
sub: 'sub-alice-123',
email: 'alice@example.com',
@@ -170,8 +162,7 @@ describe('GET /api/auth/oidc/callback', () => {
it('OIDC-005: new user gets created when registration is open', async () => {
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
mockExchangeCode.mockResolvedValueOnce({ access_token: 'new-token', id_token: 'fake.id.token', _ok: true, _status: 200 });
mockVerifyIdToken.mockResolvedValueOnce({ ok: true, claims: { sub: 'sub-newuser-999' } });
mockExchangeCode.mockResolvedValueOnce({ access_token: 'new-token', _ok: true, _status: 200 });
mockGetUserInfo.mockResolvedValueOnce({
sub: 'sub-newuser-999',
email: 'newuser@example.com',
@@ -223,49 +214,6 @@ describe('GET /api/auth/oidc/callback', () => {
expect(res.headers.location).toContain('oidc_error=token_failed');
});
it('OIDC-010a: missing id_token in token response → redirects with no_id_token error', async () => {
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
mockExchangeCode.mockResolvedValueOnce({ access_token: 'tok', _ok: true, _status: 200 }); // no id_token
const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`);
expect(res.status).toBe(302);
expect(res.headers.location).toContain('oidc_error=no_id_token');
});
it('OIDC-010b: verifyIdToken failure → redirects with id_token_invalid error', async () => {
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
mockExchangeCode.mockResolvedValueOnce({ access_token: 'tok', id_token: 'bad.id.token', _ok: true, _status: 200 });
mockVerifyIdToken.mockResolvedValueOnce({ ok: false, error: 'signature_or_claim_mismatch: invalid signature' });
const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`);
expect(res.status).toBe(302);
expect(res.headers.location).toContain('oidc_error=id_token_invalid');
});
it('OIDC-010c: userinfo.sub does not match id_token.sub → redirects with subject_mismatch error', async () => {
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
mockExchangeCode.mockResolvedValueOnce({ access_token: 'tok', id_token: 'fake.id.token', _ok: true, _status: 200 });
mockVerifyIdToken.mockResolvedValueOnce({ ok: true, claims: { sub: 'sub-from-token' } });
mockGetUserInfo.mockResolvedValueOnce({
sub: 'sub-different-from-userinfo',
email: 'alice@example.com',
name: 'Alice',
});
const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`);
expect(res.status).toBe(302);
expect(res.headers.location).toContain('oidc_error=subject_mismatch');
});
it('OIDC-010: registration disabled for new user → redirects with registration_disabled error', async () => {
// Need at least one existing user so isFirstUser=false
createUser(testDb, { email: 'existing@example.com' });
@@ -273,8 +221,7 @@ describe('GET /api/auth/oidc/callback', () => {
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
mockExchangeCode.mockResolvedValueOnce({ access_token: 'tok', id_token: 'fake.id.token', _ok: true, _status: 200 });
mockVerifyIdToken.mockResolvedValueOnce({ ok: true, claims: { sub: 'sub-blocked-user' } });
mockExchangeCode.mockResolvedValueOnce({ access_token: 'tok', _ok: true, _status: 200 });
mockGetUserInfo.mockResolvedValueOnce({
sub: 'sub-blocked-user',
email: 'blocked@example.com',
@@ -8,12 +8,12 @@ const { rows, dbMock } = vi.hoisted(() => {
db: {
prepare: vi.fn((sql: string) => ({
get: vi.fn((...args: unknown[]) => {
const [key, userId, method, path] = args;
return rows[`${key}:${userId}:${method}:${path}`] ?? undefined;
const [key, userId] = args;
return rows[`${key}:${userId}`] ?? undefined;
}),
run: vi.fn((...args: unknown[]) => {
const [key, userId, method, path, status_code, response_body] = args as [string, number, string, string, number, string];
const k = `${key}:${userId}:${method}:${path}`;
const [key, userId, , , status_code, response_body] = args as [string, number, string, string, number, string];
const k = `${key}:${userId}`;
if (!rows[k]) rows[k] = { status_code, response_body };
}),
})),
@@ -28,8 +28,8 @@ vi.mock('../../../src/db/database', () => dbMock);
import { applyIdempotency } from '../../../src/middleware/idempotency';
import type { Request, Response, NextFunction } from 'express';
function makeReq(method = 'POST', headers: Record<string, string> = {}, path = '/api/test'): Request {
return { method, path, headers } as unknown as Request;
function makeReq(method = 'POST', headers: Record<string, string> = {}): Request {
return { method, path: '/api/test', headers } as unknown as Request;
}
function makeRes(statusCode = 200): Response {
@@ -64,8 +64,8 @@ describe('applyIdempotency', () => {
expect(next).toHaveBeenCalledOnce();
});
it('replays cached response when key+user+method+path already stored', () => {
rows['cached-key:42:POST:/api/test'] = { status_code: 201, response_body: JSON.stringify({ id: 99 }) };
it('replays cached response when key+user already stored', () => {
rows['cached-key:42'] = { status_code: 201, response_body: JSON.stringify({ id: 99 }) };
const req = makeReq('POST', { 'x-idempotency-key': 'cached-key' });
const res = makeRes();
const next = vi.fn();
@@ -75,7 +75,7 @@ describe('applyIdempotency', () => {
});
it('different user same key does NOT replay', () => {
rows['cached-key:1:POST:/api/test'] = { status_code: 200, response_body: JSON.stringify({ ok: true }) };
rows['cached-key:1'] = { status_code: 200, response_body: JSON.stringify({ ok: true }) };
const req = makeReq('POST', { 'x-idempotency-key': 'cached-key' });
const res = makeRes();
const next = vi.fn();
@@ -83,33 +83,6 @@ describe('applyIdempotency', () => {
expect(next).toHaveBeenCalledOnce();
});
it('same key+user on different path does NOT replay (scoped cache)', () => {
// Key 'dual-key' is cached under /api/a but reused against /api/b.
// Without the (key, user_id, method, path) scoping, /api/b would
// have replayed /api/a's body — a silent cross-endpoint leak.
rows['dual-key:7:POST:/api/a'] = { status_code: 200, response_body: JSON.stringify({ from: 'a' }) };
const req = makeReq('POST', { 'x-idempotency-key': 'dual-key' }, '/api/b');
const res = makeRes();
const next = vi.fn(() => {
(res.json as ReturnType<typeof vi.fn>)({ from: 'b' });
});
applyIdempotency(req, res, next, 7);
expect(next).toHaveBeenCalledOnce();
expect(rows['dual-key:7:POST:/api/b']).toBeDefined();
expect(JSON.parse(rows['dual-key:7:POST:/api/b'].response_body)).toEqual({ from: 'b' });
// /api/a's row is untouched.
expect(JSON.parse(rows['dual-key:7:POST:/api/a'].response_body)).toEqual({ from: 'a' });
});
it('same key+user+path but different method does NOT replay', () => {
rows['m-key:3:POST:/api/x'] = { status_code: 201, response_body: JSON.stringify({ m: 'post' }) };
const req = makeReq('PATCH', { 'x-idempotency-key': 'm-key' }, '/api/x');
const res = makeRes();
const next = vi.fn();
applyIdempotency(req, res, next, 3);
expect(next).toHaveBeenCalledOnce();
});
it('stores 2xx response on first execution via wrapped res.json', () => {
const req = makeReq('POST', { 'x-idempotency-key': 'new-key' });
const res = makeRes(201);
@@ -119,9 +92,9 @@ describe('applyIdempotency', () => {
});
applyIdempotency(req, res, next, 7);
expect(next).toHaveBeenCalledOnce();
expect(rows['new-key:7:POST:/api/test']).toBeDefined();
expect(rows['new-key:7:POST:/api/test'].status_code).toBe(201);
expect(JSON.parse(rows['new-key:7:POST:/api/test'].response_body)).toEqual({ id: 5 });
expect(rows['new-key:7']).toBeDefined();
expect(rows['new-key:7'].status_code).toBe(201);
expect(JSON.parse(rows['new-key:7'].response_body)).toEqual({ id: 5 });
});
it('does NOT store 4xx responses', () => {
@@ -131,36 +104,7 @@ describe('applyIdempotency', () => {
(res.json as ReturnType<typeof vi.fn>)({ error: 'Invalid' });
});
applyIdempotency(req, res, next, 3);
expect(rows['fail-key:3:POST:/api/test']).toBeUndefined();
});
it('returns 400 when X-Idempotency-Key exceeds 128 characters', () => {
const longKey = 'a'.repeat(129);
const req = makeReq('POST', { 'x-idempotency-key': longKey });
const res = makeRes();
const next = vi.fn();
applyIdempotency(req, res, next, 1);
expect(next).not.toHaveBeenCalled();
expect(res.json as ReturnType<typeof vi.fn>).toHaveBeenCalledWith(
expect.objectContaining({ error: expect.stringContaining('128') }),
);
});
it('does NOT cache response body exceeding 256 KiB', () => {
const req = makeReq('POST', { 'x-idempotency-key': 'big-key' });
const res = makeRes(200);
const originalJsonSpy = res.json as ReturnType<typeof vi.fn>;
const largePayload = { data: 'x'.repeat(256 * 1024 + 1) };
const next = vi.fn(() => {
// res.json is now the wrapper; calling it exercises the size-cap branch
res.json(largePayload);
});
applyIdempotency(req, res, next, 5);
expect(next).toHaveBeenCalledOnce();
// Underlying spy was called (response reached the client)
expect(originalJsonSpy).toHaveBeenCalledWith(largePayload);
// But NOT stored in the idempotency store
expect(rows['big-key:5:POST:/api/test']).toBeUndefined();
expect(rows['fail-key:3']).toBeUndefined();
});
it('handles PUT, PATCH, and DELETE the same as POST', () => {
@@ -94,14 +94,14 @@ describe('getPreferencesMatrix', () => {
const { user } = createUser(testDb);
const { event_types } = getPreferencesMatrix(user.id, 'user');
expect(event_types).not.toContain('version_available');
expect(event_types.length).toBe(9);
expect(event_types.length).toBe(8);
});
it('NPREF-005 — user scope excludes version_available for everyone including admins', () => {
const { user } = createAdmin(testDb);
const { event_types } = getPreferencesMatrix(user.id, 'admin', 'user');
expect(event_types).not.toContain('version_available');
expect(event_types.length).toBe(9);
expect(event_types.length).toBe(8);
});
it('NPREF-005b — admin scope returns only version_available', () => {
-54
View File
@@ -1,54 +0,0 @@
# Accommodations
Link an accommodation to specific check-in and check-out days so it appears for every day you are staying there. Accommodations are a distinct record type backed by the `day_accommodations` table and are separate from regular reservations, though each accommodation automatically creates a linked **Hotel** reservation.
<!-- TODO: screenshot: accommodation list showing check-in/check-out details -->
## Creating an accommodation
There are two ways to create an accommodation:
**From the Reservations panel:** Click **Add** and select **Hotel** as the booking type. When the type is set to Hotel, the date/time and location fields are replaced by accommodation-specific inputs (see below). Saving the form creates both the hotel reservation and the underlying accommodation record at the same time.
**From the Day Detail panel:** Click the hotel icon or the **Add accommodation** button in the Day Detail overlay. A picker appears that lets you select a place from the trip, choose the day range, and optionally fill in check-in/check-out times and a confirmation code. This creates the accommodation record and its linked Hotel reservation together.
![Accommodation reservation card showing check-in details](assets/Hotel-ReservationCard.png)
## Accommodation-specific fields
When creating or editing via the Reservations panel with type set to **Hotel**, the date/time and location fields are replaced by accommodation-specific inputs:
| Field | Description |
|-------|-------------|
| **Accommodation** | Search for or select an existing trip place to link as the property. Selecting a place pre-fills the title if it is empty and pre-fills the location field if the place has an address |
| **From** | The check-in day |
| **To** | The check-out day |
| **Check-in** | The earliest time you can check in |
| **Check-in until** | The latest time the front desk accepts check-in |
| **Check-out** | The latest time you must check out |
The **Confirmation code**, **Status** (Pending / Confirmed), and **Notes** fields are also available, as they are for all reservation types.
## In the Day Detail panel
For each day between the **From** day and **To** day (inclusive), the accommodation appears in the Day Detail panel overlay. It shows the linked place name and address, a check-in or check-out label for the relevant boundary days, and the check-in window, check-out time, and confirmation code if set. Middle nights show the place name without a check-in/check-out label. The linked Hotel reservation's status and confirmation number are also shown inline.
![Day planner side bar with accomodation](assets/Hotel-ReservationDaySidebar.png)
## In the day plan sidebar
Accommodations appear as small colour-coded badges in the day header row of the day plan sidebar:
- **Green badge** — check-in day
- **Red badge** — check-out day
- **Neutral badge** — nights in between (ongoing stay)
Clicking a badge navigates to the linked place. Hotel-type reservations are filtered out of the inline transport card list between places; they do not appear as transport items in the timeline.
## In the Reservations panel
Hotel reservation cards in the Reservations panel show the linked accommodation name (place name) alongside the standard reservation fields such as the confirmation code and status. The check-in and check-out times are displayed in the metadata section of the card when they have been set.
---
**See also:** [Reservations-and-Bookings](Reservations-and-Bookings) · [Transport-Flights-Trains-Cars](Transport-Flights-Trains-Cars) · [Day-Plans-and-Notes](Day-Plans-and-Notes)
-34
View File
@@ -1,34 +0,0 @@
# Addons Overview
Addons are optional features that an admin can enable or disable for the entire TREK instance. When an addon is disabled, its navigation tabs, menu items, and API routes are hidden from all users.
![Addon overview](assets/Addons-Overview.png)
## What addons are
Each addon extends TREK with functionality beyond the core trip-planning features. Addons are managed globally — you cannot enable an addon for one user only. Once enabled, the feature becomes available to all users on the instance.
## Addon list
The following addons are registered in the system (defined in `server/src/db/seeds.ts`; the TypeScript constant `ADDON_IDS` in `server/src/addons.ts` covers all addons except `naver_list_import`):
| Addon ID | Type | Description |
|---|---|---|
| `mcp` | integration | Exposes TREK data and actions through the Model Context Protocol for AI assistant integrations. |
| `packing` | trip | Packing list management — create templates and lists linked to trips. See [Packing-Lists](Packing-Lists). |
| `budget` | trip | Trip budget tracking — log expenses, set budgets, and track spending per trip. See [Budget-Tracking](Budget-Tracking). |
| `documents` | trip | Document and file attachments for trips — store itineraries, visa copies, and other files. See [Documents-and-Files](Documents-and-Files). |
| `vacay` | global | Personal vacation day planner with a year calendar, holiday packs, and collaborator fusion. See [Vacay](Vacay). |
| `atlas` | global | Interactive world map showing countries and regions you have visited, plus a bucket list. See [Atlas](Atlas). |
| `collab` | trip | Notes, polls, and live chat for trip collaboration. See [Real-Time-Collaboration](Real-Time-Collaboration). |
| `journey` | global | Trip tracking and travel journal — check-ins, photos, and daily stories. See [Journey-Journal](Journey-Journal). |
| `naver_list_import` | trip | Import places from shared Naver Maps lists directly into a trip. |
## Enabling addons
> **Admin:** all addons are toggled from the admin panel. Navigate to [Admin-Addons](Admin-Addons) to enable or disable individual addons for your instance.
## Per-addon sub-features
Some addons expose sub-features that an admin can independently toggle. The [Real-Time-Collaboration](Real-Time-Collaboration) addon, for example, lets an admin decide which of its four sub-features (chat, notes, polls, and what's next) are active across the instance. These are configured from the [Admin-Addons](Admin-Addons) panel alongside the addon's main toggle.
-70
View File
@@ -1,70 +0,0 @@
# Admin — Addons
The **Addons** tab lets you enable or disable optional features for the entire TREK instance. Toggling an addon affects all users immediately — disabling one hides its UI elements and blocks its API routes instance-wide.
<!-- TODO: screenshot: addon toggle switches in admin panel -->
![Addon overview](assets/Addons-Overview.png)
## What addons control
Each addon toggle controls a feature set. When you disable an addon, users lose access to that feature everywhere in the app. No data is deleted; re-enabling the addon restores access to existing data.
## Addon categories
Addons are grouped into three categories, shown as labeled sections.
### Trip addons
Trip addons add per-trip feature panels. They appear in every trip where the addon is enabled.
The default trip addons are: **Lists**, **Budget**, **Documents**, **Collab**, and **Naver List Import** (all enabled by default). The exact list is determined by what is registered in your TREK database.
**Sub-toggles on trip addons:**
- **Lists** — when enabled, a nested **Bag Tracking** toggle appears. Bag Tracking lets users assign packed items to specific bags.
- **Collab** — when enabled, four sub-toggles appear for individual collaboration features:
- **Chat** — in-trip real-time chat
- **Notes** — shared trip notes
- **Polls** — trip polls
- **What's Next** — the "what's next" widget
Each sub-toggle can be disabled independently while the parent addon remains enabled.
### Global addons
Global addons add features that are not tied to a single trip. The default global addons are **Vacay**, **Atlas**, and **Journey**.
- **Vacay** — personal vacation day planner with calendar view. Enabled by default.
- **Atlas** — world map of visited countries with travel stats. Enabled by default.
- **Journey** — trip tracking and travel journal (check-ins, photos, daily stories). **Disabled by default.**
**Sub-items on global addons:**
- The **Journey** addon shows photo provider toggles underneath it. Each photo provider (e.g., Immich, Synology Photos) can be enabled or disabled independently.
### Integration addons
Integration addons connect TREK to external services. Enabling an integration addon typically requires additional configuration (API keys, URLs) in the **Settings** tab.
- The **MCP** addon requires `APP_URL` to be set in your environment. When enabled, the **MCP Access** tab appears in the Admin Panel. **Disabled by default.** See [MCP-Overview](MCP-Overview) for full details.
## Enabling or disabling an addon
Click the toggle switch on any addon row. The change is applied immediately — no save button is needed. A brief success toast confirms the update.
If a toggle fails (e.g., network error), it rolls back to its previous state.
## Additional configuration
Some addons require credentials or environment variables before they are functional:
- **Journey** — requires photo provider credentials (Immich or Synology Photos) configured per-user in their personal Settings. See [Photo-Providers](Photo-Providers).
- **MCP** — requires `APP_URL` to be set so OAuth redirect URIs resolve correctly.
## Related pages
- [Admin-Panel-Overview](Admin-Panel-Overview)
- [Admin-MCP-Tokens](Admin-MCP-Tokens)
- [MCP-Overview](MCP-Overview)
- [Addons-Overview](Addons-Overview)
-47
View File
@@ -1,47 +0,0 @@
# Admin — Categories
The **Personalization** tab → **Categories** section lets you manage global place categories. Categories are shared across all trips and all users on the instance.
<!-- TODO: screenshot: category list in admin panel -->
![Category Manager](assets/CategoryManager.png)
## What categories are
A category is a label consisting of a name, a color, and an icon. Users assign categories to places when creating or editing a place. Categories appear:
- In the place form's category selector
- As colored chips on place cards
- In the places filter panel
- In the map legend
## Creating a category
Click **New Category** (top-right of the category section). A form appears inline:
1. **Name** — required. Free-text label for the category.
2. **Icon** — a scrollable grid of ~47 curated Lucide icons (Pin, Hotel, Restaurant, Transport, Nature, etc.). Click any icon to select it. The default icon is `MapPin`.
3. **Color** — 12 preset color swatches plus a custom color picker (pipette button). The default color is `#6366f1` (indigo). The 12 presets are:
`#6366f1` · `#8b5cf6` · `#ec4899` · `#ef4444` · `#f97316` · `#f59e0b` · `#10b981` · `#06b6d4` · `#3b82f6` · `#84cc16` · `#6b7280` · `#1f2937`
4. A **live preview** chip shows how the category will appear to users as you make selections.
Click **Create** to save.
## Editing a category
Click the pencil icon on any category row. The same form appears in-place with the existing values pre-filled. Change any field and click **Update**.
## Deleting a category
Click the trash icon on a category row and confirm. Deletion sets `category_id` to `NULL` on any places that had the category assigned — the places themselves are not affected, they become uncategorized.
## List ordering
Categories are always displayed in alphabetical order by name. There is no manual reordering.
## Related pages
- [Places-and-Search](Places-and-Search)
- [Admin-Panel-Overview](Admin-Panel-Overview)
-50
View File
@@ -1,50 +0,0 @@
# Admin — GitHub Releases
The **GitHub** tab shows the TREK release history fetched from GitHub and provides links to community resources and support options.
<!-- TODO: screenshot: GitHub releases panel with release timeline -->
![GitHub tab](assets/GithubReleases.png)
## Support and resources
Six cards at the top of the tab link to external resources:
| Card | Link |
|------|------|
| **Ko-fi** | Support the project financially |
| **Buy Me a Coffee** | Alternative support link |
| **Discord** | Join the TREK community |
| **Report a Bug** | Open a GitHub issue with the bug report template |
| **Feature Request** | Open a GitHub Discussion in the feature requests category |
| **Wiki** | Open the GitHub Wiki |
## Release timeline
Below the support cards, a chronological timeline lists GitHub releases for the `mauriceboe/TREK` repository. Each entry shows:
- **Version tag** (e.g., `v2.9.14`)
- A **Latest** badge on the first (most recent) entry in the displayed list
- **Release date** and author
- A **Show details / Hide details** toggle that expands the release notes (Markdown rendered inline)
When the running server version is a stable release, pre-release entries are filtered out of the timeline.
Releases load 10 at a time. Click **Load more** at the bottom of the timeline to fetch additional pages.
If the admin API request fails, the timeline section shows an error message. If the server cannot reach the GitHub API, the timeline displays no releases (the server returns an empty list rather than an error).
## Version check
The server checks for available updates daily at 9 AM (server timezone, defaults to UTC) and sends an admin notification when a newer version is published. When an update is available, a banner also appears at the top of the Admin page on next load.
Results are cached for 5 minutes to avoid repeated API calls.
## When to check
Review the GitHub tab before performing an upgrade to read the release notes for any versions between your current install and the target version. See [Updating](Updating) for the upgrade procedure.
## Related pages
- [Updating](Updating)
- [Admin-Panel-Overview](Admin-Panel-Overview)
-48
View File
@@ -1,48 +0,0 @@
# Admin — MCP Tokens
The **MCP Access** panel shows all active MCP OAuth sessions and API tokens across every user on the instance. As an admin you can revoke sessions and delete tokens.
This panel is only visible when the **MCP addon** is enabled in the [Admin-Addons](Admin-Addons) panel.
<!-- TODO: screenshot: OAuth client and token list in MCP Access panel -->
![MCP Access](assets/MCPAccess.png)
## OAuth Sessions
OAuth sessions are created when a user authorizes an MCP client via the OAuth 2.1 flow. These are the recommended way to connect MCP clients to TREK.
**Columns:**
| Column | Description |
|--------|-------------|
| Client name | The name of the registered OAuth client. Granted scopes are shown as badges below the name (up to 6 are shown; click "+N more" to expand) |
| Owner | The username of the user who authorized the session |
| Created | Date the session was established |
| (actions) | Trash icon button to revoke |
**Revoking a session:** Click the trash icon on the row and confirm. The session is invalidated immediately and the revocation is recorded in the audit log. The user's MCP client will need to re-authorize before it can make further requests.
OAuth access tokens use the prefix `trekoa_`.
## API Tokens
API tokens are long-lived tokens that users create in their personal settings. They are identified by the `trek_` prefix.
**Columns:**
| Column | Description |
|--------|-------------|
| Token name | The label the user gave the token, with its truncated prefix shown below |
| Owner | The username of the user who created it |
| Created | Date the token was created |
| Last used | Date of the most recent API call using this token, or "Never" if unused |
| (actions) | Trash icon button to delete |
**Deleting a token:** Click the trash icon and confirm. The token is invalidated immediately. The user must create a new token in their settings if they still need access.
## Related pages
- [MCP-Overview](MCP-Overview)
- [MCP-Setup](MCP-Setup)
- [Admin-Panel-Overview](Admin-Panel-Overview)
-65
View File
@@ -1,65 +0,0 @@
# Admin — Packing Templates
The **Personalization** tab → **Packing Templates** section lets you create reusable packing list templates that users can apply to any trip.
<!-- TODO: screenshot: packing templates admin list with categories and items -->
![Packing Template Manager](assets/PackingTemplate.png)
## What templates are
A packing template is a three-level hierarchy:
```
Template
└── Category
└── Item
```
When a user applies a template to a trip, all categories and items from that template are copied into the trip's packing list.
## Template list
The template list shows each template as a collapsible row displaying:
- Template name
- Category count and item count (e.g., `3 categories · 12 items`)
- Edit (rename) and delete buttons
## Creating a template
1. Click **New Template** (top-right of the panel).
2. Type a name and press **Enter** or click the confirm button.
3. The new template is added to the list and automatically expanded.
## Adding categories to a template
With a template expanded, click **Add category** (dashed border button at the bottom of the expanded section). Type a category name and press **Enter** or click confirm.
## Adding items to a category
Click the `+` button inside any category header. An inline input appears below the last item. Type an item name and press **Enter** to add it. You can add multiple items in sequence without closing the input — press **Enter** after each one.
## Editing inline
All editing is inline:
- **Rename a template** — click the pencil icon on the template row. The name becomes an input; press **Enter** or click away to save.
- **Rename a category** — click the pencil icon in the category header. Press **Enter** or click away to save.
- **Rename an item** — hover the item row to reveal the pencil icon, then click it. Press **Enter** or click the confirm button to save (clicking away does not save).
## Deleting
- **Delete a template** — click the trash icon on the template row. The template is removed. This does not affect trips that already had items from this template applied.
- **Delete a category** — click the trash icon in the category header. All items in that category are also deleted from the template.
- **Delete an item** — hover the item row to reveal the trash icon.
## Applying templates to a trip
Users apply templates through the **Packing** panel inside the trip planner. See [Packing-Templates](Packing-Templates) for user-facing documentation.
## Related pages
- [Packing-Templates](Packing-Templates)
- [Packing-Lists](Packing-Lists)
- [Admin-Panel-Overview](Admin-Panel-Overview)
-39
View File
@@ -1,39 +0,0 @@
# Admin Panel Overview
The Admin Panel is the central control surface for TREK instance operators. It is only accessible to users with the `admin` role.
## Accessing the Admin Panel
Navigate to the **Admin** link in the top navbar. If you do not see it, your account does not have admin privileges.
<!-- TODO: screenshot: admin panel main dashboard with tabs -->
![Admin Panel](assets/AdminPanel.png)
## Tabs
The Admin Panel is divided into tabs. Most tabs are always visible; a few appear only under specific conditions.
| Tab | Purpose | Conditional? |
|-----|---------|--------------|
| **Users** | Manage users, invite links, and permissions | No |
| **Personalization** | Packing templates and place categories | No |
| **User Defaults** | Default settings applied to new users | No |
| **Addons** | Enable or disable optional features instance-wide | No |
| **Settings** | Authentication methods, MFA, allowed file types, API keys, OIDC/SSO configuration, and JWT secret rotation | No |
| **Notifications** | SMTP, webhook, ntfy, and push notification channel configuration; trip reminder toggle; admin notification preferences | No |
| **Backup** | Manual and scheduled database backups | No |
| **Audit** | Chronological activity log | No |
| **MCP Access** | OAuth sessions and static API tokens | Only when the MCP addon is enabled |
| **GitHub** | Release timeline and support links | No |
| **Dev: Notifications** | Test notification dispatch | Only in development mode (`NODE_ENV=development`) |
## Related pages
- [Admin-Users-and-Invites](Admin-Users-and-Invites)
- [Admin-Addons](Admin-Addons)
- [Admin-Categories](Admin-Categories)
- [Admin-Packing-Templates](Admin-Packing-Templates)
- [Admin-Permissions](Admin-Permissions)
- [Admin-MCP-Tokens](Admin-MCP-Tokens)
- [Admin-GitHub-Releases](Admin-GitHub-Releases)
-76
View File
@@ -1,76 +0,0 @@
# Admin — Permissions
The Permissions panel, located at the bottom of the **Users** tab, controls which role level is required to perform each action. Changes apply immediately across the entire instance.
<!-- TODO: screenshot: permissions matrix with role dropdowns -->
![Permissions panel](assets/PermissionSettings.png)
## Role model
TREK uses four permission levels, ordered from most to least privileged:
| Level | Who it includes |
|-------|----------------|
| `admin` | Instance administrators only |
| `trip_owner` | The user who created the trip |
| `trip_member` | Any user who is a member of the trip |
| `everybody` | Any authenticated user (for `trip_create`: no trip context required; for all other actions: any authenticated user with trip access) |
Each action is assigned a minimum required level. A user whose role is at or above that level can perform the action. Not every level is available for every action — each action exposes only the levels that make sense for it. For example, `trip_create` only allows `everybody` or `admin`, while `trip_edit` only allows `trip_owner` or `trip_member`.
## Action categories
Actions are grouped into five categories:
### Trip
| Action key | What it controls |
|------------|-----------------|
| `trip_create` | Create a new trip |
| `trip_edit` | Edit trip name, dates, description, and currency |
| `trip_delete` | Permanently delete a trip |
| `trip_archive` | Archive or unarchive a trip |
| `trip_cover_upload` | Upload or change the cover image for a trip |
### Members
| Action key | What it controls |
|------------|-----------------|
| `member_manage` | Invite or remove trip members |
### Files
| Action key | What it controls |
|------------|-----------------|
| `file_upload` | Upload files to a trip |
| `file_edit` | Edit file descriptions and links |
| `file_delete` | Move files to trash or permanently delete them |
### Content & Schedule
| Action key | What it controls |
|------------|-----------------|
| `place_edit` | Add, edit, or delete places |
| `day_edit` | Edit days, day notes, and place assignments |
| `reservation_edit` | Create, edit, or delete reservations |
### Budget, Packing & Collaboration
| Action key | What it controls |
|------------|-----------------|
| `budget_edit` | Create, edit, or delete budget items |
| `packing_edit` | Manage packing items and bags |
| `collab_edit` | Create notes, polls, and send messages |
| `share_manage` | Create or delete public share links |
## Changing permissions
Each action row has a dropdown. Select the minimum role level required. A **customized** badge appears next to any action that has been changed from its default.
Click **Save** (top-right of the panel) to persist your changes. Use the **Reset to defaults** button (circular arrow icon) to revert all actions to their shipped defaults without saving — you still need to click **Save** after resetting if you want to persist the reset state.
## Related pages
- [Admin-Panel-Overview](Admin-Panel-Overview)
- [Admin-Users-and-Invites](Admin-Users-and-Invites)
-90
View File
@@ -1,90 +0,0 @@
# Admin — Users and Invites
The **Users** tab in the Admin Panel lets you view all registered users, manage their accounts, and create invite links so new people can register without open registration.
<!-- TODO: screenshot: users table with invite form -->
![Users tab](assets/UsersAndInvites.png)
## User list
The user table shows every registered account with the following columns:
| Column | Description |
|--------|-------------|
| **User** | Avatar, username, and an always-visible presence dot (green = online, grey = offline) |
| **Email** | Account email address |
| **Role** | Badge showing `admin` or `user` |
| **Created** | Account creation date |
| **Last Login** | Date and time of most recent login |
| **Actions** | Edit and delete buttons |
Your own account row is highlighted. You cannot delete your own account.
## User actions
### Edit a user
Click the pencil icon on any row to open the edit form. You can change:
- **Username**
- **Email address**
- **Role** — toggle between `user` and `admin`
- **Password** — set a new password; must be at least 8 characters
Click **Save** to apply changes.
### Delete a user
Click the trash icon and confirm. Deletion is permanent. The user's account is removed from the database along with their data (cascade behavior is enforced at the database level).
You cannot delete your own account while logged in as that user.
## Creating a user directly
Click **Create User** (top-right of the Users tab) to create an account without an invite link. You set the username, email, password, and role at creation time.
## Invite links
Invite links let a specific number of people register themselves. This is useful when open registration is disabled.
![Invite links](assets/InviteLinkForm.png)
### Creating an invite
Click **Create Invite** (invite links section, below the user table). Configure:
- **Max uses** — how many times the link can be used before it expires: `1×`, `2×`, `3×`, `4×`, `5×`, or `∞` (unlimited). Defaults to `1×`.
- **Expiry** — how long the link remains valid: `1d`, `3d`, `7d`, `14d`, or `∞` (no expiry). Defaults to `7d`.
After creation the link is copied to your clipboard automatically. Share it with the intended recipient. The URL format is:
```
<APP_URL>/register?invite=<token>
```
### Invite list
Existing invites are listed below the creation button. Each row shows:
- The invite token (truncated, monospace)
- A status badge — `active`, `used up`, or `expired`
- **Usage**`used / max` (or `used / ∞` for unlimited)
- **Expiry** date, if set
- **Created by** — the admin who generated the link
- A **copy link** button (only shown for active invites)
- A **delete** (revoke) button
Revoking an invite immediately invalidates it; anyone following the link after revocation will receive an error.
## Permissions
The **Users** tab also hosts the Permissions panel at the bottom, which controls what roles can perform which actions. See [Admin-Permissions](Admin-Permissions) for details.
## Related pages
- [Login-and-Registration](Login-and-Registration)
- [Invite-Links](Invite-Links)
- [Admin-Permissions](Admin-Permissions)
- [Two-Factor-Authentication](Two-Factor-Authentication)
- [Admin-Panel-Overview](Admin-Panel-Overview)
-55
View File
@@ -1,55 +0,0 @@
# Atlas
Atlas is an interactive world map that shows countries and regions you have visited across all your trips, together with a bucket list of places you want to go.
> **Admin:** enable Atlas in [Admin-Addons](Admin-Addons).
<!-- TODO: screenshot: world map with visited countries highlighted -->
![Atlas](assets/Atlas.png)
## What Atlas is
Atlas gives you a visual overview of your travel footprint. Visited countries are highlighted on the map. You can also mark individual sub-national regions and maintain a personal bucket list of future destinations.
## Accessing Atlas
When the admin has enabled the Atlas addon, an **Atlas** entry appears in the main navigation. Your visited countries are populated automatically from your existing trips.
## Marking countries as visited
Click any country on the map to open an action popup where you can mark it as visited or add it to your bucket list. Use the search bar at the top of the map to find and fly to a country — pressing Enter or selecting a result from the dropdown opens the same action popup.
To remove a manually-marked country (one with no trips or places recorded in it), click it on the map and confirm removal in the popup.
Visits detected automatically from your trips are shown in addition to any countries you mark manually.
### Sub-national regions
At zoom level 5 and above, the map switches to a sub-national region view (states, provinces, etc.). You can mark individual regions as visited or add them to your bucket list. Marking a region also counts the parent country as visited if it was not already.
## Bucket list
The bucket list is separate from "visited". Use it to track countries or places you want to visit in the future. Each bucket list item can have a name, coordinates, country code, optional notes, and a target date.
## Statistics
Your Atlas statistics panel shows:
- **Countries visited** — total number of distinct countries.
- **Trips** — total number of trips across all time.
- **Places** — total number of individual places logged in trips.
- **Cities** — total number of distinct cities visited.
- **Travel days** — total days spent travelling.
- **Continent breakdown** — number of countries visited per continent (Europe, Asia, North America, South America, Africa, Oceania).
- **Travel streak** — number of consecutive years in which you have taken at least one trip.
- **Trips this year** — number of trips in the current calendar year.
## Visual effect
The desktop glass panel at the bottom of the map uses a liquid-glass visual effect — a dynamic inner glow and border highlight that follows your cursor across the panel.
## See also
- [Addons-Overview](Addons-Overview)
- [Admin-Addons](Admin-Addons)
-138
View File
@@ -1,138 +0,0 @@
# Audit Log
The audit log records significant actions taken on your TREK instance. Use it to monitor logins, admin changes, and integration configuration.
## Where to find it
**Admin Panel → Audit** tab.
<!-- TODO: screenshot: audit log table with action entries -->
![Audit log](assets/Audit.png)
## What the log captures
Actions are grouped by area below. The **Action key** is the raw value stored in the log.
### Authentication
| Action key | Description |
|---|---|
| `user.register` | User registered |
| `user.login` | User logged in |
| `user.login_failed` | Login attempt failed |
| `user.password_change` | User changed their password |
| `user.account_delete` | User deleted their account |
### MFA
| Action key | Description |
|---|---|
| `user.mfa_enable` | MFA enabled on an account |
| `user.mfa_disable` | MFA disabled on an account |
### Trips
| Action key | Description |
|---|---|
| `trip.create` | Trip created (includes title) |
| `trip.update` | Trip updated (includes changed fields) |
| `trip.copy` | Trip duplicated (includes source and new trip IDs) |
| `trip.delete` | Trip deleted (includes trip ID and title) |
### Admin actions
| Action key | Description |
|---|---|
| `admin.user_create` | User created by admin |
| `admin.user_update` | User edited by admin (role, email, username, etc.) |
| `admin.user_delete` | User deleted by admin |
| `admin.invite_create` | Invite link created |
| `admin.invite_delete` | Invite link deleted |
| `admin.permissions_update` | Instance permissions updated |
| `admin.oidc_update` | OIDC/SSO settings updated |
| `admin.addon_update` | Addon enabled, disabled, or configured |
| `admin.oauth_session.revoke` | OAuth session revoked by admin |
| `admin.rotate_jwt_secret` | JWT secret rotated |
| `admin.bag_tracking` | Bag tracking feature toggled |
| `admin.places_photos` | Places photos feature toggled |
| `admin.places_autocomplete` | Places autocomplete feature toggled |
| `admin.places_details` | Places details feature toggled |
| `admin.collab_features` | Collaboration features updated |
| `admin.packing_template_delete` | Packing template deleted |
| `admin.default_user_settings_update` | Default user settings updated |
| `admin.demo_baseline_save` | Demo baseline snapshot saved |
| `settings.app_update` | App settings updated (SMTP, webhooks, MFA policy, etc.) |
### Backups
| Action key | Description |
|---|---|
| `backup.create` | Manual backup created |
| `backup.restore` | Restore from stored backup |
| `backup.upload_restore` | Restore from uploaded ZIP |
| `backup.delete` | Backup deleted |
| `backup.auto_settings` | Auto-backup schedule saved |
### MCP
| Action key | Description |
|---|---|
| `mcp.tool_call` | MCP tool invoked (resource = tool name) |
### OAuth
| Action key | Description |
|---|---|
| `oauth.client.create` | OAuth client application created |
| `oauth.client.rotate_secret` | OAuth client secret rotated |
| `oauth.client.delete` | OAuth client application deleted |
| `oauth.consent.grant` | User granted OAuth consent |
| `oauth.token.issue` | OAuth access token issued |
| `oauth.token.refresh` | OAuth access token refreshed |
| `oauth.token.revoke` | OAuth token revoked |
| `oauth.token.grant_failed` | OAuth token grant attempt failed |
| `oauth.token.client_auth_failed` | OAuth client authentication failed |
### Integrations
| Action key | Description |
|---|---|
| `immich.private_ip_configured` | Immich URL saved that resolves to a private IP |
## Log columns
| Column | Description |
|---|---|
| Time | Timestamp of the action |
| User | Username and email of the acting user (or `anonymous` for unauthenticated events) |
| Action | Action key (see tables above) |
| Resource | Affected resource (filename, trip ID, tool name, etc.) where applicable |
| IP | Client IP address |
| Details | Additional context in JSON format |
## Pagination
The panel loads 100 entries at a time by default. Click **Load more** at the bottom to fetch the next page. The total count is shown above the table.
## IP addresses
The client IP is read from the `X-Forwarded-For` header. When TREK is behind a reverse proxy, set `TRUST_PROXY=true` so the header is trusted and the real client IP is recorded. Without this setting, the proxy's own IP is logged instead. See [Environment-Variables](Environment-Variables).
## Log file
In addition to the database, audit events are written to a plain-text log file:
- **Path:** `./data/logs/trek.log`
- **Rotation:** rotated when the file reaches 10 MB
- **Retention:** the 4 most recent rotated files are kept (`trek.log.1` through `trek.log.4`)
## Database retention
Audit entries in the database are never automatically deleted. They accumulate and are paginated in the UI.
## See also
- [Admin-Panel-Overview](Admin-Panel-Overview)
- [Security-Hardening](Security-Hardening)
- [Environment-Variables](Environment-Variables)
-88
View File
@@ -1,88 +0,0 @@
# Backups
TREK stores all data in a single SQLite database (`travel.db`) plus an `uploads/` directory of attachments, cover photos, and avatars. The Backup panel lets you create, download, restore, and schedule backups of both.
## Where to find it
**Admin Panel → Backup** tab.
<!-- TODO: screenshot: backup tab with backup list and auto-backup settings -->
![Backup tab](assets/Backup.png)
## What a backup contains
A backup is a ZIP archive with two entries:
| Entry | Contents |
|---|---|
| `travel.db` | The full SQLite database |
| `uploads/` | All uploaded attachments, covers, and avatars |
**Not included:** the encryption key. Store your `ENCRYPTION_KEY` separately from the backup ZIP — for example, in a password manager. See [Encryption-Key-Rotation](Encryption-Key-Rotation).
## Manual backup
Click **Create Backup** in the Backup tab. The server creates the ZIP and makes it available for download. Up to 3 manual backups can be created per hour per IP address (rate-limit window: 1 hour).
You can also download or delete any existing backup from the list.
## Restoring a backup
You can restore from:
- **A stored backup** — click **Restore** next to any backup in the list.
- **An uploaded ZIP** — click **Upload & Restore** and select a backup file from your computer (maximum upload size: 500 MB).
Before restoring, TREK runs integrity checks on the uploaded database:
1. **SQLite `PRAGMA integrity_check`** — verifies the database file is not corrupt.
2. **Required tables present** — confirms the file contains `users`, `trips`, `trip_members`, `places`, and `days`. Files missing any of these are rejected as not being a valid TREK backup.
> **Warning:** Restoring replaces all current data. Back up your current state first if you want to keep it.
## Auto-backup
Enable scheduled backups in the **Auto-Backup** section of the Backup tab.
**Interval** options:
- Hourly
- Daily
- Weekly
- Monthly
**Retention** (`Keep last … days`) — enter a number of days. Backups older than that many days are pruned after each auto-backup run. Set to **0** to keep all backups indefinitely (no pruning).
**Schedule** options (depend on interval):
- **Hour** — time of day for daily, weekly, and monthly backups (023).
- **Day of week** — Sunday through Saturday (for weekly backups).
- **Day of month** — 128 (for monthly backups). Day 2931 is excluded to avoid months with fewer days.
Auto-backup files are named `auto-backup-<timestamp>.zip` (manual backups use `backup-<timestamp>.zip`).
After each auto-backup run, **all** backup files (manual and auto) older than `keep_days` are pruned. Set `keep_days` to `0` to disable pruning entirely.
## Before updating TREK
Always create a manual backup before updating. See [Updating](Updating).
## Audit log
The following actions are recorded in the [Audit-Log](Audit-Log):
| Action key | When |
|---|---|
| `backup.create` | Manual backup created |
| `backup.restore` | Restore from stored backup |
| `backup.upload_restore` | Restore from uploaded ZIP |
| `backup.delete` | Backup deleted |
| `backup.auto_settings` | Auto-backup settings saved |
## See also
- [Encryption-Key-Rotation](Encryption-Key-Rotation)
- [Admin-Panel-Overview](Admin-Panel-Overview)
- [Security-Hardening](Security-Hardening)
- [Updating](Updating)
-83
View File
@@ -1,83 +0,0 @@
# Budget Tracking
Track trip expenses by category, split costs between members, and visualize spending.
<!-- TODO: screenshot: budget summary and expense list -->
![Budget panel](assets/Budget.png)
## Where to find it
Open the **Budget** tab inside the trip planner. The tab is only visible when the Budget addon is enabled.
> **Admin:** Budget is an addon. Enable it in [Admin-Addons](Admin-Addons).
## Currency
Use the currency picker in the Budget toolbar to select one currency for the entire trip. 46 currencies are supported (EUR, USD, GBP, JPY, CHF, CZK, PLN, SEK, NOK, DKK, TRY, THB, AUD, CAD, NZD, BRL, MXN, INR, IDR, MYR, PHP, SGD, KRW, CNY, HKD, TWD, ZAR, AED, SAR, ILS, EGP, MAD, HUF, RON, BGN, HRK, ISK, RUB, UAH, BDT, LKR, VND, CLP, COP, PEN, ARS). All amounts are displayed in this currency.
## Categories
Expenses are grouped into categories. Each category is shown with a small colored square indicator that cycles through a 12-color palette as you add more categories.
From the toolbar you can:
- **Add a category** — type a name and click the **+** button (or press Enter).
- **Rename a category** — click the pencil icon next to its name in the category header.
- **Reorder categories** — drag the grip handle on the left of the category header.
- **Delete a category** — click the trash icon in the category header. This deletes all expense items inside it.
## Expense items
Each category contains a table of items with the following columns:
| Column | Notes |
|---|---|
| Name | Editable inline. Read-only when linked to a reservation. |
| Total | The total cost for this item. |
| Persons | Number of persons (or member chips on multi-member trips). |
| Days | Number of days. |
| Per Person | Calculated: Total ÷ Persons. |
| Per Day | Calculated: Total ÷ Days. |
| Per Person/Day | Calculated: Total ÷ (Persons × Days). |
| Date | Optional expense date. |
| Note | Free-text note. |
Click any editable cell to edit it inline. Drag the grip handle to reorder items within a category.
Add a new item using the inline **add row** at the bottom of each category table.
## Splitting costs
The **Persons** column behaves differently depending on the trip:
- **Single-user trip** — enter a number of persons directly.
- **Multi-member trip** — a member chip picker appears. Click the edit button to assign or remove members from an expense. Click an assigned member chip again to mark them as **paid** (the chip shows a green ring).
## Settlement calculator
When multiple members are assigned to expenses and there are outstanding debts between members, a collapsible **Settlement** section appears inside the total card. Click the section header to expand it. It shows the minimum number of transfers needed to settle all debts (using a greedy matching algorithm), including:
- Transfer flows: who pays whom and how much.
- Net balances: each member's overall surplus or deficit.
## Budget summary
The right-hand column contains two widgets:
- **Total card** — displays the grand total in large type. On multi-member trips it also shows a per-member breakdown with a proportional bar.
- **Donut chart** — spending by category. Each segment uses that category's color. The legend always shows the amount and percentage for each category; hovering a legend row highlights it.
## Exporting
Click the **CSV** button in the toolbar to download a semicolon-delimited file containing all categories and items. The columns exported are: Category, Name, Date, Total, Persons, Days, Per Person, Per Day, Per Person/Day, Note.
## Permissions
All write operations (adding/editing/deleting items and categories, changing currency) require the `budget_edit` permission.
## See also
- [Admin-Addons](Admin-Addons)
- [Reservations-and-Bookings](Reservations-and-Bookings)
- [Trip-Planner-Overview](Trip-Planner-Overview)
-59
View File
@@ -1,59 +0,0 @@
# Collab Chat
Chat with your group in real time, without leaving the trip planner.
<!-- TODO: screenshot: chat panel with messages and reactions -->
![Collab Chat](assets/Collab.png)
## Where to find it
Open the trip planner and select the **Collab** tab. If the Chat sub-feature is enabled, a Chat panel appears — on desktop as the left column, on mobile as the first tab in the tab bar.
The Collab addon must be enabled by an admin and the Chat sub-feature must be turned on. See [Real-Time-Collaboration](Real-Time-Collaboration).
## Sending messages
Type in the input field at the bottom and press **Enter** (or click the send button) to post. Hold **Shift + Enter** to insert a line break without sending.
Messages load in pages of 100. A **Load more** button appears at the top of the chat when older messages are available.
## Emoji
Click the smiley-face button in the composer to open the emoji picker. The picker has three categories:
- **Smileys** — facial expressions and gestures
- **Reactions** — hearts, fire, thumbs, and similar
- **Travel** — planes, maps, food, cameras, and destinations
Emoji are rendered via Twemoji for consistent appearance across platforms.
## Reactions
**Right-click** a message on desktop (or **double-tap** on mobile) to open the quick-reaction menu. Eight quick reactions are available: ❤️ 😂 👍 😮 😢 🔥 👏 🎉. Click any reaction to toggle it on or off. Reactions from all users aggregate beneath the message; hover a reaction badge to see who reacted.
## Replies
Hover a message to reveal the action buttons. Click **Reply** to quote that message. A preview of the quoted text appears above your new message in the composer; click the **×** to cancel the reply. The quoted text is displayed inline inside your bubble when sent.
## URL link previews
When a message contains a URL, TREK automatically fetches an Open Graph preview (title, description, and thumbnail image) and displays it below the message text. Only the first URL in a message generates a preview.
## Message styling
Your own messages appear **right-aligned** with a blue bubble. Other members' messages appear **left-aligned** with a gray bubble. The username is shown above the first message in a group of consecutive messages from the same person; the avatar is shown beside the **last** message in that group. Timestamps appear below the last message in each group.
Messages that consist of only 13 emoji are displayed larger without a bubble.
## Deleting messages
Hover your own message to reveal the delete button (trash icon). The delete button is only visible when you have the `collab_edit` permission. Deleting replaces the bubble with an italicised notice — showing your username, "deleted a message", and a timestamp — visible to all members.
## Read-only viewers
Users without the `collab_edit` permission can read all messages but the composer is disabled — they cannot send, react, or delete messages. The reply button is visible to all users, but completing a reply still requires `collab_edit` (the send button is hidden for read-only users).
## Related pages
[Real-Time-Collaboration](Real-Time-Collaboration) · [Collab-Notes](Collab-Notes) · [Collab-Polls](Collab-Polls)
-72
View File
@@ -1,72 +0,0 @@
# Collab Notes
Share structured, richly formatted notes with your group. Notes are organized into color-coded categories and can be pinned, attached with files, and linked to external websites.
<!-- TODO: screenshot: collab notes editor with categories -->
![Collab Notes](assets/Collab.png)
## Where to find it
Open the trip planner → **Collab** tab → **Notes** section. The Collab addon must be enabled and the Notes sub-feature must be turned on. See [Real-Time-Collaboration](Real-Time-Collaboration).
## Categories
Notes are grouped into categories. Each category has a name and a color chosen from six swatches: **Indigo**, **Red**, **Amber**, **Emerald**, **Blue**, and **Violet**.
To manage categories, click the settings (gear) icon in the Notes header (only visible to users with the `collab_edit` permission). From the category settings modal you can:
- Add a new category (type a name and click **+**)
- Rename a category by clicking its name inline
- Change a category's color by clicking a swatch
- Delete a category
All changes (including color updates) are staged locally and applied only when you click **Save** in the modal. Saving a color change updates every note in that category.
## Creating a note
Click **+ New Note** in the Notes header (only visible to users with the `collab_edit` permission). A modal opens with the following fields:
| Field | Required | Notes |
|-------|----------|-------|
| Title | Yes | Plain text |
| Body | No | Markdown |
| Category | No | Select from existing categories |
| Website | No | URL; shows an Open Graph thumbnail on the note card |
| Attachments | No | Files up to 50 MB; see restrictions below |
Click **Create** to save.
## Markdown support
Note bodies are rendered as GitHub Flavored Markdown with soft line breaks. Supported syntax includes headings, bold/italic, links, ordered and unordered lists, task lists, tables, fenced code blocks, and blockquotes.
## Pinning
Click the pin icon on a note card to pin it. Pinned notes sort to the top of the list (above all unpinned notes, regardless of category). Click again (shown as the unpin icon) to unpin.
## Attachments
You can attach images, PDFs, and other files to a note (requires the `file_upload` permission). Maximum file size is **50 MB** per file.
The following file types are blocked: `.svg`, `.html`, `.htm`, `.xml`, `.xhtml`, `.js`, `.jsx`, `.ts`, `.exe`, `.bat`, `.sh`, `.cmd`, `.msi`, `.dll`, `.com`, `.vbs`, `.ps1`, `.php`.
Image thumbnails are shown on the note card. Click a thumbnail to open a lightbox. PDFs open in a document viewer overlay.
You can also paste images or PDFs directly into the create/edit form (if the `file_upload` permission is granted).
## Editing a note
Click the pencil icon on a note card to open the edit modal. All fields, including attachments, can be updated. To remove an existing attachment, click the **×** next to it in the edit form.
## Expanding a note
Click the expand icon (arrows) on a note card to open a full-content view in a portal overlay. Users with the `collab_edit` permission will see a pencil button in the overlay header to switch directly to the edit modal.
## Filtering
Use the category filter pills below the Notes header to show only notes belonging to a specific category. Click **All** to clear the filter.
## Related pages
[Real-Time-Collaboration](Real-Time-Collaboration) · [Collab-Chat](Collab-Chat) · [Collab-Polls](Collab-Polls)
-61
View File
@@ -1,61 +0,0 @@
# Collab Polls
Create group polls to make decisions collaboratively — where to eat, which hotel, what to do on a free day.
<!-- TODO: screenshot: poll with options and vote counts -->
![Collab Polls](assets/Collab.png)
## Where to find it
Open the trip planner → **Collab** tab → **Polls** section. The Collab addon must be enabled and the Polls sub-feature must be turned on. See [Real-Time-Collaboration](Real-Time-Collaboration).
## Creating a poll
Users with `collab_edit` permission can click **+ New** in the Polls header. A modal opens with the following fields:
| Field | Required | Notes |
|-------|----------|-------|
| Question | Yes | The poll prompt |
| Options | Yes | At least 2 options required; add more with **+ Add option** |
| Multiple choice | No | Toggle on to allow voters to select more than one option |
Click **Create** to save and broadcast the poll to all connected members.
## Voting
Click an option button to vote. A filled circle and blue highlight indicate your selection. For **multiple-choice** polls, you can click additional options to select more than one; clicking an already-selected option removes your vote for that option. For **single-choice** polls, clicking a different option moves your vote to the new selection.
Percentage bars and voter avatars are only visible **after you have voted** or once the poll is closed or expired.
## Results
Each option shows:
- A **percentage fill bar** that expands proportionally to the votes received
- Up to **3 voter avatar chips** stacked next to the option
- The **percentage** of total votes in the right margin
The option with the most votes is highlighted when the poll is closed or expired.
## Deadline
Polls may have an optional deadline set via the API. When a deadline is present, a live countdown badge appears on the poll card, updating every **30 seconds**. The badge shows the remaining time in days/hours/minutes format.
When the deadline passes, the poll is automatically treated as closed: voting is disabled and results are displayed to everyone.
## Closing a poll manually
Users with `collab_edit` permission can click the lock icon on an open poll to close it immediately. Once closed, voting is permanently disabled.
## Deleting a poll
Users with `collab_edit` permission can delete a poll using the trash icon. Deletion is permanent and removes the poll for all members.
## Active and closed sections
Open polls appear at the top of the list. Closed or expired polls move to a **Closed** section below the active polls.
## Related pages
[Real-Time-Collaboration](Real-Time-Collaboration) · [Collab-Chat](Collab-Chat) · [Collab-Notes](Collab-Notes)
-71
View File
@@ -1,71 +0,0 @@
# Creating a Trip
<!-- TODO: screenshot: trip creation form with date and cover fields -->
![Trip creation dialog](assets/TripCreate.png)
## Opening the Dialog
Click the **New Trip** button in the dashboard toolbar (or the **Create First Trip** button on the empty state) to open the Create Trip dialog.
You can also open it directly via a deep link: navigate to `/dashboard?create=1`. This is the URL used by system notices that prompt you to create a trip.
## Fields
### Title (required)
The trip name. Cannot be empty — saving is blocked until a title is entered.
### Description (optional)
A short free-text description shown on the trip card.
### Dates
Set a **Start date** and **End date** using the date picker. The day count is calculated automatically when both are set.
If you leave **both** dates empty, a separate **Day count** field appears. Enter a number between **1 and 365** to create a date-less itinerary with a fixed number of days.
You cannot set only one date and leave the other blank via normal interaction — setting a start date auto-fills or adjusts the end date to preserve the previous duration.
### Cover Image
The cover image is displayed on the trip card and as the background of the Spotlight card. You can add one in three ways:
- **Drag and drop** an image file onto the dashed upload area.
- **Paste from clipboard** — if you have an image in your clipboard, paste it anywhere in the dialog.
- **File picker** — click the upload area to browse for a file.
When **creating** a new trip the cover image field is always visible. When **editing** an existing trip it is only shown if you have the `trip_cover_upload` permission. For a new trip, the image is uploaded immediately after the trip is created.
### Reminder
A push notification sent before the trip departs. The field shows a set of preset options:
| Option | Days before departure |
|---|---|
| None | 0 |
| 1 day | 1 |
| 3 days | 3 |
| 9 days | 9 |
| Custom | 130 (you enter the number) |
When **creating** a new trip the reminder field is always visible. When **editing** an existing trip it is only shown to the **trip owner** or **admin** users.
If reminders are disabled on your instance (`trip_reminders_enabled = false`), the reminder section is shown at reduced opacity with an informational message in place of the preset buttons.
> **Admin:** Trip reminders are controlled by a server-side feature flag (`trip_reminders_enabled`). Contact your administrator to enable them.
### Members
Add initial trip members from the members selector. On a **new** trip, selected members are queued locally and added to the trip immediately after it is saved. The selector shows all registered users on your instance except yourself.
## Saving
Click **Create Trip**. The trip is saved and you are taken to the [Trip-Planner-Overview](Trip-Planner-Overview) for the new trip.
## Related Pages
- [Trip-Members-and-Sharing](Trip-Members-and-Sharing)
- [Trip-Planner-Overview](Trip-Planner-Overview)
- [My-Trips-Dashboard](My-Trips-Dashboard)
-62
View File
@@ -1,62 +0,0 @@
# Dashboard Widgets
The My Trips dashboard includes two utility widgets: a currency converter and a timezone clock.
<!-- TODO: screenshot: widget gallery showing currency converter and timezone clock -->
![Dashboard Widgets](assets/DashboardWidgets.png)
## Where they appear
On large screens (desktop/wide tablet), both widgets appear in a sticky right-hand sidebar of the [My-Trips-Dashboard](My-Trips-Dashboard). On mobile and narrow screens, the widgets are accessible via **Quick Actions** buttons on the dashboard — tapping the Currency or Timezone button opens a bottom sheet containing the full widget.
Each user configures their own widgets independently. Whether each widget is shown or hidden is saved to your account on the server (synced across devices). The selected currency pair and saved timezone list are stored in your browser's local storage and are device-specific.
### Showing and hiding widgets
On desktop, click the **Settings (gear) icon** in the dashboard toolbar to reveal toggle switches for each widget. Turning a widget off removes it from the sidebar; the preference is saved to your account.
---
## Currency Converter
The currency converter lets you quickly convert an amount between two currencies.
**How to use:**
1. Enter an amount in the input field.
2. Select a source currency from the left selector.
3. Select a target currency from the right selector.
4. The converted amount is displayed immediately below.
You can also click the swap arrow to reverse source and target.
**Exchange rates** are fetched from [exchangerate-api.com](https://www.exchangerate-api.com) using the `https://api.exchangerate-api.com/v4/latest/{from}` endpoint. Rates are refreshed each time you change a currency or click the refresh icon.
**Supported currencies:** 162 currencies are available in the selector, including all major fiat currencies (USD, EUR, GBP, JPY, etc.) and many minor ones.
---
## Timezone Clock
The timezone clock displays live clocks for multiple time zones simultaneously.
**How to use:**
- Your local time is always shown at the top.
- Below it, any zones you have added are listed with their current time and offset relative to your local zone.
- Click **+** to add a zone. You can pick from 18 preset city zones, or enter any IANA timezone identifier (e.g. `America/Denver`) with an optional custom label (if omitted, the city portion of the identifier is used as the label).
- Hover over a zone row and click **×** to remove it.
**Preset zones (18):**
New York, London, Berlin, Paris, Dubai, Mumbai, Bangkok, Tokyo, Sydney, Los Angeles, Chicago, São Paulo, Istanbul, Singapore, Hong Kong, Seoul, Moscow, Cairo.
Clocks update every 10 seconds. The 12-hour or 24-hour format follows your display settings (see [Display-Settings](Display-Settings)).
---
## See also
- [My-Trips-Dashboard](My-Trips-Dashboard)
- [Addons-Overview](Addons-Overview)
-73
View File
@@ -1,73 +0,0 @@
# Day Plans and Notes
The Day Plan sidebar lets you organize places into days, add free-form notes, and manage the order of your itinerary.
<!-- TODO: screenshot: itinerary view with assigned places and day notes -->
![Day Plan](assets/TripPlaner.png)
## The Day Plan sidebar
The Day Plan sidebar is the left panel in the trip planner. Each trip day is shown as a collapsible section. Expanded or collapsed state is saved per trip in `sessionStorage` (key: `day-expanded-{tripId}`), so your layout is preserved across page reloads in the same browser session.
## Day timeline
Each day shows a merged, time-ordered list of:
- **Assigned places** — with time, category icon, and action buttons
- **Day notes** — with their selected icon and optional time
- **Reservations and transports** — non-hotel types (flights, trains, cars, cruises) appear inline; hotels appear in the Day Detail panel
Items are sorted by their time or position index.
## Assigning places to a day
- **Drag and drop** — drag a place from the right-hand Places sidebar and drop it onto a day section or between existing items.
- **Mobile** — tap the **Add Place** button inside an expanded day section to open an inline search panel; find the place and tap it to assign.
You can also reorder places within a day, or move them to a different day, by dragging and dropping inside the sidebar.
## Multi-day reservations
A reservation that spans multiple days appears in each relevant day with a phase label:
| Reservation type | Start day | Middle days | End day |
|---|---|---|---|
| Flight | Departure | In transit | Arrival |
| Car | Pickup | Active | Return |
| Other | Start | Ongoing | End |
Car rentals that are in the "Active" (middle) phase are shown in the day header rather than the timeline.
## Day notes
Click the note **+** button in any day section to add a note. Notes have three fields:
- **Title** (required) — the main note text shown in the timeline
- **Subtitle / detail** (optional) — a free-form text field (Markdown supported) displayed beneath the title
- **Icon** — choose from 20 icons: FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark
Notes interleave with places and transports in the day timeline and are ordered by their `sort_order`. Use the **↑ / ↓** chevron buttons on a note to reposition it within the merged timeline. Notes can also be repositioned by dragging.
## Day Detail panel
Click a day header to open the Day Detail panel. It appears as a floating panel centered in the map area and shows:
- The weather forecast for that day (see [Weather-Forecasts](Weather-Forecasts))
- Reservations linked to assignments on that day
- Accommodation block (hotel check-in / check-out, with check-in window and confirmation number)
The panel can be collapsed to a slim header bar or closed entirely with the **X** button.
## Toolbar actions
At the top of the Day Plan sidebar:
- **Export PDF** — downloads a PDF of the full trip plan. See [PDF-Export](PDF-Export).
- **ICS** — exports the trip as a calendar file (.ics) for import into calendar apps.
- **Expand / Collapse all** — toggles all day sections open or closed at once.
- **Undo** — reverses the last drag, reorder, or assign action.
Route calculation controls (optimize order, open in Google Maps) appear inside each expanded day section after the place list.
**See also:** [Places-and-Search](Places-and-Search) · [Map-Features](Map-Features) · [Route-Optimization](Route-Optimization) · [Weather-Forecasts](Weather-Forecasts) · [Reservations-and-Bookings](Reservations-and-Bookings)
-62
View File
@@ -1,62 +0,0 @@
# Demo Mode
Demo mode lets you run a public "try before you install" instance of TREK. A shared demo account is available for visitors, write operations are blocked for that account, and the database resets automatically every hour so the instance stays in a known state.
<!-- TODO: screenshot: demo mode banner or try-demo button on login page -->
## Enabling demo mode
Set `DEMO_MODE=true` in your environment and restart TREK. See [Environment-Variables](Environment-Variables) for how to set environment variables.
When demo mode is active, the login page shows a one-click **"Try the demo"** button. Clicking it logs the visitor in as the demo user immediately — no credentials need to be entered and no registration is required.
**Demo account (auto-created on first start):**
| Field | Value |
|---|---|
| Email | `demo@trek.app` |
| Password | `demo12345` |
## What the demo user can and cannot do
The demo user account has read access to the shared trip data but the following operations are permanently blocked:
- **Password change** — returns 403.
- **Account deletion** — returns 403.
- **MFA enrollment or removal** — returns 403.
- **File uploads** — avatar uploads, trip cover uploads, and document/photo file attachments are blocked and return 403.
- **All MCP write tools** — create, update, and delete operations via the MCP API are blocked for the demo user.
Registration is also disabled while demo mode is active — visitors cannot create new accounts.
The admin account is unaffected and retains full access.
## Hourly reset
TREK schedules an automatic hourly reset of the demo database. At each reset:
1. The current `travel.db` is replaced with the saved baseline (`travel-baseline.db`).
2. The admin account's credentials (`password_hash`, API keys, avatar) are re-applied on top of the restored baseline, so admin API keys and password changes survive the reset.
If no baseline has been saved yet, the reset is skipped and a message is logged.
## Saving a baseline
The baseline is the snapshot the hourly reset restores to. The admin can update it at any time:
**Endpoint:** `POST /admin/save-demo-baseline`
This is available in the admin panel. The baseline captures the current state of the database — including trip data, settings, and encrypted API keys — so demo features (maps, photos, weather) continue to work after each reset.
On first start with demo mode active, TREK seeds three example trips (Tokyo & Kyoto, Barcelona Long Weekend, New York City) owned by the admin and shared with the demo user, then saves the initial baseline automatically.
## Limitations
- Demo mode is not for production use with real user data. The hourly reset deletes all visitor-created content.
- All demo visitors share a single account — there is no isolation between sessions.
- File uploads (photos, documents, trip covers, avatars) are disabled for the demo user.
## See also
- [Environment-Variables](Environment-Variables)
- [Backups](Backups)
-56
View File
@@ -1,56 +0,0 @@
# Display Settings
The Display tab (Settings → Display) controls the visual appearance and locale preferences of the app. All changes save immediately to your account and persist across devices.
<!-- TODO: screenshot: appearance settings panel -->
![Display Settings](assets/UsrSettings.png)
## Color mode
Choose between three options:
| Option | Behaviour |
|--------|-----------|
| Light | Always uses the light theme |
| Dark | Always uses the dark theme |
| Auto | Follows your operating system / browser preference |
## Language
Select your preferred language from the button grid (desktop) or dropdown (mobile). The change takes effect immediately without a page reload. See [Languages](Languages) for the full list of supported languages.
## Temperature unit
Affects the weather widget on trip days.
| Option | Display |
|--------|---------|
| °C Celsius | Metric |
| °F Fahrenheit | Imperial |
## Time format
Affects all time displays throughout the app.
| Option | Example |
|--------|---------|
| 24h | 14:30 |
| 12h | 2:30 PM |
## Route calculation
Toggles automatic route calculation between places on the trip map. Set to **On** or **Off**.
## Booking route labels
Shows or hides labels on booking-related route segments on the map. Set to **On** or **Off**.
## Blur booking codes
When enabled, confirmation codes and reference numbers are blurred until you hover or tap. Set to **On** or **Off**.
## See also
- [Languages](Languages)
- [User-Settings](User-Settings)
-76
View File
@@ -1,76 +0,0 @@
# Documents and Files
Attach and manage documents, tickets, and other files for your trip.
<!-- TODO: screenshot: file attachment list with filter tabs -->
![Files](assets/Files.png)
## Where to find it
Open the **Files** tab inside the trip planner, or navigate directly to `/trips/:id/files`.
> **Admin:** Files is an addon. Enable it in [Admin-Addons](Admin-Addons).
## Uploading
Drag and drop files onto the upload area, click it to open the file picker, or paste an image directly into the Files panel.
- **Maximum file size:** 50 MB per file.
- **Blocked file types:** `.svg`, `.html`, `.htm`, `.xml` — these are always rejected.
- **Default allowed types:** jpg, jpeg, png, gif, webp, heic, pdf, doc, docx, xls, xlsx, txt, csv, pkpass. An admin can customize the allowed list in [Admin-Addons](Admin-Addons).
Requires the `file_upload` permission.
## Browsing and filtering
The toolbar provides filter tabs: **All**, **PDF**, **Images**, **Docs**, and conditionally **Starred** (only shown when at least one file is starred) and **Collab** (only shown when files exist from collaborative notes). Each tab shows a count badge.
## Previewing files
Clicking a non-image file (e.g., PDF) opens an inline preview modal with options to open in a new tab or download. Clicking an image file opens a full-screen lightbox. You can:
- Navigate between images using the **arrow buttons** or the **left/right arrow keys**.
- Swipe left/right on touch devices.
- Jump to a specific image using the **thumbnail strip** at the bottom.
- Download or open the image in a new tab from the lightbox header.
## Starring
Click the **star icon** on any file to favorite it. Starred files sort to the top of the list and can be filtered with the Starred tab.
Requires the `file_edit` permission.
## Trash
Deleting a file moves it to the trash. Switch to the trash view using the **Trash** button in the toolbar. From the trash view you can:
- **Restore** a file to make it active again.
- **Permanently delete** a single file.
- **Empty trash** to permanently remove all trashed files at once.
Restore and delete operations require the `file_delete` permission.
## Linking files to places, reservations, or assignments
A file can be attached to multiple places and reservations at the same time (many-to-many). From the file manager, click the **link (pencil) icon** on a file to open the assign modal. From there you can toggle links to any trip place or reservation. You can also add a descriptive note to the file in the same modal.
From a reservation modal, use the "link existing file" picker to attach files directly.
## Downloading
Click the download icon on any file row to download it. `.pkpass` files (Apple Wallet passes) are served with the `application/vnd.apple.pkpass` MIME type, so Safari on iOS and macOS offers to add the pass to Wallet instead of saving it as a generic download.
## Permissions
| Permission | Controls |
|---|---|
| `file_upload` | Uploading new files. |
| `file_edit` | Starring and linking files. |
| `file_delete` | Moving to trash, restoring, and permanently deleting. |
## See also
- [Reservations-and-Bookings](Reservations-and-Bookings)
- [Admin-Addons](Admin-Addons)
- [Trip-Planner-Overview](Trip-Planner-Overview)
-85
View File
@@ -1,85 +0,0 @@
# Encryption Key Rotation
## What the encryption key protects
TREK encrypts sensitive settings at rest using AES-256-GCM. The following values are stored encrypted in the database:
- Google Maps API key (per user)
- Mapbox access token (per user)
- OpenWeather API key (per user)
- Immich API key (per user)
- Synology Photos password, session ID, and device ID (per user)
- Per-user webhook URL and ntfy notification token (in `settings` table)
- OIDC client secret (global, in `app_settings`)
- SMTP password (global, in `app_settings`)
- Admin webhook URL and admin ntfy token (global, in `app_settings`)
- MFA (TOTP) secrets for all users
- Photo passphrases for Synology shared-link photos (in `trek_photos`)
The encryption derives a key from `ENCRYPTION_KEY` using SHA-256 (with a domain suffix per secret type), so the raw `ENCRYPTION_KEY` value is never stored in the database.
## Key resolution order
On startup, TREK resolves the encryption key in this order:
1. **`ENCRYPTION_KEY` environment variable** — explicit, always takes priority. When set, the value is also written to `./data/.encryption_key` so it survives container restarts if the env var is later removed.
2. **`./data/.encryption_key` file** — present on any install that has started at least once.
3. **`./data/.jwt_secret` file** — one-time fallback for older installs that pre-date the dedicated encryption key. The value is immediately persisted to `./data/.encryption_key` so future JWT rotations cannot break decryption.
4. **Auto-generated** — fresh install with none of the above. A random 32-byte hex key is generated and written to `./data/.encryption_key`.
## What happens if the key is lost
All encrypted settings (API keys, SMTP password, OIDC secret, MFA secrets, notification tokens, etc.) become unreadable — TREK cannot decrypt them. They must be re-entered manually after the key is restored or replaced. Unencrypted data (trips, places, users, etc.) is unaffected.
## Backing up the key
Your backup ZIP does **not** include the encryption key. Store the key separately from your backups — for example, in a password manager or a secrets manager. See [Backups](Backups).
To find your current key: check the `ENCRYPTION_KEY` environment variable or read `./data/.encryption_key`.
## Rotating the key
Use `scripts/migrate-encryption.ts` to re-encrypt all stored secrets without downtime or manual re-entry.
**Docker:**
```bash
docker exec -it trek node --import tsx scripts/migrate-encryption.ts
```
**Host (run from the `server/` directory):**
```bash
node --import tsx scripts/migrate-encryption.ts
```
The script:
1. Prompts for the old and new encryption keys interactively (keys are never echoed to the terminal or written to shell history).
2. Asks for confirmation before making any changes.
3. Creates a timestamped backup of the database (e.g. `travel.db.backup-1713484800000`) before modifying anything.
4. Re-encrypts all stored secrets across all tables:
- `app_settings`: `oidc_client_secret`, `smtp_pass`, `admin_webhook_url`, `admin_ntfy_token`
- `users` (per user): `maps_api_key`, `openweather_api_key`, `immich_api_key`, `synology_password`, `synology_sid`, `synology_did`, `mfa_secret`
- `settings` (per user): `webhook_url`, `ntfy_token`, `mapbox_access_token`
- `trip_album_links`: `passphrase`
- `trek_photos`: `passphrase`
5. Reports counts of migrated, already-migrated, skipped (empty), and errored values.
After a successful migration:
1. Update `ENCRYPTION_KEY` in your environment to the new value.
2. Restart TREK.
If any secrets could not be migrated, the script exits with a non-zero status and the original database backup is retained.
## Upgrading from very old versions
Old installs may have used `./data/.jwt_secret` as the encryption source (before a dedicated `ENCRYPTION_KEY` was introduced). The key resolution chain above handles this automatically on startup — the JWT secret is read, immediately written to `./data/.encryption_key`, and JWT rotation is then safe without breaking decryption.
## See also
- [Backups](Backups)
- [Security-Hardening](Security-Hardening)
- [Environment-Variables](Environment-Variables)
- [User-Settings](User-Settings)
-135
View File
@@ -1,135 +0,0 @@
# Environment Variables
Complete reference for all environment variables TREK reads.
## How to Set Variables
- **Docker Compose** — use the `environment:` block or a `.env` file alongside `docker-compose.yml`
- **Docker run** — pass each variable with `-e VARIABLE=value`
- **Helm** — use `env:` for plain values and `secretEnv:` for sensitive values in `values.yaml`
- **Unraid** — set in the container template editor
---
## Core
| Variable | Description | Default |
|---|---|---|
| `PORT` | Server port | `3000` |
| `NODE_ENV` | Environment (`production` / `development`) | `production` |
| `ENCRYPTION_KEY` | At-rest encryption key — see resolution order below | auto |
| `TZ` | Timezone for logs, reminders, and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
| `LOG_LEVEL` | `info` = concise user actions; `debug` = verbose details | `info` |
| `DEFAULT_LANGUAGE` | Default language on the login page — see supported codes below | `en` |
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email notification links | same-origin |
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs. Set `true` if Immich or other integrated services are on your local network. Loopback (`127.x`) and link-local (`169.254.x`) addresses remain blocked regardless. | `false` |
| `APP_URL` | Public base URL (e.g. `https://trek.example.com`). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as the base URL for email notification links. | — |
### `ENCRYPTION_KEY` — Resolution Order
`server/src/config.ts` resolves the encryption key in this order:
1. **`ENCRYPTION_KEY` env var** — explicit value, always takes priority. Persisted to `data/.encryption_key` automatically.
2. **`data/.encryption_key` file** — present on any install that has started at least once.
3. **`data/.jwt_secret` file** — one-time fallback for existing installs upgrading without a pre-set key. The value is immediately persisted to `data/.encryption_key` so JWT rotation cannot break decryption later.
4. **Auto-generated** — fresh install with none of the above; persisted to `data/.encryption_key`.
Setting `ENCRYPTION_KEY` explicitly is recommended so you can back it up independently of the data volume.
### `DEFAULT_LANGUAGE` — Supported Codes
Verified in `server/src/config.ts` (line 107):
`de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar`
> **Note:** `id` (Indonesian / Bahasa Indonesia) appears in `client/src/i18n/supportedLanguages.ts` but is not in the server's supported-codes list in `config.ts`. Setting `DEFAULT_LANGUAGE=id` will fall back to `en` with a warning in the server log.
---
## HTTPS / Proxy
These three variables work together behind a TLS-terminating reverse proxy. See [Reverse-Proxy] for the full explanation.
| Variable | Description | Default |
|---|---|---|
| `FORCE_HTTPS` | When `true`: 301-redirects HTTP→HTTPS, sends HSTS (`max-age=31536000`), adds CSP `upgrade-insecure-requests`, forces cookie `secure` flag. Only useful behind a TLS proxy. Requires `TRUST_PROXY`. | `false` |
| `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 |
> **Warning:** `FORCE_HTTPS=true` without `TRUST_PROXY` set causes a redirect loop.
---
## OIDC / SSO
For setup instructions, see [OIDC-SSO].
| Variable | Description | Default |
|---|---|---|
| `OIDC_ISSUER` | OpenID Connect provider URL (e.g. `https://auth.example.com`) | — |
| `OIDC_CLIENT_ID` | OIDC client ID | — |
| `OIDC_CLIENT_SECRET` | OIDC client secret | — |
| `OIDC_DISPLAY_NAME` | Label shown on the SSO login button | `SSO` |
| `OIDC_ONLY` | Force SSO-only mode: disables password login and registration, overrides Admin > Settings toggles, cannot be changed at runtime. First SSO login becomes admin on a fresh instance. | `false` |
| `OIDC_ADMIN_CLAIM` | OIDC claim used to identify admin users (e.g. `groups`) | — |
| `OIDC_ADMIN_VALUE` | Value of the OIDC claim that grants admin role (e.g. `app-trek-admins`) | — |
| `OIDC_SCOPE` | Space-separated OIDC scopes to request. **Fully replaces** the default — always include `openid email profile` plus any extra scopes (e.g. add `groups` when using `OIDC_ADMIN_CLAIM`) | `openid email profile` |
| `OIDC_DISCOVERY_URL` | Override the auto-constructed OIDC discovery endpoint. Required for providers with a non-standard path (e.g. Authentik) | — |
---
## Email / SMTP
SMTP settings can be configured via the Admin panel or overridden with environment variables. Env vars take priority over the database values.
| Variable | Description | Default |
|---|---|---|
| `SMTP_HOST` | SMTP server hostname (e.g. `smtp.example.com`) | — |
| `SMTP_PORT` | SMTP server port. Port `465` enables implicit TLS (`secure: true`); all other ports use STARTTLS or plain. | — |
| `SMTP_USER` | SMTP authentication username | — |
| `SMTP_PASS` | SMTP authentication password | — |
| `SMTP_FROM` | Sender address for outbound emails (e.g. `TREK <noreply@example.com>`) | — |
| `SMTP_SKIP_TLS_VERIFY` | Set `true` to disable TLS certificate validation. Useful for self-signed certs on internal SMTP relays — not recommended in production. | `false` |
`SMTP_HOST`, `SMTP_PORT`, and `SMTP_FROM` are all required for email delivery to work. `SMTP_USER` and `SMTP_PASS` are optional (for unauthenticated relays).
---
## Initial Setup
These variables only take effect on first boot, before any user exists.
| Variable | Description | Default |
|---|---|---|
| `ADMIN_EMAIL` | Email for the first admin account | `admin@trek.local` |
| `ADMIN_PASSWORD` | Password for the first admin account | random |
Both variables must be set together. If either is omitted, the account is created with email `admin@trek.local` and a randomly generated password that is printed to the server log. Once any user exists, these variables have no effect.
---
## MCP
For setup instructions, see [MCP-Overview].
| Variable | Description | Default |
|---|---|---|
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `20` |
---
## Other
| Variable | Description | Default |
|---|---|---|
| `DEMO_MODE` | Enable demo mode (hourly data resets). Not intended for regular use. | `false` |
---
## Related Pages
- [Reverse-Proxy] — HTTPS proxy setup and the `FORCE_HTTPS` / `TRUST_PROXY` / `COOKIE_SECURE` trio
- [OIDC-SSO] — complete OIDC configuration guide
- [MCP-Overview] — MCP server setup and rate limiting
- [Encryption-Key-Rotation] — rotating the `ENCRYPTION_KEY` without losing data
-42
View File
@@ -1,42 +0,0 @@
# FAQ
## Do I need a Google Maps API key?
No. When no Google Maps key is configured, TREK automatically falls back to OpenStreetMap (Nominatim) for place search — no API key or account required. If you want richer place data (photos, ratings, opening hours), an admin can optionally add a Google Maps key in [User Settings](User-Settings).
## Can I use TREK offline?
Yes. TREK is a Progressive Web App. After your first visit, the service worker (powered by Workbox) caches map tiles (Carto and OpenStreetMap), non-sensitive API responses, uploaded covers and avatars, and all static assets. Subsequent visits work without a network connection for already-cached content. See [Offline Mode and PWA](Offline-Mode-and-PWA) for installation instructions.
> **Note:** Auth, admin, backup, and settings endpoints are intentionally excluded from the offline cache.
## How many MCP tokens can I create?
Each user can create up to **10 static API tokens**. Static tokens are deprecated — migrate to OAuth 2.1 when possible.
For OAuth 2.1, each user can register up to **10 OAuth clients**. The default limit for concurrent MCP sessions is **20 per user** (configurable via `MCP_MAX_SESSION_PER_USER`). See [MCP Setup](MCP-Setup).
> **Admin:** MCP must be enabled in Admin Panel > Addons before any user can access it.
## Where is my data stored?
| Type | Path |
|------|------|
| Database | `./data/travel.db` (SQLite) |
| Uploads | `./uploads/` |
| Logs | `./data/logs/trek.log` (auto-rotated) |
| Backups | `./data/backups/` |
When running in Docker, mount `./data` and `./uploads` as volumes so your data survives container updates. See [Install: Docker Compose](Install-Docker-Compose).
## How do I update TREK?
Pull the new image and recreate the container. Your data is in the mounted volumes and is never modified by the update process. See [Updating](Updating) for the exact commands.
## Can I restrict who can register?
Yes. An admin can disable open registration so that new accounts can only be created via invite links. See [Admin: Users and Invites](Admin-Users-and-Invites).
## Does TREK support single sign-on?
Yes, via OpenID Connect (OIDC). Compatible providers include Google, Authentik, Keycloak, and any standard OIDC-compliant IdP. Set `OIDC_ONLY=true` to disable password login entirely. See [OIDC SSO](OIDC-SSO).
-60
View File
@@ -1,60 +0,0 @@
# TREK Wiki
TREK is a self-hosted, real-time collaborative travel planner licensed under AGPL-3.0.
![Dashboard](assets/DashboardWidgets.png)
## Features
### Planning
- **Drag & Drop Planner** — organize places into day plans with reordering and cross-day moves
- **Interactive Map** — Leaflet map with photo markers, clustering, route visualization, and customizable tile sources
- **Place Search** — Google Places (photos, ratings, hours) or OpenStreetMap (free, no API key needed)
- **Day Notes** — timestamped, icon-tagged notes per day
- **Route Optimization** — auto-optimize place order and export to Google Maps
- **Weather Forecasts** — 16-day forecasts via Open-Meteo (no API key required), historical climate averages as fallback
### Travel Management
- **Reservations & Bookings** — track flights, accommodations, restaurants with confirmation numbers and file attachments
- **Budget Tracking** — category-based expenses with pie chart, per-person/per-day splitting, multi-currency support
- **Packing Lists** — category-based checklists with user assignment, templates, and progress tracking
- **Document Manager** — attach documents, tickets, and PDFs to trips, places, or reservations (up to 50 MB per file)
- **PDF Export** — export complete trip plans as PDF with cover page, images, and notes
### Collaboration
- **Real-Time Sync** — WebSocket-based live sync; changes appear instantly for all connected users
- **Multi-User** — invite members with role-based access
- **Invite Links** — one-time registration links with configurable max uses and expiry
- **OIDC SSO** — sign in with Google, Apple, Authentik, Keycloak, or any OIDC provider
- **Two-Factor Authentication** — TOTP-based 2FA with QR code setup
- **Public Share Links** — share a read-only view of any trip
### Addons _(admin-toggleable)_
- **Vacay** — personal vacation day planner with calendar view, public holidays, and carry-over tracking
- **Atlas** — interactive world map, bucket list, travel stats, continent breakdown
- **Journey** — travel journal linking entries to trips, with contributor roles
- **Memories** — photo-focused trip memories
- **Collab** — group chat, shared notes, polls, and activity sign-ups
- **Dashboard Widgets** — currency converter and timezone clock, toggled per user
### AI / MCP Integration
- **MCP Server** — built-in Model Context Protocol server with OAuth 2.1 authentication
- **80+ Tools** — create trips, plan itineraries, manage budgets, send messages, and more
- **24 OAuth Scopes** — granular permissions across 13 permission groups
- **Pre-built Prompts**`trip-summary`, `packing-list`, and `budget-overview` context loaders
### Admin
- User management, invite links, packing templates, global categories
- Addon management, API key storage, scheduled auto-backups
- System notices for onboarding and announcements
> **Admin:** Most configuration lives in the Admin Panel. The first user to register becomes the admin automatically.
## Get Started
| | |
|---|---|
| [Quick Start](Quick-Start) | Install in minutes with a single Docker command |
| [My Trips Dashboard](My-Trips-Dashboard) | Start planning your first trip |
| [Admin Panel](Admin-Panel-Overview) | Configure your instance |
| [MCP / AI Integration](MCP-Overview) | Connect Claude, Cursor, or any MCP client |
-120
View File
@@ -1,120 +0,0 @@
# Install: Docker Compose
Production-ready setup using Docker Compose with security hardening enabled.
## Compose File
Create a `docker-compose.yml` with the following content (taken directly from the repository):
```yaml
services:
app:
image: mauriceboe/trek:latest
container_name: trek
read_only: true
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- CHOWN
- SETUID
- SETGID
tmpfs:
- /tmp:noexec,nosuid,size=64m
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- PORT=3000
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # Recommended. Generate with: openssl rand -hex 32. If unset, falls back to data/.jwt_secret (existing installs) or auto-generates a key (fresh installs).
- TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
- LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details
# - DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
# - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy
# - COOKIE_SECURE=false # Escape hatch: force session cookies over plain HTTP even in production. Not recommended.
# - TRUST_PROXY=1 # Trusted proxy count for X-Forwarded-For / X-Forwarded-Proto. Required for FORCE_HTTPS to work.
# - ALLOW_INTERNAL_NETWORK=false # Set to true if Immich or other services are hosted on your local network (RFC-1918 IPs). Loopback and link-local addresses remain blocked regardless.
# - APP_URL=https://trek.example.com # Public base URL — required when OIDC is enabled (must match the redirect URI registered with your IdP); also used as base URL for links in email notifications
# - OIDC_ISSUER=https://auth.example.com # OpenID Connect provider URL
# - OIDC_CLIENT_ID=trek # OpenID Connect client ID
# - OIDC_CLIENT_SECRET=supersecret # OpenID Connect client secret
# - OIDC_DISPLAY_NAME=SSO # Label shown on the SSO login button
# - OIDC_ONLY=false # Set true to force SSO-only mode: disables password login and registration, overrides Admin > Settings toggles, cannot be changed at runtime
# - OIDC_ADMIN_CLAIM=groups # OIDC claim used to identify admin users
# - OIDC_ADMIN_VALUE=app-trek-admins # Value of the OIDC claim that grants admin role
# - OIDC_SCOPE=openid email profile # Fully overrides the default. Add extra scopes as needed (e.g. add groups if using OIDC_ADMIN_CLAIM)
# - OIDC_DISCOVERY_URL= # Override the OIDC discovery endpoint for providers with non-standard paths (e.g. Authentik)
# - ADMIN_EMAIL=admin@trek.local # Initial admin e-mail — only used on first boot when no users exist
# - ADMIN_PASSWORD=changeme # Initial admin password — only used on first boot when no users exist
# - MCP_RATE_LIMIT=300 # Max MCP API requests per user per minute (default: 300)
# - MCP_MAX_SESSION_PER_USER=20 # Max concurrent MCP sessions per user (default: 20)
volumes:
- ./data:/app/data
- ./uploads:/app/uploads
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
```
## Security Hardening Explained
The compose file ships with several hardening options enabled by default:
| Setting | What it does |
|---|---|
| `read_only: true` | Mounts the container filesystem read-only; only the two named volumes and `/tmp` are writable |
| `security_opt: no-new-privileges:true` | Prevents the process from gaining additional Linux privileges via setuid/setgid executables |
| `cap_drop: [ALL]` | Drops all Linux capabilities from the container |
| `cap_add: [CHOWN, SETUID, SETGID]` | Adds back only the capabilities needed for the entrypoint to drop privileges to the `node` user |
| `tmpfs: /tmp:noexec,nosuid,size=64m` | Mounts a 64 MB in-memory `/tmp`; required because the container root is read-only |
## Volumes
| Host path | Container path | Contents |
|---|---|---|
| `./data` | `/app/data` | SQLite database, logs, `.jwt_secret`, `.encryption_key` |
| `./uploads` | `/app/uploads` | Uploaded files (photos, documents, covers, avatars) |
## Environment Variables
The compose file reads variables from a `.env` file placed alongside `docker-compose.yml`. At minimum, set:
```bash
# .env
ENCRYPTION_KEY=<output of: openssl rand -hex 32>
TZ=Europe/Berlin
ALLOWED_ORIGINS=https://trek.example.com
APP_URL=https://trek.example.com
```
Uncomment and fill in the OIDC, initial setup, or MCP variables as needed. For a full description of every variable, see [Environment-Variables].
## Start TREK
```bash
docker compose up -d
```
Check the logs:
```bash
docker compose logs -f
```
## HTTPS and Reverse Proxy
This compose file is designed for deployments where a reverse proxy (nginx, Caddy, Traefik) terminates TLS in front of TREK. To enable HTTPS redirects and secure cookies, uncomment `FORCE_HTTPS=true` and `TRUST_PROXY=1`.
See [Reverse-Proxy] for complete proxy configuration examples.
## Next Steps
- [Environment-Variables] — full variable reference
- [Reverse-Proxy] — HTTPS configuration
- [Updating] — how to pull a new image
-76
View File
@@ -1,76 +0,0 @@
# Install: Docker
Single-container Docker run — suitable for testing or simple personal installs.
## Run Command
```bash
docker run -d \
--name trek \
-p 3000:3000 \
-v ./data:/app/data \
-v ./uploads:/app/uploads \
-e ENCRYPTION_KEY=<your-32-byte-hex-key> \
--restart unless-stopped \
mauriceboe/trek:latest
```
`ENCRYPTION_KEY` is strongly recommended but not strictly required. If omitted, a key is auto-generated on first start and persisted to `data/.encryption_key`. Setting it explicitly means you can recreate the container from scratch (e.g. on a new host) without losing access to stored encrypted data (API keys, SMTP credentials, OIDC secrets, MFA secrets).
Generate an encryption key with:
```bash
openssl rand -hex 32
```
### Common optional variables
Pass additional `-e` flags for timezone and CORS/email link support:
```bash
-e TZ=Europe/Berlin \
-e ALLOWED_ORIGINS=https://trek.example.com \
```
See [Environment-Variables] for the full list.
## Volume Reference
| Volume | Container path | What lives there |
|---|---|---|
| `./data` | `/app/data` | `travel.db` (SQLite database), `logs/trek.log`, `.jwt_secret`, `.encryption_key` |
| `./uploads` | `/app/uploads` | Uploaded files (photos, documents, covers, avatars) |
Both volumes must survive container replacement — they are your persistent state. Never remove them before pulling a new image.
## Health Check
The container exposes a health endpoint at:
```
http://localhost:3000/api/health
```
Docker polls it automatically (interval: 30 s, timeout: 5 s, retries: 3, start period: 15 s). You can check it manually:
```bash
curl -s http://localhost:3000/api/health
```
## Verify the Container Is Running
```bash
docker ps --filter name=trek
docker logs trek
```
## Limitations of `docker run`
A bare `docker run` command has no built-in secret management and is harder to reproduce after a system reboot. For production, see [Install-Docker-Compose], which adds security hardening (`read_only`, `cap_drop`, `cap_add`, `no-new-privileges`, `tmpfs`) and makes it easy to manage environment variables through a `.env` file.
## Next Steps
- [Reverse-Proxy] — HTTPS is required for PWA install and the `trek_session` cookie `secure` flag
- [Install-Docker-Compose] — recommended for production
- [Environment-Variables] — full list of configurable variables
- [Updating] — how to pull a new image without losing data
-195
View File
@@ -1,195 +0,0 @@
# Install: Helm
Deploy TREK on Kubernetes using the official Helm chart.
## Add the Chart Repository
```bash
helm repo add trek https://mauriceboe.github.io/TREK
helm repo update
```
## Basic Install
```bash
helm install trek trek/trek
```
This deploys TREK with default values: a `ClusterIP` service on port 3000, 1 Gi PVCs for data and uploads, and no ingress.
## Encryption Key
`ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. There are three ways to handle it:
**Option 1 — Let the chart generate a random key (recommended for new installs):**
```bash
helm install trek trek/trek --set generateEncryptionKey=true
```
The chart generates a 32-character alphanumeric key at install time and preserves it across upgrades. Note that this differs from the 64-character hex key produced by `openssl rand -hex 32` — both formats are accepted by the server.
**Option 2 — Set an explicit key:**
```bash
helm install trek trek/trek \
--set secretEnv.ENCRYPTION_KEY=$(openssl rand -hex 32)
```
**Option 3 — Use an existing Kubernetes Secret:**
```bash
kubectl create secret generic trek-secrets \
--from-literal=ENCRYPTION_KEY=$(openssl rand -hex 32)
helm install trek trek/trek \
--set existingSecret=trek-secrets
```
If `existingSecret` uses a different key name than `ENCRYPTION_KEY`, specify it with `--set existingSecretKey=MY_KEY_NAME`.
> **Note:** If both `generateEncryptionKey` and `existingSecret` are set, `existingSecret` takes precedence. Only one method should be active at a time.
> **Note:** If `ENCRYPTION_KEY` is left empty, the server resolves it automatically: existing installs fall back to `data/.jwt_secret` (encrypted data stays readable after upgrade); fresh installs auto-generate a key persisted to the data PVC.
> **Note:** `JWT_SECRET` is managed entirely by the server — auto-generated on first start and persisted to the data PVC. It can be rotated via the admin panel (Settings → Danger Zone → Rotate JWT Secret). No Helm configuration is needed or supported for it.
## Admin Account
`ADMIN_EMAIL` and `ADMIN_PASSWORD` are set via `secretEnv`. They are only used on first boot when no users exist yet. **Both must be set together** — if either is missing, the server ignores both values and instead creates the admin account with email `admin@trek.local` and a random password, which is printed to the server log.
```bash
helm install trek trek/trek \
--set secretEnv.ADMIN_EMAIL=admin@example.com \
--set secretEnv.ADMIN_PASSWORD=<your-secure-password>
```
> **Note:** When `OIDC_ONLY=true` is configured together with `OIDC_ISSUER` and `OIDC_CLIENT_ID`, no local admin account is created on first boot. Instead, the first user to log in via SSO automatically becomes admin.
## Key `values.yaml` Settings
### Image
```yaml
image:
repository: mauriceboe/trek
# tag: latest # defaults to the chart's appVersion
pullPolicy: IfNotPresent
# Optional: pull secrets for private registries
imagePullSecrets: []
# - name: my-registry-secret
```
### Service
```yaml
service:
type: ClusterIP # change to LoadBalancer or NodePort to expose externally
port: 3000
```
### Plain Environment Variables (`env`)
```yaml
env:
NODE_ENV: production
PORT: 3000
# TZ: "Europe/Berlin" # timezone for logs, reminders, cron jobs
# LOG_LEVEL: "info" # "info" = concise, "debug" = verbose
# DEFAULT_LANGUAGE: "en" # fallback language on login page; supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
# ALLOWED_ORIGINS: "https://trek.example.com"
# APP_URL: "https://trek.example.com"
# FORCE_HTTPS: "false" # enable HTTPS redirect + HSTS; requires TRUST_PROXY
# TRUST_PROXY: "1" # proxy hops for X-Forwarded-For/Proto; defaults to 1 in production
# COOKIE_SECURE: "true" # auto-derived; set "false" only for local HTTP testing
# ALLOW_INTERNAL_NETWORK: "false" # set "true" if Immich or other services are on a private network
# DEMO_MODE: "false" # enable demo mode (hourly data resets)
# MCP_RATE_LIMIT: "300" # max MCP requests per user per minute
# OIDC_ISSUER: "https://auth.example.com"
# OIDC_CLIENT_ID: "trek"
# OIDC_DISPLAY_NAME: "SSO"
# OIDC_ONLY: "false" # force SSO-only mode; disables password login
# OIDC_ADMIN_CLAIM: "" # OIDC claim used to identify admin users
# OIDC_ADMIN_VALUE: "" # value of that claim that grants admin role
# OIDC_SCOPE: "openid email profile groups"
# OIDC_DISCOVERY_URL: "" # override for providers with non-standard discovery paths (e.g. Authentik)
```
### Sensitive Variables (`secretEnv`)
These are stored in a Kubernetes Secret and injected as environment variables:
```yaml
secretEnv:
ENCRYPTION_KEY: "" # recommended: openssl rand -hex 32
ADMIN_EMAIL: "" # initial admin email (first boot only)
ADMIN_PASSWORD: "" # initial admin password (first boot only)
OIDC_CLIENT_SECRET: "" # set if using OIDC
```
Alternatively, use `generateEncryptionKey: true` to let the chart generate and manage the encryption key, or point `existingSecret` / `existingSecretKey` at an existing Kubernetes Secret.
### Persistent Storage
```yaml
persistence:
enabled: true
data:
size: 1Gi # SQLite database, logs, secrets
uploads:
size: 1Gi # uploaded files — increase if you expect large media uploads
```
### Resource Limits
```yaml
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
```
### Ingress
```yaml
ingress:
enabled: true
className: "nginx" # your ingress class
annotations:
nginx.ingress.kubernetes.io/proxy-read-timeout: "86400" # required for WebSockets
nginx.ingress.kubernetes.io/proxy-body-size: "500m" # required for backup restore
hosts:
- host: trek.example.com
paths:
- /
tls:
- secretName: trek-tls
hosts:
- trek.example.com
```
> **Important:** TREK uses WebSockets on `/ws`. Your ingress controller must support WebSocket upgrades. Set `proxy-read-timeout` to at least `86400` and `proxy-body-size` to at least `500m` for backup restores.
> **Note:** Keep `env.ALLOWED_ORIGINS` in sync with `ingress.hosts` — the chart does not synchronize these automatically.
> **Note:** When using ingress with TLS termination, set `env.FORCE_HTTPS: "true"` and `env.TRUST_PROXY: "1"` to enable HTTPS redirects, HSTS, and secure cookies.
## Upgrade
```bash
helm repo update
helm upgrade trek trek/trek
```
## Full Values Reference
See the [`charts/README.md`](https://github.com/mauriceboe/TREK/blob/main/charts/README.md) for all available values.
## Next Steps
- [Environment-Variables] — full variable reference
- [Reverse-Proxy] — proxy configuration for non-Kubernetes deployments
-73
View File
@@ -1,73 +0,0 @@
# Install: Unraid
Install TREK on Unraid via Community Applications or a direct template import.
<!-- TODO: screenshot: Unraid container template settings -->
## Prerequisite
Docker must be enabled in Unraid (**Settings → Docker → Enable Docker: Yes**).
## Install via Community Applications
1. Open the **Apps** tab in Unraid.
2. Search for **TREK**.
3. Click **Install** on the TREK result.
If the app does not appear, you can install directly from the template URL. In **Docker → Add Container**, paste the template URL:
```
https://raw.githubusercontent.com/mauriceboe/TREK/main/unraid-template.xml
```
## Template Fields
The Unraid template exposes the following fields in the container UI:
### Ports & Paths
| Field | Container path | Default host value |
|---|---|---|
| Web UI Port | `3000/tcp` | `3000` |
| Data | `/app/data` | `/mnt/user/appdata/trek/data` |
| Uploads | `/app/uploads` | `/mnt/user/appdata/trek/uploads` |
### Core Variables (always visible)
| Variable | Default | Notes |
|---|---|---|
| `ENCRYPTION_KEY` | *(empty)* | Set on first install. Generate with `openssl rand -hex 32` in the Unraid terminal. |
| `TZ` | `UTC` | Timezone for logs, reminders, and scheduled tasks (e.g. `Europe/Berlin`) |
| `ALLOWED_ORIGINS` | *(empty)* | Comma-separated origins for CORS and email notification links, e.g. `https://trek.example.com` |
| `APP_URL` | *(empty)* | Public base URL; required when OIDC is enabled (must match the redirect URI registered with your IdP) |
| `ADMIN_EMAIL` | *(empty)* | Email for the first admin account (first-boot only; no effect once any user exists). Must be set together with `ADMIN_PASSWORD`. |
| `ADMIN_PASSWORD` | *(empty)* | Password for the first admin account (first-boot only). Must be set together with `ADMIN_EMAIL`. If either is omitted, TREK creates the account with email `admin@trek.local` and a random password printed to the container log. |
### Advanced Variables
Additional variables (`PORT`, `NODE_ENV`, `LOG_LEVEL`, `DEFAULT_LANGUAGE`, `FORCE_HTTPS`, `TRUST_PROXY`, `COOKIE_SECURE`, `ALLOW_INTERNAL_NETWORK`, all OIDC variables, `MCP_RATE_LIMIT`, `MCP_MAX_SESSION_PER_USER`, `DEMO_MODE`) are available under **Advanced View** in the template editor.
## Setting the Encryption Key
Generate a key in the Unraid terminal (**Tools → Terminal**):
```bash
openssl rand -hex 32
```
Copy the output into the `ENCRYPTION_KEY` field before starting the container for the first time. If you skip this, TREK auto-generates a key and saves it to `data/.encryption_key` — your data is still protected, but you should record that file in your backups.
## After Install
Once the container starts, open your browser at:
```
http://<unraid-ip>:<port>
```
On first boot, TREK automatically creates an admin account. The credentials are printed to the container log — check **Docker → trek → Log** in the Unraid UI. If you set both `ADMIN_EMAIL` and `ADMIN_PASSWORD`, those values are used; otherwise the email is `admin@trek.local` and a random password is generated.
## Next Steps
- [Environment-Variables] — complete variable reference
- [Updating] — how to pull a new image on Unraid
-56
View File
@@ -1,56 +0,0 @@
# Internal Network Access
TREK makes outbound HTTP requests when you configure integrations such as Immich or Synology Photos. By default, it blocks requests to private and local IP ranges to prevent server-side request forgery (SSRF) attacks. You need to allow internal network access when those services are hosted on your LAN.
## Default behavior
All outbound requests go through an SSRF guard (`ssrfGuard.ts`). The guard resolves the hostname to an IP address before allowing the connection and blocks addresses in private ranges.
## Always blocked (no override possible)
These ranges are blocked regardless of any setting:
| Range | Description |
|---|---|
| `127.0.0.0/8`, `::1` | Loopback |
| `0.0.0.0/8` | Unspecified |
| `169.254.0.0/16`, `fe80::/10` | Link-local / cloud metadata endpoints |
| `::ffff:127.x.x.x`, `::ffff:169.254.x.x` | IPv4-mapped loopback and link-local |
In addition, hostnames ending in `.local` or `.internal` are always blocked regardless of `ALLOW_INTERNAL_NETWORK`. These suffixes are readily abused for hostname-based bypasses.
The hostname `localhost` is not blocked at the hostname stage, but it resolves to `127.0.0.1` which is caught by the loopback rule above and is therefore always blocked.
## Blocked unless `ALLOW_INTERNAL_NETWORK=true`
| Range | Description |
|---|---|
| `10.0.0.0/8` | RFC-1918 private |
| `172.16.0.0/12` | RFC-1918 private |
| `192.168.0.0/16` | RFC-1918 private |
| `100.64.0.0/10` | CGNAT / Tailscale shared address space |
| `fc00::/7` | IPv6 ULA |
| IPv4-mapped RFC-1918 variants | e.g. `::ffff:10.x`, `::ffff:192.168.x` |
## When to enable
Set `ALLOW_INTERNAL_NETWORK=true` when Immich, Synology Photos, or another integrated service is hosted on your local network and you need TREK to reach it.
See [Environment-Variables](Environment-Variables) for how to set environment variables.
> **Admin:** Set `ALLOW_INTERNAL_NETWORK=true` in [Environment-Variables](Environment-Variables) before configuring Immich or Synology on a LAN.
## DNS rebinding protection
Even with `ALLOW_INTERNAL_NETWORK=true`, TREK pins the DNS resolution to prevent rebinding attacks. When the guard checks a URL, it resolves the hostname once and records the IP. The outbound connection is then made directly to that IP using a pinned dispatcher (via undici), so the hostname cannot re-resolve to a different address between the check and the actual request.
## Audit log
When a user saves an Immich URL that resolves to a private IP, TREK records an `immich.private_ip_configured` entry in the [Audit-Log](Audit-Log) including the URL and the resolved IP address. This audit event is specific to Immich; Synology Photos does not emit an equivalent event.
## See also
- [Photo-Providers](Photo-Providers)
- [User-Settings](User-Settings)
- [Environment-Variables](Environment-Variables)
- [Security-Hardening](Security-Hardening)
-35
View File
@@ -1,35 +0,0 @@
# Invite Links
Invite new users to register on your TREK instance, even when open registration is disabled. Invite links work by embedding a short-lived token in the registration URL.
<!-- TODO: screenshot: invite link management form -->
![Invite Links](assets/UsersAndInvites.png)
## What invite links do
An invite link lets a person register a new TREK account without requiring the site to have open registration enabled. Visiting `/register?invite=<token>` pre-validates the token and switches the page to the Register form. The token's use count is incremented on successful registration, and the link stops working once the use limit is reached.
## Creating invite links
> **Admin:** invite link management is available in [Admin-Users-and-Invites](Admin-Users-and-Invites). Only admins can create invite links.
When creating an invite link you set two parameters:
**Max uses** — how many times the link can be used to register an account. Choose from preset buttons: **1×, 2×, 3×, 4×, 5×**, or **∞** (unlimited).
**Expiry** — how long until the link stops working. Choose from preset buttons: **1d, 3d, 7d, 14d**, or **∞** (no expiry).
Once created, a 32-character hexadecimal token is generated and the URL is automatically copied to your clipboard.
## Sharing the link
Copy the generated URL and send it directly to the person you want to invite. The link works in any browser without any prior authentication.
## Revoking an invite link
Delete the invite from [Admin-Users-and-Invites](Admin-Users-and-Invites). The token is invalidated immediately and visiting the URL will no longer unlock the Register form.
## Related pages
[Login-and-Registration](Login-and-Registration) · [Admin-Users-and-Invites](Admin-Users-and-Invites)
-61
View File
@@ -1,61 +0,0 @@
# Journey Journal
Journey is a photo-first travel journal. Each journey is linked to one or more of your trips and contains per-day entries with text, photos, mood, and weather.
> **Admin:** enable Journey in [Admin-Addons](Admin-Addons).
<!-- TODO: screenshot: journal entries view with photos and mood indicators -->
![Journey screenshot](assets/Journey.png)
## What Journey is
Journey lets you write a narrative account of your travels alongside your trip plan. Entries are tied to specific days and can include prose, photos, a mood rating, weather conditions, and verdict cards. Completed journeys can be shared publicly with a read-only link.
## Accessing Journey
When the admin has enabled the Journey addon, a **Journey** entry appears in the main navigation. The Journey list page shows all your journals as cards with cover images, entry counts, photo counts, and place counts.
## Creating a journey
From the Journey list, click **Create journey**. Give it a title and optional subtitle, then select one or more existing trips to link. Linking a trip imports the trip's places as location anchors for your entries. You can link additional trips later from the journal settings.
## Journal entries
Each entry corresponds to a day in your journey. The entry editor provides:
- **Title** — a short heading for the day.
- **Story** — free-form text that supports Markdown formatting.
- **Mood** — choose one of four values:
| ID | Label | Color |
|---|---|---|
| `amazing` | Amazing | Pink |
| `good` | Good | Amber |
| `neutral` | Neutral | Grey |
| `rough` | Rough | Violet |
- **Weather** — choose one of six values: Sunny, Partly cloudy, Cloudy, Rainy, Stormy, Cold.
- **Photos** — attach photos to the entry. The first photo becomes the card thumbnail in list views.
- **Pros / Cons** — optional verdict cards. Add items to a **Pros** list (thumbs-up) or a **Cons** list (thumbs-down) to summarise what you loved or what could have been better. These are stored in the `pros_cons.pros` and `pros_cons.cons` arrays on the entry.
- **Tags** — free-form labels (e.g. "hidden gem", "best meal").
- **Location** — pin the entry to a map location.
- **Time** — optionally record a time of day for the entry.
## Mobile timeline
On mobile, entries are displayed in a horizontal scrolling timeline of card thumbnails. Tap a card to open the full entry view in a modal sheet. Each card shows the entry's first photo (or a placeholder pin), date, day number, mood icon, and weather icon.
## Map view
The journey detail page includes a map on the right (desktop) or an integrated map-timeline (mobile) showing all entry locations alongside the places from linked trips.
## Public sharing
You can share a journey with a read-only public link. When creating the link you can independently toggle which sections are visible to visitors: **Timeline** (entries and stories), **Gallery** (photos), and **Map**. Visitors can only see the sections you have enabled, and no TREK account is required. See [Public-Share-Links](Public-Share-Links) for details on the separate journey share token mechanism.
## See also
- [Addons-Overview](Addons-Overview)
- [Admin-Addons](Admin-Addons)
- [Public-Share-Links](Public-Share-Links)

Some files were not shown because too many files have changed in this diff Show More