serves3 1.2.0-beta.2

A very simple proxy to browse files from private S3 buckets
// 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()
        }))
}