530308033b
`/hikes` is `prerender = true` and carries the global nav, so the prerender crawler followed those links and tried to statically render the whole dynamic, DB-/ML-backed app. SvelteKit prerenders inside a heap-capped worker_threads worker, so this exhausted its heap (ERR_WORKER_OUT_OF_MEMORY) and failed the build. - svelte.config.js: prerender.crawl = false. The intended static set is fully described by `prerender = true` (/hikes) + the /errors/[status] EntryGenerator, so crawling is unneeded. Add a defensive handleHttpError that ignores /hikes/*/images/* 404s (those binaries live in hikes-assets/, served by nginx/dev-middleware, not /static). - hooks.server.ts: skip init when `building` so builds don't connect to Mongo, start the payment scheduler, or warm the romcal cache. - hikes/[slug]: set `prerender = false`, enforcing the intent its comment already stated. Version 1.86.1 -> 1.86.2.
257 lines
10 KiB
TypeScript
257 lines
10 KiB
TypeScript
import type { Handle, HandleServerError, ServerInit } from "@sveltejs/kit"
|
|
import { redirect } from "@sveltejs/kit"
|
|
import { sequence } from "@sveltejs/kit/hooks"
|
|
import { building } from "$app/environment"
|
|
import * as auth from "./auth"
|
|
import { initializeScheduler } from "./lib/server/scheduler"
|
|
import { dbConnect } from "./utils/db"
|
|
import { errorWithVerse, getRandomVerse } from "$lib/server/errorQuote"
|
|
import { warmLiturgicalCache } from "$lib/server/liturgicalCalendar"
|
|
|
|
/** Map URL path to BCP 47 lang tag. Mirrors the [recipeLang] / [faithLang]
|
|
* param matchers — keep in sync if new locale slugs are added.
|
|
* @returns 'de' | 'en' | 'la'
|
|
*/
|
|
function langFromPath(pathname: string): 'de' | 'en' | 'la' {
|
|
const first = pathname.split('/').filter(Boolean)[0] ?? '';
|
|
if (first === 'recipes' || first === 'faith') return 'en';
|
|
if (first === 'fides') return 'la';
|
|
return 'de';
|
|
}
|
|
|
|
async function htmlLang({ event, resolve }: Parameters<Handle>[0]) {
|
|
const lang = langFromPath(event.url.pathname);
|
|
return resolve(event, {
|
|
transformPageChunk: ({ html }) => html.replace('%lang%', lang),
|
|
});
|
|
}
|
|
|
|
/** Apply headers to a response, transparently cloning it if the original
|
|
* has immutable headers. Auth.js (and certain fetch error/redirect responses)
|
|
* hand back frozen Headers, and a direct `.set()` on those throws
|
|
* `TypeError: immutable` — which would mask the underlying error and 500
|
|
* the request. Cloning preserves the body stream and status. */
|
|
function applyHeaders(response: Response, entries: Array<[string, string]>): Response {
|
|
try {
|
|
for (const [k, v] of entries) response.headers.set(k, v);
|
|
return response;
|
|
} catch (err) {
|
|
if (err instanceof TypeError) {
|
|
const headers = new Headers(response.headers);
|
|
for (const [k, v] of entries) headers.set(k, v);
|
|
return new Response(response.body, {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
headers
|
|
});
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/** Routes that must never appear in search-engine indexes. Search-results pages
|
|
* are thin/duplicate content; admin/edit/auth-walled pages have no public value
|
|
* and shouldn't burn crawl budget. Sets X-Robots-Tag rather than per-page meta
|
|
* so the rule lives in one place and also covers JSON/API responses.
|
|
*/
|
|
const NOINDEX_PATTERNS: RegExp[] = [
|
|
/^\/api(\/|$)/,
|
|
/^\/(rezepte|recipes)\/(search|admin|administration|add|edit|favorites|to-try)(\/|$)/,
|
|
/^\/login$/,
|
|
/^\/logout$/,
|
|
/^\/register(\/|$)/,
|
|
/^\/settings(\/|$)/,
|
|
/^\/tasks(\/|$)/,
|
|
/^\/fitness(\/|$)/,
|
|
/^\/cospend(\/|$)/,
|
|
/^\/expenses(\/|$)/,
|
|
];
|
|
|
|
async function noindex({ event, resolve }: Parameters<Handle>[0]) {
|
|
const response = await resolve(event);
|
|
if (NOINDEX_PATTERNS.some((p) => p.test(event.url.pathname))) {
|
|
return applyHeaders(response, [['X-Robots-Tag', 'noindex, nofollow']]);
|
|
}
|
|
return response;
|
|
}
|
|
|
|
/** Baseline security headers, set on every response.
|
|
*
|
|
* - X-Frame-Options + CSP frame-ancestors block this site from being
|
|
* iframed onto attacker pages (clickjacking on /login, /cospend,
|
|
* /fitness, etc.). Both directives are sent: modern browsers honour
|
|
* frame-ancestors and ignore the legacy header; older ones (IE11) only
|
|
* understand X-Frame-Options.
|
|
* - Strict-Transport-Security tells browsers to refuse plain-HTTP for
|
|
* bocken.org and any subdomain for one year, preventing protocol
|
|
* downgrade. Browsers ignore the header on http:// loads, so dev on
|
|
* localhost is unaffected. `preload` deliberately omitted — the HSTS
|
|
* preload list is hard to leave; revisit only after a stable production
|
|
* deployment.
|
|
*/
|
|
async function securityHeaders({ event, resolve }: Parameters<Handle>[0]) {
|
|
const response = await resolve(event);
|
|
return applyHeaders(response, [
|
|
['X-Frame-Options', 'DENY'],
|
|
['Content-Security-Policy', "frame-ancestors 'none'"],
|
|
['Strict-Transport-Security', 'max-age=31536000; includeSubDomains']
|
|
]);
|
|
}
|
|
|
|
async function timing({ event, resolve }: Parameters<Handle>[0]) {
|
|
const marks: Record<string, number> = {};
|
|
event.locals.timing = {
|
|
mark(name, dur) {
|
|
marks[name] = (marks[name] ?? 0) + dur;
|
|
},
|
|
async measure(name, fn) {
|
|
const t0 = performance.now();
|
|
try {
|
|
return await fn();
|
|
} finally {
|
|
this.mark(name, performance.now() - t0);
|
|
}
|
|
}
|
|
};
|
|
const t0 = performance.now();
|
|
const response = await resolve(event);
|
|
marks.total = performance.now() - t0;
|
|
const header = Object.entries(marks)
|
|
.map(([k, v]) => `${k};dur=${v.toFixed(1)}`)
|
|
.join(', ');
|
|
return applyHeaders(response, [['Server-Timing', header]]);
|
|
}
|
|
|
|
export const init: ServerInit = async () => {
|
|
// SvelteKit runs prerendering/analysis inside a worker_threads worker (see
|
|
// @sveltejs/kit utils/fork.js) whose JS heap is capped well below the main
|
|
// thread's. `init` fires there too, so warming the romcal cache during a
|
|
// build exhausts that worker's heap → ERR_WORKER_OUT_OF_MEMORY and a failed
|
|
// build. None of it is needed at build time: no prerendered route touches the
|
|
// DB, and connecting to Mongo / starting the payment scheduler from a build
|
|
// is undesirable regardless. Skip startup work while building.
|
|
if (building) return;
|
|
|
|
console.log('🚀 Server starting - initializing database connection...');
|
|
try {
|
|
await dbConnect();
|
|
console.log('✅ Database connected successfully');
|
|
initializeScheduler();
|
|
console.log('✅ Recurring payment scheduler initialized');
|
|
} catch (error) {
|
|
console.error('❌ Failed to connect to database on startup:', error);
|
|
// Don't crash the server - API routes will attempt reconnection
|
|
}
|
|
|
|
// Warm liturgical calendar cache in the background — non-blocking so the
|
|
// server starts accepting requests immediately; any request arriving before
|
|
// warmup completes falls back to lazy computation (still correct, just cold).
|
|
const t0 = performance.now();
|
|
warmLiturgicalCache()
|
|
.then(() => console.log(`✅ Liturgical calendar cache warmed in ${Math.round(performance.now() - t0)}ms`))
|
|
.catch((error) => console.error('⚠️ Liturgical calendar warmup failed:', error));
|
|
};
|
|
|
|
async function authorization({ event, resolve }: Parameters<Handle>[0]) {
|
|
const session = await event.locals.timing.measure('auth', () => event.locals.auth());
|
|
event.locals.session = session;
|
|
const { fetch, url } = event;
|
|
|
|
// Protect rezepte routes
|
|
if (url.pathname.startsWith('/rezepte/edit') || url.pathname.startsWith('/rezepte/add')) {
|
|
if (!session) {
|
|
const callbackUrl = encodeURIComponent(url.pathname + url.search);
|
|
redirect(303, `/login?callbackUrl=${callbackUrl}`);
|
|
}
|
|
else if (!session.user?.groups?.includes('rezepte_users')) {
|
|
await errorWithVerse(fetch, url.pathname, 403,
|
|
'Zugriff verweigert. Du hast keine Berechtigung für diesen Bereich. Falls du glaubst, dass dies ein Fehler ist, wende dich bitte an Alexander.');
|
|
}
|
|
}
|
|
|
|
// Protect cospend routes and API endpoints
|
|
if (url.pathname.startsWith('/cospend') || url.pathname.startsWith('/expenses') || url.pathname.startsWith('/api/cospend')) {
|
|
if (!session) {
|
|
// Allow share-token access to shopping list routes
|
|
const isShoppingRoute = url.pathname.startsWith('/cospend/list') || url.pathname.startsWith('/expenses/list') || url.pathname.startsWith('/api/cospend/list');
|
|
const shareToken = url.searchParams.get('token');
|
|
if (isShoppingRoute && shareToken) {
|
|
const { validateShareToken } = await import('$lib/server/shoppingAuth');
|
|
if (await validateShareToken(shareToken)) {
|
|
return resolve(event);
|
|
}
|
|
}
|
|
|
|
// For API routes, return 401 instead of redirecting
|
|
if (url.pathname.startsWith('/api/cospend')) {
|
|
await errorWithVerse(fetch, url.pathname, 401,
|
|
'Anmeldung erforderlich. Du musst angemeldet sein, um auf diesen Bereich zugreifen zu können.');
|
|
}
|
|
// For page routes, redirect to login
|
|
const callbackUrl = encodeURIComponent(url.pathname + url.search);
|
|
redirect(303, `/login?callbackUrl=${callbackUrl}`);
|
|
}
|
|
else if (!session.user?.groups?.includes('cospend')) {
|
|
await errorWithVerse(fetch, url.pathname, 403,
|
|
'Zugriff verweigert. Du hast keine Berechtigung für diesen Bereich. Falls du glaubst, dass dies ein Fehler ist, wende dich bitte an Alexander.');
|
|
}
|
|
}
|
|
|
|
// Protect tasks routes and API endpoints
|
|
if (url.pathname.startsWith('/tasks') || url.pathname.startsWith('/api/tasks')) {
|
|
if (!session) {
|
|
if (url.pathname.startsWith('/api/tasks')) {
|
|
await errorWithVerse(fetch, url.pathname, 401, 'Anmeldung erforderlich.');
|
|
}
|
|
const callbackUrl = encodeURIComponent(url.pathname + url.search);
|
|
redirect(303, `/login?callbackUrl=${callbackUrl}`);
|
|
}
|
|
else if (!session.user?.groups?.includes('task_users')) {
|
|
await errorWithVerse(fetch, url.pathname, 403,
|
|
'Zugriff verweigert. Du hast keine Berechtigung für diesen Bereich. Falls du glaubst, dass dies ein Fehler ist, wende dich bitte an Alexander.');
|
|
}
|
|
}
|
|
|
|
// Protect fitness routes and API endpoints
|
|
if (url.pathname.startsWith('/fitness') || url.pathname.startsWith('/api/fitness')) {
|
|
if (!session) {
|
|
if (url.pathname.startsWith('/api/fitness')) {
|
|
await errorWithVerse(fetch, url.pathname, 401, 'Authentication required.');
|
|
}
|
|
const callbackUrl = encodeURIComponent(url.pathname + url.search);
|
|
redirect(303, `/login?callbackUrl=${callbackUrl}`);
|
|
}
|
|
}
|
|
|
|
// If the request is still here, just proceed as normally
|
|
return resolve(event);
|
|
}
|
|
|
|
/** Browser/crawler probes for these paths are routine 404s — not bugs.
|
|
* Skip the noisy console.error so real errors stay visible. */
|
|
const SILENT_404_PATHS = new Set(['/favicon.ico', '/apple-touch-icon.png', '/apple-touch-icon-precomposed.png']);
|
|
|
|
export const handleError: HandleServerError = async ({ error, event, status, message }) => {
|
|
if (!(status === 404 && SILENT_404_PATHS.has(event.url.pathname))) {
|
|
console.error('Error occurred:', { error, status, message, url: event.url.pathname });
|
|
}
|
|
|
|
const bibleQuote = await getRandomVerse(event.fetch, event.url.pathname);
|
|
const isEnglish = event.url.pathname.startsWith('/faith/') || event.url.pathname.startsWith('/recipes/');
|
|
|
|
return {
|
|
message,
|
|
bibleQuote,
|
|
lang: isEnglish ? 'en' : 'de'
|
|
};
|
|
};
|
|
|
|
export const handle: Handle = sequence(
|
|
timing,
|
|
htmlLang,
|
|
noindex,
|
|
securityHeaders,
|
|
auth.handle,
|
|
authorization
|
|
);
|