git-ents.gitmain
⌘K
foforge
commit 44a6485
feat: comment from the web and toggle doc blobs between rendered and source

A signed-in member gets an add-comment form under both file views; the comment anchors to HEAD and lands on refs/meta/comments/<id> through the same signed_edit push settings use, with no admin-registered gate since issues and comments are exactly what self-attested members may write. Prose-format blobs gain a chip switching the rendered document and its highlighted source.

feat: add POST /{repo}/comment landing anchored comments via signed_edit feat: add /{repo}/source/<path> and a rendered/source toggle on doc blobs Assisted-by: Claude:claude-fable-5

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

crates/git-ents-server/src/web/mod.rs @@ -156,10 +156,11 @@ let Some((repo, rel, rest)) = resolve_repo(&state.data_dir, &segments) else { return not_found().into_response(); }; - if rest != ["settings"] { - return not_found().into_response(); + match rest { + ["settings"] => save_settings(state, &repo, &rel, cookie, body).await, + ["comment"] => save_comment(state, &repo, &rel, cookie, body).await, + _ => not_found().into_response(), } - save_settings(state, &repo, &rel, cookie, body).await } /// Whether this server can actually land browser edits: it needs the signed-push @@ -195,7 +196,7 @@ state.web_signing_key.clone(), ) else { return edit_error( - rel, + &format!("/{rel}/settings"), "Editing is disabled: this server has no web signing key or signed-push gate.", ) .into_response(); @@ -205,8 +206,11 @@ cookie, &write::field(&body, "csrf").unwrap_or_default(), ) { - return edit_error(rel, "the edit could not be verified; reload and try again") - .into_response(); + return edit_error( + &format!("/{rel}/settings"), + "the edit could not be verified; reload and try again", + ) + .into_response(); } let edit = write::ConfigEdit { description: write::field(&body, "description").unwrap_or_default(), @@ -236,10 +240,84 @@ }) .await; + let back = format!("/{rel}/settings"); match result { - Ok(Ok(())) => redirect(&format!("/{rel}/settings"), None), - Ok(Err(error)) => edit_error(rel, &error).into_response(), - Err(_join) => edit_error(rel, "the edit did not complete").into_response(), + Ok(Ok(())) => redirect(&back, None), + Ok(Err(error)) => edit_error(&back, &error).into_response(), + Err(_join) => edit_error(&back, "the edit did not complete").into_response(), + } +} + +/// Record a code comment posted from a file view, then redirect back to that +/// file on success or render the reason it was rejected. +async fn save_comment( + state: &AppState, + repo: &Path, + rel: &str, + cookie: Option<&str>, + body: Bytes, +) -> Response { + let path = write::field(&body, "path").unwrap_or_default(); + let back = format!("/{rel}/blob/{path}"); + let (Some(seed), Some(hooks), Some(signing_key)) = ( + state.cert_nonce_seed.clone(), + state.hooks_dir.clone(), + state.web_signing_key.clone(), + ) else { + return edit_error( + &back, + "Editing is disabled: this server has no web signing key or signed-push gate.", + ) + .into_response(); + }; + if !write::csrf_ok( + &state.sessions, + cookie, + &write::field(&body, "csrf").unwrap_or_default(), + ) { + return edit_error( + &back, + "the comment could not be verified; reload and try again", + ) + .into_response(); + } + let lines = match write::parse_lines(&write::field(&body, "lines").unwrap_or_default()) { + Ok(lines) => lines, + Err(error) => return edit_error(&back, &error).into_response(), + }; + let text = write::field(&body, "body") + .unwrap_or_default() + .trim() + .to_owned(); + if path.is_empty() || text.is_empty() { + return edit_error(&back, "a comment needs a file path and a body").into_response(); + } + let edit = write::CommentEdit { + path, + lines, + body: text, + }; + + let sessions = state.sessions.clone(); + let cookie = cookie.map(str::to_owned); + let repo = repo.to_owned(); + let result = tokio::task::spawn_blocking(move || { + write::add_comment( + &sessions, + cookie.as_deref(), + &repo, + &edit, + &seed, + &hooks, + &signing_key, + ) + }) + .await; + + match result { + Ok(Ok(())) => redirect(&back, None), + Ok(Err(error)) => edit_error(&back, &error).into_response(), + Err(_join) => edit_error(&back, "the comment did not complete").into_response(), } } @@ -288,9 +366,35 @@ let meta = gather_meta(repo, rel).await; match rest.split_first() { None => pages::repo_page(repo, &meta, host).await.into_response(), - Some((&"files", sub)) => pages::files_page(repo, &meta, sub).await, + Some((&"files", sub)) => { + let auth = resolve_auth(repo, session).await; + pages::files_page(repo, &meta, sub, auth.as_ref(), editing).await + } Some((&"tree", sub)) => pages::tree_page(repo, &meta, sub).await, - Some((&"blob", sub)) => pages::blob_page(repo, &meta, sub).await, + Some((&"blob", sub)) => { + let auth = resolve_auth(repo, session).await; + pages::blob_page( + repo, + &meta, + sub, + auth.as_ref(), + editing, + pages::BlobView::Rendered, + ) + .await + } + Some((&"source", sub)) => { + let auth = resolve_auth(repo, session).await; + pages::blob_page( + repo, + &meta, + sub, + auth.as_ref(), + editing, + pages::BlobView::Source, + ) + .await + } Some((&"commit", &[sha])) => pages::commit_page(repo, &meta, sha).await, Some((&"releases", &[])) => pages::releases_page(repo, &meta).await.into_response(), Some((&"checks", &[])) => pages::checks_page(repo, &meta).await.into_response(), @@ -598,14 +702,15 @@ ) } -/// A page reporting why a settings edit was rejected, with a way back. -fn edit_error(rel: &str, error: &str) -> Markup { +/// A page reporting why a browser write was rejected, with a way back to the +/// page it was posted from. +fn edit_error(back: &str, error: &str) -> Markup { page( "Edit rejected", html! { div.page-header { h1.page-title { "Edit rejected" } } div.card-row.muted { (error) } - p { a.btn href={ "/" (rel) "/settings" } { "Back to settings" } } + p { a.btn href=(back) { "Back" } } }, ) }
crates/git-ents-server/src/web/pages.rs @@ -268,7 +268,13 @@ /// client JavaScript, expanding a folder or opening a file is a link to /// `/<repo>/files/<path>`; the tree is rendered already expanded along the /// selected path. -pub(super) async fn files_page(repo: &Path, meta: &RepoMeta, sub: &[&str]) -> Response { +pub(super) async fn files_page( + repo: &Path, + meta: &RepoMeta, + sub: &[&str], + auth: Option<&super::Auth>, + editing: bool, +) -> Response { let rel = &meta.rel; let Some(selected) = browse_path(sub) else { return not_found().into_response(); @@ -320,7 +326,7 @@ Some(path) => { let pane = blob_pane(repo, path).await; let comments = file_comments(repo, path).await; - html! { (pane) (comments_card(&comments)) } + html! { (pane) (comments_card(&comments, comment_form(rel, path, auth, editing))) } } None => html! { div.files-empty { @@ -502,9 +508,27 @@ .into_response() } -/// A single file's contents at `sub`, syntax-highlighted when the language is -/// recognized and the file is text. -pub(super) async fn blob_page(repo: &Path, meta: &RepoMeta, sub: &[&str]) -> Response { +/// Which form of a blob the blob route shows: prose formats (AsciiDoc, +/// Markdown) rendered as a document, or the underlying source. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum BlobView { + Rendered, + Source, +} + +/// A single file's contents at `sub`. A prose format renders as a formatted +/// document under [`BlobView::Rendered`] and as its source under +/// [`BlobView::Source`], with a toggle between the two; everything else is +/// syntax-highlighted source when the language is recognized and the file is +/// text. +pub(super) async fn blob_page( + repo: &Path, + meta: &RepoMeta, + sub: &[&str], + auth: Option<&super::Auth>, + editing: bool, + view: BlobView, +) -> Response { let rel = &meta.rel; let Some(path) = browse_path(sub).filter(|p| !p.is_empty()) else { return not_found().into_response(); @@ -523,15 +547,19 @@ return not_found().into_response(); }; let name = path.rsplit('/').next().unwrap_or(&path); + let displayable = !truncated && !is_binary(&bytes); let body = if truncated { html! { div.blob { div.binary { "File too large to display (over " (human_size(MAX_RENDER_BYTES)) ")." } } } - } else if is_binary(&bytes) { + } else if !displayable { html! { div.blob { div.binary { "Binary file (" (human_size(bytes.len())) ") not shown." } } } } else { let text = String::from_utf8_lossy(&bytes); - match doc_html(name, &text) { - Some(html) => html! { div.card { article.adoc-body { (PreEscaped(html)) } } }, - None => blob_body(name, &text), + match view { + BlobView::Rendered => match doc_html(name, &text) { + Some(html) => html! { div.card { article.adoc-body { (PreEscaped(html)) } } }, + None => blob_body(name, &text), + }, + BlobView::Source => blob_body(name, &text), } }; let comments = file_comments(repo, &path).await; @@ -541,13 +569,27 @@ name, html! { (crumbs(rel, &path, true)) + @if displayable && is_doc(name) { + div.view-toggle { + @match view { + BlobView::Rendered => a.chip href={ "/" (rel) "/source/" (path) } { "View source" }, + BlobView::Source => a.chip href={ "/" (rel) "/blob/" (path) } { "View rendered" }, + } + } + } (body) - (comments_card(&comments)) + (comments_card(&comments, comment_form(rel, &path, auth, editing))) }, ) .into_response() } +/// Whether `name` is a prose format the forge renders as a document, and so +/// gets the rendered/source toggle. +fn is_doc(name: &str) -> bool { + crate::asciidoc::is_asciidoc(name) || crate::markdown::is_markdown(name) +} + /// Render text file `source` with a line-number gutter — each number a /// self-linking `#L<n>` anchor — highlighting via `arborium` when the filename /// maps to a known grammar. @@ -645,11 +687,12 @@ .unwrap_or_default() } -/// The Comments card under a file view, or nothing when the file has none. -/// A line-anchored comment links its range to the gutter's `#L<n>` anchors; -/// an outdated one is flagged instead, since its lines no longer exist. -fn comments_card(comments: &[FileComment]) -> Markup { - if comments.is_empty() { +/// The Comments card under a file view: existing comments, then the add form +/// when the viewer may comment; nothing when there are neither. A +/// line-anchored comment links its range to the gutter's `#L<n>` anchors; an +/// outdated one is flagged instead, since its lines no longer exist. +fn comments_card(comments: &[FileComment], form: Option<Markup>) -> Markup { + if comments.is_empty() && form.is_none() { return html! {}; } html! { @@ -671,10 +714,36 @@ p.comment-body { (comment.body) } } } + @if let Some(form) = form { (form) } } } } +/// The add-comment form under a file view, shown when a signed-in member views +/// a server that can land edits; `None` otherwise, since a submit would only +/// fail. The comment anchors to `HEAD`'s blob at `path`. +fn comment_form( + rel: &str, + path: &str, + auth: Option<&super::Auth>, + editing: bool, +) -> Option<Markup> { + let auth = auth.filter(|a| editing && a.username.is_some())?; + Some(html! { + div.comment-row { + form.edit-form method="post" action={ "/" (rel) "/comment" } { + input type="hidden" name="csrf" value=(auth.csrf); + input type="hidden" name="path" value=(path); + label { "Lines" } + input type="text" name="lines" placeholder="12 or 12:15 — empty for the whole file"; + label { "Comment" } + textarea name="body" rows="3" placeholder="Anchored to this file as of the current HEAD" {} + button.btn type="submit" { "Comment" } + } + } + }) +} + /// A single commit: its metadata and a colorized unified diff. pub(super) async fn commit_page(repo: &Path, meta: &RepoMeta, sha: &str) -> Response { if sha.is_empty() || sha.len() > 64 || !sha.bytes().all(|b| b.is_ascii_hexdigit()) {
crates/git-ents-server/src/web/style.css @@ -228,6 +228,10 @@ .blob-code code { display: block; font-family: inherit; padding: 0 1.25rem; white-space: pre; color: var(--color-text); } .binary { padding: 2.5rem; text-align: center; font-family: var(--font-mono); font-size: .85rem; color: var(--color-text-muted); } +.view-toggle { display: flex; justify-content: flex-end; margin-bottom: .6rem; } +.view-toggle a.chip { cursor: pointer; text-decoration: none; } +.view-toggle a.chip:hover { color: var(--color-accent); border-color: var(--color-accent); } + .file-comments { margin-bottom: 1.5rem; } .comment-row { padding: .8rem 1.1rem; border-bottom: 1px solid var(--color-border); } .comment-row:last-child { border-bottom: 0; }
crates/git-ents-server/src/web/write.rs @@ -64,6 +64,13 @@ pub(super) topics: Vec<String>, } +/// The fields of a new code comment posted from a file view. +pub(super) struct CommentEdit { + pub(super) path: String, + pub(super) lines: Option<git_anchor::LineRange>, + pub(super) body: String, +} + /// Create an empty session table. pub(crate) fn new_sessions() -> Sessions { Arc::new(Mutex::new(HashMap::new())) @@ -208,19 +215,8 @@ hooks: &Path, signing_key: &Path, ) -> Result<(), String> { - let token = cookie - .and_then(token) + let public_key = session_public_key(sessions, cookie) .ok_or_else(|| "sign in to edit settings".to_owned())?; - let public_key = { - let table = sessions - .lock() - .map_err(|_poisoned| "session store unavailable".to_owned())?; - table - .get(&token) - .ok_or_else(|| "sign in to edit settings".to_owned())? - .public_key - .clone() - }; let store = git_store::Store::open(repo).map_err(|e| format!("cannot open store: {e}"))?; let username = member_for_public_key_with(&store, &public_key) @@ -245,6 +241,74 @@ ) } +/// Land a new code comment on `refs/meta/comments/<id>`, anchored to `HEAD`'s +/// blob at the commented path. Any signed-in member may comment — including a +/// self-attested web member, whose allowed writes are exactly issues and +/// comments — so there is no [`require_admin_registered`] gate here. +pub(super) fn add_comment( + sessions: &Sessions, + cookie: Option<&str>, + repo: &Path, + edit: &CommentEdit, + seed: &str, + hooks: &Path, + signing_key: &Path, +) -> Result<(), String> { + let public_key = + session_public_key(sessions, cookie).ok_or_else(|| "sign in to comment".to_owned())?; + let store = git_store::Store::open(repo).map_err(|e| format!("cannot open store: {e}"))?; + let username = member_for_public_key_with(&store, &public_key) + .ok_or_else(|| "your web key is not a member of this repository".to_owned())?; + + let anchor = git_anchor::capture(repo, "HEAD", &edit.path, edit.lines) + .map_err(|e| format!("could not anchor the comment: {e}"))?; + let comment = git_comment::Comment { + body: edit.body.clone(), + anchor, + issue: None, + }; + let id = git_comment::new_id(None, &comment) + .map_err(|e| format!("could not derive the comment id: {e}"))?; + let target = format!("{}/{id}", git_comment::COMMENTS_NS); + signed_edit( + repo, + &target, + &comment, + "Add comment", + &username, + signing_key, + seed, + hooks, + ) +} + +/// Parse an optional `<start>[:<end>]` line-range form field; empty means a +/// whole-file comment. +pub(super) fn parse_lines(field: &str) -> Result<Option<git_anchor::LineRange>, String> { + let field = field.trim(); + if field.is_empty() { + return Ok(None); + } + let (start, end) = field.split_once(':').unwrap_or((field, field)); + let parse = |number: &str| { + number + .trim() + .parse::<u64>() + .map_err(|_error| format!("invalid line number {number:?}")) + }; + Ok(Some(git_anchor::LineRange { + start: parse(start)?, + end: parse(end)?, + })) +} + +/// The signed-in session's public key, when `cookie` names a live session. +fn session_public_key(sessions: &Sessions, cookie: Option<&str>) -> Option<String> { + let token = cookie.and_then(token)?; + let table = sessions.lock().ok()?; + Some(table.get(&token)?.public_key.clone()) +} + /// Land `value` onto `target_ref` as a real `git push --signed`, authored by /// `username` and signed with the server's own `signing_key`, through the /// same `pre-receive` gate a CLI push traverses — the one landing operation