Files
MangaCircus/server.js
T

710 lines
39 KiB
JavaScript

'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 { secret } = require('./runtime/config');
const {
applyPulse, applyVisit, chapterInsight, eligiblePages, emptyRecord, libraryInsight, mangaInsight, publicSummary,
} = require('./reading-analytics');
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 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: {}, follows: {}, notes: {}, votes: {}, greatestRatings: {}, 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); });
class HttpError extends Error { constructor(code, msg) { super(msg); this.code = code; } }
function bad(code, msg) { throw new HttpError(code, msg); }
// ---------- 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, readingClock: false, readingStory: true, 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;
}
// 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) }),
'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;
for (const k of ['adapt', 'skipExtras', 'skipCovers', 'flowPageGap', 'readingClock', 'readingStory']) 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/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,
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/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);
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.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/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.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');
return serveFile(req, res, LIB, pathname.slice(5), '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();