anvilsign in

collin/anvil · 15dac03a

feat: render markdown in the blob viewer, with a source toggle

Collin Richards · 2026-06-10 00:19 UTC · 15dac03a188949edbe2800ec4c91228e8c699ef6 · parent 870d1591 · browse files

modifiedCargo.lock+19 −0
⋯ 204 unchanged lines
205205 "base64",
206206 "lru",
207207 "maud",
208+ "pulldown-cmark",
208209 "serde",
209210 "similar",
210211 "syntect",
⋯ 3072 unchanged lines
32833284 ]
32843285
32853286 [[package]]
3287+name = "pulldown-cmark"
3288+version = "0.13.4"
3289+source = "registry+https://github.com/rust-lang/crates.io-index"
3290+checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
3291+dependencies = [
3292+ "bitflags",
3293+ "memchr",
3294+ "pulldown-cmark-escape",
3295+ "unicase",
3296+]
3297+
3298+[[package]]
3299+name = "pulldown-cmark-escape"
3300+version = "0.11.0"
3301+source = "registry+https://github.com/rust-lang/crates.io-index"
3302+checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
3303+
3304+[[package]]
32863305 name = "quick-xml"
32873306 version = "0.39.4"
32883307 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 1960 unchanged lines
modifiedCargo.toml+1 −0
⋯ 33 unchanged lines
3434 gix = { version = "0.84", default-features = false, features = ["sha1", "max-performance-safe", "blob-diff", "revision"] }
3535 gix-pack = { version = "0.71", features = ["sha1"] }
3636 maud = { version = "0.27", features = ["axum"] }
37+pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] }
3738 rand = "0.10"
3839 # HTTP client for the CD deploy webhook. No TLS feature on purpose: the deploy
3940 # receiver is host-local plaintext HTTP, and enabling rustls would drag in
⋯ 30 unchanged lines
modifiedcrates/anvil-web/Cargo.toml+1 −0
⋯ 19 unchanged lines
2020 base64.workspace = true
2121 lru.workspace = true
2222 maud.workspace = true
23+pulldown-cmark.workspace = true
2324 similar.workspace = true
2425 syntect.workspace = true
2526 time.workspace = true
modifiedcrates/anvil-web/src/ui.rs+124 −2
⋯ 9 unchanged lines
1010 use anvil_git::browse::{self, ChangeKind, FileChange};
1111 use axum::{
1212 Form, Router,
13- extract::{Path, State},
13+ extract::{Path, Query, State},
1414 http::{StatusCode, header},
1515 response::{IntoResponse, Redirect, Response},
1616 routing::{get, post},
⋯ 48 unchanged lines
6565 .clone.copied .copy-btn { color:#1a7f37; }
6666 .crumbs { margin:12px 0; font:13px ui-monospace,monospace; }
6767 .pill { display:inline-block; background:var(--code-bg); border:1px solid var(--border); border-radius:2em; padding:1px 8px; font-size:12px; color:var(--muted); }
68+.pill.active { background:var(--accent); border-color:var(--accent); color:#fff; }
69+.view-toggle { margin:8px 0; }
70+a.pill:hover { text-decoration:none; border-color:var(--accent); color:var(--accent); }
71+.md-body { padding:8px 24px 16px; line-height:1.6; overflow-wrap:break-word; }
72+.md-body h1, .md-body h2 { border-bottom:1px solid var(--border); padding-bottom:6px; }
73+.md-body pre { background:var(--code-bg); border-radius:6px; padding:12px 14px; overflow-x:auto; font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
74+.md-body code { background:var(--code-bg); border-radius:4px; padding:1px 4px; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:0.9em; }
75+.md-body pre code { background:none; padding:0; font-size:inherit; }
76+.md-body blockquote { border-left:4px solid var(--border); margin:0 0 12px; padding:0 14px; color:var(--muted); }
77+.md-body table { border-collapse:collapse; margin:12px 0; } .md-body th, .md-body td { border:1px solid var(--border); padding:5px 10px; }
78+.md-body img { max-width:100%; }
6879 .linkbtn { background:none; border:0; color:var(--accent); cursor:pointer; font:inherit; padding:0; }
6980 .linkbtn:hover { text-decoration:underline; }
7081 .btn { display:inline-block; background:var(--accent); color:#fff; border:1px solid var(--accent); border-radius:6px; padding:5px 12px; font-size:13px; cursor:pointer; }
⋯ 738 unchanged lines
809820 ))
810821 }
811822
812-/// `GET /{owner}/{repo}/blob/{rev}/{*path}` — view a file.
823+/// `GET /{owner}/{repo}/blob/{rev}/{*path}` — view a file. Markdown renders
824+/// by default; `?plain=1` shows the raw source (toggle links on the page).
813825 async fn blob(
814826 State(app): State<App>,
815827 CurrentUser(user): CurrentUser,
816828 Path((owner, repo, rev, path)): Path<(String, String, String, String)>,
829+ Query(query): Query<HashMap<String, String>>,
817830 ) -> Result<Markup, Response> {
818831 let (repo_path, _) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
819832 let (oid, bytes) = browse::read_blob_with_id(&repo_path, &rev, &path)
820833 .map_err(server_error)?
821834 .ok_or_else(|| not_found("file not found"))?;
822835
836+ let markdown = is_markdown(&path) && !is_binary(&bytes);
837+ let rendered = markdown && !query.contains_key("plain");
838+
823839 let body = if is_binary(&bytes) {
824840 html! { p.muted { "Binary file (" (bytes.len()) " bytes)" } }
841+ } else if rendered {
842+ let text = String::from_utf8_lossy(&bytes);
843+ html! { div.md-body { (render_markdown(&text)) } }
825844 } else {
826845 let text = String::from_utf8_lossy(&bytes);
827846 let budget = app.config.http.highlight_cache_mb.saturating_mul(1 << 20);
⋯ 10 unchanged lines
838857 }
839858 };
840859
860+ let blob_url = format!("/{owner}/{repo}/blob/{rev}/{path}");
841861 Ok(layout(
842862 &format!("{owner}/{repo}: {path}"),
843863 user.as_ref(),
844864 html! {
845865 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } }
846866 (breadcrumbs(&owner, &repo, &rev, &path, true))
867+ @if markdown {
868+ p.view-toggle {
869+ @if rendered {
870+ span.pill.active { "Rendered" } " "
871+ a.pill href=(format!("{blob_url}?plain=1")) { "Source" }
872+ } @else {
873+ a.pill href=(blob_url) { "Rendered" } " "
874+ span.pill.active { "Source" }
875+ }
876+ }
877+ }
847878 div.box style="overflow-x:auto" { (body) }
848879 },
849880 ))
850881 }
851882
883+/// Whether a path should be treated as markdown (by extension).
884+fn is_markdown(path: &str) -> bool {
885+ std::path::Path::new(path)
886+ .extension()
887+ .and_then(|e| e.to_str())
888+ .is_some_and(|e| e.eq_ignore_ascii_case("md") || e.eq_ignore_ascii_case("markdown"))
889+}
890+
891+/// Render markdown to HTML (tables, strikethrough, task lists, footnotes).
892+///
893+/// Repo content is untrusted, so this is a stored-XSS surface: raw HTML in the
894+/// source is emitted as escaped literal text, and `javascript:`/`data:`-style
895+/// link and image destinations are dropped.
896+fn render_markdown(text: &str) -> Markup {
897+ use pulldown_cmark::{Event, Options, Parser, Tag, html};
898+
899+ fn safe_url(dest: &str) -> bool {
900+ let d = dest.trim().to_ascii_lowercase();
901+ !(d.starts_with("javascript:") || d.starts_with("data:") || d.starts_with("vbscript:"))
902+ }
903+
904+ let opts = Options::ENABLE_TABLES
905+ | Options::ENABLE_STRIKETHROUGH
906+ | Options::ENABLE_TASKLISTS
907+ | Options::ENABLE_FOOTNOTES;
908+ let events = Parser::new_ext(text, opts).map(|ev| match ev {
909+ Event::Html(h) => Event::Text(h),
910+ Event::InlineHtml(h) => Event::Text(h),
911+ Event::Start(Tag::Link {
912+ link_type,
913+ dest_url,
914+ title,
915+ id,
916+ }) if !safe_url(&dest_url) => Event::Start(Tag::Link {
917+ link_type,
918+ dest_url: "".into(),
919+ title,
920+ id,
921+ }),
922+ Event::Start(Tag::Image {
923+ link_type,
924+ dest_url,
925+ title,
926+ id,
927+ }) if !safe_url(&dest_url) => Event::Start(Tag::Image {
928+ link_type,
929+ dest_url: "".into(),
930+ title,
931+ id,
932+ }),
933+ e => e,
934+ });
935+ let mut out = String::new();
936+ html::push_html(&mut out, events);
937+ PreEscaped(out)
938+}
939+
852940 /// How far back the per-entry "latest commit" walk looks. Entries last touched
853941 /// beyond this many commits just lose the annotation.
854942 const ENTRY_LOG_WALK: usize = 400;
⋯ 417 unchanged lines
12721360 fn is_binary(bytes: &[u8]) -> bool {
12731361 bytes.iter().take(8192).any(|&b| b == 0)
12741362 }
1363+
1364+#[cfg(test)]
1365+mod tests {
1366+ use super::*;
1367+
1368+ #[test]
1369+ fn markdown_by_extension_only() {
1370+ assert!(is_markdown("README.md"));
1371+ assert!(is_markdown("docs/guide.MarkDown"));
1372+ assert!(!is_markdown("main.rs"));
1373+ assert!(!is_markdown("md")); // no extension
1374+ }
1375+
1376+ // Repo content is untrusted; rendered markdown must not become stored XSS.
1377+ #[test]
1378+ fn rendered_markdown_neutralizes_html_and_script_urls() {
1379+ let out = render_markdown(
1380+ "# title\n\n<script>alert(1)</script>\n\n[x](javascript:alert(1))\n\n![y](data:text/html,evil)\n\n[ok](https://example.com)\n",
1381+ )
1382+ .into_string();
1383+ assert!(out.contains("<h1>title</h1>"), "markdown renders: {out}");
1384+ assert!(!out.contains("<script>"), "raw HTML escaped: {out}");
1385+ assert!(
1386+ out.contains("&lt;script&gt;"),
1387+ "raw HTML kept as text: {out}"
1388+ );
1389+ assert!(!out.contains("javascript:"), "script URL dropped: {out}");
1390+ assert!(!out.contains("data:"), "data URL dropped: {out}");
1391+ assert!(
1392+ out.contains(r#"href="https://example.com""#),
1393+ "normal links survive: {out}"
1394+ );
1395+ }
1396+}