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
+16 -2
View File
@@ -32,10 +32,12 @@ cp config/db.example.php config/db.php
### 2. Create the database schema ### 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**. 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 ### 3. Configure the web server
#### Apache — `.htaccess` is included. Enable `mod_rewrite` and set `AllowOverride All`. #### 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. 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 ## Deployment Checklist
- [ ] `config/db.php` filled in with production credentials - [ ] `config/db.php` filled in with production credentials
@@ -92,7 +104,8 @@ Rosary/
├── data/ ├── data/
│ └── prayers.php # All prayer text + build_decade_slides() │ └── prayers.php # All prayer text + build_decade_slides()
├── includes/ ├── 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 │ ├── build_slides.php
│ ├── donate.php │ ├── donate.php
│ └── mailer.php │ └── mailer.php
@@ -100,6 +113,7 @@ Rosary/
├── index.php # Public home — card grid of sessions ├── index.php # Public home — card grid of sessions
├── present.php # Presentation player (public) ├── present.php # Presentation player (public)
├── novena_public.php # Novena day-picker (public) ├── novena_public.php # Novena day-picker (public)
├── schema.sql # Canonical database schema (structure only)
├── install.php # Run once, then delete ├── install.php # Run once, then delete
└── .htaccess # URL rewriting └── .htaccess # URL rewriting
``` ```
+4
View File
@@ -6,6 +6,7 @@
*/ */
require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../includes/auth.php'; require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/csrf.php';
require_auth(); require_auth();
if (!has_role('admin')) { if (!has_role('admin')) {
@@ -182,6 +183,7 @@ foreach ($AUDIO_KEYS as $keys) {
padding:16px 20px; font-size:14px; color:#0c4a6e; margin-bottom:28px; } padding:16px 20px; font-size:14px; color:#0c4a6e; margin-bottom:28px; }
.help-note strong { display:block; margin-bottom:6px; } .help-note strong { display:block; margin-bottom:6px; }
</style> </style>
<meta name="csrf-token" content="<?= htmlspecialchars(csrf_token()) ?>">
<script>var BASE_URL = '<?= BASE_URL ?>';</script> <script>var BASE_URL = '<?= BASE_URL ?>';</script>
</head> </head>
<body> <body>
@@ -341,6 +343,7 @@ foreach ($AUDIO_KEYS as $keys) {
var fd = new FormData(); var fd = new FormData();
fd.append('key', key); fd.append('key', key);
fd.append('audio', file); 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 }) fetch(BASE_URL + '/api/upload_audio.php', { method: 'POST', body: fd })
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
@@ -365,6 +368,7 @@ foreach ($AUDIO_KEYS as $keys) {
var fd = new FormData(); var fd = new FormData();
fd.append('key', key); 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 }) fetch(BASE_URL + '/api/delete_audio.php', { method: 'POST', body: fd })
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
+2
View File
@@ -6,6 +6,7 @@
*/ */
require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../includes/auth.php'; require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/csrf.php';
require_role('superuser'); require_role('superuser');
@@ -72,6 +73,7 @@ $page_title = $session ? 'Edit: ' . htmlspecialchars($session['name']) : 'Rosary
<title><?= $page_title ?> — <?= htmlspecialchars($site_name) ?></title> <title><?= $page_title ?> — <?= htmlspecialchars($site_name) ?></title>
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/setup.css"> <link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/setup.css">
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/builder.css?v=1"> <link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/builder.css?v=1">
<meta name="csrf-token" content="<?= htmlspecialchars(csrf_token()) ?>">
</head> </head>
<body> <body>
+4
View File
@@ -4,6 +4,7 @@
*/ */
require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../includes/auth.php'; require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/csrf.php';
require_auth(); require_auth();
@@ -15,6 +16,7 @@ $site_name = get_setting('site_name', APP_NAME);
// Handle deletions // Handle deletions
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_verify();
if (isset($_POST['delete_group_id'])) { if (isset($_POST['delete_group_id'])) {
$gid = (int)$_POST['delete_group_id']; $gid = (int)$_POST['delete_group_id'];
// Verify ownership or admin // Verify ownership or admin
@@ -200,6 +202,7 @@ $novena_created = isset($_GET['novena_created']) ? (int)$_GET['novena_created']
<?php if ($is_admin || (int)$row['user_id'] === $uid): ?> <?php if ($is_admin || (int)$row['user_id'] === $uid): ?>
<form method="post" style="display:inline" <form method="post" style="display:inline"
onsubmit="return confirm('Delete this entire novena (all 9 days)?')"> onsubmit="return confirm('Delete this entire novena (all 9 days)?')">
<?= csrf_field() ?>
<input type="hidden" name="delete_group_id" value="<?= $row['id'] ?>"> <input type="hidden" name="delete_group_id" value="<?= $row['id'] ?>">
<button type="submit" class="btn btn-sm btn-danger">Delete</button> <button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form> </form>
@@ -241,6 +244,7 @@ $novena_created = isset($_GET['novena_created']) ? (int)$_GET['novena_created']
class="btn btn-sm btn-secondary">Edit</a> class="btn btn-sm btn-secondary">Edit</a>
<form method="post" style="display:inline" <form method="post" style="display:inline"
onsubmit="return confirm('Delete this session?')"> onsubmit="return confirm('Delete this session?')">
<?= csrf_field() ?>
<input type="hidden" name="delete_id" value="<?= $row['id'] ?>"> <input type="hidden" name="delete_id" value="<?= $row['id'] ?>">
<button type="submit" class="btn btn-sm btn-danger">Delete</button> <button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form> </form>
+7
View File
@@ -4,6 +4,7 @@
*/ */
require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../includes/auth.php'; require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/csrf.php';
require_auth(); require_auth();
@@ -38,6 +39,7 @@ $is_dm = ($group['mystery_set'] === 'chaplet');
// Handle delete of a single day session // Handle delete of a single day session
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_day_id'])) { if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_day_id'])) {
csrf_verify();
$did = (int)$_POST['delete_day_id']; $did = (int)$_POST['delete_day_id'];
$pdo->prepare('DELETE FROM sessions WHERE id = ? AND novena_group_id = ?')->execute([$did, $gid]); $pdo->prepare('DELETE FROM sessions WHERE id = ? AND novena_group_id = ?')->execute([$did, $gid]);
@@ -56,6 +58,7 @@ $save_error = '';
$save_success = false; $save_success = false;
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_group'])) { if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_group'])) {
csrf_verify();
$g_name = trim($_POST['g_name'] ?? ''); $g_name = trim($_POST['g_name'] ?? '');
$g_photo = trim($_POST['g_photo'] ?? '') ?: null; $g_photo = trim($_POST['g_photo'] ?? '') ?: null;
$g_public = isset($_POST['is_public']) ? 1 : 0; $g_public = isset($_POST['is_public']) ? 1 : 0;
@@ -128,6 +131,7 @@ $mystery_labels = [
<link rel="icon" type="image/svg+xml" href="<?= BASE_URL ?>/favicon.svg"> <link rel="icon" type="image/svg+xml" href="<?= BASE_URL ?>/favicon.svg">
<title><?= htmlspecialchars($group['name']) ?> — <?= htmlspecialchars($site_name) ?></title> <title><?= htmlspecialchars($group['name']) ?> — <?= htmlspecialchars($site_name) ?></title>
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/setup.css"> <link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/setup.css">
<meta name="csrf-token" content="<?= htmlspecialchars(csrf_token()) ?>">
<script>var BASE_URL = '<?= BASE_URL ?>';</script> <script>var BASE_URL = '<?= BASE_URL ?>';</script>
</head> </head>
<body> <body>
@@ -172,6 +176,7 @@ $mystery_labels = [
<section class="card" style="margin-bottom:32px"> <section class="card" style="margin-bottom:32px">
<h2 class="card-title">Novena Details</h2> <h2 class="card-title">Novena Details</h2>
<form method="post"> <form method="post">
<?= csrf_field() ?>
<input type="hidden" name="save_group" value="1"> <input type="hidden" name="save_group" value="1">
<div class="form-grid"> <div class="form-grid">
@@ -289,6 +294,7 @@ $mystery_labels = [
class="btn btn-sm btn-primary">Present</a> class="btn btn-sm btn-primary">Present</a>
<form method="post" style="display:inline" <form method="post" style="display:inline"
onsubmit="return confirm('Delete Day <?= $d ?>?')"> onsubmit="return confirm('Delete Day <?= $d ?>?')">
<?= csrf_field() ?>
<input type="hidden" name="delete_day_id" value="<?= $ses['id'] ?>"> <input type="hidden" name="delete_day_id" value="<?= $ses['id'] ?>">
<button type="submit" class="btn btn-sm btn-danger">Delete</button> <button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form> </form>
@@ -320,6 +326,7 @@ $mystery_labels = [
if (!file) return; if (!file) return;
var fd = new FormData(); var fd = new FormData();
fd.append('photo', file); fd.append('photo', file);
fd.append('csrf_token', document.querySelector('meta[name="csrf-token"]').content);
photoStatus.textContent = 'Uploading\u2026'; photoStatus.textContent = 'Uploading\u2026';
fetch(BASE_URL + '/api/upload_photo.php', { method: 'POST', body: fd }) fetch(BASE_URL + '/api/upload_photo.php', { method: 'POST', body: fd })
+5
View File
@@ -5,6 +5,7 @@
*/ */
require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../includes/auth.php'; require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/csrf.php';
require_role('admin'); require_role('admin');
@@ -18,6 +19,7 @@ $msg = '';
$msg_type = 'success'; $msg_type = 'success';
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_verify();
$action = $_POST['action'] ?? ''; $action = $_POST['action'] ?? '';
if ($action === 'delete') { if ($action === 'delete') {
@@ -185,6 +187,7 @@ $filter = $_GET['filter'] ?? 'all';
<?php if (!$is_standard): ?> <?php if (!$is_standard): ?>
<?php if ($p['is_global']): ?> <?php if ($p['is_global']): ?>
<form method="post" class="action-form"> <form method="post" class="action-form">
<?= csrf_field() ?>
<input type="hidden" name="action" value="toggle_global"> <input type="hidden" name="action" value="toggle_global">
<input type="hidden" name="prayer_id" value="<?= $p['id'] ?>"> <input type="hidden" name="prayer_id" value="<?= $p['id'] ?>">
<input type="hidden" name="new_global" value="0"> <input type="hidden" name="new_global" value="0">
@@ -192,6 +195,7 @@ $filter = $_GET['filter'] ?? 'all';
</form> </form>
<?php else: ?> <?php else: ?>
<form method="post" class="action-form"> <form method="post" class="action-form">
<?= csrf_field() ?>
<input type="hidden" name="action" value="toggle_global"> <input type="hidden" name="action" value="toggle_global">
<input type="hidden" name="prayer_id" value="<?= $p['id'] ?>"> <input type="hidden" name="prayer_id" value="<?= $p['id'] ?>">
<input type="hidden" name="new_global" value="1"> <input type="hidden" name="new_global" value="1">
@@ -201,6 +205,7 @@ $filter = $_GET['filter'] ?? 'all';
<?php if ($p['use_count'] == 0): ?> <?php if ($p['use_count'] == 0): ?>
<form method="post" class="action-form" <form method="post" class="action-form"
onsubmit="return confirm('Delete &quot;<?= htmlspecialchars(addslashes($p['name'])) ?>&quot;?')"> onsubmit="return confirm('Delete &quot;<?= htmlspecialchars(addslashes($p['name'])) ?>&quot;?')">
<?= csrf_field() ?>
<input type="hidden" name="action" value="delete"> <input type="hidden" name="action" value="delete">
<input type="hidden" name="prayer_id" value="<?= $p['id'] ?>"> <input type="hidden" name="prayer_id" value="<?= $p['id'] ?>">
<button class="btn btn-sm btn-danger">Delete</button> <button class="btn btn-sm btn-danger">Delete</button>
+6
View File
@@ -4,6 +4,7 @@
*/ */
require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../includes/auth.php'; require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/csrf.php';
require_auth(); require_auth();
@@ -31,6 +32,7 @@ if (!$profile) {
// ── Handle form submissions ─────────────────────────────────────────────────── // ── Handle form submissions ───────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_verify();
$action = $_POST['action'] ?? ''; $action = $_POST['action'] ?? '';
// ── Update profile ─────────────────────────────────────────────────────── // ── Update profile ───────────────────────────────────────────────────────
@@ -179,6 +181,7 @@ $role_labels = ['superadmin'=>'Superadmin','admin'=>'Admin','superuser'=>'Superu
<div class="profile-section"> <div class="profile-section">
<h3>Display Name</h3> <h3>Display Name</h3>
<form method="post"> <form method="post">
<?= csrf_field() ?>
<input type="hidden" name="action" value="update_profile"> <input type="hidden" name="action" value="update_profile">
<div class="form-group"> <div class="form-group">
<label for="display_name">Display Name</label> <label for="display_name">Display Name</label>
@@ -195,6 +198,7 @@ $role_labels = ['superadmin'=>'Superadmin','admin'=>'Admin','superuser'=>'Superu
<div class="profile-section"> <div class="profile-section">
<h3>Email Address</h3> <h3>Email Address</h3>
<form method="post"> <form method="post">
<?= csrf_field() ?>
<input type="hidden" name="action" value="update_email"> <input type="hidden" name="action" value="update_email">
<div class="form-group"> <div class="form-group">
<label>Current Email</label> <label>Current Email</label>
@@ -216,6 +220,7 @@ $role_labels = ['superadmin'=>'Superadmin','admin'=>'Admin','superuser'=>'Superu
<div class="profile-section"> <div class="profile-section">
<h3>Change Password</h3> <h3>Change Password</h3>
<form method="post"> <form method="post">
<?= csrf_field() ?>
<input type="hidden" name="action" value="change_password"> <input type="hidden" name="action" value="change_password">
<div class="form-group"> <div class="form-group">
<label for="cur_pass">Current Password</label> <label for="cur_pass">Current Password</label>
@@ -239,6 +244,7 @@ $role_labels = ['superadmin'=>'Superadmin','admin'=>'Admin','superuser'=>'Superu
<div class="profile-section" id="limit-section"> <div class="profile-section" id="limit-section">
<h3>Rosary Limit</h3> <h3>Rosary Limit</h3>
<form method="post"> <form method="post">
<?= csrf_field() ?>
<input type="hidden" name="action" value="update_limit"> <input type="hidden" name="action" value="update_limit">
<div class="form-group"> <div class="form-group">
<label for="rosary_limit">Limit (-1 = unlimited)</label> <label for="rosary_limit">Limit (-1 = unlimited)</label>
+16 -3
View File
@@ -5,6 +5,7 @@
require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../includes/auth.php'; require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/mailer.php'; require_once __DIR__ . '/../includes/mailer.php';
require_once __DIR__ . '/../includes/csrf.php';
require_role('superadmin'); require_role('superadmin');
@@ -15,16 +16,23 @@ $error = '';
// Save settings // Save settings
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_verify();
$action = $_POST['action'] ?? 'save'; $action = $_POST['action'] ?? 'save';
if ($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']; 'donate_enabled','donate_type','donate_handle','donate_label'];
foreach ($keys as $k) { foreach ($keys as $k) {
if (isset($_POST[$k])) { if (isset($_POST[$k])) {
set_setting($k, trim($_POST[$k])); set_setting($k, trim($_POST[$k]));
} }
} }
if (!empty($_POST['smtp_pass'])) {
set_setting('smtp_pass', trim($_POST['smtp_pass']));
}
$message = 'Settings saved.'; $message = 'Settings saved.';
$site_name = get_setting('site_name', APP_NAME); // refresh $site_name = get_setting('site_name', APP_NAME); // refresh
} }
@@ -53,7 +61,7 @@ $settings = [
'smtp_host' => get_setting('smtp_host'), 'smtp_host' => get_setting('smtp_host'),
'smtp_port' => get_setting('smtp_port', '587'), 'smtp_port' => get_setting('smtp_port', '587'),
'smtp_user' => get_setting('smtp_user'), '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' => get_setting('smtp_from'),
'smtp_from_name' => get_setting('smtp_from_name', 'Rosary Presenter'), 'smtp_from_name' => get_setting('smtp_from_name', 'Rosary Presenter'),
'donate_enabled' => get_setting('donate_enabled', '0'), 'donate_enabled' => get_setting('donate_enabled', '0'),
@@ -105,6 +113,7 @@ $settings = [
<h2 style="margin-bottom:24px">Site Settings</h2> <h2 style="margin-bottom:24px">Site Settings</h2>
<form method="post"> <form method="post">
<?= csrf_field() ?>
<input type="hidden" name="action" value="save"> <input type="hidden" name="action" value="save">
<div class="settings-section"> <div class="settings-section">
@@ -155,9 +164,12 @@ $settings = [
<div class="pass-wrap"> <div class="pass-wrap">
<input type="password" id="smtp_pass" name="smtp_pass" <input type="password" id="smtp_pass" name="smtp_pass"
autocomplete="new-password" autocomplete="new-password"
value="<?= htmlspecialchars($settings['smtp_pass']) ?>"> placeholder="<?= $settings['smtp_pass_set'] ? '•••••••• (leave blank to keep current)' : '' ?>">
<button type="button" class="pass-toggle" onclick="togglePass()">Show</button> <button type="button" class="pass-toggle" onclick="togglePass()">Show</button>
</div> </div>
<p class="help-text">
<?= $settings['smtp_pass_set'] ? '&#x2713; A password is currently set. Leave blank to keep it.' : 'No password set.' ?>
</p>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="smtp_from">From Email</label> <label for="smtp_from">From Email</label>
@@ -223,6 +235,7 @@ $settings = [
<h3>Test Email</h3> <h3>Test Email</h3>
<p class="help-text">Send a test email to <strong><?= htmlspecialchars($user['email']) ?></strong> to verify your SMTP settings.</p> <p class="help-text">Send a test email to <strong><?= htmlspecialchars($user['email']) ?></strong> to verify your SMTP settings.</p>
<form method="post"> <form method="post">
<?= csrf_field() ?>
<input type="hidden" name="action" value="test_email"> <input type="hidden" name="action" value="test_email">
<button type="submit" class="btn btn-secondary">Send Test Email</button> <button type="submit" class="btn btn-secondary">Send Test Email</button>
</form> </form>
+3
View File
@@ -4,6 +4,7 @@
*/ */
require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../includes/auth.php'; require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/csrf.php';
require_auth(); require_auth();
@@ -46,6 +47,7 @@ $page_title = $session ? 'Edit Session' : 'New Session';
<link rel="icon" type="image/svg+xml" href="<?= BASE_URL ?>/favicon.svg"> <link rel="icon" type="image/svg+xml" href="<?= BASE_URL ?>/favicon.svg">
<title><?= $page_title ?> — <?= htmlspecialchars($site_name) ?></title> <title><?= $page_title ?> — <?= htmlspecialchars($site_name) ?></title>
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/setup.css"> <link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/setup.css">
<meta name="csrf-token" content="<?= htmlspecialchars(csrf_token()) ?>">
<script>var BASE_URL = '<?= BASE_URL ?>';</script> <script>var BASE_URL = '<?= BASE_URL ?>';</script>
</head> </head>
<body> <body>
@@ -80,6 +82,7 @@ $page_title = $session ? 'Edit Session' : 'New Session';
<div id="form-message" class="alert" style="display:none"></div> <div id="form-message" class="alert" style="display:none"></div>
<form id="session-form" novalidate> <form id="session-form" novalidate>
<?= csrf_field() ?>
<?php if ($session): ?> <?php if ($session): ?>
<input type="hidden" name="id" value="<?= (int)$session['id'] ?>"> <input type="hidden" name="id" value="<?= (int)$session['id'] ?>">
<?php endif; ?> <?php endif; ?>
+7
View File
@@ -5,6 +5,7 @@
require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../includes/auth.php'; require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/mailer.php'; require_once __DIR__ . '/../includes/mailer.php';
require_once __DIR__ . '/../includes/csrf.php';
require_role('admin'); require_role('admin');
@@ -18,6 +19,7 @@ $errors = [];
// ── Handle actions ─────────────────────────────────────────────────────────── // ── Handle actions ───────────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_verify();
$action = $_POST['action'] ?? ''; $action = $_POST['action'] ?? '';
// ── Create user ────────────────────────────────────────────────────────── // ── Create user ──────────────────────────────────────────────────────────
@@ -250,6 +252,7 @@ $role_colors = [
<div class="create-panel" id="create-panel"> <div class="create-panel" id="create-panel">
<h3 style="margin:0 0 16px">Create New User</h3> <h3 style="margin:0 0 16px">Create New User</h3>
<form method="post"> <form method="post">
<?= csrf_field() ?>
<input type="hidden" name="action" value="create_user"> <input type="hidden" name="action" value="create_user">
<div class="mini-form"> <div class="mini-form">
<div class="form-group"> <div class="form-group">
@@ -331,6 +334,7 @@ $role_colors = [
<?php endif; ?> <?php endif; ?>
<?php if (!$u['email_confirmed']): ?> <?php if (!$u['email_confirmed']): ?>
<form method="post" style="display:inline"> <form method="post" style="display:inline">
<?= csrf_field() ?>
<input type="hidden" name="action" value="resend_confirmation"> <input type="hidden" name="action" value="resend_confirmation">
<input type="hidden" name="target_id" value="<?= $u['id'] ?>"> <input type="hidden" name="target_id" value="<?= $u['id'] ?>">
<button type="submit" class="btn btn-sm" <button type="submit" class="btn btn-sm"
@@ -343,6 +347,7 @@ $role_colors = [
<?php if ((int)$u['id'] !== $uid && ($is_super || $u['role'] !== 'superadmin')): ?> <?php if ((int)$u['id'] !== $uid && ($is_super || $u['role'] !== 'superadmin')): ?>
<form method="post" style="display:inline" <form method="post" style="display:inline"
onsubmit="return confirm('Delete user <?= htmlspecialchars(addslashes($u['username'])) ?>? Their sessions will remain.')"> onsubmit="return confirm('Delete user <?= htmlspecialchars(addslashes($u['username'])) ?>? Their sessions will remain.')">
<?= csrf_field() ?>
<input type="hidden" name="action" value="delete_user"> <input type="hidden" name="action" value="delete_user">
<input type="hidden" name="target_id" value="<?= $u['id'] ?>"> <input type="hidden" name="target_id" value="<?= $u['id'] ?>">
<button type="submit" class="btn btn-sm btn-danger">Delete</button> <button type="submit" class="btn btn-sm btn-danger">Delete</button>
@@ -352,6 +357,7 @@ $role_colors = [
<!-- Edit Panel --> <!-- Edit Panel -->
<div class="edit-panel" id="edit-<?= $u['id'] ?>"> <div class="edit-panel" id="edit-<?= $u['id'] ?>">
<form method="post" style="margin-bottom:16px"> <form method="post" style="margin-bottom:16px">
<?= csrf_field() ?>
<input type="hidden" name="action" value="update_user"> <input type="hidden" name="action" value="update_user">
<input type="hidden" name="target_id" value="<?= $u['id'] ?>"> <input type="hidden" name="target_id" value="<?= $u['id'] ?>">
<div class="mini-form"> <div class="mini-form">
@@ -388,6 +394,7 @@ $role_colors = [
</form> </form>
<form method="post"> <form method="post">
<?= csrf_field() ?>
<input type="hidden" name="action" value="reset_password"> <input type="hidden" name="action" value="reset_password">
<input type="hidden" name="target_id" value="<?= $u['id'] ?>"> <input type="hidden" name="target_id" value="<?= $u['id'] ?>">
<div class="mini-form"> <div class="mini-form">
+3
View File
@@ -16,6 +16,7 @@
*/ */
require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../includes/auth.php'; require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/csrf.php';
header('Content-Type: application/json'); header('Content-Type: application/json');
@@ -34,6 +35,8 @@ $uid = (int)$user['id'];
if ($_SERVER['REQUEST_METHOD'] !== 'POST') json_err('Method not allowed', 405); if ($_SERVER['REQUEST_METHOD'] !== 'POST') json_err('Method not allowed', 405);
csrf_verify();
$body = json_decode(file_get_contents('php://input'), true); $body = json_decode(file_get_contents('php://input'), true);
if (!$body) json_err('Invalid JSON'); if (!$body) json_err('Invalid JSON');
+3
View File
@@ -11,6 +11,7 @@
*/ */
require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../includes/auth.php'; require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/csrf.php';
header('Content-Type: application/json'); header('Content-Type: application/json');
require_auth(); require_auth();
@@ -27,6 +28,8 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
exit; exit;
} }
csrf_verify();
$key = trim($_POST['key'] ?? ''); $key = trim($_POST['key'] ?? '');
if (!preg_match('/^[a-z0-9_]+$/', $key) || strlen($key) > 100) { if (!preg_match('/^[a-z0-9_]+$/', $key) || strlen($key) > 100) {
http_response_code(400); http_response_code(400);
+4
View File
@@ -10,6 +10,7 @@
*/ */
require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../includes/auth.php'; require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/csrf.php';
header('Content-Type: application/json'); header('Content-Type: application/json');
@@ -28,6 +29,9 @@ $uid = (int)$user['id'];
$is_admin = has_role('admin'); $is_admin = has_role('admin');
$method = $_SERVER['REQUEST_METHOD']; $method = $_SERVER['REQUEST_METHOD'];
// CSRF check applies to state-changing methods only (GET is read-only)
if ($method !== 'GET') csrf_verify();
// ───────────────────────────────────────────────── // ─────────────────────────────────────────────────
// GET — list prayers // GET — list prayers
// ───────────────────────────────────────────────── // ─────────────────────────────────────────────────
+3
View File
@@ -9,6 +9,7 @@
*/ */
require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../includes/auth.php'; require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/csrf.php';
header('Content-Type: application/json'); header('Content-Type: application/json');
@@ -23,6 +24,8 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
exit; exit;
} }
csrf_verify();
// Collect and sanitize input // Collect and sanitize input
$id = isset($_POST['id']) && $_POST['id'] !== '' ? (int)$_POST['id'] : null; $id = isset($_POST['id']) && $_POST['id'] !== '' ? (int)$_POST['id'] : null;
$name = trim($_POST['name'] ?? ''); $name = trim($_POST['name'] ?? '');
+3
View File
@@ -12,6 +12,7 @@
*/ */
require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../includes/auth.php'; require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/csrf.php';
header('Content-Type: application/json'); header('Content-Type: application/json');
@@ -29,6 +30,8 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
exit; exit;
} }
csrf_verify();
$type = trim($_POST['type'] ?? ''); $type = trim($_POST['type'] ?? '');
$id = (int)($_POST['id'] ?? 0); $id = (int)($_POST['id'] ?? 0);
+3
View File
@@ -12,6 +12,7 @@
*/ */
require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../includes/auth.php'; require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/csrf.php';
header('Content-Type: application/json'); header('Content-Type: application/json');
require_auth(); require_auth();
@@ -28,6 +29,8 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
exit; exit;
} }
csrf_verify();
$key = trim($_POST['key'] ?? ''); $key = trim($_POST['key'] ?? '');
if (!preg_match('/^[a-z0-9_]+$/', $key) || strlen($key) > 100) { if (!preg_match('/^[a-z0-9_]+$/', $key) || strlen($key) > 100) {
http_response_code(400); http_response_code(400);
+3
View File
@@ -6,6 +6,7 @@
*/ */
require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../includes/auth.php'; require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/csrf.php';
header('Content-Type: application/json'); header('Content-Type: application/json');
@@ -17,6 +18,8 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
exit; exit;
} }
csrf_verify();
if (!isset($_FILES['photo']) || $_FILES['photo']['error'] !== UPLOAD_ERR_OK) { if (!isset($_FILES['photo']) || $_FILES['photo']['error'] !== UPLOAD_ERR_OK) {
$upload_errors = [ $upload_errors = [
UPLOAD_ERR_INI_SIZE => 'File exceeds server upload limit', UPLOAD_ERR_INI_SIZE => 'File exceeds server upload limit',
+4 -2
View File
@@ -5,6 +5,8 @@
(function () { (function () {
'use strict'; 'use strict';
const CSRF_TOKEN = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
/* ───────────────────────────────────────────────────────── /* ─────────────────────────────────────────────────────────
State State
───────────────────────────────────────────────────────── */ ───────────────────────────────────────────────────────── */
@@ -385,7 +387,7 @@
fetch(url, { fetch(url, {
method: method, 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 }), body: JSON.stringify({ name, leader_text: sendLeader, all_text: sendAll, default_bead_type: beadType, is_global: global }),
}) })
.then(r => r.json()) .then(r => r.json())
@@ -462,7 +464,7 @@
fetch(BASE_URL + '/api/builder_session.php', { fetch(BASE_URL + '/api/builder_session.php', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': CSRF_TOKEN },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}) })
.then(r => r.json()) .then(r => r.json())
+1
View File
@@ -104,6 +104,7 @@
const fd = new FormData(); const fd = new FormData();
fd.append('photo', file); fd.append('photo', file);
fd.append('csrf_token', document.querySelector('meta[name="csrf-token"]').content);
try { try {
const res = await fetch(BASE_URL + '/api/upload_photo.php', { method: 'POST', body: fd }); const res = await fetch(BASE_URL + '/api/upload_photo.php', { method: 'POST', body: fd });
+7
View File
@@ -1211,3 +1211,10 @@ $closing = [
'photo_path' => null, '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'
);
+3
View File
@@ -1,11 +1,13 @@
<?php <?php
require_once __DIR__ . '/config/db.php'; require_once __DIR__ . '/config/db.php';
require_once __DIR__ . '/includes/mailer.php'; require_once __DIR__ . '/includes/mailer.php';
require_once __DIR__ . '/includes/csrf.php';
$sent = false; $sent = false;
$site_name = get_setting('site_name', APP_NAME); $site_name = get_setting('site_name', APP_NAME);
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_verify();
$email = trim($_POST['email'] ?? ''); $email = trim($_POST['email'] ?? '');
if (filter_var($email, FILTER_VALIDATE_EMAIL)) { if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
@@ -70,6 +72,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
Enter your email address and we'll send you a link to reset your password. Enter your email address and we'll send you a link to reset your password.
</p> </p>
<form method="post" action="<?= BASE_URL ?>/forgot-password"> <form method="post" action="<?= BASE_URL ?>/forgot-password">
<?= csrf_field() ?>
<div class="form-group"> <div class="form-group">
<label for="email">Email Address</label> <label for="email">Email Address</label>
<input type="email" id="email" name="email" autofocus required> <input type="email" id="email" name="email" autofocus required>
+37
View File
@@ -66,3 +66,40 @@ function can_create_rosary(int $user_id, int $limit): bool {
$st->execute([$user_id, $user_id]); $st->execute([$user_id, $user_id]);
return (int)$st->fetchColumn() < $limit; 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]);
}
+18 -5
View File
@@ -8,7 +8,18 @@
* Applies variable substitution for {name}, {pronoun}, {pronoun_obj}, {pronoun_poss}. * 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. * 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 * @return array Flat array of slide arrays
*/ */
function build_slides(array $session): array { function build_slides(array $session): array {
global $opening, $mysteries, $hail_holy_queen, $rosary_closing_prayer, ['opening' => $opening, 'mysteries' => $mysteries, 'hail_holy_queen' => $hail_holy_queen,
$litany_passion, $novena_prayers, $litany_departed, $closing, 'rosary_closing_prayer' => $rosary_closing_prayer, 'litany_passion' => $litany_passion,
$divine_mercy_opening, $divine_mercy_novena_prayers, 'novena_prayers' => $novena_prayers, 'litany_departed' => $litany_departed, 'closing' => $closing,
$divine_mercy_chaplet_opening, $divine_mercy_chaplet_close; '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 = []; $slides = [];
+46
View File
@@ -0,0 +1,46 @@
<?php
/**
* includes/csrf.php per-session CSRF token generation and verification.
*/
require_once __DIR__ . '/auth.php';
/** Return the current session's CSRF token, generating one on first use. */
function csrf_token(): string {
_auth_start();
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
/** Echo a hidden <input> carrying the CSRF token, for use inside a <form>. */
function csrf_field(): string {
return '<input type="hidden" name="csrf_token" value="' . htmlspecialchars(csrf_token()) . '">';
}
/**
* 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 '<!DOCTYPE html><html><body style="font-family:system-ui;max-width:500px;margin:60px auto;text-align:center">'
. '<h1 style="color:#dc2626">Security Check Failed</h1>'
. '<p>Invalid or missing security token. Please go back, refresh the page, and try again.</p>'
. '</body></html>';
}
exit;
}
}
+3 -4
View File
@@ -2,14 +2,11 @@
/** /**
* index.php Public home page. Shows all public rosary sessions. * index.php Public home page. Shows all public rosary sessions.
* No auth required. * 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__ . '/config/db.php';
require_once __DIR__ . '/includes/auth.php'; require_once __DIR__ . '/includes/auth.php';
require_once __DIR__ . '/includes/donate.php'; require_once __DIR__ . '/includes/donate.php';
require_once __DIR__ . '/includes/csrf.php';
_auth_start(); _auth_start();
$pdo = get_pdo(); $pdo = get_pdo();
@@ -205,6 +202,7 @@ function render_card(array $row, bool $is_admin, array $mystery_labels, array $o
<link rel="icon" type="image/svg+xml" href="<?= BASE_URL ?>/favicon.svg"> <link rel="icon" type="image/svg+xml" href="<?= BASE_URL ?>/favicon.svg">
<title><?= htmlspecialchars($site_name) ?></title> <title><?= htmlspecialchars($site_name) ?></title>
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/public.css"> <link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/public.css">
<meta name="csrf-token" content="<?= htmlspecialchars(csrf_token()) ?>">
</head> </head>
<body> <body>
@@ -411,6 +409,7 @@ var PUBLIC_USERS = <?= json_encode(array_values($public_users_rows), JSON_HEX_TA
var fd = new FormData(); var fd = new FormData();
fd.append('type', type); fd.append('type', type);
fd.append('id', id); fd.append('id', id);
fd.append('csrf_token', document.querySelector('meta[name="csrf-token"]').content);
fetch(BASE_URL + '/api/toggle_pin.php', { method: 'POST', body: fd }) fetch(BASE_URL + '/api/toggle_pin.php', { method: 'POST', body: fd })
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
+130 -21
View File
@@ -1,8 +1,9 @@
<?php <?php
/** /**
* install.php Full database installer. * install.php Full database installer.
* Creates all tables, seeds settings and superadmin account. * Creates all tables (matching schema.sql the canonical structure reference)
* Run once in browser, then DELETE this file. * 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'; 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', " inst_sql($pdo, 'Create sessions table', "
CREATE TABLE IF NOT EXISTS sessions ( CREATE TABLE IF NOT EXISTS sessions (
id INT AUTO_INCREMENT PRIMARY KEY, 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 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
", $log, $errors); ", $log, $errors);
// ── 2. novena_groups ─────────────────────────────────────────────────────────
inst_sql($pdo, 'Create novena_groups table', " inst_sql($pdo, 'Create novena_groups table', "
CREATE TABLE IF NOT EXISTS novena_groups ( CREATE TABLE IF NOT EXISTS novena_groups (
id INT AUTO_INCREMENT PRIMARY KEY, 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 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
", $log, $errors); ", $log, $errors);
// ── 3. users ─────────────────────────────────────────────────────────────────
inst_sql($pdo, 'Create users table', " inst_sql($pdo, 'Create users table', "
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE, username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE, email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL, password_hash VARCHAR(255) NOT NULL,
display_name VARCHAR(100) NULL, display_name VARCHAR(100) NULL,
role ENUM('superadmin','admin','superuser','user') NOT NULL DEFAULT 'user', role ENUM('superadmin','admin','superuser','user') NOT NULL DEFAULT 'user',
rosary_limit INT NOT NULL DEFAULT 1, rosary_limit INT NOT NULL DEFAULT 1,
email_confirmed TINYINT(1) NOT NULL DEFAULT 0, email_confirmed TINYINT(1) NOT NULL DEFAULT 0,
confirm_token VARCHAR(64) NULL, confirm_token VARCHAR(64) NULL,
reset_token VARCHAR(64) NULL, reset_token VARCHAR(64) NULL,
reset_expires DATETIME NULL, reset_expires DATETIME NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, failed_login_attempts INT NOT NULL DEFAULT 0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP 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 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
", $log, $errors); ", $log, $errors);
// ── 4. site_settings ─────────────────────────────────────────────────────────
inst_sql($pdo, 'Create site_settings table', " inst_sql($pdo, 'Create site_settings table', "
CREATE TABLE IF NOT EXISTS site_settings ( CREATE TABLE IF NOT EXISTS site_settings (
key_name VARCHAR(100) PRIMARY KEY, 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 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
", $log, $errors); ", $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 = [ $defaults = [
'smtp_host' => '', 'smtp_host' => '',
'smtp_port' => '587', 'smtp_port' => '587',
@@ -114,7 +144,7 @@ foreach ($defaults as $k => $v) {
} }
} }
// ── 6. Seed superadmin ─────────────────────────────────────────────────────── // ── 3. Seed superadmin ───────────────────────────────────────────────────────
$hash = password_hash('supadmin', PASSWORD_BCRYPT); $hash = password_hash('supadmin', PASSWORD_BCRYPT);
try { try {
$pdo->prepare(" $pdo->prepare("
@@ -127,6 +157,85 @@ try {
$errors[] = 'Seed superadmin: ' . $e->getMessage(); $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); $overall_ok = empty($errors);
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
+9 -1
View File
@@ -1,6 +1,7 @@
<?php <?php
require_once __DIR__ . '/config/db.php'; require_once __DIR__ . '/config/db.php';
require_once __DIR__ . '/includes/auth.php'; require_once __DIR__ . '/includes/auth.php';
require_once __DIR__ . '/includes/csrf.php';
_auth_start(); _auth_start();
@@ -14,6 +15,7 @@ $error = '';
$username = ''; $username = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_verify();
$username = trim($_POST['username'] ?? ''); $username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? ''; $password = $_POST['password'] ?? '';
@@ -26,11 +28,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$stmt->execute([$username, $username]); $stmt->execute([$username, $username]);
$user = $stmt->fetch(); $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.'; $error = 'Invalid username or password.';
} elseif (!$user['email_confirmed']) { } elseif (!$user['email_confirmed']) {
$error = 'Please confirm your email address before logging in. Check your inbox for the confirmation link.'; $error = 'Please confirm your email address before logging in. Check your inbox for the confirmation link.';
} else { } else {
record_login_success((int)$user['id']);
session_regenerate_id(true); session_regenerate_id(true);
$_SESSION['user_id'] = $user['id']; $_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username']; $_SESSION['username'] = $user['username'];
@@ -72,6 +79,7 @@ $reset_msg = isset($_GET['reset']) ? 'Password reset successfully. Pleas
<?php endif; ?> <?php endif; ?>
<form method="post" action="<?= BASE_URL ?>/login"> <form method="post" action="<?= BASE_URL ?>/login">
<?= csrf_field() ?>
<div class="form-group"> <div class="form-group">
<label for="username">Username or Email</label> <label for="username">Username or Email</label>
<input type="text" id="username" name="username" <input type="text" id="username" name="username"
-117
View File
@@ -1,117 +0,0 @@
<?php
/**
* migrate_v2.php One-time database migration.
* Run once in the browser, then DELETE this file from the server.
*
* What it does:
* 1. Creates the novena_groups table
* 2. Adds novena_group_id column to sessions
* 3. Groups any existing novena day sessions under novena_group records
*/
require_once __DIR__ . '/config/db.php';
$pdo = get_pdo();
$pdo->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.';
}
}
?><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Migrate v2</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 640px; margin: 60px auto; padding: 0 24px; color: #1d2027; }
h1 { color: #2563eb; margin-bottom: 20px; }
ul { background: #f0fdf4; border: 1px solid #86efac; border-radius: 8px; padding: 16px 16px 16px 36px; margin-bottom: 20px; }
li { margin-bottom: 6px; }
.warn { background: #fef3c7; border: 1px solid #fbbf24; border-radius: 8px; padding: 16px; font-weight: 500; }
code { background: #f1f5f9; padding: 2px 6px; border-radius: 4px; font-size: 0.9em; }
</style>
</head>
<body>
<h1>Migration v2 Complete</h1>
<ul>
<?php foreach ($log as $line): ?>
<li><?= htmlspecialchars($line) ?></li>
<?php endforeach; ?>
</ul>
<div class="warn">
&#x26A0; Delete <code>migrate_v2.php</code> from your server now.
It is no longer needed and should not be left publicly accessible.
</div>
</body>
</html>
-277
View File
@@ -1,277 +0,0 @@
<?php
/**
* migrate_v3.php Run once then DELETE this file.
* Creates multi-user tables, adds columns, seeds data.
*/
require_once __DIR__ . '/config/db.php';
$pdo = get_pdo();
$log = [];
$errors = [];
function run_sql(PDO $pdo, string $label, string $sql, array &$log, array &$errors): void {
try {
$pdo->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);
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Migration v3 <?= APP_NAME ?></title>
<style>
*{box-sizing:border-box}
body{font-family:system-ui,-apple-system,sans-serif;background:#f4f4f5;margin:0;padding:32px 16px}
.wrap{max-width:720px;margin:0 auto}
h1{font-size:26px;margin-bottom:4px}
.banner{border-radius:8px;padding:20px 24px;margin-bottom:24px;font-size:15px}
.banner.ok{background:#d1fae5;border:1px solid #6ee7b7;color:#065f46}
.banner.err{background:#fee2e2;border:1px solid #fca5a5;color:#991b1b}
.warn{background:#fef3c7;border:1px solid #fcd34d;color:#92400e;border-radius:8px;padding:16px 20px;margin-bottom:24px;font-weight:600}
.card{background:#fff;border-radius:8px;padding:24px;margin-bottom:20px;box-shadow:0 1px 3px rgba(0,0,0,.07)}
.cred{background:#1e3a5f;color:#e0f2fe;border-radius:6px;padding:16px 20px;font-family:monospace;font-size:15px;line-height:1.7}
table{width:100%;border-collapse:collapse;font-size:13px}
th,td{text-align:left;padding:6px 10px;border-bottom:1px solid #e5e7eb}
th{background:#f9fafb;font-weight:600}
.ok{color:#15803d}.err{color:#b91c1c}.skip{color:#d97706}
</style>
</head>
<body>
<div class="wrap">
<h1>&#x271D; <?= APP_NAME ?> — Migration v3</h1>
<?php if ($overall_ok): ?>
<div class="banner ok">
<strong>Migration completed successfully.</strong> All steps passed (or were already applied).
</div>
<?php else: ?>
<div class="banner err">
<strong>Migration finished with errors.</strong> Review the log below. Some steps may need manual attention.
</div>
<?php endif; ?>
<div class="warn">
&#9888; DELETE this file (<code>migrate_v3.php</code>) from your server immediately after reviewing this page.
</div>
<div class="card">
<h2 style="margin-top:0">Superadmin Credentials</h2>
<div class="cred">
Username: supadmin<br>
Password: supadmin<br>
Role: superadmin
</div>
<p style="color:#b91c1c;font-weight:600;margin-top:12px">
CHANGE THE PASSWORD IMMEDIATELY go to <a href="/admin/profile">/admin/profile</a> after logging in.<br>
Also update the email from <code>admin@example.com</code> to your real email.
</p>
</div>
<div class="card">
<h2 style="margin-top:0">Migration Log</h2>
<table>
<thead><tr><th>Status</th><th>Step</th></tr></thead>
<tbody>
<?php foreach ($log as [$status, $msg]): ?>
<tr>
<td class="<?= $status ?>">
<?= $status === 'ok' ? '&#x2713; OK' : ($status === 'skip' ? '&#8212; SKIP' : '&#x2717; ERROR') ?>
</td>
<td><?= htmlspecialchars($msg) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="card">
<h2 style="margin-top:0">Next Steps</h2>
<ol style="line-height:1.9">
<li>Delete <code>migrate_v3.php</code> from your server.</li>
<li>Go to <a href="/login">/login</a> and sign in with <strong>supadmin / supadmin</strong>.</li>
<li>Go to <a href="/admin/profile">/admin/profile</a> and change your password and email.</li>
<li>Go to <a href="/admin/settings">/admin/settings</a> to configure SMTP and your site URL.</li>
</ol>
</div>
</div>
</body>
</html>
-182
View File
@@ -1,182 +0,0 @@
<?php
/**
* migrate_v4.php Adds custom_prayers and builder_steps tables for Rosary Builder.
* Seeds standard prayers as global library entries.
* Run once in browser, then delete.
*/
require_once __DIR__ . '/config/db.php';
$pdo = get_pdo();
$log = [];
function mig_sql(PDO $pdo, string $label, string $sql, array &$log): void {
try {
$pdo->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'];
}
?><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Migrate v4</title>
<style>
body { font-family: system-ui; max-width: 700px; margin: 40px auto; padding: 0 20px; }
h2 { color: #1d2027; }
.ok { color: #15803d; } .skip { color: #b45309; } .err { color: #dc2626; }
li { margin: 4px 0; font-size: 14px; }
.done { background: #f0fdf4; border: 1px solid #86efac; padding: 16px; border-radius: 8px; margin-top: 20px; }
</style>
</head>
<body>
<h2>Migrate v4 Rosary Builder Tables</h2>
<ul>
<?php foreach ($log as [$status, $msg]): ?>
<li class="<?= $status ?>">
<?= $status === 'ok' ? '&#x2713;' : ($status === 'skip' ? '&#x25CC;' : '&#x2717;') ?>
<?= htmlspecialchars($msg) ?>
</li>
<?php endforeach; ?>
</ul>
<?php if (!array_filter($log, fn($l) => $l[0] === 'err')): ?>
<div class="done">
<strong>Migration complete.</strong>
Delete this file now: <code>migrate_v4.php</code>
</div>
<?php endif; ?>
</body>
</html>
-67
View File
@@ -1,67 +0,0 @@
<?php
/**
* migrate_v5.php Adds step_type and bead_type columns to builder_steps
* for bead separator support. Makes prayer_id nullable.
* Run once in browser, then delete.
*/
require_once __DIR__ . '/config/db.php';
$pdo = get_pdo();
$log = [];
function mig5_sql(PDO $pdo, string $label, string $sql, array &$log): void {
try {
$pdo->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);
?><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Migrate v5</title>
<style>
body { font-family: system-ui; max-width: 700px; margin: 40px auto; padding: 0 20px; }
.ok { color: #15803d; } .skip { color: #b45309; } .err { color: #dc2626; }
li { margin: 4px 0; font-size: 14px; }
.done { background: #f0fdf4; border: 1px solid #86efac; padding: 16px; border-radius: 8px; margin-top: 20px; }
</style>
</head>
<body>
<h2>Migrate v5 Bead Separator Support</h2>
<ul>
<?php foreach ($log as [$status, $msg]): ?>
<li class="<?= $status ?>">
<?= $status === 'ok' ? '&#x2713;' : ($status === 'skip' ? '&#x25CC;' : '&#x2717;') ?>
<?= htmlspecialchars($msg) ?>
</li>
<?php endforeach; ?>
</ul>
<?php if (!array_filter($log, fn($l) => $l[0] === 'err')): ?>
<div class="done">
<strong>Migration complete.</strong> Delete this file: <code>migrate_v5.php</code>
</div>
<?php endif; ?>
</body>
</html>
-74
View File
@@ -1,74 +0,0 @@
<?php
/**
* migrate_v6.php Adds default_bead_type to custom_prayers.
* Updates standard seeded prayers with sensible bead defaults.
* Run once in browser, then delete.
*/
require_once __DIR__ . '/config/db.php';
$pdo = get_pdo();
$log = [];
function mig6_sql(PDO $pdo, string $label, string $sql, array &$log): void {
try {
$pdo->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"];
?><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Migrate v6</title>
<style>
body { font-family: system-ui; max-width: 700px; margin: 40px auto; padding: 0 20px; }
.ok { color: #15803d; } .skip { color: #b45309; } .err { color: #dc2626; }
li { margin: 4px 0; font-size: 14px; }
.done { background: #f0fdf4; border: 1px solid #86efac; padding: 16px; border-radius: 8px; margin-top: 20px; }
</style>
</head>
<body>
<h2>Migrate v6 Prayer Bead Defaults</h2>
<ul>
<?php foreach ($log as [$status, $msg]): ?>
<li class="<?= $status ?>">
<?= $status === 'ok' ? '&#x2713;' : ($status === 'skip' ? '&#x25CC;' : '&#x2717;') ?>
<?= htmlspecialchars($msg) ?>
</li>
<?php endforeach; ?>
</ul>
<?php if (!array_filter($log, fn($l) => $l[0] === 'err')): ?>
<div class="done"><strong>Migration complete.</strong> Delete this file: <code>migrate_v6.php</code></div>
<?php endif; ?>
</body>
</html>
-6
View File
@@ -1,6 +0,0 @@
<?php
// Redirect to admin version
require_once __DIR__ . '/config/db.php';
$id = isset($_GET['id']) ? '?id=' . (int)$_GET['id'] : '';
header('Location: ' . BASE_URL . '/admin/novena_group.php' . $id);
exit;
+3
View File
@@ -2,6 +2,7 @@
require_once __DIR__ . '/config/db.php'; require_once __DIR__ . '/config/db.php';
require_once __DIR__ . '/includes/auth.php'; require_once __DIR__ . '/includes/auth.php';
require_once __DIR__ . '/includes/mailer.php'; require_once __DIR__ . '/includes/mailer.php';
require_once __DIR__ . '/includes/csrf.php';
_auth_start(); _auth_start();
@@ -16,6 +17,7 @@ $success = false;
$fields = ['username' => '', 'display_name' => '', 'email' => '']; $fields = ['username' => '', 'display_name' => '', 'email' => ''];
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_verify();
$username = trim($_POST['username'] ?? ''); $username = trim($_POST['username'] ?? '');
$display_name = trim($_POST['display_name'] ?? ''); $display_name = trim($_POST['display_name'] ?? '');
$email = trim($_POST['email'] ?? ''); $email = trim($_POST['email'] ?? '');
@@ -132,6 +134,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
<?php endif; ?> <?php endif; ?>
<form method="post" action="<?= BASE_URL ?>/register"> <form method="post" action="<?= BASE_URL ?>/register">
<?= csrf_field() ?>
<div class="form-group"> <div class="form-group">
<label for="username">Username <span class="required">*</span></label> <label for="username">Username <span class="required">*</span></label>
<input type="text" id="username" name="username" <input type="text" id="username" name="username"
+3
View File
@@ -1,5 +1,6 @@
<?php <?php
require_once __DIR__ . '/config/db.php'; require_once __DIR__ . '/config/db.php';
require_once __DIR__ . '/includes/csrf.php';
$site_name = get_setting('site_name', APP_NAME); $site_name = get_setting('site_name', APP_NAME);
$token = trim($_GET['token'] ?? ''); $token = trim($_GET['token'] ?? '');
@@ -21,6 +22,7 @@ if (!$user) {
} }
if (!isset($token_invalid) && $_SERVER['REQUEST_METHOD'] === 'POST') { if (!isset($token_invalid) && $_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_verify();
$password = $_POST['password'] ?? ''; $password = $_POST['password'] ?? '';
$password_confirm = $_POST['password_confirm'] ?? ''; $password_confirm = $_POST['password_confirm'] ?? '';
@@ -73,6 +75,7 @@ if (!isset($token_invalid) && $_SERVER['REQUEST_METHOD'] === 'POST') {
<?php endif; ?> <?php endif; ?>
<form method="post" action="<?= BASE_URL ?>/reset-password?token=<?= urlencode($token) ?>"> <form method="post" action="<?= BASE_URL ?>/reset-password?token=<?= urlencode($token) ?>">
<?= csrf_field() ?>
<div class="form-group"> <div class="form-group">
<label for="password">New Password <span class="required">*</span></label> <label for="password">New Password <span class="required">*</span></label>
<input type="password" id="password" name="password" <input type="password" id="password" name="password"
+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;
-6
View File
@@ -1,6 +0,0 @@
<?php
// Redirect to admin version
require_once __DIR__ . '/config/db.php';
$id = isset($_GET['id']) ? '?id=' . (int)$_GET['id'] : '';
header('Location: ' . BASE_URL . '/admin/setup.php' . $id);
exit;