Skip to main content

tauri_plugin_mongoose/db/
files.rs

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