docbox_core/folders/
create_folder_zip.rs1use std::{
2 collections::{HashMap, HashSet},
3 io::{Cursor, Write},
4 str::FromStr,
5 time::Duration,
6};
7
8use bytes::Bytes;
9use chrono::{DateTime, Utc};
10use docbox_database::{
11 DbErr, DbPool,
12 models::folder::{Folder, FolderId},
13};
14use docbox_storage::{StorageLayer, StorageLayerError, UploadFileTag};
15use futures::StreamExt;
16use serde::{Deserialize, Serialize};
17use thiserror::Error;
18use tokio::task::{JoinError, spawn_blocking};
19use utoipa::ToSchema;
20use uuid::Uuid;
21use zip::{ZipWriter, result::ZipError, write::SimpleFileOptions};
22
23use crate::{
24 files::create_file_key,
25 folders::folder_stream::{FolderWalkError, FolderWalkItem, FolderWalkStream},
26};
27
28#[derive(Debug, Error)]
29pub enum CreateFolderZipError {
30 #[error("failed to files within target folder")]
31 GetFiles(DbErr),
32
33 #[error("failed to walk folder when creating zip")]
34 WalkFolder(#[from] FolderWalkError),
35
36 #[error("failed to create zip")]
37 Zip(#[from] ZipError),
38
39 #[error("failed to upload zip file")]
40 UploadZip(StorageLayerError),
41
42 #[error("failed to create to zip download")]
43 CreateZipDownload(StorageLayerError),
44
45 #[error("failed to start file download")]
46 DownloadFile(StorageLayerError),
47
48 #[error("failed to join zip task")]
49 JoinTaskError(JoinError),
50
51 #[error(transparent)]
52 InvalidFilePathError(#[from] InvalidFilePathError),
53}
54
55#[derive(Debug, Clone)]
57pub struct CreateFolderZipOptions {
58 pub include: Option<Vec<Uuid>>,
60 pub exclude: Option<Vec<Uuid>>,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
66pub struct CreateFolderZipDownloadResponse {
67 pub method: String,
68 pub uri: String,
69 pub headers: HashMap<String, String>,
70 pub expires_at: DateTime<Utc>,
71}
72
73pub async fn create_folder_zip(
75 db: &DbPool,
76 storage: &StorageLayer,
77 folder: &Folder,
78 options: CreateFolderZipOptions,
79) -> Result<CreateFolderZipDownloadResponse, CreateFolderZipError> {
80 let bundle_id = Uuid::new_v4();
81 let bundle_name = "bundle.zip";
82
83 let mime = mime::Mime::from_str("application/zip").expect("zip mime should always be valid");
84 let file_key = create_file_key(&folder.document_box, bundle_name, &mime, bundle_id);
85
86 let mut stream = FolderWalkStream::new(db, folder.clone());
87
88 let mut folders = HashMap::new();
89 let mut files = Vec::new();
90
91 while let Some(result) = stream.next().await {
93 let item = result?;
94
95 match item {
96 FolderWalkItem::Folder(folder) => {
97 folders.insert(folder.id, folder);
98 }
99
100 FolderWalkItem::File(file) => {
101 files.push(file);
102 }
103
104 FolderWalkItem::Link(_) => continue,
106 }
107 }
108
109 let mut files_with_data = Vec::new();
110
111 for file in files {
113 if options
115 .exclude
116 .as_ref()
117 .is_some_and(|excludes| excludes.contains(&file.id))
118 {
119 continue;
120 }
121
122 if file.folder_id == folder.id
125 && options
126 .include
127 .as_ref()
128 .is_some_and(|includes| !includes.contains(&file.id))
129 {
130 continue;
131 }
132
133 let bytes = storage
134 .get_file(&file.file_key)
135 .await
136 .map_err(CreateFolderZipError::DownloadFile)?
137 .collect_bytes()
138 .await
139 .map_err(CreateFolderZipError::DownloadFile)?;
140
141 files_with_data.push((file, bytes));
142 }
143
144 let zip_bytes = spawn_blocking(move || -> Result<Bytes, CreateFolderZipError> {
147 let mut zip = ZipWriter::new(Cursor::new(Vec::<u8>::new()));
148
149 let options =
150 SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
151
152 let mut seen_names_at: HashMap<Uuid, HashSet<String>> = HashMap::new();
153
154 let folders = folders;
155
156 for (file, bytes) in files_with_data {
157 let folder_path = make_folder_path(&folders, file.folder_id)?;
158 let mut name = zip_safe_name(&file.name);
159
160 let seen_folder_names = seen_names_at.entry(file.folder_id).or_default();
162 if seen_folder_names.contains(&name) {
163 name = zip_safe_name(&format!("{}_{}", file.id, file.name));
164 } else {
165 seen_folder_names.insert(name.clone());
166 }
167
168 let entry_name = if !folder_path.is_empty() {
169 format!("{folder_path}/{name}")
170 } else {
171 name
172 };
173
174 zip.start_file(&entry_name, options)?;
175 zip.write_all(&bytes).map_err(ZipError::Io)?;
176 }
177
178 let cursor = zip.finish()?;
179
180 Ok(Bytes::from(cursor.into_inner()))
181 })
182 .await
183 .map_err(CreateFolderZipError::JoinTaskError)??;
184
185 storage
186 .upload_file(
187 &file_key,
188 zip_bytes,
189 docbox_storage::UploadFileOptions {
190 content_type: "application/zip".to_string(),
191 tags: Some(vec![UploadFileTag::ExpireDays1]),
192 },
193 )
194 .await
195 .inspect_err(|error| tracing::error!(?error, "failed to upload zip file"))
196 .map_err(CreateFolderZipError::UploadZip)?;
197
198 let (signed_request, expires_at) = storage
199 .create_presigned_download(&file_key, Duration::from_secs(60 * 60 * 24))
200 .await
201 .inspect_err(|error| tracing::error!(?error, "failed to create zip presigned download"))
202 .map_err(CreateFolderZipError::CreateZipDownload)?;
203
204 Ok(CreateFolderZipDownloadResponse {
205 method: signed_request.method().to_string(),
206 uri: signed_request.uri().to_string(),
207 headers: signed_request
208 .headers()
209 .map(|(key, value)| (key.to_string(), value.to_string()))
210 .collect(),
211 expires_at,
212 })
213}
214
215#[derive(Debug, Error)]
216#[error("file had an incomplete folder path")]
217pub struct InvalidFilePathError;
218
219fn make_folder_path(
225 folders: &HashMap<FolderId, Folder>,
226 parent_id: FolderId,
227) -> Result<String, InvalidFilePathError> {
228 let mut parts = Vec::new();
229 let mut folder_id = parent_id;
230
231 loop {
232 let folder = folders.get(&folder_id).ok_or(InvalidFilePathError)?;
233 let parent_id = match folder.folder_id {
234 Some(value) => value,
235 None => break,
237 };
238
239 let safe_name = zip_safe_name(&folder.name);
240 parts.push(safe_name);
241 folder_id = parent_id;
242 }
243
244 parts.reverse();
246
247 Ok(parts.join("/"))
248}
249
250fn zip_safe_name(name: &str) -> String {
252 name.chars()
253 .map(|c| {
254 if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' {
255 c
256 } else {
257 '_'
258 }
259 })
260 .collect()
261}