mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-20 22:01:45 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e49f3467c | |||
| 93b51a0bf5 | |||
| 5b710a429a | |||
| da3cba2de3 | |||
| 7f87dc1ce1 | |||
| e7b419d397 | |||
| de3152ee57 | |||
| de6c0fb781 |
@@ -0,0 +1,37 @@
|
|||||||
|
name: Security Scan
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
scout:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: false
|
||||||
|
load: true
|
||||||
|
tags: trek:scan
|
||||||
|
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- uses: docker/scout-action@v1
|
||||||
|
with:
|
||||||
|
command: cves
|
||||||
|
image: trek:scan
|
||||||
|
only-severities: critical,high
|
||||||
|
exit-code: true
|
||||||
+7
-4
@@ -1,5 +1,5 @@
|
|||||||
# Stage 1: Build React client
|
# Stage 1: Build React client
|
||||||
FROM node:22-alpine AS client-builder
|
FROM node:24-alpine AS client-builder
|
||||||
WORKDIR /app/client
|
WORKDIR /app/client
|
||||||
COPY client/package*.json ./
|
COPY client/package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
@@ -7,7 +7,7 @@ COPY client/ ./
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 2: Production server
|
# Stage 2: Production server
|
||||||
FROM node:22-alpine
|
FROM node:24-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -15,13 +15,16 @@ WORKDIR /app
|
|||||||
COPY server/package*.json ./
|
COPY server/package*.json ./
|
||||||
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
|
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
|
||||||
npm ci --production && \
|
npm ci --production && \
|
||||||
apk del python3 make g++
|
rm package-lock.json && \
|
||||||
|
apk del python3 make g++ && \
|
||||||
|
rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||||
|
|
||||||
COPY server/ ./
|
COPY server/ ./
|
||||||
COPY --from=client-builder /app/client/dist ./public
|
COPY --from=client-builder /app/client/dist ./public
|
||||||
COPY --from=client-builder /app/client/public/fonts ./public/fonts
|
COPY --from=client-builder /app/client/public/fonts ./public/fonts
|
||||||
|
|
||||||
RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
|
RUN rm -f package-lock.json && \
|
||||||
|
mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
|
||||||
mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data && \
|
mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data && \
|
||||||
chown -R node:node /app
|
chown -R node:node /app
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ Only the latest version receives security updates. Please update to the latest r
|
|||||||
If you discover a security vulnerability, please report it responsibly:
|
If you discover a security vulnerability, please report it responsibly:
|
||||||
|
|
||||||
1. **Do not** open a public issue
|
1. **Do not** open a public issue
|
||||||
2. Email: **mauriceboe@icloud.com**
|
2. Emails: **mauriceboe@icloud.com**, **trek-security@jubnl.ch**
|
||||||
3. Include a description of the vulnerability and steps to reproduce
|
3. Include a description of the vulnerability and steps to reproduce
|
||||||
|
|
||||||
You will receive a response within 48 hours. Once confirmed, a fix will be released as soon as possible.
|
You will receive a response within 48 hours. Once confirmed, a fix will be released as soon as possible.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
apiVersion: v2
|
apiVersion: v2
|
||||||
name: trek
|
name: trek
|
||||||
version: 3.0.16
|
version: 3.0.20
|
||||||
description: Minimal Helm chart for TREK app
|
description: Minimal Helm chart for TREK app
|
||||||
appVersion: "3.0.16"
|
appVersion: "3.0.20"
|
||||||
|
|||||||
Generated
+366
-1740
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-client",
|
"name": "trek-client",
|
||||||
"version": "3.0.16",
|
"version": "3.0.20",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
"@react-pdf/renderer": "^4.3.2",
|
"@react-pdf/renderer": "^4.3.2",
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
"dexie": "^4.4.2",
|
"dexie": "^4.4.2",
|
||||||
|
"heic-to": "^1.4.2",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.344.0",
|
"lucide-react": "^0.344.0",
|
||||||
"mapbox-gl": "^3.22.0",
|
"mapbox-gl": "^3.22.0",
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ import { useCanDo } from '../../store/permissionsStore'
|
|||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { isDayInAccommodationRange } from '../../utils/dayOrder'
|
import { isDayInAccommodationRange } from '../../utils/dayOrder'
|
||||||
|
import {
|
||||||
|
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay,
|
||||||
|
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems,
|
||||||
|
type MergedItem,
|
||||||
|
} from '../../utils/dayMerge'
|
||||||
import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters'
|
import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters'
|
||||||
import { useDayNotes } from '../../hooks/useDayNotes'
|
import { useDayNotes } from '../../hooks/useDayNotes'
|
||||||
import Tooltip from '../shared/Tooltip'
|
import Tooltip from '../shared/Tooltip'
|
||||||
@@ -362,26 +367,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
|
||||||
|
|
||||||
// Get span phase: how a reservation relates to a specific day (by id)
|
|
||||||
const getSpanPhase = (r: Reservation, dayId: number): 'single' | 'start' | 'middle' | 'end' => {
|
|
||||||
const startDayId = r.day_id
|
|
||||||
const endDayId = r.end_day_id ?? startDayId
|
|
||||||
if (!startDayId || startDayId === endDayId) return 'single'
|
|
||||||
if (dayId === startDayId) return 'start'
|
|
||||||
if (dayId === endDayId) return 'end'
|
|
||||||
return 'middle'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the appropriate display time for a reservation on a specific day
|
|
||||||
const getDisplayTimeForDay = (r: Reservation, dayId: number): string | null => {
|
|
||||||
const phase = getSpanPhase(r, dayId)
|
|
||||||
if (phase === 'end') return r.reservation_end_time || null
|
|
||||||
if (phase === 'middle') return null
|
|
||||||
return r.reservation_time || null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get phase label for multi-day badge
|
// Get phase label for multi-day badge
|
||||||
const getSpanLabel = (r: Reservation, phase: string): string | null => {
|
const getSpanLabel = (r: Reservation, phase: string): string | null => {
|
||||||
if (phase === 'single') return null
|
if (phase === 'single') return null
|
||||||
@@ -406,27 +391,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
return { day_id: startId, end_day_id: targetDayId }
|
return { day_id: startId, end_day_id: targetDayId }
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTransportForDay = (dayId: number) => {
|
const getTransportForDay = (dayId: number) =>
|
||||||
const dayAssignmentIds = (assignments[String(dayId)] || []).map(a => a.id)
|
_getTransportForDay({ reservations, dayId, dayAssignmentIds: (assignments[String(dayId)] || []).map(a => a.id), days })
|
||||||
return reservations.filter(r => {
|
|
||||||
if (!TRANSPORT_TYPES.has(r.type)) return false
|
|
||||||
if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false
|
|
||||||
|
|
||||||
const startDayId = r.day_id
|
|
||||||
const endDayId = r.end_day_id ?? startDayId
|
|
||||||
|
|
||||||
if (startDayId == null) return false
|
|
||||||
|
|
||||||
if (endDayId !== startDayId) {
|
|
||||||
const startDay = days.find(d => d.id === startDayId)
|
|
||||||
const endDay = days.find(d => d.id === endDayId)
|
|
||||||
const thisDay = days.find(d => d.id === dayId)
|
|
||||||
if (!startDay || !endDay || !thisDay) return false
|
|
||||||
return getDayOrder(thisDay) >= getDayOrder(startDay) && getDayOrder(thisDay) <= getDayOrder(endDay)
|
|
||||||
}
|
|
||||||
return startDayId === dayId
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get car rentals that are in "active" (middle) phase for a day — shown in day header, not timeline
|
// Get car rentals that are in "active" (middle) phase for a day — shown in day header, not timeline
|
||||||
const getActiveRentalsForDay = (dayId: number) => {
|
const getActiveRentalsForDay = (dayId: number) => {
|
||||||
@@ -446,20 +412,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
const getDayAssignments = (dayId) =>
|
const getDayAssignments = (dayId) =>
|
||||||
(assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
(assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||||
|
|
||||||
// Helper: parse time string ("HH:MM" or ISO) to minutes since midnight, or null
|
|
||||||
const parseTimeToMinutes = (time?: string | null): number | null => {
|
|
||||||
if (!time) return null
|
|
||||||
// ISO-Format "2025-03-30T09:00:00"
|
|
||||||
if (time.includes('T')) {
|
|
||||||
const [h, m] = time.split('T')[1].split(':').map(Number)
|
|
||||||
return h * 60 + m
|
|
||||||
}
|
|
||||||
// Einfaches "HH:MM" Format
|
|
||||||
const parts = time.split(':').map(Number)
|
|
||||||
if (parts.length >= 2 && !isNaN(parts[0]) && !isNaN(parts[1])) return parts[0] * 60 + parts[1]
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute initial day_plan_position for a transport based on time
|
// Compute initial day_plan_position for a transport based on time
|
||||||
const computeTransportPosition = (r, da) => {
|
const computeTransportPosition = (r, da) => {
|
||||||
const minutes = parseTimeToMinutes(r.reservation_time) ?? 0
|
const minutes = parseTimeToMinutes(r.reservation_time) ?? 0
|
||||||
@@ -501,64 +453,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
reservationsApi.updatePositions(tripId, positions).catch(() => {})
|
reservationsApi.updatePositions(tripId, positions).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const getMergedItems = (dayId) => {
|
const getMergedItems = (dayId: number): MergedItem[] =>
|
||||||
const da = getDayAssignments(dayId)
|
_getMergedItems({
|
||||||
const dn = (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order)
|
dayAssignments: getDayAssignments(dayId),
|
||||||
const transport = getTransportForDay(dayId)
|
dayNotes: (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order),
|
||||||
|
dayTransports: getTransportForDay(dayId),
|
||||||
// All places keep their order_index — untimed can be freely moved, timed auto-sort when time is set
|
dayId,
|
||||||
const baseItems = [
|
getDisplayTime: getDisplayTimeForDay,
|
||||||
...da.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })),
|
})
|
||||||
...dn.map(n => ({ type: 'note' as const, sortKey: n.sort_order, data: n })),
|
|
||||||
].sort((a, b) => a.sortKey - b.sortKey)
|
|
||||||
|
|
||||||
// Transports are inserted among places based on time
|
|
||||||
const timedTransports = transport.map(r => ({
|
|
||||||
type: 'transport' as const,
|
|
||||||
data: r,
|
|
||||||
minutes: parseTimeToMinutes(getDisplayTimeForDay(r, dayId)) ?? 0,
|
|
||||||
})).sort((a, b) => a.minutes - b.minutes)
|
|
||||||
|
|
||||||
if (timedTransports.length === 0) return baseItems
|
|
||||||
if (baseItems.length === 0) {
|
|
||||||
return timedTransports.map((item, i) => ({ ...item, sortKey: i }))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert transports among places based on per-day position or time
|
|
||||||
const result = [...baseItems]
|
|
||||||
for (let ti = 0; ti < timedTransports.length; ti++) {
|
|
||||||
const timed = timedTransports[ti]
|
|
||||||
const minutes = timed.minutes
|
|
||||||
|
|
||||||
// Use per-day position if explicitly set by user reorder
|
|
||||||
const perDayPos = timed.data.day_positions?.[dayId] ?? timed.data.day_positions?.[String(dayId)]
|
|
||||||
if (perDayPos != null) {
|
|
||||||
result.push({ type: timed.type, sortKey: perDayPos, data: timed.data })
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find insertion position: after the last place with time <= this transport's time
|
|
||||||
let insertAfterKey = -Infinity
|
|
||||||
for (const item of result) {
|
|
||||||
if (item.type === 'place') {
|
|
||||||
const pm = parseTimeToMinutes(item.data?.place?.place_time)
|
|
||||||
if (pm !== null && pm <= minutes) insertAfterKey = item.sortKey
|
|
||||||
} else if (item.type === 'transport') {
|
|
||||||
const tm = parseTimeToMinutes(item.data?.reservation_time)
|
|
||||||
if (tm !== null && tm <= minutes) insertAfterKey = item.sortKey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastKey = result.length > 0 ? Math.max(...result.map(i => i.sortKey)) : 0
|
|
||||||
const sortKey = insertAfterKey === -Infinity
|
|
||||||
? lastKey + 0.5 + ti * 0.01
|
|
||||||
: insertAfterKey + 0.01 + ti * 0.001
|
|
||||||
|
|
||||||
result.push({ type: timed.type, sortKey, data: timed.data })
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.sort((a, b) => a.sortKey - b.sortKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-compute merged items for all days so the render loop doesn't recompute on unrelated state changes (e.g. hover)
|
// Pre-compute merged items for all days so the render loop doesn't recompute on unrelated state changes (e.g. hover)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
||||||
import { formatLocationName } from '../utils/formatters'
|
import { formatLocationName } from '../utils/formatters'
|
||||||
|
import { normalizeImageFiles } from '../utils/convertHeic'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import { useJourneyStore } from '../store/journeyStore'
|
import { useJourneyStore } from '../store/journeyStore'
|
||||||
@@ -1027,8 +1028,9 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
|
|||||||
if (!files?.length) return
|
if (!files?.length) return
|
||||||
setGalleryUploading(true)
|
setGalleryUploading(true)
|
||||||
try {
|
try {
|
||||||
|
const normalized = await normalizeImageFiles(files)
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
for (const f of files) formData.append('photos', f)
|
for (const f of normalized) formData.append('photos', f)
|
||||||
await journeyApi.uploadGalleryPhotos(journeyId, formData)
|
await journeyApi.uploadGalleryPhotos(journeyId, formData)
|
||||||
toast.success(t('journey.photosUploaded', { count: files.length }))
|
toast.success(t('journey.photosUploaded', { count: files.length }))
|
||||||
onRefresh()
|
onRefresh()
|
||||||
@@ -2265,7 +2267,8 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
if (!files?.length) return
|
if (!files?.length) return
|
||||||
// Queue files locally until Save so cancel/close actually discards. This
|
// Queue files locally until Save so cancel/close actually discards. This
|
||||||
// keeps photo behavior consistent with text fields — no silent persistence.
|
// keeps photo behavior consistent with text fields — no silent persistence.
|
||||||
setPendingFiles(prev => [...prev, ...Array.from(files)])
|
const normalized = await normalizeImageFiles(files)
|
||||||
|
setPendingFiles(prev => [...prev, ...normalized])
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import { createElement } from 'react'
|
|||||||
import { renderToStaticMarkup } from 'react-dom/server'
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
import { Clock, MapPin, FileText, Train, Plane, Bus, Car, Ship, Ticket, Hotel, Map, Luggage, Wallet, MessageCircle } from 'lucide-react'
|
import { Clock, MapPin, FileText, Train, Plane, Bus, Car, Ship, Ticket, Hotel, Map, Luggage, Wallet, MessageCircle } from 'lucide-react'
|
||||||
import { isDayInAccommodationRange } from '../utils/dayOrder'
|
import { isDayInAccommodationRange } from '../utils/dayOrder'
|
||||||
|
import { getTransportForDay, getMergedItems } from '../utils/dayMerge'
|
||||||
|
|
||||||
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
|
||||||
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
|
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
|
||||||
|
|
||||||
function createMarkerIcon(place: any) {
|
function createMarkerIcon(place: any) {
|
||||||
@@ -184,14 +184,16 @@ export default function SharedTripPage() {
|
|||||||
{sortedDays.map((day: any, di: number) => {
|
{sortedDays.map((day: any, di: number) => {
|
||||||
const da = assignments[String(day.id)] || []
|
const da = assignments[String(day.id)] || []
|
||||||
const notes = (dayNotes[String(day.id)] || [])
|
const notes = (dayNotes[String(day.id)] || [])
|
||||||
const dayTransport = (reservations || []).filter((r: any) => TRANSPORT_TYPES.has(r.type) && r.reservation_time?.split('T')[0] === day.date)
|
const dayAssignmentIds: number[] = da.map((a: any) => a.id)
|
||||||
|
const dayTransport = getTransportForDay({ reservations: reservations || [], dayId: day.id, dayAssignmentIds, days: sortedDays })
|
||||||
const dayAccs = (accommodations || []).filter((a: any) => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, sortedDays))
|
const dayAccs = (accommodations || []).filter((a: any) => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, sortedDays))
|
||||||
|
|
||||||
const merged = [
|
const merged = getMergedItems({
|
||||||
...da.map((a: any) => ({ type: 'place', k: a.order_index, data: a })),
|
dayAssignments: da,
|
||||||
...notes.map((n: any) => ({ type: 'note', k: n.sort_order ?? 0, data: n })),
|
dayNotes: notes,
|
||||||
...dayTransport.map((r: any) => ({ type: 'transport', k: r.day_plan_position ?? 999, data: r })),
|
dayTransports: dayTransport,
|
||||||
].sort((a, b) => a.k - b.k)
|
dayId: day.id,
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={day.id} style={{ background: 'var(--bg-card, white)', borderRadius: 14, overflow: 'hidden', border: '1px solid var(--border-faint, #e5e7eb)' }}>
|
<div key={day.id} style={{ background: 'var(--bg-card, white)', borderRadius: 14, overflow: 'hidden', border: '1px solid var(--border-faint, #e5e7eb)' }}>
|
||||||
@@ -212,7 +214,7 @@ export default function SharedTripPage() {
|
|||||||
|
|
||||||
{selectedDay === day.id && merged.length > 0 && (
|
{selectedDay === day.id && merged.length > 0 && (
|
||||||
<div style={{ padding: '0 16px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<div style={{ padding: '0 16px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
{merged.map((item: any, idx: number) => {
|
{merged.map((item: any) => {
|
||||||
if (item.type === 'transport') {
|
if (item.type === 'transport') {
|
||||||
const r = item.data
|
const r = item.data
|
||||||
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
|
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ export interface Reservation {
|
|||||||
accommodation_start_day_id?: number | null
|
accommodation_start_day_id?: number | null
|
||||||
accommodation_end_day_id?: number | null
|
accommodation_end_day_id?: number | null
|
||||||
day_plan_position?: number | null
|
day_plan_position?: number | null
|
||||||
|
day_positions?: Record<number, number> | null
|
||||||
metadata?: Record<string, string> | string | null
|
metadata?: Record<string, string> | string | null
|
||||||
needs_review?: number
|
needs_review?: number
|
||||||
endpoints?: ReservationEndpoint[]
|
endpoints?: ReservationEndpoint[]
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
function looksLikeHeic(file: File): boolean {
|
||||||
|
const ext = file.name.split('.').pop()?.toLowerCase() ?? ''
|
||||||
|
return ext === 'heic' || ext === 'heif' || file.type === 'image/heic' || file.type === 'image/heif'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function normalizeImageFile(file: File): Promise<File> {
|
||||||
|
if (!looksLikeHeic(file)) return file
|
||||||
|
const { isHeic, heicTo } = await import('heic-to')
|
||||||
|
if (!(await isHeic(file))) return file
|
||||||
|
const blob = await heicTo({ blob: file, type: 'image/jpeg', quality: 0.92 })
|
||||||
|
const jpegName = file.name.replace(/\.(heic|heif)$/i, '.jpg')
|
||||||
|
return new File([blob], jpegName, { type: 'image/jpeg' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function normalizeImageFiles(files: FileList | File[]): Promise<File[]> {
|
||||||
|
return Promise.all(Array.from(files).map(normalizeImageFile))
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay, getTransportForDay, getMergedItems } from './dayMerge'
|
||||||
|
|
||||||
|
describe('parseTimeToMinutes', () => {
|
||||||
|
it('parses HH:MM string', () => {
|
||||||
|
expect(parseTimeToMinutes('09:30')).toBe(570)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses ISO datetime string', () => {
|
||||||
|
expect(parseTimeToMinutes('2025-03-30T14:00:00')).toBe(840)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for null/empty', () => {
|
||||||
|
expect(parseTimeToMinutes(null)).toBeNull()
|
||||||
|
expect(parseTimeToMinutes(undefined)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getSpanPhase', () => {
|
||||||
|
it('returns single when start === end', () => {
|
||||||
|
expect(getSpanPhase({ day_id: 1, end_day_id: 1 }, 1)).toBe('single')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns start for the departure day', () => {
|
||||||
|
expect(getSpanPhase({ day_id: 1, end_day_id: 3 }, 1)).toBe('start')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns end for the arrival day', () => {
|
||||||
|
expect(getSpanPhase({ day_id: 1, end_day_id: 3 }, 3)).toBe('end')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns middle for days in between', () => {
|
||||||
|
expect(getSpanPhase({ day_id: 1, end_day_id: 3 }, 2)).toBe('middle')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getDisplayTimeForDay', () => {
|
||||||
|
const r = { day_id: 1, end_day_id: 3, reservation_time: '2025-01-01T09:00:00', reservation_end_time: '2025-01-03T14:00:00' }
|
||||||
|
|
||||||
|
it('returns reservation_time on start day', () => {
|
||||||
|
expect(getDisplayTimeForDay(r, 1)).toBe(r.reservation_time)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns reservation_end_time on end day', () => {
|
||||||
|
expect(getDisplayTimeForDay(r, 3)).toBe(r.reservation_end_time)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for middle day', () => {
|
||||||
|
expect(getDisplayTimeForDay(r, 2)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getTransportForDay', () => {
|
||||||
|
const days = [
|
||||||
|
{ id: 1, day_number: 1 },
|
||||||
|
{ id: 2, day_number: 2 },
|
||||||
|
{ id: 3, day_number: 3 },
|
||||||
|
]
|
||||||
|
|
||||||
|
it('excludes non-transport types', () => {
|
||||||
|
const reservations = [{ id: 10, type: 'hotel', day_id: 1 }]
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes single-day transport on the correct day', () => {
|
||||||
|
const reservations = [{ id: 10, type: 'flight', day_id: 1, end_day_id: 1 }]
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(1)
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 2, dayAssignmentIds: [], days })).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes multi-day transport on all spanned days', () => {
|
||||||
|
const reservations = [{ id: 10, type: 'train', day_id: 1, end_day_id: 3 }]
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(1)
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 2, dayAssignmentIds: [], days })).toHaveLength(1)
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 3, dayAssignmentIds: [], days })).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes transport linked to an assignment on that day', () => {
|
||||||
|
const reservations = [{ id: 10, type: 'bus', day_id: 1, end_day_id: 1, assignment_id: 42 }]
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [42], days })).toHaveLength(0)
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [99], days })).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getMergedItems', () => {
|
||||||
|
it('merges places and notes sorted by sortKey', () => {
|
||||||
|
const dayAssignments = [
|
||||||
|
{ id: 1, order_index: 0, place: { place_time: null } },
|
||||||
|
{ id: 2, order_index: 2, place: { place_time: null } },
|
||||||
|
]
|
||||||
|
const dayNotes = [{ id: 10, sort_order: 1 }]
|
||||||
|
const result = getMergedItems({ dayAssignments, dayNotes, dayTransports: [], dayId: 5 })
|
||||||
|
expect(result.map(i => i.type)).toEqual(['place', 'note', 'place'])
|
||||||
|
expect(result[0].data.id).toBe(1)
|
||||||
|
expect(result[1].data.id).toBe(10)
|
||||||
|
expect(result[2].data.id).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('inserts transport by time when no per-day position is set', () => {
|
||||||
|
const dayAssignments = [
|
||||||
|
{ id: 1, order_index: 0, place: { place_time: '08:00' } },
|
||||||
|
{ id: 2, order_index: 1, place: { place_time: '13:00' } },
|
||||||
|
]
|
||||||
|
const dayTransports = [
|
||||||
|
{ id: 20, type: 'flight', day_id: 5, end_day_id: 5, reservation_time: '10:30', day_positions: null },
|
||||||
|
]
|
||||||
|
const result = getMergedItems({ dayAssignments, dayNotes: [], dayTransports, dayId: 5 })
|
||||||
|
const types = result.map(i => i.type)
|
||||||
|
// transport (10:30) should be between place at 08:00 (idx 0) and place at 13:00 (idx 1)
|
||||||
|
expect(types).toEqual(['place', 'transport', 'place'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('per-day position overrides time-based insertion', () => {
|
||||||
|
const dayAssignments = [
|
||||||
|
{ id: 1, order_index: 0, place: { place_time: '08:00' } },
|
||||||
|
{ id: 2, order_index: 1, place: { place_time: '13:00' } },
|
||||||
|
]
|
||||||
|
// Transport at 10:30 would normally go between the two places
|
||||||
|
// but per-day position 1.5 puts it after the second place
|
||||||
|
const dayTransports = [
|
||||||
|
{ id: 20, type: 'train', day_id: 5, end_day_id: 5, reservation_time: '10:30', day_positions: { 5: 1.5 } },
|
||||||
|
]
|
||||||
|
const result = getMergedItems({ dayAssignments, dayNotes: [], dayTransports, dayId: 5 })
|
||||||
|
const types = result.map(i => i.type)
|
||||||
|
expect(types).toEqual(['place', 'place', 'transport'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
export const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
||||||
|
|
||||||
|
export interface MergedItem {
|
||||||
|
type: 'place' | 'note' | 'transport'
|
||||||
|
sortKey: number
|
||||||
|
data: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTimeToMinutes(time?: string | null): number | null {
|
||||||
|
if (!time) return null
|
||||||
|
if (time.includes('T')) {
|
||||||
|
const [h, m] = time.split('T')[1].split(':').map(Number)
|
||||||
|
return h * 60 + m
|
||||||
|
}
|
||||||
|
const parts = time.split(':').map(Number)
|
||||||
|
if (parts.length >= 2 && !isNaN(parts[0]) && !isNaN(parts[1])) return parts[0] * 60 + parts[1]
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSpanPhase(
|
||||||
|
r: { day_id?: number | null; end_day_id?: number | null },
|
||||||
|
dayId: number
|
||||||
|
): 'single' | 'start' | 'middle' | 'end' {
|
||||||
|
const startDayId = r.day_id
|
||||||
|
const endDayId = r.end_day_id ?? startDayId
|
||||||
|
if (!startDayId || startDayId === endDayId) return 'single'
|
||||||
|
if (dayId === startDayId) return 'start'
|
||||||
|
if (dayId === endDayId) return 'end'
|
||||||
|
return 'middle'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDisplayTimeForDay(
|
||||||
|
r: { day_id?: number | null; end_day_id?: number | null; reservation_time?: string | null; reservation_end_time?: string | null },
|
||||||
|
dayId: number
|
||||||
|
): string | null {
|
||||||
|
const phase = getSpanPhase(r, dayId)
|
||||||
|
if (phase === 'end') return r.reservation_end_time || null
|
||||||
|
if (phase === 'middle') return null
|
||||||
|
return r.reservation_time || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filter reservations that are active transports for the given day, excluding assignment-linked ones. */
|
||||||
|
export function getTransportForDay(opts: {
|
||||||
|
reservations: any[]
|
||||||
|
dayId: number
|
||||||
|
dayAssignmentIds: number[]
|
||||||
|
days: Array<{ id: number; day_number?: number }>
|
||||||
|
}): any[] {
|
||||||
|
const { reservations, dayId, dayAssignmentIds, days } = opts
|
||||||
|
|
||||||
|
const getDayOrder = (id: number): number => {
|
||||||
|
const d = days.find(x => x.id === id)
|
||||||
|
return d ? ((d as any).day_number ?? days.indexOf(d)) : 0
|
||||||
|
}
|
||||||
|
const thisDayOrder = getDayOrder(dayId)
|
||||||
|
|
||||||
|
return reservations.filter(r => {
|
||||||
|
if (!TRANSPORT_TYPES.has(r.type)) return false
|
||||||
|
if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false
|
||||||
|
|
||||||
|
const startDayId = r.day_id
|
||||||
|
const endDayId = r.end_day_id ?? startDayId
|
||||||
|
|
||||||
|
if (startDayId == null) return false
|
||||||
|
|
||||||
|
if (endDayId !== startDayId) {
|
||||||
|
const startOrder = getDayOrder(startDayId)
|
||||||
|
const endOrder = getDayOrder(endDayId)
|
||||||
|
return thisDayOrder >= startOrder && thisDayOrder <= endOrder
|
||||||
|
}
|
||||||
|
return startDayId === dayId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merge places, notes, and transports into a single ordered day timeline. */
|
||||||
|
export function getMergedItems(opts: {
|
||||||
|
dayAssignments: any[]
|
||||||
|
dayNotes: any[]
|
||||||
|
dayTransports: any[]
|
||||||
|
dayId: number
|
||||||
|
getDisplayTime?: (r: any, dayId: number) => string | null
|
||||||
|
}): MergedItem[] {
|
||||||
|
const { dayAssignments: da, dayNotes: dn, dayTransports: transport, dayId } = opts
|
||||||
|
const getDisplayTime = opts.getDisplayTime ?? getDisplayTimeForDay
|
||||||
|
|
||||||
|
const baseItems: MergedItem[] = [
|
||||||
|
...da.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })),
|
||||||
|
...dn.map(n => ({ type: 'note' as const, sortKey: n.sort_order ?? 0, data: n })),
|
||||||
|
].sort((a, b) => a.sortKey - b.sortKey)
|
||||||
|
|
||||||
|
const timedTransports = transport.map(r => ({
|
||||||
|
type: 'transport' as const,
|
||||||
|
data: r,
|
||||||
|
minutes: parseTimeToMinutes(getDisplayTime(r, dayId)) ?? 0,
|
||||||
|
})).sort((a, b) => a.minutes - b.minutes)
|
||||||
|
|
||||||
|
if (timedTransports.length === 0) return baseItems
|
||||||
|
if (baseItems.length === 0) {
|
||||||
|
return timedTransports.map((item, i) => ({ type: item.type, sortKey: i, data: item.data }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert transports among base items based on per-day position or time
|
||||||
|
const result = [...baseItems]
|
||||||
|
for (let ti = 0; ti < timedTransports.length; ti++) {
|
||||||
|
const timed = timedTransports[ti]
|
||||||
|
const minutes = timed.minutes
|
||||||
|
|
||||||
|
// Per-day position takes precedence (set by user reorder)
|
||||||
|
const perDayPos = timed.data.day_positions?.[dayId] ?? timed.data.day_positions?.[String(dayId)]
|
||||||
|
if (perDayPos != null) {
|
||||||
|
result.push({ type: timed.type, sortKey: perDayPos, data: timed.data })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time-based fallback: insert after the last item whose time <= this transport's time
|
||||||
|
let insertAfterKey = -Infinity
|
||||||
|
for (const item of result) {
|
||||||
|
if (item.type === 'place') {
|
||||||
|
const pm = parseTimeToMinutes(item.data?.place?.place_time)
|
||||||
|
if (pm !== null && pm <= minutes) insertAfterKey = item.sortKey
|
||||||
|
} else if (item.type === 'transport') {
|
||||||
|
const tm = parseTimeToMinutes(item.data?.reservation_time)
|
||||||
|
if (tm !== null && tm <= minutes) insertAfterKey = item.sortKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastKey = result.length > 0 ? Math.max(...result.map(i => i.sortKey)) : 0
|
||||||
|
const sortKey = insertAfterKey === -Infinity
|
||||||
|
? lastKey + 0.5 + ti * 0.01
|
||||||
|
: insertAfterKey + 0.01 + ti * 0.001
|
||||||
|
|
||||||
|
result.push({ type: timed.type, sortKey, data: timed.data })
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.sort((a, b) => a.sortKey - b.sortKey)
|
||||||
|
}
|
||||||
Generated
+78
-1107
File diff suppressed because it is too large
Load Diff
+5
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-server",
|
"name": "trek-server",
|
||||||
"version": "3.0.16",
|
"version": "3.0.20",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node --import tsx src/index.ts",
|
"start": "node --import tsx src/index.ts",
|
||||||
@@ -40,8 +40,10 @@
|
|||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"hono": "^4.12.12",
|
"hono": "^4.12.16",
|
||||||
"@hono/node-server": "^1.19.13"
|
"@hono/node-server": "^1.19.13",
|
||||||
|
"picomatch": "^4.0.4",
|
||||||
|
"ip-address": "^10.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/archiver": "^7.0.0",
|
"@types/archiver": "^7.0.0",
|
||||||
|
|||||||
+7
-10
@@ -50,11 +50,11 @@ import { getCollabFeatures } from './services/adminService';
|
|||||||
import { isAddonEnabled } from './services/adminService';
|
import { isAddonEnabled } from './services/adminService';
|
||||||
import { ADDON_IDS } from './addons';
|
import { ADDON_IDS } from './addons';
|
||||||
import { ALL_SCOPES } from './mcp/scopes';
|
import { ALL_SCOPES } from './mcp/scopes';
|
||||||
import { getAppUrl } from './services/oidcService';
|
|
||||||
import { mcpAuthMetadataRouter } from '@modelcontextprotocol/sdk/server/auth/router';
|
import { mcpAuthMetadataRouter } from '@modelcontextprotocol/sdk/server/auth/router';
|
||||||
import { authorizationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/authorize';
|
import { authorizationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/authorize';
|
||||||
import { clientRegistrationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/register';
|
import { clientRegistrationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/register';
|
||||||
import type { OAuthMetadata } from '@modelcontextprotocol/sdk/shared/auth';
|
import type { OAuthMetadata } from '@modelcontextprotocol/sdk/shared/auth';
|
||||||
|
import { getMcpSafeUrl } from './services/notifications';
|
||||||
|
|
||||||
export function createApp(): express.Application {
|
export function createApp(): express.Application {
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -122,7 +122,7 @@ export function createApp(): express.Application {
|
|||||||
contentSecurityPolicy: {
|
contentSecurityPolicy: {
|
||||||
directives: {
|
directives: {
|
||||||
defaultSrc: ["'self'"],
|
defaultSrc: ["'self'"],
|
||||||
scriptSrc: ["'self'", "'wasm-unsafe-eval'"],
|
scriptSrc: ["'self'", "'wasm-unsafe-eval'", "'unsafe-eval'"],
|
||||||
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://unpkg.com"],
|
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://unpkg.com"],
|
||||||
imgSrc: ["'self'", "data:", "blob:", "https:"],
|
imgSrc: ["'self'", "data:", "blob:", "https:"],
|
||||||
connectSrc: [
|
connectSrc: [
|
||||||
@@ -388,7 +388,7 @@ export function createApp(): express.Application {
|
|||||||
|
|
||||||
function getOAuthMetadata(): OAuthMetadata {
|
function getOAuthMetadata(): OAuthMetadata {
|
||||||
if (_oauthMetadata) return _oauthMetadata;
|
if (_oauthMetadata) return _oauthMetadata;
|
||||||
const base = (getAppUrl() || 'http://localhost:3001').replace(/\/+$/, '');
|
const base = getMcpSafeUrl().replace(/\/+$/, '');
|
||||||
_oauthMetadata = {
|
_oauthMetadata = {
|
||||||
issuer: base,
|
issuer: base,
|
||||||
authorization_endpoint: `${base}/oauth/authorize`,
|
authorization_endpoint: `${base}/oauth/authorize`,
|
||||||
@@ -416,14 +416,11 @@ export function createApp(): express.Application {
|
|||||||
return _sdkMetaRouter;
|
return _sdkMetaRouter;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path-aware gate: only /.well-known/* returns 404 when disabled; other paths pass through
|
// Only invoke the SDK metadata router for /.well-known/* paths.
|
||||||
// so static files and SPA routes are unaffected when MCP is off.
|
// Calling getMetaRouter() on every request triggers lazy init (new URL(...)) which
|
||||||
|
// throws "Invalid URL" when APP_URL lacks a protocol — breaking all page loads.
|
||||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||||
const isMetadataPath =
|
if (req.path.startsWith('/.well-known/') && !isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
|
||||||
req.path === '/.well-known/oauth-authorization-server' ||
|
|
||||||
req.path === '/.well-known/openid-configuration' ||
|
|
||||||
req.path.startsWith('/.well-known/oauth-protected-resource');
|
|
||||||
if (isMetadataPath && !isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
|
|
||||||
getMetaRouter()(req, res, next);
|
getMetaRouter()(req, res, next);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+31
-10
@@ -19,6 +19,7 @@ const tmpDir = path.join(__dirname, '../data/tmp');
|
|||||||
const app = createApp();
|
const app = createApp();
|
||||||
|
|
||||||
import * as scheduler from './scheduler';
|
import * as scheduler from './scheduler';
|
||||||
|
import { getAppUrl, getMcpSafeUrl } from './services/notifications';
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT) || 3001;
|
const PORT = Number(process.env.PORT) || 3001;
|
||||||
const HOST = process.env.HOST;
|
const HOST = process.env.HOST;
|
||||||
@@ -29,22 +30,42 @@ const onListen = () => {
|
|||||||
const LOG_LVL = (process.env.LOG_LEVEL || 'info').toLowerCase();
|
const LOG_LVL = (process.env.LOG_LEVEL || 'info').toLowerCase();
|
||||||
const tz = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
const tz = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||||
const origins = process.env.ALLOWED_ORIGINS || '(same-origin)';
|
const origins = process.env.ALLOWED_ORIGINS || '(same-origin)';
|
||||||
|
const appUrl = getAppUrl();
|
||||||
|
const resolvedAppUrl = getMcpSafeUrl();
|
||||||
const banner = [
|
const banner = [
|
||||||
'──────────────────────────────────────',
|
'──────────────────────────────────────',
|
||||||
' TREK API started',
|
' TREK API started',
|
||||||
` Version ${APP_VERSION}`,
|
` Version ${APP_VERSION}`,
|
||||||
...(HOST ? [` Host: ${HOST}`] : []),
|
...(HOST ? [` Host: ${HOST}`] : []),
|
||||||
` Port: ${PORT}`,
|
` Container Port: ${PORT}`,
|
||||||
` Environment: ${process.env.NODE_ENV?.toLowerCase() || 'development'}`,
|
` App URL: ${appUrl}`,
|
||||||
` Timezone: ${tz}`,
|
` Environment: ${process.env.NODE_ENV?.toLowerCase() || 'development'}`,
|
||||||
` Origins: ${origins}`,
|
` Timezone: ${tz}`,
|
||||||
` Log level: ${LOG_LVL}`,
|
` Origins: ${origins}`,
|
||||||
` Log file: /app/data/logs/trek.log`,
|
` Log level: ${LOG_LVL}`,
|
||||||
` PID: ${process.pid}`,
|
` Log file: /app/data/logs/trek.log`,
|
||||||
` User: uid=${process.getuid?.()} gid=${process.getgid?.()}`,
|
` PID: ${process.pid}`,
|
||||||
|
` User: uid=${process.getuid?.()} gid=${process.getgid?.()}`,
|
||||||
'──────────────────────────────────────',
|
'──────────────────────────────────────',
|
||||||
];
|
];
|
||||||
banner.forEach(l => console.log(l));
|
banner.forEach(l => console.log(l));
|
||||||
|
if (process.env.APP_URL) {
|
||||||
|
let parsedAppUrl: URL | null = null;
|
||||||
|
try { parsedAppUrl = new URL(process.env.APP_URL); } catch { /* invalid */ }
|
||||||
|
|
||||||
|
if (!parsedAppUrl) {
|
||||||
|
sLogWarn(`APP_URL: "${process.env.APP_URL}" is not a valid URL — it will be ignored.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mcpSafe = parsedAppUrl !== null && (
|
||||||
|
parsedAppUrl.protocol === 'https:' ||
|
||||||
|
parsedAppUrl.hostname === 'localhost' ||
|
||||||
|
parsedAppUrl.hostname === '127.0.0.1'
|
||||||
|
);
|
||||||
|
if (!mcpSafe) {
|
||||||
|
sLogWarn(`APP_URL: not MCP-safe (requires https:// or http://localhost) — MCP will use ${resolvedAppUrl}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (process.env.DEMO_MODE?.toLowerCase() === 'true') sLogInfo('Demo mode: ENABLED');
|
if (process.env.DEMO_MODE?.toLowerCase() === 'true') sLogInfo('Demo mode: ENABLED');
|
||||||
if (process.env.DEMO_MODE?.toLowerCase() === 'true' && process.env.NODE_ENV?.toLowerCase() === 'production') {
|
if (process.env.DEMO_MODE?.toLowerCase() === 'true' && process.env.NODE_ENV?.toLowerCase() === 'production') {
|
||||||
sLogWarn('SECURITY WARNING: DEMO_MODE is enabled in production!');
|
sLogWarn('SECURITY WARNING: DEMO_MODE is enabled in production!');
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { registerResources } from './resources';
|
|||||||
import { registerTools } from './tools';
|
import { registerTools } from './tools';
|
||||||
import { McpSession, sessions, revokeUserSessions, revokeUserSessionsForClient } from './sessionManager';
|
import { McpSession, sessions, revokeUserSessions, revokeUserSessionsForClient } from './sessionManager';
|
||||||
import { writeAudit, getClientIp } from '../services/auditLog';
|
import { writeAudit, getClientIp } from '../services/auditLog';
|
||||||
import { getAppUrl } from '../services/oidcService';
|
import { getMcpSafeUrl } from '../services/notifications';
|
||||||
|
|
||||||
export { revokeUserSessions, revokeUserSessionsForClient };
|
export { revokeUserSessions, revokeUserSessionsForClient };
|
||||||
|
|
||||||
@@ -153,7 +153,7 @@ const sessionSweepInterval = setInterval(() => {
|
|||||||
sessionSweepInterval.unref();
|
sessionSweepInterval.unref();
|
||||||
|
|
||||||
function setAuthChallenge(res: Response, error = 'invalid_token'): void {
|
function setAuthChallenge(res: Response, error = 'invalid_token'): void {
|
||||||
const base = (getAppUrl() || '').replace(/\/+$/, '');
|
const base = (getMcpSafeUrl() || '').replace(/\/+$/, '');
|
||||||
// RFC 9728 §5: resource with path component /mcp → PRM URL must include the path
|
// RFC 9728 §5: resource with path component /mcp → PRM URL must include the path
|
||||||
res.set('WWW-Authenticate',
|
res.set('WWW-Authenticate',
|
||||||
`Bearer realm="TREK MCP", resource_metadata="${base}/.well-known/oauth-protected-resource/mcp", error="${error}"`);
|
`Bearer realm="TREK MCP", resource_metadata="${base}/.well-known/oauth-protected-resource/mcp", error="${error}"`);
|
||||||
@@ -183,7 +183,7 @@ function verifyToken(authHeader: string | undefined): VerifyTokenResult | null {
|
|||||||
if (!result) return null;
|
if (!result) return null;
|
||||||
// RFC 8707: audience must always match this resource endpoint.
|
// RFC 8707: audience must always match this resource endpoint.
|
||||||
// Pre-audit tokens with audience=null are revoked by the SEC-H6 migration.
|
// Pre-audit tokens with audience=null are revoked by the SEC-H6 migration.
|
||||||
const expected = `${(getAppUrl() || '').replace(/\/+$/, '')}/mcp`;
|
const expected = `${(getMcpSafeUrl() || '').replace(/\/+$/, '')}/mcp`;
|
||||||
if (result.audience !== expected) return null;
|
if (result.audience !== expected) return null;
|
||||||
return { user: result.user, scopes: result.scopes, clientId: result.clientId, isStaticToken: false };
|
return { user: result.user, scopes: result.scopes, clientId: result.clientId, isStaticToken: false };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
getUserByAccessToken,
|
getUserByAccessToken,
|
||||||
} from '../services/oauthService';
|
} from '../services/oauthService';
|
||||||
import { ALL_SCOPES } from './scopes';
|
import { ALL_SCOPES } from './scopes';
|
||||||
import { getAppUrl } from '../services/oidcService';
|
import { getMcpSafeUrl } from '../services/notifications';
|
||||||
import { writeAudit } from '../services/auditLog';
|
import { writeAudit } from '../services/auditLog';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -125,7 +125,7 @@ export const trekOAuthProvider: OAuthServerProvider = {
|
|||||||
|
|
||||||
// Redirects browser to the SPA consent page with OAuth params forwarded.
|
// Redirects browser to the SPA consent page with OAuth params forwarded.
|
||||||
async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> {
|
async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> {
|
||||||
const mcpResource = `${(getAppUrl() || '').replace(/\/+$/, '')}/mcp`;
|
const mcpResource = `${getMcpSafeUrl().replace(/\/+$/, '')}/mcp`;
|
||||||
const resource = params.resource ? params.resource.href.replace(/\/+$/, '') : mcpResource;
|
const resource = params.resource ? params.resource.href.replace(/\/+$/, '') : mcpResource;
|
||||||
|
|
||||||
if (resource !== mcpResource) {
|
if (resource !== mcpResource) {
|
||||||
@@ -147,7 +147,8 @@ export const trekOAuthProvider: OAuthServerProvider = {
|
|||||||
if (params.state) qs.set('state', params.state);
|
if (params.state) qs.set('state', params.state);
|
||||||
if (params.resource) qs.set('resource', params.resource.href);
|
if (params.resource) qs.set('resource', params.resource.href);
|
||||||
|
|
||||||
res.redirect(302, `/oauth/consent?${qs.toString()}`);
|
const base = getMcpSafeUrl().replace(/\/+$/, '');
|
||||||
|
res.redirect(302, `${base}/oauth/consent?${qs.toString()}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Not called because skipLocalPkceValidation = true.
|
// Not called because skipLocalPkceValidation = true.
|
||||||
|
|||||||
@@ -148,11 +148,16 @@ router.post('/register', authLimiter, (req: Request, res: Response) => {
|
|||||||
res.status(201).json({ token: result.token, user: result.user });
|
res.status(201).json({ token: result.token, user: result.user });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/login', authLimiter, (req: Request, res: Response) => {
|
router.post('/login', authLimiter, async (req: Request, res: Response) => {
|
||||||
|
const started = Date.now();
|
||||||
const result = loginUser(req.body);
|
const result = loginUser(req.body);
|
||||||
if (result.auditAction) {
|
if (result.auditAction) {
|
||||||
writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req), details: result.auditDetails });
|
writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req), details: result.auditDetails });
|
||||||
}
|
}
|
||||||
|
const elapsed = Date.now() - started;
|
||||||
|
if (elapsed < LOGIN_MIN_LATENCY_MS) {
|
||||||
|
await new Promise((r) => setTimeout(r, LOGIN_MIN_LATENCY_MS - elapsed));
|
||||||
|
}
|
||||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
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 });
|
if (result.mfa_required) return res.json({ mfa_required: true, mfa_token: result.mfa_token });
|
||||||
setAuthCookie(res, result.token!, req);
|
setAuthCookie(res, result.token!, req);
|
||||||
@@ -166,9 +171,10 @@ router.post('/login', authLimiter, (req: Request, res: Response) => {
|
|||||||
// Generic OK response — identical regardless of email existence, to
|
// Generic OK response — identical regardless of email existence, to
|
||||||
// prevent enumeration via response body OR status code.
|
// prevent enumeration via response body OR status code.
|
||||||
const GENERIC_FORGOT_RESPONSE = { ok: true };
|
const GENERIC_FORGOT_RESPONSE = { ok: true };
|
||||||
// Minimum time we spend inside the forgot handler so a "no such user"
|
// Minimum time we spend inside the forgot/login handlers so a "no such
|
||||||
// path does not complete noticeably faster than a real reset.
|
// user" path does not complete noticeably faster than a real operation.
|
||||||
const FORGOT_MIN_LATENCY_MS = 350;
|
const FORGOT_MIN_LATENCY_MS = 350;
|
||||||
|
const LOGIN_MIN_LATENCY_MS = 350;
|
||||||
|
|
||||||
router.post('/forgot-password', forgotLimiter, async (req: Request, res: Response) => {
|
router.post('/forgot-password', forgotLimiter, async (req: Request, res: Response) => {
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import {
|
|||||||
touchLastLogin,
|
touchLastLogin,
|
||||||
generateToken,
|
generateToken,
|
||||||
frontendUrl,
|
frontendUrl,
|
||||||
getAppUrl,
|
|
||||||
} from '../services/oidcService';
|
} from '../services/oidcService';
|
||||||
|
import { getAppUrl } from '../services/notifications';
|
||||||
import { resolveAuthToggles } from '../services/authService';
|
import { resolveAuthToggles } from '../services/authService';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ import { DEMO_EMAIL_PRIMARY, isDemoEmail } from './demo';
|
|||||||
|
|
||||||
authenticator.options = { window: 1 };
|
authenticator.options = { window: 1 };
|
||||||
|
|
||||||
|
// Pre-computed bcrypt hash to equalise timing of "unknown email" and
|
||||||
|
// "OIDC-only account" branches with the real verification path (CWE-208).
|
||||||
|
// Cost factor 12 matches register/changePassword/resetPassword — must stay in sync.
|
||||||
|
const DUMMY_PASSWORD_HASH = bcrypt.hashSync('__trek_no_such_user__', 12);
|
||||||
|
|
||||||
const MFA_SETUP_TTL_MS = 15 * 60 * 1000;
|
const MFA_SETUP_TTL_MS = 15 * 60 * 1000;
|
||||||
const mfaSetupPending = new Map<number, { secret: string; exp: number }>();
|
const mfaSetupPending = new Map<number, { secret: string; exp: number }>();
|
||||||
const MFA_BACKUP_CODE_COUNT = 10;
|
const MFA_BACKUP_CODE_COUNT = 10;
|
||||||
@@ -437,14 +442,24 @@ export function loginUser(body: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const user = db.prepare('SELECT * FROM users WHERE LOWER(email) = LOWER(?)').get(email) as User | undefined;
|
const user = db.prepare('SELECT * FROM users WHERE LOWER(email) = LOWER(?)').get(email) as User | undefined;
|
||||||
|
|
||||||
|
// Always run bcrypt — even for unknown/OIDC-only users — so response time
|
||||||
|
// does not reveal whether the email exists in the database (CWE-203/208).
|
||||||
|
const hashToCheck = user?.password_hash ?? DUMMY_PASSWORD_HASH;
|
||||||
|
const validPassword = bcrypt.compareSync(password, hashToCheck);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return {
|
return {
|
||||||
error: 'Invalid email or password', status: 401,
|
error: 'Invalid email or password', status: 401,
|
||||||
auditUserId: null, auditAction: 'user.login_failed', auditDetails: { email, reason: 'unknown_email' },
|
auditUserId: null, auditAction: 'user.login_failed', auditDetails: { email, reason: 'unknown_email' },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (!user.password_hash) {
|
||||||
const validPassword = bcrypt.compareSync(password, user.password_hash!);
|
return {
|
||||||
|
error: 'Invalid email or password', status: 401,
|
||||||
|
auditUserId: Number(user.id), auditAction: 'user.login_failed', auditDetails: { email, reason: 'oidc_only' },
|
||||||
|
};
|
||||||
|
}
|
||||||
if (!validPassword) {
|
if (!validPassword) {
|
||||||
return {
|
return {
|
||||||
error: 'Invalid email or password', status: 401,
|
error: 'Invalid email or password', status: 401,
|
||||||
|
|||||||
@@ -46,13 +46,42 @@ function getSmtpConfig(): SmtpConfig | null {
|
|||||||
|
|
||||||
// Exported for use by notificationService
|
// Exported for use by notificationService
|
||||||
export function getAppUrl(): string {
|
export function getAppUrl(): string {
|
||||||
if (process.env.APP_URL) return process.env.APP_URL;
|
if (process.env.APP_URL) {
|
||||||
|
try {
|
||||||
|
const _ = new URL(process.env.APP_URL);
|
||||||
|
return process.env.APP_URL.replace(/\/+$/, '');
|
||||||
|
} catch (_ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
const origins = process.env.ALLOWED_ORIGINS;
|
const origins = process.env.ALLOWED_ORIGINS;
|
||||||
if (origins) {
|
if (origins) {
|
||||||
const first = origins.split(',')[0]?.trim();
|
const first = origins.split(',')[0]?.trim();
|
||||||
if (first) return first.replace(/\/+$/, '');
|
if (first) {
|
||||||
|
try {
|
||||||
|
const _ = new URL(first);
|
||||||
|
return first.replace(/\/+$/, '');
|
||||||
|
} catch (_ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const port = process.env.PORT || '3000';
|
const port = Number(process.env.PORT) || 3001;
|
||||||
|
return `http://localhost:${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a URL guaranteed to satisfy the MCP SDK's issuer requirements (HTTPS or localhost).
|
||||||
|
* Falls back to http://localhost:{PORT} when APP_URL/ALLOWED_ORIGINS use a non-HTTPS, non-localhost scheme
|
||||||
|
* that would cause checkIssuerUrl to throw "Issuer URL must be HTTPS". */
|
||||||
|
export function getMcpSafeUrl(): string {
|
||||||
|
const candidate = getAppUrl();
|
||||||
|
try {
|
||||||
|
const u = new URL(candidate);
|
||||||
|
if (u.protocol === 'https:' || u.hostname === 'localhost' || u.hostname === '127.0.0.1') {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// candidate was somehow invalid — fall through to localhost
|
||||||
|
}
|
||||||
|
const port = Number(process.env.PORT) || 3001;
|
||||||
return `http://localhost:${port}`;
|
return `http://localhost:${port}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,12 +316,12 @@ export function getEventText(lang: string, event: NotifEventType, params: Record
|
|||||||
|
|
||||||
// ── Email HTML builder ─────────────────────────────────────────────────────
|
// ── Email HTML builder ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function buildEmailHtml(subject: string, body: string, lang: string, navigateTarget?: string): string {
|
export function buildEmailHtml(subject: string, body: string, lang: string, navigateTarget?: string, rawBody = false): string {
|
||||||
const s = I18N[lang] || I18N.en;
|
const s = I18N[lang] || I18N.en;
|
||||||
const appUrl = getAppUrl();
|
const appUrl = getAppUrl();
|
||||||
const ctaHref = escapeHtml(navigateTarget ? `${appUrl}${navigateTarget}` : (appUrl || ''));
|
const ctaHref = escapeHtml(navigateTarget ? `${appUrl}${navigateTarget}` : (appUrl || ''));
|
||||||
const safeSubject = escapeHtml(subject);
|
const safeSubject = escapeHtml(subject);
|
||||||
const safeBody = escapeHtml(body);
|
const safeBody = rawBody ? body : escapeHtml(body);
|
||||||
|
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
@@ -367,7 +396,7 @@ function buildPasswordResetHtml(subject: string, strings: PasswordResetStrings,
|
|||||||
<p style="margin:0 0 10px 0; font-size:13px; color:#6B7280;">${safeExpiry}</p>
|
<p style="margin:0 0 10px 0; font-size:13px; color:#6B7280;">${safeExpiry}</p>
|
||||||
<p style="margin:0; font-size:13px; color:#6B7280;">${safeIgnore}</p>
|
<p style="margin:0; font-size:13px; color:#6B7280;">${safeIgnore}</p>
|
||||||
`;
|
`;
|
||||||
return buildEmailHtml(subject, block, lang);
|
return buildEmailHtml(subject, block, lang, undefined, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { ADDON_IDS } from '../addons';
|
|||||||
import { User } from '../types';
|
import { User } from '../types';
|
||||||
import { writeAudit, logWarn } from './auditLog';
|
import { writeAudit, logWarn } from './auditLog';
|
||||||
import { revokeUserSessionsForClient } from '../mcp/sessionManager';
|
import { revokeUserSessionsForClient } from '../mcp/sessionManager';
|
||||||
import { getAppUrl } from './oidcService';
|
import { getMcpSafeUrl } from './notifications';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Constants
|
// Constants
|
||||||
@@ -587,7 +587,7 @@ export function validateAuthorizeRequest(
|
|||||||
// bind the token to the MCP endpoint by default — previously this
|
// bind the token to the MCP endpoint by default — previously this
|
||||||
// left `audience = null`, and the audience-bind check on MCP requests
|
// left `audience = null`, and the audience-bind check on MCP requests
|
||||||
// then treated a null audience as "valid for any resource".
|
// then treated a null audience as "valid for any resource".
|
||||||
const mcpResource = `${(getAppUrl() || '').replace(/\/+$/, '')}/mcp`;
|
const mcpResource = `${getMcpSafeUrl().replace(/\/+$/, '')}/mcp`;
|
||||||
const resource = params.resource
|
const resource = params.resource
|
||||||
? params.resource.replace(/\/+$/, '')
|
? params.resource.replace(/\/+$/, '')
|
||||||
: mcpResource;
|
: mcpResource;
|
||||||
|
|||||||
@@ -194,14 +194,6 @@ export function generateToken(user: { id: number }): string {
|
|||||||
return jwt.sign({ id: user.id }, JWT_SECRET, { expiresIn: '24h', algorithm: 'HS256' });
|
return jwt.sign({ id: user.id }, JWT_SECRET, { expiresIn: '24h', algorithm: 'HS256' });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAppUrl(): string | null {
|
|
||||||
return (
|
|
||||||
process.env.APP_URL ||
|
|
||||||
(db.prepare("SELECT value FROM app_settings WHERE key = 'app_url'").get() as { value: string } | undefined)?.value ||
|
|
||||||
null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Token exchange with OIDC provider
|
// Token exchange with OIDC provider
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export function getSharedTripData(token: string): Record<string, any> | null {
|
|||||||
JOIN places p ON da.place_id = p.id
|
JOIN places p ON da.place_id = p.id
|
||||||
LEFT JOIN categories c ON p.category_id = c.id
|
LEFT JOIN categories c ON p.category_id = c.id
|
||||||
WHERE da.day_id IN (${ph})
|
WHERE da.day_id IN (${ph})
|
||||||
ORDER BY da.order_index ASC
|
ORDER BY da.order_index ASC, da.created_at ASC
|
||||||
`).all(...dayIds);
|
`).all(...dayIds);
|
||||||
|
|
||||||
const placeIds = [...new Set(allAssignments.map((a: any) => a.place_id))];
|
const placeIds = [...new Set(allAssignments.map((a: any) => a.place_id))];
|
||||||
@@ -137,7 +137,7 @@ export function getSharedTripData(token: string): Record<string, any> | null {
|
|||||||
}
|
}
|
||||||
assignments = byDay;
|
assignments = byDay;
|
||||||
|
|
||||||
const allNotes = db.prepare(`SELECT * FROM day_notes WHERE day_id IN (${ph}) ORDER BY sort_order ASC`).all(...dayIds);
|
const allNotes = db.prepare(`SELECT * FROM day_notes WHERE day_id IN (${ph}) ORDER BY sort_order ASC, created_at ASC`).all(...dayIds);
|
||||||
const notesByDay: Record<number, any[]> = {};
|
const notesByDay: Record<number, any[]> = {};
|
||||||
for (const n of allNotes as any[]) {
|
for (const n of allNotes as any[]) {
|
||||||
if (!notesByDay[n.day_id]) notesByDay[n.day_id] = [];
|
if (!notesByDay[n.day_id]) notesByDay[n.day_id] = [];
|
||||||
@@ -153,8 +153,24 @@ export function getSharedTripData(token: string): Record<string, any> | null {
|
|||||||
WHERE p.trip_id = ? ORDER BY p.created_at DESC
|
WHERE p.trip_id = ? ORDER BY p.created_at DESC
|
||||||
`).all(tripId);
|
`).all(tripId);
|
||||||
|
|
||||||
// Reservations
|
// Reservations — include per-day positions so the client can render the same order as the planner
|
||||||
const reservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ? ORDER BY reservation_time ASC').all(tripId);
|
const reservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ? ORDER BY reservation_time ASC').all(tripId) as any[];
|
||||||
|
|
||||||
|
const dayPositions = db.prepare(`
|
||||||
|
SELECT rdp.reservation_id, rdp.day_id, rdp.position
|
||||||
|
FROM reservation_day_positions rdp
|
||||||
|
JOIN reservations r ON rdp.reservation_id = r.id
|
||||||
|
WHERE r.trip_id = ?
|
||||||
|
`).all(tripId) as { reservation_id: number; day_id: number; position: number }[];
|
||||||
|
|
||||||
|
const posMap = new Map<number, Record<number, number>>();
|
||||||
|
for (const dp of dayPositions) {
|
||||||
|
if (!posMap.has(dp.reservation_id)) posMap.set(dp.reservation_id, {});
|
||||||
|
posMap.get(dp.reservation_id)![dp.day_id] = dp.position;
|
||||||
|
}
|
||||||
|
for (const r of reservations) {
|
||||||
|
r.day_positions = posMap.get(r.id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
// Accommodations
|
// Accommodations
|
||||||
const accommodations = db.prepare(`
|
const accommodations = db.prepare(`
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { listBudgetItems } from './budgetService';
|
|||||||
import { listItems as listPackingItems } from './packingService';
|
import { listItems as listPackingItems } from './packingService';
|
||||||
import { listReservations } from './reservationService';
|
import { listReservations } from './reservationService';
|
||||||
import { listNotes as listCollabNotes } from './collabService';
|
import { listNotes as listCollabNotes } from './collabService';
|
||||||
|
import { shiftOwnerEntriesForTripWindow } from './vacayService';
|
||||||
|
|
||||||
export const MS_PER_DAY = 86400000;
|
export const MS_PER_DAY = 86400000;
|
||||||
export const MAX_TRIP_DAYS = 365;
|
export const MAX_TRIP_DAYS = 365;
|
||||||
@@ -240,6 +241,9 @@ export function updateTrip(tripId: string | number, userId: number, data: Update
|
|||||||
WHERE id=?
|
WHERE id=?
|
||||||
`).run(newTitle, newDesc, newStart || null, newEnd || null, newCurrency, newArchived, newCover, newReminder, tripId);
|
`).run(newTitle, newDesc, newStart || null, newEnd || null, newCurrency, newArchived, newCover, newReminder, tripId);
|
||||||
|
|
||||||
|
if (trip.start_date && trip.end_date && newStart && newStart !== trip.start_date)
|
||||||
|
shiftOwnerEntriesForTripWindow(trip.user_id, trip.start_date, trip.end_date, newStart);
|
||||||
|
|
||||||
const dayCount = data.day_count ? Math.min(Math.max(Number(data.day_count) || 7, 1), MAX_TRIP_DAYS) : undefined;
|
const dayCount = data.day_count ? Math.min(Math.max(Number(data.day_count) || 7, 1), MAX_TRIP_DAYS) : undefined;
|
||||||
if (newStart !== trip.start_date || newEnd !== trip.end_date || dayCount)
|
if (newStart !== trip.start_date || newEnd !== trip.end_date || dayCount)
|
||||||
generateDays(tripId, newStart || null, newEnd || null, undefined, dayCount);
|
generateDays(tripId, newStart || null, newEnd || null, undefined, dayCount);
|
||||||
|
|||||||
@@ -101,6 +101,29 @@ export function getActivePlanId(userId: number): number {
|
|||||||
return getActivePlan(userId).id;
|
return getActivePlan(userId).id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function shiftOwnerEntriesForTripWindow(
|
||||||
|
ownerId: number,
|
||||||
|
oldStart: string,
|
||||||
|
oldEnd: string,
|
||||||
|
newStart: string
|
||||||
|
): void {
|
||||||
|
const row = db.prepare(
|
||||||
|
'SELECT CAST(julianday(?) - julianday(?) AS INTEGER) AS days'
|
||||||
|
).get(newStart, oldStart) as { days: number } | undefined;
|
||||||
|
const offset = row?.days ?? 0;
|
||||||
|
if (offset === 0) return;
|
||||||
|
|
||||||
|
const plan = getOwnPlan(ownerId);
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE OR IGNORE vacay_entries
|
||||||
|
SET date = date(date, ? || ' days')
|
||||||
|
WHERE plan_id = ?
|
||||||
|
AND user_id = ?
|
||||||
|
AND date BETWEEN ? AND ?`
|
||||||
|
).run(`${offset >= 0 ? '+' : ''}${offset}`, plan.id, ownerId, oldStart, oldEnd);
|
||||||
|
}
|
||||||
|
|
||||||
export function getPlanUsers(planId: number): VacayUser[] {
|
export function getPlanUsers(planId: number): VacayUser[] {
|
||||||
const plan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan | undefined;
|
const plan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan | undefined;
|
||||||
if (!plan) return [];
|
if (!plan) return [];
|
||||||
|
|||||||
@@ -48,7 +48,10 @@ vi.mock('../../src/services/adminService', async (importOriginal) => {
|
|||||||
return { ...actual, isAddonEnabled: isAddonEnabledMock };
|
return { ...actual, isAddonEnabled: isAddonEnabledMock };
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock('../../src/services/oidcService', () => ({ getAppUrl: () => 'https://trek.example.com' }));
|
vi.mock('../../src/services/notifications', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('../../src/services/notifications')>();
|
||||||
|
return { ...actual, getMcpSafeUrl: () => 'https://trek.example.com' };
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||||
vi.mock('../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn(), revokeUserSessionsForClient: vi.fn(), sessions: new Map() }));
|
vi.mock('../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn(), revokeUserSessionsForClient: vi.fn(), sessions: new Map() }));
|
||||||
|
|||||||
@@ -285,3 +285,61 @@ describe('Shared trip — day assignments and notes', () => {
|
|||||||
expect(res.body.assignments).toEqual({});
|
expect(res.body.assignments).toEqual({});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Shared trip — ordering parity (issue #981)', () => {
|
||||||
|
it('SHARE-014 — assignments with same order_index are ordered by created_at (tiebreaker)', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const day = createDay(testDb, trip.id, { date: '2025-09-01' });
|
||||||
|
const place1 = createPlace(testDb, trip.id, { name: 'First Created' });
|
||||||
|
const place2 = createPlace(testDb, trip.id, { name: 'Second Created' });
|
||||||
|
|
||||||
|
// Both with order_index = 0 (schema default) but different created_at
|
||||||
|
testDb.prepare(
|
||||||
|
"INSERT INTO day_assignments (day_id, place_id, order_index, created_at) VALUES (?, ?, 0, '2025-01-01T10:00:00')"
|
||||||
|
).run(day.id, place1.id);
|
||||||
|
testDb.prepare(
|
||||||
|
"INSERT INTO day_assignments (day_id, place_id, order_index, created_at) VALUES (?, ?, 0, '2025-01-01T11:00:00')"
|
||||||
|
).run(day.id, place2.id);
|
||||||
|
|
||||||
|
const { body: { token } } = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/share-link`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
const res = await request(app).get(`/api/shared/${token}`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const assignments = res.body.assignments[day.id];
|
||||||
|
expect(assignments).toHaveLength(2);
|
||||||
|
expect(assignments[0].place.name).toBe('First Created');
|
||||||
|
expect(assignments[1].place.name).toBe('Second Created');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SHARE-015 — reservations include day_positions map from reservation_day_positions table', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const day = createDay(testDb, trip.id, { date: '2025-09-01' });
|
||||||
|
|
||||||
|
const res1 = testDb.prepare(
|
||||||
|
"INSERT INTO reservations (trip_id, title, type, day_id, reservation_time) VALUES (?, ?, ?, ?, ?)"
|
||||||
|
).run(trip.id, 'Test Flight', 'flight', day.id, '2025-09-01T09:00:00');
|
||||||
|
const reservationId = Number(res1.lastInsertRowid);
|
||||||
|
|
||||||
|
// Insert a per-day position
|
||||||
|
testDb.prepare(
|
||||||
|
'INSERT INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)'
|
||||||
|
).run(reservationId, day.id, 1.5);
|
||||||
|
|
||||||
|
const { body: { token } } = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/share-link`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ share_bookings: true });
|
||||||
|
|
||||||
|
const shareRes = await request(app).get(`/api/shared/${token}`);
|
||||||
|
expect(shareRes.status).toBe(200);
|
||||||
|
const reservation = shareRes.body.reservations.find((r: any) => r.id === reservationId);
|
||||||
|
expect(reservation).toBeDefined();
|
||||||
|
expect(reservation.day_positions).toBeDefined();
|
||||||
|
expect(reservation.day_positions[day.id]).toBe(1.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -184,6 +184,88 @@ describe('Tool: update_trip', () => {
|
|||||||
expect(result.isError).toBe(true);
|
expect(result.isError).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shifts owner vacay entries when update_trip moves trip window by fixed offset', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id, { start_date: '2026-08-01', end_date: '2026-08-09' });
|
||||||
|
|
||||||
|
// Materialize active vacay plan for owner and entries in old trip window.
|
||||||
|
const planRes = testDb.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(user.id);
|
||||||
|
const planId = Number(planRes.lastInsertRowid);
|
||||||
|
testDb.prepare('INSERT INTO vacay_years (plan_id, year) VALUES (?, ?)').run(planId, 2026);
|
||||||
|
testDb.prepare(
|
||||||
|
'INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, 0)'
|
||||||
|
).run(user.id, planId, 2026);
|
||||||
|
for (const d of ['2026-08-03', '2026-08-04', '2026-08-05', '2026-08-06', '2026-08-07']) {
|
||||||
|
testDb.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(planId, user.id, d, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
await withHarness(user.id, async (h) => {
|
||||||
|
const result = await h.client.callTool({
|
||||||
|
name: 'update_trip',
|
||||||
|
arguments: { tripId: trip.id, start_date: '2026-08-08', end_date: '2026-08-16' },
|
||||||
|
});
|
||||||
|
const data = parseToolResult(result) as any;
|
||||||
|
expect(data.trip.start_date).toBe('2026-08-08');
|
||||||
|
expect(data.trip.end_date).toBe('2026-08-16');
|
||||||
|
});
|
||||||
|
|
||||||
|
const oldWindow = testDb.prepare(
|
||||||
|
"SELECT date FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date BETWEEN '2026-08-01' AND '2026-08-09'"
|
||||||
|
).all(planId, user.id) as { date: string }[];
|
||||||
|
expect(oldWindow).toHaveLength(0);
|
||||||
|
|
||||||
|
const shifted = testDb.prepare(
|
||||||
|
"SELECT date FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date BETWEEN '2026-08-08' AND '2026-08-16' ORDER BY date"
|
||||||
|
).all(planId, user.id) as { date: string }[];
|
||||||
|
expect(shifted.map(r => r.date)).toEqual([
|
||||||
|
'2026-08-10',
|
||||||
|
'2026-08-11',
|
||||||
|
'2026-08-12',
|
||||||
|
'2026-08-13',
|
||||||
|
'2026-08-14',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shifts entries from the owners own plan even if another vacay plan is active', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const { user: otherOwner } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id, { start_date: '2026-09-01', end_date: '2026-09-07' });
|
||||||
|
|
||||||
|
// Own plan with entries that should be shifted.
|
||||||
|
const ownPlanRes = testDb.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(user.id);
|
||||||
|
const ownPlanId = Number(ownPlanRes.lastInsertRowid);
|
||||||
|
testDb.prepare('INSERT INTO vacay_years (plan_id, year) VALUES (?, ?)').run(ownPlanId, 2026);
|
||||||
|
testDb.prepare(
|
||||||
|
'INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, 0)'
|
||||||
|
).run(user.id, ownPlanId, 2026);
|
||||||
|
for (const d of ['2026-09-02', '2026-09-03']) {
|
||||||
|
testDb.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(ownPlanId, user.id, d, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Different accepted plan becomes "active" for the owner.
|
||||||
|
const foreignPlanRes = testDb.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(otherOwner.id);
|
||||||
|
const foreignPlanId = Number(foreignPlanRes.lastInsertRowid);
|
||||||
|
testDb.prepare('INSERT INTO vacay_plan_members (plan_id, user_id, status) VALUES (?, ?, ?)').run(foreignPlanId, user.id, 'accepted');
|
||||||
|
|
||||||
|
await withHarness(user.id, async (h) => {
|
||||||
|
const result = await h.client.callTool({
|
||||||
|
name: 'update_trip',
|
||||||
|
arguments: { tripId: trip.id, start_date: '2026-09-08', end_date: '2026-09-14' },
|
||||||
|
});
|
||||||
|
expect(result.isError).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
const oldWindow = testDb.prepare(
|
||||||
|
"SELECT date FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date BETWEEN '2026-09-01' AND '2026-09-07' ORDER BY date"
|
||||||
|
).all(ownPlanId, user.id) as { date: string }[];
|
||||||
|
expect(oldWindow).toHaveLength(0);
|
||||||
|
|
||||||
|
const shifted = testDb.prepare(
|
||||||
|
"SELECT date FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date BETWEEN '2026-09-08' AND '2026-09-14' ORDER BY date"
|
||||||
|
).all(ownPlanId, user.id) as { date: string }[];
|
||||||
|
expect(shifted.map(r => r.date)).toEqual(['2026-09-09', '2026-09-10']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ Each entry corresponds to a day in your journey. The entry editor provides:
|
|||||||
|
|
||||||
- **Weather** — choose one of six values: Sunny, Partly cloudy, Cloudy, Rainy, Stormy, Cold.
|
- **Weather** — choose one of six values: Sunny, Partly cloudy, Cloudy, Rainy, Stormy, Cold.
|
||||||
- **Photos** — attach photos to the entry. The first photo becomes the card thumbnail in list views.
|
- **Photos** — attach photos to the entry. The first photo becomes the card thumbnail in list views.
|
||||||
|
> **Note on HEIC files:** HEIC is an Apple-only format that many browsers and platforms do not recognise as an image. To ensure broad compatibility, HEIC/HEIF files are automatically converted to JPEG before upload. This conversion may result in the loss of embedded metadata (EXIF data such as GPS coordinates, camera information, etc.).
|
||||||
- **Pros / Cons** — optional verdict cards. Add items to a **Pros** list (thumbs-up) or a **Cons** list (thumbs-down) to summarise what you loved or what could have been better. These are stored in the `pros_cons.pros` and `pros_cons.cons` arrays on the entry.
|
- **Pros / Cons** — optional verdict cards. Add items to a **Pros** list (thumbs-up) or a **Cons** list (thumbs-down) to summarise what you loved or what could have been better. These are stored in the `pros_cons.pros` and `pros_cons.cons` arrays on the entry.
|
||||||
- **Tags** — free-form labels (e.g. "hidden gem", "best meal").
|
- **Tags** — free-form labels (e.g. "hidden gem", "best meal").
|
||||||
- **Location** — pin the entry to a map location.
|
- **Location** — pin the entry to a map location.
|
||||||
|
|||||||
Reference in New Issue
Block a user