Initial release
Build and Push Docker Image / build (push) Successful in 17s

This commit is contained in:
Stardream
2026-06-16 19:26:32 +10:00
commit e3bf77296c
142 changed files with 5481 additions and 0 deletions
+294
View File
@@ -0,0 +1,294 @@
'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/<dir>/files/<name> 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 };