Some checks failed
CI / Backend (push) Successful in 56s
CI / UI (push) Successful in 38s
Release / Test backend (push) Successful in 42s
Release / Docker / caddy (push) Failing after 11s
CI / Backend (pull_request) Failing after 11s
Release / Docker / backend (push) Failing after 38s
CI / UI (pull_request) Successful in 44s
Release / Check ui (push) Successful in 1m53s
Release / Docker / runner (push) Failing after 1m26s
Release / Docker / ui (push) Successful in 3m46s
Release / Gitea Release (push) Has been skipped
Root cause: user_settings table was missing theme, locale, font_family, font_size columns — PocketBase silently dropped them on every save. Added the four columns via PocketBase API. Also: - listOne now sorts by -updated so the most-recent settings record wins - PARAGLIDE_LOCALE cookie is now cleared when switching back to English - pt-BR renamed to pt throughout (messages, inlang settings, validLocales) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
230 lines
8.8 KiB
TypeScript
230 lines
8.8 KiB
TypeScript
import type { Handle } from '@sveltejs/kit';
|
|
import { sequence } from '@sveltejs/kit/hooks';
|
|
import { handleErrorWithSentry } from '@sentry/sveltekit';
|
|
import * as Sentry from '@sentry/sveltekit';
|
|
import { randomBytes, createHmac } from 'node:crypto';
|
|
import { env } from '$env/dynamic/private';
|
|
import { env as pubEnv } from '$env/dynamic/public';
|
|
import { log } from '$lib/server/logger';
|
|
import { createUserSession, touchUserSession, isSessionRevoked, getUserById } from '$lib/server/pocketbase';
|
|
import { drain as drainPresignCache } from '$lib/server/presignCache';
|
|
import { NodeSDK } from '@opentelemetry/sdk-node';
|
|
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
|
|
import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http';
|
|
import { BatchLogRecordProcessor } from '@opentelemetry/sdk-logs';
|
|
import { resourceFromAttributes } from '@opentelemetry/resources';
|
|
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
|
|
import { paraglideMiddleware } from '$lib/paraglide/server';
|
|
|
|
// ─── OpenTelemetry server-side tracing + logs ─────────────────────────────────
|
|
// No-op when OTEL_EXPORTER_OTLP_ENDPOINT is unset (e.g. local dev).
|
|
const otlpEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
|
|
if (otlpEndpoint) {
|
|
const sdk = new NodeSDK({
|
|
resource: resourceFromAttributes({
|
|
[ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME ?? 'ui',
|
|
[ATTR_SERVICE_VERSION]: pubEnv.PUBLIC_BUILD_VERSION ?? 'dev'
|
|
}),
|
|
traceExporter: new OTLPTraceExporter({ url: `${otlpEndpoint}/v1/traces` }),
|
|
logRecordProcessors: [
|
|
new BatchLogRecordProcessor(
|
|
new OTLPLogExporter({ url: `${otlpEndpoint}/v1/logs` })
|
|
)
|
|
]
|
|
});
|
|
sdk.start();
|
|
process.once('SIGTERM', () => sdk.shutdown().catch(() => {}));
|
|
process.once('SIGINT', () => sdk.shutdown().catch(() => {}));
|
|
}
|
|
|
|
// ─── Sentry / GlitchTip server-side error tracking ────────────────────────────
|
|
// No-op when PUBLIC_GLITCHTIP_DSN is unset (e.g. local dev).
|
|
if (pubEnv.PUBLIC_GLITCHTIP_DSN) {
|
|
Sentry.init({
|
|
dsn: pubEnv.PUBLIC_GLITCHTIP_DSN,
|
|
tracesSampleRate: 0.1,
|
|
// Must match the release name used when uploading source maps in CI
|
|
// (BUILD_VERSION injected by Dockerfile as PUBLIC_BUILD_VERSION).
|
|
release: pubEnv.PUBLIC_BUILD_VERSION || undefined
|
|
});
|
|
}
|
|
|
|
export const handleError = handleErrorWithSentry();
|
|
|
|
// ─── 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 ─────────────────────────────────────────────────────────────────────
|
|
|
|
function getTextDirection(locale: string): string {
|
|
// All supported locales (en, ru, id, pt, fr) are LTR
|
|
return 'ltr';
|
|
}
|
|
|
|
const paraglideHandle: Handle = ({ event, resolve }) =>
|
|
paraglideMiddleware(event.request, ({ request: localizedRequest, locale }) => {
|
|
event.request = localizedRequest;
|
|
return resolve(event, {
|
|
transformPageChunk: ({ html }) =>
|
|
html.replace('%lang%', locale).replace('%dir%', getTextDirection(locale))
|
|
});
|
|
});
|
|
|
|
const appHandle: 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;
|
|
}
|
|
|
|
// ── isPro: read fresh from DB so role changes take effect without re-login ──
|
|
if (event.locals.user) {
|
|
try {
|
|
const dbUser = await getUserById(event.locals.user.id);
|
|
event.locals.isPro = dbUser?.role === 'pro' || dbUser?.role === 'admin';
|
|
} catch {
|
|
event.locals.isPro = false;
|
|
}
|
|
} else {
|
|
event.locals.isPro = false;
|
|
}
|
|
|
|
return resolve(event);
|
|
};
|
|
|
|
export const handle = sequence(paraglideHandle, appHandle);
|
|
|