Krolyxon
Commit ce0f374

ferreniumsrchandlersblob.rs

217 lines · 6.9 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 log_url: String,
48 pub blame_url: String,
49 pub download_url: String,
50 pub toggle: Option<ViewToggle>,
51 pub body: Body,
52}
53
54pub fn page(ctx: &Ctx, segments: &[Vec<u8>]) -> Response {
55 let (rev, path) = match ctx.resolve(segments) {
56 Ok(found) => found,
57 Err(res) => return *res,
58 };
59 let (oid, kind) = match tree::lookup(&ctx.git, rev.commit, &path) {
60 Ok(Some(PathTarget::Blob(oid, kind))) => (oid, kind),
61 Ok(Some(PathTarget::Tree(_))) => return redirect(&ctx.url("tree", &rev.name, &path)),
62 Ok(Some(PathTarget::Submodule(_))) => return ctx.not_found("PATH IS A SUBMODULE"),
63 Ok(None) => return ctx.not_found(&format!("PATH NOT FOUND IN {}", rev.name)),
64 Err(e) => return ctx.internal_error(e),
65 };
66 let source = ctx.query.view.as_deref() == Some("source");
67 let etag = ctx.etag(("blob", oid, &rev.name, source));
68 let immutable = rev.is_immutable();
69 if let Some(res) = ctx.not_modified(&etag, immutable) {
70 return res;
71 }
72 let size = match tree::size(&ctx.git, oid) {
73 Ok(size) => size,
74 Err(e) => return ctx.internal_error(e),
75 };
76
77 let path_str = path.to_str_lossy().into_owned();
78 let name = path_str.rsplit('/').next().unwrap_or(&path_str).to_string();
79 let dir = path.rsplit_once_str("/").map_or(&b""[..], |(d, _)| d);
80 let raw_url = ctx.url("raw", &rev.name, &path);
81 let mut lines_fact = None;
82 let mut language = None;
83 let mut toggle = None;
84 let ext = name
85 .rsplit_once('.')
86 .map(|(_, e)| e.to_ascii_lowercase())
87 .unwrap_or_default();
88
89 let body = if size == 0 {
90 Body::Empty
91 } else if IMAGES.contains(&ext.as_str()) && kind != EntryKind::Symlink {
92 Body::Image {
93 src: raw_url.clone(),
94 }
95 } else if size > ctx.config.ui.max_blob_kb * 1024 {
96 Body::TooLarge
97 } else {
98 let data = match tree::blob(&ctx.git, oid) {
99 Ok(data) => data,
100 Err(e) => return ctx.internal_error(e),
101 };
102 if kind == EntryKind::Symlink {
103 let target = data.to_str_lossy().into_owned();
104 let url = resolve_link(dir, &data).map(|p| ctx.url("blob", &rev.name, &p));
105 Body::Symlink { target, url }
106 } else if data[..data.len().min(8000)].contains(&0) {
107 Body::Binary
108 } else {
109 let text = String::from_utf8_lossy(&data);
110 lines_fact = Some(line_count(&text));
111 let is_md = markdown::is_markdown(&name);
112 if is_md {
113 let base = ctx.url("blob", &rev.name, &path);
114 toggle = Some(ViewToggle {
115 source_url: format!("{base}?view=source"),
116 preview_url: base,
117 source,
118 });
119 }
120 if is_md && !source {
121 let links = Links {
122 repo_url: ctx.repo_url(),
123 rev: encode_path(&rev.name),
124 dir: dir.to_vec(),
125 };
126 let key = Key::Markdown {
127 blob: oid,
128 context: links.key(),
129 };
130 let html = ctx.state.cache.html(key, || {
131 Ok::<_, std::convert::Infallible>(markdown::render(&text, &links))
132 });
133 Body::Markdown {
134 html: html.unwrap_or_else(|never| match never {}),
135 }
136 } else {
137 let (lines, lang) = highlight::cached_lines(
138 &ctx.state.cache,
139 oid,
140 &path_str,
141 &text,
142 ctx.config.ui.max_highlight_kb * 1024,
143 );
144 language = lang.map(str::to_string);
145 Body::Code { lines }
146 }
147 }
148 };
149 let facts = [lines_fact, Some(human_size(size)), language]
150 .into_iter()
151 .flatten()
152 .collect();
153
154 let (crumbs, _) = crumbs(ctx, &rev.name, &path);
155 let page = BlobPage {
156 layout: Layout::new(
157 ctx.state,
158 ctx.config,
159 Some(&format!("{path_str} · {}", ctx.repo.name)),
160 ),
161 nav: ctx.nav(Active::Tree, Some(&rev), |name| {
162 ctx.url("blob", name, &path)
163 }),
164 crumbs,
165 here: Some(name),
166 facts,
167 download_url: format!("{raw_url}?download"),
168 log_url: ctx.url("log", &rev.name, &path),
169 blame_url: ctx.url("blame", &rev.name, &path),
170 raw_url,
171 toggle,
172 body,
173 };
174 let mut res = render(StatusCode::OK, &page);
175 set_cache_headers(&mut res, &etag, immutable);
176 res
177}
178
179fn line_count(text: &str) -> String {
180 let n = text.lines().count();
181 format!("{n} {}", if n == 1 { "line" } else { "lines" })
182}
183
184/// Resolve a relative symlink target against the link's directory. `None`
185/// for absolute targets or ones leaving the repository.
186fn resolve_link(dir: &[u8], target: &[u8]) -> Option<Vec<u8>> {
187 if target.starts_with(b"/") {
188 return None;
189 }
190 let mut parts: Vec<&[u8]> = dir
191 .split(|b| *b == b'/')
192 .filter(|p| !p.is_empty())
193 .collect();
194 for part in target.split(|b| *b == b'/') {
195 match part {
196 b"" | b"." => {}
197 b".." => {
198 parts.pop()?;
199 }
200 part => parts.push(part),
201 }
202 }
203 Some(parts.join(&b'/'))
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 #[test]
211 fn symlink_targets() {
212 assert_eq!(resolve_link(b"a/b", b"../c.txt").unwrap(), b"a/c.txt");
213 assert_eq!(resolve_link(b"", b"./x").unwrap(), b"x");
214 assert!(resolve_link(b"", b"../x").is_none());
215 assert!(resolve_link(b"a", b"/etc/passwd").is_none());
216 }
217}