- 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)
221 lines
7.6 KiB
TypeScript
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);
|
|
};
|