232 lines
7 KiB
Rust
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()
|
|
}))
|
|
}
|