OXYGENHUB

Coverage / crates/hub/src/web/refs.rs 95.1%

312 of 328 lines run, 35 of 90 functions · run #513 on main · run missed

1
//! Bookmarks, tags, conflicts, trust and pushes pages.
2
3
use super::settings::{csrf, require};
4
use super::{filters, lookup, lookup_role, Error, Page, RepoShell, Shell};
5
use crate::auth::{Access, Role, Viewer};
6
use crate::repos::view;
7
use crate::{db, Shared};
8
use askama::Template;
9
use axum::extract::{Form, Path, State};
10
use serde::Deserialize;
11
12
#[derive(Template)]
13
#[template(path = "bookmarks.html")]
14
pub struct BookmarksPage {
15
    shell: Shell,
16
    repo: RepoShell,
17
    bookmarks: Vec<view::BookmarkView>,
18
    /// Whether the viewer may create and delete bookmarks (write access).
19
    can_delete: bool,
20
    /// Whether the viewer may run the pipeline of a bookmark.
21
    can_run: bool,
22
    notice: Option<String>,
23
    error: Option<String>,
24
}
25
2616×
async fn bookmarks_page(
2716×
    app: &Shared,
2816×
    shell: Shell,
2916×
    repo: crate::repos::RepoName,
3016×
    row: db::repos::RepoRow,
3116×
    role: Role,
3216×
    notice: Option<String>,
3316×
    error: Option<String>,
3416×
) -> Result<Page<BookmarksPage>, Error> {
3516×
    let default = row.default_bookmark.clone();
3616×
    let bookmarks =
3716×
        app.repos.read(&repo, move |r, cache| view::bookmarks(r, cache, &default)).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
3816×
    let can_delete = shell.user.is_some() && role >= Role::Write;
3916×
    let can_run = super::flow::why_not_run(crate::flow::enabled(app, &row), shell.user.is_some(), role).is_none();
4016×
    Ok(Page(BookmarksPage {
4116×
        shell,
4216×
        repo: RepoShell::new(app, &row, "bookmarks").with_role(role),
4316×
        bookmarks,
4416×
        can_delete,
4516×
        can_run,
4616×
        notice,
4716×
        error,
4816×
    }))
4916×
}
50
518×
pub async fn bookmarks(
528×
    State(app): State<Shared>,
538×
    viewer: Viewer,
548×
    Path((owner, name)): Path<(String, String)>,
558×
) -> Result<Page<BookmarksPage>, Error> {
568×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/bookmarks"), &viewer);
578×
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
588×
    bookmarks_page(&app, shell, repo, row, role, None, None).await
598×
}
60
61
#[derive(Deserialize)]
62
pub struct DeleteBookmarkForm {
63
    #[serde(rename = "_csrf")]
64
    csrf_token: String,
65
    bookmark: String,
66
}
67
68
/// Delete a bookmark from the web (hub spec §6.4): what `oxy push --delete`
69
/// does, through the same checks. Never the default bookmark; refused under
70
/// a trust policy, which needs a signed attestation only a client can make.
715×
pub async fn delete_bookmark(
725×
    State(app): State<Shared>,
735×
    viewer: Viewer,
745×
    Path((owner, name)): Path<(String, String)>,
755×
    Form(f): Form<DeleteBookmarkForm>,
765×
) -> Result<Page<BookmarksPage>, Error> {
775×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/bookmarks"), &viewer);
785×
    let me = require(&viewer, &shell)?;
794×
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
804×
    csrf(&viewer, &f.csrf_token, &shell)?;
814×
    let bookmark = f.bookmark.trim().to_string();
824×
    if bookmark == row.default_bookmark {
831×
        let msg = format!("{bookmark} is the default bookmark; choose another default in Settings first.");
841×
        return bookmarks_page(&app, shell, repo, row, role, None, Some(msg)).await;
853×
    }
863×
    let target = bookmark.clone();
873×
    let outcome = app
883×
        .repos
893×
        .write(&repo, move |r, _| {
903×
            let Some(state) = r.view()?.bookmarks.get(&target).copied() else {
911×
                return Ok(Err(format!("There is no bookmark {target}.")));
92
            };
932×
            let update = oxy_core::protocol::RefUpdate {
942×
                name: target.clone(),
952×
                old_state: state,
962×
                new_state: oxy_core::hash::ObjectRef::ZERO,
972×
                force: true,
982×
                attestation: None,
992×
            };
1002×
            let report = r.receive_push_report(Default::default(), vec![update], None, vec![])?;
1012×
            Ok(match report.results.first() {
1022×
                Some(res) if res.ok => Ok(state.to_hex()),
1030×
                Some(res) => Err(res.reason.clone()),
1040×
                None => Err("nothing happened".into()),
105
            })
1063×
        })
1073×
        .await
1083×
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
1093×
    let old_state = match outcome {
1102×
        Ok(s) => s,
1111×
        Err(msg) => return bookmarks_page(&app, shell, repo, row, role, None, Some(format!("Could not delete {bookmark}: {msg}"))).await,
112
    };
1132×
    let result = db::repos::PushOutcome {
1142×
        reference: bookmark.clone(),
1152×
        ok: true,
1162×
        reason: "deleted on the web".into(),
1172×
        old_state: old_state.clone(),
1182×
        new_state: oxy_core::hash::ObjectRef::ZERO.to_hex(),
1192×
        heads: 0,
1202×
        changes: 0,
1212×
    };
1222×
    db::repos::record_push(&app.db, row.id, Some((me.account.id, &me.account.username)), &[result]).await.ok();
1232×
    crate::hooks::emit(
1242×
        &app,
1252×
        &row,
1262×
        "push",
1272×
        &me.account.username,
1282×
        serde_json::json!({ "updates": [{ "reference": bookmark, "old_state": old_state, "new_state": oxy_core::hash::ObjectRef::ZERO.to_hex(), "heads": 0, "changes": 0, "deleted": true }], "via": "web" }),
1292×
    )
1302×
    .await;
1312×
    db::accounts::audit(&app.db, Some(&me.account), "bookmark.deleted", &format!("{owner}/{name}"), &bookmark).await.ok();
1322×
    bookmarks_page(
1332×
        &app,
1342×
        shell,
1352×
        repo,
1362×
        row,
1372×
        role,
1382×
        Some(format!("Deleted {bookmark}. Its changes stay while anything else reaches them.")),
1392×
        None,
1402×
    )
1412×
    .await
1425×
}
143
144
#[derive(Deserialize)]
145
pub struct CreateBookmarkForm {
146
    #[serde(rename = "_csrf")]
147
    csrf_token: String,
148
    name: String,
149
    /// A bookmark, tag or change; the default bookmark when empty.
150
    #[serde(default)]
151
    from: String,
152
}
153
154
/// Create a bookmark from the web at the heads of a bookmark, tag or change:
155
/// what pushing a new bookmark does, signed by the hub key as the user's
156
/// delegate, as the web's other changes to bookmarks are.
1574×
pub async fn create_bookmark(
1584×
    State(app): State<Shared>,
1594×
    viewer: Viewer,
1604×
    Path((owner, name)): Path<(String, String)>,
1614×
    Form(f): Form<CreateBookmarkForm>,
1624×
) -> Result<Page<BookmarksPage>, Error> {
1634×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/bookmarks"), &viewer);
1644×
    let me = require(&viewer, &shell)?;
1654×
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
1664×
    csrf(&viewer, &f.csrf_token, &shell)?;
1674×
    let bookmark = f.name.trim().to_string();
1684×
    let from = match f.from.trim() {
1694×
        "" => row.default_bookmark.clone(),
1703×
        s => s.to_string(),
171
    };
1724×
    if bookmark.is_empty() || bookmark.chars().any(char::is_whitespace) {
1731×
        return bookmarks_page(&app, shell, repo, row, role, None, Some("A bookmark's name has no spaces.".into())).await;
1743×
    }
1753×
    let (wanted, spec) = (bookmark.clone(), from.clone());
1763×
    let found = app
1773×
        .repos
1783×
        .read(&repo, move |r, cache| {
1793×
            if r.view()?.bookmarks.contains_key(&wanted) {
1801×
                return Ok(Err(format!("{wanted} is a bookmark already.")));
1812×
            }
1822×
            Ok(view::resolve(r, cache, &spec).map(|t| t.heads).map_err(|e| e.to_string()))
1833×
        })
1843×
        .await
1853×
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
1861×
    let heads = match found {
1871×
        Ok(heads) if !heads.is_empty() => heads,
1880×
        Ok(_) => return bookmarks_page(&app, shell, repo, row, role, None, Some(format!("{from} holds no changes to start from."))).await,
1892×
        Err(msg) => return bookmarks_page(&app, shell, repo, row, role, None, Some(msg)).await,
190
    };
1911×
    let made = set_bookmark(&app, &repo, me.account.id, &bookmark, heads.clone()).await;
1921×
    if let Err(e) = made {
1930×
        return bookmarks_page(&app, shell, repo, row, role, None, Some(format!("Could not create {bookmark}: {e}"))).await;
1941×
    }
1951×
    let made = bookmark.clone();
1961×
    let new_state = app
1971×
        .repos
1981×
        .read(&repo, move |r, _| Ok(r.view()?.bookmarks.get(&made).map(|s| s.to_hex()).unwrap_or_default()))
1991×
        .await
2001×
        .unwrap_or_default();
2011×
    let result = db::repos::PushOutcome {
2021×
        reference: bookmark.clone(),
2031×
        ok: true,
2041×
        reason: format!("created on the web from {from}"),
2051×
        old_state: oxy_core::hash::ObjectRef::ZERO.to_hex(),
2061×
        new_state: new_state.clone(),
2071×
        heads: heads.len() as i64,
2081×
        changes: 0,
2091×
    };
2101×
    db::repos::record_push(&app.db, row.id, Some((me.account.id, &me.account.username)), &[result]).await.ok();
2111×
    crate::hooks::emit(
2121×
        &app,
2131×
        &row,
2141×
        "push",
2151×
        &me.account.username,
2161×
        serde_json::json!({ "updates": [{ "reference": bookmark, "old_state": oxy_core::hash::ObjectRef::ZERO.to_hex(), "new_state": new_state, "heads": heads.len(), "changes": 0, "created": true }], "via": "web" }),
2171×
    )
2181×
    .await;
2191×
    db::accounts::audit(&app.db, Some(&me.account), "bookmark.created", &format!("{owner}/{name}"), &format!("{bookmark} from {from}"))
2201×
        .await
2211×
        .ok();
2221×
    let notice = format!("Created {bookmark} from {from}. Propose changes into it, or make it the default in Settings.");
2231×
    bookmarks_page(&app, shell, repo, row, role, Some(notice), None).await
2244×
}
225
226
/// Move (or make) `bookmark` at `heads` from the web: as the user's
227
/// delegate when they have one, otherwise unsigned, which a repository
228
/// without a trust policy takes, as a push without one. `Err` says why not.
2292×
pub async fn set_bookmark(
2302×
    app: &Shared,
2312×
    repo: &crate::repos::RepoName,
2322×
    account_id: i64,
2332×
    bookmark: &str,
2342×
    heads: Vec<oxy_core::hash::ChangeHash>,
2352×
) -> Result<(), String> {
2362×
    match super::edit::signer_for(app, account_id).await {
2371×
        Ok(signer) => crate::repos::edits::move_bookmark(&app.repos, repo, bookmark.to_string(), heads, signer)
2381×
            .await
2391×
            .map(|_| ())
2401×
            .map_err(|e| e.to_string()),
2411×
        Err(why) => {
2421×
            let target = bookmark.to_string();
2431×
            let unsigned = app
2441×
                .repos
2451×
                .write(repo, move |r, _| {
2461×
                    let update = r.host_ref_update(&target, &heads, None)?;
2471×
                    let report = r.receive_push_report(Default::default(), vec![update], None, vec![])?;
2481×
                    Ok(match report.results.first() {
2491×
                        Some(res) if res.ok => Ok(()),
2500×
                        Some(res) => Err(res.reason.clone()),
2510×
                        None => Err("nothing happened".to_string()),
252
                    })
2531×
                })
2541×
                .await
2551×
                .map_err(|e| e.to_string())
2561×
                .and_then(|r| r);
2571×
            unsigned.map_err(|refused| format!("{refused}. {why}"))
258
        }
259
    }
2602×
}
261
262
#[derive(Template)]
263
#[template(path = "tags.html")]
264
pub struct TagsPage {
265
    shell: Shell,
266
    repo: RepoShell,
267
    tags: Vec<view::TagView>,
268
    withdrawn: Vec<view::WithdrawnView>,
269
    /// Tags a release uses (not withdrawn while it does).
270
    released: Vec<String>,
271
    can_create: bool,
272
    can_withdraw: bool,
273
    notice: Option<String>,
274
    error: Option<String>,
275
}
276
277
impl TagsPage {
27825×
    fn released(&self, tag: &str) -> bool {
27925×
        self.released.iter().any(|t| t == tag)
28025×
    }
281
}
282
2839×
async fn tags_page(
2849×
    app: &Shared,
2859×
    shell: Shell,
2869×
    repo: crate::repos::RepoName,
2879×
    row: db::repos::RepoRow,
2889×
    role: Role,
2899×
    notice: Option<String>,
2909×
    error: Option<String>,
2919×
) -> Result<Page<TagsPage>, Error> {
2929×
    let (tags, withdrawn) = app
2939×
        .repos
2949×
        .read(&repo, |r, _| Ok((view::tags(r)?, view::withdrawn_tags(r)?)))
2959×
        .await
2969×
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
2979×
    let released = db::releases::list(&app.db, row.id).await.unwrap_or_default().into_iter().map(|r| r.tag).collect();
2989×
    let signed_in = shell.user.is_some();
299
    Ok(Page(TagsPage {
3009×
        shell,
3019×
        repo: RepoShell::new(app, &row, "tags").with_role(role),
3029×
        tags,
3039×
        withdrawn,
3049×
        released,
3059×
        can_create: signed_in && role >= Role::Write,
3069×
        can_withdraw: signed_in && role >= Role::Maintain,
3079×
        notice,
3089×
        error,
309
    }))
3109×
}
311
3123×
pub async fn tags(State(app): State<Shared>, viewer: Viewer, Path((owner, name)): Path<(String, String)>) -> Result<Page<TagsPage>, Error> {
3133×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/tags"), &viewer);
3143×
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
3153×
    tags_page(&app, shell, repo, row, role, None, None).await
3163×
}
317
318
#[derive(Deserialize)]
319
pub struct CreateTagForm {
320
    #[serde(rename = "_csrf")]
321
    csrf_token: String,
322
    name: String,
323
    #[serde(default)]
324
    target: String,
325
    #[serde(default)]
326
    message: String,
327
}
328
329
/// Create a tag from the web (#295): signed by the hub key as the user's
330
/// delegate, under the policy, as `oxy tag create` and a push would be.
3314×
pub async fn create_tag(
3324×
    State(app): State<Shared>,
3334×
    viewer: Viewer,
3344×
    Path((owner, name)): Path<(String, String)>,
3354×
    Form(f): Form<CreateTagForm>,
3364×
) -> Result<Page<TagsPage>, Error> {
3374×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/tags"), &viewer);
3384×
    let me = require(&viewer, &shell)?;
3394×
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
3404×
    csrf(&viewer, &f.csrf_token, &shell)?;
3414×
    match crate::tags::create(&app, &row, &repo, &me.account, &f.name, &f.target, &f.message).await {
3422×
        Ok(t) => {
3432×
            let notice = format!(
344
                "Tagged {} ({} head{}). Start a release from it under Releases.",
345
                t.name,
3462×
                t.heads.len(),
3472×
                if t.heads.len() == 1 { "" } else { "s" }
348
            );
3492×
            tags_page(&app, shell, repo, row, role, Some(notice), None).await
350
        }
3512×
        Err(e) => tags_page(&app, shell, repo, row, role, None, Some(format!("Could not create the tag: {e}"))).await,
352
    }
3534×
}
354
355
#[derive(Deserialize)]
356
pub struct WithdrawTagForm {
357
    #[serde(rename = "_csrf")]
358
    csrf_token: String,
359
    tag: String,
360
    #[serde(default)]
361
    reason: String,
362
}
363
364
/// Withdraw a tag from the web (#301), as `oxy push --delete tags/NAME`
365
/// does: a signed withdrawal, kept and logged. Maintainers only; never
366
/// while a release uses the tag.
3672×
pub async fn withdraw_tag(
3682×
    State(app): State<Shared>,
3692×
    viewer: Viewer,
3702×
    Path((owner, name)): Path<(String, String)>,
3712×
    Form(f): Form<WithdrawTagForm>,
3722×
) -> Result<Page<TagsPage>, Error> {
3732×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/tags"), &viewer);
3742×
    let me = require(&viewer, &shell)?;
3752×
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
3762×
    csrf(&viewer, &f.csrf_token, &shell)?;
3772×
    match crate::tags::withdraw(&app, &row, &repo, &me.account, &f.tag, &f.reason).await {
3781×
        Ok(t) => {
3791×
            let notice = format!("Withdrew {}. Clones drop it on their next fetch; the name is not used again.", t.name);
3801×
            tags_page(&app, shell, repo, row, role, Some(notice), None).await
381
        }
3821×
        Err(e) => tags_page(&app, shell, repo, row, role, None, Some(format!("Could not withdraw {}: {e}", f.tag.trim()))).await,
383
    }
3842×
}
385
386
#[derive(Template)]
387
#[template(path = "conflicts.html")]
388
pub struct ConflictsPage {
389
    shell: Shell,
390
    repo: RepoShell,
391
    conflicts: Vec<view::ConflictView>,
392
}
393
3948×
pub async fn conflicts(
3958×
    State(app): State<Shared>,
3968×
    viewer: Viewer,
3978×
    Path((owner, name)): Path<(String, String)>,
3988×
) -> Result<Page<ConflictsPage>, Error> {
3998×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/conflicts"), &viewer);
4008×
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
4018×
    let conflicts = app.repos.read(&repo, view::conflicts).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
4028×
    Ok(Page(ConflictsPage { shell, repo: RepoShell::new(&app, &row, "conflicts").with_role(role).with_sky(&app, &row).await, conflicts }))
4038×
}
404
405
#[derive(Template)]
406
#[template(path = "trust.html")]
407
pub struct TrustPage {
408
    shell: Shell,
409
    repo: RepoShell,
410
    t: view::Trust,
411
}
412
4132×
pub async fn trust(
4142×
    State(app): State<Shared>,
4152×
    viewer: Viewer,
4162×
    Path((owner, name)): Path<(String, String)>,
4172×
) -> Result<Page<TrustPage>, Error> {
4182×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/trust"), &viewer);
4192×
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
4202×
    let key = app.hub_key.key;
4212×
    let t = app.repos.read(&repo, move |r, cache| view::trust(r, cache, &key)).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
4222×
    Ok(Page(TrustPage { shell, repo: RepoShell::new(&app, &row, "trust").with_role(role), t }))
4232×
}
424
425
#[derive(Template)]
426
#[template(path = "pushes.html")]
427
pub struct PushesPage {
428
    shell: Shell,
429
    repo: RepoShell,
430
    pushes: Vec<db::repos::PushRow>,
431
}
432
4336×
pub async fn pushes(
4346×
    State(app): State<Shared>,
4356×
    viewer: Viewer,
4366×
    Path((owner, name)): Path<(String, String)>,
4376×
) -> Result<Page<PushesPage>, Error> {
4386×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/pushes"), &viewer);
4396×
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
4406×
    let pushes = db::repos::pushes(&app.db, row.id, 200).await.map_err(|e| Error::from_db(shell.clone(), e))?;
4416×
    Ok(Page(PushesPage { shell, repo: RepoShell::new(&app, &row, "pushes").with_role(role), pushes }))
4426×
}