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
+130 -21
View File
@@ -1,8 +1,9 @@
<?php
/**
* install.php — Full database installer.
* Creates all tables, seeds settings and superadmin account.
* Run once in browser, then DELETE this file.
* Creates all tables (matching schema.sql — the canonical structure reference)
* and seeds site_settings defaults, the superadmin account, and the standard
* prayer library. Run once in browser, then DELETE this file.
*/
require_once __DIR__ . '/config/db.php';
@@ -24,7 +25,7 @@ function inst_sql(PDO $pdo, string $label, string $sql, array &$log, array &$err
}
}
// ── 1. sessions ──────────────────────────────────────────────────────────────
// ── 1. Tables (kept identical to schema.sql) ───────────────────────────────────
inst_sql($pdo, 'Create sessions table', "
CREATE TABLE IF NOT EXISTS sessions (
id INT AUTO_INCREMENT PRIMARY KEY,
@@ -46,7 +47,6 @@ inst_sql($pdo, 'Create sessions table', "
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
", $log, $errors);
// ── 2. novena_groups ─────────────────────────────────────────────────────────
inst_sql($pdo, 'Create novena_groups table', "
CREATE TABLE IF NOT EXISTS novena_groups (
id INT AUTO_INCREMENT PRIMARY KEY,
@@ -65,26 +65,26 @@ inst_sql($pdo, 'Create novena_groups table', "
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
", $log, $errors);
// ── 3. users ─────────────────────────────────────────────────────────────────
inst_sql($pdo, 'Create users table', "
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,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
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
", $log, $errors);
// ── 4. site_settings ─────────────────────────────────────────────────────────
inst_sql($pdo, 'Create site_settings table', "
CREATE TABLE IF NOT EXISTS site_settings (
key_name VARCHAR(100) PRIMARY KEY,
@@ -93,7 +93,37 @@ inst_sql($pdo, 'Create site_settings table', "
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
", $log, $errors);
// ── 5. Seed site_settings ────────────────────────────────────────────────────
inst_sql($pdo, 'Create custom_prayers table', "
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
", $log, $errors);
inst_sql($pdo, 'Create builder_steps table', "
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
", $log, $errors);
// ── 2. Seed site_settings ────────────────────────────────────────────────────
$defaults = [
'smtp_host' => '',
'smtp_port' => '587',
@@ -114,7 +144,7 @@ foreach ($defaults as $k => $v) {
}
}
// ── 6. Seed superadmin ───────────────────────────────────────────────────────
// ── 3. Seed superadmin ───────────────────────────────────────────────────────
$hash = password_hash('supadmin', PASSWORD_BCRYPT);
try {
$pdo->prepare("
@@ -127,6 +157,85 @@ try {
$errors[] = 'Seed superadmin: ' . $e->getMessage();
}
// ── 4. Seed standard global prayer library ──────────────────────────────────
$existing_prayers = 0;
try {
$existing_prayers = (int)$pdo->query("SELECT COUNT(*) FROM custom_prayers WHERE is_global=1")->fetchColumn();
} catch (PDOException $e) {
$errors[] = 'Check existing prayers: ' . $e->getMessage();
}
if ($existing_prayers > 0) {
$log[] = ['skip', "Standard prayers already seeded ({$existing_prayers} found)"];
} else {
$sa = $pdo->query("SELECT id FROM users WHERE role='superadmin' LIMIT 1")->fetch();
$creator_id = $sa ? (int)$sa['id'] : 1;
$prayers = [
['name' => 'Sign of the Cross', 'bead' => 'crucifix',
'leader' => "In the name of the Father,\nand of the Son,\nand of the Holy Spirit.",
'all' => 'Amen.'],
['name' => 'Apostles\' Creed', 'bead' => null,
'leader' => "I believe in God, the Father Almighty,\nCreator of Heaven and earth;\nand in Jesus Christ, His only Son, Our Lord,\nWho was conceived by the Holy Spirit,\nborn of the Virgin Mary,\nsuffered under Pontius Pilate,\nwas crucified, died, and was buried.\nHe descended into Hell;\nthe third day He rose again from the dead;\nHe ascended into Heaven,\nand sitteth at the right hand of God, the Father Almighty;\nfrom thence He shall come to judge the living and the dead.",
'all' => "I believe in the Holy Spirit,\nthe Holy Catholic Church,\nthe communion of saints,\nthe forgiveness of sins,\nthe resurrection of the body\nand life everlasting. Amen."],
['name' => 'Our Father', 'bead' => 'large',
'leader' => "Our Father, Who art in Heaven,\nhallowed be Thy name;\nThy kingdom come,\nThy will be done on earth as it is in Heaven.",
'all' => "Give us this day our daily bread,\nand forgive us our trespasses,\nas we forgive those who trespass against us;\nand lead us not into temptation,\nbut deliver us from evil. Amen."],
['name' => 'Hail Mary', 'bead' => 'small',
'leader' => "Hail Mary, full of grace, the Lord is with thee;\nblessed art thou amongst women,\nand blessed is the fruit of thy womb, Jesus.",
'all' => "Holy Mary, Mother of God,\npray for us sinners,\nnow and at the hour of our death. Amen."],
['name' => 'Glory Be', 'bead' => null,
'leader' => "Glory be to the Father, and to the Son,\nand to the Holy Spirit,",
'all' => "as it was in the beginning, is now,\nand ever shall be, world without end. Amen."],
['name' => 'Fatima Prayer', 'bead' => null,
'leader' => "O my Jesus, forgive us our sins,\nsave us from the fires of hell,",
'all' => "lead all souls to Heaven,\nespecially those who are in most need of Thy mercy."],
['name' => 'Hail Holy Queen', 'bead' => null,
'leader' => "Hail, Holy Queen, Mother of Mercy,\nour life, our sweetness and our hope.\nTo thee do we cry,\npoor banished children of Eve.\nTo thee do we send up our sighs,\nmourning and weeping in this valley of tears.\nTurn then, most gracious advocate,\nthine eyes of mercy toward us,\nand after this our exile\nshow unto us the blessed fruit of thy womb, Jesus.\nO clement, O loving,\nO sweet Virgin Mary.",
'all' => "Pray for us, O holy Mother of God,\nthat we may be made worthy of the promises of Christ."],
['name' => 'Eternal Rest', 'bead' => null,
'leader' => "Eternal rest grant unto {pronoun_obj}, O Lord,",
'all' => "and let perpetual light shine upon {pronoun_obj}.\nMay {pronoun_poss} soul and the souls of all the faithful departed,\nthrough the mercy of God, rest in peace. Amen."],
['name' => 'The Memorare', 'bead' => null,
'leader' => "Remember, O most gracious Virgin Mary,\nthat never was it known\nthat anyone who fled to thy protection,\nimplored thy help, or sought thy intercession,\nwas left unaided.\nInspired by this confidence,\nI fly unto thee, O Virgin of virgins, my mother;\nto thee do I come,\nbefore thee I stand, sinful and sorrowful.\nO Mother of the Word Incarnate,\ndespise not my petitions,\nbut in thy mercy hear and answer me.",
'all' => 'Amen.'],
['name' => 'Act of Contrition', 'bead' => null,
'leader' => "O my God, I am heartily sorry for having offended Thee,\nand I detest all my sins\nbecause of thy just punishments,\nbut most of all because they offend Thee, my God,\nwho art all good and deserving of all my love.\nI firmly resolve, with the help of Thy grace,\nto sin no more and to avoid the near occasions of sin.",
'all' => 'Amen.'],
['name' => 'O Blood and Water', 'bead' => null,
'leader' => "O Blood and Water,\nwhich gushed forth from the Heart of Jesus\nas a fount of mercy for us,",
'all' => 'I trust in You.'],
['name' => 'Eternal Father (Divine Mercy)', 'bead' => 'large',
'leader' => "Eternal Father, I offer You the Body and Blood,\nSoul and Divinity of Your dearly beloved Son,\nOur Lord Jesus Christ,",
'all' => "in atonement for our sins and those of the whole world."],
['name' => 'For the Sake of His Sorrowful Passion', 'bead' => 'small',
'leader' => "For the sake of His sorrowful Passion,",
'all' => "have mercy on us and on the whole world."],
['name' => 'Holy God (Divine Mercy Closing)', 'bead' => null,
'leader' => "Holy God, Holy Mighty One, Holy Immortal One,",
'all' => "have mercy on us and on the whole world."],
['name' => 'Prayer to St. Michael the Archangel', 'bead' => null,
'leader' => "Saint Michael the Archangel,\ndefend us in battle.\nBe our defense against the wickedness and snares of the Devil.\nMay God rebuke him, we humbly pray,\nand do thou, O Prince of the heavenly hosts,\nby the power of God, thrust into hell Satan,\nand all the evil spirits,\nwho prowl about the world seeking the ruin of souls.",
'all' => 'Amen.'],
['name' => 'Rosary Closing Prayer', 'bead' => null,
'leader' => "Let us pray.\n\nO God, whose only-begotten Son,\nby His life, death, and resurrection,\nhas purchased for us the rewards of eternal life,\ngrant, we beseech Thee,\nthat meditating upon these mysteries\nof the Most Holy Rosary of the Blessed Virgin Mary,\nwe may imitate what they contain\nand obtain what they promise,\nthrough the same Christ Our Lord.",
'all' => 'Amen.'],
];
try {
$stmt = $pdo->prepare(
"INSERT INTO custom_prayers (name, leader_text, all_text, default_bead_type, is_global, created_by)
VALUES (?, ?, ?, ?, 1, ?)"
);
foreach ($prayers as $p) {
$stmt->execute([$p['name'], $p['leader'], $p['all'], $p['bead'], $creator_id]);
}
$log[] = ['ok', 'Seeded ' . count($prayers) . ' standard global prayers'];
} catch (PDOException $e) {
$errors[] = 'Seed prayer library: ' . $e->getMessage();
}
}
$overall_ok = empty($errors);
?>
<!DOCTYPE html>