serves3/src/main.rs

232 lines
7 KiB
Rust

// SPDX-FileCopyrightText: © Matteo Settenvini <matteo.settenvini@montecristosoftware.eu>
// 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<BoxStream<'static, Bytes>>),
}
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<object_store::Error> 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<Settings>) -> Result<FileView, Error> {
index(None, uri, state).await
}
#[rocket::get("/<path..>")]
async fn index(
path: Option<PathBuf>,
uri: &Origin<'_>,
state: &State<Settings>,
) -> Result<FileView, Error> {
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<bool, Error> {
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<FileView, Error> {
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<ObjectStorePath>,
settings: &Settings,
) -> Result<Vec<FileViewItem>, 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::<Settings>())
.attach(Template::custom(|engines| {
engines
.tera
.add_raw_template("index", std::include_str!("../templates/index.html.tera"))
.unwrap()
}))
}