Files
MangaCircus/peering/gateway.js
T

237 lines
14 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 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 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 {}; } }
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 item = { id, number: parsed.key, volume: book.key, title: String(chapterMeta.title || parsed.title || '').slice(0, 200),
pageCount: pages.length, chapterDir, pages };
chapters.push(item); byId.set(id, item);
if (chapters.length > 10000) fail(413, 'Shared manga chapter limit exceeded');
}
}
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, 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)); }
}
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') {
if (!peer.paired) fail(403, 'Verify peer identity first');
return json(res, 200, { version: 1, paired: true, 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),
year: Number(meta.year || meta.startDate?.year) || null, status: String(meta.status || 'unknown').slice(0, 40) });
}
return json(res, 200, { version: 1, items, nextCursor: cursor + 50 < shares.length ? String(cursor + 50) : null });
}
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),
direction: ['rtl', 'ltr', 'ttb'].includes(index.meta.direction) ? index.meta.direction : 'rtl',
chapters: index.chapters.slice(cursor, cursor + 100).map(({ id, number, volume, title, pageCount }) => ({ id, number, volume, title, pageCount })),
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}`));