Jump to…
snowfeat: given a new redesign and emulated terminal homepagerxkspqmsoknz1mo
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
18use axum::extract::{Path as UrlPath, Query, State};
19use axum::response::{IntoResponse, Redirect, Response};
20use axum::Form;
21use df_store::{EditOutcome, RevId};
22use serde::Deserialize;
23
24use crate::error::{AppError, AppResult};
25use crate::repo_ctx::RepoContext;
26use crate::routes::settings::urlencode;
27use crate::state::{AppState, CsrfToken, CurrentUser, Nonce};
28use crate::views::edit as v;
29use crate::views::repo as rv;
30use 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.
37const MAX_EDIT_BYTES: u64 = 1024 * 1024;
38
39#[derive(Deserialize, Default)]
40pub 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.
49pub 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)]
133pub 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}`
142pub 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.
260async 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