| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
use crate::repos::{view, RepoError, RepoName}; |
| 5 |
use crate::{db, Shared}; |
| 6 |
use axum::extract::{Path, State}; |
| 7 |
use axum::http::StatusCode; |
| 8 |
use axum::response::{IntoResponse, Response}; |
| 9 |
use axum::routing::get; |
| 10 |
use axum::{Json, Router}; |
| 11 |
use hub_api::{Bookmark, Problem, Repository, Tag}; |
| 12 |
|
| 13 |
pub fn router() -> Router<Shared> { |
| 14 |
Router::new() |
| 15 |
.route("/repos", get(list_repos)) |
| 16 |
.route("/repos/{owner}/{name}", get(get_repo)) |
| 17 |
.route("/repos/{owner}/{name}/bookmarks", get(bookmarks)) |
| 18 |
.route("/repos/{owner}/{name}/tags", get(tags)) |
| 19 |
.route("/repos/{owner}/{name}/proposals", get(proposals)) |
| 20 |
.route("/repos/{owner}/{name}/proposals/{number}", get(proposal)) |
| 21 |
} |
| 22 |
|
| 23 |
|
| 24 |
|
| 25 |
async fn caller(app: &Shared, headers: &axum::http::HeaderMap) -> Result<Option<db::accounts::Account>, ApiError> { |
| 26 |
let Some(t) = oxy_server::bearer_token(headers) else { return Ok(None) }; |
| 27 |
if app.config.auth.push_tokens.iter().any(|x| crate::auth::mac_equal(x, t)) { |
| 28 |
|
| 29 |
return Ok(db::accounts::accounts(&app.db).await?.into_iter().find(|a| a.is_admin)); |
| 30 |
} |
| 31 |
let token = db::accounts::token_by_hash(&app.db, &crate::auth::token_hash(t)).await?; |
| 32 |
match token { |
| 33 |
Some(tok) if !tok.expired(db::now_ms()) => Ok(db::accounts::account_by_id(&app.db, tok.account_id).await?), |
| 34 |
_ => Err(ApiError(StatusCode::UNAUTHORIZED, "unknown or expired token".into())), |
| 35 |
} |
| 36 |
} |
| 37 |
|
| 38 |
async fn readable( |
| 39 |
app: &Shared, |
| 40 |
headers: &axum::http::HeaderMap, |
| 41 |
owner: &str, |
| 42 |
name: &str, |
| 43 |
) -> Result<(RepoName, db::repos::RepoRow), ApiError> { |
| 44 |
let repo = RepoName::new(owner, name).ok_or(ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?; |
| 45 |
let row = db::repos::get(&app.db, owner, name).await?.ok_or(ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?; |
| 46 |
let account = caller(app, headers).await?; |
| 47 |
let role = crate::auth::roles::role_for(&app.db, account.as_ref(), &row).await?; |
| 48 |
if !crate::auth::roles::allows(role, crate::auth::Access::Read) { |
| 49 |
return Err(ApiError(StatusCode::NOT_FOUND, "no such repository".into())); |
| 50 |
} |
| 51 |
Ok((repo, row)) |
| 52 |
} |
| 53 |
|
| 54 |
async fn proposals( |
| 55 |
State(app): State<Shared>, |
| 56 |
headers: axum::http::HeaderMap, |
| 57 |
Path((owner, name)): Path<(String, String)>, |
| 58 |
) -> Result<Json<Vec<hub_api::Proposal>>, ApiError> { |
| 59 |
let (repo, row) = readable(&app, &headers, &owner, &name).await?; |
| 60 |
let mut out = Vec::new(); |
| 61 |
for p in db::proposals::list(&app.db, row.id, &["open", "draft"]).await? { |
| 62 |
let loaded = crate::web::proposals::load(&app, &repo, p).await?; |
| 63 |
let v = crate::web::proposals::verdict(&app, &loaded, row.id).await?; |
| 64 |
out.push(crate::web::proposals::to_api(&app, &owner, &name, &loaded, &v, None).await); |
| 65 |
} |
| 66 |
Ok(Json(out)) |
| 67 |
} |
| 68 |
|
| 69 |
async fn proposal( |
| 70 |
State(app): State<Shared>, |
| 71 |
headers: axum::http::HeaderMap, |
| 72 |
Path((owner, name, number)): Path<(String, String, i64)>, |
| 73 |
) -> Result<Json<hub_api::Proposal>, ApiError> { |
| 74 |
let (repo, row) = readable(&app, &headers, &owner, &name).await?; |
| 75 |
let p = db::proposals::get(&app.db, row.id, number).await?.ok_or(ApiError(StatusCode::NOT_FOUND, "no such proposal".into()))?; |
| 76 |
let source_url = match p.source_repo_id { |
| 77 |
Some(id) => db::repos::get_by_id(&app.db, id).await?.map(|r| format!("{}/{}", app.config.base_url(), r.slug())), |
| 78 |
None => None, |
| 79 |
}; |
| 80 |
let loaded = crate::web::proposals::load(&app, &repo, p).await?; |
| 81 |
let v = crate::web::proposals::verdict(&app, &loaded, row.id).await?; |
| 82 |
Ok(Json(crate::web::proposals::to_api(&app, &owner, &name, &loaded, &v, source_url).await)) |
| 83 |
} |
| 84 |
|
| 85 |
pub struct ApiError(StatusCode, String); |
| 86 |
|
| 87 |
impl IntoResponse for ApiError { |
| 88 |
fn into_response(self) -> Response { |
| 89 |
let body = Problem { status: self.0.as_u16(), title: self.0.canonical_reason().unwrap_or("error").into(), detail: self.1 }; |
| 90 |
(self.0, [("content-type", "application/problem+json")], Json(body)).into_response() |
| 91 |
} |
| 92 |
} |
| 93 |
|
| 94 |
impl From<RepoError> for ApiError { |
| 95 |
fn from(e: RepoError) -> ApiError { |
| 96 |
match e { |
| 97 |
RepoError::NotFound => ApiError(StatusCode::NOT_FOUND, "no such repository".into()), |
| 98 |
other => ApiError(StatusCode::INTERNAL_SERVER_ERROR, other.to_string()), |
| 99 |
} |
| 100 |
} |
| 101 |
} |
| 102 |
|
| 103 |
impl From<sqlx::Error> for ApiError { |
| 104 |
fn from(e: sqlx::Error) -> ApiError { |
| 105 |
ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) |
| 106 |
} |
| 107 |
} |
| 108 |
|
| 109 |
fn repository(app: &Shared, r: &db::repos::RepoRow) -> Repository { |
| 110 |
Repository { |
| 111 |
owner: r.owner.clone(), |
| 112 |
name: r.name.clone(), |
| 113 |
description: r.description.clone(), |
| 114 |
default_bookmark: r.default_bookmark.clone(), |
| 115 |
visibility: r.visibility(), |
| 116 |
created_ms: r.created_ms, |
| 117 |
updated_ms: r.updated_ms, |
| 118 |
clone_url: format!("{}/{}/{}", app.config.base_url(), r.owner, r.name), |
| 119 |
} |
| 120 |
} |
| 121 |
|
| 122 |
async fn public_row(app: &Shared, owner: &str, name: &str) -> Result<(RepoName, db::repos::RepoRow), ApiError> { |
| 123 |
let repo = RepoName::new(owner, name).ok_or(ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?; |
| 124 |
let row = db::repos::get(&app.db, owner, name).await?.filter(|r| r.visibility() == hub_api::Visibility::Public); |
| 125 |
Ok((repo, row.ok_or(ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?)) |
| 126 |
} |
| 127 |
|
| 128 |
async fn list_repos(State(app): State<Shared>) -> Result<Json<Vec<Repository>>, ApiError> { |
| 129 |
let rows = db::repos::list(&app.db).await?; |
| 130 |
Ok(Json(rows.iter().filter(|r| r.visibility() == hub_api::Visibility::Public).map(|r| repository(&app, r)).collect())) |
| 131 |
} |
| 132 |
|
| 133 |
async fn get_repo(State(app): State<Shared>, Path((owner, name)): Path<(String, String)>) -> Result<Json<Repository>, ApiError> { |
| 134 |
let (_, row) = public_row(&app, &owner, &name).await?; |
| 135 |
Ok(Json(repository(&app, &row))) |
| 136 |
} |
| 137 |
|
| 138 |
async fn bookmarks(State(app): State<Shared>, Path((owner, name)): Path<(String, String)>) -> Result<Json<Vec<Bookmark>>, ApiError> { |
| 139 |
let (repo, row) = public_row(&app, &owner, &name).await?; |
| 140 |
let default = row.default_bookmark.clone(); |
| 141 |
let list = app.repos.read(&repo, move |r, cache| view::bookmarks(r, cache, &default)).await?; |
| 142 |
Ok(Json(list.into_iter().map(|b| Bookmark { name: b.name, heads: b.heads, changes: b.changes, default: b.default }).collect())) |
| 143 |
} |
| 144 |
|
| 145 |
async fn tags(State(app): State<Shared>, Path((owner, name)): Path<(String, String)>) -> Result<Json<Vec<Tag>>, ApiError> { |
| 146 |
let (repo, _) = public_row(&app, &owner, &name).await?; |
| 147 |
let list = app.repos.read(&repo, |r, _| view::tags(r)).await?; |
| 148 |
Ok(Json(list.into_iter().map(|t| Tag { name: t.name, heads: t.heads, message: t.message, signer: t.signer }).collect())) |
| 149 |
} |