diff --git a/application/cli/seed_stakeholder_activities.php b/application/cli/seed_stakeholder_activities.php index 9586f7e..f9bcb77 100644 --- a/application/cli/seed_stakeholder_activities.php +++ b/application/cli/seed_stakeholder_activities.php @@ -122,6 +122,13 @@ try { fwrite(STDERR, "WARN ALTER review_schedule: " . $e->getMessage() . "\n"); } +// mig.054 — scadenza magic-link (idempotente: ignora 1060 colonna già esistente). +try { + $pdo->exec("ALTER TABLE stk_activity_targets ADD COLUMN token_expires_at DATETIME NULL DEFAULT NULL AFTER access_token_hash"); +} catch (PDOException $e) { + if (($e->errorInfo[1] ?? 0) !== 1060) { fwrite(STDERR, "WARN ALTER stk_activity_targets: " . $e->getMessage() . "\n"); } +} + $counts = []; foreach (['stk_questionnaire_templates','stk_template_procedures','stk_template_misure','stk_template_requisiti', 'stk_activities','stk_activity_targets','stk_activity_procedures', diff --git a/application/controllers/StakeholderActivityController.php b/application/controllers/StakeholderActivityController.php index 0f0a29e..fb3460d 100644 --- a/application/controllers/StakeholderActivityController.php +++ b/application/controllers/StakeholderActivityController.php @@ -285,7 +285,7 @@ class StakeholderActivityController extends BaseController $orgId = $this->getCurrentOrgId(); $b = $this->getJsonBody(); - $a = Database::fetchOne('SELECT id, title, due_date FROM stk_activities WHERE id = ? AND organization_id = ?', [$id, $orgId]); + $a = Database::fetchOne('SELECT id, title, due_date, assign_mode, stak_code FROM stk_activities WHERE id = ? AND organization_id = ?', [$id, $orgId]); if (!$a) { $this->jsonError('Attività non trovata', 404, 'NOT_FOUND'); } $updates = []; @@ -298,7 +298,10 @@ class StakeholderActivityController extends BaseController if ($this->hasParam('template_id')) { $updates['template_id'] = $this->validateTemplate($b['template_id'] ?? null, $orgId); } if ($this->hasParam('description')) { $updates['description'] = $this->nullableStr($b['description'] ?? null); } if ($this->hasParam('assign_mode') || $this->hasParam('stak_code')) { - [$assignMode, $stakCode] = $this->validateAssign($b, $orgId); + $bb = $b; + // se cambia solo lo stak_code, conserva la modalità esistente (non forzare 'individual') + if (!$this->hasParam('assign_mode')) { $bb['assign_mode'] = $a['assign_mode']; } + [$assignMode, $stakCode] = $this->validateAssign($bb, $orgId); $updates['assign_mode'] = $assignMode; $updates['stak_code'] = $assignMode === 'by_code' ? $stakCode : null; } @@ -358,6 +361,7 @@ class StakeholderActivityController extends BaseController $raw = $b['stakeholder_ids'] ?? null; if (!is_array($raw) || !$raw) { $this->jsonError('Seleziona almeno uno stakeholder', 422, 'NO_TARGETS'); } $ids = array_values(array_unique(array_filter(array_map('intval', $raw), static fn($i) => $i > 0))); + if (!$ids) { $this->jsonError('Seleziona almeno uno stakeholder valido', 422, 'NO_TARGETS'); } $place = implode(',', array_fill(0, count($ids), '?')); $stakeholderIds = $this->valCol( "SELECT id FROM stakeholders WHERE organization_id = ? AND id IN ($place)", @@ -367,6 +371,18 @@ class StakeholderActivityController extends BaseController } if (!$stakeholderIds) { $this->jsonError('Nessuno stakeholder da assegnare per questo criterio', 422, 'EMPTY_TARGETS'); } + $stakeholderIds = array_map('intval', $stakeholderIds); + // Modalità individuale = sincronizzazione "replace": rimuovi i destinatari deselezionati + // che NON hanno ancora risposto/firmato (gli esiti raccolti non si perdono). + if ($a['assign_mode'] === 'individual') { + $keep = implode(',', array_fill(0, count($stakeholderIds), '?')); + Database::query( + "DELETE FROM stk_activity_targets + WHERE activity_id = ? AND state NOT IN ('responded','acknowledged') AND stakeholder_id NOT IN ($keep)", + array_merge([$id], $stakeholderIds) + ); + } + $added = 0; foreach ($stakeholderIds as $sid) { try { @@ -393,13 +409,20 @@ class StakeholderActivityController extends BaseController $a = Database::fetchOne('SELECT id, due_date, title FROM stk_activities WHERE id = ? AND organization_id = ?', [$id, $orgId]); if (!$a) { $this->jsonError('Attività non trovata', 404, 'NOT_FOUND'); } + // Solo i destinatari NON ancora conclusi: NON rigenerare token né azzerare lo stato + // di chi ha già risposto/firmato (altrimenti se ne perderebbe l'esito). $targets = Database::fetchAll( - 'SELECT g.id, g.stakeholder_id, s.name AS stakeholder_name, s.contact_email, g.access_token_hash + "SELECT g.id, g.stakeholder_id, s.name AS stakeholder_name, s.contact_email, g.access_token_hash FROM stk_activity_targets g JOIN stakeholders s ON s.id = g.stakeholder_id - WHERE g.activity_id = ?', + WHERE g.activity_id = ? AND g.state NOT IN ('responded','acknowledged')", [$id] ); - if (!$targets) { $this->jsonError('Nessun destinatario: assegna prima gli stakeholder', 422, 'NO_TARGETS'); } + if (!$targets) { $this->jsonError('Nessun destinatario da inviare (assegna stakeholder o tutti hanno già risposto)', 422, 'NO_TARGETS'); } + + // Scadenza del magic-link: scadenza attività + 30gg, altrimenti 90gg da oggi. + $expires = $a['due_date'] + ? date('Y-m-d H:i:s', strtotime($a['due_date'] . ' +30 days')) + : date('Y-m-d H:i:s', strtotime('+90 days')); $links = []; foreach ($targets as $g) { @@ -408,6 +431,7 @@ class StakeholderActivityController extends BaseController 'access_token_hash' => hash('sha256', $token), 'state' => 'sent', 'sent_at' => date('Y-m-d H:i:s'), + 'token_expires_at' => $expires, ], 'id = ?', [(int) $g['id']]); $links[] = [ 'target_id' => (int) $g['id'], @@ -563,12 +587,13 @@ class StakeholderActivityController extends BaseController 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))); - $blocked = ['php','phtml','phar','php3','php4','php5','phps','cgi','pl','sh','exe','htaccess']; - if ($ext === '' || in_array($ext, $blocked, true)) { $this->jsonError('Tipo di file non consentito', 422, 'BAD_FILE_TYPE'); } + // ALLOWLIST (niente html/svg/js eseguibili same-origin): allineata a StakeholderPortalController::ALLOWED_EXT + $allowed = ['pdf','png','jpg','jpeg','gif','webp','txt','csv','xlsx','xls','docx','doc','pptx','ppt','odt','ods','zip']; + if ($ext === '' || !in_array($ext, $allowed, true)) { $this->jsonError('Tipo di file non consentito', 422, 'BAD_FILE_TYPE'); } $uploadDir = UPLOAD_PATH . "/stk_activity/{$orgId}"; if (!is_dir($uploadDir)) { mkdir($uploadDir, 0755, true); } - $filename = uniqid('sa_') . '.' . $ext; + $filename = 'sa_' . bin2hex(random_bytes(16)) . '.' . $ext; if (!move_uploaded_file($file['tmp_name'], $uploadDir . '/' . $filename)) { $this->jsonError('Errore caricamento file', 500, 'UPLOAD_ERROR'); } diff --git a/application/controllers/StakeholderPortalController.php b/application/controllers/StakeholderPortalController.php index 5e47a97..b6924dc 100644 --- a/application/controllers/StakeholderPortalController.php +++ b/application/controllers/StakeholderPortalController.php @@ -18,12 +18,19 @@ */ require_once __DIR__ . '/BaseController.php'; +require_once APP_PATH . '/services/RateLimitService.php'; class StakeholderPortalController extends BaseController { + // Estensioni consentite per gli allegati (ALLOWLIST: niente html/svg/js eseguibili same-origin) + private const ALLOWED_EXT = ['pdf','png','jpg','jpeg','gif','webp','txt','csv','xlsx','xls','docx','doc','pptx','ppt','odt','ods','zip']; + private const RL_READ = [['max' => 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( @@ -52,6 +59,7 @@ class StakeholderPortalController extends BaseController /** 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'); } @@ -83,6 +91,7 @@ class StakeholderPortalController extends BaseController /** 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'); } @@ -98,7 +107,9 @@ class StakeholderPortalController extends BaseController /** 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', [ @@ -113,20 +124,21 @@ class StakeholderPortalController extends BaseController /** 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))); - $blocked = ['php','phtml','phar','php3','php4','php5','phps','cgi','pl','sh','exe','htaccess']; - if ($ext === '' || in_array($ext, $blocked, true)) { $this->jsonError('Tipo di file non consentito', 422, 'BAD_FILE_TYPE'); } + 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 = uniqid('sa_') . '.' . $ext; + $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, @@ -153,8 +165,8 @@ class StakeholderPortalController extends BaseController $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, s.name AS stakeholder_name, - a.organization_id, a.title, a.type, a.description, a.template_id + '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 @@ -162,9 +174,39 @@ class StakeholderPortalController extends BaseController [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; } diff --git a/docs/sql/054_stakeholder_token_expiry.sql b/docs/sql/054_stakeholder_token_expiry.sql new file mode 100644 index 0000000..63a3c84 --- /dev/null +++ b/docs/sql/054_stakeholder_token_expiry.sql @@ -0,0 +1,17 @@ +-- ===================================================================== +-- 054 — Scadenza dei magic-link del portale stakeholder (Epic C / C5.2 hardening) +-- ===================================================================== +-- Aggiunge stk_activity_targets.token_expires_at: i magic-link generati da +-- StakeholderActivityController::send() ricevono una scadenza (scadenza attività +-- +30gg, altrimenti +90gg). StakeholderPortalController::resolveTarget() rifiuta +-- i token scaduti (410 TOKEN_EXPIRED). Mitiga il riuso indefinito di un link +-- eventualmente trapelato (finding flotta di verifica C5). +-- +-- Additivo, reversibile. Runner-safe: ALTER nuda; idempotenza garantita dal +-- try/catch su errno 1060 nel CLI seed_stakeholder_activities.php. +-- ===================================================================== + +ALTER TABLE stk_activity_targets ADD COLUMN token_expires_at DATETIME NULL DEFAULT NULL AFTER access_token_hash; + +-- ROLLBACK (manuale): +-- ALTER TABLE stk_activity_targets DROP COLUMN token_expires_at diff --git a/public/_app-bi-demo.html b/public/_app-bi-demo.html index f575569..3751e59 100644 --- a/public/_app-bi-demo.html +++ b/public/_app-bi-demo.html @@ -70,9 +70,9 @@ - - - + + + - - - + + + + - + + @@ -165,9 +165,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - + + @@ -372,9 +372,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - + + - - - + + + - + + - - - + + + diff --git a/public/cross-analysis.html b/public/cross-analysis.html index d785029..65a78ea 100644 --- a/public/cross-analysis.html +++ b/public/cross-analysis.html @@ -382,8 +382,8 @@ - - + + @@ -393,8 +393,8 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - + + - + + @@ -154,9 +154,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - + - + + @@ -1152,9 +1152,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - + + @@ -362,9 +362,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - + + - + + @@ -195,9 +195,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + diff --git a/public/js/common.js b/public/js/common.js index 51c6227..eb023d6 100644 --- a/public/js/common.js +++ b/public/js/common.js @@ -204,6 +204,7 @@ function loadSidebar() { { name: 'Policy', href: 'policies.html', icon: iconDocumentText(), i18nKey: 'nav.policies' }, { name: 'Supply Chain', href: 'supply-chain.html', icon: iconLink(), i18nKey: 'nav.supply_chain' }, { name: 'Stakeholder', href: 'stakeholders.html', icon: ``, i18nKey: 'nav.stakeholders' }, + { name: 'Attività stakeholder', href: 'stakeholder-activities.html', icon: ``, i18nKey: 'nav.stk_activities' }, { name: 'Segnalazioni', href: 'whistleblowing.html', icon: `` }, { name: 'Normative', href: 'normative.html', icon: `` }, { name: 'AI Cross-Analysis', href: 'cross-analysis.html', icon: `` }, diff --git a/public/kb.html b/public/kb.html index 82f56a1..a8a6082 100644 --- a/public/kb.html +++ b/public/kb.html @@ -151,8 +151,8 @@ - - + + @@ -161,9 +161,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + + - - + + - + + - - - + + + + diff --git a/public/normative.html b/public/normative.html index 03671bb..192dd33 100644 --- a/public/normative.html +++ b/public/normative.html @@ -112,8 +112,8 @@ - - + + @@ -123,9 +123,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - - + + - + + - - - + + + diff --git a/public/policies.html b/public/policies.html index 02fd052..36c40fc 100644 --- a/public/policies.html +++ b/public/policies.html @@ -333,8 +333,8 @@ - - + + @@ -344,9 +344,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - + + - - - + + + diff --git a/public/register.html b/public/register.html index 1f01cad..cbf5409 100644 --- a/public/register.html +++ b/public/register.html @@ -268,8 +268,8 @@ - - + + - + + @@ -454,9 +454,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - + + - - - + + + - + + @@ -505,9 +505,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - + + @@ -683,9 +683,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - + + - + + - - - + + + - + + - - - + + + - + + @@ -488,9 +488,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - + + @@ -303,9 +303,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - + + @@ -218,9 +218,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - - + + +