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
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user