Files
ipx/crates/ipx/src/web.rs

2564 lines
102 KiB
Rust

//! Web front end. Runs inside the daemon so it reads SQLite and the event bus directly.
use anyhow::{Context, Result};
use axum::http::HeaderMap;
use axum::{
Json, Router,
extract::{MatchedPath, Path, Query, Request, State},
http::{StatusCode, header},
middleware::{self, Next},
response::{
IntoResponse, Redirect, Response,
sse::{Event as SseEvent, Sse},
},
routing::{delete, get, patch, post},
};
use serde::Deserialize;
use tower::ServiceExt;
use tower_http::services::ServeFile;
use serde::Serialize;
use std::sync::Arc;
use tokio::sync::{broadcast, mpsc};
use crate::Ctx;
use crate::db::step;
use tracing::Instrument;
use crate::ipc::{Command, Event};
const COOKIE: &str = "ipx_token";
#[derive(Clone)]
pub struct WebState {
pub ctx: Arc<Ctx>,
pub cmds: mpsc::Sender<Command>,
pub events: broadcast::Sender<Event>,
/// Cloudflare Access's signing keys, fetched once and kept.
pub access: Arc<crate::access::Keys>,
pub playing: crate::deadair::Playing,
}
pub fn router(state: WebState) -> Router {
Router::new()
.route("/api/me", get(me).patch(patch_me))
.route("/api/tokens", get(list_tokens).post(add_token))
.route("/api/tokens/{id}", axum::routing::delete(remove_token))
.route("/api/logout", post(logout))
.route("/api/feeds", get(feeds).post(add_feed))
.route("/api/feeds/{id}", patch(patch_feed).delete(remove_feed))
.route("/api/feeds/{id}/entries", get(entries))
.route("/api/entries", get(all_entries))
.route("/api/signals", get(list_signals).post(add_signal))
.route("/api/signals/icon", post(pick_signal_icon))
.route("/api/signals/{id}", patch(patch_signal).delete(remove_signal))
.route("/api/signals/{id}/entries", get(signal_entries))
.route("/api/entries/{feed_id}/{guid}", get(one_entry))
.route("/api/entries/{feed_id}/{guid}/flags", post(set_flags))
.route("/api/entries/{feed_id}/{guid}/position", post(set_position))
.route("/api/feeds/{id}/read-all", post(read_all))
.route("/api/read-all", post(read_all_mine))
.route("/api/feeds/{id}/download-latest", post(download_latest))
.route("/api/enclosures/{id}/download", post(download_now))
.route("/api/enclosures/{id}", delete(delete_file))
.route("/api/fetch", post(fetch_now))
.route("/api/opml", get(export_opml).post(import_opml))
.route("/api/settings", get(get_settings).patch(patch_settings))
.route("/api/directory-status", get(get_directory_status))
.route("/api/status", get(status))
.route("/api/popular", get(get_popular))
.route("/api/directory", get(get_directory))
.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))
.route("/api/logs", get(logs))
.route("/api/client", post(client_event))
.route("/api/events", get(events))
.route("/media/{id}", get(media))
.route("/media/{id}/deadair.m3u8", get(media_deadair))
.route("/media/{id}/deadair.mp3", get(media_piece))
.route("/media/{id}/deadair", get(media_save))
.route("/api/art", get(art))
.route("/api/page-check", get(page_check))
.route("/api/signin", get(signin))
.layer(middleware::from_fn_with_state(state.clone(), auth))
// Signing in cannot require being signed in, so these sit outside the auth layer.
.route("/api/login", post(login))
.route("/api/login-check", get(login_check))
.route_layer(middleware::from_fn(name_span))
.layer(middleware::from_fn(access_log))
.with_state(state)
}
pub async fn serve(state: WebState, bind: &str) -> Result<()> {
use axum::serve::ListenerExt;
// Nagle off, as axum 0.7 had it: under load a response goes out in parts, and with Nagle
// on its last small part waits for the browser's delayed ACK, up to 40 ms (#170).
let listener = tokio::net::TcpListener::bind(bind)
.await
.with_context(|| format!("binding {bind}"))?
.tap_io(|tcp| {
if let Err(e) = tcp.set_nodelay(true) {
tracing::debug!(error = %e, "could not set TCP_NODELAY");
}
});
tracing::info!(bind, "web ui listening");
axum::serve(
listener,
router(state).into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.await
.context("serving the web ui")
}
/// Who is asking, in order of how specific the claim is: a proxy that vouches for a name, an
/// API token, a session cookie, then the shared token (which is the admin).
///
/// Any of them has to survive being put in a cookie: an `<audio src>` request is issued by
/// the browser, and there is no way to attach a header to it.
async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Response {
let cfg = state.ctx.cfg();
let token = cfg.web.token.clone();
// 1. A header, but only from a hop we were told to believe.
let vouched = vouched_name(&state, &cfg, peer(&req), req.headers()).await;
let mut set_cookie: Option<String> = None;
let mut user = None;
if let Some(name) = vouched {
user = match state.ctx.db.user_by_name(&name).await {
Ok(Some(u)) => Some(u),
Ok(None) if cfg.web.auto_create_users => {
tracing::info!(user = %name, "creating an account for a name the proxy vouched for");
// The first account made is the admin.
let first = state.ctx.db.users().await.map(|u| u.is_empty()).unwrap_or(false);
match state.ctx.db.create_user(&name, None, first).await {
Ok(id) => state.ctx.db.user_by_id(id).await.ok().flatten(),
Err(_) => None,
}
}
Ok(None) => {
tracing::warn!(user = %name, "proxy vouched for an unknown name and auto_create_users is off");
None
}
Err(e) => {
tracing::error!(error = %e, "looking up the vouched user");
None
}
};
// Every request comes vouched for; signed_in keeps one an hour. Failing to note the time
// must not turn anyone away, so its error goes unanswered.
if let Some(u) = &user {
let _ = state.ctx.db.signed_in(u.id).await;
}
}
let by_proxy = user.is_some();
// An API token, made by its owner in Settings, for a script or an agent acting as them
// (#123). One that is not known is turned away rather than tried as anything else: a
// script sent with a revoked token should hear so, not fall through to a cookie.
if user.is_none()
&& let Some(bearer) = req
.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
{
match state.ctx.db.api_token_user(&crate::auth::api_token_hash(bearer.trim())).await {
Ok(Some(u)) => user = Some(u),
Ok(None) => return (StatusCode::UNAUTHORIZED, "unknown or revoked API token").into_response(),
Err(e) => {
tracing::error!(error = %e, "looking up an API token");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
}
}
// 2. A session cookie from signing in here.
if user.is_none() {
if let Some(sid) = cookie(&req, SESSION_COOKIE) {
user = state
.ctx
.db
.session_user(&sid, cfg.web.session_days.max(1) * 86_400).await
.unwrap_or(None);
}
}
// 3. The shared token, which is the admin: the healthcheck and any scripts predate
// accounts and must keep working.
let from_query = req.uri().query().and_then(|q| {
q.split('&')
.find_map(|kv| kv.strip_prefix("token=").map(str::to_owned))
});
if user.is_none() && !token.is_empty() {
let supplied = from_query.clone().or_else(|| cookie(&req, COOKIE));
if supplied.is_some_and(|t| constant_time_eq(&t, &token)) {
user = admin_user(&state).await;
if from_query.is_some() {
// The token link is a sign-in; the cookie it leaves behind is not one each time.
if let Some(u) = &user {
let _ = state.ctx.db.signed_in(u.id).await;
}
set_cookie = Some(format!(
"{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000"
));
}
}
}
let Some(user) = user else {
// A browser asking for a page gets the sign-in form; anything else gets a 401 it
// can act on.
let wants_html = req
.headers()
.get(header::ACCEPT)
.and_then(|v| v.to_str().ok())
.is_some_and(|a| a.contains("text/html"));
return if wants_html {
Redirect::to("/login").into_response()
} else {
(StatusCode::UNAUTHORIZED, "sign in").into_response()
};
};
req.extensions_mut().insert(user);
req.extensions_mut().insert(Proxied(by_proxy));
let mut resp = next.run(req).await;
if let Some(c) = set_cookie {
if let Ok(v) = header::HeaderValue::from_str(&c) {
resp.headers_mut().insert(header::SET_COOKIE, v);
}
}
resp
}
const SESSION_COOKIE: &str = "ipx_session";
/// Whether the proxy signed this request in, rather than a session or the token: signing out
/// has to go through the proxy then, or its next request signs the person straight back in.
#[derive(Clone, Copy)]
struct Proxied(bool);
/// The name the proxy vouches for, when this request came from one of `trusted_proxies` and
/// carries `trusted_header`. Anyone able to reach the port could otherwise send the header and
/// be whoever they liked. With `access_team` and `access_aud` set, it also has to carry a
/// token Cloudflare Access signed, and the name is the one in the token.
///
/// Takes the request's parts rather than the request: a `&Request` held across the await makes
/// the future unsendable, as a body is not `Sync`.
async fn vouched_name(state: &WebState, cfg: &crate::config::Config, peer: String, headers: &axum::http::HeaderMap) -> Option<String> {
if cfg.web.trusted_header.is_empty() || !cfg.web.trusted_proxies.iter().any(|p| p == &peer) {
return None;
}
let header = |name: &str| headers.get(name).and_then(|v| v.to_str().ok());
let Some((team, aud)) = cfg.web.access() else {
return header(&cfg.web.trusted_header).and_then(crate::auth::name_from_header);
};
// The plain header still has to be there, as it is what switches this path on for a
// request; who it names is the token's to say.
header(&cfg.web.trusted_header)?;
let token = header("Cf-Access-Jwt-Assertion")?;
state.access.verify(&state.ctx.client, team, aud, token).await
}
fn peer(req: &Request) -> String {
req.extensions()
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
.map(|c| c.0.ip().to_string())
.unwrap_or_default()
}
/// Handlers take `User` to say they need one; the auth layer put it there, and nothing
/// reaches a handler without passing through it.
impl<S: Send + Sync> axum::extract::FromRequestParts<S> for crate::db::User {
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(
parts: &mut axum::http::request::Parts,
_state: &S,
) -> std::result::Result<Self, Self::Rejection> {
parts
.extensions
.get::<crate::db::User>()
.cloned()
.ok_or((StatusCode::UNAUTHORIZED, "sign in"))
}
}
fn cookie(req: &Request, name: &str) -> Option<String> {
headers_cookie(req.headers(), name)
}
fn headers_cookie(headers: &HeaderMap, name: &str) -> Option<String> {
headers
.get(header::COOKIE)
.and_then(|v| v.to_str().ok())
.and_then(|c| {
c.split(';')
.find_map(|kv| kv.trim().strip_prefix(&format!("{name}=")).map(str::to_owned))
})
}
/// The account the shared token stands for: the first admin, or the first user at all.
async fn admin_user(state: &WebState) -> Option<crate::db::User> {
let users = state.ctx.db.users().await.ok()?;
users
.iter()
.find(|u| u.is_admin)
.or_else(|| users.first())
.cloned()
}
#[derive(Deserialize)]
struct Credentials {
name: String,
password: String,
}
async fn login(
State(state): State<WebState>,
Json(body): Json<Credentials>,
) -> Result<Response, ApiError> {
let name = body.name.trim().to_ascii_lowercase();
let user = state.ctx.db.user_by_name(&name).await?;
// The same answer either way, in the same time: whether a name exists is not something to leak.
let ok = crate::auth::check_password(body.password, user.as_ref().and_then(|u| u.pass_hash.clone())).await;
if !ok {
tracing::warn!(user = %name, "failed sign-in");
return Ok((StatusCode::UNAUTHORIZED, "wrong name or password").into_response());
}
let user = user.expect("verified above");
let token = crate::auth::new_session_token();
state.ctx.db.create_session(user.id, &token).await?;
state.ctx.db.signed_in(user.id).await?;
tracing::info!(user = %user.name, "signed in");
let days = state.ctx.cfg().web.session_days.max(1);
let mut resp = Json(serde_json::json!({ "name": user.name, "admin": user.is_admin }))
.into_response();
if let Ok(v) = header::HeaderValue::from_str(&format!(
"{SESSION_COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={}",
days * 86_400
)) {
resp.headers_mut().insert(header::SET_COOKIE, v);
}
Ok(resp)
}
async fn logout(State(state): State<WebState>, req: Request) -> Response {
if let Some(sid) = cookie(&req, SESSION_COOKIE) {
let _ = state.ctx.db.delete_session(&sid).await;
}
let mut resp = StatusCode::NO_CONTENT.into_response();
for c in [
format!("{SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"),
format!("{COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"),
] {
if let Ok(v) = header::HeaderValue::from_str(&c) {
resp.headers_mut().append(header::SET_COOKIE, v);
}
}
resp
}
/// Who is signed in, and, for someone the proxy signed in, where Sign out should send them.
async fn me(
State(state): State<WebState>,
user: crate::db::User,
axum::Extension(Proxied(by_proxy)): axum::Extension<Proxied>,
) -> Json<serde_json::Value> {
let url = state.ctx.cfg().web.sign_out_url.clone();
let sign_out = (by_proxy && !url.is_empty()).then_some(url);
let blocked = state.ctx.db.blocklist(user.id, "").await.unwrap_or_default();
let saved = state.ctx.db.deadair_total(user.id).await.unwrap_or(0) / 1000;
// The theme the account kept from before themes were each browser's: theme.ts takes it, once,
// into a browser that has none of its own yet.
let theme = match state.ctx.db.theme(user.id).await.unwrap_or_default() {
(Some(t), Some(m)) if theme_ok(&t, &m) => Some(serde_json::json!({ "name": t, "mode": m })),
_ => None,
};
Json(serde_json::json!({
"name": user.name, "admin": user.is_admin, "sign_out": sign_out, "blocked": blocked,
"deadair_saved": saved, "theme": theme, "version": env!("CARGO_PKG_VERSION"),
"typesafe_key_set": state.ctx.db.typesafe_key_set(user.id).await.unwrap_or(false),
}))
}
#[derive(Deserialize)]
struct MePatch {
/// Words that hide an item in every feed you read.
blocked: Option<Vec<String>>,
/// The TypeSafe key Signals are judged on: a key saves it, null or empty forgets it.
#[serde(default, deserialize_with = "double_option")]
typesafe_key: Option<Option<String>>,
}
/// Saves the block list for every feed. The theme is not the account's: each browser keeps its
/// own, in a cookie (issue #69).
async fn patch_me(
State(state): State<WebState>,
user: crate::db::User,
Json(body): Json<MePatch>,
) -> Result<StatusCode, ApiError> {
if let Some(words) = body.blocked {
state.ctx.db.set_blocklist(user.id, "", &clean_words(words)?).await?;
}
if let Some(key) = body.typesafe_key {
let key = key.map(|k| k.trim().to_owned()).filter(|k| !k.is_empty());
if key.as_ref().is_some_and(|k| k.len() > 500 || !k.bytes().all(|b| b.is_ascii_graphic())) {
return Err(ApiError::bad_request("that does not look like a TypeSafe key"));
}
state.ctx.db.set_typesafe_key(user.id, key.as_deref()).await?;
state.ctx.signals_refused.lock().unwrap().remove(&user.id);
state.ctx.signals.notify_one();
}
Ok(StatusCode::NO_CONTENT)
}
// ---- Signals (#254): each person's own, so each route finds the Signal by its owner ----
#[derive(Deserialize)]
struct SignalBody {
name: Option<String>,
keywords: Option<Vec<String>>,
/// What its owner means by it, in their words: a string sets it, null or empty clears it.
#[serde(default, deserialize_with = "double_option")]
description: Option<Option<String>>,
/// A Font Awesome Free icon, `solid/robot`: a string sets it, null or empty clears it.
#[serde(default, deserialize_with = "double_option")]
icon: Option<Option<String>>,
}
/// The page puts the name into an address (`/icons/<style>/<name>.svg`), so nothing but a style
/// and a plain name gets through.
fn signal_icon(i: Option<String>) -> Result<Option<String>, ApiError> {
let Some(i) = i.filter(|i| !i.is_empty()) else { return Ok(None) };
let ok = i.split_once('/').is_some_and(|(style, name)| {
["solid", "regular", "brands"].contains(&style)
&& (1..=64).contains(&name.len())
&& name.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
});
if !ok {
return Err(ApiError::bad_request("an icon is a Font Awesome Free one, such as solid/robot"));
}
Ok(Some(i))
}
fn signal_description(d: Option<String>) -> Result<Option<String>, ApiError> {
let d = d.map(|d| d.trim().to_owned()).filter(|d| !d.is_empty());
if d.as_ref().is_some_and(|d| d.chars().count() > 500) {
return Err(ApiError::bad_request("a Signal's description is up to 500 characters"));
}
Ok(d)
}
fn signal_name(name: &str) -> Result<String, ApiError> {
let name = name.trim();
if name.is_empty() || name.chars().count() > 100 {
return Err(ApiError::bad_request("a Signal needs a name of up to 100 characters"));
}
Ok(name.to_owned())
}
fn signal_words(words: Vec<String>) -> Result<Vec<String>, ApiError> {
let words = clean_words(words)?;
// Each is in the question Jev is asked about every item.
if words.is_empty() || words.len() > 50 {
return Err(ApiError::bad_request("a Signal needs between 1 and 50 keywords"));
}
Ok(words)
}
async fn list_signals(
State(state): State<WebState>,
user: crate::db::User,
) -> Result<Json<Vec<crate::db::SignalRow>>, ApiError> {
Ok(Json(state.ctx.db.signals(user.id).await?))
}
async fn add_signal(
State(state): State<WebState>,
user: crate::db::User,
Json(body): Json<SignalBody>,
) -> Result<Json<serde_json::Value>, ApiError> {
let name = signal_name(body.name.as_deref().unwrap_or(""))?;
let words = signal_words(body.keywords.unwrap_or_default())?;
let description = signal_description(body.description.flatten())?;
let icon = signal_icon(body.icon.flatten())?;
let id = state.ctx.db.add_signal(user.id, &name, &words, description.as_deref()).await?;
if icon.is_some() {
let change = crate::db::SignalChange { icon: Some(icon.as_deref()), ..Default::default() };
state.ctx.db.set_signal(user.id, id, change).await?;
}
state.ctx.signals.notify_one();
Ok(Json(serde_json::json!({ "id": id })))
}
#[derive(Deserialize)]
struct IconAsk {
name: String,
#[serde(default)]
keywords: Vec<String>,
description: Option<String>,
/// `[style/name, label]` for each icon the page found for the Signal's words.
candidates: Vec<(String, String)>,
}
/// Jev's pick of an icon for a Signal, on the server's TypeSafe key (#263). Answers `{ "icon": ... }`,
/// or 404 when the server has no key.
async fn pick_signal_icon(
State(state): State<WebState>,
_user: crate::db::User,
Json(body): Json<IconAsk>,
) -> Result<Json<serde_json::Value>, ApiError> {
let Ok(key) = std::env::var("TYPESAFE_KEY") else {
return Err(ApiError::not_found("picking an icon is not set up on this server"));
};
let name = signal_name(&body.name)?;
let words = clean_words(body.keywords)?;
let description = signal_description(body.description)?;
// Each one is in the question, and comes back as the Signal's icon: only real icon names.
let mut candidates: Vec<(String, String)> = Vec::new();
for (id, label) in body.candidates {
let id = signal_icon(Some(id))?.ok_or_else(|| ApiError::bad_request("an empty icon"))?;
if !candidates.iter().any(|(c, _)| *c == id) {
candidates.push((id, label.chars().take(200).collect()));
}
}
if candidates.is_empty() || candidates.len() > 60 {
return Err(ApiError::bad_request("between 1 and 60 icons to choose from"));
}
let icon = crate::signals::pick_icon(&state.ctx, &key, &name, &words, description.as_deref(), &candidates)
.await
.map_err(|e| {
tracing::warn!(error = %format!("{e:#}"), "Jev could not pick an icon");
ApiError::bad_gateway("no icon could be picked just now")
})?;
Ok(Json(serde_json::json!({ "icon": icon })))
}
async fn patch_signal(
State(state): State<WebState>,
Path(id): Path<i64>,
user: crate::db::User,
Json(body): Json<SignalBody>,
) -> Result<StatusCode, ApiError> {
let name = body.name.as_deref().map(signal_name).transpose()?;
let words = body.keywords.map(signal_words).transpose()?;
let description = body.description.map(signal_description).transpose()?;
let icon = body.icon.map(signal_icon).transpose()?;
let asks_again = name.is_some() || words.is_some() || description.is_some();
let change = crate::db::SignalChange {
name: name.as_deref(),
words: words.as_deref(),
description: description.as_ref().map(|d| d.as_deref()),
icon: icon.as_ref().map(|i| i.as_deref()),
};
if !state.ctx.db.set_signal(user.id, id, change).await? {
return Err(ApiError::not_found("no such Signal"));
}
if asks_again {
state.ctx.signals.notify_one();
}
Ok(StatusCode::NO_CONTENT)
}
async fn remove_signal(
State(state): State<WebState>,
Path(id): Path<i64>,
user: crate::db::User,
) -> Result<StatusCode, ApiError> {
if !state.ctx.db.remove_signal(user.id, id).await? {
return Err(ApiError::not_found("no such Signal"));
}
Ok(StatusCode::NO_CONTENT)
}
async fn signal_entries(
State(state): State<WebState>,
Path(id): Path<i64>,
user: crate::db::User,
Query(page): Query<Page>,
) -> Result<Json<EntryPage>, ApiError> {
if state.ctx.db.signal(user.id, id).await?.is_none() {
return Err(ApiError::not_found("no such Signal"));
}
entry_page(&state, user.id, None, Some(id), &page).await
}
fn theme_ok(theme: &str, mode: &str) -> bool {
(1..=20).contains(&theme.len())
&& theme.bytes().all(|b| b.is_ascii_lowercase())
&& ["light", "dark", "auto"].contains(&mode)
}
// ---- accounts: admin only ----
fn require_admin(user: &crate::db::User) -> Result<(), ApiError> {
if user.is_admin {
Ok(())
} else {
Err(ApiError::forbidden("only an admin manages accounts"))
}
}
/// Demoting or removing this account would leave nobody able to manage anyone, and the only
/// way back would be `ipx user` on the box.
fn last_admin(users: &[crate::db::User], id: i64) -> bool {
let admins: Vec<i64> = users.iter().filter(|u| u.is_admin).map(|u| u.id).collect();
admins == [id]
}
/// Your API tokens: names and dates, never the tokens, which are not kept.
async fn list_tokens(
State(state): State<WebState>,
user: crate::db::User,
) -> Result<Json<serde_json::Value>, ApiError> {
let tokens: Vec<_> = state
.ctx
.db
.api_tokens(user.id).await?
.iter()
.map(|t| serde_json::json!({ "id": t.id, "name": t.name, "created": t.created, "last_used": t.last_used }))
.collect();
Ok(Json(serde_json::json!(tokens)))
}
#[derive(Deserialize)]
struct NewToken {
name: String,
}
/// Makes an API token that acts as you, and answers with it: the only time it is shown.
async fn add_token(
State(state): State<WebState>,
user: crate::db::User,
Json(body): Json<NewToken>,
) -> Result<Json<serde_json::Value>, ApiError> {
let name = body.name.trim();
if name.is_empty() {
return Err(ApiError::bad_request("give the token a name, to know it by when revoking it"));
}
let token = crate::auth::new_api_token();
let id = state.ctx.db.create_api_token(user.id, name, &crate::auth::api_token_hash(&token)).await?;
tracing::info!(user = %user.name, token = name, "API token made");
Ok(Json(serde_json::json!({ "id": id, "name": name, "token": token })))
}
async fn remove_token(
State(state): State<WebState>,
user: crate::db::User,
Path(id): Path<i64>,
) -> Result<StatusCode, ApiError> {
if !state.ctx.db.delete_api_token(user.id, id).await? {
return Err(ApiError::bad_request(format!("you have no API token with id {id}")));
}
tracing::info!(user = %user.name, id, "API token revoked");
Ok(StatusCode::NO_CONTENT)
}
async fn list_users(
State(state): State<WebState>,
user: crate::db::User,
) -> Result<Json<serde_json::Value>, ApiError> {
require_admin(&user)?;
let users: Vec<_> = state
.ctx
.db
.users().await?
.iter()
.map(|u| {
serde_json::json!({
"id": u.id, "name": u.name, "admin": u.is_admin, "password": u.pass_hash.is_some(),
"created": u.created, "last_login": u.last_login,
})
})
.collect();
Ok(Json(serde_json::json!(users)))
}
#[derive(Deserialize)]
struct NewUser {
name: String,
#[serde(default)]
password: String,
#[serde(default)]
admin: bool,
}
async fn add_user(
State(state): State<WebState>,
user: crate::db::User,
Json(body): Json<NewUser>,
) -> Result<StatusCode, ApiError> {
require_admin(&user)?;
// The same rules as a name a proxy vouches for, so either way of signing in finds it.
let name = crate::auth::name_from_header(&body.name).ok_or_else(|| {
ApiError::bad_request("a name is required, without commas, semicolons or line breaks")
})?;
if state.ctx.db.user_by_name(&name).await?.is_some() {
return Err(ApiError::bad_request(format!("{name} already exists")));
}
// No password is someone the proxy signs in, as with `ipx user add --no-password`.
let hash = if body.password.is_empty() {
None
} else {
Some(crate::auth::hash_password(&body.password).map_err(|e| ApiError::bad_request(format!("{e:#}")))?)
};
state.ctx.db.create_user(&name, hash.as_deref(), body.admin).await?;
tracing::info!(by = %user.name, user = %name, admin = body.admin, "account added");
Ok(StatusCode::CREATED)
}
#[derive(Deserialize)]
struct UserPatch {
admin: bool,
}
async fn patch_user(
State(state): State<WebState>,
user: crate::db::User,
Path(id): Path<i64>,
Json(body): Json<UserPatch>,
) -> Result<StatusCode, ApiError> {
require_admin(&user)?;
let users = state.ctx.db.users().await?;
let target = users
.iter()
.find(|u| u.id == id)
.ok_or_else(|| ApiError::bad_request(format!("no account with id {id}")))?;
if !body.admin && last_admin(&users, id) {
return Err(ApiError::bad_request(format!(
"{} is the only admin; make someone else an admin first",
target.name
)));
}
state.ctx.db.set_admin(id, body.admin).await?;
tracing::info!(by = %user.name, user = %target.name, admin = body.admin, "admin changed");
Ok(StatusCode::NO_CONTENT)
}
async fn remove_user(
State(state): State<WebState>,
user: crate::db::User,
Path(id): Path<i64>,
) -> Result<StatusCode, ApiError> {
require_admin(&user)?;
let users = state.ctx.db.users().await?;
let target = users
.iter()
.find(|u| u.id == id)
.ok_or_else(|| ApiError::bad_request(format!("no account with id {id}")))?;
if last_admin(&users, id) {
return Err(ApiError::bad_request(format!(
"{} is the only admin; make someone else an admin first",
target.name
)));
}
crate::remove_user(&state.ctx, id).await?;
tracing::info!(by = %user.name, user = %target.name, "account removed");
Ok(StatusCode::NO_CONTENT)
}
/// What Caddy asks before it serves the app or the admin page (#203): a signed-in person may have
/// the app, an admin the admin page; anyone else is sent where they belong. The auth layer has
/// already sent a browser that is not signed in to /login. The path is Caddy's
/// `X-Forwarded-Uri`; the page itself is Caddy's to serve.
async fn page_check(user: crate::db::User, headers: HeaderMap) -> Response {
let uri = headers.get("x-forwarded-uri").and_then(|v| v.to_str().ok()).unwrap_or("/");
match page_redirect(uri, user.is_admin) {
// The page sends its visitor back to the app; its script is refused outright.
Some(_) if uri.starts_with("/admin.js") => (StatusCode::FORBIDDEN, "only an admin").into_response(),
Some(to) => Redirect::to(to).into_response(),
None => StatusCode::NO_CONTENT.into_response(),
}
}
/// The shared token in the address, `/?token=`, as Caddy passes it on (#203): the auth layer has
/// signed the browser in and set its cookie, and it goes on to the app without the token in view.
async fn signin(_user: crate::db::User) -> Redirect {
Redirect::to("/")
}
/// Where someone asking for `uri` goes instead, if not there: the admin page and its script are
/// sent to admins only, not just hidden from everyone else.
fn page_redirect(uri: &str, admin: bool) -> Option<&'static str> {
let path = uri.split(['?', '#']).next().unwrap_or("");
(!admin && (path == "/admin" || path.starts_with("/admin.") || path.starts_with("/admin/"))).then_some("/")
}
/// What Caddy asks before it serves the sign-in page: someone the proxy vouches for is signed in
/// already, and goes to the app, since the form only made it look as if they were not.
async fn login_check(State(state): State<WebState>, req: Request) -> Response {
if vouched_name(&state, &state.ctx.cfg(), peer(&req), req.headers()).await.is_some() {
Redirect::to("/").into_response()
} else {
StatusCode::NO_CONTENT.into_response()
}
}
fn constant_time_eq(a: &str, b: &str) -> bool {
let (a, b) = (a.as_bytes(), b.as_bytes());
if a.len() != b.len() {
return false;
}
a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
// Built by build.rs from web/index.html and web/src, and minified: attribute values lose their
// quotes, so ADMIN_LINK and HTML_TAG are spelled the way the minifier leaves them.
#[derive(Serialize)]
struct FeedRow {
id: String,
url: String,
title: Option<String>,
image: Option<String>,
folder: Option<String>,
/// The Directory category an admin gave it, and the one the feed names itself, which wins.
category: Option<String>,
feed_category: Option<String>,
keywords: Vec<String>,
/// Your words that hide an item of this feed, beside your list for every feed.
blocked: Vec<String>,
/// Played shortened by DeadAir: on for this feed or its group, off until you turn it on.
deadair: bool,
/// The pause DeadAir leaves where it cuts, in ms: this feed's, its group's, or the usual.
deadair_pause: i64,
allow_explicit: bool,
auto_download: bool,
max_new_per_check: Option<usize>,
/// The OPML subscription this feed came from, if any.
group: Option<String>,
/// In a group, but the OPML no longer lists it. Kept because it has downloads.
orphaned: bool,
managed: bool,
schedule: Option<String>,
/// The feed's own override in minutes, so the UI need not re-parse the string.
schedule_mins: Option<u64>,
/// Effective schedule in minutes, after the global default and the feed's <ttl>.
every_mins: u64,
last_checked: Option<i64>,
next_check: Option<i64>,
last_error: Option<String>,
/// Set once `last_error` is a kind worth telling someone about and it has held for a
/// day -- a feed that fails once and reads fine an hour later (macmanx) never gets here.
failing: Option<FailingRow>,
entries: i64,
downloaded: i64,
unread: i64,
/// Including you. More than one means every file here is shared.
subscribers: i64,
/// Pinned to the top of your list.
pinned: bool,
/// Marked private: kept out of the directory (#205).
private: bool,
/// Private whatever it is marked, for a login or an address with a key in it.
private_by_address: bool,
}
#[derive(Serialize)]
struct FailingRow {
reason: &'static str,
new_url: Option<String>,
}
/// A day, in seconds: how long an error has to hold before the UI mentions it.
const FLAG_AFTER_SECS: i64 = 86_400;
async fn feeds(
State(state): State<WebState>,
user: crate::db::User,
) -> Result<Json<Vec<FeedRow>>, ApiError> {
Ok(Json(feed_rows(&state, &user, None).await?))
}
/// The feed list as this person sees it, or with `only` the one row for that feed: what a live
/// update sends after the feed changes, so the page redraws a row instead of reloading the list.
async fn feed_rows(state: &WebState, user: &crate::db::User, only: Option<&str>) -> anyhow::Result<Vec<FeedRow>> {
let cfg = state.ctx.cfg();
// Config entries plus the feeds derived from OPML subscriptions -- the catalogue.
// What comes back is only the part of it this person subscribes to.
let subs = crate::subscriptions(&state.ctx).instrument(step("catalogue")).await?;
let mine: std::collections::HashMap<String, crate::db::Sub> = state
.ctx
.db
.subscriptions_for(user.id)
.instrument(step("subscriptions"))
.await?
.into_iter()
.map(|s| (s.feed_id.clone(), s))
.collect();
let counts = state.ctx.db.subscriber_counts().instrument(step("subscriber_counts")).await?;
let pinned = state.ctx.db.pinned_feeds(user.id).instrument(step("pinned")).await?;
let prefs = state.ctx.db.deadair_prefs(Some(user.id)).await?;
let deadair: std::collections::HashMap<&str, bool> = prefs.iter().map(|(_, f, w, _)| (f.as_str(), *w)).collect();
let pauses: std::collections::HashMap<&str, i64> =
prefs.iter().filter_map(|(_, f, _, p)| Some((f.as_str(), (*p)?))).collect();
let mut listed = state.ctx.db.feed_list(user.id, only).instrument(step("feed_list")).await?;
let mut out = Vec::with_capacity(mine.len());
for sub in subs.iter().filter(|s| only.is_none_or(|o| o == s.id)) {
let (id, feed) = (&sub.id, &sub.cfg);
// In a group, what you have not set on the feed comes from your settings on the group,
// the same fallback the scanner uses (`Db::subscribers`).
let up = feed.group.as_deref().and_then(|g| mine.get(g));
let Some(mine) = mine.get(id) else { continue };
// A feed not scanned yet has no row: blank, as feed_summary gave it.
let crate::db::FeedListing { summary: s, ttl_mins, blocked, unread } = listed.remove(id).unwrap_or_default();
out.push(FeedRow {
private: feed.private,
private_by_address: crate::directory::looks_private(&crate::config::Feed { private: false, ..feed.clone() }),
id: id.clone(),
url: feed.url.clone(),
title: s.title,
image: s.image,
folder: feed.folder.clone(),
category: feed.category.clone(),
feed_category: s.category,
keywords: mine
.keywords
.clone()
.or_else(|| up.and_then(|u| u.keywords.clone()))
.unwrap_or_else(|| feed.keywords.clone()),
blocked,
deadair: deadair
.get(id.as_str())
.or_else(|| feed.group.as_deref().and_then(|g| deadair.get(g)))
.copied()
.unwrap_or(false),
deadair_pause: pauses
.get(id.as_str())
.or_else(|| feed.group.as_deref().and_then(|g| pauses.get(g)))
.copied()
.unwrap_or(crate::deadair::DEFAULT_PAUSE),
allow_explicit: mine
.allow_explicit
.or(up.and_then(|u| u.allow_explicit))
.unwrap_or(feed.allow_explicit),
auto_download: mine
.auto_download
.or(up.and_then(|u| u.auto_download))
.unwrap_or(feed.auto_download),
max_new_per_check: mine
.max_new_per_check
.or(up.and_then(|u| u.max_new_per_check))
.map(|n| n as usize)
.or(feed.max_new_per_check),
group: feed.group.clone(),
orphaned: s.orphaned,
// Derived from an OPML and not written to config until you change something.
managed: sub.managed,
schedule: feed.schedule.clone(),
schedule_mins: feed
.schedule
.as_deref()
.and_then(crate::config::parse_interval),
every_mins: crate::due_after(&cfg, feed, ttl_mins, None, None, 0) / 60,
last_checked: s.last_checked,
next_check: s
.last_checked
.map(|t| t + crate::due_after(&cfg, feed, ttl_mins, s.error_since, s.same_since, t) as i64),
failing: s
.error_since
.filter(|since| crate::db::now() - since >= FLAG_AFTER_SECS)
.and_then(|_| s.last_error.as_deref())
.and_then(crate::feed::explain_failure)
.map(|f| FailingRow { reason: f.reason, new_url: f.new_url }),
last_error: s.last_error,
entries: s.entries,
downloaded: s.downloaded,
unread,
subscribers: counts.get(id).copied().unwrap_or(0),
pinned: pinned.contains(id),
});
}
Ok(out)
}
// ---- the directory (#205) ----
/// A feed as the Directory draws it: never a URL, so a key in someone's feed address never
/// reaches the page, even though the directory itself has them.
#[derive(Serialize)]
struct PopularRow {
/// The directory's id for it, which its page and Subscribe go by.
id: String,
title: Option<String>,
image: Option<String>,
/// Everyone on every server that shares with the directory.
subscribers: i64,
/// Yours already.
subscribed: bool,
/// When it is yours, its id here, which opens it.
feed: Option<String>,
category: Option<String>,
subcategory: Option<String>,
podcast: bool,
}
fn dir_error(e: crate::directory::DirError) -> ApiError {
use crate::directory::DirError;
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, _)) {
tracing::warn!(error = %e, "asking the directory failed");
}
ApiError { error: anyhow::Error::msg(e.to_string()), status }
}
/// The directory's listings as this person sees them: which are theirs already, matched by
/// address, since the directory and this server name a feed differently.
async fn rows_for(state: &WebState, user_id: i64, listed: Vec<ipx_directory::Listing>) -> Result<Vec<PopularRow>> {
// A step each, as the feed list has: a Directory page of 983 ms showed 19 under spans (#250).
let mine: std::collections::HashSet<String> =
state.ctx.db.subscriptions_for(user_id).instrument(step("subscriptions")).await?.into_iter().map(|s| s.feed_id).collect();
let here: std::collections::HashMap<String, String> = crate::subscriptions(&state.ctx)
.instrument(step("catalogue"))
.await?
.into_iter()
.filter(|s| mine.contains(&s.id))
.filter_map(|s| ipx_directory::store::normalise(&s.cfg.url).map(|u| (u, s.id)))
.collect();
let _matching = step("match").entered();
Ok(listed
.into_iter()
.map(|l| {
let feed = ipx_directory::store::normalise(&l.url).and_then(|u| here.get(&u).cloned());
PopularRow {
id: l.id,
title: l.title,
image: l.image,
subscribers: l.subscribers,
subscribed: feed.is_some(),
feed,
category: l.category,
subcategory: l.subcategory,
podcast: l.podcast,
}
})
.collect())
}
async fn get_popular(
State(state): State<WebState>,
user: crate::db::User,
) -> Result<Json<Vec<PopularRow>>, ApiError> {
let listed = crate::directory::popular(&state.ctx).await.map_err(dir_error)?;
Ok(Json(rows_for(&state, user.id, listed).await?))
}
/// Every feed in the directory, A to Z.
async fn get_directory(
State(state): State<WebState>,
user: crate::db::User,
) -> Result<Json<Vec<PopularRow>>, ApiError> {
let listed = crate::directory::feeds(&state.ctx).await.map_err(dir_error)?;
Ok(Json(rows_for(&state, user.id, listed).await?))
}
/// One of a listed feed's latest items, for its page in the Directory before anyone subscribes
/// (#128): what it is, never its files, whose addresses are where a key would be.
#[derive(Serialize)]
struct ListedItem {
guid: String,
title: Option<String>,
link: Option<String>,
published: Option<i64>,
duration: Option<i64>,
description: Option<String>,
}
#[derive(Serialize)]
struct Listed {
/// What the feed says it is (#130), sanitized as its items are.
description: Option<String>,
/// 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,
Path(id): Path<String>,
) -> Result<Json<Listed>, ApiError> {
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
.items
.into_iter()
.map(|e| ListedItem {
description: e.description.as_deref().map(|d| clean_description(&mut sanitizer, d, e.link.as_deref())),
guid: e.guid,
title: e.title,
link: e.link,
published: e.published,
duration: e.duration,
})
.collect();
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
/// the directory, and goes through the same steps as Add feed.
async fn subscribe_popular(
State(state): State<WebState>,
user: crate::db::User,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, ApiError> {
let page = crate::directory::page(&state.ctx, &id).await.map_err(|e| match e {
crate::directory::DirError::Refused(404, _) => ApiError::bad_request(format!("{id:?} is not in the directory")),
e => dir_error(e),
})?;
let (feed_id, existed) = crate::add_to_catalogue(&state.ctx, &page.listing.url, None, vec![]).await?;
let already = existed && state.ctx.db.subscription(user.id, &feed_id).await?.is_some();
state.ctx.db.subscribe(user.id, &feed_id).await?;
if !already {
explicit_on_add(&state, user.id, &feed_id, false).await?;
}
scan_soon(&state, Some(feed_id.clone())).await;
Ok(Json(serde_json::json!({ "id": feed_id })))
}
/// Validates a replacement feed URL: present, parseable, and not already subscribed under
/// a different id. Returns the trimmed URL.
fn check_url(
url: &str,
id: &str,
feeds: &std::collections::BTreeMap<String, crate::config::Feed>,
) -> Result<String, String> {
let url = url.trim();
if url.is_empty() {
return Err("the feed URL cannot be empty".into());
}
match url::Url::parse(url) {
Ok(u) if u.scheme() == "http" || u.scheme() == "https" => {}
Ok(u) => return Err(format!("{:?} is not an http(s) URL", u.scheme())),
Err(e) => return Err(format!("that is not a valid URL: {e}")),
}
if let Some((other, _)) = feeds.iter().find(|(k, f)| k.as_str() != id && f.url == url) {
return Err(format!("{other:?} is already subscribed to that URL"));
}
Ok(url.to_owned())
}
/// Turns errors into a response with a readable body. Bad input from the caller is a 400;
/// anything else is a 500, because those are our fault and not the caller's.
pub struct ApiError {
error: anyhow::Error,
status: StatusCode,
}
impl<E: Into<anyhow::Error>> From<E> for ApiError {
fn from(e: E) -> Self {
Self { error: e.into(), status: StatusCode::INTERNAL_SERVER_ERROR }
}
}
impl ApiError {
fn bad_request(msg: impl Into<String>) -> Self {
Self {
error: anyhow::Error::msg(msg.into()),
status: StatusCode::BAD_REQUEST,
}
}
fn forbidden(msg: impl Into<String>) -> Self {
Self {
error: anyhow::Error::msg(msg.into()),
status: StatusCode::FORBIDDEN,
}
}
fn not_found(msg: impl Into<String>) -> Self {
Self {
error: anyhow::Error::msg(msg.into()),
status: StatusCode::NOT_FOUND,
}
}
/// A service ipx asks on someone's behalf failed them.
fn bad_gateway(msg: impl Into<String>) -> Self {
Self {
error: anyhow::Error::msg(msg.into()),
status: StatusCode::BAD_GATEWAY,
}
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
if self.status.is_server_error() {
tracing::warn!(error = ?self.error, "api error");
}
(self.status, format!("{:#}", self.error)).into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_relative_image_resolves_against_the_post() {
let mut b = feed_sanitizer();
let html = r#"<img src="images/a.webp"><a href="/about">x</a><script>bad()</script>"#;
let out = clean_description(&mut b, html, Some("https://example.com/2026/04/post/"));
assert!(out.contains(r#"src="https://example.com/2026/04/post/images/a.webp""#), "{out}");
assert!(out.contains(r#"href="https://example.com/about""#), "{out}");
assert!(!out.contains("bad()"), "{out}");
assert!(out.contains(r#"referrerpolicy="no-referrer""#), "{out}");
assert!(clean_description(&mut b, html, None).contains(r#"src="images/a.webp""#));
}
#[test]
fn only_an_admin_is_given_the_admin_page_or_its_script() {
for uri in ["/admin", "/admin.js?v=abc", "/admin#log", "/admin.html"] {
assert_eq!(page_redirect(uri, false), Some("/"), "{uri}");
assert_eq!(page_redirect(uri, true), None, "{uri}");
}
for uri in ["/", "/?feed=x", "/app.js?v=1", "/administer"] {
assert_eq!(page_redirect(uri, false), None, "{uri}");
}
}
#[test]
fn a_signal_icon_is_a_style_and_a_plain_name() {
assert_eq!(signal_icon(Some("solid/robot".into())).ok().flatten().as_deref(), Some("solid/robot"));
assert_eq!(signal_icon(Some("brands/apple".into())).ok().flatten().as_deref(), Some("brands/apple"));
assert!(matches!(signal_icon(Some(String::new())), Ok(None)), "empty clears it");
// It becomes part of an address and a style attribute on the page.
for bad in ["solid/../../api/me", "light/robot", "solid/Robot", "solid/x');background:url(x", "robot", "solid/"] {
assert!(signal_icon(Some(bad.into())).is_err(), "{bad}");
}
}
#[test]
fn only_a_plain_theme_name_is_handed_on() {
assert!(theme_ok("dracula", "dark") && theme_ok("nordic", "auto"));
// theme.ts writes it into markup, so nothing else gets there.
assert!(!theme_ok("x onload=alert(1)", "dark"));
assert!(!theme_ok("modern", "dark\""));
}
#[tokio::test]
async fn the_page_reports_only_what_it_is_meant_to() {
let u = crate::db::User { id: 1, name: "dee".into(), pass_hash: None, is_admin: false, created: None, last_login: None };
let e = |ev: &str| ClientEvent {
ev: ev.into(), msg: Some("x".repeat(10_000)), src: None, line: None, feed: None, enclosure: None, deadair: None, ms: Some(5),
filter: Some("unread".into()),
};
for ev in ["page_error", "rejection", "player_error", "play_start", "stall", "list_shown"] {
assert_eq!(client_event(u.clone(), Json(e(ev))).await, StatusCode::NO_CONTENT, "{ev}");
}
assert_eq!(client_event(u, Json(e("anything"))).await, StatusCode::BAD_REQUEST);
}
#[test]
fn only_the_last_admin_is_protected() {
let u = |id, is_admin| crate::db::User {
id,
name: format!("u{id}"),
pass_hash: None,
is_admin,
created: None,
last_login: None,
};
assert!(last_admin(&[u(1, true), u(2, false)], 1));
assert!(!last_admin(&[u(1, true), u(2, true)], 1), "another admin remains");
assert!(!last_admin(&[u(1, true), u(2, false)], 2), "not an admin at all");
}
#[test]
fn token_comparison_rejects_mismatches_and_length_differences() {
assert!(constant_time_eq("abc123", "abc123"));
assert!(!constant_time_eq("abc123", "abc124"));
assert!(!constant_time_eq("abc", "abc123"));
assert!(!constant_time_eq("", "abc"));
assert!(constant_time_eq("", ""));
}
fn feed(url: &str) -> crate::config::Feed {
crate::config::Feed {
url: url.into(), folder: None, group: None, media_types: None, schedule: None, keywords: vec![], allow_explicit: false,
auto_download: true, max_new_per_check: None, username: None,
password: None, password_env: None, category: None, listed: false, private: false,
}
}
#[test]
fn null_clears_a_field_while_absent_leaves_it_alone() {
// serde maps null onto the outer None for a bare Option<Option<T>>, which made
// "clear this field" indistinguishable from "field not supplied".
let absent: FeedPatch = serde_json::from_str("{}").unwrap();
assert!(absent.schedule.is_none() && absent.folder.is_none());
let cleared: FeedPatch =
serde_json::from_str(r#"{"schedule":null,"folder":null,"category":null,"max_new_per_check":null}"#).unwrap();
assert_eq!(cleared.schedule, Some(None), "null must mean clear");
assert_eq!(cleared.folder, Some(None));
assert_eq!(cleared.category, Some(None));
assert_eq!(cleared.max_new_per_check, Some(None));
let set: FeedPatch = serde_json::from_str(r#"{"schedule":"every 6h"}"#).unwrap();
assert_eq!(set.schedule, Some(Some("every 6h".into())));
}
#[test]
fn feed_urls_are_validated_before_being_saved() {
let mut feeds = std::collections::BTreeMap::new();
feeds.insert("a".to_string(), feed("https://a.example/rss"));
feeds.insert("b".to_string(), feed("https://b.example/rss"));
// Rotating a token on your own feed is the point of making this editable.
assert_eq!(
check_url(" https://a.example/rss?auth=new ", "a", &feeds).unwrap(),
"https://a.example/rss?auth=new",
"whitespace is trimmed"
);
// Keeping your own URL unchanged is not a collision with yourself.
assert!(check_url("https://a.example/rss", "a", &feeds).is_ok());
assert!(check_url("", "a", &feeds).is_err(), "empty");
assert!(check_url(" ", "a", &feeds).is_err(), "whitespace only");
assert!(check_url("not a url", "a", &feeds).is_err(), "unparseable");
assert!(check_url("file:///etc/passwd", "a", &feeds).is_err(), "not http(s)");
assert!(
check_url("https://b.example/rss", "a", &feeds).is_err(),
"another feed already has that URL"
);
}
}
#[derive(Deserialize)]
struct Page {
#[serde(default)]
offset: i64,
#[serde(default = "fifty")]
limit: i64,
#[serde(default)]
filter: Option<String>,
#[serde(default)]
q: Option<String>,
/// A column name and asc or desc. Anything unrecognised is newest first: the name picks a
/// fixed expression in the query and never reaches it itself.
#[serde(default)]
sort: Option<String>,
#[serde(default)]
dir: Option<String>,
}
fn fifty() -> i64 {
50
}
#[derive(Serialize)]
struct EntryPage {
total: i64,
entries: Vec<crate::db::EntryRow>,
}
async fn entries(
State(state): State<WebState>,
Path(id): Path<String>,
user: crate::db::User,
Query(page): Query<Page>,
) -> Result<Json<EntryPage>, ApiError> {
subscribed(&state, &user, &id).await?;
entry_page(&state, user.id, Some(&id), None, &page).await
}
/// A feed's items and files are for the people who subscribe to it (#129). Anyone signed in
/// could read any feed's by its id, a paid one's included, with its enclosure addresses, which
/// can carry the subscriber's key, and fetch, queue or delete its files by theirs. A feed in
/// an OPML has a subscription of its own for everyone subscribed to the OPML, so this holds
/// for those too. Not found, as for any other feed that is not yours.
async fn subscribed(state: &WebState, user: &crate::db::User, feed_id: &str) -> Result<(), ApiError> {
match state.ctx.db.subscription(user.id, feed_id).await? {
Some(_) => Ok(()),
None => Err(ApiError::not_found("you do not subscribe to that feed")),
}
}
/// Every subscribed feed's items together, newest first: All Subscriptions.
async fn all_entries(
State(state): State<WebState>,
user: crate::db::User,
Query(page): Query<Page>,
) -> Result<Json<EntryPage>, ApiError> {
entry_page(&state, user.id, None, None, &page).await
}
/// One feed's page of items, or every subscribed feed's when `feed` is None, or a Signal's.
async fn entry_page(
state: &WebState,
user_id: i64,
feed: Option<&str>,
signal: Option<i64>,
page: &Page,
) -> Result<Json<EntryPage>, ApiError> {
let filter = crate::db::Filter::parse(page.filter.as_deref().unwrap_or("all"));
let search = page.q.as_deref().map(str::trim).filter(|q| !q.is_empty());
let db = &state.ctx.db;
let order = crate::db::order_sql(page.sort.as_deref().unwrap_or("published"), page.dir.as_deref().unwrap_or("desc"));
let limit = page.limit.clamp(1, 200);
let mut rows = match signal {
Some(id) => db.signal_entries(user_id, id, filter, search, page.offset, limit, &order).instrument(step("entries_in")).await?,
None => db.entries_in(user_id, feed, filter, search, page.offset, limit, &order).instrument(step("entries_in")).await?,
};
step("sanitize").in_scope(|| {
let mut sanitizer = feed_sanitizer();
for row in &mut rows {
if let Some(d) = &row.description {
row.description = Some(clean_description(&mut sanitizer, d, row.link.as_deref()));
}
}
});
crate::deadair::for_reader(&state.ctx, user_id, &mut rows).instrument(step("deadair")).await?;
let total = match signal {
Some(id) => db.signal_count(user_id, id, filter, search).instrument(step("count_in")).await?,
None => db.count_in(user_id, feed, filter, search).instrument(step("count_in")).await?,
};
Ok(Json(EntryPage { total, entries: rows }))
}
/// One entry, in the clock of the file this person will play (`deadair::for_reader`).
async fn one_entry(
State(state): State<WebState>,
Path((feed_id, guid)): Path<(String, String)>,
user: crate::db::User,
) -> Result<Json<crate::db::EntryRow>, ApiError> {
subscribed(&state, &user, &feed_id).await?;
let Some(row) = state.ctx.db.entry(user.id, &feed_id, &guid).await? else {
return Err(ApiError::not_found("no such entry"));
};
let mut rows = [row];
crate::deadair::for_reader(&state.ctx, user.id, &mut rows).await?;
let [mut row] = rows;
if let Some(d) = &row.description {
row.description = Some(clean_description(&mut feed_sanitizer(), d, row.link.as_deref()));
}
Ok(Json(row))
}
/// Feed HTML is untrusted: it reaches the page only after ammonia has been through it.
fn feed_sanitizer() -> ammonia::Builder<'static> {
let mut b = ammonia::Builder::new();
// Every link opens in a new tab -- ammonia's default rel="noopener noreferrer" already
// keeps that safe -- so following one in show notes never navigates away from ipx.
b.add_tag_attributes("a", &["target"]).set_tag_attribute_value("a", "target", "_blank");
// An image is asked for without saying it is shown on ipx. A site that refuses images to
// other sites' pages goes by that: jeffgeerling.com answers 403, and his posts showed a
// broken image on iOS and only the alt text on desktop.
b.add_tag_attributes("img", &["referrerpolicy"]).set_tag_attribute_value("img", "referrerpolicy", "no-referrer");
b
}
/// A relative `src` or `href` in a post means relative to the post, not to ipx: The
/// Observation Deck's `images/37k-a-day-bro.webp` came up as a broken image.
fn clean_description(sanitizer: &mut ammonia::Builder, html: &str, link: Option<&str>) -> String {
let base = link.and_then(|l| url::Url::parse(l).ok());
sanitizer.url_relative(match base {
Some(b) => ammonia::UrlRelative::RewriteWithBase(b),
None => ammonia::UrlRelative::PassThrough,
});
sanitizer.clean(html).to_string()
}
#[derive(Deserialize)]
struct NewFeed {
url: String,
#[serde(default)]
folder: Option<String>,
#[serde(default)]
keywords: Vec<String>,
#[serde(default)]
allow_explicit: bool,
}
/// The Add feed dialog's explicit box. Like everything on a feed's own dialog it is yours, so it
/// goes on your subscription, and before the first scan, which would otherwise skip every
/// explicit item.
async fn explicit_on_add(state: &WebState, user_id: i64, feed_id: &str, allow: bool) -> Result<(), ApiError> {
if allow {
let sub = crate::db::Sub { feed_id: feed_id.to_owned(), allow_explicit: Some(true), ..Default::default() };
state.ctx.db.set_subscription(user_id, &sub).await?;
}
Ok(())
}
async fn add_feed(
State(state): State<WebState>,
user: crate::db::User,
Json(body): Json<NewFeed>,
) -> Result<Json<serde_json::Value>, ApiError> {
// Before the duplicate check, so a site's page finds the feed someone already has. What is
// not a feed and links none is refused here, with why, and nothing is added.
let url = crate::feed::find_feed(&state.ctx.client, &crate::feed::expand_input(&body.url))
.await
.map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
// Someone else may already have it. Then adding costs nothing: no second fetch, no
// second copy on disk, just another name against the same feed.
let (id, existed) = crate::add_to_catalogue(&state.ctx, &url, body.folder, body.keywords).await?;
let already = existed && state.ctx.db.subscription(user.id, &id).await?.is_some();
state.ctx.db.subscribe(user.id, &id).await?;
if !already {
explicit_on_add(&state, user.id, &id, body.allow_explicit).await?;
}
scan_soon(&state, Some(id.clone())).await;
Ok(Json(serde_json::json!({ "id": id, "existing": already })))
}
/// Queues a scan, so a feed just added shows its items without anyone pressing Scan now.
/// `None` scans whatever is due, which a feed never checked always is. The add has
/// succeeded either way, so a daemon not taking commands is only logged.
async fn scan_soon(state: &WebState, feed: Option<String>) {
let force = feed.is_some();
if state.cmds.send(Command::Fetch { feed, force, feeds: vec![] }).await.is_err() {
tracing::warn!("could not queue a scan: the daemon is not accepting commands");
}
}
/// Absent means "leave alone"; JSON `null` means "clear this".
///
/// That distinction needs `double_option`: serde maps `null` onto the *outer* `None` for a
/// plain `Option<Option<T>>`, making the clear case unreachable and silently turning
/// "unset the folder" into a no-op.
#[derive(Deserialize)]
struct FeedPatch {
url: Option<String>,
#[serde(default, deserialize_with = "double_option")]
schedule: Option<Option<String>>,
#[serde(default, deserialize_with = "double_option")]
folder: Option<Option<String>>,
#[serde(default, deserialize_with = "double_option")]
category: Option<Option<String>>,
keywords: Option<Vec<String>>,
blocked: Option<Vec<String>>,
allow_explicit: Option<bool>,
auto_download: Option<bool>,
#[serde(default, deserialize_with = "double_option")]
max_new_per_check: Option<Option<usize>>,
pinned: Option<bool>,
/// True plays the feed shortened by DeadAir, false keeps it out (of its group's too), null
/// follows its group again (#171).
#[serde(default, deserialize_with = "double_option")]
deadair: Option<Option<bool>>,
/// The pause DeadAir leaves where it cuts, in ms: one of `deadair::PAUSES` (#182).
deadair_pause: Option<i64>,
/// Kept out of the directory (#205). Anyone subscribed may mark a feed private, since it is
/// most likely their own paid feed; only an admin may unmark it.
private: Option<bool>,
}
/// A block list as sent: trimmed, blanks and repeats dropped, and bounded, since each word is
/// looked for in every item of every feed the list covers.
fn clean_words(words: Vec<String>) -> Result<Vec<String>, ApiError> {
let mut out: Vec<String> = Vec::new();
for w in words.iter().map(|w| w.trim()).filter(|w| !w.is_empty()) {
if !out.iter().any(|o| o.eq_ignore_ascii_case(w)) {
out.push(w.to_owned());
}
}
if out.len() > 500 || out.iter().any(|w| w.len() > 200) {
return Err(ApiError::bad_request("a block list holds up to 500 words of up to 200 characters"));
}
Ok(out)
}
fn double_option<'de, T, D>(de: D) -> Result<Option<Option<T>>, D::Error>
where
T: Deserialize<'de>,
D: serde::Deserializer<'de>,
{
Deserialize::deserialize(de).map(Some)
}
async fn patch_feed(
State(state): State<WebState>,
Path(id): Path<String>,
user: crate::db::User,
Json(body): Json<FeedPatch>,
) -> Result<StatusCode, ApiError> {
// Pinning is yours alone too, and means nothing for a feed you do not subscribe to.
if let Some(on) = body.pinned
&& !state.ctx.db.set_pinned(user.id, &id, on).await?
{
return Err(ApiError::not_found("you do not subscribe to that feed"));
}
if let Some(p) = body.deadair_pause
&& !crate::deadair::PAUSES.contains(&p)
{
return Err(ApiError::bad_request("DeadAir's pause is 200, 300, 400 or 500 ms"));
}
if let Some(wanted) = body.deadair {
state.ctx.db.set_deadair(user.id, &id, wanted).await?;
state.ctx.deadair.notify_one();
}
if let Some(p) = body.deadair_pause {
state.ctx.db.set_deadair_pause(user.id, &id, p).await?;
}
// What one person wants -- which items, whether to fetch them, how many at a time --
// is theirs. It goes on their subscription and nobody else sees the change.
if let Some(mut mine) = state.ctx.db.subscription(user.id, &id).await? {
let mut touched = false;
if let Some(v) = body.keywords.clone() {
mine.keywords = Some(v.into_iter().filter(|k| !k.trim().is_empty()).collect());
touched = true;
}
if let Some(v) = body.allow_explicit {
mine.allow_explicit = Some(v);
touched = true;
}
if let Some(v) = body.auto_download {
mine.auto_download = Some(v);
touched = true;
}
if let Some(v) = body.max_new_per_check {
mine.max_new_per_check = v.map(|n| n as i64);
touched = true;
}
if touched {
state.ctx.db.set_subscription(user.id, &mine).await?;
}
if let Some(v) = body.blocked.clone() {
state.ctx.db.set_blocklist(user.id, &id, &clean_words(v)?).await?;
}
}
// The rest describes the feed itself -- where its files land, its address, when it is
// polled -- and there is one of those however many people read it.
let admin_only =
body.url.is_some() || body.folder.is_some() || body.schedule.is_some() || body.category.is_some() || body.private == Some(false);
if !admin_only && body.private.is_none() {
return Ok(StatusCode::NO_CONTENT);
}
if admin_only && !user.is_admin {
return Err(ApiError::forbidden(
"the feed's address, folder, schedule and category, and whether it may be shared, are the same for everyone, so only an admin changes them",
));
}
let mut cfg = state.ctx.edit_cfg().await;
// Derived feeds have no config entry. Editing one is the moment it earns a real
// entry: promote it, so the config holds your decisions and nothing else.
if !cfg.feeds.contains_key(&id) {
let subs = crate::subscriptions(&state.ctx).await?;
let found = subs
.iter()
.find(|s| s.id == id)
.ok_or_else(|| ApiError::bad_request(format!("no feed with id {id:?}")))?;
cfg.feeds.insert(id.clone(), found.cfg.clone());
state.ctx.db.unmanage(&id).await?;
}
let checked = match &body.url {
Some(u) => Some(check_url(u, &id, &cfg.feeds).map_err(ApiError::bad_request)?),
None => None,
};
let feed = cfg
.feeds
.get_mut(&id)
.ok_or_else(|| ApiError::bad_request(format!("no feed with id {id:?}")))?;
let mut url_changed = false;
if let Some(url) = checked {
url_changed = url != feed.url;
feed.url = url;
}
if let Some(v) = body.schedule {
let v = v.map(|x| x.trim().to_owned()).filter(|x| !x.is_empty());
if let Some(text) = &v
&& crate::config::parse_interval(text).is_none()
{
return Err(ApiError::bad_request(format!(
"{text:?} is not a schedule — try \"every 30m\", \"every 4h\" or \"1d\""
)));
}
feed.schedule = v;
}
if let Some(v) = body.folder {
feed.folder = v.filter(|s| !s.trim().is_empty());
}
if let Some(v) = body.category {
feed.category = v.map(|s| s.trim().to_owned()).filter(|s| !s.is_empty());
}
if let Some(v) = body.private {
feed.private = v;
}
state.ctx.store_cfg(cfg).await?;
if url_changed {
// Refreshing a rotated auth token is the common case; entries and download history
// are keyed by feed id, so they survive the change.
state.ctx.db.clear_validators(&id).await?;
}
Ok(StatusCode::NO_CONTENT)
}
async fn remove_feed(
State(state): State<WebState>,
Path(id): Path<String>,
user: crate::db::User,
) -> Result<StatusCode, ApiError> {
crate::leave(&state.ctx, user.id, &id).await?;
Ok(StatusCode::NO_CONTENT)
}
#[derive(Deserialize)]
struct Flags {
read: Option<bool>,
flagged: Option<bool>,
}
async fn set_flags(
State(state): State<WebState>,
Path((feed_id, guid)): Path<(String, String)>,
user: crate::db::User,
Json(body): Json<Flags>,
) -> Result<Json<Option<FeedRow>>, ApiError> {
use crate::db::EntryFlag;
if let Some(v) = body.read {
state.ctx.db.set_entry_flag(user.id, &feed_id, &guid, EntryFlag::Read, v).await?;
}
if let Some(v) = body.flagged {
state.ctx.db.set_entry_flag(user.id, &feed_id, &guid, EntryFlag::Flagged, v).await?;
}
// The feed's row with its new unread count, for the page to put in place of the old one
// rather than reloading the whole list after every item read.
Ok(Json(feed_rows(&state, &user, Some(&feed_id)).await?.into_iter().next()))
}
/// Downloads one enclosure now. This cannot be "requeue and scan": a scan takes the
/// lowest-id pending rows up to max_new_per_check, so with a big backlog it would download
/// other episodes and leave the requested one pending.
async fn download_now(
State(state): State<WebState>,
Path(id): Path<i64>,
user: crate::db::User,
) -> Result<StatusCode, ApiError> {
let enc = state
.ctx
.db
.enclosure(id).await?
.ok_or_else(|| ApiError::not_found(format!("no file {id}")))?;
subscribed(&state, &user, &enc.feed_id).await?;
if enc.path.is_some() {
return Ok(StatusCode::NO_CONTENT); // Already here.
}
state.ctx.db.requeue(id).await?;
state
.cmds
.send(Command::Download { enclosure: id })
.await
.map_err(|_| anyhow::anyhow!("the daemon is not accepting commands"))?;
Ok(StatusCode::ACCEPTED)
}
#[derive(Deserialize)]
struct Force {
#[serde(default)]
force: bool,
}
async fn delete_file(
State(state): State<WebState>,
Path(id): Path<i64>,
user: crate::db::User,
Query(q): Query<Force>,
) -> Result<StatusCode, ApiError> {
let enc = state
.ctx
.db
.enclosure(id).await?
.ok_or_else(|| ApiError::not_found(format!("no file {id}")))?;
subscribed(&state, &user, &enc.feed_id).await?;
// There is one copy of the file: deleting it deletes everyone's. Say so before doing
// it, once, and let them decide.
if !q.force {
let (starred, unread) = state.ctx.db.others_wanting(id, user.id).await?;
let people = |n: i64| if n == 1 { "person".to_string() } else { format!("{n} people") };
let complaint = match (starred, unread) {
(0, 0) => None,
(0, u) => Some(format!("{} subscribed to this feed {} not played it yet", people(u), if u == 1 { "has" } else { "have" })),
(st, 0) => Some(format!("another {} pinned it", people(st))),
(st, u) => Some(format!(
"another {} pinned it, and {} not played it yet",
people(st),
if u == 1 { "one person has".to_string() } else { format!("{u} have") }
)),
};
if let Some(why) = complaint {
return Err(ApiError {
error: anyhow::Error::msg(format!("There is one copy of this file and {why}.")),
status: StatusCode::CONFLICT,
});
}
}
if let Some(path) = &enc.path
&& let Err(e) = crate::download::remove(std::path::Path::new(path))
&& e.kind() != std::io::ErrorKind::NotFound
{
return Err(e.into());
}
// The row survives as 'reaped', which is what stops the next scan re-downloading it.
state.ctx.db.mark_reaped(id).await?;
state.events.send(Event::Reaped {
path: enc.path.unwrap_or_default(),
bytes: enc.length.unwrap_or(0).max(0) as u64,
}).ok();
Ok(StatusCode::NO_CONTENT)
}
#[derive(Deserialize)]
struct FetchBody {
#[serde(default)]
feed: Option<String>,
#[serde(default)]
force: bool,
}
async fn fetch_now(
State(state): State<WebState>,
user: crate::db::User,
Json(body): Json<FetchBody>,
) -> Result<StatusCode, ApiError> {
// "Check every feed" is every feed this person reads, not everyone's (issue #37).
let feeds = if body.feed.is_some() {
vec![]
} else {
state.ctx.db.subscriptions_for(user.id).await?.into_iter().map(|s| s.feed_id).collect()
};
if body.feed.is_none() && feeds.is_empty() {
return Ok(StatusCode::ACCEPTED); // nothing of theirs to check; an empty list would mean all
}
state
.cmds
.send(Command::Fetch { feed: body.feed, force: body.force, feeds })
.await
.map_err(|_| anyhow::anyhow!("the daemon is not accepting commands"))?;
Ok(StatusCode::ACCEPTED)
}
/// The same broadcast the socket clients read, as server-sent events.
async fn events(
State(state): State<WebState>,
user: crate::db::User,
) -> Sse<impl futures_util::Stream<Item = Result<SseEvent, std::convert::Infallible>>> {
let rx = state.events.subscribe();
// A client that falls behind skips what it missed rather than being cut off.
let stream = futures_util::stream::unfold((rx, state, user), |(mut rx, state, user)| async move {
loop {
match rx.recv().await {
Ok(ev) => {
let Ok(data) = serde_json::to_string(&ev) else { continue };
let mut out = vec![Ok::<_, std::convert::Infallible>(SseEvent::default().data(data))];
// A feed that changed goes out as this person's row for it, which the page
// puts in place of the old one: it reloaded the whole list after each.
if let Some(feed) = changed_feed(&ev)
&& let Ok(rows) = feed_rows(&state, &user, Some(feed)).await
&& let Some(row) = rows.first()
&& let Ok(data) = serde_json::to_string(&serde_json::json!({ "ev": "feed_row", "row": row }))
{
out.push(Ok(SseEvent::default().data(data)));
}
return Some((futures_util::stream::iter(out), (rx, state, user)));
}
// Counted for /metrics: a page that misses updates shows stale counts (#195).
Err(broadcast::error::RecvError::Lagged(n)) => {
crate::metrics::STATS.events_missed.fetch_add(n, std::sync::atomic::Ordering::Relaxed);
}
Err(broadcast::error::RecvError::Closed) => return None,
}
}
});
Sse::new(futures_util::StreamExt::flatten(stream)).keep_alive(axum::response::sse::KeepAlive::default())
}
/// The feed an event changed what the list shows of: its counts, error or last check. The
/// routine skip of a feed not due never reaches here (`Emitter::emit`).
fn changed_feed(ev: &Event) -> Option<&str> {
match ev {
Event::FeedDone { feed, .. }
| Event::FeedError { feed, .. }
| Event::FeedMoved { feed, .. }
| Event::DownloadDone { feed, .. } => Some(feed),
Event::FeedSkip { feed, .. } => Some(feed),
_ => None,
}
}
/// Audio, served by ServeFile so Range requests work and the player can seek.
async fn media(
State(state): State<WebState>,
Path(id): Path<i64>,
user: crate::db::User,
req: Request,
) -> Response {
serve_media(state, id, user, req, false).await
}
/// DeadAir's playlist of a file (#181): the original's frames kept by this person's pause
/// (#182), as byte ranges of `deadair.mp3` beside it. At an address of its own, so a player that
/// had part of one version cached never stitches the two together.
async fn media_deadair(State(state): State<WebState>, Path(id): Path<i64>, user: crate::db::User) -> Response {
let Ok(Some(enc)) = state.ctx.db.enclosure(id).await else {
return (StatusCode::NOT_FOUND, "no such enclosure").into_response();
};
if let Err(e) = subscribed(&state, &user, &enc.feed_id).await {
return e.into_response();
}
let plan = match crate::deadair::plan_for(&state.ctx, user.id, &enc.feed_id, id).instrument(step("plan")).await {
Ok(Some(p)) => p,
Ok(None) => return (StatusCode::NOT_FOUND, "DeadAir has nothing to cut in this file for you").into_response(),
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")).into_response(),
};
let body = crate::deadair::playlist(&plan, "deadair.mp3");
// Which version this person is playing, so the positions their player saves are read in its
// clock (`deadair::on_save`).
state.playing.lock().unwrap().insert((user.id, enc.feed_id, enc.guid), Arc::new(plan));
// Never kept: it changes with the pause they choose.
([(header::CONTENT_TYPE, "application/vnd.apple.mpegurl"), (header::CACHE_CONTROL, "no-store")], body)
.into_response()
}
/// A file without its dead air, to keep (#186): what the save button fetches for someone who
/// plays it shortened.
// shortcut: no Range requests, so a save that breaks off starts again; fine for a button that
// saves, add them if a player is ever pointed here.
async fn media_save(State(state): State<WebState>, Path(id): Path<i64>, user: crate::db::User) -> Response {
let Ok(Some(enc)) = state.ctx.db.enclosure(id).await else {
return (StatusCode::NOT_FOUND, "no such enclosure").into_response();
};
if let Err(e) = subscribed(&state, &user, &enc.feed_id).await {
return e.into_response();
}
let Some(path) = enc.path else {
return (StatusCode::NOT_FOUND, "not downloaded").into_response();
};
let plan = match crate::deadair::plan_for(&state.ctx, user.id, &enc.feed_id, id).instrument(step("plan")).await {
Ok(Some(p)) => p,
Ok(None) => return (StatusCode::NOT_FOUND, "DeadAir has nothing to cut in this file for you").into_response(),
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")).into_response(),
};
let path = crate::download::playable(std::path::Path::new(&path));
let (len, body) = match crate::deadair::kept(&path, &plan).await {
Ok(k) => k,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
let stem = path.file_stem().map_or("episode".into(), |s| s.to_string_lossy());
let ext = path.extension().map_or("mp3".into(), |s| s.to_string_lossy());
let name = format!("{stem} (DeadAir).{ext}");
let name = percent_encoding::utf8_percent_encode(&name, percent_encoding::NON_ALPHANUMERIC);
let kind = if ext.eq_ignore_ascii_case("aac") { "audio/aac" } else { "audio/mpeg" };
(
[
(header::CONTENT_TYPE, kind.to_owned()),
(header::CONTENT_LENGTH, len.to_string()),
(header::CONTENT_DISPOSITION, format!("attachment; filename*=UTF-8''{name}")),
(header::CACHE_CONTROL, "private".to_owned()),
],
axum::body::Body::from_stream(body),
)
.into_response()
}
/// The original's bytes, for the pieces of DeadAir's playlist.
async fn media_piece(
State(state): State<WebState>,
Path(id): Path<i64>,
user: crate::db::User,
req: Request,
) -> Response {
serve_media(state, id, user, req, true).await
}
async fn serve_media(state: WebState, id: i64, user: crate::db::User, req: Request, piece: bool) -> Response {
let Ok(Some(enc)) = state.ctx.db.enclosure(id).await else {
return (StatusCode::NOT_FOUND, "no such enclosure").into_response();
};
if let Err(e) = subscribed(&state, &user, &enc.feed_id).await {
return e.into_response();
}
let Some(path) = enc.path else {
return (StatusCode::NOT_FOUND, "not downloaded").into_response();
};
// The original itself: positions this person's player saves are in its clock again. A piece
// of the playlist leaves that as the playlist set it.
if !piece {
state.playing.lock().unwrap().remove(&(user.id, enc.feed_id.clone(), enc.guid.clone()));
}
match ServeFile::new(crate::download::playable(std::path::Path::new(&path))).oneshot(req).await {
Ok(mut r) => {
// Never in a shared cache: Cloudflare kept `deadair.mp3`, by its extension, and served
// its copy to anyone Access let in, without the subscriber check above (#189).
r.headers_mut().insert(header::CACHE_CONTROL, header::HeaderValue::from_static("private"));
r.into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
#[derive(Deserialize)]
struct ArtQuery {
u: String,
}
/// Artwork, from iPX: the page asks here for every image a feed or item names, and the first
/// time it is fetched from the publisher and kept on disk (`art`, up to `art_cache_mb`), so
/// later it is fast, the publisher is not asked on every visit, and it outlives the publisher's
/// server. Only an address some feed or item names as its artwork, and only an image up to
/// 20 MB, so the route cannot be pointed at anything else. It began as a way round http-only
/// artwork on the https page (#90).
async fn art(State(state): State<WebState>, Query(q): Query<ArtQuery>) -> Response {
// 5 MB turned away the Glass Cannon Network's covers, 7 MB PNGs (#169).
const MAX: usize = 20 << 20;
let web = q.u.starts_with("http://") || q.u.starts_with("https://");
if !web || !state.ctx.db.names_image(&q.u).await.unwrap_or(false) {
return (StatusCode::NOT_FOUND, "no feed names that artwork").into_response();
}
let keep = state.ctx.cfg().general.art_cache_mb > 0;
let file = crate::art::path(&q.u);
if keep && let Some((kind, body)) = crate::art::read(&file) {
crate::metrics::STATS.art_hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
return art_response(kind, body);
}
if let Some(why) = crate::art::failed(&q.u) {
return (StatusCode::BAD_GATEWAY, why).into_response();
}
crate::metrics::STATS.art_misses.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let got = async {
let mut r = state.ctx.client.get(&q.u).timeout(crate::feed::FEED_TIMEOUT).send().await?.error_for_status()?;
let kind = r.headers().get(header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or("").to_owned();
anyhow::ensure!(kind.starts_with("image/"), "not an image: {kind}");
let mut body = Vec::new();
while let Some(c) = r.chunk().await? {
body.extend_from_slice(&c);
anyhow::ensure!(body.len() <= MAX, "larger than {MAX} bytes");
}
anyhow::Ok((kind, body))
};
match got.await {
Ok((kind, body)) => {
if keep && let Err(e) = crate::art::write(&file, &kind, &body) {
tracing::debug!(error = %e, "could not keep the artwork");
}
art_response(kind, body)
}
Err(e) => {
let why = format!("{e:#}");
// The access line says only that it failed, not which image or why (#169).
tracing::warn!(url = %crate::feed::redact(&q.u), error = %crate::feed::redact_text(&why), "could not fetch artwork");
crate::art::fail(&q.u, &why);
(StatusCode::BAD_GATEWAY, why).into_response()
}
}
}
/// An image from someone else's server, served from iPX's own address: it must stay an image.
/// An SVG opened on its own would otherwise run its script as iPX's page, with its cookies.
fn art_response(kind: String, body: Vec<u8>) -> Response {
(
[
(header::CONTENT_TYPE, kind),
(header::CACHE_CONTROL, "private, max-age=2592000".into()),
(header::X_CONTENT_TYPE_OPTIONS, "nosniff".into()),
(header::CONTENT_SECURITY_POLICY, "default-src 'none'; style-src 'unsafe-inline'; sandbox".into()),
],
body,
)
.into_response()
}
#[derive(Deserialize)]
struct Position {
secs: i64,
/// The length the player measured, for an episode whose feed gives none.
duration: Option<i64>,
}
async fn set_position(
State(state): State<WebState>,
Path((feed_id, guid)): Path<(String, String)>,
user: crate::db::User,
Json(body): Json<Position>,
) -> Result<StatusCode, ApiError> {
let (secs, duration) =
crate::deadair::on_save(&state.ctx, &state.playing, user.id, &feed_id, &guid, body.secs, body.duration).await?;
state.ctx.db.set_position(user.id, &feed_id, &guid, secs, duration).await?;
Ok(StatusCode::NO_CONTENT)
}
async fn read_all(
State(state): State<WebState>,
Path(id): Path<String>,
user: crate::db::User,
) -> Result<Json<serde_json::Value>, ApiError> {
// A subscription's own row has no entries, so marking it read means everything under it.
let mut ids = vec![id.clone()];
ids.extend(
crate::subscriptions(&state.ctx).await?
.into_iter()
.filter(|s| s.cfg.group.as_deref() == Some(id.as_str()))
.map(|s| s.id),
);
let n = state.ctx.db.mark_all_read(user.id, &ids).await?;
Ok(Json(serde_json::json!({ "marked": n })))
}
/// Everything read in every feed you subscribe to: exactly what All Subscriptions lists, since
/// that view is scoped by the same subscriptions.
async fn read_all_mine(
State(state): State<WebState>,
user: crate::db::User,
) -> Result<Json<serde_json::Value>, ApiError> {
let ids: Vec<String> =
state.ctx.db.subscriptions_for(user.id).await?.into_iter().map(|s| s.feed_id).collect();
let n = state.ctx.db.mark_all_read(user.id, &ids).await?;
Ok(Json(serde_json::json!({ "marked": n })))
}
#[derive(Deserialize)]
struct HowMany {
#[serde(default = "five")]
count: i64,
}
fn five() -> i64 {
5
}
/// Queues the newest N undownloaded episodes, each as its own explicit Download command so
/// none of them is subject to the per-scan cap.
async fn download_latest(
State(state): State<WebState>,
Path(id): Path<String>,
user: crate::db::User,
Json(body): Json<HowMany>,
) -> Result<Json<serde_json::Value>, ApiError> {
subscribed(&state, &user, &id).await?;
let ids = state.ctx.db.undownloaded(&id, body.count.clamp(1, 100)).await?;
for enc in &ids {
state.ctx.db.requeue(*enc).await?;
state
.cmds
.send(Command::Download { enclosure: *enc })
.await
.map_err(|_| anyhow::anyhow!("the daemon is not accepting commands"))?;
}
Ok(Json(serde_json::json!({ "queued": ids.len() })))
}
/// Subscriptions as OPML, so they can move to another podcast app.
async fn export_opml(
State(state): State<WebState>,
user: crate::db::User,
) -> Result<Response, ApiError> {
// Yours, not the whole catalogue: other people's feeds, and any private URLs in them, are
// not yours to download. This used to export config.toml to whoever asked.
let mine: std::collections::HashSet<String> =
state.ctx.db.subscriptions_for(user.id).await?.into_iter().map(|s| s.feed_id).collect();
let mut doc = opml::OPML {
head: Some(opml::Head {
title: Some("ipx subscriptions".into()),
..Default::default()
}),
..Default::default()
};
for s in crate::subscriptions(&state.ctx).await? {
// A feed from an OPML subscription comes back with the OPML itself.
if s.managed || !mine.contains(&s.id) {
continue;
}
let title = state
.ctx
.db
.feed_summary(&s.id).await
.ok()
.and_then(|sum| sum.title)
.unwrap_or_else(|| s.id.clone());
doc.add_feed(&title, &s.cfg.url);
}
let xml = doc.to_string().map_err(|e| anyhow::anyhow!("writing OPML: {e}"))?;
Ok((
[
(header::CONTENT_TYPE, "text/x-opml; charset=utf-8"),
(
header::CONTENT_DISPOSITION,
"attachment; filename=\"ipx-subscriptions.opml\"",
),
],
xml,
)
.into_response())
}
#[derive(Deserialize)]
struct OpmlBody {
xml: String,
}
async fn import_opml(
State(state): State<WebState>,
user: crate::db::User,
Json(body): Json<OpmlBody>,
) -> Result<Json<serde_json::Value>, ApiError> {
// Refused before anything is touched. Nothing reaches the disk either way: an uploaded
// file arrives as text, is read here, and is gone when the request ends.
let doc = opml::OPML::from_str(&body.xml)
.map_err(|e| ApiError::bad_request(format!("that is not an OPML file: {e}")))?;
let (added, already) = crate::subscribe_opml(&state.ctx, &doc, user.id).await?;
if added > 0 {
scan_soon(&state, None).await;
}
Ok(Json(serde_json::json!({ "added": added, "already": already })))
}
/// The numbers `ipx status` prints, and the version, for a dashboard such as Homepage's
/// customapi widget. Behind sign-in like the rest of /api; Homepage sends the shared token.
async fn status(State(state): State<WebState>) -> Response {
match crate::status(&state.ctx).await {
Event::Status { feeds, pending, downloaded } => Json(serde_json::json!({
"feeds": feeds,
"pending": pending,
"downloaded": downloaded,
"version": env!("CARGO_PKG_VERSION"),
}))
.into_response(),
Event::Error { msg } => (StatusCode::INTERNAL_SERVER_ERROR, msg).into_response(),
_ => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
#[derive(Serialize)]
struct Settings {
schedule: String,
every_mins: u64,
max_new_per_check: usize,
media_types: Vec<String>,
download_dir: String,
max_total_gb: f64,
max_age_days: u64,
art_cache_mb: u64,
directory_url: String,
/// Whether a token is set: the token itself never leaves the server.
directory_token_set: bool,
directory_share: bool,
}
async fn get_settings(State(state): State<WebState>) -> Json<Settings> {
let cfg = state.ctx.cfg();
Json(Settings {
schedule: cfg.general.schedule.clone(),
every_mins: cfg.general.interval(),
max_new_per_check: cfg.general.max_new_per_check,
media_types: cfg.general.media_types.clone(),
download_dir: cfg.general.download_dir.display().to_string(),
max_total_gb: cfg.general.max_total_gb,
max_age_days: cfg.general.max_age_days,
art_cache_mb: cfg.general.art_cache_mb,
directory_url: cfg.general.directory_url.clone(),
directory_token_set: !cfg.general.directory_token.trim().is_empty(),
directory_share: cfg.general.directory_share,
})
}
#[derive(Deserialize)]
struct SettingsPatch {
schedule: Option<String>,
max_new_per_check: Option<usize>,
media_types: Option<Vec<String>>,
max_total_gb: Option<f64>,
max_age_days: Option<u64>,
art_cache_mb: Option<u64>,
/// Empty for no directory.
directory_url: Option<String>,
/// Replaces the token; empty clears it.
directory_token: Option<String>,
directory_share: Option<bool>,
}
async fn patch_settings(
State(state): State<WebState>,
user: crate::db::User,
Json(body): Json<SettingsPatch>,
) -> Result<StatusCode, ApiError> {
if !user.is_admin {
return Err(ApiError::forbidden("only an admin changes these settings"));
}
let mut cfg = state.ctx.edit_cfg().await;
if let Some(sched) = body.schedule {
let sched = sched.trim().to_owned();
if crate::config::parse_interval(&sched).is_none() {
return Err(ApiError::bad_request(format!(
"{sched:?} is not a schedule — try \"every 30m\", \"every 4h\" or \"1d\""
)));
}
cfg.general.schedule = sched;
}
if let Some(v) = body.max_new_per_check {
cfg.general.max_new_per_check = v;
}
if let Some(v) = body.media_types {
cfg.general.media_types = v
.into_iter()
.map(|t| t.trim().to_lowercase())
.filter(|t| !t.is_empty())
.collect();
}
if let Some(v) = body.max_total_gb {
cfg.general.max_total_gb = v.max(0.0);
}
if let Some(v) = body.max_age_days {
cfg.general.max_age_days = v;
}
if let Some(v) = body.art_cache_mb {
cfg.general.art_cache_mb = v;
}
if let Some(v) = body.directory_url {
let v = v.trim().trim_end_matches('/').to_owned();
if !v.is_empty() && !url::Url::parse(&v).is_ok_and(|u| matches!(u.scheme(), "http" | "https")) {
return Err(ApiError::bad_request(format!("{v:?} is not a web address; try https://directory.example.net")));
}
cfg.general.directory_url = v;
}
if let Some(v) = body.directory_token {
cfg.general.directory_token = v.trim().to_owned();
}
if let Some(v) = body.directory_share {
cfg.general.directory_share = v;
}
state.ctx.store_cfg(cfg).await?;
Ok(StatusCode::NO_CONTENT)
}
/// What the admin page says about the directory: who it takes this server's token to be, or why
/// it would not say, and how the last snapshot went.
async fn get_directory_status(
State(state): State<WebState>,
user: crate::db::User,
) -> Result<Json<serde_json::Value>, ApiError> {
if !user.is_admin {
return Err(ApiError::forbidden("only an admin sees the directory's settings"));
}
let shared = state.ctx.directory_shared.lock().unwrap().clone();
let me = crate::directory::me(&state.ctx).await;
Ok(Json(serde_json::json!({
"configured": crate::directory::configured(&state.ctx.cfg()),
"name": me.as_ref().ok().map(|m| m.name.clone()),
"access": me.as_ref().ok().map(|m| m.access),
"error": me.err().map(|e| e.to_string()),
"shared": shared,
})))
}
#[derive(Deserialize)]
struct LogQuery {
/// Highest seq the client already has; 0 means "give me the tail".
#[serde(default)]
after: u64,
#[serde(default = "two_hundred")]
limit: usize,
}
fn two_hundred() -> usize {
200
}
#[derive(Serialize)]
struct LogPage {
lines: Vec<crate::logbuf::LogLine>,
latest: u64,
}
async fn logs(user: crate::db::User, Query(q): Query<LogQuery>) -> Result<Json<LogPage>, ApiError> {
// The log names every account, every feed and every failed sign-in, not just yours.
if !user.is_admin {
return Err(ApiError::forbidden("only an admin reads the log"));
}
let (lines, latest) = crate::logbuf::since(q.after, q.limit.clamp(1, 2000));
Ok(Json(LogPage { lines, latest }))
}
/// What the page reports from the browser (#190): its errors and its player's, and how long play
/// took to start or stalled for. Logged as `ev` lines under `ipx::client`, inside the request's
/// span, so a line carries the trace of the action that led to it.
#[derive(Deserialize)]
struct ClientEvent {
ev: String,
msg: Option<String>,
src: Option<String>,
line: Option<u32>,
feed: Option<String>,
enclosure: Option<i64>,
deadair: Option<bool>,
ms: Option<u64>,
/// The list's filter, for `list_shown`: all, unread, flagged.
filter: Option<String>,
}
async fn client_event(user: crate::db::User, Json(e): Json<ClientEvent>) -> StatusCode {
// From the browser: only the kinds the page sends, and nothing long enough to fill the log.
let cut = |s: &Option<String>| s.as_deref().map(|s| s.chars().take(500).collect::<String>());
let (msg, src, feed, filter) = (cut(&e.msg), cut(&e.src), cut(&e.feed), cut(&e.filter));
let (ev, line, enclosure, deadair, ms, user) = (e.ev.as_str(), e.line, e.enclosure, e.deadair, e.ms, user.name.as_str());
match ev {
"page_error" | "rejection" | "player_error" => tracing::warn!(
target: "ipx::client", ev, msg, src, line, feed, enclosure, deadair, user,
"{user}'s page: {ev}: {}", msg.as_deref().unwrap_or("")
),
"play_start" | "stall" => tracing::info!(
target: "ipx::client", ev, ms, feed, enclosure, deadair, user,
"{user}'s page: {ev} {} ms", ms.unwrap_or(0)
),
// From a click to the list drawn (#246).
"list_shown" => tracing::info!(
target: "ipx::client", ev, ms, feed, filter, user,
"{user}'s page: list_shown {} ms", ms.unwrap_or(0)
),
_ => return StatusCode::BAD_REQUEST,
}
StatusCode::NO_CONTENT
}
/// Names a request's trace by its route, `POST /api/entries/{feed_id}/{guid}/flags`, once routing
/// has found it. Named by the path `access_log` sees, every item's GUID was a trace name of its
/// own, and nothing grouped. A route layer, because only one runs after routing. Renamed on the
/// OpenTelemetry span itself: recording `otel.name` is ignored once the span has started.
async fn name_span(route: MatchedPath, req: Request, next: Next) -> Response {
use opentelemetry::trace::TraceContextExt;
use tracing_opentelemetry::OpenTelemetrySpanExt;
let span = tracing::Span::current();
span.context().span().update_name(format!("{} {}", req.method(), route.as_str()));
span.record("http.route", route.as_str());
let mut resp = next.run(req).await;
// For access_log, which runs outside routing and cannot see it otherwise.
resp.extensions_mut().insert(route);
resp
}
/// One line per HTTP request, so the web side shows up in the same log as the daemon.
///
/// The log view polls `/api/logs`, so logging that path would generate a line per poll
/// forever -- a feed of nothing but its own requests.
async fn access_log(req: Request, next: Next) -> Response {
let path = req.uri().path().to_owned();
let method = req.method().clone();
let quiet = path.starts_with("/api/logs");
// No trace for the event stream either: an open page asks for it again and again, and Tempo
// would fill with nothing else.
let span = if quiet || path == "/api/events" {
tracing::Span::none()
} else {
tracing::info_span!(
target: "ipx::http",
"http",
otel.name = %format!("{method} {path}"),
http.request.method = %method,
url.path = %path,
http.route = tracing::field::Empty,
http.response.status_code = tracing::field::Empty,
)
};
if let Some(parent) = ipx_directory::otel::remote_parent(req.headers(), req.uri().query()) {
use tracing_opentelemetry::OpenTelemetrySpanExt;
let _ = span.set_parent(parent);
}
let started = std::time::Instant::now();
let in_flight = crate::metrics::busy(&crate::metrics::STATS.http_in_flight);
let resp = tracing::Instrument::instrument(next.run(req), span.clone()).await;
drop(in_flight);
span.record("http.response.status_code", resp.status().as_u16());
if !quiet {
let duration_ms = started.elapsed().as_millis() as u64;
let status = resp.status().as_u16();
// The same as fields, for the JSON log (#91). Unrouted, a request has no route.
let route = resp.extensions().get::<MatchedPath>().map(|r| r.as_str().to_owned());
let method = method.as_str();
// Inside the request's span, so the line carries its trace id and leads to its trace.
let _in = span.enter();
if resp.status().is_success() || resp.status().is_redirection() {
tracing::info!(target: "ipx::http", method, path, route, status, duration_ms, "{method} {path} -> {status} in {duration_ms}ms");
} else {
tracing::warn!(target: "ipx::http", method, path, route, status, duration_ms, "{method} {path} -> {status} in {duration_ms}ms");
}
}
resp
}