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>
210 lines
11 KiB
TypeScript
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();
|