Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
Cargo.lock +14 −0
| @@ -198,6 +198,19 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" | |||
| 198 | [[package]] | 198 | [[package]] |
| 199 | name = "api" | 199 | name = "api" |
| 200 | version = "0.1.0" | 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 | [[package]] | 215 | [[package]] |
| 203 | name = "argon2" | 216 | name = "argon2" |
| @@ -5570,6 +5583,7 @@ name = "web" | |||
| 5570 | version = "0.1.0" | 5583 | version = "0.1.0" |
| 5571 | dependencies = [ | 5584 | dependencies = [ |
| 5572 | "ammonia", | 5585 | "ammonia", |
| 5586 | "api", | ||
| 5573 | "auth", | 5587 | "auth", |
| 5574 | "axum", | 5588 | "axum", |
| 5575 | "axum-extra", | 5589 | "axum-extra", |
crates/api/Cargo.toml +11 −0
| @@ -10,6 +10,17 @@ repository.workspace = true | |||
| 10 | publish.workspace = true | 10 | publish.workspace = true |
| 11 | 11 | ||
| 12 | [dependencies] | 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 | [lints] | 25 | [lints] |
| 15 | workspace = true | 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 | // License, v. 2.0. If a copy of the MPL was not distributed with this | 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/. | 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 | auth = { workspace = true } | 16 | auth = { workspace = true } |
| 17 | mail = { workspace = true } | 17 | mail = { workspace = true } |
| 18 | git = { workspace = true } | 18 | git = { workspace = true } |
| 19 | api = { workspace = true } | ||
| 19 | highlight = { workspace = true } | 20 | highlight = { workspace = true } |
| 20 | axum = { workspace = true } | 21 | axum = { workspace = true } |
| 21 | axum-extra = { workspace = true } | 22 | axum-extra = { workspace = true } |
crates/web/src/lib.rs +8 −0
| @@ -104,7 +104,15 @@ impl AppState { | |||
| 104 | pub fn build_router(state: AppState) -> Router { | 104 | pub fn build_router(state: AppState) -> Router { |
| 105 | let x_request_id = HeaderName::from_static("x-request-id"); | 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 | Router::new() | 114 | Router::new() |
| 115 | .nest_service("/api/v1", api) | ||
| 108 | .route("/", get(pages::home)) | 116 | .route("/", get(pages::home)) |
| 109 | .route("/login", get(pages::login_form).post(pages::login_submit)) | 117 | .route("/login", get(pages::login_form).post(pages::login_submit)) |
| 110 | .route("/logout", post(pages::logout)) | 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 | **Rationale:** Foreground is correct for systemd and Docker, which own process | 65 | **Rationale:** Foreground is correct for systemd and Docker, which own process |
| 66 | lifecycle. This deviates from an earlier `server start`/`server stop` sketch. | 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 | ## 2026-07-25 — Search lives in `web`; `path:` is a substring for the MVP | 87 | ## 2026-07-25 — Search lives in `web`; `path:` is a substring for the MVP |
| 69 | 88 | ||
| 70 | **Decision:** Search (the qualifier parser, in-repo scan, and bounded global scan) | 89 | **Decision:** Search (the qualifier parser, in-repo scan, and bounded global scan) |