'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 * SCAN_CONCURRENCY=8 chapter folders / images probed at once by an uncached scan (library.js) * 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) * BACKUP=/backup folder for built-in DATA backups (unset = backups off) */ 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 { GreatestCovers } = require('./greatest/covers'); const { secret } = require('./runtime/config'); const { PeeringState, requirePeer } = require('./peering/state'); const peerClient = require('./peering/client'); const { DiscoveryStore } = require('./discovery/store'); const { generateAsync: generateDiscovery, mergeUserFeed } = require('./discovery/engine'); const { DiscoveryCache } = require('./discovery/cache'); const { createClient: createAniListClient } = require('./discovery/anilist'); const { anchorsFor } = require('./discovery/anchors'); const { shouldRunNightly, Harvester } = require('./discovery/harvest'); const { applyPulse, applyVisit, chapterInsight, earliestCompletion, eligiblePages, emptyRecord, libraryInsight, mangaInsight, publicSummary, } = require('./reading-analytics'); const { chapterDates } = require('./chapter-dates'); const { parseSchedule, publicSchedule, loadQuestionBank, createReadingAccess } = require('./reading-access'); const { BackupService, BackupError } = require('./backup'); 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 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'); const DISCOVERY_HARVEST_CACHE = path.join(DATA, 'discovery-cache'); const HARVEST_TIME = /^([01]\d|2[0-3]):[0-5]\d$/; const DISCOVERY_HARVEST_AT = HARVEST_TIME.test(process.env.DISCOVERY_HARVEST_AT || '') ? process.env.DISCOVERY_HARVEST_AT : '03:30'; 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 greatestCovers = new GreatestCovers(path.join(DATA, 'greatest-covers')); // The versioned URL lets browsers keep a cover for a year; a new catalog URL changes `v`. const greatestCoverUrl = entry => entry.cover ? `api/greatest-cover?id=${encodeURIComponent(entry.id)}&v=${greatestCovers.version(entry.cover)}` : null; 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 ||= {}; // The reader owns backups: it alone mounts all of DATA and holds db.json in memory. const backup = new BackupService({ dataRoot: DATA, backupRoot: process.env.BACKUP || '', snapshotDb: () => JSON.stringify(db), scraper: { state: () => scraperRequest('/api/state'), control: action => scraperRequest('/api/control', { action }) } }).start(); 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 }); // `extra` fields are sent next to `error`, e.g. `nextAllowedAt` on a 429. class HttpError extends Error { constructor(code, msg, extra = {}) { super(msg); this.code = code; this.extra = extra; } } function bad(code, msg, extra) { throw new HttpError(code, msg, extra); } 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, continueRow: true, readDate: 'hour', releaseDate: 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); } } // Scoring is local: it reads the harvest cache and never calls AniList. With `onlyUser` only that // feed is rebuilt and the other users' feeds are carried over into the same publish. async function rescoreNow(onlyUser) { const startedAt = new Date().toISOString(); 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 = await generateDiscovery({ db, library: lib.mangas, greatest: greatestCatalog, cacheDir: DISCOVERY_CACHE, harvestCache, queuedTitles, onlyUser }); // Read the prior store only now, right before the synchronous publish, so no other publish slips between. const value = onlyUser === undefined ? result : { ...result, ...mergeUserFeed(discoveryStore.load(), result, onlyUser) }; return { published: discoveryStore.publish({ ...value, startedAt }), queueAvailable }; } // One rescore at a time: an admin run and a harvest follow-up must not publish over each other. let rescoring = Promise.resolve(); function rescore({ onlyUser } = {}) { const run = rescoring.then(() => rescoreNow(onlyUser)); rescoring = run.catch(() => {}); return run; } 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 { const { published, queueAvailable } = await rescore(); 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; } // ---------- discovery harvest (AniList seeds, BL-0051) ---------- const harvestCache = new DiscoveryCache(DISCOVERY_HARVEST_CACHE); const discoverySettings = state => ({ harvestEnabled: state.settings?.harvestEnabled ?? true, harvestAt: state.settings?.harvestAt ?? DISCOVERY_HARVEST_AT }); const localDate = date => [date.getFullYear(), String(date.getMonth() + 1).padStart(2, '0'), String(date.getDate()).padStart(2, '0')].join('-'); function nextNightlyAt(state, now = new Date()) { const { harvestEnabled, harvestAt } = discoverySettings(state); if (!harvestEnabled) return null; const [hours, minutes] = harvestAt.split(':').map(Number); const slot = new Date(now); slot.setHours(hours, minutes, 0, 0); if (state.nightly?.lastDate === localDate(now)) slot.setDate(slot.getDate() + 1); else if (slot < now) return now.toISOString(); // due: the next minute's check starts it return slot.toISOString(); } function harvestTargets(kind, userName) { const users = kind === 'user' ? [db.users[userName]].filter(Boolean) : Object.values(db.users); return users.filter(user => settingsOf(user).discoveryEnabled).map(user => ({ user, anchors: anchorsFor({ user, db, library: lib.mangas, greatest: greatestCatalog }), mature: !!settingsOf(user).discoveryMature })); } // Queued jobs live in memory only; a run cut off by a restart is recorded as interrupted. try { const { harvest } = discoveryStore.load(); if (harvest?.status === 'running') discoveryStore.patch({ harvest: { ...harvest, status: 'interrupted', finishedAt: new Date().toISOString() } }); } catch (error) { console.error('[discovery]', error.message); } const harvester = new Harvester({ cache: harvestCache, client: createAniListClient(), store: discoveryStore, targetsFor: harvestTargets, onDone: async job => { // A failed follow-up rescore must reach the admin panel (lastError), not only the log. try { await rescore(job.kind === 'user' ? { onlyUser: job.user } : {}); discoveryError = null; // a later success must not leave an old failure on the admin panel } catch (error) { discoveryError = `Re-scoring after harvest ${job.id} failed: ${String(error.message || error)}`.slice(0, 300); throw error; } } }); function requestHarvest(args) { readDiscovery(); // A corrupt store fails closed before any AniList call. try { return harvester.request(args); } catch (error) { if (!error.status) throw error; const extra = {}; for (const key of ['nextAllowedAt', 'blockedReason']) if (error[key]) extra[key] = error[key]; bad(error.status, error.message, extra); } } function checkNightly() { let state; try { state = discoveryStore.load(); } catch { return; } const now = new Date(), { harvestEnabled, harvestAt } = discoverySettings(state); if (!shouldRunNightly({ now, harvestAt, lastNightlyDate: state.nightly?.lastDate, enabled: harvestEnabled })) return; if (harvester.current()) return; // The date is written only once the run was accepted; a 409 (refresh in flight) retries next minute. try { harvester.request({ kind: 'nightly' }); } catch (error) { if (error.status !== 409) console.error('[discovery] nightly harvest:', error.message); return; } try { discoveryStore.patch({ nightly: { lastDate: localDate(now) } }); } catch (error) { console.error('[discovery]', error.message); } } 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 }; } // When a done chapter was completed: the progress record, or for reads saved before doneAt // existed, the earliest completion the reading clock still remembers. function readAtOf(progress, record) { if (!progress || !progress.done) return null; if (progress.doneAt) return { at: progress.doneAt, approx: false }; return earliestCompletion(record); } 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); return { reader: { ok: !scanState.error, uptime: Math.round(process.uptime()), scan: scanState }, backup: backup.publicState() }; }, 'GET /api/admin/backup': ({ u }) => { admin(u); return backup.publicState(); }, 'POST /api/admin/backup': ({ u, b }) => { admin(u); try { if (b.command === 'settings') return backup.updateSettings(b.settings); if (b.command !== 'run') bad(400, 'Unknown backup command'); if (!backup.backupRoot) bad(409, 'Backups are off: set BACKUP to a mounted folder'); if (backup.running) bad(409, 'A backup is already running'); backup.run('manual').catch(() => {}); // the result lands in last/history return backup.publicState(); } catch (e) { if (e instanceof BackupError) bad(e.code, e.message); throw e; } }, '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; if (['off', 'date', 'hour'].includes(b.readDate)) s.readDate = b.readDate; for (const k of ['adapt', 'skipExtras', 'skipCovers', 'flowPageGap', 'readingClock', 'readingStory', 'releaseDate', 'continueRow', '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, refresh: harvester.state(u.name), 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 }; }, 'POST /api/discovery/refresh': ({ u }) => { if (!settingsOf(u).discoveryEnabled) bad(403, 'Enable personalized discovery first'); return requestHarvest({ kind: 'user', user: u.name }); }, '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, harvest: state.harvest, currentHarvest: harvester.current(), cache: harvestCache.stats(), settings: discoverySettings(state), nextNightlyAt: nextNightlyAt(state) }; }, 'POST /api/admin/discovery/run': ({ u }) => { admin(u); return startDiscoveryRun(); }, 'POST /api/admin/discovery/harvest': ({ u }) => { admin(u); return requestHarvest({ kind: 'admin' }); }, 'POST /api/admin/discovery/settings': ({ u, b }) => { admin(u); const keys = b && typeof b === 'object' && !Array.isArray(b) ? Object.keys(b).sort().join() : ''; if (keys !== 'harvestAt,harvestEnabled' || typeof b.harvestEnabled !== 'boolean' || typeof b.harvestAt !== 'string' || !HARVEST_TIME.test(b.harvestAt)) bad(400, 'Send exactly { harvestEnabled: true|false, harvestAt: "HH:MM" }'); readDiscovery(); const state = discoveryStore.patch({ settings: { harvestEnabled: b.harvestEnabled, harvestAt: b.harvestAt } }); return { settings: discoverySettings(state), nextNightlyAt: nextNightlyAt(state) }; }, '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) : greatestCoverUrl(entry), 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), s = settingsOf(u), showStory = s.readingStory; const records = mangaReadingOf(u, m); return { chapterDates: { read: s.readDate, release: s.releaseDate }, 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, readAt: s.readDate === 'off' ? null : readAtOf(prog[ch.id], records[ch.id]), dates: s.releaseDate ? chapterDates(ch.versions) : 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] || {}; const now = Date.now(), done = !!(old.done || b.done); // doneAt: when the chapter was first completed; kept until it is marked unread. const doneAt = old.done ? old.doneAt : done ? now : undefined; mp[ch.id] = { v: String(b.v || ''), p: Math.max(0, b.p | 0), n: Math.max(0, b.n | 0), t: now, done, ...(doneAt ? { doneAt } : {}), 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 }; }, // Grant or revoke admin rights without touching the password. Admins only (checked here, not // just hidden in the UI); nobody can revoke their own rights, so at least one admin always remains. 'POST /api/admin/user/role': ({ u, b }) => { admin(u); const name = String(b.name || '').trim().toLowerCase(); const target = db.users[name] || bad(404, 'No such user.'); if (typeof b.admin !== 'boolean') bad(400, 'admin must be true or false.'); if (name === u.name && !b.admin) bad(400, "You can't remove your own admin rights."); if (!!target.admin !== b.admin) { target.admin = b.admin; save(); console.log(`[users] ${u.name} ${b.admin ? 'made' : 'removed'} ${name} ${b.admin ? 'an admin' : 'as admin'}`); } return { ok: true, name, admin: target.admin }; }, '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/greatest-cover' && req.method === 'GET') { if (!u) bad(401, 'Please log in'); const entry = greatestById.get(searchParams.get('id')); if (!entry?.cover) bad(404, 'Cover not found'); const file = greatestCovers.file(entry.cover); if (file) return serveFile(req, res, greatestCovers.dir, path.basename(file), 'private, max-age=31536000, immutable', IMG); // Not downloaded yet: the browser fetches the catalog's own URL this once. res.writeHead(302, { Location: entry.cover, 'Cache-Control': 'no-store' }); return res.end(); } 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, expected ? { error: e.message, ...e.extra } : { error: '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(); // Fetch missing Greatest covers shortly after start and retry failures once a day; // covers already on disk cost nothing. async function warmGreatestCovers() { try { const result = await greatestCovers.warm(greatestCatalog.entries.map(entry => entry.cover)); if (result.fetched || result.failed || result.stopped) console.log('[greatest] covers', JSON.stringify(result)); } catch (error) { console.error('[greatest] covers', error.message); } } if (process.env.GREATEST_COVER_WARM !== '0') { setTimeout(warmGreatestCovers, 10000).unref(); setInterval(warmGreatestCovers, 86400000).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(); // Nightly discovery harvest; the delayed first check also covers a slot missed while the reader was down. setInterval(checkNightly, 60000).unref(); setTimeout(checkNightly, 30000).unref();