Sign out cleared ipx's cookies and showed its password page, while Cloudflare Access still vouched for the person: nothing was signed out, and the page looked like the wrong login. /api/me now says, for someone the proxy signed in, where to go instead ([web] sign_out_url, which is /cdn-cgi/access/logout behind Access), and /login sends anyone the proxy vouches for on to their feeds. The header check both use is one function. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
1571 lines
56 KiB
Rust
1571 lines
56 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))
|
|
.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("/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))
|
|
.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) {
|
|
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
|
|
}
|
|
};
|
|
// 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);
|
|
}
|
|
}
|
|
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)
|
|
.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() {
|
|
// 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);
|
|
}
|
|
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.
|
|
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)?;
|
|
state.ctx.db.signed_in(user.id)?;
|
|
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
|
|
}
|
|
|
|
/// 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);
|
|
Json(serde_json::json!({ "name": user.name, "admin": user.is_admin, "sign_out": sign_out }))
|
|
}
|
|
|
|
// ---- 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(),
|
|
"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)?.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)
|
|
}
|
|
|
|
/// 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();
|
|
}
|
|
Html(include_str!("../web/login.html")).into_response()
|
|
}
|
|
|
|
/// 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(),
|
|
)
|
|
}
|
|
|
|
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);
|
|
// 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)?;
|
|
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()
|
|
.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),
|
|
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 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,
|
|
}
|
|
|
|
/// 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.
|
|
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 catalogue = crate::subscriptions(&state.ctx)?;
|
|
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)?;
|
|
let subscribed = mine.contains(&s.id);
|
|
out.push(PopularRow { id: s.id.clone(), title: sum.title, image: sum.image, subscribers: n, subscribed });
|
|
}
|
|
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)?;
|
|
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)?;
|
|
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)?.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)?;
|
|
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")));
|
|
// 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,
|
|
}
|
|
}
|
|
|
|
#[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"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[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)
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
|
|
/// One feed's page of items, or every subscribed feed's when `feed` is None.
|
|
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;
|
|
let order = crate::db::order_sql(
|
|
page.sort.as_deref().unwrap_or("published"),
|
|
page.dir.as_deref().unwrap_or("desc"),
|
|
);
|
|
let mut rows =
|
|
db.entries_in(user_id, feed, filter, search, page.offset, page.limit.clamp(1, 200), &order)?;
|
|
// 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 = db.count_in(user_id, feed, filter, search)?;
|
|
Ok(Json(EntryPage { total, entries: rows }))
|
|
}
|
|
|
|
#[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.
|
|
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)?;
|
|
}
|
|
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)?
|
|
.into_iter()
|
|
.find(|s| crate::feed::same_feed(&s.cfg.url, &url))
|
|
{
|
|
let already = state.ctx.db.subscription(user.id, &existing.id)?.is_some();
|
|
state.ctx.db.subscribe(user.id, &existing.id)?;
|
|
if !already {
|
|
explicit_on_add(&state, user.id, &existing.id, body.allow_explicit)?;
|
|
}
|
|
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)?;
|
|
explicit_on_add(&state, user.id, &id, body.allow_explicit)?;
|
|
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>>,
|
|
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_counts()?.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)?;
|
|
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 {} kept it", people(st))),
|
|
(st, u) => Some(format!(
|
|
"another {} kept 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)?;
|
|
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) 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 })))
|
|
}
|
|
|
|
/// 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)?.into_iter().map(|s| s.feed_id).collect();
|
|
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)?;
|
|
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
|
|
}
|