mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
7a314a92b1
- Create server/src/utils/ssrfGuard.ts with checkSsrf() and createPinnedAgent()
- Resolves DNS before allowing outbound requests to catch hostnames that
map to private IPs (closes the TOCTOU gap in the old inline checks)
- Always blocks loopback (127.x, ::1) and link-local/metadata (169.254.x)
- RFC-1918, CGNAT (100.64/10), and IPv6 ULA ranges blocked by default;
opt-in via ALLOW_INTERNAL_NETWORK=true for self-hosters running Immich
on a local network
- createPinnedAgent() pins node-fetch to the validated IP, preventing
DNS rebinding between the check and the actual connection
- Replace isValidImmichUrl() (hostname-string check, no DNS resolution)
with checkSsrf(); make PUT /integrations/immich/settings async
- Audit log entry (immich.private_ip_configured) written when a user
saves an Immich URL that resolves to a private IP
- Response includes a warning field surfaced as a toast in the UI
- Replace ~20 lines of duplicated inline SSRF logic in the link-preview
handler with a single checkSsrf() call + pinned agent
- Document ALLOW_INTERNAL_NETWORK in README, docker-compose.yml,
server/.env.example, chart/values.yaml, chart/templates/configmap.yaml,
and chart/README.md
335 lines
14 KiB
TypeScript
335 lines
14 KiB
TypeScript
import express, { Request, Response, NextFunction } from 'express';
|
|
import { db } 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;
|
|
}
|
|
|
|
// ── Immich Connection Settings ──────────────────────────────────────────────
|
|
|
|
router.get('/settings', authenticate, (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
const creds = getImmichCredentials(authReq.user.id);
|
|
res.json({
|
|
immich_url: creds?.immich_url || '',
|
|
connected: !!(creds?.immich_url && creds?.immich_api_key),
|
|
});
|
|
});
|
|
|
|
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) {
|
|
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
|
|
);
|
|
}
|
|
|
|
res.json({ success: true });
|
|
});
|
|
|
|
router.get('/status', authenticate, async (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
const creds = getImmichCredentials(authReq.user.id);
|
|
if (!creds) {
|
|
return res.json({ connected: false, error: 'Not configured' });
|
|
}
|
|
try {
|
|
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}` });
|
|
const data = await resp.json() as { name?: string; email?: string };
|
|
res.json({ connected: true, user: { name: data.name, email: data.email } });
|
|
} catch (err: unknown) {
|
|
res.json({ connected: false, error: err instanceof Error ? err.message : 'Connection failed' });
|
|
}
|
|
});
|
|
|
|
// ── Browse Immich Library (for photo picker) ────────────────────────────────
|
|
|
|
router.get('/browse', authenticate, async (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
const { page = '1', size = '50' } = req.query;
|
|
const creds = getImmichCredentials(authReq.user.id);
|
|
if (!creds) return res.status(400).json({ error: 'Immich not configured' });
|
|
|
|
try {
|
|
const resp = await fetch(`${creds.immich_url}/api/timeline/buckets`, {
|
|
method: 'GET',
|
|
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' });
|
|
const buckets = await resp.json();
|
|
res.json({ buckets });
|
|
} catch (err: unknown) {
|
|
res.status(502).json({ error: 'Could not reach Immich' });
|
|
}
|
|
});
|
|
|
|
// Search photos by date range (for the date-filter in picker)
|
|
router.post('/search', authenticate, async (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
const { from, to } = req.body;
|
|
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)
|
|
const allAssets: any[] = [];
|
|
let page = 1;
|
|
const pageSize = 1000;
|
|
while (true) {
|
|
const resp = await fetch(`${creds.immich_url}/api/search/metadata`, {
|
|
method: 'POST',
|
|
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,
|
|
type: 'IMAGE',
|
|
size: pageSize,
|
|
page,
|
|
}),
|
|
signal: AbortSignal.timeout(15000),
|
|
});
|
|
if (!resp.ok) return res.status(resp.status).json({ error: 'Search failed' });
|
|
const data = await resp.json() as { assets?: { items?: any[] } };
|
|
const items = data.assets?.items || [];
|
|
allAssets.push(...items);
|
|
if (items.length < pageSize) break; // Last page
|
|
page++;
|
|
if (page > 20) break; // Safety limit (20k photos max)
|
|
}
|
|
const assets = allAssets.map((a: any) => ({
|
|
id: a.id,
|
|
takenAt: a.fileCreatedAt || a.createdAt,
|
|
city: a.exifInfo?.city || null,
|
|
country: a.exifInfo?.country || null,
|
|
}));
|
|
res.json({ assets });
|
|
} catch {
|
|
res.status(502).json({ error: 'Could not reach Immich' });
|
|
}
|
|
});
|
|
|
|
// ── Trip Photos (selected by user) ──────────────────────────────────────────
|
|
|
|
// Get all photos for a trip (own + shared by others)
|
|
router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
const { tripId } = req.params;
|
|
|
|
const photos = db.prepare(`
|
|
SELECT tp.immich_asset_id, tp.user_id, tp.shared, tp.added_at,
|
|
u.username, u.avatar, u.immich_url
|
|
FROM trip_photos tp
|
|
JOIN users u ON tp.user_id = u.id
|
|
WHERE tp.trip_id = ?
|
|
AND (tp.user_id = ? OR tp.shared = 1)
|
|
ORDER BY tp.added_at ASC
|
|
`).all(tripId, authReq.user.id);
|
|
|
|
res.json({ photos });
|
|
});
|
|
|
|
// Add photos to a trip
|
|
router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
const { tripId } = req.params;
|
|
const { asset_ids, shared = true } = req.body;
|
|
|
|
if (!Array.isArray(asset_ids) || asset_ids.length === 0) {
|
|
return res.status(400).json({ error: 'asset_ids required' });
|
|
}
|
|
|
|
const insert = db.prepare(
|
|
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, immich_asset_id, shared) VALUES (?, ?, ?, ?)'
|
|
);
|
|
let added = 0;
|
|
for (const assetId of asset_ids) {
|
|
const result = insert.run(tripId, authReq.user.id, assetId, shared ? 1 : 0);
|
|
if (result.changes > 0) added++;
|
|
}
|
|
|
|
res.json({ success: true, added });
|
|
broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
|
|
|
|
// Notify trip members about shared photos
|
|
if (shared && added > 0) {
|
|
import('../services/notifications').then(({ notifyTripMembers }) => {
|
|
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
|
notifyTripMembers(Number(tripId), authReq.user.id, 'photos_shared', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, count: String(added) }).catch(() => {});
|
|
});
|
|
}
|
|
});
|
|
|
|
// Remove a photo from a trip (own photos only)
|
|
router.delete('/trips/:tripId/photos/:assetId', authenticate, (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
db.prepare('DELETE FROM trip_photos WHERE trip_id = ? AND user_id = ? AND immich_asset_id = ?')
|
|
.run(req.params.tripId, authReq.user.id, req.params.assetId);
|
|
res.json({ success: true });
|
|
broadcast(req.params.tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
|
|
});
|
|
|
|
// Toggle sharing for a specific photo
|
|
router.put('/trips/:tripId/photos/:assetId/sharing', authenticate, (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
const { shared } = req.body;
|
|
db.prepare('UPDATE trip_photos SET shared = ? WHERE trip_id = ? AND user_id = ? AND immich_asset_id = ?')
|
|
.run(shared ? 1 : 0, req.params.tripId, authReq.user.id, req.params.assetId);
|
|
res.json({ success: true });
|
|
broadcast(req.params.tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
|
|
});
|
|
|
|
// ── Asset Details ───────────────────────────────────────────────────────────
|
|
|
|
router.get('/assets/:assetId/info', authenticate, async (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
const { assetId } = req.params;
|
|
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 creds = getImmichCredentials(authReq.user.id);
|
|
if (!creds) return res.status(404).json({ error: 'Not found' });
|
|
|
|
try {
|
|
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' });
|
|
const asset = await resp.json() as any;
|
|
res.json({
|
|
id: asset.id,
|
|
takenAt: asset.fileCreatedAt || asset.createdAt,
|
|
width: asset.exifInfo?.exifImageWidth || null,
|
|
height: asset.exifInfo?.exifImageHeight || null,
|
|
camera: asset.exifInfo?.make && asset.exifInfo?.model ? `${asset.exifInfo.make} ${asset.exifInfo.model}` : null,
|
|
lens: asset.exifInfo?.lensModel || null,
|
|
focalLength: asset.exifInfo?.focalLength ? `${asset.exifInfo.focalLength}mm` : null,
|
|
aperture: asset.exifInfo?.fNumber ? `f/${asset.exifInfo.fNumber}` : null,
|
|
shutter: asset.exifInfo?.exposureTime || null,
|
|
iso: asset.exifInfo?.iso || null,
|
|
city: asset.exifInfo?.city || null,
|
|
state: asset.exifInfo?.state || null,
|
|
country: asset.exifInfo?.country || null,
|
|
lat: asset.exifInfo?.latitude || null,
|
|
lng: asset.exifInfo?.longitude || null,
|
|
fileSize: asset.exifInfo?.fileSizeInByte || null,
|
|
fileName: asset.originalFileName || null,
|
|
});
|
|
} catch {
|
|
res.status(502).json({ error: 'Proxy error' });
|
|
}
|
|
});
|
|
|
|
// ── Proxy Immich Assets ─────────────────────────────────────────────────────
|
|
|
|
// 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);
|
|
}
|
|
|
|
router.get('/assets/:assetId/thumbnail', authFromQuery, async (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
const { assetId } = req.params;
|
|
if (!isValidAssetId(assetId)) return res.status(400).send('Invalid asset ID');
|
|
|
|
// 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(`${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');
|
|
res.set('Content-Type', resp.headers.get('content-type') || 'image/webp');
|
|
res.set('Cache-Control', 'public, max-age=86400');
|
|
const buffer = Buffer.from(await resp.arrayBuffer());
|
|
res.send(buffer);
|
|
} catch {
|
|
res.status(502).send('Proxy error');
|
|
}
|
|
});
|
|
|
|
router.get('/assets/:assetId/original', authFromQuery, async (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
const { assetId } = req.params;
|
|
if (!isValidAssetId(assetId)) return res.status(400).send('Invalid asset ID');
|
|
|
|
// 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(`${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');
|
|
res.set('Content-Type', resp.headers.get('content-type') || 'image/jpeg');
|
|
res.set('Cache-Control', 'public, max-age=86400');
|
|
const buffer = Buffer.from(await resp.arrayBuffer());
|
|
res.send(buffer);
|
|
} catch {
|
|
res.status(502).send('Proxy error');
|
|
}
|
|
});
|
|
|
|
export default router;
|