Jump to…
snowfeat: given a new redesign and emulated terminal homepagerxkspqmsoknz1mo
Matt W1//! In-browser file editing.
Matt W2//!
Matt W3//! Not in the spec's route table — an addition on top of v1. It is built to the
Matt W4//! same rules as everything else, and two of them shape the whole design:
Matt W5//!
Matt W6//! * **Progressive enhancement is a hard requirement** (spec §7). The page is a
Matt W7//! form containing a `<textarea>`. CodeMirror replaces it when the bundle
Matt W8//! loads; when it does not, the plain textarea saves exactly the same way.
Matt W9//! * **Edits are commits, not a side channel.** Saving writes a real commit
Matt W10//! whose parent is the bookmark's current tip, through the same store the
Matt W11//! push path uses. It is a fast-forward by construction, so it satisfies the
Matt W12//! protected-bookmark rule rather than bypassing it — and the indexer picks
Matt W13//! it up like any other push.
Matt W14//!
Matt W15//! Editing is a `write`-level action: the same permission a push needs, because
Matt W16//! it is the same effect.
Matt W17
Matt W18use axum::extract::{Path as UrlPath, Query, State};
Matt W19use axum::response::{IntoResponse, Redirect, Response};
Matt W20use axum::Form;
Matt W21use df_store::{EditOutcome, RevId};
Matt W22use serde::Deserialize;
Matt W23
Matt W24use crate::error::{AppError, AppResult};
Matt W25use crate::repo_ctx::RepoContext;
Matt W26use crate::routes::settings::urlencode;
Matt W27use crate::state::{AppState, CsrfToken, CurrentUser, Nonce};
Matt W28use crate::views::edit as v;
Matt W29use crate::views::repo as rv;
Matt W30use crate::views::{self, Chrome};
Matt W31
Matt W32/// Largest file the editor will open.
Matt W33///
Matt W34/// Matches the store's own write cap. A file bigger than this is not something
Matt W35/// to edit in a browser, and saying so up front is kinder than letting somebody
Matt W36/// type into it and be refused on save.
Matt W37const MAX_EDIT_BYTES: u64 = 1024 * 1024;
Matt W38
Matt W39#[derive(Deserialize, Default)]
Matt W40pub struct EditQuery {
Matt W41 pub error: Option<String>,
Matt W42}
Matt W43
Matt W44/// `GET /{owner}/{repo}/edit/{rev}/{path}`
Matt W45///
Matt W46/// `rev` must be a **bookmark name**, not an arbitrary revision. An edit has to
Matt W47/// land somewhere, and "commit this on top of a detached revision" has no
Matt W48/// meaning the user could act on.
Matt W49pub async fn show(
Matt W50 State(state): State<AppState>,
Matt W51 UrlPath(p): UrlPath<super::repo::RevPath>,
Matt W52 Query(q): Query<EditQuery>,
Matt W53 CurrentUser(user): CurrentUser,
Matt W54 CsrfToken(csrf): CsrfToken,
Matt W55 Nonce(nonce): Nonce,
Matt W56) -> AppResult<Response> {
Matt W57 let ctx = RepoContext::load(&state, &p.owner, &p.repo, user.as_deref()).await?;
Matt W58 if user.is_none() {
Matt W59 return Err(AppError::Unauthorized);
Matt W60 }
Matt W61 ctx.require_push()?;
Matt W62 if ctx.repo.archived {
Matt W63 return Err(AppError::BadRequest(
Matt W64 "This repository is archived and cannot be edited.".into(),
Matt W65 ));
Matt W66 }
Matt W67
Matt W68 let bookmark = resolve_bookmark(&state, &ctx, &p.rev).await?;
Matt W69 let tip = state
Matt W70 .store
Matt W71 .resolve(ctx.store_id(), &bookmark)
Matt W72 .await
Matt W73 .map_err(|_| AppError::NotFound)?;
Matt W74
Matt W75 let blob = state
Matt W76 .store
Matt W77 .read_blob(ctx.store_id(), &tip, std::path::Path::new(&p.path))
Matt W78 .await
Matt W79 .map_err(|e| match e {
Matt W80 df_store::StoreError::NoSuchPath => AppError::NotFound,
Matt W81 df_store::StoreError::IsDirectory => {
Matt W82 AppError::BadRequest("that path is a directory".into())
Matt W83 }
Matt W84 other => AppError::Internal(anyhow::anyhow!(other)),
Matt W85 })?;
Matt W86
Matt W87 if blob.size > MAX_EDIT_BYTES {
Matt W88 return Err(AppError::BadRequest(format!(
Matt W89 "that file is {} bytes; the editor handles up to {MAX_EDIT_BYTES}",
Matt W90 blob.size
Matt W91 )));
Matt W92 }
Matt W93
Matt W94 // Binary content has no meaningful textarea representation, and round-
Matt W95 // tripping it through one would corrupt it.
Matt W96 let Some(text) = blob.text() else {
Matt W97 return Err(AppError::BadRequest(
Matt W98 "that file is binary and cannot be edited here".into(),
Matt W99 ));
Matt W100 };
Matt W101
Matt W102 let body = maud::html! {
Matt W103 (v::editor(&ctx, v::Editor {
Matt W104 bookmark: &bookmark,
Matt W105 tip: tip.as_str(),
Matt W106 path: &p.path,
Matt W107 content: text,
Matt W108 language: df_render::highlight::language_for_path(&p.path).map(|l| l.grammar),
Matt W109 author: user.as_deref().map(|u| u.label()).unwrap_or(""),
Matt W110 csrf: &csrf,
Matt W111 error: q.error.as_deref(),
Matt W112 }))
Matt W113 };
Matt W114
Matt W115 Ok(views::page_with_bar(
Matt W116 Chrome {
Matt W117 title: &format!("Editing {} · {}/{}", p.path, ctx.owner, ctx.repo.name),
Matt W118 user: user.as_deref(),
Matt W119 csrf: &csrf,
Matt W120 nonce: &nonce,
Matt W121 },
Matt W122 rv::header(&ctx, "code"),
Matt W123 // The one page that loads the editor bundle.
Matt W124 maud::html! {
Matt W125 (body)
Matt W126 script src="/assets/editor.js" nonce=(nonce) defer {}
Matt W127 },
Matt W128 )
Matt W129 .into_response())
Matt W130}
Matt W131
Matt W132#[derive(Deserialize)]
Matt W133pub struct SaveForm {
Matt W134 pub content: String,
Matt W135 pub message: Option<String>,
Matt W136 /// The revision the edit was composed against, so a concurrent edit can be
Matt W137 /// detected instead of silently overwritten.
Matt W138 pub tip: String,
Matt W139}
Matt W140
Matt W141/// `POST /{owner}/{repo}/edit/{rev}/{path}`
Matt W142pub async fn save(
Matt W143 State(state): State<AppState>,
Matt W144 UrlPath(p): UrlPath<super::repo::RevPath>,
Matt W145 CurrentUser(user): CurrentUser,
Matt W146 Form(form): Form<SaveForm>,
Matt W147) -> AppResult<Response> {
Matt W148 let Some(user) = user else {
Matt W149 return Err(AppError::Unauthorized);
Matt W150 };
Matt W151 let ctx = RepoContext::load(&state, &p.owner, &p.repo, Some(&user)).await?;
Matt W152 ctx.require_push()?;
Matt W153 if ctx.repo.archived {
Matt W154 return Err(AppError::BadRequest(
Matt W155 "This repository is archived and cannot be edited.".into(),
Matt W156 ));
Matt W157 }
Matt W158
Matt W159 let bookmark = resolve_bookmark(&state, &ctx, &p.rev).await?;
Matt W160
Matt W161 let back = |msg: &str| -> Response {
Matt W162 Redirect::to(&format!(
Matt W163 "{}/edit/{}/{}?error={}",
Matt W164 ctx.base(),
Matt W165 bookmark,
Matt W166 p.path,
Matt W167 urlencode(msg)
Matt W168 ))
Matt W169 .into_response()
Matt W170 };
Matt W171
Matt W172 // Browsers submit CRLF in textarea content regardless of what was typed.
Matt W173 // Normalising here keeps a save from showing up as a diff on every line of
Matt W174 // a file that uses LF — which is every file in a Unix repository.
Matt W175 let content = form.content.replace("\r\n", "\n");
Matt W176
Matt W177 if content.len() as u64 > MAX_EDIT_BYTES {
Matt W178 return Ok(back("That content is too large to save."));
Matt W179 }
Matt W180
Matt W181 let message = form
Matt W182 .message
Matt W183 .as_deref()
Matt W184 .map(str::trim)
Matt W185 .filter(|m| !m.is_empty())
Matt W186 .map(|m| m.chars().take(500).collect::<String>())
Matt W187 .unwrap_or_else(|| format!("Update {}", p.path));
Matt W188
Matt W189 // Attributed to the person who pressed save, with a noreply address — the
Matt W190 // email from the identity provider is a claim we are told not to key on and
Matt W191 // should not scatter into commit objects (spec §6).
Matt W192 let author = df_store::Signature {
Matt W193 name: user.label().to_owned(),
Matt W194 email: format!("{}@users.noreply.{}", user.handle, state.config.host()),
Matt W195 when: chrono::Utc::now(),
Matt W196 };
Matt W197
Matt W198 let outcome = state
Matt W199 .store
Matt W200 .commit_file(
Matt W201 ctx.store_id(),
Matt W202 &bookmark,
Matt W203 &RevId::from_stored(form.tip.clone()),
Matt W204 &p.path,
Matt W205 content.into_bytes(),
Matt W206 &message,
Matt W207 &author,
Matt W208 )
Matt W209 .await;
Matt W210
Matt W211 match outcome {
Matt W212 Ok(EditOutcome::Committed { rev }) => {
Matt W213 // The commit did not arrive over the wire, so no hook fired and
Matt W214 // nothing else would notice it. Index it explicitly.
Matt W215 if let Err(e) = crate::git_http::enqueue_index(&state, ctx.repo.id, Some(user.id)).await
Matt W216 {
Matt W217 tracing::error!(repo = %ctx.repo.id, "enqueuing IndexPush after an edit failed: {e:#}");
Matt W218 }
Matt W219 let _ = sqlx::query("UPDATE repos SET pushed_at = now() WHERE id = $1")
Matt W220 .bind(ctx.repo.id)
Matt W221 .execute(&state.db)
Matt W222 .await;
Matt W223
Matt W224 tracing::info!(
Matt W225 repo = %ctx.repo.id, path = %p.path, rev = %rev, actor = %user.handle,
Matt W226 "file edited in the browser"
Matt W227 );
Matt W228
Matt W229 Ok(Redirect::to(&format!(
Matt W230 "{}/blob/{bookmark}/{}",
Matt W231 ctx.base(),
Matt W232 p.path
Matt W233 ))
Matt W234 .into_response())
Matt W235 }
Matt W236 Ok(EditOutcome::Unchanged) => Ok(Redirect::to(&format!(
Matt W237 "{}/blob/{bookmark}/{}",
Matt W238 ctx.base(),
Matt W239 p.path
Matt W240 ))
Matt W241 .into_response()),
Matt W242 Ok(EditOutcome::Stale { .. }) => Ok(back(
Matt W243 "Somebody else changed this file while you were editing. \
Matt W244 Your text was not saved — reopen the editor and reapply it.",
Matt W245 )),
Matt W246 Err(df_store::StoreError::TooLarge { .. }) => Ok(back("That content is too large to save.")),
Matt W247 Err(df_store::StoreError::Path(e)) => Ok(back(&e.to_string())),
Matt W248 Err(e) => {
Matt W249 tracing::error!(repo = %ctx.repo.id, path = %p.path, "saving an edit failed: {e}");
Matt W250 Ok(back("The file could not be saved."))
Matt W251 }
Matt W252 }
Matt W253}
Matt W254
Matt W255/// Require `rev` to name a bookmark of this repository.
Matt W256///
Matt W257/// An edit has to land on something that moves. Accepting a raw revision would
Matt W258/// let the URL ask for a commit on top of a detached history that nothing
Matt W259/// points at — written, unreachable, and immediately garbage.
Matt W260async fn resolve_bookmark(
Matt W261 state: &AppState,
Matt W262 ctx: &RepoContext,
Matt W263 rev: &str,
Matt W264) -> AppResult<String> {
Matt W265 let known: bool = sqlx::query_scalar(
Matt W266 "SELECT EXISTS (SELECT 1 FROM bookmarks WHERE repo_id = $1 AND name = $2)",
Matt W267 )
Matt W268 .bind(ctx.repo.id)
Matt W269 .bind(rev)
Matt W270 .fetch_one(&state.db)
Matt W271 .await?;
Matt W272
Matt W273 if known {
Matt W274 return Ok(rev.to_string());
Matt W275 }
Matt W276
Matt W277 // A repository that has been pushed to but not indexed yet has no bookmark
Matt W278 // rows. Fall back to the configured default so the first edit is not
Matt W279 // blocked by a lagging worker.
Matt W280 if rev == ctx.repo.default_bookmark {
Matt W281 return Ok(rev.to_string());
Matt W282 }
Matt W283
Matt W284 Err(AppError::NotFound)
Matt W285}

285 lines · Rust