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