| 1 | //! In-browser file editing. |
| 2 | //! |
| 3 | //! Not in the spec's route table — an addition on top of v1. It is built to the |
| 4 | //! same rules as everything else, and two of them shape the whole design: |
| 5 | //! |
| 6 | //! * **Progressive enhancement is a hard requirement** (spec §7). The page is a |
| 7 | //! form containing a `<textarea>`. CodeMirror replaces it when the bundle |
| 8 | //! loads; when it does not, the plain textarea saves exactly the same way. |
| 9 | //! * **Edits are commits, not a side channel.** Saving writes a real commit |
| 10 | //! whose parent is the bookmark's current tip, through the same store the |
| 11 | //! push path uses. It is a fast-forward by construction, so it satisfies the |
| 12 | //! protected-bookmark rule rather than bypassing it — and the indexer picks |
| 13 | //! it up like any other push. |
| 14 | //! |
| 15 | //! Editing is a `write`-level action: the same permission a push needs, because |
| 16 | //! it is the same effect. |
| 17 | |
| 18 | use axum::extract::{Path as UrlPath, Query, State}; |
| 19 | use axum::response::{IntoResponse, Redirect, Response}; |
| 20 | use axum::Form; |
| 21 | use df_store::{EditOutcome, RevId}; |
| 22 | use serde::Deserialize; |
| 23 | |
| 24 | use crate::error::{AppError, AppResult}; |
| 25 | use crate::repo_ctx::RepoContext; |
| 26 | use crate::routes::settings::urlencode; |
| 27 | use crate::state::{AppState, CsrfToken, CurrentUser, Nonce}; |
| 28 | use crate::views::edit as v; |
| 29 | use crate::views::repo as rv; |
| 30 | use crate::views::{self, Chrome}; |
| 31 | |
| 32 | /// Largest file the editor will open. |
| 33 | /// |
| 34 | /// Matches the store's own write cap. A file bigger than this is not something |
| 35 | /// to edit in a browser, and saying so up front is kinder than letting somebody |
| 36 | /// type into it and be refused on save. |
| 37 | const MAX_EDIT_BYTES: u64 = 1024 * 1024; |
| 38 | |
| 39 | #[derive(Deserialize, Default)] |
| 40 | pub struct EditQuery { |
| 41 | pub error: Option<String>, |
| 42 | } |
| 43 | |
| 44 | /// `GET /{owner}/{repo}/edit/{rev}/{path}` |
| 45 | /// |
| 46 | /// `rev` must be a **bookmark name**, not an arbitrary revision. An edit has to |
| 47 | /// land somewhere, and "commit this on top of a detached revision" has no |
| 48 | /// meaning the user could act on. |
| 49 | pub async fn show( |
| 50 | State(state): State<AppState>, |
| 51 | UrlPath(p): UrlPath<super::repo::RevPath>, |
| 52 | Query(q): Query<EditQuery>, |
| 53 | CurrentUser(user): CurrentUser, |
| 54 | CsrfToken(csrf): CsrfToken, |
| 55 | Nonce(nonce): Nonce, |
| 56 | ) -> AppResult<Response> { |
| 57 | let ctx = RepoContext::load(&state, &p.owner, &p.repo, user.as_deref()).await?; |
| 58 | if user.is_none() { |
| 59 | return Err(AppError::Unauthorized); |
| 60 | } |
| 61 | ctx.require_push()?; |
| 62 | if ctx.repo.archived { |
| 63 | return Err(AppError::BadRequest( |
| 64 | "This repository is archived and cannot be edited.".into(), |
| 65 | )); |
| 66 | } |
| 67 | |
| 68 | let bookmark = resolve_bookmark(&state, &ctx, &p.rev).await?; |
| 69 | let tip = state |
| 70 | .store |
| 71 | .resolve(ctx.store_id(), &bookmark) |
| 72 | .await |
| 73 | .map_err(|_| AppError::NotFound)?; |
| 74 | |
| 75 | let blob = state |
| 76 | .store |
| 77 | .read_blob(ctx.store_id(), &tip, std::path::Path::new(&p.path)) |
| 78 | .await |
| 79 | .map_err(|e| match e { |
| 80 | df_store::StoreError::NoSuchPath => AppError::NotFound, |
| 81 | df_store::StoreError::IsDirectory => { |
| 82 | AppError::BadRequest("that path is a directory".into()) |
| 83 | } |
| 84 | other => AppError::Internal(anyhow::anyhow!(other)), |
| 85 | })?; |
| 86 | |
| 87 | if blob.size > MAX_EDIT_BYTES { |
| 88 | return Err(AppError::BadRequest(format!( |
| 89 | "that file is {} bytes; the editor handles up to {MAX_EDIT_BYTES}", |
| 90 | blob.size |
| 91 | ))); |
| 92 | } |
| 93 | |
| 94 | // Binary content has no meaningful textarea representation, and round- |
| 95 | // tripping it through one would corrupt it. |
| 96 | let Some(text) = blob.text() else { |
| 97 | return Err(AppError::BadRequest( |
| 98 | "that file is binary and cannot be edited here".into(), |
| 99 | )); |
| 100 | }; |
| 101 | |
| 102 | let body = maud::html! { |
| 103 | (v::editor(&ctx, v::Editor { |
| 104 | bookmark: &bookmark, |
| 105 | tip: tip.as_str(), |
| 106 | path: &p.path, |
| 107 | content: text, |
| 108 | language: df_render::highlight::language_for_path(&p.path).map(|l| l.grammar), |
| 109 | author: user.as_deref().map(|u| u.label()).unwrap_or(""), |
| 110 | csrf: &csrf, |
| 111 | error: q.error.as_deref(), |
| 112 | })) |
| 113 | }; |
| 114 | |
| 115 | Ok(views::page_with_bar( |
| 116 | Chrome { |
| 117 | title: &format!("Editing {} · {}/{}", p.path, ctx.owner, ctx.repo.name), |
| 118 | user: user.as_deref(), |
| 119 | csrf: &csrf, |
| 120 | nonce: &nonce, |
| 121 | }, |
| 122 | rv::header(&ctx, "code"), |
| 123 | // The one page that loads the editor bundle. |
| 124 | maud::html! { |
| 125 | (body) |
| 126 | script src="/assets/editor.js" nonce=(nonce) defer {} |
| 127 | }, |
| 128 | ) |
| 129 | .into_response()) |
| 130 | } |
| 131 | |
| 132 | #[derive(Deserialize)] |
| 133 | pub struct SaveForm { |
| 134 | pub content: String, |
| 135 | pub message: Option<String>, |
| 136 | /// The revision the edit was composed against, so a concurrent edit can be |
| 137 | /// detected instead of silently overwritten. |
| 138 | pub tip: String, |
| 139 | } |
| 140 | |
| 141 | /// `POST /{owner}/{repo}/edit/{rev}/{path}` |
| 142 | pub async fn save( |
| 143 | State(state): State<AppState>, |
| 144 | UrlPath(p): UrlPath<super::repo::RevPath>, |
| 145 | CurrentUser(user): CurrentUser, |
| 146 | Form(form): Form<SaveForm>, |
| 147 | ) -> AppResult<Response> { |
| 148 | let Some(user) = user else { |
| 149 | return Err(AppError::Unauthorized); |
| 150 | }; |
| 151 | let ctx = RepoContext::load(&state, &p.owner, &p.repo, Some(&user)).await?; |
| 152 | ctx.require_push()?; |
| 153 | if ctx.repo.archived { |
| 154 | return Err(AppError::BadRequest( |
| 155 | "This repository is archived and cannot be edited.".into(), |
| 156 | )); |
| 157 | } |
| 158 | |
| 159 | let bookmark = resolve_bookmark(&state, &ctx, &p.rev).await?; |
| 160 | |
| 161 | let back = |msg: &str| -> Response { |
| 162 | Redirect::to(&format!( |
| 163 | "{}/edit/{}/{}?error={}", |
| 164 | ctx.base(), |
| 165 | bookmark, |
| 166 | p.path, |
| 167 | urlencode(msg) |
| 168 | )) |
| 169 | .into_response() |
| 170 | }; |
| 171 | |
| 172 | // Browsers submit CRLF in textarea content regardless of what was typed. |
| 173 | // Normalising here keeps a save from showing up as a diff on every line of |
| 174 | // a file that uses LF — which is every file in a Unix repository. |
| 175 | let content = form.content.replace("\r\n", "\n"); |
| 176 | |
| 177 | if content.len() as u64 > MAX_EDIT_BYTES { |
| 178 | return Ok(back("That content is too large to save.")); |
| 179 | } |
| 180 | |
| 181 | let message = form |
| 182 | .message |
| 183 | .as_deref() |
| 184 | .map(str::trim) |
| 185 | .filter(|m| !m.is_empty()) |
| 186 | .map(|m| m.chars().take(500).collect::<String>()) |
| 187 | .unwrap_or_else(|| format!("Update {}", p.path)); |
| 188 | |
| 189 | // Attributed to the person who pressed save, with a noreply address — the |
| 190 | // email from the identity provider is a claim we are told not to key on and |
| 191 | // should not scatter into commit objects (spec §6). |
| 192 | let author = df_store::Signature { |
| 193 | name: user.label().to_owned(), |
| 194 | email: format!("{}@users.noreply.{}", user.handle, state.config.host()), |
| 195 | when: chrono::Utc::now(), |
| 196 | }; |
| 197 | |
| 198 | let outcome = state |
| 199 | .store |
| 200 | .commit_file( |
| 201 | ctx.store_id(), |
| 202 | &bookmark, |
| 203 | &RevId::from_stored(form.tip.clone()), |
| 204 | &p.path, |
| 205 | content.into_bytes(), |
| 206 | &message, |
| 207 | &author, |
| 208 | ) |
| 209 | .await; |
| 210 | |
| 211 | match outcome { |
| 212 | Ok(EditOutcome::Committed { rev }) => { |
| 213 | // The commit did not arrive over the wire, so no hook fired and |
| 214 | // nothing else would notice it. Index it explicitly. |
| 215 | if let Err(e) = crate::git_http::enqueue_index(&state, ctx.repo.id, Some(user.id)).await |
| 216 | { |
| 217 | tracing::error!(repo = %ctx.repo.id, "enqueuing IndexPush after an edit failed: {e:#}"); |
| 218 | } |
| 219 | let _ = sqlx::query("UPDATE repos SET pushed_at = now() WHERE id = $1") |
| 220 | .bind(ctx.repo.id) |
| 221 | .execute(&state.db) |
| 222 | .await; |
| 223 | |
| 224 | tracing::info!( |
| 225 | repo = %ctx.repo.id, path = %p.path, rev = %rev, actor = %user.handle, |
| 226 | "file edited in the browser" |
| 227 | ); |
| 228 | |
| 229 | Ok(Redirect::to(&format!( |
| 230 | "{}/blob/{bookmark}/{}", |
| 231 | ctx.base(), |
| 232 | p.path |
| 233 | )) |
| 234 | .into_response()) |
| 235 | } |
| 236 | Ok(EditOutcome::Unchanged) => Ok(Redirect::to(&format!( |
| 237 | "{}/blob/{bookmark}/{}", |
| 238 | ctx.base(), |
| 239 | p.path |
| 240 | )) |
| 241 | .into_response()), |
| 242 | Ok(EditOutcome::Stale { .. }) => Ok(back( |
| 243 | "Somebody else changed this file while you were editing. \ |
| 244 | Your text was not saved — reopen the editor and reapply it.", |
| 245 | )), |
| 246 | Err(df_store::StoreError::TooLarge { .. }) => Ok(back("That content is too large to save.")), |
| 247 | Err(df_store::StoreError::Path(e)) => Ok(back(&e.to_string())), |
| 248 | Err(e) => { |
| 249 | tracing::error!(repo = %ctx.repo.id, path = %p.path, "saving an edit failed: {e}"); |
| 250 | Ok(back("The file could not be saved.")) |
| 251 | } |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | /// Require `rev` to name a bookmark of this repository. |
| 256 | /// |
| 257 | /// An edit has to land on something that moves. Accepting a raw revision would |
| 258 | /// let the URL ask for a commit on top of a detached history that nothing |
| 259 | /// points at — written, unreachable, and immediately garbage. |
| 260 | async fn resolve_bookmark( |
| 261 | state: &AppState, |
| 262 | ctx: &RepoContext, |
| 263 | rev: &str, |
| 264 | ) -> AppResult<String> { |
| 265 | let known: bool = sqlx::query_scalar( |
| 266 | "SELECT EXISTS (SELECT 1 FROM bookmarks WHERE repo_id = $1 AND name = $2)", |
| 267 | ) |
| 268 | .bind(ctx.repo.id) |
| 269 | .bind(rev) |
| 270 | .fetch_one(&state.db) |
| 271 | .await?; |
| 272 | |
| 273 | if known { |
| 274 | return Ok(rev.to_string()); |
| 275 | } |
| 276 | |
| 277 | // A repository that has been pushed to but not indexed yet has no bookmark |
| 278 | // rows. Fall back to the configured default so the first edit is not |
| 279 | // blocked by a lagging worker. |
| 280 | if rev == ctx.repo.default_bookmark { |
| 281 | return Ok(rev.to_string()); |
| 282 | } |
| 283 | |
| 284 | Err(AppError::NotFound) |
| 285 | } |
285 lines · Rust