Skip to main content

max / makenotwork

Sweep Run #22 minors: media presign tier cap, bounded scheduler spawns, session cap Storage M1: media presign passed None for the tier per-file cap, so an oversize media video was only rejected at confirm (after the bytes were spent). Fetch get_effective_max_file_bytes (None for images, tier limit for video) and pass it, matching the uploads/versions presign paths. Scheduler bare-spawn: the publish loop's per-item/post announcement fan-out and MT-thread creation used bare tokio::spawn (unbounded per batch, can starve the 25-conn DB pool). Route all six through the bounded state.bg pool (8 concurrent, 1024 queued) — the scheduler's own loop stays a bare long-lived task. Session cap: track_session minted a user_sessions row per login with no bound. Add MAX_SESSIONS_PER_USER (100) + prune_user_sessions_over_cap (keeps the newest active sessions, never touches pending_2fa) and call it best-effort after each login. Regression test: cap keeps the newest, evicts the oldest, spares pending_2fa, and is idempotent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-18 02:20 UTC
Commit: 5342e16fe786772d9bf1cca43f76a5197059f786
Parent: 0a50783
7 files changed, +109 insertions, -10 deletions
@@ -499,6 +499,17 @@ pub async fn track_session(
499 499 let tracking_id =
500 500 db::sessions::create_user_session(pool, user_id, user_agent.as_deref(), ip.as_deref()).await?;
501 501
502 + // Cap the user's active sessions so repeated logins can't grow the table
503 + // unboundedly. Best-effort: a prune failure must not break the login (the row
504 + // we just created is the newest and is never the one evicted).
505 + match db::sessions::prune_user_sessions_over_cap(pool, user_id, constants::MAX_SESSIONS_PER_USER).await {
506 + Ok(pruned) if pruned > 0 => {
507 + tracing::info!(pruned, "pruned oldest sessions over the per-user cap");
508 + }
509 + Ok(_) => {}
510 + Err(e) => tracing::warn!(error = ?e, "failed to prune sessions over the per-user cap"),
511 + }
512 +
502 513 session
503 514 .insert(SESSION_TRACKING_KEY, tracking_id)
504 515 .await
@@ -19,6 +19,13 @@ pub const SESSION_EXPIRY_DAYS: i64 = 7;
19 19 /// session-revocation lag (admin suspend, logout-everywhere, password change) —
20 20 /// shorter = tighter revocation, slightly more DB load on the auth hot path.
21 21 pub const SESSION_TOUCH_CACHE_SECS: u64 = 5;
22 + /// Cap on tracked sessions per user. Each login mints a `user_sessions` row;
23 + /// without a bound, repeated logins (or an automated loop) grow the table
24 + /// unboundedly. After a new session is recorded, the oldest beyond this many are
25 + /// pruned (a sliding window) — the current session is always the newest, so it
26 + /// is never evicted. Matches the 100-row session-listing cap, so anything pruned
27 + /// was already invisible bloat, never a session a user could see or manage.
28 + pub const MAX_SESSIONS_PER_USER: i64 = 100;
22 29
23 30 // -- Login security --
24 31 pub const MAX_LOGIN_ATTEMPTS: i32 = 5;
@@ -360,6 +367,7 @@ const _: () = assert!(DB_MAX_LIFETIME_SECS > DB_IDLE_TIMEOUT_SECS);
360 367 // Session constants
361 368 const _: () = assert!(SESSION_EXPIRY_DAYS > 0 && SESSION_EXPIRY_DAYS <= 365);
362 369 const _: () = assert!(SESSION_TOUCH_CACHE_SECS > 0 && SESSION_TOUCH_CACHE_SECS < 86400);
370 + const _: () = assert!(MAX_SESSIONS_PER_USER > 0);
363 371
364 372 // Login security
365 373 const _: () = assert!(MAX_LOGIN_ATTEMPTS > 0);
@@ -25,6 +25,39 @@ pub async fn create_user_session(
25 25 Ok(row)
26 26 }
27 27
28 + /// Cap a user's active sessions to the newest `max`, deleting older ones.
29 + ///
30 + /// Bounds unbounded `user_sessions` growth from repeated logins (or an automated
31 + /// loop). Only `kind='active'` rows are counted and pruned — `pending_2fa`
32 + /// intermediates are never touched. Ordered by `last_active_at DESC`, so the
33 + /// freshly-created current session (newest) is always kept and the stalest are
34 + /// evicted. Returns the number of rows pruned.
35 + #[tracing::instrument(skip_all)]
36 + pub async fn prune_user_sessions_over_cap(
37 + pool: &PgPool,
38 + user_id: UserId,
39 + max: i64,
40 + ) -> Result<u64> {
41 + let result = sqlx::query(
42 + r#"
43 + DELETE FROM user_sessions
44 + WHERE user_id = $1 AND kind = 'active'
45 + AND id NOT IN (
46 + SELECT id FROM user_sessions
47 + WHERE user_id = $1 AND kind = 'active'
48 + ORDER BY last_active_at DESC
49 + LIMIT $2
50 + )
51 + "#,
52 + )
53 + .bind(user_id)
54 + .bind(max)
55 + .execute(pool)
56 + .await?;
57 +
58 + Ok(result.rows_affected())
59 + }
60 +
28 61 /// Insert a `kind='pending_2fa'` row for an intermediate session held between
29 62 /// the password step and the TOTP/backup-code step. Exposed to
30 63 /// `delete_all_sessions_for_user` so "log out everywhere" sweeps a phisher
@@ -150,10 +150,14 @@ pub(super) async fn media_presign(
150 150 // Early quota check — images bypass tier, video requires BigFiles+
151 151 db::creator_tiers::check_presign_allowed(&state.db, user.id, file_type).await?;
152 152
153 - // Validate the declared size against the static per-type cap before signing
154 - // it into Content-Length (media video is the largest upload class, up to
155 - // 20 GB — it had no protocol-level size guard before).
156 - super::validate_declared_upload_size(req.file_size_bytes, file_type, None)?;
153 + // Validate the declared size against the static per-type cap AND the user's
154 + // tier per-file cap before signing it into Content-Length, so an oversize
155 + // media upload is rejected at presign instead of after the bytes are spent
156 + // and only caught at confirm. `get_effective_max_file_bytes` returns None for
157 + // images (they bypass the tier cap) and the tier limit for video.
158 + let max_file_bytes =
159 + db::creator_tiers::get_effective_max_file_bytes(&state.db, user.id, file_type).await?;
160 + super::validate_declared_upload_size(req.file_size_bytes, file_type, max_file_bytes)?;
157 161
158 162 // Filename uniqueness is enforced at confirm time by the
159 163 // `idx_media_files_user_folder_name` unique index — see `media_confirm`,
@@ -131,7 +131,7 @@ pub fn spawn_scheduler(
131 131 );
132 132 let state_for_announce = state.clone();
133 133 let item_for_announce = item.clone();
134 - tokio::spawn(async move {
134 + state.bg.spawn("release-announcements", async move {
135 135 announcements::send_release_announcements(
136 136 &state_for_announce, &item_for_announce,
137 137 ).await;
@@ -163,7 +163,7 @@ pub fn spawn_scheduler(
163 163 );
164 164 let state_for_announce = state.clone();
165 165 let post_for_announce = post.clone();
166 - tokio::spawn(async move {
166 + state.bg.spawn("blog-post-announcements", async move {
167 167 announcements::send_blog_post_announcements(
168 168 &state_for_announce, &post_for_announce,
169 169 ).await;
@@ -24,7 +24,7 @@ pub fn spawn_mt_thread_for_item(
24 24 let username = user.username.to_string();
25 25 let display_name = user.display_name.clone();
26 26
27 - tokio::spawn(async move {
27 + state.bg.spawn("mt-thread-item", async move {
28 28 create_mt_thread_for_item(
29 29 &mt,
30 30 &db_pool,
@@ -53,7 +53,7 @@ pub(super) fn spawn_mt_thread_for_item_by_lookup(state: &AppState, item: &DbItem
53 53 let item_title = item.title.clone();
54 54 let project_id = item.project_id;
55 55
56 - tokio::spawn(async move {
56 + state.bg.spawn("mt-thread-item-lookup", async move {
57 57 let Ok(Some(project)) = db::projects::get_project_by_id(&db_pool, project_id).await
58 58 else {
59 59 return;
@@ -139,7 +139,7 @@ pub fn spawn_mt_thread_for_blog_post(
139 139 let username = user.username.to_string();
140 140 let display_name = user.display_name.clone();
141 141
142 - tokio::spawn(async move {
142 + state.bg.spawn("mt-thread-blog", async move {
143 143 create_mt_thread_for_blog_post(
144 144 &mt,
145 145 &db_pool,
@@ -170,7 +170,7 @@ pub(super) fn spawn_mt_thread_for_blog_post_by_lookup(state: &AppState, post: &D
170 170 let post_slug = post.slug.to_string();
171 171 let project_id = post.project_id;
172 172
173 - tokio::spawn(async move {
173 + state.bg.spawn("mt-thread-blog-lookup", async move {
174 174 let Ok(Some(project)) = db::projects::get_project_by_id(&db_pool, project_id).await
175 175 else {
176 176 return;
@@ -211,3 +211,46 @@ async fn touch_session_and_revoke_primitive_is_idempotent() {
211 211 // once the auth touch cache lapses.
212 212 assert!(!touch_session(&h.db, sid).await.unwrap().valid, "a revoked session must touch as invalid");
213 213 }
214 +
215 + // Per-user session cap: prune_user_sessions_over_cap bounds active-session growth,
216 + // keeping the newest `max` active sessions and never touching pending_2fa rows.
217 + #[tokio::test]
218 + async fn prune_user_sessions_over_cap_keeps_newest_and_spares_pending_2fa() {
219 + use makenotwork::db::sessions::prune_user_sessions_over_cap;
220 +
221 + let mut h = TestHarness::new().await;
222 + let user_id = h.signup("captest", "captest@test.com", "password123").await;
223 +
224 + // signup created one active session (last_active_at = now, the newest). Add
225 + // three ancient active sessions (oldest first) + one pending_2fa intermediate.
226 + let mut ancient = Vec::new();
227 + for ts in [1i64, 2, 3] {
228 + let id: uuid::Uuid = sqlx::query_scalar(
229 + "INSERT INTO user_sessions (user_id, kind, last_active_at) VALUES ($1, 'active', to_timestamp($2)) RETURNING id",
230 + ).bind(user_id).bind(ts).fetch_one(&h.db).await.unwrap();
231 + ancient.push(id);
232 + }
233 + sqlx::query("INSERT INTO user_sessions (user_id, kind, last_active_at) VALUES ($1, 'pending_2fa', to_timestamp(5))")
234 + .bind(user_id).execute(&h.db).await.unwrap();
235 +
236 + // 4 active + 1 pending_2fa. Cap to 2 active → the two oldest active are pruned.
237 + let pruned = prune_user_sessions_over_cap(&h.db, user_id, 2).await.unwrap();
238 + assert_eq!(pruned, 2, "the two oldest active sessions must be pruned");
239 +
240 + let active: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM user_sessions WHERE user_id = $1 AND kind = 'active'")
241 + .bind(user_id).fetch_one(&h.db).await.unwrap();
242 + assert_eq!(active, 2, "active sessions capped to the newest two");
243 +
244 + let pending: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM user_sessions WHERE user_id = $1 AND kind = 'pending_2fa'")
245 + .bind(user_id).fetch_one(&h.db).await.unwrap();
246 + assert_eq!(pending, 1, "a pending_2fa session must never be pruned by the cap");
247 +
248 + // The two oldest (ts=1, ts=2) were evicted; the newer ancient (ts=3) and the
249 + // signup session (now) are kept.
250 + assert!(!session_exists(&h, ancient[0]).await, "oldest active session must be pruned");
251 + assert!(!session_exists(&h, ancient[1]).await, "second-oldest active session must be pruned");
252 + assert!(session_exists(&h, ancient[2]).await, "the newer active session must survive");
253 +
254 + // Re-pruning at the same cap is a no-op.
255 + assert_eq!(prune_user_sessions_over_cap(&h.db, user_id, 2).await.unwrap(), 0, "re-prune is idempotent");
256 + }