Compare commits

..

11 Commits

Author SHA1 Message Date
jubnl a93ae2ffed chore: add build-from-sources script 2026-05-06 15:18:28 +02:00
jubnl cf7a1bea4f chore: remove committed build artifacts from server/public
Dockerfile and Proxmox community script both rebuild client/dist and copy
it into server/public at build time — committed artifacts were never used.
Replace with .gitkeep and add server/public/* to .gitignore.
2026-05-06 15:12:13 +02:00
jubnl 69141fcacc fix(pwa): unregister SW before proxy-reauth reload so Pangolin can challenge
WorkBox's NavigationRoute served the cached SPA shell on window.location.reload(),
meaning Pangolin/CF Access never saw the navigation and the app was left stuck
showing stale offline data. Unregistering the SW first lets the navigation reach
the network so the upstream proxy can run its auth flow.

Also rebuilds server/public with corrected sw.js (health excluded from
NetworkFirst, /oauth/ and /.well-known/ added to NavigationRoute denylist).
2026-05-06 15:08:20 +02:00
jubnl 0909abfa60 fix(budget): expose toolbar on mobile so users can add budget categories 2026-05-06 14:21:32 +02:00
jubnl a0c10e38f7 fix(files): add bottom-nav padding to files tab wrapper on mobile 2026-05-06 14:02:40 +02:00
jubnl 3ee4da9775 fix(pwa): detect upstream proxy auth challenges and recover gracefully
Behind Cloudflare Zero Trust or Pangolin, cross-origin auth redirects on
/api/* calls surface as CORS errors (error.response === undefined) that
the existing 401 interceptor never catches, leaving the PWA stuck with
network-error toasts instead of re-authenticating.

New connectivity module probes /api/health every 30s using fetch with
cache:no-store and inspects Content-Type to reliably detect whether the
server is reachable vs intercepted by an upstream proxy.

axios interceptor changes:
- On !error.response + navigator.onLine: run probeNow(); if the health
  probe also fails (proxy is intercepting all requests), trigger a guarded
  window.location.reload() so the edge proxy can intercept the top-level
  navigation and run its auth flow (covers CF Access and Pangolin 302 mode)
- On error.response status 401 with text/html body: same reload path,
  covering Pangolin header-auth extended compatibility mode which returns
  401+HTML instead of a 302 redirect. TREK own 401s are always JSON so
  there is no collision with the existing AUTH_REQUIRED branch.
- sessionStorage flag prevents reload loops; cleared on any successful
  response so the guard resets after re-auth.

/api/health excluded from SW NetworkFirst cache (vite.config.js regex)
and Cache-Control: no-store added server-side so probes always hit the
network and cannot be served stale from the 24h api-data cache.

LoginPage caches last-known appConfig in localStorage so the SSO button
renders in OIDC+UN/PW dual mode even when the config fetch is intercepted
by the proxy. Auto-redirect to IdP skipped when config comes from cache
to avoid redirect loops while the proxy is challenging.

Fixes discussion #836.
2026-05-06 12:16:08 +02:00
jubnl 48c0f97ab9 docs(mcp): document Cloudflare bot detection blocking ChatGPT MCP requests
Add Cloudflare WAF note to MCP-Setup and a full troubleshooting entry covering
root cause (IP reputation + UA heuristics), free-plan limitation (disable Bot
Fight Mode entirely, with explicit warning), and paid-plan WAF skip rule with
the full expression syntax and path table for all MCP/OAuth/.well-known routes.
2026-05-06 11:32:49 +02:00
jubnl 7b2928a007 fix(ntfy): encode non-Latin-1 header values with RFC 2047 to prevent ByteString crash
Todo/trip names containing chars like → or € (and non-Latin-1 locale templates
for Czech, Chinese, Russian, etc.) caused the Fetch API to throw when setting
the ntfy Title header. Apply RFC 2047 base64 encoded-word encoding for any
header value containing chars above U+00FF; ntfy decodes this automatically.
2026-05-06 11:18:13 +02:00
jubnl f089c557e7 fix(mcp): fix OAuth popup blank page — SW denylist and COOP header
Service worker was intercepting /oauth/authorize navigate requests
(not in denylist), serving index.html, and React Router's catch-all
redirected to / instead of the SDK authorize handler.

Helmet's default COOP: same-origin isolated the /oauth/consent popup
from its cross-origin opener, making window.opener null and breaking
the popup-based OAuth completion signal for ChatGPT and similar clients.
2026-05-06 11:05:06 +02:00
jubnl 69432443b7 fix(mcp): serve flat /.well-known/oauth-protected-resource for ChatGPT reconnect
Clients such as ChatGPT probe the flat well-known URL on every fresh discovery
cycle (i.e. after a full disconnect/reconnect where cached OAuth state is cleared).
The SDK's mcpAuthMetadataRouter only serves the path-based form
/.well-known/oauth-protected-resource/mcp, so the flat probe returned 404.

Without the resource metadata, ChatGPT fell back to the issuer URL as the
resource parameter (https://…/ instead of https://…/mcp). The authorize handler
then rejected it with invalid_target and redirected back to ChatGPT's callback
with an error — showing the user the TREK home page instead of the consent form.

Add an explicit GET handler for the flat URL that returns the same protected
resource metadata, so the resource URI is discovered correctly on the first probe.
2026-05-06 10:35:23 +02:00
jubnl cbaf744f0e fix(mcp): MCP RFC compliant for more strict clients 2026-05-06 09:59:43 +02:00
41 changed files with 3084 additions and 1273 deletions
-37
View File
@@ -1,37 +0,0 @@
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
+4 -7
View File
@@ -1,5 +1,5 @@
# Stage 1: Build React client
FROM node:24-alpine AS client-builder
FROM node:22-alpine AS client-builder
WORKDIR /app/client
COPY client/package*.json ./
RUN npm ci
@@ -7,7 +7,7 @@ COPY client/ ./
RUN npm run build
# Stage 2: Production server
FROM node:24-alpine
FROM node:22-alpine
WORKDIR /app
@@ -15,16 +15,13 @@ WORKDIR /app
COPY server/package*.json ./
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
npm ci --production && \
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
apk del python3 make g++
COPY server/ ./
COPY --from=client-builder /app/client/dist ./public
COPY --from=client-builder /app/client/public/fonts ./public/fonts
RUN rm -f package-lock.json && \
mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
RUN 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 && \
chown -R node:node /app
+1 -1
View File
@@ -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:
1. **Do not** open a public issue
2. Emails: **mauriceboe@icloud.com**, **trek-security@jubnl.ch**
2. Email: **mauriceboe@icloud.com**
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.
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 3.0.18
version: 3.0.15
description: Minimal Helm chart for TREK app
appVersion: "3.0.18"
appVersion: "3.0.15"
+1739 -358
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "trek-client",
"version": "3.0.18",
"version": "3.0.15",
"private": true,
"type": "module",
"scripts": {
+113 -15
View File
@@ -23,11 +23,6 @@ import { useCanDo } from '../../store/permissionsStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
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 { useDayNotes } from '../../hooks/useDayNotes'
import Tooltip from '../shared/Tooltip'
@@ -367,6 +362,26 @@ 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
const getSpanLabel = (r: Reservation, phase: string): string | null => {
if (phase === 'single') return null
@@ -391,8 +406,27 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
return { day_id: startId, end_day_id: targetDayId }
}
const getTransportForDay = (dayId: number) =>
_getTransportForDay({ reservations, dayId, dayAssignmentIds: (assignments[String(dayId)] || []).map(a => a.id), days })
const getTransportForDay = (dayId: number) => {
const dayAssignmentIds = (assignments[String(dayId)] || []).map(a => a.id)
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
const getActiveRentalsForDay = (dayId: number) => {
@@ -412,6 +446,20 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const getDayAssignments = (dayId) =>
(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
const computeTransportPosition = (r, da) => {
const minutes = parseTimeToMinutes(r.reservation_time) ?? 0
@@ -453,14 +501,64 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
reservationsApi.updatePositions(tripId, positions).catch(() => {})
}
const getMergedItems = (dayId: number): MergedItem[] =>
_getMergedItems({
dayAssignments: getDayAssignments(dayId),
dayNotes: (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order),
dayTransports: getTransportForDay(dayId),
dayId,
getDisplayTime: getDisplayTimeForDay,
})
const getMergedItems = (dayId) => {
const da = getDayAssignments(dayId)
const dn = (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order)
const transport = getTransportForDay(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 })),
...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)
// eslint-disable-next-line react-hooks/exhaustive-deps
+8 -10
View File
@@ -11,8 +11,8 @@ import { createElement } from 'react'
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 { 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 }
function createMarkerIcon(place: any) {
@@ -184,16 +184,14 @@ export default function SharedTripPage() {
{sortedDays.map((day: any, di: number) => {
const da = assignments[String(day.id)] || []
const notes = (dayNotes[String(day.id)] || [])
const dayAssignmentIds: number[] = da.map((a: any) => a.id)
const dayTransport = getTransportForDay({ reservations: reservations || [], dayId: day.id, dayAssignmentIds, days: sortedDays })
const dayTransport = (reservations || []).filter((r: any) => TRANSPORT_TYPES.has(r.type) && r.reservation_time?.split('T')[0] === day.date)
const dayAccs = (accommodations || []).filter((a: any) => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, sortedDays))
const merged = getMergedItems({
dayAssignments: da,
dayNotes: notes,
dayTransports: dayTransport,
dayId: day.id,
})
const merged = [
...da.map((a: any) => ({ type: 'place', k: a.order_index, data: a })),
...notes.map((n: any) => ({ type: 'note', k: n.sort_order ?? 0, data: n })),
...dayTransport.map((r: any) => ({ type: 'transport', k: r.day_plan_position ?? 999, data: r })),
].sort((a, b) => a.k - b.k)
return (
<div key={day.id} style={{ background: 'var(--bg-card, white)', borderRadius: 14, overflow: 'hidden', border: '1px solid var(--border-faint, #e5e7eb)' }}>
@@ -214,7 +212,7 @@ export default function SharedTripPage() {
{selectedDay === day.id && merged.length > 0 && (
<div style={{ padding: '0 16px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
{merged.map((item: any) => {
{merged.map((item: any, idx: number) => {
if (item.type === 'transport') {
const r = item.data
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
-1
View File
@@ -175,7 +175,6 @@ export interface Reservation {
accommodation_start_day_id?: number | null
accommodation_end_day_id?: number | null
day_plan_position?: number | null
day_positions?: Record<number, number> | null
metadata?: Record<string, string> | string | null
needs_review?: number
endpoints?: ReservationEndpoint[]
-127
View File
@@ -1,127 +0,0 @@
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'])
})
})
-136
View File
@@ -1,136 +0,0 @@
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)
}
+1107 -78
View File
File diff suppressed because it is too large Load Diff
+3 -5
View File
@@ -1,6 +1,6 @@
{
"name": "trek-server",
"version": "3.0.18",
"version": "3.0.15",
"main": "src/index.ts",
"scripts": {
"start": "node --import tsx src/index.ts",
@@ -40,10 +40,8 @@
"zod": "^4.3.6"
},
"overrides": {
"hono": "^4.12.16",
"@hono/node-server": "^1.19.13",
"picomatch": "^4.0.4",
"ip-address": "^10.1.1"
"hono": "^4.12.12",
"@hono/node-server": "^1.19.13"
},
"devDependencies": {
"@types/archiver": "^7.0.0",
+9 -6
View File
@@ -50,11 +50,11 @@ import { getCollabFeatures } from './services/adminService';
import { isAddonEnabled } from './services/adminService';
import { ADDON_IDS } from './addons';
import { ALL_SCOPES } from './mcp/scopes';
import { getAppUrl } from './services/oidcService';
import { mcpAuthMetadataRouter } from '@modelcontextprotocol/sdk/server/auth/router';
import { authorizationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/authorize';
import { clientRegistrationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/register';
import type { OAuthMetadata } from '@modelcontextprotocol/sdk/shared/auth';
import { getMcpSafeUrl } from './services/notifications';
export function createApp(): express.Application {
const app = express();
@@ -388,7 +388,7 @@ export function createApp(): express.Application {
function getOAuthMetadata(): OAuthMetadata {
if (_oauthMetadata) return _oauthMetadata;
const base = getMcpSafeUrl().replace(/\/+$/, '');
const base = (getAppUrl() || 'http://localhost:3001').replace(/\/+$/, '');
_oauthMetadata = {
issuer: base,
authorization_endpoint: `${base}/oauth/authorize`,
@@ -416,11 +416,14 @@ export function createApp(): express.Application {
return _sdkMetaRouter;
}
// Only invoke the SDK metadata router for /.well-known/* paths.
// Calling getMetaRouter() on every request triggers lazy init (new URL(...)) which
// throws "Invalid URL" when APP_URL lacks a protocol — breaking all page loads.
// Path-aware gate: only /.well-known/* returns 404 when disabled; other paths pass through
// so static files and SPA routes are unaffected when MCP is off.
app.use((req: Request, res: Response, next: NextFunction) => {
if (req.path.startsWith('/.well-known/') && !isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
const isMetadataPath =
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);
});
+10 -31
View File
@@ -19,7 +19,6 @@ const tmpDir = path.join(__dirname, '../data/tmp');
const app = createApp();
import * as scheduler from './scheduler';
import { getAppUrl, getMcpSafeUrl } from './services/notifications';
const PORT = Number(process.env.PORT) || 3001;
const HOST = process.env.HOST;
@@ -30,42 +29,22 @@ const onListen = () => {
const LOG_LVL = (process.env.LOG_LEVEL || 'info').toLowerCase();
const tz = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
const origins = process.env.ALLOWED_ORIGINS || '(same-origin)';
const appUrl = getAppUrl();
const resolvedAppUrl = getMcpSafeUrl();
const banner = [
'──────────────────────────────────────',
' TREK API started',
` Version ${APP_VERSION}`,
...(HOST ? [` Host: ${HOST}`] : []),
` Container Port: ${PORT}`,
` App URL: ${appUrl}`,
` Environment: ${process.env.NODE_ENV?.toLowerCase() || 'development'}`,
` Timezone: ${tz}`,
` Origins: ${origins}`,
` Log level: ${LOG_LVL}`,
` Log file: /app/data/logs/trek.log`,
` PID: ${process.pid}`,
` User: uid=${process.getuid?.()} gid=${process.getgid?.()}`,
` Version ${APP_VERSION}`,
...(HOST ? [` Host: ${HOST}`] : []),
` Port: ${PORT}`,
` Environment: ${process.env.NODE_ENV?.toLowerCase() || 'development'}`,
` Timezone: ${tz}`,
` Origins: ${origins}`,
` Log level: ${LOG_LVL}`,
` Log file: /app/data/logs/trek.log`,
` PID: ${process.pid}`,
` User: uid=${process.getuid?.()} gid=${process.getgid?.()}`,
'──────────────────────────────────────',
];
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' && process.env.NODE_ENV?.toLowerCase() === 'production') {
sLogWarn('SECURITY WARNING: DEMO_MODE is enabled in production!');
+3 -3
View File
@@ -11,7 +11,7 @@ import { registerResources } from './resources';
import { registerTools } from './tools';
import { McpSession, sessions, revokeUserSessions, revokeUserSessionsForClient } from './sessionManager';
import { writeAudit, getClientIp } from '../services/auditLog';
import { getMcpSafeUrl } from '../services/notifications';
import { getAppUrl } from '../services/oidcService';
export { revokeUserSessions, revokeUserSessionsForClient };
@@ -153,7 +153,7 @@ const sessionSweepInterval = setInterval(() => {
sessionSweepInterval.unref();
function setAuthChallenge(res: Response, error = 'invalid_token'): void {
const base = (getMcpSafeUrl() || '').replace(/\/+$/, '');
const base = (getAppUrl() || '').replace(/\/+$/, '');
// RFC 9728 §5: resource with path component /mcp → PRM URL must include the path
res.set('WWW-Authenticate',
`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;
// RFC 8707: audience must always match this resource endpoint.
// Pre-audit tokens with audience=null are revoked by the SEC-H6 migration.
const expected = `${(getMcpSafeUrl() || '').replace(/\/+$/, '')}/mcp`;
const expected = `${(getAppUrl() || '').replace(/\/+$/, '')}/mcp`;
if (result.audience !== expected) return null;
return { user: result.user, scopes: result.scopes, clientId: result.clientId, isStaticToken: false };
}
+2 -2
View File
@@ -16,7 +16,7 @@ import {
getUserByAccessToken,
} from '../services/oauthService';
import { ALL_SCOPES } from './scopes';
import { getMcpSafeUrl } from '../services/notifications';
import { getAppUrl } from '../services/oidcService';
import { writeAudit } from '../services/auditLog';
// ---------------------------------------------------------------------------
@@ -125,7 +125,7 @@ export const trekOAuthProvider: OAuthServerProvider = {
// Redirects browser to the SPA consent page with OAuth params forwarded.
async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> {
const mcpResource = `${getMcpSafeUrl().replace(/\/+$/, '')}/mcp`;
const mcpResource = `${(getAppUrl() || '').replace(/\/+$/, '')}/mcp`;
const resource = params.resource ? params.resource.href.replace(/\/+$/, '') : mcpResource;
if (resource !== mcpResource) {
+3 -9
View File
@@ -148,16 +148,11 @@ router.post('/register', authLimiter, (req: Request, res: Response) => {
res.status(201).json({ token: result.token, user: result.user });
});
router.post('/login', authLimiter, async (req: Request, res: Response) => {
const started = Date.now();
router.post('/login', authLimiter, (req: Request, res: Response) => {
const result = loginUser(req.body);
if (result.auditAction) {
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.mfa_required) return res.json({ mfa_required: true, mfa_token: result.mfa_token });
setAuthCookie(res, result.token!, req);
@@ -171,10 +166,9 @@ router.post('/login', authLimiter, async (req: Request, res: Response) => {
// Generic OK response — identical regardless of email existence, to
// prevent enumeration via response body OR status code.
const GENERIC_FORGOT_RESPONSE = { ok: true };
// Minimum time we spend inside the forgot/login handlers so a "no such
// user" path does not complete noticeably faster than a real operation.
// Minimum time we spend inside the forgot handler so a "no such user"
// path does not complete noticeably faster than a real reset.
const FORGOT_MIN_LATENCY_MS = 350;
const LOGIN_MIN_LATENCY_MS = 350;
router.post('/forgot-password', forgotLimiter, async (req: Request, res: Response) => {
const started = Date.now();
+1 -1
View File
@@ -14,8 +14,8 @@ import {
touchLastLogin,
generateToken,
frontendUrl,
getAppUrl,
} from '../services/oidcService';
import { getAppUrl } from '../services/notifications';
import { resolveAuthToggles } from '../services/authService';
const router = express.Router();
+2 -17
View File
@@ -26,11 +26,6 @@ import { DEMO_EMAIL_PRIMARY, isDemoEmail } from './demo';
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 mfaSetupPending = new Map<number, { secret: string; exp: number }>();
const MFA_BACKUP_CODE_COUNT = 10;
@@ -442,24 +437,14 @@ export function loginUser(body: {
}
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) {
return {
error: 'Invalid email or password', status: 401,
auditUserId: null, auditAction: 'user.login_failed', auditDetails: { email, reason: 'unknown_email' },
};
}
if (!user.password_hash) {
return {
error: 'Invalid email or password', status: 401,
auditUserId: Number(user.id), auditAction: 'user.login_failed', auditDetails: { email, reason: 'oidc_only' },
};
}
const validPassword = bcrypt.compareSync(password, user.password_hash!);
if (!validPassword) {
return {
error: 'Invalid email or password', status: 401,
+3 -32
View File
@@ -46,42 +46,13 @@ function getSmtpConfig(): SmtpConfig | null {
// Exported for use by notificationService
export function getAppUrl(): string {
if (process.env.APP_URL) {
try {
const _ = new URL(process.env.APP_URL);
return process.env.APP_URL.replace(/\/+$/, '');
} catch (_ignored) {
}
}
if (process.env.APP_URL) return process.env.APP_URL;
const origins = process.env.ALLOWED_ORIGINS;
if (origins) {
const first = origins.split(',')[0]?.trim();
if (first) {
try {
const _ = new URL(first);
return first.replace(/\/+$/, '');
} catch (_ignored) {
}
}
if (first) return first.replace(/\/+$/, '');
}
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;
const port = process.env.PORT || '3000';
return `http://localhost:${port}`;
}
+2 -2
View File
@@ -6,7 +6,7 @@ import { ADDON_IDS } from '../addons';
import { User } from '../types';
import { writeAudit, logWarn } from './auditLog';
import { revokeUserSessionsForClient } from '../mcp/sessionManager';
import { getMcpSafeUrl } from './notifications';
import { getAppUrl } from './oidcService';
// ---------------------------------------------------------------------------
// Constants
@@ -587,7 +587,7 @@ export function validateAuthorizeRequest(
// 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 = `${getMcpSafeUrl().replace(/\/+$/, '')}/mcp`;
const mcpResource = `${(getAppUrl() || '').replace(/\/+$/, '')}/mcp`;
const resource = params.resource
? params.resource.replace(/\/+$/, '')
: mcpResource;
+8
View File
@@ -194,6 +194,14 @@ export function generateToken(user: { id: number }): string {
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
// ---------------------------------------------------------------------------
+4 -20
View File
@@ -114,7 +114,7 @@ export function getSharedTripData(token: string): Record<string, any> | null {
JOIN places p ON da.place_id = p.id
LEFT JOIN categories c ON p.category_id = c.id
WHERE da.day_id IN (${ph})
ORDER BY da.order_index ASC, da.created_at ASC
ORDER BY da.order_index ASC
`).all(...dayIds);
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;
const allNotes = db.prepare(`SELECT * FROM day_notes WHERE day_id IN (${ph}) ORDER BY sort_order ASC, created_at ASC`).all(...dayIds);
const allNotes = db.prepare(`SELECT * FROM day_notes WHERE day_id IN (${ph}) ORDER BY sort_order ASC`).all(...dayIds);
const notesByDay: Record<number, any[]> = {};
for (const n of allNotes as any[]) {
if (!notesByDay[n.day_id]) notesByDay[n.day_id] = [];
@@ -153,24 +153,8 @@ export function getSharedTripData(token: string): Record<string, any> | null {
WHERE p.trip_id = ? ORDER BY p.created_at DESC
`).all(tripId);
// 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) 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;
}
// Reservations
const reservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ? ORDER BY reservation_time ASC').all(tripId);
// Accommodations
const accommodations = db.prepare(`
-4
View File
@@ -7,7 +7,6 @@ import { listBudgetItems } from './budgetService';
import { listItems as listPackingItems } from './packingService';
import { listReservations } from './reservationService';
import { listNotes as listCollabNotes } from './collabService';
import { shiftOwnerEntriesForTripWindow } from './vacayService';
export const MS_PER_DAY = 86400000;
export const MAX_TRIP_DAYS = 365;
@@ -241,9 +240,6 @@ export function updateTrip(tripId: string | number, userId: number, data: Update
WHERE id=?
`).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;
if (newStart !== trip.start_date || newEnd !== trip.end_date || dayCount)
generateDays(tripId, newStart || null, newEnd || null, undefined, dayCount);
-23
View File
@@ -101,29 +101,6 @@ export function getActivePlanId(userId: number): number {
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[] {
const plan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan | undefined;
if (!plan) return [];
+1 -4
View File
@@ -48,10 +48,7 @@ vi.mock('../../src/services/adminService', async (importOriginal) => {
return { ...actual, isAddonEnabled: isAddonEnabledMock };
});
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/services/oidcService', () => ({ getAppUrl: () => 'https://trek.example.com' }));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
vi.mock('../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn(), revokeUserSessionsForClient: vi.fn(), sessions: new Map() }));
-58
View File
@@ -285,61 +285,3 @@ describe('Shared trip — day assignments and notes', () => {
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);
});
});
-82
View File
@@ -184,88 +184,6 @@ describe('Tool: update_trip', () => {
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']);
});
});
// ---------------------------------------------------------------------------
+57 -37
View File
@@ -4,7 +4,63 @@ Production-ready setup using Docker Compose with security hardening enabled.
## Compose File
See https://github.com/mauriceboe/TREK/blob/main/docker-compose.yml
Create a `docker-compose.yml` with the following content (taken directly from the repository):
```yaml
services:
app:
image: mauriceboe/trek:latest
container_name: trek
read_only: true
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- CHOWN
- SETUID
- SETGID
tmpfs:
- /tmp:noexec,nosuid,size=64m
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- PORT=3000
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # Recommended. Generate with: openssl rand -hex 32. If unset, falls back to data/.jwt_secret (existing installs) or auto-generates a key (fresh installs).
- TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
- LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details
# - DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
# - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy
# - COOKIE_SECURE=false # Escape hatch: force session cookies over plain HTTP even in production. Not recommended.
# - TRUST_PROXY=1 # Trusted proxy count for X-Forwarded-For / X-Forwarded-Proto. Required for FORCE_HTTPS to work.
# - ALLOW_INTERNAL_NETWORK=false # Set to true if Immich or other services are hosted on your local network (RFC-1918 IPs). Loopback and link-local addresses remain blocked regardless.
# - APP_URL=https://trek.example.com # Public base URL — required when OIDC is enabled (must match the redirect URI registered with your IdP); also used as base URL for links in email notifications
# - OIDC_ISSUER=https://auth.example.com # OpenID Connect provider URL
# - OIDC_CLIENT_ID=trek # OpenID Connect client ID
# - OIDC_CLIENT_SECRET=supersecret # OpenID Connect client secret
# - OIDC_DISPLAY_NAME=SSO # Label shown on the SSO login button
# - OIDC_ONLY=false # Set true to force SSO-only mode: disables password login and registration, overrides Admin > Settings toggles, cannot be changed at runtime
# - OIDC_ADMIN_CLAIM=groups # OIDC claim used to identify admin users
# - OIDC_ADMIN_VALUE=app-trek-admins # Value of the OIDC claim that grants admin role
# - OIDC_SCOPE=openid email profile # Fully overrides the default. Add extra scopes as needed (e.g. add groups if using OIDC_ADMIN_CLAIM)
# - OIDC_DISCOVERY_URL= # Override the OIDC discovery endpoint for providers with non-standard paths (e.g. Authentik)
# - ADMIN_EMAIL=admin@trek.local # Initial admin e-mail — only used on first boot when no users exist
# - ADMIN_PASSWORD=changeme # Initial admin password — only used on first boot when no users exist
# - MCP_RATE_LIMIT=300 # Max MCP API requests per user per minute (default: 300)
# - MCP_MAX_SESSION_PER_USER=20 # Max concurrent MCP sessions per user (default: 20)
volumes:
- ./data:/app/data
- ./uploads:/app/uploads
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
```
## Security Hardening Explained
@@ -25,25 +81,6 @@ The compose file ships with several hardening options enabled by default:
| `./data` | `/app/data` | SQLite database, logs, `.jwt_secret`, `.encryption_key` |
| `./uploads` | `/app/uploads` | Uploaded files (photos, documents, covers, avatars) |
### Named Volumes
The compose file above uses bind mounts (`./data`, `./uploads`). You can switch to Docker named volumes, which are fully managed by Docker and not tied to a specific host path. See the [Docker Compose volumes reference](https://docs.docker.com/reference/compose-file/volumes/) for all options.
```yaml
services:
app:
# ... (rest of service config unchanged)
volumes:
- trek_data:/app/data
- trek_uploads:/app/uploads
volumes:
trek_data:
trek_uploads:
```
Docker creates the volumes automatically on first `docker compose up`. Use `docker volume ls` and `docker volume inspect` to manage them.
## Environment Variables
The compose file reads variables from a `.env` file placed alongside `docker-compose.yml`. At minimum, set:
@@ -58,23 +95,6 @@ APP_URL=https://trek.example.com
Uncomment and fill in the OIDC, initial setup, or MCP variables as needed. For a full description of every variable, see [Environment-Variables](Environment-Variables).
## Image Tags
Three tag strategies are available:
| Tag | Example | Behavior |
|---|---|---|
| `latest` | `mauriceboe/trek:latest` | Always the newest release across all major versions |
| Major version | `mauriceboe/trek:3` | Latest release pinned to that major version |
| Full version | `mauriceboe/trek:3.0.15` | Exact release; never changes |
The compose file above uses `latest`. To pin, change the `image:` line:
```yaml
image: mauriceboe/trek:3 # track major version 3
image: mauriceboe/trek:3.0.15 # pin to exact release
```
## Start TREK
```bash
-27
View File
@@ -34,16 +34,6 @@ Pass additional `-e` flags for timezone and CORS/email link support:
See [Environment-Variables](Environment-Variables) for the full list.
## Image Tags
| Tag | Example | Behavior |
|---|---|---|
| `latest` | `mauriceboe/trek:latest` | Always the newest release across all major versions |
| Major version | `mauriceboe/trek:3` | Latest release pinned to that major version |
| Full version | `mauriceboe/trek:3.0.15` | Exact release; never changes |
Replace `mauriceboe/trek:latest` in the run command with your chosen tag to pin to a major version or exact release.
## Volume Reference
| Volume | Container path | What lives there |
@@ -53,23 +43,6 @@ Replace `mauriceboe/trek:latest` in the run command with your chosen tag to pin
Both volumes must survive container replacement — they are your persistent state. Never remove them before pulling a new image.
### Named Volumes
The run command above uses bind mounts (`./data`, `./uploads`). You can use Docker named volumes instead, which are fully managed by Docker and not tied to a host path:
```bash
docker run -d \
--name trek \
-p 3000:3000 \
-v trek_data:/app/data \
-v trek_uploads:/app/uploads \
-e ENCRYPTION_KEY=<your-32-byte-hex-key> \
--restart unless-stopped \
mauriceboe/trek:latest
```
Docker creates `trek_data` and `trek_uploads` automatically on first run. Named volumes are easier to manage with `docker volume` commands and work better in some NAS or container-management environments.
## Health Check
The container exposes a health endpoint at:
-99
View File
@@ -1,99 +0,0 @@
# Install: Portainer
Install TREK on Portainer using a Stack (Docker Compose).
## Prerequisite
Portainer must be installed and connected to your Docker environment. Use **Stacks** — it supports Docker Compose and gives you the full compose syntax including environment variables, volumes, and restart policies.
## Create a Stack
![Stacks page with arrows pointing to the Stacks menu item and the Add stack button](assets/portainer-add-stack.png)
1. In Portainer, go to **Stacks → Add stack**.
2. Give the stack a name (e.g. `trek`).
3. Select **Web editor** and paste the compose file from [docker-compose.yml](https://github.com/mauriceboe/TREK/blob/main/docker-compose.yml).
![Web editor with the docker-compose content pasted in](assets/portainer-stack-save.png)
4. Fill in the environment variables at the bottom of the page.
![Environment variables section with key/value fields filled in](assets/portainer-environment-variable.png)
5. Click **Deploy the stack**.
![Deploy the stack button highlighted](assets/portainer-deploy-stack.png)
## Compose Content
See https://github.com/mauriceboe/TREK/blob/main/docker-compose.yml
Set at minimum `ENCRYPTION_KEY`, `TZ`, and `APP_URL` in the **Environment variables** section of the stack editor. Generate an encryption key with:
```bash
openssl rand -hex 32
```
## Image Tags
Three tag strategies are available:
| Tag | Example | Behavior |
|---|---|---|
| `latest` | `mauriceboe/trek:latest` | Always the newest release across all major versions |
| Major version | `mauriceboe/trek:3` | Latest release pinned to that major version |
| Full version | `mauriceboe/trek:3.0.15` | Exact release; never changes |
Use `latest` or a major-version tag (e.g. `3`) if you want automatic updates on redeploy. Use a full version tag (e.g. `3.0.15`) if you want explicit control over which release runs.
## Updating
How you update depends on the tag you chose:
**`latest` or major-version tag** — In Portainer, open the stack, click **Redeploy**, enable the **Re-pull image and redeploy** switch, then confirm. Portainer will pull the newest matching image and recreate the container.
![Re-pull image and redeploy switch ticked, with arrows pointing to the switch and the Update button](assets/portainer-force-pull.png)
**Pinned full-version tag** — Edit the stack, change the tag in the `image:` line (e.g. `3.0.15``3.0.16`), then click **Update the stack**. No need to toggle the re-pull switch — a tag change forces a fresh pull.
![Edit stack page with an arrow pointing to the image tag in the compose editor](assets/portainer-update-version.png)
![Edit stack page with an arrow pointing to the Update the stack button](assets/portainer-update-stack.png)
> Back up your data before any update. Go to **Admin Panel → Backups** or copy your `./data` and `./uploads` directories. See [Backups](Backups).
## Volumes
| Stack-relative path | Container path | Contents |
|---|---|---|
| `./data` | `/app/data` | SQLite database, logs, encryption key |
| `./uploads` | `/app/uploads` | Uploaded files (photos, documents, covers, avatars) |
Portainer resolves `./` relative to the stack's working directory. Confirm the paths under **Stack details** after deploying.
### Named Volumes
You can use Docker named volumes instead of bind mounts. Named volumes are fully managed by Docker and not tied to a host path — a good fit for Portainer where the working directory can vary. See the [Docker Compose volumes reference](https://docs.docker.com/reference/compose-file/volumes/) for all options.
Replace the `volumes:` block in the service and add a top-level declaration:
```yaml
services:
app:
# ... (rest of service config unchanged)
volumes:
- trek_data:/app/data
- trek_uploads:/app/uploads
volumes:
trek_data:
trek_uploads:
```
Portainer lists named volumes under **Volumes** in the sidebar, where you can inspect or back them up.
## Next Steps
- [Environment-Variables](Environment-Variables) — full variable reference
- [Reverse-Proxy](Reverse-Proxy) — HTTPS configuration
- [Updating](Updating) — update strategies across all install methods
+1 -37
View File
@@ -6,33 +6,13 @@ How to update TREK to a newer version without losing data.
Back up your data first. Go to Admin Panel → Backups and create a manual backup, or copy your `./data` and `./uploads` directories to a safe location. See [Backups](Backups) for details.
## Image Tags
| Tag | Example | Behavior |
|---|---|---|
| `latest` | `mauriceboe/trek:latest` | Always the newest release across all major versions |
| Major version | `mauriceboe/trek:3` | Latest release pinned to that major version |
| Full version | `mauriceboe/trek:3.0.15` | Exact release; never changes |
Use `latest` or a major-version tag if you want updates on each redeploy. Use a full version tag for explicit control — update by changing the tag, not by re-pulling.
## Docker Compose (Recommended)
**`latest` or major-version tag:**
```bash
docker compose pull && docker compose up -d
```
This pulls the newest matching image and recreates the container with your existing volumes. Your data is untouched.
**Pinned full-version tag:**
Edit `docker-compose.yml`, update the tag in the `image:` line (e.g. `3.0.15``3.0.16`), then redeploy:
```bash
docker compose up -d
```
This pulls the latest image and recreates the container with your existing volumes. Your data is untouched.
## Docker Run
@@ -83,22 +63,6 @@ To verify the update completed and check for errors:
journalctl -u trek -n 50
```
## Portainer
Open the **Stacks** list, click the TREK stack, then click **Redeploy**.
**`latest` or major-version tag** — enable the **Re-pull image and redeploy** switch before confirming. Portainer pulls the newest matching image and recreates the container.
![Re-pull image and redeploy switch ticked, with arrows pointing to the switch and the Update button](assets/portainer-force-pull.png)
**Pinned full-version tag** (e.g. `3.0.15`) — edit the stack, update the tag in the `image:` line, then click **Update the stack**. No re-pull switch needed; the tag change forces a fresh pull.
![Edit stack page with an arrow pointing to the image tag in the compose editor](assets/portainer-update-version.png)
![Edit stack page with an arrow pointing to the Update the stack button](assets/portainer-update-stack.png)
See [Install-Portainer](Install-Portainer) for the full installation walkthrough.
## Unraid
In the Unraid Docker tab, click the TREK container and select **Update**. Unraid will pull the latest image and restart with the same volumes.
-1
View File
@@ -6,7 +6,6 @@
- [[Install: Helm|Install-Helm]]
- [[Install: Proxmox VE (LXC)|Install-Proxmox]]
- [[Install: Unraid|Install-Unraid]]
- [[Install: Portainer|Install-Portainer]]
- [[Reverse Proxy|Reverse-Proxy]]
- [[Environment Variables|Environment-Variables]]
- [[Updating]]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB