$_) { if (str_starts_with($key, 'flash_')) unset($_SESSION[$key]); } } /** * Create a CSRF token. */ function csrf() { if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = token(); return $_SESSION['csrf']; } /** * Verify a CSRF token. */ function csrf_verify($token) { return hash_equals($_SESSION['csrf'] ?? '', $token); } /** * Create a hidden input field for CSRF tokens. */ function csrf_field() { return ''; } /** * Kill the current request with a 418 error, if $_POST['csrf'] is invalid. */ function csrf_ensure() { if (!csrf_verify($_POST['csrf'] ?? '')) error_response(418); } /** * Set a cookie with secure and HTTP-only flags. */ function set_cookie($name, $value, $expires) { setcookie($name, $value, [ 'expires' => $expires, 'path' => '/', 'domain' => '', // Defaults to the current domain 'secure' => true, // Ensure the cookie is only sent over HTTPS 'httponly' => true, // Prevent access to cookie via JavaScript 'samesite' => 'Strict' // Enforce SameSite=Strict ]); } /** * Get the current user's object from SESSION if it exists. */ function user(): User|false { if (empty($_SESSION['user'])) return false; return unserialize($_SESSION['user']); } /** * Modify a field in the user session object. Returns true on success and false on failure. */ function modify_user_session(string $field, mixed $value): bool { $user = user(); if ($user === false || !property_exists($user, $field)) return false; $user->{$field} = $value; $_SESSION['user'] = serialize($user); return true; } /** * If the current user has a selected char and the data is in the session, retrieve the character object. If there * is no character data, populate it. */ function char(): Character|false { if (empty($_SESSION['user'])) return false; if (empty($GLOBALS['char'])) $GLOBALS['char'] = user()->current_char(); return $GLOBALS['char']; } /** * Update the user's selected character. Returns true on success, false on failure. Database * is updated if the character ID is different from the current session. */ function change_user_character(int $char_id): bool { // If the character does not exist, return false if (($char = Character::find($char_id)) === false) return false; $GLOBALS['char'] = $char; // If the character ID is different, update the session and database if (user()->char_id !== $char_id) { modify_user_session('char_id', $char_id); db_query(db_auth(), "UPDATE users SET char_id = :c WHERE id = :u", [':c' => $char_id, ':u' => user()->id]); } return true; } /** * Get a percent between two ints, rounded to the nearest whole number or return 0. */ function percent(float $num, float $denom, int $precision = 4): float { if ($denom === 0) return 0; $p = ($num / $denom) * 100; return $p < 0 ? 0 : round($p, $precision); } /** * Access the account wallet. On first execution it will populate $GLOBALS['wallet'] with the wallet data. This way * the data is up to date with every request without having to query the database every use within, for example, a * template. Will return false if the user or wallet does not exist. */ function wallet(): Wallet|false { if (user() === false) return false; if (empty($GLOBALS['wallet'])) $w = user()->wallet(); if ($w === false) return false; return $GLOBALS['wallet'] = $w; } /** * Access the character location. On first execution it will populate $GLOBALS['location'] with the location data. This * way the data is up to date with every request without having to query the database every use within, for example, a * template. Will return false if the field does not exist, or the entire location array if no field is specified. */ function location($field = '') { if (empty($GLOBALS['location'])) { $GLOBALS['location'] = db_query( db_live(), "SELECT * FROM char_locations WHERE char_id = :c", [':c' => user()->char_id] )->fetchArray(SQLITE3_ASSOC); } if ($field === '') return $GLOBALS['location']; return $GLOBALS['location'][$field] ?? false; } /** * Format an array of strings to a ul element. */ function array_to_ul($array) { $html = ''; foreach ($array as $item) $html .= "
  • $item
  • "; return ""; } /** * Start a keyed stopwatch to measure the time between two points in the code. */ function stopwatch_start($key) { if (!env('debug', false)) return; $GLOBALS['stopwatch'][$key] = microtime(true); } /** * Stop a keyed stopwatch. Stores the time in the global $stopwatch array under the key. */ function stopwatch_stop($key) { if (!env('debug', false)) return; if (empty($GLOBALS['stopwatch'][$key])) return 0; $GLOBALS['stopwatch'][$key] = microtime(true) - $GLOBALS['stopwatch'][$key]; } /** * Get the stopwatch value and format it to within 10 digits. */ function stopwatch_get(string $key): string { if (!env('debug', false)) return ''; if (empty($GLOBALS['stopwatch'][$key])) return ''; return number_format($GLOBALS['stopwatch'][$key], 10); } /** * Conditional Echo; if the condition is true, echo the value. If the condition is false, echo the $or value. */ function ce(bool $condition, mixed $value, mixed $or = ''): void { echo $condition ? $value : $or; } /** * Get whether the request is an HTMX request. */ function is_htmx(): bool { return isset($_SERVER['HTTP_HX_REQUEST']); } /** * Get whether the request is an AJAX (fetch) request. */ function is_ajax(): bool { return isset($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest'; } /** * Limit a request to AJAX only. */ function ajax_only(): void { if (!is_ajax()) error_response(418); } /** * Return a JSON response with the given data. */ function json_response(mixed $data): void { header('Content-Type: application/json; charset=utf-8'); echo json_encode($data); exit; } /** * Handle an error by setting the response code and echoing an error message */ function error_response(int $code): void { http_response_code($code); echo match ($code) { 403 => 'Forbidden', 404 => 'Not Found', 405 => 'Method Not Allowed', 418 => 'I\'m a teapot', 999 => 'Cheating attempt detected', default => 'Unknown Error', }; exit; } /** * Return a title's [name, lore]. */ function title(int $title_id): array|false { return db_query(db_live(), 'SELECT * FROM titles WHERE id = ?', [$title_id])->fetchArray(); } /** * Abbreviate a number in text notation. */ function abb_num(int $num): string { $d = $num % 100 === 0 ? 0 : 1; return match(true) { $num >= 1000000000 => number_format($num / 1000000000, $d) . 'B', $num >= 1000000 => number_format($num / 1000000, $d) . 'M', default => number_format($num, 0) }; } /** * Check if all keys of an array are numeric. */ function all_keys_numeric(array $a): bool { foreach (array_keys($a) as $k) if (!is_int($k)) return false; return true; } /** * Parse simple BBCode to HTML, and keep count of characters. */ function parse_bbcode(string $text): array { $html = $text; // Store the original text without tags and whitespace for counting $textForCounting = preg_replace('/\[.*?\]/', '', $text); // Remove BBCode tags $textForCounting = preg_replace('/\s+/', '', $textForCounting); // Remove whitespace $charCount = strlen($textForCounting); // Basic BBCode conversions $replacements = [ '/\[b\](.*?)\[\/b\]/is' => '$1', '/\[i\](.*?)\[\/i\]/is' => '$1', '/\[u\](.*?)\[\/u\]/is' => '$1', '/\[url=(.*?)\](.*?)\[\/url\]/is' => '$2', '/\[url\](.*?)\[\/url\]/is' => '$1', //'/\[img\](.*?)\[\/img\]/is' => '', '/\[size=([1-7])\](.*?)\[\/size\]/is' => '$2', '/\[color=(#[A-F0-9]{6}|[a-z]+)\](.*?)\[\/color\]/is' => '$2', '/\[quote\](.*?)\[\/quote\]/is' => '
    $1
    ', '/\[code\](.*?)\[\/code\]/is' => '
    $1
    ', '/\[list\](.*?)\[\/list\]/is' => '', '/\[list=1\](.*?)\[\/list\]/is' => '
      $1
    ', '/\[\*\](.*?)(?=\[\*\]|\[\/list\])/is' => '
  • $1
  • ' ]; foreach ($replacements as $pattern => $replacement) $html = preg_replace($pattern, $replacement, $html); // Convert newlines to
    tags $html = nl2br($html); return [ 'html' => $html, 'char_count' => $charCount ]; } /** * Shorthand to verify auth, a character is selected, CSRF is correct, and it is an AJAX request. Used for * front-end API routes. */ function ui_guard() { auth_only_and_must_have_character(); ajax_only(); csrf_ensure(); }