Skip to main content

firebase_admin_sdk/storage/
file.rs

1use crate::core::middleware::AuthMiddleware;
2use crate::storage::StorageError;
3use reqwest::header;
4use reqwest_middleware::ClientWithMiddleware;
5use rsa::pkcs1::DecodeRsaPrivateKey;
6use rsa::pkcs8::DecodePrivateKey;
7use rsa::{Pkcs1v15Sign, RsaPrivateKey};
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10use std::time::{SystemTime, Duration};
11use url::Url;
12
13/// Represents a file within a Google Cloud Storage bucket.
14pub struct File {
15    client: ClientWithMiddleware,
16    base_url: String,
17    bucket_name: String,
18    name: String,
19    middleware: AuthMiddleware,
20}
21
22/// Options for generating a signed URL.
23#[derive(Debug, Clone)]
24pub struct GetSignedUrlOptions {
25    /// The HTTP method to allow.
26    pub method: SignedUrlMethod,
27    /// The expiration time.
28    pub expires: SystemTime,
29    /// The content type (required if the client provides it).
30    pub content_type: Option<String>,
31}
32
33/// Supported HTTP methods for signed URLs.
34#[derive(Debug, Clone, Copy, PartialEq)]
35pub enum SignedUrlMethod {
36    GET,
37    PUT,
38    POST,
39    DELETE,
40}
41
42impl SignedUrlMethod {
43    fn as_str(&self) -> &'static str {
44        match self {
45            SignedUrlMethod::GET => "GET",
46            SignedUrlMethod::PUT => "PUT",
47            SignedUrlMethod::POST => "POST",
48            SignedUrlMethod::DELETE => "DELETE",
49        }
50    }
51}
52
53/// Metadata for a Google Cloud Storage object.
54#[derive(Debug, Serialize, Deserialize, Default)]
55#[serde(rename_all = "camelCase")]
56pub struct ObjectMetadata {
57    // ... (fields remain same)
58    /// The name of the object.
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub name: Option<String>,
61    /// The bucket containing the object.
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub bucket: Option<String>,
64    /// The content generation of this object. Used for object versioning.
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub generation: Option<String>,
67    /// The version of the metadata for this object at this generation.
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub metageneration: Option<String>,
70    /// Content-Type of the object data.
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub content_type: Option<String>,
73    /// The creation time of the object.
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub time_created: Option<String>,
76    /// The modification time of the object metadata.
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub updated: Option<String>,
79    /// Storage class of the object.
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub storage_class: Option<String>,
82    /// Content-Length of the data in bytes.
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub size: Option<String>,
85    /// MD5 hash of the data; encoded using base64.
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub md5_hash: Option<String>,
88    /// Media download link.
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub media_link: Option<String>,
91    /// Content-Encoding of the object data.
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub content_encoding: Option<String>,
94    /// Content-Disposition of the object data.
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub content_disposition: Option<String>,
97    /// Cache-Control directive for the object data.
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub cache_control: Option<String>,
100    /// User-provided metadata, in key/value pairs.
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub metadata: Option<std::collections::HashMap<String, String>>,
103    /// CRC32c checksum, as described in RFC 4960, Appendix B; encoded using base64.
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub crc32c: Option<String>,
106    /// HTTP 1.1 Entity tag for the object.
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub etag: Option<String>,
109}
110
111impl File {
112    pub(crate) fn new(
113        client: ClientWithMiddleware,
114        base_url: String,
115        bucket_name: String,
116        name: String,
117        middleware: AuthMiddleware,
118    ) -> Self {
119        Self {
120            client,
121            base_url,
122            bucket_name,
123            name,
124            middleware,
125        }
126    }
127
128    /// Returns the name of the file.
129    pub fn name(&self) -> &str {
130        &self.name
131    }
132
133    /// Returns the name of the bucket containing the file.
134    pub fn bucket(&self) -> &str {
135        &self.bucket_name
136    }
137
138    /// Generates a V4 signed URL for accessing the file.
139    ///
140    /// # Arguments
141    ///
142    /// * `options` - The options for generating the signed URL.
143    pub fn get_signed_url(&self, options: GetSignedUrlOptions) -> Result<String, StorageError> {
144        let key = &self.middleware.key;
145        let client_email = &key.client_email;
146        let private_key_pem = &key.private_key;
147
148        if client_email.is_empty() || private_key_pem.is_empty() {
149            return Err(StorageError::ProjectIdMissing); // Using existing error enum, though maybe not precise
150        }
151
152                let now = SystemTime::now();
153
154                
155
156                let iso_date = chrono::DateTime::<chrono::Utc>::from(now).format("%Y%m%dT%H%M%SZ").to_string();
157
158        
159        let date_stamp = &iso_date[0..8]; // YYYYMMDD
160
161        let credential_scope = format!("{}/auto/storage/goog4_request", date_stamp);
162
163        let host = "storage.googleapis.com";
164        let canonical_headers = format!("host:{}\n", host);
165        let signed_headers = "host";
166
167        // Canonical Resource
168        let encoded_name = url::form_urlencoded::byte_serialize(self.name.as_bytes())
169            .collect::<String>()
170            .replace("+", "%20");
171
172        let canonical_uri = format!("/{}/{}", self.bucket_name, encoded_name);
173
174        // Calculate expiration in seconds from now
175        let duration_seconds = options
176            .expires
177            .duration_since(now)
178            .unwrap_or(Duration::from_secs(0))
179            .as_secs();
180
181        let mut query_params = vec![
182            ("X-Goog-Algorithm", "GOOG4-RSA-SHA256".to_string()),
183            (
184                "X-Goog-Credential",
185                format!("{}/{}", client_email, credential_scope),
186            ),
187            ("X-Goog-Date", iso_date.clone()),
188            ("X-Goog-Expires", duration_seconds.to_string()),
189            ("X-Goog-SignedHeaders", signed_headers.to_string()),
190        ];
191
192        query_params.sort_by(|a, b| a.0.cmp(b.0));
193        
194        let canonical_query_string = query_params.iter()
195            .map(|(k, v)| {
196                let encoded_k = url::form_urlencoded::byte_serialize(k.as_bytes()).collect::<String>();
197                let encoded_v = url::form_urlencoded::byte_serialize(v.as_bytes()).collect::<String>();
198                format!("{}={}", encoded_k, encoded_v)
199            })
200            .collect::<Vec<_>>()
201            .join("&");
202
203        let payload_hash = "UNSIGNED-PAYLOAD";
204
205        let canonical_request = format!(
206            "{}\n{}\n{}\n{}\n\n{}\n{}",
207            options.method.as_str(),
208            canonical_uri,
209            canonical_query_string,
210            canonical_headers,
211            signed_headers,
212            payload_hash
213        );
214
215        let algorithm = "GOOG4-RSA-SHA256";
216        let request_hash = Sha256::digest(canonical_request.as_bytes());
217        let request_hash_hex = hex::encode(request_hash);
218
219        let string_to_sign = format!(
220            "{}\n{}\n{}\n{}",
221            algorithm, iso_date, credential_scope, request_hash_hex
222        );
223
224        let hash_to_sign = Sha256::digest(string_to_sign.as_bytes());
225
226        let priv_key = if private_key_pem.contains("BEGIN RSA PRIVATE KEY") {
227            RsaPrivateKey::from_pkcs1_pem(private_key_pem).map_err(|e| {
228                StorageError::ApiError(format!("Invalid private key (PKCS1): {}", e))
229            })?
230        } else {
231            RsaPrivateKey::from_pkcs8_pem(private_key_pem).map_err(|e| {
232                StorageError::ApiError(format!("Invalid private key (PKCS8): {}", e))
233            })?
234        };
235
236        let signature = priv_key
237            .sign(Pkcs1v15Sign::new::<Sha256>(), &hash_to_sign)
238            .map_err(|e| StorageError::ApiError(format!("Signing failed: {}", e)))?;
239
240        let signature_hex = hex::encode(signature);
241
242        let final_url = format!(
243            "https://{}{}?{}&X-Goog-Signature={}",
244            host, canonical_uri, canonical_query_string, signature_hex
245        );
246
247        Ok(final_url)
248    }
249
250    /// Uploads data to the file.
251    ///
252    /// This method uses the simple upload API.
253    ///
254    /// # Arguments
255    ///
256    /// * `body` - The data to upload.
257    /// * `mime_type` - The MIME type of the data.
258    pub async fn save(
259        &self,
260        body: impl Into<reqwest::Body>,
261        mime_type: &str,
262    ) -> Result<(), StorageError> {
263        // Upload endpoint: https://storage.googleapis.com/upload/storage/v1/b/[BUCKET_NAME]/o
264        // For testing purposes (or if base_url is not the default), we construct the upload URL from base_url.
265        // If base_url is "https://storage.googleapis.com/storage/v1", we change it to "https://storage.googleapis.com/upload/storage/v1".
266        // If it's something else (e.g. mock server), we just append /upload or similar?
267        // Actually, GCS convention is tricky.
268        // If standard GCS URL, we use the upload subdomain.
269        // If using a mock (base_url doesn't contain storage.googleapis.com), we might want to assume the mock handles uploads under the same host but maybe different path?
270        // For simplicity and enabling tests, let's trust that if the user overrides base_url, they might be pointing to an emulator or mock.
271
272        let url = if self.base_url.contains("storage.googleapis.com/storage/v1") {
273            format!(
274                "https://storage.googleapis.com/upload/storage/v1/b/{}/o",
275                self.bucket_name
276            )
277        } else {
278            // Assume mock/emulator environment where we might append /upload prefix or similar relative to base?
279            // Or better: replace "/storage/v1" with "/upload/storage/v1" if present.
280            if self.base_url.contains("/storage/v1") {
281                let upload_base = self.base_url.replace("/storage/v1", "/upload/storage/v1");
282                format!("{}/b/{}/o", upload_base, self.bucket_name)
283            } else {
284                // Fallback: just append /upload if it doesn't match known patterns?
285                // Or just use base_url as is, assuming the caller set it to the root of the API including 'upload' capability if needed?
286                // But `download` uses `base_url` too.
287                // Let's try to be smart for the mock server in tests.
288                // In tests: base_url is `http://127.0.0.1:PORT`.
289                // We want `http://127.0.0.1:PORT/upload/storage/v1...`
290                // But `download` uses `http://127.0.0.1:PORT/b/...` (which implies base_url was root-ish or included /storage/v1?)
291                // In `FirebaseStorage::new`, base_url is `https://storage.googleapis.com/storage/v1`.
292                // So `download` appends `/b/...` resulting in `.../storage/v1/b/...`.
293                // If I set mock url as base_url, say `http://host:port`, `download` does `http://host:port/b/...`.
294                // So for upload, I should probably target `http://host:port/upload/storage/v1/b/...`?
295                // Let's try prepending `/upload` to the path relative to the server root, but `base_url` might have a path.
296
297                // If base_url ends in `/storage/v1` (standard or emulated), switch to `/upload/storage/v1`.
298                if self.base_url.ends_with("/storage/v1") {
299                    let upload_base = self.base_url.replace("/storage/v1", "/upload/storage/v1");
300                    format!("{}/b/{}/o", upload_base, self.bucket_name)
301                } else {
302                    // If strictly just a host, maybe we are mocking specific paths.
303                    // Let's just fallback to standard behavior if we can't deduce.
304                    // But for tests we need it to work.
305                    // Let's assume for tests we want to hit `/upload/storage/v1` on the mock server if base_url is root.
306                    // If base_url is `http://localhost:1234`, we want `http://localhost:1234/upload/storage/v1/b/...`?
307                    format!(
308                        "{}/upload/storage/v1/b/{}/o",
309                        self.base_url, self.bucket_name
310                    )
311                }
312            }
313        };
314
315        let mut url_obj = Url::parse(&url).map_err(|e| StorageError::ApiError(e.to_string()))?;
316        url_obj
317            .query_pairs_mut()
318            .append_pair("uploadType", "media")
319            .append_pair("name", &self.name);
320
321        let response = self
322            .client
323            .post(url_obj)
324            .header(header::CONTENT_TYPE, mime_type)
325            .body(body)
326            .send()
327            .await?;
328
329        if !response.status().is_success() {
330            let status = response.status();
331            let text = response.text().await.unwrap_or_default();
332            return Err(StorageError::ApiError(format!(
333                "Upload failed {}: {}",
334                status, text
335            )));
336        }
337
338        Ok(())
339    }
340
341    /// Downloads the file's content.
342    pub async fn download(&self) -> Result<bytes::Bytes, StorageError> {
343        // Download endpoint: https://storage.googleapis.com/storage/v1/b/[BUCKET_NAME]/o/[OBJECT_NAME]?alt=media
344        // Object name must be URL-encoded.
345        let encoded_name =
346            url::form_urlencoded::byte_serialize(self.name.as_bytes()).collect::<String>();
347        let url = format!(
348            "{}/b/{}/o/{}",
349            self.base_url, self.bucket_name, encoded_name
350        );
351
352        let mut url_obj = Url::parse(&url).map_err(|e| StorageError::ApiError(e.to_string()))?;
353        url_obj.query_pairs_mut().append_pair("alt", "media");
354
355        let response = self.client.get(url_obj).send().await?;
356
357        if !response.status().is_success() {
358            let status = response.status();
359            let text = response.text().await.unwrap_or_default();
360            return Err(StorageError::ApiError(format!(
361                "Download failed {}: {}",
362                status, text
363            )));
364        }
365
366        Ok(response.bytes().await?)
367    }
368
369    /// Deletes the file.
370    pub async fn delete(&self) -> Result<(), StorageError> {
371        let encoded_name =
372            url::form_urlencoded::byte_serialize(self.name.as_bytes()).collect::<String>();
373        let url = format!(
374            "{}/b/{}/o/{}",
375            self.base_url, self.bucket_name, encoded_name
376        );
377
378        let response = self.client.delete(&url).send().await?;
379
380        if !response.status().is_success() {
381            let status = response.status();
382            let text = response.text().await.unwrap_or_default();
383            return Err(StorageError::ApiError(format!(
384                "Delete failed {}: {}",
385                status, text
386            )));
387        }
388
389        Ok(())
390    }
391
392    /// Gets the file's metadata.
393    pub async fn get_metadata(&self) -> Result<ObjectMetadata, StorageError> {
394        let encoded_name =
395            url::form_urlencoded::byte_serialize(self.name.as_bytes()).collect::<String>();
396        let url = format!(
397            "{}/b/{}/o/{}",
398            self.base_url, self.bucket_name, encoded_name
399        );
400
401        let response = self.client.get(&url).send().await?;
402
403        if !response.status().is_success() {
404            let status = response.status();
405            let text = response.text().await.unwrap_or_default();
406            return Err(StorageError::ApiError(format!(
407                "Get metadata failed {}: {}",
408                status, text
409            )));
410        }
411
412        Ok(response.json().await?)
413    }
414
415    /// Sets the file's metadata.
416    ///
417    /// This method uses the PATCH method to update the file's metadata.
418    /// Only non-null fields in the provided `metadata` object will be updated.
419    ///
420    /// # Arguments
421    ///
422    /// * `metadata` - The metadata to set.
423    pub async fn set_metadata(&self, metadata: &ObjectMetadata) -> Result<ObjectMetadata, StorageError> {
424        let encoded_name =
425            url::form_urlencoded::byte_serialize(self.name.as_bytes()).collect::<String>();
426        let url = format!(
427            "{}/b/{}/o/{}",
428            self.base_url, self.bucket_name, encoded_name
429        );
430
431        let response = self
432            .client
433            .patch(&url)
434            .header(header::CONTENT_TYPE, "application/json")
435            .json(metadata)
436            .send()
437            .await?;
438
439        if !response.status().is_success() {
440            let status = response.status();
441            let text = response.text().await.unwrap_or_default();
442            return Err(StorageError::ApiError(format!(
443                "Set metadata failed {}: {}",
444                status, text
445            )));
446        }
447
448        Ok(response.json().await?)
449    }
450}