Files
Rosary/admin/builder.php
T
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

282 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* admin/builder.php — Rosary Builder
* Available to superuser, admin, superadmin.
* Create or edit a custom prayer-sequence session.
*/
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/csrf.php';
require_role('superuser');
$pdo = get_pdo();
$user = current_user();
$uid = (int)$user['id'];
$is_admin = has_role('admin');
$site_name = get_setting('site_name', APP_NAME);
// ── Load existing session for edit mode ──────────────────────────────────────
$session = null;
$edit_steps = [];
if (isset($_GET['id'])) {
$stmt = $pdo->prepare("SELECT * FROM sessions WHERE id = ? AND occasion = 'custom'");
$stmt->execute([(int)$_GET['id']]);
$session = $stmt->fetch();
if (!$session) {
header('Location: ' . BASE_URL . '/admin/');
exit;
}
if (!$is_admin && (int)$session['user_id'] !== $uid) {
header('Location: ' . BASE_URL . '/admin/');
exit;
}
// Load steps with prayer data
$step_stmt = $pdo->prepare("
SELECT bs.step_type, bs.bead_type, bs.prayer_id, bs.attribution,
cp.name, cp.leader_text, cp.all_text, cp.is_global, cp.created_by,
IF(cp.is_global=1 AND u.role='superadmin','standard',
IF(cp.is_global=1,'global','mine')) AS source_tag
FROM builder_steps bs
LEFT JOIN custom_prayers cp ON cp.id = bs.prayer_id
LEFT JOIN users u ON u.id = cp.created_by
WHERE bs.session_id = ?
ORDER BY bs.step_order ASC
");
$step_stmt->execute([(int)$session['id']]);
$edit_steps = $step_stmt->fetchAll();
}
// ── Load prayer library ───────────────────────────────────────────────────────
$lib_stmt = $pdo->prepare("
SELECT cp.id, cp.name, cp.leader_text, cp.all_text,
cp.default_bead_type, cp.is_global, cp.created_by,
IF(cp.is_global=1 AND u.role='superadmin','standard',
IF(cp.is_global=1,'global','mine')) AS source_tag
FROM custom_prayers cp
LEFT JOIN users u ON u.id = cp.created_by
WHERE cp.is_global = 1 OR cp.created_by = ?
ORDER BY (cp.is_global=1 AND u.role='superadmin') DESC, cp.name ASC
");
$lib_stmt->execute([$uid]);
$prayers_data = $lib_stmt->fetchAll();
$page_title = $session ? 'Edit: ' . htmlspecialchars($session['name']) : 'Rosary Builder';
?>
<!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><?= $page_title ?> — <?= htmlspecialchars($site_name) ?></title>
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/setup.css">
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/builder.css?v=1">
<meta name="csrf-token" content="<?= htmlspecialchars(csrf_token()) ?>">
</head>
<body>
<div id="builder-page">
<header class="admin-header" style="flex-shrink:0;padding:0 24px">
<h1 style="font-size:1rem">&#x271D; <?= htmlspecialchars($site_name) ?></h1>
<div class="header-actions">
<a href="<?= BASE_URL ?>/" class="btn btn-ghost" style="font-size:12px">&#x2190; View Site</a>
<?php if ($is_admin): ?>
<a href="<?= BASE_URL ?>/admin/prayers.php" class="btn btn-ghost">Prayer Library</a>
<?php endif; ?>
<a href="<?= BASE_URL ?>/admin/" class="btn btn-ghost">&#x2190; Dashboard</a>
<a href="<?= BASE_URL ?>/logout" class="btn btn-ghost">Logout</a>
</div>
</header>
<div class="builder-wrap">
<!-- Toolbar -->
<div class="builder-toolbar">
<h2>&#x2712; <?= $session ? 'Edit Sequence' : 'Rosary Builder' ?></h2>
<div class="divider"></div>
<input type="text" id="session-name" class="session-name-input"
placeholder="Session label (required)"
value="<?= htmlspecialchars($session['name'] ?? '') ?>">
<div class="toolbar-right">
<label class="public-toggle">
<input type="checkbox" id="is-public"
<?= (!$session || $session['is_public']) ? 'checked' : '' ?>>
Public
</label>
<?php if ($session): ?>
<?php
$owner_stmt = $pdo->prepare("SELECT username FROM users WHERE id = ?");
$owner_stmt->execute([$session['user_id']]);
$owner_uname = $owner_stmt->fetchColumn() ?: '';
$present_url = BASE_URL . '/' . rawurlencode($owner_uname) . '/' . rawurlencode($session['slug'] ?? '');
?>
<a id="present-link" href="<?= htmlspecialchars($present_url) ?>"
target="_blank" class="btn btn-ghost" style="font-size:13px">
Present &#x2197;
</a>
<?php else: ?>
<a id="present-link" href="#" target="_blank"
class="btn btn-ghost" style="font-size:13px;display:none">
Present &#x2197;
</a>
<?php endif; ?>
<button id="btn-save" class="btn btn-primary">Save Session</button>
</div>
</div>
<!-- Message bar -->
<div id="builder-msg"></div>
<!-- Optional subject (for variable substitution in prayers like Eternal Rest) -->
<details class="builder-subject">
<summary>For a specific person (optional — enables {name}, {pronoun} substitutions)</summary>
<div class="builder-subject-fields">
<input type="text" id="subject-name"
placeholder="Full name, e.g. Maria Santos"
value="<?= htmlspecialchars($session['subject_name'] ?? '') ?>">
<select id="subject-pronoun">
<option value="he" <?= ($session['subject_pronoun'] ?? 'he') === 'he' ? 'selected' : '' ?>>He / Him / His</option>
<option value="she" <?= ($session['subject_pronoun'] ?? '') === 'she' ? 'selected' : '' ?>>She / Her / Her</option>
</select>
<input type="text" id="subject-dates"
placeholder="Life dates, e.g. Aug 12, 1949 July 25, 2025"
style="min-width:280px"
value="<?= htmlspecialchars($session['subject_dates'] ?? '') ?>">
</div>
</details>
<!-- Two-panel body -->
<div class="builder-body">
<!-- Left: Sequence -->
<div class="builder-sequence">
<div class="panel-header">
<h3>Your Sequence</h3>
<span id="step-count-badge" class="step-count-badge">0 prayers</span>
</div>
<div id="step-list"></div>
</div>
<!-- Right: Prayer library -->
<div class="builder-library">
<div class="library-header">
<input type="search" id="prayer-search" class="library-search"
placeholder="&#x1F50D; Search prayers by name or text…">
<div class="library-tabs">
<button class="tab-btn active" data-tab="all">All</button>
<button class="tab-btn" data-tab="standard">Standard</button>
<button class="tab-btn" data-tab="mine">My Prayers</button>
</div>
</div>
<div id="prayer-list">
<div class="library-empty" style="grid-column:1/-1;padding:40px;text-align:center;color:var(--muted)">
Loading…
</div>
</div>
<div class="library-footer">
<button id="btn-create-prayer" class="btn btn-secondary">+ Create Custom Prayer</button>
</div>
</div>
</div><!-- /builder-body -->
</div><!-- /builder-wrap -->
</div><!-- /builder-page -->
<!-- ── Create / Edit Prayer Modal ─────────────────────────────── -->
<div id="prayer-modal" class="modal-overlay">
<div class="modal-box">
<h3 id="modal-title">New Custom Prayer</h3>
<div class="form-group">
<label for="modal-name">Prayer Name <span class="required">*</span></label>
<input type="text" id="modal-name" class="form-control"
placeholder="e.g. Opening Prayer, Special Intention…"
style="width:100%;padding:9px 12px;border:1px solid var(--border);border-radius:6px;font-family:var(--font);font-size:14px">
</div>
<div class="form-group">
<label>Attribution</label>
<div class="attr-radios">
<label class="attr-radio-label">
<input type="radio" name="modal-attr" value="leader_all" checked>
<span><strong>Leader / All</strong><br><small>Two voices — leader speaks, congregation responds</small></span>
</label>
<label class="attr-radio-label">
<input type="radio" name="modal-attr" value="leader_only">
<span><strong>Leader only</strong><br><small>Leader speaks the whole prayer</small></span>
</label>
<label class="attr-radio-label">
<input type="radio" name="modal-attr" value="all_only">
<span><strong>All together</strong><br><small>Everyone prays in unison</small></span>
</label>
<label class="attr-radio-label">
<input type="radio" name="modal-attr" value="none">
<span><strong>No attribution</strong><br><small>Plain text — no leader/all labels</small></span>
</label>
</div>
</div>
<div class="form-group">
<label>Bead (optional)</label>
<div class="bead-btn-row" id="modal-bead-row">
<button type="button" class="bead-sel-btn active" data-bead="">No bead</button>
<button type="button" class="bead-sel-btn" data-bead="small">&#x25CB; Small</button>
<button type="button" class="bead-sel-btn" data-bead="large">&#x25CF; Large</button>
<button type="button" class="bead-sel-btn" data-bead="crucifix">&#x271D; Crucifix</button>
</div>
<input type="hidden" id="modal-bead-type" value="">
<p class="help-hint">Sets the default bead when this prayer is added to a sequence.</p>
</div>
<div class="form-group" id="modal-leader-group">
<label for="modal-leader">Leader text</label>
<textarea id="modal-leader" class="modal-textarea" rows="5"
placeholder="What the leader says…"></textarea>
<p class="help-hint">Use line breaks to control pacing. Variables: {name}, {pronoun_obj}, {pronoun_poss}</p>
</div>
<div class="form-group" id="modal-all-group">
<label for="modal-all">All / Response text</label>
<textarea id="modal-all" class="modal-textarea" rows="3"
placeholder="What everyone says together…"></textarea>
</div>
<?php if ($is_admin): ?>
<div class="form-group" id="modal-global-group">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-weight:normal">
<input type="checkbox" id="modal-global" style="width:16px;height:16px">
Make this prayer available to all users (global library)
</label>
</div>
<?php else: ?>
<input type="hidden" id="modal-global">
<?php endif; ?>
<div class="modal-footer">
<button id="modal-cancel" class="btn btn-ghost">Cancel</button>
<button id="modal-save" class="btn btn-primary">Save Prayer</button>
</div>
</div>
</div>
<script>
var BASE_URL = '<?= BASE_URL ?>';
var IS_ADMIN = <?= $is_admin ? 'true' : 'false' ?>;
var EDIT_SESSION_ID = <?= $session ? (int)$session['id'] : 'null' ?>;
var PRAYERS_DATA = <?= json_encode(array_values($prayers_data)) ?>;
var EXISTING_STEPS = <?= json_encode(array_map(fn($s) => [
'step_type' => $s['step_type'] ?? 'prayer',
'bead_type' => $s['bead_type'] ?? null,
'prayer_id' => $s['prayer_id'] ? (int)$s['prayer_id'] : null,
'attribution' => $s['attribution'] ?? 'leader_all',
], $edit_steps)) ?>;
</script>
<script src="<?= BASE_URL ?>/assets/js/builder.js?v=1"></script>
</body>
</html>