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")]
68pub struct DisplayTemplate<'s> {
69 pub state: TemplateState<'s>,
70 pub id: String,
71 pub filename: String,
72 pub expiry_date: Option<DateTime<Utc>>,
73 pub expiry_relative: Option<TimeDelta>,
74 pub size: u64,
75 pub mimetype: Mime,
76 pub contents: DisplayType,
77 pub raw_url: Url,
78 pub download_url: Url,
79}
80#[derive(Debug)]
81pub enum DisplayType {
82 Text {
83 highlighted: String,
84 },
85 Markdown {
86 highlighted: String,
87 displayed: String,
88 },
89 Image,
90 Video,
91 Audio,
92 Pdf,
93 Other,
94 TooLarge,
95}
96
97const MAX_DISPLAY_SIZE: u64 = 1024 * 1024; #[instrument(skip(state))]
102pub async fn display(
103 State(state): State<&'static AppState>,
104 Path(id): Path<String>,
105) -> Result<impl IntoResponse, ErrorResponse> {
106 let tmpl_state = TemplateState::from(state);
107 let mut upload = open_upload(state, id).await.map_err(|e| match e {
108 ViewUploadError::NotFound => ErrorTemplate {
109 state: tmpl_state.clone(),
110 code: StatusCode::NOT_FOUND,
111 message: e.to_string(),
112 },
113 ViewUploadError::InternalServer(_) => ErrorTemplate {
114 state: tmpl_state.clone(),
115 code: StatusCode::INTERNAL_SERVER_ERROR,
116 message: e.to_string(),
117 },
118 })?;
119 let size = upload
120 .file
121 .metadata()
122 .await
123 .map_err(|e| ErrorTemplate {
124 state: tmpl_state.clone(),
125 code: StatusCode::INTERNAL_SERVER_ERROR,
126 message: format!("error reading file size: {e}"),
127 })?
128 .len();
129
130 let contents = {
131 let mimetype = upload.metadata.mimetype.clone();
132 match (mimetype.type_(), mimetype.subtype()) {
133 (mime::TEXT, _) | (mime::APPLICATION, mime::JSON) => {
134 if size > MAX_DISPLAY_SIZE {
135 DisplayType::TooLarge
136 } else {
137 let extension = std::path::Path::new(&upload.metadata.filename)
138 .extension()
139 .and_then(|s| s.to_str())
140 .unwrap_or("");
141 let syntax = state
142 .syntax_set
143 .find_syntax_by_extension(extension)
144 .unwrap_or_else(|| state.syntax_set.find_syntax_plain_text());
145 let mut contents = String::with_capacity(size as usize);
148 upload
149 .file
150 .read_to_string(&mut contents)
151 .await
152 .map_err(|e| ErrorTemplate {
153 state: tmpl_state.clone(),
154 code: StatusCode::INTERNAL_SERVER_ERROR,
155 message: format!("error reading file contents: {e}"),
156 })?;
157
158 event!(
159 Level::DEBUG,
160 "highlighting file with syntax {}",
161 syntax.name
162 );
163 let highlighted = {
164 let mut generator = ClassedHTMLGenerator::new_with_class_style(
165 syntax,
166 &state.syntax_set,
167 CLASS_STYLE,
168 );
169 for line in LinesWithEndings::from(&contents) {
170 generator
171 .parse_html_for_line_which_includes_newline(line)
172 .map_err(|e| ErrorTemplate {
173 state: tmpl_state.clone(),
174 code: StatusCode::INTERNAL_SERVER_ERROR,
175 message: format!("error highlighting file contents: {e}"),
176 })?;
177 }
178 generator.finalize()
179 };
180
181 if extension.eq_ignore_ascii_case("md") {
182 let displayed = render_markdown_with_syntax_set(
183 &contents,
184 &state.syntax_set,
185 )
186 .map_err(|e| ErrorTemplate {
187 state: tmpl_state.clone(),
188 code: StatusCode::INTERNAL_SERVER_ERROR,
189 message: format!("error highlighting markdown fenced code block: {e}",),
190 })?;
191
192 DisplayType::Markdown {
193 highlighted,
194 displayed,
195 }
196 } else {
197 DisplayType::Text { highlighted }
198 }
199 }
200 }
201 (mime::IMAGE, _) => DisplayType::Image,
202 (mime::VIDEO, _) => DisplayType::Video,
203 (mime::AUDIO, _) => DisplayType::Audio,
204 (mime::APPLICATION, mime::PDF) => DisplayType::Pdf,
205 (_, _) => DisplayType::Other,
206 }
207 };
208
209 event!(Level::DEBUG, "rendering upload template");
210 let raw_url = state.raw_url.join(&upload.metadata.id).unwrap();
211 let mut download_url = raw_url.clone();
212 download_url.set_query(Some("download"));
213 render_template(DisplayTemplate {
214 raw_url,
215 download_url,
216 id: upload.metadata.id,
217 filename: upload.metadata.filename,
218 expiry_date: upload.metadata.expiry_date,
219 expiry_relative: upload.metadata.expiry_date.map(|e| e - Utc::now()),
220 size,
221 mimetype: upload.metadata.mimetype,
222 contents,
223 state: tmpl_state,
224 })
225}
226
227fn string_is_true<'de, D>(_: D) -> Result<bool, D::Error>
228where
229 D: Deserializer<'de>,
230{
231 Ok(true)
232}
233#[derive(Debug, Deserialize)]
234pub struct RawParams {
235 #[serde(default, deserialize_with = "string_is_true")]
236 download: bool,
237}
238#[instrument(skip(state))]
240pub async fn raw(
241 State(state): State<&'static AppState>,
242 Path(id): Path<String>,
243 Query(RawParams { download }): Query<RawParams>,
244) -> Result<impl IntoResponse, ErrorResponse> {
245 let tmpl_state = TemplateState::from(state);
246 let upload = open_upload(state, id).await.map_err(|e| match e {
247 ViewUploadError::NotFound => ErrorTemplate {
248 state: tmpl_state.clone(),
249 code: StatusCode::NOT_FOUND,
250 message: e.to_string(),
251 },
252 ViewUploadError::InternalServer(_) => ErrorTemplate {
253 state: tmpl_state.clone(),
254 code: StatusCode::INTERNAL_SERVER_ERROR,
255 message: e.to_string(),
256 },
257 })?;
258
259 let size = upload
260 .file
261 .metadata()
262 .await
263 .map_err(|e| ErrorTemplate {
264 state: tmpl_state.clone(),
265 code: StatusCode::INTERNAL_SERVER_ERROR,
266 message: format!("error reading file size: {e}"),
267 })?
268 .len();
269 event!(Level::DEBUG, size, "found size of upload file",);
270
271 let body = Body::from_stream(ReaderStream::new(upload.file));
272
273 event!(
274 Level::INFO,
275 "type" = %upload.metadata.mimetype,
276 length = size,
277 filename = upload.metadata.filename,
278 "successfully streaming upload file to client"
279 );
280 Ok((
281 StatusCode::OK,
282 [
283 (header::CONTENT_TYPE, upload.metadata.mimetype.to_string()),
284 (header::CONTENT_LENGTH, size.to_string()),
285 (
286 header::CONTENT_DISPOSITION,
287 if download {
289 format!("attachment; filename=\"{}\"", upload.metadata.filename)
290 } else {
291 format!("inline; filename=\"{}\"", upload.metadata.filename)
292 },
293 ),
294 ],
295 body,
296 ))
297}