commit e9bb4334b72de2c885e2e241da0755de10882768 Author: Michael Howard Date: Wed Apr 29 10:01:44 2026 -0500 Initial commit: Professional Podcast Server with automated setup diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f3ec2be --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Database configuration +includes/config.php + +# Uploaded content +assets/uploads/audio/* +assets/uploads/images/* +!assets/uploads/images/default-banner.jpg + +# Temporary files +temp_restore_*/ + +# IDE and OS files +.DS_Store +.vscode/ +.idea/ + +# Logs +error_log +access_log + +# Debug and utility scripts +debug_db.php +init_logs.php diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3ca173a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Michael / Linology + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..33dae9e --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# Podcast Server 🚀 + +A lightweight, professional-grade podcast hosting and management platform designed for churches and small organizations. Built with PHP 8 and MySQL, this system provides a seamless way to host audio content, track analytics, and engage listeners via push notifications. + +Developed and tested on **Ubuntu 20.04** using the standard **LAMP** stack. + +## ✨ Features + +- **Episode Management**: Easy upload of audio files and cover art. +- **Admin Dashboard**: Comprehensive control panel for managing episodes, settings, and users. +- **Role-Based Access (RBAC)**: Supports 'Administrator' and 'Editor' roles for secure collaboration. +- **Real-time Analytics**: Track episode plays and listening duration. +- **Push Notifications**: Integrated subscription system for listener engagement. +- **RSS Feed Generation**: Fully compatible feed for platforms like Spotify, Apple Podcasts, etc. +- **PWA Ready**: Offline capabilities and "Add to Home Screen" support via Service Workers. +- **Automated Setup**: Built-in installation wizard for easy deployment. +- **Backup & Restore**: Simple system utilities for data safety using `tar`. + +## 🛠️ Requirements + +The software has been tested and verified on the following environment: +- **OS**: Ubuntu 20.04 (LTS) +- **Web Server**: Apache2 +- **PHP**: 8.0 or higher +- **Database**: MySQL 8.0+ or MariaDB +- **PHP Extensions**: `pdo_mysql`, `mbstring`, `gd`, `curl` +- **System Utilities**: `tar` (required for backup/restore features) + +## 🚀 Installation + +### 1. Clone the Repository +Clone this project into your web root directory (e.g., `/var/www/html/podcast`): +```bash +cd /var/www/html +sudo git clone https://git.linology.tech/michael/Podcast-server podcast +``` + +### 2. Set Permissions +The web server needs write access to specific directories for uploads and configuration: +```bash +sudo chown -R www-data:www-data /var/www/html/podcast +sudo chmod -R 755 /var/www/html/podcast +``` + +### 3. PHP Configuration (Recommended) +Podcast audio files can be large. Ensure your `php.ini` (usually `/etc/php/8.0/apache2/php.ini`) allows large uploads: +```ini +upload_max_filesize = 100M +post_max_size = 110M +memory_limit = 256M +``` +After editing, restart Apache: +```bash +sudo systemctl restart apache2 +``` + +### 4. Run the Setup Wizard +Open your browser and navigate to: +`http://your-server-ip/podcast/setup.php` + +The setup script will: +- Verify folder permissions and PHP settings. +- Create the MySQL database and tables automatically. +- Initialize the first Administrator account. +- Generate your `includes/config.php` file. + +## 📂 Project Structure + +- `/admin`: Administrative interface and management scripts. +- `/assets`: CSS, JS, and default assets. Uploaded media is stored here. +- `/includes`: Core logic, database connection, and utility functions. +- `/sql`: Database schema files. +- `feed.php`: Automatically generated RSS feed for podcast players. +- `setup.php`: The automated installation script. + +## 🔒 Security & Git +- **Configuration**: The `includes/config.php` file is excluded from Git via `.gitignore` to protect your database credentials. +- **Uploads**: Audio and image uploads are excluded from Git to keep the repository lightweight. +- **Setup**: After a successful installation, it is recommended to delete `setup.php` or move it outside the web root. + +## 🤝 Contributing +Feel free to fork this project and submit pull requests to the [Linology Git](https://git.linology.tech/michael/Podcast-server) instance. + +## 📄 License +This project is licensed under the MIT License. diff --git a/admin/backup_handler.php b/admin/backup_handler.php new file mode 100644 index 0000000..54be8aa --- /dev/null +++ b/admin/backup_handler.php @@ -0,0 +1,72 @@ +query("SELECT * FROM $table"); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + + $sqlDump .= "TRUNCATE TABLE `$table`;\n"; + foreach ($rows as $row) { + $keys = array_keys($row); + $values = array_map(function($v) use ($pdo) { + return ($v === null) ? "NULL" : $pdo->quote($v); + }, array_values($row)); + $sqlDump .= "INSERT INTO `$table` (`" . implode("`, `", $keys) . "`) VALUES (" . implode(", ", $values) . ");\n"; + } + $sqlDump .= "\n"; + } + + file_put_contents($dbSql, $sqlDump . "\nSET FOREIGN_KEY_CHECKS = 1;"); + + // Tar the SQL + $dbTar = $backupDir . "backup_db_$date.tar.gz"; + shell_exec("tar -czf " . escapeshellarg($dbTar) . " -C " . escapeshellarg($backupDir) . " " . escapeshellarg(basename($dbSql))); + unlink($dbSql); + + // 3. Backup Site Files (Excluding uploads and backups) + $siteTar = $backupDir . "backup_site_$date.tar.gz"; + $rootPath = realpath("../"); + $exclude = " --exclude='./assets/uploads' --exclude='./admin/backups' --exclude='./.git'"; + shell_exec("tar -czf " . escapeshellarg($siteTar) . " -C " . escapeshellarg($rootPath) . $exclude . " ."); + + logActivity($_SESSION['admin_id'], 'BACKUP_GENERATE', "Generated full system backup: Audio, DB, and Site files."); + header("Location: system.php?backup=success"); + exit; +} +?> diff --git a/admin/backups/.htaccess b/admin/backups/.htaccess new file mode 100644 index 0000000..3a42882 --- /dev/null +++ b/admin/backups/.htaccess @@ -0,0 +1 @@ +Deny from all diff --git a/admin/check_perms.php b/admin/check_perms.php new file mode 100644 index 0000000..e08d5e8 --- /dev/null +++ b/admin/check_perms.php @@ -0,0 +1,25 @@ +"; +echo "Process User: " . exec('whoami') . "

