//! An account's settings, for the pages: how it shows on knotless //! (`getOwnProfile`, `updateProfile`, `uploadAvatar`), the addresses its //! commits show as its by (`getEmails`, `addEmail`, `removeEmail`), whom it //! blocks (`listBlocks`, `blockActor`, `unblockActor`), and its //! preferences (`getPreferences`, `putPreferences`): how the pages are //! drawn for it, and which notifications it hears of. //! //! What lives in the account's own repo is written as the account logged //! in to the pages, with the session the server keeps for it, through the //! same code as `knotless account profile`, `email` and `block`. Preferences, of //! notifications and the rest, are the appview's to keep, as `updateSeen`'s time is, so a //! service token asks for them as well as the cookie. use super::notifications::asking; use super::stars::logged_in; use super::*; use crate::index::{APPEARANCE, NOTIFICATIONS, THEMES}; use crate::viewer::Viewer; use axum::Extension; use knotless::moderation::{self, Blocked}; use knotless::notifications::Preference; use knotless::read::BadInput; /// The reasons a notification is given for, as `listNotifications` knows /// them: one added there is one an account can turn off here. static REASONS: LazyLock> = LazyLock::new(|| { let lexicon = include_str!("../../../../lexicons/dev/knotless/listNotifications.json"); let lexicon: serde_json::Value = serde_json::from_str(lexicon).unwrap_or_default(); let known = &lexicon["defs"]["notification"]["properties"]["reason"]["knownValues"]; let known = known.as_array().into_iter().flatten(); known .filter_map(|v| v.as_str().map(str::to_string)) .collect() }); /// The settings' queries and procedures. pub fn routes() -> Router> { let x = |nsid: &str| format!("/xrpc/dev.knotless.{nsid}"); Router::new() .route(&x("getOwnProfile"), get(get_own_profile)) .route(&x("updateProfile"), post(update_profile)) .route( &x("uploadAvatar"), post(upload_avatar).layer(axum::extract::DefaultBodyLimit::max( super::threads::PICTURE_LIMIT, )), ) .route(&x("getEmails"), get(get_emails)) .route(&x("addEmail"), post(add_email)) .route(&x("removeEmail"), post(remove_email)) .route(&x("listBlocks"), get(list_blocks)) .route(&x("blockActor"), post(block_actor)) .route(&x("unblockActor"), post(unblock_actor)) .route(&x("getPreferences"), get(get_preferences)) .route(&x("putPreferences"), post(put_preferences)) } /// The account asking, by a service token or the pages' cookie. fn someone(viewer: Viewer) -> Result { let why = "ask as an account: with a service token, or logged in to the pages"; viewer.0.ok_or(Shown(StatusCode::UNAUTHORIZED, why.into())) } /// A change the pages asked for that failed: refused for what it asked, /// in the words of the code that refused it, or failed at a server. fn refused(e: anyhow::Error) -> Shown { if e.downcast_ref::().is_some() { return Shown(StatusCode::BAD_REQUEST, crate::shown(&e)); } AppError::upstream(e).into() } /// What the index said, as the pages' error if it failed. fn indexed(done: rusqlite::Result) -> Result { let done = done.map_err(anyhow::Error::from); Ok(done.map_err(AppError::internal)?) } /// `did`'s account, as a repository of nothing. fn account(did: &str) -> RepoUrl { RepoUrl { authority: did.to_string(), name: "_".into(), } } /// `dev.knotless.getOwnProfile#ownProfile`: the account's knotless /// profile alone, with nothing of its Bluesky one. #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct OwnProfile { #[serde(skip_serializing_if = "Option::is_none")] display_name: Option, #[serde(skip_serializing_if = "Option::is_none")] description: Option, #[serde(skip_serializing_if = "Option::is_none")] pronouns: Option, #[serde(skip_serializing_if = "Vec::is_empty")] links: Vec, #[serde(skip_serializing_if = "Option::is_none")] avatar: Option, #[serde(skip_serializing_if = "std::ops::Not::not")] bot: bool, } fn own(did: &str, profile: ProfileRecord) -> OwnProfile { let avatar = profile.avatar.map(|a| own_avatar_path(did, &a.link.cid)); OwnProfile { display_name: profile.display_name, description: profile.description, pronouns: profile.pronouns, links: profile.links, avatar: avatar.map(absolute), bot: profile.bot, } } /// `dev.knotless.getOwnProfile`. pub async fn get_own_profile( State(app): State>, Extension(viewer): Extension, ) -> Result { let did = someone(viewer)?; let remote = Remote::connect(&app.ctx, &account(&did)).await; let remote = remote.map_err(AppError::upstream)?; let found = super::own_profile(&app, &remote).await?; Ok(answer(own(&did, found.unwrap_or_default()))) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct ProfileInput { display_name: Option, description: Option, pronouns: Option, links: Option>, #[serde(default)] remove_avatar: bool, bot: Option, } /// `dev.knotless.updateProfile`. pub async fn update_profile( State(app): State>, Extension(viewer): Extension, headers: HeaderMap, axum::Json(input): axum::Json, ) -> Result { let did = logged_in(&app, viewer, &headers, "change your profile")?; let change = profile::Change { name: input.display_name, bio: input.description, pronouns: input.pronouns, links: input.links, avatar: input.remove_avatar.then(Vec::new), bot: input.bot, }; profiled(&app, &did, change).await } /// `dev.knotless.uploadAvatar`: the picture is the whole of the body. pub async fn upload_avatar( State(app): State>, Extension(viewer): Extension, headers: HeaderMap, body: axum::body::Bytes, ) -> Result { let did = logged_in(&app, viewer, &headers, "change your picture")?; if body.is_empty() { let why = "send the picture, a PNG, JPEG, WebP or GIF, as the body"; return Err(Shown(StatusCode::BAD_REQUEST, why.into())); } let change = profile::Change { avatar: Some(body.to_vec()), ..Default::default() }; profiled(&app, &did, change).await } /// Changes `did`'s profile as `change` says, and has the index and the /// pictures kept here know at once, so the next page shows it. async fn profiled(app: &App, did: &str, change: profile::Change) -> Result { let ctx = crate::login::ctx_for(app, did); let me = session::me(&ctx).await.map_err(AppError::upstream)?; let now = profile::set(&ctx, &me, change).await.map_err(refused)?; let value = knotless::records::typed(nsid::PROFILE, &now); let gone = now == ProfileRecord::default(); let kept = app.index.as_ref().map(|index| match gone { true => index.delete(did, nsid::PROFILE, profile::RKEY), false => index.put(did, nsid::PROFILE, profile::RKEY, &value), }); indexed(kept.transpose())?; let remote = Remote::connect(&app.ctx, &account(did)).await; let remote = remote.map_err(AppError::upstream)?; let watching = app.watching.load(Ordering::Relaxed); let records = profile_records(app.index.as_deref(), watching, &remote).await; let about = about(did, records); if about.answered { AVATARS.load(&app.ctx.env.cache_dir); AVATARS.put(did, about.avatar); } Ok(answer(own(did, now))) } /// `dev.knotless.getEmails`'s answer. #[derive(Serialize)] pub struct EmailsOutput { count: usize, } /// `dev.knotless.getEmails`. pub async fn get_emails( State(app): State>, Extension(viewer): Extension, ) -> Result { let did = someone(viewer)?; let claimed = knotless::emails::claimed(&app.ctx, &did).await; let claimed = claimed.map_err(AppError::upstream)?; Ok(answer(EmailsOutput { count: claimed.len(), })) } #[derive(Deserialize)] pub struct EmailInput { email: String, } /// `dev.knotless.addEmail` and `removeEmail`'s answer. #[derive(Serialize)] pub struct EmailOutput { changed: bool, count: usize, } /// `dev.knotless.addEmail`. pub async fn add_email( State(app): State>, Extension(viewer): Extension, headers: HeaderMap, axum::Json(input): axum::Json, ) -> Result { emailing(&app, viewer, &headers, &input.email, true).await } /// `dev.knotless.removeEmail`. pub async fn remove_email( State(app): State>, Extension(viewer): Extension, headers: HeaderMap, axum::Json(input): axum::Json, ) -> Result { emailing(&app, viewer, &headers, &input.email, false).await } /// Claims `email` as the account logged in to the pages, or takes the /// claim back, and keeps the claims as they are now for its commits. async fn emailing( app: &App, viewer: Viewer, headers: &HeaderMap, email: &str, on: bool, ) -> Result { let did = logged_in(app, viewer, headers, "claim an address")?; let ctx = crate::login::ctx_for(app, &did); let me = session::me(&ctx).await.map_err(AppError::upstream)?; let set = knotless::emails::set(&ctx, &me, email, on).await; let set = set.map_err(refused)?; CLAIMS.load(&app.ctx.env.cache_dir); CLAIMS.put(&did, set.hashes.clone()); Ok(answer(EmailOutput { changed: set.changed, count: set.hashes.len(), })) } /// `dev.knotless.listBlocks#block`. #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct BlockView { uri: String, subject: ActorView, #[serde(skip_serializing_if = "Option::is_none")] repo: Option, created_at: String, } #[derive(Serialize)] pub struct BlocksOutput { blocks: Vec, } /// `dev.knotless.listBlocks`: from the account's own repo, so a block /// shows here as soon as it is made. pub async fn list_blocks( State(app): State>, Extension(viewer): Extension, ) -> Result { let did = someone(viewer)?; let blocks = moderation::blocks(&app.ctx, &did).await; let blocks = blocks.map_err(AppError::upstream)?; let views = blocks.iter().map(|b| block_view(&app, b)); let blocks = futures::future::join_all(views).await; Ok(answer(BlocksOutput { blocks })) } async fn block_view(app: &App, b: &Blocked) -> BlockView { let repo = match &b.repo { Some(repo) => repo_basic(app, repo).await, None => None, }; BlockView { uri: b.uri.clone(), subject: actor(app, &b.subject).await, repo, created_at: b.created_at.clone(), } } #[derive(Deserialize)] pub struct BlockInput { subject: String, repo: Option, } /// `dev.knotless.blockActor` and `unblockActor`'s answer. #[derive(Serialize)] pub struct BlockState { subject: String, blocking: bool, } /// `dev.knotless.blockActor`. pub async fn block_actor( State(app): State>, Extension(viewer): Extension, headers: HeaderMap, axum::Json(input): axum::Json, ) -> Result { blocking(&app, viewer, &headers, input, true).await } /// `dev.knotless.unblockActor`. pub async fn unblock_actor( State(app): State>, Extension(viewer): Extension, headers: HeaderMap, axum::Json(input): axum::Json, ) -> Result { blocking(&app, viewer, &headers, input, false).await } /// Blocks `input.subject` as the account logged in to the pages, from all /// its repositories or the one `input.repo` names, or unblocks it. A block /// from all of them is indexed at once, so pages after leave it out; one /// from a repository comes in from the firehose, or its space. async fn blocking( app: &App, viewer: Viewer, headers: &HeaderMap, input: BlockInput, on: bool, ) -> Result { let did = logged_in(app, viewer, headers, "block")?; let ctx = crate::login::ctx_for(app, &did); let url = match &input.repo { Some(uri) => Some(repo_url(uri)?), None => None, }; let done = moderation::block(&ctx, &input.subject, url.as_ref(), on).await; let done = done.map_err(refused)?; let index = app.index.as_ref().filter(|_| url.is_none()); for rkey in &done.deleted { let deleted = index.map(|i| i.delete(&did, nsid::BLOCK, rkey)); indexed(deleted.transpose())?; } if let Some((rkey, record)) = &done.made { let value = knotless::records::typed(nsid::BLOCK, record); let put = index.map(|i| i.put(&did, nsid::BLOCK, rkey, &value)); indexed(put.transpose())?; } Ok(answer(BlockState { subject: done.did, blocking: on, })) } /// The repository whose record `uri` names. fn repo_url(uri: &str) -> Result { let parsed = forge::parse_uri(uri).ok(); let Some((did, nsid::REPO, name)) = parsed else { let why = format!("{uri} is not at:///{}/", nsid::REPO); return Err(Shown(StatusCode::BAD_REQUEST, why)); }; Ok(RepoUrl { authority: did.to_string(), name: name.to_string(), }) } /// The kinds of preference, as `dev.knotless.getPreferences` names them. const APPEARANCE_PREF: &str = "dev.knotless.getPreferences#appearancePref"; const NOTIFICATION_PREF: &str = "dev.knotless.getPreferences#notificationPref"; const THEMES_PREF: &str = "dev.knotless.getPreferences#themesPref"; /// The most themes an account adds to its own. const ADDED: usize = 50; /// `dev.knotless.getPreferences#notificationPref`: every reason, on unless /// `off` names it. #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct NotificationPref { #[serde(rename = "$type")] kind: &'static str, reasons: Vec, followed_only: bool, } fn notification_pref(off: &[String], followed_only: bool) -> serde_json::Value { let each = REASONS.iter().map(|reason| Preference { reason: reason.clone(), enabled: !off.contains(reason), }); let pref = NotificationPref { kind: NOTIFICATION_PREF, reasons: each.collect(), followed_only, }; serde_json::to_value(pref).unwrap_or_default() } /// `did`'s preferences, as `getPreferences` and `putPreferences` answer /// them: its appearance and the themes it added if it set them, and its /// notifications always. fn preferences(index: &Index, did: &str) -> Result { let mut all = Vec::new(); for (kind, named) in [(APPEARANCE, APPEARANCE_PREF), (THEMES, THEMES_PREF)] { if let Some(mut set) = indexed(index.preference(did, kind))? { set["$type"] = named.into(); all.push(set); } } let (off, followed_only) = indexed(index.notification_preferences(did))?; all.push(notification_pref(&off, followed_only)); Ok(answer(serde_json::json!({ "preferences": all }))) } /// `p`, a preference given to `putPreferences`, as the appview keeps it: /// its kind, and only the fields that kind has, each as its lexicon /// bounds it, with a theme named as the pages name theirs, to go in a /// cookie as it is, and only reasons the appview gives; else why not. fn kept(p: &serde_json::Value) -> std::result::Result<(&'static str, serde_json::Value), String> { if p["$type"] == APPEARANCE_PREF { let mut kept = choice(p)?; if let Some(night) = p.get("night") { kept["night"] = choice(night).map_err(|why| format!("night: {why}"))?; } return Ok((APPEARANCE, kept)); } if p["$type"] == THEMES_PREF { let given = p["themes"].as_array().ok_or("themes: a list is wanted")?; if given.len() > ADDED { return Err(format!("themes: {ADDED} at most")); } let each = given .iter() .map(added) .collect::, _>>(); return Ok((THEMES, serde_json::json!({ "themes": each? }))); } if p["$type"] != NOTIFICATION_PREF { let kinds = [APPEARANCE_PREF, NOTIFICATION_PREF, THEMES_PREF].join(", "); return Err(format!("{} is no kind of preference: {kinds}", p["$type"])); } let given = p .get("reasons") .cloned() .unwrap_or_else(|| serde_json::json!([])); let reasons = serde_json::from_value::>(given); let reasons = reasons.map_err(|e| format!("reasons: {e}"))?; let mut off = Vec::new(); for r in reasons { if !REASONS.contains(&r.reason) { return Err(format!( "{:?} is no reason: {}", r.reason, REASONS.join(", ") )); } off.extend((!r.enabled).then_some(r.reason)); } let followed_only = p["followedOnly"].as_bool().unwrap_or(false); let kept = serde_json::json!({ "off": off, "followedOnly": followed_only }); Ok((NOTIFICATIONS, kept)) } /// A theme the pages are drawn in, as `appearancePref` and its `night` /// give it: a theme named as the pages name theirs, to go in a cookie as /// it is, or the colours of one of the account's own, held to their /// lexicon, and where they came from. fn choice(p: &serde_json::Value) -> std::result::Result { let mut kept = serde_json::json!({}); if let Some(theme) = p.get("theme") { let fits = |b: u8| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'; let named = |t: &&str| t.len() <= 64 && t.bytes().all(fits); let why = || format!("{theme} is no theme: one is named in a-z, 0-9 and -, 64 at most"); kept["theme"] = theme.as_str().filter(named).ok_or_else(why)?.into(); } if let Some(palette) = p.get("palette") { kept["palette"] = super::themes::written(palette)?; } if kept["theme"] == "custom" && kept.get("palette").is_none() { return Err("theme custom draws in a palette, and none is given".into()); } if let Some(source) = p.get("source") { kept["source"] = theme_uri(source) .ok_or_else(|| format!("source: {source} is no dev.knotless.theme"))?; } Ok(kept) } /// `given` if it is a dev.knotless.theme's `at://` URI. fn theme_uri(given: &serde_json::Value) -> Option { let theme = |s: &&str| s.starts_with("at://") && s.contains("/dev.knotless.theme/"); let fits = |s: &&str| s.len() <= 512 && !s.contains(char::is_whitespace); given.as_str().filter(theme).filter(fits).map(Into::into) } /// `t`, a theme added to `themesPref`, as its lexicon bounds it: where /// it came from and its colours, with its name, maker and when it was /// added, if given. fn added(t: &serde_json::Value) -> std::result::Result { let uri = &t["uri"]; let why = || format!("themes: {uri} is no dev.knotless.theme"); let mut kept = serde_json::json!({ "uri": theme_uri(uri).ok_or_else(why)? }); kept["palette"] = super::themes::written(&t["palette"]).map_err(|why| format!("themes: {why}"))?; if let Some(name) = t.get("name") { let short = |n: &&str| n.chars().count() <= knotless::theme::NAME; let why = || { format!( "themes: {name} is no name, of {} characters at most", knotless::theme::NAME ) }; kept["name"] = name.as_str().filter(short).ok_or_else(why)?.into(); } if let Some(creator) = t.get("creator") { let did = |d: &&str| d.starts_with("did:") && d.len() <= 2048 && !d.contains(char::is_whitespace); let why = || format!("themes: creator {creator} is no DID"); kept["creator"] = creator.as_str().filter(did).ok_or_else(why)?.into(); } if let Some(at) = t.get("addedAt") { let when = |a: &&str| a.len() <= 64 && a.parse::().is_ok(); let why = || format!("themes: addedAt {at} is no time"); kept["addedAt"] = at.as_str().filter(when).ok_or_else(why)?.into(); } Ok(kept) } /// `dev.knotless.getPreferences`. pub async fn get_preferences( State(app): State>, Extension(viewer): Extension, ) -> Result { let (did, index) = asking(&app, viewer)?; preferences(&index, &did) } #[derive(Deserialize)] pub struct PutPreferences { preferences: Vec, } /// `dev.knotless.putPreferences`: each preference given takes its kind's /// place, and the rest stay. With the pages' cookie, only from the site /// itself. pub async fn put_preferences( State(app): State>, Extension(viewer): Extension, headers: HeaderMap, axum::Json(input): axum::Json, ) -> Result { let token = headers.contains_key(header::AUTHORIZATION); if !token && !crate::login::same_site(&headers) { return Err(Shown(StatusCode::FORBIDDEN, "not from this site".into())); } let (did, index) = asking(&app, viewer)?; let mut set = Vec::new(); for p in &input.preferences { set.push(kept(p).map_err(|why| Shown(StatusCode::BAD_REQUEST, why))?); } for (kind, value) in &set { indexed(index.set_preference(&did, kind, value))?; } preferences(&index, &did) }