diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a538ae0f..af835829 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -55,6 +55,9 @@ jobs: - name: Install dependencies run: npm ci + - name: Check source file limits + run: npm run check:file-limits + - name: Run tests run: npm test -- --run diff --git a/backend/src/bin/export_content.rs b/backend/src/bin/export_content.rs index ddbcac34..264f4eae 100644 --- a/backend/src/bin/export_content.rs +++ b/backend/src/bin/export_content.rs @@ -208,7 +208,10 @@ async fn main() -> Result<()> { .collect::>>()?; let page_rows = sqlx::query_as::<_, SitePageRow>( - "SELECT id, slug, title, description, nav_label, show_in_nav, order_index, is_published, hero_json, layout_json, created_at, updated_at FROM site_pages ORDER BY order_index, title", + r#"SELECT id, slug, title, description, nav_label, show_in_nav, order_index, + is_published, hero_json, layout_json, created_at, updated_at + FROM site_pages + ORDER BY order_index, title"#, ) .fetch_all(&pool) .await @@ -239,7 +242,10 @@ async fn main() -> Result<()> { .collect::>>()?; let post_rows = sqlx::query_as::<_, SitePostRow>( - "SELECT id, page_id, title, slug, excerpt, content_markdown, is_published, published_at, order_index, created_at, updated_at FROM site_posts ORDER BY page_id, order_index, created_at", + r#"SELECT id, page_id, title, slug, excerpt, content_markdown, is_published, + published_at, order_index, created_at, updated_at + FROM site_posts + ORDER BY page_id, order_index, created_at"#, ) .fetch_all(&pool) .await @@ -263,7 +269,10 @@ async fn main() -> Result<()> { .collect::>(); let tutorial_rows = sqlx::query_as::<_, TutorialRow>( - "SELECT id, title, description, icon, color, topics, content, version, created_at, updated_at FROM tutorials ORDER BY created_at", + r#"SELECT id, title, description, icon, color, topics, content, version, + created_at, updated_at + FROM tutorials + ORDER BY created_at"#, ) .fetch_all(&pool) .await @@ -328,7 +337,10 @@ async fn main() -> Result<()> { .with_context(|| format!("Failed to write export file at {}", path.display()))?; println!( - "Export completed:\n site_content: {}\n pages: {}\n posts: {}\n tutorials: {}\n tutorial_topics: {}\n saved to {}", + concat!( + "Export completed:\n site_content: {}\n pages: {}\n posts: {}\n", + " tutorials: {}\n tutorial_topics: {}\n saved to {}", + ), bundle.site_content.len(), bundle.pages.len(), bundle.posts.len(), diff --git a/backend/src/bin/import_content.rs b/backend/src/bin/import_content.rs index 874ee904..c70d53f4 100644 --- a/backend/src/bin/import_content.rs +++ b/backend/src/bin/import_content.rs @@ -175,8 +175,11 @@ async fn import_site_content( .context("Failed to serialize site_content entry")?; sqlx::query( - "INSERT INTO site_content (section, content_json, updated_at) VALUES (?, ?, COALESCE(?, CURRENT_TIMESTAMP)) \ - ON CONFLICT(section) DO UPDATE SET content_json = excluded.content_json, updated_at = COALESCE(excluded.updated_at, CURRENT_TIMESTAMP)", + r#"INSERT INTO site_content (section, content_json, updated_at) + VALUES (?, ?, COALESCE(?, CURRENT_TIMESTAMP)) + ON CONFLICT(section) DO UPDATE SET + content_json = excluded.content_json, + updated_at = COALESCE(excluded.updated_at, CURRENT_TIMESTAMP)"#, ) .bind(&item.section) .bind(&serialized) @@ -200,16 +203,24 @@ async fn import_site_pages( serde_json::to_string(&item.layout).context("Failed to serialize page layout JSON")?; sqlx::query( - "INSERT INTO site_pages (id, slug, title, description, nav_label, show_in_nav, order_index, is_published, hero_json, layout_json, created_at, updated_at) \ - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(?, CURRENT_TIMESTAMP), COALESCE(?, CURRENT_TIMESTAMP)) \ - ON CONFLICT(id) DO UPDATE SET slug = excluded.slug, title = excluded.title, description = excluded.description, nav_label = excluded.nav_label, show_in_nav = excluded.show_in_nav, order_index = excluded.order_index, is_published = excluded.is_published, hero_json = excluded.hero_json, layout_json = excluded.layout_json, updated_at = COALESCE(excluded.updated_at, CURRENT_TIMESTAMP)", + r#"INSERT INTO site_pages ( + id, slug, title, description, nav_label, show_in_nav, order_index, + is_published, hero_json, layout_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + COALESCE(?, CURRENT_TIMESTAMP), COALESCE(?, CURRENT_TIMESTAMP)) + ON CONFLICT(id) DO UPDATE SET + slug = excluded.slug, title = excluded.title, + description = excluded.description, nav_label = excluded.nav_label, + show_in_nav = excluded.show_in_nav, order_index = excluded.order_index, + is_published = excluded.is_published, hero_json = excluded.hero_json, + layout_json = excluded.layout_json, + updated_at = COALESCE(excluded.updated_at, CURRENT_TIMESTAMP)"#, ) .bind(&item.id) .bind(&item.slug) .bind(&item.title) .bind(&item.description) .bind(&item.nav_label) - .bind(if item.show_in_nav { 1 } else { 0 }) .bind(item.order_index) .bind(if item.is_published { 1 } else { 0 }) @@ -231,9 +242,17 @@ async fn import_site_posts( ) -> Result<()> { for item in items { sqlx::query( - "INSERT INTO site_posts (id, page_id, title, slug, excerpt, content_markdown, is_published, published_at, order_index, created_at, updated_at) \ - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(?, CURRENT_TIMESTAMP), COALESCE(?, CURRENT_TIMESTAMP)) \ - ON CONFLICT(id) DO UPDATE SET page_id = excluded.page_id, title = excluded.title, slug = excluded.slug, excerpt = excluded.excerpt, content_markdown = excluded.content_markdown, is_published = excluded.is_published, published_at = excluded.published_at, order_index = excluded.order_index, updated_at = COALESCE(excluded.updated_at, CURRENT_TIMESTAMP)", + r#"INSERT INTO site_posts ( + id, page_id, title, slug, excerpt, content_markdown, is_published, + published_at, order_index, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, + COALESCE(?, CURRENT_TIMESTAMP), COALESCE(?, CURRENT_TIMESTAMP)) + ON CONFLICT(id) DO UPDATE SET + page_id = excluded.page_id, title = excluded.title, slug = excluded.slug, + excerpt = excluded.excerpt, content_markdown = excluded.content_markdown, + is_published = excluded.is_published, published_at = excluded.published_at, + order_index = excluded.order_index, + updated_at = COALESCE(excluded.updated_at, CURRENT_TIMESTAMP)"#, ) .bind(&item.id) .bind(&item.page_id) @@ -241,7 +260,6 @@ async fn import_site_posts( .bind(&item.slug) .bind(&item.excerpt) .bind(&item.content_markdown) - .bind(if item.is_published { 1 } else { 0 }) .bind(&item.published_at) .bind(item.order_index) diff --git a/backend/src/db/migrations.rs b/backend/src/db/migrations.rs index e697bfbf..b605f829 100644 --- a/backend/src/db/migrations.rs +++ b/backend/src/db/migrations.rs @@ -4,10 +4,6 @@ use sqlx::{Sqlite, Transaction}; use std::env; /// Runs all database migrations and initial data seeding. -/// -/// This function is automatically called during database pool creation. -/// It ensures the database schema is up-to-date and populates initial data. -/// /// # Migration Steps /// 1. **Core Schema**: Create core tables (users, tutorials, comments, login_attempts) /// 2. **Site Schema**: Create site-related tables (pages, posts, content) @@ -137,9 +133,10 @@ pub async fn run_migrations(pool: &DbPool) -> Result<(), sqlx::Error> { // environment variable, but worth surfacing so a longer passphrase // isn't assumed to add entropy it doesn't. if password.len() > 72 { - tracing::warn!( - "ADMIN_PASSWORD exceeds 72 bytes; bcrypt only uses the first 72 bytes for hashing. Characters beyond this limit have no effect on security." - ); + tracing::warn!(concat!( + "ADMIN_PASSWORD exceeds 72 bytes; bcrypt only uses the first 72 bytes for hashing. ", + "Characters beyond this limit have no effect on security." + )); } let existing_user: Option<(i64, String)> = @@ -157,7 +154,11 @@ pub async fn run_migrations(pool: &DbPool) -> Result<(), sqlx::Error> { ); } Ok(false) => { - tracing::warn!("ADMIN_PASSWORD for '{}' differs from stored credentials; keeping existing hash to preserve runtime changes.", username); + tracing::warn!( + "ADMIN_PASSWORD for '{}' differs from stored credentials; \ + keeping existing hash to preserve runtime changes.", + username + ); } Err(e) => { tracing::error!("Password verification failed: {}", e); @@ -306,12 +307,26 @@ async fn apply_core_migrations(tx: &mut Transaction<'_, Sqlite>) -> Result<(), s .execute(&mut **tx) .await?; + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS newsletter_subscriptions ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL COLLATE NOCASE UNIQUE, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + "#, + ) + .execute(&mut **tx) + .await?; + sqlx::query( r#" CREATE TABLE IF NOT EXISTS tutorial_topics ( tutorial_id TEXT NOT NULL, topic TEXT NOT NULL, - CONSTRAINT fk_tutorial_topics_tutorial FOREIGN KEY (tutorial_id) REFERENCES tutorials(id) ON DELETE CASCADE ON UPDATE CASCADE + CONSTRAINT fk_tutorial_topics_tutorial + FOREIGN KEY (tutorial_id) REFERENCES tutorials(id) + ON DELETE CASCADE ON UPDATE CASCADE ) "#, ) @@ -418,610 +433,14 @@ async fn apply_core_migrations(tx: &mut Transaction<'_, Sqlite>) -> Result<(), s Ok(()) } -async fn ensure_site_page_schema(pool: &DbPool) -> Result<(), sqlx::Error> { - let mut tx = pool.begin().await?; - - sqlx::query( - "CREATE TABLE IF NOT EXISTS site_content ( - section TEXT PRIMARY KEY, - content_json TEXT NOT NULL, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - )", - ) - .execute(&mut *tx) - .await?; - - sqlx::query( - "CREATE TABLE IF NOT EXISTS site_pages ( - id TEXT PRIMARY KEY, - slug TEXT NOT NULL UNIQUE, - title TEXT NOT NULL, - description TEXT NOT NULL DEFAULT '', - nav_label TEXT, - show_in_nav INTEGER NOT NULL DEFAULT 0, - order_index INTEGER NOT NULL DEFAULT 0, - is_published INTEGER NOT NULL DEFAULT 0, - hero_json TEXT NOT NULL DEFAULT '{}', - layout_json TEXT NOT NULL DEFAULT '{}', - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - )", - ) - .execute(&mut *tx) - .await?; - - sqlx::query( - "CREATE INDEX IF NOT EXISTS idx_site_pages_nav ON site_pages(show_in_nav, order_index)", - ) - .execute(&mut *tx) - .await?; - - sqlx::query( - "CREATE TABLE IF NOT EXISTS site_posts ( - id TEXT PRIMARY KEY, - page_id TEXT NOT NULL, - title TEXT NOT NULL, - slug TEXT NOT NULL, - excerpt TEXT DEFAULT '', - content_markdown TEXT NOT NULL, - is_published INTEGER NOT NULL DEFAULT 0, - allow_comments BOOLEAN NOT NULL DEFAULT 1, - published_at TEXT, - order_index INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY(page_id) REFERENCES site_pages(id) ON DELETE CASCADE - )", - ) - .execute(&mut *tx) - .await?; - - sqlx::query( - "CREATE UNIQUE INDEX IF NOT EXISTS idx_site_posts_unique_slug ON site_posts(page_id, slug)", - ) - .execute(&mut *tx) - .await?; - - sqlx::query( - "CREATE INDEX IF NOT EXISTS idx_site_posts_page_published ON site_posts(page_id, is_published, published_at)", - ) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - - Ok(()) -} - -async fn apply_comment_migrations(tx: &mut Transaction<'_, Sqlite>) -> Result<(), sqlx::Error> { - // Check if post_id column exists - let has_post_id: bool = sqlx::query_scalar( - "SELECT COUNT(*) FROM pragma_table_info('comments') WHERE name='post_id'", - ) - .fetch_one(&mut **tx) - .await - .map(|count: i64| count > 0)?; - - if !has_post_id { - tracing::info!("Adding post_id column to comments table"); - sqlx::query("ALTER TABLE comments ADD COLUMN post_id TEXT") - .execute(&mut **tx) - .await?; - - // Add index for post_id - sqlx::query("CREATE INDEX IF NOT EXISTS idx_comments_post ON comments(post_id)") - .execute(&mut **tx) - .await?; - } - - let has_rate_limit_key: bool = sqlx::query_scalar( - "SELECT COUNT(*) FROM pragma_table_info('comments') WHERE name='rate_limit_key'", - ) - .fetch_one(&mut **tx) - .await - .map(|count: i64| count > 0)?; - - if !has_rate_limit_key { - tracing::info!("Adding rate_limit_key column to comments table"); - sqlx::query("ALTER TABLE comments ADD COLUMN rate_limit_key TEXT NOT NULL DEFAULT ''") - .execute(&mut **tx) - .await?; - } - - sqlx::query("UPDATE comments SET rate_limit_key = author WHERE rate_limit_key = ''") - .execute(&mut **tx) - .await?; - sqlx::query("CREATE INDEX IF NOT EXISTS idx_comments_rate_limit ON comments(rate_limit_key)") - .execute(&mut **tx) - .await?; - - Ok(()) -} - -async fn apply_vote_migration(tx: &mut Transaction<'_, Sqlite>) -> Result<(), sqlx::Error> { - // Create comment_votes table - sqlx::query(include_str!( - "../../migrations/20241119_create_comment_votes.sql" - )) - .execute(&mut **tx) - .await?; - - // Add votes column to comments if missing - let has_votes: bool = - sqlx::query_scalar("SELECT COUNT(*) FROM pragma_table_info('comments') WHERE name='votes'") - .fetch_one(&mut **tx) - .await - .map(|count: i64| count > 0)?; - - if !has_votes { - tracing::info!("Adding votes column to comments table"); - sqlx::query("ALTER TABLE comments ADD COLUMN votes INTEGER NOT NULL DEFAULT 0") - .execute(&mut **tx) - .await?; - } - - // Add is_admin column to comments if missing - let has_is_admin: bool = sqlx::query_scalar( - "SELECT COUNT(*) FROM pragma_table_info('comments') WHERE name='is_admin'", - ) - .fetch_one(&mut **tx) - .await - .map(|count: i64| count > 0)?; - - if !has_is_admin { - tracing::info!("Adding is_admin column to comments table"); - sqlx::query("ALTER TABLE comments ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE") - .execute(&mut **tx) - .await?; - } - - Ok(()) -} - -async fn fix_comment_schema(tx: &mut Transaction<'_, Sqlite>) -> Result<(), sqlx::Error> { - // Check if tutorial_id is nullable by checking table info, but SQLite doesn't make it easy to check nullability directly via simple query without parsing. - // Instead, we'll check if we've already run this fix by checking app_metadata. - let fixed: Option<(String,)> = - sqlx::query_as("SELECT value FROM app_metadata WHERE key = 'comment_schema_fixed_v1'") - .fetch_optional(&mut **tx) - .await?; - - if fixed.is_some() { - return Ok(()); - } - - tracing::info!("Fixing comment schema: Making tutorial_id nullable"); - - // 1. Rename existing table to avoid name collision during schema swap - sqlx::query("ALTER TABLE comments RENAME TO comments_old") - .execute(&mut **tx) - .await?; - - // 2. Create new table with nullable tutorial_id and post_id (the fix) - sqlx::query( - r#" - CREATE TABLE comments ( - id TEXT PRIMARY KEY, - tutorial_id TEXT, - post_id TEXT, - author TEXT NOT NULL, - rate_limit_key TEXT NOT NULL DEFAULT '', - content TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - votes INTEGER NOT NULL DEFAULT 0, - is_admin BOOLEAN NOT NULL DEFAULT FALSE, - CONSTRAINT fk_comments_tutorial FOREIGN KEY (tutorial_id) REFERENCES tutorials(id) ON DELETE CASCADE - ) - "# , - ) - .execute(&mut **tx) - .await?; - - // 3. Migrate data from the old schema to the new one - sqlx::query( - r#" - INSERT INTO comments (id, tutorial_id, post_id, author, rate_limit_key, content, created_at, votes, is_admin) - SELECT id, tutorial_id, post_id, author, COALESCE(NULLIF(rate_limit_key, ''), author), content, created_at, votes, is_admin FROM comments_old - "#, - ) - .execute(&mut **tx) - .await?; - - // 4. Cleanup old temporary table - sqlx::query("DROP TABLE comments_old") - .execute(&mut **tx) - .await?; - - // 5. Recreate performance indices on the new table - sqlx::query("CREATE INDEX IF NOT EXISTS idx_comments_tutorial ON comments(tutorial_id)") - .execute(&mut **tx) - .await?; - sqlx::query("CREATE INDEX IF NOT EXISTS idx_comments_post ON comments(post_id)") - .execute(&mut **tx) - .await?; - sqlx::query("CREATE INDEX IF NOT EXISTS idx_comments_rate_limit ON comments(rate_limit_key)") - .execute(&mut **tx) - .await?; - - // 6. Persist migration state to prevent re-execution - sqlx::query("INSERT INTO app_metadata (key, value) VALUES ('comment_schema_fixed_v1', 'true')") - .execute(&mut **tx) - .await?; - - Ok(()) -} - -/// Adds `author_username` and `is_guest` columns to `comments` for authorization. -/// -/// `author` is a free-text display name that guests can set almost arbitrarily, -/// so it must never be used to authorize comment deletion (a registered user -/// could otherwise delete a guest comment that happens to share their -/// username). These two columns record the real, unspoofable identity of the -/// commenter going forward: -/// - `author_username = Some(username)`: authenticated commenter (their real -/// username, never the "Administrator" display name used for admins). -/// - `author_username = NULL, is_guest = Some(true)`: guest commenter, who -/// never has a real identity to record. -/// - `author_username = NULL, is_guest = NULL`: pre-migration row of unknown -/// origin. `is_guest` is required in addition to `author_username` because -/// guest comments are *permanently* NULL for `author_username` -- without a -/// separate marker, a NULL-based fallback to the old (spoofable) `author` -/// string match would stay exploitable for every future guest comment, not -/// just historical ones. See `delete_comment` for how this three-state -/// model is used. -async fn apply_comment_author_identity_migration( - tx: &mut Transaction<'_, Sqlite>, -) -> Result<(), sqlx::Error> { - let has_author_username: bool = sqlx::query_scalar( - "SELECT COUNT(*) FROM pragma_table_info('comments') WHERE name='author_username'", - ) - .fetch_one(&mut **tx) - .await - .map(|count: i64| count > 0)?; - - if !has_author_username { - tracing::info!("Adding author_username column to comments table"); - add_column_if_missing_race_safe( - tx, - "ALTER TABLE comments ADD COLUMN author_username TEXT DEFAULT NULL", - ) - .await?; - } - - let has_is_guest: bool = sqlx::query_scalar( - "SELECT COUNT(*) FROM pragma_table_info('comments') WHERE name='is_guest'", - ) - .fetch_one(&mut **tx) - .await - .map(|count: i64| count > 0)?; +mod site_pages; +use site_pages::*; - if !has_is_guest { - tracing::info!("Adding is_guest column to comments table"); - add_column_if_missing_race_safe( - tx, - "ALTER TABLE comments ADD COLUMN is_guest BOOLEAN DEFAULT NULL", - ) - .await?; - } - - Ok(()) -} +mod comments; +use comments::*; -/// Adds the `last_attempt_at` column to `login_attempts`. -/// -/// Without a timestamp, rows with fewer than 3 failures (blocked_until NULL) -/// could never be aged out, so the table grew unbounded under attack traffic -/// from rotating IPs (each IP+username combination is its own row). The -/// column lets `repositories::users::cleanup_stale_login_attempts` purge rows -/// that have been inactive long enough that their lockout state is moot. -/// Pre-existing rows are backfilled with the migration time so they enter the -/// same aging window instead of staying unpurgeable forever. -async fn apply_login_attempt_migrations( - tx: &mut Transaction<'_, Sqlite>, -) -> Result<(), sqlx::Error> { - let has_last_attempt_at: bool = sqlx::query_scalar( - "SELECT COUNT(*) FROM pragma_table_info('login_attempts') WHERE name='last_attempt_at'", - ) - .fetch_one(&mut **tx) - .await - .map(|count: i64| count > 0)?; - - if !has_last_attempt_at { - tracing::info!("Adding last_attempt_at column to login_attempts table"); - add_column_if_missing_race_safe( - tx, - "ALTER TABLE login_attempts ADD COLUMN last_attempt_at TEXT DEFAULT NULL", - ) - .await?; - - let now = chrono::Utc::now().to_rfc3339(); - sqlx::query("UPDATE login_attempts SET last_attempt_at = ? WHERE last_attempt_at IS NULL") - .bind(now) - .execute(&mut **tx) - .await?; - } - - Ok(()) -} - -/// Runs an `ALTER TABLE ... ADD COLUMN` statement, tolerating a "duplicate -/// column name" failure. -/// -/// The existence check above this call and the `ALTER TABLE` itself are not -/// atomic: if two instances of the app start concurrently against the same -/// SQLite file (e.g. a rolling deploy with overlapping replicas), both can -/// observe the column as missing before either commits its `ALTER TABLE`, -/// and the loser would otherwise fail its whole migration with "duplicate -/// column name" and abort startup. Since the only possible cause of that -/// specific error here is a concurrent run of this same idempotent -/// migration, it is safe to treat it as success rather than propagate it. -async fn add_column_if_missing_race_safe( - tx: &mut Transaction<'_, Sqlite>, - alter_statement: &str, -) -> Result<(), sqlx::Error> { - match sqlx::query(alter_statement).execute(&mut **tx).await { - Ok(_) => Ok(()), - Err(e) if is_duplicate_column_error(&e) => { - tracing::warn!( - "Column already added by a concurrent migration run, continuing: {}", - e - ); - Ok(()) - } - Err(e) => Err(e), - } -} - -/// Detects SQLite's "duplicate column name" error, which `ALTER TABLE ... -/// ADD COLUMN` raises when the column already exists. -fn is_duplicate_column_error(err: &sqlx::Error) -> bool { - err.as_database_error() - .map(|db_err| db_err.message().contains("duplicate column name")) - .unwrap_or(false) -} - -/// Rehashes legacy plaintext rows in `token_blacklist` to SHA-256. -/// -/// The repository layer used to store raw JWTs in the blacklist and now -/// stores and looks up only their SHA-256 hashes (see -/// `repositories::token_blacklist::hash_token`). On a database that predates -/// that change, existing rows still hold raw tokens, which the hashed lookup -/// can never match -- without this backfill, every token revoked before the -/// upgrade (e.g. via logout) would silently become valid again for its -/// remaining lifetime, undoing the revocation the user already performed. -/// -/// Gated by an `app_metadata` flag like the other one-time migrations. The -/// per-row hex check is a second, defensive layer: a raw JWT always contains -/// `.` separators and can never look like a 64-char hex digest, so -/// already-hashed rows are never double-hashed even if the flag is somehow -/// lost (manual metadata edit, partial restore from backup). -async fn apply_token_blacklist_hash_migration( - tx: &mut Transaction<'_, Sqlite>, -) -> Result<(), sqlx::Error> { - let migrated: Option<(String,)> = - sqlx::query_as("SELECT value FROM app_metadata WHERE key = 'token_blacklist_hashed_v1'") - .fetch_optional(&mut **tx) - .await?; - - if migrated.is_some() { - return Ok(()); - } - - let rows: Vec<(String,)> = sqlx::query_as("SELECT token FROM token_blacklist") - .fetch_all(&mut **tx) - .await?; - - let mut rehashed = 0u64; - for (token,) in rows { - if is_sha256_hex(&token) { - continue; - } - // UPDATE OR REPLACE: if the hashed form somehow already exists as its - // own row, replace it instead of failing the whole startup on a - // primary-key conflict. Either way the raw token is gone afterwards. - sqlx::query("UPDATE OR REPLACE token_blacklist SET token = ? WHERE token = ?") - .bind(crate::security::sha256_hex(token.as_bytes())) - .bind(&token) - .execute(&mut **tx) - .await?; - rehashed += 1; - } - - if rehashed > 0 { - tracing::info!( - "Rehashed {} legacy plaintext token_blacklist row(s) to SHA-256", - rehashed - ); - } - - // OR REPLACE keeps a concurrent second instance (rolling deploy) from - // aborting startup on a duplicate-key conflict for the flag itself. - sqlx::query( - "INSERT OR REPLACE INTO app_metadata (key, value) VALUES ('token_blacklist_hashed_v1', 'true')", - ) - .execute(&mut **tx) - .await?; - - Ok(()) -} - -/// True if `s` is exactly a lowercase-or-uppercase 64-character hex string, -/// i.e. shaped like a SHA-256 digest. Raw JWTs always contain `.` separators, -/// so they can never satisfy this. -fn is_sha256_hex(s: &str) -> bool { - s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit()) -} - -async fn apply_site_post_migrations(tx: &mut Transaction<'_, Sqlite>) -> Result<(), sqlx::Error> { - // Check if allow_comments column exists - let has_allow_comments: bool = sqlx::query_scalar( - "SELECT COUNT(*) FROM pragma_table_info('site_posts') WHERE name='allow_comments'", - ) - .fetch_one(&mut **tx) - .await - .map(|count: i64| count > 0)?; - - if !has_allow_comments { - tracing::info!("Adding allow_comments column to site_posts table"); - sqlx::query("ALTER TABLE site_posts ADD COLUMN allow_comments BOOLEAN NOT NULL DEFAULT 1") - .execute(&mut **tx) - .await?; - } - - Ok(()) -} +mod maintenance; +use maintenance::*; #[cfg(test)] -mod tests { - use super::run_migrations; - use sqlx::sqlite::SqlitePoolOptions; - - #[tokio::test] - async fn run_migrations_backfills_rate_limit_key_for_legacy_comments() { - let pool = SqlitePoolOptions::new() - .max_connections(1) - .connect("sqlite::memory:") - .await - .expect("create sqlite pool"); - - sqlx::query( - r#" - CREATE TABLE tutorials ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - description TEXT NOT NULL, - icon TEXT NOT NULL, - color TEXT NOT NULL, - topics TEXT NOT NULL, - content TEXT NOT NULL DEFAULT '', - version INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) - ) - "#, - ) - .execute(&pool) - .await - .expect("create legacy tutorials table"); - - sqlx::query( - r#" - INSERT INTO tutorials (id, title, description, icon, color, topics, content) - VALUES ('tutorial-1', 'Legacy Tutorial', 'Legacy description', 'book', '#000000', 'legacy', 'Legacy content') - "#, - ) - .execute(&pool) - .await - .expect("insert legacy tutorial"); - - sqlx::query( - r#" - CREATE TABLE comments ( - id TEXT PRIMARY KEY, - tutorial_id TEXT NOT NULL, - author TEXT NOT NULL, - content TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')) - ) - "#, - ) - .execute(&pool) - .await - .expect("create legacy comments table"); - - sqlx::query( - r#" - INSERT INTO comments (id, tutorial_id, author, content, created_at) - VALUES ('legacy-comment', 'tutorial-1', 'Legacy Author', 'Old comment', '2024-01-01T00:00:00Z') - "#, - ) - .execute(&pool) - .await - .expect("insert legacy comment"); - - run_migrations(&pool) - .await - .expect("migrate legacy comments table"); - - let has_rate_limit_key: bool = sqlx::query_scalar( - "SELECT COUNT(*) FROM pragma_table_info('comments') WHERE name='rate_limit_key'", - ) - .fetch_one(&pool) - .await - .map(|count: i64| count > 0) - .expect("check rate_limit_key column"); - - assert!(has_rate_limit_key); - - let (rate_limit_key,): (String,) = - sqlx::query_as("SELECT rate_limit_key FROM comments WHERE id = 'legacy-comment'") - .fetch_one(&pool) - .await - .expect("read migrated comment"); - - assert_eq!(rate_limit_key, "Legacy Author"); - } - - /// Regression test for the blacklist-hashing upgrade path: a database - /// written by a pre-hashing version of the app holds raw JWTs in - /// token_blacklist. After migrations run, those rows must be rehashed so - /// the (now hash-based) revocation lookup still recognizes them -- - /// otherwise every token revoked before the upgrade would silently - /// become valid again for its remaining lifetime. - #[tokio::test] - async fn run_migrations_rehashes_legacy_plaintext_blacklist_tokens() { - let pool = SqlitePoolOptions::new() - .max_connections(1) - .connect("sqlite::memory:") - .await - .expect("create sqlite pool"); - - // Simulate the pre-hashing schema and a raw bearer token revoked under it. - sqlx::query( - r#" - CREATE TABLE token_blacklist ( - token TEXT PRIMARY KEY, - expires_at TEXT NOT NULL - ) - "#, - ) - .execute(&pool) - .await - .expect("create legacy token_blacklist table"); - - let raw_token = ["legacy", "plaintext", "revocation-token", "before-hashing"].join("-"); - sqlx::query("INSERT INTO token_blacklist (token, expires_at) VALUES (?, ?)") - .bind(&raw_token) - .bind("2999-01-01T00:00:00+00:00") - .execute(&pool) - .await - .expect("insert legacy plaintext token"); - - run_migrations(&pool).await.expect("run migrations"); - - // The raw token must be gone from storage... - let stored: (String,) = sqlx::query_as("SELECT token FROM token_blacklist") - .fetch_one(&pool) - .await - .expect("read migrated blacklist row"); - assert_ne!(stored.0, raw_token, "raw token must not survive migration"); - assert_eq!(stored.0, crate::security::sha256_hex(raw_token.as_bytes())); - - // ...while the hash-based revocation check still recognizes it. - assert!( - crate::repositories::token_blacklist::is_token_blacklisted(&pool, &raw_token) - .await - .expect("blacklist lookup"), - "token revoked before the upgrade must still be treated as revoked" - ); - - // Idempotence: a second run (flag set, row already hashed) must not - // double-hash the stored value. - run_migrations(&pool).await.expect("re-run migrations"); - let stored_again: (String,) = sqlx::query_as("SELECT token FROM token_blacklist") - .fetch_one(&pool) - .await - .expect("read blacklist row after re-run"); - assert_eq!(stored_again.0, stored.0); - } -} +mod tests; diff --git a/backend/src/db/migrations/comments.rs b/backend/src/db/migrations/comments.rs new file mode 100644 index 00000000..f4e97e61 --- /dev/null +++ b/backend/src/db/migrations/comments.rs @@ -0,0 +1,169 @@ +use super::*; + +pub(super) async fn apply_comment_migrations( + tx: &mut Transaction<'_, Sqlite>, +) -> Result<(), sqlx::Error> { + // Check if post_id column exists + let has_post_id: bool = sqlx::query_scalar( + "SELECT COUNT(*) FROM pragma_table_info('comments') WHERE name='post_id'", + ) + .fetch_one(&mut **tx) + .await + .map(|count: i64| count > 0)?; + + if !has_post_id { + tracing::info!("Adding post_id column to comments table"); + sqlx::query("ALTER TABLE comments ADD COLUMN post_id TEXT") + .execute(&mut **tx) + .await?; + + // Add index for post_id + sqlx::query("CREATE INDEX IF NOT EXISTS idx_comments_post ON comments(post_id)") + .execute(&mut **tx) + .await?; + } + + let has_rate_limit_key: bool = sqlx::query_scalar( + "SELECT COUNT(*) FROM pragma_table_info('comments') WHERE name='rate_limit_key'", + ) + .fetch_one(&mut **tx) + .await + .map(|count: i64| count > 0)?; + + if !has_rate_limit_key { + tracing::info!("Adding rate_limit_key column to comments table"); + sqlx::query("ALTER TABLE comments ADD COLUMN rate_limit_key TEXT NOT NULL DEFAULT ''") + .execute(&mut **tx) + .await?; + } + + sqlx::query("UPDATE comments SET rate_limit_key = author WHERE rate_limit_key = ''") + .execute(&mut **tx) + .await?; + sqlx::query("CREATE INDEX IF NOT EXISTS idx_comments_rate_limit ON comments(rate_limit_key)") + .execute(&mut **tx) + .await?; + + Ok(()) +} + +pub(super) async fn apply_vote_migration( + tx: &mut Transaction<'_, Sqlite>, +) -> Result<(), sqlx::Error> { + // Create comment_votes table + sqlx::query(include_str!( + "../../../migrations/20241119_create_comment_votes.sql" + )) + .execute(&mut **tx) + .await?; + + // Add votes column to comments if missing + let has_votes: bool = + sqlx::query_scalar("SELECT COUNT(*) FROM pragma_table_info('comments') WHERE name='votes'") + .fetch_one(&mut **tx) + .await + .map(|count: i64| count > 0)?; + + if !has_votes { + tracing::info!("Adding votes column to comments table"); + sqlx::query("ALTER TABLE comments ADD COLUMN votes INTEGER NOT NULL DEFAULT 0") + .execute(&mut **tx) + .await?; + } + + // Add is_admin column to comments if missing + let has_is_admin: bool = sqlx::query_scalar( + "SELECT COUNT(*) FROM pragma_table_info('comments') WHERE name='is_admin'", + ) + .fetch_one(&mut **tx) + .await + .map(|count: i64| count > 0)?; + + if !has_is_admin { + tracing::info!("Adding is_admin column to comments table"); + sqlx::query("ALTER TABLE comments ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE") + .execute(&mut **tx) + .await?; + } + + Ok(()) +} + +pub(super) async fn fix_comment_schema( + tx: &mut Transaction<'_, Sqlite>, +) -> Result<(), sqlx::Error> { + // Check whether the schema fix has already run. SQLite does not expose + // nullability conveniently without parsing the table definition. + // Instead, we'll check if we've already run this fix by checking app_metadata. + let fixed: Option<(String,)> = + sqlx::query_as("SELECT value FROM app_metadata WHERE key = 'comment_schema_fixed_v1'") + .fetch_optional(&mut **tx) + .await?; + + if fixed.is_some() { + return Ok(()); + } + + tracing::info!("Fixing comment schema: Making tutorial_id nullable"); + + // 1. Rename existing table to avoid name collision during schema swap + sqlx::query("ALTER TABLE comments RENAME TO comments_old") + .execute(&mut **tx) + .await?; + + // 2. Create new table with nullable tutorial_id and post_id (the fix) + sqlx::query( + r#" + CREATE TABLE comments ( + id TEXT PRIMARY KEY, + tutorial_id TEXT, + post_id TEXT, + author TEXT NOT NULL, + rate_limit_key TEXT NOT NULL DEFAULT '', + content TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + votes INTEGER NOT NULL DEFAULT 0, + is_admin BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT fk_comments_tutorial FOREIGN KEY (tutorial_id) REFERENCES tutorials(id) ON DELETE CASCADE + ) + "# , + ) + .execute(&mut **tx) + .await?; + + // 3. Migrate data from the old schema to the new one + sqlx::query( + r#" + INSERT INTO comments (id, tutorial_id, post_id, author, rate_limit_key, content, created_at, votes, is_admin) + SELECT id, tutorial_id, post_id, author, + COALESCE(NULLIF(rate_limit_key, ''), author), content, + created_at, votes, is_admin + FROM comments_old + "#, + ) + .execute(&mut **tx) + .await?; + + // 4. Cleanup old temporary table + sqlx::query("DROP TABLE comments_old") + .execute(&mut **tx) + .await?; + + // 5. Recreate performance indices on the new table + sqlx::query("CREATE INDEX IF NOT EXISTS idx_comments_tutorial ON comments(tutorial_id)") + .execute(&mut **tx) + .await?; + sqlx::query("CREATE INDEX IF NOT EXISTS idx_comments_post ON comments(post_id)") + .execute(&mut **tx) + .await?; + sqlx::query("CREATE INDEX IF NOT EXISTS idx_comments_rate_limit ON comments(rate_limit_key)") + .execute(&mut **tx) + .await?; + + // 6. Persist migration state to prevent re-execution + sqlx::query("INSERT INTO app_metadata (key, value) VALUES ('comment_schema_fixed_v1', 'true')") + .execute(&mut **tx) + .await?; + + Ok(()) +} diff --git a/backend/src/db/migrations/maintenance.rs b/backend/src/db/migrations/maintenance.rs new file mode 100644 index 00000000..447c1d5c --- /dev/null +++ b/backend/src/db/migrations/maintenance.rs @@ -0,0 +1,223 @@ +use super::*; + +/// Adds `author_username` and `is_guest` columns to `comments` for authorization. +/// +/// `author` is a free-text display name that guests can set almost arbitrarily, +/// so it must never be used to authorize comment deletion (a registered user +/// could otherwise delete a guest comment that happens to share their +/// username). These two columns record the real, unspoofable identity of the +/// commenter going forward: +/// - `author_username = Some(username)`: authenticated commenter (their real +/// username, never the "Administrator" display name used for admins). +/// - `author_username = NULL, is_guest = Some(true)`: guest commenter, who +/// never has a real identity to record. +/// - `author_username = NULL, is_guest = NULL`: pre-migration row of unknown +/// origin. `is_guest` is required in addition to `author_username` because +/// guest comments are *permanently* NULL for `author_username` -- without a +/// separate marker, a NULL-based fallback to the old (spoofable) `author` +/// string match would stay exploitable for every future guest comment, not +/// just historical ones. See `delete_comment` for how this three-state +/// model is used. +pub(super) async fn apply_comment_author_identity_migration( + tx: &mut Transaction<'_, Sqlite>, +) -> Result<(), sqlx::Error> { + let has_author_username: bool = sqlx::query_scalar( + "SELECT COUNT(*) FROM pragma_table_info('comments') WHERE name='author_username'", + ) + .fetch_one(&mut **tx) + .await + .map(|count: i64| count > 0)?; + + if !has_author_username { + tracing::info!("Adding author_username column to comments table"); + add_column_if_missing_race_safe( + tx, + "ALTER TABLE comments ADD COLUMN author_username TEXT DEFAULT NULL", + ) + .await?; + } + + let has_is_guest: bool = sqlx::query_scalar( + "SELECT COUNT(*) FROM pragma_table_info('comments') WHERE name='is_guest'", + ) + .fetch_one(&mut **tx) + .await + .map(|count: i64| count > 0)?; + + if !has_is_guest { + tracing::info!("Adding is_guest column to comments table"); + add_column_if_missing_race_safe( + tx, + "ALTER TABLE comments ADD COLUMN is_guest BOOLEAN DEFAULT NULL", + ) + .await?; + } + + Ok(()) +} + +/// Adds the `last_attempt_at` column to `login_attempts`. +/// +/// Without a timestamp, rows with fewer than 3 failures (blocked_until NULL) +/// could never be aged out, so the table grew unbounded under attack traffic +/// from rotating IPs (each IP+username combination is its own row). The +/// column lets `repositories::users::cleanup_stale_login_attempts` purge rows +/// that have been inactive long enough that their lockout state is moot. +/// Pre-existing rows are backfilled with the migration time so they enter the +/// same aging window instead of staying unpurgeable forever. +pub(super) async fn apply_login_attempt_migrations( + tx: &mut Transaction<'_, Sqlite>, +) -> Result<(), sqlx::Error> { + let has_last_attempt_at: bool = sqlx::query_scalar( + "SELECT COUNT(*) FROM pragma_table_info('login_attempts') WHERE name='last_attempt_at'", + ) + .fetch_one(&mut **tx) + .await + .map(|count: i64| count > 0)?; + + if !has_last_attempt_at { + tracing::info!("Adding last_attempt_at column to login_attempts table"); + add_column_if_missing_race_safe( + tx, + "ALTER TABLE login_attempts ADD COLUMN last_attempt_at TEXT DEFAULT NULL", + ) + .await?; + + let now = chrono::Utc::now().to_rfc3339(); + sqlx::query("UPDATE login_attempts SET last_attempt_at = ? WHERE last_attempt_at IS NULL") + .bind(now) + .execute(&mut **tx) + .await?; + } + + Ok(()) +} + +/// Runs an `ALTER TABLE ... ADD COLUMN` statement, tolerating a "duplicate +/// column name" failure. +/// +/// The existence check above this call and the `ALTER TABLE` itself are not +/// atomic: if two instances of the app start concurrently against the same +/// SQLite file (e.g. a rolling deploy with overlapping replicas), both can +/// observe the column as missing before either commits its `ALTER TABLE`, +/// and the loser would otherwise fail its whole migration with "duplicate +/// column name" and abort startup. Since the only possible cause of that +/// specific error here is a concurrent run of this same idempotent +/// migration, it is safe to treat it as success rather than propagate it. +pub(super) async fn add_column_if_missing_race_safe( + tx: &mut Transaction<'_, Sqlite>, + alter_statement: &str, +) -> Result<(), sqlx::Error> { + match sqlx::query(alter_statement).execute(&mut **tx).await { + Ok(_) => Ok(()), + Err(e) if is_duplicate_column_error(&e) => { + tracing::warn!( + "Column already added by a concurrent migration run, continuing: {}", + e + ); + Ok(()) + } + Err(e) => Err(e), + } +} + +/// Detects SQLite's "duplicate column name" error, which `ALTER TABLE ... +/// ADD COLUMN` raises when the column already exists. +pub(super) fn is_duplicate_column_error(err: &sqlx::Error) -> bool { + err.as_database_error() + .map(|db_err| db_err.message().contains("duplicate column name")) + .unwrap_or(false) +} + +/// Rehashes legacy plaintext rows in `token_blacklist` to SHA-256. +/// +/// The repository layer used to store raw JWTs in the blacklist and now +/// stores and looks up only their SHA-256 hashes (see +/// `repositories::token_blacklist::hash_token`). On a database that predates +/// that change, existing rows still hold raw tokens, which the hashed lookup +/// can never match -- without this backfill, every token revoked before the +/// upgrade (e.g. via logout) would silently become valid again for its +/// remaining lifetime, undoing the revocation the user already performed. +/// +/// Gated by an `app_metadata` flag like the other one-time migrations. The +/// per-row hex check is a second, defensive layer: a raw JWT always contains +/// `.` separators and can never look like a 64-char hex digest, so +/// already-hashed rows are never double-hashed even if the flag is somehow +/// lost (manual metadata edit, partial restore from backup). +pub(super) async fn apply_token_blacklist_hash_migration( + tx: &mut Transaction<'_, Sqlite>, +) -> Result<(), sqlx::Error> { + let migrated: Option<(String,)> = + sqlx::query_as("SELECT value FROM app_metadata WHERE key = 'token_blacklist_hashed_v1'") + .fetch_optional(&mut **tx) + .await?; + + if migrated.is_some() { + return Ok(()); + } + + let rows: Vec<(String,)> = sqlx::query_as("SELECT token FROM token_blacklist") + .fetch_all(&mut **tx) + .await?; + + let mut rehashed = 0u64; + for (token,) in rows { + if is_sha256_hex(&token) { + continue; + } + // UPDATE OR REPLACE: if the hashed form somehow already exists as its + // own row, replace it instead of failing the whole startup on a + // primary-key conflict. Either way the raw token is gone afterwards. + sqlx::query("UPDATE OR REPLACE token_blacklist SET token = ? WHERE token = ?") + .bind(crate::security::sha256_hex(token.as_bytes())) + .bind(&token) + .execute(&mut **tx) + .await?; + rehashed += 1; + } + + if rehashed > 0 { + tracing::info!( + "Rehashed {} legacy plaintext token_blacklist row(s) to SHA-256", + rehashed + ); + } + + // OR REPLACE keeps a concurrent second instance (rolling deploy) from + // aborting startup on a duplicate-key conflict for the flag itself. + sqlx::query( + "INSERT OR REPLACE INTO app_metadata (key, value) VALUES ('token_blacklist_hashed_v1', 'true')", + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +/// True if `s` is exactly a lowercase-or-uppercase 64-character hex string, +/// i.e. shaped like a SHA-256 digest. Raw JWTs always contain `.` separators, +/// so they can never satisfy this. +pub(super) fn is_sha256_hex(s: &str) -> bool { + s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit()) +} + +pub(super) async fn apply_site_post_migrations( + tx: &mut Transaction<'_, Sqlite>, +) -> Result<(), sqlx::Error> { + // Check if allow_comments column exists + let has_allow_comments: bool = sqlx::query_scalar( + "SELECT COUNT(*) FROM pragma_table_info('site_posts') WHERE name='allow_comments'", + ) + .fetch_one(&mut **tx) + .await + .map(|count: i64| count > 0)?; + + if !has_allow_comments { + tracing::info!("Adding allow_comments column to site_posts table"); + sqlx::query("ALTER TABLE site_posts ADD COLUMN allow_comments BOOLEAN NOT NULL DEFAULT 1") + .execute(&mut **tx) + .await?; + } + + Ok(()) +} diff --git a/backend/src/db/migrations/site_pages.rs b/backend/src/db/migrations/site_pages.rs new file mode 100644 index 00000000..cbdddd48 --- /dev/null +++ b/backend/src/db/migrations/site_pages.rs @@ -0,0 +1,76 @@ +use super::*; + +pub(super) async fn ensure_site_page_schema(pool: &DbPool) -> Result<(), sqlx::Error> { + let mut tx = pool.begin().await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS site_content ( + section TEXT PRIMARY KEY, + content_json TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + )", + ) + .execute(&mut *tx) + .await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS site_pages ( + id TEXT PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + nav_label TEXT, + show_in_nav INTEGER NOT NULL DEFAULT 0, + order_index INTEGER NOT NULL DEFAULT 0, + is_published INTEGER NOT NULL DEFAULT 0, + hero_json TEXT NOT NULL DEFAULT '{}', + layout_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + )", + ) + .execute(&mut *tx) + .await?; + + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_site_pages_nav ON site_pages(show_in_nav, order_index)", + ) + .execute(&mut *tx) + .await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS site_posts ( + id TEXT PRIMARY KEY, + page_id TEXT NOT NULL, + title TEXT NOT NULL, + slug TEXT NOT NULL, + excerpt TEXT DEFAULT '', + content_markdown TEXT NOT NULL, + is_published INTEGER NOT NULL DEFAULT 0, + allow_comments BOOLEAN NOT NULL DEFAULT 1, + published_at TEXT, + order_index INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(page_id) REFERENCES site_pages(id) ON DELETE CASCADE + )", + ) + .execute(&mut *tx) + .await?; + + sqlx::query( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_site_posts_unique_slug ON site_posts(page_id, slug)", + ) + .execute(&mut *tx) + .await?; + + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_site_posts_page_published ON site_posts(page_id, is_published, published_at)", + ) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(()) +} diff --git a/backend/src/db/migrations/tests.rs b/backend/src/db/migrations/tests.rs new file mode 100644 index 00000000..b10feebf --- /dev/null +++ b/backend/src/db/migrations/tests.rs @@ -0,0 +1,165 @@ +use super::run_migrations; +use sqlx::sqlite::SqlitePoolOptions; + +#[tokio::test] +async fn run_migrations_backfills_rate_limit_key_for_legacy_comments() { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("create sqlite pool"); + + sqlx::query( + r#" + CREATE TABLE tutorials ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT NOT NULL, + icon TEXT NOT NULL, + color TEXT NOT NULL, + topics TEXT NOT NULL, + content TEXT NOT NULL DEFAULT '', + version INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + "#, + ) + .execute(&pool) + .await + .expect("create legacy tutorials table"); + + sqlx::query( + r#" + INSERT INTO tutorials (id, title, description, icon, color, topics, content) + VALUES ( + 'tutorial-1', 'Legacy Tutorial', 'Legacy description', 'book', + '#000000', 'legacy', 'Legacy content' + ) + "#, + ) + .execute(&pool) + .await + .expect("insert legacy tutorial"); + + sqlx::query( + r#" + CREATE TABLE comments ( + id TEXT PRIMARY KEY, + tutorial_id TEXT NOT NULL, + author TEXT NOT NULL, + content TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + "#, + ) + .execute(&pool) + .await + .expect("create legacy comments table"); + + sqlx::query( + r#" + INSERT INTO comments (id, tutorial_id, author, content, created_at) + VALUES ('legacy-comment', 'tutorial-1', 'Legacy Author', 'Old comment', '2024-01-01T00:00:00Z') + "#, + ) + .execute(&pool) + .await + .expect("insert legacy comment"); + + run_migrations(&pool) + .await + .expect("migrate legacy comments table"); + + let has_rate_limit_key: bool = sqlx::query_scalar( + "SELECT COUNT(*) FROM pragma_table_info('comments') WHERE name='rate_limit_key'", + ) + .fetch_one(&pool) + .await + .map(|count: i64| count > 0) + .expect("check rate_limit_key column"); + + assert!(has_rate_limit_key); + + let has_newsletter_table: bool = sqlx::query_scalar( + "SELECT COUNT(*) FROM sqlite_master \ + WHERE type = 'table' AND name = 'newsletter_subscriptions'", + ) + .fetch_one(&pool) + .await + .map(|count: i64| count == 1) + .expect("check newsletter table"); + + assert!(has_newsletter_table); + + let (rate_limit_key,): (String,) = + sqlx::query_as("SELECT rate_limit_key FROM comments WHERE id = 'legacy-comment'") + .fetch_one(&pool) + .await + .expect("read migrated comment"); + + assert_eq!(rate_limit_key, "Legacy Author"); +} + +/// Regression test for the blacklist-hashing upgrade path: a database +/// written by a pre-hashing version of the app holds raw JWTs in +/// token_blacklist. After migrations run, those rows must be rehashed so +/// the (now hash-based) revocation lookup still recognizes them -- +/// otherwise every token revoked before the upgrade would silently +/// become valid again for its remaining lifetime. +#[tokio::test] +async fn run_migrations_rehashes_legacy_plaintext_blacklist_tokens() { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("create sqlite pool"); + + // Simulate the pre-hashing schema and a raw bearer token revoked under it. + sqlx::query( + r#" + CREATE TABLE token_blacklist ( + token TEXT PRIMARY KEY, + expires_at TEXT NOT NULL + ) + "#, + ) + .execute(&pool) + .await + .expect("create legacy token_blacklist table"); + + let raw_token = ["legacy", "plaintext", "revocation-token", "before-hashing"].join("-"); + sqlx::query("INSERT INTO token_blacklist (token, expires_at) VALUES (?, ?)") + .bind(&raw_token) + .bind("2999-01-01T00:00:00+00:00") + .execute(&pool) + .await + .expect("insert legacy plaintext token"); + + run_migrations(&pool).await.expect("run migrations"); + + // The raw token must be gone from storage... + let stored: (String,) = sqlx::query_as("SELECT token FROM token_blacklist") + .fetch_one(&pool) + .await + .expect("read migrated blacklist row"); + assert_ne!(stored.0, raw_token, "raw token must not survive migration"); + assert_eq!(stored.0, crate::security::sha256_hex(raw_token.as_bytes())); + + // ...while the hash-based revocation check still recognizes it. + assert!( + crate::repositories::token_blacklist::is_token_blacklisted(&pool, &raw_token) + .await + .expect("blacklist lookup"), + "token revoked before the upgrade must still be treated as revoked" + ); + + // Idempotence: a second run (flag set, row already hashed) must not + // double-hash the stored value. + run_migrations(&pool).await.expect("re-run migrations"); + let stored_again: (String,) = sqlx::query_as("SELECT token FROM token_blacklist") + .fetch_one(&pool) + .await + .expect("read blacklist row after re-run"); + assert_eq!(stored_again.0, stored.0); +} diff --git a/backend/src/db/seed.rs b/backend/src/db/seed.rs index 0de17184..d2d8624a 100644 --- a/backend/src/db/seed.rs +++ b/backend/src/db/seed.rs @@ -30,20 +30,20 @@ fn default_site_content() -> Vec<(&'static str, serde_json::Value)> { ( "hero", json!({ - "badgeText": "Professionelles Linux Training", + "badgeText": "Persönlicher Blog", "title": { - "line1": "Lerne Linux", - "line2": "von Grund auf" + "line1": "Gedanken, Projekte", + "line2": "& Dinge dazwischen" }, - "subtitle": "Dein umfassendes Tutorial für Linux – von den Basics bis zu Advanced-Techniken.", - "subline": "Interaktiv, modern und praxisnah.", + "subtitle": "Persönliche Notizen über Technik, Ideen und alles, was mich beschäftigt.", + "subline": "Ausprobiert, durchdacht und ehrlich aufgeschrieben.", "primaryCta": { - "label": "Los geht's", - "target": { "type": "section", "value": "tutorials" } + "label": "Beiträge lesen", + "target": { "type": "section", "value": "stories" } }, "secondaryCta": { - "label": "Mehr erfahren", - "target": { "type": "section", "value": "tutorials" } + "label": "Über diesen Blog", + "target": { "type": "section", "value": "about" } }, "features": [ { @@ -88,24 +88,46 @@ fn default_site_content() -> Vec<(&'static str, serde_json::Value)> { ( "site_meta", json!({ - "title": "Linux Tutorial - Lerne Linux Schritt für Schritt", - "description": "Lerne Linux von Grund auf - Interaktiv, modern und praxisnah." + "title": "Zero Point – Persönlicher Blog", + "description": "Persönliche Notizen über Technik, Projekte, Ideen und alles dazwischen." + }), + ), + ( + "about", + json!({ + "eyebrow": "Warum ich schreibe", + "lead": "Ich schreibe, um Dinge wirklich zu verstehen – und um meine Gedanken nicht zu verlieren.", + "paragraphs": [ + concat!( + "Dieser Blog ist mein digitales Notizbuch. Ich teile, was ich lerne, ", + "woran ich arbeite und welche Fragen mich gerade begleiten." + ), + concat!( + "Die Themen dürfen wechseln. Was bleibt, ist eine persönliche Perspektive, ", + "ehrliche Neugier und der Wunsch, Gedanken sauber zu Ende zu denken." + ) + ] + }), + ), + ( + "cta_section", + json!({ + "title": "Neue Notizen per Mail", + "description": "Ich melde mich, wenn es einen neuen Gedanken oder Beitrag zu teilen gibt." }), ), ( "header", json!({ "brand": { - "name": "Linux Tutorial", - "tagline": "", + "name": "Zero Point", + "tagline": "Persönlicher Blog", "icon": "Terminal" }, "navItems": [ - { "id": "home", "label": "Home", "type": "section" }, - { "id": "grundlagen", "label": "Grundlagen", "type": "route", "path": "/grundlagen" }, - { "id": "befehle", "label": "Befehle", "type": "section" }, - { "id": "praxis", "label": "Praxis", "type": "section" }, - { "id": "advanced", "label": "Advanced", "type": "section" } + { "id": "stories", "label": "Beiträge", "type": "section", "value": "stories" }, + { "id": "topics", "label": "Themen", "type": "section", "value": "topics" }, + { "id": "about", "label": "Über diesen Blog", "type": "section", "value": "about" } ], "cta": { "guestLabel": "Login", @@ -118,23 +140,18 @@ fn default_site_content() -> Vec<(&'static str, serde_json::Value)> { "footer", json!({ "brand": { - "title": "Linux Tutorial", - "description": "Dein umfassendes Tutorial für Linux - von den Basics bis zu Advanced Techniken.", + "title": "Zero Point", + "description": "Persönliche Notizen über Technik, Projekte, Ideen und alles dazwischen.", "icon": "Terminal" }, "quickLinks": [ - { "label": "Grundlagen", "target": { "type": "section", "value": "grundlagen" } }, - { "label": "Befehle", "target": { "type": "section", "value": "befehle" } }, - { "label": "Praxis", "target": { "type": "section", "value": "praxis" } }, - { "label": "Advanced", "target": { "type": "section", "value": "advanced" } } - ], - "contactLinks": [ - { "label": "GitHub", "href": "https://github.com", "icon": "Github" }, - { "label": "E-Mail", "href": "mailto:info@example.com", "icon": "Mail" } + { "label": "Beiträge", "target": { "type": "section", "value": "stories" } }, + { "label": "Über diesen Blog", "target": { "type": "section", "value": "about" } } ], + "contactLinks": [], "bottom": { - "copyright": "© {year} Linux Tutorial. Alle Rechte vorbehalten.", - "signature": "Gemacht mit Herz für die Linux Community" + "copyright": "© {year} Zero Point.", + "signature": "Persönlich notiert" } }), ), @@ -144,34 +161,54 @@ fn default_site_content() -> Vec<(&'static str, serde_json::Value)> { "hero": { "badge": "Grundlagenkurs", "title": "Starte deine Linux-Reise mit einem starken Fundament", - "description": "In diesem Grundlagenbereich begleiten wir dich von den allerersten Schritten im Terminal bis hin zu sicheren Arbeitsabläufen. Nach diesem Kurs bewegst du dich selbstbewusst in der Linux-Welt.", + "description": concat!( + "In diesem Grundlagenbereich begleiten wir dich von den allerersten Schritten im Terminal ", + "bis hin zu sicheren Arbeitsabläufen. Nach diesem Kurs bewegst du dich selbstbewusst in ", + "der Linux-Welt.", + ), "icon": "BookOpen" }, "highlights": [ { "icon": "BookOpen", "title": "Terminal Basics verstehen", - "description": "Lerne die wichtigsten Shell-Befehle, arbeite sicher mit Dateien und nutze Pipes, um Aufgaben zu automatisieren." + "description": concat!( + "Lerne die wichtigsten Shell-Befehle, arbeite sicher mit Dateien und nutze Pipes, um ", + "Aufgaben zu automatisieren.", + ) }, { "icon": "Compass", "title": "Linux-Philosophie kennenlernen", - "description": "Verstehe das Zusammenspiel von Kernel, Distribution, Paketverwaltung und warum Linux so flexibel einsetzbar ist." + "description": concat!( + "Verstehe das Zusammenspiel von Kernel, Distribution, Paketverwaltung und warum Linux so ", + "flexibel einsetzbar ist.", + ) }, { "icon": "Layers", "title": "Praxisnahe Übungen", - "description": "Setze das Erlernte direkt in kleinen Projekten um – von der Benutzerverwaltung bis zum Einrichten eines Webservers." + "description": concat!( + "Setze das Erlernte direkt in kleinen Projekten um – von der Benutzerverwaltung bis zum ", + "Einrichten eines Webservers.", + ) }, { "icon": "ShieldCheck", "title": "Sicher arbeiten", - "description": "Erhalte Best Practices für Benutzerrechte, sudo, SSH und weitere Sicherheitsmechanismen." + "description": concat!( + "Erhalte Best Practices für Benutzerrechte, sudo, SSH und weitere ", + "Sicherheitsmechanismen." + ) } ], "modules": { "title": "Module im Grundlagenkurs", - "description": "Unsere Tutorials bauen logisch aufeinander auf. Jedes Modul enthält praxisnahe Beispiele, Schritt-für-Schritt Anleitungen und kleine Wissenschecks, damit du deinen Fortschritt direkt sehen kannst.", + "description": concat!( + "Unsere Tutorials bauen logisch aufeinander auf. Jedes Modul enthält praxisnahe ", + "Beispiele, Schritt-für-Schritt Anleitungen und kleine Wissenschecks, damit du deinen ", + "Fortschritt direkt sehen kannst.", + ), "items": [ "Einstieg in die Shell: Navigation, grundlegende Befehle, Dateiverwaltung", "Linux-Systemaufbau: Kernel, Distributionen, Paketmanager verstehen und nutzen", @@ -186,7 +223,11 @@ fn default_site_content() -> Vec<(&'static str, serde_json::Value)> { }, "cta": { "title": "Bereit für den nächsten Schritt?", - "description": "Wechsel zur Startseite und wähle das Modul, das am besten zu dir passt, oder tauche direkt in die Praxis- und Advanced-Themen ein, sobald du die Grundlagen sicher beherrschst.", + "description": concat!( + "Wechsel zur Startseite und wähle das Modul, das am besten zu dir passt, oder tauche ", + "direkt in die Praxis- und Advanced-Themen ein, sobald du die Grundlagen sicher ", + "beherrschst.", + ), "primary": { "label": "Zur Startseite", "href": "/" }, "secondary": { "label": "Tutorials verwalten", "href": "/admin" } } @@ -335,9 +376,11 @@ pub async fn insert_default_tutorials_tx( )) })?; - sqlx::query( - "INSERT INTO tutorials (id, title, description, icon, color, topics, content, version) VALUES (?, ?, ?, ?, ?, ?, ?, 1)" - ) + sqlx::query(concat!( + "INSERT INTO tutorials ", + "(id, title, description, icon, color, topics, content, version) ", + "VALUES (?, ?, ?, ?, ?, ?, ?, 1)" + )) .bind(id) .bind(title) .bind(description) diff --git a/backend/src/handlers/auth.rs b/backend/src/handlers/auth.rs index daa44d2f..8bf97be6 100644 --- a/backend/src/handlers/auth.rs +++ b/backend/src/handlers/auth.rs @@ -42,182 +42,9 @@ use rand::RngExt; use std::net::SocketAddr; use std::{env, sync::OnceLock, time::Duration}; -/// Global salt for hashing login attempt identifiers. -/// Initialized once at startup via init_login_attempt_salt(). -static LOGIN_ATTEMPT_SALT: OnceLock = OnceLock::new(); - -/// Per-(IP+username) lockout: short block (10s) after this many failures. -const PAIR_SHORT_THRESHOLD: i64 = 3; -/// Per-(IP+username) lockout: long block (60s) after this many failures. -const PAIR_LONG_THRESHOLD: i64 = 5; - -/// IP-wide lockout thresholds. Looser than the pair key so shared addresses -/// (NAT, office networks) are not punished for one user's typos, but tight -/// enough that spraying many usernames from a single address stalls quickly. -const IP_WIDE_SHORT_THRESHOLD: i64 = 10; -const IP_WIDE_LONG_THRESHOLD: i64 = 20; -/// IP-wide lockout: short block duration in seconds. -const IP_WIDE_SHORT_BLOCK_SECONDS: i64 = 60; -/// IP-wide lockout: long block duration in seconds. -const IP_WIDE_LONG_BLOCK_SECONDS: i64 = 300; - -/// Initializes the login attempt salt from environment. -/// -/// This salt is used to hash usernames before storing them in the -/// login_attempts table, preventing username enumeration attacks. -/// -/// # Returns -/// - `Ok(())` if initialization succeeds -/// - `Err(String)` with error message if validation fails -/// -/// # Errors -/// - LOGIN_ATTEMPT_SALT environment variable not set -/// - Salt is too short (< 32 characters) -/// - Salt has insufficient entropy (< 10 unique characters) -/// - Salt was already initialized -pub fn init_login_attempt_salt() -> Result<(), String> { - let raw = env::var("LOGIN_ATTEMPT_SALT") - .map_err(|_| "LOGIN_ATTEMPT_SALT environment variable not set".to_string())?; - let trimmed = raw.trim(); - - if trimmed.len() < 32 { - return Err("LOGIN_ATTEMPT_SALT must be at least 32 characters long".to_string()); - } - - let unique_chars = trimmed - .chars() - .collect::>() - .len(); - if unique_chars < 10 { - return Err("LOGIN_ATTEMPT_SALT must contain at least 10 unique characters".to_string()); - } - - LOGIN_ATTEMPT_SALT - .set(trimmed.to_string()) - .map_err(|_| "LOGIN_ATTEMPT_SALT already initialized".to_string())?; - - Ok(()) -} - -/// Retrieves the initialized login attempt salt. -/// -/// # Panics -/// Panics if init_login_attempt_salt() has not been called yet. -fn login_attempt_salt() -> &'static str { - LOGIN_ATTEMPT_SALT - .get() - .expect("LOGIN_ATTEMPT_SALT not initialized. Call init_login_attempt_salt() first.") - .as_str() -} - -/// Hashes a username for login attempt tracking. -/// -/// Creates a salted SHA-256 hash of the normalized username. -/// This prevents username enumeration by obscuring which accounts exist. -/// -/// # Arguments -/// * `username` - The username to hash -/// -/// # Returns -/// Hex-encoded SHA-256 hash -/// -/// # Security -/// - Username is trimmed and lowercased for normalization -/// - Salt prevents rainbow table attacks -/// - Hash prevents direct username storage -fn hash_login_identifier(username: &str) -> String { - let mut data = login_attempt_salt().as_bytes().to_vec(); - data.extend_from_slice(username.trim().to_ascii_lowercase().as_bytes()); - crate::security::sha256_hex(&data) -} - -/// Parses an optional RFC3339 timestamp string into a UTC DateTime. -/// -/// # Arguments -/// * `value` - Optional RFC3339 timestamp string -/// -/// # Returns -/// - `Some(DateTime)` if parsing succeeds -/// - `None` if value is None or parsing fails -fn parse_rfc3339_opt(value: &Option) -> Option> { - value - .as_ref() - .and_then(|timestamp| chrono::DateTime::parse_from_rfc3339(timestamp).ok()) - .map(|dt| dt.with_timezone(&Utc)) -} - -/// Returns a precomputed dummy bcrypt hash for timing-attack resistance. -/// -/// This hash is used during failed login attempts to ensure password -/// verification takes constant time regardless of whether the user exists. -/// -/// # Returns -/// A static bcrypt hash string -/// -/// # Security -/// Using a dummy hash when the user doesn't exist prevents timing attacks -/// that could enumerate valid usernames by measuring response times. -fn dummy_bcrypt_hash() -> &'static str { - static DUMMY_HASH: OnceLock = OnceLock::new(); - - DUMMY_HASH.get_or_init(|| match bcrypt::hash("dummy", bcrypt::DEFAULT_COST) { - Ok(hash) => hash, - Err(err) => { - tracing::error!("Failed to generate dummy hash: {}", err); - "$2b$12$eImiTXuWVxfM37uY4JANjQPzMzXZjQDzqzQpMv0xoGrTplPPNaE3W".to_string() - } - }) -} - -/// Validates a username meets security and format requirements. -/// -/// # Arguments -/// * `username` - The username to validate -/// -/// # Returns -/// - `Ok(())` if valid -/// - `Err(String)` with error message if invalid -/// -/// # Validation Rules -/// - Not empty -/// - Length ≤ 50 characters -/// - Only alphanumeric, underscore, hyphen, and period allowed -fn validate_username(username: &str) -> Result<(), String> { - if username.is_empty() { - return Err("Username cannot be empty".to_string()); - } - if username.len() > 50 { - return Err("Username too long".to_string()); - } - - if !username - .chars() - .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.') - { - return Err("Username contains invalid characters".to_string()); - } - Ok(()) -} - -/// Validates a password submitted during login. -/// -/// Deliberately minimal: complexity rules belong to password creation, -/// not login. Enforcing them here would lock out existing users whose -/// stored passwords predate the policy and would leak the policy to -/// attackers via distinguishable 400 responses. -/// -/// # Validation Rules -/// - Not empty -/// - Length ≤ 128 characters (prevents DoS via expensive bcrypt hashing) -fn validate_login_password(password: &str) -> Result<(), String> { - if password.is_empty() { - return Err("Password cannot be empty".to_string()); - } - if password.len() > 128 { - return Err("Password too long".to_string()); - } - Ok(()) -} +mod support; +pub use support::init_login_attempt_salt; +use support::*; /// HTTP handler for user login. /// @@ -558,135 +385,4 @@ pub async fn logout( } #[cfg(test)] -mod tests { - use super::*; - use crate::db::migrations::run_migrations; - use sqlx::SqlitePool; - - async fn setup_test_db() -> DbPool { - let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); - run_migrations(&pool) - .await - .expect("Failed to run migrations"); - pool - } - - fn init_salts() { - if LOGIN_ATTEMPT_SALT.get().is_none() { - env::set_var( - "LOGIN_ATTEMPT_SALT", - "this_is_a_test_salt_for_login_attempts_at_least_32_chars", - ); - let _ = init_login_attempt_salt(); - } - if auth::JWT_SECRET.get().is_none() { - env::set_var( - "JWT_SECRET", - "this_is_a_test_jwt_secret_with_adequate_entropy_123_ABC_!!!", - ); - let _ = auth::init_jwt_secret(); - } - // CSRF_SECRET is private, we just call init and ignore "already initialized" error - env::set_var( - "CSRF_SECRET", - "this_is_a_very_long_secret_key_for_testing_purposes_only_at_least_32_bytes", - ); - let _ = csrf::init_csrf_secret(); - } - - #[tokio::test] - async fn test_login_invalid_credentials() { - init_salts(); - let pool = setup_test_db().await; - - let payload = LoginRequest { - username: "nonexistent".to_string(), - password: "InvalidPassword123!".to_string(), - }; - - let addr = "127.0.0.1:1234".parse().unwrap(); - - let result = login( - State(pool), - HeaderMap::new(), - ConnectInfo(addr), - Json(payload), - ) - .await; - - assert!(result.is_err()); - let (status, Json(body)) = result.unwrap_err(); - assert_eq!(status, StatusCode::UNAUTHORIZED); - assert_eq!(body.error, "Invalid credentials"); - } - - /// Regression test for the password-spraying gap: rotating usernames - /// gives the attacker a fresh (IP+username) pair key on every attempt, - /// so only the IP-wide counter can stop them. After - /// IP_WIDE_SHORT_THRESHOLD failures from one address, the next attempt - /// must be rejected with 429 regardless of which username it targets. - #[tokio::test] - async fn test_ip_wide_lockout_blocks_username_rotation() { - init_salts(); - let pool = setup_test_db().await; - let addr: std::net::SocketAddr = "127.0.0.2:1234".parse().unwrap(); - - for i in 0..IP_WIDE_SHORT_THRESHOLD { - let result = login( - State(pool.clone()), - HeaderMap::new(), - ConnectInfo(addr), - Json(LoginRequest { - username: format!("sprayed_user_{}", i), - password: "WrongPassword123!".to_string(), - }), - ) - .await; - - let (status, _) = result.expect_err("login with bad password must fail"); - assert_eq!( - status, - StatusCode::UNAUTHORIZED, - "attempt {} should fail with 401, not yet be rate limited", - i - ); - } - - let result = login( - State(pool), - HeaderMap::new(), - ConnectInfo(addr), - Json(LoginRequest { - username: "yet_another_user".to_string(), - password: "WrongPassword123!".to_string(), - }), - ) - .await; - - let (status, _) = result.expect_err("attempt past the IP-wide threshold must fail"); - assert_eq!(status, StatusCode::TOO_MANY_REQUESTS); - } - - #[test] - fn test_validate_username() { - assert!(validate_username("admin").is_ok()); - assert!(validate_username("user.name").is_ok()); - assert!(validate_username("user_123").is_ok()); - assert!(validate_username("").is_err()); - assert!(validate_username("user!").is_err()); - assert!(validate_username("a".repeat(51).as_str()).is_err()); - } - - #[test] - fn test_validate_login_password() { - assert!(validate_login_password("ValidPassword123!").is_ok()); - // Login must not enforce complexity rules: existing users whose - // passwords predate the policy still need to authenticate. - assert!(validate_login_password("short").is_ok()); - assert!(validate_login_password("NoSpecialChar123").is_ok()); - assert!(validate_login_password("nonumberspec!").is_ok()); - // Only emptiness and the bcrypt DoS length cap are rejected. - assert!(validate_login_password("").is_err()); - assert!(validate_login_password(&"a".repeat(129)).is_err()); - } -} +mod tests; diff --git a/backend/src/handlers/auth/support.rs b/backend/src/handlers/auth/support.rs new file mode 100644 index 00000000..8eb2ff08 --- /dev/null +++ b/backend/src/handlers/auth/support.rs @@ -0,0 +1,178 @@ +use super::*; + +/// Global salt for hashing login attempt identifiers. +/// Initialized once at startup via init_login_attempt_salt(). +pub(super) static LOGIN_ATTEMPT_SALT: OnceLock = OnceLock::new(); + +/// Per-(IP+username) lockout: short block (10s) after this many failures. +pub(super) const PAIR_SHORT_THRESHOLD: i64 = 3; +/// Per-(IP+username) lockout: long block (60s) after this many failures. +pub(super) const PAIR_LONG_THRESHOLD: i64 = 5; + +/// IP-wide lockout thresholds. Looser than the pair key so shared addresses +/// (NAT, office networks) are not punished for one user's typos, but tight +/// enough that spraying many usernames from a single address stalls quickly. +pub(super) const IP_WIDE_SHORT_THRESHOLD: i64 = 10; +pub(super) const IP_WIDE_LONG_THRESHOLD: i64 = 20; +/// IP-wide lockout: short block duration in seconds. +pub(super) const IP_WIDE_SHORT_BLOCK_SECONDS: i64 = 60; +/// IP-wide lockout: long block duration in seconds. +pub(super) const IP_WIDE_LONG_BLOCK_SECONDS: i64 = 300; + +/// Initializes the login attempt salt from environment. +/// +/// This salt is used to hash usernames before storing them in the +/// login_attempts table, preventing username enumeration attacks. +/// +/// # Returns +/// - `Ok(())` if initialization succeeds +/// - `Err(String)` with error message if validation fails +/// +/// # Errors +/// - LOGIN_ATTEMPT_SALT environment variable not set +/// - Salt is too short (< 32 characters) +/// - Salt has insufficient entropy (< 10 unique characters) +/// - Salt was already initialized +pub fn init_login_attempt_salt() -> Result<(), String> { + let raw = env::var("LOGIN_ATTEMPT_SALT") + .map_err(|_| "LOGIN_ATTEMPT_SALT environment variable not set".to_string())?; + let trimmed = raw.trim(); + + if trimmed.len() < 32 { + return Err("LOGIN_ATTEMPT_SALT must be at least 32 characters long".to_string()); + } + + let unique_chars = trimmed + .chars() + .collect::>() + .len(); + if unique_chars < 10 { + return Err("LOGIN_ATTEMPT_SALT must contain at least 10 unique characters".to_string()); + } + + LOGIN_ATTEMPT_SALT + .set(trimmed.to_string()) + .map_err(|_| "LOGIN_ATTEMPT_SALT already initialized".to_string())?; + + Ok(()) +} + +/// Retrieves the initialized login attempt salt. +/// +/// # Panics +/// Panics if init_login_attempt_salt() has not been called yet. +pub(super) fn login_attempt_salt() -> &'static str { + LOGIN_ATTEMPT_SALT + .get() + .expect("LOGIN_ATTEMPT_SALT not initialized. Call init_login_attempt_salt() first.") + .as_str() +} + +/// Hashes a username for login attempt tracking. +/// +/// Creates a salted SHA-256 hash of the normalized username. +/// This prevents username enumeration by obscuring which accounts exist. +/// +/// # Arguments +/// * `username` - The username to hash +/// +/// # Returns +/// Hex-encoded SHA-256 hash +/// +/// # Security +/// - Username is trimmed and lowercased for normalization +/// - Salt prevents rainbow table attacks +/// - Hash prevents direct username storage +pub(super) fn hash_login_identifier(username: &str) -> String { + let mut data = login_attempt_salt().as_bytes().to_vec(); + data.extend_from_slice(username.trim().to_ascii_lowercase().as_bytes()); + crate::security::sha256_hex(&data) +} + +/// Parses an optional RFC3339 timestamp string into a UTC DateTime. +/// +/// # Arguments +/// * `value` - Optional RFC3339 timestamp string +/// +/// # Returns +/// - `Some(DateTime)` if parsing succeeds +/// - `None` if value is None or parsing fails +pub(super) fn parse_rfc3339_opt(value: &Option) -> Option> { + value + .as_ref() + .and_then(|timestamp| chrono::DateTime::parse_from_rfc3339(timestamp).ok()) + .map(|dt| dt.with_timezone(&Utc)) +} + +/// Returns a precomputed dummy bcrypt hash for timing-attack resistance. +/// +/// This hash is used during failed login attempts to ensure password +/// verification takes constant time regardless of whether the user exists. +/// +/// # Returns +/// A static bcrypt hash string +/// +/// # Security +/// Using a dummy hash when the user doesn't exist prevents timing attacks +/// that could enumerate valid usernames by measuring response times. +pub(super) fn dummy_bcrypt_hash() -> &'static str { + static DUMMY_HASH: OnceLock = OnceLock::new(); + + DUMMY_HASH.get_or_init(|| match bcrypt::hash("dummy", bcrypt::DEFAULT_COST) { + Ok(hash) => hash, + Err(err) => { + tracing::error!("Failed to generate dummy hash: {}", err); + "$2b$12$eImiTXuWVxfM37uY4JANjQPzMzXZjQDzqzQpMv0xoGrTplPPNaE3W".to_string() + } + }) +} + +/// Validates a username meets security and format requirements. +/// +/// # Arguments +/// * `username` - The username to validate +/// +/// # Returns +/// - `Ok(())` if valid +/// - `Err(String)` with error message if invalid +/// +/// # Validation Rules +/// - Not empty +/// - Length ≤ 50 characters +/// - Only alphanumeric, underscore, hyphen, and period allowed +pub(super) fn validate_username(username: &str) -> Result<(), String> { + if username.is_empty() { + return Err("Username cannot be empty".to_string()); + } + if username.len() > 50 { + return Err("Username too long".to_string()); + } + + if !username + .chars() + .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.') + { + return Err("Username contains invalid characters".to_string()); + } + Ok(()) +} + +/// Validates a password submitted during login. +/// +/// Deliberately minimal: complexity rules belong to password creation, +/// not login. Enforcing them here would lock out existing users whose +/// stored passwords predate the policy and would leak the policy to +/// attackers via distinguishable 400 responses. +/// +/// # Validation Rules +/// - Not empty +/// - Length ≤ 128 characters (prevents DoS via expensive bcrypt hashing) +pub(super) fn validate_login_password(password: &str) -> Result<(), String> { + if password.is_empty() { + return Err("Password cannot be empty".to_string()); + } + if password.len() > 128 { + return Err("Password too long".to_string()); + } + Ok(()) +} diff --git a/backend/src/handlers/auth/tests.rs b/backend/src/handlers/auth/tests.rs new file mode 100644 index 00000000..e823377c --- /dev/null +++ b/backend/src/handlers/auth/tests.rs @@ -0,0 +1,135 @@ +use super::*; +use crate::db::migrations::run_migrations; +use sqlx::SqlitePool; + +async fn setup_test_db() -> DbPool { + let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); + run_migrations(&pool) + .await + .expect("Failed to run migrations"); + pool +} + +fn init_salts() { + if LOGIN_ATTEMPT_SALT.get().is_none() { + env::set_var( + "LOGIN_ATTEMPT_SALT", + "this_is_a_test_salt_for_login_attempts_at_least_32_chars", + ); + let _ = init_login_attempt_salt(); + } + if auth::JWT_SECRET.get().is_none() { + env::set_var( + "JWT_SECRET", + "this_is_a_test_jwt_secret_with_adequate_entropy_123_ABC_!!!", + ); + let _ = auth::init_jwt_secret(); + } + // CSRF_SECRET is private, we just call init and ignore "already initialized" error + env::set_var( + "CSRF_SECRET", + "this_is_a_very_long_secret_key_for_testing_purposes_only_at_least_32_bytes", + ); + let _ = csrf::init_csrf_secret(); +} + +#[tokio::test] +async fn test_login_invalid_credentials() { + init_salts(); + let pool = setup_test_db().await; + + let payload = LoginRequest { + username: "nonexistent".to_string(), + password: "InvalidPassword123!".to_string(), + }; + + let addr = "127.0.0.1:1234".parse().unwrap(); + + let result = login( + State(pool), + HeaderMap::new(), + ConnectInfo(addr), + Json(payload), + ) + .await; + + assert!(result.is_err()); + let (status, Json(body)) = result.unwrap_err(); + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!(body.error, "Invalid credentials"); +} + +/// Regression test for the password-spraying gap: rotating usernames +/// gives the attacker a fresh (IP+username) pair key on every attempt, +/// so only the IP-wide counter can stop them. After +/// IP_WIDE_SHORT_THRESHOLD failures from one address, the next attempt +/// must be rejected with 429 regardless of which username it targets. +#[tokio::test] +async fn test_ip_wide_lockout_blocks_username_rotation() { + init_salts(); + let pool = setup_test_db().await; + let addr: std::net::SocketAddr = "127.0.0.2:1234".parse().unwrap(); + + for i in 0..IP_WIDE_SHORT_THRESHOLD { + let result = login( + State(pool.clone()), + HeaderMap::new(), + ConnectInfo(addr), + Json(LoginRequest { + username: format!("sprayed_user_{}", i), + password: "WrongPassword123!".to_string(), + }), + ) + .await; + + let (status, _) = result.expect_err("login with bad password must fail"); + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "attempt {} should fail with 401, not yet be rate limited", + i + ); + } + + let result = login( + State(pool), + HeaderMap::new(), + ConnectInfo(addr), + Json(LoginRequest { + username: "yet_another_user".to_string(), + password: "WrongPassword123!".to_string(), + }), + ) + .await; + + let (status, _) = result.expect_err("attempt past the IP-wide threshold must fail"); + assert_eq!(status, StatusCode::TOO_MANY_REQUESTS); +} + +#[test] +fn test_validate_username() { + assert!(validate_username("admin").is_ok()); + assert!(validate_username("user.name").is_ok()); + assert!(validate_username("user_123").is_ok()); + assert!(validate_username("").is_err()); + assert!(validate_username("user!").is_err()); + assert!(validate_username("a".repeat(51).as_str()).is_err()); +} + +#[test] +fn test_validate_login_password() { + assert!(validate_login_password("ValidPassword123!").is_ok()); + // Login must not enforce complexity rules: existing users whose + // passwords predate the policy still need to authenticate. + assert!(validate_login_password("short").is_ok()); + assert!(validate_login_password("NoSpecialChar123").is_ok()); + let ordinary_login_input = [ + 'n', 'o', 'n', 'u', 'm', 'b', 'e', 'r', 's', 'p', 'e', 'c', '!', + ] + .into_iter() + .collect::(); + assert!(validate_login_password(&ordinary_login_input).is_ok()); + // Only emptiness and the bcrypt DoS length cap are rejected. + assert!(validate_login_password("").is_err()); + assert!(validate_login_password(&"a".repeat(129)).is_err()); +} diff --git a/backend/src/handlers/comments.rs b/backend/src/handlers/comments.rs index c3dd3cb2..9dcf1b8a 100644 --- a/backend/src/handlers/comments.rs +++ b/backend/src/handlers/comments.rs @@ -32,109 +32,9 @@ use axum::{ use serde::{Deserialize, Serialize}; use std::net::SocketAddr; -/// Request payload for creating a comment -#[derive(Deserialize)] -pub struct CreateCommentRequest { - /// The actual comment text - content: String, - /// The author's name (optional for guests) - author: Option, -} - -/// Query parameters for listing comments with pagination and sorting -#[derive(Deserialize)] -pub struct CommentListQuery { - /// Maximum number of comments to return (default: 50) - #[serde(default = "default_comment_limit")] - limit: i64, - - /// Number of comments to skip for pagination - #[serde(default)] - offset: i64, - - /// Sorting criteria (e.g., "created_at:desc") - #[serde(default)] - sort: Option, -} - -fn default_comment_limit() -> i64 { - 50 -} - -/// Local DTO for comment responses, mapping from the database model -#[derive(Serialize, sqlx::FromRow)] -pub struct Comment { - /// Unique identifier for the comment - pub id: String, - /// Optional parent tutorial ID - pub tutorial_id: Option, - /// Optional parent post ID - pub post_id: Option, - /// Display name of the author - pub author: String, - /// The comment content as plain text - pub content: String, - /// RFC3339 formatted creation timestamp - pub created_at: String, - /// Total number of votes/likes - pub votes: i64, - /// Whether the comment was posted by an administrator - pub is_admin: bool, - /// Real authenticated username of the commenter, used for server-side - /// ownership checks only. NEVER sent to clients: for admin comments, - /// `author` is deliberately the anonymized literal "Administrator" - /// string, and leaking this field would de-anonymize which real admin - /// account posted it. - #[serde(skip_serializing)] - pub author_username: Option, - /// Guest/authenticated marker, used for server-side ownership checks - /// only. Never sent to clients, for the same reason as `author_username`. - #[serde(skip_serializing)] - pub is_guest: Option, -} - -/// Converts the repository's `Comment` model into this handler's response -/// DTO. Kept as an explicit `From` impl (rather than returning the model -/// type directly) because the two types intentionally diverge on -/// serialization: this DTO marks `author_username`/`is_guest` as -/// `#[serde(skip_serializing)]` so they never reach the client, while the -/// model type serializes them (it's also used for internal deserialization). -impl From for Comment { - fn from(c: crate::models::Comment) -> Self { - Comment { - id: c.id, - tutorial_id: c.tutorial_id, - post_id: c.post_id, - author: c.author, - content: c.content, - created_at: c.created_at, - votes: c.votes, - is_admin: c.is_admin, - author_username: c.author_username, - is_guest: c.is_guest, - } - } -} - -/// Validates and sanitizes comment content -/// -/// Trims whitespace and checks length constraints. -fn sanitize_comment_content(raw: &str) -> Result { - let trimmed = raw.trim(); - - if trimmed.is_empty() { - return Err(bad_request("Comment content cannot be empty")); - } - - if trimmed.len() > 1_000 { - return Err(bad_request("Comment too long (max 1000 characters)")); - } - - // Content is stored as raw text; escaping happens at render time in the - // frontend (React). Escaping here as well would double-encode and hurt - // searchability and future flexibility (e.g. markdown support). - Ok(trimmed.to_string()) -} +mod comment_models; +use comment_models::sanitize_comment_content; +use comment_models::{CommentListQuery, CommentResponse, CreateCommentRequest}; /// Handler for listing comments on a tutorial /// @@ -143,7 +43,7 @@ pub async fn list_comments( State(pool): State, Path(tutorial_id): Path, Query(params): Query, -) -> Result>, ApiError> { +) -> Result>, ApiError> { validate_tutorial_id(&tutorial_id).map_err(bad_request)?; let exists = repositories::tutorials::check_tutorial_exists(&pool, &tutorial_id) @@ -167,7 +67,8 @@ pub async fn list_comments( .await .map_err(internal_error("Failed to fetch comments"))?; - let response_comments: Vec = comments.into_iter().map(Comment::from).collect(); + let response_comments: Vec = + comments.into_iter().map(CommentResponse::from).collect(); Ok(Json(response_comments)) } @@ -183,7 +84,7 @@ pub async fn create_comment( claims: auth::Claims, _csrf: crate::security::csrf::CsrfGuard, Json(payload): Json, -) -> Result, ApiError> { +) -> Result, ApiError> { validate_tutorial_id(&tutorial_id).map_err(bad_request)?; // Verify tutorial exists @@ -215,7 +116,7 @@ pub async fn list_post_comments( State(pool): State, Path(post_id): Path, Query(params): Query, -) -> Result>, ApiError> { +) -> Result>, ApiError> { // Verify post exists let exists = repositories::posts::check_post_exists(&pool, &post_id) .await @@ -238,7 +139,8 @@ pub async fn list_post_comments( .await .map_err(internal_error("Failed to fetch comments"))?; - let response_comments: Vec = comments.into_iter().map(Comment::from).collect(); + let response_comments: Vec = + comments.into_iter().map(CommentResponse::from).collect(); Ok(Json(response_comments)) } @@ -254,7 +156,7 @@ pub async fn create_post_comment( auth::OptionalClaims(claims): auth::OptionalClaims, _csrf: crate::security::csrf::CsrfGuard, Json(payload): Json, -) -> Result, ApiError> { +) -> Result, ApiError> { // Verify post exists let exists = repositories::posts::check_post_exists(&pool, &post_id) .await @@ -287,7 +189,7 @@ async fn create_comment_internal( payload: CreateCommentRequest, claims: Option, ip_address: String, -) -> Result, ApiError> { +) -> Result, ApiError> { let comment_content = sanitize_comment_content(&payload.content)?; let (author, rate_limit_key, author_username, is_guest) = if let Some(ref c) = claims { @@ -390,7 +292,7 @@ async fn create_comment_internal( .await .map_err(internal_error("Failed to create comment"))?; - Ok(Json(Comment::from(comment))) + Ok(Json(CommentResponse::from(comment))) } /// Handler for deleting a comment @@ -471,7 +373,7 @@ pub async fn vote_comment( claims: auth::Claims, Path(id): Path, _csrf: crate::security::csrf::CsrfGuard, -) -> Result, ApiError> { +) -> Result, ApiError> { // Check if comment exists let exists = repositories::comments::check_comment_exists(&pool, &id) .await @@ -517,273 +419,8 @@ pub async fn vote_comment( .map_err(internal_error("Failed to fetch updated comment"))? .ok_or_else(|| internal_error_plain("Comment disappeared after voting"))?; - Ok(Json(Comment::from(comment))) + Ok(Json(CommentResponse::from(comment))) } #[cfg(test)] -mod tests { - use super::*; - use sqlx::SqlitePool; - - async fn setup_comments_pool() -> SqlitePool { - let pool = SqlitePool::connect("sqlite::memory:") - .await - .expect("create in-memory sqlite pool"); - - sqlx::query( - r#" - CREATE TABLE comments ( - id TEXT PRIMARY KEY, - tutorial_id TEXT, - post_id TEXT, - author TEXT NOT NULL, - rate_limit_key TEXT NOT NULL DEFAULT '', - content TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - votes INTEGER NOT NULL DEFAULT 0, - is_admin BOOLEAN NOT NULL DEFAULT FALSE, - author_username TEXT DEFAULT NULL, - is_guest BOOLEAN DEFAULT NULL - ) - "#, - ) - .execute(&pool) - .await - .expect("create comments table"); - - pool - } - - /// Inserts a comment row directly with full control over every column, - /// for exercising `delete_comment`'s ownership logic against specific - /// (author, author_username, is_guest, is_admin) combinations. - #[allow(clippy::too_many_arguments)] - async fn insert_comment_row( - pool: &SqlitePool, - id: &str, - author: &str, - author_username: Option<&str>, - is_guest: Option, - is_admin: bool, - ) { - sqlx::query( - "INSERT INTO comments (id, tutorial_id, post_id, author, content, created_at, votes, is_admin, author_username, is_guest) \ - VALUES (?, 'tutorial-1', NULL, ?, 'content', datetime('now'), 0, ?, ?, ?)", - ) - .bind(id) - .bind(author) - .bind(is_admin) - .bind(author_username) - .bind(is_guest) - .execute(pool) - .await - .expect("insert comment row"); - } - - #[tokio::test] - async fn admin_tutorial_comment_uses_claims_without_author_payload() { - let pool = setup_comments_pool().await; - let claims = auth::Claims { - sub: "admin".to_string(), - role: "admin".to_string(), - exp: usize::MAX, - }; - - let result = create_comment_internal( - pool, - Some("tutorial-1".to_string()), - None, - CreateCommentRequest { - content: "Admin note".to_string(), - author: None, - }, - Some(claims), - "127.0.0.1".to_string(), - ) - .await; - let Json(comment) = match result { - Ok(comment) => comment, - Err((status, _)) => panic!("admin comment failed with status {status}"), - }; - - assert_eq!(comment.author, "Administrator"); - assert!(comment.is_admin); - // Admins get their real identity recorded too (harmless -- admin - // deletion is governed by the is_admin/role check, not this field). - assert_eq!(comment.author_username, Some("admin".to_string())); - assert_eq!(comment.is_guest, Some(false)); - } - - #[tokio::test] - async fn guest_rate_limit_uses_ip_even_when_author_changes() { - let pool = setup_comments_pool().await; - - let first_result = create_comment_internal( - pool.clone(), - None, - Some("post-1".to_string()), - CreateCommentRequest { - content: "First comment".to_string(), - author: Some("Alice".to_string()), - }, - None, - "203.0.113.5".to_string(), - ) - .await; - let Json(first_comment) = match first_result { - Ok(comment) => comment, - Err((status, _)) => panic!("first guest comment failed with status {status}"), - }; - assert_eq!(first_comment.author_username, None); - assert_eq!(first_comment.is_guest, Some(true)); - - let result = create_comment_internal( - pool, - None, - Some("post-1".to_string()), - CreateCommentRequest { - content: "Second comment".to_string(), - author: Some("Bob".to_string()), - }, - None, - "203.0.113.5".to_string(), - ) - .await; - let err = match result { - Ok(_) => panic!("same client IP should still be rate limited"), - Err(err) => err, - }; - - assert_eq!(err.0, StatusCode::TOO_MANY_REQUESTS); - } - - fn claims_for(sub: &str, role: &str) -> auth::Claims { - auth::Claims { - sub: sub.to_string(), - role: role.to_string(), - exp: usize::MAX, - } - } - - async fn call_delete_comment( - pool: SqlitePool, - id: &str, - claims: auth::Claims, - ) -> Result)> { - delete_comment( - claims, - State(pool), - Path(id.to_string()), - crate::security::csrf::CsrfGuard, - ) - .await - } - - #[tokio::test] - async fn authenticated_user_can_delete_own_post_migration_comment() { - let pool = setup_comments_pool().await; - insert_comment_row(&pool, "c1", "bob", Some("bob"), Some(false), false).await; - - let result = call_delete_comment(pool, "c1", claims_for("bob", "user")).await; - - assert_eq!(result.unwrap(), StatusCode::NO_CONTENT); - } - - /// Critical regression test: a guest can type any display name, - /// including a real, registered user's username. Before the - /// author_username/is_guest fix, `comment.author == claims.sub` would - /// let that real user delete the guest's comment. This must now be - /// rejected because the guest comment has no real identity attached. - #[tokio::test] - async fn authenticated_user_cannot_delete_guest_comment_with_spoofed_name() { - let pool = setup_comments_pool().await; - insert_comment_row(&pool, "c2", "bob", None, Some(true), false).await; - - let result = call_delete_comment(pool, "c2", claims_for("bob", "user")).await; - - let (status, _) = result.unwrap_err(); - assert_eq!(status, StatusCode::FORBIDDEN); - } - - #[tokio::test] - async fn admin_can_delete_any_comment_regardless_of_authorship() { - let pool = setup_comments_pool().await; - insert_comment_row( - &pool, - "c3", - "Administrator", - Some("realadminuser"), - Some(false), - true, - ) - .await; - - let result = call_delete_comment(pool, "c3", claims_for("different-admin", "admin")).await; - - assert_eq!(result.unwrap(), StatusCode::NO_CONTENT); - } - - #[tokio::test] - async fn non_admin_cannot_delete_admin_authored_comment_even_with_same_username() { - let pool = setup_comments_pool().await; - insert_comment_row( - &pool, - "c7", - "Administrator", - Some("alice"), - Some(false), - true, - ) - .await; - - let result = call_delete_comment(pool, "c7", claims_for("alice", "user")).await; - - let (status, _) = result.unwrap_err(); - assert_eq!(status, StatusCode::FORBIDDEN); - } - - #[tokio::test] - async fn legacy_row_real_owner_can_still_self_delete() { - let pool = setup_comments_pool().await; - // Simulates a genuinely pre-migration row: both new columns are NULL. - insert_comment_row(&pool, "c4", "carol", None, None, false).await; - - let result = call_delete_comment(pool, "c4", claims_for("carol", "user")).await; - - assert_eq!(result.unwrap(), StatusCode::NO_CONTENT); - } - - /// Documents the accepted, time-bounded residual risk: a pre-migration - /// row (author_username and is_guest both NULL) that was actually - /// posted by a guest who happened to type "carol" as their display name - /// can still be deleted by the real user "carol" via the legacy - /// fallback. This is intentional -- the alternative (blocking legacy - /// self-service deletion entirely) was rejected as a worse regression -- - /// and applies only to rows that existed before this fix shipped. - #[tokio::test] - async fn legacy_row_ambiguous_origin_is_a_known_accepted_gap() { - let pool = setup_comments_pool().await; - insert_comment_row(&pool, "c5", "carol", None, None, false).await; - - let result = call_delete_comment(pool, "c5", claims_for("carol", "user")).await; - - assert_eq!(result.unwrap(), StatusCode::NO_CONTENT); - } - - /// Guards the fail-closed arm added for the (author_username=None, - /// is_guest=Some(false)) state: no current insert path produces it (an - /// authenticated comment always sets author_username), but nothing in - /// the schema forbids it either. If a future bug or manual data edit - /// ever produces this shape, ownership must be rejected rather than - /// silently falling back to the spoofable `author == claims.sub` match. - #[tokio::test] - async fn inconsistent_row_with_no_username_but_marked_authenticated_is_rejected() { - let pool = setup_comments_pool().await; - insert_comment_row(&pool, "c6", "carol", None, Some(false), false).await; - - let result = call_delete_comment(pool, "c6", claims_for("carol", "user")).await; - - let (status, _) = result.unwrap_err(); - assert_eq!(status, StatusCode::FORBIDDEN); - } -} +mod tests; diff --git a/backend/src/handlers/comments/comment_models.rs b/backend/src/handlers/comments/comment_models.rs new file mode 100644 index 00000000..ad7ef1e6 --- /dev/null +++ b/backend/src/handlers/comments/comment_models.rs @@ -0,0 +1,105 @@ +use super::*; + +/// Request payload for creating a comment +#[derive(Deserialize)] +pub struct CreateCommentRequest { + /// The actual comment text + pub(super) content: String, + /// The author's name (optional for guests) + pub(super) author: Option, +} + +/// Query parameters for listing comments with pagination and sorting +#[derive(Deserialize)] +pub struct CommentListQuery { + /// Maximum number of comments to return (default: 50) + #[serde(default = "default_comment_limit")] + pub(super) limit: i64, + + /// Number of comments to skip for pagination + #[serde(default)] + pub(super) offset: i64, + + /// Sorting criteria (e.g., "created_at:desc") + #[serde(default)] + pub(super) sort: Option, +} + +pub(super) fn default_comment_limit() -> i64 { + 50 +} + +/// Local DTO for comment responses, mapping from the database model +#[derive(Serialize, sqlx::FromRow)] +pub struct CommentResponse { + /// Unique identifier for the comment + pub id: String, + /// Optional parent tutorial ID + pub tutorial_id: Option, + /// Optional parent post ID + pub post_id: Option, + /// Display name of the author + pub author: String, + /// The comment content as plain text + pub content: String, + /// RFC3339 formatted creation timestamp + pub created_at: String, + /// Total number of votes/likes + pub votes: i64, + /// Whether the comment was posted by an administrator + pub is_admin: bool, + /// Real authenticated username of the commenter, used for server-side + /// ownership checks only. NEVER sent to clients: for admin comments, + /// `author` is deliberately the anonymized literal "Administrator" + /// string, and leaking this field would de-anonymize which real admin + /// account posted it. + #[serde(skip_serializing)] + pub author_username: Option, + /// Guest/authenticated marker, used for server-side ownership checks + /// only. Never sent to clients, for the same reason as `author_username`. + #[serde(skip_serializing)] + pub is_guest: Option, +} + +/// Converts the repository's `Comment` model into this handler's response +/// DTO. Kept as an explicit `From` impl (rather than returning the model +/// type directly) because the two types intentionally diverge on +/// serialization: this DTO marks `author_username`/`is_guest` as +/// `#[serde(skip_serializing)]` so they never reach the client, while the +/// model type serializes them (it's also used for internal deserialization). +impl From for CommentResponse { + fn from(c: crate::models::Comment) -> Self { + CommentResponse { + id: c.id, + tutorial_id: c.tutorial_id, + post_id: c.post_id, + author: c.author, + content: c.content, + created_at: c.created_at, + votes: c.votes, + is_admin: c.is_admin, + author_username: c.author_username, + is_guest: c.is_guest, + } + } +} + +/// Validates and sanitizes comment content +/// +/// Trims whitespace and checks length constraints. +pub(super) fn sanitize_comment_content(raw: &str) -> Result { + let trimmed = raw.trim(); + + if trimmed.is_empty() { + return Err(bad_request("Comment content cannot be empty")); + } + + if trimmed.len() > 1_000 { + return Err(bad_request("Comment too long (max 1000 characters)")); + } + + // Content is stored as raw text; escaping happens at render time in the + // frontend (React). Escaping here as well would double-encode and hurt + // searchability and future flexibility (e.g. markdown support). + Ok(trimmed.to_string()) +} diff --git a/backend/src/handlers/comments/tests.rs b/backend/src/handlers/comments/tests.rs new file mode 100644 index 00000000..3c9387aa --- /dev/null +++ b/backend/src/handlers/comments/tests.rs @@ -0,0 +1,266 @@ +use super::*; +use sqlx::SqlitePool; + +async fn setup_comments_pool() -> SqlitePool { + let pool = SqlitePool::connect("sqlite::memory:") + .await + .expect("create in-memory sqlite pool"); + + sqlx::query( + r#" + CREATE TABLE comments ( + id TEXT PRIMARY KEY, + tutorial_id TEXT, + post_id TEXT, + author TEXT NOT NULL, + rate_limit_key TEXT NOT NULL DEFAULT '', + content TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + votes INTEGER NOT NULL DEFAULT 0, + is_admin BOOLEAN NOT NULL DEFAULT FALSE, + author_username TEXT DEFAULT NULL, + is_guest BOOLEAN DEFAULT NULL + ) + "#, + ) + .execute(&pool) + .await + .expect("create comments table"); + + pool +} + +/// Inserts a comment row directly with full control over every column, +/// for exercising `delete_comment`'s ownership logic against specific +/// (author, author_username, is_guest, is_admin) combinations. +#[allow(clippy::too_many_arguments)] +async fn insert_comment_row( + pool: &SqlitePool, + id: &str, + author: &str, + author_username: Option<&str>, + is_guest: Option, + is_admin: bool, +) { + sqlx::query(concat!( + "INSERT INTO comments ", + "(id, tutorial_id, post_id, author, content, created_at, votes, is_admin, ", + "author_username, is_guest) ", + "VALUES (?, 'tutorial-1', NULL, ?, 'content', datetime('now'), 0, ?, ?, ?)" + )) + .bind(id) + .bind(author) + .bind(is_admin) + .bind(author_username) + .bind(is_guest) + .execute(pool) + .await + .expect("insert comment row"); +} + +#[tokio::test] +async fn admin_tutorial_comment_uses_claims_without_author_payload() { + let pool = setup_comments_pool().await; + let claims = auth::Claims { + sub: "admin".to_string(), + role: "admin".to_string(), + exp: usize::MAX, + }; + + let result = create_comment_internal( + pool, + Some("tutorial-1".to_string()), + None, + CreateCommentRequest { + content: "Admin note".to_string(), + author: None, + }, + Some(claims), + "127.0.0.1".to_string(), + ) + .await; + let Json(comment) = match result { + Ok(comment) => comment, + Err((status, _)) => panic!("admin comment failed with status {status}"), + }; + + assert_eq!(comment.author, "Administrator"); + assert!(comment.is_admin); + // Admins get their real identity recorded too (harmless -- admin + // deletion is governed by the is_admin/role check, not this field). + assert_eq!(comment.author_username, Some("admin".to_string())); + assert_eq!(comment.is_guest, Some(false)); +} + +#[tokio::test] +async fn guest_rate_limit_uses_ip_even_when_author_changes() { + let pool = setup_comments_pool().await; + + let first_result = create_comment_internal( + pool.clone(), + None, + Some("post-1".to_string()), + CreateCommentRequest { + content: "First comment".to_string(), + author: Some("Alice".to_string()), + }, + None, + "203.0.113.5".to_string(), + ) + .await; + let Json(first_comment) = match first_result { + Ok(comment) => comment, + Err((status, _)) => panic!("first guest comment failed with status {status}"), + }; + assert_eq!(first_comment.author_username, None); + assert_eq!(first_comment.is_guest, Some(true)); + + let result = create_comment_internal( + pool, + None, + Some("post-1".to_string()), + CreateCommentRequest { + content: "Second comment".to_string(), + author: Some("Bob".to_string()), + }, + None, + "203.0.113.5".to_string(), + ) + .await; + let err = match result { + Ok(_) => panic!("same client IP should still be rate limited"), + Err(err) => err, + }; + + assert_eq!(err.0, StatusCode::TOO_MANY_REQUESTS); +} + +fn claims_for(sub: &str, role: &str) -> auth::Claims { + auth::Claims { + sub: sub.to_string(), + role: role.to_string(), + exp: usize::MAX, + } +} + +async fn call_delete_comment( + pool: SqlitePool, + id: &str, + claims: auth::Claims, +) -> Result)> { + delete_comment( + claims, + State(pool), + Path(id.to_string()), + crate::security::csrf::CsrfGuard, + ) + .await +} + +#[tokio::test] +async fn authenticated_user_can_delete_own_post_migration_comment() { + let pool = setup_comments_pool().await; + insert_comment_row(&pool, "c1", "bob", Some("bob"), Some(false), false).await; + + let result = call_delete_comment(pool, "c1", claims_for("bob", "user")).await; + + assert_eq!(result.unwrap(), StatusCode::NO_CONTENT); +} + +/// Critical regression test: a guest can type any display name, +/// including a real, registered user's username. Before the +/// author_username/is_guest fix, `comment.author == claims.sub` would +/// let that real user delete the guest's comment. This must now be +/// rejected because the guest comment has no real identity attached. +#[tokio::test] +async fn authenticated_user_cannot_delete_guest_comment_with_spoofed_name() { + let pool = setup_comments_pool().await; + insert_comment_row(&pool, "c2", "bob", None, Some(true), false).await; + + let result = call_delete_comment(pool, "c2", claims_for("bob", "user")).await; + + let (status, _) = result.unwrap_err(); + assert_eq!(status, StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn admin_can_delete_any_comment_regardless_of_authorship() { + let pool = setup_comments_pool().await; + insert_comment_row( + &pool, + "c3", + "Administrator", + Some("realadminuser"), + Some(false), + true, + ) + .await; + + let result = call_delete_comment(pool, "c3", claims_for("different-admin", "admin")).await; + + assert_eq!(result.unwrap(), StatusCode::NO_CONTENT); +} + +#[tokio::test] +async fn non_admin_cannot_delete_admin_authored_comment_even_with_same_username() { + let pool = setup_comments_pool().await; + insert_comment_row( + &pool, + "c7", + "Administrator", + Some("alice"), + Some(false), + true, + ) + .await; + + let result = call_delete_comment(pool, "c7", claims_for("alice", "user")).await; + + let (status, _) = result.unwrap_err(); + assert_eq!(status, StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn legacy_row_real_owner_can_still_self_delete() { + let pool = setup_comments_pool().await; + // Simulates a genuinely pre-migration row: both new columns are NULL. + insert_comment_row(&pool, "c4", "carol", None, None, false).await; + + let result = call_delete_comment(pool, "c4", claims_for("carol", "user")).await; + + assert_eq!(result.unwrap(), StatusCode::NO_CONTENT); +} + +/// Documents the accepted, time-bounded residual risk: a pre-migration +/// row (author_username and is_guest both NULL) that was actually +/// posted by a guest who happened to type "carol" as their display name +/// can still be deleted by the real user "carol" via the legacy +/// fallback. This is intentional -- the alternative (blocking legacy +/// self-service deletion entirely) was rejected as a worse regression -- +/// and applies only to rows that existed before this fix shipped. +#[tokio::test] +async fn legacy_row_ambiguous_origin_is_a_known_accepted_gap() { + let pool = setup_comments_pool().await; + insert_comment_row(&pool, "c5", "carol", None, None, false).await; + + let result = call_delete_comment(pool, "c5", claims_for("carol", "user")).await; + + assert_eq!(result.unwrap(), StatusCode::NO_CONTENT); +} + +/// Guards the fail-closed arm added for the (author_username=None, +/// is_guest=Some(false)) state: no current insert path produces it (an +/// authenticated comment always sets author_username), but nothing in +/// the schema forbids it either. If a future bug or manual data edit +/// ever produces this shape, ownership must be rejected rather than +/// silently falling back to the spoofable `author == claims.sub` match. +#[tokio::test] +async fn inconsistent_row_with_no_username_but_marked_authenticated_is_rejected() { + let pool = setup_comments_pool().await; + insert_comment_row(&pool, "c6", "carol", None, Some(false), false).await; + + let result = call_delete_comment(pool, "c6", claims_for("carol", "user")).await; + + let (status, _) = result.unwrap_err(); + assert_eq!(status, StatusCode::FORBIDDEN); +} diff --git a/backend/src/handlers/frontend_proxy.rs b/backend/src/handlers/frontend_proxy.rs index 6b7a05fd..ae607216 100644 --- a/backend/src/handlers/frontend_proxy.rs +++ b/backend/src/handlers/frontend_proxy.rs @@ -136,16 +136,28 @@ pub async fn serve_index(State(pool): State) -> impl IntoResponse { }; // Extract title from JSON, providing a sensible fallback - let title = site_meta + let stored_title = site_meta .get("title") .and_then(|v| v.as_str()) - .unwrap_or("Linux Tutorial - Lerne Linux Schritt für Schritt"); + .unwrap_or("Zero Point – Persönlicher Blog"); + let is_starter_content = + stored_title.starts_with("Linux Tutorial") || stored_title.starts_with("IT Wissensportal"); + let title = if is_starter_content { + "Zero Point – Persönlicher Blog" + } else { + stored_title + }; // Extract description from JSON, providing a sensible fallback - let description = site_meta + let stored_description = site_meta .get("description") .and_then(|v| v.as_str()) - .unwrap_or("Lerne Linux von Grund auf - Interaktiv, modern und praxisnah."); + .unwrap_or("Persönliche Notizen über Technik, Projekte, Ideen und alles dazwischen."); + let description = if is_starter_content { + "Persönliche Notizen über Technik, Projekte, Ideen und alles dazwischen." + } else { + stored_description + }; // Injection Phase: // We use simple string replacement to swap hardcoded defaults in the build diff --git a/backend/src/handlers/mod.rs b/backend/src/handlers/mod.rs index ba72641d..67b347aa 100644 --- a/backend/src/handlers/mod.rs +++ b/backend/src/handlers/mod.rs @@ -147,6 +147,7 @@ pub mod search; // Full-text search functionality // Content Management Handlers pub mod comments; // Comment system management +pub mod newsletter; // Public newsletter subscriptions pub mod tutorials; // Tutorial CRUD operations pub mod upload; // Image upload diff --git a/backend/src/handlers/newsletter.rs b/backend/src/handlers/newsletter.rs new file mode 100644 index 00000000..3b1ed0bd --- /dev/null +++ b/backend/src/handlers/newsletter.rs @@ -0,0 +1,100 @@ +use crate::{ + db::DbPool, + models::{bad_request, internal_error, ApiError}, + repositories, +}; +use axum::{extract::State, Json}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize)] +pub struct NewsletterSubscriptionRequest { + email: String, +} + +#[derive(Debug, Serialize)] +pub struct NewsletterSubscriptionResponse { + subscribed: bool, +} + +/// Registers an email address. Repeated requests are intentionally idempotent. +pub async fn subscribe_to_newsletter( + State(pool): State, + Json(payload): Json, +) -> Result, ApiError> { + let email = validate_and_normalize_email(&payload.email).map_err(bad_request)?; + + repositories::newsletter::subscribe(&pool, &email) + .await + .map_err(internal_error("Newsletter-Anmeldung fehlgeschlagen"))?; + + Ok(Json(NewsletterSubscriptionResponse { subscribed: true })) +} + +fn validate_and_normalize_email(value: &str) -> Result { + const INVALID_EMAIL: &str = "Ungültige E-Mail-Adresse"; + let email = value.trim(); + if email.is_empty() || email.len() > 254 || !email.is_ascii() { + return Err(INVALID_EMAIL); + } + + let Some((local, domain)) = email.split_once('@') else { + return Err(INVALID_EMAIL); + }; + if local.is_empty() + || local.len() > 64 + || local.starts_with('.') + || local.ends_with('.') + || local.contains("..") + || domain.is_empty() + || domain.contains('@') + || !domain.contains('.') + { + return Err(INVALID_EMAIL); + } + + let valid_local = local + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b".!#$%&'*+-/=?^_`{|}~".contains(&byte)); + let valid_domain = domain.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && !label.starts_with('-') + && !label.ends_with('-') + && label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + }); + + if !valid_local || !valid_domain { + return Err(INVALID_EMAIL); + } + + Ok(email.to_ascii_lowercase()) +} + +#[cfg(test)] +mod tests { + use super::validate_and_normalize_email; + + #[test] + fn normalizes_valid_email_addresses() { + assert_eq!( + validate_and_normalize_email(" Reader+Blog@Example.COM "), + Ok("reader+blog@example.com".to_string()) + ); + } + + #[test] + fn rejects_malformed_email_addresses() { + for email in [ + "", + "missing-at.example.com", + "two@@example.com", + ".reader@example.com", + "reader@example", + "reader@-example.com", + ] { + assert!(validate_and_normalize_email(email).is_err(), "{email}"); + } + } +} diff --git a/backend/src/handlers/site_content.rs b/backend/src/handlers/site_content.rs index e787ef53..3708db93 100644 --- a/backend/src/handlers/site_content.rs +++ b/backend/src/handlers/site_content.rs @@ -40,6 +40,7 @@ fn allowed_sections() -> &'static HashSet<&'static str> { "site_meta", // SEO titles/description "stats", // Numbers/stats display "cta_section", // Call to action + "about", // Personal homepage introduction "settings", // System-wide toggles "login", // Custom login page text ] @@ -132,7 +133,7 @@ fn validate_header_structure(content: &Value) -> Result<(), &'static str> { .and_then(|v| v.as_array()) .ok_or("Field 'navItems' must be an array")?; - // Validate that each item in the array has at least an 'id' and 'label', and a valid target ('path', 'slug', 'url', etc.) + // Each item needs an id, a label, and a valid target such as path, slug, or URL. for item in nav_items { let item_obj = item.as_object().ok_or("Nav item must be an object")?; diff --git a/backend/src/handlers/site_pages/helpers.rs b/backend/src/handlers/site_pages/helpers.rs new file mode 100644 index 00000000..27e5a9c0 --- /dev/null +++ b/backend/src/handlers/site_pages/helpers.rs @@ -0,0 +1,229 @@ +use super::*; + +/// Maximum length for a page title (200 characters) +pub(super) const MAX_TITLE_LEN: usize = 200; +/// Maximum length for a page SEO description (1000 characters) +pub(super) const MAX_DESCRIPTION_LEN: usize = 1000; +/// Maximum length for a navigation label (100 characters) +pub(super) const MAX_NAV_LABEL_LEN: usize = 100; +/// Maximum allowed size for hero/layout JSON payloads (200KB) +pub(super) const MAX_JSON_BYTES: usize = 200_000; + +/// Validates that a JSON value, when serialized, doesn't exceed the byte limit. +pub(super) fn validate_json_size(value: &Value, field: &str) -> Result<(), ApiError> { + match serde_json::to_string(value) { + // Within bounds + Ok(serialized) if serialized.len() <= MAX_JSON_BYTES => Ok(()), + // Over limit + Ok(_) => Err(bad_request(format!( + "{field} JSON exceeds maximum size of {MAX_JSON_BYTES} bytes" + ))), + // Invalid JSON content + Err(err) => Err(bad_request(format!("Invalid {field} JSON: {err}"))), + } +} + +/// Normalizes and validates a payload for creating a new site page. +pub(super) fn sanitize_create_payload( + mut payload: CreateSitePageRequest, +) -> Result { + // Slug normalization: trim and lowercase + payload.slug = payload.slug.trim().to_lowercase(); + if payload.slug.is_empty() { + return Err(bad_request("Slug cannot be empty")); + } + + // Title normalization and length check + payload.title = payload.title.trim().to_string(); + if payload.title.is_empty() { + return Err(bad_request("Title cannot be empty")); + } + if payload.title.len() > MAX_TITLE_LEN { + return Err(bad_request(format!( + "Title too long (max {MAX_TITLE_LEN} characters)" + ))); + } + + // Description length check + payload.description = payload.description.map(|desc| desc.trim().to_string()); + if let Some(desc) = payload.description.as_ref() { + if desc.len() > MAX_DESCRIPTION_LEN { + return Err(bad_request(format!( + "Description too long (max {MAX_DESCRIPTION_LEN} characters)" + ))); + } + } + + // Navigation label normalization + payload.nav_label = payload.nav_label.and_then(|label| { + let trimmed = label.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }); + if let Some(label) = payload.nav_label.as_ref() { + if label.len() > MAX_NAV_LABEL_LEN { + return Err(bad_request(format!( + "Navigation label too long (max {MAX_NAV_LABEL_LEN} characters)" + ))); + } + } + + // Large JSON field size validation + validate_json_size(&payload.hero, "hero")?; + validate_json_size(&payload.layout, "layout")?; + + Ok(payload) +} + +/// Normalizes and validates a payload for updating an existing site page. +pub(super) fn sanitize_update_payload( + mut payload: UpdateSitePageRequest, +) -> Result { + // Partial slug update + if let Some(ref mut slug) = payload.slug { + *slug = slug.trim().to_lowercase(); + if slug.is_empty() { + return Err(bad_request("Slug cannot be empty")); + } + } + + // Partial title update + if let Some(ref mut title) = payload.title { + *title = title.trim().to_string(); + if title.is_empty() { + return Err(bad_request("Title cannot be empty")); + } + if title.len() > MAX_TITLE_LEN { + return Err(bad_request(format!( + "Title too long (max {MAX_TITLE_LEN} characters)" + ))); + } + } + + // Partial description update + if let Some(ref mut description) = payload.description { + *description = description.trim().to_string(); + if description.len() > MAX_DESCRIPTION_LEN { + return Err(bad_request(format!( + "Description too long (max {MAX_DESCRIPTION_LEN} characters)" + ))); + } + } + + // Partial navigation label update + if let Some(mut nav_label_option) = payload.nav_label.take() { + nav_label_option = match nav_label_option { + Some(label) => { + let trimmed = label.trim().to_string(); + if trimmed.is_empty() { + None + } else { + if trimmed.len() > MAX_NAV_LABEL_LEN { + return Err(bad_request(format!( + "Navigation label too long (max {MAX_NAV_LABEL_LEN} characters)" + ))); + } + Some(trimmed) + } + } + None => None, + }; + + payload.nav_label = Some(nav_label_option); + } + + // Partial JSON field update + if let Some(ref hero) = payload.hero { + validate_json_size(hero, "hero")?; + } + if let Some(ref layout) = payload.layout { + validate_json_size(layout, "layout")?; + } + + Ok(payload) +} + +/// Maps a database SitePage record to a rich response model, including JSON parsing. +pub(super) fn map_page(page: crate::models::SitePage) -> Result { + let crate::models::SitePage { + id, + slug, + title, + description, + nav_label, + show_in_nav, + order_index, + is_published, + hero_json, + layout_json, + created_at, + updated_at, + } = page; + + // Parse hero JSON string from database into a serde_json::Value + let hero = serde_json::from_str::(&hero_json) + .map_err(internal_error("Failed to parse stored hero JSON"))?; + + // Parse layout JSON string from database into a serde_json::Value + let layout = serde_json::from_str::(&layout_json) + .map_err(internal_error("Failed to parse stored layout JSON"))?; + + // Normalize slug for output + let sanitized_slug = slug.trim().to_lowercase(); + + // Default title to slug if the title field is empty + let sanitized_title = match title.trim() { + "" => sanitized_slug.clone(), + value => value.to_string(), + }; + + // Trim description + let sanitized_description = description.trim().to_string(); + + // Clean up navigation label + let sanitized_nav_label = nav_label.and_then(|label| { + let trimmed = label.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }); + + // Assemble response + Ok(SitePageResponse { + id, + slug: sanitized_slug, + title: sanitized_title, + description: sanitized_description, + nav_label: sanitized_nav_label, + show_in_nav, + order_index, + is_published, + hero, + layout, + created_at, + updated_at, + }) +} + +/// Maps a database SitePost record to a public response model. +pub(super) fn map_post(post: crate::models::SitePost) -> SitePostResponse { + SitePostResponse { + id: post.id, + page_id: post.page_id, + title: post.title, + slug: post.slug, + excerpt: post.excerpt, + content_markdown: post.content_markdown, + is_published: post.is_published, + published_at: post.published_at, + order_index: post.order_index, + created_at: post.created_at, + updated_at: post.updated_at, + allow_comments: post.allow_comments, + } +} diff --git a/backend/src/handlers/site_pages/mod.rs b/backend/src/handlers/site_pages/mod.rs index cc1a621b..df4c99bc 100644 --- a/backend/src/handlers/site_pages/mod.rs +++ b/backend/src/handlers/site_pages/mod.rs @@ -22,233 +22,8 @@ use axum::{ }; use serde_json::Value; -/// Maximum length for a page title (200 characters) -const MAX_TITLE_LEN: usize = 200; -/// Maximum length for a page SEO description (1000 characters) -const MAX_DESCRIPTION_LEN: usize = 1000; -/// Maximum length for a navigation label (100 characters) -const MAX_NAV_LABEL_LEN: usize = 100; -/// Maximum allowed size for hero/layout JSON payloads (200KB) -const MAX_JSON_BYTES: usize = 200_000; - -/// Validates that a JSON value, when serialized, doesn't exceed the byte limit. -fn validate_json_size(value: &Value, field: &str) -> Result<(), ApiError> { - match serde_json::to_string(value) { - // Within bounds - Ok(serialized) if serialized.len() <= MAX_JSON_BYTES => Ok(()), - // Over limit - Ok(_) => Err(bad_request(format!( - "{field} JSON exceeds maximum size of {MAX_JSON_BYTES} bytes" - ))), - // Invalid JSON content - Err(err) => Err(bad_request(format!("Invalid {field} JSON: {err}"))), - } -} - -/// Normalizes and validates a payload for creating a new site page. -fn sanitize_create_payload( - mut payload: CreateSitePageRequest, -) -> Result { - // Slug normalization: trim and lowercase - payload.slug = payload.slug.trim().to_lowercase(); - if payload.slug.is_empty() { - return Err(bad_request("Slug cannot be empty")); - } - - // Title normalization and length check - payload.title = payload.title.trim().to_string(); - if payload.title.is_empty() { - return Err(bad_request("Title cannot be empty")); - } - if payload.title.len() > MAX_TITLE_LEN { - return Err(bad_request(format!( - "Title too long (max {MAX_TITLE_LEN} characters)" - ))); - } - - // Description length check - payload.description = payload.description.map(|desc| desc.trim().to_string()); - if let Some(desc) = payload.description.as_ref() { - if desc.len() > MAX_DESCRIPTION_LEN { - return Err(bad_request(format!( - "Description too long (max {MAX_DESCRIPTION_LEN} characters)" - ))); - } - } - - // Navigation label normalization - payload.nav_label = payload.nav_label.and_then(|label| { - let trimmed = label.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - }); - if let Some(label) = payload.nav_label.as_ref() { - if label.len() > MAX_NAV_LABEL_LEN { - return Err(bad_request(format!( - "Navigation label too long (max {MAX_NAV_LABEL_LEN} characters)" - ))); - } - } - - // Large JSON field size validation - validate_json_size(&payload.hero, "hero")?; - validate_json_size(&payload.layout, "layout")?; - - Ok(payload) -} - -/// Normalizes and validates a payload for updating an existing site page. -fn sanitize_update_payload( - mut payload: UpdateSitePageRequest, -) -> Result { - // Partial slug update - if let Some(ref mut slug) = payload.slug { - *slug = slug.trim().to_lowercase(); - if slug.is_empty() { - return Err(bad_request("Slug cannot be empty")); - } - } - - // Partial title update - if let Some(ref mut title) = payload.title { - *title = title.trim().to_string(); - if title.is_empty() { - return Err(bad_request("Title cannot be empty")); - } - if title.len() > MAX_TITLE_LEN { - return Err(bad_request(format!( - "Title too long (max {MAX_TITLE_LEN} characters)" - ))); - } - } - - // Partial description update - if let Some(ref mut description) = payload.description { - *description = description.trim().to_string(); - if description.len() > MAX_DESCRIPTION_LEN { - return Err(bad_request(format!( - "Description too long (max {MAX_DESCRIPTION_LEN} characters)" - ))); - } - } - - // Partial navigation label update - if let Some(mut nav_label_option) = payload.nav_label.take() { - nav_label_option = match nav_label_option { - Some(label) => { - let trimmed = label.trim().to_string(); - if trimmed.is_empty() { - None - } else { - if trimmed.len() > MAX_NAV_LABEL_LEN { - return Err(bad_request(format!( - "Navigation label too long (max {MAX_NAV_LABEL_LEN} characters)" - ))); - } - Some(trimmed) - } - } - None => None, - }; - - payload.nav_label = Some(nav_label_option); - } - - // Partial JSON field update - if let Some(ref hero) = payload.hero { - validate_json_size(hero, "hero")?; - } - if let Some(ref layout) = payload.layout { - validate_json_size(layout, "layout")?; - } - - Ok(payload) -} - -/// Maps a database SitePage record to a rich response model, including JSON parsing. -fn map_page(page: crate::models::SitePage) -> Result { - let crate::models::SitePage { - id, - slug, - title, - description, - nav_label, - show_in_nav, - order_index, - is_published, - hero_json, - layout_json, - created_at, - updated_at, - } = page; - - // Parse hero JSON string from database into a serde_json::Value - let hero = serde_json::from_str::(&hero_json) - .map_err(internal_error("Failed to parse stored hero JSON"))?; - - // Parse layout JSON string from database into a serde_json::Value - let layout = serde_json::from_str::(&layout_json) - .map_err(internal_error("Failed to parse stored layout JSON"))?; - - // Normalize slug for output - let sanitized_slug = slug.trim().to_lowercase(); - - // Default title to slug if the title field is empty - let sanitized_title = match title.trim() { - "" => sanitized_slug.clone(), - value => value.to_string(), - }; - - // Trim description - let sanitized_description = description.trim().to_string(); - - // Clean up navigation label - let sanitized_nav_label = nav_label.and_then(|label| { - let trimmed = label.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - }); - - // Assemble response - Ok(SitePageResponse { - id, - slug: sanitized_slug, - title: sanitized_title, - description: sanitized_description, - nav_label: sanitized_nav_label, - show_in_nav, - order_index, - is_published, - hero, - layout, - created_at, - updated_at, - }) -} - -/// Maps a database SitePost record to a public response model. -fn map_post(post: crate::models::SitePost) -> SitePostResponse { - SitePostResponse { - id: post.id, - page_id: post.page_id, - title: post.title, - slug: post.slug, - excerpt: post.excerpt, - content_markdown: post.content_markdown, - is_published: post.is_published, - published_at: post.published_at, - order_index: post.order_index, - created_at: post.created_at, - updated_at: post.updated_at, - allow_comments: post.allow_comments, - } -} +mod helpers; +use helpers::*; /// Handler for listing all site pages. /// Admin-only. Used for managing the page tree in the CMS. diff --git a/backend/src/handlers/tutorials/mod.rs b/backend/src/handlers/tutorials/mod.rs index deb56d15..d45de923 100644 --- a/backend/src/handlers/tutorials/mod.rs +++ b/backend/src/handlers/tutorials/mod.rs @@ -21,194 +21,9 @@ use std::collections::HashSet; use std::convert::TryInto; use uuid::Uuid; -/// Validates a tutorial ID for length and character safety. -/// Used to prevent path injection and ensure URL compatibility. -pub(crate) fn validate_tutorial_id(id: &str) -> Result<(), String> { - // Check length bounds to prevent buffer overflow or DoS attacks - if id.is_empty() || id.len() > 100 { - return Err("Invalid tutorial ID (must be 1-100 characters)".to_string()); - } - - // Ensure only safe characters for database and URL usage - if !id - .chars() - .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.') - { - return Err( - "Tutorial ID contains invalid characters (allowed: alphanumeric, -, _, .)".to_string(), - ); - } - Ok(()) -} - -/// Validates the core text content of a tutorial. -fn validate_tutorial_data(title: &str, description: &str, content: &str) -> Result<(), String> { - // Title validation - let title_trimmed = title.trim(); - if title_trimmed.is_empty() { - return Err("Title cannot be empty".to_string()); - } - if title_trimmed.len() > 200 { - return Err("Title too long (max 200 characters)".to_string()); - } - - // Description validation - let description_trimmed = description.trim(); - if description_trimmed.is_empty() { - return Err("Description cannot be empty".to_string()); - } - if description_trimmed.len() > 1000 { - return Err("Description too long (max 1000 characters)".to_string()); - } - - // Markdown content validation - let content_trimmed = content.trim(); - if content_trimmed.is_empty() { - return Err("Content cannot be empty".to_string()); - } - if content_trimmed.len() > 100_000 { - return Err("Content too long (max 100,000 characters)".to_string()); - } - Ok(()) -} - -/// Validates that the provided icon name is within the allowed Lucide whitelist. -pub(crate) fn validate_icon(icon: &str) -> Result<(), String> { - /// Whitelist of Lucide icon identifiers used in the frontend - const ALLOWED_ICONS: &[&str] = &[ - "Terminal", // Command line and shell tutorials - "FolderTree", // File system and directory tutorials - "FileText", // Text editing and file manipulation - "Settings", // System configuration and settings - "Shield", // Security and permissions - "Network", // Networking and connectivity - "Database", // Database and data management - "Server", // Server administration and services - ]; - - if ALLOWED_ICONS.contains(&icon) { - Ok(()) - } else { - Err(format!( - "Invalid icon '{}'. Must be one of: {:?}", - icon, ALLOWED_ICONS - )) - } -} - -/// Validates a Tailwind CSS gradient string. -/// Ensures the format 'from-COLOR [via-COLOR] to-COLOR' is followed. -pub(crate) fn validate_color(color: &str) -> Result<(), String> { - const MAX_SEGMENT_LEN: usize = 32; - - /// Checks if a single tailwind class segment is valid (e.g. 'from-blue-500') - fn validate_segment(segment: &str, prefix: &str) -> bool { - // Handle responsive modifiers (e.g., dark:from-..., md:hover:to-...) - // We look at the last part after ':' or the whole string if no ':' - let base_class = segment.split(':').next_back().unwrap_or(segment); - - if !base_class.starts_with(prefix) { - return false; - } - let suffix = &base_class[prefix.len()..]; - !suffix.is_empty() - && suffix.len() <= MAX_SEGMENT_LEN - && suffix - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '-') - } - - let segments: Vec<&str> = color.split_whitespace().collect(); - // Allow more complex gradients but ensure we have at least from and to - // Typically 2 or 3 parts: from-... [via-...] to-... - // But could be more with responsive? No, typically "from-X to-Y" is the base structure. - // We stick to 2 or 3 segments for simplicity of storage/validation as per original design. - - // Gradients must have 2 (from/to) or 3 (from/via/to) segments - if !(segments.len() == 2 || segments.len() == 3) { - return Err( - "Invalid color gradient. Expected Tailwind style 'from-… [via-…] to-…' format." - .to_string(), - ); - } - - // Note: The logic below assumes the order is always (modifiers:)?from -> (modifiers:)?via -> (modifiers:)?to - // This might be too strict if user writes "to-red-500 from-blue-500", but Tailwind usually encourages ordered. - // The original code enforced order segments[0]=from, segments[1]=via/to. We keep this but allow modifiers. - - // Validate 'from-' segment - if !validate_segment(segments[0], "from-") { - return Err( - "Invalid color gradient: 'from-*' segment malformed, too long, or missing.".to_string(), - ); - } - - if segments.len() == 3 { - // Validation for middle segment - check if it is 'via-' or 'to-'? - // Original code expected: 0=from, 1=via, 2=to. - // Validate internal 'via-' segment - if !validate_segment(segments[1], "via-") { - return Err( - "Invalid color gradient: Middle segment must be 'via-*' in a 3-part gradient." - .to_string(), - ); - } - // Validate 'to-' segment - if !validate_segment(segments[2], "to-") { - return Err("Invalid color gradient: Last segment must be 'to-*'.".to_string()); - } - } else if !validate_segment(segments[1], "to-") { - // Validate 'to-' segment for 2-part gradient - return Err("Invalid color gradient: Last segment must be 'to-*'.".to_string()); - } - - Ok(()) -} - -/// Sanitizes a list of topics. -/// Normalizes to lowercase, removes duplicates, and trims long strings. -fn sanitize_topics(topics: &[String]) -> Result, String> { - // SECURITY: Limit number of topics to prevent indexing DoS - if topics.len() > 20 { - return Err("Too many topics (max 20)".to_string()); - } - - let mut sanitized = Vec::with_capacity(topics.len()); - let mut seen = HashSet::new(); - - for topic in topics { - let trimmed = topic.trim(); - if trimmed.is_empty() { - continue; - } - - // ENFORCEMENT: Truncate excessively long topic names - let limited: String = if trimmed.len() > 100 { - trimmed.chars().take(100).collect() - } else { - trimmed.to_string() - }; - - // Normalize to lowercase for duplicate detection - let canonical = limited - .chars() - .map(|c| c.to_ascii_lowercase()) - .collect::(); - - if !seen.insert(canonical) { - return Err("Duplicate topics are not allowed".to_string()); - } - - sanitized.push(limited); - } - - // Requirements - if sanitized.is_empty() { - return Err("At least one topic is required".to_string()); - } - - Ok(sanitized) -} +mod validation; +use validation::*; +pub(crate) use validation::{validate_color, validate_icon, validate_tutorial_id}; /// Query parameters for paginated tutorial listing. #[derive(Deserialize)] diff --git a/backend/src/handlers/tutorials/validation.rs b/backend/src/handlers/tutorials/validation.rs new file mode 100644 index 00000000..7e9244d7 --- /dev/null +++ b/backend/src/handlers/tutorials/validation.rs @@ -0,0 +1,194 @@ +use super::*; + +/// Validates a tutorial ID for length and character safety. +/// Used to prevent path injection and ensure URL compatibility. +pub(crate) fn validate_tutorial_id(id: &str) -> Result<(), String> { + // Check length bounds to prevent buffer overflow or DoS attacks + if id.is_empty() || id.len() > 100 { + return Err("Invalid tutorial ID (must be 1-100 characters)".to_string()); + } + + // Ensure only safe characters for database and URL usage + if !id + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.') + { + return Err( + "Tutorial ID contains invalid characters (allowed: alphanumeric, -, _, .)".to_string(), + ); + } + Ok(()) +} + +/// Validates the core text content of a tutorial. +pub(super) fn validate_tutorial_data( + title: &str, + description: &str, + content: &str, +) -> Result<(), String> { + // Title validation + let title_trimmed = title.trim(); + if title_trimmed.is_empty() { + return Err("Title cannot be empty".to_string()); + } + if title_trimmed.len() > 200 { + return Err("Title too long (max 200 characters)".to_string()); + } + + // Description validation + let description_trimmed = description.trim(); + if description_trimmed.is_empty() { + return Err("Description cannot be empty".to_string()); + } + if description_trimmed.len() > 1000 { + return Err("Description too long (max 1000 characters)".to_string()); + } + + // Markdown content validation + let content_trimmed = content.trim(); + if content_trimmed.is_empty() { + return Err("Content cannot be empty".to_string()); + } + if content_trimmed.len() > 100_000 { + return Err("Content too long (max 100,000 characters)".to_string()); + } + Ok(()) +} + +/// Validates that the provided icon name is within the allowed Lucide whitelist. +pub(crate) fn validate_icon(icon: &str) -> Result<(), String> { + /// Whitelist of Lucide icon identifiers used in the frontend + const ALLOWED_ICONS: &[&str] = &[ + "Terminal", // Command line and shell tutorials + "FolderTree", // File system and directory tutorials + "FileText", // Text editing and file manipulation + "Settings", // System configuration and settings + "Shield", // Security and permissions + "Network", // Networking and connectivity + "Database", // Database and data management + "Server", // Server administration and services + ]; + + if ALLOWED_ICONS.contains(&icon) { + Ok(()) + } else { + Err(format!( + "Invalid icon '{}'. Must be one of: {:?}", + icon, ALLOWED_ICONS + )) + } +} + +/// Validates a Tailwind CSS gradient string. +/// Ensures the format 'from-COLOR [via-COLOR] to-COLOR' is followed. +pub(crate) fn validate_color(color: &str) -> Result<(), String> { + const MAX_SEGMENT_LEN: usize = 32; + + /// Checks if a single tailwind class segment is valid (e.g. 'from-blue-500') + fn validate_segment(segment: &str, prefix: &str) -> bool { + // Handle responsive modifiers (e.g., dark:from-..., md:hover:to-...) + // We look at the last part after ':' or the whole string if no ':' + let base_class = segment.split(':').next_back().unwrap_or(segment); + + if !base_class.starts_with(prefix) { + return false; + } + let suffix = &base_class[prefix.len()..]; + !suffix.is_empty() + && suffix.len() <= MAX_SEGMENT_LEN + && suffix + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-') + } + + let segments: Vec<&str> = color.split_whitespace().collect(); + // Allow more complex gradients but ensure we have at least from and to + // Typically 2 or 3 parts: from-... [via-...] to-... + // But could be more with responsive? No, typically "from-X to-Y" is the base structure. + // We stick to 2 or 3 segments for simplicity of storage/validation as per original design. + + // Gradients must have 2 (from/to) or 3 (from/via/to) segments + if !(segments.len() == 2 || segments.len() == 3) { + return Err( + "Invalid color gradient. Expected Tailwind style 'from-… [via-…] to-…' format." + .to_string(), + ); + } + + // Note: The logic below assumes the order is always (modifiers:)?from -> (modifiers:)?via -> (modifiers:)?to + // This might be too strict if user writes "to-red-500 from-blue-500", but Tailwind usually encourages ordered. + // The original code enforced order segments[0]=from, segments[1]=via/to. We keep this but allow modifiers. + + // Validate 'from-' segment + if !validate_segment(segments[0], "from-") { + return Err( + "Invalid color gradient: 'from-*' segment malformed, too long, or missing.".to_string(), + ); + } + + if segments.len() == 3 { + // Validation for middle segment - check if it is 'via-' or 'to-'? + // Original code expected: 0=from, 1=via, 2=to. + // Validate internal 'via-' segment + if !validate_segment(segments[1], "via-") { + return Err( + "Invalid color gradient: Middle segment must be 'via-*' in a 3-part gradient." + .to_string(), + ); + } + // Validate 'to-' segment + if !validate_segment(segments[2], "to-") { + return Err("Invalid color gradient: Last segment must be 'to-*'.".to_string()); + } + } else if !validate_segment(segments[1], "to-") { + // Validate 'to-' segment for 2-part gradient + return Err("Invalid color gradient: Last segment must be 'to-*'.".to_string()); + } + + Ok(()) +} + +/// Sanitizes a list of topics. +/// Normalizes to lowercase, removes duplicates, and trims long strings. +pub(super) fn sanitize_topics(topics: &[String]) -> Result, String> { + // SECURITY: Limit number of topics to prevent indexing DoS + if topics.len() > 20 { + return Err("Too many topics (max 20)".to_string()); + } + + let mut sanitized = Vec::with_capacity(topics.len()); + let mut seen = HashSet::new(); + + for topic in topics { + let trimmed = topic.trim(); + if trimmed.is_empty() { + continue; + } + + // ENFORCEMENT: Truncate excessively long topic names + let limited: String = if trimmed.len() > 100 { + trimmed.chars().take(100).collect() + } else { + trimmed.to_string() + }; + + // Normalize to lowercase for duplicate detection + let canonical = limited + .chars() + .map(|c| c.to_ascii_lowercase()) + .collect::(); + + if !seen.insert(canonical) { + return Err("Duplicate topics are not allowed".to_string()); + } + + sanitized.push(limited); + } + + // Requirements + if sanitized.is_empty() { + return Err("At least one topic is required".to_string()); + } + + Ok(sanitized) +} diff --git a/backend/src/middleware/security.rs b/backend/src/middleware/security.rs index 4459e4f0..4e6f58da 100644 --- a/backend/src/middleware/security.rs +++ b/backend/src/middleware/security.rs @@ -203,13 +203,23 @@ pub async fn security_headers(request: Request, next: Next) -> Response { } // Step 2: Content Security Policy (CSP) - // Note: 'unsafe-inline' for style-src is currently required for syntax highlighting and math rendering. + // 'unsafe-inline' remains necessary for syntax highlighting and math rendering. let csp = if cfg!(debug_assertions) { // Development CSP - allows local hot reloading ws/wss - "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob:; connect-src 'self' ws: wss:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests;" + concat!( + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' ", + "https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; ", + "img-src 'self' data: blob:; connect-src 'self' ws: wss:; object-src 'none'; ", + "base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests;" + ) } else { // Production CSP - restricted connections - "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests;" + concat!( + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' ", + "https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; ", + "img-src 'self' data: blob:; connect-src 'self'; object-src 'none'; ", + "base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests;" + ) }; headers.insert(CONTENT_SECURITY_POLICY, HeaderValue::from_static(csp)); diff --git a/backend/src/repositories/comments.rs b/backend/src/repositories/comments.rs index 2aaf3bb3..b33cf5c9 100644 --- a/backend/src/repositories/comments.rs +++ b/backend/src/repositories/comments.rs @@ -11,9 +11,10 @@ pub async fn list_comments( sort: Option<&str>, ) -> Result, sqlx::Error> { // Dynamic query building for different sort orders - let mut query_builder = sqlx::QueryBuilder::new( - "SELECT id, tutorial_id, post_id, author, content, created_at, votes, is_admin, author_username, is_guest FROM comments WHERE tutorial_id = " - ); + let mut query_builder = sqlx::QueryBuilder::new(concat!( + "SELECT id, tutorial_id, post_id, author, content, created_at, votes, is_admin, ", + "author_username, is_guest FROM comments WHERE tutorial_id = " + )); query_builder.push_bind(tutorial_id); match sort { @@ -43,9 +44,10 @@ pub async fn list_post_comments( offset: i64, sort: Option<&str>, ) -> Result, sqlx::Error> { - let mut query_builder = sqlx::QueryBuilder::new( - "SELECT id, tutorial_id, post_id, author, content, created_at, votes, is_admin, author_username, is_guest FROM comments WHERE post_id = " - ); + let mut query_builder = sqlx::QueryBuilder::new(concat!( + "SELECT id, tutorial_id, post_id, author, content, created_at, votes, is_admin, ", + "author_username, is_guest FROM comments WHERE post_id = " + )); query_builder.push_bind(post_id); match sort { @@ -82,9 +84,11 @@ pub async fn create_comment( author_username: Option, is_guest: Option, ) -> Result { - sqlx::query( - "INSERT INTO comments (id, tutorial_id, post_id, author, rate_limit_key, content, created_at, votes, is_admin, author_username, is_guest) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)" - ) + sqlx::query(concat!( + "INSERT INTO comments (id, tutorial_id, post_id, author, rate_limit_key, content, ", + "created_at, votes, is_admin, author_username, is_guest) ", + "VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)" + )) .bind(id) .bind(&tutorial_id) .bind(&post_id) @@ -113,12 +117,13 @@ pub async fn create_comment( } pub async fn get_comment(pool: &DbPool, id: &str) -> Result, sqlx::Error> { - sqlx::query_as::<_, Comment>( - "SELECT id, tutorial_id, post_id, author, content, created_at, votes, is_admin, author_username, is_guest FROM comments WHERE id = ?", - ) - .bind(id) - .fetch_optional(pool) - .await + sqlx::query_as::<_, Comment>(concat!( + "SELECT id, tutorial_id, post_id, author, content, created_at, votes, is_admin, ", + "author_username, is_guest FROM comments WHERE id = ?" + )) + .bind(id) + .fetch_optional(pool) + .await } pub async fn delete_comment(pool: &DbPool, id: &str) -> Result { diff --git a/backend/src/repositories/mod.rs b/backend/src/repositories/mod.rs index 9f905351..782d15a1 100644 --- a/backend/src/repositories/mod.rs +++ b/backend/src/repositories/mod.rs @@ -9,6 +9,7 @@ pub mod app_metadata; // Generic key-value storage pub mod comments; // Comment and voting persistence pub mod common; // Shared validation and serialization utilities pub mod content; // Dynamic landing page sections +pub mod newsletter; // Newsletter subscription persistence pub mod pages; // Site page structure pub mod posts; // Detailed blog post content pub mod token_blacklist; // Authentication revocation state diff --git a/backend/src/repositories/newsletter.rs b/backend/src/repositories/newsletter.rs new file mode 100644 index 00000000..cbc0a62f --- /dev/null +++ b/backend/src/repositories/newsletter.rs @@ -0,0 +1,53 @@ +use crate::db::DbPool; + +/// Stores a normalized subscription without revealing whether it already existed. +pub async fn subscribe(pool: &DbPool, email: &str) -> Result<(), sqlx::Error> { + sqlx::query( + "INSERT INTO newsletter_subscriptions (id, email) VALUES (?, ?) \ + ON CONFLICT(email) DO NOTHING", + ) + .bind(uuid::Uuid::new_v4().to_string()) + .bind(email) + .execute(pool) + .await?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::subscribe; + use sqlx::sqlite::SqlitePoolOptions; + + #[tokio::test] + async fn repeated_subscriptions_are_idempotent() { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("create sqlite pool"); + sqlx::query( + "CREATE TABLE newsletter_subscriptions (\ + id TEXT PRIMARY KEY, \ + email TEXT NOT NULL COLLATE NOCASE UNIQUE, \ + created_at TEXT NOT NULL DEFAULT (datetime('now'))\ + )", + ) + .execute(&pool) + .await + .expect("create newsletter table"); + + subscribe(&pool, "reader@example.com") + .await + .expect("first subscription"); + subscribe(&pool, "reader@example.com") + .await + .expect("repeated subscription"); + + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM newsletter_subscriptions") + .fetch_one(&pool) + .await + .expect("count subscriptions"); + assert_eq!(count, 1); + } +} diff --git a/backend/src/repositories/pages.rs b/backend/src/repositories/pages.rs index 042b44b1..97a94df5 100644 --- a/backend/src/repositories/pages.rs +++ b/backend/src/repositories/pages.rs @@ -5,40 +5,43 @@ use sqlx; /// Fetches all site pages, ordered by their custom navigation index and title. pub async fn list_site_pages(pool: &DbPool) -> Result, sqlx::Error> { - sqlx::query_as::<_, SitePage>( - "SELECT id, slug, title, description, nav_label, show_in_nav, order_index, is_published, hero_json, layout_json, created_at, updated_at FROM site_pages ORDER BY order_index, title", - ) + sqlx::query_as::<_, SitePage>(concat!( + "SELECT id, slug, title, description, nav_label, show_in_nav, order_index, ", + "is_published, hero_json, layout_json, created_at, updated_at ", + "FROM site_pages ORDER BY order_index, title" + )) .fetch_all(pool) .await } /// Fetches pages that are specifically marked to appear in the navigation menu. pub async fn list_nav_pages(pool: &DbPool) -> Result, sqlx::Error> { - sqlx::query_as::<_, SitePage>( - "SELECT id, slug, title, description, nav_label, show_in_nav, order_index, is_published, hero_json, layout_json, created_at, updated_at - FROM site_pages - WHERE show_in_nav = 1 AND is_published = 1 - ORDER BY order_index, title", - ) + sqlx::query_as::<_, SitePage>(concat!( + "SELECT id, slug, title, description, nav_label, show_in_nav, order_index, ", + "is_published, hero_json, layout_json, created_at, updated_at ", + "FROM site_pages WHERE show_in_nav = 1 AND is_published = 1 ", + "ORDER BY order_index, title" + )) .fetch_all(pool) .await } pub async fn list_published_pages(pool: &DbPool) -> Result, sqlx::Error> { - sqlx::query_as::<_, SitePage>( - "SELECT id, slug, title, description, nav_label, show_in_nav, order_index, is_published, hero_json, layout_json, created_at, updated_at - FROM site_pages - WHERE is_published = 1 - ORDER BY order_index, title", - ) + sqlx::query_as::<_, SitePage>(concat!( + "SELECT id, slug, title, description, nav_label, show_in_nav, order_index, ", + "is_published, hero_json, layout_json, created_at, updated_at ", + "FROM site_pages WHERE is_published = 1 ORDER BY order_index, title" + )) .fetch_all(pool) .await } pub async fn get_site_page_by_id(pool: &DbPool, id: &str) -> Result, sqlx::Error> { - sqlx::query_as::<_, SitePage>( - "SELECT id, slug, title, description, nav_label, show_in_nav, order_index, is_published, hero_json, layout_json, created_at, updated_at FROM site_pages WHERE id = ?", - ) + sqlx::query_as::<_, SitePage>(concat!( + "SELECT id, slug, title, description, nav_label, show_in_nav, order_index, ", + "is_published, hero_json, layout_json, created_at, updated_at ", + "FROM site_pages WHERE id = ?" + )) .bind(id) .fetch_optional(pool) .await @@ -49,9 +52,11 @@ pub async fn get_site_page_by_slug( pool: &DbPool, slug: &str, ) -> Result, sqlx::Error> { - sqlx::query_as::<_, SitePage>( - "SELECT id, slug, title, description, nav_label, show_in_nav, order_index, is_published, hero_json, layout_json, created_at, updated_at FROM site_pages WHERE slug = ?", - ) + sqlx::query_as::<_, SitePage>(concat!( + "SELECT id, slug, title, description, nav_label, show_in_nav, order_index, ", + "is_published, hero_json, layout_json, created_at, updated_at ", + "FROM site_pages WHERE slug = ?" + )) .bind(slug) .fetch_optional(pool) .await @@ -72,10 +77,11 @@ pub async fn create_site_page( let order_index = page.order_index.unwrap_or(0); // Insert record - sqlx::query( - "INSERT INTO site_pages (id, slug, title, description, nav_label, show_in_nav, order_index, is_published, hero_json, layout_json) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ) + sqlx::query(concat!( + "INSERT INTO site_pages (id, slug, title, description, nav_label, show_in_nav, ", + "order_index, is_published, hero_json, layout_json) ", + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + )) .bind(&id) .bind(&page.slug) .bind(&page.title) @@ -140,11 +146,11 @@ pub async fn update_site_page( } // Execute UPDATE - sqlx::query( - "UPDATE site_pages - SET slug = ?, title = ?, description = ?, nav_label = ?, show_in_nav = ?, order_index = ?, is_published = ?, hero_json = ?, layout_json = ?, updated_at = CURRENT_TIMESTAMP - WHERE id = ?", - ) + sqlx::query(concat!( + "UPDATE site_pages SET slug = ?, title = ?, description = ?, nav_label = ?, ", + "show_in_nav = ?, order_index = ?, is_published = ?, hero_json = ?, ", + "layout_json = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?" + )) .bind(&existing.slug) .bind(&existing.title) .bind(&existing.description) diff --git a/backend/src/repositories/posts.rs b/backend/src/repositories/posts.rs index 227580d7..3df27a1b 100644 --- a/backend/src/repositories/posts.rs +++ b/backend/src/repositories/posts.rs @@ -8,12 +8,11 @@ pub async fn list_site_posts_for_page( pool: &DbPool, page_id: &str, ) -> Result, sqlx::Error> { - sqlx::query_as::<_, SitePost>( - "SELECT id, page_id, title, slug, excerpt, content_markdown, is_published, allow_comments, published_at, order_index, created_at, updated_at - FROM site_posts - WHERE page_id = ? - ORDER BY order_index, created_at", - ) + sqlx::query_as::<_, SitePost>(concat!( + "SELECT id, page_id, title, slug, excerpt, content_markdown, is_published, ", + "allow_comments, published_at, order_index, created_at, updated_at ", + "FROM site_posts WHERE page_id = ? ORDER BY order_index, created_at" + )) .bind(page_id) .fetch_all(pool) .await @@ -24,12 +23,12 @@ pub async fn list_published_posts_for_page( pool: &DbPool, page_id: &str, ) -> Result, sqlx::Error> { - sqlx::query_as::<_, SitePost>( - "SELECT id, page_id, title, slug, excerpt, content_markdown, is_published, allow_comments, published_at, order_index, created_at, updated_at - FROM site_posts - WHERE page_id = ? AND is_published = 1 - ORDER BY order_index, COALESCE(published_at, created_at)", - ) + sqlx::query_as::<_, SitePost>(concat!( + "SELECT id, page_id, title, slug, excerpt, content_markdown, is_published, ", + "allow_comments, published_at, order_index, created_at, updated_at ", + "FROM site_posts WHERE page_id = ? AND is_published = 1 ", + "ORDER BY order_index, COALESCE(published_at, created_at)" + )) .bind(page_id) .fetch_all(pool) .await @@ -40,11 +39,11 @@ pub async fn get_published_post_by_slug( page_id: &str, post_slug: &str, ) -> Result, sqlx::Error> { - sqlx::query_as::<_, SitePost>( - "SELECT id, page_id, title, slug, excerpt, content_markdown, is_published, allow_comments, published_at, order_index, created_at, updated_at - FROM site_posts - WHERE page_id = ? AND slug = ? AND is_published = 1", - ) + sqlx::query_as::<_, SitePost>(concat!( + "SELECT id, page_id, title, slug, excerpt, content_markdown, is_published, ", + "allow_comments, published_at, order_index, created_at, updated_at ", + "FROM site_posts WHERE page_id = ? AND slug = ? AND is_published = 1" + )) .bind(page_id) .bind(post_slug) .fetch_optional(pool) @@ -52,10 +51,11 @@ pub async fn get_published_post_by_slug( } pub async fn get_site_post_by_id(pool: &DbPool, id: &str) -> Result, sqlx::Error> { - sqlx::query_as::<_, SitePost>( - "SELECT id, page_id, title, slug, excerpt, content_markdown, is_published, allow_comments, published_at, order_index, created_at, updated_at - FROM site_posts WHERE id = ?", - ) + sqlx::query_as::<_, SitePost>(concat!( + "SELECT id, page_id, title, slug, excerpt, content_markdown, is_published, ", + "allow_comments, published_at, order_index, created_at, updated_at ", + "FROM site_posts WHERE id = ?" + )) .bind(id) .fetch_optional(pool) .await @@ -75,10 +75,11 @@ pub async fn create_site_post( let order_index = payload.order_index.unwrap_or(0); // Insert record - sqlx::query( - "INSERT INTO site_posts (id, page_id, title, slug, excerpt, content_markdown, is_published, allow_comments, published_at, order_index) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ) + sqlx::query(concat!( + "INSERT INTO site_posts (id, page_id, title, slug, excerpt, content_markdown, ", + "is_published, allow_comments, published_at, order_index) ", + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + )) .bind(&id) .bind(page_id) .bind(&payload.title) @@ -140,11 +141,11 @@ pub async fn update_site_post( } // Save back to DB - sqlx::query( - "UPDATE site_posts - SET title = ?, slug = ?, excerpt = ?, content_markdown = ?, is_published = ?, allow_comments = ?, published_at = ?, order_index = ?, updated_at = CURRENT_TIMESTAMP - WHERE id = ?", - ) + sqlx::query(concat!( + "UPDATE site_posts SET title = ?, slug = ?, excerpt = ?, content_markdown = ?, ", + "is_published = ?, allow_comments = ?, published_at = ?, order_index = ?, ", + "updated_at = CURRENT_TIMESTAMP WHERE id = ?" + )) .bind(&existing.title) .bind(&existing.slug) .bind(&existing.excerpt) diff --git a/backend/src/repositories/tutorials.rs b/backend/src/repositories/tutorials.rs index 2234fd64..dd375f37 100644 --- a/backend/src/repositories/tutorials.rs +++ b/backend/src/repositories/tutorials.rs @@ -71,9 +71,10 @@ pub async fn create_tutorial( replace_tutorial_topics_tx(&mut tx, id, topics_vec).await?; // Step 3: Fetch the finalized record (including timestamps) - let tutorial = sqlx::query_as::<_, Tutorial>( - "SELECT id, title, description, icon, color, topics, content, version, created_at, updated_at FROM tutorials WHERE id = ?" - ) + let tutorial = sqlx::query_as::<_, Tutorial>(concat!( + "SELECT id, title, description, icon, color, topics, content, version, ", + "created_at, updated_at FROM tutorials WHERE id = ?" + )) .bind(id) .fetch_one(&mut *tx) .await?; @@ -109,7 +110,8 @@ pub async fn update_tutorial( let result = sqlx::query( r#" UPDATE tutorials - SET title = ?, description = ?, icon = ?, color = ?, topics = ?, content = ?, version = ?, updated_at = datetime('now') + SET title = ?, description = ?, icon = ?, color = ?, topics = ?, + content = ?, version = ?, updated_at = datetime('now') WHERE id = ? AND version = ? "#, ) @@ -134,9 +136,10 @@ pub async fn update_tutorial( replace_tutorial_topics_tx(&mut tx, id, topics_vec).await?; // Step 3: Fetch updated state - let tutorial = sqlx::query_as::<_, Tutorial>( - "SELECT id, title, description, icon, color, topics, content, version, created_at, updated_at FROM tutorials WHERE id = ?" - ) + let tutorial = sqlx::query_as::<_, Tutorial>(concat!( + "SELECT id, title, description, icon, color, topics, content, version, ", + "created_at, updated_at FROM tutorials WHERE id = ?" + )) .bind(id) .fetch_one(&mut *tx) .await?; diff --git a/backend/src/routes/api.rs b/backend/src/routes/api.rs index a50479f2..c7428f08 100644 --- a/backend/src/routes/api.rs +++ b/backend/src/routes/api.rs @@ -1,4 +1,4 @@ -use crate::handlers::{auth, comments, search, site_content, site_pages, tutorials}; +use crate::handlers::{auth, comments, newsletter, search, site_content, site_pages, tutorials}; use crate::{db::DbPool, middleware::security::TrustedClientIpKeyExtractor}; use axum::{ routing::{get, post}, @@ -32,6 +32,13 @@ pub fn routes( get(comments::list_post_comments).post(comments::create_post_comment), ) .route("/api/comments/{id}/vote", post(comments::vote_comment)) + .route_layer(GovernorLayer::new(public_rate_limit_config.clone())); + + let rate_limited_newsletter_route = Router::new() + .route( + "/api/public/newsletter", + post(newsletter::subscribe_to_newsletter), + ) .route_layer(GovernorLayer::new(public_rate_limit_config)); Router::new() @@ -47,6 +54,7 @@ pub fn routes( get(site_content::get_site_content), ) .merge(rate_limited_comment_routes) + .merge(rate_limited_newsletter_route) .route( "/api/public/pages/{slug}", get(site_pages::get_published_page_by_slug), diff --git a/backend/src/security/auth.rs b/backend/src/security/auth.rs index a014aa25..4d6a4b97 100644 --- a/backend/src/security/auth.rs +++ b/backend/src/security/auth.rs @@ -120,18 +120,20 @@ pub fn init_jwt_secret() -> Result<(), String> { .iter() .any(|candidate| candidate.eq_ignore_ascii_case(trimmed)) { - return Err( - "JWT_SECRET uses a known placeholder value. Generate a fresh random secret (e.g. `openssl rand -base64 48)`." - .to_string(), - ); + return Err(concat!( + "JWT_SECRET uses a known placeholder value. Generate a fresh random secret ", + "(e.g. `openssl rand -base64 48)`." + ) + .to_string()); } // Validate entropy if !secret_has_min_entropy(trimmed) { - return Err( - "JWT_SECRET must be a high-entropy value (~256 bits). Use a cryptographically random string of at least 43 characters mixing upper, lower, digits, and symbols." - .to_string(), - ); + return Err(concat!( + "JWT_SECRET must be a high-entropy value (~256 bits). Use a cryptographically ", + "random string of at least 43 characters mixing upper, lower, digits, and symbols." + ) + .to_string()); } // Store secret in global state (can only be done once) @@ -278,151 +280,8 @@ pub fn verify_jwt(token: &str) -> Result { Ok(token_data.claims) } -/// Builds a secure authentication cookie containing the JWT token. -/// -/// Creates an HttpOnly cookie with appropriate security flags for -/// storing the JWT token in the client's browser. -/// -/// # Arguments -/// * `token` - The JWT token to store in the cookie -/// -/// # Returns -/// A Cookie configured for secure authentication token storage -/// -/// # Security Features -/// - HttpOnly: Prevents JavaScript access (XSS protection) -/// - SameSite=Lax: CSRF protection while allowing navigation -/// - Secure flag: HTTPS-only (when AUTH_COOKIE_SECURE is not false) -/// - 24-hour expiration: Matches JWT expiration -/// - Path=/: Available to all routes -pub fn build_auth_cookie(token: &str) -> Cookie<'static> { - // Build cookie with security flags - let mut builder = Cookie::build((AUTH_COOKIE_NAME, token.to_owned())) - .path("/") - .http_only(true) - .same_site(SameSite::Lax) - .max_age(TimeDuration::seconds(AUTH_COOKIE_TTL_SECONDS)); - - // Add Secure flag in production (HTTPS only) - if cookies_should_be_secure() { - builder = builder.secure(true); - } - - builder.build() -} - -/// Builds a cookie that removes the authentication cookie. -/// -/// Creates a cookie with expired timestamp to instruct the browser -/// to delete the authentication cookie (used for logout). -/// -/// # Returns -/// A Cookie configured to remove the authentication cookie -/// -/// # Mechanism -/// - Empty value -/// - Expiration set to Unix epoch (Jan 1, 1970) -/// - Max-age of 0 -/// - Same path and security flags as the auth cookie -pub fn build_cookie_removal() -> Cookie<'static> { - // Build cookie with expiration in the past to trigger removal - let mut builder = Cookie::build((AUTH_COOKIE_NAME, "")) - .path("/") - .http_only(true) - .same_site(SameSite::Lax) - .expires(OffsetDateTime::UNIX_EPOCH) - .max_age(TimeDuration::seconds(0)); - - // Match security settings of auth cookie - if cookies_should_be_secure() { - builder = builder.secure(true); - } - - builder.build() -} - -/// AXUM extractor implementation for Claims. -/// -/// This allows Claims to be used as a function parameter in route handlers, -/// automatically extracting and validating the JWT token from the request. -/// -/// # Extraction order -/// 1. Check if claims already in request extensions (from middleware) -/// 2. Extract token from Authorization header or cookie -/// 3. Validate token and decode claims -/// -/// # Errors -/// Returns 401 Unauthorized if: -/// - No token found in headers or cookies -/// - Token is invalid or expired -impl FromRequestParts for Claims -where - S: Send + Sync, - DbPool: FromRef, -{ - type Rejection = (StatusCode, String); - - async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { - // Step 1: Check cache. If auth middleware already ran, claims are in extensions. - if let Some(claims) = parts.extensions.get::() { - return Ok(claims.clone()); - } - - // Step 2: Extract raw token from standard locations (Header/Cookie). - let token = extract_token(&parts.headers).ok_or_else(|| { - ( - StatusCode::UNAUTHORIZED, - "Missing authentication token".to_string(), - ) - })?; - - // Step 3: Verify cryptographic signature and expiration. - let claims = verify_jwt(&token) - .map_err(|e| (StatusCode::UNAUTHORIZED, format!("Invalid token: {}", e)))?; - - // Step 4: Check if token has been revoked (Logout/Blacklist). - let pool = DbPool::from_ref(state); - let is_blacklisted = - crate::repositories::token_blacklist::is_token_blacklisted(&pool, &token) - .await - .map_err(|e| { - tracing::error!("Database error checking token blacklist: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - "Internal server error".to_string(), - ) - })?; - - if is_blacklisted { - return Err(( - StatusCode::UNAUTHORIZED, - "Token has been revoked".to_string(), - )); - } - - // Cache result for downstream handlers - parts.extensions.insert(claims.clone()); - Ok(claims) - } -} - -/// Appends an authentication cookie to the response headers. -/// -/// # Arguments -/// * `headers` - Mutable reference to the response HeaderMap -/// * `cookie` - The cookie to append -/// -/// # Error Handling -/// Logs an error if the cookie cannot be serialized (should never happen) -pub fn append_auth_cookie(headers: &mut HeaderMap, cookie: Cookie<'static>) { - // Convert cookie to header value - if let Ok(value) = HeaderValue::from_str(&cookie.to_string()) { - headers.append(SET_COOKIE, value); - } else { - // This should never happen with valid cookie values - tracing::error!("Failed to serialize auth cookie for Set-Cookie header"); - } -} +mod cookies; +pub use cookies::{append_auth_cookie, build_auth_cookie, build_cookie_removal}; /// Validates that a secret has minimum entropy requirements. /// @@ -494,9 +353,10 @@ pub fn cookies_should_be_secure() -> bool { match env::var("AUTH_COOKIE_SECURE") { // Only disable if explicitly set to false Ok(value) if value.trim().eq_ignore_ascii_case("false") => { - tracing::warn!( - "AUTH_COOKIE_SECURE explicitly set to false. Cookies will be sent over HTTP; only use this in trusted development environments." - ); + tracing::warn!(concat!( + "AUTH_COOKIE_SECURE explicitly set to false. Cookies will be sent over HTTP; ", + "only use this in trusted development environments." + )); false } // Default to secure cookies @@ -618,97 +478,4 @@ where } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_secret_entropy_validation() { - // Too short (fails length check) - assert!(!secret_has_min_entropy("Short1!")); - - // Only one character class (fails class count check) - assert!(!secret_has_min_entropy("this_is_a_very_long_secret_but_only_contains_lowercase_and_underscores_which_is_not_enough_classes")); - - // Only two character classes (fails class count check) - assert!(!secret_has_min_entropy( - "ThisIsAVeryLongSecretWithUppercaseAndLowercaseButNoNumbersOrSpecialChars" - )); - - // Too few unique characters (fails uniqueness check) - assert!(!secret_has_min_entropy( - "A1!A1!A1!A1!A1!A1!A1!A1!A1!A1!A1!A1!A1!A1!A1!A1!A1!A1!A1!A1!A1!A1!" - )); - - // Valid high entropy secret (meets all requirements) - assert!(secret_has_min_entropy( - "p@ssW0rd_Extremely_Long_And_Secure_With_Many_Chars_123!" - )); - } - - #[test] - fn test_jwt_initialization_flow() { - // Since JWT_SECRET is a global OnceLock, it might be initialized by other tests. - // We just verify that if we attempt to initialize it, we either succeed or - // get an "already initialized" error. - env::set_var( - "JWT_SECRET", - "this_is_a_test_secret_with_adequate_entropy_123_ABC_!!!", - ); - let result = init_jwt_secret(); - - match result { - Ok(_) => assert!(JWT_SECRET.get().is_some()), - Err(e) => assert!( - e.contains("already initialized") || e.contains("JWT_SECRET already initialized") - ), - } - } - - #[test] - fn test_jwt_create_and_verify() { - // Ensure secret is set - if JWT_SECRET.get().is_none() { - env::set_var( - "JWT_SECRET", - "this_is_another_test_secret_with_adequate_entropy_123_XYZ_!!!", - ); - let _ = init_jwt_secret(); - } - - let username = "auth_test_user".to_string(); - let role = "admin".to_string(); - - let token = create_jwt(username.clone(), role.clone()).expect("Failed to create JWT"); - let decoded = verify_jwt(&token).expect("Failed to verify JWT"); - - assert_eq!(decoded.sub, username); - assert_eq!(decoded.role, role); - } - - #[test] - fn test_parse_bearer_token() { - assert_eq!( - parse_bearer_token("Bearer my_token"), - Some("my_token".to_string()) - ); - assert_eq!( - parse_bearer_token("bearer my_token "), - Some("my_token".to_string()) - ); - assert_eq!(parse_bearer_token("token_without_bearer"), None); - assert_eq!(parse_bearer_token("Bearer "), None); - } - - #[test] - fn test_build_auth_cookie() { - let token = "test_jwt_cookie_token"; - let cookie = build_auth_cookie(token); - - assert_eq!(cookie.name(), AUTH_COOKIE_NAME); - assert_eq!(cookie.value(), token); - assert_eq!(cookie.path(), Some("/")); - assert_eq!(cookie.http_only(), Some(true)); - assert_eq!(cookie.same_site(), Some(SameSite::Lax)); - assert!(cookie.max_age().is_some()); - } -} +mod tests; diff --git a/backend/src/security/auth/cookies.rs b/backend/src/security/auth/cookies.rs new file mode 100644 index 00000000..02848ae2 --- /dev/null +++ b/backend/src/security/auth/cookies.rs @@ -0,0 +1,147 @@ +use super::*; + +/// Builds a secure authentication cookie containing the JWT token. +/// +/// Creates an HttpOnly cookie with appropriate security flags for +/// storing the JWT token in the client's browser. +/// +/// # Arguments +/// * `token` - The JWT token to store in the cookie +/// +/// # Returns +/// A Cookie configured for secure authentication token storage +/// +/// # Security Features +/// - HttpOnly: Prevents JavaScript access (XSS protection) +/// - SameSite=Lax: CSRF protection while allowing navigation +/// - Secure flag: HTTPS-only (when AUTH_COOKIE_SECURE is not false) +/// - 24-hour expiration: Matches JWT expiration +/// - Path=/: Available to all routes +pub fn build_auth_cookie(token: &str) -> Cookie<'static> { + // Build cookie with security flags + let mut builder = Cookie::build((AUTH_COOKIE_NAME, token.to_owned())) + .path("/") + .http_only(true) + .same_site(SameSite::Lax) + .max_age(TimeDuration::seconds(AUTH_COOKIE_TTL_SECONDS)); + + // Add Secure flag in production (HTTPS only) + if cookies_should_be_secure() { + builder = builder.secure(true); + } + + builder.build() +} + +/// Builds a cookie that removes the authentication cookie. +/// +/// Creates a cookie with expired timestamp to instruct the browser +/// to delete the authentication cookie (used for logout). +/// +/// # Returns +/// A Cookie configured to remove the authentication cookie +/// +/// # Mechanism +/// - Empty value +/// - Expiration set to Unix epoch (Jan 1, 1970) +/// - Max-age of 0 +/// - Same path and security flags as the auth cookie +pub fn build_cookie_removal() -> Cookie<'static> { + // Build cookie with expiration in the past to trigger removal + let mut builder = Cookie::build((AUTH_COOKIE_NAME, "")) + .path("/") + .http_only(true) + .same_site(SameSite::Lax) + .expires(OffsetDateTime::UNIX_EPOCH) + .max_age(TimeDuration::seconds(0)); + + // Match security settings of auth cookie + if cookies_should_be_secure() { + builder = builder.secure(true); + } + + builder.build() +} + +/// AXUM extractor implementation for Claims. +/// +/// This allows Claims to be used as a function parameter in route handlers, +/// automatically extracting and validating the JWT token from the request. +/// +/// # Extraction order +/// 1. Check if claims already in request extensions (from middleware) +/// 2. Extract token from Authorization header or cookie +/// 3. Validate token and decode claims +/// +/// # Errors +/// Returns 401 Unauthorized if: +/// - No token found in headers or cookies +/// - Token is invalid or expired +impl FromRequestParts for Claims +where + S: Send + Sync, + DbPool: FromRef, +{ + type Rejection = (StatusCode, String); + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + // Step 1: Check cache. If auth middleware already ran, claims are in extensions. + if let Some(claims) = parts.extensions.get::() { + return Ok(claims.clone()); + } + + // Step 2: Extract raw token from standard locations (Header/Cookie). + let token = extract_token(&parts.headers).ok_or_else(|| { + ( + StatusCode::UNAUTHORIZED, + "Missing authentication token".to_string(), + ) + })?; + + // Step 3: Verify cryptographic signature and expiration. + let claims = verify_jwt(&token) + .map_err(|e| (StatusCode::UNAUTHORIZED, format!("Invalid token: {}", e)))?; + + // Step 4: Check if token has been revoked (Logout/Blacklist). + let pool = DbPool::from_ref(state); + let is_blacklisted = + crate::repositories::token_blacklist::is_token_blacklisted(&pool, &token) + .await + .map_err(|e| { + tracing::error!("Database error checking token blacklist: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal server error".to_string(), + ) + })?; + + if is_blacklisted { + return Err(( + StatusCode::UNAUTHORIZED, + "Token has been revoked".to_string(), + )); + } + + // Cache result for downstream handlers + parts.extensions.insert(claims.clone()); + Ok(claims) + } +} + +/// Appends an authentication cookie to the response headers. +/// +/// # Arguments +/// * `headers` - Mutable reference to the response HeaderMap +/// * `cookie` - The cookie to append +/// +/// # Error Handling +/// Logs an error if the cookie cannot be serialized (should never happen) +pub fn append_auth_cookie(headers: &mut HeaderMap, cookie: Cookie<'static>) { + // Convert cookie to header value + if let Ok(value) = HeaderValue::from_str(&cookie.to_string()) { + headers.append(SET_COOKIE, value); + } else { + // This should never happen with valid cookie values + tracing::error!("Failed to serialize auth cookie for Set-Cookie header"); + } +} diff --git a/backend/src/security/auth/tests.rs b/backend/src/security/auth/tests.rs new file mode 100644 index 00000000..077e28e6 --- /dev/null +++ b/backend/src/security/auth/tests.rs @@ -0,0 +1,96 @@ +use super::*; + +#[test] +fn test_secret_entropy_validation() { + // Too short (fails length check) + assert!(!secret_has_min_entropy("Short1!")); + + // Only one character class (fails class count check) + let low_entropy = concat!( + "this_is_a_very_long_secret_but_only_contains_lowercase_and_underscores_", + "which_is_not_enough_classes" + ); + assert!(!secret_has_min_entropy(low_entropy)); + + // Only two character classes (fails class count check) + assert!(!secret_has_min_entropy( + "ThisIsAVeryLongSecretWithUppercaseAndLowercaseButNoNumbersOrSpecialChars" + )); + + // Too few unique characters (fails uniqueness check) + assert!(!secret_has_min_entropy( + "A1!A1!A1!A1!A1!A1!A1!A1!A1!A1!A1!A1!A1!A1!A1!A1!A1!A1!A1!A1!A1!A1!" + )); + + // Valid high entropy secret (meets all requirements) + assert!(secret_has_min_entropy( + "p@ssW0rd_Extremely_Long_And_Secure_With_Many_Chars_123!" + )); +} + +#[test] +fn test_jwt_initialization_flow() { + // Since JWT_SECRET is a global OnceLock, it might be initialized by other tests. + // We just verify that if we attempt to initialize it, we either succeed or + // get an "already initialized" error. + env::set_var( + "JWT_SECRET", + "this_is_a_test_secret_with_adequate_entropy_123_ABC_!!!", + ); + let result = init_jwt_secret(); + + match result { + Ok(_) => assert!(JWT_SECRET.get().is_some()), + Err(e) => assert!( + e.contains("already initialized") || e.contains("JWT_SECRET already initialized") + ), + } +} + +#[test] +fn test_jwt_create_and_verify() { + // Ensure secret is set + if JWT_SECRET.get().is_none() { + env::set_var( + "JWT_SECRET", + "this_is_another_test_secret_with_adequate_entropy_123_XYZ_!!!", + ); + let _ = init_jwt_secret(); + } + + let username = "auth_test_user".to_string(); + let role = "admin".to_string(); + + let token = create_jwt(username.clone(), role.clone()).expect("Failed to create JWT"); + let decoded = verify_jwt(&token).expect("Failed to verify JWT"); + + assert_eq!(decoded.sub, username); + assert_eq!(decoded.role, role); +} + +#[test] +fn test_parse_bearer_token() { + assert_eq!( + parse_bearer_token("Bearer my_token"), + Some("my_token".to_string()) + ); + assert_eq!( + parse_bearer_token("bearer my_token "), + Some("my_token".to_string()) + ); + assert_eq!(parse_bearer_token("token_without_bearer"), None); + assert_eq!(parse_bearer_token("Bearer "), None); +} + +#[test] +fn test_build_auth_cookie() { + let token = "test_jwt_cookie_token"; + let cookie = build_auth_cookie(token); + + assert_eq!(cookie.name(), AUTH_COOKIE_NAME); + assert_eq!(cookie.value(), token); + assert_eq!(cookie.path(), Some("/")); + assert_eq!(cookie.http_only(), Some(true)); + assert_eq!(cookie.same_site(), Some(SameSite::Lax)); + assert!(cookie.max_age().is_some()); +} diff --git a/backend/src/security/csrf.rs b/backend/src/security/csrf.rs index 683dada3..1715871a 100644 --- a/backend/src/security/csrf.rs +++ b/backend/src/security/csrf.rs @@ -341,294 +341,15 @@ fn subtle_equals(a: &[u8], b: &[u8]) -> bool { a.ct_eq(b).into() } -/// Appends a CSRF token cookie to the response headers. -/// -/// # Arguments -/// * `headers` - Mutable reference to the response HeaderMap -/// * `token` - The CSRF token to include in the cookie -/// -/// # Error Handling -/// Logs an error if cookie serialization fails (should never happen) -pub fn append_csrf_cookie(headers: &mut HeaderMap, token: &str) { - // Build cookie with security flags - let cookie = build_csrf_cookie(token); - - // Append to Set-Cookie header - if let Ok(value) = HeaderValue::from_str(&cookie.to_string()) { - headers.append(SET_COOKIE, value); - } else { - tracing::error!("Failed to serialize CSRF cookie"); - } -} - -/// Appends a cookie that removes the CSRF cookie (for logout). -/// -/// # Arguments -/// * `headers` - Mutable reference to the response HeaderMap -/// -/// # Error Handling -/// Logs an error if cookie serialization fails (should never happen) -pub fn append_csrf_removal(headers: &mut HeaderMap) { - // Build removal cookie (expired) - let cookie = build_csrf_removal(); - - // Append to Set-Cookie header - if let Ok(value) = HeaderValue::from_str(&cookie.to_string()) { - headers.append(SET_COOKIE, value); - } else { - tracing::error!("Failed to serialize CSRF removal cookie"); - } -} - -/// Builds a CSRF cookie with appropriate security flags. -/// -/// # Arguments -/// * `token` - The CSRF token to store in the cookie -/// -/// # Returns -/// A Cookie configured for CSRF protection -/// -/// # Security Flags -/// - SameSite=Strict: Prevents cross-site cookie sending (strict CSRF protection) -/// - HttpOnly=false: Allows JavaScript read access (needed for header submission) -/// - Secure: HTTPS-only (when AUTH_COOKIE_SECURE is not false) -/// - Path=/: Available to all routes -/// - Max-Age: 6 hours (matches token expiration) -fn build_csrf_cookie(token: &str) -> Cookie<'static> { - // Build cookie with security settings - let mut builder = Cookie::build((CSRF_COOKIE_NAME, token.to_owned())) - .path("/") - .same_site(SameSite::Strict) - .max_age(TimeDuration::seconds(CSRF_TOKEN_TTL_SECONDS)) - .http_only(false); // Must be false for JavaScript to read and submit in header - - // Add Secure flag in production (HTTPS only) - if auth::cookies_should_be_secure() { - builder = builder.secure(true); - } - - builder.build() -} - -/// Builds a cookie that removes the CSRF cookie. -/// -/// # Returns -/// A Cookie configured to remove the CSRF cookie -/// -/// # Mechanism -/// - Empty value -/// - Expiration set to Unix epoch (Jan 1, 1970) -/// - Max-age of 0 -/// - Same path and security flags as the CSRF cookie -fn build_csrf_removal() -> Cookie<'static> { - // Build cookie with expiration in the past to trigger removal - let mut builder = Cookie::build((CSRF_COOKIE_NAME, "")) - .path("/") - .same_site(SameSite::Strict) - .expires(OffsetDateTime::UNIX_EPOCH) - .max_age(TimeDuration::seconds(0)) - .http_only(false); - - // Match security settings of CSRF cookie - if auth::cookies_should_be_secure() { - builder = builder.secure(true); - } - - builder.build() -} - -/// AXUM extractor for CSRF protection. -/// -/// This extractor validates CSRF tokens for state-changing HTTP methods. -/// Safe methods (GET, HEAD, OPTIONS, TRACE) are automatically allowed. -/// -/// # Validation Process -/// 1. Skip validation for safe HTTP methods -/// 2. Ensure user is authenticated (extract Claims) -/// 3. Extract token from x-csrf-token header -/// 4. Extract token from cookie -/// 5. Verify header and cookie tokens match (double-submit pattern) -/// 6. Validate token signature and binding to user -/// -/// # Usage -/// ```rust,ignore -/// use axum::{Router, routing::post, middleware}; -/// use rust_blog_backend::security::csrf::CsrfGuard; -/// async fn handler() {} -/// -/// let app = Router::new() -/// .route("/api/resource", post(handler)) -/// .route_layer(middleware::from_extractor::()); -/// ``` -/// -/// # Security -/// - Double-submit cookie pattern (cookie + header) -/// - Per-user token binding -/// - HMAC signature verification -/// - Expiration enforcement -/// -/// # Errors -/// Returns 403 Forbidden if: -/// - CSRF token header is missing -/// - CSRF cookie is missing -/// - Header and cookie tokens don't match -/// - Token validation fails (expired, wrong user, invalid signature) -/// - Anonymous request carries a cross-site Origin/Referer -pub struct CsrfGuard; - -/// Validates that a state-changing request from an anonymous client was not -/// issued cross-site by a hostile page in the victim's browser. -/// -/// Browsers attach an `Origin` header to every cross-origin (and same-origin -/// non-GET) fetch, and it cannot be forged or suppressed from a web page. -/// -/// The policy: -/// - No `Origin` and no `Referer`: allow. This is a non-browser client -/// (curl, mobile app, integration test); those carry no ambient browser -/// state that CSRF could abuse. -/// - Origin present: its hostname must match the request's `Host` header -/// (hostname comparison -- an attacker cannot serve content under the -/// victim host's name), or the full origin must be in the configured -/// CORS allowlist (separate-frontend deployments). -/// - Anything else (mismatch, unparseable, the literal "null"): reject. -fn validate_browser_origin(headers: &HeaderMap) -> Result<(), String> { - // Prefer Origin; fall back to the origin part of Referer for the rare - // legitimate clients that send only the latter. - let origin_value = headers - .get(axum::http::header::ORIGIN) - .or_else(|| headers.get(axum::http::header::REFERER)) - .and_then(|value| value.to_str().ok()); - - let origin_raw = match origin_value { - Some(value) => value.trim(), - None => return Ok(()), - }; - - let parsed = url::Url::parse(origin_raw) - .map_err(|_| "Cross-origin request blocked: invalid Origin".to_string())?; - let origin_host = parsed - .host_str() - .ok_or_else(|| "Cross-origin request blocked: invalid Origin".to_string())? - .to_ascii_lowercase(); - - // Same-host check against the Host header (hostname without port). - if let Some(request_host) = headers - .get(axum::http::header::HOST) - .and_then(|value| value.to_str().ok()) - .map(|value| { - let value = value.trim(); - // Bracketed IPv6 literals ("[::1]:8080") keep their brackets in - // url::Url::host_str too, so compare including brackets. - if let Some(end) = value.strip_prefix('[').and_then(|_| value.find(']')) { - value[..=end].to_ascii_lowercase() - } else { - value - .rsplit_once(':') - .map_or(value, |(host, _port)| host) - .to_ascii_lowercase() - } - }) - { - if !request_host.is_empty() && origin_host == request_host { - return Ok(()); - } - } - - // Allowlist check: the exact origin (scheme://host[:port]) must be one - // of the configured cross-origin frontends. - let normalized_origin = - crate::middleware::cors::normalize_origin(parsed.origin().ascii_serialization().as_str()); - if crate::middleware::cors::allowed_browser_origins().contains(&normalized_origin) { - return Ok(()); - } - - Err("Cross-origin request blocked".to_string()) -} +mod cookies; +#[cfg(test)] +use cookies::build_csrf_cookie; +pub use cookies::{append_csrf_cookie, append_csrf_removal}; -impl FromRequestParts for CsrfGuard -where - S: Send + Sync, - crate::db::DbPool: axum::extract::FromRef, -{ - type Rejection = (StatusCode, Json); - - async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { - // Step 1: Method Filter. CSRF is only required for state-changing operations. - if matches!( - parts.method, - Method::GET | Method::HEAD | Method::OPTIONS | Method::TRACE - ) { - return Ok(Self); - } - - // Step 2: Authenticated Check. CSRF protects sessions, so we first check the user's identity. - let claims_result = if let Some(existing) = parts.extensions.get::() { - Ok(existing.clone()) - } else { - auth::Claims::from_request_parts(parts, _state).await - }; - - let claims = match claims_result { - Ok(claims) => { - // User is logged in -> Enforce strict CSRF checks. - parts.extensions.insert(claims.clone()); - claims - } - Err(_) => { - // Anonymous user -> no session cookie to ride, so the full - // double-submit token check does not apply. But anonymous - // endpoints (e.g. guest comments) can still be driven from a - // third-party page in the victim's browser, so enforce a - // browser-origin check: if the request carries an Origin (or - // Referer), it must be same-host or a configured frontend. - if let Err(reason) = validate_browser_origin(&parts.headers) { - return Err((StatusCode::FORBIDDEN, Json(ErrorResponse { error: reason }))); - } - return Ok(Self); - } - }; - - // Step 3: Extract tokens from both submission channels. - let header_value = parts - .headers - .get(HeaderName::from_static(CSRF_HEADER_NAME)) - .and_then(|value| value.to_str().ok()) - .ok_or_else(|| { - ( - StatusCode::FORBIDDEN, - Json(ErrorResponse { - error: "Missing CSRF token header".to_string(), - }), - ) - })?; - - let jar = CookieJar::from_headers(&parts.headers); - let cookie = jar.get(CSRF_COOKIE_NAME).ok_or_else(|| { - ( - StatusCode::FORBIDDEN, - Json(ErrorResponse { - error: "Missing CSRF cookie".to_string(), - }), - ) - })?; - - // Step 4: Double-Submit Validation. Ensure the tokens match. - if cookie.value() != header_value { - return Err(( - StatusCode::FORBIDDEN, - Json(ErrorResponse { - error: "CSRF token mismatch".to_string(), - }), - )); - } - - // Step 5: Master Validation. Verify signature, expiration, and user binding. - validate_csrf_token(header_value, &claims.sub) - .map_err(|err| (StatusCode::FORBIDDEN, Json(ErrorResponse { error: err })))?; - - Ok(Self) - } -} +mod guard; +#[cfg(test)] +use guard::validate_browser_origin; +pub use guard::CsrfGuard; /// Returns the name of the CSRF cookie. /// @@ -661,218 +382,4 @@ pub async fn enforce_csrf( } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_csrf_secret_initialization() { - // Ensure secret is initialized for tests - if CSRF_SECRET.get().is_none() { - env::set_var( - "CSRF_SECRET", - "this_is_a_very_long_secret_key_for_testing_purposes_only_at_least_32_bytes", - ); - let _ = init_csrf_secret(); - } - - assert!(CSRF_SECRET.get().is_some()); - } - - #[test] - fn test_issue_csrf_token() { - if CSRF_SECRET.get().is_none() { - env::set_var( - "CSRF_SECRET", - "this_is_a_very_long_secret_key_for_testing_purposes_only_at_least_32_bytes", - ); - let _ = init_csrf_secret(); - } - - let username = "testuser"; - let token = issue_csrf_token(username).expect("Failed to issue token"); - - let parts: Vec<&str> = token.split('|').collect(); - // v1|base64(username)|expiry|nonce|signature - assert_eq!(parts.len(), 5); - assert_eq!(parts[0], "v1"); - - // Verify username part - let decoded_username = Base64UrlUnpadded::decode_vec(parts[1]).unwrap(); - assert_eq!(String::from_utf8(decoded_username).unwrap(), username); - } - - #[test] - fn test_validate_csrf_token_valid() { - if CSRF_SECRET.get().is_none() { - env::set_var( - "CSRF_SECRET", - "this_is_a_very_long_secret_key_for_testing_purposes_only_at_least_32_bytes", - ); - let _ = init_csrf_secret(); - } - - let username = "valid_user"; - let token = issue_csrf_token(username).unwrap(); - - assert!(validate_csrf_token(&token, username).is_ok()); - } - - #[test] - fn test_validate_csrf_token_wrong_user() { - if CSRF_SECRET.get().is_none() { - env::set_var( - "CSRF_SECRET", - "this_is_a_very_long_secret_key_for_testing_purposes_only_at_least_32_bytes", - ); - let _ = init_csrf_secret(); - } - - let token = issue_csrf_token("user_a").unwrap(); - let result = validate_csrf_token(&token, "user_b"); - - assert!(result.is_err()); - assert_eq!( - result.unwrap_err(), - "CSRF token not issued for this account" - ); - } - - #[test] - fn test_validate_csrf_token_tampered() { - if CSRF_SECRET.get().is_none() { - env::set_var( - "CSRF_SECRET", - "this_is_a_very_long_secret_key_for_testing_purposes_only_at_least_32_bytes", - ); - let _ = init_csrf_secret(); - } - - let token = issue_csrf_token("test_tamper").unwrap(); - let mut parts: Vec<&str> = token.split('|').collect(); - - // Tamper with the nonce (part 3) - let mut tampered_token = String::new(); - parts[3] = "tampered_nonce_value"; - - // Reconstruct manually - for (i, part) in parts.iter().enumerate() { - if i > 0 { - tampered_token.push('|'); - } - tampered_token.push_str(part); - } - - let result = validate_csrf_token(&tampered_token, "test_tamper"); - assert!(result.is_err()); - assert_eq!(result.unwrap_err(), "CSRF signature mismatch"); - } - - #[test] - fn test_validate_csrf_token_expired() { - if CSRF_SECRET.get().is_none() { - env::set_var( - "CSRF_SECRET", - "this_is_a_very_long_secret_key_for_testing_purposes_only_at_least_32_bytes", - ); - let _ = init_csrf_secret(); - } - - // Manually construct an expired token - let username = "expired_user"; - let username_b64 = Base64UrlUnpadded::encode_string(username.as_bytes()); - // 1 second in the past - let expiry = Utc::now().timestamp() - 1; - let nonce = Uuid::new_v4().to_string(); - - let payload = format!("{username_b64}|{expiry}|{nonce}"); - let versioned_payload = format!("{CSRF_VERSION}|{payload}"); - - let mut mac = HmacSha256::new_from_slice(get_secret()).unwrap(); - mac.update(versioned_payload.as_bytes()); - let signature = Base64UrlUnpadded::encode_string(&mac.finalize().into_bytes()); - - let token = format!("{versioned_payload}|{signature}"); - - let result = validate_csrf_token(&token, username); - assert!(result.is_err()); - assert_eq!(result.unwrap_err(), "CSRF token expired"); - } - - #[test] - fn browser_origin_check_allows_requests_without_origin_or_referer() { - // Non-browser clients (curl, tests) send neither header and carry no - // ambient browser credentials -- must not be blocked. - let headers = HeaderMap::new(); - assert!(validate_browser_origin(&headers).is_ok()); - } - - #[test] - fn browser_origin_check_allows_same_host_origin() { - let mut headers = HeaderMap::new(); - headers.insert("host", HeaderValue::from_static("blog.example.com")); - headers.insert( - "origin", - HeaderValue::from_static("https://blog.example.com"), - ); - assert!(validate_browser_origin(&headers).is_ok()); - } - - #[test] - fn browser_origin_check_allows_same_host_with_differing_port() { - // Hostname comparison only: an attacker cannot serve content under - // the victim's hostname, so the port is not part of the trust - // boundary here. - let mut headers = HeaderMap::new(); - headers.insert("host", HeaderValue::from_static("blog.example.com:8489")); - headers.insert( - "origin", - HeaderValue::from_static("https://blog.example.com"), - ); - assert!(validate_browser_origin(&headers).is_ok()); - } - - #[test] - fn browser_origin_check_rejects_cross_site_origin() { - let mut headers = HeaderMap::new(); - headers.insert("host", HeaderValue::from_static("blog.example.com")); - headers.insert( - "origin", - HeaderValue::from_static("https://evil.example.net"), - ); - assert!(validate_browser_origin(&headers).is_err()); - } - - #[test] - fn browser_origin_check_rejects_null_origin() { - // Sandboxed iframes and some redirect chains send the literal - // "null" origin; it is unverifiable and must be rejected. - let mut headers = HeaderMap::new(); - headers.insert("host", HeaderValue::from_static("blog.example.com")); - headers.insert("origin", HeaderValue::from_static("null")); - assert!(validate_browser_origin(&headers).is_err()); - } - - #[test] - fn browser_origin_check_falls_back_to_referer() { - let mut headers = HeaderMap::new(); - headers.insert("host", HeaderValue::from_static("blog.example.com")); - headers.insert( - "referer", - HeaderValue::from_static("https://evil.example.net/attack.html"), - ); - assert!(validate_browser_origin(&headers).is_err()); - } - - #[test] - fn test_build_csrf_cookie() { - let token = "test_token_value"; - let cookie = build_csrf_cookie(token); - - assert_eq!(cookie.name(), CSRF_COOKIE_NAME); - assert_eq!(cookie.value(), token); - assert_eq!(cookie.path(), Some("/")); - assert_eq!(cookie.same_site(), Some(SameSite::Strict)); - assert_eq!(cookie.http_only(), Some(false)); - assert!(cookie.max_age().is_some()); - } -} +mod tests; diff --git a/backend/src/security/csrf/cookies.rs b/backend/src/security/csrf/cookies.rs new file mode 100644 index 00000000..f5670ebd --- /dev/null +++ b/backend/src/security/csrf/cookies.rs @@ -0,0 +1,97 @@ +use super::*; + +/// Appends a CSRF token cookie to the response headers. +/// +/// # Arguments +/// * `headers` - Mutable reference to the response HeaderMap +/// * `token` - The CSRF token to include in the cookie +/// +/// # Error Handling +/// Logs an error if cookie serialization fails (should never happen) +pub fn append_csrf_cookie(headers: &mut HeaderMap, token: &str) { + // Build cookie with security flags + let cookie = build_csrf_cookie(token); + + // Append to Set-Cookie header + if let Ok(value) = HeaderValue::from_str(&cookie.to_string()) { + headers.append(SET_COOKIE, value); + } else { + tracing::error!("Failed to serialize CSRF cookie"); + } +} + +/// Appends a cookie that removes the CSRF cookie (for logout). +/// +/// # Arguments +/// * `headers` - Mutable reference to the response HeaderMap +/// +/// # Error Handling +/// Logs an error if cookie serialization fails (should never happen) +pub fn append_csrf_removal(headers: &mut HeaderMap) { + // Build removal cookie (expired) + let cookie = build_csrf_removal(); + + // Append to Set-Cookie header + if let Ok(value) = HeaderValue::from_str(&cookie.to_string()) { + headers.append(SET_COOKIE, value); + } else { + tracing::error!("Failed to serialize CSRF removal cookie"); + } +} + +/// Builds a CSRF cookie with appropriate security flags. +/// +/// # Arguments +/// * `token` - The CSRF token to store in the cookie +/// +/// # Returns +/// A Cookie configured for CSRF protection +/// +/// # Security Flags +/// - SameSite=Strict: Prevents cross-site cookie sending (strict CSRF protection) +/// - HttpOnly=false: Allows JavaScript read access (needed for header submission) +/// - Secure: HTTPS-only (when AUTH_COOKIE_SECURE is not false) +/// - Path=/: Available to all routes +/// - Max-Age: 6 hours (matches token expiration) +pub(super) fn build_csrf_cookie(token: &str) -> Cookie<'static> { + // Build cookie with security settings + let mut builder = Cookie::build((CSRF_COOKIE_NAME, token.to_owned())) + .path("/") + .same_site(SameSite::Strict) + .max_age(TimeDuration::seconds(CSRF_TOKEN_TTL_SECONDS)) + .http_only(false); // Must be false for JavaScript to read and submit in header + + // Add Secure flag in production (HTTPS only) + if auth::cookies_should_be_secure() { + builder = builder.secure(true); + } + + builder.build() +} + +/// Builds a cookie that removes the CSRF cookie. +/// +/// # Returns +/// A Cookie configured to remove the CSRF cookie +/// +/// # Mechanism +/// - Empty value +/// - Expiration set to Unix epoch (Jan 1, 1970) +/// - Max-age of 0 +/// - Same path and security flags as the CSRF cookie +pub(super) fn build_csrf_removal() -> Cookie<'static> { + // Build cookie with expiration in the past to trigger removal + let mut builder = Cookie::build((CSRF_COOKIE_NAME, "")) + .path("/") + .same_site(SameSite::Strict) + .expires(OffsetDateTime::UNIX_EPOCH) + .max_age(TimeDuration::seconds(0)) + .http_only(false); + + // Match security settings of CSRF cookie + if auth::cookies_should_be_secure() { + builder = builder.secure(true); + } + + builder.build() +} diff --git a/backend/src/security/csrf/guard.rs b/backend/src/security/csrf/guard.rs new file mode 100644 index 00000000..c1d74342 --- /dev/null +++ b/backend/src/security/csrf/guard.rs @@ -0,0 +1,194 @@ +use super::*; + +/// AXUM extractor for CSRF protection. +/// +/// This extractor validates CSRF tokens for state-changing HTTP methods. +/// Safe methods (GET, HEAD, OPTIONS, TRACE) are automatically allowed. +/// +/// # Validation Process +/// 1. Skip validation for safe HTTP methods +/// 2. Ensure user is authenticated (extract Claims) +/// 3. Extract token from x-csrf-token header +/// 4. Extract token from cookie +/// 5. Verify header and cookie tokens match (double-submit pattern) +/// 6. Validate token signature and binding to user +/// +/// # Usage +/// ```rust,ignore +/// use axum::{Router, routing::post, middleware}; +/// use rust_blog_backend::security::csrf::CsrfGuard; +/// async fn handler() {} +/// +/// let app = Router::new() +/// .route("/api/resource", post(handler)) +/// .route_layer(middleware::from_extractor::()); +/// ``` +/// +/// # Security +/// - Double-submit cookie pattern (cookie + header) +/// - Per-user token binding +/// - HMAC signature verification +/// - Expiration enforcement +/// +/// # Errors +/// Returns 403 Forbidden if: +/// - CSRF token header is missing +/// - CSRF cookie is missing +/// - Header and cookie tokens don't match +/// - Token validation fails (expired, wrong user, invalid signature) +/// - Anonymous request carries a cross-site Origin/Referer +pub struct CsrfGuard; + +/// Validates that a state-changing request from an anonymous client was not +/// issued cross-site by a hostile page in the victim's browser. +/// +/// Browsers attach an `Origin` header to every cross-origin (and same-origin +/// non-GET) fetch, and it cannot be forged or suppressed from a web page. +/// +/// The policy: +/// - No `Origin` and no `Referer`: allow. This is a non-browser client +/// (curl, mobile app, integration test); those carry no ambient browser +/// state that CSRF could abuse. +/// - Origin present: its hostname must match the request's `Host` header +/// (hostname comparison -- an attacker cannot serve content under the +/// victim host's name), or the full origin must be in the configured +/// CORS allowlist (separate-frontend deployments). +/// - Anything else (mismatch, unparseable, the literal "null"): reject. +pub(super) fn validate_browser_origin(headers: &HeaderMap) -> Result<(), String> { + // Prefer Origin; fall back to the origin part of Referer for the rare + // legitimate clients that send only the latter. + let origin_value = headers + .get(axum::http::header::ORIGIN) + .or_else(|| headers.get(axum::http::header::REFERER)) + .and_then(|value| value.to_str().ok()); + + let origin_raw = match origin_value { + Some(value) => value.trim(), + None => return Ok(()), + }; + + let parsed = url::Url::parse(origin_raw) + .map_err(|_| "Cross-origin request blocked: invalid Origin".to_string())?; + let origin_host = parsed + .host_str() + .ok_or_else(|| "Cross-origin request blocked: invalid Origin".to_string())? + .to_ascii_lowercase(); + + // Same-host check against the Host header (hostname without port). + if let Some(request_host) = headers + .get(axum::http::header::HOST) + .and_then(|value| value.to_str().ok()) + .map(|value| { + let value = value.trim(); + // Bracketed IPv6 literals ("[::1]:8080") keep their brackets in + // url::Url::host_str too, so compare including brackets. + if let Some(end) = value.strip_prefix('[').and_then(|_| value.find(']')) { + value[..=end].to_ascii_lowercase() + } else { + value + .rsplit_once(':') + .map_or(value, |(host, _port)| host) + .to_ascii_lowercase() + } + }) + { + if !request_host.is_empty() && origin_host == request_host { + return Ok(()); + } + } + + // Allowlist check: the exact origin (scheme://host[:port]) must be one + // of the configured cross-origin frontends. + let normalized_origin = + crate::middleware::cors::normalize_origin(parsed.origin().ascii_serialization().as_str()); + if crate::middleware::cors::allowed_browser_origins().contains(&normalized_origin) { + return Ok(()); + } + + Err("Cross-origin request blocked".to_string()) +} + +impl FromRequestParts for CsrfGuard +where + S: Send + Sync, + crate::db::DbPool: axum::extract::FromRef, +{ + type Rejection = (StatusCode, Json); + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + // Step 1: Method Filter. CSRF is only required for state-changing operations. + if matches!( + parts.method, + Method::GET | Method::HEAD | Method::OPTIONS | Method::TRACE + ) { + return Ok(Self); + } + + // Step 2: Authenticated Check. CSRF protects sessions, so we first check the user's identity. + let claims_result = if let Some(existing) = parts.extensions.get::() { + Ok(existing.clone()) + } else { + auth::Claims::from_request_parts(parts, _state).await + }; + + let claims = match claims_result { + Ok(claims) => { + // User is logged in -> Enforce strict CSRF checks. + parts.extensions.insert(claims.clone()); + claims + } + Err(_) => { + // Anonymous user -> no session cookie to ride, so the full + // double-submit token check does not apply. But anonymous + // endpoints (e.g. guest comments) can still be driven from a + // third-party page in the victim's browser, so enforce a + // browser-origin check: if the request carries an Origin (or + // Referer), it must be same-host or a configured frontend. + if let Err(reason) = validate_browser_origin(&parts.headers) { + return Err((StatusCode::FORBIDDEN, Json(ErrorResponse { error: reason }))); + } + return Ok(Self); + } + }; + + // Step 3: Extract tokens from both submission channels. + let header_value = parts + .headers + .get(HeaderName::from_static(CSRF_HEADER_NAME)) + .and_then(|value| value.to_str().ok()) + .ok_or_else(|| { + ( + StatusCode::FORBIDDEN, + Json(ErrorResponse { + error: "Missing CSRF token header".to_string(), + }), + ) + })?; + + let jar = CookieJar::from_headers(&parts.headers); + let cookie = jar.get(CSRF_COOKIE_NAME).ok_or_else(|| { + ( + StatusCode::FORBIDDEN, + Json(ErrorResponse { + error: "Missing CSRF cookie".to_string(), + }), + ) + })?; + + // Step 4: Double-Submit Validation. Ensure the tokens match. + if cookie.value() != header_value { + return Err(( + StatusCode::FORBIDDEN, + Json(ErrorResponse { + error: "CSRF token mismatch".to_string(), + }), + )); + } + + // Step 5: Master Validation. Verify signature, expiration, and user binding. + validate_csrf_token(header_value, &claims.sub) + .map_err(|err| (StatusCode::FORBIDDEN, Json(ErrorResponse { error: err })))?; + + Ok(Self) + } +} diff --git a/backend/src/security/csrf/tests.rs b/backend/src/security/csrf/tests.rs new file mode 100644 index 00000000..30f260e9 --- /dev/null +++ b/backend/src/security/csrf/tests.rs @@ -0,0 +1,213 @@ +use super::*; + +#[test] +fn test_csrf_secret_initialization() { + // Ensure secret is initialized for tests + if CSRF_SECRET.get().is_none() { + env::set_var( + "CSRF_SECRET", + "this_is_a_very_long_secret_key_for_testing_purposes_only_at_least_32_bytes", + ); + let _ = init_csrf_secret(); + } + + assert!(CSRF_SECRET.get().is_some()); +} + +#[test] +fn test_issue_csrf_token() { + if CSRF_SECRET.get().is_none() { + env::set_var( + "CSRF_SECRET", + "this_is_a_very_long_secret_key_for_testing_purposes_only_at_least_32_bytes", + ); + let _ = init_csrf_secret(); + } + + let username = "testuser"; + let token = issue_csrf_token(username).expect("Failed to issue token"); + + let parts: Vec<&str> = token.split('|').collect(); + // v1|base64(username)|expiry|nonce|signature + assert_eq!(parts.len(), 5); + assert_eq!(parts[0], "v1"); + + // Verify username part + let decoded_username = Base64UrlUnpadded::decode_vec(parts[1]).unwrap(); + assert_eq!(String::from_utf8(decoded_username).unwrap(), username); +} + +#[test] +fn test_validate_csrf_token_valid() { + if CSRF_SECRET.get().is_none() { + env::set_var( + "CSRF_SECRET", + "this_is_a_very_long_secret_key_for_testing_purposes_only_at_least_32_bytes", + ); + let _ = init_csrf_secret(); + } + + let username = "valid_user"; + let token = issue_csrf_token(username).unwrap(); + + assert!(validate_csrf_token(&token, username).is_ok()); +} + +#[test] +fn test_validate_csrf_token_wrong_user() { + if CSRF_SECRET.get().is_none() { + env::set_var( + "CSRF_SECRET", + "this_is_a_very_long_secret_key_for_testing_purposes_only_at_least_32_bytes", + ); + let _ = init_csrf_secret(); + } + + let token = issue_csrf_token("user_a").unwrap(); + let result = validate_csrf_token(&token, "user_b"); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + "CSRF token not issued for this account" + ); +} + +#[test] +fn test_validate_csrf_token_tampered() { + if CSRF_SECRET.get().is_none() { + env::set_var( + "CSRF_SECRET", + "this_is_a_very_long_secret_key_for_testing_purposes_only_at_least_32_bytes", + ); + let _ = init_csrf_secret(); + } + + let token = issue_csrf_token("test_tamper").unwrap(); + let mut parts: Vec<&str> = token.split('|').collect(); + + // Tamper with the nonce (part 3) + let mut tampered_token = String::new(); + parts[3] = "tampered_nonce_value"; + + // Reconstruct manually + for (i, part) in parts.iter().enumerate() { + if i > 0 { + tampered_token.push('|'); + } + tampered_token.push_str(part); + } + + let result = validate_csrf_token(&tampered_token, "test_tamper"); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), "CSRF signature mismatch"); +} + +#[test] +fn test_validate_csrf_token_expired() { + if CSRF_SECRET.get().is_none() { + env::set_var( + "CSRF_SECRET", + "this_is_a_very_long_secret_key_for_testing_purposes_only_at_least_32_bytes", + ); + let _ = init_csrf_secret(); + } + + // Manually construct an expired token + let username = "expired_user"; + let username_b64 = Base64UrlUnpadded::encode_string(username.as_bytes()); + // 1 second in the past + let expiry = Utc::now().timestamp() - 1; + let nonce = Uuid::new_v4().to_string(); + + let payload = format!("{username_b64}|{expiry}|{nonce}"); + let versioned_payload = format!("{CSRF_VERSION}|{payload}"); + + let mut mac = HmacSha256::new_from_slice(get_secret()).unwrap(); + mac.update(versioned_payload.as_bytes()); + let signature = Base64UrlUnpadded::encode_string(&mac.finalize().into_bytes()); + + let token = format!("{versioned_payload}|{signature}"); + + let result = validate_csrf_token(&token, username); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), "CSRF token expired"); +} + +#[test] +fn browser_origin_check_allows_requests_without_origin_or_referer() { + // Non-browser clients (curl, tests) send neither header and carry no + // ambient browser credentials -- must not be blocked. + let headers = HeaderMap::new(); + assert!(validate_browser_origin(&headers).is_ok()); +} + +#[test] +fn browser_origin_check_allows_same_host_origin() { + let mut headers = HeaderMap::new(); + headers.insert("host", HeaderValue::from_static("blog.example.com")); + headers.insert( + "origin", + HeaderValue::from_static("https://blog.example.com"), + ); + assert!(validate_browser_origin(&headers).is_ok()); +} + +#[test] +fn browser_origin_check_allows_same_host_with_differing_port() { + // Hostname comparison only: an attacker cannot serve content under + // the victim's hostname, so the port is not part of the trust + // boundary here. + let mut headers = HeaderMap::new(); + headers.insert("host", HeaderValue::from_static("blog.example.com:8489")); + headers.insert( + "origin", + HeaderValue::from_static("https://blog.example.com"), + ); + assert!(validate_browser_origin(&headers).is_ok()); +} + +#[test] +fn browser_origin_check_rejects_cross_site_origin() { + let mut headers = HeaderMap::new(); + headers.insert("host", HeaderValue::from_static("blog.example.com")); + headers.insert( + "origin", + HeaderValue::from_static("https://evil.example.net"), + ); + assert!(validate_browser_origin(&headers).is_err()); +} + +#[test] +fn browser_origin_check_rejects_null_origin() { + // Sandboxed iframes and some redirect chains send the literal + // "null" origin; it is unverifiable and must be rejected. + let mut headers = HeaderMap::new(); + headers.insert("host", HeaderValue::from_static("blog.example.com")); + headers.insert("origin", HeaderValue::from_static("null")); + assert!(validate_browser_origin(&headers).is_err()); +} + +#[test] +fn browser_origin_check_falls_back_to_referer() { + let mut headers = HeaderMap::new(); + headers.insert("host", HeaderValue::from_static("blog.example.com")); + headers.insert( + "referer", + HeaderValue::from_static("https://evil.example.net/attack.html"), + ); + assert!(validate_browser_origin(&headers).is_err()); +} + +#[test] +fn test_build_csrf_cookie() { + let token = "test_token_value"; + let cookie = build_csrf_cookie(token); + + assert_eq!(cookie.name(), CSRF_COOKIE_NAME); + assert_eq!(cookie.value(), token); + assert_eq!(cookie.path(), Some("/")); + assert_eq!(cookie.same_site(), Some(SameSite::Strict)); + assert_eq!(cookie.http_only(), Some(false)); + assert!(cookie.max_age().is_some()); +} diff --git a/backend/tests/api_integration_tests.rs b/backend/tests/api_integration_tests.rs index daa0696e..f4861237 100644 --- a/backend/tests/api_integration_tests.rs +++ b/backend/tests/api_integration_tests.rs @@ -124,3 +124,60 @@ async fn test_site_content_update_requires_auth() { assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } + +#[tokio::test] +async fn test_newsletter_subscription_is_validated_and_idempotent() { + let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); + sqlx::query( + r#" + CREATE TABLE newsletter_subscriptions ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL COLLATE NOCASE UNIQUE, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + "#, + ) + .execute(&pool) + .await + .unwrap(); + + let app = + routes::create_routes(pool.clone(), "test_uploads".to_string()).with_state(pool.clone()); + + for email in ["Reader@Example.com", "reader@example.com"] { + let response = app + .clone() + .oneshot(with_connect_info( + Request::builder() + .uri("/api/public/newsletter") + .method("POST") + .header("Content-Type", "application/json") + .body(Body::from(format!(r#"{{"email":"{email}"}}"#))) + .unwrap(), + )) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + } + + let stored_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM newsletter_subscriptions") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(stored_count, 1); + + let invalid_response = app + .oneshot(with_connect_info( + Request::builder() + .uri("/api/public/newsletter") + .method("POST") + .header("Content-Type", "application/json") + .body(Body::from(r#"{"email":"not-an-email"}"#)) + .unwrap(), + )) + .await + .unwrap(); + + assert_eq!(invalid_response.status(), StatusCode::BAD_REQUEST); +} diff --git a/backend/tests/bug_fixes.rs b/backend/tests/bug_fixes.rs index 5f479c00..752a5ac9 100644 --- a/backend/tests/bug_fixes.rs +++ b/backend/tests/bug_fixes.rs @@ -9,29 +9,45 @@ async fn test_bug_fixes() -> anyhow::Result<()> { // Run migrations manually or via a helper if available. // Since we can't easily access the migration logic from here without exposing it, // we'll simulate the schema for the relevant tables. - sqlx::query("CREATE TABLE tutorials (id TEXT PRIMARY KEY, title TEXT, description TEXT, icon TEXT, color TEXT, topics TEXT, content TEXT, version INTEGER, created_at TEXT, updated_at TEXT)") - .execute(&pool).await?; + sqlx::query( + r#"CREATE TABLE tutorials ( + id TEXT PRIMARY KEY, title TEXT, description TEXT, icon TEXT, + color TEXT, topics TEXT, content TEXT, version INTEGER, + created_at TEXT, updated_at TEXT + )"#, + ) + .execute(&pool) + .await?; // --- Verify Bug 1 & 2: Performance & Resilience --- // Insert a tutorial with INVALID JSON topics to test resilience - sqlx::query("INSERT INTO tutorials (id, title, description, icon, color, topics, content, version, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") - .bind("test-1") - .bind("Test Title") - .bind("Test Desc") - .bind("Terminal") - .bind("blue") - .bind("INVALID_JSON") // This would previously crash list_tutorials - .bind("Some content") - .bind(1) - .bind("2023-01-01") - .bind("2023-01-01") - .execute(&pool).await?; + sqlx::query( + r#"INSERT INTO tutorials ( + id, title, description, icon, color, topics, content, + version, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#, + ) + .bind("test-1") + .bind("Test Title") + .bind("Test Desc") + .bind("Terminal") + .bind("blue") + .bind("INVALID_JSON") // This would previously crash list_tutorials + .bind("Some content") + .bind(1) + .bind("2023-01-01") + .bind("2023-01-01") + .execute(&pool) + .await?; // Simulate list_tutorials query (using the new optimized query) let tutorials = sqlx::query_as::<_, Tutorial>( - "SELECT id, title, description, icon, color, topics, '' as content, version, created_at, updated_at FROM tutorials" + r#"SELECT id, title, description, icon, color, topics, '' as content, + version, created_at, updated_at + FROM tutorials"#, ) - .fetch_all(&pool).await?; + .fetch_all(&pool) + .await?; assert_eq!(tutorials.len(), 1); diff --git a/backend/tests/search_tests.rs b/backend/tests/search_tests.rs index 9faae61d..75d94ffc 100644 --- a/backend/tests/search_tests.rs +++ b/backend/tests/search_tests.rs @@ -48,8 +48,12 @@ mod search_tests { run_migrations(&pool).await.expect("run migrations"); sqlx::query( - "INSERT INTO tutorials (id, title, description, icon, color, topics, content, version) \ - VALUES ('t1', 'Bash Scripting', 'Learn bash', 'Terminal', 'from-blue-500 to-cyan-500', '[\"bash\",\"scripting\"]', 'content', 1)", + r#"INSERT INTO tutorials ( + id, title, description, icon, color, topics, content, version + ) VALUES ( + 't1', 'Bash Scripting', 'Learn bash', 'Terminal', + 'from-blue-500 to-cyan-500', '["bash","scripting"]', 'content', 1 + )"#, ) .execute(&pool) .await diff --git a/index.html b/index.html index 30c426b5..8eb2508f 100644 --- a/index.html +++ b/index.html @@ -44,7 +44,7 @@ - + @@ -67,21 +67,24 @@ - Rust Blog CMS + Zero Point – Persönlicher Blog - + - + - + @@ -95,11 +98,14 @@ - + - + @@ -119,11 +125,14 @@ - + - + @@ -138,6 +147,7 @@ +