Files
platform/frontend-v2/src/hooks.server.ts
Yusuf Suleman 6bd23e7e8b fix: security hardening across platform
- Disable open /api/auth/register endpoint (gateway)
- Require gateway session auth on Immich and Karakeep hooks proxies
- Replace SHA-256 with bcrypt in fitness service (auth + seed)
- Remove hardcoded Telegram user IDs from fitness seed
- Add Secure flag to session cookie
- Add domain allowlist and content-type validation to image proxy
- Strengthen .gitignore (env variants, runtime data, test artifacts)
2026-03-29 08:25:50 -05:00

221 lines
7.6 KiB
TypeScript

import type { Handle } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
const gatewayUrl = env.GATEWAY_URL || 'http://localhost:8100';
const immichUrl = env.IMMICH_URL || '';
const immichApiKey = env.IMMICH_API_KEY || '';
const karakeepUrl = env.KARAKEEP_URL || '';
const karakeepApiKey = env.KARAKEEP_API_KEY || '';
export const handle: Handle = async ({ event, resolve }) => {
async function isAuthenticated(request: Request): Promise<boolean> {
const cookie = request.headers.get('cookie') || '';
if (!cookie.includes('platform_session=')) return false;
try {
const res = await fetch(`${gatewayUrl}/api/auth/me`, { headers: { cookie } });
if (!res.ok) return false;
const data = await res.json();
return data.authenticated === true;
} catch {
return false;
}
}
// ── Immich API proxy (shared across all pages) ──
if (event.url.pathname.startsWith('/api/immich/') && immichUrl && immichApiKey) {
if (!await isAuthenticated(event.request)) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401, headers: { 'Content-Type': 'application/json' }
});
}
const immichPath = event.url.pathname.replace('/api/immich', '/api');
// Thumbnail/original image proxy — cache-friendly binary response
if (immichPath.includes('/thumbnail') || immichPath.includes('/original')) {
const assetId = event.url.pathname.split('/')[4]; // /api/immich/assets/{id}/thumbnail
const type = immichPath.includes('/original') ? 'original' : 'thumbnail';
try {
const response = await fetch(`${immichUrl}/api/assets/${assetId}/${type}`, {
headers: { 'x-api-key': immichApiKey }
});
return new Response(response.body, {
status: response.status,
headers: {
'Content-Type': response.headers.get('Content-Type') || 'image/webp',
'Cache-Control': 'public, max-age=86400'
}
});
} catch {
return new Response('', { status: 502 });
}
}
// JSON API proxy (search, timeline, etc.)
try {
const targetUrl = `${immichUrl}${immichPath}${event.url.search}`;
const reqHeaders: Record<string, string> = { 'x-api-key': immichApiKey };
const ct = event.request.headers.get('content-type');
if (ct) reqHeaders['content-type'] = ct;
const response = await fetch(targetUrl, {
method: event.request.method,
headers: reqHeaders,
body: event.request.method !== 'GET' && event.request.method !== 'HEAD'
? await event.request.arrayBuffer()
: undefined
});
// For search results, strip down to minimal fields to avoid huge payloads
if (immichPath.includes('/search/') && response.ok) {
const data = await response.json();
if (data.assets?.items) {
data.assets.items = data.assets.items.map((a: Record<string, unknown>) => ({
id: a.id,
type: a.type,
fileCreatedAt: a.fileCreatedAt,
originalFileName: a.originalFileName
}));
}
return new Response(JSON.stringify(data), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
const responseHeaders = new Headers();
for (const [key, value] of response.headers.entries()) {
responseHeaders.append(key, value);
}
return new Response(response.body, { status: response.status, headers: responseHeaders });
} catch {
return new Response(JSON.stringify({ error: 'Immich unavailable' }), {
status: 502, headers: { 'Content-Type': 'application/json' }
});
}
}
// ── Karakeep API proxy (server-to-server) ──
if (event.url.pathname.startsWith('/api/karakeep/') && karakeepUrl && karakeepApiKey) {
if (!await isAuthenticated(event.request)) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401, headers: { 'Content-Type': 'application/json' }
});
}
const action = event.url.pathname.split('/').pop(); // 'save' or 'delete'
try {
const body = await event.request.json();
if (action === 'save' && body.url) {
const response = await fetch(`${karakeepUrl}/api/v1/bookmarks`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${karakeepApiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ type: 'link', url: body.url })
});
const data = await response.json();
if (response.ok) {
return new Response(JSON.stringify({ ok: true, id: data.id || '' }), {
status: 200, headers: { 'Content-Type': 'application/json' }
});
}
return new Response(JSON.stringify({ error: 'Save failed', status: response.status }), {
status: response.status, headers: { 'Content-Type': 'application/json' }
});
}
if (action === 'delete' && body.id) {
const response = await fetch(`${karakeepUrl}/api/v1/bookmarks/${body.id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${karakeepApiKey}` }
});
return new Response(JSON.stringify({ ok: response.ok }), {
status: 200, headers: { 'Content-Type': 'application/json' }
});
}
return new Response(JSON.stringify({ error: 'Invalid request' }), {
status: 400, headers: { 'Content-Type': 'application/json' }
});
} catch (err) {
console.error('Karakeep proxy error:', err);
return new Response(JSON.stringify({ error: 'Karakeep unavailable', detail: String(err) }), {
status: 502, headers: { 'Content-Type': 'application/json' }
});
}
}
// Legacy trips-specific Immich thumbnail (keep for backward compat)
if (event.url.pathname.startsWith('/api/trips/immich/thumb/') && immichUrl && immichApiKey) {
if (!await isAuthenticated(event.request)) {
return new Response('', { status: 401 });
}
const assetId = event.url.pathname.split('/').pop();
try {
const response = await fetch(`${immichUrl}/api/assets/${assetId}/thumbnail`, {
headers: { 'x-api-key': immichApiKey }
});
return new Response(response.body, {
status: response.status,
headers: {
'Content-Type': response.headers.get('Content-Type') || 'image/webp',
'Cache-Control': 'public, max-age=86400'
}
});
} catch {
return new Response('', { status: 502 });
}
}
// Proxy all /api/* requests to the gateway
if (event.url.pathname.startsWith('/api/') || event.url.pathname.startsWith('/images/')) {
// Rewrite /images/trips/* to /api/trips/images/* for gateway routing
let targetPath = event.url.pathname;
if (targetPath.startsWith('/images/trips/')) {
targetPath = '/api/trips' + targetPath.replace('/images/trips', '/images');
} else if (targetPath.startsWith('/images/fitness/')) {
targetPath = '/api/fitness' + targetPath.replace('/images/fitness', '/images');
}
const targetUrl = `${gatewayUrl}${targetPath}${event.url.search}`;
const headers = new Headers();
// Forward all relevant headers
for (const [key, value] of event.request.headers.entries()) {
if (['authorization', 'content-type', 'cookie', 'x-api-key', 'x-telegram-user-id'].includes(key.toLowerCase())) {
headers.set(key, value);
}
}
try {
const response = await fetch(targetUrl, {
method: event.request.method,
headers,
body: event.request.method !== 'GET' && event.request.method !== 'HEAD'
? await event.request.arrayBuffer()
: undefined
});
// Forward set-cookie headers from gateway
const responseHeaders = new Headers();
for (const [key, value] of response.headers.entries()) {
responseHeaders.append(key, value);
}
return new Response(response.body, {
status: response.status,
headers: responseHeaders
});
} catch (error) {
console.error('Gateway proxy error:', error);
return new Response(JSON.stringify({ error: 'Gateway unavailable' }), {
status: 502,
headers: { 'Content-Type': 'application/json' }
});
}
}
return resolve(event);
};