Redesign admin peering settings and catalogs

This commit is contained in:
Codex committed 2026-10-11 01:06:03 +02:00
1 parent 492477f86a
commit da40d73f02
16 files changed
+1037 -85

No files matched your search

+19 -4
View File
@@ -39,7 +39,7 @@ function connection(store, id, needGrant = true, requireReceive = false) {
return { peer, agent, token };
}
function request(store, id, endpoint, options = {}) {
const { peer, agent, token } = connection(store, id, !options.pairing && !options.access, !!options.binary);
const { peer, agent, token } = connection(store, id, !options.pairing && !options.access, !!options.requireReceive);
const base = new URL(peer.url), target = new URL(endpoint, base);
if (target.origin !== base.origin || !target.pathname.startsWith('/peer/v1/')) throw new Error('Invalid peer endpoint');
return new Promise((resolve, reject) => {
@@ -82,6 +82,20 @@ async function access(store, id) {
|| !Array.isArray(result.grant?.scopes)) throw new Error('Invalid peer access status');
return result;
}
async function cover(store, id, shareId) {
if (!/^m[0-9a-f]{24}$/.test(shareId)) throw new Error('Invalid peer manga ID');
const res = await request(store, id, `/peer/v1/covers/${shareId}`, { binary: true });
if (res.statusCode !== 200) {
const body = await readResponse(res, 4096).catch(() => Buffer.alloc(0));
let message; try { message = JSON.parse(body.toString()).error; } catch { message = 'Cover unavailable'; }
throw remoteError(res.statusCode, message, res.headers['retry-after']);
}
const mime = String(res.headers['content-type'] || '').split(';')[0];
if (!/^image\/(?:jpeg|png|webp|gif|avif|bmp)$/.test(mime)) throw new Error('Peer returned an unsupported cover type');
const buffer = await readResponse(res, 5 * 1024 * 1024);
if (!buffer.length) throw new Error('Peer returned an empty cover');
return { buffer, mime, etag: res.headers.etag || null };
}
async function catalog(store, id) {
const items = []; let cursor = null;
for (let page = 0; page < 100; page++) {
@@ -99,7 +113,7 @@ async function manga(store, id, shareId) {
const result = await jsonRequest(store, id, `/peer/v1/manga/${shareId}${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''}`);
if (result.id !== shareId || !Array.isArray(result.chapters) || result.chapters.length > 100) throw new Error('Invalid peer manga manifest');
first ||= result; chapters.push(...result.chapters); cursor = result.nextCursor;
if (!cursor) return { id: shareId, title: first.title, direction: first.direction, chapters };
if (!cursor) return { id: shareId, title: first.title, ...first, chapters };
}
throw new Error('Peer manga exceeds chapter limit');
}
@@ -123,7 +137,8 @@ async function downloadPage(store, id, page, file, shouldStop) {
if (stat && stat.size > page.bytes) { await fsp.rm(file, { force: true }); stat = null; }
const start = stat?.size || 0;
if (shouldStop?.()) throw new Error('Peer transfer paused');
const res = await request(store, id, `/peer/v1/blobs/${page.id}`, { binary: true, headers: start ? { range: `bytes=${start}-`, ...(page.etag ? { 'if-match': page.etag } : {}) } : {} });
const res = await request(store, id, `/peer/v1/blobs/${page.id}`, { binary: true, requireReceive: true,
headers: start ? { range: `bytes=${start}-`, ...(page.etag ? { 'if-match': page.etag } : {}) } : {} });
if (res.statusCode !== (start ? 206 : 200)) {
const body = await readResponse(res, 4096).catch(() => Buffer.alloc(0));
let message; try { message = JSON.parse(body.toString()).error; } catch { message = 'Transfer failed'; }
@@ -143,4 +158,4 @@ async function downloadPage(store, id, page, file, shouldStop) {
return { reused: false };
}
module.exports = { connection, jsonRequest, probe, access, catalog, manga, chapter, downloadPage };
module.exports = { connection, jsonRequest, probe, access, catalog, manga, cover, chapter, downloadPage };
+33 -4
View File
@@ -18,6 +18,7 @@ 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 });
@@ -76,6 +77,21 @@ async function dirs(root) {
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);
@@ -169,6 +185,18 @@ async function streamBlob(req, res, peer, id) {
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 {
@@ -187,8 +215,7 @@ const server = https.createServer({ key: identity.key, cert: identity.cert, requ
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,
return json(res, 200, { version: 1, paired: !!peer.paired, name: peering.load().name,
certificateExpiresAt: new crypto.X509Certificate(identity.cert).validTo,
grant: grantStatus(peer) });
}
@@ -204,10 +231,12 @@ const server = https.createServer({ key: identity.key, cert: identity.cert, requ
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) });
...publicMetadata(meta), 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);
@@ -215,7 +244,7 @@ const server = https.createServer({ key: identity.key, cert: identity.cert, requ
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',
...publicMetadata(index.meta), coverAvailable: !!coverFile(index.mangaDir),
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 });
}
+14 -8
View File
@@ -54,9 +54,9 @@ function serverName(value) {
return name;
}
function normalizeScopes(value) {
const scopes = [...new Set((Array.isArray(value) ? value : []).map(String))].filter(scope => GRANT_SCOPES.has(scope));
if (scopes.includes('download') && !scopes.includes('catalog')) scopes.push('catalog');
return scopes;
const scopes = new Set((Array.isArray(value) ? value : []).map(String).filter(scope => GRANT_SCOPES.has(scope)));
if (scopes.has('download')) scopes.add('catalog');
return [...GRANT_SCOPES].filter(scope => scopes.has(scope));
}
function scopesFor(peer) {
return Array.isArray(peer.grantScopes) ? normalizeScopes(peer.grantScopes) : ['catalog', 'download'];
@@ -190,12 +190,18 @@ class PeeringState {
return publicPeer(peer, this.outbound());
}
setShare(id, mangaId, enabled) {
const peer = requirePeer(this.load(), id), ids = new Set((peer.shares || []).map(s => s.mangaId));
if (enabled) ids.add(mangaId); else ids.delete(mangaId);
return this.setShares(id, [...ids]);
}
setShares(id, mangaIds) {
const state = this.load(), peer = requirePeer(state, id);
if (typeof mangaId !== 'string' || !mangaId || mangaId.startsWith('.') || mangaId.includes('/') || mangaId.includes('\\')) throw new Error('Invalid manga ID');
peer.shares ||= [];
const existing = peer.shares.find(s => s.mangaId === mangaId);
if (enabled && !existing) peer.shares.push({ mangaId, id: `m${crypto.randomBytes(12).toString('hex')}` });
if (!enabled) peer.shares = peer.shares.filter(s => s.mangaId !== mangaId);
if (!Array.isArray(mangaIds) || mangaIds.length > 5000) throw new Error('Choose no more than 5,000 manga');
const unique = [...new Set(mangaIds)];
if (unique.some(mangaId => typeof mangaId !== 'string' || !mangaId || mangaId.startsWith('.') || mangaId.includes('/') || mangaId.includes('\\')))
throw new Error('Invalid manga ID');
const existing = new Map((peer.shares || []).map(share => [share.mangaId, share]));
peer.shares = unique.map(mangaId => existing.get(mangaId) || { mangaId, id: `m${crypto.randomBytes(12).toString('hex')}` });
atomic(this.stateFile(), state);
return publicPeer(peer, this.outbound());
}
+8
View File
@@ -67,6 +67,7 @@ test('mTLS pairing, opt-in catalog, peer queue publication, and revocation', { t
const chapterDir = path.join(mangaDir, 'v01', 'c0001 [Local]');
await fsp.mkdir(chapterDir, { recursive: true });
await fsp.writeFile(path.join(mangaDir, 'manga.json'), JSON.stringify({ title: 'Sample Manga', direction: 'ltr' }));
await fsp.writeFile(path.join(mangaDir, 'cover.png'), PNG);
await fsp.writeFile(path.join(chapterDir, 'chapter.json'), JSON.stringify({ title: 'The first chapter', source: { name: 'Local' } }));
await fsp.writeFile(path.join(chapterDir, '0001.png'), PNG);
const ga = await gateway(dataA, libA, portA, path.join(root, 'usage-a.json'));
@@ -88,9 +89,14 @@ test('mTLS pairing, opt-in catalog, peer queue publication, and revocation', { t
const listing = await client.catalog(b, inB.id);
assert.equal(listing.length, 1);
assert.equal(listing[0].title, 'Sample Manga');
assert.equal(listing[0].coverAvailable, true);
assert.deepEqual(listing[0].tags, []);
assert.equal(JSON.stringify(listing).includes('Sample Manga\\v01'), false);
const remote = await client.manga(b, inB.id, listing[0].id);
assert.equal(remote.chapters.length, 1);
const cover = await client.cover(b, inB.id, listing[0].id);
assert.equal(cover.mime, 'image/png');
assert.deepEqual(cover.buffer, PNG, 'Catalog-only grants may browse approved covers');
const remoteAccess = await client.access(b, inB.id);
assert.equal(remoteAccess.grant.status, 'active');
assert.deepEqual(remoteAccess.grant.scopes, ['catalog']);
@@ -103,6 +109,8 @@ test('mTLS pairing, opt-in catalog, peer queue publication, and revocation', { t
/Peer HTTP 403: Grant does not allow download/);
a.updatePeer(inA.id, { grantScopes: ['catalog', 'download'] });
assert.deepEqual((await client.access(b, inB.id)).grant.scopes, ['catalog', 'download'], 'Permission edits apply without exchanging a new token');
a.updatePeer(inA.id, { grantScopes: ['download'] });
assert.deepEqual((await client.access(b, inB.id)).grant.scopes, ['catalog', 'download'], 'Download always implies catalog in canonical order');
await assert.rejects(() => client.jsonRequest(b, inB.id, '/peer/v1/manga/m000000000000000000000000'), /Peer HTTP 404/);
await assert.rejects(() => client.jsonRequest(b, inB.id, '/peer/v1/catalog', { headers: { authorization: 'Bearer ' + 'x'.repeat(43) } }), /Peer HTTP 401/);
const noCert = await new Promise((resolve, reject) => https.get({ hostname: '127.0.0.1', port: portA,