max / makenotwork
85 files changed,
+1909 insertions,
-330 deletions
| @@ -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 |
| @@ -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: ®ex::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>`. |