use std::sync::Arc; use axum::Router; use axum::response::{IntoResponse, Response}; use maud::Markup; use time::OffsetDateTime; use time::format_description::well_known::Rfc3339; use tracing::warn; use crate::context::facade::GitContext; use crate::service::GitService; use crate::service::repos::RepoDto; use crate::share::access::BasicAuth; use crate::share::access::allow_repo_read; use crate::share::errors::GitError; pub mod badge; pub mod blame; pub mod blob_content; pub mod blob_header; pub mod blob_pages; pub mod breadcrumbs; pub mod clone_buttons; pub mod commit_diff; pub mod commits; pub mod error; pub mod header; pub mod languages; pub mod last_commit; pub mod quickstart; pub mod readme; pub mod refs_dropdown; pub mod refs_summary; pub mod star_button; pub mod tree; pub mod tree_row; pub mod watch_button; pub use error::html_err; const MAX_LIMIT: usize = 1000; const DEFAULT_LIMIT: usize = 1000; const MAX_PATH_LEN: usize = 4096; const MAX_NAME_LEN: usize = 128; const MAX_SUBJECT_LEN: usize = 200; const MAX_REVISION_LEN: usize = 256; #[derive(Clone)] pub struct HtmlCtx { pub service: GitService, pub ctx: Arc, pub dto: RepoDto, pub uid: Option, pub space_name: String, pub repo_name: String, } pub fn router() -> Router { Router::new() .route( "/clone-buttons", axum::routing::get(clone_buttons::render_clone_buttons), ) .route("/badge", axum::routing::get(badge::render_badge)) .route( "/quickstart", axum::routing::get(quickstart::render_quickstart), ) .route("/header", axum::routing::get(header::render_header)) .route( "/last-commit", axum::routing::get(last_commit::render_last_commit), ) .route( "/refs-summary", axum::routing::get(refs_summary::render_refs_summary), ) .route( "/refs-dropdown", axum::routing::get(refs_dropdown::render_refs_dropdown), ) .route("/tree", axum::routing::get(tree::render_tree)) .route("/tree-row", axum::routing::get(tree_row::render_tree_row)) .route( "/breadcrumbs", axum::routing::get(breadcrumbs::render_breadcrumbs), ) .route( "/blob-header", axum::routing::get(blob_header::render_blob_header), ) .route( "/blob-content", axum::routing::get(blob_content::render_blob_content), ) .route( "/blob-pages", axum::routing::get(blob_pages::render_blob_pages), ) .route("/readme", axum::routing::get(readme::render_readme)) .route("/commits", axum::routing::get(commits::render_commits)) .route( "/commit-diff", axum::routing::get(commit_diff::render_commit_diff), ) .route("/blame", axum::routing::get(blame::render_blame)) .route( "/languages", axum::routing::get(languages::render_languages), ) .route( "/star-button", axum::routing::get(star_button::render_star_button), ) .route( "/watch-button", axum::routing::get(watch_button::render_watch_button), ) } #[derive(Debug, Clone, serde::Deserialize, utoipa::IntoParams)] pub struct HtmlRepoPath { pub space_name: String, pub repo_name: String, } /// The error arm is a ready-made response, not a value: every one of the twenty /// page fragments either renders or immediately returns it unchanged. Boxing it /// would only add a dereference at each call site, so the lint is allowed here. #[allow(clippy::result_large_err)] pub async fn resolve( service: GitService, path: HtmlRepoPath, auth: BasicAuth, ) -> Result { if path.space_name.chars().count() > MAX_NAME_LEN || path.repo_name.chars().count() > MAX_NAME_LEN { return Err(html_err(404, "not found")); } let ctx = match service .context(path.space_name.clone(), path.repo_name.clone()) .await { Ok(c) => Arc::new(c), Err(GitError::NotFound) => return Err(html_err(404, "repo not found")), Err(e) => { warn!(error = %e, "html resolve internal"); return Err(html_err(500, "internal")); } }; let uid = match allow_repo_read(&service, &auth, &ctx).await { Ok(uid) => uid, Err(GitError::Unauthorized) => return Err(html_err(401, "auth required")), Err(GitError::Forbidden) => return Err(html_err(403, "forbidden")), Err(e) => { warn!(error = %e, "html auth internal"); return Err(html_err(500, "internal")); } }; let dto = match service.get_repo_for_html(ctx.clone()).await { Ok(d) => d, Err(GitError::NotFound) => return Err(html_err(404, "repo not found")), Err(e) => { warn!(error = %e, "html dto internal"); return Err(html_err(500, "internal")); } }; Ok(HtmlCtx { service, ctx, dto, uid, space_name: path.space_name, repo_name: path.repo_name, }) } pub struct HtmlView { pub ctx: HtmlCtx, pub revision: String, pub clean_path: String, } /// See [`resolve`]: the error arm is the response itself. #[allow(clippy::result_large_err)] pub async fn resolve_view( service: GitService, path: HtmlRepoPath, auth: BasicAuth, revision: Option, raw_path: Option, require_path: bool, ) -> Result { let ctx = resolve(service, path, auth).await?; let revision = revision .as_deref() .unwrap_or(ctx.dto.default_branch.as_str()) .to_string(); if crate::share::validate::validate_revision(&revision).is_err() { return Err(html_err(404, "ref invalid")); } let clean_path = validate_path(raw_path.as_deref().unwrap_or_default()) .ok_or_else(|| html_err(404, "path invalid"))?; if require_path && clean_path.is_empty() { return Err(html_err(404, "path required")); } Ok(HtmlView { ctx, revision, clean_path, }) } pub fn clamp_limit(q: Option) -> usize { match q { None => DEFAULT_LIMIT, Some(0) => DEFAULT_LIMIT, Some(n) if n > MAX_LIMIT => MAX_LIMIT, Some(n) => n, } } pub fn validate_path(p: &str) -> Option { if p.len() > MAX_PATH_LEN { return None; } if p.contains('\0') || p.contains('\\') { return None; } if p.split('/').any(|seg| seg == "..") { return None; } if p.starts_with('/') { return None; } Some(p.trim().to_string()) } pub fn validate_cursor(c: &str) -> Option { if c.len() != 40 || !c.chars().all(|x| x.is_ascii_hexdigit()) { return None; } Some(c.to_ascii_lowercase()) } pub fn validate_revision(rev: &str) -> Option { let r = rev.trim(); if r.is_empty() || r.len() > MAX_REVISION_LEN { return None; } if r.starts_with('-') || r.contains('\0') || r.contains('\n') || r.contains('\r') { return None; } Some(r.to_string()) } pub fn short_sha(s: &str) -> String { s.chars().take(7).collect() } pub fn first_line(s: &str) -> String { let line = s.lines().next().unwrap_or(""); if line.chars().count() <= MAX_SUBJECT_LEN { return line.to_string(); } let mut out: String = line.chars().take(MAX_SUBJECT_LEN - 1).collect(); out.push('…'); out } pub fn relative_time(t: OffsetDateTime) -> String { let now = OffsetDateTime::now_utc(); let delta = (now - t).whole_seconds(); if delta < 0 { return "just now".to_string(); } if delta < 60 { return format!("{delta}s ago"); } if delta < 3600 { return format!("{}m ago", delta / 60); } if delta < 86_400 { return format!("{}h ago", delta / 3600); } if delta < 86_400 * 30 { return format!("{}d ago", delta / 86_400); } if delta < 86_400 * 365 { return format!("{}mo ago", delta / (86_400 * 30)); } format!("{}y ago", delta / (86_400 * 365)) } pub fn iso_time(t: OffsetDateTime) -> String { t.format(&Rfc3339).unwrap_or_default() } pub fn unix_to_time(unix: i64) -> OffsetDateTime { OffsetDateTime::from_unix_timestamp(unix).unwrap_or(OffsetDateTime::UNIX_EPOCH) } pub fn relative_time_unix(unix: i64) -> String { relative_time(unix_to_time(unix)) } pub fn iso_time_unix(unix: i64) -> String { iso_time(unix_to_time(unix)) } pub fn format_size(bytes: u64) -> String { if bytes < 1024 { return format!("{bytes} B"); } let kb = bytes / 1024; if kb < 1024 { return format!("{kb} KB"); } let mb = kb / 1024; if mb < 1024 { return format!("{mb} MB"); } let gb = mb / 1024; format!("{gb} GB") } pub fn response_html(markup: Markup, max_age: u32) -> Response { let body = markup.into_string(); let mut resp = (axum::http::StatusCode::OK, body).into_response(); resp.headers_mut().insert( axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8".parse().unwrap(), ); if max_age == 0 { resp.headers_mut().insert( axum::http::header::CACHE_CONTROL, "no-store".parse().unwrap(), ); } else { resp.headers_mut().insert( axum::http::header::CACHE_CONTROL, format!("private, max-age={max_age}").parse().unwrap(), ); } resp.headers_mut() .insert(axum::http::header::VARY, "Cookie".parse().unwrap()); resp } pub fn archived_class(dto: &RepoDto, base: &str) -> String { if dto.is_archived { format!("{base} opacity-60") } else { base.to_string() } } pub fn clone_https_url(service: &GitService, space: &str, repo: &str) -> (String, bool) { match service.http_clone_url(space, repo) { Some(url) => (url, false), None => (String::new(), true), } } pub fn clone_ssh_url(service: &GitService, space: &str, repo: &str) -> (String, bool) { match service.ssh_clone_url(space, repo) { Some(url) => (url, false), None => (String::new(), true), } } pub fn empty_state(dto: &RepoDto) -> bool { dto.commits_count == 0 }