feat(git): html fragment endpoints for repo page components with git.base_url
ZhenYi committed
Modified lib/config/src/git.rs lib/config/src/git.rs +2 −0 View lib/config/src/git.rs
@@ -35,6 +35,7 @@
3535 pub git_rpc_timeout_secs: u64,
3636 pub sync: SyncSettings,
3737 pub ssh: GitSshConfig,
38 pub base_url: String,
3839}
3940
4041impl Default for GitConfig {
@@ -47,6 +48,7 @@
4748 git_rpc_timeout_secs: 3600,
4849 sync: SyncSettings::default(),
4950 ssh: GitSshConfig::default(),
51 base_url: "http://localhost:3000".to_string(),
5052 }
5153 }
5254}
Added lib/git/src/handler/html/badge.rs lib/git/src/handler/html/badge.rs +59 −0 View lib/git/src/handler/html/badge.rs
@@ -0,0 +1,59 @@
1use axum::extract::{Path, State};
2use axum::response::Response;
3use maud::html;
4
5use crate::handler::html::{HtmlRepoPath, archived_class, format_size, resolve, response_html};
6use crate::service::GitService;
7use crate::share::access::BasicAuth;
8
9#[utoipa::path(
10 get,
11 path = "/repos/{space_name}/{repo_name}/html/badge",
12 tag = "html",
13 params(HtmlRepoPath),
14 responses(
15 (status = 200, description = "repo badge html fragment", content_type = "text/html"),
16 (status = 404, description = "repo not found")
17 )
18)]
19pub async fn render(
20 State(service): State<GitService>,
21 Path(path): Path<HtmlRepoPath>,
22 auth: BasicAuth,
23) -> Response {
24 let ctx = match resolve(service, path, auth).await {
25 Ok(c) => c,
26 Err(resp) => return resp,
27 };
28 let base = "git-badge inline-flex items-center gap-2 rounded-md bg-zinc-100 px-2 py-0.5 text-xs text-zinc-700";
29 let cls = archived_class(&ctx.dto, base);
30 let size_str = format_size(ctx.dto.size.max(0) as u64);
31 let markup = html! {
32 div class=(cls)
33 data-component="git-badge"
34 data-space=(ctx.space_name)
35 data-repo=(ctx.repo_name)
36 data-default-branch=(ctx.dto.default_branch)
37 data-commits=(ctx.dto.nums_commits)
38 data-size=(ctx.dto.size)
39 data-archived=(if ctx.dto.is_archived { "true" } else { "false" }) {
40 span class="rounded bg-white px-1.5 py-0.5 font-mono text-[10px]" {
41 (ctx.dto.default_branch)
42 }
43 span class="flex items-center gap-1" {
44 svg class="h-3 w-3" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {
45 path d="M11.93 8.5a4.002 4.002 0 01-7.86 0H.75a.75.75 0 010-1.5h3.32a4.002 4.002 0 017.86 0h3.32a.75.75 0 010 1.5zm-1.43-.75a2.5 2.5 0 10-5 0 2.5 2.5 0 005 0z" {}
46 }
47 span class="font-mono" { (ctx.dto.nums_commits) }
48 span { "commits" }
49 }
50 span class="flex items-center gap-1" {
51 svg class="h-3 w-3" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {
52 path d="M2 2.5A2.5 2.5 0 014.5 0h8.75a.75.75 0 01.75.75v12.5a.75.75 0 01-.75.75h-2.5a.75.75 0 110-1.5h1.75v-2h-8a1 1 0 00-.714 1.7.75.75 0 01-1.072 1.05A2.495 2.495 0 012 11.5zm10.5-1V9h-8c-.356 0-.694.074-1 .208V2.5a1 1 0 011-1h8zM5 12.25v3.25a.25.25 0 00.4.2l1.45-1.087a.25.25 0 01.3 0L8.6 15.7a.25.25 0 00.4-.2v-3.25a.25.25 0 00-.25-.25h-3.5a.25.25 0 00-.25.25z" {}
53 }
54 span class="font-mono" { (size_str) }
55 }
56 }
57 };
58 response_html(markup, 30)
59}
No newline at end of file
Added lib/git/src/handler/html/blob_header.rs lib/git/src/handler/html/blob_header.rs +84 −0 View lib/git/src/handler/html/blob_header.rs
@@ -0,0 +1,84 @@
1use axum::extract::{Path, Query, State};
2use axum::response::Response;
3use maud::html;
4
5use crate::handler::html::error::html_err;
6use crate::handler::html::{HtmlRepoPath, archived_class, resolve, response_html};
7use crate::service::GitService;
8use crate::share::access::BasicAuth;
9use crate::share::validate::validate_revision;
10
11#[derive(Debug, Clone, serde::Deserialize, utoipa::IntoParams)]
12pub struct BlobHeaderQuery {
13 #[serde(default)]
14 pub revision: Option<String>,
15 #[serde(default)]
16 pub path: Option<String>,
17}
18
19#[utoipa::path(
20 get,
21 path = "/repos/{space_name}/{repo_name}/html/blob-header",
22 tag = "html",
23 params(HtmlRepoPath, BlobHeaderQuery),
24 responses(
25 (status = 200, description = "blob header html fragment", content_type = "text/html"),
26 (status = 404, description = "repo, ref or blob not found")
27 )
28)]
29pub async fn render(
30 State(service): State<GitService>,
31 Path(path): Path<HtmlRepoPath>,
32 Query(q): Query<BlobHeaderQuery>,
33 auth: BasicAuth,
34) -> Response {
35 let ctx = match resolve(service, path, auth).await {
36 Ok(c) => c,
37 Err(resp) => return resp,
38 };
39 let revision = q
40 .revision
41 .as_deref()
42 .unwrap_or(ctx.dto.default_branch.as_str())
43 .to_string();
44 if validate_revision(&revision).is_err() {
45 return html_err(404, "ref invalid");
46 }
47 let raw_path = q.path.unwrap_or_default();
48 let clean_path = match crate::handler::html::validate_path(&raw_path) {
49 Some(p) => p,
50 None => return html_err(404, "path invalid"),
51 };
52 if clean_path.is_empty() {
53 return html_err(404, "path required");
54 }
55 let base = "git-blob-header flex items-center justify-between border-b border-zinc-200 px-3 py-2";
56 let cls = archived_class(&ctx.dto, base);
57 let filename = clean_path.rsplit('/').next().unwrap_or(&clean_path);
58 let space = ctx.space_name.clone();
59 let repo = ctx.repo_name.clone();
60 let raw_url = format!("/{space}/{repo}/blob/raw/{revision}/{clean_path}");
61 let markup = html! {
62 div class=(cls)
63 data-component="git-blob-header"
64 data-space=(space)
65 data-repo=(repo)
66 data-revision=(revision)
67 data-path=(clean_path.as_str())
68 data-archived=(if ctx.dto.is_archived { "true" } else { "false" }) {
69 div class="flex items-center gap-2" {
70 svg class="h-4 w-4 text-zinc-500" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {
71 path d="M2 2.5A2.5 2.5 0 014.5 0h8.75a.75.75 0 01.75.75v12.5a.75.75 0 01-.75.75h-2.5a.75.75 0 110-1.5h1.75v-2h-8a1 1 0 00-.714 1.7.75.75 0 01-1.072 1.05A2.495 2.495 0 012 11.5zm10.5-1V9h-8c-.356 0-.694.074-1 .208V2.5a1 1 0 011-1h8z" {}
72 }
73 span class="font-mono text-sm text-zinc-700" { (filename) }
74 }
75 div class="flex items-center gap-2" {
76 a class="rounded border border-zinc-200 px-2 py-0.5 text-xs hover:bg-zinc-50"
77 href=(raw_url) {
78 "raw"
79 }
80 }
81 }
82 };
83 response_html(markup, 30)
84}
No newline at end of file
Added lib/git/src/handler/html/breadcrumbs.rs lib/git/src/handler/html/breadcrumbs.rs +102 −0 View lib/git/src/handler/html/breadcrumbs.rs
@@ -0,0 +1,102 @@
1use axum::extract::{Path, Query, State};
2use axum::response::Response;
3use maud::html;
4
5use crate::handler::html::error::html_err;
6use crate::handler::html::{HtmlRepoPath, archived_class, resolve, response_html};
7use crate::service::GitService;
8use crate::share::access::BasicAuth;
9use crate::share::validate::validate_revision;
10
11#[derive(Debug, Clone, serde::Deserialize, utoipa::IntoParams)]
12pub struct BreadcrumbsQuery {
13 #[serde(default)]
14 pub revision: Option<String>,
15 #[serde(default)]
16 pub path: Option<String>,
17}
18
19#[utoipa::path(
20 get,
21 path = "/repos/{space_name}/{repo_name}/html/breadcrumbs",
22 tag = "html",
23 params(HtmlRepoPath, BreadcrumbsQuery),
24 responses(
25 (status = 200, description = "path breadcrumbs html fragment", content_type = "text/html"),
26 (status = 404, description = "repo or ref not found")
27 )
28)]
29pub async fn render(
30 State(service): State<GitService>,
31 Path(path): Path<HtmlRepoPath>,
32 Query(q): Query<BreadcrumbsQuery>,
33 auth: BasicAuth,
34) -> Response {
35 let ctx = match resolve(service, path, auth).await {
36 Ok(c) => c,
37 Err(resp) => return resp,
38 };
39 let revision = q
40 .revision
41 .as_deref()
42 .unwrap_or(ctx.dto.default_branch.as_str())
43 .to_string();
44 if validate_revision(&revision).is_err() {
45 return html_err(404, "ref invalid");
46 }
47 let raw_path = q.path.unwrap_or_default();
48 let clean_path = crate::handler::html::validate_path(&raw_path).unwrap_or_default();
49 let segments: Vec<&str> = if clean_path.is_empty() {
50 Vec::new()
51 } else {
52 clean_path.split('/').filter(|s| !s.is_empty()).collect()
53 };
54 let base = "git-breadcrumbs flex items-center gap-1 text-sm text-zinc-700";
55 let cls = archived_class(&ctx.dto, base);
56 let space = ctx.space_name.clone();
57 let repo = ctx.repo_name.clone();
58 let accumulated: Vec<String> = segments
59 .iter()
60 .scan(String::new(), |acc, seg| {
61 if !acc.is_empty() {
62 acc.push('/');
63 }
64 acc.push_str(seg);
65 Some(acc.clone())
66 })
67 .collect();
68 let markup = html! {
69 nav class=(cls)
70 data-component="git-breadcrumbs"
71 data-space=(space)
72 data-repo=(repo)
73 data-revision=(revision)
74 data-path=(clean_path.as_str())
75 data-archived=(if ctx.dto.is_archived { "true" } else { "false" })
76 aria-label="breadcrumb" {
77 a class="hover:underline text-zinc-500"
78 href=(format!("/{}/{}", ctx.space_name, ctx.repo_name)) {
79 (ctx.repo_name)
80 }
81 @if segments.is_empty() {
82 span class="text-zinc-400" { "/" }
83 } @else {
84 @for (i, seg) in segments.iter().enumerate() {
85 span class="text-zinc-400" { "/" }
86 @let is_last = i + 1 == segments.len();
87 @if is_last {
88 span class="font-medium text-zinc-800" { (seg) }
89 } @else {
90 a class="hover:underline text-zinc-600"
91 href=(format!("/{}/{}/tree/{}/{}",
92 ctx.space_name, ctx.repo_name, revision,
93 accumulated[i])) {
94 (seg)
95 }
96 }
97 }
98 }
99 }
100 };
101 response_html(markup, 30)
102}
No newline at end of file
Added lib/git/src/handler/html/clone_buttons.rs lib/git/src/handler/html/clone_buttons.rs +93 −0 View lib/git/src/handler/html/clone_buttons.rs
@@ -0,0 +1,93 @@
1use axum::extract::{Path, State};
2use axum::response::Response;
3use maud::html;
4
5use crate::handler::html::{HtmlRepoPath, clone_https_url, clone_ssh_url, resolve, response_html};
6use crate::service::GitService;
7use crate::share::access::BasicAuth;
8
9#[utoipa::path(
10 get,
11 path = "/repos/{space_name}/{repo_name}/html/clone-buttons",
12 tag = "html",
13 params(HtmlRepoPath),
14 responses(
15 (status = 200, description = "clone buttons html fragment", content_type = "text/html"),
16 (status = 404, description = "repo not found")
17 )
18)]
19pub async fn render(
20 State(service): State<GitService>,
21 Path(path): Path<HtmlRepoPath>,
22 auth: BasicAuth,
23) -> Response {
24 let ctx = match resolve(service.clone(), path.clone(), auth).await {
25 Ok(c) => c,
26 Err(resp) => return resp,
27 };
28 let (https_url, https_warn) = clone_https_url(&service, &ctx.space_name, &ctx.repo_name);
29 let (ssh_url, ssh_warn) = clone_ssh_url(&service, &ctx.space_name, &ctx.repo_name);
30 let base = "git-clone-buttons flex items-center gap-2";
31 let cls = if ctx.dto.is_archived {
32 format!("{base} opacity-60")
33 } else {
34 base.to_string()
35 };
36 let warn_attr = if https_warn || ssh_warn { "true" } else { "false" };
37 let markup = html! {
38 div class=(cls)
39 data-component="git-clone-buttons"
40 data-space=(ctx.space_name)
41 data-repo=(ctx.repo_name)
42 data-archived=(if ctx.dto.is_archived { "true" } else { "false" })
43 data-warn=(warn_attr) {
44 @if !https_url.is_empty() {
45 button class="rounded border border-zinc-200 px-2 py-1 text-xs hover:bg-zinc-50"
46 type="button"
47 data-protocol="https"
48 data-url=(https_url) {
49 "HTTPS"
50 }
51 }
52 @if !ssh_url.is_empty() {
53 button class="rounded border border-zinc-200 px-2 py-1 text-xs hover:bg-zinc-50"
54 type="button"
55 data-protocol="ssh"
56 data-url=(ssh_url) {
57 "SSH"
58 }
59 }
60 @if !https_url.is_empty() {
61 input class="rounded border border-zinc-200 px-2 py-1 font-mono text-xs"
62 type="text"
63 readonly
64 value=(https_url.clone())
65 aria-label="clone url" {}
66 button class="rounded border border-zinc-200 px-2 py-1 text-xs hover:bg-zinc-50"
67 type="button"
68 data-action="copy"
69 data-target="input" {
70 "copy"
71 }
72 } @else if !ssh_url.is_empty() {
73 input class="rounded border border-zinc-200 px-2 py-1 font-mono text-xs"
74 type="text"
75 readonly
76 value=(ssh_url.clone())
77 aria-label="clone url" {}
78 button class="rounded border border-zinc-200 px-2 py-1 text-xs hover:bg-zinc-50"
79 type="button"
80 data-action="copy"
81 data-target="input" {
82 "copy"
83 }
84 } @else {
85 span class="text-xs text-amber-600"
86 data-warn="missing-base-url" {
87 "clone url unavailable: base_url not configured"
88 }
89 }
90 }
91 };
92 response_html(markup, 30)
93}
No newline at end of file
Added lib/git/src/handler/html/error.rs lib/git/src/handler/html/error.rs +40 −0 View lib/git/src/handler/html/error.rs
@@ -0,0 +1,40 @@
1use axum::http::StatusCode;
2use axum::response::{IntoResponse, Response};
3use maud::html;
4
5pub fn html_err(code: u16, msg: &str) -> Response {
6 let safe_msg = match code {
7 401 => "auth required",
8 403 => "forbidden",
9 404 => "not found",
10 504 => "timeout",
11 _ => "internal",
12 };
13 let display_msg = if msg.is_empty() { safe_msg } else { msg };
14 let markup = html! {
15 div class="git-err rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700"
16 data-code=(code) data-component="git-err" role="alert" {
17 (display_msg)
18 }
19 };
20 let body = markup.into_string();
21 let status = StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
22 let mut resp = (status, body).into_response();
23 resp.headers_mut()
24 .insert(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8".parse().unwrap());
25 resp.headers_mut()
26 .insert(axum::http::header::CACHE_CONTROL, "no-store".parse().unwrap());
27 resp.headers_mut()
28 .insert(axum::http::header::VARY, "Cookie".parse().unwrap());
29 if status == StatusCode::UNAUTHORIZED {
30 resp.headers_mut().insert(
31 axum::http::header::WWW_AUTHENTICATE,
32 "Basic realm=\"gitdata\"".parse().unwrap(),
33 );
34 }
35 resp
36}
37
38pub fn err_archived() -> String {
39 String::new()
40}
No newline at end of file
Added lib/git/src/handler/html/header.rs lib/git/src/handler/html/header.rs +59 −0 View lib/git/src/handler/html/header.rs
@@ -0,0 +1,59 @@
1use axum::extract::{Path, State};
2use axum::response::Response;
3use maud::html;
4
5use crate::handler::html::{HtmlRepoPath, archived_class, resolve, response_html};
6use crate::service::GitService;
7use crate::share::access::BasicAuth;
8
9#[utoipa::path(
10 get,
11 path = "/repos/{space_name}/{repo_name}/html/header",
12 tag = "html",
13 params(HtmlRepoPath),
14 responses(
15 (status = 200, description = "repo header html fragment", content_type = "text/html"),
16 (status = 404, description = "repo not found")
17 )
18)]
19pub async fn render(
20 State(service): State<GitService>,
21 Path(path): Path<HtmlRepoPath>,
22 auth: BasicAuth,
23) -> Response {
24 let ctx = match resolve(service, path, auth).await {
25 Ok(c) => c,
26 Err(resp) => return resp,
27 };
28 let base = "git-header flex items-center gap-2 text-lg font-semibold";
29 let cls = archived_class(&ctx.dto, base);
30 let markup = html! {
31 div class=(cls)
32 data-component="git-header"
33 data-space=(ctx.space_name)
34 data-repo=(ctx.repo_name)
35 data-private=(if ctx.dto.is_private { "true" } else { "false" })
36 data-archived=(if ctx.dto.is_archived { "true" } else { "false" })
37 data-default-branch=(ctx.dto.default_branch) {
38 a class="text-zinc-700 hover:text-zinc-900" href=(format!("/{}/{}", ctx.space_name, ctx.repo_name)) {
39 span class="font-medium text-zinc-500" { (ctx.space_name) }
40 span class="px-1 text-zinc-400" { "/" }
41 span { (ctx.repo_name) }
42 }
43 @if ctx.dto.is_private {
44 span class="rounded bg-amber-100 px-1.5 py-0.5 text-xs text-amber-700" {
45 "private"
46 }
47 }
48 @if ctx.dto.is_archived {
49 span class="rounded bg-zinc-200 px-1.5 py-0.5 text-xs text-zinc-600" {
50 "archived"
51 }
52 }
53 @if let Some(desc) = ctx.dto.description.as_ref() {
54 p class="ml-3 text-sm font-normal text-zinc-500" { (desc) }
55 }
56 }
57 };
58 response_html(markup, 30)
59}
No newline at end of file
Added lib/git/src/handler/html/last_commit.rs lib/git/src/handler/html/last_commit.rs +99 −0 View lib/git/src/handler/html/last_commit.rs
@@ -0,0 +1,99 @@
1use axum::extract::{Path, Query, State};
2use axum::response::Response;
3use gitbaby::tree::types::TreeEntryWithLatest;
4use maud::Markup;
5use maud::html;
6
7use crate::handler::html::error::html_err;
8use crate::handler::html::{
9 HtmlRepoPath, archived_class, first_line, resolve, response_html, short_sha,
10};
11use crate::share::access::BasicAuth;
12use crate::service::GitService;
13use crate::share::validate::validate_revision;
14
15#[derive(Debug, Clone, serde::Deserialize, utoipa::IntoParams)]
16pub struct LastCommitQuery {
17 #[serde(default)]
18 pub revision: Option<String>,
19}
20
21#[utoipa::path(
22 get,
23 path = "/repos/{space_name}/{repo_name}/html/last-commit",
24 tag = "html",
25 params(HtmlRepoPath, LastCommitQuery),
26 responses(
27 (status = 200, description = "last commit html fragment", content_type = "text/html"),
28 (status = 404, description = "repo not found")
29 )
30)]
31pub async fn render(
32 State(service): State<GitService>,
33 Path(path): Path<HtmlRepoPath>,
34 Query(q): Query<LastCommitQuery>,
35 auth: BasicAuth,
36) -> Response {
37 let ctx = match resolve(service.clone(), path.clone(), auth).await {
38 Ok(c) => c,
39 Err(resp) => return resp,
40 };
41 let revision = q
42 .revision
43 .as_deref()
44 .unwrap_or(ctx.dto.default_branch.as_str())
45 .to_string();
46 if validate_revision(&revision).is_err() {
47 return html_err(404, "ref invalid");
48 }
49 let rows = match ctx
50 .service
51 .tree_entries_with_latest_html(&ctx.ctx, &revision)
52 .await
53 {
54 Ok(r) => r,
55 Err(e) => {
56 tracing::warn!(error = %e, "last_commit internal");
57 return html_err(500, "internal");
58 }
59 };
60 let latest = rows.into_iter().next();
61 let base = "git-last-commit rounded-md border border-zinc-200 p-3";
62 let cls = archived_class(&ctx.dto, base);
63 let markup = html! {
64 div class=(cls)
65 data-component="git-last-commit"
66 data-space=(ctx.space_name)
67 data-repo=(ctx.repo_name)
68 data-revision=(revision)
69 data-archived=(if ctx.dto.is_archived { "true" } else { "false" }) {
70 @if let Some(row) = latest {
71 (render_row(&revision, &path, &row))
72 } @else {
73 p class="text-xs text-zinc-500" { "no commits yet" }
74 }
75 }
76 };
77 response_html(markup, 30)
78}
79
80fn render_row(revision: &str, path: &HtmlRepoPath, row: &TreeEntryWithLatest) -> Markup {
81 let sha = row.latest.id.to_string();
82 let short = short_sha(&sha);
83 let subject = first_line(&row.latest.message);
84 let author = &row.latest.author.name;
85 let iso = row.latest.author.time.format(&time::format_description::well_known::Rfc3339).unwrap_or_default();
86 let space = &path.space_name;
87 let repo = &path.repo_name;
88 let commit_url = format!("/{space}/{repo}/commit/{sha}");
89 html! {
90 a class="flex items-center gap-3 text-zinc-700 hover:text-zinc-900"
91 href=(commit_url)
92 title=(format!("{sha}\n{subject}\n{author}\n{iso}")) {
93 code class="rounded bg-zinc-100 px-1.5 py-0.5 font-mono text-xs" { (short) }
94 span class="truncate text-sm" { (subject) }
95 span class="ml-auto text-xs text-zinc-500" { (author) }
96 span class="sr-only" { (revision) }
97 }
98 }
99}
No newline at end of file
Added lib/git/src/handler/html/mod.rs lib/git/src/handler/html/mod.rs +277 −0 View lib/git/src/handler/html/mod.rs
@@ -0,0 +1,277 @@
1use std::sync::Arc;
2
3use axum::Router;
4use axum::response::{IntoResponse, Response};
5use maud::Markup;
6use time::OffsetDateTime;
7use time::format_description::well_known::Rfc3339;
8use tracing::warn;
9
10use crate::context::facade::GitContext;
11use crate::service::GitService;
12use crate::service::repos::RepoDto;
13use crate::share::access::BasicAuth;
14use crate::share::access::allow_repo_read;
15use crate::share::errors::GitError;
16
17pub mod badge;
18pub mod blob_header;
19pub mod breadcrumbs;
20pub mod clone_buttons;
21pub mod error;
22pub mod header;
23pub mod last_commit;
24pub mod quickstart;
25pub mod refs_dropdown;
26pub mod refs_summary;
27pub mod tree;
28pub mod tree_row;
29
30pub use error::html_err;
31
32const MAX_LIMIT: usize = 1000;
33const DEFAULT_LIMIT: usize = 1000;
34const MAX_PATH_LEN: usize = 4096;
35const MAX_NAME_LEN: usize = 128;
36const MAX_SUBJECT_LEN: usize = 200;
37const MAX_REVISION_LEN: usize = 256;
38
39#[derive(Clone)]
40pub struct HtmlCtx {
41 pub service: GitService,
42 pub ctx: Arc<GitContext>,
43 pub dto: RepoDto,
44 pub uid: Option<uuid::Uuid>,
45 pub space_name: String,
46 pub repo_name: String,
47}
48
49pub fn router() -> Router<GitService> {
50 Router::new()
51 .route("/clone-buttons", axum::routing::get(clone_buttons::render))
52 .route("/badge", axum::routing::get(badge::render))
53 .route("/quickstart", axum::routing::get(quickstart::render))
54 .route("/header", axum::routing::get(header::render))
55 .route("/last-commit", axum::routing::get(last_commit::render))
56 .route("/refs-summary", axum::routing::get(refs_summary::render))
57 .route("/refs-dropdown", axum::routing::get(refs_dropdown::render))
58 .route("/tree", axum::routing::get(tree::render))
59 .route("/tree-row", axum::routing::get(tree_row::render))
60 .route("/breadcrumbs", axum::routing::get(breadcrumbs::render))
61 .route("/blob-header", axum::routing::get(blob_header::render))
62}
63
64#[derive(Debug, Clone, serde::Deserialize, utoipa::IntoParams)]
65pub struct HtmlRepoPath {
66 pub space_name: String,
67 pub repo_name: String,
68}
69
70pub async fn resolve(
71 service: GitService,
72 path: HtmlRepoPath,
73 auth: BasicAuth,
74) -> Result<HtmlCtx, Response> {
75 if path.space_name.chars().count() > MAX_NAME_LEN || path.repo_name.chars().count() > MAX_NAME_LEN {
76 return Err(html_err(404, "not found"));
77 }
78 let ctx = match service.context(path.space_name.clone(), path.repo_name.clone()).await {
79 Ok(c) => Arc::new(c),
80 Err(GitError::NotFound) => return Err(html_err(404, "repo not found")),
81 Err(e) => {
82 warn!(error = %e, "html resolve internal");
83 return Err(html_err(500, "internal"));
84 }
85 };
86 let uid = match allow_repo_read(&service, &auth, &ctx).await {
87 Ok(uid) => uid,
88 Err(GitError::Unauthorized) => return Err(html_err(401, "auth required")),
89 Err(GitError::Forbidden) => return Err(html_err(403, "forbidden")),
90 Err(e) => {
91 warn!(error = %e, "html auth internal");
92 return Err(html_err(500, "internal"));
93 }
94 };
95 let dto = match service.get_repo_for_html(ctx.clone()).await {
96 Ok(d) => d,
97 Err(GitError::NotFound) => return Err(html_err(404, "repo not found")),
98 Err(e) => {
99 warn!(error = %e, "html dto internal");
100 return Err(html_err(500, "internal"));
101 }
102 };
103 Ok(HtmlCtx {
104 service,
105 ctx,
106 dto,
107 uid,
108 space_name: path.space_name,
109 repo_name: path.repo_name,
110 })
111}
112
113pub fn clamp_limit(q: Option<usize>) -> usize {
114 match q {
115 None => DEFAULT_LIMIT,
116 Some(0) => DEFAULT_LIMIT,
117 Some(n) if n > MAX_LIMIT => MAX_LIMIT,
118 Some(n) => n,
119 }
120}
121
122pub fn validate_path(p: &str) -> Option<String> {
123 if p.len() > MAX_PATH_LEN {
124 return None;
125 }
126 if p.contains('\0') || p.contains('\\') {
127 return None;
128 }
129 if p.split('/').any(|seg| seg == "..") {
130 return None;
131 }
132 if p.starts_with('/') {
133 return None;
134 }
135 Some(p.trim().to_string())
136}
137
138pub fn validate_cursor(c: &str) -> Option<String> {
139 if c.len() != 40 || !c.chars().all(|x| x.is_ascii_hexdigit()) {
140 return None;
141 }
142 Some(c.to_ascii_lowercase())
143}
144
145pub fn validate_revision(rev: &str) -> Option<String> {
146 let r = rev.trim();
147 if r.is_empty() || r.len() > MAX_REVISION_LEN {
148 return None;
149 }
150 if r.starts_with('-') || r.contains('\0') || r.contains('\n') || r.contains('\r') {
151 return None;
152 }
153 Some(r.to_string())
154}
155
156pub fn short_sha(s: &str) -> String {
157 s.chars().take(7).collect()
158}
159
160pub fn first_line(s: &str) -> String {
161 let line = s.lines().next().unwrap_or("");
162 if line.chars().count() <= MAX_SUBJECT_LEN {
163 return line.to_string();
164 }
165 let mut out: String = line.chars().take(MAX_SUBJECT_LEN - 1).collect();
166 out.push('…');
167 out
168}
169
170pub fn relative_time(t: OffsetDateTime) -> String {
171 let now = OffsetDateTime::now_utc();
172 let delta = (now - t).whole_seconds();
173 if delta < 0 {
174 return "just now".to_string();
175 }
176 if delta < 60 {
177 return format!("{delta}s ago");
178 }
179 if delta < 3600 {
180 return format!("{}m ago", delta / 60);
181 }
182 if delta < 86_400 {
183 return format!("{}h ago", delta / 3600);
184 }
185 if delta < 86_400 * 30 {
186 return format!("{}d ago", delta / 86_400);
187 }
188 if delta < 86_400 * 365 {
189 return format!("{}mo ago", delta / (86_400 * 30));
190 }
191 format!("{}y ago", delta / (86_400 * 365))
192}
193
194pub fn iso_time(t: OffsetDateTime) -> String {
195 t.format(&Rfc3339).unwrap_or_default()
196}
197
198pub fn format_size(bytes: u64) -> String {
199 if bytes < 1024 {
200 return format!("{bytes} B");
201 }
202 let kb = bytes / 1024;
203 if kb < 1024 {
204 return format!("{kb} KB");
205 }
206 let mb = kb / 1024;
207 if mb < 1024 {
208 return format!("{mb} MB");
209 }
210 let gb = mb / 1024;
211 format!("{gb} GB")
212}
213
214pub fn response_html(markup: Markup, max_age: u32) -> Response {
215 let body = markup.into_string();
216 let mut resp = (axum::http::StatusCode::OK, body).into_response();
217 resp.headers_mut()
218 .insert(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8".parse().unwrap());
219 if max_age == 0 {
220 resp.headers_mut()
221 .insert(axum::http::header::CACHE_CONTROL, "no-store".parse().unwrap());
222 } else {
223 resp.headers_mut().insert(
224 axum::http::header::CACHE_CONTROL,
225 format!("private, max-age={max_age}").parse().unwrap(),
226 );
227 }
228 resp.headers_mut().insert(axum::http::header::VARY, "Cookie".parse().unwrap());
229 resp
230}
231
232pub fn archived_class(dto: &RepoDto, base: &str) -> String {
233 if dto.is_archived {
234 format!("{base} opacity-60")
235 } else {
236 base.to_string()
237 }
238}
239
240pub fn clone_https_url(service: &GitService, space: &str, repo: &str) -> (String, bool) {
241 let base = &service.state.config.git.base_url;
242 if base.is_empty() {
243 return (String::new(), true);
244 }
245 let mut url = base.trim_end_matches('/').to_string();
246 url.push('/');
247 url.push_str(space);
248 url.push('/');
249 url.push_str(repo);
250 url.push_str(".git");
251 (url, false)
252}
253
254pub fn clone_ssh_url(service: &GitService, space: &str, repo: &str) -> (String, bool) {
255 let base = &service.state.config.git.base_url;
256 if base.is_empty() {
257 return (String::new(), true);
258 }
259 let scheme_stripped = base
260 .trim_start_matches("https://")
261 .trim_start_matches("http://");
262 let host = scheme_stripped.split('/').next().unwrap_or("");
263 let port = service.state.config.git.ssh.port;
264 if host.is_empty() {
265 return (String::new(), true);
266 }
267 let url = if port == 22 {
268 format!("git@{host}:{space}/{repo}.git")
269 } else {
270 format!("ssh://git@{host}:{port}/{space}/{repo}.git")
271 };
272 (url, false)
273}
274
275pub fn empty_state(dto: &RepoDto) -> bool {
276 dto.nums_commits == 0
277}
No newline at end of file
Added lib/git/src/handler/html/quickstart.rs lib/git/src/handler/html/quickstart.rs +82 −0 View lib/git/src/handler/html/quickstart.rs
@@ -0,0 +1,82 @@
1use axum::extract::{Path, State};
2use axum::response::Response;
3use maud::html;
4
5use crate::handler::html::{
6 HtmlRepoPath, clone_https_url, clone_ssh_url, empty_state, resolve, response_html,
7};
8use crate::service::GitService;
9use crate::share::access::BasicAuth;
10
11#[utoipa::path(
12 get,
13 path = "/repos/{space_name}/{repo_name}/html/quickstart",
14 tag = "html",
15 params(HtmlRepoPath),
16 responses(
17 (status = 200, description = "empty repo quickstart html fragment", content_type = "text/html"),
18 (status = 404, description = "repo not found")
19 )
20)]
21pub async fn render(
22 State(service): State<GitService>,
23 Path(path): Path<HtmlRepoPath>,
24 auth: BasicAuth,
25) -> Response {
26 let ctx = match resolve(service.clone(), path.clone(), auth).await {
27 Ok(c) => c,
28 Err(resp) => return resp,
29 };
30 let is_empty = empty_state(&ctx.dto);
31 let base = "git-quickstart space-y-3 rounded-lg border border-dashed border-zinc-300 p-4";
32 let cls = if ctx.dto.is_archived {
33 format!("{base} opacity-60")
34 } else {
35 base.to_string()
36 };
37 let (https_url, _) = clone_https_url(&service, &ctx.space_name, &ctx.repo_name);
38 let (ssh_url, _) = clone_ssh_url(&service, &ctx.space_name, &ctx.repo_name);
39 let clone_url = if !https_url.is_empty() {
40 https_url.clone()
41 } else {
42 ssh_url.clone()
43 };
44 let markup = html! {
45 div class=(cls)
46 data-component="git-quickstart"
47 data-space=(ctx.space_name)
48 data-repo=(ctx.repo_name)
49 data-empty=(if is_empty { "true" } else { "false" })
50 data-default-branch=(ctx.dto.default_branch) {
51 h3 class="text-sm font-semibold text-zinc-700" { "Quick start" }
52 @if is_empty {
53 div class="rounded bg-white p-3 text-xs text-zinc-600" {
54 "This repository is empty. Push an existing Git repository to get started."
55 }
56 } @else {
57 div class="rounded bg-white p-3 text-xs text-zinc-600" {
58 "Repository is initialized. Clone it locally to start contributing."
59 }
60 }
61 @if !clone_url.is_empty() {
62 pre class="overflow-x-auto rounded bg-zinc-900 p-3 font-mono text-xs text-zinc-100" {
63 code { "git clone " (clone_url) }
64 }
65 pre class="overflow-x-auto rounded bg-zinc-900 p-3 font-mono text-xs text-zinc-100" {
66 code {
67 "cd " (ctx.repo_name) "\n"
68 "touch README.md\n"
69 "git add README.md\n"
70 "git commit -m \"first commit\"\n"
71 "git push -u origin " (ctx.dto.default_branch)
72 }
73 }
74 } @else {
75 p class="text-xs text-amber-600" data-warn="missing-base-url" {
76 "Clone URL unavailable. Configure [git].base_url to enable."
77 }
78 }
79 }
80 };
81 response_html(markup, 30)
82}
No newline at end of file
Added lib/git/src/handler/html/refs_dropdown.rs lib/git/src/handler/html/refs_dropdown.rs +57 −0 View lib/git/src/handler/html/refs_dropdown.rs
@@ -0,0 +1,57 @@
1use axum::extract::{Path, State};
2use axum::response::Response;
3use maud::html;
4
5use crate::handler::html::{HtmlRepoPath, archived_class, resolve, response_html};
6use crate::service::GitService;
7use crate::share::access::BasicAuth;
8
9#[utoipa::path(
10 get,
11 path = "/repos/{space_name}/{repo_name}/html/refs-dropdown",
12 tag = "html",
13 params(HtmlRepoPath),
14 responses(
15 (status = 200, description = "refs dropdown html fragment", content_type = "text/html"),
16 (status = 404, description = "repo not found")
17 )
18)]
19pub async fn render(
20 State(service): State<GitService>,
21 Path(path): Path<HtmlRepoPath>,
22 auth: BasicAuth,
23) -> Response {
24 let ctx = match resolve(service, path, auth).await {
25 Ok(c) => c,
26 Err(resp) => return resp,
27 };
28 let base = "git-refs-dropdown relative inline-flex w-full max-w-xs";
29 let cls = archived_class(&ctx.dto, base);
30 let default = ctx.dto.default_branch.clone();
31 let space = ctx.space_name.clone();
32 let repo = ctx.repo_name.clone();
33 let markup = html! {
34 div class=(cls)
35 data-component="git-refs-dropdown"
36 data-space=(space)
37 data-repo=(repo)
38 data-default-branch=(default)
39 data-archived=(if ctx.dto.is_archived { "true" } else { "false" }) {
40 label class="mb-1 block text-xs font-medium text-zinc-600" { "Branch" }
41 select class="w-full rounded border border-zinc-200 bg-white px-2 py-1 text-sm"
42 data-action="switch-ref" {
43 option value=(ctx.dto.default_branch) selected[true] {
44 (ctx.dto.default_branch) " (default)"
45 }
46 @if ctx.dto.nums_branches > 1 {
47 option disabled="true" value="" { "—" }
48 option value="__tags__" disabled="true" { "tags: " (ctx.dto.nums_tags) }
49 }
50 }
51 p class="mt-1 text-xs text-zinc-500" {
52 (ctx.dto.nums_branches) " branches · " (ctx.dto.nums_tags) " tags"
53 }
54 }
55 };
56 response_html(markup, 30)
57}
No newline at end of file
Added lib/git/src/handler/html/refs_summary.rs lib/git/src/handler/html/refs_summary.rs +57 −0 View lib/git/src/handler/html/refs_summary.rs
@@ -0,0 +1,57 @@
1use axum::extract::{Path, State};
2use axum::response::Response;
3use maud::html;
4
5use crate::handler::html::{HtmlRepoPath, archived_class, resolve, response_html};
6use crate::service::GitService;
7use crate::share::access::BasicAuth;
8
9#[utoipa::path(
10 get,
11 path = "/repos/{space_name}/{repo_name}/html/refs-summary",
12 tag = "html",
13 params(HtmlRepoPath),
14 responses(
15 (status = 200, description = "refs summary html fragment", content_type = "text/html"),
16 (status = 404, description = "repo not found")
17 )
18)]
19pub async fn render(
20 State(service): State<GitService>,
21 Path(path): Path<HtmlRepoPath>,
22 auth: BasicAuth,
23) -> Response {
24 let ctx = match resolve(service, path, auth).await {
25 Ok(c) => c,
26 Err(resp) => return resp,
27 };
28 let branch_count = ctx.dto.nums_branches;
29 let tag_count = ctx.dto.nums_tags;
30 let base = "git-refs-summary flex items-center gap-3 text-sm text-zinc-600";
31 let cls = archived_class(&ctx.dto, base);
32 let markup = html! {
33 div class=(cls)
34 data-component="git-refs-summary"
35 data-space=(ctx.space_name)
36 data-repo=(ctx.repo_name)
37 data-branches=(branch_count)
38 data-tags=(tag_count)
39 data-archived=(if ctx.dto.is_archived { "true" } else { "false" }) {
40 span class="flex items-center gap-1" {
41 svg class="h-4 w-4" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {
42 path d="M11.75 2.5a.75.75 0 100 1.5.75.75 0 000-1.5zm-2.25.75a2.25 2.25 0 113 2.122V6A2.5 2.5 0 0110 8.5H6a1 1 0 00-1 1v1.128a2.5 2.5 0 11-1.5 0V5.372a2.5 2.5 0 011.5-2.122h4.5z" {}
43 }
44 span class="font-mono" { (branch_count) }
45 span { "branches" }
46 }
47 span class="flex items-center gap-1" {
48 svg class="h-4 w-4" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {
49 path d="M1 7.775V2.75C1 1.784 1.784 1 2.75 1h5.025c.464 0 .91.184 1.238.513l6.25 6.25a1.75 1.75 0 010 2.474l-5.026 5.026a1.75 1.75 0 01-2.474 0l-6.25-6.25A1.75 1.75 0 011 7.775z" {}
50 }
51 span class="font-mono" { (tag_count) }
52 span { "tags" }
53 }
54 }
55 };
56 response_html(markup, 30)
57}
No newline at end of file
Added lib/git/src/handler/html/tree.rs lib/git/src/handler/html/tree.rs +189 −0 View lib/git/src/handler/html/tree.rs
@@ -0,0 +1,189 @@
1use axum::extract::{Path, Query, State};
2use axum::response::Response;
3use gitbaby::commit::types::TreeEntryMode;
4use gitbaby::tree::types::TreeEntryWithLatest;
5use maud::Markup;
6use maud::html;
7
8use crate::handler::html::error::html_err;
9use crate::handler::html::{
10 HtmlRepoPath, archived_class, clamp_limit, first_line, iso_time, relative_time,
11 resolve, response_html, short_sha, validate_path,
12};
13use crate::service::GitService;
14use crate::share::access::BasicAuth;
15use crate::share::validate::validate_revision;
16
17#[derive(Debug, Clone, serde::Deserialize, utoipa::IntoParams)]
18pub struct TreeQuery {
19 #[serde(default)]
20 pub revision: Option<String>,
21 #[serde(default)]
22 pub path: Option<String>,
23 #[serde(default)]
24 pub limit: Option<usize>,
25}
26
27#[utoipa::path(
28 get,
29 path = "/repos/{space_name}/{repo_name}/html/tree",
30 tag = "html",
31 params(HtmlRepoPath, TreeQuery),
32 responses(
33 (status = 200, description = "tree list html fragment", content_type = "text/html"),
34 (status = 404, description = "repo, ref or path not found")
35 )
36)]
37pub async fn render(
38 State(service): State<GitService>,
39 Path(path): Path<HtmlRepoPath>,
40 Query(q): Query<TreeQuery>,
41 auth: BasicAuth,
42) -> Response {
43 let ctx = match resolve(service, path, auth).await {
44 Ok(c) => c,
45 Err(resp) => return resp,
46 };
47 let revision = q
48 .revision
49 .as_deref()
50 .unwrap_or(ctx.dto.default_branch.as_str())
51 .to_string();
52 if validate_revision(&revision).is_err() {
53 return html_err(404, "ref invalid");
54 }
55 let raw_path = q.path.unwrap_or_default();
56 let clean_path = match validate_path(&raw_path) {
57 Some(p) => p,
58 None => return html_err(404, "path invalid"),
59 };
60 let limit = clamp_limit(q.limit);
61 let rows = match ctx
62 .service
63 .tree_with_commits_html(&ctx.ctx, &revision, &clean_path)
64 .await
65 {
66 Ok(r) => r,
67 Err(e) => {
68 tracing::warn!(error = %e, "tree internal");
69 return html_err(500, "internal");
70 }
71 };
72 let total_count = rows.len();
73 let mut entries = rows;
74 entries.sort_by(|a, b| {
75 let dir_a = matches!(a.entry.mode, TreeEntryMode::Tree);
76 let dir_b = matches!(b.entry.mode, TreeEntryMode::Tree);
77 dir_b.cmp(&dir_a).then_with(|| a.entry.name.cmp(&b.entry.name))
78 });
79 let truncated = entries.len() > limit;
80 if truncated {
81 entries.truncate(limit);
82 }
83 let base = "git-tree w-full border-collapse text-sm";
84 let cls = archived_class(&ctx.dto, base);
85 let space = ctx.space_name.clone();
86 let repo = ctx.repo_name.clone();
87 let markup = html! {
88 table class=(cls)
89 data-component="git-tree"
90 data-space=(space)
91 data-repo=(repo)
92 data-revision=(revision)
93 data-path=(clean_path.as_str())
94 data-count=(total_count)
95 data-truncated=(if truncated { "true" } else { "false" })
96 data-archived=(if ctx.dto.is_archived { "true" } else { "false" }) {
97 thead class="bg-zinc-50 text-xs uppercase text-zinc-500" {
98 tr {
99 th class="px-2 py-1 text-left" { "name" }
100 th class="px-2 py-1 text-left" { "last commit" }
101 th class="px-2 py-1 text-right" { "date" }
102 }
103 }
104 tbody {
105 @if entries.is_empty() {
106 tr {
107 td class="px-2 py-3 text-center text-xs text-zinc-500"
108 colspan="3" {
109 "empty directory"
110 }
111 }
112 } @else {
113 @for row in &entries {
114 (render_row(&ctx, &revision, &clean_path, row))
115 }
116 }
117 }
118 }
119 };
120 response_html(markup, 30)
121}
122
123fn render_row(
124 ctx: &crate::handler::html::HtmlCtx,
125 revision: &str,
126 parent_path: &str,
127 row: &TreeEntryWithLatest,
128) -> Markup {
129 let mode = match row.entry.mode {
130 TreeEntryMode::Tree => "tree",
131 TreeEntryMode::Submodule => "submodule",
132 TreeEntryMode::Commit => "commit",
133 TreeEntryMode::Symlink => "symlink",
134 TreeEntryMode::Exec => "exec",
135 TreeEntryMode::Blob => "blob",
136 };
137 let entry_path = if parent_path.is_empty() {
138 row.entry.name.clone()
139 } else {
140 format!("{parent_path}/{}", row.entry.name)
141 };
142 let href_path = if mode == "tree" || mode == "submodule" {
143 format!("{entry_path}/")
144 } else {
145 entry_path.clone()
146 };
147 let href = format!(
148 "/{}/{}/{}",
149 ctx.space_name,
150 ctx.repo_name,
151 if mode == "tree" || mode == "submodule" {
152 format!("tree/{revision}/{href_path}")
153 } else {
154 format!("blob/{revision}/{href_path}")
155 }
156 );
157 let oid_str = row.entry.oid.to_string();
158 let short_oid = short_sha(&oid_str);
159 let sha = row.latest.id.to_string();
160 let short_sha_str = short_sha(&sha);
161 let subject = first_line(&row.latest.message);
162 let author = row.latest.author.name.clone();
163 let iso = iso_time(row.latest.author.time);
164 let rel = relative_time(row.latest.author.time);
165 let commit_url = format!("/{}/{}/commit/{}", ctx.space_name, ctx.repo_name, sha);
166 html! {
167 tr class="border-b border-zinc-100 hover:bg-zinc-50"
168 data-mode=(mode)
169 data-name=(row.entry.name)
170 data-path=(entry_path)
171 data-oid=(short_oid)
172 data-commit=(short_sha_str) {
173 td class="px-2 py-1 font-mono" {
174 a class="text-zinc-900 hover:underline" href=(href) { (row.entry.name) }
175 }
176 td class="px-2 py-1" {
177 a class="flex items-center gap-2 text-zinc-700 hover:text-zinc-900"
178 href=(commit_url)
179 title=(format!("{}\n{}\n{}\n{}", sha, subject, author, iso)) {
180 code class="rounded bg-zinc-100 px-1 text-xs" { (short_sha_str) }
181 span class="truncate max-w-md text-xs" { (subject) }
182 }
183 }
184 td class="px-2 py-1 text-right text-zinc-500 text-xs" data-time=(row.latest.author.time.unix_timestamp()) {
185 (rel)
186 }
187 }
188 }
189}
No newline at end of file
Added lib/git/src/handler/html/tree_row.rs lib/git/src/handler/html/tree_row.rs +183 −0 View lib/git/src/handler/html/tree_row.rs
@@ -0,0 +1,183 @@
1use axum::extract::{Path, Query, State};
2use axum::response::Response;
3use gitbaby::commit::types::TreeEntryMode;
4use maud::html;
5
6use crate::handler::html::error::html_err;
7use crate::handler::html::{
8 HtmlRepoPath, first_line, iso_time, relative_time, resolve, response_html, short_sha,
9};
10use crate::service::GitService;
11use crate::share::access::BasicAuth;
12use crate::share::validate::validate_revision;
13
14#[derive(Debug, Clone, serde::Deserialize, utoipa::IntoParams)]
15pub struct TreeRowQuery {
16 #[serde(default)]
17 pub revision: Option<String>,
18 #[serde(default)]
19 pub path: Option<String>,
20 #[serde(default)]
21 pub name: Option<String>,
22}
23
24#[utoipa::path(
25 get,
26 path = "/repos/{space_name}/{repo_name}/html/tree-row",
27 tag = "html",
28 params(HtmlRepoPath, TreeRowQuery),
29 responses(
30 (status = 200, description = "tree row html fragment", content_type = "text/html"),
31 (status = 404, description = "repo, ref or path not found")
32 )
33)]
34pub async fn render(
35 State(service): State<GitService>,
36 Path(path): Path<HtmlRepoPath>,
37 Query(q): Query<TreeRowQuery>,
38 auth: BasicAuth,
39) -> Response {
40 let ctx = match resolve(service, path, auth).await {
41 Ok(c) => c,
42 Err(resp) => return resp,
43 };
44 let revision = q
45 .revision
46 .as_deref()
47 .unwrap_or(ctx.dto.default_branch.as_str())
48 .to_string();
49 if validate_revision(&revision).is_err() {
50 return html_err(404, "ref invalid");
51 }
52 let parent = q.path.unwrap_or_default();
53 let parent_clean = match crate::handler::html::validate_path(&parent) {
54 Some(p) => p,
55 None => return html_err(404, "path invalid"),
56 };
57 let name = match q.name.as_deref() {
58 Some(n) if !n.is_empty() && n != ".." && !n.contains('/') && !n.contains('\\') => n.to_string(),
59 _ => return html_err(404, "name invalid"),
60 };
61 if name.is_empty() || name.chars().count() > crate::handler::html::MAX_NAME_LEN {
62 return html_err(404, "name invalid");
63 }
64 let lookup_path = if parent_clean.is_empty() {
65 format!("{name}/")
66 } else {
67 format!("{parent_clean}/{name}/")
68 };
69 let lookup_file = if parent_clean.is_empty() {
70 name.clone()
71 } else {
72 format!("{parent_clean}/{name}")
73 };
74 let rows = match ctx
75 .service
76 .tree_with_commits_html(&ctx.ctx, &revision, &parent_clean)
77 .await
78 {
79 Ok(r) => r,
80 Err(e) => {
81 tracing::warn!(error = %e, "tree-row internal");
82 return html_err(500, "internal");
83 }
84 };
85 let matched = rows.iter().find(|r| {
86 let entry_path = if r.entry.mode == TreeEntryMode::Tree {
87 format!("{}/", r.entry.name)
88 } else {
89 r.entry.name.clone()
90 };
91 if parent_clean.is_empty() {
92 r.entry.name == name || entry_path == lookup_path
93 } else {
94 entry_path == lookup_file || entry_path == lookup_path
95 }
96 });
97 let row = match matched {
98 Some(r) => r,
99 None => return html_err(404, "entry not found"),
100 };
101 let mode = match row.entry.mode {
102 TreeEntryMode::Tree => "tree",
103 TreeEntryMode::Submodule => "submodule",
104 TreeEntryMode::Commit => "commit",
105 TreeEntryMode::Symlink => "symlink",
106 TreeEntryMode::Exec => "exec",
107 TreeEntryMode::Blob => "blob",
108 };
109 let oid_str = row.entry.oid.to_string();
110 let short_oid = short_sha(&oid_str);
111 let sha = row.latest.id.to_string();
112 let short_sha_str = short_sha(&sha);
113 let subject = first_line(&row.latest.message);
114 let author = row.latest.author.name.clone();
115 let iso = iso_time(row.latest.author.time);
116 let rel = relative_time(row.latest.author.time);
117 let commit_url = format!("/{}/{}/commit/{}", ctx.space_name, ctx.repo_name, sha);
118 let blob_url = format!("/{}/{}/blob/{}/{}",
119 ctx.space_name, ctx.repo_name, revision, lookup_path);
120 let href = if mode == "tree" || mode == "submodule" {
121 format!("/{}/{}/tree/{}/{}", ctx.space_name, ctx.repo_name, revision, lookup_path)
122 } else {
123 blob_url.clone()
124 };
125 let base = "git-tree-row border-b border-zinc-100 hover:bg-zinc-50";
126 let cls = if ctx.dto.is_archived {
127 format!("{base} opacity-60")
128 } else {
129 base.to_string()
130 };
131 let markup = html! {
132 tr class=(cls)
133 data-component="git-tree-row"
134 data-space=(ctx.space_name)
135 data-repo=(ctx.repo_name)
136 data-revision=(revision)
137 data-path=(lookup_path)
138 data-mode=(mode)
139 data-name=(name.as_str())
140 data-oid=(short_oid)
141 data-commit=(short_sha_str)
142 data-archived=(if ctx.dto.is_archived { "true" } else { "false" }) {
143 td class="w-8 px-2 py-1 text-zinc-400" {
144 @if mode == "tree" {
145 svg class="h-4 w-4" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {
146 path d="M1.75 1A1.75 1.75 0 000 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0016 13.25V2.75A1.75 1.75 0 0014.25 1H1.75zM1.5 2.75a.25.25 0 01.25-.25h12.5a.25.25 0 01.25.25v.857l-4.61 4.61a.25.25 0 01-.354 0L5.61 4.354a.25.25 0 00-.354 0L1.5 8.111V2.75zm0 7.268l3.405-3.405 2.79 2.79a.75.75 0 001.06 0l3.245-3.245V13.25a.25.25 0 01-.25.25H1.75a.25.25 0 01-.25-.25V10.018z" {}
147 }
148 } @else if mode == "submodule" {
149 svg class="h-4 w-4" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {
150 path d="M4.25 2.5a.75.75 0 000 1.5h7.5a.75.75 0 000-1.5h-7.5zm0 4a.75.75 0 000 1.5h7.5a.75.75 0 000-1.5h-7.5zm0 4a.75.75 0 000 1.5h4.5a.75.75 0 000-1.5h-4.5z" {}
151 }
152 } @else if mode == "symlink" {
153 svg class="h-4 w-4" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {
154 path d="M2.5 4.5v7a1 1 0 001 1h7a1 1 0 001-1v-3.379a1.5 1.5 0 00-.44-1.061L8.94 5.94a1.5 1.5 0 00-1.061-.439H3.5a1 1 0 00-1 1z" {}
155 }
156 } @else if mode == "exec" {
157 svg class="h-4 w-4" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {
158 path d="M2.75 2.5a.25.25 0 00-.25.25v10.5c0 .138.112.25.25.25h10.5a.25.25 0 00.25-.25V2.75a.25.25 0 00-.25-.25H2.75z" {}
159 }
160 } @else {
161 svg class="h-4 w-4" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {
162 path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0113.25 16h-9.5A1.75 1.75 0 012 14.25V1.75z" {}
163 }
164 }
165 }
166 td class="px-2 py-1 font-mono" {
167 a class="text-zinc-900 hover:underline" href=(href) { (name) }
168 }
169 td class="px-2 py-1" {
170 a class="flex items-center gap-2 text-zinc-700 hover:text-zinc-900"
171 href=(commit_url)
172 title=(format!("{}\n{}\n{}\n{}", sha, subject, author, iso)) {
173 code class="rounded bg-zinc-100 px-1 text-xs" { (short_sha_str) }
174 span class="truncate max-w-md text-xs" { (subject) }
175 }
176 }
177 td class="px-2 py-1 text-right text-zinc-500 text-xs" data-time=(row.latest.author.time.unix_timestamp()) {
178 (rel)
179 }
180 }
181 };
182 response_html(markup, 30)
183}
No newline at end of file
Modified lib/git/src/handler/mod.rs lib/git/src/handler/mod.rs +1 −0 View lib/git/src/handler/mod.rs
@@ -9,6 +9,7 @@
99pub mod diffs;
1010pub mod graphql;
1111pub mod hooks;
12pub mod html;
1213pub mod licenses;
1314pub mod lfs;
1415pub mod logs;
Modified lib/git/src/service/repos.rs lib/git/src/service/repos.rs +7 −0 View lib/git/src/service/repos.rs
@@ -324,6 +324,13 @@
324324 Ok(RepoDto::from(ctx.model))
325325 }
326326
327 pub async fn get_repo_for_html(
328 &self,
329 ctx: std::sync::Arc<crate::context::facade::GitContext>,
330 ) -> Result<RepoDto, GitError> {
331 Ok(RepoDto::from(ctx.model.clone()))
332 }
333
327334 pub async fn list_repos(
328335 &self,
329336 space_name: String,
Modified lib/git/src/service/trees.rs lib/git/src/service/trees.rs +29 −0 View lib/git/src/service/trees.rs
@@ -152,6 +152,35 @@
152152 })
153153 .collect())
154154 }
155
156 pub async fn tree_entries_with_latest_html(
157 &self,
158 ctx: &std::sync::Arc<crate::context::facade::GitContext>,
159 revision: &str,
160 ) -> Result<Vec<gitbaby::tree::types::TreeEntryWithLatest>, GitError> {
161 validate_revision(revision)?;
162 let baby = self.baby(ctx);
163 baby.tree_entries_with_latest(revision, None)
164 .await
165 .map_err(map_baby)
166 }
167
168 pub async fn tree_with_commits_html(
169 &self,
170 ctx: &std::sync::Arc<crate::context::facade::GitContext>,
171 revision: &str,
172 path: &str,
173 ) -> Result<Vec<gitbaby::tree::types::TreeEntryWithLatest>, GitError> {
174 validate_revision(revision)?;
175 if path.contains("..") || path.contains('\\') || path.starts_with('/') || path.contains('\0') {
176 return Err(GitError::InvalidArgument("invalid path".into()));
177 }
178 let baby = self.baby(ctx);
179 let p = if path.is_empty() { None } else { Some(path) };
180 baby.tree_entries_with_latest(revision, p)
181 .await
182 .map_err(map_baby)
183 }
155184}
156185
157186fn mode_to_i8(mode: TreeEntryMode) -> i8 {
Modified lib/git/src/openapi.rs lib/git/src/openapi.rs +11 −0 View lib/git/src/openapi.rs
@@ -131,6 +131,17 @@
131131 handler::whitelists::list_tag_whitelists,
132132 handler::whitelists::remove_branch_whitelist,
133133 handler::whitelists::remove_tag_whitelist,
134 handler::html::clone_buttons::render,
135 handler::html::badge::render,
136 handler::html::quickstart::render,
137 handler::html::header::render,
138 handler::html::last_commit::render,
139 handler::html::refs_summary::render,
140 handler::html::refs_dropdown::render,
141 handler::html::tree::render,
142 handler::html::tree_row::render,
143 handler::html::breadcrumbs::render,
144 handler::html::blob_header::render,
134145 ),
135146 components(schemas(
136147 export::rest::NoData,
Modified lib/git/src/routes.rs lib/git/src/routes.rs +44 −0 View lib/git/src/routes.rs
@@ -465,6 +465,50 @@
465465 "/{space_name}/{repo_name}/tag-protection/{policy_id}/whitelists/{user_id}",
466466 delete(remove_tag_whitelist),
467467 )
468 .route(
469 "/{space_name}/{repo_name}/html/clone-buttons",
470 get(crate::handler::html::clone_buttons::render),
471 )
472 .route(
473 "/{space_name}/{repo_name}/html/badge",
474 get(crate::handler::html::badge::render),
475 )
476 .route(
477 "/{space_name}/{repo_name}/html/quickstart",
478 get(crate::handler::html::quickstart::render),
479 )
480 .route(
481 "/{space_name}/{repo_name}/html/header",
482 get(crate::handler::html::header::render),
483 )
484 .route(
485 "/{space_name}/{repo_name}/html/last-commit",
486 get(crate::handler::html::last_commit::render),
487 )
488 .route(
489 "/{space_name}/{repo_name}/html/refs-summary",
490 get(crate::handler::html::refs_summary::render),
491 )
492 .route(
493 "/{space_name}/{repo_name}/html/refs-dropdown",
494 get(crate::handler::html::refs_dropdown::render),
495 )
496 .route(
497 "/{space_name}/{repo_name}/html/tree",
498 get(crate::handler::html::tree::render),
499 )
500 .route(
501 "/{space_name}/{repo_name}/html/tree-row",
502 get(crate::handler::html::tree_row::render),
503 )
504 .route(
505 "/{space_name}/{repo_name}/html/breadcrumbs",
506 get(crate::handler::html::breadcrumbs::render),
507 )
508 .route(
509 "/{space_name}/{repo_name}/html/blob-header",
510 get(crate::handler::html::blob_header::render),
511 )
468512}
469513
470514fn topics_router() -> Router<GitService> {
Modified lib/git/Cargo.toml lib/git/Cargo.toml +2 −1 View lib/git/Cargo.toml
@@ -34,4 +34,5 @@
3434rand = { workspace = true }
3535bytes = { workspace = true }
3636session = { workspace = true }
37utoipa = { workspace = true }
No newline at end of file
37utoipa = { workspace = true }
38maud = { workspace = true }
No newline at end of file