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:
nvrl committed 2026-11-02 13:20:22 +01:00
1 parent 6e7311bb9d
commit 300674b8ac
11 files changed
+232 -26

No files matched your search

+43 -3
View File
@@ -524,6 +524,12 @@ pub struct User {
} }
impl 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> { fn from_row(r: &Row) -> rusqlite::Result<Self> {
Ok(User { Ok(User {
id: r.get("id")?, id: r.get("id")?,
@@ -547,6 +553,32 @@ pub fn upsert_user(conn: &Connection, id: i64, handle: &str, name: Option<&str>,
Ok(()) 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 { pub fn hash_token(token: &str) -> String {
hex::encode(Sha256::digest(token.as_bytes())) 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() tx.commit()
} }
/// Drop expired sessions and old "served" events. Returns (sessions, events) removed. /// Drop expired sessions, old "served" events and guests whose session is gone
pub fn purge(conn: &Connection) -> rusqlite::Result<(usize, usize)> { /// (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 now = now();
let s = conn.execute("DELETE FROM sessions WHERE expires_at <= ?", [now])?; let s = conn.execute("DELETE FROM sessions WHERE expires_at <= ?", [now])?;
let e = conn.execute( let e = conn.execute(
"DELETE FROM events WHERE kind='served' AND ts < ?", "DELETE FROM events WHERE kind='served' AND ts < ?",
[now - SERVED_RETENTION_DAYS * 86400.0], [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)] #[cfg(test)]
+1 -1
View File
@@ -23,7 +23,7 @@ pub async fn me(Extension(current): Extension<CurrentUser>) -> Response {
Some(u) => ( Some(u) => (
no_store(), no_store(),
Json(json!({ 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 }, "pl8list": { "connected": current.link.is_some(), "synced_at": current.synced_at },
})), })),
) )
+31 -1
View File
@@ -162,6 +162,8 @@ pub async fn verify(State(app): St, Extension(current): Extension<CurrentUser>,
if let Some(old) = app.connected(&current).await { if let Some(old) = app.connected(&current).await {
app.pl8.logout(&old.1).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 let token = app
.db .db
.call(move |c| { .call(move |c| {
@@ -169,6 +171,9 @@ pub async fn verify(State(app): St, Extension(current): Extension<CurrentUser>,
db::delete_session(c, &t)?; db::delete_session(c, &t)?;
} }
db::upsert_user(c, u.id, &u.handle, u.name.as_deref(), u.img.as_deref())?; 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) db::create_session(c, u.id)
}) })
.await?; .await?;
@@ -189,11 +194,36 @@ pub async fn verify(State(app): St, Extension(current): Extension<CurrentUser>,
Ok(res) 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 { pub async fn logout(State(app): St, Extension(current): Extension<CurrentUser>) -> Res {
if let Some((_, session)) = app.connected(&current).await { if let Some((_, session)) = app.connected(&current).await {
app.pl8.logout(&session).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?; app.db.call(move |c| db::delete_session(c, &token)).await?;
} }
Ok(signed_out(&app, json!({ "ok": true }))) Ok(signed_out(&app, json!({ "ok": true })))
+7 -2
View File
@@ -54,6 +54,8 @@ pub struct Limits {
pub pl8_writes: RateLimiter, pub pl8_writes: RateLimiter,
/// The resync button. /// The resync button.
pub resync: RateLimiter, pub resync: RateLimiter,
/// Guests made per IP: each is a row in the database.
pub guests: RateLimiter,
} }
pub struct App { pub struct App {
@@ -87,6 +89,7 @@ impl App {
proxy: RateLimiter::new(1200, 60), proxy: RateLimiter::new(1200, 60),
pl8_writes: RateLimiter::new(60, 600), pl8_writes: RateLimiter::new(60, 600),
resync: RateLimiter::new(1, 120), resync: RateLimiter::new(1, 120),
guests: RateLimiter::new(10, 3600),
}, },
logins: auth::PendingLogins::default(), logins: auth::PendingLogins::default(),
segments: proxy::SegmentKeys::default(), segments: proxy::SegmentKeys::default(),
@@ -102,13 +105,14 @@ impl App {
/// Hourly housekeeping. /// Hourly housekeeping.
pub async fn purge(&self) { pub async fn purge(&self) {
match self.db.call(|c| db::purge(c)).await { match self.db.call(db::purge).await {
Ok((s, e)) if s + e > 0 => tracing::info!("purged {s} expired sessions, {e} old served events"), Ok((s, e, g)) if s + e + g > 0 => tracing::info!("purged {s} expired sessions, {e} old served events, {g} gone guests"),
Ok(_) => {} Ok(_) => {}
Err(e) => tracing::warn!("purge failed: {e:#}"), Err(e) => tracing::warn!("purge failed: {e:#}"),
} }
self.logins.prune(); self.logins.prune();
self.segments.prune(); self.segments.prune();
self.limits.guests.prune();
} }
/// This device's stored pl8list session, if it stays connected. A blob that no /// 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/resend", post(auth::resend))
.route("/auth/verify", post(auth::verify)) .route("/auth/verify", post(auth::verify))
.route("/auth/logout", post(auth::logout)) .route("/auth/logout", post(auth::logout))
.route("/auth/guest", post(auth::guest))
.route("/local/me", get(app::me)) .route("/local/me", get(app::me))
.route("/local/feed", get(app::feed)) .route("/local/feed", get(app::feed))
.route("/local/likes", get(app::likes)) .route("/local/likes", get(app::likes))
+70
View File
@@ -506,3 +506,73 @@ async fn artist_sheet_and_radio_reasons() {
assert_eq!(why[&2], serde_json::json!({ "kind": "related" })); assert_eq!(why[&2], serde_json::json!({ "kind": "related" }));
assert_eq!(why[&3], serde_json::json!({ "kind": "via", "handle": "b", "name": "B" })); 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);
}
+7
View File
@@ -34,6 +34,13 @@ impl RateLimiter {
q.push_back(now); q.push_back(now);
true 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)] #[cfg(test)]
+19 -6
View File
@@ -97,6 +97,14 @@
sofort, wenn Sie sich abmelden. sofort, wenn Sie sich abmelden.
Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO.
</p> </p>
<p>
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.
</p>
<h2>7. Vorschläge aus Ihrem Hörverhalten</h2> <h2>7. Vorschläge aus Ihrem Hörverhalten</h2>
<p> <p>
@@ -127,7 +135,9 @@
E-Mail-Adresse. Dafür halten wir IP-Adresse und E-Mail-Adresse höchstens 15 Minuten im Arbeitsspeicher; 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, 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 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.
</p> </p>
<h2>10. Cookies und lokaler Speicher</h2> <h2>10. Cookies und lokaler Speicher</h2>
@@ -151,7 +161,7 @@
<p>Sie haben das Recht auf</p> <p>Sie haben das Recht auf</p>
<ul> <ul>
<li>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;</li> <li>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;</li>
<li>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;</li> <li>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“;</li>
<li>Widerruf einer Einwilligung (Art. 7 Abs. 3 DSGVO), siehe Abschnitt 5;</li> <li>Widerruf einer Einwilligung (Art. 7 Abs. 3 DSGVO), siehe Abschnitt 5;</li>
<li>Berichtigung (Art. 16 DSGVO) und Einschränkung der Verarbeitung (Art. 18 DSGVO);</li> <li>Berichtigung (Art. 16 DSGVO) und Einschränkung der Verarbeitung (Art. 18 DSGVO);</li>
<li>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;</li> <li>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;</li>
@@ -161,13 +171,16 @@
<h2>13. Pflicht zur Bereitstellung</h2> <h2>13. Pflicht zur Bereitstellung</h2>
<p> <p>
Sie sind nicht verpflichtet, uns Daten zu geben. Ohne Anmeldung können Sie den Dienst aber nicht nutzen, Sie sind nicht verpflichtet, uns Daten zu geben. Ohne pl8list-Konto können Sie den Dienst als Gast nutzen
weil die Vorschläge an Ihr Konto gebunden sind. (Abschnitt 6); Ihre Vorschläge sind dann an das Gastkonto in diesem Browser gebunden.
</p> </p>
<h2 id="english">English summary</h2> <h2 id="english">English summary</h2>
<p> <p>
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, 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 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 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 (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 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 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 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. protection. You may complain to a data protection authority.
</p> </p>
+9 -5
View File
@@ -65,6 +65,10 @@
<div class="login-box"> <div class="login-box">
<h1><svg class="login-mark" aria-hidden="true"><use href="#i-rewind"/></svg>pl8list <b>rewind</b></h1> <h1><svg class="login-mark" aria-hidden="true"><use href="#i-rewind"/></svg>pl8list <b>rewind</b></h1>
<p class="tagline">endless riddim from pl8list. like it, and it comes back.</p> <p class="tagline">endless riddim from pl8list. like it, and it comes back.</p>
<div id="guest-start">
<button type="button" id="guest-btn" class="primary"><svg><use href="#i-play"/></svg> start listening</button>
<p class="or">no account needed. or sign in with pl8list to bring your follows and likes along.</p>
</div>
<form id="email-form"> <form id="email-form">
<label for="email">sign in with your pl8list email</label> <label for="email">sign in with your pl8list email</label>
<input id="email" type="email" name="email" autocomplete="email" required placeholder="you@example.com"> <input id="email" type="email" name="email" autocomplete="email" required placeholder="you@example.com">
@@ -90,11 +94,11 @@
<button type="button" id="login-cancel" class="link" hidden>cancel, keep using the app</button> <button type="button" id="login-cancel" class="link" hidden>cancel, keep using the app</button>
<p id="login-error" role="alert"></p> <p id="login-error" role="alert"></p>
<p class="fineprint"> <p class="fineprint">
No account? <a href="https://pl8list.com/signup" target="_blank" rel="noopener">sign up on pl8list</a>. This site is not affiliated with pl8list. Without an account we keep what you play, like and
This site is not affiliated with pl8list. Your email and code go to pl8list, which signs this follow under a random cookie, to pick tracks for you; signing out deletes it. Your email and code
server in for you. Unless you choose to stay connected, we use that pl8list login once to read go to pl8list, which signs this server in for you. Unless you choose to stay connected, we use
your feed and library, then log it out; it is never stored. We keep your pl8list handle, the that pl8list login once to read your feed and library, then log it out; it is never stored. We
artists you follow and what you play and like, to pick tracks for you. keep your pl8list handle, the artists you follow and what you play and like.
</p> </p>
<p class="fineprint legal-links"> <p class="fineprint legal-links">
<a href="/datenschutz">Datenschutz / privacy</a> · <a href="/impressum">Impressum</a> <a href="/datenschutz">Datenschutz / privacy</a> · <a href="/impressum">Impressum</a>
+2
View File
@@ -61,6 +61,8 @@ export interface Stats {
export interface User { export interface User {
handle: string; handle: string;
name: string | null; name: string | null;
/// Listening without a pl8list account.
guest?: boolean;
} }
export interface Pl8Status { export interface Pl8Status {
+38 -8
View File
@@ -123,6 +123,7 @@ async function loadMore(): Promise<void> {
if (gen !== feedGen) return; if (gen !== feedGen) return;
if (err instanceof api.HttpError && err.status === 401) { if (err instanceof api.HttpError && err.status === 401) {
loading = false; loading = false;
signedInUser = null;
showLogin(); showLogin();
return; return;
} }
@@ -996,13 +997,29 @@ function showLogin({ resync = false } = {}): void {
loginEl.hidden = false; loginEl.hidden = false;
emailForm.hidden = false; emailForm.hidden = false;
codeForm.hidden = true; codeForm.hidden = true;
loginCancel.hidden = !resync; // Someone already listening is here only to sign in with pl8list.
guestStart.hidden = !!signedInUser;
loginCancel.hidden = !signedInUser;
loginError.textContent = resync ? "sign in with pl8list again to sync your library" : ""; loginError.textContent = resync ? "sign in with pl8list again to sync your library" : "";
$("email").focus(); if (signedInUser) $("email").focus();
} }
loginCancel.addEventListener("click", () => { if (signedInUser) signedIn(signedInUser); }); loginCancel.addEventListener("click", () => { if (signedInUser) signedIn(signedInUser); });
const guestStart = $("guest-start");
$("guest-btn").addEventListener("click", async (e) => {
const btn = e.currentTarget as HTMLButtonElement;
btn.disabled = true;
loginError.textContent = "";
try {
const { user } = await api.postJSON<{ user: User }>("/auth/guest");
signedIn(user);
} catch (err) {
loginError.textContent = (err as Error).message;
}
btn.disabled = false;
});
async function withForm(form: HTMLFormElement, fn: () => Promise<void>): Promise<void> { async function withForm(form: HTMLFormElement, fn: () => Promise<void>): Promise<void> {
const buttons = form.querySelectorAll("button"); const buttons = form.querySelectorAll("button");
buttons.forEach((b) => { b.disabled = true; }); buttons.forEach((b) => { b.disabled = true; });
@@ -1062,7 +1079,10 @@ const disconnectBtn = $("disconnect-btn");
function showSync(s: Pl8Status | undefined): void { function showSync(s: Pl8Status | undefined): void {
const connected = !!s?.connected; const connected = !!s?.connected;
syncStatus.classList.toggle("on", connected); syncStatus.classList.toggle("on", connected);
syncStatus.textContent = connected syncStatus.textContent = signedInUser?.guest
? "listening as a guest on this device. Sign in with pl8list to bring your follows and likes " +
"along; what you did here comes with you."
: connected
? `connected on this device${s?.synced_at ? `, last sync ${ago(s.synced_at)}` : ""}. ` + ? `connected on this device${s?.synced_at ? `, last sync ${ago(s.synced_at)}` : ""}. ` +
"Likes and follows are saved to your pl8list library." "Likes and follows are saved to your pl8list library."
: "not connected. Resync signs you in with pl8list once to read your feed and library."; : "not connected. Resync signs you in with pl8list once to read your feed and library.";
@@ -1075,6 +1095,10 @@ sheets.account.onOpen = async () => {
}; };
$("sync-btn").addEventListener("click", async () => { $("sync-btn").addEventListener("click", async () => {
if (signedInUser?.guest) {
showLogin();
return;
}
try { try {
await api.postJSON("/account/pl8list/sync"); await api.postJSON("/account/pl8list/sync");
toast("syncing with pl8list…"); toast("syncing with pl8list…");
@@ -1126,14 +1150,20 @@ deleteForm.addEventListener("submit", async (e) => {
function signedIn(user: User, pl8list?: Pl8Status): void { function signedIn(user: User, pl8list?: Pl8Status): void {
signedInUser = user; signedInUser = user;
if (pl8list) showSync(pl8list); const guest = !!user.guest;
showSync(pl8list);
loginEl.hidden = true; loginEl.hidden = true;
$("bar").hidden = false; $("bar").hidden = false;
accountBtn.textContent = (user.name || user.handle)[0]?.toUpperCase() ?? "?"; accountBtn.textContent = guest ? "?" : (user.name || user.handle)[0]?.toUpperCase() ?? "?";
accountBtn.style.backgroundColor = accentForName(user.handle); accountBtn.style.backgroundColor = guest ? "#ffffff30" : accentForName(user.handle);
accountBtn.title = `@${user.handle}`; accountBtn.title = guest ? "guest" : `@${user.handle}`;
$("account-handle").textContent = `@${user.handle}`; $("account-handle").textContent = guest ? "listening as a guest" : `@${user.handle}`;
$("delete-handle").textContent = user.handle; $("delete-handle").textContent = user.handle;
// A guest has one session and nothing on pl8list: signing out forgets them.
$("sync-title").textContent = guest ? "pl8list" : "pl8list sync";
$("sync-btn").textContent = guest ? "sign in with pl8list" : "resync with pl8list";
$("logout-btn").textContent = guest ? "sign out and forget me" : "sign out";
for (const id of ["logout-all-btn", "delete-open"]) $(id).hidden = guest;
// Typing the login code counts as a user gesture, so playback may start right away. // Typing the login code counts as a user gesture, so playback may start right away.
if (navigator.userActivation?.hasBeenActive) start(); if (navigator.userActivation?.hasBeenActive) start();
else if (!started) startEl.hidden = false; else if (!started) startEl.hidden = false;
+5
View File
@@ -1000,6 +1000,11 @@ button.primary:disabled { opacity: 0.35; cursor: default; }
} }
#code { font-family: ui-monospace, monospace; font-size: 1.4rem; letter-spacing: 0.5em; text-align: center; } #code { font-family: ui-monospace, monospace; font-size: 1.4rem; letter-spacing: 0.5em; text-align: center; }
.login-box button[type=submit] { background: var(--accent); border: none; color: #fff; padding: 0.7em; font-weight: 600; } .login-box button[type=submit] { background: var(--accent); border: none; color: #fff; padding: 0.7em; font-weight: 600; }
/* Listening without an account comes first; signing in is the quieter way below. */
#guest-start { display: flex; flex-direction: column; gap: 0.6rem; padding-bottom: 0.4rem; border-bottom: 1px solid var(--line); }
#guest-btn { width: 100%; font-size: 1.05rem; padding: 0.8em; }
#guest-start .or { margin: 0 0 0.5rem; color: var(--muted); font-size: 0.85rem; text-align: center; }
#guest-start:not([hidden]) ~ #email-form button[type=submit] { background: none; border: 1px solid var(--line-strong); color: var(--fg); }
.login-box button[disabled] { opacity: 0.6; cursor: wait; } .login-box button[disabled] { opacity: 0.6; cursor: wait; }
.login-links { display: flex; justify-content: space-between; } .login-links { display: flex; justify-content: space-between; }
.login-box label.check { display: flex; gap: 0.6rem; align-items: flex-start; color: var(--fg); cursor: pointer; } .login-box label.check { display: flex; gap: 0.6rem; align-items: flex-start; color: var(--fg); cursor: pointer; }