Krolyxon
Commit 55c1225

ferreniumsrchandlersblob.rs

223 lines · 7.4 KB · Rust

1//! `/{repo}/blob/{rev}/{path…}` — a file: highlighted code with line
2//! anchors, rendered Markdown, inline images.
3
4use std::sync::Arc;
5
6use askama::Template;
7use axum::http::StatusCode;
8use axum::response::Response;
9use gix::bstr::ByteSlice;
10
11use super::repo::{Active, Crumb, Ctx, RepoNav, crumbs, redirect, set_cache_headers};
12use super::{Layout, render};
13use crate::cache::Key;
14use crate::git::tree::{self, EntryKind, PathTarget};
15use crate::render::markdown::{self, Links};
16use crate::render::{encode_path, highlight, human_size};
17
18/// Extensions shown inline as images (served by the raw endpoint).
19const IMAGES: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "avif", "bmp", "ico"];
20
21pub enum Body {
22 Code { lines: Arc<Vec<String>> },
23 Markdown { html: Arc<str> },
24 Image { src: String },
25 Symlink { target: String, url: Option<String> },
26 Binary,
27 TooLarge,
28 Empty,
29}
30
31pub struct ViewToggle {
32 pub preview_url: String,
33 pub source_url: String,
34 pub source: bool,
35}
36
37#[derive(Template)]
38#[template(path = "blob.html")]
39pub struct BlobPage {
40 pub layout: Layout,
41 pub nav: RepoNav,
42 pub crumbs: Vec<Crumb>,
43 pub here: Option<String>,
44 /// `412 lines`, `12.4 KB`, `Rust`.
45 pub facts: Vec<String>,
46 pub raw_url: String,
47 pub download_url: String,
48 pub toggle: Option<ViewToggle>,
49 pub body: Body,
50}
51
52pub fn page(ctx: &Ctx, segments: &[Vec<u8>]) -> Response {
53 let (rev, path) = match ctx.resolve(segments) {
54 Ok(found) => found,
55 Err(res) => return *res,
56 };
57 let (oid, kind) = match tree::lookup(&ctx.git, rev.commit, &path) {
58 Ok(Some(PathTarget::Blob(oid, kind))) => (oid, kind),
59 Ok(Some(PathTarget::Tree(_))) => return redirect(&ctx.url("tree", &rev.name, &path)),
60 Ok(Some(PathTarget::Submodule(_))) => return ctx.not_found("PATH IS A SUBMODULE"),
61 Ok(None) => return ctx.not_found(&format!("PATH NOT FOUND IN {}", rev.name)),
62 Err(e) => return ctx.internal_error(e),
63 };
64 let source = ctx.query.view.as_deref() == Some("source");
65 let etag = ctx.etag(("blob", oid, &rev.name, source));
66 let immutable = rev.is_immutable();
67 if let Some(res) = ctx.not_modified(&etag, immutable) {
68 return res;
69 }
70 let size = match tree::size(&ctx.git, oid) {
71 Ok(size) => size,
72 Err(e) => return ctx.internal_error(e),
73 };
74
75 let path_str = path.to_str_lossy().into_owned();
76 let name = path_str.rsplit('/').next().unwrap_or(&path_str).to_string();
77 let dir = path.rsplit_once_str("/").map_or(&b""[..], |(d, _)| d);
78 let raw_url = ctx.url("raw", &rev.name, &path);
79 let mut lines_fact = None;
80 let mut language = None;
81 let mut toggle = None;
82 let ext = name
83 .rsplit_once('.')
84 .map(|(_, e)| e.to_ascii_lowercase())
85 .unwrap_or_default();
86
87 let body = if size == 0 {
88 Body::Empty
89 } else if IMAGES.contains(&ext.as_str()) && kind != EntryKind::Symlink {
90 Body::Image {
91 src: raw_url.clone(),
92 }
93 } else if size > ctx.config.ui.max_blob_kb * 1024 {
94 Body::TooLarge
95 } else {
96 let data = match tree::blob(&ctx.git, oid) {
97 Ok(data) => data,
98 Err(e) => return ctx.internal_error(e),
99 };
100 if kind == EntryKind::Symlink {
101 let target = data.to_str_lossy().into_owned();
102 let url = resolve_link(dir, &data).map(|p| ctx.url("blob", &rev.name, &p));
103 Body::Symlink { target, url }
104 } else if data[..data.len().min(8000)].contains(&0) {
105 Body::Binary
106 } else {
107 let text = String::from_utf8_lossy(&data);
108 lines_fact = Some(line_count(&text));
109 let is_md = markdown::is_markdown(&name);
110 if is_md {
111 let base = ctx.url("blob", &rev.name, &path);
112 toggle = Some(ViewToggle {
113 source_url: format!("{base}?view=source"),
114 preview_url: base,
115 source,
116 });
117 }
118 if is_md && !source {
119 let links = Links {
120 repo_url: ctx.repo_url(),
121 rev: encode_path(&rev.name),
122 dir: dir.to_vec(),
123 };
124 let key = Key::Markdown {
125 blob: oid,
126 context: links.key(),
127 };
128 let html = ctx.state.cache.html(key, || {
129 Ok::<_, std::convert::Infallible>(markdown::render(&text, &links))
130 });
131 Body::Markdown {
132 html: html.unwrap_or_else(|never| match never {}),
133 }
134 } else {
135 let first_line = text.lines().next().unwrap_or_default();
136 let syntax = highlight::detect(&path_str, first_line);
137 language = syntax.map(|s| s.name.clone());
138 let lines = match syntax {
139 Some(syntax) if size <= ctx.config.ui.max_highlight_kb * 1024 => {
140 let key = Key::Highlight {
141 blob: oid,
142 syntax: syntax.name.clone(),
143 };
144 ctx.state
145 .cache
146 .lines(key, || {
147 Ok::<_, std::convert::Infallible>(highlight::lines(&text, syntax))
148 })
149 .unwrap_or_else(|never| match never {})
150 }
151 _ => Arc::new(highlight::plain_lines(&text)),
152 };
153 Body::Code { lines }
154 }
155 }
156 };
157 let facts = [lines_fact, Some(human_size(size)), language]
158 .into_iter()
159 .flatten()
160 .collect();
161
162 let (crumbs, _) = crumbs(ctx, &rev.name, &path);
163 let page = BlobPage {
164 layout: Layout::new(
165 ctx.state,
166 ctx.config,
167 Some(&format!("{path_str} · {}", ctx.repo.name)),
168 ),
169 nav: ctx.nav(Active::Tree, Some(&rev), |name| {
170 ctx.url("blob", name, &path)
171 }),
172 crumbs,
173 here: Some(name),
174 facts,
175 download_url: format!("{raw_url}?download"),
176 raw_url,
177 toggle,
178 body,
179 };
180 let mut res = render(StatusCode::OK, &page);
181 set_cache_headers(&mut res, &etag, immutable);
182 res
183}
184
185fn line_count(text: &str) -> String {
186 let n = text.lines().count();
187 format!("{n} {}", if n == 1 { "line" } else { "lines" })
188}
189
190/// Resolve a relative symlink target against the link's directory. `None`
191/// for absolute targets or ones leaving the repository.
192fn resolve_link(dir: &[u8], target: &[u8]) -> Option<Vec<u8>> {
193 if target.starts_with(b"/") {
194 return None;
195 }
196 let mut parts: Vec<&[u8]> = dir
197 .split(|b| *b == b'/')
198 .filter(|p| !p.is_empty())
199 .collect();
200 for part in target.split(|b| *b == b'/') {
201 match part {
202 b"" | b"." => {}
203 b".." => {
204 parts.pop()?;
205 }
206 part => parts.push(part),
207 }
208 }
209 Some(parts.join(&b'/'))
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215
216 #[test]
217 fn symlink_targets() {
218 assert_eq!(resolve_link(b"a/b", b"../c.txt").unwrap(), b"a/c.txt");
219 assert_eq!(resolve_link(b"", b"./x").unwrap(), b"x");
220 assert!(resolve_link(b"", b"../x").is_none());
221 assert!(resolve_link(b"a", b"/etc/passwd").is_none());
222 }
223}