OXYGENHUB
# c40c2d3edb51 oxygenhub / crates / hub / src / api / mod.rs
44.6 KiB · Rust · last changed Milestone 0.6: releases, Git mirror, code search, anchored API comments 1 month ago source blame history raw
1
//! The JSON API (hub spec §12.1) under `/api/v1`: bearer tokens, RFC 7807
2
//! problems, the same shapes as `oxy --json` where one exists. Reads need
3
//! `repo:read` (anonymous for public repositories); writes the scope named
4
//! on each handler.
5
6
use crate::auth::{self, Access, Role};
7
use crate::db::{self, accounts::Account};
8
use crate::repos::{view, RepoError, RepoName};
9
use crate::Shared;
10
use axum::extract::{Path, Query, State};
11
use axum::http::{HeaderMap, StatusCode};
12
use axum::response::{IntoResponse, Response};
13
use axum::routing::{get, post};
14
use axum::{Json, Router};
15
use hub_api::*;
16
use serde::Deserialize;
17
18
pub fn router() -> Router<Shared> {
19
    Router::new()
20
        .route("/user", get(me))
21
        .route("/users/{name}", get(user))
22
        .route("/orgs/{name}", get(org))
23
        .route("/search", get(search))
24
        .route("/repos", get(list_repos).post(create_repo))
25
        .route("/repos/{owner}/{name}", get(get_repo))
26
        .route("/repos/{owner}/{name}/bookmarks", get(bookmarks))
27
        .route("/repos/{owner}/{name}/tags", get(tags))
28
        .route("/repos/{owner}/{name}/history/{r}", get(history))
29
        .route("/repos/{owner}/{name}/changes/{hash}", get(change))
30
        .route("/repos/{owner}/{name}/tree/{r}", get(tree_root))
31
        .route("/repos/{owner}/{name}/tree/{r}/{*path}", get(tree))
32
        .route("/repos/{owner}/{name}/file/{r}/{*path}", get(file))
33
        .route("/repos/{owner}/{name}/blame/{r}/{*path}", get(blame))
34
        .route("/repos/{owner}/{name}/conflicts", get(conflicts))
35
        .route("/repos/{owner}/{name}/trust", get(trust))
36
        .route("/repos/{owner}/{name}/pushes", get(pushes))
37
        .route("/repos/{owner}/{name}/proposals", get(proposals).post(create_proposal))
38
        .route("/repos/{owner}/{name}/proposals/{number}", get(proposal))
39
        .route("/repos/{owner}/{name}/proposals/{number}/comments", get(proposal_comments).post(proposal_comment))
40
        .route("/repos/{owner}/{name}/proposals/{number}/reviews", get(proposal_reviews).post(proposal_review))
41
        .route("/repos/{owner}/{name}/proposals/{number}/checks", get(proposal_checks))
42
        .route("/repos/{owner}/{name}/issues", get(issues).post(create_issue))
43
        .route("/repos/{owner}/{name}/issues/{number}", get(issue).post(issue_state))
44
        .route("/repos/{owner}/{name}/issues/{number}/comments", get(issue_comments).post(issue_comment))
45
        .route("/repos/{owner}/{name}/labels", get(labels))
46
        .route("/repos/{owner}/{name}/milestones", get(milestones))
47
        .route("/repos/{owner}/{name}/hooks", get(hooks).post(create_hook))
48
        .route("/repos/{owner}/{name}/hooks/{id}/delete", post(delete_hook))
49
        .route("/repos/{owner}/{name}/checks", post(post_check))
50
        .route("/repos/{owner}/{name}/checks/{state}", get(checks_for_state))
51
        .route("/repos/{owner}/{name}/releases", get(releases).post(create_release))
52
        .route("/repos/{owner}/{name}/releases/{tag}/assets", post(upload_asset))
53
}
54
55
async fn releases(
56
    State(app): State<Shared>,
57
    headers: HeaderMap,
58
    Path((owner, name)): Path<(String, String)>,
59
) -> Result<Json<Vec<Release>>, ApiError> {
60
    let c = caller(&app, &headers).await?;
61
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
62
    Ok(Json(crate::web::releases::api_views(&app, &repo, &row).await?))
63
}
64
65
async fn create_release(
66
    State(app): State<Shared>,
67
    headers: HeaderMap,
68
    Path((owner, name)): Path<(String, String)>,
69
    Json(body): Json<NewRelease>,
70
) -> Result<(StatusCode, Json<Release>), ApiError> {
71
    let c = caller(&app, &headers).await?;
72
    let me = c.require("repo:write")?.clone();
73
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?;
74
    crate::web::releases::create_release(&app, &repo, &row, &me, &body.tag, &body.title, body.notes, body.prerelease)
75
        .await
76
        .map_err(|m| ApiError(StatusCode::BAD_REQUEST, m))?;
77
    let r = crate::web::releases::api_views(&app, &repo, &row)
78
        .await?
79
        .into_iter()
80
        .find(|r| r.tag == body.tag)
81
        .ok_or_else(|| not_found("no such release"))?;
82
    Ok((StatusCode::CREATED, Json(r)))
83
}
84
85
#[derive(Deserialize)]
86
struct AssetQuery {
87
    name: String,
88
}
89
90
/// `POST …/releases/{tag}/assets?name=file.tar.gz` with the file as the body.
91
async fn upload_asset(
92
    State(app): State<Shared>,
93
    headers: HeaderMap,
94
    Path((owner, name, tag)): Path<(String, String, String)>,
95
    Query(q): Query<AssetQuery>,
96
    body: axum::body::Bytes,
97
) -> Result<(StatusCode, Json<Release>), ApiError> {
98
    let c = caller(&app, &headers).await?;
99
    c.require("repo:write")?;
100
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?;
101
    let r = db::releases::get(&app.db, row.id, &tag).await?.ok_or_else(|| not_found("no such release"))?;
102
    let ct = headers.get(axum::http::header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or("application/octet-stream").to_string();
103
    crate::web::releases::store_asset(&app, r.id, &q.name, &ct, &body).await.map_err(|m| ApiError(StatusCode::BAD_REQUEST, m))?;
104
    let r = crate::web::releases::api_views(&app, &repo, &row)
105
        .await?
106
        .into_iter()
107
        .find(|r| r.tag == tag)
108
        .ok_or_else(|| not_found("no such release"))?;
109
    Ok((StatusCode::CREATED, Json(r)))
110
}
111
112
pub struct ApiError(StatusCode, String);
113
114
impl IntoResponse for ApiError {
115
    fn into_response(self) -> Response {
116
        let body = Problem { status: self.0.as_u16(), title: self.0.canonical_reason().unwrap_or("error").into(), detail: self.1 };
117
        (self.0, [("content-type", "application/problem+json")], Json(body)).into_response()
118
    }
119
}
120
121
impl From<RepoError> for ApiError {
122
    fn from(e: RepoError) -> ApiError {
123
        match e {
124
            RepoError::NotFound => not_found("no such repository"),
125
            RepoError::Oxy(oxy_core::repo::RepoError::Msg(m)) => ApiError(StatusCode::NOT_FOUND, m),
126
            other => ApiError(StatusCode::INTERNAL_SERVER_ERROR, other.to_string()),
127
        }
128
    }
129
}
130
131
impl From<sqlx::Error> for ApiError {
132
    fn from(e: sqlx::Error) -> ApiError {
133
        ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
134
    }
135
}
136
137
fn not_found(what: &str) -> ApiError {
138
    ApiError(StatusCode::NOT_FOUND, what.into())
139
}
140
141
fn forbidden(what: &str) -> ApiError {
142
    ApiError(StatusCode::FORBIDDEN, what.into())
143
}
144
145
/// Who calls: the account behind a bearer token, with the token's scopes.
146
pub struct Caller {
147
    pub account: Option<Account>,
148
    pub scopes: Vec<String>,
149
    pub site: bool,
150
}
151
152
impl Caller {
153
    fn has(&self, scope: &str) -> bool {
154
        self.site
155
            || self.scopes.iter().any(|s| {
156
                s == scope
157
                    || (scope == "repo:read" && (s == "repo:write" || s == "repo:admin"))
158
                    || (scope == "repo:write" && s == "repo:admin")
159
            })
160
    }
161
162
    fn require(&self, scope: &str) -> Result<&Account, ApiError> {
163
        if !self.has(scope) {
164
            return Err(forbidden(&format!("this token lacks the {scope} scope")));
165
        }
166
        self.account.as_ref().ok_or_else(|| ApiError(StatusCode::UNAUTHORIZED, "a personal access token is required".into()))
167
    }
168
}
169
170
pub async fn caller(app: &Shared, headers: &HeaderMap) -> Result<Caller, ApiError> {
171
    let Some(t) = oxy_server::bearer_token(headers) else { return Ok(Caller { account: None, scopes: vec![], site: false }) };
172
    if app.config.auth.push_tokens.iter().any(|x| auth::mac_equal(x, t)) {
173
        let admin = db::accounts::accounts(&app.db).await?.into_iter().find(|a| a.is_admin);
174
        return Ok(Caller { account: admin, scopes: vec!["repo:admin".into(), "user:read".into(), "checks:write".into()], site: true });
175
    }
176
    let token = db::accounts::token_by_hash(&app.db, &auth::token_hash(t)).await?;
177
    match token {
178
        Some(tok) if !tok.expired(db::now_ms()) => {
179
            let account = db::accounts::account_by_id(&app.db, tok.account_id).await?.filter(|a| !a.banned);
180
            db::accounts::touch_token(&app.db, tok.id).await.ok();
181
            Ok(Caller { account, scopes: tok.scope_list().iter().map(|s| s.to_string()).collect(), site: false })
182
        }
183
        _ => Err(ApiError(StatusCode::UNAUTHORIZED, "unknown or expired token".into())),
184
    }
185
}
186
187
fn repository(app: &Shared, r: &db::repos::RepoRow) -> Repository {
188
    Repository {
189
        owner: r.owner.clone(),
190
        name: r.name.clone(),
191
        description: r.description.clone(),
192
        default_bookmark: r.default_bookmark.clone(),
193
        visibility: r.visibility(),
194
        created_ms: r.created_ms,
195
        updated_ms: r.updated_ms,
196
        clone_url: format!("{}/{}/{}", app.config.base_url(), r.owner, r.name),
197
    }
198
}
199
200
/// A repository the caller may reach with `access`, else 404.
201
async fn reach(
202
    app: &Shared,
203
    c: &Caller,
204
    owner: &str,
205
    name: &str,
206
    access: Access,
207
) -> Result<(RepoName, db::repos::RepoRow, Role), ApiError> {
208
    let repo = RepoName::new(owner, name).ok_or_else(|| not_found("no such repository"))?;
209
    let row = db::repos::get(&app.db, owner, name).await?.ok_or_else(|| not_found("no such repository"))?;
210
    let role = auth::roles::role_for(&app.db, c.account.as_ref(), &row).await?;
211
    if !auth::roles::allows(role, access) {
212
        return Err(not_found("no such repository"));
213
    }
214
    Ok((repo, row, role.unwrap_or(Role::Read)))
215
}
216
217
fn summary(c: &view::ChangeView) -> ChangeSummary {
218
    ChangeSummary {
219
        hash: c.hash.clone(),
220
        message: if c.body.is_empty() { c.title.clone() } else { format!("{}\n\n{}", c.title, c.body) },
221
        author: Author {
222
            name: c.author.name.clone(),
223
            email: c.author.email.clone(),
224
            key: c.author.key.clone(),
225
            account: c.author.account.clone(),
226
        },
227
        when_ms: c.when_ms,
228
        deps: c.deps.clone(),
229
        head: c.head,
230
        signature: match &c.signature {
231
            view::SigView::Unsigned => Signature::Unsigned,
232
            view::SigView::Valid { key, sealed, revoked, .. } => Signature::Valid { key: key.clone(), sealed: *sealed, revoked: *revoked },
233
            view::SigView::Invalid(why) => Signature::Invalid { reason: why.clone() },
234
        },
235
    }
236
}
237
238
// ---------------------------------------------------------------- accounts
239
240
async fn me(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<User>, ApiError> {
241
    let c = caller(&app, &headers).await?;
242
    let a = c.require("user:read")?;
243
    Ok(Json(user_of(&app, a).await?))
244
}
245
246
async fn user_of(app: &Shared, a: &Account) -> Result<User, ApiError> {
247
    let keys = db::accounts::identities_of(&app.db, a.id).await?.into_iter().map(|i| i.key).collect();
248
    Ok(User { username: a.username.clone(), display_name: a.display_name.clone(), is_admin: a.is_admin, keys, created_ms: a.created_ms })
249
}
250
251
async fn user(State(app): State<Shared>, Path(name): Path<String>) -> Result<Json<User>, ApiError> {
252
    let a = db::accounts::account_by_name(&app.db, &name).await?.ok_or_else(|| not_found("no such account"))?;
253
    Ok(Json(user_of(&app, &a).await?))
254
}
255
256
async fn org(State(app): State<Shared>, Path(name): Path<String>) -> Result<Json<Organisation>, ApiError> {
257
    let o = db::accounts::org_by_name(&app.db, &name).await?.ok_or_else(|| not_found("no such organisation"))?;
258
    let members = db::accounts::org_members(&app.db, o.id).await?.into_iter().map(|(a, _)| a.username).collect();
259
    Ok(Json(Organisation { name: o.name, display_name: o.display_name, description: o.description, members }))
260
}
261
262
#[derive(Deserialize)]
263
struct SearchQuery {
264
    q: String,
265
}
266
267
async fn search(
268
    State(app): State<Shared>,
269
    headers: HeaderMap,
270
    Query(q): Query<SearchQuery>,
271
) -> Result<Json<Vec<serde_json::Value>>, ApiError> {
272
    let c = caller(&app, &headers).await?;
273
    let mut out = Vec::new();
274
    for h in db::notify::search(&app.db, &q.q, None, 50).await? {
275
        let Some(row) = db::repos::get_by_id(&app.db, h.repo_id).await? else { continue };
276
        let role = auth::roles::role_for(&app.db, c.account.as_ref(), &row).await?;
277
        if !auth::roles::allows(role, Access::Read) {
278
            continue;
279
        }
280
        out.push(
281
            serde_json::json!({ "kind": h.kind, "repository": row.slug(), "number": h.number, "title": h.title, "snippet": h.snippet }),
282
        );
283
    }
284
    Ok(Json(out))
285
}
286
287
// ------------------------------------------------------------ repositories
288
289
async fn list_repos(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<Vec<Repository>>, ApiError> {
290
    let c = caller(&app, &headers).await?;
291
    let mut out = Vec::new();
292
    for r in db::repos::list(&app.db).await? {
293
        let role = auth::roles::role_for(&app.db, c.account.as_ref(), &r).await?;
294
        if auth::roles::allows(role, Access::Read) {
295
            out.push(repository(&app, &r));
296
        }
297
    }
298
    Ok(Json(out))
299
}
300
301
async fn create_repo(
302
    State(app): State<Shared>,
303
    headers: HeaderMap,
304
    Json(body): Json<NewRepository>,
305
) -> Result<(StatusCode, Json<Repository>), ApiError> {
306
    let c = caller(&app, &headers).await?;
307
    let me = c.require("repo:admin")?;
308
    let name = RepoName::new(&body.owner, &body.name).ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "invalid owner or name".into()))?;
309
    if !auth::roles::may_create_under(&app.db, me, &name.owner).await? {
310
        return Err(forbidden("you cannot create repositories under that owner"));
311
    }
312
    if db::repos::get(&app.db, &name.owner, &name.name).await?.is_some() || app.repos.exists(&name) {
313
        return Err(ApiError(StatusCode::CONFLICT, "that repository exists".into()));
314
    }
315
    app.repos.create(&name, None)?;
316
    let row = db::repos::create(&app.db, &name.owner, &name.name, &body.description, body.visibility).await?;
317
    db::accounts::audit(&app.db, Some(me), "repo.created", &name.to_string(), "api").await.ok();
318
    Ok((StatusCode::CREATED, Json(repository(&app, &row))))
319
}
320
321
async fn get_repo(
322
    State(app): State<Shared>,
323
    headers: HeaderMap,
324
    Path((owner, name)): Path<(String, String)>,
325
) -> Result<Json<Repository>, ApiError> {
326
    let c = caller(&app, &headers).await?;
327
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
328
    Ok(Json(repository(&app, &row)))
329
}
330
331
async fn bookmarks(
332
    State(app): State<Shared>,
333
    headers: HeaderMap,
334
    Path((owner, name)): Path<(String, String)>,
335
) -> Result<Json<Vec<Bookmark>>, ApiError> {
336
    let c = caller(&app, &headers).await?;
337
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
338
    let default = row.default_bookmark.clone();
339
    let list = app.repos.read(&repo, move |r, cache| view::bookmarks(r, cache, &default)).await?;
340
    Ok(Json(list.into_iter().map(|b| Bookmark { name: b.name, heads: b.heads, changes: b.changes, default: b.default }).collect()))
341
}
342
343
async fn tags(
344
    State(app): State<Shared>,
345
    headers: HeaderMap,
346
    Path((owner, name)): Path<(String, String)>,
347
) -> Result<Json<Vec<Tag>>, ApiError> {
348
    let c = caller(&app, &headers).await?;
349
    let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
350
    let list = app.repos.read(&repo, |r, _| view::tags(r)).await?;
351
    Ok(Json(list.into_iter().map(|t| Tag { name: t.name, heads: t.heads, message: t.message, signer: t.signer }).collect()))
352
}
353
354
#[derive(Deserialize)]
355
struct HistoryQuery {
356
    path: Option<String>,
357
    offset: Option<usize>,
358
    limit: Option<usize>,
359
}
360
361
async fn history(
362
    State(app): State<Shared>,
363
    headers: HeaderMap,
364
    Path((owner, name, r)): Path<(String, String, String)>,
365
    Query(q): Query<HistoryQuery>,
366
) -> Result<Json<History>, ApiError> {
367
    let c = caller(&app, &headers).await?;
368
    let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
369
    let (offset, limit) = (q.offset.unwrap_or(0), q.limit.unwrap_or(50).min(500));
370
    let h = app
371
        .repos
372
        .read(&repo, move |repo, cache| {
373
            let target = view::resolve(repo, cache, &r)?;
374
            view::history(repo, cache, target, q.path.as_deref(), offset, limit)
375
        })
376
        .await?;
377
    let mut entries = h.entries;
378
    crate::web::attribute(&app, &mut entries.iter_mut().collect::<Vec<_>>()).await;
379
    Ok(Json(History {
380
        reference: h.target.label,
381
        heads: h.target.heads.iter().map(|x| x.to_hex()).collect(),
382
        total: h.total,
383
        offset: h.offset,
384
        changes: entries.iter().map(summary).collect(),
385
    }))
386
}
387
388
async fn change(
389
    State(app): State<Shared>,
390
    headers: HeaderMap,
391
    Path((owner, name, hash)): Path<(String, String, String)>,
392
) -> Result<Json<ChangeDetail>, ApiError> {
393
    let c = caller(&app, &headers).await?;
394
    let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
395
    let d = app
396
        .repos
397
        .read(&repo, move |repo, cache| {
398
            let h = view::resolve_change(repo, cache, &hash)?;
399
            view::change(repo, cache, &h)
400
        })
401
        .await?;
402
    let mut v = d.view;
403
    crate::web::attribute(&app, &mut [&mut v]).await;
404
    let files = crate::web::history::render_files(&d.files, &d.bases)
405
        .into_iter()
406
        .map(|f| FileChange { path: f.path, kind: f.kind, from: f.from, added: f.added, removed: f.removed })
407
        .collect();
408
    Ok(Json(ChangeDetail {
409
        body: v.body.clone(),
410
        change: summary(&v),
411
        dependents: d.dependents.iter().map(|x| x.hash.clone()).collect(),
412
        in_bookmarks: d.in_bookmarks,
413
        in_tags: d.in_tags,
414
        files,
415
    }))
416
}
417
418
fn entry(e: &view::Entry) -> TreeEntry {
419
    TreeEntry {
420
        name: e.name.clone(),
421
        path: e.path.clone(),
422
        kind: match e.kind {
423
            view::EntryKind::Dir => "dir",
424
            view::EntryKind::File => "file",
425
            view::EntryKind::Symlink => "symlink",
426
            view::EntryKind::Subrepo => "subrepo",
427
        }
428
        .into(),
429
        exec: e.exec,
430
        size: e.size,
431
        conflicted: e.conflicted,
432
    }
433
}
434
435
async fn tree_root(
436
    State(app): State<Shared>,
437
    headers: HeaderMap,
438
    Path((owner, name, r)): Path<(String, String, String)>,
439
) -> Result<Json<Vec<TreeEntry>>, ApiError> {
440
    tree(State(app), headers, Path((owner, name, r, String::new()))).await
441
}
442
443
async fn tree(
444
    State(app): State<Shared>,
445
    headers: HeaderMap,
446
    Path((owner, name, r, path)): Path<(String, String, String, String)>,
447
) -> Result<Json<Vec<TreeEntry>>, ApiError> {
448
    let c = caller(&app, &headers).await?;
449
    let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
450
    let t = app
451
        .repos
452
        .read(&repo, move |repo, cache| {
453
            let target = view::resolve(repo, cache, &r)?;
454
            view::tree(repo, cache, target, &path)
455
        })
456
        .await?;
457
    Ok(Json(t.entries.iter().map(entry).collect()))
458
}
459
460
async fn file(
461
    State(app): State<Shared>,
462
    headers: HeaderMap,
463
    Path((owner, name, r, path)): Path<(String, String, String, String)>,
464
) -> Result<Response, ApiError> {
465
    let c = caller(&app, &headers).await?;
466
    let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
467
    let limit = app.config.limits.render_limit.0;
468
    let f = app
469
        .repos
470
        .read(&repo, move |repo, cache| {
471
            let target = view::resolve(repo, cache, &r)?;
472
            view::file(repo, cache, target, &path, limit)
473
        })
474
        .await?;
475
    Ok(crate::web::raw(f.bytes, &f.name, false))
476
}
477
478
async fn blame(
479
    State(app): State<Shared>,
480
    headers: HeaderMap,
481
    Path((owner, name, r, path)): Path<(String, String, String, String)>,
482
) -> Result<Json<Vec<BlameLine>>, ApiError> {
483
    let c = caller(&app, &headers).await?;
484
    let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
485
    let limit = app.config.limits.render_limit.0;
486
    let b = app
487
        .repos
488
        .read(&repo, move |repo, cache| {
489
            let target = view::resolve(repo, cache, &r)?;
490
            view::blame(repo, cache, target, &path, limit)
491
        })
492
        .await?;
493
    Ok(Json(
494
        b.lines
495
            .into_iter()
496
            .map(|l| BlameLine {
497
                line: l.line,
498
                change: l.hash.to_hex(),
499
                index: l.index,
500
                author: l.author,
501
                when_ms: l.when_ms,
502
                text: String::from_utf8_lossy(&l.text).into_owned(),
503
            })
504
            .collect(),
505
    ))
506
}
507
508
async fn conflicts(
509
    State(app): State<Shared>,
510
    headers: HeaderMap,
511
    Path((owner, name)): Path<(String, String)>,
512
) -> Result<Json<Vec<Conflict>>, ApiError> {
513
    let c = caller(&app, &headers).await?;
514
    let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
515
    let list = app.repos.read(&repo, view::conflicts).await?;
516
    Ok(Json(
517
        list.into_iter()
518
            .map(|c| Conflict {
519
                bookmark: c.bookmark,
520
                path: c.path,
521
                kind: c.kind,
522
                contributors: c.contributors.iter().map(|x| x.hash.clone()).collect(),
523
            })
524
            .collect(),
525
    ))
526
}
527
528
async fn trust(
529
    State(app): State<Shared>,
530
    headers: HeaderMap,
531
    Path((owner, name)): Path<(String, String)>,
532
) -> Result<Json<Trust>, ApiError> {
533
    let c = caller(&app, &headers).await?;
534
    let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
535
    let key = app.hub_key.key;
536
    let t = app.repos.read(&repo, move |r, cache| view::trust(r, cache, &key)).await?;
537
    Ok(Json(Trust {
538
        policy: t.policy.map(|p| Policy {
539
            serial: p.serial,
540
            expires_ms: p.expires_ms,
541
            roots: p.roots.iter().map(|k| k.key.clone()).collect(),
542
            root_threshold: p.root_threshold,
543
            rules: p
544
                .rules
545
                .iter()
546
                .map(|r| Rule {
547
                    pattern: r.pattern.clone(),
548
                    keys: r.keys.iter().map(|k| k.key.clone()).collect(),
549
                    threshold: r.threshold,
550
                    require: r.require.clone(),
551
                    allow_conflicts: r.allow_conflicts,
552
                })
553
                .collect(),
554
        }),
555
        signed: t.verification.signed,
556
        sealed: t.verification.sealed,
557
        unsigned: t.verification.unsigned,
558
        invalid: t.verification.invalid.iter().map(|(h, _)| h.clone()).collect(),
559
        hub_key: t.hub_key,
560
    }))
561
}
562
563
async fn pushes(
564
    State(app): State<Shared>,
565
    headers: HeaderMap,
566
    Path((owner, name)): Path<(String, String)>,
567
) -> Result<Json<Vec<Push>>, ApiError> {
568
    let c = caller(&app, &headers).await?;
569
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
570
    let rows = db::repos::pushes(&app.db, row.id, 200).await?;
571
    Ok(Json(
572
        rows.into_iter()
573
            .map(|p| Push {
574
                at_ms: p.at_ms,
575
                pusher: p.pusher,
576
                reference: p.reference,
577
                ok: p.ok,
578
                reason: p.reason,
579
                old_state: p.old_state,
580
                new_state: p.new_state,
581
                heads: p.heads,
582
                changes: p.changes,
583
            })
584
            .collect(),
585
    ))
586
}
587
588
// --------------------------------------------------------------- proposals
589
590
async fn proposals(
591
    State(app): State<Shared>,
592
    headers: HeaderMap,
593
    Path((owner, name)): Path<(String, String)>,
594
) -> Result<Json<Vec<Proposal>>, ApiError> {
595
    let c = caller(&app, &headers).await?;
596
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
597
    let mut out = Vec::new();
598
    for p in db::proposals::list(&app.db, row.id, &["open", "draft"]).await? {
599
        let loaded = crate::web::proposals::load(&app, &repo, p).await?;
600
        let v = crate::web::proposals::verdict(&app, &loaded, row.id).await?;
601
        out.push(crate::web::proposals::to_api(&app, &owner, &name, &loaded, &v, None).await);
602
    }
603
    Ok(Json(out))
604
}
605
606
async fn proposal(
607
    State(app): State<Shared>,
608
    headers: HeaderMap,
609
    Path((owner, name, number)): Path<(String, String, i64)>,
610
) -> Result<Json<Proposal>, ApiError> {
611
    let c = caller(&app, &headers).await?;
612
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
613
    let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?;
614
    let source_url = match p.source_repo_id {
615
        Some(id) => db::repos::get_by_id(&app.db, id).await?.map(|r| format!("{}/{}", app.config.base_url(), r.slug())),
616
        None => None,
617
    };
618
    let loaded = crate::web::proposals::load(&app, &repo, p).await?;
619
    let v = crate::web::proposals::verdict(&app, &loaded, row.id).await?;
620
    Ok(Json(crate::web::proposals::to_api(&app, &owner, &name, &loaded, &v, source_url).await))
621
}
622
623
async fn create_proposal(
624
    State(app): State<Shared>,
625
    headers: HeaderMap,
626
    Path((owner, name)): Path<(String, String)>,
627
    Json(body): Json<NewProposal>,
628
) -> Result<(StatusCode, Json<Proposal>), ApiError> {
629
    let c = caller(&app, &headers).await?;
630
    let me = c.require("proposals:write").or_else(|_| c.require("repo:write"))?.clone();
631
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
632
    let created =
633
        crate::web::proposals::open_proposal(&app, &repo, &row, &me, &body.title, &body.body, &body.target, &body.source, body.draft)
634
            .await
635
            .map_err(|e| ApiError(StatusCode::BAD_REQUEST, e))?;
636
    let loaded = crate::web::proposals::load(&app, &repo, created).await?;
637
    let v = crate::web::proposals::verdict(&app, &loaded, row.id).await?;
638
    Ok((StatusCode::CREATED, Json(crate::web::proposals::to_api(&app, &owner, &name, &loaded, &v, None).await)))
639
}
640
641
fn comment_of(c: &db::proposals::CommentRow) -> Comment {
642
    Comment {
643
        id: c.id,
644
        author: c.author.clone(),
645
        body: c.body.clone(),
646
        created_ms: c.created_ms,
647
        path: if c.path.is_empty() { None } else { Some(c.path.clone()) },
648
        anchor: if c.anchor_change.is_empty() {
649
            None
650
        } else {
651
            Some(Atom { change: c.anchor_change.clone(), index: c.anchor_index as u32 })
652
        },
653
        resolved: c.resolved,
654
    }
655
}
656
657
async fn proposal_comments(
658
    State(app): State<Shared>,
659
    headers: HeaderMap,
660
    Path((owner, name, number)): Path<(String, String, i64)>,
661
) -> Result<Json<Vec<Comment>>, ApiError> {
662
    let c = caller(&app, &headers).await?;
663
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
664
    let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?;
665
    Ok(Json(db::proposals::comments(&app.db, p.id).await?.iter().map(comment_of).collect()))
666
}
667
668
async fn proposal_comment(
669
    State(app): State<Shared>,
670
    headers: HeaderMap,
671
    Path((owner, name, number)): Path<(String, String, i64)>,
672
    Json(body): Json<NewComment>,
673
) -> Result<(StatusCode, Json<Comment>), ApiError> {
674
    let c = caller(&app, &headers).await?;
675
    let me = c.require("proposals:write").or_else(|_| c.require("repo:write"))?.clone();
676
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
677
    let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?;
678
    if body.body.trim().is_empty() {
679
        return Err(ApiError(StatusCode::BAD_REQUEST, "an empty comment says nothing".into()));
680
    }
681
    let (repo, _) = (RepoName::new(&owner, &name).ok_or_else(|| not_found("no such repository"))?, ());
682
    let anchor = match (body.path.as_deref().map(|x| x.trim_matches('/')), body.line) {
683
        (Some(path), Some(line)) if !path.is_empty() => {
684
            let loaded = crate::web::proposals::load(&app, &repo, p.clone()).await?;
685
            let (heads, file) = (loaded.heads.clone(), path.to_string());
686
            let atom = app
687
                .repos
688
                .read(&repo, move |r, cache| {
689
                    let projected = cache.load(r, &heads)?;
690
                    Ok(crate::repos::proposals::atom_at(&projected, &file, line).map(|(h, i)| (h.to_hex(), i)))
691
                })
692
                .await?
693
                .ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, format!("{path}:{line} is not a line of the projected state")))?;
694
            Some((path.to_string(), atom))
695
        }
696
        _ => None,
697
    };
