false,'error'=>'DISABLED']. Si "accende" così: * 1. applicare migrate_063_vocea_channel.php (colonne organizations.vocea_*) * 2. VOCEA_ENABLED=true + VOCEA_API_KEY (vault tier1__nis2-app__vocea__*) * 3. impostare organizations.vocea_tenant_slug + vocea_channel_status='active' sul canale * * CONFINE ZERO-KNOWLEDGE: l'API NON ritorna MAI contenuti in chiaro. listReports * restituisce metadati + ciphertext; la decifratura avviene client-side dal gestore. * * Contratto: whistleblowing-agile/docs/API_GATEWAY_INTEGRATIONS.md * GET /integrations/reports (scope reports:read) — lista metadati (paginata) * GET /integrations/reports/{code}/status (scope status:read) * POST /integrations/reports/submit (scope reports:submit) — payload GIÀ cifrato * GET /integrations/keyholders · POST /integrations/keyholders (enroll public key RSA) * GET /tenants/{slug}/info (pubblico — public key del canale) * * NB: base URL/prefisso (`/api/wb/v1` vs `/v1`) e shape esatto risposte vanno * riconfermati allo smoke a MS live; sono parametrizzati (VOCEA_BASE_URL) apposta. */ class VoceaService { private const TIMEOUT_SEC = 12; private const CONNECT_TIMEOUT_SEC = 6; private string $baseUrl; private string $apiBase; // base NON versionata (endpoint pubblici tipo /tenants/{slug}/info) private string $apiKey; public function __construct(?string $apiKey = null) { $this->baseUrl = rtrim(defined('VOCEA_BASE_URL') ? VOCEA_BASE_URL : 'https://api.vocea.cloud/api/wb/v1', '/'); // gli endpoint /integrations/* sono versionati (/v1); gli endpoint pubblici (/tenants/{slug}/info) NO. $this->apiBase = preg_replace('#/v\d+$#', '', $this->baseUrl); $this->apiKey = $apiKey ?? (defined('VOCEA_API_KEY') ? VOCEA_API_KEY : ''); } /** Flag globale: l'integrazione Vocea è attiva? */ public static function enabled(): bool { return defined('VOCEA_ENABLED') && VOCEA_ENABLED === true; } /** Il canale è utilizzabile per questa organization? (flag globale + slug + stato active + key). */ public function isEnabledForOrg(array $org): bool { return self::enabled() && !empty($org['vocea_tenant_slug']) && (($org['vocea_channel_status'] ?? 'none') === 'active') && $this->apiKey !== ''; } /** * URL del portale segnalante (per embed via wb-link.js). * NB: iframe diretto è bloccato dalla CSP `frame-ancestors 'self'` di Vocea → * usare il widget wb-link.js oppure far allargare la CSP al dominio NIS2. * Path reale (confermato smoke 23/6): /{locale}/segnala/{slug}. */ public static function portalUrl(string $slug, string $locale = 'it'): string { $base = rtrim(defined('VOCEA_PORTAL_URL') ? VOCEA_PORTAL_URL : 'https://app.vocea.cloud', '/'); return "{$base}/" . rawurlencode($locale) . '/segnala/' . rawurlencode($slug); } /** URL portale per il tracciamento stato di una segnalazione (/{locale}/stato/{slug}). */ public static function portalStatusUrl(string $slug, string $locale = 'it'): string { $base = rtrim(defined('VOCEA_PORTAL_URL') ? VOCEA_PORTAL_URL : 'https://app.vocea.cloud', '/'); return "{$base}/" . rawurlencode($locale) . '/stato/' . rawurlencode($slug); } // ── Read / management (server-to-server, API key) ──────────────────────── /** Public key del canale (endpoint pubblico NON versionato, no API key). */ public function tenantInfo(string $slug): array { // /tenants/{slug}/info vive sotto /api/wb (NON /api/wb/v1) — confermato smoke 23/6. return $this->request('GET', '/tenants/' . rawurlencode($slug) . '/info', null, false, $this->apiBase); } /** Lista metadati segnalazioni del canale (paginata, SENZA plaintext). */ public function listReports(string $slug, array $query = []): array { $qs = $query ? ('?' . http_build_query($query)) : ''; return $this->request('GET', "/integrations/reports{$qs}"); } /** Stato di una segnalazione tramite codice ricevuta. */ public function getReportStatus(string $slug, string $code): array { return $this->request('GET', '/integrations/reports/' . rawurlencode($code) . '/status'); } /** Invio segnalazione: $encryptedPayload è GIÀ cifrato lato client (zero-knowledge). */ public function submitReport(string $slug, array $encryptedPayload): array { return $this->request('POST', '/integrations/reports/submit', $encryptedPayload); } public function listKeyHolders(string $slug): array { return $this->request('GET', '/integrations/keyholders'); } public function enrollKeyHolder(string $slug, array $keyHolder): array { return $this->request('POST', '/integrations/keyholders', $keyHolder); } // ── HTTP ───────────────────────────────────────────────────────────────── /** * @param bool $auth Se true invia X-API-Key (default). Endpoint pubblici (tenantInfo) → false. * @param ?string $baseOverride Base URL alternativa (es. NON versionata per /tenants/{slug}/info). * @return array ['ok'=>bool,'status'=>int,'data'=>mixed,'error'=>?string] */ private function request(string $method, string $path, ?array $body = null, bool $auth = true, ?string $baseOverride = null): array { if (!self::enabled()) { return ['ok' => false, 'error' => 'DISABLED', 'message' => 'Integrazione Vocea non attiva (VOCEA_ENABLED=false).']; } if ($auth && $this->apiKey === '') { return ['ok' => false, 'error' => 'NOT_PROVISIONED', 'message' => 'VOCEA_API_KEY non configurata (vault tier1__nis2-app__vocea__*).']; } $headers = ['Accept: application/json']; if ($auth) { $headers[] = 'X-API-Key: ' . $this->apiKey; } $ch = curl_init(($baseOverride ?? $this->baseUrl) . $path); $opts = [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => $method, CURLOPT_TIMEOUT => self::TIMEOUT_SEC, CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT_SEC, ]; if ($body !== null) { $opts[CURLOPT_POSTFIELDS] = json_encode($body, JSON_UNESCAPED_UNICODE); $headers[] = 'Content-Type: application/json'; } $opts[CURLOPT_HTTPHEADER] = $headers; curl_setopt_array($ch, $opts); $raw = curl_exec($ch); $status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch); if ($raw === false || $status === 0) { return ['ok' => false, 'status' => 0, 'error' => 'NETWORK', 'message' => $err ?: 'connessione fallita']; } $decoded = json_decode($raw, true); $ok = $status >= 200 && $status < 300; return [ 'ok' => $ok, 'status' => $status, 'data' => $decoded, 'error' => $ok ? null : (($decoded['error']['code'] ?? null) ?: ('HTTP_' . $status)), ]; } }