pub mod local;
pub mod s3;
pub mod sigv4;
use rustlavel_core::{Config, Error, Result};
pub use local::LocalStorage;
pub use s3::{S3Config, S3Storage};
#[derive(Debug, Clone, PartialEq)]
pub struct Entry {
pub path: String,
pub size: u64,
pub is_directory: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Visibility {
Private,
Public,
}
pub trait Storage: Send + Sync {
fn put(&self, path: &str, contents: Vec<u8>) -> impl Future<Output = Result<()>> + Send;
fn put_with(
&self,
path: &str,
contents: Vec<u8>,
visibility: Visibility,
) -> impl Future<Output = Result<()>> + Send;
fn get(&self, path: &str) -> impl Future<Output = Result<Vec<u8>>> + Send;
fn exists(&self, path: &str) -> impl Future<Output = Result<bool>> + Send;
fn delete(&self, path: &str) -> impl Future<Output = Result<()>> + Send;
fn size(&self, path: &str) -> impl Future<Output = Result<u64>> + Send;
fn list(&self, prefix: &str) -> impl Future<Output = Result<Vec<Entry>>> + Send;
fn url(&self, path: &str) -> Option<String>;
}
pub fn normalize(path: &str) -> Result<String> {
let mut segments: Vec<&str> = Vec::new();
for segment in path.split('/') {
match segment {
"" | "." => continue,
".." => {
if segments.pop().is_none() {
return Err(traversal(path));
}
}
s if s.contains('\\') || s.contains('\0') => return Err(traversal(path)),
s => segments.push(s),
}
}
if segments.is_empty() {
return Err(Error::msg("a storage path cannot be empty"));
}
Ok(segments.join("/"))
}
fn traversal(path: &str) -> Error {
Error::msg(format!(
"`{path}` escapes the storage root. Paths are relative and may not contain `..`."
))
}
pub fn content_type(path: &str) -> &'static str {
match path.rsplit('.').next().unwrap_or("").to_ascii_lowercase().as_str() {
"html" | "htm" => "text/html; charset=utf-8",
"css" => "text/css; charset=utf-8",
"js" | "mjs" => "text/javascript; charset=utf-8",
"json" => "application/json",
"svg" => "image/svg+xml",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"avif" => "image/avif",
"pdf" => "application/pdf",
"csv" => "text/csv; charset=utf-8",
"txt" | "md" => "text/plain; charset=utf-8",
"zip" => "application/zip",
"mp4" => "video/mp4",
"webm" => "video/webm",
_ => "application/octet-stream",
}
}
pub enum AnyStorage {
Local(LocalStorage),
S3(Box<S3Storage>),
}
impl AnyStorage {
pub fn from_config(config: &Config, root: &std::path::Path) -> Result<AnyStorage> {
match config.string("storage.driver", "local").as_str() {
"local" => {
let path = config.string("storage.path", "storage/app");
Ok(AnyStorage::Local(LocalStorage::new(root.join(path))))
}
"s3" => Ok(AnyStorage::S3(Box::new(S3Storage::new(S3Config::from_config(config)?)))),
other => Err(Error::msg(format!(
"`{other}` is not a storage driver. Use `local` or `s3`."
))),
}
}
}
impl Storage for AnyStorage {
async fn put(&self, path: &str, contents: Vec<u8>) -> Result<()> {
match self {
AnyStorage::Local(driver) => driver.put(path, contents).await,
AnyStorage::S3(driver) => driver.put(path, contents).await,
}
}
async fn put_with(&self, path: &str, contents: Vec<u8>, visibility: Visibility) -> Result<()> {
match self {
AnyStorage::Local(driver) => driver.put_with(path, contents, visibility).await,
AnyStorage::S3(driver) => driver.put_with(path, contents, visibility).await,
}
}
async fn get(&self, path: &str) -> Result<Vec<u8>> {
match self {
AnyStorage::Local(driver) => driver.get(path).await,
AnyStorage::S3(driver) => driver.get(path).await,
}
}
async fn exists(&self, path: &str) -> Result<bool> {
match self {
AnyStorage::Local(driver) => driver.exists(path).await,
AnyStorage::S3(driver) => driver.exists(path).await,
}
}
async fn delete(&self, path: &str) -> Result<()> {
match self {
AnyStorage::Local(driver) => driver.delete(path).await,
AnyStorage::S3(driver) => driver.delete(path).await,
}
}
async fn size(&self, path: &str) -> Result<u64> {
match self {
AnyStorage::Local(driver) => driver.size(path).await,
AnyStorage::S3(driver) => driver.size(path).await,
}
}
async fn list(&self, prefix: &str) -> Result<Vec<Entry>> {
match self {
AnyStorage::Local(driver) => driver.list(prefix).await,
AnyStorage::S3(driver) => driver.list(prefix).await,
}
}
fn url(&self, path: &str) -> Option<String> {
match self {
AnyStorage::Local(driver) => driver.url(path),
AnyStorage::S3(driver) => driver.url(path),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalizes_ordinary_paths() {
assert_eq!(normalize("avatars/7.png").unwrap(), "avatars/7.png");
assert_eq!(normalize("/avatars//7.png").unwrap(), "avatars/7.png");
assert_eq!(normalize("a/./b").unwrap(), "a/b");
assert_eq!(normalize("a/b/../c").unwrap(), "a/c");
}
#[test]
fn refuses_to_escape_the_root() {
for attempt in ["../secrets", "a/../../secrets", "..", "a\\b", "a\0b"] {
assert!(normalize(attempt).is_err(), "{attempt:?} should be rejected");
}
}
#[test]
fn an_empty_path_is_an_error() {
assert!(normalize("").is_err());
assert!(normalize("/").is_err());
}
#[test]
fn guesses_content_types_from_the_extension() {
assert_eq!(content_type("a/b/photo.PNG"), "image/png");
assert_eq!(content_type("report.pdf"), "application/pdf");
assert_eq!(content_type("noextension"), "application/octet-stream");
}
}