diff --git a/README.md b/README.md index 65755a5..5644377 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,9 @@ ## Quick Start ```bash -docker run -d -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek +ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \ + -e ENCRYPTION_KEY=$ENCRYPTION_KEY \ + -v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek ``` The app runs on port `3000`. The first user to register becomes the admin. @@ -136,10 +138,11 @@ services: environment: - NODE_ENV=production - PORT=3000 - - JWT_SECRET=${JWT_SECRET:-} # Auto-generated if not set; persist across restarts for stable sessions + - 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). - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links - 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 + # - ALLOW_INTERNAL_NETWORK=true # Uncomment if Immich is on your local network (RFC-1918 IPs) volumes: - ./data:/app/data - ./uploads:/app/uploads @@ -178,6 +181,18 @@ docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/upl Your data is persisted in the mounted `data` and `uploads` volumes — updates never touch your existing data. +### Rotating the Encryption Key + +If you need to rotate `ENCRYPTION_KEY` (e.g. you are upgrading from a version that derived encryption from `JWT_SECRET`), use the migration script to re-encrypt all stored secrets under the new key without starting the app: + +```bash +docker exec -it trek node --import tsx scripts/migrate-encryption.ts +``` + +The script will prompt for your old and new keys interactively (input is not echoed). It creates a timestamped database backup before making any changes and exits with a non-zero code if anything fails. + +**Upgrading from a previous version?** Your old JWT secret is in `./data/.jwt_secret`. Use its contents as the "old key" and your new `ENCRYPTION_KEY` value as the "new key". + ### Reverse Proxy (recommended) For production, put TREK behind a reverse proxy with HTTPS (e.g. Nginx, Caddy, Traefik). @@ -245,12 +260,13 @@ trek.yourdomain.com { | **Core** | | | | `PORT` | Server port | `3000` | | `NODE_ENV` | Environment (`production` / `development`) | `production` | -| `JWT_SECRET` | JWT signing secret; auto-generated and saved to `data/` if not set | Auto-generated | +| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto | | `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` | | `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` | | `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin | | `FORCE_HTTPS` | Redirect HTTP to HTTPS behind a TLS-terminating proxy | `false` | | `TRUST_PROXY` | Number of trusted reverse proxies for `X-Forwarded-For` | `1` | +| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IP addresses. Set to `true` if Immich or other integrated services are hosted on your local network. Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) are always blocked regardless of this setting. | `false` | | **OIDC / SSO** | | | | `OIDC_ISSUER` | OpenID Connect provider URL | — | | `OIDC_CLIENT_ID` | OIDC client ID | — | diff --git a/chart/README.md b/chart/README.md index c5689b9..54e8b90 100644 --- a/chart/README.md +++ b/chart/README.md @@ -14,7 +14,6 @@ This is a minimal Helm chart for deploying the TREK app. ```sh helm install trek ./chart \ - --set secretEnv.JWT_SECRET=your_jwt_secret \ --set ingress.enabled=true \ --set ingress.hosts[0].host=yourdomain.com ``` @@ -29,5 +28,7 @@ See `values.yaml` for more options. ## Notes - Ingress is off by default. Enable and configure hosts for your domain. - PVCs require a default StorageClass or specify one as needed. -- JWT_SECRET must be set for production use. +- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed. +- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC. - If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically. +- Set `env.ALLOW_INTERNAL_NETWORK: "true"` if Immich or other integrated services are hosted on a private/RFC-1918 address (e.g. a pod on the same cluster or a NAS on your LAN). Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) remain blocked regardless. diff --git a/chart/templates/NOTES.txt b/chart/templates/NOTES.txt index 45a1993..0e258f4 100644 --- a/chart/templates/NOTES.txt +++ b/chart/templates/NOTES.txt @@ -1,13 +1,23 @@ -1. JWT_SECRET handling: - - By default, the chart creates a secret with the value from `values.yaml: secretEnv.JWT_SECRET`. - - To generate a random JWT_SECRET at install, set `generateJwtSecret: true`. - - To use an existing Kubernetes secret, set `existingSecret` to the secret name. The secret must have a key matching `existingSecretKey` (defaults to `JWT_SECRET`). +1. ENCRYPTION_KEY handling: + - ENCRYPTION_KEY encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. + - By default, the chart creates a Kubernetes Secret from `secretEnv.ENCRYPTION_KEY` in values.yaml. + - To generate a random key at install (preserved across upgrades), set `generateEncryptionKey: true`. + - To use an existing Kubernetes secret, set `existingSecret` to the secret name. The secret must + contain a key matching `existingSecretKey` (defaults to `ENCRYPTION_KEY`). + - If left empty, the server resolves the key automatically: existing installs fall back to + data/.jwt_secret (encrypted data stays readable with no manual action); fresh installs + auto-generate a key persisted to the data PVC. -2. Example usage: - - Set a custom secret: `--set secretEnv.JWT_SECRET=your_secret` - - Generate a random secret: `--set generateJwtSecret=true` +2. JWT_SECRET is managed entirely by the server: + - Auto-generated on first start and persisted to the data PVC (data/.jwt_secret). + - Rotate it via the admin panel (Settings → Danger Zone → Rotate JWT Secret). + - No Helm configuration needed or supported. + +3. Example usage: + - Set an explicit encryption key: `--set secretEnv.ENCRYPTION_KEY=your_enc_key` + - Generate a random key at install: `--set generateEncryptionKey=true` - Use an existing secret: `--set existingSecret=my-k8s-secret` - - Use a custom key in the existing secret: `--set existingSecret=my-k8s-secret --set existingSecretKey=MY_KEY` + - Use a custom key name in the existing secret: `--set existingSecret=my-k8s-secret --set existingSecretKey=MY_ENC_KEY` -3. Only one method should be used at a time. If both `generateJwtSecret` and `existingSecret` are set, `existingSecret` takes precedence. - If using `existingSecret`, ensure the referenced secret and key exist in the target namespace. +4. Only one method should be used at a time. If both `generateEncryptionKey` and `existingSecret` are + set, `existingSecret` takes precedence. Ensure the referenced secret and key exist in the namespace. diff --git a/chart/templates/configmap.yaml b/chart/templates/configmap.yaml index 7a7ed6a..7e0a5a3 100644 --- a/chart/templates/configmap.yaml +++ b/chart/templates/configmap.yaml @@ -10,3 +10,6 @@ data: {{- if .Values.env.ALLOWED_ORIGINS }} ALLOWED_ORIGINS: {{ .Values.env.ALLOWED_ORIGINS | quote }} {{- end }} + {{- if .Values.env.ALLOW_INTERNAL_NETWORK }} + ALLOW_INTERNAL_NETWORK: {{ .Values.env.ALLOW_INTERNAL_NETWORK | quote }} + {{- end }} diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml index 25169f6..df5884b 100644 --- a/chart/templates/deployment.yaml +++ b/chart/templates/deployment.yaml @@ -36,11 +36,12 @@ spec: - configMapRef: name: {{ include "trek.fullname" . }}-config env: - - name: JWT_SECRET + - name: ENCRYPTION_KEY valueFrom: secretKeyRef: name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }} - key: {{ .Values.existingSecretKey | default "JWT_SECRET" }} + key: {{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }} + optional: true volumeMounts: - name: data mountPath: /app/data diff --git a/chart/templates/secret.yaml b/chart/templates/secret.yaml index 6ead7f1..204e91c 100644 --- a/chart/templates/secret.yaml +++ b/chart/templates/secret.yaml @@ -1,4 +1,4 @@ -{{- if and (not .Values.existingSecret) (not .Values.generateJwtSecret) }} +{{- if and (not .Values.existingSecret) (not .Values.generateEncryptionKey) }} apiVersion: v1 kind: Secret metadata: @@ -7,10 +7,10 @@ metadata: app: {{ include "trek.name" . }} type: Opaque data: - {{ .Values.existingSecretKey | default "JWT_SECRET" }}: {{ .Values.secretEnv.JWT_SECRET | b64enc | quote }} + {{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ .Values.secretEnv.ENCRYPTION_KEY | b64enc | quote }} {{- end }} -{{- if and (not .Values.existingSecret) (.Values.generateJwtSecret) }} +{{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }} {{- $secretName := printf "%s-secret" (include "trek.fullname" .) }} {{- $existingSecret := (lookup "v1" "Secret" .Release.Namespace $secretName) }} apiVersion: v1 @@ -22,8 +22,8 @@ metadata: type: Opaque stringData: {{- if and $existingSecret $existingSecret.data }} - {{ .Values.existingSecretKey | default "JWT_SECRET" }}: {{ index $existingSecret.data (.Values.existingSecretKey | default "JWT_SECRET") | b64dec }} + {{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ index $existingSecret.data (.Values.existingSecretKey | default "ENCRYPTION_KEY") | b64dec }} {{- else }} - {{ .Values.existingSecretKey | default "JWT_SECRET" }}: {{ randAlphaNum 32 }} + {{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ randAlphaNum 32 }} {{- end }} {{- end }} diff --git a/chart/values.yaml b/chart/values.yaml index 0d9d0c9..07164e3 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -16,20 +16,29 @@ env: NODE_ENV: production PORT: 3000 # ALLOWED_ORIGINS: "" -# NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration. + # NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration. + # ALLOW_INTERNAL_NETWORK: "false" + # Set to "true" if Immich or other integrated services are hosted on a private/RFC-1918 network address. + # Loopback (127.x) and link-local/metadata addresses (169.254.x) are always blocked. -# JWT secret configuration +# Secret environment variables stored in a Kubernetes Secret. +# JWT_SECRET is managed entirely by the server (auto-generated into the data PVC, +# rotatable via the admin panel) — it is not configured here. secretEnv: - # If set, use this value for JWT_SECRET (base64-encoded in secret.yaml) - JWT_SECRET: "" + # At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC, etc.). + # Recommended: set to a random 32-byte hex value (openssl rand -hex 32). + # If left empty the server resolves the key automatically: + # 1. data/.jwt_secret (existing installs — encrypted data stays readable after upgrade) + # 2. data/.encryption_key auto-generated on first start (fresh installs) + ENCRYPTION_KEY: "" -# If true, a random JWT_SECRET will be generated during install (overrides secretEnv.JWT_SECRET) -generateJwtSecret: false +# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades +generateEncryptionKey: false -# If set, use an existing Kubernetes secret for JWT_SECRET +# If set, use an existing Kubernetes secret that contains ENCRYPTION_KEY existingSecret: "" -existingSecretKey: JWT_SECRET +existingSecretKey: ENCRYPTION_KEY persistence: enabled: true diff --git a/client/src/App.tsx b/client/src/App.tsx index fa31363..0235276 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -75,13 +75,11 @@ function RootRedirect() { } export default function App() { - const { loadUser, token, isAuthenticated, demoMode, setDemoMode, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore() + const { loadUser, isAuthenticated, demoMode, setDemoMode, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore() const { loadSettings } = useSettingsStore() useEffect(() => { - if (token) { - loadUser() - } + loadUser() authApi.getAppConfig().then(async (config: { demo_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record }) => { if (config?.demo_mode) setDemoMode(true) if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key) diff --git a/client/src/api/authUrl.ts b/client/src/api/authUrl.ts new file mode 100644 index 0000000..203ceb3 --- /dev/null +++ b/client/src/api/authUrl.ts @@ -0,0 +1,16 @@ +export async function getAuthUrl(url: string, purpose: 'download' | 'immich'): Promise { + if (!url) return url + try { + const resp = await fetch('/api/auth/resource-token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ purpose }), + }) + if (!resp.ok) return url + const { token } = await resp.json() + return `${url}${url.includes('?') ? '&' : '?'}token=${token}` + } catch { + return url + } +} diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 790e341..7992005 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -3,18 +3,15 @@ import { getSocketId } from './websocket' const apiClient: AxiosInstance = axios.create({ baseURL: '/api', + withCredentials: true, headers: { 'Content-Type': 'application/json', }, }) -// Request interceptor - add auth token and socket ID +// Request interceptor - add socket ID apiClient.interceptors.request.use( (config) => { - const token = localStorage.getItem('auth_token') - if (token) { - config.headers.Authorization = `Bearer ${token}` - } const sid = getSocketId() if (sid) { config.headers['X-Socket-Id'] = sid @@ -29,7 +26,6 @@ apiClient.interceptors.response.use( (response) => response, (error) => { if (error.response?.status === 401) { - localStorage.removeItem('auth_token') if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register')) { window.location.href = '/login' } @@ -165,7 +161,6 @@ export const adminApi = { addons: () => apiClient.get('/admin/addons').then(r => r.data), updateAddon: (id: number | string, data: Record) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data), checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data), - installUpdate: () => apiClient.post('/admin/update', {}, { timeout: 300000 }).then(r => r.data), getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data), updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data), packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data), @@ -188,6 +183,7 @@ export const adminApi = { deleteMcpToken: (id: number) => apiClient.delete(`/admin/mcp-tokens/${id}`).then(r => r.data), getPermissions: () => apiClient.get('/admin/permissions').then(r => r.data), updatePermissions: (permissions: Record) => apiClient.put('/admin/permissions', { permissions }).then(r => r.data), + rotateJwtSecret: () => apiClient.post('/admin/rotate-jwt-secret').then(r => r.data), } export const addonsApi = { @@ -285,9 +281,8 @@ export const backupApi = { list: () => apiClient.get('/backup/list').then(r => r.data), create: () => apiClient.post('/backup/create').then(r => r.data), download: async (filename: string): Promise => { - const token = localStorage.getItem('auth_token') const res = await fetch(`/api/backup/download/${filename}`, { - headers: { Authorization: `Bearer ${token}` }, + credentials: 'include', }) if (!res.ok) throw new Error('Download failed') const blob = await res.blob() diff --git a/client/src/api/websocket.ts b/client/src/api/websocket.ts index bde9815..2b4a520 100644 --- a/client/src/api/websocket.ts +++ b/client/src/api/websocket.ts @@ -9,9 +9,10 @@ let reconnectDelay = 1000 const MAX_RECONNECT_DELAY = 30000 const listeners = new Set() const activeTrips = new Set() -let currentToken: string | null = null +let shouldReconnect = false let refetchCallback: RefetchCallback | null = null let mySocketId: string | null = null +let connecting = false export function getSocketId(): string | null { return mySocketId @@ -21,9 +22,28 @@ export function setRefetchCallback(fn: RefetchCallback | null): void { refetchCallback = fn } -function getWsUrl(token: string): string { +function getWsUrl(wsToken: string): string { const protocol = location.protocol === 'https:' ? 'wss' : 'ws' - return `${protocol}://${location.host}/ws?token=${token}` + return `${protocol}://${location.host}/ws?token=${wsToken}` +} + +async function fetchWsToken(): Promise { + try { + const resp = await fetch('/api/auth/ws-token', { + method: 'POST', + credentials: 'include', + }) + if (resp.status === 401) { + // Session expired — stop reconnecting + shouldReconnect = false + return null + } + if (!resp.ok) return null + const { token } = await resp.json() + return token as string + } catch { + return null + } } function handleMessage(event: MessageEvent): void { @@ -45,19 +65,29 @@ function scheduleReconnect(): void { if (reconnectTimer) return reconnectTimer = setTimeout(() => { reconnectTimer = null - if (currentToken) { - connectInternal(currentToken, true) + if (shouldReconnect) { + connectInternal(true) } }, reconnectDelay) reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY) } -function connectInternal(token: string, _isReconnect = false): void { +async function connectInternal(_isReconnect = false): Promise { + if (connecting) return if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) { return } - const url = getWsUrl(token) + connecting = true + const wsToken = await fetchWsToken() + connecting = false + + if (!wsToken) { + if (shouldReconnect) scheduleReconnect() + return + } + + const url = getWsUrl(wsToken) socket = new WebSocket(url) socket.onopen = () => { @@ -82,7 +112,7 @@ function connectInternal(token: string, _isReconnect = false): void { socket.onclose = () => { socket = null - if (currentToken) { + if (shouldReconnect) { scheduleReconnect() } } @@ -92,18 +122,18 @@ function connectInternal(token: string, _isReconnect = false): void { } } -export function connect(token: string): void { - currentToken = token +export function connect(): void { + shouldReconnect = true reconnectDelay = 1000 if (reconnectTimer) { clearTimeout(reconnectTimer) reconnectTimer = null } - connectInternal(token, false) + connectInternal(false) } export function disconnect(): void { - currentToken = null + shouldReconnect = false if (reconnectTimer) { clearTimeout(reconnectTimer) reconnectTimer = null diff --git a/client/src/components/Files/FileManager.tsx b/client/src/components/Files/FileManager.tsx index 3fcf8e2..3dc6044 100644 --- a/client/src/components/Files/FileManager.tsx +++ b/client/src/components/Files/FileManager.tsx @@ -1,5 +1,5 @@ import ReactDOM from 'react-dom' -import { useState, useCallback, useRef } from 'react' +import { useState, useCallback, useRef, useEffect } from 'react' import { useDropzone } from 'react-dropzone' import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check } from 'lucide-react' import { useToast } from '../shared/Toast' @@ -9,11 +9,7 @@ import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../ty import { useCanDo } from '../../store/permissionsStore' import { useTripStore } from '../../store/tripStore' -function authUrl(url: string): string { - const token = localStorage.getItem('auth_token') - if (!token || !url || url.includes('token=')) return url - return `${url}${url.includes('?') ? '&' : '?'}token=${token}` -} +import { getAuthUrl } from '../../api/authUrl' function isImage(mimeType) { if (!mimeType) return false @@ -49,6 +45,10 @@ interface ImageLightboxProps { function ImageLightbox({ file, onClose }: ImageLightboxProps) { const { t } = useTranslation() + const [imgSrc, setImgSrc] = useState('') + useEffect(() => { + getAuthUrl(file.url, 'download').then(setImgSrc) + }, [file.url]) return (
e.stopPropagation()}> {file.original_name}
{file.original_name}
- + @@ -76,6 +80,15 @@ function ImageLightbox({ file, onClose }: ImageLightboxProps) { ) } +// Authenticated image — fetches a short-lived download token and renders the image +function AuthedImg({ src, style }: { src: string; style?: React.CSSProperties }) { + const [authSrc, setAuthSrc] = useState('') + useEffect(() => { + getAuthUrl(src, 'download').then(setAuthSrc) + }, [src]) + return authSrc ? : null +} + // Source badge interface SourceBadgeProps { icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }> @@ -292,6 +305,14 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate, } const [previewFile, setPreviewFile] = useState(null) + const [previewFileUrl, setPreviewFileUrl] = useState('') + useEffect(() => { + if (previewFile) { + getAuthUrl(previewFile.url, 'download').then(setPreviewFileUrl) + } else { + setPreviewFileUrl('') + } + }, [previewFile?.url]) const [assignFileId, setAssignFileId] = useState(null) const handleAssign = async (fileId: number, data: { place_id?: number | null; reservation_id?: number | null }) => { @@ -322,8 +343,6 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate, if (file.reservation_id) allLinkedResIds.add(file.reservation_id) for (const rid of (file.linked_reservation_ids || [])) allLinkedResIds.add(rid) const linkedReservations = [...allLinkedResIds].map(rid => reservations?.find(r => r.id === rid)).filter(Boolean) - const fileUrl = authUrl(file.url) - return (
{/* Icon or thumbnail */}
!isTrash && openFile({ ...file, url: fileUrl })} + onClick={() => !isTrash && openFile(file)} style={{ flexShrink: 0, width: 36, height: 36, borderRadius: 8, background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', @@ -345,7 +364,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate, }} > {isImage(file.mime_type) - ? + ? : (() => { const ext = (file.original_name || '').split('.').pop()?.toUpperCase() || '?' const isPdf = file.mime_type === 'application/pdf' @@ -366,7 +385,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate, )} {!isTrash && file.starred ? : null} !isTrash && openFile({ ...file, url: fileUrl })} + onClick={() => !isTrash && openFile(file)} style={{ fontWeight: 500, fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: isTrash ? 'default' : 'pointer' }} > {file.original_name} @@ -416,7 +435,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate, onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}> } - @@ -633,12 +652,13 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
{previewFile.original_name}
- e.currentTarget.style.color = 'var(--text-primary)'} - onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}> +

