30, 'window_seconds' => 60], ['max' => 200, 'window_seconds' => 3600]]; private const RL_WRITE = [['max' => 10, 'window_seconds' => 60], ['max' => 60, 'window_seconds' => 3600]]; /** GET /api/stakeholder-portal/access?t= */ public function access(): void { $this->rateLimit('read'); $tg = $this->resolveTarget(); $template = $this->loadTemplate($tg); $resp = Database::fetchOne( 'SELECT answers, acknowledged_at, submitted_at FROM stk_activity_responses WHERE target_id = ?', [(int) $tg['target_id']] ); $existing = null; if ($resp) { $ans = $resp['answers'] ? json_decode($resp['answers'], true) : null; $existing = [ 'answers' => is_array($ans) ? $ans : null, 'acknowledged_at' => $resp['acknowledged_at'], 'submitted_at' => $resp['submitted_at'], ]; } $this->jsonSuccess([ 'activity' => ['title' => $tg['title'], 'type' => $tg['type'], 'description' => $tg['description']], 'stakeholder_name' => $tg['stakeholder_name'], 'state' => $tg['state'], 'template' => $template, 'submitted' => in_array($tg['state'], ['responded', 'acknowledged'], true), 'existing' => $existing, ]); } /** POST /api/stakeholder-portal/respond Body: {t*, answers*, respondent_name?} */ public function respond(): void { $this->rateLimit('write'); $tg = $this->resolveTarget(); if ($tg['type'] === 'read_ack') { $this->jsonError('Questa attività richiede una firma di avvenuta lettura, non un questionario.', 422, 'WRONG_TYPE'); } if (in_array($tg['state'], ['responded', 'acknowledged'], true)) { $this->jsonError('Risposta già inviata: non è più modificabile.', 409, 'ALREADY_SUBMITTED'); } $b = $this->getJsonBody(); $answers = $b['answers'] ?? null; if (!is_array($answers) || !$answers) { $this->jsonError('Nessuna risposta fornita', 422, 'NO_ANSWERS'); } // verifica risposte obbligatorie del template (se presente) $template = $this->loadTemplate($tg); if ($template && !empty($template['questions'])) { $missing = []; foreach ($template['questions'] as $q) { if (!empty($q['required'])) { $code = $q['code'] ?? ''; $v = $answers[$code] ?? null; if ($v === null || $v === '' || (is_array($v) && !$v)) { $missing[] = $code; } } } if ($missing) { $this->jsonError('Mancano risposte obbligatorie.', 422, 'REQUIRED_MISSING', ['missing' => $missing]); } } $this->saveResponse((int) $tg['target_id'], json_encode($answers, JSON_UNESCAPED_UNICODE), null, $b['respondent_name'] ?? null); Database::update('stk_activity_targets', ['state' => 'responded', 'responded_at' => date('Y-m-d H:i:s')], 'id = ?', [(int) $tg['target_id']]); $this->maybeComplete((int) $tg['activity_id']); $this->jsonSuccess(['state' => 'responded'], 'Grazie, risposta inviata.'); } /** POST /api/stakeholder-portal/acknowledge Body: {t*, respondent_name?} */ public function acknowledge(): void { $this->rateLimit('write'); $tg = $this->resolveTarget(); if ($tg['type'] !== 'read_ack') { $this->jsonError('Questa attività è un questionario da compilare.', 422, 'WRONG_TYPE'); } if (in_array($tg['state'], ['responded', 'acknowledged'], true)) { $this->jsonError('Firma già registrata.', 409, 'ALREADY_SUBMITTED'); } $b = $this->getJsonBody(); $now = date('Y-m-d H:i:s'); $this->saveResponse((int) $tg['target_id'], null, $now, $b['respondent_name'] ?? null); Database::update('stk_activity_targets', ['state' => 'acknowledged', 'responded_at' => $now], 'id = ?', [(int) $tg['target_id']]); $this->maybeComplete((int) $tg['activity_id']); $this->jsonSuccess(['state' => 'acknowledged'], 'Firma di avvenuta lettura registrata. Grazie.'); } /** POST /api/stakeholder-portal/comment Body: {t*, body*} */ public function comment(): void { $this->rateLimit('write'); $tg = $this->resolveTarget(); $this->assertWritable($tg); $body = trim((string) ($this->getJsonBody()['body'] ?? '')); if ($body === '') { $this->jsonError('Commento vuoto', 422, 'EMPTY_COMMENT'); } Database::insert('stk_activity_comments', [ 'activity_id' => (int) $tg['activity_id'], 'body' => mb_substr($body, 0, 5000), 'author_kind' => 'external', 'author_label'=> mb_substr((string) $tg['stakeholder_name'], 0, 255), ]); $this->jsonSuccess(null, 'Commento inviato.'); } /** POST /api/stakeholder-portal/attachment?t= (multipart: file) */ public function attachment(): void { $this->rateLimit('write'); $tg = $this->resolveTarget(); $this->assertWritable($tg); if (!isset($_FILES['file']) || ($_FILES['file']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { $this->jsonError('File non fornito', 400, 'NO_FILE'); } $file = $_FILES['file']; if ($file['size'] > 10 * 1024 * 1024) { $this->jsonError('File troppo grande (max 10MB)', 400, 'FILE_TOO_LARGE'); } $ext = strtolower(preg_replace('/[^a-zA-Z0-9]/', '', pathinfo($file['name'], PATHINFO_EXTENSION))); if ($ext === '' || !in_array($ext, self::ALLOWED_EXT, true)) { $this->jsonError('Tipo di file non consentito', 422, 'BAD_FILE_TYPE'); } $orgId = (int) $tg['organization_id']; $dir = UPLOAD_PATH . "/stk_activity/{$orgId}"; if (!is_dir($dir)) { mkdir($dir, 0755, true); } $filename = 'sa_' . bin2hex(random_bytes(16)) . '.' . $ext; if (!move_uploaded_file($file['tmp_name'], $dir . '/' . $filename)) { $this->jsonError('Errore caricamento', 500, 'UPLOAD_ERROR'); } Database::insert('evidence_files', [ 'organization_id' => $orgId, 'entity_type' => 'stk_activity', 'entity_id' => (int) $tg['activity_id'], 'file_name' => mb_substr((string) $file['name'], 0, 255), 'file_path' => "stk_activity/{$orgId}/{$filename}", 'file_size' => (int) $file['size'], 'mime_type' => mb_substr((string) ($file['type'] ?? ''), 0, 100), 'uploaded_by' => null, ]); $this->jsonSuccess(null, 'Allegato caricato.', 201); } // ───────────────────────────────────────────────────────────────────────── // HELPER // ───────────────────────────────────────────────────────────────────────── /** Risolve il destinatario dal token (?t= o body.t). 401 se assente, 404 se non valido. */ private function resolveTarget(): array { $token = trim((string) ($_GET['t'] ?? $this->getJsonBody()['t'] ?? '')); if ($token === '' || !preg_match('/^[a-f0-9]{48}$/', $token)) { $this->jsonError('Link di accesso mancante o non valido.', 401, 'MISSING_TOKEN'); } $row = Database::fetchOne( 'SELECT g.id AS target_id, g.activity_id, g.state, g.token_expires_at, s.name AS stakeholder_name, a.organization_id, a.title, a.type, a.description, a.template_id, a.status AS activity_status FROM stk_activity_targets g JOIN stk_activities a ON a.id = g.activity_id JOIN stakeholders s ON s.id = g.stakeholder_id WHERE g.access_token_hash = ?', [hash('sha256', $token)] ); if (!$row) { $this->jsonError('Link di accesso non valido o scaduto.', 404, 'INVALID_TOKEN'); } if (!empty($row['token_expires_at']) && strtotime($row['token_expires_at']) < time()) { $this->jsonError('Link di accesso scaduto. Richiedi un nuovo invio all\'organizzazione.', 410, 'TOKEN_EXPIRED'); } return $row; } /** Le scritture (commenti/allegati) sono bloccate se l'attività è chiusa. */ private function assertWritable(array $tg): void { if (in_array($tg['activity_status'] ?? '', ['completed', 'cancelled'], true)) { $this->jsonError('Attività chiusa: non sono più ammessi contributi.', 409, 'ACTIVITY_CLOSED'); } } /** Rate limit per-IP sugli endpoint pubblici del portale. */ private function rateLimit(string $kind): void { $ip = $this->getClientIP(); $limits = $kind === 'write' ? self::RL_WRITE : self::RL_READ; RateLimitService::check("stkp_{$kind}_ip:" . $ip, $limits); RateLimitService::increment("stkp_{$kind}_ip:" . $ip); } private function getClientIP(): 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 loadTemplate(array $tg): ?array { if (empty($tg['template_id'])) { return null; } $t = Database::fetchOne('SELECT kind, content, questions FROM stk_questionnaire_templates WHERE id = ?', [(int) $tg['template_id']]); if (!$t) { return null; } $q = $t['questions'] ? json_decode($t['questions'], true) : []; return ['kind' => $t['kind'], 'content' => $t['content'], 'questions' => is_array($q) ? $q : []]; } /** Upsert della risposta (una per target). */ private function saveResponse(int $targetId, ?string $answersJson, ?string $ackAt, $respondentName): void { $ip = $_SERVER['REMOTE_ADDR'] ?? null; $name = $respondentName !== null ? mb_substr(trim((string) $respondentName), 0, 255) : null; Database::query( 'INSERT INTO stk_activity_responses (target_id, answers, acknowledged_at, respondent_name, ip_address, submitted_at) VALUES (?, ?, ?, ?, ?, NOW()) ON DUPLICATE KEY UPDATE answers = VALUES(answers), acknowledged_at = VALUES(acknowledged_at), respondent_name = VALUES(respondent_name), ip_address = VALUES(ip_address), submitted_at = NOW()', [$targetId, $answersJson, $ackAt, $name, $ip] ); } /** Se tutti i destinatari hanno risposto/firmato, segna l'attività 'completed'. */ private function maybeComplete(int $activityId): void { $tot = (int) Database::fetchOne('SELECT COUNT(*) AS c FROM stk_activity_targets WHERE activity_id = ?', [$activityId])['c']; $done = (int) Database::fetchOne( "SELECT COUNT(*) AS c FROM stk_activity_targets WHERE activity_id = ? AND state IN ('responded','acknowledged')", [$activityId] )['c']; if ($tot > 0 && $done >= $tot) { Database::update('stk_activities', ['status' => 'completed'], 'id = ?', [$activityId]); } else { Database::query("UPDATE stk_activities SET status = 'in_progress' WHERE id = ? AND status = 'sent'", [$activityId]); } } }