The last nineteen functions move to SeaORM: recording feeds, items and enclosures, managed OPML feeds, folding WordPress's repeated files, and handing a Patreon creator's files to its shows. Two SQLite-only forms go: GLOB becomes a LIKE with the underscore escaped (broader, harmlessly: the fold still keys on `_=` and digits), and UPDATE OR IGNORE becomes an UPDATE ... WHERE NOT EXISTS. The two transactions are SeaORM transactions. With nothing left on it, rusqlite goes, with the SQL schema and migrate(). The entities are the schema: create_missing makes whatever tables and indexes a database lacks, from them, with CREATE ... IF NOT EXISTS. Production's schema already has every column migrate() added and none it dropped. Not SeaORM's schema sync, used until now: despite its docs it drops a unique index the entities do not describe, so it dropped users_name_lower on every open. Every `ipx` command then took a write lock, and against a daemon busy writing, `ipx status` -- the healthcheck -- failed 7 times in 15 where the old code failed none. Now 15 in 15, as before. On Postgres it would not have started. WAL is set only when a file is not already in it: setting it takes a lock that cannot wait out a busy daemon. Checked on copies of production: a forced scan of all 162 feeds against the real feeds with no database errors; the feed list, filters, sorts, search and the reaper's candidates against the old code on the same data, earlier in the branch. The column comments from the SQL schema move to the entities. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1849 lines
69 KiB
Rust
1849 lines
69 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 serde::Deserialize;
|
|
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>,
|
|
}
|
|
|
|
pub fn router(state: WebState) -> Router {
|
|
Router::new()
|
|
.route("/", get(index))
|
|
.route("/api/me", get(me).patch(patch_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", get(all_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/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/popular", get(get_popular))
|
|
.route("/api/directory", get(get_directory))
|
|
.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("/admin", get(admin_page))
|
|
.route("/admin.js", get(admin_js))
|
|
.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))
|
|
.route("/icon.png", get(icon))
|
|
.route("/favicon.ico", get(favicon))
|
|
.route("/favicon.png", get(favicon))
|
|
.route("/apple-touch-icon.png", get(touch_icon))
|
|
.route("/app.js", get(app_js))
|
|
.route("/app.css", get(app_css))
|
|
.route("/login.js", get(login_js))
|
|
.route("/inter.woff2", get(inter))
|
|
.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();
|
|
|
|
// 1. A header, but only from a hop we were told to believe.
|
|
let vouched = vouched_name(&cfg, &req);
|
|
|
|
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();
|
|
|
|
// 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.
|
|
fn vouched_name(cfg: &crate::config::Config, req: &Request) -> Option<String> {
|
|
let peer = req
|
|
.extensions()
|
|
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
|
|
.map(|c| c.0.ip().to_string())
|
|
.unwrap_or_default();
|
|
if cfg.web.trusted_header.is_empty() || !cfg.web.trusted_proxies.iter().any(|p| p == &peer) {
|
|
return None;
|
|
}
|
|
req.headers()
|
|
.get(&cfg.web.trusted_header)
|
|
.and_then(|v| v.to_str().ok())
|
|
.and_then(crate::auth::name_from_header)
|
|
}
|
|
|
|
/// 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.
|
|
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: 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).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 (theme, mode) = state.ctx.db.theme(user.id).await.unwrap_or_default();
|
|
Json(serde_json::json!({
|
|
"name": user.name, "admin": user.is_admin, "sign_out": sign_out, "theme": theme, "mode": mode,
|
|
}))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct MePatch {
|
|
theme: String,
|
|
mode: String,
|
|
}
|
|
|
|
/// Saves the theme to the account, so it follows the person rather than the browser.
|
|
async fn patch_me(
|
|
State(state): State<WebState>,
|
|
user: crate::db::User,
|
|
Json(body): Json<MePatch>,
|
|
) -> Result<StatusCode, ApiError> {
|
|
// The page's script knows the themes; this only makes sure what is kept is safe to write
|
|
// into the page's <html> tag, which is where index() puts it.
|
|
if !theme_ok(&body.theme, &body.mode) {
|
|
return Err(ApiError::bad_request("not a theme"));
|
|
}
|
|
state.ctx.db.set_theme(user.id, &body.theme, &body.mode).await?;
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
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]
|
|
}
|
|
|
|
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
|
|
)));
|
|
}
|
|
state.ctx.db.delete_user(id).await?;
|
|
tracing::info!(by = %user.name, user = %target.name, "account removed");
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
/// The password form, except for someone the proxy vouches for: they are signed in already, and
|
|
/// the form only made it look as if they were not.
|
|
async fn login_page(State(state): State<WebState>, req: Request) -> Response {
|
|
if vouched_name(&state.ctx.cfg(), &req).is_some() {
|
|
return Redirect::to("/").into_response();
|
|
}
|
|
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(include_str!(concat!(env!("OUT_DIR"), "/login.html"))))
|
|
.into_response()
|
|
}
|
|
|
|
/// The pages are checked on every visit, so a browser always has the one naming the current
|
|
/// scripts; the scripts, named by a hash of their contents (/app.js?v=<hash>, see
|
|
/// web/build.mjs), are kept a year and never asked for again. A deploy that changes a script
|
|
/// changes its name in the page, and the browser fetches it.
|
|
const PAGE_CACHE: &str = "no-cache";
|
|
const SCRIPT_CACHE: &str = "public, max-age=31536000, immutable";
|
|
|
|
/// The page's script, and the sign-in page's. Outside the auth layer, like the icon: the sign-in
|
|
/// page needs its own before anyone has signed in, and neither holds anything private.
|
|
async fn app_js() -> impl IntoResponse {
|
|
script(include_str!(concat!(env!("OUT_DIR"), "/app.js")))
|
|
}
|
|
|
|
/// The stylesheet the app and admin pages share, named by hash in each as the scripts are.
|
|
async fn app_css() -> impl IntoResponse {
|
|
(
|
|
[(header::CONTENT_TYPE, "text/css; charset=utf-8"), (header::CACHE_CONTROL, SCRIPT_CACHE)],
|
|
include_str!(concat!(env!("OUT_DIR"), "/app.css")),
|
|
)
|
|
}
|
|
|
|
/// The admin page and its script go to admins only: not just hidden from everyone else, never
|
|
/// sent. Anyone else asking for the page is sent back to the app.
|
|
async fn admin_page(State(state): State<WebState>, user: crate::db::User) -> Response {
|
|
if !user.is_admin {
|
|
return Redirect::to("/").into_response();
|
|
}
|
|
let theme = state.ctx.db.theme(user.id).await.unwrap_or_default();
|
|
let page = with_theme(include_str!(concat!(env!("OUT_DIR"), "/admin.html")), theme);
|
|
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(page)).into_response()
|
|
}
|
|
|
|
async fn admin_js(user: crate::db::User) -> Response {
|
|
if !user.is_admin {
|
|
return (StatusCode::FORBIDDEN, "only an admin").into_response();
|
|
}
|
|
script(include_str!(concat!(env!("OUT_DIR"), "/admin.js"))).into_response()
|
|
}
|
|
|
|
async fn login_js() -> impl IntoResponse {
|
|
script(include_str!(concat!(env!("OUT_DIR"), "/login.js")))
|
|
}
|
|
|
|
fn script(js: &'static str) -> impl IntoResponse {
|
|
([(header::CONTENT_TYPE, "text/javascript; charset=utf-8"), (header::CACHE_CONTROL, SCRIPT_CACHE)], js)
|
|
}
|
|
|
|
/// The 2004 icon, served once for both pages rather than inlined as base64 into each. The
|
|
/// sign-in page shows it, so it sits outside the auth layer with /login.
|
|
async fn icon() -> impl IntoResponse {
|
|
(
|
|
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")],
|
|
include_bytes!("../web/ipodderx-icon.png").as_slice(),
|
|
)
|
|
}
|
|
|
|
/// The logo, squared up with transparent padding: it is 128x121, and a tab icon that is not
|
|
/// square can be passed over. /favicon.ico is the same PNG, for a browser that asks for that
|
|
/// on its own; behind the auth layer it answered 401, and the tab stayed blank.
|
|
async fn favicon() -> impl IntoResponse {
|
|
(
|
|
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")],
|
|
include_bytes!("../web/favicon.png").as_slice(),
|
|
)
|
|
}
|
|
|
|
/// For an iPhone's home screen, which paints a transparent icon's background black, so this
|
|
/// one is on white.
|
|
async fn touch_icon() -> impl IntoResponse {
|
|
(
|
|
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")],
|
|
include_bytes!("../web/apple-touch-icon.png").as_slice(),
|
|
)
|
|
}
|
|
|
|
/// Inter, the pages' typeface, served from the binary as the icon is, so neither page loads
|
|
/// anything from anyone else. Outside the auth layer for the sign-in page. Its licence, the SIL
|
|
/// Open Font License, is web/Inter-LICENSE.txt.
|
|
async fn inter() -> impl IntoResponse {
|
|
(
|
|
[(header::CONTENT_TYPE, "font/woff2"), (header::CACHE_CONTROL, "max-age=604800")],
|
|
include_bytes!("../web/InterVariable.woff2").as_slice(),
|
|
)
|
|
}
|
|
|
|
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.
|
|
const INDEX: &str = include_str!(concat!(env!("OUT_DIR"), "/index.html"));
|
|
const ADMIN_LINK: &str = "<a id=admin ";
|
|
|
|
/// The page, with the log button left out for anyone but an admin. Hiding it from the page's
|
|
/// script instead showed it for a moment on every load, until /api/me answered.
|
|
async fn index(State(state): State<WebState>, user: crate::db::User) -> impl IntoResponse {
|
|
let theme = state.ctx.db.theme(user.id).await.unwrap_or_default();
|
|
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(page_for(user.is_admin, theme)))
|
|
}
|
|
|
|
const HTML_TAG: &str = "<html lang=en>";
|
|
|
|
/// The page for this person: their theme on its <html> tag, and the link to /admin only if they
|
|
/// are an admin. Not hidden for everyone else but left out: hiding it from the page's script
|
|
/// showed it for a moment on every load, until /api/me answered (issue #29).
|
|
fn page_for(admin: bool, theme: (Option<String>, Option<String>)) -> String {
|
|
let mut page = with_theme(INDEX, theme);
|
|
if !admin
|
|
&& let Some(at) = page.find(ADMIN_LINK)
|
|
&& let Some(len) = page[at..].find("</a>")
|
|
{
|
|
page.replace_range(at..at + len + "</a>".len(), "");
|
|
}
|
|
page
|
|
}
|
|
|
|
/// A page with the account's theme on its <html> tag, so it is drawn in it from the first frame
|
|
/// on any browser.
|
|
fn with_theme(page: &str, theme: (Option<String>, Option<String>)) -> String {
|
|
let (Some(t), Some(m)) = theme else { return page.to_owned() };
|
|
if !theme_ok(&t, &m) {
|
|
return page.to_owned();
|
|
}
|
|
// data-choice is light, dark or auto; data-mode, what the CSS reads, is only known here for
|
|
// the first two. theme.ts works Auto out from the system.
|
|
let mode = if m == "auto" { String::new() } else { format!(" data-mode={m}") };
|
|
page.replacen(HTML_TAG, &format!("<html lang=en data-theme={t} data-choice={m}{mode}>"), 1)
|
|
}
|
|
|
|
#[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>,
|
|
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,
|
|
}
|
|
|
|
#[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> {
|
|
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).await?;
|
|
let mine: std::collections::HashMap<String, crate::db::Sub> = state
|
|
.ctx
|
|
.db
|
|
.subscriptions_for(user.id).await?
|
|
.into_iter()
|
|
.map(|s| (s.feed_id.clone(), s))
|
|
.collect();
|
|
let counts = state.ctx.db.subscriber_counts().await?;
|
|
let pinned = state.ctx.db.pinned_feeds(user.id).await?;
|
|
let mut out = Vec::with_capacity(mine.len());
|
|
for sub in &subs {
|
|
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 };
|
|
let s = state.ctx.db.feed_summary(id).await?;
|
|
let st = state.ctx.db.http_state(id).await?;
|
|
out.push(FeedRow {
|
|
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()),
|
|
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, 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),
|
|
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: state.ctx.db.unread_count(user.id, id).await?,
|
|
subscribers: counts.get(id).copied().unwrap_or(0),
|
|
pinned: pinned.contains(id),
|
|
});
|
|
}
|
|
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 key hidden in the path of a host not in `PAID_HOSTS` gets
|
|
/// through; a per-feed `unlisted` flag is the upgrade if that happens again.
|
|
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 };
|
|
// Paid-feed services put the subscriber's key in the path as often as in the query, and a
|
|
// long path segment alone proves nothing (acast's public show ids look the same). So a
|
|
// feed from one of them is private whatever its URL looks like. Supercast is why this
|
|
// exists: `feeds.supercast.com/feeds/<key>` reached the directory before it did.
|
|
let host = u.host_str().unwrap_or("");
|
|
PAID_HOSTS.iter().any(|h| host == *h || host.ends_with(&format!(".{h}")))
|
|
|| !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))
|
|
})
|
|
}
|
|
|
|
/// Services whose feeds are always one subscriber's own.
|
|
const PAID_HOSTS: &[&str] =
|
|
&["patreon.com", "supercast.com", "supportingcast.fm", "glow.fm", "memberful.com"];
|
|
|
|
/// 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,
|
|
/// The feed's own iTunes category, if it names one; most blogs do not.
|
|
category: Option<String>,
|
|
/// Any audio or video enclosure. Unlike category, every feed has an answer, so the
|
|
/// Directory's Podcasts and Blogs between them hold everything.
|
|
podcast: bool,
|
|
}
|
|
|
|
/// Every feed that may be listed, with everyone counted, you included, most subscribers
|
|
/// first. Popular is the top of it, the directory is all of it, and it is all that
|
|
/// `subscribe_popular` will subscribe you to. An OPML or a Patreon creator is listed as the
|
|
/// feeds inside it and never itself: both lists are for finding a show.
|
|
async 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).await?.into_iter().map(|s| s.feed_id).collect();
|
|
let counts = db.subscriber_counts().await?;
|
|
let media = db.media_feeds().await?;
|
|
let catalogue = crate::subscriptions(&state.ctx).await?;
|
|
let by_id: std::collections::HashMap<&str, &crate::config::Feed> =
|
|
catalogue.iter().map(|s| (s.id.as_str(), &s.cfg)).collect();
|
|
let is_folder: std::collections::HashSet<&str> =
|
|
catalogue.iter().filter_map(|s| s.cfg.group.as_deref()).collect();
|
|
let mut out = vec![];
|
|
for s in &catalogue {
|
|
let n = counts.get(&s.id).copied().unwrap_or(0);
|
|
// A feed inside an OPML that looks private is as private as the OPML.
|
|
let folder = s.cfg.group.as_deref().and_then(|g| by_id.get(g));
|
|
if n == 0
|
|
|| is_folder.contains(s.id.as_str())
|
|
|| looks_private(&s.cfg)
|
|
|| folder.is_some_and(|f| looks_private(f))
|
|
{
|
|
continue;
|
|
}
|
|
let sum = db.feed_summary(&s.id).await?;
|
|
let subscribed = mine.contains(&s.id);
|
|
out.push(PopularRow {
|
|
id: s.id.clone(),
|
|
title: sum.title,
|
|
image: sum.image,
|
|
subscribers: n,
|
|
subscribed,
|
|
// The feed's own wins; an admin's is for the feeds, mostly blogs, that name none.
|
|
category: sum.category.or_else(|| s.cfg.category.clone()),
|
|
podcast: media.contains(&s.id),
|
|
});
|
|
}
|
|
out.sort_by(|a, b| b.subscribers.cmp(&a.subscribers).then_with(|| sort_name(a).cmp(&sort_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).await?;
|
|
rows.truncate(10);
|
|
Ok(Json(rows))
|
|
}
|
|
|
|
/// Every feed that may be listed, A to Z, with the feeds inside an OPML in place of the OPML.
|
|
async fn get_directory(
|
|
State(state): State<WebState>,
|
|
user: crate::db::User,
|
|
) -> Result<Json<Vec<PopularRow>>, ApiError> {
|
|
let mut rows = popular(&state, user.id).await?;
|
|
rows.sort_by_key(sort_name);
|
|
Ok(Json(rows))
|
|
}
|
|
|
|
fn sort_name(p: &PopularRow) -> String {
|
|
p.title.clone().unwrap_or_else(|| p.id.clone()).to_lowercase()
|
|
}
|
|
|
|
/// 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).await?.iter().any(|p| p.id == id) {
|
|
return Err(ApiError::bad_request(format!("{id:?} is not in the directory")));
|
|
}
|
|
state.ctx.db.subscribe(user.id, &id).await?;
|
|
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,
|
|
}
|
|
}
|
|
|
|
fn not_found(msg: impl Into<String>) -> Self {
|
|
Self {
|
|
error: anyhow::Error::msg(msg.into()),
|
|
status: StatusCode::NOT_FOUND,
|
|
}
|
|
}
|
|
}
|
|
|
|
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_sent_the_admin_link() {
|
|
// If the markup drifts from ADMIN_LINK, find matches nothing and says nothing.
|
|
assert!(page_for(true, (None, None)).contains(ADMIN_LINK));
|
|
let page = page_for(false, (None, None));
|
|
assert!(!page.contains(ADMIN_LINK) && !page.contains("href=/admin"), "the link is gone");
|
|
assert!(page.contains("id=prefs"), "and only the link: the settings button beside it stays");
|
|
}
|
|
|
|
#[test]
|
|
fn the_page_arrives_in_the_theme_the_account_chose() {
|
|
let page = |t: &str, m: &str| page_for(true, (Some(t.into()), Some(m.into())));
|
|
// If the minifier ever writes the tag differently, HTML_TAG matches nothing, silently.
|
|
assert!(page("dracula", "dark").contains("<html lang=en data-theme=dracula data-choice=dark data-mode=dark>"));
|
|
assert!(page("nordic", "auto").contains("<html lang=en data-theme=nordic data-choice=auto>"));
|
|
// Whatever is in the column is written into markup, so only a plain name gets there.
|
|
assert!(!page("x onload=alert(1)", "dark").contains("onload"));
|
|
assert!(page("modern", "dark\"").contains("<html lang=en>"));
|
|
}
|
|
|
|
#[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,
|
|
category: 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")));
|
|
// Paid-feed services put the key in the path; the host gives them away.
|
|
assert!(looks_private(&f("https://feeds.supercast.com/feeds/abcdefghijklmnopqrstuvwx")));
|
|
assert!(looks_private(&f("https://someshow.supportingcast.fm/content/abc123.rss")));
|
|
// A long path segment alone is not a key: acast's public show ids look the same.
|
|
assert!(!looks_private(&f("https://feeds.acast.com/public/shows/0123456789abcdef01234567")));
|
|
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,
|
|
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,
|
|
}
|
|
}
|
|
|
|
#[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> {
|
|
entry_page(&state, user.id, Some(&id), &page).await
|
|
}
|
|
|
|
/// 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, &page).await
|
|
}
|
|
|
|
/// One feed's page of items, or every subscribed feed's when `feed` is None.
|
|
async fn entry_page(
|
|
state: &WebState,
|
|
user_id: i64,
|
|
feed: Option<&str>,
|
|
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;
|
|
// Currently Listening keeps its own order, pinned or not: it is what you are part-way through.
|
|
let order = crate::db::order_sql(
|
|
page.sort.as_deref().unwrap_or("published"),
|
|
page.dir.as_deref().unwrap_or("desc"),
|
|
filter != crate::db::Filter::InProgress,
|
|
);
|
|
let mut rows =
|
|
db.entries_in(user_id, feed, filter, search, page.offset, page.limit.clamp(1, 200), &order).await?;
|
|
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()));
|
|
}
|
|
}
|
|
let total = db.count_in(user_id, feed, filter, search).await?;
|
|
Ok(Json(EntryPage { total, entries: rows }))
|
|
}
|
|
|
|
/// 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> {
|
|
let mut cfg = (*state.ctx.cfg()).clone();
|
|
let url = crate::feed::expand_input(&body.url);
|
|
// 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).await?
|
|
.into_iter()
|
|
.find(|s| crate::feed::same_feed(&s.cfg.url, &url))
|
|
{
|
|
let already = state.ctx.db.subscription(user.id, &existing.id).await?.is_some();
|
|
state.ctx.db.subscribe(user.id, &existing.id).await?;
|
|
if !already {
|
|
explicit_on_add(&state, user.id, &existing.id, body.allow_explicit).await?;
|
|
}
|
|
scan_soon(&state, Some(existing.id.clone())).await;
|
|
return Ok(Json(
|
|
serde_json::json!({ "id": existing.id, "existing": already }),
|
|
));
|
|
}
|
|
let id = crate::add_one(&state.ctx, &mut cfg, &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).await?;
|
|
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": false })))
|
|
}
|
|
|
|
/// 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 }).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>>,
|
|
allow_explicit: Option<bool>,
|
|
auto_download: Option<bool>,
|
|
#[serde(default, deserialize_with = "double_option")]
|
|
max_new_per_check: Option<Option<usize>>,
|
|
pinned: Option<bool>,
|
|
}
|
|
|
|
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"));
|
|
}
|
|
// 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).await?.is_some() {
|
|
let mut mine = state
|
|
.ctx
|
|
.db
|
|
.subscription(user.id, &id).await?
|
|
.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).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 feed_level =
|
|
body.url.is_some() || body.folder.is_some() || body.schedule.is_some() || body.category.is_some();
|
|
if !feed_level {
|
|
return Ok(StatusCode::NO_CONTENT);
|
|
}
|
|
if !user.is_admin {
|
|
return Err(ApiError::forbidden(
|
|
"the feed's address, folder, schedule and category 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).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());
|
|
}
|
|
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).await?;
|
|
}
|
|
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).await?;
|
|
for child in crate::subscriptions(&state.ctx).await?
|
|
.iter()
|
|
.filter(|s| s.cfg.group.as_deref() == Some(id.as_str()))
|
|
{
|
|
state.ctx.db.unsubscribe(user.id, &child.id).await?;
|
|
}
|
|
if state.ctx.db.subscriber_counts().await?.contains_key(&id) {
|
|
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).await?;
|
|
return Ok(StatusCode::NO_CONTENT);
|
|
}
|
|
cfg.save(&state.config_path)?;
|
|
state.ctx.reload_cfg(&state.config_path)?;
|
|
crate::retire_group(&state.ctx, &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<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).await?;
|
|
}
|
|
if let Some(v) = body.flagged {
|
|
state.ctx.db.set_entry_flag(user.id, &feed_id, &guid, EntryFlag::Flagged, v).await?;
|
|
}
|
|
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).await?
|
|
.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).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(|| 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).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) = 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).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>,
|
|
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>>> {
|
|
// A client that falls behind skips what it missed rather than being cut off.
|
|
let stream = futures_util::stream::unfold(state.events.subscribe(), |mut rx| async move {
|
|
loop {
|
|
match rx.recv().await {
|
|
Ok(ev) => {
|
|
if let Ok(data) = serde_json::to_string(&ev) {
|
|
let ev = Ok::<_, std::convert::Infallible>(SseEvent::default().data(data));
|
|
return Some((ev, rx));
|
|
}
|
|
}
|
|
Err(broadcast::error::RecvError::Lagged(_)) => {}
|
|
Err(broadcast::error::RecvError::Closed) => return None,
|
|
}
|
|
}
|
|
});
|
|
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).await 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,
|
|
/// 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> {
|
|
state.ctx.db.set_position(user.id, &feed_id, &guid, body.secs, body.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>,
|
|
Json(body): Json<HowMany>,
|
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
|
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, &state.config_path, &doc, user.id).await?;
|
|
if added > 0 {
|
|
scan_soon(&state, None).await;
|
|
}
|
|
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;
|
|
}
|
|
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
|
|
}
|