Files
MangaCircus/server.js
T

968 lines
54 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/*
* MangaCircus: a no-frills, self-hosted manga reader. No dependencies, just `node server.js`.
*
* Environment:
* PORT=8080 LIBRARY=./library DATA=./data
* SIGNUP=closed|open (the very first account can always be created and becomes admin)
* SCAN_INTERVAL=10 minutes between shallow change checks, 0 = off
* FULL_SCAN_INTERVAL=1440 minutes between full reconciliations, 0 = off
* COOKIE_SECURE=1 mark the session cookie Secure (when served over HTTPS)
* SCRAPER_URL=http://127.0.0.1:8091 scraper daemon API
* SCRAPER_TOKEN=... shared daemon token (optional on loopback)
*/
const http = require('http'), fs = require('fs'), path = require('path'), crypto = require('crypto');
const { scanLibrary, scanChangedLibrary, libraryStamps, snapshotIndex, restoreIndex } = require('./library');
const { Catalog, CatalogError, collectionViews, TYPES, ROLES } = require('./catalog');
const { RATINGS: GREATEST_RATINGS, loadCatalog: loadGreatestCatalog, findOwned: findGreatestOwned,
countsFor: greatestCounts, scoreFor: greatestScore } = require('./greatest');
const { hosterLinks } = require('./greatest/hosters');
const { secret } = require('./runtime/config');
const { PeeringState, requirePeer } = require('./peering/state');
const peerClient = require('./peering/client');
const { DiscoveryStore } = require('./discovery/store');
const { generate: generateDiscovery } = require('./discovery/engine');
const {
applyPulse, applyVisit, chapterInsight, eligiblePages, emptyRecord, libraryInsight, mangaInsight, publicSummary,
} = require('./reading-analytics');
const { parseSchedule, publicSchedule, loadQuestionBank, createReadingAccess } = require('./reading-access');
const PORT = +process.env.PORT || 8080;
const LIB = path.resolve(process.env.LIBRARY || 'library');
const DATA = path.resolve(process.env.DATA || 'data');
const OPEN_SIGNUP = process.env.SIGNUP === 'open';
const SCAN_MINUTES = +(process.env.SCAN_INTERVAL || 10);
const FULL_SCAN_MINUTES = +(process.env.FULL_SCAN_INTERVAL || 1440);
const SECURE_COOKIE = process.env.COOKIE_SECURE === '1';
const SCRAPER_URL = String(process.env.SCRAPER_URL || 'http://127.0.0.1:8091').replace(/\/$/, '');
const SCRAPER_TOKEN = secret('SCRAPER_TOKEN');
const SENTINEL_URL = String(process.env.SENTINEL_URL || 'http://127.0.0.1:8092').replace(/\/$/, '');
const PEER_DATA = path.resolve(process.env.PEER_DATA || path.join(DATA, 'peering'));
const peerStore = new PeeringState(PEER_DATA);
const discoveryStore = new DiscoveryStore(DATA);
const DISCOVERY_CACHE = path.join(DATA, 'scraper', 'metadata', 'anilist');
let discoveryRun = null, discoveryError = null;
const PUB = path.join(__dirname, 'public');
const IMG = /\.(jpe?g|png|webp|gif|avif|bmp)$/i;
const MIME = {
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8',
'.json': 'application/json', '.webmanifest': 'application/manifest+json', '.svg': 'image/svg+xml', '.ico': 'image/x-icon',
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif', '.avif': 'image/avif', '.bmp': 'image/bmp',
};
// ---------- storage: one JSON file, written atomically ----------
fs.mkdirSync(DATA, { recursive: true });
const catalog = new Catalog(DATA);
const greatestCatalog = loadGreatestCatalog();
const greatestById = new Map(greatestCatalog.entries.map(entry => [entry.id, entry]));
const DB_FILE = path.join(DATA, 'db.json'), CACHE_FILE = path.join(DATA, 'scan-cache.json'), INDEX_FILE = path.join(DATA, 'library-index.json');
const db = { users: {}, sessions: {}, progress: {}, reading: {}, readingAccess: {}, follows: {}, notes: {}, votes: {}, greatestRatings: {}, discoveryFeedback: {}, known: {} };
// A corrupt db.json stops the server instead of silently starting empty and overwriting it.
if (fs.existsSync(DB_FILE)) Object.assign(db, JSON.parse(fs.readFileSync(DB_FILE, 'utf8')));
function writeAtomic(file, text) { fs.writeFileSync(file + '.tmp', text); fs.renameSync(file + '.tmp', file); }
let saveTimer = null;
function save() { saveTimer = saveTimer || setTimeout(flush, 500); }
function flush() { clearTimeout(saveTimer); saveTimer = null; writeAtomic(DB_FILE, JSON.stringify(db)); }
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { flush(); process.exit(0); });
db.readingAccess ||= {};
const readingSchedule = parseSchedule(process.env.READING_WINDOWS_JSON || '', process.env.READING_TIMEZONE || process.env.TZ || 'Europe/Berlin');
const readingAccess = createReadingAccess({ schedule: readingSchedule,
bank: loadQuestionBank(process.env.READING_QUIZ_CATALOG_PATH || ''),
unlockMinutes: Number(process.env.READING_QUIZ_UNLOCK_MINUTES || 60), grants: db.readingAccess, save,
getProgress: (user, mangaId) => db.progress[user]?.[mangaId] || {},
getMangaTitle: mangaId => lib.byId.get(mangaId)?.title || null });
class HttpError extends Error { constructor(code, msg) { super(msg); this.code = code; } }
function bad(code, msg) { throw new HttpError(code, msg); }
function peerForRoute(id) {
try { return requirePeer(peerStore.load(), id); }
catch { bad(404, 'Peer not found'); }
}
async function peerRemote(task) {
try { return await task(); }
catch (error) { bad(502, `Peer service: ${String(error.message || error).slice(0, 300)}`); }
}
// ---------- users & sessions ----------
const sha = s => crypto.createHash('sha256').update(s).digest('hex');
function hashPw(pw) { const salt = crypto.randomBytes(16).toString('hex'); return salt + ':' + crypto.scryptSync(pw, salt, 32).toString('hex'); }
function checkPw(pw, stored) {
const [salt, hex] = String(stored).split(':'), want = Buffer.from(hex || '', 'hex'), got = crypto.scryptSync(pw, salt || '', 32);
return want.length === got.length && crypto.timingSafeEqual(want, got);
}
const DUMMY_HASH = hashPw('x');
const DEFAULTS = { startMode: 'last', lastMode: 'page', adapt: true, skipExtras: false, skipCovers: false, flowPageGap: true, pageTone: 'original', readingClock: false, readingStory: true,
discoveryEnabled: false, discoveryReadingActivity: false, discoveryMature: false, zoom: {}, webhook: '' };
const settingsOf = u => ({ ...DEFAULTS, ...u.settings, zoom: { ...(u.settings && u.settings.zoom) } });
function createUser(name, pw, admin, replace) {
name = String(name || '').trim().toLowerCase(); pw = String(pw || '');
if (!/^[a-z0-9_.-]{1,32}$/.test(name)) bad(400, 'User names may only use a-z, 0-9, "_", "." and "-" (max. 32).');
if (pw.length < 4) bad(400, 'The password needs at least 4 characters.');
const old = db.users[name];
if (old && !replace) bad(409, 'That user name is taken.');
db.users[name] = { settings: {}, ...old, name, hash: hashPw(pw), admin: !!admin, created: old ? old.created : Date.now() };
save();
return db.users[name];
}
function startSession(res, user) {
const token = crypto.randomBytes(32).toString('base64url');
db.sessions[sha(token)] = { u: user.name, t: Date.now() };
save();
res.setHeader('Set-Cookie', `sid=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000${SECURE_COOKIE ? '; Secure' : ''}`);
}
const tokenOf = req => { const m = /(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || ''); return m ? m[1] : null; };
function userOf(req) { const t = tokenOf(req), s = t && db.sessions[sha(t)]; return (s && db.users[s.u]) || null; }
for (const [k, s] of Object.entries(db.sessions)) if (Date.now() - s.t > 365 * 864e5 || !db.users[s.u]) delete db.sessions[k];
// ---------- library index ----------
let lib = { mangas: [], byId: new Map() }, scanning = null, pendingScan = null, scanState = {}, scanCache = {}, scanStamps = {};
let lastFullAt = 0, lastFullAttemptAt = 0;
try { scanCache = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')); } catch { /* first run */ }
try {
const saved = JSON.parse(fs.readFileSync(INDEX_FILE, 'utf8'));
if (saved.version !== 1 || saved.root !== LIB || !saved.cache || !saved.stamps) throw new Error('Invalid library index snapshot');
const restored = restoreIndex(saved.mangas);
// A disconnected/replaced library mount must not appear populated from cache.
const present = new Set(fs.readdirSync(LIB, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => e.name));
if (restored.mangas.length && !restored.mangas.some(m => present.has(m.id))) throw new Error('Library mount differs from snapshot');
lib = restored; scanCache = saved.cache; scanStamps = saved.stamps;
lastFullAt = Number(saved.fullAt) || Number(saved.at) || 0;
scanState = { at: saved.at, mode: 'snapshot', stale: true, mangas: lib.mangas.length, error: null };
console.log(`[scan] restored ${lib.mangas.length} manga from saved index; verifying in background`);
} catch (e) { if (e.code !== 'ENOENT') console.warn(`[scan] ignoring saved index: ${e.message}`); }
async function performScan(mode) {
const t0 = Date.now();
try {
// Capture before the scan: a publication during the walk remains visible
// as a changed stamp on the next check, rather than being silently missed.
const stamps = await libraryStamps(LIB);
const result = mode === 'changed' && Object.keys(scanStamps).length
? await scanChangedLibrary(LIB, lib, scanCache, scanStamps, stamps)
: { ...(await scanLibrary(LIB, mode === 'deep' ? {} : scanCache)), stamps, changed: Object.keys(stamps) };
const { index, cache, changed } = result;
if (mode !== 'changed' || changed.length) {
announce(index);
lib = index; scanCache = cache; scanStamps = stamps;
if (mode !== 'changed') lastFullAt = Date.now();
writeAtomic(CACHE_FILE, JSON.stringify(cache));
writeAtomic(INDEX_FILE, JSON.stringify({ version: 1, root: LIB, at: Date.now(), fullAt: lastFullAt,
mangas: snapshotIndex(index), cache, stamps }));
}
let chapters = 0, pages = 0;
for (const m of lib.mangas) for (const ch of m.chapters) { chapters++; for (const v of ch.versions) pages += v.pages.length; }
scanState = { at: Date.now(), ms: Date.now() - t0, mode, changedMangas: changed.length,
mangas: lib.mangas.length, chapters, pages, stale: false, error: null };
console.log(`[scan] ${mode}: ${changed.length} changed manga, ${lib.mangas.length} manga, ${chapters} chapters, ${pages} pages in ${scanState.ms} ms`);
return scanState;
} catch (e) {
scanState = { ...scanState, at: Date.now(), error: e.message };
console.error('[scan] failed:', e.message);
return scanState;
}
}
function queueScan(mode) {
if (scanning) {
const priority = { changed: 1, full: 2, deep: 3 };
if (!pendingScan || priority[mode] > priority[pendingScan]) pendingScan = mode;
return scanning;
}
scanning = (async () => {
let next = mode, result;
do { result = await performScan(next); next = pendingScan; pendingScan = null; } while (next);
return result;
})().finally(() => { scanning = null; });
return scanning;
}
function rescan(full) { return queueScan(full ? 'deep' : 'full'); }
function refreshChanged() { return queueScan('changed'); }
// New chapter ids (compared with every id ever seen for that manga) notify the followers.
function announce(index) {
for (const m of index.mangas) {
const known = db.known[m.id];
if (!known) { db.known[m.id] = m.chapters.map(c => c.id); save(); continue; }
const seen = new Set(known), fresh = m.chapters.filter(c => !seen.has(c.id));
if (!fresh.length) continue;
db.known[m.id] = known.concat(fresh.map(c => c.id));
for (const [name, follows] of Object.entries(db.follows)) if (follows[m.id] && db.users[name]) notify(db.users[name], m, fresh);
save();
}
}
function notify(user, m, chapters) {
const notes = (db.notes[user.name] = db.notes[user.name] || []);
for (const ch of chapters) {
notes.push({ id: crypto.randomBytes(6).toString('hex'), t: Date.now(), m: m.id, c: ch.id, text: `${m.title} · ${ch.label}${ch.title ? ': ' + ch.title : ''}`, read: false });
}
if (notes.length > 200) notes.splice(0, notes.length - 200);
const hook = settingsOf(user).webhook;
if (hook) {
const body = `New in ${m.title}: ${chapters.map(c => c.label + (c.title ? ' ' + c.title : '')).join(', ')}`;
fetch(hook, { method: 'POST', body, headers: { 'Content-Type': 'text/plain; charset=utf-8', Title: 'MangaCircus' }, signal: AbortSignal.timeout(10000) })
.catch(e => console.warn(`[notify] webhook of ${user.name} failed: ${e.message}`));
}
}
// ---------- view helpers ----------
const url = rel => (rel ? 'lib/' + rel.split('/').map(encodeURIComponent).join('/') : null);
const mangaOf = id => lib.byId.get(String(id)) || bad(404, 'Manga not found');
const chapterOf = (m, id) => m.chapterById.get(String(id)) || bad(404, 'Chapter not found');
const progressOf = (u, m) => (db.progress[u.name] && db.progress[u.name][m.id]) || {};
const mangaReadingOf = (u, m) => (db.reading[u.name] && db.reading[u.name][m.id]) || {};
const readingOf = (u, m, ch) => (db.reading[u.name] && db.reading[u.name][m.id] && db.reading[u.name][m.id][ch.id]) || null;
const unreadOf = u => (db.notes[u.name] || []).filter(n => !n.read).length;
const admin = u => u.admin || bad(403, 'Admins only');
const READING_LEASE_MS = 15000;
const readingLeases = new Map();
async function scraperRequest(endpoint, body) {
const headers = { accept: 'application/json' };
if (SCRAPER_TOKEN) headers.authorization = `Bearer ${SCRAPER_TOKEN}`;
const options = { headers, signal: AbortSignal.timeout(7000) };
if (body !== undefined) {
options.method = 'POST'; headers['content-type'] = 'application/json'; options.body = JSON.stringify(body);
}
let response;
try { response = await fetch(SCRAPER_URL + endpoint, options); }
catch (error) { throw new HttpError(503, `Scraper service unavailable: ${error.message}`); }
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new HttpError(response.status >= 500 ? 502 : response.status, data.error || `Scraper HTTP ${response.status}`);
return data;
}
function readDiscovery() {
try { return discoveryStore.load(); }
catch (error) { bad(503, error.message); }
}
function startDiscoveryRun() {
if (discoveryRun?.status === 'running') bad(409, 'A discovery run is already active');
readDiscovery(); // Refuse a new run if the on-disk store is corrupt.
const id = crypto.randomUUID(), startedAt = new Date().toISOString();
discoveryRun = { id, status: 'running', startedAt };
discoveryError = null;
setImmediate(async () => {
try {
let queuedTitles = [], queueAvailable = true;
try { queuedTitles = (await scraperRequest('/api/state')).queue?.filter(item => !['complete', 'removed'].includes(item.status)).map(item => item.title) || []; }
catch { queueAvailable = false; }
const result = generateDiscovery({ db, library: lib.mangas, greatest: greatestCatalog,
cacheDir: DISCOVERY_CACHE, queuedTitles });
const published = discoveryStore.publish({ ...result, startedAt });
discoveryRun = { id, status: 'success', startedAt, finishedAt: published.lastRun.finishedAt,
candidates: published.lastRun.candidateCount, queueAvailable };
} catch (error) {
discoveryError = String(error.message || error).slice(0, 300);
discoveryRun = { id, status: 'failed', startedAt, finishedAt: new Date().toISOString() };
console.error('[discovery]', discoveryError);
}
});
return discoveryRun;
}
const mangaDexSearchCache = new Map();
let lastMangaDexSearch = 0;
async function searchGreatestMangaDex(entry) {
const cached = mangaDexSearchCache.get(entry.id);
if (cached && Date.now() - cached.time < 60 * 60 * 1000) return cached.results;
if (Date.now() - lastMangaDexSearch < 2000) bad(429, 'Wait two seconds before another MangaDex lookup');
lastMangaDexSearch = Date.now();
let response;
try {
response = await fetch(`https://api.mangadex.org/manga?title=${encodeURIComponent(entry.title)}&limit=10`,
{ headers: { accept: 'application/json' }, signal: AbortSignal.timeout(7000) });
} catch (error) { throw new HttpError(503, `MangaDex lookup unavailable: ${error.message}`); }
if (!response.ok) throw new HttpError(response.status === 429 ? 503 : 502, `MangaDex lookup returned HTTP ${response.status}`);
const body = await response.json();
const results = (body.data || []).filter(row => /^[0-9a-f-]{36}$/i.test(row.id)).map(row => ({
title: row.attributes?.title?.en || Object.values(row.attributes?.title || {})[0] || 'Untitled',
year: row.attributes?.year || null, url: `https://mangadex.org/title/${row.id}`,
}));
mangaDexSearchCache.set(entry.id, { time: Date.now(), results });
return results;
}
// Versions sorted by the sum of all user votes; ties go to the higher "priority" in chapter.json, then folder order.
function ranked(m, ch) {
const votes = (db.votes[m.id] && db.votes[m.id][ch.id]) || {};
return ch.versions
.map((v, i) => ({ v, i, score: Object.values(votes[v.id] || {}).reduce((a, b) => a + b, 0) }))
.sort((a, b) => b.score - a.score || (+b.v.meta.priority || 0) - (+a.v.meta.priority || 0) || a.i - b.i);
}
function versionsOf(m, ch, u) {
const votes = (db.votes[m.id] && db.votes[m.id][ch.id]) || {};
return ranked(m, ch).map(({ v, score }) => ({
id: v.id, source: v.source, sourceUrl: v.sourceUrl, pages: v.pages.length, added: v.added, score, my: (votes[v.id] && votes[v.id][u.name]) || 0,
}));
}
// Normalize the release-date shapes accepted by manga.json for library sorting.
function firstReleaseOf(meta) {
meta = meta || {};
const raw = meta.firstReleaseDate || meta.releaseDate || meta.startDate;
let year, month, day, label, time;
if (raw && typeof raw === 'object') {
year = +raw.year; month = +raw.month || 1; day = +raw.day || 1;
if (year > 0) {
label = [String(year).padStart(4, '0'), raw.month ? String(month).padStart(2, '0') : null, raw.day ? String(day).padStart(2, '0') : null].filter(Boolean).join('-');
time = Date.UTC(year, month - 1, day);
}
} else if (typeof raw === 'string' && raw.trim()) {
const parsed = Date.parse(raw);
if (Number.isFinite(parsed)) { label = raw.trim(); time = parsed; }
}
if (!Number.isFinite(time)) {
year = +meta.year;
if (year > 0) { label = String(year); time = Date.UTC(year, 0, 1); }
}
return { label: label || '', time: Number.isFinite(time) ? time : 0 };
}
function continueOf(m, prog, s) {
const ok = c => !(s.skipExtras && c.extra);
let last = null;
for (const ch of m.chapters) { const p = prog[ch.id]; if (p && (!last || p.t > last.p.t)) last = { ch, p }; }
if (!last) { const first = m.chapters.find(ok) || m.chapters[0]; return { c: first.id, text: 'Start reading · ' + first.label }; }
if (!last.p.done) return { c: last.ch.id, v: last.p.v, text: `Continue · ${last.ch.label} · page ${last.p.p + 1}` };
const next = m.chapters.slice(m.chapters.indexOf(last.ch) + 1).find(ok);
return next ? { c: next.id, text: 'Read next · ' + next.label } : { c: last.ch.id, v: last.p.v, text: 'Read again · ' + last.ch.label };
}
function neighbour(m, ch, step, s) {
for (let i = m.chapters.indexOf(ch) + step; i >= 0 && i < m.chapters.length; i += step) {
const c = m.chapters[i];
if (!(s.skipExtras && c.extra)) return { id: c.id, label: c.label, title: c.title };
}
return null;
}
// ---------- API ----------
const PUBLIC = new Set(['/api/auth', '/api/login', '/api/signup']);
const routes = {
'GET /api/health': () => ({ ok: !scanState.error, service: 'reader', uptime: Math.round(process.uptime()), scan: scanState }),
'GET /api/collections': ({ u }) => ({ collections: routes['GET /api/library']({ u }).collections }),
'GET /api/collection': ({ u, q }) => {
const view = routes['GET /api/library']({ u }).collections.find(c => c.id === q.get('id')) || bad(404, 'Collection not found');
const next = view.members.find(m => m.read < m.chapters) || view.members[0];
const manga = mangaOf(next.id);
return { ...view, continue: { m: next.id, ...continueOf(manga, progressOf(u, manga), settingsOf(u)) },
relations: catalog.state.relations.filter(r => r.state === 'confirmed' && view.members.some(m => m.id === r.from || m.id === r.to)) };
},
'GET /api/admin/catalog': ({ u }) => { admin(u); return { ...catalog.state, types: TYPES, roles: ROLES, mangas: lib.mangas.map(m => ({ id: m.id, title: m.title })) }; },
'POST /api/admin/catalog': ({ u, b }) => { admin(u); return catalog.update(b, new Set(lib.byId.keys()), u.name); },
'POST /api/admin/catalog/suggest': ({ u, b }) => {
admin(u);
return catalog.update({ ...catalog.state, revision: b.revision, relations: catalog.suggest(lib.mangas) }, new Set(lib.byId.keys()), u.name);
},
'POST /api/admin/catalog/research': async ({ u, b }) => {
admin(u);
const m = mangaOf(b.m), id = m.meta?.['x-anilist']?.id;
if (!id) bad(400, 'Assign a verified AniList identity first');
const { lookupAniListById } = require('./scraper/metadata/anilist');
const meta = await lookupAniListById(id, { cacheDir: path.join(DATA, 'metadata', 'anilist') });
const enriched = lib.mangas.map(item => item.id === m.id ? { ...item, meta } : item);
return catalog.update({ ...catalog.state, revision: b.revision, relations: catalog.suggest(enriched) }, new Set(lib.byId.keys()), u.name);
},
'POST /api/admin/collection-cover': ({ u, b }) => {
admin(u);
const c = catalog.state.collections.find(c => c.id === b.id) || bad(404, 'Collection not found');
if (b.revision !== catalog.state.revision) bad(409, 'Catalog changed; reload before saving');
const image = Buffer.from(String(b.base64 || ''), 'base64');
const ext = require('./scraper/lib/images').imageExtension(image);
if (!ext || image.length > 600000) bad(400, 'Use a JPG, PNG, WebP, GIF, AVIF or BMP under 600 KB');
const name = sha(image) + '.' + ext;
const dir = path.join(DATA, 'collection-covers'); fs.mkdirSync(dir, { recursive: true });
if (!fs.existsSync(path.join(dir, name))) fs.writeFileSync(path.join(dir, name), image, { flag: 'wx' });
return catalog.update({ ...catalog.state, collections: catalog.state.collections.map(item => item.id === c.id ? { ...item, coverMode: 'custom', customCover: name } : item) }, new Set(lib.byId.keys()), u.name);
},
'GET /api/admin/health': async ({ u }) => {
admin(u);
const sentinel = await fetch(SENTINEL_URL + '/api/state', { headers: SCRAPER_TOKEN ? { authorization: `Bearer ${SCRAPER_TOKEN}` } : {}, signal: AbortSignal.timeout(4000) })
.then(async r => { if (!r.ok) throw new Error(); return r.json(); }).catch(() => ({ status: 'unreachable' }));
return { reader: { ok: !scanState.error, uptime: Math.round(process.uptime()), scan: scanState }, sentinel };
},
'GET /api/auth': () => ({ firstRun: !Object.keys(db.users).length, signup: OPEN_SIGNUP }),
'POST /api/signup': ({ b, res }) => {
const first = !Object.keys(db.users).length;
if (!first && !OPEN_SIGNUP) bad(403, 'Sign-up is closed. Ask an admin for an account.');
startSession(res, createUser(b.name, b.password, first));
return { ok: true };
},
'POST /api/login': ({ b, res }) => {
const user = db.users[String(b.name || '').trim().toLowerCase()], pw = String(b.password || '');
if (!checkPw(pw, user ? user.hash : DUMMY_HASH) || !user) bad(403, 'Wrong user name or password.');
startSession(res, user);
return { ok: true };
},
'POST /api/logout': ({ req, res }) => {
delete db.sessions[sha(tokenOf(req))];
save();
res.setHeader('Set-Cookie', 'sid=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
return { ok: true };
},
'GET /api/me': ({ u }) => ({ name: u.name, admin: !!u.admin, settings: settingsOf(u), unread: unreadOf(u) }),
'GET /api/reading-schedule': () => publicSchedule(readingSchedule),
'GET /api/reading-access': ({ u, q }) => { mangaOf(q.get('m')); return readingAccess.status(u.name, true); },
'POST /api/reading-quiz/start': ({ u, b }) => {
try { return readingAccess.start(u.name); }
catch (e) { bad(409, e.message); }
},
'POST /api/reading-quiz/answer': ({ u, b }) => {
try { return readingAccess.answer(u.name, String(b.challenge || ''), String(b.answer || '')); }
catch (e) { bad(409, e.message); }
},
'POST /api/reading-quiz/abandon': ({ u, b }) => {
readingAccess.abandon(u.name, String(b.challenge || ''));
return { ok: true };
},
'POST /api/settings': ({ u, b }) => {
if (typeof b.webhook === 'string' && b.webhook.trim() && !/^https?:\/\/\S+$/i.test(b.webhook.trim())) bad(400, 'The webhook must be an http(s) URL.');
const s = (u.settings = u.settings || {});
if (['last', 'flow', 'page', 'book'].includes(b.startMode)) s.startMode = b.startMode;
if (['flow', 'page', 'book'].includes(b.lastMode)) s.lastMode = b.lastMode;
if (['original', 'inverted', 'sepia', 'dim', 'extra-dim'].includes(b.pageTone)) s.pageTone = b.pageTone;
for (const k of ['adapt', 'skipExtras', 'skipCovers', 'flowPageGap', 'readingClock', 'readingStory',
'discoveryEnabled', 'discoveryReadingActivity', 'discoveryMature']) if (typeof b[k] === 'boolean') s[k] = b[k];
if (b.zoom && typeof b.zoom === 'object') {
s.zoom = {};
for (const k of ['flow', 'page', 'book', 'strip']) if (+b.zoom[k] > 0) s.zoom[k] = Math.min(8, Math.max(0.1, +b.zoom[k]));
}
if (typeof b.webhook === 'string') s.webhook = b.webhook.trim().slice(0, 500);
save();
return { settings: settingsOf(u) };
},
'POST /api/password': ({ u, b }) => {
if (!checkPw(String(b.old || ''), u.hash)) bad(403, 'The current password is wrong.');
createUser(u.name, b.new, u.admin, true);
return { ok: true };
},
'GET /api/library': ({ u }) => {
const prog = db.progress[u.name] || {}, fol = db.follows[u.name] || {}, notes = (db.notes[u.name] || []).filter(n => !n.read);
const showStory = settingsOf(u).readingStory;
const mangas = lib.mangas.map(m => {
const p = prog[m.id] || {}, meta = m.meta || {}, release = firstReleaseOf(meta);
return {
id: m.id, title: m.title, cover: url(m.cover), direction: m.direction, chapters: m.chapters.length, updated: m.updated,
altTitles: Array.isArray(meta.altTitles) ? meta.altTitles : (meta.altTitles ? [String(meta.altTitles)] : []),
authors: Array.isArray(meta.authors) ? meta.authors : (meta.authors ? [String(meta.authors)] : []),
tags: Array.isArray(meta.tags) ? meta.tags : (meta.tags ? [String(meta.tags)] : []), status: meta.status || '', year: meta.year || '',
firstRelease: release.label, firstReleaseAt: release.time,
read: m.chapters.filter(c => p[c.id] && p[c.id].done).length,
lastRead: Object.values(p).reduce((t, x) => Math.max(t, x.t || 0), 0),
following: !!fol[m.id], unread: notes.filter(n => n.m === m.id).length,
reading: showStory ? mangaInsight(mangaReadingOf(u, m), m.chapters.map(ch => ch.id)) : null,
};
});
return {
scanning: !!scanning && !lib.mangas.length,
reading: showStory ? libraryInsight(mangas) : null,
mangas,
collections: collectionViews(catalog.state, mangas),
};
},
'GET /api/discovery': ({ u }) => {
const state = readDiscovery(), enabled = !!settingsOf(u).discoveryEnabled;
const feed = enabled ? state.feeds[u.name] || null : null;
return { enabled, settings: { discoveryEnabled: enabled,
discoveryReadingActivity: !!settingsOf(u).discoveryReadingActivity,
discoveryMature: !!settingsOf(u).discoveryMature },
generatedAt: feed?.generatedAt || null, revision: state.revision, lastRun: state.lastRun,
feedback: db.discoveryFeedback?.[u.name] || {},
items: (feed?.items || []).map(item => ({ ...item, candidate: state.candidates[item.candidateId] || null })).filter(item => item.candidate) };
},
'POST /api/discovery/feedback': ({ u, b }) => {
if (!settingsOf(u).discoveryEnabled) bad(403, 'Enable personalized discovery first');
const id = String(b.candidateId || ''), value = b.value === null ? null : String(b.value || '');
if (!readDiscovery().candidates[id]) bad(404, 'Discovery candidate not found');
if (value !== null && !['interested', 'maybe', 'dismissed', 'hidden'].includes(value)) bad(400, 'Unknown feedback');
const mine = (db.discoveryFeedback ||= {})[u.name] ||= {};
if (value === null) delete mine[id]; else mine[id] = value;
save();
return { candidateId: id, value };
},
'GET /api/admin/discovery': ({ u }) => {
admin(u);
const state = readDiscovery();
return { revision: state.revision, lastRun: state.lastRun, currentRun: discoveryRun,
lastError: discoveryError, optedInUsers: Object.values(db.users).filter(user => user.settings?.discoveryEnabled).length,
candidateCount: Object.keys(state.candidates).length };
},
'POST /api/admin/discovery/run': ({ u }) => { admin(u); return startDiscoveryRun(); },
'GET /api/greatest': ({ u }) => ({
title: greatestCatalog.title, updatedAt: greatestCatalog.updatedAt, periods: greatestCatalog.periods,
ratings: GREATEST_RATINGS,
entries: greatestCatalog.entries.map(entry => {
const owned = findGreatestOwned(entry, lib.mangas);
const counts = greatestCounts(entry.id, db.greatestRatings || {}, db.users);
return {
...entry,
cover: owned?.cover ? url(owned.cover) : entry.cover,
chapters: owned ? owned.chapters.length : entry.chapters,
status: owned?.meta?.status || entry.status,
library: owned ? { id: owned.id, chapters: owned.chapters.length } : null,
hosters: hosterLinks(entry, owned).links,
myRating: db.greatestRatings?.[u.name]?.[entry.id] || null,
votes: counts, score: greatestScore(counts),
};
}),
}),
'POST /api/greatest/rating': ({ u, b }) => {
const id = String(b.id || '');
if (!greatestById.has(id)) bad(404, 'Catalog entry not found');
const rating = b.rating === null || b.rating === '' ? null : String(b.rating || '');
if (rating !== null && !GREATEST_RATINGS.includes(rating)) bad(400, 'Unknown rating');
const all = (db.greatestRatings = db.greatestRatings || {});
const mine = (all[u.name] = all[u.name] || {});
if (rating === null) delete mine[id]; else mine[id] = rating;
save();
const votes = greatestCounts(id, all, db.users);
return { id, myRating: rating, votes, score: greatestScore(votes) };
},
'GET /api/greatest/sources': async ({ u, q }) => {
admin(u);
const entry = greatestById.get(String(q.get('id') || ''));
if (!entry) bad(404, 'Catalog entry not found');
const owned = findGreatestOwned(entry, lib.mangas);
try {
const state = await scraperRequest('/api/state');
return { available: true, ...hosterLinks(entry, owned, state.queue || []) };
} catch (error) {
return { available: false, error: error.message, ...hosterLinks(entry, owned) };
}
},
'GET /api/greatest/mangadex-search': async ({ u, q }) => {
admin(u);
const entry = greatestById.get(String(q.get('id') || ''));
if (!entry) bad(404, 'Catalog entry not found');
return { results: await searchGreatestMangaDex(entry) };
},
'GET /api/manga': ({ u, q }) => {
const m = mangaOf(q.get('m')), prog = progressOf(u, m), showStory = settingsOf(u).readingStory;
const records = mangaReadingOf(u, m);
return {
id: m.id, title: m.title, meta: m.meta, cover: url(m.cover), direction: m.direction,
collections: catalog.state.collections.filter(c => c.members.some(member => member.mangaId === m.id)).map(c => ({ id: c.id, title: c.title })),
relations: catalog.state.relations.filter(r => r.state === 'confirmed' && (r.from === m.id || r.to === m.id)).map(r => ({ ...r,
otherId: r.from === m.id ? r.to : r.from, otherTitle: lib.byId.get(r.from === m.id ? r.to : r.from)?.title || (r.from === m.id ? r.to : r.from) })),
following: !!(db.follows[u.name] && db.follows[u.name][m.id]),
books: m.books.map(b => ({ key: b.key, label: b.label, title: b.title, extra: b.extra })),
chapters: m.chapters.map(ch => ({
id: ch.id, book: ch.book, label: ch.label, title: ch.title, extra: ch.extra, progress: prog[ch.id] || null, versions: versionsOf(m, ch, u),
reading: showStory ? chapterInsight(records[ch.id]) : null,
})),
reading: showStory ? mangaInsight(records, m.chapters.map(ch => ch.id)) : null,
continue: continueOf(m, prog, settingsOf(u)),
};
},
'GET /api/reading/export': ({ u }) => {
const byManga = db.reading[u.name] || {};
const mangas = Object.entries(byManga).map(([id, records]) => {
const m = lib.byId.get(id), chapterIds = m ? m.chapters.map(ch => ch.id) : Object.keys(records || {});
const chapterMap = m ? m.chapterById : new Map();
return {
id, title: m ? m.title : id,
reading: mangaInsight(records, chapterIds),
chapters: chapterIds.map(chapterId => {
const chapter = chapterMap.get(chapterId), reading = chapterInsight(records && records[chapterId]);
return reading ? { id: chapterId, label: chapter ? chapter.label : chapterId, title: chapter ? chapter.title : '', reading } : null;
}).filter(Boolean),
};
}).filter(item => item.reading.timedChapters || item.reading.completedPasses);
return {
schema: 1,
exportedAt: new Date().toISOString(),
user: u.name,
reading: libraryInsight(mangas),
mangas,
};
},
'POST /api/reading/delete': ({ u, b }) => {
if (b.all) {
if (!checkPw(String(b.password || ''), u.hash)) bad(403, 'The current password is wrong.');
delete db.reading[u.name];
readingLeases.delete(u.name);
save();
return { ok: true, deleted: 'all' };
}
const m = mangaOf(b.m), all = db.reading[u.name];
if (all) delete all[m.id];
save();
return { ok: true, deleted: m.id };
},
'GET /api/chapter': ({ u, q }) => {
const m = mangaOf(q.get('m')), ch = chapterOf(m, q.get('c')), s = settingsOf(u), prog = progressOf(u, m);
if (!readingAccess.status(u.name).allowed) bad(423, 'Reading is outside the deployment schedule. Complete the five-question challenge to unlock it.');
const v = ch.versions.find(x => x.id === q.get('v')) || ranked(m, ch)[0].v;
// Opening a chapter clears its "new chapter" notification.
let seen = false;
for (const n of db.notes[u.name] || []) if (!n.read && n.m === m.id && n.c === ch.id) seen = n.read = true;
if (seen) save();
return {
manga: { id: m.id, title: m.title },
chapter: { id: ch.id, label: ch.label, title: ch.title, extra: ch.extra },
version: v.id, versions: versionsOf(m, ch, u), direction: v.direction,
pages: v.pages.map(p => ({ u: url(v.rel + '/' + p.f), w: p.w, h: p.h, t: p.t, r: p.r })),
prev: neighbour(m, ch, -1, s), next: neighbour(m, ch, 1, s),
progress: prog[ch.id] || null,
reading: s.readingClock ? publicSummary(readingOf(u, m, ch), v.id, eligiblePages(v, s)) : null,
// for the chapter picker: the whole manga across all books
books: m.books.map(b => ({ key: b.key, label: b.label, title: b.title })),
chapters: m.chapters.map(c => ({ id: c.id, book: c.book, label: c.label, title: c.title, extra: c.extra, done: !!(prog[c.id] && prog[c.id].done) })),
};
},
'POST /api/progress': ({ u, b }) => {
const m = mangaOf(b.m), ch = chapterOf(m, b.c);
const all = (db.progress[u.name] = db.progress[u.name] || {}), mp = (all[m.id] = all[m.id] || {}), old = mp[ch.id] || {};
mp[ch.id] = {
v: String(b.v || ''), p: Math.max(0, b.p | 0), n: Math.max(0, b.n | 0), t: Date.now(), done: !!(old.done || b.done),
shift: Array.isArray(b.shift) ? b.shift.filter(Number.isInteger).slice(0, 1000) : old.shift || [],
};
save();
return { ok: true };
},
// Separate endpoint: an older server on rollback cannot misread cumulative
// visit totals as legacy deltas. Old open tabs retain their original protocol.
'POST /api/reading/visit': context => routes['POST /api/reading/pulse']({ ...context, cumulative: true }),
'POST /api/reading/pulse': ({ u, b, cumulative = false }) => {
const settings = settingsOf(u);
if (!settings.readingClock) bad(403, 'The Active Reading Clock is disabled.');
const m = mangaOf(b.m), ch = chapterOf(m, b.c);
const v = ch.versions.find(x => x.id === b.v) || bad(404, 'Version not found');
const session = String(b.session || '');
const seq = Number(b.seq);
if (!/^[a-zA-Z0-9_-]{8,80}$/.test(session)) bad(400, 'Invalid reading session.');
if (!Number.isInteger(seq) || seq < 1 || seq > 1e9) bad(400, 'Invalid reading pulse sequence.');
const now = Date.now(), eligible = eligiblePages(v, settings), lease = readingLeases.get(u.name);
const previous = readingOf(u, m, ch)?.recentSessions?.find(item => item.id === session);
// A stale request must not reclaim a lease after a newer exit save. Final
// snapshots can also arrive before the initial claim: do not lose that visit.
if (cumulative && previous && seq <= previous.seq) return {
accepted: true, duplicate: true, leaseMs: 0,
reading: publicSummary(readingOf(u, m, ch), v.id, eligible),
};
const lateFinal = cumulative && b.final;
if (!lateFinal && lease && lease.session !== session && now - lease.at < READING_LEASE_MS) {
return {
accepted: false,
reason: 'other-tab',
retryAfterMs: READING_LEASE_MS - (now - lease.at),
reading: publicSummary(readingOf(u, m, ch), v.id, eligible),
};
}
if (!lateFinal) readingLeases.set(u.name, { session, at: now });
const byUser = (db.reading[u.name] = db.reading[u.name] || {});
const byManga = (byUser[m.id] = byUser[m.id] || {});
const record = (byManga[ch.id] = readingOf(u, m, ch) || emptyRecord());
const result = (cumulative ? applyVisit : applyPulse)(record, b, { version: v.id, pageCount: v.pages.length, eligible, now });
if (b.release && readingLeases.get(u.name)?.session === session) readingLeases.delete(u.name);
if (!result.duplicate) save();
return {
accepted: true,
duplicate: result.duplicate,
leaseMs: b.release ? 0 : READING_LEASE_MS,
reading: publicSummary(record, v.id, eligible),
};
},
'POST /api/unread': ({ u, b }) => {
const m = mangaOf(b.m), mp = db.progress[u.name] && db.progress[u.name][m.id];
if (mp) delete mp[chapterOf(m, b.c).id];
save();
return { ok: true };
},
'POST /api/vote': ({ u, b }) => {
const m = mangaOf(b.m), ch = chapterOf(m, b.c), v = ch.versions.find(x => x.id === b.v) || bad(404, 'Version not found');
const byChapter = (db.votes[m.id] = db.votes[m.id] || {}), byVersion = (byChapter[ch.id] = byChapter[ch.id] || {});
const votes = (byVersion[v.id] = byVersion[v.id] || {}), value = Math.sign(+b.value || 0);
if (value) votes[u.name] = value;
else delete votes[u.name];
save();
return { versions: versionsOf(m, ch, u) };
},
'POST /api/follow': ({ u, b }) => {
const m = mangaOf(b.m), f = (db.follows[u.name] = db.follows[u.name] || {});
if (b.on) f[m.id] = true;
else delete f[m.id];
save();
return { following: !!f[m.id] };
},
'GET /api/notifications': ({ u }) => ({ items: (db.notes[u.name] || []).slice().reverse() }),
'POST /api/notifications/read': ({ u, b }) => {
for (const n of db.notes[u.name] || []) if (b.all || n.id === b.id) n.read = true;
save();
return { unread: unreadOf(u) };
},
'GET /api/admin': ({ u }) => {
admin(u);
return { users: Object.values(db.users).map(x => ({ name: x.name, admin: !!x.admin, created: x.created })), scan: { ...scanState, running: !!scanning, library: LIB } };
},
'POST /api/admin/rescan': async ({ u, b }) => {
admin(u);
await rescan(!!b.full);
return { scan: { ...scanState, running: false, library: LIB } };
},
'POST /api/admin/user': ({ u, b }) => {
admin(u);
const name = String(b.name || '').trim().toLowerCase();
createUser(name, b.password, name === u.name || !!b.admin, true);
return { ok: true };
},
'POST /api/admin/user/delete': ({ u, b }) => {
admin(u);
const name = String(b.name || '');
if (name === u.name) bad(400, "You can't delete your own account.");
delete db.users[name]; delete db.progress[name]; delete db.reading[name]; delete db.follows[name]; delete db.notes[name];
if (db.discoveryFeedback) delete db.discoveryFeedback[name];
if (db.greatestRatings) delete db.greatestRatings[name];
for (const [k, s] of Object.entries(db.sessions)) if (s.u === name) delete db.sessions[k];
save();
return { ok: true };
},
'GET /api/admin/peering': ({ u }) => {
admin(u);
return { ...peerStore.publicState(), library: lib.mangas.map(m => ({ id: m.id, title: m.title, cover: url(m.cover),
chapters: m.chapters.length, year: Number(m.meta.year || m.meta.startDate?.year) || null,
status: String(m.meta.status || 'unknown'), description: String(m.meta.description || m.meta.synopsis || '').slice(0, 3000),
tags: Array.isArray(m.meta.tags) ? m.meta.tags.map(tag => typeof tag === 'string' ? tag : tag.name).filter(Boolean).slice(0, 20) : [] })) };
},
'POST /api/admin/peering': async ({ u, b }) => {
admin(u);
const command = String(b.command || '');
if (command === 'set-name') peerStore.setName(b.name);
else if (command === 'generate') peerStore.generateIdentity(b.name);
else if (command === 'invite') return { ...peerStore.publicState(), invitation: peerStore.createInvitation(b.url) };
else if (command === 'import') peerStore.importInvitation(b.invitation);
else if (command === 'probe') await peerClient.probe(peerStore, String(b.id || ''));
else if (command === 'set') peerStore.updatePeer(String(b.id || ''), b.patch || {});
else if (command === 'revoke-grant') peerStore.revokeGrant(String(b.id || ''));
else if (command === 'share') {
if (!lib.byId.has(String(b.mangaId || ''))) bad(404, 'Library manga not found');
peerStore.setShare(String(b.id || ''), String(b.mangaId), !!b.enabled);
}
else if (command === 'share-set') {
const ids = Array.isArray(b.mangaIds) ? [...new Set(b.mangaIds.map(String))] : null;
if (!ids) bad(400, 'Manga selection is required');
if (ids.some(id => !lib.byId.has(id))) bad(404, 'One or more library manga are no longer available');
peerStore.setShares(String(b.id || ''), ids);
}
else if (command === 'grant') return { ...peerStore.publicState(), grant: peerStore.issueGrant(String(b.id || ''), b.scopes) };
else if (command === 'accept-grant') peerStore.acceptGrant(String(b.id || ''), b.token);
else if (command === 'remove') peerStore.removePeer(String(b.id || ''));
else bad(400, 'Unknown peering command');
return { ...peerStore.publicState(), library: lib.mangas.map(m => ({ id: m.id, title: m.title })) };
},
'GET /api/peering': ({ u }) => { admin(u); return { peers: peerStore.publicState().peers
.map(p => ({ id: p.id, alias: p.alias, receive: !!p.receive, paired: !!p.paired, fingerprint: p.fingerprint,
url: p.url, grantStatus: p.grantStatus, grantScopes: p.grantScopes,
readerQueue: !!p.readerQueue, shares: p.shares, hasGrant: p.hasOutboundGrant })) }; },
'GET /api/peering/access': async ({ u, q }) => {
admin(u);
const id = String(q.get('peer') || ''), peer = peerForRoute(id);
if (!peer.paired) return { paired: false, grant: { status: 'none', scopes: [] } };
return peerRemote(() => peerClient.access(peerStore, id));
},
'GET /api/peering/catalog': async ({ u, q }) => {
admin(u);
const id = String(q.get('peer') || '');
const peer = peerForRoute(id);
if (!peer.paired) bad(403, 'Verify peer identity first');
const access = await peerRemote(() => peerClient.access(peerStore, id));
if (access.grant.status !== 'active' || !access.grant.scopes.includes('catalog')) bad(403, 'This peer has not granted active catalog access');
return { peer: { id, alias: peer.alias }, items: await peerRemote(() => peerClient.catalog(peerStore, id)) };
},
'GET /api/peering/manga': async ({ u, q }) => {
admin(u);
const id = String(q.get('peer') || ''), shareId = String(q.get('manga') || '');
const peer = peerForRoute(id);
if (!peer.paired) bad(403, 'Verify peer identity first');
return peerRemote(() => peerClient.manga(peerStore, id, shareId));
},
'POST /api/peering/queue': async ({ u, b }) => {
admin(u);
const id = String(b.peer || ''), shareId = String(b.manga || '');
const peer = peerForRoute(id);
if (!peer.paired || !peer.receive || (!u.admin && !peer.readerQueue)) bad(403, 'Peer downloads are disabled for this account');
const access = await peerRemote(() => peerClient.access(peerStore, id));
if (!access.grant.scopes.includes('download')) bad(403, 'This peer has granted catalog browsing only');
if (!/^m[0-9a-f]{24}$/.test(shareId)) bad(400, 'Invalid peer manga ID');
const remote = await peerRemote(() => peerClient.manga(peerStore, id, shareId));
const present = await peerRemote(() => peerClient.catalog(peerStore, id));
if (!present.some(entry => entry.id === shareId)) bad(404, 'Peer manga not available');
return scraperRequest('/api/queue', { url: `peer://${id}/${shareId}`, title: remote.title, workIdentity: remote.workIdentity,
scope: b.scope || { mode: 'missing' }, existingPolicy: b.existingPolicy || 'skip-any',
...(b.libraryMangaId ? { libraryMangaId: String(b.libraryMangaId) } : {}) });
},
'POST /api/peering/queue-bulk': async ({ u, b }) => {
admin(u);
const id = String(b.peer || ''), peer = peerForRoute(id);
const ids = Array.isArray(b.mangas) ? [...new Set(b.mangas.map(String))] : [];
if (!ids.length || ids.length > 50 || ids.some(value => !/^m[0-9a-f]{24}$/.test(value))) bad(400, 'Select 1–50 peer titles');
if (!peer.paired || !peer.receive) bad(403, 'Enable downloads from this peer first');
const access = await peerRemote(() => peerClient.access(peerStore, id));
if (access.grant.status !== 'active' || !access.grant.scopes.includes('download')) bad(403, 'This peer has not granted active download access');
const available = new Set(await peerRemote(() => peerClient.catalog(peerStore, id)).then(items => items.map(item => item.id)));
const results = [];
for (const mangaId of ids) {
if (!available.has(mangaId)) { results.push({ id: mangaId, status: 'unavailable' }); continue; }
try {
const remote = await peerRemote(() => peerClient.manga(peerStore, id, mangaId));
const result = await scraperRequest('/api/queue', { url: `peer://${id}/${mangaId}`, title: remote.title, workIdentity: remote.workIdentity,
scope: { mode: 'missing' }, existingPolicy: 'skip-any' });
results.push({ id: mangaId, title: remote.title, status: 'queued', result });
} catch (error) { results.push({ id: mangaId, status: 'failed', error: String(error.message || error).slice(0, 240) }); }
}
return { results, queued: results.filter(item => item.status === 'queued').length };
},
'GET /api/admin/scraper': async ({ u }) => {
admin(u);
try { return { available: true, ...(await scraperRequest('/api/state')) }; }
catch (error) { return { available: false, error: error.message, url: SCRAPER_URL }; }
},
'POST /api/admin/scraper': async ({ u, b }) => {
admin(u);
const command = String(b.command || '');
if (command === 'enqueue') return scraperRequest('/api/queue', {
url: b.url, title: b.title, metadataSearch: b.metadataSearch,
scope: b.scope, existingPolicy: b.existingPolicy, libraryMangaId: b.libraryMangaId,
inspect: b.inspect === true,
});
if (command === 'remove') return scraperRequest('/api/queue/remove', { id: b.id });
if (command === 'clear-completed') return scraperRequest('/api/queue/clear-completed', { source: b.source });
if (command === 'reorder') return scraperRequest('/api/queue/reorder', { ids: b.ids });
if (command === 'control') return scraperRequest('/api/control', { action: b.action, id: b.id });
if (command === 'settings') return scraperRequest('/api/settings', b.settings || {});
if (command === 'discover') return scraperRequest('/api/discovery/run', {});
if (command === 'auto-update') return scraperRequest('/api/library/auto-update', { id: b.id, enabled: !!b.enabled });
if (command === 'publish-legacy-ready') return scraperRequest('/api/legacy/publish-ready', {});
bad(400, 'Unknown scraper command');
},
};
// ---------- HTTP ----------
function sendJson(res, code, obj) {
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(obj));
}
async function readBody(req) {
let s = '';
for await (const c of req) { s += c; if (s.length > 1e6) bad(413, 'Request too large'); }
try { return s ? JSON.parse(s) : {}; } catch { bad(400, 'Invalid JSON'); }
}
async function handleApi(req, res, u, route, q) {
let b = {};
if (req.method === 'POST') {
// JSON-only + same-site: plain HTML forms from other sites can't reach the API.
if (!/^application\/json/.test(req.headers['content-type'] || '') || req.headers['sec-fetch-site'] === 'cross-site') bad(403, 'Bad request origin');
b = (await readBody(req)) || {};
}
sendJson(res, 200, await route({ req, res, u, b, q }));
}
function serveFile(req, res, root, rel, cacheControl, only) {
let file;
try { file = path.resolve(root, decodeURIComponent(rel)); } catch { return sendJson(res, 400, { error: 'Bad path' }); }
if (!file.startsWith(root + path.sep) || (only && !only.test(file))) return sendJson(res, 404, { error: 'Not found' });
fs.stat(file, (err, st) => {
if (err || !st.isFile()) return sendJson(res, 404, { error: 'Not found' });
const etag = `"${st.size.toString(36)}-${Math.round(st.mtimeMs).toString(36)}"`;
if (req.headers['if-none-match'] === etag) { res.writeHead(304, { ETag: etag, 'Cache-Control': cacheControl }); return res.end(); }
res.writeHead(200, { 'Content-Type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream', 'Content-Length': st.size, ETag: etag, 'Cache-Control': cacheControl });
if (req.method === 'HEAD') return res.end();
fs.createReadStream(file).on('error', () => res.destroy()).pipe(res);
});
}
http.createServer(async (req, res) => {
try {
const { pathname, searchParams } = new URL(req.url, 'http://localhost');
const u = userOf(req);
if (pathname === '/health' && req.method === 'GET') return sendJson(res, 200, { ...routes['GET /api/health'](), ok: true, ready: !scanState.error });
if (pathname === '/api/collection-cover' && req.method === 'GET') {
if (!u) bad(401, 'Please log in');
const c = catalog.state.collections.find(c => c.id === searchParams.get('id'));
if (!c?.customCover) bad(404, 'Cover not found');
return serveFile(req, res, path.join(DATA, 'collection-covers'), c.customCover, 'private, no-cache', IMG);
}
if (pathname === '/api/peering/cover' && req.method === 'GET') {
if (!u) bad(401, 'Please log in');
admin(u);
const peerId = String(searchParams.get('peer') || ''), mangaId = String(searchParams.get('manga') || ''), peer = peerForRoute(peerId);
if (!peer.paired) bad(403, 'Verify peer identity first');
const access = await peerRemote(() => peerClient.access(peerStore, peerId));
if (access.grant.status !== 'active' || !access.grant.scopes.includes('catalog')) bad(403, 'This peer has not granted active catalog access');
const image = await peerRemote(() => peerClient.cover(peerStore, peerId, mangaId));
res.writeHead(200, { 'Content-Type': image.mime, 'Content-Length': image.buffer.length,
...(image.etag ? { ETag: image.etag } : {}), 'Cache-Control': 'private, max-age=3600', 'X-Content-Type-Options': 'nosniff' });
return res.end(image.buffer);
}
if (pathname.startsWith('/api/')) {
const route = routes[req.method + ' ' + pathname];
if (!route) bad(404, 'Unknown endpoint');
if (!u && !PUBLIC.has(pathname)) bad(401, 'Please log in');
return await handleApi(req, res, u, route, searchParams);
}
if (req.method !== 'GET' && req.method !== 'HEAD') bad(405, 'Method not allowed');
if (pathname.startsWith('/lib/')) {
if (!u) bad(401, 'Please log in');
let imagePath;
try { imagePath = path.resolve(LIB, decodeURIComponent(pathname.slice(5))); } catch { bad(400, 'Bad image path'); }
const relative = path.relative(LIB, imagePath);
const parts = relative.split(path.sep);
if (parts.length >= 4 && !readingAccess.status(u.name).allowed)
bad(423, 'Reading is outside the deployment schedule.');
return serveFile(req, res, LIB, pathname.slice(5), readingSchedule && parts.length >= 4 ? 'private, no-store' : 'private, max-age=86400', IMG);
}
serveFile(req, res, PUB, pathname === '/' ? 'index.html' : pathname.slice(1), 'no-cache');
} catch (e) {
const expected = e instanceof HttpError || e instanceof CatalogError;
if (!expected) console.error(e);
if (!res.headersSent) sendJson(res, expected ? e.code : 500, { error: expected ? e.message : 'Internal error' });
}
}).listen(PORT, () => console.log(`MangaCircus on http://localhost:${PORT} (library: ${LIB}, data: ${DATA})`));
if (lib.mangas.length) refreshChanged();
else rescan();
if (SCAN_MINUTES > 0) setInterval(() => refreshChanged(), SCAN_MINUTES * 60000).unref();
// Persist the last reconciliation time. Restarting a healthy reader should not
// trigger another full tree walk; overdue reconciliations run within a minute.
if (FULL_SCAN_MINUTES > 0) setInterval(() => {
const now = Date.now();
if (now - Math.max(lastFullAt, lastFullAttemptAt) >= FULL_SCAN_MINUTES * 60000) {
lastFullAttemptAt = now;
rescan();
}
}, 60000).unref();
// A completed scraper publish increments a durable revision. Polling it here keeps the
// reader index fresh without exposing an unauthenticated rescan endpoint to the daemon.
let scraperRevision = null;
async function pollScraper() {
try {
const state = await scraperRequest('/api/state');
const revision = Number(state.publishedRevision || 0);
if (scraperRevision === null || revision > scraperRevision) {
const checked = await refreshChanged();
if (checked.error) throw new Error(checked.error);
// A publication without a visible directory change should not disappear
// because of coarse filesystem timestamps or a missed scan race.
if (scraperRevision !== null && revision > scraperRevision && !checked.changedMangas) {
const reconciled = await rescan();
if (reconciled.error) throw new Error(reconciled.error);
}
}
scraperRevision = revision;
} catch { /* the reader also works when the optional scraper daemon is offline */ }
}
pollScraper();
setInterval(pollScraper, 15000).unref();