use std::path::{Path, PathBuf};
use async_trait::async_trait;
use chrono::Local;
use md5::{Digest, Md5};
use rand::Rng;
use sz_orm_storage::Storage;
use super::{UploadError, UploadedFile};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StorageEngineKind {
Local,
Aliyun,
Qcloud,
Qiniu,
S3,
}
impl StorageEngineKind {
pub fn parse(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"local" => Some(Self::Local),
"aliyun" => Some(Self::Aliyun),
"qcloud" => Some(Self::Qcloud),
"qiniu" => Some(Self::Qiniu),
"s3" => Some(Self::S3),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Local => "local",
Self::Aliyun => "aliyun",
Self::Qcloud => "qcloud",
Self::Qiniu => "qiniu",
Self::S3 => "s3",
}
}
}
#[derive(Debug, Clone, Default)]
pub struct EngineConfig {
pub bucket: String,
pub region: String,
pub endpoint: String,
pub domain: String,
pub access_key_id: String,
pub access_key_secret: String,
pub secret_id: String,
pub secret_key: String,
pub access_key: String,
pub base_path: String,
}
impl EngineConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_bucket(mut self, bucket: impl Into<String>) -> Self {
self.bucket = bucket.into();
self
}
pub fn with_region(mut self, region: impl Into<String>) -> Self {
self.region = region.into();
self
}
pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.endpoint = endpoint.into();
self
}
pub fn with_domain(mut self, domain: impl Into<String>) -> Self {
self.domain = domain.into();
self
}
pub fn with_base_path(mut self, base_path: impl Into<String>) -> Self {
self.base_path = base_path.into();
self
}
pub fn with_access_key_id(mut self, key: impl Into<String>) -> Self {
self.access_key_id = key.into();
self
}
pub fn with_access_key_secret(mut self, key: impl Into<String>) -> Self {
self.access_key_secret = key.into();
self
}
pub fn with_secret_id(mut self, key: impl Into<String>) -> Self {
self.secret_id = key.into();
self
}
pub fn with_secret_key(mut self, key: impl Into<String>) -> Self {
self.secret_key = key.into();
self
}
pub fn with_access_key(mut self, key: impl Into<String>) -> Self {
self.access_key = key.into();
self
}
}
#[derive(Debug, Clone)]
pub struct UploadFileInfo {
pub name: String,
pub size: u64,
pub extension: String,
pub tmp_name: PathBuf,
pub error: i32,
pub is_internal: bool,
}
impl UploadFileInfo {
pub fn from_uploaded_file(file: &UploadedFile) -> Result<Self, UploadError> {
let path = file.as_file().path();
let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
Ok(Self {
name: file.original_name().to_string(),
size,
extension: file.original_extension(),
tmp_name: path.to_path_buf(),
error: file.error_code() as i32,
is_internal: false,
})
}
pub fn from_real_path<P: AsRef<Path>>(path: P, extension: &str) -> Result<Self, UploadError> {
let path = path.as_ref();
if !path.exists() {
return Err(UploadError::FileNotFound(
path.to_string_lossy().to_string(),
));
}
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
Ok(Self {
name,
size,
extension: extension.to_string(),
tmp_name: path.to_path_buf(),
error: 0,
is_internal: true,
})
}
}
pub fn build_save_name(real_path: &Path, extension: &str) -> String {
let now = Local::now();
let ymd = now.format("%Y%m%d").to_string();
let ymd_his = now.format("%Y%m%d%H%M%S").to_string();
let mut md5 = Md5::new();
md5.update(real_path.to_string_lossy().as_bytes());
let md5_hex = hex::encode(md5.finalize());
let md5_prefix = &md5_hex[..5];
let rand_num: u32 = rand::thread_rng().gen_range(0..=9999);
let rand_padded = format!("{:04}", rand_num);
if extension.is_empty() {
format!("storage/{}/{}{}{}", ymd, ymd_his, md5_prefix, rand_padded)
} else {
format!(
"storage/{}/{}{}{}.{}",
ymd, ymd_his, md5_prefix, rand_padded, extension
)
}
}
pub fn build_internal_save_name(file_path: &Path) -> String {
let now = Local::now();
let ymd = now.format("%Y%m%d").to_string();
let basename = file_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
format!("storage/{}/{}", ymd, basename)
}
#[async_trait]
pub trait StorageEngine: Send + Sync {
async fn upload(&mut self) -> Result<Option<String>, UploadError>;
async fn delete(&mut self, file_name: &str) -> Result<bool, UploadError>;
fn file_name(&self) -> Option<&str>;
fn file_info(&self) -> Option<&UploadFileInfo>;
fn error(&self) -> Option<&str>;
fn set_upload_file(&mut self, file: &UploadedFile) -> Result<(), UploadError>;
fn set_upload_file_by_real(
&mut self,
file_path: &Path,
extension: &str,
) -> Result<(), UploadError>;
fn is_internal(&self) -> bool;
fn real_path(&self) -> Option<&Path> {
self.file_info().map(|info| info.tmp_name.as_path())
}
}
pub struct LocalStorageEngine {
config: EngineConfig,
file_info: Option<UploadFileInfo>,
file_name: Option<String>,
error: Option<String>,
is_internal: bool,
upload_source_path: Option<PathBuf>,
}
impl LocalStorageEngine {
pub fn new(config: EngineConfig) -> Self {
Self {
config,
file_info: None,
file_name: None,
error: None,
is_internal: false,
upload_source_path: None,
}
}
pub fn upload_dir(&self) -> PathBuf {
if self.config.base_path.is_empty() {
PathBuf::from("uploads")
} else {
PathBuf::from(&self.config.base_path).join("uploads")
}
}
#[tracing::instrument(skip(self))]
async fn upload_by_internal(&mut self) -> Result<bool, UploadError> {
let target = self.upload_dir().join(
self.file_name
.as_ref()
.ok_or_else(|| UploadError::UploadFailed("file name not set".to_string()))?
.as_str(),
);
let real_path = self
.file_info
.as_ref()
.ok_or_else(|| UploadError::UploadFailed("file info not set".to_string()))?
.tmp_name
.clone();
if let Some(parent) = target.parent() {
tokio::fs::create_dir_all(parent).await?;
}
match tokio::fs::rename(&real_path, &target).await {
Ok(_) => Ok(true),
Err(e) => {
self.error = Some("upload write error".to_string());
Err(UploadError::MoveFailed {
from: real_path.to_string_lossy().to_string(),
to: target.to_string_lossy().to_string(),
error: e.to_string(),
})
}
}
}
#[tracing::instrument(skip(self))]
async fn upload_by_external(&mut self) -> Result<String, UploadError> {
let file_name = self
.file_name
.as_ref()
.ok_or_else(|| UploadError::UploadFailed("file name not set".to_string()))?
.clone();
let source = self
.upload_source_path
.as_ref()
.or_else(|| self.file_info.as_ref().map(|info| &info.tmp_name))
.cloned()
.ok_or_else(|| UploadError::UploadFailed("source path not set".to_string()))?;
let target = self.upload_dir().join(&file_name);
if let Some(parent) = target.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::copy(&source, &target).await?;
Ok(file_name)
}
}
#[async_trait]
impl StorageEngine for LocalStorageEngine {
#[tracing::instrument(skip(self))]
async fn upload(&mut self) -> Result<Option<String>, UploadError> {
if self.is_internal {
self.upload_by_internal().await?;
Ok(None)
} else {
let save_name = self.upload_by_external().await?;
Ok(Some(save_name))
}
}
#[tracing::instrument(skip(self))]
async fn delete(&mut self, file_name: &str) -> Result<bool, UploadError> {
if file_name.contains("..") || std::path::Path::new(file_name).is_absolute() {
return Err(UploadError::InvalidFileName(file_name.to_string()));
}
let file_path = self.upload_dir().join(file_name);
if let (Ok(upload_canon), Ok(file_canon)) = (
std::fs::canonicalize(self.upload_dir()),
std::fs::canonicalize(&file_path),
) {
if !file_canon.starts_with(&upload_canon) {
return Err(UploadError::InvalidFileName(file_name.to_string()));
}
}
if !file_path.exists() {
return Ok(true);
}
match tokio::fs::remove_file(&file_path).await {
Ok(_) => Ok(true),
Err(e) => {
self.error = Some(e.to_string());
Ok(false)
}
}
}
fn file_name(&self) -> Option<&str> {
self.file_name.as_deref()
}
fn file_info(&self) -> Option<&UploadFileInfo> {
self.file_info.as_ref()
}
fn error(&self) -> Option<&str> {
self.error.as_deref()
}
fn set_upload_file(&mut self, file: &UploadedFile) -> Result<(), UploadError> {
let info = UploadFileInfo::from_uploaded_file(file)?;
let save_name = build_save_name(&info.tmp_name, &info.extension);
self.upload_source_path = Some(info.tmp_name.clone());
self.file_info = Some(info);
self.file_name = Some(save_name);
self.is_internal = false;
Ok(())
}
fn set_upload_file_by_real(
&mut self,
file_path: &Path,
extension: &str,
) -> Result<(), UploadError> {
let info = UploadFileInfo::from_real_path(file_path, extension)?;
let save_name = build_internal_save_name(file_path);
self.file_info = Some(info);
self.file_name = Some(save_name);
self.is_internal = true;
Ok(())
}
fn is_internal(&self) -> bool {
self.is_internal
}
}
pub struct AliyunStorageEngine {
config: EngineConfig,
file_info: Option<UploadFileInfo>,
file_name: Option<String>,
error: Option<String>,
is_internal: bool,
}
impl AliyunStorageEngine {
pub fn new(config: EngineConfig) -> Self {
Self {
config,
file_info: None,
file_name: None,
error: None,
is_internal: false,
}
}
fn create_storage(&self) -> sz_orm_storage::AliyunOssStorage {
sz_orm_storage::AliyunOssStorage::new(
self.config.bucket.clone(),
self.config.endpoint.clone(),
)
}
}
#[async_trait]
impl StorageEngine for AliyunStorageEngine {
#[tracing::instrument(skip(self))]
async fn upload(&mut self) -> Result<Option<String>, UploadError> {
let file_name = self
.file_name
.as_ref()
.ok_or_else(|| UploadError::UploadFailed("file name not set".to_string()))?
.clone();
let info = self
.file_info
.as_ref()
.ok_or_else(|| UploadError::UploadFailed("file info not set".to_string()))?
.clone();
let storage = self.create_storage();
let data = tokio::fs::read(&info.tmp_name).await?;
let content_type = mime_guess::from_path(&info.tmp_name)
.first_or_octet_stream()
.to_string();
match storage.put(&file_name, &data, &content_type).await {
Ok(_) => {
Ok(Some(file_name))
}
Err(e) => {
let msg = e.to_string();
self.error = Some(msg.clone());
Err(UploadError::UploadFailed(msg))
}
}
}
#[tracing::instrument(skip(self))]
async fn delete(&mut self, file_name: &str) -> Result<bool, UploadError> {
let storage = self.create_storage();
match storage.delete(file_name).await {
Ok(_) => Ok(true),
Err(e) => {
let msg = e.to_string();
self.error = Some(msg);
Ok(false)
}
}
}
fn file_name(&self) -> Option<&str> {
self.file_name.as_deref()
}
fn file_info(&self) -> Option<&UploadFileInfo> {
self.file_info.as_ref()
}
fn error(&self) -> Option<&str> {
self.error.as_deref()
}
fn set_upload_file(&mut self, file: &UploadedFile) -> Result<(), UploadError> {
let info = UploadFileInfo::from_uploaded_file(file)?;
let save_name = build_save_name(&info.tmp_name, &info.extension);
self.file_info = Some(info);
self.file_name = Some(save_name);
self.is_internal = false;
Ok(())
}
fn set_upload_file_by_real(
&mut self,
file_path: &Path,
extension: &str,
) -> Result<(), UploadError> {
let info = UploadFileInfo::from_real_path(file_path, extension)?;
let save_name = build_internal_save_name(file_path);
self.file_info = Some(info);
self.file_name = Some(save_name);
self.is_internal = true;
Ok(())
}
fn is_internal(&self) -> bool {
self.is_internal
}
}
pub struct QcloudStorageEngine {
config: EngineConfig,
file_info: Option<UploadFileInfo>,
file_name: Option<String>,
error: Option<String>,
is_internal: bool,
}
impl QcloudStorageEngine {
pub fn new(config: EngineConfig) -> Self {
Self {
config,
file_info: None,
file_name: None,
error: None,
is_internal: false,
}
}
fn create_storage(&self) -> sz_orm_storage::TencentCosStorage {
sz_orm_storage::TencentCosStorage::new(
self.config.bucket.clone(),
self.config.region.clone(),
)
}
}
#[async_trait]
impl StorageEngine for QcloudStorageEngine {
#[tracing::instrument(skip(self))]
async fn upload(&mut self) -> Result<Option<String>, UploadError> {
let file_name = self
.file_name
.as_ref()
.ok_or_else(|| UploadError::UploadFailed("file name not set".to_string()))?
.clone();
let info = self
.file_info
.as_ref()
.ok_or_else(|| UploadError::UploadFailed("file info not set".to_string()))?
.clone();
let storage = self.create_storage();
let data = tokio::fs::read(&info.tmp_name).await?;
let content_type = mime_guess::from_path(&info.tmp_name)
.first_or_octet_stream()
.to_string();
match storage.put(&file_name, &data, &content_type).await {
Ok(_) => Ok(Some(file_name)),
Err(e) => {
let msg = e.to_string();
self.error = Some(msg.clone());
Err(UploadError::UploadFailed(msg))
}
}
}
#[tracing::instrument(skip(self))]
async fn delete(&mut self, file_name: &str) -> Result<bool, UploadError> {
let storage = self.create_storage();
match storage.delete(file_name).await {
Ok(_) => Ok(true),
Err(e) => {
let msg = e.to_string();
self.error = Some(msg);
Ok(false)
}
}
}
fn file_name(&self) -> Option<&str> {
self.file_name.as_deref()
}
fn file_info(&self) -> Option<&UploadFileInfo> {
self.file_info.as_ref()
}
fn error(&self) -> Option<&str> {
self.error.as_deref()
}
fn set_upload_file(&mut self, file: &UploadedFile) -> Result<(), UploadError> {
let info = UploadFileInfo::from_uploaded_file(file)?;
let save_name = build_save_name(&info.tmp_name, &info.extension);
self.file_info = Some(info);
self.file_name = Some(save_name);
self.is_internal = false;
Ok(())
}
fn set_upload_file_by_real(
&mut self,
file_path: &Path,
extension: &str,
) -> Result<(), UploadError> {
let info = UploadFileInfo::from_real_path(file_path, extension)?;
let save_name = build_internal_save_name(file_path);
self.file_info = Some(info);
self.file_name = Some(save_name);
self.is_internal = true;
Ok(())
}
fn is_internal(&self) -> bool {
self.is_internal
}
}
pub struct QiniuStorageEngine {
config: EngineConfig,
file_info: Option<UploadFileInfo>,
file_name: Option<String>,
error: Option<String>,
is_internal: bool,
}
impl QiniuStorageEngine {
pub fn new(config: EngineConfig) -> Self {
Self {
config,
file_info: None,
file_name: None,
error: None,
is_internal: false,
}
}
fn create_storage(&self) -> sz_orm_storage::QiniuKodoStorage {
sz_orm_storage::QiniuKodoStorage::new(self.config.bucket.clone())
}
}
#[async_trait]
impl StorageEngine for QiniuStorageEngine {
#[tracing::instrument(skip(self))]
async fn upload(&mut self) -> Result<Option<String>, UploadError> {
let file_name = self
.file_name
.as_ref()
.ok_or_else(|| UploadError::UploadFailed("file name not set".to_string()))?
.clone();
let info = self
.file_info
.as_ref()
.ok_or_else(|| UploadError::UploadFailed("file info not set".to_string()))?
.clone();
let storage = self.create_storage();
let data = tokio::fs::read(&info.tmp_name).await?;
let content_type = mime_guess::from_path(&info.tmp_name)
.first_or_octet_stream()
.to_string();
match storage.put(&file_name, &data, &content_type).await {
Ok(_) => Ok(Some(file_name)),
Err(e) => {
let msg = e.to_string();
self.error = Some(msg.clone());
Err(UploadError::UploadFailed(msg))
}
}
}
#[tracing::instrument(skip(self))]
async fn delete(&mut self, file_name: &str) -> Result<bool, UploadError> {
let storage = self.create_storage();
match storage.delete(file_name).await {
Ok(_) => Ok(true),
Err(e) => {
let msg = e.to_string();
self.error = Some(msg);
Ok(false)
}
}
}
fn file_name(&self) -> Option<&str> {
self.file_name.as_deref()
}
fn file_info(&self) -> Option<&UploadFileInfo> {
self.file_info.as_ref()
}
fn error(&self) -> Option<&str> {
self.error.as_deref()
}
fn set_upload_file(&mut self, file: &UploadedFile) -> Result<(), UploadError> {
let info = UploadFileInfo::from_uploaded_file(file)?;
let save_name = build_save_name(&info.tmp_name, &info.extension);
self.file_info = Some(info);
self.file_name = Some(save_name);
self.is_internal = false;
Ok(())
}
fn set_upload_file_by_real(
&mut self,
file_path: &Path,
extension: &str,
) -> Result<(), UploadError> {
let info = UploadFileInfo::from_real_path(file_path, extension)?;
let save_name = build_internal_save_name(file_path);
self.file_info = Some(info);
self.file_name = Some(save_name);
self.is_internal = true;
Ok(())
}
fn is_internal(&self) -> bool {
self.is_internal
}
}
pub struct S3StorageEngine {
config: EngineConfig,
file_info: Option<UploadFileInfo>,
file_name: Option<String>,
error: Option<String>,
is_internal: bool,
}
impl S3StorageEngine {
pub fn new(config: EngineConfig) -> Self {
Self {
config,
file_info: None,
file_name: None,
error: None,
is_internal: false,
}
}
fn create_storage(&self) -> sz_orm_storage::S3Storage {
sz_orm_storage::S3Storage::new(self.config.bucket.clone(), self.config.region.clone())
}
}
#[async_trait]
impl StorageEngine for S3StorageEngine {
#[tracing::instrument(skip(self))]
async fn upload(&mut self) -> Result<Option<String>, UploadError> {
let file_name = self
.file_name
.as_ref()
.ok_or_else(|| UploadError::UploadFailed("file name not set".to_string()))?
.clone();
let info = self
.file_info
.as_ref()
.ok_or_else(|| UploadError::UploadFailed("file info not set".to_string()))?
.clone();
let storage = self.create_storage();
let data = tokio::fs::read(&info.tmp_name).await?;
let content_type = mime_guess::from_path(&info.tmp_name)
.first_or_octet_stream()
.to_string();
match storage.put(&file_name, &data, &content_type).await {
Ok(_) => Ok(Some(file_name)),
Err(e) => {
let msg = e.to_string();
self.error = Some(msg.clone());
Err(UploadError::UploadFailed(msg))
}
}
}
#[tracing::instrument(skip(self))]
async fn delete(&mut self, file_name: &str) -> Result<bool, UploadError> {
let storage = self.create_storage();
match storage.delete(file_name).await {
Ok(_) => Ok(true),
Err(e) => {
let msg = e.to_string();
self.error = Some(msg);
Ok(false)
}
}
}
fn file_name(&self) -> Option<&str> {
self.file_name.as_deref()
}
fn file_info(&self) -> Option<&UploadFileInfo> {
self.file_info.as_ref()
}
fn error(&self) -> Option<&str> {
self.error.as_deref()
}
fn set_upload_file(&mut self, file: &UploadedFile) -> Result<(), UploadError> {
let info = UploadFileInfo::from_uploaded_file(file)?;
let save_name = build_save_name(&info.tmp_name, &info.extension);
self.file_info = Some(info);
self.file_name = Some(save_name);
self.is_internal = false;
Ok(())
}
fn set_upload_file_by_real(
&mut self,
file_path: &Path,
extension: &str,
) -> Result<(), UploadError> {
let info = UploadFileInfo::from_real_path(file_path, extension)?;
let save_name = build_internal_save_name(file_path);
self.file_info = Some(info);
self.file_name = Some(save_name);
self.is_internal = true;
Ok(())
}
fn is_internal(&self) -> bool {
self.is_internal
}
}
pub enum StorageDriver {
Local(LocalStorageEngine),
Aliyun(AliyunStorageEngine),
Qcloud(QcloudStorageEngine),
Qiniu(QiniuStorageEngine),
S3(S3StorageEngine),
}
impl StorageDriver {
pub fn new(kind: StorageEngineKind, config: EngineConfig) -> Self {
match kind {
StorageEngineKind::Local => Self::Local(LocalStorageEngine::new(config)),
StorageEngineKind::Aliyun => Self::Aliyun(AliyunStorageEngine::new(config)),
StorageEngineKind::Qcloud => Self::Qcloud(QcloudStorageEngine::new(config)),
StorageEngineKind::Qiniu => Self::Qiniu(QiniuStorageEngine::new(config)),
StorageEngineKind::S3 => Self::S3(S3StorageEngine::new(config)),
}
}
#[tracing::instrument(skip(self))]
pub async fn upload(&mut self) -> Result<Option<String>, UploadError> {
match self {
Self::Local(e) => e.upload().await,
Self::Aliyun(e) => e.upload().await,
Self::Qcloud(e) => e.upload().await,
Self::Qiniu(e) => e.upload().await,
Self::S3(e) => e.upload().await,
}
}
#[tracing::instrument(skip(self))]
pub async fn delete(&mut self, file_name: &str) -> Result<bool, UploadError> {
match self {
Self::Local(e) => e.delete(file_name).await,
Self::Aliyun(e) => e.delete(file_name).await,
Self::Qcloud(e) => e.delete(file_name).await,
Self::Qiniu(e) => e.delete(file_name).await,
Self::S3(e) => e.delete(file_name).await,
}
}
pub fn file_name(&self) -> Option<&str> {
match self {
Self::Local(e) => e.file_name(),
Self::Aliyun(e) => e.file_name(),
Self::Qcloud(e) => e.file_name(),
Self::Qiniu(e) => e.file_name(),
Self::S3(e) => e.file_name(),
}
}
pub fn file_info(&self) -> Option<&UploadFileInfo> {
match self {
Self::Local(e) => e.file_info(),
Self::Aliyun(e) => e.file_info(),
Self::Qcloud(e) => e.file_info(),
Self::Qiniu(e) => e.file_info(),
Self::S3(e) => e.file_info(),
}
}
pub fn error(&self) -> Option<&str> {
match self {
Self::Local(e) => e.error(),
Self::Aliyun(e) => e.error(),
Self::Qcloud(e) => e.error(),
Self::Qiniu(e) => e.error(),
Self::S3(e) => e.error(),
}
}
pub fn set_upload_file(&mut self, file: &UploadedFile) -> Result<(), UploadError> {
match self {
Self::Local(e) => e.set_upload_file(file),
Self::Aliyun(e) => e.set_upload_file(file),
Self::Qcloud(e) => e.set_upload_file(file),
Self::Qiniu(e) => e.set_upload_file(file),
Self::S3(e) => e.set_upload_file(file),
}
}
pub fn set_upload_file_by_real(
&mut self,
file_path: &Path,
extension: &str,
) -> Result<(), UploadError> {
match self {
Self::Local(e) => e.set_upload_file_by_real(file_path, extension),
Self::Aliyun(e) => e.set_upload_file_by_real(file_path, extension),
Self::Qcloud(e) => e.set_upload_file_by_real(file_path, extension),
Self::Qiniu(e) => e.set_upload_file_by_real(file_path, extension),
Self::S3(e) => e.set_upload_file_by_real(file_path, extension),
}
}
pub fn is_internal(&self) -> bool {
match self {
Self::Local(e) => e.is_internal(),
Self::Aliyun(e) => e.is_internal(),
Self::Qcloud(e) => e.is_internal(),
Self::Qiniu(e) => e.is_internal(),
Self::S3(e) => e.is_internal(),
}
}
pub fn kind(&self) -> StorageEngineKind {
match self {
Self::Local(_) => StorageEngineKind::Local,
Self::Aliyun(_) => StorageEngineKind::Aliyun,
Self::Qcloud(_) => StorageEngineKind::Qcloud,
Self::Qiniu(_) => StorageEngineKind::Qiniu,
Self::S3(_) => StorageEngineKind::S3,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn create_temp_file(name: &str, content: &[u8]) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"sz_rust_storage_test_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(name);
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(content).unwrap();
path
}
fn create_temp_base_path() -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"sz_rust_storage_base_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn test_storage_engine_kind_parse_local() {
assert_eq!(
StorageEngineKind::parse("local"),
Some(StorageEngineKind::Local)
);
}
#[test]
fn test_storage_engine_kind_parse_aliyun() {
assert_eq!(
StorageEngineKind::parse("aliyun"),
Some(StorageEngineKind::Aliyun)
);
}
#[test]
fn test_storage_engine_kind_parse_qcloud() {
assert_eq!(
StorageEngineKind::parse("qcloud"),
Some(StorageEngineKind::Qcloud)
);
}
#[test]
fn test_storage_engine_kind_parse_qiniu() {
assert_eq!(
StorageEngineKind::parse("qiniu"),
Some(StorageEngineKind::Qiniu)
);
}
#[test]
fn test_storage_engine_kind_parse_s3() {
assert_eq!(StorageEngineKind::parse("s3"), Some(StorageEngineKind::S3));
}
#[test]
fn test_storage_engine_kind_parse_case_insensitive() {
assert_eq!(
StorageEngineKind::parse("LOCAL"),
Some(StorageEngineKind::Local)
);
assert_eq!(
StorageEngineKind::parse("Aliyun"),
Some(StorageEngineKind::Aliyun)
);
}
#[test]
fn test_storage_engine_kind_parse_invalid() {
assert_eq!(StorageEngineKind::parse("invalid"), None);
assert_eq!(StorageEngineKind::parse(""), None);
}
#[test]
fn test_storage_engine_kind_as_str() {
assert_eq!(StorageEngineKind::Local.as_str(), "local");
assert_eq!(StorageEngineKind::Aliyun.as_str(), "aliyun");
assert_eq!(StorageEngineKind::Qcloud.as_str(), "qcloud");
assert_eq!(StorageEngineKind::Qiniu.as_str(), "qiniu");
assert_eq!(StorageEngineKind::S3.as_str(), "s3");
}
#[test]
fn test_engine_config_builder() {
let config = EngineConfig::new()
.with_bucket("my-bucket")
.with_region("us-east-1")
.with_endpoint("oss-cn-hangzhou.aliyuncs.com")
.with_domain("https://cdn.example.com")
.with_base_path("/var/www/uploads")
.with_access_key_id("akid")
.with_access_key_secret("aksecret")
.with_secret_id("sid")
.with_secret_key("sk")
.with_access_key("ak");
assert_eq!(config.bucket, "my-bucket");
assert_eq!(config.region, "us-east-1");
assert_eq!(config.endpoint, "oss-cn-hangzhou.aliyuncs.com");
assert_eq!(config.domain, "https://cdn.example.com");
assert_eq!(config.base_path, "/var/www/uploads");
assert_eq!(config.access_key_id, "akid");
assert_eq!(config.access_key_secret, "aksecret");
assert_eq!(config.secret_id, "sid");
assert_eq!(config.secret_key, "sk");
assert_eq!(config.access_key, "ak");
}
#[test]
fn test_engine_config_default() {
let config = EngineConfig::default();
assert!(config.bucket.is_empty());
assert!(config.region.is_empty());
assert!(config.endpoint.is_empty());
assert!(config.domain.is_empty());
assert!(config.base_path.is_empty());
}
#[test]
fn test_build_save_name_format() {
let path = Path::new("/tmp/photo.jpg");
let name = build_save_name(path, "jpg");
assert!(name.starts_with("storage/"), "name = {}", name);
assert!(name.ends_with(".jpg"), "name = {}", name);
let parts: Vec<&str> = name.split('/').collect();
assert_eq!(parts.len(), 3, "name = {}", name);
let ymd_part = parts[1];
assert_eq!(ymd_part.len(), 8, "Ymd should be 8 chars");
assert!(ymd_part.chars().all(|c| c.is_ascii_digit()));
}
#[test]
fn test_build_save_name_md5_prefix_consistent() {
let path = Path::new("/tmp/test.png");
let name1 = build_save_name(path, "png");
let name2 = build_save_name(path, "png");
let extract_md5 = |s: &str| -> String {
let parts: Vec<&str> = s.split('/').collect();
if parts.len() != 3 {
return String::new();
}
let last = parts[2];
let last = last.rsplit_once('.').map(|(l, _)| l).unwrap_or(last);
if last.len() > 14 {
last[14..19].to_string()
} else {
String::new()
}
};
let md5_1 = extract_md5(&name1);
let md5_2 = extract_md5(&name2);
assert_eq!(
md5_1, md5_2,
"md5 prefix should be consistent for same path"
);
assert_eq!(md5_1.len(), 5, "md5 prefix should be 5 chars");
}
#[test]
fn test_build_save_name_empty_extension() {
let path = Path::new("/tmp/noext");
let name = build_save_name(path, "");
assert!(!name.ends_with('.'), "name = {}", name);
assert!(name.starts_with("storage/"));
}
#[test]
fn test_build_internal_save_name_format() {
let path = Path::new("/tmp/photo.jpg");
let name = build_internal_save_name(path);
assert!(name.starts_with("storage/"), "name = {}", name);
assert!(name.ends_with("photo.jpg"), "name = {}", name);
let parts: Vec<&str> = name.split('/').collect();
assert_eq!(parts.len(), 3, "name = {}", name);
assert_eq!(parts[1].len(), 8);
assert!(parts[1].chars().all(|c| c.is_ascii_digit()));
}
#[test]
fn test_upload_file_info_from_real_path() {
let path = create_temp_file("test.txt", b"hello world");
let info = UploadFileInfo::from_real_path(&path, "txt").unwrap();
assert_eq!(info.name, "test.txt");
assert_eq!(info.size, 11);
assert_eq!(info.extension, "txt");
assert_eq!(info.tmp_name, path);
assert_eq!(info.error, 0);
assert!(info.is_internal);
std::fs::remove_file(&path).ok();
}
#[test]
fn test_upload_file_info_from_real_path_not_found() {
let result = UploadFileInfo::from_real_path("/nonexistent/path.txt", "txt");
assert!(result.is_err());
}
#[tokio::test]
async fn test_local_storage_engine_new() {
let config = EngineConfig::new().with_base_path("/tmp/test");
let engine = LocalStorageEngine::new(config);
assert!(!engine.is_internal());
assert!(engine.file_name().is_none());
assert!(engine.file_info().is_none());
assert!(engine.error().is_none());
}
#[tokio::test]
async fn test_local_storage_upload_dir_default() {
let config = EngineConfig::new();
let engine = LocalStorageEngine::new(config);
assert_eq!(engine.upload_dir(), PathBuf::from("uploads"));
}
#[tokio::test]
async fn test_local_storage_upload_dir_with_base_path() {
let base = create_temp_base_path();
let config = EngineConfig::new().with_base_path(base.to_string_lossy().to_string());
let engine = LocalStorageEngine::new(config);
let expected = base.join("uploads");
assert_eq!(engine.upload_dir(), expected);
std::fs::remove_dir_all(&base).ok();
}
#[tokio::test]
async fn test_local_storage_set_upload_file_by_real() {
let path = create_temp_file("internal.txt", b"internal data");
let base = create_temp_base_path();
let config = EngineConfig::new().with_base_path(base.to_string_lossy().to_string());
let mut engine = LocalStorageEngine::new(config);
engine
.set_upload_file_by_real(&path, "txt")
.expect("set_upload_file_by_real failed");
assert!(engine.is_internal());
let file_name = engine.file_name().expect("file name should be set");
assert!(file_name.starts_with("storage/"));
assert!(file_name.ends_with("internal.txt"));
let info = engine.file_info().expect("file info should be set");
assert_eq!(info.name, "internal.txt");
assert!(info.is_internal);
std::fs::remove_file(&path).ok();
std::fs::remove_dir_all(&base).ok();
}
#[tokio::test]
async fn test_local_storage_upload_internal_uses_rename() {
let path = create_temp_file("rename_test.txt", b"rename me");
let base = create_temp_base_path();
let config = EngineConfig::new().with_base_path(base.to_string_lossy().to_string());
let mut engine = LocalStorageEngine::new(config);
engine
.set_upload_file_by_real(&path, "txt")
.expect("set_upload_file_by_real failed");
let file_name = engine.file_name().unwrap().to_string();
let result = engine.upload().await;
assert!(result.is_ok(), "upload should succeed");
assert!(result.unwrap().is_none());
assert!(!path.exists(), "original file should be renamed away");
let target = engine.upload_dir().join(&file_name);
assert!(target.exists(), "target file should exist: {:?}", target);
std::fs::remove_dir_all(&base).ok();
}
#[tokio::test]
async fn test_local_storage_upload_external_returns_save_name() {
let path = create_temp_file("external.txt", b"external data");
let base = create_temp_base_path();
let config = EngineConfig::new().with_base_path(base.to_string_lossy().to_string());
let mut engine = LocalStorageEngine::new(config);
let file = UploadedFile::new(&path, "external.txt", Some("text/plain"), Some(0), true)
.expect("UploadedFile::new failed");
engine
.set_upload_file(&file)
.expect("set_upload_file failed");
let result = engine.upload().await;
assert!(result.is_ok(), "upload should succeed");
let save_name = result
.unwrap()
.expect("external upload should return save name");
assert!(save_name.starts_with("storage/"));
assert!(path.exists(), "source file should be preserved");
let target = engine.upload_dir().join(&save_name);
assert!(target.exists(), "target file should exist: {:?}", target);
std::fs::remove_file(&path).ok();
std::fs::remove_dir_all(&base).ok();
}
#[tokio::test]
async fn test_local_storage_delete_existing_file() {
let base = create_temp_base_path();
let uploads_dir = base.join("uploads").join("storage").join("20260101");
std::fs::create_dir_all(&uploads_dir).unwrap();
let file_path = uploads_dir.join("delete_me.txt");
std::fs::write(&file_path, b"delete me").unwrap();
let config = EngineConfig::new().with_base_path(base.to_string_lossy().to_string());
let mut engine = LocalStorageEngine::new(config);
let result = engine.delete("storage/20260101/delete_me.txt").await;
assert!(result.is_ok());
assert!(result.unwrap(), "delete should return true");
assert!(!file_path.exists(), "file should be deleted");
std::fs::remove_dir_all(&base).ok();
}
#[tokio::test]
async fn test_local_storage_delete_nonexistent_file() {
let base = create_temp_base_path();
let config = EngineConfig::new().with_base_path(base.to_string_lossy().to_string());
let mut engine = LocalStorageEngine::new(config);
let result = engine.delete("nonexistent.txt").await;
assert!(result.is_ok());
assert!(result.unwrap(), "delete nonexistent should return true");
std::fs::remove_dir_all(&base).ok();
}
#[test]
fn test_aliyun_storage_engine_new() {
let config = EngineConfig::new()
.with_bucket("aliyun-bucket")
.with_endpoint("oss-cn-hangzhou.aliyuncs.com")
.with_access_key_id("akid")
.with_access_key_secret("aksecret");
let engine = AliyunStorageEngine::new(config);
assert!(!engine.is_internal());
assert!(engine.file_name().is_none());
}
#[test]
fn test_qcloud_storage_engine_new() {
let config = EngineConfig::new()
.with_bucket("cos-bucket")
.with_region("ap-guangzhou")
.with_secret_id("sid")
.with_secret_key("sk");
let engine = QcloudStorageEngine::new(config);
assert!(!engine.is_internal());
}
#[test]
fn test_qiniu_storage_engine_new() {
let config = EngineConfig::new()
.with_bucket("qiniu-bucket")
.with_access_key("ak")
.with_secret_key("sk");
let engine = QiniuStorageEngine::new(config);
assert!(!engine.is_internal());
}
#[test]
fn test_s3_storage_engine_new() {
let config = EngineConfig::new()
.with_bucket("s3-bucket")
.with_region("us-east-1");
let engine = S3StorageEngine::new(config);
assert!(!engine.is_internal());
}
#[test]
fn test_aliyun_set_upload_file_by_real() {
let path = create_temp_file("aliyun.txt", b"aliyun");
let config = EngineConfig::new()
.with_bucket("bucket")
.with_endpoint("endpoint");
let mut engine = AliyunStorageEngine::new(config);
engine.set_upload_file_by_real(&path, "txt").unwrap();
assert!(engine.is_internal());
assert!(engine.file_name().unwrap().starts_with("storage/"));
std::fs::remove_file(&path).ok();
}
#[test]
fn test_qcloud_set_upload_file_by_real() {
let path = create_temp_file("qcloud.txt", b"qcloud");
let config = EngineConfig::new()
.with_bucket("bucket")
.with_region("region");
let mut engine = QcloudStorageEngine::new(config);
engine.set_upload_file_by_real(&path, "txt").unwrap();
assert!(engine.is_internal());
std::fs::remove_file(&path).ok();
}
#[test]
fn test_qiniu_set_upload_file_by_real() {
let path = create_temp_file("qiniu.txt", b"qiniu");
let config = EngineConfig::new().with_bucket("bucket");
let mut engine = QiniuStorageEngine::new(config);
engine.set_upload_file_by_real(&path, "txt").unwrap();
assert!(engine.is_internal());
std::fs::remove_file(&path).ok();
}
#[test]
fn test_s3_set_upload_file_by_real() {
let path = create_temp_file("s3.txt", b"s3");
let config = EngineConfig::new()
.with_bucket("bucket")
.with_region("region");
let mut engine = S3StorageEngine::new(config);
engine.set_upload_file_by_real(&path, "txt").unwrap();
assert!(engine.is_internal());
std::fs::remove_file(&path).ok();
}
#[test]
fn test_storage_driver_new_local() {
let config = EngineConfig::new();
let driver = StorageDriver::new(StorageEngineKind::Local, config);
assert_eq!(driver.kind(), StorageEngineKind::Local);
assert!(!driver.is_internal());
}
#[test]
fn test_storage_driver_new_aliyun() {
let config = EngineConfig::new();
let driver = StorageDriver::new(StorageEngineKind::Aliyun, config);
assert_eq!(driver.kind(), StorageEngineKind::Aliyun);
}
#[test]
fn test_storage_driver_new_qcloud() {
let config = EngineConfig::new();
let driver = StorageDriver::new(StorageEngineKind::Qcloud, config);
assert_eq!(driver.kind(), StorageEngineKind::Qcloud);
}
#[test]
fn test_storage_driver_new_qiniu() {
let config = EngineConfig::new();
let driver = StorageDriver::new(StorageEngineKind::Qiniu, config);
assert_eq!(driver.kind(), StorageEngineKind::Qiniu);
}
#[test]
fn test_storage_driver_new_s3() {
let config = EngineConfig::new();
let driver = StorageDriver::new(StorageEngineKind::S3, config);
assert_eq!(driver.kind(), StorageEngineKind::S3);
}
#[tokio::test]
async fn test_storage_driver_local_upload_and_delete() {
let path = create_temp_file("driver.txt", b"driver data");
let base = create_temp_base_path();
let config = EngineConfig::new().with_base_path(base.to_string_lossy().to_string());
let mut driver = StorageDriver::new(StorageEngineKind::Local, config);
driver
.set_upload_file_by_real(&path, "txt")
.expect("set_upload_file_by_real failed");
let file_name = driver.file_name().unwrap().to_string();
let result = driver.upload().await;
assert!(result.is_ok());
assert!(result.unwrap().is_none());
let del_result = driver.delete(&file_name).await;
assert!(del_result.is_ok());
assert!(del_result.unwrap());
std::fs::remove_file(&path).ok();
std::fs::remove_dir_all(&base).ok();
}
#[tokio::test]
async fn test_storage_driver_aliyun_upload() {
let path = create_temp_file("aliyun_driver.txt", b"aliyun data");
let config = EngineConfig::new()
.with_bucket("test-bucket")
.with_endpoint("oss-cn-hangzhou.aliyuncs.com");
let mut driver = StorageDriver::new(StorageEngineKind::Aliyun, config);
driver
.set_upload_file_by_real(&path, "txt")
.expect("set_upload_file_by_real failed");
let result = driver.upload().await;
assert!(result.is_ok(), "upload should succeed");
assert!(result.unwrap().is_some());
std::fs::remove_file(&path).ok();
}
#[tokio::test]
async fn test_storage_driver_error_after_failed_delete() {
let config = EngineConfig::new()
.with_bucket("bucket")
.with_endpoint("endpoint");
let mut driver = StorageDriver::new(StorageEngineKind::Aliyun, config);
let result = driver.delete("nonexistent.txt").await;
assert!(result.is_ok());
}
#[test]
fn test_r5_16_build_save_name_format() {
let path = Path::new("/tmp/r5_16_test.jpg");
let name = build_save_name(path, "jpg");
assert!(name.starts_with("storage/"), "name = {}", name);
assert!(name.ends_with(".jpg"), "name = {}", name);
let parts: Vec<&str> = name.split('/').collect();
assert_eq!(parts.len(), 3, "should have 3 parts: storage, Ymd, rest");
let ymd = parts[1];
assert_eq!(ymd.len(), 8, "Ymd should be 8 digits");
assert!(ymd.chars().all(|c| c.is_ascii_digit()));
let last = parts[2];
assert_eq!(
last.len(),
27,
"last part length = {}, last = {}",
last.len(),
last
);
}
#[test]
fn test_r5_17_internal_save_name_uses_basename() {
let path = Path::new("/tmp/r5_17_test.png");
let name = build_internal_save_name(path);
assert!(name.starts_with("storage/"));
assert!(name.ends_with("r5_17_test.png"));
let parts: Vec<&str> = name.split('/').collect();
assert_eq!(parts.len(), 3, "should have 3 parts");
assert_eq!(parts[1].len(), 8);
assert!(parts[1].chars().all(|c| c.is_ascii_digit()));
assert_eq!(parts[2], "r5_17_test.png");
}
#[tokio::test]
async fn test_r5_18_real_path_internal_vs_external() {
let internal_path = create_temp_file("internal.txt", b"internal");
let external_path = create_temp_file("external.txt", b"external");
let base = create_temp_base_path();
let config = EngineConfig::new().with_base_path(base.to_string_lossy().to_string());
let mut engine = LocalStorageEngine::new(config.clone());
engine
.set_upload_file_by_real(&internal_path, "txt")
.unwrap();
assert!(engine.is_internal());
let real_path = engine.real_path().expect("real_path should be set");
assert_eq!(real_path, internal_path);
let mut engine2 = LocalStorageEngine::new(config);
let file = UploadedFile::new(
&external_path,
"external.txt",
Some("text/plain"),
Some(0),
true,
)
.expect("UploadedFile::new failed");
engine2.set_upload_file(&file).unwrap();
assert!(!engine2.is_internal());
let real_path = engine2.real_path().expect("real_path should be set");
assert_eq!(real_path, external_path);
std::fs::remove_file(&internal_path).ok();
std::fs::remove_file(&external_path).ok();
std::fs::remove_dir_all(&base).ok();
}
#[tokio::test]
async fn test_r5_19_upload_internal_rename_external_copy() {
let internal_path = create_temp_file("r5_19_internal.txt", b"internal");
let external_path = create_temp_file("r5_19_external.txt", b"external");
let base = create_temp_base_path();
let config = EngineConfig::new().with_base_path(base.to_string_lossy().to_string());
let mut engine = LocalStorageEngine::new(config.clone());
engine
.set_upload_file_by_real(&internal_path, "txt")
.unwrap();
engine.upload().await.expect("internal upload failed");
assert!(!internal_path.exists(), "rename should move file away");
let mut engine2 = LocalStorageEngine::new(config);
let file = UploadedFile::new(
&external_path,
"r5_19_external.txt",
Some("text/plain"),
Some(0),
true,
)
.expect("UploadedFile::new failed");
engine2.set_upload_file(&file).unwrap();
engine2.upload().await.expect("external upload failed");
assert!(external_path.exists(), "copy should preserve source");
std::fs::remove_file(&internal_path).ok();
std::fs::remove_file(&external_path).ok();
std::fs::remove_dir_all(&base).ok();
}
#[tokio::test]
async fn test_r5_20_delete_elvis_short_circuit() {
let base = create_temp_base_path();
let config = EngineConfig::new().with_base_path(base.to_string_lossy().to_string());
let mut engine = LocalStorageEngine::new(config);
let result = engine.delete("nonexistent_file.txt").await;
assert!(result.is_ok());
assert!(result.unwrap(), "delete nonexistent should return true");
let uploads_dir = base.join("uploads").join("storage").join("20260101");
std::fs::create_dir_all(&uploads_dir).unwrap();
let file_path = uploads_dir.join("exists.txt");
std::fs::write(&file_path, b"exists").unwrap();
let result = engine.delete("storage/20260101/exists.txt").await;
assert!(result.is_ok());
assert!(result.unwrap(), "delete existing should return true");
assert!(!file_path.exists(), "file should be deleted");
std::fs::remove_dir_all(&base).ok();
}
#[tokio::test]
async fn test_r5_21_internal_upload_failure_sets_error() {
let nonexistent_path = std::env::temp_dir().join(format!(
"nonexistent_{}.txt",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
let path = create_temp_file("will_fail.txt", b"will fail");
let base = create_temp_base_path();
let config = EngineConfig::new().with_base_path(base.to_string_lossy().to_string());
let mut engine = LocalStorageEngine::new(config);
engine
.set_upload_file_by_real(&path, "txt")
.expect("set_upload_file_by_real failed");
std::fs::remove_file(&path).unwrap();
let result = engine.upload().await;
assert!(result.is_err(), "upload should fail when source missing");
let error_msg = engine.error().unwrap_or("");
assert!(
error_msg.contains("upload write error"),
"error should contain 'upload write error', got: {}",
error_msg
);
let _ = nonexistent_path;
std::fs::remove_dir_all(&base).ok();
}
#[tokio::test]
async fn test_r5_22_cloud_upload_returns_save_name() {
let path = create_temp_file("r5_22.txt", b"cloud upload");
let config = EngineConfig::new()
.with_bucket("r5_22_bucket")
.with_endpoint("oss-cn-hangzhou.aliyuncs.com");
let mut driver = StorageDriver::new(StorageEngineKind::Aliyun, config);
driver
.set_upload_file_by_real(&path, "txt")
.expect("set_upload_file_by_real failed");
let file_name = driver.file_name().unwrap().to_string();
let result = driver.upload().await;
assert!(result.is_ok(), "upload should succeed");
let returned = result.unwrap().expect("cloud upload should return Some");
assert_eq!(returned, file_name, "should return save name");
std::fs::remove_file(&path).ok();
}
#[tokio::test]
async fn test_r5_23_local_external_upload_returns_save_name() {
let path = create_temp_file("r5_23.txt", b"external save name");
let base = create_temp_base_path();
let config = EngineConfig::new().with_base_path(base.to_string_lossy().to_string());
let mut engine = LocalStorageEngine::new(config);
let file = UploadedFile::new(&path, "r5_23.txt", Some("text/plain"), Some(0), true)
.expect("UploadedFile::new failed");
engine.set_upload_file(&file).unwrap();
let expected_name = engine.file_name().unwrap().to_string();
let result = engine.upload().await;
assert!(result.is_ok(), "upload should succeed");
let save_name = result
.unwrap()
.expect("external upload should return save name");
assert_eq!(save_name, expected_name, "should return save name");
std::fs::remove_file(&path).ok();
std::fs::remove_dir_all(&base).ok();
}
}