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:
@@ -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).
|
||||||
|
|||||||
@@ -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?;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 ---------------- */
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user