- PDF herunterladen +

diff --git a/client/src/components/Memories/MemoriesPanel.tsx b/client/src/components/Memories/MemoriesPanel.tsx index 2bc4f7d..b45c82e 100644 --- a/client/src/components/Memories/MemoriesPanel.tsx +++ b/client/src/components/Memories/MemoriesPanel.tsx @@ -3,6 +3,15 @@ import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPi import apiClient from '../../api/client' import { useAuthStore } from '../../store/authStore' import { useTranslation } from '../../i18n' +import { getAuthUrl } from '../../api/authUrl' + +function ImmichImg({ baseUrl, style, loading }: { baseUrl: string; style?: React.CSSProperties; loading?: 'lazy' | 'eager' }) { + const [src, setSrc] = useState('') + useEffect(() => { + getAuthUrl(baseUrl, 'immich').then(setSrc) + }, [baseUrl]) + return src ? : null +} // ── Types ─────────────────────────────────────────────────────────────────── @@ -110,6 +119,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const [lightboxUserId, setLightboxUserId] = useState(null) const [lightboxInfo, setLightboxInfo] = useState(null) const [lightboxInfoLoading, setLightboxInfoLoading] = useState(false) + const [lightboxOriginalSrc, setLightboxOriginalSrc] = useState('') // ── Init ────────────────────────────────────────────────────────────────── @@ -221,13 +231,8 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa // ── Helpers ─────────────────────────────────────────────────────────────── - const token = useAuthStore(s => s.token) - - const thumbnailUrl = (assetId: string, userId: number) => - `/api/integrations/immich/assets/${assetId}/thumbnail?userId=${userId}&token=${token}` - - const originalUrl = (assetId: string, userId: number) => - `/api/integrations/immich/assets/${assetId}/original?userId=${userId}&token=${token}` + const thumbnailBaseUrl = (assetId: string, userId: number) => + `/api/integrations/immich/assets/${assetId}/thumbnail?userId=${userId}` const ownPhotos = tripPhotos.filter(p => p.user_id === currentUser?.id) const othersPhotos = tripPhotos.filter(p => p.user_id !== currentUser?.id && p.shared) @@ -448,7 +453,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa outline: isSelected ? '3px solid var(--text-primary)' : 'none', outlineOffset: -3, }}> - {isSelected && (
{ setLightboxId(photo.immich_asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null) + setLightboxOriginalSrc('') + getAuthUrl(`/api/integrations/immich/assets/${photo.immich_asset_id}/original?userId=${photo.user_id}`, 'immich').then(setLightboxOriginalSrc) setLightboxInfoLoading(true) apiClient.get(`/integrations/immich/assets/${photo.immich_asset_id}/info?userId=${photo.user_id}`) .then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false)) }}> - {/* Other user's avatar */} @@ -748,7 +755,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
e.stopPropagation()} style={{ display: 'flex', gap: 16, alignItems: 'flex-start', justifyContent: 'center', padding: 20, width: '100%', height: '100%' }}> diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 1b062c7..4e1840c 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -737,7 +737,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ onClick={async () => { try { const res = await fetch(`/api/trips/${tripId}/export.ics`, { - headers: { 'Authorization': `Bearer ${localStorage.getItem('auth_token')}` }, + credentials: 'include', }) if (!res.ok) throw new Error() const blob = await res.blob() diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx index 05b76fd..f6c00a5 100644 --- a/client/src/components/Planner/PlaceInspector.tsx +++ b/client/src/components/Planner/PlaceInspector.tsx @@ -1,10 +1,5 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' - -function authUrl(url: string): string { - const token = localStorage.getItem('auth_token') - if (!token || !url) return url - return `${url}${url.includes('?') ? '&' : '?'}token=${token}` -} +import { getAuthUrl } from '../../api/authUrl' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Users, Mountain, TrendingUp } from 'lucide-react' @@ -587,11 +582,11 @@ export default function PlaceInspector({ {filesExpanded && placeFiles.length > 0 && ( )} diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index 5cac990..dde5c0d 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -257,7 +257,7 @@ const ar: Record = { 'settings.passwordRequired': 'أدخل كلمة المرور الحالية والجديدة', 'settings.passwordTooShort': 'يجب أن تتكون كلمة المرور من 8 أحرف على الأقل', 'settings.passwordMismatch': 'كلمتا المرور غير متطابقتين', - 'settings.passwordWeak': 'يجب أن تحتوي كلمة المرور على حرف كبير وحرف صغير ورقم', + 'settings.passwordWeak': 'يجب أن تحتوي كلمة المرور على حرف كبير وحرف صغير ورقم ورمز خاص', 'settings.passwordChanged': 'تم تغيير كلمة المرور بنجاح', 'settings.deleteAccount': 'حذف الحساب', 'settings.deleteAccountTitle': 'هل تريد حذف حسابك؟', @@ -356,7 +356,7 @@ const ar: Record = { // Register 'register.passwordMismatch': 'كلمتا المرور غير متطابقتين', - 'register.passwordTooShort': 'يجب أن تتكون كلمة المرور من 6 أحرف على الأقل', + 'register.passwordTooShort': 'يجب أن تتكون كلمة المرور من 8 أحرف على الأقل', 'register.failed': 'فشل التسجيل', 'register.getStarted': 'ابدأ الآن', 'register.subtitle': 'أنشئ حسابًا وابدأ التخطيط لرحلات أحلامك.', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 32bed17..97c1c69 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -226,7 +226,7 @@ const br: Record = { 'settings.passwordRequired': 'Informe a senha atual e a nova', 'settings.passwordTooShort': 'A senha deve ter pelo menos 8 caracteres', 'settings.passwordMismatch': 'As senhas não coincidem', - 'settings.passwordWeak': 'A senha deve ter maiúscula, minúscula e número', + 'settings.passwordWeak': 'A senha deve ter maiúscula, minúscula, número e um caractere especial', 'settings.passwordChanged': 'Senha alterada com sucesso', 'settings.deleteAccount': 'Excluir conta', 'settings.deleteAccountTitle': 'Excluir sua conta?', @@ -351,7 +351,7 @@ const br: Record = { // Register 'register.passwordMismatch': 'As senhas não coincidem', - 'register.passwordTooShort': 'A senha deve ter pelo menos 6 caracteres', + 'register.passwordTooShort': 'A senha deve ter pelo menos 8 caracteres', 'register.failed': 'Falha no cadastro', 'register.getStarted': 'Começar', 'register.subtitle': 'Crie uma conta e comece a planejar suas viagens.', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index 326ab00..e3d4cc6 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -204,7 +204,7 @@ const cs: Record = { 'settings.passwordRequired': 'Zadejte prosím současné i nové heslo', 'settings.passwordTooShort': 'Heslo musí mít alespoň 8 znaků', 'settings.passwordMismatch': 'Hesla se neshodují', - 'settings.passwordWeak': 'Heslo musí obsahovat velké a malé písmeno a číslici', + 'settings.passwordWeak': 'Heslo musí obsahovat velké a malé písmeno, číslici a speciální znak', 'settings.passwordChanged': 'Heslo bylo úspěšně změněno', 'settings.deleteAccount': 'Smazat účet', 'settings.deleteAccountTitle': 'Smazat váš účet?', @@ -351,7 +351,7 @@ const cs: Record = { // Registrace (Register) 'register.passwordMismatch': 'Hesla se neshodují', - 'register.passwordTooShort': 'Heslo musí mít alespoň 6 znaků', + 'register.passwordTooShort': 'Heslo musí mít alespoň 8 znaků', 'register.failed': 'Registrace se nezdařila', 'register.getStarted': 'Začínáme', 'register.subtitle': 'Vytvořte si účet a začněte plánovat svou vysněnou cestu.', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 7010c61..8e6b449 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -252,7 +252,7 @@ const de: Record = { 'settings.passwordRequired': 'Bitte aktuelles und neues Passwort eingeben', 'settings.passwordTooShort': 'Passwort muss mindestens 8 Zeichen lang sein', 'settings.passwordMismatch': 'Passwörter stimmen nicht überein', - 'settings.passwordWeak': 'Passwort muss Groß-, Kleinbuchstaben und eine Zahl enthalten', + 'settings.passwordWeak': 'Passwort muss Groß-, Kleinbuchstaben, eine Zahl und ein Sonderzeichen enthalten', 'settings.passwordChanged': 'Passwort erfolgreich geändert', 'settings.deleteAccount': 'Löschen', 'settings.deleteAccountTitle': 'Account wirklich löschen?', @@ -351,7 +351,7 @@ const de: Record = { // Register 'register.passwordMismatch': 'Passwörter stimmen nicht überein', - 'register.passwordTooShort': 'Passwort muss mindestens 6 Zeichen lang sein', + 'register.passwordTooShort': 'Passwort muss mindestens 8 Zeichen lang sein', 'register.failed': 'Registrierung fehlgeschlagen', 'register.getStarted': 'Jetzt starten', 'register.subtitle': 'Erstellen Sie ein Konto und beginnen Sie, Ihre Traumreisen zu planen.', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index ef12cda..632a8c3 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -251,7 +251,7 @@ const en: Record = { 'settings.passwordRequired': 'Please enter current and new password', 'settings.passwordTooShort': 'Password must be at least 8 characters', 'settings.passwordMismatch': 'Passwords do not match', - 'settings.passwordWeak': 'Password must contain uppercase, lowercase, and a number', + 'settings.passwordWeak': 'Password must contain uppercase, lowercase, a number, and a special character', 'settings.passwordChanged': 'Password changed successfully', 'settings.mustChangePassword': 'You must change your password before you can continue. Please set a new password below.', 'settings.deleteAccount': 'Delete account', @@ -351,7 +351,7 @@ const en: Record = { // Register 'register.passwordMismatch': 'Passwords do not match', - 'register.passwordTooShort': 'Password must be at least 6 characters', + 'register.passwordTooShort': 'Password must be at least 8 characters', 'register.failed': 'Registration failed', 'register.getStarted': 'Get Started', 'register.subtitle': 'Create an account and start planning your dream trips.', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index 8c29be0..408fc3d 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -349,7 +349,7 @@ const es: Record = { // Register 'register.passwordMismatch': 'Las contraseñas no coinciden', - 'register.passwordTooShort': 'La contraseña debe tener al menos 6 caracteres', + 'register.passwordTooShort': 'La contraseña debe tener al menos 8 caracteres', 'register.failed': 'Falló el registro', 'register.getStarted': 'Empezar', 'register.subtitle': 'Crea una cuenta y empieza a planificar tus viajes.', @@ -1440,7 +1440,7 @@ const es: Record = { // Settings (2.6.2) 'settings.currentPasswordRequired': 'La contraseña actual es obligatoria', - 'settings.passwordWeak': 'La contraseña debe contener mayúsculas, minúsculas y números', + 'settings.passwordWeak': 'La contraseña debe contener mayúsculas, minúsculas, números y un carácter especial', // Permissions 'admin.tabs.permissions': 'Permisos', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index fc011a7..5e31399 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -252,7 +252,7 @@ const fr: Record = { 'settings.passwordRequired': 'Veuillez saisir le mot de passe actuel et le nouveau', 'settings.passwordTooShort': 'Le mot de passe doit comporter au moins 8 caractères', 'settings.passwordMismatch': 'Les mots de passe ne correspondent pas', - 'settings.passwordWeak': 'Le mot de passe doit contenir des majuscules, des minuscules et un chiffre', + 'settings.passwordWeak': 'Le mot de passe doit contenir des majuscules, des minuscules, un chiffre et un caractère spécial', 'settings.passwordChanged': 'Mot de passe modifié avec succès', 'settings.deleteAccount': 'Supprimer le compte', 'settings.deleteAccountTitle': 'Supprimer votre compte ?', @@ -351,7 +351,7 @@ const fr: Record = { // Register 'register.passwordMismatch': 'Les mots de passe ne correspondent pas', - 'register.passwordTooShort': 'Le mot de passe doit comporter au moins 6 caractères', + 'register.passwordTooShort': 'Le mot de passe doit comporter au moins 8 caractères', 'register.failed': 'Échec de l\'inscription', 'register.getStarted': 'Commencer', 'register.subtitle': 'Créez un compte et commencez à planifier vos voyages de rêve.', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index c1c5630..fc516d4 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -202,7 +202,7 @@ const hu: Record = { 'settings.passwordRequired': 'Kérjük, add meg a jelenlegi és az új jelszót', 'settings.currentPasswordRequired': 'A jelenlegi jelszó megadása kötelező', 'settings.passwordTooShort': 'A jelszónak legalább 8 karakter hosszúnak kell lennie', - 'settings.passwordWeak': 'A jelszónak tartalmaznia kell nagybetűt, kisbetűt és számot', + 'settings.passwordWeak': 'A jelszónak tartalmaznia kell nagybetűt, kisbetűt, számot és speciális karaktert', 'settings.passwordMismatch': 'A jelszavak nem egyeznek', 'settings.passwordChanged': 'Jelszó sikeresen módosítva', 'settings.deleteAccount': 'Törlés', @@ -351,7 +351,7 @@ const hu: Record = { // Regisztráció 'register.passwordMismatch': 'A jelszavak nem egyeznek', - 'register.passwordTooShort': 'A jelszónak legalább 6 karakter hosszúnak kell lennie', + 'register.passwordTooShort': 'A jelszónak legalább 8 karakter hosszúnak kell lennie', 'register.failed': 'Regisztráció sikertelen', 'register.getStarted': 'Kezdjük', 'register.subtitle': 'Hozz létre egy fiókot, és kezdd el megtervezni álomutazásaidat.', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index 0cb96ea..7309550 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -203,7 +203,7 @@ const it: Record = { 'settings.passwordRequired': 'Inserisci la password attuale e quella nuova', 'settings.passwordTooShort': 'La password deve contenere almeno 8 caratteri', 'settings.passwordMismatch': 'Le password non corrispondono', - 'settings.passwordWeak': 'La password deve contenere lettere maiuscole, minuscole e un numero', + 'settings.passwordWeak': 'La password deve contenere lettere maiuscole, minuscole, un numero e un carattere speciale', 'settings.passwordChanged': 'Password cambiata con successo', 'settings.deleteAccount': 'Elimina account', 'settings.deleteAccountTitle': 'Eliminare il tuo account?', @@ -351,7 +351,7 @@ const it: Record = { // Register 'register.passwordMismatch': 'Le password non corrispondono', - 'register.passwordTooShort': 'La password deve contenere almeno 6 caratteri', + 'register.passwordTooShort': 'La password deve contenere almeno 8 caratteri', 'register.failed': 'Registrazione fallita', 'register.getStarted': 'Inizia', 'register.subtitle': 'Crea un account e inizia a programmare i viaggi dei tuoi sogni.', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index ee8b6ba..f7d2bb0 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -252,7 +252,7 @@ const nl: Record = { 'settings.passwordRequired': 'Voer het huidige en nieuwe wachtwoord in', 'settings.passwordTooShort': 'Wachtwoord moet minimaal 8 tekens bevatten', 'settings.passwordMismatch': 'Wachtwoorden komen niet overeen', - 'settings.passwordWeak': 'Wachtwoord moet hoofdletters, kleine letters en een cijfer bevatten', + 'settings.passwordWeak': 'Wachtwoord moet hoofdletters, kleine letters, een cijfer en een speciaal teken bevatten', 'settings.passwordChanged': 'Wachtwoord succesvol gewijzigd', 'settings.deleteAccount': 'Account verwijderen', 'settings.deleteAccountTitle': 'Account verwijderen?', @@ -351,7 +351,7 @@ const nl: Record = { // Register 'register.passwordMismatch': 'Wachtwoorden komen niet overeen', - 'register.passwordTooShort': 'Wachtwoord moet minimaal 6 tekens bevatten', + 'register.passwordTooShort': 'Wachtwoord moet minimaal 8 tekens bevatten', 'register.failed': 'Registratie mislukt', 'register.getStarted': 'Aan de slag', 'register.subtitle': 'Maak een account aan en begin met het plannen van je droomreizen.', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 5264729..876e9d1 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -252,7 +252,7 @@ const ru: Record = { 'settings.passwordRequired': 'Введите текущий и новый пароль', 'settings.passwordTooShort': 'Пароль должен содержать не менее 8 символов', 'settings.passwordMismatch': 'Пароли не совпадают', - 'settings.passwordWeak': 'Пароль должен содержать заглавные, строчные буквы и цифру', + 'settings.passwordWeak': 'Пароль должен содержать заглавные, строчные буквы, цифру и специальный символ', 'settings.passwordChanged': 'Пароль успешно изменён', 'settings.deleteAccount': 'Удалить аккаунт', 'settings.deleteAccountTitle': 'Удалить ваш аккаунт?', @@ -351,7 +351,7 @@ const ru: Record = { // Register 'register.passwordMismatch': 'Пароли не совпадают', - 'register.passwordTooShort': 'Пароль должен содержать не менее 6 символов', + 'register.passwordTooShort': 'Пароль должен содержать не менее 8 символов', 'register.failed': 'Ошибка регистрации', 'register.getStarted': 'Начать', 'register.subtitle': 'Создайте аккаунт и начните планировать поездки мечты.', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index b78b196..92ac821 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -252,7 +252,7 @@ const zh: Record = { 'settings.passwordRequired': '请输入当前密码和新密码', 'settings.passwordTooShort': '密码至少需要 8 个字符', 'settings.passwordMismatch': '两次输入的密码不一致', - 'settings.passwordWeak': '密码必须包含大写字母、小写字母和数字', + 'settings.passwordWeak': '密码必须包含大写字母、小写字母、数字和特殊字符', 'settings.passwordChanged': '密码修改成功', 'settings.deleteAccount': '删除账户', 'settings.deleteAccountTitle': '确定删除账户?', @@ -351,7 +351,7 @@ const zh: Record = { // Register 'register.passwordMismatch': '两次输入的密码不一致', - 'register.passwordTooShort': '密码至少需要 6 个字符', + 'register.passwordTooShort': '密码至少需要 8 个字符', 'register.failed': '注册失败', 'register.getStarted': '开始使用', 'register.subtitle': '创建账户,开始规划你的梦想旅行。', diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx index d195d03..54f7acf 100644 --- a/client/src/pages/AdminPage.tsx +++ b/client/src/pages/AdminPage.tsx @@ -17,7 +17,7 @@ import PackingTemplateManager from '../components/Admin/PackingTemplateManager' import AuditLogPanel from '../components/Admin/AuditLogPanel' import AdminMcpTokensPanel from '../components/Admin/AdminMcpTokensPanel' import PermissionsPanel from '../components/Admin/PermissionsPanel' -import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, AlertTriangle, RefreshCw, GitBranch, Sun, Link2, Copy, Plus } from 'lucide-react' +import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, GitBranch, Sun, Link2, Copy, Plus, RefreshCw, AlertTriangle } from 'lucide-react' import CustomSelect from '../components/shared/CustomSelect' interface AdminUser { @@ -45,6 +45,7 @@ interface OidcConfig { client_secret_set: boolean display_name: string oidc_only: boolean + discovery_url: string } interface UpdateInfo { @@ -85,7 +86,7 @@ export default function AdminPage(): React.ReactElement { useEffect(() => { adminApi.getBagTracking().then(d => setBagTrackingEnabled(d.enabled)).catch(() => {}) }, []) // OIDC config - const [oidcConfig, setOidcConfig] = useState({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '', oidc_only: false }) + const [oidcConfig, setOidcConfig] = useState({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '', oidc_only: false, discovery_url: '' }) const [savingOidc, setSavingOidc] = useState(false) // Registration toggle @@ -122,13 +123,14 @@ export default function AdminPage(): React.ReactElement { // Version check & update const [updateInfo, setUpdateInfo] = useState(null) const [showUpdateModal, setShowUpdateModal] = useState(false) - const [updating, setUpdating] = useState(false) - const [updateResult, setUpdateResult] = useState<'success' | 'error' | null>(null) - const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore() + const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled, logout } = useAuthStore() const navigate = useNavigate() const toast = useToast() + const [showRotateJwtModal, setShowRotateJwtModal] = useState(false) + const [rotatingJwt, setRotatingJwt] = useState(false) + useEffect(() => { loadData() loadAppConfig() @@ -178,26 +180,6 @@ export default function AdminPage(): React.ReactElement { } } - const handleInstallUpdate = async () => { - setUpdating(true) - setUpdateResult(null) - try { - await adminApi.installUpdate() - setUpdateResult('success') - // Server is restarting — poll until it comes back, then reload - const poll = setInterval(async () => { - try { - await authApi.getAppConfig() - clearInterval(poll) - window.location.reload() - } catch { /* still restarting */ } - }, 2000) - } catch { - setUpdateResult('error') - setUpdating(false) - } - } - const handleToggleRegistration = async (value) => { setAllowRegistration(value) try { @@ -272,6 +254,10 @@ export default function AdminPage(): React.ReactElement { toast.error(t('admin.toast.fieldsRequired')) return } + if (createForm.password.trim().length < 8) { + toast.error(t('settings.passwordTooShort')) + return + } try { const data = await adminApi.createUser(createForm) setUsers(prev => [data.user, ...prev]) @@ -327,7 +313,13 @@ export default function AdminPage(): React.ReactElement { email: editForm.email.trim() || undefined, role: editForm.role, } - if (editForm.password.trim()) payload.password = editForm.password.trim() + if (editForm.password.trim()) { + if (editForm.password.trim().length < 8) { + toast.error(t('settings.passwordTooShort')) + return + } + payload.password = editForm.password.trim() + } const data = await adminApi.updateUser(editingUser.id, payload) setUsers(prev => prev.map(u => u.id === editingUser.id ? data.user : u)) setEditingUser(null) @@ -395,23 +387,13 @@ export default function AdminPage(): React.ReactElement { {t('admin.update.button')} )} - {updateInfo.is_docker ? ( - - ) : ( - - )} +
)} @@ -914,6 +896,17 @@ export default function AdminPage(): React.ReactElement { />

{t('admin.oidcIssuerHint')}

+
+ + setOidcConfig(c => ({ ...c, discovery_url: e.target.value }))} + placeholder='https://auth.example.com/application/o/trek/.well-known/openid-configuration' + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" + /> +

Override the auto-constructed discovery URL. Required for providers like Authentik where the endpoint is not at {'/.well-known/openid-configuration'}.

+
{ setSavingOidc(true) try { - const payload: Record = { issuer: oidcConfig.issuer, client_id: oidcConfig.client_id, display_name: oidcConfig.display_name, oidc_only: oidcConfig.oidc_only } + const payload: Record = { issuer: oidcConfig.issuer, client_id: oidcConfig.client_id, display_name: oidcConfig.display_name, oidc_only: oidcConfig.oidc_only, discovery_url: oidcConfig.discovery_url } if (oidcConfig.client_secret) payload.client_secret = oidcConfig.client_secret await adminApi.updateOidc(payload) toast.success(t('admin.oidcSaved')) @@ -1155,6 +1148,31 @@ export default function AdminPage(): React.ReactElement {
+ + {/* Danger Zone */} +
+
+

+ + Danger Zone +

+
+
+
+
+

Rotate JWT Secret

+

Generate a new JWT signing secret. All active sessions will be invalidated immediately.

+
+ +
+
+
)} @@ -1304,78 +1322,37 @@ export default function AdminPage(): React.ReactElement { )} - {/* Update confirmation popup — matches backup restore style */} + {/* Update instructions popup */} {showUpdateModal && (
{ if (!updating) setShowUpdateModal(false) }} + onClick={() => setShowUpdateModal(false)} >
e.stopPropagation()} style={{ width: '100%', maxWidth: 440, borderRadius: 16, overflow: 'hidden' }} className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700" > - {updateResult === 'success' ? ( - <> -
-
- -
-
-

{t('admin.update.success')}

-
-
-
- -

{t('admin.update.reloadHint')}

-
- - ) : updateResult === 'error' ? ( - <> -
-
- -
-
-

{t('admin.update.failed')}

-
-
-
- -
- - ) : ( - <> - {/* Red header */} -
-
- -
-
-

{t('admin.update.confirmTitle')}

-

- v{updateInfo?.current} → v{updateInfo?.latest} -

-
-
+
+
+ +
+
+

{t('admin.update.howTo')}

+

+ v{updateInfo?.current} → v{updateInfo?.latest} +

+
+
- {/* Body */} -
- {updateInfo?.is_docker ? ( - <> -

- {t('admin.update.dockerText').replace('{version}', `v${updateInfo.latest}`)} -

+
+

+ {t('admin.update.dockerText').replace('{version}', `v${updateInfo?.latest ?? ''}`)} +

-
+
{`docker pull mauriceboe/nomad:latest docker stop nomad && docker rm nomad docker run -d --name nomad \\ @@ -1384,90 +1361,93 @@ docker run -d --name nomad \\ -v /opt/nomad/uploads:/app/uploads \\ --restart unless-stopped \\ mauriceboe/nomad:latest`} -
+
-
-
- - {t('admin.update.dataInfo')} -
-
- - ) : ( - <> -

- {updateInfo && t('admin.update.confirmText').replace('{current}', `v${updateInfo.current}`).replace('{version}', `v${updateInfo.latest}`)} -

- -
-
- - {t('admin.update.dataInfo')} -
-
- -
-
- - - {t('admin.update.backupHint')}{' '} - - -
-
- -
-
- - {t('admin.update.warning')} -
-
- - )} +
+
+ + {t('admin.update.dataInfo')}
+
- {/* Footer */} -
- - {!updateInfo?.is_docker && ( - - )} + {updateInfo?.release_url && ( + - - )} + )} +
+ +
+ +
)} + + {/* Rotate JWT Secret confirmation modal */} + setShowRotateJwtModal(false)} + title="Rotate JWT Secret" + size="sm" + footer={ +
+ + +
+ } + > +
+
+ +
+
+

