// SPDX-FileCopyrightText: © Matteo Settenvini // SPDX-License-Identifier: EUPL-1.2 mod settings; mod sizes; use { anyhow::Result, bytes::Bytes, futures::{StreamExt, stream::BoxStream}, lazy_static::lazy_static, object_store::path::Path as ObjectStorePath, rocket::{ Request, State, fairing::AdHoc, figment::{ Profile, providers::{Env, Format as _, Toml}, }, http::{ContentType, uri::Origin}, response::{self, Redirect, Responder, stream::ByteStream}, serde::Serialize, }, rocket_dyn_templates::{Template, context}, settings::Settings, std::path::PathBuf, }; enum FileView { Folder(Template), Redirect(Redirect), File(ByteStream>), } impl<'r> Responder<'r, 'r> for FileView { fn respond_to(self, req: &'r Request<'_>) -> response::Result<'r> { match self { Self::Folder(template) => template.respond_to(req).map(|mut r| { r.set_header(ContentType::HTML); r }), Self::Redirect(redirect) => redirect.respond_to(req), Self::File(stream) => stream.respond_to(req), } } } #[derive(Serialize)] struct FileViewItem { path: String, size: String, size_bytes: u64, last_modification: String, } #[derive(Responder, Debug)] enum Error { #[response(status = 404)] NotFound(String), #[response(status = 400)] InvalidRequest(String), #[response(status = 500)] UnknownError(String), } impl From for Error { fn from(value: object_store::Error) -> Self { match value { object_store::Error::NotFound { path, source: _ } => { Self::NotFound(format!("object not found at {}", path)) } err => Error::UnknownError(err.to_string()), } } } #[rocket::get("/")] async fn index_root(uri: &Origin<'_>, state: &State) -> Result { index(None, uri, state).await } #[rocket::get("/")] async fn index( path: Option, uri: &Origin<'_>, state: &State, ) -> Result { let object_path = if let Some(url_path) = path.as_ref() { let s = url_path.to_str().ok_or(Error::InvalidRequest( "Path cannot be converted to UTF-8".into(), ))?; Some(ObjectStorePath::from(s)) } else { None }; // We try first to retrieve list an object as a file. if let Some(object_path) = &object_path && object_exists(object_path, &state).await? { log::info!("serving S3 object at {}", &object_path); return serve_object(&object_path, &state).await; } // If we fail, we fallback to retrieving the equivalent folder. // For hyperlinks in the generated HTML to work properly, let's // normalize the path to end with a slash. if !uri.path().ends_with("/") { // If the path does not end with a slash, we redirect to // the normalized path with a slash appended. let redirect = uri .map_path(|p| format!("{}/", p)) .expect("cannot append slash to origin URL, this should never happen!"); return Ok(FileView::Redirect(Redirect::permanent( redirect.to_string(), ))); } // We can now assume we have a full path to a folder, // ending with a slash. let path = path.unwrap_or_default(); log::info!("listing S3 objects at {}", path.display()); let objects = file_view(object_path, &state).await?; let rendered = Template::render( "index", context! { path: format!("{}/", path.display()), objects }, ); Ok(FileView::Folder(rendered)) } async fn object_exists(s3_path: &ObjectStorePath, settings: &Settings) -> Result { log::debug!("checking existence of S3 object at {}", s3_path); match settings.s3_bucket.head(s3_path).await { Ok(_metadata) => Ok(true), Err(object_store::Error::NotFound { path: _, source: _ }) => Ok(false), Err(e) => Err(Error::UnknownError(e.to_string())), } } async fn serve_object(s3_path: &ObjectStorePath, settings: &Settings) -> Result { let object_stream = settings .s3_bucket .get(&s3_path) .await .map_err(Error::from)? .into_stream(); let s3_path = s3_path.clone(); let stream = object_stream .map(move |chunk| match chunk { Ok(bytes) => bytes, Err(err) => { log::error!("connection error while reading {}: {}", s3_path, err); Bytes::new() // Forces end of stream } }) .boxed(); // TODO: unfortunately Rocket does not have a ByteStream with a Result per chunk, // meaning that if there is a failure in the middle of the stream, the best we can do is... // nothing? Panic? All options are bad. Ok(FileView::File(ByteStream::from(stream))) } async fn file_view( s3_folder_path: Option, settings: &Settings, ) -> Result, Error> { /* if listing a folder: - folders will be under 'common_prefixes' - files will be under the 'contents' property */ let s3_objects = settings .s3_bucket .list_with_delimiter(s3_folder_path.as_ref()) .await .map_err(Error::from)?; let folders = s3_objects.common_prefixes.into_iter().map(|dir| { let dirname = dir.parts().last().unwrap(); FileViewItem { path: format!("{}/", dirname.as_ref().to_string()), size_bytes: 0, size: "[DIR]".to_owned(), last_modification: String::default(), } }); let files = s3_objects.objects.into_iter().map(|obj| FileViewItem { path: obj.location.filename().unwrap().into(), size_bytes: obj.size, size: sizes::bytes_to_human(obj.size), last_modification: obj.last_modified.to_rfc3339(), }); Ok(folders.chain(files).collect()) } lazy_static! { // Workaround for https://github.com/SergioBenitez/Rocket/issues/1792 static ref EMPTY_DIR: tempfile::TempDir = tempfile::tempdir() .expect("unable to create an empty temporary folder, is the whole FS read-only?"); } #[rocket::launch] fn rocket() -> _ { let config_figment = rocket::Config::figment() .merge(Toml::file("serves3.toml").nested()) .merge(Env::prefixed("SERVES3_").global()) .merge(("template_dir", EMPTY_DIR.path())) // We compile the templates in anyway .select(Profile::from_env_or("SERVES3_PROFILE", "default")); rocket::custom(config_figment) .mount("/", rocket::routes![index_root, index]) .attach(AdHoc::config::()) .attach(Template::custom(|engines| { engines .tera .add_raw_template("index", std::include_str!("../templates/index.html.tera")) .unwrap() })) }