Initial commit — Rosary Presenter App

Full source for loveandrosary.com: slide-based Rosary/novena/Divine Mercy
Chaplet presentation tool with multi-user roles, SVG bead ring, audio uploads,
donate strip, and public session profiles.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-13 18:44:08 -07:00
commit 663fde3909
46 changed files with 10902 additions and 0 deletions
+68
View File
@@ -0,0 +1,68 @@
<?php
/**
* includes/auth.php — multi-user role-based authentication.
*/
function _auth_start(): void {
if (session_status() === PHP_SESSION_NONE) session_start();
}
/** Redirect to login if not authenticated. */
function require_auth(): void {
_auth_start();
if (empty($_SESSION['user_id'])) {
header('Location: ' . BASE_URL . '/login');
exit;
}
}
/** Redirect/abort if user doesn't have the minimum role. */
function require_role(string $min_role): void {
require_auth();
if (!has_role($min_role)) {
http_response_code(403);
echo '<!DOCTYPE html><html><body style="font-family:system-ui;max-width:500px;margin:60px auto;text-align:center">'
. '<h1 style="color:#dc2626">Access Denied</h1>'
. '<p>You do not have permission to view this page.</p>'
. '<a href="' . BASE_URL . '/admin/">&#8592; Dashboard</a></body></html>';
exit;
}
}
/** True if current session user has at least $min_role. */
function has_role(string $min): bool {
_auth_start();
$levels = ['user' => 1, 'superuser' => 2, 'admin' => 3, 'superadmin' => 4];
return ($levels[$_SESSION['role'] ?? ''] ?? 0) >= ($levels[$min] ?? 999);
}
/** Return current user data from session (or empty defaults). */
function current_user(): array {
_auth_start();
return [
'id' => $_SESSION['user_id'] ?? null,
'username' => $_SESSION['username'] ?? '',
'email' => $_SESSION['email'] ?? '',
'role' => $_SESSION['role'] ?? '',
'display_name' => $_SESSION['display_name'] ?? '',
'rosary_limit' => $_SESSION['rosary_limit'] ?? 1,
];
}
/**
* Check if user can create another rosary.
* Novenas count as 1 regardless of number of days.
* Returns true if under limit (or limit is -1 = unlimited).
*/
function can_create_rosary(int $user_id, int $limit): bool {
if ($limit < 0) return true; // unlimited
$pdo = get_pdo();
$st = $pdo->prepare("
SELECT
(SELECT COUNT(*) FROM sessions WHERE user_id = ? AND occasion != 'novena_deceased') +
(SELECT COUNT(*) FROM novena_groups WHERE user_id = ?)
AS total
");
$st->execute([$user_id, $user_id]);
return (int)$st->fetchColumn() < $limit;
}
+261
View File
@@ -0,0 +1,261 @@
<?php
/**
* includes/build_slides.php
*
* build_slides(array $session): array
*
* Assembles the full slide sequence for a rosary session.
* Applies variable substitution for {name}, {pronoun}, {pronoun_obj}, {pronoun_poss}.
*/
require_once __DIR__ . '/../data/prayers.php';
/**
* Returns the slide array for one decade of the Chaplet of Divine Mercy.
*
* Large bead : "Eternal Father…"
* Small beads: "For the sake of His sorrowful Passion…" × 10
* No mystery announcement; no Glory Be between decades.
*
* @param int $decade_num 15
* @param int $of_bead_index bead_index for the large (Our Father) bead
* @param int $hm_bead_start bead_index for the first small bead of this decade
*/
function build_chaplet_decade_slides(int $decade_num, int $of_bead_index, int $hm_bead_start): array {
$slides = [];
$slides[] = [
'id' => 'dm_eternal_father_d' . $decade_num,
'type' => 'prayer',
'section' => 'dm_chaplet_decade_' . $decade_num,
'title' => 'Eternal Father',
'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.",
'bead' => 'large',
'bead_index' => $of_bead_index,
];
for ($i = 0; $i < 10; $i++) {
$slides[] = [
'id' => 'dm_for_sake_d' . $decade_num . '_' . ($i + 1),
'type' => 'prayer',
'section' => 'dm_chaplet_decade_' . $decade_num,
'title' => '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.',
'bead' => 'small',
'bead_index' => $hm_bead_start + $i,
];
}
return $slides;
}
/**
* Build the complete slide array for a session.
*
* @param array $session Row from the sessions table (keys match column names)
* @return array Flat array of slide arrays
*/
function build_slides(array $session): array {
global $opening, $mysteries, $hail_holy_queen, $rosary_closing_prayer,
$litany_passion, $novena_prayers, $litany_departed, $closing,
$divine_mercy_opening, $divine_mercy_novena_prayers,
$divine_mercy_chaplet_opening, $divine_mercy_chaplet_close;
$slides = [];
// -----------------------------------------------------------------------
// 1. Cover slide
// -----------------------------------------------------------------------
$cover_title = '';
$cover_all = '';
switch ($session['occasion']) {
case 'novena_deceased':
$day = (int)($session['novena_day'] ?? 1);
$cover_title = 'Nine-Day Novena Rosary';
$cover_all = "In Loving Memory of\n{name}\n{subject_dates}\n\nDay {$day} of 9";
break;
case 'memorial':
$cover_title = 'Memorial Rosary';
$cover_all = "In Loving Memory of\n{name}\n{subject_dates}";
break;
case 'divine_mercy_novena':
$day = (int)($session['novena_day'] ?? 1);
$cover_title = 'Divine Mercy Novena';
$cover_all = "Chaplet of Divine Mercy\n\nDay {$day} of 9";
break;
case 'general_rosary':
default:
$cover_title = 'The Holy Rosary';
$cover_all = ucfirst($session['mystery_set']) . ' Mysteries';
break;
}
$slides[] = [
'id' => 'cover',
'type' => 'cover',
'section' => 'cover',
'title' => $cover_title,
'leader' => '',
'all' => $cover_all,
'bead' => null,
'bead_index' => null,
'photo_path' => $session['photo_path'] ?? null,
];
// -----------------------------------------------------------------------
// 2. Opening prayers
// -----------------------------------------------------------------------
if ($session['occasion'] === 'divine_mercy_novena') {
// Pre-chaplet devotion: opening prayer + O Blood and Water × 3
foreach ($divine_mercy_opening as $slide) {
$slides[] = $slide;
}
// Day-specific intention and prayer
$day = (int)($session['novena_day'] ?? 1);
if (isset($divine_mercy_novena_prayers[$day])) {
foreach ($divine_mercy_novena_prayers[$day] as $slide) {
$slides[] = $slide;
}
}
// Chaplet opening on stem beads (Sign of Cross, Our Father, Hail Mary, Creed)
foreach ($divine_mercy_chaplet_opening as $slide) {
$slides[] = $slide;
}
} else {
foreach ($opening as $slide) {
$slides[] = $slide;
}
}
// -----------------------------------------------------------------------
// 3. Five decades
//
// Bead layout:
// Bead 0: Crucifix (Sign of Cross / Creed)
// Bead 1: large (Our Father, stem)
// Beads 24: small (3 Hail Marys / Creed for chaplet, stem)
// Bead 5: large (Our Father, Decade 1)
// Beads 615: small (Decade 1, 10 HMs / For the sake…)
// Bead 16: large (Our Father, Decade 2)
// Beads 1726: small (Decade 2)
// Bead 27: large (Our Father, Decade 3)
// Beads 2837: small (Decade 3)
// Bead 38: large (Our Father, Decade 4)
// Beads 3948: small (Decade 4)
// Bead 49: large (Our Father, Decade 5)
// Beads 5059: small (Decade 5)
// -----------------------------------------------------------------------
$decade_bead_map = [
// decade => [our_father_index, first_hm_index]
1 => [5, 6],
2 => [16, 17],
3 => [27, 28],
4 => [38, 39],
5 => [49, 50],
];
if ($session['occasion'] === 'divine_mercy_novena') {
for ($d = 1; $d <= 5; $d++) {
[$of_idx, $hm_idx] = $decade_bead_map[$d];
foreach (build_chaplet_decade_slides($d, $of_idx, $hm_idx) as $slide) {
$slides[] = $slide;
}
}
} else {
$mystery_set = $mysteries[$session['mystery_set']] ?? $mysteries['sorrowful'];
for ($d = 1; $d <= 5; $d++) {
[$of_idx, $hm_idx] = $decade_bead_map[$d];
$mystery_slide = $mystery_set[$d - 1];
$decade_slides = build_decade_slides($d, $mystery_slide, $of_idx, $hm_idx);
foreach ($decade_slides as $slide) {
$slides[] = $slide;
}
}
}
// -----------------------------------------------------------------------
// 4. Post-decade prayers by occasion
// -----------------------------------------------------------------------
switch ($session['occasion']) {
case 'novena_deceased':
// Hail Holy Queen
foreach ($hail_holy_queen as $slide) {
$slides[] = $slide;
}
// Day-specific novena prayer
$day = (int)($session['novena_day'] ?? 1);
if (isset($novena_prayers[$day])) {
$slides[] = $novena_prayers[$day];
}
// Litany of the Passion
foreach ($litany_passion as $slide) {
$slides[] = $slide;
}
// Litany for the Departed
foreach ($litany_departed as $slide) {
$slides[] = $slide;
}
break;
case 'divine_mercy_novena':
// Holy God, Holy Mighty One, Holy Immortal One × 3
foreach ($divine_mercy_chaplet_close as $slide) {
$slides[] = $slide;
}
break;
case 'memorial':
case 'general_rosary':
default:
foreach ($hail_holy_queen as $slide) {
$slides[] = $slide;
}
$slides[] = $rosary_closing_prayer;
break;
}
// -----------------------------------------------------------------------
// 5. Closing slide — inject photo for personal occasions
// -----------------------------------------------------------------------
$occasion_key = $session['occasion'];
if (!isset($closing[$occasion_key])) {
$occasion_key = 'general_rosary';
}
$closing_slide = $closing[$occasion_key];
if (in_array($occasion_key, ['novena_deceased', 'memorial'])) {
$closing_slide['photo_path'] = $session['photo_path'] ?? null;
}
$slides[] = $closing_slide;
// -----------------------------------------------------------------------
// 6. Variable substitution
// -----------------------------------------------------------------------
$name = $session['subject_name'] ?? '';
$pronoun = $session['subject_pronoun'] ?? 'he'; // 'he' or 'she'
$dates = $session['subject_dates'] ?? '';
$pronoun_obj = ($pronoun === 'she') ? 'her' : 'him';
$pronoun_poss = ($pronoun === 'she') ? 'her' : 'his';
$pronoun_cap = ucfirst($pronoun);
$find = ['{name}', '{pronoun}', '{pronoun_obj}', '{pronoun_poss}', '{subject_dates}'];
$replace = [$name, $pronoun, $pronoun_obj, $pronoun_poss, $dates];
foreach ($slides as &$slide) {
$slide['title'] = str_replace($find, $replace, $slide['title'] ?? '');
$slide['leader'] = str_replace($find, $replace, $slide['leader'] ?? '');
$slide['all'] = str_replace($find, $replace, $slide['all'] ?? '');
if (isset($slide['subtitle'])) {
$slide['subtitle'] = str_replace($find, $replace, $slide['subtitle']);
}
}
unset($slide);
return $slides;
}
+48
View File
@@ -0,0 +1,48 @@
<?php
/**
* includes/donate.php — Renders the public donate strip if enabled.
* Include on any public page: require_once __DIR__ . '/includes/donate.php';
* Then call: render_donate_strip();
*/
function render_donate_strip(): void {
if (!get_setting('donate_enabled', '0')) return;
$type = get_setting('donate_type', 'custom');
$handle = trim(get_setting('donate_handle', ''));
$label = trim(get_setting('donate_label', ''));
if ($handle === '') return;
// Build URL and default label by type
switch ($type) {
case 'paypal':
$url = 'https://paypal.me/' . rawurlencode(ltrim($handle, '@'));
$icon = '&#x1F49B;'; // 💛
$label = $label ?: 'Support via PayPal';
break;
case 'venmo':
$url = 'https://venmo.com/u/' . rawurlencode(ltrim($handle, '@'));
$icon = '&#x1F4B8;'; // 💸
$label = $label ?: '@' . ltrim($handle, '@') . ' on Venmo';
break;
case 'buymeacoffee':
$url = 'https://buymeacoffee.com/' . rawurlencode(ltrim($handle, '@'));
$icon = '&#x2615;'; // ☕
$label = $label ?: 'Buy Me a Coffee';
break;
default: // custom
$url = $handle; // full URL stored in handle field for custom
$icon = '&#x2764;'; // ❤
$label = $label ?: 'Support This Ministry';
break;
}
?>
<div class="donate-strip">
<span class="donate-strip-text">Help keep this site running</span>
<a href="<?= htmlspecialchars($url) ?>" target="_blank" rel="noopener" class="donate-strip-link">
<?= $icon ?> <?= htmlspecialchars($label) ?>
</a>
</div>
<?php
}
+207
View File
@@ -0,0 +1,207 @@
<?php
/**
* includes/mailer.php — lightweight SMTP mailer using stream_socket_client().
* Falls back to PHP mail() when smtp_host is not configured.
*/
/**
* Send an email.
*
* @param string $to_email Recipient email address
* @param string $to_name Recipient display name
* @param string $subject Email subject
* @param string $html HTML body
* @param string $text Plain-text body (auto-generated from HTML if empty)
* @return bool True on success, false on failure
*/
function send_email(string $to_email, string $to_name, string $subject, string $html, string $text = ''): bool {
$smtp_host = get_setting('smtp_host');
$smtp_port = (int) get_setting('smtp_port', '587');
$smtp_user = get_setting('smtp_user');
$smtp_pass = get_setting('smtp_pass');
$smtp_from = get_setting('smtp_from');
$smtp_from_name = get_setting('smtp_from_name', 'Rosary Presenter');
if ($text === '') {
$text = strip_tags(preg_replace('/<br\s*\/?>/i', "\n", $html));
}
// Fall back to PHP mail() if SMTP is not configured
if ($smtp_host === '') {
$headers = "MIME-Version: 1.0\r\n";
$headers .= "Content-Type: text/html; charset=UTF-8\r\n";
if ($smtp_from !== '') {
$headers .= 'From: ' . _mail_encode_name($smtp_from_name) . ' <' . $smtp_from . ">\r\n";
}
return @mail($to_email, $subject, $html, $headers);
}
try {
return _smtp_send($smtp_host, $smtp_port, $smtp_user, $smtp_pass, $smtp_from, $smtp_from_name, $to_email, $to_name, $subject, $html, $text);
} catch (Throwable $e) {
error_log('Mailer error: ' . $e->getMessage());
return false;
}
}
/**
* Return a simple styled HTML email wrapper.
*/
function email_template(string $title, string $body_html): string {
$site_name = get_setting('site_name', 'Rosary Presenter');
return <<<HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{$title}</title>
</head>
<body style="margin:0;padding:0;background:#f4f4f5;font-family:system-ui,-apple-system,sans-serif">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;padding:40px 20px">
<tr><td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:8px;overflow:hidden;max-width:600px;width:100%">
<tr>
<td style="background:#1e3a5f;padding:24px 32px;text-align:center">
<h1 style="margin:0;color:#ffffff;font-size:22px;font-weight:600">&#x271D; {$site_name}</h1>
</td>
</tr>
<tr>
<td style="padding:32px">
{$body_html}
</td>
</tr>
<tr>
<td style="background:#f4f4f5;padding:20px 32px;text-align:center;color:#6b7280;font-size:13px">
&copy; <?= date('Y') ?> {$site_name}. This email was sent automatically.
</td>
</tr>
</table>
</td></tr>
</table>
</body>
</html>
HTML;
}
// ---------------------------------------------------------------------------
// Internal SMTP helpers
// ---------------------------------------------------------------------------
function _smtp_send(
string $host, int $port,
string $user, string $pass,
string $from_addr, string $from_name,
string $to_addr, string $to_name,
string $subject, string $html, string $text
): bool {
$use_ssl = ($port === 465);
$socket_addr = ($use_ssl ? 'ssl://' : 'tcp://') . $host . ':' . $port;
$errno = 0;
$errstr = '';
$sock = stream_socket_client($socket_addr, $errno, $errstr, 15);
if (!$sock) {
throw new RuntimeException("SMTP connect failed ({$errno}): {$errstr}");
}
stream_set_timeout($sock, 15);
_smtp_expect($sock, 220);
_smtp_cmd($sock, 'EHLO ' . gethostname());
$ehlo = _smtp_read_all($sock);
// STARTTLS for port 587
if (!$use_ssl && strpos($ehlo, 'STARTTLS') !== false) {
_smtp_cmd($sock, 'STARTTLS');
_smtp_expect($sock, 220);
if (!stream_socket_enable_crypto($sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
throw new RuntimeException('STARTTLS negotiation failed');
}
_smtp_cmd($sock, 'EHLO ' . gethostname());
_smtp_read_all($sock);
}
// AUTH LOGIN
if ($user !== '') {
_smtp_cmd($sock, 'AUTH LOGIN');
_smtp_expect($sock, 334);
_smtp_cmd($sock, base64_encode($user));
_smtp_expect($sock, 334);
_smtp_cmd($sock, base64_encode($pass));
_smtp_expect($sock, 235);
}
_smtp_cmd($sock, 'MAIL FROM:<' . $from_addr . '>');
_smtp_expect($sock, 250);
_smtp_cmd($sock, 'RCPT TO:<' . $to_addr . '>');
_smtp_expect($sock, [250, 251]);
_smtp_cmd($sock, 'DATA');
_smtp_expect($sock, 354);
$boundary = 'b_' . bin2hex(random_bytes(8));
$date = date('r');
$msg_id = bin2hex(random_bytes(12)) . '@' . gethostname();
$headers = "Date: {$date}\r\n";
$headers .= 'From: ' . _mail_encode_name($from_name) . ' <' . $from_addr . ">\r\n";
$headers .= 'To: ' . _mail_encode_name($to_name) . ' <' . $to_addr . ">\r\n";
$headers .= 'Subject: ' . _mail_encode_name($subject) . "\r\n";
$headers .= "Message-ID: <{$msg_id}>\r\n";
$headers .= "MIME-Version: 1.0\r\n";
$headers .= "Content-Type: multipart/alternative; boundary=\"{$boundary}\"\r\n";
$body = "--{$boundary}\r\n";
$body .= "Content-Type: text/plain; charset=UTF-8\r\n";
$body .= "Content-Transfer-Encoding: quoted-printable\r\n\r\n";
$body .= quoted_printable_encode($text) . "\r\n";
$body .= "--{$boundary}\r\n";
$body .= "Content-Type: text/html; charset=UTF-8\r\n";
$body .= "Content-Transfer-Encoding: quoted-printable\r\n\r\n";
$body .= quoted_printable_encode($html) . "\r\n";
$body .= "--{$boundary}--\r\n";
// Dot-stuff the body
$body = preg_replace('/^\.$/m', '..', $body);
fwrite($sock, $headers . "\r\n" . $body . "\r\n.\r\n");
_smtp_expect($sock, 250);
_smtp_cmd($sock, 'QUIT');
fclose($sock);
return true;
}
function _smtp_cmd($sock, string $cmd): void {
fwrite($sock, $cmd . "\r\n");
}
function _smtp_read_all($sock): string {
$data = '';
while (!feof($sock)) {
$line = fgets($sock, 512);
if ($line === false) break;
$data .= $line;
// Last line of multi-line response has a space after the code
if (strlen($line) >= 4 && $line[3] === ' ') break;
}
return $data;
}
function _smtp_expect($sock, $codes): void {
$response = _smtp_read_all($sock);
$code = (int)substr(trim($response), 0, 3);
$expected = (array)$codes;
if (!in_array($code, $expected, true)) {
throw new RuntimeException("SMTP unexpected response {$code}: " . trim($response));
}
}
function _mail_encode_name(string $name): string {
if ($name === '') return '';
// RFC 2047 encode if needed
if (preg_match('/[^\x20-\x7E]/', $name) || strpbrk($name, '"\\,;<>@') !== false) {
return '=?UTF-8?B?' . base64_encode($name) . '?=';
}
return $name;
}