//! Bookmarks, tags, conflicts, trust and pushes pages. use super::settings::{csrf, require}; use super::{filters, lookup, lookup_role, Error, Page, RepoShell, Shell}; use crate::auth::{Access, Role, Viewer}; use crate::repos::view; use crate::{db, Shared}; use askama::Template; use axum::extract::{Form, Path, State}; use serde::Deserialize; #[derive(Template)] #[template(path = "bookmarks.html")] pub struct BookmarksPage { shell: Shell, repo: RepoShell, bookmarks: Vec, /// Whether the viewer may delete bookmarks (write access). can_delete: bool, /// Whether the viewer may run the pipeline of a bookmark. can_run: bool, notice: Option, error: Option, } async fn bookmarks_page( app: &Shared, shell: Shell, repo: crate::repos::RepoName, row: db::repos::RepoRow, role: Role, notice: Option, error: Option, ) -> Result, Error> { let default = row.default_bookmark.clone(); let bookmarks = app.repos.read(&repo, move |r, cache| view::bookmarks(r, cache, &default)).await.map_err(|e| Error::from_repo(shell.clone(), e))?; let can_delete = shell.user.is_some() && role >= Role::Write; let can_run = super::flow::why_not_run(crate::flow::enabled(app, &row), shell.user.is_some(), role).is_none(); Ok(Page(BookmarksPage { shell, repo: RepoShell::new(app, &row, "bookmarks").with_role(role), bookmarks, can_delete, can_run, notice, error, })) } pub async fn bookmarks( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/bookmarks"), &viewer); let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; bookmarks_page(&app, shell, repo, row, role, None, None).await } #[derive(Deserialize)] pub struct DeleteBookmarkForm { #[serde(rename = "_csrf")] csrf_token: String, bookmark: String, } /// Delete a bookmark from the web (hub spec ยง6.4): what `oxy push --delete` /// does, through the same checks. Never the default bookmark; refused under /// a trust policy, which needs a signed attestation only a client can make. pub async fn delete_bookmark( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/bookmarks"), &viewer); let me = require(&viewer, &shell)?; let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?; csrf(&viewer, &f.csrf_token, &shell)?; let bookmark = f.bookmark.trim().to_string(); if bookmark == row.default_bookmark { let msg = format!("{bookmark} is the default bookmark; choose another default in Settings first."); return bookmarks_page(&app, shell, repo, row, role, None, Some(msg)).await; } let target = bookmark.clone(); let outcome = app .repos .write(&repo, move |r, _| { let Some(state) = r.view()?.bookmarks.get(&target).copied() else { return Ok(Err(format!("There is no bookmark {target}."))); }; let update = oxy_core::protocol::RefUpdate { name: target.clone(), old_state: state, new_state: oxy_core::hash::ObjectRef::ZERO, force: true, attestation: None, }; let report = r.receive_push_report(Default::default(), vec![update], None, vec![])?; Ok(match report.results.first() { Some(res) if res.ok => Ok(state.to_hex()), Some(res) => Err(res.reason.clone()), None => Err("nothing happened".into()), }) }) .await .map_err(|e| Error::from_repo(shell.clone(), e))?; let old_state = match outcome { Ok(s) => s, Err(msg) => return bookmarks_page(&app, shell, repo, row, role, None, Some(format!("Could not delete {bookmark}: {msg}"))).await, }; let result = db::repos::PushOutcome { reference: bookmark.clone(), ok: true, reason: "deleted on the web".into(), old_state: old_state.clone(), new_state: oxy_core::hash::ObjectRef::ZERO.to_hex(), heads: 0, changes: 0, }; db::repos::record_push(&app.db, row.id, Some((me.account.id, &me.account.username)), &[result]).await.ok(); crate::hooks::emit( &app, &row, "push", &me.account.username, 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" }), ) .await; db::accounts::audit(&app.db, Some(&me.account), "bookmark.deleted", &format!("{owner}/{name}"), &bookmark).await.ok(); bookmarks_page( &app, shell, repo, row, role, Some(format!("Deleted {bookmark}. Its changes stay while anything else reaches them.")), None, ) .await } #[derive(Template)] #[template(path = "tags.html")] pub struct TagsPage { shell: Shell, repo: RepoShell, tags: Vec, withdrawn: Vec, /// Tags a release uses (not withdrawn while it does). released: Vec, can_create: bool, can_withdraw: bool, notice: Option, error: Option, } impl TagsPage { fn released(&self, tag: &str) -> bool { self.released.iter().any(|t| t == tag) } } async fn tags_page( app: &Shared, shell: Shell, repo: crate::repos::RepoName, row: db::repos::RepoRow, role: Role, notice: Option, error: Option, ) -> Result, Error> { let (tags, withdrawn) = app .repos .read(&repo, |r, _| Ok((view::tags(r)?, view::withdrawn_tags(r)?))) .await .map_err(|e| Error::from_repo(shell.clone(), e))?; let released = db::releases::list(&app.db, row.id).await.unwrap_or_default().into_iter().map(|r| r.tag).collect(); let signed_in = shell.user.is_some(); Ok(Page(TagsPage { shell, repo: RepoShell::new(app, &row, "tags").with_role(role), tags, withdrawn, released, can_create: signed_in && role >= Role::Write, can_withdraw: signed_in && role >= Role::Maintain, notice, error, })) } pub async fn tags(State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/tags"), &viewer); let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; tags_page(&app, shell, repo, row, role, None, None).await } #[derive(Deserialize)] pub struct CreateTagForm { #[serde(rename = "_csrf")] csrf_token: String, name: String, #[serde(default)] target: String, #[serde(default)] message: String, } /// Create a tag from the web (#295): signed by the hub key as the user's /// delegate, under the policy, as `oxy tag create` and a push would be. pub async fn create_tag( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/tags"), &viewer); let me = require(&viewer, &shell)?; let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?; csrf(&viewer, &f.csrf_token, &shell)?; match crate::tags::create(&app, &row, &repo, &me.account, &f.name, &f.target, &f.message).await { Ok(t) => { let notice = format!( "Tagged {} ({} head{}). Start a release from it under Releases.", t.name, t.heads.len(), if t.heads.len() == 1 { "" } else { "s" } ); tags_page(&app, shell, repo, row, role, Some(notice), None).await } Err(e) => tags_page(&app, shell, repo, row, role, None, Some(format!("Could not create the tag: {e}"))).await, } } #[derive(Deserialize)] pub struct WithdrawTagForm { #[serde(rename = "_csrf")] csrf_token: String, tag: String, #[serde(default)] reason: String, } /// Withdraw a tag from the web (#301), as `oxy push --delete tags/NAME` /// does: a signed withdrawal, kept and logged. Maintainers only; never /// while a release uses the tag. pub async fn withdraw_tag( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/tags"), &viewer); let me = require(&viewer, &shell)?; let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; csrf(&viewer, &f.csrf_token, &shell)?; match crate::tags::withdraw(&app, &row, &repo, &me.account, &f.tag, &f.reason).await { Ok(t) => { let notice = format!("Withdrew {}. Clones drop it on their next fetch; the name is not used again.", t.name); tags_page(&app, shell, repo, row, role, Some(notice), None).await } Err(e) => tags_page(&app, shell, repo, row, role, None, Some(format!("Could not withdraw {}: {e}", f.tag.trim()))).await, } } #[derive(Template)] #[template(path = "conflicts.html")] pub struct ConflictsPage { shell: Shell, repo: RepoShell, conflicts: Vec, } pub async fn conflicts( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/conflicts"), &viewer); let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; let conflicts = app.repos.read(&repo, view::conflicts).await.map_err(|e| Error::from_repo(shell.clone(), e))?; Ok(Page(ConflictsPage { shell, repo: RepoShell::new(&app, &row, "conflicts").with_role(role).with_sky(&app, &row).await, conflicts })) } #[derive(Template)] #[template(path = "trust.html")] pub struct TrustPage { shell: Shell, repo: RepoShell, t: view::Trust, } pub async fn trust( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/trust"), &viewer); let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; let key = app.hub_key.key; let t = app.repos.read(&repo, move |r, cache| view::trust(r, cache, &key)).await.map_err(|e| Error::from_repo(shell.clone(), e))?; Ok(Page(TrustPage { shell, repo: RepoShell::new(&app, &row, "trust").with_role(role), t })) } #[derive(Template)] #[template(path = "pushes.html")] pub struct PushesPage { shell: Shell, repo: RepoShell, pushes: Vec, } pub async fn pushes( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/pushes"), &viewer); let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; let pushes = db::repos::pushes(&app.db, row.id, 200).await.map_err(|e| Error::from_db(shell.clone(), e))?; Ok(Page(PushesPage { shell, repo: RepoShell::new(&app, &row, "pushes").with_role(role), pushes })) }