johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
//! 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<Vec<String>> = 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<Arc<App>> {
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<String> {
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::<BadInput>().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<T>(done: rusqlite::Result<T>) -> Result<T> {
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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pronouns: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
links: Vec<knotless::records::ProfileLink>,
#[serde(skip_serializing_if = "Option::is_none")]
avatar: Option<String>,
#[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<Arc<App>>,
Extension(viewer): Extension<Viewer>,
) -> Result<Response> {
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<String>,
description: Option<String>,
pronouns: Option<String>,
links: Option<Vec<knotless::records::ProfileLink>>,
#[serde(default)]
remove_avatar: bool,
bot: Option<bool>,
}
/// `dev.knotless.updateProfile`.
pub async fn update_profile(
State(app): State<Arc<App>>,
Extension(viewer): Extension<Viewer>,
headers: HeaderMap,
axum::Json(input): axum::Json<ProfileInput>,
) -> Result<Response> {
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<Arc<App>>,
Extension(viewer): Extension<Viewer>,
headers: HeaderMap,
body: axum::body::Bytes,
) -> Result<Response> {
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<Response> {
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<Arc<App>>,
Extension(viewer): Extension<Viewer>,
) -> Result<Response> {
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<Arc<App>>,
Extension(viewer): Extension<Viewer>,
headers: HeaderMap,
axum::Json(input): axum::Json<EmailInput>,
) -> Result<Response> {
emailing(&app, viewer, &headers, &input.email, true).await
}
/// `dev.knotless.removeEmail`.
pub async fn remove_email(
State(app): State<Arc<App>>,
Extension(viewer): Extension<Viewer>,
headers: HeaderMap,
axum::Json(input): axum::Json<EmailInput>,
) -> Result<Response> {
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<Response> {
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<RepoViewBasic>,
created_at: String,
}
#[derive(Serialize)]
pub struct BlocksOutput {
blocks: Vec<BlockView>,
}
/// `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<Arc<App>>,
Extension(viewer): Extension<Viewer>,
) -> Result<Response> {
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<String>,
}
/// `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<Arc<App>>,
Extension(viewer): Extension<Viewer>,
headers: HeaderMap,
axum::Json(input): axum::Json<BlockInput>,
) -> Result<Response> {
blocking(&app, viewer, &headers, input, true).await
}
/// `dev.knotless.unblockActor`.
pub async fn unblock_actor(
State(app): State<Arc<App>>,
Extension(viewer): Extension<Viewer>,
headers: HeaderMap,
axum::Json(input): axum::Json<BlockInput>,
) -> Result<Response> {
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<Response> {
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<RepoUrl> {
let parsed = forge::parse_uri(uri).ok();
let Some((did, nsid::REPO, name)) = parsed else {
let why = format!("{uri} is not at://<did>/{}/<name>", 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<Preference>,
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<Response> {
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::<std::result::Result<Vec<_>, _>>();
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::<Vec<Preference>>(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<serde_json::Value, String> {
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<serde_json::Value> {
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<serde_json::Value, String> {
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::<jiff::Timestamp>().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<Arc<App>>,
Extension(viewer): Extension<Viewer>,
) -> Result<Response> {
let (did, index) = asking(&app, viewer)?;
preferences(&index, &did)
}
#[derive(Deserialize)]
pub struct PutPreferences {
preferences: Vec<serde_json::Value>,
}
/// `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<Arc<App>>,
Extension(viewer): Extension<Viewer>,
headers: HeaderMap,
axum::Json(input): axum::Json<PutPreferences>,
) -> Result<Response> {
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)
}