~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
600 lines · 21.2 KB Raw History
//! 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)
}