max / balanced_breakfast
- Co-Authored-By
- Claude Opus 4.6 <noreply@anthropic.com>
15 files changed,
+320 insertions,
-148 deletions
| @@ -198,6 +198,7 @@ | |||
| 198 | 198 | "sha2", | |
| 199 | 199 | "sqlx", | |
| 200 | 200 | "synckit-client", | |
| 201 | + | "tagtree", | |
| 201 | 202 | "tauri", | |
| 202 | 203 | "tauri-build", | |
| 203 | 204 | "tauri-plugin-dialog", | |
| @@ -234,12 +235,12 @@ | |||
| 234 | 235 | version = "0.3.0" | |
| 235 | 236 | dependencies = [ | |
| 236 | 237 | "aes-gcm", | |
| 237 | - | "ammonia", | |
| 238 | 238 | "base64 0.22.1", | |
| 239 | 239 | "bb-db", | |
| 240 | 240 | "bb-feed", | |
| 241 | 241 | "bb-interface", | |
| 242 | 242 | "chrono", | |
| 243 | + | "docengine", | |
| 243 | 244 | "html2text", | |
| 244 | 245 | "keyring", | |
| 245 | 246 | "parking_lot", | |
| @@ -971,6 +972,15 @@ | |||
| 971 | 972 | "syn 2.0.114", | |
| 972 | 973 | ] | |
| 973 | 974 | ||
| 975 | + | [[package]] | |
| 976 | + | name = "docengine" | |
| 977 | + | version = "0.3.0" | |
| 978 | + | dependencies = [ | |
| 979 | + | "ammonia", | |
| 980 | + | "pulldown-cmark", | |
| 981 | + | "serde", | |
| 982 | + | ] | |
| 983 | + | ||
| 974 | 984 | [[package]] | |
| 975 | 985 | name = "dotenvy" | |
| 976 | 986 | version = "0.15.7" | |
| @@ -1438,6 +1448,15 @@ | |||
| 1438 | 1448 | "version_check", | |
| 1439 | 1449 | ] | |
| 1440 | 1450 | ||
| 1451 | + | [[package]] | |
| 1452 | + | name = "getopts" | |
| 1453 | + | version = "0.2.24" | |
| 1454 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1455 | + | checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" | |
| 1456 | + | dependencies = [ | |
| 1457 | + | "unicode-width 0.2.2", | |
| 1458 | + | ] | |
| 1459 | + | ||
| 1441 | 1460 | [[package]] | |
| 1442 | 1461 | name = "getrandom" | |
| 1443 | 1462 | version = "0.1.16" | |
| @@ -1736,7 +1755,7 @@ | |||
| 1736 | 1755 | "markup5ever 0.12.1", | |
| 1737 | 1756 | "tendril", | |
| 1738 | 1757 | "thiserror 1.0.69", | |
| 1739 | - | "unicode-width", | |
| 1758 | + | "unicode-width 0.1.13", | |
| 1740 | 1759 | ] | |
| 1741 | 1760 | ||
| 1742 | 1761 | [[package]] | |
| @@ -3449,6 +3468,25 @@ | |||
| 3449 | 3468 | "unicode-ident", | |
| 3450 | 3469 | ] | |
| 3451 | 3470 | ||
| 3471 | + | [[package]] | |
| 3472 | + | name = "pulldown-cmark" | |
| 3473 | + | version = "0.12.2" | |
| 3474 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3475 | + | checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14" | |
| 3476 | + | dependencies = [ | |
| 3477 | + | "bitflags 2.10.0", | |
| 3478 | + | "getopts", | |
| 3479 | + | "memchr", | |
| 3480 | + | "pulldown-cmark-escape", | |
| 3481 | + | "unicase", | |
| 3482 | + | ] | |
| 3483 | + | ||
| 3484 | + | [[package]] | |
| 3485 | + | name = "pulldown-cmark-escape" | |
| 3486 | + | version = "0.11.0" | |
| 3487 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3488 | + | checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" | |
| 3489 | + | ||
| 3452 | 3490 | [[package]] | |
| 3453 | 3491 | name = "quick-xml" | |
| 3454 | 3492 | version = "0.38.4" | |
| @@ -4843,6 +4881,10 @@ | |||
| 4843 | 4881 | "version-compare", | |
| 4844 | 4882 | ] | |
| 4845 | 4883 | ||
| 4884 | + | [[package]] | |
| 4885 | + | name = "tagtree" | |
| 4886 | + | version = "0.3.0" | |
| 4887 | + | ||
| 4846 | 4888 | [[package]] | |
| 4847 | 4889 | name = "tao" | |
| 4848 | 4890 | version = "0.34.5" | |
| @@ -5759,6 +5801,12 @@ | |||
| 5759 | 5801 | "unic-common", | |
| 5760 | 5802 | ] | |
| 5761 | 5803 | ||
| 5804 | + | [[package]] | |
| 5805 | + | name = "unicase" | |
| 5806 | + | version = "2.9.0" | |
| 5807 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5808 | + | checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" | |
| 5809 | + | ||
| 5762 | 5810 | [[package]] | |
| 5763 | 5811 | name = "unicode-bidi" | |
| 5764 | 5812 | version = "0.3.18" | |
| @@ -5798,6 +5846,12 @@ | |||
| 5798 | 5846 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5799 | 5847 | checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d" | |
| 5800 | 5848 | ||
| 5849 | + | [[package]] | |
| 5850 | + | name = "unicode-width" | |
| 5851 | + | version = "0.2.2" | |
| 5852 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5853 | + | checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" | |
| 5854 | + | ||
| 5801 | 5855 | [[package]] | |
| 5802 | 5856 | name = "universal-hash" | |
| 5803 | 5857 | version = "0.5.1" |
| @@ -53,4 +53,6 @@ | |||
| 53 | 53 | bb-feed = { path = "crates/bb-feed" } | |
| 54 | 54 | bb-db = { path = "crates/bb-db" } | |
| 55 | 55 | synckit-client = { path = "../synckit-client" } | |
| 56 | + | tauri = "2.10.2" | |
| 56 | 57 | tauri-plugin-updater = "2" | |
| 58 | + | tagtree = { path = "../tagtree" } |
| @@ -58,6 +58,9 @@ | |||
| 58 | 58 | tracing.workspace = true | |
| 59 | 59 | tracing-subscriber.workspace = true | |
| 60 | 60 | ||
| 61 | + | # Tag standard | |
| 62 | + | tagtree.workspace = true | |
| 63 | + | ||
| 61 | 64 | # Desktop-only plugins | |
| 62 | 65 | [target.'cfg(not(any(target_os = "ios", target_os = "android")))'.dependencies] | |
| 63 | 66 | tauri-plugin-shell = "2.3.5" |
| @@ -46,7 +46,7 @@ | |||
| 46 | 46 | regex = "1" | |
| 47 | 47 | ||
| 48 | 48 | # HTML sanitization for untrusted feed content | |
| 49 | - | ammonia = "4" | |
| 49 | + | docengine = { path = "../../../docengine" } | |
| 50 | 50 | ||
| 51 | 51 | # Article extraction (reader view) | |
| 52 | 52 | readable-readability = "0.4" |
| @@ -87,6 +87,8 @@ | |||
| 87 | 87 | commands::import_opml, | |
| 88 | 88 | commands::list_themes, | |
| 89 | 89 | commands::get_theme, | |
| 90 | + | commands::sync_test_api_key, | |
| 91 | + | commands::sync_save_api_key, | |
| 90 | 92 | commands::sync_status, | |
| 91 | 93 | commands::sync_start_auth, | |
| 92 | 94 | commands::sync_complete_auth, |
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | //! Application state wrapping the Orchestrator | |
| 2 | 2 | use bb_core::{Orchestrator, OrchestratorConfig}; | |
| 3 | 3 | use bb_db::parse_timestamp; | |
| 4 | + | use parking_lot::RwLock; | |
| 4 | 5 | use std::path::PathBuf; | |
| 5 | 6 | use std::sync::Arc; | |
| 6 | 7 | use synckit_client::{SyncKitClient, SyncKitConfig}; | |
| @@ -8,11 +9,16 @@ | |||
| 8 | 9 | use tokio::task::AbortHandle; | |
| 9 | 10 | use tracing::{debug, error, info, warn}; | |
| 10 | 11 | ||
| 12 | + | /// Default sync server URL. | |
| 13 | + | pub const SYNC_SERVER_URL: &str = "https://makenot.work"; | |
| 14 | + | ||
| 11 | 15 | /// Application state wrapping the Orchestrator | |
| 12 | 16 | pub struct AppState { | |
| 13 | 17 | pub orchestrator: Orchestrator, | |
| 14 | - | /// SyncKit client for cloud sync (None if not configured). | |
| 15 | - | pub sync_client: Option<SyncKitClient>, | |
| 18 | + | /// SyncKit client for cloud sync (None until configured via API key). | |
| 19 | + | pub sync_client: RwLock<Option<Arc<SyncKitClient>>>, | |
| 20 | + | /// App data directory for key persistence. | |
| 21 | + | pub data_dir: PathBuf, | |
| 16 | 22 | /// Handle to abort the background auto-fetch task on shutdown. | |
| 17 | 23 | auto_fetch_handle: parking_lot::Mutex<Option<AbortHandle>>, | |
| 18 | 24 | /// Handle to abort the background stale-item cleanup task on shutdown. | |
| @@ -87,35 +93,15 @@ | |||
| 87 | 93 | // Initialize plugins from DB | |
| 88 | 94 | init_plugins_from_db(&orchestrator).await; | |
| 89 | 95 | ||
| 90 | - | // Initialize SyncKit client from env vars (optional) | |
| 91 | - | let sync_client = match ( | |
| 92 | - | std::env::var("BB_SYNC_SERVER_URL"), | |
| 93 | - | std::env::var("BB_SYNC_API_KEY"), | |
| 94 | - | ) { | |
| 95 | - | (Ok(server_url), Ok(api_key)) => { | |
| 96 | - | info!(%server_url, "SyncKit client configured"); | |
| 97 | - | let client = SyncKitClient::new(SyncKitConfig { server_url, api_key }); | |
| 98 | - | ||
| 99 | - | // Try to restore session from keychain | |
| 100 | - | match client.try_load_key_from_keychain() { | |
| 101 | - | Ok(true) => info!("Sync encryption key loaded from keychain"), | |
| 102 | - | Ok(false) => debug!("No sync encryption key in keychain"), | |
| 103 | - | Err(e) => warn!(error = %e, "Failed to load sync encryption key"), | |
| 104 | - | } | |
| 105 | - | ||
| 106 | - | Some(client) | |
| 107 | - | } | |
| 108 | - | _ => { | |
| 109 | - | debug!("SyncKit not configured (BB_SYNC_SERVER_URL / BB_SYNC_API_KEY not set)"); | |
| 110 | - | None | |
| 111 | - | } | |
| 112 | - | }; | |
| 96 | + | // Initialize SyncKit client from saved key or env vars | |
| 97 | + | let sync_client = load_sync_client(&app_data_dir); | |
| 113 | 98 | ||
| 114 | 99 | info!("Application state initialized"); | |
| 115 | 100 | ||
| 116 | 101 | Ok(Self { | |
| 117 | 102 | orchestrator, | |
| 118 | - | sync_client, | |
| 103 | + | sync_client: RwLock::new(sync_client.map(Arc::new)), | |
| 104 | + | data_dir: app_data_dir, | |
| 119 | 105 | auto_fetch_handle: parking_lot::Mutex::new(None), | |
| 120 | 106 | cleanup_handle: parking_lot::Mutex::new(None), | |
| 121 | 107 | }) | |
| @@ -166,6 +152,39 @@ | |||
| 166 | 152 | } | |
| 167 | 153 | } | |
| 168 | 154 | ||
| 155 | + | /// Load API key from the saved file, falling back to env var. | |
| 156 | + | pub fn load_api_key(data_dir: &std::path::Path) -> Option<String> { | |
| 157 | + | let key_path = data_dir.join("sync_api_key"); | |
| 158 | + | if let Ok(key) = std::fs::read_to_string(&key_path) { | |
| 159 | + | let key = key.trim().to_string(); | |
| 160 | + | if !key.is_empty() { | |
| 161 | + | return Some(key); | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | std::env::var("BB_SYNC_API_KEY").ok() | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | /// Save API key to the data directory. | |
| 168 | + | pub fn save_api_key(data_dir: &std::path::Path, api_key: &str) { | |
| 169 | + | let key_path = data_dir.join("sync_api_key"); | |
| 170 | + | let _ = std::fs::write(&key_path, api_key); | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | /// Create a SyncKitClient from the saved or env-var API key. | |
| 174 | + | fn load_sync_client(data_dir: &std::path::Path) -> Option<SyncKitClient> { | |
| 175 | + | let api_key = load_api_key(data_dir)?; | |
| 176 | + | let server_url = std::env::var("BB_SYNC_SERVER_URL") | |
| 177 | + | .unwrap_or_else(|_| SYNC_SERVER_URL.to_string()); | |
| 178 | + | info!(%server_url, "SyncKit client configured"); | |
| 179 | + | let client = SyncKitClient::new(SyncKitConfig { server_url, api_key }); | |
| 180 | + | match client.try_load_key_from_keychain() { | |
| 181 | + | Ok(true) => info!("Sync encryption key loaded from keychain"), | |
| 182 | + | Ok(false) => debug!("No sync encryption key in keychain"), | |
| 183 | + | Err(e) => warn!(error = %e, "Failed to load sync encryption key"), | |
| 184 | + | } | |
| 185 | + | Some(client) | |
| 186 | + | } | |
| 187 | + | ||
| 169 | 188 | /// Find the plugins directory, preferring dev-mode project root | |
| 170 | 189 | fn find_plugins_dir(app: &AppHandle) -> PathBuf { | |
| 171 | 190 | // In dev mode, use the project-root plugins/ directory |
| @@ -34,8 +34,7 @@ | |||
| 34 | 34 | warn!(error = %e, "Sync changelog retention check failed"); | |
| 35 | 35 | } | |
| 36 | 36 | ||
| 37 | - | let sync_client = state.sync_client.as_ref(); | |
| 38 | - | let client = match sync_client { | |
| 37 | + | let client: Arc<synckit_client::SyncKitClient> = match state.sync_client.read().clone() { | |
| 39 | 38 | Some(c) => c, | |
| 40 | 39 | None => continue, | |
| 41 | 40 | }; | |
| @@ -107,7 +106,7 @@ | |||
| 107 | 106 | } | |
| 108 | 107 | ||
| 109 | 108 | // Perform sync | |
| 110 | - | match sync_service::perform_sync(pool, client).await { | |
| 109 | + | match sync_service::perform_sync(pool, &client).await { | |
| 111 | 110 | Ok(result) => { | |
| 112 | 111 | consecutive_failures = 0; | |
| 113 | 112 | backoff_until = None; |
| @@ -18,14 +18,14 @@ | |||
| 18 | 18 | const PREFIX: &str = "bb_enc:v1:"; | |
| 19 | 19 | ||
| 20 | 20 | /// Generate a random 256-bit encryption key. | |
| 21 | - | pub fn generate_key() -> [u8; 32] { | |
| 21 | + | fn generate_key() -> [u8; 32] { | |
| 22 | 22 | let mut key = [0u8; 32]; | |
| 23 | 23 | rand::thread_rng().fill_bytes(&mut key); | |
| 24 | 24 | key | |
| 25 | 25 | } | |
| 26 | 26 | ||
| 27 | 27 | /// Load an encryption key from a file, or generate and save one if it doesn't exist. | |
| 28 | - | pub fn load_or_create_key(path: &Path) -> Result<[u8; 32], String> { | |
| 28 | + | fn load_or_create_key(path: &Path) -> Result<[u8; 32], String> { | |
| 29 | 29 | if path.exists() { | |
| 30 | 30 | let data = std::fs::read(path).map_err(|e| format!("Failed to read encryption key: {e}"))?; | |
| 31 | 31 | if data.len() != 32 { |
| @@ -189,8 +189,15 @@ | |||
| 189 | 189 | return Ok(0); | |
| 190 | 190 | }; | |
| 191 | 191 | ||
| 192 | - | let plugins = self.plugins.read().await; | |
| 193 | - | let result = match plugins.fetch(plugin_id, None) { | |
| 192 | + | // Acquire the read lock only for the fetch call, then release it | |
| 193 | + | // before any async DB operations to avoid holding the lock across | |
| 194 | + | // blocking network I/O. | |
| 195 | + | let fetch_result = { | |
| 196 | + | let plugins = self.plugins.read().await; | |
| 197 | + | plugins.fetch(plugin_id, None) | |
| 198 | + | }; | |
| 199 | + | ||
| 200 | + | let result = match fetch_result { | |
| 194 | 201 | Ok(r) => r, | |
| 195 | 202 | Err(e) => { | |
| 196 | 203 | // Record fetch failure before propagating | |
| @@ -233,7 +240,7 @@ | |||
| 233 | 240 | // Sanitize HTML in body to strip scripts, event handlers, and | |
| 234 | 241 | // other dangerous markup from untrusted feed/plugin content. | |
| 235 | 242 | if let Some(ref body) = create_item.body { | |
| 236 | - | create_item.body = Some(ammonia::clean(body)); | |
| 243 | + | create_item.body = Some(docengine::sanitize_html(body)); | |
| 237 | 244 | } | |
| 238 | 245 | ||
| 239 | 246 | match self.db.items().upsert(create_item).await { |
| @@ -95,6 +95,10 @@ | |||
| 95 | 95 | ||
| 96 | 96 | // --- Sync: cloud sync via MNW SyncKit --- | |
| 97 | 97 | sync: { | |
| 98 | + | /** Validate an API key against the server. Returns app name. */ | |
| 99 | + | testApiKey: (apiKey) => invoke('sync_test_api_key', { apiKey }), | |
| 100 | + | /** Save an API key and create the sync client. */ | |
| 101 | + | saveApiKey: (apiKey) => invoke('sync_save_api_key', { apiKey }), | |
| 98 | 102 | /** Get current sync status. */ | |
| 99 | 103 | status: () => invoke('sync_status'), | |
| 100 | 104 | /** Start OAuth2 PKCE auth flow, returns auth URL + PKCE params. */ |
| @@ -38,21 +38,86 @@ | |||
| 38 | 38 | } | |
| 39 | 39 | } | |
| 40 | 40 | ||
| 41 | - | // ── State 1: Connect ── | |
| 41 | + | // ── State 1: API Key Setup ── | |
| 42 | 42 | ||
| 43 | 43 | function renderConnect(container) { | |
| 44 | 44 | const div = document.createElement('div'); | |
| 45 | 45 | div.className = 'sync-connect'; | |
| 46 | 46 | div.innerHTML = | |
| 47 | - | '<p>Connect to Makenot.work to sync your feeds, read state, and preferences across devices.</p>' + | |
| 48 | - | '<p>Data is end-to-end encrypted. The server never sees your plaintext data.</p>'; | |
| 47 | + | '<p>Sync your feeds and preferences across devices via Makenot.work.</p>' + | |
| 48 | + | '<p style="margin-bottom: 0.5rem;"><a href="https://makenot.work/docs/synckit-api" target="_blank">Get an API key</a></p>'; | |
| 49 | 49 | ||
| 50 | - | const btn = document.createElement('button'); | |
| 51 | - | btn.className = 'btn btn-primary'; | |
| 52 | - | btn.textContent = 'Connect to Makenot.work'; | |
| 53 | - | btn.onclick = startAuth; | |
| 54 | - | div.appendChild(btn); | |
| 50 | + | const group = document.createElement('div'); | |
| 51 | + | group.className = 'form-group'; | |
| 52 | + | const label = document.createElement('label'); | |
| 53 | + | label.textContent = 'API Key'; | |
| 54 | + | const row = document.createElement('div'); | |
| 55 | + | row.style.display = 'flex'; | |
| 56 | + | row.style.gap = '0.5rem'; | |
| 57 | + | const input = document.createElement('input'); | |
| 58 | + | input.className = 'form-input'; | |
| 59 | + | input.type = 'password'; | |
| 60 | + | input.placeholder = 'sk_...'; | |
| 61 | + | input.style.flex = '1'; | |
| 62 | + | const testBtn = document.createElement('button'); | |
| 63 | + | testBtn.className = 'btn'; | |
| 64 | + | testBtn.textContent = 'Test'; | |
| 55 | 65 | ||
| 66 | + | const statusDiv = document.createElement('div'); | |
| 67 | + | statusDiv.style.fontSize = '0.875rem'; | |
| 68 | + | statusDiv.style.marginTop = '0.5rem'; | |
| 69 | + | statusDiv.style.display = 'none'; | |
| 70 | + | ||
| 71 | + | const saveBtn = document.createElement('button'); | |
| 72 | + | saveBtn.className = 'btn btn-primary'; | |
| 73 | + | saveBtn.textContent = 'Save & Connect'; | |
| 74 | + | saveBtn.style.marginTop = '0.75rem'; | |
| 75 | + | saveBtn.style.display = 'none'; | |
| 76 | + | ||
| 77 | + | testBtn.onclick = async () => { | |
| 78 | + | const key = input.value.trim(); | |
| 79 | + | if (!key) return; | |
| 80 | + | testBtn.disabled = true; | |
| 81 | + | testBtn.textContent = 'Testing...'; | |
| 82 | + | statusDiv.style.display = 'block'; | |
| 83 | + | statusDiv.textContent = 'Validating...'; | |
| 84 | + | statusDiv.style.color = ''; | |
| 85 | + | saveBtn.style.display = 'none'; | |
| 86 | + | try { | |
| 87 | + | const appName = await BB.api.sync.testApiKey(key); | |
| 88 | + | statusDiv.style.color = 'var(--accent-green, green)'; | |
| 89 | + | statusDiv.textContent = 'Valid \u2014 ' + appName; | |
| 90 | + | saveBtn.style.display = ''; | |
| 91 | + | } catch (err) { | |
| 92 | + | statusDiv.style.color = 'var(--accent-red, red)'; | |
| 93 | + | statusDiv.textContent = err.message || String(err); | |
| 94 | + | saveBtn.style.display = 'none'; | |
| 95 | + | } finally { | |
| 96 | + | testBtn.disabled = false; | |
| 97 | + | testBtn.textContent = 'Test'; | |
| 98 | + | } | |
| 99 | + | }; | |
| 100 | + | ||
| 101 | + | saveBtn.onclick = async () => { | |
| 102 | + | const key = input.value.trim(); | |
| 103 | + | if (!key) return; | |
| 104 | + | try { | |
| 105 | + | await BB.api.sync.saveApiKey(key); | |
| 106 | + | BB.ui.showToast('API key saved!', 'success'); | |
| 107 | + | const status = await BB.api.sync.status(); | |
| 108 | + | renderState(container, status); | |
| 109 | + | } catch (err) { | |
| 110 | + | BB.ui.showToast('Failed to save: ' + (err.message || err), 'error'); | |
| 111 | + | } | |
| 112 | + | }; | |
| 113 | + | ||
| 114 | + | row.appendChild(input); | |
| 115 | + | row.appendChild(testBtn); | |
| 116 | + | group.appendChild(label); | |
| 117 | + | group.appendChild(row); | |
| 118 | + | div.appendChild(group); | |
| 119 | + | div.appendChild(statusDiv); | |
| 120 | + | div.appendChild(saveBtn); | |
| 56 | 121 | container.appendChild(div); | |
| 57 | 122 | } | |
| 58 | 123 |
| @@ -181,11 +181,15 @@ | |||
| 181 | 181 | ||
| 182 | 182 | let name = validate_feed_input(&input.name, &input.config, &schema)?; | |
| 183 | 183 | ||
| 184 | + | // Serialize the plaintext config for the duplicate check before we move | |
| 185 | + | // `input.config` into the encryption step (avoids an extra clone). | |
| 186 | + | let new_config_str = serde_json::to_string(&input.config).unwrap_or_default(); | |
| 187 | + | ||
| 184 | 188 | let db = state.orchestrator.database(); | |
| 185 | 189 | ||
| 186 | - | // Encrypt Secret-type fields before storage | |
| 190 | + | // Encrypt Secret-type fields before storage (mutates in place, no clone) | |
| 187 | 191 | let config = { | |
| 188 | - | let mut cfg = input.config.clone(); | |
| 192 | + | let mut cfg = input.config; | |
| 189 | 193 | if let Some(key) = state.orchestrator.encryption_key() { | |
| 190 | 194 | let plugins = state.orchestrator.plugins(); | |
| 191 | 195 | let plugins = plugins.read().await; | |
| @@ -217,7 +221,6 @@ | |||
| 217 | 221 | plugins.get_config_schema(&input.busser_id) | |
| 218 | 222 | }; | |
| 219 | 223 | ||
| 220 | - | let new_config_str = serde_json::to_string(&input.config).unwrap_or_default(); | |
| 221 | 224 | for feed in &existing { | |
| 222 | 225 | let mut existing_config = feed.config_json(); | |
| 223 | 226 | // Decrypt existing config for comparison against plaintext input | |
| @@ -337,22 +340,19 @@ | |||
| 337 | 340 | ||
| 338 | 341 | let name = validate_feed_input(&name, &config, &schema)?; | |
| 339 | 342 | ||
| 340 | - | // Encrypt Secret-type fields before storage | |
| 341 | - | let encrypted_config = { | |
| 342 | - | let mut cfg = config.clone(); | |
| 343 | - | if let Some(key) = state.orchestrator.encryption_key() { | |
| 344 | - | let plugins = state.orchestrator.plugins(); | |
| 345 | - | let plugins = plugins.read().await; | |
| 346 | - | if let Some(schema) = plugins.get_config_schema(&busser_id) { | |
| 347 | - | bb_core::crypto::encrypt_config_secrets(&mut cfg, &schema, key); | |
| 348 | - | } | |
| 343 | + | // Encrypt Secret-type fields before storage (mutate in place, no clone) | |
| 344 | + | let mut config = config; | |
| 345 | + | if let Some(key) = state.orchestrator.encryption_key() { | |
| 346 | + | let plugins = state.orchestrator.plugins(); | |
| 347 | + | let plugins = plugins.read().await; | |
| 348 | + | if let Some(schema) = plugins.get_config_schema(&busser_id) { | |
| 349 | + | bb_core::crypto::encrypt_config_secrets(&mut config, &schema, key); | |
| 349 | 350 | } | |
| 350 | - | cfg | |
| 351 | - | }; | |
| 351 | + | } | |
| 352 | 352 | ||
| 353 | 353 | // Update name and config | |
| 354 | 354 | db.feeds().update_name(feed_id, &name).await?; | |
| 355 | - | let config_str = serde_json::to_string(&encrypted_config) | |
| 355 | + | let config_str = serde_json::to_string(&config) | |
| 356 | 356 | .map_err(|e| ApiError::internal(format!("Failed to serialize config: {}", e)))?; | |
| 357 | 357 | db.feeds().update_config(feed_id, &config_str).await?; | |
| 358 | 358 | ||
| @@ -428,6 +428,13 @@ | |||
| 428 | 428 | Ok(()) | |
| 429 | 429 | } | |
| 430 | 430 | ||
| 431 | + | /// Tag rules for Balanced Breakfast: shallow hierarchy, no required semantic prefix. | |
| 432 | + | const BB_TAG_CONFIG: tagtree::TagConfig = tagtree::TagConfig { | |
| 433 | + | max_depth: 3, | |
| 434 | + | max_length: 80, | |
| 435 | + | semantic_depth: 0, | |
| 436 | + | }; | |
| 437 | + | ||
| 431 | 438 | /// Set tags on all feeds belonging to a busser. | |
| 432 | 439 | #[tauri::command] | |
| 433 | 440 | #[instrument(skip_all)] | |
| @@ -436,6 +443,11 @@ | |||
| 436 | 443 | busser_id: String, | |
| 437 | 444 | tags: Vec<String>, | |
| 438 | 445 | ) -> Result<(), ApiError> { | |
| 446 | + | for tag in &tags { | |
| 447 | + | tagtree::validate_with(tag, &BB_TAG_CONFIG) | |
| 448 | + | .map_err(|e| ApiError::bad_request(format!("invalid tag: {}", e.0)))?; | |
| 449 | + | } | |
| 450 | + | ||
| 439 | 451 | let db = state.orchestrator.database(); | |
| 440 | 452 | let feeds = db.feeds().get_by_busser(&busser_id).await?; | |
| 441 | 453 |