OXYGENHUB
# 351cf57e5608 oxygenhub / crates / hub / src / web / refs.rs
16.4 KiB · Rust · last changed A default bookmark must exist: settings refuse a missing one, the overview stands in main with a note, an unknown name is not a bookmark, tag or change; Bookmarks › New bookmark makes one on the web (closes #345) 10 days ago source blame history raw
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
26
async fn bookmarks_page(
27
    app: &Shared,
28
    shell: Shell,
29
    repo: crate::repos::RepoName,
30
    row: db::repos::RepoRow,
31
    role: Role,
32
    notice: Option<String>,
33
    error: Option<String>,
34
) -> Result<Page<BookmarksPage>, Error> {
35
    let default = row.default_bookmark.clone();
36
    let bookmarks =
37
        app.repos.read(&repo, move |r, cache| view::bookmarks(r, cache, &default)).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
38
    let can_delete = shell.user.is_some() && role >= Role::Write;
39
    let can_run = super::flow::why_not_run(crate::flow::enabled(app, &row), shell.user.is_some(), role).is_none();
40
    Ok(Page(BookmarksPage {
41
        shell,
42
        repo: RepoShell::new(app, &row, "bookmarks").with_role(role),
43
        bookmarks,
44
        can_delete,
45
        can_run,
46
        notice,
47
        error,
48
    }))
49
}
50
51
pub async fn bookmarks(
52
    State(app): State<Shared>,
53
    viewer: Viewer,
54
    Path((owner, name)): Path<(String, String)>,
55
) -> Result<Page<BookmarksPage>, Error> {
56
    let shell = Shell::new(&app, &format!("/{owner}/{name}/bookmarks"), &viewer);
57
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
58
    bookmarks_page(&app, shell, repo, row, role, None, None).await
59
}
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.
71
pub async fn delete_bookmark(
72
    State(app): State<Shared>,
73
    viewer: Viewer,
74
    Path((owner, name)): Path<(String, String)>,
75
    Form(f): Form<DeleteBookmarkForm>,
76
) -> Result<Page<BookmarksPage>, Error> {
77
    let shell = Shell::new(&app, &format!("/{owner}/{name}/bookmarks"), &viewer);
78
    let me = require(&viewer, &shell)?;
79
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
80
    csrf(&viewer, &f.csrf_token, &shell)?;
81
    let bookmark = f.bookmark.trim().to_string();
82
    if bookmark == row.default_bookmark {
83
        let msg = format!("{bookmark} is the default bookmark; choose another default in Settings first.");
84
        return bookmarks_page(&app, shell, repo, row, role, None, Some(msg)).await;
85
    }
86
    let target = bookmark.clone();
87
    let outcome = app
88
        .repos
89
        .write(&repo, move |r, _| {
90
            let Some(state) = r.view()?.bookmarks.get(&target).copied() else {
91
                return Ok(Err(format!("There is no bookmark {target}.")));
92
            };
93
            let update = oxy_core::protocol::RefUpdate {
94
                name: target.clone(),
95
                old_state: state,
96
                new_state: oxy_core::hash::ObjectRef::ZERO,
97
                force: true,
98
                attestation: None,
99
            };
100
            let report = r.receive_push_report(Default::default(), vec![update], None, vec![])?;
101
            Ok(match report.results.first() {
102
                Some(res) if res.ok => Ok(state.to_hex()),
103
                Some(res) => Err(res.reason.clone()),
104
                None => Err("nothing happened".into()),
105
            })
106
        })
107
        .await
108
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
109
    let old_state = match outcome {
110
        Ok(s) => s,
111
        Err(msg) => return bookmarks_page(&app, shell, repo, row, role, None, Some(format!("Could not delete {bookmark}: {msg}"))).await,
112
    };
113
    let result = db::repos::PushOutcome {
114
        reference: bookmark.clone(),
115
        ok: true,
116
        reason: "deleted on the web".into(),
117
        old_state: old_state.clone(),
118
        new_state: oxy_core::hash::ObjectRef::ZERO.to_hex(),
119
        heads: 0,
120
        changes: 0,
121
    };
122
    db::repos::record_push(&app.db, row.id, Some((me.account.id, &me.account.username)), &[result]).await.ok();
123
    crate::hooks::emit(
124
        &app,
125
        &row,
126
        "push",
127
        &me.account.username,
128
        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" }),
129
    )
130
    .await;
131
    db::accounts::audit(&app.db, Some(&me.account), "bookmark.deleted", &format!("{owner}/{name}"), &bookmark).await.ok();
132
    bookmarks_page(
133
        &app,
134
        shell,
135
        repo,
136
        row,
137
        role,
138
        Some(format!("Deleted {bookmark}. Its changes stay while anything else reaches them.")),
139
        None,
140
    )
141
    .await
142
}
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.
157
pub async fn create_bookmark(
158
    State(app): State<Shared>,
159
    viewer: Viewer,
160
    Path((owner, name)): Path<(String, String)>,
161
    Form(f): Form<CreateBookmarkForm>,
162
) -> Result<Page<BookmarksPage>, Error> {
163
    let shell = Shell::new(&app, &format!("/{owner}/{name}/bookmarks"), &viewer);
164
    let me = require(&viewer, &shell)?;
165
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
166
    csrf(&viewer, &f.csrf_token, &shell)?;
167
    let bookmark = f.name.trim().to_string();
168
    let from = match f.from.trim() {
169
        "" => row.default_bookmark.clone(),
170
        s => s.to_string(),
171
    };
172
    if bookmark.is_empty() || bookmark.chars().any(char::is_whitespace) {
173
        return bookmarks_page(&app, shell, repo, row, role, None, Some("A bookmark's name has no spaces.".into())).await;
174
    }
175
    let (wanted, spec) = (bookmark.clone(), from.clone());
176
    let found = app
177
        .repos
178
        .read(&repo, move |r, cache| {
179
            if r.view()?.bookmarks.contains_key(&wanted) {
180
                return Ok(Err(format!("{wanted} is a bookmark already.")));
181
            }
182
            Ok(view::resolve(r, cache, &spec).map(|t| t.heads).map_err(|e| e.to_string()))
183
        })
184
        .await
185
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
186
    let heads = match found {
187
        Ok(heads) if !heads.is_empty() => heads,
188
        Ok(_) => return bookmarks_page(&app, shell, repo, row, role, None, Some(format!("{from} holds no changes to start from."))).await,
189
        Err(msg) => return bookmarks_page(&app, shell, repo, row, role, None, Some(msg)).await,
190
    };
191
    // As the user's delegate when they have one; otherwise unsigned, which
192
    // a repository without a trust policy takes, as a push without one.
193
    let made = match super::edit::signer_for(&app, me.account.id).await {
194
        Ok(signer) => crate::repos::edits::move_bookmark(&app.repos, &repo, bookmark.clone(), heads.clone(), signer)
195
            .await
196
            .map(|_| ())
197
            .map_err(|e| e.to_string()),
198
        Err(why) => {
199
            let (target, at) = (bookmark.clone(), heads.clone());
200
            let unsigned = app
201
                .repos
202
                .write(&repo, move |r, _| {
203
                    let update = r.host_ref_update(&target, &at, None)?;
204
                    let report = r.receive_push_report(Default::default(), vec![update], None, vec![])?;
205
                    Ok(match report.results.first() {
206
                        Some(res) if res.ok => Ok(()),
207
                        Some(res) => Err(res.reason.clone()),
208
                        None => Err("nothing happened".to_string()),
209
                    })
210
                })
211
                .await
212
                .map_err(|e| e.to_string())
213
                .and_then(|r| r);
214
            unsigned.map_err(|refused| format!("{refused}. {why}"))
215
        }
216
    };
217
    if let Err(e) = made {
218
        return bookmarks_page(&app, shell, repo, row, role, None, Some(format!("Could not create {bookmark}: {e}"))).await;
219
    }
220
    let made = bookmark.clone();
221
    let new_state = app
222
        .repos
223
        .read(&repo, move |r, _| Ok(r.view()?.bookmarks.get(&made).map(|s| s.to_hex()).unwrap_or_default()))
224
        .await
225
        .unwrap_or_default();
226
    let result = db::repos::PushOutcome {
227
        reference: bookmark.clone(),
228
        ok: true,
229
        reason: format!("created on the web from {from}"),
230
        old_state: oxy_core::hash::ObjectRef::ZERO.to_hex(),
231
        new_state: new_state.clone(),
232
        heads: heads.len() as i64,
233
        changes: 0,
234
    };
235
    db::repos::record_push(&app.db, row.id, Some((me.account.id, &me.account.username)), &[result]).await.ok();
236
    crate::hooks::emit(
237
        &app,
238
        &row,
239
        "push",
240
        &me.account.username,
241
        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" }),
242
    )
243
    .await;
244
    db::accounts::audit(&app.db, Some(&me.account), "bookmark.created", &format!("{owner}/{name}"), &format!("{bookmark} from {from}"))
245
        .await
246
        .ok();
247
    let notice = format!("Created {bookmark} from {from}. Propose changes into it, or make it the default in Settings.");
248
    bookmarks_page(&app, shell, repo, row, role, Some(notice), None).await
249
}
250
251
#[derive(Template)]
252
#[template(path = "tags.html")]
253
pub struct TagsPage {
254
    shell: Shell,
255
    repo: RepoShell,
256
    tags: Vec<view::TagView>,
257
    withdrawn: Vec<view::WithdrawnView>,
258
    /// Tags a release uses (not withdrawn while it does).
259
    released: Vec<String>,
260
    can_create: bool,
261
    can_withdraw: bool,
262
    notice: Option<String>,
263
    error: Option<String>,
264
}
265
266
impl TagsPage {
267
    fn released(&self, tag: &str) -> bool {
268
        self.released.iter().any(|t| t == tag)
269
    }
270
}
271
272
async fn tags_page(
273
    app: &Shared,
274
    shell: Shell,
275
    repo: crate::repos::RepoName,
276
    row: db::repos::RepoRow,
277
    role: Role,
278
    notice: Option<String>,
279
    error: Option<String>,
280
) -> Result<Page<TagsPage>, Error> {
281
    let (tags, withdrawn) = app
282
        .repos
283
        .read(&repo, |r, _| Ok((view::tags(r)?, view::withdrawn_tags(r)?)))
284
        .await
285
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
286
    let released = db::releases::list(&app.db, row.id).await.unwrap_or_default().into_iter().map(|r| r.tag).collect();
287
    let signed_in = shell.user.is_some();
288
    Ok(Page(TagsPage {
289
        shell,
290
        repo: RepoShell::new(app, &row, "tags").with_role(role),
291
        tags,
292
        withdrawn,
293
        released,
294
        can_create: signed_in && role >= Role::Write,
295
        can_withdraw: signed_in && role >= Role::Maintain,
296
        notice,
297
        error,
298
    }))
299
}
300
301
pub async fn tags(State(app): State<Shared>, viewer: Viewer, Path((owner, name)): Path<(String, String)>) -> Result<Page<TagsPage>, Error> {
302
    let shell = Shell::new(&app, &format!("/{owner}/{name}/tags"), &viewer);
303
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
304
    tags_page(&app, shell, repo, row, role, None, None).await
305
}
306
307
#[derive(Deserialize)]
308
pub struct CreateTagForm {
309
    #[serde(rename = "_csrf")]
310
    csrf_token: String,
311
    name: String,
312
    #[serde(default)]
313
    target: String,
314
    #[serde(default)]
315
    message: String,
316
}
317
318
/// Create a tag from the web (#295): signed by the hub key as the user's
319
/// delegate, under the policy, as `oxy tag create` and a push would be.
320
pub async fn create_tag(
321
    State(app): State<Shared>,
322
    viewer: Viewer,
323
    Path((owner, name)): Path<(String, String)>,
324
    Form(f): Form<CreateTagForm>,
325
) -> Result<Page<TagsPage>, Error> {
326
    let shell = Shell::new(&app, &format!("/{owner}/{name}/tags"), &viewer);
327
    let me = require(&viewer, &shell)?;
328
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
329
    csrf(&viewer, &f.csrf_token, &shell)?;
330
    match crate::tags::create(&app, &row, &repo, &me.account, &f.name, &f.target, &f.message).await {
331
        Ok(t) => {
332
            let notice = format!(
333
                "Tagged {} ({} head{}). Start a release from it under Releases.",
334
                t.name,
335
                t.heads.len(),
336
                if t.heads.len() == 1 { "" } else { "s" }
337
            );
338
            tags_page(&app, shell, repo, row, role, Some(notice), None).await
339
        }
340
        Err(e) => tags_page(&app, shell, repo, row, role, None, Some(format!("Could not create the tag: {e}"))).await,
341
    }
342
}
343
344
#[derive(Deserialize)]
345
pub struct WithdrawTagForm {
346
    #[serde(rename = "_csrf")]
347
    csrf_token: String,
348
    tag: String,
349
    #[serde(default)]
350
    reason: String,
351
}
352
353
/// Withdraw a tag from the web (#301), as `oxy push --delete tags/NAME`
354
/// does: a signed withdrawal, kept and logged. Maintainers only; never
355
/// while a release uses the tag.
356
pub async fn withdraw_tag(
357
    State(app): State<Shared>,
358
    viewer: Viewer,
359
    Path((owner, name)): Path<(String, String)>,
360
    Form(f): Form<WithdrawTagForm>,
361
) -> Result<Page<TagsPage>, Error> {
362
    let shell = Shell::new(&app, &format!("/{owner}/{name}/tags"), &viewer);
363
    let me = require(&viewer, &shell)?;
364
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
365
    csrf(&viewer, &f.csrf_token, &shell)?;
366
    match crate::tags::withdraw(&app, &row, &repo, &me.account, &f.tag, &f.reason).await {
367
        Ok(t) => {
368
            let notice = format!("Withdrew {}. Clones drop it on their next fetch; the name is not used again.", t.name);
369
            tags_page(&app, shell, repo, row, role, Some(notice), None).await
370
        }
371
        Err(e) => tags_page(&app, shell, repo, row, role, None, Some(format!("Could not withdraw {}: {e}", f.tag.trim()))).await,
372
    }
373
}
374
375
#[derive(Template)]
376
#[template(path = "conflicts.html")]
377
pub struct ConflictsPage {
378
    shell: Shell,
379
    repo: RepoShell,
380
    conflicts: Vec<view::ConflictView>,
381
}
382
383
pub async fn conflicts(
384
    State(app): State<Shared>,
385
    viewer: Viewer,
386
    Path((owner, name)): Path<(String, String)>,
387
) -> Result<Page<ConflictsPage>, Error> {
388
    let shell = Shell::new(&app, &format!("/{owner}/{name}/conflicts"), &viewer);
389
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
390
    let conflicts = app.repos.read(&repo, view::conflicts).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
391
    Ok(Page(ConflictsPage { shell, repo: RepoShell::new(&app, &row, "conflicts").with_role(role).with_sky(&app, &row).await, conflicts }))
392
}
393
394
#[derive(Template)]
395
#[template(path = "trust.html")]
396
pub struct TrustPage {
397
    shell: Shell,
398
    repo: RepoShell,
399
    t: view::Trust,
400
}
401
402
pub async fn trust(
403
    State(app): State<Shared>,
404
    viewer: Viewer,
405
    Path((owner, name)): Path<(String, String)>,
406
) -> Result<Page<TrustPage>, Error> {
407
    let shell = Shell::new(&app, &format!("/{owner}/{name}/trust"), &viewer);
408
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
409
    let key = app.hub_key.key;
410
    let t = app.repos.read(&repo, move |r, cache| view::trust(r, cache, &key)).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
411
    Ok(Page(TrustPage { shell, repo: RepoShell::new(&app, &row, "trust").with_role(role), t }))
412
}
413
414
#[derive(Template)]
415
#[template(path = "pushes.html")]
416
pub struct PushesPage {
417
    shell: Shell,
418
    repo: RepoShell,
419
    pushes: Vec<db::repos::PushRow>,
420
}
421
422
pub async fn pushes(
423
    State(app): State<Shared>,
424
    viewer: Viewer,
425
    Path((owner, name)): Path<(String, String)>,
426
) -> Result<Page<PushesPage>, Error> {
427
    let shell = Shell::new(&app, &format!("/{owner}/{name}/pushes"), &viewer);
428
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
429
    let pushes = db::repos::pushes(&app.db, row.id, 200).await.map_err(|e| Error::from_db(shell.clone(), e))?;
430
    Ok(Page(PushesPage { shell, repo: RepoShell::new(&app, &row, "pushes").with_role(role), pushes }))
431
}