// 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'; } /** * OneSignal configuration. * Server-side API key is never exposed to the browser. */ function get_onesignal_config($db) { $app_id = trim((string)(getenv('ONESIGNAL_APP_ID') ?: get_setting($db, 'onesignal_app_id', ''))); $rest_api_key = trim((string)(getenv('ONESIGNAL_REST_API_KEY') ?: 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, ]; } /** * Low-level JSON request helper for OneSignal REST API. */ function onesignal_api_request($method, $url, $api_key, $payload = null) { $ch = curl_init($url); $headers = [ 'Authorization: Key ' . $api_key, 'Accept: application/json', 'Content-Type: application/json; charset=utf-8', ]; $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_UNICODE | JSON_UNESCAPED_SLASHES); } curl_setopt_array($ch, $options); $response = curl_exec($ch); $curl_error = curl_error($ch); $http_code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $decoded = null; if (is_string($response) && $response !== '') { $decoded = json_decode($response, true); } return [ 'ok' => ($response !== false && $curl_error === '' && $http_code >= 200 && $http_code < 300), 'http_code' => $http_code, 'curl_error' => $curl_error, 'raw' => $response === false ? '' : (string)$response, 'json' => is_array($decoded) ? $decoded : null, ]; } /** * Fetch a OneSignal user by Quiz360 External ID and return its active push * Subscription IDs. This gives the Push Center an exact device-level target * while still keeping Quiz360's stable External ID as the source of identity. */ function get_onesignal_push_subscription_ids($db, $external_id) { $config = get_onesignal_config($db); if (!$config['configured'] || trim((string)$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((string)$external_id); $result = onesignal_api_request('GET', $url, $config['rest_api_key']); if (!$result['ok']) { $detail = $result['json']['errors'] ?? $result['json']['message'] ?? $result['raw']; if (is_array($detail)) { $detail = json_encode($detail, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } error_log('OneSignal user lookup failed (' . $result['http_code'] . '): ' . (string)$detail); return [ 'success' => false, 'subscription_ids' => [], 'message' => 'OneSignal user lookup failed. HTTP ' . $result['http_code'] . '.', 'response' => $result, ]; } $subscriptions = $result['json']['subscriptions'] ?? []; $subscription_ids = []; if (is_array($subscriptions)) { foreach ($subscriptions as $subscription) { if (!is_array($subscription)) { continue; } $id = trim((string)($subscription['id'] ?? '')); $type = strtolower(trim((string)($subscription['type'] ?? ''))); $enabled = $subscription['enabled'] ?? false; // Web Push subscriptions are the intended channel here. The type // value can vary slightly across OneSignal versions, so accept any // subscription type containing "push", but never email/SMS. $is_push = ($type !== '' && strpos($type, 'push') !== false); if ($id !== '' && $is_push && $enabled === true) { $subscription_ids[] = $id; } } } $subscription_ids = array_values(array_unique($subscription_ids)); return [ 'success' => true, 'subscription_ids' => $subscription_ids, 'message' => empty($subscription_ids) ? 'No active push subscription was found for this user.' : 'Active push subscription found.', 'response' => $result['json'], ]; } /** * Centralized OneSignal Push sender. * * $target supports exactly one targeting method: * ['external_ids' => ['quiz360_user_13', ...]] * ['subscription_ids' => ['uuid', ...]] * ['segment' => 'Subscribed Users'] */ 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. Add the 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); // include_subscription_ids already identifies the push channel. unset($fields['target_channel']); $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?c=push', $config['rest_api_key'], $fields ); if (!$result['ok']) { $detail = $result['json']['errors'] ?? $result['json']['message'] ?? $result['raw']; if (is_array($detail)) { $detail = json_encode($detail, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } error_log('OneSignal send failed (' . $result['http_code'] . '): ' . (string)$detail); return [ 'success' => false, 'id' => null, 'message' => 'OneSignal rejected the notification (HTTP ' . $result['http_code'] . '). ' . trim((string)$detail), 'response' => $result['json'] ?? $result['raw'], ]; } $response_json = $result['json'] ?? []; $message_id = trim((string)($response_json['id'] ?? '')); // OneSignal documents that a 200 without an id means no valid recipient // was created for the selected push audience. if ($message_id === '') { $detail = $response_json['errors'] ?? $response_json['message'] ?? 'No eligible push subscription matched the audience.'; if (is_array($detail)) { $detail = json_encode($detail, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } error_log('OneSignal accepted request but created no message: ' . (string)$detail); return [ 'success' => false, 'id' => null, 'message' => 'OneSignal accepted the request but found no eligible push subscription for this audience.', 'response' => $response_json, ]; } return [ 'success' => true, 'id' => $message_id, 'message' => 'Push notification accepted by OneSignal.', 'response' => $response_json, ]; } /** * Sends a transactional support-chat Web Push to a Quiz360 user. * The target user's active OneSignal push subscriptions are resolved first. */ 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 support push skipped for ' . $target_email . ': ' . $lookup['message']); return false; } $result = send_onesignal_push_notification( $db, ['subscription_ids' => $lookup['subscription_ids']], $title, $message, $click_url, ['source' => 'quiz360_support'] ); return $result['success'] ? $result : false; } catch (Throwable $e) { error_log('OneSignal support push error: ' . $e->getMessage()); return false; } } ?>