698
    let row_c = db::proposals::add_comment(
699
        &app.db,
700
        p.id,
701
        Some(me.id),
702
        &me.username,
703
        body.body.trim(),
704
        anchor.as_ref().map(|(path, _)| path.as_str()).unwrap_or(""),
705
        anchor.as_ref().map(|(_, (h, i))| (h.as_str(), *i as i64)),
706
    )
707
    .await?;
708
    crate::notify::deliver(
709
        &app,
710
        crate::notify::Event {
711
            kind: "proposal.comment",
712
            title: format!("{owner}/{name}#{number}: {}", p.title),
713
            url: format!("/{owner}/{name}/proposal/{number}"),
714
            actor: &me,
715
            repo_id: row.id,
716
            thread: Some(("proposal", p.id)),
717
            mention_text: &body.body,
718
            direct: p.author_id.into_iter().collect(),
719
        },
720
    )
721
    .await;
722
    crate::hooks::emit(
723
        &app,
724
        &row,
725
        "comment.proposal",
726
        &me.username,
727
        serde_json::json!({ "number": number, "comment": comment_of(&row_c) }),
728
    )
729
    .await;
730
    Ok((StatusCode::CREATED, Json(comment_of(&row_c))))
731
}
732
733
async fn proposal_reviews(
734
    State(app): State<Shared>,
735
    headers: HeaderMap,
736
    Path((owner, name, number)): Path<(String, String, i64)>,
737
) -> Result<Json<Vec<Review>>, ApiError> {
738
    let c = caller(&app, &headers).await?;
739
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
740
    let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?;
741
    let loaded = crate::web::proposals::load(&app, &repo, p).await?;
742
    let v = crate::web::proposals::verdict(&app, &loaded, row.id).await?;
743
    Ok(Json(
744
        v.reviews
745
            .into_iter()
746
            .map(|r| Review {
747
                id: r.row.id,
748
                author: r.row.author,
749
                verdict: r.row.verdict,
750
                body: r.row.body,
751
                sealed: r.sealed,
752
                created_ms: r.row.created_ms,
753
            })
754
            .collect(),
755
    ))
756
}
757
758
async fn proposal_review(
759
    State(app): State<Shared>,
760
    headers: HeaderMap,
761
    Path((owner, name, number)): Path<(String, String, i64)>,
762
    Json(body): Json<NewReview>,
763
) -> Result<(StatusCode, Json<Review>), ApiError> {
764
    let c = caller(&app, &headers).await?;
765
    let me = c.require("proposals:write").or_else(|_| c.require("repo:write"))?.clone();
766
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
767
    let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?;
768
    if !matches!(body.verdict.as_str(), "comment" | "changes" | "approve") {
769
        return Err(ApiError(StatusCode::BAD_REQUEST, "verdict is comment, changes or approve".into()));
770
    }
771
    if body.verdict == "approve" && Some(me.id) == p.author_id {
772
        return Err(forbidden("you cannot approve your own proposal"));
773
    }
774
    let r = db::proposals::add_review(&app.db, p.id, me.id, &me.username, &body.verdict, body.body.trim()).await?;
775
    db::proposals::add_event(&app.db, p.id, &me.username, &format!("review.{}", body.verdict), "").await.ok();
776
    crate::hooks::emit(&app, &row, "review", &me.username, serde_json::json!({ "number": number, "verdict": body.verdict })).await;
777
    Ok((
778
        StatusCode::CREATED,
779
        Json(Review { id: r.id, author: r.author, verdict: r.verdict, body: r.body, sealed: false, created_ms: r.created_ms }),
780
    ))
781
}
782
783
async fn proposal_checks(
784
    State(app): State<Shared>,
785
    headers: HeaderMap,
786
    Path((owner, name, number)): Path<(String, String, i64)>,
787
) -> Result<Json<Vec<Check>>, ApiError> {
788
    let c = caller(&app, &headers).await?;
789
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
790
    let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?;
791
    let loaded = crate::web::proposals::load(&app, &repo, p).await?;
792
    Ok(Json(db::hooks::checks_for_state(&app.db, row.id, &loaded.state).await?.iter().map(check_of).collect()))
793
}
794
795
// ------------------------------------------------------------------ issues
796
797
async fn issue_of(app: &Shared, row: &db::repos::RepoRow, i: &db::issues::IssueRow) -> Result<Issue, ApiError> {
798
    let labels = db::issues::issue_labels(&app.db, i.id)
799
        .await?
800
        .into_iter()
801
        .map(|l| Label { name: l.name, colour: l.colour, description: l.description })
802
        .collect();
803
    let assignees = db::issues::assignees(&app.db, i.id).await?;
804
    let milestone = match i.milestone_id {
805
        Some(id) => db::issues::milestone(&app.db, id).await?.map(|m| m.title),
806
        None => None,
807
    };
808
    let comments: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issue_comments WHERE issue_id = ?").bind(i.id).fetch_one(&app.db).await?;
809
    Ok(Issue {
810
        number: i.number,
811
        title: i.title.clone(),
812
        body: i.body.clone(),
813
        author: i.author.clone(),
814
        state: i.state.clone(),
815
        reason: i.reason.clone(),
816
        labels,
817
        assignees,
818
        milestone,
819
        comments,
820
        locked: i.locked,
821
        pinned: i.pinned,
822
        created_ms: i.created_ms,
823
        updated_ms: i.updated_ms,
824
        closed_ms: i.closed_ms,
825
        url: format!("{}/{}/issue/{}", app.config.base_url(), row.slug(), i.number),
826
    })
827
}
828
829
#[derive(Deserialize)]
830
struct IssuesQuery {
831
    state: Option<String>,
832
}
833
834
async fn issues(
835
    State(app): State<Shared>,
836
    headers: HeaderMap,
837
    Path((owner, name)): Path<(String, String)>,
838
    Query(q): Query<IssuesQuery>,
839
) -> Result<Json<Vec<Issue>>, ApiError> {
840
    let c = caller(&app, &headers).await?;
841
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
842
    let state = q.state.unwrap_or_else(|| "open".into());
843
    let mut out = Vec::new();
844
    for i in db::issues::list(&app.db, row.id).await? {
845
        if state == "all" || i.state == state {
846
            out.push(issue_of(&app, &row, &i).await?);
847
        }
848
    }
849
    Ok(Json(out))
850
}
851
852
async fn create_issue(
853
    State(app): State<Shared>,
854
    headers: HeaderMap,
855
    Path((owner, name)): Path<(String, String)>,
856
    Json(body): Json<NewIssue>,
857
) -> Result<(StatusCode, Json<Issue>), ApiError> {
858
    let c = caller(&app, &headers).await?;
859
    let me = c.require("issues:write").or_else(|_| c.require("repo:write"))?.clone();
860
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
861
    if body.title.trim().is_empty() {
862
        return Err(ApiError(StatusCode::BAD_REQUEST, "an issue needs a title".into()));
863
    }
864
    let i = db::issues::create(
865
        &app.db,
866
        db::issues::NewIssue {
867
            repo_id: row.id,
868
            title: body.title.trim(),
869
            body: body.body.trim(),
870
            author_id: Some(me.id),
871
            author: &me.username,
872
        },
873
    )
874
    .await?;
875
    db::issues::add_event(&app.db, i.id, &me.username, "opened", "").await.ok();
876
    crate::web::issues::reindex(&app, row.id, &i).await;
877
    let api = issue_of(&app, &row, &i).await?;
878
    crate::notify::deliver(
879
        &app,
880
        crate::notify::Event {
881
            kind: "issue.opened",
882
            title: format!("{owner}/{name}#{}: {}", i.number, i.title),
883
            url: format!("/{owner}/{name}/issue/{}", i.number),
884
            actor: &me,
885
            repo_id: row.id,
886
            thread: Some(("issue", i.id)),
887
            mention_text: &body.body,
888
            direct: vec![],
889
        },
890
    )
891
    .await;
892
    crate::hooks::emit(&app, &row, "issue.opened", &me.username, serde_json::json!({ "issue": api })).await;
893
    Ok((StatusCode::CREATED, Json(api)))
894
}
895
896
async fn issue(
897
    State(app): State<Shared>,
898
    headers: HeaderMap,
899
    Path((owner, name, number)): Path<(String, String, i64)>,
900
) -> Result<Json<Issue>, ApiError> {
901
    let c = caller(&app, &headers).await?;
902
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
903
    let i = db::issues::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such issue"))?;
904
    Ok(Json(issue_of(&app, &row, &i).await?))
905
}
906
907
async fn issue_state(
908
    State(app): State<Shared>,
909
    headers: HeaderMap,
910
    Path((owner, name, number)): Path<(String, String, i64)>,
911
    Json(body): Json<IssueStateChange>,
912
) -> Result<Json<Issue>, ApiError> {
913
    let c = caller(&app, &headers).await?;
914
    let me = c.require("issues:write").or_else(|_| c.require("repo:write"))?.clone();
915
    let (_, row, role) = reach(&app, &c, &owner, &name, Access::Read).await?;
916
    let i = db::issues::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such issue"))?;
917
    if role < Role::Triage && Some(me.id) != i.author_id {
918
        return Err(forbidden("only the author or triage may change the state"));
919
    }
920
    let (state, reason) = match body.state.as_str() {
921
        "closed" => ("closed", if body.reason == "not_planned" { "not_planned" } else { "completed" }),
922
        "open" => ("open", ""),
923
        _ => return Err(ApiError(StatusCode::BAD_REQUEST, "state is open or closed".into())),
924
    };
925
    db::issues::set_state(&app.db, i.id, state, reason).await?;
926
    db::issues::add_event(&app.db, i.id, &me.username, if state == "closed" { "closed" } else { "reopened" }, "").await.ok();
927
    let fresh = db::issues::get(&app.db, row.id, number).await?.unwrap_or(i);
928
    let api = issue_of(&app, &row, &fresh).await?;
929
    crate::hooks::emit(
930
        &app,
931
        &row,
932
        if state == "closed" { "issue.closed" } else { "issue.reopened" },
933
        &me.username,
934
        serde_json::json!({ "issue": api }),
935
    )
936
    .await;
937
    Ok(Json(api))
938
}
939
940
async fn issue_comments(
941
    State(app): State<Shared>,
942
    headers: HeaderMap,
943
    Path((owner, name, number)): Path<(String, String, i64)>,
944
) -> Result<Json<Vec<Comment>>, ApiError> {
945
    let c = caller(&app, &headers).await?;
946
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
947
    let i = db::issues::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such issue"))?;
948
    Ok(Json(
949
        db::issues::comments(&app.db, i.id)
950
            .await?
951
            .into_iter()
952
            .map(|c| Comment {
953
                id: c.id,
954
                author: c.author,
955
                body: c.body,
956
                created_ms: c.created_ms,
957
                path: None,
958
                anchor: None,
959
                resolved: false,
960
            })
961
            .collect(),
962
    ))
963
}
964
965
async fn issue_comment(
966
    State(app): State<Shared>,
967
    headers: HeaderMap,
968
    Path((owner, name, number)): Path<(String, String, i64)>,
969
    Json(body): Json<NewComment>,
970
) -> Result<(StatusCode, Json<Comment>), ApiError> {
971
    let c = caller(&app, &headers).await?;
972
    let me = c.require("issues:write").or_else(|_| c.require("repo:write"))?.clone();
973
    let (_, row, role) = reach(&app, &c, &owner, &name, Access::Read).await?;
974
    let i = db::issues::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such issue"))?;
975
    if i.locked && role < Role::Triage {
976
        return Err(forbidden("this issue is locked"));
977
    }
978
    if body.body.trim().is_empty() {
979
        return Err(ApiError(StatusCode::BAD_REQUEST, "an empty comment says nothing".into()));
980
    }
981
    let cm = db::issues::add_comment(&app.db, i.id, Some(me.id), &me.username, body.body.trim()).await?;
982
    crate::web::issues::reindex(&app, row.id, &i).await;
983
    crate::notify::deliver(
984
        &app,
985
        crate::notify::Event {
986
            kind: "issue.comment",
987
            title: format!("{owner}/{name}#{number}: {}", i.title),
988
            url: format!("/{owner}/{name}/issue/{number}"),
989
            actor: &me,
990
            repo_id: row.id,
991
            thread: Some(("issue", i.id)),
992
            mention_text: &body.body,
993
            direct: i.author_id.into_iter().collect(),
994
        },
995
    )
996
    .await;
997
    let api = Comment { id: cm.id, author: cm.author, body: cm.body, created_ms: cm.created_ms, path: None, anchor: None, resolved: false };
998
    crate::hooks::emit(&app, &row, "comment.issue", &me.username, serde_json::json!({ "number": number, "comment": api })).await;
999
    Ok((StatusCode::CREATED, Json(api)))
1000
}
1001
1002
async fn labels(
1003
    State(app): State<Shared>,
1004
    headers: HeaderMap,
1005
    Path((owner, name)): Path<(String, String)>,
1006
) -> Result<Json<Vec<Label>>, ApiError> {
1007
    let c = caller(&app, &headers).await?;
1008
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
1009
    Ok(Json(
1010
        db::issues::labels(&app.db, row.id)
1011
            .await?
1012
            .into_iter()
1013
            .map(|l| Label { name: l.name, colour: l.colour, description: l.description })
1014
            .collect(),
1015
    ))
1016
}
1017
1018
async fn milestones(
1019
    State(app): State<Shared>,
1020
    headers: HeaderMap,
1021
    Path((owner, name)): Path<(String, String)>,
1022
) -> Result<Json<Vec<Milestone>>, ApiError> {
1023
    let c = caller(&app, &headers).await?;
1024
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
1025
    let mut out = Vec::new();
1026
    for m in db::issues::milestones(&app.db, row.id).await? {
1027
        let (open, closed) = db::issues::milestone_counts(&app.db, m.id).await?;
1028
        out.push(Milestone { title: m.title, description: m.description, due_ms: m.due_ms, state: m.state, open, closed });
1029
    }
1030
    Ok(Json(out))
1031
}
1032
1033
// --------------------------------------------------------- hooks, checks
1034
1035
fn hook_of(h: &db::hooks::Hook) -> Hook {
1036
    Hook { id: h.id, url: h.url.clone(), events: h.events.clone(), active: h.active, created_ms: h.created_ms }
1037
}
1038
1039
async fn hooks(
1040
    State(app): State<Shared>,
1041
    headers: HeaderMap,
1042
    Path((owner, name)): Path<(String, String)>,
1043
) -> Result<Json<Vec<Hook>>, ApiError> {
1044
    let c = caller(&app, &headers).await?;
1045
    c.require("hooks:admin").or_else(|_| c.require("repo:admin"))?;
1046
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Admin).await?;
1047
    Ok(Json(db::hooks::hooks_for_repo(&app.db, row.id).await?.iter().map(hook_of).collect()))
