From da40d73f0241fe88976d7c642cfe8a3094e9f00e Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 11 Oct 2026 01:06:03 +0200 Subject: [PATCH] Redesign admin peering settings and catalogs --- docs/peering.md | 79 +++++++---- package.json | 5 +- peering/client.js | 23 ++- peering/gateway.js | 37 ++++- peering/state.js | 22 +-- peering/test/peering.test.js | 8 ++ public/app.css | 63 +++++++++ public/common.js | 1 + public/peer-catalog.html | 10 ++ public/peer-catalog.js | 185 +++++++++++++++++++++++++ public/peers.html | 10 ++ public/peers.js | 262 +++++++++++++++++++++++++++++++++++ public/settings-nav.js | 2 + server.js | 119 ++++++++++++++++ tools/test-peer-ab.js | 207 +++++++++++++++++++++++++++ tools/test-peer-ui.js | 89 +++++++----- 16 files changed, 1037 insertions(+), 85 deletions(-) create mode 100644 public/peer-catalog.html create mode 100644 public/peer-catalog.js create mode 100644 public/peers.html create mode 100644 public/peers.js create mode 100644 tools/test-peer-ab.js diff --git a/docs/peering.md b/docs/peering.md index ab0b8da..2d272ea 100644 --- a/docs/peering.md +++ b/docs/peering.md @@ -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. diff --git a/package.json b/package.json index df568ef..cdbc8a7 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/peering/client.js b/peering/client.js index bf5a39b..c1293de 100644 --- a/peering/client.js +++ b/peering/client.js @@ -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 }; diff --git a/peering/gateway.js b/peering/gateway.js index dce367c..e18716f 100644 --- a/peering/gateway.js +++ b/peering/gateway.js @@ -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 }); } diff --git a/peering/state.js b/peering/state.js index 492601e..69103fd 100644 --- a/peering/state.js +++ b/peering/state.js @@ -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()); } diff --git a/peering/test/peering.test.js b/peering/test/peering.test.js index 01aded0..4f02026 100644 --- a/peering/test/peering.test.js +++ b/peering/test/peering.test.js @@ -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, diff --git a/public/app.css b/public/app.css index 0157d37..bb8acf1 100644 --- a/public/app.css +++ b/public/app.css @@ -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%; } diff --git a/public/common.js b/public/common.js index f3a0c51..5e86564 100644 --- a/public/common.js +++ b/public/common.js @@ -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); diff --git a/public/peer-catalog.html b/public/peer-catalog.html new file mode 100644 index 0000000..a0b4157 --- /dev/null +++ b/public/peer-catalog.html @@ -0,0 +1,10 @@ + + + + +Peer catalogs · MangaCircus + + + +

Loading peer catalogs…

+ diff --git a/public/peer-catalog.js b/public/peer-catalog.js new file mode 100644 index 0000000..261ef9b --- /dev/null +++ b/public/peer-catalog.js @@ -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); diff --git a/public/peers.html b/public/peers.html new file mode 100644 index 0000000..37427a7 --- /dev/null +++ b/public/peers.html @@ -0,0 +1,10 @@ + + + + +Peering settings · MangaCircus + + + +

Loading…

+ diff --git a/public/peers.js b/public/peers.js new file mode 100644 index 0000000..c96fd0a --- /dev/null +++ b/public/peers.js @@ -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); diff --git a/public/settings-nav.js b/public/settings-nav.js index db50a1e..a8292a2 100644 --- a/public/settings-nav.js +++ b/public/settings-nav.js @@ -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' }, diff --git a/server.js b/server.js index fca1428..c156cd3 100644 --- a/server.js +++ b/server.js @@ -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'); diff --git a/tools/test-peer-ab.js b/tools/test-peer-ab.js new file mode 100644 index 0000000..cf58c4b --- /dev/null +++ b/tools/test-peer-ab.js @@ -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; }); diff --git a/tools/test-peer-ui.js b/tools/test-peer-ui.js index 7ceb9d3..8e51bdd 100644 --- a/tools/test-peer-ui.js +++ b/tools/test-peer-ui.js @@ -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: '', 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(); } };