vyuh 0.2.9

Vyuh web framework for Axum and SQLx with handler-first APIs
Documentation
use std::path::{Path, PathBuf};
use std::pin::Pin;

use tokio::io::AsyncWriteExt;
use uuid::Uuid;

use super::{FileStorageError, SavedFile, StorageBackend, StorageName, UploadConf};
use crate::routes::multipart::UploadedFile;

#[derive(Debug, Clone)]
pub struct LocalStorage {
    root: PathBuf,
    base_url: Option<String>,
}

impl LocalStorage {
    pub fn new(root: impl Into<PathBuf>, base_url: Option<String>) -> Self {
        Self {
            root: root.into(),
            base_url,
        }
    }

    pub fn from_conf(project_dir: &Path, conf: &UploadConf) -> Self {
        Self::new(project_dir.join(&conf.dir), conf.base_url.clone())
    }

    pub fn root(&self) -> &Path {
        &self.root
    }

    pub async fn save(&self, file: &UploadedFile) -> Result<SavedFile, FileStorageError> {
        <Self as StorageBackend>::save(self, file).await
    }

    pub async fn save_as(
        &self,
        file: &UploadedFile,
        name: StorageName,
    ) -> Result<SavedFile, FileStorageError> {
        <Self as StorageBackend>::save_as(self, file, name).await
    }

    pub async fn open(&self, name: &StorageName) -> Result<tokio::fs::File, FileStorageError> {
        <Self as StorageBackend>::open(self, name).await
    }

    pub async fn delete(&self, name: &StorageName) -> Result<(), FileStorageError> {
        <Self as StorageBackend>::delete(self, name).await
    }

    pub fn url(&self, name: &StorageName) -> Option<String> {
        <Self as StorageBackend>::url(self, name)
    }

    fn generated_name(&self, file: &UploadedFile) -> Result<StorageName, FileStorageError> {
        let extension = file
            .file_name()
            .and_then(|name| name.rsplit_once('.').map(|(_, ext)| ext))
            .filter(|ext| {
                !ext.is_empty()
                    && ext
                        .chars()
                        .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
            })
            .map(|ext| format!(".{}", ext.to_ascii_lowercase()))
            .unwrap_or_default();
        StorageName::new(format!("{}{}", Uuid::now_v7(), extension))
    }

    async fn copy_uploaded_file(
        &self,
        file: &UploadedFile,
        name: StorageName,
    ) -> Result<SavedFile, FileStorageError> {
        tokio::fs::create_dir_all(&self.root).await?;
        let path = name.join_to(&self.root)?;
        if let Some(parent) = path.parent() {
            tokio::fs::create_dir_all(parent).await?;
        }
        if file.is_memory() {
            let mut out = tokio::fs::File::create(&path).await?;
            out.write_all(file.memory_bytes().unwrap_or_default())
                .await?;
            out.flush().await?;
        } else if let Some(temp_path) = file.temp_path() {
            tokio::fs::copy(temp_path, &path).await?;
        } else {
            return Err(FileStorageError::NotConfigured);
        }
        Ok(SavedFile::new(name, path, self.base_url.clone()))
    }
}

impl StorageBackend for LocalStorage {
    fn save<'a>(
        &'a self,
        file: &'a UploadedFile,
    ) -> Pin<Box<dyn std::future::Future<Output = Result<SavedFile, FileStorageError>> + Send + 'a>>
    {
        Box::pin(async move {
            let name = self.generated_name(file)?;
            self.copy_uploaded_file(file, name).await
        })
    }

    fn save_as<'a>(
        &'a self,
        file: &'a UploadedFile,
        name: StorageName,
    ) -> Pin<Box<dyn std::future::Future<Output = Result<SavedFile, FileStorageError>> + Send + 'a>>
    {
        Box::pin(async move { self.copy_uploaded_file(file, name).await })
    }

    fn open<'a>(
        &'a self,
        name: &'a StorageName,
    ) -> Pin<
        Box<
            dyn std::future::Future<Output = Result<tokio::fs::File, FileStorageError>> + Send + 'a,
        >,
    > {
        Box::pin(async move { Ok(tokio::fs::File::open(name.join_to(&self.root)?).await?) })
    }

    fn delete<'a>(
        &'a self,
        name: &'a StorageName,
    ) -> Pin<Box<dyn std::future::Future<Output = Result<(), FileStorageError>> + Send + 'a>> {
        Box::pin(async move {
            let path = name.join_to(&self.root)?;
            match tokio::fs::remove_file(path).await {
                Ok(()) => Ok(()),
                Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
                Err(err) => Err(err.into()),
            }
        })
    }

    fn url(&self, name: &StorageName) -> Option<String> {
        self.base_url.as_ref().map(|base| {
            format!(
                "{}/{}",
                base.trim_end_matches('/'),
                percent_encoding::utf8_percent_encode(
                    name.as_str(),
                    percent_encoding::NON_ALPHANUMERIC
                )
            )
        })
    }
}