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

+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 });
}