max / goingson
5 files changed,
+40 insertions,
-29 deletions
| @@ -13,7 +13,7 @@ use goingson_core::{ | |||
| 13 | 13 | SocialHandle, SocialHandleId, UpdateContact, UserId, | |
| 14 | 14 | }; | |
| 15 | 15 | ||
| 16 | - | use crate::utils::{format_datetime_now, parse_datetime, parse_tags, parse_uuid}; | |
| 16 | + | use crate::utils::{escape_like, format_datetime_now, parse_datetime, parse_tags, parse_uuid}; | |
| 17 | 17 | ||
| 18 | 18 | // ============ Row Structs ============ | |
| 19 | 19 | ||
| @@ -436,12 +436,12 @@ impl ContactRepository for SqliteContactRepository { | |||
| 436 | 436 | #[tracing::instrument(skip_all)] | |
| 437 | 437 | async fn list_by_tag(&self, user_id: UserId, tag: &str) -> Result<Vec<Contact>> { | |
| 438 | 438 | // Tags stored as JSON array, use LIKE for matching | |
| 439 | - | let pattern = format!("%\"{}\"%" , tag); | |
| 439 | + | let pattern = format!("%\"{}\"%" , escape_like(tag)); | |
| 440 | 440 | let rows = sqlx::query_as::<_, ContactRow>( | |
| 441 | 441 | r#" | |
| 442 | 442 | SELECT id, display_name, nickname, company, title, notes, tags, birthday, timezone, external_source, external_id, created_at, updated_at | |
| 443 | 443 | FROM contacts | |
| 444 | - | WHERE user_id = ? AND tags LIKE ? | |
| 444 | + | WHERE user_id = ? AND tags LIKE ? ESCAPE '\' | |
| 445 | 445 | ORDER BY display_name ASC | |
| 446 | 446 | "#, | |
| 447 | 447 | ) | |
| @@ -467,8 +467,8 @@ impl ContactRepository for SqliteContactRepository { | |||
| 467 | 467 | let mut binds: Vec<String> = vec![user_id.to_string()]; | |
| 468 | 468 | ||
| 469 | 469 | if let Some(t) = tag.filter(|t| !t.is_empty()) { | |
| 470 | - | conditions.push("c.tags LIKE ?".to_string()); | |
| 471 | - | binds.push(format!("%\"{}\"%" , t)); | |
| 470 | + | conditions.push("c.tags LIKE ? ESCAPE '\\'".to_string()); | |
| 471 | + | binds.push(format!("%\"{}\"%" , escape_like(t))); | |
| 472 | 472 | } | |
| 473 | 473 | ||
| 474 | 474 | if let Some(s) = search.filter(|s| !s.is_empty()) { |
| @@ -282,7 +282,10 @@ fn row_to_saved_view(row: ViewRow) -> Result<SavedView> { | |||
| 282 | 282 | let view_type = ViewType::from_str_or_default(&row.view_type); | |
| 283 | 283 | ||
| 284 | 284 | let filters: ViewFilters = serde_json::from_str(&row.filters) | |
| 285 | - | .unwrap_or_default(); | |
| 285 | + | .unwrap_or_else(|e| { | |
| 286 | + | tracing::warn!(view_id = %row.id, error = %e, "Malformed saved view filters, using defaults"); | |
| 287 | + | ViewFilters::default() | |
| 288 | + | }); | |
| 286 | 289 | ||
| 287 | 290 | Ok(SavedView { | |
| 288 | 291 | id: parse_uuid(&row.id)?.into(), | |
| @@ -290,7 +293,10 @@ fn row_to_saved_view(row: ViewRow) -> Result<SavedView> { | |||
| 290 | 293 | name: row.name, | |
| 291 | 294 | view_type, | |
| 292 | 295 | filters, | |
| 293 | - | sort_by: row.sort_by.as_deref().and_then(|s| serde_json::from_str(&format!("\"{}\"", s)).ok()), | |
| 296 | + | sort_by: row.sort_by.as_deref().and_then(|s| { | |
| 297 | + | let json = serde_json::Value::String(s.to_string()); | |
| 298 | + | serde_json::from_value(json).ok() | |
| 299 | + | }), | |
| 294 | 300 | sort_order: SortDirection::from_str_or_default(&row.sort_order), | |
| 295 | 301 | is_pinned: row.is_pinned == 1, | |
| 296 | 302 | position: row.position, |
| @@ -13,7 +13,7 @@ use goingson_core::{ | |||
| 13 | 13 | SearchRepository, SearchResultItem, SearchResultType, UserId, | |
| 14 | 14 | }; | |
| 15 | 15 | ||
| 16 | - | use crate::utils::parse_uuid; | |
| 16 | + | use crate::utils::{escape_like, parse_uuid}; | |
| 17 | 17 | ||
| 18 | 18 | /// SQLite-backed implementation of [`SearchRepository`]. | |
| 19 | 19 | /// | |
| @@ -54,7 +54,7 @@ impl SearchRepository for SqliteSearchRepository { | |||
| 54 | 54 | let user_id_str = user_id.to_string(); | |
| 55 | 55 | let limit = query.limit.unwrap_or(50); | |
| 56 | 56 | let offset = query.offset.unwrap_or(0); | |
| 57 | - | let per_type_cap = offset + limit; | |
| 57 | + | let per_type_cap = offset.saturating_add(limit); | |
| 58 | 58 | ||
| 59 | 59 | // Prepare search term for FTS5 (escape special characters and add prefix matching) | |
| 60 | 60 | let search_term = if has_text { | |
| @@ -214,8 +214,9 @@ fn build_is_filter_clauses(is_filters: &[IsFilter]) -> Vec<String> { | |||
| 214 | 214 | IsFilter::Today => "(t.due IS NOT NULL AND date(t.due, 'localtime') = date('now', 'localtime'))".to_string(), | |
| 215 | 215 | IsFilter::Tomorrow => "(t.due IS NOT NULL AND date(t.due, 'localtime') = date('now', '+1 day', 'localtime'))".to_string(), | |
| 216 | 216 | IsFilter::ThisWeek => { | |
| 217 | - | // Due between now and end of week (Sunday) | |
| 218 | - | "(t.due IS NOT NULL AND datetime(t.due) <= datetime('now', 'weekday 0', '+7 days', 'start of day'))".to_string() | |
| 217 | + | // Due on or before end of this week (Sunday). | |
| 218 | + | // weekday 1 = next Monday; < Monday midnight = through Sunday 23:59:59. | |
| 219 | + | "(t.due IS NOT NULL AND datetime(t.due) < datetime('now', 'weekday 1'))".to_string() | |
| 219 | 220 | } | |
| 220 | 221 | IsFilter::Snoozed => "(t.snoozed_until IS NOT NULL AND datetime(t.snoozed_until) > datetime('now'))".to_string(), | |
| 221 | 222 | IsFilter::Pending => "t.status = 'Pending'".to_string(), | |
| @@ -326,15 +327,15 @@ async fn search_tasks_fts( | |||
| 326 | 327 | ||
| 327 | 328 | // Tag filters (include) — tags stored as JSON array in tasks.tags column | |
| 328 | 329 | for tag in &query.tags_include { | |
| 329 | - | sql.push_str(&format!(" AND t.tags LIKE ${}", param_idx)); | |
| 330 | - | params.push(format!("%\"{}\"%" , tag)); | |
| 330 | + | sql.push_str(&format!(" AND t.tags LIKE ${} ESCAPE '\\'", param_idx)); | |
| 331 | + | params.push(format!("%\"{}\"%" , escape_like(tag))); | |
| 331 | 332 | param_idx += 1; | |
| 332 | 333 | } | |
| 333 | 334 | ||
| 334 | 335 | // Tag filters (exclude) | |
| 335 | 336 | for tag in &query.tags_exclude { | |
| 336 | - | sql.push_str(&format!(" AND t.tags NOT LIKE ${}", param_idx)); | |
| 337 | - | params.push(format!("%\"{}\"%" , tag)); | |
| 337 | + | sql.push_str(&format!(" AND t.tags NOT LIKE ${} ESCAPE '\\'", param_idx)); | |
| 338 | + | params.push(format!("%\"{}\"%" , escape_like(tag))); | |
| 338 | 339 | param_idx += 1; | |
| 339 | 340 | } | |
| 340 | 341 | ||
| @@ -674,7 +675,7 @@ async fn search_events_fts( | |||
| 674 | 675 | ); | |
| 675 | 676 | } | |
| 676 | 677 | IsFilter::ThisWeek => { | |
| 677 | - | sql.push_str(" AND datetime(ev.start_time) <= datetime('now', 'weekday 0', '+7 days', 'start of day')"); | |
| 678 | + | sql.push_str(" AND datetime(ev.start_time) < datetime('now', 'weekday 1')"); | |
| 678 | 679 | } | |
| 679 | 680 | IsFilter::Overdue => { | |
| 680 | 681 | sql.push_str(" AND datetime(ev.start_time) < datetime('now')"); |
| @@ -22,22 +22,11 @@ pub(crate) async fn snooze( | |||
| 22 | 22 | user_id: UserId, | |
| 23 | 23 | until: DateTime<Utc>, | |
| 24 | 24 | ) -> Result<Option<Task>> { | |
| 25 | - | // Check task status before snoozing | |
| 26 | - | if let Some(task) = get_task_by_id(pool, id, user_id).await? { | |
| 27 | - | if task.status == TaskStatus::Completed { | |
| 28 | - | return Err(CoreError::validation("status", "cannot snooze a completed task")); | |
| 29 | - | } | |
| 30 | - | if task.status == TaskStatus::Deleted { | |
| 31 | - | return Err(CoreError::validation("status", "cannot snooze a deleted task")); | |
| 32 | - | } | |
| 33 | - | } else { | |
| 34 | - | return Ok(None); | |
| 35 | - | } | |
| 36 | - | ||
| 37 | 25 | let until_str = format_datetime(&until); | |
| 38 | 26 | ||
| 27 | + | // Atomically update only if task is not completed/deleted | |
| 39 | 28 | let result = sqlx::query( | |
| 40 | - | "UPDATE tasks SET snoozed_until = ? WHERE id = ? AND user_id = ?" | |
| 29 | + | "UPDATE tasks SET snoozed_until = ? WHERE id = ? AND user_id = ? AND status NOT IN ('Completed', 'Deleted')" | |
| 41 | 30 | ) | |
| 42 | 31 | .bind(&until_str) | |
| 43 | 32 | .bind(id.to_string()) | |
| @@ -49,6 +38,15 @@ pub(crate) async fn snooze( | |||
| 49 | 38 | if result.rows_affected() > 0 { | |
| 50 | 39 | get_task_by_id(pool, id, user_id).await | |
| 51 | 40 | } else { | |
| 41 | + | // Distinguish "not found" from "wrong status" | |
| 42 | + | if let Some(task) = get_task_by_id(pool, id, user_id).await? { | |
| 43 | + | if task.status == TaskStatus::Completed { | |
| 44 | + | return Err(CoreError::validation("status", "cannot snooze a completed task")); | |
| 45 | + | } | |
| 46 | + | if task.status == TaskStatus::Deleted { | |
| 47 | + | return Err(CoreError::validation("status", "cannot snooze a deleted task")); | |
| 48 | + | } | |
| 49 | + | } | |
| 52 | 50 | Ok(None) | |
| 53 | 51 | } | |
| 54 | 52 | } |
| @@ -66,6 +66,12 @@ pub fn parse_uuid_opt(s: Option<&str>) -> Result<Option<Uuid>, CoreError> { | |||
| 66 | 66 | s.map(parse_uuid).transpose() | |
| 67 | 67 | } | |
| 68 | 68 | ||
| 69 | + | /// Escape LIKE wildcards in a value to prevent unintended pattern matching. | |
| 70 | + | #[inline] | |
| 71 | + | pub fn escape_like(value: &str) -> String { | |
| 72 | + | value.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_") | |
| 73 | + | } | |
| 74 | + | ||
| 69 | 75 | /// Validate email address format (RFC 5321/5322 compliant). | |
| 70 | 76 | /// | |
| 71 | 77 | /// Validates the basic structure of an email address: |