clientIp(); if (!$this->rateLimit($ip)) { header('Retry-After: 60'); $this->jsonError('Troppe richieste demo, riprova tra poco', 429, 'RATE_LIMIT_EXCEEDED'); } $language = in_array($this->getParam('language'), ['it', 'en'], true) ? $this->getParam('language') : 'it'; $variant = in_array($this->getParam('tour_variant'), ['full', 'express'], true) ? $this->getParam('tour_variant') : 'full'; $sessionId = 'demo-' . $this->uuid4(); $hmacKeySeed = base64_encode(random_bytes(32)); // 44 char base64 standard $orgId = self::DEMO_ORG_ID; $expiresTs = time() + self::JWT_TTL_SEC; $demoJwt = $this->generateJWT(0, [ 'scope' => 'demo:read-only', 'org_id' => $orgId, 'demo_session_id' => $sessionId, 'exp' => $expiresTs, ]); // ip_hash server-side (anti-spoof): sha256(ip + daily salt) $ipHash = hash('sha256', $ip . '|' . date('Y-m-d') . '|' . JWT_SECRET); Database::insert('demo_sessions', [ 'session_id' => $sessionId, 'organization_id' => $orgId, 'hmac_key_seed' => $hmacKeySeed, 'language' => $language, 'tour_variant' => $variant, 'presentation_mode' => 'realtime', 'referrer_url' => mb_substr((string) $this->getParam('referrer_url', ''), 0, 512) ?: null, 'user_agent' => mb_substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 512) ?: null, 'ip_hash' => $ipHash, 'expires_at' => date('Y-m-d H:i:s', $expiresTs), ]); $this->jsonSuccess([ 'session_id' => $sessionId, 'demo_jwt' => $demoJwt, 'company_id' => $orgId, // alias di compatibilità standard 'organization_id' => $orgId, // spa_iframe_url atterra su dashboard e porta il demo_jwt nel FRAGMENT (#djwt): // common-bi.js lo imposta come token API in modo SINCRONO (prima di checkAuth), // così la SPA si auto-autentica anche cross-origin (niente sessionStorage same-origin). 'spa_iframe_url' => $this->demoBase() . '/dashboard.html?demo=' . $sessionId . '#djwt=' . $demoJwt, 'manifest_url' => $this->demoBase() . '/api/demo/manifest?session_id=' . $sessionId, 'credentials_url' => $this->demoBase() . '/api/demo/credentials?session_id=' . $sessionId, 'expires_at' => gmdate('Y-m-d\TH:i:s\Z', $expiresTs), 'hmac_key_seed' => $hmacKeySeed, 'presentation_mode' => 'realtime', ], 'Sessione demo creata', 201); } // ── 2) GET /api/demo/manifest?session_id= ──────────────────────────────── public function manifest(): void { $session = $this->resolveSession(); if (!$session) { $this->jsonError('Sessione demo non valida o scaduta', 401, 'DEMO_SESSION_INVALID'); } $lang = in_array($this->getParam('language'), ['it', 'en'], true) ? $this->getParam('language') : ($session['language'] ?? 'it'); $file = PUBLIC_PATH . '/demo/nis2-tour-2026.json'; if (is_file($file)) { $manifest = json_decode((string) file_get_contents($file), true) ?: []; $manifest['session_id'] = $session['session_id']; $manifest['language'] = $lang; $this->jsonSuccess($manifest); } // Placeholder finché il manifest reale non è pubblicato (Fase 1, step successivo) $this->jsonSuccess([ 'tour_id' => 'demo:nis2-tour-2026', 'tour_version' => '0.1', 'session_id' => $session['session_id'], 'language' => $lang, 'tour_variant' => $session['tour_variant'] ?? 'full', 'presentation_mode' => 'realtime', 'persona_key' => 'ARIA_SUPPORT_NIS2', 'control_protocol' => 'agilehub.product-demo.v1', 'iframe_origin' => self::DEMO_BASE, 'steps' => [], 'fallback_static' => ['image_url' => null, 'audio_url_it' => null, 'audio_url_en' => null], '_note' => 'manifest placeholder — steps in arrivo (Fase 1)', ]); } // ── 3) POST /api/demo/event ────────────────────────────────────────────── public function event(): void { $session = $this->resolveSession(); if (!$session) { $this->jsonError('Sessione demo non valida o scaduta', 401, 'DEMO_SESSION_INVALID'); } $eventType = (string) $this->getParam('event_type', ''); $allowed = ['step_completed','step_skipped','tour_paused','tour_aborted','free_question_asked','cta_clicked','presentation_mode_chosen','error_encountered']; if (!in_array($eventType, $allowed, true)) { $this->jsonError('event_type non valido', 400, 'INVALID_EVENT_TYPE'); } $stepId = $this->getParam('step_id'); $tsIso = mb_substr((string) $this->getParam('client_timestamp_iso', gmdate('Y-m-d\TH:i:s\Z')), 0, 40); $meta = $this->getParam('metadata'); // Idempotente: UNIQUE (session_id, event_type, step_id, client_timestamp_iso) try { Database::insert('demo_events', [ 'session_id' => $session['session_id'], 'event_type' => $eventType, 'step_id' => $stepId !== null ? (int) $stepId : null, 'metadata' => $meta !== null ? json_encode($meta, JSON_UNESCAPED_UNICODE) : null, 'client_timestamp_iso' => $tsIso, ]); } catch (Throwable $e) { // duplicato (replay idempotente) → ignora silenziosamente } http_response_code(204); exit; } // ── 4) GET /api/demo/credentials?session_id= ───────────────────────────── public function credentials(): void { $sessionId = (string) $this->getParam('session_id', ''); $session = $sessionId !== '' ? $this->loadSession($sessionId) : null; if (!$session) { $this->jsonError('Sessione demo non trovata o scaduta', 404, 'DEMO_SESSION_NOT_FOUND'); } $expiresTs = strtotime($session['expires_at']); $demoJwt = $this->generateJWT(0, [ 'scope' => 'demo:read-only', 'org_id' => (int) $session['organization_id'], 'demo_session_id' => $session['session_id'], 'exp' => $expiresTs, ]); $this->jsonSuccess([ 'demo_jwt' => $demoJwt, 'company_id' => (int) $session['organization_id'], 'organization_id' => (int) $session['organization_id'], 'session_id' => $session['session_id'], 'expires_at' => gmdate('Y-m-d\TH:i:s\Z', $expiresTs), ]); } // ── 5) POST /api/demo/reset-dataset ────────────────────────────────────── public function resetDataset(): void { $key = $_SERVER['HTTP_X_INTERNAL_KEY'] ?? ''; $expected = getenv('INTERNAL_DEMO_KEY') ?: (getenv('INTERNAL_EMAIL_KEY') ?: ''); if ($expected === '' || !hash_equals($expected, $key)) { $this->jsonError('Non autorizzato', 401, 'UNAUTHORIZED'); } // Reset idempotente del dataset demo (clona la golden #151 in 996001/996002). // ?scope=demo → solo 996001 (read-only) | ?scope=sandbox → solo 996002 | default: entrambe. $scope = (string) $this->getParam('scope', ''); $targets = $scope === 'demo' ? [996001 => DemoSeedService::TARGETS[996001]] : ($scope === 'sandbox' ? [996002 => DemoSeedService::TARGETS[996002]] : DemoSeedService::TARGETS); $result = DemoSeedService::seed($targets); $failed = array_filter($result, fn($v) => str_starts_with($v, 'errore')); $this->jsonSuccess([ 'reset' => empty($failed) ? 'done' : 'partial', 'company_ids' => array_keys($result), 'detail' => $result, ], empty($failed) ? 'Dataset demo reimpostato' : 'Reset demo parziale (vedi detail)', empty($failed) ? 200 : 207); } // ═══ Helpers ═════════════════════════════════════════════════════════════ /** Base URL della SPA demo (override via env DEMO_BASE_URL per switch a sottodominio). */ private function demoBase(): string { return getenv('DEMO_BASE_URL') ?: ($_SERVER['DEMO_BASE_URL'] ?? '') ?: ($_ENV['DEMO_BASE_URL'] ?? '') ?: self::DEMO_BASE; } /** Risolve la sessione da Bearer demo_jwt (preferito) o ?session_id=. */ private function resolveSession(): ?array { $token = $this->getBearerToken(); if ($token) { $payload = $this->verifyJWT($token); if ($payload && ($payload['scope'] ?? '') === 'demo:read-only' && !empty($payload['demo_session_id'])) { return $this->loadSession((string) $payload['demo_session_id']); } } $sid = (string) $this->getParam('session_id', ''); return $sid !== '' ? $this->loadSession($sid) : null; } private function loadSession(string $sessionId): ?array { $s = Database::fetchOne('SELECT * FROM demo_sessions WHERE session_id = ?', [$sessionId]); if (!$s) return null; if (strtotime($s['expires_at']) < time()) return null; // scaduta return $s; } /** Rate-limit file-based: 5/min + 50/h per IP. true = consentito. */ private function rateLimit(string $ip): bool { if (!is_dir(self::RL_DIR)) @mkdir(self::RL_DIR, 0700, true); $f = self::RL_DIR . '/' . md5($ip) . '.json'; $now = time(); $hits = is_file($f) ? (json_decode((string) file_get_contents($f), true) ?: []) : []; $hits = array_values(array_filter($hits, fn($t) => $t > $now - 3600)); $lastMin = count(array_filter($hits, fn($t) => $t > $now - 60)); if ($lastMin >= 5 || count($hits) >= 50) return false; $hits[] = $now; @file_put_contents($f, json_encode($hits), LOCK_EX); return true; } private function uuid4(): string { $b = random_bytes(16); $b[6] = chr((ord($b[6]) & 0x0f) | 0x40); $b[8] = chr((ord($b[8]) & 0x3f) | 0x80); return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($b), 4)); } private function clientIp(): string { $xff = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''; if ($xff !== '') return trim(explode(',', $xff)[0]); return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; } }