Files
MaashkinoozheandClaude Opus 5.5 e0dacd81c6 Chapter dates: read date setting and tiered release dates (BL-0047)
- Chapter rows show when you first finished a chapter (setting: date + hour,
  date, off) from a new durable progress doneAt; older reads fall back to the
  reading clock's earliest remembered completion.
- chapter.json keeps added (arrival in this library, now kept on replace) plus
  x-dates.source (source upload, bulk imports flagged), x-dates.release
  (official, for a later metadata job) and x-dates.ecosystem (first entry into
  any Manga Circus library; carried over peering, earliest wins, clamped).
- Rows show one release date by tier: official > source upload > ecosystem >
  library, with all known dates in the tooltip (setting to hide).
- Fixes: WeebCentral dates came from the chapter page, which has none (every
  chapter got the download time); they now come from the full chapter list.
  MangaDex no longer writes a group's publishAt into added. Peering sends dates.
- tools/backfill-chapter-dates.js migrates an existing library (dry run default).
- Coverage dry run on the server: docs/chapter-dates-coverage-2026-10-24.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:01:33 +02:00

281 lines
17 KiB
JavaScript

#!/usr/bin/env node
'use strict';
const https = require('node:https');
const fs = require('node:fs');
const fsp = fs.promises;
const path = require('node:path');
const crypto = require('node:crypto');
const { once } = require('node:events');
const { PeeringState, fp, hash, requirePeer, grantStatus } = require('./state');
const { parseKey, parseSpecialFolder } = require('../chapter-identity');
const { peerDates } = require('../chapter-dates');
const { resolveWorkIdentity } = require('../scraper/lib/work-identity');
const DATA = path.resolve(process.env.PEER_DATA || path.join(process.env.DATA || 'data', 'peering'));
const LIBRARY = path.resolve(process.env.LIBRARY || 'library');
const HOST = process.env.PEER_HOST || '127.0.0.1';
const PORT = Number(process.env.PEER_PORT || 8093);
const peering = new PeeringState(DATA);
const identity = peering.identity();
if (!identity) throw new Error('Generate peering identity in the admin UI before starting the gateway');
const MAX_PAGE = 30 * 1024 * 1024;
const MAX_COVER = 5 * 1024 * 1024;
const MIME = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif', '.avif': 'image/avif', '.bmp': 'image/bmp' };
const usageFile = path.resolve(process.env.PEER_USAGE_FILE || path.join(DATA, 'gateway-usage.json'));
fs.mkdirSync(path.dirname(usageFile), { recursive: true, mode: 0o700 });
const usage = (() => { try { return JSON.parse(fs.readFileSync(usageFile, 'utf8')); } catch (e) { if (e.code === 'ENOENT') return { day: '', peers: {}, usedInvites: [] }; throw e; } })();
const active = new Map();
const uploadSlots = new Map();
const GLOBAL_STREAMS = Math.max(1, Math.min(32, Number(process.env.PEER_GLOBAL_STREAMS || 4)));
const indexCache = new Map();
const blobCache = new Map();
const sha = s => crypto.createHash('sha256').update(s).digest('hex');
const opaque = s => sha(s).slice(0, 32);
function saveUsage() { const tmp = `${usageFile}.tmp`; fs.writeFileSync(tmp, JSON.stringify(usage), { mode: 0o600 }); fs.renameSync(tmp, usageFile); }
function fail(code, message, retryAfter) { const e = new Error(message); e.code = code; e.retryAfter = retryAfter; throw e; }
function json(res, code, value, extra = {}) {
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...extra });
res.end(JSON.stringify(value));
}
function peerOf(req) {
const cert = req.socket.getPeerCertificate(true);
if (!cert?.raw || Date.parse(cert.valid_to) <= Date.now() || Date.parse(cert.valid_from) > Date.now()) fail(401, 'Valid client certificate required');
const fingerprint = fp(cert.raw);
const state = peering.load();
const peer = state.peers.find(p => p.fingerprint === fingerprint);
if (!peer || fp(peer.cert) !== fingerprint) fail(401, 'Unrecognized client certificate');
return { state, peer };
}
function grant(req, peer, scope = 'catalog') {
const status = grantStatus(peer);
if (!peer.paired || status.status !== 'active') fail(403, `Peering grant ${status.status}`);
const match = /^Bearer ([A-Za-z0-9_-]{40,60})$/.exec(String(req.headers.authorization || ''));
if (!match) fail(401, 'Grant required');
const got = Buffer.from(hash(match[1]), 'hex'), want = Buffer.from(peer.grantHash, 'hex');
if (got.length !== want.length || !crypto.timingSafeEqual(got, want)) fail(401, 'Invalid grant');
if (!status.scopes.includes(scope)) fail(403, `Grant does not allow ${scope}`);
}
function quota(peer, bytes = 0, catalog = false) {
const now = new Date(), day = now.toISOString().slice(0, 10), minute = Math.floor(Date.now() / 60000);
if (usage.day !== day) { usage.day = day; usage.peers = {}; saveUsage(); }
const entry = usage.peers[peer.id] ||= { bytes: 0, minute, catalogs: 0 };
if (entry.minute !== minute) { entry.minute = minute; entry.catalogs = 0; }
if (catalog && entry.catalogs >= peer.limits.catalogPerMinute) fail(429, 'Catalog request limit reached', 60);
if (entry.bytes + bytes > peer.limits.bytesPerDay) fail(429, 'Daily upload limit reached', 3600);
if (catalog) entry.catalogs++;
if (bytes) entry.bytes += bytes; // Reserve before streaming; an interrupted stream never resets quota.
saveUsage();
}
function safeDir(parent, name) {
if (typeof name !== 'string' || !name || name.startsWith('.') || name.includes('/') || name.includes('\\') || name === '..') return null;
const file = path.join(parent, name);
if (!file.startsWith(parent + path.sep)) return null;
try { if (!fs.lstatSync(file).isDirectory()) return null; } catch { return null; }
return file;
}
async function dirs(root) {
try { return (await fsp.readdir(root, { withFileTypes: true })).filter(e => e.isDirectory() && !e.isSymbolicLink() && !e.name.startsWith('.')).map(e => e.name).sort(); }
catch { return []; }
}
async function jsonFile(file) { try { return JSON.parse(await fsp.readFile(file, 'utf8')); } catch { return {}; } }
function publicMetadata(meta = {}) {
const tags = value => Array.isArray(value) ? value.map(item => String(typeof item === 'string' ? item : item?.name || '')).filter(Boolean).slice(0, 40) : [];
return { year: Number(meta.year || meta.startDate?.year) || null, status: String(meta.status || 'unknown').slice(0, 40),
description: String(meta.description || meta.synopsis || '').slice(0, 3000), tags: tags(meta.tags), genres: tags(meta.genres),
direction: ['rtl', 'ltr', 'ttb'].includes(meta.direction) ? meta.direction : null };
}
function coverFile(mangaDir) {
try {
const entry = fs.readdirSync(mangaDir, { withFileTypes: true }).find(item => item.isFile() && !item.isSymbolicLink()
&& /^cover\.(jpe?g|png|webp|gif|avif|bmp)$/i.test(item.name));
if (!entry) return null;
const file = path.join(mangaDir, entry.name), stat = fs.lstatSync(file);
return stat.isFile() && stat.size > 0 && stat.size <= MAX_COVER ? { file, size: stat.size, mime: MIME[path.extname(file).toLowerCase()], mtimeMs: stat.mtimeMs } : null;
} catch { return null; }
}
async function mangaIndex(peer, share) {
const key = `${peer.id}:${share.id}`;
const old = indexCache.get(key);
if (old && Date.now() - old.at < 30000) return old;
const mangaDir = safeDir(LIBRARY, share.mangaId);
if (!mangaDir) fail(404, 'Manga not found');
const meta = await jsonFile(path.join(mangaDir, 'manga.json'));
const chapters = [], byId = new Map();
for (const bookName of await dirs(mangaDir)) {
const book = parseKey(bookName);
if (!book) continue;
const bookDir = safeDir(mangaDir, bookName);
for (const chapterName of await dirs(bookDir)) {
const parsed = parseSpecialFolder(chapterName) || parseKey(chapterName);
if (!parsed) continue;
const chapterDir = safeDir(bookDir, chapterName);
// A chapter without final metadata may still be a manually written partial.
if (!fs.existsSync(path.join(chapterDir, 'chapter.json'))) continue;
const entries = await fsp.readdir(chapterDir, { withFileTypes: true });
const pages = entries.filter(e => e.isFile() && MIME[path.extname(e.name).toLowerCase()]).map(e => e.name).sort();
if (!pages.length) continue;
const id = `c${opaque(`${share.id}/${bookName}/${chapterName}`)}`;
const chapterMeta = await jsonFile(path.join(chapterDir, 'chapter.json'));
const dirStat = await fsp.stat(chapterDir);
const item = { id, number: parsed.key, volume: book.key, title: String(chapterMeta.title || parsed.title || '').slice(0, 200),
pageCount: pages.length, chapterDir, pages, dates: peerDates(chapterMeta, dirStat.mtimeMs) };
chapters.push(item); byId.set(id, item);
if (chapters.length > 10000) fail(413, 'Shared manga chapter limit exceeded');
}
}
// Every version of a chapter number offers the earliest ecosystem date known for it.
const earliest = new Map();
for (const item of chapters) {
const key = `${item.volume}/${item.number}`, at = item.dates.ecosystem;
if (at && (!earliest.has(key) || Date.parse(at) < Date.parse(earliest.get(key)))) earliest.set(key, at);
}
for (const item of chapters) {
const at = earliest.get(`${item.volume}/${item.number}`);
if (at) item.dates = { ...item.dates, ecosystem: at };
}
const result = { at: Date.now(), mangaDir, meta, chapters, byId };
indexCache.set(key, result);
return result;
}
function permittedShare(peer, id) {
const share = (peer.shares || []).find(s => s.id === id);
if (!share) fail(404, 'Manga not found');
return share;
}
async function chapterManifest(peer, share, chapterId) {
const index = await mangaIndex(peer, share), chapter = index.byId.get(chapterId);
if (!chapter) fail(404, 'Chapter not found');
const pages = [];
for (const name of chapter.pages) {
const file = path.join(chapter.chapterDir, name), stat = await fsp.lstat(file);
if (!stat.isFile() || stat.size < 1 || stat.size > MAX_PAGE) fail(413, 'Page size outside transfer limits');
const digest = crypto.createHash('sha256');
for await (const chunk of fs.createReadStream(file)) digest.update(chunk);
const id = `b${opaque(`${share.id}/${chapter.id}/${name}`)}`;
const etag = `"${id}-${stat.size}-${Math.round(stat.mtimeMs)}"`;
pages.push({ id, bytes: stat.size, sha256: digest.digest('hex'), mime: MIME[path.extname(name).toLowerCase()], etag });
blobCache.set(`${peer.id}:${id}`, { file, chapterDir: chapter.chapterDir, shareId: share.id, chapterId,
size: stat.size, mtimeMs: stat.mtimeMs, at: Date.now() });
if (pages.length > 500) fail(413, 'Chapter page limit exceeded');
}
return { id: chapter.id, number: chapter.number, volume: chapter.volume, title: chapter.title, dates: chapter.dates, pages };
}
async function streamBlob(req, res, peer, id) {
const item = blobCache.get(`${peer.id}:${id}`);
if (!item || Date.now() - item.at > 30 * 60 * 1000) fail(404, 'Refresh chapter manifest');
permittedShare(peer, item.shareId);
if (!fs.existsSync(path.join(item.chapterDir, 'chapter.json'))) fail(404, 'Chapter no longer complete');
const stat = await fsp.lstat(item.file).catch(() => null);
if (!stat?.isFile() || stat.size !== item.size || stat.mtimeMs !== item.mtimeMs) fail(412, 'Page changed; refresh manifest');
const etag = `"${id}-${stat.size}-${Math.round(stat.mtimeMs)}"`;
if (req.headers['if-match'] && req.headers['if-match'] !== etag) fail(412, 'Page changed; refresh manifest');
const match = /^bytes=(\d+)-$/.exec(String(req.headers.range || ''));
if (req.headers.range && !match) fail(416, 'Unsupported range');
const start = match ? Number(match[1]) : 0;
if (!Number.isSafeInteger(start) || start >= stat.size) fail(416, 'Range outside page');
const count = stat.size - start;
if ((active.get(peer.id) || 0) >= peer.limits.concurrent) fail(429, 'Too many transfers', 2);
if ([...active.values()].reduce((a, b) => a + b, 0) >= GLOBAL_STREAMS) fail(429, 'Server transfer limit reached', 2);
quota(peer, count);
active.set(peer.id, (active.get(peer.id) || 0) + 1);
try {
res.writeHead(start ? 206 : 200, {
'content-type': MIME[path.extname(item.file).toLowerCase()], 'content-length': count,
'accept-ranges': 'bytes', etag,
...(start ? { 'content-range': `bytes ${start}-${stat.size - 1}/${stat.size}` } : {}),
'cache-control': 'no-store',
});
const stream = fs.createReadStream(item.file, { start, highWaterMark: 64 * 1024 });
for await (const chunk of stream) {
if (req.destroyed || res.destroyed) break;
// Reserve each chunk on a shared per-peer timeline, including simultaneous streams.
const now = Date.now(), ready = Math.max(now, uploadSlots.get(peer.id) || now) + chunk.length * 1000 / peer.limits.bytesPerSecond;
uploadSlots.set(peer.id, ready);
if (ready > now) await new Promise(resolve => setTimeout(resolve, ready - now));
if (!res.write(chunk)) await once(res, 'drain');
}
res.end();
} finally { active.set(peer.id, Math.max(0, (active.get(peer.id) || 1) - 1)); }
}
async function streamCover(req, res, peer, shareId) {
const share = permittedShare(peer, shareId), mangaDir = safeDir(LIBRARY, share.mangaId);
if (!mangaDir) fail(404, 'Manga not found');
const cover = coverFile(mangaDir);
if (!cover) fail(404, 'Cover not available');
const etag = `\"${opaque(`${share.id}/${cover.size}/${cover.mtimeMs}`)}\"`;
if (req.headers['if-none-match'] === etag) { res.writeHead(304, { etag, 'cache-control': 'private, max-age=3600' }); return res.end(); }
quota(peer, cover.size);
res.writeHead(200, { 'content-type': cover.mime, 'content-length': cover.size, etag, 'cache-control': 'private, max-age=3600', 'x-content-type-options': 'nosniff' });
if (req.method === 'HEAD') return res.end();
await new Promise((resolve, reject) => fs.createReadStream(cover.file).on('error', reject).on('end', resolve).pipe(res));
}
const server = https.createServer({ key: identity.key, cert: identity.cert, requestCert: true, rejectUnauthorized: false, minVersion: 'TLSv1.3' }, async (req, res) => {
try {
const url = new URL(req.url, 'https://peer');
if (req.method !== 'GET') fail(405, 'GET only');
if (url.pathname === '/health') return json(res, 200, { ok: true });
const { state, peer } = peerOf(req);
if (url.pathname === '/peer/v1/hello') {
if (!peer.paired) {
const invitation = state.invitations.find(i => i.id === req.headers['x-invitation-id']);
const code = String(req.headers['x-invitation-code'] || '');
if (!invitation || Date.parse(invitation.expiresAt) <= Date.now() || invitation.codeHash !== hash(code)
|| usage.usedInvites.includes(invitation.id)) fail(403, 'Valid unused invitation required');
usage.usedInvites.push(invitation.id); usage.usedInvites = usage.usedInvites.slice(-1000); saveUsage();
}
return json(res, 200, { version: 1, id: identity.id, fingerprint: identity.fingerprint });
}
if (url.pathname === '/peer/v1/access') {
return json(res, 200, { version: 1, paired: !!peer.paired, name: peering.load().name,
certificateExpiresAt: new crypto.X509Certificate(identity.cert).validTo,
grant: grantStatus(peer) });
}
const requiredScope = url.pathname.startsWith('/peer/v1/blobs/') ? 'download' : 'catalog';
grant(req, peer, requiredScope);
if (url.pathname === '/peer/v1/catalog') {
quota(peer, 0, true);
const cursor = Number(url.searchParams.get('cursor') || 0);
if (!Number.isSafeInteger(cursor) || cursor < 0) fail(400, 'Invalid cursor');
const shares = peer.shares || [], items = [];
for (const share of shares.slice(cursor, cursor + 50)) {
const mangaDir = safeDir(LIBRARY, share.mangaId);
if (!mangaDir) continue;
const meta = await jsonFile(path.join(mangaDir, 'manga.json'));
items.push({ id: share.id, title: String(meta.title || share.mangaId).slice(0, 200),
...publicMetadata(meta), workIdentity: resolveWorkIdentity(meta, share.mangaId, identity.id),
coverAvailable: !!coverFile(mangaDir) });
}
return json(res, 200, { version: 1, items, nextCursor: cursor + 50 < shares.length ? String(cursor + 50) : null });
}
const coverMatch = /^\/peer\/v1\/covers\/(m[0-9a-f]{24})$/.exec(url.pathname);
if (coverMatch) return await streamCover(req, res, peer, coverMatch[1]);
const mangaMatch = /^\/peer\/v1\/manga\/(m[0-9a-f]{24})$/.exec(url.pathname);
if (mangaMatch) {
quota(peer, 0, true);
const share = permittedShare(peer, mangaMatch[1]), index = await mangaIndex(peer, share);
const cursor = Number(url.searchParams.get('cursor') || 0);
if (!Number.isSafeInteger(cursor) || cursor < 0) fail(400, 'Invalid cursor');
return json(res, 200, { id: share.id, title: String(index.meta.title || share.mangaId).slice(0, 200),
workIdentity: resolveWorkIdentity(index.meta, share.mangaId, identity.id),
...publicMetadata(index.meta), coverAvailable: !!coverFile(index.mangaDir),
chapters: index.chapters.slice(cursor, cursor + 100).map(({ id, number, volume, title, pageCount, dates }) => ({ id, number, volume, title, pageCount, dates })),
nextCursor: cursor + 100 < index.chapters.length ? String(cursor + 100) : null });
}
const chapterMatch = /^\/peer\/v1\/manga\/(m[0-9a-f]{24})\/chapters\/(c[0-9a-f]{32})$/.exec(url.pathname);
if (chapterMatch) {
quota(peer, 0, true);
return json(res, 200, await chapterManifest(peer, permittedShare(peer, chapterMatch[1]), chapterMatch[2]));
}
const blobMatch = /^\/peer\/v1\/blobs\/(b[0-9a-f]{32})$/.exec(url.pathname);
if (blobMatch) return await streamBlob(req, res, peer, blobMatch[1]);
fail(404, 'Not found');
} catch (error) {
if (!res.headersSent) json(res, Number(error.code) || 500, { error: Number(error.code) ? error.message : 'Internal error' }, error.retryAfter ? { 'retry-after': String(error.retryAfter) } : {});
else res.destroy();
if (!Number(error.code)) console.error('[peer-gateway]', error.message);
}
});
server.listen(PORT, HOST, () => console.log(`[peer-gateway] listening on ${HOST}:${PORT}`));