Files
ipx/web/src/admin.ts
rays a00516687a Keep artwork on disk and serve every image from iPX (#111)
The page loaded artwork from each publisher's server, or through /api/art, fetched every time,
for http-only hosts. Nothing was kept, every visit asked every publisher, and artwork went when
a publisher's server did.

- The page draws every image a feed or item names from /api/art. The first time, iPX fetches
  it (only an address a feed or item names, only an image, up to 5 MB, within the feed timeout)
  and keeps it in art/ beside the database, under a hash of its address with its type beside
  it (src/art.rs). Later it comes from disk, which marks it as used.
- art_cache_mb, a server setting on the admin page, 500 by default, caps what is kept: the
  sweep before each scan drops the least recently shown until it fits. 0 keeps nothing, and
  artwork is fetched through iPX each time. Settings saved before it get the default.
- Served from iPX's own address, someone else's image must stay an image: nosniff, and a CSP
  with sandbox, so an SVG opened on its own runs no script as iPX.
- The fixture server sends .jpg as image/jpeg, which /api/art requires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:50:46 +00:00

210 lines
11 KiB
TypeScript

/* ---------------- admin page ---------------- */
// /admin: the server's settings, the accounts, and the log, each a section chosen by the URL's
// hash so a link can go straight to one. The server sends this page and this script to admins
// only, and refuses every call below to anyone else; they were parts of Settings and the header,
// shown or hidden by the main page's script (issue #19).
let me: {name: string} | null = null;
api('/api/me').then(u => { me = u; }).catch(() => {});
const SECTIONS: Record<string, () => void> = {server: drawServer, accounts: drawAccounts, log: drawLogView};
function showSection(){
const t = SECTIONS[location.hash.slice(1)] ? location.hash.slice(1) : 'server';
for(const s of $$('#admin > section')) s.hidden = s.id !== t;
for(const a of $$('#atabs a')) a.classList.toggle('on', a.dataset.t === t);
// The log polls every two seconds while it is showing, and not otherwise.
if(t !== 'log' && logTimer){ clearInterval(logTimer); logTimer = null; }
SECTIONS[t]();
}
window.addEventListener('hashchange', showSection);
/* ---------------- server ---------------- */
async function drawServer(){
const box = $('#server');
const g = await api('/api/settings');
const gs = splitEvery(g.every_mins);
box.innerHTML = `<h2>Server</h2>
<p class="hint">These apply to everyone. Each person's own choices, such as keywords or how
many items a feed downloads for them, are in that feed's settings.</p>
<div class="field"><label>Check feeds every</label>
<div class="inline">
<input type="number" id="gnum" min="1" max="999" value="${gs.n}">
<select id="gunit">${unitOptions(gs.u)}</select>
</div>
<span class="hint">Applies to every feed that does not set its own. A feed's suggested
interval (its <b>ttl</b>) is still honoured when it asks to be polled less often.</span></div>
<div class="field"><label>Newest episodes to download, per feed</label>
<input type="number" id="gmax" min="0" max="999" value="${g.max_new_per_check}">
<span class="hint">Applies to any feed that does not set its own — including every feed
inside an OPML subscription. Older episodes stay listed to download by hand. <b>0 means
every episode</b>, the whole back catalogue.</span></div>
<div class="field"><label>Download these media types automatically</label>
<input type="text" id="gtypes" value="${esc((g.media_types||[]).join(', '))}" placeholder="audio, video">
<span class="hint">Anything else is still listed and can be downloaded by hand — blog feeds
put article images in enclosures, and those are not worth keeping. Empty takes everything.</span></div>
<div class="field"><label>Disk quota (GB, 0 = unlimited)</label>
<input type="number" id="gquota" min="0" step="0.5" value="${g.max_total_gb}">
<span class="hint">Over this, the oldest played items are deleted first. Pinned
items are never touched.</span></div>
<div class="field"><label>Delete items older than (days, 0 = keep)</label>
<input type="number" id="gage" min="0" value="${g.max_age_days}"></div>
<div class="field"><label>Artwork kept (MB, 0 = none)</label>
<input type="number" id="gart" min="0" value="${g.art_cache_mb}">
<span class="hint">Show and episode artwork is kept here once shown, so it loads from iPX
instead of each publisher. Over this, what has gone longest unshown is dropped first.</span></div>
<div class="field"><label>Download folder</label>
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>
<div class="cardacts"><button class="btn primary" id="gsave">${ICON.check} Save</button></div>`;
$('#gsave').onclick = async () => {
try{
await api('/api/settings', {method: 'PATCH', body: JSON.stringify({
schedule: `every ${Math.max(1, Number($('#gnum').value) || 1)}${$('#gunit').value}`,
max_new_per_check: Math.max(0, Number($('#gmax').value) || 0),
media_types: $('#gtypes').value.split(',').map(t => t.trim()).filter(Boolean),
max_total_gb: Number($('#gquota').value) || 0,
max_age_days: Number($('#gage').value) || 0,
art_cache_mb: Math.max(0, Number($('#gart').value) || 0)})});
toast('Settings saved');
}catch(e){ toast(e.message, true); }
};
}
/* ---------------- accounts ---------------- */
async function drawAccounts(){
const box = $('#accounts');
const users = await api('/api/users') || [];
box.innerHTML = `<h2>Accounts</h2>
${users.map(u => `<div class="inline urow" data-id="${u.id}">
<div style="flex:1;min-width:0"><b style="overflow-wrap:anywhere">${esc(u.name)}</b>
<small style="display:block;color:var(--faint)">${u.created ? `Added ${dateOf(u.created)}` : 'Added before this was kept'} · ${
u.last_login ? `signed in ${ago(u.last_login)}` : 'never signed in'}</small></div>
${u.password ? '' : '<span class="tag" title="No password: signs in through the proxy">Proxy</span>'}
<label class="check" style="margin:0"><input type="checkbox" data-a="admin" ${u.admin ? 'checked' : ''}> Admin</label>
<button class="btn ico danger" data-a="rm" title="Remove ${esc(u.name)}" aria-label="Remove ${esc(u.name)}">${ICON.trash}</button></div>`).join('')}
<div class="field" style="margin-top:20px"><label>Add someone</label>
<div class="inline">
<input type="text" id="uname" placeholder="Name" autocomplete="off" spellcheck="false">
<input type="password" id="upass" placeholder="Password" autocomplete="new-password">
</div>
<label class="check" style="margin-top:8px"><input type="checkbox" id="uadmin"> Admin</label>
<span class="hint">At least 8 characters. Leave the password empty for someone who signs in
through the proxy. New people start with no feeds.</span></div>
<div class="cardacts"><button class="btn primary" id="uadd">${ICON.plus} Add</button></div>`;
const change = async (u, opts) => {
try{
await api(`/api/users/${u.id}`, opts);
// Demoting yourself takes this page away; go back to the app rather than stay on a page
// the server no longer answers. Only on success: a refusal's toast has to stay readable.
if(u.name === me?.name){ location.href = '/'; return; }
}catch(e){ toast(e.message, true); }
drawAccounts(); // on a refusal, this puts the checkbox back where the server left it
};
for(const row of $$('#accounts [data-id]')){
const u = users.find(x => String(x.id) === row.dataset.id);
$('[data-a="admin"]', row).onchange = e =>
change(u, {method: 'PATCH', body: JSON.stringify({admin: e.target.checked})});
$('[data-a="rm"]', row).onclick = () => {
if(confirm(`Remove ${u.name}? Their subscriptions and read state go with them. Downloaded files stay.`))
change(u, {method: 'DELETE'});
};
}
$('#uadd').onclick = async () => {
try{
await api('/api/users', {method: 'POST', body: JSON.stringify({
name: $('#uname').value, password: $('#upass').value, admin: $('#uadmin').checked})});
toast('Added'); drawAccounts();
}catch(e){ toast(e.message, true); } // keep what was typed
};
}
/* ---------------- log ---------------- */
let logTimer = null, logSeq = 0, logLines = [], logFilter = '', logLevel = '', logTab = 'all';
// Which sources belong to each tab. "daemon" is the control protocol itself: every
// command in and every event out, whatever sent it.
const LOG_TABS = {
all: null,
daemon: t => t === 'ipx::io',
scan: t => t === 'ipx::scan',
web: t => t === 'ipx::http',
};
const LEVELS = {ERROR: 3, WARN: 2, INFO: 1, DEBUG: 0, TRACE: 0};
function drawLogView(){
logSeq = 0; logLines = [];
$('#log').innerHTML = `<h2>Log</h2>
<div class="logbar">
<div class="tabs" id="logtabs">
${Object.keys(LOG_TABS).map(t =>
`<button data-t="${t}" class="${logTab === t ? 'on' : ''}">${
{all: 'All', daemon: 'Daemon I/O', scan: 'Scans', web: 'HTTP'}[t]}</button>`).join('')}
</div>
<select id="loglevel" style="width:auto">
<option value="">All levels</option>
<option value="INFO">Info and above</option>
<option value="WARN">Warnings and errors</option>
<option value="ERROR">Errors only</option>
</select>
<input type="search" id="logq" class="grow" placeholder="Filter…">
<label class="check" style="margin:0"><input type="checkbox" id="logfollow" checked> Follow</label>
<button class="btn ico" id="logcopy" title="Copy what is showing" aria-label="Copy what is showing">${ICON.copy}</button>
</div>
<div id="logbox"><p class="empty">Loading…</p></div>
<span class="hint"><b>Daemon I/O</b> is the control protocol itself — every command in and
every event out. <b>Scans</b> is feed and download activity, <b>HTTP</b> is web requests.
The buffer keeps debug detail even when the terminal does not; <b>IPX_UI_LOG</b> changes
what it captures.</span>`;
for(const b of $$('#logtabs button')) b.onclick = () => {
logTab = b.dataset.t;
for(const x of $$('#logtabs button')) x.classList.toggle('on', x.dataset.t === logTab);
drawLog();
};
$('#loglevel').onchange = e => { logLevel = e.target.value; drawLog(); };
$('#logq').oninput = e => { logFilter = e.target.value.toLowerCase(); drawLog(); };
$('#logcopy').onclick = () => copyText(visibleLog().map(l =>
`${new Date(l.ts * 1000).toISOString()} ${l.level} ${l.target} ${l.msg}`).join('\n'), $('#logcopy'));
pollLog();
if(!logTimer) logTimer = setInterval(pollLog, 2000);
}
async function pollLog(){
try{
const r = await api(`/api/logs?after=${logSeq}&limit=500`);
if(r.lines.length){
logLines = logLines.concat(r.lines).slice(-2000);
logSeq = r.latest;
drawLog();
}else if(!logLines.length){ drawLog(); }
}catch(e){
const box = $('#logbox');
if(box) box.innerHTML = `<p class="empty">Lost contact with the daemon: ${esc(e.message)}</p>`;
}
}
function visibleLog(){
const min = logLevel ? LEVELS[logLevel] : -1;
const tab = LOG_TABS[logTab];
return logLines.filter(l =>
(!tab || tab(l.target)) &&
(LEVELS[l.level] ?? 1) >= min &&
(!logFilter || (l.msg + ' ' + l.target).toLowerCase().includes(logFilter)));
}
function drawLog(){
const box = $('#logbox'); if(!box) return;
const follow = $('#logfollow')?.checked;
const rows = visibleLog();
box.innerHTML = rows.length ? rows.map(l => {
const t = new Date(l.ts * 1000).toLocaleTimeString();
if(logTab === 'daemon'){
const out = l.msg.startsWith('<-');
return `<div class="l"><time>${t}</time>` +
`<span class="lv" style="color:${out ? 'var(--good)' : 'var(--accent)'}">${out ? 'out' : 'in'}</span>` +
`<span>${esc(l.msg.replace(/^[<-]+\s*/, ''))}</span></div>`;
}
return `<div class="l"><time>${t}</time><span class="lv ${esc(l.level)}">${esc(l.level)}</span>` +
`<span class="tg">${esc(l.target.replace(/^ipx::?/, ''))}</span><span>${esc(l.msg)}</span></div>`;
}).join('') : '<p class="empty">Nothing matches.</p>';
if(follow) box.scrollTop = box.scrollHeight;
}
showSection();