Skip to main content

max / goingson

Fix cold-spot correctness: parser UTF-8 panic, desktop blob_gc, backup collision, FE bugs, XSS sinks - parser.rs/search_parser.rs: char-boundary-safe unit split (was split_at(len-1) panic on trailing multibyte) + regression tests - main.rs: run blob_gc::reconcile + cleanup_stale_temp_files on desktop (were mobile-only; desktop leaked orphan blobs + temp files) - create_backup delegates to create_backup_now (collision-safe filename, spawn_blocking, last_backup_at); drops dead duplicate path - settings-sync.js: Retry toast referenced undefined syncNow -> doSyncNow - address-highlight.js: per-attach debounce timer (was module-shared across To/CC/BCC) - email-accounts.js/settings.js/search.js: escape server- and import-sourced strings before innerHTML Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-04 17:44 UTC
Commit: ea9ba852840db089b9461114b2b6381f7fc574d1
Parent: b45802a
9 files changed, +64 insertions, -44 deletions
@@ -193,16 +193,19 @@ fn parse_relative_date(s: &str, from: NaiveDate, time: NaiveTime) -> Option<Date
193 193 return None;
194 194 }
195 195
196 - let (num_str, unit) = s.split_at(s.len() - 1);
196 + // Split off the last char (the unit) using char-boundary-safe indexing so a
197 + // trailing multibyte char (e.g. "1😀") returns None instead of panicking.
198 + let (unit_idx, unit) = s.char_indices().next_back()?;
199 + let num_str = &s[..unit_idx];
197 200 let num: i64 = num_str.parse().ok()?;
198 201 if !(0..=MAX_RELATIVE_DATE_DAYS).contains(&num) {
199 202 return None;
200 203 }
201 204
202 205 let target = match unit {
203 - "d" => from + Duration::days(num),
204 - "w" => from + Duration::weeks(num),
205 - "m" => {
206 + 'd' => from + Duration::days(num),
207 + 'w' => from + Duration::weeks(num),
208 + 'm' => {
206 209 // Approximate month
207 210 from + Duration::days(num * APPROXIMATE_DAYS_PER_MONTH)
208 211 }
@@ -456,4 +459,16 @@ mod tests {
456 459 assert_eq!(strip_prefix_ci("proj:test", "project:"), None);
457 460 assert_eq!(strip_prefix_ci("pr", "project:"), None);
458 461 }
462 +
463 + #[test]
464 + fn test_due_relative_multibyte_does_not_panic() {
465 + // Regression: a trailing multibyte char in a +Nunit relative date used to
466 + // panic in split_at(len-1). Relative dates are reached only via a leading '+'.
467 + for input in ["Task due:+1😀", "Task due:+3€", "Task due:+😀", "Task due:+1d"] {
468 + let _ = parse_quick_add(input);
469 + }
470 + // The multibyte unit is unrecognized → no due; "+1d" is valid → due set.
471 + assert!(parse_quick_add("Task due:+1😀").due.is_none());
472 + assert!(parse_quick_add("Task due:+1d").due.is_some());
473 + }
459 474 }
@@ -321,7 +321,10 @@ fn parse_relative_date(s: &str, from: NaiveDate, time: NaiveTime) -> Option<Date
321 321 return None;
322 322 }
323 323
324 - let (num_str, unit) = rest.split_at(rest.len() - 1);
324 + // Split off the last char (the unit) using char-boundary-safe indexing so a
325 + // trailing multibyte char (e.g. "+1😀") returns None instead of panicking.
326 + let (unit_idx, unit) = rest.char_indices().next_back()?;
327 + let num_str = &rest[..unit_idx];
325 328 let raw: i64 = num_str.parse::<i64>().ok()?;
326 329 if raw > MAX_RELATIVE_DATE_DAYS {
327 330 return None;
@@ -329,9 +332,9 @@ fn parse_relative_date(s: &str, from: NaiveDate, time: NaiveTime) -> Option<Date
329 332 let num: i64 = raw * sign;
330 333
331 334 let target = match unit {
332 - "d" => from + Duration::days(num),
333 - "w" => from + Duration::weeks(num),
334 - "m" => from + Duration::days(num * APPROXIMATE_DAYS_PER_MONTH),
335 + 'd' => from + Duration::days(num),
336 + 'w' => from + Duration::weeks(num),
337 + 'm' => from + Duration::days(num * APPROXIMATE_DAYS_PER_MONTH),
335 338 _ => return None,
336 339 };
337 340
@@ -430,4 +433,15 @@ mod tests {
430 433 let q = parse_search_query("is:pending is:pending");
431 434 assert_eq!(q.is_filters.len(), 1);
432 435 }
436 +
437 + #[test]
438 + fn test_relative_date_multibyte_does_not_panic() {
439 + // Regression: a trailing multibyte char used to panic in split_at(len-1).
440 + for input in ["after:+1😀", "before:-3€", "after:+😀", "after:+1d"] {
441 + let _ = parse_search_query(input);
442 + }
443 + // "+1d" is the only valid relative date; multibyte input yields no filter.
444 + assert!(parse_search_query("after:+1😀").date_from.is_none());
445 + assert!(parse_search_query("after:+1d").date_from.is_some());
446 + }
433 447 }
@@ -20,7 +20,6 @@
20 20
21 21 // Session-scoped cache: email (lowercase) -> status string
22 22 const statusCache = new Map();
23 - let debounceTimer = null;
24 23
25 24 /**
26 25 * Attach address highlighting and ghost text to an email input element.
@@ -32,6 +31,9 @@
32 31 function attach(input, opts) {
33 32 if (!input || input._addressHighlight) return;
34 33
34 + // Per-input debounce timer (each To/CC/BCC field validates independently).
35 + let debounceTimer = null;
36 +
35 37 // Create mirror div
36 38 const mirror = document.createElement('div');
37 39 mirror.className = 'address-highlight-mirror';
@@ -503,8 +503,9 @@
503 503 const result = await GoingsOn.api.emailAccounts.test(id);
504 504
505 505 // Build detailed result modal
506 - const imapStatus = result.imapSuccess ? 'IMAP OK' : 'IMAP Failed: ' + result.imapMessage;
507 - const smtpStatus = result.smtpSuccess ? 'SMTP OK' : 'SMTP Failed: ' + result.smtpMessage;
506 + // imapMessage/smtpMessage are server-returned strings — escape before innerHTML.
507 + const imapStatus = result.imapSuccess ? 'IMAP OK' : 'IMAP Failed: ' + esc(result.imapMessage);
508 + const smtpStatus = result.smtpSuccess ? 'SMTP OK' : 'SMTP Failed: ' + esc(result.smtpMessage);
508 509
509 510 const foldersHtml = result.availableFolders && result.availableFolders.length > 0
510 511 ? `<div class="test-conn-section">
@@ -158,12 +158,12 @@
158 158 const snippet = r.snippet ? `<span class="cp-snippet">${esc(r.snippet)}</span>` : '';
159 159
160 160 html += `<div class="cp-result${selected}" data-index="${i}" onmouseenter="GoingsOn.search._hover(${i})" onclick="GoingsOn.search._select(${i})">
161 - <span class="cp-type-icon" title="${label}">${icon}</span>
161 + <span class="cp-type-icon" title="${escAttr(label)}">${icon}</span>
162 162 <div class="cp-result-body">
163 163 <span class="cp-title">${esc(r.title)}</span>
164 164 ${project}${snippet}
165 165 </div>
166 - <span class="cp-type-badge">${label}</span>
166 + <span class="cp-type-badge">${esc(label)}</span>
167 167 </div>`;
168 168 }
169 169
@@ -335,7 +335,7 @@
335 335 refreshSyncSection();
336 336 } catch (err) {
337 337 GoingsOn.ui.showToast('Sync failed: ' + GoingsOn.utils.getErrorMessage(err), 'error', {
338 - action: { label: 'Retry', fn: syncNow },
338 + action: { label: 'Retry', fn: doSyncNow },
339 339 duration: 8000,
340 340 });
341 341 if (btn) {
@@ -90,7 +90,7 @@
90 90
91 91 const highContrastGroup = highContrastThemes.length > 0
92 92 ? `<optgroup label="High Contrast">
93 - ${highContrastThemes.map(t => `<option value="${t.id}" ${savedTheme === t.id ? 'selected' : ''}>${esc(t.name)}</option>`).join('')}
93 + ${highContrastThemes.map(t => `<option value="${escAttr(t.id)}" ${savedTheme === t.id ? 'selected' : ''}>${esc(t.name)}</option>`).join('')}
94 94 </optgroup>`
95 95 : '';
96 96
@@ -104,10 +104,10 @@
104 104 <option value="system" ${savedTheme === 'system' ? 'selected' : ''}>Follow System</option>
105 105 </optgroup>
106 106 <optgroup label="Light Themes">
107 - ${lightThemes.map(t => `<option value="${t.id}" ${savedTheme === t.id ? 'selected' : ''}>${esc(t.name)}</option>`).join('')}
107 + ${lightThemes.map(t => `<option value="${escAttr(t.id)}" ${savedTheme === t.id ? 'selected' : ''}>${esc(t.name)}</option>`).join('')}
108 108 </optgroup>
109 109 <optgroup label="Dark Themes">
110 - ${darkThemes.map(t => `<option value="${t.id}" ${savedTheme === t.id ? 'selected' : ''}>${esc(t.name)}</option>`).join('')}
110 + ${darkThemes.map(t => `<option value="${escAttr(t.id)}" ${savedTheme === t.id ? 'selected' : ''}>${esc(t.name)}</option>`).join('')}
111 111 </optgroup>
112 112 ${highContrastGroup}
113 113 </select>
@@ -9,7 +9,6 @@
9 9 use std::path::Path;
10 10 use std::sync::Arc;
11 11
12 - use chrono::Utc;
13 12 use serde::{Deserialize, Serialize};
14 13 use tauri::{Manager, State};
15 14 use tracing::instrument;
@@ -245,32 +244,12 @@ pub async fn create_backup(
245 244 state: State<'_, Arc<AppState>>,
246 245 app: tauri::AppHandle,
247 246 ) -> Result<ExportResponse, ApiError> {
248 - // Get backup directory
249 - let backup_dir = app
250 - .path()
251 - .app_data_dir()
252 - .map_api_err("Failed to get app data dir", ApiError::internal)?
253 - .join("backups");
254 -
255 - std::fs::create_dir_all(&backup_dir)
256 - .map_api_err("Failed to create backup directory", ApiError::internal)?;
257 -
258 - // Generate timestamped filename
259 - let timestamp = Utc::now().format("%Y%m%d-%H%M%S");
260 - let filename = format!("goingson-backup-{}.json.gz", timestamp);
261 - let file_path = backup_dir.join(&filename);
262 -
263 - let export = crate::backup_scheduler::collect_full_export(&state, DESKTOP_USER_ID).await?;
264 - let item_count = export.total_count();
265 -
266 - let size_bytes = backup::write_backup(&export, &file_path)
267 - .map_api_err("Failed to write backup", ApiError::internal)?;
268 -
269 - Ok(ExportResponse {
270 - file_path: file_path.to_string_lossy().into_owned(),
271 - item_count,
272 - size_bytes,
273 - })
247 + // Delegate to the scheduler's on-demand path: it uses a collision-safe
248 + // filename (GO-11), offloads gzip to the blocking pool, and records
249 + // last_backup_at — none of which a hand-rolled body here would.
250 + crate::backup_scheduler::create_backup_now(&app, &state)
251 + .await
252 + .map_err(ApiError::internal)
274 253 }
275 254
276 255 /// Lists available backups in the backup directory.
@@ -4,6 +4,7 @@
4 4 #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
5 5
6 6 use goingson_desktop::backup_scheduler;
7 + use goingson_desktop::blob_gc;
7 8 use goingson_desktop::commands;
8 9 use goingson_desktop::email_sync_scheduler;
9 10 use goingson_desktop::sync_scheduler;
@@ -305,6 +306,9 @@ fn main() {
305 306 tauri::async_runtime::block_on(async move {
306 307 let state = AppState::new(&app_handle).await
307 308 .expect("Failed to initialize app state");
309 + // Reclaim orphaned attachment blobs before the schedulers spawn
310 + // and before the UI can create attachments — race-free here.
311 + blob_gc::reconcile(&state.pool, &state.data_dir).await;
308 312 app_handle.manage(Arc::new(state));
309 313 });
310 314
@@ -352,6 +356,11 @@ fn main() {
352 356 sync_scheduler::start_sync_scheduler(cloud_sync_handle, cloud_cancel).await;
353 357 });
354 358
359 + // Clean up stale email preview temp files from previous sessions
360 + tauri::async_runtime::spawn(async {
361 + commands::cleanup_stale_temp_files().await;
362 + });
363 +
355 364 // Check for OTA updates after a short delay (desktop only).
356 365 // Gated on the user's preference (default true).
357 366 #[cfg(not(any(target_os = "ios", target_os = "android")))]