Redesign admin peering settings and catalogs
This commit is contained in:
1 parent
492477f86a
commit
da40d73f02
16 files changed
+1037
-85
No files matched your search
+19
-4
@@ -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
@@ -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
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user