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