| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 51 | pub async fn current_user(auth: ApiAuth) -> ApiResult<Json<Value>> { |
| 52 | Ok(Json(user_json(&auth.user))) |
| 53 | } |
| 54 | |
| 55 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | visibility: if body.private.unwrap_or(true) { |
| 147 | model::Visibility::Private |
| 148 | } else { |
| 149 | model::Visibility::Public |
| 150 | }, |
| 151 | default_branch: branch.clone(), |
| 152 | }) |
| 153 | .await?; |
| 154 | |
| 155 | let hook = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("fabrica")); |
| 156 | if let Err(err) = git::create_bare(&state.config.storage.repo_dir, &repo.id, &branch, &hook) { |
| 157 | let _ = state.store.delete_repo(&repo.id).await; |
| 158 | return Err(ApiError::internal(err)); |
| 159 | } |
| 160 | |
| 161 | Ok((StatusCode::CREATED, Json(repo_json(&repo, &owner.username))).into_response()) |
| 162 | } |
| 163 | |
| 164 | |
| 165 | enum Sub { |
| 166 | Detail, |
| 167 | Branches, |
| 168 | Tags, |
| 169 | Commits, |
| 170 | Commit(String), |
| 171 | Tree(String, String), |
| 172 | Raw(String, String), |
| 173 | } |
| 174 | |
| 175 | |
| 176 | |
| 177 | |
| 178 | fn split_rest(rest: &str) -> (String, Sub) { |
| 179 | let segs: Vec<&str> = rest.split('/').filter(|s| !s.is_empty()).collect(); |
| 180 | let keyword = segs |
| 181 | .iter() |
| 182 | .position(|s| matches!(*s, "branches" | "tags" | "commits" | "tree" | "raw")); |
| 183 | let Some(i) = keyword else { |
| 184 | return (segs.join("/"), Sub::Detail); |
| 185 | }; |
| 186 | let repo_path = segs[..i].join("/"); |
| 187 | let rest = &segs[i + 1..]; |
| 188 | let sub = match segs[i] { |
| 189 | "branches" => Sub::Branches, |
| 190 | "tags" => Sub::Tags, |
| 191 | "commits" => rest |
| 192 | .first() |
| 193 | .map_or(Sub::Commits, |sha| Sub::Commit((*sha).to_string())), |
| 194 | "tree" => Sub::Tree( |
| 195 | rest.first().copied().unwrap_or_default().to_string(), |
| 196 | rest.get(1..).map(|s| s.join("/")).unwrap_or_default(), |
| 197 | ), |
| 198 | _ => Sub::Raw( |
| 199 | rest.first().copied().unwrap_or_default().to_string(), |
| 200 | rest.get(1..).map(|s| s.join("/")).unwrap_or_default(), |
| 201 | ), |
| 202 | }; |
| 203 | (repo_path, sub) |
| 204 | } |
| 205 | |
| 206 | |
| 207 | pub async fn repo_get( |
| 208 | State(state): State<ApiState>, |
| 209 | auth: ApiAuth, |
| 210 | Query(params): Query<ListParams>, |
| 211 | Path((owner, rest)): Path<(String, String)>, |
| 212 | ) -> ApiResult<Response> { |
| 213 | auth.require(Scope::RepoRead)?; |
| 214 | let (repo_path, sub) = split_rest(&rest); |
| 215 | let repo = resolve_repo(&state, &auth, &owner, &repo_path, Access::Read).await?; |
| 216 | let default_ref = repo.default_branch.clone(); |
| 217 | |
| 218 | match sub { |
| 219 | Sub::Detail => Ok(Json(repo_json(&repo, &owner)).into_response()), |
| 220 | Sub::Branches => { |
| 221 | let branches = git_read(&state, &repo.id, git::Repo::branches).await?; |
| 222 | let view: Vec<Value> = branches |
| 223 | .iter() |
| 224 | .map(|b| { |
| 225 | json!({ |
| 226 | "name": b.name, "commit": b.oid.as_str(), "default": b.is_default, |
| 227 | "ahead": b.ahead, "behind": b.behind, |
| 228 | }) |
| 229 | }) |
| 230 | .collect(); |
| 231 | Ok(Json(view).into_response()) |
| 232 | } |
| 233 | Sub::Tags => { |
| 234 | let tags = git_read(&state, &repo.id, git::Repo::tags).await?; |
| 235 | let view: Vec<Value> = tags |
| 236 | .iter() |
| 237 | .map(|t| json!({ "name": t.name, "commit": t.oid.as_str(), "annotated": t.annotated })) |
| 238 | .collect(); |
| 239 | Ok(Json(view).into_response()) |
| 240 | } |
| 241 | Sub::Commits => { |
| 242 | let (offset, limit) = params.page(); |
| 243 | let reference = params.reference.unwrap_or(default_ref); |
| 244 | let commits = git_read(&state, &repo.id, move |repo| { |
| 245 | let rev = repo.resolve_ref(&reference)?; |
| 246 | repo.commits(&rev, None, git::Page { offset, limit }) |
| 247 | }) |
| 248 | .await?; |
| 249 | let view: Vec<Value> = commits.iter().map(commit_summary_json).collect(); |
| 250 | Ok(Json(view).into_response()) |
| 251 | } |
| 252 | Sub::Commit(sha) => { |
| 253 | let detail = git_read(&state, &repo.id, move |repo| { |
| 254 | repo.commit(&git::Oid::new(sha)) |
| 255 | }) |
| 256 | .await?; |
| 257 | Ok(Json(commit_detail_json(&detail)).into_response()) |
| 258 | } |
| 259 | Sub::Tree(reference, path) => { |
| 260 | let reference = if reference.is_empty() { |
| 261 | default_ref |
| 262 | } else { |
| 263 | reference |
| 264 | }; |
| 265 | let entries = git_read(&state, &repo.id, move |repo| { |
| 266 | let rev = repo.resolve_ref(&reference)?; |
| 267 | repo.tree_entries(&rev, FsPath::new(&path)) |
| 268 | }) |
| 269 | .await?; |
| 270 | let view: Vec<Value> = entries |
| 271 | .iter() |
| 272 | .map(|e| { |
| 273 | json!({ |
| 274 | "name": e.name, "kind": entry_kind(e.kind), |
| 275 | "mode": format!("{:o}", e.mode), "oid": e.oid.as_str(), "size": e.size, |
| 276 | }) |
| 277 | }) |
| 278 | .collect(); |
| 279 | Ok(Json(view).into_response()) |
| 280 | } |
| 281 | Sub::Raw(reference, path) => { |
| 282 | let reference = if reference.is_empty() { |
| 283 | default_ref |
| 284 | } else { |
| 285 | reference |
| 286 | }; |
| 287 | let path2 = path.clone(); |
| 288 | let blob = git_read(&state, &repo.id, move |repo| { |
| 289 | let rev = repo.resolve_ref(&reference)?; |
| 290 | repo.blob(&rev, FsPath::new(&path2)) |
| 291 | }) |
| 292 | .await?; |
| 293 | let content_type = if blob.is_binary { |
| 294 | "application/octet-stream" |
| 295 | } else { |
| 296 | "text/plain; charset=utf-8" |
| 297 | }; |
| 298 | Ok(( |
| 299 | [ |
| 300 | (header::CONTENT_TYPE, content_type), |
| 301 | (header::X_CONTENT_TYPE_OPTIONS, "nosniff"), |
| 302 | ], |
| 303 | blob.content, |
| 304 | ) |
| 305 | .into_response()) |
| 306 | } |
| 307 | } |
| 308 | } |
| 309 | |
| 310 | |
| 311 | pub async fn repo_delete( |
| 312 | State(state): State<ApiState>, |
| 313 | auth: ApiAuth, |
| 314 | Query(params): Query<ListParams>, |
| 315 | Path((owner, rest)): Path<(String, String)>, |
| 316 | ) -> ApiResult<Response> { |
| 317 | auth.require(Scope::RepoAdmin)?; |
| 318 | let (repo_path, _) = split_rest(&rest); |
| 319 | let repo = resolve_repo(&state, &auth, &owner, &repo_path, Access::Admin).await?; |
| 320 | |
| 321 | state.store.delete_repo(&repo.id).await?; |
| 322 | if let Ok(dir) = git::repo_path(&state.config.storage.repo_dir, &repo.id) { |
| 323 | if dir.exists() { |
| 324 | if params.purge.unwrap_or(false) { |
| 325 | let _ = std::fs::remove_dir_all(&dir); |
| 326 | } else { |
| 327 | let trash = state.config.storage.data_dir.join("trash").join(format!( |
| 328 | "{}-{}.git", |
| 329 | repo.id, |
| 330 | crate::now_secs() |
| 331 | )); |
| 332 | if let Some(parent) = trash.parent() { |
| 333 | let _ = std::fs::create_dir_all(parent); |
| 334 | } |
| 335 | let _ = std::fs::rename(&dir, &trash); |
| 336 | } |
| 337 | } |
| 338 | } |
| 339 | Ok(StatusCode::NO_CONTENT.into_response()) |
| 340 | } |
| 341 | |
| 342 | |
| 343 | pub async fn list_groups( |
| 344 | State(state): State<ApiState>, |
| 345 | auth: ApiAuth, |
| 346 | Path(owner): Path<String>, |
| 347 | ) -> ApiResult<Response> { |
| 348 | auth.require(Scope::RepoRead)?; |
| 349 | let owner_user = state |
| 350 | .store |
| 351 | .user_by_username(&owner) |
| 352 | .await? |
| 353 | .ok_or(ApiError::NotFound)?; |
| 354 | let groups = state.store.groups_by_owner(&owner_user.id).await?; |
| 355 | let view: Vec<Value> = groups |
| 356 | .iter() |
| 357 | .map(|g| json!({ "name": g.name, "path": g.path, "description": g.description })) |
| 358 | .collect(); |
| 359 | Ok(Json(view).into_response()) |
| 360 | } |
| 361 | |
| 362 | |
| 363 | pub async fn search( |
| 364 | State(state): State<ApiState>, |
| 365 | auth: ApiAuth, |
| 366 | Query(params): Query<ListParams>, |
| 367 | ) -> ApiResult<Response> { |
| 368 | auth.require(Scope::RepoRead)?; |
| 369 | let needle = params.q.unwrap_or_default().to_lowercase(); |
| 370 | if needle.is_empty() { |
| 371 | return Ok(Json(json!({ "repos": [], "users": [] })).into_response()); |
| 372 | } |
| 373 | let mut repos = Vec::new(); |
| 374 | for repo in state.store.list_repos().await? { |
| 375 | if repo_access(&state, &auth, &repo).await? < Access::Read { |
| 376 | continue; |
| 377 | } |
| 378 | let owner = owner_name(&state, &repo.owner_id).await; |
| 379 | let hay = format!( |
| 380 | "{}/{} {}", |
| 381 | owner, |
| 382 | repo.path, |
| 383 | repo.description.clone().unwrap_or_default() |
| 384 | ) |
| 385 | .to_lowercase(); |
| 386 | if hay.contains(&needle) { |
| 387 | repos.push(repo_json(&repo, &owner)); |
| 388 | } |
| 389 | } |
| 390 | let users: Vec<Value> = state |
| 391 | .store |
| 392 | .list_users() |
| 393 | .await? |
| 394 | .iter() |
| 395 | .filter(|u| u.username.to_lowercase().contains(&needle)) |
| 396 | .map(user_json) |
| 397 | .collect(); |
| 398 | Ok(Json(json!({ "repos": repos, "users": users })).into_response()) |
| 399 | } |
| 400 | |
| 401 | |
| 402 | pub async fn not_found() -> ApiError { |
| 403 | ApiError::NotFound |
| 404 | } |
| 405 | |
| 406 | |
| 407 | |
| 408 | |
| 409 | async fn resolve_repo( |
| 410 | state: &ApiState, |
| 411 | auth: &ApiAuth, |
| 412 | owner: &str, |
| 413 | repo_path: &str, |
| 414 | required: Access, |
| 415 | ) -> ApiResult<Repo> { |
| 416 | let owner_user = state |
| 417 | .store |
| 418 | .user_by_username(owner) |
| 419 | .await? |
| 420 | .ok_or(ApiError::NotFound)?; |
| 421 | let repo = state |
| 422 | .store |
| 423 | .repo_by_owner_path(&owner_user.id, repo_path) |
| 424 | .await? |
| 425 | .ok_or(ApiError::NotFound)?; |
| 426 | if repo_access(state, auth, &repo).await? >= required { |
| 427 | Ok(repo) |
| 428 | } else { |
| 429 | Err(ApiError::NotFound) |
| 430 | } |
| 431 | } |
| 432 | |
| 433 | |
| 434 | async fn repo_access(state: &ApiState, auth: &ApiAuth, repo: &Repo) -> ApiResult<Access> { |
| 435 | let collaborator = state |
| 436 | .store |
| 437 | .collaborator_permission(&repo.id, &auth.user.id) |
| 438 | .await? |
| 439 | .and_then(|p| auth::Permission::parse(&p)); |
| 440 | Ok(auth::access( |
| 441 | Some(&auth.viewer()), |
| 442 | repo, |
| 443 | collaborator, |
| 444 | state.config.instance.allow_anonymous, |
| 445 | )) |
| 446 | } |
| 447 | |
| 448 | |
| 449 | async fn owner_name(state: &ApiState, owner_id: &str) -> String { |
| 450 | state |
| 451 | .store |
| 452 | .user_by_id(owner_id) |
| 453 | .await |
| 454 | .ok() |
| 455 | .flatten() |
| 456 | .map_or_else(|| owner_id.to_string(), |u| u.username) |
| 457 | } |
| 458 | |
| 459 | |
| 460 | fn with_total(json: Json<Vec<Value>>, total: usize) -> Response { |
| 461 | ( |
| 462 | [( |
| 463 | header::HeaderName::from_static("x-total-count"), |
| 464 | header::HeaderValue::from(u64::try_from(total).unwrap_or(0)), |
| 465 | )], |
| 466 | json, |
| 467 | ) |
| 468 | .into_response() |
| 469 | } |
| 470 | |
| 471 | |
| 472 | fn user_json(user: &User) -> Value { |
| 473 | json!({ |
| 474 | "username": user.username, |
| 475 | "email": user.email, |
| 476 | "display_name": user.display_name, |
| 477 | "is_admin": user.is_admin, |
| 478 | "disabled": user.disabled_at.is_some(), |
| 479 | "created_at": rfc3339(user.created_at), |
| 480 | }) |
| 481 | } |
| 482 | |
| 483 | |
| 484 | fn repo_json(repo: &Repo, owner: &str) -> Value { |
| 485 | json!({ |
| 486 | "id": repo.id, |
| 487 | "owner": owner, |
| 488 | "name": repo.name, |
| 489 | "path": repo.path, |
| 490 | "private": repo.visibility.is_private(), |
| 491 | "visibility": repo.visibility.as_str(), |
| 492 | "default_branch": repo.default_branch, |
| 493 | "description": repo.description, |
| 494 | "archived": repo.archived_at.is_some(), |
| 495 | "size_bytes": repo.size_bytes, |
| 496 | "pushed_at": rfc3339_opt(repo.pushed_at), |
| 497 | "created_at": rfc3339(repo.created_at), |
| 498 | "updated_at": rfc3339(repo.updated_at), |
| 499 | }) |
| 500 | } |
| 501 | |
| 502 | |
| 503 | fn commit_summary_json(c: &git::CommitSummary) -> Value { |
| 504 | json!({ |
| 505 | "sha": c.oid.as_str(), |
| 506 | "summary": c.summary, |
| 507 | "author": { "name": c.author.name, "email": c.author.email, "date": rfc3339(c.author.time_ms) }, |
| 508 | "parents": c.parents, |
| 509 | }) |
| 510 | } |
| 511 | |
| 512 | |
| 513 | fn commit_detail_json(c: &git::CommitDetail) -> Value { |
| 514 | json!({ |
| 515 | "sha": c.oid.as_str(), |
| 516 | "message": c.message, |
| 517 | "tree": c.tree.as_str(), |
| 518 | "parents": c.parents.iter().map(git::Oid::as_str).collect::<Vec<_>>(), |
| 519 | "author": { "name": c.author.name, "email": c.author.email, "date": rfc3339(c.author.time_ms) }, |
| 520 | "committer": { "name": c.committer.name, "email": c.committer.email, "date": rfc3339(c.committer.time_ms) }, |
| 521 | }) |
| 522 | } |
| 523 | |
| 524 | |
| 525 | fn entry_kind(kind: git::EntryKind) -> &'static str { |
| 526 | match kind { |
| 527 | git::EntryKind::Directory => "dir", |
| 528 | git::EntryKind::File => "file", |
| 529 | git::EntryKind::Symlink => "symlink", |
| 530 | git::EntryKind::Submodule => "submodule", |
| 531 | } |
| 532 | } |