1048
}
1049
1050
async fn create_hook(
1051
    State(app): State<Shared>,
1052
    headers: HeaderMap,
1053
    Path((owner, name)): Path<(String, String)>,
1054
    Json(body): Json<NewHook>,
1055
) -> Result<(StatusCode, Json<Hook>), ApiError> {
1056
    let c = caller(&app, &headers).await?;
1057
    let me = c.require("hooks:admin").or_else(|_| c.require("repo:admin"))?.clone();
1058
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Admin).await?;
1059
    if !(body.url.starts_with("http://") || body.url.starts_with("https://")) {
1060
        return Err(ApiError(StatusCode::BAD_REQUEST, "the URL must be http or https".into()));
1061
    }
1062
    let h = db::hooks::create_hook(&app.db, Some(row.id), None, &body.url, &body.secret, &body.events).await?;
1063
    db::accounts::audit(&app.db, Some(&me), "hook.created", &row.slug(), &body.url).await.ok();
1064
    Ok((StatusCode::CREATED, Json(hook_of(&h))))
1065
}
1066
1067
async fn delete_hook(
1068
    State(app): State<Shared>,
1069
    headers: HeaderMap,
1070
    Path((owner, name, id)): Path<(String, String, i64)>,
1071
) -> Result<StatusCode, ApiError> {
1072
    let c = caller(&app, &headers).await?;
1073
    c.require("hooks:admin").or_else(|_| c.require("repo:admin"))?;
1074
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Admin).await?;
1075
    if db::hooks::hook(&app.db, id).await?.is_some_and(|h| h.repo_id == Some(row.id)) {
1076
        db::hooks::delete_hook(&app.db, id).await?;
1077
    }
