use std::{
collections::{HashMap, HashSet},
io::{Cursor, Write},
str::FromStr,
time::Duration,
};
use bytes::Bytes;
use chrono::{DateTime, Utc};
use docbox_database::{
DbErr, DbPool,
models::folder::{Folder, FolderId},
};
use docbox_storage::{StorageLayer, StorageLayerError, UploadFileTag};
use futures::StreamExt;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tokio::task::{JoinError, spawn_blocking};
use utoipa::ToSchema;
use uuid::Uuid;
use zip::{ZipWriter, result::ZipError, write::SimpleFileOptions};
use crate::{
files::create_file_key,
folders::folder_stream::{FolderWalkError, FolderWalkItem, FolderWalkStream},
};
#[derive(Debug, Error)]
pub enum CreateFolderZipError {
#[error("failed to files within target folder")]
GetFiles(DbErr),
#[error("failed to walk folder when creating zip")]
WalkFolder(#[from] FolderWalkError),
#[error("failed to create zip")]
Zip(#[from] ZipError),
#[error("failed to upload zip file")]
UploadZip(StorageLayerError),
#[error("failed to create to zip download")]
CreateZipDownload(StorageLayerError),
#[error("failed to start file download")]
DownloadFile(StorageLayerError),
#[error("failed to join zip task")]
JoinTaskError(JoinError),
#[error(transparent)]
InvalidFilePathError(#[from] InvalidFilePathError),
}
#[derive(Debug, Clone)]
pub struct CreateFolderZipOptions {
pub include: Option<Vec<Uuid>>,
pub exclude: Option<Vec<Uuid>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CreateFolderZipDownloadResponse {
pub method: String,
pub uri: String,
pub headers: HashMap<String, String>,
pub expires_at: DateTime<Utc>,
}
pub async fn create_folder_zip(
db: &DbPool,
storage: &StorageLayer,
folder: &Folder,
options: CreateFolderZipOptions,
) -> Result<CreateFolderZipDownloadResponse, CreateFolderZipError> {
let bundle_id = Uuid::new_v4();
let bundle_name = "bundle.zip";
let mime = mime::Mime::from_str("application/zip").expect("zip mime should always be valid");
let file_key = create_file_key(&folder.document_box, bundle_name, &mime, bundle_id);
let mut stream = FolderWalkStream::new(db, folder.clone());
let mut folders = HashMap::new();
let mut files = Vec::new();
while let Some(result) = stream.next().await {
let item = result?;
match item {
FolderWalkItem::Folder(folder) => {
folders.insert(folder.id, folder);
}
FolderWalkItem::File(file) => {
files.push(file);
}
FolderWalkItem::Link(_) => continue,
}
}
let mut files_with_data = Vec::new();
for file in files {
if options
.exclude
.as_ref()
.is_some_and(|excludes| excludes.contains(&file.id))
{
continue;
}
if file.folder_id == folder.id
&& options
.include
.as_ref()
.is_some_and(|includes| !includes.contains(&file.id))
{
continue;
}
let bytes = storage
.get_file(&file.file_key)
.await
.map_err(CreateFolderZipError::DownloadFile)?
.collect_bytes()
.await
.map_err(CreateFolderZipError::DownloadFile)?;
files_with_data.push((file, bytes));
}
let zip_bytes = spawn_blocking(move || -> Result<Bytes, CreateFolderZipError> {
let mut zip = ZipWriter::new(Cursor::new(Vec::<u8>::new()));
let options =
SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
let mut seen_names_at: HashMap<Uuid, HashSet<String>> = HashMap::new();
let folders = folders;
for (file, bytes) in files_with_data {
let folder_path = make_folder_path(&folders, file.folder_id)?;
let mut name = zip_safe_name(&file.name);
let seen_folder_names = seen_names_at.entry(file.folder_id).or_default();
if seen_folder_names.contains(&name) {
name = zip_safe_name(&format!("{}_{}", file.id, file.name));
} else {
seen_folder_names.insert(name.clone());
}
let entry_name = if !folder_path.is_empty() {
format!("{folder_path}/{name}")
} else {
name
};
zip.start_file(&entry_name, options)?;
zip.write_all(&bytes).map_err(ZipError::Io)?;
}
let cursor = zip.finish()?;
Ok(Bytes::from(cursor.into_inner()))
})
.await
.map_err(CreateFolderZipError::JoinTaskError)??;
storage
.upload_file(
&file_key,
zip_bytes,
docbox_storage::UploadFileOptions {
content_type: "application/zip".to_string(),
tags: Some(vec![UploadFileTag::ExpireDays1]),
},
)
.await
.inspect_err(|error| tracing::error!(?error, "failed to upload zip file"))
.map_err(CreateFolderZipError::UploadZip)?;
let (signed_request, expires_at) = storage
.create_presigned_download(&file_key, Duration::from_secs(60 * 60 * 24))
.await
.inspect_err(|error| tracing::error!(?error, "failed to create zip presigned download"))
.map_err(CreateFolderZipError::CreateZipDownload)?;
Ok(CreateFolderZipDownloadResponse {
method: signed_request.method().to_string(),
uri: signed_request.uri().to_string(),
headers: signed_request
.headers()
.map(|(key, value)| (key.to_string(), value.to_string()))
.collect(),
expires_at,
})
}
#[derive(Debug, Error)]
#[error("file had an incomplete folder path")]
pub struct InvalidFilePathError;
fn make_folder_path(
folders: &HashMap<FolderId, Folder>,
parent_id: FolderId,
) -> Result<String, InvalidFilePathError> {
let mut parts = Vec::new();
let mut folder_id = parent_id;
loop {
let folder = folders.get(&folder_id).ok_or(InvalidFilePathError)?;
let parent_id = match folder.folder_id {
Some(value) => value,
None => break,
};
let safe_name = zip_safe_name(&folder.name);
parts.push(safe_name);
folder_id = parent_id;
}
parts.reverse();
Ok(parts.join("/"))
}
fn zip_safe_name(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect()
}