Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
Cargo.lock +14 −0
| @@ -198,6 +198,19 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" | |||
| 198 | 198 | [[package]] | |
| 199 | 199 | name = "api" | |
| 200 | 200 | version = "0.1.0" | |
| 201 | + | dependencies = [ | |
| 202 | + | "auth", | |
| 203 | + | "axum", | |
| 204 | + | "config", | |
| 205 | + | "git", | |
| 206 | + | "model", | |
| 207 | + | "serde", | |
| 208 | + | "serde_json", | |
| 209 | + | "store", | |
| 210 | + | "time", | |
| 211 | + | "tokio", | |
| 212 | + | "tracing", | |
| 213 | + | ] | |
| 201 | 214 | ||
| 202 | 215 | [[package]] | |
| 203 | 216 | name = "argon2" | |
| @@ -5570,6 +5583,7 @@ name = "web" | |||
| 5570 | 5583 | version = "0.1.0" | |
| 5571 | 5584 | dependencies = [ | |
| 5572 | 5585 | "ammonia", | |
| 5586 | + | "api", | |
| 5573 | 5587 | "auth", | |
| 5574 | 5588 | "axum", | |
| 5575 | 5589 | "axum-extra", | |
crates/api/Cargo.toml +11 −0
| @@ -10,6 +10,17 @@ repository.workspace = true | |||
| 10 | 10 | publish.workspace = true | |
| 11 | 11 | ||
| 12 | 12 | [dependencies] | |
| 13 | + | config = { workspace = true } | |
| 14 | + | store = { workspace = true } | |
| 15 | + | model = { workspace = true } | |
| 16 | + | git = { workspace = true } | |
| 17 | + | auth = { workspace = true } | |
| 18 | + | axum = { workspace = true } | |
| 19 | + | serde = { workspace = true } | |
| 20 | + | serde_json = { workspace = true } | |
| 21 | + | time = { workspace = true } | |
| 22 | + | tokio = { workspace = true } | |
| 23 | + | tracing = { workspace = true } | |
| 13 | 24 | ||
| 14 | 25 | [lints] | |
| 15 | 26 | workspace = true | |
crates/api/src/handlers.rs +527 −0
| @@ -0,0 +1,527 @@ | |||
| 1 | + | // This Source Code Form is subject to the terms of the Mozilla Public | |
| 2 | + | // License, v. 2.0. If a copy of the MPL was not distributed with this | |
| 3 | + | // file, You can obtain one at https://mozilla.org/MPL/2.0/. | |
| 4 | + | ||
| 5 | + | //! The `/api/v1` handlers. | |
| 6 | + | ||
| 7 | + | use std::path::Path as FsPath; | |
| 8 | + | ||
| 9 | + | use auth::{Access, Scope}; | |
| 10 | + | use axum::Json; | |
| 11 | + | use axum::extract::{Path, Query, State}; | |
| 12 | + | use axum::http::{StatusCode, header}; | |
| 13 | + | use axum::response::{IntoResponse, Response}; | |
| 14 | + | use model::{Repo, User}; | |
| 15 | + | use serde::Deserialize; | |
| 16 | + | use serde_json::{Value, json}; | |
| 17 | + | ||
| 18 | + | use crate::{ApiAuth, ApiError, ApiResult, ApiState, git_read, rfc3339, rfc3339_opt}; | |
| 19 | + | ||
| 20 | + | /// Pagination + ref query parameters. | |
| 21 | + | #[derive(Debug, Default, Deserialize)] | |
| 22 | + | pub struct ListParams { | |
| 23 | + | page: Option<usize>, | |
| 24 | + | per_page: Option<usize>, | |
| 25 | + | #[serde(rename = "ref")] | |
| 26 | + | reference: Option<String>, | |
| 27 | + | #[serde(default)] | |
| 28 | + | q: Option<String>, | |
| 29 | + | #[serde(default)] | |
| 30 | + | purge: Option<bool>, | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | impl ListParams { | |
| 34 | + | /// The zero-based offset and page size (`per_page` capped at 100). | |
| 35 | + | fn page(&self) -> (usize, usize) { | |
| 36 | + | let per_page = self.per_page.unwrap_or(30).clamp(1, 100); | |
| 37 | + | let page = self.page.unwrap_or(1).max(1); | |
| 38 | + | ((page - 1) * per_page, per_page) | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | /// `GET /version`. | |
| 43 | + | pub async fn version(State(state): State<ApiState>) -> Json<Value> { | |
| 44 | + | Json(json!({ | |
| 45 | + | "name": state.config.instance.name, | |
| 46 | + | "version": env!("CARGO_PKG_VERSION"), | |
| 47 | + | })) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | /// `GET /user` — the current token's user. | |
| 51 | + | pub async fn current_user(auth: ApiAuth) -> ApiResult<Json<Value>> { | |
| 52 | + | Ok(Json(user_json(&auth.user))) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | /// `GET /users` — admin only. | |
| 56 | + | pub async fn list_users(State(state): State<ApiState>, auth: ApiAuth) -> ApiResult<Response> { | |
| 57 | + | auth.require(Scope::Admin)?; | |
| 58 | + | let users = state.store.list_users().await?; | |
| 59 | + | let total = users.len(); | |
| 60 | + | let (offset, limit) = ListParams::default().page(); | |
| 61 | + | let view: Vec<Value> = users | |
| 62 | + | .iter() | |
| 63 | + | .skip(offset) | |
| 64 | + | .take(limit) | |
| 65 | + | .map(user_json) | |
| 66 | + | .collect(); | |
| 67 | + | Ok(with_total(Json(view), total)) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | /// `GET /repos` — repos visible to the token. | |
| 71 | + | pub async fn list_repos( | |
| 72 | + | State(state): State<ApiState>, | |
| 73 | + | auth: ApiAuth, | |
| 74 | + | Query(params): Query<ListParams>, | |
| 75 | + | ) -> ApiResult<Response> { | |
| 76 | + | auth.require(Scope::RepoRead)?; | |
| 77 | + | let mut visible = Vec::new(); | |
| 78 | + | for repo in state.store.list_repos().await? { | |
| 79 | + | if repo_access(&state, &auth, &repo).await? >= Access::Read { | |
| 80 | + | visible.push(repo); | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | let total = visible.len(); | |
| 84 | + | let (offset, limit) = params.page(); | |
| 85 | + | let mut view = Vec::new(); | |
| 86 | + | for repo in visible.into_iter().skip(offset).take(limit) { | |
| 87 | + | let owner = owner_name(&state, &repo.owner_id).await; | |
| 88 | + | view.push(repo_json(&repo, &owner)); | |
| 89 | + | } | |
| 90 | + | Ok(with_total(Json(view), total)) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | /// New-repo request body. | |
| 94 | + | #[derive(Debug, Deserialize)] | |
| 95 | + | pub struct CreateRepo { | |
| 96 | + | owner: Option<String>, | |
| 97 | + | name: String, | |
| 98 | + | #[serde(default)] | |
| 99 | + | private: Option<bool>, | |
| 100 | + | #[serde(default)] | |
| 101 | + | description: Option<String>, | |
| 102 | + | #[serde(default)] | |
| 103 | + | default_branch: Option<String>, | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | /// `POST /repos`. | |
| 107 | + | pub async fn create_repo( | |
| 108 | + | State(state): State<ApiState>, | |
| 109 | + | auth: ApiAuth, | |
| 110 | + | Json(body): Json<CreateRepo>, | |
| 111 | + | ) -> ApiResult<Response> { | |
| 112 | + | auth.require(Scope::RepoWrite)?; | |
| 113 | + | // Resolve the owner; only an admin may create for someone else. | |
| 114 | + | let owner = match &body.owner { | |
| 115 | + | Some(name) => state | |
| 116 | + | .store | |
| 117 | + | .user_by_username(name) | |
| 118 | + | .await? | |
| 119 | + | .ok_or(ApiError::NotFound)?, | |
| 120 | + | None => auth.user.clone(), | |
| 121 | + | }; | |
| 122 | + | if owner.id != auth.user.id && !auth.user.is_admin { | |
| 123 | + | return Err(ApiError::Forbidden("cannot create repos for another user")); | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | let (group_path, leaf) = match body.name.rsplit_once('/') { | |
| 127 | + | Some((g, l)) => (Some(g.to_string()), l.to_string()), | |
| 128 | + | None => (None, body.name.clone()), | |
| 129 | + | }; | |
| 130 | + | let group_id = match &group_path { | |
| 131 | + | Some(g) => Some(state.store.ensure_group_path(&owner.id, g).await?.id), | |
| 132 | + | None => None, | |
| 133 | + | }; | |
| 134 | + | let branch = body | |
| 135 | + | .default_branch | |
| 136 | + | .unwrap_or_else(|| state.config.instance.default_branch.clone()); | |
| 137 | + | ||
| 138 | + | let repo = state | |
| 139 | + | .store | |
| 140 | + | .create_repo(store::NewRepo { | |
| 141 | + | owner_id: owner.id.clone(), | |
| 142 | + | group_id, | |
| 143 | + | name: leaf, | |
| 144 | + | path: body.name.clone(), | |
| 145 | + | description: body.description, | |
| 146 | + | is_private: body.private.unwrap_or(true), | |
| 147 | + | default_branch: branch.clone(), | |
| 148 | + | }) | |
| 149 | + | .await?; | |
| 150 | + | ||
| 151 | + | let hook = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("fabrica")); | |
| 152 | + | if let Err(err) = git::create_bare(&state.config.storage.repo_dir, &repo.id, &branch, &hook) { | |
| 153 | + | let _ = state.store.delete_repo(&repo.id).await; | |
| 154 | + | return Err(ApiError::internal(err)); | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | Ok((StatusCode::CREATED, Json(repo_json(&repo, &owner.username))).into_response()) | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | /// A parsed repo sub-resource. | |
| 161 | + | enum Sub { | |
| 162 | + | Detail, | |
| 163 | + | Branches, | |
| 164 | + | Tags, | |
| 165 | + | Commits, | |
| 166 | + | Commit(String), | |
| 167 | + | Tree(String, String), | |
| 168 | + | Raw(String, String), | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | /// Split `{*rest}` into the repo path and its sub-resource. The first segment that | |
| 172 | + | /// is a known keyword ends the repo path (repos should not be named after these | |
| 173 | + | /// keywords — a documented MVP limitation). | |
| 174 | + | fn split_rest(rest: &str) -> (String, Sub) { | |
| 175 | + | let segs: Vec<&str> = rest.split('/').filter(|s| !s.is_empty()).collect(); | |
| 176 | + | let keyword = segs | |
| 177 | + | .iter() | |
| 178 | + | .position(|s| matches!(*s, "branches" | "tags" | "commits" | "tree" | "raw")); | |
| 179 | + | let Some(i) = keyword else { | |
| 180 | + | return (segs.join("/"), Sub::Detail); | |
| 181 | + | }; | |
| 182 | + | let repo_path = segs[..i].join("/"); | |
| 183 | + | let rest = &segs[i + 1..]; | |
| 184 | + | let sub = match segs[i] { | |
| 185 | + | "branches" => Sub::Branches, | |
| 186 | + | "tags" => Sub::Tags, | |
| 187 | + | "commits" => rest | |
| 188 | + | .first() | |
| 189 | + | .map_or(Sub::Commits, |sha| Sub::Commit((*sha).to_string())), | |
| 190 | + | "tree" => Sub::Tree( | |
| 191 | + | rest.first().copied().unwrap_or_default().to_string(), | |
| 192 | + | rest.get(1..).map(|s| s.join("/")).unwrap_or_default(), | |
| 193 | + | ), | |
| 194 | + | _ => Sub::Raw( | |
| 195 | + | rest.first().copied().unwrap_or_default().to_string(), | |
| 196 | + | rest.get(1..).map(|s| s.join("/")).unwrap_or_default(), | |
| 197 | + | ), | |
| 198 | + | }; | |
| 199 | + | (repo_path, sub) | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | /// `GET /repos/{owner}/{*rest}` — repo detail or a git sub-resource. | |
| 203 | + | pub async fn repo_get( | |
| 204 | + | State(state): State<ApiState>, | |
| 205 | + | auth: ApiAuth, | |
| 206 | + | Query(params): Query<ListParams>, | |
| 207 | + | Path((owner, rest)): Path<(String, String)>, | |
| 208 | + | ) -> ApiResult<Response> { | |
| 209 | + | auth.require(Scope::RepoRead)?; | |
| 210 | + | let (repo_path, sub) = split_rest(&rest); | |
| 211 | + | let repo = resolve_repo(&state, &auth, &owner, &repo_path, Access::Read).await?; | |
| 212 | + | let default_ref = repo.default_branch.clone(); | |
| 213 | + | ||
| 214 | + | match sub { | |
| 215 | + | Sub::Detail => Ok(Json(repo_json(&repo, &owner)).into_response()), | |
| 216 | + | Sub::Branches => { | |
| 217 | + | let branches = git_read(&state, &repo.id, git::Repo::branches).await?; | |
| 218 | + | let view: Vec<Value> = branches | |
| 219 | + | .iter() | |
| 220 | + | .map(|b| { | |
| 221 | + | json!({ | |
| 222 | + | "name": b.name, "commit": b.oid.as_str(), "default": b.is_default, | |
| 223 | + | "ahead": b.ahead, "behind": b.behind, | |
| 224 | + | }) | |
| 225 | + | }) | |
| 226 | + | .collect(); | |
| 227 | + | Ok(Json(view).into_response()) | |
| 228 | + | } | |
| 229 | + | Sub::Tags => { | |
| 230 | + | let tags = git_read(&state, &repo.id, git::Repo::tags).await?; | |
| 231 | + | let view: Vec<Value> = tags | |
| 232 | + | .iter() | |
| 233 | + | .map(|t| json!({ "name": t.name, "commit": t.oid.as_str(), "annotated": t.annotated })) | |
| 234 | + | .collect(); | |
| 235 | + | Ok(Json(view).into_response()) | |
| 236 | + | } | |
| 237 | + | Sub::Commits => { | |
| 238 | + | let (offset, limit) = params.page(); | |
| 239 | + | let reference = params.reference.unwrap_or(default_ref); | |
| 240 | + | let commits = git_read(&state, &repo.id, move |repo| { | |
| 241 | + | let rev = repo.resolve_ref(&reference)?; | |
| 242 | + | repo.commits(&rev, None, git::Page { offset, limit }) | |
| 243 | + | }) | |
| 244 | + | .await?; | |
| 245 | + | let view: Vec<Value> = commits.iter().map(commit_summary_json).collect(); | |
| 246 | + | Ok(Json(view).into_response()) | |
| 247 | + | } | |
| 248 | + | Sub::Commit(sha) => { | |
| 249 | + | let detail = git_read(&state, &repo.id, move |repo| { | |
| 250 | + | repo.commit(&git::Oid::new(sha)) | |
| 251 | + | }) | |
| 252 | + | .await?; | |
| 253 | + | Ok(Json(commit_detail_json(&detail)).into_response()) | |
| 254 | + | } | |
| 255 | + | Sub::Tree(reference, path) => { | |
| 256 | + | let reference = if reference.is_empty() { | |
| 257 | + | default_ref | |
| 258 | + | } else { | |
| 259 | + | reference | |
| 260 | + | }; | |
| 261 | + | let entries = git_read(&state, &repo.id, move |repo| { | |
| 262 | + | let rev = repo.resolve_ref(&reference)?; | |
| 263 | + | repo.tree_entries(&rev, FsPath::new(&path)) | |
| 264 | + | }) | |
| 265 | + | .await?; | |
| 266 | + | let view: Vec<Value> = entries | |
| 267 | + | .iter() | |
| 268 | + | .map(|e| { | |
| 269 | + | json!({ | |
| 270 | + | "name": e.name, "kind": entry_kind(e.kind), | |
| 271 | + | "mode": format!("{:o}", e.mode), "oid": e.oid.as_str(), "size": e.size, | |
| 272 | + | }) | |
| 273 | + | }) | |
| 274 | + | .collect(); | |
| 275 | + | Ok(Json(view).into_response()) | |
| 276 | + | } | |
| 277 | + | Sub::Raw(reference, path) => { | |
| 278 | + | let reference = if reference.is_empty() { | |
| 279 | + | default_ref | |
| 280 | + | } else { | |
| 281 | + | reference | |
| 282 | + | }; | |
| 283 | + | let path2 = path.clone(); | |
| 284 | + | let blob = git_read(&state, &repo.id, move |repo| { | |
| 285 | + | let rev = repo.resolve_ref(&reference)?; | |
| 286 | + | repo.blob(&rev, FsPath::new(&path2)) | |
| 287 | + | }) | |
| 288 | + | .await?; | |
| 289 | + | let content_type = if blob.is_binary { | |
| 290 | + | "application/octet-stream" | |
| 291 | + | } else { | |
| 292 | + | "text/plain; charset=utf-8" | |
| 293 | + | }; | |
| 294 | + | Ok(( | |
| 295 | + | [ | |
| 296 | + | (header::CONTENT_TYPE, content_type), | |
| 297 | + | (header::X_CONTENT_TYPE_OPTIONS, "nosniff"), | |
| 298 | + | ], | |
| 299 | + | blob.content, | |
| 300 | + | ) | |
| 301 | + | .into_response()) | |
| 302 | + | } | |
| 303 | + | } | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | /// `DELETE /repos/{owner}/{*rest}?purge=`. | |
| 307 | + | pub async fn repo_delete( | |
| 308 | + | State(state): State<ApiState>, | |
| 309 | + | auth: ApiAuth, | |
| 310 | + | Query(params): Query<ListParams>, | |
| 311 | + | Path((owner, rest)): Path<(String, String)>, | |
| 312 | + | ) -> ApiResult<Response> { | |
| 313 | + | auth.require(Scope::RepoAdmin)?; | |
| 314 | + | let (repo_path, _) = split_rest(&rest); | |
| 315 | + | let repo = resolve_repo(&state, &auth, &owner, &repo_path, Access::Admin).await?; | |
| 316 | + | ||
| 317 | + | state.store.delete_repo(&repo.id).await?; | |
| 318 | + | if let Ok(dir) = git::repo_path(&state.config.storage.repo_dir, &repo.id) { | |
| 319 | + | if dir.exists() { | |
| 320 | + | if params.purge.unwrap_or(false) { | |
| 321 | + | let _ = std::fs::remove_dir_all(&dir); | |
| 322 | + | } else { | |
| 323 | + | let trash = state.config.storage.data_dir.join("trash").join(format!( | |
| 324 | + | "{}-{}.git", | |
| 325 | + | repo.id, | |
| 326 | + | crate::now_secs() | |
| 327 | + | )); | |
| 328 | + | if let Some(parent) = trash.parent() { | |
| 329 | + | let _ = std::fs::create_dir_all(parent); | |
| 330 | + | } | |
| 331 | + | let _ = std::fs::rename(&dir, &trash); | |
| 332 | + | } | |
| 333 | + | } | |
| 334 | + | } | |
| 335 | + | Ok(StatusCode::NO_CONTENT.into_response()) | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | /// `GET /groups/{owner}`. | |
| 339 | + | pub async fn list_groups( | |
| 340 | + | State(state): State<ApiState>, | |
| 341 | + | auth: ApiAuth, | |
| 342 | + | Path(owner): Path<String>, | |
| 343 | + | ) -> ApiResult<Response> { | |
| 344 | + | auth.require(Scope::RepoRead)?; | |
| 345 | + | let owner_user = state | |
| 346 | + | .store | |
| 347 | + | .user_by_username(&owner) | |
| 348 | + | .await? | |
| 349 | + | .ok_or(ApiError::NotFound)?; | |
| 350 | + | let groups = state.store.groups_by_owner(&owner_user.id).await?; | |
| 351 | + | let view: Vec<Value> = groups | |
| 352 | + | .iter() | |
| 353 | + | .map(|g| json!({ "name": g.name, "path": g.path, "description": g.description })) | |
| 354 | + | .collect(); | |
| 355 | + | Ok(Json(view).into_response()) | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | /// `GET /search?q=` — repos and users matching the query (name/description). | |
| 359 | + | pub async fn search( | |
| 360 | + | State(state): State<ApiState>, | |
| 361 | + | auth: ApiAuth, | |
| 362 | + | Query(params): Query<ListParams>, | |
| 363 | + | ) -> ApiResult<Response> { | |
| 364 | + | auth.require(Scope::RepoRead)?; | |
| 365 | + | let needle = params.q.unwrap_or_default().to_lowercase(); | |
| 366 | + | if needle.is_empty() { | |
| 367 | + | return Ok(Json(json!({ "repos": [], "users": [] })).into_response()); | |
| 368 | + | } | |
| 369 | + | let mut repos = Vec::new(); | |
| 370 | + | for repo in state.store.list_repos().await? { | |
| 371 | + | if repo_access(&state, &auth, &repo).await? < Access::Read { | |
| 372 | + | continue; | |
| 373 | + | } | |
| 374 | + | let owner = owner_name(&state, &repo.owner_id).await; | |
| 375 | + | let hay = format!( | |
| 376 | + | "{}/{} {}", | |
| 377 | + | owner, | |
| 378 | + | repo.path, | |
| 379 | + | repo.description.clone().unwrap_or_default() | |
| 380 | + | ) | |
| 381 | + | .to_lowercase(); | |
| 382 | + | if hay.contains(&needle) { | |
| 383 | + | repos.push(repo_json(&repo, &owner)); | |
| 384 | + | } | |
| 385 | + | } | |
| 386 | + | let users: Vec<Value> = state | |
| 387 | + | .store | |
| 388 | + | .list_users() | |
| 389 | + | .await? | |
| 390 | + | .iter() | |
| 391 | + | .filter(|u| u.username.to_lowercase().contains(&needle)) | |
| 392 | + | .map(user_json) | |
| 393 | + | .collect(); | |
| 394 | + | Ok(Json(json!({ "repos": repos, "users": users })).into_response()) | |
| 395 | + | } | |
| 396 | + | ||
| 397 | + | /// The API fallback for unmatched routes. | |
| 398 | + | pub async fn not_found() -> ApiError { | |
| 399 | + | ApiError::NotFound | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | // ---- helpers ---- | |
| 403 | + | ||
| 404 | + | /// Resolve `(owner, path)` to a repo the token may access at `required`, else 404. | |
| 405 | + | async fn resolve_repo( | |
| 406 | + | state: &ApiState, | |
| 407 | + | auth: &ApiAuth, | |
| 408 | + | owner: &str, | |
| 409 | + | repo_path: &str, | |
| 410 | + | required: Access, | |
| 411 | + | ) -> ApiResult<Repo> { | |
| 412 | + | let owner_user = state | |
| 413 | + | .store | |
| 414 | + | .user_by_username(owner) | |
| 415 | + | .await? | |
| 416 | + | .ok_or(ApiError::NotFound)?; | |
| 417 | + | let repo = state | |
| 418 | + | .store | |
| 419 | + | .repo_by_owner_path(&owner_user.id, repo_path) | |
| 420 | + | .await? | |
| 421 | + | .ok_or(ApiError::NotFound)?; | |
| 422 | + | if repo_access(state, auth, &repo).await? >= required { | |
| 423 | + | Ok(repo) | |
| 424 | + | } else { | |
| 425 | + | Err(ApiError::NotFound) // 404, never 403, for a private resource. | |
| 426 | + | } | |
| 427 | + | } | |
| 428 | + | ||
| 429 | + | /// The token's access level to a repo. | |
| 430 | + | async fn repo_access(state: &ApiState, auth: &ApiAuth, repo: &Repo) -> ApiResult<Access> { | |
| 431 | + | let collaborator = state | |
| 432 | + | .store | |
| 433 | + | .collaborator_permission(&repo.id, &auth.user.id) | |
| 434 | + | .await? | |
| 435 | + | .and_then(|p| auth::Permission::parse(&p)); | |
| 436 | + | Ok(auth::access( | |
| 437 | + | Some(&auth.viewer()), | |
| 438 | + | repo, | |
| 439 | + | collaborator, | |
| 440 | + | state.config.instance.allow_anonymous, | |
| 441 | + | )) | |
| 442 | + | } | |
| 443 | + | ||
| 444 | + | /// Resolve an owner id to a username. | |
| 445 | + | async fn owner_name(state: &ApiState, owner_id: &str) -> String { | |
| 446 | + | state | |
| 447 | + | .store | |
| 448 | + | .user_by_id(owner_id) | |
| 449 | + | .await | |
| 450 | + | .ok() | |
| 451 | + | .flatten() | |
| 452 | + | .map_or_else(|| owner_id.to_string(), |u| u.username) | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | /// Attach an `X-Total-Count` header to a JSON list response. | |
| 456 | + | fn with_total(json: Json<Vec<Value>>, total: usize) -> Response { | |
| 457 | + | ( | |
| 458 | + | [( | |
| 459 | + | header::HeaderName::from_static("x-total-count"), | |
| 460 | + | header::HeaderValue::from(u64::try_from(total).unwrap_or(0)), | |
| 461 | + | )], | |
| 462 | + | json, | |
| 463 | + | ) | |
| 464 | + | .into_response() | |
| 465 | + | } | |
| 466 | + | ||
| 467 | + | /// Render a user (never the password hash). | |
| 468 | + | fn user_json(user: &User) -> Value { | |
| 469 | + | json!({ | |
| 470 | + | "username": user.username, | |
| 471 | + | "email": user.email, | |
| 472 | + | "display_name": user.display_name, | |
| 473 | + | "is_admin": user.is_admin, | |
| 474 | + | "disabled": user.disabled_at.is_some(), | |
| 475 | + | "created_at": rfc3339(user.created_at), | |
| 476 | + | }) | |
| 477 | + | } | |
| 478 | + | ||
| 479 | + | /// Render a repo. | |
| 480 | + | fn repo_json(repo: &Repo, owner: &str) -> Value { | |
| 481 | + | json!({ | |
| 482 | + | "id": repo.id, | |
| 483 | + | "owner": owner, | |
| 484 | + | "name": repo.name, | |
| 485 | + | "path": repo.path, | |
| 486 | + | "private": repo.is_private, | |
| 487 | + | "default_branch": repo.default_branch, | |
| 488 | + | "description": repo.description, | |
| 489 | + | "archived": repo.archived_at.is_some(), | |
| 490 | + | "size_bytes": repo.size_bytes, | |
| 491 | + | "pushed_at": rfc3339_opt(repo.pushed_at), | |
| 492 | + | "created_at": rfc3339(repo.created_at), | |
| 493 | + | "updated_at": rfc3339(repo.updated_at), | |
| 494 | + | }) | |
| 495 | + | } | |
| 496 | + | ||
| 497 | + | /// Render a commit summary. | |
| 498 | + | fn commit_summary_json(c: &git::CommitSummary) -> Value { | |
| 499 | + | json!({ | |
| 500 | + | "sha": c.oid.as_str(), | |
| 501 | + | "summary": c.summary, | |
| 502 | + | "author": { "name": c.author.name, "email": c.author.email, "date": rfc3339(c.author.time_ms) }, | |
| 503 | + | "parents": c.parents, | |
| 504 | + | }) | |
| 505 | + | } | |
| 506 | + | ||
| 507 | + | /// Render a full commit. | |
| 508 | + | fn commit_detail_json(c: &git::CommitDetail) -> Value { | |
| 509 | + | json!({ | |
| 510 | + | "sha": c.oid.as_str(), | |
| 511 | + | "message": c.message, | |
| 512 | + | "tree": c.tree.as_str(), | |
| 513 | + | "parents": c.parents.iter().map(git::Oid::as_str).collect::<Vec<_>>(), | |
| 514 | + | "author": { "name": c.author.name, "email": c.author.email, "date": rfc3339(c.author.time_ms) }, | |
| 515 | + | "committer": { "name": c.committer.name, "email": c.committer.email, "date": rfc3339(c.committer.time_ms) }, | |
| 516 | + | }) | |
| 517 | + | } | |
| 518 | + | ||
| 519 | + | /// The wire name for a tree entry kind. | |
| 520 | + | fn entry_kind(kind: git::EntryKind) -> &'static str { | |
| 521 | + | match kind { | |
| 522 | + | git::EntryKind::Directory => "dir", | |
| 523 | + | git::EntryKind::File => "file", | |
| 524 | + | git::EntryKind::Symlink => "symlink", | |
| 525 | + | git::EntryKind::Submodule => "submodule", | |
| 526 | + | } | |
| 527 | + | } | |
crates/api/src/lib.rs +254 −1
| @@ -2,4 +2,257 @@ | |||
| 2 | 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this | |
| 3 | 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. | |
| 4 | 4 | ||
| 5 | - | //! The JSON API surface. | |
| 5 | + | //! The JSON API served under `/api/v1`. | |
| 6 | + | //! | |
| 7 | + | //! Bearer-JWT authenticated (`auth`), scoped, and — like the web UI — it renders | |
| 8 | + | //! `404` (never `403`) for a private resource the token cannot reach, so existence | |
| 9 | + | //! never leaks. Errors share one shape: | |
| 10 | + | //! `{"error": {"code": …, "message": …, "details": {}}}`. Timestamps are RFC 3339 | |
| 11 | + | //! strings on the wire even though they are epoch-ms at rest. The router is built | |
| 12 | + | //! by [`router`] and nested at `/api/v1` by the web server. | |
| 13 | + | ||
| 14 | + | mod handlers; | |
| 15 | + | ||
| 16 | + | use std::sync::Arc; | |
| 17 | + | use std::time::{SystemTime, UNIX_EPOCH}; | |
| 18 | + | ||
| 19 | + | use axum::extract::FromRequestParts; | |
| 20 | + | use axum::http::request::Parts; | |
| 21 | + | use axum::http::{StatusCode, header}; | |
| 22 | + | use axum::response::{IntoResponse, Response}; | |
| 23 | + | use axum::routing::get; | |
| 24 | + | use axum::{Json, Router}; | |
| 25 | + | use config::Config; | |
| 26 | + | use model::User; | |
| 27 | + | use store::Store; | |
| 28 | + | ||
| 29 | + | /// Shared state for the API router. | |
| 30 | + | #[derive(Clone)] | |
| 31 | + | pub struct ApiState { | |
| 32 | + | /// Database handle. | |
| 33 | + | pub store: Store, | |
| 34 | + | /// Effective configuration. | |
| 35 | + | pub config: Arc<Config>, | |
| 36 | + | /// The HS256 signing secret used to verify tokens. | |
| 37 | + | pub secret: Arc<String>, | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | /// Build the API router, fully stated. Nest it at `/api/v1`. | |
| 41 | + | pub fn router(store: Store, config: Arc<Config>, secret: String) -> Router { | |
| 42 | + | let state = ApiState { | |
| 43 | + | store, | |
| 44 | + | config, | |
| 45 | + | secret: Arc::new(secret), | |
| 46 | + | }; | |
| 47 | + | Router::new() | |
| 48 | + | .route("/version", get(handlers::version)) | |
| 49 | + | .route("/user", get(handlers::current_user)) | |
| 50 | + | .route("/users", get(handlers::list_users)) | |
| 51 | + | .route( | |
| 52 | + | "/repos", | |
| 53 | + | get(handlers::list_repos).post(handlers::create_repo), | |
| 54 | + | ) | |
| 55 | + | .route( | |
| 56 | + | "/repos/{owner}/{*rest}", | |
| 57 | + | get(handlers::repo_get).delete(handlers::repo_delete), | |
| 58 | + | ) | |
| 59 | + | .route("/groups/{owner}", get(handlers::list_groups)) | |
| 60 | + | .route("/search", get(handlers::search)) | |
| 61 | + | .fallback(handlers::not_found) | |
| 62 | + | .with_state(state) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | /// An API error, rendered as the shared JSON error shape. | |
| 66 | + | #[derive(Debug)] | |
| 67 | + | pub enum ApiError { | |
| 68 | + | /// 401 — missing or invalid bearer token. | |
| 69 | + | Unauthorized(&'static str), | |
| 70 | + | /// 403 — the token lacks the required scope. | |
| 71 | + | Forbidden(&'static str), | |
| 72 | + | /// 404 — missing, or a private resource the token cannot reach. | |
| 73 | + | NotFound, | |
| 74 | + | /// 400 — a malformed request. | |
| 75 | + | BadRequest(String), | |
| 76 | + | /// 409 — a uniqueness conflict. | |
| 77 | + | Conflict(String), | |
| 78 | + | /// 500 — an internal failure (detail logged, not shown). | |
| 79 | + | Internal(String), | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | impl ApiError { | |
| 83 | + | /// Build an internal error, logging the detail. | |
| 84 | + | pub fn internal(detail: impl std::fmt::Display) -> Self { | |
| 85 | + | Self::Internal(detail.to_string()) | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | impl From<store::StoreError> for ApiError { | |
| 90 | + | fn from(err: store::StoreError) -> Self { | |
| 91 | + | match err { | |
| 92 | + | store::StoreError::Conflict { entity, field } => { | |
| 93 | + | Self::Conflict(format!("{entity} already exists with that {field}")) | |
| 94 | + | } | |
| 95 | + | store::StoreError::Name(e) => Self::BadRequest(e.to_string()), | |
| 96 | + | other => Self::internal(other), | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | impl From<git::GitError> for ApiError { | |
| 102 | + | fn from(err: git::GitError) -> Self { | |
| 103 | + | Self::internal(err) | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | impl IntoResponse for ApiError { | |
| 108 | + | fn into_response(self) -> Response { | |
| 109 | + | let (status, code, message) = match self { | |
| 110 | + | ApiError::Unauthorized(m) => (StatusCode::UNAUTHORIZED, "unauthorized", m.to_string()), | |
| 111 | + | ApiError::Forbidden(m) => (StatusCode::FORBIDDEN, "forbidden", m.to_string()), | |
| 112 | + | ApiError::NotFound => (StatusCode::NOT_FOUND, "not_found", "not found".to_string()), | |
| 113 | + | ApiError::BadRequest(m) => (StatusCode::BAD_REQUEST, "bad_request", m), | |
| 114 | + | ApiError::Conflict(m) => (StatusCode::CONFLICT, "conflict", m), | |
| 115 | + | ApiError::Internal(detail) => { | |
| 116 | + | tracing::error!(error.detail = %detail, "api internal error"); | |
| 117 | + | ( | |
| 118 | + | StatusCode::INTERNAL_SERVER_ERROR, | |
| 119 | + | "internal", | |
| 120 | + | "internal error".to_string(), | |
| 121 | + | ) | |
| 122 | + | } | |
| 123 | + | }; | |
| 124 | + | let body = Json(serde_json::json!({ | |
| 125 | + | "error": { "code": code, "message": message, "details": {} } | |
| 126 | + | })); | |
| 127 | + | let mut response = (status, body).into_response(); | |
| 128 | + | if status == StatusCode::UNAUTHORIZED { | |
| 129 | + | response.headers_mut().insert( | |
| 130 | + | header::WWW_AUTHENTICATE, | |
| 131 | + | header::HeaderValue::from_static("Bearer"), | |
| 132 | + | ); | |
| 133 | + | } | |
| 134 | + | response | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | /// A result alias for API handlers. | |
| 139 | + | pub type ApiResult<T> = Result<T, ApiError>; | |
| 140 | + | ||
| 141 | + | /// The authenticated token identity, extracted from the `Authorization: Bearer` | |
| 142 | + | /// header. Verifies the signature and expiry, that the `jti` row exists and is not | |
| 143 | + | /// revoked, and that the user is enabled; bumps `last_used_at`. | |
| 144 | + | pub struct ApiAuth { | |
| 145 | + | /// The token's user. | |
| 146 | + | pub user: User, | |
| 147 | + | /// The token's scopes. | |
| 148 | + | pub scopes: Vec<auth::Scope>, | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | impl ApiAuth { | |
| 152 | + | /// Require a scope; `admin` grants everything. | |
| 153 | + | /// | |
| 154 | + | /// # Errors | |
| 155 | + | /// | |
| 156 | + | /// Returns [`ApiError::Forbidden`] if the scope is absent. | |
| 157 | + | pub fn require(&self, scope: auth::Scope) -> ApiResult<()> { | |
| 158 | + | if self.scopes.contains(&scope) || self.scopes.contains(&auth::Scope::Admin) { | |
| 159 | + | Ok(()) | |
| 160 | + | } else { | |
| 161 | + | Err(ApiError::Forbidden("missing required scope")) | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | /// A [`auth::Viewer`] for permission checks. | |
| 166 | + | #[must_use] | |
| 167 | + | pub fn viewer(&self) -> auth::Viewer { | |
| 168 | + | auth::Viewer { | |
| 169 | + | id: self.user.id.clone(), | |
| 170 | + | is_admin: self.user.is_admin, | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | impl FromRequestParts<ApiState> for ApiAuth { | |
| 176 | + | type Rejection = ApiError; | |
| 177 | + | ||
| 178 | + | async fn from_request_parts( | |
| 179 | + | parts: &mut Parts, | |
| 180 | + | state: &ApiState, | |
| 181 | + | ) -> Result<Self, Self::Rejection> { | |
| 182 | + | let token = parts | |
| 183 | + | .headers | |
| 184 | + | .get(header::AUTHORIZATION) | |
| 185 | + | .and_then(|v| v.to_str().ok()) | |
| 186 | + | .and_then(|v| v.strip_prefix("Bearer ")) | |
| 187 | + | .ok_or(ApiError::Unauthorized("missing bearer token"))?; | |
| 188 | + | ||
| 189 | + | let claims = auth::verify_jwt(&state.secret, token.trim(), now_secs()) | |
| 190 | + | .map_err(|_| ApiError::Unauthorized("invalid or expired token"))?; | |
| 191 | + | ||
| 192 | + | // Revocation is real only because we check the jti row here. | |
| 193 | + | let row = state | |
| 194 | + | .store | |
| 195 | + | .token_by_id(&claims.jti) | |
| 196 | + | .await? | |
| 197 | + | .ok_or(ApiError::Unauthorized("token revoked"))?; | |
| 198 | + | if row.revoked_at.is_some() { | |
| 199 | + | return Err(ApiError::Unauthorized("token revoked")); | |
| 200 | + | } | |
| 201 | + | let user = state | |
| 202 | + | .store | |
| 203 | + | .user_by_id(&claims.sub) | |
| 204 | + | .await? | |
| 205 | + | .ok_or(ApiError::Unauthorized("no such user"))?; | |
| 206 | + | if user.disabled_at.is_some() { | |
| 207 | + | return Err(ApiError::Unauthorized("account disabled")); | |
| 208 | + | } | |
| 209 | + | let _ = state.store.touch_token(&claims.jti).await; | |
| 210 | + | ||
| 211 | + | Ok(Self { | |
| 212 | + | user, | |
| 213 | + | scopes: auth::parse_scopes(&claims.scopes).unwrap_or_default(), | |
| 214 | + | }) | |
| 215 | + | } | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | /// The current time in Unix seconds. | |
| 219 | + | pub(crate) fn now_secs() -> i64 { | |
| 220 | + | SystemTime::now() | |
| 221 | + | .duration_since(UNIX_EPOCH) | |
| 222 | + | .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)) | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | /// Format an epoch-millisecond timestamp as an RFC 3339 string, or `null` on | |
| 226 | + | /// overflow. | |
| 227 | + | pub(crate) fn rfc3339(ms: i64) -> serde_json::Value { | |
| 228 | + | let nanos = i128::from(ms) * 1_000_000; | |
| 229 | + | match time::OffsetDateTime::from_unix_timestamp_nanos(nanos) { | |
| 230 | + | Ok(dt) => dt | |
| 231 | + | .format(&time::format_description::well_known::Rfc3339) | |
| 232 | + | .map_or(serde_json::Value::Null, serde_json::Value::String), | |
| 233 | + | Err(_) => serde_json::Value::Null, | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | /// Format an optional timestamp. | |
| 238 | + | pub(crate) fn rfc3339_opt(ms: Option<i64>) -> serde_json::Value { | |
| 239 | + | ms.map_or(serde_json::Value::Null, rfc3339) | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | /// Run a blocking git read for a repo on the blocking pool. | |
| 243 | + | pub(crate) async fn git_read<T, F>(state: &ApiState, repo_id: &str, f: F) -> ApiResult<T> | |
| 244 | + | where | |
| 245 | + | F: FnOnce(&git::Repo) -> Result<T, git::GitError> + Send + 'static, | |
| 246 | + | T: Send + 'static, | |
| 247 | + | { | |
| 248 | + | let repo_dir = state.config.storage.repo_dir.clone(); | |
| 249 | + | let repo_id = repo_id.to_string(); | |
| 250 | + | tokio::task::spawn_blocking(move || { | |
| 251 | + | let path = git::repo_path(&repo_dir, &repo_id)?; | |
| 252 | + | let repo = git::Repo::open_path(&path)?; | |
| 253 | + | f(&repo) | |
| 254 | + | }) | |
| 255 | + | .await | |
| 256 | + | .map_err(ApiError::internal)? | |
| 257 | + | .map_err(ApiError::from) | |
| 258 | + | } | |
crates/web/Cargo.toml +1 −0
| @@ -16,6 +16,7 @@ model = { workspace = true } | |||
| 16 | 16 | auth = { workspace = true } | |
| 17 | 17 | mail = { workspace = true } | |
| 18 | 18 | git = { workspace = true } | |
| 19 | + | api = { workspace = true } | |
| 19 | 20 | highlight = { workspace = true } | |
| 20 | 21 | axum = { workspace = true } | |
| 21 | 22 | axum-extra = { workspace = true } | |
crates/web/src/lib.rs +8 −0
| @@ -104,7 +104,15 @@ impl AppState { | |||
| 104 | 104 | pub fn build_router(state: AppState) -> Router { | |
| 105 | 105 | let x_request_id = HeaderName::from_static("x-request-id"); | |
| 106 | 106 | ||
| 107 | + | // The JSON API is a self-contained, fully-stated router nested under /api/v1. | |
| 108 | + | let api = api::router( | |
| 109 | + | state.store.clone(), | |
| 110 | + | Arc::clone(&state.config), | |
| 111 | + | (*state.secret).clone(), | |
| 112 | + | ); | |
| 113 | + | ||
| 107 | 114 | Router::new() | |
| 115 | + | .nest_service("/api/v1", api) | |
| 108 | 116 | .route("/", get(pages::home)) | |
| 109 | 117 | .route("/login", get(pages::login_form).post(pages::login_submit)) | |
| 110 | 118 | .route("/logout", post(pages::logout)) | |
docs/decisions.md +19 −0
| @@ -65,6 +65,25 @@ file. There is no `server start` / `server stop` pair. | |||
| 65 | 65 | **Rationale:** Foreground is correct for systemd and Docker, which own process | |
| 66 | 66 | lifecycle. This deviates from an earlier `server start`/`server stop` sketch. | |
| 67 | 67 | ||
| 68 | + | ## 2026-07-25 — API nested into web via `nest_service`; web may depend on api | |
| 69 | + | ||
| 70 | + | **Decision:** The `/api/v1` JSON surface is a self-contained `Router` built by | |
| 71 | + | `api::router(store, config, secret)` with its own state applied, and `web` nests it | |
| 72 | + | with `.nest_service("/api/v1", …)`. `web` depends on `api`. | |
| 73 | + | ||
| 74 | + | **Alternatives:** Run the API on a separate port/server; have `api` depend on `web` | |
| 75 | + | (forbidden — nothing may depend on `web`). | |
| 76 | + | ||
| 77 | + | **Rationale:** The dependency rule bars anything depending on `web`, but says | |
| 78 | + | nothing against `web → api` (both are top-level). Nesting keeps one server, one | |
| 79 | + | port, and one middleware stack. Because the nested router is fully stated | |
| 80 | + | (`Router<()>`), `nest_service` mounts it as a `Service` and strips the `/api/v1` | |
| 81 | + | prefix, so `api` defines routes relative to root and never learns `web`'s | |
| 82 | + | `AppState`. Repo sub-resources are parsed by scanning `{*rest}` for the first | |
| 83 | + | keyword segment (`branches`/`tags`/`commits`/`tree`/`raw`); a repo named exactly | |
| 84 | + | after one of those would be misrouted — a documented MVP limitation. The API | |
| 85 | + | search returns name/description matches only (content search stays in `web`). | |
| 86 | + | ||
| 68 | 87 | ## 2026-07-25 — Search lives in `web`; `path:` is a substring for the MVP | |
| 69 | 88 | ||
| 70 | 89 | **Decision:** Search (the qualifier parser, in-repo scan, and bounded global scan) | |