From 300674b8ac04a6af41848fed08b3610a38794ef3 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Mon, 2 Nov 2026 13:20:22 +0100 Subject: [PATCH] Listen without a pl8list account "start listening" on the sign-in page makes a guest: a user with a negative id and a session of its own, so the feed, likes, follows and radio work as for anyone. Signing in with pl8list later moves the guest's plays, likes and follows to the account. Signing out as a guest deletes it; guests whose session ran out are purged hourly. New guests are capped at 10 per IP and hour, and that limiter forgets IPs hourly. The privacy policy covers guests. --- backend/src/db.rs | 46 ++++++++++++++++++-- backend/src/http/app.rs | 2 +- backend/src/http/auth.rs | 32 +++++++++++++- backend/src/http/mod.rs | 9 +++- backend/src/http/tests.rs | 70 ++++++++++++++++++++++++++++++ backend/src/ratelimit.rs | 7 +++ backend/templates/datenschutz.html | 25 ++++++++--- web/index.html | 14 +++--- web/src/api.ts | 2 + web/src/main.ts | 46 ++++++++++++++++---- web/src/style.css | 5 +++ 11 files changed, 232 insertions(+), 26 deletions(-) diff --git a/backend/src/db.rs b/backend/src/db.rs index 09fb331..3185263 100644 --- a/backend/src/db.rs +++ b/backend/src/db.rs @@ -524,6 +524,12 @@ pub struct User { } impl User { + /// Listening without a pl8list account. Guests get negative ids, so they never + /// meet a pl8list user id. + pub fn is_guest(&self) -> bool { + self.id < 0 + } + fn from_row(r: &Row) -> rusqlite::Result { Ok(User { id: r.get("id")?, @@ -547,6 +553,32 @@ pub fn upsert_user(conn: &Connection, id: i64, handle: &str, name: Option<&str>, Ok(()) } +pub const GUEST_HANDLE: &str = "guest"; + +/// A new guest and a session for them. Returns the guest and the raw token. +pub fn create_guest(conn: &Connection) -> rusqlite::Result<(User, String)> { + let id = -((rand::rng().next_u64() >> 1) as i64).max(1); + upsert_user(conn, id, GUEST_HANDLE, None, None)?; + let token = create_session(conn, id)?; + let user = conn.query_row("SELECT * FROM users WHERE id=?", [id], User::from_row)?; + Ok((user, token)) +} + +/// A guest signed in with pl8list: what they played, liked and followed moves to +/// the account, and the guest is gone. +pub fn merge_guest(conn: &mut Connection, guest_id: i64, user_id: i64) -> rusqlite::Result<()> { + let tx = conn.transaction()?; + tx.execute("UPDATE events SET user_id=? WHERE user_id=?", params![user_id, guest_id])?; + tx.execute( + "INSERT OR IGNORE INTO follows (user_id, handle, source, ts) SELECT ?, handle, source, ts FROM follows WHERE user_id=?", + params![user_id, guest_id], + )?; + tx.execute("DELETE FROM follows WHERE user_id=?", [guest_id])?; + tx.execute("DELETE FROM sessions WHERE user_id=?", [guest_id])?; + tx.execute("DELETE FROM users WHERE id=?", [guest_id])?; + tx.commit() +} + pub fn hash_token(token: &str) -> String { hex::encode(Sha256::digest(token.as_bytes())) } @@ -760,15 +792,23 @@ pub fn delete_user(conn: &mut Connection, user_id: i64) -> rusqlite::Result<()> tx.commit() } -/// Drop expired sessions and old "served" events. Returns (sessions, events) removed. -pub fn purge(conn: &Connection) -> rusqlite::Result<(usize, usize)> { +/// Drop expired sessions, old "served" events and guests whose session is gone +/// (nobody can come back as them). Returns (sessions, events, guests) removed. +pub fn purge(conn: &mut Connection) -> rusqlite::Result<(usize, usize, usize)> { let now = now(); let s = conn.execute("DELETE FROM sessions WHERE expires_at <= ?", [now])?; let e = conn.execute( "DELETE FROM events WHERE kind='served' AND ts < ?", [now - SERVED_RETENTION_DAYS * 86400.0], )?; - Ok((s, e)) + let gone: Vec = conn + .prepare("SELECT id FROM users WHERE id < 0 AND NOT EXISTS (SELECT 1 FROM sessions WHERE user_id = users.id)")? + .query_map([], |r| r.get(0))? + .collect::>()?; + for &id in &gone { + delete_user(conn, id)?; + } + Ok((s, e, gone.len())) } #[cfg(test)] diff --git a/backend/src/http/app.rs b/backend/src/http/app.rs index c73af86..b3ad532 100644 --- a/backend/src/http/app.rs +++ b/backend/src/http/app.rs @@ -23,7 +23,7 @@ pub async fn me(Extension(current): Extension) -> Response { Some(u) => ( no_store(), Json(json!({ - "user": { "handle": u.handle, "name": u.name, "img": u.img }, + "user": { "handle": u.handle, "name": u.name, "img": u.img, "guest": u.is_guest() }, "pl8list": { "connected": current.link.is_some(), "synced_at": current.synced_at }, })), ) diff --git a/backend/src/http/auth.rs b/backend/src/http/auth.rs index 623fb34..8b9ff10 100644 --- a/backend/src/http/auth.rs +++ b/backend/src/http/auth.rs @@ -162,6 +162,8 @@ pub async fn verify(State(app): St, Extension(current): Extension, if let Some(old) = app.connected(¤t).await { app.pl8.logout(&old.1).await; } + // A guest signing in keeps what they played, liked and followed. + let guest = current.user.as_ref().filter(|g| g.is_guest()).map(|g| g.id); let token = app .db .call(move |c| { @@ -169,6 +171,9 @@ pub async fn verify(State(app): St, Extension(current): Extension, db::delete_session(c, &t)?; } db::upsert_user(c, u.id, &u.handle, u.name.as_deref(), u.img.as_deref())?; + if let Some(g) = guest { + db::merge_guest(c, g, u.id)?; + } db::create_session(c, u.id) }) .await?; @@ -189,11 +194,36 @@ pub async fn verify(State(app): St, Extension(current): Extension, Ok(res) } +/// Listen without a pl8list account: a guest with a session of their own. A device +/// that already has a session keeps it. +pub async fn guest(State(app): St, Extension(current): Extension, peer: Peer, headers: HeaderMap) -> Res { + let user = match current.user { + Some(u) => u, + None => { + if !app.limits.guests.allow(&client_ip(&app, &headers, peer.0)) { + return Ok(json_error(StatusCode::TOO_MANY_REQUESTS, TOO_MANY)); + } + let (user, token) = app.db.call(|c| db::create_guest(c)).await?; + let mut res = (no_store(), Json(guest_body(&user))).into_response(); + res.headers_mut().append(header::SET_COOKIE, app.cookies.set(SESSION_COOKIE, &token, (db::SESSION_IDLE_DAYS * 86400.0) as i64)); + return Ok(res); + } + }; + Ok((no_store(), Json(guest_body(&user))).into_response()) +} + +fn guest_body(u: &db::User) -> Value { + json!({ "user": { "handle": u.handle, "name": u.name, "img": u.img, "guest": u.is_guest() } }) +} + pub async fn logout(State(app): St, Extension(current): Extension) -> Res { if let Some((_, session)) = app.connected(¤t).await { app.pl8.logout(&session).await; } - if let Some(token) = current.token { + // Nobody can sign back in as a guest: their data goes with the session. + if let Some(g) = current.user.filter(|u| u.is_guest()) { + app.db.call(move |c| db::delete_user(c, g.id)).await?; + } else if let Some(token) = current.token { app.db.call(move |c| db::delete_session(c, &token)).await?; } Ok(signed_out(&app, json!({ "ok": true }))) diff --git a/backend/src/http/mod.rs b/backend/src/http/mod.rs index 193bdc7..8fb2f99 100644 --- a/backend/src/http/mod.rs +++ b/backend/src/http/mod.rs @@ -54,6 +54,8 @@ pub struct Limits { pub pl8_writes: RateLimiter, /// The resync button. pub resync: RateLimiter, + /// Guests made per IP: each is a row in the database. + pub guests: RateLimiter, } pub struct App { @@ -87,6 +89,7 @@ impl App { proxy: RateLimiter::new(1200, 60), pl8_writes: RateLimiter::new(60, 600), resync: RateLimiter::new(1, 120), + guests: RateLimiter::new(10, 3600), }, logins: auth::PendingLogins::default(), segments: proxy::SegmentKeys::default(), @@ -102,13 +105,14 @@ impl App { /// 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"), + 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(); + self.limits.guests.prune(); } /// This device's stored pl8list session, if it stays connected. A blob that no @@ -319,6 +323,7 @@ pub fn router(app: Arc) -> Router { .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)) diff --git a/backend/src/http/tests.rs b/backend/src/http/tests.rs index 06beb93..d997027 100644 --- a/backend/src/http/tests.rs +++ b/backend/src/http/tests.rs @@ -506,3 +506,73 @@ async fn artist_sheet_and_radio_reasons() { assert_eq!(why[&2], serde_json::json!({ "kind": "related" })); assert_eq!(why[&3], serde_json::json!({ "kind": "via", "handle": "b", "name": "B" })); } + +#[tokio::test] +async fn guests_listen_and_keep_it_all_when_they_sign_in() { + let pl8 = fake_pl8list(1).await; + let (r, app, _) = setup("guest", &pl8.uri()).await; + let res = r.clone().oneshot(req("POST", "/auth/guest", Some("{}"), None, "203.0.113.20")).await.unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let guest = cookie_pair(&set_cookies(&res), "__Host-pl8inf"); + assert_eq!(json_body(res).await["user"]["guest"], true); + let me = json_body(r.clone().oneshot(req("GET", "/local/me", None, Some(&guest), "203.0.113.20")).await.unwrap()).await; + assert_eq!(me["user"]["guest"], true); + for (uri, body) in [("/local/event", r#"{"track_id":7,"kind":"like"}"#), ("/local/follow", r#"{"handle":"motus","follow":true}"#)] { + let res = r.clone().oneshot(req("POST", uri, Some(body), Some(&guest), "203.0.113.20")).await.unwrap(); + assert_eq!(res.status(), StatusCode::OK, "{uri}"); + } + + // Signing in on the same device takes the guest's likes and follows along. + let res = r.clone().oneshot(req("POST", "/auth/start", Some(r#"{"email":"a@b.de"}"#), None, "203.0.113.20")).await.unwrap(); + let login = cookie_pair(&set_cookies(&res), "__Host-pl8inf_login"); + let both = format!("{login}; {guest}"); + let res = r.clone().oneshot(req("POST", "/auth/verify", Some(r#"{"code":"123456"}"#), Some(&both), "203.0.113.20")).await.unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let (likes, follows, guests) = app + .db + .call(|c| { + c.query_row( + "SELECT (SELECT COUNT(*) FROM events WHERE user_id=42 AND kind='like' AND track_id=7), \ + (SELECT COUNT(*) FROM follows WHERE user_id=42 AND handle='motus'), (SELECT COUNT(*) FROM users WHERE id < 0)", + [], + |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?)), + ) + }) + .await + .unwrap(); + assert_eq!((likes, follows, guests), (1, 1, 0)); + let res = r.clone().oneshot(req("GET", "/local/me", None, Some(&guest), "203.0.113.20")).await.unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + // The one sync after signing in logs the pl8list session out in the background. + assert!(wait_for(&pl8, |q| q.url.path() == "/logout").await); +} + +#[tokio::test] +async fn guests_leave_nothing_behind_and_are_capped_per_ip() { + let (r, app, _) = setup("guest-gone", "http://127.0.0.1:9").await; + let res = r.clone().oneshot(req("POST", "/auth/guest", Some("{}"), None, "203.0.113.21")).await.unwrap(); + let guest = cookie_pair(&set_cookies(&res), "__Host-pl8inf"); + // A device with a session gets no second guest. + let res = r.clone().oneshot(req("POST", "/auth/guest", Some("{}"), Some(&guest), "203.0.113.21")).await.unwrap(); + assert!(set_cookies(&res).is_empty()); + let res = r.clone().oneshot(req("POST", "/local/event", Some(r#"{"track_id":7,"kind":"like"}"#), Some(&guest), "203.0.113.21")).await.unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let res = r.clone().oneshot(req("POST", "/auth/logout", Some("{}"), Some(&guest), "203.0.113.21")).await.unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let count = "SELECT (SELECT COUNT(*) FROM users) + (SELECT COUNT(*) FROM sessions) + (SELECT COUNT(*) FROM events)"; + assert_eq!(app.db.call(move |c| c.query_row(count, [], |r| r.get::<_, i64>(0))).await.unwrap(), 0); + + // A guest whose session ran out is purged with what they did. + let res = r.clone().oneshot(req("POST", "/auth/guest", Some("{}"), None, "203.0.113.21")).await.unwrap(); + let guest = cookie_pair(&set_cookies(&res), "__Host-pl8inf"); + r.clone().oneshot(req("POST", "/local/event", Some(r#"{"track_id":7,"kind":"play"}"#), Some(&guest), "203.0.113.21")).await.unwrap(); + app.db.call(|c| c.execute("UPDATE sessions SET expires_at=0", [])).await.unwrap(); + app.purge().await; + assert_eq!(app.db.call(move |c| c.query_row(count, [], |r| r.get::<_, i64>(0))).await.unwrap(), 0); + + let mut statuses = vec![]; + for _ in 0..10 { + statuses.push(r.clone().oneshot(req("POST", "/auth/guest", Some("{}"), None, "203.0.113.21")).await.unwrap().status()); + } + assert_eq!(statuses.iter().filter(|s| **s == StatusCode::TOO_MANY_REQUESTS).count(), 2); +} diff --git a/backend/src/ratelimit.rs b/backend/src/ratelimit.rs index 2ee807e..cf133a3 100644 --- a/backend/src/ratelimit.rs +++ b/backend/src/ratelimit.rs @@ -34,6 +34,13 @@ impl RateLimiter { q.push_back(now); true } + + /// Forget keys with no hits left in the window. + pub fn prune(&self) { + let now = Instant::now(); + let mut hits = self.hits.lock().unwrap_or_else(|e| e.into_inner()); + hits.retain(|_, q| q.back().is_some_and(|t| now.duration_since(*t) < self.window)); + } } #[cfg(test)] diff --git a/backend/templates/datenschutz.html b/backend/templates/datenschutz.html index 149e798..bf001ca 100644 --- a/backend/templates/datenschutz.html +++ b/backend/templates/datenschutz.html @@ -97,6 +97,14 @@ sofort, wenn Sie sich abmelden. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO.

