Skip to main content

supreme_fs/game_storage/online/
transfer.rs

1//! Uploading, committing, and fetching whole snapshot files (profiles and
2//! replays), including local `.supreme/<subdirectory>/` caching.
3
4use super::util::ensure_json_extension;
5use crate::game_storage::GameStorage;
6use crate::{HybridStorageError, OnlineStorageError, SnapshotEvent};
7use std::fs;
8use std::io;
9use std::sync::mpsc::Sender;
10
11impl GameStorage {
12    pub fn upload_profile(
13        &self,
14        profile_name: &str,
15        data: &[u8],
16        tx: Option<Sender<SnapshotEvent>>,
17    ) -> Result<(), HybridStorageError> {
18        self.save_and_upload_snapshot("profiles", profile_name, data, tx)
19    }
20
21    /// Upload a replay snapshot (json file) to the backend.
22    pub fn upload_replay(
23        &self,
24        snapshot_name: &str,
25        data: &[u8],
26        tx: Option<Sender<SnapshotEvent>>,
27    ) -> Result<(), HybridStorageError> {
28        self.save_and_upload_snapshot("replays", snapshot_name, data, tx)
29    }
30
31    fn save_and_upload_snapshot(
32        &self,
33        subdirectory: &str,
34        name: &str,
35        data: &[u8],
36        tx: Option<Sender<SnapshotEvent>>,
37    ) -> Result<(), HybridStorageError> {
38        let filename = ensure_json_extension(name);
39
40        // Standardize path: .supreme/<subdirectory>/<filename>
41        let local_path = self
42            .root_path
43            .join(".supreme")
44            .join(subdirectory)
45            .join(&filename);
46
47        fs::create_dir_all(
48            local_path
49                .parent()
50                .ok_or_else(|| io::Error::other("Local path has no parent directory"))?,
51        )?;
52        fs::write(&local_path, data)?;
53
54        if self.api_url.is_some() {
55            self.upload_snapshot_generic(subdirectory, name, data, tx)?;
56        }
57
58        Ok(())
59    }
60
61    fn upload_snapshot_generic(
62        &self,
63        subdirectory: &str,
64        _name: &str,
65        data: &[u8],
66        tx: Option<Sender<SnapshotEvent>>,
67    ) -> Result<(), HybridStorageError> {
68        if self.api_url.is_some() {
69            // Use unified chunked upload for all files
70            return self.upload_file_unified(subdirectory, data, "snapshot", None, tx);
71        }
72
73        Ok(())
74    }
75
76    /// Commit a staged snapshot.
77    pub fn commit_snapshot(&self, snapshot_name: &str) -> Result<(), HybridStorageError> {
78        self.commit_snapshot_type("replays", snapshot_name)
79    }
80
81    pub fn commit_snapshot_type(
82        &self,
83        subdirectory: &str,
84        snapshot_filename_with_ext: &str,
85    ) -> Result<(), HybridStorageError> {
86        use super::multipart::{MultipartForm, SendMultipart};
87        let client = self
88            .client
89            .as_ref()
90            .ok_or_else(|| io::Error::other("Online storage not configured"))?;
91        let api_url = self
92            .api_url
93            .as_ref()
94            .ok_or_else(|| io::Error::other("Online storage not configured"))?;
95
96        let mut form = MultipartForm::new()
97            .text("action", "commit_snapshot")
98            .text("name", snapshot_filename_with_ext)
99            .text("type", subdirectory);
100
101        if let Some(token) = &self.token {
102            form = form.text("api_token", token.clone());
103        }
104
105        let mut request = client.post(api_url);
106        if let Some(ref token) = self.token {
107            request = request.header("X-SSBZ-Token", token);
108        }
109        let response = request.send_multipart(form)?;
110
111        if !response.status().is_success() {
112            return Err(HybridStorageError::UploadFailed(
113                self.extract_error_message(response),
114            ));
115        }
116        Ok(())
117    }
118
119    /// Fetch a replay snapshot JSON by name.
120    /// Tries local .supreme/replays first, then online at /replays/.
121    pub fn fetch_replay(&self, snapshot_name: &str) -> Result<Vec<u8>, HybridStorageError> {
122        self.fetch_and_cache_snapshot("replays", snapshot_name)
123    }
124
125    pub(crate) fn fetch_and_cache_snapshot(
126        &self,
127        subdirectory: &str,
128        name: &str,
129    ) -> Result<Vec<u8>, HybridStorageError> {
130        let filename = ensure_json_extension(name);
131
132        // Standardize path: .supreme/<subdirectory>/<filename>
133        let local_path = self
134            .root_path
135            .join(".supreme")
136            .join(subdirectory)
137            .join(&filename);
138
139        if local_path.exists() {
140            return Ok(fs::read(&local_path)?);
141        }
142
143        if self.api_url.is_some() {
144            let data = self.fetch_snapshot(subdirectory, &filename)?;
145            // Cache locally
146            fs::create_dir_all(
147                local_path
148                    .parent()
149                    .ok_or_else(|| io::Error::other("Local path has no parent directory"))?,
150            )?;
151            fs::write(&local_path, &data)?;
152            Ok(data)
153        } else {
154            Err(HybridStorageError::NotFound(filename))
155        }
156    }
157
158    fn fetch_snapshot(
159        &self,
160        subdirectory: &str,
161        filename: &str,
162    ) -> Result<Vec<u8>, HybridStorageError> {
163        let client = self
164            .client
165            .as_ref()
166            .ok_or_else(|| io::Error::other("Online storage not configured"))?;
167        let base_url = self
168            .api_url
169            .as_ref()
170            .ok_or_else(|| io::Error::other("Online storage not configured"))?
171            .trim_end_matches('/');
172
173        let filename_json = ensure_json_extension(filename);
174        let url = format!("{base_url}/{subdirectory}/{filename_json}");
175
176        let response = client.get(&url).call()?;
177        if response.status().is_success() {
178            let mut bytes = Vec::new();
179            use std::io::Read;
180            response.into_body().into_reader().read_to_end(&mut bytes)?;
181            Ok(bytes)
182        } else {
183            Err(OnlineStorageError::NotFound(format!("URL: {subdirectory}/{filename_json}")).into())
184        }
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use tempfile::tempdir;
192
193    #[test]
194    fn fetch_and_cache_snapshot_reads_from_local_cache_first() {
195        let dir = tempdir().unwrap();
196        let storage = GameStorage::new(dir.path());
197        let cache_dir = dir.path().join(".supreme").join("replays");
198        fs::create_dir_all(&cache_dir).unwrap();
199        fs::write(cache_dir.join("my_replay.json"), b"cached data").unwrap();
200
201        let data = storage
202            .fetch_and_cache_snapshot("replays", "my_replay")
203            .expect("fetch_and_cache_snapshot");
204        assert_eq!(data, b"cached data");
205    }
206
207    #[test]
208    fn fetch_and_cache_snapshot_returns_not_found_when_offline_and_uncached() {
209        let dir = tempdir().unwrap();
210        let storage = GameStorage::new(dir.path());
211        let result = storage.fetch_and_cache_snapshot("replays", "missing");
212        assert!(matches!(result, Err(HybridStorageError::NotFound(_))));
213    }
214
215    #[test]
216    fn save_and_upload_snapshot_writes_local_cache_even_when_offline() {
217        let dir = tempdir().unwrap();
218        let storage = GameStorage::new(dir.path());
219
220        storage
221            .upload_profile("my_profile", b"profile-bytes", None)
222            .expect("upload_profile should succeed locally when offline");
223
224        let cached = dir
225            .path()
226            .join(".supreme")
227            .join("profiles")
228            .join("my_profile.json");
229        assert!(cached.exists());
230        assert_eq!(fs::read(cached).unwrap(), b"profile-bytes");
231    }
232}