Skip to main content

docbox_core/folders/
create_folder_zip.rs

1use 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/// Options when creating a ZIP file from a folder
56#[derive(Debug, Clone)]
57pub struct CreateFolderZipOptions {
58    /// Optionally only include the specified files
59    pub include: Option<Vec<Uuid>>,
60    /// Optionally exclude the specified files including
61    /// all other files
62    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
73/// Create a compressed ZIP file from the provided folder contents
74pub 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    // Walk the folder tree to obtain all the child folders and files
92    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            // Links are not included in exports
105            FolderWalkItem::Link(_) => continue,
106        }
107    }
108
109    let mut files_with_data = Vec::new();
110
111    // Download all of the files
112    for file in files {
113        // Exclusion filter is applied to all files
114        if options
115            .exclude
116            .as_ref()
117            .is_some_and(|excludes| excludes.contains(&file.id))
118        {
119            continue;
120        }
121
122        // Inclusion filter is only applied to direct descendants and
123        // not the children of other folders
124        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    // Move the zip compression to a blocking task to prevent slowing down the
145    // async runtime
146    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            // Prefix duplicate files with the file ID which is known to be unique
161            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
219/// Using a `parent_id` and a collection of `folders` resolve the folder path name
220/// to the item based on its parent.
221///
222/// `seen_folder_names` tracks the names of folders seen within a specific parent folder
223/// and is used to ensure duplicate folder paths are not created
224fn 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            // Don't append a "Root" folder to the path, these are internal
236            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    // Paths are iterated in reverse order so we need to flip them here
245    parts.reverse();
246
247    Ok(parts.join("/"))
248}
249
250/// Helper to create a zip entry safe name
251fn 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}