Files
pl8list-rewind/backend/src/http/account.rs
T
nvrl 27225332fb Add opt-in pl8list connection with library sync, follows and resync
At sign-in users can choose to stay connected to pl8list on that device.
The pl8list session is then stored in the device's session row, encrypted
with XChaCha20-Poly1305 under a key derived from the device's local token
(only the browser has it) and a server key kept outside the database. It
is only decrypted during that device's own requests, re-encrypted when the
token rotates or pl8list sends a new cookie, and deleted when pl8list
rejects it.

Every sign-in now syncs the personal feed, saved tracks (imported as likes)
and saved artists (imported as follows). Connected devices resync on use
every six hours and push new likes and follows to the pl8list library with
addOnly, so nothing is ever removed there. Without the connection, the
resync button runs the email-code sign-in again.

Artists can be followed from each card (f); follows feed the ranker. The
account menu shows the sync state, resync and disconnect. Privacy policy,
export and account deletion cover the new data.
2026-11-01 12:36:00 +01:00

97 lines
3.7 KiB
Rust

//! Data export, account deletion and signing out everywhere (Art. 15, 17, 20 GDPR),
//! plus the pl8list connection of this device.
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, no_store};
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;
// Other devices' stored sessions can't be opened without their cookies; deleting
// the rows makes them unusable. This device's one is logged out at pl8list too.
if let Some((_, session)) = app.connected(&current).await {
app.pl8.logout(&session).await;
}
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),
};
if let Some((_, session)) = app.connected(&current).await {
app.pl8.logout(&session).await;
}
app.db.call(move |c| db::delete_user_sessions(c, uid)).await?;
Ok(signed_out(&app, json!({ "ok": true })))
}
/// The resync button. A connected device syncs with its stored session; any other
/// device has to sign in with pl8list again (409 tells the page to show the sign-in).
pub async fn sync(State(app): St, Extension(current): Extension<CurrentUser>) -> Res {
let uid = match current.require() {
Ok(u) => u.id,
Err(r) => return Ok(r),
};
let Some((sealer, session)) = app.connected(&current).await else {
return Ok(json_error(StatusCode::CONFLICT, "sign in with pl8list again to sync"));
};
if !app.limits.resync.allow(&uid.to_string()) {
return Ok(json_error(StatusCode::TOO_MANY_REQUESTS, "synced a moment ago, try again in two minutes"));
}
let started = app.sync.linked(sealer, session);
Ok((no_store(), Json(json!({ "ok": true, "started": started }))).into_response())
}
/// Stop staying connected on this device: log the session out at pl8list and delete it.
pub async fn disconnect(State(app): St, Extension(current): Extension<CurrentUser>) -> Res {
if let Err(r) = current.require() {
return Ok(r);
}
if let Some((_, session)) = app.connected(&current).await {
app.pl8.logout(&session).await;
}
if let Some(token) = current.token.clone() {
app.db.call(move |c| db::clear_link(c, &db::hash_token(&token))).await?;
}
Ok((no_store(), Json(json!({ "ok": true }))).into_response())
}