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
directory's 403 reaches the page as 403, not 502.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 16:06:03 +00:00
parent 10a78efcc3
commit 954399ed5d
14 changed files with 299 additions and 11 deletions

View File

@@ -12,5 +12,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- `GET /v1/instances` lists the servers holding a snapshot, with their feeds and subscribers, for an admin token (#206).
- `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, each feed listed once it has been read. Tokens are in its config file, each read-only, read-write or admin, and revoked one at a time (#204, #205). - `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, each feed listed once it has been read. Tokens are in its config file, each read-only, read-write or admin, and revoked one at a time (#204, #205).
- Traces: a span a request, joining the trace of the ipx server that called, and a trace a refresher round with a span a feed (#213). - Traces: a span a request, joining the trace of the ipx server that called, and a trace a refresher round with a span a feed (#213).

View File

@@ -94,6 +94,14 @@ pub struct Me {
pub access: Access, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Blocked { pub struct Blocked {
pub id: String, pub id: String,
@@ -242,6 +250,7 @@ pub fn router(dir: Shared) -> Router {
.route("/v1/snapshot", post(snapshot)) .route("/v1/snapshot", post(snapshot))
.route("/v1/blocked", get(blocked)) .route("/v1/blocked", get(blocked))
.route("/v1/blocked/{id}", delete(unblock)) .route("/v1/blocked/{id}", delete(unblock))
.route("/v1/instances", get(instances))
.route("/v1/instances/{name}", delete(drop_instance)) .route("/v1/instances/{name}", delete(drop_instance))
.layer(axum::middleware::from_fn_with_state(dir.clone(), authenticate)); .layer(axum::middleware::from_fn_with_state(dir.clone(), authenticate));
Router::new() Router::new()
@@ -402,6 +411,11 @@ async fn unblock(State(dir): State<Shared>, Extension(me): Extension<Me>, Path(i
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
async fn instances(State(dir): State<Shared>, Extension(me): Extension<Me>) -> Result<Json<Vec<Instance>>, ApiError> {
need(&me, Access::Rwa)?;
Ok(Json(dir.store.instances().await?))
}
async fn drop_instance(State(dir): State<Shared>, Extension(me): Extension<Me>, Path(name): Path<String>) -> Result<StatusCode, ApiError> { async fn drop_instance(State(dir): State<Shared>, Extension(me): Extension<Me>, Path(name): Path<String>) -> Result<StatusCode, ApiError> {
need(&me, Access::Rwa)?; need(&me, Access::Rwa)?;
let n = dir.store.drop_instance(&name).await?; let n = dir.store.drop_instance(&name).await?;

View File

@@ -5,7 +5,7 @@
use anyhow::Result; use anyhow::Result;
use sea_orm::{ConnectionTrait, DatabaseConnection, Statement, TransactionTrait, Value}; 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. /// A feed's items as its page shows them, and as many as ipx's own Directory page showed.
pub const ITEMS: usize = 20; 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) Ok(self.exec("DELETE FROM blocked WHERE id = $1", vec![id.into()]).await? > 0)
} }
pub async fn instances(&self) -> Result<Vec<Instance>> {
// 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<Vec<Blocked>> { pub async fn blocked(&self) -> Result<Vec<Blocked>> {
self.rows("SELECT id, url, title, at FROM blocked ORDER BY at DESC, id", vec![]) self.rows("SELECT id, url, title, at FROM blocked ORDER BY at DESC, id", vec![])
.await? .await?
@@ -498,6 +511,9 @@ pub(crate) mod tests {
assert_eq!(all.len(), 1); assert_eq!(all.len(), 1);
assert_eq!(all[0].subscribers, 3); 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::<Vec<_>>(), [("a", 1, 1), ("b", 1, 2)]);
// A token taken out of the config file takes its counts. // A token taken out of the config file takes its counts.
s.keep_instances(&["b".into()]).await.unwrap(); s.keep_instances(&["b".into()]).await.unwrap();
assert_eq!(s.list().await.unwrap()[0].subscribers, 2); assert_eq!(s.list().await.unwrap()[0].subscribers, 2);

View File

@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- With a directory token that may administer it, ipx's admins remove a feed from the directory (blocking it) or check it now, list and unblock what is blocked, and list and drop the servers sharing (#206).
- ipx passes its trace on when it calls the directory, so a Directory page is one trace from the browser to the directory (#213). - ipx passes its trace on when it calls the directory, so a Directory page is one trace from the browser to the directory (#213).
### Changed ### Changed

View File

@@ -71,7 +71,13 @@ async fn call<T: DeserializeOwned>(ctx: &Ctx, method: reqwest::Method, path: &st
let msg = resp.json::<serde_json::Value>().await.ok().and_then(|v| v["error"].as_str().map(str::to_owned)).unwrap_or_default(); let msg = resp.json::<serde_json::Value>().await.ok().and_then(|v| v["error"].as_str().map(str::to_owned)).unwrap_or_default();
return Err(DirError::Refused(status.as_u16(), msg)); 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<Me, DirError> { pub async fn me(ctx: &Ctx) -> Result<Me, DirError> {
@@ -87,7 +93,47 @@ pub async fn popular(ctx: &Ctx) -> Result<Vec<Listing>, DirError> {
} }
pub async fn page(ctx: &Ctx, id: &str) -> Result<FeedPage, DirError> { pub async fn page(ctx: &Ctx, id: &str) -> Result<FeedPage, DirError> {
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<serde::de::IgnoredAny, DirError> {
call(ctx, reqwest::Method::POST, &format!("/v1/feeds/{}/refresh", seg(id)), None).await
}
pub async fn blocked(ctx: &Ctx) -> Result<Vec<ipx_directory::Blocked>, 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<Vec<ipx_directory::Instance>, 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 /// Whether a feed is someone's own: marked private, given a username or password, or at an
@@ -150,7 +196,8 @@ pub async fn run(ctx: std::sync::Arc<Ctx>, mut stop: tokio::sync::watch::Receive
let got = match snapshot(&ctx).await { let got = match snapshot(&ctx).await {
Ok(entries) => { Ok(entries) => {
let key = serde_json::to_vec(&(target(&cfg), &entries)).unwrap_or_default(); 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 span = tracing::info_span!(target: "ipx::directory", "directory_share", feeds = entries.len()); let span = tracing::info_span!(target: "ipx::directory", "directory_share", feeds = entries.len());
let got = tracing::Instrument::instrument(share(&ctx, &entries), span).await; let got = tracing::Instrument::instrument(share(&ctx, &entries), span).await;
if got.is_ok() { if got.is_ok() {

View File

@@ -166,6 +166,9 @@ pub struct Ctx {
pub directory_wake: tokio::sync::Notify, pub directory_wake: tokio::sync::Notify,
/// When the snapshot last went and what came of it, for the admin page. /// When the snapshot last went and what came of it, for the admin page.
pub directory_shared: std::sync::Mutex<Option<directory::Shared>>, pub directory_shared: std::sync::Mutex<Option<directory::Shared>>,
/// 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. /// 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(), deadair: Default::default(),
directory_wake: Default::default(), directory_wake: Default::default(),
directory_shared: Default::default(), directory_shared: Default::default(),
directory_resend: Default::default(),
}); });
let result = match cli.command { let result = match cli.command {
@@ -2501,6 +2505,7 @@ mod tests {
deadair: Default::default(), deadair: Default::default(),
directory_wake: Default::default(), directory_wake: Default::default(),
directory_shared: Default::default(), directory_shared: Default::default(),
directory_resend: Default::default(),
directory_cleaned: Default::default(), directory_cleaned: Default::default(),
} }
} }

View File

@@ -62,7 +62,11 @@ pub fn router(state: WebState) -> Router {
.route("/api/status", get(status)) .route("/api/status", get(status))
.route("/api/popular", get(get_popular)) .route("/api/popular", get(get_popular))
.route("/api/directory", get(get_directory)) .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/popular/{id}", post(subscribe_popular))
.route("/api/users", get(list_users).post(add_user)) .route("/api/users", get(list_users).post(add_user))
.route("/api/users/{id}", patch(patch_user).delete(remove_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 { let status = match &e {
DirError::NotConfigured => StatusCode::SERVICE_UNAVAILABLE, DirError::NotConfigured => StatusCode::SERVICE_UNAVAILABLE,
DirError::Refused(404, _) => StatusCode::NOT_FOUND, 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, _ => StatusCode::BAD_GATEWAY,
}; };
if !matches!(e, DirError::NotConfigured | DirError::Refused(404, _)) { 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. /// Why its last check failed, when it did (#142), in the directory's words.
failing: Option<String>, failing: Option<String>,
items: Vec<ListedItem>, items: Vec<ListedItem>,
/// 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 /// 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. /// the feed's own HTML, which the directory passes on as it was sent.
async fn get_listed( async fn get_listed(
State(state): State<WebState>, State(state): State<WebState>,
_user: crate::db::User, user: crate::db::User,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<Json<Listed>, ApiError> { ) -> Result<Json<Listed>, 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 mut sanitizer = feed_sanitizer();
let description = page.description.as_deref().map(|d| clean_description(&mut sanitizer, d, None)); let description = page.description.as_deref().map(|d| clean_description(&mut sanitizer, d, None));
let items = page let items = page
@@ -912,7 +923,60 @@ async fn get_listed(
duration: e.duration, duration: e.duration,
}) })
.collect(); .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<WebState>, user: crate::db::User, Path(id): Path<String>) -> Result<StatusCode, ApiError> {
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<WebState>, user: crate::db::User, Path(id): Path<String>) -> Result<StatusCode, ApiError> {
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<String>,
at: i64,
}
async fn get_directory_admin(State(state): State<WebState>, user: crate::db::User) -> Result<Json<serde_json::Value>, ApiError> {
admin_only(&user)?;
let (instances, blocked) = tokio::join!(crate::directory::instances(&state.ctx), crate::directory::blocked(&state.ctx));
let blocked: Vec<BlockedRow> = 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<WebState>, user: crate::db::User, Path(id): Path<String>) -> Result<StatusCode, ApiError> {
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<WebState>, user: crate::db::User, Path(name): Path<String>) -> Result<StatusCode, ApiError> {
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 /// Subscribes by the directory's id, because the page never sees a URL: the address comes from

View File

@@ -161,6 +161,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/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/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 | | `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/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/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 | | `GET /api/users`, `POST /api/users`, `PATCH /api/users/{id}`, `DELETE /api/users/{id}` | admin-only; the only admin cannot be demoted or removed |

View File

@@ -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 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. 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 With sharing on, ipx sends its snapshot when what it would send has changed (checked after every
change, and every half hour: each feed checked at least once, with how many people here subscribe, 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 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 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 a paid feed iPX does not recognise by its address; anyone subscribed may turn it on, and only an
admin off. 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 ## Tokens
Everything but `/healthz` needs a token, sent as `Authorization: Bearer <token>`. Each one has a Everything but `/healthz` needs a token, sent as `Authorization: Bearer <token>`. Each one has a
@@ -116,6 +124,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 | | `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 | | `DELETE /v1/feeds/{id}` | rwa | remove and block |
| `GET /v1/blocked`, `DELETE /v1/blocked/{id}` | rwa | what is blocked; unblock | | `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 | | `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 | | `POST /v1/feeds/{id}/refresh` | rwa | fetch the feed now, answering with its page |

View File

@@ -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); 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 }) => { 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 page.locator('.feed', { hasText: 'Test Show' }).click();
await expect(page.locator('#eps .ep').first()).toBeVisible({ timeout: 20_000 }); await expect(page.locator('#eps .ep').first()).toBeVisible({ timeout: 20_000 });

View File

@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- With a directory token that may administer it, an admin has Check now and Remove from the directory on a Directory feed's page, and Servers and Blocked lists in Admin, Directory (#206).
- The page has a version of its own, which the logo's tooltip shows beside ipx's (#207). - The page has a version of its own, which the logo's tooltip shows beside ipx's (#207).
- The page is served by its own container, ipx-web (Caddy), which passes the API to ipx on the same address, so a change to the page deploys without rebuilding ipx (#203). - The page is served by its own container, ipx-web (Caddy), which passes the API to ipx on the same address, so a change to the page deploys without rebuilding ipx (#203).
- Admin, Directory sets the directory's address and this server's token, and whether to share, and says how the last share went (#205). - Admin, Directory sets the directory's address and this server's token, and whether to share, and says how the last share went (#205).

View File

@@ -893,6 +893,8 @@ body.adminpage #topbar > a.btn{text-decoration:none}
html:not([data-admin]) #topbar a#admin{display:none} html:not([data-admin]) #topbar a#admin{display:none}
body.adminpage #admin{max-width:780px;margin:0 auto} body.adminpage #admin{max-width:780px;margin:0 auto}
body.adminpage #admin h2{font-size:18px;margin:0 0 12px} 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} body.adminpage #log{max-width:none}
.urow{padding:9px 0;border-bottom:1px solid var(--line)} .urow{padding:9px 0;border-bottom:1px solid var(--line)}
.toolbar .grow{flex:1;min-width:150px;max-width:320px} .toolbar .grow{flex:1;min-width:150px;max-width:320px}

View File

@@ -92,7 +92,8 @@ async function drawDirectorySettings(){
<span class="hint">Sends the directory the feeds people here subscribe to, and how many of them do, every <span class="hint">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.</span></div> half hour. Never a private feed: one marked private, one with a login, or one whose address carries a key.</span></div>
<div class="field"><label>Status</label><p class="hint" id="dstatus">Asking the directory…</p></div> <div class="field"><label>Status</label><p class="hint" id="dstatus">Asking the directory…</p></div>
<div class="cardacts"><button class="btn primary" id="dsave">${ICON.check} Save</button></div>`; <div class="cardacts"><button class="btn primary" id="dsave">${ICON.check} Save</button></div>
<div id="dadmin"></div>`;
const save = async (patch: Record<string, unknown>, said: string) => { const save = async (patch: Record<string, unknown>, said: string) => {
try{ try{
await api('/api/settings', {method: 'PATCH', body: JSON.stringify(patch)}); 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.` : '')); (sh.refused_blocked ? ` ${plural(sh.refused_blocked, 'feed')} the directory has removed.` : ''));
} }
el.innerHTML = lines.join('<br>'); el.innerHTML = lines.join('<br>');
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 = `<p class="hint">${esc(e.message)}</p>`; return; }
if(!box.isConnected) return;
box.innerHTML = `<h2>Servers</h2>
<p class="hint">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.</p>
${a.instances.length ? a.instances.map(i => `<div class="inline urow" data-name="${esc(i.name)}">
<div style="flex:1;min-width:0"><b style="overflow-wrap:anywhere">${esc(i.name)}</b>
<small style="display:block;color:var(--faint)">${plural(i.feeds, 'feed')} · ${plural(i.subscribers, 'subscriber')}</small></div>
<button class="btn ico danger" data-a="drop" title="Drop ${esc(i.name)}'s feeds" aria-label="Drop ${esc(i.name)}'s feeds">${ICON.trash}</button></div>`).join('')
: '<p class="hint">No server is sharing yet.</p>'}
<h2>Blocked</h2>
<p class="hint">Feeds removed from the directory. No snapshot brings them back until they are unblocked.</p>
${a.blocked.length ? a.blocked.map(b => `<div class="inline urow" data-id="${esc(b.id)}">
<div style="flex:1;min-width:0"><b style="overflow-wrap:anywhere">${esc(b.title || 'Untitled')}</b>
<small style="display:block;color:var(--faint)">Removed ${dateOf(b.at)}</small></div>
<button class="btn" data-a="unblock">Unblock</button></div>`).join('')
: '<p class="hint">Nothing is blocked.</p>'}`;
box.onclick = async ev => {
const b = (ev.target as Element).closest<HTMLElement>('[data-a]');
if(!b) return;
const row = b.closest<HTMLElement>('.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 ---------------- */ /* ---------------- accounts ---------------- */

View File

@@ -225,6 +225,25 @@ async function fillShow(box,p){
} }
// A broken feed says so, or it reads as a quiet one (#142). // 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; } 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',
`<button class="btn ico" data-a="drefresh" title="Check this feed now" aria-label="Check this feed now">${ICON.scan}</button>`
+`<button class="btn ico danger" data-a="dremove" title="Remove from the directory" aria-label="Remove from the directory">${ICON.trash}</button>`);
$('[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; const items=got.items;
list.innerHTML=items.length?items.map(e=>{ list.innerHTML=items.length?items.map(e=>{
const name=entryName(e), text=name.derived?'':plainText(e.description).slice(0,500); const name=entryName(e), text=name.derived?'':plainText(e.description).slice(0,500);