fabrica

hanna/fabrica

138442 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Repository browsing: the user page, repo home (README/tree), tree and blob
6//! views, raw content, branches, tags, and the commit list.
7//!
8//! Group nesting makes repo paths variable-length, so `/{owner}/{*rest}` is a
9//! single catch-all that splits `rest` on the GitLab `/-/` separator: everything
10//! before is the repo (or group) path, everything after is the view. Access is
11//! resolved once through [`auth::access`]; no access to a private repo renders
12//! `404`, never `403`, so existence never leaks.
13
14use std::collections::HashMap;
15use std::path::Path as FsPath;
16
17use axum::Form;
18use axum::extract::{Path, Query, State};
19use axum::http::{HeaderMap, Uri, header};
20use axum::response::{Html, IntoResponse, Redirect, Response};
21use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
22use maud::{Markup, html};
23use model::{Repo, User, Visibility};
24use serde::Deserialize;
25
26use crate::AppState;
27use crate::diff::{self, RenderedFile};
28use crate::error::{AppError, AppResult};
29use crate::icons::{Icon, icon};
30use crate::layout::page;
31use crate::markdown;
32use crate::pages::build_chrome;
33use crate::session::{MaybeUser, verify_csrf};
34
35/// The diff-view preference cookie (`unified` | `split`).
36const DIFF_COOKIE: &str = "fabrica_diffview";
37
38/// Query parameters for the commit view and its context-expansion partial.
39#[derive(Debug, Default, Deserialize)]
40pub struct DiffQuery {
41 /// `unified` or `split`.
42 view: Option<String>,
43 /// The file whose context to expand.
44 file: Option<String>,
45 /// The 1-based first line of the context window.
46 from: Option<u32>,
47 /// The 1-based last line of the context window.
48 to: Option<u32>,
49 /// The smart-HTTP service, for `…​.git/info/refs?service=`.
50 service: Option<String>,
51 /// The search query, for the in-repo `/-/search` view.
52 #[serde(default)]
53 q: Option<String>,
54}
55
56/// The candidate README filenames, in lookup order.
57const README_NAMES: &[&str] = &["README.md", "README", "readme.md", "README.txt"];
58
59/// Which repo tab is active, for highlighting.
60#[derive(Clone, Copy, PartialEq, Eq)]
61pub(crate) enum Tab {
62 Code,
63 Issues,
64 Pulls,
65 Commits,
66 Branches,
67 Tags,
68 Releases,
69 Search,
70 Settings,
71}
72
73/// A resolved repository plus the viewer's access to it.
74pub(crate) struct RepoCtx {
75 pub(crate) owner: User,
76 pub(crate) repo: Repo,
77 /// The viewer's access level, used to gate the Settings tab and its actions.
78 /// Every browse view requires only `Read`.
79 pub(crate) access: auth::Access,
80 /// The signed-in viewer's id, if any (gates the Fork action).
81 pub(crate) viewer_id: Option<String>,
82 /// Whether the viewer has bookmarked this repo.
83 pub(crate) bookmarked: bool,
84 /// The parent repo's `(owner_username, path)` when this repo is a fork.
85 pub(crate) fork_parent: Option<(String, String)>,
86}
87
88/// Run a blocking libgit2 read for a repo on the blocking pool, opening the repo
89/// per operation (§3.7 threading).
90pub(crate) async fn git_read<T, F>(state: &AppState, repo_id: &str, f: F) -> AppResult<T>
91where
92 F: FnOnce(&git::Repo) -> Result<T, git::GitError> + Send + 'static,
93 T: Send + 'static,
94{
95 let repo_dir = state.config.storage.repo_dir.clone();
96 let repo_id = repo_id.to_string();
97 tokio::task::spawn_blocking(move || {
98 let path = git::repo_path(&repo_dir, &repo_id)?;
99 let repo = git::Repo::open_path(&path)?;
100 f(&repo)
101 })
102 .await
103 .map_err(AppError::internal)?
104 .map_err(AppError::from)
105}
106
107/// The local branch names for the switcher, empty on any error (an empty repo or
108/// a failed read simply yields no dropdown).
109pub(crate) async fn branch_names(state: &AppState, repo_id: &str) -> Vec<String> {
110 git_read(state, repo_id, git::Repo::branch_names)
111 .await
112 .unwrap_or_default()
113}
114
115/// Resolve `(owner, path)` to a repo and check access, or `Ok(None)` if the owner
116/// or repo does not exist (so the caller can try a group listing).
117///
118/// Denied access to an existing private repo returns `Err(NotFound)` — identical
119/// to a missing repo, so private existence never leaks.
120pub(crate) async fn resolve(
121 state: &AppState,
122 owner_name: &str,
123 repo_path: &str,
124 viewer: Option<&User>,
125) -> AppResult<Option<RepoCtx>> {
126 let Some(owner) = state.store.user_by_username(owner_name).await? else {
127 return Ok(None);
128 };
129 let Some(repo) = state.store.repo_by_owner_path(&owner.id, repo_path).await? else {
130 return Ok(None);
131 };
132
133 let viewer_id = viewer.map(|u| auth::Viewer {
134 id: u.id.clone(),
135 is_admin: u.is_admin,
136 });
137 let collaborator = match viewer {
138 Some(u) => state
139 .store
140 .effective_permission(&repo.id, &u.id)
141 .await?
142 .and_then(|p| auth::Permission::parse(&p)),
143 None => None,
144 };
145 let access = auth::access(
146 viewer_id.as_ref(),
147 &repo,
148 collaborator,
149 state.allow_anonymous(),
150 );
151 if access == auth::Access::None {
152 return Err(AppError::NotFound);
153 }
154 // Resolve the fork parent's owner/path for the "forked from" line (best-effort).
155 let fork_parent = match &repo.fork_parent_id {
156 Some(pid) => resolve_fork_parent(state, pid).await,
157 None => None,
158 };
159 let bookmarked = match viewer {
160 Some(u) => state
161 .store
162 .is_bookmarked(&u.id, &repo.id)
163 .await
164 .unwrap_or(false),
165 None => false,
166 };
167 Ok(Some(RepoCtx {
168 owner,
169 repo,
170 access,
171 viewer_id: viewer.map(|u| u.id.clone()),
172 bookmarked,
173 fork_parent,
174 }))
175}
176
177/// Resolve a parent repo id to its `(owner_username, path)` for display.
178async fn resolve_fork_parent(state: &AppState, parent_id: &str) -> Option<(String, String)> {
179 let parent = state.store.repo_by_id(parent_id).await.ok().flatten()?;
180 let owner = state
181 .store
182 .user_by_id(&parent.owner_id)
183 .await
184 .ok()
185 .flatten()?;
186 Some((owner.username, parent.path))
187}
188
189/// A listing filter query (`?q=`), shared by the profile and explore views.
190#[derive(Debug, Default, Deserialize)]
191pub struct ListQuery {
192 /// Case-insensitive substring filter over repository paths.
193 #[serde(default)]
194 pub q: Option<String>,
195 /// Active profile tab: `repos` (default), `groups`, or `bookmarks`.
196 #[serde(default)]
197 pub tab: Option<String>,
198}
199
200/// Paging state parsed from `?page=&per_page=`, for long lists (commits, …).
201#[derive(Debug, Clone, Copy)]
202pub(crate) struct Pagination {
203 /// 1-based page number.
204 pub page: usize,
205 /// Items per page.
206 pub per_page: usize,
207}
208
209impl Pagination {
210 /// The default page size.
211 pub(crate) const DEFAULT_PER_PAGE: usize = 10;
212 /// The offered page sizes.
213 const CHOICES: [usize; 4] = [10, 25, 50, 100];
214
215 /// Parse from a raw query string, clamping to sane bounds.
216 pub(crate) fn from_query(query: Option<&str>) -> Self {
217 let (mut page, mut per_page) = (1, Self::DEFAULT_PER_PAGE);
218 if let Some(q) = query {
219 for (k, v) in url::form_urlencoded::parse(q.as_bytes()) {
220 match k.as_ref() {
221 "page" => page = v.parse::<usize>().unwrap_or(1).max(1),
222 "per_page" => {
223 per_page = v
224 .parse::<usize>()
225 .unwrap_or(Self::DEFAULT_PER_PAGE)
226 .clamp(1, 100);
227 }
228 _ => {}
229 }
230 }
231 }
232 Self { page, per_page }
233 }
234
235 /// The zero-based offset of the current page.
236 pub(crate) fn offset(self) -> usize {
237 (self.page - 1) * self.per_page
238 }
239}
240
241/// Slice an already-fetched list to the current page, returning the page's items
242/// and whether a next page exists. For lists that are filtered/searched in Rust
243/// (so SQL `LIMIT`/`OFFSET` cannot bound them), this keeps the rendered output
244/// bounded even when the full set is large.
245pub(crate) fn page_slice<T: Clone>(items: &[T], p: Pagination) -> (Vec<T>, bool) {
246 let start = p.offset().min(items.len());
247 let end = start.saturating_add(p.per_page).min(items.len());
248 let has_next = end < items.len();
249 (items[start..end].to_vec(), has_next)
250}
251
252/// Render prev/next controls and a page-size selector for a paginated list.
253///
254/// `path` is the list URL and `query` its current query string; every query
255/// parameter other than `page`/`per_page` is preserved (e.g. the issues `state`),
256/// so the filter survives paging. `has_next` is computed by fetching one extra row.
257pub(crate) fn pagination_nav(
258 path: &str,
259 query: Option<&str>,
260 p: Pagination,
261 has_next: bool,
262) -> Markup {
263 // The non-paging query parameters, carried through on every link.
264 let kept: Vec<(String, String)> = query
265 .map(|q| {
266 url::form_urlencoded::parse(q.as_bytes())
267 .filter(|(k, _)| k != "page" && k != "per_page")
268 .map(|(k, v)| (k.into_owned(), v.into_owned()))
269 .collect()
270 })
271 .unwrap_or_default();
272 let with = |extra: &[(&str, String)]| -> String {
273 let mut s = url::form_urlencoded::Serializer::new(String::new());
274 for (k, v) in &kept {
275 s.append_pair(k, v);
276 }
277 for (k, v) in extra {
278 s.append_pair(k, v);
279 }
280 format!("{path}?{}", s.finish())
281 };
282 let link = |page: usize| {
283 with(&[
284 ("page", page.to_string()),
285 ("per_page", p.per_page.to_string()),
286 ])
287 };
288 html! {
289 nav class="pagination" aria-label="Pagination" {
290 @if p.page > 1 {
291 a class="btn" href=(link(p.page - 1)) { "← Previous" }
292 } @else {
293 span class="btn disabled" aria-disabled="true" { "← Previous" }
294 }
295 // Changing the page size returns to page 1 (the form omits `page`).
296 form class="per-page" method="get" action=(path) {
297 @for (k, v) in &kept {
298 input type="hidden" name=(k) value=(v);
299 }
300 label { "Per page "
301 select name="per_page" data-autosubmit {
302 @for n in Pagination::CHOICES {
303 option value=(n) selected[n == p.per_page] { (n) }
304 }
305 }
306 }
307 noscript { button class="btn" type="submit" { "Apply" } }
308 }
309 span class="muted pagination-page" { "Page " (p.page) }
310 @if has_next {
311 a class="btn" href=(link(p.page + 1)) { "Next →" }
312 } @else {
313 span class="btn disabled" aria-disabled="true" { "Next →" }
314 }
315 }
316 }
317}
318
319/// `GET /{owner}` — the user's profile: an info sidebar on the left, their groups
320/// and repositories (filterable) on the right.
321#[allow(clippy::too_many_lines)] // A flat per-tab dispatcher; splitting hurts readability.
322pub async fn user_page(
323 State(state): State<AppState>,
324 MaybeUser(viewer): MaybeUser,
325 jar: CookieJar,
326 uri: Uri,
327 Query(lq): Query<ListQuery>,
328 Path(owner_name): Path<String>,
329) -> AppResult<Response> {
330 let Some(owner) = state.store.user_by_username(&owner_name).await? else {
331 return Err(AppError::NotFound);
332 };
333 let tab = match lq.tab.as_deref() {
334 Some("groups") => ProfileTab::Groups,
335 Some("bookmarks") => ProfileTab::Bookmarks,
336 Some("followers") => ProfileTab::Followers,
337 Some("following") => ProfileTab::Following,
338 _ => ProfileTab::Repos,
339 };
340 let q = lq.q.unwrap_or_default();
341 let needle = q.trim().to_lowercase();
342 let base = format!("/{}", owner.username);
343 let paging = Pagination::from_query(uri.query());
344
345 let content = match tab {
346 ProfileTab::Repos => {
347 let repos = visible_repos(&state, &owner, viewer.as_ref()).await?;
348 // Top-level repos only; a search spans nested ones so they are findable.
349 let entries: Vec<RepoEntry> = repos
350 .iter()
351 .filter(|r| {
352 let matches = needle.is_empty() || r.path.to_lowercase().contains(&needle);
353 matches && (!needle.is_empty() || !r.path.contains('/'))
354 })
355 .map(|r| RepoEntry::owned(&owner.username, r))
356 .collect();
357 let (entries, has_next) = page_slice(&entries, paging);
358 html! {
359 form class="search-row" method="get" {
360 input type="search" name="q" value=(q)
361 placeholder="Search repositories…" aria-label="Search repositories";
362 button class="btn btn-primary" type="submit" { "Search" }
363 }
364 (repo_card("", "No repositories.", &entries))
365 @if has_next || paging.page > 1 {
366 (pagination_nav(uri.path(), uri.query(), paging, has_next))
367 }
368 }
369 }
370 ProfileTab::Groups => {
371 let groups = state.store.groups_by_owner(&owner.id).await?;
372 let top_groups: Vec<_> = groups.iter().filter(|g| !g.path.contains('/')).collect();
373 html! {
374 section class="listing" {
375 @if top_groups.is_empty() {
376 div class="card" { p class="muted" { "No groups." } }
377 } @else {
378 ul class="repo-list card" {
379 @for group in &top_groups {
380 li {
381 a class="repo-row" href={ "/" (owner.username) "/" (group.path) } {
382 span class="repo-row-name" {
383 (icon(Icon::Folder)) span { (group.path) }
384 }
385 }
386 }
387 }
388 }
389 }
390 }
391 }
392 }
393 ProfileTab::Bookmarks => {
394 let bookmarked = state.store.bookmarked_repos(&owner.id).await?;
395 let visible = filter_readable(&state, bookmarked, viewer.as_ref()).await?;
396 let entries: Vec<RepoEntry> = visible
397 .iter()
398 .map(|r| {
399 // Resolve each repo's own owner for the cross-owner label.
400 RepoEntry::global(&r.owner_id, r)
401 })
402 .collect();
403 // Replace the owner-id label with the username where we can.
404 let entries = resolve_bookmark_labels(&state, &visible, entries).await;
405 let (entries, has_next) = page_slice(&entries, paging);
406 html! {
407 (repo_card("", "No bookmarks yet.", &entries))
408 @if has_next || paging.page > 1 {
409 (pagination_nav(uri.path(), uri.query(), paging, has_next))
410 }
411 }
412 }
413 ProfileTab::Followers => {
414 let users = state.store.followers(&owner.id).await?;
415 let (page, has_next) = page_slice(&users, paging);
416 html! {
417 (user_list(&state, &page, viewer.as_ref(), "No followers yet.").await?)
418 @if has_next || paging.page > 1 {
419 (pagination_nav(uri.path(), uri.query(), paging, has_next))
420 }
421 }
422 }
423 ProfileTab::Following => {
424 let users = state.store.following(&owner.id).await?;
425 let (page, has_next) = page_slice(&users, paging);
426 html! {
427 (user_list(&state, &page, viewer.as_ref(), "Not following anyone yet.").await?)
428 @if has_next || paging.page > 1 {
429 (pagination_nav(uri.path(), uri.query(), paging, has_next))
430 }
431 }
432 }
433 };
434
435 let followers = state.store.count_followers(&owner.id).await?;
436 let following = state.store.count_following(&owner.id).await?;
437 let follow = match &viewer {
438 Some(v) if v.id != owner.id => Some(
439 state
440 .store
441 .is_following(&v.id, &owner.id)
442 .await
443 .unwrap_or(false),
444 ),
445 _ => None,
446 };
447
448 let (jar, chrome) = build_chrome(&state, jar, viewer.clone(), uri.path().to_string());
449 let ptab = |t: ProfileTab, key: &str, label: &str| {
450 html! {
451 a href=(format!("{base}?tab={key}")) aria-current=[(tab == t).then_some("page")] { (label) }
452 }
453 };
454 let body = html! {
455 div class="profile-layout" {
456 aside class="profile-sidebar" { (profile_sidebar(&owner, followers, following, follow)) }
457 div class="profile-main" {
458 nav class="tabs profile-tabs" {
459 (ptab(ProfileTab::Repos, "repos", "Repositories"))
460 (ptab(ProfileTab::Groups, "groups", "Groups"))
461 (ptab(ProfileTab::Bookmarks, "bookmarks", "Bookmarks"))
462 }
463 (content)
464 }
465 }
466 };
467 Ok((jar, page(&chrome, &owner.username, body)).into_response())
468}
469
470/// The tabs on a user's profile. Followers/Following are reached from the
471/// sidebar counts rather than the main tab strip.
472#[derive(Debug, Clone, Copy, PartialEq, Eq)]
473enum ProfileTab {
474 Repos,
475 Groups,
476 Bookmarks,
477 Followers,
478 Following,
479}
480
481/// Render a list of users as follow-cards (avatar, name, bio, location, and a
482/// Follow/Unfollow button for signed-in viewers looking at other people).
483async fn user_list(
484 state: &AppState,
485 users: &[User],
486 viewer: Option<&User>,
487 empty: &str,
488) -> AppResult<Markup> {
489 if users.is_empty() {
490 return Ok(html! { div class="card" { p class="muted" { (empty) } } });
491 }
492 // Precompute the viewer's follow relationship to each listed user.
493 let mut following: std::collections::HashSet<String> = std::collections::HashSet::new();
494 if let Some(v) = viewer {
495 for u in users {
496 if u.id != v.id
497 && state
498 .store
499 .is_following(&v.id, &u.id)
500 .await
501 .unwrap_or(false)
502 {
503 following.insert(u.id.clone());
504 }
505 }
506 }
507 Ok(html! {
508 ul class="user-list card" {
509 @for u in users {
510 @let name = u.display_name.as_deref().unwrap_or(&u.username);
511 li class="user-row" {
512 img class="avatar" src={ "/avatar/" (u.username) } alt="";
513 div class="user-row-body" {
514 div class="user-row-title" {
515 a class="user-row-name" href={ "/" (u.username) } { (name) }
516 " " span class="muted" { (u.username) }
517 }
518 @if let Some(bio) = non_empty(u.bio.as_ref()) {
519 div class="user-row-bio muted markdown" { (markdown::render(bio)) }
520 }
521 @if let Some(loc) = non_empty(u.location.as_ref()) {
522 p class="user-row-loc muted" { (icon(Icon::MapPin)) " " (loc) }
523 }
524 }
525 @if let Some(v) = viewer {
526 @if v.id != u.id {
527 a class=(if following.contains(&u.id) { "btn" } else { "btn btn-primary" })
528 href={ "/user-follow/" (u.username) } {
529 (if following.contains(&u.id) { "Unfollow" } else { "Follow" })
530 }
531 }
532 }
533 }
534 }
535 }
536 })
537}
538
539/// Filter an arbitrary repo list to those `viewer` may read (for bookmarks).
540async fn filter_readable(
541 state: &AppState,
542 repos: Vec<Repo>,
543 viewer: Option<&User>,
544) -> AppResult<Vec<Repo>> {
545 let viewer_id = viewer.map(|v| auth::Viewer {
546 id: v.id.clone(),
547 is_admin: v.is_admin,
548 });
549 let mut out = Vec::new();
550 for repo in repos {
551 let collab = match viewer {
552 Some(v) => state
553 .store
554 .effective_permission(&repo.id, &v.id)
555 .await?
556 .and_then(|p| auth::Permission::parse(&p)),
557 None => None,
558 };
559 let access = auth::access(viewer_id.as_ref(), &repo, collab, state.allow_anonymous());
560 if access >= auth::Access::Read {
561 out.push(repo);
562 }
563 }
564 Ok(out)
565}
566
567/// Replace each bookmark entry's owner-id label/href with the real username.
568async fn resolve_bookmark_labels(
569 state: &AppState,
570 repos: &[Repo],
571 mut entries: Vec<RepoEntry>,
572) -> Vec<RepoEntry> {
573 let mut names: std::collections::HashMap<String, String> = std::collections::HashMap::new();
574 for (repo, entry) in repos.iter().zip(entries.iter_mut()) {
575 let owner = if let Some(n) = names.get(&repo.owner_id) {
576 n.clone()
577 } else {
578 let n = state
579 .store
580 .user_by_id(&repo.owner_id)
581 .await
582 .ok()
583 .flatten()
584 .map_or_else(|| repo.owner_id.clone(), |u| u.username);
585 names.insert(repo.owner_id.clone(), n.clone());
586 n
587 };
588 entry.href = format!("/{owner}/{}", repo.path);
589 entry.label = format!("{owner}/{}", repo.path);
590 }
591 entries
592}
593
594/// `GET /{owner}/{*rest}` — dispatch a repo path and view.
595pub async fn dispatch(
596 State(state): State<AppState>,
597 MaybeUser(viewer): MaybeUser,
598 jar: CookieJar,
599 headers: HeaderMap,
600 uri: Uri,
601 Query(dq): Query<DiffQuery>,
602 Path((owner_name, rest)): Path<(String, String)>,
603) -> AppResult<Response> {
604 // LFS object download shares the GET catch-all (`…/info/lfs/objects/{oid}`).
605 if !rest.contains("/-/")
606 && let Some((repo_path, tail)) = crate::lfs::split_lfs(&rest)
607 && let Some(oid) = tail.strip_prefix("objects/")
608 && !oid.contains('/')
609 {
610 return Ok(crate::lfs::download(
611 state,
612 jar,
613 headers,
614 owner_name,
615 repo_path.to_string(),
616 oid.to_string(),
617 )
618 .await);
619 }
620
621 // Smart-HTTP pack transport: `{owner}/{path}.git/info/refs`. Guarded so a blob
622 // path that merely contains ".git/" is never misrouted (those carry "/-/").
623 if !rest.contains("/-/")
624 && let Some((repo_path, tail)) = crate::git_http::split_git(&rest)
625 && matches!(tail, "info/refs" | "git-upload-pack" | "git-receive-pack")
626 {
627 return Ok(crate::git_http::http_get(
628 &state,
629 &jar,
630 &headers,
631 &owner_name,
632 repo_path,
633 tail,
634 dq.service.as_deref(),
635 )
636 .await);
637 }
638
639 if let Some((repo_path, view)) = rest.split_once("/-/") {
640 let ctx = resolve(&state, &owner_name, repo_path, viewer.as_ref())
641 .await?
642 .ok_or(AppError::NotFound)?;
643 dispatch_view(&state, viewer, jar, &uri, ctx, view, &dq).await
644 } else {
645 match resolve(&state, &owner_name, &rest, viewer.as_ref()).await? {
646 Some(ctx) => repo_home(&state, viewer, jar, &uri, ctx).await,
647 None => group_listing(&state, viewer, jar, &uri, &owner_name, &rest).await,
648 }
649 }
650}
651
652/// Dispatch the portion after `/-/`.
653async fn dispatch_view(
654 state: &AppState,
655 viewer: Option<User>,
656 jar: CookieJar,
657 uri: &Uri,
658 ctx: RepoCtx,
659 view: &str,
660 dq: &DiffQuery,
661) -> AppResult<Response> {
662 let (kind, rest) = view.split_once('/').unwrap_or((view, ""));
663 match kind {
664 "tree" => tree_view(state, viewer, jar, uri, ctx, rest).await,
665 "blob" => blob_view(state, viewer, jar, uri, ctx, rest).await,
666 "raw" => raw_view(state, ctx, rest).await,
667 "commits" => commits_view(state, viewer, jar, uri, ctx, rest).await,
668 "commit" => match rest.split_once('/') {
669 Some((sha, "context")) => context_partial(state, ctx, sha, dq).await,
670 _ => commit_view(state, viewer, jar, uri, ctx, rest, dq).await,
671 },
672 "branches" => branches_view(state, viewer, jar, uri, ctx).await,
673 "tags" => tags_view(state, viewer, jar, uri, ctx).await,
674 "search" => {
675 let branches = branch_names(state, &ctx.repo.id).await;
676 let header = repo_header(
677 state,
678 &ctx,
679 Tab::Search,
680 &ctx.repo.default_branch,
681 &branches,
682 );
683 let raw_query = dq.q.clone().unwrap_or_default();
684 crate::search::in_repo(state, viewer, jar, uri, &ctx, &raw_query, header).await
685 }
686 "issues" => {
687 let kind = crate::issues::Kind::issues();
688 match rest {
689 "" => {
690 let closed = uri.query().is_some_and(|q| q.contains("state=closed"));
691 crate::issues::list(state, viewer, jar, uri, ctx, kind, closed).await
692 }
693 "new" => crate::issues::new_form(state, viewer, jar, uri, ctx, kind).await,
694 n => match n.parse::<i64>() {
695 Ok(num) => crate::issues::view(state, viewer, jar, uri, ctx, kind, num).await,
696 Err(_) => Err(AppError::NotFound),
697 },
698 }
699 }
700 "pulls" => {
701 let kind = crate::issues::Kind::pulls();
702 match rest {
703 "" => {
704 let closed = uri.query().is_some_and(|q| q.contains("state=closed"));
705 crate::issues::list(state, viewer, jar, uri, ctx, kind, closed).await
706 }
707 "new" => crate::pulls::new_form(state, viewer, jar, uri, ctx).await,
708 n => match n.parse::<i64>() {
709 Ok(num) => crate::pulls::view(state, viewer, jar, uri, ctx, num).await,
710 Err(_) => Err(AppError::NotFound),
711 },
712 }
713 }
714 "releases" => match rest {
715 "" => crate::releases::list(state, viewer, jar, uri, ctx).await,
716 "new" => crate::releases::new_form(state, viewer, jar, uri, ctx).await,
717 tag => crate::releases::view(state, viewer, jar, uri, ctx, tag).await,
718 },
719 "archive" => Ok(crate::releases::archive(state, ctx, rest).await),
720 "settings" => repo_settings_view(state, viewer, jar, uri, ctx).await,
721 // Reserved `runs` slot returns 404 until built.
722 _ => Err(AppError::NotFound),
723 }
724}
725
726/// Split `"{ref}/{path..}"` into the ref (first segment) and the remaining path.
727/// Branch names containing slashes resolve to their first segment only — a known
728/// MVP limitation.
729fn split_ref_path(rest: &str) -> (String, String) {
730 match rest.split_once('/') {
731 Some((r, p)) => (r.to_string(), p.to_string()),
732 None => (rest.to_string(), String::new()),
733 }
734}
735
736/// The reference to browse, defaulting to the repo's default branch when none is
737/// given in the URL.
738fn ref_or_default(repo: &Repo, given: &str) -> String {
739 if given.is_empty() {
740 repo.default_branch.clone()
741 } else {
742 given.to_string()
743 }
744}
745
746// ---- Repo home ----
747
748/// The Linguist-style language bar: a proportional stripe plus a legend. Shows
749/// the top languages, lumping the remainder into "Other".
750fn language_bar(langs: &[crate::languages::LangStat]) -> Markup {
751 const MAX_SHOWN: usize = 8;
752 // Collapse the long tail into a single grey "Other" segment.
753 let (head, tail) = langs.split_at(langs.len().min(MAX_SHOWN));
754 let other: f64 = tail.iter().map(|l| l.percent).sum();
755 let pct = |p: f64| format!("{p:.1}%");
756 // A `<details>`: the bar is the always-visible summary; the legend expands on
757 // click and works without JavaScript.
758 html! {
759 details class="card lang-bar-wrap" {
760 summary class="lang-bar-summary" title="Show languages" {
761 div class="lang-bar" aria-hidden="true" {
762 @for l in head {
763 span class="lang-seg"
764 style=(format!("width:{:.2}%;background:{}", l.percent, l.color)) {}
765 }
766 @if other > 0.0 {
767 span class="lang-seg" style=(format!("width:{other:.2}%;background:#8b8b8b")) {}
768 }
769 }
770 }
771 ul class="lang-legend" {
772 @for l in head {
773 li {
774 span class="lang-dot" style=(format!("background:{}", l.color)) {}
775 span class="lang-name" { (l.name) }
776 span class="muted" { (pct(l.percent)) }
777 }
778 }
779 @if other > 0.0 {
780 li {
781 span class="lang-dot" style="background:#8b8b8b" {}
782 span class="lang-name" { "Other" }
783 span class="muted" { (pct(other)) }
784 }
785 }
786 }
787 }
788 }
789}
790
791async fn repo_home(
792 state: &AppState,
793 viewer: Option<User>,
794 jar: CookieJar,
795 uri: &Uri,
796 ctx: RepoCtx,
797) -> AppResult<Response> {
798 let rev_name = ctx.repo.default_branch.clone();
799 // Resolve the default branch; an unborn HEAD means an empty repo.
800 let resolved = git_read(state, &ctx.repo.id, {
801 let rev_name = rev_name.clone();
802 move |repo| repo.resolve_ref(&rev_name)
803 })
804 .await;
805
806 let body = match resolved {
807 Err(_) => empty_repo_body(state, &ctx),
808 Ok(rev) => {
809 let branches = branch_names(state, &ctx.repo.id).await;
810 landing_body(state, &ctx, &rev_name, &rev, &branches).await?
811 }
812 };
813
814 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
815 let title = format!("{}/{}", ctx.owner.username, ctx.repo.path);
816 Ok((jar, page(&chrome, &title, body)).into_response())
817}
818
819/// The rich repository landing for a resolved ref: search box, language bar, the
820/// root tree, and the README. Shared by the repo home (default branch) and the
821/// root tree view, so selecting a branch or tag in the ref switcher lands on the
822/// same page for that ref rather than a bare file list.
823async fn landing_body(
824 state: &AppState,
825 ctx: &RepoCtx,
826 rev_name: &str,
827 rev: &git::Resolved,
828 branches: &[String],
829) -> AppResult<Markup> {
830 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
831 let entries = git_read(state, &ctx.repo.id, {
832 let rev = rev.clone();
833 move |repo| repo.tree_entries(&rev, FsPath::new(""))
834 })
835 .await?;
836 let readme = load_readme(state, &ctx.repo.id, rev, &entries, &base, rev_name).await;
837 let annotations = git_read(state, &ctx.repo.id, {
838 let rev = rev.clone();
839 move |repo| repo.tree_annotations(&rev, FsPath::new(""))
840 })
841 .await
842 .unwrap_or_default();
843 // The language breakdown of this ref (best-effort).
844 let langs = git_read(state, &ctx.repo.id, {
845 let rev = rev.clone();
846 move |repo| repo.blob_sizes(&rev)
847 })
848 .await
849 .map(|files| crate::languages::breakdown(&files))
850 .unwrap_or_default();
851 Ok(html! {
852 (repo_header(state, ctx, Tab::Code, rev_name, branches))
853 form class="search-row repo-codesearch" method="get" action=(format!("{base}/-/search")) {
854 input type="search" name="q" placeholder="Search this repository…"
855 aria-label="Search this repository";
856 button class="btn" type="submit" { (icon(Icon::Search)) span { "Search" } }
857 }
858 @if !langs.is_empty() {
859 (language_bar(&langs))
860 }
861 (tree_table(&ctx.owner.username, &ctx.repo.path, rev_name, "", &entries, &annotations))
862 @if let Some(rendered) = readme {
863 div class="card markdown readme" { (rendered) }
864 }
865 })
866}
867
868/// Find and render the first matching README in the root tree, rewriting its
869/// relative links/images into the repo at `ref_name`.
870async fn load_readme(
871 state: &AppState,
872 repo_id: &str,
873 rev: &git::Resolved,
874 entries: &[git::TreeEntry],
875 base: &str,
876 ref_name: &str,
877) -> Option<Markup> {
878 let name = README_NAMES.iter().find(|candidate| {
879 entries
880 .iter()
881 .any(|e| e.kind == git::EntryKind::File && e.name.eq_ignore_ascii_case(candidate))
882 })?;
883 let name = (*name).to_string();
884 let blob = git_read(state, repo_id, {
885 let rev = rev.clone();
886 let name = name.clone();
887 move |repo| repo.blob(&rev, FsPath::new(&name))
888 })
889 .await
890 .ok()?;
891 if blob.is_binary {
892 return None;
893 }
894 let text = String::from_utf8_lossy(&blob.content);
895 // The README lives at the repo root, so relative paths resolve from "".
896 Some(markdown::render_repo(&text, base, ref_name, ""))
897}
898
899/// The "empty repository" landing with clone instructions.
900fn empty_repo_body(state: &AppState, ctx: &RepoCtx) -> Markup {
901 html! {
902 (repo_header(state, ctx, Tab::Code, &ctx.repo.default_branch, &[]))
903 div class="card" {
904 h2 { "This repository is empty" }
905 p class="muted" { "Push a commit to get started:" }
906 pre class="code" {
907 (clone_https(state, ctx)) "\n"
908 }
909 }
910 }
911}
912
913// ---- Tree view ----
914
915async fn tree_view(
916 state: &AppState,
917 viewer: Option<User>,
918 jar: CookieJar,
919 uri: &Uri,
920 ctx: RepoCtx,
921 rest: &str,
922) -> AppResult<Response> {
923 let (rev_name, path) = split_ref_path(rest);
924 let rev_name = ref_or_default(&ctx.repo, &rev_name);
925 let rev = git_read(state, &ctx.repo.id, {
926 let rev_name = rev_name.clone();
927 move |repo| repo.resolve_ref(&rev_name)
928 })
929 .await?;
930 let branches = branch_names(state, &ctx.repo.id).await;
931
932 // At the repo root, render the full landing (search, language bar, README)
933 // for this ref, so switching to a tag/branch mirrors the home page.
934 if path.is_empty() {
935 let body = landing_body(state, &ctx, &rev_name, &rev, &branches).await?;
936 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
937 let title = format!("{}/{}", ctx.owner.username, ctx.repo.path);
938 return Ok((jar, page(&chrome, &title, body)).into_response());
939 }
940
941 let entries = git_read(state, &ctx.repo.id, {
942 let rev = rev.clone();
943 let path = path.clone();
944 move |repo| repo.tree_entries(&rev, FsPath::new(&path))
945 })
946 .await?;
947 let annotations = git_read(state, &ctx.repo.id, {
948 let rev = rev.clone();
949 let path = path.clone();
950 move |repo| repo.tree_annotations(&rev, FsPath::new(&path))
951 })
952 .await
953 .unwrap_or_default();
954
955 let body = html! {
956 (repo_header(state, &ctx, Tab::Code, &rev_name, &branches))
957 (breadcrumb(&ctx.owner.username, &ctx.repo.path, &rev_name, &path))
958 (tree_table(&ctx.owner.username, &ctx.repo.path, &rev_name, &path, &entries, &annotations))
959 };
960 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
961 let title = format!("{}/{}: {path}", ctx.owner.username, ctx.repo.path);
962 Ok((jar, page(&chrome, &title, body)).into_response())
963}
964
965// ---- Blob view ----
966
967async fn blob_view(
968 state: &AppState,
969 viewer: Option<User>,
970 jar: CookieJar,
971 uri: &Uri,
972 ctx: RepoCtx,
973 rest: &str,
974) -> AppResult<Response> {
975 // `?display=source` forces the highlighted source over a rendered preview.
976 let source_view = uri.query().is_some_and(|q| q.contains("display=source"));
977 let (rev_name, path) = split_ref_path(rest);
978 let rev_name = ref_or_default(&ctx.repo, &rev_name);
979 if path.is_empty() {
980 return Err(AppError::NotFound);
981 }
982 let rev = git_read(state, &ctx.repo.id, {
983 let rev_name = rev_name.clone();
984 move |repo| repo.resolve_ref(&rev_name)
985 })
986 .await?;
987
988 // Read the blob and resolve attribute-driven facts (language, LFS) in one pass.
989 let (blob, attr_lang, is_lfs) = git_read(state, &ctx.repo.id, {
990 let rev = rev.clone();
991 let path = path.clone();
992 move |repo| {
993 let blob = repo.blob(&rev, FsPath::new(&path))?;
994 let attrs = repo.attributes_set(&rev.oid).ok();
995 let resolved = attrs.map(|set| set.attributes(&path));
996 let lang = resolved
997 .as_ref()
998 .and_then(|a| a.language().map(str::to_string));
999 let is_lfs = resolved.as_ref().is_some_and(git::Attributes::is_lfs)
1000 || (blob.size < 1024
1001 && blob
1002 .content
1003 .starts_with(b"version https://git-lfs.github.com/spec/v1"));
1004 Ok((blob, lang, is_lfs))
1005 }
1006 })
1007 .await?;
1008
1009 let content_body = render_blob(
1010 &ctx,
1011 &rev_name,
1012 &path,
1013 &blob,
1014 attr_lang.as_deref(),
1015 is_lfs,
1016 source_view,
1017 );
1018 // For a license file, detect it and show a rights summary above the content.
1019 let license = (!blob.is_binary && crate::license::is_license_path(&path))
1020 .then(|| crate::license::detect(&String::from_utf8_lossy(&blob.content)))
1021 .flatten();
1022 let branches = branch_names(state, &ctx.repo.id).await;
1023 let body = html! {
1024 (repo_header(state, &ctx, Tab::Code, &rev_name, &branches))
1025 (breadcrumb(&ctx.owner.username, &ctx.repo.path, &rev_name, &path))
1026 @if let Some(info) = license {
1027 (license_banner(info))
1028 }
1029 (content_body)
1030 };
1031 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
1032 let title = format!("{}/{}: {path}", ctx.owner.username, ctx.repo.path);
1033 Ok((jar, page(&chrome, &title, body)).into_response())
1034}
1035
1036/// The GitHub-style license summary banner: name, description, and the
1037/// permissions / conditions / limitations columns. Informational, not legal
1038/// advice.
1039fn license_banner(info: &crate::license::LicenseInfo) -> Markup {
1040 let column = |title: &str, items: &[&str], marker: Icon, class: &str| {
1041 html! {
1042 div class="license-col" {
1043 h4 { (title) }
1044 @if items.is_empty() {
1045 p class="muted license-none" { "None" }
1046 } @else {
1047 ul {
1048 @for item in items {
1049 li class=(class) { (icon(marker)) span { (item) } }
1050 }
1051 }
1052 }
1053 }
1054 }
1055 };
1056 html! {
1057 div class="card license-banner" {
1058 div class="license-head" {
1059 (icon(Icon::Scale))
1060 div {
1061 p class="muted license-eyebrow" { "This repository is licensed under the" }
1062 h3 { (info.name) " " span class="muted mono" { "(" (info.spdx) ")" } }
1063 }
1064 }
1065 p class="license-desc muted" { (info.description) }
1066 div class="license-cols" {
1067 (column("Permissions", info.permissions, Icon::Check, "ok"))
1068 (column("Conditions", info.conditions, Icon::Issue, "cond"))
1069 (column("Limitations", info.limitations, Icon::X, "no"))
1070 }
1071 p class="license-note muted" { "This is a summary, not legal advice." }
1072 }
1073 }
1074}
1075
1076/// Render a blob: a rendered preview for markdown, highlighted code with line
1077/// anchors, an inline image, or a binary notice.
1078fn render_blob(
1079 ctx: &RepoCtx,
1080 rev_name: &str,
1081 path: &str,
1082 blob: &git::Blob,
1083 attr_lang: Option<&str>,
1084 is_lfs: bool,
1085 source_view: bool,
1086) -> Markup {
1087 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
1088 let raw_url = format!("{base}/-/raw/{rev_name}/{path}");
1089 let blob_url = format!("{base}/-/blob/{rev_name}/{path}");
1090 let filename = path.rsplit('/').next().unwrap_or(path);
1091 let previewable = is_markdown(filename) && !blob.is_binary;
1092 let show_preview = previewable && !source_view;
1093
1094 html! {
1095 div class="blob-header" {
1096 div class="blob-meta" {
1097 span class="muted" { (blob.size) " bytes" }
1098 @if is_lfs { span class="badge lfs-pill" { "LFS" } }
1099 }
1100 div class="blob-actions" {
1101 @if previewable {
1102 a class=(toggle_class(show_preview)) href=(blob_url.clone()) { "Preview" }
1103 a class=(toggle_class(!show_preview)) href=(format!("{blob_url}?display=source")) { "Code" }
1104 }
1105 a class="btn" href=(raw_url) { "Raw" }
1106 button class="btn" type="button" data-clipboard=(raw_url) {
1107 (icon(Icon::Copy)) span { "Copy path" }
1108 }
1109 }
1110 }
1111 @if is_image(filename) {
1112 div class="blob-image" { img src=(raw_url) alt=(filename); }
1113 } @else if blob.is_binary {
1114 div class="card" {
1115 p class="muted" { "Binary file not shown." }
1116 p { a class="btn" href=(raw_url) { "Download" } }
1117 }
1118 } @else if show_preview {
1119 // Relative links in the file resolve from its own directory.
1120 @let dir = path.rsplit_once('/').map_or("", |(d, _)| d);
1121 div class="blob-preview markdown" {
1122 (markdown::render_repo(&String::from_utf8_lossy(&blob.content), &base, rev_name, dir))
1123 }
1124 } @else {
1125 (highlighted_code(FsPath::new(path), &blob.content, attr_lang))
1126 }
1127 }
1128}
1129
1130/// Whether a filename is a markdown document (rendered as a preview by default).
1131fn is_markdown(filename: &str) -> bool {
1132 matches!(
1133 filename
1134 .rsplit('.')
1135 .next()
1136 .map(str::to_ascii_lowercase)
1137 .as_deref(),
1138 Some("md" | "markdown" | "mdown" | "mkd")
1139 )
1140}
1141
1142/// Render highlighted code as a line-numbered table with `#L{n}` anchors.
1143fn highlighted_code(path: &FsPath, content: &[u8], attr_lang: Option<&str>) -> Markup {
1144 let highlighted = highlight::highlight(path, content, attr_lang);
1145 html! {
1146 div class="blob-code" {
1147 table class="blob" {
1148 tbody {
1149 @for (i, line) in highlighted.lines.iter().enumerate() {
1150 @let n = i + 1;
1151 tr id={ "L" (n) } {
1152 td class="lineno" { a href={ "#L" (n) } { (n) } }
1153 td class="code-line" {
1154 @for span in &line.spans {
1155 @match span.class {
1156 Some(class) => { span class=(class) { (span.text) } }
1157 None => { (span.text) }
1158 }
1159 }
1160 }
1161 }
1162 }
1163 }
1164 }
1165 }
1166 }
1167}
1168
1169// ---- Raw ----
1170
1171async fn raw_view(state: &AppState, ctx: RepoCtx, rest: &str) -> AppResult<Response> {
1172 let (rev_name, path) = split_ref_path(rest);
1173 let rev_name = ref_or_default(&ctx.repo, &rev_name);
1174 if path.is_empty() {
1175 return Err(AppError::NotFound);
1176 }
1177 let rev = git_read(state, &ctx.repo.id, {
1178 let rev_name = rev_name.clone();
1179 move |repo| repo.resolve_ref(&rev_name)
1180 })
1181 .await?;
1182 let blob = git_read(state, &ctx.repo.id, {
1183 let rev = rev.clone();
1184 let path = path.clone();
1185 move |repo| repo.blob(&rev, FsPath::new(&path))
1186 })
1187 .await?;
1188
1189 let filename = path.rsplit('/').next().unwrap_or(&path);
1190 // Serve images with their real type; everything else as text/plain to prevent
1191 // a stored-HTML blob from being interpreted as markup. `nosniff` reinforces it.
1192 let content_type = if is_image(filename) {
1193 mime_guess::from_path(filename)
1194 .first_raw()
1195 .unwrap_or("application/octet-stream")
1196 } else if blob.is_binary {
1197 "application/octet-stream"
1198 } else {
1199 "text/plain; charset=utf-8"
1200 };
1201 Ok((
1202 [
1203 (header::CONTENT_TYPE, content_type),
1204 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
1205 (header::CONTENT_DISPOSITION, "inline"),
1206 ],
1207 blob.content,
1208 )
1209 .into_response())
1210}
1211
1212// ---- Branches ----
1213
1214async fn branches_view(
1215 state: &AppState,
1216 viewer: Option<User>,
1217 jar: CookieJar,
1218 uri: &Uri,
1219 ctx: RepoCtx,
1220) -> AppResult<Response> {
1221 let all = git_read(state, &ctx.repo.id, git::Repo::branches).await?;
1222 let branch_list: Vec<String> = all.iter().map(|b| b.name.clone()).collect();
1223 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
1224 let now = crate::now_ms();
1225 let paging = Pagination::from_query(uri.query());
1226 let (branches, has_next) = page_slice(&all, paging);
1227 let body = html! {
1228 (repo_header(state, &ctx, Tab::Branches, &ctx.repo.default_branch, &branch_list))
1229 ul class="entry-list card" {
1230 @for b in &branches {
1231 li class="entry-row" {
1232 div class="entry-row-body" {
1233 a class="entry-row-title" href=(format!("{base}/-/tree/{}", b.name)) { (b.name) }
1234 div class="entry-row-meta muted branch-meta" {
1235 @if b.ahead > 0 || b.behind > 0 {
1236 span class="branch-counts" {
1237 @if b.ahead > 0 { span class="ahead" { "↑" (b.ahead) } }
1238 @if b.behind > 0 { span class="behind" { "↓" (b.behind) } }
1239 }
1240 }
1241 span { "Updated " (crate::activity::fmt_relative(b.tip.author.time_ms, now)) }
1242 span { "by " (b.tip.author.name) }
1243 }
1244 }
1245 div class="entry-row-actions" {
1246 @if b.is_default { span class="badge badge-default" { "default" } }
1247 a class="mono commit-sha-pill" href=(format!("{base}/-/commit/{}", b.oid)) {
1248 (b.oid.short())
1249 }
1250 }
1251 }
1252 }
1253 }
1254 @if has_next || paging.page > 1 {
1255 (pagination_nav(uri.path(), uri.query(), paging, has_next))
1256 }
1257 };
1258 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
1259 let title = format!("{}/{}: branches", ctx.owner.username, ctx.repo.path);
1260 Ok((jar, page(&chrome, &title, body)).into_response())
1261}
1262
1263// ---- Tags ----
1264
1265async fn tags_view(
1266 state: &AppState,
1267 viewer: Option<User>,
1268 jar: CookieJar,
1269 uri: &Uri,
1270 ctx: RepoCtx,
1271) -> AppResult<Response> {
1272 let all = git_read(state, &ctx.repo.id, git::Repo::tags).await?;
1273 let branches = branch_names(state, &ctx.repo.id).await;
1274 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
1275 let paging = Pagination::from_query(uri.query());
1276 let (tags, has_next) = page_slice(&all, paging);
1277 let body = html! {
1278 (repo_header(state, &ctx, Tab::Tags, &ctx.repo.default_branch, &branches))
1279 @if tags.is_empty() {
1280 div class="card" { p class="muted" { "No tags." } }
1281 } @else {
1282 ul class="entry-list card" {
1283 @for t in &tags {
1284 li class="entry-row" {
1285 div class="entry-row-body" {
1286 a class="entry-row-title" href=(format!("{base}/-/tree/{}", t.name)) { (t.name) }
1287 }
1288 div class="entry-row-actions" {
1289 a class="mono commit-sha-pill" href=(format!("{base}/-/commit/{}", t.oid)) {
1290 (t.oid.short())
1291 }
1292 }
1293 }
1294 }
1295 }
1296 @if has_next || paging.page > 1 {
1297 (pagination_nav(uri.path(), uri.query(), paging, has_next))
1298 }
1299 }
1300 };
1301 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
1302 let title = format!("{}/{}: tags", ctx.owner.username, ctx.repo.path);
1303 Ok((jar, page(&chrome, &title, body)).into_response())
1304}
1305
1306// ---- Settings (owner/admin only) ----
1307
1308/// `GET …/-/settings` — the repo admin page: rename, visibility, and delete.
1309/// Returns `404` for anyone without `Admin` access (existence never leaks).
1310async fn repo_settings_view(
1311 state: &AppState,
1312 viewer: Option<User>,
1313 jar: CookieJar,
1314 uri: &Uri,
1315 ctx: RepoCtx,
1316) -> AppResult<Response> {
1317 if ctx.access != auth::Access::Admin {
1318 return Err(AppError::NotFound);
1319 }
1320 let branches = branch_names(state, &ctx.repo.id).await;
1321 let collaborators = state.store.list_collaborators(&ctx.repo.id).await?;
1322 let labels = state.store.list_labels(&ctx.repo.id).await?;
1323 let mirrors = state.store.mirrors_for_repo(&ctx.repo.id).await?;
1324 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
1325
1326 let id = &ctx.repo.id;
1327 let vis = ctx.repo.visibility;
1328 // The group ceiling: a repo may not be set more visible than its group.
1329 let vis_ceiling = match &ctx.repo.group_id {
1330 Some(gid) => state.store.group_visibility_ceiling(gid).await?,
1331 None => Visibility::Public,
1332 };
1333 let vis_option = |value: Visibility, label: &str| -> Markup {
1334 // Hide options above the ceiling, but always keep the current value so the
1335 // form round-trips even if the ceiling later tightened.
1336 if value.rank() <= vis_ceiling.rank() || value == vis {
1337 html! { option value=(value.as_str()) selected[vis == value] { (label) } }
1338 } else {
1339 html! {}
1340 }
1341 };
1342 let body = html! {
1343 (repo_header(state, &ctx, Tab::Settings, &ctx.repo.default_branch, &branches))
1344 section class="listing" {
1345 h2 { "General" }
1346 div class="card" {
1347 form id="repo-general" method="post" action=(format!("/repo-settings/{id}/general")) {
1348 input type="hidden" name="_csrf" value=(chrome.csrf);
1349 label for="name" { "Repository name" }
1350 input type="text" id="name" name="name" value=(ctx.repo.name) required;
1351 label for="visibility" { "Visibility" }
1352 select id="visibility" name="visibility" {
1353 (vis_option(Visibility::Public, "Public — visible to everyone"))
1354 (vis_option(Visibility::Internal, "Internal — any signed-in user"))
1355 (vis_option(Visibility::Private, "Private — only you and collaborators"))
1356 }
1357 @if vis_ceiling.rank() < Visibility::Public.rank() {
1358 p class="muted field-hint" {
1359 "Capped by this repository's group, which is "
1360 (vis_ceiling.as_str()) "."
1361 }
1362 }
1363 label for="object_format" { "Object format" }
1364 select id="object_format" disabled title="Fixed at creation" {
1365 option { (ctx.repo.object_format.to_uppercase()) }
1366 }
1367 label { "Features" }
1368 label class="checkbox" {
1369 input type="checkbox" name="issues_enabled" value="1"
1370 checked[ctx.repo.issues_enabled]; " Issues"
1371 }
1372 label class="checkbox" {
1373 input type="checkbox" name="pulls_enabled" value="1"
1374 checked[ctx.repo.pulls_enabled]; " Pull requests"
1375 }
1376 label class="checkbox" {
1377 input type="checkbox" name="releases_enabled" value="1"
1378 checked[ctx.repo.releases_enabled]; " Releases"
1379 }
1380 }
1381 div class="settings-actions" {
1382 button class="btn btn-primary" type="submit" form="repo-general" { "Save changes" }
1383 @let archived = ctx.repo.archived_at.is_some();
1384 form method="post" action=(format!("/repo-settings/{id}/archive")) {
1385 input type="hidden" name="_csrf" value=(chrome.csrf);
1386 input type="hidden" name="archived" value=(if archived { "false" } else { "true" });
1387 button class="btn inline-btn" type="submit" {
1388 (if archived { "Unarchive" } else { "Archive" })
1389 }
1390 }
1391 }
1392 }
1393 }
1394 (collaborators_section(id, &chrome.csrf, &collaborators))
1395 // Labels are only used by issues and PRs, so hide the section when both
1396 // are disabled.
1397 @if ctx.repo.issues_enabled || ctx.repo.pulls_enabled {
1398 (labels_section(id, &chrome.csrf, &labels))
1399 }
1400 (mirror_section(id, &chrome.csrf, &mirrors))
1401 section class="listing" {
1402 h2 { "Danger zone" }
1403 div class="card danger-zone" {
1404 p { strong { "Delete this repository." } " This permanently removes it from fabrica; the on-disk copy is moved to trash." }
1405 form method="post" action=(format!("/repo-settings/{id}/delete")) {
1406 input type="hidden" name="_csrf" value=(chrome.csrf);
1407 button class="btn btn-danger" type="submit" { "Delete repository" }
1408 }
1409 }
1410 }
1411 };
1412 let title = format!("{}/{}: settings", ctx.owner.username, ctx.repo.path);
1413 Ok((jar, page(&chrome, &title, body)).into_response())
1414}
1415
1416/// The Mirror settings section: existing mirrors (push and pull) with their
1417/// status and controls, plus a form to add a push mirror.
1418fn mirror_section(id: &str, csrf: &str, mirrors: &[store::Mirror]) -> Markup {
1419 let now = crate::now_ms();
1420 html! {
1421 section class="listing" {
1422 h2 { "Mirror settings" }
1423 div class="card" {
1424 @if mirrors.is_empty() {
1425 p class="muted empty-note" { "No mirrors configured." }
1426 } @else {
1427 ul class="entry-list admin-list" {
1428 @for m in mirrors {
1429 li class="entry-row" {
1430 div class="entry-row-body" {
1431 span class="entry-row-title" {
1432 span class=(if m.direction == store::MirrorDirection::Push { "badge badge-verified" } else { "badge" }) {
1433 (if m.direction == store::MirrorDirection::Push { "push" } else { "pull" })
1434 }
1435 " " (m.remote_url)
1436 }
1437 div class="entry-row-meta muted" {
1438 @match m.last_sync_at {
1439 Some(t) => { "Last synced " (crate::activity::fmt_relative(t, now)) }
1440 None => { "Never synced" }
1441 }
1442 @if m.interval_secs > 0 { " · every " (fmt_duration(m.interval_secs)) } @else { " · manual" }
1443 @if let Some(err) = &m.last_error { " · " span class="mirror-error" { "error: " (err) } }
1444 }
1445 }
1446 div class="entry-row-actions admin-actions" {
1447 form method="post" action=(format!("/repo-settings/{id}/mirror/{}/sync", m.id)) class="inline-form" {
1448 input type="hidden" name="_csrf" value=(csrf);
1449 button class="btn" type="submit" { "Sync now" }
1450 }
1451 form method="post" action=(format!("/repo-settings/{id}/mirror/{}/delete", m.id)) class="inline-form" {
1452 input type="hidden" name="_csrf" value=(csrf);
1453 button class="btn btn-danger" type="submit" { "Delete" }
1454 }
1455 }
1456 }
1457 }
1458 }
1459 }
1460 div class="mirror-add" {
1461 form method="post" action=(format!("/repo-settings/{id}/mirror")) {
1462 input type="hidden" name="_csrf" value=(csrf);
1463 label for="m-url" { "Git remote repository URL" }
1464 input type="url" id="m-url" name="remote_url" placeholder="https://example.com/owner/repo.git" required;
1465 label for="m-filter" { "Branch filter (optional)" }
1466 input type="text" id="m-filter" name="branch_filter" placeholder="main release/*";
1467 p class="muted field-hint" { "Space- or comma-separated branch patterns. Leave blank to mirror all branches. Tags are always mirrored." }
1468 label { "Authorization (optional)" }
1469 div class="admin-form-row" {
1470 input type="text" name="username" placeholder="username" autocomplete="off";
1471 input type="password" name="secret" placeholder="password or access token" autocomplete="off";
1472 }
1473 label for="m-interval" { "Mirror interval" }
1474 input type="text" id="m-interval" name="interval" value="1h0m0s";
1475 p class="muted field-hint" { "Time units are “h”, “m”, “s”. 0 disables periodic sync (minimum 10m)." }
1476 div class="admin-form-actions" {
1477 button class="btn btn-primary inline-btn" type="submit" { "Add push mirror" }
1478 label class="checkbox" { input type="checkbox" name="sync_on_push" value="1"; " Sync when commits are pushed" }
1479 }
1480 }
1481 }
1482 }
1483 }
1484 }
1485}
1486
1487/// Parse a duration like `1h0m0s`, `30m`, or `0` into seconds. Returns `None` on
1488/// a malformed value; `Some(0)` disables periodic sync.
1489pub(crate) fn parse_duration(input: &str) -> Option<i64> {
1490 let s = input.trim();
1491 if s == "0" {
1492 return Some(0);
1493 }
1494 // A bare integer is treated as seconds.
1495 if let Ok(n) = s.parse::<i64>() {
1496 return (n >= 0).then_some(n);
1497 }
1498 let mut total: i64 = 0;
1499 let mut num = String::new();
1500 let mut saw_unit = false;
1501 for ch in s.chars() {
1502 if ch.is_ascii_digit() {
1503 num.push(ch);
1504 } else {
1505 let value: i64 = num.parse().ok()?;
1506 num.clear();
1507 saw_unit = true;
1508 total += match ch {
1509 'h' | 'H' => value * 3600,
1510 'm' | 'M' => value * 60,
1511 's' | 'S' => value,
1512 _ => return None,
1513 };
1514 }
1515 }
1516 // Trailing digits with no unit, or no units at all, is malformed.
1517 if !num.is_empty() || !saw_unit {
1518 return None;
1519 }
1520 Some(total)
1521}
1522
1523/// Format seconds as `1h0m0s` (matching the input the form accepts).
1524fn fmt_duration(secs: i64) -> String {
1525 let h = secs / 3600;
1526 let m = (secs % 3600) / 60;
1527 let s = secs % 60;
1528 format!("{h}h{m}m{s}s")
1529}
1530
1531/// The Collaborators section of the repo settings page: the current list with
1532/// remove buttons, and the add form.
1533fn collaborators_section(id: &str, csrf: &str, collaborators: &[store::Collaborator]) -> Markup {
1534 html! {
1535 section class="listing" {
1536 h2 { "Collaborators" }
1537 (collaborators_card(&format!("/repo-settings/{id}"), csrf, collaborators))
1538 }
1539 }
1540}
1541
1542/// The collaborators card — an avatar/name/role list with remove buttons, then
1543/// an add row. Shared by the repo and group settings pages; `base` is the action
1544/// URL prefix (`/repo-settings/{id}` or `/group-settings/{id}`).
1545pub(crate) fn collaborators_card(
1546 base: &str,
1547 csrf: &str,
1548 collaborators: &[store::Collaborator],
1549) -> Markup {
1550 let perm_option = |value: &str, label: &str| html! { option value=(value) { (label) } };
1551 html! {
1552 div class="card" {
1553 @if collaborators.is_empty() {
1554 p class="muted" { "No collaborators yet." }
1555 } @else {
1556 ul class="entry-list collab-list" {
1557 @for c in collaborators {
1558 li class="entry-row" {
1559 div class="collab-ident" {
1560 img class="avatar avatar-sm" src=(format!("/avatar/{}", c.username)) alt="";
1561 a class="entry-row-title" href={ "/" (c.username) } { (c.username) }
1562 span class="collab-perm" { (c.permission) }
1563 }
1564 div class="entry-row-actions" {
1565 form method="post" action=(format!("{base}/collaborators/remove")) {
1566 input type="hidden" name="_csrf" value=(csrf);
1567 input type="hidden" name="user_id" value=(c.user_id);
1568 button class="btn btn-danger" type="submit" { "Remove" }
1569 }
1570 }
1571 }
1572 }
1573 }
1574 }
1575 form class="collab-add" method="post" action=(format!("{base}/collaborators")) {
1576 input type="hidden" name="_csrf" value=(csrf);
1577 input type="text" name="username" placeholder="Username" aria-label="Username" required;
1578 select name="permission" aria-label="Permission" {
1579 (perm_option("read", "Read"))
1580 (perm_option("write", "Write"))
1581 (perm_option("admin", "Admin"))
1582 }
1583 button class="btn btn-primary inline-btn" type="submit" { "Add" }
1584 }
1585 }
1586 }
1587}
1588
1589/// A coloured label chip. A scoped label (`scope: value`) renders two-tone: the
1590/// scope on a neutral theme background, the value in the label's colour. A plain
1591/// label is a single coloured pill.
1592pub(crate) fn label_chip(label: &model::Label) -> Markup {
1593 let color = format!("--label-color: {}", css_color(&label.color));
1594 if let Some((scope, value)) = label.name.split_once(": ") {
1595 html! {
1596 span class="label-chip label-chip-scoped" style=(color) {
1597 span class="label-scope" { (scope.trim()) }
1598 span class="label-value" { (value.trim()) }
1599 }
1600 }
1601 } else {
1602 html! {
1603 span class="label-chip" style=(color) { (label.name) }
1604 }
1605 }
1606}
1607
1608/// Sanitize a stored colour for inline CSS: only `#` + up to 8 hex digits.
1609pub(crate) fn css_color(color: &str) -> String {
1610 let hex: String = color
1611 .trim_start_matches('#')
1612 .chars()
1613 .filter(char::is_ascii_hexdigit)
1614 .take(8)
1615 .collect();
1616 if hex.is_empty() {
1617 "#888888".to_string()
1618 } else {
1619 format!("#{hex}")
1620 }
1621}
1622
1623/// The Labels section of the repo settings page.
1624fn labels_section(id: &str, csrf: &str, labels: &[model::Label]) -> Markup {
1625 html! {
1626 section class="listing" {
1627 h2 { "Labels" }
1628 div class="card" {
1629 @if labels.is_empty() {
1630 p class="muted" { "No labels yet. Use a scope like " code { "type: backend" } " for grouped labels." }
1631 } @else {
1632 ul class="entry-list label-list" {
1633 @for l in labels {
1634 li class="entry-row" {
1635 div class="entry-row-body" {
1636 (label_chip(l))
1637 @if let Some(desc) = &l.description {
1638 div class="entry-row-meta muted" { (desc) }
1639 }
1640 }
1641 div class="entry-row-actions" {
1642 form method="post" action=(format!("/repo-settings/{id}/labels/delete")) {
1643 input type="hidden" name="_csrf" value=(csrf);
1644 input type="hidden" name="label_id" value=(l.id);
1645 button class="btn btn-danger" type="submit" { "Delete" }
1646 }
1647 }
1648 }
1649 }
1650 }
1651 }
1652 form class="label-add" method="post" action=(format!("/repo-settings/{id}/labels")) {
1653 input type="hidden" name="_csrf" value=(csrf);
1654 input type="text" name="name" placeholder="name or scope: value" aria-label="Label name" required;
1655 input type="color" name="color" value="#2f81f7" aria-label="Colour";
1656 input type="text" name="description" placeholder="Description (optional)" aria-label="Description";
1657 button class="btn btn-primary inline-btn" type="submit" { "Add label" }
1658 }
1659 }
1660 }
1661 }
1662}
1663
1664/// The add-label form.
1665#[derive(Debug, Deserialize)]
1666pub struct LabelAddForm {
1667 /// Label name (may be `scope: value`).
1668 #[serde(default)]
1669 name: String,
1670 /// Hex colour.
1671 #[serde(default)]
1672 color: String,
1673 /// Optional description.
1674 #[serde(default)]
1675 description: String,
1676 /// CSRF token.
1677 #[serde(rename = "_csrf")]
1678 csrf: String,
1679}
1680
1681/// `POST /repo-settings/{id}/labels` — create a label.
1682pub async fn settings_label_add(
1683 State(state): State<AppState>,
1684 MaybeUser(viewer): MaybeUser,
1685 jar: CookieJar,
1686 headers: HeaderMap,
1687 Path(id): Path<String>,
1688 Form(form): Form<LabelAddForm>,
1689) -> Response {
1690 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
1691 return AppError::Forbidden.into_response();
1692 }
1693 let result = async {
1694 let repo = require_admin_repo(&state, viewer.as_ref(), &id).await?;
1695 let name = form.name.trim();
1696 if name.is_empty() {
1697 return Err(AppError::BadRequest("Label name is required.".to_string()));
1698 }
1699 let color = css_color(&form.color);
1700 let desc = form.description.trim();
1701 state
1702 .store
1703 .create_label(&repo.id, name, &color, (!desc.is_empty()).then_some(desc))
1704 .await
1705 .map_err(|e| match e {
1706 store::StoreError::Conflict { .. } => {
1707 AppError::BadRequest("A label with that name already exists.".to_string())
1708 }
1709 other => AppError::from(other),
1710 })?;
1711 Ok::<String, AppError>(repo_settings_url(&state, &repo).await)
1712 }
1713 .await;
1714 match result {
1715 Ok(dest) => Redirect::to(&dest).into_response(),
1716 Err(err) => err.into_response(),
1717 }
1718}
1719
1720/// The delete-label form.
1721#[derive(Debug, Deserialize)]
1722pub struct LabelDeleteForm {
1723 /// The label id.
1724 #[serde(default)]
1725 label_id: String,
1726 /// CSRF token.
1727 #[serde(rename = "_csrf")]
1728 csrf: String,
1729}
1730
1731/// `POST /repo-settings/{id}/labels/delete` — delete a label.
1732pub async fn settings_label_delete(
1733 State(state): State<AppState>,
1734 MaybeUser(viewer): MaybeUser,
1735 jar: CookieJar,
1736 headers: HeaderMap,
1737 Path(id): Path<String>,
1738 Form(form): Form<LabelDeleteForm>,
1739) -> Response {
1740 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
1741 return AppError::Forbidden.into_response();
1742 }
1743 let result = async {
1744 let repo = require_admin_repo(&state, viewer.as_ref(), &id).await?;
1745 // Only delete a label that belongs to this repo.
1746 if let Some(label) = state.store.label_by_id(&form.label_id).await?
1747 && label.repo_id == repo.id
1748 {
1749 state.store.delete_label(&label.id).await?;
1750 }
1751 Ok::<String, AppError>(repo_settings_url(&state, &repo).await)
1752 }
1753 .await;
1754 match result {
1755 Ok(dest) => Redirect::to(&dest).into_response(),
1756 Err(err) => err.into_response(),
1757 }
1758}
1759
1760/// The add-push-mirror form.
1761#[derive(Debug, serde::Deserialize)]
1762pub struct MirrorAddForm {
1763 #[serde(default)]
1764 remote_url: String,
1765 #[serde(default)]
1766 branch_filter: String,
1767 #[serde(default)]
1768 username: String,
1769 #[serde(default)]
1770 secret: String,
1771 #[serde(default)]
1772 sync_on_push: Option<String>,
1773 #[serde(default)]
1774 interval: String,
1775 #[serde(rename = "_csrf")]
1776 csrf: String,
1777}
1778
1779/// A CSRF-only form for mirror actions (delete, sync).
1780#[derive(Debug, serde::Deserialize)]
1781pub struct MirrorActionForm {
1782 #[serde(rename = "_csrf")]
1783 csrf: String,
1784}
1785
1786/// `POST /repo-settings/{id}/mirror` — add a push mirror.
1787pub async fn settings_mirror_add(
1788 State(state): State<AppState>,
1789 MaybeUser(viewer): MaybeUser,
1790 jar: CookieJar,
1791 headers: HeaderMap,
1792 Path(id): Path<String>,
1793 Form(form): Form<MirrorAddForm>,
1794) -> Response {
1795 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
1796 return AppError::Forbidden.into_response();
1797 }
1798 let result = async {
1799 let repo = require_admin_repo(&state, viewer.as_ref(), &id).await?;
1800 let url = form.remote_url.trim();
1801 if url.is_empty() {
1802 return Err(AppError::BadRequest(
1803 "A remote URL is required.".to_string(),
1804 ));
1805 }
1806 let interval = parse_duration(&form.interval)
1807 .ok_or_else(|| AppError::BadRequest("Invalid mirror interval.".to_string()))?;
1808 // Enforce the documented 10-minute floor on periodic sync.
1809 let interval = if interval > 0 && interval < 600 {
1810 600
1811 } else {
1812 interval
1813 };
1814 let opt = |s: &str| {
1815 let t = s.trim();
1816 (!t.is_empty()).then(|| t.to_string())
1817 };
1818 state
1819 .store
1820 .create_mirror(store::NewMirror {
1821 repo_id: repo.id.clone(),
1822 direction: store::MirrorDirection::Push,
1823 remote_url: url.to_string(),
1824 username: opt(&form.username),
1825 secret: opt(&form.secret),
1826 branch_filter: opt(&form.branch_filter),
1827 interval_secs: interval,
1828 sync_on_push: form.sync_on_push.is_some(),
1829 })
1830 .await?;
1831 Ok::<String, AppError>(repo_settings_url(&state, &repo).await)
1832 }
1833 .await;
1834 match result {
1835 Ok(dest) => Redirect::to(&dest).into_response(),
1836 Err(err) => err.into_response(),
1837 }
1838}
1839
1840/// `POST /repo-settings/{id}/mirror/{mid}/delete` — remove a mirror.
1841pub async fn settings_mirror_delete(
1842 State(state): State<AppState>,
1843 MaybeUser(viewer): MaybeUser,
1844 jar: CookieJar,
1845 headers: HeaderMap,
1846 Path((id, mid)): Path<(String, String)>,
1847 Form(form): Form<MirrorActionForm>,
1848) -> Response {
1849 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
1850 return AppError::Forbidden.into_response();
1851 }
1852 let result = async {
1853 let repo = require_admin_repo(&state, viewer.as_ref(), &id).await?;
1854 if let Some(m) = state.store.mirror_by_id(&mid).await?
1855 && m.repo_id == repo.id
1856 {
1857 state.store.delete_mirror(&m.id).await?;
1858 }
1859 Ok::<String, AppError>(repo_settings_url(&state, &repo).await)
1860 }
1861 .await;
1862 match result {
1863 Ok(dest) => Redirect::to(&dest).into_response(),
1864 Err(err) => err.into_response(),
1865 }
1866}
1867
1868/// `POST /repo-settings/{id}/mirror/{mid}/sync` — trigger a sync now (background).
1869pub async fn settings_mirror_sync(
1870 State(state): State<AppState>,
1871 MaybeUser(viewer): MaybeUser,
1872 jar: CookieJar,
1873 headers: HeaderMap,
1874 Path((id, mid)): Path<(String, String)>,
1875 Form(form): Form<MirrorActionForm>,
1876) -> Response {
1877 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
1878 return AppError::Forbidden.into_response();
1879 }
1880 let result = async {
1881 let repo = require_admin_repo(&state, viewer.as_ref(), &id).await?;
1882 if let Some(m) = state.store.mirror_by_id(&mid).await?
1883 && m.repo_id == repo.id
1884 {
1885 // Sync off-request so a slow remote never blocks the response.
1886 let store = state.store.clone();
1887 let config = state.config.clone();
1888 tokio::spawn(async move {
1889 let _ = mirror::sync_one(&store, &config, &m).await;
1890 });
1891 }
1892 Ok::<String, AppError>(repo_settings_url(&state, &repo).await)
1893 }
1894 .await;
1895 match result {
1896 Ok(dest) => Redirect::to(&dest).into_response(),
1897 Err(err) => err.into_response(),
1898 }
1899}
1900
1901/// Load a repo by id and require the viewer to be able to read it, else `404`.
1902pub(crate) async fn require_readable_repo(
1903 state: &AppState,
1904 viewer: Option<&User>,
1905 repo_id: &str,
1906) -> Result<Repo, AppError> {
1907 let repo = state
1908 .store
1909 .repo_by_id(repo_id)
1910 .await?
1911 .ok_or(AppError::NotFound)?;
1912 let viewer_id = viewer.map(|u| auth::Viewer {
1913 id: u.id.clone(),
1914 is_admin: u.is_admin,
1915 });
1916 let collaborator = match viewer {
1917 Some(u) => state
1918 .store
1919 .effective_permission(&repo.id, &u.id)
1920 .await?
1921 .and_then(|p| auth::Permission::parse(&p)),
1922 None => None,
1923 };
1924 let access = auth::access(
1925 viewer_id.as_ref(),
1926 &repo,
1927 collaborator,
1928 state.allow_anonymous(),
1929 );
1930 if access == auth::Access::None {
1931 return Err(AppError::NotFound);
1932 }
1933 Ok(repo)
1934}
1935
1936/// `GET /repo-bookmark/{id}` — toggle the viewer's bookmark of a readable repo,
1937/// then return to it. (A GET toggle, matching the fork link; bookmarks are a
1938/// trivial, reversible per-user flag.)
1939pub async fn bookmark_toggle(
1940 State(state): State<AppState>,
1941 MaybeUser(viewer): MaybeUser,
1942 Path(id): Path<String>,
1943) -> AppResult<Response> {
1944 let Some(user) = viewer.clone() else {
1945 return Ok(Redirect::to("/login").into_response());
1946 };
1947 let repo = require_readable_repo(&state, viewer.as_ref(), &id).await?;
1948 if state.store.is_bookmarked(&user.id, &repo.id).await? {
1949 state.store.remove_bookmark(&user.id, &repo.id).await?;
1950 } else {
1951 state.store.add_bookmark(&user.id, &repo.id).await?;
1952 }
1953 let owner = state
1954 .store
1955 .user_by_id(&repo.owner_id)
1956 .await?
1957 .map_or_else(|| repo.owner_id.clone(), |u| u.username);
1958 Ok(Redirect::to(&format!("/{owner}/{}", repo.path)).into_response())
1959}
1960
1961/// `GET /user-follow/{username}` — toggle following a user, then return to their
1962/// profile. (A GET toggle, matching the bookmark link.)
1963pub async fn follow_toggle(
1964 State(state): State<AppState>,
1965 MaybeUser(viewer): MaybeUser,
1966 Path(username): Path<String>,
1967) -> AppResult<Response> {
1968 let Some(me) = viewer else {
1969 return Ok(Redirect::to("/login").into_response());
1970 };
1971 let Some(target) = state.store.user_by_username(&username).await? else {
1972 return Err(AppError::NotFound);
1973 };
1974 if target.id != me.id {
1975 if state.store.is_following(&me.id, &target.id).await? {
1976 state.store.unfollow(&me.id, &target.id).await?;
1977 } else {
1978 state.store.follow(&me.id, &target.id).await?;
1979 }
1980 }
1981 Ok(Redirect::to(&format!("/{}", target.username)).into_response())
1982}
1983
1984/// `GET /repo-fork/{id}` — confirm forking a readable repo to your account.
1985pub async fn fork_confirm(
1986 State(state): State<AppState>,
1987 MaybeUser(viewer): MaybeUser,
1988 jar: CookieJar,
1989 uri: Uri,
1990 Path(id): Path<String>,
1991) -> AppResult<Response> {
1992 let Some(user) = viewer.clone() else {
1993 return Ok(Redirect::to("/login").into_response());
1994 };
1995 let source = require_readable_repo(&state, viewer.as_ref(), &id).await?;
1996 let source_owner = state
1997 .store
1998 .user_by_id(&source.owner_id)
1999 .await?
2000 .ok_or(AppError::NotFound)?;
2001 let (jar, chrome) = build_chrome(&state, jar, Some(user.clone()), uri.path().to_string());
2002 let body = html! {
2003 div class="new-repo" {
2004 h1 { "Fork a repository" }
2005 div class="card" {
2006 p { "Create a copy of "
2007 strong { (source_owner.username) "/" (source.path) }
2008 " under your account, " strong { (user.username) } "." }
2009 form method="post" action=(format!("/repo-fork/{}", source.id)) {
2010 input type="hidden" name="_csrf" value=(chrome.csrf);
2011 label for="fk-name" { "Repository name" }
2012 div class="admin-form-row admin-form-row-last" {
2013 input type="text" id="fk-name" name="name" value=(source.name) required;
2014 button class="btn btn-primary inline-btn" type="submit" { "Fork repository" }
2015 }
2016 }
2017 }
2018 }
2019 };
2020 Ok((jar, page(&chrome, "Fork a repository", body)).into_response())
2021}
2022
2023/// The fork form.
2024#[derive(Debug, serde::Deserialize)]
2025pub struct ForkForm {
2026 #[serde(default)]
2027 name: String,
2028 #[serde(rename = "_csrf")]
2029 csrf: String,
2030}
2031
2032/// `POST /repo-fork/{id}` — create the fork (row + on-disk copy) and redirect.
2033pub async fn fork_create(
2034 State(state): State<AppState>,
2035 MaybeUser(viewer): MaybeUser,
2036 jar: CookieJar,
2037 headers: HeaderMap,
2038 Path(id): Path<String>,
2039 Form(form): Form<ForkForm>,
2040) -> Response {
2041 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
2042 return AppError::Forbidden.into_response();
2043 }
2044 let Some(user) = viewer.clone() else {
2045 return Redirect::to("/login").into_response();
2046 };
2047 let source = match require_readable_repo(&state, viewer.as_ref(), &id).await {
2048 Ok(repo) => repo,
2049 Err(e) => return e.into_response(),
2050 };
2051 match do_fork(&state, &user, &source, form.name.trim()).await {
2052 Ok(path) => Redirect::to(&format!("/{}/{path}", user.username)).into_response(),
2053 Err(msg) => AppError::BadRequest(msg).into_response(),
2054 }
2055}
2056
2057/// Create the fork repo (row + bare repo on disk seeded from the source).
2058async fn do_fork(
2059 state: &AppState,
2060 user: &User,
2061 source: &Repo,
2062 name: &str,
2063) -> Result<String, String> {
2064 let leaf = name.trim();
2065 if leaf.is_empty() {
2066 return Err("Repository name is required.".to_string());
2067 }
2068 let repo = state
2069 .store
2070 .create_repo(store::NewRepo {
2071 owner_id: user.id.clone(),
2072 group_id: None,
2073 name: leaf.to_string(),
2074 path: leaf.to_string(),
2075 description: source.description.clone(),
2076 visibility: source.visibility,
2077 default_branch: source.default_branch.clone(),
2078 })
2079 .await
2080 .map_err(|e| match e {
2081 store::StoreError::Conflict { .. } => {
2082 "You already have a repository with that name.".to_string()
2083 }
2084 store::StoreError::Name(_) => "Invalid repository name.".to_string(),
2085 other => other.to_string(),
2086 })?;
2087 let _ = state.store.set_fork_parent(&repo.id, &source.id).await;
2088
2089 let format = git::ObjectFormat::from_token(&source.object_format);
2090 if format != git::ObjectFormat::Sha1 {
2091 let _ = state
2092 .store
2093 .set_object_format(&repo.id, format.as_str())
2094 .await;
2095 }
2096 let repo_dir = &state.config.storage.repo_dir;
2097 let hook = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("fabrica"));
2098 if let Err(e) =
2099 git::create_bare_with_format(repo_dir, &repo.id, &source.default_branch, &hook, format)
2100 {
2101 let _ = state.store.delete_repo(&repo.id).await;
2102 return Err(format!("Could not create the fork on disk: {e}"));
2103 }
2104 // Seed the fork by fetching the source's refs over a local path.
2105 let (Ok(src_dir), Ok(dest_dir)) = (
2106 git::repo_path(repo_dir, &source.id),
2107 git::repo_path(repo_dir, &repo.id),
2108 ) else {
2109 let _ = state.store.delete_repo(&repo.id).await;
2110 return Err("Could not locate the repositories on disk.".to_string());
2111 };
2112 let home = state.config.storage.data_dir.join("tmp");
2113 let src_url = src_dir.to_string_lossy().into_owned();
2114 if let Err(e) = git::mirror::fetch(
2115 &state.config.git.binary,
2116 &dest_dir,
2117 &src_url,
2118 git::mirror::RemoteCred::default(),
2119 &home,
2120 )
2121 .await
2122 {
2123 let _ = state.store.delete_repo(&repo.id).await;
2124 return Err(format!("Could not copy the repository contents: {e}"));
2125 }
2126 let size = i64::try_from(crate::admin::dir_size(&dest_dir)).unwrap_or(0);
2127 let _ = state.store.record_push(&repo.id, size).await;
2128 Ok(repo.path)
2129}
2130
2131/// Resolve a repo by id and require the viewer to have `Admin` access, else a
2132/// `404` (so a non-admin cannot even confirm the repo exists).
2133async fn require_admin_repo(
2134 state: &AppState,
2135 viewer: Option<&User>,
2136 repo_id: &str,
2137) -> AppResult<Repo> {
2138 let repo = state
2139 .store
2140 .repo_by_id(repo_id)
2141 .await?
2142 .ok_or(AppError::NotFound)?;
2143 let viewer_id = viewer.map(|u| auth::Viewer {
2144 id: u.id.clone(),
2145 is_admin: u.is_admin,
2146 });
2147 let collab = match viewer {
2148 Some(u) => state
2149 .store
2150 .effective_permission(&repo.id, &u.id)
2151 .await?
2152 .and_then(|p| auth::Permission::parse(&p)),
2153 None => None,
2154 };
2155 let access = auth::access(viewer_id.as_ref(), &repo, collab, state.allow_anonymous());
2156 if access == auth::Access::Admin {
2157 Ok(repo)
2158 } else {
2159 Err(AppError::NotFound)
2160 }
2161}
2162
2163/// The repo general-settings form (rename + visibility).
2164#[derive(Debug, Deserialize)]
2165pub struct RepoGeneralForm {
2166 /// The new leaf name (the group prefix is preserved).
2167 #[serde(default)]
2168 name: String,
2169 /// The chosen visibility token.
2170 #[serde(default)]
2171 visibility: String,
2172 /// Enable issues (checkbox present when on).
2173 #[serde(default)]
2174 issues_enabled: Option<String>,
2175 /// Enable pull requests.
2176 #[serde(default)]
2177 pulls_enabled: Option<String>,
2178 /// Enable releases.
2179 #[serde(default)]
2180 releases_enabled: Option<String>,
2181 /// CSRF token.
2182 #[serde(rename = "_csrf")]
2183 csrf: String,
2184}
2185
2186/// `POST /repo-settings/{id}/general` — rename and set visibility.
2187pub async fn settings_general(
2188 State(state): State<AppState>,
2189 MaybeUser(viewer): MaybeUser,
2190 jar: CookieJar,
2191 headers: HeaderMap,
2192 Path(id): Path<String>,
2193 Form(form): Form<RepoGeneralForm>,
2194) -> Response {
2195 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
2196 return AppError::Forbidden.into_response();
2197 }
2198 let result = async {
2199 let repo = require_admin_repo(&state, viewer.as_ref(), &id).await?;
2200 // Preserve any group prefix; only the leaf changes.
2201 let new_name = form.name.trim();
2202 let new_path = match repo.path.rsplit_once('/') {
2203 Some((group, _)) => format!("{group}/{new_name}"),
2204 None => new_name.to_string(),
2205 };
2206 state
2207 .store
2208 .rename_repo(&repo.id, new_name, &new_path)
2209 .await?;
2210 if let Some(mut vis) = Visibility::from_token(&form.visibility) {
2211 // A fork may never be more visible than its parent (no laundering a
2212 // private repo into a public one).
2213 if let Some(parent_id) = &repo.fork_parent_id
2214 && let Some(parent) = state.store.repo_by_id(parent_id).await?
2215 && vis.rank() > parent.visibility.rank()
2216 {
2217 vis = parent.visibility;
2218 }
2219 // Nor more visible than its group ceiling.
2220 if let Some(gid) = &repo.group_id {
2221 let ceiling = state.store.group_visibility_ceiling(gid).await?;
2222 if vis.rank() > ceiling.rank() {
2223 vis = ceiling;
2224 }
2225 }
2226 state.store.set_visibility(&repo.id, vis).await?;
2227 }
2228 state
2229 .store
2230 .set_feature_enabled(&repo.id, "issues", form.issues_enabled.is_some())
2231 .await?;
2232 state
2233 .store
2234 .set_feature_enabled(&repo.id, "pulls", form.pulls_enabled.is_some())
2235 .await?;
2236 state
2237 .store
2238 .set_feature_enabled(&repo.id, "releases", form.releases_enabled.is_some())
2239 .await?;
2240 let owner = state
2241 .store
2242 .user_by_id(&repo.owner_id)
2243 .await?
2244 .map_or_else(|| repo.owner_id.clone(), |u| u.username);
2245 Ok::<String, AppError>(format!("/{owner}/{new_path}/-/settings"))
2246 }
2247 .await;
2248 match result {
2249 Ok(dest) => Redirect::to(&dest).into_response(),
2250 Err(err) => err.into_response(),
2251 }
2252}
2253
2254/// The repo delete form (CSRF only).
2255#[derive(Debug, Deserialize)]
2256pub struct RepoDeleteForm {
2257 /// CSRF token.
2258 #[serde(rename = "_csrf")]
2259 csrf: String,
2260}
2261
2262/// `POST /repo-settings/{id}/delete` — delete the repo row and move its on-disk
2263/// directory to trash.
2264pub async fn settings_delete(
2265 State(state): State<AppState>,
2266 MaybeUser(viewer): MaybeUser,
2267 jar: CookieJar,
2268 headers: HeaderMap,
2269 Path(id): Path<String>,
2270 Form(form): Form<RepoDeleteForm>,
2271) -> Response {
2272 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
2273 return AppError::Forbidden.into_response();
2274 }
2275 let result = async {
2276 let repo = require_admin_repo(&state, viewer.as_ref(), &id).await?;
2277 let owner = state
2278 .store
2279 .user_by_id(&repo.owner_id)
2280 .await?
2281 .map_or_else(|| repo.owner_id.clone(), |u| u.username);
2282 state.store.delete_repo(&repo.id).await?;
2283 // Best-effort: move the on-disk directory to trash. A failure here does
2284 // not fail the request — the repo is already unreachable through fabrica.
2285 if let Ok(dir) = git::repo_path(&state.config.storage.repo_dir, &repo.id)
2286 && dir.exists()
2287 {
2288 let trash = state.config.storage.data_dir.join("trash").join(format!(
2289 "{}-{}.git",
2290 repo.id,
2291 crate::now_ms()
2292 ));
2293 if let Some(parent) = trash.parent() {
2294 let _ = std::fs::create_dir_all(parent);
2295 }
2296 if let Err(err) = std::fs::rename(&dir, &trash) {
2297 tracing::warn!(repo = %repo.id, %err, "could not move deleted repo to trash");
2298 }
2299 }
2300 Ok::<String, AppError>(format!("/{owner}"))
2301 }
2302 .await;
2303 match result {
2304 Ok(dest) => Redirect::to(&dest).into_response(),
2305 Err(err) => err.into_response(),
2306 }
2307}
2308
2309/// The archive/unarchive form.
2310#[derive(Debug, Deserialize)]
2311pub struct RepoArchiveForm {
2312 /// Desired archived state (`true` to archive, `false` to unarchive).
2313 #[serde(default)]
2314 archived: String,
2315 /// CSRF token.
2316 #[serde(rename = "_csrf")]
2317 csrf: String,
2318}
2319
2320/// `POST /repo-settings/{id}/archive` — archive or unarchive the repo.
2321pub async fn settings_archive(
2322 State(state): State<AppState>,
2323 MaybeUser(viewer): MaybeUser,
2324 jar: CookieJar,
2325 headers: HeaderMap,
2326 Path(id): Path<String>,
2327 Form(form): Form<RepoArchiveForm>,
2328) -> Response {
2329 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
2330 return AppError::Forbidden.into_response();
2331 }
2332 let result = async {
2333 let repo = require_admin_repo(&state, viewer.as_ref(), &id).await?;
2334 state
2335 .store
2336 .set_archived(&repo.id, form.archived == "true")
2337 .await?;
2338 Ok::<String, AppError>(repo_settings_url(&state, &repo).await)
2339 }
2340 .await;
2341 match result {
2342 Ok(dest) => Redirect::to(&dest).into_response(),
2343 Err(err) => err.into_response(),
2344 }
2345}
2346
2347/// The `/-/settings` URL for a repo, resolving the owner username.
2348async fn repo_settings_url(state: &AppState, repo: &Repo) -> String {
2349 let owner = state
2350 .store
2351 .user_by_id(&repo.owner_id)
2352 .await
2353 .ok()
2354 .flatten()
2355 .map_or_else(|| repo.owner_id.clone(), |u| u.username);
2356 format!("/{owner}/{}/-/settings", repo.path)
2357}
2358
2359/// The add-collaborator form.
2360#[derive(Debug, Deserialize)]
2361pub struct CollaboratorAddForm {
2362 /// The collaborator's username.
2363 #[serde(default)]
2364 username: String,
2365 /// The permission to grant (`read` | `write` | `admin`).
2366 #[serde(default)]
2367 permission: String,
2368 /// CSRF token.
2369 #[serde(rename = "_csrf")]
2370 csrf: String,
2371}
2372
2373/// `POST /repo-settings/{id}/collaborators` — add or update a collaborator.
2374pub async fn settings_collaborator_add(
2375 State(state): State<AppState>,
2376 MaybeUser(viewer): MaybeUser,
2377 jar: CookieJar,
2378 headers: HeaderMap,
2379 Path(id): Path<String>,
2380 Form(form): Form<CollaboratorAddForm>,
2381) -> Response {
2382 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
2383 return AppError::Forbidden.into_response();
2384 }
2385 let result = async {
2386 let repo = require_admin_repo(&state, viewer.as_ref(), &id).await?;
2387 let permission = auth::Permission::parse(&form.permission)
2388 .ok_or_else(|| AppError::BadRequest("Invalid permission.".to_string()))?;
2389 let target = state
2390 .store
2391 .user_by_username(form.username.trim())
2392 .await?
2393 .ok_or_else(|| AppError::BadRequest("No such user.".to_string()))?;
2394 // The owner is already an admin; adding them as a collaborator is a no-op.
2395 if target.id != repo.owner_id {
2396 state
2397 .store
2398 .set_collaborator(&repo.id, &target.id, permission.as_str())
2399 .await?;
2400 }
2401 Ok::<String, AppError>(repo_settings_url(&state, &repo).await)
2402 }
2403 .await;
2404 match result {
2405 Ok(dest) => Redirect::to(&dest).into_response(),
2406 Err(err) => err.into_response(),
2407 }
2408}
2409
2410/// The remove-collaborator form.
2411#[derive(Debug, Deserialize)]
2412pub struct CollaboratorRemoveForm {
2413 /// The collaborator's user id.
2414 #[serde(default)]
2415 user_id: String,
2416 /// CSRF token.
2417 #[serde(rename = "_csrf")]
2418 csrf: String,
2419}
2420
2421/// `POST /repo-settings/{id}/collaborators/remove` — remove a collaborator.
2422pub async fn settings_collaborator_remove(
2423 State(state): State<AppState>,
2424 MaybeUser(viewer): MaybeUser,
2425 jar: CookieJar,
2426 headers: HeaderMap,
2427 Path(id): Path<String>,
2428 Form(form): Form<CollaboratorRemoveForm>,
2429) -> Response {
2430 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
2431 return AppError::Forbidden.into_response();
2432 }
2433 let result = async {
2434 let repo = require_admin_repo(&state, viewer.as_ref(), &id).await?;
2435 state
2436 .store
2437 .remove_collaborator(&repo.id, &form.user_id)
2438 .await?;
2439 Ok::<String, AppError>(repo_settings_url(&state, &repo).await)
2440 }
2441 .await;
2442 match result {
2443 Ok(dest) => Redirect::to(&dest).into_response(),
2444 Err(err) => err.into_response(),
2445 }
2446}
2447
2448// ---- Commit list ----
2449
2450async fn commits_view(
2451 state: &AppState,
2452 viewer: Option<User>,
2453 jar: CookieJar,
2454 uri: &Uri,
2455 ctx: RepoCtx,
2456 rest: &str,
2457) -> AppResult<Response> {
2458 let rev_name = ref_or_default(&ctx.repo, rest);
2459 let rev = git_read(state, &ctx.repo.id, {
2460 let rev_name = rev_name.clone();
2461 move |repo| repo.resolve_ref(&rev_name)
2462 })
2463 .await?;
2464 // Paginate: fetch one extra row to detect whether a next page exists.
2465 let paging = Pagination::from_query(uri.query());
2466 let mut commits = git_read(state, &ctx.repo.id, {
2467 let rev = rev.clone();
2468 let page = git::Page {
2469 offset: paging.offset(),
2470 limit: paging.per_page + 1,
2471 };
2472 move |repo| repo.commits(&rev, None, page)
2473 })
2474 .await?;
2475 let has_next = commits.len() > paging.per_page;
2476 commits.truncate(paging.per_page);
2477
2478 // Verify signatures against all registered keys (bounded to this page's rows),
2479 // reusing the TTL'd signature cache.
2480 let keys = signing_keys(state.store.all_keys().await?);
2481 let cache = state.signatures.clone();
2482 let states = git_read(state, &ctx.repo.id, {
2483 let oids: Vec<git::Oid> = commits.iter().map(|c| c.oid.clone()).collect();
2484 move |repo| {
2485 Ok(oids
2486 .iter()
2487 .map(|o| (*cache.get(repo, o, &keys)).clone())
2488 .collect::<Vec<_>>())
2489 }
2490 })
2491 .await?;
2492
2493 // Resolve the usernames of verified signers for the hover popovers.
2494 let mut signers: HashMap<String, String> = HashMap::new();
2495 for s in &states {
2496 if let git::SignatureState::Verified { user_id, .. } = s
2497 && !signers.contains_key(user_id)
2498 && let Ok(Some(u)) = state.store.user_by_id(user_id).await
2499 {
2500 signers.insert(user_id.clone(), u.username);
2501 }
2502 }
2503
2504 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
2505 let now = crate::now_ms();
2506 let branches = branch_names(state, &ctx.repo.id).await;
2507 let body = html! {
2508 (repo_header(state, &ctx, Tab::Commits, &rev_name, &branches))
2509 ul class="entry-list card" {
2510 @for (commit, sig) in commits.iter().zip(states.iter()) {
2511 @let commit_url = format!("{base}/-/commit/{}", commit.oid);
2512 @let signer = match sig {
2513 git::SignatureState::Verified { user_id, .. } => signers.get(user_id).map(String::as_str),
2514 _ => None,
2515 };
2516 li class="entry-row" {
2517 div class="entry-row-body" {
2518 a class="entry-row-title" href=(commit_url) { (commit.summary) }
2519 div class="entry-row-meta muted" {
2520 span { (commit.author.name) }
2521 span { "·" }
2522 span { (crate::activity::fmt_relative(commit.author.time_ms, now)) }
2523 }
2524 }
2525 div class="entry-row-actions" {
2526 (commit_sig_cell(sig, signer, commit.author.time_ms))
2527 a class="mono commit-sha-pill" href=(commit_url) { (commit.oid.short()) }
2528 button class="icon-btn commit-copy" type="button"
2529 data-clipboard=(commit.oid.to_string()) aria-label="Copy commit id" {
2530 (icon(Icon::Copy))
2531 }
2532 }
2533 }
2534 }
2535 }
2536 @if has_next || paging.page > 1 {
2537 (pagination_nav(uri.path(), uri.query(), paging, has_next))
2538 }
2539 };
2540 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
2541 let title = format!("{}/{}: commits", ctx.owner.username, ctx.repo.path);
2542 Ok((jar, page(&chrome, &title, body)).into_response())
2543}
2544
2545// ---- Commit view ----
2546
2547async fn commit_view(
2548 state: &AppState,
2549 viewer: Option<User>,
2550 jar: CookieJar,
2551 uri: &Uri,
2552 ctx: RepoCtx,
2553 sha: &str,
2554 dq: &DiffQuery,
2555) -> AppResult<Response> {
2556 let keys = signing_keys(state.store.all_keys().await?);
2557 let (detail, files, sig) = load_commit(state, &ctx.repo.id, sha, keys).await?;
2558
2559 // Resolve the signer's username for a verified signature's detail line.
2560 let signer = match &sig {
2561 git::SignatureState::Verified { user_id, .. } => state
2562 .store
2563 .user_by_id(user_id)
2564 .await
2565 .ok()
2566 .flatten()
2567 .map(|u| u.username),
2568 _ => None,
2569 };
2570
2571 // View mode: an explicit `?view=` wins and is remembered; else the cookie;
2572 // else unified.
2573 let chosen = dq.view.clone().filter(|v| v == "split" || v == "unified");
2574 let cookie_mode = jar.get(DIFF_COOKIE).map(|c| c.value().to_string());
2575 let split = chosen.clone().or(cookie_mode).as_deref() == Some("split");
2576
2577 let (adds, dels) = files.iter().fold((0, 0), |(a, d), f| {
2578 let (fa, fd) = f.stats();
2579 (a + fa, d + fd)
2580 });
2581 let repo_base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
2582 let branches = branch_names(state, &ctx.repo.id).await;
2583
2584 let body = html! {
2585 (repo_header(state, &ctx, Tab::Code, &ctx.repo.default_branch, &branches))
2586 (commit_meta(&detail))
2587 (signature_detail(&sig, signer.as_deref()))
2588 div class="diff-toolbar" {
2589 span {
2590 (files.len()) " files changed · "
2591 span style="color:var(--fb-diff-add-fg)" { "+" (adds) }
2592 " "
2593 span style="color:var(--fb-diff-del-fg)" { "−" (dels) }
2594 }
2595 span class="diff-toggle" {
2596 a class=(toggle_class(!split)) href=(format!("{repo_base}/-/commit/{sha}?view=unified")) { "Unified" }
2597 a class=(toggle_class(split)) href=(format!("{repo_base}/-/commit/{sha}?view=split")) { "Split" }
2598 }
2599 }
2600 nav class="changed-files" aria-label="Changed files" {
2601 @for (i, f) in files.iter().enumerate() {
2602 @let (fa, fd) = f.stats();
2603 a href=(format!("#file-{i}")) {
2604 (f.path()) " " span class="muted" { "+" (fa) " −" (fd) }
2605 }
2606 }
2607 }
2608 @for (i, f) in files.iter().enumerate() {
2609 details open id=(format!("file-{i}")) class="diff-file" {
2610 summary {
2611 @let (fa, fd) = f.stats();
2612 span class="mono" { (f.path()) }
2613 " " span class="muted" { "+" (fa) " −" (fd) }
2614 }
2615 @if f.diff.is_binary {
2616 p class="muted" style="padding:0.5rem" { "Binary file not shown." }
2617 } @else if f.diff.hunks.is_empty() {
2618 p class="muted" style="padding:0.5rem" { "No textual changes." }
2619 } @else if split {
2620 (diff::split(f))
2621 } @else {
2622 (diff::unified(f, Some(&format!("{repo_base}/-/commit/{sha}/context?file={}", f.path()))))
2623 }
2624 }
2625 }
2626 };
2627
2628 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
2629 let jar = match chosen {
2630 Some(mode) => jar.add(diff_cookie(mode, state.config.auth.cookie_secure)),
2631 None => jar,
2632 };
2633 let title = format!(
2634 "{}/{}: {}",
2635 ctx.owner.username,
2636 ctx.repo.path,
2637 detail.oid.short()
2638 );
2639 Ok((jar, page(&chrome, &title, body)).into_response())
2640}
2641
2642/// Load a commit, its diff against the first parent, the highlighted pre/post
2643/// images of each file, and its signature — all on the blocking pool.
2644type LoadedCommit = (git::CommitDetail, Vec<RenderedFile>, git::SignatureState);
2645async fn load_commit(
2646 state: &AppState,
2647 repo_id: &str,
2648 sha: &str,
2649 keys: Vec<git::SigningKey>,
2650) -> AppResult<LoadedCommit> {
2651 let oid = git::Oid::new(sha);
2652 let context_lines = state.config.ui.diff_context_lines;
2653 let sha_owned = sha.to_string();
2654 let cache = state.signatures.clone();
2655 git_read(state, repo_id, move |repo| {
2656 let detail = repo.commit(&oid)?;
2657 let base = detail.parents.first().cloned();
2658 let diffs = repo.diff(base.as_ref(), &oid, git::DiffOpts { context_lines })?;
2659
2660 let head_rev = git::Resolved {
2661 oid: oid.clone(),
2662 kind: git::RefKind::Commit,
2663 name: sha_owned,
2664 };
2665 let base_rev = base.as_ref().map(|o| git::Resolved {
2666 oid: o.clone(),
2667 kind: git::RefKind::Commit,
2668 name: o.to_string(),
2669 });
2670
2671 let rendered = diffs
2672 .files
2673 .into_iter()
2674 .map(|file| render_one_file(repo, &head_rev, base_rev.as_ref(), file))
2675 .collect();
2676 let sig = (*cache.get(repo, &oid, &keys)).clone();
2677 Ok((detail, rendered, sig))
2678 })
2679 .await
2680}
2681
2682/// The commits and rendered diff a pull request contributes: the head-only
2683/// commit list (newest first) and the three-dot diff (merge-base → head).
2684pub(crate) type RangeDiff = (Vec<git::CommitSummary>, Vec<RenderedFile>);
2685
2686/// Load a pull request's commits and diff. `base` and `head` are branch names (or
2687/// revs); the diff is three-dot (against their merge base) so unrelated base
2688/// commits do not appear as changes.
2689pub(crate) async fn load_range_diff(
2690 state: &AppState,
2691 repo_id: &str,
2692 base: &str,
2693 head: &str,
2694) -> AppResult<RangeDiff> {
2695 let context_lines = state.config.ui.diff_context_lines;
2696 let base = base.to_string();
2697 let head = head.to_string();
2698 git_read(state, repo_id, move |repo| {
2699 let base_oid = repo.resolve_ref(&base)?.oid;
2700 let head_oid = repo.resolve_ref(&head)?.oid;
2701 // Three-dot: diff from the merge base, so only the head's own work shows.
2702 let merge_base = repo.merge_base(&base_oid, &head_oid)?.unwrap_or(base_oid);
2703 let commits = repo.commits_between(&merge_base, &head_oid, 200)?;
2704 let diffs = repo.diff(
2705 Some(&merge_base),
2706 &head_oid,
2707 git::DiffOpts { context_lines },
2708 )?;
2709
2710 let head_rev = git::Resolved {
2711 oid: head_oid.clone(),
2712 kind: git::RefKind::Commit,
2713 name: head.clone(),
2714 };
2715 let base_rev = git::Resolved {
2716 oid: merge_base.clone(),
2717 kind: git::RefKind::Commit,
2718 name: merge_base.to_string(),
2719 };
2720 let rendered = diffs
2721 .files
2722 .into_iter()
2723 .map(|file| render_one_file(repo, &head_rev, Some(&base_rev), file))
2724 .collect();
2725 Ok((commits, rendered))
2726 })
2727 .await
2728}
2729
2730/// Build a [`RenderedFile`] for one diff entry, highlighting each side as a whole
2731/// document.
2732fn render_one_file(
2733 repo: &git::Repo,
2734 head_rev: &git::Resolved,
2735 base_rev: Option<&git::Resolved>,
2736 file: git::FileDiff,
2737) -> RenderedFile {
2738 let text_at = |rev: &git::Resolved, path: &str| {
2739 repo.blob(rev, FsPath::new(path))
2740 .ok()
2741 .filter(|b| !b.is_binary)
2742 .map(|b| String::from_utf8_lossy(&b.content).into_owned())
2743 };
2744 let new_text = file.new_path.as_deref().and_then(|p| text_at(head_rev, p));
2745 let old_text = match (base_rev, file.old_path.as_deref()) {
2746 (Some(rev), Some(p)) => text_at(rev, p),
2747 _ => None,
2748 };
2749 let lang_path = file
2750 .new_path
2751 .clone()
2752 .or_else(|| file.old_path.clone())
2753 .unwrap_or_default();
2754 let probe = new_text.as_deref().or(old_text.as_deref()).unwrap_or("");
2755 let lang = highlight::resolve(FsPath::new(&lang_path), probe.as_bytes(), None);
2756 let highlight_side = |text: Option<&str>| {
2757 text.map(|t| highlight::highlight_document(&lang, t))
2758 .unwrap_or_default()
2759 };
2760 RenderedFile {
2761 old_lines: highlight_side(old_text.as_deref()),
2762 new_lines: highlight_side(new_text.as_deref()),
2763 diff: file,
2764 }
2765}
2766
2767/// The commit metadata block: the subject with the short id, author, and commit
2768/// date to its right, and the body below.
2769fn commit_meta(detail: &git::CommitDetail) -> Markup {
2770 let (subject, body) = detail
2771 .message
2772 .split_once("\n\n")
2773 .map_or((detail.message.as_str(), ""), |(s, b)| (s.trim(), b));
2774 html! {
2775 div class="commit-meta" {
2776 div class="commit-title-row" {
2777 h2 class="commit-title" { (subject.lines().next().unwrap_or("")) }
2778 p class="commit-title-meta muted" {
2779 span class="mono" { (detail.oid.short()) }
2780 " · " (detail.author.name)
2781 " committed on " (fmt_date(detail.committer.time_ms))
2782 }
2783 }
2784 @if !body.trim().is_empty() {
2785 pre class="commit-body" { (body.trim_end()) }
2786 }
2787 }
2788 }
2789}
2790
2791/// `GET …/-/commit/{sha}/context` — the highlighted hidden-context rows, as an
2792/// htmx partial that replaces its expander.
2793async fn context_partial(
2794 state: &AppState,
2795 ctx: RepoCtx,
2796 sha: &str,
2797 dq: &DiffQuery,
2798) -> AppResult<Response> {
2799 let (Some(file), Some(from), Some(to)) = (dq.file.clone(), dq.from, dq.to) else {
2800 return Err(AppError::BadRequest(
2801 "missing context parameters".to_string(),
2802 ));
2803 };
2804 let oid = git::Oid::new(sha);
2805 let sha_owned = sha.to_string();
2806 let (lines, highlighted) = git_read(state, &ctx.repo.id, move |repo| {
2807 let lines = repo.diff_context(&oid, FsPath::new(&file), from, to)?;
2808 let rev = git::Resolved {
2809 oid: oid.clone(),
2810 kind: git::RefKind::Commit,
2811 name: sha_owned,
2812 };
2813 let highlighted = repo
2814 .blob(&rev, FsPath::new(&file))
2815 .ok()
2816 .filter(|b| !b.is_binary)
2817 .map(|b| {
2818 let text = String::from_utf8_lossy(&b.content).into_owned();
2819 let lang = highlight::resolve(FsPath::new(&file), text.as_bytes(), None);
2820 highlight::highlight_document(&lang, &text)
2821 })
2822 .unwrap_or_default();
2823 Ok((lines, highlighted))
2824 })
2825 .await?;
2826
2827 Ok(Html(diff::context_rows(&lines, &highlighted).into_string()).into_response())
2828}
2829
2830/// The active/inactive class for a diff-view toggle button.
2831fn toggle_class(active: bool) -> &'static str {
2832 if active { "btn active" } else { "btn" }
2833}
2834
2835/// Build the diff-view preference cookie.
2836fn diff_cookie(mode: String, secure: bool) -> Cookie<'static> {
2837 Cookie::build((DIFF_COOKIE, mode))
2838 .http_only(false)
2839 .same_site(SameSite::Lax)
2840 .secure(secure)
2841 .path("/")
2842 .max_age(time::Duration::days(365))
2843 .build()
2844}
2845
2846// ---- Group listing ----
2847
2848async fn group_listing(
2849 state: &AppState,
2850 viewer: Option<User>,
2851 jar: CookieJar,
2852 uri: &Uri,
2853 owner_name: &str,
2854 group_path: &str,
2855) -> AppResult<Response> {
2856 let Some(owner) = state.store.user_by_username(owner_name).await? else {
2857 return Err(AppError::NotFound);
2858 };
2859 let Some(group) = state
2860 .store
2861 .group_by_owner_path(&owner.id, group_path)
2862 .await?
2863 else {
2864 return Err(AppError::NotFound);
2865 };
2866
2867 // A private group is invisible to non-members (404, never 403).
2868 let access = crate::groups::group_access(state, viewer.as_ref(), &group).await?;
2869 if access == auth::Access::None {
2870 return Err(AppError::NotFound);
2871 }
2872 let is_admin = access == auth::Access::Admin;
2873
2874 let all_groups = state.store.groups_by_owner(&owner.id).await?;
2875 let prefix = format!("{group_path}/");
2876 // Direct child groups the viewer may see (a private subgroup is hidden, not
2877 // just 404 on visit — its very name would otherwise leak here).
2878 let mut subgroups: Vec<&model::Group> = Vec::new();
2879 for g in all_groups
2880 .iter()
2881 .filter(|g| g.path.starts_with(&prefix) && !g.path[prefix.len()..].contains('/'))
2882 {
2883 if crate::groups::group_access(state, viewer.as_ref(), g).await? != auth::Access::None {
2884 subgroups.push(g);
2885 }
2886 }
2887 let repos: Vec<Repo> = visible_repos(state, &owner, viewer.as_ref())
2888 .await?
2889 .into_iter()
2890 .filter(|r| r.path.starts_with(&prefix) && !r.path[prefix.len()..].contains('/'))
2891 .collect();
2892
2893 let crumbs = path_crumbs(&owner.username, &group.path);
2894 let body = html! {
2895 div class="group-head" {
2896 h1 class="repo-slug group-slug" {
2897 (icon(Icon::Folder))
2898 a href={ "/" (owner.username) } { (owner.username) }
2899 @for (name, href) in &crumbs {
2900 span class="slug-sep" { "/" }
2901 a href=(href) { (name) }
2902 }
2903 }
2904 @if is_admin {
2905 div class="group-head-actions" {
2906 a class="btn inline-btn" href=(format!("/new/group?parent={}", group.path)) {
2907 (icon(Icon::Plus)) span { "New subgroup" }
2908 }
2909 a class="btn inline-btn" href=(format!("/group-settings/{}", group.id))
2910 title="Group settings" aria-label="Group settings" {
2911 (icon(Icon::Settings))
2912 }
2913 }
2914 }
2915 }
2916 @if !subgroups.is_empty() {
2917 section class="listing" { h2 { "Subgroups" }
2918 ul class="repo-list card" {
2919 @for g in &subgroups {
2920 li {
2921 a class="repo-row" href={ "/" (owner.username) "/" (g.path) } {
2922 span class="repo-row-name" { (icon(Icon::Folder)) span { (g.path) } }
2923 }
2924 }
2925 }
2926 }
2927 }
2928 }
2929 (owned_repo_card(&owner.username, &repos))
2930 };
2931 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
2932 let title = format!("{}/{}", owner.username, group.path);
2933 Ok((jar, page(&chrome, &title, body)).into_response())
2934}
2935
2936// ---- Shared components ----
2937
2938/// The repos of `owner` the viewer may see: all of them for the owner/admin, else
2939/// the public ones.
2940async fn visible_repos(
2941 state: &AppState,
2942 owner: &User,
2943 viewer: Option<&User>,
2944) -> AppResult<Vec<Repo>> {
2945 let repos = state.store.repos_by_owner(&owner.id).await?;
2946 let is_owner_or_admin = viewer.is_some_and(|v| v.id == owner.id || v.is_admin);
2947 if is_owner_or_admin {
2948 return Ok(repos);
2949 }
2950 // Non-owners: whatever the access function grants read to (visibility +
2951 // collaborator rows), so internal repos surface for signed-in viewers.
2952 let viewer_id = viewer.map(|v| auth::Viewer {
2953 id: v.id.clone(),
2954 is_admin: v.is_admin,
2955 });
2956 let mut out = Vec::new();
2957 for repo in repos {
2958 let collab = match viewer {
2959 Some(v) => state
2960 .store
2961 .effective_permission(&repo.id, &v.id)
2962 .await?
2963 .and_then(|p| auth::Permission::parse(&p)),
2964 None => None,
2965 };
2966 let access = auth::access(viewer_id.as_ref(), &repo, collab, state.allow_anonymous());
2967 if access >= auth::Access::Read {
2968 out.push(repo);
2969 }
2970 }
2971 Ok(out)
2972}
2973
2974/// One row in a repository listing card.
2975#[derive(Clone)]
2976pub(crate) struct RepoEntry {
2977 /// Link target.
2978 pub(crate) href: String,
2979 /// Display label (a bare repo path, or `owner/path` in cross-owner listings).
2980 pub(crate) label: String,
2981 /// The repo's visibility (drives the badge and subtitle).
2982 pub(crate) visibility: model::Visibility,
2983 /// The default branch, shown in the subtitle.
2984 pub(crate) default_branch: String,
2985 /// When the repo was last pushed to (falling back to its last metadata
2986 /// change), for the "Updated …" subtitle.
2987 pub(crate) updated: i64,
2988 /// Whether the repo is archived (shows an archived badge).
2989 pub(crate) archived: bool,
2990 /// Whether the repo is a mirror (shows the mirror icon).
2991 pub(crate) is_mirror: bool,
2992 /// Whether the repo is a fork (shows the fork icon).
2993 pub(crate) is_fork: bool,
2994 /// Optional one-line description, shown under the name when present.
2995 pub(crate) description: Option<String>,
2996}
2997
2998impl RepoEntry {
2999 /// Build an entry for a repo shown on its owner's own page (bare-path label).
3000 pub(crate) fn owned(owner: &str, repo: &Repo) -> Self {
3001 Self {
3002 href: format!("/{owner}/{}", repo.path),
3003 label: repo.path.clone(),
3004 visibility: repo.visibility,
3005 default_branch: repo.default_branch.clone(),
3006 updated: repo.pushed_at.unwrap_or(repo.updated_at),
3007 archived: repo.archived_at.is_some(),
3008 is_mirror: repo.is_mirror,
3009 is_fork: repo.fork_parent_id.is_some(),
3010 description: repo.description.clone(),
3011 }
3012 }
3013
3014 /// Build an entry for a cross-owner listing (e.g. Explore), labelled
3015 /// `owner/path`.
3016 pub(crate) fn global(owner: &str, repo: &Repo) -> Self {
3017 Self {
3018 href: format!("/{owner}/{}", repo.path),
3019 label: format!("{owner}/{}", repo.path),
3020 visibility: repo.visibility,
3021 default_branch: repo.default_branch.clone(),
3022 updated: repo.pushed_at.unwrap_or(repo.updated_at),
3023 archived: repo.archived_at.is_some(),
3024 is_mirror: repo.is_mirror,
3025 is_fork: repo.fork_parent_id.is_some(),
3026 description: repo.description.clone(),
3027 }
3028 }
3029}
3030
3031/// The visibility badge shown next to a repo name (nothing for public).
3032fn visibility_badge(visibility: model::Visibility) -> Markup {
3033 html! {
3034 @if visibility != model::Visibility::Public {
3035 span class="badge badge-private" { (visibility.as_str()) }
3036 }
3037 }
3038}
3039
3040/// An "archived" badge when the repo is archived, else nothing.
3041fn archived_badge(archived: bool) -> Markup {
3042 html! {
3043 @if archived {
3044 span class="badge badge-archived" { "archived" }
3045 }
3046 }
3047}
3048
3049/// Render a titled card of repository rows in the reference listing style: a
3050/// card headed by `title`, one bordered row per repo with a bold name and a
3051/// muted `visibility · default: branch` subtitle.
3052pub(crate) fn repo_card(title: &str, empty: &str, entries: &[RepoEntry]) -> Markup {
3053 let now = crate::now_ms();
3054 html! {
3055 section class="listing" {
3056 @if !title.is_empty() { h2 { (title) } }
3057 @if entries.is_empty() {
3058 div class="card" { p class="muted" { (empty) } }
3059 } @else {
3060 ul class="repo-list card" {
3061 @for e in entries {
3062 li {
3063 a class="repo-row" href=(e.href) {
3064 span class="repo-row-name" {
3065 (icon(if e.is_mirror { Icon::Mirror } else if e.is_fork { Icon::GitFork } else { Icon::Box }))
3066 span { (e.label) }
3067 (visibility_badge(e.visibility))
3068 (archived_badge(e.archived))
3069 }
3070 @if let Some(desc) = &e.description {
3071 span class="repo-row-desc muted" { (desc) }
3072 }
3073 span class="repo-row-meta muted" {
3074 (icon(Icon::GitBranch)) " " (e.default_branch)
3075 " · Updated " (crate::activity::fmt_relative(e.updated, now))
3076 }
3077 }
3078 }
3079 }
3080 }
3081 }
3082 }
3083 }
3084}
3085
3086/// Render an owner's repositories as a listing card.
3087pub(crate) fn owned_repo_card(owner: &str, repos: &[Repo]) -> Markup {
3088 let entries: Vec<RepoEntry> = repos.iter().map(|r| RepoEntry::owned(owner, r)).collect();
3089 repo_card("Repositories", "No repositories.", &entries)
3090}
3091
3092/// The profile sidebar card: avatar, name, handle, pronouns, markdown bio,
3093/// location/social links, and the join date.
3094fn profile_sidebar(owner: &User, followers: i64, following: i64, follow: Option<bool>) -> Markup {
3095 let name = owner.display_name.as_deref().unwrap_or(&owner.username);
3096 let user = &owner.username;
3097 html! {
3098 div class="card profile-card" {
3099 img class="avatar avatar-xl" src={ "/avatar/" (owner.username) } alt="";
3100 h1 class="profile-name" { (name) }
3101 p class="profile-handle muted" {
3102 "@" (owner.username)
3103 @if let Some(pronouns) = pronouns(owner) {
3104 " · " (pronouns)
3105 }
3106 }
3107 p class="profile-follow muted" {
3108 (icon(Icon::Users)) " "
3109 a href={ "/" (user) "?tab=followers" } {
3110 strong { (followers) } " follower" @if followers != 1 { "s" }
3111 }
3112 " · "
3113 a href={ "/" (user) "?tab=following" } {
3114 strong { (following) } " following"
3115 }
3116 }
3117 @match follow {
3118 Some(true) => {
3119 a class="btn profile-follow-btn" href={ "/user-follow/" (user) } { "Unfollow" }
3120 }
3121 Some(false) => {
3122 a class="btn btn-primary profile-follow-btn" href={ "/user-follow/" (user) } { "Follow" }
3123 }
3124 None => {}
3125 }
3126 @if let Some(bio) = non_empty(owner.bio.as_ref()) {
3127 div class="profile-bio markdown" { (markdown::render(bio)) }
3128 }
3129 @let links = profile_links(owner);
3130 @if !links.is_empty() {
3131 ul class="profile-links muted" {
3132 @for link in &links { (link) }
3133 }
3134 }
3135 p class="profile-joined muted" { "Joined on " (fmt_joined(owner.created_at)) }
3136 }
3137 }
3138}
3139
3140/// The trimmed pronouns, if set.
3141fn pronouns(owner: &User) -> Option<&str> {
3142 non_empty(owner.pronouns.as_ref())
3143}
3144
3145/// A field's trimmed value, or `None` when empty.
3146fn non_empty(field: Option<&String>) -> Option<&str> {
3147 field.map(|s| s.trim()).filter(|s| !s.is_empty())
3148}
3149
3150/// The profile's location and its arbitrary links, each an icon-prefixed row.
3151/// The brand icon is inferred from the link's host.
3152fn profile_links(owner: &User) -> Vec<Markup> {
3153 let mut out = Vec::new();
3154 if let Some(location) = non_empty(owner.location.as_ref()) {
3155 out.push(html! { li { (icon(Icon::MapPin)) span { (location) } } });
3156 }
3157 // The primary email, only when the owner has chosen to publish it.
3158 if owner.email_public {
3159 if let Some(email) = non_empty(Some(&owner.email)) {
3160 out.push(html! {
3161 li {
3162 a href=(format!("mailto:{email}")) { (icon(Icon::Mail)) span { (email) } }
3163 }
3164 });
3165 }
3166 }
3167 for link in &owner.links {
3168 let href = normalize_url(link);
3169 let shown = href
3170 .trim_start_matches("https://")
3171 .trim_start_matches("http://")
3172 .trim_end_matches('/');
3173 out.push(html! {
3174 li {
3175 a href=(href) rel="nofollow noopener me" {
3176 (icon(link_icon(&href))) span { (shown) }
3177 }
3178 }
3179 });
3180 }
3181 out
3182}
3183
3184/// Prefix a bare URL with `https://` when it has no scheme.
3185fn normalize_url(url: &str) -> String {
3186 let url = url.trim();
3187 if url.starts_with("http://") || url.starts_with("https://") {
3188 url.to_string()
3189 } else {
3190 format!("https://{url}")
3191 }
3192}
3193
3194/// Choose a brand icon for a link from its host.
3195fn link_icon(url: &str) -> Icon {
3196 let host = url
3197 .trim_start_matches("https://")
3198 .trim_start_matches("http://")
3199 .split('/')
3200 .next()
3201 .unwrap_or("")
3202 .to_ascii_lowercase();
3203 if host == "github.com" || host.ends_with(".github.com") {
3204 Icon::Github
3205 } else if host.contains("mastodon") {
3206 Icon::Mastodon
3207 } else {
3208 Icon::Globe
3209 }
3210}
3211
3212/// The repo sub-header: slug, description, and a tab strip sharing its line with
3213/// the branch switcher and clone menu.
3214/// Build breadcrumb `(segment, href)` pairs for each segment of a repo `path`
3215/// under `owner`. Group segments link to their group listing; the last is the
3216/// repo itself.
3217pub(crate) fn path_crumbs(owner: &str, path: &str) -> Vec<(String, String)> {
3218 let mut out = Vec::new();
3219 let mut cumulative = String::new();
3220 for seg in path.split('/') {
3221 if cumulative.is_empty() {
3222 cumulative = seg.to_string();
3223 } else {
3224 cumulative = format!("{cumulative}/{seg}");
3225 }
3226 out.push((seg.to_string(), format!("/{owner}/{cumulative}")));
3227 }
3228 out
3229}
3230
3231pub(crate) fn repo_header(
3232 state: &AppState,
3233 ctx: &RepoCtx,
3234 active: Tab,
3235 rev: &str,
3236 branches: &[String],
3237) -> Markup {
3238 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
3239 let tab = |t: Tab, label: &str, glyph: Icon, href: String| {
3240 html! {
3241 a href=(href) aria-current=[(active == t).then_some("page")] {
3242 (icon(glyph)) span { (label) }
3243 }
3244 }
3245 };
3246 // Breadcrumb: owner / group… / repo. Each path segment links to its own page
3247 // (group segments render the group listing; the leaf is the repo).
3248 let crumbs = path_crumbs(&ctx.owner.username, &ctx.repo.path);
3249 html! {
3250 div class="repo-head" {
3251 h1 class="repo-slug" {
3252 a href={ "/" (ctx.owner.username) } { (ctx.owner.username) }
3253 @for (name, href) in &crumbs {
3254 span class="slug-sep" { "/" }
3255 a href=(href) { (name) }
3256 }
3257 (visibility_badge(ctx.repo.visibility))
3258 (archived_badge(ctx.repo.archived_at.is_some()))
3259 }
3260 @if let Some((powner, ppath)) = &ctx.fork_parent {
3261 p class="muted fork-note" {
3262 (icon(Icon::GitFork)) " forked from "
3263 a href=(format!("/{powner}/{ppath}")) { (powner) "/" (ppath) }
3264 }
3265 }
3266 @if let Some(desc) = &ctx.repo.description { p class="muted" { (desc) } }
3267 div class="repo-nav" {
3268 nav class="tabs repo-tabs" {
3269 (tab(Tab::Code, "Code", Icon::File, base.clone()))
3270 @if ctx.repo.issues_enabled {
3271 (tab(Tab::Issues, "Issues", Icon::Issue, format!("{base}/-/issues")))
3272 }
3273 @if ctx.repo.pulls_enabled {
3274 (tab(Tab::Pulls, "Pull requests", Icon::GitPull, format!("{base}/-/pulls")))
3275 }
3276 (tab(Tab::Commits, "Commits", Icon::History, format!("{base}/-/commits/{rev}")))
3277 (tab(Tab::Branches, "Branches", Icon::GitBranch, format!("{base}/-/branches")))
3278 (tab(Tab::Tags, "Tags", Icon::Box, format!("{base}/-/tags")))
3279 @if ctx.repo.releases_enabled {
3280 (tab(Tab::Releases, "Releases", Icon::Download, format!("{base}/-/releases")))
3281 }
3282 @if ctx.access == auth::Access::Admin {
3283 (tab(Tab::Settings, "Settings", Icon::Settings, format!("{base}/-/settings")))
3284 }
3285 }
3286 div class="repo-actions" {
3287 @if ctx.viewer_id.is_some() {
3288 @let bm_label = if ctx.bookmarked { "Remove bookmark" } else { "Bookmark" };
3289 a class=(if ctx.bookmarked { "icon-btn btn-active" } else { "icon-btn" })
3290 href=(format!("/repo-bookmark/{}", ctx.repo.id))
3291 aria-label=(bm_label) title=(bm_label) {
3292 (icon(Icon::Bookmark))
3293 }
3294 a class="icon-btn" href=(format!("/repo-fork/{}", ctx.repo.id))
3295 aria-label="Fork" title="Fork" {
3296 (icon(Icon::GitFork))
3297 }
3298 }
3299 (branch_menu(&base, rev, branches))
3300 (clone_menu(state, ctx))
3301 }
3302 }
3303 }
3304 }
3305}
3306
3307/// The branch switcher: a no-JS `details` menu linking to each branch's tree.
3308/// Falls back to a plain link to the branches page when the list is empty.
3309fn branch_menu(base: &str, rev: &str, branches: &[String]) -> Markup {
3310 html! {
3311 @if branches.is_empty() {
3312 a class="btn" href=(format!("{base}/-/branches")) {
3313 (icon(Icon::GitBranch)) span { (rev) }
3314 }
3315 } @else {
3316 details class="branch-menu" {
3317 summary class="btn" { (icon(Icon::GitBranch)) span { (rev) } (icon(Icon::ChevronDown)) }
3318 div class="branch-pop card" {
3319 div class="branch-pop-head muted" { "Switch branch" }
3320 @for name in branches {
3321 a href=(format!("{base}/-/tree/{name}"))
3322 aria-current=[(name == rev).then_some("page")] { (name) }
3323 }
3324 a class="branch-pop-all" href=(format!("{base}/-/branches")) { "View all branches" }
3325 }
3326 }
3327 }
3328 }
3329}
3330
3331/// The Clone dropdown: a no-JS `details` menu offering the HTTPS and SSH URLs.
3332fn clone_menu(state: &AppState, ctx: &RepoCtx) -> Markup {
3333 let https = clone_https(state, ctx);
3334 let ssh = clone_ssh(state, ctx);
3335 let field = |label: &str, url: &str| {
3336 html! {
3337 div class="clone-field" {
3338 span class="clone-field-label muted" { (label) }
3339 div class="clone-input" {
3340 input type="text" readonly value=(url) aria-label=(format!("{label} clone URL"));
3341 button class="btn" type="button" data-clipboard=(url)
3342 aria-label=(format!("Copy {label} URL")) { (icon(Icon::Copy)) }
3343 }
3344 }
3345 }
3346 };
3347 html! {
3348 details class="clone-menu" {
3349 summary class="btn btn-primary" { (icon(Icon::Download)) span { "Clone" } }
3350 div class="clone-pop card" {
3351 (field("HTTPS", &https))
3352 (field("SSH", &ssh))
3353 }
3354 }
3355 }
3356}
3357
3358/// The HTTPS clone URL.
3359fn clone_https(state: &AppState, ctx: &RepoCtx) -> String {
3360 format!(
3361 "{}/{}/{}.git",
3362 state.config.instance.url.trim_end_matches('/'),
3363 ctx.owner.username,
3364 ctx.repo.path
3365 )
3366}
3367
3368/// The SSH clone URL — scp-style on the default port, `ssh://` otherwise.
3369fn clone_ssh(state: &AppState, ctx: &RepoCtx) -> String {
3370 let host = &state.config.ssh.clone_host;
3371 let port = state.config.ssh.clone_port;
3372 let (owner, repo) = (&ctx.owner.username, &ctx.repo.path);
3373 if port == 22 {
3374 format!("git@{host}:{owner}/{repo}.git")
3375 } else {
3376 format!("ssh://git@{host}:{port}/{owner}/{repo}.git")
3377 }
3378}
3379
3380/// A path breadcrumb for tree/blob views.
3381fn breadcrumb(owner: &str, repo_path: &str, rev: &str, path: &str) -> Markup {
3382 let root = format!("/{owner}/{repo_path}/-/tree/{rev}");
3383 // Precompute each crumb's (label, cumulative href) before rendering.
3384 let mut acc = String::new();
3385 let crumbs: Vec<(String, String)> = path
3386 .split('/')
3387 .filter(|s| !s.is_empty())
3388 .map(|segment| {
3389 if acc.is_empty() {
3390 acc.push_str(segment);
3391 } else {
3392 acc.push('/');
3393 acc.push_str(segment);
3394 }
3395 (segment.to_string(), format!("{root}/{acc}"))
3396 })
3397 .collect();
3398 html! {
3399 nav class="breadcrumb" {
3400 a href=(root) { (repo_path) }
3401 @for (label, href) in &crumbs {
3402 " / " a href=(href) { (label) }
3403 }
3404 }
3405 }
3406}
3407
3408/// A tree listing table.
3409fn tree_table(
3410 owner: &str,
3411 repo_path: &str,
3412 rev: &str,
3413 dir: &str,
3414 entries: &[git::TreeEntry],
3415 annotations: &git::TreeAnnotations,
3416) -> Markup {
3417 let href = |name: &str, kind: git::EntryKind| {
3418 let sub = if dir.is_empty() {
3419 name.to_string()
3420 } else {
3421 format!("{dir}/{name}")
3422 };
3423 let view = match kind {
3424 git::EntryKind::Directory => "tree",
3425 _ => "blob",
3426 };
3427 format!("/{owner}/{repo_path}/-/{view}/{rev}/{sub}")
3428 };
3429 // Directories first, then files, each alphabetical (case-insensitive).
3430 let mut sorted: Vec<&git::TreeEntry> = entries.iter().collect();
3431 sorted.sort_by(|a, b| {
3432 let is_file = |e: &git::TreeEntry| e.kind != git::EntryKind::Directory;
3433 is_file(a)
3434 .cmp(&is_file(b))
3435 .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
3436 });
3437 html! {
3438 div class="file-tree card" {
3439 @for entry in sorted {
3440 @let full = if dir.is_empty() { entry.name.clone() } else { format!("{dir}/{}", entry.name) };
3441 @if entry.kind == git::EntryKind::Submodule {
3442 // A gitlink: link out to the submodule's remote (from .gitmodules).
3443 @let url = annotations.submodules.get(&full).map(|u| submodule_href(u));
3444 @if let Some(url) = url {
3445 a class="file-row is-submodule" href=(url) rel="noopener" {
3446 span class="file-icon" { (icon(Icon::Box)) }
3447 span class="file-name" { (entry.name) }
3448 span class="tree-pill" { "submodule" }
3449 }
3450 } @else {
3451 span class="file-row is-submodule" {
3452 span class="file-icon" { (icon(Icon::Box)) }
3453 span class="file-name" { (entry.name) }
3454 span class="tree-pill" { "submodule" }
3455 }
3456 }
3457 } @else {
3458 @let is_dir = entry.kind == git::EntryKind::Directory;
3459 a class=(if is_dir { "file-row is-dir" } else { "file-row" })
3460 href=(href(&entry.name, entry.kind)) {
3461 span class="file-icon" { (icon(entry_icon(entry.kind))) }
3462 span class="file-name" { (entry.name) }
3463 @if annotations.lfs.contains(&entry.name) {
3464 span class="tree-pill lfs-pill" { "LFS" }
3465 }
3466 }
3467 }
3468 }
3469 }
3470 }
3471}
3472
3473/// Turn a submodule's `.gitmodules` URL into a browsable https link (best-effort).
3474fn submodule_href(url: &str) -> String {
3475 let u = url.trim();
3476 if let Some(rest) = u.strip_prefix("git@")
3477 && let Some((host, path)) = rest.split_once(':')
3478 {
3479 return format!("https://{host}/{}", path.trim_end_matches(".git"));
3480 }
3481 if let Some(rest) = u.strip_prefix("ssh://") {
3482 let rest = rest.strip_prefix("git@").unwrap_or(rest);
3483 if let Some((host, path)) = rest.split_once('/') {
3484 return format!("https://{host}/{}", path.trim_end_matches(".git"));
3485 }
3486 }
3487 u.trim_end_matches(".git").to_string()
3488}
3489
3490/// An icon glyph for a tree entry kind.
3491fn entry_icon(kind: git::EntryKind) -> Icon {
3492 match kind {
3493 git::EntryKind::Directory => Icon::Folder,
3494 git::EntryKind::Submodule => Icon::Box,
3495 git::EntryKind::Symlink => Icon::Link,
3496 git::EntryKind::File => Icon::File,
3497 }
3498}
3499
3500/// The uppercase label for a key kind.
3501fn key_kind_label(kind: model::KeyKind) -> &'static str {
3502 match kind {
3503 model::KeyKind::Ssh => "SSH",
3504 model::KeyKind::Gpg => "GPG",
3505 }
3506}
3507
3508/// A commit's signature cell for the commits list: a verified signature gets a
3509/// hover/focus popover with the signer, key fingerprint, and verified date; any
3510/// other state falls back to the plain badge.
3511fn commit_sig_cell(state: &git::SignatureState, signer: Option<&str>, time_ms: i64) -> Markup {
3512 match state {
3513 git::SignatureState::Verified {
3514 kind,
3515 fingerprint,
3516 user_id,
3517 ..
3518 } => html! {
3519 span class="sig-pop-wrap" tabindex="0" {
3520 (signature_badge(state))
3521 div class="sig-pop card" {
3522 div class="sig-pop-title" {
3523 "This commit was signed with a verified signature."
3524 }
3525 div class="sig-pop-signer" {
3526 "Signed by " strong { (signer.unwrap_or(user_id)) }
3527 }
3528 div class="sig-pop-fp" {
3529 (key_kind_label(*kind)) " key fingerprint:"
3530 span class="mono" { (fingerprint) }
3531 }
3532 div class="muted sig-pop-when" { "Verified on " (fmt_joined(time_ms)) }
3533 }
3534 }
3535 },
3536 _ => signature_badge(state),
3537 }
3538}
3539
3540/// A signature badge (green Verified / amber Unverified) with a descriptive title.
3541fn signature_badge(state: &git::SignatureState) -> Markup {
3542 match state {
3543 git::SignatureState::Verified { fingerprint, .. } => html! {
3544 span class="badge badge-verified" title={ "Verified: " (fingerprint) } { "Verified" }
3545 },
3546 git::SignatureState::Unsigned => html! {
3547 span class="badge badge-unverified" title="This commit is not signed" { "Unverified" }
3548 },
3549 git::SignatureState::UnknownKey { fingerprint, .. } => html! {
3550 span class="badge badge-unverified"
3551 title={ "Signed by an unrecognized key " (fingerprint) } { "Unverified" }
3552 },
3553 git::SignatureState::Invalid { reason, .. } => html! {
3554 span class="badge badge-unverified"
3555 title={ "Signature could not be verified: " (reason) } { "Unverified" }
3556 },
3557 }
3558}
3559
3560/// The detail bar under a commit's metadata describing its signature: who signed
3561/// it and with which key fingerprint. Renders nothing for an unsigned commit.
3562fn signature_detail(state: &git::SignatureState, signer: Option<&str>) -> Markup {
3563 match state {
3564 git::SignatureState::Unsigned => html! {},
3565 git::SignatureState::Verified {
3566 kind,
3567 fingerprint,
3568 user_id,
3569 ..
3570 } => html! {
3571 div class="sig-detail sig-verified" {
3572 span class="sig-signer" {
3573 "Signed by " strong { (signer.unwrap_or(user_id)) }
3574 }
3575 span class="sig-fp mono" {
3576 (key_kind_label(*kind)) " key fingerprint: " (fingerprint)
3577 }
3578 }
3579 },
3580 git::SignatureState::UnknownKey { kind, fingerprint } => html! {
3581 div class="sig-detail sig-unverified" {
3582 span { "Signed by an unregistered " (key_kind_label(*kind)) " key" }
3583 span class="sig-fp mono" { (fingerprint) }
3584 }
3585 },
3586 git::SignatureState::Invalid { reason, .. } => html! {
3587 div class="sig-detail sig-unverified" {
3588 span { "Signature could not be verified: " (reason) }
3589 }
3590 },
3591 }
3592}
3593
3594/// Map registered keys to the verification input type.
3595fn signing_keys(keys: Vec<model::Key>) -> Vec<git::SigningKey> {
3596 keys.into_iter()
3597 .map(|k| git::SigningKey {
3598 user_id: k.user_id,
3599 key_id: k.id,
3600 kind: k.kind,
3601 fingerprint: k.fingerprint,
3602 public_key: k.public_key,
3603 })
3604 .collect()
3605}
3606
3607/// Whether a filename is a browser-renderable image.
3608fn is_image(filename: &str) -> bool {
3609 matches!(
3610 filename
3611 .rsplit('.')
3612 .next()
3613 .map(str::to_ascii_lowercase)
3614 .as_deref(),
3615 Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "ico" | "bmp")
3616 )
3617}
3618
3619/// Format an epoch-millisecond timestamp as a human join date, e.g.
3620/// `Jul 11, 2026`.
3621pub(crate) fn fmt_joined(ms: i64) -> String {
3622 let secs = ms.div_euclid(1000);
3623 match time::OffsetDateTime::from_unix_timestamp(secs) {
3624 Ok(dt) => {
3625 let month = match dt.month() {
3626 time::Month::January => "Jan",
3627 time::Month::February => "Feb",
3628 time::Month::March => "Mar",
3629 time::Month::April => "Apr",
3630 time::Month::May => "May",
3631 time::Month::June => "Jun",
3632 time::Month::July => "Jul",
3633 time::Month::August => "Aug",
3634 time::Month::September => "Sep",
3635 time::Month::October => "Oct",
3636 time::Month::November => "Nov",
3637 time::Month::December => "Dec",
3638 };
3639 format!("{month} {}, {}", dt.day(), dt.year())
3640 }
3641 Err(_) => "—".to_string(),
3642 }
3643}
3644
3645/// Format an epoch-millisecond timestamp as a `YYYY-MM-DD` date.
3646pub(crate) fn fmt_date(ms: i64) -> String {
3647 let secs = ms.div_euclid(1000);
3648 match time::OffsetDateTime::from_unix_timestamp(secs) {
3649 Ok(dt) => format!(
3650 "{:04}-{:02}-{:02}",
3651 dt.year(),
3652 u8::from(dt.month()),
3653 dt.day()
3654 ),
3655 Err(_) => "—".to_string(),
3656 }
3657}