1use std::sync::Arc;
14
15use crate::crypto;
16use crate::http::{HttpError, RestClient};
17use crate::rest::{ContentType, PushRequest};
18use crate::store::models::StoredClip;
19use crate::store::{queries, Store, StoreError};
20
21#[derive(Debug, Clone)]
25pub enum PushOutcome {
26 Synced(String),
28 Queued(String),
31}
32
33#[derive(Clone)]
39pub struct LocalPusher {
40 store: Arc<Store>,
41 client: Arc<RestClient>,
42 enc_key: Option<[u8; 32]>,
43}
44
45#[derive(Debug, thiserror::Error)]
46pub enum IngestError {
47 #[error("no encryption key available — clip dropped (E2EE required)")]
51 NoEncryptionKey,
52 #[error("encryption failed: {0}")]
53 Crypto(String),
54 #[error("relay push failed: {0}")]
55 Push(#[from] HttpError),
56 #[error("local store write failed: {0}")]
57 Store(#[from] StoreError),
58}
59
60impl LocalPusher {
61 pub fn new(store: Arc<Store>, client: Arc<RestClient>, enc_key: Option<[u8; 32]>) -> Self {
62 Self {
63 store,
64 client,
65 enc_key,
66 }
67 }
68
69 pub async fn push_text(
79 &self,
80 raw: Vec<u8>,
81 source: &str,
82 label: &str,
83 content_type: ContentType,
84 ) -> Result<PushOutcome, IngestError> {
85 let original_size = raw.len() as i64;
86
87 if let Some(key) = self.enc_key {
88 match self
89 .try_push_text_online(&key, &raw, source, label, content_type, original_size)
90 .await
91 {
92 Ok(clip_id) => return Ok(PushOutcome::Synced(clip_id)),
93 Err(IngestError::Push(_)) => {
94 }
96 Err(e) => return Err(e),
97 }
98 }
99
100 let clip_id = crate::sync::backlog_flusher::enqueue_local(
101 &self.store,
102 source,
103 label,
104 content_type.as_wire(),
105 raw,
106 original_size,
107 )?;
108 Ok(PushOutcome::Queued(clip_id))
109 }
110
111 pub async fn push_image_png(
116 &self,
117 raw_png: Vec<u8>,
118 source: &str,
119 label: &str,
120 ) -> Result<PushOutcome, IngestError> {
121 let original_size = raw_png.len() as i64;
122
123 if let Some(key) = self.enc_key {
124 match self
125 .try_push_image_png_online(&key, &raw_png, source, label, original_size)
126 .await
127 {
128 Ok(clip_id) => return Ok(PushOutcome::Synced(clip_id)),
129 Err(IngestError::Push(_)) => {
130 }
132 Err(e) => return Err(e),
133 }
134 }
135
136 let clip_id = crate::sync::backlog_flusher::enqueue_local(
137 &self.store,
138 source,
139 label,
140 ContentType::Image.as_wire(),
141 raw_png,
142 original_size,
143 )?;
144 Ok(PushOutcome::Queued(clip_id))
145 }
146
147 async fn try_push_text_online(
148 &self,
149 key: &[u8; 32],
150 raw: &[u8],
151 source: &str,
152 label: &str,
153 content_type: ContentType,
154 original_size: i64,
155 ) -> Result<String, IngestError> {
156 let ciphertext = crypto::encrypt(key, raw).map_err(IngestError::Crypto)?;
157 let wire = content_type.as_wire();
158 let req = PushRequest {
159 content: ciphertext,
160 content_type: wire.to_string(),
161 label: label.to_string(),
162 source: source.to_string(),
163 media_path: None,
164 byte_size: original_size,
165 encrypted: true,
166 target_device_id: None,
167 client_created_at: None,
168 idempotency_key: None,
169 };
170 let resp = self.client.push_clip_json(&req).await?;
171 self.write_through(&resp.clip_id, source, wire, raw.to_vec(), original_size)?;
172 Ok(resp.clip_id)
173 }
174
175 async fn try_push_image_png_online(
176 &self,
177 key: &[u8; 32],
178 raw_png: &[u8],
179 source: &str,
180 label: &str,
181 original_size: i64,
182 ) -> Result<String, IngestError> {
183 let ciphertext = crypto::encrypt(key, raw_png).map_err(IngestError::Crypto)?;
184 let req = PushRequest {
185 content: ciphertext,
186 content_type: ContentType::Image.as_wire().into(),
187 label: label.to_string(),
188 source: source.to_string(),
189 media_path: None,
190 byte_size: original_size,
191 encrypted: true,
192 target_device_id: None,
193 client_created_at: None,
194 idempotency_key: None,
195 };
196 let resp = self.client.push_clip_json(&req).await?;
197 self.write_through(
198 &resp.clip_id,
199 source,
200 ContentType::Image.as_wire(),
201 raw_png.to_vec(),
202 original_size,
203 )?;
204 Ok(resp.clip_id)
205 }
206
207 fn write_through(
208 &self,
209 clip_id: &str,
210 source: &str,
211 content_type: &str,
212 raw: Vec<u8>,
213 byte_size: i64,
214 ) -> Result<(), IngestError> {
215 let stored = StoredClip {
216 id: clip_id.to_string(),
217 source: source.to_string(),
218 source_key: None,
219 content_type: content_type.to_string(),
220 content: Some(raw),
221 media_path: None,
222 byte_size,
223 created_at: chrono::Utc::now().timestamp_millis(),
224 pinned: false,
225 pinned_at: None,
226 synced: true,
227 };
228 queries::insert_clip(&self.store, &stored)?;
229 let _ = queries::set_watermark(&self.store, clip_id);
231 Ok(())
232 }
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238 use crate::store::queries;
239 use std::sync::Arc;
240
241 fn fresh_store() -> Arc<Store> {
242 Arc::new(Store::open(std::path::Path::new(":memory:")).unwrap())
243 }
244
245 fn offline_client() -> Arc<RestClient> {
246 Arc::new(RestClient::for_test_offline())
247 }
248
249 #[tokio::test]
250 async fn push_text_queues_when_no_key() {
251 let store = fresh_store();
252 let pusher = LocalPusher::new(store.clone(), offline_client(), None);
253 let outcome = pusher
254 .push_text(
255 b"hello".to_vec(),
256 "remote:host",
257 "",
258 crate::rest::ContentType::Text,
259 )
260 .await
261 .expect("push_text");
262 match outcome {
263 PushOutcome::Queued(id) => assert!(id.starts_with("local-")),
264 PushOutcome::Synced(_) => panic!("expected Queued, got Synced"),
265 }
266 let rows = queries::list_unsynced_clips(&store).unwrap();
267 assert_eq!(rows.len(), 1);
268 assert_eq!(rows[0].content.as_deref(), Some(&b"hello"[..]));
269 }
270
271 #[tokio::test]
272 async fn push_text_queues_when_network_fails() {
273 let store = fresh_store();
274 let pusher = LocalPusher::new(store.clone(), offline_client(), Some([9u8; 32]));
275 let outcome = pusher
276 .push_text(
277 b"hello".to_vec(),
278 "remote:host",
279 "",
280 crate::rest::ContentType::Text,
281 )
282 .await
283 .expect("push_text");
284 assert!(matches!(outcome, PushOutcome::Queued(_)));
285 }
286
287 #[tokio::test]
288 async fn push_image_png_queues_when_no_key() {
289 let store = fresh_store();
290 let pusher = LocalPusher::new(store.clone(), offline_client(), None);
291 let outcome = pusher
292 .push_image_png(b"\x89PNG\r\n\x1a\n".to_vec(), "remote:host", "")
293 .await
294 .expect("push_image_png");
295 match outcome {
296 PushOutcome::Queued(id) => assert!(id.starts_with("local-")),
297 PushOutcome::Synced(_) => panic!("expected Queued, got Synced"),
298 }
299 let rows = queries::list_unsynced_clips(&store).unwrap();
300 assert_eq!(rows.len(), 1);
301 assert_eq!(rows[0].content_type, "image");
302 }
303
304 #[tokio::test]
305 async fn push_image_png_queues_when_network_fails() {
306 let store = fresh_store();
307 let pusher = LocalPusher::new(store.clone(), offline_client(), Some([9u8; 32]));
308 let outcome = pusher
309 .push_image_png(b"\x89PNG\r\n\x1a\n".to_vec(), "remote:host", "")
310 .await
311 .expect("push_image_png");
312 assert!(matches!(outcome, PushOutcome::Queued(_)));
313 }
314}