Redesign admin peering settings and catalogs
This commit is contained in:
1 parent
492477f86a
commit
da40d73f02
16 files changed
+1037
-85
No files matched your search
+49
-30
@@ -11,21 +11,30 @@ received chapters. No database server or second library writer is introduced.
|
|||||||
|
|
||||||
## Roles and direction
|
## Roles and direction
|
||||||
|
|
||||||
The admin UI is **Settings → Peering**, and all users can open the **Peers** catalog
|
The interface is administrator-only and split by task: **Settings → Admin → Peering**
|
||||||
page. Each server may have many peers. For every peer, its admin independently sets:
|
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
|
- **Grant scopes**: Catalog permits catalog, covers and metadata. Download includes
|
||||||
peer. Off is the default and invalidates the current serving grant.
|
Catalog and permits chapter transfers. Each direction has its own grant.
|
||||||
- **Allow downloads**: queue and pull chapters from that peer. Off is the default;
|
- **Serving state**: Active or Paused. Pausing preserves the grant/token; explicit
|
||||||
a verified pairing and imported grant still allow browsing its approved catalog.
|
Revoke invalidates it. Scope changes take effect without exchanging a new token.
|
||||||
- **Let readers queue**: ordinary readers may request transfers; otherwise only an
|
- **Approved titles**: the publisher chooses titles on Peer catalogs, not in the
|
||||||
admin may queue. Browsing a permitted catalog does not disclose the local user to
|
connection settings. Pairing or a grant alone does not share a title.
|
||||||
the remote server.
|
- **Local receiving** is a separate switch. Browsing works with Catalog permission
|
||||||
- Upload streams, bytes/s and bytes/day, and receiving pages in flight and bytes/s.
|
even when local receiving is off; downloads require an active Download grant and
|
||||||
Defaults are two in-flight pages and 2 MiB/s in each direction per peer. The
|
local receiving enabled.
|
||||||
publisher's caps remain authoritative. A gateway-wide four-stream ceiling applies.
|
- 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
|
## 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.
|
past one instance for the same usage-counter directory.
|
||||||
|
|
||||||
1. Build the image and start the normal stack as documented in
|
1. Build the image and start the normal stack as documented in
|
||||||
[`compose-deployment.md`](compose-deployment.md). Generate the local certificate
|
[`compose-deployment.md`](compose-deployment.md). Set the local server name and
|
||||||
in **Settings → Peering**. Keep `/data/peering/identity.key` private and backed up.
|
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
|
2. Start the optional service with `docker compose --profile peering up -d
|
||||||
peer-gateway`. Verify `docker compose --profile peering ps` shows healthy and
|
peer-gateway`. Verify `docker compose --profile peering ps` shows healthy and
|
||||||
`https://127.0.0.1:8093/health` responds. `/health` is deliberately unauthenticated
|
`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**.
|
2. Both admins import the other's invitation and press **Test & verify pairing**.
|
||||||
The HTTPS handshake presents client and server certificates, checks their exact
|
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.
|
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
|
3. A publisher approves titles for that peer on **Peer catalogs**, chooses Catalog
|
||||||
90-day random grant, and sends the token privately to the receiving admin. The
|
or Download grant scopes, and sends the one-time token privately. The receiving
|
||||||
receiving admin imports the grant and enables **Allow downloads**. Reverse
|
admin imports it and independently enables local receiving for downloads. Reverse
|
||||||
direction needs its own share selection and grant. The server stores only a hash
|
direction needs its own approvals and grant. The server stores only a hash of a
|
||||||
of a grant it issued; the receiver stores its imported token server-side.
|
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
|
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
|
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
|
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
|
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
|
## Peer v1 HTTP interface
|
||||||
|
|
||||||
All content endpoints require TLS 1.3 client certificate pinning, a paired/serving
|
Catalog, cover, manifest and blob endpoints require TLS 1.3 client certificate
|
||||||
peer, and a currently valid certificate-bound bearer grant. There is no insecure
|
pinning, paired identity, an active grant with the matching scope, and no serving
|
||||||
HTTP fallback and no path-based file browsing.
|
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 |
|
| Request | Response |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `GET /peer/v1/hello` with one-use invitation headers | Protocol version and server identity fingerprint; no catalog |
|
| `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/access` | Pairing state, certificate expiry and current grant status/scopes; no catalog |
|
||||||
| `GET /peer/v1/manga/{id}?cursor=N` | Up to 100 complete chapter IDs and light metadata |
|
| `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/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 |
|
| `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
|
`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,
|
tests mTLS pairing, no-cert/bad-grant rejection, private catalog, per-title sharing,
|
||||||
manifest hashes, a resumed range response, scraper publication, duplicate skip and
|
catalog-only cover access, manifest hashes, a resumed range response, scraper
|
||||||
revocation. `npm test` includes it. An isolated Compose acceptance is:
|
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
|
```powershell
|
||||||
$env:APP_IMAGE = 'mangacircus:local' # replace with the image just built
|
$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.
|
to recover from a network error; that would break every peer pin.
|
||||||
|
|
||||||
Current limits: no automatic certificate renewal, invitation cancellation UI,
|
Current limits: no automatic certificate renewal, invitation cancellation UI,
|
||||||
peer cover-image endpoint, multi-peer performance pilot, VM deployment, or public
|
multi-peer performance pilot, VM deployment, or public internet exposure review.
|
||||||
internet exposure review. The gateway has no automated hoster-interaction logic;
|
The gateway has no automated hoster-interaction logic;
|
||||||
source scraping remains subject to the existing source-safety policy.
|
source scraping remains subject to the existing source-safety policy.
|
||||||
+4
-1
@@ -7,6 +7,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
"start:scraper": "node scraper/daemon.js",
|
"start:scraper": "node scraper/daemon.js",
|
||||||
|
"start:peer": "node peering/gateway.js",
|
||||||
"start:all": "node tools/start-all.js",
|
"start:all": "node tools/start-all.js",
|
||||||
"start:sentinel": "node sentinel/daemon.js",
|
"start:sentinel": "node sentinel/daemon.js",
|
||||||
"check": "node tools/check.js",
|
"check": "node tools/check.js",
|
||||||
@@ -17,7 +18,9 @@
|
|||||||
"test:direction-ui": "node tools/test-compose.js --direction-ui",
|
"test:direction-ui": "node tools/test-compose.js --direction-ui",
|
||||||
"demo": "node tools/make-dummy.js library",
|
"demo": "node tools/make-dummy.js library",
|
||||||
"scrape": "node scraper/cli.js",
|
"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:reader": "node --test test/*.test.js",
|
||||||
"test:scraper": "node --test scraper/test/*.test.js"
|
"test:scraper": "node --test scraper/test/*.test.js"
|
||||||
},
|
},
|
||||||
|
|||||||
+19
-4
@@ -39,7 +39,7 @@ function connection(store, id, needGrant = true, requireReceive = false) {
|
|||||||
return { peer, agent, token };
|
return { peer, agent, token };
|
||||||
}
|
}
|
||||||
function request(store, id, endpoint, options = {}) {
|
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);
|
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');
|
if (target.origin !== base.origin || !target.pathname.startsWith('/peer/v1/')) throw new Error('Invalid peer endpoint');
|
||||||
return new Promise((resolve, reject) => {
|
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');
|
|| !Array.isArray(result.grant?.scopes)) throw new Error('Invalid peer access status');
|
||||||
return result;
|
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) {
|
async function catalog(store, id) {
|
||||||
const items = []; let cursor = null;
|
const items = []; let cursor = null;
|
||||||
for (let page = 0; page < 100; page++) {
|
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)}` : ''}`);
|
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');
|
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;
|
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');
|
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; }
|
if (stat && stat.size > page.bytes) { await fsp.rm(file, { force: true }); stat = null; }
|
||||||
const start = stat?.size || 0;
|
const start = stat?.size || 0;
|
||||||
if (shouldStop?.()) throw new Error('Peer transfer paused');
|
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)) {
|
if (res.statusCode !== (start ? 206 : 200)) {
|
||||||
const body = await readResponse(res, 4096).catch(() => Buffer.alloc(0));
|
const body = await readResponse(res, 4096).catch(() => Buffer.alloc(0));
|
||||||
let message; try { message = JSON.parse(body.toString()).error; } catch { message = 'Transfer failed'; }
|
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 };
|
return { reused: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { connection, jsonRequest, probe, access, catalog, manga, chapter, downloadPage };
|
module.exports = { connection, jsonRequest, probe, access, catalog, manga, cover, chapter, downloadPage };
|
||||||
+33
-4
@@ -18,6 +18,7 @@ const peering = new PeeringState(DATA);
|
|||||||
const identity = peering.identity();
|
const identity = peering.identity();
|
||||||
if (!identity) throw new Error('Generate peering identity in the admin UI before starting the gateway');
|
if (!identity) throw new Error('Generate peering identity in the admin UI before starting the gateway');
|
||||||
const MAX_PAGE = 30 * 1024 * 1024;
|
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 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'));
|
const usageFile = path.resolve(process.env.PEER_USAGE_FILE || path.join(DATA, 'gateway-usage.json'));
|
||||||
fs.mkdirSync(path.dirname(usageFile), { recursive: true, mode: 0o700 });
|
fs.mkdirSync(path.dirname(usageFile), { recursive: true, mode: 0o700 });
|
||||||
@@ -76,6 +77,21 @@ async function dirs(root) {
|
|||||||
catch { return []; }
|
catch { return []; }
|
||||||
}
|
}
|
||||||
async function jsonFile(file) { try { return JSON.parse(await fsp.readFile(file, 'utf8')); } 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) {
|
async function mangaIndex(peer, share) {
|
||||||
const key = `${peer.id}:${share.id}`;
|
const key = `${peer.id}:${share.id}`;
|
||||||
const old = indexCache.get(key);
|
const old = indexCache.get(key);
|
||||||
@@ -169,6 +185,18 @@ async function streamBlob(req, res, peer, id) {
|
|||||||
res.end();
|
res.end();
|
||||||
} finally { active.set(peer.id, Math.max(0, (active.get(peer.id) || 1) - 1)); }
|
} 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) => {
|
const server = https.createServer({ key: identity.key, cert: identity.cert, requestCert: true, rejectUnauthorized: false, minVersion: 'TLSv1.3' }, async (req, res) => {
|
||||||
try {
|
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 });
|
return json(res, 200, { version: 1, id: identity.id, fingerprint: identity.fingerprint });
|
||||||
}
|
}
|
||||||
if (url.pathname === '/peer/v1/access') {
|
if (url.pathname === '/peer/v1/access') {
|
||||||
if (!peer.paired) fail(403, 'Verify peer identity first');
|
return json(res, 200, { version: 1, paired: !!peer.paired, name: peering.load().name,
|
||||||
return json(res, 200, { version: 1, paired: true, name: peering.load().name,
|
|
||||||
certificateExpiresAt: new crypto.X509Certificate(identity.cert).validTo,
|
certificateExpiresAt: new crypto.X509Certificate(identity.cert).validTo,
|
||||||
grant: grantStatus(peer) });
|
grant: grantStatus(peer) });
|
||||||
}
|
}
|
||||||
@@ -204,10 +231,12 @@ const server = https.createServer({ key: identity.key, cert: identity.cert, requ
|
|||||||
if (!mangaDir) continue;
|
if (!mangaDir) continue;
|
||||||
const meta = await jsonFile(path.join(mangaDir, 'manga.json'));
|
const meta = await jsonFile(path.join(mangaDir, 'manga.json'));
|
||||||
items.push({ id: share.id, title: String(meta.title || share.mangaId).slice(0, 200),
|
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 });
|
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);
|
const mangaMatch = /^\/peer\/v1\/manga\/(m[0-9a-f]{24})$/.exec(url.pathname);
|
||||||
if (mangaMatch) {
|
if (mangaMatch) {
|
||||||
quota(peer, 0, true);
|
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);
|
const cursor = Number(url.searchParams.get('cursor') || 0);
|
||||||
if (!Number.isSafeInteger(cursor) || cursor < 0) fail(400, 'Invalid cursor');
|
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),
|
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 })),
|
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 });
|
nextCursor: cursor + 100 < index.chapters.length ? String(cursor + 100) : null });
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-8
@@ -54,9 +54,9 @@ function serverName(value) {
|
|||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
function normalizeScopes(value) {
|
function normalizeScopes(value) {
|
||||||
const scopes = [...new Set((Array.isArray(value) ? value : []).map(String))].filter(scope => GRANT_SCOPES.has(scope));
|
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');
|
if (scopes.has('download')) scopes.add('catalog');
|
||||||
return scopes;
|
return [...GRANT_SCOPES].filter(scope => scopes.has(scope));
|
||||||
}
|
}
|
||||||
function scopesFor(peer) {
|
function scopesFor(peer) {
|
||||||
return Array.isArray(peer.grantScopes) ? normalizeScopes(peer.grantScopes) : ['catalog', 'download'];
|
return Array.isArray(peer.grantScopes) ? normalizeScopes(peer.grantScopes) : ['catalog', 'download'];
|
||||||
@@ -190,12 +190,18 @@ class PeeringState {
|
|||||||
return publicPeer(peer, this.outbound());
|
return publicPeer(peer, this.outbound());
|
||||||
}
|
}
|
||||||
setShare(id, mangaId, enabled) {
|
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);
|
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');
|
if (!Array.isArray(mangaIds) || mangaIds.length > 5000) throw new Error('Choose no more than 5,000 manga');
|
||||||
peer.shares ||= [];
|
const unique = [...new Set(mangaIds)];
|
||||||
const existing = peer.shares.find(s => s.mangaId === mangaId);
|
if (unique.some(mangaId => typeof mangaId !== 'string' || !mangaId || mangaId.startsWith('.') || mangaId.includes('/') || mangaId.includes('\\')))
|
||||||
if (enabled && !existing) peer.shares.push({ mangaId, id: `m${crypto.randomBytes(12).toString('hex')}` });
|
throw new Error('Invalid manga ID');
|
||||||
if (!enabled) peer.shares = peer.shares.filter(s => s.mangaId !== mangaId);
|
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);
|
atomic(this.stateFile(), state);
|
||||||
return publicPeer(peer, this.outbound());
|
return publicPeer(peer, this.outbound());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ test('mTLS pairing, opt-in catalog, peer queue publication, and revocation', { t
|
|||||||
const chapterDir = path.join(mangaDir, 'v01', 'c0001 [Local]');
|
const chapterDir = path.join(mangaDir, 'v01', 'c0001 [Local]');
|
||||||
await fsp.mkdir(chapterDir, { recursive: true });
|
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, '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, 'chapter.json'), JSON.stringify({ title: 'The first chapter', source: { name: 'Local' } }));
|
||||||
await fsp.writeFile(path.join(chapterDir, '0001.png'), PNG);
|
await fsp.writeFile(path.join(chapterDir, '0001.png'), PNG);
|
||||||
const ga = await gateway(dataA, libA, portA, path.join(root, 'usage-a.json'));
|
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);
|
const listing = await client.catalog(b, inB.id);
|
||||||
assert.equal(listing.length, 1);
|
assert.equal(listing.length, 1);
|
||||||
assert.equal(listing[0].title, 'Sample Manga');
|
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);
|
assert.equal(JSON.stringify(listing).includes('Sample Manga\\v01'), false);
|
||||||
const remote = await client.manga(b, inB.id, listing[0].id);
|
const remote = await client.manga(b, inB.id, listing[0].id);
|
||||||
assert.equal(remote.chapters.length, 1);
|
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);
|
const remoteAccess = await client.access(b, inB.id);
|
||||||
assert.equal(remoteAccess.grant.status, 'active');
|
assert.equal(remoteAccess.grant.status, 'active');
|
||||||
assert.deepEqual(remoteAccess.grant.scopes, ['catalog']);
|
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/);
|
/Peer HTTP 403: Grant does not allow download/);
|
||||||
a.updatePeer(inA.id, { grantScopes: ['catalog', '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');
|
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/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/);
|
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,
|
const noCert = await new Promise((resolve, reject) => https.get({ hostname: '127.0.0.1', port: portA,
|
||||||
|
|||||||
@@ -216,6 +216,69 @@ main { max-width: 1200px; margin: 0 auto; padding: 16px; }
|
|||||||
.settings-title-row { display: flex; align-items: center; gap: 12px; }
|
.settings-title-row { display: flex; align-items: center; gap: 12px; }
|
||||||
.settings-hint { margin: 5px 0 20px; }
|
.settings-hint { margin: 5px 0 20px; }
|
||||||
.settings-content .card { max-width: none; }
|
.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 { min-width: 0; margin: 0; padding: 0; border: 0; }
|
||||||
.catalog-workspace > .row { margin-bottom: 16px; }
|
.catalog-workspace > .row { margin-bottom: 16px; }
|
||||||
.catalog-workspace input, .catalog-workspace select, .catalog-workspace textarea { min-width: 0; max-width: 100%; }
|
.catalog-workspace input, .catalog-workspace select, .catalog-workspace textarea { min-width: 0; max-width: 100%; }
|
||||||
|
|||||||
@@ -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('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: 'grow' }),
|
||||||
h('div', { class: 'bell-wrap' }, bell, panel),
|
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('a', { class: 'btn', href: 'settings.html' }, 'Settings'),
|
||||||
h('button', { class: 'btn', onclick: async () => { await api('api/logout', {}); location.href = 'login.html'; } }, 'Log out'));
|
h('button', { class: 'btn', onclick: async () => { await api('api/logout', {}); location.href = 'login.html'; } }, 'Log out'));
|
||||||
document.body.prepend(nav);
|
document.body.prepend(nav);
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<title>Peer catalogs · MangaCircus</title>
|
||||||
|
<link rel="stylesheet" href="app.css"><link rel="icon" href="icon.svg">
|
||||||
|
<script src="common.js" defer></script><script src="peer-catalog.js" defer></script>
|
||||||
|
</head>
|
||||||
|
<body><main><p class="loading">Loading peer catalogs…</p></main><dialog id="peer-manga-dialog" class="peer-manga-dialog"></dialog></body>
|
||||||
|
</html>
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<title>Peering settings · MangaCircus</title>
|
||||||
|
<link rel="stylesheet" href="app.css"><link rel="icon" href="icon.svg">
|
||||||
|
<script src="common.js" defer></script><script src="settings-nav.js" defer></script><script src="peers.js" defer></script>
|
||||||
|
</head>
|
||||||
|
<body><main><p class="loading">Loading…</p></main></body>
|
||||||
|
</html>
|
||||||
+262
@@ -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);
|
||||||
@@ -7,6 +7,8 @@ const SETTINGS_SECTIONS = [
|
|||||||
{ id: 'user/notifications', group: 'User', title: 'Notifications', hint: 'Chapter alerts and your optional webhook.' },
|
{ 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: '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/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/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/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' },
|
{ id: 'admin/collections', group: 'Admin', title: 'Collections & metadata', hint: 'Group manga and review their relationships.', href: 'collections.html' },
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ const { RATINGS: GREATEST_RATINGS, loadCatalog: loadGreatestCatalog, findOwned:
|
|||||||
countsFor: greatestCounts, scoreFor: greatestScore } = require('./greatest');
|
countsFor: greatestCounts, scoreFor: greatestScore } = require('./greatest');
|
||||||
const { hosterLinks } = require('./greatest/hosters');
|
const { hosterLinks } = require('./greatest/hosters');
|
||||||
const { secret } = require('./runtime/config');
|
const { secret } = require('./runtime/config');
|
||||||
|
const { PeeringState, requirePeer } = require('./peering/state');
|
||||||
|
const peerClient = require('./peering/client');
|
||||||
const {
|
const {
|
||||||
applyPulse, applyVisit, chapterInsight, eligiblePages, emptyRecord, libraryInsight, mangaInsight, publicSummary,
|
applyPulse, applyVisit, chapterInsight, eligiblePages, emptyRecord, libraryInsight, mangaInsight, publicSummary,
|
||||||
} = require('./reading-analytics');
|
} = 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_URL = String(process.env.SCRAPER_URL || 'http://127.0.0.1:8091').replace(/\/$/, '');
|
||||||
const SCRAPER_TOKEN = secret('SCRAPER_TOKEN');
|
const SCRAPER_TOKEN = secret('SCRAPER_TOKEN');
|
||||||
const SENTINEL_URL = String(process.env.SENTINEL_URL || 'http://127.0.0.1:8092').replace(/\/$/, '');
|
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 PUB = path.join(__dirname, 'public');
|
||||||
const IMG = /\.(jpe?g|png|webp|gif|avif|bmp)$/i;
|
const IMG = /\.(jpe?g|png|webp|gif|avif|bmp)$/i;
|
||||||
const MIME = {
|
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; } }
|
class HttpError extends Error { constructor(code, msg) { super(msg); this.code = code; } }
|
||||||
function bad(code, msg) { throw new HttpError(code, msg); }
|
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 ----------
|
// ---------- users & sessions ----------
|
||||||
const sha = s => crypto.createHash('sha256').update(s).digest('hex');
|
const sha = s => crypto.createHash('sha256').update(s).digest('hex');
|
||||||
@@ -626,6 +638,101 @@ const routes = {
|
|||||||
save();
|
save();
|
||||||
return { ok: true };
|
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 }) => {
|
'GET /api/admin/scraper': async ({ u }) => {
|
||||||
admin(u);
|
admin(u);
|
||||||
try { return { available: true, ...(await scraperRequest('/api/state')) }; }
|
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');
|
if (!c?.customCover) bad(404, 'Cover not found');
|
||||||
return serveFile(req, res, path.join(DATA, 'collection-covers'), c.customCover, 'private, no-cache', IMG);
|
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/')) {
|
if (pathname.startsWith('/api/')) {
|
||||||
const route = routes[req.method + ' ' + pathname];
|
const route = routes[req.method + ' ' + pathname];
|
||||||
if (!route) bad(404, 'Unknown endpoint');
|
if (!route) bad(404, 'Unknown endpoint');
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Disposable, two-project Compose acceptance. No live library, db or queue is mounted.
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const fsp = fs.promises;
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const crypto = require('node:crypto');
|
||||||
|
const net = require('node:net');
|
||||||
|
const { spawn } = require('node:child_process');
|
||||||
|
const { PeeringState } = require('../peering/state');
|
||||||
|
const { Canvas } = require('./png');
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..');
|
||||||
|
const APP_IMAGE = process.env.PEER_AB_IMAGE || `mangacircus:peer-ab-${crypto.randomBytes(3).toString('hex')}`;
|
||||||
|
const TEST_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'mangacircus-peer-ab-'));
|
||||||
|
const projects = ['mc-peer-a-' + crypto.randomBytes(3).toString('hex'), 'mc-peer-b-' + crypto.randomBytes(3).toString('hex')];
|
||||||
|
const OPENSSL_BIN = process.env.OPENSSL_BIN || (process.platform === 'win32' ? 'C:\\Program Files\\Git\\usr\\bin\\openssl.exe' : 'openssl');
|
||||||
|
process.env.OPENSSL_BIN = OPENSSL_BIN;
|
||||||
|
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
async function freePort() {
|
||||||
|
const server = net.createServer();
|
||||||
|
await new Promise((resolve, reject) => server.once('error', reject).listen(0, '127.0.0.1', resolve));
|
||||||
|
const port = server.address().port;
|
||||||
|
await new Promise(resolve => server.close(resolve));
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
function command(bin, args, options = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(bin, args, { cwd: ROOT, windowsHide: true, env: options.env || process.env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
let stdout = '', stderr = '';
|
||||||
|
child.stdout.on('data', data => stdout += data);
|
||||||
|
child.stderr.on('data', data => stderr += data);
|
||||||
|
child.on('error', reject);
|
||||||
|
child.once('exit', code => code === 0 ? resolve(stdout.trim()) : reject(new Error(`${bin} ${args[0]} failed (${code}): ${stderr.slice(-4000)}`)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function composeArgs(project) { return ['compose', '-p', project, '-f', path.join(ROOT, 'docker-compose.yml'), '--profile', 'peering']; }
|
||||||
|
async function docker(project, env, ...args) { return command('docker', [...composeArgs(project), ...args], { env }); }
|
||||||
|
async function until(check, message, attempts = 120) {
|
||||||
|
let last;
|
||||||
|
for (let i = 0; i < attempts; i++) {
|
||||||
|
try { if (await check()) return; } catch (error) { last = error; }
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
throw new Error(`${message}${last ? `: ${last.message}` : ''}`);
|
||||||
|
}
|
||||||
|
async function setup(label, readerPort, peerPort) {
|
||||||
|
const root = path.join(TEST_ROOT, label), library = path.join(root, 'library'), data = path.join(root, 'data');
|
||||||
|
const scraper = path.join(data, 'scraper'), sentinel = path.join(data, 'sentinel'), peerData = path.join(data, 'peering');
|
||||||
|
const usage = path.join(data, 'peer-usage'), token = path.join(root, 'scraper-token');
|
||||||
|
for (const dir of [library, scraper, sentinel, peerData, usage]) await fsp.mkdir(dir, { recursive: true });
|
||||||
|
await fsp.writeFile(path.join(scraper, 'queue.json'), JSON.stringify({ paused: true, queue: [], settings: { autoUpdateEnabled: false } }));
|
||||||
|
await fsp.writeFile(token, crypto.randomBytes(32).toString('hex'), { mode: 0o600 });
|
||||||
|
const png = new Canvas(180, 270, [55, 105, 150]).png();
|
||||||
|
const manga = `${label === 'a' ? 'Peer A' : 'Peer B'} Test Manga`;
|
||||||
|
await fsp.writeFile(path.join(library, '.fixture'), '');
|
||||||
|
const mangaDir = path.join(library, manga);
|
||||||
|
await fsp.mkdir(mangaDir);
|
||||||
|
await fsp.writeFile(path.join(mangaDir, 'cover.png'), png);
|
||||||
|
await fsp.writeFile(path.join(mangaDir, 'manga.json'), JSON.stringify({ title: manga, year: 2024,
|
||||||
|
status: 'ongoing', description: 'A disposable peer test title.', tags: ['Fixture', 'Peering'], genres: ['Test'], direction: 'ttb' }));
|
||||||
|
for (let n = 1; n <= 2; n++) {
|
||||||
|
const chapter = path.join(mangaDir, 'v01', `c${String(n).padStart(4, '0')} Chapter ${n}`);
|
||||||
|
await fsp.mkdir(chapter, { recursive: true });
|
||||||
|
await fsp.writeFile(path.join(chapter, 'chapter.json'), JSON.stringify({ title: `Chapter ${n}`, source: { name: 'LocalFixture' } }));
|
||||||
|
await fsp.writeFile(path.join(chapter, '0001.png'), png);
|
||||||
|
}
|
||||||
|
new PeeringState(peerData).generateIdentity(label === 'a' ? 'PeerA' : 'PeerB');
|
||||||
|
const env = { ...process.env, APP_IMAGE, VCS_REF: 'peer-redesign-test', LIBRARY_PATH: library, DATA_PATH: data,
|
||||||
|
SCRAPER_DATA_PATH: scraper, SENTINEL_DATA_PATH: sentinel, PEER_DATA_PATH: peerData, PEER_USAGE_PATH: usage,
|
||||||
|
SCRAPER_TOKEN_PATH: token, READER_PORT: String(readerPort), READER_BIND: '127.0.0.1',
|
||||||
|
PEER_PORT: String(peerPort), PEER_BIND: '127.0.0.1', COOKIE_SECURE: '0', TZ: 'UTC' };
|
||||||
|
return { label, root, library, data, peerData, manga, readerPort, peerPort, env, url: `http://127.0.0.1:${readerPort}` };
|
||||||
|
}
|
||||||
|
async function json(url, cookie, body) {
|
||||||
|
const response = await fetch(url, { headers: { ...(cookie ? { cookie } : {}), 'content-type': 'application/json' },
|
||||||
|
...(body === undefined ? {} : { method: 'POST', body: JSON.stringify(body) }) });
|
||||||
|
const data = response.headers.get('content-type')?.includes('application/json') ? await response.json() : await response.arrayBuffer();
|
||||||
|
return { response, data };
|
||||||
|
}
|
||||||
|
async function login(instance) {
|
||||||
|
const { response } = await json(`${instance.url}/api/signup`, null, { name: 'qa', password: 'DisposablePeerTest42' });
|
||||||
|
assert.equal(response.status, 200, `${instance.label}: first admin signup`);
|
||||||
|
return response.headers.get('set-cookie').split(';')[0];
|
||||||
|
}
|
||||||
|
async function browserCheck(instance, cookie, peerId, shareId) {
|
||||||
|
const { chromium } = require('playwright');
|
||||||
|
const browser = await chromium.launch({ channel: process.env.QA_BROWSER_CHANNEL || 'msedge', headless: true });
|
||||||
|
try {
|
||||||
|
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||||
|
const [name, ...value] = cookie.split('='); await context.addCookies([{ name, value: value.join('='), url: instance.url }]);
|
||||||
|
const page = await context.newPage(), errors = []; page.on('pageerror', error => errors.push(error.message));
|
||||||
|
await page.goto(`${instance.url}/peer-catalog.html?peer=${encodeURIComponent(peerId)}#browse`);
|
||||||
|
await page.getByRole('heading', { name: 'Peer catalogs', exact: true }).waitFor();
|
||||||
|
await page.locator('.peer-catalog-card').filter({ hasText: 'Peer A Test Manga' }).waitFor();
|
||||||
|
assert.equal(await page.locator('.peer-cover').count(), 1, 'Remote cover must render in the catalog');
|
||||||
|
await page.getByRole('button', { name: 'Details', exact: true }).click();
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await dialog.getByText('A disposable peer test title.').waitFor();
|
||||||
|
assert.equal(await dialog.locator('.peer-chapter-option').count(), 2);
|
||||||
|
await dialog.locator('.peer-chapter-picker summary').click();
|
||||||
|
await dialog.getByLabel('Queue scope').selectOption('after');
|
||||||
|
await dialog.getByLabel('Chapter threshold').waitFor({ state: 'visible' });
|
||||||
|
const exactQueue = dialog.getByRole('button', { name: 'Queue checked chapters' });
|
||||||
|
assert.equal(await exactQueue.isDisabled(), true, 'Exact chapter queue starts disabled');
|
||||||
|
await dialog.getByLabel('Select chapter 1').check();
|
||||||
|
assert.equal(await exactQueue.isDisabled(), false);
|
||||||
|
await dialog.getByLabel('Select chapter 1').uncheck();
|
||||||
|
await dialog.getByRole('button', { name: 'Close details' }).click();
|
||||||
|
await page.getByRole('checkbox', { name: 'Select Peer A Test Manga' }).check();
|
||||||
|
await page.getByRole('button', { name: 'Queue 1 missing' }).click();
|
||||||
|
await page.getByText(/1 manga queued; 0 unavailable or failed/).waitFor();
|
||||||
|
assert.equal(errors.length, 0, `Peer catalog browser errors: ${errors.join('; ')}`);
|
||||||
|
} finally { await browser.close(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
let started = [], succeeded = false;
|
||||||
|
try {
|
||||||
|
const ports = [];
|
||||||
|
while (ports.length < 4) { const value = await freePort(); if (!ports.includes(value)) ports.push(value); }
|
||||||
|
const [portA, portB, gateA, gateB] = ports;
|
||||||
|
const A = await setup('a', portA, gateA), B = await setup('b', portB, gateB);
|
||||||
|
const args = ['build', '-t', APP_IMAGE, '--build-arg', 'VCS_REF=peer-redesign-test', '.'];
|
||||||
|
await command('docker', args);
|
||||||
|
for (const [index, instance] of [A, B].entries()) {
|
||||||
|
const project = projects[index];
|
||||||
|
await docker(project, instance.env, 'config', '-q');
|
||||||
|
await docker(project, instance.env, 'up', '-d'); started.push({ project, env: instance.env });
|
||||||
|
await until(async () => (await fetch(`${instance.url}/health`)).ok, `${instance.label}: reader health`);
|
||||||
|
await until(async () => new Promise(resolve => {
|
||||||
|
const https = require('node:https');
|
||||||
|
https.get({ hostname: '127.0.0.1', port: instance.peerPort, path: '/health', rejectUnauthorized: false }, res => { res.resume(); resolve(res.statusCode === 200); }).on('error', () => resolve(false));
|
||||||
|
}), `${instance.label}: peer gateway health`);
|
||||||
|
}
|
||||||
|
const cookieA = await login(A), cookieB = await login(B);
|
||||||
|
const stateA = (await json(`${A.url}/api/admin/peering`, cookieA)).data;
|
||||||
|
const stateB = (await json(`${B.url}/api/admin/peering`, cookieB)).data;
|
||||||
|
const hostUrlA = `https://host.docker.internal:${gateA}/`, hostUrlB = `https://host.docker.internal:${gateB}/`;
|
||||||
|
const inviteA = (await json(`${A.url}/api/admin/peering`, cookieA, { command: 'invite', url: hostUrlA })).data.invitation;
|
||||||
|
const inviteB = (await json(`${B.url}/api/admin/peering`, cookieB, { command: 'invite', url: hostUrlB })).data.invitation;
|
||||||
|
let importedA = await json(`${A.url}/api/admin/peering`, cookieA, { command: 'import', invitation: inviteB });
|
||||||
|
let importedB = await json(`${B.url}/api/admin/peering`, cookieB, { command: 'import', invitation: inviteA });
|
||||||
|
assert.equal(importedA.response.status, 200); assert.equal(importedB.response.status, 200);
|
||||||
|
const peerA = importedA.data.peers.find(peer => peer.fingerprint === inviteB.fingerprint);
|
||||||
|
const peerB = importedB.data.peers.find(peer => peer.fingerprint === inviteA.fingerprint);
|
||||||
|
assert.ok(peerA && peerB);
|
||||||
|
const probeA = await json(`${A.url}/api/admin/peering`, cookieA, { command: 'probe', id: peerA.id });
|
||||||
|
const probeB = await json(`${B.url}/api/admin/peering`, cookieB, { command: 'probe', id: peerB.id });
|
||||||
|
if (probeA.response.status !== 200 || probeB.response.status !== 200) {
|
||||||
|
console.error('Peer probe responses:', JSON.stringify({ a: probeA.data, b: probeB.data }));
|
||||||
|
for (const [index, instance] of [A, B].entries()) {
|
||||||
|
console.error(`${instance.label} gateway logs:\n${await docker(projects[index], instance.env, 'logs', '--tail', '60', 'peer-gateway').catch(() => '(unavailable)')}`);
|
||||||
|
console.error(`${instance.label} reader logs:\n${await docker(projects[index], instance.env, 'logs', '--tail', '60', 'reader').catch(() => '(unavailable)')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.equal(probeA.response.status, 200); assert.equal(probeB.response.status, 200);
|
||||||
|
assert.equal(probeA.data.peers.find(peer => peer.id === peerA.id).paired, true);
|
||||||
|
assert.equal(probeB.data.peers.find(peer => peer.id === peerB.id).paired, true);
|
||||||
|
assert.ok(stateA.identity?.fingerprint && stateB.identity?.fingerprint);
|
||||||
|
|
||||||
|
let libraryA = [];
|
||||||
|
await until(async () => { libraryA = (await json(`${A.url}/api/admin/peering`, cookieA)).data.library || []; return libraryA.some(item => item.title === A.manga); }, 'A fixture manga was not scanned');
|
||||||
|
const latestLibraryA = (await json(`${A.url}/api/admin/peering`, cookieA)).data.library || libraryA;
|
||||||
|
const mangaA = latestLibraryA.find(item => item.title === A.manga);
|
||||||
|
assert.ok(mangaA, 'A fixture manga is indexed');
|
||||||
|
let saved = await json(`${A.url}/api/admin/peering`, cookieA, { command: 'share-set', id: peerA.id, mangaIds: [mangaA.id] });
|
||||||
|
assert.equal(saved.response.status, 200); assert.equal(saved.data.peers.find(peer => peer.id === peerA.id).shares.length, 1);
|
||||||
|
const grant = await json(`${A.url}/api/admin/peering`, cookieA, { command: 'grant', id: peerA.id, scopes: ['catalog'] });
|
||||||
|
assert.equal(grant.response.status, 200);
|
||||||
|
assert.equal((await json(`${B.url}/api/admin/peering`, cookieB, { command: 'accept-grant', id: peerB.id, token: grant.data.grant.token })).response.status, 200);
|
||||||
|
const catalog = await json(`${B.url}/api/peering/catalog?peer=${encodeURIComponent(peerB.id)}`, cookieB);
|
||||||
|
assert.equal(catalog.response.status, 200); assert.equal(catalog.data.items.length, 1);
|
||||||
|
const shareId = catalog.data.items[0].id;
|
||||||
|
const cover = await fetch(`${B.url}/api/peering/cover?peer=${encodeURIComponent(peerB.id)}&manga=${shareId}`, { headers: { cookie: cookieB } });
|
||||||
|
assert.equal(cover.status, 200); assert.match(cover.headers.get('content-type'), /^image\/png/);
|
||||||
|
const denied = await json(`${B.url}/api/peering/queue`, cookieB, { peer: peerB.id, manga: shareId, scope: { mode: 'missing' } });
|
||||||
|
assert.equal(denied.response.status, 403, 'Catalog grant alone must not permit chapter queueing');
|
||||||
|
const updateGrant = await json(`${A.url}/api/admin/peering`, cookieA, { command: 'set', id: peerA.id, patch: { grantScopes: ['download'] } });
|
||||||
|
assert.equal(updateGrant.response.status, 200);
|
||||||
|
const accessB = await json(`${B.url}/api/peering/access?peer=${encodeURIComponent(peerB.id)}`, cookieB);
|
||||||
|
assert.deepEqual(accessB.data.grant.scopes, ['catalog', 'download'], 'Grant scope updates must not need token reimport');
|
||||||
|
const receiving = await json(`${B.url}/api/admin/peering`, cookieB, { command: 'set', id: peerB.id, patch: { receive: true } });
|
||||||
|
assert.equal(receiving.response.status, 200);
|
||||||
|
await browserCheck(B, cookieB, peerB.id, shareId);
|
||||||
|
const queue = (await json(`${B.url}/api/admin/scraper`, cookieB)).data;
|
||||||
|
assert.equal(queue.paused, true, 'Peer testing must leave the scraper queue paused');
|
||||||
|
assert.equal(queue.queue.filter(item => item.url === `peer://${peerB.id}/${shareId}`).length, 1, 'Exactly one peer queue item is added');
|
||||||
|
const volumes = await Promise.all(started.map(({ project, env }) => docker(project, env, 'ps', '--format', '{{.Service}}')));
|
||||||
|
for (const services of volumes) for (const name of ['reader', 'scraper', 'sentinel', 'peer-gateway']) assert.ok(services.includes(name), `${name} is running in A and B`);
|
||||||
|
console.log('PASS: isolated Compose A↔B pairing, catalog-only access, cover proxy, grant update, browser details/bulk queue, one paused queue writer, and admin-only API/UI boundary.');
|
||||||
|
console.log(`Test Compose projects: ${projects.join(', ')}; disposable roots: ${TEST_ROOT}`);
|
||||||
|
succeeded = true;
|
||||||
|
} finally {
|
||||||
|
if (!succeeded && process.argv.includes('--keep-on-failure')) console.error(`Failure fixtures retained for cleanup: ${TEST_ROOT}; projects: ${projects.join(', ')}`);
|
||||||
|
else {
|
||||||
|
for (const { project, env } of [...started].reverse()) await docker(project, env, 'down').catch(error => console.error(`Cleanup warning for ${project}: ${error.message}`));
|
||||||
|
await fsp.rm(TEST_ROOT, { recursive: true, force: true });
|
||||||
|
if (!process.env.PEER_AB_IMAGE) await command('docker', ['image', 'rm', APP_IMAGE]).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
main().catch(error => { console.error(error.stack || error); process.exitCode = 1; });
|
||||||
+51
-38
@@ -12,61 +12,74 @@ module.exports = async function testPeerUI(url, cookie) {
|
|||||||
const page = await context.newPage(), errors = [];
|
const page = await context.newPage(), errors = [];
|
||||||
page.on('pageerror', error => errors.push(error.message));
|
page.on('pageerror', error => errors.push(error.message));
|
||||||
try {
|
try {
|
||||||
await page.goto(url + '/peers.html');
|
await page.goto(url + '/peers.html#admin');
|
||||||
await page.getByRole('heading', { name: 'Peer catalogs', exact: true }).waitFor();
|
await page.getByRole('heading', { name: 'Known peers' }).waitFor();
|
||||||
let state = await page.evaluate(() => fetch('/api/admin/peering').then(response => response.json()));
|
assert.ok(await page.getByRole('link', { name: 'Peer catalogs' }).count() >= 1);
|
||||||
if (!state.identity) {
|
const state = await page.evaluate(() => fetch('/api/admin/peering').then(response => response.json()));
|
||||||
await page.getByRole('button', { name: 'Generate local certificate' }).click();
|
assert.equal(state.identity?.name, 'ComposeQA');
|
||||||
await page.getByText('Identity generated').waitFor();
|
await page.getByRole('button', { name: 'Add a peer' }).click();
|
||||||
state = await page.evaluate(() => fetch('/api/admin/peering').then(response => response.json()));
|
await page.getByRole('heading', { name: 'Add a peer' }).waitFor();
|
||||||
}
|
await page.getByLabel('This server’s reachable peer gateway URL').fill('http://localhost:8080');
|
||||||
assert.match(state.identity.fingerprint, /^[0-9a-f]{64}$/);
|
|
||||||
await page.getByLabel('Reachable peer-gateway URL').fill('http://localhost:8080');
|
|
||||||
await page.getByRole('button', { name: 'Create 48-hour invitation' }).click();
|
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/);
|
assert.match(await page.getByLabel('This server’s 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');
|
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.getByRole('button', { name: 'Create 48-hour invitation' }).click();
|
||||||
await page.waitForFunction(() => document.querySelector('textarea[aria-label="Invitation to exchange"]')?.value.includes('fingerprint'));
|
const outgoing = page.getByLabel('Invitation to send');
|
||||||
const invitation = JSON.parse(await page.getByLabel('Invitation to exchange').inputValue());
|
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.url, 'https://peer-test.example:8093');
|
||||||
assert.equal(invitation.fingerprint, state.identity.fingerprint);
|
assert.match(invitation.name, /^[A-Za-z0-9]{1,25}$/);
|
||||||
await page.getByLabel('Other server invitation').fill('{bad json');
|
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.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-'));
|
const peerDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-peer-ui-'));
|
||||||
|
let peerId;
|
||||||
try {
|
try {
|
||||||
if (process.platform === 'win32' && !process.env.OPENSSL_BIN) process.env.OPENSSL_BIN = 'C:\\Program Files\\Git\\usr\\bin\\openssl.exe';
|
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);
|
const peer = new PeeringState(peerDir);
|
||||||
peer.generateIdentity('UIFixturePeer');
|
peer.generateIdentity('UIFixturePeer');
|
||||||
const fixtureInvitation = peer.createInvitation('https://fixture-peer.example:8093');
|
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('button', { name: 'Import invitation' }).click();
|
||||||
await page.getByRole('heading', { name: 'UI fixture peer' }).waitFor();
|
await page.getByRole('heading', { name: '<UIFixturePeer>', exact: true }).waitFor();
|
||||||
const card = page.locator('.peer-card').filter({ has: page.getByRole('heading', { name: 'UI fixture peer' }) });
|
const apiState = await page.evaluate(() => fetch('/api/peering').then(response => response.json()));
|
||||||
await card.getByText('Transfer limits').click();
|
peerId = apiState.peers.find(item => item.alias === 'UIFixturePeer').id;
|
||||||
await card.getByText('Approved manga (0)').click();
|
await page.locator('.peer-page-tabs').getByRole('link', { name: 'Peer catalogs' }).click();
|
||||||
await card.getByText('Access grant').click();
|
await page.getByRole('heading', { name: 'Peer catalogs' }).waitFor();
|
||||||
await card.getByLabel('Moon Garden').check();
|
if (!await page.getByRole('button', { name: 'Select visible' }).count()) {
|
||||||
await card.getByText('Approved manga (1)').waitFor();
|
console.error('Peer catalog page content:', await page.locator('main').innerText());
|
||||||
assert.equal(await card.locator('details[open]').count(), 3, 'Saving a share must preserve all expanded sections');
|
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 }); }
|
} finally { fs.rmSync(peerDir, { recursive: true, force: true }); }
|
||||||
assert.equal(errors.length, 0, `Browser errors: ${errors.join('; ')}`);
|
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');
|
const created = await page.evaluate(async () => (await fetch('/api/admin/user', { method: 'POST', headers: { 'content-type': 'application/json' },
|
||||||
// An ordinary reader may browse, but cannot operate certificate controls.
|
body: JSON.stringify({ name: 'peer-reader-qa', password: 'disposable-reader-test', admin: false }) })).status);
|
||||||
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;
|
|
||||||
});
|
|
||||||
assert.equal(created, 200);
|
assert.equal(created, 200);
|
||||||
await page.evaluate(() => fetch('/api/logout', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }));
|
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' },
|
await page.evaluate(() => fetch('/api/login', { method: 'POST', headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ name: 'peer-reader-qa', password: 'disposable-reader-test' }) }));
|
body: JSON.stringify({ name: 'peer-reader-qa', password: 'disposable-reader-test' }) }));
|
||||||
await page.reload();
|
await page.goto(url + '/peer-catalog.html');
|
||||||
await page.getByRole('heading', { name: 'Peer catalogs', exact: true }).waitFor();
|
await page.getByText('Peer catalogs are available to administrators only.').waitFor();
|
||||||
assert.equal(await page.getByRole('heading', { name: 'Server identity & invitations' }).count(), 0);
|
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']
|
||||||
assert.equal((await page.evaluate(() => fetch('/api/admin/peering').then(response => response.status))), 403);
|
.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('; ')}`);
|
assert.equal(errors.length, 0, `Browser errors: ${errors.join('; ')}`);
|
||||||
} finally { await browser.close(); }
|
} finally { await browser.close(); }
|
||||||
};
|
};
|
||||||
Reference in new issue
Block a user