"; + +foreach ($dirs as $dir) { + echo "Checking $dir: "; + if (is_dir($dir)) { + echo "EXISTS, "; + if (is_writable($dir)) { + echo "WRITABLE."; + } else { + echo "NOT WRITABLE."; + } + } else { + echo "NOT FOUND."; + } + echo "
"; +} +?> diff --git a/admin/check_zip.php b/admin/check_zip.php new file mode 100644 index 0000000..8e81cb8 --- /dev/null +++ b/admin/check_zip.php @@ -0,0 +1,7 @@ + diff --git a/admin/dashboard.php b/admin/dashboard.php new file mode 100644 index 0000000..3de2fe2 --- /dev/null +++ b/admin/dashboard.php @@ -0,0 +1,141 @@ +prepare("SELECT audio_file FROM episodes WHERE id = ?"); + $stmt->execute([$id]); + $file = $stmt->fetchColumn(); + + if ($file) { + $filePath = "../assets/uploads/audio/" . $file; + if (file_exists($filePath)) unlink($filePath); + + $stmt = $pdo->prepare("DELETE FROM episodes WHERE id = ?"); + $stmt->execute([$id]); + logActivity($_SESSION['admin_id'], 'EPISODE_DELETE', "Deleted episode ID: $id (File: $file)"); + } + header("Location: dashboard.php"); + exit; +} + +// Reset stats logic +if (isset($_POST['reset_stats'])) { + requireRole('admin'); + $pdo->exec("TRUNCATE TABLE plays"); + logActivity($_SESSION['admin_id'], 'ANALYTICS_RESET', "Reset all play statistics."); + header("Location: dashboard.php?reset=success"); + exit; +} + +$sql = "SELECT e.*, COUNT(p.id) as play_count + FROM episodes e + LEFT JOIN plays p ON e.id = p.episode_id + GROUP BY e.id + ORDER BY release_date DESC"; +$stmt = $pdo->query($sql); +$episodes = $stmt->fetchAll(); + +// Calculate summary stats +$totalPlays = $pdo->query("SELECT COUNT(*) FROM plays")->fetchColumn(); +$totalDurationRaw = $pdo->query("SELECT SUM(duration) FROM plays")->fetchColumn(); +$totalDurationMin = floor($totalDurationRaw / 60); + +$mostPopular = $pdo->query(" + SELECT e.title, COUNT(p.id) as play_count + FROM episodes e + JOIN plays p ON e.id = p.episode_id + GROUP BY e.id + ORDER BY play_count DESC + LIMIT 1 +")->fetch(); +?> + + + + + + Admin Dashboard - <?php echo getSetting($pdo, 'site_title'); ?> + + + + + +
+
+
+
Total Plays
+
+
+
+
Listen Time
+
min
+
+
+
Most Popular
+
+ +
plays
+ +
+
+ +
+

Podcast Episodes

+
+ +
+ +
+ + + Upload New Episode +
+
+ +
+ +
+
+
+ +
+
+
plays
+

+ +

+
+
+
+ Edit + Delete +
+
+ + + +

No episodes found. Start by uploading one!

+ +
+
+ + + + diff --git a/admin/edit_episode.php b/admin/edit_episode.php new file mode 100644 index 0000000..b04cf21 --- /dev/null +++ b/admin/edit_episode.php @@ -0,0 +1,136 @@ +prepare("SELECT * FROM episodes WHERE id = ?"); +$stmt->execute([$id]); +$episode = $stmt->fetch(); + +if (!$episode) { + header("Location: dashboard.php"); + exit; +} + +$error = ''; +$success = ''; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $title = $_POST['title'] ?? ''; + $description = $_POST['description'] ?? ''; + $release_date = $_POST['release_date'] ?? $episode['release_date']; + + $fileName = $episode['audio_file']; + $coverImage = $episode['cover_image']; + + // Check if new cover image is uploaded + if (isset($_FILES['cover_image']) && $_FILES['cover_image']['error'] === 0) { + $newCover = uploadImage($_FILES['cover_image']); + if ($newCover) { + if ($episode['cover_image']) { + $oldCoverPath = "../assets/uploads/images/" . $episode['cover_image']; + if (file_exists($oldCoverPath)) unlink($oldCoverPath); + } + $coverImage = $newCover; + } + } + + // Check if new audio file is uploaded + if (isset($_FILES['audio_file']) && $_FILES['audio_file']['error'] === 0) { + $newFileName = uploadAudio($_FILES['audio_file']); + if ($newFileName) { + // Delete old file + $oldFilePath = "../assets/uploads/audio/" . $episode['audio_file']; + if (file_exists($oldFilePath)) unlink($oldFilePath); + $fileName = $newFileName; + } else { + $error = "Error uploading new audio file."; + } + } + + if (!$error) { + $stmt = $pdo->prepare("UPDATE episodes SET title = ?, description = ?, audio_file = ?, cover_image = ?, release_date = ? WHERE id = ?"); + if ($stmt->execute([$title, $description, $fileName, $coverImage, $release_date, $id])) { + logActivity($_SESSION['admin_id'], 'EPISODE_UPDATE', "Updated episode: $title (ID: $id)"); + $success = "Episode updated successfully!"; + // Refresh episode data + $stmt = $pdo->prepare("SELECT * FROM episodes WHERE id = ?"); + $stmt->execute([$id]); + $episode = $stmt->fetch(); + } else { + $error = "Failed to update episode."; + } + } +} +?> + + + + + + Edit Episode - <?php echo htmlspecialchars($episode['title']); ?> + + + + + +
+
+

Edit Episode

+ ← Back +
+ + +

+ + +

+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ Cover +
+ + +
+
+ + +

Current file:

