d614ae0
use std::sync::Arc;
use api::http::{Response, Router};
use maud::Markup;
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use tracing::warn;
use crate::context::facade::GitContext;
use crate::errors::ApiError;
use crate::handler::ensure_read_access;
use crate::service::GitService;
use crate::service::repos::RepoDto;
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;
fn error_status(error: &ApiError) -> u16 {
match error {
ApiError::BadRequest(_) => 400,
ApiError::Unauthorized(_) => 401,
ApiError::Forbidden(_) => 403,
ApiError::NotFound(_) => 404,
ApiError::Conflict(_) => 409,
ApiError::Internal(_) => 500,
}
}
#[derive(Clone)]
pub struct HtmlCtx {
pub service: GitService,
pub ctx: Arc<GitContext>,
pub dto: RepoDto,
pub uid: Option<i64>,
pub space_name: String,
pub repo_name: String,
}
pub fn router() -> Router<GitService> {
Router::new()
.route(
"/clone-buttons",
api::http::routing::get(clone_buttons::render_clone_buttons),
)
.route("/badge", api::http::routing::get(badge::render_badge))
.route(
"/quickstart",
api::http::routing::get(quickstart::render_quickstart),
)
.route("/header", api::http::routing::get(header::render_header))
.route(
"/last-commit",
api::http::routing::get(last_commit::render_last_commit),
)
.route(
"/refs-summary",
api::http::routing::get(refs_summary::render_refs_summary),
)
.route(
"/refs-dropdown",
api::http::routing::get(refs_dropdown::render_refs_dropdown),
)
.route("/tree", api::http::routing::get(tree::render_tree))
.route(
"/tree-row",
api::http::routing::get(tree_row::render_tree_row),
)
.route(
"/breadcrumbs",
api::http::routing::get(breadcrumbs::render_breadcrumbs),
)
.route(
"/blob-header",
api::http::routing::get(blob_header::render_blob_header),
)
.route(
"/blob-content",
api::http::routing::get(blob_content::render_blob_content),
)
.route(
"/blob-pages",
api::http::routing::get(blob_pages::render_blob_pages),
)
.route("/readme", api::http::routing::get(readme::render_readme))
.route("/commits", api::http::routing::get(commits::render_commits))
.route(
"/commit-diff",
api::http::routing::get(commit_diff::render_commit_diff),
)
.route("/blame", api::http::routing::get(blame::render_blame))
.route(
"/languages",
api::http::routing::get(languages::render_languages),
)
.route(
"/star-button",
api::http::routing::get(star_button::render_star_button),
)
.route(
"/watch-button",
api::http::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,
viewer: Option<i64>,
) -> Result<HtmlCtx, Response> {
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 ensure_read_access(&service, viewer, &ctx).await {
Ok(uid) => uid,
Err(error) => return Err(html_err(error_status(&error), "")),
};
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,
viewer: Option<i64>,
revision: Option<String>,
raw_path: Option<String>,
require_path: bool,
) -> Result<HtmlView, Response> {
let ctx = resolve(service, path, viewer).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>) -> 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<String> {
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_revision(rev: &str) -> Option<String> {
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 = actix_web::HttpResponse::Ok().body(body);
resp.headers_mut().insert(
actix_web::http::header::CONTENT_TYPE,
"text/html; charset=utf-8".parse().unwrap(),
);
if max_age == 0 {
resp.headers_mut().insert(
actix_web::http::header::CACHE_CONTROL,
"no-store".parse().unwrap(),
);
} else {
resp.headers_mut().insert(
actix_web::http::header::CACHE_CONTROL,
format!("private, max-age={max_age}").parse().unwrap(),
);
}
resp.headers_mut()
.insert(actix_web::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),
}
}