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:
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
/**
|
||||
* api/delete_audio.php
|
||||
* POST: delete the audio file for a specific prayer key.
|
||||
* Admin only.
|
||||
*
|
||||
* POST params:
|
||||
* key — audio key string
|
||||
*
|
||||
* Returns JSON: {"deleted": true|false}
|
||||
*/
|
||||
require_once __DIR__ . '/../config/db.php';
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
require_auth();
|
||||
|
||||
if (!has_role('admin')) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Permission denied']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['error' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$key = trim($_POST['key'] ?? '');
|
||||
if (!preg_match('/^[a-z0-9_]+$/', $key) || strlen($key) > 100) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Invalid audio key']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$audio_dir = UPLOADS_DIR . 'audio/';
|
||||
$deleted = false;
|
||||
|
||||
foreach (glob($audio_dir . $key . '.*') ?: [] as $file) {
|
||||
unlink($file);
|
||||
$deleted = true;
|
||||
}
|
||||
|
||||
echo json_encode(['deleted' => $deleted]);
|
||||
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
/**
|
||||
* api/save_session.php
|
||||
* POST: insert or update a session.
|
||||
*
|
||||
* NEW novena_deceased (no id): inserts group + 9 rows (Day 1–9), returns {"novena":true,"ids":[...]}
|
||||
* EDIT any session (id provided): updates single row, returns {"id": N}
|
||||
* NEW other occasion: inserts 1 row, returns {"id": N}
|
||||
*/
|
||||
require_once __DIR__ . '/../config/db.php';
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
require_auth();
|
||||
|
||||
$user = current_user();
|
||||
$uid = (int)$user['id'];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['error' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Collect and sanitize input
|
||||
$id = isset($_POST['id']) && $_POST['id'] !== '' ? (int)$_POST['id'] : null;
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
$occasion = trim($_POST['occasion'] ?? '');
|
||||
$mystery_set = trim($_POST['mystery_set'] ?? '');
|
||||
$novena_mystery_mode = trim($_POST['novena_mystery_mode'] ?? '');
|
||||
$novena_day = isset($_POST['novena_day']) && $_POST['novena_day'] !== '' ? (int)$_POST['novena_day'] : null;
|
||||
$subject_name = trim($_POST['subject_name'] ?? '') ?: null;
|
||||
$subject_pronoun = trim($_POST['subject_pronoun'] ?? '') ?: null;
|
||||
$subject_dates = trim($_POST['subject_dates'] ?? '') ?: null;
|
||||
$photo_path = trim($_POST['photo_path'] ?? '') ?: null;
|
||||
$is_public = isset($_POST['is_public']) ? 1 : 0;
|
||||
|
||||
// For novena sessions, mystery_set is determined by novena_mystery_mode
|
||||
if ($occasion === 'novena_deceased') {
|
||||
$mystery_set = ($novena_mystery_mode === 'by_day_of_week') ? 'by_day_of_week' : 'sorrowful';
|
||||
}
|
||||
// Divine Mercy Novena uses the chaplet — no mystery set
|
||||
if ($occasion === 'divine_mercy_novena') {
|
||||
$mystery_set = 'chaplet';
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
$valid_occasions = ['novena_deceased', 'divine_mercy_novena', 'general_rosary', 'memorial'];
|
||||
$valid_mysteries = ['sorrowful', 'joyful', 'glorious', 'luminous', 'by_day_of_week', 'chaplet'];
|
||||
|
||||
if ($name === '') {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Session name is required']);
|
||||
exit;
|
||||
}
|
||||
if (!in_array($occasion, $valid_occasions, true)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Invalid occasion']);
|
||||
exit;
|
||||
}
|
||||
if (!in_array($mystery_set, $valid_mysteries, true)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Invalid mystery set']);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = get_pdo();
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// EDIT: update single existing session
|
||||
// ------------------------------------------------------------------
|
||||
if ($id !== null) {
|
||||
// Verify ownership or admin
|
||||
$chk = $pdo->prepare('SELECT user_id FROM sessions WHERE id = ?');
|
||||
$chk->execute([$id]);
|
||||
$row = $chk->fetch();
|
||||
if (!$row) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'Session not found']);
|
||||
exit;
|
||||
}
|
||||
if (!has_role('admin') && (int)$row['user_id'] !== $uid) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Permission denied']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Update slug if name changed and user owns it
|
||||
$new_slug = null;
|
||||
if (!has_role('admin') || (int)$row['user_id'] === $uid) {
|
||||
$owner_id = (int)$row['user_id'];
|
||||
$new_slug = unique_slug($name, $owner_id, 'sessions', $id);
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
UPDATE sessions
|
||||
SET name = ?, occasion = ?, mystery_set = ?,
|
||||
subject_name = ?, subject_pronoun = ?, subject_dates = ?,
|
||||
photo_path = COALESCE(?, photo_path),
|
||||
is_public = ?' .
|
||||
($new_slug !== null ? ', slug = ?' : '') . '
|
||||
WHERE id = ?
|
||||
');
|
||||
|
||||
$params = [
|
||||
$name, $occasion, $mystery_set,
|
||||
$subject_name, $subject_pronoun, $subject_dates,
|
||||
$photo_path, $is_public,
|
||||
];
|
||||
if ($new_slug !== null) $params[] = $new_slug;
|
||||
$params[] = $id;
|
||||
|
||||
$stmt->execute($params);
|
||||
echo json_encode(['id' => $id]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Creating new: check rosary limit
|
||||
// ------------------------------------------------------------------
|
||||
if (!can_create_rosary($uid, $user['rosary_limit'])) {
|
||||
http_response_code(429);
|
||||
echo json_encode(['error' => 'Rosary limit reached. Contact an administrator to increase your limit.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// CREATE NEW NOVENA: create a group record, then 9 day sessions
|
||||
// ------------------------------------------------------------------
|
||||
if ($occasion === 'divine_mercy_novena') {
|
||||
$grp_slug = unique_slug($name, $uid, 'novena_groups');
|
||||
|
||||
$grp = $pdo->prepare('
|
||||
INSERT INTO novena_groups
|
||||
(name, mystery_set, subject_name, subject_pronoun, subject_dates, photo_path, user_id, is_public, slug)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
');
|
||||
$grp->execute([$name, $mystery_set, null, null, null, $photo_path, $uid, $is_public, $grp_slug]);
|
||||
$group_id = (int)$pdo->lastInsertId();
|
||||
|
||||
$insert = $pdo->prepare('
|
||||
INSERT INTO sessions
|
||||
(name, occasion, mystery_set, novena_day,
|
||||
subject_name, subject_pronoun, subject_dates, photo_path, novena_group_id,
|
||||
user_id, is_public, slug)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
');
|
||||
$created_ids = [];
|
||||
for ($day = 1; $day <= 9; $day++) {
|
||||
$day_name = $name . ' — Day ' . $day;
|
||||
$day_slug = unique_slug($day_name, $uid, 'sessions');
|
||||
$insert->execute([
|
||||
$day_name, $occasion, $mystery_set, $day,
|
||||
null, null, null, $photo_path, $group_id,
|
||||
$uid, $is_public, $day_slug,
|
||||
]);
|
||||
$created_ids[] = (int)$pdo->lastInsertId();
|
||||
}
|
||||
echo json_encode(['novena' => true, 'group_id' => $group_id, 'ids' => $created_ids]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($occasion === 'novena_deceased') {
|
||||
$grp_slug = unique_slug($name, $uid, 'novena_groups');
|
||||
|
||||
$grp = $pdo->prepare('
|
||||
INSERT INTO novena_groups
|
||||
(name, mystery_set, subject_name, subject_pronoun, subject_dates, photo_path, user_id, is_public, slug)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
');
|
||||
$grp->execute([$name, $mystery_set, $subject_name, $subject_pronoun, $subject_dates, $photo_path, $uid, $is_public, $grp_slug]);
|
||||
$group_id = (int)$pdo->lastInsertId();
|
||||
|
||||
$insert = $pdo->prepare('
|
||||
INSERT INTO sessions
|
||||
(name, occasion, mystery_set, novena_day,
|
||||
subject_name, subject_pronoun, subject_dates, photo_path, novena_group_id,
|
||||
user_id, is_public, slug)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
');
|
||||
$created_ids = [];
|
||||
for ($day = 1; $day <= 9; $day++) {
|
||||
$day_name = $name . ' — Day ' . $day;
|
||||
$day_slug = unique_slug($day_name, $uid, 'sessions');
|
||||
$insert->execute([
|
||||
$day_name, $occasion, $mystery_set, $day,
|
||||
$subject_name, $subject_pronoun, $subject_dates, $photo_path, $group_id,
|
||||
$uid, $is_public, $day_slug,
|
||||
]);
|
||||
$created_ids[] = (int)$pdo->lastInsertId();
|
||||
}
|
||||
echo json_encode(['novena' => true, 'group_id' => $group_id, 'ids' => $created_ids]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// CREATE NEW: single session (general_rosary or memorial)
|
||||
// ------------------------------------------------------------------
|
||||
$slug = unique_slug($name, $uid, 'sessions');
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO sessions
|
||||
(name, occasion, mystery_set, novena_day,
|
||||
subject_name, subject_pronoun, subject_dates, photo_path,
|
||||
user_id, is_public, slug)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
');
|
||||
$stmt->execute([
|
||||
$name, $occasion, $mystery_set, $novena_day,
|
||||
$subject_name, $subject_pronoun, $subject_dates, $photo_path,
|
||||
$uid, $is_public, $slug,
|
||||
]);
|
||||
echo json_encode(['id' => (int)$pdo->lastInsertId()]);
|
||||
|
||||
} catch (PDOException $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Database error: ' . $e->getMessage()]);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
/**
|
||||
* api/toggle_pin.php
|
||||
* POST: toggle is_pinned on a session or novena_group.
|
||||
* Admin / superadmin only.
|
||||
*
|
||||
* POST params:
|
||||
* type — 'session' | 'novena'
|
||||
* id — integer row ID
|
||||
*
|
||||
* Returns JSON: {"pinned": true|false}
|
||||
*/
|
||||
require_once __DIR__ . '/../config/db.php';
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
_auth_start();
|
||||
|
||||
if (!has_role('admin')) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Permission denied']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['error' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$type = trim($_POST['type'] ?? '');
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
|
||||
if (!in_array($type, ['session', 'novena'], true) || $id < 1) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Invalid parameters']);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = get_pdo();
|
||||
$table = ($type === 'session') ? 'sessions' : 'novena_groups';
|
||||
|
||||
$sel = $pdo->prepare("SELECT is_pinned FROM {$table} WHERE id = ?");
|
||||
$sel->execute([$id]);
|
||||
$row = $sel->fetch();
|
||||
|
||||
if (!$row) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'Record not found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$new = $row['is_pinned'] ? 0 : 1;
|
||||
$upd = $pdo->prepare("UPDATE {$table} SET is_pinned = ? WHERE id = ?");
|
||||
$upd->execute([$new, $id]);
|
||||
|
||||
echo json_encode(['pinned' => (bool)$new]);
|
||||
|
||||
} catch (PDOException $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Database error']);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
/**
|
||||
* api/upload_audio.php
|
||||
* POST: upload an audio file for a specific prayer key.
|
||||
* Admin only. Replaces any existing file for that key.
|
||||
*
|
||||
* POST params:
|
||||
* key — audio key string (alphanumeric + underscores)
|
||||
* audio — uploaded file (MP3, M4A, OGG, WAV)
|
||||
*
|
||||
* Returns JSON: {"key": "...", "ext": "mp3"} or {"error": "..."}
|
||||
*/
|
||||
require_once __DIR__ . '/../config/db.php';
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
require_auth();
|
||||
|
||||
if (!has_role('admin')) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Permission denied']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['error' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$key = trim($_POST['key'] ?? '');
|
||||
if (!preg_match('/^[a-z0-9_]+$/', $key) || strlen($key) > 100) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Invalid audio key']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!isset($_FILES['audio']) || $_FILES['audio']['error'] !== UPLOAD_ERR_OK) {
|
||||
$codes = [
|
||||
UPLOAD_ERR_INI_SIZE => 'File exceeds server limit',
|
||||
UPLOAD_ERR_FORM_SIZE => 'File exceeds form limit',
|
||||
UPLOAD_ERR_PARTIAL => 'File only partially uploaded',
|
||||
UPLOAD_ERR_NO_FILE => 'No file uploaded',
|
||||
];
|
||||
$code = $_FILES['audio']['error'] ?? UPLOAD_ERR_NO_FILE;
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => $codes[$code] ?? 'Upload error']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$file = $_FILES['audio'];
|
||||
$max_size = 50 * 1024 * 1024; // 50 MB
|
||||
|
||||
if ($file['size'] > $max_size) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'File too large (max 50 MB)']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||
$mime = $finfo->file($file['tmp_name']);
|
||||
$allowed = [
|
||||
'audio/mpeg' => 'mp3',
|
||||
'audio/mp3' => 'mp3',
|
||||
'audio/mp4' => 'm4a',
|
||||
'audio/x-m4a' => 'm4a',
|
||||
'audio/ogg' => 'ogg',
|
||||
'audio/wav' => 'wav',
|
||||
'audio/x-wav' => 'wav',
|
||||
'audio/wave' => 'wav',
|
||||
'audio/webm' => 'webm',
|
||||
];
|
||||
|
||||
if (!isset($allowed[$mime])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Invalid format. Allowed: MP3, M4A, OGG, WAV']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$ext = $allowed[$mime];
|
||||
$audio_dir = UPLOADS_DIR . 'audio/';
|
||||
|
||||
if (!is_dir($audio_dir)) {
|
||||
mkdir($audio_dir, 0755, true);
|
||||
}
|
||||
|
||||
// Delete any existing file for this key (regardless of extension)
|
||||
foreach (glob($audio_dir . $key . '.*') ?: [] as $old) {
|
||||
unlink($old);
|
||||
}
|
||||
|
||||
$dest = $audio_dir . $key . '.' . $ext;
|
||||
|
||||
if (!move_uploaded_file($file['tmp_name'], $dest)) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Failed to save file']);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode(['key' => $key, 'ext' => $ext]);
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
/**
|
||||
* api/upload_photo.php
|
||||
* POST: handle photo upload.
|
||||
* Returns JSON: {"path": "uploads/filename.jpg"} on success, {"error": "..."} on failure.
|
||||
*/
|
||||
require_once __DIR__ . '/../config/db.php';
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
require_auth();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['error' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!isset($_FILES['photo']) || $_FILES['photo']['error'] !== UPLOAD_ERR_OK) {
|
||||
$upload_errors = [
|
||||
UPLOAD_ERR_INI_SIZE => 'File exceeds server upload limit',
|
||||
UPLOAD_ERR_FORM_SIZE => 'File exceeds form size limit',
|
||||
UPLOAD_ERR_PARTIAL => 'File was only partially uploaded',
|
||||
UPLOAD_ERR_NO_FILE => 'No file was uploaded',
|
||||
UPLOAD_ERR_NO_TMP_DIR => 'Missing temporary folder',
|
||||
UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk',
|
||||
UPLOAD_ERR_EXTENSION => 'A PHP extension stopped the upload',
|
||||
];
|
||||
$err_code = $_FILES['photo']['error'] ?? UPLOAD_ERR_NO_FILE;
|
||||
$err_msg = $upload_errors[$err_code] ?? 'Unknown upload error';
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => $err_msg]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$file = $_FILES['photo'];
|
||||
$max_size = 5 * 1024 * 1024; // 5 MB
|
||||
|
||||
// Validate file size
|
||||
if ($file['size'] > $max_size) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'File is too large (max 5 MB)']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Validate MIME type using finfo (not just extension)
|
||||
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||
$mime = $finfo->file($file['tmp_name']);
|
||||
$allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
|
||||
if (!in_array($mime, $allowed, true)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Invalid file type. Allowed: JPEG, PNG, GIF, WebP']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$ext_map = [
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/gif' => 'gif',
|
||||
'image/webp' => 'webp',
|
||||
];
|
||||
$ext = $ext_map[$mime];
|
||||
$filename = bin2hex(random_bytes(16)) . '.' . $ext;
|
||||
$dest = UPLOADS_DIR . $filename;
|
||||
|
||||
if (!is_dir(UPLOADS_DIR)) {
|
||||
mkdir(UPLOADS_DIR, 0755, true);
|
||||
}
|
||||
|
||||
if (!move_uploaded_file($file['tmp_name'], $dest)) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Failed to save file']);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode(['path' => UPLOADS_URL . $filename]);
|
||||
Reference in New Issue
Block a user