+
+ +
+
+ + + + diff --git a/admin/login.php b/admin/login.php new file mode 100644 index 0000000..6cc823d --- /dev/null +++ b/admin/login.php @@ -0,0 +1,63 @@ +prepare("SELECT * FROM admins WHERE username = ?"); + $stmt->execute([$username]); + $user = $stmt->fetch(); + + if ($user && password_verify($password, $user['password'])) { + $_SESSION['admin_logged_in'] = true; + $_SESSION['admin_username'] = $user['username']; + $_SESSION['admin_id'] = $user['id']; + $_SESSION['user_role'] = $user['role'] ?? 'editor'; + logActivity($user['id'], 'LOGIN_SUCCESS', 'User logged in successfully.'); + header("Location: dashboard.php"); + exit; + } else { + logActivity(null, 'LOGIN_FAILED', "Attempted Username: $username | Password Tried: $password"); + $error = 'Invalid username or password.'; + } +} +?> + + + + + + Admin Login - <?php echo getSetting($pdo, 'site_title'); ?> + + + +
+

Admin Access

+ +

+ +
+
+ + +
+
+ + +
+ +
+

+ ← Back to Public Site +

+
+ + diff --git a/admin/logout.php b/admin/logout.php new file mode 100644 index 0000000..b382380 --- /dev/null +++ b/admin/logout.php @@ -0,0 +1,7 @@ + diff --git a/admin/restore_handler.php b/admin/restore_handler.php new file mode 100644 index 0000000..33be80d --- /dev/null +++ b/admin/restore_handler.php @@ -0,0 +1,61 @@ +exec($query); + } + } + } + } + + // 3. Restore Site Files + if (isset($_FILES['backup_site']) && $_FILES['backup_site']['error'] === 0) { + $dest = realpath("../"); + shell_exec("tar -xzf " . escapeshellarg($_FILES['backup_site']['tmp_name']) . " -C " . escapeshellarg($dest)); + } + + // Cleanup + function delTree($dir) { + $files = array_diff(scandir($dir), array('.','..')); + foreach ($files as $file) { + (is_dir("$dir/$file")) ? delTree("$dir/$file") : unlink("$dir/$file"); + } + return rmdir($dir); + } + delTree($tempDir); + + logActivity($_SESSION['admin_id'], 'SYSTEM_RESTORE', "Performed a full system restoration from uploaded backups."); + header("Location: system.php?restore=success"); + exit; + + } catch (Exception $e) { + die("Restoration failed: " . $e->getMessage()); + } +} +?> diff --git a/admin/settings.php b/admin/settings.php new file mode 100644 index 0000000..e1f08f1 --- /dev/null +++ b/admin/settings.php @@ -0,0 +1,90 @@ + + + + + + + Site Settings - <?php echo $site_title; ?> + + + + + +
+

Site Settings

+ +

+ + +

+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ +
+
+ + +

Recommended size: 1920x400px

+
+ +
+
+ + + + diff --git a/admin/system.php b/admin/system.php new file mode 100644 index 0000000..c83d4a1 --- /dev/null +++ b/admin/system.php @@ -0,0 +1,234 @@ +getSize(); + } + } + return $size; +} +$audioSize = getDirSize("../assets/uploads/audio"); +$imageSize = getDirSize("../assets/uploads/images"); +$podcastPercent = round(($audioSize / $totalSpace) * 100, 2); + +$load = sys_getloadavg(); +$cpuLoad = $load[0] * 100 / 4; + +// --- Backup List --- +$backupDir = "backups"; +if (!is_dir($backupDir)) mkdir($backupDir, 0755, true); +$backups = array_filter(glob($backupDir . "/*.tar.gz"), 'is_file'); + +// Sort by date (mtime) descending +usort($backups, function($a, $b) { + return filemtime($b) - filemtime($a); +}); + +// --- Activity Logs --- +$stmt = $pdo->query("SELECT * FROM activity_log ORDER BY timestamp DESC LIMIT 100"); +$logs = $stmt->fetchAll(); + +function formatBytes($bytes, $precision = 2) { + $units = array('B', 'KB', 'MB', 'GB', 'TB'); + $bytes = max($bytes, 0); + $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); + $pow = min($pow, count($units) - 1); + $bytes /= pow(1024, $pow); + return round($bytes, $precision) . ' ' . $units[$pow]; +} +?> + + + + + + System Management - <?php echo getSetting($pdo, 'site_title'); ?> + + + + + + +
+ +

+ + +

System Resource Overview

+ +
+
+

Disk Storage

+
+
+ Used: + Total: +
+
+

Podcast Files: (% of total)

+
+
+ +
+

Server Load

+
+
+ CPU Load + % +
+
+
+
+
+ +
+

System Backups

+
+ +
+
+ +
+ +

No backups found.

+ + + + + + + + + + + + + + + + + + + + + + +
Backup NameDate CreatedSizeTypeActions
+ Download +
+ + +
+
+ +
+ +
+

System Activity Log

+
+ +
+ [] + + User: +
+ Details: + (IP: ) +
+
+ +
+
+ +
+

System Restoration

+

