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