+

+ Sie können die App auch ohne pl8list-Konto nutzen („start listening“). Dann legen wir ein Gastkonto ohne + Namen und ohne Verbindung zu pl8list an; es ist nur über das Sitzungs-Cookie Ihres Browsers erreichbar. + Melden Sie sich als Gast ab, löschen wir das Gastkonto mit allem, was dazu gespeichert ist (Abschnitt 7), sofort. + Läuft die Sitzung ab (30 Tage ohne Nutzung), löschen wir es innerhalb einer Stunde. Melden Sie sich als Gast + mit pl8list an, übernehmen wir Ihren Hörverlauf, Ihre Likes und gefolgten Künstler in Ihr Konto und löschen + das Gastkonto. +

7. Vorschläge aus Ihrem Hörverhalten

@@ -127,7 +135,9 @@ E-Mail-Adresse. Dafür halten wir IP-Adresse und E-Mail-Adresse höchstens 15 Minuten im Arbeitsspeicher; gespeichert werden sie nicht. Erfolgreiche Anmeldungen protokollieren wir mit Ihrem pl8list-Nutzernamen, um Fehler und Missbrauch erkennen zu können; diese Protokolle werden fortlaufend überschrieben - (höchstens etwa 3 MB). Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. + (höchstens etwa 3 MB). Damit niemand massenhaft Gastkonten anlegt, begrenzen wir auch diese je + IP-Adresse; dafür halten wir die IP-Adresse höchstens zwei Stunden im Arbeitsspeicher. + Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO.

