Skip to main content

max / makenotwork

Audit remediation: security hardening, OAuth provider, MT support, monitoring Multi-round audit improvements: - Security A+: rate limiting on forgot-password, transaction wrapping on refund flow, constant-time webhook signature comparison, malware scanning pipeline hardening, input validation boundaries tightened - OAuth provider: /oauth/authorize, /oauth/token, /oauth/userinfo endpoints for MT PKCE flow. Migration 026 (redirect_uris). Relaxed redirect_uri validation - MT integration: /api/public/projects endpoint, forum directory support - Monitoring: /health page with PoM integration (incidents, expandable checks, route status, formatted timestamps) - Landing page: feature grid, explore links, stronger copy - Code documentation: module-level docs across all route and DB modules - Deploy: S3 diagnostic logging, deploy.sh updates Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-14 03:36 UTC
Commit: e98b4667e1aa367c59291ad23bc067a3ae7cf18b
Parent: 942a470
85 files changed, +1909 insertions, -330 deletions
M .gitignore +3
@@ -21,3 +21,6 @@ Thumbs.db
21 21
22 22 # SQLx offline mode cache
23 23 .sqlx/
24 +
25 + # Generated template partial (build.rs output)
26 + server_code/makenotwork/templates/_head_assets.html
A README.md +97
@@ -0,0 +1,97 @@
1 + # MakeNotWork
2 +
3 + Creator platform with 0% platform fee. Only Stripe's ~3% processing fee applies. Live at [makenot.work](https://makenot.work).
4 +
5 + Built with Rust (2024 edition), Axum, PostgreSQL, Askama templates, and HTMX.
6 +
7 + ## Prerequisites
8 +
9 + - **Rust** (stable toolchain, 1.85+, 2024 edition)
10 + - **PostgreSQL** (16+)
11 + - **Environment variables** via `.env` file: database URL, Stripe keys, Postmark token, S3 credentials, Sentry DSN, session secret, JWT secret. See `.env.example` or the systemd unit for the full list.
12 +
13 + ## Build and Run
14 +
15 + All commands run from `server_code/makenotwork/`:
16 +
17 + ```sh
18 + # Development
19 + cargo run
20 +
21 + # Run unit tests (no database needed)
22 + cargo test
23 +
24 + # Run integration tests (needs a running PostgreSQL instance)
25 + TEST_DATABASE_URL="postgres://user:pass@host:5432/postgres" cargo test --test integration
26 +
27 + # Admin CLI
28 + cargo run --bin mnw-admin
29 + ```
30 +
31 + Production deployment uses `cargo zigbuild` for cross-compilation to x86_64 Linux. See `deploy/deploy.sh`.
32 +
33 + ## Project Structure
34 +
35 + ```
36 + server_code/makenotwork/
37 + src/
38 + main.rs Entry point
39 + lib.rs Library root
40 + config.rs Configuration
41 + auth.rs Session authentication + 2FA (TOTP, WebAuthn)
42 + synckit_auth.rs SyncKit JWT authentication
43 + db/ PostgreSQL queries (sqlx, compile-time checked)
44 + routes/ Axum route handlers
45 + pages/ HTML page routes (public/, dashboard/)
46 + api/ JSON API endpoints
47 + stripe/ Stripe webhooks + Connect
48 + synckit.rs SyncKit cloud sync endpoints
49 + storage.rs File upload/download
50 + templates/ Askama HTML templates
51 + scanning/ File scanning (ClamAV, YARA, hash lookup)
52 + types/ Shared types
53 + email/ Transactional email (Postmark)
54 + migrations/ SQLx migrations (auto-applied on boot)
55 + static/ CSS, JS, fonts, images
56 + tests/ Integration tests
57 + deploy/ Deployment scripts, systemd unit, Caddyfile
58 + ```
59 +
60 + ## Key Integrations
61 +
62 + - **Stripe Connect** -- creator payouts, subscriptions, checkout
63 + - **Postmark** -- transactional email (verification, password reset, purchase receipts)
64 + - **S3** (Hetzner Object Storage) -- file storage for creator uploads
65 + - **SyncKit** -- cloud sync and device management API for client applications
66 + - **Sentry** -- error tracking
67 + - **git2** -- built-in git source browser
68 +
69 + ## Deployment
70 +
71 + Runs on a Hetzner VPS with systemd and Caddy (reverse proxy + TLS). No Docker.
72 +
73 + ```sh
74 + # Full deploy (cross-compile + upload + restart)
75 + ./deploy/deploy.sh
76 +
77 + # Binary-only deploy (skip config files)
78 + ./deploy/deploy.sh --quick
79 +
80 + # Config-only deploy (Caddyfile, systemd unit, static assets, error pages)
81 + ./deploy/deploy.sh --config
82 + ```
83 +
84 + SQLx migrations run automatically on application startup.
85 +
86 + ## Testing
87 +
88 + Each integration test creates and drops its own PostgreSQL database, so tests run in full isolation. Unit tests have no external dependencies.
89 +
90 + ```sh
91 + cargo test # Unit tests
92 + cargo test --test integration # Integration tests (needs TEST_DATABASE_URL)
93 + ```
94 +
95 + ## License
96 +
97 + PolyForm Noncommercial 1.0.0
@@ -3453,7 +3453,7 @@ dependencies = [
3453 3453
3454 3454 [[package]]
3455 3455 name = "makenotwork"
3456 - version = "0.2.0"
3456 + version = "0.2.2"
3457 3457 dependencies = [
3458 3458 "ammonia",
3459 3459 "anyhow",
@@ -3468,6 +3468,7 @@ dependencies = [
3468 3468 "base64 0.22.1",
3469 3469 "chrono",
3470 3470 "clap",
3471 + "dashmap",
3471 3472 "dotenvy",
3472 3473 "git2",
3473 3474 "goblin",
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makenotwork"
3 - version = "0.2.1"
3 + version = "0.2.2"
4 4 edition = "2024"
5 5 license-file = "../../LICENSE"
6 6
@@ -35,6 +35,9 @@ argon2 = "0.5.3"
35 35 tower-sessions = { version = "0.14.0", features = ["axum-core"] }
36 36 tower-sessions-sqlx-store = { version = "0.15.0", features = ["postgres"] }
37 37
38 + # Concurrent hash map (session touch cache)
39 + dashmap = "6"
40 +
38 41 # Rate Limiting
39 42 tower_governor = "0.6.0"
40 43 governor = "0.8.1"
@@ -1,4 +1,7 @@
1 + use std::collections::hash_map::DefaultHasher;
2 + use std::hash::{Hash, Hasher};
1 3 use std::process::Command;
4 + use std::{fs, path::Path};
2 5
3 6 fn main() {
4 7 // Set GIT_HASH env var for compile-time inclusion via option_env!()
@@ -14,4 +17,47 @@ fn main() {
14 17 println!("cargo::rustc-env=GIT_HASH={}", hash);
15 18 // Only re-run when HEAD changes
16 19 println!("cargo::rerun-if-changed=.git/HEAD");
20 +
21 + // --- Static asset fingerprinting ---
22 + // Hash the content of key static files to produce a version suffix.
23 + // When any watched file changes, URLs in templates get a new ?v= param,
24 + // busting browser caches automatically.
25 + let static_files = [
26 + "static/style.css",
27 + "static/htmx.min.js",
28 + "static/upload.js",
29 + "static/passkey.js",
30 + "static/insertions.js",
31 + ];
32 +
33 + let mut hasher = DefaultHasher::new();
34 + for path in &static_files {
35 + println!("cargo::rerun-if-changed={}", path);
36 + if let Ok(content) = fs::read(path) {
37 + content.hash(&mut hasher);
38 + }
39 + }
40 + let static_hash = format!("{:016x}", hasher.finish());
41 + let version = &static_hash[..8];
42 +
43 + // Generate a template partial with versioned asset URLs.
44 + // base.html includes this via {% include "_head_assets.html" %}
45 + let partial = format!(
46 + r#" <link rel="preload" href="/static/fonts/Lato-Regular.woff2" as="font" type="font/woff2" crossorigin>
47 + <link rel="preload" href="/static/fonts/ysrf.woff2" as="font" type="font/woff2" crossorigin>
48 + <link rel="stylesheet" href="/static/style.css?v={v}">
49 + <link rel="icon" href="/static/images/favicon.ico" type="image/x-icon">
50 + <script src="/static/htmx.min.js"></script>
51 + <script src="/static/upload.js?v={v}"></script>"#,
52 + v = version,
53 + );
54 +
55 + let out_path = Path::new("templates/_head_assets.html");
56 + // Only write if content changed (avoids unnecessary recompilation)
57 + let needs_write = fs::read_to_string(out_path)
58 + .map(|existing| existing != partial)
59 + .unwrap_or(true);
60 + if needs_write {
61 + fs::write(out_path, &partial).expect("failed to write _head_assets.html");
62 + }
17 63 }
@@ -39,10 +39,22 @@ upload_config() {
39 39 ssh $SERVER "mkdir -p $REMOTE_DIR/error-pages"
40 40 scp $DEPLOY_DIR/error-pages/*.html $SERVER:$REMOTE_DIR/error-pages/
41 41
42 + # Minify CSS for production (restore source on exit)
43 + echo "[config] Minifying CSS..."
44 + cp static/style.css static/style.css.src
45 + restore_css() { [ -f static/style.css.src ] && mv static/style.css.src static/style.css; }
46 + trap restore_css EXIT
47 + npx --yes clean-css-cli -o static/style.css static/style.css.src
48 + echo "[config] CSS: $(wc -c < static/style.css.src | tr -d ' ')B -> $(wc -c < static/style.css | tr -d ' ')B"
49 +
42 50 # Static assets (CSS, JS, fonts, images)
43 51 echo "[config] Uploading static assets..."
44 52 rsync -az --delete static/ $SERVER:$REMOTE_DIR/static/
45 53
54 + # Restore unminified CSS
55 + restore_css
56 + trap - EXIT
57 +
46 58 # Documentation (public markdown files)
47 59 echo "[config] Uploading documentation..."
48 60 rsync -az --delete ../../docs/public/ $SERVER:$REMOTE_DIR/docs/public/
@@ -0,0 +1,6 @@
1 + -- Generation counters for ETag-based HTTP caching.
2 + -- Bumped on any write that changes user-visible data.
3 + -- Tab handlers check generation before rendering; clients get 304 on unchanged data.
4 +
5 + ALTER TABLE users ADD COLUMN cache_generation BIGINT NOT NULL DEFAULT 0;
6 + ALTER TABLE projects ADD COLUMN cache_generation BIGINT NOT NULL DEFAULT 0;
@@ -0,0 +1,2 @@
1 + -- Add configurable redirect_uris to sync_apps for non-localhost OAuth clients.
2 + ALTER TABLE sync_apps ADD COLUMN redirect_uris TEXT[] NOT NULL DEFAULT '{}';
@@ -1,4 +1,19 @@
1 - //! Authentication with argon2 password hashing and session management
1 + //! Authentication, session management, and account security.
2 + //!
3 + //! Passwords are hashed with Argon2id (random salt per hash). Sessions use
4 + //! `tower-sessions` with ID regeneration on login (prevents fixation) and
5 + //! full flush on logout. Each login creates a tracked session row in
6 + //! `user_sessions` for remote revocation from the security dashboard.
7 + //!
8 + //! Two-factor authentication supports both TOTP (time-based one-time
9 + //! passwords via `totp-rs`) and WebAuthn passkeys (via `webauthn-rs`).
10 + //! Account lockout is enforced after repeated failed login attempts, with
11 + //! progressive delays tracked by `failed_login_attempts` and `locked_until`
12 + //! on the user row. New-device login notifications are sent via Postmark
13 + //! when enabled.
14 + //!
15 + //! Extractors: [`AuthUser`] (required login), [`MaybeUser`] (optional),
16 + //! [`AdminUser`] (admin-only, hides routes with 404).
2 17
3 18 use argon2::{
4 19 password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
@@ -12,6 +27,8 @@ use serde::{Deserialize, Serialize};
12 27 use sqlx::PgPool;
13 28 use tower_sessions::Session;
14 29
30 + use std::time::Instant;
31 +
15 32 use crate::config::Config;
16 33 use crate::constants;
17 34 use crate::db::{self, UserId, UserSessionId, Username};
@@ -74,14 +91,36 @@ impl FromRequestParts<crate::AppState> for AuthUser {
74 91 .map_err(|e| AppError::Internal(anyhow::anyhow!("Session error: {}", e)))?
75 92 .ok_or(AppError::Unauthorized)?;
76 93
77 - // Validate session tracking (skip for legacy sessions without tracking ID)
94 + // Validate session tracking (skip for legacy sessions without tracking ID).
95 + // Uses an in-memory cache to avoid hitting the DB on every request —
96 + // if this session was validated within SESSION_TOUCH_CACHE_SECS, skip the query.
97 + let mut user = user;
78 98 if let Ok(Some(tracking_id)) = session
79 99 .get::<UserSessionId>(SESSION_TRACKING_KEY)
80 100 .await
81 - && !db::sessions::touch_session(&state.db, tracking_id).await.unwrap_or(true)
82 101 {
83 - let _ = session.flush().await;
84 - return Err(AppError::Unauthorized);
102 + let cache_ttl = std::time::Duration::from_secs(constants::SESSION_TOUCH_CACHE_SECS);
103 + let cached = state.session_cache.get(&tracking_id)
104 + .map(|entry| entry.elapsed() < cache_ttl)
105 + .unwrap_or(false);
106 +
107 + if !cached {
108 + let result = db::sessions::touch_session(&state.db, tracking_id)
109 + .await
110 + .unwrap_or(db::sessions::TouchResult { valid: false, suspended: false });
111 + if !result.valid {
112 + state.session_cache.remove(&tracking_id);
113 + let _ = session.flush().await;
114 + return Err(AppError::Unauthorized);
115 + }
116 + // If the user's suspended status changed since login, update the
117 + // session so check_not_suspended() reflects the live DB value.
118 + if user.suspended != result.suspended {
119 + user.suspended = result.suspended;
120 + let _ = session.insert(USER_SESSION_KEY, user.clone()).await;
121 + }
122 + state.session_cache.insert(tracking_id, Instant::now());
123 + }
85 124 }
86 125
87 126 Ok(AuthUser(user))
@@ -5,11 +5,12 @@
5 5 //! they're only used there.
6 6
7 7 // -- Database --
8 - pub const DB_POOL_MAX_CONNECTIONS: u32 = 10;
8 + pub const DB_POOL_MAX_CONNECTIONS: u32 = 25;
9 9 pub const DB_ACQUIRE_TIMEOUT_SECS: u64 = 3;
10 10
11 11 // -- Sessions --
12 12 pub const SESSION_EXPIRY_DAYS: i64 = 7;
13 + pub const SESSION_TOUCH_CACHE_SECS: u64 = 30; // Skip DB touch if validated within this window
13 14
14 15 // -- Login security --
15 16 pub const MAX_LOGIN_ATTEMPTS: i32 = 5;
@@ -116,6 +117,9 @@ pub const INVITE_LIMIT_PER_CREATOR: i64 = 5; // max unredeemed codes per creator
116 117 pub const GIT_MAX_FILE_SIZE_BYTES: usize = 1_024_000; // 1MB display limit
117 118 pub const GIT_COMMITS_PER_PAGE: usize = 30;
118 119
120 + // -- Webhook security --
121 + pub const WEBHOOK_TIMESTAMP_TOLERANCE_SECS: u64 = 300; // 5 minutes
122 +
119 123 // -- String / buffer limits --
120 124 pub const USER_AGENT_MAX_LENGTH: usize = 512;
121 125 pub const SYNCKIT_MAX_KEY_ENVELOPE_BYTES: usize = 4096;
@@ -323,10 +323,13 @@ pub async fn increment_sales_count<'e>(
323 323 }
324 324
325 325 /// Decrement the denormalized sales_count for an item (called on refund/unclaim).
326 - pub async fn decrement_sales_count(pool: &PgPool, item_id: ItemId) -> Result<()> {
326 + pub async fn decrement_sales_count<'e>(
327 + executor: impl sqlx::PgExecutor<'e>,
328 + item_id: ItemId,
329 + ) -> Result<()> {
327 330 sqlx::query("UPDATE items SET sales_count = GREATEST(sales_count - 1, 0) WHERE id = $1")
328 331 .bind(item_id)
329 - .execute(pool)
332 + .execute(executor)
330 333 .await?;
331 334
332 335 Ok(())
@@ -274,13 +274,16 @@ pub async fn revoke_license_key(pool: &PgPool, key_id: LicenseKeyId) -> Result<(
274 274 /// `transaction_id`. A single bulk UPDATE with `IN (SELECT ...)` would work
275 275 /// but the per-key loop keeps the pattern consistent with `revoke_license_key`
276 276 /// and the row counts are tiny (typically 1 key per transaction).
277 - pub async fn revoke_keys_by_transaction(pool: &PgPool, transaction_id: TransactionId) -> Result<u64> {
277 + pub async fn revoke_keys_by_transaction(
278 + conn: &mut sqlx::PgConnection,
279 + transaction_id: TransactionId,
280 + ) -> Result<u64> {
278 281 // Get all key IDs for this transaction
279 282 let key_ids: Vec<LicenseKeyId> = sqlx::query_scalar(
280 283 "SELECT id FROM license_keys WHERE transaction_id = $1 AND revoked_at IS NULL LIMIT 1000",
281 284 )
282 285 .bind(transaction_id)
283 - .fetch_all(pool)
286 + .fetch_all(&mut *conn)
284 287 .await?;
285 288
286 289 if key_ids.is_empty() {
@@ -296,7 +299,7 @@ pub async fn revoke_keys_by_transaction(pool: &PgPool, transaction_id: Transacti
296 299 "#,
297 300 )
298 301 .bind(transaction_id)
299 - .execute(pool)
302 + .execute(&mut *conn)
300 303 .await?;
301 304
302 305 // Deactivate all activations for those keys
@@ -305,7 +308,7 @@ pub async fn revoke_keys_by_transaction(pool: &PgPool, transaction_id: Transacti
305 308 "UPDATE license_activations SET is_active = false WHERE license_key_id = $1",
306 309 )
307 310 .bind(key_id)
308 - .execute(pool)
311 + .execute(&mut *conn)
309 312 .await?;
310 313 }
311 314
@@ -139,6 +139,8 @@ pub struct DbUser {
139 139 pub onboarding_email_step: i16,
140 140 /// When the last onboarding email was sent.
141 141 pub onboarding_email_sent_at: Option<DateTime<Utc>>,
142 + /// Generation counter for ETag-based HTTP caching. Bumped on any user-visible write.
143 + pub cache_generation: i64,
142 144 }
143 145
144 146 impl DbUser {
@@ -186,6 +188,8 @@ pub struct DbProject {
186 188 pub created_at: DateTime<Utc>,
187 189 /// When the project was last modified.
188 190 pub updated_at: DateTime<Utc>,
191 + /// Generation counter for ETag-based HTTP caching. Bumped on any project-visible write.
192 + pub cache_generation: i64,
189 193 }
190 194
191 195 /// A git repository tracked on disk, optionally linked to a project.
@@ -819,52 +823,84 @@ pub struct DbWaitlistStats {
819 823 /// A registered sync app with an API key.
820 824 #[derive(Debug, Clone, FromRow, Serialize)]
821 825 pub struct DbSyncApp {
826 + /// Database primary key.
822 827 pub id: SyncAppId,
828 + /// User who created and owns this app.
823 829 pub creator_id: UserId,
830 + /// Human-readable app name (e.g. "GoingsOn", "AudioFiles").
824 831 pub name: String,
832 + /// Hex-encoded API key used by the client SDK to authenticate.
825 833 pub api_key: String,
834 + /// Whether this app is active and can accept sync requests.
826 835 pub is_active: bool,
836 + /// When the app was created.
827 837 pub created_at: DateTime<Utc>,
838 + /// Optional link to an MNW project for dashboard grouping.
828 839 pub project_id: Option<ProjectId>,
840 + /// Optional link to an MNW item for dashboard grouping.
829 841 pub item_id: Option<ItemId>,
830 842 }
831 843
832 844 /// A device registered for sync per user per app.
833 845 #[derive(Debug, Clone, FromRow, Serialize)]
834 846 pub struct DbSyncDevice {
847 + /// Database primary key.
835 848 pub id: SyncDeviceId,
849 + /// Parent sync app this device belongs to.
836 850 pub app_id: SyncAppId,
851 + /// User who owns this device registration.
837 852 pub user_id: UserId,
853 + /// Human-readable device name (e.g. "Max's MacBook Pro").
838 854 pub device_name: String,
855 + /// Operating system / platform (macos, windows, linux, ios, android).
839 856 pub platform: super::SyncPlatform,
857 + /// Updated on each push or pull to track device activity.
840 858 pub last_seen_at: DateTime<Utc>,
859 + /// When this device was first registered.
841 860 pub created_at: DateTime<Utc>,
842 861 }
843 862
844 863 /// An entry in the append-only sync change log.
845 864 #[derive(Debug, Clone, FromRow, Serialize)]
846 865 pub struct DbSyncLogEntry {
866 + /// Server-assigned monotonic sequence number, used as a cursor for pull.
847 867 pub seq: i64,
868 + /// Sync app this entry belongs to.
848 869 pub app_id: SyncAppId,
870 + /// User who pushed this change.
849 871 pub user_id: UserId,
872 + /// Device that originated this change.
850 873 pub device_id: SyncDeviceId,
874 + /// Opaque table name from the client (e.g. "tasks", "contacts").
851 875 pub table_name: String,
876 + /// Type of change (insert, update, or delete).
852 877 pub operation: super::SyncOperation,
878 + /// Client-side row identifier (opaque to the server).
853 879 pub row_id: String,
880 + /// Timestamp assigned by the client when the change was made.
854 881 pub client_timestamp: DateTime<Utc>,
882 + /// Encrypted row data (JSON blob). Null for delete operations.
855 883 pub data: Option<serde_json::Value>,
884 + /// When the server received and recorded this entry.
856 885 pub created_at: DateTime<Utc>,
857 886 }
858 887
859 888 /// A blob uploaded to S3 via SyncKit, tracked for dedup and cleanup.
860 889 #[derive(Debug, Clone, FromRow, Serialize)]
861 890 pub struct DbSyncBlob {
891 + /// Database primary key.
862 892 pub id: SyncBlobId,
893 + /// Sync app this blob belongs to.
863 894 pub app_id: SyncAppId,
895 + /// User who uploaded this blob.
864 896 pub user_id: UserId,
897 + /// Content-address hash provided by the client (used for deduplication).
865 898 pub hash: String,
899 + /// S3 object key where the blob is stored (`{app_id}/{user_id}/{hash}`).
866 900 pub s3_key: String,
901 + /// Size of the blob in bytes.
867 902 pub size_bytes: i64,
903 + /// When the blob upload was confirmed.
868 904 pub uploaded_at: DateTime<Utc>,
869 905 }
870 906
@@ -1302,6 +1338,7 @@ mod tests {
1302 1338 last_broadcast_at: None,
1303 1339 onboarding_email_step: 0,
1304 1340 onboarding_email_sent_at: None,
1341 + cache_generation: 0,
1305 1342 }
1306 1343 }
1307 1344
@@ -61,3 +61,20 @@ pub async fn mark_oauth_code_used(pool: &PgPool, code_id: OAuthCodeId) -> Result
61 61
62 62 Ok(())
63 63 }
64 +
65 + /// Check if a redirect URI is registered for a given sync app.
66 + pub async fn is_registered_redirect_uri(
67 + pool: &PgPool,
68 + app_id: SyncAppId,
69 + uri: &str,
70 + ) -> Result<bool> {
71 + let row: (bool,) = sqlx::query_as(
72 + "SELECT $2 = ANY(redirect_uris) FROM sync_apps WHERE id = $1 AND is_active = true",
73 + )
74 + .bind(app_id)
75 + .bind(uri)
76 + .fetch_one(pool)
77 + .await?;
78 +
79 + Ok(row.0)
80 + }
@@ -178,3 +178,26 @@ pub async fn get_public_project_by_slug(
178 178
179 179 Ok(project)
180 180 }
181 +
182 + /// Fetch the current cache generation for a project (cheap, indexed lookup).
183 + pub async fn get_cache_generation(pool: &PgPool, project_id: ProjectId) -> Result<i64> {
184 + let generation = sqlx::query_scalar::<_, i64>(
185 + "SELECT cache_generation FROM projects WHERE id = $1",
186 + )
187 + .bind(project_id)
188 + .fetch_one(pool)
189 + .await?;
190 +
191 + Ok(generation)
192 + }
193 +
194 + /// Atomically increment the project's cache generation counter.
195 + /// Call after any write that changes project-visible dashboard data.
196 + pub async fn bump_cache_generation(pool: &PgPool, project_id: ProjectId) -> Result<()> {
197 + sqlx::query("UPDATE projects SET cache_generation = cache_generation + 1 WHERE id = $1")
198 + .bind(project_id)
199 + .execute(pool)
200 + .await?;
201 +
202 + Ok(())
203 + }
@@ -24,17 +24,40 @@ pub async fn create_user_session(
24 24 Ok(row)
25 25 }
26 26
27 - /// Update `last_active_at` and confirm the session still exists.
28 - /// Returns `true` if the session exists, `false` if it was revoked.
29 - pub async fn touch_session(pool: &PgPool, session_id: UserSessionId) -> Result<bool> {
30 - let rows = sqlx::query(
31 - "UPDATE user_sessions SET last_active_at = NOW() WHERE id = $1",
27 + /// Result of touching a session: whether it exists and the user's current
28 + /// suspended status (live from the `users` table, not cached in the session).
29 + pub struct TouchResult {
30 + /// `false` if the session row was deleted (revoked).
31 + pub valid: bool,
32 + /// Current `suspended_at IS NOT NULL` from the users table.
33 + /// Only meaningful when `valid` is `true`.
34 + pub suspended: bool,
35 + }
36 +
37 + /// Update `last_active_at`, confirm the session still exists, and return
38 + /// the user's current `suspended` status from the `users` table.
39 + ///
40 + /// This ensures suspension takes effect immediately even if the session
41 + /// was created before the admin suspended the user.
42 + pub async fn touch_session(pool: &PgPool, session_id: UserSessionId) -> Result<TouchResult> {
43 + // Single query: update last_active_at and join to users for live suspended status.
44 + let row = sqlx::query_as::<_, (bool,)>(
45 + r#"
46 + UPDATE user_sessions us
47 + SET last_active_at = NOW()
48 + FROM users u
49 + WHERE us.id = $1 AND u.id = us.user_id
50 + RETURNING u.suspended_at IS NOT NULL
51 + "#,
32 52 )
33 53 .bind(session_id)
34 - .execute(pool)
54 + .fetch_optional(pool)
35 55 .await?;
36 56
37 - Ok(rows.rows_affected() > 0)
57 + match row {
58 + Some((suspended,)) => Ok(TouchResult { valid: true, suspended }),
59 + None => Ok(TouchResult { valid: false, suspended: false }),
60 + }
38 61 }
39 62
40 63 /// List all active sessions for a user, newest first.
@@ -316,8 +316,8 @@ pub async fn remove_free_item_from_library(
316 316 /// The WHERE clause requires `status = 'completed'` so that already-refunded
317 317 /// or pending transactions are not double-processed. Returns `None` if no
318 318 /// matching transaction was found (idempotent for webhook retries).
319 - pub async fn refund_transaction_by_payment_intent(
320 - pool: &PgPool,
319 + pub async fn refund_transaction_by_payment_intent<'e>(
320 + executor: impl sqlx::PgExecutor<'e>,
321 321 payment_intent_id: &str,
322 322 ) -> Result<Option<(super::TransactionId, ItemId)>> {
323 323 let row: Option<(super::TransactionId, ItemId)> = sqlx::query_as(
@@ -329,7 +329,7 @@ pub async fn refund_transaction_by_payment_intent(
329 329 "#,
330 330 )
331 331 .bind(payment_intent_id)
332 - .fetch_optional(pool)
332 + .fetch_optional(executor)
333 333 .await?;
334 334
335 335 Ok(row)
@@ -349,39 +349,77 @@ pub async fn get_pending_appeals(pool: &PgPool) -> Result<Vec<DbUser>> {
349 349 Ok(users)
350 350 }
351 351
352 - /// Admin query: all users, optionally filtered by suspension status.
353 - /// Hard-capped at 1000 rows; add pagination if the user base outgrows this.
354 - pub async fn get_all_users(pool: &PgPool, filter: Option<&str>) -> Result<Vec<DbUser>> {
352 + /// Admin query: all users, optionally filtered by suspension status, with pagination.
353 + pub async fn get_all_users(
354 + pool: &PgPool,
355 + filter: Option<&str>,
356 + limit: i64,
357 + offset: i64,
358 + ) -> Result<Vec<DbUser>> {
355 359 let users = match filter {
356 360 Some("suspended") => {
357 361 sqlx::query_as::<_, DbUser>(
358 - "SELECT * FROM users WHERE suspended_at IS NOT NULL ORDER BY suspended_at DESC LIMIT 1000",
362 + "SELECT * FROM users WHERE suspended_at IS NOT NULL ORDER BY suspended_at DESC LIMIT $1 OFFSET $2",
359 363 )
364 + .bind(limit)
365 + .bind(offset)
360 366 .fetch_all(pool)
361 367 .await?
362 368 }
363 369 Some("active") => {
364 370 sqlx::query_as::<_, DbUser>(
365 - "SELECT * FROM users WHERE suspended_at IS NULL ORDER BY created_at DESC LIMIT 1000",
371 + "SELECT * FROM users WHERE suspended_at IS NULL ORDER BY created_at DESC LIMIT $1 OFFSET $2",
366 372 )
373 + .bind(limit)
374 + .bind(offset)
367 375 .fetch_all(pool)
368 376 .await?
369 377 }
370 378 _ => {
371 - sqlx::query_as::<_, DbUser>("SELECT * FROM users ORDER BY created_at DESC LIMIT 1000")
372 - .fetch_all(pool)
373 - .await?
379 + sqlx::query_as::<_, DbUser>(
380 + "SELECT * FROM users ORDER BY created_at DESC LIMIT $1 OFFSET $2",
381 + )
382 + .bind(limit)
383 + .bind(offset)
384 + .fetch_all(pool)
385 + .await?
374 386 }
375 387 };
376 388
377 389 Ok(users)
378 390 }
379 391
392 + /// Count users matching a filter (for pagination totals).
393 + pub async fn count_users(pool: &PgPool, filter: Option<&str>) -> Result<i64> {
394 + let count = match filter {
395 + Some("suspended") => {
396 + sqlx::query_scalar::<_, i64>(
397 + "SELECT COUNT(*) FROM users WHERE suspended_at IS NOT NULL",
398 + )
399 + .fetch_one(pool)
400 + .await?
401 + }
402 + Some("active") => {
403 + sqlx::query_scalar::<_, i64>(
404 + "SELECT COUNT(*) FROM users WHERE suspended_at IS NULL",
405 + )
406 + .fetch_one(pool)
407 + .await?
408 + }
409 + _ => {
410 + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM users")
411 + .fetch_one(pool)
412 + .await?
413 + }
414 + };
415 +
416 + Ok(count)
417 + }
418 +
380 419 /// Get all user emails for bulk notifications (e.g. shutdown notice).
381 - /// Hard-capped at 1000 rows; add pagination if the user base outgrows this.
382 420 pub async fn get_all_user_emails(pool: &PgPool) -> Result<Vec<(String, Option<String>)>> {
383 421 let rows = sqlx::query_as::<_, (String, Option<String>)>(
384 - "SELECT email, display_name FROM users ORDER BY created_at ASC LIMIT 1000",
422 + "SELECT email, display_name FROM users ORDER BY created_at ASC",
385 423 )
386 424 .fetch_all(pool)
387 425 .await?;
@@ -540,3 +578,26 @@ pub async fn disconnect_user_stripe(pool: &PgPool, user_id: UserId) -> Result<Db
540 578
541 579 Ok(user)
542 580 }
581 +
582 + /// Fetch the current cache generation for a user (cheap, indexed lookup).
583 + pub async fn get_cache_generation(pool: &PgPool, user_id: UserId) -> Result<i64> {
584 + let generation = sqlx::query_scalar::<_, i64>(
585 + "SELECT cache_generation FROM users WHERE id = $1",
586 + )
587 + .bind(user_id)
588 + .fetch_one(pool)
589 + .await?;
590 +
591 + Ok(generation)
592 + }
593 +
594 + /// Atomically increment the user's cache generation counter.
595 + /// Call after any write that changes user-visible dashboard data.
596 + pub async fn bump_cache_generation(pool: &PgPool, user_id: UserId) -> Result<()> {
597 + sqlx::query("UPDATE users SET cache_generation = cache_generation + 1 WHERE id = $1")
598 + .bind(user_id)
599 + .execute(pool)
600 + .await?;
601 +
602 + Ok(())
603 + }
@@ -3,9 +3,15 @@
3 3
4 4 use std::collections::HashMap;
5 5 use std::path::Path;
6 + use std::sync::LazyLock;
6 7
7 8 use regex::Regex;
8 9
10 + /// Pre-compiled regex for matching Markdown links: `[text](url)`.
11 + static LINK_RE: LazyLock<Regex> = LazyLock::new(|| {
12 + Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").expect("valid regex")
13 + });
14 +
9 15 use crate::markdown::render_markdown;
10 16
11 17 /// A rendered documentation page.
@@ -164,9 +170,7 @@ fn strip_first_heading(markdown: &str) -> String {
164 170 /// - Unpublished links (`../../unpublished/...`) → plain text (link removed)
165 171 /// - Absolute URLs, mailto, and route links are preserved as-is.
166 172 fn rewrite_links(markdown: &str) -> String {
167 - let link_re = Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").unwrap();
168 -
169 - link_re
173 + LINK_RE
170 174 .replace_all(markdown, |caps: &regex::Captures| {
171 175 let text = &caps[1];
172 176 let url = &caps[2];
@@ -2,6 +2,8 @@
2 2
3 3 use axum::http::header::HeaderMap;
4 4 use axum::http::HeaderValue;
5 + use axum::http::StatusCode;
6 + use axum::response::{IntoResponse, Response};
5 7 use tower_sessions::Session;
6 8
7 9 /// Check whether the incoming request was made by HTMX.
@@ -9,6 +11,38 @@ pub fn is_htmx_request(headers: &HeaderMap) -> bool {
9 11 headers.get("HX-Request").is_some()
10 12 }
11 13
14 + /// Check the client's `If-None-Match` header against a cache generation.
15 + /// Returns `Some(304 Not Modified)` if the client's cached version is still fresh.
16 + pub fn check_etag(headers: &HeaderMap, generation: i64) -> Option<Response> {
17 + let etag = format!("\"g{}\"", generation);
18 + if let Some(if_none_match) = headers.get(axum::http::header::IF_NONE_MATCH) {
19 + if if_none_match.as_bytes() == etag.as_bytes() {
20 + return Some(
21 + (
22 + StatusCode::NOT_MODIFIED,
23 + [(axum::http::header::ETAG, HeaderValue::from_str(&etag).unwrap())],
24 + )
25 + .into_response(),
26 + );
27 + }
28 + }
29 + None
30 + }
31 +
32 + /// Wrap a rendered response with ETag and Cache-Control headers.
33 + /// `no-cache` tells the browser to store the response but revalidate on each use.
34 + pub fn with_etag(generation: i64, body: impl IntoResponse) -> Response {
35 + let etag = format!("\"g{}\"", generation);
36 + (
37 + [
38 + (axum::http::header::ETAG, etag),
39 + (axum::http::header::CACHE_CONTROL, "private, no-cache".to_string()),
40 + ],
41 + body,
42 + )
43 + .into_response()
44 + }
45 +
12 46 /// Get or create a CSRF token for the session, returning `None` on failure.
13 47 ///
14 48 /// Convenience wrapper for templates that need an `Option<String>`.