//! Private repositories, on a server people log in to: each read as the //! viewer asking, with the session they logged in to the pages with, and //! never as this server. //! //! A query is answered within [`reading`], which names its viewer: the //! account the session cookie or service token proves, or no one. A //! repository no one may read publicly is then looked for in its space as //! that viewer ([`App::as_viewer`]), whose own PDS mints the delegation //! token and whose membership the owner's PDS checks as it issues the //! credential. What is found is kept for that viewer alone: its mirror //! under their cache, its entry under their DID. Anything else reads as no //! one, and so finds public repositories only: a card, a clone, a task //! spawned to refresh a mirror. //! //! Membership is asked again, with a fresh credential, once the last //! answer is older than [`REPROVE`], so someone removed from a repository //! stops reading it here within that; in between it is read as found //! then, with no round trip to find it again. Logging out forgets what the //! viewer was shown at once ([`forget`]). //! //! An answer that read anything private is the viewer's alone, kept by no //! cache, the browser's included ([`unshared`]). //! //! A private repository read here is indexed here: its space is synced //! into [`App::spaces`] with the session of the member who last read it //! ([`crate::spaces`]), first as they read it, and its pulls, issues and //! checks are read from that, as a public repository's are from the //! appview's index, by viewers proven members as above. Its owner's own //! are found as they log in or look at their profile //! ([`crate::spaces::discover`]), so their list of repositories is read //! from the index too, never from their PDS as they look. use crate::{App, AppError, Located}; use axum::http::{HeaderValue, StatusCode, header}; use axum::response::Response; use knotless::env::Ctx; use knotless::remote::Remote; use knotless::space::Space; use knotless::url::RepoUrl; use std::future::Future; use std::sync::Mutex; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; /// How long a viewer's reading of a private repository is taken as /// allowed before the owner's PDS is asked again: how long someone removed /// from it may still read it here. pub const REPROVE: Duration = Duration::from_secs(60); /// Who a request is answered for, and whether the answer read anything /// private. struct Reading { viewer: Mutex>, delegated: Option, private: AtomicBool, } /// A delegation token a query came with, checked and spent here /// ([`App::spend`]): it reads the one private repository whose space it /// names, as the member it names, with no session of theirs here. #[derive(Clone)] pub struct Delegated { pub did: String, pub space: String, pub token: String, } tokio::task_local! { static READING: Reading; } /// `answer`, as `viewer` reads, or as `delegated` names once it proves /// them a member, and whether it read anything private. pub async fn reading( viewer: Option, delegated: Option, answer: F, ) -> (F::Output, bool) { let reading = Reading { viewer: Mutex::new(viewer), delegated, private: AtomicBool::new(false), }; let read = async { let out = answer.await; (out, READING.with(|r| r.private.load(Ordering::Relaxed))) }; READING.scope(reading, read).await } /// The viewer of the request being answered: none outside [`reading`]. pub fn viewer() -> Option { READING .try_with(|r| r.viewer.lock().unwrap().clone()) .ok() .flatten() } /// The delegation token the request being answered came with, if it is /// for the space `uri`, and from its viewer if it has one. pub fn delegated(uri: &str) -> Option { let found = READING.try_with(|r| r.delegated.clone()).ok().flatten()?; let theirs = viewer().is_none_or(|v| v == found.did); (found.space == uri && theirs).then_some(found) } /// Takes `did`, whom a delegation token proved a member of what it reads, /// as the request's viewer from now on. fn proven(did: &str) { let _ = READING.try_with(|r| *r.viewer.lock().unwrap() = Some(did.to_string())); } /// Marks the answer being made as holding something private. pub fn touched() { let _ = READING.try_with(|r| r.private.store(true, Ordering::Relaxed)); } /// `res`, for its viewer alone, and kept by no cache at all. pub fn unshared(mut res: Response) -> Response { res.headers_mut().insert( header::CACHE_CONTROL, HeaderValue::from_static("private, no-store"), ); res } /// Forgets what `did` was shown of private repositories, and the /// credentials and mirrors it was read with: as they log out. Which /// spaces they were proven to read is kept, to be asked again at their /// next login. pub fn forget(app: &App, did: &str) { app.viewed.lock().unwrap().retain(|(v, _, _), _| v != did); if let Some(index) = &app.spaces { let _ = index.drop_syncer(did); } let ctx = crate::login::ctx_for(app, did); knotless::space::forget(&ctx); let _ = std::fs::remove_dir_all(&ctx.env.cache_dir); } type Viewed = (String, String, String); impl App { /// Whether `viewer` was shown `did`'s repository `name` as private. pub(crate) fn shown_private(&self, viewer: &str, did: &str, name: &str) -> bool { let key = (viewer.to_string(), did.to_string(), name.to_string()); self.viewed.lock().unwrap().contains_key(&key) } /// `did`'s private repository `url`, read as `viewer`: `missing` (or /// that there is no such repository) if they may not read it, or it is /// not private, so that a non-member learns nothing of it. With /// `delegated`, a delegation token of theirs for its space, they are /// proven a member with it rather than with a session of theirs here. pub(crate) async fn as_viewer( &self, viewer: &str, did: &str, url: &RepoUrl, missing: Option, delegated: Option<&Delegated>, ) -> Result { let none = format!("{url}: no such repository"); let missing = missing.unwrap_or(AppError(StatusCode::NOT_FOUND, none)); let ctx = crate::login::ctx_for(self, viewer); let key = (viewer.to_string(), did.to_string(), url.name.clone()); let entry = self .viewed .lock() .unwrap() .entry(key.clone()) .or_default() .clone(); let mut entry = entry.lock().await; let reprove = Duration::from_millis(self.reprove.load(Ordering::Relaxed)); let stale = entry.proved.is_none_or(|at| at.elapsed() >= reprove); // Between provings the space is read as it was found: its owner's // session and a member's credential each last a minute more. let remote = match entry.remote.clone().filter(|_| !stale) { Some(remote) => remote, None => { // A credential is asked for afresh, which the owner's PDS // gives only to a member. let opened = match delegated { Some(d) => Remote::delegated(&ctx, url, viewer, &d.token).await, None => { knotless::space::forget(&ctx); Remote::open(&ctx, url).await } }; match opened { Ok(remote) if remote.space.is_some() => remote, // Not private, or not theirs to read: as if there were // none. other => { let failed = other.err().filter(|e| !knotless::space::refused(e)); if failed.is_none() { self.reads(viewer, &knotless::space::uri(did, &url.name), false); } let e = failed.map_or(missing, AppError::upstream); return self.unview(&key, Err(e)); } } } }; let space = entry.space.clone().unwrap_or_default(); let moves = self.moves_of(&space); let entry_key = entry.key(did); let any = self.events_for(did); let events = self.stamp(did, entry_key.as_deref()); // A space no one here has a session for is synced only as it is // read, with what proved its reader a member. let followed = self.space_followed(&space); let fresh = match (&entry.git, entry.synced) { // Nothing is written to a space but its sync finds, within // seconds, or the pages touch. (Some(_), Some(_)) if followed => entry.moves == moves && entry.events == events, (Some(_), Some(at)) => at.elapsed() < self.ttl && entry.events == events, _ => false, }; // No `?` ends a block, which coverage would count as a line never // run. let mut read = Ok(()); if !fresh { read = self.sync(&ctx, remote.clone(), url, &mut entry).await; } if !fresh && read.is_ok() { entry.moves = moves; let found = entry.key(did); entry.events = self.settled(did, entry_key.as_deref(), events, any, found.as_deref()); } // Fresh or not, it is synced as whoever read it last: once its // syncer logs out, the next to read it takes over. A reader with a // delegation token has no session to sync it with after: it is // synced with their credential as they read it, when nobody's // session syncs it. if read.is_ok() { let space = entry.space.clone().unwrap_or_default(); read = match &remote.space { Some(joined) if delegated.is_some() => self.keep_read(&ctx, joined).await, _ => self.keep(&space, viewer).await, }; } self.unview(&key, read)?; // Theirs to read, it is in their lists from now on. if viewer != did { self.reads(viewer, entry.space.as_deref().unwrap_or_default(), true); } if stale { entry.proved = Some(Instant::now()); entry.remote = Some(remote); } if delegated.is_some() { proven(viewer); } touched(); Ok(self.found(&entry, did, ctx)) } /// Keeps an index of the private repository in the space `joined`, as /// a member reads it with a delegation token: synced now with their /// credential if it never was, or if no session here syncs it. A client /// shows the appview each private repository it writes to so /// ([`knotless::space::show_appview`]), so what it wrote is heard of. async fn keep_read(&self, ctx: &Ctx, joined: &Space) -> Result<(), AppError> { let none = "this server keeps no index of private repositories"; let none = AppError(StatusCode::INTERNAL_SERVER_ERROR, none.into()); let index = self.spaces.as_ref().ok_or(none)?; let uri = joined.uri.as_str(); let kept = index.keep_space(uri).map_err(anyhow::Error::from); kept.map_err(AppError::internal)?; let synced = index.space_synced(uri).map_err(anyhow::Error::from); let synced = synced.map_err(AppError::internal)?; // Synced as the reader, too, when its syncer has not lately. let sync = !synced || self.syncer_of(uri).is_none() || self.sync_stale(uri); if !sync { return Ok(()); } let read = crate::spaces::sync_with(ctx, index, joined).await; self.read_now(uri, read, synced) } /// What a reader's sync of the space `uri` makes of their read, with /// `synced` whether it was ever synced before. A sync that failed /// counts as one made, so it is tried again only after /// [`STALE`](crate::spaces::STALE), not at each read; and a space /// already synced is then read from its index as it was, the failure /// logged (`space not synced`), rather than failing the read. One never /// synced has nothing to be read from, and its failure is the read's. fn read_now( &self, uri: &str, read: anyhow::Result, synced: bool, ) -> Result<(), AppError> { if read.is_ok() || synced { self.synced_now(uri); } if matches!(read, Ok(true)) { self.moved(uri); } let read = read.map(drop); let (logged, read) = if synced { (read, Ok(())) } else { (Ok(()), read) }; crate::telemetry::space_synced(uri, logged); read.map_err(AppError::upstream) } /// Notes whether `viewer` may read the private repository in the /// space `uri`, for their lists of repositories ([`Index::read_of`]). /// /// [`Index::read_of`]: crate::index::Index::read_of pub(crate) fn reads(&self, viewer: &str, uri: &str, may: bool) { let Some(index) = &self.spaces else { return }; let _ = match may { true => index.read_by(uri, viewer), false => index.unread_by(uri, viewer), }; } /// Whether the space `uri` is synced as its authority tells of writes /// there, so that it is current until a sync of it finds one /// ([`App::moves_of`]). pub(crate) fn space_followed(&self, uri: &str) -> bool { self.following.load(Ordering::Relaxed) && self.syncer_of(uri).is_some() } /// The member whose session here syncs the space `uri`, if any. fn syncer_of(&self, uri: &str) -> Option { let index = self.spaces.as_ref()?; index.syncer_of(uri).ok().flatten() } /// Keeps an index of the private repository in `space`, synced as /// `syncer`, a member, who reads it: synced now if it never was, so its /// pages read from the index from their first look. pub async fn keep(&self, space: &str, syncer: &str) -> Result<(), AppError> { let none = "this server keeps no index of private repositories"; let none = AppError(StatusCode::INTERNAL_SERVER_ERROR, none.into()); let index = self.spaces.as_ref().ok_or(none)?; let followed = index.follow_space(space, syncer); followed .map_err(anyhow::Error::from) .map_err(AppError::internal)?; self.follow_org(space, syncer); let synced = index.space_synced(space); let synced = synced.map_err(anyhow::Error::from); let synced = synced.map_err(AppError::internal)?; // Synced, and lately: what was written since its sync will reach it. if synced && !self.sync_stale(space) { return Ok(()); } let ctx = crate::login::syncer_ctx(self, syncer); // Synced before, it is read as it was while it syncs aside, as a // sync reads the whole space and can take seconds: only one never // synced has nothing to read until its sync is done. Taken as // synced now, so reads meanwhile start no other. if synced { self.synced_now(space); let (moves, last) = (self.moves.clone(), self.last_synced.clone()); let (index, space) = (index.clone(), space.to_string()); tokio::spawn(async move { let read = crate::spaces::sync(&ctx, &index, &space).await; last.lock().unwrap().insert(space.clone(), Instant::now()); if matches!(read, Ok(true)) { *moves.lock().unwrap().entry(space.clone()).or_default() += 1; } crate::telemetry::space_synced(&space, read.map(drop)); }); return Ok(()); } let read = crate::spaces::sync(&ctx, index, space).await; self.read_now(space, read, synced) } /// Stops syncing the space `uri`, and forgets what it gave: its sync /// failed, as it does once its syncer is no longer a member. pub fn unfollow(&self, index: &crate::index::Index, uri: &str) { let _ = index.forget_space(uri); self.moved(uri); } /// The spaces to sync, each with the member whose session it is synced /// with. pub fn syncers(&self) -> Vec<(String, String)> { let index = self.spaces.as_ref(); let syncers = index.map(|i| i.syncers().unwrap_or_default()); syncers.unwrap_or_default() } /// `read`, having forgotten that the viewer `key` names was shown its /// repository if reading it again failed. fn unview(&self, key: &Viewed, read: Result) -> Result { if read.is_err() { self.viewed.lock().unwrap().remove(key); } read } }