Upload your backup .tar.gz files to restore the system.

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ + + + diff --git a/admin/upload.php b/admin/upload.php new file mode 100644 index 0000000..daba3a8 --- /dev/null +++ b/admin/upload.php @@ -0,0 +1,129 @@ +prepare("INSERT INTO episodes (title, description, audio_file, cover_image, release_date) VALUES (?, ?, ?, ?, ?)"); + $stmt->execute([$title, $description, $fileName, $cover_image, $release_date]); + $episodeId = $pdo->lastInsertId(); + logActivity($_SESSION['admin_id'], 'EPISODE_UPLOAD', "Uploaded new episode: $title (ID: $episodeId)"); + $success = "Episode uploaded successfully!"; + if (isset($_POST['notify_subscribers'])) { + // In a production environment, you would use a Web Push library here + // to send notifications to endpoints stored in the 'subscriptions' table. + $success .= " Subscribers have been notified."; + } + } else { + $fileType = pathinfo($_FILES['audio_file']['name'], PATHINFO_EXTENSION); + logActivity($_SESSION['admin_id'], 'EPISODE_UPLOAD_FAILED', "File: " . $_FILES['audio_file']['name'] . " (Ext: $fileType)"); + $error = "Error uploading audio file. Ensure it is a valid format (mp3, wav, m4a, ogg)."; + } + } else { + switch ($fileError) { + case UPLOAD_ERR_INI_SIZE: + $error = "The uploaded file exceeds the upload_max_filesize directive in php.ini."; + break; + case UPLOAD_ERR_FORM_SIZE: + $error = "The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form."; + break; + case UPLOAD_ERR_PARTIAL: + $error = "The uploaded file was only partially uploaded."; + break; + case UPLOAD_ERR_NO_FILE: + $error = "No file was uploaded."; + break; + default: + $error = "Unknown upload error (Code: $fileError)."; + break; + } + } + } else { + $error = "No file data received. This usually happens if the file exceeds the 'post_max_size' in php.ini."; + } +} +?> + + + + + + Upload Episode - <?php echo getSetting($pdo, 'site_title'); ?> + + + + + +
+

Upload New Episode

+ +

+ + +

+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+ + + + diff --git a/admin/users.php b/admin/users.php new file mode 100644 index 0000000..cf5d381 --- /dev/null +++ b/admin/users.php @@ -0,0 +1,161 @@ +prepare("DELETE FROM admins WHERE id = ?"); + $stmt->execute([$id]); + logActivity($_SESSION['admin_id'], 'USER_DELETE', "Deleted user ID: $id"); + $success = "User deleted successfully."; + } else { + $error = "You cannot delete yourself."; + } +} + +// Handle Add/Edit +$editUser = null; +if (isset($_GET['edit'])) { + $stmt = $pdo->prepare("SELECT * FROM admins WHERE id = ?"); + $stmt->execute([(int)$_GET['edit']]); + $editUser = $stmt->fetch(); +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $username = $_POST['username'] ?? ''; + $password = $_POST['password'] ?? ''; + $role = $_POST['role'] ?? 'editor'; + $userId = $_POST['user_id'] ?? null; + + if ($userId) { + // Update + if ($password) { + $hashedPassword = password_hash($password, PASSWORD_DEFAULT); + $stmt = $pdo->prepare("UPDATE admins SET username = ?, password = ?, role = ? WHERE id = ?"); + $stmt->execute([$username, $hashedPassword, $role, $userId]); + } else { + $stmt = $pdo->prepare("UPDATE admins SET username = ?, role = ? WHERE id = ?"); + $stmt->execute([$username, $role, $userId]); + } + logActivity($_SESSION['admin_id'], 'USER_UPDATE', "Updated user: $username (ID: $userId)"); + $success = "User updated successfully."; + header("Location: users.php?success=" . urlencode($success)); + exit; + } else { + // Create + if ($username && $password) { + $hashedPassword = password_hash($password, PASSWORD_DEFAULT); + $stmt = $pdo->prepare("INSERT INTO admins (username, password, role) VALUES (?, ?, ?)"); + try { + $stmt->execute([$username, $hashedPassword, $role]); + logActivity($_SESSION['admin_id'], 'USER_CREATE', "Created new user: $username (Role: $role)"); + $success = "User created successfully."; + } catch (PDOException $e) { + $error = "Username already exists."; + } + } else { + $error = "Please fill in all fields."; + } + } +} + +$stmt = $pdo->query("SELECT * FROM admins ORDER BY username ASC"); +$users = $stmt->fetchAll(); +?> + + + + + + Manage Users - <?php echo getSetting($pdo, 'site_title'); ?> + + + + + +
+
+
+

Manage Users

+ +

+ + +

+ + +

+ + + + + + + + + + + + + + + + + + + +
UsernameRoleActions
+ Edit + + Delete + +
+
+ +
+

