'use strict'; const fsp = require('node:fs/promises'); const path = require('node:path'); const zlib = require('node:zlib'); const { URL } = require('node:url'); const { send, json, readJson, safeName, mimeByExt } = require('./http'); const auth = require('./auth'); const covers = require('./covers'); const sync = require('./sync'); const ROOT = path.resolve(__dirname, '..'); const APP_DIR = path.join(ROOT, 'app'); const APP_HTML = path.join(APP_DIR, 'BangumiVault.html'); const JS_DIR = path.join(APP_DIR, 'js'); const CSS_DIR = path.join(APP_DIR, 'css'); const FONTS_DIR = path.join(APP_DIR, 'fonts'); function createHandler(store) { let publicModeCache = null; // Version-keyed cache of the client state (plain + gzip), so repeated refreshes without any // data change serve a 304 / cached bytes instead of re-stripping+re-gzipping the big JSON. // It's also pre-warmed in the background (startup + after writes) so the build cost stays off // the request path and even the first load after a change is fast. let stateCache = null; // { version, plain, gzip, etag } let warming = null; async function buildStateCache(version) { const state = await store.getClientState(); if (!state) { stateCache = null; return null; } const plain = Buffer.from(JSON.stringify(state), 'utf8'); stateCache = { version, plain, gzip: zlib.gzipSync(plain), etag: `W/"st-${version}"` }; return stateCache; } function ensureWarm() { const v = store.getStateVersion(); if ((stateCache && stateCache.version === v) || warming) return; warming = buildStateCache(v).catch(() => {}).finally(() => { warming = null; }); } const warmTimer = setInterval(ensureWarm, 2000); if (warmTimer.unref) warmTimer.unref(); ensureWarm(); // pre-warm at startup async function getPublicMode() { if (publicModeCache === null) { publicModeCache = (await store.getSetting('public_mode')) === 'true'; } return publicModeCache; } // Serve a file with conditional caching (ETag from size+mtime). Returns 304 when unchanged. async function serveFileCached(req, res, target, cacheControl) { try { const st = await fsp.stat(target); const etag = `W/"${st.size}-${Math.floor(st.mtimeMs)}"`; if (req.headers['if-none-match'] === etag) { res.writeHead(304, { 'ETag': etag, 'Cache-Control': cacheControl }); res.end(); return; } const buf = await fsp.readFile(target); send(res, 200, buf, mimeByExt(target), { 'ETag': etag, 'Last-Modified': st.mtime.toUTCString(), 'Cache-Control': cacheControl }); } catch { send(res, 404, 'Not found'); } } async function serveStatic(req, res, dir, name) { const file = safeName(path.basename(name), ''); if (!file) { send(res, 404, 'Not found'); return; } await serveFileCached(req, res, path.join(dir, file), 'no-cache'); } return async function handleRequest(req, res) { res._acceptGzip = /\bgzip\b/.test(req.headers['accept-encoding'] || ''); const requestUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`); const pathname = decodeURIComponent(requestUrl.pathname); // --- public assets (no auth) --- if (req.method === 'GET' && (pathname === '/' || pathname === '/BangumiVault.html')) { await serveFileCached(req, res, APP_HTML, 'no-cache'); return; } if (req.method === 'GET' && pathname.startsWith('/js/')) { await serveStatic(req, res, JS_DIR, pathname.slice('/js/'.length)); return; } if (req.method === 'GET' && pathname.startsWith('/css/')) { await serveStatic(req, res, CSS_DIR, pathname.slice('/css/'.length)); return; } // Self-hosted web fonts (public, immutable). Allows the /fonts//files/ subpaths. if (req.method === 'GET' && pathname.startsWith('/fonts/')) { const target = path.normalize(path.join(FONTS_DIR, pathname.slice('/fonts/'.length))); if (target !== FONTS_DIR && !target.startsWith(FONTS_DIR + path.sep)) { send(res, 404, 'Not found'); return; } await serveFileCached(req, res, target, 'public, max-age=31536000, immutable'); return; } if (req.method === 'GET' && pathname === '/api/session') { const pm = await getPublicMode(); const psn = (await store.getSetting('public_show_nsfw')) === 'true'; json(res, 200, { authenticated: auth.isAuthed(req), publicMode: pm, publicShowNsfw: psn }); return; } if (req.method === 'POST' && pathname === '/api/login') { const body = await readJson(req); if (!auth.verifyPassword(body.password || '', await store.getSetting('admin_password_hash'))) { json(res, 401, { ok: false, error: 'invalid password' }); return; } json(res, 200, { ok: true }, { 'Set-Cookie': `bv_session=${encodeURIComponent(auth.createSessionCookie())}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${Math.floor(auth.SESSION_TTL_MS / 1000)}` }); return; } if (req.method === 'POST' && pathname === '/api/logout') { json(res, 200, { ok: true }, { 'Set-Cookie': 'bv_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0' }); return; } if (req.method === 'GET' && pathname === '/api/ping') { json(res, 200, { ok: true, docker: true, database: store.kind, filesDir: covers.FILES_DIR }); return; } // --- auth gate (public mode allows read of state + images) --- if ((pathname.startsWith('/api/') || pathname.startsWith('/images/')) && !auth.isAuthed(req)) { const pm = await getPublicMode(); const readOk = pm && ( (req.method === 'GET' && pathname === '/api/state') || (req.method === 'GET' && pathname === '/api/history' && requestUrl.searchParams.get('subject_id')) || (req.method === 'GET' && pathname === '/api/subject-meta' && requestUrl.searchParams.get('subject_id')) || (req.method === 'GET' && pathname === '/api/infoboxes') || (req.method === 'GET' && pathname.startsWith('/images/')) ); if (!readOk) { json(res, 401, { ok: false, error: 'unauthorized' }); return; } } if (req.method === 'POST' && pathname === '/api/admin/password') { const body = await readJson(req); const currentPassword = String(body.currentPassword || ''); const newPassword = String(body.newPassword || ''); if (!auth.verifyPassword(currentPassword, await store.getSetting('admin_password_hash'))) { json(res, 400, { ok: false, error: 'current password is incorrect' }); return; } if (newPassword.length < 10) { json(res, 400, { ok: false, error: 'new password must be at least 10 characters' }); return; } await store.setSetting('admin_password_hash', auth.hashPassword(newPassword)); json(res, 200, { ok: true }); return; } if (req.method === 'POST' && pathname === '/api/admin/public-mode') { const body = await readJson(req); const enabled = !!body.enabled; const showNsfw = !!body.showNsfw; await store.setSetting('public_mode', String(enabled)); await store.setSetting('public_show_nsfw', String(showNsfw)); publicModeCache = enabled; json(res, 200, { ok: true, publicMode: enabled, publicShowNsfw: showNsfw }); return; } if (req.method === 'GET' && pathname === '/api/state') { // History is excluded here (lazy-loaded per subject) to keep this payload small. const version = store.getStateVersion(); if (!stateCache || stateCache.version !== version) { if (warming) { await warming; } if (!stateCache || stateCache.version !== version) { await buildStateCache(version); } } if (!stateCache) { send(res, 204, '', 'application/json; charset=utf-8'); return; } const c = stateCache; if (req.headers['if-none-match'] === c.etag) { res.writeHead(304, { 'ETag': c.etag, 'Cache-Control': 'no-cache', 'Vary': 'Accept-Encoding' }); res.end(); return; } const useGz = res._acceptGzip; const body = useGz ? c.gzip : c.plain; const headers = { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': body.length, 'Cache-Control': 'no-cache', 'ETag': c.etag, 'Vary': 'Accept-Encoding' }; if (useGz) headers['Content-Encoding'] = 'gzip'; res.writeHead(200, headers); res.end(body); return; } if (req.method === 'GET' && pathname === '/api/history') { const subjectId = requestUrl.searchParams.get('subject_id'); const history = subjectId ? await store.getSubjectHistory(subjectId) : await store.getAllHistory(); send(res, 200, JSON.stringify({ history }), 'application/json; charset=utf-8'); return; } // Subject infobox (lazy-loaded by the detail drawer; stripped from /api/state for payload size). if (req.method === 'GET' && pathname === '/api/subject-meta') { const subjectId = requestUrl.searchParams.get('subject_id'); const infobox = subjectId ? await store.getSubjectInfobox(subjectId) : []; send(res, 200, JSON.stringify({ infobox }), 'application/json; charset=utf-8'); return; } // All infoboxes as a map (background-prefetched by the client so drawers show 条目资料 instantly). if (req.method === 'GET' && pathname === '/api/infoboxes') { send(res, 200, JSON.stringify({ infoboxes: await store.getAllInfoboxes() }), 'application/json; charset=utf-8'); return; } // Full replace — backup import only. if (req.method === 'POST' && pathname === '/api/state') { await store.persistState(await readJson(req)); json(res, 200, { ok: true }); return; } // Merge partial settings without touching collections. if (req.method === 'POST' && pathname === '/api/settings') { const body = await readJson(req); await store.setStateSettings(body.settings || {}); json(res, 200, { ok: true }); return; } // Update a single collection's status fields (safety box). if (req.method === 'POST' && pathname === '/api/collections/status') { const body = await readJson(req); if (!body.subject_id) { json(res, 400, { ok: false, error: 'missing subject_id' }); return; } const patch = {}; if (body.status !== undefined) patch.status = body.status; if (body.missing_count !== undefined) patch.missing_count = body.missing_count; const merged = await store.patchCollection(body.subject_id, patch); json(res, merged ? 200 : 404, merged ? { ok: true, collection: merged } : { ok: false, error: 'not found' }); return; } if (req.method === 'POST' && pathname === '/api/sync') { const body = await readJson(req); const mode = body.mode === 'full' ? 'full' : 'recent'; const opts = {}; if (body.recentMonths !== undefined) opts.recentMonths = Math.max(1, Math.min(120, Number(body.recentMonths) || 3)); if (body.cacheCovers !== undefined) opts.cacheCovers = !!body.cacheCovers; if (body.refreshSubjects !== undefined) opts.refreshSubjects = !!body.refreshSubjects; const started = sync.startSync(store, mode, 'manual', opts); if (started) json(res, 200, { ok: true, started: true }); else json(res, 409, { ok: false, started: false, error: 'sync already running' }); return; } if (req.method === 'GET' && pathname === '/api/sync/status') { json(res, 200, sync.getStatus()); return; } if (req.method === 'POST' && pathname === '/api/cache-cover') { const body = await readJson(req); const remoteUrl = String(body.url || '').trim(); if (!body.subject_id || !remoteUrl) { json(res, 400, { ok: false, error: 'missing subject_id or url' }); return; } const r = await covers.cacheCover(body.subject_id, remoteUrl); json(res, 200, { ok: true, url: r.url, file: r.file }); return; } if (req.method === 'GET' && pathname.startsWith('/images/')) { const file = safeName(path.basename(pathname.slice('/images/'.length)), 'image.jpg'); // Covers are effectively immutable per subject; cache for a day so refreshes don't re-download. await serveFileCached(req, res, path.join(covers.COVERS_DIR, file), 'public, max-age=86400'); return; } send(res, 404, 'Not found'); }; } module.exports = { createHandler };