Files
MangaCircus/public/peers.js

300 lines
25 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 identityStatus = !peer.paired
? peerState('pending', 'Identity not verified yet', 'Verify the other server after importing its invitation.')
: remote?.error
? peerState('error', 'Other server status unavailable', `This server is verified; refresh status to check the other side. ${remote.error}`)
: remote?.paired
? peerState('ok', 'Peering confirmed on both servers', 'Both servers have verified each other’s certificate.')
: peerState('pending', 'Identity verified on this server', 'Waiting for the other server to verify this one.');
const panel = h('section', { class: 'peer-step' }, h('h3', {}, 'Connection and identity'),
identityStatus,
h('p', { class: 'muted small' }, `Our fingerprint: ${state.identity?.fingerprint || 'certificate not created'}`),
h('p', { class: 'muted small' }, `Their fingerprint: ${peer.fingerprint}`),
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') });
const grantHint = h('p', { class: 'muted small', role: 'status' });
let grantAction = null;
const updateGrantHint = () => {
grantHint.textContent = !peer.paired
? 'Granting is locked until identity pairing is complete. Finish the invitation exchange and verify this server above.'
: (!catalog.checked && !download.checked ? 'Choose a permission above to enable Issue grant.' : '');
grantHint.hidden = !grantHint.textContent;
if (grantAction) grantAction.disabled = busy || !peer.paired || (!catalog.checked && !download.checked);
};
catalog.onchange = () => { if (!catalog.checked) download.checked = false; updateGrantHint(); };
download.onchange = () => { if (download.checked) catalog.checked = true; updateGrantHint(); };
updateGrantHint();
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 : '');
grantAction = 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'), '', !peer.paired || (!catalog.checked && !download.checked) || busy);
updateGrantHint();
const outbound = h('section', { class: 'peer-step' }, h('h4', {}, 'We grant access to ', peerTag(peer.alias)),
localGrantStatus(peer),
h('label', { class: 'check' }, catalog, h('span', {}, 'Browse approved catalog')),
h('label', { class: 'check' }, download, h('span', {}, 'Download chapters (includes catalog access)')),
grantHint,
grantAction,
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'),
remoteGrantStatus(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 peerState(tone, title, detail) {
return h('div', { class: `peer-state peer-state-${tone}`, role: 'status', 'aria-live': 'polite' },
h('strong', {}, title), detail ? h('span', {}, detail) : null);
}
function localGrantStatus(peer) {
const scopes = peer.grantScopes || [];
const access = scopes.includes('download') ? 'Browse and download chapters' : scopes.includes('catalog') ? 'Browse approved catalog' : '';
if (peer.grantStatus === 'active') return peerState('ok', 'Grant active', `${access} · expires ${date(peer.grantExpiresAt)}.`);
if (peer.grantStatus === 'paused') return peerState('pending', 'Grant paused', 'The grant is retained. Resume access to make it usable again.');
if (peer.grantStatus === 'expired') return peerState('warn', 'Grant expired', 'Issue a new grant to restore access.');
return peerState('neutral', 'No grant issued', 'Choose the access level below to create a grant.');
}
function remoteGrantStatus(value) {
if (!value) return peerState('pending', 'Status not checked', 'Refresh to ask the other server for its current access status.');
if (value.error) return peerState('error', 'Could not confirm access', value.error);
const grant = value.grant || {};
const scopes = grant.scopes || [];
if (!value.paired) return peerState('pending', 'Identity not verified by peer', 'Ask the other admin to verify this server.');
if (grant.status === 'active') {
const role = scopes.includes('download') ? 'Browse and download chapters' : 'Browse approved catalog';
return peerState('ok', 'Active grant from peer', `${role} · expires ${date(grant.expiresAt)}.`);
}
if (grant.status === 'paused') return peerState('pending', 'Peer grant paused', 'The other admin can resume it without exchanging a new token.');
if (grant.status === 'expired') return peerState('warn', 'Peer grant expired', 'Ask the other admin to issue a new grant.');
return peerState('neutral', 'No active grant from peer', 'The other server has not granted catalog or download access.');
}
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);