// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! Repository browsing: the user page, repo home (README/tree), tree and blob //! views, raw content, branches, tags, and the commit list. //! //! Group nesting makes repo paths variable-length, so `/{owner}/{*rest}` is a //! single catch-all that splits `rest` on the GitLab `/-/` separator: everything //! before is the repo (or group) path, everything after is the view. Access is //! resolved once through [`auth::access`]; no access to a private repo renders //! `404`, never `403`, so existence never leaks. use std::collections::HashMap; use std::path::Path as FsPath; use axum::Form; use axum::extract::{Path, Query, State}; use axum::http::{HeaderMap, Uri, header}; use axum::response::{Html, IntoResponse, Redirect, Response}; use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; use maud::{Markup, html}; use model::{Repo, User, Visibility}; use serde::Deserialize; use crate::AppState; use crate::diff::{self, RenderedFile}; use crate::error::{AppError, AppResult}; use crate::icons::{Icon, icon}; use crate::layout::page; use crate::markdown; use crate::pages::build_chrome; use crate::session::{MaybeUser, verify_csrf}; /// The diff-view preference cookie (`unified` | `split`). const DIFF_COOKIE: &str = "fabrica_diffview"; /// Query parameters for the commit view and its context-expansion partial. #[derive(Debug, Default, Deserialize)] pub struct DiffQuery { /// `unified` or `split`. view: Option, /// The file whose context to expand. file: Option, /// The 1-based first line of the context window. from: Option, /// The 1-based last line of the context window. to: Option, /// The smart-HTTP service, for `…​.git/info/refs?service=`. service: Option, /// The search query, for the in-repo `/-/search` view. #[serde(default)] q: Option, } /// The candidate README filenames, in lookup order. const README_NAMES: &[&str] = &["README.md", "README", "readme.md", "README.txt"]; /// Which repo tab is active, for highlighting. #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum Tab { Code, Issues, Pulls, Commits, Branches, Tags, Releases, Search, Settings, } /// A resolved repository plus the viewer's access to it. pub(crate) struct RepoCtx { pub(crate) owner: User, pub(crate) repo: Repo, /// The viewer's access level, used to gate the Settings tab and its actions. /// Every browse view requires only `Read`. pub(crate) access: auth::Access, /// The signed-in viewer's id, if any (gates the Fork action). pub(crate) viewer_id: Option, /// Whether the viewer has bookmarked this repo. pub(crate) bookmarked: bool, /// The parent repo's `(owner_username, path)` when this repo is a fork. pub(crate) fork_parent: Option<(String, String)>, } /// Run a blocking libgit2 read for a repo on the blocking pool, opening the repo /// per operation (§3.7 threading). pub(crate) async fn git_read(state: &AppState, repo_id: &str, f: F) -> AppResult where F: FnOnce(&git::Repo) -> Result + Send + 'static, T: Send + 'static, { let repo_dir = state.config.storage.repo_dir.clone(); let repo_id = repo_id.to_string(); tokio::task::spawn_blocking(move || { let path = git::repo_path(&repo_dir, &repo_id)?; let repo = git::Repo::open_path(&path)?; f(&repo) }) .await .map_err(AppError::internal)? .map_err(AppError::from) } /// The local branch names for the switcher, empty on any error (an empty repo or /// a failed read simply yields no dropdown). pub(crate) async fn branch_names(state: &AppState, repo_id: &str) -> Vec { git_read(state, repo_id, git::Repo::branch_names) .await .unwrap_or_default() } /// Resolve `(owner, path)` to a repo and check access, or `Ok(None)` if the owner /// or repo does not exist (so the caller can try a group listing). /// /// Denied access to an existing private repo returns `Err(NotFound)` — identical /// to a missing repo, so private existence never leaks. pub(crate) async fn resolve( state: &AppState, owner_name: &str, repo_path: &str, viewer: Option<&User>, ) -> AppResult> { let Some(owner) = state.store.user_by_username(owner_name).await? else { return Ok(None); }; let Some(repo) = state.store.repo_by_owner_path(&owner.id, repo_path).await? else { return Ok(None); }; let viewer_id = viewer.map(|u| auth::Viewer { id: u.id.clone(), is_admin: u.is_admin, }); let collaborator = match viewer { Some(u) => state .store .effective_permission(&repo.id, &u.id) .await? .and_then(|p| auth::Permission::parse(&p)), None => None, }; let access = auth::access( viewer_id.as_ref(), &repo, collaborator, state.allow_anonymous(), ); if access == auth::Access::None { return Err(AppError::NotFound); } // Resolve the fork parent's owner/path for the "forked from" line (best-effort). let fork_parent = match &repo.fork_parent_id { Some(pid) => resolve_fork_parent(state, pid).await, None => None, }; let bookmarked = match viewer { Some(u) => state .store .is_bookmarked(&u.id, &repo.id) .await .unwrap_or(false), None => false, }; Ok(Some(RepoCtx { owner, repo, access, viewer_id: viewer.map(|u| u.id.clone()), bookmarked, fork_parent, })) } /// Resolve a parent repo id to its `(owner_username, path)` for display. async fn resolve_fork_parent(state: &AppState, parent_id: &str) -> Option<(String, String)> { let parent = state.store.repo_by_id(parent_id).await.ok().flatten()?; let owner = state .store .user_by_id(&parent.owner_id) .await .ok() .flatten()?; Some((owner.username, parent.path)) } /// A listing filter query (`?q=`), shared by the profile and explore views. #[derive(Debug, Default, Deserialize)] pub struct ListQuery { /// Case-insensitive substring filter over repository paths. #[serde(default)] pub q: Option, /// Active profile tab: `repos` (default), `groups`, or `bookmarks`. #[serde(default)] pub tab: Option, } /// Paging state parsed from `?page=&per_page=`, for long lists (commits, …). #[derive(Debug, Clone, Copy)] pub(crate) struct Pagination { /// 1-based page number. pub page: usize, /// Items per page. pub per_page: usize, } impl Pagination { /// The default page size. pub(crate) const DEFAULT_PER_PAGE: usize = 10; /// The offered page sizes. const CHOICES: [usize; 4] = [10, 25, 50, 100]; /// Parse from a raw query string, clamping to sane bounds. pub(crate) fn from_query(query: Option<&str>) -> Self { let (mut page, mut per_page) = (1, Self::DEFAULT_PER_PAGE); if let Some(q) = query { for (k, v) in url::form_urlencoded::parse(q.as_bytes()) { match k.as_ref() { "page" => page = v.parse::().unwrap_or(1).max(1), "per_page" => { per_page = v .parse::() .unwrap_or(Self::DEFAULT_PER_PAGE) .clamp(1, 100); } _ => {} } } } Self { page, per_page } } /// The zero-based offset of the current page. pub(crate) fn offset(self) -> usize { (self.page - 1) * self.per_page } } /// Slice an already-fetched list to the current page, returning the page's items /// and whether a next page exists. For lists that are filtered/searched in Rust /// (so SQL `LIMIT`/`OFFSET` cannot bound them), this keeps the rendered output /// bounded even when the full set is large. pub(crate) fn page_slice(items: &[T], p: Pagination) -> (Vec, bool) { let start = p.offset().min(items.len()); let end = start.saturating_add(p.per_page).min(items.len()); let has_next = end < items.len(); (items[start..end].to_vec(), has_next) } /// Render prev/next controls and a page-size selector for a paginated list. /// /// `path` is the list URL and `query` its current query string; every query /// parameter other than `page`/`per_page` is preserved (e.g. the issues `state`), /// so the filter survives paging. `has_next` is computed by fetching one extra row. pub(crate) fn pagination_nav( path: &str, query: Option<&str>, p: Pagination, has_next: bool, ) -> Markup { // The non-paging query parameters, carried through on every link. let kept: Vec<(String, String)> = query .map(|q| { url::form_urlencoded::parse(q.as_bytes()) .filter(|(k, _)| k != "page" && k != "per_page") .map(|(k, v)| (k.into_owned(), v.into_owned())) .collect() }) .unwrap_or_default(); let with = |extra: &[(&str, String)]| -> String { let mut s = url::form_urlencoded::Serializer::new(String::new()); for (k, v) in &kept { s.append_pair(k, v); } for (k, v) in extra { s.append_pair(k, v); } format!("{path}?{}", s.finish()) }; let link = |page: usize| { with(&[ ("page", page.to_string()), ("per_page", p.per_page.to_string()), ]) }; html! { nav class="pagination" aria-label="Pagination" { @if p.page > 1 { a class="btn" href=(link(p.page - 1)) { "← Previous" } } @else { span class="btn disabled" aria-disabled="true" { "← Previous" } } // Changing the page size returns to page 1 (the form omits `page`). form class="per-page" method="get" action=(path) { @for (k, v) in &kept { input type="hidden" name=(k) value=(v); } label { "Per page " select name="per_page" data-autosubmit { @for n in Pagination::CHOICES { option value=(n) selected[n == p.per_page] { (n) } } } } noscript { button class="btn" type="submit" { "Apply" } } } span class="muted pagination-page" { "Page " (p.page) } @if has_next { a class="btn" href=(link(p.page + 1)) { "Next →" } } @else { span class="btn disabled" aria-disabled="true" { "Next →" } } } } } /// `GET /{owner}` — the user's profile: an info sidebar on the left, their groups /// and repositories (filterable) on the right. #[allow(clippy::too_many_lines)] // A flat per-tab dispatcher; splitting hurts readability. pub async fn user_page( State(state): State, MaybeUser(viewer): MaybeUser, jar: CookieJar, uri: Uri, Query(lq): Query, Path(owner_name): Path, ) -> AppResult { let Some(owner) = state.store.user_by_username(&owner_name).await? else { return Err(AppError::NotFound); }; let tab = match lq.tab.as_deref() { Some("groups") => ProfileTab::Groups, Some("bookmarks") => ProfileTab::Bookmarks, Some("followers") => ProfileTab::Followers, Some("following") => ProfileTab::Following, _ => ProfileTab::Repos, }; let q = lq.q.unwrap_or_default(); let needle = q.trim().to_lowercase(); let base = format!("/{}", owner.username); let paging = Pagination::from_query(uri.query()); let content = match tab { ProfileTab::Repos => { let repos = visible_repos(&state, &owner, viewer.as_ref()).await?; // Top-level repos only; a search spans nested ones so they are findable. let entries: Vec = repos .iter() .filter(|r| { let matches = needle.is_empty() || r.path.to_lowercase().contains(&needle); matches && (!needle.is_empty() || !r.path.contains('/')) }) .map(|r| RepoEntry::owned(&owner.username, r)) .collect(); let (entries, has_next) = page_slice(&entries, paging); html! { form class="search-row" method="get" { input type="search" name="q" value=(q) placeholder="Search repositories…" aria-label="Search repositories"; button class="btn btn-primary" type="submit" { "Search" } } (repo_card("", "No repositories.", &entries)) @if has_next || paging.page > 1 { (pagination_nav(uri.path(), uri.query(), paging, has_next)) } } } ProfileTab::Groups => { let groups = state.store.groups_by_owner(&owner.id).await?; let top_groups: Vec<_> = groups.iter().filter(|g| !g.path.contains('/')).collect(); html! { section class="listing" { @if top_groups.is_empty() { div class="card" { p class="muted" { "No groups." } } } @else { ul class="repo-list card" { @for group in &top_groups { li { a class="repo-row" href={ "/" (owner.username) "/" (group.path) } { span class="repo-row-name" { (icon(Icon::Folder)) span { (group.path) } } } } } } } } } } ProfileTab::Bookmarks => { let bookmarked = state.store.bookmarked_repos(&owner.id).await?; let visible = filter_readable(&state, bookmarked, viewer.as_ref()).await?; let entries: Vec = visible .iter() .map(|r| { // Resolve each repo's own owner for the cross-owner label. RepoEntry::global(&r.owner_id, r) }) .collect(); // Replace the owner-id label with the username where we can. let entries = resolve_bookmark_labels(&state, &visible, entries).await; let (entries, has_next) = page_slice(&entries, paging); html! { (repo_card("", "No bookmarks yet.", &entries)) @if has_next || paging.page > 1 { (pagination_nav(uri.path(), uri.query(), paging, has_next)) } } } ProfileTab::Followers => { let users = state.store.followers(&owner.id).await?; let (page, has_next) = page_slice(&users, paging); html! { (user_list(&state, &page, viewer.as_ref(), "No followers yet.").await?) @if has_next || paging.page > 1 { (pagination_nav(uri.path(), uri.query(), paging, has_next)) } } } ProfileTab::Following => { let users = state.store.following(&owner.id).await?; let (page, has_next) = page_slice(&users, paging); html! { (user_list(&state, &page, viewer.as_ref(), "Not following anyone yet.").await?) @if has_next || paging.page > 1 { (pagination_nav(uri.path(), uri.query(), paging, has_next)) } } } }; let followers = state.store.count_followers(&owner.id).await?; let following = state.store.count_following(&owner.id).await?; let follow = match &viewer { Some(v) if v.id != owner.id => Some( state .store .is_following(&v.id, &owner.id) .await .unwrap_or(false), ), _ => None, }; let (jar, chrome) = build_chrome(&state, jar, viewer.clone(), uri.path().to_string()); let ptab = |t: ProfileTab, key: &str, label: &str| { html! { a href=(format!("{base}?tab={key}")) aria-current=[(tab == t).then_some("page")] { (label) } } }; let body = html! { div class="profile-layout" { aside class="profile-sidebar" { (profile_sidebar(&owner, followers, following, follow)) } div class="profile-main" { nav class="tabs profile-tabs" { (ptab(ProfileTab::Repos, "repos", "Repositories")) (ptab(ProfileTab::Groups, "groups", "Groups")) (ptab(ProfileTab::Bookmarks, "bookmarks", "Bookmarks")) } (content) } } }; Ok((jar, page(&chrome, &owner.username, body)).into_response()) } /// The tabs on a user's profile. Followers/Following are reached from the /// sidebar counts rather than the main tab strip. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ProfileTab { Repos, Groups, Bookmarks, Followers, Following, } /// Render a list of users as follow-cards (avatar, name, bio, location, and a /// Follow/Unfollow button for signed-in viewers looking at other people). async fn user_list( state: &AppState, users: &[User], viewer: Option<&User>, empty: &str, ) -> AppResult { if users.is_empty() { return Ok(html! { div class="card" { p class="muted" { (empty) } } }); } // Precompute the viewer's follow relationship to each listed user. let mut following: std::collections::HashSet = std::collections::HashSet::new(); if let Some(v) = viewer { for u in users { if u.id != v.id && state .store .is_following(&v.id, &u.id) .await .unwrap_or(false) { following.insert(u.id.clone()); } } } Ok(html! { ul class="user-list card" { @for u in users { @let name = u.display_name.as_deref().unwrap_or(&u.username); li class="user-row" { img class="avatar" src={ "/avatar/" (u.username) } alt=""; div class="user-row-body" { div class="user-row-title" { a class="user-row-name" href={ "/" (u.username) } { (name) } " " span class="muted" { (u.username) } } @if let Some(bio) = non_empty(u.bio.as_ref()) { div class="user-row-bio muted markdown" { (markdown::render(bio)) } } @if let Some(loc) = non_empty(u.location.as_ref()) { p class="user-row-loc muted" { (icon(Icon::MapPin)) " " (loc) } } } @if let Some(v) = viewer { @if v.id != u.id { a class=(if following.contains(&u.id) { "btn" } else { "btn btn-primary" }) href={ "/user-follow/" (u.username) } { (if following.contains(&u.id) { "Unfollow" } else { "Follow" }) } } } } } } }) } /// Filter an arbitrary repo list to those `viewer` may read (for bookmarks). async fn filter_readable( state: &AppState, repos: Vec, viewer: Option<&User>, ) -> AppResult> { let viewer_id = viewer.map(|v| auth::Viewer { id: v.id.clone(), is_admin: v.is_admin, }); let mut out = Vec::new(); for repo in repos { let collab = match viewer { Some(v) => state .store .effective_permission(&repo.id, &v.id) .await? .and_then(|p| auth::Permission::parse(&p)), None => None, }; let access = auth::access(viewer_id.as_ref(), &repo, collab, state.allow_anonymous()); if access >= auth::Access::Read { out.push(repo); } } Ok(out) } /// Replace each bookmark entry's owner-id label/href with the real username. async fn resolve_bookmark_labels( state: &AppState, repos: &[Repo], mut entries: Vec, ) -> Vec { let mut names: std::collections::HashMap = std::collections::HashMap::new(); for (repo, entry) in repos.iter().zip(entries.iter_mut()) { let owner = if let Some(n) = names.get(&repo.owner_id) { n.clone() } else { let n = state .store .user_by_id(&repo.owner_id) .await .ok() .flatten() .map_or_else(|| repo.owner_id.clone(), |u| u.username); names.insert(repo.owner_id.clone(), n.clone()); n }; entry.href = format!("/{owner}/{}", repo.path); entry.label = format!("{owner}/{}", repo.path); } entries } /// `GET /{owner}/{*rest}` — dispatch a repo path and view. pub async fn dispatch( State(state): State, MaybeUser(viewer): MaybeUser, jar: CookieJar, headers: HeaderMap, uri: Uri, Query(dq): Query, Path((owner_name, rest)): Path<(String, String)>, ) -> AppResult { // LFS object download shares the GET catch-all (`…/info/lfs/objects/{oid}`). if !rest.contains("/-/") && let Some((repo_path, tail)) = crate::lfs::split_lfs(&rest) && let Some(oid) = tail.strip_prefix("objects/") && !oid.contains('/') { return Ok(crate::lfs::download( state, jar, headers, owner_name, repo_path.to_string(), oid.to_string(), ) .await); } // Smart-HTTP pack transport: `{owner}/{path}.git/info/refs`. Guarded so a blob // path that merely contains ".git/" is never misrouted (those carry "/-/"). if !rest.contains("/-/") && let Some((repo_path, tail)) = crate::git_http::split_git(&rest) && matches!(tail, "info/refs" | "git-upload-pack" | "git-receive-pack") { return Ok(crate::git_http::http_get( &state, &jar, &headers, &owner_name, repo_path, tail, dq.service.as_deref(), ) .await); } if let Some((repo_path, view)) = rest.split_once("/-/") { let ctx = resolve(&state, &owner_name, repo_path, viewer.as_ref()) .await? .ok_or(AppError::NotFound)?; dispatch_view(&state, viewer, jar, &uri, ctx, view, &dq).await } else { match resolve(&state, &owner_name, &rest, viewer.as_ref()).await? { Some(ctx) => repo_home(&state, viewer, jar, &uri, ctx).await, None => group_listing(&state, viewer, jar, &uri, &owner_name, &rest).await, } } } /// Dispatch the portion after `/-/`. async fn dispatch_view( state: &AppState, viewer: Option, jar: CookieJar, uri: &Uri, ctx: RepoCtx, view: &str, dq: &DiffQuery, ) -> AppResult { let (kind, rest) = view.split_once('/').unwrap_or((view, "")); match kind { "tree" => tree_view(state, viewer, jar, uri, ctx, rest).await, "blob" => blob_view(state, viewer, jar, uri, ctx, rest).await, "raw" => raw_view(state, ctx, rest).await, "commits" => commits_view(state, viewer, jar, uri, ctx, rest).await, "commit" => match rest.split_once('/') { Some((sha, "context")) => context_partial(state, ctx, sha, dq).await, _ => commit_view(state, viewer, jar, uri, ctx, rest, dq).await, }, "branches" => branches_view(state, viewer, jar, uri, ctx).await, "tags" => tags_view(state, viewer, jar, uri, ctx).await, "search" => { let branches = branch_names(state, &ctx.repo.id).await; let header = repo_header( state, &ctx, Tab::Search, &ctx.repo.default_branch, &branches, ); let raw_query = dq.q.clone().unwrap_or_default(); crate::search::in_repo(state, viewer, jar, uri, &ctx, &raw_query, header).await } "issues" => { let kind = crate::issues::Kind::issues(); match rest { "" => { let closed = uri.query().is_some_and(|q| q.contains("state=closed")); crate::issues::list(state, viewer, jar, uri, ctx, kind, closed).await } "new" => crate::issues::new_form(state, viewer, jar, uri, ctx, kind).await, n => match n.parse::() { Ok(num) => crate::issues::view(state, viewer, jar, uri, ctx, kind, num).await, Err(_) => Err(AppError::NotFound), }, } } "pulls" => { let kind = crate::issues::Kind::pulls(); match rest { "" => { let closed = uri.query().is_some_and(|q| q.contains("state=closed")); crate::issues::list(state, viewer, jar, uri, ctx, kind, closed).await } "new" => crate::pulls::new_form(state, viewer, jar, uri, ctx).await, n => match n.parse::() { Ok(num) => crate::pulls::view(state, viewer, jar, uri, ctx, num).await, Err(_) => Err(AppError::NotFound), }, } } "releases" => match rest { "" => crate::releases::list(state, viewer, jar, uri, ctx).await, "new" => crate::releases::new_form(state, viewer, jar, uri, ctx).await, tag => crate::releases::view(state, viewer, jar, uri, ctx, tag).await, }, "archive" => Ok(crate::releases::archive(state, ctx, rest).await), "settings" => repo_settings_view(state, viewer, jar, uri, ctx).await, // Reserved `runs` slot returns 404 until built. _ => Err(AppError::NotFound), } } /// Split `"{ref}/{path..}"` into the ref (first segment) and the remaining path. /// Branch names containing slashes resolve to their first segment only — a known /// MVP limitation. fn split_ref_path(rest: &str) -> (String, String) { match rest.split_once('/') { Some((r, p)) => (r.to_string(), p.to_string()), None => (rest.to_string(), String::new()), } } /// The reference to browse, defaulting to the repo's default branch when none is /// given in the URL. fn ref_or_default(repo: &Repo, given: &str) -> String { if given.is_empty() { repo.default_branch.clone() } else { given.to_string() } } // ---- Repo home ---- /// The Linguist-style language bar: a proportional stripe plus a legend. Shows /// the top languages, lumping the remainder into "Other". fn language_bar(langs: &[crate::languages::LangStat]) -> Markup { const MAX_SHOWN: usize = 8; // Collapse the long tail into a single grey "Other" segment. let (head, tail) = langs.split_at(langs.len().min(MAX_SHOWN)); let other: f64 = tail.iter().map(|l| l.percent).sum(); let pct = |p: f64| format!("{p:.1}%"); // A `
`: the bar is the always-visible summary; the legend expands on // click and works without JavaScript. html! { details class="card lang-bar-wrap" { summary class="lang-bar-summary" title="Show languages" { div class="lang-bar" aria-hidden="true" { @for l in head { span class="lang-seg" style=(format!("width:{:.2}%;background:{}", l.percent, l.color)) {} } @if other > 0.0 { span class="lang-seg" style=(format!("width:{other:.2}%;background:#8b8b8b")) {} } } } ul class="lang-legend" { @for l in head { li { span class="lang-dot" style=(format!("background:{}", l.color)) {} span class="lang-name" { (l.name) } span class="muted" { (pct(l.percent)) } } } @if other > 0.0 { li { span class="lang-dot" style="background:#8b8b8b" {} span class="lang-name" { "Other" } span class="muted" { (pct(other)) } } } } } } } async fn repo_home( state: &AppState, viewer: Option, jar: CookieJar, uri: &Uri, ctx: RepoCtx, ) -> AppResult { let rev_name = ctx.repo.default_branch.clone(); // Resolve the default branch; an unborn HEAD means an empty repo. let resolved = git_read(state, &ctx.repo.id, { let rev_name = rev_name.clone(); move |repo| repo.resolve_ref(&rev_name) }) .await; let body = match resolved { Err(_) => empty_repo_body(state, &ctx), Ok(rev) => { let branches = branch_names(state, &ctx.repo.id).await; landing_body(state, &ctx, &rev_name, &rev, &branches).await? } }; let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string()); let title = format!("{}/{}", ctx.owner.username, ctx.repo.path); Ok((jar, page(&chrome, &title, body)).into_response()) } /// The rich repository landing for a resolved ref: search box, language bar, the /// root tree, and the README. Shared by the repo home (default branch) and the /// root tree view, so selecting a branch or tag in the ref switcher lands on the /// same page for that ref rather than a bare file list. async fn landing_body( state: &AppState, ctx: &RepoCtx, rev_name: &str, rev: &git::Resolved, branches: &[String], ) -> AppResult { let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path); let entries = git_read(state, &ctx.repo.id, { let rev = rev.clone(); move |repo| repo.tree_entries(&rev, FsPath::new("")) }) .await?; let readme = load_readme(state, &ctx.repo.id, rev, &entries, &base, rev_name).await; let annotations = git_read(state, &ctx.repo.id, { let rev = rev.clone(); move |repo| repo.tree_annotations(&rev, FsPath::new("")) }) .await .unwrap_or_default(); // The language breakdown of this ref (best-effort). let langs = git_read(state, &ctx.repo.id, { let rev = rev.clone(); move |repo| repo.blob_sizes(&rev) }) .await .map(|files| crate::languages::breakdown(&files)) .unwrap_or_default(); Ok(html! { (repo_header(state, ctx, Tab::Code, rev_name, branches)) form class="search-row repo-codesearch" method="get" action=(format!("{base}/-/search")) { input type="search" name="q" placeholder="Search this repository…" aria-label="Search this repository"; button class="btn" type="submit" { (icon(Icon::Search)) span { "Search" } } } @if !langs.is_empty() { (language_bar(&langs)) } (tree_table(&ctx.owner.username, &ctx.repo.path, rev_name, "", &entries, &annotations)) @if let Some(rendered) = readme { div class="card markdown readme" { (rendered) } } }) } /// Find and render the first matching README in the root tree, rewriting its /// relative links/images into the repo at `ref_name`. async fn load_readme( state: &AppState, repo_id: &str, rev: &git::Resolved, entries: &[git::TreeEntry], base: &str, ref_name: &str, ) -> Option { let name = README_NAMES.iter().find(|candidate| { entries .iter() .any(|e| e.kind == git::EntryKind::File && e.name.eq_ignore_ascii_case(candidate)) })?; let name = (*name).to_string(); let blob = git_read(state, repo_id, { let rev = rev.clone(); let name = name.clone(); move |repo| repo.blob(&rev, FsPath::new(&name)) }) .await .ok()?; if blob.is_binary { return None; } let text = String::from_utf8_lossy(&blob.content); // The README lives at the repo root, so relative paths resolve from "". Some(markdown::render_repo(&text, base, ref_name, "")) } /// The "empty repository" landing with clone instructions. fn empty_repo_body(state: &AppState, ctx: &RepoCtx) -> Markup { html! { (repo_header(state, ctx, Tab::Code, &ctx.repo.default_branch, &[])) div class="card" { h2 { "This repository is empty" } p class="muted" { "Push a commit to get started:" } pre class="code" { (clone_https(state, ctx)) "\n" } } } } // ---- Tree view ---- async fn tree_view( state: &AppState, viewer: Option, jar: CookieJar, uri: &Uri, ctx: RepoCtx, rest: &str, ) -> AppResult { let (rev_name, path) = split_ref_path(rest); let rev_name = ref_or_default(&ctx.repo, &rev_name); let rev = git_read(state, &ctx.repo.id, { let rev_name = rev_name.clone(); move |repo| repo.resolve_ref(&rev_name) }) .await?; let branches = branch_names(state, &ctx.repo.id).await; // At the repo root, render the full landing (search, language bar, README) // for this ref, so switching to a tag/branch mirrors the home page. if path.is_empty() { let body = landing_body(state, &ctx, &rev_name, &rev, &branches).await?; let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string()); let title = format!("{}/{}", ctx.owner.username, ctx.repo.path); return Ok((jar, page(&chrome, &title, body)).into_response()); } let entries = git_read(state, &ctx.repo.id, { let rev = rev.clone(); let path = path.clone(); move |repo| repo.tree_entries(&rev, FsPath::new(&path)) }) .await?; let annotations = git_read(state, &ctx.repo.id, { let rev = rev.clone(); let path = path.clone(); move |repo| repo.tree_annotations(&rev, FsPath::new(&path)) }) .await .unwrap_or_default(); let body = html! { (repo_header(state, &ctx, Tab::Code, &rev_name, &branches)) (breadcrumb(&ctx.owner.username, &ctx.repo.path, &rev_name, &path)) (tree_table(&ctx.owner.username, &ctx.repo.path, &rev_name, &path, &entries, &annotations)) }; let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string()); let title = format!("{}/{}: {path}", ctx.owner.username, ctx.repo.path); Ok((jar, page(&chrome, &title, body)).into_response()) } // ---- Blob view ---- async fn blob_view( state: &AppState, viewer: Option, jar: CookieJar, uri: &Uri, ctx: RepoCtx, rest: &str, ) -> AppResult { // `?display=source` forces the highlighted source over a rendered preview. let source_view = uri.query().is_some_and(|q| q.contains("display=source")); let (rev_name, path) = split_ref_path(rest); let rev_name = ref_or_default(&ctx.repo, &rev_name); if path.is_empty() { return Err(AppError::NotFound); } let rev = git_read(state, &ctx.repo.id, { let rev_name = rev_name.clone(); move |repo| repo.resolve_ref(&rev_name) }) .await?; // Read the blob and resolve attribute-driven facts (language, LFS) in one pass. let (blob, attr_lang, is_lfs) = git_read(state, &ctx.repo.id, { let rev = rev.clone(); let path = path.clone(); move |repo| { let blob = repo.blob(&rev, FsPath::new(&path))?; let attrs = repo.attributes_set(&rev.oid).ok(); let resolved = attrs.map(|set| set.attributes(&path)); let lang = resolved .as_ref() .and_then(|a| a.language().map(str::to_string)); let is_lfs = resolved.as_ref().is_some_and(git::Attributes::is_lfs) || (blob.size < 1024 && blob .content .starts_with(b"version https://git-lfs.github.com/spec/v1")); Ok((blob, lang, is_lfs)) } }) .await?; let content_body = render_blob( &ctx, &rev_name, &path, &blob, attr_lang.as_deref(), is_lfs, source_view, ); // For a license file, detect it and show a rights summary above the content. let license = (!blob.is_binary && crate::license::is_license_path(&path)) .then(|| crate::license::detect(&String::from_utf8_lossy(&blob.content))) .flatten(); let branches = branch_names(state, &ctx.repo.id).await; let body = html! { (repo_header(state, &ctx, Tab::Code, &rev_name, &branches)) (breadcrumb(&ctx.owner.username, &ctx.repo.path, &rev_name, &path)) @if let Some(info) = license { (license_banner(info)) } (content_body) }; let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string()); let title = format!("{}/{}: {path}", ctx.owner.username, ctx.repo.path); Ok((jar, page(&chrome, &title, body)).into_response()) } /// The GitHub-style license summary banner: name, description, and the /// permissions / conditions / limitations columns. Informational, not legal /// advice. fn license_banner(info: &crate::license::LicenseInfo) -> Markup { let column = |title: &str, items: &[&str], marker: Icon, class: &str| { html! { div class="license-col" { h4 { (title) } @if items.is_empty() { p class="muted license-none" { "None" } } @else { ul { @for item in items { li class=(class) { (icon(marker)) span { (item) } } } } } } } }; html! { div class="card license-banner" { div class="license-head" { (icon(Icon::Scale)) div { p class="muted license-eyebrow" { "This repository is licensed under the" } h3 { (info.name) " " span class="muted mono" { "(" (info.spdx) ")" } } } } p class="license-desc muted" { (info.description) } div class="license-cols" { (column("Permissions", info.permissions, Icon::Check, "ok")) (column("Conditions", info.conditions, Icon::Issue, "cond")) (column("Limitations", info.limitations, Icon::X, "no")) } p class="license-note muted" { "This is a summary, not legal advice." } } } } /// Render a blob: a rendered preview for markdown, highlighted code with line /// anchors, an inline image, or a binary notice. fn render_blob( ctx: &RepoCtx, rev_name: &str, path: &str, blob: &git::Blob, attr_lang: Option<&str>, is_lfs: bool, source_view: bool, ) -> Markup { let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path); let raw_url = format!("{base}/-/raw/{rev_name}/{path}"); let blob_url = format!("{base}/-/blob/{rev_name}/{path}"); let filename = path.rsplit('/').next().unwrap_or(path); let previewable = is_markdown(filename) && !blob.is_binary; let show_preview = previewable && !source_view; html! { div class="blob-header" { div class="blob-meta" { span class="muted" { (blob.size) " bytes" } @if is_lfs { span class="badge lfs-pill" { "LFS" } } } div class="blob-actions" { @if previewable { a class=(toggle_class(show_preview)) href=(blob_url.clone()) { "Preview" } a class=(toggle_class(!show_preview)) href=(format!("{blob_url}?display=source")) { "Code" } } a class="btn" href=(raw_url) { "Raw" } button class="btn" type="button" data-clipboard=(raw_url) { (icon(Icon::Copy)) span { "Copy path" } } } } @if is_image(filename) { div class="blob-image" { img src=(raw_url) alt=(filename); } } @else if blob.is_binary { div class="card" { p class="muted" { "Binary file not shown." } p { a class="btn" href=(raw_url) { "Download" } } } } @else if show_preview { // Relative links in the file resolve from its own directory. @let dir = path.rsplit_once('/').map_or("", |(d, _)| d); div class="blob-preview markdown" { (markdown::render_repo(&String::from_utf8_lossy(&blob.content), &base, rev_name, dir)) } } @else { (highlighted_code(FsPath::new(path), &blob.content, attr_lang)) } } } /// Whether a filename is a markdown document (rendered as a preview by default). fn is_markdown(filename: &str) -> bool { matches!( filename .rsplit('.') .next() .map(str::to_ascii_lowercase) .as_deref(), Some("md" | "markdown" | "mdown" | "mkd") ) } /// Render highlighted code as a line-numbered table with `#L{n}` anchors. fn highlighted_code(path: &FsPath, content: &[u8], attr_lang: Option<&str>) -> Markup { let highlighted = highlight::highlight(path, content, attr_lang); html! { div class="blob-code" { table class="blob" { tbody { @for (i, line) in highlighted.lines.iter().enumerate() { @let n = i + 1; tr id={ "L" (n) } { td class="lineno" { a href={ "#L" (n) } { (n) } } td class="code-line" { @for span in &line.spans { @match span.class { Some(class) => { span class=(class) { (span.text) } } None => { (span.text) } } } } } } } } } } } // ---- Raw ---- async fn raw_view(state: &AppState, ctx: RepoCtx, rest: &str) -> AppResult { let (rev_name, path) = split_ref_path(rest); let rev_name = ref_or_default(&ctx.repo, &rev_name); if path.is_empty() { return Err(AppError::NotFound); } let rev = git_read(state, &ctx.repo.id, { let rev_name = rev_name.clone(); move |repo| repo.resolve_ref(&rev_name) }) .await?; let blob = git_read(state, &ctx.repo.id, { let rev = rev.clone(); let path = path.clone(); move |repo| repo.blob(&rev, FsPath::new(&path)) }) .await?; let filename = path.rsplit('/').next().unwrap_or(&path); // Serve images with their real type; everything else as text/plain to prevent // a stored-HTML blob from being interpreted as markup. `nosniff` reinforces it. let content_type = if is_image(filename) { mime_guess::from_path(filename) .first_raw() .unwrap_or("application/octet-stream") } else if blob.is_binary { "application/octet-stream" } else { "text/plain; charset=utf-8" }; Ok(( [ (header::CONTENT_TYPE, content_type), (header::X_CONTENT_TYPE_OPTIONS, "nosniff"), (header::CONTENT_DISPOSITION, "inline"), ], blob.content, ) .into_response()) } // ---- Branches ---- async fn branches_view( state: &AppState, viewer: Option, jar: CookieJar, uri: &Uri, ctx: RepoCtx, ) -> AppResult { let all = git_read(state, &ctx.repo.id, git::Repo::branches).await?; let branch_list: Vec = all.iter().map(|b| b.name.clone()).collect(); let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path); let now = crate::now_ms(); let paging = Pagination::from_query(uri.query()); let (branches, has_next) = page_slice(&all, paging); let body = html! { (repo_header(state, &ctx, Tab::Branches, &ctx.repo.default_branch, &branch_list)) ul class="entry-list card" { @for b in &branches { li class="entry-row" { div class="entry-row-body" { a class="entry-row-title" href=(format!("{base}/-/tree/{}", b.name)) { (b.name) } div class="entry-row-meta muted branch-meta" { @if b.ahead > 0 || b.behind > 0 { span class="branch-counts" { @if b.ahead > 0 { span class="ahead" { "↑" (b.ahead) } } @if b.behind > 0 { span class="behind" { "↓" (b.behind) } } } } span { "Updated " (crate::activity::fmt_relative(b.tip.author.time_ms, now)) } span { "by " (b.tip.author.name) } } } div class="entry-row-actions" { @if b.is_default { span class="badge badge-default" { "default" } } a class="mono commit-sha-pill" href=(format!("{base}/-/commit/{}", b.oid)) { (b.oid.short()) } } } } } @if has_next || paging.page > 1 { (pagination_nav(uri.path(), uri.query(), paging, has_next)) } }; let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string()); let title = format!("{}/{}: branches", ctx.owner.username, ctx.repo.path); Ok((jar, page(&chrome, &title, body)).into_response()) } // ---- Tags ---- async fn tags_view( state: &AppState, viewer: Option, jar: CookieJar, uri: &Uri, ctx: RepoCtx, ) -> AppResult { let all = git_read(state, &ctx.repo.id, git::Repo::tags).await?; let branches = branch_names(state, &ctx.repo.id).await; let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path); let paging = Pagination::from_query(uri.query()); let (tags, has_next) = page_slice(&all, paging); let body = html! { (repo_header(state, &ctx, Tab::Tags, &ctx.repo.default_branch, &branches)) @if tags.is_empty() { div class="card" { p class="muted" { "No tags." } } } @else { ul class="entry-list card" { @for t in &tags { li class="entry-row" { div class="entry-row-body" { a class="entry-row-title" href=(format!("{base}/-/tree/{}", t.name)) { (t.name) } } div class="entry-row-actions" { a class="mono commit-sha-pill" href=(format!("{base}/-/commit/{}", t.oid)) { (t.oid.short()) } } } } } @if has_next || paging.page > 1 { (pagination_nav(uri.path(), uri.query(), paging, has_next)) } } }; let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string()); let title = format!("{}/{}: tags", ctx.owner.username, ctx.repo.path); Ok((jar, page(&chrome, &title, body)).into_response()) } // ---- Settings (owner/admin only) ---- /// `GET …/-/settings` — the repo admin page: rename, visibility, and delete. /// Returns `404` for anyone without `Admin` access (existence never leaks). async fn repo_settings_view( state: &AppState, viewer: Option, jar: CookieJar, uri: &Uri, ctx: RepoCtx, ) -> AppResult { if ctx.access != auth::Access::Admin { return Err(AppError::NotFound); } let branches = branch_names(state, &ctx.repo.id).await; let collaborators = state.store.list_collaborators(&ctx.repo.id).await?; let labels = state.store.list_labels(&ctx.repo.id).await?; let mirrors = state.store.mirrors_for_repo(&ctx.repo.id).await?; let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string()); let id = &ctx.repo.id; let vis = ctx.repo.visibility; // The group ceiling: a repo may not be set more visible than its group. let vis_ceiling = match &ctx.repo.group_id { Some(gid) => state.store.group_visibility_ceiling(gid).await?, None => Visibility::Public, }; let vis_option = |value: Visibility, label: &str| -> Markup { // Hide options above the ceiling, but always keep the current value so the // form round-trips even if the ceiling later tightened. if value.rank() <= vis_ceiling.rank() || value == vis { html! { option value=(value.as_str()) selected[vis == value] { (label) } } } else { html! {} } }; let body = html! { (repo_header(state, &ctx, Tab::Settings, &ctx.repo.default_branch, &branches)) section class="listing" { h2 { "General" } div class="card" { form id="repo-general" method="post" action=(format!("/repo-settings/{id}/general")) { input type="hidden" name="_csrf" value=(chrome.csrf); label for="name" { "Repository name" } input type="text" id="name" name="name" value=(ctx.repo.name) required; label for="visibility" { "Visibility" } select id="visibility" name="visibility" { (vis_option(Visibility::Public, "Public — visible to everyone")) (vis_option(Visibility::Internal, "Internal — any signed-in user")) (vis_option(Visibility::Private, "Private — only you and collaborators")) } @if vis_ceiling.rank() < Visibility::Public.rank() { p class="muted field-hint" { "Capped by this repository's group, which is " (vis_ceiling.as_str()) "." } } label for="object_format" { "Object format" } select id="object_format" disabled title="Fixed at creation" { option { (ctx.repo.object_format.to_uppercase()) } } label { "Features" } label class="checkbox" { input type="checkbox" name="issues_enabled" value="1" checked[ctx.repo.issues_enabled]; " Issues" } label class="checkbox" { input type="checkbox" name="pulls_enabled" value="1" checked[ctx.repo.pulls_enabled]; " Pull requests" } label class="checkbox" { input type="checkbox" name="releases_enabled" value="1" checked[ctx.repo.releases_enabled]; " Releases" } } div class="settings-actions" { button class="btn btn-primary" type="submit" form="repo-general" { "Save changes" } @let archived = ctx.repo.archived_at.is_some(); form method="post" action=(format!("/repo-settings/{id}/archive")) { input type="hidden" name="_csrf" value=(chrome.csrf); input type="hidden" name="archived" value=(if archived { "false" } else { "true" }); button class="btn inline-btn" type="submit" { (if archived { "Unarchive" } else { "Archive" }) } } } } } (collaborators_section(id, &chrome.csrf, &collaborators)) // Labels are only used by issues and PRs, so hide the section when both // are disabled. @if ctx.repo.issues_enabled || ctx.repo.pulls_enabled { (labels_section(id, &chrome.csrf, &labels)) } (mirror_section(id, &chrome.csrf, &mirrors)) section class="listing" { h2 { "Danger zone" } div class="card danger-zone" { p { strong { "Delete this repository." } " This permanently removes it from fabrica; the on-disk copy is moved to trash." } form method="post" action=(format!("/repo-settings/{id}/delete")) { input type="hidden" name="_csrf" value=(chrome.csrf); button class="btn btn-danger" type="submit" { "Delete repository" } } } } }; let title = format!("{}/{}: settings", ctx.owner.username, ctx.repo.path); Ok((jar, page(&chrome, &title, body)).into_response()) } /// The Mirror settings section: existing mirrors (push and pull) with their /// status and controls, plus a form to add a push mirror. fn mirror_section(id: &str, csrf: &str, mirrors: &[store::Mirror]) -> Markup { let now = crate::now_ms(); html! { section class="listing" { h2 { "Mirror settings" } div class="card" { @if mirrors.is_empty() { p class="muted empty-note" { "No mirrors configured." } } @else { ul class="entry-list admin-list" { @for m in mirrors { li class="entry-row" { div class="entry-row-body" { span class="entry-row-title" { span class=(if m.direction == store::MirrorDirection::Push { "badge badge-verified" } else { "badge" }) { (if m.direction == store::MirrorDirection::Push { "push" } else { "pull" }) } " " (m.remote_url) } div class="entry-row-meta muted" { @match m.last_sync_at { Some(t) => { "Last synced " (crate::activity::fmt_relative(t, now)) } None => { "Never synced" } } @if m.interval_secs > 0 { " · every " (fmt_duration(m.interval_secs)) } @else { " · manual" } @if let Some(err) = &m.last_error { " · " span class="mirror-error" { "error: " (err) } } } } div class="entry-row-actions admin-actions" { form method="post" action=(format!("/repo-settings/{id}/mirror/{}/sync", m.id)) class="inline-form" { input type="hidden" name="_csrf" value=(csrf); button class="btn" type="submit" { "Sync now" } } form method="post" action=(format!("/repo-settings/{id}/mirror/{}/delete", m.id)) class="inline-form" { input type="hidden" name="_csrf" value=(csrf); button class="btn btn-danger" type="submit" { "Delete" } } } } } } } div class="mirror-add" { form method="post" action=(format!("/repo-settings/{id}/mirror")) { input type="hidden" name="_csrf" value=(csrf); label for="m-url" { "Git remote repository URL" } input type="url" id="m-url" name="remote_url" placeholder="https://example.com/owner/repo.git" required; label for="m-filter" { "Branch filter (optional)" } input type="text" id="m-filter" name="branch_filter" placeholder="main release/*"; p class="muted field-hint" { "Space- or comma-separated branch patterns. Leave blank to mirror all branches. Tags are always mirrored." } label { "Authorization (optional)" } div class="admin-form-row" { input type="text" name="username" placeholder="username" autocomplete="off"; input type="password" name="secret" placeholder="password or access token" autocomplete="off"; } label for="m-interval" { "Mirror interval" } input type="text" id="m-interval" name="interval" value="1h0m0s"; p class="muted field-hint" { "Time units are “h”, “m”, “s”. 0 disables periodic sync (minimum 10m)." } div class="admin-form-actions" { button class="btn btn-primary inline-btn" type="submit" { "Add push mirror" } label class="checkbox" { input type="checkbox" name="sync_on_push" value="1"; " Sync when commits are pushed" } } } } } } } } /// Parse a duration like `1h0m0s`, `30m`, or `0` into seconds. Returns `None` on /// a malformed value; `Some(0)` disables periodic sync. pub(crate) fn parse_duration(input: &str) -> Option { let s = input.trim(); if s == "0" { return Some(0); } // A bare integer is treated as seconds. if let Ok(n) = s.parse::() { return (n >= 0).then_some(n); } let mut total: i64 = 0; let mut num = String::new(); let mut saw_unit = false; for ch in s.chars() { if ch.is_ascii_digit() { num.push(ch); } else { let value: i64 = num.parse().ok()?; num.clear(); saw_unit = true; total += match ch { 'h' | 'H' => value * 3600, 'm' | 'M' => value * 60, 's' | 'S' => value, _ => return None, }; } } // Trailing digits with no unit, or no units at all, is malformed. if !num.is_empty() || !saw_unit { return None; } Some(total) } /// Format seconds as `1h0m0s` (matching the input the form accepts). fn fmt_duration(secs: i64) -> String { let h = secs / 3600; let m = (secs % 3600) / 60; let s = secs % 60; format!("{h}h{m}m{s}s") } /// The Collaborators section of the repo settings page: the current list with /// remove buttons, and the add form. fn collaborators_section(id: &str, csrf: &str, collaborators: &[store::Collaborator]) -> Markup { html! { section class="listing" { h2 { "Collaborators" } (collaborators_card(&format!("/repo-settings/{id}"), csrf, collaborators)) } } } /// The collaborators card — an avatar/name/role list with remove buttons, then /// an add row. Shared by the repo and group settings pages; `base` is the action /// URL prefix (`/repo-settings/{id}` or `/group-settings/{id}`). pub(crate) fn collaborators_card( base: &str, csrf: &str, collaborators: &[store::Collaborator], ) -> Markup { let perm_option = |value: &str, label: &str| html! { option value=(value) { (label) } }; html! { div class="card" { @if collaborators.is_empty() { p class="muted" { "No collaborators yet." } } @else { ul class="entry-list collab-list" { @for c in collaborators { li class="entry-row" { div class="collab-ident" { img class="avatar avatar-sm" src=(format!("/avatar/{}", c.username)) alt=""; a class="entry-row-title" href={ "/" (c.username) } { (c.username) } span class="collab-perm" { (c.permission) } } div class="entry-row-actions" { form method="post" action=(format!("{base}/collaborators/remove")) { input type="hidden" name="_csrf" value=(csrf); input type="hidden" name="user_id" value=(c.user_id); button class="btn btn-danger" type="submit" { "Remove" } } } } } } } form class="collab-add" method="post" action=(format!("{base}/collaborators")) { input type="hidden" name="_csrf" value=(csrf); input type="text" name="username" placeholder="Username" aria-label="Username" required; select name="permission" aria-label="Permission" { (perm_option("read", "Read")) (perm_option("write", "Write")) (perm_option("admin", "Admin")) } button class="btn btn-primary inline-btn" type="submit" { "Add" } } } } } /// A coloured label chip. A scoped label (`scope: value`) renders two-tone: the /// scope on a neutral theme background, the value in the label's colour. A plain /// label is a single coloured pill. pub(crate) fn label_chip(label: &model::Label) -> Markup { let color = format!("--label-color: {}", css_color(&label.color)); if let Some((scope, value)) = label.name.split_once(": ") { html! { span class="label-chip label-chip-scoped" style=(color) { span class="label-scope" { (scope.trim()) } span class="label-value" { (value.trim()) } } } } else { html! { span class="label-chip" style=(color) { (label.name) } } } } /// Sanitize a stored colour for inline CSS: only `#` + up to 8 hex digits. pub(crate) fn css_color(color: &str) -> String { let hex: String = color .trim_start_matches('#') .chars() .filter(char::is_ascii_hexdigit) .take(8) .collect(); if hex.is_empty() { "#888888".to_string() } else { format!("#{hex}") } } /// The Labels section of the repo settings page. fn labels_section(id: &str, csrf: &str, labels: &[model::Label]) -> Markup { html! { section class="listing" { h2 { "Labels" } div class="card" { @if labels.is_empty() { p class="muted" { "No labels yet. Use a scope like " code { "type: backend" } " for grouped labels." } } @else { ul class="entry-list label-list" { @for l in labels { li class="entry-row" { div class="entry-row-body" { (label_chip(l)) @if let Some(desc) = &l.description { div class="entry-row-meta muted" { (desc) } } } div class="entry-row-actions" { form method="post" action=(format!("/repo-settings/{id}/labels/delete")) { input type="hidden" name="_csrf" value=(csrf); input type="hidden" name="label_id" value=(l.id); button class="btn btn-danger" type="submit" { "Delete" } } } } } } } form class="label-add" method="post" action=(format!("/repo-settings/{id}/labels")) { input type="hidden" name="_csrf" value=(csrf); input type="text" name="name" placeholder="name or scope: value" aria-label="Label name" required; input type="color" name="color" value="#2f81f7" aria-label="Colour"; input type="text" name="description" placeholder="Description (optional)" aria-label="Description"; button class="btn btn-primary inline-btn" type="submit" { "Add label" } } } } } } /// The add-label form. #[derive(Debug, Deserialize)] pub struct LabelAddForm { /// Label name (may be `scope: value`). #[serde(default)] name: String, /// Hex colour. #[serde(default)] color: String, /// Optional description. #[serde(default)] description: String, /// CSRF token. #[serde(rename = "_csrf")] csrf: String, } /// `POST /repo-settings/{id}/labels` — create a label. pub async fn settings_label_add( State(state): State, MaybeUser(viewer): MaybeUser, jar: CookieJar, headers: HeaderMap, Path(id): Path, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let result = async { let repo = require_admin_repo(&state, viewer.as_ref(), &id).await?; let name = form.name.trim(); if name.is_empty() { return Err(AppError::BadRequest("Label name is required.".to_string())); } let color = css_color(&form.color); let desc = form.description.trim(); state .store .create_label(&repo.id, name, &color, (!desc.is_empty()).then_some(desc)) .await .map_err(|e| match e { store::StoreError::Conflict { .. } => { AppError::BadRequest("A label with that name already exists.".to_string()) } other => AppError::from(other), })?; Ok::(repo_settings_url(&state, &repo).await) } .await; match result { Ok(dest) => Redirect::to(&dest).into_response(), Err(err) => err.into_response(), } } /// The delete-label form. #[derive(Debug, Deserialize)] pub struct LabelDeleteForm { /// The label id. #[serde(default)] label_id: String, /// CSRF token. #[serde(rename = "_csrf")] csrf: String, } /// `POST /repo-settings/{id}/labels/delete` — delete a label. pub async fn settings_label_delete( State(state): State, MaybeUser(viewer): MaybeUser, jar: CookieJar, headers: HeaderMap, Path(id): Path, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let result = async { let repo = require_admin_repo(&state, viewer.as_ref(), &id).await?; // Only delete a label that belongs to this repo. if let Some(label) = state.store.label_by_id(&form.label_id).await? && label.repo_id == repo.id { state.store.delete_label(&label.id).await?; } Ok::(repo_settings_url(&state, &repo).await) } .await; match result { Ok(dest) => Redirect::to(&dest).into_response(), Err(err) => err.into_response(), } } /// The add-push-mirror form. #[derive(Debug, serde::Deserialize)] pub struct MirrorAddForm { #[serde(default)] remote_url: String, #[serde(default)] branch_filter: String, #[serde(default)] username: String, #[serde(default)] secret: String, #[serde(default)] sync_on_push: Option, #[serde(default)] interval: String, #[serde(rename = "_csrf")] csrf: String, } /// A CSRF-only form for mirror actions (delete, sync). #[derive(Debug, serde::Deserialize)] pub struct MirrorActionForm { #[serde(rename = "_csrf")] csrf: String, } /// `POST /repo-settings/{id}/mirror` — add a push mirror. pub async fn settings_mirror_add( State(state): State, MaybeUser(viewer): MaybeUser, jar: CookieJar, headers: HeaderMap, Path(id): Path, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let result = async { let repo = require_admin_repo(&state, viewer.as_ref(), &id).await?; let url = form.remote_url.trim(); if url.is_empty() { return Err(AppError::BadRequest( "A remote URL is required.".to_string(), )); } let interval = parse_duration(&form.interval) .ok_or_else(|| AppError::BadRequest("Invalid mirror interval.".to_string()))?; // Enforce the documented 10-minute floor on periodic sync. let interval = if interval > 0 && interval < 600 { 600 } else { interval }; let opt = |s: &str| { let t = s.trim(); (!t.is_empty()).then(|| t.to_string()) }; state .store .create_mirror(store::NewMirror { repo_id: repo.id.clone(), direction: store::MirrorDirection::Push, remote_url: url.to_string(), username: opt(&form.username), secret: opt(&form.secret), branch_filter: opt(&form.branch_filter), interval_secs: interval, sync_on_push: form.sync_on_push.is_some(), }) .await?; Ok::(repo_settings_url(&state, &repo).await) } .await; match result { Ok(dest) => Redirect::to(&dest).into_response(), Err(err) => err.into_response(), } } /// `POST /repo-settings/{id}/mirror/{mid}/delete` — remove a mirror. pub async fn settings_mirror_delete( State(state): State, MaybeUser(viewer): MaybeUser, jar: CookieJar, headers: HeaderMap, Path((id, mid)): Path<(String, String)>, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let result = async { let repo = require_admin_repo(&state, viewer.as_ref(), &id).await?; if let Some(m) = state.store.mirror_by_id(&mid).await? && m.repo_id == repo.id { state.store.delete_mirror(&m.id).await?; } Ok::(repo_settings_url(&state, &repo).await) } .await; match result { Ok(dest) => Redirect::to(&dest).into_response(), Err(err) => err.into_response(), } } /// `POST /repo-settings/{id}/mirror/{mid}/sync` — trigger a sync now (background). pub async fn settings_mirror_sync( State(state): State, MaybeUser(viewer): MaybeUser, jar: CookieJar, headers: HeaderMap, Path((id, mid)): Path<(String, String)>, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let result = async { let repo = require_admin_repo(&state, viewer.as_ref(), &id).await?; if let Some(m) = state.store.mirror_by_id(&mid).await? && m.repo_id == repo.id { // Sync off-request so a slow remote never blocks the response. let store = state.store.clone(); let config = state.config.clone(); tokio::spawn(async move { let _ = mirror::sync_one(&store, &config, &m).await; }); } Ok::(repo_settings_url(&state, &repo).await) } .await; match result { Ok(dest) => Redirect::to(&dest).into_response(), Err(err) => err.into_response(), } } /// Load a repo by id and require the viewer to be able to read it, else `404`. pub(crate) async fn require_readable_repo( state: &AppState, viewer: Option<&User>, repo_id: &str, ) -> Result { let repo = state .store .repo_by_id(repo_id) .await? .ok_or(AppError::NotFound)?; let viewer_id = viewer.map(|u| auth::Viewer { id: u.id.clone(), is_admin: u.is_admin, }); let collaborator = match viewer { Some(u) => state .store .effective_permission(&repo.id, &u.id) .await? .and_then(|p| auth::Permission::parse(&p)), None => None, }; let access = auth::access( viewer_id.as_ref(), &repo, collaborator, state.allow_anonymous(), ); if access == auth::Access::None { return Err(AppError::NotFound); } Ok(repo) } /// `GET /repo-bookmark/{id}` — toggle the viewer's bookmark of a readable repo, /// then return to it. (A GET toggle, matching the fork link; bookmarks are a /// trivial, reversible per-user flag.) pub async fn bookmark_toggle( State(state): State, MaybeUser(viewer): MaybeUser, Path(id): Path, ) -> AppResult { let Some(user) = viewer.clone() else { return Ok(Redirect::to("/login").into_response()); }; let repo = require_readable_repo(&state, viewer.as_ref(), &id).await?; if state.store.is_bookmarked(&user.id, &repo.id).await? { state.store.remove_bookmark(&user.id, &repo.id).await?; } else { state.store.add_bookmark(&user.id, &repo.id).await?; } let owner = state .store .user_by_id(&repo.owner_id) .await? .map_or_else(|| repo.owner_id.clone(), |u| u.username); Ok(Redirect::to(&format!("/{owner}/{}", repo.path)).into_response()) } /// `GET /user-follow/{username}` — toggle following a user, then return to their /// profile. (A GET toggle, matching the bookmark link.) pub async fn follow_toggle( State(state): State, MaybeUser(viewer): MaybeUser, Path(username): Path, ) -> AppResult { let Some(me) = viewer else { return Ok(Redirect::to("/login").into_response()); }; let Some(target) = state.store.user_by_username(&username).await? else { return Err(AppError::NotFound); }; if target.id != me.id { if state.store.is_following(&me.id, &target.id).await? { state.store.unfollow(&me.id, &target.id).await?; } else { state.store.follow(&me.id, &target.id).await?; } } Ok(Redirect::to(&format!("/{}", target.username)).into_response()) } /// `GET /repo-fork/{id}` — confirm forking a readable repo to your account. pub async fn fork_confirm( State(state): State, MaybeUser(viewer): MaybeUser, jar: CookieJar, uri: Uri, Path(id): Path, ) -> AppResult { let Some(user) = viewer.clone() else { return Ok(Redirect::to("/login").into_response()); }; let source = require_readable_repo(&state, viewer.as_ref(), &id).await?; let source_owner = state .store .user_by_id(&source.owner_id) .await? .ok_or(AppError::NotFound)?; let (jar, chrome) = build_chrome(&state, jar, Some(user.clone()), uri.path().to_string()); let body = html! { div class="new-repo" { h1 { "Fork a repository" } div class="card" { p { "Create a copy of " strong { (source_owner.username) "/" (source.path) } " under your account, " strong { (user.username) } "." } form method="post" action=(format!("/repo-fork/{}", source.id)) { input type="hidden" name="_csrf" value=(chrome.csrf); label for="fk-name" { "Repository name" } div class="admin-form-row admin-form-row-last" { input type="text" id="fk-name" name="name" value=(source.name) required; button class="btn btn-primary inline-btn" type="submit" { "Fork repository" } } } } } }; Ok((jar, page(&chrome, "Fork a repository", body)).into_response()) } /// The fork form. #[derive(Debug, serde::Deserialize)] pub struct ForkForm { #[serde(default)] name: String, #[serde(rename = "_csrf")] csrf: String, } /// `POST /repo-fork/{id}` — create the fork (row + on-disk copy) and redirect. pub async fn fork_create( State(state): State, MaybeUser(viewer): MaybeUser, jar: CookieJar, headers: HeaderMap, Path(id): Path, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let Some(user) = viewer.clone() else { return Redirect::to("/login").into_response(); }; let source = match require_readable_repo(&state, viewer.as_ref(), &id).await { Ok(repo) => repo, Err(e) => return e.into_response(), }; match do_fork(&state, &user, &source, form.name.trim()).await { Ok(path) => Redirect::to(&format!("/{}/{path}", user.username)).into_response(), Err(msg) => AppError::BadRequest(msg).into_response(), } } /// Create the fork repo (row + bare repo on disk seeded from the source). async fn do_fork( state: &AppState, user: &User, source: &Repo, name: &str, ) -> Result { let leaf = name.trim(); if leaf.is_empty() { return Err("Repository name is required.".to_string()); } let repo = state .store .create_repo(store::NewRepo { owner_id: user.id.clone(), group_id: None, name: leaf.to_string(), path: leaf.to_string(), description: source.description.clone(), visibility: source.visibility, default_branch: source.default_branch.clone(), }) .await .map_err(|e| match e { store::StoreError::Conflict { .. } => { "You already have a repository with that name.".to_string() } store::StoreError::Name(_) => "Invalid repository name.".to_string(), other => other.to_string(), })?; let _ = state.store.set_fork_parent(&repo.id, &source.id).await; let format = git::ObjectFormat::from_token(&source.object_format); if format != git::ObjectFormat::Sha1 { let _ = state .store .set_object_format(&repo.id, format.as_str()) .await; } let repo_dir = &state.config.storage.repo_dir; let hook = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("fabrica")); if let Err(e) = git::create_bare_with_format(repo_dir, &repo.id, &source.default_branch, &hook, format) { let _ = state.store.delete_repo(&repo.id).await; return Err(format!("Could not create the fork on disk: {e}")); } // Seed the fork by fetching the source's refs over a local path. let (Ok(src_dir), Ok(dest_dir)) = ( git::repo_path(repo_dir, &source.id), git::repo_path(repo_dir, &repo.id), ) else { let _ = state.store.delete_repo(&repo.id).await; return Err("Could not locate the repositories on disk.".to_string()); }; let home = state.config.storage.data_dir.join("tmp"); let src_url = src_dir.to_string_lossy().into_owned(); if let Err(e) = git::mirror::fetch( &state.config.git.binary, &dest_dir, &src_url, git::mirror::RemoteCred::default(), &home, ) .await { let _ = state.store.delete_repo(&repo.id).await; return Err(format!("Could not copy the repository contents: {e}")); } let size = i64::try_from(crate::admin::dir_size(&dest_dir)).unwrap_or(0); let _ = state.store.record_push(&repo.id, size).await; Ok(repo.path) } /// Resolve a repo by id and require the viewer to have `Admin` access, else a /// `404` (so a non-admin cannot even confirm the repo exists). async fn require_admin_repo( state: &AppState, viewer: Option<&User>, repo_id: &str, ) -> AppResult { let repo = state .store .repo_by_id(repo_id) .await? .ok_or(AppError::NotFound)?; let viewer_id = viewer.map(|u| auth::Viewer { id: u.id.clone(), is_admin: u.is_admin, }); let collab = match viewer { Some(u) => state .store .effective_permission(&repo.id, &u.id) .await? .and_then(|p| auth::Permission::parse(&p)), None => None, }; let access = auth::access(viewer_id.as_ref(), &repo, collab, state.allow_anonymous()); if access == auth::Access::Admin { Ok(repo) } else { Err(AppError::NotFound) } } /// The repo general-settings form (rename + visibility). #[derive(Debug, Deserialize)] pub struct RepoGeneralForm { /// The new leaf name (the group prefix is preserved). #[serde(default)] name: String, /// The chosen visibility token. #[serde(default)] visibility: String, /// Enable issues (checkbox present when on). #[serde(default)] issues_enabled: Option, /// Enable pull requests. #[serde(default)] pulls_enabled: Option, /// Enable releases. #[serde(default)] releases_enabled: Option, /// CSRF token. #[serde(rename = "_csrf")] csrf: String, } /// `POST /repo-settings/{id}/general` — rename and set visibility. pub async fn settings_general( State(state): State, MaybeUser(viewer): MaybeUser, jar: CookieJar, headers: HeaderMap, Path(id): Path, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let result = async { let repo = require_admin_repo(&state, viewer.as_ref(), &id).await?; // Preserve any group prefix; only the leaf changes. let new_name = form.name.trim(); let new_path = match repo.path.rsplit_once('/') { Some((group, _)) => format!("{group}/{new_name}"), None => new_name.to_string(), }; state .store .rename_repo(&repo.id, new_name, &new_path) .await?; if let Some(mut vis) = Visibility::from_token(&form.visibility) { // A fork may never be more visible than its parent (no laundering a // private repo into a public one). if let Some(parent_id) = &repo.fork_parent_id && let Some(parent) = state.store.repo_by_id(parent_id).await? && vis.rank() > parent.visibility.rank() { vis = parent.visibility; } // Nor more visible than its group ceiling. if let Some(gid) = &repo.group_id { let ceiling = state.store.group_visibility_ceiling(gid).await?; if vis.rank() > ceiling.rank() { vis = ceiling; } } state.store.set_visibility(&repo.id, vis).await?; } state .store .set_feature_enabled(&repo.id, "issues", form.issues_enabled.is_some()) .await?; state .store .set_feature_enabled(&repo.id, "pulls", form.pulls_enabled.is_some()) .await?; state .store .set_feature_enabled(&repo.id, "releases", form.releases_enabled.is_some()) .await?; let owner = state .store .user_by_id(&repo.owner_id) .await? .map_or_else(|| repo.owner_id.clone(), |u| u.username); Ok::(format!("/{owner}/{new_path}/-/settings")) } .await; match result { Ok(dest) => Redirect::to(&dest).into_response(), Err(err) => err.into_response(), } } /// The repo delete form (CSRF only). #[derive(Debug, Deserialize)] pub struct RepoDeleteForm { /// CSRF token. #[serde(rename = "_csrf")] csrf: String, } /// `POST /repo-settings/{id}/delete` — delete the repo row and move its on-disk /// directory to trash. pub async fn settings_delete( State(state): State, MaybeUser(viewer): MaybeUser, jar: CookieJar, headers: HeaderMap, Path(id): Path, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let result = async { let repo = require_admin_repo(&state, viewer.as_ref(), &id).await?; let owner = state .store .user_by_id(&repo.owner_id) .await? .map_or_else(|| repo.owner_id.clone(), |u| u.username); state.store.delete_repo(&repo.id).await?; // Best-effort: move the on-disk directory to trash. A failure here does // not fail the request — the repo is already unreachable through fabrica. if let Ok(dir) = git::repo_path(&state.config.storage.repo_dir, &repo.id) && dir.exists() { let trash = state.config.storage.data_dir.join("trash").join(format!( "{}-{}.git", repo.id, crate::now_ms() )); if let Some(parent) = trash.parent() { let _ = std::fs::create_dir_all(parent); } if let Err(err) = std::fs::rename(&dir, &trash) { tracing::warn!(repo = %repo.id, %err, "could not move deleted repo to trash"); } } Ok::(format!("/{owner}")) } .await; match result { Ok(dest) => Redirect::to(&dest).into_response(), Err(err) => err.into_response(), } } /// The archive/unarchive form. #[derive(Debug, Deserialize)] pub struct RepoArchiveForm { /// Desired archived state (`true` to archive, `false` to unarchive). #[serde(default)] archived: String, /// CSRF token. #[serde(rename = "_csrf")] csrf: String, } /// `POST /repo-settings/{id}/archive` — archive or unarchive the repo. pub async fn settings_archive( State(state): State, MaybeUser(viewer): MaybeUser, jar: CookieJar, headers: HeaderMap, Path(id): Path, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let result = async { let repo = require_admin_repo(&state, viewer.as_ref(), &id).await?; state .store .set_archived(&repo.id, form.archived == "true") .await?; Ok::(repo_settings_url(&state, &repo).await) } .await; match result { Ok(dest) => Redirect::to(&dest).into_response(), Err(err) => err.into_response(), } } /// The `/-/settings` URL for a repo, resolving the owner username. async fn repo_settings_url(state: &AppState, repo: &Repo) -> String { let owner = state .store .user_by_id(&repo.owner_id) .await .ok() .flatten() .map_or_else(|| repo.owner_id.clone(), |u| u.username); format!("/{owner}/{}/-/settings", repo.path) } /// The add-collaborator form. #[derive(Debug, Deserialize)] pub struct CollaboratorAddForm { /// The collaborator's username. #[serde(default)] username: String, /// The permission to grant (`read` | `write` | `admin`). #[serde(default)] permission: String, /// CSRF token. #[serde(rename = "_csrf")] csrf: String, } /// `POST /repo-settings/{id}/collaborators` — add or update a collaborator. pub async fn settings_collaborator_add( State(state): State, MaybeUser(viewer): MaybeUser, jar: CookieJar, headers: HeaderMap, Path(id): Path, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let result = async { let repo = require_admin_repo(&state, viewer.as_ref(), &id).await?; let permission = auth::Permission::parse(&form.permission) .ok_or_else(|| AppError::BadRequest("Invalid permission.".to_string()))?; let target = state .store .user_by_username(form.username.trim()) .await? .ok_or_else(|| AppError::BadRequest("No such user.".to_string()))?; // The owner is already an admin; adding them as a collaborator is a no-op. if target.id != repo.owner_id { state .store .set_collaborator(&repo.id, &target.id, permission.as_str()) .await?; } Ok::(repo_settings_url(&state, &repo).await) } .await; match result { Ok(dest) => Redirect::to(&dest).into_response(), Err(err) => err.into_response(), } } /// The remove-collaborator form. #[derive(Debug, Deserialize)] pub struct CollaboratorRemoveForm { /// The collaborator's user id. #[serde(default)] user_id: String, /// CSRF token. #[serde(rename = "_csrf")] csrf: String, } /// `POST /repo-settings/{id}/collaborators/remove` — remove a collaborator. pub async fn settings_collaborator_remove( State(state): State, MaybeUser(viewer): MaybeUser, jar: CookieJar, headers: HeaderMap, Path(id): Path, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let result = async { let repo = require_admin_repo(&state, viewer.as_ref(), &id).await?; state .store .remove_collaborator(&repo.id, &form.user_id) .await?; Ok::(repo_settings_url(&state, &repo).await) } .await; match result { Ok(dest) => Redirect::to(&dest).into_response(), Err(err) => err.into_response(), } } // ---- Commit list ---- async fn commits_view( state: &AppState, viewer: Option, jar: CookieJar, uri: &Uri, ctx: RepoCtx, rest: &str, ) -> AppResult { let rev_name = ref_or_default(&ctx.repo, rest); let rev = git_read(state, &ctx.repo.id, { let rev_name = rev_name.clone(); move |repo| repo.resolve_ref(&rev_name) }) .await?; // Paginate: fetch one extra row to detect whether a next page exists. let paging = Pagination::from_query(uri.query()); let mut commits = git_read(state, &ctx.repo.id, { let rev = rev.clone(); let page = git::Page { offset: paging.offset(), limit: paging.per_page + 1, }; move |repo| repo.commits(&rev, None, page) }) .await?; let has_next = commits.len() > paging.per_page; commits.truncate(paging.per_page); // Verify signatures against all registered keys (bounded to this page's rows), // reusing the TTL'd signature cache. let keys = signing_keys(state.store.all_keys().await?); let cache = state.signatures.clone(); let states = git_read(state, &ctx.repo.id, { let oids: Vec = commits.iter().map(|c| c.oid.clone()).collect(); move |repo| { Ok(oids .iter() .map(|o| (*cache.get(repo, o, &keys)).clone()) .collect::>()) } }) .await?; // Resolve the usernames of verified signers for the hover popovers. let mut signers: HashMap = HashMap::new(); for s in &states { if let git::SignatureState::Verified { user_id, .. } = s && !signers.contains_key(user_id) && let Ok(Some(u)) = state.store.user_by_id(user_id).await { signers.insert(user_id.clone(), u.username); } } let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path); let now = crate::now_ms(); let branches = branch_names(state, &ctx.repo.id).await; let body = html! { (repo_header(state, &ctx, Tab::Commits, &rev_name, &branches)) ul class="entry-list card" { @for (commit, sig) in commits.iter().zip(states.iter()) { @let commit_url = format!("{base}/-/commit/{}", commit.oid); @let signer = match sig { git::SignatureState::Verified { user_id, .. } => signers.get(user_id).map(String::as_str), _ => None, }; li class="entry-row" { div class="entry-row-body" { a class="entry-row-title" href=(commit_url) { (commit.summary) } div class="entry-row-meta muted" { span { (commit.author.name) } span { "·" } span { (crate::activity::fmt_relative(commit.author.time_ms, now)) } } } div class="entry-row-actions" { (commit_sig_cell(sig, signer, commit.author.time_ms)) a class="mono commit-sha-pill" href=(commit_url) { (commit.oid.short()) } button class="icon-btn commit-copy" type="button" data-clipboard=(commit.oid.to_string()) aria-label="Copy commit id" { (icon(Icon::Copy)) } } } } } @if has_next || paging.page > 1 { (pagination_nav(uri.path(), uri.query(), paging, has_next)) } }; let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string()); let title = format!("{}/{}: commits", ctx.owner.username, ctx.repo.path); Ok((jar, page(&chrome, &title, body)).into_response()) } // ---- Commit view ---- async fn commit_view( state: &AppState, viewer: Option, jar: CookieJar, uri: &Uri, ctx: RepoCtx, sha: &str, dq: &DiffQuery, ) -> AppResult { let keys = signing_keys(state.store.all_keys().await?); let (detail, files, sig) = load_commit(state, &ctx.repo.id, sha, keys).await?; // Resolve the signer's username for a verified signature's detail line. let signer = match &sig { git::SignatureState::Verified { user_id, .. } => state .store .user_by_id(user_id) .await .ok() .flatten() .map(|u| u.username), _ => None, }; // View mode: an explicit `?view=` wins and is remembered; else the cookie; // else unified. let chosen = dq.view.clone().filter(|v| v == "split" || v == "unified"); let cookie_mode = jar.get(DIFF_COOKIE).map(|c| c.value().to_string()); let split = chosen.clone().or(cookie_mode).as_deref() == Some("split"); let (adds, dels) = files.iter().fold((0, 0), |(a, d), f| { let (fa, fd) = f.stats(); (a + fa, d + fd) }); let repo_base = format!("/{}/{}", ctx.owner.username, ctx.repo.path); let branches = branch_names(state, &ctx.repo.id).await; let body = html! { (repo_header(state, &ctx, Tab::Code, &ctx.repo.default_branch, &branches)) (commit_meta(&detail)) (signature_detail(&sig, signer.as_deref())) div class="diff-toolbar" { span { (files.len()) " files changed · " span style="color:var(--fb-diff-add-fg)" { "+" (adds) } " " span style="color:var(--fb-diff-del-fg)" { "−" (dels) } } span class="diff-toggle" { a class=(toggle_class(!split)) href=(format!("{repo_base}/-/commit/{sha}?view=unified")) { "Unified" } a class=(toggle_class(split)) href=(format!("{repo_base}/-/commit/{sha}?view=split")) { "Split" } } } nav class="changed-files" aria-label="Changed files" { @for (i, f) in files.iter().enumerate() { @let (fa, fd) = f.stats(); a href=(format!("#file-{i}")) { (f.path()) " " span class="muted" { "+" (fa) " −" (fd) } } } } @for (i, f) in files.iter().enumerate() { details open id=(format!("file-{i}")) class="diff-file" { summary { @let (fa, fd) = f.stats(); span class="mono" { (f.path()) } " " span class="muted" { "+" (fa) " −" (fd) } } @if f.diff.is_binary { p class="muted" style="padding:0.5rem" { "Binary file not shown." } } @else if f.diff.hunks.is_empty() { p class="muted" style="padding:0.5rem" { "No textual changes." } } @else if split { (diff::split(f)) } @else { (diff::unified(f, Some(&format!("{repo_base}/-/commit/{sha}/context?file={}", f.path())))) } } } }; let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string()); let jar = match chosen { Some(mode) => jar.add(diff_cookie(mode, state.config.auth.cookie_secure)), None => jar, }; let title = format!( "{}/{}: {}", ctx.owner.username, ctx.repo.path, detail.oid.short() ); Ok((jar, page(&chrome, &title, body)).into_response()) } /// Load a commit, its diff against the first parent, the highlighted pre/post /// images of each file, and its signature — all on the blocking pool. type LoadedCommit = (git::CommitDetail, Vec, git::SignatureState); async fn load_commit( state: &AppState, repo_id: &str, sha: &str, keys: Vec, ) -> AppResult { let oid = git::Oid::new(sha); let context_lines = state.config.ui.diff_context_lines; let sha_owned = sha.to_string(); let cache = state.signatures.clone(); git_read(state, repo_id, move |repo| { let detail = repo.commit(&oid)?; let base = detail.parents.first().cloned(); let diffs = repo.diff(base.as_ref(), &oid, git::DiffOpts { context_lines })?; let head_rev = git::Resolved { oid: oid.clone(), kind: git::RefKind::Commit, name: sha_owned, }; let base_rev = base.as_ref().map(|o| git::Resolved { oid: o.clone(), kind: git::RefKind::Commit, name: o.to_string(), }); let rendered = diffs .files .into_iter() .map(|file| render_one_file(repo, &head_rev, base_rev.as_ref(), file)) .collect(); let sig = (*cache.get(repo, &oid, &keys)).clone(); Ok((detail, rendered, sig)) }) .await } /// The commits and rendered diff a pull request contributes: the head-only /// commit list (newest first) and the three-dot diff (merge-base → head). pub(crate) type RangeDiff = (Vec, Vec); /// Load a pull request's commits and diff. `base` and `head` are branch names (or /// revs); the diff is three-dot (against their merge base) so unrelated base /// commits do not appear as changes. pub(crate) async fn load_range_diff( state: &AppState, repo_id: &str, base: &str, head: &str, ) -> AppResult { let context_lines = state.config.ui.diff_context_lines; let base = base.to_string(); let head = head.to_string(); git_read(state, repo_id, move |repo| { let base_oid = repo.resolve_ref(&base)?.oid; let head_oid = repo.resolve_ref(&head)?.oid; // Three-dot: diff from the merge base, so only the head's own work shows. let merge_base = repo.merge_base(&base_oid, &head_oid)?.unwrap_or(base_oid); let commits = repo.commits_between(&merge_base, &head_oid, 200)?; let diffs = repo.diff( Some(&merge_base), &head_oid, git::DiffOpts { context_lines }, )?; let head_rev = git::Resolved { oid: head_oid.clone(), kind: git::RefKind::Commit, name: head.clone(), }; let base_rev = git::Resolved { oid: merge_base.clone(), kind: git::RefKind::Commit, name: merge_base.to_string(), }; let rendered = diffs .files .into_iter() .map(|file| render_one_file(repo, &head_rev, Some(&base_rev), file)) .collect(); Ok((commits, rendered)) }) .await } /// Build a [`RenderedFile`] for one diff entry, highlighting each side as a whole /// document. fn render_one_file( repo: &git::Repo, head_rev: &git::Resolved, base_rev: Option<&git::Resolved>, file: git::FileDiff, ) -> RenderedFile { let text_at = |rev: &git::Resolved, path: &str| { repo.blob(rev, FsPath::new(path)) .ok() .filter(|b| !b.is_binary) .map(|b| String::from_utf8_lossy(&b.content).into_owned()) }; let new_text = file.new_path.as_deref().and_then(|p| text_at(head_rev, p)); let old_text = match (base_rev, file.old_path.as_deref()) { (Some(rev), Some(p)) => text_at(rev, p), _ => None, }; let lang_path = file .new_path .clone() .or_else(|| file.old_path.clone()) .unwrap_or_default(); let probe = new_text.as_deref().or(old_text.as_deref()).unwrap_or(""); let lang = highlight::resolve(FsPath::new(&lang_path), probe.as_bytes(), None); let highlight_side = |text: Option<&str>| { text.map(|t| highlight::highlight_document(&lang, t)) .unwrap_or_default() }; RenderedFile { old_lines: highlight_side(old_text.as_deref()), new_lines: highlight_side(new_text.as_deref()), diff: file, } } /// The commit metadata block: the subject with the short id, author, and commit /// date to its right, and the body below. fn commit_meta(detail: &git::CommitDetail) -> Markup { let (subject, body) = detail .message .split_once("\n\n") .map_or((detail.message.as_str(), ""), |(s, b)| (s.trim(), b)); html! { div class="commit-meta" { div class="commit-title-row" { h2 class="commit-title" { (subject.lines().next().unwrap_or("")) } p class="commit-title-meta muted" { span class="mono" { (detail.oid.short()) } " · " (detail.author.name) " committed on " (fmt_date(detail.committer.time_ms)) } } @if !body.trim().is_empty() { pre class="commit-body" { (body.trim_end()) } } } } } /// `GET …/-/commit/{sha}/context` — the highlighted hidden-context rows, as an /// htmx partial that replaces its expander. async fn context_partial( state: &AppState, ctx: RepoCtx, sha: &str, dq: &DiffQuery, ) -> AppResult { let (Some(file), Some(from), Some(to)) = (dq.file.clone(), dq.from, dq.to) else { return Err(AppError::BadRequest( "missing context parameters".to_string(), )); }; let oid = git::Oid::new(sha); let sha_owned = sha.to_string(); let (lines, highlighted) = git_read(state, &ctx.repo.id, move |repo| { let lines = repo.diff_context(&oid, FsPath::new(&file), from, to)?; let rev = git::Resolved { oid: oid.clone(), kind: git::RefKind::Commit, name: sha_owned, }; let highlighted = repo .blob(&rev, FsPath::new(&file)) .ok() .filter(|b| !b.is_binary) .map(|b| { let text = String::from_utf8_lossy(&b.content).into_owned(); let lang = highlight::resolve(FsPath::new(&file), text.as_bytes(), None); highlight::highlight_document(&lang, &text) }) .unwrap_or_default(); Ok((lines, highlighted)) }) .await?; Ok(Html(diff::context_rows(&lines, &highlighted).into_string()).into_response()) } /// The active/inactive class for a diff-view toggle button. fn toggle_class(active: bool) -> &'static str { if active { "btn active" } else { "btn" } } /// Build the diff-view preference cookie. fn diff_cookie(mode: String, secure: bool) -> Cookie<'static> { Cookie::build((DIFF_COOKIE, mode)) .http_only(false) .same_site(SameSite::Lax) .secure(secure) .path("/") .max_age(time::Duration::days(365)) .build() } // ---- Group listing ---- async fn group_listing( state: &AppState, viewer: Option, jar: CookieJar, uri: &Uri, owner_name: &str, group_path: &str, ) -> AppResult { let Some(owner) = state.store.user_by_username(owner_name).await? else { return Err(AppError::NotFound); }; let Some(group) = state .store .group_by_owner_path(&owner.id, group_path) .await? else { return Err(AppError::NotFound); }; // A private group is invisible to non-members (404, never 403). let access = crate::groups::group_access(state, viewer.as_ref(), &group).await?; if access == auth::Access::None { return Err(AppError::NotFound); } let is_admin = access == auth::Access::Admin; let all_groups = state.store.groups_by_owner(&owner.id).await?; let prefix = format!("{group_path}/"); // Direct child groups the viewer may see (a private subgroup is hidden, not // just 404 on visit — its very name would otherwise leak here). let mut subgroups: Vec<&model::Group> = Vec::new(); for g in all_groups .iter() .filter(|g| g.path.starts_with(&prefix) && !g.path[prefix.len()..].contains('/')) { if crate::groups::group_access(state, viewer.as_ref(), g).await? != auth::Access::None { subgroups.push(g); } } let repos: Vec = visible_repos(state, &owner, viewer.as_ref()) .await? .into_iter() .filter(|r| r.path.starts_with(&prefix) && !r.path[prefix.len()..].contains('/')) .collect(); let crumbs = path_crumbs(&owner.username, &group.path); let body = html! { div class="group-head" { h1 class="repo-slug group-slug" { (icon(Icon::Folder)) a href={ "/" (owner.username) } { (owner.username) } @for (name, href) in &crumbs { span class="slug-sep" { "/" } a href=(href) { (name) } } } @if is_admin { div class="group-head-actions" { a class="btn inline-btn" href=(format!("/new/group?parent={}", group.path)) { (icon(Icon::Plus)) span { "New subgroup" } } a class="btn inline-btn" href=(format!("/group-settings/{}", group.id)) title="Group settings" aria-label="Group settings" { (icon(Icon::Settings)) } } } } @if !subgroups.is_empty() { section class="listing" { h2 { "Subgroups" } ul class="repo-list card" { @for g in &subgroups { li { a class="repo-row" href={ "/" (owner.username) "/" (g.path) } { span class="repo-row-name" { (icon(Icon::Folder)) span { (g.path) } } } } } } } } (owned_repo_card(&owner.username, &repos)) }; let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string()); let title = format!("{}/{}", owner.username, group.path); Ok((jar, page(&chrome, &title, body)).into_response()) } // ---- Shared components ---- /// The repos of `owner` the viewer may see: all of them for the owner/admin, else /// the public ones. async fn visible_repos( state: &AppState, owner: &User, viewer: Option<&User>, ) -> AppResult> { let repos = state.store.repos_by_owner(&owner.id).await?; let is_owner_or_admin = viewer.is_some_and(|v| v.id == owner.id || v.is_admin); if is_owner_or_admin { return Ok(repos); } // Non-owners: whatever the access function grants read to (visibility + // collaborator rows), so internal repos surface for signed-in viewers. let viewer_id = viewer.map(|v| auth::Viewer { id: v.id.clone(), is_admin: v.is_admin, }); let mut out = Vec::new(); for repo in repos { let collab = match viewer { Some(v) => state .store .effective_permission(&repo.id, &v.id) .await? .and_then(|p| auth::Permission::parse(&p)), None => None, }; let access = auth::access(viewer_id.as_ref(), &repo, collab, state.allow_anonymous()); if access >= auth::Access::Read { out.push(repo); } } Ok(out) } /// One row in a repository listing card. #[derive(Clone)] pub(crate) struct RepoEntry { /// Link target. pub(crate) href: String, /// Display label (a bare repo path, or `owner/path` in cross-owner listings). pub(crate) label: String, /// The repo's visibility (drives the badge and subtitle). pub(crate) visibility: model::Visibility, /// The default branch, shown in the subtitle. pub(crate) default_branch: String, /// When the repo was last pushed to (falling back to its last metadata /// change), for the "Updated …" subtitle. pub(crate) updated: i64, /// Whether the repo is archived (shows an archived badge). pub(crate) archived: bool, /// Whether the repo is a mirror (shows the mirror icon). pub(crate) is_mirror: bool, /// Whether the repo is a fork (shows the fork icon). pub(crate) is_fork: bool, /// Optional one-line description, shown under the name when present. pub(crate) description: Option, } impl RepoEntry { /// Build an entry for a repo shown on its owner's own page (bare-path label). pub(crate) fn owned(owner: &str, repo: &Repo) -> Self { Self { href: format!("/{owner}/{}", repo.path), label: repo.path.clone(), visibility: repo.visibility, default_branch: repo.default_branch.clone(), updated: repo.pushed_at.unwrap_or(repo.updated_at), archived: repo.archived_at.is_some(), is_mirror: repo.is_mirror, is_fork: repo.fork_parent_id.is_some(), description: repo.description.clone(), } } /// Build an entry for a cross-owner listing (e.g. Explore), labelled /// `owner/path`. pub(crate) fn global(owner: &str, repo: &Repo) -> Self { Self { href: format!("/{owner}/{}", repo.path), label: format!("{owner}/{}", repo.path), visibility: repo.visibility, default_branch: repo.default_branch.clone(), updated: repo.pushed_at.unwrap_or(repo.updated_at), archived: repo.archived_at.is_some(), is_mirror: repo.is_mirror, is_fork: repo.fork_parent_id.is_some(), description: repo.description.clone(), } } } /// The visibility badge shown next to a repo name (nothing for public). fn visibility_badge(visibility: model::Visibility) -> Markup { html! { @if visibility != model::Visibility::Public { span class="badge badge-private" { (visibility.as_str()) } } } } /// An "archived" badge when the repo is archived, else nothing. fn archived_badge(archived: bool) -> Markup { html! { @if archived { span class="badge badge-archived" { "archived" } } } } /// Render a titled card of repository rows in the reference listing style: a /// card headed by `title`, one bordered row per repo with a bold name and a /// muted `visibility · default: branch` subtitle. pub(crate) fn repo_card(title: &str, empty: &str, entries: &[RepoEntry]) -> Markup { let now = crate::now_ms(); html! { section class="listing" { @if !title.is_empty() { h2 { (title) } } @if entries.is_empty() { div class="card" { p class="muted" { (empty) } } } @else { ul class="repo-list card" { @for e in entries { li { a class="repo-row" href=(e.href) { span class="repo-row-name" { (icon(if e.is_mirror { Icon::Mirror } else if e.is_fork { Icon::GitFork } else { Icon::Box })) span { (e.label) } (visibility_badge(e.visibility)) (archived_badge(e.archived)) } @if let Some(desc) = &e.description { span class="repo-row-desc muted" { (desc) } } span class="repo-row-meta muted" { (icon(Icon::GitBranch)) " " (e.default_branch) " · Updated " (crate::activity::fmt_relative(e.updated, now)) } } } } } } } } } /// Render an owner's repositories as a listing card. pub(crate) fn owned_repo_card(owner: &str, repos: &[Repo]) -> Markup { let entries: Vec = repos.iter().map(|r| RepoEntry::owned(owner, r)).collect(); repo_card("Repositories", "No repositories.", &entries) } /// The profile sidebar card: avatar, name, handle, pronouns, markdown bio, /// location/social links, and the join date. fn profile_sidebar(owner: &User, followers: i64, following: i64, follow: Option) -> Markup { let name = owner.display_name.as_deref().unwrap_or(&owner.username); let user = &owner.username; html! { div class="card profile-card" { img class="avatar avatar-xl" src={ "/avatar/" (owner.username) } alt=""; h1 class="profile-name" { (name) } p class="profile-handle muted" { "@" (owner.username) @if let Some(pronouns) = pronouns(owner) { " · " (pronouns) } } p class="profile-follow muted" { (icon(Icon::Users)) " " a href={ "/" (user) "?tab=followers" } { strong { (followers) } " follower" @if followers != 1 { "s" } } " · " a href={ "/" (user) "?tab=following" } { strong { (following) } " following" } } @match follow { Some(true) => { a class="btn profile-follow-btn" href={ "/user-follow/" (user) } { "Unfollow" } } Some(false) => { a class="btn btn-primary profile-follow-btn" href={ "/user-follow/" (user) } { "Follow" } } None => {} } @if let Some(bio) = non_empty(owner.bio.as_ref()) { div class="profile-bio markdown" { (markdown::render(bio)) } } @let links = profile_links(owner); @if !links.is_empty() { ul class="profile-links muted" { @for link in &links { (link) } } } p class="profile-joined muted" { "Joined on " (fmt_joined(owner.created_at)) } } } } /// The trimmed pronouns, if set. fn pronouns(owner: &User) -> Option<&str> { non_empty(owner.pronouns.as_ref()) } /// A field's trimmed value, or `None` when empty. fn non_empty(field: Option<&String>) -> Option<&str> { field.map(|s| s.trim()).filter(|s| !s.is_empty()) } /// The profile's location and its arbitrary links, each an icon-prefixed row. /// The brand icon is inferred from the link's host. fn profile_links(owner: &User) -> Vec { let mut out = Vec::new(); if let Some(location) = non_empty(owner.location.as_ref()) { out.push(html! { li { (icon(Icon::MapPin)) span { (location) } } }); } // The primary email, only when the owner has chosen to publish it. if owner.email_public { if let Some(email) = non_empty(Some(&owner.email)) { out.push(html! { li { a href=(format!("mailto:{email}")) { (icon(Icon::Mail)) span { (email) } } } }); } } for link in &owner.links { let href = normalize_url(link); let shown = href .trim_start_matches("https://") .trim_start_matches("http://") .trim_end_matches('/'); out.push(html! { li { a href=(href) rel="nofollow noopener me" { (icon(link_icon(&href))) span { (shown) } } } }); } out } /// Prefix a bare URL with `https://` when it has no scheme. fn normalize_url(url: &str) -> String { let url = url.trim(); if url.starts_with("http://") || url.starts_with("https://") { url.to_string() } else { format!("https://{url}") } } /// Choose a brand icon for a link from its host. fn link_icon(url: &str) -> Icon { let host = url .trim_start_matches("https://") .trim_start_matches("http://") .split('/') .next() .unwrap_or("") .to_ascii_lowercase(); if host == "github.com" || host.ends_with(".github.com") { Icon::Github } else if host.contains("mastodon") { Icon::Mastodon } else { Icon::Globe } } /// The repo sub-header: slug, description, and a tab strip sharing its line with /// the branch switcher and clone menu. /// Build breadcrumb `(segment, href)` pairs for each segment of a repo `path` /// under `owner`. Group segments link to their group listing; the last is the /// repo itself. pub(crate) fn path_crumbs(owner: &str, path: &str) -> Vec<(String, String)> { let mut out = Vec::new(); let mut cumulative = String::new(); for seg in path.split('/') { if cumulative.is_empty() { cumulative = seg.to_string(); } else { cumulative = format!("{cumulative}/{seg}"); } out.push((seg.to_string(), format!("/{owner}/{cumulative}"))); } out } pub(crate) fn repo_header( state: &AppState, ctx: &RepoCtx, active: Tab, rev: &str, branches: &[String], ) -> Markup { let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path); let tab = |t: Tab, label: &str, glyph: Icon, href: String| { html! { a href=(href) aria-current=[(active == t).then_some("page")] { (icon(glyph)) span { (label) } } } }; // Breadcrumb: owner / group… / repo. Each path segment links to its own page // (group segments render the group listing; the leaf is the repo). let crumbs = path_crumbs(&ctx.owner.username, &ctx.repo.path); html! { div class="repo-head" { h1 class="repo-slug" { a href={ "/" (ctx.owner.username) } { (ctx.owner.username) } @for (name, href) in &crumbs { span class="slug-sep" { "/" } a href=(href) { (name) } } (visibility_badge(ctx.repo.visibility)) (archived_badge(ctx.repo.archived_at.is_some())) } @if let Some((powner, ppath)) = &ctx.fork_parent { p class="muted fork-note" { (icon(Icon::GitFork)) " forked from " a href=(format!("/{powner}/{ppath}")) { (powner) "/" (ppath) } } } @if let Some(desc) = &ctx.repo.description { p class="muted" { (desc) } } div class="repo-nav" { nav class="tabs repo-tabs" { (tab(Tab::Code, "Code", Icon::File, base.clone())) @if ctx.repo.issues_enabled { (tab(Tab::Issues, "Issues", Icon::Issue, format!("{base}/-/issues"))) } @if ctx.repo.pulls_enabled { (tab(Tab::Pulls, "Pull requests", Icon::GitPull, format!("{base}/-/pulls"))) } (tab(Tab::Commits, "Commits", Icon::History, format!("{base}/-/commits/{rev}"))) (tab(Tab::Branches, "Branches", Icon::GitBranch, format!("{base}/-/branches"))) (tab(Tab::Tags, "Tags", Icon::Box, format!("{base}/-/tags"))) @if ctx.repo.releases_enabled { (tab(Tab::Releases, "Releases", Icon::Download, format!("{base}/-/releases"))) } @if ctx.access == auth::Access::Admin { (tab(Tab::Settings, "Settings", Icon::Settings, format!("{base}/-/settings"))) } } div class="repo-actions" { @if ctx.viewer_id.is_some() { @let bm_label = if ctx.bookmarked { "Remove bookmark" } else { "Bookmark" }; a class=(if ctx.bookmarked { "icon-btn btn-active" } else { "icon-btn" }) href=(format!("/repo-bookmark/{}", ctx.repo.id)) aria-label=(bm_label) title=(bm_label) { (icon(Icon::Bookmark)) } a class="icon-btn" href=(format!("/repo-fork/{}", ctx.repo.id)) aria-label="Fork" title="Fork" { (icon(Icon::GitFork)) } } (branch_menu(&base, rev, branches)) (clone_menu(state, ctx)) } } } } } /// The branch switcher: a no-JS `details` menu linking to each branch's tree. /// Falls back to a plain link to the branches page when the list is empty. fn branch_menu(base: &str, rev: &str, branches: &[String]) -> Markup { html! { @if branches.is_empty() { a class="btn" href=(format!("{base}/-/branches")) { (icon(Icon::GitBranch)) span { (rev) } } } @else { details class="branch-menu" { summary class="btn" { (icon(Icon::GitBranch)) span { (rev) } (icon(Icon::ChevronDown)) } div class="branch-pop card" { div class="branch-pop-head muted" { "Switch branch" } @for name in branches { a href=(format!("{base}/-/tree/{name}")) aria-current=[(name == rev).then_some("page")] { (name) } } a class="branch-pop-all" href=(format!("{base}/-/branches")) { "View all branches" } } } } } } /// The Clone dropdown: a no-JS `details` menu offering the HTTPS and SSH URLs. fn clone_menu(state: &AppState, ctx: &RepoCtx) -> Markup { let https = clone_https(state, ctx); let ssh = clone_ssh(state, ctx); let field = |label: &str, url: &str| { html! { div class="clone-field" { span class="clone-field-label muted" { (label) } div class="clone-input" { input type="text" readonly value=(url) aria-label=(format!("{label} clone URL")); button class="btn" type="button" data-clipboard=(url) aria-label=(format!("Copy {label} URL")) { (icon(Icon::Copy)) } } } } }; html! { details class="clone-menu" { summary class="btn btn-primary" { (icon(Icon::Download)) span { "Clone" } } div class="clone-pop card" { (field("HTTPS", &https)) (field("SSH", &ssh)) } } } } /// The HTTPS clone URL. fn clone_https(state: &AppState, ctx: &RepoCtx) -> String { format!( "{}/{}/{}.git", state.config.instance.url.trim_end_matches('/'), ctx.owner.username, ctx.repo.path ) } /// The SSH clone URL — scp-style on the default port, `ssh://` otherwise. fn clone_ssh(state: &AppState, ctx: &RepoCtx) -> String { let host = &state.config.ssh.clone_host; let port = state.config.ssh.clone_port; let (owner, repo) = (&ctx.owner.username, &ctx.repo.path); if port == 22 { format!("git@{host}:{owner}/{repo}.git") } else { format!("ssh://git@{host}:{port}/{owner}/{repo}.git") } } /// A path breadcrumb for tree/blob views. fn breadcrumb(owner: &str, repo_path: &str, rev: &str, path: &str) -> Markup { let root = format!("/{owner}/{repo_path}/-/tree/{rev}"); // Precompute each crumb's (label, cumulative href) before rendering. let mut acc = String::new(); let crumbs: Vec<(String, String)> = path .split('/') .filter(|s| !s.is_empty()) .map(|segment| { if acc.is_empty() { acc.push_str(segment); } else { acc.push('/'); acc.push_str(segment); } (segment.to_string(), format!("{root}/{acc}")) }) .collect(); html! { nav class="breadcrumb" { a href=(root) { (repo_path) } @for (label, href) in &crumbs { " / " a href=(href) { (label) } } } } } /// A tree listing table. fn tree_table( owner: &str, repo_path: &str, rev: &str, dir: &str, entries: &[git::TreeEntry], annotations: &git::TreeAnnotations, ) -> Markup { let href = |name: &str, kind: git::EntryKind| { let sub = if dir.is_empty() { name.to_string() } else { format!("{dir}/{name}") }; let view = match kind { git::EntryKind::Directory => "tree", _ => "blob", }; format!("/{owner}/{repo_path}/-/{view}/{rev}/{sub}") }; // Directories first, then files, each alphabetical (case-insensitive). let mut sorted: Vec<&git::TreeEntry> = entries.iter().collect(); sorted.sort_by(|a, b| { let is_file = |e: &git::TreeEntry| e.kind != git::EntryKind::Directory; is_file(a) .cmp(&is_file(b)) .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) }); html! { div class="file-tree card" { @for entry in sorted { @let full = if dir.is_empty() { entry.name.clone() } else { format!("{dir}/{}", entry.name) }; @if entry.kind == git::EntryKind::Submodule { // A gitlink: link out to the submodule's remote (from .gitmodules). @let url = annotations.submodules.get(&full).map(|u| submodule_href(u)); @if let Some(url) = url { a class="file-row is-submodule" href=(url) rel="noopener" { span class="file-icon" { (icon(Icon::Box)) } span class="file-name" { (entry.name) } span class="tree-pill" { "submodule" } } } @else { span class="file-row is-submodule" { span class="file-icon" { (icon(Icon::Box)) } span class="file-name" { (entry.name) } span class="tree-pill" { "submodule" } } } } @else { @let is_dir = entry.kind == git::EntryKind::Directory; a class=(if is_dir { "file-row is-dir" } else { "file-row" }) href=(href(&entry.name, entry.kind)) { span class="file-icon" { (icon(entry_icon(entry.kind))) } span class="file-name" { (entry.name) } @if annotations.lfs.contains(&entry.name) { span class="tree-pill lfs-pill" { "LFS" } } } } } } } } /// Turn a submodule's `.gitmodules` URL into a browsable https link (best-effort). fn submodule_href(url: &str) -> String { let u = url.trim(); if let Some(rest) = u.strip_prefix("git@") && let Some((host, path)) = rest.split_once(':') { return format!("https://{host}/{}", path.trim_end_matches(".git")); } if let Some(rest) = u.strip_prefix("ssh://") { let rest = rest.strip_prefix("git@").unwrap_or(rest); if let Some((host, path)) = rest.split_once('/') { return format!("https://{host}/{}", path.trim_end_matches(".git")); } } u.trim_end_matches(".git").to_string() } /// An icon glyph for a tree entry kind. fn entry_icon(kind: git::EntryKind) -> Icon { match kind { git::EntryKind::Directory => Icon::Folder, git::EntryKind::Submodule => Icon::Box, git::EntryKind::Symlink => Icon::Link, git::EntryKind::File => Icon::File, } } /// The uppercase label for a key kind. fn key_kind_label(kind: model::KeyKind) -> &'static str { match kind { model::KeyKind::Ssh => "SSH", model::KeyKind::Gpg => "GPG", } } /// A commit's signature cell for the commits list: a verified signature gets a /// hover/focus popover with the signer, key fingerprint, and verified date; any /// other state falls back to the plain badge. fn commit_sig_cell(state: &git::SignatureState, signer: Option<&str>, time_ms: i64) -> Markup { match state { git::SignatureState::Verified { kind, fingerprint, user_id, .. } => html! { span class="sig-pop-wrap" tabindex="0" { (signature_badge(state)) div class="sig-pop card" { div class="sig-pop-title" { "This commit was signed with a verified signature." } div class="sig-pop-signer" { "Signed by " strong { (signer.unwrap_or(user_id)) } } div class="sig-pop-fp" { (key_kind_label(*kind)) " key fingerprint:" span class="mono" { (fingerprint) } } div class="muted sig-pop-when" { "Verified on " (fmt_joined(time_ms)) } } } }, _ => signature_badge(state), } } /// A signature badge (green Verified / amber Unverified) with a descriptive title. fn signature_badge(state: &git::SignatureState) -> Markup { match state { git::SignatureState::Verified { fingerprint, .. } => html! { span class="badge badge-verified" title={ "Verified: " (fingerprint) } { "Verified" } }, git::SignatureState::Unsigned => html! { span class="badge badge-unverified" title="This commit is not signed" { "Unverified" } }, git::SignatureState::UnknownKey { fingerprint, .. } => html! { span class="badge badge-unverified" title={ "Signed by an unrecognized key " (fingerprint) } { "Unverified" } }, git::SignatureState::Invalid { reason, .. } => html! { span class="badge badge-unverified" title={ "Signature could not be verified: " (reason) } { "Unverified" } }, } } /// The detail bar under a commit's metadata describing its signature: who signed /// it and with which key fingerprint. Renders nothing for an unsigned commit. fn signature_detail(state: &git::SignatureState, signer: Option<&str>) -> Markup { match state { git::SignatureState::Unsigned => html! {}, git::SignatureState::Verified { kind, fingerprint, user_id, .. } => html! { div class="sig-detail sig-verified" { span class="sig-signer" { "Signed by " strong { (signer.unwrap_or(user_id)) } } span class="sig-fp mono" { (key_kind_label(*kind)) " key fingerprint: " (fingerprint) } } }, git::SignatureState::UnknownKey { kind, fingerprint } => html! { div class="sig-detail sig-unverified" { span { "Signed by an unregistered " (key_kind_label(*kind)) " key" } span class="sig-fp mono" { (fingerprint) } } }, git::SignatureState::Invalid { reason, .. } => html! { div class="sig-detail sig-unverified" { span { "Signature could not be verified: " (reason) } } }, } } /// Map registered keys to the verification input type. fn signing_keys(keys: Vec) -> Vec { keys.into_iter() .map(|k| git::SigningKey { user_id: k.user_id, key_id: k.id, kind: k.kind, fingerprint: k.fingerprint, public_key: k.public_key, }) .collect() } /// Whether a filename is a browser-renderable image. fn is_image(filename: &str) -> bool { matches!( filename .rsplit('.') .next() .map(str::to_ascii_lowercase) .as_deref(), Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "ico" | "bmp") ) } /// Format an epoch-millisecond timestamp as a human join date, e.g. /// `Jul 11, 2026`. pub(crate) fn fmt_joined(ms: i64) -> String { let secs = ms.div_euclid(1000); match time::OffsetDateTime::from_unix_timestamp(secs) { Ok(dt) => { let month = match dt.month() { time::Month::January => "Jan", time::Month::February => "Feb", time::Month::March => "Mar", time::Month::April => "Apr", time::Month::May => "May", time::Month::June => "Jun", time::Month::July => "Jul", time::Month::August => "Aug", time::Month::September => "Sep", time::Month::October => "Oct", time::Month::November => "Nov", time::Month::December => "Dec", }; format!("{month} {}, {}", dt.day(), dt.year()) } Err(_) => "—".to_string(), } } /// Format an epoch-millisecond timestamp as a `YYYY-MM-DD` date. pub(crate) fn fmt_date(ms: i64) -> String { let secs = ms.div_euclid(1000); match time::OffsetDateTime::from_unix_timestamp(secs) { Ok(dt) => format!( "{:04}-{:02}-{:02}", dt.year(), u8::from(dt.month()), dt.day() ), Err(_) => "—".to_string(), } }