1078
    Ok(StatusCode::NO_CONTENT)
1079
}
1080
1081
pub fn check_of(c: &db::hooks::Check) -> Check {
1082
    Check {
1083
        state: c.state.clone(),
1084
        name: c.name.clone(),
1085
        status: CheckStatus::parse(&c.status).unwrap_or(CheckStatus::Queued),
1086
        summary: c.summary.clone(),
1087
        url: c.url.clone(),
1088
        reporter: c.reporter.clone(),
1089
        seal: if c.seal.is_empty() { None } else { Some(c.seal.clone()) },
1090
        started_ms: c.started_ms,
1091
        finished_ms: c.finished_ms,
1092
    }
1093
}
1094
1095
/// `POST /repos/{o}/{n}/checks`: any `checks:write` (or write) token may
1096
/// report; the state hash names what was checked.
1097
async fn post_check(
1098
    State(app): State<Shared>,
1099
    headers: HeaderMap,
1100
    Path((owner, name)): Path<(String, String)>,
1101
    Json(body): Json<NewCheck>,
1102
) -> Result<(StatusCode, Json<Check>), ApiError> {
1103
    let c = caller(&app, &headers).await?;
1104
    let me = c.require("checks:write").or_else(|_| c.require("repo:write"))?.clone();
1105
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
1106
    if body.state.len() != 64 || !body.state.chars().all(|ch| ch.is_ascii_hexdigit()) {
1107
        return Err(ApiError(StatusCode::BAD_REQUEST, "state is a 64-character hex state hash".into()));
1108
    }
1109
    if body.name.trim().is_empty() || body.name.len() > 100 {
1110
        return Err(ApiError(StatusCode::BAD_REQUEST, "a check has a short name".into()));
1111
    }
1112
    let ch = db::hooks::upsert_check(
1113
        &app.db,
1114
        row.id,
1115
        &body.state,
1116
        body.name.trim(),
1117
        body.status.as_str(),
1118
        &body.summary,
1119
        &body.url,
1120
        Some((me.id, &me.username)),
1121
        body.seal.as_deref().unwrap_or(""),
1122
    )
1123
    .await?;
1124
    let api = check_of(&ch);
1125
    app.events.proposal(row.id, ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
1126
    crate::hooks::emit(&app, &row, "check", &me.username, serde_json::json!({ "check": api })).await;
1127
    Ok((StatusCode::CREATED, Json(api)))
1128
}
1129
1130
async fn checks_for_state(
1131
    State(app): State<Shared>,
1132
    headers: HeaderMap,
1133
    Path((owner, name, state)): Path<(String, String, String)>,
1134
) -> Result<Json<Vec<Check>>, ApiError> {
1135
    let c = caller(&app, &headers).await?;
1136
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
1137
    Ok(Json(db::hooks::checks_for_state(&app.db, row.id, &state).await?.iter().map(check_of).collect()))
1138
}