Merge pull request #1 from jubnl/main

apply hot fixes to dev
This commit is contained in:
Julien G.
2026-04-01 23:07:38 +02:00
committed by GitHub
14 changed files with 73 additions and 31 deletions

View File

@@ -139,10 +139,23 @@ services:
- NODE_ENV=production
- PORT=3000
- 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)
- 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
# - COOKIE_SECURE=false # Uncomment if accessing over plain HTTP (no HTTPS). Not recommended for production.
- TRUST_PROXY=1 # Number of trusted proxies for X-Forwarded-For
# - ALLOW_INTERNAL_NETWORK=true # Uncomment if Immich or other services are on your local network (RFC-1918 IPs)
- APP_URL=${APP_URL:-} # 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
# - OIDC_CLIENT_SECRET=supersecret # OpenID Connect client secret
# - OIDC_DISPLAY_NAME=SSO # Label shown on the SSO login button
# - OIDC_ONLY=false # Set to true to 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 OIDC discovery endpoint for providers with non-standard paths (e.g. Authentik)
# - DEMO_MODE=false # Enable demo mode (resets data hourly)
volumes:
- ./data:/app/data
- ./uploads:/app/uploads
@@ -265,6 +278,7 @@ trek.yourdomain.com {
| `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` |
| `COOKIE_SECURE` | Set to `false` to allow session cookies over plain HTTP (e.g. accessing via IP without HTTPS). Defaults to `true` in production. **Not recommended to disable in production.** | `true` |
| `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** | | |
@@ -273,6 +287,7 @@ trek.yourdomain.com {
| `OIDC_CLIENT_SECRET` | OIDC client secret | — |
| `OIDC_DISPLAY_NAME` | Label shown on the SSO login button | `SSO` |
| `OIDC_ONLY` | Disable local password auth entirely (first SSO login becomes admin) | `false` |
| `OIDC_DISCOVERY_URL` | Override the auto-constructed OIDC discovery endpoint. Useful for providers that expose it at a non-standard path (e.g. Authentik: `https://auth.example.com/application/o/trek/.well-known/openid-configuration`) | — |
| **Other** | | |
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |

View File

@@ -32,3 +32,5 @@ See `values.yaml` for more options.
- `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.
- Set `env.COOKIE_SECURE: "false"` only if your deployment has no TLS (e.g. during local testing without ingress). Session cookies require HTTPS in all other cases.
- Set `env.OIDC_DISCOVERY_URL` to override the auto-constructed OIDC discovery endpoint. Required for providers (e.g. Authentik) that expose it at a non-standard path.

View File

@@ -13,3 +13,9 @@ data:
{{- if .Values.env.ALLOW_INTERNAL_NETWORK }}
ALLOW_INTERNAL_NETWORK: {{ .Values.env.ALLOW_INTERNAL_NETWORK | quote }}
{{- end }}
{{- if .Values.env.COOKIE_SECURE }}
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
{{- end }}
{{- if .Values.env.OIDC_DISCOVERY_URL }}
OIDC_DISCOVERY_URL: {{ .Values.env.OIDC_DISCOVERY_URL | quote }}
{{- end }}

View File

@@ -20,6 +20,10 @@ env:
# 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.
# COOKIE_SECURE: "true"
# Set to "false" to allow session cookies over plain HTTP (e.g. no ingress TLS). Not recommended for production.
# OIDC_DISCOVERY_URL: ""
# Override the OIDC discovery endpoint for providers with non-standard paths (e.g. Authentik).
# Secret environment variables stored in a Kubernetes Secret.

View File

@@ -25,7 +25,7 @@ apiClient.interceptors.request.use(
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register')) {
window.location.href = '/login'
}

View File

@@ -4,6 +4,7 @@ import apiClient from '../../api/client'
import { useAuthStore } from '../../store/authStore'
import { useTranslation } from '../../i18n'
import { getAuthUrl } from '../../api/authUrl'
import { useToast } from '../shared/Toast'
function ImmichImg({ baseUrl, style, loading }: { baseUrl: string; style?: React.CSSProperties; loading?: 'lazy' | 'eager' }) {
const [src, setSrc] = useState('')
@@ -40,6 +41,7 @@ interface MemoriesPanelProps {
export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPanelProps) {
const { t } = useTranslation()
const toast = useToast()
const currentUser = useAuthStore(s => s.user)
const [connected, setConnected] = useState(false)
@@ -81,7 +83,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
try {
const res = await apiClient.get('/integrations/immich/albums')
setAlbums(res.data.albums || [])
} catch { setAlbums([]) }
} catch { setAlbums([]); toast.error(t('memories.error.loadAlbums')) }
finally { setAlbumsLoading(false) }
}
@@ -94,14 +96,14 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
const linksRes = await apiClient.get(`/integrations/immich/trips/${tripId}/album-links`)
const newLink = (linksRes.data.links || []).find((l: any) => l.immich_album_id === albumId)
if (newLink) await syncAlbum(newLink.id)
} catch {}
} catch { toast.error(t('memories.error.linkAlbum')) }
}
const unlinkAlbum = async (linkId: number) => {
try {
await apiClient.delete(`/integrations/immich/trips/${tripId}/album-links/${linkId}`)
loadAlbumLinks()
} catch {}
} catch { toast.error(t('memories.error.unlinkAlbum')) }
}
const syncAlbum = async (linkId: number) => {
@@ -110,7 +112,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
await apiClient.post(`/integrations/immich/trips/${tripId}/album-links/${linkId}/sync`)
await loadAlbumLinks()
await loadPhotos()
} catch {}
} catch { toast.error(t('memories.error.syncAlbum')) }
finally { setSyncing(null) }
}
@@ -178,6 +180,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
setPickerPhotos(res.data.assets || [])
} catch {
setPickerPhotos([])
toast.error(t('memories.error.loadPhotos'))
} finally {
setPickerLoading(false)
}
@@ -206,7 +209,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
})
setShowPicker(false)
loadInitial()
} catch {}
} catch { toast.error(t('memories.error.addPhotos')) }
}
// ── Remove photo ──────────────────────────────────────────────────────────
@@ -215,7 +218,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
try {
await apiClient.delete(`/integrations/immich/trips/${tripId}/photos/${assetId}`)
setTripPhotos(prev => prev.filter(p => p.immich_asset_id !== assetId))
} catch {}
} catch { toast.error(t('memories.error.removePhoto')) }
}
// ── Toggle sharing ────────────────────────────────────────────────────────
@@ -226,7 +229,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
setTripPhotos(prev => prev.map(p =>
p.immich_asset_id === assetId ? { ...p, shared: shared ? 1 : 0 } : p
))
} catch {}
} catch { toast.error(t('memories.error.toggleSharing')) }
}
// ── Helpers ───────────────────────────────────────────────────────────────

View File

@@ -1363,6 +1363,14 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'memories.confirmShareTitle': 'Share with trip members?',
'memories.confirmShareHint': '{count} photos will be visible to all members of this trip. You can make individual photos private later.',
'memories.confirmShareButton': 'Share photos',
'memories.error.loadAlbums': 'Failed to load albums',
'memories.error.linkAlbum': 'Failed to link album',
'memories.error.unlinkAlbum': 'Failed to unlink album',
'memories.error.syncAlbum': 'Failed to sync album',
'memories.error.loadPhotos': 'Failed to load photos',
'memories.error.addPhotos': 'Failed to add photos',
'memories.error.removePhoto': 'Failed to remove photo',
'memories.error.toggleSharing': 'Failed to update sharing',
// Collab Addon
'collab.tabs.chat': 'Chat',

View File

@@ -4,6 +4,7 @@ import { useAuthStore } from '../store/authStore'
import { useSettingsStore } from '../store/settingsStore'
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'
import { authApi } from '../api/client'
import { getApiErrorMessage } from '../types'
import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route, Shield, KeyRound } from 'lucide-react'
interface AppConfig {
@@ -171,7 +172,7 @@ export default function LoginPage(): React.ReactElement {
setShowTakeoff(true)
setTimeout(() => navigate('/dashboard'), 2600)
} catch (err: unknown) {
setError(err instanceof Error ? err.message : t('login.error'))
setError(getApiErrorMessage(err, t('login.error')))
setIsLoading(false)
}
}

View File

@@ -23,13 +23,15 @@ services:
- 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
# - COOKIE_SECURE=false # Uncomment if accessing over plain HTTP (no HTTPS). Not recommended for production.
- 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
- OIDC_DISPLAY_NAME=SSO # Label shown on the SSO login button
- OIDC_ONLY=false # Set true to disable local password auth entirely (SSO only)
# - 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
# - OIDC_DISPLAY_NAME=SSO # Label shown on the SSO login button
# - OIDC_ONLY=false # Set true to disable local password auth entirely (SSO only)
# - OIDC_DISCOVERY_URL= # Override the OIDC discovery endpoint for providers with non-standard paths (e.g. Authentik)
volumes:
- ./data:/app/data
- ./uploads:/app/uploads

View File

@@ -10,6 +10,7 @@ LOG_LEVEL=info # info = concise user actions; debug = verbose admin-level detail
ALLOWED_ORIGINS=https://trek.example.com # Comma-separated origins for CORS and email links
FORCE_HTTPS=false # Redirect HTTP → HTTPS behind a TLS proxy
COOKIE_SECURE=true # Set to false to allow session cookies over HTTP (e.g. plain-IP or non-HTTPS setups). Defaults to true in production.
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.
@@ -22,6 +23,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)
OIDC_DISCOVERY_URL= # Override the auto-constructed OIDC discovery endpoint. Useful for providers (e.g. Authentik) that expose it at a non-standard path. Example: https://auth.example.com/application/o/trek/.well-known/openid-configuration
DEMO_MODE=false # Demo mode - resets data hourly

View File

@@ -152,7 +152,7 @@ app.get('/uploads/photos/:filename', (req: Request, res: Response) => {
jwt.verify(token, process.env.JWT_SECRET || require('./config').JWT_SECRET);
} catch {
// Check if it's a share token
const shareRow = db.prepare('SELECT id FROM share_tokens WHERE token = ?').get(token);
const shareRow = addonDb.prepare('SELECT id FROM share_tokens WHERE token = ?').get(token);
if (!shareRow) return res.status(401).send('Authentication required');
}
res.sendFile(resolved);
@@ -203,7 +203,7 @@ app.use('/api/admin', adminRoutes);
// Public addons endpoint (authenticated but not admin-only)
import { authenticate as addonAuth } from './middleware/auth';
import { db as addonDb } from './db/database';
import {db as addonDb} from './db/database';
import { Addon } from './types';
app.get('/api/addons', addonAuth, (req: Request, res: Response) => {
const addons = addonDb.prepare('SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1 ORDER BY sort_order').all() as Pick<Addon, 'id' | 'name' | 'type' | 'icon' | 'enabled'>[];

View File

@@ -16,7 +16,7 @@ const authenticate = (req: Request, res: Response, next: NextFunction): void =>
const token = extractToken(req);
if (!token) {
res.status(401).json({ error: 'Access token required' });
res.status(401).json({ error: 'Access token required', code: 'AUTH_REQUIRED' });
return;
}
@@ -26,13 +26,13 @@ const authenticate = (req: Request, res: Response, next: NextFunction): void =>
'SELECT id, username, email, role FROM users WHERE id = ?'
).get(decoded.id) as User | undefined;
if (!user) {
res.status(401).json({ error: 'User not found' });
res.status(401).json({ error: 'User not found', code: 'AUTH_REQUIRED' });
return;
}
(req as AuthRequest).user = user;
next();
} catch (err: unknown) {
res.status(401).json({ error: 'Invalid or expired token' });
res.status(401).json({ error: 'Invalid or expired token', code: 'AUTH_REQUIRED' });
}
};

View File

@@ -359,12 +359,12 @@ router.get('/assets/:assetId/original', authFromQuery, async (req: Request, res:
// List user's Immich albums
router.get('/albums', 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) 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/albums`, {
headers: { 'x-api-key': user.immich_api_key, 'Accept': 'application/json' },
const resp = await fetch(`${creds.immich_url}/api/albums`, {
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 to fetch albums' });
@@ -431,12 +431,12 @@ router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req:
.get(linkId, tripId, authReq.user.id) as any;
if (!link) return res.status(404).json({ error: 'Album link not found' });
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/albums/${link.immich_album_id}`, {
headers: { 'x-api-key': user.immich_api_key, 'Accept': 'application/json' },
const resp = await fetch(`${creds.immich_url}/api/albums/${link.immich_album_id}`, {
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 album' });

View File

@@ -3,7 +3,7 @@ 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';
const secure = process.env.COOKIE_SECURE !== 'false' && (process.env.NODE_ENV === 'production' || process.env.FORCE_HTTPS === 'true');
return {
httpOnly: true,
secure,