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#[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
95/// Maximum file size that will be rendered
96const MAX_DISPLAY_SIZE: u64 = 1024 * 1024; // 1 MiB
97
98/// Display an upload as HTML
99#[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                    // should be alright to assume that 1,048,576 fits in usize on relevant
144                    // platforms
145                    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/// Download the raw upload file
237#[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 params.download {
286                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}