mirror of
https://github.com/narrrl/pl8list-rewind.git
synced 2026-11-03 16:41:26 +01:00
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.
This commit is contained in:
1 parent
6e7311bb9d
commit
300674b8ac
11 files changed
+232
-26
No files matched your search
+43
-3
@@ -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<Self> {
|
||||
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<i64> = 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::<rusqlite::Result<_>>()?;
|
||||
for &id in &gone {
|
||||
delete_user(conn, id)?;
|
||||
}
|
||||
Ok((s, e, gone.len()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -23,7 +23,7 @@ pub async fn me(Extension(current): Extension<CurrentUser>) -> 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 },
|
||||
})),
|
||||
)
|
||||
|
||||
@@ -162,6 +162,8 @@ pub async fn verify(State(app): St, Extension(current): Extension<CurrentUser>,
|
||||
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<CurrentUser>,
|
||||
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<CurrentUser>,
|
||||
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<CurrentUser>, 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<CurrentUser>) -> 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 })))
|
||||
|
||||
@@ -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<App>) -> 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))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
Reference in new issue
Block a user