diff --git a/CHANGELOG.md b/CHANGELOG.md index 3766f49..5598d6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,48 @@ matters, what was wrong before -- the reasoning is the point, not the diff. See [README.md](README.md) for what the thing is, and [docs/](docs/) for how to run it. +## 2026-09-11 — The log is admin-only + +`GET /api/logs` now returns `403` to anyone who is not an admin, and the page hides the Log button +from them. Before this, every signed-in person could read the whole log. That includes every +account's name, every feed anyone subscribes to, and every failed sign-in. `/api/events` stays open +to everyone, because it carries the scan progress each person's page shows. + +--- + +## 2026-09-11 — Managing users from the web + +Settings has a **Manage users…** screen for an admin. From it you can add someone, with a password +or with none for someone the proxy signs in, tick or untick Admin, and remove an account. It is +backed by `GET/POST /api/users` and `PATCH/DELETE /api/users/{id}`, which return `403` for anyone +who is not an admin. The only admin cannot be demoted or removed, because nobody would then be +able to manage accounts except from the CLI on the box. Before this, accounts could only be managed +with `ipx user`. + +--- + +## 2026-09-11 — Unread feeds first inside an OPML + +An OPML subscription's feeds, both in the sidebar folder and on its own page, now list the ones with +unread items first. They were listed alphabetically, so with dozens of feeds the few with anything +new were scattered through the list. Within each half the order is still alphabetical. The browser +suite's fixture OPML gained a second feed, Aardvark Radio, which sorts first by name and by position, +so the new test only passes if unread wins. + +--- + +## 2026-09-11 — Deploying is a Docker image + +Production moved from a hand-started daemon in code-server to the `iPodderX` container in the Arcane +project `content`. `CLAUDE.md` now deploys by pushing to the registry at `192.168.1.130:5000` and +recreating that one service with `docker compose`. The old instructions copied a binary over a +process nobody supervised, so it did not come back after a reboot. Two known gaps are gone: the +image does support accounts, and the entrypoint drops to `99:100`, so downloads are no longer +owned by root. The README's Docker section said `docker compose up -d` builds the image and named the +service `ipx`; the compose file pulls from the registry and the service is `ipodderx`. + +--- + ## 2026-09-11 — Documentation `PROGRESS.md` became this changelog; the finished step lists moved to an appendix. The README is now diff --git a/CLAUDE.md b/CLAUDE.md index 26aebc2..0cf5f8b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,31 +6,37 @@ made here. ## Where things are -The live install on this machine: +Production is the `iPodderX` container on Tower (192.168.1.130), the `ipodderx` service of the +Arcane project `content`: `/mnt/fast/arcane/projects/content/compose.yaml`. That file is what runs; +`docker-compose.yml` in this repo is a copy, and editing it changes nothing in production. -| | | -|---|---| -| Binary | `/config/.cargo/bin/ipx` | -| Config | `/config/.config/ipx/config.toml` | -| Database | `/config/.local/share/ipx/state.db` | -| Downloads | `/mnt/user/audio/ipx` | -| Web UI | `0.0.0.0:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel | +| | Host | In the container | +|---|---|---| +| Image | `192.168.1.130:5000/ipodderx:latest` | | +| Config | `/mnt/fast/appdata/ipodderx/config.toml` | `/config/config.toml` | +| Database | `/mnt/user/ipodderx/state.db` | `/data/state.db` | +| Downloads | `/mnt/user/ipodderx/downloads` | `/downloads` | +| Web UI | `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel | `0.0.0.0:8099` | -Deploying a change is: build, stop, copy, start. +Deploying a change is: build and push the image, then pull it and recreate the container. ```sh -cargo build --release -pkill -x ipx; sleep 2 -cp target/release/ipx /config/.cargo/bin/ipx -setsid nohup /config/.cargo/bin/ipx --config /config/.config/ipx/config.toml daemon \ - >/tmp/ipx.log 2>&1 ` subtitle is dropped whenever `content:encoded` exists, which loses Substack-style subtitles. diff --git a/README.md b/README.md index 1941af3..cd0f15c 100644 --- a/README.md +++ b/README.md @@ -85,11 +85,15 @@ never left behind with nothing explaining where it came from. ## Docker ```sh -docker compose up -d # builds the image and starts it -docker compose logs -f ipx # the token is printed on first start +docker buildx build --tag 192.168.1.130:5000/ipodderx:latest . --push +docker compose pull ipodderx && docker compose up -d ipodderx +docker compose logs -f ipodderx # the first start prints the default admin password ``` -`docker-compose.yml` mounts `./config`, `./data` and a downloads directory, publishes 8099 for the +`docker-compose.yml` runs the image from the registry above rather than building it, so build and +push first; change the tag in both places to use another registry. It mounts `/config` (config.toml), +`/data` (state.db) and `/downloads` from this install's host paths, which you will want to change for +yours. It publishes 8099 for the UI and 6881 (TCP **and** UDP -- DHT needs the UDP side), and sets `PUID`/`PGID` to `99:100` so files land owned the way Unraid shares expect. The healthcheck runs `ipx status` through the control socket, so it catches a daemon that is alive but wedged rather than merely one that has died. diff --git a/docs/architecture.md b/docs/architecture.md index 56708f4..2441f5b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -110,8 +110,9 @@ else a `401`. | `POST /api/enclosures/{id}/download`, `DELETE /api/enclosures/{id}` | `?force=true` overrides the shared-file warning | | `POST /api/fetch`, `GET /api/opml`, `POST /api/opml` | | | `GET /api/settings`, `PATCH /api/settings` | admin-only to write | +| `GET /api/users`, `POST /api/users`, `PATCH /api/users/{id}`, `DELETE /api/users/{id}` | admin-only; the only admin cannot be demoted or removed | | `GET /api/events` | SSE, the same broadcast the socket carries | -| `GET /api/logs` | the ring buffer, with a sequence cursor | +| `GET /api/logs` | admin-only; the ring buffer, with a sequence cursor | | `GET /media/{id}` | the file, with Range support so seeking works | Show notes are feed-supplied HTML from an untrusted source, sanitized with `ammonia` server-side diff --git a/docs/users.md b/docs/users.md index 964c21a..e6dba6c 100644 --- a/docs/users.md +++ b/docs/users.md @@ -70,13 +70,17 @@ list with their own read state. Unsubscribing removes it from their list alone; subscriber leaves does the feed stop being scanned, and even then its files and history stay, so re-subscribing does not pull the back catalogue again. -Accounts are managed from the command line only; there is no user administration in the web UI yet. +An admin can do the same from **Settings → Manage users…**: add someone (with a password, or none +for someone the proxy signs in), tick or untick Admin, or remove an account. Removing one takes its +subscriptions and read state with it; downloaded files stay. The only admin cannot be demoted or +removed there, so there is always someone who can manage the rest. ## Admin The first account is an admin. An admin can change global settings (scanning interval, quota, -retention, media types, download folder) and a feed's URL, folder and schedule. Everyone else gets -the Settings button hidden and a `403` if they ask anyway. +retention, media types, download folder), a feed's URL, folder and schedule, and who has an account +and who else is an admin, and read the log, which names everyone's feeds and sign-ins. Everyone else +gets the Settings and Log buttons hidden and a `403` if they ask anyway. ```sh ipx user list # the admin column says who diff --git a/src/web.rs b/src/web.rs index 291f354..dfde3ed 100644 --- a/src/web.rs +++ b/src/web.rs @@ -74,6 +74,8 @@ pub fn router(state: WebState) -> Router { .route("/api/fetch", post(fetch_now)) .route("/api/opml", get(export_opml).post(import_opml)) .route("/api/settings", get(get_settings).patch(patch_settings)) + .route("/api/users", get(list_users).post(add_user)) + .route("/api/users/{id}", patch(patch_user).delete(remove_user)) .route("/api/logs", get(logs)) .route("/api/events", get(events)) .route("/media/{id}", get(media)) @@ -303,6 +305,125 @@ async fn me(user: crate::db::User) -> Json { Json(serde_json::json!({ "name": user.name, "admin": user.is_admin })) } +// ---- accounts: admin only ---- + +fn require_admin(user: &crate::db::User) -> Result<(), ApiError> { + if user.is_admin { + Ok(()) + } else { + Err(ApiError::forbidden("only an admin manages accounts")) + } +} + +/// Demoting or removing this account would leave nobody able to manage anyone, and the only +/// way back would be `ipx user` on the box. +fn last_admin(users: &[crate::db::User], id: i64) -> bool { + let admins: Vec = users.iter().filter(|u| u.is_admin).map(|u| u.id).collect(); + admins == [id] +} + +async fn list_users( + State(state): State, + user: crate::db::User, +) -> Result, ApiError> { + require_admin(&user)?; + let users: Vec<_> = state + .ctx + .db + .users()? + .iter() + .map(|u| { + serde_json::json!({ + "id": u.id, "name": u.name, "admin": u.is_admin, "password": u.pass_hash.is_some(), + }) + }) + .collect(); + Ok(Json(serde_json::json!(users))) +} + +#[derive(Deserialize)] +struct NewUser { + name: String, + #[serde(default)] + password: String, + #[serde(default)] + admin: bool, +} + +async fn add_user( + State(state): State, + user: crate::db::User, + Json(body): Json, +) -> Result { + require_admin(&user)?; + // The same rules as a name a proxy vouches for, so either way of signing in finds it. + let name = crate::auth::name_from_header(&body.name).ok_or_else(|| { + ApiError::bad_request("a name is required, without commas, semicolons or line breaks") + })?; + if state.ctx.db.user_by_name(&name)?.is_some() { + return Err(ApiError::bad_request(format!("{name} already exists"))); + } + // No password is someone the proxy signs in, as with `ipx user add --no-password`. + let hash = if body.password.is_empty() { + None + } else { + Some(crate::auth::hash_password(&body.password).map_err(|e| ApiError::bad_request(format!("{e:#}")))?) + }; + state.ctx.db.create_user(&name, hash.as_deref(), body.admin)?; + tracing::info!(by = %user.name, user = %name, admin = body.admin, "account added"); + Ok(StatusCode::CREATED) +} + +#[derive(Deserialize)] +struct UserPatch { + admin: bool, +} + +async fn patch_user( + State(state): State, + user: crate::db::User, + Path(id): Path, + Json(body): Json, +) -> Result { + require_admin(&user)?; + let users = state.ctx.db.users()?; + let target = users + .iter() + .find(|u| u.id == id) + .ok_or_else(|| ApiError::bad_request(format!("no account with id {id}")))?; + if !body.admin && last_admin(&users, id) { + return Err(ApiError::bad_request(format!( + "{} is the only admin; make someone else an admin first", + target.name + ))); + } + state.ctx.db.set_admin(id, body.admin)?; + tracing::info!(by = %user.name, user = %target.name, admin = body.admin, "admin changed"); + Ok(StatusCode::NO_CONTENT) +} + +async fn remove_user( + State(state): State, + user: crate::db::User, + Path(id): Path, +) -> Result { + require_admin(&user)?; + let users = state.ctx.db.users()?; + let target = users + .iter() + .find(|u| u.id == id) + .ok_or_else(|| ApiError::bad_request(format!("no account with id {id}")))?; + if last_admin(&users, id) { + return Err(ApiError::bad_request(format!( + "{} is the only admin; make someone else an admin first", + target.name + ))); + } + state.ctx.db.delete_user(id)?; + tracing::info!(by = %user.name, user = %target.name, "account removed"); + Ok(StatusCode::NO_CONTENT) +} + async fn login_page() -> Html<&'static str> { Html(include_str!("../web/login.html")) } @@ -473,6 +594,14 @@ impl IntoResponse for ApiError { mod tests { use super::*; + #[test] + fn only_the_last_admin_is_protected() { + let u = |id, is_admin| crate::db::User { id, name: format!("u{id}"), pass_hash: None, is_admin }; + assert!(last_admin(&[u(1, true), u(2, false)], 1)); + assert!(!last_admin(&[u(1, true), u(2, true)], 1), "another admin remains"); + assert!(!last_admin(&[u(1, true), u(2, false)], 2), "not an admin at all"); + } + #[test] fn token_comparison_rejects_mismatches_and_length_differences() { assert!(constant_time_eq("abc123", "abc123")); @@ -1161,9 +1290,13 @@ struct LogPage { latest: u64, } -async fn logs(Query(q): Query) -> Json { +async fn logs(user: crate::db::User, Query(q): Query) -> Result, ApiError> { + // The log names every account, every feed and every failed sign-in, not just yours. + if !user.is_admin { + return Err(ApiError::forbidden("only an admin reads the log")); + } let (lines, latest) = crate::logbuf::since(q.after, q.limit.clamp(1, 2000)); - Json(LogPage { lines, latest }) + Ok(Json(LogPage { lines, latest })) } /// One line per HTTP request, so the web side shows up in the same log as the daemon. diff --git a/tests/page-smoke.js b/tests/page-smoke.js index 01d6ec6..9c2ce9c 100644 --- a/tests/page-smoke.js +++ b/tests/page-smoke.js @@ -44,7 +44,9 @@ const ctx = { json: () => Promise.resolve( String(url).includes('/api/settings') ? { schedule: 'every 60m', every_mins: 60, download_dir: '/tmp', max_total_gb: 0, max_age_days: 0 } - : []), + : String(url).includes('/api/users') + ? [{ id: 1, name: 'admin', admin: true, password: true }, { id: 2, name: 'sam', admin: false, password: false }] + : []), }), EventSource: function () { this.close = () => {}; }, MediaMetadata: function () {}, @@ -79,6 +81,7 @@ const drive = [ ['downloadLatestModal', () => ctx.downloadLatestModal(feed)], ['removeFeed', () => ctx.removeFeed(feed)], ['prefsModal', () => ctx.prefsModal()], + ['usersModal', () => ctx.usersModal()], ['logsModal', () => ctx.logsModal()], // `const S` is not reachable from here: top-level const/let do not become properties // of a vm context the way var and function declarations do. diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index 33bdb75..1656856 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -10,7 +10,7 @@ test.beforeEach(async ({ page }) => { test('the page loads and lists the configured feeds', async ({ page }) => { // Regression: a ReferenceError in the script left the shell rendered and the sidebar // empty, with every handler below the error dead. Server-side checks all passed. - // Three top-level feeds in the fixture config; the OPML's child is inside a closed folder. + // Three top-level feeds in the fixture config; the OPML's children are inside a closed folder. await expect(page.locator('.feed')).toHaveCount(4, { timeout: 15_000 }); await expect(page.getByText('Test Show')).toBeVisible(); const errors = []; @@ -178,16 +178,50 @@ test('an OPML subscription is a collapsible folder', async ({ page }) => { const chev = page.locator('.feed.group .chev'); await expect(chev).toBeVisible({ timeout: 20_000 }); - // Closed by default: the child is not listed until the folder is opened. + // Closed by default: the children are not listed until the folder is opened. const before = await page.locator('.feed').count(); await chev.click(); - await expect(page.locator('.feed')).toHaveCount(before + 1); + await expect(page.locator('.feed')).toHaveCount(before + 2); // Scoped to the sidebar: the name also appears as the page heading once selected. await expect(page.locator('#feedlist').getByText('Grouped Show')).toBeVisible(); // The subscription's own page lists what is inside it. await page.locator('.feed', { hasText: 'Test Subscriptions' }).first().click(); - await expect(page.locator('.childrow')).toHaveCount(1); + await expect(page.locator('.childrow')).toHaveCount(2); +}); + +test('inside an OPML, feeds with unread items are listed first', async ({ page }) => { + await page.evaluate(() => + fetch('/api/fetch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ feed: 'test-subscriptions', force: true }), + })); + const chev = page.locator('.feed.group .chev'); + await expect(chev).toBeVisible({ timeout: 20_000 }); + await chev.click(); + const side = page.locator('#feedlist'); + await expect(side.getByText('Aardvark Radio')).toBeVisible({ timeout: 20_000 }); + + // Other tests change read state, so set it here: Aardvark Radio read, Grouped Show not. + // Opening an item reads it; the toggle in the pane below flips it back. + await side.getByText('Aardvark Radio').click(); + const aa = page.locator('.ep', { hasText: 'Aardvark Ep' }); + await aa.click(); + await expect(aa).toHaveClass(/read/); + await side.getByText('Grouped Show').click(); + const gs = page.locator('.ep', { hasText: 'Grouped Ep' }); + await gs.click(); + await page.locator('#detail button', { hasText: 'Mark unread' }).click(); + await expect(gs).not.toHaveClass(/read/); + + // Aardvark comes first alphabetically and in the OPML, so only the unread sort puts + // Grouped Show above it. The folder stays open across the reload (localStorage). + await page.reload(); + const want = ['Grouped Show', 'Aardvark Radio']; + await expect(page.locator('#feedlist .feed.child b')).toHaveText(want, { timeout: 20_000 }); + await page.locator('.feed', { hasText: 'Test Subscriptions' }).first().click(); + await expect(page.locator('.childrow b')).toHaveText(want); }); test('marking an OPML subscription read covers the feeds inside it', async ({ page }) => { @@ -283,6 +317,10 @@ test('a second person has their own feeds and their own read state', async ({ br // Sam subscribes to nothing yet, so sees nothing -- the admin's feeds are not theirs. await expect(page.locator('#feedlist')).toContainText('No feeds.'); await expect(page.locator('#prefs')).toBeHidden(); // not an admin + // Hiding the button is not the guard; the server is. + expect((await page.request.get('/api/users')).status()).toBe(403); + await expect(page.locator('#logs')).toBeHidden(); + expect((await page.request.get('/api/logs')).status()).toBe(403); // Subscribing to a feed the admin already has costs no second fetch: same feed, same // files, but Sam's own read state. @@ -333,3 +371,44 @@ test('deleting a shared file warns that it is everyone\'s copy', async ({ page } await page.locator('.tabs button', { hasText: 'Downloaded' }).click(); await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 }); }); + +// Every row says "Admin" on its checkbox, so match the name exactly. +const userRow = (page, name) => + page.locator('#modalCard [data-id]').filter({ has: page.locator('b', { hasText: new RegExp(`^${name}$`) }) }); + +async function openUsers(page) { + await page.locator('#prefs').click(); + await page.locator('#gusers').click(); + await expect(userRow(page, 'admin')).toBeVisible(); +} + +test('an admin adds someone, makes them an admin, and removes them', async ({ page }) => { + await openUsers(page); + await page.locator('#uname').fill('pat'); + await page.locator('#upass').fill('patpassword'); + await page.locator('#uadd').click(); + const row = userRow(page, 'pat'); + await expect(row).toBeVisible(); + await expect(row.locator('[data-a="admin"]')).not.toBeChecked(); + + await row.locator('[data-a="admin"]').check(); + // Not just the box: it has to have reached the database. + await expect.poll(async () => + (await (await page.request.get('/api/users')).json()).find(u => u.name === 'pat')?.admin + ).toBe(true); + + page.once('dialog', d => d.accept()); + await row.locator('[data-a="rm"]').click(); + await expect(row).toHaveCount(0); +}); + +test('the only admin cannot be demoted or removed', async ({ page }) => { + await openUsers(page); + await userRow(page, 'admin').locator('[data-a="admin"]').click(); + await expect(page.locator('.toast.bad')).toContainText('only admin'); + // Redrawn from the server, so the box is back. + await expect(userRow(page, 'admin').locator('[data-a="admin"]')).toBeChecked(); + + const me = (await (await page.request.get('/api/users')).json()).find(u => u.name === 'admin'); + expect((await page.request.delete(`/api/users/${me.id}`)).status()).toBe(400); +}); diff --git a/tests/ui/fixtures/aardvark.xml b/tests/ui/fixtures/aardvark.xml new file mode 100644 index 0000000..128109b --- /dev/null +++ b/tests/ui/fixtures/aardvark.xml @@ -0,0 +1,5 @@ + +Aardvark Radiohttp://127.0.0.1:8792/ +Inside the OPML, and first in it and alphabetically. +Aardvark Epaa-1x + diff --git a/tests/ui/fixtures/subs.opml b/tests/ui/fixtures/subs.opml index 298a518..7036287 100644 --- a/tests/ui/fixtures/subs.opml +++ b/tests/ui/fixtures/subs.opml @@ -1,4 +1,5 @@ Test Subscriptions + - \ No newline at end of file + diff --git a/web/index.html b/web/index.html index 317444d..490c8bd 100644 --- a/web/index.html +++ b/web/index.html @@ -513,6 +513,9 @@ async function loadFeeds(keepSel){ renderFeeds(); if(!keepSel && !S.feed && S.feeds.length) selectFeed(S.feeds[0].id); } +// An OPML can hold dozens of feeds; the ones with something new go first. sort is stable, so the +// server's alphabetical order still holds within each half. +const unreadFirst=(a,b)=>(b.unread>0)-(a.unread>0); function renderFeeds(){ const q=$('#feedFilter').value.trim().toLowerCase(); const list=$('#feedlist'); const top=list.scrollTop; list.innerHTML=''; @@ -527,7 +530,8 @@ function renderFeeds(){ order.push([f,0]); // A subscription can hold dozens of feeds, so a folder starts closed. Searching // opens them all, or matches inside a closed folder would be invisible. - if(expanded.has(f.id) || q) for(const c of shown) if(c.group===f.id) order.push([c,1]); + if(expanded.has(f.id) || q) + for(const c of shown.filter(c=>c.group===f.id).sort(unreadFirst)) order.push([c,1]); } for(const [f,depth] of order){ const kids=shown.filter(c=>c.group===f.id).length; @@ -646,7 +650,7 @@ function renderGroup(f,kids){ const draw=()=>{ const q=($('#kidSearch').value||'').trim().toLowerCase(); const box=$('#kidlist'); box.innerHTML=''; - const rows=kids.filter(c=>!q||(c.title||c.id).toLowerCase().includes(q)); + const rows=kids.filter(c=>!q||(c.title||c.id).toLowerCase().includes(q)).sort(unreadFirst); if(!rows.length){ box.innerHTML='

