fabrica

hanna/fabrica

17238 bytes
Raw
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
7use std::path::Path as FsPath;
8
9use auth::{Access, Scope};
10use axum::Json;
11use axum::extract::{Path, Query, State};
12use axum::http::{StatusCode, header};
13use axum::response::{IntoResponse, Response};
14use model::{Repo, User};
15use serde::Deserialize;
16use serde_json::{Value, json};
17
18use crate::{ApiAuth, ApiError, ApiResult, ApiState, git_read, rfc3339, rfc3339_opt};
19
20/// Pagination + ref query parameters.
21#[derive(Debug, Default, Deserialize)]
22pub 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
33impl 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`.
43pub 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.
51pub async fn current_user(auth: ApiAuth) -> ApiResult<Json<Value>> {
52 Ok(Json(user_json(&auth.user)))
53}
54
55/// `GET /users` — admin only.
56pub 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.
71pub 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)]
95pub 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`.
107pub 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 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/// A parsed repo sub-resource.
165enum Sub {
166 Detail,
167 Branches,
168 Tags,
169 Commits,
170 Commit(String),
171 Tree(String, String),
172 Raw(String, String),
173}
174
175/// Split `{*rest}` into the repo path and its sub-resource. The first segment that
176/// is a known keyword ends the repo path (repos should not be named after these
177/// keywords — a documented MVP limitation).
178fn 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/// `GET /repos/{owner}/{*rest}` — repo detail or a git sub-resource.
207pub 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/// `DELETE /repos/{owner}/{*rest}?purge=`.
311pub 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/// `GET /groups/{owner}`.
343pub 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/// `GET /search?q=` — repos and users matching the query (name/description).
363pub 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/// The API fallback for unmatched routes.
402pub async fn not_found() -> ApiError {
403 ApiError::NotFound
404}
405
406// ---- helpers ----
407
408/// Resolve `(owner, path)` to a repo the token may access at `required`, else 404.
409async 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) // 404, never 403, for a private resource.
430 }
431}
432
433/// The token's access level to a repo.
434async 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/// Resolve an owner id to a username.
449async 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/// Attach an `X-Total-Count` header to a JSON list response.
460fn 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/// Render a user (never the password hash).
472fn 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/// Render a repo.
484fn 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/// Render a commit summary.
503fn 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/// Render a full commit.
513fn 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/// The wire name for a tree entry kind.
525fn 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}