Skip to main content

max / goingson

18.5 KB · 450 lines History Blame Raw
1 //! Application state holding database pools and repository handles.
2
3 use goingson_core::{
4 AttachmentRepository, BackupSettingsRepository, ContactRepository, DailyNoteRepository,
5 EmailAccountRepository, EmailRepository, EventRepository,
6 MilestoneRepository, MonthlyReviewRepository, ProjectRepository, SavedViewRepository,
7 SearchRepository, StatsRepository, SyncAccountRepository, TaskRepository,
8 WeeklyReviewRepository,
9 };
10 use goingson_db_sqlite::{
11 SqliteAttachmentRepository, SqliteBackupSettingsRepository, SqliteContactRepository,
12 SqliteDailyNoteRepository, SqliteEmailAccountRepository, SqliteEmailRepository,
13 SqliteEventRepository, SqliteMilestoneRepository,
14 SqliteMonthlyReviewRepository, SqliteProjectRepository, SqliteSavedViewRepository,
15 SqliteSearchRepository, SqliteStatsRepository, SqliteSyncAccountRepository,
16 SqliteTaskRepository, SqliteWeeklyReviewRepository,
17 };
18 use sqlx::SqlitePool;
19 use std::path::PathBuf;
20 use std::sync::{Arc, Mutex, RwLock};
21 use tokio::sync::Mutex as TokioMutex;
22 use synckit_client::{SyncKitClient, SyncKitConfig};
23 use tauri::{AppHandle, Manager};
24 use tracing::{debug, info, instrument, warn};
25
26 /// Default SyncKit server URL.
27 pub const SYNC_SERVER_URL: &str = "https://makenot.work";
28
29 /// Load the SyncKit config from synckit.toml in the project root (compile-time embed).
30 /// The API key is a public client identifier, not a secret.
31 const SYNCKIT_TOML: &str = include_str!("../../synckit.toml");
32
33 /// Holds the optional sync client behind a poison-recovering lock.
34 ///
35 /// The inner `RwLock` is private to this newtype, so no caller can write
36 /// `.read().expect("poisoned")` and crash a background loop on a poisoned lock.
37 /// The only access is [`SyncClientCell::get`], which recovers instead of
38 /// panicking — the constructive fix for the recurring sync-scheduler poison nit.
39 #[derive(Default)]
40 pub struct SyncClientCell(RwLock<Option<Arc<SyncKitClient>>>);
41
42 impl SyncClientCell {
43 /// Wrap an initial (possibly absent) client.
44 pub(crate) fn new(client: Option<Arc<SyncKitClient>>) -> Self {
45 Self(RwLock::new(client))
46 }
47
48 /// Clone out the current client, recovering from a poisoned lock rather
49 /// than panicking.
50 pub(crate) fn get(&self) -> Option<Arc<SyncKitClient>> {
51 self.0.read().unwrap_or_else(|e| e.into_inner()).clone()
52 }
53 }
54
55 /// Application state holding database connections and repositories
56 pub struct AppState {
57 pub pool: SqlitePool,
58 pub projects: Arc<dyn ProjectRepository>,
59 pub tasks: Arc<dyn TaskRepository>,
60 pub events: Arc<dyn EventRepository>,
61 pub emails: Arc<dyn EmailRepository>,
62 pub email_accounts: Arc<dyn EmailAccountRepository>,
63 pub contacts: Arc<dyn ContactRepository>,
64 pub daily_notes: Arc<dyn DailyNoteRepository>,
65 pub attachments: Arc<dyn AttachmentRepository>,
66 pub stats: Arc<dyn StatsRepository>,
67 pub search: Arc<dyn SearchRepository>,
68 pub milestones: Arc<dyn MilestoneRepository>,
69 pub saved_views: Arc<dyn SavedViewRepository>,
70 pub weekly_reviews: Arc<dyn WeeklyReviewRepository>,
71 pub monthly_reviews: Arc<dyn MonthlyReviewRepository>,
72 pub backup_settings: Arc<dyn BackupSettingsRepository>,
73 pub sync_accounts: Arc<dyn SyncAccountRepository>,
74 pub sync_client: SyncClientCell,
75 pub sync_lock: Arc<TokioMutex<()>>,
76 /// Per-account email sync locks to prevent concurrent syncs on the same account.
77 pub email_sync_locks: Arc<Mutex<std::collections::HashSet<goingson_core::EmailAccountId>>>,
78 /// Per-account token refresh locks to prevent concurrent refreshes.
79 pub token_refresh_locks: Arc<Mutex<std::collections::HashMap<uuid::Uuid, Arc<TokioMutex<()>>>>>,
80 /// Pending OAuth flows keyed by state token (CSRF + PKCE verifier stored server-side).
81 pub pending_oauth_flows: Arc<Mutex<std::collections::HashMap<String, PendingOAuthFlow>>>,
82 /// Live OAuth callback servers keyed by loopback port. The callback result is
83 /// polled through the trusted IPC layer (`poll_oauth_result`) rather than an
84 /// unauthenticated local HTTP endpoint.
85 pub pending_oauth_servers: Arc<Mutex<std::collections::HashMap<u16, crate::oauth::OAuthCallbackServer>>>,
86 pub data_dir: PathBuf,
87 }
88
89 /// Server-side storage for a pending OAuth flow.
90 /// Keeps the PKCE code_verifier out of the frontend and enables state validation.
91 #[derive(Debug)]
92 pub struct PendingOAuthFlow {
93 pub code_verifier: String,
94 pub provider_id: String,
95 pub port: u16,
96 }
97
98 impl AppState {
99 #[instrument(skip(app), name = "AppState::new")]
100 pub async fn new(app: &AppHandle) -> Result<Self, String> {
101 // Get app data directory
102 let app_data_dir = app
103 .path()
104 .app_data_dir()
105 .map_err(|e| format!("Failed to get app data dir: {}", e))?;
106
107 info!(?app_data_dir, "Initializing application state");
108
109 // Create directory if it doesn't exist
110 std::fs::create_dir_all(&app_data_dir)
111 .map_err(|e| format!("Failed to create app data dir: {}", e))?;
112
113 let db_path = app_data_dir.join("goingson.db");
114
115 debug!(?db_path, "Connecting to database");
116
117 // Create database connection pool (WAL mode, FK enforcement, pool limits)
118 let pool = goingson_db_sqlite::init_pool(Some(db_path.to_str().unwrap_or("goingson.db")))
119 .await
120 .map_err(|e| format!("Failed to connect to database: {}", e))?;
121
122 info!("Database connection established");
123
124 // Run migrations
125 debug!("Running database migrations");
126 sqlx::migrate!("../migrations/sqlite")
127 .run(&pool)
128 .await
129 .map_err(|e| format!("Failed to run migrations: {}", e))?;
130
131 info!("Database migrations completed");
132
133 // Reset applying_remote flag in case app crashed during pull
134 if let Err(e) = sqlx::query("UPDATE sync_state SET value = '0' WHERE key = 'applying_remote'")
135 .execute(&pool)
136 .await
137 {
138 warn!("Failed to reset applying_remote flag: {e}");
139 }
140
141 // Migrate email IDs from random v4 to deterministic v5
142 goingson_db_sqlite::migrations::migrate_deterministic_email_ids(&pool)
143 .await
144 .map_err(|e| format!("Email ID migration failed: {e}"))?;
145
146 // Move any plaintext passwords left by older versions into the keychain.
147 scrub_legacy_email_passwords(&pool).await;
148
149 // Ensure desktop user exists (single-user mode)
150 ensure_desktop_user_exists(&pool).await?;
151
152 // Create repositories
153 let projects = Arc::new(SqliteProjectRepository::new(pool.clone()));
154 let tasks = Arc::new(SqliteTaskRepository::new(pool.clone()));
155 let events = Arc::new(SqliteEventRepository::new(pool.clone()));
156 let emails = Arc::new(SqliteEmailRepository::new(pool.clone()));
157 let email_accounts = Arc::new(SqliteEmailAccountRepository::new(pool.clone()));
158 let contacts = Arc::new(SqliteContactRepository::new(pool.clone()));
159 let daily_notes = Arc::new(SqliteDailyNoteRepository::new(pool.clone()));
160 let attachments = Arc::new(SqliteAttachmentRepository::new(pool.clone()));
161 let stats = Arc::new(SqliteStatsRepository::new(pool.clone()));
162 let search = Arc::new(SqliteSearchRepository::new(pool.clone()));
163 let milestones = Arc::new(SqliteMilestoneRepository::new(pool.clone()));
164 let saved_views = Arc::new(SqliteSavedViewRepository::new(pool.clone()));
165 let weekly_reviews = Arc::new(SqliteWeeklyReviewRepository::new(pool.clone()));
166 let monthly_reviews = Arc::new(SqliteMonthlyReviewRepository::new(pool.clone()));
167 let backup_settings = Arc::new(SqliteBackupSettingsRepository::new(pool.clone()));
168 let sync_accounts = Arc::new(SqliteSyncAccountRepository::new(pool.clone()));
169
170 // Initialize SyncKit client from saved key or env vars (optional)
171 let sync_client = load_sync_client(&app_data_dir);
172
173 Ok(Self {
174 pool,
175 projects,
176 tasks,
177 events,
178 emails,
179 email_accounts,
180 contacts,
181 daily_notes,
182 attachments,
183 stats,
184 search,
185 milestones,
186 saved_views,
187 weekly_reviews,
188 monthly_reviews,
189 backup_settings,
190 sync_accounts,
191 sync_client: SyncClientCell::new(sync_client.map(Arc::new)),
192 sync_lock: Arc::new(TokioMutex::new(())),
193 email_sync_locks: Arc::new(Mutex::new(std::collections::HashSet::new())),
194 token_refresh_locks: Arc::new(Mutex::new(std::collections::HashMap::new())),
195 pending_oauth_flows: Arc::new(Mutex::new(std::collections::HashMap::new())),
196 pending_oauth_servers: Arc::new(Mutex::new(std::collections::HashMap::new())),
197 data_dir: app_data_dir,
198 })
199 }
200
201 /// Clone out the current sync client, recovering from a poisoned lock
202 /// instead of panicking. Background loops must use this (never a raw
203 /// `.read().expect(...)`), so a writer panic can't kill sync for the session.
204 pub(crate) fn read_recovering(&self) -> Option<Arc<SyncKitClient>> {
205 self.sync_client.get()
206 }
207
208 /// Gets or creates a per-account token refresh lock.
209 pub fn token_refresh_lock(&self, account_id: uuid::Uuid) -> Arc<TokioMutex<()>> {
210 let mut locks = self.token_refresh_locks.lock().unwrap_or_else(|e| e.into_inner());
211 locks.entry(account_id)
212 .or_insert_with(|| Arc::new(TokioMutex::new(())))
213 .clone()
214 }
215 }
216
217 /// One-time scrub: move any plaintext password still in the `email_accounts`
218 /// column into the OS keychain, then blank the column. New accounts already
219 /// store secrets keychain-only (the column is `""`); this cleans up accounts
220 /// created by older versions so plaintext secrets don't linger in the DB file.
221 async fn scrub_legacy_email_passwords(pool: &SqlitePool) {
222 scrub_legacy_passwords(pool).await;
223 scrub_legacy_oauth_tokens(pool).await;
224 }
225
226 /// Migrate plaintext IMAP/SMTP passwords from the `password` column into the
227 /// keychain, then blank the column.
228 async fn scrub_legacy_passwords(pool: &SqlitePool) {
229 let rows: Vec<(String, String)> =
230 match sqlx::query_as("SELECT id, password FROM email_accounts WHERE password != ''")
231 .fetch_all(pool)
232 .await
233 {
234 Ok(rows) => rows,
235 Err(e) => {
236 warn!("Could not scan for legacy email passwords: {e}");
237 return;
238 }
239 };
240
241 for (id, password) in rows {
242 let Ok(uuid) = uuid::Uuid::parse_str(&id) else {
243 continue;
244 };
245 // Store in the keychain only if it isn't already there; if that fails,
246 // leave the column intact and retry on a future launch.
247 if crate::oauth::CredentialStore::get_password(uuid).is_none()
248 && let Err(e) = crate::oauth::CredentialStore::store_password(uuid, &password)
249 {
250 warn!("Failed to migrate email password to keychain for {id}: {e}");
251 continue;
252 }
253 if let Err(e) = sqlx::query("UPDATE email_accounts SET password = '' WHERE id = ?")
254 .bind(&id)
255 .execute(pool)
256 .await
257 {
258 warn!("Failed to blank legacy email password column for {id}: {e}");
259 } else {
260 info!("Migrated a legacy email password to the keychain");
261 }
262 }
263 }
264
265 /// Migrate plaintext OAuth2 access/refresh tokens from the
266 /// `oauth2_access_token` / `oauth2_refresh_token` columns into the keychain,
267 /// then blank the columns. Older versions wrote these in cleartext and the
268 /// token manager still reads them as a fallback, so without this they would
269 /// linger unencrypted in the SQLite file forever.
270 async fn scrub_legacy_oauth_tokens(pool: &SqlitePool) {
271 let rows: Vec<(String, Option<String>, Option<String>)> = match sqlx::query_as(
272 "SELECT id, oauth2_access_token, oauth2_refresh_token FROM email_accounts \
273 WHERE (oauth2_access_token IS NOT NULL AND oauth2_access_token != '') \
274 OR (oauth2_refresh_token IS NOT NULL AND oauth2_refresh_token != '')",
275 )
276 .fetch_all(pool)
277 .await
278 {
279 Ok(rows) => rows,
280 Err(e) => {
281 warn!("Could not scan for legacy OAuth tokens: {e}");
282 return;
283 }
284 };
285
286 for (id, access_token, refresh_token) in rows {
287 let Ok(uuid) = uuid::Uuid::parse_str(&id) else {
288 continue;
289 };
290 // Don't clobber fresher keychain tokens (a refresh may have already
291 // written newer ones); only migrate when the keychain has none. If the
292 // store fails, leave the columns intact and retry on a future launch.
293 if crate::oauth::CredentialStore::get_oauth(uuid).is_none() {
294 let credentials = crate::oauth::OAuthCredentials {
295 access_token: access_token.unwrap_or_default(),
296 refresh_token: refresh_token.filter(|t| !t.is_empty()),
297 };
298 if let Err(e) = crate::oauth::CredentialStore::store_oauth(uuid, &credentials) {
299 warn!("Failed to migrate OAuth tokens to keychain for {id}: {e}");
300 continue;
301 }
302 }
303 if let Err(e) = sqlx::query(
304 "UPDATE email_accounts SET oauth2_access_token = '', oauth2_refresh_token = '' WHERE id = ?",
305 )
306 .bind(&id)
307 .execute(pool)
308 .await
309 {
310 warn!("Failed to blank legacy OAuth token columns for {id}: {e}");
311 } else {
312 info!("Migrated legacy OAuth tokens to the keychain");
313 }
314 }
315 }
316
317 /// Load a SyncKit API key from the keychain, migrating from plaintext file if needed.
318 fn load_api_key(data_dir: &std::path::Path) -> Option<String> {
319 // Migrate plaintext file to keychain (one-time)
320 let key_path = data_dir.join("sync_api_key");
321 if key_path.exists()
322 && let Ok(file_key) = std::fs::read_to_string(&key_path) {
323 let file_key = file_key.trim().to_string();
324 if !file_key.is_empty() {
325 if crate::oauth::CredentialStore::get_sync_api_key().is_none() {
326 match crate::oauth::CredentialStore::store_sync_api_key(&file_key) {
327 Ok(()) => info!("Migrated sync API key from file to keychain"),
328 Err(e) => warn!("Failed to migrate sync API key to keychain: {}", e),
329 }
330 }
331 // Delete plaintext file regardless (keychain now has it or already had it)
332 if let Err(e) = std::fs::remove_file(&key_path) {
333 warn!("Failed to remove plaintext sync API key file: {}", e);
334 }
335 }
336 }
337
338 // Load from keychain
339 if let Some(key) = crate::oauth::CredentialStore::get_sync_api_key() {
340 return Some(key);
341 }
342
343 if let Ok(key) = std::env::var("GOINGSON_SYNC_API_KEY") {
344 return Some(key);
345 }
346
347 // Fall back to bundled synckit.toml
348 parse_synckit_toml_key().map(String::from)
349 }
350
351 /// Extract the api_key value from the bundled synckit.toml.
352 fn parse_synckit_toml_key() -> Option<&'static str> {
353 for line in SYNCKIT_TOML.lines() {
354 let line = line.trim();
355 if let Some(rest) = line.strip_prefix("api_key") {
356 let rest = rest.trim_start();
357 if let Some(rest) = rest.strip_prefix('=') {
358 let rest = rest.trim();
359 let rest = rest.trim_matches('"');
360 if !rest.is_empty() {
361 return Some(rest);
362 }
363 }
364 }
365 }
366 None
367 }
368
369 /// Create a SyncKitClient from a saved or env-provided API key.
370 fn load_sync_client(data_dir: &std::path::Path) -> Option<SyncKitClient> {
371 let api_key = load_api_key(data_dir)?;
372 let server_url = std::env::var("GOINGSON_SYNC_SERVER_URL")
373 .unwrap_or_else(|_| SYNC_SERVER_URL.to_string());
374 info!(%server_url, "SyncKit client configured");
375 let client = SyncKitClient::new(SyncKitConfig { server_url, api_key });
376
377 // Try to restore session from keychain
378 match crate::oauth::CredentialStore::get_sync_token() {
379 Some(creds) => {
380 info!("Restoring sync session from keychain (user={})", creds.user_id);
381 client.restore_session(
382 &creds.token,
383 synckit_client::UserId::new(creds.user_id),
384 synckit_client::AppId::new(creds.app_id),
385 );
386 if client.is_token_expired() {
387 warn!("Stored sync token is expired, clearing session");
388 client.clear_session();
389 } else {
390 info!("Sync session restored successfully");
391 match client.try_load_key_from_keychain() {
392 Ok(true) => info!("Sync encryption key loaded from keychain"),
393 Ok(false) => debug!("No sync encryption key in keychain"),
394 Err(e) => warn!("Failed to load sync encryption key: {}", e),
395 }
396 }
397 }
398 None => {
399 info!("No sync token found in keychain — user will need to authenticate");
400 }
401 }
402
403 Some(client)
404 }
405
406 /// Save an API key to the OS keychain.
407 pub fn save_api_key(_data_dir: &std::path::Path, api_key: &str) {
408 if let Err(e) = crate::oauth::CredentialStore::store_sync_api_key(api_key) {
409 tracing::error!("Failed to save API key to keychain: {e}");
410 }
411 }
412
413 /// Fixed user ID for single-user desktop app
414 pub const DESKTOP_USER_ID: goingson_core::UserId = goingson_core::UserId::from_uuid(uuid::Uuid::from_u128(1));
415
416 /// Ensure the desktop user exists in the database
417 #[instrument(skip(pool))]
418 async fn ensure_desktop_user_exists(pool: &SqlitePool) -> Result<(), String> {
419 let user_id = DESKTOP_USER_ID.to_string();
420
421 // Check if user already exists
422 let exists: Option<(String,)> = sqlx::query_as("SELECT id FROM users WHERE id = ?")
423 .bind(&user_id)
424 .fetch_optional(pool)
425 .await
426 .map_err(|e| format!("Failed to check for desktop user: {}", e))?;
427
428 if exists.is_none() {
429 info!("Creating desktop user");
430 // Create desktop user with a placeholder password (not used for auth in desktop mode)
431 let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
432 sqlx::query(
433 "INSERT INTO users (id, email, password_hash, display_name, created_at) VALUES (?, ?, ?, ?, ?)"
434 )
435 .bind(&user_id)
436 .bind("desktop@localhost")
437 .bind("desktop-mode-no-password")
438 .bind("Desktop User")
439 .bind(&now)
440 .execute(pool)
441 .await
442 .map_err(|e| format!("Failed to create desktop user: {}", e))?;
443 } else {
444 debug!("Desktop user already exists");
445 }
446
447 Ok(())
448 }
449
450