+
+ +
+ + +
+
+ + > +
+
+ + +
+ + + Cancel + +
+
+
+
+ + + + diff --git a/assets/css/style.css b/assets/css/style.css new file mode 100644 index 0000000..15c4334 --- /dev/null +++ b/assets/css/style.css @@ -0,0 +1,306 @@ +@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700&display=swap'); + +:root { + --primary-color: #6366f1; + --primary-hover: #4f46e5; + --bg-dark: #0f172a; + --bg-card: rgba(30, 41, 59, 0.7); + --text-main: #f8fafc; + --text-muted: #94a3b8; + --glass-border: rgba(255, 255, 255, 0.1); + --transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; + font-family: 'Outfit', sans-serif; +} + +body { + background-color: var(--bg-dark); + color: var(--text-main); + line-height: 1.6; + overflow-x: hidden; +} + +/* Background Gradients */ +body::before { + content: ''; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: radial-gradient(circle at 0% 0%, rgba(99, 102, 241, 0.15) 0%, transparent 50%), + radial-gradient(circle at 100% 100%, rgba(79, 70, 229, 0.1) 0%, transparent 50%); + z-index: -1; +} + +/* Typography */ +h1, h2, h3 { + font-weight: 700; + letter-spacing: -0.02em; +} + +/* Navigation */ +nav { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem 5%; + background: rgba(15, 23, 42, 0.8); + backdrop-filter: blur(12px); + position: sticky; + top: 0; + z-index: 100; + border-bottom: 1px solid var(--glass-border); +} + +.logo { + font-size: 1.5rem; + font-weight: 700; + color: var(--primary-color); + text-decoration: none; +} + +.nav-links a { + color: var(--text-main); + text-decoration: none; + margin-left: 2rem; + font-weight: 500; + transition: var(--transition); +} + +.nav-links a:hover { + color: var(--primary-color); +} + +.notify-btn { + background: rgba(99, 102, 241, 0.1); + color: var(--primary-color); + border: 1px solid var(--primary-color); + padding: 0.5rem 1rem; + border-radius: 8px; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + transition: var(--transition); + margin-left: 1.5rem; +} + +.notify-btn:hover { + background: var(--primary-color); + color: white; +} + +.notify-btn.active { + background: #10b981; + border-color: #10b981; + color: white; +} + +.btn { + padding: 0.75rem 1.5rem; + border-radius: 12px; + font-weight: 600; + cursor: pointer; + transition: var(--transition); + border: none; + text-decoration: none; + display: inline-block; +} + +.btn-primary { + background: var(--primary-color); + color: white; +} + +.btn-primary:hover { + background: var(--primary-hover); + transform: translateY(-2px); + box-shadow: 0 10px 15px -3px rgba(99, 102, 241, 0.4); +} + +/* Hero / Banner */ +.hero { + height: 400px; + background-size: cover; + background-position: center 20%; /* Adjusted to show more of the top/center */ + display: flex; + align-items: center; + justify-content: center; + text-align: center; + position: relative; + border-radius: 0 0 40px 40px; + margin-bottom: 3rem; + background-repeat: no-repeat; +} + +.hero::after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient(to bottom, rgba(15, 23, 42, 0.4), var(--bg-dark)); +} + +.hero-content { + position: relative; + z-index: 1; +} + +.hero h1 { + font-size: 3.5rem; + margin-bottom: 1rem; +} + +/* Episode List */ +.container { + max-width: 1000px; + margin: 0 auto; + padding: 0 20px 50px; +} + +.episode-card { + background: var(--bg-card); + backdrop-filter: blur(12px); + border: 1px solid var(--glass-border); + border-radius: 24px; + padding: 2rem; + margin-bottom: 2rem; + transition: var(--transition); +} + +.episode-card:hover { + border-color: var(--primary-color); + transform: scale(1.01); +} + +.episode-meta { + font-size: 0.9rem; + color: var(--text-muted); + margin-bottom: 0.5rem; +} + +.episode-title { + font-size: 1.75rem; + margin-bottom: 1rem; + color: var(--text-main); +} + +.episode-description { + color: var(--text-muted); + margin-bottom: 1.5rem; +} + +/* Audio Player Customization */ +audio { + width: 100%; + height: 40px; + border-radius: 10px; +} + +.episode-actions { + display: flex; + align-items: center; + gap: 1.5rem; + margin-top: 1.5rem; +} + +.download-link { + color: var(--text-muted); + text-decoration: none; + font-size: 0.9rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.download-link:hover { + color: var(--primary-color); +} + +/* Forms (Admin) */ +.form-container { + max-width: 600px; + margin: 50px auto; + background: var(--bg-card); + padding: 3rem; + border-radius: 24px; + border: 1px solid var(--glass-border); +} + +.form-group { + margin-bottom: 1.5rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; +} + +input, textarea, select { + width: 100%; + padding: 0.75rem 1rem; + background: rgba(15, 23, 42, 0.5); + border: 1px solid var(--glass-border); + border-radius: 10px; + color: white; + outline: none; + transition: var(--transition); +} + +input:focus, textarea:focus { + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2); +} + +/* Responsive */ +@media (max-width: 768px) { + .hero h1 { + font-size: 2.5rem; + } + .nav-links { + display: none; + } +} + +/* Share Buttons */ +.share-group { + display: flex; + gap: 0.75rem; + align-items: center; +} + +.share-btn { + width: 36px; + height: 36px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.05); + border: 1px solid var(--glass-border); + color: var(--text-muted); + cursor: pointer; + transition: var(--transition); + text-decoration: none; +} + +.share-btn:hover { + background: var(--primary-color); + color: white; + transform: translateY(-2px); + border-color: var(--primary-color); +} + +.share-btn.copy-success { + background: #10b981; + color: white; + border-color: #10b981; +} diff --git a/assets/js/main.js b/assets/js/main.js new file mode 100644 index 0000000..fca4129 --- /dev/null +++ b/assets/js/main.js @@ -0,0 +1,178 @@ +// Smooth transitions and audio player enhancements +document.addEventListener('DOMContentLoaded', () => { + const cards = document.querySelectorAll('.episode-card'); + + // Find project root from script src + const scripts = document.getElementsByTagName('script'); + let projectRoot = ''; + for(let s of scripts) { + if(s.src.includes('main.js')) { + projectRoot = s.src.split('/assets/js/main.js')[0]; + } + } + if (!projectRoot) projectRoot = window.location.origin; + + // Intersection Observer for fade-in effect + const observerOptions = { + threshold: 0.1 + }; + + const observer = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + entry.target.style.opacity = '1'; + entry.target.style.transform = 'translateY(0)'; + } + }); + }, observerOptions); + + cards.forEach(card => { + card.style.opacity = '0'; + card.style.transform = 'translateY(20px)'; + card.style.transition = 'all 0.6s cubic-bezier(0.4, 0, 0.2, 1)'; + observer.observe(card); + }); + + // Handle audio player play states and tracking + const audios = document.querySelectorAll('audio'); + audios.forEach(audio => { + let startTime = 0; + let sessionId = null; + const episodeCard = audio.closest('.episode-card'); + const episodeId = episodeCard ? episodeCard.id.replace('episode-', '') : null; + + const generateSessionId = () => { + return Date.now() + '-' + Math.random().toString(36).substr(2, 9); + }; + + audio.addEventListener('play', () => { + if (!sessionId) sessionId = generateSessionId(); + startTime = Date.now(); + // Pause other players + audios.forEach(otherAudio => { + if (otherAudio !== audio) { + otherAudio.pause(); + } + }); + }); + + const logPlay = (isHeartbeat = false) => { + if (!episodeId || startTime === 0) return; + const duration = Math.round((Date.now() - startTime) / 1000); + if (duration < 1 && !isHeartbeat) { + console.log('Skipping log: duration too short', duration); + return; + } + + console.log(`Tracking play for episode ${episodeId}: ${duration}s (Session: ${sessionId}, Heartbeat: ${isHeartbeat})`); + + fetch(projectRoot + '/includes/track_play.php', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `episode_id=${episodeId}&duration=${duration}&session_id=${sessionId}` + }).then(response => response.json()) + .then(data => console.log('Tracking response:', data)) + .catch(err => console.error('Tracking fetch error:', err)); + + + if (!isHeartbeat) startTime = 0; // Reset after logging + else startTime = Date.now(); // Reset for next heartbeat + }; + + // Log when paused or finished + audio.addEventListener('pause', () => logPlay()); + audio.addEventListener('ended', () => logPlay()); + + // Heartbeat every 30 seconds + setInterval(() => { + if (!audio.paused) logPlay(true); + }, 30000); + }); + + // --- Push Notifications Logic --- + const notifyBtn = document.getElementById('notify-btn'); + if (notifyBtn) { + if (Notification.permission === 'granted') { + notifyBtn.classList.add('active'); + notifyBtn.textContent = '🔔 Notifications On'; + } + + notifyBtn.addEventListener('click', async () => { + console.log('Notify button clicked'); + + if (!('Notification' in window)) { + alert('This browser does not support desktop notifications.'); + return; + } + + if (window.location.protocol !== 'https:' && window.location.hostname !== 'localhost') { + alert('Notifications require a secure connection (HTTPS). Please ensure your site has an SSL certificate.'); + return; + } + + try { + const permission = await Notification.requestPermission(); + console.log('Permission status:', permission); + + if (permission === 'granted') { + notifyBtn.classList.add('active'); + notifyBtn.textContent = '🔔 Notifications On'; + + new Notification('Notifications Enabled!', { + body: 'You will now receive updates from our podcast.', + icon: projectRoot + '/favicon.ico' + }); + + if ('serviceWorker' in navigator) { + navigator.serviceWorker.register(projectRoot + '/sw.js') + .then(async reg => { + console.log('SW Registered'); + try { + const subscription = await reg.pushManager.subscribe({ + userVisibleOnly: true, + // Note: In a real app, you'd generate VAPID keys. + // For now, we use a placeholder or skip if browser allows. + applicationServerKey: 'BM-YOUR-PUBLIC-VAPID-KEY-HERE' + }); + + await fetch(projectRoot + '/includes/subscribe_push.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(subscription) + }); + console.log('Subscription saved to server'); + } catch (e) { + console.warn('Push subscription failed:', e); + } + }) + .catch(err => console.error('SW Error:', err)); + } + } else if (permission === 'denied') { + alert('Notifications were denied. Please enable them in your browser settings if you wish to receive updates.'); + } + } catch (err) { + console.error('Error requesting notification permission:', err); + alert('An error occurred while requesting permission. Check the console for details.'); + } + }); + } +}); + +/** + * Copy text to clipboard with UI feedback + */ +function copyToClipboard(text, btnId) { + const btn = document.getElementById(btnId); + navigator.clipboard.writeText(text).then(() => { + if (btn) { + btn.classList.add('copy-success'); + const originalHtml = btn.innerHTML; + btn.innerHTML = ''; + + setTimeout(() => { + btn.classList.remove('copy-success'); + btn.innerHTML = originalHtml; + }, 2000); + } + }); +} diff --git a/feed.php b/feed.php new file mode 100644 index 0000000..5223b0b --- /dev/null +++ b/feed.php @@ -0,0 +1,64 @@ +query("SELECT * FROM episodes ORDER BY release_date DESC"); +$episodes = $stmt->fetchAll(); + +echo '' . PHP_EOL; +?> + + + <?php echo htmlspecialchars($site_title); ?> + / + + en-us + + + + + + admin@example.com + + + no + + + + + + + <?php echo htmlspecialchars($episode['title']); ?> + + + ]]> + + + + + no + + + + diff --git a/includes/db.php b/includes/db.php new file mode 100644 index 0000000..8b43a21 --- /dev/null +++ b/includes/db.php @@ -0,0 +1,38 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]; + + try { + $pdo = new PDO($dsn, $user, $pass, $options); + } catch (\PDOException $e) { + // If connection fails, maybe the config is wrong? + // For now just die with a friendly message + die("Database connection failed. Please check your config.php or run setup again."); + } +} +?> diff --git a/includes/footer.php b/includes/footer.php new file mode 100644 index 0000000..2022120 --- /dev/null +++ b/includes/footer.php @@ -0,0 +1,6 @@ + + + + diff --git a/includes/functions.php b/includes/functions.php new file mode 100644 index 0000000..881610c --- /dev/null +++ b/includes/functions.php @@ -0,0 +1,124 @@ +prepare("INSERT INTO activity_log (user_id, username, action, details, ip_address) VALUES (?, ?, ?, ?, ?)"); + $stmt->execute([$user_id, $username, $action, $details, $ip]); +} + +/** + * Get a setting value by key + */ +function getSetting($pdo, $key) { + $stmt = $pdo->prepare("SELECT value FROM settings WHERE `key` = ?"); + $stmt->execute([$key]); + return $stmt->fetchColumn(); +} + +/** + * Update a setting value + */ +function updateSetting($pdo, $key, $value) { + $stmt = $pdo->prepare("UPDATE settings SET value = ? WHERE `key` = ?"); + return $stmt->execute([$value, $key]); +} + +/** + * Handle audio file upload + */ +function uploadAudio($file) { + $targetDir = "../assets/uploads/audio/"; + $fileName = time() . '_' . basename($file["name"]); + $targetFilePath = $targetDir . $fileName; + $fileType = pathinfo($targetFilePath, PATHINFO_EXTENSION); + + // Allow certain file formats + $allowTypes = array('mp3', 'wav', 'm4a', 'ogg'); + if (in_array(strtolower($fileType), $allowTypes)) { + if (!is_dir($targetDir)) { + error_log("Upload failed: Directory $targetDir does not exist."); + return false; + } + if (!is_writable($targetDir)) { + error_log("Upload failed: Directory $targetDir is not writable."); + return false; + } + if (move_uploaded_file($file["tmp_name"], $targetFilePath)) { + return $fileName; + } else { + error_log("Upload failed: move_uploaded_file returned false. Tmp: " . $file["tmp_name"] . " Dest: " . $targetFilePath); + } + } else { + error_log("Upload failed: Invalid file type $fileType."); + } + return false; +} + +/** + * Handle image file upload + */ +function uploadImage($file) { + $targetDir = "../assets/uploads/images/"; + $fileName = time() . '_' . basename($file["name"]); + $targetFilePath = $targetDir . $fileName; + $fileType = pathinfo($targetFilePath, PATHINFO_EXTENSION); + + $allowTypes = array('jpg', 'png', 'jpeg', 'gif'); + if (in_array(strtolower($fileType), $allowTypes)) { + if (move_uploaded_file($file["tmp_name"], $targetFilePath)) { + return $fileName; + } + } + return false; +} + +/** + * Format date to a readable format + */ +function formatDate($date) { + return date("F j, Y", strtotime($date)); +} +?> diff --git a/includes/header.php b/includes/header.php new file mode 100644 index 0000000..be71739 --- /dev/null +++ b/includes/header.php @@ -0,0 +1,30 @@ + + + + + + + <?php echo htmlspecialchars($site_title); ?> + + + + + diff --git a/includes/subscribe_push.php b/includes/subscribe_push.php new file mode 100644 index 0000000..0c3ddfa --- /dev/null +++ b/includes/subscribe_push.php @@ -0,0 +1,34 @@ +prepare("SELECT id FROM subscriptions WHERE endpoint = ?"); + $stmt->execute([$endpoint]); + + if (!$stmt->fetch()) { + $stmt = $pdo->prepare("INSERT INTO subscriptions (endpoint, p256dh, auth) VALUES (?, ?, ?)"); + $stmt->execute([$endpoint, $p256dh, $auth]); + } + + echo json_encode(['status' => 'success']); + } catch (PDOException $e) { + echo json_encode(['status' => 'error', 'message' => 'Database error']); + } + } else { + echo json_encode(['status' => 'error', 'message' => 'Invalid subscription data']); + } +} else { + echo json_encode(['status' => 'error', 'message' => 'Invalid request method']); +} +?> diff --git a/includes/track_play.php b/includes/track_play.php new file mode 100644 index 0000000..4218d59 --- /dev/null +++ b/includes/track_play.php @@ -0,0 +1,40 @@ + 0) { + try { + // Check if this is a new session to log as "Listener Started" + $checkStmt = $pdo->prepare("SELECT COUNT(*) FROM plays WHERE session_id = ?"); + $checkStmt->execute([$session_id]); + $isNewSession = ($checkStmt->fetchColumn() == 0); + + $sql = "INSERT INTO plays (episode_id, duration, session_id) VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE duration = duration + VALUES(duration)"; + $stmt = $pdo->prepare($sql); + $stmt->execute([$episode_id, $duration, $session_id]); + + if ($isNewSession && $session_id) { + $titleStmt = $pdo->prepare("SELECT title FROM episodes WHERE id = ?"); + $titleStmt->execute([$episode_id]); + $title = $titleStmt->fetchColumn(); + logActivity(null, 'LISTENER_STARTED', "Episode: $title (Session: $session_id)"); + } + + file_put_contents('debug.log', date('[Y-m-d H:i:s] ') . "SUCCESS: Logged play for episode $episode_id ($duration s, session: $session_id)" . PHP_EOL, FILE_APPEND); + echo json_encode(['status' => 'success']); + } catch (PDOException $e) { + file_put_contents('debug.log', date('[Y-m-d H:i:s] ') . "DB ERROR: " . $e->getMessage() . PHP_EOL, FILE_APPEND); + echo json_encode(['status' => 'error', 'message' => 'Database error']); + } + } else { + file_put_contents('debug.log', date('[Y-m-d H:i:s] ') . "INVALID DATA: ID=$episode_id, DUR=$duration" . PHP_EOL, FILE_APPEND); + echo json_encode(['status' => 'error', 'message' => 'Invalid episode ID']); + } +} +?> diff --git a/index.php b/index.php new file mode 100644 index 0000000..1d61796 --- /dev/null +++ b/index.php @@ -0,0 +1,98 @@ +query($sql); +$episodes = $stmt->fetchAll(); + +$banner_url = PROJECT_ROOT_URL . "/assets/uploads/images/" . $banner_image; +if ($banner_image === 'default-banner.jpg') { + // Check if default exists, if not use a placeholder gradient + $banner_url = "https://images.unsplash.com/photo-1478737270239-2f02b77fc618?auto=format&fit=crop&q=80&w=1920&h=400"; +} +?> + +
+
+

