Skip to main content

bobashare_web/views/
display.rs

1//! Routes to display or download an upload in a browser
2
3use 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/// Errors when trying to view/download an upload
27#[derive(Debug, Error, Display)]
28pub enum ViewUploadError {
29    /// an upload at the specified id was not found
30    NotFound,
31
32    /// internal server error
33    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        // don't upload.flush() since it's not open for writing -- it will fail
53        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// `blocks = ["title"]` not required here as diplay.html.jinja overrides the preview_metadata to
66// customize the title separately from the embed meta tags
67#[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
97/// Maximum file size that will be rendered
98const MAX_DISPLAY_SIZE: u64 = 1024 * 1024; // 1 MiB
99
100/// Display an upload as HTML
101#[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                    // should be alright to assume that 1,048,576 fits in usize on relevant
146                    // platforms
147                    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/// Download the raw upload file
239#[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 params.download {
288                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}