1use anyhow::Context;
4use askama::Template;
5use axum::{
6 extract::{Path, Query, Request, State},
7 response::IntoResponse,
8};
9use bobashare::storage::{file::OpenUploadError, handle::UploadHandle};
10use chrono::{DateTime, TimeDelta, Utc};
11use displaydoc::Display;
12use hyper::{header, StatusCode};
13use mime::Mime;
14use serde::{Deserialize, Deserializer};
15use syntect::{html::ClassedHTMLGenerator, util::LinesWithEndings};
16use thiserror::Error;
17use tokio::io::AsyncReadExt;
18use tower_http::services::ServeFile;
19use tracing::{event, instrument, Level};
20use url::Url;
21
22use super::{filters, prelude::*, render_template, ErrorResponse, ErrorTemplate, TemplateState};
23use crate::{render_markdown_with_syntax_set, AppState, CLASS_STYLE};
24
25#[derive(Debug, Error, Display)]
27pub enum ViewUploadError {
28 NotFound,
30
31 InternalServer(#[from] anyhow::Error),
33}
34impl From<OpenUploadError> for ViewUploadError {
35 fn from(err: OpenUploadError) -> Self {
36 match err {
37 OpenUploadError::NotFound(_) => Self::NotFound,
38 _ => Self::InternalServer(anyhow::Error::new(err).context("error opening upload")),
39 }
40 }
41}
42
43async fn open_upload<S: AsRef<str>>(
44 state: &AppState,
45 id: S,
46) -> Result<UploadHandle, ViewUploadError> {
47 let upload = state.backend.open_upload(id.as_ref(), false).await?;
48
49 if upload.metadata.is_expired() {
50 event!(Level::INFO, "upload is expired; it will be deleted");
51 state
53 .backend
54 .delete_upload(id.as_ref())
55 .await
56 .context("error deleting expired upload")?;
57 return Err(ViewUploadError::NotFound);
58 }
59
60 Ok(upload)
61}
62
63#[derive(Template)]
64#[template(path = "display.html.jinja")]
67pub struct DisplayTemplate<'s> {
68 pub state: TemplateState<'s>,
69 pub id: String,
70 pub filename: String,
71 pub expiry_date: Option<DateTime<Utc>>,
72 pub expiry_relative: Option<TimeDelta>,
73 pub size: u64,
74 pub mimetype: Mime,
75 pub contents: DisplayType,
76 pub raw_url: Url,
77 pub download_url: Url,
78}
79#[derive(Debug)]
80pub enum DisplayType {
81 Text {
82 highlighted: String,
83 },
84 FailedParseText {
85 text: String,
86 },
87 Markdown {
88 highlighted: String,
89 displayed: String,
90 },
91 Image,
92 Video,
93 Audio,
94 Pdf,
95 Other,
96 TooLarge,
97}
98
99const MAX_DISPLAY_SIZE: u64 = 1024 * 1024; #[instrument(skip(state))]
104pub async fn display(
105 State(state): State<&'static AppState>,
106 Path(id): Path<String>,
107) -> Result<impl IntoResponse, ErrorResponse> {
108 let tmpl_state = TemplateState::from(state);
109 let mut upload = open_upload(state, id).await.map_err(|e| match e {
110 ViewUploadError::NotFound => ErrorTemplate {
111 state: tmpl_state.clone(),
112 code: StatusCode::NOT_FOUND,
113 message: e.to_string(),
114 },
115 ViewUploadError::InternalServer(_) => ErrorTemplate {
116 state: tmpl_state.clone(),
117 code: StatusCode::INTERNAL_SERVER_ERROR,
118 message: e.to_string(),
119 },
120 })?;
121 let size = upload
122 .file
123 .metadata()
124 .await
125 .map_err(|e| ErrorTemplate {
126 state: tmpl_state.clone(),
127 code: StatusCode::INTERNAL_SERVER_ERROR,
128 message: format!("error reading file size: {e}"),
129 })?
130 .len();
131
132 let contents = {
133 let mimetype = upload.metadata.mimetype.clone();
134 match (mimetype.type_(), mimetype.subtype()) {
135 (mime::IMAGE, _) => DisplayType::Image,
136 (mime::VIDEO, _) => DisplayType::Video,
137 (mime::AUDIO, _) => DisplayType::Audio,
138 (mime::APPLICATION, mime::PDF) => DisplayType::Pdf,
139 (mime::TEXT, _) | (mime::APPLICATION, mime::JSON) => 'arm: {
140 if size > MAX_DISPLAY_SIZE {
141 break 'arm DisplayType::TooLarge;
142 }
143
144 let mut bytes: Vec<u8> = Vec::with_capacity(size as usize);
147 upload
148 .file
149 .read_to_end(&mut bytes)
150 .await
151 .map_err(|e| ErrorTemplate {
152 state: tmpl_state.clone(),
153 code: StatusCode::INTERNAL_SERVER_ERROR,
154 message: format!("error reading file contents: {e}"),
155 })?;
156
157 let extension = std::path::Path::new(&upload.metadata.filename)
158 .extension()
159 .and_then(|s| s.to_str())
160 .unwrap_or("");
161 let syntax = state
162 .syntax_set
163 .find_syntax_by_extension(extension)
164 .unwrap_or_else(|| state.syntax_set.find_syntax_plain_text());
165
166 let Ok(contents) = String::from_utf8(bytes.clone()) else {
167 break 'arm DisplayType::FailedParseText {
168 text: String::from_utf8_lossy(&bytes).to_string(),
169 };
170 };
171
172 event!(
173 Level::DEBUG,
174 "highlighting file with syntax {}",
175 syntax.name
176 );
177 let highlighted = {
178 let mut generator = ClassedHTMLGenerator::new_with_class_style(
179 syntax,
180 &state.syntax_set,
181 CLASS_STYLE,
182 );
183 for line in LinesWithEndings::from(&contents) {
184 generator
185 .parse_html_for_line_which_includes_newline(line)
186 .map_err(|e| ErrorTemplate {
187 state: tmpl_state.clone(),
188 code: StatusCode::INTERNAL_SERVER_ERROR,
189 message: format!("error highlighting file contents: {e}"),
190 })?;
191 }
192 generator.finalize()
193 };
194
195 if extension.eq_ignore_ascii_case("md") {
196 let displayed = render_markdown_with_syntax_set(&contents, &state.syntax_set)
197 .map_err(|e| ErrorTemplate {
198 state: tmpl_state.clone(),
199 code: StatusCode::INTERNAL_SERVER_ERROR,
200 message: format!("error highlighting markdown fenced code block: {e}",),
201 })?;
202
203 DisplayType::Markdown {
204 highlighted,
205 displayed,
206 }
207 } else {
208 DisplayType::Text { highlighted }
209 }
210 }
211 (_, _) => DisplayType::Other,
212 }
213 };
214
215 event!(Level::DEBUG, "rendering upload template");
216 let raw_url = state.raw_url.join(&upload.metadata.id).unwrap();
217 let mut download_url = raw_url.clone();
218 download_url.set_query(Some("download"));
219 render_template(DisplayTemplate {
220 raw_url,
221 download_url,
222 id: upload.metadata.id,
223 filename: upload.metadata.filename,
224 expiry_date: upload.metadata.expiry_date,
225 expiry_relative: upload.metadata.expiry_date.map(|e| e - Utc::now()),
226 size,
227 mimetype: upload.metadata.mimetype,
228 contents,
229 state: tmpl_state,
230 })
231}
232
233fn string_is_true<'de, D>(_: D) -> Result<bool, D::Error>
234where
235 D: Deserializer<'de>,
236{
237 Ok(true)
238}
239#[derive(Debug, Deserialize)]
240pub struct RawParams {
241 #[serde(default, deserialize_with = "string_is_true")]
242 download: bool,
243}
244#[instrument(skip(state, request))]
246pub async fn raw(
247 State(state): State<&'static AppState>,
248 Path(id): Path<String>,
249 Query(RawParams { download }): Query<RawParams>,
250 request: Request,
251) -> Result<impl IntoResponse, ErrorResponse> {
252 let tmpl_state = TemplateState::from(state);
253 let upload = open_upload(state, id).await.map_err(|e| match e {
254 ViewUploadError::NotFound => ErrorTemplate {
255 state: tmpl_state.clone(),
256 code: StatusCode::NOT_FOUND,
257 message: e.to_string(),
258 },
259 ViewUploadError::InternalServer(_) => ErrorTemplate {
260 state: tmpl_state.clone(),
261 code: StatusCode::INTERNAL_SERVER_ERROR,
262 message: e.to_string(),
263 },
264 })?;
265
266 let response = ServeFile::new_with_mime(&upload.file_path, &upload.metadata.mimetype)
267 .try_call(request)
268 .await
269 .map_err(|e| ErrorTemplate {
270 state: tmpl_state.clone(),
271 code: if e.kind() == std::io::ErrorKind::NotFound {
272 StatusCode::NOT_FOUND
273 } else {
274 StatusCode::INTERNAL_SERVER_ERROR
275 },
276 message: format!("error serving upload: {e}"),
277 })?;
278
279 event!(
280 Level::INFO,
281 "type" = %upload.metadata.mimetype,
282 status = %response.status(),
283 filename = upload.metadata.filename,
284 "serving upload file to client"
285 );
286 if !matches!(
287 response.status(),
288 StatusCode::OK | StatusCode::PARTIAL_CONTENT
289 ) {
290 return Ok(response.into_response());
291 }
292
293 Ok((
294 [(
295 header::CONTENT_DISPOSITION,
296 if download {
297 format!("attachment; filename=\"{}\"", upload.metadata.filename)
298 } else {
299 format!("inline; filename=\"{}\"", upload.metadata.filename)
300 },
301 )],
302 response,
303 )
304 .into_response())
305}