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

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
165 lines · 5.7 KB Raw History
//! What an account chose to hear of a repository, a pull or an issue:
//! `dev.knotless.putSubscription` and `listSubscriptions`, for the account
//! a service token or the pages' session cookie names. The appview keeps
//! each choice for that account alone, as it keeps its preferences: it is
//! how one reader wants its notifications read, nothing anyone else needs,
//! and a record would publish it. [`Index::notifications`] reads them.

use super::notifications::asking;
use super::repo::{found, invalid};
use super::*;
use crate::index::{PARTICIPATING, Subscription};
use crate::viewer::Viewer;
use axum::Extension;

/// The most choices the appview keeps for one account.
pub const MAX: usize = 1000;
/// What a choice past [`MAX`] is answered.
const FULL: &str = "1000 choices at most: forget one first, as participating";

/// The levels a repository takes, past [`PARTICIPATING`].
const REPO_LEVELS: [&str; 3] = ["all", "releases", "ignore"];
/// The levels a pull or an issue takes, past [`PARTICIPATING`].
const THREAD_LEVELS: [&str; 2] = ["subscribed", "muted"];

/// The procedure and the query.
pub fn routes() -> Router<Arc<App>> {
    Router::new()
        .route("/xrpc/dev.knotless.putSubscription", post(put_subscription))
        .route(
            "/xrpc/dev.knotless.listSubscriptions",
            get(list_subscriptions),
        )
}

#[derive(Deserialize)]
pub struct PutInput {
    subject: String,
    level: String,
}

/// `dev.knotless.listSubscriptions#subscription`.
#[derive(Serialize, Debug, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SubscriptionView {
    subject: String,
    level: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    created_at: Option<String>,
}

/// 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)?)
}

/// `dev.knotless.putSubscription`. With the pages' cookie, only from the
/// site itself.
pub async fn put_subscription(
    State(app): State<Arc<App>>,
    Extension(viewer): Extension<Viewer>,
    headers: HeaderMap,
    axum::Json(input): axum::Json<PutInput>,
) -> 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 (subject, levels) = match forge::parse_uri(&input.subject) {
        Ok((_, nsid::REPO, _)) => {
            let repo = found(&app, &input.subject).await?;
            (Subscription::repo(&repo.did, &repo.key), &REPO_LEVELS[..])
        }
        Ok((author, collection @ (nsid::PULL | nsid::ISSUE), rkey)) => (
            format!("at://{author}/{collection}/{rkey}"),
            &THREAD_LEVELS[..],
        ),
        _ => {
            let why = format!("{} is no repository, pull or issue", input.subject);
            return Err(invalid(why));
        }
    };
    let level = input.level.as_str();
    if level != PARTICIPATING && !levels.contains(&level) {
        let why = format!(
            "{level:?} is no level for it: {PARTICIPATING}, {}",
            levels.join(", ")
        );
        return Err(invalid(why));
    }
    let kept = indexed(index.subscribe(&did, &subject, level, &knotless::records::now(), MAX))?;
    kept.then_some(()).ok_or(invalid(FULL.into()))?;
    let kept = indexed(index.subscriptions(&did))?;
    let now = kept.into_iter().find(|s| s.subject == subject);
    Ok(answer(SubscriptionView {
        subject: input.subject,
        level: now
            .as_ref()
            .map_or(PARTICIPATING.into(), |s| s.level.clone()),
        created_at: now.map(|s| s.at),
    }))
}

#[derive(Deserialize)]
pub struct ListParams {
    limit: Option<usize>,
    cursor: Option<String>,
}

/// `dev.knotless.listSubscriptions`.
pub async fn list_subscriptions(
    State(app): State<Arc<App>>,
    Extension(viewer): Extension<Viewer>,
    Query(p): Query<ListParams>,
) -> Result<Response> {
    let (did, index) = asking(&app, viewer)?;
    let limit = p.limit.unwrap_or(50).clamp(1, 100);
    // An empty cursor is none, as a client paging from the start sends.
    let given = p.cursor.as_deref().filter(|c| !c.is_empty());
    let skip = match given.map(str::parse::<usize>) {
        None => 0,
        Some(Ok(skip)) => skip,
        Some(Err(_)) => return Err(invalid("cursor is no count".into())),
    };
    let all = indexed(index.subscriptions(&did))?;
    let more = all.len() > skip + limit;
    let mut subscriptions = Vec::new();
    for s in all.into_iter().skip(skip).take(limit) {
        subscriptions.extend(shown(&app, &index, s));
    }
    let cursor = more.then(|| (skip + limit).to_string());
    Ok(answer(ListOutput {
        subscriptions,
        cursor,
    }))
}

#[derive(Serialize)]
pub struct ListOutput {
    subscriptions: Vec<SubscriptionView>,
    #[serde(skip_serializing_if = "Option::is_none")]
    cursor: Option<String>,
}

/// `s` as `listSubscriptions` gives it: a repository by the name it has
/// now, in the index or this server's index of its space; none for one
/// neither has.
fn shown(app: &App, index: &Index, s: Subscription) -> Option<SubscriptionView> {
    let subject = match s.repo_of() {
        Some((did, key)) => {
            let indexes = std::iter::once(index).chain(app.spaces.as_deref());
            let name = indexes
                .filter_map(|i| i.name(did, key).ok().flatten())
                .next()?;
            forge::repo_uri(did, &name)
        }
        None => s.subject.clone(),
    };
    Some(SubscriptionView {
        subject,
        level: s.level,
        created_at: Some(s.at),
    })
}