//! The JSON API (hub spec §12.1) under `/api/v1`: bearer tokens, RFC 7807 //! problems, the same shapes as `oxy --json` where one exists. Reads need //! `repo:read` (anonymous for public repositories); writes the scope named //! on each handler. use crate::auth::{self, Access, Role}; use crate::db::{self, accounts::Account}; use crate::repos::{view, RepoError, RepoName}; use crate::Shared; use axum::extract::{Path, Query, State}; use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use axum::{Json, Router}; use hub_api::*; use serde::Deserialize; pub fn router() -> Router { Router::new() .route("/flow/claim", post(crate::flow::claim)) .route("/flow/jobs/{id}/log", post(crate::flow::log)) .route("/flow/jobs/{id}/finish", post(crate::flow::finish)) .route("/user", get(me)) .route("/users/{name}", get(user)) .route("/orgs/{name}", get(org)) .route("/search", get(search)) .route("/repos", get(list_repos).post(create_repo)) .route("/repos/{owner}/{name}", get(get_repo)) .route("/repos/{owner}/{name}/bookmarks", get(bookmarks)) .route("/repos/{owner}/{name}/tags", get(tags)) .route("/repos/{owner}/{name}/history/{r}", get(history)) .route("/repos/{owner}/{name}/changes/{hash}", get(change)) .route("/repos/{owner}/{name}/tree/{r}", get(tree_root)) .route("/repos/{owner}/{name}/tree/{r}/{*path}", get(tree)) .route("/repos/{owner}/{name}/file/{r}/{*path}", get(file)) .route("/repos/{owner}/{name}/blame/{r}/{*path}", get(blame)) .route("/repos/{owner}/{name}/conflicts", get(conflicts)) .route("/repos/{owner}/{name}/trust", get(trust)) .route("/repos/{owner}/{name}/pushes", get(pushes)) .route("/repos/{owner}/{name}/proposals", get(proposals).post(create_proposal)) .route("/repos/{owner}/{name}/proposals/{number}", get(proposal)) .route("/repos/{owner}/{name}/proposals/{number}/comments", get(proposal_comments).post(proposal_comment)) .route("/repos/{owner}/{name}/proposals/{number}/reviews", get(proposal_reviews).post(proposal_review)) .route("/repos/{owner}/{name}/proposals/{number}/checks", get(proposal_checks)) .route("/repos/{owner}/{name}/issues", get(issues).post(create_issue)) .route("/repos/{owner}/{name}/issues/{number}", get(issue).post(issue_state)) .route("/repos/{owner}/{name}/issues/{number}/comments", get(issue_comments).post(issue_comment)) .route("/repos/{owner}/{name}/labels", get(labels)) .route("/repos/{owner}/{name}/milestones", get(milestones)) .route("/repos/{owner}/{name}/hooks", get(hooks).post(create_hook)) .route("/repos/{owner}/{name}/hooks/{id}/delete", post(delete_hook)) .route("/repos/{owner}/{name}/checks", post(post_check)) .route("/repos/{owner}/{name}/checks/{state}", get(checks_for_state)) .route("/repos/{owner}/{name}/releases", get(releases).post(create_release)) .route("/repos/{owner}/{name}/releases/{tag}/assets", post(upload_asset)) } async fn releases( State(app): State, headers: HeaderMap, Path((owner, name)): Path<(String, String)>, ) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; Ok(Json(crate::web::releases::api_views(&app, &repo, &row).await?)) } async fn create_release( State(app): State, headers: HeaderMap, Path((owner, name)): Path<(String, String)>, Json(body): Json, ) -> Result<(StatusCode, Json), ApiError> { let c = caller(&app, &headers).await?; let me = c.require("repo:write")?.clone(); let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?; crate::web::releases::create_release(&app, &repo, &row, &me, &body.tag, &body.title, body.notes, body.prerelease) .await .map_err(|m| ApiError(StatusCode::BAD_REQUEST, m))?; let r = crate::web::releases::api_views(&app, &repo, &row) .await? .into_iter() .find(|r| r.tag == body.tag) .ok_or_else(|| not_found("no such release"))?; Ok((StatusCode::CREATED, Json(r))) } #[derive(Deserialize)] struct AssetQuery { name: String, } /// `POST …/releases/{tag}/assets?name=file.tar.gz` with the file as the body. async fn upload_asset( State(app): State, headers: HeaderMap, Path((owner, name, tag)): Path<(String, String, String)>, Query(q): Query, body: axum::body::Bytes, ) -> Result<(StatusCode, Json), ApiError> { let c = caller(&app, &headers).await?; c.require("repo:write")?; let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?; let r = db::releases::get(&app.db, row.id, &tag).await?.ok_or_else(|| not_found("no such release"))?; let ct = headers.get(axum::http::header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or("application/octet-stream").to_string(); crate::web::releases::store_asset(&app, r.id, &q.name, &ct, &body).await.map_err(|m| ApiError(StatusCode::BAD_REQUEST, m))?; let r = crate::web::releases::api_views(&app, &repo, &row) .await? .into_iter() .find(|r| r.tag == tag) .ok_or_else(|| not_found("no such release"))?; Ok((StatusCode::CREATED, Json(r))) } pub struct ApiError(pub StatusCode, pub String); impl IntoResponse for ApiError { fn into_response(self) -> Response { let body = Problem { status: self.0.as_u16(), title: self.0.canonical_reason().unwrap_or("error").into(), detail: self.1 }; (self.0, [("content-type", "application/problem+json")], Json(body)).into_response() } } impl From for ApiError { fn from(e: RepoError) -> ApiError { match e { RepoError::NotFound => not_found("no such repository"), RepoError::Oxy(oxy_core::repo::RepoError::Msg(m)) => ApiError(StatusCode::NOT_FOUND, m), other => ApiError(StatusCode::INTERNAL_SERVER_ERROR, other.to_string()), } } } impl From for ApiError { fn from(e: sqlx::Error) -> ApiError { ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) } } fn not_found(what: &str) -> ApiError { ApiError(StatusCode::NOT_FOUND, what.into()) } fn forbidden(what: &str) -> ApiError { ApiError(StatusCode::FORBIDDEN, what.into()) } /// Who calls: the account behind a bearer token, with the token's scopes. pub struct Caller { pub account: Option, pub scopes: Vec, pub site: bool, } impl Caller { fn has(&self, scope: &str) -> bool { self.site || self.scopes.iter().any(|s| { s == scope || (scope == "repo:read" && (s == "repo:write" || s == "repo:admin")) || (scope == "repo:write" && s == "repo:admin") }) } fn require(&self, scope: &str) -> Result<&Account, ApiError> { if !self.has(scope) { return Err(forbidden(&format!("this token lacks the {scope} scope"))); } self.account.as_ref().ok_or_else(|| ApiError(StatusCode::UNAUTHORIZED, "a personal access token is required".into())) } } pub async fn caller(app: &Shared, headers: &HeaderMap) -> Result { let Some(t) = oxy_server::bearer_token(headers) else { return Ok(Caller { account: None, scopes: vec![], site: false }) }; if app.config.auth.push_tokens.iter().any(|x| auth::mac_equal(x, t)) { let admin = db::accounts::accounts(&app.db).await?.into_iter().find(|a| a.is_admin); return Ok(Caller { account: admin, scopes: vec!["repo:admin".into(), "user:read".into(), "checks:write".into()], site: true }); } let token = db::accounts::token_by_hash(&app.db, &auth::token_hash(t)).await?; match token { Some(tok) if !tok.expired(db::now_ms()) => { let account = db::accounts::account_by_id(&app.db, tok.account_id).await?.filter(|a| !a.banned); db::accounts::touch_token(&app.db, tok.id).await.ok(); Ok(Caller { account, scopes: tok.scope_list().iter().map(|s| s.to_string()).collect(), site: false }) } _ => Err(ApiError(StatusCode::UNAUTHORIZED, "unknown or expired token".into())), } } fn repository(app: &Shared, r: &db::repos::RepoRow) -> Repository { Repository { owner: r.owner.clone(), name: r.name.clone(), description: r.description.clone(), default_bookmark: r.default_bookmark.clone(), visibility: r.visibility(), created_ms: r.created_ms, updated_ms: r.updated_ms, clone_url: format!("{}/{}/{}", app.config.base_url(), r.owner, r.name), } } /// A repository the caller may reach with `access`, else 404. async fn reach( app: &Shared, c: &Caller, owner: &str, name: &str, access: Access, ) -> Result<(RepoName, db::repos::RepoRow, Role), ApiError> { let repo = RepoName::new(owner, name).ok_or_else(|| not_found("no such repository"))?; let row = db::repos::get(&app.db, owner, name).await?.ok_or_else(|| not_found("no such repository"))?; let role = auth::roles::role_for(&app.db, c.account.as_ref(), &row).await?; if !auth::roles::allows(role, access) { return Err(not_found("no such repository")); } Ok((repo, row, role.unwrap_or(Role::Read))) } fn summary(c: &view::ChangeView) -> ChangeSummary { ChangeSummary { hash: c.hash.clone(), message: if c.body.is_empty() { c.title.clone() } else { format!("{}\n\n{}", c.title, c.body) }, author: Author { name: c.author.name.clone(), email: c.author.email.clone(), key: c.author.key.clone(), account: c.author.account.clone(), }, when_ms: c.when_ms, deps: c.deps.clone(), head: c.head, signature: match &c.signature { view::SigView::Unsigned => Signature::Unsigned, view::SigView::Valid { key, sealed, revoked, .. } => Signature::Valid { key: key.clone(), sealed: *sealed, revoked: *revoked }, view::SigView::Invalid(why) => Signature::Invalid { reason: why.clone() }, }, } } // ---------------------------------------------------------------- accounts async fn me(State(app): State, headers: HeaderMap) -> Result, ApiError> { let c = caller(&app, &headers).await?; let a = c.require("user:read")?; Ok(Json(user_of(&app, a).await?)) } async fn user_of(app: &Shared, a: &Account) -> Result { let keys = db::accounts::identities_of(&app.db, a.id).await?.into_iter().map(|i| i.key).collect(); Ok(User { username: a.username.clone(), display_name: a.display_name.clone(), is_admin: a.is_admin, keys, created_ms: a.created_ms }) } async fn user(State(app): State, Path(name): Path) -> Result, ApiError> { let a = db::accounts::account_by_name(&app.db, &name).await?.ok_or_else(|| not_found("no such account"))?; Ok(Json(user_of(&app, &a).await?)) } async fn org(State(app): State, Path(name): Path) -> Result, ApiError> { let o = db::accounts::org_by_name(&app.db, &name).await?.ok_or_else(|| not_found("no such organisation"))?; let members = db::accounts::org_members(&app.db, o.id).await?.into_iter().map(|(a, _)| a.username).collect(); Ok(Json(Organisation { name: o.name, display_name: o.display_name, description: o.description, members })) } #[derive(Deserialize)] struct SearchQuery { q: String, } async fn search( State(app): State, headers: HeaderMap, Query(q): Query, ) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let mut out = Vec::new(); for h in db::notify::search(&app.db, &q.q, None, 50).await? { let Some(row) = db::repos::get_by_id(&app.db, h.repo_id).await? else { continue }; let role = auth::roles::role_for(&app.db, c.account.as_ref(), &row).await?; if !auth::roles::allows(role, Access::Read) { continue; } out.push( serde_json::json!({ "kind": h.kind, "repository": row.slug(), "number": h.number, "title": h.title, "snippet": h.snippet }), ); } Ok(Json(out)) } // ------------------------------------------------------------ repositories async fn list_repos(State(app): State, headers: HeaderMap) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let mut out = Vec::new(); for r in db::repos::list(&app.db).await? { let role = auth::roles::role_for(&app.db, c.account.as_ref(), &r).await?; if auth::roles::allows(role, Access::Read) { out.push(repository(&app, &r)); } } Ok(Json(out)) } async fn create_repo( State(app): State, headers: HeaderMap, Json(body): Json, ) -> Result<(StatusCode, Json), ApiError> { let c = caller(&app, &headers).await?; let me = c.require("repo:admin")?; let name = RepoName::new(&body.owner, &body.name).ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "invalid owner or name".into()))?; if !auth::roles::may_create_under(&app.db, me, &name.owner).await? { return Err(forbidden("you cannot create repositories under that owner")); } if db::repos::get(&app.db, &name.owner, &name.name).await?.is_some() || app.repos.exists(&name) { return Err(ApiError(StatusCode::CONFLICT, "that repository exists".into())); } app.repos.create(&name, None)?; let row = db::repos::create(&app.db, &name.owner, &name.name, &body.description, body.visibility).await?; db::accounts::audit(&app.db, Some(me), "repo.created", &name.to_string(), "api").await.ok(); Ok((StatusCode::CREATED, Json(repository(&app, &row)))) } async fn get_repo( State(app): State, headers: HeaderMap, Path((owner, name)): Path<(String, String)>, ) -> Result, ApiError> { let c = caller(&app, &headers).await?; let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; Ok(Json(repository(&app, &row))) } async fn bookmarks( State(app): State, headers: HeaderMap, Path((owner, name)): Path<(String, String)>, ) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let default = row.default_bookmark.clone(); let list = app.repos.read(&repo, move |r, cache| view::bookmarks(r, cache, &default)).await?; Ok(Json(list.into_iter().map(|b| Bookmark { name: b.name, heads: b.heads, changes: b.changes, default: b.default }).collect())) } async fn tags( State(app): State, headers: HeaderMap, Path((owner, name)): Path<(String, String)>, ) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let list = app.repos.read(&repo, |r, _| view::tags(r)).await?; Ok(Json(list.into_iter().map(|t| Tag { name: t.name, heads: t.heads, message: t.message, signer: t.signer }).collect())) } #[derive(Deserialize)] struct HistoryQuery { path: Option, offset: Option, limit: Option, } async fn history( State(app): State, headers: HeaderMap, Path((owner, name, r)): Path<(String, String, String)>, Query(q): Query, ) -> Result, ApiError> { let c = caller(&app, &headers).await?; let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let (offset, limit) = (q.offset.unwrap_or(0), q.limit.unwrap_or(50).min(500)); let h = app .repos .read(&repo, move |repo, cache| { let target = view::resolve(repo, cache, &r)?; view::history(repo, cache, target, q.path.as_deref(), offset, limit) }) .await?; let mut entries = h.entries; crate::web::attribute(&app, &mut entries.iter_mut().collect::>()).await; Ok(Json(History { reference: h.target.label, heads: h.target.heads.iter().map(|x| x.to_hex()).collect(), total: h.total, offset: h.offset, changes: entries.iter().map(summary).collect(), })) } async fn change( State(app): State, headers: HeaderMap, Path((owner, name, hash)): Path<(String, String, String)>, ) -> Result, ApiError> { let c = caller(&app, &headers).await?; let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let d = app .repos .read(&repo, move |repo, cache| { let h = view::resolve_change(repo, cache, &hash)?; view::change(repo, cache, &h) }) .await?; let mut v = d.view; crate::web::attribute(&app, &mut [&mut v]).await; let files = crate::web::history::render_files(&d.files, &d.bases) .into_iter() .map(|f| FileChange { path: f.path, kind: f.kind, from: f.from, added: f.added, removed: f.removed }) .collect(); Ok(Json(ChangeDetail { body: v.body.clone(), change: summary(&v), dependents: d.dependents.iter().map(|x| x.hash.clone()).collect(), in_bookmarks: d.in_bookmarks, in_tags: d.in_tags, files, })) } fn entry(e: &view::Entry) -> TreeEntry { TreeEntry { name: e.name.clone(), path: e.path.clone(), kind: match e.kind { view::EntryKind::Dir => "dir", view::EntryKind::File => "file", view::EntryKind::Symlink => "symlink", view::EntryKind::Subrepo => "subrepo", } .into(), exec: e.exec, size: e.size, conflicted: e.conflicted, } } async fn tree_root( State(app): State, headers: HeaderMap, Path((owner, name, r)): Path<(String, String, String)>, ) -> Result>, ApiError> { tree(State(app), headers, Path((owner, name, r, String::new()))).await } async fn tree( State(app): State, headers: HeaderMap, Path((owner, name, r, path)): Path<(String, String, String, String)>, ) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let t = app .repos .read(&repo, move |repo, cache| { let target = view::resolve(repo, cache, &r)?; view::tree(repo, cache, target, &path) }) .await?; Ok(Json(t.entries.iter().map(entry).collect())) } async fn file( State(app): State, headers: HeaderMap, Path((owner, name, r, path)): Path<(String, String, String, String)>, ) -> Result { let c = caller(&app, &headers).await?; let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let limit = app.config.limits.render_limit.0; let f = app .repos .read(&repo, move |repo, cache| { let target = view::resolve(repo, cache, &r)?; view::file(repo, cache, target, &path, limit) }) .await?; Ok(crate::web::raw(f.bytes, &f.name, false)) } async fn blame( State(app): State, headers: HeaderMap, Path((owner, name, r, path)): Path<(String, String, String, String)>, ) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let limit = app.config.limits.render_limit.0; let b = app .repos .read(&repo, move |repo, cache| { let target = view::resolve(repo, cache, &r)?; view::blame(repo, cache, target, &path, limit) }) .await?; Ok(Json( b.lines .into_iter() .map(|l| BlameLine { line: l.line, change: l.hash.to_hex(), index: l.index, author: l.author, when_ms: l.when_ms, text: String::from_utf8_lossy(&l.text).into_owned(), }) .collect(), )) } async fn conflicts( State(app): State, headers: HeaderMap, Path((owner, name)): Path<(String, String)>, ) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let list = app.repos.read(&repo, view::conflicts).await?; Ok(Json( list.into_iter() .map(|c| Conflict { bookmark: c.bookmark, path: c.path, kind: c.kind, contributors: c.contributors.iter().map(|x| x.hash.clone()).collect(), }) .collect(), )) } async fn trust( State(app): State, headers: HeaderMap, Path((owner, name)): Path<(String, String)>, ) -> Result, ApiError> { let c = caller(&app, &headers).await?; let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let key = app.hub_key.key; let t = app.repos.read(&repo, move |r, cache| view::trust(r, cache, &key)).await?; Ok(Json(Trust { policy: t.policy.map(|p| Policy { serial: p.serial, expires_ms: p.expires_ms, roots: p.roots.iter().map(|k| k.key.clone()).collect(), root_threshold: p.root_threshold, rules: p .rules .iter() .map(|r| Rule { pattern: r.pattern.clone(), keys: r.keys.iter().map(|k| k.key.clone()).collect(), threshold: r.threshold, require: r.require.clone(), allow_conflicts: r.allow_conflicts, }) .collect(), }), signed: t.verification.signed, sealed: t.verification.sealed, unsigned: t.verification.unsigned, invalid: t.verification.invalid.iter().map(|(h, _)| h.clone()).collect(), hub_key: t.hub_key, })) } async fn pushes( State(app): State, headers: HeaderMap, Path((owner, name)): Path<(String, String)>, ) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let rows = db::repos::pushes(&app.db, row.id, 200).await?; Ok(Json( rows.into_iter() .map(|p| Push { at_ms: p.at_ms, pusher: p.pusher, reference: p.reference, ok: p.ok, reason: p.reason, old_state: p.old_state, new_state: p.new_state, heads: p.heads, changes: p.changes, }) .collect(), )) } // --------------------------------------------------------------- proposals async fn proposals( State(app): State, headers: HeaderMap, Path((owner, name)): Path<(String, String)>, ) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let mut out = Vec::new(); for p in db::proposals::list(&app.db, row.id, &["open", "draft"]).await? { let loaded = crate::web::proposals::load(&app, &repo, p).await?; let v = crate::web::proposals::verdict(&app, &loaded, row.id).await?; out.push(crate::web::proposals::to_api(&app, &owner, &name, &loaded, &v, None).await); } Ok(Json(out)) } async fn proposal( State(app): State, headers: HeaderMap, Path((owner, name, number)): Path<(String, String, i64)>, ) -> Result, ApiError> { let c = caller(&app, &headers).await?; let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?; let source_url = match p.source_repo_id { Some(id) => db::repos::get_by_id(&app.db, id).await?.map(|r| format!("{}/{}", app.config.base_url(), r.slug())), None => None, }; let loaded = crate::web::proposals::load(&app, &repo, p).await?; let v = crate::web::proposals::verdict(&app, &loaded, row.id).await?; Ok(Json(crate::web::proposals::to_api(&app, &owner, &name, &loaded, &v, source_url).await)) } async fn create_proposal( State(app): State, headers: HeaderMap, Path((owner, name)): Path<(String, String)>, Json(body): Json, ) -> Result<(StatusCode, Json), ApiError> { let c = caller(&app, &headers).await?; let me = c.require("proposals:write").or_else(|_| c.require("repo:write"))?.clone(); let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let created = crate::web::proposals::open_proposal(&app, &repo, &row, &me, &body.title, &body.body, &body.target, &body.source, body.draft) .await .map_err(|e| ApiError(StatusCode::BAD_REQUEST, e))?; let loaded = crate::web::proposals::load(&app, &repo, created).await?; let v = crate::web::proposals::verdict(&app, &loaded, row.id).await?; Ok((StatusCode::CREATED, Json(crate::web::proposals::to_api(&app, &owner, &name, &loaded, &v, None).await))) } fn comment_of(c: &db::proposals::CommentRow) -> Comment { Comment { id: c.id, author: c.author.clone(), body: c.body.clone(), created_ms: c.created_ms, path: if c.path.is_empty() { None } else { Some(c.path.clone()) }, anchor: if c.anchor_change.is_empty() { None } else { Some(Atom { change: c.anchor_change.clone(), index: c.anchor_index as u32 }) }, resolved: c.resolved, } } async fn proposal_comments( State(app): State, headers: HeaderMap, Path((owner, name, number)): Path<(String, String, i64)>, ) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?; Ok(Json(db::proposals::comments(&app.db, p.id).await?.iter().map(comment_of).collect())) } async fn proposal_comment( State(app): State, headers: HeaderMap, Path((owner, name, number)): Path<(String, String, i64)>, Json(body): Json, ) -> Result<(StatusCode, Json), ApiError> { let c = caller(&app, &headers).await?; let me = c.require("proposals:write").or_else(|_| c.require("repo:write"))?.clone(); let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?; if body.body.trim().is_empty() { return Err(ApiError(StatusCode::BAD_REQUEST, "an empty comment says nothing".into())); } let (repo, _) = (RepoName::new(&owner, &name).ok_or_else(|| not_found("no such repository"))?, ()); let anchor = match (body.path.as_deref().map(|x| x.trim_matches('/')), body.line) { (Some(path), Some(line)) if !path.is_empty() => { let loaded = crate::web::proposals::load(&app, &repo, p.clone()).await?; let (heads, file) = (loaded.heads.clone(), path.to_string()); let atom = app .repos .read(&repo, move |r, cache| { let projected = cache.load(r, &heads)?; Ok(crate::repos::proposals::atom_at(&projected, &file, line).map(|(h, i)| (h.to_hex(), i))) }) .await? .ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, format!("{path}:{line} is not a line of the projected state")))?; Some((path.to_string(), atom)) } _ => None, }; let row_c = db::proposals::add_comment( &app.db, p.id, Some(me.id), &me.username, body.body.trim(), anchor.as_ref().map(|(path, _)| path.as_str()).unwrap_or(""), anchor.as_ref().map(|(_, (h, i))| (h.as_str(), *i as i64)), ) .await?; crate::notify::deliver( &app, crate::notify::Event { kind: "proposal.comment", title: format!("{owner}/{name}#{number}: {}", p.title), url: format!("/{owner}/{name}/proposal/{number}"), actor: &me, repo_id: row.id, thread: Some(("proposal", p.id)), mention_text: &body.body, direct: p.author_id.into_iter().collect(), }, ) .await; crate::hooks::emit( &app, &row, "comment.proposal", &me.username, serde_json::json!({ "number": number, "comment": comment_of(&row_c) }), ) .await; Ok((StatusCode::CREATED, Json(comment_of(&row_c)))) } async fn proposal_reviews( State(app): State, headers: HeaderMap, Path((owner, name, number)): Path<(String, String, i64)>, ) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?; let loaded = crate::web::proposals::load(&app, &repo, p).await?; let v = crate::web::proposals::verdict(&app, &loaded, row.id).await?; Ok(Json( v.reviews .into_iter() .map(|r| Review { id: r.row.id, author: r.row.author, verdict: r.row.verdict, body: r.row.body, sealed: r.sealed, created_ms: r.row.created_ms, }) .collect(), )) } async fn proposal_review( State(app): State, headers: HeaderMap, Path((owner, name, number)): Path<(String, String, i64)>, Json(body): Json, ) -> Result<(StatusCode, Json), ApiError> { let c = caller(&app, &headers).await?; let me = c.require("proposals:write").or_else(|_| c.require("repo:write"))?.clone(); let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?; if !matches!(body.verdict.as_str(), "comment" | "changes" | "approve") { return Err(ApiError(StatusCode::BAD_REQUEST, "verdict is comment, changes or approve".into())); } if body.verdict == "approve" && Some(me.id) == p.author_id { return Err(forbidden("you cannot approve your own proposal")); } let r = db::proposals::add_review(&app.db, p.id, me.id, &me.username, &body.verdict, body.body.trim()).await?; db::proposals::add_event(&app.db, p.id, &me.username, &format!("review.{}", body.verdict), "").await.ok(); crate::hooks::emit(&app, &row, "review", &me.username, serde_json::json!({ "number": number, "verdict": body.verdict })).await; Ok(( StatusCode::CREATED, Json(Review { id: r.id, author: r.author, verdict: r.verdict, body: r.body, sealed: false, created_ms: r.created_ms }), )) } async fn proposal_checks( State(app): State, headers: HeaderMap, Path((owner, name, number)): Path<(String, String, i64)>, ) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?; let loaded = crate::web::proposals::load(&app, &repo, p).await?; Ok(Json(db::hooks::checks_for_state(&app.db, row.id, &loaded.state).await?.iter().map(check_of).collect())) } // ------------------------------------------------------------------ issues async fn issue_of(app: &Shared, row: &db::repos::RepoRow, i: &db::issues::IssueRow) -> Result { let labels = db::issues::issue_labels(&app.db, i.id) .await? .into_iter() .map(|l| Label { name: l.name, colour: l.colour, description: l.description }) .collect(); let assignees = db::issues::assignees(&app.db, i.id).await?; let milestone = match i.milestone_id { Some(id) => db::issues::milestone(&app.db, id).await?.map(|m| m.title), None => None, }; let comments: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issue_comments WHERE issue_id = ?").bind(i.id).fetch_one(&app.db).await?; Ok(Issue { number: i.number, title: i.title.clone(), body: i.body.clone(), author: i.author.clone(), state: i.state.clone(), reason: i.reason.clone(), labels, assignees, milestone, comments, locked: i.locked, pinned: i.pinned, created_ms: i.created_ms, updated_ms: i.updated_ms, closed_ms: i.closed_ms, url: format!("{}/{}/issue/{}", app.config.base_url(), row.slug(), i.number), }) } #[derive(Deserialize)] struct IssuesQuery { state: Option, } async fn issues( State(app): State, headers: HeaderMap, Path((owner, name)): Path<(String, String)>, Query(q): Query, ) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let state = q.state.unwrap_or_else(|| "open".into()); let mut out = Vec::new(); for i in db::issues::list(&app.db, row.id).await? { if state == "all" || i.state == state { out.push(issue_of(&app, &row, &i).await?); } } Ok(Json(out)) } async fn create_issue( State(app): State, headers: HeaderMap, Path((owner, name)): Path<(String, String)>, Json(body): Json, ) -> Result<(StatusCode, Json), ApiError> { let c = caller(&app, &headers).await?; let me = c.require("issues:write").or_else(|_| c.require("repo:write"))?.clone(); let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; if body.title.trim().is_empty() { return Err(ApiError(StatusCode::BAD_REQUEST, "an issue needs a title".into())); } let i = db::issues::create( &app.db, db::issues::NewIssue { repo_id: row.id, title: body.title.trim(), body: body.body.trim(), author_id: Some(me.id), author: &me.username, }, ) .await?; db::issues::add_event(&app.db, i.id, &me.username, "opened", "").await.ok(); crate::web::issues::reindex(&app, row.id, &i).await; let api = issue_of(&app, &row, &i).await?; crate::notify::deliver( &app, crate::notify::Event { kind: "issue.opened", title: format!("{owner}/{name}#{}: {}", i.number, i.title), url: format!("/{owner}/{name}/issue/{}", i.number), actor: &me, repo_id: row.id, thread: Some(("issue", i.id)), mention_text: &body.body, direct: vec![], }, ) .await; crate::hooks::emit(&app, &row, "issue.opened", &me.username, serde_json::json!({ "issue": api })).await; Ok((StatusCode::CREATED, Json(api))) } async fn issue( State(app): State, headers: HeaderMap, Path((owner, name, number)): Path<(String, String, i64)>, ) -> Result, ApiError> { let c = caller(&app, &headers).await?; let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let i = db::issues::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such issue"))?; Ok(Json(issue_of(&app, &row, &i).await?)) } async fn issue_state( State(app): State, headers: HeaderMap, Path((owner, name, number)): Path<(String, String, i64)>, Json(body): Json, ) -> Result, ApiError> { let c = caller(&app, &headers).await?; let me = c.require("issues:write").or_else(|_| c.require("repo:write"))?.clone(); let (_, row, role) = reach(&app, &c, &owner, &name, Access::Read).await?; let i = db::issues::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such issue"))?; if role < Role::Triage && Some(me.id) != i.author_id { return Err(forbidden("only the author or triage may change the state")); } let (state, reason) = match body.state.as_str() { "closed" => ("closed", if body.reason == "not_planned" { "not_planned" } else { "completed" }), "open" => ("open", ""), _ => return Err(ApiError(StatusCode::BAD_REQUEST, "state is open or closed".into())), }; db::issues::set_state(&app.db, i.id, state, reason).await?; db::issues::add_event(&app.db, i.id, &me.username, if state == "closed" { "closed" } else { "reopened" }, "").await.ok(); let fresh = db::issues::get(&app.db, row.id, number).await?.unwrap_or(i); let api = issue_of(&app, &row, &fresh).await?; crate::hooks::emit( &app, &row, if state == "closed" { "issue.closed" } else { "issue.reopened" }, &me.username, serde_json::json!({ "issue": api }), ) .await; Ok(Json(api)) } async fn issue_comments( State(app): State, headers: HeaderMap, Path((owner, name, number)): Path<(String, String, i64)>, ) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let i = db::issues::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such issue"))?; Ok(Json( db::issues::comments(&app.db, i.id) .await? .into_iter() .map(|c| Comment { id: c.id, author: c.author, body: c.body, created_ms: c.created_ms, path: None, anchor: None, resolved: false, }) .collect(), )) } async fn issue_comment( State(app): State, headers: HeaderMap, Path((owner, name, number)): Path<(String, String, i64)>, Json(body): Json, ) -> Result<(StatusCode, Json), ApiError> { let c = caller(&app, &headers).await?; let me = c.require("issues:write").or_else(|_| c.require("repo:write"))?.clone(); let (_, row, role) = reach(&app, &c, &owner, &name, Access::Read).await?; let i = db::issues::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such issue"))?; if i.locked && role < Role::Triage { return Err(forbidden("this issue is locked")); } if body.body.trim().is_empty() { return Err(ApiError(StatusCode::BAD_REQUEST, "an empty comment says nothing".into())); } let cm = db::issues::add_comment(&app.db, i.id, Some(me.id), &me.username, body.body.trim()).await?; crate::web::issues::reindex(&app, row.id, &i).await; crate::notify::deliver( &app, crate::notify::Event { kind: "issue.comment", title: format!("{owner}/{name}#{number}: {}", i.title), url: format!("/{owner}/{name}/issue/{number}"), actor: &me, repo_id: row.id, thread: Some(("issue", i.id)), mention_text: &body.body, direct: i.author_id.into_iter().collect(), }, ) .await; let api = Comment { id: cm.id, author: cm.author, body: cm.body, created_ms: cm.created_ms, path: None, anchor: None, resolved: false }; crate::hooks::emit(&app, &row, "comment.issue", &me.username, serde_json::json!({ "number": number, "comment": api })).await; Ok((StatusCode::CREATED, Json(api))) } async fn labels( State(app): State, headers: HeaderMap, Path((owner, name)): Path<(String, String)>, ) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; Ok(Json( db::issues::labels(&app.db, row.id) .await? .into_iter() .map(|l| Label { name: l.name, colour: l.colour, description: l.description }) .collect(), )) } async fn milestones( State(app): State, headers: HeaderMap, Path((owner, name)): Path<(String, String)>, ) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let mut out = Vec::new(); for m in db::issues::milestones(&app.db, row.id).await? { let (open, closed) = db::issues::milestone_counts(&app.db, m.id).await?; out.push(Milestone { title: m.title, description: m.description, due_ms: m.due_ms, state: m.state, open, closed }); } Ok(Json(out)) } // --------------------------------------------------------- hooks, checks fn hook_of(h: &db::hooks::Hook) -> Hook { Hook { id: h.id, url: h.url.clone(), events: h.events.clone(), active: h.active, created_ms: h.created_ms } } async fn hooks( State(app): State, headers: HeaderMap, Path((owner, name)): Path<(String, String)>, ) -> Result>, ApiError> { let c = caller(&app, &headers).await?; c.require("hooks:admin").or_else(|_| c.require("repo:admin"))?; let (_, row, _) = reach(&app, &c, &owner, &name, Access::Admin).await?; Ok(Json(db::hooks::hooks_for_repo(&app.db, row.id).await?.iter().map(hook_of).collect())) } async fn create_hook( State(app): State, headers: HeaderMap, Path((owner, name)): Path<(String, String)>, Json(body): Json, ) -> Result<(StatusCode, Json), ApiError> { let c = caller(&app, &headers).await?; let me = c.require("hooks:admin").or_else(|_| c.require("repo:admin"))?.clone(); let (_, row, _) = reach(&app, &c, &owner, &name, Access::Admin).await?; if !(body.url.starts_with("http://") || body.url.starts_with("https://")) { return Err(ApiError(StatusCode::BAD_REQUEST, "the URL must be http or https".into())); } let h = db::hooks::create_hook(&app.db, Some(row.id), None, &body.url, &body.secret, &body.events).await?; db::accounts::audit(&app.db, Some(&me), "hook.created", &row.slug(), &body.url).await.ok(); Ok((StatusCode::CREATED, Json(hook_of(&h)))) } async fn delete_hook( State(app): State, headers: HeaderMap, Path((owner, name, id)): Path<(String, String, i64)>, ) -> Result { let c = caller(&app, &headers).await?; c.require("hooks:admin").or_else(|_| c.require("repo:admin"))?; let (_, row, _) = reach(&app, &c, &owner, &name, Access::Admin).await?; if db::hooks::hook(&app.db, id).await?.is_some_and(|h| h.repo_id == Some(row.id)) { db::hooks::delete_hook(&app.db, id).await?; } Ok(StatusCode::NO_CONTENT) } pub fn check_of(c: &db::hooks::Check) -> Check { Check { state: c.state.clone(), name: c.name.clone(), status: CheckStatus::parse(&c.status).unwrap_or(CheckStatus::Queued), summary: c.summary.clone(), url: c.url.clone(), reporter: c.reporter.clone(), seal: if c.seal.is_empty() { None } else { Some(c.seal.clone()) }, started_ms: c.started_ms, finished_ms: c.finished_ms, } } /// `POST /repos/{o}/{n}/checks`: any `checks:write` (or write) token may /// report; the state hash names what was checked. async fn post_check( State(app): State, headers: HeaderMap, Path((owner, name)): Path<(String, String)>, Json(body): Json, ) -> Result<(StatusCode, Json), ApiError> { let c = caller(&app, &headers).await?; let me = c.require("checks:write").or_else(|_| c.require("repo:write"))?.clone(); let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; if body.state.len() != 64 || !body.state.chars().all(|ch| ch.is_ascii_hexdigit()) { return Err(ApiError(StatusCode::BAD_REQUEST, "state is a 64-character hex state hash".into())); } if body.name.trim().is_empty() || body.name.len() > 100 { return Err(ApiError(StatusCode::BAD_REQUEST, "a check has a short name".into())); } let ch = db::hooks::upsert_check( &app.db, row.id, &body.state, body.name.trim(), body.status.as_str(), &body.summary, &body.url, Some((me.id, &me.username)), body.seal.as_deref().unwrap_or(""), ) .await?; let api = check_of(&ch); app.events.proposal(row.id, ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) }); crate::hooks::emit(&app, &row, "check", &me.username, serde_json::json!({ "check": api })).await; Ok((StatusCode::CREATED, Json(api))) } async fn checks_for_state( State(app): State, headers: HeaderMap, Path((owner, name, state)): Path<(String, String, String)>, ) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; Ok(Json(db::hooks::checks_for_state(&app.db, row.id, &state).await?.iter().map(check_of).collect())) }