diff --git a/README.md b/README.md index dfaeb3b..9e3ca7b 100644 --- a/README.md +++ b/README.md @@ -32,10 +32,12 @@ cp config/db.example.php config/db.php ### 2. Create the database schema -Visit `install.php` in your browser once to create all tables and seed the superadmin account. **Delete `install.php` immediately after.** +Visit `install.php` in your browser once — it creates all tables (matching `schema.sql`, kept as the canonical structure reference) and seeds `site_settings` defaults, the superadmin account, and the standard prayer library. **Delete `install.php` immediately after.** Default superadmin credentials: `supadmin` / `supadmin` — **change these immediately**. +`schema.sql` documents the current database structure; there is no separate migration-script chain to run. + ### 3. Configure the web server #### Apache — `.htaccess` is included. Enable `mod_rewrite` and set `AllowOverride All`. @@ -62,6 +64,16 @@ chmod 755 uploads/ Configure outbound email in **Admin → Settings** for registration confirmation and password reset emails. If left blank, the app will auto-confirm new users instead. +## Upgrading an Existing Install + +`schema.sql` reflects the current database structure. For a production database that predates the `failed_login_attempts` / `locked_until` login-lockout columns, run this once against it manually — it's not applied automatically since there's no migration runner against a live database: + +```sql +ALTER TABLE users + ADD COLUMN failed_login_attempts INT NOT NULL DEFAULT 0, + ADD COLUMN locked_until DATETIME NULL; +``` + ## Deployment Checklist - [ ] `config/db.php` filled in with production credentials @@ -92,7 +104,8 @@ Rosary/ ├── data/ │ └── prayers.php # All prayer text + build_decade_slides() ├── includes/ -│ ├── auth.php # require_auth(), current_user(), has_role() +│ ├── auth.php # require_auth(), current_user(), has_role(), login lockout +│ ├── csrf.php # csrf_token(), csrf_field(), csrf_verify() │ ├── build_slides.php │ ├── donate.php │ └── mailer.php @@ -100,6 +113,7 @@ Rosary/ ├── index.php # Public home — card grid of sessions ├── present.php # Presentation player (public) ├── novena_public.php # Novena day-picker (public) +├── schema.sql # Canonical database schema (structure only) ├── install.php # Run once, then delete └── .htaccess # URL rewriting ``` diff --git a/admin/audio.php b/admin/audio.php index 44cf798..d627625 100644 --- a/admin/audio.php +++ b/admin/audio.php @@ -6,6 +6,7 @@ */ require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../includes/auth.php'; +require_once __DIR__ . '/../includes/csrf.php'; require_auth(); if (!has_role('admin')) { @@ -182,6 +183,7 @@ foreach ($AUDIO_KEYS as $keys) { padding:16px 20px; font-size:14px; color:#0c4a6e; margin-bottom:28px; } .help-note strong { display:block; margin-bottom:6px; } + @@ -341,6 +343,7 @@ foreach ($AUDIO_KEYS as $keys) { var fd = new FormData(); fd.append('key', key); fd.append('audio', file); + fd.append('csrf_token', document.querySelector('meta[name="csrf-token"]').content); fetch(BASE_URL + '/api/upload_audio.php', { method: 'POST', body: fd }) .then(function (r) { return r.json(); }) @@ -365,6 +368,7 @@ foreach ($AUDIO_KEYS as $keys) { var fd = new FormData(); fd.append('key', key); + fd.append('csrf_token', document.querySelector('meta[name="csrf-token"]').content); fetch(BASE_URL + '/api/delete_audio.php', { method: 'POST', body: fd }) .then(function (r) { return r.json(); }) diff --git a/admin/builder.php b/admin/builder.php index 236d2e4..2b1f9f3 100644 --- a/admin/builder.php +++ b/admin/builder.php @@ -6,6 +6,7 @@ */ require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../includes/auth.php'; +require_once __DIR__ . '/../includes/csrf.php'; require_role('superuser'); @@ -72,6 +73,7 @@ $page_title = $session ? 'Edit: ' . htmlspecialchars($session['name']) : 'Rosary <?= $page_title ?> — <?= htmlspecialchars($site_name) ?> + diff --git a/admin/index.php b/admin/index.php index ba765c4..bbb56a0 100644 --- a/admin/index.php +++ b/admin/index.php @@ -4,6 +4,7 @@ */ require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../includes/auth.php'; +require_once __DIR__ . '/../includes/csrf.php'; require_auth(); @@ -15,6 +16,7 @@ $site_name = get_setting('site_name', APP_NAME); // Handle deletions if ($_SERVER['REQUEST_METHOD'] === 'POST') { + csrf_verify(); if (isset($_POST['delete_group_id'])) { $gid = (int)$_POST['delete_group_id']; // Verify ownership or admin @@ -200,6 +202,7 @@ $novena_created = isset($_GET['novena_created']) ? (int)$_GET['novena_created']
+
@@ -241,6 +244,7 @@ $novena_created = isset($_GET['novena_created']) ? (int)$_GET['novena_created'] class="btn btn-sm btn-secondary">Edit
+
diff --git a/admin/novena_group.php b/admin/novena_group.php index cd31867..521783a 100644 --- a/admin/novena_group.php +++ b/admin/novena_group.php @@ -4,6 +4,7 @@ */ require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../includes/auth.php'; +require_once __DIR__ . '/../includes/csrf.php'; require_auth(); @@ -38,6 +39,7 @@ $is_dm = ($group['mystery_set'] === 'chaplet'); // Handle delete of a single day session if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_day_id'])) { + csrf_verify(); $did = (int)$_POST['delete_day_id']; $pdo->prepare('DELETE FROM sessions WHERE id = ? AND novena_group_id = ?')->execute([$did, $gid]); @@ -56,6 +58,7 @@ $save_error = ''; $save_success = false; if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_group'])) { + csrf_verify(); $g_name = trim($_POST['g_name'] ?? ''); $g_photo = trim($_POST['g_photo'] ?? '') ?: null; $g_public = isset($_POST['is_public']) ? 1 : 0; @@ -128,6 +131,7 @@ $mystery_labels = [ <?= htmlspecialchars($group['name']) ?> — <?= htmlspecialchars($site_name) ?> + @@ -172,6 +176,7 @@ $mystery_labels = [

Novena Details

+
@@ -289,6 +294,7 @@ $mystery_labels = [ class="btn btn-sm btn-primary">Present + @@ -320,6 +326,7 @@ $mystery_labels = [ if (!file) return; var fd = new FormData(); fd.append('photo', file); + fd.append('csrf_token', document.querySelector('meta[name="csrf-token"]').content); photoStatus.textContent = 'Uploading\u2026'; fetch(BASE_URL + '/api/upload_photo.php', { method: 'POST', body: fd }) diff --git a/admin/prayers.php b/admin/prayers.php index a16548d..5d48cc4 100644 --- a/admin/prayers.php +++ b/admin/prayers.php @@ -5,6 +5,7 @@ */ require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../includes/auth.php'; +require_once __DIR__ . '/../includes/csrf.php'; require_role('admin'); @@ -18,6 +19,7 @@ $msg = ''; $msg_type = 'success'; if ($_SERVER['REQUEST_METHOD'] === 'POST') { + csrf_verify(); $action = $_POST['action'] ?? ''; if ($action === 'delete') { @@ -185,6 +187,7 @@ $filter = $_GET['filter'] ?? 'all';
+ @@ -192,6 +195,7 @@ $filter = $_GET['filter'] ?? 'all';
+ @@ -201,6 +205,7 @@ $filter = $_GET['filter'] ?? 'all'; + diff --git a/admin/profile.php b/admin/profile.php index 4dff128..1855a78 100644 --- a/admin/profile.php +++ b/admin/profile.php @@ -4,6 +4,7 @@ */ require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../includes/auth.php'; +require_once __DIR__ . '/../includes/csrf.php'; require_auth(); @@ -31,6 +32,7 @@ if (!$profile) { // ── Handle form submissions ─────────────────────────────────────────────────── if ($_SERVER['REQUEST_METHOD'] === 'POST') { + csrf_verify(); $action = $_POST['action'] ?? ''; // ── Update profile ─────────────────────────────────────────────────────── @@ -179,6 +181,7 @@ $role_labels = ['superadmin'=>'Superadmin','admin'=>'Admin','superuser'=>'Superu

Display Name

+
@@ -195,6 +198,7 @@ $role_labels = ['superadmin'=>'Superadmin','admin'=>'Admin','superuser'=>'Superu

Email Address

+
@@ -216,6 +220,7 @@ $role_labels = ['superadmin'=>'Superadmin','admin'=>'Admin','superuser'=>'Superu

Change Password

+
@@ -239,6 +244,7 @@ $role_labels = ['superadmin'=>'Superadmin','admin'=>'Admin','superuser'=>'Superu

Rosary Limit

+
diff --git a/admin/settings.php b/admin/settings.php index 1d16b26..ff4e875 100644 --- a/admin/settings.php +++ b/admin/settings.php @@ -5,6 +5,7 @@ require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../includes/auth.php'; require_once __DIR__ . '/../includes/mailer.php'; +require_once __DIR__ . '/../includes/csrf.php'; require_role('superadmin'); @@ -15,16 +16,23 @@ $error = ''; // Save settings if ($_SERVER['REQUEST_METHOD'] === 'POST') { + csrf_verify(); $action = $_POST['action'] ?? 'save'; if ($action === 'save') { - $keys = ['site_name','site_url','smtp_host','smtp_port','smtp_user','smtp_pass','smtp_from','smtp_from_name', + // smtp_pass is handled separately: the form always renders it blank + // (see below), so a blank submission means "leave it unchanged", + // not "clear it". + $keys = ['site_name','site_url','smtp_host','smtp_port','smtp_user','smtp_from','smtp_from_name', 'donate_enabled','donate_type','donate_handle','donate_label']; foreach ($keys as $k) { if (isset($_POST[$k])) { set_setting($k, trim($_POST[$k])); } } + if (!empty($_POST['smtp_pass'])) { + set_setting('smtp_pass', trim($_POST['smtp_pass'])); + } $message = 'Settings saved.'; $site_name = get_setting('site_name', APP_NAME); // refresh } @@ -53,7 +61,7 @@ $settings = [ 'smtp_host' => get_setting('smtp_host'), 'smtp_port' => get_setting('smtp_port', '587'), 'smtp_user' => get_setting('smtp_user'), - 'smtp_pass' => get_setting('smtp_pass'), + 'smtp_pass_set' => get_setting('smtp_pass') !== '', 'smtp_from' => get_setting('smtp_from'), 'smtp_from_name' => get_setting('smtp_from_name', 'Rosary Presenter'), 'donate_enabled' => get_setting('donate_enabled', '0'), @@ -105,6 +113,7 @@ $settings = [

Site Settings

+
@@ -155,9 +164,12 @@ $settings = [
+ placeholder="">
+

+ +

@@ -223,6 +235,7 @@ $settings = [

Test Email

Send a test email to to verify your SMTP settings.

+ diff --git a/admin/setup.php b/admin/setup.php index 7488ac5..29cf5ef 100644 --- a/admin/setup.php +++ b/admin/setup.php @@ -4,6 +4,7 @@ */ require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../includes/auth.php'; +require_once __DIR__ . '/../includes/csrf.php'; require_auth(); @@ -46,6 +47,7 @@ $page_title = $session ? 'Edit Session' : 'New Session'; <?= $page_title ?> — <?= htmlspecialchars($site_name) ?> + @@ -80,6 +82,7 @@ $page_title = $session ? 'Edit Session' : 'New Session';
+ diff --git a/admin/users.php b/admin/users.php index 5cdb3c6..6c505ff 100644 --- a/admin/users.php +++ b/admin/users.php @@ -5,6 +5,7 @@ require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../includes/auth.php'; require_once __DIR__ . '/../includes/mailer.php'; +require_once __DIR__ . '/../includes/csrf.php'; require_role('admin'); @@ -18,6 +19,7 @@ $errors = []; // ── Handle actions ─────────────────────────────────────────────────────────── if ($_SERVER['REQUEST_METHOD'] === 'POST') { + csrf_verify(); $action = $_POST['action'] ?? ''; // ── Create user ────────────────────────────────────────────────────────── @@ -250,6 +252,7 @@ $role_colors = [

Create New User

+
@@ -331,6 +334,7 @@ $role_colors = [ + @@ -352,6 +357,7 @@ $role_colors = [
+
@@ -388,6 +394,7 @@ $role_colors = [
+
diff --git a/api/builder_session.php b/api/builder_session.php index 3024e96..adaf828 100644 --- a/api/builder_session.php +++ b/api/builder_session.php @@ -16,6 +16,7 @@ */ require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../includes/auth.php'; +require_once __DIR__ . '/../includes/csrf.php'; header('Content-Type: application/json'); @@ -34,6 +35,8 @@ $uid = (int)$user['id']; if ($_SERVER['REQUEST_METHOD'] !== 'POST') json_err('Method not allowed', 405); +csrf_verify(); + $body = json_decode(file_get_contents('php://input'), true); if (!$body) json_err('Invalid JSON'); diff --git a/api/delete_audio.php b/api/delete_audio.php index 2a2f670..3eccb9e 100644 --- a/api/delete_audio.php +++ b/api/delete_audio.php @@ -11,6 +11,7 @@ */ require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../includes/auth.php'; +require_once __DIR__ . '/../includes/csrf.php'; header('Content-Type: application/json'); require_auth(); @@ -27,6 +28,8 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') { exit; } +csrf_verify(); + $key = trim($_POST['key'] ?? ''); if (!preg_match('/^[a-z0-9_]+$/', $key) || strlen($key) > 100) { http_response_code(400); diff --git a/api/prayers_api.php b/api/prayers_api.php index 663a769..fcf5b6d 100644 --- a/api/prayers_api.php +++ b/api/prayers_api.php @@ -10,6 +10,7 @@ */ require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../includes/auth.php'; +require_once __DIR__ . '/../includes/csrf.php'; header('Content-Type: application/json'); @@ -28,6 +29,9 @@ $uid = (int)$user['id']; $is_admin = has_role('admin'); $method = $_SERVER['REQUEST_METHOD']; +// CSRF check applies to state-changing methods only (GET is read-only) +if ($method !== 'GET') csrf_verify(); + // ───────────────────────────────────────────────── // GET — list prayers // ───────────────────────────────────────────────── diff --git a/api/save_session.php b/api/save_session.php index 971492f..8215dfe 100644 --- a/api/save_session.php +++ b/api/save_session.php @@ -9,6 +9,7 @@ */ require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../includes/auth.php'; +require_once __DIR__ . '/../includes/csrf.php'; header('Content-Type: application/json'); @@ -23,6 +24,8 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') { exit; } +csrf_verify(); + // Collect and sanitize input $id = isset($_POST['id']) && $_POST['id'] !== '' ? (int)$_POST['id'] : null; $name = trim($_POST['name'] ?? ''); diff --git a/api/toggle_pin.php b/api/toggle_pin.php index 9ad4f9c..3f5b8ac 100644 --- a/api/toggle_pin.php +++ b/api/toggle_pin.php @@ -12,6 +12,7 @@ */ require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../includes/auth.php'; +require_once __DIR__ . '/../includes/csrf.php'; header('Content-Type: application/json'); @@ -29,6 +30,8 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') { exit; } +csrf_verify(); + $type = trim($_POST['type'] ?? ''); $id = (int)($_POST['id'] ?? 0); diff --git a/api/upload_audio.php b/api/upload_audio.php index 050166e..bab99eb 100644 --- a/api/upload_audio.php +++ b/api/upload_audio.php @@ -12,6 +12,7 @@ */ require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../includes/auth.php'; +require_once __DIR__ . '/../includes/csrf.php'; header('Content-Type: application/json'); require_auth(); @@ -28,6 +29,8 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') { exit; } +csrf_verify(); + $key = trim($_POST['key'] ?? ''); if (!preg_match('/^[a-z0-9_]+$/', $key) || strlen($key) > 100) { http_response_code(400); diff --git a/api/upload_photo.php b/api/upload_photo.php index 3bc8108..62cf871 100644 --- a/api/upload_photo.php +++ b/api/upload_photo.php @@ -6,6 +6,7 @@ */ require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../includes/auth.php'; +require_once __DIR__ . '/../includes/csrf.php'; header('Content-Type: application/json'); @@ -17,6 +18,8 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') { exit; } +csrf_verify(); + if (!isset($_FILES['photo']) || $_FILES['photo']['error'] !== UPLOAD_ERR_OK) { $upload_errors = [ UPLOAD_ERR_INI_SIZE => 'File exceeds server upload limit', diff --git a/assets/js/builder.js b/assets/js/builder.js index 23c01d5..e653ff8 100644 --- a/assets/js/builder.js +++ b/assets/js/builder.js @@ -5,6 +5,8 @@ (function () { 'use strict'; + const CSRF_TOKEN = (document.querySelector('meta[name="csrf-token"]') || {}).content || ''; + /* ───────────────────────────────────────────────────────── State ───────────────────────────────────────────────────────── */ @@ -385,7 +387,7 @@ fetch(url, { method: method, - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': CSRF_TOKEN }, body: JSON.stringify({ name, leader_text: sendLeader, all_text: sendAll, default_bead_type: beadType, is_global: global }), }) .then(r => r.json()) @@ -462,7 +464,7 @@ fetch(BASE_URL + '/api/builder_session.php', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': CSRF_TOKEN }, body: JSON.stringify(payload), }) .then(r => r.json()) diff --git a/assets/js/setup.js b/assets/js/setup.js index c8f68cc..f3c6311 100644 --- a/assets/js/setup.js +++ b/assets/js/setup.js @@ -104,6 +104,7 @@ const fd = new FormData(); fd.append('photo', file); + fd.append('csrf_token', document.querySelector('meta[name="csrf-token"]').content); try { const res = await fetch(BASE_URL + '/api/upload_photo.php', { method: 'POST', body: fd }); diff --git a/data/prayers.php b/data/prayers.php index b132836..f388acf 100644 --- a/data/prayers.php +++ b/data/prayers.php @@ -1211,3 +1211,10 @@ $closing = [ 'photo_path' => null, ], ]; + +return compact( + 'opening', 'mysteries', 'hail_holy_queen', 'rosary_closing_prayer', + 'litany_passion', 'novena_prayers', 'litany_departed', 'closing', + 'divine_mercy_opening', 'divine_mercy_novena_prayers', + 'divine_mercy_chaplet_opening', 'divine_mercy_chaplet_close' +); diff --git a/forgot_password.php b/forgot_password.php index 956ad9a..3795d3a 100644 --- a/forgot_password.php +++ b/forgot_password.php @@ -1,11 +1,13 @@ +
diff --git a/includes/auth.php b/includes/auth.php index c5ab5d5..9bd0334 100644 --- a/includes/auth.php +++ b/includes/auth.php @@ -66,3 +66,40 @@ function can_create_rosary(int $user_id, int $limit): bool { $st->execute([$user_id, $user_id]); return (int)$st->fetchColumn() < $limit; } + +const LOGIN_LOCKOUT_THRESHOLD = 5; +const LOGIN_LOCKOUT_MINUTES = 15; + +/** True if this user account is currently locked out from login attempts. */ +function is_locked_out(array $user): bool { + if (empty($user['locked_until'])) return false; + return strtotime($user['locked_until']) > time(); +} + +/** Minutes remaining until a locked-out account can try again (0 if not locked). */ +function login_lockout_minutes_remaining(array $user): int { + if (!is_locked_out($user)) return 0; + return (int)ceil((strtotime($user['locked_until']) - time()) / 60); +} + +/** Record a failed login attempt; locks the account after LOGIN_LOCKOUT_THRESHOLD attempts. */ +function record_login_failure(int $user_id): void { + $pdo = get_pdo(); + $pdo->prepare('UPDATE users SET failed_login_attempts = failed_login_attempts + 1 WHERE id = ?') + ->execute([$user_id]); + + $st = $pdo->prepare('SELECT failed_login_attempts FROM users WHERE id = ?'); + $st->execute([$user_id]); + $attempts = (int)$st->fetchColumn(); + + if ($attempts >= LOGIN_LOCKOUT_THRESHOLD) { + $locked_until = date('Y-m-d H:i:s', time() + LOGIN_LOCKOUT_MINUTES * 60); + $pdo->prepare('UPDATE users SET locked_until = ? WHERE id = ?')->execute([$locked_until, $user_id]); + } +} + +/** Reset the failed-attempt counter and any lockout after a successful login. */ +function record_login_success(int $user_id): void { + get_pdo()->prepare('UPDATE users SET failed_login_attempts = 0, locked_until = NULL WHERE id = ?') + ->execute([$user_id]); +} diff --git a/includes/build_slides.php b/includes/build_slides.php index cf21240..5b957f4 100644 --- a/includes/build_slides.php +++ b/includes/build_slides.php @@ -8,7 +8,18 @@ * Applies variable substitution for {name}, {pronoun}, {pronoun_obj}, {pronoun_poss}. */ -require_once __DIR__ . '/../data/prayers.php'; +/** + * Load (and memoize) the prayer content arrays from data/prayers.php. + * That file returns its data explicitly rather than relying on being + * require_once'd for its side-effect of defining global variables. + */ +function get_prayer_data(): array { + static $data = null; + if ($data === null) { + $data = require __DIR__ . '/../data/prayers.php'; + } + return $data; +} /** * Fetch ordered builder steps (with prayer text) for a custom session. @@ -74,10 +85,12 @@ function build_chaplet_decade_slides(int $decade_num, int $of_bead_index, int $h * @return array Flat array of slide arrays */ function build_slides(array $session): array { - global $opening, $mysteries, $hail_holy_queen, $rosary_closing_prayer, - $litany_passion, $novena_prayers, $litany_departed, $closing, - $divine_mercy_opening, $divine_mercy_novena_prayers, - $divine_mercy_chaplet_opening, $divine_mercy_chaplet_close; + ['opening' => $opening, 'mysteries' => $mysteries, 'hail_holy_queen' => $hail_holy_queen, + 'rosary_closing_prayer' => $rosary_closing_prayer, 'litany_passion' => $litany_passion, + 'novena_prayers' => $novena_prayers, 'litany_departed' => $litany_departed, 'closing' => $closing, + 'divine_mercy_opening' => $divine_mercy_opening, 'divine_mercy_novena_prayers' => $divine_mercy_novena_prayers, + 'divine_mercy_chaplet_opening' => $divine_mercy_chaplet_opening, + 'divine_mercy_chaplet_close' => $divine_mercy_chaplet_close] = get_prayer_data(); $slides = []; diff --git a/includes/csrf.php b/includes/csrf.php new file mode 100644 index 0000000..1349b22 --- /dev/null +++ b/includes/csrf.php @@ -0,0 +1,46 @@ + carrying the CSRF token, for use inside a . */ +function csrf_field(): string { + return ''; +} + +/** + * Verify the CSRF token on the current request (checks $_POST['csrf_token'], + * falling back to the X-CSRF-Token header for JSON-body API calls). Aborts + * the request with a 403 on failure. + */ +function csrf_verify(): void { + _auth_start(); + $sent = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; + $expected = $_SESSION['csrf_token'] ?? ''; + + if ($sent === '' || $expected === '' || !hash_equals($expected, $sent)) { + http_response_code(403); + $accept = $_SERVER['HTTP_ACCEPT'] ?? ''; + $ctype = $_SERVER['CONTENT_TYPE'] ?? ''; + if (str_contains($accept, 'application/json') || str_contains($ctype, 'application/json')) { + header('Content-Type: application/json'); + echo json_encode(['error' => 'Invalid or missing security token. Please refresh the page and try again.']); + } else { + echo '' + . '

Security Check Failed

' + . '

Invalid or missing security token. Please go back, refresh the page, and try again.

' + . ''; + } + exit; + } +} diff --git a/index.php b/index.php index 5538b8c..4079bfd 100644 --- a/index.php +++ b/index.php @@ -2,14 +2,11 @@ /** * index.php — Public home page. Shows all public rosary sessions. * No auth required. - * - * MIGRATION (run once on existing installs): - * ALTER TABLE sessions ADD COLUMN is_pinned TINYINT(1) NOT NULL DEFAULT 0; - * ALTER TABLE novena_groups ADD COLUMN is_pinned TINYINT(1) NOT NULL DEFAULT 0; */ require_once __DIR__ . '/config/db.php'; require_once __DIR__ . '/includes/auth.php'; require_once __DIR__ . '/includes/donate.php'; +require_once __DIR__ . '/includes/csrf.php'; _auth_start(); $pdo = get_pdo(); @@ -205,6 +202,7 @@ function render_card(array $row, bool $is_admin, array $mystery_labels, array $o <?= htmlspecialchars($site_name) ?> + @@ -411,6 +409,7 @@ var PUBLIC_USERS = '', '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); ?> diff --git a/login.php b/login.php index 3fe051b..814b7d4 100644 --- a/login.php +++ b/login.php @@ -1,6 +1,7 @@ execute([$username, $username]); $user = $stmt->fetch(); - if (!$user || !password_verify($password, $user['password_hash'])) { + if ($user && is_locked_out($user)) { + $mins = login_lockout_minutes_remaining($user); + $error = "Too many failed login attempts. Please try again in {$mins} minute" . ($mins === 1 ? '' : 's') . '.'; + } elseif (!$user || !password_verify($password, $user['password_hash'])) { + if ($user) record_login_failure((int)$user['id']); $error = 'Invalid username or password.'; } elseif (!$user['email_confirmed']) { $error = 'Please confirm your email address before logging in. Check your inbox for the confirmation link.'; } else { + record_login_success((int)$user['id']); session_regenerate_id(true); $_SESSION['user_id'] = $user['id']; $_SESSION['username'] = $user['username']; @@ -72,6 +79,7 @@ $reset_msg = isset($_GET['reset']) ? 'Password reset successfully. Pleas +
setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - -$log = []; - -// ----------------------------------------------------------------------- -// 1. Create novena_groups table -// ----------------------------------------------------------------------- -$pdo->exec(" - CREATE TABLE IF NOT EXISTS novena_groups ( - id INT AUTO_INCREMENT PRIMARY KEY, - 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, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP - ) -"); -$log[] = 'novena_groups table ready.'; - -// ----------------------------------------------------------------------- -// 2. Add novena_group_id to sessions (silent if already present) -// ----------------------------------------------------------------------- -try { - $pdo->exec('ALTER TABLE sessions ADD COLUMN novena_group_id INT NULL'); - $log[] = 'Added novena_group_id column to sessions.'; -} catch (PDOException $e) { - $log[] = 'novena_group_id column already exists — skipped.'; -} - -// ----------------------------------------------------------------------- -// 3. Migrate existing novena sessions that have no group yet -// ----------------------------------------------------------------------- -$novenas = $pdo->query(" - SELECT * FROM sessions - WHERE occasion = 'novena_deceased' - AND (novena_group_id IS NULL OR novena_group_id = 0) - ORDER BY name, novena_day -")->fetchAll(); - -if (empty($novenas)) { - $log[] = 'No ungrouped novena sessions found — nothing to migrate.'; -} else { - // Bucket sessions by the base name (strip trailing " — Day N") - $buckets = []; - foreach ($novenas as $n) { - $base = preg_replace('/ — Day \d+$/', '', $n['name']); - $buckets[$base][] = $n; - } - - $ins_grp = $pdo->prepare(' - INSERT INTO novena_groups - (name, mystery_set, subject_name, subject_pronoun, subject_dates, photo_path) - VALUES (?, ?, ?, ?, ?, ?) - '); - $upd_ses = $pdo->prepare('UPDATE sessions SET novena_group_id = ? WHERE id = ?'); - - foreach ($buckets as $base_name => $days) { - $first = $days[0]; - $ins_grp->execute([ - $base_name, - $first['mystery_set'], - $first['subject_name'], - $first['subject_pronoun'], - $first['subject_dates'], - $first['photo_path'], - ]); - $gid = (int)$pdo->lastInsertId(); - foreach ($days as $day) { - $upd_ses->execute([$gid, $day['id']]); - } - $log[] = 'Created group #' . $gid . ' "' . $base_name . '" — ' . count($days) . ' day(s) linked.'; - } -} - -?> - - - - Migrate v2 - - - -

Migration v2 Complete

-
    - -
  • - -
-
- ⚠ Delete migrate_v2.php from your server now. - It is no longer needed and should not be left publicly accessible. -
- - diff --git a/migrate_v3.php b/migrate_v3.php deleted file mode 100644 index 9ee1ce0..0000000 --- a/migrate_v3.php +++ /dev/null @@ -1,277 +0,0 @@ -exec($sql); - $log[] = ['ok', $label]; - } catch (PDOException $e) { - // Ignore "already exists" / "duplicate column" errors (1060, 1061, 1050) - if (in_array($e->errorInfo[1], [1060, 1061, 1050], true)) { - $log[] = ['skip', $label . ' (already exists, skipped)']; - } else { - $errors[] = $label . ': ' . $e->getMessage(); - $log[] = ['err', $label . ': ' . $e->getMessage()]; - } - } -} - -// ── 1. Create users table ──────────────────────────────────────────────────── -run_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 - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci -", $log, $errors); - -// ── 2. Create site_settings table ─────────────────────────────────────────── -run_sql($pdo, 'Create site_settings table', " - 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 -", $log, $errors); - -// ── 3. Create sessions table (fresh install) ──────────────────────────────── -run_sql($pdo, 'Create sessions table', " - CREATE TABLE IF NOT EXISTS sessions ( - id INT AUTO_INCREMENT PRIMARY KEY, - name VARCHAR(255) NOT NULL, - occasion VARCHAR(50) NOT NULL, - mystery_set VARCHAR(50) NOT NULL, - novena_day TINYINT NULL, - subject_name VARCHAR(255) NULL, - subject_pronoun VARCHAR(10) NULL, - subject_dates VARCHAR(150) NULL, - photo_path VARCHAR(500) NULL, - novena_group_id INT 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. Create novena_groups table (fresh install) ──────────────────────────── -run_sql($pdo, 'Create novena_groups table', " - CREATE TABLE IF NOT EXISTS novena_groups ( - id INT AUTO_INCREMENT PRIMARY KEY, - 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, - 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); - -// ── 5. Add columns to sessions ─────────────────────────────────────────────── -foreach ([ - ['Add sessions.user_id', "ALTER TABLE sessions ADD COLUMN user_id INT NULL AFTER id"], - ['Add sessions.is_public', "ALTER TABLE sessions ADD COLUMN is_public TINYINT(1) NOT NULL DEFAULT 1 AFTER user_id"], - ['Add sessions.slug', "ALTER TABLE sessions ADD COLUMN slug VARCHAR(255) NULL AFTER is_public"], -] as [$label, $sql]) { - run_sql($pdo, $label, $sql, $log, $errors); -} - -// ── 6. Add columns to novena_groups ───────────────────────────────────────── -foreach ([ - ['Add novena_groups.user_id', "ALTER TABLE novena_groups ADD COLUMN user_id INT NULL AFTER id"], - ['Add novena_groups.is_public', "ALTER TABLE novena_groups ADD COLUMN is_public TINYINT(1) NOT NULL DEFAULT 1 AFTER user_id"], - ['Add novena_groups.slug', "ALTER TABLE novena_groups ADD COLUMN slug VARCHAR(255) NULL AFTER is_public"], -] as [$label, $sql]) { - run_sql($pdo, $label, $sql, $log, $errors); -} - -// ── 7. Seed site_settings ──────────────────────────────────────────────────── -$settings = [ - 'smtp_host' => '', - 'smtp_port' => '587', - 'smtp_user' => '', - 'smtp_pass' => '', - 'smtp_from' => '', - 'smtp_from_name' => 'Rosary Presenter', - 'site_name' => 'Rosary Presenter', - 'site_url' => '', -]; -$ins_setting = $pdo->prepare('INSERT IGNORE INTO site_settings (key_name, val) VALUES (?, ?)'); -foreach ($settings as $k => $v) { - try { - $ins_setting->execute([$k, $v]); - $log[] = ['ok', "Seeded site_settings: {$k}"]; - } catch (PDOException $e) { - $errors[] = "site_settings {$k}: " . $e->getMessage(); - } -} - -// ── 8. Seed superadmin user ────────────────────────────────────────────────── -$supadmin_hash = password_hash('supadmin', PASSWORD_BCRYPT); -try { - $pdo->prepare(" - INSERT IGNORE INTO users (username, email, password_hash, display_name, role, rosary_limit, email_confirmed) - VALUES ('supadmin', 'admin@example.com', ?, 'Super Admin', 'superadmin', -1, 1) - ")->execute([$supadmin_hash]); - $log[] = ['ok', 'Seeded superadmin user']; -} catch (PDOException $e) { - $errors[] = 'Seed superadmin: ' . $e->getMessage(); -} - -// Get superadmin ID -$supadmin_row = $pdo->query("SELECT id FROM users WHERE username = 'supadmin'")->fetch(); -$supadmin_id = $supadmin_row ? (int)$supadmin_row['id'] : null; - -if ($supadmin_id) { - // ── 9. Assign unowned sessions to superadmin ───────────────────────────── - try { - $affected = $pdo->prepare("UPDATE sessions SET user_id = ? WHERE user_id IS NULL") - ->execute([$supadmin_id]); - $log[] = ['ok', 'Assigned orphan sessions to superadmin']; - } catch (PDOException $e) { - $errors[] = 'Assign sessions: ' . $e->getMessage(); - } - - // ── 10. Assign unowned novena_groups to superadmin ──────────────────────── - try { - $pdo->prepare("UPDATE novena_groups SET user_id = ? WHERE user_id IS NULL") - ->execute([$supadmin_id]); - $log[] = ['ok', 'Assigned orphan novena_groups to superadmin']; - } catch (PDOException $e) { - $errors[] = 'Assign novena_groups: ' . $e->getMessage(); - } - - // ── 11. Generate slugs for sessions without one ─────────────────────────── - try { - $sessions_no_slug = $pdo->query("SELECT id, name, user_id FROM sessions WHERE slug IS NULL OR slug = ''")->fetchAll(); - $upd_slug = $pdo->prepare("UPDATE sessions SET slug = ? WHERE id = ?"); - foreach ($sessions_no_slug as $row) { - $uid = (int)($row['user_id'] ?? $supadmin_id); - $base = slugify($row['name']); - $slug = unique_slug($row['name'], $uid, 'sessions', (int)$row['id']); - $upd_slug->execute([$slug, $row['id']]); - } - $log[] = ['ok', 'Generated slugs for ' . count($sessions_no_slug) . ' sessions']; - } catch (PDOException $e) { - $errors[] = 'Generate session slugs: ' . $e->getMessage(); - } - - // ── 12. Generate slugs for novena_groups without one ───────────────────── - try { - $groups_no_slug = $pdo->query("SELECT id, name, user_id FROM novena_groups WHERE slug IS NULL OR slug = ''")->fetchAll(); - $upd_gslug = $pdo->prepare("UPDATE novena_groups SET slug = ? WHERE id = ?"); - foreach ($groups_no_slug as $row) { - $uid = (int)($row['user_id'] ?? $supadmin_id); - $slug = unique_slug($row['name'], $uid, 'novena_groups', (int)$row['id']); - $upd_gslug->execute([$slug, $row['id']]); - } - $log[] = ['ok', 'Generated slugs for ' . count($groups_no_slug) . ' novena groups']; - } catch (PDOException $e) { - $errors[] = 'Generate novena_group slugs: ' . $e->getMessage(); - } -} - -// ── Render result page ─────────────────────────────────────────────────────── -$overall_ok = empty($errors); -?> - - - - - -Migration v3 — <?= APP_NAME ?> - - - -
-

— Migration v3

- - - - - - - -
- ⚠ DELETE this file (migrate_v3.php) from your server immediately after reviewing this page. -
- -
-

Superadmin Credentials

-
- Username: supadmin
- Password: supadmin
- Role: superadmin -
-

- CHANGE THE PASSWORD IMMEDIATELY — go to /admin/profile after logging in.
- Also update the email from admin@example.com to your real email. -

-
- -
-

Migration Log

- - - - - - - - - - -
StatusStep
- -
-
- -
-

Next Steps

-
    -
  1. Delete migrate_v3.php from your server.
  2. -
  3. Go to /login and sign in with supadmin / supadmin.
  4. -
  5. Go to /admin/profile and change your password and email.
  6. -
  7. Go to /admin/settings to configure SMTP and your site URL.
  8. -
-
-
- - diff --git a/migrate_v4.php b/migrate_v4.php deleted file mode 100644 index a31ea47..0000000 --- a/migrate_v4.php +++ /dev/null @@ -1,182 +0,0 @@ -exec($sql); - $log[] = ['ok', $label]; - } catch (PDOException $e) { - if (in_array($e->errorInfo[1], [1060, 1061, 1050], true)) { - $log[] = ['skip', $label . ' (already exists)']; - } else { - $log[] = ['err', $label . ': ' . $e->getMessage()]; - } - } -} - -mig_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, - 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); - -mig_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_order INT NOT NULL DEFAULT 0, - prayer_id INT NOT 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); - -// Seed standard global prayers (skip if already done) -$existing = (int)$pdo->query("SELECT COUNT(*) FROM custom_prayers WHERE is_global=1")->fetchColumn(); -if ($existing > 0) { - $log[] = ['skip', "Standard prayers already seeded ({$existing} 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', - 'leader' => "In the name of the Father,\nand of the Son,\nand of the Holy Spirit.", - 'all' => 'Amen.', - ], - [ - 'name' => 'Apostles\' Creed', - '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', - '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', - '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', - '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', - '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', - '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', - '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', - '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', - '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', - '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)', - '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', - 'leader' => "For the sake of His sorrowful Passion,", - 'all' => "have mercy on us and on the whole world.", - ], - [ - 'name' => 'Holy God (Divine Mercy Closing)', - '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', - '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', - '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.', - ], - ]; - - $stmt = $pdo->prepare( - "INSERT INTO custom_prayers (name, leader_text, all_text, is_global, created_by) - VALUES (?, ?, ?, 1, ?)" - ); - foreach ($prayers as $p) { - $stmt->execute([$p['name'], $p['leader'], $p['all'], $creator_id]); - } - $log[] = ['ok', 'Seeded ' . count($prayers) . ' standard global prayers']; -} - -?> - - - - Migrate v4 - - - -

Migrate v4 — Rosary Builder Tables

-
    - -
  • - - -
  • - -
- $l[0] === 'err')): ?> -
- Migration complete. - Delete this file now: migrate_v4.php -
- - - diff --git a/migrate_v5.php b/migrate_v5.php deleted file mode 100644 index 6179a22..0000000 --- a/migrate_v5.php +++ /dev/null @@ -1,67 +0,0 @@ -exec($sql); - $log[] = ['ok', $label]; - } catch (PDOException $e) { - if (in_array($e->errorInfo[1], [1060, 1061, 1054], true)) { - $log[] = ['skip', $label . ' (already exists)']; - } else { - $log[] = ['err', $label . ': ' . $e->getMessage()]; - } - } -} - -mig5_sql($pdo, 'Add step_type column', " - ALTER TABLE builder_steps - ADD COLUMN step_type ENUM('prayer','bead') NOT NULL DEFAULT 'prayer' AFTER session_id -", $log); - -mig5_sql($pdo, 'Add bead_type column', " - ALTER TABLE builder_steps - ADD COLUMN bead_type ENUM('small','large','crucifix') NULL AFTER step_type -", $log); - -mig5_sql($pdo, 'Make prayer_id nullable', " - ALTER TABLE builder_steps - MODIFY COLUMN prayer_id INT NULL -", $log); - -?> - - - - Migrate v5 - - - -

Migrate v5 — Bead Separator Support

-
    - -
  • - - -
  • - -
- $l[0] === 'err')): ?> -
- Migration complete. Delete this file: migrate_v5.php -
- - - diff --git a/migrate_v6.php b/migrate_v6.php deleted file mode 100644 index 4c2977a..0000000 --- a/migrate_v6.php +++ /dev/null @@ -1,74 +0,0 @@ -exec($sql); - $log[] = ['ok', $label]; - } catch (PDOException $e) { - if (in_array($e->errorInfo[1], [1060, 1054], true)) { - $log[] = ['skip', $label . ' (already exists)']; - } else { - $log[] = ['err', $label . ': ' . $e->getMessage()]; - } - } -} - -mig6_sql($pdo, 'Add default_bead_type to custom_prayers', " - ALTER TABLE custom_prayers - ADD COLUMN default_bead_type ENUM('small','large','crucifix') NULL AFTER all_text -", $log); - -// Set defaults for the seeded standard global prayers -$defaults = [ - 'Sign of the Cross' => 'crucifix', - 'Our Father' => 'large', - 'Hail Mary' => 'small', - 'Eternal Father (Divine Mercy)' => 'large', - 'For the Sake of His Sorrowful Passion' => 'small', -]; - -$updated = 0; -$st = $pdo->prepare( - "UPDATE custom_prayers SET default_bead_type = ? WHERE name = ? AND is_global = 1" -); -foreach ($defaults as $name => $bead) { - $st->execute([$bead, $name]); - if ($st->rowCount() > 0) $updated++; -} -$log[] = ['ok', "Updated default bead types for {$updated} standard prayers"]; - -?> - - - - Migrate v6 - - - -

Migrate v6 — Prayer Bead Defaults

-
    - -
  • - - -
  • - -
- $l[0] === 'err')): ?> -
Migration complete. Delete this file: migrate_v6.php
- - - diff --git a/novena_group.php b/novena_group.php deleted file mode 100644 index fd35208..0000000 --- a/novena_group.php +++ /dev/null @@ -1,6 +0,0 @@ - '', 'display_name' => '', 'email' => '']; if ($_SERVER['REQUEST_METHOD'] === 'POST') { + csrf_verify(); $username = trim($_POST['username'] ?? ''); $display_name = trim($_POST['display_name'] ?? ''); $email = trim($_POST['email'] ?? ''); @@ -132,6 +134,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { +
+