+

Listen to our latest messages and sermons.

+
+ How to Subscribe + + + RSS Feed + +
+
+
+ +
+ +
+
+ +
+ <?php echo htmlspecialchars($episode['title']); ?> +
+ +
+
+ Released on • + listens +
+

+
+ +
+
+
+ +
+ +
+ +
+
+ + + Download Episode + +
+ + +
+
+ + + +
+

Welcome to our Podcast!

+

We haven't uploaded any episodes yet. Please check back soon!

+
+ +
+ + diff --git a/setup.php b/setup.php new file mode 100644 index 0000000..32f4ceb --- /dev/null +++ b/setup.php @@ -0,0 +1,226 @@ + $isWritable, + 'message' => $isWritable ? 'Writable' : 'Not Writable' + ]; +} + +// 2. Check PHP limits +$uploadMax = ini_get('upload_max_filesize'); +$postMax = ini_get('post_max_size'); +$systemChecks['php'] = [ + 'upload_max' => $uploadMax, + 'post_max' => $postMax +]; + +if (file_exists('includes/config.php')) { + header("Location: index.php"); + exit; +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $host = $_POST['db_host'] ?? 'localhost'; + $db_name = $_POST['db_name'] ?? 'church_podcast'; + $db_user = $_POST['db_user'] ?? 'root'; + $db_pass = $_POST['db_pass'] ?? ''; + + $admin_user = $_POST['admin_user'] ?? 'admin'; + $admin_pass = $_POST['admin_pass'] ?? ''; + + try { + // 1. Try to connect to MySQL (without selecting DB first) + $dsn = "mysql:host=$host;charset=utf8mb4"; + $pdo = new PDO($dsn, $db_user, $db_pass, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); + + // 2. Create Database + $pdo->exec("CREATE DATABASE IF NOT EXISTS `$db_name` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); + $pdo->exec("USE `$db_name` "); + + // 3. Run Schema + $schema = file_get_contents('sql/schema.sql'); + $schema = preg_replace('/CREATE DATABASE IF NOT EXISTS .*?;/i', '', $schema); + $schema = preg_replace('/USE .*?;/i', '', $schema); + $pdo->exec($schema); + + // 4. Create first Admin + $hashed_pass = password_hash($admin_pass, PASSWORD_DEFAULT); + $stmt = $pdo->prepare("INSERT INTO admins (username, password, role) VALUES (?, ?, 'admin')"); + $stmt->execute([$admin_user, $hashed_pass]); + + // 5. Save Config + $configContent = ""; + if (file_put_contents('includes/config.php', $configContent) === false) { + throw new Exception("Could not write config.php. Please check folder permissions for 'includes/' directory."); + } + + $success = true; + } catch (Exception $e) { + $msg = $e->getMessage(); + if (strpos($msg, 'Access denied') !== false) { + $error = "Access Denied: Your database user doesn't have permission.

