max / balanced_breakfast
14 files changed,
+249 insertions,
-269 deletions
| @@ -104,7 +104,7 @@ pub fn load_or_create_key_from_keychain(file_path: &Path) -> Result<EncryptionKe | |||
| 104 | 104 | if file_path.exists() { | |
| 105 | 105 | let key = load_or_create_key(file_path)?; | |
| 106 | 106 | // Migrate to keychain | |
| 107 | - | let b64 = BASE64.encode(&*key); | |
| 107 | + | let b64 = BASE64.encode(*key); | |
| 108 | 108 | if entry.set_password(&b64).is_ok() { | |
| 109 | 109 | // Read back and verify before deleting the file fallback | |
| 110 | 110 | if let Ok(readback) = entry.get_password() { | |
| @@ -138,7 +138,7 @@ pub fn load_or_create_key_from_keychain(file_path: &Path) -> Result<EncryptionKe | |||
| 138 | 138 | and will be flagged for re-entry." | |
| 139 | 139 | ); | |
| 140 | 140 | let key = generate_key(); | |
| 141 | - | let b64 = BASE64.encode(&*key); | |
| 141 | + | let b64 = BASE64.encode(*key); | |
| 142 | 142 | if entry.set_password(&b64).is_ok() { | |
| 143 | 143 | return Ok(key); | |
| 144 | 144 | } | |
| @@ -213,15 +213,16 @@ pub fn encrypt_config_secrets( | |||
| 213 | 213 | if field.field_type != ConfigFieldType::Secret { | |
| 214 | 214 | continue; | |
| 215 | 215 | } | |
| 216 | - | if let Some(serde_json::Value::String(val)) = obj.get(&field.key) { | |
| 217 | - | if !val.is_empty() && !val.starts_with(PREFIX) { | |
| 218 | - | match encrypt_field(val, key) { | |
| 219 | - | Ok(encrypted) => { | |
| 220 | - | obj.insert(field.key.clone(), serde_json::Value::String(encrypted)); | |
| 221 | - | } | |
| 222 | - | Err(e) => { | |
| 223 | - | tracing::warn!(field = %field.key, error = %e, "Failed to encrypt secret, plaintext retained"); | |
| 224 | - | } | |
| 216 | + | if let Some(serde_json::Value::String(val)) = obj.get(&field.key) | |
| 217 | + | && !val.is_empty() | |
| 218 | + | && !val.starts_with(PREFIX) | |
| 219 | + | { | |
| 220 | + | match encrypt_field(val, key) { | |
| 221 | + | Ok(encrypted) => { | |
| 222 | + | obj.insert(field.key.clone(), serde_json::Value::String(encrypted)); | |
| 223 | + | } | |
| 224 | + | Err(e) => { | |
| 225 | + | tracing::warn!(field = %field.key, error = %e, "Failed to encrypt secret, plaintext retained"); | |
| 225 | 226 | } | |
| 226 | 227 | } | |
| 227 | 228 | } | |
| @@ -249,17 +250,17 @@ pub fn decrypt_config_secrets( | |||
| 249 | 250 | if field.field_type != ConfigFieldType::Secret { | |
| 250 | 251 | continue; | |
| 251 | 252 | } | |
| 252 | - | if let Some(serde_json::Value::String(val)) = obj.get(&field.key) { | |
| 253 | - | if val.starts_with(PREFIX) { | |
| 254 | - | match decrypt_field(val, key) { | |
| 255 | - | Ok(decrypted) => { | |
| 256 | - | obj.insert(field.key.clone(), serde_json::Value::String(decrypted)); | |
| 257 | - | } | |
| 258 | - | Err(e) => { | |
| 259 | - | tracing::error!(field = %field.key, error = %e, "Failed to decrypt secret, clearing field to prevent ciphertext leakage. Prompting for re-entry."); | |
| 260 | - | obj.insert(field.key.clone(), serde_json::Value::String(String::new())); | |
| 261 | - | needs_reentry.push(field.key.clone()); | |
| 262 | - | } | |
| 253 | + | if let Some(serde_json::Value::String(val)) = obj.get(&field.key) | |
| 254 | + | && val.starts_with(PREFIX) | |
| 255 | + | { | |
| 256 | + | match decrypt_field(val, key) { | |
| 257 | + | Ok(decrypted) => { | |
| 258 | + | obj.insert(field.key.clone(), serde_json::Value::String(decrypted)); | |
| 259 | + | } | |
| 260 | + | Err(e) => { | |
| 261 | + | tracing::error!(field = %field.key, error = %e, "Failed to decrypt secret, clearing field to prevent ciphertext leakage. Prompting for re-entry."); | |
| 262 | + | obj.insert(field.key.clone(), serde_json::Value::String(String::new())); | |
| 263 | + | needs_reentry.push(field.key.clone()); | |
| 263 | 264 | } | |
| 264 | 265 | } | |
| 265 | 266 | } |
| @@ -32,72 +32,72 @@ pub(crate) fn dynamic_to_config_schema(val: Dynamic) -> Result<ConfigSchema, Rha | |||
| 32 | 32 | ||
| 33 | 33 | let mut schema = ConfigSchema::new(description); | |
| 34 | 34 | ||
| 35 | - | if let Some(fields_val) = map.get("fields") { | |
| 36 | - | if let Some(fields) = fields_val.clone().try_cast::<rhai::Array>() { | |
| 37 | - | for field_val in fields { | |
| 38 | - | if let Some(field_map) = field_val.try_cast::<Map>() { | |
| 39 | - | let key = field_map | |
| 40 | - | .get("key") | |
| 41 | - | .and_then(|v| v.clone().try_cast::<String>()) | |
| 42 | - | .unwrap_or_default(); | |
| 43 | - | ||
| 44 | - | let label = field_map | |
| 45 | - | .get("label") | |
| 46 | - | .and_then(|v| v.clone().try_cast::<String>()) | |
| 47 | - | .unwrap_or_default(); | |
| 48 | - | ||
| 49 | - | let field_type_str = field_map | |
| 50 | - | .get("field_type") | |
| 51 | - | .and_then(|v| v.clone().try_cast::<String>()) | |
| 52 | - | .unwrap_or_else(|| "text".to_string()); | |
| 53 | - | ||
| 54 | - | let field_type = match field_type_str.to_lowercase().as_str() { | |
| 55 | - | "url" => ConfigFieldType::Url, | |
| 56 | - | "secret" => ConfigFieldType::Secret, | |
| 57 | - | "textarea" => ConfigFieldType::TextArea, | |
| 58 | - | "number" => ConfigFieldType::Number, | |
| 59 | - | "toggle" => ConfigFieldType::Toggle, | |
| 60 | - | "select" => ConfigFieldType::Select, | |
| 61 | - | _ => ConfigFieldType::Text, | |
| 62 | - | }; | |
| 63 | - | ||
| 64 | - | let required = field_map | |
| 65 | - | .get("required") | |
| 66 | - | .and_then(|v| v.clone().try_cast::<bool>()) | |
| 67 | - | .unwrap_or(false); | |
| 68 | - | ||
| 69 | - | // Try both "default" and "default_value" since "default" is reserved in Rhai | |
| 70 | - | let default_val = field_map | |
| 71 | - | .get("default_value") | |
| 72 | - | .or_else(|| field_map.get("default")) | |
| 73 | - | .and_then(|v| v.clone().try_cast::<String>()); | |
| 74 | - | ||
| 75 | - | let mut field = ConfigField { | |
| 76 | - | key, | |
| 77 | - | label, | |
| 78 | - | field_type, | |
| 79 | - | required, | |
| 80 | - | description: field_map | |
| 81 | - | .get("description") | |
| 82 | - | .and_then(|v| v.clone().try_cast::<String>()), | |
| 83 | - | default: default_val, | |
| 84 | - | placeholder: field_map | |
| 85 | - | .get("placeholder") | |
| 86 | - | .and_then(|v| v.clone().try_cast::<String>()), | |
| 87 | - | options: Vec::new(), | |
| 88 | - | }; | |
| 89 | - | ||
| 90 | - | if let Some(opts_val) = field_map.get("options") { | |
| 91 | - | if let Some(opts) = opts_val.clone().try_cast::<rhai::Array>() { | |
| 92 | - | field.options = opts | |
| 93 | - | .into_iter() | |
| 94 | - | .filter_map(|v| v.try_cast::<String>()) | |
| 95 | - | .collect(); | |
| 96 | - | } | |
| 97 | - | } | |
| 98 | - | ||
| 99 | - | schema.fields.push(field); | |
| 35 | + | if let Some(fields_val) = map.get("fields") | |
| 36 | + | && let Some(fields) = fields_val.clone().try_cast::<rhai::Array>() | |
| 37 | + | { | |
| 38 | + | for field_val in fields { | |
| 39 | + | if let Some(field_map) = field_val.try_cast::<Map>() { | |
| 40 | + | let key = field_map | |
| 41 | + | .get("key") | |
| 42 | + | .and_then(|v| v.clone().try_cast::<String>()) | |
| 43 | + | .unwrap_or_default(); | |
| 44 | + | ||
| 45 | + | let label = field_map | |
| 46 | + | .get("label") | |
| 47 | + | .and_then(|v| v.clone().try_cast::<String>()) | |
| 48 | + | .unwrap_or_default(); | |
| 49 | + | ||
| 50 | + | let field_type_str = field_map | |
| 51 | + | .get("field_type") | |
| 52 | + | .and_then(|v| v.clone().try_cast::<String>()) | |
| 53 | + | .unwrap_or_else(|| "text".to_string()); | |
| 54 | + | ||
| 55 | + | let field_type = match field_type_str.to_lowercase().as_str() { | |
| 56 | + | "url" => ConfigFieldType::Url, | |
| 57 | + | "secret" => ConfigFieldType::Secret, | |
| 58 | + | "textarea" => ConfigFieldType::TextArea, | |
| 59 | + | "number" => ConfigFieldType::Number, | |
| 60 | + | "toggle" => ConfigFieldType::Toggle, | |
| 61 | + | "select" => ConfigFieldType::Select, | |
| 62 | + | _ => ConfigFieldType::Text, | |
| 63 | + | }; | |
| 64 | + | ||
| 65 | + | let required = field_map | |
| 66 | + | .get("required") | |
| 67 | + | .and_then(|v| v.clone().try_cast::<bool>()) | |
| 68 | + | .unwrap_or(false); | |
| 69 | + | ||
| 70 | + | // Try both "default" and "default_value" since "default" is reserved in Rhai | |
| 71 | + | let default_val = field_map | |
| 72 | + | .get("default_value") | |
| 73 | + | .or_else(|| field_map.get("default")) | |
| 74 | + | .and_then(|v| v.clone().try_cast::<String>()); | |
| 75 | + | ||
| 76 | + | let mut field = ConfigField { | |
| 77 | + | key, | |
| 78 | + | label, | |
| 79 | + | field_type, | |
| 80 | + | required, | |
| 81 | + | description: field_map | |
| 82 | + | .get("description") | |
| 83 | + | .and_then(|v| v.clone().try_cast::<String>()), | |
| 84 | + | default: default_val, | |
| 85 | + | placeholder: field_map | |
| 86 | + | .get("placeholder") | |
| 87 | + | .and_then(|v| v.clone().try_cast::<String>()), | |
| 88 | + | options: Vec::new(), | |
| 89 | + | }; | |
| 90 | + | ||
| 91 | + | if let Some(opts_val) = field_map.get("options") | |
| 92 | + | && let Some(opts) = opts_val.clone().try_cast::<rhai::Array>() | |
| 93 | + | { | |
| 94 | + | field.options = opts | |
| 95 | + | .into_iter() | |
| 96 | + | .filter_map(|v| v.try_cast::<String>()) | |
| 97 | + | .collect(); | |
| 100 | 98 | } | |
| 99 | + | ||
| 100 | + | schema.fields.push(field); | |
| 101 | 101 | } | |
| 102 | 102 | } | |
| 103 | 103 | } | |
| @@ -267,29 +267,29 @@ pub(crate) fn dynamic_to_feed_item( | |||
| 267 | 267 | .get("url") | |
| 268 | 268 | .and_then(|v| v.clone().try_cast::<String>()); | |
| 269 | 269 | ||
| 270 | - | if let Some(media_val) = content_map.get("media") { | |
| 271 | - | if let Some(media_arr) = media_val.clone().try_cast::<rhai::Array>() { | |
| 272 | - | content.media = media_arr | |
| 273 | - | .into_iter() | |
| 274 | - | .filter_map(|v| v.try_cast::<String>()) | |
| 275 | - | .collect(); | |
| 276 | - | } | |
| 270 | + | if let Some(media_val) = content_map.get("media") | |
| 271 | + | && let Some(media_arr) = media_val.clone().try_cast::<rhai::Array>() | |
| 272 | + | { | |
| 273 | + | content.media = media_arr | |
| 274 | + | .into_iter() | |
| 275 | + | .filter_map(|v| v.try_cast::<String>()) | |
| 276 | + | .collect(); | |
| 277 | 277 | } | |
| 278 | 278 | ||
| 279 | - | if let Some(actions_val) = content_map.get("actions") { | |
| 280 | - | if let Some(actions_arr) = actions_val.clone().try_cast::<rhai::Array>() { | |
| 281 | - | content.actions = actions_arr | |
| 282 | - | .into_iter() | |
| 283 | - | .filter_map(|v| { | |
| 284 | - | let m = v.try_cast::<Map>()?; | |
| 285 | - | Some(ItemAction { | |
| 286 | - | label: m.get("label")?.clone().try_cast::<String>()?, | |
| 287 | - | action_type: m.get("action_type")?.clone().try_cast::<String>()?, | |
| 288 | - | url: m.get("url")?.clone().try_cast::<String>()?, | |
| 289 | - | }) | |
| 279 | + | if let Some(actions_val) = content_map.get("actions") | |
| 280 | + | && let Some(actions_arr) = actions_val.clone().try_cast::<rhai::Array>() | |
| 281 | + | { | |
| 282 | + | content.actions = actions_arr | |
| 283 | + | .into_iter() | |
| 284 | + | .filter_map(|v| { | |
| 285 | + | let m = v.try_cast::<Map>()?; | |
| 286 | + | Some(ItemAction { | |
| 287 | + | label: m.get("label")?.clone().try_cast::<String>()?, | |
| 288 | + | action_type: m.get("action_type")?.clone().try_cast::<String>()?, | |
| 289 | + | url: m.get("url")?.clone().try_cast::<String>()?, | |
| 290 | 290 | }) | |
| 291 | - | .collect(); | |
| 292 | - | } | |
| 291 | + | }) | |
| 292 | + | .collect(); | |
| 293 | 293 | } | |
| 294 | 294 | content | |
| 295 | 295 | } else { | |
| @@ -317,13 +317,13 @@ pub(crate) fn dynamic_to_feed_item( | |||
| 317 | 317 | .get("score") | |
| 318 | 318 | .and_then(|v| v.clone().try_cast::<i64>()); | |
| 319 | 319 | ||
| 320 | - | if let Some(tags_val) = meta_map.get("tags") { | |
| 321 | - | if let Some(tags_arr) = tags_val.clone().try_cast::<rhai::Array>() { | |
| 322 | - | meta.tags = tags_arr | |
| 323 | - | .into_iter() | |
| 324 | - | .filter_map(|v| v.try_cast::<String>()) | |
| 325 | - | .collect(); | |
| 326 | - | } | |
| 320 | + | if let Some(tags_val) = meta_map.get("tags") | |
| 321 | + | && let Some(tags_arr) = tags_val.clone().try_cast::<rhai::Array>() | |
| 322 | + | { | |
| 323 | + | meta.tags = tags_arr | |
| 324 | + | .into_iter() | |
| 325 | + | .filter_map(|v| v.try_cast::<String>()) | |
| 326 | + | .collect(); | |
| 327 | 327 | } | |
| 328 | 328 | meta | |
| 329 | 329 | } else { |
| @@ -118,22 +118,18 @@ fn parse_atom_entry(node: &roxmltree::Node) -> Dynamic { | |||
| 118 | 118 | "title" => { | |
| 119 | 119 | entry.insert("title".into(), get_text_content(&child).into()); | |
| 120 | 120 | } | |
| 121 | - | "summary" | "content" => { | |
| 122 | - | if !entry.contains_key("summary") { | |
| 123 | - | entry.insert("summary".into(), get_text_content(&child).into()); | |
| 124 | - | } | |
| 121 | + | "summary" | "content" if !entry.contains_key("summary") => { | |
| 122 | + | entry.insert("summary".into(), get_text_content(&child).into()); | |
| 125 | 123 | } | |
| 126 | 124 | "link" => { | |
| 127 | 125 | if let Some(href) = child.attribute("href") { | |
| 128 | 126 | entry.insert("link".into(), href.to_string().into()); | |
| 129 | 127 | } | |
| 130 | 128 | } | |
| 131 | - | "published" | "updated" => { | |
| 132 | - | if !entry.contains_key("published") { | |
| 133 | - | let date_str = get_text_content(&child); | |
| 134 | - | if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&date_str) { | |
| 135 | - | entry.insert("published".into(), dt.timestamp().into()); | |
| 136 | - | } | |
| 129 | + | "published" | "updated" if !entry.contains_key("published") => { | |
| 130 | + | let date_str = get_text_content(&child); | |
| 131 | + | if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&date_str) { | |
| 132 | + | entry.insert("published".into(), dt.timestamp().into()); | |
| 137 | 133 | } | |
| 138 | 134 | } | |
| 139 | 135 | "author" => { |
| @@ -82,10 +82,10 @@ pub(crate) fn parse_json_feed(json_str: &str) -> Result<Dynamic, Box<rhai::EvalA | |||
| 82 | 82 | } | |
| 83 | 83 | ||
| 84 | 84 | // Published date | |
| 85 | - | if let Some(date_str) = item_obj.get("date_published").and_then(|v| v.as_str()) { | |
| 86 | - | if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(date_str) { | |
| 87 | - | entry.insert("published".into(), dt.timestamp().into()); | |
| 88 | - | } | |
| 85 | + | if let Some(date_str) = item_obj.get("date_published").and_then(|v| v.as_str()) | |
| 86 | + | && let Ok(dt) = chrono::DateTime::parse_from_rfc3339(date_str) | |
| 87 | + | { | |
| 88 | + | entry.insert("published".into(), dt.timestamp().into()); | |
| 89 | 89 | } | |
| 90 | 90 | ||
| 91 | 91 | // Author: JSON Feed 1.1 uses "authors" array, 1.0 uses "author" object |
| @@ -36,12 +36,11 @@ pub(super) const MAX_FETCH_DURATION: Duration = Duration::from_secs(60); | |||
| 36 | 36 | ||
| 37 | 37 | /// Check if a host is in the CGNAT/shared address space (100.64.0.0/10). | |
| 38 | 38 | fn is_cgnat(host: &str) -> bool { | |
| 39 | - | if let Some(rest) = host.strip_prefix("100.") { | |
| 40 | - | if let Some(second) = rest.split('.').next() { | |
| 41 | - | if let Ok(n) = second.parse::<u8>() { | |
| 42 | - | return (64..=127).contains(&n); | |
| 43 | - | } | |
| 44 | - | } | |
| 39 | + | if let Some(rest) = host.strip_prefix("100.") | |
| 40 | + | && let Some(second) = rest.split('.').next() | |
| 41 | + | && let Ok(n) = second.parse::<u8>() | |
| 42 | + | { | |
| 43 | + | return (64..=127).contains(&n); | |
| 45 | 44 | } | |
| 46 | 45 | false | |
| 47 | 46 | } | |
| @@ -74,7 +73,7 @@ fn validate_url(url: &str) -> Result<(), String> { | |||
| 74 | 73 | }; | |
| 75 | 74 | let host = host.as_str(); | |
| 76 | 75 | // Strip userinfo (user@) from host to prevent blocklist bypass via http://user@localhost/ | |
| 77 | - | let host = host.split('@').last().unwrap_or(host); | |
| 76 | + | let host = host.split('@').next_back().unwrap_or(host); | |
| 78 | 77 | if host == "localhost" | |
| 79 | 78 | || host == "127.0.0.1" | |
| 80 | 79 | || host == "[::1]" | |
| @@ -99,14 +98,12 @@ fn validate_url(url: &str) -> Result<(), String> { | |||
| 99 | 98 | } | |
| 100 | 99 | } | |
| 101 | 100 | // Block 172.16.0.0/12 | |
| 102 | - | if let Some(rest) = host.strip_prefix("172.") { | |
| 103 | - | if let Some(second) = rest.split('.').next() { | |
| 104 | - | if let Ok(n) = second.parse::<u8>() { | |
| 105 | - | if (16..=31).contains(&n) { | |
| 106 | - | return Err(format!("Blocked request to internal address: {}", url)); | |
| 107 | - | } | |
| 108 | - | } | |
| 109 | - | } | |
| 101 | + | if let Some(rest) = host.strip_prefix("172.") | |
| 102 | + | && let Some(second) = rest.split('.').next() | |
| 103 | + | && let Ok(n) = second.parse::<u8>() | |
| 104 | + | && (16..=31).contains(&n) | |
| 105 | + | { | |
| 106 | + | return Err(format!("Blocked request to internal address: {}", url)); | |
| 110 | 107 | } | |
| 111 | 108 | // Block encoded IP forms: decimal (2130706433), hex (0x7f000001), | |
| 112 | 109 | // and octal-prefixed octets (0177.0.0.1). These bypass string-based |
| @@ -40,7 +40,7 @@ impl OrderBy { | |||
| 40 | 40 | pub fn apply(&self, items: &mut [FeedItem]) { | |
| 41 | 41 | match self { | |
| 42 | 42 | OrderBy::Chronological => { | |
| 43 | - | items.sort_by(|a, b| b.meta.published_at.cmp(&a.meta.published_at)); | |
| 43 | + | items.sort_by_key(|i| std::cmp::Reverse(i.meta.published_at)); | |
| 44 | 44 | } | |
| 45 | 45 | OrderBy::Score => { | |
| 46 | 46 | // Use i64::MIN for None so scoreless items sort after all scored items | |
| @@ -205,10 +205,10 @@ impl FeedFilter { | |||
| 205 | 205 | /// Check if an item matches using pre-compiled regex cache. | |
| 206 | 206 | pub fn matches_with_cache(&self, item: &FeedItem, regex_cache: &HashMap<usize, Regex>) -> bool { | |
| 207 | 207 | // Check source filter | |
| 208 | - | if let Some(ref source) = self.source { | |
| 209 | - | if item.id.source.as_str() != source { | |
| 210 | - | return false; | |
| 211 | - | } | |
| 208 | + | if let Some(ref source) = self.source | |
| 209 | + | && item.id.source.as_str() != source | |
| 210 | + | { | |
| 211 | + | return false; | |
| 212 | 212 | } | |
| 213 | 213 | ||
| 214 | 214 | // Check unread filter | |
| @@ -266,26 +266,24 @@ impl FeedFilter { | |||
| 266 | 266 | _ => "", | |
| 267 | 267 | }; | |
| 268 | 268 | match c.operator.as_str() { | |
| 269 | - | "contains" => { | |
| 270 | - | if !field_value.to_lowercase().contains(&c.value.to_lowercase()) { | |
| 271 | - | return false; | |
| 272 | - | } | |
| 269 | + | "contains" | |
| 270 | + | if !field_value.to_lowercase().contains(&c.value.to_lowercase()) => | |
| 271 | + | { | |
| 272 | + | return false; | |
| 273 | 273 | } | |
| 274 | - | "not_contains" => { | |
| 275 | - | if field_value.to_lowercase().contains(&c.value.to_lowercase()) { | |
| 276 | - | return false; | |
| 277 | - | } | |
| 274 | + | "not_contains" | |
| 275 | + | if field_value.to_lowercase().contains(&c.value.to_lowercase()) => | |
| 276 | + | { | |
| 277 | + | return false; | |
| 278 | 278 | } | |
| 279 | - | "equals" => { | |
| 280 | - | if !field_value.eq_ignore_ascii_case(&c.value) { | |
| 281 | - | return false; | |
| 282 | - | } | |
| 279 | + | "equals" if !field_value.eq_ignore_ascii_case(&c.value) => { | |
| 280 | + | return false; | |
| 283 | 281 | } | |
| 284 | 282 | "matches_regex" => { | |
| 285 | - | if let Some(re) = regex_cache.get(&i) { | |
| 286 | - | if !re.is_match(field_value) { | |
| 287 | - | return false; | |
| 288 | - | } | |
| 283 | + | if let Some(re) = regex_cache.get(&i) | |
| 284 | + | && !re.is_match(field_value) | |
| 285 | + | { | |
| 286 | + | return false; | |
| 289 | 287 | } | |
| 290 | 288 | // Missing from cache = invalid regex, skip (already warned) | |
| 291 | 289 | } |
| @@ -213,10 +213,10 @@ pub async fn create_bookmark_from_item( | |||
| 213 | 213 | { | |
| 214 | 214 | return Err(ApiError::bad_request("Item is already bookmarked")); | |
| 215 | 215 | } | |
| 216 | - | if let Some(ref url) = item.url { | |
| 217 | - | if db.bookmarks().get_by_url(url).await?.is_some() { | |
| 218 | - | return Err(ApiError::bad_request("URL is already bookmarked")); | |
| 219 | - | } | |
| 216 | + | if let Some(ref url) = item.url | |
| 217 | + | && db.bookmarks().get_by_url(url).await?.is_some() | |
| 218 | + | { | |
| 219 | + | return Err(ApiError::bad_request("URL is already bookmarked")); | |
| 220 | 220 | } | |
| 221 | 221 | ||
| 222 | 222 | let url = item.url.clone().unwrap_or_default(); | |
| @@ -232,11 +232,8 @@ pub async fn create_bookmark_from_item( | |||
| 232 | 232 | description: item | |
| 233 | 233 | .body | |
| 234 | 234 | .clone() | |
| 235 | - | .map(|b| { | |
| 236 | - | // Truncate body to a description-length excerpt | |
| 237 | - | let plain = b.chars().take(300).collect::<String>(); | |
| 238 | - | plain | |
| 239 | - | }) | |
| 235 | + | // Truncate body to a description-length excerpt | |
| 236 | + | .map(|b| b.chars().take(300).collect::<String>()) | |
| 240 | 237 | .unwrap_or_default(), | |
| 241 | 238 | author: item.bite_author.clone(), | |
| 242 | 239 | source_name: item.source_name.clone(), |
| @@ -120,58 +120,58 @@ fn validate_feed_input( | |||
| 120 | 120 | ))); | |
| 121 | 121 | } | |
| 122 | 122 | ||
| 123 | - | if let Some(serde_json::Value::String(s)) = value { | |
| 124 | - | if !s.is_empty() { | |
| 125 | - | match field.field_type { | |
| 126 | - | bb_interface::ConfigFieldType::Url => { | |
| 127 | - | if !s.starts_with("http://") && !s.starts_with("https://") { | |
| 128 | - | return Err(ApiError::bad_request(format!( | |
| 129 | - | "'{}' must start with http:// or https://", | |
| 130 | - | field.label | |
| 131 | - | ))); | |
| 132 | - | } | |
| 133 | - | let after_scheme = s.split("://").nth(1).unwrap_or(""); | |
| 134 | - | if after_scheme.is_empty() || after_scheme == "/" { | |
| 135 | - | return Err(ApiError::bad_request(format!( | |
| 136 | - | "'{}' is not a valid URL", | |
| 137 | - | field.label | |
| 138 | - | ))); | |
| 139 | - | } | |
| 123 | + | if let Some(serde_json::Value::String(s)) = value | |
| 124 | + | && !s.is_empty() | |
| 125 | + | { | |
| 126 | + | match field.field_type { | |
| 127 | + | bb_interface::ConfigFieldType::Url => { | |
| 128 | + | if !s.starts_with("http://") && !s.starts_with("https://") { | |
| 129 | + | return Err(ApiError::bad_request(format!( | |
| 130 | + | "'{}' must start with http:// or https://", | |
| 131 | + | field.label | |
| 132 | + | ))); | |
| 140 | 133 | } | |
| 141 | - | bb_interface::ConfigFieldType::Number => { | |
| 142 | - | if s.parse::<f64>().is_err() { | |
| 143 | - | return Err(ApiError::bad_request(format!( | |
| 144 | - | "'{}' must be a valid number", | |
| 145 | - | field.label | |
| 146 | - | ))); | |
| 147 | - | } | |
| 134 | + | let after_scheme = s.split("://").nth(1).unwrap_or(""); | |
| 135 | + | if after_scheme.is_empty() || after_scheme == "/" { | |
| 136 | + | return Err(ApiError::bad_request(format!( | |
| 137 | + | "'{}' is not a valid URL", | |
| 138 | + | field.label | |
| 139 | + | ))); | |
| 148 | 140 | } | |
| 149 | - | bb_interface::ConfigFieldType::Select => { | |
| 150 | - | if !field.options.contains(s) { | |
| 151 | - | return Err(ApiError::bad_request(format!( | |
| 152 | - | "'{}' must be one of: {}", | |
| 153 | - | field.label, | |
| 154 | - | field.options.join(", ") | |
| 155 | - | ))); | |
| 156 | - | } | |
| 141 | + | } | |
| 142 | + | bb_interface::ConfigFieldType::Number => { | |
| 143 | + | if s.parse::<f64>().is_err() { | |
| 144 | + | return Err(ApiError::bad_request(format!( | |
| 145 | + | "'{}' must be a valid number", | |
| 146 | + | field.label | |
| 147 | + | ))); | |
| 157 | 148 | } | |
| 158 | - | bb_interface::ConfigFieldType::Toggle => { | |
| 159 | - | if s != "true" && s != "false" { | |
| 160 | - | return Err(ApiError::bad_request(format!( | |
| 161 | - | "'{}' must be \"true\" or \"false\"", | |
| 162 | - | field.label | |
| 163 | - | ))); | |
| 164 | - | } | |
| 149 | + | } | |
| 150 | + | bb_interface::ConfigFieldType::Select => { | |
| 151 | + | if !field.options.contains(s) { | |
| 152 | + | return Err(ApiError::bad_request(format!( | |
| 153 | + | "'{}' must be one of: {}", | |
| 154 | + | field.label, | |
| 155 | + | field.options.join(", ") | |
| 156 | + | ))); | |
| 165 | 157 | } | |
| 166 | - | bb_interface::ConfigFieldType::Text | |
| 167 | - | | bb_interface::ConfigFieldType::TextArea | |
| 168 | - | | bb_interface::ConfigFieldType::Secret => { | |
| 169 | - | if s.len() > 10_000 { | |
| 170 | - | return Err(ApiError::bad_request(format!( | |
| 171 | - | "'{}' exceeds maximum length of 10,000 characters", | |
| 172 | - | field.label | |
| 173 | - | ))); | |
| 174 | - | } | |
| 158 | + | } | |
| 159 | + | bb_interface::ConfigFieldType::Toggle => { | |
| 160 | + | if s != "true" && s != "false" { | |
| 161 | + | return Err(ApiError::bad_request(format!( | |
| 162 | + | "'{}' must be \"true\" or \"false\"", | |
| 163 | + | field.label | |
| 164 | + | ))); | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | bb_interface::ConfigFieldType::Text | |
| 168 | + | | bb_interface::ConfigFieldType::TextArea | |
| 169 | + | | bb_interface::ConfigFieldType::Secret => { | |
| 170 | + | if s.len() > 10_000 { | |
| 171 | + | return Err(ApiError::bad_request(format!( | |
| 172 | + | "'{}' exceeds maximum length of 10,000 characters", | |
| 173 | + | field.label | |
| 174 | + | ))); | |
| 175 | 175 | } | |
| 176 | 176 | } | |
| 177 | 177 | } |
| @@ -434,7 +434,7 @@ pub async fn download_and_open(url: String) -> Result<(), ApiError> { | |||
| 434 | 434 | )); | |
| 435 | 435 | } | |
| 436 | 436 | ||
| 437 | - | let result = tokio::task::spawn_blocking(move || -> Result<(), ApiError> { | |
| 437 | + | tokio::task::spawn_blocking(move || -> Result<(), ApiError> { | |
| 438 | 438 | let resp = ureq::get(&url) | |
| 439 | 439 | .call() | |
| 440 | 440 | .map_err(|e| ApiError::internal(format!("Download failed: {}", e)))?; | |
| @@ -493,9 +493,7 @@ pub async fn download_and_open(url: String) -> Result<(), ApiError> { | |||
| 493 | 493 | Ok(()) | |
| 494 | 494 | }) | |
| 495 | 495 | .await | |
| 496 | - | .map_err(|e| ApiError::internal(format!("Task join error: {}", e)))?; | |
| 497 | - | ||
| 498 | - | result | |
| 496 | + | .map_err(|e| ApiError::internal(format!("Task join error: {}", e)))? | |
| 499 | 497 | } | |
| 500 | 498 | ||
| 501 | 499 | #[cfg(test)] |
| @@ -148,10 +148,10 @@ pub async fn import_opml( | |||
| 148 | 148 | ||
| 149 | 149 | // Re-initialize the RSS plugin so it discovers the newly imported feed | |
| 150 | 150 | // URLs without requiring an app restart. | |
| 151 | - | if imported > 0 { | |
| 152 | - | if let Err(e) = state.orchestrator.init_plugin_from_db("rss").await { | |
| 153 | - | tracing::warn!(error = %e, "Failed to reinitialize RSS plugin"); | |
| 154 | - | } | |
| 151 | + | if imported > 0 | |
| 152 | + | && let Err(e) = state.orchestrator.init_plugin_from_db("rss").await | |
| 153 | + | { | |
| 154 | + | tracing::warn!(error = %e, "Failed to reinitialize RSS plugin"); | |
| 155 | 155 | } | |
| 156 | 156 | ||
| 157 | 157 | info!( |
| @@ -112,6 +112,7 @@ enum StoredCallback { | |||
| 112 | 112 | /// Returns the port. The server handles: | |
| 113 | 113 | /// - The browser redirect with `?code=...&state=...` (stores result, returns HTML) | |
| 114 | 114 | /// - `/result` polling endpoint (returns JSON: pending, success, or error) | |
| 115 | + | /// | |
| 115 | 116 | /// Any previously running callback server is cancelled via the shared generation counter. | |
| 116 | 117 | fn start_callback_server() -> Result<u16, ApiError> { | |
| 117 | 118 | let listener = std::net::TcpListener::bind("127.0.0.1:0") |
| @@ -123,10 +123,10 @@ impl AppState { | |||
| 123 | 123 | ||
| 124 | 124 | // Clean up old download temp files from previous sessions | |
| 125 | 125 | let downloads_dir = std::env::temp_dir().join("bb-downloads"); | |
| 126 | - | if downloads_dir.exists() { | |
| 127 | - | if let Err(e) = std::fs::remove_dir_all(&downloads_dir) { | |
| 128 | - | debug!(error = %e, "Failed to clean up old download temp files"); | |
| 129 | - | } | |
| 126 | + | if downloads_dir.exists() | |
| 127 | + | && let Err(e) = std::fs::remove_dir_all(&downloads_dir) | |
| 128 | + | { | |
| 129 | + | debug!(error = %e, "Failed to clean up old download temp files"); | |
| 130 | 130 | } | |
| 131 | 131 | ||
| 132 | 132 | info!("Application state initialized"); | |
| @@ -265,10 +265,10 @@ fn find_plugins_dir(app: &AppHandle) -> PathBuf { | |||
| 265 | 265 | .parent() | |
| 266 | 266 | .map(|p| p.join("plugins")); | |
| 267 | 267 | ||
| 268 | - | if let Some(ref dev_path) = dev_plugins { | |
| 269 | - | if dev_path.exists() { | |
| 270 | - | return dev_path.clone(); | |
| 271 | - | } | |
| 268 | + | if let Some(ref dev_path) = dev_plugins | |
| 269 | + | && dev_path.exists() | |
| 270 | + | { | |
| 271 | + | return dev_path.clone(); | |
| 272 | 272 | } | |
| 273 | 273 | ||
| 274 | 274 | // In production, use the app config dir (fall back to app data dir) | |
| @@ -296,14 +296,12 @@ fn copy_bundled_plugins(app: &AppHandle, plugins_dir: &PathBuf) { | |||
| 296 | 296 | if path.extension().is_some_and(|e| e == "rhai") { | |
| 297 | 297 | let dest = plugins_dir.join(entry.file_name()); | |
| 298 | 298 | // Skip if installed file is identical to bundled version | |
| 299 | - | if dest.exists() { | |
| 300 | - | if let (Ok(bundled_bytes), Ok(installed_bytes)) = | |
| 299 | + | if dest.exists() | |
| 300 | + | && let (Ok(bundled_bytes), Ok(installed_bytes)) = | |
| 301 | 301 | (std::fs::read(&path), std::fs::read(&dest)) | |
| 302 | - | { | |
| 303 | - | if bundled_bytes == installed_bytes { | |
| 304 | - | continue; | |
| 305 | - | } | |
| 306 | - | } | |
| 302 | + | && bundled_bytes == installed_bytes | |
| 303 | + | { | |
| 304 | + | continue; | |
| 307 | 305 | } | |
| 308 | 306 | if let Err(e) = std::fs::copy(&path, &dest) { | |
| 309 | 307 | tracing::warn!(error = %e, ?path, "Failed to copy plugin"); |
| @@ -133,23 +133,23 @@ pub fn start_sync_scheduler(app: AppHandle) -> tokio::task::AbortHandle { | |||
| 133 | 133 | } | |
| 134 | 134 | }; | |
| 135 | 135 | ||
| 136 | - | if !last_sync.is_empty() { | |
| 137 | - | if let Ok(last) = chrono::DateTime::parse_from_rfc3339(&last_sync) { | |
| 138 | - | let elapsed = chrono::Utc::now() - last.with_timezone(&chrono::Utc); | |
| 139 | - | if elapsed.num_minutes() < interval_minutes as i64 { | |
| 140 | - | continue; | |
| 141 | - | } | |
| 136 | + | if !last_sync.is_empty() | |
| 137 | + | && let Ok(last) = chrono::DateTime::parse_from_rfc3339(&last_sync) | |
| 138 | + | { | |
| 139 | + | let elapsed = chrono::Utc::now() - last.with_timezone(&chrono::Utc); | |
| 140 | + | if elapsed.num_minutes() < interval_minutes as i64 { | |
| 141 | + | continue; | |
| 142 | 142 | } | |
| 143 | 143 | } | |
| 144 | 144 | } | |
| 145 | 145 | sse_triggered = false; | |
| 146 | 146 | ||
| 147 | 147 | // Check backoff | |
| 148 | - | if let Some(until) = backoff_until { | |
| 149 | - | if chrono::Utc::now() < until { | |
| 150 | - | debug!(%until, "Sync scheduler: backing off"); | |
| 151 | - | continue; | |
| 152 | - | } | |
| 148 | + | if let Some(until) = backoff_until | |
| 149 | + | && chrono::Utc::now() < until | |
| 150 | + | { | |
| 151 | + | debug!(%until, "Sync scheduler: backing off"); | |
| 152 | + | continue; | |
| 153 | 153 | } | |
| 154 | 154 | ||
| 155 | 155 | // Prevent concurrent sync operations (manual + scheduler). |
| @@ -101,10 +101,10 @@ async fn apply_changes_inner_tx( | |||
| 101 | 101 | ||
| 102 | 102 | for table in UPSERT_ORDER { | |
| 103 | 103 | for change in &upserts { | |
| 104 | - | if change.table == *table { | |
| 105 | - | if let Some(ref data) = change.data { | |
| 106 | - | apply_upsert_exec(&mut **tx, table, &change.row_id, data).await?; | |
| 107 | - | } | |
| 104 | + | if change.table == *table | |
| 105 | + | && let Some(ref data) = change.data | |
| 106 | + | { | |
| 107 | + | apply_upsert_exec(tx, table, &change.row_id, data).await?; | |
| 108 | 108 | } | |
| 109 | 109 | } | |
| 110 | 110 | } | |
| @@ -112,7 +112,7 @@ async fn apply_changes_inner_tx( | |||
| 112 | 112 | for table in DELETE_ORDER { | |
| 113 | 113 | for change in &deletes { | |
| 114 | 114 | if change.table == *table { | |
| 115 | - | apply_delete_exec(&mut **tx, table, &change.row_id).await?; | |
| 115 | + | apply_delete_exec(tx, table, &change.row_id).await?; | |
| 116 | 116 | } | |
| 117 | 117 | } | |
| 118 | 118 | } | |
| @@ -179,7 +179,7 @@ async fn apply_upsert( | |||
| 179 | 179 | data: &serde_json::Value, | |
| 180 | 180 | ) -> Result<(), ApiError> { | |
| 181 | 181 | let mut conn = pool.acquire().await.map_err(super::db_err)?; | |
| 182 | - | apply_upsert_exec(&mut *conn, table, row_id, data).await | |
| 182 | + | apply_upsert_exec(&mut conn, table, row_id, data).await | |
| 183 | 183 | } | |
| 184 | 184 | ||
| 185 | 185 | /// Apply an INSERT OR REPLACE for a remote change on any SQLite executor. | |
| @@ -286,13 +286,7 @@ async fn apply_upsert_exec( | |||
| 286 | 286 | fn json_to_i32(val: &serde_json::Value) -> i32 { | |
| 287 | 287 | match val { | |
| 288 | 288 | serde_json::Value::Number(n) => n.as_i64().unwrap_or(0) as i32, | |
| 289 | - | serde_json::Value::Bool(b) => { | |
| 290 | - | if *b { | |
| 291 | - | 1 | |
| 292 | - | } else { | |
| 293 | - | 0 | |
| 294 | - | } | |
| 295 | - | } | |
| 289 | + | serde_json::Value::Bool(b) => i32::from(*b), | |
| 296 | 290 | _ => 0, | |
| 297 | 291 | } | |
| 298 | 292 | } | |
| @@ -301,7 +295,7 @@ fn json_to_i32(val: &serde_json::Value) -> i32 { | |||
| 301 | 295 | #[cfg(test)] | |
| 302 | 296 | async fn apply_delete(pool: &SqlitePool, table: &str, row_id: &str) -> Result<(), ApiError> { | |
| 303 | 297 | let mut conn = pool.acquire().await.map_err(super::db_err)?; | |
| 304 | - | apply_delete_exec(&mut *conn, table, row_id).await | |
| 298 | + | apply_delete_exec(&mut conn, table, row_id).await | |
| 305 | 299 | } | |
| 306 | 300 | ||
| 307 | 301 | /// Apply a DELETE for a remote change on any SQLite executor. |