johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
//! 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),
})
}