//! Editing files in the browser (hub spec §6.9) and resolving conflicts //! there (§6.10): the change is authored by the user's linked identity and //! signed by the hub key as its delegate, so it lands only when the user //! delegated to the hub and the policy lets them update the bookmark. use super::settings::{csrf, require}; use super::{lookup_role, Error, Page, RepoShell, Shell}; use crate::auth::{Access, Viewer}; use crate::db::{self, accounts}; use crate::repos::edits::{self, Signer}; use crate::repos::view::{self, FileBody}; use crate::Shared; use askama::Template; use axum::extract::{Form, Path, Query, State}; use axum::response::{IntoResponse, Redirect, Response}; use oxy_core::identity::{Identity, Scope}; use oxy_core::repo::FileEdit; use serde::Deserialize; /// The user's linked identity that names the hub key as a delegate. pub async fn delegated_identity(app: &Shared, account_id: i64) -> Option { let linked = accounts::identities_of(&app.db, account_id).await.ok()?; // The hub signs the user's changes and attests the reference for them, // so the delegation needs both scopes, and must still be in force. let now = crate::db::now_ms() as u64; linked .into_iter() .filter_map(|l| Identity::decode(&l.object).ok()) .find(|i| i.delegates_to(&app.hub_key.key, Scope::CHANGES | Scope::REFS, now)) } /// Everything needed to sign for the user, or why not. /// The hub's own key, acting for itself: how Diffusion lands a bump /// (docs/diffusion.md §4.2). A trust policy admits it only where it /// authorises the hub's key to move the bookmark. pub fn hub_signer(app: &Shared) -> Result { let (hub_key, hub_secret) = crate::keys::load_secret(&app.config.keys_dir()).map_err(|e| format!("the hub key is unavailable: {e}"))?; let identity = app.hub_key.identity.clone(); Ok(Signer { author: identity.clone(), hub_key, hub_secret, hub_identity: identity }) } pub async fn signer_for(app: &Shared, account_id: i64) -> Result { let author = delegated_identity(app, account_id).await.ok_or_else(|| { "You have not delegated to this hub's key yet. In a checkout with your identity, download the hub's key from /.well-known/oxyhub-key and run `oxy identity delegate add oxyhub-key.identity --scope changes,refs`. That makes a new version of your own identity: export it there (`oxy identity export`), upload that file under Settings › Keys and sign the challenge in the same checkout. The hub's key file is not the one to upload.".to_string() })?; let (hub_key, hub_secret) = crate::keys::load_secret(&app.config.keys_dir()).map_err(|e| format!("the hub key is unavailable: {e}"))?; Ok(Signer { author, hub_key, hub_secret, hub_identity: app.hub_key.identity.clone() }) } #[derive(Template)] #[template(path = "edit.html")] pub struct EditPage { shell: Shell, repo: RepoShell, r: String, path: String, content: String, new_file: bool, is_bookmark: bool, conflicted: bool, delegation: Option, error: Option, message: String, } #[derive(Deserialize)] pub struct NewQuery { #[serde(default)] dir: String, } /// The edit page, fresh or re-shown with an error and the unsaved text. #[allow(clippy::too_many_arguments)] async fn page( app: &Shared, viewer: &Viewer, owner: &str, name: &str, r: &str, path: Option<&str>, dir: &str, error: Option, content: Option, ) -> Result, Error> { let shell = Shell::new(app, &format!("/{owner}/{name}/edit/{r}"), viewer); let me = require(viewer, &shell)?; let (repo, row, role) = lookup_role(app, &shell, viewer, owner, name, Access::Write).await?; let limit = app.config.limits.render_limit.0; let spec = r.to_string(); let p = path.map(String::from); let (is_bookmark, file) = app .repos .read(&repo, move |repo, cache| { let target = view::resolve(repo, cache, &spec)?; let is_bookmark = target.kind == view::RefKind::Bookmark; let file = match &p { Some(p) => Some(view::file(repo, cache, target, p, limit)?), None => None, }; Ok((is_bookmark, file)) }) .await .map_err(|e| Error::from_repo(shell.clone(), e))?; let (existing, conflicted) = match &file { Some(f) => match &f.body { FileBody::Text(t) => (t.clone(), f.conflicted), _ => return Err(Error::bad(shell, "Only text files within the render limit can be edited in the browser.")), }, None => (String::new(), false), }; let delegation = signer_for(app, me.account.id).await.err(); let new_file = path.is_none(); let default_path = if dir.is_empty() { String::new() } else { format!("{}/", dir.trim_matches('/')) }; Ok(Page(EditPage { shell, repo: RepoShell::new(app, &row, "code").with_role(role), r: r.to_string(), path: path.map(String::from).unwrap_or(default_path), content: content.unwrap_or(existing), new_file, is_bookmark, conflicted, delegation, error, message: String::new(), })) } pub async fn edit_form( State(app): State, viewer: Viewer, Path((owner, name, r, path)): Path<(String, String, String, String)>, ) -> Result, Error> { page(&app, &viewer, &owner, &name, &r, Some(&path), "", None, None).await } pub async fn new_form( State(app): State, viewer: Viewer, Path((owner, name, r)): Path<(String, String, String)>, Query(q): Query, ) -> Result, Error> { page(&app, &viewer, &owner, &name, &r, None, &q.dir, None, None).await } #[derive(Deserialize)] pub struct SaveForm { #[serde(rename = "_csrf")] csrf_token: String, path: String, #[serde(default)] original: String, #[serde(default)] content: String, message: String, /// `commit` to the bookmark, or `propose` on a new bookmark. #[serde(default = "commit_mode")] mode: String, #[serde(default)] new_bookmark: String, #[serde(default)] delete: Option, } fn commit_mode() -> String { "commit".into() } pub async fn save( State(app): State, viewer: Viewer, Path((owner, name, r)): Path<(String, String, String)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/edit/{r}"), &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?; let again = |msg: String| { let (app, viewer, owner, name, r) = (app.clone(), viewer.clone(), owner.clone(), name.clone(), r.clone()); let (orig, content) = (f.original.clone(), f.content.clone()); async move { page(&app, &viewer, &owner, &name, &r, if orig.is_empty() { None } else { Some(orig.as_str()) }, "", Some(msg), Some(content)) .await .map(IntoResponse::into_response) } }; let signer = match signer_for(&app, me.account.id).await { Ok(s) => s, Err(msg) => return again(msg).await, }; let path = f.path.trim().trim_matches('/').to_string(); if path.is_empty() { return again("Name the file.".into()).await; } let message = f.message.trim().to_string(); if message.is_empty() { return again("Describe the change.".into()).await; } // Normalise line endings to what the editor shows. let content = f.content.replace("\r\n", "\n"); let mut changes = vec![]; if f.delete.is_some() { changes.push(FileEdit { path: if f.original.is_empty() { path.clone() } else { f.original.trim_matches('/').to_string() }, content: None, }); } else { if !f.original.is_empty() && f.original.trim_matches('/') != path { // A rename: the old path goes, the new one arrives with the content. changes.push(FileEdit { path: f.original.trim_matches('/').to_string(), content: None }); } changes.push(FileEdit { path: path.clone(), content: Some(content.into_bytes()) }); } let target = if f.mode == "propose" { let b = f.new_bookmark.trim(); let b = if b.is_empty() { format!("{}-patch-{}", me.account.username, crate::auth::random_hex(3)) } else { b.to_string() }; if !crate::repos::names::valid_segment(&b) { return again("A bookmark name is letters, digits, dots, dashes and underscores.".into()).await; } b } else { r.clone() }; let done = match edits::commit(&app.repos, &repo, r.clone(), target.clone(), changes, message.clone(), signer).await { Ok(d) => d, Err(e) => return again(e.to_string()).await, }; after_web_commit(&app, &me.account, &row, &owner, &name, &done, &message).await; if f.mode == "propose" { match super::proposals::open_proposal(&app, &repo, &row, &me.account, &message, "", &r, &done.bookmark, false).await { Ok(p) => return Ok(Redirect::to(&format!("/{owner}/{name}/proposal/{}", p.number)).into_response()), Err(msg) => { return Ok(Redirect::to(&format!("/{owner}/{name}/tree/{}?notice={}", done.bookmark, super::settings::urlencode(&msg))) .into_response()) } } } let to = if f.delete.is_some() { format!("/{owner}/{name}/tree/{}", done.bookmark) } else { format!("/{owner}/{name}/file/{}/{}", done.bookmark, path) }; Ok(Redirect::to(&to).into_response()) } /// What a push would have caused, for a change the hub committed for a /// user: the push log, webhooks, a Flow run, closing keywords, the Git /// mirror, and the audit log. pub(super) async fn after_web_commit( app: &Shared, account: &accounts::Account, row: &db::repos::RepoRow, owner: &str, name: &str, done: &edits::Committed, message: &str, ) { let outcome = db::repos::PushOutcome { reference: done.bookmark.clone(), ok: true, reason: "web edit".into(), old_state: done.old_state.clone(), new_state: done.new_state.clone(), heads: done.heads.len() as i64, changes: 1, }; db::repos::record_push(&app.db, row.id, Some((account.id, &account.username)), &[outcome]).await.ok(); crate::hooks::emit(app, row, "push", &account.username, serde_json::json!({ "updates": [{ "reference": done.bookmark, "old_state": done.old_state, "new_state": done.new_state, "heads": done.heads.len(), "changes": 1 }], "via": "web" })).await; crate::flow::on_push(app, row, &done.bookmark, &done.new_state, &done.heads, &account.username).await; if done.bookmark == row.default_bookmark { super::issues::close_from_messages(app, row, &[(done.change.to_hex(), message.to_string())]).await; crate::mirror::refresh(app, row).await.ok(); } accounts::audit( &app.db, Some(account), "web.edit", &format!("{owner}/{name}"), &format!("{} {}", done.bookmark, done.change.short(12)), ) .await .ok(); }