use std::{path::PathBuf, str::FromStr, time::SystemTime}; use actix_files::NamedFile; use actix_web::{ error, http::header::{ Accept, CacheControl, CacheDirective, Charset, ContentDisposition, DispositionParam, DispositionType, Expires, ExtendedValue, Header, HeaderValue, HttpDate, TryIntoHeaderValue, ACCEPT, CACHE_CONTROL, EXPIRES, VARY, }, web, Error, HttpRequest, HttpResponse, }; use mime::{Mime, TEXT_HTML}; use sqlx::postgres::PgPool; use std::path::Path; use time::OffsetDateTime; use tokio::fs; use url::Url; use crate::deleter; use crate::{config::Config, file_kind::FileKind}; const TEXT_VIEW_HTML: &str = include_str!("../template/text-view.html"); const URL_VIEW_HTML: &str = include_str!("../template/url-view.html"); const TEXT_VIEW_SIZE_LIMIT: u64 = 512 * 1024; // 512KiB enum ViewType { Raw, Download, Html, } pub async fn download( req: HttpRequest, db: web::Data, config: web::Data, ) -> Result { let id = req.match_info().query("id"); let (file_id, file_name, valid_till, file_kind, delete) = load_file_info(id, &db).await?; let mut path = config.files_dir.clone(); path.push(&file_id); let file_mime = get_content_type(&path); let mut response = match get_view_type(&req, &file_kind, &file_mime, &path, delete).await { ViewType::Raw => build_file_response(false, &file_name, path, file_mime, &req).await, ViewType::Download => build_file_response(true, &file_name, path, file_mime, &req).await, ViewType::Html => build_text_response(&path).await, }?; insert_cache_headers(&mut response, valid_till); if delete { deleter::delete_by_id(&db, &file_id, &config.files_dir) .await .map_err(|db_err| { log::error!("could not delete file {:?}", db_err); error::ErrorInternalServerError("could not delete file") })?; } Ok(response) } async fn load_file_info( id: &str, db: &web::Data>, ) -> Result<(String, String, OffsetDateTime, String, bool), Error> { sqlx::query_as( "SELECT file_id, file_name, valid_till, kind, delete_on_download from files WHERE file_id = $1", ) .bind(id) .fetch_optional(db.as_ref()) .await .map_err(|db_err| { log::error!("could not run select statement {:?}", db_err); error::ErrorInternalServerError("could not run select statement") })? .ok_or_else(|| error::ErrorNotFound("file does not exist or has expired")) } fn get_content_type(path: &Path) -> Mime { let std_path = std::path::Path::new(path.as_os_str()); tree_magic_mini::from_filepath(std_path) .unwrap_or("application/octet-stream") .parse::() .expect("tree_magic_mini should not produce invalid mime") } async fn get_view_type( req: &HttpRequest, file_kind: &str, file_mime: &Mime, file_path: &Path, delete_on_download: bool, ) -> ViewType { if delete_on_download || req.query_string().contains("dl") { return ViewType::Download; } if req.query_string().contains("raw") { return ViewType::Raw; } let is_text = FileKind::from_str(file_kind) == Ok(FileKind::Text) || file_mime.type_() == mime::TEXT; if !is_text { return ViewType::Raw; } if get_file_size(file_path).await >= TEXT_VIEW_SIZE_LIMIT { return ViewType::Raw; } if let Ok(accept) = Accept::parse(req) { for accept_mime in accept.ranked() { if accept_mime == TEXT_HTML { return ViewType::Html; } if mime_matches(&accept_mime, file_mime) { break; } } } ViewType::Raw } fn mime_matches(accept: &Mime, content: &Mime) -> bool { let type_matches = accept.type_() == content.type_() || accept.type_() == mime::STAR; let subtype_matches = accept.subtype() == content.subtype() || accept.subtype() == mime::STAR; type_matches && subtype_matches } async fn get_file_size(file_path: &Path) -> u64 { fs::metadata(file_path) .await .map(|metadata| metadata.len()) .unwrap_or(0) } async fn build_text_response(path: &Path) -> Result { let content = fs::read_to_string(path).await.map_err(|file_err| { log::error!("file could not be read {:?}", file_err); error::ErrorInternalServerError("this file should be here but could not be found") })?; let encoded = htmlescape::encode_minimal(&content); let html = if !content.contains(&['\n', '\r'][..]) && Url::from_str(&content).is_ok() { let attribute_encoded = htmlescape::encode_attribute(&content); URL_VIEW_HTML .replace("{link_content}", &encoded) .replace("{link_attribute}", &attribute_encoded) } else { TEXT_VIEW_HTML.replace("{text}", &encoded) }; Ok(HttpResponse::Ok() .content_type(TEXT_HTML.to_string()) .body(html)) } async fn build_file_response( download: bool, file_name: &str, path: PathBuf, content_type: Mime, req: &HttpRequest, ) -> Result { let content_disposition = ContentDisposition { disposition: if download { DispositionType::Attachment } else { DispositionType::Inline }, parameters: get_disposition_params(file_name), }; let file = NamedFile::open(path) .map_err(|file_err| { log::error!("file could not be read {:?}", file_err); error::ErrorInternalServerError("this file should be here but could not be found") })? .set_content_type(content_type) .set_content_disposition(content_disposition); Ok(file.into_response(req)) } fn get_disposition_params(filename: &str) -> Vec { let mut parameters = vec![DispositionParam::Filename(filename.to_owned())]; if !filename.is_ascii() { parameters.push(DispositionParam::FilenameExt(ExtendedValue { charset: Charset::Ext(String::from("UTF-8")), language_tag: None, value: filename.to_owned().into_bytes(), })) } parameters } fn insert_cache_headers(response: &mut HttpResponse, valid_till: OffsetDateTime) { if response.status().is_success() { let valid_duration = valid_till - OffsetDateTime::now_utc(); let valid_cache_seconds = valid_duration.whole_seconds().clamp(0, u32::MAX as i64) as u32; response.headers_mut().insert( CACHE_CONTROL, CacheControl(vec![ CacheDirective::Public, CacheDirective::MustRevalidate, CacheDirective::MaxAge(valid_cache_seconds), // todo: expiry in seconds CacheDirective::NoTransform, CacheDirective::Extension("immutable".to_owned(), None), ]) .try_into_value() .unwrap(), ); response.headers_mut().insert( EXPIRES, Expires(HttpDate::from( SystemTime::now() + std::time::Duration::from_secs(valid_cache_seconds.into()), )) .try_into_value() .unwrap(), ); } response .headers_mut() .insert(VARY, HeaderValue::from_name(ACCEPT)); }