//! Who is asking, and stars: `dev.knotless.getViewer`, `starRepo` and //! `unstarRepo`. The pages star as the account logged in to them, with //! the session the server keeps for it ([`crate::login`]), through the //! same code as `knotless repo star`. use super::*; use crate::viewer::Viewer; use axum::Extension; use knotless::social; /// `dev.knotless.getViewer`'s answer. #[derive(Serialize)] pub struct ViewerOutput { #[serde(skip_serializing_if = "Option::is_none")] actor: Option, login: bool, /// The other accounts the browser asking is logged in to. #[serde(skip_serializing_if = "Vec::is_empty")] accounts: Vec, /// Set for an operator, whom `KNOTLESS_ADMIN_DIDS` names. #[serde(skip_serializing_if = "std::ops::Not::not")] admin: bool, /// Whether their PDS has spaces, for private repositories; absent /// until known. #[serde(skip_serializing_if = "Option::is_none")] spaces: Option, /// Set when this server's gateway writes for the account asking. #[serde(skip_serializing_if = "std::ops::Not::not")] delegated: bool, /// Set when the account asking is an organization. #[serde(skip_serializing_if = "std::ops::Not::not")] organization: bool, /// Set when their login to the pages is older than what they ask for. #[serde(skip_serializing_if = "std::ops::Not::not")] reauth: bool, /// Set when the gateway writes for them under an older grant than it /// asks for now. #[serde(skip_serializing_if = "std::ops::Not::not")] redelegate: bool, } /// `dev.knotless.getViewer`. pub async fn get_viewer( State(app): State>, Extension(viewer): Extension, headers: HeaderMap, ) -> Response { let actor = match &viewer.0 { Some(did) => Some(actor(&app, did).await), None => None, }; let mut accounts = Vec::new(); for did in crate::login::accounts(&app, &headers) { accounts.push(super::actor(&app, &did).await); } let login = app.gateway.is_some(); let spaces = viewer.0.as_deref().and_then(|did| app.has_spaces(did)); if let (Some(did), None) = (&viewer.0, spaces) { // Known for a look after: as they logged in, unless before this // server last started. crate::spaces::discover(&app, did); } let admin = (viewer.0.as_ref()).is_some_and(|did| app.ctx.env.admin_dids.contains(did)); let delegated = (viewer.0.as_ref()).is_some_and(|did| crate::gateway::writes_for(&app, did)); let organization = (viewer.0.as_ref()).is_some_and(|did| super::orgs::is_org(&app, did)); let reauth = (viewer.0.as_ref()).is_some_and(|did| crate::login::behind(&app, did)); let redelegate = (viewer.0.as_ref()).is_some_and(|did| crate::gateway::grant_behind(&app, did)); answer(ViewerOutput { actor, login, accounts, admin, delegated, spaces, organization, reauth, redelegate, }) } /// `dev.knotless.defs#starState`. #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct StarState { star_count: u64, #[serde(skip_serializing_if = "Option::is_none")] star: Option, } #[derive(Deserialize)] pub struct StarInput { repo: String, } /// `dev.knotless.starRepo`. pub async fn star_repo( State(app): State>, Extension(viewer): Extension, headers: HeaderMap, axum::Json(input): axum::Json, ) -> Result { starring(&app, viewer, &headers, &input.repo, true).await } /// `dev.knotless.unstarRepo`. pub async fn unstar_repo( State(app): State>, Extension(viewer): Extension, headers: HeaderMap, axum::Json(input): axum::Json, ) -> Result { starring(&app, viewer, &headers, &input.repo, false).await } /// The account logged in to the pages, for a write from them: refused /// from another site, or for anyone without a session here to `act` with. pub(super) fn logged_in( app: &App, viewer: Viewer, headers: &HeaderMap, act: &str, ) -> Result { if !crate::login::same_site(headers) { return Err(Shown(StatusCode::FORBIDDEN, "not from this site".into())); } // The pages' writes are made only with their cookie: a service token // names someone, who may have a session here, but is not them asking. let token = headers.contains_key(header::AUTHORIZATION); let kept = |did: &String| session::load(&crate::login::ctx_for(app, did), did).is_some(); let Some(did) = viewer.0.filter(|_| !token).filter(kept) else { let why = format!("log in to this site's pages to {act}"); return Err(Shown(StatusCode::UNAUTHORIZED, why)); }; Ok(did) } /// Stars the repository `uri` names as the account logged in to the /// pages, or takes its star back, and indexes what that wrote at once, so /// the count it answers, and the page after, have it. async fn starring( app: &App, viewer: Viewer, headers: &HeaderMap, uri: &str, on: bool, ) -> Result { let did = logged_in(app, viewer, headers, "star")?; let ctx = crate::login::ctx_for(app, &did); let found = repo::found(app, uri).await?; let public = Some(found).filter(|r| r.in_space.is_none()); let private = anyhow::anyhow!("{uri}: no such public repository"); let repo = public.ok_or(not_found(private))?; let url = RepoUrl { authority: repo.did.clone(), name: repo.name.clone(), }; let starred = social::star(&ctx, &url, on) .await .map_err(AppError::upstream)?; let index = app.index.as_ref(); for rkey in &starred.deleted { let deleted = index.map(|i| i.delete(&did, nsid::STAR, rkey)); deleted .transpose() .map_err(anyhow::Error::from) .map_err(AppError::internal)?; } if let Some((rkey, record)) = &starred.mark { let value = knotless::records::typed(nsid::STAR, record); let put = index.map(|i| i.put(&did, nsid::STAR, rkey, &value)); put.transpose() .map_err(anyhow::Error::from) .map_err(AppError::internal)?; } let count = index.map(|i| i.star_count(&repo.did, &repo.key)); let star_count = count .transpose() .map_err(anyhow::Error::from) .map_err(AppError::internal)?; Ok(answer(StarState { star_count: star_count.unwrap_or_default(), star: starred.uri(), })) }