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

+49 -30
View File
@@ -11,21 +11,30 @@ received chapters. No database server or second library writer is introduced.
## Roles and direction
The admin UI is **Settings → Peering**, and all users can open the **Peers** catalog
page. Each server may have many peers. For every peer, its admin independently sets:
The interface is administrator-only and split by task: **Settings → Admin → Peering**
manages server identity, peers, grants and transfer limits; **Peer catalogs** manages
per-peer title approvals and remote catalog browsing. Ordinary users cannot see either
page or call their APIs. Each server may have many peers.
- **Serve approved manga**: expose only individually checked library titles to that
peer. Off is the default and invalidates the current serving grant.
- **Allow downloads**: queue and pull chapters from that peer. Off is the default;
a verified pairing and imported grant still allow browsing its approved catalog.
- **Let readers queue**: ordinary readers may request transfers; otherwise only an
admin may queue. Browsing a permitted catalog does not disclose the local user to
the remote server.
- Upload streams, bytes/s and bytes/day, and receiving pages in flight and bytes/s.
Defaults are two in-flight pages and 2 MiB/s in each direction per peer. The
publisher's caps remain authoritative. A gateway-wide four-stream ceiling applies.
- **Grant scopes**: Catalog permits catalog, covers and metadata. Download includes
Catalog and permits chapter transfers. Each direction has its own grant.
- **Serving state**: Active or Paused. Pausing preserves the grant/token; explicit
Revoke invalidates it. Scope changes take effect without exchanging a new token.
- **Approved titles**: the publisher chooses titles on Peer catalogs, not in the
connection settings. Pairing or a grant alone does not share a title.
- **Local receiving** is a separate switch. Browsing works with Catalog permission
even when local receiving is off; downloads require an active Download grant and
local receiving enabled.
- Transfer limits cover upload streams, bytes/s and bytes/day, plus receiving pages
in flight and bytes/s. Defaults are two in-flight pages and 2 MiB/s each direction
per peer. Publisher caps remain authoritative; a gateway-wide four-stream ceiling
applies.
Serving and receiving are separate directions. Pairing by itself exposes no title.
The required local server display name is 1–25 ASCII letters/digits. It is a friendly
label, not the cryptographic identity; changing it does not rotate the certificate.
An invitation expires after 48 hours, the certificate currently lasts one year, and
a grant lasts up to 90 days bounded by certificate validity. The UI labels these as
separate lifetimes.
## Deployment
@@ -38,8 +47,9 @@ scraper queue, Docker socket, or writable library mount. It must never be scaled
past one instance for the same usage-counter directory.
1. Build the image and start the normal stack as documented in
[`compose-deployment.md`](compose-deployment.md). Generate the local certificate
in **Settings → Peering**. Keep `/data/peering/identity.key` private and backed up.
[`compose-deployment.md`](compose-deployment.md). Set the local server name and
generate its certificate in **Settings → Admin → Peering**. Keep
`/data/peering/identity.key` private and backed up.
2. Start the optional service with `docker compose --profile peering up -d
peer-gateway`. Verify `docker compose --profile peering ps` shows healthy and
`https://127.0.0.1:8093/health` responds. `/health` is deliberately unauthenticated
@@ -65,12 +75,12 @@ require administrator-supervised re-pairing; there is no automatic key rotation
2. Both admins import the other's invitation and press **Test & verify pairing**.
The HTTPS handshake presents client and server certificates, checks their exact
pins and dates, and consumes the one-use invitation code. Both sides must probe.
3. A publisher checks specific titles under that peer, enables **Serve**, issues a
90-day random grant, and sends the token privately to the receiving admin. The
receiving admin imports the grant and enables **Allow downloads**. Reverse
direction needs its own share selection and grant. The server stores only a hash
of a grant it issued; the receiver stores its imported token server-side.
4. A local user browses the catalog, selects missing/newest/last-five or an exact
3. A publisher approves titles for that peer on **Peer catalogs**, chooses Catalog
or Download grant scopes, and sends the one-time token privately. The receiving
admin imports it and independently enables local receiving for downloads. Reverse
direction needs its own approvals and grant. The server stores only a hash of a
grant it issued; the receiver stores its imported token server-side.
4. An admin browses the permitted catalog, selects missing/newest/last-five or an exact
chapter, and queues it through the normal scraper. The peer adapter downloads up
to the configured pages in flight, validates size and SHA-256, resumes a partial
page with HTTP Range, checkpoints between batches, and atomically publishes each
@@ -82,15 +92,19 @@ the human reader. It can still observe requested titles, times and byte counts.
## Peer v1 HTTP interface
All content endpoints require TLS 1.3 client certificate pinning, a paired/serving
peer, and a currently valid certificate-bound bearer grant. There is no insecure
HTTP fallback and no path-based file browsing.
Catalog, cover, manifest and blob endpoints require TLS 1.3 client certificate
pinning, paired identity, an active grant with the matching scope, and no serving
pause. There is no insecure HTTP fallback or path-based file browsing. `/access`
requires the pinned client certificate and reports status without a bearer grant so
an admin can distinguish pairing from permission.
| Request | Response |
| --- | --- |
| `GET /peer/v1/hello` with one-use invitation headers | Protocol version and server identity fingerprint; no catalog |
| `GET /peer/v1/catalog?cursor=N` | Up to 50 approved opaque title IDs, title/year/status, next cursor |
| `GET /peer/v1/manga/{id}?cursor=N` | Up to 100 complete chapter IDs and light metadata |
| `GET /peer/v1/access` | Pairing state, certificate expiry and current grant status/scopes; no catalog |
| `GET /peer/v1/catalog?cursor=N` | Up to 50 approved opaque title IDs, safe metadata and cover availability |
| `GET /peer/v1/covers/{mangaId}` | Approved cover image only; bounded to 5 MiB and charged to peer byte quota |
| `GET /peer/v1/manga/{id}?cursor=N` | Up to 100 complete chapter IDs and public metadata |
| `GET /peer/v1/manga/{id}/chapters/{id}` | Ordered page IDs, MIME, byte size, SHA-256 and ETag |
| `GET /peer/v1/blobs/{id}` | One image, optional `Range: bytes=N-` and `If-Match`; 206 on resume |
@@ -111,8 +125,13 @@ parallelism, not unbounded connections.
`npm run test:peer` creates disposable A/B identities, gateways and libraries and
tests mTLS pairing, no-cert/bad-grant rejection, private catalog, per-title sharing,
manifest hashes, a resumed range response, scraper publication, duplicate skip and
revocation. `npm test` includes it. An isolated Compose acceptance is:
catalog-only cover access, manifest hashes, a resumed range response, scraper
publication, duplicate skip, pause and revocation. `npm run test:peer-ab` additionally
builds two independent Compose projects from synthetic fixtures, exercises real
two-way pairing, browsing, cover retrieval, permission updates and sequential queueing,
checks the queue remains paused, and tears both stacks down. The UI suite checks the
guided setup, approvals, and non-admin boundary. An isolated single-stack Compose
acceptance is:
```powershell
$env:APP_IMAGE = 'mangacircus:local' # replace with the image just built
@@ -133,6 +152,6 @@ without affecting reading or existing local downloads. Do not delete the identit
to recover from a network error; that would break every peer pin.
Current limits: no automatic certificate renewal, invitation cancellation UI,
peer cover-image endpoint, multi-peer performance pilot, VM deployment, or public
internet exposure review. The gateway has no automated hoster-interaction logic;
multi-peer performance pilot, VM deployment, or public internet exposure review.
The gateway has no automated hoster-interaction logic;
source scraping remains subject to the existing source-safety policy.
+4 -1
View File
@@ -7,6 +7,7 @@
"scripts": {
"start": "node server.js",
"start:scraper": "node scraper/daemon.js",
"start:peer": "node peering/gateway.js",
"start:all": "node tools/start-all.js",
"start:sentinel": "node sentinel/daemon.js",
"check": "node tools/check.js",
@@ -17,7 +18,9 @@
"test:direction-ui": "node tools/test-compose.js --direction-ui",
"demo": "node tools/make-dummy.js library",
"scrape": "node scraper/cli.js",
"test": "npm run test:reader && npm run test:scraper && npm run test:runtime",
"test": "npm run test:reader && npm run test:scraper && npm run test:runtime && npm run test:peer",
"test:peer": "node --test peering/test/*.test.js",
"test:peer-ab": "node tools/test-peer-ab.js",
"test:reader": "node --test test/*.test.js",
"test:scraper": "node --test scraper/test/*.test.js"
},
+19 -4
View File
@@ -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
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 });
}
+14 -8
View File
@@ -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());
}
+8
View File
@@ -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,
+63
View File
@@ -216,6 +216,69 @@ main { max-width: 1200px; margin: 0 auto; padding: 16px; }
.settings-title-row { display: flex; align-items: center; gap: 12px; }
.settings-hint { margin: 5px 0 20px; }
.settings-content .card { max-width: none; }
.peer-card { min-width: 0; overflow-wrap: anywhere; }
.peer-card textarea, .peer-card input { min-width: 0; max-width: 100%; }
.peer-card .row > .field { min-width: 0; }
.peer-name { color: #9ac8ff; font-weight: 650; white-space: nowrap; }
.peer-name.local { color: #ffc78a; }
.peer-page-tabs, .peer-section-head, .peer-catalog-heading, .peer-catalog-toolbar, .peer-catalog-actions, .peer-card-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.peer-page-tabs { margin: 0 0 16px; }
.peer-section-head, .peer-catalog-heading { justify-content: space-between; }
.peer-catalog-heading { margin-bottom: 16px; }
.peer-catalog-heading h1, .peer-section-head h2 { margin: 0; }
.peer-catalog-toolbar { padding: 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--card); margin-bottom: 14px; }
.peer-catalog-peer { min-width: min(220px, 100%); }
.peer-catalog-actions { padding: 12px; margin: 12px 0; border: 1px solid var(--line); border-radius: 10px; background: var(--card); }
.peer-search { flex: 1 1 220px; max-width: 420px; }
.peer-catalog-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 14px; align-items: start; }
.peer-catalog-card { min-width: 0; overflow: hidden; border: 1px solid var(--line); border-radius: 10px; background: var(--card); }
.peer-cover-wrap { position: relative; overflow: hidden; aspect-ratio: 2 / 3; background: #17181b; }
.peer-cover { display: block; width: 100%; height: 100%; object-fit: cover; }
.peer-cover-placeholder { display: grid; place-items: center; width: 100%; height: 100%; color: var(--muted); }
.peer-approval-badge { position: absolute; top: 8px; left: 8px; display: grid; place-items: center; width: 28px; height: 28px; border-radius: 50%; background: #1f9d61; color: white; font-weight: 800; }
.peer-select-overlay { position: absolute; top: 8px; right: 8px; width: 32px; height: 32px; display: grid; place-items: center; border-radius: 7px; background: #101114d9; }
.peer-select-overlay input { width: 19px; height: 19px; margin: 0; accent-color: #4aa675; }
.peer-catalog-card-info { display: grid; gap: 7px; padding: 10px; }
.peer-catalog-card-info .small { min-height: 2.5em; }
.peer-title { padding: 0; border: 0; background: transparent; color: var(--fg); font: inherit; font-weight: 650; text-align: left; cursor: pointer; }
.peer-selected { border-color: #278256; color: #8fe0b2; }
.peer-catalog-message { margin: 10px 0; }
.peer-table { overflow-x: auto; }
.peer-table-row { display: grid; grid-template-columns: minmax(140px, 1.3fr) minmax(150px, 1.1fr) minmax(130px, 1fr) minmax(130px, 1fr) auto; align-items: center; gap: 12px; min-width: 720px; padding: 12px; border-bottom: 1px solid var(--line); }
.peer-table-head { color: var(--muted); font-size: 12px; text-transform: uppercase; }
.peer-row-button { width: 100%; color: var(--fg); background: transparent; border: 0; border-bottom: 1px solid var(--line); text-align: left; cursor: pointer; font: inherit; }
.peer-row-button:hover { background: #ffffff08; }
.peer-status-ok { color: #8fe0b2; }
.peer-status-pending { color: #efc36e; }
.peer-row-action { white-space: nowrap; color: var(--accent); }
.peer-wizard-grid, .peer-limits-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.peer-step { min-width: 0; padding: 14px; border: 1px solid var(--line); border-radius: 10px; background: var(--card); }
.peer-step h3, .peer-step h4 { margin-top: 0; }
.peer-fingerprint { overflow-wrap: anywhere; }
.peer-danger-zone { padding: 16px 0; border-top: 1px solid var(--line); }
.peer-manga-dialog { width: min(860px, calc(100vw - 24px)); max-height: min(90vh, 900px); overflow: auto; color: var(--fg); background: var(--bg); border: 1px solid var(--line); border-radius: 12px; padding: 22px; }
.peer-manga-dialog::backdrop { background: #000b; }
.peer-dialog-close { float: right; font-size: 20px; }
.peer-dialog-layout { display: grid; grid-template-columns: minmax(120px, 220px) minmax(0, 1fr); gap: 20px; }
.peer-dialog-cover { width: 100%; aspect-ratio: 2 / 3; object-fit: cover; border-radius: 8px; }
.peer-chapter-list { max-height: 40vh; overflow: auto; padding-left: 24px; }
.peer-chapter-list li { padding: 4px 0; }
.peer-detail-queue { display: grid; gap: 10px; margin-top: 14px; }
.peer-detail-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.peer-chapter-picker { margin-top: 12px; border-top: 1px solid var(--line); padding-top: 10px; }
.peer-chapter-picker summary { cursor: pointer; color: var(--muted); }
.peer-chapter-picker .peer-chapter-list { display: grid; gap: 4px; margin-top: 10px; padding: 8px; border: 1px solid var(--line); border-radius: 8px; }
.peer-chapter-option { display: flex; align-items: flex-start; gap: 8px; padding: 5px; }
.peer-chapter-option input { min-height: 0; margin-top: 4px; accent-color: #4aa675; }
.peer-tags { display: flex; gap: 6px; flex-wrap: wrap; }
.peer-tags .tag { padding: 3px 8px; border-radius: 999px; background: var(--card); color: var(--muted); }
@media (max-width: 620px) {
.peer-wizard-grid, .peer-limits-grid, .peer-dialog-layout { grid-template-columns: 1fr; }
.peer-catalog-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; }
.peer-catalog-toolbar > .seg { width: 100%; }
.peer-catalog-toolbar > .seg .btn { flex: 1; }
.peer-dialog-cover { max-width: 180px; }
}
.catalog-workspace { min-width: 0; margin: 0; padding: 0; border: 0; }
.catalog-workspace > .row { margin-bottom: 16px; }
.catalog-workspace input, .catalog-workspace select, .catalog-workspace textarea { min-width: 0; max-width: 100%; }
+1
View File
@@ -99,6 +99,7 @@ function topNav(me) {
h('a', { class: 'btn nav-greatest', href: 'greatest.html', title: 'Shijō Saikō no Manga · Greatest of All Time', 'aria-label': 'Shijō Saikō no Manga' }, 'Shijō Saikō no Manga'),
h('div', { class: 'grow' }),
h('div', { class: 'bell-wrap' }, bell, panel),
...(me.admin ? [h('a', { class: 'btn', href: 'peer-catalog.html' }, 'Peer catalogs')] : []),
h('a', { class: 'btn', href: 'settings.html' }, 'Settings'),
h('button', { class: 'btn', onclick: async () => { await api('api/logout', {}); location.href = 'login.html'; } }, 'Log out'));
document.body.prepend(nav);
+10
View File
@@ -0,0 +1,10 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Peer catalogs · MangaCircus</title>
<link rel="stylesheet" href="app.css"><link rel="icon" href="icon.svg">
<script src="common.js" defer></script><script src="peer-catalog.js" defer></script>
</head>
<body><main><p class="loading">Loading peer catalogs…</p></main><dialog id="peer-manga-dialog" class="peer-manga-dialog"></dialog></body>
</html>
+185
View File
@@ -0,0 +1,185 @@
'use strict';
(async function () {
const me = await api('api/me');
if (!me.admin) return showError(new Error('Peer catalogs are available to administrators only.'));
topNav(me);
const main = $('main'); main.textContent = '';
const state = await api('api/admin/peering');
const peersResult = await api('api/peering');
let peers = peersResult.peers || [], peerId = new URLSearchParams(location.search).get('peer') || peers[0]?.id || '';
const peer = () => peers.find(item => item.id === peerId);
let mode = location.hash === '#browse' ? 'browse' : 'share', localSearch = '', remoteSearch = '',
localSelected = new Set((peer()?.shares || []).map(item => item.mangaId)), remoteSelected = new Set();
let access = null, remoteItems = [], noteText = '', busy = false;
const shareDirty = () => { const saved = new Set((peer()?.shares || []).map(item => item.mangaId)); return saved.size !== localSelected.size || [...saved].some(id => !localSelected.has(id)); };
const button = (label, click, cls = '', disabled = false) => h('button', { type: 'button', class: `btn ${cls}`, onclick: click, disabled }, label);
function coverUrl(item) { return `api/peering/cover?${qsa({ peer: peerId, manga: item.id })}`; }
function tabs() {
const select = h('select', { 'aria-label': 'Select peer', onchange: async event => { peerId = event.target.value; history.replaceState(null, '', `?peer=${encodeURIComponent(peerId)}${mode === 'browse' ? '#browse' : ''}`); localSelected = new Set((peer()?.shares || []).map(item => item.mangaId)); remoteSelected.clear(); await loadRemote(); draw(); } },
peers.map(item => h('option', { value: item.id, selected: item.id === peerId }, item.alias)));
const bar = h('div', { class: 'peer-catalog-toolbar' }, h('label', { class: 'field peer-catalog-peer' }, h('span', {}, 'Peer'), select),
h('div', { class: 'seg' }, button('Share my library', () => setMode('share', true), mode === 'share' ? 'on' : ''), button('Browse peer catalog', () => setMode('browse', true), mode === 'browse' ? 'on' : '')),
button('Peer settings', () => { location.href = `peers.html#admin/peering/${encodeURIComponent(peerId)}`; }));
return bar;
}
function setMode(value, reload = false) { mode = value; history.replaceState(null, '', `?peer=${encodeURIComponent(peerId)}${value === 'browse' ? '#browse' : ''}`); if (reload) { loadRemote().then(draw); } else draw(); }
async function loadRemote() {
access = null; remoteItems = [];
if (!peer()?.paired) return;
try {
access = await api(`api/peering/access?peer=${encodeURIComponent(peerId)}`);
if (access.grant?.status === 'active' && access.grant.scopes?.includes('catalog')) {
const result = await api(`api/peering/catalog?peer=${encodeURIComponent(peerId)}`); remoteItems = result.items || [];
}
} catch (error) { access = { error: error.message }; }
}
function statusBanner() {
if (!peers.length) return h('section', { class: 'card' }, h('h2', {}, 'Add a peer first'), h('p', { class: 'muted' }, 'Connect another MangaCircus server and set up a catalog grant before sharing or browsing.'), button('Open peering settings', () => { location.href = 'peers.html#admin'; }, 'primary'));
const p = peer();
if (mode === 'browse' && !p?.paired) return h('section', { class: 'card peer-catalog-message' }, h('b', {}, `Setup is incomplete for <${p?.alias || 'peer'}>.`), h('p', { class: 'muted' }, 'Finish the two-way identity exchange in Peering settings.'), button('Open peer settings', () => { location.href = `peers.html#admin/peering/${encodeURIComponent(peerId)}`; }));
if (mode === 'browse') {
if (access?.error) return h('section', { class: 'card error' }, `Could not read ${p.alias}’s current permissions: ${access.error}`);
if (!access) return h('section', { class: 'card muted' }, 'Checking the peer’s current access grant…');
if (access.grant?.status !== 'active' || !access.grant.scopes?.includes('catalog')) return h('section', { class: 'card peer-catalog-message' },
h('b', {}, `<${p.alias}> has not granted catalog browsing.`), h('p', { class: 'muted' }, 'Ask that server’s admin to issue an active Catalog grant. Download permission is not needed to browse.'));
return null;
}
return h('section', { class: 'card peer-catalog-message' }, h('b', {}, `Choose which of your titles <${p.alias}> may see.`),
h('p', { class: 'muted' }, 'Only approved manga titles and their available chapter manifests are shared. User accounts and reading history are never included.'),
!p.paired ? h('p', { class: 'muted small' }, 'You can prepare this list now; sharing begins after identity pairing and an active Catalog grant.') : null);
}
function tile(item, kind) {
const isLocal = kind === 'local', p = peer();
const selected = isLocal ? localSelected.has(item.id) : remoteSelected.has(item.id);
const approved = isLocal && (p?.shares || []).some(share => share.mangaId === item.id);
const quickAllowed = !isLocal && p?.receive && access?.grant?.status === 'active' && access.grant.scopes?.includes('download');
const image = h('div', { class: 'peer-cover-wrap' }, item.coverAvailable && !isLocal ? h('img', { class: 'peer-cover', src: coverUrl(item), alt: '', loading: 'lazy' })
: isLocal && item.cover ? h('img', { class: 'peer-cover', src: item.cover, alt: '', loading: 'lazy' }) : h('div', { class: 'peer-cover-placeholder' }, 'No cover'),
approved ? h('span', { class: 'peer-approval-badge', title: 'Shared with this peer', 'aria-label': 'Shared with this peer' }, '✓') : null,
!isLocal ? h('label', { class: 'peer-select-overlay', title: `Select ${item.title}` }, h('input', { type: 'checkbox', checked: selected, 'aria-label': `Select ${item.title}`, onchange: event => { event.stopPropagation(); selected ? remoteSelected.delete(item.id) : remoteSelected.add(item.id); draw(); } })) : null);
const selectLocal = button(selected ? 'Will share ✓' : 'Will not share', () => {
selected ? localSelected.delete(item.id) : localSelected.add(item.id); draw();
}, selected ? 'peer-selected' : '', false);
const open = button(item.title, () => isLocal ? null : showDetails(item), 'peer-title');
return h('article', { class: 'peer-catalog-card' }, image,
h('div', { class: 'peer-catalog-card-info' }, open,
h('div', { class: 'muted small' }, [item.year, item.status, item.chapters ? `${item.chapters} chapters` : ''].filter(Boolean).join(' · ') || 'Metadata not supplied'),
isLocal ? selectLocal : h('div', { class: 'peer-card-actions' }, quickAllowed ? button('Queue missing', () => queueOne(item), 'small') : null,
button('Details', () => showDetails(item), 'small'))));
}
function libraryView() {
const p = peer();
const search = h('input', { type: 'search', value: localSearch, placeholder: 'Search your library', oninput: event => { localSearch = event.target.value; draw(); } });
const items = (state.library || []).filter(item => item.title.toLowerCase().includes(localSearch.toLowerCase()));
const selectVisible = button('Select visible', () => { for (const item of items) localSelected.add(item.id); draw(); });
const clearVisible = button('Clear visible', () => { for (const item of items) localSelected.delete(item.id); draw(); });
const save = button('Save approvals', async () => {
await runAction('Saving title approvals…', async () => { const result = await api('api/admin/peering', { command: 'share-set', id: peerId, mangaIds: [...localSelected] });
peers = result.peers || peers; localSelected = new Set((peer()?.shares || []).map(item => item.mangaId)); noteText = 'Approved titles updated.'; });
}, 'primary', !p || !shareDirty() || busy);
const toolbar = h('div', { class: 'peer-catalog-actions' }, h('label', { class: 'field peer-search' }, h('span', {}, 'Search'), search), selectVisible, clearVisible, save);
const changedCount = [...new Set([...(p?.shares || []).map(item => item.mangaId), ...localSelected])].filter(id => localSelected.has(id) !== (p?.shares || []).some(item => item.mangaId === id)).length;
const summary = h('p', { class: 'muted small' }, `${p?.shares?.length || 0} title${p?.shares?.length === 1 ? '' : 's'} approved for <${p?.alias || 'peer'}> · ${changedCount} changes`);
return h('section', {}, toolbar, summary, h('div', { class: 'peer-catalog-grid' }, items.map(item => tile(item, 'local'))));
}
function remoteView() {
const p = peer();
const search = h('input', { type: 'search', value: remoteSearch, placeholder: 'Search peer catalog', oninput: event => { remoteSearch = event.target.value; draw(); } });
const eligible = !!(p?.receive && access?.grant?.status === 'active' && access.grant.scopes?.includes('download'));
const items = remoteItems.filter(item => item.title.toLowerCase().includes(remoteSearch.toLowerCase()));
const actions = h('div', { class: 'peer-catalog-actions' }, h('label', { class: 'field peer-search' }, h('span', {}, 'Search'), search),
button('Select visible', () => { for (const item of items) remoteSelected.add(item.id); draw(); }),
button('Clear selection', () => { remoteSelected.clear(); draw(); }),
button(`Queue ${remoteSelected.size} missing`, queueSelected, 'primary', !eligible || !remoteSelected.size || busy));
const banner = h('p', { class: 'muted small' }, eligible ? 'Download permission is active. Queueing is sequential and skips existing chapters.' : 'Browsing is separate from downloads. Queue actions require an active Download grant and local receiving enabled.');
const cards = h('div', { class: 'peer-catalog-grid' }, items.map(item => tile(item, 'remote')));
return h('section', {}, actions, banner, cards);
}
async function queueOne(item, scope = { mode: 'missing' }, label = 'missing chapters') {
await runAction(`Queueing ${label} from ${item.title}…`, async () => {
const result = await api('api/peering/queue', { peer: peerId, manga: item.id, scope, existingPolicy: 'skip-any' });
noteText = `Queued ${label} from ${item.title}. ${result?.added ? `${result.added} added.` : ''}`;
});
}
async function queueSelected() {
if (!remoteSelected.size) return;
const titles = new Map(remoteItems.map(item => [item.id, item.title]));
await runAction('Queueing selected manga sequentially…', async () => {
const ids = [...remoteSelected], failed = []; let queued = 0;
for (let offset = 0; offset < ids.length; offset += 50) {
const result = await api('api/peering/queue-bulk', { peer: peerId, mangas: ids.slice(offset, offset + 50) });
queued += result.queued || 0; failed.push(...(result.results || []).filter(item => item.status !== 'queued'));
}
noteText = `${queued} manga queued; ${failed.length} unavailable or failed.`;
remoteSelected.clear();
if (failed.length) noteText += ` Check results for: ${failed.map(item => titles.get(item.id) || item.id).join(', ')}.`;
});
}
async function runAction(label, action) {
if (busy) return;
busy = true; noteText = label; draw();
try { await action(); }
catch (error) { noteText = error.message; }
finally { busy = false; await reloadCurrent(); draw(); }
}
async function reloadCurrent() { try { peers = (await api('api/peering')).peers || peers; if (mode === 'browse') await loadRemote(); } catch (error) { noteText = error.message; } }
function showDetails(item) {
const dialog = $('#peer-manga-dialog'); dialog.textContent = 'Loading manga details…';
dialog.showModal();
api(`api/peering/manga?peer=${encodeURIComponent(peerId)}&manga=${encodeURIComponent(item.id)}`).then(detail => {
dialog.textContent = '';
dialog.append(h('button', { class: 'btn peer-dialog-close', type: 'button', onclick: () => dialog.close(), 'aria-label': 'Close details' }, '×'),
h('div', { class: 'peer-dialog-layout' }, item.coverAvailable ? h('img', { class: 'peer-dialog-cover', src: coverUrl(item), alt: '' }) : null,
h('div', {}, h('h2', {}, detail.title || item.title), h('p', { class: 'muted' }, [detail.year, detail.status, detail.direction].filter(Boolean).join(' · ')),
detail.description ? h('p', {}, detail.description) : null,
[...(detail.tags || []), ...(detail.genres || [])].length ? h('p', { class: 'peer-tags' }, [...new Set([...(detail.tags || []), ...(detail.genres || [])])].map(tag => h('span', { class: 'tag' }, tag))) : null,
h('h3', {}, `${detail.chapters.length} complete chapters`),
(() => {
const scope = h('select', { 'aria-label': 'Queue scope' },
h('option', { value: 'missing' }, 'All missing chapters'), h('option', { value: 'latest' }, 'Newest chapter only'),
h('option', { value: 'last-5' }, 'Last five chapters'), h('option', { value: 'first-10' }, 'First ten chapters'),
h('option', { value: 'after' }, 'After chapter number…'),
[...new Set(detail.chapters.map(chapter => chapter.volume).filter(value => value !== null && value !== undefined && value !== ''))]
.map(volume => h('option', { value: `volume:${volume}` }, `Volume ${volume}`)));
const after = h('input', { type: 'number', min: '0', step: '0.1', value: '0', 'aria-label': 'Chapter threshold' });
const afterField = h('label', { class: 'field peer-scope-number', hidden: true }, h('span', {}, 'Chapter threshold'), after);
scope.onchange = () => { afterField.hidden = scope.value !== 'after'; };
const checks = detail.chapters.map(chapter => h('label', { class: 'peer-chapter-option' },
h('input', { type: 'checkbox', value: chapter.id, 'aria-label': `Select chapter ${chapter.number}` }),
h('span', {}, `Vol. ${chapter.volume} · Ch. ${chapter.number}${chapter.title ? ` · ${chapter.title}` : ''} · ${chapter.pageCount} pages`)));
const canQueue = !!(peer()?.receive && access?.grant?.status === 'active' && access.grant.scopes?.includes('download'));
const applyRule = () => {
const value = scope.value;
const selection = value === 'latest' ? { mode: 'latest' } : value === 'last-5' ? { mode: 'last', count: 5 }
: value === 'first-10' ? { mode: 'first', count: 10 } : value === 'after' ? { mode: 'after', chapter: after.value }
: value.startsWith('volume:') ? { mode: 'volume', volume: value.slice(7) } : { mode: 'missing' };
dialog.close(); queueOne(item, selection, scope.selectedOptions[0]?.textContent.toLowerCase() || 'selected chapters');
};
const selectedChapters = () => [...dialog.querySelectorAll('.peer-chapter-option input:checked')].map(input => input.value);
const exactButton = button('Queue checked chapters', () => { const ids = selectedChapters(); if (!ids.length) return; dialog.close(); queueOne(item, { mode: 'ids', ids }, `${ids.length} selected chapters`); }, '', true);
const refreshExactButton = () => { exactButton.disabled = !selectedChapters().length; };
for (const option of checks) option.querySelector('input').addEventListener('change', refreshExactButton);
return h('div', { class: 'peer-detail-queue' },
h('label', { class: 'field' }, h('span', {}, 'Queue scope'), scope),
afterField,
canQueue ? h('div', { class: 'peer-detail-actions' }, button('Queue with selected rule', applyRule, 'primary'), exactButton)
: h('p', { class: 'muted small' }, 'Queueing requires active Download permission and local receiving enabled.'),
h('details', { class: 'peer-chapter-picker' }, h('summary', {}, 'Choose exact chapters'), h('div', { class: 'peer-chapter-list' }, checks)));
})())));
}).catch(error => { dialog.textContent = ''; dialog.append(h('p', { class: 'error' }, error.message), button('Close', () => dialog.close())); });
}
function draw() {
main.textContent = '';
main.append(h('div', { class: 'peer-catalog-heading' }, h('div', {}, h('p', { class: 'muted small' }, 'Admin / Peering'), h('h1', {}, 'Peer catalogs'), h('p', { class: 'muted' }, 'Approve the titles you serve, or browse catalogs granted to you. These actions do not change grants.')),
button('Peering settings', () => { location.href = 'peers.html#admin'; })));
main.append(tabs());
if (noteText) main.append(h('p', { role: 'status', class: 'muted small peer-catalog-status' }, noteText));
const banner = statusBanner();
if (banner) { main.append(banner); if (mode === 'browse' || !peers.length) return; }
if (mode === 'share') main.append(libraryView()); else main.append(remoteView());
}
const libraryResponse = await api('api/admin/peering');
state.library = libraryResponse.library || [];
async function init() { await loadRemote(); draw(); }
await init();
})().catch(showError);
+10
View File
@@ -0,0 +1,10 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Peering settings · MangaCircus</title>
<link rel="stylesheet" href="app.css"><link rel="icon" href="icon.svg">
<script src="common.js" defer></script><script src="settings-nav.js" defer></script><script src="peers.js" defer></script>
</head>
<body><main><p class="loading">Loading…</p></main></body>
</html>
+262
View File
@@ -0,0 +1,262 @@
'use strict';
(async function () {
let me;
try { me = await api('api/me'); } catch (error) { return showError(error); }
if (!me.admin) return showError(new Error('Peering settings are available to administrators only.'));
topNav(me);
const shell = settingsShell(me, 'admin/peering'), main = shell.content;
let state = null, peers = [], access = new Map(), route = '', busy = false;
let pendingInvitation = '', pendingGrant = null, messageText = '', messageError = false;
const note = (text, error = false) => { messageText = text; messageError = error; draw(); };
const field = (label, control, hint) => h('label', { class: 'field' }, h('span', {}, label), control,
hint ? h('small', { class: 'muted' }, hint) : null);
const button = (text, click, cls = '', disabled = false) => h('button', { class: `btn ${cls}`, type: 'button', onclick: click, disabled }, text);
const peerTag = (name, local = false) => h('span', { class: `peer-name${local ? ' local' : ''}` }, `<${name || 'Unnamed'}>${local ? ' · this server' : ''}`);
const date = value => value ? new Date(value).toLocaleString() : '—';
async function command(payload, success = 'Saved') {
if (busy) return null;
busy = true; messageText = 'Working…'; messageError = false; draw();
try {
const result = await api('api/admin/peering', payload);
if (result.identity || result.peers) state = { ...state, ...result };
messageText = success; messageError = false;
await reload(false); return result;
} catch (error) { messageText = error.message; messageError = true; draw(); return null; }
finally { busy = false; draw(); }
}
async function reload(redraw = true) {
try {
[state, peers] = await Promise.all([api('api/admin/peering'), api('api/peering').then(value => value.peers)]);
await Promise.all(peers.filter(peer => peer.paired).map(async peer => {
try { access.set(peer.id, await api(`api/peering/access?peer=${encodeURIComponent(peer.id)}`)); }
catch (error) { access.set(peer.id, { error: error.message }); }
}));
if (redraw) draw();
} catch (error) { messageText = error.message; messageError = true; if (redraw) draw(); }
}
async function refreshAccess(id) {
access.delete(id);
try { access.set(id, await api(`api/peering/access?peer=${encodeURIComponent(id)}`)); }
catch (error) { access.set(id, { error: error.message }); }
draw();
}
function setRoute(value) { location.hash = value; }
function statusLabel(peer) {
if (!peer.paired) return 'Identity not verified here';
const remote = access.get(peer.id);
if (remote?.paired) return 'Verified on both servers';
if (remote?.error) return 'Waiting for the other server';
return 'Verified here · checking other side';
}
function serverIdentityPanel() {
const identity = state?.identity, currentName = state?.name || identity?.name || '';
const panel = h('section', { class: 'card peer-card' }, h('h2', {}, 'This server'),
h('p', { class: 'muted' }, 'Choose the short name other admins will see. It is a display name; changing it does not replace this server’s certificate.'),
peerTag(currentName || 'Set a name', true));
const name = h('input', { value: currentName, maxlength: '25', pattern: '[A-Za-z0-9]{1,25}', autocomplete: 'off', placeholder: 'MangaCircus' });
panel.append(field('Server name · letters and numbers, up to 25 characters', name),
button('Save server name', () => command({ command: 'set-name', name: name.value }, 'Server name saved'), '', busy));
if (!identity) {
panel.append(h('h3', {}, 'Create this server’s certificate'),
h('p', { class: 'muted small' }, 'The certificate is this server’s durable cryptographic identity. It currently lasts one year. It is separate from the 48-hour invitation code and is not recreated when you edit the display name.'),
button('Create certificate', () => command({ command: 'generate', name: currentName }, 'Certificate created'), 'primary', !currentName || busy));
return panel;
}
panel.append(h('h3', {}, 'Server certificate'),
h('p', { class: 'muted small' }, `Valid until ${date(identity.expiresAt)}. This is the certificate lifetime; an invitation expires after 48 hours, and a grant lasts up to 90 days.`),
h('details', {}, h('summary', {}, 'Certificate fingerprint'), h('code', { class: 'peer-fingerprint' }, identity.fingerprint)));
if (state.invitations?.length) panel.append(h('p', { class: 'muted small' }, `Unexpired invitations: ${state.invitations.map(item => date(item.expiresAt)).join(' · ')}`));
return panel;
}
function peerTable() {
const panel = h('section', { class: 'card peer-card' },
h('div', { class: 'peer-section-head' }, h('div', {}, h('h2', {}, 'Known peers'), h('p', { class: 'muted small' }, 'Each row shows identity setup and permissions in both directions.')),
button('Add a peer', () => setRoute('#admin/peering/add'), 'primary')));
if (!peers.length) return panel.append(h('p', { class: 'muted' }, 'No peers yet. Add a peer to start the two-way identity exchange.')), panel;
const table = h('div', { class: 'peer-table', role: 'table', 'aria-label': 'Known peers' });
table.append(h('div', { class: 'peer-table-row peer-table-head', role: 'row' },
...['Peer', 'Connection', 'I grant them', 'They grant me', ''].map(label => h('span', { role: 'columnheader' }, label))));
for (const peer of peers) {
const remote = access.get(peer.id), mine = peer.grantStatus === 'active' ? peer.grantScopes : [];
const theirs = remote?.grant?.status === 'active' ? remote.grant.scopes : [];
const summary = scopes => scopes.includes('download') ? 'Browse + download' : scopes.includes('catalog') ? 'Browse catalog' : '—';
table.append(h('button', { class: 'peer-table-row peer-row-button', type: 'button', role: 'row', onclick: () => setRoute(`#admin/peering/${encodeURIComponent(peer.id)}`) },
h('span', { role: 'cell' }, peerTag(peer.alias)),
h('span', { role: 'cell', class: peer.paired && remote?.paired ? 'peer-status-ok' : 'peer-status-pending' }, peer.paired && remote?.paired ? '✓ Fully paired' : '○ Setup incomplete'),
h('span', { role: 'cell' }, peer.grantStatus === 'paused' ? 'Paused' : summary(mine)),
h('span', { role: 'cell' }, remote?.error ? 'Check status' : summary(theirs)),
h('span', { role: 'cell', class: 'peer-row-action' }, 'Manage →')));
}
panel.append(table); return panel;
}
function invitationWizard() {
const identity = state?.identity;
const panel = h('section', { class: 'card peer-card' }, h('div', { class: 'peer-section-head' },
h('div', {}, h('h2', {}, 'Add a peer'), h('p', { class: 'muted small' }, 'Both admins exchange one invitation, then verify the other server. Pairing sets up identity in both directions; sharing manga remains a separate choice.')),
button('Back to peers', () => setRoute('#admin/peering'))));
if (!identity) return panel.append(h('p', { class: 'error' }, 'Set this server name and create its certificate before inviting a peer.'));
const url = h('input', { type: 'url', placeholder: 'https://your-tailnet-name:8093', value: pendingUrl, class: 'grow' });
const outgoing = h('textarea', { rows: '7', readonly: true, placeholder: 'Create your invitation first. It will appear here with its 48-hour setup expiry.', 'aria-label': 'Invitation to send' }, pendingInvitation);
const incoming = h('textarea', { rows: '7', placeholder: 'Paste the invitation JSON from the other admin.', 'aria-label': 'Invitation received' });
const send = h('section', { class: 'peer-step' }, h('h3', {}, '1 · Create and send your invitation'),
h('p', { class: 'muted small' }, `Tell the other admin to use this gateway URL and send their invitation back. Your server: `, peerTag(state.name, true)),
field('This server’s reachable peer gateway URL', url, 'Use its HTTPS gateway address on port 8093. This is not the reader URL.'),
button('Create 48-hour invitation', async () => {
const parsed = (() => { try { return new URL(url.value); } catch { return null; } })();
if (!parsed || parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.search || parsed.hash || parsed.pathname !== '/') {
url.setCustomValidity('Enter an HTTPS peer gateway origin, without a path.'); url.reportValidity(); return;
}
const result = await command({ command: 'invite', url: url.value }, 'Invitation created. Send it privately to the other admin.');
if (result?.invitation) { pendingInvitation = JSON.stringify(result.invitation, null, 2); pendingUrl = url.value; draw(); }
}, '', busy), field('Invitation to send', outgoing),
button('Copy invitation', async () => { try { await navigator.clipboard.writeText(outgoing.value); note('Invitation copied. It contains a one-use setup code; share it privately.'); } catch { note('Copy failed; select and copy the invitation text.', true); } }, '', !pendingInvitation));
const receive = h('section', { class: 'peer-step' }, h('h3', {}, '2 · Receive and import their invitation'),
h('p', { class: 'muted small' }, 'This tells your server which certificate and gateway belong to the other server. Compare the displayed fingerprint with the other admin through a separate channel.'),
field('Invitation received', incoming), button('Import invitation', async () => {
let invitation; try { invitation = JSON.parse(incoming.value); } catch { note('That is not valid invitation JSON.', true); return; }
const result = await command({ command: 'import', invitation }, 'Invitation imported. Next, verify the other server’s identity.');
if (result?.peers) {
const match = result.peers.find(p => p.fingerprint === invitation.fingerprint);
if (match) setRoute(`#admin/peering/${encodeURIComponent(match.id)}`);
}
}, 'primary', busy));
panel.append(h('div', { class: 'peer-wizard-grid' }, send, receive),
h('p', { class: 'muted small' }, 'An invitation expires after 48 hours. It does not expire an established peering connection or the server certificate.'));
return panel;
}
let pendingUrl = '';
function connectionPanel(peer) {
const remote = access.get(peer.id);
const panel = h('section', { class: 'peer-step' }, h('h3', {}, 'Connection and identity'),
h('p', { class: peer.paired ? 'peer-status-ok' : 'peer-status-pending' }, peer.paired ? 'This server has verified the peer certificate.' : 'Import the other server’s invitation before verifying it.'),
h('p', { class: 'muted small' }, `Our fingerprint: ${state.identity?.fingerprint || 'certificate not created'}`),
h('p', { class: 'muted small' }, `Their fingerprint: ${peer.fingerprint}`),
h('p', { class: 'muted small' }, remote?.paired ? 'The other server has verified this server too.' : remote?.error ? `Other side: ${remote.error}` : 'The other server has not confirmed its side yet.'),
peer.paired ? null : button('Verify the other server', () => command({ command: 'probe', id: peer.id }, 'Identity verified here. Ask the other admin to verify back.'), 'primary', busy),
button('Refresh other-side status', () => refreshAccess(peer.id), '', busy));
if (!peer.paired || !remote?.paired) {
const url = h('input', { type: 'url', placeholder: 'https://this-server:8093', value: pendingUrl, class: 'grow' });
const invitation = h('textarea', { rows: '6', readonly: true, placeholder: 'Create your invitation to send to the other admin.', 'aria-label': 'Invitation to send' }, pendingInvitation);
const received = h('textarea', { rows: '5', placeholder: 'Paste the other server’s invitation JSON.', 'aria-label': 'Invitation received' });
panel.append(h('details', { class: 'peer-setup-again', open: !peer.paired }, h('summary', {}, 'Continue the two-way invitation exchange'),
h('div', { class: 'peer-wizard-grid' },
h('section', { class: 'peer-step' }, h('h4', {}, 'Send from this server'), field('Gateway URL', url),
button('Create invitation', async () => {
const parsed = (() => { try { return new URL(url.value); } catch { return null; } })();
if (!parsed || parsed.protocol !== 'https:' || parsed.pathname !== '/') return note('Enter this server’s HTTPS gateway origin.', true);
const result = await command({ command: 'invite', url: url.value });
if (result?.invitation) { pendingInvitation = JSON.stringify(result.invitation, null, 2); pendingUrl = url.value; draw(); }
}), field('JSON to send', invitation), button('Copy invitation', async () => { try { await navigator.clipboard.writeText(invitation.value); note('Invitation copied privately.'); } catch { note('Copy failed; select and copy it.', true); } }, '', !pendingInvitation)),
h('section', { class: 'peer-step' }, h('h4', {}, 'Receive from this server'), field('Their invitation', received),
button('Import invitation', async () => { let value; try { value = JSON.parse(received.value); } catch { return note('Invalid invitation JSON.', true); }
const result = await command({ command: 'import', invitation: value }, 'Invitation imported. Verify this server next.');
if (result?.peers) setRoute(`#admin/peering/${encodeURIComponent(peer.id)}`);
})))));
}
return panel;
}
function grantPanel(peer) {
const panel = h('section', { class: 'peer-step' }, h('h3', {}, 'Access between servers'),
h('div', { class: 'peer-wizard-grid' }));
const grid = panel.lastChild;
const catalog = h('input', { type: 'checkbox', checked: (peer.grantScopes || []).includes('catalog') || (peer.grantScopes || []).includes('download') });
const download = h('input', { type: 'checkbox', checked: (peer.grantScopes || []).includes('download') });
catalog.onchange = () => { if (!catalog.checked) download.checked = false; };
download.onchange = () => { if (download.checked) catalog.checked = true; };
const token = h('textarea', { rows: '3', readonly: true, placeholder: 'Issue a grant to create a one-time token.', 'aria-label': 'Grant token' }, pendingGrant?.id === peer.id ? pendingGrant.token : '');
const outbound = h('section', { class: 'peer-step' }, h('h4', {}, 'We grant access to ', peerTag(peer.alias)),
h('p', { class: 'muted small' }, `Status: ${peer.grantStatus}. Expires: ${date(peer.grantExpiresAt)}. Pausing preserves the grant; revoke only when you want to invalidate it.`),
h('label', { class: 'check' }, catalog, h('span', {}, 'Browse approved catalog')),
h('label', { class: 'check' }, download, h('span', {}, 'Download chapters (includes catalog access)')),
peer.grantStatus === 'none' || peer.grantStatus === 'expired'
? button('Issue grant', async () => { const result = await command({ command: 'grant', id: peer.id,
scopes: [...(catalog.checked ? ['catalog'] : []), ...(download.checked ? ['download'] : [])] }, 'Grant issued; send the token privately.');
if (result?.grant) { pendingGrant = { id: peer.id, token: result.grant.token }; draw(); } }, 'primary', !peer.paired || (!catalog.checked && !download.checked) || busy)
: button('Save grant permissions', () => command({ command: 'set', id: peer.id, patch: { grantScopes: [...(catalog.checked ? ['catalog'] : []), ...(download.checked ? ['download'] : [])] } }, 'Grant permissions updated'), '', (!catalog.checked && !download.checked) || busy),
peer.grantStatus === 'active' ? button('Pause access', () => command({ command: 'set', id: peer.id, patch: { servingPaused: true } }, 'Access paused; grant retained'))
: peer.grantStatus === 'paused' ? button('Resume access', () => command({ command: 'set', id: peer.id, patch: { servingPaused: false } }, 'Access resumed')) : null,
field('Grant token · copy once and send privately', token),
button('Copy grant token', async () => { try { await navigator.clipboard.writeText(token.value); note('Grant token copied privately.'); } catch { note('Copy failed; select and copy it.', true); } }, '', !token.value),
peer.grantStatus !== 'none' ? button('Revoke grant', async () => {
if (!confirm(`Revoke access for ${peer.alias}? Their saved token will stop working. You can issue a new grant later.`)) return;
pendingGrant = null; await command({ command: 'revoke-grant', id: peer.id }, 'Grant revoked.');
}, 'danger small') : null);
const inbound = h('section', { class: 'peer-step' }, h('h4', {}, peerTag(peer.alias), ' grants access to us'),
h('p', { class: 'muted small' }, remoteGrantText(access.get(peer.id))),
h('label', { class: 'check' }, h('input', { type: 'checkbox', checked: !!peer.receive, disabled: !peer.hasGrant,
onchange: event => command({ command: 'set', id: peer.id, patch: { receive: event.target.checked } }, 'Local download permission saved') }),
h('span', {}, 'Allow this server to download from them')),
field('Grant token received', h('input', { type: 'password', autocomplete: 'off', placeholder: 'Paste their grant token', 'aria-label': 'Grant token received' })),
button('Import their grant', async () => {
const input = panel.querySelector('[aria-label="Grant token received"]');
if (await command({ command: 'accept-grant', id: peer.id, token: input.value }, 'Their grant imported')) {
input.value = ''; access.delete(peer.id); await refreshAccess(peer.id);
}
}, 'primary', busy),
button('Refresh their grant status', () => refreshAccess(peer.id), '', busy),
h('p', { class: 'muted small' }, `Allow ordinary readers to request queue items: ${peer.readerQueue ? 'yes' : 'no'}.`),
h('label', { class: 'check' }, h('input', { type: 'checkbox', checked: !!peer.readerQueue,
onchange: event => command({ command: 'set', id: peer.id, patch: { readerQueue: event.target.checked } }, 'Reader queue permission saved') }),
h('span', {}, 'Let ordinary readers queue downloads')));
grid.append(outbound, inbound); return panel;
}
function remoteGrantText(value) {
if (!value) return 'Refresh to ask the other server for its current access status.';
if (value.error) return `Status unavailable: ${value.error}`;
const grant = value.grant || {};
const scopes = grant.scopes || [];
const role = scopes.includes('download') ? 'Browse + download' : scopes.includes('catalog') ? 'Browse catalog' : 'No access grant';
return `${value.paired ? 'Identity verified' : 'The other server has not verified us'} · ${role} · ${grant.status || 'unknown'} · expires ${date(grant.expiresAt)}`;
}
function limitPanel(peer) {
const limits = peer.limits || {}, download = peer.download || {};
const concurrent = h('input', { type: 'number', min: '1', max: '8', value: limits.concurrent || 2 });
const upload = h('input', { type: 'number', min: '1', max: '100', value: Math.round((limits.bytesPerSecond || 2097152) / 1048576) });
const daily = h('input', { type: 'number', min: '1', value: Math.round((limits.bytesPerDay || 10737418240) / 1073741824) });
const inFlight = h('input', { type: 'number', min: '1', max: '4', value: download.concurrent || 2 });
const inbound = h('input', { type: 'number', min: '1', max: '100', value: Math.round((download.bytesPerSecond || 2097152) / 1048576) });
return h('details', { class: 'card peer-card peer-details' }, h('summary', {}, 'Transfer limits'),
h('p', { class: 'muted small' }, `These are local caps for ${peer.alias}; the other server’s upload cap still applies.`),
h('div', { class: 'peer-limits-grid' }, field('Upload streams to them', concurrent), field('Upload MiB/s', upload), field('Upload GiB/day', daily),
field('Download pages in flight', inFlight), field('Download MiB/s', inbound)),
button('Save limits', () => command({ command: 'set', id: peer.id, patch: {
limits: { concurrent: +concurrent.value, bytesPerSecond: Math.round(+upload.value * 1048576), bytesPerDay: Math.round(+daily.value * 1073741824) },
download: { concurrent: +inFlight.value, bytesPerSecond: Math.round(+inbound.value * 1048576) },
} }, 'Transfer limits saved')));
}
function managePeer(peer) {
const remote = access.get(peer.id), name = h('input', { value: peer.alias, maxlength: '25', pattern: '[A-Za-z0-9]{1,25}' });
return h('div', { class: 'peer-workspace' },
h('div', { class: 'peer-section-head' }, h('div', {}, h('p', { class: 'muted small' }, 'Settings / Peering / Peer'), h('h2', {}, peerTag(peer.alias))),
button('Back to peers', () => setRoute('#admin/peering'))),
h('section', { class: 'card peer-card' }, h('h3', {}, 'Peer name and address'),
h('div', { class: 'peer-manage-name' }, field('Local name for this peer', name), button('Save name', () => command({ command: 'set', id: peer.id, patch: { alias: name.value } }, 'Peer name saved'))),
h('p', { class: 'muted small' }, `${peer.url} · certificate ${peer.fingerprint}`)),
connectionPanel(peer), grantPanel(peer), limitPanel(peer),
h('section', { class: 'peer-danger-zone' }, button('Remove peer', async () => {
if (!confirm(`Remove ${peer.alias}, delete its grants and pause future transfers? Already downloaded chapters remain.`)) return;
await command({ command: 'remove', id: peer.id }, 'Peer removed'); setRoute('#admin/peering');
}, 'danger small')));
}
function draw() {
const open = new Set([...main.querySelectorAll('details[data-peer-section][open]')].map(item => item.dataset.peerSection));
main.textContent = '';
const parts = location.hash.split('/');
route = parts[1] === 'peering' ? decodeURIComponent(parts[2] || '') : '';
const status = h('p', { class: messageError ? 'error' : 'muted small', role: 'status', 'aria-live': 'polite' }, messageText);
const tabs = h('div', { class: 'peer-page-tabs' }, h('a', { class: 'btn', href: 'peers.html#admin' }, 'Peering settings'),
h('a', { class: 'btn', href: 'peer-catalog.html' }, 'Peer catalogs'));
main.append(tabs, status);
if (route === 'add') main.append(invitationWizard());
else if (route) {
const peer = peers.find(item => item.id === decodeURIComponent(route));
if (!peer) main.append(button('Back to peers', () => setRoute('#admin/peering')), h('p', { class: 'muted' }, 'Peer not found.'));
else main.append(managePeer(peer));
} else main.append(serverIdentityPanel(), peerTable());
for (const details of main.querySelectorAll('details[data-peer-section]')) details.open = open.has(details.dataset.peerSection);
}
window.addEventListener('hashchange', () => { draw(); const peer = peers.find(item => item.id === decodeURIComponent(route)); if (peer) refreshAccess(peer.id); });
await reload(); draw();
const first = peers.find(peer => peer.id === decodeURIComponent(location.hash.split('/')[2] || ''));
if (first) refreshAccess(first.id);
})().catch(showError);
+2
View File
@@ -7,6 +7,8 @@ const SETTINGS_SECTIONS = [
{ id: 'user/notifications', group: 'User', title: 'Notifications', hint: 'Chapter alerts and your optional webhook.' },
{ id: 'user/account', group: 'User', title: 'Account', hint: 'Your account and password.' },
{ id: 'admin/scraper', group: 'Admin', title: 'Scraper', hint: 'Live activity, downloads, update checks and sources.' },
{ id: 'admin/peering', group: 'Admin', title: 'Peering', hint: 'Pair servers and manage their permissions.', href: 'peers.html#admin' },
{ id: 'admin/peer-catalog', group: 'Admin', title: 'Peer catalogs', hint: 'Approve titles to share and browse peer libraries.', href: 'peer-catalog.html' },
{ id: 'admin/library', group: 'Admin', title: 'Server & library', hint: 'Library location, scan status and maintenance.' },
{ id: 'admin/users', group: 'Admin', title: 'Users & access', hint: 'Manage accounts, passwords and administrators.' },
{ id: 'admin/collections', group: 'Admin', title: 'Collections & metadata', hint: 'Group manga and review their relationships.', href: 'collections.html' },
+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');
+207
View File
@@ -0,0 +1,207 @@
#!/usr/bin/env node
'use strict';
// Disposable, two-project Compose acceptance. No live library, db or queue is mounted.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const fsp = fs.promises;
const os = require('node:os');
const path = require('node:path');
const crypto = require('node:crypto');
const net = require('node:net');
const { spawn } = require('node:child_process');
const { PeeringState } = require('../peering/state');
const { Canvas } = require('./png');
const ROOT = path.resolve(__dirname, '..');
const APP_IMAGE = process.env.PEER_AB_IMAGE || `mangacircus:peer-ab-${crypto.randomBytes(3).toString('hex')}`;
const TEST_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'mangacircus-peer-ab-'));
const projects = ['mc-peer-a-' + crypto.randomBytes(3).toString('hex'), 'mc-peer-b-' + crypto.randomBytes(3).toString('hex')];
const OPENSSL_BIN = process.env.OPENSSL_BIN || (process.platform === 'win32' ? 'C:\\Program Files\\Git\\usr\\bin\\openssl.exe' : 'openssl');
process.env.OPENSSL_BIN = OPENSSL_BIN;
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
async function freePort() {
const server = net.createServer();
await new Promise((resolve, reject) => server.once('error', reject).listen(0, '127.0.0.1', resolve));
const port = server.address().port;
await new Promise(resolve => server.close(resolve));
return port;
}
function command(bin, args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(bin, args, { cwd: ROOT, windowsHide: true, env: options.env || process.env, stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '', stderr = '';
child.stdout.on('data', data => stdout += data);
child.stderr.on('data', data => stderr += data);
child.on('error', reject);
child.once('exit', code => code === 0 ? resolve(stdout.trim()) : reject(new Error(`${bin} ${args[0]} failed (${code}): ${stderr.slice(-4000)}`)));
});
}
function composeArgs(project) { return ['compose', '-p', project, '-f', path.join(ROOT, 'docker-compose.yml'), '--profile', 'peering']; }
async function docker(project, env, ...args) { return command('docker', [...composeArgs(project), ...args], { env }); }
async function until(check, message, attempts = 120) {
let last;
for (let i = 0; i < attempts; i++) {
try { if (await check()) return; } catch (error) { last = error; }
await sleep(500);
}
throw new Error(`${message}${last ? `: ${last.message}` : ''}`);
}
async function setup(label, readerPort, peerPort) {
const root = path.join(TEST_ROOT, label), library = path.join(root, 'library'), data = path.join(root, 'data');
const scraper = path.join(data, 'scraper'), sentinel = path.join(data, 'sentinel'), peerData = path.join(data, 'peering');
const usage = path.join(data, 'peer-usage'), token = path.join(root, 'scraper-token');
for (const dir of [library, scraper, sentinel, peerData, usage]) await fsp.mkdir(dir, { recursive: true });
await fsp.writeFile(path.join(scraper, 'queue.json'), JSON.stringify({ paused: true, queue: [], settings: { autoUpdateEnabled: false } }));
await fsp.writeFile(token, crypto.randomBytes(32).toString('hex'), { mode: 0o600 });
const png = new Canvas(180, 270, [55, 105, 150]).png();
const manga = `${label === 'a' ? 'Peer A' : 'Peer B'} Test Manga`;
await fsp.writeFile(path.join(library, '.fixture'), '');
const mangaDir = path.join(library, manga);
await fsp.mkdir(mangaDir);
await fsp.writeFile(path.join(mangaDir, 'cover.png'), png);
await fsp.writeFile(path.join(mangaDir, 'manga.json'), JSON.stringify({ title: manga, year: 2024,
status: 'ongoing', description: 'A disposable peer test title.', tags: ['Fixture', 'Peering'], genres: ['Test'], direction: 'ttb' }));
for (let n = 1; n <= 2; n++) {
const chapter = path.join(mangaDir, 'v01', `c${String(n).padStart(4, '0')} Chapter ${n}`);
await fsp.mkdir(chapter, { recursive: true });
await fsp.writeFile(path.join(chapter, 'chapter.json'), JSON.stringify({ title: `Chapter ${n}`, source: { name: 'LocalFixture' } }));
await fsp.writeFile(path.join(chapter, '0001.png'), png);
}
new PeeringState(peerData).generateIdentity(label === 'a' ? 'PeerA' : 'PeerB');
const env = { ...process.env, APP_IMAGE, VCS_REF: 'peer-redesign-test', LIBRARY_PATH: library, DATA_PATH: data,
SCRAPER_DATA_PATH: scraper, SENTINEL_DATA_PATH: sentinel, PEER_DATA_PATH: peerData, PEER_USAGE_PATH: usage,
SCRAPER_TOKEN_PATH: token, READER_PORT: String(readerPort), READER_BIND: '127.0.0.1',
PEER_PORT: String(peerPort), PEER_BIND: '127.0.0.1', COOKIE_SECURE: '0', TZ: 'UTC' };
return { label, root, library, data, peerData, manga, readerPort, peerPort, env, url: `http://127.0.0.1:${readerPort}` };
}
async function json(url, cookie, body) {
const response = await fetch(url, { headers: { ...(cookie ? { cookie } : {}), 'content-type': 'application/json' },
...(body === undefined ? {} : { method: 'POST', body: JSON.stringify(body) }) });
const data = response.headers.get('content-type')?.includes('application/json') ? await response.json() : await response.arrayBuffer();
return { response, data };
}
async function login(instance) {
const { response } = await json(`${instance.url}/api/signup`, null, { name: 'qa', password: 'DisposablePeerTest42' });
assert.equal(response.status, 200, `${instance.label}: first admin signup`);
return response.headers.get('set-cookie').split(';')[0];
}
async function browserCheck(instance, cookie, peerId, shareId) {
const { chromium } = require('playwright');
const browser = await chromium.launch({ channel: process.env.QA_BROWSER_CHANNEL || 'msedge', headless: true });
try {
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const [name, ...value] = cookie.split('='); await context.addCookies([{ name, value: value.join('='), url: instance.url }]);
const page = await context.newPage(), errors = []; page.on('pageerror', error => errors.push(error.message));
await page.goto(`${instance.url}/peer-catalog.html?peer=${encodeURIComponent(peerId)}#browse`);
await page.getByRole('heading', { name: 'Peer catalogs', exact: true }).waitFor();
await page.locator('.peer-catalog-card').filter({ hasText: 'Peer A Test Manga' }).waitFor();
assert.equal(await page.locator('.peer-cover').count(), 1, 'Remote cover must render in the catalog');
await page.getByRole('button', { name: 'Details', exact: true }).click();
const dialog = page.getByRole('dialog');
await dialog.getByText('A disposable peer test title.').waitFor();
assert.equal(await dialog.locator('.peer-chapter-option').count(), 2);
await dialog.locator('.peer-chapter-picker summary').click();
await dialog.getByLabel('Queue scope').selectOption('after');
await dialog.getByLabel('Chapter threshold').waitFor({ state: 'visible' });
const exactQueue = dialog.getByRole('button', { name: 'Queue checked chapters' });
assert.equal(await exactQueue.isDisabled(), true, 'Exact chapter queue starts disabled');
await dialog.getByLabel('Select chapter 1').check();
assert.equal(await exactQueue.isDisabled(), false);
await dialog.getByLabel('Select chapter 1').uncheck();
await dialog.getByRole('button', { name: 'Close details' }).click();
await page.getByRole('checkbox', { name: 'Select Peer A Test Manga' }).check();
await page.getByRole('button', { name: 'Queue 1 missing' }).click();
await page.getByText(/1 manga queued; 0 unavailable or failed/).waitFor();
assert.equal(errors.length, 0, `Peer catalog browser errors: ${errors.join('; ')}`);
} finally { await browser.close(); }
}
async function main() {
let started = [], succeeded = false;
try {
const ports = [];
while (ports.length < 4) { const value = await freePort(); if (!ports.includes(value)) ports.push(value); }
const [portA, portB, gateA, gateB] = ports;
const A = await setup('a', portA, gateA), B = await setup('b', portB, gateB);
const args = ['build', '-t', APP_IMAGE, '--build-arg', 'VCS_REF=peer-redesign-test', '.'];
await command('docker', args);
for (const [index, instance] of [A, B].entries()) {
const project = projects[index];
await docker(project, instance.env, 'config', '-q');
await docker(project, instance.env, 'up', '-d'); started.push({ project, env: instance.env });
await until(async () => (await fetch(`${instance.url}/health`)).ok, `${instance.label}: reader health`);
await until(async () => new Promise(resolve => {
const https = require('node:https');
https.get({ hostname: '127.0.0.1', port: instance.peerPort, path: '/health', rejectUnauthorized: false }, res => { res.resume(); resolve(res.statusCode === 200); }).on('error', () => resolve(false));
}), `${instance.label}: peer gateway health`);
}
const cookieA = await login(A), cookieB = await login(B);
const stateA = (await json(`${A.url}/api/admin/peering`, cookieA)).data;
const stateB = (await json(`${B.url}/api/admin/peering`, cookieB)).data;
const hostUrlA = `https://host.docker.internal:${gateA}/`, hostUrlB = `https://host.docker.internal:${gateB}/`;
const inviteA = (await json(`${A.url}/api/admin/peering`, cookieA, { command: 'invite', url: hostUrlA })).data.invitation;
const inviteB = (await json(`${B.url}/api/admin/peering`, cookieB, { command: 'invite', url: hostUrlB })).data.invitation;
let importedA = await json(`${A.url}/api/admin/peering`, cookieA, { command: 'import', invitation: inviteB });
let importedB = await json(`${B.url}/api/admin/peering`, cookieB, { command: 'import', invitation: inviteA });
assert.equal(importedA.response.status, 200); assert.equal(importedB.response.status, 200);
const peerA = importedA.data.peers.find(peer => peer.fingerprint === inviteB.fingerprint);
const peerB = importedB.data.peers.find(peer => peer.fingerprint === inviteA.fingerprint);
assert.ok(peerA && peerB);
const probeA = await json(`${A.url}/api/admin/peering`, cookieA, { command: 'probe', id: peerA.id });
const probeB = await json(`${B.url}/api/admin/peering`, cookieB, { command: 'probe', id: peerB.id });
if (probeA.response.status !== 200 || probeB.response.status !== 200) {
console.error('Peer probe responses:', JSON.stringify({ a: probeA.data, b: probeB.data }));
for (const [index, instance] of [A, B].entries()) {
console.error(`${instance.label} gateway logs:\n${await docker(projects[index], instance.env, 'logs', '--tail', '60', 'peer-gateway').catch(() => '(unavailable)')}`);
console.error(`${instance.label} reader logs:\n${await docker(projects[index], instance.env, 'logs', '--tail', '60', 'reader').catch(() => '(unavailable)')}`);
}
}
assert.equal(probeA.response.status, 200); assert.equal(probeB.response.status, 200);
assert.equal(probeA.data.peers.find(peer => peer.id === peerA.id).paired, true);
assert.equal(probeB.data.peers.find(peer => peer.id === peerB.id).paired, true);
assert.ok(stateA.identity?.fingerprint && stateB.identity?.fingerprint);
let libraryA = [];
await until(async () => { libraryA = (await json(`${A.url}/api/admin/peering`, cookieA)).data.library || []; return libraryA.some(item => item.title === A.manga); }, 'A fixture manga was not scanned');
const latestLibraryA = (await json(`${A.url}/api/admin/peering`, cookieA)).data.library || libraryA;
const mangaA = latestLibraryA.find(item => item.title === A.manga);
assert.ok(mangaA, 'A fixture manga is indexed');
let saved = await json(`${A.url}/api/admin/peering`, cookieA, { command: 'share-set', id: peerA.id, mangaIds: [mangaA.id] });
assert.equal(saved.response.status, 200); assert.equal(saved.data.peers.find(peer => peer.id === peerA.id).shares.length, 1);
const grant = await json(`${A.url}/api/admin/peering`, cookieA, { command: 'grant', id: peerA.id, scopes: ['catalog'] });
assert.equal(grant.response.status, 200);
assert.equal((await json(`${B.url}/api/admin/peering`, cookieB, { command: 'accept-grant', id: peerB.id, token: grant.data.grant.token })).response.status, 200);
const catalog = await json(`${B.url}/api/peering/catalog?peer=${encodeURIComponent(peerB.id)}`, cookieB);
assert.equal(catalog.response.status, 200); assert.equal(catalog.data.items.length, 1);
const shareId = catalog.data.items[0].id;
const cover = await fetch(`${B.url}/api/peering/cover?peer=${encodeURIComponent(peerB.id)}&manga=${shareId}`, { headers: { cookie: cookieB } });
assert.equal(cover.status, 200); assert.match(cover.headers.get('content-type'), /^image\/png/);
const denied = await json(`${B.url}/api/peering/queue`, cookieB, { peer: peerB.id, manga: shareId, scope: { mode: 'missing' } });
assert.equal(denied.response.status, 403, 'Catalog grant alone must not permit chapter queueing');
const updateGrant = await json(`${A.url}/api/admin/peering`, cookieA, { command: 'set', id: peerA.id, patch: { grantScopes: ['download'] } });
assert.equal(updateGrant.response.status, 200);
const accessB = await json(`${B.url}/api/peering/access?peer=${encodeURIComponent(peerB.id)}`, cookieB);
assert.deepEqual(accessB.data.grant.scopes, ['catalog', 'download'], 'Grant scope updates must not need token reimport');
const receiving = await json(`${B.url}/api/admin/peering`, cookieB, { command: 'set', id: peerB.id, patch: { receive: true } });
assert.equal(receiving.response.status, 200);
await browserCheck(B, cookieB, peerB.id, shareId);
const queue = (await json(`${B.url}/api/admin/scraper`, cookieB)).data;
assert.equal(queue.paused, true, 'Peer testing must leave the scraper queue paused');
assert.equal(queue.queue.filter(item => item.url === `peer://${peerB.id}/${shareId}`).length, 1, 'Exactly one peer queue item is added');
const volumes = await Promise.all(started.map(({ project, env }) => docker(project, env, 'ps', '--format', '{{.Service}}')));
for (const services of volumes) for (const name of ['reader', 'scraper', 'sentinel', 'peer-gateway']) assert.ok(services.includes(name), `${name} is running in A and B`);
console.log('PASS: isolated Compose A↔B pairing, catalog-only access, cover proxy, grant update, browser details/bulk queue, one paused queue writer, and admin-only API/UI boundary.');
console.log(`Test Compose projects: ${projects.join(', ')}; disposable roots: ${TEST_ROOT}`);
succeeded = true;
} finally {
if (!succeeded && process.argv.includes('--keep-on-failure')) console.error(`Failure fixtures retained for cleanup: ${TEST_ROOT}; projects: ${projects.join(', ')}`);
else {
for (const { project, env } of [...started].reverse()) await docker(project, env, 'down').catch(error => console.error(`Cleanup warning for ${project}: ${error.message}`));
await fsp.rm(TEST_ROOT, { recursive: true, force: true });
if (!process.env.PEER_AB_IMAGE) await command('docker', ['image', 'rm', APP_IMAGE]).catch(() => {});
}
}
}
main().catch(error => { console.error(error.stack || error); process.exitCode = 1; });
+51 -38
View File
@@ -12,61 +12,74 @@ module.exports = async function testPeerUI(url, cookie) {
const page = await context.newPage(), errors = [];
page.on('pageerror', error => errors.push(error.message));
try {
await page.goto(url + '/peers.html');
await page.getByRole('heading', { name: 'Peer catalogs', exact: true }).waitFor();
let state = await page.evaluate(() => fetch('/api/admin/peering').then(response => response.json()));
if (!state.identity) {
await page.getByRole('button', { name: 'Generate local certificate' }).click();
await page.getByText('Identity generated').waitFor();
state = await page.evaluate(() => fetch('/api/admin/peering').then(response => response.json()));
}
assert.match(state.identity.fingerprint, /^[0-9a-f]{64}$/);
await page.getByLabel('Reachable peer-gateway URL').fill('http://localhost:8080');
await page.goto(url + '/peers.html#admin');
await page.getByRole('heading', { name: 'Known peers' }).waitFor();
assert.ok(await page.getByRole('link', { name: 'Peer catalogs' }).count() >= 1);
const state = await page.evaluate(() => fetch('/api/admin/peering').then(response => response.json()));
assert.equal(state.identity?.name, 'ComposeQA');
await page.getByRole('button', { name: 'Add a peer' }).click();
await page.getByRole('heading', { name: 'Add a peer' }).waitFor();
await page.getByLabel('This server’s reachable peer gateway URL').fill('http://localhost:8080');
await page.getByRole('button', { name: 'Create 48-hour invitation' }).click();
assert.match(await page.getByLabel('Reachable peer-gateway URL').evaluate(el => el.validationMessage), /HTTPS peer-gateway origin/);
await page.getByLabel('Reachable peer-gateway URL').fill('https://peer-test.example:8093');
assert.match(await page.getByLabel('This server’s reachable peer gateway URL').evaluate(el => el.validationMessage), /HTTPS peer gateway origin/);
await page.getByLabel('This server’s reachable peer gateway URL').fill('https://peer-test.example:8093');
await page.getByRole('button', { name: 'Create 48-hour invitation' }).click();
await page.waitForFunction(() => document.querySelector('textarea[aria-label="Invitation to exchange"]')?.value.includes('fingerprint'));
const invitation = JSON.parse(await page.getByLabel('Invitation to exchange').inputValue());
const outgoing = page.getByLabel('Invitation to send');
await page.waitForFunction(() => document.querySelector('textarea[aria-label="Invitation to send"]')?.value.includes('fingerprint'));
const invitation = JSON.parse(await outgoing.inputValue());
assert.equal(invitation.url, 'https://peer-test.example:8093');
assert.equal(invitation.fingerprint, state.identity.fingerprint);
await page.getByLabel('Other server invitation').fill('{bad json');
assert.match(invitation.name, /^[A-Za-z0-9]{1,25}$/);
assert.ok(Date.parse(invitation.expiresAt) > Date.now());
await page.getByLabel('Invitation received').fill('{bad json');
await page.getByRole('button', { name: 'Import invitation' }).click();
await page.getByText('Invalid invitation JSON').waitFor();
await page.getByText('That is not valid invitation JSON.').waitFor();
const peerDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-peer-ui-'));
let peerId;
try {
if (process.platform === 'win32' && !process.env.OPENSSL_BIN) process.env.OPENSSL_BIN = 'C:\\Program Files\\Git\\usr\\bin\\openssl.exe';
const peer = new PeeringState(peerDir);
peer.generateIdentity('UIFixturePeer');
const fixtureInvitation = peer.createInvitation('https://fixture-peer.example:8093');
await page.getByLabel('Other server invitation').fill(JSON.stringify(fixtureInvitation));
await page.getByLabel('Invitation received').fill(JSON.stringify(fixtureInvitation));
await page.getByRole('button', { name: 'Import invitation' }).click();
await page.getByRole('heading', { name: 'UI fixture peer' }).waitFor();
const card = page.locator('.peer-card').filter({ has: page.getByRole('heading', { name: 'UI fixture peer' }) });
await card.getByText('Transfer limits').click();
await card.getByText('Approved manga (0)').click();
await card.getByText('Access grant').click();
await card.getByLabel('Moon Garden').check();
await card.getByText('Approved manga (1)').waitFor();
assert.equal(await card.locator('details[open]').count(), 3, 'Saving a share must preserve all expanded sections');
await page.getByRole('heading', { name: '<UIFixturePeer>', exact: true }).waitFor();
const apiState = await page.evaluate(() => fetch('/api/peering').then(response => response.json()));
peerId = apiState.peers.find(item => item.alias === 'UIFixturePeer').id;
await page.locator('.peer-page-tabs').getByRole('link', { name: 'Peer catalogs' }).click();
await page.getByRole('heading', { name: 'Peer catalogs' }).waitFor();
if (!await page.getByRole('button', { name: 'Select visible' }).count()) {
console.error('Peer catalog page content:', await page.locator('main').innerText());
console.error('Peer catalog JS errors:', errors);
}
await page.getByLabel('Select peer').selectOption(peerId);
assert.equal(await page.getByText('Setup is incomplete', { exact: false }).count(), 0, 'Sharing can be prepared before pairing');
await page.getByRole('button', { name: 'Select visible' }).click();
await page.getByRole('button', { name: 'Save approvals' }).click();
await page.getByText('Approved titles updated.').waitFor();
const saved = await page.evaluate(id => fetch('/api/admin/peering').then(r => r.json()).then(s => s.peers.find(p => p.id === id).shares.length), peerId);
assert.equal(saved, 3, 'Bulk approval saves the selected local catalog');
assert.equal(await page.getByRole('button', { name: 'Browse peer catalog' }).count(), 1);
await page.getByRole('button', { name: 'Browse peer catalog' }).click();
await page.getByText('Setup is incomplete', { exact: false }).waitFor();
await page.setViewportSize({ width: 390, height: 844 });
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), 'Peer catalog should fit mobile width');
} finally { fs.rmSync(peerDir, { recursive: true, force: true }); }
assert.equal(errors.length, 0, `Browser errors: ${errors.join('; ')}`);
await page.setViewportSize({ width: 390, height: 844 });
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), 'Peering page overflows on mobile');
// An ordinary reader may browse, but cannot operate certificate controls.
const created = await page.evaluate(async () => {
const response = await fetch('/api/admin/user', { method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name: 'peer-reader-qa', password: 'disposable-reader-test', admin: false }) });
return response.status;
});
const created = await page.evaluate(async () => (await fetch('/api/admin/user', { method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name: 'peer-reader-qa', password: 'disposable-reader-test', admin: false }) })).status);
assert.equal(created, 200);
await page.evaluate(() => fetch('/api/logout', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }));
await page.evaluate(() => fetch('/api/login', { method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name: 'peer-reader-qa', password: 'disposable-reader-test' }) }));
await page.reload();
await page.getByRole('heading', { name: 'Peer catalogs', exact: true }).waitFor();
assert.equal(await page.getByRole('heading', { name: 'Server identity & invitations' }).count(), 0);
assert.equal((await page.evaluate(() => fetch('/api/admin/peering').then(response => response.status))), 403);
await page.goto(url + '/peer-catalog.html');
await page.getByText('Peer catalogs are available to administrators only.').waitFor();
const denied = await page.evaluate(async () => Promise.all(['/api/admin/peering', '/api/peering', '/api/peering/catalog?peer=x', '/api/peering/cover?peer=x&manga=x']
.map(async endpoint => (await fetch(endpoint)).status)));
assert.deepEqual(denied, [403, 403, 403, 403], 'Every peering API remains admin-only');
await page.goto(url + '/');
await page.getByRole('link', { name: 'Peer catalogs' }).waitFor({ state: 'detached' });
assert.equal(errors.length, 0, `Browser errors: ${errors.join('; ')}`);
} finally { await browser.close(); }
};