// includes/functions.php // This file contains reusable helper functions for the entire application. // --- CONFIGURATION: Set Global Timezone to Asia/Kolkata (IST) --- date_default_timezone_set('Asia/Kolkata'); /** * Starts a secure PHP session with long-term persistence (6 Months). * This should be called at the very top of any page that needs sessions. */ function start_secure_session() { // 1. Define the custom path to your 'sessions' folder $session_save_path = realpath(__DIR__ . '/../sessions'); // Only set the path if the folder actually exists to avoid errors if ($session_save_path && is_dir($session_save_path)) { session_save_path($session_save_path); } // 2. Set Lifetime to 6 Months (approx 15 million seconds) $lifetime = 15552000; // SERVER-SIDE: Tell PHP not to delete the session data for 6 months ini_set('session.gc_maxlifetime', $lifetime); // CLIENT-SIDE: Set session cookie parameters for long-term storage $cookieParams = [ 'lifetime' => $lifetime, 'path' => '/', 'domain' => '', // Your domain 'secure' => isset($_SERVER['HTTPS']), // True if using HTTPS 'httponly' => true, // Prevents JavaScript from accessing the cookie 'samesite' => 'Lax' // 'Lax' is better for persistent login than 'Strict' ]; session_set_cookie_params($cookieParams); // Start the session if (session_status() == PHP_SESSION_NONE) { session_start(); } // CRITICAL: Refresh the cookie on every page load to keep the session alive if (isset($_COOKIE[session_name()])) { setcookie( session_name(), session_id(), time() + $lifetime, '/', '', isset($_SERVER['HTTPS']), true ); } } /** * Safely escapes HTML output to prevent XSS attacks. */ function escape_html($string) { if ($string === null) { return ''; } return htmlspecialchars($string, ENT_QUOTES, 'UTF-8'); } /** * Retrieves the client's IP address safely. */ function get_client_ip() { if (!empty($_SERVER['HTTP_CLIENT_IP'])) { $ip = $_SERVER['HTTP_CLIENT_IP']; } elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { $ip = $_SERVER['HTTP_X_FORWARDED_FOR']; if (strpos($ip, ',') !== false) { $ip = trim(explode(',', $ip)[0]); } } else { $ip = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'; } if (filter_var($ip, FILTER_VALIDATE_IP)) { return $ip; } return '127.0.0.1'; } /** * Redirects the user to a different page. */ function redirect($url) { header("Location: $url"); exit; } /** * Checks if a user (student) is logged in. */ function is_student_logged_in() { return isset($_SESSION['user_id']); } /** * Checks if an admin is logged in. */ function is_admin_logged_in() { return isset($_SESSION['admin_id']); } /** * Checks if a user has a password hash set. */ function is_password_set($user_details) { return !empty($user_details['password_hash']) && !is_null($user_details['password_hash']); } /** * Formats a database timestamp. */ function format_date($date_string) { if (empty($date_string)) return 'N/A'; try { $date = new DateTime($date_string); return $date->format('M d, Y'); } catch (Exception $e) { return 'Invalid Date'; } } /** * Retrieves a setting value from the database. */ function get_setting($db, $key_name, $default_value = '') { if (!isset($db) || !($db instanceof PDO)) { error_log("CRITICAL ERROR: Attempted to call get_setting() before \$db was initialized."); return $default_value; } try { $stmt = $db->prepare("SELECT key_value FROM settings WHERE key_name = ?"); $stmt->execute([$key_name]); $result = $stmt->fetch(); return $result ? $result['key_value'] : $default_value; } catch (PDOException $e) { error_log("Database error fetching setting {$key_name}: " . $e->getMessage()); return $default_value; } } /** * Fetches and renders the universal header HTML. */ function render_universal_header($db) { include 'header.php'; } /** * Returns the OneSignal server configuration. * The App ID is public; the REST/App API key is used only by PHP on the server. */ function get_onesignal_config($db) { if (!isset($db) || !($db instanceof PDO)) { return [ 'app_id' => '', 'rest_api_key' => '', 'configured' => false ]; } $app_id = trim((string) getenv('ONESIGNAL_APP_ID')); $rest_api_key = trim((string) getenv('ONESIGNAL_REST_API_KEY')); if ($app_id === '') { $app_id = trim((string) get_setting($db, 'onesignal_app_id', '')); } if ($rest_api_key === '') { $rest_api_key = trim((string) get_setting($db, 'onesignal_rest_api_key', '')); } $configured = ($app_id !== '' && $rest_api_key !== '' && $app_id !== 'YOUR_ONESIGNAL_APP_ID' && $rest_api_key !== 'YOUR_ONESIGNAL_REST_API_KEY'); return [ 'app_id' => $app_id, 'rest_api_key' => $rest_api_key, 'configured' => $configured ]; } /** * Makes a small JSON request to the OneSignal REST API. */ function onesignal_api_request($method, $url, $api_key, $payload = null) { if (!function_exists('curl_init')) { return [ 'ok' => false, 'http_code' => 0, 'json' => null, 'raw' => '', 'error' => 'PHP cURL extension is not available.' ]; } $ch = curl_init($url); $headers = [ 'Content-Type: application/json; charset=utf-8', 'Authorization: Key ' . $api_key ]; $options = [ CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true, CURLOPT_HEADER => false, CURLOPT_CUSTOMREQUEST => strtoupper($method), CURLOPT_TIMEOUT => 25, CURLOPT_CONNECTTIMEOUT => 10 ]; if ($payload !== null) { $options[CURLOPT_POSTFIELDS] = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); } curl_setopt_array($ch, $options); $raw = curl_exec($ch); $curl_error = curl_error($ch); $http_code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $json = is_string($raw) ? json_decode($raw, true) : null; return [ 'ok' => ($raw !== false && $curl_error === '' && $http_code >= 200 && $http_code < 300), 'http_code' => $http_code, 'json' => is_array($json) ? $json : null, 'raw' => ($raw === false ? '' : (string) $raw), 'error' => $curl_error ]; } /** * Finds active Web Push subscription IDs belonging to a Quiz360 External ID. * This is used for exact subscription targeting so an accepted API request * cannot be mistaken for a delivered browser notification. */ function get_onesignal_push_subscription_ids($db, $external_id) { $config = get_onesignal_config($db); $external_id = trim((string) $external_id); if (!$config['configured'] || $external_id === '') { return [ 'success' => false, 'subscription_ids' => [], 'message' => 'OneSignal is not configured or External ID is missing.', 'response' => null ]; } $url = 'https://api.onesignal.com/apps/' . rawurlencode($config['app_id']) . '/users/by/external_id/' . rawurlencode($external_id); $result = onesignal_api_request('GET', $url, $config['rest_api_key']); if (!$result['ok']) { error_log('OneSignal user lookup failed (' . $result['http_code'] . '): ' . $result['raw']); return [ 'success' => false, 'subscription_ids' => [], 'message' => 'OneSignal user lookup failed.', 'response' => $result ]; } $json = $result['json'] ?: []; $subscriptions = isset($json['subscriptions']) && is_array($json['subscriptions']) ? $json['subscriptions'] : []; $ids = []; foreach ($subscriptions as $subscription) { if (!is_array($subscription)) { continue; } $id = trim((string) ($subscription['id'] ?? '')); $type = strtolower(trim((string) ($subscription['type'] ?? ''))); $enabled = $subscription['enabled'] ?? false; $status = strtolower(trim((string) ($subscription['status'] ?? ''))); // Only Web Push subscriptions are relevant here. // Some OneSignal responses use type='Web' while newer responses // expose channel information differently, so do not reject an // otherwise enabled subscription solely because type is absent. $is_push = ($type === '' || $type === 'web' || $type === 'push'); $is_enabled = ($enabled === true || $enabled === 1 || $enabled === '1' || $status === 'subscribed' || $status === 'enabled'); if ($id !== '' && $is_push && $is_enabled) { $ids[] = $id; } } $ids = array_values(array_unique($ids)); return [ 'success' => true, 'subscription_ids' => $ids, 'message' => empty($ids) ? 'No active Web Push subscription was found for this user.' : 'Active Web Push subscription found.', 'response' => $json ]; } /** * Sends a Quiz360 Web Push notification through OneSignal. * $target may contain subscription_ids, external_ids, or segment. */ function send_onesignal_push_notification($db, $target, $title, $message, $click_url = '', $custom_data = []) { $config = get_onesignal_config($db); if (!$config['configured']) { return [ 'success' => false, 'id' => null, 'message' => 'OneSignal is not configured. Check App ID and REST API Key in Manage Settings.', 'response' => null ]; } $title = trim((string) $title); $message = trim((string) $message); if ($title === '' || $message === '') { return [ 'success' => false, 'id' => null, 'message' => 'Notification title and message are required.', 'response' => null ]; } $fields = [ 'app_id' => $config['app_id'], 'target_channel' => 'push', 'headings' => ['en' => $title], 'contents' => ['en' => $message] ]; $has_target = false; if (!empty($target['subscription_ids']) && is_array($target['subscription_ids'])) { $ids = array_values(array_unique(array_filter(array_map('strval', $target['subscription_ids'])))); if (!empty($ids)) { $fields['include_subscription_ids'] = array_slice($ids, 0, 20000); $has_target = true; } } elseif (!empty($target['external_ids']) && is_array($target['external_ids'])) { $ids = array_values(array_unique(array_filter(array_map('strval', $target['external_ids'])))); if (!empty($ids)) { $fields['include_aliases'] = ['external_id' => array_slice($ids, 0, 20000)]; $has_target = true; } } elseif (!empty($target['segment'])) { $fields['included_segments'] = [(string) $target['segment']]; $has_target = true; } if (!$has_target) { return [ 'success' => false, 'id' => null, 'message' => 'No valid OneSignal audience was supplied.', 'response' => null ]; } if ($click_url !== '') { $fields['url'] = (string) $click_url; } if (!empty($custom_data) && is_array($custom_data)) { $fields['data'] = $custom_data; } $result = onesignal_api_request( 'POST', 'https://api.onesignal.com/notifications', $config['rest_api_key'], $fields ); if (!$result['ok']) { error_log('OneSignal Create Message API error (' . $result['http_code'] . '): ' . $result['raw']); return [ 'success' => false, 'id' => null, 'message' => 'OneSignal rejected the notification request.', 'response' => $result ]; } $json = $result['json'] ?: []; $message_id = trim((string) ($json['id'] ?? '')); if ($message_id === '') { error_log('OneSignal accepted the request but returned no message ID: ' . $result['raw']); return [ 'success' => false, 'id' => null, 'message' => 'OneSignal accepted the request but no message ID was returned.', 'response' => $json ]; } return [ 'success' => true, 'id' => $message_id, 'message' => 'Push notification accepted by OneSignal.', 'response' => $json ]; } /** * Sends a transactional Web Push notification to a Quiz360 user by email. */ function send_chat_push_notification($db, $target_email, $title, $message, $click_url = 'https://quiz360.in/support_chat.php') { if (!isset($db) || !($db instanceof PDO) || empty($target_email)) { return false; } try { $stmt = $db->prepare('SELECT id FROM users WHERE email = ? LIMIT 1'); $stmt->execute([$target_email]); $target_user_id = $stmt->fetchColumn(); if (!$target_user_id) { error_log('OneSignal push skipped: no Quiz360 user found for ' . $target_email); return false; } $external_id = 'quiz360_user_' . (string) $target_user_id; $lookup = get_onesignal_push_subscription_ids($db, $external_id); if (!$lookup['success'] || empty($lookup['subscription_ids'])) { error_log('OneSignal push skipped for ' . $target_email . ': ' . $lookup['message']); return false; } $result = send_onesignal_push_notification( $db, ['subscription_ids' => $lookup['subscription_ids']], $title, $message, $click_url, ['quiz360_user_id' => (string) $target_user_id] ); return $result['success'] ? $result : false; } catch (Throwable $e) { error_log('OneSignal support push error: ' . $e->getMessage()); return false; } }