max / makenotwork
85 files changed,
+1909 insertions,
-330 deletions
| @@ -21,3 +21,6 @@ | |||
| 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 |
| @@ -3453,7 +3453,7 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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/ |
| @@ -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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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; |
| @@ -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 @@ | |||
| 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 @@ | |||
| 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>`. |
| @@ -35,6 +35,9 @@ | |||
| 35 | 35 | ||
| 36 | 36 | use std::sync::Arc; | |
| 37 | 37 | ||
| 38 | + | use dashmap::DashMap; | |
| 39 | + | use db::UserSessionId; | |
| 40 | + | ||
| 38 | 41 | use config::Config; | |
| 39 | 42 | use docs::DocEngine; | |
| 40 | 43 | use email::EmailClient; | |
| @@ -62,6 +65,10 @@ | |||
| 62 | 65 | pub syntax: Option<Arc<git::SyntaxHighlighter>>, | |
| 63 | 66 | pub started_at: chrono::DateTime<chrono::Utc>, | |
| 64 | 67 | pub start_instant: Instant, | |
| 68 | + | /// Cache of recently-validated session tracking IDs to skip per-request DB touch. | |
| 69 | + | /// Maps session tracking ID → last validated instant. Entries older than | |
| 70 | + | /// SESSION_TOUCH_CACHE_SECS are treated as expired. | |
| 71 | + | pub session_cache: Arc<DashMap<UserSessionId, Instant>>, | |
| 65 | 72 | } | |
| 66 | 73 | ||
| 67 | 74 | /// Build the app router with all routes and middleware (minus tracing/TCP). |
| @@ -95,15 +95,18 @@ | |||
| 95 | 95 | .await | |
| 96 | 96 | .expect("Failed to migrate session store"); | |
| 97 | 97 | ||
| 98 | - | // In release mode, require HTTPS for session cookies | |
| 99 | - | let secure_cookies = !cfg!(debug_assertions); | |
| 98 | + | // In release mode, require HTTPS for session cookies (override with INSECURE_COOKIES=1 for staging) | |
| 99 | + | let secure_cookies = | |
| 100 | + | !cfg!(debug_assertions) && std::env::var("INSECURE_COOKIES").unwrap_or_default() != "1"; | |
| 100 | 101 | if secure_cookies { | |
| 101 | 102 | tracing::info!("Session cookies configured for HTTPS (secure=true)"); | |
| 103 | + | } else if !cfg!(debug_assertions) { | |
| 104 | + | tracing::warn!("Session cookies set to insecure (INSECURE_COOKIES=1)"); | |
| 102 | 105 | } | |
| 103 | 106 | ||
| 104 | 107 | let session_layer = SessionManagerLayer::new(session_store) | |
| 105 | 108 | .with_secure(secure_cookies) | |
| 106 | - | .with_same_site(SameSite::Strict) // CSRF defense | |
| 109 | + | .with_same_site(SameSite::Lax) // Lax allows session on top-level navigations (OAuth redirects) | |
| 107 | 110 | .with_expiry(Expiry::OnInactivity(CookieDuration::days( | |
| 108 | 111 | constants::SESSION_EXPIRY_DAYS, | |
| 109 | 112 | ))); | |
| @@ -216,6 +219,7 @@ | |||
| 216 | 219 | syntax, | |
| 217 | 220 | started_at, | |
| 218 | 221 | start_instant, | |
| 222 | + | session_cache: std::sync::Arc::new(dashmap::DashMap::new()), | |
| 219 | 223 | }; | |
| 220 | 224 | ||
| 221 | 225 | // Start background health monitor and scheduler |
Binary file
Binary file
Binary file
Binary file
Binary file