Files
pguzman 9622f9aeca 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>
2026-09-15 09:34:10 -07:00

265 lines
12 KiB
PHP

<?php
/**
* admin/index.php — Dashboard. Shows sessions for current user (or all for admin+).
*/
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/csrf.php';
require_auth();
$pdo = get_pdo();
$user = current_user();
$uid = (int)$user['id'];
$is_admin = has_role('admin');
$site_name = get_setting('site_name', APP_NAME);
// Handle deletions
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_verify();
if (isset($_POST['delete_group_id'])) {
$gid = (int)$_POST['delete_group_id'];
// Verify ownership or admin
if ($is_admin) {
$pdo->prepare('DELETE FROM sessions WHERE novena_group_id = ?')->execute([$gid]);
$pdo->prepare('DELETE FROM novena_groups WHERE id = ?')->execute([$gid]);
} else {
$pdo->prepare('DELETE FROM sessions WHERE novena_group_id = ? AND (SELECT user_id FROM novena_groups WHERE id = ?) = ?')
->execute([$gid, $gid, $uid]);
$pdo->prepare('DELETE FROM novena_groups WHERE id = ? AND user_id = ?')->execute([$gid, $uid]);
}
header('Location: ' . BASE_URL . '/admin/');
exit;
}
if (isset($_POST['delete_id'])) {
$id = (int)$_POST['delete_id'];
if ($is_admin) {
$pdo->prepare('DELETE FROM sessions WHERE id = ?')->execute([$id]);
} else {
$pdo->prepare('DELETE FROM sessions WHERE id = ? AND user_id = ?')->execute([$id, $uid]);
}
header('Location: ' . BASE_URL . '/admin/');
exit;
}
}
// Load sessions
if ($is_admin) {
$regular = $pdo->query("
SELECT s.*, 'session' AS row_type, u.username, u.display_name
FROM sessions s
LEFT JOIN users u ON u.id = s.user_id
WHERE s.novena_group_id IS NULL
ORDER BY s.created_at DESC
")->fetchAll();
$novena_groups = $pdo->query("
SELECT ng.*, 'novena_group' AS row_type, COUNT(s.id) AS day_count,
u.username, u.display_name
FROM novena_groups ng
LEFT JOIN sessions s ON s.novena_group_id = ng.id
LEFT JOIN users u ON u.id = ng.user_id
GROUP BY ng.id
ORDER BY ng.created_at DESC
")->fetchAll();
} else {
$regular = $pdo->prepare("
SELECT s.*, 'session' AS row_type, u.username, u.display_name
FROM sessions s
LEFT JOIN users u ON u.id = s.user_id
WHERE s.novena_group_id IS NULL AND s.user_id = ?
ORDER BY s.created_at DESC
");
$regular->execute([$uid]);
$regular = $regular->fetchAll();
$ng_stmt = $pdo->prepare("
SELECT ng.*, 'novena_group' AS row_type, COUNT(s.id) AS day_count,
u.username, u.display_name
FROM novena_groups ng
LEFT JOIN sessions s ON s.novena_group_id = ng.id
LEFT JOIN users u ON u.id = ng.user_id
WHERE ng.user_id = ?
GROUP BY ng.id
ORDER BY ng.created_at DESC
");
$ng_stmt->execute([$uid]);
$novena_groups = $ng_stmt->fetchAll();
}
$all_rows = array_merge($regular, $novena_groups);
usort($all_rows, fn($a, $b) => strcmp($b['created_at'], $a['created_at']));
$occasion_labels = [
'novena_deceased' => 'Novena for Deceased',
'divine_mercy_novena' => 'Divine Mercy Novena',
'general_rosary' => 'General Rosary',
'memorial' => 'Memorial',
'custom' => 'Custom Sequence',
];
$mystery_labels = [
'sorrowful' => 'Sorrowful',
'joyful' => 'Joyful',
'glorious' => 'Glorious',
'luminous' => 'Luminous',
'by_day_of_week' => 'By Day',
];
$novena_created = isset($_GET['novena_created']) ? (int)$_GET['novena_created'] : 0;
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/svg+xml" href="<?= BASE_URL ?>/favicon.svg">
<title>Dashboard — <?= htmlspecialchars($site_name) ?></title>
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/setup.css">
</head>
<body>
<div class="admin-container">
<header class="admin-header">
<h1>&#x271D; <?= htmlspecialchars($site_name) ?></h1>
<div class="header-actions">
<a href="<?= BASE_URL ?>/" class="btn btn-ghost" style="font-size:13px">&#x2190; View Site</a>
<?php if (has_role('superuser')): ?>
<a href="<?= BASE_URL ?>/admin/builder.php" class="btn btn-ghost">&#x2712; Builder</a>
<?php endif; ?>
<?php if (has_role('admin')): ?>
<a href="<?= BASE_URL ?>/admin/prayers.php" class="btn btn-ghost">Prayers</a>
<a href="<?= BASE_URL ?>/admin/users.php" class="btn btn-ghost">Users</a>
<a href="<?= BASE_URL ?>/admin/audio.php" class="btn btn-ghost">Audio</a>
<?php endif; ?>
<?php if (has_role('superadmin')): ?>
<a href="<?= BASE_URL ?>/admin/settings.php" class="btn btn-ghost">Settings</a>
<?php endif; ?>
<a href="<?= BASE_URL ?>/admin/profile.php" class="btn btn-ghost"><?= htmlspecialchars($user['display_name'] ?: $user['username']) ?></a>
<a href="<?= BASE_URL ?>/logout" class="btn btn-ghost">Logout</a>
</div>
</header>
<main>
<?php if ($novena_created > 0): ?>
<div class="alert alert-success">
&#x2713; <?= $novena_created ?> novena day sessions created.
</div>
<?php endif; ?>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">
<h2 style="margin:0"><?= $is_admin ? 'All Sessions' : 'My Sessions' ?></h2>
<div style="display:flex;gap:8px">
<?php if (has_role('superuser')): ?>
<a href="<?= BASE_URL ?>/admin/builder.php" class="btn btn-secondary">&#x2712; Build Custom</a>
<?php endif; ?>
<a href="<?= BASE_URL ?>/admin/setup.php" class="btn btn-primary">+ New Session</a>
</div>
</div>
<?php if (empty($all_rows)): ?>
<div class="empty-state">
<p>No sessions yet.</p>
<a href="<?= BASE_URL ?>/admin/setup.php" class="btn btn-primary">Create Your First Session</a>
</div>
<?php else: ?>
<div class="sessions-table-wrap">
<table class="sessions-table">
<thead>
<tr>
<th>Name</th>
<th>Occasion</th>
<th>Mysteries</th>
<?php if ($is_admin): ?><th>Owner</th><?php endif; ?>
<th>Public</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($all_rows as $row): ?>
<?php if ($row['row_type'] === 'novena_group'): ?>
<tr class="novena-group-row">
<td class="session-name">
<span class="novena-group-icon">&#x271D;</span>
<?= htmlspecialchars($row['name']) ?>
<?php if ($row['subject_name']): ?>
<span class="subject-name"><?= htmlspecialchars($row['subject_name']) ?></span>
<?php endif; ?>
<span class="novena-badge">9-Day Novena</span>
</td>
<td><?= ($row['mystery_set'] === 'chaplet') ? 'Divine Mercy Novena' : 'Novena for Deceased' ?></td>
<td><?= htmlspecialchars($mystery_labels[$row['mystery_set']] ?? $row['mystery_set']) ?></td>
<?php if ($is_admin): ?>
<td><?= htmlspecialchars($row['display_name'] ?: ($row['username'] ?? '—')) ?></td>
<?php endif; ?>
<td><?= $row['is_public'] ? '<span style="color:#15803d">&#x2713; Yes</span>' : '<span style="color:#9ca3af">No</span>' ?></td>
<td><?= date('M j, Y', strtotime($row['created_at'])) ?></td>
<td class="actions">
<a href="<?= BASE_URL ?>/admin/novena_group.php?id=<?= $row['id'] ?>"
class="btn btn-sm btn-primary">View Days &rarr;</a>
<?php if ($is_admin || (int)$row['user_id'] === $uid): ?>
<form method="post" style="display:inline"
onsubmit="return confirm('Delete this entire novena (all 9 days)?')">
<?= csrf_field() ?>
<input type="hidden" name="delete_group_id" value="<?= $row['id'] ?>">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
<?php endif; ?>
</td>
</tr>
<?php else: ?>
<tr>
<td class="session-name">
<?= htmlspecialchars($row['name']) ?>
<?php if ($row['subject_name']): ?>
<span class="subject-name"><?= htmlspecialchars($row['subject_name']) ?></span>
<?php endif; ?>
</td>
<td><?= htmlspecialchars($occasion_labels[$row['occasion']] ?? $row['occasion']) ?></td>
<td><?= htmlspecialchars($mystery_labels[$row['mystery_set']] ?? $row['mystery_set']) ?></td>
<?php if ($is_admin): ?>
<td><?= htmlspecialchars($row['display_name'] ?: ($row['username'] ?? '—')) ?></td>
<?php endif; ?>
<td><?= $row['is_public'] ? '<span style="color:#15803d">&#x2713; Yes</span>' : '<span style="color:#9ca3af">No</span>' ?></td>
<td><?= date('M j, Y', strtotime($row['created_at'])) ?></td>
<td class="actions">
<?php
$slug_val = $row['slug'] ?? '';
$owner = $row['username'] ?? '';
$present_url = ($slug_val && $owner)
? BASE_URL . '/' . rawurlencode($owner) . '/' . rawurlencode($slug_val)
: BASE_URL . '/present.php?id=' . $row['id'];
?>
<a href="<?= htmlspecialchars($present_url) ?>"
target="_blank"
class="btn btn-sm btn-primary">Present</a>
<?php if ($is_admin || (int)$row['user_id'] === $uid): ?>
<?php $edit_url = ($row['occasion'] === 'custom')
? BASE_URL . '/admin/builder.php?id=' . $row['id']
: BASE_URL . '/admin/setup.php?id=' . $row['id']; ?>
<a href="<?= htmlspecialchars($edit_url) ?>"
class="btn btn-sm btn-secondary">Edit</a>
<form method="post" style="display:inline"
onsubmit="return confirm('Delete this session?')">
<?= csrf_field() ?>
<input type="hidden" name="delete_id" value="<?= $row['id'] ?>">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
<?php endif; ?>
</td>
</tr>
<?php endif; ?>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</main>
</div>
</body>
</html>