Skip to main content

max / goingson

12.7 KB · 310 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 /// Application state holding database connections and repositories
34 pub struct AppState {
35 pub pool: SqlitePool,
36 pub projects: Arc<dyn ProjectRepository>,
37 pub tasks: Arc<dyn TaskRepository>,
38 pub events: Arc<dyn EventRepository>,
39 pub emails: Arc<dyn EmailRepository>,
40 pub email_accounts: Arc<dyn EmailAccountRepository>,
41 pub contacts: Arc<dyn ContactRepository>,
42 pub daily_notes: Arc<dyn DailyNoteRepository>,
43 pub attachments: Arc<dyn AttachmentRepository>,
44 pub stats: Arc<dyn StatsRepository>,
45 pub search: Arc<dyn SearchRepository>,
46 pub milestones: Arc<dyn MilestoneRepository>,
47 pub saved_views: Arc<dyn SavedViewRepository>,
48 pub weekly_reviews: Arc<dyn WeeklyReviewRepository>,
49 pub monthly_reviews: Arc<dyn MonthlyReviewRepository>,
50 pub backup_settings: Arc<dyn BackupSettingsRepository>,
51 pub sync_accounts: Arc<dyn SyncAccountRepository>,
52 pub sync_client: RwLock<Option<Arc<SyncKitClient>>>,
53 pub sync_lock: Arc<TokioMutex<()>>,
54 /// Per-account email sync locks to prevent concurrent syncs on the same account.
55 pub email_sync_locks: Arc<Mutex<std::collections::HashSet<goingson_core::EmailAccountId>>>,
56 /// Per-account token refresh locks to prevent concurrent refreshes.
57 pub token_refresh_locks: Arc<Mutex<std::collections::HashMap<uuid::Uuid, Arc<TokioMutex<()>>>>>,
58 /// Pending OAuth flows keyed by state token (CSRF + PKCE verifier stored server-side).
59 pub pending_oauth_flows: Arc<Mutex<std::collections::HashMap<String, PendingOAuthFlow>>>,
60 pub data_dir: PathBuf,
61 }
62
63 /// Server-side storage for a pending OAuth flow.
64 /// Keeps the PKCE code_verifier out of the frontend and enables state validation.
65 #[derive(Debug)]
66 pub struct PendingOAuthFlow {
67 pub code_verifier: String,
68 pub provider_id: String,
69 pub port: u16,
70 }
71
72 impl AppState {
73 #[instrument(skip(app), name = "AppState::new")]
74 pub async fn new(app: &AppHandle) -> Result<Self, String> {
75 // Get app data directory
76 let app_data_dir = app
77 .path()
78 .app_data_dir()
79 .map_err(|e| format!("Failed to get app data dir: {}", e))?;
80
81 info!(?app_data_dir, "Initializing application state");
82
83 // Create directory if it doesn't exist
84 std::fs::create_dir_all(&app_data_dir)
85 .map_err(|e| format!("Failed to create app data dir: {}", e))?;
86
87 let db_path = app_data_dir.join("goingson.db");
88
89 debug!(?db_path, "Connecting to database");
90
91 // Create database connection pool (WAL mode, FK enforcement, pool limits)
92 let pool = goingson_db_sqlite::init_pool(Some(db_path.to_str().unwrap_or("goingson.db")))
93 .await
94 .map_err(|e| format!("Failed to connect to database: {}", e))?;
95
96 info!("Database connection established");
97
98 // Run migrations
99 debug!("Running database migrations");
100 sqlx::migrate!("../migrations/sqlite")
101 .run(&pool)
102 .await
103 .map_err(|e| format!("Failed to run migrations: {}", e))?;
104
105 info!("Database migrations completed");
106
107 // Reset applying_remote flag in case app crashed during pull
108 if let Err(e) = sqlx::query("UPDATE sync_state SET value = '0' WHERE key = 'applying_remote'")
109 .execute(&pool)
110 .await
111 {
112 warn!("Failed to reset applying_remote flag: {e}");
113 }
114
115 // Migrate email IDs from random v4 to deterministic v5
116 goingson_db_sqlite::migrations::migrate_deterministic_email_ids(&pool)
117 .await
118 .map_err(|e| format!("Email ID migration failed: {e}"))?;
119
120 // Ensure desktop user exists (single-user mode)
121 ensure_desktop_user_exists(&pool).await?;
122
123 // Create repositories
124 let projects = Arc::new(SqliteProjectRepository::new(pool.clone()));
125 let tasks = Arc::new(SqliteTaskRepository::new(pool.clone()));
126 let events = Arc::new(SqliteEventRepository::new(pool.clone()));
127 let emails = Arc::new(SqliteEmailRepository::new(pool.clone()));
128 let email_accounts = Arc::new(SqliteEmailAccountRepository::new(pool.clone()));
129 let contacts = Arc::new(SqliteContactRepository::new(pool.clone()));
130 let daily_notes = Arc::new(SqliteDailyNoteRepository::new(pool.clone()));
131 let attachments = Arc::new(SqliteAttachmentRepository::new(pool.clone()));
132 let stats = Arc::new(SqliteStatsRepository::new(pool.clone()));
133 let search = Arc::new(SqliteSearchRepository::new(pool.clone()));
134 let milestones = Arc::new(SqliteMilestoneRepository::new(pool.clone()));
135 let saved_views = Arc::new(SqliteSavedViewRepository::new(pool.clone()));
136 let weekly_reviews = Arc::new(SqliteWeeklyReviewRepository::new(pool.clone()));
137 let monthly_reviews = Arc::new(SqliteMonthlyReviewRepository::new(pool.clone()));
138 let backup_settings = Arc::new(SqliteBackupSettingsRepository::new(pool.clone()));
139 let sync_accounts = Arc::new(SqliteSyncAccountRepository::new(pool.clone()));
140
141 // Initialize SyncKit client from saved key or env vars (optional)
142 let sync_client = load_sync_client(&app_data_dir);
143
144 Ok(Self {
145 pool,
146 projects,
147 tasks,
148 events,
149 emails,
150 email_accounts,
151 contacts,
152 daily_notes,
153 attachments,
154 stats,
155 search,
156 milestones,
157 saved_views,
158 weekly_reviews,
159 monthly_reviews,
160 backup_settings,
161 sync_accounts,
162 sync_client: RwLock::new(sync_client.map(Arc::new)),
163 sync_lock: Arc::new(TokioMutex::new(())),
164 email_sync_locks: Arc::new(Mutex::new(std::collections::HashSet::new())),
165 token_refresh_locks: Arc::new(Mutex::new(std::collections::HashMap::new())),
166 pending_oauth_flows: Arc::new(Mutex::new(std::collections::HashMap::new())),
167 data_dir: app_data_dir,
168 })
169 }
170
171 /// Gets or creates a per-account token refresh lock.
172 pub fn token_refresh_lock(&self, account_id: uuid::Uuid) -> Arc<TokioMutex<()>> {
173 let mut locks = self.token_refresh_locks.lock().unwrap_or_else(|e| e.into_inner());
174 locks.entry(account_id)
175 .or_insert_with(|| Arc::new(TokioMutex::new(())))
176 .clone()
177 }
178 }
179
180 /// Load a SyncKit API key from the keychain, migrating from plaintext file if needed.
181 fn load_api_key(data_dir: &std::path::Path) -> Option<String> {
182 // Migrate plaintext file to keychain (one-time)
183 let key_path = data_dir.join("sync_api_key");
184 if key_path.exists() {
185 if let Ok(file_key) = std::fs::read_to_string(&key_path) {
186 let file_key = file_key.trim().to_string();
187 if !file_key.is_empty() {
188 if crate::oauth::CredentialStore::get_sync_api_key().is_none() {
189 match crate::oauth::CredentialStore::store_sync_api_key(&file_key) {
190 Ok(()) => info!("Migrated sync API key from file to keychain"),
191 Err(e) => warn!("Failed to migrate sync API key to keychain: {}", e),
192 }
193 }
194 // Delete plaintext file regardless (keychain now has it or already had it)
195 if let Err(e) = std::fs::remove_file(&key_path) {
196 warn!("Failed to remove plaintext sync API key file: {}", e);
197 }
198 }
199 }
200 }
201
202 // Load from keychain
203 if let Some(key) = crate::oauth::CredentialStore::get_sync_api_key() {
204 return Some(key);
205 }
206
207 if let Ok(key) = std::env::var("GOINGSON_SYNC_API_KEY") {
208 return Some(key);
209 }
210
211 // Fall back to bundled synckit.toml
212 parse_synckit_toml_key().map(String::from)
213 }
214
215 /// Extract the api_key value from the bundled synckit.toml.
216 fn parse_synckit_toml_key() -> Option<&'static str> {
217 for line in SYNCKIT_TOML.lines() {
218 let line = line.trim();
219 if let Some(rest) = line.strip_prefix("api_key") {
220 let rest = rest.trim_start();
221 if let Some(rest) = rest.strip_prefix('=') {
222 let rest = rest.trim();
223 let rest = rest.trim_matches('"');
224 if !rest.is_empty() {
225 return Some(rest);
226 }
227 }
228 }
229 }
230 None
231 }
232
233 /// Create a SyncKitClient from a saved or env-provided API key.
234 fn load_sync_client(data_dir: &std::path::Path) -> Option<SyncKitClient> {
235 let api_key = load_api_key(data_dir)?;
236 let server_url = std::env::var("GOINGSON_SYNC_SERVER_URL")
237 .unwrap_or_else(|_| SYNC_SERVER_URL.to_string());
238 info!(%server_url, "SyncKit client configured");
239 let client = SyncKitClient::new(SyncKitConfig { server_url, api_key });
240
241 // Try to restore session from keychain
242 match crate::oauth::CredentialStore::get_sync_token() {
243 Some(creds) => {
244 info!("Restoring sync session from keychain (user={})", creds.user_id);
245 client.restore_session(&creds.token, creds.user_id, creds.app_id);
246 if client.is_token_expired() {
247 warn!("Stored sync token is expired, clearing session");
248 client.clear_session();
249 } else {
250 info!("Sync session restored successfully");
251 match client.try_load_key_from_keychain() {
252 Ok(true) => info!("Sync encryption key loaded from keychain"),
253 Ok(false) => debug!("No sync encryption key in keychain"),
254 Err(e) => warn!("Failed to load sync encryption key: {}", e),
255 }
256 }
257 }
258 None => {
259 info!("No sync token found in keychain — user will need to authenticate");
260 }
261 }
262
263 Some(client)
264 }
265
266 /// Save an API key to the OS keychain.
267 pub fn save_api_key(_data_dir: &std::path::Path, api_key: &str) {
268 if let Err(e) = crate::oauth::CredentialStore::store_sync_api_key(api_key) {
269 tracing::error!("Failed to save API key to keychain: {e}");
270 }
271 }
272
273 /// Fixed user ID for single-user desktop app
274 pub const DESKTOP_USER_ID: goingson_core::UserId = goingson_core::UserId::from_uuid(uuid::Uuid::from_u128(1));
275
276 /// Ensure the desktop user exists in the database
277 #[instrument(skip(pool))]
278 async fn ensure_desktop_user_exists(pool: &SqlitePool) -> Result<(), String> {
279 let user_id = DESKTOP_USER_ID.to_string();
280
281 // Check if user already exists
282 let exists: Option<(String,)> = sqlx::query_as("SELECT id FROM users WHERE id = ?")
283 .bind(&user_id)
284 .fetch_optional(pool)
285 .await
286 .map_err(|e| format!("Failed to check for desktop user: {}", e))?;
287
288 if exists.is_none() {
289 info!("Creating desktop user");
290 // Create desktop user with a placeholder password (not used for auth in desktop mode)
291 let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
292 sqlx::query(
293 "INSERT INTO users (id, email, password_hash, display_name, created_at) VALUES (?, ?, ?, ?, ?)"
294 )
295 .bind(&user_id)
296 .bind("desktop@localhost")
297 .bind("desktop-mode-no-password")
298 .bind("Desktop User")
299 .bind(&now)
300 .execute(pool)
301 .await
302 .map_err(|e| format!("Failed to create desktop user: {}", e))?;
303 } else {
304 debug!("Desktop user already exists");
305 }
306
307 Ok(())
308 }
309
310