Files
libnovel/v3/ui/src/hooks.server.ts
Admin a85636d5db feat(v3): add v3 stack — backend rewrite, renamed env vars, docs
- New Go backend binary (backend + runner) replacing old scraper/
- Rename SCRAPER_API_URL → BACKEND_API_URL in UI env and docker-compose
- Rename scraperFetch → backendFetch across all 19 UI server files
- Remove SCRAPER_PROXY env var and proxy transport from browser.Config
- Add Meilisearch, Valkey, Caddy to docker-compose
- Add docs/: api-endpoints.md, request-flow.mermaid.md, data-flow.mermaid.md
2026-03-22 17:27:32 +05:00

156 lines
5.9 KiB
TypeScript

import type { Handle } from '@sveltejs/kit';
import { randomBytes, createHmac } from 'node:crypto';
import { env } from '$env/dynamic/private';
import { log } from '$lib/server/logger';
import { createUserSession, touchUserSession, isSessionRevoked } from '$lib/server/pocketbase';
import { drain as drainPresignCache } from '$lib/server/presignCache';
// ─── Graceful shutdown ────────────────────────────────────────────────────────
//
// When Docker/Kubernetes sends SIGTERM (or the user sends SIGINT), we:
// 1. Set shuttingDown = true so new requests immediately receive 503.
// 2. Flush/drain in-process caches (presign URL cache).
// 3. Allow Node.js to exit naturally once in-flight requests finish.
//
// adapter-node does not provide a built-in hook for this, so we wire it here
// in hooks.server.ts which runs in the server Node.js process.
let shuttingDown = false;
function shutdown(signal: string) {
if (shuttingDown) return;
shuttingDown = true;
log.info('shutdown', `received ${signal}, draining in-flight requests`);
drainPresignCache();
// Don't call process.exit() — let Node exit naturally once the event loop
// is empty (adapter-node closes the HTTP server on its own).
}
process.once('SIGTERM', () => shutdown('SIGTERM'));
process.once('SIGINT', () => shutdown('SIGINT'));
const SESSION_COOKIE = 'libnovel_session';
const AUTH_COOKIE = 'libnovel_auth';
const ONE_YEAR = 60 * 60 * 24 * 365;
const AUTH_SECRET = env.AUTH_SECRET ?? 'dev_secret_change_in_production';
// ─── Token helpers ────────────────────────────────────────────────────────────
/**
* Sign a payload string with HMAC-SHA256 using AUTH_SECRET.
* Returns "<payload>.<signature>".
*/
export function signToken(payload: string): string {
const sig = createHmac('sha256', AUTH_SECRET).update(payload).digest('hex');
return `${payload}.${sig}`;
}
/**
* Verify a signed token. Returns the payload string on success, null on failure.
*/
export function verifyToken(token: string): string | null {
const lastDot = token.lastIndexOf('.');
if (lastDot < 0) return null;
const payload = token.slice(0, lastDot);
const expected = createHmac('sha256', AUTH_SECRET).update(payload).digest('hex');
const actual = token.slice(lastDot + 1);
// constant-time comparison
if (expected.length !== actual.length) return null;
let diff = 0;
for (let i = 0; i < expected.length; i++) {
diff |= expected.charCodeAt(i) ^ actual.charCodeAt(i);
}
return diff === 0 ? payload : null;
}
/**
* Create a signed auth token for a user.
* Payload format: "<userId>:<username>:<role>:<authSessionId>"
* authSessionId uniquely identifies this login session (for revocation).
*/
export function createAuthToken(userId: string, username: string, role: string, authSessionId: string): string {
return signToken(`${userId}:${username}:${role}:${authSessionId}`);
}
/**
* Parse a verified auth token into user data. Returns null if invalid.
* Supports both old format (3 segments) and new format (4 segments).
*/
export function parseAuthToken(token: string): { id: string; username: string; role: string; authSessionId: string } | null {
const payload = verifyToken(token);
if (!payload) return null;
const parts = payload.split(':');
// New format: userId:username:role:authSessionId (4 parts)
// Old format: userId:username:role (3 parts — legacy tokens before session tracking)
if (parts.length < 3) return null;
const id = parts[0];
const username = parts[1];
const role = parts[2];
const authSessionId = parts[3] ?? ''; // empty string for legacy tokens
if (!id || !username) return null;
return { id, username, role, authSessionId };
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
export const handle: Handle = async ({ event, resolve }) => {
// During graceful shutdown, reject new requests immediately so the load
// balancer / Docker health-check can drain existing connections.
if (shuttingDown) {
return new Response('Service shutting down', { status: 503 });
}
// Anonymous session cookie (for reading progress)
let sessionId = event.cookies.get(SESSION_COOKIE) ?? '';
if (!sessionId) {
sessionId = randomBytes(16).toString('hex');
event.cookies.set(SESSION_COOKIE, sessionId, {
path: '/',
httpOnly: true,
sameSite: 'lax',
maxAge: ONE_YEAR
});
}
event.locals.sessionId = sessionId;
// Auth cookie → resolve logged-in user
const authToken = event.cookies.get(AUTH_COOKIE);
if (authToken) {
const user = parseAuthToken(authToken);
if (!user) {
log.warn('auth', 'auth cookie present but failed to parse (malformed or tampered)');
event.locals.user = null;
} else {
// Validate session against DB (only for new-format tokens with authSessionId)
let sessionValid = true;
if (user.authSessionId) {
try {
const revoked = await isSessionRevoked(user.authSessionId);
if (revoked) {
log.info('auth', 'auth cookie references revoked session', {
userId: user.id,
authSessionId: user.authSessionId
});
sessionValid = false;
// Clear the invalid cookie
event.cookies.delete(AUTH_COOKIE, { path: '/' });
} else {
// Best-effort: update last_seen in the background
touchUserSession(user.authSessionId).catch(() => {});
}
} catch (err) {
// DB error — fail open to avoid locking everyone out
log.warn('auth', 'session check failed (fail open)', { err: String(err) });
}
}
event.locals.user = sessionValid ? user : null;
}
} else {
event.locals.user = null;
}
return resolve(event);
};