Merge pull request #257 from jubnl/dev

Security hardening, encryption at rest
This commit is contained in:
Maurice
2026-04-01 17:42:43 +02:00
committed by GitHub
59 changed files with 1449 additions and 624 deletions

View File

@@ -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 | — |

View File

@@ -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.

View File

@@ -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.

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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<string, PermissionLevel> }) => {
if (config?.demo_mode) setDemoMode(true)
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)

16
client/src/api/authUrl.ts Normal file
View File

@@ -0,0 +1,16 @@
export async function getAuthUrl(url: string, purpose: 'download' | 'immich'): Promise<string> {
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
}
}

View File

@@ -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<string, unknown>) => 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<string, string>) => 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<void> => {
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()

View File

@@ -9,9 +9,10 @@ let reconnectDelay = 1000
const MAX_RECONNECT_DELAY = 30000
const listeners = new Set<WebSocketListener>()
const activeTrips = new Set<string>()
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<string | null> {
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<void> {
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

View File

@@ -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 (
<div
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
@@ -56,16 +56,20 @@ function ImageLightbox({ file, onClose }: ImageLightboxProps) {
>
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
<img
src={authUrl(file.url)}
src={imgSrc}
alt={file.original_name}
style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }}
/>
<div style={{ position: 'absolute', top: -40, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 4px' }}>
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '80%' }}>{file.original_name}</span>
<div style={{ display: 'flex', gap: 8 }}>
<a href={authUrl(file.url)} target="_blank" rel="noreferrer" style={{ color: 'rgba(255,255,255,0.7)', display: 'flex' }} title={t('files.openTab')}>
<button
onClick={async () => { const u = await getAuthUrl(file.url, 'download'); window.open(u, '_blank', 'noreferrer') }}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}
title={t('files.openTab')}
>
<ExternalLink size={16} />
</a>
</button>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}>
<X size={18} />
</button>
@@ -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 ? <img src={authSrc} alt="" style={style} /> : 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<number | null>(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 (
<div key={file.id} style={{
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
@@ -337,7 +356,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
>
{/* Icon or thumbnail */}
<div
onClick={() => !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)
? <img src={fileUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
? <AuthedImg src={file.url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: (() => {
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 ? <Star size={12} fill="#facc15" color="#facc15" style={{ flexShrink: 0 }} /> : null}
<span
onClick={() => !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)'}>
<Pencil size={14} />
</button>}
<button onClick={() => openFile({ ...file, url: fileUrl })} title={t('common.open')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
<button onClick={() => openFile(file)} title={t('common.open')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<ExternalLink size={14} />
</button>
@@ -633,12 +652,13 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<a href={authUrl(previewFile.url)} target="_blank" rel="noreferrer"
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}>
<button
onClick={async () => { const u = await getAuthUrl(previewFile.url, 'download'); window.open(u, '_blank', 'noreferrer') }}
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'}
onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}>
<ExternalLink size={13} /> {t('files.openTab')}
</a>
</button>
<button onClick={() => setPreviewFile(null)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 4, borderRadius: 6, transition: 'color 0.15s' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
@@ -648,13 +668,13 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
</div>
</div>
<object
data={`${authUrl(previewFile.url)}#view=FitH`}
data={previewFileUrl ? `${previewFileUrl}#view=FitH` : undefined}
type="application/pdf"
style={{ flex: 1, width: '100%', border: 'none' }}
title={previewFile.original_name}
>
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
<a href={authUrl(previewFile.url)} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-primary)', textDecoration: 'underline' }}>PDF herunterladen</a>
<button onClick={async () => { const u = await getAuthUrl(previewFile.url, 'download'); window.open(u, '_blank', 'noopener noreferrer') }} style={{ color: 'var(--text-primary)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer', font: 'inherit' }}>PDF herunterladen</button>
</p>
</object>
</div>

View File

@@ -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 ? <img src={src} alt="" loading={loading} style={style} /> : null
}
// ── Types ───────────────────────────────────────────────────────────────────
@@ -110,6 +119,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
const [lightboxUserId, setLightboxUserId] = useState<number | null>(null)
const [lightboxInfo, setLightboxInfo] = useState<any>(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,
}}>
<img src={thumbnailUrl(asset.id, currentUser!.id)} alt="" loading="lazy"
<ImmichImg baseUrl={thumbnailBaseUrl(asset.id, currentUser!.id)} loading="lazy"
style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
{isSelected && (
<div style={{
@@ -626,12 +631,14 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
style={{ position: 'relative', aspectRatio: '1', borderRadius: 10, overflow: 'visible', cursor: 'pointer' }}
onClick={() => {
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))
}}>
<img src={thumbnailUrl(photo.immich_asset_id, photo.user_id)} alt="" loading="lazy"
<ImmichImg baseUrl={thumbnailBaseUrl(photo.immich_asset_id, photo.user_id)} loading="lazy"
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 10 }} />
{/* Other user's avatar */}
@@ -748,7 +755,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
</button>
<div onClick={e => e.stopPropagation()} style={{ display: 'flex', gap: 16, alignItems: 'flex-start', justifyContent: 'center', padding: 20, width: '100%', height: '100%' }}>
<img
src={originalUrl(lightboxId, lightboxUserId)}
src={lightboxOriginalSrc}
alt=""
style={{ maxWidth: lightboxInfo ? 'calc(100% - 280px)' : '100%', maxHeight: '100%', objectFit: 'contain', borderRadius: 10, cursor: 'default' }}
/>

View File

@@ -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()

View File

@@ -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 && (
<div style={{ padding: '0 12px 10px', display: 'flex', flexDirection: 'column', gap: 4 }}>
{placeFiles.map(f => (
<a key={f.id} href={authUrl(f.url)} target="_blank" rel="noopener noreferrer" style={{ display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none', cursor: 'pointer' }}>
<button key={f.id} onClick={async () => { const u = await getAuthUrl(f.url, 'download'); window.open(u, '_blank', 'noopener noreferrer') }} style={{ display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none', cursor: 'pointer', background: 'none', border: 'none', width: '100%', textAlign: 'left' }}>
{(f.mime_type || '').startsWith('image/') ? <FileImage size={12} color="#6b7280" /> : <File size={12} color="#6b7280" />}
<span style={{ fontSize: 12, color: 'var(--text-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
{f.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)', flexShrink: 0 }}>{formatFileSize(f.file_size)}</span>}
</a>
</button>
))}
</div>
)}

View File

@@ -257,7 +257,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'settings.passwordRequired': 'أدخل كلمة المرور الحالية والجديدة',
'settings.passwordTooShort': 'يجب أن تتكون كلمة المرور من 8 أحرف على الأقل',
'settings.passwordMismatch': 'كلمتا المرور غير متطابقتين',
'settings.passwordWeak': 'يجب أن تحتوي كلمة المرور على حرف كبير وحرف صغير ورقم',
'settings.passwordWeak': 'يجب أن تحتوي كلمة المرور على حرف كبير وحرف صغير ورقم ورمز خاص',
'settings.passwordChanged': 'تم تغيير كلمة المرور بنجاح',
'settings.deleteAccount': 'حذف الحساب',
'settings.deleteAccountTitle': 'هل تريد حذف حسابك؟',
@@ -356,7 +356,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
// Register
'register.passwordMismatch': 'كلمتا المرور غير متطابقتين',
'register.passwordTooShort': 'يجب أن تتكون كلمة المرور من 6 أحرف على الأقل',
'register.passwordTooShort': 'يجب أن تتكون كلمة المرور من 8 أحرف على الأقل',
'register.failed': 'فشل التسجيل',
'register.getStarted': 'ابدأ الآن',
'register.subtitle': 'أنشئ حسابًا وابدأ التخطيط لرحلات أحلامك.',

View File

@@ -226,7 +226,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'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<string, string | { name: string; category: string }[]> = {
// 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.',

View File

@@ -204,7 +204,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'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<string, string | { name: string; category: string }[]> = {
// 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.',

View File

@@ -252,7 +252,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'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<string, string | { name: string; category: string }[]> = {
// 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.',

View File

@@ -251,7 +251,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'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<string, string | { name: string; category: string }[]> = {
// 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.',

View File

@@ -349,7 +349,7 @@ const es: Record<string, string> = {
// 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<string, string> = {
// 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',

View File

@@ -252,7 +252,7 @@ const fr: Record<string, string> = {
'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<string, string> = {
// 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.',

View File

@@ -202,7 +202,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'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<string, string | { name: string; category: string }[]> = {
// 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.',

View File

@@ -203,7 +203,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'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<string, string | { name: string; category: string }[]> = {
// 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.',

View File

@@ -252,7 +252,7 @@ const nl: Record<string, string> = {
'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<string, string> = {
// 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.',

View File

@@ -252,7 +252,7 @@ const ru: Record<string, string> = {
'settings.passwordRequired': 'Введите текущий и новый пароль',
'settings.passwordTooShort': 'Пароль должен содержать не менее 8 символов',
'settings.passwordMismatch': 'Пароли не совпадают',
'settings.passwordWeak': 'Пароль должен содержать заглавные, строчные буквы и цифру',
'settings.passwordWeak': 'Пароль должен содержать заглавные, строчные буквы, цифру и специальный символ',
'settings.passwordChanged': 'Пароль успешно изменён',
'settings.deleteAccount': 'Удалить аккаунт',
'settings.deleteAccountTitle': 'Удалить ваш аккаунт?',
@@ -351,7 +351,7 @@ const ru: Record<string, string> = {
// Register
'register.passwordMismatch': 'Пароли не совпадают',
'register.passwordTooShort': 'Пароль должен содержать не менее 6 символов',
'register.passwordTooShort': 'Пароль должен содержать не менее 8 символов',
'register.failed': 'Ошибка регистрации',
'register.getStarted': 'Начать',
'register.subtitle': 'Создайте аккаунт и начните планировать поездки мечты.',

View File

@@ -252,7 +252,7 @@ const zh: Record<string, string> = {
'settings.passwordRequired': '请输入当前密码和新密码',
'settings.passwordTooShort': '密码至少需要 8 个字符',
'settings.passwordMismatch': '两次输入的密码不一致',
'settings.passwordWeak': '密码必须包含大写字母、小写字母和数字',
'settings.passwordWeak': '密码必须包含大写字母、小写字母、数字和特殊字符',
'settings.passwordChanged': '密码修改成功',
'settings.deleteAccount': '删除账户',
'settings.deleteAccountTitle': '确定删除账户?',
@@ -351,7 +351,7 @@ const zh: Record<string, string> = {
// Register
'register.passwordMismatch': '两次输入的密码不一致',
'register.passwordTooShort': '密码至少需要 6 个字符',
'register.passwordTooShort': '密码至少需要 8 个字符',
'register.failed': '注册失败',
'register.getStarted': '开始使用',
'register.subtitle': '创建账户,开始规划你的梦想旅行。',

View File

@@ -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<OidcConfig>({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '', oidc_only: false })
const [oidcConfig, setOidcConfig] = useState<OidcConfig>({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '', oidc_only: false, discovery_url: '' })
const [savingOidc, setSavingOidc] = useState<boolean>(false)
// Registration toggle
@@ -122,13 +123,14 @@ export default function AdminPage(): React.ReactElement {
// Version check & update
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null)
const [showUpdateModal, setShowUpdateModal] = useState<boolean>(false)
const [updating, setUpdating] = useState<boolean>(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<boolean>(false)
const [rotatingJwt, setRotatingJwt] = useState<boolean>(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')}
</a>
)}
{updateInfo.is_docker ? (
<button
onClick={() => setShowUpdateModal(true)}
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-semibold transition-colors bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-700 dark:hover:bg-gray-200"
>
<Download className="w-4 h-4" />
{t('admin.update.howTo')}
</button>
) : (
<button
onClick={() => setShowUpdateModal(true)}
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-semibold transition-colors bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-700 dark:hover:bg-gray-200"
>
<Download className="w-4 h-4" />
{t('admin.update.install')}
</button>
)}
<button
onClick={() => setShowUpdateModal(true)}
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-semibold transition-colors bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-700 dark:hover:bg-gray-200"
>
<Download className="w-4 h-4" />
{t('admin.update.howTo')}
</button>
</div>
</div>
)}
@@ -914,6 +896,17 @@ export default function AdminPage(): React.ReactElement {
/>
<p className="text-xs text-slate-400 mt-1">{t('admin.oidcIssuerHint')}</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Discovery URL <span className="text-slate-400 font-normal">(optional)</span></label>
<input
type="url"
value={oidcConfig.discovery_url}
onChange={e => 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"
/>
<p className="text-xs text-slate-400 mt-1">Override the auto-constructed discovery URL. Required for providers like Authentik where the endpoint is not at <code className="bg-slate-100 px-1 rounded">{'<issuer>/.well-known/openid-configuration'}</code>.</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Client ID</label>
<input
@@ -955,7 +948,7 @@ export default function AdminPage(): React.ReactElement {
onClick={async () => {
setSavingOidc(true)
try {
const payload: Record<string, unknown> = { issuer: oidcConfig.issuer, client_id: oidcConfig.client_id, display_name: oidcConfig.display_name, oidc_only: oidcConfig.oidc_only }
const payload: Record<string, unknown> = { 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 {
</div>
</div>
</div>
{/* Danger Zone */}
<div className="bg-white rounded-xl border border-red-200 overflow-hidden">
<div className="px-6 py-4 border-b border-red-100 bg-red-50">
<h2 className="font-semibold text-red-700 flex items-center gap-2">
<AlertTriangle className="w-4 h-4" />
Danger Zone
</h2>
</div>
<div className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-700">Rotate JWT Secret</p>
<p className="text-xs text-slate-400 mt-0.5">Generate a new JWT signing secret. All active sessions will be invalidated immediately.</p>
</div>
<button
onClick={() => setShowRotateJwtModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm font-medium transition-colors"
>
<RefreshCw className="w-4 h-4" />
Rotate
</button>
</div>
</div>
</div>
</div>
)}
@@ -1304,78 +1322,37 @@ export default function AdminPage(): React.ReactElement {
)}
</Modal>
{/* Update confirmation popup — matches backup restore style */}
{/* Update instructions popup */}
{showUpdateModal && (
<div
style={{ position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
onClick={() => { if (!updating) setShowUpdateModal(false) }}
onClick={() => setShowUpdateModal(false)}
>
<div
onClick={e => 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' ? (
<>
<div style={{ background: 'linear-gradient(135deg, #16a34a, #15803d)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<CheckCircle size={20} style={{ color: 'white' }} />
</div>
<div>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>{t('admin.update.success')}</h3>
</div>
</div>
<div style={{ padding: '20px 24px', textAlign: 'center' }}>
<RefreshCw className="w-5 h-5 animate-spin mx-auto mb-2" style={{ color: 'var(--text-muted)' }} />
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>{t('admin.update.reloadHint')}</p>
</div>
</>
) : updateResult === 'error' ? (
<>
<div style={{ background: 'linear-gradient(135deg, #dc2626, #b91c1c)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<XCircle size={20} style={{ color: 'white' }} />
</div>
<div>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>{t('admin.update.failed')}</h3>
</div>
</div>
<div style={{ padding: '0 24px 20px', display: 'flex', justifyContent: 'flex-end', marginTop: 16 }}>
<button
onClick={() => { setShowUpdateModal(false); setUpdateResult(null) }}
className="bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-700 dark:hover:bg-gray-200"
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
>
{t('common.cancel')}
</button>
</div>
</>
) : (
<>
{/* Red header */}
<div style={{ background: 'linear-gradient(135deg, #dc2626, #b91c1c)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<AlertTriangle size={20} style={{ color: 'white' }} />
</div>
<div>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>{t('admin.update.confirmTitle')}</h3>
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'rgba(255,255,255,0.8)' }}>
v{updateInfo?.current} v{updateInfo?.latest}
</p>
</div>
</div>
<div style={{ background: 'linear-gradient(135deg, #0f172a, #1e293b)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<ArrowUpCircle size={20} style={{ color: 'white' }} />
</div>
<div>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>{t('admin.update.howTo')}</h3>
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'rgba(255,255,255,0.8)' }}>
v{updateInfo?.current} v{updateInfo?.latest}
</p>
</div>
</div>
{/* Body */}
<div style={{ padding: '20px 24px' }}>
{updateInfo?.is_docker ? (
<>
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
{t('admin.update.dockerText').replace('{version}', `v${updateInfo.latest}`)}
</p>
<div style={{ padding: '20px 24px' }}>
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
{t('admin.update.dockerText').replace('{version}', `v${updateInfo?.latest ?? ''}`)}
</p>
<div style={{ marginTop: 14, padding: '12px 14px', borderRadius: 10, fontSize: 12, lineHeight: 1.8, fontFamily: 'monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
className="bg-gray-900 dark:bg-gray-950 text-gray-100 border border-gray-700"
>
<div style={{ marginTop: 14, padding: '12px 14px', borderRadius: 10, fontSize: 12, lineHeight: 1.8, fontFamily: 'monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
className="bg-gray-900 dark:bg-gray-950 text-gray-100 border border-gray-700"
>
{`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`}
</div>
</div>
<div style={{ marginTop: 10, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
className="bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-800"
>
<div className="flex items-start gap-2">
<CheckCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
<span>{t('admin.update.dataInfo')}</span>
</div>
</div>
</>
) : (
<>
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
{updateInfo && t('admin.update.confirmText').replace('{current}', `v${updateInfo.current}`).replace('{version}', `v${updateInfo.latest}`)}
</p>
<div style={{ marginTop: 14, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
className="bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-800"
>
<div className="flex items-start gap-2">
<CheckCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
<span>{t('admin.update.dataInfo')}</span>
</div>
</div>
<div style={{ marginTop: 10, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
className="bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800"
>
<div className="flex items-start gap-2">
<Download className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
<span>
{t('admin.update.backupHint')}{' '}
<button
onClick={() => { setShowUpdateModal(false); setActiveTab('backup') }}
className="underline font-semibold hover:text-blue-950 dark:hover:text-blue-100"
>{t('admin.update.backupLink')}</button>
</span>
</div>
</div>
<div style={{ marginTop: 10, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
className="bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800"
>
<div className="flex items-start gap-2">
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
<span>{t('admin.update.warning')}</span>
</div>
</div>
</>
)}
<div style={{ marginTop: 10, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
className="bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-800"
>
<div className="flex items-start gap-2">
<CheckCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
<span>{t('admin.update.dataInfo')}</span>
</div>
</div>
{/* Footer */}
<div style={{ padding: '0 24px 20px', display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<button
onClick={() => setShowUpdateModal(false)}
disabled={updating}
className="text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40"
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
>
{t('common.cancel')}
</button>
{!updateInfo?.is_docker && (
<button
onClick={handleInstallUpdate}
disabled={updating}
className="bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-700 dark:hover:bg-gray-200 disabled:opacity-60 flex items-center gap-2"
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
>
{updating ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Download size={14} />
)}
{updating ? t('admin.update.installing') : t('admin.update.confirm')}
</button>
)}
{updateInfo?.release_url && (
<div style={{ marginTop: 10, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
className="bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800"
>
<div className="flex items-start gap-2">
<ExternalLink className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
<span>
<a href={updateInfo.release_url} target="_blank" rel="noopener noreferrer" className="underline font-semibold">
{t('admin.update.button')}
</a>
</span>
</div>
</div>
</>
)}
)}
</div>
<div style={{ padding: '0 24px 20px', display: 'flex', justifyContent: 'flex-end' }}>
<button
onClick={() => setShowUpdateModal(false)}
className="bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-700 dark:hover:bg-gray-200"
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
>
{t('common.close')}
</button>
</div>
</div>
</div>
)}
{/* Rotate JWT Secret confirmation modal */}
<Modal
isOpen={showRotateJwtModal}
onClose={() => setShowRotateJwtModal(false)}
title="Rotate JWT Secret"
size="sm"
footer={
<div className="flex gap-3 justify-end">
<button
onClick={() => setShowRotateJwtModal(false)}
disabled={rotatingJwt}
className="px-4 py-2 text-sm text-slate-600 border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-50"
>
{t('common.cancel')}
</button>
<button
onClick={async () => {
setRotatingJwt(true)
try {
await adminApi.rotateJwtSecret()
setShowRotateJwtModal(false)
logout()
navigate('/login')
} catch {
toast.error(t('common.error'))
setRotatingJwt(false)
}
}}
disabled={rotatingJwt}
className="flex items-center gap-2 px-4 py-2 text-sm bg-red-600 hover:bg-red-700 disabled:bg-red-300 text-white rounded-lg font-medium"
>
{rotatingJwt ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <RefreshCw className="w-4 h-4" />}
Rotate &amp; Log out
</button>
</div>
}
>
<div className="flex gap-3">
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-red-100 flex items-center justify-center">
<AlertTriangle className="w-5 h-5 text-red-600" />
</div>
<div>
<p className="text-sm font-medium text-slate-900 mb-1">Warning, this will invalidate all sessions and log you out.</p>
<p className="text-xs text-slate-500">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.</p>
</div>
</div>
</Modal>
</div>
)
}

View File

@@ -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)

View File

@@ -26,7 +26,7 @@ export default function RegisterPage(): React.ReactElement {
return
}
if (password.length < 6) {
if (password.length < 8) {
setError(t('register.passwordTooShort'))
return
}

View File

@@ -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)

View File

@@ -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<AuthState>((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<AuthState>((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<AuthState>((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<AuthState>((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<AuthState>((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<AuthState>((set, get) => ({
}
set({
user: null,
token: null,
isAuthenticated: false,
error: null,
})
@@ -144,11 +136,6 @@ export const useAuthStore = create<AuthState>((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<AuthState>((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<AuthState>((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')

View File

@@ -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

View File

@@ -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=<random-256-bit-hex> # 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

View File

@@ -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",

View File

@@ -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",

View File

@@ -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<string> {
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<string> {
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<string> {
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<string, string>;
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);
});

View File

@@ -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;

View File

@@ -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 };

View File

@@ -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}`);
}
}

View File

@@ -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);
});

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<string, { count: number; first: number }>();
const mfaAttempts = new Map<string, { count: number; first: number }>();
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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;

View File

@@ -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<typeof Database> | 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 {

View File

@@ -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<string> }) => {

View File

@@ -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);

View File

@@ -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 <img> 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 <img> 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');

View File

@@ -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 });
});

View File

@@ -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);
});

View File

@@ -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) {

View File

@@ -108,6 +108,7 @@ const ACTION_LABELS: Record<string, string> = {
'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<string, unknown>): 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 '';
}

View File

@@ -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));
}

View File

@@ -0,0 +1,54 @@
import crypto from 'crypto';
const TTL: Record<string, number> = {
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<string, TokenEntry>();
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<typeof setInterval> | 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;
}
}

View File

@@ -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. */

View File

@@ -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 };

View File

@@ -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 };
}

View File

@@ -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<SsrfResult> {
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);
}

View File

@@ -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);