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