//! Creating a repository, and the page one is shown on.
//!
//! Creating is a two-step thing that has to stay in step: a bare repository on
//! disk, under the owner's directory, and a row naming it. The disk comes
//! first - a row without a directory behind it is a repository nothing can
//! reach - and a failure to write the row removes the directory again rather
//! than leaving the two disagreeing.
pub mod patches;
pub mod settings;
pub mod tickets;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use actix_web::{HttpRequest, HttpResponse, get, http::header, post, web};
use sea_orm::DatabaseConnection;
use serde::Deserialize;
use tokio::sync::RwLock;
use tracing::error;
use crate::AppState;
use crate::auth::{CurrentUser, viewer_name};
use crate::entities::repositories::Model as Repository;
use crate::entities::users;
use crate::highlight;
use crate::markdown;
use crate::repositories::store;
use crate::repositories::{self, RepositoryPath};
use crate::state_read;
use crate::views::{self, RepositoryForm, RepositorySummary};
/// What the new repository form posts.
#[derive(Debug, Deserialize)]
pub struct NewRepository {
slug: String,
#[serde(default)]
description: String,
#[serde(default)]
visibility: String,
}
/// The user `name`, when `current` is allowed to act as them.
///
/// Returns the response to send instead when the user is missing or the
/// request belongs to somebody else. An anonymous request never gets here:
/// [`CurrentUser`] turns it into a `401` first.
///
/// The response is boxed because it is only ever built on the way out: an
/// `HttpResponse` is large, and a `Result` that carries one unboxed makes every
/// successful call move it around.
pub(crate) async fn acting_user(
data: &web::Data<Arc<RwLock<AppState>>>,
current: &CurrentUser,
name: &str,
) -> Result<users::Model, Box<HttpResponse>> {
let user = {
let state = state_read(data).await;
match store::find_user(&state.db, name).await {
Ok(user) => user,
Err(error) => {
error!(%error, user = %name, "looking up a user failed");
return Err(Box::new(HttpResponse::InternalServerError().finish()));
}
}
};
let Some(user) = user else {
return Err(Box::new(
HttpResponse::NotFound().body(format!("User `{name}` could not be found")),
));
};
if user.id != current.id {
return Err(Box::new(HttpResponse::Forbidden().finish()));
}
Ok(user)
}
/// The repository at `owner_name/slug` as `viewer` may read it, with the
/// directory its bare repositories live under.
///
/// A private repository is answered exactly as one that does not exist: the
/// two are the same word, because someone who may not read it is never told
/// that it is there.
///
/// The error case carries the response to send - boxed for the reason
/// [`acting_user`] gives.
pub(crate) async fn readable_repository(
data: &web::Data<Arc<RwLock<AppState>>>,
viewer: Option<&CurrentUser>,
owner_name: &str,
slug: &str,
) -> Result<(users::Model, Repository, PathBuf), Box<HttpResponse>> {
let (found, repo_root) = {
let state = state_read(data).await;
let found = match store::find_repository_by_path(&state.db, owner_name, slug).await {
Ok(found) => found,
Err(error) => {
error!(%error, owner = %owner_name, slug = %slug, "looking up a repository failed");
return Err(Box::new(HttpResponse::InternalServerError().finish()));
}
};
(found, state.repo_root.clone())
};
let missing = || {
Box::new(HttpResponse::NotFound().body(format!(
"Repository `{owner_name}/{slug}` could not be found"
))) as Box<HttpResponse>
};
let Some((owner, repository)) = found else {
return Err(missing());
};
if !repositories::may_read(
repository.visibility,
repository.owner_id,
viewer.map(|viewer| viewer.id),
) {
return Err(missing());
}
Ok((owner, repository, repo_root))
}
#[get("/~{user_or_team}/repositories/new")]
#[tracing::instrument(skip_all, fields(user_or_team = %user_or_team.as_str()))]
async fn get_new_repository(
data: web::Data<Arc<RwLock<AppState>>>,
current: CurrentUser,
user_or_team: web::Path<String>,
) -> HttpResponse {
let user = match acting_user(&data, ¤t, user_or_team.as_str()).await {
Ok(user) => user,
Err(response) => return *response,
};
views::new_repository(Some(¤t.name), &user.name, &RepositoryForm::default())
}
#[post("/~{user_or_team}/repositories")]
#[tracing::instrument(skip_all, fields(user_or_team = %user_or_team.as_str()))]
async fn post_new_repository(
data: web::Data<Arc<RwLock<AppState>>>,
current: CurrentUser,
user_or_team: web::Path<String>,
form: web::Form<NewRepository>,
) -> HttpResponse {
let user = match acting_user(&data, ¤t, user_or_team.as_str()).await {
Ok(user) => user,
Err(response) => return *response,
};
let form = form.into_inner();
let slug = form.slug.trim();
let visibility = repositories::visibility_from_form(&form.visibility);
// Render the form back, with the values the visitor typed, rather than a
// bare error: retyping a rejected name is the least useful thing to ask.
// The label is kept rather than the value itself so the closure borrows
// nothing that goes on to be stored.
let visibility_label = visibility.label();
let refuse = |error: String| {
views::new_repository(
Some(¤t.name),
&user.name,
&RepositoryForm {
slug: Some(form.slug.clone()),
description: Some(form.description.clone()),
visibility: visibility_label,
error: Some(error),
},
)
};
if !repositories::is_slug(slug) {
return refuse(format!(
"`{slug}` cannot name a repository: use letters, digits, `-`, `_` and `.`, \
starting and ending with a letter or digit, up to {} characters.",
repositories::MAX_SLUG_LEN
));
}
// A slug that is otherwise valid may still be a page of the webui's own.
if repositories::is_reserved(slug) {
return refuse(format!(
"`{slug}` is reserved by the webui. Pick another name."
));
}
let slug = slug.to_string();
let (db, repo_root) = {
let state = state_read(&data).await;
(state.db.clone(), state.repo_root.clone())
};
// Checked with the deleted rows included, because the uniqueness the
// database enforces does not care about `deleted_at`: a repository that
// was removed still holds its name, and saying so here beats letting the
// insert fail on the constraint.
match store::find_any_repository(&db, user.id, &slug).await {
Ok(None) => {}
Ok(Some(_)) => return refuse(format!("You already have a repository called `{slug}`.")),
Err(error) => {
error!(%error, user = %user.name, slug = %slug, "checking for a repository failed");
return HttpResponse::InternalServerError().finish();
}
}
let path = RepositoryPath {
owner: user.name.clone(),
slug: slug.clone(),
};
let bare = path.bare_path(&repo_root);
// Disk first: a row that no directory backs is a repository that cannot be
// reached, and cleaning that up afterwards is the harder direction.
if let Err(error) = repositories::init_bare(&bare).await {
error!(%error, path = %bare.display(), "creating the bare repository failed");
return HttpResponse::InternalServerError().finish();
}
let description = form.description.trim();
let description = (!description.is_empty()).then_some(description);
match store::create_repository(&db, user.id, &slug, description, visibility).await {
Ok(repository) => {
// Seed the default labels for the new repository
if let Err(error) =
crate::tickets::store::ensure_default_labels(&db, repository.id).await
{
error!(%error, user = %user.name, slug = %slug, "seeding default labels failed");
// Clean up the repository if seeding fails
if let Err(error) = std::fs::remove_dir_all(&bare) {
error!(%error, path = %bare.display(), "cleaning up the bare repository failed");
}
return HttpResponse::InternalServerError().finish();
}
HttpResponse::SeeOther()
.append_header(("Location", format!("/~{}/{slug}", user.name)))
.finish()
}
Err(error) => {
error!(%error, user = %user.name, slug = %slug, "recording the repository failed");
// Undo the half that did land, so the next attempt is not refused
// for a directory nobody can see.
if let Err(error) = std::fs::remove_dir_all(&bare) {
error!(%error, path = %bare.display(), "cleaning up the bare repository failed");
}
HttpResponse::InternalServerError().finish()
}
}
}
#[tracing::instrument(
skip_all,
fields(owner = tracing::field::Empty, repository_slug = tracing::field::Empty)
)]
pub async fn get_repository(
data: web::Data<Arc<RwLock<AppState>>>,
req: HttpRequest,
viewer: Option<CurrentUser>,
path: web::Path<(String, String)>,
) -> HttpResponse {
let (owner_name, repository_slug) = path.into_inner();
// The path arrives as a pair, so the span's fields are recorded by hand
// rather than named in the attribute.
let span = tracing::Span::current();
span.record("owner", owner_name.as_str());
span.record("repository_slug", repository_slug.as_str());
// What the visitor's browser would prefer to read, most wanted first. Used
// to pick a localised readme.
let languages = accept_languages(
req.headers()
.get(header::ACCEPT_LANGUAGE)
.and_then(|value| value.to_str().ok()),
);
let (owner, repository, repo_root) =
match readable_repository(&data, viewer.as_ref(), &owner_name, &repository_slug).await {
Ok(found) => found,
Err(response) => return *response,
};
// The SSH settings have their own read of the state, after the lookup
// above released it.
let ssh = { state_read(&data).await.ssh.clone() };
// Read outside the lock: spawning `git` is slow enough that no other
// request should wait behind it.
let bare = RepositoryPath {
owner: owner.name.clone(),
slug: repository.slug.clone(),
}
.bare_path(&repo_root);
let branches =
match repositories::git::branches(&bare, Some(repositories::git::REF_LIMIT)).await {
Ok(branches) => branches,
Err(error) => {
error!(%error, path = %bare.display(), "listing a repository's branches failed");
Vec::new()
}
};
let tags = match repositories::git::tags(&bare, Some(repositories::git::REF_LIMIT)).await {
Ok(tags) => tags,
Err(error) => {
error!(%error, path = %bare.display(), "listing a repository's tags failed");
Vec::new()
}
};
let readme =
match repositories::git::readme(&bare, &repository.default_branch, &languages).await {
Ok(readme) => readme,
Err(error) => {
error!(%error, path = %bare.display(), "reading a repository's readme failed");
None
}
};
// A signed-in visitor is offered the way they can write; anyone else only
// the way they can read.
let clone_url = if viewer.is_some() {
ssh.clone_url(&owner.name, &repository.slug)
} else {
http_clone_url(&req, &owner.name, &repository.slug)
};
let detail = views::RepositoryDetail {
slug: repository.slug.clone(),
description: repository.description.clone(),
visibility: repository.visibility.label(),
default_branch: repository.default_branch.clone(),
clone_url,
readme: readme.map(|readme| {
let html = markdown::render_with_repo_context(
&readme.content,
&owner.name,
&repository.slug,
&repository.default_branch,
&readme.path,
);
views::ReadmeView {
path: readme.path,
html,
}
}),
branches: branches.into_iter().map(ref_summary).collect(),
tags: tags.into_iter().map(ref_summary).collect(),
};
views::repository(viewer_name(viewer.as_ref()), &owner.name, &detail)
}
/// The repository's refs: every branch and tag it has, newest first.
///
/// Same address shape as [`get_repository`] with one more segment, so it
/// needs no reserved slug - `/~{user}/refs` is still a repository - and the
/// two patterns cannot reach each other's pages.
#[get("/~{user_or_team}/{repository_slug}/refs")]
#[tracing::instrument(
skip_all,
fields(owner = tracing::field::Empty, repository_slug = tracing::field::Empty)
)]
async fn get_refs(
data: web::Data<Arc<RwLock<AppState>>>,
viewer: Option<CurrentUser>,
path: web::Path<(String, String)>,
) -> HttpResponse {
let (owner_name, repository_slug) = path.into_inner();
let span = tracing::Span::current();
span.record("owner", owner_name.as_str());
span.record("repository_slug", repository_slug.as_str());
let (owner, repository, repo_root) =
match readable_repository(&data, viewer.as_ref(), &owner_name, &repository_slug).await {
Ok(found) => found,
Err(response) => return *response,
};
// Read outside the lock: spawning `git` is slow enough that no other
// request should wait behind it.
let bare = RepositoryPath {
owner: owner.name.clone(),
slug: repository.slug.clone(),
}
.bare_path(&repo_root);
// No limit - this page is the complete listing whose first three of each
// the landing page previews. A failure empties the section rather than
// failing the page, the same way the landing page treats it.
let branches = match repositories::git::branches(&bare, None).await {
Ok(branches) => branches,
Err(error) => {
error!(%error, path = %bare.display(), "listing a repository's branches failed");
Vec::new()
}
};
let tags = match repositories::git::tags(&bare, None).await {
Ok(tags) => tags,
Err(error) => {
error!(%error, path = %bare.display(), "listing a repository's tags failed");
Vec::new()
}
};
let branches: Vec<views::RefSummary> = branches.into_iter().map(ref_summary).collect();
let tags: Vec<views::RefSummary> = tags.into_iter().map(ref_summary).collect();
views::refs(
viewer_name(viewer.as_ref()),
&owner.name,
&repository.slug,
repository.visibility.label(),
&repository.default_branch,
&branches,
&tags,
)
}
/// The diff of one commit.
///
/// `/~{owner}/{repository_slug}/commit/{rev}` - the revision is a single
/// commit whose changes to show. Unlike tree or raw addresses, there is no
/// path within the revision - just the revision itself.
#[get("/~{user_or_team}/{repository_slug}/commit/{rev:.*}")]
#[tracing::instrument(
skip_all,
fields(
owner = tracing::field::Empty,
repository_slug = tracing::field::Empty,
rev = tracing::field::Empty,
reference = tracing::field::Empty,
)
)]
async fn get_commit(
data: web::Data<Arc<RwLock<AppState>>>,
viewer: Option<CurrentUser>,
path: web::Path<(String, String, String)>,
) -> HttpResponse {
let (owner_name, repository_slug, rev) = path.into_inner();
let span = tracing::Span::current();
span.record("owner", owner_name.as_str());
span.record("repository_slug", repository_slug.as_str());
span.record("rev", rev.as_str());
let (owner, repository, repo_root) =
match readable_repository(&data, viewer.as_ref(), &owner_name, &repository_slug).await {
Ok(found) => found,
Err(response) => return *response,
};
let bare = RepositoryPath {
owner: owner.name.clone(),
slug: repository.slug.clone(),
}
.bare_path(&repo_root);
commit_address(&data, viewer, owner, repository, bare, rev).await
}
/// A single commit's diff, read and rendered.
///
/// The `rev` must resolve to a commit via [`repositories::git::resolves_to_commit`];
/// otherwise the page is `404`. A `rev` starting with `-` is refused to avoid
/// argument injection, the same guard [`commits_address`] uses.
async fn commit_address(
_data: &web::Data<Arc<RwLock<AppState>>>,
viewer: Option<CurrentUser>,
owner: users::Model,
repository: Repository,
bare: std::path::PathBuf,
rev: String,
) -> HttpResponse {
let span = tracing::Span::current();
// Refuse revisions starting with `-` to avoid argument injection.
if rev.starts_with('-') {
return HttpResponse::NotFound().body(format!(
"`{rev}` names no revision of `{}/{}`",
owner.name, repository.slug
));
}
// Check whether the revision resolves to a commit.
let reaches = match repositories::git::resolves_to_commit(&bare, &rev).await {
Ok(reaches) => reaches,
Err(error) => {
error!(%error, path = %bare.display(), "resolving a revision failed");
return HttpResponse::InternalServerError().finish();
}
};
if !reaches {
return HttpResponse::NotFound().body(format!(
"`{rev}` names no revision of `{}/{}`",
owner.name, repository.slug
));
}
span.record("reference", rev.as_str());
// Fetch the commit metadata.
let commit = match repositories::git::commit(&bare, &rev).await {
Ok(Some(commit)) => commit,
Ok(None) => {
return HttpResponse::NotFound().body(format!(
"`{rev}` names no revision of `{}/{}`",
owner.name, repository.slug
));
}
Err(error) => {
error!(%error, path = %bare.display(), "reading commit metadata failed");
return HttpResponse::InternalServerError().finish();
}
};
// Fetch the diff.
let files = match repositories::git::commit_diff(&bare, &rev).await {
Ok(Some(files)) => files,
Ok(None) => {
// Should not happen if resolves_to_commit returned true, but handle it.
return HttpResponse::NotFound().body(format!(
"`{rev}` names no revision of `{}/{}`",
owner.name, repository.slug
));
}
Err(error) => {
error!(%error, path = %bare.display(), "reading commit diff failed");
return HttpResponse::InternalServerError().finish();
}
};
views::commit(
viewer_name(viewer.as_ref()),
&owner.name,
&repository.slug,
repository.visibility.label(),
&repository.default_branch,
&commit,
&files,
)
}
/// The commit history of one revision: every commit reachable from it, newest
/// first.
///
/// `/~{owner}/{repository_slug}/commits/{tail}` - the tail is the revision
/// whose history to show. Unlike tree or raw addresses, there is no path
/// within the revision - just the revision itself.
#[get("/~{user_or_team}/{repository_slug}/commits/{tail:.*}")]
#[tracing::instrument(
skip_all,
fields(
owner = tracing::field::Empty,
repository_slug = tracing::field::Empty,
tail = tracing::field::Empty,
reference = tracing::field::Empty,
)
)]
async fn get_commits(
data: web::Data<Arc<RwLock<AppState>>>,
viewer: Option<CurrentUser>,
path: web::Path<(String, String, String)>,
) -> HttpResponse {
let (owner_name, repository_slug, tail) = path.into_inner();
let span = tracing::Span::current();
span.record("owner", owner_name.as_str());
span.record("repository_slug", repository_slug.as_str());
span.record("tail", tail.as_str());
let (owner, repository, repo_root) =
match readable_repository(&data, viewer.as_ref(), &owner_name, &repository_slug).await {
Ok(found) => found,
Err(response) => return *response,
};
let bare = RepositoryPath {
owner: owner.name.clone(),
slug: repository.slug.clone(),
}
.bare_path(&repo_root);
commits_address(&data, viewer, owner, repository, bare, tail).await
}
/// Query parameters for the compare landing form.
#[derive(Debug, Deserialize)]
struct CompareQuery {
from: Option<String>,
to: Option<String>,
}
/// The compare landing page with a form to pick two revisions.
#[get("/~{user_or_team}/{repository_slug}/compare")]
async fn get_compare_root(
data: web::Data<Arc<RwLock<AppState>>>,
viewer: Option<CurrentUser>,
path: web::Path<(String, String)>,
query: web::Query<CompareQuery>,
) -> HttpResponse {
let (owner_name, repository_slug) = path.into_inner();
let (owner, repository, repo_root) =
match readable_repository(&data, viewer.as_ref(), &owner_name, &repository_slug).await {
Ok(found) => found,
Err(response) => return *response,
};
let bare = RepositoryPath {
owner: owner.name.clone(),
slug: repository.slug.clone(),
}
.bare_path(&repo_root);
compare_landing_address(&data, viewer, owner, repository, bare, query.into_inner()).await
}
/// The same compare landing page with a trailing slash.
#[get("/~{user_or_team}/{repository_slug}/compare/")]
async fn get_compare_root_slash(
data: web::Data<Arc<RwLock<AppState>>>,
viewer: Option<CurrentUser>,
path: web::Path<(String, String)>,
query: web::Query<CompareQuery>,
) -> HttpResponse {
let (owner_name, repository_slug) = path.into_inner();
let (owner, repository, repo_root) =
match readable_repository(&data, viewer.as_ref(), &owner_name, &repository_slug).await {
Ok(found) => found,
Err(response) => return *response,
};
let bare = RepositoryPath {
owner: owner.name.clone(),
slug: repository.slug.clone(),
}
.bare_path(&repo_root);
compare_landing_address(&data, viewer, owner, repository, bare, query.into_inner()).await
}
/// The compare page showing the diff between two revisions.
#[get("/~{user_or_team}/{repository_slug}/compare/{tail:.*}")]
async fn get_compare(
data: web::Data<Arc<RwLock<AppState>>>,
viewer: Option<CurrentUser>,
path: web::Path<(String, String, String)>,
) -> HttpResponse {
let (owner_name, repository_slug, tail) = path.into_inner();
let (owner, repository, repo_root) =
match readable_repository(&data, viewer.as_ref(), &owner_name, &repository_slug).await {
Ok(found) => found,
Err(response) => return *response,
};
let bare = RepositoryPath {
owner: owner.name.clone(),
slug: repository.slug.clone(),
}
.bare_path(&repo_root);
// If tail is empty, show the landing page
if tail.is_empty() {
return compare_landing_address(
&data,
viewer,
owner,
repository,
bare,
CompareQuery {
from: None,
to: None,
},
)
.await;
}
compare_address(&data, viewer, owner, repository, bare, tail).await
}
/// Handles the compare landing page logic: either redirects to the canonical
/// compare URL if both ends are valid, or shows the picker form.
#[tracing::instrument(
skip_all,
fields(
owner = tracing::field::Empty,
repository_slug = tracing::field::Empty,
)
)]
async fn compare_landing_address(
_data: &web::Data<Arc<RwLock<AppState>>>,
viewer: Option<CurrentUser>,
owner: users::Model,
repository: Repository,
bare: std::path::PathBuf,
query: CompareQuery,
) -> HttpResponse {
let span = tracing::Span::current();
span.record("owner", owner.name.as_str());
span.record("repository_slug", repository.slug.as_str());
let from = query.from.as_deref();
let to = query.to.as_deref();
// If we have any query parameters, validate them
if from.is_some() || to.is_some() {
if let (Some(from), Some(to)) = (from, to) {
if usable_compare_end(from) && usable_compare_end(to) {
// Both present and valid - redirect
return HttpResponse::SeeOther()
.append_header((
header::LOCATION,
format!(
"/~{owner}/{slug}/compare/{from}...{to}",
owner = owner.name,
slug = repository.slug,
from = from,
to = to,
),
))
.finish();
} else {
// Both present but invalid
return compare_landing(
_data,
&repository,
&bare,
viewer_name(viewer.as_ref()),
&owner.name,
&repository.slug,
repository.visibility.label(),
&repository.default_branch,
Some("Invalid comparison endpoints."),
)
.await;
}
} else {
// One present, one missing
return compare_landing(
_data,
&repository,
&bare,
viewer_name(viewer.as_ref()),
&owner.name,
&repository.slug,
repository.visibility.label(),
&repository.default_branch,
Some("A comparison needs both endpoints."),
)
.await;
}
}
// No query parameters - show the picker
compare_landing(
_data,
&repository,
&bare,
viewer_name(viewer.as_ref()),
&owner.name,
&repository.slug,
repository.visibility.label(),
&repository.default_branch,
None,
)
.await
}
/// Renders the compare landing page with branches and tags for selection.
async fn compare_landing(
data: &web::Data<Arc<RwLock<AppState>>>,
repository: &Repository,
bare: &Path,
viewer: Option<&str>,
owner: &str,
slug: &str,
visibility: &'static str,
default_branch: &str,
error: Option<&str>,
) -> HttpResponse {
// Fetch branches and tags
let branches = match repositories::git::branches(bare, None).await {
Ok(branches) => branches,
Err(e) => {
tracing::error!(%e, "failed to fetch branches");
Vec::new()
}
};
let tags = match repositories::git::tags(bare, None).await {
Ok(tags) => tags,
Err(e) => {
tracing::error!(%e, "failed to fetch tags");
Vec::new()
}
};
let branches: Vec<views::RefSummary> = branches.into_iter().map(ref_summary).collect();
let tags: Vec<views::RefSummary> = tags.into_iter().map(ref_summary).collect();
// Fetch open patches from the database
let db = {
let state = state_read(data).await;
state.db.clone()
};
let repository_id = repository.id;
let patches = match crate::patches::store::list(
&db,
repository_id,
Some(crate::entities::patch_proposals::Status::Open),
)
.await
{
Ok(patches) => patches
.into_iter()
.map(|p| views::PatchSummary {
number: p.number,
summary: p.summary,
})
.collect(),
Err(e) => {
tracing::error!(%e, "failed to fetch patches");
Vec::new()
}
};
views::compare_form(
viewer,
owner,
slug,
visibility,
default_branch,
&branches,
&tags,
&patches,
error,
)
}
/// Handles the actual compare page logic: validates the range, fetches commits
/// and diff, and renders the compare view.
#[tracing::instrument(
skip_all,
fields(
owner = tracing::field::Empty,
repository_slug = tracing::field::Empty,
tail = tracing::field::Empty,
base = tracing::field::Empty,
head = tracing::field::Empty,
)
)]
async fn compare_address(
_data: &web::Data<Arc<RwLock<AppState>>>,
viewer: Option<CurrentUser>,
owner: users::Model,
repository: Repository,
bare: std::path::PathBuf,
tail: String,
) -> HttpResponse {
let span = tracing::Span::current();
span.record("owner", owner.name.as_str());
span.record("repository_slug", repository.slug.as_str());
span.record("tail", tail.as_str());
// Split the range
let (base, head) = match split_compare_range(&tail) {
Some((base, head)) => (base, head),
None => {
return HttpResponse::NotFound().body(format!(
"`{tail}` is not a comparison of `{}/{}`: the address takes the shape `{{base}}...{{head}}`",
owner.name, repository.slug
));
}
};
span.record("base", base);
span.record("head", head);
// Validate both ends
if base.starts_with('-') {
return HttpResponse::NotFound().body(format!(
"`{}` names no revision of `{}/{}`",
base, owner.name, repository.slug
));
}
if head.starts_with('-') {
return HttpResponse::NotFound().body(format!(
"`{}` names no revision of `{}/{}`",
head, owner.name, repository.slug
));
}
// Check both ends resolve
let base_resolves = match repositories::git::resolves_to_commit(&bare, base).await {
Ok(resolves) => resolves,
Err(e) => {
tracing::error!(%e, "failed to resolve base");
return HttpResponse::InternalServerError().finish();
}
};
if !base_resolves {
return HttpResponse::NotFound().body(format!(
"`{}` names no revision of `{}/{}`",
base, owner.name, repository.slug
));
}
let head_resolves = match repositories::git::resolves_to_commit(&bare, head).await {
Ok(resolves) => resolves,
Err(e) => {
tracing::error!(%e, "failed to resolve head");
return HttpResponse::InternalServerError().finish();
}
};
if !head_resolves {
return HttpResponse::NotFound().body(format!(
"`{}` names no revision of `{}/{}`",
head, owner.name, repository.slug
));
}
// Fetch commits in range
let commits = match repositories::git::history(&bare, &format!("{base}..{head}")).await {
Ok(Some(commits)) => commits,
Ok(None) => Vec::new(),
Err(e) => {
tracing::error!(%e, "failed to fetch commits");
return HttpResponse::InternalServerError().finish();
}
};
// Fetch diff
let files = match repositories::git::compare_diff(&bare, &format!("{base}...{head}")).await {
Ok(Some(files)) => files,
Ok(None) => {
return HttpResponse::NotFound().body(format!(
"`{}` and `{}` have no common ancestor in `{}/{}`",
base, head, owner.name, repository.slug
));
}
Err(e) => {
tracing::error!(%e, "failed to fetch diff");
return HttpResponse::InternalServerError().finish();
}
};
// Convert commits to summaries
let summaries: Vec<views::CommitSummary> = commits.into_iter().map(commit_summary).collect();
views::compare(
viewer_name(viewer.as_ref()),
&owner.name,
&repository.slug,
repository.visibility.label(),
&repository.default_branch,
base,
head,
&summaries,
&files,
)
}
/// Converts a git::Commit to a views::CommitSummary.
fn commit_summary(commit: repositories::git::Commit) -> views::CommitSummary {
views::CommitSummary {
hash: commit.hash,
full_hash: commit.full_hash,
author: commit.author,
date: commit.date,
subject: commit.subject,
files_changed: commit.files_changed,
insertions: commit.insertions,
deletions: commit.deletions,
}
}
/// Query parameters for the commits root route.
#[derive(Debug, Deserialize)]
struct CommitsQuery {
#[serde(rename = "ref")]
ref_name: Option<String>,
}
/// The commits root: `/commits` redirects to the default branch's history.
#[get("/~{user_or_team}/{repository_slug}/commits")]
async fn get_commits_root(
data: web::Data<Arc<RwLock<AppState>>>,
viewer: Option<CurrentUser>,
path: web::Path<(String, String)>,
query: web::Query<CommitsQuery>,
) -> HttpResponse {
let (owner_name, repository_slug) = path.into_inner();
let (_, repository, _) =
match readable_repository(&data, viewer.as_ref(), &owner_name, &repository_slug).await {
Ok(found) => found,
Err(response) => return *response,
};
let reference = query
.ref_name
.as_deref()
.unwrap_or(&repository.default_branch);
// Validate the reference before redirecting
if reference.starts_with('-') {
return HttpResponse::NotFound().body(format!(
"`{reference}` names no revision of `{}/{}`",
owner_name, repository_slug
));
}
HttpResponse::SeeOther()
.append_header((
header::LOCATION,
format!(
"/~{owner}/{slug}/commits/{reference}",
owner = owner_name,
slug = repository_slug,
reference = reference
),
))
.finish()
}
/// The same bare address with its trailing slash.
#[get("/~{user_or_team}/{repository_slug}/commits/")]
async fn get_commits_root_slash(
data: web::Data<Arc<RwLock<AppState>>>,
viewer: Option<CurrentUser>,
path: web::Path<(String, String)>,
query: web::Query<CommitsQuery>,
) -> HttpResponse {
let (owner_name, repository_slug) = path.into_inner();
let (_, repository, _) =
match readable_repository(&data, viewer.as_ref(), &owner_name, &repository_slug).await {
Ok(found) => found,
Err(response) => return *response,
};
let reference = query
.ref_name
.as_deref()
.unwrap_or(&repository.default_branch);
// Validate the reference before redirecting
if reference.starts_with('-') {
return HttpResponse::NotFound().body(format!(
"`{reference}` names no revision of `{}/{}`",
owner_name, repository_slug
));
}
HttpResponse::SeeOther()
.append_header((
header::LOCATION,
format!(
"/~{owner}/{slug}/commits/{reference}",
owner = owner_name,
slug = repository_slug,
reference = reference
),
))
.finish()
}
/// The commit history of one revision, read and rendered.
///
/// The tail must resolve to a commit via [`resolves_to_commit`]; otherwise
/// the page is `404`. A tail starting with `-` is refused to avoid argument
/// injection, the same guard [`resolve_address`] uses for tree addresses.
/// When the tail equals the repository's default branch but the branch is
/// unborn (no commits yet), the page renders empty rather than `404` - an
/// empty repository has no history to show, not a missing one.
async fn commits_address(
data: &web::Data<Arc<RwLock<AppState>>>,
viewer: Option<CurrentUser>,
owner: users::Model,
repository: Repository,
bare: std::path::PathBuf,
tail: String,
) -> HttpResponse {
let span = tracing::Span::current();
// Refuse tails starting with `-` to avoid argument injection.
if tail.starts_with('-') {
return HttpResponse::NotFound().body(format!(
"`{tail}` names no revision of `{}/{}`",
owner.name, repository.slug
));
}
// Check whether the tail resolves to a commit.
let reaches = match repositories::git::resolves_to_commit(&bare, &tail).await {
Ok(reaches) => reaches,
Err(error) => {
error!(%error, path = %bare.display(), "resolving a revision failed");
return HttpResponse::InternalServerError().finish();
}
};
if !reaches {
// If the tail equals the default branch, check whether it's unborn.
if tail == repository.default_branch {
// Unborn default branch: render an empty page.
return views::commits(
viewer_name(viewer.as_ref()),
&owner.name,
&repository.slug,
repository.visibility.label(),
&repository.default_branch,
&tail,
&[],
&[],
&[],
&[],
);
} else {
return HttpResponse::NotFound().body(format!(
"`{tail}` names no revision of `{}/{}`",
owner.name, repository.slug
));
}
}
span.record("reference", tail.as_str());
// Fetch the commit history.
let commits = match repositories::git::history(&bare, &tail).await {
Ok(Some(commits)) => commits,
Ok(None) => {
// Should not happen if resolves_to_commit returned true, but handle it.
return HttpResponse::NotFound().body(format!(
"`{tail}` names no revision of `{}/{}`",
owner.name, repository.slug
));
}
Err(error) => {
error!(%error, path = %bare.display(), "reading commit history failed");
return HttpResponse::InternalServerError().finish();
}
};
let commit_summaries: Vec<views::CommitSummary> =
commits.into_iter().map(commit_summary).collect();
// Fetch branches and tags for the ref selector
let branches = match repositories::git::branches(&bare, None).await {
Ok(branches) => branches,
Err(e) => {
error!(%e, "failed to fetch branches");
Vec::new()
}
};
let tags = match repositories::git::tags(&bare, None).await {
Ok(tags) => tags,
Err(e) => {
error!(%e, "failed to fetch tags");
Vec::new()
}
};
let branches: Vec<views::RefSummary> = branches.into_iter().map(ref_summary).collect();
let tags: Vec<views::RefSummary> = tags.into_iter().map(ref_summary).collect();
// Fetch open patches from the database
let db = {
let state = state_read(data).await;
state.db.clone()
};
let repository_id = repository.id;
let patches = match crate::patches::store::list(
&db,
repository_id,
Some(crate::entities::patch_proposals::Status::Open),
)
.await
{
Ok(patches) => patches
.into_iter()
.map(|p| views::PatchSummary {
number: p.number,
summary: p.summary,
})
.collect(),
Err(e) => {
error!(%e, "failed to fetch patches");
Vec::new()
}
};
views::commits(
viewer_name(viewer.as_ref()),
&owner.name,
&repository.slug,
repository.visibility.label(),
&repository.default_branch,
&tail,
&commit_summaries,
&branches,
&tags,
&patches,
)
}
/// One address of one ref: a directory to walk into, or a file to read.
///
/// `/~{owner}/{repository_slug}/tree/{ref_or_commit}/{file_path}` - a
/// revision, then the path within it. The two pieces are read as one
/// address again before anything resolves them ([`tree_address`]), because
/// a ref may hold a slash of its own.
#[get("/~{user_or_team}/{repository_slug}/tree/{ref_or_commit}/{file_path:.*}")]
async fn get_tree(
data: web::Data<Arc<RwLock<AppState>>>,
viewer: Option<CurrentUser>,
path: web::Path<(String, String, String, String)>,
) -> HttpResponse {
let (owner_name, repository_slug, ref_or_commit, file_path) = path.into_inner();
tree_address(
data,
viewer,
owner_name,
repository_slug,
ref_or_commit,
file_path,
)
.await
}
/// The same address stopping at the revision: `/tree/{ref_or_commit}` is
/// the root of a ref - a tree, or a file at the top of it - rather than
/// anything beneath it.
#[get("/~{user_or_team}/{repository_slug}/tree/{ref_or_commit}")]
async fn get_tree_revision(
data: web::Data<Arc<RwLock<AppState>>>,
viewer: Option<CurrentUser>,
path: web::Path<(String, String, String)>,
) -> HttpResponse {
let (owner_name, repository_slug, ref_or_commit) = path.into_inner();
tree_address(
data,
viewer,
owner_name,
repository_slug,
ref_or_commit,
String::new(),
)
.await
}
/// What a tree address names, read the way this forge reads every address
/// of a ref.
///
/// The route splits the address after its first slash - a revision segment
/// and everything under it - but a ref may hold slashes of its own, so the
/// pieces are put back together ([`joined_address`]) and the whole address
/// is read longest-ref-first ([`resolve_tail`]): `feature/x/pkg` is `pkg`
/// on the branch `feature/x`, not a path under a branch called `feature`;
/// failing that, git may still know the whole address as a revision, which
/// is how a bare commit hash is browsed. What the path turns out to be
/// decides the page: a directory lists itself, a file shows itself.
///
/// An address no ref or revision answers for is a `404` - except the branch
/// the repository opens on, which the browse tab of every page leads to and
/// which does not resolve before anything has been pushed: that is an empty
/// repository, and an empty tree to show for it, not a missing page. The
/// address with no revision in it at all settles on that same branch with a
/// redirect instead, and a private repository is not redirected either: the
/// lookup runs first, so the answer is the one everything else gives.
#[tracing::instrument(
skip_all,
fields(
owner = tracing::field::Empty,
repository_slug = tracing::field::Empty,
ref_or_commit = tracing::field::Empty,
file_path = tracing::field::Empty,
reference = tracing::field::Empty,
within = tracing::field::Empty,
)
)]
async fn tree_address(
data: web::Data<Arc<RwLock<AppState>>>,
viewer: Option<CurrentUser>,
owner_name: String,
repository_slug: String,
ref_or_commit: String,
file_path: String,
) -> HttpResponse {
// The address arrives as pieces, so the span's fields are recorded by
// hand rather than named in the attribute.
let span = tracing::Span::current();
span.record("owner", owner_name.as_str());
span.record("repository_slug", repository_slug.as_str());
// What the address asked for, exactly as it was asked: the resolution
// recorded beside it is what the forge understood by it.
span.record("ref_or_commit", ref_or_commit.as_str());
span.record("file_path", file_path.as_str());
let (owner, repository, repo_root) =
match readable_repository(&data, viewer.as_ref(), &owner_name, &repository_slug).await {
Ok(found) => found,
Err(response) => return *response,
};
// Read outside the lock: spawning `git` is slow enough that no other
// request should wait behind it.
let bare = RepositoryPath {
owner: owner.name.clone(),
slug: repository.slug.clone(),
}
.bare_path(&repo_root);
// The two pieces the route split at are one address again here: where a
// revision ends and its path begins is for the repository's refs to say,
// not for the first slash in the URL.
let tail = joined_address(&ref_or_commit, &file_path);
// The address with no revision asks for the one the repository opens
// on - the branch the browse tab itself leads to.
if tail.is_empty() {
return HttpResponse::SeeOther()
.append_header((
header::LOCATION,
format!(
"/~{}/{}/tree/{}",
owner.name, repository.slug, repository.default_branch
),
))
.finish();
}
let (reference, within) = match resolve_address(&bare, &tail, &repository.default_branch).await
{
Address::Found { reference, within } => (reference, within),
Address::Unborn => {
return views::tree(
viewer_name(viewer.as_ref()),
&owner.name,
&views::TreeDetail {
slug: repository.slug.clone(),
visibility: repository.visibility.label(),
default_branch: repository.default_branch.clone(),
reference: tail,
path: String::new(),
entries: Vec::new(),
},
);
}
Address::Missing => {
return HttpResponse::NotFound().body(format!(
"`{tail}` names no ref or revision of `{owner_name}/{repository_slug}`"
));
}
};
span.record("reference", reference.as_str());
span.record("within", within.as_str());
// The root of a revision is always somewhere to walk into; below it,
// what the path names decides the page.
let kind = if within.is_empty() {
Some(repositories::git::ObjectKind::Tree)
} else {
match repositories::git::object_kind(&bare, &format!("{reference}:{within}")).await {
Ok(kind) => kind,
Err(error) => {
// A fault reading the repository empties the page rather
// than failing it, the way the landing page treats its
// listings: fall through to the listing, which answers it
// that way.
error!(%error, path = %bare.display(), "asking git what a path is failed");
Some(repositories::git::ObjectKind::Tree)
}
}
};
match kind {
Some(repositories::git::ObjectKind::Blob) => {
file_page(
&bare,
viewer.as_ref(),
&owner,
&repository,
&reference,
&within,
)
.await
}
Some(repositories::git::ObjectKind::Tree) => {
let entries = match repositories::git::listing(&bare, &reference, &within).await {
Ok(None) => {
// Git said so plainly: there is no such directory, or
// the ref does not reach a tree at all - a tag on a
// blob, say, whose root has no listing.
let described = if within.is_empty() {
format!("`{reference}`")
} else {
format!("`{reference}:{within}`")
};
return HttpResponse::NotFound().body(format!(
"{described} is not a tree of `{owner_name}/{repository_slug}`"
));
}
Ok(Some(entries)) => entries,
Err(error) => {
// A fault reading the repository empties the page rather
// than failing it, the way the landing page treats its
// listings.
error!(%error, path = %bare.display(), "listing a directory failed");
Vec::new()
}
};
let mut entries: Vec<views::TreeEntry> = entries
.into_iter()
.map(|entry| views::TreeEntry {
name: entry.name,
is_directory: entry.kind == repositories::git::EntryKind::Directory,
})
.collect();
// Directories first, the shape of a file tree everywhere, and then by
// name: what a reader scanning a page wants, rather than git's own
// byte order.
entries.sort_by(|left, right| {
right
.is_directory
.cmp(&left.is_directory)
.then_with(|| left.name.cmp(&right.name))
});
views::tree(
viewer_name(viewer.as_ref()),
&owner.name,
&views::TreeDetail {
slug: repository.slug.clone(),
visibility: repository.visibility.label(),
default_branch: repository.default_branch.clone(),
reference,
path: within,
entries,
},
)
}
// Nothing to walk into and nothing to read: a submodule's gitlink
// is a path out of this repository rather than into it, and a path
// git has never heard of is simply not there.
Some(repositories::git::ObjectKind::Other) | None => {
HttpResponse::NotFound().body(format!(
"`{reference}:{within}` names nothing to browse in `{owner_name}/{repository_slug}`"
))
}
}
}
/// The tree address without a ref, which settles on the branch the
/// repository opens on: one redirect rather than a dead end for an address
/// typed that far. The same address written with a trailing slash is
/// settled the same way ([`get_tree_root_slash`]).
#[get("/~{user_or_team}/{repository_slug}/tree")]
async fn get_tree_root(
data: web::Data<Arc<RwLock<AppState>>>,
viewer: Option<CurrentUser>,
path: web::Path<(String, String)>,
) -> HttpResponse {
let (owner_name, repository_slug) = path.into_inner();
tree_address(
data,
viewer,
owner_name,
repository_slug,
String::new(),
String::new(),
)
.await
}
/// The same bare address with its trailing slash, which says no more than
/// no slash does.
#[get("/~{user_or_team}/{repository_slug}/tree/")]
async fn get_tree_root_slash(
data: web::Data<Arc<RwLock<AppState>>>,
viewer: Option<CurrentUser>,
path: web::Path<(String, String)>,
) -> HttpResponse {
let (owner_name, repository_slug) = path.into_inner();
tree_address(
data,
viewer,
owner_name,
repository_slug,
String::new(),
String::new(),
)
.await
}
/// One file as git holds it, served as its own bytes.
///
/// The same address as [`get_tree`] with `tree` read as `raw`: a revision,
/// then a path within it, and the answer is the file itself rather than a
/// page about it - what a `curl`, an editor or an `<img>` wants.
#[get("/~{user_or_team}/{repository_slug}/raw/{ref_or_commit}/{file_path:.*}")]
async fn get_raw(
data: web::Data<Arc<RwLock<AppState>>>,
viewer: Option<CurrentUser>,
path: web::Path<(String, String, String, String)>,
) -> HttpResponse {
let (owner_name, repository_slug, ref_or_commit, file_path) = path.into_inner();
raw_address(
data,
viewer,
owner_name,
repository_slug,
ref_or_commit,
file_path,
)
.await
}
/// The same address stopping at the revision: a revision with no path after
/// it is a directory's address, not a file's, and has no raw answer.
#[get("/~{user_or_team}/{repository_slug}/raw/{ref_or_commit}")]
async fn get_raw_revision(
data: web::Data<Arc<RwLock<AppState>>>,
viewer: Option<CurrentUser>,
path: web::Path<(String, String, String)>,
) -> HttpResponse {
let (owner_name, repository_slug, ref_or_commit) = path.into_inner();
raw_address(
data,
viewer,
owner_name,
repository_slug,
ref_or_commit,
String::new(),
)
.await
}
/// What a raw address names, read as one address exactly the way
/// [`tree_address`] reads a tree one - the two are one address, after all -
/// and answered with the file's bytes rather than a page about it. An
/// address whose revision has no path after it names a directory, and a
/// directory has no bytes: that is a `404`, not a listing.
#[tracing::instrument(
skip_all,
fields(
owner = tracing::field::Empty,
repository_slug = tracing::field::Empty,
ref_or_commit = tracing::field::Empty,
file_path = tracing::field::Empty,
reference = tracing::field::Empty,
within = tracing::field::Empty,
bytes = tracing::field::Empty,
)
)]
async fn raw_address(
data: web::Data<Arc<RwLock<AppState>>>,
viewer: Option<CurrentUser>,
owner_name: String,
repository_slug: String,
ref_or_commit: String,
file_path: String,
) -> HttpResponse {
let span = tracing::Span::current();
span.record("owner", owner_name.as_str());
span.record("repository_slug", repository_slug.as_str());
// What the address asked for, exactly as it was asked: the resolution
// recorded beside it is what the forge understood by it.
span.record("ref_or_commit", ref_or_commit.as_str());
span.record("file_path", file_path.as_str());
let (owner, repository, repo_root) =
match readable_repository(&data, viewer.as_ref(), &owner_name, &repository_slug).await {
Ok(found) => found,
Err(response) => return *response,
};
// Read outside the lock, for the reason [`tree_address`] gives.
let bare = RepositoryPath {
owner: owner.name.clone(),
slug: repository.slug.clone(),
}
.bare_path(&repo_root);
// The two pieces the route split at are one address again here, for the
// reason [`tree_address`] gives.
let tail = joined_address(&ref_or_commit, &file_path);
// There is no default file to settle on the way the tree address
// settles on the default branch: an address whose revision has no path
// after it names a directory, and a directory has no bytes.
let (reference, within) = match resolve_address(&bare, &tail, &repository.default_branch).await
{
Address::Found { reference, within } if !within.is_empty() => (reference, within),
Address::Found { .. } | Address::Unborn | Address::Missing => {
return HttpResponse::NotFound().body(format!(
"`{tail}` names no file of `{owner_name}/{repository_slug}`"
));
}
};
span.record("reference", reference.as_str());
span.record("within", within.as_str());
let bytes = match repositories::git::blob(&bare, &format!("{reference}:{within}")).await {
Ok(Some(bytes)) => bytes,
Ok(None) => {
return HttpResponse::NotFound().body(format!(
"`{reference}:{within}` is not a file of `{owner_name}/{repository_slug}`"
));
}
Err(error) => {
// The path was there a moment ago and git would not hand it
// over: a fault, not an empty file, and saying nothing is
// better than answering with bytes that are not the file's.
error!(%error, path = %bare.display(), "reading a file failed");
return HttpResponse::InternalServerError().finish();
}
};
span.record("bytes", bytes.len());
// The name says what the file is; `nosniff` says a browser may not
// second-guess that into running something the file never was.
let mime = mime_guess::from_path(within).first_or_octet_stream();
let content_type = if mime.type_() == mime_guess::mime::TEXT {
format!("{mime}; charset=utf-8")
} else {
mime.to_string()
};
HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, content_type))
.insert_header((header::X_CONTENT_TYPE_OPTIONS, "nosniff"))
.body(bytes)
}
/// One file of one ref: its own page, in the one form the file suits -
/// rendered when it is markdown, coloured when it is source, and refused
/// when it is not text at all.
#[tracing::instrument(
level = "debug",
skip(bare, viewer, owner, repository),
fields(bytes = tracing::field::Empty, display = tracing::field::Empty)
)]
async fn file_page(
bare: &Path,
viewer: Option<&CurrentUser>,
owner: &users::Model,
repository: &Repository,
reference: &str,
within: &str,
) -> HttpResponse {
let spec = format!("{reference}:{within}");
let bytes = match repositories::git::blob(bare, &spec).await {
Ok(Some(bytes)) => bytes,
Ok(None) => {
return HttpResponse::NotFound().body(format!(
"`{spec}` is not a file of `{}/{}`",
owner.name, repository.slug
));
}
Err(error) => {
// A file git named but would not hand over is a fault, not an
// empty file: showing nothing would be a lie about what the
// repository holds.
error!(%error, path = %bare.display(), "reading a file failed");
return HttpResponse::InternalServerError().finish();
}
};
let text = || String::from_utf8_lossy(&bytes);
let (display, described) = if repositories::git::looks_binary(&bytes) {
(views::FileDisplay::Binary, "binary")
} else if is_markdown(within) {
// `markdown::render` sanitizes what it returns, so the page embeds
// it as it stands, the same as a readme.
(
views::FileDisplay::Rendered(markdown::render_with_repo_context(
&text(),
&owner.name,
&repository.slug,
reference,
within,
)),
"rendered",
)
} else {
// `highlight::render` escapes as it colours, for the same reason.
(
views::FileDisplay::Coloured(highlight::render(&text(), within)),
"coloured",
)
};
// Only a file that came out of git gets these: a page refusing to show
// one says so by having read nothing, not by claiming zero bytes.
let span = tracing::Span::current();
span.record("bytes", bytes.len());
span.record("display", described);
views::file(
viewer_name(viewer),
&owner.name,
&views::FileDetail {
slug: repository.slug.clone(),
visibility: repository.visibility.label(),
default_branch: repository.default_branch.clone(),
reference: reference.to_string(),
path: within.to_string(),
display,
},
)
}
/// Whether a file is markdown the forge renders rather than shows.
fn is_markdown(path: &str) -> bool {
Path::new(path)
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| {
matches!(extension.to_ascii_lowercase().as_str(), "md" | "markdown")
})
}
/// The address to resolve out of the two pieces the route splits it into.
///
/// The split is the route's reading rather than git's: a ref may hold
/// slashes of its own, so only the repository's own refs - asked about the
/// whole address, longest first - can say where a revision ends and its
/// path begins. The pieces are therefore put back together here before
/// anything resolves them, and a trailing slash on the path, which says no
/// more than no slash does, is dropped on the way.
fn joined_address(ref_or_commit: &str, file_path: &str) -> String {
let file_path = file_path.trim_end_matches('/');
if file_path.is_empty() {
ref_or_commit.to_string()
} else {
format!("{ref_or_commit}/{file_path}")
}
}
/// What a tree or raw address's tail turned out to name.
enum Address {
/// A revision - a branch, a tag, a hash - and the path within it.
Found { reference: String, within: String },
/// The branch the repository opens on, which nothing has been pushed
/// to yet: an empty answer rather than a missing one.
Unborn,
/// Nothing in the repository answers for the tail.
Missing,
}
/// The revision and the path within it that a tail names.
///
/// The tree browser and the raw file address are two readings of one
/// address, so they read it the same way, here: [`resolve_tail`] longest
/// ref first, then git's own knowledge of where a revision in the tail
/// ends and its path begins, then the default branch of a repository with
/// nothing pushed to it, then nothing at all. A tail dressed up as an
/// option - one beginning with a dash - is nothing too, since nothing of
/// git's reading begins with one.
async fn resolve_address(bare: &Path, tail: &str, default_branch: &str) -> Address {
if tail.starts_with('-') {
return Address::Missing;
}
let available = match repositories::git::ref_names(bare).await {
Ok(available) => available,
Err(error) => {
error!(%error, path = %bare.display(), "listing a repository's ref names failed");
Vec::new()
}
};
if let Some((reference, within)) = resolve_tail(tail, &available) {
return Address::Found { reference, within };
}
// No ref names any part of it, but git may still know where the
// revision ends and the path begins: a commit hash, `HEAD~3`. Every
// point the tail could end at is asked, longest first, the same rule
// the refs were read by - so `hash/src/lib.rs` is `lib.rs` at `hash`
// once the prefixes that are not revisions have failed.
let mut ends: Vec<usize> = tail.match_indices('/').map(|(at, _)| at).collect();
ends.push(tail.len());
for end in ends.into_iter().rev() {
if end == 0 {
continue;
}
let reaches = match repositories::git::resolves_to_commit(bare, &tail[..end]).await {
Ok(reaches) => reaches,
Err(error) => {
error!(%error, path = %bare.display(), "resolving a revision failed");
false
}
};
if reaches {
let within = if end == tail.len() {
String::new()
} else {
tail[end + 1..].to_string()
};
return Address::Found {
reference: tail[..end].to_string(),
within,
};
}
}
if tail == default_branch {
Address::Unborn
} else {
Address::Missing
}
}
/// The ref a tree address names, and the directory left over as its path.
///
/// The tail is read longest-ref-first: `feature/x/pkg` is the branch
/// `feature/x` with `pkg` beneath it, not the branch `feature` with `x/pkg`
/// beneath that, when a repository's packed refs hold both. `None` when no
/// ref names any prefix of the tail.
fn resolve_tail(tail: &str, available: &[String]) -> Option<(String, String)> {
// Every point a ref could end at: each slash, and the tail itself.
let mut ends: Vec<usize> = tail.match_indices('/').map(|(at, _)| at).collect();
ends.push(tail.len());
for end in ends.into_iter().rev() {
if !available.iter().any(|name| name == &tail[..end]) {
continue;
}
let path = if end == tail.len() {
""
} else {
&tail[end + 1..]
};
return Some((tail[..end].to_string(), path.to_string()));
}
None
}
/// A ref as its listing shows it.
fn ref_summary(reference: repositories::git::Ref) -> views::RefSummary {
views::RefSummary {
name: reference.name,
hash: reference.commit.hash,
full_hash: reference.commit.full_hash,
author: reference.commit.author,
subject: reference.commit.subject,
}
}
/// Splits a compare range like "main...feat" into its base and head components.
///
/// Only accepts three-dot syntax (`base...head`). Returns `None` for two-dot
/// syntax or missing separator. Both sides must be non-empty.
fn split_compare_range(tail: &str) -> Option<(&str, &str)> {
let (base, head) = tail.split_once("...")?;
if base.is_empty() || head.is_empty() {
None
} else {
Some((base, head))
}
}
/// Validates that a ref name is safe to use as a compare endpoint.
///
/// Rejects empty strings, refs starting with `-` (git argument injection),
/// and refs containing `..` (which would be ambiguous with range syntax).
fn usable_compare_end(end: &str) -> bool {
!end.is_empty() && !end.starts_with('-') && !end.contains("..")
}
/// The read-only clone URL for a repository reached at this request's host.
///
/// Built from the request rather than from a configured setting, because the
/// web server's own bind address is often a loopback or wildcard one that says
/// nothing about the name a client dialled it by.
fn http_clone_url(req: &HttpRequest, owner: &str, slug: &str) -> String {
let connection = req.connection_info();
format!(
"{}://{}/{owner}/{slug}.git",
connection.scheme(),
connection.host()
)
}
/// The languages a request asks for, most wanted first.
///
/// Just enough of `Accept-Language` to choose a readme: a comma-separated list
/// of tags, each with an optional `;q=` quality, ordered by quality and then
/// by the order they were written. A wildcard is dropped - it asks for
/// anything, which is no preference - and a tag with a quality of zero is a
/// refusal rather than a request.
pub(crate) fn accept_languages(header: Option<&str>) -> Vec<String> {
let Some(header) = header else {
return Vec::new();
};
let mut weighted = Vec::new();
for (position, part) in header.split(',').enumerate() {
let mut parameters = part.split(';');
let Some(tag) = parameters.next() else {
continue;
};
let tag = tag.trim();
if tag.is_empty() || tag == "*" {
continue;
}
let mut quality = 1.0_f32;
for parameter in parameters {
if let Some(value) = parameter.trim().strip_prefix("q=")
&& let Ok(parsed) = value.trim().parse::<f32>()
{
quality = parsed;
}
}
if quality <= 0.0 {
continue;
}
weighted.push((quality, position, tag.to_string()));
}
// The sort is stable, so equal qualities keep the order they were written.
weighted.sort_by(|left, right| {
right
.0
.partial_cmp(&left.0)
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut languages = Vec::new();
for (_, _, tag) in weighted {
if !languages.contains(&tag) {
languages.push(tag);
}
}
languages
}
/// The repositories shown on `name`'s profile, newest first.
///
/// `viewer` is `None` for somebody signed out. A private repository is left
/// out entirely - not shown as "private" but omitted - so that the profile
/// does not tell a stranger what it cannot show them.
pub(crate) async fn summaries(
db: &DatabaseConnection,
owner_id: i64,
owner_name: &str,
viewer: Option<i64>,
) -> Result<Vec<RepositorySummary>, sea_orm::DbErr> {
let repositories = store::list_repositories(db, owner_id).await?;
Ok(repositories
.into_iter()
.filter(|repository| {
repositories::may_read(repository.visibility, repository.owner_id, viewer)
})
.map(|repository| RepositorySummary {
owner_name: owner_name.to_string(),
slug: repository.slug,
description: repository.description,
visibility: repository.visibility.label(),
})
.collect())
}
#[cfg(test)]
mod tests {
use super::accept_languages;
use super::is_markdown;
use super::resolve_tail;
use super::split_compare_range;
use super::usable_compare_end;
fn available(names: &[&str]) -> Vec<String> {
names.iter().map(|name| name.to_string()).collect()
}
#[test]
fn a_tail_resolves_against_the_refs_it_names() {
let refs = available(&["main", "feature/x", "v1"]);
assert_eq!(
resolve_tail("main", &refs),
Some(("main".to_string(), String::new()))
);
assert_eq!(
resolve_tail("main/src/deep", &refs),
Some(("main".to_string(), "src/deep".to_string()))
);
// The whole tail is one ref, even though `feature` on its own would
// also have been a prefix of it.
assert_eq!(
resolve_tail("feature/x", &refs),
Some(("feature/x".to_string(), String::new()))
);
assert_eq!(
resolve_tail("feature/x/pkg", &refs),
Some(("feature/x".to_string(), "pkg".to_string()))
);
}
#[test]
fn a_tail_no_ref_answers_for_is_not_resolvable() {
let refs = available(&["main", "feature/x"]);
assert_eq!(resolve_tail("nope", &refs), None);
assert_eq!(resolve_tail("nope/deeper", &refs), None);
// `main` is a ref, but nothing makes `maint` one.
assert_eq!(resolve_tail("maint", &refs), None);
}
#[test]
fn the_longest_ref_holding_another_one_wins() {
// A repository's refs cannot hold both as loose files, but packed
// ones can, and the answer has to be the same either way: the ref
// that says the most of the tail is the one meant.
let refs = available(&["feature", "feature/x"]);
assert_eq!(
resolve_tail("feature/x/y", &refs),
Some(("feature/x".to_string(), "y".to_string()))
);
assert_eq!(
resolve_tail("feature/y", &refs),
Some(("feature".to_string(), "y".to_string()))
);
}
#[test]
fn markdown_is_recognised_by_the_file_name() {
assert!(is_markdown("README.md"));
assert!(is_markdown("docs/guide.markdown"));
// The extension is a file's own, whatever case it was written in.
assert!(is_markdown("RELEASES.MD"));
// A file that merely looks long enough to be one, and a directory
// whose name ends like a file's, are not.
assert!(!is_markdown("mr.d"));
assert!(!is_markdown("md"));
}
#[test]
fn no_header_is_no_preference() {
assert!(accept_languages(None).is_empty());
assert!(accept_languages(Some("")).is_empty());
assert!(accept_languages(Some(" ")).is_empty());
}
#[test]
fn languages_are_ordered_by_quality_then_as_written() {
assert_eq!(
accept_languages(Some("en;q=0.5, fr, de;q=0.8")),
["fr", "de", "en"]
);
// Equal qualities keep the order they were written in.
assert_eq!(accept_languages(Some("fr, en")), ["fr", "en"]);
}
#[test]
fn a_wildcard_and_a_refusal_are_not_preferences() {
assert!(accept_languages(Some("*")).is_empty());
assert_eq!(accept_languages(Some("*, fr")), ["fr"]);
assert_eq!(accept_languages(Some("fr;q=0, en")), ["en"]);
}
#[test]
fn repeated_languages_appear_once() {
assert_eq!(
accept_languages(Some("fr, fr-CA;q=0.8, fr")),
["fr", "fr-CA"]
);
}
#[test]
fn a_malformed_quality_is_ignored() {
// No quality is a request for it; a `q` that is not a number leaves the
// request as the default rather than dropping the language.
assert_eq!(accept_languages(Some("fr;q=bob")), ["fr"]);
}
#[test]
fn split_compare_range_parses_three_dot_syntax() {
assert_eq!(split_compare_range("main...feat"), Some(("main", "feat")));
assert_eq!(
split_compare_range("feature/x...release/1.0"),
Some(("feature/x", "release/1.0"))
);
assert_eq!(split_compare_range("v1.0...v2.0"), Some(("v1.0", "v2.0")));
// Edge case: a....b splits at first ..., leaving .b as head
assert_eq!(split_compare_range("a....b"), Some(("a", ".b")));
}
#[test]
fn split_compare_range_rejects_two_dots() {
assert_eq!(split_compare_range("main..feat"), None);
}
#[test]
fn split_compare_range_rejects_missing_separator() {
assert_eq!(split_compare_range("main"), None);
}
#[test]
fn split_compare_range_rejects_empty_sides() {
assert_eq!(split_compare_range("main..."), None);
assert_eq!(split_compare_range("...feat"), None);
}
#[test]
fn usable_compare_end_accepts_valid_refs() {
assert!(usable_compare_end("main"));
assert!(usable_compare_end("feature/x"));
assert!(usable_compare_end("v1.0"));
}
#[test]
fn usable_compare_end_rejects_empty() {
assert!(!usable_compare_end(""));
}
#[test]
fn usable_compare_end_rejects_leading_dash() {
assert!(!usable_compare_end("-HEAD"));
assert!(!usable_compare_end("-main"));
}
#[test]
fn usable_compare_end_rejects_double_dots() {
assert!(!usable_compare_end("main..feat"));
assert!(!usable_compare_end("a..b"));
}
}