// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! The `/api/v1` handlers. use std::path::Path as FsPath; use auth::{Access, Scope}; use axum::Json; use axum::extract::{Path, Query, State}; use axum::http::{StatusCode, header}; use axum::response::{IntoResponse, Response}; use model::{Repo, User}; use serde::Deserialize; use serde_json::{Value, json}; use crate::{ApiAuth, ApiError, ApiResult, ApiState, git_read, rfc3339, rfc3339_opt}; /// Pagination + ref query parameters. #[derive(Debug, Default, Deserialize)] pub struct ListParams { page: Option, per_page: Option, #[serde(rename = "ref")] reference: Option, #[serde(default)] q: Option, #[serde(default)] purge: Option, } impl ListParams { /// The zero-based offset and page size (`per_page` capped at 100). fn page(&self) -> (usize, usize) { let per_page = self.per_page.unwrap_or(30).clamp(1, 100); let page = self.page.unwrap_or(1).max(1); ((page - 1) * per_page, per_page) } } /// `GET /version`. pub async fn version(State(state): State) -> Json { Json(json!({ "name": state.config.instance.name, "version": env!("CARGO_PKG_VERSION"), })) } /// `GET /user` — the current token's user. pub async fn current_user(auth: ApiAuth) -> ApiResult> { Ok(Json(user_json(&auth.user))) } /// `GET /users` — admin only. pub async fn list_users(State(state): State, auth: ApiAuth) -> ApiResult { auth.require(Scope::Admin)?; let users = state.store.list_users().await?; let total = users.len(); let (offset, limit) = ListParams::default().page(); let view: Vec = users .iter() .skip(offset) .take(limit) .map(user_json) .collect(); Ok(with_total(Json(view), total)) } /// `GET /repos` — repos visible to the token. pub async fn list_repos( State(state): State, auth: ApiAuth, Query(params): Query, ) -> ApiResult { auth.require(Scope::RepoRead)?; let mut visible = Vec::new(); for repo in state.store.list_repos().await? { if repo_access(&state, &auth, &repo).await? >= Access::Read { visible.push(repo); } } let total = visible.len(); let (offset, limit) = params.page(); let mut view = Vec::new(); for repo in visible.into_iter().skip(offset).take(limit) { let owner = owner_name(&state, &repo.owner_id).await; view.push(repo_json(&repo, &owner)); } Ok(with_total(Json(view), total)) } /// New-repo request body. #[derive(Debug, Deserialize)] pub struct CreateRepo { owner: Option, name: String, #[serde(default)] private: Option, #[serde(default)] description: Option, #[serde(default)] default_branch: Option, } /// `POST /repos`. pub async fn create_repo( State(state): State, auth: ApiAuth, Json(body): Json, ) -> ApiResult { auth.require(Scope::RepoWrite)?; // Resolve the owner; only an admin may create for someone else. let owner = match &body.owner { Some(name) => state .store .user_by_username(name) .await? .ok_or(ApiError::NotFound)?, None => auth.user.clone(), }; if owner.id != auth.user.id && !auth.user.is_admin { return Err(ApiError::Forbidden("cannot create repos for another user")); } let (group_path, leaf) = match body.name.rsplit_once('/') { Some((g, l)) => (Some(g.to_string()), l.to_string()), None => (None, body.name.clone()), }; let group_id = match &group_path { Some(g) => Some(state.store.ensure_group_path(&owner.id, g).await?.id), None => None, }; let branch = body .default_branch .unwrap_or_else(|| state.config.instance.default_branch.clone()); let repo = state .store .create_repo(store::NewRepo { owner_id: owner.id.clone(), group_id, name: leaf, path: body.name.clone(), description: body.description, visibility: if body.private.unwrap_or(true) { model::Visibility::Private } else { model::Visibility::Public }, default_branch: branch.clone(), }) .await?; let hook = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("fabrica")); if let Err(err) = git::create_bare(&state.config.storage.repo_dir, &repo.id, &branch, &hook) { let _ = state.store.delete_repo(&repo.id).await; return Err(ApiError::internal(err)); } Ok((StatusCode::CREATED, Json(repo_json(&repo, &owner.username))).into_response()) } /// A parsed repo sub-resource. enum Sub { Detail, Branches, Tags, Commits, Commit(String), Tree(String, String), Raw(String, String), } /// Split `{*rest}` into the repo path and its sub-resource. The first segment that /// is a known keyword ends the repo path (repos should not be named after these /// keywords — a documented MVP limitation). fn split_rest(rest: &str) -> (String, Sub) { let segs: Vec<&str> = rest.split('/').filter(|s| !s.is_empty()).collect(); let keyword = segs .iter() .position(|s| matches!(*s, "branches" | "tags" | "commits" | "tree" | "raw")); let Some(i) = keyword else { return (segs.join("/"), Sub::Detail); }; let repo_path = segs[..i].join("/"); let rest = &segs[i + 1..]; let sub = match segs[i] { "branches" => Sub::Branches, "tags" => Sub::Tags, "commits" => rest .first() .map_or(Sub::Commits, |sha| Sub::Commit((*sha).to_string())), "tree" => Sub::Tree( rest.first().copied().unwrap_or_default().to_string(), rest.get(1..).map(|s| s.join("/")).unwrap_or_default(), ), _ => Sub::Raw( rest.first().copied().unwrap_or_default().to_string(), rest.get(1..).map(|s| s.join("/")).unwrap_or_default(), ), }; (repo_path, sub) } /// `GET /repos/{owner}/{*rest}` — repo detail or a git sub-resource. pub async fn repo_get( State(state): State, auth: ApiAuth, Query(params): Query, Path((owner, rest)): Path<(String, String)>, ) -> ApiResult { auth.require(Scope::RepoRead)?; let (repo_path, sub) = split_rest(&rest); let repo = resolve_repo(&state, &auth, &owner, &repo_path, Access::Read).await?; let default_ref = repo.default_branch.clone(); match sub { Sub::Detail => Ok(Json(repo_json(&repo, &owner)).into_response()), Sub::Branches => { let branches = git_read(&state, &repo.id, git::Repo::branches).await?; let view: Vec = branches .iter() .map(|b| { json!({ "name": b.name, "commit": b.oid.as_str(), "default": b.is_default, "ahead": b.ahead, "behind": b.behind, }) }) .collect(); Ok(Json(view).into_response()) } Sub::Tags => { let tags = git_read(&state, &repo.id, git::Repo::tags).await?; let view: Vec = tags .iter() .map(|t| json!({ "name": t.name, "commit": t.oid.as_str(), "annotated": t.annotated })) .collect(); Ok(Json(view).into_response()) } Sub::Commits => { let (offset, limit) = params.page(); let reference = params.reference.unwrap_or(default_ref); let commits = git_read(&state, &repo.id, move |repo| { let rev = repo.resolve_ref(&reference)?; repo.commits(&rev, None, git::Page { offset, limit }) }) .await?; let view: Vec = commits.iter().map(commit_summary_json).collect(); Ok(Json(view).into_response()) } Sub::Commit(sha) => { let detail = git_read(&state, &repo.id, move |repo| { repo.commit(&git::Oid::new(sha)) }) .await?; Ok(Json(commit_detail_json(&detail)).into_response()) } Sub::Tree(reference, path) => { let reference = if reference.is_empty() { default_ref } else { reference }; let entries = git_read(&state, &repo.id, move |repo| { let rev = repo.resolve_ref(&reference)?; repo.tree_entries(&rev, FsPath::new(&path)) }) .await?; let view: Vec = entries .iter() .map(|e| { json!({ "name": e.name, "kind": entry_kind(e.kind), "mode": format!("{:o}", e.mode), "oid": e.oid.as_str(), "size": e.size, }) }) .collect(); Ok(Json(view).into_response()) } Sub::Raw(reference, path) => { let reference = if reference.is_empty() { default_ref } else { reference }; let path2 = path.clone(); let blob = git_read(&state, &repo.id, move |repo| { let rev = repo.resolve_ref(&reference)?; repo.blob(&rev, FsPath::new(&path2)) }) .await?; let content_type = if blob.is_binary { "application/octet-stream" } else { "text/plain; charset=utf-8" }; Ok(( [ (header::CONTENT_TYPE, content_type), (header::X_CONTENT_TYPE_OPTIONS, "nosniff"), ], blob.content, ) .into_response()) } } } /// `DELETE /repos/{owner}/{*rest}?purge=`. pub async fn repo_delete( State(state): State, auth: ApiAuth, Query(params): Query, Path((owner, rest)): Path<(String, String)>, ) -> ApiResult { auth.require(Scope::RepoAdmin)?; let (repo_path, _) = split_rest(&rest); let repo = resolve_repo(&state, &auth, &owner, &repo_path, Access::Admin).await?; state.store.delete_repo(&repo.id).await?; if let Ok(dir) = git::repo_path(&state.config.storage.repo_dir, &repo.id) { if dir.exists() { if params.purge.unwrap_or(false) { let _ = std::fs::remove_dir_all(&dir); } else { let trash = state.config.storage.data_dir.join("trash").join(format!( "{}-{}.git", repo.id, crate::now_secs() )); if let Some(parent) = trash.parent() { let _ = std::fs::create_dir_all(parent); } let _ = std::fs::rename(&dir, &trash); } } } Ok(StatusCode::NO_CONTENT.into_response()) } /// `GET /groups/{owner}`. pub async fn list_groups( State(state): State, auth: ApiAuth, Path(owner): Path, ) -> ApiResult { auth.require(Scope::RepoRead)?; let owner_user = state .store .user_by_username(&owner) .await? .ok_or(ApiError::NotFound)?; let groups = state.store.groups_by_owner(&owner_user.id).await?; let view: Vec = groups .iter() .map(|g| json!({ "name": g.name, "path": g.path, "description": g.description })) .collect(); Ok(Json(view).into_response()) } /// `GET /search?q=` — repos and users matching the query (name/description). pub async fn search( State(state): State, auth: ApiAuth, Query(params): Query, ) -> ApiResult { auth.require(Scope::RepoRead)?; let needle = params.q.unwrap_or_default().to_lowercase(); if needle.is_empty() { return Ok(Json(json!({ "repos": [], "users": [] })).into_response()); } let mut repos = Vec::new(); for repo in state.store.list_repos().await? { if repo_access(&state, &auth, &repo).await? < Access::Read { continue; } let owner = owner_name(&state, &repo.owner_id).await; let hay = format!( "{}/{} {}", owner, repo.path, repo.description.clone().unwrap_or_default() ) .to_lowercase(); if hay.contains(&needle) { repos.push(repo_json(&repo, &owner)); } } let users: Vec = state .store .list_users() .await? .iter() .filter(|u| u.username.to_lowercase().contains(&needle)) .map(user_json) .collect(); Ok(Json(json!({ "repos": repos, "users": users })).into_response()) } /// The API fallback for unmatched routes. pub async fn not_found() -> ApiError { ApiError::NotFound } // ---- helpers ---- /// Resolve `(owner, path)` to a repo the token may access at `required`, else 404. async fn resolve_repo( state: &ApiState, auth: &ApiAuth, owner: &str, repo_path: &str, required: Access, ) -> ApiResult { let owner_user = state .store .user_by_username(owner) .await? .ok_or(ApiError::NotFound)?; let repo = state .store .repo_by_owner_path(&owner_user.id, repo_path) .await? .ok_or(ApiError::NotFound)?; if repo_access(state, auth, &repo).await? >= required { Ok(repo) } else { Err(ApiError::NotFound) // 404, never 403, for a private resource. } } /// The token's access level to a repo. async fn repo_access(state: &ApiState, auth: &ApiAuth, repo: &Repo) -> ApiResult { let collaborator = state .store .collaborator_permission(&repo.id, &auth.user.id) .await? .and_then(|p| auth::Permission::parse(&p)); Ok(auth::access( Some(&auth.viewer()), repo, collaborator, state.config.instance.allow_anonymous, )) } /// Resolve an owner id to a username. async fn owner_name(state: &ApiState, owner_id: &str) -> String { state .store .user_by_id(owner_id) .await .ok() .flatten() .map_or_else(|| owner_id.to_string(), |u| u.username) } /// Attach an `X-Total-Count` header to a JSON list response. fn with_total(json: Json>, total: usize) -> Response { ( [( header::HeaderName::from_static("x-total-count"), header::HeaderValue::from(u64::try_from(total).unwrap_or(0)), )], json, ) .into_response() } /// Render a user (never the password hash). fn user_json(user: &User) -> Value { json!({ "username": user.username, "email": user.email, "display_name": user.display_name, "is_admin": user.is_admin, "disabled": user.disabled_at.is_some(), "created_at": rfc3339(user.created_at), }) } /// Render a repo. fn repo_json(repo: &Repo, owner: &str) -> Value { json!({ "id": repo.id, "owner": owner, "name": repo.name, "path": repo.path, "private": repo.visibility.is_private(), "visibility": repo.visibility.as_str(), "default_branch": repo.default_branch, "description": repo.description, "archived": repo.archived_at.is_some(), "size_bytes": repo.size_bytes, "pushed_at": rfc3339_opt(repo.pushed_at), "created_at": rfc3339(repo.created_at), "updated_at": rfc3339(repo.updated_at), }) } /// Render a commit summary. fn commit_summary_json(c: &git::CommitSummary) -> Value { json!({ "sha": c.oid.as_str(), "summary": c.summary, "author": { "name": c.author.name, "email": c.author.email, "date": rfc3339(c.author.time_ms) }, "parents": c.parents, }) } /// Render a full commit. fn commit_detail_json(c: &git::CommitDetail) -> Value { json!({ "sha": c.oid.as_str(), "message": c.message, "tree": c.tree.as_str(), "parents": c.parents.iter().map(git::Oid::as_str).collect::>(), "author": { "name": c.author.name, "email": c.author.email, "date": rfc3339(c.author.time_ms) }, "committer": { "name": c.committer.name, "email": c.committer.email, "date": rfc3339(c.committer.time_ms) }, }) } /// The wire name for a tree entry kind. fn entry_kind(kind: git::EntryKind) -> &'static str { match kind { git::EntryKind::Directory => "dir", git::EntryKind::File => "file", git::EntryKind::Symlink => "symlink", git::EntryKind::Submodule => "submodule", } }