product-demo-protocol v1.0.1, Modalita A. Additivo (zero impatto endpoint esistenti). - DemoController: session-start, manifest, event, credentials, reset-dataset * demo_jwt scope=demo:read-only, org_id 996001 (range demo 996000-996999), TTL 30m * hmac_key_seed base64 44ch, rate-limit 5/min+50/h per IP, eventi idempotenti - Migration 040: tabelle demo_sessions + demo_events - Rotte 'demo' in index.php (chiavi camelCase: router camelCasa il segmento URL) - docs/DEMO_AVATAR_NIS2.md: piano Fase1/Fase2, decisioni, punti integrazione AgileHub, gap protocollo Smoke prod OK: session-start 201, manifest 200, credentials 200, event 204, reset 401(no key). TODO Fase 1: read-only guard in requireAuth, dataset demo 996001, frontend demo-mode, manifest+mappe. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
245 lines
11 KiB
PHP
245 lines
11 KiB
PHP
<?php
|
|
/**
|
|
* NIS2 Agile - Demo Controller (Avatar di prodotto, product-demo-protocol v1.0.1)
|
|
*
|
|
* 5 endpoint per la modalità demo guidata dall'avatar (Modalità A, prodotto live).
|
|
* Doc: docs/DEMO_AVATAR_NIS2.md | Standard: agile-services/docs/STANDARD_PRODUCT_DEMO_PROTOCOL.md
|
|
*
|
|
* NB: questi endpoint NON usano requireAuth() — auth dedicata demo (JWT scope=demo:read-only
|
|
* / X-Internal-Key). Il riconoscimento del demo_jwt sulle API app è in BaseController (step a parte).
|
|
*/
|
|
|
|
require_once __DIR__ . '/BaseController.php';
|
|
|
|
class DemoController extends BaseController
|
|
{
|
|
/** Range org riservato demo NIS2 (standard §7.1) */
|
|
private const DEMO_ORG_ID = 996001;
|
|
private const DEMO_RANGE = [996000, 996999];
|
|
private const JWT_TTL_SEC = 1800; // 30 min
|
|
private const DEMO_BASE = 'https://dimostrazione.agile.software';
|
|
private const RL_DIR = '/tmp/nis2_demo_rl';
|
|
|
|
// ── 1) POST /api/demo/session-start ──────────────────────────────────────
|
|
public function sessionStart(): void
|
|
{
|
|
$ip = $this->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' => self::DEMO_BASE . '/?demo=' . $sessionId,
|
|
'manifest_url' => self::DEMO_BASE . '/api/demo/manifest?session_id=' . $sessionId,
|
|
'credentials_url' => self::DEMO_BASE . '/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');
|
|
}
|
|
|
|
// TODO Fase 1 (increment dataset): DELETE + re-seed org demo 996001 da seed idempotente.
|
|
$this->jsonSuccess([
|
|
'reset' => 'pending_implementation',
|
|
'company_ids' => [self::DEMO_ORG_ID],
|
|
'note' => 'Logica di reset dataset demo in arrivo (seed isolato org 996001).',
|
|
], 'Reset demo (stub)');
|
|
}
|
|
|
|
// ═══ Helpers ═════════════════════════════════════════════════════════════
|
|
|
|
/** 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';
|
|
}
|
|
}
|