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