| ⋯ 9 unchanged lines |
| 10 | 10 | | use anvil_git::browse::{self, ChangeKind, FileChange}; |
| 11 | 11 | | use axum::{ |
| 12 | 12 | | Form, Router, |
| 13 | | - | extract::{Path, State}, |
| 13 | + | extract::{Path, Query, State}, |
| 14 | 14 | | http::{StatusCode, header}, |
| 15 | 15 | | response::{IntoResponse, Redirect, Response}, |
| 16 | 16 | | routing::{get, post}, |
| ⋯ 48 unchanged lines |
| 65 | 65 | | .clone.copied .copy-btn { color:#1a7f37; } |
| 66 | 66 | | .crumbs { margin:12px 0; font:13px ui-monospace,monospace; } |
| 67 | 67 | | .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%; } |
| 68 | 79 | | .linkbtn { background:none; border:0; color:var(--accent); cursor:pointer; font:inherit; padding:0; } |
| 69 | 80 | | .linkbtn:hover { text-decoration:underline; } |
| 70 | 81 | | .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 |
| 809 | 820 | | )) |
| 810 | 821 | | } |
| 811 | 822 | | |
| 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). |
| 813 | 825 | | async fn blob( |
| 814 | 826 | | State(app): State<App>, |
| 815 | 827 | | CurrentUser(user): CurrentUser, |
| 816 | 828 | | Path((owner, repo, rev, path)): Path<(String, String, String, String)>, |
| 829 | + | Query(query): Query<HashMap<String, String>>, |
| 817 | 830 | | ) -> Result<Markup, Response> { |
| 818 | 831 | | let (repo_path, _) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?; |
| 819 | 832 | | let (oid, bytes) = browse::read_blob_with_id(&repo_path, &rev, &path) |
| 820 | 833 | | .map_err(server_error)? |
| 821 | 834 | | .ok_or_else(|| not_found("file not found"))?; |
| 822 | 835 | | |
| 836 | + | let markdown = is_markdown(&path) && !is_binary(&bytes); |
| 837 | + | let rendered = markdown && !query.contains_key("plain"); |
| 838 | + | |
| 823 | 839 | | let body = if is_binary(&bytes) { |
| 824 | 840 | | 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)) } } |
| 825 | 844 | | } else { |
| 826 | 845 | | let text = String::from_utf8_lossy(&bytes); |
| 827 | 846 | | let budget = app.config.http.highlight_cache_mb.saturating_mul(1 << 20); |
| ⋯ 10 unchanged lines |
| 838 | 857 | | } |
| 839 | 858 | | }; |
| 840 | 859 | | |
| 860 | + | let blob_url = format!("/{owner}/{repo}/blob/{rev}/{path}"); |
| 841 | 861 | | Ok(layout( |
| 842 | 862 | | &format!("{owner}/{repo}: {path}"), |
| 843 | 863 | | user.as_ref(), |
| 844 | 864 | | html! { |
| 845 | 865 | | h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } } |
| 846 | 866 | | (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 | + | } |
| 847 | 878 | | div.box style="overflow-x:auto" { (body) } |
| 848 | 879 | | }, |
| 849 | 880 | | )) |
| 850 | 881 | | } |
| 851 | 882 | | |
| 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 | + | |
| 852 | 940 | | /// How far back the per-entry "latest commit" walk looks. Entries last touched |
| 853 | 941 | | /// beyond this many commits just lose the annotation. |
| 854 | 942 | | const ENTRY_LOG_WALK: usize = 400; |
| ⋯ 417 unchanged lines |
| 1272 | 1360 | | fn is_binary(bytes: &[u8]) -> bool { |
| 1273 | 1361 | | bytes.iter().take(8192).any(|&b| b == 0) |
| 1274 | 1362 | | } |
| 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\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("<script>"), |
| 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 | + | } |