use std::path::{Path, PathBuf};
use bytes::Bytes;
use tokio::fs;
use crate::error::Error;
use crate::traits::StorageBackend;
use crate::types::{PutOptions, RawDownloadResult};
pub struct LocalBackend {
base_dir: PathBuf,
public_base_url: String,
}
impl LocalBackend {
pub fn new(base_dir: impl Into<PathBuf>, public_base_url: &str) -> Self {
Self {
base_dir: base_dir.into(),
public_base_url: public_base_url.trim_end_matches('/').to_string(),
}
}
pub fn base_dir(&self) -> &Path {
&self.base_dir
}
fn safe_path(&self, key: &str) -> Result<PathBuf, Error> {
use std::path::Component;
if key.is_empty() {
return Err(Error::Backend("Key must not be empty".into()));
}
for component in Path::new(key).components() {
match component {
Component::Normal(_) => {}
_ => {
return Err(Error::Backend(format!(
"Invalid key '{key}': must consist of normal path components only"
)));
}
}
}
Ok(self.base_dir.join(key))
}
}
fn guess_content_type(path: &Path) -> &str {
match path.extension().and_then(|e| e.to_str()) {
Some("jpg" | "jpeg") => "image/jpeg",
Some("png") => "image/png",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
Some("pdf") => "application/pdf",
Some("json") => "application/json",
Some("html") => "text/html",
Some("css") => "text/css",
Some("js") => "application/javascript",
Some("txt") => "text/plain",
Some("svg") => "image/svg+xml",
Some("xml") => "application/xml",
_ => "application/octet-stream",
}
}
impl StorageBackend for LocalBackend {
async fn put_object(
&self,
key: &str,
data: Bytes,
_content_type: &str,
_options: &PutOptions,
) -> Result<(), Error> {
let path = self.safe_path(key)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).await?;
}
fs::write(&path, &data).await?;
Ok(())
}
async fn get_object(&self, key: &str) -> Result<RawDownloadResult, Error> {
let path = self.safe_path(key)?;
let data = fs::read(&path).await?;
let content_type = guess_content_type(&path).to_string();
let len = data.len() as i64;
Ok(RawDownloadResult {
data: Bytes::from(data),
content_type,
content_length: Some(len),
})
}
async fn delete_object(&self, key: &str) -> Result<(), Error> {
let path = self.safe_path(key)?;
if path.exists() {
fs::remove_file(&path).await?;
}
Ok(())
}
async fn exists(&self, key: &str) -> Result<bool, Error> {
let path = self.safe_path(key)?;
Ok(path.exists())
}
async fn presigned_get_url(&self, _key: &str, _expires_in_secs: u64) -> Result<String, Error> {
Err(Error::PresignNotSupported)
}
async fn presigned_put_url(
&self,
_key: &str,
_content_type: &str,
_expires_in_secs: u64,
) -> Result<String, Error> {
Err(Error::PresignNotSupported)
}
fn public_url(&self, key: &str) -> String {
format!("{}/{key}", self.public_base_url)
}
async fn test_connection(&self) -> Result<(), Error> {
if !self.base_dir.exists() {
return Err(Error::Backend(format!(
"Local storage directory does not exist: {}",
self.base_dir.display()
)));
}
if !self.base_dir.is_dir() {
return Err(Error::Backend(format!(
"Local storage path is not a directory: {}",
self.base_dir.display()
)));
}
Ok(())
}
}