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