263 lines
23 KiB
JavaScript
263 lines
23 KiB
JavaScript
'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);
|