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