10. Cookies und lokaler Speicher

@@ -151,7 +161,7 @@

Sie haben das Recht auf

  • Auskunft (Art. 15 DSGVO) und Datenübertragbarkeit (Art. 20 DSGVO): im Menü „account“ unter „download my data“ erhalten Sie alle über Sie gespeicherten Daten als JSON-Datei;
  • -
  • Löschung (Art. 17 DSGVO): im Menü „account“ unter „delete account“ entfernen Sie Konto, Sitzungen, gespeicherte pl8list-Sitzungen, gefolgte Künstler und Hörverlauf sofort und vollständig;
  • +
  • Löschung (Art. 17 DSGVO): im Menü „account“ unter „delete account“ entfernen Sie Konto, Sitzungen, gespeicherte pl8list-Sitzungen, gefolgte Künstler und Hörverlauf sofort und vollständig; als Gast genügt „sign out and forget me“;
  • Widerruf einer Einwilligung (Art. 7 Abs. 3 DSGVO), siehe Abschnitt 5;
  • Berichtigung (Art. 16 DSGVO) und Einschränkung der Verarbeitung (Art. 18 DSGVO);
  • Widerspruch (Art. 21 DSGVO) gegen Verarbeitungen, die auf Art. 6 Abs. 1 lit. f DSGVO beruhen, aus Gründen, die sich aus Ihrer besonderen Situation ergeben;
  • @@ -161,13 +171,16 @@

    13. Pflicht zur Bereitstellung

    - Sie sind nicht verpflichtet, uns Daten zu geben. Ohne Anmeldung können Sie den Dienst aber nicht nutzen, - weil die Vorschläge an Ihr Konto gebunden sind. + Sie sind nicht verpflichtet, uns Daten zu geben. Ohne pl8list-Konto können Sie den Dienst als Gast nutzen + (Abschnitt 6); Ihre Vorschläge sind dann an das Gastkonto in diesem Browser gebunden.

    English summary

    - This site is not affiliated with pl8list. To sign in, your email address and login code are passed to + This site is not affiliated with pl8list. You can listen without an account: we then keep what you are + shown, play, like and follow under a random guest session cookie, and delete it all when you sign out, or + within an hour once the session has gone unused for 30 days; signing in with pl8list later moves it to + your account. To sign in, your email address and login code are passed to pl8list.com, which sends you the code. We keep your pl8list user id, handle, display name and avatar id, plus what you are shown, play, finish, skip, like or mark "not for me" and which artists you follow, to recommend tracks. By default your pl8list login is used once, in memory, for at most two minutes to read @@ -179,7 +192,7 @@ (stop staying connected, sign out, or delete account); we then log the session out and delete it. Audio and images are fetched through our server, so pl8list does not see your IP address. We use only the cookies listed above, which are strictly necessary; there is no tracking or analytics. IP and email address are held in memory for up to 15 minutes to rate-limit - sign-ins. You can download all your data and delete your account in the "account" menu at any time. + sign-ins, and the IP address for up to two hours to rate-limit new guests. You can download all your data and delete your account in the "account" menu at any time. Legal bases: Art. 6(1)(b) GDPR for the account and recommendations, Art. 6(1)(f) for delivery and abuse protection. You may complain to a data protection authority.

    diff --git a/web/index.html b/web/index.html index 6d57295..c5487af 100644 --- a/web/index.html +++ b/web/index.html @@ -65,6 +65,10 @@