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

+119
View File
@@ -18,6 +18,8 @@ const { RATINGS: GREATEST_RATINGS, loadCatalog: loadGreatestCatalog, findOwned:
countsFor: greatestCounts, scoreFor: greatestScore } = require('./greatest');
const { hosterLinks } = require('./greatest/hosters');
const { secret } = require('./runtime/config');
const { PeeringState, requirePeer } = require('./peering/state');
const peerClient = require('./peering/client');
const {
applyPulse, applyVisit, chapterInsight, eligiblePages, emptyRecord, libraryInsight, mangaInsight, publicSummary,
} = require('./reading-analytics');
@@ -32,6 +34,8 @@ const SECURE_COOKIE = process.env.COOKIE_SECURE === '1';
const SCRAPER_URL = String(process.env.SCRAPER_URL || 'http://127.0.0.1:8091').replace(/\/$/, '');
const SCRAPER_TOKEN = secret('SCRAPER_TOKEN');
const SENTINEL_URL = String(process.env.SENTINEL_URL || 'http://127.0.0.1:8092').replace(/\/$/, '');
const PEER_DATA = path.resolve(process.env.PEER_DATA || path.join(DATA, 'peering'));
const peerStore = new PeeringState(PEER_DATA);
const PUB = path.join(__dirname, 'public');
const IMG = /\.(jpe?g|png|webp|gif|avif|bmp)$/i;
const MIME = {
@@ -57,6 +61,14 @@ for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { flush(); proces
class HttpError extends Error { constructor(code, msg) { super(msg); this.code = code; } }
function bad(code, msg) { throw new HttpError(code, msg); }
function peerForRoute(id) {
try { return requirePeer(peerStore.load(), id); }
catch { bad(404, 'Peer not found'); }
}
async function peerRemote(task) {
try { return await task(); }
catch (error) { bad(502, `Peer service: ${String(error.message || error).slice(0, 300)}`); }
}
// ---------- users & sessions ----------
const sha = s => crypto.createHash('sha256').update(s).digest('hex');
@@ -626,6 +638,101 @@ const routes = {
save();
return { ok: true };
},
'GET /api/admin/peering': ({ u }) => {
admin(u);
return { ...peerStore.publicState(), library: lib.mangas.map(m => ({ id: m.id, title: m.title, cover: url(m.cover),
chapters: m.chapters.length, year: Number(m.meta.year || m.meta.startDate?.year) || null,
status: String(m.meta.status || 'unknown'), description: String(m.meta.description || m.meta.synopsis || '').slice(0, 3000),
tags: Array.isArray(m.meta.tags) ? m.meta.tags.map(tag => typeof tag === 'string' ? tag : tag.name).filter(Boolean).slice(0, 20) : [] })) };
},
'POST /api/admin/peering': async ({ u, b }) => {
admin(u);
const command = String(b.command || '');
if (command === 'set-name') peerStore.setName(b.name);
else if (command === 'generate') peerStore.generateIdentity(b.name);
else if (command === 'invite') return { ...peerStore.publicState(), invitation: peerStore.createInvitation(b.url) };
else if (command === 'import') peerStore.importInvitation(b.invitation);
else if (command === 'probe') await peerClient.probe(peerStore, String(b.id || ''));
else if (command === 'set') peerStore.updatePeer(String(b.id || ''), b.patch || {});
else if (command === 'revoke-grant') peerStore.revokeGrant(String(b.id || ''));
else if (command === 'share') {
if (!lib.byId.has(String(b.mangaId || ''))) bad(404, 'Library manga not found');
peerStore.setShare(String(b.id || ''), String(b.mangaId), !!b.enabled);
}
else if (command === 'share-set') {
const ids = Array.isArray(b.mangaIds) ? [...new Set(b.mangaIds.map(String))] : null;
if (!ids) bad(400, 'Manga selection is required');
if (ids.some(id => !lib.byId.has(id))) bad(404, 'One or more library manga are no longer available');
peerStore.setShares(String(b.id || ''), ids);
}
else if (command === 'grant') return { ...peerStore.publicState(), grant: peerStore.issueGrant(String(b.id || ''), b.scopes) };
else if (command === 'accept-grant') peerStore.acceptGrant(String(b.id || ''), b.token);
else if (command === 'remove') peerStore.removePeer(String(b.id || ''));
else bad(400, 'Unknown peering command');
return { ...peerStore.publicState(), library: lib.mangas.map(m => ({ id: m.id, title: m.title })) };
},
'GET /api/peering': ({ u }) => { admin(u); return { peers: peerStore.publicState().peers
.map(p => ({ id: p.id, alias: p.alias, receive: !!p.receive, paired: !!p.paired, fingerprint: p.fingerprint,
url: p.url, grantStatus: p.grantStatus, grantScopes: p.grantScopes,
readerQueue: !!p.readerQueue, shares: p.shares, hasGrant: p.hasOutboundGrant })) }; },
'GET /api/peering/access': async ({ u, q }) => {
admin(u);
const id = String(q.get('peer') || ''), peer = peerForRoute(id);
if (!peer.paired) return { paired: false, grant: { status: 'none', scopes: [] } };
return peerRemote(() => peerClient.access(peerStore, id));
},
'GET /api/peering/catalog': async ({ u, q }) => {
admin(u);
const id = String(q.get('peer') || '');
const peer = peerForRoute(id);
if (!peer.paired) bad(403, 'Verify peer identity first');
const access = await peerRemote(() => peerClient.access(peerStore, id));
if (access.grant.status !== 'active' || !access.grant.scopes.includes('catalog')) bad(403, 'This peer has not granted active catalog access');
return { peer: { id, alias: peer.alias }, items: await peerRemote(() => peerClient.catalog(peerStore, id)) };
},
'GET /api/peering/manga': async ({ u, q }) => {
admin(u);
const id = String(q.get('peer') || ''), shareId = String(q.get('manga') || '');
const peer = peerForRoute(id);
if (!peer.paired) bad(403, 'Verify peer identity first');
return peerRemote(() => peerClient.manga(peerStore, id, shareId));
},
'POST /api/peering/queue': async ({ u, b }) => {
admin(u);
const id = String(b.peer || ''), shareId = String(b.manga || '');
const peer = peerForRoute(id);
if (!peer.paired || !peer.receive || (!u.admin && !peer.readerQueue)) bad(403, 'Peer downloads are disabled for this account');
const access = await peerRemote(() => peerClient.access(peerStore, id));
if (!access.grant.scopes.includes('download')) bad(403, 'This peer has granted catalog browsing only');
if (!/^m[0-9a-f]{24}$/.test(shareId)) bad(400, 'Invalid peer manga ID');
const remote = await peerRemote(() => peerClient.manga(peerStore, id, shareId));
const present = await peerRemote(() => peerClient.catalog(peerStore, id));
if (!present.some(entry => entry.id === shareId)) bad(404, 'Peer manga not available');
return scraperRequest('/api/queue', { url: `peer://${id}/${shareId}`, title: remote.title,
scope: b.scope || { mode: 'missing' }, existingPolicy: b.existingPolicy || 'skip-any',
...(b.libraryMangaId ? { libraryMangaId: String(b.libraryMangaId) } : {}) });
},
'POST /api/peering/queue-bulk': async ({ u, b }) => {
admin(u);
const id = String(b.peer || ''), peer = peerForRoute(id);
const ids = Array.isArray(b.mangas) ? [...new Set(b.mangas.map(String))] : [];
if (!ids.length || ids.length > 50 || ids.some(value => !/^m[0-9a-f]{24}$/.test(value))) bad(400, 'Select 1–50 peer titles');
if (!peer.paired || !peer.receive) bad(403, 'Enable downloads from this peer first');
const access = await peerRemote(() => peerClient.access(peerStore, id));
if (access.grant.status !== 'active' || !access.grant.scopes.includes('download')) bad(403, 'This peer has not granted active download access');
const available = new Set(await peerRemote(() => peerClient.catalog(peerStore, id)).then(items => items.map(item => item.id)));
const results = [];
for (const mangaId of ids) {
if (!available.has(mangaId)) { results.push({ id: mangaId, status: 'unavailable' }); continue; }
try {
const remote = await peerRemote(() => peerClient.manga(peerStore, id, mangaId));
const result = await scraperRequest('/api/queue', { url: `peer://${id}/${mangaId}`, title: remote.title,
scope: { mode: 'missing' }, existingPolicy: 'skip-any' });
results.push({ id: mangaId, title: remote.title, status: 'queued', result });
} catch (error) { results.push({ id: mangaId, status: 'failed', error: String(error.message || error).slice(0, 240) }); }
}
return { results, queued: results.filter(item => item.status === 'queued').length };
},
'GET /api/admin/scraper': async ({ u }) => {
admin(u);
try { return { available: true, ...(await scraperRequest('/api/state')) }; }
@@ -695,6 +802,18 @@ http.createServer(async (req, res) => {
if (!c?.customCover) bad(404, 'Cover not found');
return serveFile(req, res, path.join(DATA, 'collection-covers'), c.customCover, 'private, no-cache', IMG);
}
if (pathname === '/api/peering/cover' && req.method === 'GET') {
if (!u) bad(401, 'Please log in');
admin(u);
const peerId = String(searchParams.get('peer') || ''), mangaId = String(searchParams.get('manga') || ''), peer = peerForRoute(peerId);
if (!peer.paired) bad(403, 'Verify peer identity first');
const access = await peerRemote(() => peerClient.access(peerStore, peerId));
if (access.grant.status !== 'active' || !access.grant.scopes.includes('catalog')) bad(403, 'This peer has not granted active catalog access');
const image = await peerRemote(() => peerClient.cover(peerStore, peerId, mangaId));
res.writeHead(200, { 'Content-Type': image.mime, 'Content-Length': image.buffer.length,
...(image.etag ? { ETag: image.etag } : {}), 'Cache-Control': 'private, max-age=3600', 'X-Content-Type-Options': 'nosniff' });
return res.end(image.buffer);
}
if (pathname.startsWith('/api/')) {
const route = routes[req.method + ' ' + pathname];
if (!route) bad(404, 'Unknown endpoint');