| Server IP : 208.122.213.31 / Your IP : 216.73.216.45 Web Server : Apache System : Linux msd6191.mjhst.com 4.18.0-553.137.1.el8_10.x86_64 #1 SMP Wed Jun 24 11:40:24 UTC 2026 x86_64 User : WHMCS_MIA_382 ( 1001) PHP Version : 7.4.33 Disable Function : NONE MySQL : ON | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : ON Directory : /home/httpd/html/cms.classicscash.com/public_html/EmblemAVS/ |
Upload File : |
<?php
/**
* EmblemAVS Class
*
* @package EmblemAVS
* @version 1.0.0
* @link https://ghost.army/
*
**/
class EmblemAVS {
public $config;
public $debugMode;
private $siteId;
private $subsiteId;
/**
* Resolve site/subsite IDs (args > globals > flags), load config, then init session and handle requests.
* Defaults: SITE_ID → 0, SUBSITE_ID → null. Empty-string subsite is treated as null.
* @param int|null $siteId Optional override for site id.
* @param int|null $subsiteId Optional override for sub-site id.
*/
public function __construct(?int $siteId = null, ?int $subsiteId = null)
{
// Avoid notices; treat missing flags as []
$flags = $GLOBALS['loadvars']['pini']['flags'] ?? [];
// siteId: args > globals > flags > 0
$resolvedSiteId = $siteId ?? ($GLOBALS['SITE_ID'] ?? ($flags['SITE_ID'] ?? 0));
$this->siteId = max(0, (int)$resolvedSiteId);
// subsiteId: args > globals > flags > null ('' => null)
$resolvedSubsiteId = $subsiteId ?? ($GLOBALS['SUBSITE_ID'] ?? ($flags['SUBSITE_ID'] ?? null));
$this->subsiteId = (isset($resolvedSubsiteId) && trim((string)$resolvedSubsiteId) !== '')
? (int)$resolvedSubsiteId
: null;
// Load configuration for this site
$this->config = $this->readConfig($this->siteId, $this->subsiteId);
// Initialize session and handle special request actions (e.g., flush)
$this->initializeSession();
$this->handleRequests();
}
/**
* Load config and compose final settings for a site/subsite.
* Merge order (last wins): global default < site default < subsite override.
* Throws when the file/shape is invalid or the merge yields an empty result.
*
* @param int $siteId
* @param int|null $subsiteId
* @return array
* @throws \RuntimeException
*/
private function readConfig(int $siteId, ?int $subsiteId): array
{
static $rawConfig = null; // cache parsed file for this process
$configFile = __DIR__ . '/EmblemAVS.Config.php';
if (!is_file($configFile) || !is_readable($configFile)) {
throw new \RuntimeException("Configuration file {$configFile} not found or unreadable.");
}
// Support either: (a) file returns array, or (b) defines $emblem_config array.
if ($rawConfig === null) {
$emblem_config = null;
$loaded = require $configFile;
if (is_array($loaded)) {
$rawConfig = $loaded;
} elseif (is_array($emblem_config)) {
$rawConfig = $emblem_config;
} else {
throw new \RuntimeException("Config must return an array or define \$emblem_config array in {$configFile}.");
}
}
if (!is_array($rawConfig)) {
throw new \RuntimeException("Configuration is not an array after loading {$configFile}.");
}
// 1) Global default
$globalDefault = [];
if (array_key_exists('default', $rawConfig)) {
if (!is_array($rawConfig['default'])) {
throw new \RuntimeException("'default' in {$configFile} must be an array.");
}
$globalDefault = $rawConfig['default'];
}
// 2) Site block (flat site default OR ['default'=>[], 'subsites'=>[]])
$siteBlock = $rawConfig[$siteId] ?? [];
if (!is_array($siteBlock)) {
$siteBlock = [];
}
// Validate 'subsites' shape if present
if (isset($siteBlock['subsites']) && !is_array($siteBlock['subsites'])) {
throw new \RuntimeException("Site {$siteId} 'subsites' must be an array in {$configFile}.");
}
// Site default: prefer nested 'default', else flat block (when no 'subsites')
if (isset($siteBlock['default'])) {
if (!is_array($siteBlock['default'])) {
throw new \RuntimeException("Site {$siteId} 'default' must be an array in {$configFile}.");
}
$siteDefault = $siteBlock['default'];
} else {
$siteDefault = isset($siteBlock['subsites']) ? [] : $siteBlock;
}
// 3) Subsite override
$subOverride = [];
if ($subsiteId !== null && isset($siteBlock['subsites'])) {
if (array_key_exists($subsiteId, $siteBlock['subsites'])) {
if (!is_array($siteBlock['subsites'][$subsiteId])) {
throw new \RuntimeException("Site {$siteId} subsite {$subsiteId} must be an array in {$configFile}.");
}
$subOverride = $siteBlock['subsites'][$subsiteId];
}
}
$final = array_replace_recursive($globalDefault, $siteDefault, $subOverride);
if (empty($final)) {
$available = implode(', ', array_map(
static fn($k) => is_int($k) ? (string)$k : "'{$k}'",
array_keys($rawConfig)
));
throw new \RuntimeException(
"No effective configuration for siteId {$siteId}" .
($subsiteId === null ? '' : " / subsite {$subsiteId}") .
" from {$configFile}. Available top-level keys: {$available}."
);
}
return $final;
}
/**
* Start a session if none is active.
* Skips CLI; avoids starting after headers are sent; hardens cookie flags (HttpOnly, Secure, SameSite=Lax).
* @return void
*/
private function initializeSession(): void
{
// Don’t start sessions for CLI scripts
if (PHP_SAPI === 'cli') {
return;
}
// Already active? done.
if (session_status() === PHP_SESSION_ACTIVE) {
return;
}
// If headers already went out, don’t attempt to start (prevents warnings)
if (headers_sent()) {
return;
}
// Secure cookie params (respect HTTPS if present)
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || (isset($_SERVER['SERVER_PORT']) && (int)$_SERVER['SERVER_PORT'] === 443);
session_set_cookie_params([
'lifetime' => 0,
'path' => ini_get('session.cookie_path') ?: '/',
'domain' => ini_get('session.cookie_domain') ?: '',
'secure' => $secure,
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
}
/**
* Handle special query actions.
* Supports:
* - emblem=flush → clear session/cookies
* - emblem_uuid=<uuid> → set Emblem session (only if UUID is valid)
* - emblemDebug=1|true → enable debug mode
* @return void
*/
private function handleRequests(): void
{
// 1) Flush (consider requiring POST or a nonce in production)
$action = filter_input(INPUT_GET, 'emblem', FILTER_UNSAFE_RAW);
if ($action === 'flush') {
$this->clearSessionAndCookie();
}
// 2) Adopt Emblem UUID (strict v4 format)
$uuid = filter_input(INPUT_GET, 'emblem_uuid', FILTER_UNSAFE_RAW);
if ($uuid !== null) {
if (preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $uuid)) {
$this->setEmblemSession($uuid);
} else {
// Optional: log invalid UUID attempt (don’t echo)
// $this->logDebug('Invalid emblem_uuid provided');
}
}
// 3) Debug mode flag
$dbg = filter_input(INPUT_GET, 'emblemDebug', FILTER_UNSAFE_RAW);
if ($dbg !== null) {
$v = strtolower(trim((string)$dbg));
if ($v === '1' || $v === 'true' || $v === 'yes' || $v === 'on') {
$this->debugMode = true;
}
}
}
/**
* Build the AVS call-to-action for a user.
* Normalizes input (ID, username/email/IP), calls GhostArmy API, and returns the proper button/alert HTML.
* Returns '' when required info is missing or the API yields no actionable response.
*
* @param array $userInfo {user_id, username, user_ip_address?, user_email?, property_uuid?, property_group_id?, success_redirect_url?, enforce_geo?}
* @return string
*/
public function displayAVSButton(array $userInfo = []): string
{
// --- Requireds ----------------------------------------------------------
$userId = (int)($userInfo['user_id'] ?? 0);
$username = trim((string)($userInfo['username'] ?? ''));
if ($userId <= 0 || $username === '') {
return '';
}
$username = function_exists('mb_strtolower') ? mb_strtolower($username, 'UTF-8') : strtolower($username);
// --- Optionals / defaults ----------------------------------------------
$email = isset($userInfo['user_email']) && filter_var($userInfo['user_email'], FILTER_VALIDATE_EMAIL) ? $userInfo['user_email'] : null;
// If caller didn’t pass IP, fall back to REMOTE_ADDR (or your getClientIP() if you have it)
$ip = $userInfo['user_ip_address'] ?? ($_SERVER['REMOTE_ADDR'] ?? null);
$redirectUrl = (string)($userInfo['success_redirect_url'] ?? ($this->config['success_redirect_url'] ?? ''));
$postData = [
'whitelabel_uuid' => $userInfo['property_uuid'] ?? ($this->config['property_uuid'] ?? null),
'whitelabel_group_id' => $userInfo['property_group_id'] ?? ($this->config['property_group_id'] ?? null),
'username' => $username,
'user_email' => $email,
'user_id' => $userId,
'user_ip_address' => $ip,
'success_redirect_url'=> $redirectUrl,
'enforce_geo' => (bool)($userInfo['enforce_geo'] ?? ($this->config['enforce_geo'] ?? false)),
];
// --- API call -----------------------------------------------------------
$apiResponse = $this->ghostArmyAPI($postData, 'post', 'emblem_avs_auth');
if (empty($apiResponse) || !is_array($apiResponse)) {
return '';
}
// --- HTML output --------------------------------------------------------
$html = '';
// Case 1: we got a redirect URL to start verification
if (!empty($apiResponse['redirectUrl'])) {
$html .= '<div class="alert alert-warning text-center">'
. htmlspecialchars((string)($this->config['avs_message'] ?? 'Verification required'), ENT_QUOTES, 'UTF-8')
. '</div>';
$html .= '<a href="' . htmlspecialchars((string)$apiResponse['redirectUrl'], ENT_QUOTES, 'UTF-8')
. '" target="_blank" rel="noopener noreferrer" id="emblem-avs-button" class="'
. htmlspecialchars((string)($this->config['avs_button_class'] ?? 'btn btn-primary'), ENT_QUOTES, 'UTF-8')
. '">'
. htmlspecialchars((string)($this->config['avs_button'] ?? 'Verify Age'), ENT_QUOTES, 'UTF-8')
. '</a>';
return $html;
}
// Case 2: already verified (auth_success === 1)
$auth = $apiResponse['auth'] ?? null;
if (is_array($auth) && !empty($auth['auth_success']) && (int)$auth['auth_success'] === 1 && !empty($auth['uuid'])) {
// Append emblem_uuid safely
$successUrl = $redirectUrl ?: '';
if ($successUrl !== '') {
$sep = (strpos($successUrl, '?') !== false) ? '&' : '?';
$successUrl = $successUrl . $sep . 'emblem_uuid=' . rawurlencode((string)$auth['uuid']);
}
$html .= '<div class="alert alert-success text-center">'
. htmlspecialchars((string)($this->config['avs_complete'] ?? 'Verification complete'), ENT_QUOTES, 'UTF-8')
. '</div>';
$html .= '<a href="' . htmlspecialchars($successUrl, ENT_QUOTES, 'UTF-8')
. '" id="emblem-avs-button" class="'
. htmlspecialchars((string)($this->config['avs_button_class_success'] ?? 'btn btn-success'), ENT_QUOTES, 'UTF-8')
. '">'
. htmlspecialchars((string)($this->config['avs_button_success'] ?? 'Continue'), ENT_QUOTES, 'UTF-8')
. '</a>';
return $html;
}
// No actionable content
return '';
}
/**
* Render the AVS template with provided $userInfo.
* Validates template path, isolates scope, and returns or echoes the output.
*
* @param array $userInfo Data exposed to the template as $EmblemClientAVS_userInfo.
* @param string|null $template Optional template filename (overrides config).
* @param bool $echo Echo output (true) or return as string (false).
* @return string|null Rendered HTML when $echo=false; otherwise null.
* @throws \RuntimeException When the template is missing or outside the base path.
*/
public function includeAVSTemplate(array $userInfo = [], ?string $template = null, bool $echo = true)
{
$base = rtrim((string)($this->config['emblem_absolute_path'] ?? ''), '/');
$file = (string)($template ?? ($this->config['emblem_template'] ?? ''));
if ($base === '' || $file === '') {
throw new \RuntimeException('Template base path or filename not configured.');
}
// Build and validate full path (prevents traversal)
$full = $base . '/' . ltrim($file, '/');
$baseReal = realpath($base);
$fullReal = $full && is_file($full) ? realpath($full) : false;
if ($baseReal === false || $fullReal === false || strpos($fullReal, $baseReal . DIRECTORY_SEPARATOR) !== 0) {
throw new \RuntimeException("Template not found or invalid path: {$full}");
}
if (!is_readable($fullReal)) {
throw new \RuntimeException("Template not readable: {$full}");
}
// Scoped render (no globals). Expose $EmblemClientAVS_userInfo inside the template.
$render = static function (string $templateFile, array $data): string {
$EmblemClientAVS_userInfo = $data; // variable available to the template
ob_start();
include $templateFile;
return (string)ob_get_clean();
};
$html = $render($fullReal, $userInfo);
if ($echo) {
echo $html;
return null;
}
return $html;
}
/**
* Resolve and return the absolute path to the AVS template.
* No side effects (does not set globals). Validates that the file exists, is readable,
* and resides under the configured base path.
*
* @param array $userInfo Ignored; kept for BC (template rendering should receive data at include time).
* @return string Absolute, validated template path.
* @throws \RuntimeException When misconfigured or file is missing/unreadable/outside base.
*/
public function getAVSTemplate(array $userInfo = []): string
{
$base = rtrim((string)($this->config['emblem_absolute_path'] ?? ''), '/');
$file = (string)($this->config['emblem_template'] ?? '');
if ($base === '' || $file === '') {
throw new \RuntimeException('Template base path or filename not configured.');
}
// Build and validate full path (prevents traversal)
$full = $base . '/' . ltrim($file, '/');
$baseReal = realpath($base);
$fullReal = is_file($full) ? realpath($full) : false;
if ($baseReal === false || $fullReal === false || strpos($fullReal, $baseReal . DIRECTORY_SEPARATOR) !== 0) {
throw new \RuntimeException("Template not found or invalid path: {$full}");
}
if (!is_readable($fullReal)) {
throw new \RuntimeException("Template not readable: {$full}");
}
return $fullReal;
}
/**
* Store the user's Emblem UUID in the session.
* Validates RFC4122 UUID (v1–v5). Optionally mirrors into $_SERVER if configured.
*
* @param string|null $uuid
* @return void
*/
private function setEmblemSession(?string $uuid = null): void
{
// Nothing to do
$uuid = trim((string)$uuid);
if ($uuid === '') {
return;
}
// Strict UUID (v1–v5) validation
if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $uuid)) {
// Optional: $this->logDebug('Invalid UUID supplied to setEmblemSession');
return;
}
// Ensure session is available (your initializeSession() already skips CLI and checks headers)
$this->initializeSession();
// Session key: fallback to a sensible default if not configured
$name = (string)($this->config['session_name'] ?? 'emblem_uuid');
// Write only if changed
if (!isset($_SESSION[$name]) || $_SESSION[$name] !== $uuid) {
$_SESSION[$name] = $uuid;
}
// Optional mirroring to $_SERVER for downstream consumers (opt-in)
if (!empty($this->config['mirror_uuid_to_server'])) {
$_SERVER[strtoupper(preg_replace('/[^a-z0-9_]/i', '_', $name))] = $uuid;
}
}
/**
* Get the URL the user should follow for AVS.
* Calls GhostArmy (`emblem_avs_auth`) and returns either the verification start URL
* or, if already verified, the success URL with `emblem_uuid`. Returns null if inputs
* are insufficient or the API has no actionable response.
*
* @param array $userInfo {user_id, username, user_ip_address?, user_email?, property_uuid?, property_group_id?, success_redirect_url?, enforce_geo?}
* @return string|null
*/
public function getAVSRedirect(array $userInfo = []): ?string
{
// --- Requireds ----------------------------------------------------------
$userId = (int)($userInfo['user_id'] ?? 0);
$username = trim((string)($userInfo['username'] ?? ''));
if ($userId <= 0 || $username === '') {
return null;
}
$username = function_exists('mb_strtolower') ? mb_strtolower($username, 'UTF-8') : strtolower($username);
// --- Optionals / defaults ----------------------------------------------
$email = isset($userInfo['user_email']) && filter_var($userInfo['user_email'], FILTER_VALIDATE_EMAIL)
? $userInfo['user_email']
: null;
$ip = $userInfo['user_ip_address']
?? ($_SERVER['REMOTE_ADDR'] ?? null); // or swap in $this->getClientIP() if you have it
$redirectUrl = (string)($userInfo['success_redirect_url'] ?? ($this->config['success_redirect_url'] ?? ''));
$postData = [
'whitelabel_uuid' => $userInfo['property_uuid'] ?? ($this->config['property_uuid'] ?? null),
'whitelabel_group_id' => $userInfo['property_group_id'] ?? ($this->config['property_group_id'] ?? null),
'username' => $username,
'user_email' => $email,
'user_id' => $userId,
'user_ip_address' => $ip,
'success_redirect_url' => $redirectUrl,
'enforce_geo' => (bool)($userInfo['enforce_geo'] ?? ($this->config['enforce_geo'] ?? false)),
];
// --- API call -----------------------------------------------------------
$apiResponse = $this->ghostArmyAPI($postData, 'post', 'emblem_avs_auth');
if ($this->debugMode ?? false) {
// Don’t echo from libraries; log instead
error_log('[EmblemAVS] getAVSRedirect response: ' . json_encode($apiResponse));
}
if (empty($apiResponse) || !is_array($apiResponse)) {
return null;
}
// Case 1: verification needs to start
if (!empty($apiResponse['redirectUrl'])) {
return (string)$apiResponse['redirectUrl'];
}
// Case 2: already verified -> send to success URL with emblem_uuid
$auth = $apiResponse['auth'] ?? null;
if (is_array($auth) && !empty($auth['auth_success']) && (int)$auth['auth_success'] === 1 && !empty($auth['uuid'])) {
if ($redirectUrl === '') {
return null; // nowhere meaningful to send them
}
$sep = (strpos($redirectUrl, '?') !== false) ? '&' : '?';
return $redirectUrl . $sep . 'emblem_uuid=' . rawurlencode((string)$auth['uuid']);
}
return null;
}
/**
* Determine if the current user is AVS-verified.
* Uses session short-circuit; otherwise calls GhostArmy `emblem_avs_auth/verify`.
* May set a session UUID (API UUID or generated) when not blocked. Returns true when usable.
*
* @param array $userInfo {user_id, username, user_ip_address?, user_email?, property_uuid?, property_group_id?, success_redirect_url?, enforce_geo?}
* @return bool
*/
public function isUserAVS(array $userInfo = []): bool
{
$this->initializeSession();
$sessionKey = (string)($this->config['session_name'] ?? 'emblem_uuid');
// If we already have a UUID in session, consider user verified.
if (!empty($_SESSION[$sessionKey])) {
return true;
}
// --- Requireds to verify ------------------------------------------------
$userId = (int)($userInfo['user_id'] ?? 0);
$username = trim((string)($userInfo['username'] ?? ''));
if ($userId <= 0 || $username === '') {
return false;
}
$username = function_exists('mb_strtolower') ? mb_strtolower($username, 'UTF-8') : strtolower($username);
// --- Optionals / defaults ----------------------------------------------
$email = isset($userInfo['user_email']) && filter_var($userInfo['user_email'], FILTER_VALIDATE_EMAIL)
? $userInfo['user_email']
: null;
$ip = $userInfo['user_ip_address']
?? ($_SERVER['REMOTE_ADDR'] ?? null); // or swap with your getClientIP()
$redirectUrl = (string)($userInfo['success_redirect_url'] ?? ($this->config['success_redirect_url'] ?? ''));
$wlUuid = $userInfo['property_uuid'] ?? ($this->config['property_uuid'] ?? null);
$wlGroup = $userInfo['property_group_id'] ?? ($this->config['property_group_id'] ?? null);
$enfGeo = (bool)($userInfo['enforce_geo'] ?? ($this->config['enforce_geo'] ?? false));
$postData = [
'whitelabel_uuid' => $wlUuid,
'whitelabel_group_id' => $wlGroup,
'username' => $username,
'user_email' => $email,
'user_id' => $userId,
'user_ip_address' => $ip,
'success_redirect_url' => $redirectUrl,
'enforce_geo' => $enfGeo,
];
if ($this->debugMode ?? false) {
error_log('[EmblemAVS] isUserAVS postData: ' . json_encode($postData));
}
// --- API: verify --------------------------------------------------------
$apiResponse = $this->ghostArmyAPI($postData, 'post', 'emblem_avs_auth/verify');
if ($this->debugMode ?? false) {
error_log('[EmblemAVS] isUserAVS response: ' . json_encode($apiResponse));
}
if (empty($apiResponse) || !is_array($apiResponse)) {
return false;
}
// Blocked? (explicit trigger)
if (!empty($apiResponse['trigger_block'])) {
return false;
}
// Already verified (auth_success === 1): store API UUID and succeed.
if (!empty($apiResponse['auth'])
&& is_array($apiResponse['auth'])
&& !empty($apiResponse['auth']['uuid'])
&& (int)($apiResponse['auth']['auth_success'] ?? 0) === 1
) {
$this->setEmblemSession((string)$apiResponse['auth']['uuid']);
return true;
}
// Not blocked and no redirect/uuid/auth: treat as allowed; set a deterministic UUID.
if (empty($apiResponse['uuid']) && empty($apiResponse['auth']) && empty($apiResponse['redirectUrl'])) {
if (method_exists($this, 'generateUserUUID')) {
$this->setEmblemSession($this->generateUserUUID($userId, $username, (string)$wlUuid));
}
return true;
}
// Any other case (e.g., needs redirect) is not "verified" yet.
return false;
}
/**
* Append a JSON log line to logs/emblemAVS_YYYYMM.log.
* Ensures the logs directory exists; writes atomically; enriches entry with ISO8601 UTC timestamp and level.
*
* @param array $logData Arbitrary structured data to log.
* @param string $level Log level hint (e.g., info, warn, error). Default: 'info'.
* @return void
* @throws \RuntimeException When misconfigured or write fails.
*/
public function logIt(array $logData, string $level = 'info'): void
{
$base = rtrim((string)($this->config['emblem_absolute_path'] ?? ''), DIRECTORY_SEPARATOR);
if ($base === '') {
throw new \RuntimeException('emblem_absolute_path not configured.');
}
$logDir = $base . DIRECTORY_SEPARATOR . 'logs';
if (!is_dir($logDir)) {
// Create recursively; respect process umask
if (!@mkdir($logDir, 0775, true) && !is_dir($logDir)) {
throw new \RuntimeException("Failed to create log directory: {$logDir}");
}
}
if (!is_writable($logDir)) {
throw new \RuntimeException("Log directory is not writable: {$logDir}");
}
$logFile = $logDir . DIRECTORY_SEPARATOR . 'emblemAVS_' . date('Ym') . '.log';
// Compose entry (ISO8601 UTC timestamp + level + payload)
$entry = [
'ts' => gmdate('c'),
'level' => $level,
] + $logData;
$json = json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($json === false) {
// Fallback: stringify on encoding failure
$json = '{"ts":"' . gmdate('c') . '","level":"' . $level . '","_raw":' . json_encode((string)print_r($logData, true)) . '}';
}
$json .= PHP_EOL;
// Atomic append with lock
$ok = @file_put_contents($logFile, $json, FILE_APPEND | LOCK_EX);
if ($ok === false) {
throw new \RuntimeException("Failed writing to log file: {$logFile}");
}
}
/**
* Call GhostArmy API and return decoded JSON (plus http_code/headers).
* Supports GET/POST (JSON body for POST). Adds UA/Auth/Origin headers, verifies SSL,
* enforces timeouts, and logs failures. Returns [] on failure or non-JSON responses.
*
* @param array $post_data Payload or query params.
* @param string $method 'post'|'get' (case-insensitive). Default: 'post'.
* @param string $endpoint Path relative to config api_url (no leading slash needed).
* @return array Decoded JSON with 'http_code' and 'headers' when available; [] on failure.
*/
private function ghostArmyAPI(array $post_data = [], string $method = 'post', string $endpoint = 'emblem_avs_auth'): array
{
$response_data = [];
$base = rtrim((string)($this->config['api_url'] ?? ''), '/');
$key = (string)($this->config['api_key'] ?? '');
$orig = (string)($this->config['origin_domain'] ?? '');
$ep = ltrim((string)$endpoint, '/');
if ($base === '' || $key === '') {
$this->logIt([
'label' => 'api_misconfig',
'status' => 'error',
'api_url'=> $base,
'has_key'=> $key !== '',
], 'error');
return [];
}
// Build URL
$api_url = $base . '/' . $ep;
$method = strtolower($method) === 'get' ? 'get' : 'post';
if ($method === 'get' && !empty($post_data)) {
$qs = http_build_query($post_data, '', '&', PHP_QUERY_RFC3986);
$api_url .= (str_contains($api_url, '?') ? '&' : '?') . $qs;
}
// Init cURL
$ch = curl_init($api_url);
if ($ch === false) {
$this->logIt(['label' => 'curl_init_failed', 'status' => 'error', 'url' => $api_url], 'error');
return [];
}
// Headers
$headers = [
'Content-Type: application/json',
'Accept: application/json',
'Origin: ' . $orig,
'Authorization: Bearer ' . $key,
];
// POST payload
if ($method === 'post') {
curl_setopt($ch, CURLOPT_POST, 1);
// Safer JSON flags; handle potential encoding errors
$json = json_encode($post_data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($json === false) {
$json = '{}';
}
curl_setopt($ch, CURLOPT_POSTFIELDS, $json);
}
// Core options
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false, // avoid auth/header leaks on redirects
CURLOPT_USERAGENT => 'EmblemClientAVS/1.0',
CURLOPT_HTTPHEADER => $headers,
CURLOPT_HEADER => true, // capture headers + body
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
]);
$response = curl_exec($ch);
$errno = curl_errno($ch);
$err = $errno ? curl_error($ch) : null;
$http = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$hdrSize = (int)curl_getinfo($ch, CURLINFO_HEADER_SIZE);
// Split headers/body
$rawHeaders = $hdrSize > 0 ? substr((string)$response, 0, $hdrSize) : '';
$body = $hdrSize > 0 ? substr((string)$response, $hdrSize) : (string)$response;
curl_close($ch);
// Parse headers into assoc (last value wins)
$headerLines = preg_split("/\r\n|\n|\r/", (string)$rawHeaders, -1, PREG_SPLIT_NO_EMPTY) ?: [];
$parsedHeaders = [];
foreach ($headerLines as $line) {
if (stripos($line, 'HTTP/') === 0) {
$parsedHeaders[':status-line'] = $line;
continue;
}
$pos = strpos($line, ':');
if ($pos !== false) {
$name = trim(substr($line, 0, $pos));
$value = trim(substr($line, $pos + 1));
$parsedHeaders[$name] = $value;
}
}
if ($this->debugMode ?? false) {
// Redact auth
$dbgHeaders = $parsedHeaders;
if (isset($dbgHeaders['Authorization'])) {
$dbgHeaders['Authorization'] = 'Bearer ****';
}
$logData = [
'label' => 'api_debug',
'origin' => $orig,
'property_uuid' => $this->config['property_uuid'] ?? null,
'property_group_id' => $this->config['property_group_id'] ?? null,
'status' => $errno ? 'error' : 'ok',
'method' => strtoupper($method),
'url' => $api_url,
'http' => $http,
'errno' => $errno,
'headers' => $dbgHeaders,
'body_len'=> strlen($body),
];
$this->logIt($logData, $errno ? 'warn' : 'info');
// notify Ghost Army of the failure
$this->notifyGhostArmy($logData);
}
// Handle transport errors or non-2xx
if ($errno !== 0 || $http < 200 || $http >= 300) {
$logData = [
'label' => 'api_failed',
'origin' => $orig,
'property_uuid' => $this->config['property_uuid'] ?? null,
'property_group_id' => $this->config['property_group_id'] ?? null,
'status' => 'error',
'method' => strtoupper($method),
'url' => $api_url,
'http' => $http,
'errno' => $errno,
'error' => $err,
'resp_snip' => substr($body, 0, 500),
];
$this->logIt($logData, 'error');
// notify Ghost Army of the failure
$this->notifyGhostArmy($logData);
return [];
}
// Decode JSON body
$decoded = json_decode($body, true);
if (!is_array($decoded)) {
// Not JSON; log and return []
$logData = [
'label' => 'api_non_json',
'origin' => $orig,
'property_uuid' => $this->config['property_uuid'] ?? null,
'property_group_id' => $this->config['property_group_id'] ?? null,
'status' => 'warn',
'method' => strtoupper($method),
'url' => $api_url,
'http' => $http,
'resp_snip'=> substr($body, 0, 500),
];
$this->logIt($logData, 'warn');
// notify Ghost Army of the failure
$this->notifyGhostArmy($logData);
return [];
}
// Attach meta
$decoded['http_code'] = $http;
$decoded['headers'] = $parsedHeaders;
return $decoded;
}
/**
* Notify Ghost Army of API issues via a dedicated endpoint.
*
* @param array $logData
* @return void
*/
private function notifyGhostArmy(array $logData): void
{
$encodedData = base64_encode(json_encode($logData));
$bugsnagUrl = 'https://support.ghost.army/EmblemBugsnag/?data=' . rawurlencode($encodedData);
$chBugsnag = curl_init($bugsnagUrl);
if ($chBugsnag !== false) {
curl_setopt_array($chBugsnag, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_USERAGENT => 'EmblemClientAVS/1.0',
CURLOPT_TIMEOUT => 5,
]);
curl_exec($chBugsnag);
curl_close($chBugsnag);
}
}
/**
* Clear session and related cookies.
* Empties $_SESSION, expires the PHP session cookie (respecting SameSite/flags),
* destroys the session, and removes the app cookie (config session_name) on host
* and dot-domain variants. No output; safe when headers already sent (best-effort).
*
* @return void
*/
private function clearSessionAndCookie(): void
{
// Ensure session exists unless headers already sent (can’t set cookies then)
if (!headers_sent()) {
$this->initializeSession();
}
// --- Clear session data -------------------------------------------------
if (session_status() === PHP_SESSION_ACTIVE) {
$_SESSION = [];
// Expire the session cookie using current params
$params = session_get_cookie_params();
$cookieName = session_name();
// Build deletion options (PHP 7.3+ array form keeps SameSite/flags)
$opts = [
'expires' => time() - 3600,
'path' => $params['path'] ?: '/',
'domain' => $params['domain'] ?? '',
'secure' => (bool)($params['secure'] ?? false),
'httponly' => true,
];
if (isset($params['samesite'])) {
$opts['samesite'] = $params['samesite']; // usually 'Lax' or 'Strict'
}
if (!headers_sent()) {
@setcookie($cookieName, '', $opts);
}
// Regenerate then destroy to prevent fixation
@session_regenerate_id(true);
@session_destroy();
@session_write_close();
}
// --- App-specific cookie(s) --------------------------------------------
$appCookie = (string)($this->config['session_name'] ?? 'emblem_uuid');
if ($appCookie !== '') {
// Remove from superglobal regardless
unset($_COOKIE[$appCookie]);
if (!headers_sent()) {
// Try expiring on both host and dot-domain (covers subdomains)
$host = $_SERVER['HTTP_HOST'] ?? ($_SERVER['SERVER_NAME'] ?? '');
$host = preg_replace('~:\d+$~', '', (string)$host); // strip port if present
$dotDomain = $host !== '' && strpos($host, '.') !== false ? '.' . ltrim($host, '.') : '';
$cookieOpts = [
'expires' => time() - 3600,
'path' => '/',
'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| ((int)($_SERVER['SERVER_PORT'] ?? 0) === 443),
'httponly' => true,
'samesite' => 'Lax',
];
// Current host
@setcookie($appCookie, '', $cookieOpts + ['domain' => '']);
if ($host !== '') {
@setcookie($appCookie, '', $cookieOpts + ['domain' => $host]);
}
// Dot-domain (parent scope)
if ($dotDomain !== '') {
@setcookie($appCookie, '', $cookieOpts + ['domain' => $dotDomain]);
}
}
}
// Optional: clear any server mirror
if (!empty($this->config['mirror_uuid_to_server'])) {
$k = strtoupper(preg_replace('/[^a-z0-9_]/i', '_', (string)($this->config['session_name'] ?? 'emblem_uuid')));
unset($_SERVER[$k]);
}
}
/**
* Resolve the client's public IP.
* Prefers trusted proxy headers (CF/XFF/True-Client-IP) when enabled; else uses REMOTE_ADDR.
* Localhost (::1/127.0.0.1) → "127.0.0.1". Returns '' if no public IP found.
*
* Config (optional):
* - trust_proxy_headers (bool) default: false
* - trusted_proxy_cidrs (string[] CIDRs) e.g., Cloudflare ranges
*
* @return string
*/
public function getUserIP(): string
{
// Helper: CIDR match
$ipInCidrs = static function (string $ip, array $cidrs): bool {
if ($ip === '' || empty($cidrs)) return false;
$ipBin = @inet_pton($ip);
if ($ipBin === false) return false;
foreach ($cidrs as $cidr) {
if (!preg_match('~^(.+?)/(\\d{1,3})$~', $cidr, $m)) continue;
[$full, $net, $bits] = $m;
$netBin = @inet_pton($net);
if ($netBin === false || strlen($netBin) !== strlen($ipBin)) continue;
$bytes = (int) floor($bits / 8);
$rem = (int) ($bits % 8);
if ($bytes && substr($ipBin, 0, $bytes) !== substr($netBin, 0, $bytes)) continue;
if ($rem) {
$mask = chr(0xFF << (8 - $rem) & 0xFF);
if ((ord($ipBin[$bytes]) & ord($mask)) !== (ord($netBin[$bytes]) & ord($mask))) continue;
}
return true;
}
return false;
};
// Helper: validate public IP; normalize localhost
$normalize = static function (string $ip): string {
if ($ip === '::1' || $ip === '127.0.0.1') return '127.0.0.1';
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) ? $ip : '';
};
// Decide if we should trust proxy headers
$trustProxy = (bool)($this->config['trust_proxy_headers'] ?? false);
$trustedCidrs = (array)($this->config['trusted_proxy_cidrs'] ?? []); // e.g., Cloudflare IPv4/IPv6 ranges
$remoteAddr = (string)($_SERVER['REMOTE_ADDR'] ?? '');
$remoteIsTrusted = $trustProxy && ($remoteAddr !== '') && ($ipInCidrs($remoteAddr, $trustedCidrs) || empty($trustedCidrs));
// Candidate sources, in order, when proxy is trusted
if ($remoteIsTrusted) {
$candidates = [
(string)($_SERVER['HTTP_CF_CONNECTING_IP'] ?? ''), // Cloudflare
(string)($_SERVER['HTTP_TRUE_CLIENT_IP'] ?? ''), // Some CDNs
(string)($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''), // May be a list
(string)($_SERVER['HTTP_X_REAL_IP'] ?? ''), // Nginx/Ingress
];
foreach ($candidates as $val) {
if ($val === '') continue;
// XFF can be a comma-separated list (left-most is original client)
foreach (explode(',', $val) as $ip) {
$ip = trim($ip);
if ($ip === '') continue;
$pub = $normalize($ip);
if ($pub !== '') return $pub;
// allow localhost normalization for direct CF dev envs
if ($ip === '::1' || $ip === '127.0.0.1') return '127.0.0.1';
}
}
}
// Fallback: REMOTE_ADDR (direct client or untrusted proxy)
if ($remoteAddr !== '') {
$pub = $normalize($remoteAddr);
if ($pub !== '') return $pub;
if ($remoteAddr === '::1' || $remoteAddr === '127.0.0.1') return '127.0.0.1';
}
return '';
}
/**
* Generate a deterministic RFC-4122 UUIDv5 for a user.
* Uses property UUID as the namespace and "user_id:username" as the name.
* Falls back to DNS namespace if $property_uuid is invalid.
*
* @param mixed $user_id
* @param string $username
* @param string $property_uuid Namespace UUID (e.g., per-property/brand)
* @return string UUIDv5 string (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)
*/
public function generateUserUUID($user_id, string $username, string $property_uuid): string
{
// Normalize inputs
$id = (string)(int)$user_id;
$usr = function_exists('mb_strtolower') ? mb_strtolower(trim($username), 'UTF-8') : strtolower(trim($username));
// Validate namespace UUID (v1–v5)
$isUuid = static function (string $u): bool {
return (bool)preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $u);
};
// Use provided namespace or fallback to DNS namespace (RFC 4122)
$ns = $isUuid($property_uuid) ? $property_uuid : '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
// Convert namespace UUID to 16 raw bytes
$nsBin = hex2bin(str_replace('-', '', strtolower($ns)));
if ($nsBin === false || strlen($nsBin) !== 16) {
// Shouldn't happen, but guard anyway
$nsBin = hex2bin(str_replace('-', '', '6ba7b810-9dad-11d1-80b4-00c04fd430c8'));
}
// Name = "id:username"
$name = $id . ':' . $usr;
// SHA-1 over namespace bytes + name (binary output)
$hash = sha1($nsBin . $name, true); // 20 bytes
// Set version (5) and variant (RFC 4122)
$bytes = substr($hash, 0, 16);
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x50); // version 5
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80); // variant 10xxxxxx
// Format as UUID string
$hex = bin2hex($bytes);
return sprintf(
'%s-%s-%s-%s-%s',
substr($hex, 0, 8),
substr($hex, 8, 4),
substr($hex, 12, 4),
substr($hex, 16, 4),
substr($hex, 20, 12)
);
}
}