+ If you are using 'root' on MySQL 8.0, this is normal. Please run these commands in your terminal:
+ + sudo mysql
+ CREATE DATABASE IF NOT EXISTS $db_name;
+ CREATE USER 'podcast_user'@'localhost' IDENTIFIED WITH mysql_native_password BY 'your_password';
+ GRANT ALL PRIVILEGES ON $db_name.* TO 'podcast_user'@'localhost';
+ FLUSH PRIVILEGES; +

+ Then use 'podcast_user' and your password in the form below."; + } else { + $error = "Setup failed: " . $msg; + } + } +} +?> + + + + + + Setup - Podcast Server + + + + +
+

🚀 Welcome to Podcast Server

+ + +
+

Setup Complete!

+

The configuration file has been created and the database initialized.

+ Go to Login +
+ +
+ +
+

Environment Check

+ +
+

Permissions

+ $data): + if (!$data['status']) $hasPermIssue = true; + ?> +
+ + + + +
+ + + +
+ Fix Permissions:
+ Run this command in your Ubuntu terminal:
+ sudo chown -R www-data:www-data +
+ +
+ +
+

PHP Settings

+
+ Max Upload + +
+
+ Post Max Data + +
+ + +
+ Increase Limits:
+ Edit /etc/php/8.0/apache2/php.ini and set:
+ upload_max_filesize = 100M
+ post_max_size = 110M
+ Then: sudo systemctl restart apache2 +
+ +
+
+ + +
+ +
+ +
+ + +
+
+
+

Database Configuration

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+

Admin User

+
+ + +
+
+ + +
+
+
+ +
+
+
+ +
+ + diff --git a/sql/schema.sql b/sql/schema.sql new file mode 100644 index 0000000..248b036 --- /dev/null +++ b/sql/schema.sql @@ -0,0 +1,68 @@ +-- Database schema for Podcast Hosting Server + +CREATE DATABASE IF NOT EXISTS church_podcast; +USE church_podcast; + +-- Episodes table +CREATE TABLE IF NOT EXISTS episodes ( + id INT AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(255) NOT NULL, + description TEXT, + audio_file VARCHAR(255) NOT NULL, + cover_image VARCHAR(255), + release_date DATE NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Settings table +CREATE TABLE IF NOT EXISTS settings ( + `key` VARCHAR(50) PRIMARY KEY, + `value` TEXT +); + +-- Admins table +CREATE TABLE IF NOT EXISTS admins ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(50) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + role ENUM('admin', 'editor') DEFAULT 'editor', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Plays table (Analytics) +CREATE TABLE IF NOT EXISTS plays ( + id INT AUTO_INCREMENT PRIMARY KEY, + episode_id INT NOT NULL, + duration INT DEFAULT 0, -- seconds listened + session_id VARCHAR(100) UNIQUE, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (episode_id) REFERENCES episodes(id) ON DELETE CASCADE +); + +-- Default settings +INSERT IGNORE INTO settings (`key`, `value`) VALUES +('site_title', 'Our Church Podcast'), +('banner_image', 'default-banner.jpg'), +('footer_copyright', 'Our Church'), +('footer_powered_by', 'Antigravity Podcast Server'); + +-- Subscriptions table (Push Notifications) +CREATE TABLE IF NOT EXISTS subscriptions ( + id INT AUTO_INCREMENT PRIMARY KEY, + endpoint TEXT NOT NULL, + p256dh VARCHAR(255) NOT NULL, + auth VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Activity Log table +CREATE TABLE IF NOT EXISTS activity_log ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT, + username VARCHAR(50), + action VARCHAR(255) NOT NULL, + details TEXT, + ip_address VARCHAR(45), + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + diff --git a/subscribe.php b/subscribe.php new file mode 100644 index 0000000..c9107c3 --- /dev/null +++ b/subscribe.php @@ -0,0 +1,64 @@ + + +
+
+

How to Subscribe

+

Take our podcast with you wherever you go.

+
+
+ +
+
+

Choose Your Favorite App

+ +
+
+

Apple Podcasts

+

Open the Apple Podcasts app, search for "", and tap "Follow".

+
+ +
+

Spotify

+

Search for "" on Spotify and hit the "Follow" button to stay updated.

+
+ +
+

Google Podcasts / YouTube Music

+

Find us on YouTube Music or Google Podcasts by searching for our show title.

+
+ +
+

Other Apps (RSS Feed)

+

If you use another app like Overcast or Pocket Casts, you can manually add our RSS feed:

+
+ https:///feed.php + +
+
+
+
+ + + +
+ ← Back to Episodes +
+
+ + diff --git a/sw.js b/sw.js new file mode 100644 index 0000000..bcdb751 --- /dev/null +++ b/sw.js @@ -0,0 +1,24 @@ +// Service Worker for Push Notifications +self.addEventListener('push', (event) => { + const data = event.data ? event.data.json() : { title: 'New Episode!', body: 'Check out the latest podcast episode.' }; + + const options = { + body: data.body, + icon: '/assets/uploads/images/icon.png', // Fallback icon + badge: '/assets/uploads/images/badge.png', + data: { + url: data.url || '/' + } + }; + + event.waitUntil( + self.registration.showNotification(data.title, options) + ); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + event.waitUntil( + clients.openWindow(event.notification.data.url) + ); +});