file viewer: edit Markdown with optimistic-lock conflict detection
Nightly Build / build (push) Successful in 6m47s
Nightly Build / build (push) Successful in 6m47s
- GET /api/file returns ETag (mtime+size) + X-Writable on disk files; PUT /api/file accepts optional if_match -> 409 Conflict on stale version (last-write-wins preserved when omitted), echoes the new ETag - FileViewerBase: View | Edit tabs for .md when the caller can write; source textarea with Save/Cancel, live preview while editing - Watcher no longer clobbers the buffer mid-edit: while editing with unsaved changes it probes the server ETag and only raises a conflict when the remote actually moved on (own-save echo is ignored) - Conflict banner: Reload remote | Copy mine, then reload | Overwrite - i18n (en/it/fr) + CSS; docs/projects.md updated
This commit is contained in:
@@ -4,7 +4,7 @@ use axum::{
|
||||
Extension, Json,
|
||||
body::Bytes,
|
||||
extract::{Query, State},
|
||||
http::{HeaderValue, StatusCode, header},
|
||||
http::{HeaderValue, HeaderMap, StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -58,6 +58,22 @@ fn require_write(fs: &UserFs, agent: &str) -> Result<(), ApiError> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A lightweight version token for a file on disk, used as a strong-ish `ETag`
|
||||
/// for the editor's optimistic locking. It is *not* a content hash: it combines
|
||||
/// the mtime (nanosecond precision on the local filesystems we run on) and the
|
||||
/// size, which is enough to detect "someone wrote after you loaded" without the
|
||||
/// cost of hashing every served file (including images/PDFs). Two distinct
|
||||
/// writes with identical size in the same nanosecond would collide — acceptable
|
||||
/// for a small-instance, last-write-wins-becomes-visible contract.
|
||||
fn disk_etag(md: &std::fs::Metadata) -> String {
|
||||
let mtime_ns = md.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
format!("\"{}-{}\"", mtime_ns, md.len())
|
||||
}
|
||||
|
||||
/// GET /api/files/dir?path=… — the immediate children of a directory (dirs
|
||||
/// first, then name), resolved and scoped exactly like `GET /api/file`.
|
||||
pub async fn list_dir(
|
||||
@@ -182,10 +198,11 @@ pub async fn get_file(
|
||||
}
|
||||
|
||||
let user_fs = ctx.fs.load();
|
||||
let abs = match fs_tools::resolve_view_path(user_fs.as_ref(), &q.path) {
|
||||
Ok((abs, _)) => abs,
|
||||
Err(e) => return (StatusCode::BAD_REQUEST, format!("Invalid path: {e}")).into_response(),
|
||||
let (abs, agent) = match fs_tools::resolve_view_path(user_fs.as_ref(), &q.path) {
|
||||
Ok((abs, agent)) => (abs, agent),
|
||||
Err(e) => return (StatusCode::BAD_REQUEST, format!("Invalid path: {e}")).into_response(),
|
||||
};
|
||||
let writable = user_fs.can_write_to(&agent);
|
||||
|
||||
if q.compile_latex && is_latex(&q.path) {
|
||||
return match state.latex_compiler().compile(&abs).await {
|
||||
@@ -207,6 +224,20 @@ pub async fn get_file(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static(content_type_for(&q.path)),
|
||||
);
|
||||
// Optimistic-locking version token + write flag for the editor.
|
||||
// Only disk files are editable through this surface, so both come
|
||||
// from the on-disk metadata / `UserFs` membership snapshot.
|
||||
if let Ok(md) = tokio::fs::metadata(&abs).await {
|
||||
if let Ok(v) = HeaderValue::from_str(&disk_etag(&md)) {
|
||||
response.headers_mut().insert(header::ETAG, v);
|
||||
}
|
||||
}
|
||||
if writable {
|
||||
response.headers_mut().insert(
|
||||
header::HeaderName::from_static("x-writable"),
|
||||
HeaderValue::from_static("1"),
|
||||
);
|
||||
}
|
||||
if q.force_download {
|
||||
set_attachment(&mut response, &basename(&q.path));
|
||||
}
|
||||
@@ -327,6 +358,14 @@ fn content_type_for(path: &str) -> &'static str {
|
||||
pub struct SavePayload {
|
||||
pub path: String,
|
||||
pub content: String,
|
||||
/// Optional optimistic-locking token (the `ETag` returned by `GET /api/file`
|
||||
/// when the editor loaded the file). When present, the save only succeeds
|
||||
/// if the file on disk still matches; otherwise the handler returns
|
||||
/// `409 Conflict` so the editor can prompt (reload remote / overwrite /
|
||||
/// copy my changes) instead of silently clobbering a concurrent write.
|
||||
/// Absent ⇒ legacy last-write-wins behaviour (no caller is broken).
|
||||
#[serde(default)]
|
||||
pub if_match: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -389,7 +428,7 @@ pub async fn save_file(
|
||||
State(state): State<Arc<Skald>>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
Json(body): Json<SavePayload>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
) -> Result<(StatusCode, HeaderMap), ApiError> {
|
||||
let ctx = require_context(&state, &auth.user_id).await?;
|
||||
let fs = ctx.fs.load();
|
||||
let (abs, display) = fs_tools::resolve_view_path(fs.as_ref(), &body.path)
|
||||
@@ -398,8 +437,26 @@ pub async fn save_file(
|
||||
if !abs.exists() {
|
||||
return Err(anyhow::anyhow!("File not found: {display}").into());
|
||||
}
|
||||
// Optimistic locking: if the caller pinned a version, refuse to overwrite a
|
||||
// file that changed underneath it. We treat a missing file here as a
|
||||
// conflict too (the base it was edited against is gone).
|
||||
if let Some(expected) = body.if_match.as_deref() {
|
||||
let current = std::fs::metadata(&abs).ok().map(|m| disk_etag(&m));
|
||||
if current.as_deref() != Some(expected) {
|
||||
return Err(ApiError::conflict(format!(
|
||||
"File modified remotely: {display}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
std::fs::write(&abs, &body.content)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
// Echo the new version so the editor can update its token without a re-fetch.
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Ok(md) = std::fs::metadata(&abs) {
|
||||
if let Ok(v) = HeaderValue::from_str(&disk_etag(&md)) {
|
||||
headers.insert(header::ETAG, v);
|
||||
}
|
||||
}
|
||||
Ok((StatusCode::NO_CONTENT, headers))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
||||
@@ -238,6 +238,13 @@ impl ApiError {
|
||||
pub fn payload_too_large(msg: impl Into<String>) -> Self {
|
||||
Self { status: StatusCode::PAYLOAD_TOO_LARGE, message: msg.into() }
|
||||
}
|
||||
|
||||
/// `409 Conflict` — used by the file editor's optimistic locking: the
|
||||
/// caller sent an `if_match` ETag that no longer matches the file on disk
|
||||
/// (someone else — another user, another tab, or an agent — wrote first).
|
||||
pub fn conflict(msg: impl Into<String>) -> Self {
|
||||
Self { status: StatusCode::CONFLICT, message: msg.into() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the authenticated caller's per-user runtime context, or `401` when the
|
||||
|
||||
Reference in New Issue
Block a user