'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 automatic library rescans, 0 = off * COOKIE_SECURE=1 mark the session cookie Secure (when served over HTTPS) * SENPAI_URL=http://senpai-api:8780 optional story companion ("Who's here"); off when unset * SENPAI_TOKEN=... service token for SENPAI_URL */ const http = require('http'), https = require('https'), fs = require('fs'), path = require('path'), crypto = require('crypto'); const { scanLibrary } = require('./library'); 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 SECURE_COOKIE = process.env.COOKIE_SECURE === '1'; const SENPAI = process.env.SENPAI_URL ? process.env.SENPAI_URL.replace(/\/?$/, '/') : ''; const SENPAI_TOKEN = process.env.SENPAI_TOKEN || ''; 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 DB_FILE = path.join(DATA, 'db.json'), CACHE_FILE = path.join(DATA, 'scan-cache.json'); const db = { users: {}, sessions: {}, progress: {}, follows: {}, notes: {}, votes: {}, 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, 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, scanState = {}, scanCache = {}; try { scanCache = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')); } catch { /* first run */ } function rescan(full) { scanning = scanning || (async () => { const t0 = Date.now(); try { const { index, cache } = await scanLibrary(LIB, full ? {} : scanCache); announce(index); lib = index; scanCache = cache; writeAtomic(CACHE_FILE, JSON.stringify(cache)); let chapters = 0, pages = 0; for (const m of index.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, mangas: index.mangas.length, chapters, pages, error: null }; console.log(`[scan] ${index.mangas.length} manga, ${chapters} chapters, ${pages} pages in ${scanState.ms} ms`); } catch (e) { scanState = { ...scanState, at: Date.now(), error: e.message }; console.error('[scan] failed:', e.message); } finally { scanning = null; } })(); return scanning; } // 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 unreadOf = u => (db.notes[u.name] || []).filter(n => !n.read).length; const admin = u => u.admin || bad(403, 'Admins only'); // 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, })); } 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/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), senpai: !!SENPAI }), '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']) 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); return { scanning: !!scanning && !lib.mangas.length, mangas: lib.mangas.map(m => { const p = prog[m.id] || {}; return { id: m.id, title: m.title, cover: url(m.cover), direction: m.direction, chapters: m.chapters.length, updated: m.updated, 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, }; }), }; }, 'GET /api/manga': ({ u, q }) => { const m = mangaOf(q.get('m')), prog = progressOf(u, m); return { id: m.id, title: m.title, meta: m.meta, cover: url(m.cover), direction: m.direction, 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), })), continue: continueOf(m, prog, settingsOf(u)), }; }, '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, // 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 v = String(b.v || ''), p = Math.max(0, b.p | 0); mp[ch.id] = { v, p, 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 || [], // furthest page read in this version (paging back to re-read does not lower the reading horizon) top: Math.max(p, old.v === v ? old.top | 0 || old.p | 0 : 0), }; save(); return { ok: true }; }, '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.follows[name]; delete db.notes[name]; for (const [k, s] of Object.entries(db.sessions)) if (s.u === name) delete db.sessions[k]; save(); return { ok: true }; }, }; // ---------- Senpai proxy (optional) ---------- // GET /api/senpai/v1/... is forwarded to SENPAI_URL with the service token, the user name and the user's reading // horizon in this manga: the furthest chapter (in reading order) with saved progress, its version and page. // Senpai never sees Circus sessions, and the browser can't raise its own horizon. const SENPAI_PATHS = /^v1\/(locate|xray|characters\/[A-Za-z0-9-]{1,80}(\/timeline)?|portraits\/[A-Za-z0-9-]{1,80})$/; function horizonOf(u, m) { const prog = progressOf(u, m); let last = null; for (const ch of m.chapters) if (prog[ch.id]) last = { ch, p: prog[ch.id] }; if (!last) return ''; const enc = encodeURIComponent; const page = Math.max(last.p.p | 0, last.p.top | 0); return `m=${enc(m.id)};c=${enc(last.ch.id)};v=${enc(last.p.v || '')};p=${page};done=${last.p.done ? 1 : 0}`; } function senpaiProxy(req, res, u, rest, q) { if (!SENPAI) bad(404, 'Senpai is not enabled'); if (req.method !== 'GET') bad(405, 'Method not allowed'); if (!SENPAI_PATHS.test(rest)) bad(404, 'Unknown endpoint'); const m = mangaOf(q.get('m')); const target = new URL(rest + '?' + q.toString(), SENPAI); const headers = { Authorization: 'Bearer ' + SENPAI_TOKEN, 'X-Senpai-User': u.name, 'X-Senpai-Horizon': horizonOf(u, m), Accept: req.headers.accept || '*/*' }; const up = (target.protocol === 'https:' ? https : http).request(target, { method: 'GET', headers, timeout: 15000 }, r => { const out = { 'Content-Type': r.headers['content-type'] || 'application/octet-stream', 'Cache-Control': r.headers['cache-control'] || 'no-store' }; if (r.headers['content-length']) out['Content-Length'] = r.headers['content-length']; res.writeHead(r.statusCode, out); r.pipe(res); }); up.on('timeout', () => up.destroy(new Error('timeout'))); up.on('error', e => { console.warn(`[senpai] ${e.message}`); if (!res.headersSent) sendJson(res, 502, { error: 'Senpai is not reachable' }); else res.destroy(); }); up.end(); } // ---------- 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.startsWith('/api/senpai/')) { if (!u) bad(401, 'Please log in'); return senpaiProxy(req, res, u, pathname.slice('/api/senpai/'.length), searchParams); } 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) { if (!(e instanceof HttpError)) console.error(e); if (!res.headersSent) sendJson(res, e.code || 500, { error: e instanceof HttpError ? e.message : 'Internal error' }); } }).listen(PORT, () => console.log(`MangaCircus on http://localhost:${PORT} (library: ${LIB}, data: ${DATA})${SENPAI ? ' senpai: ' + SENPAI : ''}`)); rescan(); if (SCAN_MINUTES > 0) setInterval(() => rescan(), SCAN_MINUTES * 60000).unref();