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)]
|
||||
|
||||
@@ -97,6 +97,14 @@
|
||||
sofort, wenn Sie sich abmelden.
|
||||
Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO.
|
||||
</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>
|
||||
<p>
|
||||
@@ -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.
|
||||
</p>
|
||||
|
||||
<h2>10. Cookies und lokaler Speicher</h2>
|
||||
@@ -151,7 +161,7 @@
|
||||
<p>Sie haben das Recht auf</p>
|
||||
<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>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>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>
|
||||
@@ -161,13 +171,16 @@
|
||||
|
||||
<h2>13. Pflicht zur Bereitstellung</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2 id="english">English summary</h2>
|
||||
<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,
|
||||
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.
|
||||
</p>
|
||||
|
||||
+9
-5
@@ -65,6 +65,10 @@
|
||||
<div class="login-box">
|
||||
<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>
|
||||
<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">
|
||||
<label for="email">sign in with your pl8list email</label>
|
||||
<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>
|
||||
<p id="login-error" role="alert"></p>
|
||||
<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. Your email and code go to pl8list, which signs this
|
||||
server in for you. Unless you choose to stay connected, we use that pl8list login once to read
|
||||
your feed and library, then log it out; it is never stored. We keep your pl8list handle, the
|
||||
artists you follow and what you play and like, to pick tracks for you.
|
||||
This site is not affiliated with pl8list. Without an account we keep what you play, like and
|
||||
follow under a random cookie, to pick tracks for you; signing out deletes it. Your email and code
|
||||
go to pl8list, which signs this server in for you. Unless you choose to stay connected, we use
|
||||
that pl8list login once to read your feed and library, then log it out; it is never stored. We
|
||||
keep your pl8list handle, the artists you follow and what you play and like.
|
||||
</p>
|
||||
<p class="fineprint legal-links">
|
||||
<a href="/datenschutz">Datenschutz / privacy</a> · <a href="/impressum">Impressum</a>
|
||||
|
||||
@@ -61,6 +61,8 @@ export interface Stats {
|
||||
export interface User {
|
||||
handle: string;
|
||||
name: string | null;
|
||||
/// Listening without a pl8list account.
|
||||
guest?: boolean;
|
||||
}
|
||||
|
||||
export interface Pl8Status {
|
||||
|
||||
+38
-8
@@ -123,6 +123,7 @@ async function loadMore(): Promise<void> {
|
||||
if (gen !== feedGen) return;
|
||||
if (err instanceof api.HttpError && err.status === 401) {
|
||||
loading = false;
|
||||
signedInUser = null;
|
||||
showLogin();
|
||||
return;
|
||||
}
|
||||
@@ -996,13 +997,29 @@ function showLogin({ resync = false } = {}): void {
|
||||
loginEl.hidden = false;
|
||||
emailForm.hidden = false;
|
||||
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" : "";
|
||||
$("email").focus();
|
||||
if (signedInUser) $("email").focus();
|
||||
}
|
||||
|
||||
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> {
|
||||
const buttons = form.querySelectorAll("button");
|
||||
buttons.forEach((b) => { b.disabled = true; });
|
||||
@@ -1062,7 +1079,10 @@ const disconnectBtn = $("disconnect-btn");
|
||||
function showSync(s: Pl8Status | undefined): void {
|
||||
const connected = !!s?.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)}` : ""}. ` +
|
||||
"Likes and follows are saved to your pl8list 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 () => {
|
||||
if (signedInUser?.guest) {
|
||||
showLogin();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.postJSON("/account/pl8list/sync");
|
||||
toast("syncing with pl8list…");
|
||||
@@ -1126,14 +1150,20 @@ deleteForm.addEventListener("submit", async (e) => {
|
||||
|
||||
function signedIn(user: User, pl8list?: Pl8Status): void {
|
||||
signedInUser = user;
|
||||
if (pl8list) showSync(pl8list);
|
||||
const guest = !!user.guest;
|
||||
showSync(pl8list);
|
||||
loginEl.hidden = true;
|
||||
$("bar").hidden = false;
|
||||
accountBtn.textContent = (user.name || user.handle)[0]?.toUpperCase() ?? "?";
|
||||
accountBtn.style.backgroundColor = accentForName(user.handle);
|
||||
accountBtn.title = `@${user.handle}`;
|
||||
$("account-handle").textContent = `@${user.handle}`;
|
||||
accountBtn.textContent = guest ? "?" : (user.name || user.handle)[0]?.toUpperCase() ?? "?";
|
||||
accountBtn.style.backgroundColor = guest ? "#ffffff30" : accentForName(user.handle);
|
||||
accountBtn.title = guest ? "guest" : `@${user.handle}`;
|
||||
$("account-handle").textContent = guest ? "listening as a guest" : `@${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.
|
||||
if (navigator.userActivation?.hasBeenActive) start();
|
||||
else if (!started) startEl.hidden = false;
|
||||
|
||||
@@ -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; }
|
||||
.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-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; }
|
||||
|
||||
Reference in new issue
Block a user