From 2cb45e6d62af36436aea87dd01eb8bdfd07ece75 Mon Sep 17 00:00:00 2001 From: rays Date: Mon, 12 Oct 2026 16:06:03 +0000 Subject: [PATCH] Administer the directory from iPX with an rwa token (#206) A Directory feed's page offers Check now and Remove to an admin whose token is rwa (the page learns it from can_admin on the feed's page, asked of the directory only for admins). Admin, Directory lists the servers sharing, from a new GET /v1/instances, and the blocked feeds by title: a feed is often blocked because its address carries a key, so the address stays in the directory. Unblocking or dropping a snapshot sends this server's snapshot again at once; since #211 an unchanged one would otherwise wait half an hour, and an unblocked feed with it. The directory's 403 reaches the page as 403, not 502. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + crates/directory/src/lib.rs | 14 +++++++ crates/directory/src/store.rs | 18 ++++++++- crates/ipx/src/directory.rs | 53 ++++++++++++++++++++++++-- crates/ipx/src/main.rs | 5 +++ crates/ipx/src/web.rs | 72 +++++++++++++++++++++++++++++++++-- docs/architecture.md | 2 + docs/directory.md | 13 ++++++- tests/ui/app.spec.js | 64 +++++++++++++++++++++++++++++++ web/app.css | 2 + web/src/admin.ts | 45 +++++++++++++++++++++- web/src/dialogs.ts | 19 +++++++++ 12 files changed, 297 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17c4c95..6ea4e36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- With a directory token that may administer it, an admin removes a feed from the directory (and blocks it), checks one now, sees and unblocks what is blocked, and sees and drops the servers sharing, from iPX (#206). - The Grafana dashboard is laid out by container: an overview of all three, Tower's load, CPU, memory and disks, then ipx-web (from its new request log), ipx's API, worker and process, and the directory, each with its own warnings (#210). - `ipx-directory`, a directory of public podcasts that ipx servers share: each sends the public feeds its people subscribe to, and any server with a token browses them. Tokens are in its config file, each read-only, read-write or admin, and revoked one at a time (#204). diff --git a/crates/directory/src/lib.rs b/crates/directory/src/lib.rs index 23846da..9dba934 100644 --- a/crates/directory/src/lib.rs +++ b/crates/directory/src/lib.rs @@ -93,6 +93,14 @@ pub struct Me { pub access: Access, } +/// A token whose snapshot the directory holds: how many feeds it gave, and how many people. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Instance { + pub name: String, + pub feeds: i64, + pub subscribers: i64, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Blocked { pub id: String, @@ -232,6 +240,7 @@ pub fn router(dir: Shared) -> Router { .route("/v1/snapshot", post(snapshot)) .route("/v1/blocked", get(blocked)) .route("/v1/blocked/{id}", delete(unblock)) + .route("/v1/instances", get(instances)) .route("/v1/instances/{name}", delete(drop_instance)) .layer(axum::middleware::from_fn_with_state(dir.clone(), authenticate)); Router::new() @@ -373,6 +382,11 @@ async fn unblock(State(dir): State, Extension(me): Extension, Path(i Ok(StatusCode::NO_CONTENT) } +async fn instances(State(dir): State, Extension(me): Extension) -> Result>, ApiError> { + need(&me, Access::Rwa)?; + Ok(Json(dir.store.instances().await?)) +} + async fn drop_instance(State(dir): State, Extension(me): Extension, Path(name): Path) -> Result { need(&me, Access::Rwa)?; let n = dir.store.drop_instance(&name).await?; diff --git a/crates/directory/src/store.rs b/crates/directory/src/store.rs index 966dfc3..3f863b9 100644 --- a/crates/directory/src/store.rs +++ b/crates/directory/src/store.rs @@ -5,7 +5,7 @@ use anyhow::Result; use sea_orm::{ConnectionTrait, DatabaseConnection, Statement, TransactionTrait, Value}; -use crate::{Blocked, FeedPage, Item, Listing, SnapshotEntry, SnapshotResult}; +use crate::{Blocked, FeedPage, Instance, Item, Listing, SnapshotEntry, SnapshotResult}; /// A feed's items as its page shows them, and as many as ipx's own Directory page showed. pub const ITEMS: usize = 20; @@ -334,6 +334,19 @@ impl Store { Ok(self.exec("DELETE FROM blocked WHERE id = $1", vec![id.into()]).await? > 0) } + pub async fn instances(&self) -> Result> { + // Postgres sums a BIGINT as NUMERIC, which does not read as an i64. + self.rows( + "SELECT instance, count(*) AS feeds, CAST(sum(subscribers) AS BIGINT) AS subscribers + FROM snapshots GROUP BY instance ORDER BY instance", + vec![], + ) + .await? + .iter() + .map(|r| Ok(Instance { name: r.try_get("", "instance")?, feeds: r.try_get("", "feeds")?, subscribers: r.try_get("", "subscribers")? })) + .collect() + } + pub async fn blocked(&self) -> Result> { self.rows("SELECT id, url, title, at FROM blocked ORDER BY at DESC, id", vec![]) .await? @@ -498,6 +511,9 @@ pub(crate) mod tests { assert_eq!(all.len(), 1); assert_eq!(all[0].subscribers, 3); + let i = s.instances().await.unwrap(); + assert_eq!(i.iter().map(|i| (i.name.as_str(), i.feeds, i.subscribers)).collect::>(), [("a", 1, 1), ("b", 1, 2)]); + // A token taken out of the config file takes its counts. s.keep_instances(&["b".into()]).await.unwrap(); assert_eq!(s.list().await.unwrap()[0].subscribers, 2); diff --git a/crates/ipx/src/directory.rs b/crates/ipx/src/directory.rs index f5c53f3..baa4b47 100644 --- a/crates/ipx/src/directory.rs +++ b/crates/ipx/src/directory.rs @@ -67,7 +67,13 @@ async fn call(ctx: &Ctx, method: reqwest::Method, path: &st let msg = resp.json::().await.ok().and_then(|v| v["error"].as_str().map(str::to_owned)).unwrap_or_default(); return Err(DirError::Refused(status.as_u16(), msg)); } - resp.json().await.map_err(|e| DirError::Unreachable(format!("its answer did not read: {e:#}"))) + // A removal or an unblock answers 204, with nothing to read: that reads as null, which is (). + let body = resp.bytes().await.map_err(|e| DirError::Unreachable(format!("{e:#}")))?; + serde_json::from_slice(if body.is_empty() { b"null" } else { &body }).map_err(|e| DirError::Unreachable(format!("its answer did not read: {e:#}"))) +} + +fn seg(s: &str) -> percent_encoding::PercentEncode<'_> { + percent_encoding::utf8_percent_encode(s, percent_encoding::NON_ALPHANUMERIC) } pub async fn me(ctx: &Ctx) -> Result { @@ -83,7 +89,47 @@ pub async fn popular(ctx: &Ctx) -> Result, DirError> { } pub async fn page(ctx: &Ctx, id: &str) -> Result { - call(ctx, reqwest::Method::GET, &format!("/v1/feeds/{}", percent_encoding::utf8_percent_encode(id, percent_encoding::NON_ALPHANUMERIC)), None).await + call(ctx, reqwest::Method::GET, &format!("/v1/feeds/{}", seg(id)), None).await +} + +// Administering the directory (#206): each needs an rwa token, which the directory checks. + +/// Removes a feed and blocks it, so no snapshot brings it back. +pub async fn remove(ctx: &Ctx, id: &str) -> Result<(), DirError> { + call(ctx, reqwest::Method::DELETE, &format!("/v1/feeds/{}", seg(id)), None).await +} + +pub async fn refresh(ctx: &Ctx, id: &str) -> Result { + call(ctx, reqwest::Method::POST, &format!("/v1/feeds/{}/refresh", seg(id)), None).await +} + +pub async fn blocked(ctx: &Ctx) -> Result, DirError> { + call(ctx, reqwest::Method::GET, "/v1/blocked", None).await +} + +pub async fn unblock(ctx: &Ctx, id: &str) -> Result<(), DirError> { + call(ctx, reqwest::Method::DELETE, &format!("/v1/blocked/{}", seg(id)), None).await +} + +pub async fn instances(ctx: &Ctx) -> Result, DirError> { + call(ctx, reqwest::Method::GET, "/v1/instances", None).await +} + +/// Drops one server's snapshot, and its counts with it. It comes back with that server's next +/// one: to keep a server out, revoke its token. +pub async fn drop_instance(ctx: &Ctx, name: &str) -> Result<(), DirError> { + call(ctx, reqwest::Method::DELETE, &format!("/v1/instances/{}", seg(name)), None).await +} + +/// Has the snapshot sent again now, changed or not. +pub fn resend_now(ctx: &Ctx) { + ctx.directory_resend.store(true, std::sync::atomic::Ordering::Relaxed); + ctx.directory_wake.notify_one(); +} + +/// Whether this server's token may administer the directory. +pub async fn can_admin(ctx: &Ctx) -> bool { + me(ctx).await.is_ok_and(|m| m.access == ipx_directory::config::Access::Rwa) } /// Whether a feed is someone's own: marked private, given a username or password, or at an @@ -146,7 +192,8 @@ pub async fn run(ctx: std::sync::Arc, mut stop: tokio::sync::watch::Receive let got = match snapshot(&ctx).await { Ok(entries) => { let key = serde_json::to_vec(&(target(&cfg), &entries)).unwrap_or_default(); - if resend(last.as_ref(), &key) { + let forced = ctx.directory_resend.swap(false, std::sync::atomic::Ordering::Relaxed); + if forced || resend(last.as_ref(), &key) { let got = share(&ctx, &entries).await; if got.is_ok() { last = Some((key, std::time::Instant::now())); diff --git a/crates/ipx/src/main.rs b/crates/ipx/src/main.rs index 5f9ec76..287dbf2 100644 --- a/crates/ipx/src/main.rs +++ b/crates/ipx/src/main.rs @@ -166,6 +166,9 @@ pub struct Ctx { pub directory_wake: tokio::sync::Notify, /// When the snapshot last went and what came of it, for the admin page. pub directory_shared: std::sync::Mutex>, + /// Send the snapshot even if it has not changed: after an unblock or a dropped snapshot the + /// directory needs it again, though nothing here differs (#206). + pub directory_resend: std::sync::atomic::AtomicBool, } /// A copy of the config to change, holding off every other change until `store_cfg` keeps it. @@ -365,6 +368,7 @@ async fn main() -> Result<()> { deadair: Default::default(), directory_wake: Default::default(), directory_shared: Default::default(), + directory_resend: Default::default(), }); let result = match cli.command { @@ -2518,6 +2522,7 @@ mod tests { deadair: Default::default(), directory_wake: Default::default(), directory_shared: Default::default(), + directory_resend: Default::default(), directory_cleaned: Default::default(), } } diff --git a/crates/ipx/src/web.rs b/crates/ipx/src/web.rs index a8b3dc0..7c1c3c3 100644 --- a/crates/ipx/src/web.rs +++ b/crates/ipx/src/web.rs @@ -62,7 +62,11 @@ pub fn router(state: WebState) -> Router { .route("/api/status", get(status)) .route("/api/popular", get(get_popular)) .route("/api/directory", get(get_directory)) - .route("/api/directory/{id}", get(get_listed)) + .route("/api/directory/{id}", get(get_listed).delete(remove_listed)) + .route("/api/directory/{id}/refresh", post(refresh_listed)) + .route("/api/directory-admin", get(get_directory_admin)) + .route("/api/directory-blocked/{id}", delete(unblock_listed)) + .route("/api/directory-instances/{name}", delete(drop_instance)) .route("/api/popular/{id}", post(subscribe_popular)) .route("/api/users", get(list_users).post(add_user)) .route("/api/users/{id}", patch(patch_user).delete(remove_user)) @@ -814,6 +818,8 @@ fn dir_error(e: crate::directory::DirError) -> ApiError { let status = match &e { DirError::NotConfigured => StatusCode::SERVICE_UNAVAILABLE, DirError::Refused(404, _) => StatusCode::NOT_FOUND, + // This server's token may not do it: the admin asked for something only rwa may (#206). + DirError::Refused(403, _) => StatusCode::FORBIDDEN, _ => StatusCode::BAD_GATEWAY, }; if !matches!(e, DirError::NotConfigured | DirError::Refused(404, _)) { @@ -888,16 +894,21 @@ struct Listed { /// Why its last check failed, when it did (#142), in the directory's words. failing: Option, items: Vec, + /// An admin here whose token may administer the directory, who sees Remove and Refresh (#206). + can_admin: bool, } /// A listed feed's description and latest items, from the directory, sanitized here: they are /// the feed's own HTML, which the directory passes on as it was sent. async fn get_listed( State(state): State, - _user: crate::db::User, + user: crate::db::User, Path(id): Path, ) -> Result, ApiError> { - let page = crate::directory::page(&state.ctx, &id).await.map_err(dir_error)?; + let (page, can_admin) = tokio::join!(crate::directory::page(&state.ctx, &id), async { + user.is_admin && crate::directory::can_admin(&state.ctx).await + }); + let page = page.map_err(dir_error)?; let mut sanitizer = feed_sanitizer(); let description = page.description.as_deref().map(|d| clean_description(&mut sanitizer, d, None)); let items = page @@ -912,7 +923,60 @@ async fn get_listed( duration: e.duration, }) .collect(); - Ok(Json(Listed { description, failing: page.failing, items })) + Ok(Json(Listed { description, failing: page.failing, items, can_admin })) +} + +// ---- administering the directory (#206): an admin here, and a token there that may ---- + +fn admin_only(user: &crate::db::User) -> Result<(), ApiError> { + if user.is_admin { Ok(()) } else { Err(ApiError::forbidden("only an admin administers the directory")) } +} + +async fn remove_listed(State(state): State, user: crate::db::User, Path(id): Path) -> Result { + admin_only(&user)?; + crate::directory::remove(&state.ctx, &id).await.map_err(dir_error)?; + tracing::info!(by = %user.name, feed = %id, "removed a feed from the directory"); + Ok(StatusCode::NO_CONTENT) +} + +async fn refresh_listed(State(state): State, user: crate::db::User, Path(id): Path) -> Result { + admin_only(&user)?; + crate::directory::refresh(&state.ctx, &id).await.map_err(dir_error)?; + Ok(StatusCode::NO_CONTENT) +} + +/// A blocked feed, by its title: a feed is often blocked because its address holds someone's +/// key, so the address stays in the directory. +#[derive(Serialize)] +struct BlockedRow { + id: String, + title: Option, + at: i64, +} + +async fn get_directory_admin(State(state): State, user: crate::db::User) -> Result, ApiError> { + admin_only(&user)?; + let (instances, blocked) = tokio::join!(crate::directory::instances(&state.ctx), crate::directory::blocked(&state.ctx)); + let blocked: Vec = blocked.map_err(dir_error)?.into_iter().map(|b| BlockedRow { id: b.id, title: b.title, at: b.at }).collect(); + Ok(Json(serde_json::json!({ "instances": instances.map_err(dir_error)?, "blocked": blocked }))) +} + +async fn unblock_listed(State(state): State, user: crate::db::User, Path(id): Path) -> Result { + admin_only(&user)?; + crate::directory::unblock(&state.ctx, &id).await.map_err(dir_error)?; + // Unblocked, it comes back with a snapshot that has it, and this one has not changed. + crate::directory::resend_now(&state.ctx); + tracing::info!(by = %user.name, feed = %id, "unblocked a feed in the directory"); + Ok(StatusCode::NO_CONTENT) +} + +async fn drop_instance(State(state): State, user: crate::db::User, Path(name): Path) -> Result { + admin_only(&user)?; + crate::directory::drop_instance(&state.ctx, &name).await.map_err(dir_error)?; + // When it was this server's own, its feeds come back now rather than in half an hour. + crate::directory::resend_now(&state.ctx); + tracing::info!(by = %user.name, instance = %name, "dropped a server's snapshot from the directory"); + Ok(StatusCode::NO_CONTENT) } /// Subscribes by the directory's id, because the page never sees a URL: the address comes from diff --git a/docs/architecture.md b/docs/architecture.md index 1104ef5..b12322f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -141,6 +141,8 @@ else a `401`. A feed's items and files (its entries, `download-latest`, `/api/en | `GET /api/opml`, `POST /api/opml` | export your subscriptions; subscribe to every feed in an OPML | | `GET /api/directory/{id}` | a feed's page from the directory service (#205), by the directory's id: its description and latest twenty items, sanitized here, and why its last check failed, in words; never a file or its address | | `GET /api/popular`, `GET /api/directory`, `POST /api/popular/{id}` | the directory service's ten most subscribed and every feed it lists, A to Z, asked by ipx with its token (directory id, title, art, subscribers across every sharing server, whether it is yours and its id here, category, whether it carries audio or video; never a URL); subscribe by the directory's id, which takes the feed's address from the directory. `503` with no directory set up | +| `DELETE /api/directory/{id}`, `POST /api/directory/{id}/refresh` | admin-only, and the directory refuses unless this server's token is `rwa` (#206): remove and block a feed; check it now. `GET /api/directory/{id}` says `can_admin` when both hold | +| `GET /api/directory-admin`, `DELETE /api/directory-blocked/{id}`, `DELETE /api/directory-instances/{name}` | admin-only, `rwa` (#206): the servers sharing (name, feeds, subscribers) and the blocked feeds (id, title, when; never the address, often why it was blocked); unblock; drop a server's snapshot. Unblocking or dropping sends this server's snapshot again at once | | `GET /api/directory-status` | admin-only: the name and access the directory gives this server's token, or why it refused, and how the last snapshot went | | `GET /api/settings`, `PATCH /api/settings` | admin-only to write. The directory's token is written, never read back: `directory_token_set` says whether there is one | | `GET /api/users`, `POST /api/users`, `PATCH /api/users/{id}`, `DELETE /api/users/{id}` | admin-only; the only admin cannot be demoted or removed | diff --git a/docs/directory.md b/docs/directory.md index a81a3be..a1c269b 100644 --- a/docs/directory.md +++ b/docs/directory.md @@ -21,13 +21,21 @@ ipx keeps the address and token with its other server settings, in its database again: the tab says it is set and offers to clear it. To replace one, make a new token on the directory, paste it over the old, then delete the old token's entry from the directory's config. -With sharing on, ipx sends its snapshot after every scan, whenever the catalogue or these settings -change, and every half hour: each feed checked at least once, with how many people here subscribe, +With sharing on, ipx sends its snapshot when what it would send has changed (checked after every +scan, and whenever the catalogue or these settings change), and every half hour regardless (#211): each feed checked at least once, with how many people here subscribe, and the category an admin gave a feed that names none. It never sends an OPML or a Patreon creator itself (their feeds go instead), or a private feed. A feed's settings have a **Private** switch for a paid feed iPX does not recognise by its address; anyone subscribed may turn it on, and only an admin off. +When this server's token is `rwa`, its admins administer the directory from iPX (#206). A feed's +page in the Directory has **Check now** and **Remove from the directory**, which blocks it so no +server's snapshot brings it back. Admin, Directory lists the **Servers** sharing, by token name, +with how many feeds and subscribers each gave, and **Blocked**, by title, with Unblock. Dropping a +server's snapshot takes its feeds and counts out until it next shares; to keep a server out, +revoke its token. An admin's own subscriptions open as their feeds, not as Directory pages, so +removing one of those is done by marking it private, or from another admin's account. + ## Tokens Everything but `/healthz` needs a token, sent as `Authorization: Bearer `. Each one has a @@ -109,6 +117,7 @@ blocks it, so no snapshot brings it back. | `POST /v1/snapshot` | rw | `[{"url", "subscribers"}]`, at most 20,000: replaces everything this token sent before; answers what it `accepted` and how many it refused as private, blocked or invalid | | `DELETE /v1/feeds/{id}` | rwa | remove and block | | `GET /v1/blocked`, `DELETE /v1/blocked/{id}` | rwa | what is blocked; unblock | +| `GET /v1/instances` | rwa | each token holding a snapshot: its `name`, `feeds` and `subscribers` | | `DELETE /v1/instances/{name}` | rwa | drop one token's snapshot, its counts with it | | `POST /v1/feeds/{id}/refresh` | rwa | fetch the feed now, answering with its page | diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index a15591b..15812bb 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -1376,6 +1376,70 @@ test('the admin page sets up the directory, and a feed marked private leaves it if (paid) expect(paid.private_by_address).toBe(true); }); +test('an admin removes a feed from the directory, and unblocks it (#206)', async ({ page, browser }) => { + const all = () => page.evaluate(() => api('/api/directory')); + const listed = async () => (await all()).map(p => p.title); + await expect.poll(listed, { timeout: 20_000 }).toContain('Aardvark Radio'); + const id = (await all()).find(p => p.title === 'Aardvark Radio').id; + + // Someone who is not an admin is neither offered it nor allowed it. + const { execFileSync } = require('child_process'); + const setup = require('./global-setup'); + const env = { ...process.env, IPX_CONFIG: `${setup.root}/config/config.toml`, IPX_DATA_DIR: `${setup.root}/data` }; + try { + execFileSync('./target/debug/ipx', ['user', 'add', 'piper'], { input: 'piperpassword', env }); + } catch (e) { + if (!String(e.stderr || e.stdout).includes('already exists')) throw e; + } + const ctx = await browser.newContext(); + const piper = await ctx.newPage(); + await piper.goto('/login'); + await piper.locator('#name').fill('piper'); + await piper.locator('#pw').fill('piperpassword'); + await piper.locator('button[type=submit]').click(); + await expect(piper.locator('#feedlist')).toBeVisible(); + expect((await (await piper.request.get(`/api/directory/${id}`)).json()).can_admin).toBe(false); + expect((await piper.request.delete(`/api/directory/${id}`)).status()).toBe(403); + expect((await piper.request.post(`/api/directory/${id}/refresh`)).status()).toBe(403); + expect((await piper.request.get('/api/directory-admin')).status()).toBe(403); + expect((await piper.request.delete('/api/directory-instances/test')).status()).toBe(403); + + // Made an admin, and subscribed to none of it, piper opens its page and is offered both. The + // admin here subscribes to every fixture, so their tiles open their own feeds instead. + const users = await page.evaluate(() => api('/api/users')); + const pid = users.find(u => u.name === 'piper').id; + const setAdmin = admin => page.evaluate(([id, admin]) => api(`/api/users/${id}`, { method: 'PATCH', body: JSON.stringify({ admin }) }), [pid, admin]); + await setAdmin(true); + try { + await piper.goto('/'); + await piper.locator('#feedlist .place', { hasText: 'Directory' }).click(); + await piper.locator('#epSearch').fill('aardvark'); + await piper.locator('#dtiles .tile', { hasText: 'Aardvark Radio' }).locator('.txt').click(); + await expect(piper.locator('.show h2')).toHaveText('Aardvark Radio'); + await piper.locator('.show [data-a="drefresh"]').click(); + await expect(piper.locator('.toast', { hasText: 'Checked' })).toBeVisible(); + await expect(piper.locator('.show [data-a="dremove"]')).toHaveCount(1); + piper.once('dialog', d => d.accept()); + await piper.locator('.show [data-a="dremove"]').click(); + await expect(piper.locator('.show h2')).toHaveCount(0); + expect(await listed()).not.toContain('Aardvark Radio'); + } finally { + await setAdmin(false); + await ctx.close(); + } + + // Admin, Directory lists the servers sharing and what is blocked, by title, never its address. + await page.goto('/admin#directory'); + await expect(page.locator('#dadmin .urow[data-name="test"]')).toContainText(/\d+ feeds? · \d+ subscribers?/); + const blocked = page.locator('#dadmin .urow[data-id]', { hasText: 'Aardvark Radio' }); + await expect(blocked).toBeVisible(); + expect(JSON.stringify(await page.evaluate(() => api('/api/directory-admin')))).not.toMatch(/https?:/); + // Unblocked, it is back with the snapshot ipx sends straight away, unchanged as it is. + await blocked.locator('[data-a="unblock"]').click(); + await expect(blocked).toHaveCount(0); + await expect.poll(listed, { timeout: 20_000 }).toContain('Aardvark Radio'); +}); + test('the pinned heading sits over its pins, and the page is set in Inter', async ({ page }) => { await page.locator('.feed', { hasText: 'Test Show' }).click(); await expect(page.locator('#eps .ep').first()).toBeVisible({ timeout: 20_000 }); diff --git a/web/app.css b/web/app.css index 8516839..ff705ed 100644 --- a/web/app.css +++ b/web/app.css @@ -893,6 +893,8 @@ body.adminpage #topbar > a.btn{text-decoration:none} html:not([data-admin]) #topbar a#admin{display:none} body.adminpage #admin{max-width:780px;margin:0 auto} body.adminpage #admin h2{font-size:18px;margin:0 0 12px} +/* The directory's Servers and Blocked follow its Save and each other's rows (#206). */ +body.adminpage #dadmin h2{margin-top:32px} body.adminpage #log{max-width:none} .urow{padding:9px 0;border-bottom:1px solid var(--line)} .toolbar .grow{flex:1;min-width:150px;max-width:320px} diff --git a/web/src/admin.ts b/web/src/admin.ts index cf9da34..57618b5 100644 --- a/web/src/admin.ts +++ b/web/src/admin.ts @@ -92,7 +92,8 @@ async function drawDirectorySettings(){ Sends the directory the feeds people here subscribe to, and how many of them do, every half hour. Never a private feed: one marked private, one with a login, or one whose address carries a key.

Asking the directory…

-
`; +
+
`; const save = async (patch: Record, said: string) => { try{ await api('/api/settings', {method: 'PATCH', body: JSON.stringify(patch)}); @@ -129,6 +130,48 @@ async function drawDirectoryStatus(){ (sh.refused_blocked ? ` ${plural(sh.refused_blocked, 'feed')} the directory has removed.` : '')); } el.innerHTML = lines.join('
'); + if(s.access === 'rwa') drawDirectoryAdmin(); +} + +/// What a token that may administer the directory can change from here (#206): the servers +/// sharing with it, and the feeds removed from it. +async function drawDirectoryAdmin(){ + const box = $('#dadmin'); + let a; + try{ a = await api('/api/directory-admin'); }catch(e){ box.innerHTML = `

${esc(e.message)}

`; return; } + if(!box.isConnected) return; + box.innerHTML = `

Servers

+

Each server sharing with the directory, by its token's name. Dropping one removes its + feeds and counts until it next shares; to keep it out, revoke its token in the directory's config.

+ ${a.instances.length ? a.instances.map(i => `
+
${esc(i.name)} + ${plural(i.feeds, 'feed')} · ${plural(i.subscribers, 'subscriber')}
+
`).join('') + : '

No server is sharing yet.

'} +

Blocked

+

Feeds removed from the directory. No snapshot brings them back until they are unblocked.

+ ${a.blocked.length ? a.blocked.map(b => `
+
${esc(b.title || 'Untitled')} + Removed ${dateOf(b.at)}
+
`).join('') + : '

Nothing is blocked.

'}`; + box.onclick = async ev => { + const b = (ev.target as Element).closest('[data-a]'); + if(!b) return; + const row = b.closest('.urow'); + try{ + if(b.dataset.a === 'drop'){ + const name = row.dataset.name; + if(!confirm(`Drop ${name}'s feeds and counts from the directory? They come back the next time ${name} shares.`)) return; + await api(`/api/directory-instances/${encodeURIComponent(name)}`, {method: 'DELETE'}); + toast(`Dropped ${name}'s snapshot`); + }else if(b.dataset.a === 'unblock'){ + await api(`/api/directory-blocked/${encodeURIComponent(row.dataset.id)}`, {method: 'DELETE'}); + toast('Unblocked. It comes back with the next snapshot that has it'); + } + drawDirectoryAdmin(); + }catch(e){ toast(e.message, true); } + }; } /* ---------------- accounts ---------------- */ diff --git a/web/src/dialogs.ts b/web/src/dialogs.ts index a8bf73d..12d0e73 100644 --- a/web/src/dialogs.ts +++ b/web/src/dialogs.ts @@ -225,6 +225,25 @@ async function fillShow(box,p){ } // A broken feed says so, or it reads as a quiet one (#142). if(got.failing){ const f=$('#dfail',box); f.textContent=got.failing; f.hidden=false; } + // An admin whose token may administer the directory (#206); once, as Refresh fills it again. + const acts=$('.show .acts',box); + if(got.can_admin&&!$('[data-a="dremove"]',acts)){ + acts.insertAdjacentHTML('beforeend', + `` + +``); + $('[data-a="drefresh"]',acts).onclick=async()=>{ + try{ await api(`/api/directory/${encodeURIComponent(p.id)}/refresh`,{method:'POST'}); toast('Checked'); fillShow(box,p); } + catch(e){ toast(e.message,true); } + }; + $('[data-a="dremove"]',acts).onclick=async()=>{ + if(!confirm(`Remove ${NAME(p)} from the directory? It is blocked, so no server's snapshot brings it back. Admin, Directory can unblock it.`)) return; + try{ await api(`/api/directory/${encodeURIComponent(p.id)}`,{method:'DELETE'}); } + catch(e){ return toast(e.message,true); } + toast(`Removed ${NAME(p)} from the directory`); + dirRows=dirRows.filter(r=>r.id!==p.id); dirShow=null; + drawDirectory(box); + }; + } const items=got.items; list.innerHTML=items.length?items.map(e=>{ const name=entryName(e), text=name.derived?'':plainText(e.description).slice(0,500);