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:
@@ -10,5 +10,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### 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).
|
||||
- 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,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@@ -242,6 +250,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()
|
||||
@@ -402,6 +411,11 @@ async fn unblock(State(dir): State<Shared>, Extension(me): Extension<Me>, Path(i
|
||||
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> {
|
||||
need(&me, Access::Rwa)?;
|
||||
let n = dir.store.drop_instance(&name).await?;
|
||||
|
||||
@@ -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<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>> {
|
||||
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::<Vec<_>>(), [("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);
|
||||
|
||||
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### 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).
|
||||
|
||||
### 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();
|
||||
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> {
|
||||
@@ -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> {
|
||||
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
|
||||
@@ -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 {
|
||||
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 span = tracing::info_span!(target: "ipx::directory", "directory_share", feeds = entries.len());
|
||||
let got = tracing::Instrument::instrument(share(&ctx, &entries), span).await;
|
||||
if got.is_ok() {
|
||||
|
||||
@@ -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<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.
|
||||
@@ -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 {
|
||||
@@ -2501,6 +2505,7 @@ mod tests {
|
||||
deadair: Default::default(),
|
||||
directory_wake: Default::default(),
|
||||
directory_shared: Default::default(),
|
||||
directory_resend: Default::default(),
|
||||
directory_cleaned: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
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
|
||||
/// the feed's own HTML, which the directory passes on as it was sent.
|
||||
async fn get_listed(
|
||||
State(state): State<WebState>,
|
||||
_user: crate::db::User,
|
||||
user: crate::db::User,
|
||||
Path(id): Path<String>,
|
||||
) -> 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 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<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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 <token>`. 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 |
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -11,6 +11,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 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 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).
|
||||
- A feed's settings have a Private switch, which keeps it out of the directory; on and locked for a feed whose address or login shows it is private (#205).
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
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="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) => {
|
||||
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('<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 ---------------- */
|
||||
|
||||
@@ -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',
|
||||
`<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;
|
||||
list.innerHTML=items.length?items.map(e=>{
|
||||
const name=entryName(e), text=name.derived?'':plainText(e.description).slice(0,500);
|
||||
|
||||
Reference in New Issue
Block a user