Skip to main content

tauri_plugin_mongoose/db/
files.rs

1use std::path::Path;
2
3use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
4use futures::io::{AsyncReadExt, Cursor};
5use mongodb::{
6    bson::{doc, oid::ObjectId, Bson, DateTime, Document},
7    gridfs::FilesCollectionDocument,
8    options::{GridFsBucketOptions, GridFsUploadOptions},
9};
10use serde::Deserialize;
11use serde_json::Value;
12
13use crate::db::state::{get_client, get_db_name};
14
15/// Arguments accepted by the `save_file` command.
16#[derive(Debug, Deserialize)]
17#[serde(rename_all = "camelCase")]
18pub struct SaveFileRequest {
19    /// GridFS bucket / collection name. Maps to `<bucket>.files` and `<bucket>.chunks` collections.
20    pub collection: String,
21    /// Absolute path of a file to read from disk.
22    pub path: Option<Value>,
23    /// Raw bytes to be written. Useful when the caller already has the buffer in memory.
24    pub data: Option<Vec<u8>>,
25    /// Optional filename stored in GridFS. Defaults to the basename of `path` or `"untitled"`.
26    pub filename: Option<String>,
27    /// Arbitrary JSON metadata stored alongside the file document.
28    pub metadata: Option<Value>,
29    /// Optional chunk size in bytes. Defaults to MongoDB GridFS default (255 KiB).
30    pub chunk_size_bytes: Option<u32>,
31}
32
33/// Store a file in MongoDB GridFS using either a filesystem path or an in-memory buffer.
34/// Returns the created files document as JSON (including the `_id`).
35pub async fn save_file(args: SaveFileRequest) -> Result<Value, String> {
36    let SaveFileRequest {
37        collection,
38        path,
39        data,
40        filename,
41        metadata,
42        chunk_size_bytes,
43    } = args;
44
45    if path.is_none() && data.is_none() {
46        return Err("Either 'path' or 'data' must be provided".to_string());
47    }
48
49    let client = get_client().await?;
50    let db_name = get_db_name().await;
51    let db = client.database(&db_name);
52
53    let bucket_options = GridFsBucketOptions::builder()
54        .bucket_name(collection.clone())
55        .chunk_size_bytes(chunk_size_bytes)
56        .build();
57    let bucket = db.gridfs_bucket(bucket_options);
58
59    // Load bytes and determine filename.
60    let (bytes, final_filename) = if let Some(path_value) = path {
61        let path_str_opt = match path_value {
62            Value::String(s) => Some(s),
63            Value::Object(map) => map
64                .get("path")
65                .and_then(|v| v.as_str())
66                .map(|s| s.to_string())
67                .or_else(|| {
68                    map.get("filePath")
69                        .and_then(|v| v.as_str())
70                        .map(|s| s.to_string())
71                }),
72            _ => None,
73        };
74
75        let file_path = path_str_opt.ok_or_else(|| {
76            "Invalid 'path': expected a string or object with a 'path' field".to_string()
77        })?;
78
79        let bytes = tokio::fs::read(&file_path)
80            .await
81            .map_err(|e| format!("Failed to read file at {}: {}", file_path, e))?;
82        let derived_name = Path::new(&file_path)
83            .file_name()
84            .and_then(|n| n.to_str())
85            .map(|s| s.to_string());
86        (
87            bytes,
88            filename
89                .or(derived_name)
90                .unwrap_or_else(|| "untitled".to_string()),
91        )
92    } else if let Some(bytes) = data {
93        (bytes, filename.unwrap_or_else(|| "untitled".to_string()))
94    } else {
95        // Unreachable because of the earlier check.
96        return Err("No data supplied".to_string());
97    };
98
99    let length = bytes.len() as i64;
100    let chunk_size = chunk_size_bytes.unwrap_or(255 * 1024);
101
102    let metadata_doc: Option<Document> = match metadata {
103        Some(value) => Some(
104            mongodb::bson::to_document(&value).map_err(|e| format!("Invalid metadata: {}", e))?,
105        ),
106        None => None,
107    };
108
109    let upload_options = GridFsUploadOptions::builder()
110        .chunk_size_bytes(chunk_size_bytes)
111        .metadata(metadata_doc.clone())
112        .build();
113
114    let cursor = Cursor::new(bytes);
115    let file_id = bucket
116        .upload_from_futures_0_3_reader(final_filename.clone(), cursor, upload_options)
117        .await
118        .map_err(|e| e.to_string())?;
119
120    // Try to fetch the stored files document for richer output.
121    let files_collection =
122        db.collection::<FilesCollectionDocument>(&format!("{}.files", collection));
123    let stored = files_collection
124        .find_one(doc! {"_id": file_id.clone()}, None)
125        .await
126        .map_err(|e| e.to_string())?;
127
128    let mut response_doc = if let Some(file) = stored {
129        mongodb::bson::to_document(&file).map_err(|e| e.to_string())?
130    } else {
131        // Fallback when the files document could not be read immediately.
132        let mut fallback = doc! {
133            "_id": file_id.clone(),
134            "length": length,
135            "chunkSizeBytes": chunk_size as i32,
136            "uploadDate": DateTime::now(),
137            "filename": final_filename.clone(),
138        };
139        if let Some(meta) = metadata_doc.clone() {
140            fallback.insert("metadata", meta);
141        }
142        fallback
143    };
144
145    response_doc.insert("_id", file_id.to_hex());
146    response_doc.insert("bucket", collection.clone());
147
148    mongodb::bson::from_document::<Value>(response_doc).map_err(|e| e.to_string())
149}
150
151/// Resolve a file by id and return it as a data URI string.
152pub async fn get_file_url(
153    collection: String,
154    id: String,
155    mime_type: Option<String>,
156) -> Result<String, String> {
157    let client = get_client().await?;
158    let db_name = get_db_name().await;
159    let db = client.database(&db_name);
160
161    let files_collection = db.collection::<Document>(&format!("{}.files", collection));
162    let file_filter = match ObjectId::parse_str(&id) {
163        Ok(oid) => doc! {
164            "$or": [
165                { "_id": oid },
166                { "_id": id.clone() }
167            ]
168        },
169        Err(_) => doc! { "_id": id.clone() },
170    };
171
172    let file_doc = files_collection
173        .find_one(file_filter, None)
174        .await
175        .map_err(|e| e.to_string())?
176        .ok_or_else(|| format!("File not found for id '{}'", id))?;
177
178    let file_id = file_doc
179        .get("_id")
180        .cloned()
181        .ok_or_else(|| "Stored file is missing _id".to_string())?;
182    let filename = file_doc
183        .get("filename")
184        .and_then(Bson::as_str)
185        .map(|name| name.to_string());
186    let metadata = match file_doc.get("metadata") {
187        Some(Bson::Document(doc)) => Some(doc),
188        _ => None,
189    };
190
191    let bucket_options = GridFsBucketOptions::builder()
192        .bucket_name(collection)
193        .build();
194    let bucket = db.gridfs_bucket(bucket_options);
195    let mut stream = bucket
196        .open_download_stream(file_id)
197        .await
198        .map_err(|e| e.to_string())?;
199
200    let mut bytes = Vec::new();
201    stream
202        .read_to_end(&mut bytes)
203        .await
204        .map_err(|e| e.to_string())?;
205
206    let resolved_mime = resolve_mime_type(filename.as_deref(), metadata, mime_type);
207    let encoded = BASE64_STANDARD.encode(bytes);
208
209    Ok(format!("data:{};base64,{}", resolved_mime, encoded))
210}
211
212fn resolve_mime_type(
213    filename: Option<&str>,
214    metadata: Option<&Document>,
215    explicit_mime_type: Option<String>,
216) -> String {
217    if let Some(explicit) = explicit_mime_type {
218        if !explicit.trim().is_empty() {
219            return explicit;
220        }
221    }
222
223    if let Some(meta) = metadata {
224        if let Some(Bson::String(content_type)) = meta.get("contentType") {
225            return content_type.clone();
226        }
227        if let Some(Bson::String(content_type)) = meta.get("mimeType") {
228            return content_type.clone();
229        }
230    }
231
232    if let Some(name) = filename {
233        if let Some(ext) = Path::new(name).extension().and_then(|value| value.to_str()) {
234            match ext.to_ascii_lowercase().as_str() {
235                "png" => return "image/png".to_string(),
236                "jpg" | "jpeg" => return "image/jpeg".to_string(),
237                "gif" => return "image/gif".to_string(),
238                "webp" => return "image/webp".to_string(),
239                "svg" => return "image/svg+xml".to_string(),
240                "bmp" => return "image/bmp".to_string(),
241                "tif" | "tiff" => return "image/tiff".to_string(),
242                "pdf" => return "application/pdf".to_string(),
243                "txt" => return "text/plain".to_string(),
244                "json" => return "application/json".to_string(),
245                _ => {}
246            }
247        }
248    }
249
250    "application/octet-stream".to_string()
251}