OXYGENHUB
◆ v0.9.8 oxygenhub / crates / hub / src / web / edit.rs
11.1 KiB · Rust · last changed A durable work queue, first slice (refs #276) 15 days ago source blame history raw
14530604 Andy McCall ↶ 1
//! Editing files in the browser (hub spec §6.9) and resolving conflicts
2
//! there (§6.10): the change is authored by the user's linked identity and
3
//! signed by the hub key as its delegate, so it lands only when the user
4
//! delegated to the hub and the policy lets them update the bookmark.
5
6
use super::settings::{csrf, require};
7
use super::{lookup_role, Error, Page, RepoShell, Shell};
8
use crate::auth::{Access, Viewer};
9
use crate::db::{self, accounts};
10
use crate::repos::edits::{self, Signer};
11
use crate::repos::view::{self, FileBody};
12
use crate::Shared;
13
use askama::Template;
14
use axum::extract::{Form, Path, Query, State};
15
use axum::response::{IntoResponse, Redirect, Response};
b43a9ab0 Andy McCall ↶ 16
use oxy_core::identity::{Identity, Scope};
14530604 Andy McCall ↶ 17
use oxy_core::repo::FileEdit;
18
use serde::Deserialize;
19
20
/// The user's linked identity that names the hub key as a delegate.
21
pub async fn delegated_identity(app: &Shared, account_id: i64) -> Option<Identity> {
22
    let linked = accounts::identities_of(&app.db, account_id).await.ok()?;
b43a9ab0 Andy McCall ↶ 23
    // The hub signs the user's changes and attests the reference for them,
24
    // so the delegation needs both scopes, and must still be in force.
25
    let now = crate::db::now_ms() as u64;
26
    linked
27
        .into_iter()
28
        .filter_map(|l| Identity::decode(&l.object).ok())
29
        .find(|i| i.delegates_to(&app.hub_key.key, Scope::CHANGES | Scope::REFS, now))
14530604 Andy McCall ↶ 30
}
31
32
/// Everything needed to sign for the user, or why not.
f4d6ef41 Andy McCall ↶ 33
/// The hub's own key, acting for itself: how Diffusion lands a bump
34
/// (docs/diffusion.md §4.2). A trust policy admits it only where it
35
/// authorises the hub's key to move the bookmark.
36
pub fn hub_signer(app: &Shared) -> Result<Signer, String> {
37
    let (hub_key, hub_secret) = crate::keys::load_secret(&app.config.keys_dir()).map_err(|e| format!("the hub key is unavailable: {e}"))?;
38
    let identity = app.hub_key.identity.clone();
39
    Ok(Signer { author: identity.clone(), hub_key, hub_secret, hub_identity: identity })
40
}
41
14530604 Andy McCall ↶ 42
pub async fn signer_for(app: &Shared, account_id: i64) -> Result<Signer, String> {
43
    let author = delegated_identity(app, account_id).await.ok_or_else(|| {
78763257 Andy McCall ↶ 44
        "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()
14530604 Andy McCall ↶ 45
    })?;
46
    let (hub_key, hub_secret) = crate::keys::load_secret(&app.config.keys_dir()).map_err(|e| format!("the hub key is unavailable: {e}"))?;
47
    Ok(Signer { author, hub_key, hub_secret, hub_identity: app.hub_key.identity.clone() })
48
}
49
50
#[derive(Template)]
51
#[template(path = "edit.html")]
52
pub struct EditPage {
53
    shell: Shell,
54
    repo: RepoShell,
55
    r: String,
56
    path: String,
57
    content: String,
58
    new_file: bool,
59
    is_bookmark: bool,
60
    conflicted: bool,
61
    delegation: Option<String>,
62
    error: Option<String>,
63
    message: String,
64
}
65
66
#[derive(Deserialize)]
67
pub struct NewQuery {
68
    #[serde(default)]
69
    dir: String,
70
}
71
72
/// The edit page, fresh or re-shown with an error and the unsaved text.
73
#[allow(clippy::too_many_arguments)]
74
async fn page(
75
    app: &Shared,
76
    viewer: &Viewer,
77
    owner: &str,
78
    name: &str,
79
    r: &str,
80
    path: Option<&str>,
81
    dir: &str,
82
    error: Option<String>,
83
    content: Option<String>,
84
) -> Result<Page<EditPage>, Error> {
85
    let shell = Shell::new(app, &format!("/{owner}/{name}/edit/{r}"), viewer);
86
    let me = require(viewer, &shell)?;
87
    let (repo, row, role) = lookup_role(app, &shell, viewer, owner, name, Access::Write).await?;
88
    let limit = app.config.limits.render_limit.0;
89
    let spec = r.to_string();
90
    let p = path.map(String::from);
91
    let (is_bookmark, file) = app
92
        .repos
93
        .read(&repo, move |repo, cache| {
94
            let target = view::resolve(repo, cache, &spec)?;
95
            let is_bookmark = target.kind == view::RefKind::Bookmark;
96
            let file = match &p {
97
                Some(p) => Some(view::file(repo, cache, target, p, limit)?),
98
                None => None,
99
            };
100
            Ok((is_bookmark, file))
101
        })
102
        .await
103
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
104
    let (existing, conflicted) = match &file {
105
        Some(f) => match &f.body {
106
            FileBody::Text(t) => (t.clone(), f.conflicted),
107
            _ => return Err(Error::bad(shell, "Only text files within the render limit can be edited in the browser.")),
108
        },
109
        None => (String::new(), false),
110
    };
111
    let delegation = signer_for(app, me.account.id).await.err();
112
    let new_file = path.is_none();
113
    let default_path = if dir.is_empty() { String::new() } else { format!("{}/", dir.trim_matches('/')) };
114
    Ok(Page(EditPage {
115
        shell,
116
        repo: RepoShell::new(app, &row, "code").with_role(role),
117
        r: r.to_string(),
118
        path: path.map(String::from).unwrap_or(default_path),
119
        content: content.unwrap_or(existing),
120
        new_file,
121
        is_bookmark,
122
        conflicted,
123
        delegation,
124
        error,
125
        message: String::new(),
126
    }))
127
}
128
129
pub async fn edit_form(
130
    State(app): State<Shared>,
131
    viewer: Viewer,
132
    Path((owner, name, r, path)): Path<(String, String, String, String)>,
133
) -> Result<Page<EditPage>, Error> {
134
    page(&app, &viewer, &owner, &name, &r, Some(&path), "", None, None).await
135
}
136
137
pub async fn new_form(
138
    State(app): State<Shared>,
139
    viewer: Viewer,
140
    Path((owner, name, r)): Path<(String, String, String)>,
141
    Query(q): Query<NewQuery>,
142
) -> Result<Page<EditPage>, Error> {
143
    page(&app, &viewer, &owner, &name, &r, None, &q.dir, None, None).await
144
}
145
146
#[derive(Deserialize)]
147
pub struct SaveForm {
148
    #[serde(rename = "_csrf")]
149
    csrf_token: String,
150
    path: String,
151
    #[serde(default)]
152
    original: String,
153
    #[serde(default)]
154
    content: String,
155
    message: String,
156
    /// `commit` to the bookmark, or `propose` on a new bookmark.
157
    #[serde(default = "commit_mode")]
158
    mode: String,
159
    #[serde(default)]
160
    new_bookmark: String,
161
    #[serde(default)]
162
    delete: Option<String>,
163
}
164
165
fn commit_mode() -> String {
166
    "commit".into()
167
}
168
169
pub async fn save(
170
    State(app): State<Shared>,
171
    viewer: Viewer,
172
    Path((owner, name, r)): Path<(String, String, String)>,
173
    Form(f): Form<SaveForm>,
174
) -> Result<Response, Error> {
175
    let shell = Shell::new(&app, &format!("/{owner}/{name}/edit/{r}"), &viewer);
176
    let me = require(&viewer, &shell)?;
177
    csrf(&viewer, &f.csrf_token, &shell)?;
178
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
179
    let again = |msg: String| {
180
        let (app, viewer, owner, name, r) = (app.clone(), viewer.clone(), owner.clone(), name.clone(), r.clone());
181
        let (orig, content) = (f.original.clone(), f.content.clone());
182
        async move {
183
            page(&app, &viewer, &owner, &name, &r, if orig.is_empty() { None } else { Some(orig.as_str()) }, "", Some(msg), Some(content))
184
                .await
185
                .map(IntoResponse::into_response)
186
        }
187
    };
188
    let signer = match signer_for(&app, me.account.id).await {
189
        Ok(s) => s,
190
        Err(msg) => return again(msg).await,
191
    };
192
    let path = f.path.trim().trim_matches('/').to_string();
193
    if path.is_empty() {
194
        return again("Name the file.".into()).await;
195
    }
196
    let message = f.message.trim().to_string();
197
    if message.is_empty() {
198
        return again("Describe the change.".into()).await;
199
    }
200
    // Normalise line endings to what the editor shows.
201
    let content = f.content.replace("\r\n", "\n");
202
    let mut changes = vec![];
203
    if f.delete.is_some() {
204
        changes.push(FileEdit {
205
            path: if f.original.is_empty() { path.clone() } else { f.original.trim_matches('/').to_string() },
206
            content: None,
207
        });
208
    } else {
209
        if !f.original.is_empty() && f.original.trim_matches('/') != path {
210
            // A rename: the old path goes, the new one arrives with the content.
211
            changes.push(FileEdit { path: f.original.trim_matches('/').to_string(), content: None });
212
        }
213
        changes.push(FileEdit { path: path.clone(), content: Some(content.into_bytes()) });
214
    }
215
    let target = if f.mode == "propose" {
216
        let b = f.new_bookmark.trim();
217
        let b = if b.is_empty() { format!("{}-patch-{}", me.account.username, crate::auth::random_hex(3)) } else { b.to_string() };
218
        if !crate::repos::names::valid_segment(&b) {
219
            return again("A bookmark name is letters, digits, dots, dashes and underscores.".into()).await;
220
        }
221
        b
222
    } else {
223
        r.clone()
224
    };
225
    let done = match edits::commit(&app.repos, &repo, r.clone(), target.clone(), changes, message.clone(), signer).await {
226
        Ok(d) => d,
227
        Err(e) => return again(e.to_string()).await,
228
    };
c5f3bde4 Andy McCall ↶ 229
    after_web_commit(&app, &me.account, &row, &owner, &name, &done, &message).await;
230
    if f.mode == "propose" {
231
        match super::proposals::open_proposal(&app, &repo, &row, &me.account, &message, "", &r, &done.bookmark, false).await {
232
            Ok(p) => return Ok(Redirect::to(&format!("/{owner}/{name}/proposal/{}", p.number)).into_response()),
233
            Err(msg) => {
234
                return Ok(Redirect::to(&format!("/{owner}/{name}/tree/{}?notice={}", done.bookmark, super::settings::urlencode(&msg)))
235
                    .into_response())
236
            }
237
        }
238
    }
239
    let to = if f.delete.is_some() {
240
        format!("/{owner}/{name}/tree/{}", done.bookmark)
241
    } else {
242
        format!("/{owner}/{name}/file/{}/{}", done.bookmark, path)
243
    };
244
    Ok(Redirect::to(&to).into_response())
245
}
246
247
/// What a push would have caused, for a change the hub committed for a
248
/// user: the push log, webhooks, a Flow run, closing keywords, the Git
249
/// mirror, and the audit log.
250
pub(super) async fn after_web_commit(
251
    app: &Shared,
252
    account: &accounts::Account,
253
    row: &db::repos::RepoRow,
254
    owner: &str,
255
    name: &str,
256
    done: &edits::Committed,
257
    message: &str,
258
) {
14530604 Andy McCall ↶ 259
    let outcome = db::repos::PushOutcome {
260
        reference: done.bookmark.clone(),
261
        ok: true,
262
        reason: "web edit".into(),
263
        old_state: done.old_state.clone(),
264
        new_state: done.new_state.clone(),
265
        heads: done.heads.len() as i64,
266
        changes: 1,
267
    };
c5f3bde4 Andy McCall ↶ 268
    db::repos::record_push(&app.db, row.id, Some((account.id, &account.username)), &[outcome]).await.ok();
269
    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;
270
    crate::flow::on_push(app, row, &done.bookmark, &done.new_state, &done.heads, &account.username).await;
14530604 Andy McCall ↶ 271
    if done.bookmark == row.default_bookmark {
c5f3bde4 Andy McCall ↶ 272
        super::issues::close_from_messages(app, row, &[(done.change.to_hex(), message.to_string())]).await;
dd327d76 Andy McCall ↶ 273
        crate::mirror::refresh(app, row).await.ok();
14530604 Andy McCall ↶ 274
    }
275
    accounts::audit(
276
        &app.db,
c5f3bde4 Andy McCall ↶ 277
        Some(account),
14530604 Andy McCall ↶ 278
        "web.edit",
279
        &format!("{owner}/{name}"),
280
        &format!("{} {}", done.bookmark, done.change.short(12)),
281
    )
282
    .await
283
    .ok();
284
}