client_core/sync/
local_pusher.rs1use std::sync::Arc;
19
20use crate::crypto;
21use crate::http::{HttpError, RestClient};
22use crate::rest::{ContentType, PushRequest};
23use crate::store::models::StoredClip;
24use crate::store::{queries, Store, StoreError};
25
26#[derive(Clone)]
32pub struct LocalPusher {
33 store: Arc<Store>,
34 client: Arc<RestClient>,
35 enc_key: Option<[u8; 32]>,
36}
37
38#[derive(Debug, thiserror::Error)]
39pub enum IngestError {
40 #[error("no encryption key available — clip dropped (E2EE required)")]
41 NoEncryptionKey,
42 #[error("encryption failed: {0}")]
43 Crypto(String),
44 #[error("relay push failed: {0}")]
45 Push(#[from] HttpError),
46 #[error("local store write failed: {0}")]
47 Store(#[from] StoreError),
48}
49
50impl LocalPusher {
51 pub fn new(store: Arc<Store>, client: Arc<RestClient>, enc_key: Option<[u8; 32]>) -> Self {
52 Self {
53 store,
54 client,
55 enc_key,
56 }
57 }
58
59 pub async fn push_text(
62 &self,
63 raw: Vec<u8>,
64 source: &str,
65 label: &str,
66 ) -> Result<String, IngestError> {
67 let key = self.enc_key.ok_or(IngestError::NoEncryptionKey)?;
68 let original_size = raw.len() as i64;
69 let ciphertext = crypto::encrypt(&key, &raw).map_err(IngestError::Crypto)?;
70 let req = PushRequest {
71 content: ciphertext,
72 content_type: String::new(), label: label.to_string(),
74 source: source.to_string(),
75 media_path: None,
76 byte_size: original_size,
77 encrypted: true,
78 target_device_id: None,
79 };
80 let resp = self.client.push_clip_json(&req).await?;
81 self.write_through(&resp.clip_id, source, "text/plain", raw, original_size)?;
82 Ok(resp.clip_id)
83 }
84
85 pub async fn push_image_png(
88 &self,
89 raw_png: Vec<u8>,
90 source: &str,
91 label: &str,
92 ) -> Result<String, IngestError> {
93 let key = self.enc_key.ok_or(IngestError::NoEncryptionKey)?;
94 let original_size = raw_png.len() as i64;
95 let ciphertext = crypto::encrypt(&key, &raw_png).map_err(IngestError::Crypto)?;
96 let req = PushRequest {
97 content: ciphertext,
98 content_type: ContentType::Image.as_wire().into(),
99 label: label.to_string(),
100 source: source.to_string(),
101 media_path: None,
102 byte_size: original_size,
103 encrypted: true,
104 target_device_id: None,
105 };
106 let resp = self.client.push_clip_json(&req).await?;
107 self.write_through(&resp.clip_id, source, "image/png", raw_png, original_size)?;
108 Ok(resp.clip_id)
109 }
110
111 fn write_through(
112 &self,
113 clip_id: &str,
114 source: &str,
115 content_type: &str,
116 raw: Vec<u8>,
117 byte_size: i64,
118 ) -> Result<(), IngestError> {
119 let stored = StoredClip {
120 id: clip_id.to_string(),
121 source: source.to_string(),
122 source_key: None,
123 content_type: content_type.to_string(),
124 content: Some(raw),
125 media_path: None,
126 byte_size,
127 created_at: chrono::Utc::now().timestamp_millis(),
128 pinned: false,
129 pinned_at: None,
130 };
131 queries::insert_clip(&self.store, &stored)?;
132 let _ = queries::set_watermark(&self.store, clip_id);
134 Ok(())
135 }
136}