From 1173c1cf12e038196e5d1a14b50491fe8709ba29 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 8 Oct 2026 14:11:53 -0400 Subject: [PATCH] A device's snooze is clamped to the server's range, 1 minute to 30 days The server clamped; the core took any i64, so 0 or less set a reminder in the past and a huge value overflowed Duration::minutes. Every caller passes 60 or 1440 today, so this was latent. Both sides now name the range, SNOOZE_MAX_MINUTES, and point at each other. Fixes #5386. Co-Authored-By: Claude Opus 5.5 --- core/src/local/store.rs | 25 +++++++++++++++++++++++++ src/inkwell/notes/__init__.py | 7 ++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/core/src/local/store.rs b/core/src/local/store.rs index 4478181..777f4e7 100644 --- a/core/src/local/store.rs +++ b/core/src/local/store.rs @@ -711,8 +711,14 @@ pub fn complete_reminder(conn: &Connection, id: &str) -> rusqlite::Result load_note(conn, id) } +/// The longest snooze, in minutes: 30 days. The server's snooze route clamps to the +/// same range (`notes.SNOOZE_MAX_MINUTES`), so a device and the web agree (#5386). +pub const SNOOZE_MAX_MINUTES: i64 = 60 * 24 * 30; + pub fn snooze_reminder(conn: &Connection, id: &str, minutes: i64) -> rusqlite::Result { require_owner(conn, id)?; + // 0 or less would set a reminder in the past; a huge value overflows `Duration`. + let minutes = minutes.clamp(1, SNOOZE_MAX_MINUTES); let t = (Utc::now() + Duration::minutes(minutes)).to_rfc3339_opts(SecondsFormat::Millis, true); conn.execute( "UPDATE notes SET remind_at = ?1 WHERE id = ?2", @@ -1229,6 +1235,25 @@ mod tests { assert!(own.shared_by.is_none()); } + #[test] + fn a_snooze_lasts_between_a_minute_and_thirty_days() { + let conn = db(); + let id = note(&conn, "call back").id; + // Minutes from just before the call, rounded: the stored time is cut to the + // millisecond, so it can land a hair under the whole minute. + let lands = |minutes: i64| -> i64 { + let before = Utc::now(); + let snoozed = snooze_reminder(&conn, &id, minutes).expect("snooze"); + let stamp = snoozed.remind_at.expect("set"); + let at = DateTime::parse_from_rfc3339(&stamp).expect("rfc3339"); + ((at.with_timezone(&Utc) - before).num_seconds() + 30) / 60 + }; + assert_eq!(lands(0), 1, "never in the past"); + assert_eq!(lands(-5), 1); + assert_eq!(lands(i64::MAX), SNOOZE_MAX_MINUTES, "no overflow"); + assert_eq!(lands(60), 60); + } + #[test] fn a_view_share_changes_nothing_here() { let conn = db(); diff --git a/src/inkwell/notes/__init__.py b/src/inkwell/notes/__init__.py index 77d447e..0d23f81 100644 --- a/src/inkwell/notes/__init__.py +++ b/src/inkwell/notes/__init__.py @@ -206,6 +206,11 @@ async def complete_reminder(note_id: str): return jsonify(await _serialize_note(db, note, g.user_id)) +# The longest snooze: 30 days. The core clamps to the same range +# (`store::SNOOZE_MAX_MINUTES`), so a device and the web agree (#5386). +SNOOZE_MAX_MINUTES = 60 * 24 * 30 + + @bp.post("//reminder/snooze") @login_required async def snooze_reminder(note_id: str): @@ -215,7 +220,7 @@ async def snooze_reminder(note_id: str): minutes = int(data.get("minutes", 10)) except (ValueError, TypeError): minutes = 10 - minutes = max(1, min(minutes, 60 * 24 * 30)) # 1 minute .. 30 days + minutes = max(1, min(minutes, SNOOZE_MAX_MINUTES)) async with session_scope() as db: note = await _get_owned(db, note_id) if note is None: