mirror of
https://github.com/narrrl/pl8list-rewind.git
synced 2026-11-13 09:17:03 +01:00
Rewrite backend in Rust, add Impressum, privacy policy and account tools
New axum server in backend/ with the same HTTP API and database as the Python one. The pl8list session is only held in memory during the feed crawl and logged out afterwards. Local sessions use a __Host- cookie, 30-day idle expiry and weekly token rotation; the old cookie is migrated. The audio and image proxy only accepts known parameters, fixes the content type, follows redirects only to pl8list's file host and caches segments and images on disk. Strict security headers, origin checks on POST, body and concurrency limits. /impressum and /datenschutz are rendered from env variables. The account menu offers data export, sign out on all devices and account deletion. hls.js is self-hosted and artist images are proxied, so the browser only talks to this server. The Docker image builds a static binary on scratch.
This commit is contained in:
1 parent
61af1a6dd2
commit
54a436ae48
35 files changed
+6389
-42
No files matched your search
@@ -0,0 +1,56 @@
|
||||
//! Data export, account deletion and signing out everywhere (Art. 15, 17, 20 GDPR).
|
||||
|
||||
use axum::{
|
||||
Extension, Json,
|
||||
extract::State,
|
||||
http::{StatusCode, header},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CurrentUser, Res, St, auth::signed_out, json_error};
|
||||
use crate::db;
|
||||
|
||||
pub async fn export(State(app): St, Extension(current): Extension<CurrentUser>) -> Res {
|
||||
let user = match current.require() {
|
||||
Ok(u) => u.clone(),
|
||||
Err(r) => return Ok(r),
|
||||
};
|
||||
let uid = user.id;
|
||||
let data = app.db.call(move |c| db::export_user(c, uid)).await?;
|
||||
let body = serde_json::to_vec_pretty(&data)?;
|
||||
let safe: String = user.handle.chars().filter(|c| c.is_ascii_alphanumeric() || "_.-".contains(*c)).collect();
|
||||
let disposition = format!("attachment; filename=\"pl8inf-{safe}.json\"");
|
||||
Ok((
|
||||
[
|
||||
(header::CONTENT_TYPE, "application/json".to_string()),
|
||||
(header::CACHE_CONTROL, "no-store".to_string()),
|
||||
(header::CONTENT_DISPOSITION, disposition),
|
||||
],
|
||||
body,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
pub async fn delete(State(app): St, Extension(current): Extension<CurrentUser>, Json(body): Json<Value>) -> Res {
|
||||
let user = match current.require() {
|
||||
Ok(u) => u.clone(),
|
||||
Err(r) => return Ok(r),
|
||||
};
|
||||
if body.get("confirm").and_then(Value::as_str) != Some(&user.handle) {
|
||||
return Ok(json_error(StatusCode::BAD_REQUEST, "type your handle to confirm"));
|
||||
}
|
||||
let uid = user.id;
|
||||
app.db.call(move |c| db::delete_user(c, uid)).await?;
|
||||
tracing::info!("account deleted");
|
||||
Ok(signed_out(&app, json!({ "ok": true })))
|
||||
}
|
||||
|
||||
pub async fn logout_all(State(app): St, Extension(current): Extension<CurrentUser>) -> Res {
|
||||
let uid = match current.require() {
|
||||
Ok(u) => u.id,
|
||||
Err(r) => return Ok(r),
|
||||
};
|
||||
app.db.call(move |c| db::delete_user_sessions(c, uid)).await?;
|
||||
Ok(signed_out(&app, json!({ "ok": true })))
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! The feed, likes and listening events.
|
||||
|
||||
use std::{collections::HashMap, time::Duration};
|
||||
|
||||
use axum::{
|
||||
Extension, Json,
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::time::Instant;
|
||||
|
||||
use super::{CurrentUser, Res, St, json_error, no_store};
|
||||
use crate::{db, ranker};
|
||||
|
||||
const EVENT_KINDS: &[&str] = &["play", "like", "unlike", "skip", "complete", "dislike"];
|
||||
/// On a cold start the crawler needs a few seconds before there is anything to serve.
|
||||
const FEED_WAIT: Duration = Duration::from_secs(10);
|
||||
|
||||
pub async fn me(Extension(current): Extension<CurrentUser>) -> Response {
|
||||
match current.user {
|
||||
Some(u) => (no_store(), Json(json!({ "user": { "handle": u.handle, "name": u.name, "img": u.img } }))).into_response(),
|
||||
None => json_error(StatusCode::UNAUTHORIZED, "not signed in"),
|
||||
}
|
||||
}
|
||||
|
||||
fn num(q: &HashMap<String, String>, name: &str) -> Option<f64> {
|
||||
q.get(name).and_then(|v| v.parse::<f64>().ok()).filter(|v| v.is_finite())
|
||||
}
|
||||
|
||||
pub async fn feed(State(app): St, Extension(current): Extension<CurrentUser>, Query(q): Query<HashMap<String, String>>) -> Res {
|
||||
let user = match current.require() {
|
||||
Ok(u) => u.clone(),
|
||||
Err(r) => return Ok(r),
|
||||
};
|
||||
let Some(ranker) = ranker::by_name(q.get("mode").map(String::as_str)) else {
|
||||
return Ok(json_error(StatusCode::BAD_REQUEST, "unknown mode"));
|
||||
};
|
||||
let n = num(&q, "n").map(|n| n as usize).unwrap_or(10).clamp(1, 30);
|
||||
let filters = ranker::Filters {
|
||||
free_only: q.get("free").map(String::as_str) == Some("1"),
|
||||
bpm_min: num(&q, "bpm_min"),
|
||||
bpm_max: num(&q, "bpm_max"),
|
||||
};
|
||||
let deadline = Instant::now() + FEED_WAIT;
|
||||
let uid = user.id;
|
||||
let tracks = loop {
|
||||
let tracks = app.db.call(move |c| ranker::pick(c, uid, n, filters, ranker)).await?;
|
||||
if !tracks.is_empty() || Instant::now() >= deadline {
|
||||
break tracks;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(1500)).await;
|
||||
};
|
||||
let ids: Vec<i64> = tracks.iter().filter_map(|t| t["id"].as_i64()).collect();
|
||||
let stats = app
|
||||
.db
|
||||
.call(move |c| {
|
||||
for id in ids {
|
||||
db::add_event(c, uid, id, "served", None)?;
|
||||
}
|
||||
let mut s = db::stats(c, uid)?;
|
||||
s.liked = ranker::taste(c, uid)?.liked_ids.len();
|
||||
Ok(s)
|
||||
})
|
||||
.await?;
|
||||
Ok((no_store(), Json(json!({ "tracks": tracks, "stats": stats }))).into_response())
|
||||
}
|
||||
|
||||
pub async fn likes(State(app): St, Extension(current): Extension<CurrentUser>) -> Res {
|
||||
let uid = match current.require() {
|
||||
Ok(u) => u.id,
|
||||
Err(r) => return Ok(r),
|
||||
};
|
||||
let tracks = app.db.call(move |c| ranker::likes(c, uid)).await?;
|
||||
Ok((no_store(), Json(json!({ "tracks": tracks }))).into_response())
|
||||
}
|
||||
|
||||
pub async fn event(State(app): St, Extension(current): Extension<CurrentUser>, Json(body): Json<Value>) -> Res {
|
||||
let uid = match current.require() {
|
||||
Ok(u) => u.id,
|
||||
Err(r) => return Ok(r),
|
||||
};
|
||||
let track_id = body.get("track_id").and_then(|v| v.as_i64().or_else(|| v.as_str()?.parse().ok()));
|
||||
let kind = body.get("kind").and_then(Value::as_str).filter(|k| EVENT_KINDS.contains(k));
|
||||
let seconds = match body.get("seconds") {
|
||||
None | Some(Value::Null) => Some(None),
|
||||
Some(v) => v.as_f64().filter(|s| s.is_finite() && *s >= 0.0).map(Some),
|
||||
};
|
||||
let (Some(track_id), Some(kind), Some(seconds)) = (track_id, kind, seconds) else {
|
||||
return Ok(StatusCode::BAD_REQUEST.into_response());
|
||||
};
|
||||
if !app.limits.events.allow(&uid.to_string()) {
|
||||
return Ok(StatusCode::TOO_MANY_REQUESTS.into_response());
|
||||
}
|
||||
let kind = kind.to_string();
|
||||
let k = kind.clone();
|
||||
app.db.call(move |c| db::add_event(c, uid, track_id, &k, seconds)).await?;
|
||||
if kind == "like" || kind == "complete" {
|
||||
app.crawler.on_positive(track_id).await;
|
||||
}
|
||||
Ok((no_store(), Json(json!({ "ok": true }))).into_response())
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Sign-in through pl8list: email, emailed 6-digit code, then our own session cookie.
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicU32, Ordering},
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use axum::{
|
||||
Extension, Json,
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{App, CurrentUser, LOGIN_COOKIE, Peer, Res, SESSION_COOKIE, St, client_ip, json_error, no_store, security};
|
||||
use crate::{db, pl8::login::LoginFlow};
|
||||
|
||||
const LOGIN_TTL: Duration = Duration::from_secs(900);
|
||||
const MAX_PENDING: usize = 500;
|
||||
const MAX_RESENDS: u32 = 2;
|
||||
const MAX_CODE_ATTEMPTS: u32 = 5;
|
||||
const TOO_MANY: &str = "too many attempts, wait a few minutes";
|
||||
|
||||
/// A login waiting for its emailed code, keyed by a random id in a short-lived cookie.
|
||||
pub struct Pending {
|
||||
created: Instant,
|
||||
attempts: AtomicU32,
|
||||
resends: AtomicU32,
|
||||
/// Also serialises resend/verify calls of one login.
|
||||
flow: tokio::sync::Mutex<LoginFlow>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct PendingLogins(Mutex<HashMap<String, Arc<Pending>>>);
|
||||
|
||||
impl PendingLogins {
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, Arc<Pending>>> {
|
||||
self.0.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Returns the new flow id, or `None` when too many logins are pending.
|
||||
fn add(&self, flow: LoginFlow) -> Option<(String, Arc<Pending>)> {
|
||||
let mut map = self.lock();
|
||||
map.retain(|_, p| p.created.elapsed() < LOGIN_TTL);
|
||||
if map.len() >= MAX_PENDING {
|
||||
return None;
|
||||
}
|
||||
let id = db::new_token();
|
||||
let p = Arc::new(Pending {
|
||||
created: Instant::now(),
|
||||
attempts: AtomicU32::new(0),
|
||||
resends: AtomicU32::new(0),
|
||||
flow: tokio::sync::Mutex::new(flow),
|
||||
});
|
||||
map.insert(id.clone(), p.clone());
|
||||
Some((id, p))
|
||||
}
|
||||
|
||||
fn get(&self, id: Option<&str>) -> Option<Arc<Pending>> {
|
||||
let p = self.lock().get(id?).cloned()?;
|
||||
(p.created.elapsed() < LOGIN_TTL).then_some(p)
|
||||
}
|
||||
|
||||
fn drop(&self, id: &str) {
|
||||
self.lock().remove(id);
|
||||
}
|
||||
|
||||
pub fn prune(&self) {
|
||||
self.lock().retain(|_, p| p.created.elapsed() < LOGIN_TTL);
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_email(e: &str) -> bool {
|
||||
let Some((local, domain)) = e.split_once('@') else { return false };
|
||||
e.len() <= 254
|
||||
&& (1..=64).contains(&local.len())
|
||||
&& !domain.contains('@')
|
||||
&& domain.split('.').count() >= 2
|
||||
&& domain.split('.').all(|p| !p.is_empty())
|
||||
&& !e.chars().any(|c| c.is_whitespace() || c.is_control())
|
||||
}
|
||||
|
||||
fn str_field(body: &Value, key: &str) -> String {
|
||||
body.get(key).and_then(Value::as_str).unwrap_or_default().trim().to_string()
|
||||
}
|
||||
|
||||
fn allow_login_mail(app: &App, ip: &str, email: &str) -> bool {
|
||||
app.limits.login_ip.allow(ip) && app.limits.login_email.allow(email) && app.limits.login_global.allow("")
|
||||
}
|
||||
|
||||
pub async fn start(State(app): St, peer: Peer, headers: HeaderMap, Json(body): Json<Value>) -> Res {
|
||||
let email = str_field(&body, "email").to_lowercase();
|
||||
if !valid_email(&email) {
|
||||
return Ok(json_error(StatusCode::BAD_REQUEST, "enter a valid email"));
|
||||
}
|
||||
if !allow_login_mail(&app, &client_ip(&app, &headers, peer.0), &email) {
|
||||
return Ok(json_error(StatusCode::TOO_MANY_REQUESTS, TOO_MANY));
|
||||
}
|
||||
let Some((id, pending)) = app.logins.add(LoginFlow::new(email.clone())) else {
|
||||
return Ok(json_error(StatusCode::TOO_MANY_REQUESTS, "too many sign-ins right now, try again in a few minutes"));
|
||||
};
|
||||
if let Err(e) = pending.flow.lock().await.start(&app.pl8).await {
|
||||
app.logins.drop(&id);
|
||||
return Ok(json_error(StatusCode::BAD_REQUEST, &e.0));
|
||||
}
|
||||
let cookie = app.cookies.set(LOGIN_COOKIE, &id, LOGIN_TTL.as_secs() as i64);
|
||||
Ok(([(header::SET_COOKIE, cookie)], no_store(), Json(json!({ "ok": true, "email": email }))).into_response())
|
||||
}
|
||||
|
||||
fn login_cookie<'a>(app: &App, headers: &'a HeaderMap) -> Option<&'a str> {
|
||||
security::cookie(headers, &app.cookies.name(LOGIN_COOKIE))
|
||||
}
|
||||
|
||||
fn expired() -> Response {
|
||||
json_error(StatusCode::BAD_REQUEST, "login expired, start again")
|
||||
}
|
||||
|
||||
pub async fn resend(State(app): St, peer: Peer, headers: HeaderMap) -> Res {
|
||||
let Some(pending) = app.logins.get(login_cookie(&app, &headers)) else { return Ok(expired()) };
|
||||
if pending.resends.fetch_add(1, Ordering::SeqCst) >= MAX_RESENDS {
|
||||
return Ok(json_error(StatusCode::TOO_MANY_REQUESTS, "no more resends, start again"));
|
||||
}
|
||||
let mut flow = pending.flow.lock().await;
|
||||
if !allow_login_mail(&app, &client_ip(&app, &headers, peer.0), &flow.email) {
|
||||
return Ok(json_error(StatusCode::TOO_MANY_REQUESTS, TOO_MANY));
|
||||
}
|
||||
if let Err(e) = flow.resend(&app.pl8).await {
|
||||
return Ok(json_error(StatusCode::BAD_REQUEST, &e.0));
|
||||
}
|
||||
Ok((no_store(), Json(json!({ "ok": true }))).into_response())
|
||||
}
|
||||
|
||||
pub async fn verify(State(app): St, headers: HeaderMap, Json(body): Json<Value>) -> Res {
|
||||
let id = login_cookie(&app, &headers).map(String::from);
|
||||
let Some(pending) = app.logins.get(id.as_deref()) else { return Ok(expired()) };
|
||||
let id = id.unwrap_or_default();
|
||||
let code = str_field(&body, "code");
|
||||
if code.len() != 6 || !code.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return Ok(json_error(StatusCode::BAD_REQUEST, "the code has 6 digits"));
|
||||
}
|
||||
if pending.attempts.fetch_add(1, Ordering::SeqCst) >= MAX_CODE_ATTEMPTS {
|
||||
app.logins.drop(&id);
|
||||
return Ok(json_error(StatusCode::TOO_MANY_REQUESTS, "too many wrong codes, start again"));
|
||||
}
|
||||
let result = pending.flow.lock().await.verify(&app.pl8, &code).await;
|
||||
let (user, pl8_session) = match result {
|
||||
Ok(ok) => ok,
|
||||
Err(e) => return Ok(json_error(StatusCode::BAD_REQUEST, &e.0)),
|
||||
};
|
||||
app.logins.drop(&id);
|
||||
// The pl8list session goes straight to the feed crawl, which logs it out afterwards.
|
||||
app.crawler.crawl_feed(pl8_session);
|
||||
let u = user.clone();
|
||||
let token = app
|
||||
.db
|
||||
.call(move |c| {
|
||||
db::upsert_user(c, u.id, &u.handle, u.name.as_deref(), u.img.as_deref())?;
|
||||
db::create_session(c, u.id)
|
||||
})
|
||||
.await?;
|
||||
tracing::info!("signed in: {}", user.handle);
|
||||
let mut res = (no_store(), Json(json!({ "user": { "handle": user.handle, "name": user.name } }))).into_response();
|
||||
let h = res.headers_mut();
|
||||
h.append(header::SET_COOKIE, app.cookies.set(SESSION_COOKIE, &token, (db::SESSION_IDLE_DAYS * 86400.0) as i64));
|
||||
h.append(header::SET_COOKIE, app.cookies.clear(LOGIN_COOKIE));
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
pub async fn logout(State(app): St, Extension(current): Extension<CurrentUser>) -> Res {
|
||||
if let Some(token) = current.token {
|
||||
app.db.call(move |c| db::delete_session(c, &token)).await?;
|
||||
}
|
||||
Ok(signed_out(&app, json!({ "ok": true })))
|
||||
}
|
||||
|
||||
/// Response that removes the session cookie (and the Python version's one).
|
||||
pub fn signed_out(app: &App, body: Value) -> Response {
|
||||
let mut res = (no_store(), Json(body)).into_response();
|
||||
res.headers_mut().append(header::SET_COOKIE, app.cookies.clear(SESSION_COOKIE));
|
||||
if app.cookies.secure {
|
||||
res.headers_mut().append(header::SET_COOKIE, app.cookies.clear_legacy(SESSION_COOKIE));
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn email_validation() {
|
||||
assert!(valid_email("a@b.co"));
|
||||
assert!(!valid_email("a@b"));
|
||||
assert!(!valid_email("a b@c.de"));
|
||||
assert!(!valid_email("@c.de"));
|
||||
assert!(!valid_email("a@@c.de"));
|
||||
assert!(!valid_email("a@c..de"));
|
||||
assert!(!valid_email(&format!("{}@c.de", "x".repeat(65))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Impressum (§ 5 DDG, § 18 MStV) and Datenschutzerklärung (Art. 13 GDPR).
|
||||
//!
|
||||
//! The texts live in `templates/`; operator details come from the environment so
|
||||
//! they are not committed. Pages are rendered once at startup.
|
||||
|
||||
use axum::{
|
||||
http::header,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
|
||||
use crate::config::Legal;
|
||||
|
||||
const IMPRESSUM: &str = include_str!("../../templates/impressum.html");
|
||||
const DATENSCHUTZ: &str = include_str!("../../templates/datenschutz.html");
|
||||
const UPDATED: &str = "1. November 2026";
|
||||
|
||||
pub struct Pages {
|
||||
impressum: String,
|
||||
datenschutz: String,
|
||||
}
|
||||
|
||||
fn escape(s: &str) -> String {
|
||||
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """).replace('\'', "'")
|
||||
}
|
||||
|
||||
fn lines(s: &str) -> String {
|
||||
s.lines().map(|l| escape(l.trim())).filter(|l| !l.is_empty()).collect::<Vec<_>>().join("<br>\n")
|
||||
}
|
||||
|
||||
fn fill(template: &str, l: &Legal) -> String {
|
||||
let missing = "<strong>[nicht konfiguriert]</strong>";
|
||||
let or_missing = |v: String| if v.is_empty() { missing.to_string() } else { v };
|
||||
let email = if l.email.is_empty() {
|
||||
missing.to_string()
|
||||
} else {
|
||||
format!("<a href=\"mailto:{0}\">{0}</a>", escape(&l.email))
|
||||
};
|
||||
let phone = if l.phone.is_empty() { String::new() } else { format!("<br>\nTelefon: {}", escape(&l.phone)) };
|
||||
template
|
||||
.replace("{{name}}", &or_missing(escape(&l.name)))
|
||||
.replace("{{address}}", &or_missing(lines(&l.address)))
|
||||
.replace("{{email}}", &email)
|
||||
.replace("{{phone}}", &phone)
|
||||
.replace("{{hosting}}", &or_missing(lines(&l.hosting)))
|
||||
.replace("{{updated}}", UPDATED)
|
||||
}
|
||||
|
||||
impl Pages {
|
||||
pub fn render(l: &Legal) -> Self {
|
||||
if !l.is_complete() {
|
||||
tracing::warn!("IMPRESSUM_NAME, IMPRESSUM_ADDRESS and IMPRESSUM_EMAIL are not all set; the Impressum is incomplete");
|
||||
}
|
||||
if l.hosting.is_empty() {
|
||||
tracing::warn!("HOSTING_PROVIDER is not set; the privacy policy names no hosting provider");
|
||||
}
|
||||
Pages { impressum: fill(IMPRESSUM, l), datenschutz: fill(DATENSCHUTZ, l) }
|
||||
}
|
||||
}
|
||||
|
||||
fn html(body: String) -> Response {
|
||||
([(header::CONTENT_TYPE, "text/html; charset=utf-8"), (header::CACHE_CONTROL, "no-cache")], body).into_response()
|
||||
}
|
||||
|
||||
pub async fn impressum(axum::extract::State(app): super::St) -> Response {
|
||||
html(app.legal.impressum.clone())
|
||||
}
|
||||
|
||||
pub async fn datenschutz(axum::extract::State(app): super::St) -> Response {
|
||||
html(app.legal.datenschutz.clone())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn operator_details_are_escaped() {
|
||||
let l = Legal { name: "A <b>".into(), address: "Str. 1\n12345 Ort".into(), email: "a@b.de".into(), ..Default::default() };
|
||||
let p = Pages::render(&l);
|
||||
assert!(p.impressum.contains("A <b>"));
|
||||
assert!(p.impressum.contains("Str. 1<br>\n12345 Ort"));
|
||||
assert!(!p.impressum.contains("{{"));
|
||||
assert!(!p.datenschutz.contains("{{"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
//! 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 crate::{
|
||||
cache::Cache,
|
||||
config::Config,
|
||||
crawler::Crawler,
|
||||
db::{self, Db, User},
|
||||
pl8::Pl8,
|
||||
ratelimit::RateLimiter,
|
||||
};
|
||||
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,
|
||||
pub proxy: RateLimiter,
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
pub cfg: Config,
|
||||
pub db: Db,
|
||||
pub pl8: Arc<Pl8>,
|
||||
pub crawler: Arc<Crawler>,
|
||||
pub cache: Cache,
|
||||
pub logins: auth::PendingLogins,
|
||||
pub limits: Limits,
|
||||
pub cookies: Cookies,
|
||||
pub segments: proxy::SegmentKeys,
|
||||
pub legal: legal::Pages,
|
||||
}
|
||||
|
||||
pub type St = State<Arc<App>>;
|
||||
|
||||
impl App {
|
||||
pub fn new(cfg: Config, db: Db, pl8: Arc<Pl8>, crawler: Arc<Crawler>) -> Arc<Self> {
|
||||
let cache = Cache::open(cfg.cache_dir.clone(), cfg.cache_max_bytes);
|
||||
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),
|
||||
proxy: RateLimiter::new(1200, 60),
|
||||
},
|
||||
logins: auth::PendingLogins::default(),
|
||||
segments: proxy::SegmentKeys::default(),
|
||||
cache,
|
||||
legal,
|
||||
cfg,
|
||||
db,
|
||||
pl8,
|
||||
crawler,
|
||||
})
|
||||
}
|
||||
|
||||
/// Hourly housekeeping.
|
||||
pub async fn purge(&self) {
|
||||
match self.db.call(|c| db::purge(c)).await {
|
||||
Ok((s, e)) if s + e > 0 => tracing::info!("purged {s} expired sessions, {e} old served events"),
|
||||
Ok(_) => {}
|
||||
Err(e) => tracing::warn!("purge failed: {e:#}"),
|
||||
}
|
||||
self.logins.prune();
|
||||
self.segments.prune();
|
||||
}
|
||||
}
|
||||
|
||||
// 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>,
|
||||
}
|
||||
|
||||
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() };
|
||||
if let Some(t) = token {
|
||||
let lookup = t.clone();
|
||||
if let Ok(Some(hit)) = app.db.call(move |c| db::session_user(c, &lookup)).await {
|
||||
let mut new_token = None;
|
||||
if hit.created_at < db::now() - ROTATE_AFTER {
|
||||
let (old, uid) = (t.clone(), hit.user.id);
|
||||
new_token = app.db.call(move |c| db::rotate_session(c, &old, uid)).await.ok();
|
||||
} 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 ------------------------------------------------------------
|
||||
|
||||
#[derive(Embed)]
|
||||
#[folder = "../static/"]
|
||||
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 {
|
||||
match Static::get(path) {
|
||||
Some(f) => ([(header::CONTENT_TYPE, content_type(path)), (header::CACHE_CONTROL, "no-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)
|
||||
}
|
||||
|
||||
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;
|
||||
Router::new()
|
||||
.route("/", get(index))
|
||||
.route("/static/{*path}", get(static_route))
|
||||
.route("/healthz", get(healthz))
|
||||
.route("/impressum", get(legal::impressum))
|
||||
.route("/datenschutz", get(legal::datenschutz))
|
||||
.route("/privacy", get(legal::datenschutz))
|
||||
.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("/local/me", get(app::me))
|
||||
.route("/local/feed", get(app::feed))
|
||||
.route("/local/likes", get(app::likes))
|
||||
.route("/local/event", post(app::event))
|
||||
.route("/account/export", get(account::export))
|
||||
.route("/account/delete", post(account::delete))
|
||||
.route("/account/logout-all", post(account::logout_all))
|
||||
.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))
|
||||
.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)
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
//! Audio and image proxy.
|
||||
//!
|
||||
//! The browser only talks to us, so visitors' addresses never reach pl8list. Only
|
||||
//! HLS playlists, their segments and artist images are proxied, each with a fixed
|
||||
//! or allowlisted content type, so nothing pl8list returns can run as script on
|
||||
//! our origin. Previews and free tracks stream fine as a guest.
|
||||
//!
|
||||
//! Segments and images are cached on disk. A segment URL carries a signed token
|
||||
//! (`st`) that changes with every playlist, so the cache key is the upload id inside
|
||||
//! that token. We cannot check pl8list's signature, so we only trust tokens that
|
||||
//! came out of a playlist we fetched ourselves.
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::Mutex,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use axum::{
|
||||
Extension,
|
||||
extract::{Path, Query, State},
|
||||
http::{StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use bytes::{Bytes, BytesMut};
|
||||
use futures_util::StreamExt;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{App, CurrentUser, Res, St};
|
||||
use crate::cache::{self, IMAGE_TYPES, SEGMENT_TYPES};
|
||||
|
||||
const MAX_PLAYLIST: usize = 256 << 10;
|
||||
const MAX_SEGMENT: usize = 16 << 20;
|
||||
const MAX_IMAGE: usize = 5 << 20;
|
||||
const TOKEN_TTL: Duration = Duration::from_secs(6 * 3600);
|
||||
const MAX_TOKENS: usize = 50_000;
|
||||
|
||||
/// Segment tokens seen in playlists from pl8list, with the upload they belong to.
|
||||
#[derive(Default)]
|
||||
pub struct SegmentKeys(Mutex<HashMap<String, (String, Instant)>>);
|
||||
|
||||
impl SegmentKeys {
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, (String, Instant)>> {
|
||||
self.0.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn insert(&self, token: String, upload: String) {
|
||||
let mut m = self.lock();
|
||||
if m.len() >= MAX_TOKENS {
|
||||
m.retain(|_, (_, t)| t.elapsed() < TOKEN_TTL);
|
||||
if m.len() >= MAX_TOKENS {
|
||||
m.clear();
|
||||
}
|
||||
}
|
||||
m.insert(token, (upload, Instant::now()));
|
||||
}
|
||||
|
||||
fn upload(&self, token: &str) -> Option<String> {
|
||||
self.lock().get(token).filter(|(_, t)| t.elapsed() < TOKEN_TTL).map(|(u, _)| u.clone())
|
||||
}
|
||||
|
||||
pub fn prune(&self) {
|
||||
self.lock().retain(|_, (_, t)| t.elapsed() < TOKEN_TTL);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles, slugs and upload ids on pl8list only use these characters.
|
||||
fn safe_segment(s: &str, max: usize) -> bool {
|
||||
!s.is_empty()
|
||||
&& s.len() <= max
|
||||
&& s != "."
|
||||
&& s != ".."
|
||||
&& s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'.' || b == b'-')
|
||||
}
|
||||
|
||||
/// The upload id pl8list put into a segment token (`base64url(json).signature`).
|
||||
fn token_upload(token: &str) -> Option<String> {
|
||||
let payload = URL_SAFE_NO_PAD.decode(token.split('.').next()?).ok()?;
|
||||
let v: Value = serde_json::from_slice(&payload).ok()?;
|
||||
v.get("u")?.as_str().filter(|u| safe_segment(u, 128)).map(String::from)
|
||||
}
|
||||
|
||||
fn require_user(app: &App, current: &CurrentUser) -> Result<(), Response> {
|
||||
let user = current.require()?;
|
||||
if !app.limits.proxy.allow(&user.id.to_string()) {
|
||||
return Err(StatusCode::TOO_MANY_REQUESTS.into_response());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read an upstream body, giving up past `max` bytes.
|
||||
async fn read_capped(res: reqwest::Response, max: usize) -> Result<Bytes, StatusCode> {
|
||||
if res.content_length().is_some_and(|l| l as usize > max) {
|
||||
return Err(StatusCode::BAD_GATEWAY);
|
||||
}
|
||||
let mut body = BytesMut::new();
|
||||
let mut stream = res.bytes_stream();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|_| StatusCode::BAD_GATEWAY)?;
|
||||
if body.len() + chunk.len() > max {
|
||||
return Err(StatusCode::BAD_GATEWAY);
|
||||
}
|
||||
body.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok(body.freeze())
|
||||
}
|
||||
|
||||
async fn fetch(app: &App, url: url::Url) -> Result<reqwest::Response, StatusCode> {
|
||||
let res = app.pl8.request(url, None).send().await.map_err(|e| {
|
||||
tracing::debug!("proxy: {}", e.without_url());
|
||||
StatusCode::BAD_GATEWAY
|
||||
})?;
|
||||
match res.status().as_u16() {
|
||||
200 => Ok(res),
|
||||
403 => Err(StatusCode::FORBIDDEN),
|
||||
404 => Err(StatusCode::NOT_FOUND),
|
||||
_ => Err(StatusCode::BAD_GATEWAY),
|
||||
}
|
||||
}
|
||||
|
||||
fn media(ctype: &'static str, body: Bytes, cache_control: &'static str) -> Response {
|
||||
([(header::CONTENT_TYPE, ctype), (header::CACHE_CONTROL, cache_control)], body).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PlaylistQuery {
|
||||
v: Option<String>,
|
||||
preview: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn playlist(
|
||||
State(app): St,
|
||||
Extension(current): Extension<CurrentUser>,
|
||||
Path((handle, slug)): Path<(String, String)>,
|
||||
Query(q): Query<PlaylistQuery>,
|
||||
) -> Res {
|
||||
if let Err(r) = require_user(&app, ¤t) {
|
||||
return Ok(r);
|
||||
}
|
||||
if !safe_segment(&handle, 64) || !safe_segment(&slug, 160) || q.v.as_deref().is_some_and(|v| !safe_segment(v, 128)) {
|
||||
return Ok(StatusCode::NOT_FOUND.into_response());
|
||||
}
|
||||
let mut url = app.pl8.url(&["api", &handle, &slug, "hls", "playlist.m3u8"]);
|
||||
{
|
||||
let mut qp = url.query_pairs_mut();
|
||||
if let Some(v) = &q.v {
|
||||
qp.append_pair("v", v);
|
||||
}
|
||||
if q.preview.as_deref() == Some("true") {
|
||||
qp.append_pair("preview", "true");
|
||||
}
|
||||
}
|
||||
let body = match fetch(&app, url).await {
|
||||
Ok(res) => match read_capped(res, MAX_PLAYLIST).await {
|
||||
Ok(b) => b,
|
||||
Err(s) => return Ok(s.into_response()),
|
||||
},
|
||||
Err(s) => return Ok(s.into_response()),
|
||||
};
|
||||
let Ok(text) = std::str::from_utf8(&body) else { return Ok(StatusCode::BAD_GATEWAY.into_response()) };
|
||||
if !text.starts_with("#EXTM3U") {
|
||||
return Ok(StatusCode::BAD_GATEWAY.into_response());
|
||||
}
|
||||
for line in text.lines().filter(|l| l.starts_with("segment/")) {
|
||||
if let Some(token) = line.split_once("st=").map(|(_, t)| t.split('&').next().unwrap_or_default())
|
||||
&& let Some(upload) = token_upload(token) {
|
||||
app.segments.insert(token.to_string(), upload);
|
||||
}
|
||||
}
|
||||
Ok(media("application/vnd.apple.mpegurl", body, "no-store"))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SegmentQuery {
|
||||
st: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn segment(
|
||||
State(app): St,
|
||||
Extension(current): Extension<CurrentUser>,
|
||||
Path((handle, slug, n)): Path<(String, String, String)>,
|
||||
Query(q): Query<SegmentQuery>,
|
||||
) -> Res {
|
||||
if let Err(r) = require_user(&app, ¤t) {
|
||||
return Ok(r);
|
||||
}
|
||||
let token = q.st.unwrap_or_default();
|
||||
let valid_token = token.len() <= 2048 && token.bytes().all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b));
|
||||
let Ok(n) = n.parse::<u32>() else { return Ok(StatusCode::NOT_FOUND.into_response()) };
|
||||
if !safe_segment(&handle, 64) || !safe_segment(&slug, 160) || !valid_token {
|
||||
return Ok(StatusCode::NOT_FOUND.into_response());
|
||||
}
|
||||
let key = app.segments.upload(&token).map(|u| format!("seg:{u}:{n}"));
|
||||
if let Some(k) = &key
|
||||
&& let Some((body, ctype)) = app.cache.get(k).await {
|
||||
return Ok(media(ctype, body, "private, max-age=86400"));
|
||||
}
|
||||
let mut url = app.pl8.url(&["api", &handle, &slug, "hls", "segment", &n.to_string()]);
|
||||
if !token.is_empty() {
|
||||
url.query_pairs_mut().append_pair("st", &token);
|
||||
}
|
||||
let res = match fetch(&app, url).await {
|
||||
Ok(r) => r,
|
||||
Err(s) => return Ok(s.into_response()),
|
||||
};
|
||||
let upstream_type = res.headers().get(header::CONTENT_TYPE).and_then(|v| v.to_str().ok());
|
||||
let Some((ctype, ext)) = cache::allowed_type(upstream_type, SEGMENT_TYPES) else {
|
||||
return Ok(StatusCode::BAD_GATEWAY.into_response());
|
||||
};
|
||||
let body = match read_capped(res, MAX_SEGMENT).await {
|
||||
Ok(b) => b,
|
||||
Err(s) => return Ok(s.into_response()),
|
||||
};
|
||||
if let Some(k) = &key {
|
||||
app.cache.put(k, ext, &body).await;
|
||||
}
|
||||
Ok(media(ctype, body, "private, max-age=86400"))
|
||||
}
|
||||
|
||||
pub async fn image(State(app): St, Extension(current): Extension<CurrentUser>, Path(id): Path<String>) -> Res {
|
||||
if let Err(r) = require_user(&app, ¤t) {
|
||||
return Ok(r);
|
||||
}
|
||||
if id.len() > 64 || id.is_empty() || !id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') {
|
||||
return Ok(StatusCode::NOT_FOUND.into_response());
|
||||
}
|
||||
let key = format!("img:{id}");
|
||||
if let Some((body, ctype)) = app.cache.get(&key).await {
|
||||
return Ok(media(ctype, body, "private, max-age=86400"));
|
||||
}
|
||||
let res = match fetch(&app, app.pl8.url(&["img", &id])).await {
|
||||
Ok(r) => r,
|
||||
Err(s) => return Ok(s.into_response()),
|
||||
};
|
||||
let upstream_type = res.headers().get(header::CONTENT_TYPE).and_then(|v| v.to_str().ok());
|
||||
let Some((ctype, ext)) = cache::allowed_type(upstream_type, IMAGE_TYPES) else {
|
||||
return Ok(StatusCode::BAD_GATEWAY.into_response());
|
||||
};
|
||||
let body = match read_capped(res, MAX_IMAGE).await {
|
||||
Ok(b) => b,
|
||||
Err(s) => return Ok(s.into_response()),
|
||||
};
|
||||
app.cache.put(&key, ext, &body).await;
|
||||
Ok(media(ctype, body, "private, max-age=86400"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn path_segments() {
|
||||
assert!(safe_segment("garp-chorus-flute-vip-final-v5", 160));
|
||||
assert!(!safe_segment("..", 160));
|
||||
assert!(!safe_segment(".", 160));
|
||||
assert!(!safe_segment("a/b", 160));
|
||||
assert!(!safe_segment("a%2Fb", 160));
|
||||
assert!(!safe_segment("", 160));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_upload_from_token() {
|
||||
let t = "eyJ1IjoiMjU1YzhmZGUtYTUyYy00Njc2LTk2MzgtMGExOGZkNTRmN2UyLndhdiIsImgiOiJnYXJwYmUiLCJzIjoiZ2FycC1zY290dGlzY2gtc3F1YXJlLWZpbmFsLXYzLTIiLCJlIjoxNzkzNTMwMTgwfQ.nPGK9LdPDvmHDwhqlCbHbaEtJhLLqCwUIS26MlCvYR8";
|
||||
assert_eq!(token_upload(t).as_deref(), Some("255c8fde-a52c-4676-9638-0a18fd54f7e2.wav"));
|
||||
assert_eq!(token_upload("garbage"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_tokens_get_no_cache_key() {
|
||||
let keys = SegmentKeys::default();
|
||||
keys.insert("known".into(), "u1".into());
|
||||
assert_eq!(keys.upload("known").as_deref(), Some("u1"));
|
||||
assert_eq!(keys.upload("forged"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//! Client address, Origin check, cookies and response headers.
|
||||
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
|
||||
use axum::{
|
||||
extract::Request,
|
||||
http::{HeaderMap, HeaderValue, header},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
|
||||
/// The address rate limits use.
|
||||
///
|
||||
/// Each trusted proxy appends the address it saw to `X-Forwarded-For`, so with
|
||||
/// `hops` proxies the client is the `hops`-th entry from the right. Everything to
|
||||
/// the left of it was written by the client and is ignored.
|
||||
pub fn client_ip(headers: &HeaderMap, peer: SocketAddr, hops: usize) -> IpAddr {
|
||||
if hops == 0 {
|
||||
return peer.ip();
|
||||
}
|
||||
let entries: Vec<&str> = headers
|
||||
.get_all("x-forwarded-for")
|
||||
.iter()
|
||||
.filter_map(|v| v.to_str().ok())
|
||||
.flat_map(|v| v.split(','))
|
||||
.map(str::trim)
|
||||
.collect();
|
||||
entries
|
||||
.len()
|
||||
.checked_sub(hops)
|
||||
.and_then(|i| entries[i].parse().ok())
|
||||
.unwrap_or(peer.ip())
|
||||
}
|
||||
|
||||
/// Rate-limit key for an address. IPv6 users usually get a whole /64, so limit by that.
|
||||
pub fn ip_key(ip: IpAddr) -> String {
|
||||
match ip {
|
||||
IpAddr::V4(v4) => v4.to_string(),
|
||||
IpAddr::V6(v6) => match v6.to_ipv4_mapped() {
|
||||
Some(v4) => v4.to_string(),
|
||||
None => {
|
||||
let s = v6.segments();
|
||||
format!("{:x}:{:x}:{:x}:{:x}::/64", s[0], s[1], s[2], s[3])
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Reject cross-site POSTs. Browsers send Origin on every POST and Sec-Fetch-Site
|
||||
/// on every request; either one pointing elsewhere is enough to refuse.
|
||||
pub fn same_origin(headers: &HeaderMap, public_origin: Option<&str>) -> bool {
|
||||
if let Some(site) = headers.get("sec-fetch-site").and_then(|v| v.to_str().ok())
|
||||
&& site != "same-origin" && site != "none" {
|
||||
return false;
|
||||
}
|
||||
let Some(origin) = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok()) else { return true };
|
||||
match public_origin {
|
||||
Some(expected) => origin == expected,
|
||||
None => {
|
||||
let host = headers.get(header::HOST).and_then(|v| v.to_str().ok()).unwrap_or_default();
|
||||
origin.split_once("://").map(|(_, h)| h) == Some(host)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cookie<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
|
||||
headers
|
||||
.get_all(header::COOKIE)
|
||||
.iter()
|
||||
.filter_map(|v| v.to_str().ok())
|
||||
.flat_map(|v| v.split(';'))
|
||||
.filter_map(|c| c.trim().split_once('='))
|
||||
.find(|(k, _)| *k == name)
|
||||
.map(|(_, v)| v)
|
||||
.filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
pub struct Cookies {
|
||||
pub secure: bool,
|
||||
}
|
||||
|
||||
impl Cookies {
|
||||
/// `__Host-` makes browsers refuse the cookie unless it is Secure, host-only and Path=/.
|
||||
pub fn name(&self, base: &str) -> String {
|
||||
if self.secure { format!("__Host-{base}") } else { base.to_string() }
|
||||
}
|
||||
|
||||
pub fn set(&self, base: &str, value: &str, max_age: i64) -> HeaderValue {
|
||||
let secure = if self.secure { "; Secure" } else { "" };
|
||||
let v = format!("{}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}{secure}", self.name(base));
|
||||
HeaderValue::from_str(&v).expect("cookie values are URL-safe base64")
|
||||
}
|
||||
|
||||
pub fn clear(&self, base: &str) -> HeaderValue {
|
||||
self.set(base, "", 0)
|
||||
}
|
||||
|
||||
/// Remove a cookie that was set without the prefix by the Python version.
|
||||
pub fn clear_legacy(&self, name: &str) -> HeaderValue {
|
||||
HeaderValue::from_str(&format!("{name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0")).expect("static")
|
||||
}
|
||||
}
|
||||
|
||||
const CSP: &str = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; \
|
||||
media-src 'self' blob:; worker-src 'self' blob:; connect-src 'self'; object-src 'none'; \
|
||||
base-uri 'none'; form-action 'self'; frame-ancestors 'none'";
|
||||
|
||||
const PERMISSIONS: &str = "camera=(), microphone=(), geolocation=(), payment=(), usb=(), \
|
||||
browsing-topics=(), interest-cohort=()";
|
||||
|
||||
/// Headers on every response.
|
||||
pub async fn headers(secure: bool, req: Request, next: Next) -> Response {
|
||||
let mut res = next.run(req).await;
|
||||
let h = res.headers_mut();
|
||||
let set = |h: &mut HeaderMap, k: &'static str, v: &'static str| {
|
||||
h.entry(k).or_insert(HeaderValue::from_static(v));
|
||||
};
|
||||
set(h, "content-security-policy", CSP);
|
||||
set(h, "x-content-type-options", "nosniff");
|
||||
set(h, "referrer-policy", "strict-origin-when-cross-origin");
|
||||
set(h, "x-frame-options", "DENY");
|
||||
set(h, "permissions-policy", PERMISSIONS);
|
||||
set(h, "cross-origin-opener-policy", "same-origin");
|
||||
set(h, "cross-origin-resource-policy", "same-origin");
|
||||
if secure {
|
||||
set(h, "strict-transport-security", "max-age=31536000; includeSubDomains");
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn h(pairs: &[(&'static str, &'static str)]) -> HeaderMap {
|
||||
let mut m = HeaderMap::new();
|
||||
for (k, v) in pairs {
|
||||
m.append(*k, HeaderValue::from_static(v));
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xff_uses_entry_added_by_proxy() {
|
||||
let peer: SocketAddr = "172.18.0.2:5000".parse().unwrap();
|
||||
let spoofed = h(&[("x-forwarded-for", "6.6.6.6, 203.0.113.9")]);
|
||||
assert_eq!(client_ip(&spoofed, peer, 1).to_string(), "203.0.113.9");
|
||||
assert_eq!(client_ip(&spoofed, peer, 2).to_string(), "6.6.6.6");
|
||||
assert_eq!(client_ip(&spoofed, peer, 0), peer.ip());
|
||||
// Fewer entries than proxies, or garbage: fall back to the peer.
|
||||
assert_eq!(client_ip(&spoofed, peer, 3), peer.ip());
|
||||
assert_eq!(client_ip(&h(&[("x-forwarded-for", "nonsense")]), peer, 1), peer.ip());
|
||||
assert_eq!(client_ip(&HeaderMap::new(), peer, 1), peer.ip());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv6_is_grouped_by_64() {
|
||||
let a: IpAddr = "2001:db8:1:2:aaaa::1".parse().unwrap();
|
||||
let b: IpAddr = "2001:db8:1:2:bbbb::9".parse().unwrap();
|
||||
assert_eq!(ip_key(a), ip_key(b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn origin_check() {
|
||||
assert!(same_origin(&h(&[("host", "pl8.narl.io"), ("origin", "https://pl8.narl.io")]), None));
|
||||
assert!(!same_origin(&h(&[("host", "pl8.narl.io"), ("origin", "https://evil.example")]), None));
|
||||
assert!(!same_origin(&h(&[("sec-fetch-site", "cross-site")]), None));
|
||||
assert!(same_origin(&h(&[("origin", "https://pl8.narl.io")]), Some("https://pl8.narl.io")));
|
||||
assert!(!same_origin(&h(&[("origin", "http://pl8.narl.io")]), Some("https://pl8.narl.io")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cookie_attributes() {
|
||||
let c = Cookies { secure: true };
|
||||
let v = c.set("pl8inf", "tok", 60);
|
||||
assert_eq!(v.to_str().unwrap(), "__Host-pl8inf=tok; Path=/; HttpOnly; SameSite=Lax; Max-Age=60; Secure");
|
||||
let jar = h(&[("cookie", "a=1; __Host-pl8inf=tok; b=")]);
|
||||
assert_eq!(cookie(&jar, "__Host-pl8inf"), Some("tok"));
|
||||
assert_eq!(cookie(&jar, "b"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
//! End-to-end tests of the HTTP API against a fake pl8list.
|
||||
|
||||
use std::{net::SocketAddr, path::PathBuf, sync::Arc, time::Duration};
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
body::Body,
|
||||
extract::ConnectInfo,
|
||||
http::{Request, StatusCode, header},
|
||||
};
|
||||
use base64::Engine;
|
||||
use tower::ServiceExt;
|
||||
use wiremock::{
|
||||
Mock, MockServer, ResponseTemplate,
|
||||
matchers::{body_string_contains, header as has_header, method, path, query_param},
|
||||
};
|
||||
|
||||
use super::{App, router};
|
||||
use crate::{config::{Config, Legal}, crawler::Crawler, db::Db, pl8::Pl8};
|
||||
|
||||
const LOGGED_IN: &str = "LOGGED-IN-PL8-SESSION-SECRET";
|
||||
|
||||
fn db_path(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("pl8inf-test-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir.join("test.db")
|
||||
}
|
||||
|
||||
async fn setup(name: &str, pl8_base: &str) -> (Router, Arc<App>, PathBuf) {
|
||||
let db_path = db_path(name);
|
||||
let cfg = Config {
|
||||
host: "127.0.0.1".into(),
|
||||
port: 0,
|
||||
db_path: db_path.clone(),
|
||||
proxy_hops: 1,
|
||||
public_origin: Some("https://pl8.example".into()),
|
||||
secure: true,
|
||||
cache_dir: db_path.parent().unwrap().join("cache"),
|
||||
cache_max_bytes: 1 << 20,
|
||||
pl8_base: pl8_base.into(),
|
||||
legal: Legal::default(),
|
||||
};
|
||||
let db = Db::open(&db_path).unwrap();
|
||||
let pl8 = Arc::new(Pl8::new(pl8_base, Duration::ZERO).unwrap());
|
||||
let crawler = Crawler::new(pl8.clone(), db.clone());
|
||||
let app = App::new(cfg, db, pl8, crawler);
|
||||
(router(app.clone()), app, db_path)
|
||||
}
|
||||
|
||||
fn req(method: &str, uri: &str, body: Option<&str>, cookie: Option<&str>, xff: &str) -> Request<Body> {
|
||||
let mut b = Request::builder()
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.header(header::HOST, "pl8.example")
|
||||
.header("x-forwarded-for", xff);
|
||||
if body.is_some() {
|
||||
b = b.header(header::CONTENT_TYPE, "application/json").header(header::ORIGIN, "https://pl8.example");
|
||||
}
|
||||
if let Some(c) = cookie {
|
||||
b = b.header(header::COOKIE, c);
|
||||
}
|
||||
let mut r = b.body(Body::from(body.unwrap_or_default().to_string())).unwrap();
|
||||
r.extensions_mut().insert(ConnectInfo(SocketAddr::from(([172, 18, 0, 2], 4000))));
|
||||
r
|
||||
}
|
||||
|
||||
fn set_cookies(res: &axum::response::Response) -> Vec<String> {
|
||||
res.headers().get_all(header::SET_COOKIE).iter().map(|v| v.to_str().unwrap().to_string()).collect()
|
||||
}
|
||||
|
||||
fn cookie_pair(cookies: &[String], name: &str) -> String {
|
||||
cookies.iter().find(|c| c.starts_with(&format!("{name}="))).unwrap().split(';').next().unwrap().to_string()
|
||||
}
|
||||
|
||||
async fn fake_pl8list(logouts: u64) -> MockServer {
|
||||
let server = MockServer::start().await;
|
||||
let guest = base64::engine::general_purpose::STANDARD.encode(r#"{"pl8list_guest_id":"g-1"}"#);
|
||||
Mock::given(method("GET")).and(path("/login"))
|
||||
.respond_with(ResponseTemplate::new(200).insert_header("set-cookie", format!("_session={guest}.sig; Path=/; HttpOnly").as_str()))
|
||||
.mount(&server).await;
|
||||
Mock::given(method("POST")).and(path("/login")).and(body_string_contains("pl8list_guest_id=g-1"))
|
||||
.respond_with(ResponseTemplate::new(204).insert_header("x-remix-redirect", "/verify?email=a%40b.de&mode=login"))
|
||||
.mount(&server).await;
|
||||
Mock::given(method("POST")).and(path("/verify")).and(body_string_contains("code=000000"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"error":"wrong code"}"#))
|
||||
.mount(&server).await;
|
||||
Mock::given(method("POST")).and(path("/verify")).and(body_string_contains("code=123456"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string("{}")
|
||||
.insert_header("set-cookie", format!("_session={LOGGED_IN}; Path=/; HttpOnly").as_str()))
|
||||
.mount(&server).await;
|
||||
Mock::given(method("GET")).and(path("/")).and(query_param("_data", "root"))
|
||||
.and(has_header("cookie", format!("_session={LOGGED_IN}").as_str()))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"currentUser":{"id":42,"handle":"tester","name":"T"}}"#))
|
||||
.mount(&server).await;
|
||||
Mock::given(method("GET")).and(path("/api/feed"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"tracks":[],"totalPages":1}"#))
|
||||
.mount(&server).await;
|
||||
Mock::given(method("GET")).and(path("/logout"))
|
||||
.and(has_header("cookie", format!("_session={LOGGED_IN}").as_str()))
|
||||
.respond_with(ResponseTemplate::new(204))
|
||||
.expect(logouts)
|
||||
.named("logout of the user's pl8list session")
|
||||
.mount(&server).await;
|
||||
server
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_logs_pl8list_session_out_and_never_stores_it() {
|
||||
let pl8 = fake_pl8list(1).await;
|
||||
let (r, _app, db_path) = setup("login", &pl8.uri()).await;
|
||||
|
||||
let res = r.clone().oneshot(req("POST", "/auth/start", Some(r#"{"email":"A@b.de"}"#), None, "203.0.113.1")).await.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let login = cookie_pair(&set_cookies(&res), "__Host-pl8inf_login");
|
||||
|
||||
let res = r.clone().oneshot(req("POST", "/auth/verify", Some(r#"{"code":"000000"}"#), Some(&login), "203.0.113.1")).await.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
let res = r.clone().oneshot(req("POST", "/auth/verify", Some(r#"{"code":"123456"}"#), Some(&login), "203.0.113.1")).await.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let cookies = set_cookies(&res);
|
||||
assert!(cookies.iter().any(|c| c.starts_with("__Host-pl8inf=") && c.contains("Secure") && c.contains("HttpOnly")));
|
||||
let session = cookie_pair(&cookies, "__Host-pl8inf");
|
||||
|
||||
let res = r.clone().oneshot(req("GET", "/local/me", None, Some(&session), "203.0.113.1")).await.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
|
||||
// The feed crawl logs the pl8list session out in the background.
|
||||
for _ in 0..50 {
|
||||
let logged_out = pl8.received_requests().await.unwrap().iter().any(|q| q.url.path() == "/logout");
|
||||
if logged_out {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
pl8.verify().await;
|
||||
|
||||
let raw = std::fs::read(&db_path).unwrap();
|
||||
let wal = std::fs::read(db_path.with_extension("db-wal")).unwrap_or_default();
|
||||
let needle = LOGGED_IN.as_bytes();
|
||||
assert!(!raw.windows(needle.len()).any(|w| w == needle), "pl8list session found in database file");
|
||||
assert!(!wal.windows(needle.len()).any(|w| w == needle), "pl8list session found in WAL");
|
||||
assert!(!raw.windows(5).any(|w| w == b"a@b.d") && !wal.windows(5).any(|w| w == b"a@b.d"), "email stored");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spoofed_forwarded_for_does_not_reset_ip_limit() {
|
||||
let pl8 = fake_pl8list(0).await;
|
||||
let (r, _app, _) = setup("xff", &pl8.uri()).await;
|
||||
let mut statuses = vec![];
|
||||
for i in 0..7 {
|
||||
let body = format!(r#"{{"email":"u{i}@b.de"}}"#);
|
||||
let xff = format!("6.6.6.{i}, 198.51.100.7");
|
||||
statuses.push(r.clone().oneshot(req("POST", "/auth/start", Some(&body), None, &xff)).await.unwrap().status());
|
||||
}
|
||||
assert_eq!(&statuses[..5], &[StatusCode::OK; 5]);
|
||||
assert_eq!(&statuses[5..], &[StatusCode::TOO_MANY_REQUESTS; 2]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cross_site_posts_and_anonymous_reads_are_refused() {
|
||||
let (r, _app, _) = setup("csrf", "http://127.0.0.1:9").await;
|
||||
let mut cross = req("POST", "/auth/logout", Some("{}"), None, "203.0.113.1");
|
||||
cross.headers_mut().insert(header::ORIGIN, "https://evil.example".parse().unwrap());
|
||||
assert_eq!(r.clone().oneshot(cross).await.unwrap().status(), StatusCode::FORBIDDEN);
|
||||
|
||||
for uri in ["/local/feed", "/local/likes", "/account/export", "/img/abc", "/api/a/b/hls/playlist.m3u8"] {
|
||||
let res = r.clone().oneshot(req("GET", uri, None, None, "203.0.113.1")).await.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "{uri}");
|
||||
}
|
||||
let res = r.clone().oneshot(req("GET", "/", None, None, "203.0.113.1")).await.unwrap();
|
||||
let h = res.headers();
|
||||
assert!(h["content-security-policy"].to_str().unwrap().contains("script-src 'self';"));
|
||||
assert!(h.contains_key("strict-transport-security"));
|
||||
assert_eq!(h["cross-origin-opener-policy"], "same-origin");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_account_needs_handle_and_removes_data() {
|
||||
let (r, app, _) = setup("delete", "http://127.0.0.1:9").await;
|
||||
let token = app
|
||||
.db
|
||||
.call(|c| {
|
||||
crate::db::upsert_user(c, 5, "tester", None, None)?;
|
||||
crate::db::add_event(c, 5, 1, "like", None)?;
|
||||
crate::db::create_session(c, 5)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let cookie = format!("__Host-pl8inf={token}");
|
||||
let res = r.clone().oneshot(req("POST", "/account/delete", Some(r#"{"confirm":"nope"}"#), Some(&cookie), "203.0.113.1")).await.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
|
||||
let res = r.clone().oneshot(req("GET", "/account/export", None, Some(&cookie), "203.0.113.1")).await.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let res = r.clone().oneshot(req("POST", "/account/delete", Some(r#"{"confirm":"tester"}"#), Some(&cookie), "203.0.113.1")).await.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let left = app
|
||||
.db
|
||||
.call(|c| c.query_row("SELECT (SELECT COUNT(*) FROM users) + (SELECT COUNT(*) FROM sessions) + (SELECT COUNT(*) FROM events)", [], |r| r.get::<_, i64>(0)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(left, 0);
|
||||
let res = r.clone().oneshot(req("GET", "/local/me", None, Some(&cookie), "203.0.113.1")).await.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_cookie_moves_to_host_prefix() {
|
||||
let (r, app, _) = setup("legacy", "http://127.0.0.1:9").await;
|
||||
let token = app
|
||||
.db
|
||||
.call(|c| {
|
||||
crate::db::upsert_user(c, 5, "tester", None, None)?;
|
||||
crate::db::create_session(c, 5)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let res = r.oneshot(req("GET", "/local/me", None, Some(&format!("pl8inf={token}")), "203.0.113.1")).await.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let cookies = set_cookies(&res);
|
||||
assert!(cookies.iter().any(|c| c.starts_with(&format!("__Host-pl8inf={token};"))));
|
||||
assert!(cookies.iter().any(|c| c.starts_with("pl8inf=;") && c.contains("Max-Age=0")));
|
||||
}
|
||||
Reference in new issue
Block a user