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