Nothing matches.

'; return; } for(const c of rows){ const el=document.createElement('div'); @@ -1227,9 +1231,13 @@ async function prefsModal(){ Export hands every subscription to another podcast app. Import adds the feeds listed in an OPML you paste in. +
+
+ Add and remove the people who can sign in, and choose who is an admin.
`); $('#gopml').onclick=opmlModal; + $('#gusers').onclick=usersModal; $('#gsave').onclick=async()=>{ try{ await api('/api/settings',{method:'PATCH',body:JSON.stringify({ @@ -1244,6 +1252,52 @@ async function prefsModal(){ }; } +// Admin only, and the server enforces that: this screen is just the way in. +async function usersModal(){ + const users = await api('/api/users') || []; + openModal(`

Users

+ ${users.map(u=>`
+ ${esc(u.name)} + ${u.password?'':'proxy'} + +
`).join('')} +
+
+ + +
+ + At least 8 characters. Leave the password empty for someone who signs in + through the proxy. New people start with no feeds.
+
+
`); + const change=async(u,opts)=>{ + try{ + await api(`/api/users/${u.id}`,opts); + // Demoting yourself takes this screen away; reload so the page stops offering it. Only + // on success: reloading after a refusal wiped the toast that said why. + if(u.name===S.me?.name){ location.reload(); return; } + }catch(e){ toast(e.message,true); } + usersModal(); // on a refusal, this puts the checkbox back where the server left it + }; + $$('#modalCard [data-id]').forEach(row=>{ + 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'); usersModal(); + }catch(e){ toast(e.message,true); } // keep what was typed + }; +} + function settingsModal(f){ const isGroup = S.feeds.some(c=>c.group===f.id); openModal(`

${esc(f.title||f.id)}

@@ -1360,8 +1414,9 @@ on('#signout','onclick',async()=>{ await api('/api/logout',{method:'POST'}); loc api('/api/me').then(u=>{ S.me=u; $('#who').textContent=u.name+(u.admin?' · admin':''); - // Scanning, quotas and the download folder are the operator's business. - if(!u.admin) $('#prefs').hidden=true; + // Scanning, quotas, accounts and the log are the operator's business. The server refuses + // them too; hiding the buttons just stops offering what would fail. + if(!u.admin){ $('#prefs').hidden=true; $('#logs').hidden=true; } }).catch(()=>{}); on('#logs','onclick',logsModal); $('#feedFilter').oninput=renderFeeds;