Consolidate schema, add CSRF protection, and add login rate-limiting

Six fixes from a codebase review:

- Consolidate the ad hoc install.php + migrate_v2..v6.php chain into one
  canonical schema.sql (structure reference) plus a simplified install.php
  that creates all tables and seeds site_settings, the superadmin account,
  and the standard prayer library. The six migrate_v*.php scripts are
  deleted — their cumulative effect is now fully captured in schema.sql.

- Delete the two root-level setup.php/novena_group.php files that existed
  only to redirect to their admin/ equivalents of the same name; confirmed
  unreferenced by any link or .htaccess rule.

- Decouple includes/build_slides.php from data/prayers.php's implicit
  `global $opening, $mysteries, ...` contract. data/prayers.php now
  explicitly returns its arrays; build_slides.php loads them through a
  small memoized get_prayer_data() and destructures them by key.

- Add CSRF protection (includes/csrf.php: csrf_token/csrf_field/csrf_verify)
  across every POST-handling endpoint — 10 form pages and 7 API endpoints —
  plus token wiring in the JS/inline scripts that call the FormData- and
  JSON-body API endpoints (builder.js, setup.js, and the inline scripts in
  admin/audio.php, admin/novena_group.php, and index.php).

- Stop round-tripping the SMTP password in plaintext through the settings
  form: the field now renders blank with a "currently set" hint, and a
  blank submission leaves the stored password unchanged instead of
  clearing it.

- Add login rate-limiting: users.failed_login_attempts / locked_until
  columns, is_locked_out()/record_login_failure()/record_login_success()
  helpers in includes/auth.php, and lockout handling in login.php (5
  failed attempts locks the account for 15 minutes). README documents the
  one manual ALTER TABLE needed to add these columns to an existing
  production database.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 09:34:10 -07:00
parent 8c047f5b28
commit 9622f9aeca
37 changed files with 459 additions and 767 deletions
+103
View File
@@ -0,0 +1,103 @@
-- Rosary Presenter -- MySQL 8.x / MariaDB schema
-- Canonical, consolidated schema (structure only). Supersedes the old
-- install.php + migrate_v2..v6.php chain of hand-run scripts.
--
-- This file is DDL only -- every CREATE TABLE is IF NOT EXISTS, so it's safe to
-- run directly (mysql -u user -p dbname < schema.sql) against either an empty
-- database or an existing one. Seed data (site_settings defaults, the
-- superadmin account, and the standard prayer library) is NOT included here:
-- it's inserted by install.php via PHP arrays + prepared statements instead of
-- raw SQL, both because the superadmin password needs PHP's password_hash()
-- and to avoid any risk of the prayer texts' own punctuation (they contain
-- semicolons and apostrophes) being misread as SQL syntax.
--
-- For an EXISTING production install that already ran the old migrate_v2..v6
-- scripts, applying this file is a no-op EXCEPT for the new
-- users.failed_login_attempts / users.locked_until columns added for login
-- rate-limiting -- see README.md "Upgrading an Existing Install" for the one
-- manual ALTER TABLE needed there.
CREATE TABLE IF NOT EXISTS sessions (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NULL,
is_public TINYINT(1) NOT NULL DEFAULT 1,
slug VARCHAR(255) NULL,
name VARCHAR(255) NOT NULL,
occasion VARCHAR(50) NOT NULL,
mystery_set VARCHAR(50) NOT NULL,
novena_day TINYINT NULL,
novena_group_id INT NULL,
subject_name VARCHAR(255) NULL,
subject_pronoun VARCHAR(10) NULL,
subject_dates VARCHAR(150) NULL,
photo_path VARCHAR(500) NULL,
is_pinned TINYINT(1) NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS novena_groups (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NULL,
is_public TINYINT(1) NOT NULL DEFAULT 1,
slug VARCHAR(255) NULL,
name VARCHAR(255) NOT NULL,
mystery_set VARCHAR(50) NOT NULL DEFAULT 'sorrowful',
subject_name VARCHAR(255) NULL,
subject_pronoun VARCHAR(10) NULL,
subject_dates VARCHAR(150) NULL,
photo_path VARCHAR(500) NULL,
is_pinned TINYINT(1) NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
display_name VARCHAR(100) NULL,
role ENUM('superadmin','admin','superuser','user') NOT NULL DEFAULT 'user',
rosary_limit INT NOT NULL DEFAULT 1,
email_confirmed TINYINT(1) NOT NULL DEFAULT 0,
confirm_token VARCHAR(64) NULL,
reset_token VARCHAR(64) NULL,
reset_expires DATETIME NULL,
failed_login_attempts INT NOT NULL DEFAULT 0,
locked_until DATETIME NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS site_settings (
key_name VARCHAR(100) PRIMARY KEY,
val TEXT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS custom_prayers (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
leader_text TEXT,
all_text TEXT,
default_bead_type ENUM('small','large','crucifix') NULL,
is_global TINYINT(1) NOT NULL DEFAULT 0,
created_by INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_global (is_global),
INDEX idx_created_by (created_by)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS builder_steps (
id INT AUTO_INCREMENT PRIMARY KEY,
session_id INT NOT NULL,
step_type ENUM('prayer','bead') NOT NULL DEFAULT 'prayer',
bead_type ENUM('small','large','crucifix') NULL,
step_order INT NOT NULL DEFAULT 0,
prayer_id INT NULL,
attribution ENUM('leader_all','leader_only','all_only','none') NOT NULL DEFAULT 'leader_all',
INDEX idx_session (session_id),
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;