mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 22:31:46 +00:00
Compare commits
21 Commits
b3f2f7308a
...
ee31c78db8
| Author | SHA1 | Date | |
|---|---|---|---|
| ee31c78db8 | |||
| edf14e2ebc | |||
| 2aad8f465c | |||
| 16b81a8356 | |||
| 5984adb2ea | |||
| f8eb1915fe | |||
| b556c636eb | |||
| b20db1428d | |||
| 4a5a59cb78 | |||
| 20bf9c2312 | |||
| 9f57ab4517 | |||
| 292e443dbe | |||
| 2d0414b4a3 | |||
| e612de9143 | |||
| c857d38bcd | |||
| d7a71c0572 | |||
| 58c061e653 | |||
| 22d1d06d39 | |||
| 290f566daa | |||
| 8ca2507050 | |||
| 9c666a0aaf |
@@ -25,4 +25,3 @@
|
||||
*.eot binary
|
||||
*.pdf binary
|
||||
*.zip binary
|
||||
.github/assets/TREK1.gif filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b9153871a41ca2c53ab9188ea400eb45f4065680eae0ee0ebc3fbcf18373d99c
|
||||
size 95418702
|
||||
+3
-1
@@ -58,4 +58,6 @@ coverage
|
||||
*.tgz
|
||||
|
||||
.scannerwork
|
||||
test-data
|
||||
test-data
|
||||
|
||||
.run
|
||||
@@ -31,7 +31,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src=".github/assets/TREK1.gif" alt="TREK — 60-second tour" width="100%" />
|
||||
<img src="https://github.com/mauriceboe/trek-media/releases/download/readme-assets/TREK1.gif" alt="TREK — 60-second tour" width="100%" />
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@@ -62,13 +62,20 @@ 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') {
|
||||
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
|
||||
const { pathname } = window.location
|
||||
if (!isAuthPublicPath(pathname)) {
|
||||
const currentPath = pathname + window.location.search
|
||||
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -900,29 +900,30 @@ 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, 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} />
|
||||
<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>
|
||||
)}
|
||||
</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' }}>
|
||||
|
||||
@@ -477,7 +477,11 @@ export const MapView = memo(function MapView({
|
||||
cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb)))
|
||||
|
||||
if (!cached && !isLoading(cacheKey)) {
|
||||
const photoId = place.image_url || place.google_place_id || place.osm_id
|
||||
const photoId =
|
||||
(place.image_url?.startsWith('/api/maps/place-photo/') ? place.image_url : null)
|
||||
|| place.google_place_id
|
||||
|| place.osm_id
|
||||
|| place.image_url
|
||||
if (photoId || (place.lat && place.lng)) {
|
||||
fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
|
||||
}
|
||||
|
||||
@@ -8,9 +8,10 @@ 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 } from '../../types'
|
||||
import type { Place, Reservation } from '../../types'
|
||||
|
||||
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
|
||||
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
|
||||
@@ -44,6 +45,10 @@ 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 {
|
||||
@@ -139,17 +144,28 @@ 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
|
||||
@@ -228,6 +244,10 @@ 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) => {
|
||||
@@ -299,12 +319,17 @@ 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
|
||||
|
||||
@@ -341,7 +366,11 @@ export function MapViewGL({
|
||||
}
|
||||
cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb)))
|
||||
if (!cached && !isLoading(cacheKey)) {
|
||||
const photoId = place.image_url || place.google_place_id || place.osm_id
|
||||
const photoId =
|
||||
(place.image_url?.startsWith('/api/maps/place-photo/') ? place.image_url : null)
|
||||
|| place.google_place_id
|
||||
|| place.osm_id
|
||||
|| place.image_url
|
||||
if (photoId || (place.lat && place.lng)) {
|
||||
fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
|
||||
}
|
||||
@@ -434,6 +463,41 @@ 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
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
// 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,6 +1,7 @@
|
||||
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'
|
||||
|
||||
@@ -43,9 +44,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,10 +959,9 @@ 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 {
|
||||
@@ -1020,10 +1019,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,6 +336,10 @@ 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 => {
|
||||
@@ -490,11 +494,6 @@ 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 })),
|
||||
@@ -1117,7 +1116,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
</div>
|
||||
|
||||
{/* Tagesliste */}
|
||||
<div className="scroll-container trek-stagger" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||||
<div className={`scroll-container${draggingId ? '' : ' trek-stagger'}`} style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||||
{days.map((day, index) => {
|
||||
const isSelected = selectedDayId === day.id
|
||||
const isExpanded = expandedDays.has(day.id)
|
||||
@@ -1135,7 +1134,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(); setDragOverDayId(day.id) }}
|
||||
onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }}
|
||||
onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOverDayId(null) }}
|
||||
onDrop={e => handleDropOnDay(e, day.id)}
|
||||
style={{
|
||||
@@ -1236,9 +1235,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(); 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' }}>
|
||||
<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' }}>
|
||||
<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.place_name}</span>
|
||||
<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>
|
||||
)
|
||||
})
|
||||
@@ -1349,7 +1348,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
>
|
||||
{merged.length === 0 && !dayNoteUi ? (
|
||||
<div
|
||||
onDragOver={e => { e.preventDefault(); setDragOverDayId(day.id) }}
|
||||
onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) 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',
|
||||
@@ -1409,7 +1408,6 @@ 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 => {
|
||||
@@ -1499,6 +1497,7 @@ 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,
|
||||
}}
|
||||
@@ -1722,7 +1721,6 @@ 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 => {
|
||||
@@ -1771,6 +1769,8 @@ 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,7 +1844,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
{showDropLineAfter && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
@@ -1855,7 +1854,6 @@ 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}`) }}
|
||||
@@ -1911,6 +1909,7 @@ 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,6 +143,18 @@ 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 = (() => {
|
||||
@@ -193,9 +205,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_place_id && form.hotel_start_day && form.hotel_end_day) {
|
||||
if (form.type === 'hotel' && form.hotel_start_day && form.hotel_end_day) {
|
||||
saveData.create_accommodation = {
|
||||
place_id: form.hotel_place_id,
|
||||
place_id: form.hotel_place_id || null,
|
||||
start_day_id: form.hotel_start_day,
|
||||
end_day_id: form.hotel_end_day,
|
||||
check_in: form.meta_check_in_time || null,
|
||||
|
||||
@@ -25,6 +25,7 @@ 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',
|
||||
|
||||
@@ -204,6 +204,7 @@ 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)',
|
||||
@@ -1995,6 +1996,8 @@ 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': 'تمت مشاركة الصور',
|
||||
|
||||
@@ -199,6 +199,7 @@ 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)',
|
||||
@@ -1935,6 +1936,8 @@ 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',
|
||||
|
||||
@@ -200,6 +200,7 @@ 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)',
|
||||
@@ -1940,6 +1941,8 @@ 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',
|
||||
|
||||
@@ -204,6 +204,7 @@ 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)',
|
||||
@@ -1945,6 +1946,8 @@ 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',
|
||||
|
||||
@@ -204,6 +204,7 @@ 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)',
|
||||
@@ -1948,6 +1949,8 @@ 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',
|
||||
|
||||
@@ -200,6 +200,7 @@ 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)',
|
||||
@@ -1945,6 +1946,8 @@ 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',
|
||||
|
||||
@@ -199,6 +199,7 @@ 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)',
|
||||
@@ -1939,6 +1940,8 @@ 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',
|
||||
|
||||
@@ -199,6 +199,7 @@ 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)',
|
||||
@@ -1937,6 +1938,8 @@ 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',
|
||||
|
||||
@@ -202,6 +202,7 @@ 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)',
|
||||
@@ -1946,6 +1947,8 @@ 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',
|
||||
|
||||
@@ -199,6 +199,7 @@ 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)',
|
||||
@@ -1940,6 +1941,8 @@ 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',
|
||||
|
||||
@@ -199,6 +199,7 @@ 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)',
|
||||
@@ -1939,6 +1940,8 @@ 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',
|
||||
|
||||
@@ -182,6 +182,7 @@ 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)',
|
||||
@@ -1929,6 +1930,8 @@ 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',
|
||||
|
||||
@@ -199,6 +199,7 @@ const ru: Record<string, string> = {
|
||||
'settings.notifyTripInvite': 'Приглашения в поездку',
|
||||
'settings.notifyBookingChange': 'Изменения бронирований',
|
||||
'settings.notifyTripReminder': 'Напоминания о поездке',
|
||||
'settings.notifyTodoDue': 'Задача к сроку',
|
||||
'settings.notifyVacayInvite': 'Приглашения слияния Vacay',
|
||||
'settings.notifyPhotosShared': 'Общие фото (Immich)',
|
||||
'settings.notifyCollabMessage': 'Сообщения чата (Collab)',
|
||||
@@ -1936,6 +1937,8 @@ 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': 'Фото опубликованы',
|
||||
|
||||
@@ -199,6 +199,7 @@ const zh: Record<string, string> = {
|
||||
'settings.notifyTripInvite': '旅行邀请',
|
||||
'settings.notifyBookingChange': '预订变更',
|
||||
'settings.notifyTripReminder': '旅行提醒',
|
||||
'settings.notifyTodoDue': '待办事项即将到期',
|
||||
'settings.notifyVacayInvite': 'Vacay 融合邀请',
|
||||
'settings.notifyPhotosShared': '共享照片 (Immich)',
|
||||
'settings.notifyCollabMessage': '聊天消息 (Collab)',
|
||||
@@ -1936,6 +1937,8 @@ 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': '照片已分享',
|
||||
|
||||
@@ -199,6 +199,7 @@ const zhTw: Record<string, string> = {
|
||||
'settings.notifyTripInvite': '旅行邀請',
|
||||
'settings.notifyBookingChange': '預訂變更',
|
||||
'settings.notifyTripReminder': '旅行提醒',
|
||||
'settings.notifyTodoDue': '待辦事項即將到期',
|
||||
'settings.notifyVacayInvite': 'Vacay 融合邀請',
|
||||
'settings.notifyPhotosShared': '共享照片 (Immich)',
|
||||
'settings.notifyCollabMessage': '聊天訊息 (Collab)',
|
||||
@@ -2195,6 +2196,8 @@ 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': '照片已分享',
|
||||
|
||||
@@ -595,7 +595,11 @@ export default function JourneyDetailPage() {
|
||||
</div>
|
||||
|
||||
{entries.map((entry, idx) => {
|
||||
const canReorder = !isMobile && canEditEntries && entries.length > 1
|
||||
// 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 move = (direction: -1 | 1) => {
|
||||
if (!current) return
|
||||
const target = idx + direction
|
||||
|
||||
@@ -36,8 +36,8 @@ interface PublicPhoto {
|
||||
caption?: string | null
|
||||
}
|
||||
|
||||
function photoUrl(p: PublicPhoto, shareToken: string): string {
|
||||
return `/api/public/journey/${shareToken}/photos/${p.photo_id}/original`
|
||||
function photoUrl(p: PublicPhoto, shareToken: string, kind: 'thumbnail' | 'original' = 'original'): string {
|
||||
return `/api/public/journey/${shareToken}/photos/${p.photo_id}/${kind}`
|
||||
}
|
||||
|
||||
function formatDate(d: string): { weekday: string; month: string; day: number } {
|
||||
@@ -84,9 +84,20 @@ export default function JourneyPublicPage() {
|
||||
const journey = data?.journey || {}
|
||||
const stats = data?.stats || {}
|
||||
|
||||
const groupedEntries = useMemo(() => groupByDate(entries), [entries])
|
||||
// `[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 sortedDates = useMemo(() => [...groupedEntries.keys()].sort(), [groupedEntries])
|
||||
const mapEntries = useMemo(() => entries.filter(e => e.location_lat && e.location_lng), [entries])
|
||||
const mapEntries = useMemo(
|
||||
() => timelineEntries.filter(e => e.location_lat && e.location_lng),
|
||||
[timelineEntries],
|
||||
)
|
||||
const allPhotos = useMemo(() => entries.flatMap(e => (e.photos || []).map(p => ({ photo: p, entry: e }))), [entries])
|
||||
|
||||
// Set default view based on permissions
|
||||
@@ -312,7 +323,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!)} className="w-full h-full object-cover hover:scale-105 transition-transform" alt="" loading="lazy" />
|
||||
<img src={photoUrl(photo, token!, 'thumbnail')} className="w-full h-full object-cover hover:scale-105 transition-transform" alt="" loading="lazy" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,7 +0,0 @@
|
||||
# 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)
|
||||
+56
-13
@@ -5,11 +5,9 @@ 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 } from './middleware/auth';
|
||||
import { authenticate, verifyJwtAndLoadUser } from './middleware/auth';
|
||||
import { db } from './db/database';
|
||||
|
||||
import authRoutes from './routes/auth';
|
||||
@@ -76,6 +74,19 @@ 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(
|
||||
@@ -112,7 +123,7 @@ export function createApp(): express.Application {
|
||||
}
|
||||
},
|
||||
crossOriginEmbedderPolicy: false,
|
||||
hsts: shouldForceHttps ? { maxAge: 31536000, includeSubDomains: false } : false,
|
||||
hsts: hstsActive ? { maxAge: 31536000, includeSubDomains: hstsIncludeSubdomains } : false,
|
||||
}));
|
||||
|
||||
if (shouldForceHttps) {
|
||||
@@ -161,12 +172,33 @@ export function createApp(): express.Application {
|
||||
});
|
||||
}
|
||||
|
||||
// Static: avatars, covers, and journey photos
|
||||
// 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).
|
||||
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 auth or valid share token
|
||||
// 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.
|
||||
app.get('/uploads/photos/:filename', (req: Request, res: Response) => {
|
||||
const safeName = path.basename(req.params.filename);
|
||||
const filePath = path.join(__dirname, '../uploads/photos', safeName);
|
||||
@@ -174,17 +206,28 @@ 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 token = (req.query.token as string) || (authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null);
|
||||
if (!token) return res.status(401).send('Authentication required');
|
||||
const rawToken = (req.query.token as string) || (authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null);
|
||||
if (!rawToken) 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');
|
||||
// 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');
|
||||
}
|
||||
res.sendFile(resolved);
|
||||
});
|
||||
|
||||
@@ -35,15 +35,6 @@ 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?)');
|
||||
@@ -56,6 +47,15 @@ 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) {}
|
||||
|
||||
@@ -1792,6 +1792,160 @@ 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);
|
||||
`);
|
||||
},
|
||||
// Migration: null out proxy image_url entries that have no backing disk cache.
|
||||
// Migrations 107 and the migration below wrote /api/maps/place-photo/<id>/bytes
|
||||
// into places.image_url without actually fetching/caching the photo bytes. The
|
||||
// photoService short-circuits on that prefix and hits /bytes directly → 404.
|
||||
// Rows with a confirmed disk cache entry in google_place_photo_meta are left alone;
|
||||
// only stale proxy URLs (never actually fetched) are cleared so the normal
|
||||
// fetch-and-cache flow can repopulate them.
|
||||
() => {
|
||||
db.exec(`
|
||||
UPDATE places
|
||||
SET image_url = NULL, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE image_url LIKE '/api/maps/place-photo/%/bytes'
|
||||
AND google_place_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM google_place_photo_meta
|
||||
WHERE place_id = places.google_place_id
|
||||
AND error_at IS NULL
|
||||
)
|
||||
`);
|
||||
},
|
||||
// Migration: clear legacy Google photo URLs missed by Migration 107.
|
||||
// Migration 107 matched /places/%/photos/% only; lh3.googleusercontent.com URLs use
|
||||
// /place-photos/ or /places/<opaque-id> paths and were skipped. NULL those stale URLs
|
||||
// so the normal fetch-and-cache flow repopulates image_url with a real proxy URL.
|
||||
() => {
|
||||
db.exec(`
|
||||
UPDATE places
|
||||
SET image_url = NULL,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE image_url IS NOT NULL
|
||||
AND image_url != ''
|
||||
AND image_url NOT LIKE '/api/maps/place-photo/%'
|
||||
AND (
|
||||
image_url LIKE 'http://%googleusercontent.com/%'
|
||||
OR image_url LIKE 'https://%googleusercontent.com/%'
|
||||
OR image_url LIKE 'http://%places.googleapis.com/%'
|
||||
OR image_url LIKE 'https://%places.googleapis.com/%'
|
||||
)
|
||||
`);
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -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 NOT NULL REFERENCES places(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,
|
||||
|
||||
@@ -46,6 +46,7 @@ const server = app.listen(PORT, () => {
|
||||
}
|
||||
scheduler.start();
|
||||
scheduler.startTripReminders();
|
||||
scheduler.startTodoReminders();
|
||||
scheduler.startVersionCheck();
|
||||
scheduler.startDemoReset();
|
||||
scheduler.startIdempotencyCleanup();
|
||||
|
||||
@@ -180,11 +180,10 @@ function verifyToken(authHeader: string | undefined): VerifyTokenResult | null {
|
||||
if (token.startsWith('trekoa_')) {
|
||||
const result = getUserByAccessToken(token);
|
||||
if (!result) 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;
|
||||
}
|
||||
// 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;
|
||||
return { user: result.user, scopes: result.scopes, clientId: result.clientId, isStaticToken: false };
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ 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)
|
||||
@@ -13,7 +14,18 @@ export function extractToken(req: Request): string | null {
|
||||
return (authHeader && authHeader.split(' ')[1]) || null;
|
||||
}
|
||||
|
||||
function verifyJwtAndLoadUser(token: string): User | 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 {
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number; pv?: number };
|
||||
const row = db.prepare(
|
||||
@@ -93,8 +105,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' && authReq.user?.email === 'demo@nomad.app') {
|
||||
res.status(403).json({ error: 'Uploads are disabled in demo mode. Self-host NOMAD for full functionality.' });
|
||||
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.' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
|
||||
@@ -2,6 +2,13 @@ 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;
|
||||
@@ -12,9 +19,14 @@ interface IdempotencyRow {
|
||||
* Called from within `authenticate` after req.user is set.
|
||||
*
|
||||
* For mutating requests carrying X-Idempotency-Key:
|
||||
* - If (key, userId) already stored: replays the cached response.
|
||||
* - If (key, userId, method, path) 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 {
|
||||
@@ -28,11 +40,17 @@ 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 if key already processed for this user
|
||||
// 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.
|
||||
const existing = db.prepare(
|
||||
'SELECT status_code, response_body FROM idempotency_keys WHERE key = ? AND user_id = ?'
|
||||
).get(key, userId) as IdempotencyRow | undefined;
|
||||
'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;
|
||||
|
||||
if (existing) {
|
||||
res.status(existing.status_code).json(JSON.parse(existing.response_body));
|
||||
@@ -44,10 +62,13 @@ export function applyIdempotency(req: Request, res: Response, next: NextFunction
|
||||
res.json = function (body: unknown): Response {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
try {
|
||||
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));
|
||||
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));
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: if storage fails, the request still succeeds
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { db } from '../db/database';
|
||||
import { JWT_SECRET } from '../config';
|
||||
import { extractToken, verifyJwtAndLoadUser } from './auth';
|
||||
import { DEMO_EMAILS } from '../services/demo';
|
||||
|
||||
/** Paths that never require MFA (public or pre-auth). */
|
||||
export function isPublicApiPath(method: string, pathNoQuery: string): boolean {
|
||||
@@ -42,21 +42,25 @@ export function enforceGlobalMfaPolicy(req: Request, res: Response, next: NextFu
|
||||
return;
|
||||
}
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
// 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);
|
||||
if (!token) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
let userId: number;
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number };
|
||||
userId = decoded.id;
|
||||
} catch {
|
||||
// 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) {
|
||||
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') {
|
||||
@@ -64,16 +68,13 @@ export function enforceGlobalMfaPolicy(req: Request, res: Response, next: NextFu
|
||||
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;
|
||||
}
|
||||
if (process.env.DEMO_MODE === 'true' && verified.email && DEMO_EMAILS.has(verified.email)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const row = db.prepare('SELECT mfa_enabled, role FROM users WHERE id = ?').get(userId) as
|
||||
| { mfa_enabled: number | boolean; role: string }
|
||||
const row = db.prepare('SELECT mfa_enabled FROM users WHERE id = ?').get(userId) as
|
||||
| { mfa_enabled: number | boolean }
|
||||
| undefined;
|
||||
if (!row) {
|
||||
next();
|
||||
|
||||
+18
-17
@@ -39,7 +39,7 @@ import {
|
||||
requestPasswordReset,
|
||||
resetPassword,
|
||||
} from '../services/authService';
|
||||
import { sendPasswordResetEmail } from '../services/notifications';
|
||||
import { sendPasswordResetEmail, getAppUrl } 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!);
|
||||
setAuthCookie(res, result.token!, req);
|
||||
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!);
|
||||
setAuthCookie(res, result.token!, req);
|
||||
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!);
|
||||
setAuthCookie(res, result.token!, req);
|
||||
res.json({ token: result.token, user: result.user });
|
||||
});
|
||||
|
||||
@@ -178,11 +178,12 @@ 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 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')}`;
|
||||
// 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();
|
||||
const url = `${origin.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(outcome.tokenForDelivery)}`;
|
||||
|
||||
// Audit the REQUEST always — even for "no user" — so abuse is visible.
|
||||
@@ -231,8 +232,8 @@ router.get('/me', authenticate, (req: Request, res: Response) => {
|
||||
res.json({ user });
|
||||
});
|
||||
|
||||
router.post('/logout', (_req: Request, res: Response) => {
|
||||
clearAuthCookie(res);
|
||||
router.post('/logout', (req: Request, res: Response) => {
|
||||
clearAuthCookie(res, req);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
@@ -276,15 +277,15 @@ router.get('/me/settings', authenticate, (req: Request, res: Response) => {
|
||||
res.json({ settings: result.settings });
|
||||
});
|
||||
|
||||
router.post('/avatar', authenticate, demoUploadBlock, avatarUpload.single('avatar'), (req: Request, res: Response) => {
|
||||
router.post('/avatar', authenticate, demoUploadBlock, avatarUpload.single('avatar'), async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!req.file) return res.status(400).json({ error: 'No image uploaded' });
|
||||
res.json(saveAvatar(authReq.user.id, req.file.filename));
|
||||
res.json(await saveAvatar(authReq.user.id, req.file.filename));
|
||||
});
|
||||
|
||||
router.delete('/avatar', authenticate, (req: Request, res: Response) => {
|
||||
router.delete('/avatar', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
res.json(deleteAvatar(authReq.user.id));
|
||||
res.json(await deleteAvatar(authReq.user.id));
|
||||
});
|
||||
|
||||
router.get('/users', authenticate, (req: Request, res: Response) => {
|
||||
@@ -329,7 +330,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!);
|
||||
setAuthCookie(res, result.token!, req);
|
||||
res.json({ token: result.token, user: result.user });
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ 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,
|
||||
@@ -41,8 +42,10 @@ const noteUpload = multer({
|
||||
defParamCharset: 'utf8',
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
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')) {
|
||||
// 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 err: Error & { statusCode?: number } = new Error('File type not allowed');
|
||||
err.statusCode = 400;
|
||||
return cb(err);
|
||||
|
||||
@@ -210,7 +210,7 @@ router.post('/:id/restore', authenticate, (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
// Permanently delete from trash
|
||||
router.delete('/:id/permanent', authenticate, (req: Request, res: Response) => {
|
||||
router.delete('/:id/permanent', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
|
||||
@@ -222,13 +222,13 @@ router.delete('/:id/permanent', authenticate, (req: Request, res: Response) => {
|
||||
const file = getDeletedFile(id, tripId);
|
||||
if (!file) return res.status(404).json({ error: 'File not found in trash' });
|
||||
|
||||
permanentDeleteFile(file);
|
||||
await 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, (req: Request, res: Response) => {
|
||||
router.delete('/trash/empty', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
|
||||
@@ -237,7 +237,7 @@ router.delete('/trash/empty', authenticate, (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 = emptyTrash(tripId);
|
||||
const deleted = await emptyTrash(tripId);
|
||||
res.json({ success: true, deleted });
|
||||
});
|
||||
|
||||
|
||||
@@ -205,6 +205,37 @@ 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';
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
consumeAuthCode,
|
||||
exchangeCodeForToken,
|
||||
getUserInfo,
|
||||
verifyIdToken,
|
||||
findOrCreateUser,
|
||||
touchLastLogin,
|
||||
generateToken,
|
||||
@@ -97,10 +98,40 @@ 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) {
|
||||
@@ -126,7 +157,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);
|
||||
setAuthCookie(res, result.token, req);
|
||||
res.json({ token: result.token });
|
||||
});
|
||||
|
||||
|
||||
+76
-1
@@ -207,6 +207,81 @@ 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;
|
||||
|
||||
@@ -280,4 +355,4 @@ function stop(): void {
|
||||
if (trekPhotoCacheTask) { trekPhotoCacheTask.stop(); trekPhotoCacheTask = null; }
|
||||
}
|
||||
|
||||
export { start, stop, startDemoReset, startTripReminders, startVersionCheck, startIdempotencyCleanup, startTrekPhotoCacheCleanup, loadSettings, saveSettings, VALID_INTERVALS };
|
||||
export { start, stop, startDemoReset, startTripReminders, startTodoReminders, startVersionCheck, startIdempotencyCleanup, startTrekPhotoCacheCleanup, loadSettings, saveSettings, VALID_INTERVALS };
|
||||
|
||||
@@ -15,7 +15,9 @@ 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
|
||||
@@ -175,10 +177,46 @@ 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) {
|
||||
@@ -260,7 +298,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@trek.app' : undefined,
|
||||
demo_email: isDemo ? DEMO_EMAIL_PRIMARY : undefined,
|
||||
demo_password: isDemo ? 'demo12345' : undefined,
|
||||
timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
|
||||
notification_channel: notifChannel,
|
||||
@@ -283,7 +321,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@trek.app') as User | undefined;
|
||||
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(DEMO_EMAIL_PRIMARY) 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>;
|
||||
@@ -458,7 +496,7 @@ export function changePassword(
|
||||
if (isOidcOnlyMode()) {
|
||||
return { error: 'Password authentication is disabled.', status: 403 };
|
||||
}
|
||||
if (process.env.DEMO_MODE === 'true' && userEmail === 'demo@trek.app') {
|
||||
if (process.env.DEMO_MODE === 'true' && isDemoEmail(userEmail)) {
|
||||
return { error: 'Password change is disabled in demo mode.', status: 403 };
|
||||
}
|
||||
|
||||
@@ -480,7 +518,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' && userEmail === 'demo@trek.app') {
|
||||
if (process.env.DEMO_MODE === 'true' && isDemoEmail(userEmail)) {
|
||||
return { error: 'Account deletion is disabled in demo mode.', status: 403 };
|
||||
}
|
||||
if (userRole === 'admin') {
|
||||
@@ -600,11 +638,13 @@ export function getSettings(userId: number): { error?: string; status?: number;
|
||||
// Avatar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function saveAvatar(userId: number, filename: string) {
|
||||
export async 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);
|
||||
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
|
||||
await fs.promises.rm(oldPath, { force: true }).catch(() => {});
|
||||
}
|
||||
|
||||
db.prepare('UPDATE users SET avatar = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(filename, userId);
|
||||
@@ -613,11 +653,11 @@ export function saveAvatar(userId: number, filename: string) {
|
||||
return { success: true, avatar_url: avatarUrl(updated || {}) };
|
||||
}
|
||||
|
||||
export function deleteAvatar(userId: number) {
|
||||
export async 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);
|
||||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||
await fs.promises.rm(filePath, { force: true }).catch(() => {});
|
||||
}
|
||||
db.prepare('UPDATE users SET avatar = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(userId);
|
||||
return { success: true };
|
||||
@@ -865,7 +905,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' && userEmail === 'demo@nomad.app') {
|
||||
if (process.env.DEMO_MODE === 'true' && isDemoEmail(userEmail)) {
|
||||
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;
|
||||
@@ -898,7 +938,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(hashBackupCode);
|
||||
const backupHashes = backupCodes.map(hashBackupCodeBcrypt);
|
||||
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,
|
||||
@@ -914,7 +954,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' && userEmail === 'demo@nomad.app') {
|
||||
if (process.env.DEMO_MODE === 'true' && isDemoEmail(userEmail)) {
|
||||
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;
|
||||
@@ -973,8 +1013,9 @@ export function verifyMfaLogin(body: {
|
||||
const okTotp = authenticator.verify({ token: tokenStr.replace(/\s/g, ''), secret });
|
||||
if (!okTotp) {
|
||||
const hashes = parseBackupCodeHashes(user.mfa_backup_codes);
|
||||
const candidateHash = hashBackupCode(tokenStr);
|
||||
const idx = hashes.findIndex(h => h === candidateHash);
|
||||
// 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));
|
||||
if (idx === -1) {
|
||||
return { error: 'Invalid verification code', status: 401 };
|
||||
}
|
||||
@@ -1166,8 +1207,7 @@ export function resetPassword(body: {
|
||||
const okTotp = authenticator.verify({ token: supplied.replace(/\s/g, ''), secret });
|
||||
if (!okTotp) {
|
||||
const hashes = parseBackupCodeHashes(user.mfa_backup_codes);
|
||||
const candidateHash = hashBackupCode(supplied);
|
||||
const idx = hashes.findIndex(h => h === candidateHash);
|
||||
const idx = hashes.findIndex((h) => matchBackupCode(supplied, h));
|
||||
if (idx === -1) return { error: 'Invalid MFA code', status: 401 };
|
||||
backupCodeConsumedIndex = idx;
|
||||
}
|
||||
@@ -1193,6 +1233,16 @@ 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.
|
||||
@@ -1267,7 +1317,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 user?.email === 'demo@nomad.app';
|
||||
return isDemoEmail(user?.email);
|
||||
}
|
||||
|
||||
export function verifyMcpToken(rawToken: string): User | null {
|
||||
@@ -1285,12 +1335,15 @@ 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 {
|
||||
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;
|
||||
}
|
||||
return verifyJwtAndLoadUser(token);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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
|
||||
@@ -246,6 +247,12 @@ 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 });
|
||||
@@ -253,6 +260,13 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,32 @@
|
||||
import { Response } from 'express';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
const COOKIE_NAME = 'trek_session';
|
||||
|
||||
export function cookieOptions(clear = false) {
|
||||
const secure = process.env.COOKIE_SECURE !== 'false' && (process.env.NODE_ENV === 'production' || process.env.FORCE_HTTPS === 'true');
|
||||
/**
|
||||
* 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) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure,
|
||||
@@ -13,10 +36,10 @@ export function cookieOptions(clear = false) {
|
||||
};
|
||||
}
|
||||
|
||||
export function setAuthCookie(res: Response, token: string): void {
|
||||
res.cookie(COOKIE_NAME, token, cookieOptions());
|
||||
export function setAuthCookie(res: Response, token: string, req?: Request): void {
|
||||
res.cookie(COOKIE_NAME, token, cookieOptions(false, req));
|
||||
}
|
||||
|
||||
export function clearAuthCookie(res: Response): void {
|
||||
res.clearCookie(COOKIE_NAME, cookieOptions(true));
|
||||
export function clearAuthCookie(res: Response, req?: Request): void {
|
||||
res.clearCookie(COOKIE_NAME, cookieOptions(true, req));
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ export function deleteDay(id: string | number) {
|
||||
export interface DayAccommodation {
|
||||
id: number;
|
||||
trip_id: number;
|
||||
place_id: number;
|
||||
place_id: number | null;
|
||||
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
|
||||
JOIN places p ON a.place_id = p.id
|
||||
LEFT JOIN places p ON a.place_id = p.id
|
||||
WHERE a.id = ?
|
||||
`).get(id);
|
||||
}
|
||||
@@ -191,9 +191,11 @@ 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
|
||||
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
|
||||
FROM day_accommodations a
|
||||
JOIN places p ON a.place_id = p.id
|
||||
LEFT JOIN places p ON a.place_id = p.id
|
||||
LEFT JOIN reservations r ON r.accommodation_id = a.id
|
||||
WHERE a.trip_id = ?
|
||||
ORDER BY a.created_at ASC
|
||||
`).all(tripId);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// 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);
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
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';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -12,7 +11,18 @@ 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';
|
||||
export const BLOCKED_EXTENSIONS = ['.svg', '.html', '.htm', '.xml'];
|
||||
// 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 filesDir = path.join(__dirname, '../../uploads/files');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -68,12 +78,12 @@ export function authenticateDownload(bearerToken: string | undefined, queryToken
|
||||
}
|
||||
|
||||
if (bearerToken) {
|
||||
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 };
|
||||
}
|
||||
// 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 };
|
||||
}
|
||||
|
||||
const uid = consumeEphemeralToken(queryToken!, 'download');
|
||||
@@ -193,24 +203,42 @@ export function restoreFile(id: string | number) {
|
||||
return formatFile(restored);
|
||||
}
|
||||
|
||||
export function permanentDeleteFile(file: TripFile) {
|
||||
export async function permanentDeleteFile(file: TripFile): Promise<void> {
|
||||
const { resolved } = resolveFilePath(file.filename);
|
||||
if (fs.existsSync(resolved)) {
|
||||
try { fs.unlinkSync(resolved); } catch (e) { console.error('Error deleting file:', e); }
|
||||
// `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;
|
||||
}
|
||||
db.prepare('DELETE FROM trip_files WHERE id = ?').run(file.id);
|
||||
}
|
||||
|
||||
export function emptyTrash(tripId: string | number): number {
|
||||
export async function emptyTrash(tripId: string | number): Promise<number> {
|
||||
const trashed = db.prepare('SELECT * FROM trip_files WHERE trip_id = ? AND deleted_at IS NOT NULL').all(tripId) as TripFile[];
|
||||
for (const file of trashed) {
|
||||
// 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) => {
|
||||
const { resolved } = resolveFilePath(file.filename);
|
||||
if (fs.existsSync(resolved)) {
|
||||
try { fs.unlinkSync(resolved); } catch (e) { console.error('Error deleting file:', e); }
|
||||
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 (successfullyUnlinked.length > 0) {
|
||||
const placeholders = successfullyUnlinked.map(() => '?').join(',');
|
||||
db.prepare(`DELETE FROM trip_files WHERE id IN (${placeholders})`).run(...successfullyUnlinked);
|
||||
}
|
||||
db.prepare('DELETE FROM trip_files WHERE trip_id = ? AND deleted_at IS NOT NULL').run(tripId);
|
||||
return trashed.length;
|
||||
return successfullyUnlinked.length;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -648,6 +648,12 @@ export async function getPlacePhoto(
|
||||
return null;
|
||||
}
|
||||
|
||||
// Reject URL-shaped placeIds — legacy DBs may store raw photo URLs in image_url
|
||||
if (/^https?:\/\//i.test(placeId)) {
|
||||
placePhotoCache.markError(placeId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Google Photos — fetch details to get photo name
|
||||
const detailsRes = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}`, `getPlacePhoto/details(${placeId})`, {
|
||||
headers: {
|
||||
@@ -655,13 +661,15 @@ export async function getPlacePhoto(
|
||||
'X-Goog-FieldMask': 'photos',
|
||||
},
|
||||
});
|
||||
const details = await detailsRes.json() as GooglePlaceDetails & { error?: { message?: string } };
|
||||
|
||||
const body = await detailsRes.text();
|
||||
if (!detailsRes.ok) {
|
||||
console.error('Google Places photo details error:', details.error?.message || detailsRes.status);
|
||||
console.error('Google Places photo details error:', detailsRes.status, body.slice(0, 200));
|
||||
placePhotoCache.markError(placeId);
|
||||
return null;
|
||||
}
|
||||
let details: GooglePlaceDetails & { error?: { message?: string } };
|
||||
try { details = body ? JSON.parse(body) : { photos: [] }; }
|
||||
catch { placePhotoCache.markError(placeId); return null; }
|
||||
|
||||
if (!details.photos?.length) {
|
||||
placePhotoCache.markError(placeId);
|
||||
|
||||
@@ -679,8 +679,10 @@ 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)
|
||||
const resolvedSize = kind === 'original' ? 'xl' : (size || 'sm');
|
||||
// (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');
|
||||
const params = new URLSearchParams({
|
||||
api: 'SYNO.Foto.Thumbnail',
|
||||
method: 'get',
|
||||
|
||||
@@ -9,6 +9,7 @@ export type NotifEventType =
|
||||
| 'trip_invite'
|
||||
| 'booking_change'
|
||||
| 'trip_reminder'
|
||||
| 'todo_due'
|
||||
| 'vacay_invite'
|
||||
| 'photos_shared'
|
||||
| 'collab_message'
|
||||
@@ -29,6 +30,7 @@ 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,6 +82,13 @@ 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',
|
||||
|
||||
@@ -100,6 +100,7 @@ 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}` }),
|
||||
@@ -111,6 +112,7 @@ 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}` }),
|
||||
@@ -122,6 +124,7 @@ 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}` }),
|
||||
@@ -133,6 +136,7 @@ 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}` }),
|
||||
@@ -144,6 +148,7 @@ 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}` }),
|
||||
@@ -155,6 +160,7 @@ 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}` }),
|
||||
@@ -166,6 +172,7 @@ 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}` }),
|
||||
@@ -177,6 +184,7 @@ 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}` }),
|
||||
@@ -188,6 +196,7 @@ 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}` }),
|
||||
@@ -199,6 +208,7 @@ 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}` }),
|
||||
@@ -210,6 +220,7 @@ 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}` }),
|
||||
@@ -221,6 +232,7 @@ 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}` }),
|
||||
@@ -232,6 +244,7 @@ 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}` }),
|
||||
@@ -243,6 +256,7 @@ 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}` }),
|
||||
@@ -254,6 +268,7 @@ 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}` }),
|
||||
|
||||
@@ -582,10 +582,16 @@ 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
|
||||
// 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".
|
||||
const mcpResource = `${(getAppUrl() || '').replace(/\/+$/, '')}/mcp`;
|
||||
const resource = params.resource ? params.resource.replace(/\/+$/, '') : null;
|
||||
if (resource !== null && resource !== mcpResource) {
|
||||
const resource = params.resource
|
||||
? params.resource.replace(/\/+$/, '')
|
||||
: mcpResource;
|
||||
if (resource !== mcpResource) {
|
||||
return { valid: false, error: 'invalid_target', error_description: 'Requested resource must be the TREK MCP endpoint' };
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface OidcDiscoveryDoc {
|
||||
authorization_endpoint: string;
|
||||
token_endpoint: string;
|
||||
userinfo_endpoint: string;
|
||||
jwks_uri?: string;
|
||||
issuer?: string;
|
||||
_issuer?: string;
|
||||
}
|
||||
|
||||
@@ -138,6 +140,12 @@ 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();
|
||||
@@ -221,6 +229,102 @@ 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -286,21 +390,34 @@ export function findOrCreateUser(
|
||||
const existing = db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)').get(username);
|
||||
if (existing) username = `${username}_${Date.now() % 10000}`;
|
||||
|
||||
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)`);
|
||||
// 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' };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
user = { id: Number(result.lastInsertRowid), username, email, role } as User;
|
||||
return { user };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -96,7 +96,9 @@ export function getInFlight(placeId: string): Promise<{ filePath: string; attrib
|
||||
|
||||
export function setInFlight(placeId: string, promise: Promise<{ filePath: string; attribution: string | null } | null>): void {
|
||||
inFlight.set(placeId, promise);
|
||||
promise.finally(() => inFlight.delete(placeId));
|
||||
promise
|
||||
.finally(() => inFlight.delete(placeId))
|
||||
.catch(() => { /* awaiter logs; this .catch only prevents unhandledRejection */ });
|
||||
}
|
||||
|
||||
export function serveFilePath(placeId: string): string | null {
|
||||
|
||||
@@ -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 (accPlaceId && start_day_id && end_day_id) {
|
||||
if (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, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null);
|
||||
).run(tripId, accPlaceId || null, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null);
|
||||
resolvedAccommodationId = Number(accResult.lastInsertRowid);
|
||||
accommodationCreated = true;
|
||||
}
|
||||
@@ -274,11 +274,16 @@ 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 (accPlaceId && start_day_id && end_day_id) {
|
||||
if (start_day_id && end_day_id) {
|
||||
if (resolvedAccId) {
|
||||
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 {
|
||||
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) {
|
||||
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);
|
||||
|
||||
@@ -44,9 +44,14 @@ 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');
|
||||
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);
|
||||
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);
|
||||
return { token, created: true };
|
||||
}
|
||||
|
||||
@@ -79,7 +84,9 @@ 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 = ?').get(token) as any;
|
||||
const shareRow = db.prepare(
|
||||
"SELECT * FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))"
|
||||
).get(token) as any;
|
||||
if (!shareRow) return null;
|
||||
|
||||
const tripId = shareRow.trip_id;
|
||||
|
||||
@@ -44,6 +44,11 @@ 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(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -58,6 +63,7 @@ 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',
|
||||
@@ -142,9 +148,11 @@ 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',
|
||||
@@ -162,7 +170,8 @@ 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', _ok: true, _status: 200 });
|
||||
mockExchangeCode.mockResolvedValueOnce({ access_token: 'new-token', id_token: 'fake.id.token', _ok: true, _status: 200 });
|
||||
mockVerifyIdToken.mockResolvedValueOnce({ ok: true, claims: { sub: 'sub-newuser-999' } });
|
||||
mockGetUserInfo.mockResolvedValueOnce({
|
||||
sub: 'sub-newuser-999',
|
||||
email: 'newuser@example.com',
|
||||
@@ -214,6 +223,49 @@ 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' });
|
||||
@@ -221,7 +273,8 @@ 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', _ok: true, _status: 200 });
|
||||
mockExchangeCode.mockResolvedValueOnce({ access_token: 'tok', id_token: 'fake.id.token', _ok: true, _status: 200 });
|
||||
mockVerifyIdToken.mockResolvedValueOnce({ ok: true, claims: { sub: 'sub-blocked-user' } });
|
||||
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] = args;
|
||||
return rows[`${key}:${userId}`] ?? undefined;
|
||||
const [key, userId, method, path] = args;
|
||||
return rows[`${key}:${userId}:${method}:${path}`] ?? undefined;
|
||||
}),
|
||||
run: vi.fn((...args: unknown[]) => {
|
||||
const [key, userId, , , status_code, response_body] = args as [string, number, string, string, number, string];
|
||||
const k = `${key}:${userId}`;
|
||||
const [key, userId, method, path, status_code, response_body] = args as [string, number, string, string, number, string];
|
||||
const k = `${key}:${userId}:${method}:${path}`;
|
||||
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> = {}): Request {
|
||||
return { method, path: '/api/test', headers } as unknown as Request;
|
||||
function makeReq(method = 'POST', headers: Record<string, string> = {}, path = '/api/test'): Request {
|
||||
return { method, path, 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 already stored', () => {
|
||||
rows['cached-key:42'] = { status_code: 201, response_body: JSON.stringify({ id: 99 }) };
|
||||
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 }) };
|
||||
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'] = { status_code: 200, response_body: JSON.stringify({ ok: true }) };
|
||||
rows['cached-key:1:POST:/api/test'] = { 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,6 +83,33 @@ 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);
|
||||
@@ -92,9 +119,9 @@ describe('applyIdempotency', () => {
|
||||
});
|
||||
applyIdempotency(req, res, next, 7);
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
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 });
|
||||
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 });
|
||||
});
|
||||
|
||||
it('does NOT store 4xx responses', () => {
|
||||
@@ -104,7 +131,36 @@ describe('applyIdempotency', () => {
|
||||
(res.json as ReturnType<typeof vi.fn>)({ error: 'Invalid' });
|
||||
});
|
||||
applyIdempotency(req, res, next, 3);
|
||||
expect(rows['fail-key:3']).toBeUndefined();
|
||||
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();
|
||||
});
|
||||
|
||||
it('handles PUT, PATCH, and DELETE the same as POST', () => {
|
||||
|
||||
@@ -1235,7 +1235,7 @@ describe('getPlacePhoto (fetch stubbed)', () => {
|
||||
// First call: get place details (with photos)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
text: async () => JSON.stringify({
|
||||
photos: [{ name: 'places/ChIJABC/photos/photo1', authorAttributions: [{ displayName: 'Photographer' }] }],
|
||||
}),
|
||||
})
|
||||
@@ -1258,7 +1258,7 @@ describe('getPlacePhoto (fetch stubbed)', () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
json: async () => ({ error: { message: 'Forbidden' } }),
|
||||
text: async () => JSON.stringify({ error: { message: 'Forbidden' } }),
|
||||
}));
|
||||
const { getPlacePhoto } = await import('../../../src/services/mapsService');
|
||||
const errId = `ChIJErr-${Date.now()}`;
|
||||
@@ -1269,7 +1269,7 @@ describe('getPlacePhoto (fetch stubbed)', () => {
|
||||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ photos: [] }),
|
||||
text: async () => JSON.stringify({ photos: [] }),
|
||||
}));
|
||||
const { getPlacePhoto } = await import('../../../src/services/mapsService');
|
||||
const noPhotoId = `ChIJNone-${Date.now()}`;
|
||||
@@ -1281,7 +1281,7 @@ describe('getPlacePhoto (fetch stubbed)', () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
text: async () => JSON.stringify({
|
||||
photos: [{ name: 'places/ChIJXYZ/photos/photo1', authorAttributions: [] }],
|
||||
}),
|
||||
})
|
||||
@@ -1301,7 +1301,7 @@ describe('getPlacePhoto (fetch stubbed)', () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
text: async () => JSON.stringify({
|
||||
photos: [{ name: 'places/ChIJNoAttr/photos/photo1', authorAttributions: [] }],
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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(8);
|
||||
expect(event_types.length).toBe(9);
|
||||
});
|
||||
|
||||
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(8);
|
||||
expect(event_types.length).toBe(9);
|
||||
});
|
||||
|
||||
it('NPREF-005b — admin scope returns only version_available', () => {
|
||||
|
||||
Reference in New Issue
Block a user