Files
pl8list-rewind/backend/src/http/mod.rs
T
nvrl b42780d24b Timed comments on the waveform
Each comment sits as a dot under the moment it was left at. When the music
gets there the dot pings and a bubble grows out of it on a spark, then drifts
up and fades; a mouse over a dot peeks at it. The server fetches the newest
60 comments from pl8list on demand and keeps them for 15 minutes. A setting
turns them off.
2026-11-02 23:18:44 +01:00

379 lines
14 KiB
Rust

//! Routes, shared state and the middleware every request goes through.
pub mod account;
pub mod app;
pub mod auth;
pub mod legal;
pub mod proxy;
pub mod security;
#[cfg(test)]
mod tests;
use std::{net::SocketAddr, sync::Arc, time::Duration};
use axum::{
Json, Router,
extract::{ConnectInfo, DefaultBodyLimit, Path, Request, State},
http::{HeaderMap, HeaderValue, Method, StatusCode, header},
middleware::{self, Next},
response::{IntoResponse, Response},
routing::{get, post},
};
use rust_embed::Embed;
use serde_json::json;
use tower::limit::GlobalConcurrencyLimitLayer;
use tower_http::timeout::TimeoutLayer;
use secrecy::SecretString;
use crate::{
cache::Cache,
comments::Comments,
config::Config,
crawler::Crawler,
db::{self, Db, User},
link::{LinkKey, Sealer},
pl8::Pl8,
ratelimit::RateLimiter,
sync::Syncer,
};
use security::Cookies;
pub const SESSION_COOKIE: &str = "pl8inf";
pub const LOGIN_COOKIE: &str = "pl8inf_login";
/// Sessions get a new token once they are this old.
const ROTATE_AFTER: f64 = 7.0 * 86400.0;
pub struct Limits {
pub login_ip: RateLimiter,
pub login_email: RateLimiter,
/// Every start and resend makes pl8list send a mail; cap them for the whole server.
pub login_global: RateLimiter,
pub events: RateLimiter,
/// Feed, artist, track and likes: each runs queries over the catalogue.
pub reads: RateLimiter,
pub proxy: RateLimiter,
/// The proxy per address too: guests are cheap to make, so a per-user limit
/// alone would not hold.
pub proxy_ip: RateLimiter,
/// Saves to a user's pl8list library (likes and follows pushed from here).
pub pl8_writes: RateLimiter,
/// The resync button.
pub resync: RateLimiter,
/// Guests made per IP: each is a row in the database.
pub guests: RateLimiter,
/// Comment fetches from pl8list, per user: only misses of the comment cache count.
pub comments: RateLimiter,
}
pub struct App {
pub cfg: Config,
pub db: Db,
pub pl8: Arc<Pl8>,
pub crawler: Arc<Crawler>,
pub sync: Arc<Syncer>,
pub cache: Cache,
pub logins: auth::PendingLogins,
pub limits: Limits,
pub cookies: Cookies,
pub segments: proxy::SegmentKeys,
pub legal: legal::Pages,
pub comments: Comments,
}
pub type St = State<Arc<App>>;
impl App {
pub fn new(cfg: Config, db: Db, pl8: Arc<Pl8>, crawler: Arc<Crawler>, link: LinkKey) -> Arc<Self> {
let cache = Cache::open(cfg.cache_dir.clone(), cfg.cache_max_bytes);
let sync = Syncer::new(pl8.clone(), db.clone(), crawler.clone(), link);
let legal = legal::Pages::render(&cfg.legal);
Arc::new(App {
cookies: Cookies { secure: cfg.secure },
limits: Limits {
login_ip: RateLimiter::new(5, 900),
login_email: RateLimiter::new(3, 900),
login_global: RateLimiter::new(30, 600),
events: RateLimiter::new(600, 600),
reads: RateLimiter::new(120, 60),
proxy: RateLimiter::new(1200, 60),
proxy_ip: RateLimiter::new(3000, 60),
pl8_writes: RateLimiter::new(60, 600),
resync: RateLimiter::new(1, 120),
guests: RateLimiter::new(10, 3600),
comments: RateLimiter::new(30, 300),
},
comments: Comments::default(),
logins: auth::PendingLogins::default(),
segments: proxy::SegmentKeys::default(),
sync,
cache,
legal,
cfg,
db,
pl8,
crawler,
})
}
/// Hourly housekeeping.
pub async fn purge(&self) {
match self.db.call(db::purge).await {
Ok((s, e, g)) if s + e + g > 0 => tracing::info!("purged {s} expired sessions, {e} old served events, {g} gone guests"),
Ok(_) => {}
Err(e) => tracing::warn!("purge failed: {e:#}"),
}
self.logins.prune();
self.segments.prune();
for l in [&self.limits.guests, &self.limits.reads, &self.limits.proxy, &self.limits.proxy_ip, &self.limits.events, &self.limits.comments] {
l.prune();
}
}
/// This device's stored pl8list session, if it stays connected. A blob that no
/// longer opens (for example after the server key changed) is dropped.
pub async fn connected(&self, current: &CurrentUser) -> Option<(Sealer, SecretString)> {
let (Some(user), Some(token), Some(blob)) = (&current.user, &current.token, &current.link) else { return None };
let sealer = self.sync.key.sealer(token, user.id);
match sealer.open(blob) {
Some(session) => Some((sealer, session)),
None => {
let hash = sealer.token_hash.clone();
let _ = self.db.call(move |c| db::clear_link(c, &hash)).await;
None
}
}
}
}
// Errors ------------------------------------------------------------------
pub struct AppError(anyhow::Error);
impl<E: Into<anyhow::Error>> From<E> for AppError {
fn from(e: E) -> Self {
AppError(e.into())
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
tracing::error!("{:#}", self.0);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
pub type Res = Result<Response, AppError>;
pub fn json_error(status: StatusCode, msg: &str) -> Response {
(status, no_store(), Json(json!({ "error": msg }))).into_response()
}
pub fn no_store() -> [(header::HeaderName, &'static str); 1] {
[(header::CACHE_CONTROL, "no-store")]
}
// Session -----------------------------------------------------------------
/// The signed-in user, put into request extensions by `session`.
#[derive(Clone)]
pub struct CurrentUser {
pub user: Option<User>,
pub token: Option<String>,
/// Encrypted pl8list session of this device (see `link.rs`).
pub link: Option<Vec<u8>>,
pub synced_at: Option<f64>,
}
impl CurrentUser {
pub fn require(&self) -> Result<&User, Response> {
self.user.as_ref().ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())
}
}
/// Look up the session cookie, rotate old tokens and move the Python version's
/// unprefixed cookie to the `__Host-` one.
async fn session(State(app): St, mut req: Request, next: Next) -> Response {
let name = app.cookies.name(SESSION_COOKIE);
let headers = req.headers();
let (token, legacy) = match security::cookie(headers, &name) {
Some(t) => (Some(t.to_string()), false),
None if app.cookies.secure => (security::cookie(headers, SESSION_COOKIE).map(String::from), true),
None => (None, false),
};
let mut set_cookie: Vec<HeaderValue> = vec![];
let mut current = CurrentUser { user: None, token: token.clone(), link: None, synced_at: None };
if let Some(t) = token {
let lookup = t.clone();
// Looked up on a reader; the writer only sees the rare request that has to
// record something.
if let Ok(Some(hit)) = app.db.read(move |c| db::session_user(c, &lookup)).await {
if hit.stale() {
let (tt, h) = (t.clone(), hit.clone());
let _ = app.db.call(move |c| db::touch_session(c, &tt, &h)).await;
}
let mut new_token = None;
current.link = hit.link.clone();
current.synced_at = hit.synced_at;
if hit.created_at < db::now() - ROTATE_AFTER {
let (old, uid) = (t.clone(), hit.user.id);
// The stored pl8list session is encrypted for the old token; move it over.
let reopened = hit.link.as_deref().and_then(|b| app.sync.key.sealer(&t, uid).open(b));
let key = &app.sync.key;
let synced = hit.synced_at;
new_token = match app.db.call(move |c| db::rotate_session(c, &old, uid)).await {
Ok(Some(nt)) => {
if let Some(session) = reopened {
let new = key.sealer(&nt, uid);
let blob = new.seal(&session);
let old_hash = db::hash_token(&t);
let stored = blob.clone();
let _ = app
.db
.call(move |c| {
db::set_link(c, &new.token_hash, &stored, synced)?;
db::clear_link(c, &old_hash)
})
.await;
current.link = Some(blob);
} else {
current.link = None;
}
Some(nt)
}
// Rotated by a request running alongside this one, or failed: the
// old token works for another minute either way.
Ok(None) | Err(_) => None,
};
} else if legacy {
new_token = Some(t.clone());
}
if let Some(nt) = new_token {
set_cookie.push(app.cookies.set(SESSION_COOKIE, &nt, (db::SESSION_IDLE_DAYS * 86400.0) as i64));
current.token = Some(nt);
}
current.user = Some(hit.user);
}
if legacy {
set_cookie.push(app.cookies.clear_legacy(SESSION_COOKIE));
}
}
req.extensions_mut().insert(current);
let mut res = next.run(req).await;
// Handlers that set the session cookie themselves (sign-in, sign-out) win.
let handled = res.headers().get_all(header::SET_COOKIE).iter().any(|v| v.as_bytes().starts_with(format!("{name}=").as_bytes()));
if !handled {
for c in set_cookie {
res.headers_mut().append(header::SET_COOKIE, c);
}
}
res
}
/// POSTs must come from our own pages.
async fn origin_check(State(app): St, req: Request, next: Next) -> Response {
if req.method() == Method::POST && !security::same_origin(req.headers(), app.cfg.public_origin.as_deref()) {
return StatusCode::FORBIDDEN.into_response();
}
next.run(req).await
}
pub fn client_ip(app: &App, headers: &HeaderMap, peer: SocketAddr) -> String {
security::ip_key(security::client_ip(headers, peer, app.cfg.proxy_hops))
}
pub type Peer = ConnectInfo<SocketAddr>;
// Static files ------------------------------------------------------------
/// The built front end (`web/`, `npm run build`). Hashed asset names, so they
/// can be cached for good; index.html and fixed-name files are revalidated.
#[derive(Embed)]
#[folder = "../web/dist/"]
struct Static;
fn content_type(path: &str) -> &'static str {
match path.rsplit('.').next().unwrap_or_default() {
"html" => "text/html; charset=utf-8",
"js" => "text/javascript; charset=utf-8",
"css" => "text/css; charset=utf-8",
"png" => "image/png",
"svg" => "image/svg+xml",
"webmanifest" => "application/manifest+json",
"txt" => "text/plain; charset=utf-8",
_ => "application/octet-stream",
}
}
fn static_file(path: &str) -> Response {
let cache = if path.starts_with("assets/") { "public, max-age=31536000, immutable" } else { "no-cache" };
match Static::get(path) {
Some(f) => ([(header::CONTENT_TYPE, content_type(path)), (header::CACHE_CONTROL, cache)], f.data).into_response(),
None => StatusCode::NOT_FOUND.into_response(),
}
}
async fn index() -> Response {
static_file("index.html")
}
async fn static_route(Path(path): Path<String>) -> Response {
static_file(&path)
}
/// The service worker, at the root so its scope covers the whole app.
async fn service_worker() -> Response {
static_file("sw.js")
}
async fn healthz() -> Response {
(no_store(), Json(json!({ "ok": true }))).into_response()
}
// Router ------------------------------------------------------------------
pub fn router(app: Arc<App>) -> Router {
let secure = app.cfg.secure;
// The page itself, its assets and the legal pages need no session: no database
// lookup for each of them.
let public = Router::new()
.route("/", get(index))
.route("/static/{*path}", get(static_route))
.route("/sw.js", get(service_worker))
.route("/healthz", get(healthz))
.route("/impressum", get(legal::impressum))
.route("/datenschutz", get(legal::datenschutz))
.route("/privacy", get(legal::datenschutz));
let api = Router::new()
.route("/auth/start", post(auth::start))
.route("/auth/resend", post(auth::resend))
.route("/auth/verify", post(auth::verify))
.route("/auth/logout", post(auth::logout))
.route("/auth/guest", post(auth::guest))
.route("/local/me", get(app::me))
.route("/local/feed", get(app::feed))
.route("/local/likes", get(app::likes))
.route("/local/track", get(app::track))
.route("/local/artist", get(app::artist))
.route("/local/comments", get(app::comments))
.route("/local/event", post(app::event))
.route("/local/follow", post(app::follow))
.route("/account/export", get(account::export))
.route("/account/delete", post(account::delete))
.route("/account/logout-all", post(account::logout_all))
.route("/account/pl8list/sync", post(account::sync))
.route("/account/pl8list/disconnect", post(account::disconnect))
.route("/api/{handle}/{slug}/hls/playlist.m3u8", get(proxy::playlist))
.route("/api/{handle}/{slug}/hls/segment/{n}", get(proxy::segment))
.route("/img/{id}", get(proxy::image))
.layer(middleware::from_fn_with_state(app.clone(), session));
public
.merge(api)
.layer(middleware::from_fn_with_state(app.clone(), origin_check))
.layer(DefaultBodyLimit::max(4096))
.layer(TimeoutLayer::with_status_code(StatusCode::REQUEST_TIMEOUT, Duration::from_secs(30)))
.layer(GlobalConcurrencyLimitLayer::new(512))
.layer(middleware::from_fn(move |req, next| security::headers(secure, req, next)))
.with_state(app)
}