diff --git a/README.md b/README.md
index 3e90a0e8..f3dfae0f 100644
--- a/README.md
+++ b/README.md
@@ -400,6 +400,7 @@ Caddy handles TLS and WebSockets automatically.
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` |
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
+| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
diff --git a/charts/trek/templates/configmap.yaml b/charts/trek/templates/configmap.yaml
index af3a7182..33efce0c 100644
--- a/charts/trek/templates/configmap.yaml
+++ b/charts/trek/templates/configmap.yaml
@@ -22,6 +22,9 @@ data:
{{- if .Values.env.FORCE_HTTPS }}
FORCE_HTTPS: {{ .Values.env.FORCE_HTTPS | quote }}
{{- end }}
+ {{- if .Values.env.HSTS_INCLUDE_SUBDOMAINS }}
+ HSTS_INCLUDE_SUBDOMAINS: {{ .Values.env.HSTS_INCLUDE_SUBDOMAINS | quote }}
+ {{- end }}
{{- if .Values.env.COOKIE_SECURE }}
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
{{- end }}
diff --git a/charts/trek/values.yaml b/charts/trek/values.yaml
index 42c86b1f..0f19d230 100644
--- a/charts/trek/values.yaml
+++ b/charts/trek/values.yaml
@@ -30,6 +30,8 @@ env:
# Also used as the base URL for links in email notifications and other external links.
# FORCE_HTTPS: "false"
# Optional. When "true": HTTPS redirect, HSTS, CSP upgrade-insecure-requests, secure cookies. Only behind a TLS proxy. Requires TRUST_PROXY.
+ # HSTS_INCLUDE_SUBDOMAINS: "false"
+ # When "true": adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave "false" if sibling subdomains still run over plain HTTP.
# COOKIE_SECURE: "true"
# Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production.
# TRUST_PROXY: "1"
diff --git a/client/src/components/Budget/BudgetPanel.tsx b/client/src/components/Budget/BudgetPanel.tsx
index e442a0ff..a91a977e 100644
--- a/client/src/components/Budget/BudgetPanel.tsx
+++ b/client/src/components/Budget/BudgetPanel.tsx
@@ -634,7 +634,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
}
const handleRenameCategory = async (oldName, newName) => {
if (!newName.trim() || newName.trim() === oldName) return
- const items = grouped[oldName] || []
+ const items = grouped.get(oldName) || []
for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() })
}
const handleAddCategory = () => {
diff --git a/client/src/components/Planner/DayDetailPanel.tsx b/client/src/components/Planner/DayDetailPanel.tsx
index 8ef2282b..9487f402 100644
--- a/client/src/components/Planner/DayDetailPanel.tsx
+++ b/client/src/components/Planner/DayDetailPanel.tsx
@@ -66,7 +66,11 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
- const fmtTime = (v) => formatTime12(v, is12h)
+ const fmtTime = (v) => {
+ if (!v) return v
+ if (v.includes('T')) return new Date(v).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })
+ return formatTime12(v, is12h)
+ }
const unit = isFahrenheit ? '°F' : '°C'
const collapsed = collapsedProp
const toggleCollapse = () => onToggleCollapse?.()
diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx
index 64049e89..b1eda2d3 100644
--- a/client/src/components/Planner/DayPlanSidebar.tsx
+++ b/client/src/components/Planner/DayPlanSidebar.tsx
@@ -1576,7 +1576,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
{res.reservation_time?.includes('T') && (
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
- {res.reservation_end_time && ` – ${res.reservation_end_time}`}
+ {res.reservation_end_time && ` – ${(() => {
+ const endStr = res.reservation_end_time.includes('T') ? res.reservation_end_time : (res.reservation_time.split('T')[0] + 'T' + res.reservation_end_time)
+ return new Date(endStr).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
+ })()}`}
)}
{(() => {
diff --git a/client/src/components/Planner/ReservationModal.tsx b/client/src/components/Planner/ReservationModal.tsx
index 7fa8a7e8..f5ec6f13 100644
--- a/client/src/components/Planner/ReservationModal.tsx
+++ b/client/src/components/Planner/ReservationModal.tsx
@@ -182,6 +182,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
let combinedEndTime = form.reservation_end_time
if (form.end_date) {
combinedEndTime = form.reservation_end_time ? `${form.end_date}T${form.reservation_end_time}` : form.end_date
+ } else if (form.reservation_end_time && form.reservation_time) {
+ combinedEndTime = `${form.reservation_time.split('T')[0]}T${form.reservation_end_time}`
}
if (isBudgetEnabled) {
if (form.price) metadata.price = form.price
diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx
index 00c105c2..770d36a5 100644
--- a/client/src/components/Planner/ReservationsPanel.tsx
+++ b/client/src/components/Planner/ReservationsPanel.tsx
@@ -236,7 +236,16 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
{t('reservations.date')}
{fmtDate(r.reservation_time)}
- {r.reservation_end_time && (r.reservation_end_time.includes('T') ? r.reservation_end_time.split('T')[0] : r.reservation_end_time) !== r.reservation_time.split('T')[0] && (
+ {(() => {
+ const endDatePart = r.reservation_end_time
+ ? r.reservation_end_time.includes('T')
+ ? r.reservation_end_time.split('T')[0]
+ : /^\d{4}-\d{2}-\d{2}$/.test(r.reservation_end_time)
+ ? r.reservation_end_time
+ : null
+ : null
+ return endDatePart && endDatePart !== r.reservation_time.split('T')[0]
+ })() && (
<> – {fmtDate(r.reservation_end_time)}>
)}
diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx
index dd514b38..f4d3dde8 100644
--- a/client/src/pages/TripPlannerPage.tsx
+++ b/client/src/pages/TripPlannerPage.tsx
@@ -343,7 +343,10 @@ export default function TripPlannerPage(): React.ReactElement | null {
}, [tripId])
useEffect(() => {
- if (tripId) tripActions.loadReservations(tripId)
+ if (tripId) {
+ tripActions.loadReservations(tripId)
+ tripActions.loadBudgetItems?.(tripId)
+ }
}, [tripId])
useTripWebSocket(tripId)
@@ -1106,7 +1109,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
{mobileSidebarOpen === 'left'
- ?
{ handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} />
+ ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} />
: { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} />
}
diff --git a/docker-compose.yml b/docker-compose.yml
index e0d84418..a72cbecd 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -24,6 +24,7 @@ services:
# - DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
- 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
+# - HSTS_INCLUDE_SUBDOMAINS=false # When true: adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave false if sibling subdomains still run over plain HTTP.
# - COOKIE_SECURE=false # Escape hatch: force session cookies over plain HTTP even in production. Not recommended.
# - 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.
diff --git a/server/.env.example b/server/.env.example
index ba9da901..7fb96267 100644
--- a/server/.env.example
+++ b/server/.env.example
@@ -13,6 +13,7 @@ LOG_LEVEL=info # info = concise user actions; debug = verbose admin-level detail
ALLOWED_ORIGINS=https://trek.example.com # Comma-separated origins for CORS and email links
FORCE_HTTPS=false # Optional. When true: HTTPS redirect + HSTS + CSP upgrade-insecure-requests + secure cookies. Only behind a TLS proxy.
+# HSTS_INCLUDE_SUBDOMAINS=false # When true: adds includeSubDomains to the HSTS header. Only effective when HSTS is active (FORCE_HTTPS=true or NODE_ENV=production). Leave false if you run other services on sibling subdomains over plain HTTP.
COOKIE_SECURE=true # Auto-derived (true when NODE_ENV=production or FORCE_HTTPS=true). Set false to force cookies over plain HTTP.
TRUST_PROXY=1 # Trusted proxy hops (parseInt or 1). Active in production by default; off in dev unless set. Needed for FORCE_HTTPS.
ALLOW_INTERNAL_NETWORK=false # Allow outbound requests to private/RFC1918 IPs (e.g. Immich hosted on your LAN). Loopback and link-local addresses are always blocked.
diff --git a/server/src/app.ts b/server/src/app.ts
index 9bca8fcb..d21c0d3c 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -124,6 +124,7 @@ export function createApp(): express.Application {
},
crossOriginEmbedderPolicy: false,
hsts: hstsActive ? { maxAge: 31536000, includeSubDomains: hstsIncludeSubdomains } : false,
+ referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}));
if (shouldForceHttps) {
diff --git a/server/src/services/packingService.ts b/server/src/services/packingService.ts
index a9eddb25..19fa54d9 100644
--- a/server/src/services/packingService.ts
+++ b/server/src/services/packingService.ts
@@ -1,4 +1,5 @@
import { db, canAccessTrip } from '../db/database';
+import { avatarUrl } from './authService';
const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b'];
@@ -131,7 +132,10 @@ export function listBags(tripId: string | number) {
if (!membersByBag.has(m.bag_id)) membersByBag.set(m.bag_id, []);
membersByBag.get(m.bag_id)!.push(m);
}
- return bags.map(b => ({ ...b, members: membersByBag.get(b.id) || [] }));
+ return bags.map(b => ({
+ ...b,
+ members: (membersByBag.get(b.id) || []).map(m => ({ ...m, avatar: avatarUrl(m) })),
+ }));
}
export function setBagMembers(tripId: string | number, bagId: string | number, userIds: number[]) {
@@ -140,11 +144,12 @@ export function setBagMembers(tripId: string | number, bagId: string | number, u
db.prepare('DELETE FROM packing_bag_members WHERE bag_id = ?').run(bagId);
const ins = db.prepare('INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)');
for (const uid of userIds) ins.run(bagId, uid);
- return db.prepare(`
+ const rows = db.prepare(`
SELECT bm.user_id, u.username, u.avatar
FROM packing_bag_members bm JOIN users u ON bm.user_id = u.id
WHERE bm.bag_id = ?
- `).all(bagId);
+ `).all(bagId) as { user_id: number; username: string; avatar: string | null }[];
+ return rows.map(m => ({ ...m, avatar: avatarUrl(m) }));
}
export function createBag(tripId: string | number, data: { name: string; color?: string }) {
@@ -260,7 +265,7 @@ export function getCategoryAssignees(tripId: string | number) {
const assignees: Record = {};
for (const row of rows as any[]) {
if (!assignees[row.category_name]) assignees[row.category_name] = [];
- assignees[row.category_name].push({ user_id: row.user_id, username: row.username, avatar: row.avatar });
+ assignees[row.category_name].push({ user_id: row.user_id, username: row.username, avatar: avatarUrl(row) });
}
return assignees;
@@ -274,12 +279,13 @@ export function updateCategoryAssignees(tripId: string | number, categoryName: s
for (const uid of userIds) insert.run(tripId, categoryName, uid);
}
- return db.prepare(`
+ const updated = db.prepare(`
SELECT pca.user_id, u.username, u.avatar
FROM packing_category_assignees pca
JOIN users u ON pca.user_id = u.id
WHERE pca.trip_id = ? AND pca.category_name = ?
- `).all(tripId, categoryName);
+ `).all(tripId, categoryName) as { user_id: number; username: string; avatar: string | null }[];
+ return updated.map(m => ({ ...m, avatar: avatarUrl(m) }));
}
// ── Reorder ────────────────────────────────────────────────────────────────
diff --git a/unraid-template.xml b/unraid-template.xml
index 69ca38f6..b9c48442 100644
--- a/unraid-template.xml
+++ b/unraid-template.xml
@@ -37,6 +37,7 @@
false
+ false
true
1
false
diff --git a/wiki/Environment-Variables.md b/wiki/Environment-Variables.md
index 3156801b..7ea29c5a 100644
--- a/wiki/Environment-Variables.md
+++ b/wiki/Environment-Variables.md
@@ -53,6 +53,7 @@ These three variables work together behind a TLS-terminating reverse proxy. See
| Variable | Description | Default |
|---|---|---|
| `FORCE_HTTPS` | When `true`: 301-redirects HTTP→HTTPS, sends HSTS (`max-age=31536000`), adds CSP `upgrade-insecure-requests`, forces cookie `secure` flag. Only useful behind a TLS proxy. Requires `TRUST_PROXY`. | `false` |
+| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
| `TRUST_PROXY` | Number of trusted proxy hops. Tells Express to read the real client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` automatically in production. Required for `FORCE_HTTPS` to detect the forwarded protocol. | `1` (production) |
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived as `true` when `NODE_ENV=production` or `FORCE_HTTPS=true`. Set to `false` only as an escape hatch for LAN testing without TLS — not recommended in production. | auto |
diff --git a/wiki/Map-Features.md b/wiki/Map-Features.md
index 878976eb..3d8e170e 100644
--- a/wiki/Map-Features.md
+++ b/wiki/Map-Features.md
@@ -36,18 +36,20 @@ When you have a day selected, a dark dashed line connects consecutive places in
At zoom level 12 or higher, small pill-shaped labels appear between consecutive places and show the estimated **walking time** and **driving time** for each segment. Below zoom 12 they are hidden to keep the map clean.
+> **Requires:** Settings → Display → **Route calculation** must be ON. When this setting is OFF, TREK never queries the routing service, so no pills are calculated or drawn at any zoom level.
+
## Reservation and transport overlay
-Flights, trains, cars, and cruises are drawn as overlays between their endpoint places:
+Flights, trains, cars, and cruises can be drawn as overlays between their endpoint places. Overlays are **off by default** — activate each reservation individually by clicking the small **Route** icon next to the booking row in the day sidebar. The selection is remembered per trip in your browser. Click the icon again to hide it.
- **Flights and cruises** — geodesic great-circle arcs
- **Trains and cars** — straight lines
- **Antimeridian crossings** — arcs that would cross the date line are split into sub-arcs to avoid wrapping across the map
- **Endpoint markers** — pill-shaped labels with the transport icon and the endpoint code (e.g. IATA airport code) or location name
-- **Flight stats** — a floating label on the arc shows departure code → arrival code and, when times are available, the duration and great-circle distance. Stats labels are only rendered for flights.
+- **Flight stats** — a floating label on the arc shows departure code → arrival code and, when times are available, the duration and great-circle distance. Stats labels are only rendered for flights and require Settings → Display → **Route calculation** to be ON.
- **Confirmed reservations** — solid line; **Pending** — dashed line
-> **Admin:** Whether endpoint labels appear is controlled by the **Show connection labels** setting (`map_booking_labels`).
+> **Admin:** Whether endpoint text labels appear on the endpoint markers is controlled by the **Booking route labels** setting in Settings → Display (`map_booking_labels`).
## Location button