Skip to content

feat: Migrate database from PostgreSQL to SQLite.#3

Merged
j-cray merged 65 commits intomainfrom
refactor--use-sqlite-instead-of-postgres
Mar 25, 2026
Merged

feat: Migrate database from PostgreSQL to SQLite.#3
j-cray merged 65 commits intomainfrom
refactor--use-sqlite-instead-of-postgres

Conversation

@j-cray
Copy link
Copy Markdown
Owner

@j-cray j-cray commented Mar 19, 2026

No description provided.

Copilot AI review requested due to automatic review settings March 19, 2026 18:51
@gemini-code-assist
Copy link
Copy Markdown

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request transitions the application's data persistence layer from PostgreSQL to SQLite. This strategic shift aims to reduce operational complexity, particularly for local development and simplified deployments, by embedding the database directly within the application. The changes encompass comprehensive updates across the codebase, including dependency management, database interaction logic, schema definitions, and infrastructure configurations, ensuring full compatibility and functionality with SQLite.

Highlights

  • Database Migration: The application's database has been migrated from PostgreSQL to SQLite, simplifying the database infrastructure and local development setup.
  • Dependency Updates: Rust dependencies in Cargo.lock and backend/Cargo.toml were updated to reflect the switch to SQLite, including changing the sqlx feature from postgres to sqlite and removing explicit version numbers for many transitive dependencies.
  • Backend Code Adjustments: The backend Rust code was modified to use sqlx::SqlitePool instead of sqlx::PgPool and adjusted SQL query syntax from PostgreSQL-style $1 parameters to SQLite-style ? parameters.
  • Docker Configuration Changes: Docker Compose files (docker-compose.prod.yaml and docker-compose.yaml) were updated to remove the PostgreSQL database and migration services, configuring the backend to use a local SQLite database file instead.
  • SQL Schema and Seed Script Adaptation: The initial SQL migration script (migrations/20260110000000_initial_schema.sql) was adapted for SQLite, removing PostgreSQL-specific features like UUID extensions, TIMESTAMPTZ, NOW(), TEXT[] array types, and ENUM types. The admin seed script (migrations/20260210120000_seed_admin_password.sql) was also updated to use a hardcoded UUID.
  • Development Setup Streamlined: The scripts/setup-dev.sh script was revised to remove PostgreSQL-specific commands and now directly manages the SQLite database file, making the local development environment easier to set up.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request successfully migrates the database backend from PostgreSQL to SQLite. The changes are comprehensive, covering dependency updates, database connection logic, SQL query syntax, and Docker Compose configurations. The transition to an embedded SQLite database simplifies the deployment and local development environment by removing the need for a separate PostgreSQL service. However, there are a few areas that require attention, particularly regarding UUID generation and data type handling for enums and timestamps, to ensure data integrity and consistency.

@github-actions
Copy link
Copy Markdown

PR Review: PostgreSQL → SQLite Migration

Critical Bugs

1. Missing UUID defaults will break all inserts

migrations/20260110000000_initial_schema.sql removes DEFAULT uuid_generate_v4() but adds no replacement. SQLite has no built-in UUID generation. Any INSERT without an explicit id will fail or insert NULL into the primary key.

