//! 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 create and 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(Deserialize)] pub struct CreateBookmarkForm { #[serde(rename = "_csrf")] csrf_token: String, name: String, /// A bookmark, tag or change; the default bookmark when empty. #[serde(default)] from: String, } /// Create a bookmark from the web at the heads of a bookmark, tag or change: /// what pushing a new bookmark does, signed by the hub key as the user's /// delegate, as the web's other changes to bookmarks are. pub async fn create_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.name.trim().to_string(); let from = match f.from.trim() { "" => row.default_bookmark.clone(), s => s.to_string(), }; if bookmark.is_empty() || bookmark.chars().any(char::is_whitespace) { return bookmarks_page(&app, shell, repo, row, role, None, Some("A bookmark's name has no spaces.".into())).await; } let (wanted, spec) = (bookmark.clone(), from.clone()); let found = app .repos .read(&repo, move |r, cache| { if r.view()?.bookmarks.contains_key(&wanted) { return Ok(Err(format!("{wanted} is a bookmark already."))); } Ok(view::resolve(r, cache, &spec).map(|t| t.heads).map_err(|e| e.to_string())) }) .await .map_err(|e| Error::from_repo(shell.clone(), e))?; let heads = match found { Ok(heads) if !heads.is_empty() => heads, Ok(_) => return bookmarks_page(&app, shell, repo, row, role, None, Some(format!("{from} holds no changes to start from."))).await, Err(msg) => return bookmarks_page(&app, shell, repo, row, role, None, Some(msg)).await, }; let made = set_bookmark(&app, &repo, me.account.id, &bookmark, heads.clone()).await; if let Err(e) = made { return bookmarks_page(&app, shell, repo, row, role, None, Some(format!("Could not create {bookmark}: {e}"))).await; } let made = bookmark.clone(); let new_state = app .repos .read(&repo, move |r, _| Ok(r.view()?.bookmarks.get(&made).map(|s| s.to_hex()).unwrap_or_default())) .await .unwrap_or_default(); let result = db::repos::PushOutcome { reference: bookmark.clone(), ok: true, reason: format!("created on the web from {from}"), old_state: oxy_core::hash::ObjectRef::ZERO.to_hex(), new_state: new_state.clone(), heads: heads.len() as i64, 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": oxy_core::hash::ObjectRef::ZERO.to_hex(), "new_state": new_state, "heads": heads.len(), "changes": 0, "created": true }], "via": "web" }), ) .await; db::accounts::audit(&app.db, Some(&me.account), "bookmark.created", &format!("{owner}/{name}"), &format!("{bookmark} from {from}")) .await .ok(); let notice = format!("Created {bookmark} from {from}. Propose changes into it, or make it the default in Settings."); bookmarks_page(&app, shell, repo, row, role, Some(notice), None).await } /// Move (or make) `bookmark` at `heads` from the web: as the user's /// delegate when they have one, otherwise unsigned, which a repository /// without a trust policy takes, as a push without one. `Err` says why not. pub async fn set_bookmark( app: &Shared, repo: &crate::repos::RepoName, account_id: i64, bookmark: &str, heads: Vec, ) -> Result<(), String> { match super::edit::signer_for(app, account_id).await { Ok(signer) => crate::repos::edits::move_bookmark(&app.repos, repo, bookmark.to_string(), heads, signer) .await .map(|_| ()) .map_err(|e| e.to_string()), Err(why) => { let target = bookmark.to_string(); let unsigned = app .repos .write(repo, move |r, _| { let update = r.host_ref_update(&target, &heads, None)?; let report = r.receive_push_report(Default::default(), vec![update], None, vec![])?; Ok(match report.results.first() { Some(res) if res.ok => Ok(()), Some(res) => Err(res.reason.clone()), None => Err("nothing happened".to_string()), }) }) .await .map_err(|e| e.to_string()) .and_then(|r| r); unsigned.map_err(|refused| format!("{refused}. {why}")) } } } #[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 })) }