Skip to main content

max / goingson

15.0 KB · 450 lines History Blame Raw
1 //! SyncKit cloud sync commands.
2 //!
3 //! Provides Tauri commands for authenticating with the MNW sync service
4 //! via OAuth2 PKCE flow, managing sync credentials, encryption setup,
5 //! manual sync triggers, and sync settings.
6
7 use serde::{Deserialize, Serialize};
8 use std::sync::Arc;
9 use tauri::{Emitter, State};
10 use tracing::instrument;
11 use uuid::Uuid;
12
13 use crate::oauth::callback_server::OAuthCallbackServer;
14 use crate::oauth::credentials::CredentialStore;
15 use crate::oauth::provider::{generate_code_challenge, generate_code_verifier, generate_state};
16 use crate::state::AppState;
17 use crate::sync_service;
18 use super::{ApiError, OptionApiError, ResultApiError};
19
20 // ============ Types ============
21
22 /// Response for sync_status command.
23 #[derive(Debug, Serialize)]
24 #[serde(rename_all = "camelCase")]
25 pub struct SyncStatusResponse {
26 pub configured: bool,
27 pub authenticated: bool,
28 pub server_url: Option<String>,
29 pub encryption_ready: bool,
30 pub has_server_key: Option<bool>,
31 pub device_id: Option<String>,
32 pub auto_sync_enabled: bool,
33 pub sync_interval_minutes: u32,
34 pub last_sync_at: Option<String>,
35 pub pending_changes: i64,
36 }
37
38 /// Response for sync_start_auth command.
39 #[derive(Debug, Serialize)]
40 #[serde(rename_all = "camelCase")]
41 pub struct SyncAuthStartResponse {
42 pub auth_url: String,
43 pub state: String,
44 pub port: u16,
45 }
46
47 /// Input for sync_complete_auth command.
48 #[derive(Debug, Deserialize)]
49 #[serde(rename_all = "camelCase")]
50 pub struct SyncAuthCompleteInput {
51 pub code: String,
52 pub state: String,
53 }
54
55 /// Response for sync_complete_auth command.
56 #[derive(Debug, Serialize)]
57 #[serde(rename_all = "camelCase")]
58 pub struct SyncAuthCompleteResponse {
59 pub user_id: Uuid,
60 pub app_id: Uuid,
61 }
62
63 /// Input for sync_update_settings command.
64 #[derive(Debug, Deserialize)]
65 #[serde(rename_all = "camelCase")]
66 pub struct SyncSettingsInput {
67 pub auto_sync_enabled: Option<bool>,
68 pub sync_interval_minutes: Option<u32>,
69 }
70
71 // ============ Helpers ============
72
73 /// Extract the sync client from state. Clones the Arc for use across await points.
74 fn get_sync_client(state: &AppState) -> Option<std::sync::Arc<synckit_client::SyncKitClient>> {
75 state.read_recovering()
76 }
77
78 fn require_sync_client(state: &AppState) -> Result<std::sync::Arc<synckit_client::SyncKitClient>, ApiError> {
79 get_sync_client(state).ok_or_else(|| ApiError::bad_request("Sync is not configured"))
80 }
81
82 // ============ Commands ============
83
84 /// Fetch the pricing formula for this app (no auth required, uses API key).
85 /// GoingsOn doesn't expose a cap slider — it presents a single suggested cap
86 /// to the user — but the formula is what the server now returns instead of a
87 /// tier list, so the command still hits the network on app load to populate
88 /// the subscribe banner with a real number.
89 #[tauri::command]
90 #[instrument(skip_all)]
91 pub async fn sync_get_tiers(
92 state: State<'_, Arc<AppState>>,
93 ) -> Result<synckit_client::AppPricing, ApiError> {
94 let client = require_sync_client(&state)?;
95 client.get_app_pricing()
96 .await
97 .map_api_err("Failed to fetch pricing", ApiError::external_service)
98 }
99
100 /// Returns the current sync configuration, authentication, encryption, and sync state.
101 #[tauri::command]
102 #[instrument(skip_all)]
103 pub async fn sync_status(
104 state: State<'_, Arc<AppState>>,
105 ) -> Result<SyncStatusResponse, ApiError> {
106 let (configured, authenticated, server_url, encryption_ready, has_server_key) = match get_sync_client(&state) {
107 Some(client) => {
108 let url = Some(client.config().server_url.clone());
109 let enc_ready = client.has_master_key();
110 // Use in-memory session as the source of truth for authenticated state.
111 // The keychain is for persistence across restarts; within a session,
112 // the client's session_info() reflects the latest auth state.
113 let authed = client.session_info().is_some();
114
115 let server_key = if authed {
116 client.has_server_key().await.ok()
117 } else {
118 None
119 };
120
121 (true, authed, url, enc_ready, server_key)
122 }
123 None => (false, false, None, false, None),
124 };
125
126 // Read sync state from DB — batch query + pending count in parallel
127 let (states_result, pending_changes) = tokio::join!(
128 sync_service::get_sync_states_batch(
129 &state.pool,
130 &["device_id", "auto_sync_enabled", "sync_interval_minutes", "last_sync_at"],
131 ),
132 sync_service::count_pending_changes(&state.pool),
133 );
134
135 let states = states_result.unwrap_or_default();
136 let pending_changes = pending_changes.unwrap_or(0);
137
138 let device_id = states.get("device_id").filter(|s| !s.is_empty()).cloned();
139 let auto_sync_enabled = states.get("auto_sync_enabled").map(|v| v == "1").unwrap_or(true);
140 let sync_interval_minutes = states.get("sync_interval_minutes").and_then(|v| v.parse().ok()).unwrap_or(5);
141 let last_sync_at = states.get("last_sync_at").filter(|s| !s.is_empty()).cloned();
142
143 Ok(SyncStatusResponse {
144 configured,
145 authenticated,
146 server_url,
147 encryption_ready,
148 has_server_key,
149 device_id,
150 auto_sync_enabled,
151 sync_interval_minutes,
152 last_sync_at,
153 pending_changes,
154 })
155 }
156
157 /// Starts the SyncKit OAuth2 PKCE flow.
158 #[tauri::command]
159 #[instrument(skip_all)]
160 pub async fn sync_start_auth(
161 state: State<'_, Arc<AppState>>,
162 ) -> Result<SyncAuthStartResponse, ApiError> {
163 let client = require_sync_client(&state)?;
164
165 let code_verifier = generate_code_verifier();
166 let code_challenge = generate_code_challenge(&code_verifier);
167 let csrf_state = generate_state();
168
169 let callback_server = OAuthCallbackServer::start()
170 .map_api_err("Failed to start callback server", ApiError::internal)?;
171 let port = callback_server.port();
172
173 let auth_url = client.build_authorize_url(port, &csrf_state, &code_challenge);
174
175 // Store PKCE verifier server-side (never sent to frontend)
176 {
177 let mut flows = state.pending_oauth_flows.lock().unwrap_or_else(|e| e.into_inner());
178 flows.insert(csrf_state.clone(), crate::state::PendingOAuthFlow {
179 code_verifier,
180 provider_id: "synckit".to_string(),
181 port,
182 });
183 }
184
185 // Keep the callback server alive so the frontend can poll its result over
186 // IPC (`poll_oauth_result`) instead of an unauthenticated HTTP endpoint.
187 {
188 let mut servers = state.pending_oauth_servers.lock().unwrap_or_else(|e| e.into_inner());
189 servers.insert(port, callback_server);
190 }
191
192 Ok(SyncAuthStartResponse {
193 auth_url,
194 state: csrf_state,
195 port,
196 })
197 }
198
199 /// Completes the SyncKit OAuth2 flow by exchanging the authorization code for a JWT.
200 #[tauri::command]
201 #[instrument(skip_all)]
202 pub async fn sync_complete_auth(
203 state: State<'_, Arc<AppState>>,
204 input: SyncAuthCompleteInput,
205 ) -> Result<SyncAuthCompleteResponse, ApiError> {
206 let client = require_sync_client(&state)?;
207
208 // Look up and consume the pending flow by state token (CSRF + PKCE validation)
209 let flow = {
210 let mut flows = state.pending_oauth_flows.lock().unwrap_or_else(|e| e.into_inner());
211 flows.remove(&input.state)
212 }.ok_or_else(|| ApiError::bad_request("Invalid or expired OAuth state token"))?;
213
214 // The callback has been consumed; tear down its loopback server.
215 {
216 let mut servers = state.pending_oauth_servers.lock().unwrap_or_else(|e| e.into_inner());
217 servers.remove(&flow.port);
218 }
219
220 let (user_id, app_id) = client
221 .authenticate_with_code(&input.code, &flow.code_verifier, flow.port, "__internal__")
222 .await
223 .map_api_err("Token exchange failed", ApiError::external_service)?;
224 // GO stores and surfaces these as bare UUIDs; unwrap the SDK newtypes here.
225 let (user_id, app_id) = (user_id.as_uuid(), app_id.as_uuid());
226
227 let session_info = client
228 .session_info()
229 .or_api_err(|| ApiError::internal("Session not available after authentication"))?;
230
231 CredentialStore::store_sync_token(&session_info.token, user_id, app_id)
232 .map_api_err("Failed to store sync token", ApiError::internal)?;
233
234 match client.try_load_key_from_keychain() {
235 Ok(true) => tracing::info!("Sync encryption key loaded from keychain"),
236 Ok(false) => tracing::debug!("No sync encryption key in keychain yet"),
237 Err(e) => tracing::warn!("Failed to load sync encryption key: {}", e),
238 }
239
240 Ok(SyncAuthCompleteResponse { user_id, app_id })
241 }
242
243 /// Disconnects from the sync service by clearing stored credentials.
244 #[tauri::command]
245 #[instrument(skip_all)]
246 pub async fn sync_disconnect(
247 _state: State<'_, Arc<AppState>>,
248 ) -> Result<bool, ApiError> {
249 CredentialStore::delete_sync_token()
250 .map_api_err("Failed to delete sync token", ApiError::internal)?;
251 Ok(true)
252 }
253
254 /// Manual sync trigger. Returns pushed/pulled counts.
255 #[tauri::command]
256 #[instrument(skip_all)]
257 pub async fn sync_now(
258 state: State<'_, Arc<AppState>>,
259 app: tauri::AppHandle,
260 ) -> Result<sync_service::SyncResult, ApiError> {
261 let client = require_sync_client(&state)?;
262
263 if client.session_info().is_none() {
264 return Err(ApiError::bad_request("Not authenticated"));
265 }
266
267 if !client.has_master_key() {
268 return Err(ApiError::bad_request("Encryption not set up"));
269 }
270
271 let _sync_guard = state.sync_lock.lock().await;
272
273 // Create initial snapshot if needed (must be inside sync_lock to avoid TOCTOU race)
274 let snapshot_done = sync_service::get_sync_state(&state.pool, "initial_snapshot_done")
275 .await
276 .unwrap_or_default();
277 if snapshot_done != "1" {
278 sync_service::create_initial_snapshot(&state.pool)
279 .await
280 .map_api_err("Failed to create initial snapshot", ApiError::internal)?;
281 }
282 let _ = app.emit("sync:status-changed", "syncing");
283 let result = match sync_service::perform_sync_with_blobs(&state.pool, &client, Some(&state.data_dir)).await {
284 Ok(r) => {
285 let _ = app.emit("sync:status-changed", "idle");
286 r
287 }
288 Err(e) => {
289 let _ = app.emit("sync:status-changed", "error");
290 return Err(ApiError::external_service(format!("Sync failed: {e}")));
291 }
292 };
293
294 if result.pulled > 0 {
295 // Carry the changed tables so the UI invalidates selectively.
296 let _ = app.emit("sync:changes-applied", &result.pulled_tables);
297 }
298
299 // Cleanup after manual sync too
300 let _ = sync_service::cleanup_changelog(&state.pool).await;
301
302 Ok(result)
303 }
304
305 /// First device: generate a new master key, encrypt with password, push to server.
306 #[tauri::command]
307 #[instrument(skip_all)]
308 pub async fn sync_setup_encryption_new(
309 state: State<'_, Arc<AppState>>,
310 password: String,
311 ) -> Result<bool, ApiError> {
312 let client = require_sync_client(&state)?;
313
314 client
315 .setup_encryption_new(&password)
316 .await
317 .map_api_err("Encryption setup failed", ApiError::external_service)?;
318
319 Ok(true)
320 }
321
322 /// Additional device: decrypt master key from server using password.
323 #[tauri::command]
324 #[instrument(skip_all)]
325 pub async fn sync_setup_encryption_existing(
326 state: State<'_, Arc<AppState>>,
327 password: String,
328 ) -> Result<bool, ApiError> {
329 let client = require_sync_client(&state)?;
330
331 client
332 .setup_encryption_existing(&password)
333 .await
334 .map_api_err("Encryption setup failed", ApiError::external_service)?;
335
336 Ok(true)
337 }
338
339 /// Update sync settings (auto_sync_enabled, sync_interval_minutes).
340 #[tauri::command]
341 #[instrument(skip_all)]
342 pub async fn sync_update_settings(
343 state: State<'_, Arc<AppState>>,
344 input: SyncSettingsInput,
345 ) -> Result<bool, ApiError> {
346 if let Some(enabled) = input.auto_sync_enabled {
347 sync_service::set_sync_state(
348 &state.pool,
349 "auto_sync_enabled",
350 if enabled { "1" } else { "0" },
351 )
352 .await
353 .map_api_err("Failed to update setting", ApiError::internal)?;
354 }
355
356 if let Some(minutes) = input.sync_interval_minutes {
357 sync_service::set_sync_state(
358 &state.pool,
359 "sync_interval_minutes",
360 &minutes.to_string(),
361 )
362 .await
363 .map_api_err("Failed to update setting", ApiError::internal)?;
364 }
365
366 Ok(true)
367 }
368
369 // ============ Subscription Commands ============
370
371 /// Returns the authenticated user's email + username (for "logged in as ..." UI).
372 #[tauri::command]
373 #[instrument(skip_all)]
374 pub async fn sync_account_info(
375 state: State<'_, Arc<AppState>>,
376 ) -> Result<synckit_client::AccountInfo, ApiError> {
377 let client = require_sync_client(&state)?;
378
379 if client.session_info().is_none() {
380 return Err(ApiError::bad_request("Not authenticated"));
381 }
382
383 client
384 .get_account_info()
385 .await
386 .map_api_err("Failed to fetch account info", ApiError::external_service)
387 }
388
389 /// Check subscription status for this user + app.
390 #[tauri::command]
391 #[instrument(skip_all)]
392 pub async fn sync_subscription_status(
393 state: State<'_, Arc<AppState>>,
394 ) -> Result<synckit_client::SubscriptionStatus, ApiError> {
395 let client = require_sync_client(&state)?;
396
397 if client.session_info().is_none() {
398 return Err(ApiError::bad_request("Not authenticated"));
399 }
400
401 client
402 .get_subscription_status()
403 .await
404 .map_api_err("Failed to check subscription", ApiError::external_service)
405 }
406
407 /// Create a Stripe Checkout session for subscribing to cloud sync.
408 /// Opens the checkout URL in the user's default browser.
409 #[tauri::command]
410 #[instrument(skip_all)]
411 pub async fn sync_subscribe(
412 state: State<'_, Arc<AppState>>,
413 interval: String,
414 ) -> Result<String, ApiError> {
415 let client = require_sync_client(&state)?;
416
417 if client.session_info().is_none() {
418 return Err(ApiError::bad_request("Not authenticated"));
419 }
420
421 // GoingsOn syncs metadata only — no blob storage — so the cap is set to
422 // the formula's minimum (10 GiB) which trips the $2/mo floor. The cap is
423 // mostly cosmetic for non-blob apps but is required by the new API.
424 let interval_enum = synckit_client::BillingInterval::from_wire(&interval);
425 const GO_DEFAULT_CAP_BYTES: i64 = 10 * 1024 * 1024 * 1024;
426 let response = match client
427 .create_subscription_checkout(GO_DEFAULT_CAP_BYTES, interval_enum)
428 .await
429 {
430 Ok(r) => r,
431 Err(e) => {
432 tracing::error!(error = %e, debug = ?e, "Subscription checkout failed");
433 return Err(ApiError::external_service(format!("Failed to create checkout: {e}")));
434 }
435 };
436
437 // Open in default browser. Route through the same scheme validator as
438 // open_external_url so a malicious/compromised sync server can't return a
439 // non-http(s) URL (e.g. file://) that the OS opener would act on.
440 if crate::commands::window::is_external_http_url(&response.checkout_url) {
441 if let Err(e) = open::that(&response.checkout_url) {
442 tracing::warn!(error = %e, "Failed to open browser, returning URL");
443 }
444 } else {
445 tracing::warn!("Refusing to open non-http(s) checkout URL");
446 }
447
448 Ok(response.checkout_url)
449 }
450