Merge pull request #257 from jubnl/dev
Security hardening, encryption at rest
This commit is contained in:
22
README.md
22
README.md
@@ -98,7 +98,9 @@
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
docker run -d -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
|
||||
ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
|
||||
-e ENCRYPTION_KEY=$ENCRYPTION_KEY \
|
||||
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
|
||||
```
|
||||
|
||||
The app runs on port `3000`. The first user to register becomes the admin.
|
||||
@@ -136,10 +138,11 @@ services:
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
- JWT_SECRET=${JWT_SECRET:-} # Auto-generated if not set; persist across restarts for stable sessions
|
||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # Recommended. Generate with: openssl rand -hex 32. If unset, falls back to data/.jwt_secret (existing installs) or auto-generates a key (fresh installs).
|
||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
|
||||
- TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details
|
||||
# - ALLOW_INTERNAL_NETWORK=true # Uncomment if Immich is on your local network (RFC-1918 IPs)
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./uploads:/app/uploads
|
||||
@@ -178,6 +181,18 @@ docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/upl
|
||||
|
||||
Your data is persisted in the mounted `data` and `uploads` volumes — updates never touch your existing data.
|
||||
|
||||
### Rotating the Encryption Key
|
||||
|
||||
If you need to rotate `ENCRYPTION_KEY` (e.g. you are upgrading from a version that derived encryption from `JWT_SECRET`), use the migration script to re-encrypt all stored secrets under the new key without starting the app:
|
||||
|
||||
```bash
|
||||
docker exec -it trek node --import tsx scripts/migrate-encryption.ts
|
||||
```
|
||||
|
||||
The script will prompt for your old and new keys interactively (input is not echoed). It creates a timestamped database backup before making any changes and exits with a non-zero code if anything fails.
|
||||
|
||||
**Upgrading from a previous version?** Your old JWT secret is in `./data/.jwt_secret`. Use its contents as the "old key" and your new `ENCRYPTION_KEY` value as the "new key".
|
||||
|
||||
### Reverse Proxy (recommended)
|
||||
|
||||
For production, put TREK behind a reverse proxy with HTTPS (e.g. Nginx, Caddy, Traefik).
|
||||
@@ -245,12 +260,13 @@ trek.yourdomain.com {
|
||||
| **Core** | | |
|
||||
| `PORT` | Server port | `3000` |
|
||||
| `NODE_ENV` | Environment (`production` / `development`) | `production` |
|
||||
| `JWT_SECRET` | JWT signing secret; auto-generated and saved to `data/` if not set | Auto-generated |
|
||||
| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto |
|
||||
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
|
||||
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
|
||||
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
|
||||
| `FORCE_HTTPS` | Redirect HTTP to HTTPS behind a TLS-terminating proxy | `false` |
|
||||
| `TRUST_PROXY` | Number of trusted reverse proxies for `X-Forwarded-For` | `1` |
|
||||
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IP addresses. Set to `true` if Immich or other integrated services are hosted on your local network. Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) are always blocked regardless of this setting. | `false` |
|
||||
| **OIDC / SSO** | | |
|
||||
| `OIDC_ISSUER` | OpenID Connect provider URL | — |
|
||||
| `OIDC_CLIENT_ID` | OIDC client ID | — |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
16
client/src/api/authUrl.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' }}
|
||||
/>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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': 'أنشئ حسابًا وابدأ التخطيط لرحلات أحلامك.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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': 'Создайте аккаунт и начните планировать поездки мечты.',
|
||||
|
||||
@@ -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': '创建账户,开始规划你的梦想旅行。',
|
||||
|
||||
@@ -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 & 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function RegisterPage(): React.ReactElement {
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
if (password.length < 8) {
|
||||
setError(t('register.passwordTooShort'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
31
server/package-lock.json
generated
31
server/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
298
server/scripts/migrate-encryption.ts
Normal file
298
server/scripts/migrate-encryption.ts
Normal 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);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> }) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
|
||||
|
||||
22
server/src/services/cookie.ts
Normal file
22
server/src/services/cookie.ts
Normal 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));
|
||||
}
|
||||
54
server/src/services/ephemeralTokens.ts
Normal file
54
server/src/services/ephemeralTokens.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 };
|
||||
|
||||
28
server/src/services/passwordPolicy.ts
Normal file
28
server/src/services/passwordPolicy.ts
Normal 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 };
|
||||
}
|
||||
122
server/src/utils/ssrfGuard.ts
Normal file
122
server/src/utils/ssrfGuard.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user