//! Shared lookup for every repo page: resolve owner/name, authorize Read for the viewer, //! and return 404 for both missing and hidden repos so private repos are never revealed. //! A past owner/name (a redirect left by a rename or transfer) gets a 301 to the current URL //! when the viewer may read the repo it now points at. use std::path::PathBuf; use forge_core::authz::{Action, authorize}; use forge_core::{Repo, Role, Session, User, Visibility, WatchLevel}; use forge_git::repo::repo_path; use http::{HeaderValue, header::LOCATION}; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::error::not_found; use topcoat::router::request::uri; use topcoat::router::response::Response; use topcoat::router::{Body, Layer, LayerFuture, Next, Path, StatusCode}; use crate::auth::current_user; use crate::i18n::{self, Lang}; use crate::pages::repo_mirrors::{redacted_url, stamp_in_tz}; use crate::state::AppState; pub struct RepoCtx { pub repo: Repo, /// On-disk path of the bare repository. pub path: PathBuf, pub viewer: Option<(User, Session)>, /// The viewer's collaborator role on this repo; `None` for anonymous viewers and non-members. pub role: Option, /// The viewer's role through the organization that owns the repo (see /// `Store::org_repo_role`); `None` for person-owned repos. pub org_role: Option, /// The viewer's interface language, for the header and the page's strings. pub lang: Lang, } /// The viewer's star control on a repo header. Only signed-in viewers get one. #[derive(Clone)] pub struct StarButton { pub starred: bool, /// The session's CSRF token for the star form. pub csrf: String, } /// Everything the repo header shows, built once per page from the repo, the viewer and the page's /// active tab. `repo_head` renders it, including the archived notice. #[derive(Clone)] pub struct RepoHead { /// The viewer's interface language, for the strings `repo_head` draws. pub lang: Lang, pub owner: String, pub name: String, pub default_branch: String, pub visibility: Visibility, pub description: String, pub archived: bool, /// The current header tab: `code`, `issues`, `pulls`, `commits`, `branches`, `tags` or /// `settings`, or `security`. pub active: &'static str, /// Whether the viewer may open the Settings tab. pub can_admin: bool, /// Open issues, for the Issues tab count. pub open_issues: u64, /// Open pull requests, for the Pull requests tab count. pub open_pulls: u64, /// The source repo's `(owner, name)` when this repo is a fork and the viewer may read the /// source. `None` otherwise, so a hidden parent is never named. pub forked_from: Option<(String, String)>, /// Whether the viewer may fork this repo: signed in and not its owner. pub can_fork: bool, pub stars: u64, /// `None` for anonymous viewers, who see the star count without a button. pub star: Option, /// Repo watchers (level `watching`), for the header count. pub watchers: u64, /// `None` for anonymous viewers, who see the count only. pub watch: Option, /// The pull mirror this repo follows, shown as the "Mirror of" line. `None` for other repos. pub mirror: Option, /// Whether the repo has discussions turned on, for the Discussions tab. pub discussions: bool, /// Whether the repo's wiki is on, for the Wiki tab. pub wiki: bool, /// Whether the Security tab shows: advisories are enabled and the viewer may manage alerts. pub security: bool, } /// The "Mirror of" line's values: the redacted source URL and the last sync, already formatted in the /// viewer's timezone. The URL is host and path only, so credentials and tokens never show. #[derive(Clone)] pub struct MirrorSource { pub url: String, pub last_sync: Option, pub last_ok: Option, } /// The viewer's watch control on a repo header: the current level and the session CSRF token. #[derive(Clone)] pub struct WatchButton { pub level: WatchLevel, pub csrf: String, } /// Raised by `load_repo` when the requested owner/name is a past name of a repo the viewer may /// read. The `repo_moved` layer turns it into a 301 to `location`. #[derive(Debug)] pub struct RepoMoved { pub location: String, } impl std::fmt::Display for RepoMoved { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str("repository moved") } } impl std::error::Error for RepoMoved {} impl RepoCtx { /// Whether the viewer may delete this repo: its owner or an instance admin only. pub fn can_delete(&self) -> bool { let actor = self.viewer.as_ref().map(|(user, _)| user); authorize(actor, &self.repo, None, self.org_role, Action::Delete).is_ok() } /// Whether the viewer may manage the repo's settings (such as branch rules): the owner, an /// instance admin, or a collaborator with the Admin role. pub fn can_admin(&self) -> bool { let actor = self.viewer.as_ref().map(|(user, _)| user); authorize(actor, &self.repo, self.role, self.org_role, Action::Admin).is_ok() } /// Whether the viewer may write to the repo (push, and edit the wiki): a collaborator with the /// Write role or higher, an org role that grants it, or the owner. Archived repos stay read-only. pub fn can_write(&self) -> bool { let actor = self.viewer.as_ref().map(|(user, _)| user); !self.repo.archived && authorize(actor, &self.repo, self.role, self.org_role, Action::Write).is_ok() } /// Open issue count for the Issues tab: one indexed COUNT query. pub async fn open_issues(&self, state: &AppState) -> Result { Ok(state.store.issue_counts(&self.repo.id).await?.0) } /// The source repo of this fork as `(owner, name)`, if the viewer may read it. async fn forked_from(&self, state: &AppState) -> Result> { let Some(source_id) = &self.repo.fork_of else { return Ok(None); }; let Some(source) = state.store.repo_by_id(source_id).await? else { return Ok(None); }; let (role, org_role) = match &self.viewer { Some((user, _)) => ( state.store.collaborator_role(&source.id, &user.id).await?, state .store .org_repo_role(&source.owner_id, &user.id, &source.id) .await?, ), None => (None, None), }; let actor = self.viewer.as_ref().map(|(user, _)| user); if authorize(actor, &source, role, org_role, Action::Read).is_err() { return Ok(None); } Ok(Some((source.owner_name, source.name))) } /// The header for this repo: counts, the fork line, the archived flag and the viewer's star /// state. `active` is the header tab the page is on. pub async fn head(&self, state: &AppState, active: &'static str) -> Result { let stars = state.store.star_count(&self.repo.id).await?; let open_pulls = state.store.pull_counts(&self.repo.id).await?.0; let forked_from = self.forked_from(state).await?; let can_fork = self .viewer .as_ref() .is_some_and(|(user, _)| user.id != self.repo.owner_id); let star = match &self.viewer { Some((user, session)) => Some(StarButton { starred: state.store.is_starred(&user.id, &self.repo.id).await?, csrf: session.csrf_token.clone(), }), None => None, }; let watchers = u64::try_from(state.store.watchers(&self.repo.id).await?.len()).unwrap_or(u64::MAX); let watch = match &self.viewer { Some((user, session)) => Some(WatchButton { level: state.store.watch_level(&user.id, &self.repo.id).await?, csrf: session.csrf_token.clone(), }), None => None, }; let discussions = state.store.discussions_enabled(&self.repo.id).await?; let wiki = state.store.wiki_enabled(&self.repo.id).await?; let security = state.config.advisories.enabled && crate::pages::repo_security::can_write(self); let mirror = state .store .pull_mirror(&self.repo.id) .await? .map(|mirror| MirrorSource { url: redacted_url(&mirror.url), last_sync: mirror .last_sync_at .as_deref() .map(|stamp| stamp_in_tz(stamp, self.viewer_tz())), last_ok: mirror.last_ok, }); Ok(RepoHead { lang: self.lang, owner: self.repo.owner_name.clone(), name: self.repo.name.clone(), default_branch: self.repo.default_branch.clone(), visibility: self.repo.visibility, description: self.repo.description.clone(), archived: self.repo.archived, active, can_admin: self.can_admin(), open_issues: self.open_issues(state).await?, open_pulls, forked_from, can_fork, stars, star, watchers, watch, mirror, discussions, wiki, security, }) } /// Timezone for absolute times: the signed-in viewer's, or UTC for anonymous viewers. pub fn viewer_tz(&self) -> &str { self.viewer .as_ref() .map_or("UTC", |(user, _)| user.timezone.as_str()) } } /// Load `owner/name` for the current viewer, or 404. pub async fn load_repo(cx: &Cx, owner: &str, name: &str) -> Result { let state = app_context::(cx); let viewer = current_user(cx).await?; let Some(repo) = state.store.repo_by_owner_and_name(owner, name).await? else { if let Some(location) = moved_location(cx, state, viewer.as_ref(), owner, name).await? { return Err(RepoMoved { location }.into()); } return Err(not_found().into()); }; let (role, org_role) = match &viewer { Some((user, _)) => ( state.store.collaborator_role(&repo.id, &user.id).await?, state .store .org_repo_role(&repo.owner_id, &user.id, &repo.id) .await?, ), None => (None, None), }; let actor = viewer.as_ref().map(|(user, _)| user); if authorize(actor, &repo, role, org_role, Action::Read).is_err() { return Err(not_found().into()); } let path = repo_path(&state.config.data_dir, &repo.id)?; let lang = i18n::lang(cx, viewer.as_ref().map(|(user, _)| user)); Ok(RepoCtx { repo, path, viewer, role, org_role, lang, }) } /// The URL to send a past owner/name to: the same path and query with the owner and name segments /// replaced. `None` unless the redirect target exists and the viewer may read it, so a hidden repo /// answers exactly like a missing one. async fn moved_location( cx: &Cx, state: &AppState, viewer: Option<&(User, Session)>, owner: &str, name: &str, ) -> Result> { let Some(target) = state.store.repo_by_redirect(owner, name).await? else { return Ok(None); }; let (role, org_role) = match viewer { Some((user, _)) => ( state.store.collaborator_role(&target.id, &user.id).await?, state .store .org_repo_role(&target.owner_id, &user.id, &target.id) .await?, ), None => (None, None), }; let actor = viewer.map(|(user, _)| user); if authorize(actor, &target, role, org_role, Action::Read).is_err() { return Ok(None); } // The path is `/{owner}/{name}[/rest]`; `splitn` keeps the rest (and any trailing slash) as is. let request = uri(cx); let rest = request .path() .splitn(4, '/') .nth(3) .map(|rest| format!("/{rest}")) .unwrap_or_default(); let query = request .query() .map(|query| format!("?{query}")) .unwrap_or_default(); Ok(Some(format!( "/{}/{}{rest}{query}", target.owner_name, target.name ))) } /// Wraps every request (no path) so a `RepoMoved` error becomes a 301. Registered explicitly on the /// public router in `boot.rs`: a `#[layer("/")]` would clash with the security-headers layer. pub struct RepoMovedLayer; impl Layer for RepoMovedLayer { fn path(&self) -> Option<&Path> { None } fn handle<'a>(&'a self, cx: &'a Cx, body: Body, next: Next<'a>) -> LayerFuture<'a> { Box::pin(repo_moved(cx, body, next)) } } /// Turns a `RepoMoved` error from a handler into a 301 with the new location. Other errors pass /// through unchanged. async fn repo_moved(cx: &Cx, body: Body, next: Next<'_>) -> Result { match next.run(cx, body).await { Ok(response) => Ok(response), Err(error) => match error.downcast_ref::() { Some(moved) => { let mut response = Response::new(Body::empty()); *response.status_mut() = StatusCode::MOVED_PERMANENTLY; // Owner and repo names are plain ASCII and the query came from a valid URI, so this // only fails on a corrupt location; a 404 is the safe answer then. let Ok(location) = HeaderValue::from_str(&moved.location) else { return Err(not_found().into()); }; response.headers_mut().insert(LOCATION, location); Ok(response) } None => Err(error), }, } }