firebase_rs_sdk/storage/
service.rs

1use std::sync::{Arc, Mutex};
2use std::time::Duration;
3
4use crate::app::FirebaseApp;
5use crate::app_check::FirebaseAppCheckInternal;
6use crate::auth::Auth;
7use crate::component::Provider;
8use crate::storage::constants::{
9    DEFAULT_HOST, DEFAULT_MAX_OPERATION_RETRY_TIME_MS, DEFAULT_MAX_UPLOAD_RETRY_TIME_MS,
10    DEFAULT_PROTOCOL,
11};
12use crate::storage::error::{internal_error, no_default_bucket, StorageResult};
13use crate::storage::location::Location;
14use crate::storage::reference::StorageReference;
15use crate::storage::request::{BackoffConfig, HttpClient, RequestInfo};
16use crate::storage::util::is_url;
17
18#[derive(Clone)]
19pub struct FirebaseStorageImpl {
20    app: FirebaseApp,
21    auth_provider: Provider,
22    app_check_provider: Provider,
23    firebase_version: Option<String>,
24    url_override: Option<String>,
25    state: Arc<Mutex<FirebaseStorageState>>,
26}
27
28struct FirebaseStorageState {
29    bucket: Option<Location>,
30    host: String,
31    protocol: String,
32    max_operation_retry_time_ms: u64,
33    max_upload_retry_time_ms: u64,
34    override_auth_token: Option<String>,
35    is_using_emulator: bool,
36}
37
38impl FirebaseStorageImpl {
39    #[allow(clippy::too_many_arguments)]
40    pub fn new(
41        app: FirebaseApp,
42        auth_provider: Provider,
43        app_check_provider: Provider,
44        url_override: Option<String>,
45        firebase_version: Option<String>,
46    ) -> StorageResult<Self> {
47        let host = DEFAULT_HOST.to_string();
48        let bucket = if let Some(url) = url_override.as_ref() {
49            Some(Location::from_bucket_spec(url, &host)?)
50        } else {
51            extract_bucket(&host, &app)?
52        };
53
54        let state = FirebaseStorageState {
55            bucket,
56            host,
57            protocol: DEFAULT_PROTOCOL.to_string(),
58            max_operation_retry_time_ms: DEFAULT_MAX_OPERATION_RETRY_TIME_MS,
59            max_upload_retry_time_ms: DEFAULT_MAX_UPLOAD_RETRY_TIME_MS,
60            override_auth_token: None,
61            is_using_emulator: false,
62        };
63
64        Ok(Self {
65            app,
66            auth_provider,
67            app_check_provider,
68            firebase_version,
69            url_override,
70            state: Arc::new(Mutex::new(state)),
71        })
72    }
73
74    pub fn app(&self) -> &FirebaseApp {
75        &self.app
76    }
77
78    pub fn host(&self) -> String {
79        self.state.lock().unwrap().host.clone()
80    }
81
82    pub fn protocol(&self) -> String {
83        self.state.lock().unwrap().protocol.clone()
84    }
85
86    pub fn auth_provider(&self) -> Provider {
87        self.auth_provider.clone()
88    }
89
90    pub fn app_check_provider(&self) -> Provider {
91        self.app_check_provider.clone()
92    }
93
94    pub fn firebase_version(&self) -> Option<&str> {
95        self.firebase_version.as_deref()
96    }
97
98    pub fn bucket(&self) -> Option<Location> {
99        self.state.lock().unwrap().bucket.clone()
100    }
101
102    pub fn max_upload_retry_time(&self) -> u64 {
103        self.state.lock().unwrap().max_upload_retry_time_ms
104    }
105
106    pub fn max_operation_retry_time(&self) -> u64 {
107        self.state.lock().unwrap().max_operation_retry_time_ms
108    }
109
110    pub fn set_max_upload_retry_time(&self, millis: u64) {
111        self.state.lock().unwrap().max_upload_retry_time_ms = millis;
112    }
113
114    pub fn set_max_operation_retry_time(&self, millis: u64) {
115        self.state.lock().unwrap().max_operation_retry_time_ms = millis;
116    }
117
118    pub fn is_using_emulator(&self) -> bool {
119        self.state.lock().unwrap().is_using_emulator
120    }
121
122    pub fn connect_emulator(
123        &self,
124        host: &str,
125        port: u16,
126        mock_user_token: Option<String>,
127    ) -> StorageResult<()> {
128        let host_string = format!("{host}:{port}");
129        let bucket = self.compute_bucket_for_host(&host_string)?;
130        let mut state = self.state.lock().unwrap();
131        state.host = host_string;
132        state.bucket = bucket;
133        state.protocol = "http".to_string();
134        state.is_using_emulator = true;
135        state.override_auth_token = mock_user_token;
136        Ok(())
137    }
138
139    pub fn set_host(&self, host: &str) -> StorageResult<()> {
140        let bucket = self.compute_bucket_for_host(host)?;
141        let mut state = self.state.lock().unwrap();
142        state.host = host.to_string();
143        state.bucket = bucket;
144        Ok(())
145    }
146
147    fn compute_bucket_for_host(&self, host: &str) -> StorageResult<Option<Location>> {
148        if let Some(url) = self.url_override.as_ref() {
149            Ok(Some(Location::from_bucket_spec(url, host)?))
150        } else {
151            extract_bucket(host, &self.app)
152        }
153    }
154
155    pub fn make_storage_reference(&self, location: Location) -> StorageReference {
156        StorageReference::new(self.clone(), location)
157    }
158
159    pub fn root_reference(&self) -> StorageResult<StorageReference> {
160        let state = self.state.lock().unwrap();
161        let bucket = state.bucket.clone().ok_or_else(no_default_bucket)?;
162        Ok(StorageReference::new(self.clone(), bucket))
163    }
164
165    pub fn reference_from_path(&self, path: Option<&str>) -> StorageResult<StorageReference> {
166        let location = match path {
167            Some(path) if is_url(path) => Location::from_url(path, &self.host())?,
168            Some(path) => {
169                let base = self.bucket().ok_or_else(no_default_bucket)?;
170                let child_path = crate::storage::path::child(base.path(), path);
171                Location::new(base.bucket(), child_path)
172            }
173            None => self.bucket().ok_or_else(no_default_bucket)?,
174        };
175        Ok(StorageReference::new(self.clone(), location))
176    }
177
178    pub fn http_client(&self) -> StorageResult<HttpClient> {
179        let timeout = Duration::from_millis(self.max_operation_retry_time());
180        let config = BackoffConfig::standard_operation().with_total_timeout(timeout);
181        HttpClient::new(self.is_using_emulator(), config)
182    }
183
184    pub fn upload_http_client(&self) -> StorageResult<HttpClient> {
185        let timeout = Duration::from_millis(self.max_upload_retry_time());
186        let config = BackoffConfig::upload_operation(timeout);
187        HttpClient::new(self.is_using_emulator(), config)
188    }
189
190    pub fn run_request<O>(&self, info: RequestInfo<O>) -> StorageResult<O> {
191        let client = self.http_client()?;
192        let info = self.prepare_request(info)?;
193        client.execute(info)
194    }
195
196    pub fn run_upload_request<O>(&self, info: RequestInfo<O>) -> StorageResult<O> {
197        let client = self.upload_http_client()?;
198        let info = self.prepare_request(info)?;
199        client.execute(info)
200    }
201
202    fn prepare_request<O>(&self, mut info: RequestInfo<O>) -> StorageResult<RequestInfo<O>> {
203        if let Some(token) = self.auth_token()? {
204            if !token.is_empty() {
205                info.headers
206                    .insert("Authorization".to_string(), format!("Firebase {token}"));
207            }
208        }
209
210        if let Some(token) = self.app_check_token()? {
211            if !token.is_empty() {
212                info.headers
213                    .insert("X-Firebase-AppCheck".to_string(), token);
214            }
215        }
216
217        if !info.headers.contains_key("X-Firebase-Storage-Version") {
218            let version = format!(
219                "webjs/{}",
220                self.firebase_version.as_deref().unwrap_or("AppManager")
221            );
222            info.headers
223                .insert("X-Firebase-Storage-Version".to_string(), version);
224        }
225
226        if let Some(app_id) = self.app.options().app_id {
227            if !app_id.is_empty() {
228                info.headers
229                    .entry("X-Firebase-GMPID".to_string())
230                    .or_insert(app_id);
231            }
232        }
233
234        Ok(info)
235    }
236
237    fn auth_token(&self) -> StorageResult<Option<String>> {
238        if let Some(token) = {
239            let state = self.state.lock().unwrap();
240            state.override_auth_token.clone()
241        } {
242            return Ok(Some(token));
243        }
244
245        let auth = match self
246            .auth_provider
247            .get_immediate_with_options::<Auth>(None, true)
248        {
249            Ok(Some(auth)) => auth,
250            Ok(None) => return Ok(None),
251            Err(err) => {
252                return Err(internal_error(format!(
253                    "failed to resolve auth provider: {err}"
254                )))
255            }
256        };
257
258        match auth.get_token(false) {
259            Ok(Some(token)) if token.is_empty() => Ok(None),
260            Ok(Some(token)) => Ok(Some(token)),
261            Ok(None) => Ok(None),
262            Err(err) => Err(internal_error(format!(
263                "failed to obtain auth token: {err}"
264            ))),
265        }
266    }
267
268    fn app_check_token(&self) -> StorageResult<Option<String>> {
269        let app_check = match self
270            .app_check_provider
271            .get_immediate_with_options::<FirebaseAppCheckInternal>(None, true)
272        {
273            Ok(Some(app_check)) => app_check,
274            Ok(None) => return Ok(None),
275            Err(err) => {
276                return Err(internal_error(format!(
277                    "failed to resolve app check provider: {err}"
278                )))
279            }
280        };
281
282        let result = app_check
283            .get_token(false)
284            .map_err(|err| internal_error(format!("failed to obtain App Check token: {err}")))?;
285
286        if let Some(error) = result.error {
287            return Err(internal_error(error.to_string()));
288        }
289        if let Some(error) = result.internal_error {
290            return Err(internal_error(error.to_string()));
291        }
292
293        if result.token.is_empty() {
294            Ok(None)
295        } else {
296            Ok(Some(result.token))
297        }
298    }
299}
300
301fn extract_bucket(host: &str, app: &FirebaseApp) -> StorageResult<Option<Location>> {
302    let options = app.options();
303    match options.storage_bucket {
304        Some(bucket) => Ok(Some(Location::from_bucket_spec(&bucket, host)?)),
305        None => Ok(None),
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use crate::app::api::initialize_app;
313    use crate::app::{FirebaseAppSettings, FirebaseOptions};
314    use crate::app_check::api::{initialize_app_check, token_with_ttl};
315    use crate::app_check::{AppCheckOptions, AppCheckProvider, AppCheckToken};
316    use crate::component::types::{ComponentError, DynService, InstanceFactoryOptions};
317    use crate::component::{Component, ComponentType};
318    use crate::storage::request::{RequestInfo, ResponseHandler};
319    use reqwest::Method;
320    use std::sync::atomic::{AtomicUsize, Ordering};
321    use std::sync::Arc;
322    use std::time::Duration;
323
324    fn unique_settings(prefix: &str) -> FirebaseAppSettings {
325        static COUNTER: AtomicUsize = AtomicUsize::new(0);
326        FirebaseAppSettings {
327            name: Some(format!(
328                "{prefix}-{}",
329                COUNTER.fetch_add(1, Ordering::SeqCst)
330            )),
331            ..Default::default()
332        }
333    }
334
335    fn base_options() -> FirebaseOptions {
336        FirebaseOptions {
337            storage_bucket: Some("my-bucket".into()),
338            app_id: Some("1:123:web:abc".into()),
339            ..Default::default()
340        }
341    }
342
343    fn test_request() -> RequestInfo<()> {
344        let handler: ResponseHandler<()> = Arc::new(|_| Ok(()));
345        RequestInfo::new(
346            "https://example.com",
347            Method::GET,
348            Duration::from_secs(5),
349            handler,
350        )
351    }
352
353    fn build_storage_with<F>(configure: F) -> FirebaseStorageImpl
354    where
355        F: Fn(&FirebaseApp),
356    {
357        let app = initialize_app(base_options(), Some(unique_settings("storage-service")))
358            .expect("failed to initialize app");
359        configure(&app);
360
361        let container = app.container();
362        let auth_provider = container.get_provider("auth-internal");
363        let app_check_provider = container.get_provider("app-check-internal");
364        FirebaseStorageImpl::new(
365            app,
366            auth_provider,
367            app_check_provider,
368            None,
369            Some("test-sdk".into()),
370        )
371        .expect("storage construction should succeed")
372    }
373
374    #[test]
375    fn prepare_request_adds_headers_for_emulator_override() {
376        let storage = build_storage_with(|_| {});
377        storage
378            .connect_emulator("localhost", 9199, Some("mock-token".into()))
379            .unwrap();
380
381        let prepared = storage.prepare_request(test_request()).unwrap();
382
383        assert_eq!(
384            prepared.headers.get("Authorization"),
385            Some(&"Firebase mock-token".to_string())
386        );
387
388        let expected_version = format!(
389            "webjs/{}",
390            storage.firebase_version().unwrap_or("AppManager")
391        );
392        assert_eq!(
393            prepared.headers.get("X-Firebase-Storage-Version"),
394            Some(&expected_version)
395        );
396
397        assert_eq!(
398            prepared.headers.get("X-Firebase-GMPID"),
399            Some(&"1:123:web:abc".to_string())
400        );
401
402        assert!(prepared.headers.get("X-Firebase-AppCheck").is_none());
403    }
404
405    #[derive(Clone)]
406    struct StaticAppCheckProvider;
407
408    impl AppCheckProvider for StaticAppCheckProvider {
409        fn get_token(&self) -> crate::app_check::AppCheckResult<AppCheckToken> {
410            token_with_ttl("app-check-token", Duration::from_secs(60))
411        }
412    }
413
414    fn register_app_check(app: &FirebaseApp) {
415        let app_clone = app.clone();
416        let factory =
417            Arc::new(
418                move |_: &crate::component::ComponentContainer,
419                      _: InstanceFactoryOptions|
420                      -> Result<DynService, ComponentError> {
421                    let provider = Arc::new(StaticAppCheckProvider);
422                    let options = AppCheckOptions::new(provider);
423                    let app_check = initialize_app_check(Some(app_clone.clone()), options)
424                        .map_err(|err| ComponentError::InitializationFailed {
425                            name: "app-check-internal".to_string(),
426                            reason: err.to_string(),
427                        })?;
428                    Ok(Arc::new(FirebaseAppCheckInternal::new(app_check)) as DynService)
429                },
430            );
431
432        let component = Component::new("app-check-internal", factory, ComponentType::Private);
433        app.container().add_or_overwrite_component(component);
434    }
435
436    #[test]
437    fn prepare_request_includes_app_check_header_when_available() {
438        let storage = build_storage_with(|app| register_app_check(app));
439        let prepared = storage.prepare_request(test_request()).unwrap();
440
441        assert_eq!(
442            prepared.headers.get("X-Firebase-AppCheck"),
443            Some(&"app-check-token".to_string())
444        );
445    }
446}