OXYGENHUB
# 076c7a7c86b1 oxygenhub / crates / hub / src / web / edit.rs
10.5 KiB · Rust · last changed Pin oxygen main: scoped delegates; the hub needs changes and refs 1 month ago source blame history raw
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};
16
use oxy_core::identity::{Identity, Scope};
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()?;
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))
30
}
31
32
/// Everything needed to sign for the user, or why not.
33
pub async fn signer_for(app: &Shared, account_id: i64) -> Result<Signer, String> {
34
    let author = delegated_identity(app, account_id).await.ok_or_else(|| {
35
        "You have not delegated to this hub's key yet. From a checkout with your identity: download the hub key from /.well-known/oxyhub-key, run `oxy identity delegate add oxyhub-key.identity --scope changes,refs`, then link the new identity under Settings › Keys (`oxy identity export`).".to_string()
36
    })?;
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
    Ok(Signer { author, hub_key, hub_secret, hub_identity: app.hub_key.identity.clone() })
39
}
40
41
#[derive(Template)]
42
#[template(path = "edit.html")]
43
pub struct EditPage {
44
    shell: Shell,
45
    repo: RepoShell,
46
    r: String,
47
    path: String,
48
    content: String,
49
    new_file: bool,
50
    is_bookmark: bool,
51
    conflicted: bool,
52
    delegation: Option<String>,
53
    error: Option<String>,
54
    message: String,
55
}
56
57
#[derive(Deserialize)]
58
pub struct NewQuery {
59
    #[serde(default)]
60
    dir: String,
61
}
62
63
/// The edit page, fresh or re-shown with an error and the unsaved text.
64
#[allow(clippy::too_many_arguments)]
65
async fn page(
66
    app: &Shared,
67
    viewer: &Viewer,
68
    owner: &str,
69
    name: &str,
70
    r: &str,
71
    path: Option<&str>,
72
    dir: &str,
73
    error: Option<String>,
74
    content: Option<String>,
75
) -> Result<Page<EditPage>, Error> {
76
    let shell = Shell::new(app, &format!("/{owner}/{name}/edit/{r}"), viewer);
77
    let me = require(viewer, &shell)?;
78
    let (repo, row, role) = lookup_role(app, &shell, viewer, owner, name, Access::Write).await?;
79
    let limit = app.config.limits.render_limit.0;
80
    let spec = r.to_string();
81
    let p = path.map(String::from);
82
    let (is_bookmark, file) = app
83
        .repos
84
        .read(&repo, move |repo, cache| {
85
            let target = view::resolve(repo, cache, &spec)?;
86
            let is_bookmark = target.kind == view::RefKind::Bookmark;
87
            let file = match &p {
88
                Some(p) => Some(view::file(repo, cache, target, p, limit)?),
89
                None => None,
90
            };
91
            Ok((is_bookmark, file))
92
        })
93
        .await
94
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
95
    let (existing, conflicted) = match &file {
96
        Some(f) => match &f.body {
97
            FileBody::Text(t) => (t.clone(), f.conflicted),
98
            _ => return Err(Error::bad(shell, "Only text files within the render limit can be edited in the browser.")),
99
        },
100
        None => (String::new(), false),
101
    };
102
    let delegation = signer_for(app, me.account.id).await.err();
103
    let new_file = path.is_none();
104
    let default_path = if dir.is_empty() { String::new() } else { format!("{}/", dir.trim_matches('/')) };
105
    Ok(Page(EditPage {
106
        shell,
107
        repo: RepoShell::new(app, &row, "code").with_role(role),
108
        r: r.to_string(),
109
        path: path.map(String::from).unwrap_or(default_path),
110
        content: content.unwrap_or(existing),
111
        new_file,
112
        is_bookmark,
113
        conflicted,
114
        delegation,
115
        error,
116
        message: String::new(),
117
    }))
118
}
119
120
pub async fn edit_form(
121
    State(app): State<Shared>,
122
    viewer: Viewer,
123
    Path((owner, name, r, path)): Path<(String, String, String, String)>,
124
) -> Result<Page<EditPage>, Error> {
125
    page(&app, &viewer, &owner, &name, &r, Some(&path), "", None, None).await
126
}
127
128
pub async fn new_form(
129
    State(app): State<Shared>,
130
    viewer: Viewer,
131
    Path((owner, name, r)): Path<(String, String, String)>,
132
    Query(q): Query<NewQuery>,
133
) -> Result<Page<EditPage>, Error> {
134
    page(&app, &viewer, &owner, &name, &r, None, &q.dir, None, None).await
135
}
136
137
#[derive(Deserialize)]
138
pub struct SaveForm {
139
    #[serde(rename = "_csrf")]
140
    csrf_token: String,
141
    path: String,
142
    #[serde(default)]
143
    original: String,
144
    #[serde(default)]
145
    content: String,
146
    message: String,
147
    /// `commit` to the bookmark, or `propose` on a new bookmark.
148
    #[serde(default = "commit_mode")]
149
    mode: String,
150
    #[serde(default)]
151
    new_bookmark: String,
152
    #[serde(default)]
153
    delete: Option<String>,
154
}
155
156
fn commit_mode() -> String {
157
    "commit".into()
158
}
159
160
pub async fn save(
161
    State(app): State<Shared>,
162
    viewer: Viewer,
163
    Path((owner, name, r)): Path<(String, String, String)>,
164
    Form(f): Form<SaveForm>,
165
) -> Result<Response, Error> {
166
    let shell = Shell::new(&app, &format!("/{owner}/{name}/edit/{r}"), &viewer);
167
    let me = require(&viewer, &shell)?;
168
    csrf(&viewer, &f.csrf_token, &shell)?;
169
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
170
    let again = |msg: String| {
171
        let (app, viewer, owner, name, r) = (app.clone(), viewer.clone(), owner.clone(), name.clone(), r.clone());
172
        let (orig, content) = (f.original.clone(), f.content.clone());
173
        async move {
174
            page(&app, &viewer, &owner, &name, &r, if orig.is_empty() { None } else { Some(orig.as_str()) }, "", Some(msg), Some(content))
175
                .await
176
                .map(IntoResponse::into_response)
177
        }
178
    };
179
    let signer = match signer_for(&app, me.account.id).await {
180
        Ok(s) => s,
181
        Err(msg) => return again(msg).await,
182
    };
183
    let path = f.path.trim().trim_matches('/').to_string();
184
    if path.is_empty() {
185
        return again("Name the file.".into()).await;
186
    }
187
    let message = f.message.trim().to_string();
188
    if message.is_empty() {
189
        return again("Describe the change.".into()).await;
190
    }
191
    // Normalise line endings to what the editor shows.
192
    let content = f.content.replace("\r\n", "\n");
193
    let mut changes = vec![];
194
    if f.delete.is_some() {
195
        changes.push(FileEdit {
196
            path: if f.original.is_empty() { path.clone() } else { f.original.trim_matches('/').to_string() },
197
            content: None,
198
        });
199
    } else {
200
        if !f.original.is_empty() && f.original.trim_matches('/') != path {
201
            // A rename: the old path goes, the new one arrives with the content.
202
            changes.push(FileEdit { path: f.original.trim_matches('/').to_string(), content: None });
203
        }
204
        changes.push(FileEdit { path: path.clone(), content: Some(content.into_bytes()) });
205
    }
206
    let target = if f.mode == "propose" {
207
        let b = f.new_bookmark.trim();
208
        let b = if b.is_empty() { format!("{}-patch-{}", me.account.username, crate::auth::random_hex(3)) } else { b.to_string() };
209
        if !crate::repos::names::valid_segment(&b) {
210
            return again("A bookmark name is letters, digits, dots, dashes and underscores.".into()).await;
211
        }
212
        b
213
    } else {
214
        r.clone()
215
    };
216
    let done = match edits::commit(&app.repos, &repo, r.clone(), target.clone(), changes, message.clone(), signer).await {
217
        Ok(d) => d,
218
        Err(e) => return again(e.to_string()).await,
219
    };
220
    after_web_commit(&app, &me.account, &row, &owner, &name, &done, &message).await;
221
    if f.mode == "propose" {
222
        match super::proposals::open_proposal(&app, &repo, &row, &me.account, &message, "", &r, &done.bookmark, false).await {
223
            Ok(p) => return Ok(Redirect::to(&format!("/{owner}/{name}/proposal/{}", p.number)).into_response()),
224
            Err(msg) => {
225
                return Ok(Redirect::to(&format!("/{owner}/{name}/tree/{}?notice={}", done.bookmark, super::settings::urlencode(&msg)))
226
                    .into_response())
227
            }
228
        }
229
    }
230
    let to = if f.delete.is_some() {
231
        format!("/{owner}/{name}/tree/{}", done.bookmark)
232
    } else {
233
        format!("/{owner}/{name}/file/{}/{}", done.bookmark, path)
234
    };
235
    Ok(Redirect::to(&to).into_response())
236
}
237
238
/// What a push would have caused, for a change the hub committed for a
239
/// user: the push log, webhooks, a Flow run, closing keywords, the Git
240
/// mirror, and the audit log.
241
pub(super) async fn after_web_commit(
242
    app: &Shared,
243
    account: &accounts::Account,
244
    row: &db::repos::RepoRow,
245
    owner: &str,
246
    name: &str,
247
    done: &edits::Committed,
248
    message: &str,
249
) {
250
    let outcome = db::repos::PushOutcome {
251
        reference: done.bookmark.clone(),
252
        ok: true,
253
        reason: "web edit".into(),
254
        old_state: done.old_state.clone(),
255
        new_state: done.new_state.clone(),
256
        heads: done.heads.len() as i64,
257
        changes: 1,
258
    };
259
    db::repos::record_push(&app.db, row.id, Some((account.id, &account.username)), &[outcome]).await.ok();
260
    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;
261
    crate::flow::on_push(app, row, &done.bookmark, &done.new_state, &done.heads, &account.username).await;
262
    if done.bookmark == row.default_bookmark {
263
        super::issues::close_from_messages(app, row, &[(done.change.to_hex(), message.to_string())]).await;
264
        crate::mirror::refresh(app, row).await;
265
    }
266
    accounts::audit(
267
        &app.db,
268
        Some(account),
269
        "web.edit",
270
        &format!("{owner}/{name}"),
271
        &format!("{} {}", done.bookmark, done.change.short(12)),
272
    )
273
    .await
274
    .ok();
275
}