| 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