Files
ipodderx-rs/src/web.rs
rays c0f4b0bcb2 Popular button in the sidebar; the popular list counts everyone
- A Popular button beside + Feed opens the popular list directly; the
  Add feed dialog keeps it too.
- The list counts every subscriber, you included. Your own feeds stay on
  it, marked Subscribed, and clicking one opens it. Private feeds and
  feeds inside an OPML are still never listed, for anyone.
- GET /api/popular rows carry `subscribed`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HdTEWQNrzyFULijigkmMn
2026-09-11 14:05:24 +00:00

1418 lines
49 KiB
Rust

//! Web front end. Runs inside the daemon so it reads SQLite and the event bus directly.
use anyhow::{Context, Result};
use axum::{
Json, Router,
extract::{Path, Query, Request, State},
http::{StatusCode, header},
middleware::{self, Next},
response::{
Html, IntoResponse, Redirect, Response,
sse::{Event as SseEvent, Sse},
},
routing::{delete, get, patch, post},
};
use futures_util::StreamExt;
use serde::Deserialize;
use tokio_stream::wrappers::BroadcastStream;
use tower::ServiceExt;
use tower_http::services::ServeFile;
use serde::Serialize;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::{broadcast, mpsc};
use crate::Ctx;
use crate::ipc::{Command, Event};
const COOKIE: &str = "ipx_token";
#[derive(Clone)]
pub struct WebState {
pub ctx: Arc<Ctx>,
pub config_path: PathBuf,
pub cmds: mpsc::Sender<Command>,
pub events: broadcast::Sender<Event>,
}
/// A 32-hex-character shared secret, generated when config.toml has none.
///
/// ponytail: /dev/urandom rather than a CSPRNG crate -- 16 bytes, once, on a Unix-only
/// binary. Falls back to the clock only if urandom is somehow unreadable, which would be a
/// weak token, so that case is logged loudly.
pub fn generate_token() -> String {
use std::io::Read;
let mut bytes = [0u8; 16];
match std::fs::File::open("/dev/urandom").and_then(|mut f| f.read_exact(&mut bytes)) {
Ok(()) => {}
Err(e) => {
tracing::error!(error = %e, "could not read /dev/urandom; token is NOT secure");
let n = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
bytes[..8].copy_from_slice(&n.to_le_bytes());
}
}
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
pub fn router(state: WebState) -> Router {
Router::new()
.route("/", get(index))
.route("/api/me", get(me))
.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/{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/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/popular", get(get_popular))
.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/events", get(events))
.route("/media/{id}", get(media))
.layer(middleware::from_fn_with_state(state.clone(), auth))
// Signing in cannot require being signed in, so these sit outside the auth layer.
.route("/login", get(login_page))
.route("/api/login", post(login))
.layer(middleware::from_fn(access_log))
.with_state(state)
}
pub async fn serve(state: WebState, bind: &str) -> Result<()> {
let listener = tokio::net::TcpListener::bind(bind)
.await
.with_context(|| format!("binding {bind}"))?;
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,
/// 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();
let peer = req
.extensions()
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
.map(|c| c.0.ip().to_string())
.unwrap_or_default();
// 1. A header, but only from a hop we were told to believe. Anyone able to reach the
// port could otherwise send it and be whoever they liked.
let vouched = (!cfg.web.trusted_header.is_empty()
&& cfg.web.trusted_proxies.iter().any(|p| p == &peer))
.then(|| {
req.headers()
.get(&cfg.web.trusted_header)
.and_then(|v| v.to_str().ok())
.and_then(crate::auth::name_from_header)
})
.flatten();
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) {
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");
state
.ctx
.db
.create_user(&name, None, state.ctx.db.users().map(|u| u.is_empty()).unwrap_or(false))
.ok()
.and_then(|id| state.ctx.db.user_by_id(id).ok().flatten())
}
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
}
};
}
// 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)
.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);
if from_query.is_some() {
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);
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";
/// 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> {
req.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.
fn admin_user(state: &WebState) -> Option<crate::db::User> {
let users = state.ctx.db.users().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)?;
// The same answer either way: whether a name exists is not something to leak.
let ok = user
.as_ref()
.and_then(|u| u.pass_hash.as_deref())
.is_some_and(|h| crate::auth::verify_password(&body.password, h));
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)?;
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);
}
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
}
async fn me(user: crate::db::User) -> Json<serde_json::Value> {
Json(serde_json::json!({ "name": user.name, "admin": user.is_admin }))
}
// ---- 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]
}
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()?
.iter()
.map(|u| {
serde_json::json!({
"id": u.id, "name": u.name, "admin": u.is_admin, "password": u.pass_hash.is_some(),
})
})
.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)?.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)?;
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()?;
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)?;
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()?;
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
)));
}
state.ctx.db.delete_user(id)?;
tracing::info!(by = %user.name, user = %target.name, "account removed");
Ok(StatusCode::NO_CONTENT)
}
async fn login_page() -> Html<&'static str> {
Html(include_str!("../web/login.html"))
}
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
}
async fn index() -> Html<&'static str> {
Html(include_str!("../web/index.html"))
}
#[derive(Serialize)]
struct FeedRow {
id: String,
url: String,
title: Option<String>,
image: Option<String>,
folder: Option<String>,
keywords: Vec<String>,
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>,
entries: i64,
downloaded: i64,
unread: i64,
/// Including you. More than one means every file here is shared.
subscribers: i64,
}
async fn feeds(
State(state): State<WebState>,
user: crate::db::User,
) -> Result<Json<Vec<FeedRow>>, ApiError> {
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)?;
let mine: std::collections::HashMap<String, crate::db::Sub> = state
.ctx
.db
.subscriptions_for(user.id)?
.into_iter()
.map(|s| (s.feed_id.clone(), s))
.collect();
let counts = state.ctx.db.subscriber_counts()?;
let mut out = Vec::with_capacity(mine.len());
for sub in &subs {
let (id, feed) = (&sub.id, &sub.cfg);
let Some(mine) = mine.get(id) else { continue };
let s = state.ctx.db.feed_summary(id)?;
let st = state.ctx.db.http_state(id)?;
out.push(FeedRow {
id: id.clone(),
url: feed.url.clone(),
title: s.title,
image: s.image,
folder: feed.folder.clone(),
keywords: mine.keywords.clone().unwrap_or_else(|| feed.keywords.clone()),
allow_explicit: mine.allow_explicit.unwrap_or(feed.allow_explicit),
auto_download: mine.auto_download.unwrap_or(feed.auto_download),
max_new_per_check: mine
.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, st.ttl_mins) / 60,
last_checked: s.last_checked,
next_check: s
.last_checked
.map(|t| t + crate::due_after(&cfg, feed, st.ttl_mins) as i64),
last_error: s.last_error,
entries: s.entries,
downloaded: s.downloaded,
unread: state.ctx.db.unread_count(user.id, id)?,
subscribers: counts.get(id).copied().unwrap_or(0),
});
}
Ok(Json(out))
}
// ---- popular on this server ----
/// A feed that carries a credential is someone's paid or private subscription. Listing it
/// would let anyone signed in subscribe to it and read what they pay for.
///
/// ponytail: a heuristic. A token hidden in the URL's path gets through; a per-feed
/// `unlisted` flag is the upgrade if that ever happens.
fn looks_private(feed: &crate::config::Feed) -> bool {
if feed.username.is_some() || feed.password.is_some() || feed.password_env.is_some() {
return true;
}
let Ok(u) = url::Url::parse(&feed.url) else { return true };
!u.username().is_empty()
|| u.password().is_some()
|| u.query_pairs().any(|(k, _)| {
let k = k.to_ascii_lowercase();
["auth", "token", "key", "secret", "pass", "sig", "session", "user", "uid"]
.iter()
.any(|w| k.contains(w))
})
}
/// Only an id, a title, artwork and a count: never a URL, which is where a key would be.
#[derive(Serialize)]
struct PopularRow {
id: String,
title: Option<String>,
image: Option<String>,
subscribers: i64,
/// Yours already. Everyone counts, you included, so your own feeds are listed too.
subscribed: bool,
}
/// What everyone here subscribes to, you included, most subscribers first. What the Popular
/// button and the Add feed screen show, and all that `subscribe_popular` will subscribe
/// you to.
fn popular(state: &WebState, user_id: i64) -> Result<Vec<PopularRow>> {
let db = &state.ctx.db;
let mine: std::collections::HashSet<String> =
db.subscriptions_for(user_id)?.into_iter().map(|s| s.feed_id).collect();
let counts = db.subscriber_counts()?;
let mut out = vec![];
for s in crate::subscriptions(&state.ctx)? {
let n = counts.get(&s.id).copied().unwrap_or(0);
// A feed from an OPML rides on the OPML: everyone subscribed to it counts every feed
// inside, which would bury everything anyone chose on purpose.
let from_opml = s.managed || s.cfg.group.is_some();
if n == 0 || from_opml || looks_private(&s.cfg) {
continue;
}
let sum = db.feed_summary(&s.id)?;
let subscribed = mine.contains(&s.id);
out.push(PopularRow { id: s.id, title: sum.title, image: sum.image, subscribers: n, subscribed });
}
let name = |p: &PopularRow| p.title.clone().unwrap_or_else(|| p.id.clone()).to_lowercase();
out.sort_by(|a, b| b.subscribers.cmp(&a.subscribers).then_with(|| name(a).cmp(&name(b))));
Ok(out)
}
async fn get_popular(
State(state): State<WebState>,
user: crate::db::User,
) -> Result<Json<Vec<PopularRow>>, ApiError> {
let mut rows = popular(&state, user.id)?;
rows.truncate(20);
Ok(Json(rows))
}
/// Subscribes by id, because the list never shows a URL. Checked against the same list, so
/// a guessed id cannot reach a private feed.
async fn subscribe_popular(
State(state): State<WebState>,
user: crate::db::User,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, ApiError> {
if !popular(&state, user.id)?.iter().any(|p| p.id == id) {
return Err(ApiError::bad_request(format!("{id:?} is not on the popular list")));
}
state.ctx.db.subscribe(user.id, &id)?;
Ok(Json(serde_json::json!({ "id": 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,
}
}
}
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_feed_with_a_credential_is_never_popular() {
let f = |url: &str| 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,
};
assert!(!looks_private(&f("https://feeds.twit.tv/twit.xml")));
assert!(!looks_private(&f("https://example.com/rss?format=mp3")));
// Patreon's shape: the key is a query parameter.
assert!(looks_private(&f("https://www.patreon.com/rss/x?auth=abc123&show=2073588")));
assert!(looks_private(&f("https://example.com/rss?api_key=abc")));
assert!(looks_private(&f("https://ray:hunter2@example.com/rss")));
assert!(looks_private(&f("not a url")), "unparseable is not safe to list");
let mut basic = f("https://example.com/rss");
basic.username = Some("ray".into());
assert!(looks_private(&basic), "a feed with a login configured");
}
#[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 };
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,
}
}
#[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,"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.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"
);
}
#[test]
fn generated_tokens_are_32_hex_chars_and_not_repeated() {
let a = generate_token();
let b = generate_token();
assert_eq!(a.len(), 32);
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
assert_ne!(a, b);
}
}
#[derive(Deserialize)]
struct Page {
#[serde(default)]
offset: i64,
#[serde(default = "fifty")]
limit: i64,
#[serde(default)]
filter: Option<String>,
#[serde(default)]
q: 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> {
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 mut rows = state
.ctx
.db
.entries(user.id, &id, filter, search, page.offset, page.limit.clamp(1, 200))?;
// Feed HTML is untrusted: it reaches the page only after ammonia has been through it.
for row in &mut rows {
if let Some(d) = &row.description {
row.description = Some(ammonia::clean(d));
}
}
let total = state.ctx.db.count_entries(user.id, &id, filter, search)?;
Ok(Json(EntryPage { total, entries: rows }))
}
#[derive(Deserialize)]
struct NewFeed {
url: String,
#[serde(default)]
folder: Option<String>,
#[serde(default)]
keywords: Vec<String>,
}
async fn add_feed(
State(state): State<WebState>,
user: crate::db::User,
Json(body): Json<NewFeed>,
) -> Result<Json<serde_json::Value>, ApiError> {
let mut cfg = (*state.ctx.cfg()).clone();
// 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.
if let Some(existing) = crate::subscriptions(&state.ctx)?
.into_iter()
.find(|s| s.cfg.url == body.url)
{
let already = state.ctx.db.subscription(user.id, &existing.id)?.is_some();
state.ctx.db.subscribe(user.id, &existing.id)?;
return Ok(Json(
serde_json::json!({ "id": existing.id, "existing": already }),
));
}
let id = crate::add_one(&state.ctx, &mut cfg, &body.url, body.folder, body.keywords).await?;
cfg.save(&state.config_path)?;
state.ctx.reload_cfg(&state.config_path)?;
state.ctx.db.subscribe(user.id, &id)?;
Ok(Json(serde_json::json!({ "id": id, "existing": false })))
}
/// 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>>,
keywords: Option<Vec<String>>,
allow_explicit: Option<bool>,
auto_download: Option<bool>,
#[serde(default, deserialize_with = "double_option")]
max_new_per_check: Option<Option<usize>>,
}
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> {
// 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 state.ctx.db.subscription(user.id, &id)?.is_some() {
let mut mine = state
.ctx
.db
.subscription(user.id, &id)?
.unwrap_or_else(|| crate::db::Sub { feed_id: id.clone(), ..Default::default() });
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)?;
}
}
// 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 feed_level = body.url.is_some() || body.folder.is_some() || body.schedule.is_some();
if !feed_level {
return Ok(StatusCode::NO_CONTENT);
}
if !user.is_admin {
return Err(ApiError::forbidden(
"the feed's address, folder and schedule are the same for everyone, so only an admin changes them",
));
}
let mut cfg = (*state.ctx.cfg()).clone();
// 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)?;
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)?;
}
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());
}
cfg.save(&state.config_path)?;
state.ctx.reload_cfg(&state.config_path)?;
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)?;
}
Ok(StatusCode::NO_CONTENT)
}
async fn remove_feed(
State(state): State<WebState>,
Path(id): Path<String>,
user: crate::db::User,
) -> Result<StatusCode, ApiError> {
// Unsubscribing is personal: it takes the feed off your list and leaves everyone
// else's alone.
state.ctx.db.unsubscribe(user.id, &id)?;
for child in crate::subscriptions(&state.ctx)?
.iter()
.filter(|s| s.cfg.group.as_deref() == Some(id.as_str()))
{
state.ctx.db.unsubscribe(user.id, &child.id)?;
}
if state.ctx.db.subscriber_count(&id)? > 0 {
return Ok(StatusCode::NO_CONTENT);
}
// Nobody is left: the feed stops being scanned. Its files and history stay, so if
// someone subscribes again they do not pull the back catalogue a second time.
let mut cfg = (*state.ctx.cfg()).clone();
if cfg.feeds.remove(&id).is_none() {
// A derived feed: forget it here, though the OPML will list it again on the next
// read unless you unsubscribe from the OPML itself.
state.ctx.db.drop_managed(&id)?;
return Ok(StatusCode::NO_CONTENT);
}
cfg.save(&state.config_path)?;
state.ctx.reload_cfg(&state.config_path)?;
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<StatusCode, 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)?;
}
if let Some(v) = body.flagged {
state.ctx.db.set_entry_flag(user.id, &feed_id, &guid, EntryFlag::Flagged, v)?;
}
Ok(StatusCode::NO_CONTENT)
}
/// 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>,
) -> Result<StatusCode, ApiError> {
let enc = state
.ctx
.db
.enclosure(id)?
.ok_or_else(|| anyhow::anyhow!("no enclosure {id}"))?;
if enc.path.is_some() {
return Ok(StatusCode::NO_CONTENT); // Already here.
}
state.ctx.db.requeue(id)?;
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)?
.ok_or_else(|| anyhow::anyhow!("no enclosure {id}"))?;
// 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)?;
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 {} starred it to keep", people(st))),
(st, u) => Some(format!(
"another {} starred it to keep, 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) = std::fs::remove_file(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)?;
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>,
Json(body): Json<FetchBody>,
) -> Result<StatusCode, ApiError> {
state
.cmds
.send(Command::Fetch { feed: body.feed, force: body.force })
.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>) -> Sse<impl futures_util::Stream<Item = Result<SseEvent, std::convert::Infallible>>> {
let stream = BroadcastStream::new(state.events.subscribe()).filter_map(|ev| async move {
let ev = ev.ok()?;
Some(Ok(SseEvent::default().data(serde_json::to_string(&ev).ok()?)))
});
Sse::new(stream).keep_alive(axum::response::sse::KeepAlive::default())
}
/// Audio, served by ServeFile so Range requests work and the player can seek.
async fn media(
State(state): State<WebState>,
Path(id): Path<i64>,
req: Request,
) -> Response {
let Ok(Some(enc)) = state.ctx.db.enclosure(id) else {
return (StatusCode::NOT_FOUND, "no such enclosure").into_response();
};
let Some(path) = enc.path else {
return (StatusCode::NOT_FOUND, "not downloaded").into_response();
};
match ServeFile::new(path).oneshot(req).await {
Ok(r) => r.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
#[derive(Deserialize)]
struct Position {
secs: 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> {
state.ctx.db.set_position(user.id, &feed_id, &guid, body.secs)?;
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)?
.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)?;
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>,
Json(body): Json<HowMany>,
) -> Result<Json<serde_json::Value>, ApiError> {
let ids = state.ctx.db.undownloaded(&id, body.count.clamp(1, 100))?;
for enc in &ids {
state.ctx.db.requeue(*enc)?;
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)?.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)? {
// 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)
.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, &state.config_path, &doc, user.id)?;
Ok(Json(serde_json::json!({ "added": added, "already": already })))
}
#[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,
}
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,
})
}
#[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>,
}
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.cfg()).clone();
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;
// The legacy key would otherwise keep shadowing intent in the file.
cfg.general.interval_mins = None;
}
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;
}
cfg.save(&state.config_path)?;
state.ctx.reload_cfg(&state.config_path)?;
Ok(StatusCode::NO_CONTENT)
}
#[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 }))
}
/// 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");
let started = std::time::Instant::now();
let resp = next.run(req).await;
if !quiet {
let ms = started.elapsed().as_millis();
let status = resp.status().as_u16();
if resp.status().is_success() || resp.status().is_redirection() {
tracing::info!(target: "ipx::http", "{method} {path} -> {status} in {ms}ms");
} else {
tracing::warn!(target: "ipx::http", "{method} {path} -> {status} in {ms}ms");
}
}
resp
}