Warning, this will invalidate all sessions and log you out.

+

A new JWT secret will be generated immediately. Every logged-in user — including you — will be signed out and will need to log in again.

+
+
+
) } diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index 47e244c..1474838 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -55,11 +55,11 @@ export default function LoginPage(): React.ReactElement { if (oidcCode) { setIsLoading(true) window.history.replaceState({}, '', '/login') - fetch('/api/auth/oidc/exchange?code=' + encodeURIComponent(oidcCode)) + fetch('/api/auth/oidc/exchange?code=' + encodeURIComponent(oidcCode), { credentials: 'include' }) .then(r => r.json()) - .then(data => { + .then(async data => { if (data.token) { - localStorage.setItem('auth_token', data.token) + await loadUser() navigate('/dashboard', { replace: true }) } else { setError(data.error || 'OIDC login failed') @@ -150,7 +150,7 @@ export default function LoginPage(): React.ReactElement { } if (mode === 'register') { if (!username.trim()) { setError('Username is required'); setIsLoading(false); return } - if (password.length < 6) { setError('Password must be at least 6 characters'); setIsLoading(false); return } + if (password.length < 8) { setError('Password must be at least 8 characters'); setIsLoading(false); return } await register(username, email, password, inviteToken || undefined) } else { const result = await login(email, password) diff --git a/client/src/pages/RegisterPage.tsx b/client/src/pages/RegisterPage.tsx index 762d1f1..824a615 100644 --- a/client/src/pages/RegisterPage.tsx +++ b/client/src/pages/RegisterPage.tsx @@ -26,7 +26,7 @@ export default function RegisterPage(): React.ReactElement { return } - if (password.length < 6) { + if (password.length < 8) { setError(t('register.passwordTooShort')) return } diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx index 94f46d9..853b04f 100644 --- a/client/src/pages/SettingsPage.tsx +++ b/client/src/pages/SettingsPage.tsx @@ -147,7 +147,8 @@ export default function SettingsPage(): React.ReactElement { const handleSaveImmich = async () => { setSaving(s => ({ ...s, immich: true })) try { - await apiClient.put('/integrations/immich/settings', { immich_url: immichUrl, immich_api_key: immichApiKey || undefined }) + const saveRes = await apiClient.put('/integrations/immich/settings', { immich_url: immichUrl, immich_api_key: immichApiKey || undefined }) + if (saveRes.data.warning) toast.warn(saveRes.data.warning) toast.success(t('memories.saved')) const res = await apiClient.get('/integrations/immich/status') setImmichConnected(res.data.connected) diff --git a/client/src/store/authStore.ts b/client/src/store/authStore.ts index 9fbad53..a98bd65 100644 --- a/client/src/store/authStore.ts +++ b/client/src/store/authStore.ts @@ -17,7 +17,6 @@ interface AvatarResponse { interface AuthState { user: User | null - token: string | null isAuthenticated: boolean isLoading: boolean error: string | null @@ -49,9 +48,8 @@ interface AuthState { export const useAuthStore = create((set, get) => ({ user: null, - token: localStorage.getItem('auth_token') || null, - isAuthenticated: !!localStorage.getItem('auth_token'), - isLoading: false, + isAuthenticated: false, + isLoading: true, error: null, demoMode: localStorage.getItem('demo_mode') === 'true', hasMapsKey: false, @@ -67,15 +65,13 @@ export const useAuthStore = create((set, get) => ({ set({ isLoading: false, error: null }) return { mfa_required: true as const, mfa_token: data.mfa_token } } - localStorage.setItem('auth_token', data.token) set({ user: data.user, - token: data.token, isAuthenticated: true, isLoading: false, error: null, }) - connect(data.token) + connect() return data as AuthResponse } catch (err: unknown) { const error = getApiErrorMessage(err, 'Login failed') @@ -88,15 +84,13 @@ export const useAuthStore = create((set, get) => ({ set({ isLoading: true, error: null }) try { const data = await authApi.verifyMfaLogin({ mfa_token: mfaToken, code: code.replace(/\s/g, '') }) - localStorage.setItem('auth_token', data.token) set({ user: data.user, - token: data.token, isAuthenticated: true, isLoading: false, error: null, }) - connect(data.token) + connect() return data as AuthResponse } catch (err: unknown) { const error = getApiErrorMessage(err, 'Verification failed') @@ -109,15 +103,13 @@ export const useAuthStore = create((set, get) => ({ set({ isLoading: true, error: null }) try { const data = await authApi.register({ username, email, password, invite_token }) - localStorage.setItem('auth_token', data.token) set({ user: data.user, - token: data.token, isAuthenticated: true, isLoading: false, error: null, }) - connect(data.token) + connect() return data } catch (err: unknown) { const error = getApiErrorMessage(err, 'Registration failed') @@ -128,7 +120,8 @@ export const useAuthStore = create((set, get) => ({ logout: () => { disconnect() - localStorage.removeItem('auth_token') + // Tell server to clear the httpOnly cookie + fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {}) // Clear service worker caches containing sensitive data if ('caches' in window) { caches.delete('api-data').catch(() => {}) @@ -136,7 +129,6 @@ export const useAuthStore = create((set, get) => ({ } set({ user: null, - token: null, isAuthenticated: false, error: null, }) @@ -144,11 +136,6 @@ export const useAuthStore = create((set, get) => ({ loadUser: async (opts?: { silent?: boolean }) => { const silent = !!opts?.silent - const token = get().token - if (!token) { - if (!silent) set({ isLoading: false }) - return - } if (!silent) set({ isLoading: true }) try { const data = await authApi.me() @@ -157,16 +144,14 @@ export const useAuthStore = create((set, get) => ({ isAuthenticated: true, isLoading: false, }) - connect(token) + connect() } catch (err: unknown) { // Only clear auth state on 401 (invalid/expired token), not on network errors const isAuthError = err && typeof err === 'object' && 'response' in err && (err as { response?: { status?: number } }).response?.status === 401 if (isAuthError) { - localStorage.removeItem('auth_token') set({ user: null, - token: null, isAuthenticated: false, isLoading: false, }) @@ -233,16 +218,14 @@ export const useAuthStore = create((set, get) => ({ set({ isLoading: true, error: null }) try { const data = await authApi.demoLogin() - localStorage.setItem('auth_token', data.token) set({ user: data.user, - token: data.token, isAuthenticated: true, isLoading: false, demoMode: true, error: null, }) - connect(data.token) + connect() return data } catch (err: unknown) { const error = getApiErrorMessage(err, 'Demo login failed') diff --git a/docker-compose.yml b/docker-compose.yml index 37e1123..6c2ccba 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,12 +18,13 @@ services: environment: - NODE_ENV=production - PORT=3000 - - JWT_SECRET=${JWT_SECRET:-} # Auto-generated if not set; persist across restarts for stable sessions + - 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 - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links - FORCE_HTTPS=true # Redirect HTTP to HTTPS when behind a TLS-terminating proxy - TRUST_PROXY=1 # Number of trusted proxies (for X-Forwarded-For / real client IP) + - 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. - 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 diff --git a/server/.env.example b/server/.env.example index 2aae179..1dcabc0 100644 --- a/server/.env.example +++ b/server/.env.example @@ -1,12 +1,19 @@ PORT=3001 # Port to run the server on NODE_ENV=development # development = development mode; production = production mode -JWT_SECRET=your-super-secret-jwt-key-change-in-production # Auto-generated if not set; persist across restarts for stable sessions +# ENCRYPTION_KEY= # Separate key for encrypting stored secrets (API keys, MFA, SMTP, OIDC, etc.) +# Auto-generated and persisted to ./data/.encryption_key if not set. +# Upgrade from a version that used JWT_SECRET for encryption: set to your old JWT_SECRET value so +# existing encrypted data remains readable, then re-save credentials via the admin panel. +# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" TZ=UTC # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin) LOG_LEVEL=info # info = concise user actions; debug = verbose admin-level details ALLOWED_ORIGINS=https://trek.example.com # Comma-separated origins for CORS and email links FORCE_HTTPS=false # Redirect HTTP → HTTPS behind a TLS proxy TRUST_PROXY=1 # Number of trusted proxies for X-Forwarded-For +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. + +APP_URL=https://trek.example.com # Base URL of this instance — required when OIDC is enabled; must match the redirect URI registered with your IdP OIDC_ISSUER=https://auth.example.com # OpenID Connect provider URL OIDC_CLIENT_ID=trek # OpenID Connect client ID @@ -15,5 +22,6 @@ OIDC_DISPLAY_NAME=SSO # Label shown on the SSO login button OIDC_ONLY=true # Disable local password auth entirely (SSO only) 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_DISCOVERY_URL= # Override the auto-constructed discovery endpoint (e.g. Authentik: https://auth.example.com/application/o/trek/.well-known/openid-configuration) DEMO_MODE=false # Demo mode - resets data hourly diff --git a/server/package-lock.json b/server/package-lock.json index f4af0a6..fb46ba6 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -12,6 +12,7 @@ "archiver": "^6.0.1", "bcryptjs": "^2.4.3", "better-sqlite3": "^12.8.0", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.4.1", "express": "^4.18.3", @@ -34,6 +35,7 @@ "@types/archiver": "^7.0.0", "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.13", + "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.19", "@types/express": "^4.17.25", "@types/jsonwebtoken": "^9.0.10", @@ -914,6 +916,16 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -1713,6 +1725,25 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", diff --git a/server/package.json b/server/package.json index 27b8210..20030bc 100644 --- a/server/package.json +++ b/server/package.json @@ -11,6 +11,7 @@ "archiver": "^6.0.1", "bcryptjs": "^2.4.3", "better-sqlite3": "^12.8.0", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.4.1", "express": "^4.18.3", @@ -33,6 +34,7 @@ "@types/archiver": "^7.0.0", "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.13", + "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.19", "@types/express": "^4.17.25", "@types/jsonwebtoken": "^9.0.10", diff --git a/server/scripts/migrate-encryption.ts b/server/scripts/migrate-encryption.ts new file mode 100644 index 0000000..f5d788f --- /dev/null +++ b/server/scripts/migrate-encryption.ts @@ -0,0 +1,298 @@ +/** + * Encryption key migration script. + * + * Re-encrypts all at-rest secrets in the TREK database from one ENCRYPTION_KEY + * to another without requiring the application to be running. + * + * Usage (host): + * cd server + * node --import tsx scripts/migrate-encryption.ts + * + * Usage (Docker): + * docker exec -it trek node --import tsx scripts/migrate-encryption.ts + * + * The script will prompt for the old and new keys interactively so they never + * appear in shell history, process arguments, or log output. + */ + +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import readline from 'readline'; +import Database from 'better-sqlite3'; + +// --------------------------------------------------------------------------- +// Crypto helpers — mirrors apiKeyCrypto.ts and mfaCrypto.ts but with +// explicit key arguments so the script is independent of config.ts / env vars. +// --------------------------------------------------------------------------- + +const ENCRYPTED_PREFIX = 'enc:v1:'; + +function apiKey(encryptionKey: string): Buffer { + return crypto.createHash('sha256').update(`${encryptionKey}:api_keys:v1`).digest(); +} + +function mfaKey(encryptionKey: string): Buffer { + return crypto.createHash('sha256').update(`${encryptionKey}:mfa:v1`).digest(); +} + +function encryptApiKey(plain: string, encryptionKey: string): string { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', apiKey(encryptionKey), iv); + const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return `${ENCRYPTED_PREFIX}${Buffer.concat([iv, tag, enc]).toString('base64')}`; +} + +function decryptApiKey(value: string, encryptionKey: string): string | null { + if (!value.startsWith(ENCRYPTED_PREFIX)) return null; + try { + const buf = Buffer.from(value.slice(ENCRYPTED_PREFIX.length), 'base64'); + const decipher = crypto.createDecipheriv('aes-256-gcm', apiKey(encryptionKey), buf.subarray(0, 12)); + decipher.setAuthTag(buf.subarray(12, 28)); + return Buffer.concat([decipher.update(buf.subarray(28)), decipher.final()]).toString('utf8'); + } catch { + return null; + } +} + +function encryptMfa(plain: string, encryptionKey: string): string { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', mfaKey(encryptionKey), iv); + const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return Buffer.concat([iv, tag, enc]).toString('base64'); +} + +function decryptMfa(value: string, encryptionKey: string): string | null { + try { + const buf = Buffer.from(value, 'base64'); + if (buf.length < 28) return null; + const decipher = crypto.createDecipheriv('aes-256-gcm', mfaKey(encryptionKey), buf.subarray(0, 12)); + decipher.setAuthTag(buf.subarray(12, 28)); + return Buffer.concat([decipher.update(buf.subarray(28)), decipher.final()]).toString('utf8'); + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Prompt helpers +// --------------------------------------------------------------------------- +// A single readline interface is shared for the entire script lifetime so +// stdin is never paused between prompts. +// +// Lines are collected into a queue as soon as readline emits them — this +// prevents the race where a line event fires before the next listener is +// registered (common with piped / pasted input that arrives all at once). + +const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + +const lineQueue: string[] = []; +const lineWaiters: ((line: string) => void)[] = []; + +rl.on('line', (line) => { + if (lineWaiters.length > 0) { + lineWaiters.shift()!(line); + } else { + lineQueue.push(line); + } +}); + +function nextLine(): Promise { + return new Promise((resolve) => { + if (lineQueue.length > 0) { + resolve(lineQueue.shift()!); + } else { + lineWaiters.push(resolve); + } + }); +} + +// Muted prompt — typed/pasted characters are not echoed. +// _writeToOutput is suppressed only while waiting for this line. +async function promptSecret(question: string): Promise { + process.stdout.write(question); + (rl as any)._writeToOutput = () => {}; + const line = await nextLine(); + (rl as any)._writeToOutput = (s: string) => process.stdout.write(s); + process.stdout.write('\n'); + return line.trim(); +} + +async function prompt(question: string): Promise { + process.stdout.write(question); + const line = await nextLine(); + return line.trim(); +} + +// --------------------------------------------------------------------------- +// Migration +// --------------------------------------------------------------------------- + +interface MigrationResult { + migrated: number; + alreadyMigrated: number; + skipped: number; + errors: string[]; +} + +async function main() { + console.log('=== TREK Encryption Key Migration ===\n'); + console.log('This script re-encrypts all stored secrets under a new ENCRYPTION_KEY.'); + console.log('A backup of the database will be created before any changes are made.\n'); + + // Resolve DB path + const dbPath = path.resolve( + process.env.DB_PATH ?? path.join(__dirname, '../data/travel.db') + ); + + if (!fs.existsSync(dbPath)) { + console.error(`Database not found at: ${dbPath}`); + console.error('Set DB_PATH env var if your database is in a non-standard location.'); + process.exit(1); + } + + console.log(`Database: ${dbPath}\n`); + + // Collect keys interactively + const oldKey = await promptSecret('Old ENCRYPTION_KEY: '); + const newKey = await promptSecret('New ENCRYPTION_KEY: '); + + if (!oldKey || !newKey) { + rl.close(); + console.error('Both keys are required.'); + process.exit(1); + } + + if (oldKey === newKey) { + rl.close(); + console.error('Old and new keys are identical — nothing to do.'); + process.exit(0); + } + + // Confirm + const confirm = await prompt('\nProceed with migration? This will modify the database. Type "yes" to confirm: '); + if (confirm.trim().toLowerCase() !== 'yes') { + rl.close(); + console.log('Aborted.'); + process.exit(0); + } + + // Backup + const backupPath = `${dbPath}.backup-${Date.now()}`; + fs.copyFileSync(dbPath, backupPath); + console.log(`\nBackup created: ${backupPath}`); + + const db = new Database(dbPath); + db.pragma('journal_mode = WAL'); + + const result: MigrationResult = { migrated: 0, alreadyMigrated: 0, skipped: 0, errors: [] }; + + // Helper: migrate a single api-key-style value (enc:v1: prefix) + function migrateApiKeyValue(raw: string, label: string): string | null { + if (!raw || !raw.startsWith(ENCRYPTED_PREFIX)) { + result.skipped++; + console.warn(` SKIP ${label}: not an encrypted value (missing enc:v1: prefix)`); + return null; + } + + const plain = decryptApiKey(raw, oldKey); + if (plain !== null) { + result.migrated++; + return encryptApiKey(plain, newKey); + } + + // Try new key — already migrated? + const check = decryptApiKey(raw, newKey); + if (check !== null) { + result.alreadyMigrated++; + return null; // no change needed + } + + result.errors.push(`${label}: decryption failed with both keys`); + console.error(` ERROR ${label}: could not decrypt with either key — skipping`); + return null; + } + + // Helper: migrate a single MFA value (no prefix, raw base64) + function migrateMfaValue(raw: string, label: string): string | null { + if (!raw) { result.skipped++; return null; } + + const plain = decryptMfa(raw, oldKey); + if (plain !== null) { + result.migrated++; + return encryptMfa(plain, newKey); + } + + const check = decryptMfa(raw, newKey); + if (check !== null) { + result.alreadyMigrated++; + return null; + } + + result.errors.push(`${label}: decryption failed with both keys`); + console.error(` ERROR ${label}: could not decrypt with either key — skipping`); + return null; + } + + db.transaction(() => { + // --- app_settings: oidc_client_secret, smtp_pass --- + for (const key of ['oidc_client_secret', 'smtp_pass']) { + const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined; + if (!row?.value) continue; + const newVal = migrateApiKeyValue(row.value, `app_settings.${key}`); + if (newVal !== null) { + db.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(newVal, key); + } + } + + // --- users: maps_api_key, openweather_api_key, immich_api_key --- + const apiKeyColumns = ['maps_api_key', 'openweather_api_key', 'immich_api_key']; + const users = db.prepare('SELECT id FROM users').all() as { id: number }[]; + + for (const user of users) { + const row = db.prepare(`SELECT ${apiKeyColumns.join(', ')} FROM users WHERE id = ?`).get(user.id) as Record; + + for (const col of apiKeyColumns) { + if (!row[col]) continue; + const newVal = migrateApiKeyValue(row[col], `users[${user.id}].${col}`); + if (newVal !== null) { + db.prepare(`UPDATE users SET ${col} = ? WHERE id = ?`).run(newVal, user.id); + } + } + + // mfa_secret (mfa crypto) + const mfaRow = db.prepare('SELECT mfa_secret FROM users WHERE id = ? AND mfa_secret IS NOT NULL').get(user.id) as { mfa_secret: string } | undefined; + if (mfaRow?.mfa_secret) { + const newVal = migrateMfaValue(mfaRow.mfa_secret, `users[${user.id}].mfa_secret`); + if (newVal !== null) { + db.prepare('UPDATE users SET mfa_secret = ? WHERE id = ?').run(newVal, user.id); + } + } + } + })(); + + db.close(); + rl.close(); + + console.log('\n=== Migration complete ==='); + console.log(` Migrated: ${result.migrated}`); + console.log(` Already on new key: ${result.alreadyMigrated}`); + console.log(` Skipped (empty): ${result.skipped}`); + if (result.errors.length > 0) { + console.warn(` Errors: ${result.errors.length}`); + result.errors.forEach(e => console.warn(` - ${e}`)); + console.warn('\nSome secrets could not be migrated. Check the errors above.'); + console.warn(`Your original database is backed up at: ${backupPath}`); + process.exit(1); + } else { + console.log('\nAll secrets successfully re-encrypted.'); + console.log(`Backup retained at: ${backupPath}`); + } +} + +main().catch((err) => { + console.error('Unexpected error:', err); + process.exit(1); +}); diff --git a/server/src/config.ts b/server/src/config.ts index adbc559..9a77477 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -2,27 +2,99 @@ import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; -let JWT_SECRET: string = process.env.JWT_SECRET || ''; +const dataDir = path.resolve(__dirname, '../data'); -if (!JWT_SECRET) { - const dataDir = path.resolve(__dirname, '../data'); - const secretFile = path.join(dataDir, '.jwt_secret'); +// JWT_SECRET is always managed by the server — auto-generated on first start and +// persisted to data/.jwt_secret. Use the admin panel to rotate it; do not set it +// via environment variable (env var would override a rotation on next restart). +const jwtSecretFile = path.join(dataDir, '.jwt_secret'); +let _jwtSecret: string; +try { + _jwtSecret = fs.readFileSync(jwtSecretFile, 'utf8').trim(); +} catch { + _jwtSecret = crypto.randomBytes(32).toString('hex'); try { - JWT_SECRET = fs.readFileSync(secretFile, 'utf8').trim(); - } catch { - JWT_SECRET = crypto.randomBytes(32).toString('hex'); - try { - if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); - fs.writeFileSync(secretFile, JWT_SECRET, { mode: 0o600 }); - console.log('Generated and saved JWT secret to', secretFile); - } catch (writeErr: unknown) { - console.warn('WARNING: Could not persist JWT secret to disk:', writeErr instanceof Error ? writeErr.message : writeErr); - console.warn('Sessions will reset on server restart. Set JWT_SECRET env var for persistent sessions.'); - } + if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); + fs.writeFileSync(jwtSecretFile, _jwtSecret, { mode: 0o600 }); + console.log('Generated and saved JWT secret to', jwtSecretFile); + } catch (writeErr: unknown) { + console.warn('WARNING: Could not persist JWT secret to disk:', writeErr instanceof Error ? writeErr.message : writeErr); + console.warn('Sessions will reset on server restart.'); } } -const JWT_SECRET_IS_GENERATED = !process.env.JWT_SECRET; +// export let so TypeScript's CJS output keeps exports.JWT_SECRET live +// (generates `exports.JWT_SECRET = JWT_SECRET = newVal` inside updateJwtSecret) +export let JWT_SECRET = _jwtSecret; -export { JWT_SECRET, JWT_SECRET_IS_GENERATED }; +// Called by the admin rotate-jwt-secret endpoint to update the in-process +// binding that all middleware and route files reference. +export function updateJwtSecret(newSecret: string): void { + JWT_SECRET = newSecret; +} + +// ENCRYPTION_KEY is used to derive at-rest encryption keys for stored secrets +// (API keys, MFA TOTP secrets, SMTP password, OIDC client secret, etc.). +// Keeping it separate from JWT_SECRET means you can rotate session tokens without +// invalidating all stored encrypted data, and vice-versa. +// +// Resolution order: +// 1. ENCRYPTION_KEY env var — explicit, always takes priority. +// 2. data/.encryption_key file — present on any install that has started at +// least once (written automatically by cases 1b and 3 below). +// 3. data/.jwt_secret — one-time fallback for existing installs upgrading +// without a pre-set ENCRYPTION_KEY. The value is immediately persisted to +// data/.encryption_key so JWT rotation can never break decryption later. +// 4. Auto-generated — fresh install with none of the above; persisted to +// data/.encryption_key. +const encKeyFile = path.join(dataDir, '.encryption_key'); +let _encryptionKey: string = process.env.ENCRYPTION_KEY || ''; + +if (_encryptionKey) { + // Env var is set explicitly — persist it to file so the value survives + // container restarts even if the env var is later removed. + try { + if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); + fs.writeFileSync(encKeyFile, _encryptionKey, { mode: 0o600 }); + } catch { + // Non-fatal: env var is the source of truth when set. + } +} else { + // Try the dedicated key file first (covers all installs after first start). + try { + _encryptionKey = fs.readFileSync(encKeyFile, 'utf8').trim(); + } catch { + // File not found — first start on an existing or fresh install. + } + + if (!_encryptionKey) { + // One-time migration: existing install upgrading for the first time. + // Use the JWT secret as the encryption key and immediately write it to + // .encryption_key so future JWT rotations cannot break decryption. + try { + _encryptionKey = fs.readFileSync(jwtSecretFile, 'utf8').trim(); + console.warn('WARNING: ENCRYPTION_KEY is not set. Falling back to JWT secret for at-rest encryption.'); + console.warn('The value has been persisted to data/.encryption_key — JWT rotation is now safe.'); + } catch { + // JWT secret not found — must be a fresh install. + } + } + + if (!_encryptionKey) { + // Fresh install — auto-generate a dedicated key. + _encryptionKey = crypto.randomBytes(32).toString('hex'); + } + + // Persist whatever key was resolved so subsequent starts skip the fallback chain. + try { + if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); + fs.writeFileSync(encKeyFile, _encryptionKey, { mode: 0o600 }); + console.log('Encryption key persisted to', encKeyFile); + } catch (writeErr: unknown) { + console.warn('WARNING: Could not persist encryption key to disk:', writeErr instanceof Error ? writeErr.message : writeErr); + console.warn('Set ENCRYPTION_KEY env var to avoid losing access to encrypted secrets on restart.'); + } +} + +export const ENCRYPTION_KEY = _encryptionKey; diff --git a/server/src/db/database.ts b/server/src/db/database.ts index 0b59233..0c6c009 100644 --- a/server/src/db/database.ts +++ b/server/src/db/database.ts @@ -84,7 +84,7 @@ interface PlaceWithTags extends Place { } function getPlaceWithTags(placeId: number | string): PlaceWithTags | null { - const place = _db!.prepare(` + const place = db.prepare(` SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id @@ -93,7 +93,7 @@ function getPlaceWithTags(placeId: number | string): PlaceWithTags | null { if (!place) return null; - const tags = _db!.prepare(` + const tags = db.prepare(` SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ? @@ -117,7 +117,7 @@ interface TripAccess { } function canAccessTrip(tripId: number | string, userId: number): TripAccess | undefined { - return _db!.prepare(` + return db.prepare(` SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL) @@ -125,7 +125,7 @@ function canAccessTrip(tripId: number | string, userId: number): TripAccess | un } function isOwner(tripId: number | string, userId: number): boolean { - return !!_db!.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId); + return !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId); } export { db, closeDb, reinitialize, getPlaceWithTags, canAccessTrip, isOwner }; diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 9a0106a..d0b0a5e 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -1,4 +1,5 @@ import Database from 'better-sqlite3'; +import { encrypt_api_key } from '../services/apiKeyCrypto'; function runMigrations(db: Database.Database): void { db.exec('CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)'); @@ -60,17 +61,17 @@ function runMigrations(db: Database.Database): void { } }, () => { - try { db.exec('ALTER TABLE day_accommodations ADD COLUMN check_in TEXT'); } catch {} - try { db.exec('ALTER TABLE day_accommodations ADD COLUMN check_out TEXT'); } catch {} - try { db.exec('ALTER TABLE day_accommodations ADD COLUMN confirmation TEXT'); } catch {} + try { db.exec('ALTER TABLE day_accommodations ADD COLUMN check_in TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE day_accommodations ADD COLUMN check_out TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE day_accommodations ADD COLUMN confirmation TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { - try { db.exec('ALTER TABLE places ADD COLUMN end_time TEXT'); } catch {} + try { db.exec('ALTER TABLE places ADD COLUMN end_time TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { - try { db.exec('ALTER TABLE day_assignments ADD COLUMN reservation_status TEXT DEFAULT \'none\''); } catch {} - try { db.exec('ALTER TABLE day_assignments ADD COLUMN reservation_notes TEXT'); } catch {} - try { db.exec('ALTER TABLE day_assignments ADD COLUMN reservation_datetime TEXT'); } catch {} + try { db.exec('ALTER TABLE day_assignments ADD COLUMN reservation_status TEXT DEFAULT \'none\''); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE day_assignments ADD COLUMN reservation_notes TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE day_assignments ADD COLUMN reservation_datetime TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } try { db.exec(` UPDATE day_assignments SET @@ -85,7 +86,7 @@ function runMigrations(db: Database.Database): void { } }, () => { - try { db.exec('ALTER TABLE reservations ADD COLUMN assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE SET NULL'); } catch {} + try { db.exec('ALTER TABLE reservations ADD COLUMN assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE SET NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { db.exec(` @@ -144,18 +145,22 @@ function runMigrations(db: Database.Database): void { `); try { db.prepare("INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('collab', 'Collab', 'Notes, polls, and live chat for trip collaboration', 'trip', 'Users', 1, 6)").run(); - } catch {} + } catch (err: any) { + console.warn('[migrations] Non-fatal migration step failed:', err); + } }, () => { - try { db.exec('ALTER TABLE day_assignments ADD COLUMN assignment_time TEXT'); } catch {} - try { db.exec('ALTER TABLE day_assignments ADD COLUMN assignment_end_time TEXT'); } catch {} + try { db.exec('ALTER TABLE day_assignments ADD COLUMN assignment_time TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE day_assignments ADD COLUMN assignment_end_time TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } try { db.exec(` UPDATE day_assignments SET assignment_time = (SELECT place_time FROM places WHERE places.id = day_assignments.place_id), assignment_end_time = (SELECT end_time FROM places WHERE places.id = day_assignments.place_id) `); - } catch {} + } catch (err: any) { + console.warn('[migrations] Non-fatal migration step failed:', err); + } }, () => { db.exec(` @@ -184,26 +189,26 @@ function runMigrations(db: Database.Database): void { `); }, () => { - try { db.exec('ALTER TABLE collab_messages ADD COLUMN deleted INTEGER DEFAULT 0'); } catch {} + try { db.exec('ALTER TABLE collab_messages ADD COLUMN deleted INTEGER DEFAULT 0'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { - try { db.exec('ALTER TABLE trip_files ADD COLUMN note_id INTEGER REFERENCES collab_notes(id) ON DELETE SET NULL'); } catch {} - try { db.exec('ALTER TABLE collab_notes ADD COLUMN website TEXT'); } catch {} + try { db.exec('ALTER TABLE trip_files ADD COLUMN note_id INTEGER REFERENCES collab_notes(id) ON DELETE SET NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE collab_notes ADD COLUMN website TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { - try { db.exec('ALTER TABLE reservations ADD COLUMN reservation_end_time TEXT'); } catch {} + try { db.exec('ALTER TABLE reservations ADD COLUMN reservation_end_time TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { - try { db.exec('ALTER TABLE places ADD COLUMN osm_id TEXT'); } catch {} + try { db.exec('ALTER TABLE places ADD COLUMN osm_id TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { - try { db.exec('ALTER TABLE trip_files ADD COLUMN uploaded_by INTEGER REFERENCES users(id) ON DELETE SET NULL'); } catch {} - try { db.exec('ALTER TABLE trip_files ADD COLUMN starred INTEGER DEFAULT 0'); } catch {} - try { db.exec('ALTER TABLE trip_files ADD COLUMN deleted_at TEXT'); } catch {} + try { db.exec('ALTER TABLE trip_files ADD COLUMN uploaded_by INTEGER REFERENCES users(id) ON DELETE SET NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE trip_files ADD COLUMN starred INTEGER DEFAULT 0'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE trip_files ADD COLUMN deleted_at TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { - try { db.exec('ALTER TABLE reservations ADD COLUMN accommodation_id INTEGER REFERENCES day_accommodations(id) ON DELETE SET NULL'); } catch {} - try { db.exec('ALTER TABLE reservations ADD COLUMN metadata TEXT'); } catch {} + try { db.exec('ALTER TABLE reservations ADD COLUMN accommodation_id INTEGER REFERENCES day_accommodations(id) ON DELETE SET NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE reservations ADD COLUMN metadata TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { db.exec(`CREATE TABLE IF NOT EXISTS invite_tokens ( @@ -217,8 +222,8 @@ function runMigrations(db: Database.Database): void { )`); }, () => { - try { db.exec('ALTER TABLE users ADD COLUMN mfa_enabled INTEGER DEFAULT 0'); } catch {} - try { db.exec('ALTER TABLE users ADD COLUMN mfa_secret TEXT'); } catch {} + try { db.exec('ALTER TABLE users ADD COLUMN mfa_enabled INTEGER DEFAULT 0'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE users ADD COLUMN mfa_secret TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { db.exec(`CREATE TABLE IF NOT EXISTS packing_category_assignees ( @@ -243,7 +248,9 @@ function runMigrations(db: Database.Database): void { sort_order INTEGER NOT NULL DEFAULT 0 )`); // Recreate items table with category_id FK (replaces old template_id-based schema) - try { db.exec('DROP TABLE IF EXISTS packing_template_items'); } catch {} + try { db.exec('DROP TABLE IF EXISTS packing_template_items'); } catch (err: any) { + console.warn('[migrations] Non-fatal migration step failed:', err); + } db.exec(`CREATE TABLE packing_template_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, category_id INTEGER NOT NULL REFERENCES packing_template_categories(id) ON DELETE CASCADE, @@ -261,8 +268,8 @@ function runMigrations(db: Database.Database): void { sort_order INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`); - try { db.exec('ALTER TABLE packing_items ADD COLUMN weight_grams INTEGER'); } catch {} - try { db.exec('ALTER TABLE packing_items ADD COLUMN bag_id INTEGER REFERENCES packing_bags(id) ON DELETE SET NULL'); } catch {} + try { db.exec('ALTER TABLE packing_items ADD COLUMN weight_grams INTEGER'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE packing_items ADD COLUMN bag_id INTEGER REFERENCES packing_bags(id) ON DELETE SET NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { db.exec(`CREATE TABLE IF NOT EXISTS visited_countries ( @@ -287,12 +294,12 @@ function runMigrations(db: Database.Database): void { }, () => { // Configurable weekend days - try { db.exec("ALTER TABLE vacay_plans ADD COLUMN weekend_days TEXT DEFAULT '0,6'"); } catch {} + try { db.exec("ALTER TABLE vacay_plans ADD COLUMN weekend_days TEXT DEFAULT '0,6'"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { // Immich integration - try { db.exec("ALTER TABLE users ADD COLUMN immich_url TEXT"); } catch {} - try { db.exec("ALTER TABLE users ADD COLUMN immich_api_key TEXT"); } catch {} + try { db.exec("ALTER TABLE users ADD COLUMN immich_url TEXT"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec("ALTER TABLE users ADD COLUMN immich_api_key TEXT"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } db.exec(`CREATE TABLE IF NOT EXISTS trip_photos ( id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, @@ -305,7 +312,9 @@ function runMigrations(db: Database.Database): void { // Add memories addon try { db.prepare("INSERT INTO addons (id, name, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)").run('memories', 'Photos', 'trip', 'Image', 0, 7); - } catch {} + } catch (err: any) { + console.warn('[migrations] Non-fatal migration step failed:', err); + } }, () => { // Allow files to be linked to multiple reservations/assignments @@ -323,15 +332,15 @@ function runMigrations(db: Database.Database): void { }, () => { // Add day_plan_position to reservations for persistent transport ordering in day timeline - try { db.exec('ALTER TABLE reservations ADD COLUMN day_plan_position REAL DEFAULT NULL'); } catch {} + try { db.exec('ALTER TABLE reservations ADD COLUMN day_plan_position REAL DEFAULT NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { // Add paid_by_user_id to budget_items for expense tracking / settlement - try { db.exec('ALTER TABLE budget_items ADD COLUMN paid_by_user_id INTEGER REFERENCES users(id)'); } catch {} + try { db.exec('ALTER TABLE budget_items ADD COLUMN paid_by_user_id INTEGER REFERENCES users(id)'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { // Add target_date to bucket_list for optional visit planning - try { db.exec('ALTER TABLE bucket_list ADD COLUMN target_date TEXT DEFAULT NULL'); } catch {} + try { db.exec('ALTER TABLE bucket_list ADD COLUMN target_date TEXT DEFAULT NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { // Notification preferences per user @@ -351,10 +360,10 @@ function runMigrations(db: Database.Database): void { }, () => { // Add missing notification preference columns for existing tables - try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_vacay_invite INTEGER DEFAULT 1'); } catch {} - try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_photos_shared INTEGER DEFAULT 1'); } catch {} - try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_collab_message INTEGER DEFAULT 1'); } catch {} - try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_packing_tagged INTEGER DEFAULT 1'); } catch {} + try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_vacay_invite INTEGER DEFAULT 1'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_photos_shared INTEGER DEFAULT 1'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_collab_message INTEGER DEFAULT 1'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_packing_tagged INTEGER DEFAULT 1'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { // Public share links for read-only trip access @@ -373,11 +382,11 @@ function runMigrations(db: Database.Database): void { }, () => { // Add permission columns to share_tokens - try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_map INTEGER DEFAULT 1'); } catch {} - try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_bookings INTEGER DEFAULT 1'); } catch {} - try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_packing INTEGER DEFAULT 0'); } catch {} - try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_budget INTEGER DEFAULT 0'); } catch {} - try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_collab INTEGER DEFAULT 0'); } catch {} + try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_map INTEGER DEFAULT 1'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_bookings INTEGER DEFAULT 1'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_packing INTEGER DEFAULT 0'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_budget INTEGER DEFAULT 0'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_collab INTEGER DEFAULT 0'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { // Audit log @@ -396,7 +405,7 @@ function runMigrations(db: Database.Database): void { }, () => { // MFA backup/recovery codes - try { db.exec('ALTER TABLE users ADD COLUMN mfa_backup_codes TEXT'); } catch {} + try { db.exec('ALTER TABLE users ADD COLUMN mfa_backup_codes TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, // MCP long-lived API tokens () => db.exec(` @@ -415,7 +424,9 @@ function runMigrations(db: Database.Database): void { try { db.prepare("INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)") .run('mcp', 'MCP', 'Model Context Protocol for AI assistant integration', 'integration', 'Terminal', 0, 12); - } catch {} + } catch (err: any) { + console.warn('[migrations] Non-fatal migration step failed:', err); + } }, // Index on mcp_tokens.token_hash () => db.exec(` @@ -425,16 +436,41 @@ function runMigrations(db: Database.Database): void { () => { try { db.prepare("UPDATE addons SET type = 'integration' WHERE id = 'mcp'").run(); - } catch {} + } catch (err: any) { + console.warn('[migrations] Non-fatal migration step failed:', err); + } }, () => { - try { db.exec('ALTER TABLE places ADD COLUMN route_geometry TEXT'); } catch {} + try { db.exec('ALTER TABLE places ADD COLUMN route_geometry TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { - try { db.exec('ALTER TABLE users ADD COLUMN must_change_password INTEGER DEFAULT 0'); } catch {} + try { db.exec('ALTER TABLE users ADD COLUMN must_change_password INTEGER DEFAULT 0'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { - try { db.exec('ALTER TABLE trips ADD COLUMN reminder_days INTEGER DEFAULT 3'); } catch {} + try { db.exec('ALTER TABLE trips ADD COLUMN reminder_days INTEGER DEFAULT 3'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + }, + // Encrypt any plaintext oidc_client_secret left in app_settings + () => { + const row = db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_client_secret'").get() as { value: string } | undefined; + if (row?.value && !row.value.startsWith('enc:v1:')) { + db.prepare("UPDATE app_settings SET value = ? WHERE key = 'oidc_client_secret'").run(encrypt_api_key(row.value)); + } + }, + // Encrypt any plaintext smtp_pass left in app_settings + () => { + const row = db.prepare("SELECT value FROM app_settings WHERE key = 'smtp_pass'").get() as { value: string } | undefined; + if (row?.value && !row.value.startsWith('enc:v1:')) { + db.prepare("UPDATE app_settings SET value = ? WHERE key = 'smtp_pass'").run(encrypt_api_key(row.value)); + } + }, + // Encrypt any plaintext immich_api_key values in the users table + () => { + const rows = db.prepare( + "SELECT id, immich_api_key FROM users WHERE immich_api_key IS NOT NULL AND immich_api_key != '' AND immich_api_key NOT LIKE 'enc:v1:%'" + ).all() as { id: number; immich_api_key: string }[]; + for (const row of rows) { + db.prepare('UPDATE users SET immich_api_key = ? WHERE id = ?').run(encrypt_api_key(row.immich_api_key), row.id); + } }, () => { try { db.exec('ALTER TABLE budget_items ADD COLUMN expense_date TEXT DEFAULT NULL'); } catch {} @@ -460,9 +496,14 @@ function runMigrations(db: Database.Database): void { if (currentVersion < migrations.length) { for (let i = currentVersion; i < migrations.length; i++) { console.log(`[DB] Running migration ${i + 1}/${migrations.length}`); - migrations[i](); + try { + db.transaction(() => migrations[i]())(); + } catch (err) { + console.error(`[migrations] FATAL: Migration ${i + 1} failed, rolled back:`, err); + process.exit(1); + } + db.prepare('UPDATE schema_version SET version = ?').run(i + 1); } - db.prepare('UPDATE schema_version SET version = ?').run(migrations.length); console.log(`[DB] Migrations complete — schema version ${migrations.length}`); } } diff --git a/server/src/index.ts b/server/src/index.ts index a0a0f95..a180bae 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,9 +1,9 @@ import 'dotenv/config'; -import { JWT_SECRET_IS_GENERATED } from './config'; import express, { Request, Response, NextFunction } from 'express'; import { enforceGlobalMfaPolicy } from './middleware/mfaPolicy'; import cors from 'cors'; import helmet from 'helmet'; +import cookieParser from 'cookie-parser'; import path from 'path'; import fs from 'fs'; @@ -87,6 +87,7 @@ if (shouldForceHttps) { } app.use(express.json({ limit: '100kb' })); app.use(express.urlencoded({ extended: true })); +app.use(cookieParser()); app.use(enforceGlobalMfaPolicy); @@ -282,9 +283,6 @@ const server = app.listen(PORT, () => { '──────────────────────────────────────', ]; banner.forEach(l => console.log(l)); - if (JWT_SECRET_IS_GENERATED) { - sLogWarn('[SECURITY WARNING] JWT_SECRET was auto-generated. Sessions will not persist across restarts. Set JWT_SECRET env var for production use.'); - } if (process.env.DEMO_MODE === 'true') sLogInfo('Demo mode: ENABLED'); if (process.env.DEMO_MODE === 'true' && process.env.NODE_ENV === 'production') { sLogWarn('SECURITY WARNING: DEMO_MODE is enabled in production!'); @@ -292,6 +290,8 @@ const server = app.listen(PORT, () => { scheduler.start(); scheduler.startTripReminders(); scheduler.startDemoReset(); + const { startTokenCleanup } = require('./services/ephemeralTokens'); + startTokenCleanup(); import('./websocket').then(({ setupWebSocket }) => { setupWebSocket(server); }); diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index 9b0c040..b2a7807 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -4,9 +4,16 @@ import { db } from '../db/database'; import { JWT_SECRET } from '../config'; import { AuthRequest, OptionalAuthRequest, User } from '../types'; -const authenticate = (req: Request, res: Response, next: NextFunction): void => { +function extractToken(req: Request): string | null { + // Prefer httpOnly cookie; fall back to Authorization: Bearer (MCP, API clients) + const cookieToken = (req as any).cookies?.trek_session; + if (cookieToken) return cookieToken; const authHeader = req.headers['authorization']; - const token = authHeader && authHeader.split(' ')[1]; + return (authHeader && authHeader.split(' ')[1]) || null; +} + +const authenticate = (req: Request, res: Response, next: NextFunction): void => { + const token = extractToken(req); if (!token) { res.status(401).json({ error: 'Access token required' }); @@ -30,8 +37,7 @@ const authenticate = (req: Request, res: Response, next: NextFunction): void => }; const optionalAuth = (req: Request, res: Response, next: NextFunction): void => { - const authHeader = req.headers['authorization']; - const token = authHeader && authHeader.split(' ')[1]; + const token = extractToken(req); if (!token) { (req as OptionalAuthRequest).user = null; diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index 674aa31..cfe6544 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -1,7 +1,6 @@ import express, { Request, Response } from 'express'; import bcrypt from 'bcryptjs'; import crypto from 'crypto'; -import { execSync } from 'child_process'; import path from 'path'; import fs from 'fs'; import { db } from '../db/database'; @@ -10,6 +9,9 @@ import { AuthRequest, User, Addon } from '../types'; import { writeAudit, getClientIp, logInfo } from '../services/auditLog'; import { getAllPermissions, savePermissions, PERMISSION_ACTIONS } from '../services/permissions'; import { revokeUserSessions } from '../mcp'; +import { maybe_encrypt_api_key, decrypt_api_key } from '../services/apiKeyCrypto'; +import { validatePassword } from '../services/passwordPolicy'; +import { updateJwtSecret } from '../config'; const router = express.Router(); @@ -46,6 +48,9 @@ router.post('/users', (req: Request, res: Response) => { return res.status(400).json({ error: 'Username, email and password are required' }); } + const pwCheck = validatePassword(password.trim()); + if (!pwCheck.ok) return res.status(400).json({ error: pwCheck.reason }); + if (role && !['user', 'admin'].includes(role)) { return res.status(400).json({ error: 'Invalid role' }); } @@ -96,6 +101,10 @@ router.put('/users/:id', (req: Request, res: Response) => { if (conflict) return res.status(409).json({ error: 'Email already taken' }); } + if (password) { + const pwCheck = validatePassword(password); + if (!pwCheck.ok) return res.status(400).json({ error: pwCheck.reason }); + } const passwordHash = password ? bcrypt.hashSync(password, 12) : null; db.prepare(` @@ -233,24 +242,26 @@ router.get('/audit-log', (req: Request, res: Response) => { router.get('/oidc', (_req: Request, res: Response) => { const get = (key: string) => (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || ''; - const secret = get('oidc_client_secret'); + const secret = decrypt_api_key(get('oidc_client_secret')); res.json({ issuer: get('oidc_issuer'), client_id: get('oidc_client_id'), client_secret_set: !!secret, display_name: get('oidc_display_name'), oidc_only: get('oidc_only') === 'true', + discovery_url: get('oidc_discovery_url'), }); }); router.put('/oidc', (req: Request, res: Response) => { - const { issuer, client_id, client_secret, display_name, oidc_only } = req.body; + const { issuer, client_id, client_secret, display_name, oidc_only, discovery_url } = req.body; const set = (key: string, val: string) => db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val || ''); set('oidc_issuer', issuer); set('oidc_client_id', client_id); - if (client_secret !== undefined) set('oidc_client_secret', client_secret); + if (client_secret !== undefined) set('oidc_client_secret', maybe_encrypt_api_key(client_secret) ?? ''); set('oidc_display_name', display_name); set('oidc_only', oidc_only ? 'true' : 'false'); + set('oidc_discovery_url', discovery_url); const authReq = req as AuthRequest; writeAudit({ userId: authReq.user.id, @@ -326,49 +337,6 @@ router.get('/version-check', async (_req: Request, res: Response) => { } }); -router.post('/update', async (req: Request, res: Response) => { - const rootDir = path.resolve(__dirname, '../../..'); - const serverDir = path.resolve(__dirname, '../..'); - const clientDir = path.join(rootDir, 'client'); - const steps: { step: string; success?: boolean; output?: string; version?: string }[] = []; - - try { - const pullOutput = execSync('git pull origin main', { cwd: rootDir, timeout: 60000, encoding: 'utf8' }); - steps.push({ step: 'git pull', success: true, output: pullOutput.trim() }); - - execSync('npm install --production --ignore-scripts', { cwd: serverDir, timeout: 120000, encoding: 'utf8' }); - steps.push({ step: 'npm install (server)', success: true }); - - if (process.env.NODE_ENV === 'production') { - execSync('npm install --ignore-scripts', { cwd: clientDir, timeout: 120000, encoding: 'utf8' }); - execSync('npm run build', { cwd: clientDir, timeout: 120000, encoding: 'utf8' }); - steps.push({ step: 'npm install + build (client)', success: true }); - } - - delete require.cache[require.resolve('../../package.json')]; - const { version: newVersion } = require('../../package.json'); - steps.push({ step: 'version', version: newVersion }); - - const authReq = req as AuthRequest; - writeAudit({ - userId: authReq.user.id, - action: 'admin.system_update', - resource: newVersion, - ip: getClientIp(req), - }); - res.json({ success: true, steps, restarting: true }); - - setTimeout(() => { - console.log('[Update] Restarting after update...'); - process.exit(0); - }, 1000); - } catch (err: unknown) { - console.error(err); - steps.push({ step: 'error', success: false, output: 'Internal error' }); - res.status(500).json({ success: false, steps }); - } -}); - // ── Invite Tokens ─────────────────────────────────────────────────────────── router.get('/invites', (_req: Request, res: Response) => { @@ -600,4 +568,28 @@ router.delete('/mcp-tokens/:id', (req: Request, res: Response) => { res.json({ success: true }); }); +router.post('/rotate-jwt-secret', (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const newSecret = crypto.randomBytes(32).toString('hex'); + const dataDir = path.resolve(__dirname, '../../data'); + const secretFile = path.join(dataDir, '.jwt_secret'); + try { + if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); + fs.writeFileSync(secretFile, newSecret, { mode: 0o600 }); + } catch (err: unknown) { + return res.status(500).json({ error: 'Failed to persist new JWT secret to disk' }); + } + updateJwtSecret(newSecret); + writeAudit({ + user_id: authReq.user?.id ?? null, + username: authReq.user?.username ?? 'unknown', + action: 'admin.rotate_jwt_secret', + target_type: 'system', + target_id: null, + details: null, + ip: getClientIp(req), + }); + res.json({ success: true }); +}); + export default router; diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 679d20d..eb96998 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -10,6 +10,7 @@ import fetch from 'node-fetch'; import { authenticator } from 'otplib'; import QRCode from 'qrcode'; import { db } from '../db/database'; +import { validatePassword } from '../services/passwordPolicy'; import { authenticate, optionalAuth, demoUploadBlock } from '../middleware/auth'; import { JWT_SECRET } from '../config'; import { encryptMfaSecret, decryptMfaSecret } from '../services/mfaCrypto'; @@ -18,8 +19,10 @@ import { randomBytes, createHash } from 'crypto'; import { revokeUserSessions } from '../mcp'; import { AuthRequest, OptionalAuthRequest, User } from '../types'; import { writeAudit, getClientIp } from '../services/auditLog'; -import { decrypt_api_key, maybe_encrypt_api_key } from '../services/apiKeyCrypto'; +import { decrypt_api_key, maybe_encrypt_api_key, encrypt_api_key } from '../services/apiKeyCrypto'; import { startTripReminders } from '../scheduler'; +import { createEphemeralToken } from '../services/ephemeralTokens'; +import { setAuthCookie, clearAuthCookie } from '../services/cookie'; authenticator.options = { window: 1 }; @@ -112,23 +115,27 @@ const RATE_LIMIT_WINDOW = 15 * 60 * 1000; // 15 minutes const RATE_LIMIT_CLEANUP = 5 * 60 * 1000; // 5 minutes const loginAttempts = new Map(); +const mfaAttempts = new Map(); setInterval(() => { const now = Date.now(); for (const [key, record] of loginAttempts) { if (now - record.first >= RATE_LIMIT_WINDOW) loginAttempts.delete(key); } + for (const [key, record] of mfaAttempts) { + if (now - record.first >= RATE_LIMIT_WINDOW) mfaAttempts.delete(key); + } }, RATE_LIMIT_CLEANUP); -function rateLimiter(maxAttempts: number, windowMs: number) { +function rateLimiter(maxAttempts: number, windowMs: number, store = loginAttempts) { return (req: Request, res: Response, next: NextFunction) => { const key = req.ip || 'unknown'; const now = Date.now(); - const record = loginAttempts.get(key); + const record = store.get(key); if (record && record.count >= maxAttempts && now - record.first < windowMs) { return res.status(429).json({ error: 'Too many attempts. Please try again later.' }); } if (!record || now - record.first >= windowMs) { - loginAttempts.set(key, { count: 1, first: now }); + store.set(key, { count: 1, first: now }); } else { record.count++; } @@ -136,6 +143,7 @@ function rateLimiter(maxAttempts: number, windowMs: number) { }; } const authLimiter = rateLimiter(10, RATE_LIMIT_WINDOW); +const mfaLimiter = rateLimiter(5, RATE_LIMIT_WINDOW, mfaAttempts); function isOidcOnlyMode(): boolean { const get = (key: string) => (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || null; @@ -222,6 +230,7 @@ router.post('/demo-login', (_req: Request, res: Response) => { if (!user) return res.status(500).json({ error: 'Demo user not found' }); const token = generateToken(user); const safe = stripUserForClient(user) as Record; + setAuthCookie(res, token); res.json({ token, user: { ...safe, avatar_url: avatarUrl(user) } }); }); @@ -262,13 +271,8 @@ router.post('/register', authLimiter, (req: Request, res: Response) => { return res.status(400).json({ error: 'Username, email and password are required' }); } - if (password.length < 8) { - return res.status(400).json({ error: 'Password must be at least 8 characters' }); - } - - if (!/[A-Z]/.test(password) || !/[a-z]/.test(password) || !/[0-9]/.test(password)) { - return res.status(400).json({ error: 'Password must contain at least one uppercase letter, one lowercase letter, and one number' }); - } + const pwCheck = validatePassword(password); + if (!pwCheck.ok) return res.status(400).json({ error: pwCheck.reason }); const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(email)) { @@ -305,6 +309,7 @@ router.post('/register', authLimiter, (req: Request, res: Response) => { } writeAudit({ userId: Number(result.lastInsertRowid), action: 'user.register', ip: getClientIp(req), details: { username, email, role } }); + setAuthCookie(res, token); res.status(201).json({ token, user: { ...user, avatar_url: null } }); } catch (err: unknown) { res.status(500).json({ error: 'Error creating user' }); @@ -348,6 +353,7 @@ router.post('/login', authLimiter, (req: Request, res: Response) => { const userSafe = stripUserForClient(user) as Record; writeAudit({ userId: Number(user.id), action: 'user.login', ip: getClientIp(req), details: { email } }); + setAuthCookie(res, token); res.json({ token, user: { ...userSafe, avatar_url: avatarUrl(user) } }); }); @@ -365,6 +371,11 @@ router.get('/me', authenticate, (req: Request, res: Response) => { res.json({ user: { ...base, avatar_url: avatarUrl(user) } }); }); +router.post('/logout', (req: Request, res: Response) => { + clearAuthCookie(res); + res.json({ success: true }); +}); + router.put('/me/password', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => { const authReq = req as AuthRequest; if (isOidcOnlyMode()) { @@ -376,11 +387,8 @@ router.put('/me/password', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req const { current_password, new_password } = req.body; if (!current_password) return res.status(400).json({ error: 'Current password is required' }); if (!new_password) return res.status(400).json({ error: 'New password is required' }); - if (new_password.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters' }); - - if (!/[A-Z]/.test(new_password) || !/[a-z]/.test(new_password) || !/[0-9]/.test(new_password)) { - return res.status(400).json({ error: 'Password must contain at least one uppercase letter, one lowercase letter, and one number' }); - } + const pwCheck = validatePassword(new_password); + if (!pwCheck.ok) return res.status(400).json({ error: pwCheck.reason }); const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(authReq.user.id) as { password_hash: string } | undefined; if (!user || !bcrypt.compareSync(current_password, user.password_hash)) { @@ -665,6 +673,7 @@ router.put('/app-settings', authenticate, (req: Request, res: Response) => { } // Don't save masked password if (key === 'smtp_pass' && val === '••••••••') continue; + if (key === 'smtp_pass') val = encrypt_api_key(val); db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val); } } @@ -776,7 +785,7 @@ router.get('/travel-stats', authenticate, (req: Request, res: Response) => { }); }); -router.post('/mfa/verify-login', authLimiter, (req: Request, res: Response) => { +router.post('/mfa/verify-login', mfaLimiter, (req: Request, res: Response) => { const { mfa_token, code } = req.body as { mfa_token?: string; code?: string }; if (!mfa_token || !code) { return res.status(400).json({ error: 'Verification token and code are required' }); @@ -810,6 +819,7 @@ router.post('/mfa/verify-login', authLimiter, (req: Request, res: Response) => { const sessionToken = generateToken(user); const userSafe = stripUserForClient(user) as Record; writeAudit({ userId: Number(user.id), action: 'user.login', ip: getClientIp(req), details: { mfa: true } }); + setAuthCookie(res, sessionToken); res.json({ token: sessionToken, user: { ...userSafe, avatar_url: avatarUrl(user) } }); } catch { return res.status(401).json({ error: 'Invalid or expired verification token' }); @@ -844,7 +854,7 @@ router.post('/mfa/setup', authenticate, (req: Request, res: Response) => { }); }); -router.post('/mfa/enable', authenticate, (req: Request, res: Response) => { +router.post('/mfa/enable', authenticate, mfaLimiter, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { code } = req.body as { code?: string }; if (!code) { @@ -950,4 +960,24 @@ router.delete('/mcp-tokens/:id', authenticate, (req: Request, res: Response) => res.json({ success: true }); }); +// Short-lived single-use token for WebSocket connections (avoids JWT in WS URL) +router.post('/ws-token', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const token = createEphemeralToken(authReq.user.id, 'ws'); + if (!token) return res.status(503).json({ error: 'Service unavailable' }); + res.json({ token }); +}); + +// Short-lived single-use token for direct resource URLs (file downloads, Immich assets) +router.post('/resource-token', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { purpose } = req.body as { purpose?: string }; + if (purpose !== 'download' && purpose !== 'immich') { + return res.status(400).json({ error: 'Invalid purpose' }); + } + const token = createEphemeralToken(authReq.user.id, purpose); + if (!token) return res.status(503).json({ error: 'Service unavailable' }); + res.json({ token }); +}); + export default router; diff --git a/server/src/routes/backup.ts b/server/src/routes/backup.ts index de47375..53a772c 100644 --- a/server/src/routes/backup.ts +++ b/server/src/routes/backup.ts @@ -4,6 +4,7 @@ import unzipper from 'unzipper'; import multer from 'multer'; import path from 'path'; import fs from 'fs'; +import Database from 'better-sqlite3'; import { authenticate, adminOnly } from '../middleware/auth'; import * as scheduler from '../scheduler'; import { db, closeDb, reinitialize } from '../db/database'; @@ -159,6 +160,34 @@ async function restoreFromZip(zipPath: string, res: Response, audit?: RestoreAud return res.status(400).json({ error: 'Invalid backup: travel.db not found' }); } + let uploadedDb: InstanceType | null = null; + try { + uploadedDb = new Database(extractedDb, { readonly: true }); + + const integrityResult = uploadedDb.prepare('PRAGMA integrity_check').get() as { integrity_check: string }; + if (integrityResult.integrity_check !== 'ok') { + fs.rmSync(extractDir, { recursive: true, force: true }); + return res.status(400).json({ error: `Uploaded database failed integrity check: ${integrityResult.integrity_check}` }); + } + + const requiredTables = ['users', 'trips', 'trip_members', 'places', 'days']; + const existingTables = uploadedDb + .prepare("SELECT name FROM sqlite_master WHERE type='table'") + .all() as { name: string }[]; + const tableNames = new Set(existingTables.map(t => t.name)); + for (const table of requiredTables) { + if (!tableNames.has(table)) { + fs.rmSync(extractDir, { recursive: true, force: true }); + return res.status(400).json({ error: `Uploaded database is missing required table: ${table}. This does not appear to be a TREK backup.` }); + } + } + } catch (err) { + fs.rmSync(extractDir, { recursive: true, force: true }); + return res.status(400).json({ error: 'Uploaded file is not a valid SQLite database' }); + } finally { + uploadedDb?.close(); + } + closeDb(); try { diff --git a/server/src/routes/collab.ts b/server/src/routes/collab.ts index 98be75a..15f1779 100644 --- a/server/src/routes/collab.ts +++ b/server/src/routes/collab.ts @@ -9,6 +9,7 @@ import { broadcast } from '../websocket'; import { validateStringLengths } from '../middleware/validate'; import { checkPermission } from '../services/permissions'; import { AuthRequest, CollabNote, CollabPoll, CollabMessage, TripFile } from '../types'; +import { checkSsrf, createPinnedAgent } from '../utils/ssrfGuard'; interface ReactionRow { emoji: string; @@ -513,35 +514,19 @@ router.get('/link-preview', authenticate, async (req: Request, res: Response) => try { const parsed = new URL(url); - if (!['http:', 'https:'].includes(parsed.protocol)) { - return res.status(400).json({ error: 'Only HTTP(S) URLs are allowed' }); - } - const hostname = parsed.hostname.toLowerCase(); - if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || - hostname === '0.0.0.0' || hostname.endsWith('.local') || hostname.endsWith('.internal') || - /^10\./.test(hostname) || /^172\.(1[6-9]|2\d|3[01])\./.test(hostname) || /^192\.168\./.test(hostname) || - /^169\.254\./.test(hostname) || hostname === '[::1]' || hostname.startsWith('fc') || hostname.startsWith('fd') || hostname.startsWith('fe80')) { - return res.status(400).json({ error: 'Private/internal URLs are not allowed' }); - } - - const dns = require('dns').promises; - let resolved: { address: string }; - try { - resolved = await dns.lookup(parsed.hostname); - } catch { - return res.status(400).json({ error: 'Could not resolve hostname' }); - } - const ip = resolved.address; - if (/^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|0\.|169\.254\.|::1|::ffff:(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.))/.test(ip)) { - return res.status(400).json({ error: 'Private/internal URLs are not allowed' }); + const ssrf = await checkSsrf(url); + if (!ssrf.allowed) { + return res.status(400).json({ error: ssrf.error }); } const nodeFetch = require('node-fetch'); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); - nodeFetch(url, { redirect: 'error', + nodeFetch(url, { + redirect: 'error', signal: controller.signal, + agent: createPinnedAgent(ssrf.resolvedIp!, parsed.protocol), headers: { 'User-Agent': 'Mozilla/5.0 (compatible; NOMAD/1.0; +https://github.com/mauriceboe/NOMAD)' }, }) .then((r: { ok: boolean; text: () => Promise }) => { diff --git a/server/src/routes/files.ts b/server/src/routes/files.ts index 6da1b0a..13df3cd 100644 --- a/server/src/routes/files.ts +++ b/server/src/routes/files.ts @@ -6,6 +6,7 @@ import { v4 as uuidv4 } from 'uuid'; import jwt from 'jsonwebtoken'; import { JWT_SECRET } from '../config'; import { db, canAccessTrip } from '../db/database'; +import { consumeEphemeralToken } from '../services/ephemeralTokens'; import { authenticate, demoUploadBlock } from '../middleware/auth'; import { requireTripAccess } from '../middleware/tripAccess'; import { broadcast } from '../websocket'; @@ -84,17 +85,25 @@ function getPlaceFiles(tripId: string | number, placeId: number) { router.get('/:id/download', (req: Request, res: Response) => { const { tripId, id } = req.params; - // Accept token from Authorization header or query parameter + // Accept token from Authorization header (JWT) or query parameter (ephemeral token) const authHeader = req.headers['authorization']; - const token = (authHeader && authHeader.split(' ')[1]) || (req.query.token as string); - if (!token) return res.status(401).json({ error: 'Authentication required' }); + const bearerToken = authHeader && authHeader.split(' ')[1]; + const queryToken = req.query.token as string | undefined; + + if (!bearerToken && !queryToken) return res.status(401).json({ error: 'Authentication required' }); let userId: number; - try { - const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number }; - userId = decoded.id; - } catch { - return res.status(401).json({ error: 'Invalid or expired token' }); + if (bearerToken) { + try { + const decoded = jwt.verify(bearerToken, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number }; + userId = decoded.id; + } catch { + return res.status(401).json({ error: 'Invalid or expired token' }); + } + } else { + const uid = consumeEphemeralToken(queryToken!, 'download'); + if (!uid) return res.status(401).json({ error: 'Invalid or expired token' }); + userId = uid; } const trip = verifyTripOwnership(tripId, userId); diff --git a/server/src/routes/immich.ts b/server/src/routes/immich.ts index 1602ec4..c39a7ec 100644 --- a/server/src/routes/immich.ts +++ b/server/src/routes/immich.ts @@ -1,68 +1,83 @@ -import express, { Request, Response } from 'express'; +import express, { Request, Response, NextFunction } from 'express'; import { db, canAccessTrip } from '../db/database'; import { authenticate } from '../middleware/auth'; import { broadcast } from '../websocket'; import { AuthRequest } from '../types'; +import { consumeEphemeralToken } from '../services/ephemeralTokens'; +import { maybe_encrypt_api_key, decrypt_api_key } from '../services/apiKeyCrypto'; +import { checkSsrf } from '../utils/ssrfGuard'; +import { writeAudit, getClientIp } from '../services/auditLog'; const router = express.Router(); +function getImmichCredentials(userId: number) { + const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(userId) as any; + if (!user?.immich_url || !user?.immich_api_key) return null; + return { immich_url: user.immich_url as string, immich_api_key: decrypt_api_key(user.immich_api_key) as string }; +} + /** Validate that an asset ID is a safe UUID-like string (no path traversal). */ function isValidAssetId(id: string): boolean { return /^[a-zA-Z0-9_-]+$/.test(id) && id.length <= 100; } -/** Validate that an Immich URL is a safe HTTP(S) URL (no internal/metadata IPs). */ -function isValidImmichUrl(raw: string): boolean { - try { - const url = new URL(raw); - if (url.protocol !== 'http:' && url.protocol !== 'https:') return false; - const hostname = url.hostname.toLowerCase(); - // Block metadata endpoints and localhost - if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') return false; - if (hostname === '169.254.169.254' || hostname === 'metadata.google.internal') return false; - // Block link-local and loopback ranges - if (hostname.startsWith('10.') || hostname.startsWith('172.') || hostname.startsWith('192.168.')) return false; - if (hostname.endsWith('.internal') || hostname.endsWith('.local')) return false; - return true; - } catch { - return false; - } -} - // ── Immich Connection Settings ────────────────────────────────────────────── router.get('/settings', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; - const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any; + const creds = getImmichCredentials(authReq.user.id); res.json({ - immich_url: user?.immich_url || '', - connected: !!(user?.immich_url && user?.immich_api_key), + immich_url: creds?.immich_url || '', + connected: !!(creds?.immich_url && creds?.immich_api_key), }); }); -router.put('/settings', authenticate, (req: Request, res: Response) => { +router.put('/settings', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { immich_url, immich_api_key } = req.body; - if (immich_url && !isValidImmichUrl(immich_url.trim())) { - return res.status(400).json({ error: 'Invalid Immich URL. Must be a valid HTTP(S) URL.' }); + + if (immich_url) { + const ssrf = await checkSsrf(immich_url.trim()); + if (!ssrf.allowed) { + return res.status(400).json({ error: `Invalid Immich URL: ${ssrf.error}` }); + } + db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?').run( + immich_url.trim(), + maybe_encrypt_api_key(immich_api_key), + authReq.user.id + ); + if (ssrf.isPrivate) { + writeAudit({ + userId: authReq.user.id, + action: 'immich.private_ip_configured', + ip: getClientIp(req), + details: { immich_url: immich_url.trim(), resolved_ip: ssrf.resolvedIp }, + }); + return res.json({ + success: true, + warning: `Immich URL resolves to a private IP address (${ssrf.resolvedIp}). Make sure this is intentional.`, + }); + } + } else { + db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?').run( + null, + maybe_encrypt_api_key(immich_api_key), + authReq.user.id + ); } - db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?').run( - immich_url?.trim() || null, - immich_api_key?.trim() || null, - authReq.user.id - ); + res.json({ success: true }); }); router.get('/status', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; - const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any; - if (!user?.immich_url || !user?.immich_api_key) { + const creds = getImmichCredentials(authReq.user.id); + if (!creds) { return res.json({ connected: false, error: 'Not configured' }); } try { - const resp = await fetch(`${user.immich_url}/api/users/me`, { - headers: { 'x-api-key': user.immich_api_key, 'Accept': 'application/json' }, + const resp = await fetch(`${creds.immich_url}/api/users/me`, { + headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, signal: AbortSignal.timeout(10000), }); if (!resp.ok) return res.json({ connected: false, error: `HTTP ${resp.status}` }); @@ -77,7 +92,8 @@ router.get('/status', authenticate, async (req: Request, res: Response) => { router.post('/test', authenticate, async (req: Request, res: Response) => { const { immich_url, immich_api_key } = req.body; if (!immich_url || !immich_api_key) return res.json({ connected: false, error: 'URL and API key required' }); - if (!isValidImmichUrl(immich_url)) return res.json({ connected: false, error: 'Invalid Immich URL' }); + const ssrf = await checkSsrf(immich_url); + if (!ssrf.allowed) return res.json({ connected: false, error: ssrf.error ?? 'Invalid Immich URL' }); try { const resp = await fetch(`${immich_url}/api/users/me`, { headers: { 'x-api-key': immich_api_key, 'Accept': 'application/json' }, @@ -96,13 +112,13 @@ router.post('/test', authenticate, async (req: Request, res: Response) => { router.get('/browse', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { page = '1', size = '50' } = req.query; - const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any; - if (!user?.immich_url || !user?.immich_api_key) return res.status(400).json({ error: 'Immich not configured' }); + const creds = getImmichCredentials(authReq.user.id); + if (!creds) return res.status(400).json({ error: 'Immich not configured' }); try { - const resp = await fetch(`${user.immich_url}/api/timeline/buckets`, { + const resp = await fetch(`${creds.immich_url}/api/timeline/buckets`, { method: 'GET', - headers: { 'x-api-key': user.immich_api_key, 'Accept': 'application/json' }, + headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, signal: AbortSignal.timeout(15000), }); if (!resp.ok) return res.status(resp.status).json({ error: 'Failed to fetch from Immich' }); @@ -117,8 +133,8 @@ router.get('/browse', authenticate, async (req: Request, res: Response) => { router.post('/search', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { from, to } = req.body; - const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any; - if (!user?.immich_url || !user?.immich_api_key) return res.status(400).json({ error: 'Immich not configured' }); + const creds = getImmichCredentials(authReq.user.id); + if (!creds) return res.status(400).json({ error: 'Immich not configured' }); try { // Paginate through all results (Immich limits per-page to 1000) @@ -126,9 +142,9 @@ router.post('/search', authenticate, async (req: Request, res: Response) => { let page = 1; const pageSize = 1000; while (true) { - const resp = await fetch(`${user.immich_url}/api/search/metadata`, { + const resp = await fetch(`${creds.immich_url}/api/search/metadata`, { method: 'POST', - headers: { 'x-api-key': user.immich_api_key, 'Content-Type': 'application/json' }, + headers: { 'x-api-key': creds.immich_api_key, 'Content-Type': 'application/json' }, body: JSON.stringify({ takenAfter: from ? `${from}T00:00:00.000Z` : undefined, takenBefore: to ? `${to}T23:59:59.999Z` : undefined, @@ -240,12 +256,12 @@ router.get('/assets/:assetId/info', authenticate, async (req: Request, res: Resp if (!isValidAssetId(assetId)) return res.status(400).json({ error: 'Invalid asset ID' }); // Only allow accessing own Immich credentials — prevent leaking other users' API keys - const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any; - if (!user?.immich_url || !user?.immich_api_key) return res.status(404).json({ error: 'Not found' }); + const creds = getImmichCredentials(authReq.user.id); + if (!creds) return res.status(404).json({ error: 'Not found' }); try { - const resp = await fetch(`${user.immich_url}/api/assets/${assetId}`, { - headers: { 'x-api-key': user.immich_api_key, 'Accept': 'application/json' }, + const resp = await fetch(`${creds.immich_url}/api/assets/${assetId}`, { + headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, signal: AbortSignal.timeout(10000), }); if (!resp.ok) return res.status(resp.status).json({ error: 'Failed' }); @@ -276,11 +292,16 @@ router.get('/assets/:assetId/info', authenticate, async (req: Request, res: Resp // ── Proxy Immich Assets ───────────────────────────────────────────────────── -// Asset proxy routes accept token via query param (for src usage) -function authFromQuery(req: Request, res: Response, next: Function) { - const token = req.query.token as string; - if (token && !req.headers.authorization) { - req.headers.authorization = `Bearer ${token}`; +// Asset proxy routes accept ephemeral token via query param (for src usage) +function authFromQuery(req: Request, res: Response, next: NextFunction) { + const queryToken = req.query.token as string | undefined; + if (queryToken) { + const userId = consumeEphemeralToken(queryToken, 'immich'); + if (!userId) return res.status(401).send('Invalid or expired token'); + const user = db.prepare('SELECT id, username, email, role, mfa_enabled FROM users WHERE id = ?').get(userId) as any; + if (!user) return res.status(401).send('User not found'); + (req as AuthRequest).user = user; + return next(); } return (authenticate as any)(req, res, next); } @@ -290,14 +311,13 @@ router.get('/assets/:assetId/thumbnail', authFromQuery, async (req: Request, res const { assetId } = req.params; if (!isValidAssetId(assetId)) return res.status(400).send('Invalid asset ID'); - // Use photo owner's Immich credentials if userId is provided (for shared photos) - const targetUserId = req.query.userId ? Number(req.query.userId) : authReq.user.id; - const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(targetUserId) as any; - if (!user?.immich_url || !user?.immich_api_key) return res.status(404).send('Not found'); + // Only allow accessing own Immich credentials — prevent leaking other users' API keys + const creds = getImmichCredentials(authReq.user.id); + if (!creds) return res.status(404).send('Not found'); try { - const resp = await fetch(`${user.immich_url}/api/assets/${assetId}/thumbnail`, { - headers: { 'x-api-key': user.immich_api_key }, + const resp = await fetch(`${creds.immich_url}/api/assets/${assetId}/thumbnail`, { + headers: { 'x-api-key': creds.immich_api_key }, signal: AbortSignal.timeout(10000), }); if (!resp.ok) return res.status(resp.status).send('Failed'); @@ -315,14 +335,13 @@ router.get('/assets/:assetId/original', authFromQuery, async (req: Request, res: const { assetId } = req.params; if (!isValidAssetId(assetId)) return res.status(400).send('Invalid asset ID'); - // Use photo owner's Immich credentials if userId is provided (for shared photos) - const targetUserId = req.query.userId ? Number(req.query.userId) : authReq.user.id; - const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(targetUserId) as any; - if (!user?.immich_url || !user?.immich_api_key) return res.status(404).send('Not found'); + // Only allow accessing own Immich credentials — prevent leaking other users' API keys + const creds = getImmichCredentials(authReq.user.id); + if (!creds) return res.status(404).send('Not found'); try { - const resp = await fetch(`${user.immich_url}/api/assets/${assetId}/original`, { - headers: { 'x-api-key': user.immich_api_key }, + const resp = await fetch(`${creds.immich_url}/api/assets/${assetId}/original`, { + headers: { 'x-api-key': creds.immich_api_key }, signal: AbortSignal.timeout(30000), }); if (!resp.ok) return res.status(resp.status).send('Failed'); diff --git a/server/src/routes/oidc.ts b/server/src/routes/oidc.ts index f600237..8ba8c23 100644 --- a/server/src/routes/oidc.ts +++ b/server/src/routes/oidc.ts @@ -5,6 +5,8 @@ import jwt from 'jsonwebtoken'; import { db } from '../db/database'; import { JWT_SECRET } from '../config'; import { User } from '../types'; +import { decrypt_api_key } from '../services/apiKeyCrypto'; +import { setAuthCookie } from '../services/cookie'; interface OidcDiscoveryDoc { authorization_endpoint: string; @@ -57,24 +59,26 @@ function getOidcConfig() { const get = (key: string) => (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || null; const issuer = process.env.OIDC_ISSUER || get('oidc_issuer'); const clientId = process.env.OIDC_CLIENT_ID || get('oidc_client_id'); - const clientSecret = process.env.OIDC_CLIENT_SECRET || get('oidc_client_secret'); + const clientSecret = process.env.OIDC_CLIENT_SECRET || decrypt_api_key(get('oidc_client_secret')); const displayName = process.env.OIDC_DISPLAY_NAME || get('oidc_display_name') || 'SSO'; + const discoveryUrl = process.env.OIDC_DISCOVERY_URL || get('oidc_discovery_url') || null; if (!issuer || !clientId || !clientSecret) return null; - return { issuer: issuer.replace(/\/+$/, ''), clientId, clientSecret, displayName }; + return { issuer: issuer.replace(/\/+$/, ''), clientId, clientSecret, displayName, discoveryUrl }; } let discoveryCache: OidcDiscoveryDoc | null = null; let discoveryCacheTime = 0; const DISCOVERY_TTL = 60 * 60 * 1000; // 1 hour -async function discover(issuer: string) { - if (discoveryCache && Date.now() - discoveryCacheTime < DISCOVERY_TTL && discoveryCache._issuer === issuer) { +async function discover(issuer: string, discoveryUrl?: string | null) { + const url = discoveryUrl || `${issuer}/.well-known/openid-configuration`; + if (discoveryCache && Date.now() - discoveryCacheTime < DISCOVERY_TTL && discoveryCache._issuer === url) { return discoveryCache; } - const res = await fetch(`${issuer}/.well-known/openid-configuration`); + const res = await fetch(url); if (!res.ok) throw new Error('Failed to fetch OIDC discovery document'); const doc = await res.json() as OidcDiscoveryDoc; - doc._issuer = issuer; + doc._issuer = url; discoveryCache = doc; discoveryCacheTime = Date.now(); return doc; @@ -119,17 +123,13 @@ router.get('/login', async (req: Request, res: Response) => { } try { - const doc = await discover(config.issuer); + const doc = await discover(config.issuer, config.discoveryUrl); const state = crypto.randomBytes(32).toString('hex'); const appUrl = process.env.APP_URL || (db.prepare("SELECT value FROM app_settings WHERE key = 'app_url'").get() as { value: string } | undefined)?.value; - let redirectUri: string; - if (appUrl) { - redirectUri = `${appUrl.replace(/\/+$/, '')}/api/auth/oidc/callback`; - } else { - const proto = (req.headers['x-forwarded-proto'] as string) || req.protocol; - const host = (req.headers['x-forwarded-host'] as string) || req.headers.host; - redirectUri = `${proto}://${host}/api/auth/oidc/callback`; + if (!appUrl) { + return res.status(500).json({ error: 'APP_URL is not configured. OIDC cannot be used.' }); } + const redirectUri = `${appUrl.replace(/\/+$/, '')}/api/auth/oidc/callback`; const inviteToken = req.query.invite as string | undefined; pendingStates.set(state, { createdAt: Date.now(), redirectUri, inviteToken }); @@ -175,7 +175,7 @@ router.get('/callback', async (req: Request, res: Response) => { } try { - const doc = await discover(config.issuer); + const doc = await discover(config.issuer, config.discoveryUrl); const tokenRes = await fetch(doc.token_endpoint, { method: 'POST', @@ -290,6 +290,7 @@ router.get('/exchange', (req: Request, res: Response) => { if (!entry) return res.status(400).json({ error: 'Invalid or expired code' }); authCodes.delete(code); if (Date.now() - entry.created > AUTH_CODE_TTL) return res.status(400).json({ error: 'Code expired' }); + setAuthCookie(res, entry.token); res.json({ token: entry.token }); }); diff --git a/server/src/routes/trips.ts b/server/src/routes/trips.ts index 4b5cbac..651a479 100644 --- a/server/src/routes/trips.ts +++ b/server/src/routes/trips.ts @@ -396,7 +396,12 @@ router.get('/:id/export.ics', authenticate, (req: Request, res: Response) => { const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(req.params.id) as any[]; const reservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ?').all(req.params.id) as any[]; - const esc = (s: string) => s.replace(/[\\;,\n]/g, m => m === '\n' ? '\\n' : '\\' + m); + const esc = (s: string) => s + .replace(/\\/g, '\\\\') + .replace(/;/g, '\\;') + .replace(/,/g, '\\,') + .replace(/\r?\n/g, '\\n') + .replace(/\r/g, ''); const fmtDate = (d: string) => d.replace(/-/g, ''); const now = new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'; const uid = (id: number, type: string) => `trek-${type}-${id}@trek`; @@ -444,14 +449,14 @@ router.get('/:id/export.ics', authenticate, (req: Request, res: Response) => { ics += `SUMMARY:${esc(r.title)}\r\n`; let desc = r.type ? `Type: ${r.type}` : ''; - if (r.confirmation_number) desc += `\\nConfirmation: ${r.confirmation_number}`; - if (meta.airline) desc += `\\nAirline: ${meta.airline}`; - if (meta.flight_number) desc += `\\nFlight: ${meta.flight_number}`; - if (meta.departure_airport) desc += `\\nFrom: ${meta.departure_airport}`; - if (meta.arrival_airport) desc += `\\nTo: ${meta.arrival_airport}`; - if (meta.train_number) desc += `\\nTrain: ${meta.train_number}`; - if (r.notes) desc += `\\n${r.notes}`; - if (desc) ics += `DESCRIPTION:${desc}\r\n`; + if (r.confirmation_number) desc += `\nConfirmation: ${r.confirmation_number}`; + if (meta.airline) desc += `\nAirline: ${meta.airline}`; + if (meta.flight_number) desc += `\nFlight: ${meta.flight_number}`; + if (meta.departure_airport) desc += `\nFrom: ${meta.departure_airport}`; + if (meta.arrival_airport) desc += `\nTo: ${meta.arrival_airport}`; + if (meta.train_number) desc += `\nTrain: ${meta.train_number}`; + if (r.notes) desc += `\n${r.notes}`; + if (desc) ics += `DESCRIPTION:${esc(desc)}\r\n`; if (r.location) ics += `LOCATION:${esc(r.location)}\r\n`; ics += `END:VEVENT\r\n`; } @@ -459,7 +464,8 @@ router.get('/:id/export.ics', authenticate, (req: Request, res: Response) => { ics += 'END:VCALENDAR\r\n'; res.setHeader('Content-Type', 'text/calendar; charset=utf-8'); - res.setHeader('Content-Disposition', `attachment; filename="${esc(trip.title || 'trek-trip')}.ics"`); + const safeFilename = (trip.title || 'trek-trip').replace(/["\r\n]/g, '').replace(/[^\w\s.-]/g, '_'); + res.setHeader('Content-Disposition', `attachment; filename="${safeFilename}.ics"`); res.send(ics); }); diff --git a/server/src/services/apiKeyCrypto.ts b/server/src/services/apiKeyCrypto.ts index 881b840..0ad9cb2 100644 --- a/server/src/services/apiKeyCrypto.ts +++ b/server/src/services/apiKeyCrypto.ts @@ -1,10 +1,10 @@ import * as crypto from 'crypto'; -import { JWT_SECRET } from '../config'; +import { ENCRYPTION_KEY } from '../config'; const ENCRYPTED_PREFIX = 'enc:v1:'; function get_key() { - return crypto.createHash('sha256').update(`${JWT_SECRET}:api_keys:v1`).digest(); + return crypto.createHash('sha256').update(`${ENCRYPTION_KEY}:api_keys:v1`).digest(); } export function encrypt_api_key(plain: unknown) { diff --git a/server/src/services/auditLog.ts b/server/src/services/auditLog.ts index 527d514..03476ec 100644 --- a/server/src/services/auditLog.ts +++ b/server/src/services/auditLog.ts @@ -108,6 +108,7 @@ const ACTION_LABELS: Record = { 'admin.user_role_change': 'changed user role', 'admin.user_delete': 'deleted user', 'admin.invite_create': 'created invite', + 'immich.private_ip_configured': 'configured Immich with private IP', }; /** Best-effort; never throws — failures are logged only. */ @@ -158,6 +159,9 @@ function buildInfoSummary(action: string, details?: Record): st if (details.require_mfa !== undefined) parts.push(`mfa=${details.require_mfa}`); return parts.length ? ` (${parts.join(', ')})` : ''; } + if (action === 'immich.private_ip_configured') { + return details.resolved_ip ? ` url=${details.immich_url} ip=${details.resolved_ip}` : ''; + } return ''; } diff --git a/server/src/services/cookie.ts b/server/src/services/cookie.ts new file mode 100644 index 0000000..448e25c --- /dev/null +++ b/server/src/services/cookie.ts @@ -0,0 +1,22 @@ +import { Response } from 'express'; + +const COOKIE_NAME = 'trek_session'; + +function cookieOptions(clear = false) { + const secure = process.env.NODE_ENV === 'production' || process.env.FORCE_HTTPS === 'true'; + return { + httpOnly: true, + secure, + sameSite: 'strict' as const, + path: '/', + ...(clear ? {} : { maxAge: 24 * 60 * 60 * 1000 }), // 24h — matches JWT expiry + }; +} + +export function setAuthCookie(res: Response, token: string): void { + res.cookie(COOKIE_NAME, token, cookieOptions()); +} + +export function clearAuthCookie(res: Response): void { + res.clearCookie(COOKIE_NAME, cookieOptions(true)); +} diff --git a/server/src/services/ephemeralTokens.ts b/server/src/services/ephemeralTokens.ts new file mode 100644 index 0000000..0d1c12b --- /dev/null +++ b/server/src/services/ephemeralTokens.ts @@ -0,0 +1,54 @@ +import crypto from 'crypto'; + +const TTL: Record = { + ws: 30_000, + download: 60_000, + immich: 60_000, +}; + +const MAX_STORE_SIZE = 10_000; + +interface TokenEntry { + userId: number; + purpose: string; + expiresAt: number; +} + +const store = new Map(); + +export function createEphemeralToken(userId: number, purpose: string): string | null { + if (store.size >= MAX_STORE_SIZE) return null; + const token = crypto.randomBytes(32).toString('hex'); + const ttl = TTL[purpose] ?? 60_000; + store.set(token, { userId, purpose, expiresAt: Date.now() + ttl }); + return token; +} + +export function consumeEphemeralToken(token: string, purpose: string): number | null { + const entry = store.get(token); + if (!entry) return null; + store.delete(token); + if (entry.purpose !== purpose || Date.now() > entry.expiresAt) return null; + return entry.userId; +} + +let cleanupInterval: ReturnType | null = null; + +export function startTokenCleanup(): void { + if (cleanupInterval) return; + cleanupInterval = setInterval(() => { + const now = Date.now(); + for (const [token, entry] of store) { + if (now > entry.expiresAt) store.delete(token); + } + }, 60_000); + // Allow process to exit even if interval is active + if (cleanupInterval.unref) cleanupInterval.unref(); +} + +export function stopTokenCleanup(): void { + if (cleanupInterval) { + clearInterval(cleanupInterval); + cleanupInterval = null; + } +} diff --git a/server/src/services/mfaCrypto.ts b/server/src/services/mfaCrypto.ts index 748f9bd..2a64743 100644 --- a/server/src/services/mfaCrypto.ts +++ b/server/src/services/mfaCrypto.ts @@ -1,8 +1,8 @@ import crypto from 'crypto'; -import { JWT_SECRET } from '../config'; +import { ENCRYPTION_KEY } from '../config'; function getKey(): Buffer { - return crypto.createHash('sha256').update(`${JWT_SECRET}:mfa:v1`).digest(); + return crypto.createHash('sha256').update(`${ENCRYPTION_KEY}:mfa:v1`).digest(); } /** Encrypt TOTP secret for storage in SQLite. */ diff --git a/server/src/services/notifications.ts b/server/src/services/notifications.ts index 53e4e5c..06f5f76 100644 --- a/server/src/services/notifications.ts +++ b/server/src/services/notifications.ts @@ -1,6 +1,7 @@ import nodemailer from 'nodemailer'; import fetch from 'node-fetch'; import { db } from '../db/database'; +import { decrypt_api_key } from './apiKeyCrypto'; import { logInfo, logDebug, logError } from './auditLog'; // ── Types ────────────────────────────────────────────────────────────────── @@ -32,7 +33,7 @@ function getSmtpConfig(): SmtpConfig | null { const host = process.env.SMTP_HOST || getAppSetting('smtp_host'); const port = process.env.SMTP_PORT || getAppSetting('smtp_port'); const user = process.env.SMTP_USER || getAppSetting('smtp_user'); - const pass = process.env.SMTP_PASS || getAppSetting('smtp_pass'); + const pass = process.env.SMTP_PASS || decrypt_api_key(getAppSetting('smtp_pass')) || ''; const from = process.env.SMTP_FROM || getAppSetting('smtp_from'); if (!host || !port || !from) return null; return { host, port: parseInt(port, 10), user: user || '', pass: pass || '', from, secure: parseInt(port, 10) === 465 }; diff --git a/server/src/services/passwordPolicy.ts b/server/src/services/passwordPolicy.ts new file mode 100644 index 0000000..9b20143 --- /dev/null +++ b/server/src/services/passwordPolicy.ts @@ -0,0 +1,28 @@ +const COMMON_PASSWORDS = new Set([ + 'password', '12345678', '123456789', '1234567890', 'password1', + 'qwerty123', 'iloveyou', 'admin123', 'letmein12', 'welcome1', + 'monkey123', 'dragon12', 'master12', 'qwerty12', 'abc12345', + 'trustno1', 'baseball', 'football', 'shadow12', 'michael1', + 'jennifer', 'superman', 'abcdefgh', 'abcd1234', 'password123', + 'admin1234', 'changeme', 'welcome123', 'passw0rd', 'p@ssword', +]); + +export function validatePassword(password: string): { ok: boolean; reason?: string } { + if (password.length < 8) return { ok: false, reason: 'Password must be at least 8 characters' }; + + if (/^(.)\1+$/.test(password)) { + return { ok: false, reason: 'Password is too repetitive' }; + } + + if (COMMON_PASSWORDS.has(password.toLowerCase())) { + return { ok: false, reason: 'Password is too common. Please choose a unique password.' }; + } + + const requirementsMessage = 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character'; + if (!/[A-Z]/.test(password)) return { ok: false, reason: requirementsMessage }; + if (!/[a-z]/.test(password)) return { ok: false, reason: requirementsMessage }; + if (!/[0-9]/.test(password)) return { ok: false, reason: requirementsMessage }; + if (!/[^A-Za-z0-9]/.test(password)) return { ok: false, reason: requirementsMessage }; + + return { ok: true }; +} diff --git a/server/src/utils/ssrfGuard.ts b/server/src/utils/ssrfGuard.ts new file mode 100644 index 0000000..6882cdc --- /dev/null +++ b/server/src/utils/ssrfGuard.ts @@ -0,0 +1,122 @@ +import dns from 'dns/promises'; +import http from 'http'; +import https from 'https'; + +const ALLOW_INTERNAL_NETWORK = process.env.ALLOW_INTERNAL_NETWORK === 'true'; + +export interface SsrfResult { + allowed: boolean; + resolvedIp?: string; + isPrivate: boolean; + error?: string; +} + +// Always blocked — no override possible +function isAlwaysBlocked(ip: string): boolean { + // Strip IPv6 brackets + const addr = ip.startsWith('[') ? ip.slice(1, -1) : ip; + + // Loopback + if (/^127\./.test(addr) || addr === '::1') return true; + // Unspecified + if (/^0\./.test(addr)) return true; + // Link-local / cloud metadata + if (/^169\.254\./.test(addr) || /^fe80:/i.test(addr)) return true; + // IPv4-mapped loopback / link-local: ::ffff:127.x.x.x, ::ffff:169.254.x.x + if (/^::ffff:127\./i.test(addr) || /^::ffff:169\.254\./i.test(addr)) return true; + + return false; +} + +// Blocked unless ALLOW_INTERNAL_NETWORK=true +function isPrivateNetwork(ip: string): boolean { + const addr = ip.startsWith('[') ? ip.slice(1, -1) : ip; + + // RFC-1918 private ranges + if (/^10\./.test(addr)) return true; + if (/^172\.(1[6-9]|2\d|3[01])\./.test(addr)) return true; + if (/^192\.168\./.test(addr)) return true; + // CGNAT / Tailscale shared address space (100.64.0.0/10) + if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(addr)) return true; + // IPv6 ULA (fc00::/7) + if (/^f[cd]/i.test(addr)) return true; + // IPv4-mapped RFC-1918 + if (/^::ffff:10\./i.test(addr)) return true; + if (/^::ffff:172\.(1[6-9]|2\d|3[01])\./i.test(addr)) return true; + if (/^::ffff:192\.168\./i.test(addr)) return true; + + return false; +} + +function isInternalHostname(hostname: string): boolean { + const h = hostname.toLowerCase(); + return h.endsWith('.local') || h.endsWith('.internal') || h === 'localhost'; +} + +export async function checkSsrf(rawUrl: string): Promise { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + return { allowed: false, isPrivate: false, error: 'Invalid URL' }; + } + + if (!['http:', 'https:'].includes(url.protocol)) { + return { allowed: false, isPrivate: false, error: 'Only HTTP and HTTPS URLs are allowed' }; + } + + const hostname = url.hostname.toLowerCase(); + + // Block internal hostname suffixes (no override — these are too easy to abuse) + if (isInternalHostname(hostname) && hostname !== 'localhost') { + return { allowed: false, isPrivate: false, error: 'Requests to .local/.internal domains are not allowed' }; + } + + // Resolve hostname to IP + let resolvedIp: string; + try { + const result = await dns.lookup(hostname); + resolvedIp = result.address; + } catch { + return { allowed: false, isPrivate: false, error: 'Could not resolve hostname' }; + } + + if (isAlwaysBlocked(resolvedIp)) { + return { + allowed: false, + isPrivate: true, + resolvedIp, + error: 'Requests to loopback and link-local addresses are not allowed', + }; + } + + if (isPrivateNetwork(resolvedIp) || isInternalHostname(hostname)) { + if (!ALLOW_INTERNAL_NETWORK) { + return { + allowed: false, + isPrivate: true, + resolvedIp, + error: 'Requests to private/internal network addresses are not allowed. Set ALLOW_INTERNAL_NETWORK=true to permit this for self-hosted setups.', + }; + } + return { allowed: true, isPrivate: true, resolvedIp }; + } + + return { allowed: true, isPrivate: false, resolvedIp }; +} + +/** + * Returns an http/https Agent whose `lookup` function is pinned to the + * already-validated IP. This prevents DNS rebinding (TOCTOU) by ensuring + * the outbound connection goes to the IP we checked, not a re-resolved one. + */ +export function createPinnedAgent(resolvedIp: string, protocol: string): http.Agent | https.Agent { + const options = { + lookup: (_hostname: string, _opts: unknown, callback: (err: Error | null, addr: string, family: number) => void) => { + // Determine address family from IP format + const family = resolvedIp.includes(':') ? 6 : 4; + callback(null, resolvedIp, family); + }, + }; + return protocol === 'https:' ? new https.Agent(options) : new http.Agent(options); +} diff --git a/server/src/websocket.ts b/server/src/websocket.ts index 69b1d7e..3bc310c 100644 --- a/server/src/websocket.ts +++ b/server/src/websocket.ts @@ -1,7 +1,6 @@ import { WebSocketServer, WebSocket } from 'ws'; -import jwt from 'jsonwebtoken'; -import { JWT_SECRET } from './config'; import { db, canAccessTrip } from './db/database'; +import { consumeEphemeralToken } from './services/ephemeralTokens'; import { User } from './types'; import http from 'http'; @@ -70,27 +69,27 @@ function setupWebSocket(server: http.Server): void { return; } - let user: User | undefined; - try { - const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number }; - user = db.prepare( - 'SELECT id, username, email, role, mfa_enabled FROM users WHERE id = ?' - ).get(decoded.id) as User | undefined; - if (!user) { - nws.close(4001, 'User not found'); - return; - } - const requireMfa = (db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined)?.value === 'true'; - const mfaOk = user.mfa_enabled === 1 || user.mfa_enabled === true; - if (requireMfa && !mfaOk) { - nws.close(4403, 'MFA required'); - return; - } - } catch (err: unknown) { + const userId = consumeEphemeralToken(token, 'ws'); + if (!userId) { nws.close(4001, 'Invalid or expired token'); return; } + let user: User | undefined; + user = db.prepare( + 'SELECT id, username, email, role, mfa_enabled FROM users WHERE id = ?' + ).get(userId) as User | undefined; + if (!user) { + nws.close(4001, 'User not found'); + return; + } + const requireMfa = (db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined)?.value === 'true'; + const mfaOk = user.mfa_enabled === 1 || user.mfa_enabled === true; + if (requireMfa && !mfaOk) { + nws.close(4403, 'MFA required'); + return; + } + nws.isAlive = true; const sid = nextSocketId++; socketId.set(nws, sid);