Fix: Use DEFAULT (lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' || substr(lower(hex(randomblob(2))),2) || '-' || substr('89ab',abs(random()) % 4 + 1, 1) || substr(lower(hex(randomblob(2))),2) || '-' || lower(hex(randomblob(6)))) or generate UUIDs in application code before every insert.

2. No migration runner in production

docker-compose.prod.yaml removes the migration service with no replacement. The app doesn't run sqlx::migrate!() on startup (main.rs). The database schema will never be applied in production.

Fix: Add sqlx::migrate!().run(&pool).await?; in main.rs after pool creation.


Performance / Reliability

3. SQLite write contention with max_connections(5)

backend/src/main.rs — SQLite only allows one concurrent writer. A pool of 5 connections will cause SQLITE_BUSY errors under any write concurrency.

Fix: Set max_connections(1) or configure WAL mode:

.pragma("journal_mode", "WAL")
.pragma("busy_timeout", "5000")

Silent Data Failures

4. JSON/date parsing swallows errors (backend/src/api/public.rs)

// tags: serde_json::from_str(&s).ok()  — silently returns None on corrupt data
// published_at: row.try_get("published_at").unwrap_or_default()  — silently returns epoch on failure

Both will silently hide data integrity issues. Consider logging the errors at minimum.


Minor

5. Unquoted variable in shell script (scripts/setup-dev.sh:60)

cargo sqlx migrate run -D $DATABASE_URL  # should be "$DATABASE_URL"

6. Hardcoded DATABASE_URL in prod compose (docker-compose.prod.yaml)

DATABASE_URL=sqlite://data/sqlite.db  # not using an env var like the old postgres config

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR migrates the application’s persistence layer from a Postgres-backed setup (via Docker Compose) to an embedded SQLite database, updating runtime configuration, migrations, and SQLx pool usage accordingly.

Changes:

  • Switch backend SQLx pool/queries from PgPool to SqlitePool, and create SQLite DB files if missing.
  • Update SQL migrations/schema to be SQLite-compatible (timestamps, arrays/enums → text).
  • Remove Postgres services from compose files and adjust dev/prod setup to use a local DB file.

Reviewed changes

Copilot reviewed 10 out of 11 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
scripts/setup-dev.sh Removes Postgres container startup; runs SQLite migrations and seeds admin user via sqlite3.
migrations/20260110000000_initial_schema.sql Converts schema to SQLite-friendly types/defaults (DATETIME, TEXT for arrays/enums).
migrations/20260210120000_seed_admin_password.sql Seeds/updates admin password with a fixed UUID for SQLite.
docker-compose.yaml Removes the standalone Postgres service definition.
docker-compose.prod.yaml Switches production DATABASE_URL to SQLite and mounts /app/data for DB persistence.
backend/src/state.rs AppState pool type changed from PgPool to SqlitePool.
backend/src/main.rs Uses SqliteConnectOptions/SqlitePoolOptions and creates DB if missing; runs migrations on startup.
backend/src/api/public.rs Updates query row mapping/types for SQLite; adds JSON parsing for tags.
backend/src/api/admin.rs Updates parameter binding placeholders from $n to ? and switches to SqlitePool.
backend/Cargo.toml Enables sqlx sqlite feature (replaces postgres).
Cargo.toml Removes the migration workspace member.
Cargo.lock Dependency graph updated due to DB/backend changes.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@github-actions
Copy link
Copy Markdown

PR Review: PostgreSQL → SQLite Migration

Critical Issues

1. No UUID defaults — all inserts will fail

migrations/20260110000000_initial_schema.sql

-- Every table has this pattern:
id UUID PRIMARY KEY,   -- No DEFAULT!

PostgreSQL had DEFAULT uuid_generate_v4(). SQLite has no equivalent built-in. Any INSERT that omits id will fail with a NOT NULL constraint violation. The schema needs either DEFAULT (lower(hex(randomblob(4))) || '-' || ...) or the application code must generate and bind UUIDs on every insert. Verify all write paths supply an explicit id.

2. SQLite write contention — pool size too large

backend/src/main.rs:43

let pool = SqlitePoolOptions::new()
    .max_connections(5)   // SQLite = 1 writer at a time
    .connect_with(connect_options)

SQLite allows only one concurrent writer. Without WAL mode and a busy timeout, concurrent requests will return SQLITE_BUSY errors. Fix:

use sqlx::sqlite::SqliteJournalMode;
use std::time::Duration;

let connect_options = SqliteConnectOptions::from_str(&database_url)?
    .create_if_missing(true)
    .journal_mode(SqliteJournalMode::Wal)
    .busy_timeout(Duration::from_secs(5));

let pool = SqlitePoolOptions::new()
    .max_connections(1)  // or keep higher only with WAL mode
    .connect_with(connect_options)

3. UUID decoding is fragile

backend/src/api/public.rs:28,50

id: row.get::<uuid::Uuid, _>("id"),

SQLite stores UUID-typed columns as TEXT (SQLite ignores type names). The sqlx SQLite driver can decode UUIDs from TEXT, but only if they're stored in the canonical hyphenated format. Since there's no enforcement at the DB layer, any malformed UUID stored there will panic at runtime (.get() panics on decode failure). Use .try_get() and handle the error, or store UUIDs as BLOB with UNHEX.

High Severity

4. Silent datetime error suppression

backend/src/api/public.rs:55

published_at: row.try_get("published_at").unwrap_or_default(),

unwrap_or_default() on a DateTime gives epoch (1970-01-01), masking real schema or data issues. Log the error or propagate it.

5. Shell injection / unquoted variable

scripts/setup-dev.sh

cargo sqlx migrate run -D $DATABASE_URL  # unquoted

Should be "$DATABASE_URL". Also the SQLite insert uses unquoted $ADMIN_UUID interpolated into a SQL string — safe here since uuidgen output is controlled, but the pattern is fragile.

Minor

6. Empty volumes: in docker-compose.yaml — syntactically valid but misleading; should be removed entirely.

7. Hardcoded fallback UUID in setup script (dfbfb952-...) means any dev without uuidgen gets the same admin UUID. Low risk in dev, but worth noting.


Summary of must-fixes before merge:

  1. Add UUID generation defaults to the schema (or audit all insert call sites)
  2. Enable WAL mode + busy timeout in connection options
  3. Replace .get::<uuid::Uuid, _>() with .try_get() and handle errors

@github-actions
Copy link
Copy Markdown

PR Review: PostgreSQL → SQLite Migration

Critical Bugs

1. UUID deserialization will panic at runtime (backend/src/api/public.rs)

SQLite stores UUIDs as TEXT. The row.get::<uuid::Uuid, _>("id") calls will fail because sqlx's SQLite driver cannot directly decode a text UUID string into uuid::Uuid — you need to go through String first:

// Current (will panic):
id: row.get::<uuid::Uuid, _>("id"),

// Fix:
id: row.get::<String, _>("id").parse().unwrap_or_default(),
// or use sqlx::types::Uuid with the "uuid" feature properly configured

2. SQLite write contention under any concurrency (backend/src/main.rs:46)

max_connections(5) with SQLite's default journal mode will produce SQLITE_BUSY errors. Add WAL mode:

let connect_options = SqliteConnectOptions::from_str(&database_url)?
    .create_if_missing(true)
    .journal_mode(SqliteJournalMode::Wal);

3. No UUID defaults in schema (migrations/20260110000000_initial_schema.sql)

All tables drop their DEFAULT uuid_generate_v4() with no replacement. Any INSERT without an explicit id will fail with a NOT NULL violation. Application code must generate all UUIDs — this needs to be verified across all insert paths.


Security Issues

4. Shell injection vector in setup script (scripts/setup-dev.sh:57-58)

The $ADMIN_UUID variable is interpolated directly into a shell-constructed SQL string:

sqlite3 sqlite.db "INSERT INTO users (id, ...) VALUES ('$ADMIN_UUID', ...)"

While uuidgen output is safe, this anti-pattern is risky if the variable ever comes from user input. Use sqlite3's -cmd or a heredoc with proper quoting instead.

5. Hardcoded fallback UUID (scripts/setup-dev.sh:57)

ADMIN_UUID=$(uuidgen 2>/dev/null || echo "dfbfb952-b8ec-4bd8-b1aa-ed154109addf")

Every installation without uuidgen gets the same admin UUID. If the app performs UUID-based lookups or logs, this leaks a predictable identifier.


Other Issues

6. Relative SQLite path in production (docker-compose.prod.yaml:7)

DATABASE_URL=sqlite://data/sqlite.db

This is relative to the working directory, which may not be /app. Use sqlite:////app/data/sqlite.db (3 slashes for absolute path) to be explicit.

7. Invalid docker-compose syntax (docker-compose.yaml)

An empty volumes: key with no entries is invalid YAML for docker-compose and will fail to parse on some versions.

8. Silent failure for published_at (backend/src/api/public.rs:61)

published_at: row.try_get("published_at").unwrap_or_default(),

If there's a type mismatch (e.g., SQLite DATETIME text not parsing to chrono::DateTime), this silently returns the epoch. Log or propagate the error instead.

@github-actions
Copy link
Copy Markdown

PR Review: PostgreSQL → SQLite Migration

Critical: SQLite connection pool will deadlock under load

backend/src/main.rs:

let pool = SqlitePoolOptions::new()
    .max_connections(5)  // ❌ SQLite only allows one concurrent writer

SQLite has a single-writer lock. With 5 connections and any concurrent writes, you'll get SQLITE_BUSY/locked errors immediately. Fix:

.max_connections(1)
// Or enable WAL mode via connect_options:
.journal_mode(SqliteJournalMode::Wal)
.max_connections(5) // WAL allows concurrent reads

Critical: Production migrations never run

The migration service was removed from docker-compose.prod.yaml without a replacement. The app creates the DB file via create_if_missing(true) but never runs sqlx migrate run. All tables will be missing on first deployment.

Fix: either call sqlx::migrate!().run(&pool).await? in main.rs, or restore a migration step.


Bug: UUID column affinity in SQLite

migrations/20260110000000_initial_schema.sql:

id UUID PRIMARY KEY,  -- ❌ SQLite has no UUID type; affinity becomes NUMERIC

SQLite's affinity rules don't recognize UUID, so it becomes NUMERIC. This can silently corrupt UUID strings (e.g., coercing them to numbers). Use TEXT PRIMARY KEY instead.


Bug: Silent migration failure in dev script

scripts/setup-dev.sh:

cargo sqlx migrate run -D $DATABASE_URL || true  # ❌ silences all errors

Also, $DATABASE_URL is unquoted (word-splitting risk). Use:

cargo sqlx migrate run --database-url "$DATABASE_URL" || echo "⚠️ Migration failed"

Security: SQL injection via unquoted shell variable

scripts/setup-dev.sh:

sqlite3 sqlite.db "INSERT INTO users ... VALUES ('$ADMIN_UUID', ...)"

$ADMIN_UUID is injected directly into the SQL string. While uuidgen output is safe in practice, the pattern is dangerous. Use parameterized input or pass via stdin:

sqlite3 sqlite.db <<SQL
INSERT INTO users (id, username, password_hash) VALUES ('$ADMIN_UUID', 'admin', '...')
SQL

Or better, use sqlite3's -cmd with explicit quoting.


Minor: Relative DATABASE_URL path in production

docker-compose.prod.yaml:

DATABASE_URL=sqlite://data/sqlite.db  # relative path

This depends on the process working directory at runtime. Prefer an absolute path matching the volume mount: sqlite:////app/data/sqlite.db.


Minor: Broken docker-compose.yaml

volumes:
  # empty — invalid/confusing

The empty volumes: key is harmless but should be removed entirely.


Summary of severity:

Issue Severity
max_connections(5) with SQLite writes Critical
Migrations never run in production Critical
UUID column affinity (NUMERIC) High
Silent migration failure Medium
Shell SQL injection Medium
Relative DB path Low

… and `MediaContext` enums and configure their `serde` renaming.
@github-actions
Copy link
Copy Markdown

PR Review: PostgreSQL → SQLite Migration

Critical Issues

1. No migrations in production (data loss risk)

docker-compose.prod.yaml removes the migration service, but main.rs never calls sqlx::migrate!(). Tables will never be created on a fresh deployment.

// backend/src/main.rs — add after pool creation:
sqlx::migrate!("../migrations")
    .run(&pool)
    .await
    .map_err(|e| format!("Migration failed: {}", e))?;

2. SQLite WAL mode not configured (backend/src/main.rs)

With max_connections(5) and default journal mode, concurrent requests will hit SQLITE_BUSY / "database is locked" errors immediately. WAL mode is mandatory for any web workload:

let connect_options = SqliteConnectOptions::from_str(&database_url)?
    .create_if_missing(true)
    .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
    .synchronous(sqlx::sqlite::SqliteSynchronous::Normal);

3. Invalid docker-compose.yaml

volumes:
  # empty — invalid YAML, will fail to parse

Either remove the volumes: key entirely or add a placeholder comment above it, not below an empty key.


Important Issues

4. Panicking row.get inside .map() (backend/src/api/public.rs)

row.get::<uuid::Uuid, _>("id") panics on type conversion failure. SQLite stores UUIDs as TEXT; a malformed value will crash the request handler. Use try_get and propagate the error:

// Risk: panics if UUID text is malformed
id: row.get::<uuid::Uuid, _>("id"),

5. UUID generation correctness (migrations/20260210120000_seed_admin_password.sql)

The hand-rolled UUID expression is structurally correct for v4 format but hardcodes variant bits as a (only one of the four valid variant values: 8, 9, a, b). Minor, but worth noting.

6. Silent error swallowing in API handlers

list_articles and list_blog_posts return empty JSON arrays on any database error without logging. Users see no content; operators see no signal. At minimum, add tracing::error! before returning the empty vec.


Minor Issues

  • cargo sqlx migrate run -D $DATABASE_URL in setup-dev.sh is unquoted — quote it.
  • published_at: row.try_get("published_at").unwrap_or_default() silently substitutes Unix epoch for missing timestamps.
  • PRAGMA foreign_keys = ON is not set anywhere — SQLite doesn't enforce FK constraints by default.

@github-actions
Copy link
Copy Markdown

Critical Issues

1. DateTime<Utc> Deserialization Will Panic at Runtime

migrations/20260110000000_initial_schema.sql + backend/src/api/public.rs

SQLite's CURRENT_TIMESTAMP produces "2026-03-19 10:30:00" (no timezone). The sqlx SQLite driver expects RFC3339 ("2026-03-19T10:30:00Z") to decode into chrono::DateTime<Utc>. This will cause a runtime panic on every row fetch.

Fix: Use CURRENT_TIMESTAMP stored via application code, or store as TEXT with RFC3339 format and use NaiveDateTime internally, or switch all published_at/created_at fields to INTEGER (Unix epoch) with #[sqlx(rename)].

2. SQLite Write Concurrency — Database Locked Errors

backend/src/main.rs:52

let pool = SqlitePoolOptions::new()
    .max_connections(5)   // ← bug for SQLite

SQLite allows only one writer at a time. With 5 connections, concurrent writes will hit SQLITE_BUSY/database is locked. Set max_connections(1) or configure journal_mode=WAL (which allows one writer + multiple readers) and set busy_timeout.

3. UUIDs Have No Database Default — Silent Insert Failures

migrations/20260110000000_initial_schema.sql

All tables removed DEFAULT uuid_generate_v4() but kept id UUID PRIMARY KEY with no default. Any INSERT that omits id will fail with a NOT NULL constraint. SQLite doesn't auto-generate UUIDs. The application must supply UUIDs on every insert — verify every insert path does this.

4. Invalid docker-compose.yaml — Empty volumes: Key

docker-compose.yaml

volumes:
  # (empty)

An empty volumes: mapping is invalid in some compose implementations and will cause parse errors. Remove the key entirely.

5. Shell Injection Risk in setup-dev.sh

scripts/setup-dev.sh:62

sqlite3 sqlite.db "INSERT INTO users ... VALUES ('$ADMIN_UUID', ..."

$ADMIN_UUID is interpolated directly into the SQL string without quoting. While uuidgen output is safe in practice, $DATABASE_URL on line 56 is also unquoted:

cargo sqlx migrate run -D $DATABASE_URL || true  # ← unquoted

Quote both: "$DATABASE_URL" and use a heredoc or parameterized approach for the sqlite3 call.

Non-Critical Issues

Silent tag deserialization failure (backend/src/api/public.rs:55): serde_json::from_str(&s).ok() silently drops malformed tag data as None. Consider logging the error.

Hardcoded fallback UUID (setup-dev.sh:60): If uuidgen is unavailable, all dev installs share dfbfb952-... as the admin ID. Fine for dev, but document it.

j-cray added 2 commits March 19, 2026 12:41
…601 timestamp defaults, and improve dev setup script.
…nt `uuidgen` fallback behavior in the dev setup script.
@github-actions
Copy link
Copy Markdown

PR Review: PostgreSQL → SQLite Migration

Overall this is a clean migration, but there are several issues ranging from critical to moderate.


Critical

1. No migration runner in production (docker-compose.prod.yaml)

The migration service was removed with no replacement. There's no mechanism to run sqlx migrate run when the app starts. On a fresh deployment the database will be empty and the app will fail at runtime. You need either sqlx::migrate!() called in main.rs on startup, or an entrypoint script.

// backend/src/main.rs — add after pool creation
sqlx::migrate!("../migrations")
    .run(&pool)
    .await
    .map_err(|e| format!("Migration failed: {}", e))?;

2. Missing UUID defaults on all primary keys (migrations/20260110000000_initial_schema.sql)

Removing DEFAULT uuid_generate_v4() without a SQLite equivalent means any INSERT that omits id will hit a NOT NULL constraint violation. SQLite has no built-in UUID function. Either generate UUIDs in application code before every insert, or add a trigger:

-- Option: generate in app code (preferred with sqlx)
-- Option: use a trigger per table
CREATE TRIGGER set_users_id AFTER INSERT ON users
WHEN NEW.id IS NULL
BEGIN UPDATE users SET id = lower(hex(randomblob(16))) WHERE rowid = NEW.rowid; END;

Significant

3. SQLite connection pool too large (backend/src/main.rs)

.max_connections(5)  // problematic for SQLite

SQLite serializes all writers regardless of WAL mode. Five pooled connections increase write lock contention without benefit. For SQLite, max_connections(1) for writes or at most 2–3 is appropriate. The 5-second busy_timeout helps but doesn't eliminate queuing.

4. Fragile relative path in Docker (docker-compose.prod.yaml)

DATABASE_URL=sqlite://data/sqlite.db   # relative path
volumes:
  - ./data:/app/data

sqlite://data/sqlite.db resolves relative to the process's working directory. If the binary's CWD is not /app, the path won't match the volume mount and the DB file will be created in the wrong location (or not found after restart). Use an absolute path: sqlite:////app/data/sqlite.db.


Moderate

5. Shell injection risk in setup-dev.sh

ADMIN_UUID=$(uuidgen 2>/dev/null || echo "dfbfb952-...")
sqlite3 sqlite.db <<EOF
INSERT INTO users (id, ...) VALUES ('${ADMIN_UUID}', ...
EOF

${ADMIN_UUID} is interpolated directly into SQL. If uuidgen is somehow replaced or produces unexpected output (e.g. a UUID with a ' character from a malformed tool), this breaks. Use printf '%q' or pass via SQLite's -cmd with parameter binding. Low real-world risk but worth fixing.

6. docker-compose.yaml is structurally invalid

version: "3.8"

# services:
  # The web and backend services will now use a local SQLite DB file.

There's no services key. This will cause docker compose to error. Either delete the file or leave a minimal valid stub.


Minor

7. list_articles / list_blog_posts silently swallow errors (backend/src/api/public.rs)

This predates the PR but remains — on fetch_all failure the handlers return an empty Json(vec![]) with a 200 OK. Errors are logged but the client has no way to distinguish "no data" from "query failed". Consider returning a 500 on DB errors.

@github-actions
Copy link
Copy Markdown

PR Review: PostgreSQL → SQLite Migration

Critical Issues

1. Schema missing UUID defaults — runtime INSERT failures

migrations/20260110000000_initial_schema.sql removes DEFAULT uuid_generate_v4() but doesn't add a SQLite equivalent:

id UUID PRIMARY KEY,  -- No default! Any INSERT without explicit id will fail

Any application-level INSERT that doesn't generate and supply a UUID will fail at runtime. Every insert path needs auditing to ensure it generates a UUID before inserting.

2. docker-compose.yaml left in broken state

After the diff, the file has:

version: "3.8"

# services:
  # The web and backend services will now use a local SQLite DB file.

No valid services: block exists, so docker-compose up will error. Either remove this file or add the web service definition.

3. UUID deserialization from SQLite is fragile

backend/src/api/public.rs:

id: row.get::<uuid::Uuid, _>("id"),

SQLite has no native UUID type — UUIDs are stored as TEXT or BLOB depending on the driver. The sqlx SQLite driver's UUID decode requires the value to be stored in a specific format (typically xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx as text). If the format ever drifts (e.g., uppercase, no dashes), this will panic. Prefer decoding as String and parsing explicitly:

id: row.get::<String, _>("id").parse::<uuid::Uuid>().unwrap_or_default(),

Or better, return an error rather than unwrapping.

Moderate Issues

4. SQLite relative path is deployment-fragile

docker-compose.prod.yaml:

DATABASE_URL=sqlite://data/sqlite.db

This is relative to the process working directory. If the working directory ever changes (entrypoint, exec, etc.), the database silently opens/creates a new file. Use an absolute path: sqlite:////app/data/sqlite.db.

5. Shell heredoc interpolation without validation

scripts/setup-dev.sh:

sqlite3 sqlite.db <<EOF
INSERT INTO users ... VALUES ('${ADMIN_UUID}', ...
EOF

${ADMIN_UUID} is interpolated into SQL without quoting. While uuidgen output is safe, the fallback hardcoded UUID and lack of quoting is a bad pattern. Use parameterized input or at minimum quote the value: '${ADMIN_UUID}' (already done) — but validate the format before interpolating.

6. Write contention under load

max_connections(5) with SQLite WAL allows only one concurrent writer. The busy_timeout(5s) helps but concurrent write bursts will queue. For a personal site this is likely fine, but worth documenting as a known limitation.

Minor

  • The migration_tool workspace member was removed from Cargo.toml but the migration directory still exists — consider cleaning it up or documenting the manual migration path.
  • The seed migration's hardcoded a prefix for the UUID variant nibble ('-a' || ...) is technically valid (RFC 4122 allows [89ab]) but using lower(hex(randomblob(1) & X'3f' | X'80')) would be more correct.

…, image linking, paragraph wrapping, and admin article editing capabilities.
@github-actions
Copy link
Copy Markdown

Code Review: PostgreSQL → SQLite Migration

Critical Bugs

1. UUID Triggers Are Dead Code (migration SQL)

id UUID PRIMARY KEY enforces NOT NULL, so WHEN NEW.id IS NULL can never be true — the INSERT fails before the trigger fires. Every INSERT without an explicit id will raise a constraint error.

-- This trigger never fires because PRIMARY KEY rejects NULL
CREATE TRIGGER set_users_id AFTER INSERT ON users
WHEN NEW.id IS NULL
BEGIN UPDATE users SET id = lower(hex(randomblob(16))) WHERE rowid = NEW.rowid; END;

2. UUID Storage Format Mismatch

The seed migration stores UUIDs as hyphenated strings (e.g., a1b2c3d4-...), but the triggers (if they ever fired) would store raw 32-char hex (a1b2c3d4ef...). Meanwhile, sqlx with the uuid feature encodes uuid::Uuid bindings as 16-byte BLOBs for SQLite. This means:

  • row.get::<uuid::Uuid, _>("id") in public.rs will likely panic or return garbage for TEXT-stored UUIDs.
  • .bind(user_id) in admin.rs (binding a uuid::Uuid) sends a BLOB, but the stored value is TEXT — the WHERE id = ? query silently returns no rows.

The fix is to pick one representation and be consistent. Using sqlx's BLOB encoding everywhere is safest, but requires the migration to insert raw bytes, not strings.

3. Hardcoded JWT Secret

fn get_jwt_secret() -> &'static [u8] {
    // In production, use environment variable...
    b"change-me-in-production"
}

This is exploitable as-is. The env var read is commented out. Forging a valid admin JWT requires only knowing this public string. This needs to be fixed before any deployment, not left as a TODO comment.


Security Issues

4. Hardcoded Fallback UUID in Setup Script

ADMIN_UUID=$(uuidgen 2>/dev/null || echo "dfbfb952-b8ec-4bd8-b1aa-ed154109addf")

The comment acknowledges this "results in shared admin IDs across affected dev installs." Even for dev, a static UUID for an admin account is a bad pattern — it enables ID enumeration and is easy to accidentally carry into staging/prod images.


Bugs

5. DATABASE_URL Format Inconsistency

Three different formats are used across the project:

File Value
.envrc sqlite:sqlite.db
setup-dev.sh sqlite://sqlite.db
docker-compose.prod.yaml sqlite://data/sqlite.db

SQLx accepts both sqlite: and sqlite://, but the inconsistency is a maintenance hazard. More critically, sqlite://data/sqlite.db in the container is a relative path from the working directory — if the app doesn't run from /app, the database file won't be at the mounted volume path /app/data/sqlite.db.

6. Silent Data Loss on list_blog_posts Tag Deserialization

let tags = tags_str.and_then(|s| match serde_json::from_str(&s) {
    Ok(t) => Some(t),
    Err(e) => {
        tracing::warn!("Failed to deserialize tags: {}", e);
        None  // tags silently dropped
    }
});

A malformed tags column returns None with no API-level error. The client receives a post with tags: null and no indication that data is corrupt. Consider returning an error or a dedicated sentinel.


Performance

7. SQLite Connection Pool Size

let pool = SqlitePoolOptions::new()
    .max_connections(5)
    .connect_with(connect_options)

SQLite allows only one concurrent writer. With 5 connections, write requests will queue and hit the 5-second busy_timeout. For a low-traffic personal site this is fine, but for correctness under load, consider max_connections(1) or separate reader/writer pools (sqlx supports this via SqlitePoolOptions::new().max_write_delay()).

j-cray added 3 commits March 19, 2026 13:04
…ions, and refine frontend code and Docker configurations.
…API queries, remove docker-compose stub, and add dev setup validation.
…e triggers, and enforce JWT secret via environment variables.
@github-actions
Copy link
Copy Markdown

Code Review

Critical Security Bug: me Endpoint Auth Bypass

backend/src/api/admin.rs, me handler:

async fn me(headers: HeaderMap) -> Result<&'static str, StatusCode> {
    let token = headers
        .and_then(|s| s.strip_prefix("Bearer "))
        .ok_or(StatusCode::UNAUTHORIZED)?;
    Ok("Authenticated")  // ← never validates the token!
}

The handler only checks that a Bearer prefix exists. It never decodes or verifies the JWT signature/expiry. Any arbitrary string like Bearer fake will return 200 OK. Apply the same jsonwebtoken::decode pattern used in change_password.


Critical Performance Bug: max_connections(1)

backend/src/main.rs:

let pool = SqlitePoolOptions::new()
    .max_connections(1)  // ← single connection for all requests
    .connect_with(connect_options)

SQLite in WAL mode supports multiple concurrent readers. With max_connections(1), every request — including reads — queues behind one connection. The README incorrectly documents this as max_connections(5). Set to 4–8 for WAL mode: readers can run concurrently; writers auto-queue.


Security: JWT_SECRET Panics at Runtime, Not Startup

backend/src/api/admin.rs:

fn get_jwt_secret() -> Vec<u8> {
    std::env::var("JWT_SECRET")
        .expect("JWT_SECRET environment variable must be set")
        .into_bytes()
}

The application starts successfully without JWT_SECRET, then panics on the first login attempt. Validate it in main() at startup (e.g., alongside DATABASE_URL), so the process fails fast rather than crashing under load.


Security: No Request Body Size Limit

backend/src/api/admin.rs, login handler:

The body is read with to_bytes without a maximum size. An attacker can send a multi-gigabyte body to exhaust memory. Axum's to_bytes accepts a limit parameter:

let bytes = to_bytes(body, 1024 * 16).await  // e.g. 16KB max
    .map_err(|_| (StatusCode::BAD_REQUEST, "Body too large".to_string()))?;

Security: Filename Not Sanitized Against Path Traversal

frontend/src/api/articles.rs, upload_media:

let safe_name = format!("{}_{}", timestamp, filename.replace(" ", "_"));

Only spaces are replaced. A filename like ../../etc/passwd or one containing %2F encoded slashes passes through and becomes part of the GCS destination path. Filter to alphanumerics, dots, hyphens, and underscores only:

let safe_name = format!("{}_{}", timestamp,
    filename.chars().filter(|c| c.is_alphanumeric() || matches!(c, '.' | '-' | '_')).collect::<String>());

Minor: get_jwt_secret() Reads Env Var on Every Auth Request

Once startup validation is added, cache the secret with std::sync::OnceLock or once_cell::sync::Lazy to avoid repeated env lookups and heap allocations per request.


Informational: SQLite UUID Generation in Migration Is Fragile

migrations/20260210120000_seed_admin_password.sql:

The raw SQLite expression for UUID v4 generation is correct in behavior but brittle. The bitwise ops (randomblob(1) & X'3f' | X'80') rely on SQLite's implicit blob→integer coercion. If this migration ever needs auditing or porting, the intent is non-obvious. Since the setup-dev.sh script already handles UUID generation externally, consider whether this migration UUID expression is even needed in production (where the admin row would exist already or be inserted differently).

…enames, validate JWT in the `me` endpoint, increase database connection pool size, and update admin seed ID generation.
…lic API rate limits, and refactor database row mapping.
@github-actions
Copy link
Copy Markdown

PR Review: PostgreSQL → SQLite + Rate Limiting

Critical Issues

1. get_dummy_hash() lazily initialized — timing leak on first bad login

shared/src/auth.rs + backend/src/api/admin.rs:79

The dummy hash is computed on the first call (first login with a non-existent username). That first request takes full Argon2 time to initialize the hash and verify against it, while all subsequent requests only verify. This creates a measurable timing difference for the very first probe. Pre-compute it in main():

// main.rs, after init_jwt_secret()
let _ = crate::api::admin::init_dummy_hash(); // expose a pub fn that calls get_dummy_hash()

2. setup-dev.sh — SQL injection risk in sqlite3 param binding

scripts/setup-dev.sh:88-91

sqlite3 sqlite.db -cmd ".param set @id '$SAFE_UUID'" -cmd ".param set @hash '$ESCAPED_HASH'" \
  "INSERT INTO users ..."

The $ characters in the argon2 hash ($argon2id$v=19$m=...) are expanded by bash before the string reaches sqlite3, because the outer string uses double quotes. Undefined shell variables expand to empty, silently corrupting the hash. Fix:

sqlite3 sqlite.db \
  "INSERT INTO users (id, username, password_hash) VALUES ('$SAFE_UUID', 'admin', '$ESCAPED_HASH') ON CONFLICT (username) DO NOTHING;"

The argon2 PHC format contains no single-quotes, so the existing //\'/\'\' escape is sufficient.

3. Separate per-endpoint rate limit buckets

backend/src/api/public.rs:7-27

articles_governor_layer and blog_governor_layer are independent instances. A client gets 20 burst tokens on /api/articles and 20 on /api/blog = 40 total burst requests. If the intent is a shared budget, the same Arc<GovernorConfig> should be used for both routes.


Security Issues

4. No username length check in login

backend/src/api/admin.rs:254

Password length is capped at 128 bytes, but req.username has no bound. The query is parameterized (no SQLi), but a multi-megabyte username can pass through to the DB layer before being rejected. Add:

if req.username.len() > 64 {
    return Err((StatusCode::BAD_REQUEST, "Username too long".to_string()));
}

5. TrustedProxyIpKeyExtractor defined in admin.rs, used in public.rs

backend/src/api/public.rs:13

.key_extractor(crate::api::admin::TrustedProxyIpKeyExtractor)

This creates an implicit coupling between two API modules. Move TrustedProxyIpKeyExtractor to backend/src/api/mod.rs or a dedicated rate_limit.rs.


Bug

6. articles and blog routes both missing from the original router

backend/src/api/public.rs (old code, pre-diff) had list_blog_posts as a handler but was not wired into the router. The new PR adds /api/blog — but double-check the original router didn't silently drop this route and that integration tests cover it.


Notes (Low Severity)

  • burst_size(1) on login is aggressive — a single accidental fast retry locks out the admin for 1 second. Fine for a personal site, but worth documenting.
  • The cursor-based before param silently wins over offset when both are provided. A 400 Bad Request when both are supplied would be clearer to API consumers.
  • Good practice: the dummy hash approach, #[inline(never)] on verify_password, OnceLock for Argon2/JWT, and init_jwt_secret() at startup are all correct and well-considered.

…lidation for query parameters and username length, and initialize a dummy hash at startup.
@github-actions
Copy link
Copy Markdown

Code Review

Critical: Cursor pagination timestamp format mismatch

backend/src/api/public.rs

if chrono::DateTime::parse_from_rfc3339(&before).is_err() {
    return Err(axum::http::StatusCode::BAD_REQUEST);
}
sqlx::query("...WHERE published_at < ?").bind(before)...

The DB stores dates as strftime('%Y-%m-%dT%H:%M:%fZ', 'now') → e.g. 2026-01-01T12:00:00.000Z. A valid RFC3339 before value like 2026-01-01T12:00:00Z (no millis) or 2026-01-01T12:00:00+00:00 (offset form) will produce wrong lexicographic comparisons in SQLite since these are stored as TEXT. Normalize the bound value to the stored format:

let dt = chrono::DateTime::parse_from_rfc3339(&before)
    .map_err(|_| StatusCode::BAD_REQUEST)?
    .to_utc();
let normalized = dt.format("%Y-%m-%dT%H:%M:%6f").to_string() + "Z";
sqlx::query("...WHERE published_at < ?").bind(normalized)...

Bug: Shared rate limiter state across two routes

backend/src/api/public.rs

let common_governor_config = std::sync::Arc::new(...);
let articles_governor_layer = tower_governor::GovernorLayer { config: common_governor_config.clone() };
let blog_governor_layer    = tower_governor::GovernorLayer { config: common_governor_config.clone() };

Because both layers share the same Arc<GovernorConfig>, they share one in-memory counter store. A client hammering /api/articles will exhaust their allowance for /api/blog and vice versa. Give each route its own GovernorConfig instance if independent limiting is intended.


Security: Constant-time guarantee is incomplete

backend/src/api/admin.rs

#[inline(never)]
fn verify_password(password: &str, password_hash: &str) -> bool {
    ...
    get_argon2().verify_password(password.as_bytes(), &parsed_hash).is_ok()
}

#[inline(never)] prevents the call from being inlined but does not make the function constant-time. The real protection here comes from argon2's internal use of the subtle crate for the final hash comparison, which is correct. However, PasswordHash::new parsing can fail early and return before reaching Argon2, so a malformed stored hash would leak timing. Consider logging a hard error and returning the dummy-hash path if parsing fails, rather than return false.


Security: Production startup check misses IPv6 private ranges

backend/src/main.rs

if let Ok(std::net::IpAddr::V4(v4)) = ip_str.parse::<std::net::IpAddr>() {
    // checks 10.x, 172.16-31.x, 192.168.x
}

IPv6 private ranges (fc00::/7, ::1) are silently skipped; the warning about ephemeral Docker IPs won't fire for IPv6-configured proxy containers. Add an IpAddr::V6 arm or use the is_loopback() / is_private() stabilized methods where available.


Bug: Identical rate limiter configs duplicated

backend/src/api/admin.rs

login_governor_layer and password_governor_layer are constructed with identical parameters (1 req/s, burst 1). They should share an Arc<GovernorConfig> (same as public.rs already does) or be collapsed into one layer applied to both routes. As-is they're separate counters, which is likely not the intended behavior — a client can interleave login and password-change attempts to double the effective rate.


Minor: me endpoint exposes internal user ID

backend/src/api/admin.rs

Ok(Json(serde_json::json!({
    "authenticated": true,
    "user_id": token_data.claims.sub   // leaks DB primary key
})))

The sub claim (SQLite TEXT UUID) is now returned in the response body. The client already holds the JWT and can decode sub itself — returning it here is unnecessary and widens the attack surface if other endpoints are added that accept a user ID.

…sted proxy IP detection to include IPv6, and standardize date query parameter formatting.
@github-actions
Copy link
Copy Markdown

Code Review

Critical Bug — setup-dev.sh: Argon2 hash mangled by bash variable expansion

ESCAPED_HASH="${ADMIN_HASH//\'/\'\'}"
sqlite3 sqlite.db \
  "INSERT INTO users ... VALUES ('$SAFE_UUID', 'admin', '$ESCAPED_HASH') ..."

The Argon2 hash contains literal $ characters (e.g. $argon2id$v=19$m=...). Inside a double-quoted bash string, these are treated as variable expansions. $argon2id, $v, $m etc. all expand to empty strings, corrupting the hash. The || echo "⚠️ Could not create user" suppresses the error, so this fails silently.

Fix: Use printf to build the SQL or pass the hash via a heredoc with a quoted delimiter:

sqlite3 sqlite.db "$(printf "INSERT INTO users (id, username, password_hash) VALUES ('%s', 'admin', '%s') ON CONFLICT (username) DO NOTHING;" \
  "$SAFE_UUID" "$(printf '%s' "$ADMIN_HASH" | sed "s/'/''/g")")"

Bug — before cursor format mismatch causes incorrect pagination (public.rs)

let normalized = dt.format("%Y-%m-%dT%H:%M:%6f").to_string() + "Z";
// e.g. "2026-01-01T12:00:00.500000Z" (6 decimal digits)

SQLite's strftime('%Y-%m-%dT%H:%M:%fZ', 'now') stores millisecond precision (3 digits): "2026-01-01T12:00:00.500Z". SQLite compares timestamps as strings. When comparing "2026-01-01T12:00:00.500Z" vs "2026-01-01T12:00:00.500000Z", at the 4th decimal position 'Z' (ASCII 90) > '0' (ASCII 48), so the stored value compares greater than the 6-digit cursor. Records that should be included by WHERE published_at < ? will be silently excluded.

Fix: Use %3f to match SQLite's stored precision:

let normalized = dt.format("%Y-%m-%dT%H:%M:%3fZ").to_string();

Security — Rate-limit bypass in CDN/load-balancer scenario (api/mod.rs)

if Some(parsed_ip) == peer_ip {
    tracing::warn!("X-Forwarded-For rightmost IP {} matches the proxy peer IP...");
} else {
    tracing::debug!("Extracted client IP {} ...");
}
return Ok(parsed_ip.to_string()); // ← Executes unconditionally

When the warning fires (CDN overwriting X-Forwarded-For), the code still returns the proxy's IP as the rate-limit key, collapsing all users into a single bucket and rendering per-client rate limiting inoperative. The code should fall through to use peer_ip directly in this case, accepting that the proxy IP will be used rather than silently miscategorizing all traffic:

if Some(parsed_ip) == peer_ip {
    tracing::warn!("...");
    // fall through to peer_ip fallback below
} else {
    return Ok(parsed_ip.to_string());
}

Minor — Duplicate rate-limiter configs (public.rs)

articles_governor_layer and blog_governor_layer are identically configured (per_second(5), burst_size(20)). Extract to a shared Arc to avoid redundant allocations and keep the config DRY:

let public_governor_config = std::sync::Arc::new(
    GovernorConfigBuilder::default()
        .key_extractor(TrustedProxyIpKeyExtractor)
        .per_second(5).burst_size(20).finish().unwrap(),
);

… SQLite string escaping, and refine X-Forwarded-For IP extraction logic.
@github-actions
Copy link
Copy Markdown

PR Review: PostgreSQL → SQLite Migration with Rate Limiting

Overall this is a well-structured migration with good security hygiene. Here are the issues ranked by severity.


Critical Bugs

1. Un-gated console.log in production WASM (frontend/src/pages/sections.rs ~L401-413)

Three web_sys::console::log_1 calls are not wrapped in #[cfg(debug_assertions)], unlike the surrounding calls that were correctly gated in this PR:

web_sys::console::log_1(&"Checking auth token...".into());        // un-gated
// ...
web_sys::console::log_1(&"Admin mode enabled".into());            // un-gated
// ...
web_sys::console::log_1(&"No token found in localStorage".into()); // un-gated

These will appear in production WASM builds, leaking admin session detection behavior to anyone with devtools open.


Security Issues

2. Shell SQL injection in scripts/setup-dev.sh

sqlite3 sqlite.db "$(printf "INSERT INTO users ... VALUES ('%s', 'admin', '%s') ..." \
  "$SAFE_UUID" "$(printf '%s' "$ADMIN_HASH" | sed "s/'/''/g")")"

The UUID is validated by regex and the argon2 hash is validated with =~ ^\$argon2, so the current inputs are safe. However, this is structurally fragile — any change to how ADMIN_HASH is generated that breaks the regex could silently allow injection. Prefer the heredoc .read approach with sqlite3's native variable binding:

sqlite3 sqlite.db <<SQL
INSERT INTO users (id, username, password_hash) VALUES ('$SAFE_UUID', 'admin', '$ADMIN_HASH')
ON CONFLICT (username) DO NOTHING;
SQL

Or better, use sqlite3's -cmd with a bound parameter file.

3. JWT_SECRET length check is byte-count, not character-count

shared/src/auth.rs:21:

if secret.len() < 32 {
    panic!("JWT_SECRET must be at least 32 characters long...");

str::len() returns bytes. A 16-character Unicode secret (32 bytes) passes, but the error message says "characters." Inaccurate but harmless for typical base64/ASCII secrets. Change the error message to say "bytes" or use secret.chars().count().

4. Hardcoded Docker bridge IPs in auto-generated .env (scripts/remote_build.sh)

TRUSTED_PROXY_IPS=172.18.0.2,172.18.0.3

Docker bridge IPs are reassigned on network recreation. If the container gets 172.18.0.4, the rate limiter silently falls back to rate-limiting by proxy IP, collapsing all clients into one bucket. The existing startup warning is good, but consider documenting a docker network inspect step in the deploy runbook.


Minor Issues

5. change_password shares rate-limit bucket with /login

Both routes share the same Arc<GovernorConfig> (auth_governor_config.clone()). This means 1 login attempt consumes the burst for password changes from that IP. This is actually the more conservative posture, but if it's intentional, it deserves a code comment explaining the shared-bucket design. If unintentional, create separate configs.

6. updated_at columns removed from SQLite migration

The PostgreSQL schema had updated_at on articles, blog_posts, etc. These are gone in the SQLite migration. If any future feature or audit trail needs mutation timestamps, this will require a new migration. Worth noting in the PR description.

7. before-cursor datetime precision assumption

backend/src/api/public.rs:

let normalized = dt.format("%Y-%m-%dT%H:%M:%3fZ").to_string();

This normalizes to millisecond precision before the WHERE published_at < ? comparison. If any existing rows in the database store published_at with a different precision (e.g., from an import script using %fZ = 6 decimal places), cursor-based pagination will silently skip or double-return rows. Add a migration or import-time normalization step to guarantee consistent precision.


Acknowledged Limitations (No Action Needed)

The in-memory rate limiter restart bypass, SQLite single-writer bottleneck, and deleted-user JWT validity window are all clearly documented in code comments and README. The dummy-hash timing attack mitigation with #[inline(never)] is correct.

…, refine backend login rate limits, and enable debug-only frontend logging.
@github-actions
Copy link
Copy Markdown

Code Review

Critical Bug: Shell Heredoc Corrupts Argon2 Hash (scripts/setup-dev.sh)

This is the most critical issue. The unquoted heredoc causes bash to expand $ in the Argon2 hash:

sqlite3 sqlite.db <<SQL   # ← unquoted: variable expansion occurs
INSERT INTO users (id, username, password_hash) VALUES ('$SAFE_UUID', 'admin', '$ADMIN_HASH')
SQL

An Argon2 hash looks like $argon2id$v=19$m=19456,t=2,p=1$.... Bash expands $argon2id, $v, $m, etc. as undefined variables (empty strings), silently corrupting the hash. The admin user is created with an invalid hash and login becomes impossible.

Fix: Use a quoted heredoc (<<'SQL') or pass values via SQLite's .parameter mechanism:

sqlite3 sqlite.db "INSERT INTO users (id, username, password_hash) VALUES ('$SAFE_UUID', 'admin', '$ADMIN_HASH') ON CONFLICT (username) DO NOTHING;"

Or escape the hash properly, or use printf to construct the SQL.


Security: Rate Limiter Config Duplication Creates False Isolation (backend/src/api/admin.rs)

login_governor_config and password_governor_config are created separately with identical settings (1 req/sec, burst 1), but each has its own independent in-memory counter. An attacker gets 1 req/sec against /login and 1 req/sec against /password simultaneously — effectively doubling the allowed attempt rate. If the intent is a shared budget across both endpoints, they must share the same Arc<GovernorConfig>.


Security: X-Real-IP Trusted Unconditionally Over X-Forwarded-For (backend/src/api/mod.rs)

// Priority 1: X-Real-IP
if let Some(real_ip) = req.headers().get("X-Real-IP").and_then(...) {
    return Ok(parsed_ip.to_string());
}

X-Real-IP is set by Nginx, but it is also trivially forgeable by the client if Nginx is misconfigured (e.g., proxy_set_header X-Real-IP $http_x_real_ip instead of $remote_addr). Trusting it without verifying the peer is actually the trusted proxy creates a bypass if the Nginx config changes. The is_trusted_proxy check gates the whole block, so this is only triggered when the peer is trusted — but it's worth a comment that the correctness depends entirely on Nginx configuration.


Minor Bug: Unnecessary UUID Round-Trip in change_password (backend/src/api/admin.rs)

let user_id = uuid::Uuid::parse_str(&token_data.claims.sub)...?;
// ...
.bind(user_id.to_string())  // converts back to the same string

If the UUID stored in the DB uses a different casing or format than Uuid::to_string() produces (e.g., uppercase), the WHERE id = ? query returns no rows, triggering the dummy-hash path and returning 403. Since the SQLite UUID generator produces lowercase and Uuid::to_string() also produces lowercase, this happens to be consistent — but the parse is unnecessary and the fragility is hidden. Binding &token_data.claims.sub directly (after a format check) is cleaner.


Performance: SqliteRow::try_get("published_at") May Silently Fail at Runtime (backend/src/api/public.rs)

published_at: row.try_get("published_at")?,

sqlx maps SQLite DATETIME TEXT columns to chrono::DateTime<Utc> only if the string format matches exactly what the driver expects. The migration normalizes to %Y-%m-%dT%H:%M:%3fZ, but any row inserted before the normalization migration runs (or inserted via raw SQL with a different format) will cause a decode error at query time and return HTTP 500 to all list callers. A runtime test against the actual DB after migration would catch this.


Summary

Severity Issue
Critical setup-dev.sh heredoc expands $ in Argon2 hash → invalid hash, login broken
High Separate governor configs for /login and /password double the effective attempt rate
Medium X-Real-IP trust depends entirely on Nginx config being correct — no runtime defense
Low UUID round-trip in change_password — fragile coincidence, not a correctness risk today
Low published_at decode relies on exact string format — no migration safety net

@github-actions
Copy link
Copy Markdown

Code Review

Critical Bug: Shared Rate Limiter State

backend/src/api/admin.rs

let auth_governor_config = std::sync::Arc::new(...);
let login_governor_layer = tower_governor::GovernorLayer { config: auth_governor_config.clone() };
let password_governor_layer = tower_governor::GovernorLayer { config: auth_governor_config }; // same Arc

/login and /password share the same underlying rate limiter bucket. Under a login brute-force attack, the legitimate admin is also locked out of /password. These should use separate GovernorConfigBuilder instances.


Bug: updated_at Columns Won't Auto-Update

migrations/20260325000000_add_updated_at.sql

The migration adds updated_at columns with an INSERT-time default, but no CREATE TRIGGER is defined to update them on UPDATE. SQLite requires explicit triggers for this. Currently every row's updated_at will be frozen at the time of its last insert, making the column misleading.


Security: Shell SQL Injection Risk in Setup Script

scripts/setup-dev.sh

sqlite3 sqlite.db "INSERT INTO users (id, username, password_hash) VALUES ('$SAFE_UUID', 'admin', '$ADMIN_HASH') ..."

$ADMIN_HASH is validated only with =~ ^\$argon2 (prefix check), not sanitized for SQL. Argon2 PHC strings are safe in practice, but any unexpected output from hgen (e.g., a warning to stdout) could corrupt the query or cause silent failure. Use printf with a heredoc or pass via sqlite3's stdin:

sqlite3 sqlite.db <<SQL
INSERT INTO users (id, username, password_hash) VALUES ('${SAFE_UUID}', 'admin', '${ADMIN_HASH}') ON CONFLICT (username) DO NOTHING;
SQL

Performance: Cursor Pagination Relies on String Date Comparison

backend/src/api/public.rs

WHERE published_at < ?

SQLite compares DATETIME as text. This only works correctly if all timestamps have identical format/precision. The normalization migration (20260325000001) mitigates this for existing data, but newly inserted rows that bypass the normalization (e.g., from external tools) could produce incorrect pagination results. A CHECK constraint enforcing the %Y-%m-%dT%H:%M:%3fZ format would prevent this silently breaking.


Minor: me Endpoint Exposes Logging to Peer Info Extraction Mismatch

backend/src/api/admin.rs

async fn me(
    headers: HeaderMap,
    peer_info: Option<axum::extract::ConnectInfo<std::net::SocketAddr>>,

peer_info is Option for resilience, but it is only used for logging on validation failure. Since into_make_service_with_connect_info is configured in main.rs, this is always Some in production. The Option is fine, but the warn log path silently reports IP as "unknown" rather than failing visibly — acceptable tradeoff, but worth noting.


Summary

Severity Issue Location
High Shared rate limiter between /login and /password admin.rs:router()
Medium updated_at never updates without triggers 20260325000000_add_updated_at.sql
Medium Unescaped shell SQL interpolation setup-dev.sh
Low Cursor pagination fragile to timestamp format variance public.rs
Info peer_info always Some in production, Option adds noise admin.rs:me()

…published_at` format validation, and refactor backend rate limiters and the dev setup script.
@github-actions
Copy link
Copy Markdown

Code Review

Critical: Trigger chain causes silent UPDATE failures

migrations/20260325000002_add_updated_at_triggers.sql + 20260325000003_add_published_at_check.sql interact dangerously.

When any UPDATE fires on articles, the AFTER UPDATE trigger runs UPDATE articles SET updated_at = ..., which in turn fires the BEFORE UPDATE check trigger. That check compares the row's current published_at against strftime('%Y-%m-%dT%H:%M:%fZ', published_at) — 6-decimal format.

The normalization migration (_normalize_timestamps.sql) uses %f (6 decimals), which should be consistent. However, if any row's published_at cannot be parsed by SQLite's strftime (returns NULL) or slipped through normalization, every subsequent UPDATE to that row — including title/content changes — will abort with the misleading error 'published_at must be in %Y-%m-%dT%H:%M:%3fZ format'. Note also the error message says %3f (milliseconds) but the trigger condition enforces %f (microseconds), which is a confusing mismatch for debugging.

Fix: Either make the check trigger only fire when published_at itself is being changed (WHEN OLD.published_at != NEW.published_at AND ...), or drop the check trigger in favor of application-layer validation.


Security: me endpoint logs proxy IP, not client IP

backend/src/api/admin.rs:

let ip = peer_addr.ip().to_string();
tracing::warn!("Invalid token on /me from {}: {}", ip, e);

peer_addr is the TCP connection source — behind Nginx, this is always the proxy's IP. The rate limiter correctly extracts the real client IP via TrustedProxyIpKeyExtractor, but these security-relevant warn logs will just show the proxy address, making brute-force log analysis useless.

Fix: Extract the client IP using the same header-inspection logic, or at minimum add a note that this IP is the proxy.


Security: In-memory rate limiter resets on restart (undocumented risk surface)

This is documented in a comment, but worth flagging formally: the tower_governor state is per-process. A process restart fully resets burst windows. With a burst_size(1) login limiter, an attacker who can trigger restarts (OOM, crash-loop, etc.) gets 1 free attempt per restart. The existing fail2ban recommendation in the comment should be treated as required, not advisory, for the login endpoint.


Bug: hgen CI skips -D warnings

.github/workflows/ci.yml:

- name: Run cargo clippy for hgen
  run: cd hgen && cargo clippy --all-targets --all-features -- -D warnings

Wait — this actually does include -D warnings. But the fmt step for hgen runs cargo fmt --all -- --check without being part of the fmt job's toolchain setup step that installs rustfmt. If the hgen format check runs in the clippy job (it doesn't — it's in fmt), the component may not be installed. Currently the hgen fmt check is correctly in the fmt job, so this is fine as written.


Minor: setup-dev.sh SQL heredoc with argon2 hash

sqlite3 sqlite.db <<SQL
INSERT INTO users ... VALUES ('${SAFE_UUID}', 'admin', '${ADMIN_HASH}') ...
SQL

Argon2 hashes contain $ (e.g., $argon2id$v=19$...). In an unquoted heredoc (<<SQL not <<'SQL'), ${ADMIN_HASH} expands correctly and the result is not re-expanded — so this works. But the regex guard only checks ^\$argon2, not the full PHC string format. A truncated or malformed hash would silently insert a bad record. Consider quoting the heredoc (<<'SQL') and using printf-based substitution, or validating the full hash format.


Minor: UUID variant bit bias in SQL UUID generator

substr('89ab', (random() & 3) + 1, 1)

random() in SQLite returns a signed 64-bit integer. random() & 3 produces values 0–3 uniformly, mapping to 8, 9, a, b. This is correct and uniform — no issue.

…date triggers, and improve dev script SQL execution.
@github-actions
Copy link
Copy Markdown

PR Review: PostgreSQL → SQLite Migration + Rate Limiting

Overall this is a well-structured migration with good security thinking (dummy hash timing mitigation, algorithm-pinned JWT validation, #[inline(never)] on verify). A few real issues to address:


Critical: Argon2 blocks the async runtime

backend/src/api/admin.rsverify_password and hash_password are synchronous CPU-bound operations (~50–100ms each) called directly inside async handlers. This blocks a tokio worker thread for the duration.

// login handler — blocks the executor:
let password_match = verify_password(&req.password, hash_to_verify);

Fix: wrap with spawn_blocking:

let hash = hash_to_verify.to_string();
let pw = req.password.clone();
let password_match = tokio::task::spawn_blocking(move || verify_password(&pw, &hash))
    .await
    .unwrap_or(false);

Same fix needed in hash_password inside change_password.


Security: Log injection via untrusted headers in /me

backend/src/api/admin.rs — The me handler reads X-Real-IP and X-Forwarded-For from any client and logs them on invalid token attempts, without first verifying the request came from a trusted proxy:

let real_ip = headers
    .get("X-Real-IP")
    .and_then(|h| h.to_str().ok())
    .map(|s| s.trim().to_string()); // attacker-controlled
// ...
tracing::warn!("Invalid token on /me from client IP {} ...", client_ip, ...);

An attacker can inject arbitrary content (including newlines) into log lines. At minimum, replace newlines before logging: client_ip.replace(['\n', '\r'], " ").


Bug: Trigger error message uses invalid SQLite format specifier

migrations/20260325000003_add_published_at_check.sql — The WHEN clause correctly uses %f, but the RAISE message says %3f which is not a valid SQLite strftime modifier:

-- WHEN clause (correct):
WHEN NEW.published_at != strftime('%Y-%m-%dT%H:%M:%fZ', NEW.published_at)
-- Error message (wrong specifier):
SELECT RAISE(ABORT, 'published_at must be in %Y-%m-%dT%H:%M:%3fZ format');
--                                                             ^^

Change to %fZ in the message.


Minor: Identical rate limiter configs instantiated separately

backend/src/api/admin.rslogin_governor_config and password_governor_config are byte-for-byte identical (1 req/sec, burst 1). Share the Arc:

let auth_governor_config = Arc::new(
    GovernorConfigBuilder::default()
        .key_extractor(TrustedProxyIpKeyExtractor)
        .per_second(1)
        .burst_size(1)
        .finish()
        .unwrap(),
);
// reuse for both routes

Operational: TRUSTED_PROXY_IPS parsing is lazy, startup check may not catch bad IPs

backend/src/api/mod.rs — The startup code in main.rs correctly panics if the var is missing/empty in production, but individual IP address validation in TrustedProxyIpKeyExtractor happens lazily on first request with a tracing::warn! on bad IPs. A misconfigured IP silently falls through to degraded rate limiting. Consider calling a validation function at startup alongside init_jwt_secret() and init_dummy_hash().


Positive highlights worth keeping

  • Constant-time dummy hash for non-existent users is correctly implemented
  • #[inline(never)] on verify_password is a good timing-attack hint
  • JWT validation now pins Algorithm::HS256 (previously Validation::default() was algorithm-agnostic)
  • Password length cap before Argon2 (> 128 bytes) prevents hash-flooding
  • SQLite updated_at triggers are correctly safe: with recursive_triggers = OFF (SQLite default), the inner UPDATE from the trigger won't re-fire the same trigger; and the published_at format check trigger's WHEN clause correctly evaluates to false for the inner update since published_at is not being changed

…te limiters, offload password operations to blocking tasks, sanitize client IP logging, and correct a SQL `published_at` format error message
@github-actions
Copy link
Copy Markdown

PR Review: PostgreSQL → SQLite Migration + Rate Limiting

This is a well-structured refactor with solid security improvements (timing-safe auth, centralized JWT secret, Argon2 params). Below are the critical issues.


1. Log Injection in me Endpoint (Security — Medium)

backend/src/api/admin.rs

let real_ip = headers.get("X-Real-IP")
    .and_then(|h| h.to_str().ok())
    .map(|s| s.trim().to_string());
let forwarded_for = headers.get("X-Forwarded-For")...
let client_ip = real_ip.or(forwarded_for)...

The me handler logs IP from X-Real-IP/X-Forwarded-For without checking whether the request arrived from a trusted proxy. Any client can set these headers to arbitrary values, allowing log spoofing/injection. The TrustedProxyIpKeyExtractor logic should be reused here, or at minimum mirror the trusted-proxy guard from mod.rs.


2. hgen Drops \r-Only Line Endings (Bug)

hgen/src/main.rs:15-20

let password = password
    .strip_suffix("\r\n")
    .or_else(|| password.strip_suffix("\n"))
    .unwrap_or(&password);

Classic Mac (\r) line endings are not stripped. On such systems — or if piped from a tool that emits \r — the carriage return becomes part of the hashed password. The user's real password won't match. Fix:

let password = password.trim_end_matches(['\r', '\n']);

3. Missing CHECK Constraints for Former Enum Columns (Data Integrity)

migrations/20260110000000_initial_schema.sql

The PostgreSQL CREATE TYPE enums are replaced with plain TEXT columns (work_type, category, context), but no CHECK constraints are added:

work_type TEXT NOT NULL,  -- 'story', 'novel', 'poetry' — unenforced
category TEXT NOT NULL,   -- 'photography', 'visual_art', etc. — unenforced

SQLite won't reject invalid string values. Add constraints:

work_type TEXT NOT NULL CHECK(work_type IN ('story', 'novel', 'poetry')),
category  TEXT NOT NULL CHECK(category IN ('photography', 'visual_art', 'video', 'j_school')),
context   TEXT NOT NULL DEFAULT 'personal' CHECK(context IN ('personal', 'professional')),

4. Hardcoded Docker Bridge IPs Silently Break Rate Limiting (Ops Security)

scripts/remote_build.sh

TRUSTED_PROXY_IPS=172.18.0.2,172.18.0.3

These IPs are baked into the generated .env as defaults. Docker bridge IPs change on network recreation. If stale, the proxy won't be trusted, and all users will share the same rate-limit bucket (the proxy's IP), effectively disabling per-client rate limiting on login silently. The existing comment warns about this but doesn't fail loudly enough — consider logging TRUSTED_PROXY_IPS at startup (which init_trusted_proxies could do) so the operator sees the active value.


5. Minor: parse_flexible_datetime Multi-Pass Overhead After Normalization (Performance)

backend/src/api/public.rs:189-202

After migration 20260325000001_normalize_timestamps.sql runs, all published_at values should be in RFC 3339 format. The two fallback parse paths (%Y-%m-%d %H:%M:%S) will always fail for normalized rows, adding needless overhead per row. This is low-impact for a personal site but consider removing the fallbacks once the migration has settled, or add a debug-mode warning when a fallback is hit.


Summary Table

# Location Severity Type
1 admin.rsme() Medium Security (log injection)
2 hgen/src/main.rs Medium Bug (wrong password hash)
3 Initial schema migration Medium Data integrity
4 remote_build.sh Medium Ops security
5 public.rsparse_flexible_datetime Low Performance

The timing-safe dummy-hash path, spawn_blocking for Argon2, #[inline(never)] on verify_password, and the JWT secret centralization are all solid improvements worth keeping.

…P logging based on trusted proxies, and simplify password trimming and datetime parsing logic.
@github-actions
Copy link
Copy Markdown

PR Review

Critical Bug: AFTER UPDATE Triggers — Latent Infinite Loop

migrations/20260325000002_add_updated_at_triggers.sql

All triggers do UPDATE <same_table> inside an AFTER UPDATE trigger:

CREATE TRIGGER update_articles_updated_at
AFTER UPDATE ON articles FOR EACH ROW
BEGIN
    UPDATE articles SET updated_at = ... WHERE id = NEW.id;
END;

With PRAGMA recursive_triggers = OFF (SQLite default) this works, but if that pragma is ever enabled (by sqlx, a future migration, or a connection option), every UPDATE will recurse infinitely. Add a WHEN guard to prevent re-triggering:

WHEN NEW.updated_at IS OLD.updated_at

This fires the trigger only when the application didn't explicitly change updated_at, safely preventing recursion even with recursive_triggers = ON.


Security: Hardcoded Docker Bridge IPs in remote_build.sh

TRUSTED_PROXY_IPS=172.18.0.2,172.18.0.3

Docker bridge IPs are assigned dynamically and change on restart. If these are stale, TrustedProxyIpKeyExtractor won't trust the real proxy, collapsing all users into one rate-limit bucket under the proxy IP. The warning comment is good, but the defaults shouldn't exist in the generated .env — leave the key present but empty (or with a TODO) so the operator is forced to inspect:

TRUSTED_PROXY_IPS=  # REQUIRED: set to your Nginx container IP (docker network inspect ...)

Security: Shell SQL Injection Vector in setup-dev.sh

printf "INSERT INTO users ... VALUES ('%s', 'admin', '%s') ...\n" "$SAFE_UUID" "$ADMIN_HASH" | sqlite3 sqlite.db

Argon2 hashes won't contain single quotes today, but this pattern is fragile. If the hash format ever includes one (or $ADMIN_HASH is substituted with unexpected output), it silently corrupts the SQL. Use a heredoc with sqlite3's .param or pass via a temporary file with proper escaping.


Medium Bug: Duplicate Proxy IP Detection in me Handler

backend/src/api/admin.rs:me() reimplements the same proxy IP extraction logic (X-Real-IPX-Forwarded-For → peer) that already lives in TrustedProxyIpKeyExtractor. These two implementations will diverge. Extract a shared helper in api/mod.rs (e.g., fn extract_client_ip(headers, peer_addr) -> String) and call it from both places.


Medium: UnableToExtractKey Fails Closed — But Loudly

In TrustedProxyIpKeyExtractor::extract, if ConnectInfo is missing from extensions:

peer_ip
    .map(|ip| ip.to_string())
    .ok_or(tower_governor::GovernorError::UnableToExtractKey)

tower_governor returns a 500 for every request that hits this path. Since main.rs now uses into_make_service_with_connect_info, this won't occur in practice — but this should be logged as a hard error at startup rather than silently returning 500 in production if the service misconfiguration ever happens. Consider adding a startup assertion.


Minor: me Returns 200 authenticated: true Without Emitting the User ID

Ok(Json(serde_json::json!({ "authenticated": true })))

The frontend needs to know who is authenticated (e.g., to show the username). The decoded claims _token_data already contain sub. Consider including it:

Ok(Json(serde_json::json!({ "authenticated": true, "sub": _token_data.claims.sub })))

Positive Observations

  • Constant-time password verification via dummy hash is correctly implemented and the #[inline(never)] annotation prevents the compiler from eliding the dummy verification path.
  • Argon2 parameters are centralized in shared::auth and used consistently in both admin.rs and hgen.
  • JWT secret length enforcement (< 32 bytes panic) is a good startup guard.
  • The before/offset mutual exclusion check in pagination and the offset > 10_000 cap are solid defensive measures.
  • #[cfg(debug_assertions)] gating of console::log calls is correct.

… `updated_at` database triggers, and improve setup script robustness.
@github-actions
Copy link
Copy Markdown

PR Review: PostgreSQL → SQLite Migration + Rate Limiting

Critical Bugs

1. Production deployment will panic on startup (scripts/remote_build.sh)

The generated .env sets TRUSTED_PROXY_IPS= # REQUIRED... (empty string) alongside ENVIRONMENT=production. main.rs has an explicit panic guard for this exact case:

Ok(ips) if ips.trim().is_empty() => panic!("TRUSTED_PROXY_IPS is set but empty..."),

The app will never start with the default generated config. Either use TRUSTED_PROXY_IPS= (absent from file) or remove the generated line and document it separately.

2. Login and password-change share the same rate-limit bucket (backend/src/api/admin.rs)

let auth_governor_config = std::sync::Arc::new(...);
let login_governor_layer = tower_governor::GovernorLayer { config: auth_governor_config.clone() };
let password_governor_layer = tower_governor::GovernorLayer { config: auth_governor_config }; // same Arc

Both layers point to the same underlying RateLimiter state. One login attempt exhausts the password-change quota for that IP and vice versa, since burst_size(1). These should be separate GovernorConfigBuilder instances.


Security Issues

3. /me leaks internal user UUID unnecessarily (backend/src/api/admin.rs:~205)

Ok(Json(serde_json::json!({
    "authenticated": true,
    "sub": _token_data.claims.sub  // exposes internal UUID
})))

The original only returned "Authenticated". Returning the UUID is unnecessary for an admin panel and exposes an internal identifier. Return only {"authenticated": true} or omit sub.

4. linkify_images: img_tag content is not sanitized before wrapping (frontend/src/pages/sections.rs:~244)

let new_content = format!("{}{}{}", wrapper_start, img_tag, wrapper_end);

Only the src URL is sanitized/checked for safe schemes, but the full img_tag (which may contain onerror=, onload=, etc.) is embedded verbatim. If article HTML comes from any untrusted path, this is XSS. Even for admin-only content this pattern should be noted.


Correctness / Quality Issues

5. _token_data naming is misleading (backend/src/api/admin.rs:~196)

let _token_data = jsonwebtoken::decode::<Claims>(...)...?;
// ...
"sub": _token_data.claims.sub

The underscore prefix signals an intentionally unused variable but it is used. Rename to token_data.

6. parse_flexible_datetime only handles RFC3339 (backend/src/api/public.rs)

SQLite can return timestamps in other formats (e.g., 2026-01-01 12:00:00 with space separator). The normalization migration handles existing rows, but any SQLite internal operation that bypasses your format (e.g., direct DB access, sqlite3 CLI inserts) will cause a runtime decode error. Consider adding a fallback format: "%Y-%m-%d %H:%M:%S".

7. before cursor doesn't validate pagination exclusivity clearly (backend/src/api/public.rs:~55)

if query.before.is_some() && query.offset.is_some() {
    return Err(axum::http::StatusCode::BAD_REQUEST);
}

The error gives no message to the client. Since this is an API, returning a body like "Cannot use 'before' and 'offset' together" via (StatusCode, String) would improve debuggability for API consumers.


Summary Table

Severity Issue Location
🔴 Critical Startup panic with generated .env in production scripts/remote_build.sh
🔴 Critical Login/password share rate limit state backend/src/api/admin.rs
🟡 Security /me leaks internal UUID backend/src/api/admin.rs
🟡 Security img_tag not sanitized before wrapping frontend/src/pages/sections.rs
🟢 Minor _token_data misleading name backend/src/api/admin.rs
🟢 Minor Rigid datetime parsing, no fallback format backend/src/api/public.rs

…ne admin rate limiting, ensure safe image tag rendering, and clarify `TRUSTED_PROXY_IPS` documentation.
@github-actions
Copy link
Copy Markdown

PR Review: PostgreSQL → SQLite + Rate Limiting Migration

Critical

1. .env file permissions — JWT secret exposed (scripts/remote_build.sh)

cat <<EOF > .env
...
JWT_SECRET=$(openssl rand -base64 48 | tr -d '\n')
EOF

No chmod 600 .env follows. The generated file inherits umask-derived permissions (often 644), making the JWT secret world-readable on the server.

Fix: Add chmod 600 .env immediately after generation.


2. Missing rel="noopener noreferrer" on target="_blank" links (frontend/src/pages/sections.rs:linkify_images)

let wrapper_start = format!(
    "<a href=\"{}\" target=\"_blank\" class=\"article-image-link\">",
    safe_url
);

Without rel="noopener noreferrer", the opened page gains a reference to the opener via window.opener, enabling tab-napping attacks.

Fix: Add rel=\"noopener noreferrer\" to the format string.


High

3. GovernorError::UnableToExtractKey produces a 500, not a 429 (backend/src/api/mod.rs)

extract_client_ip(req.headers(), peer_ip)
    .ok_or(tower_governor::GovernorError::UnableToExtractKey)

When peer_ip is None (the code even logs "CRITICAL" if ConnectInfo is missing) and no forwarded headers are present, extract_client_ip returns None. tower_governor maps UnableToExtractKey to a 500 Internal Server Error, which denies all requests on that path rather than failing open or closed gracefully. While into_make_service_with_connect_info makes this path unlikely in production, it will break all rate-limited endpoints in integration tests that don't provide ConnectInfo.

Fix: Either panic at startup if ConnectInfo is missing (fast-fail), or return a fallback key (e.g., "0.0.0.0") so rate limiting degrades gracefully rather than breaking all traffic.


4. me handler takes ConnectInfo as a mandatory extractor for logging only (backend/src/api/admin.rs:me)

async fn me(
    headers: HeaderMap,
    axum::extract::ConnectInfo(peer_addr): axum::extract::ConnectInfo<std::net::SocketAddr>,
) -> ...

ConnectInfo is required by the extractor signature but is only used for logging on the error path. If extraction fails (e.g., in tests), Axum returns 500 before the handler body runs. The JWT validation is silently skipped.

Fix: Use Option<axum::extract::ConnectInfo<SocketAddr>> and handle the None case.


Medium

5. Duplicate placeholder rendered when article has no image (frontend/src/pages/sections.rs:JournalismPage)

{if has_image { ... } else {
    view! { <svg ...><text ...>"Image coming soon"</text></svg> }.into_any()
}}
{(!has_image).then(|| view! { <div class="journalism-placeholder-text">"Image coming soon"</div> })}

When has_image is false, both the inline SVG (which already contains "Image coming soon") and the <div class="journalism-placeholder-text"> are rendered, doubling the placeholder content.

Fix: Remove the .then(|| ...) div, or remove the text from the SVG.


6. hgen reads only one line from stdin (hgen/src/main.rs)

std::io::stdin().read_line(&mut password).expect("Failed to read password");
let password = password.trim_end_matches(['\r', '\n']);

If a password with an embedded newline is ever piped (unusual but plausible via a file), only the first line is used silently. Use read_to_string + a single trailing trim instead to consume the full input. Low severity given the use case, but worth noting since this tool generates production credentials.

…obustness, set `.env` file permissions, and refine password input.
@github-actions
Copy link
Copy Markdown

PR Review: PostgreSQL → SQLite Migration + Rate Limiting

Overall this is a well-structured migration. The timing-attack mitigations (dummy hash, spawn_blocking, #[inline(never)]) are solid. A few issues to address:


Critical: Rate Limiter "0.0.0.0" Fallback (DoS Risk)

backend/src/api/mod.rs, TrustedProxyIpKeyExtractor::extract:

Ok(extract_client_ip(req.headers(), peer_ip).unwrap_or_else(|| "0.0.0.0".to_string()))

If ConnectInfo is missing (or peer_ip is None), all clients collapse into one shared rate-limit bucket at "0.0.0.0". A single client could exhaust the login rate limit for everyone. While into_make_service_with_connect_info should prevent this, fail-closed is safer:

let peer_ip = connect_info.map(|ci| ci.0.ip());
let key = extract_client_ip(req.headers(), peer_ip)
    .ok_or(tower_governor::GovernorError::UnableToExtractKey)?;
Ok(key)

Significant: X-Forwarded-For Warning Falls Through to Proxy IP

backend/src/api/mod.rs, extract_client_ip:

When the trusted proxy is detected but the rightmost X-Forwarded-For IP matches the proxy IP (CDN case), the code logs a warning and falls through to the end of the if is_trusted_proxy block—then falls to peer_ip.map(|ip| ip.to_string()), returning the proxy IP as the client IP. This silently collapses all users into one rate-limit bucket with no error. The warning is easy to miss in logs. Consider returning an explicit error or None with a clearer code path.


Bug: setup-dev.sh Uses Bash Syntax Without Shebang

The script uses bash-specific features ([[ =~ ]], ${var//pat/rep}, (( ))) but no #!/usr/bin/env bash shebang is present in the diff. On Ubuntu/Debian where /bin/sh is dash, the script will fail at:

if [ "$ENVIRONMENT" = "production" ] || [[ "$DATABASE_URL" == *"/app/data"* ]]; then

Add #!/usr/bin/env bash as the first line.


Minor Bug: Unsafe unwrap() in Leptos View

frontend/src/pages/sections.rs:

let has_image = image.is_some();
...
{if has_image {
    view! { <img src=image.clone().unwrap() ... /> }.into_any()

This is logically safe but fragile. Prefer:

{if let Some(ref img) = image {
    view! { <img src=img.clone() ... /> }.into_any()
} else { ... }}

Minor: Identical Rate Limit Configs Create Separate Buckets Per Endpoint

login_governor_config and password_governor_config in admin.rs are identical (per_second(1).burst_size(1)) but independent—meaning an attacker gets 1 req/sec to /login and 1 req/sec to /password simultaneously. If you want a shared budget across auth endpoints, they need to share the same Arc<GovernorConfig>.


Informational: Documented Limitations Are Well-Handled

  • In-memory rate limit state reset on restart → documented with tracing::warn! + comment recommending fail2ban. ✓
  • SQLite single-writer limitation → documented in README and comments. ✓
  • JWT not invalidated on user deletion → documented design trade-off for single-admin site. ✓
  • TRUSTED_PROXY_IPS production panic → good fail-fast behavior. ✓

… for unreliable IPs and refactor shared governor configuration.
@github-actions
Copy link
Copy Markdown

Code Review

This is a solid migration from PostgreSQL to SQLite with meaningful security improvements. Here are the critical issues:


Critical: Deployment Startup Panic

scripts/remote_build.sh generates a .env with ENVIRONMENT=production but without TRUSTED_PROXY_IPS:

cat <<EOF > .env
...
ENVIRONMENT=production
JWT_SECRET=$(openssl rand -base64 48 | tr -d '\n')
# Warning: ...manually add TRUSTED_PROXY_IPS=<ip>...
EOF

main.rs then panics:

if std::env::var("ENVIRONMENT").as_deref() == Ok("production") {
    match std::env::var("TRUSTED_PROXY_IPS").as_deref() {
        Err(_) => panic!("TRUSTED_PROXY_IPS must be set in production..."),

Every fresh deployment will fail to start until the operator manually edits .env. Either add TRUSTED_PROXY_IPS as a required step in remote_build.sh, or change the panic to a loud warning so the service can start without it.


Security: Rate Limiter Returns 500 for Untrusted Proxy Configs

In backend/src/api/mod.rs:TrustedProxyIpKeyExtractor::extract:

let key = extract_client_ip(req.headers(), peer_ip)
    .ok_or(tower_governor::GovernorError::UnableToExtractKey)?;

If a trusted proxy IP is configured but doesn't send X-Real-IP or X-Forwarded-For, extract_client_ip returns NoneUnableToExtractKey → client gets HTTP 500. This is documented as intentional "fail closed," but a 503/429 with a clear message would be less confusing than a 500.


Security: me Endpoint JWT Validation Uses Validation::new(Algorithm::HS256) Without Explicit Expiry Check

let validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256);

By default jsonwebtoken does validate exp, so this is fine. However, it's worth an explicit comment since the design note says "does not query the database" — worth confirming validate_exp defaults to true for the audit trail.


Bug: parse_flexible_datetime Error Reporting

backend/src/api/public.rs:

fn parse_flexible_datetime(dt_str: String) -> Result<chrono::DateTime<chrono::Utc>, sqlx::Error> {
    chrono::DateTime::parse_from_rfc3339(&dt_str)
        ...
        .or_else(|_| {
            chrono::NaiveDateTime::parse_from_str(&dt_str, "%Y-%m-%d %H:%M:%S")
                .map(|ndt| ndt.and_utc())
        })
        .map_err(|e| sqlx::Error::Decode(Box::new(e)))
}

The map_err at the end converts NaiveDateTime::ParseError to sqlx::Error::Decode. The original RFC3339 parse error is silently discarded. If both formats fail, the returned error only mentions the second format, making debugging harder. Keep the original error or combine both error messages.


Security: hgen/src/main.rs Uses Default Argon2 Params Fallback Silently

let params = argon2::Params::new(
    shared::auth::ARGON2_M_COST,
    ...
).unwrap();

.unwrap() here will panic with no output if params are invalid (e.g., if shared::auth constants ever change to out-of-range values). Use .expect("Invalid Argon2 parameters in shared::auth — check constants") for a clearer error.


Minor: SQLite Trigger Condition Uses IS Not !=

migrations/20260325000002_add_updated_at_triggers.sql:

WHEN NEW.updated_at IS OLD.updated_at

IS in SQLite is NULL-safe equality. This means the trigger fires when updated_at was NOT changed, which is the intent. However, if someone explicitly sets updated_at to a new value, the trigger won't fire (correct). But if the comparison were =, NULL handling would differ. The IS usage is correct here but non-obvious — worth a comment explaining why IS is preferred over = (NULL safety).


Minor: No TRUSTED_PROXY_IPS Validation Against Actual Docker Network

The production check warns about private IP ranges but the Nginx container IP isn't known at code-generation time. The warning in remote_build.sh is good, but consider adding a docker network inspect call to emit the current proxy IP as part of the deploy output, so operators have actionable data immediately.


Overall: The timing-attack mitigations (dummy hash, spawn_blocking, #[inline(never)]), the strict content-type enforcement, and the password length limits are all good. The biggest operational risk is the deployment panic on missing TRUSTED_PROXY_IPS.

@j-cray j-cray merged commit 90fcbf9 into main Mar 25, 2026
2 of 6 checks passed
@j-cray j-cray deleted the refactor--use-sqlite-instead-of-postgres branch March 25, 2026 00:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants