From 05175ac03bcfc726ae4e1f9e08a7c0fe0d192bd7 Mon Sep 17 00:00:00 2001 From: Michael Howard Date: Wed, 29 Apr 2026 22:19:01 -0500 Subject: [PATCH] ChurchTube v3.1: Admin Logs, User Avatars, Timestamped Bookmarks, and Mobile Fixes --- README.md | 16 ++- admin/edit_video.php | 1 + admin/index.php | 5 + admin/logs.php | 80 +++++++++++++ api/delete_comment.php | 21 +++- api/get_comments.php | 2 +- api/increment_views.php | 7 ++ api/post_comment.php | 5 + api/toggle_bookmark.php | 22 ++++ api/toggle_theme.php | 11 ++ diagnostics.php | 87 -------------- includes/db.php | 10 ++ includes/functions.php | 22 ++++ includes/header.php | 93 ++++++++++++--- includes/settings_helper.php | 18 +-- index.php | 65 ++++++++--- install.php | 20 ++++ login.php | 2 + profile.php | 218 +++++++++++++++++++++++++++++++++++ watch.php | 155 ++++++++++++++++--------- 20 files changed, 668 insertions(+), 192 deletions(-) create mode 100644 admin/logs.php create mode 100644 api/toggle_bookmark.php create mode 100644 api/toggle_theme.php delete mode 100644 diagnostics.php create mode 100644 includes/functions.php create mode 100644 profile.php diff --git a/README.md b/README.md index 0e2d7ef..9151f8a 100644 --- a/README.md +++ b/README.md @@ -6,19 +6,23 @@ ChurchTube is a premium, self-hosted video platform designed specifically for ch ## ✨ Features -- **Premium UX**: Modern, responsive design with glassmorphism and smooth animations. +- **Premium UX**: Modern, responsive design with forced **Dark Mode** and glassmorphism. - **Dual Video Sources**: Upload videos directly or link them from external sources (NAS, Google Drive, Cloud). -- **Smart Google Drive Support**: Automatically converts Google Drive links for seamless embedding. +- **Smart Google Drive Support**: Automatically converts Google Drive links for seamless embedding and mobile-friendly controls. +- **User Identity**: + - Custom **User Avatars** for a more personal community experience. + - Profile management for passwords and identity. +- **Timestamped Bookmarks**: Save the exact second of a sermon and jump back to it later from your profile. - **Interactive Community**: - AJAX-based commenting (no video reloads). - 5 reaction types (👍, ❤️, 🙏, 💡, 👏). - Automated Profanity Filter with auto-reporting. -- **Robust Moderation**: + - Users can delete their own comments. +- **Administrative Accountability**: + - **System Logs**: Track logins, failed attempts, video plays, and comment history with IP address auditing. - Role-Based Access Control (Admin, Moderator, Editor, User). - - Dedicated Admin/Moderator dashboard for reports and users. - **Custom Branding**: Real-time control over site title, colors, logo, and footer. -- **Search & Discovery**: Tag-based categorization, search, and intelligent recommendations. -- **Analytics**: Engagement-based view counting (only counts when video is played). +- **Search & Discovery**: Keyword search, intelligent recommendations, and pagination. ## 🛠️ Technology Stack diff --git a/admin/edit_video.php b/admin/edit_video.php index 6d19337..903fa42 100644 --- a/admin/edit_video.php +++ b/admin/edit_video.php @@ -85,6 +85,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $pdo->prepare("INSERT IGNORE INTO video_tags (video_id, tag_id) VALUES (?, ?)")->execute([$id, $tag_id]); } } + cleanupTags(); $success = "Video updated successfully!"; // Refresh video data $stmt = $pdo->prepare("SELECT * FROM videos WHERE id = ?"); diff --git a/admin/index.php b/admin/index.php index af489e5..4c4dc5b 100644 --- a/admin/index.php +++ b/admin/index.php @@ -15,6 +15,7 @@ if (isset($_GET['delete'])) { } $pdo->prepare("DELETE FROM videos WHERE id = ?")->execute([$id]); + cleanupTags(); header('Location: index.php?msg=deleted'); exit; } @@ -51,6 +52,10 @@ echo str_replace(['assets/', 'index.php', 'login.php', 'logout.php', 'admin/'], ! + + + System Logs +
diff --git a/admin/logs.php b/admin/logs.php new file mode 100644 index 0000000..838ceed --- /dev/null +++ b/admin/logs.php @@ -0,0 +1,80 @@ +prepare($query); +$stmt->execute($params); +$logs = $stmt->fetchAll(); + +ob_start(); +require_once '../includes/header.php'; +$header = ob_get_clean(); +echo str_replace(['assets/', 'index.php', 'login.php', 'logout.php', 'admin/'], ['../assets/', '../index.php', '../login.php', '../logout.php', './'], $header); +?> + +
+
+

System Logs

+
+ + Back to Dashboard +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
TimeTypeUserMessageIP Address
+ + + +
No logs found.
+
+
+ + diff --git a/api/delete_comment.php b/api/delete_comment.php index 66340ac..838e953 100644 --- a/api/delete_comment.php +++ b/api/delete_comment.php @@ -4,20 +4,35 @@ require_once '../includes/auth.php'; header('Content-Type: application/json'); -if (!isModerator()) { - echo json_encode(['success' => false, 'error' => 'Moderator privileges required']); +if (!isLoggedIn()) { + echo json_encode(['success' => false, 'error' => 'Login required']); exit; } $comment_id = (int)($_POST['comment_id'] ?? 0); - if (!$comment_id) { echo json_encode(['success' => false, 'error' => 'Invalid data']); exit; } try { + // Check ownership or moderator status + $stmt = $pdo->prepare("SELECT user_id FROM comments WHERE id = ?"); + $stmt->execute([$comment_id]); + $comment = $stmt->fetch(); + + if (!$comment) { + echo json_encode(['success' => false, 'error' => 'Comment not found']); + exit; + } + + if ($comment['user_id'] != $_SESSION['user_id'] && !isModerator()) { + echo json_encode(['success' => false, 'error' => 'Unauthorized']); + exit; + } + $pdo->prepare("DELETE FROM comments WHERE id = ?")->execute([$comment_id]); + logEvent('comment', "Comment deleted: ID $comment_id by user " . $_SESSION['username']); echo json_encode(['success' => true]); } catch (Exception $e) { echo json_encode(['success' => false, 'error' => 'DB error']); diff --git a/api/get_comments.php b/api/get_comments.php index 76d19d3..252f797 100644 --- a/api/get_comments.php +++ b/api/get_comments.php @@ -12,7 +12,7 @@ if (!$video_id) { try { $stmt = $pdo->prepare(" - SELECT c.*, u.username, + SELECT c.*, u.username, u.avatar_url, (SELECT COUNT(*) FROM reactions WHERE comment_id = c.id AND reaction_type = 'thumb') as thumbs, (SELECT COUNT(*) FROM reactions WHERE comment_id = c.id AND reaction_type = 'heart') as hearts, (SELECT COUNT(*) FROM reactions WHERE comment_id = c.id AND reaction_type = 'pray') as prays, diff --git a/api/increment_views.php b/api/increment_views.php index a63c6a8..d771ca4 100644 --- a/api/increment_views.php +++ b/api/increment_views.php @@ -13,6 +13,13 @@ if (!$video_id) { try { $stmt = $pdo->prepare("UPDATE videos SET views = views + 1 WHERE id = ?"); $stmt->execute([$video_id]); + + // Log the play event + $v_stmt = $pdo->prepare("SELECT title FROM videos WHERE id = ?"); + $v_stmt->execute([$video_id]); + $title = $v_stmt->fetchColumn(); + logEvent('play', "Started watching: $title (ID: $video_id)"); + echo json_encode(['success' => true]); } catch (Exception $e) { echo json_encode(['success' => false, 'error' => 'Database error']); diff --git a/api/post_comment.php b/api/post_comment.php index 686a3d9..81a9e9c 100644 --- a/api/post_comment.php +++ b/api/post_comment.php @@ -33,6 +33,11 @@ foreach ($bad_words as $word) { try { $stmt = $pdo->prepare("INSERT INTO comments (video_id, user_id, comment_text, is_reported) VALUES (?, ?, ?, ?)"); if ($stmt->execute([$video_id, $_SESSION['user_id'], $filtered_text, $is_flagged ? 1 : 0])) { + // Log the comment + $v_stmt = $pdo->prepare("SELECT title FROM videos WHERE id = ?"); + $v_stmt->execute([$video_id]); + $title = $v_stmt->fetchColumn(); + logEvent('comment', "Commented on $title: $filtered_text" . ($is_flagged ? " [FLAGGED]" : "")); echo json_encode(['success' => true]); } else { echo json_encode(['success' => false, 'error' => 'Database error']); diff --git a/api/toggle_bookmark.php b/api/toggle_bookmark.php new file mode 100644 index 0000000..e6052ea --- /dev/null +++ b/api/toggle_bookmark.php @@ -0,0 +1,22 @@ +prepare("SELECT id FROM bookmarks WHERE user_id = ? AND video_id = ?"); + $stmt->execute([$user_id, $video_id]); + $bookmark = $stmt->fetch(); + + if ($bookmark) { + $pdo->prepare("DELETE FROM bookmarks WHERE id = ?")->execute([$bookmark['id']]); + echo json_encode(['success' => true, 'action' => 'removed']); + } else { + $pdo->prepare("INSERT INTO bookmarks (user_id, video_id, video_timestamp) VALUES (?, ?, ?)")->execute([$user_id, $video_id, $timestamp]); + echo json_encode(['success' => true, 'action' => 'added']); + } +} +?> diff --git a/api/toggle_theme.php b/api/toggle_theme.php new file mode 100644 index 0000000..c1c3daf --- /dev/null +++ b/api/toggle_theme.php @@ -0,0 +1,11 @@ +prepare("UPDATE users SET theme_preference = ? WHERE id = ?"); + $stmt->execute([$theme, $_SESSION['user_id']]); + echo json_encode(['success' => true]); +} +?> diff --git a/diagnostics.php b/diagnostics.php deleted file mode 100644 index 23cc557..0000000 --- a/diagnostics.php +++ /dev/null @@ -1,87 +0,0 @@ -[PASS] $success"; - } else { - echo "
  • [FAIL] $failure
  • "; - return false; - } - return true; -} - -?> - - - - - ChurchTube Diagnostics - - - - -
    -

    ChurchTube Diagnostics

    -
    -

    System Health Check

    -
      - ='), - "PHP Version: " . PHP_VERSION, - "PHP Version: " . PHP_VERSION . " (Requires 7.4+)"); - - // Extensions - check_status(extension_loaded('pdo_mysql'), "PDO MySQL extension is loaded.", "PDO MySQL extension is MISSING."); - check_status(extension_loaded('curl'), "CURL extension is loaded.", "CURL extension is MISSING (required for external link validation)."); - - // Config File - $has_config = file_exists('includes/config.php'); - if (check_status($has_config, "Configuration file exists.", "Configuration file (includes/config.php) is MISSING.")) { - require_once 'includes/config.php'; - // DB Connection - try { - $pdo = new PDO("mysql:host=".DB_HOST.";dbname=".DB_NAME, DB_USER, DB_PASS); - check_status(true, "Database connection established successfully.", ""); - } catch (Exception $e) { - check_status(false, "", "Database connection failed: " . $e->getMessage()); - } - } - - // Write Permissions - check_status(is_writable('uploads'), "Uploads directory is writable.", "Uploads directory is NOT writable. Run: chmod 777 uploads"); - - // PHP Settings - $upload_max = ini_get('upload_max_filesize'); - $post_max = ini_get('post_max_size'); - $is_low = (int)$upload_max < 100 || (int)$post_max < 100; - - if ($is_low) { - echo "
    • [WARN] Max Upload Size: $upload_max (Post Max: $post_max) - This is low for videos!
    • "; - } else { - echo "
    • [PASS] Max Upload Size: $upload_max (Post Max: $post_max)
    • "; - } - ?> -
    - -
    -

    Pro-Tips & Fixes:

    -
      -
    • MySQL Issues? Run: sudo systemctl status mysql. If stopped, run sudo systemctl start mysql.
    • -
    • Upload Limits? Edit /etc/php/7.4/apache2/php.ini. Look for upload_max_filesize and post_max_size. Set them to 500M or more, then run sudo systemctl restart apache2.
    • -
    • Permission Issues? Run: sudo chmod -R 777 uploads/ to ensure the web server can save video files.
    • -
    -
    -
    -
    - Back to Site -
    -
    - - diff --git a/includes/db.php b/includes/db.php index 8ef75ea..a3fb709 100644 --- a/includes/db.php +++ b/includes/db.php @@ -5,6 +5,16 @@ try { $pdo = new PDO("mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=utf8mb4", DB_USER, DB_PASS); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + require_once 'functions.php'; + + function cleanupTags() { + global $pdo; + try { + $pdo->exec("DELETE FROM tags WHERE id NOT IN (SELECT tag_id FROM video_tags)"); + } catch (Exception $e) { + // Fail silently + } + } } catch (PDOException $e) { die("Database Connection Error: " . $e->getMessage()); } diff --git a/includes/functions.php b/includes/functions.php new file mode 100644 index 0000000..8648a89 --- /dev/null +++ b/includes/functions.php @@ -0,0 +1,22 @@ +prepare("INSERT INTO logs (user_id, type, message, ip_address) VALUES (?, ?, ?, ?)"); + $stmt->execute([$user_id, $type, $message, $ip]); + } catch (Exception $e) { + // Fail silently + } +} +?> diff --git a/includes/header.php b/includes/header.php index 15c0e99..575ebe9 100644 --- a/includes/header.php +++ b/includes/header.php @@ -19,23 +19,74 @@ $logo_url = get_setting('logo_url', ''); - + +
    -
    +
    diff --git a/includes/settings_helper.php b/includes/settings_helper.php index 7db6f72..d2cb3a9 100644 --- a/includes/settings_helper.php +++ b/includes/settings_helper.php @@ -1,13 +1,17 @@ prepare("SELECT setting_value FROM settings WHERE setting_key = ?"); - $stmt->execute([$key]); - $res = $stmt->fetch(); - return $res ? $res['setting_value'] : $default; - } catch (Exception $e) { - return $default; + static $settings_cache = null; + + if ($settings_cache === null) { + try { + $stmt = $pdo->query("SELECT setting_key, setting_value FROM settings"); + $settings_cache = $stmt->fetchAll(PDO::FETCH_KEY_PAIR); + } catch (Exception $e) { + $settings_cache = []; + } } + + return isset($settings_cache[$key]) ? $settings_cache[$key] : $default; } ?> diff --git a/index.php b/index.php index 935c6ff..bf55d58 100644 --- a/index.php +++ b/index.php @@ -8,24 +8,43 @@ require_once 'includes/db.php'; require_once 'includes/settings_helper.php'; require_once 'includes/header.php'; -$search = $_GET['q'] ?? ''; -$tag_filter = $_GET['tag'] ?? ''; +$page = isset($_GET['p']) ? (int)$_GET['p'] : 1; +$limit = 10; +$offset = ($page - 1) * $limit; $query = "SELECT DISTINCT v.*, u.username as uploader FROM videos v - JOIN users u ON v.uploader_id = u.id + LEFT JOIN users u ON v.uploader_id = u.id LEFT JOIN video_tags vt ON v.id = vt.video_id LEFT JOIN tags t ON vt.tag_id = t.id"; +$search = $_GET['q'] ?? ''; +$tag_filter = $_GET['tag'] ?? ''; $params = []; +$where_clauses = []; if ($search) { - $query .= " WHERE (v.title LIKE ? OR v.description LIKE ? OR t.name LIKE ?)"; - $params = ["%$search%", "%$search%", "%$search%"]; -} elseif ($tag_filter) { - $query .= " WHERE t.name = ?"; - $params = [$tag_filter]; + $where_clauses[] = "(v.title LIKE ? OR v.description LIKE ? OR t.name LIKE ?)"; + $params = array_merge($params, ["%$search%", "%$search%", "%$search%"]); +} +if ($tag_filter) { + $where_clauses[] = "t.name = ?"; + $params[] = $tag_filter; } -$query .= " ORDER BY v.release_date DESC, v.created_at DESC"; +if (!empty($where_clauses)) { + $query .= " WHERE " . implode(" AND ", $where_clauses); +} + +// Count total for pagination +$count_query = "SELECT COUNT(DISTINCT v.id) FROM videos v " . + "LEFT JOIN video_tags vt ON v.id = vt.video_id " . + "LEFT JOIN tags t ON vt.tag_id = t.id " . + (!empty($where_clauses) ? " WHERE " . implode(" AND ", $where_clauses) : ""); +$total_stmt = $pdo->prepare($count_query); +$total_stmt->execute($params); +$total_count = $total_stmt->fetchColumn(); +$total_pages = ceil($total_count / $limit); + +$query .= " ORDER BY v.release_date DESC, v.created_at DESC LIMIT $limit OFFSET $offset"; $stmt = $pdo->prepare($query); $stmt->execute($params); $videos = $stmt->fetchAll(); @@ -34,13 +53,6 @@ $videos = $stmt->fetchAll(); $popular_tags = $pdo->query("SELECT name FROM tags LIMIT 10")->fetchAll(PDO::FETCH_COLUMN); ?> -
    - All - - - -
    -
    @@ -54,12 +66,15 @@ $popular_tags = $pdo->query("SELECT name FROM tags LIMIT 10")->fetchAll(PDO::FET
    -

    +
    + +
    • + views
    @@ -68,4 +83,20 @@ $popular_tags = $pdo->query("SELECT name FROM tags LIMIT 10")->fetchAll(PDO::FET
    + 1): ?> +
    + 1): ?> + Previous + + + + + + + + Next + +
    + + diff --git a/install.php b/install.php index 3c3445c..f6f3618 100644 --- a/install.php +++ b/install.php @@ -33,6 +33,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { password VARCHAR(255) NOT NULL, email VARCHAR(100) NOT NULL UNIQUE, role ENUM('admin', 'moderator', 'editor', 'user') DEFAULT 'user', + avatar_url TEXT, + theme_preference ENUM('dark', 'light') DEFAULT 'dark', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS videos ( @@ -78,6 +80,24 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { FOREIGN KEY (video_id) REFERENCES videos(id) ON DELETE CASCADE, FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE ); + CREATE TABLE IF NOT EXISTS bookmarks ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + video_id INT NOT NULL, + video_timestamp FLOAT DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (video_id) REFERENCES videos(id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS logs ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NULL, + type VARCHAR(50), + message TEXT, + ip_address VARCHAR(45), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL + ); CREATE TABLE IF NOT EXISTS settings ( setting_key VARCHAR(50) PRIMARY KEY, setting_value TEXT diff --git a/login.php b/login.php index 31514bf..b94586f 100644 --- a/login.php +++ b/login.php @@ -21,9 +21,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $_SESSION['user_id'] = $user['id']; $_SESSION['username'] = $user['username']; $_SESSION['user_role'] = $user['role']; + logEvent('auth', "User logged in: $username"); header('Location: index.php'); exit; } else { + logEvent('auth', "FAILED login attempt for username: $username"); $error = "Invalid username or password."; } } diff --git a/profile.php b/profile.php new file mode 100644 index 0000000..b25a8e3 --- /dev/null +++ b/profile.php @@ -0,0 +1,218 @@ +prepare("SELECT password FROM users WHERE id = ?"); + $stmt->execute([$user_id]); + $user = $stmt->fetch(); + + if (password_verify($old_pass, $user['password'])) { + if ($new_pass === $confirm_pass) { + if (strlen($new_pass) >= 6) { + $hashed = password_hash($new_pass, PASSWORD_DEFAULT); + $pdo->prepare("UPDATE users SET password = ? WHERE id = ?")->execute([$hashed, $user_id]); + $success = "Password changed successfully!"; + } else { + $error = "New password must be at least 6 characters."; + } + } else { + $error = "New passwords do not match."; + } + } else { + $error = "Incorrect current password."; + } + } + + if (isset($_POST['update_avatar'])) { + if (isset($_FILES['avatar']) && $_FILES['avatar']['error'] === 0) { + $ext = strtolower(pathinfo($_FILES['avatar']['name'], PATHINFO_EXTENSION)); + $allowed = ['jpg', 'jpeg', 'png', 'webp']; + if (in_array($ext, $allowed)) { + $filename = 'avatar_' . $user_id . '_' . time() . '.' . $ext; + if (move_uploaded_file($_FILES['avatar']['tmp_name'], 'uploads/' . $filename)) { + // Delete old avatar if exists + $stmt = $pdo->prepare("SELECT avatar_url FROM users WHERE id = ?"); + $stmt->execute([$user_id]); + $old = $stmt->fetchColumn(); + if ($old && strpos($old, 'uploads/') === 0) @unlink($old); + + $avatar_url = 'uploads/' . $filename; + $pdo->prepare("UPDATE users SET avatar_url = ? WHERE id = ?")->execute([$avatar_url, $user_id]); + $success = "Avatar updated!"; + } + } else { + $error = "Invalid image format."; + } + } + } +} + +// Get user data +$stmt = $pdo->prepare("SELECT * FROM users WHERE id = ?"); +$stmt->execute([$user_id]); +$user_data = $stmt->fetch(); +$avatar = $user_data['avatar_url'] ?: ''; + +// Get bookmarks +$stmt = $pdo->prepare("SELECT v.* FROM videos v JOIN bookmarks b ON v.id = b.video_id WHERE b.user_id = ? ORDER BY b.created_at DESC"); +$stmt->execute([$user_id]); +$bookmarks = $stmt->fetchAll(); + +require_once 'includes/header.php'; +?> + +
    + +
    +
    +
    +
    + + + + + +
    +

    +

    Member since

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

    My Bookmarks

    + prepare("SELECT v.*, b.video_timestamp FROM videos v JOIN bookmarks b ON v.id = b.video_id WHERE b.user_id = ? ORDER BY b.created_at DESC"); + $stmt->execute([$user_id]); + $bookmarks = $stmt->fetchAll(); + + function formatTime($seconds) { + if ($seconds <= 0) return ""; + $mins = floor($seconds / 60); + $secs = floor($seconds % 60); + return sprintf("%d:%02d", $mins, $secs); + } + ?> + +
    + +

    You haven't bookmarked any sermons yet.

    +
    + + + +
    + + + + +
    +

    Security

    +
    +

    Change Password

    +

    Keep your account secure by using a strong password.

    + + +
    + +
    + + + +
    + +
    + + +
    + +
    + + +
    +
    + + +
    +
    + + +
    + +
    +
    +
    +
    +
    + + diff --git a/watch.php b/watch.php index 360abb4..ed04132 100644 --- a/watch.php +++ b/watch.php @@ -63,36 +63,24 @@ require_once 'includes/header.php';
    -
    +
    - +
    + +
    -
    -
    +
    +
    - @@ -108,36 +96,40 @@ require_once 'includes/header.php'; views •
    - - - - + + prepare("SELECT 1 FROM bookmarks WHERE user_id = ? AND video_id = ?"); + $stmt->execute([$_SESSION['user_id'], $video_id]); + $is_bookmarked = $stmt->fetch(); + ?> + +
    -
    - + prepare("SELECT avatar_url FROM users WHERE id = ?"); + $u_stmt->execute([$video['uploader_id']]); + $uploader_avatar = $u_stmt->fetchColumn(); + ?> +
    + + + + +
    -
    +
    @@ -155,8 +147,12 @@ require_once 'includes/header.php';
    -
    - +
    + + + + +
    @@ -175,7 +171,7 @@ require_once 'includes/header.php'; REPORT - +
    @@ -208,6 +204,8 @@ require_once 'includes/header.php';