fuori da email_log). * 6) Ogni endpoint valida campaign.supplier_id == session.supplier_id (no IDOR). */ require_once __DIR__ . '/BaseController.php'; require_once APP_PATH . '/services/EmailService.php'; require_once APP_PATH . '/services/RateLimitService.php'; require_once APP_PATH . '/services/AuditService.php'; class SupplierPortalController extends BaseController { private const SESSION_TTL_SECONDS = 4 * 3600; private const OTP_TTL_SECONDS = 15 * 60; private const OTP_DIGITS = 8; private const OTP_MAX_ATTEMPTS = 5; private const OTP_LOCKOUT_SECONDS = 15 * 60; private const RL_REQUEST = [['max' => 3, 'window_seconds' => 600], ['max' => 8, 'window_seconds' => 3600]]; private const RL_VERIFY = [['max' => 10, 'window_seconds' => 60], ['max' => 40, 'window_seconds' => 3600]]; private ?array $supplierSession = null; // ════════════════ AUTH OTP / MAGIC-LINK (NO AUTH interna) ════════════════ /** POST /api/supplier-portal/request-otp Body: {email} — risposta opaca. */ public function requestOtp(): void { $email = strtolower(trim((string) $this->getParam('email', ''))); $ip = $this->getClientIPLocal(); $opaqueMsg = 'Se l\'email e\' registrata, riceverai il codice di accesso.'; if ($email === '' || !$this->validateEmail($email)) { $this->jsonSuccess(['delivered' => true], $opaqueMsg); } RateLimitService::check('sp_req_email:' . $email, self::RL_REQUEST); RateLimitService::check('sp_req_ip:' . $ip, self::RL_REQUEST); RateLimitService::increment('sp_req_email:' . $email); RateLimitService::increment('sp_req_ip:' . $ip); $su = Database::fetchOne( 'SELECT su.id, su.supplier_id, su.organization_id, su.email, su.full_name FROM supplier_users su JOIN suppliers s ON s.id = su.supplier_id WHERE su.email = ? AND su.is_active = 1 AND s.deleted_at IS NULL ORDER BY su.last_login_at DESC, su.id DESC LIMIT 1', [$email] ); if (!$su) { $this->jsonSuccess(['delivered' => true], $opaqueMsg); } // Invalida OTP/magic precedenti del referente. Database::query('UPDATE supplier_otp SET consumed_at = NOW() WHERE supplier_user_id = ? AND consumed_at IS NULL', [(int) $su['id']]); $otpCode = $this->generateNumericOtp(self::OTP_DIGITS); $magicToken = 'sp_' . bin2hex(random_bytes(32)); Database::insert('supplier_otp', [ 'supplier_user_id' => (int) $su['id'], 'otp_hash' => hash('sha256', $otpCode), 'magic_token_hash' => hash('sha256', $magicToken), 'purpose' => 'login', 'attempts' => 0, 'max_attempts' => self::OTP_MAX_ATTEMPTS, 'expires_at' => date('Y-m-d H:i:s', time() + self::OTP_TTL_SECONDS), 'ip_created' => substr($ip, 0, 45), ]); $committente = Database::fetchOne('SELECT name FROM organizations WHERE id = ?', [(int) $su['organization_id']]); $committenteName = $committente['name'] ?? 'il committente'; $magicUrl = rtrim(APP_URL, '/') . '/supplier-portal.html?magic=' . urlencode($magicToken); try { (new EmailService())->sendViaTemplate($su['email'], 'supplier_otp', [ 'committente' => $committenteName, 'otp' => trim(chunk_split($otpCode, 3, ' ')), 'magic_url' => $magicUrl, 'full_name' => $su['full_name'] ?? '', 'ttl_minutes' => (string) (int) (self::OTP_TTL_SECONDS / 60), ], $committenteName); } catch (Throwable $e) { error_log('[SUPPLIER_PORTAL] invio OTP fallito: ' . $e->getMessage()); } $this->auditSupplier((int) $su['organization_id'], 'supplier_otp_requested', 'supplier_user', (int) $su['id'], ['supplier_id' => (int) $su['supplier_id']]); $this->jsonSuccess(['delivered' => true], $opaqueMsg); } /** POST /api/supplier-portal/verify-otp Body: {email,code} | {magic_token}. */ public function verifyOtp(): void { $ip = $this->getClientIPLocal(); RateLimitService::check('sp_verify_ip:' . $ip, self::RL_VERIFY); RateLimitService::increment('sp_verify_ip:' . $ip); $magicToken = trim((string) $this->getParam('magic_token', '')); if ($magicToken !== '') { $otpRow = $this->resolveOtpByMagic($magicToken); } else { $email = strtolower(trim((string) $this->getParam('email', ''))); $code = preg_replace('/\D+/', '', (string) $this->getParam('code', '')); if ($email === '' || $code === '') { $this->jsonError('Inserisci email e codice di accesso.', 422, 'MISSING_CREDENTIALS'); } $otpRow = $this->resolveOtpByCode($email, $code); } $su = Database::fetchOne( 'SELECT su.id, su.supplier_id, su.organization_id, su.email, su.full_name FROM supplier_users su JOIN suppliers s ON s.id = su.supplier_id WHERE su.id = ? AND su.is_active = 1 AND s.deleted_at IS NULL', [(int) $otpRow['supplier_user_id']] ); if (!$su) { $this->jsonError('Accesso non disponibile. Contatta il committente.', 403, 'SUPPLIER_DISABLED'); } // Consumo single-use atomico. $consumed = Database::query('UPDATE supplier_otp SET consumed_at = NOW() WHERE id = ? AND consumed_at IS NULL', [(int) $otpRow['id']]); if ($consumed->rowCount() === 0) { $this->jsonError('Questo codice e\' gia\' stato utilizzato. Richiedi un nuovo accesso.', 409, 'ALREADY_USED'); } $jti = $this->uuidv4(); $expiresAt = date('Y-m-d H:i:s', time() + self::SESSION_TTL_SECONDS); Database::insert('supplier_sessions', [ 'supplier_user_id' => (int) $su['id'], 'organization_id' => (int) $su['organization_id'], 'jti' => $jti, 'ip_address' => substr($ip, 0, 45), 'user_agent' => substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 500), 'expires_at' => $expiresAt, ]); Database::update('supplier_users', ['last_login_at' => date('Y-m-d H:i:s')], 'id = ?', [(int) $su['id']]); $jwt = $this->issueSupplierJwt([ 'sp_uid' => (int) $su['id'], 'supplier_id' => (int) $su['supplier_id'], 'org_id' => (int) $su['organization_id'], 'jti' => $jti, ]); $this->auditSupplier((int) $su['organization_id'], 'supplier_login', 'supplier_user', (int) $su['id'], ['supplier_id' => (int) $su['supplier_id'], 'method' => $magicToken !== '' ? 'magic' : 'otp']); $supplier = Database::fetchOne('SELECT name FROM suppliers WHERE id = ?', [(int) $su['supplier_id']]); $this->jsonSuccess([ 'token' => $jwt, 'expires_at' => $expiresAt, 'supplier' => ['id' => (int) $su['supplier_id'], 'name' => $supplier['name'] ?? ''], 'user' => ['id' => (int) $su['id'], 'email' => $su['email'], 'full_name' => $su['full_name']], ], 'Accesso effettuato'); } // ════════════════ ENDPOINT AUTENTICATI (sessione fornitore) ════════════════ /** GET /api/supplier-portal/me */ public function me(): void { $this->requireSupplierSession(); $supplierId = $this->supplierSession['supplier_id']; $orgId = $this->supplierSession['org_id']; $supplier = Database::fetchOne( 'SELECT id, name, service_type, criticality FROM suppliers WHERE id = ? AND organization_id = ? AND deleted_at IS NULL', [$supplierId, $orgId] ); if (!$supplier) { $this->jsonError('Fornitore non disponibile', 404, 'SUPPLIER_NOT_FOUND'); } $campaigns = Database::fetchAll( "SELECT id, template_id, template_version, status, due_at, sent_at, completed_at, expires_at, show_score_to_supplier, language, CASE WHEN show_score_to_supplier = 1 THEN score ELSE NULL END AS score FROM questionnaire_campaigns WHERE supplier_id = ? AND organization_id = ? AND status IN ('sent','in_progress','completed','expired') ORDER BY (status IN ('sent','in_progress')) DESC, (due_at IS NULL), due_at ASC, sent_at DESC", [$supplierId, $orgId] ); $committente = Database::fetchOne('SELECT name FROM organizations WHERE id = ?', [$orgId]); $this->jsonSuccess([ 'supplier' => [ 'id' => (int) $supplier['id'], 'name' => $supplier['name'], 'service_type' => $supplier['service_type'], 'criticality' => $supplier['criticality'], ], 'committente' => ['name' => $committente['name'] ?? ''], 'user' => [ 'id' => $this->supplierSession['sp_uid'], 'email' => $this->supplierSession['email'], 'full_name' => $this->supplierSession['full_name'], ], 'campaigns' => array_map(static fn(array $c): array => [ 'id' => (int) $c['id'], 'status' => $c['status'], 'due_at' => $c['due_at'], 'sent_at' => $c['sent_at'], 'completed_at' => $c['completed_at'], 'expires_at' => $c['expires_at'], 'language' => $c['language'], 'show_score' => (bool) $c['show_score_to_supplier'], 'score' => $c['score'] !== null ? (int) $c['score'] : null, ], $campaigns), ]); } /** GET /api/supplier-portal/{id} — template+domande della campagna (+ risposte salvate). */ public function getQuestionnaire(int $id): void { $this->requireSupplierSession(); $campaign = $this->loadOwnedCampaign($id); $supplier = Database::fetchOne('SELECT name, criticality FROM suppliers WHERE id = ?', [$campaign['supplier_id']]); $highCrit = in_array(strtolower((string) ($supplier['criticality'] ?? '')), ['high', 'critical'], true); $questions = $this->loadCampaignQuestions($campaign, $highCrit); $answers = Database::fetchAll( 'SELECT question_id, question_code, answer_value, answer_text, file_ref FROM questionnaire_answers WHERE campaign_id = ? AND organization_id = ?', [$campaign['id'], $campaign['organization_id']] ); $answersByQid = []; foreach ($answers as $a) { $key = $a['question_id'] !== null ? (string) (int) $a['question_id'] : ('code:' . $a['question_code']); $answersByQid[$key] = [ 'value' => $a['answer_value'] !== null ? json_decode($a['answer_value'], true) : null, 'text' => $a['answer_text'], 'file_ref' => $a['file_ref'], ]; } $committente = Database::fetchOne('SELECT name FROM organizations WHERE id = ?', [$campaign['organization_id']]); $this->jsonSuccess([ 'campaign' => [ 'id' => (int) $campaign['id'], 'status' => $campaign['status'], 'due_at' => $campaign['due_at'], 'expires_at' => $campaign['expires_at'], 'language' => $campaign['language'], 'show_score' => (bool) $campaign['show_score_to_supplier'], ], 'committente' => ['name' => $committente['name'] ?? ''], 'supplier' => ['name' => $supplier['name'] ?? ''], 'questions' => $questions, 'answers' => $answersByQid, ]); } /** PATCH /api/supplier-portal/{id}/answers — autosave per-domanda. */ public function saveAnswers(int $id): void { $this->requireSupplierSession(); $campaign = $this->loadOwnedCampaign($id); if ($campaign['status'] === 'completed') { $this->jsonError('Questionario gia\' inviato: non e\' piu\' modificabile.', 409, 'ALREADY_SUBMITTED'); } $this->assertCampaignOpen($campaign); $body = $this->getJsonBody(); $answers = is_array($body['answers'] ?? null) ? $body['answers'] : []; if (empty($answers)) { $this->jsonError('Nessuna risposta da salvare', 422, 'NO_ANSWERS'); } $validQ = $this->validQuestionIds($campaign); $saved = 0; foreach ($answers as $a) { if (!is_array($a)) continue; $qid = isset($a['question_id']) ? (int) $a['question_id'] : null; $code = isset($a['question_code']) ? (string) $a['question_code'] : null; if ($qid !== null && !isset($validQ[$qid])) continue; if ($qid === null && $code === null) continue; $valueJson = array_key_exists('value', $a) && $a['value'] !== null ? json_encode($a['value'], JSON_UNESCAPED_UNICODE) : null; $this->upsertAnswer((int) $campaign['id'], (int) $campaign['organization_id'], $qid, $code, $valueJson, isset($a['text']) ? (string) $a['text'] : null, isset($a['file_ref']) ? substr((string) $a['file_ref'], 0, 255) : null, $this->supplierSession['sp_uid']); $saved++; } if ($campaign['status'] === 'sent') { Database::query("UPDATE questionnaire_campaigns SET status = 'in_progress' WHERE id = ? AND organization_id = ? AND status = 'sent'", [(int) $campaign['id'], (int) $campaign['organization_id']]); } $this->jsonSuccess(['saved' => $saved, 'status' => 'in_progress', 'saved_at' => date('c')], 'Bozza salvata'); } /** POST /api/supplier-portal/{id}/submit — invio definitivo + scoring. */ public function submitQuestionnaire(int $id): void { $this->requireSupplierSession(); $campaign = $this->loadOwnedCampaign($id); if ($campaign['status'] === 'completed') { $this->jsonError('Questionario gia\' inviato.', 409, 'ALREADY_SUBMITTED'); } $this->assertCampaignOpen($campaign); $body = $this->getJsonBody(); $answers = is_array($body['answers'] ?? null) ? $body['answers'] : []; $supplier = Database::fetchOne('SELECT criticality FROM suppliers WHERE id = ?', [$campaign['supplier_id']]); $highCrit = in_array(strtolower((string) ($supplier['criticality'] ?? '')), ['high', 'critical'], true); $questions = $this->loadCampaignQuestions($campaign, $highCrit); Database::beginTransaction(); try { $validQ = $this->validQuestionIds($campaign); foreach ($answers as $a) { if (!is_array($a)) continue; $qid = isset($a['question_id']) ? (int) $a['question_id'] : null; $code = isset($a['question_code']) ? (string) $a['question_code'] : null; if ($qid !== null && !isset($validQ[$qid])) continue; if ($qid === null && $code === null) continue; $valueJson = array_key_exists('value', $a) && $a['value'] !== null ? json_encode($a['value'], JSON_UNESCAPED_UNICODE) : null; $this->upsertAnswer((int) $campaign['id'], (int) $campaign['organization_id'], $qid, $code, $valueJson, isset($a['text']) ? (string) $a['text'] : null, isset($a['file_ref']) ? substr((string) $a['file_ref'], 0, 255) : null, $this->supplierSession['sp_uid']); } $stored = Database::fetchAll( 'SELECT question_id, question_code, answer_value, answer_text FROM questionnaire_answers WHERE campaign_id = ? AND organization_id = ?', [(int) $campaign['id'], (int) $campaign['organization_id']] ); $storedByQid = []; foreach ($stored as $s) { if ($s['question_id'] !== null) $storedByQid[(int) $s['question_id']] = $s; elseif ($s['question_code'] !== null) $storedByQid['code:' . $s['question_code']] = $s; } $missing = []; foreach ($questions as $q) { if (!$q['is_required']) continue; $stKey = $q['id'] !== null ? (int) $q['id'] : ('code:' . $q['code']); if (!$this->answerIsPresent($storedByQid[$stKey] ?? null)) $missing[] = $q['code'] ?? (string) $q['id']; } if (!empty($missing)) { Database::rollback(); $this->jsonError('Mancano risposte obbligatorie.', 422, 'REQUIRED_MISSING', ['missing' => $missing]); } [$score, $vulnerabilities] = $this->computeScore($questions, $storedByQid, $highCrit); $riskLevel = $this->riskLevelFromScore($score); $upd = Database::query( "UPDATE questionnaire_campaigns SET status='completed', score=?, risk_level=?, answers=?, completed_at=NOW() WHERE id = ? AND organization_id = ? AND status <> 'completed'", [$score, $riskLevel, json_encode(['vulnerabilities' => $vulnerabilities], JSON_UNESCAPED_UNICODE), (int) $campaign['id'], (int) $campaign['organization_id']] ); if ($upd->rowCount() === 0) { Database::rollback(); $this->jsonError('Questionario gia\' inviato.', 409, 'ALREADY_SUBMITTED'); } Database::query( 'UPDATE suppliers SET risk_score = ?, security_requirements_met = ?, last_assessment_date = CURDATE() WHERE id = ? AND organization_id = ?', [$score, $score >= 70 ? 1 : 0, (int) $campaign['supplier_id'], (int) $campaign['organization_id']] ); Database::commit(); } catch (Throwable $e) { if (Database::getInstance()->inTransaction()) Database::rollback(); throw $e; } $this->auditSupplier((int) $campaign['organization_id'], 'supplier_questionnaire_submitted', 'questionnaire_campaign', (int) $campaign['id'], ['supplier_id' => (int) $campaign['supplier_id'], 'score' => $score, 'risk_level' => $riskLevel]); $payload = ['completed_at' => date('c'), 'show_score' => (bool) $campaign['show_score_to_supplier']]; if ($campaign['show_score_to_supplier']) { $payload['score'] = $score; $payload['risk_level'] = $riskLevel; } $this->jsonSuccess($payload, 'Questionario inviato. Grazie.'); } // ════════════════ AUTH SEPARATA ════════════════ private function requireSupplierSession(): void { $token = $this->getBearerToken(); if (!$token) $this->jsonError('Sessione mancante. Richiedi un nuovo accesso.', 401, 'MISSING_TOKEN'); $payload = $this->verifySupplierJwt($token); if (!$payload) $this->jsonError('Sessione non valida o scaduta. Richiedi un nuovo accesso.', 401, 'INVALID_TOKEN'); $jti = $payload['jti'] ?? null; $spUid = isset($payload['sp_uid']) ? (int) $payload['sp_uid'] : 0; if (!$jti || $spUid <= 0) $this->jsonError('Sessione non valida.', 401, 'INVALID_SESSION'); $session = Database::fetchOne( 'SELECT ss.id, ss.supplier_user_id, ss.organization_id, su.supplier_id, su.email, su.full_name, su.is_active FROM supplier_sessions ss JOIN supplier_users su ON su.id = ss.supplier_user_id WHERE ss.jti = ? AND ss.supplier_user_id = ? AND ss.revoked_at IS NULL AND ss.expires_at > NOW()', [$jti, $spUid] ); if (!$session || (int) $session['is_active'] !== 1) { $this->jsonError('Sessione non valida o revocata. Richiedi un nuovo accesso.', 401, 'SESSION_REVOKED'); } if ((int) $payload['supplier_id'] !== (int) $session['supplier_id'] || (int) $payload['org_id'] !== (int) $session['organization_id']) { $this->jsonError('Sessione incoerente. Richiedi un nuovo accesso.', 401, 'SESSION_MISMATCH'); } $this->supplierSession = [ 'jti' => $jti, 'sp_uid' => (int) $session['supplier_user_id'], 'supplier_id' => (int) $session['supplier_id'], 'org_id' => (int) $session['organization_id'], 'email' => $session['email'], 'full_name' => $session['full_name'], ]; } private function issueSupplierJwt(array $claims): string { $header = json_encode(['typ' => 'JWT', 'alg' => 'HS256']); $payload = json_encode(array_merge([ 'aud' => 'supplier-portal', 'iss' => APP_URL, 'iat' => time(), 'exp' => time() + self::SESSION_TTL_SECONDS, ], $claims), JSON_UNESCAPED_UNICODE); $h = $this->base64UrlEncode($header); $p = $this->base64UrlEncode($payload); $s = $this->base64UrlEncode(hash_hmac('sha256', "$h.$p", $this->supplierJwtSecret(), true)); return "$h.$p.$s"; } private function verifySupplierJwt(string $token): ?array { $parts = explode('.', $token); if (count($parts) !== 3) return null; [$h, $p, $sig] = $parts; $expected = hash_hmac('sha256', "$h.$p", $this->supplierJwtSecret(), true); if (!hash_equals($expected, $this->base64UrlDecode($sig))) return null; $payload = json_decode($this->base64UrlDecode($p), true); if (!is_array($payload)) return null; if (($payload['aud'] ?? null) !== 'supplier-portal') return null; if (isset($payload['exp']) && (int) $payload['exp'] < time()) return null; if (isset($payload['user_id'])) return null; // token interno → rifiuto netto return $payload; } private function supplierJwtSecret(): string { if (defined('SUPPLIER_JWT_SECRET') && SUPPLIER_JWT_SECRET !== '') return SUPPLIER_JWT_SECRET; return hash_hmac('sha256', 'supplier-portal-v1', JWT_SECRET); } // ════════════════ HELPER OTP ════════════════ private function resolveOtpByCode(string $email, string $code): array { $su = Database::fetchOne('SELECT id FROM supplier_users WHERE email = ? AND is_active = 1 ORDER BY id DESC LIMIT 1', [$email]); if (!$su) $this->jsonError('Codice non valido o scaduto. Richiedi un nuovo accesso.', 401, 'OTP_INVALID'); $otp = Database::fetchOne( "SELECT * FROM supplier_otp WHERE supplier_user_id = ? AND purpose = 'login' AND otp_hash IS NOT NULL AND consumed_at IS NULL ORDER BY id DESC LIMIT 1", [(int) $su['id']] ); if (!$otp) $this->jsonError('Codice non valido o scaduto. Richiedi un nuovo accesso.', 401, 'OTP_INVALID'); $this->assertOtpUsable($otp); if (!hash_equals((string) $otp['otp_hash'], hash('sha256', $code))) { $attempts = (int) $otp['attempts'] + 1; $fields = ['attempts' => $attempts]; $remaining = max(0, (int) $otp['max_attempts'] - $attempts); if ($attempts >= (int) $otp['max_attempts']) { $fields['locked_until'] = date('Y-m-d H:i:s', time() + self::OTP_LOCKOUT_SECONDS); } Database::update('supplier_otp', $fields, 'id = ?', [(int) $otp['id']]); if ($remaining <= 0) { $this->jsonError('Troppi tentativi. Accesso bloccato per ' . (int) (self::OTP_LOCKOUT_SECONDS / 60) . ' minuti.', 429, 'OTP_LOCKED'); } $this->jsonError('Codice errato. Hai ancora ' . $remaining . ' tentativi.', 401, 'OTP_WRONG', ['remaining' => $remaining]); } return $otp; } private function resolveOtpByMagic(string $magicToken): array { if (!preg_match('/^sp_[a-f0-9]{64}$/', $magicToken)) { $this->jsonError('Link non valido o scaduto. Richiedi un nuovo accesso.', 401, 'MAGIC_INVALID'); } $otp = Database::fetchOne( "SELECT * FROM supplier_otp WHERE magic_token_hash = ? AND purpose = 'login' AND consumed_at IS NULL ORDER BY id DESC LIMIT 1", [hash('sha256', $magicToken)] ); if (!$otp) $this->jsonError('Link non valido o gia\' utilizzato. Richiedi un nuovo accesso.', 401, 'MAGIC_INVALID'); $this->assertOtpUsable($otp); return $otp; } private function assertOtpUsable(array $otp): void { if (!empty($otp['locked_until']) && strtotime($otp['locked_until']) > time()) { $minutes = max(1, (int) ceil((strtotime($otp['locked_until']) - time()) / 60)); $this->jsonError('Accesso temporaneamente bloccato. Riprova tra ' . $minutes . ' minuti.', 429, 'OTP_LOCKED'); } if (strtotime((string) $otp['expires_at']) < time()) { $this->jsonError('Codice scaduto. Richiedi un nuovo accesso.', 401, 'OTP_EXPIRED'); } } private function generateNumericOtp(int $digits): string { $max = (10 ** $digits) - 1; return str_pad((string) random_int(0, $max), $digits, '0', STR_PAD_LEFT); } // ════════════════ HELPER CAMPAGNA / SCORING ════════════════ private function loadOwnedCampaign(int $id): array { $campaign = Database::fetchOne( 'SELECT * FROM questionnaire_campaigns WHERE id = ? AND supplier_id = ? AND organization_id = ?', [$id, $this->supplierSession['supplier_id'], $this->supplierSession['org_id']] ); if (!$campaign) $this->jsonError('Questionario non trovato', 404, 'CAMPAIGN_NOT_FOUND'); return $campaign; } private function assertCampaignOpen(array $campaign): void { if (in_array($campaign['status'], ['cancelled', 'draft', 'expired'], true)) { $this->jsonError('Questionario non disponibile alla compilazione.', 409, 'CAMPAIGN_CLOSED'); } if (!empty($campaign['expires_at']) && strtotime((string) $campaign['expires_at']) < time()) { Database::query("UPDATE questionnaire_campaigns SET status='expired' WHERE id = ? AND status NOT IN ('completed','cancelled')", [(int) $campaign['id']]); $this->jsonError('Il termine per la compilazione e\' scaduto.', 410, 'CAMPAIGN_EXPIRED'); } } private function loadCampaignQuestions(array $campaign, bool $highCrit): array { $raw = []; if ($campaign['template_id'] !== null && $campaign['template_version'] !== null) { $snap = Database::fetchOne( 'SELECT questions_snapshot FROM questionnaire_template_versions WHERE template_id = ? AND version = ? AND organization_id = ?', [(int) $campaign['template_id'], $campaign['template_version'], (int) $campaign['organization_id']] ); if ($snap && !empty($snap['questions_snapshot'])) { $decoded = json_decode($snap['questions_snapshot'], true); if (is_array($decoded)) $raw = $decoded; } } if (empty($raw) && $campaign['template_id'] !== null) { $raw = Database::fetchAll( 'SELECT id, question_code AS code, question_text AS text, question_type AS type, options, weight, is_required, order_index, nis2_ref, vuln_flag, high_criticality_only FROM questionnaire_questions WHERE template_id = ? AND organization_id = ? ORDER BY order_index, id', [(int) $campaign['template_id'], (int) $campaign['organization_id']] ); } $out = []; foreach ($raw as $q) { if ((bool) ($q['high_criticality_only'] ?? false) && !$highCrit) continue; $opts = $q['options'] ?? null; if (is_string($opts)) $opts = json_decode($opts, true); $out[] = [ 'id' => isset($q['id']) ? (int) $q['id'] : null, 'code' => $q['code'] ?? ($q['question_code'] ?? null), 'text' => (string) ($q['text'] ?? $q['question_text'] ?? ''), 'type' => (string) ($q['type'] ?? $q['question_type'] ?? 'yes_no_partial'), 'options' => is_array($opts) ? $opts : null, 'weight' => (float) ($q['weight'] ?? 1.0), 'is_required' => (bool) ($q['is_required'] ?? $q['required'] ?? true), 'nis2_ref' => $q['nis2_ref'] ?? null, 'vuln_flag' => $q['vuln_flag'] ?? null, 'help_text' => $q['help_text'] ?? null, ]; } return $out; } private function validQuestionIds(array $campaign): array { if ($campaign['template_id'] === null) return []; $rows = Database::fetchAll('SELECT id FROM questionnaire_questions WHERE template_id = ? AND organization_id = ?', [(int) $campaign['template_id'], (int) $campaign['organization_id']]); $map = []; foreach ($rows as $r) $map[(int) $r['id']] = true; return $map; } private function upsertAnswer(int $campaignId, int $orgId, ?int $questionId, ?string $questionCode, ?string $valueJson, ?string $text, ?string $fileRef, int $answeredBy): void { if ($questionId !== null) { Database::query( 'INSERT INTO questionnaire_answers (campaign_id, organization_id, question_id, question_code, answer_value, answer_text, file_ref, answered_by, answered_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW()) ON DUPLICATE KEY UPDATE answer_value=VALUES(answer_value), answer_text=VALUES(answer_text), file_ref=VALUES(file_ref), answered_by=VALUES(answered_by), answered_at=NOW()', [$campaignId, $orgId, $questionId, $questionCode, $valueJson, $text, $fileRef, $answeredBy] ); return; } $existing = $questionCode !== null ? Database::fetchOne( 'SELECT id FROM questionnaire_answers WHERE campaign_id = ? AND organization_id = ? AND question_id IS NULL AND question_code = ? LIMIT 1', [$campaignId, $orgId, $questionCode]) : null; if ($existing) { Database::update('questionnaire_answers', [ 'answer_value' => $valueJson, 'answer_text' => $text, 'file_ref' => $fileRef, 'answered_by' => $answeredBy, 'answered_at' => date('Y-m-d H:i:s'), ], 'id = ?', [(int) $existing['id']]); } else { Database::insert('questionnaire_answers', [ 'campaign_id' => $campaignId, 'organization_id' => $orgId, 'question_id' => null, 'question_code' => $questionCode, 'answer_value' => $valueJson, 'answer_text' => $text, 'file_ref' => $fileRef, 'answered_by' => $answeredBy, ]); } } private function answerIsPresent(?array $row): bool { if ($row === null) return false; if (!empty($row['answer_text'])) return true; if (!empty($row['file_ref'])) return true; if ($row['answer_value'] !== null) { $decoded = json_decode((string) $row['answer_value'], true); if (is_array($decoded)) return count($decoded) > 0; return $decoded !== null && $decoded !== ''; } return false; } private function computeScore(array $questions, array $storedByQid, bool $highCrit): array { $earned = 0.0; $maxScore = 0.0; $vulns = []; $rtoThreshold = $highCrit ? 8 : 24; $highExposure = ['privileg', 'amministr', 'critic', 'segret', 'sensibil', 'personal']; foreach ($questions as $q) { $key = $q['id'] !== null ? (int) $q['id'] : ('code:' . $q['code']); $value = $this->decodeAnswerValue($storedByQid[$key] ?? null); $weight = max(0.0, (float) $q['weight']); switch ($q['type']) { case 'yes_no_partial': $maxScore += $weight; $v = is_string($value) ? strtolower($value) : ''; $factor = match ($v) { 'yes', 'si', 'sì' => 1.0, 'partial', 'parziale' => 0.5, default => 0.0 }; $earned += $weight * $factor; if ($factor < 1.0 && !empty($q['vuln_flag'])) $vulns[] = $q['vuln_flag']; break; case 'scale_1_5': $maxScore += $weight; $n = $this->scaleToInt($value); if ($n !== null && $n >= 1 && $n <= 5) { $earned += $weight * (($n - 1) / 4); if ($n <= 2 && !empty($q['vuln_flag'])) $vulns[] = $q['vuln_flag']; } elseif (!empty($q['vuln_flag'])) { $vulns[] = $q['vuln_flag']; } break; case 'number': $num = is_numeric($value) ? (float) $value : null; if (($num === null || $num > $rtoThreshold) && !empty($q['vuln_flag'])) $vulns[] = $q['vuln_flag']; break; case 'single_choice': case 'multi_choice': $picks = is_array($value) ? $value : ($value !== null ? [$value] : []); foreach ($picks as $pick) { $p = strtolower((string) $pick); foreach ($highExposure as $needle) { if (str_contains($p, $needle)) { if (!empty($q['vuln_flag'])) $vulns[] = $q['vuln_flag']; break 2; } } } break; } } $score = $maxScore > 0.0 ? (int) round($earned / $maxScore * 100) : 0; return [$score, array_values(array_unique($vulns))]; } private function decodeAnswerValue(?array $row) { if ($row === null) return null; if ($row['answer_value'] !== null) { $decoded = json_decode((string) $row['answer_value'], true); if ($decoded !== null) return $decoded; } return $row['answer_text'] ?? null; } private function scaleToInt($value): ?int { if (is_int($value)) return $value; if (is_numeric($value)) return (int) $value; if (is_string($value) && preg_match('/^\s*([1-5])/', $value, $m)) return (int) $m[1]; return null; } private function riskLevelFromScore(int $score): string { return $score >= 80 ? 'low' : ($score >= 60 ? 'medium' : ($score >= 40 ? 'high' : 'critical')); } // ════════════════ UTILITY ════════════════ private function getClientIPLocal(): string { $xff = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''; if ($xff !== '') { $first = trim(explode(',', $xff)[0]); if (filter_var($first, FILTER_VALIDATE_IP)) return $first; } return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; } private function uuidv4(): 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 auditSupplier(int $orgId, string $action, string $entityType, ?int $entityId, array $details): void { try { AuditService::log($orgId, null, $action, $entityType, $entityId, $details, $this->getClientIPLocal(), $_SERVER['HTTP_USER_AGENT'] ?? null, AuditService::resolveSeverity($action, $details), null); } catch (Throwable $e) { error_log('[SUPPLIER_PORTAL] audit fallito: ' . $e->getMessage()); } } }