From 4a5d035c2e6aaf7e80855b93bf920094b43eeeb8 Mon Sep 17 00:00:00 2001 From: Ruben Date: Fri, 16 Jan 2026 00:37:13 +0100 Subject: [PATCH] Add petition form plugin with GDPR-compliant double opt-in email confirmation --- custom/plugins/page/petition-form.php | 1258 +++++++++++++++++++++++++ 1 file changed, 1258 insertions(+) create mode 100644 custom/plugins/page/petition-form.php diff --git a/custom/plugins/page/petition-form.php b/custom/plugins/page/petition-form.php new file mode 100644 index 0000000..cdde2a1 --- /dev/null +++ b/custom/plugins/page/petition-form.php @@ -0,0 +1,1258 @@ += $maxAttempts) { + flock($fp, LOCK_UN); + fclose($fp); + return false; + } + + // Add current attempt + $attempts[] = $currentTime; + + // Rewrite file with cleaned data + rewind($fp); + ftruncate($fp, 0); + + // Write back other IPs' recent attempts + foreach ($otherEntries as $entry) { + fputcsv($fp, $entry, ',', '"', ''); + } + + // Write our attempts + foreach ($attempts as $timestamp) { + fputcsv($fp, [$timestamp, $ipHash], ',', '"', ''); + } + + flock($fp, LOCK_UN); + fclose($fp); + + return true; +} + +/** + * Get translation from language files with section support + */ +function petitionT(Context $ctx, string $section, string $key, array $replacements = []): string { + $translations = $ctx->get('translations', []); + $fullKey = "{$section}.{$key}"; + + $text = $translations[$fullKey] ?? $key; + + foreach ($replacements as $placeholder => $value) { + $text = str_replace("{{$placeholder}}", $value, $text); + } + + return $text; +} + +/** + * Get regions from language file + */ +function petitionGetRegions(Context $ctx): array { + $translations = $ctx->get('translations', []); + $regions = []; + + $regionKeys = [ + 'agder', 'akershus', 'buskerud', 'finnmark', 'innlandet', + 'more_og_romsdal', 'nordland', 'oslo', 'rogaland', 'telemark', + 'troms', 'trondelag', 'vestfold', 'vestland', 'ostfold' + ]; + + foreach ($regionKeys as $key) { + $fullKey = "regions.{$key}"; + $regions[$key] = $translations[$fullKey] ?? $key; + } + + return $regions; +} + +/** + * Get path to petition CSV file + */ +function petitionGetCsvPath(string $petitionId): string { + // Sanitize petition ID + $petitionId = preg_replace('/[^a-z0-9\-_]/i', '', $petitionId); + + // Additional path traversal protection + if (empty($petitionId) || strpos($petitionId, '..') !== false || strpos($petitionId, '/') !== false || strpos($petitionId, '\\') !== false) { + throw new Exception('Invalid petition ID'); + } + + return dirname(__DIR__, 2) . "/data/petitions/{$petitionId}.csv"; +} + +/** + * Extract petition ID from the resolved page directory path + * Uses the last directory segment (the petition's slug) as the ID + */ +function petitionGetIdFromPath(string $pageDir): ?string { + // Get the last directory segment as the petition ID + $petitionSlug = basename($pageDir); + + // Sanitize: remove any leading numeric prefix (e.g., "01-" becomes just the slug part) + // This handles folders like "01-my-petition" -> "my-petition" + $petitionSlug = preg_replace('/^\d+-/', '', $petitionSlug); + + if (empty($petitionSlug)) { + return null; + } + + return $petitionSlug; +} + +/** + * Append a new signature to the CSV file with file locking + * Now includes duplicate email check inside the lock to prevent race conditions + */ +function petitionAppendSignature(string $csvPath, array $data): bool { + $dir = dirname($csvPath); + if (!is_dir($dir)) { + mkdir($dir, 0750, true); + } + + $isNewFile = !file_exists($csvPath); + + // Open for reading and appending + $fp = fopen($csvPath, 'a+'); + if (!$fp) { + return false; + } + + if (flock($fp, LOCK_EX)) { + // Check for duplicate email INSIDE the lock to prevent race conditions + rewind($fp); + $foundDuplicate = false; + + if (!$isNewFile) { + fgetcsv($fp, null, ',', '"', ''); // Skip header + + while (($row = fgetcsv($fp, null, ',', '"', '')) !== false) { + if (isset($row[1]) && strtolower($row[1]) === strtolower($data['email'])) { + $foundDuplicate = true; + break; + } + } + } + + if ($foundDuplicate) { + flock($fp, LOCK_UN); + fclose($fp); + return false; + } + + // Write header if new file + if ($isNewFile) { + fputcsv($fp, ['timestamp', 'email', 'firstname', 'surname', 'region', 'display', 'status', 'token', 'token_created', 'ip_hash'], ',', '"', ''); + } + + // Sanitize user input to prevent CSV injection + fputcsv($fp, [ + $data['timestamp'], + petitionSanitizeCSV($data['email']), + petitionSanitizeCSV($data['firstname']), + petitionSanitizeCSV($data['surname']), + $data['region'], + $data['display'], + $data['status'], + $data['token'], + $data['token_created'], + $data['ip_hash'] + ], ',', '"', ''); + + flock($fp, LOCK_UN); + fclose($fp); + return true; + } + + fclose($fp); + return false; +} + +/** + * Update signature status (for confirmation) + * Includes 30-day token expiry check + */ +function petitionConfirmSignature(string $csvPath, string $token): string { + if (!file_exists($csvPath)) { + return 'error'; + } + + $fp = fopen($csvPath, 'r+'); + if (!$fp) { + return 'error'; + } + + if (!flock($fp, LOCK_EX)) { + fclose($fp); + return 'error'; + } + + $rows = []; + $header = fgetcsv($fp, null, ',', '"', ''); + $rows[] = $header; + + $found = false; + $alreadyConfirmed = false; + $tokenExpired = false; + $currentTime = time(); + + // Token is at index 7, token_created is at index 8 + while (($row = fgetcsv($fp, null, ',', '"', '')) !== false) { + if (isset($row[7]) && $row[7] === $token) { + $found = true; + + // Check if token is expired (30 days = 2592000 seconds) + $tokenCreated = isset($row[8]) ? (int)$row[8] : 0; + if ($tokenCreated > 0 && ($currentTime - $tokenCreated) > 2592000) { + $tokenExpired = true; + } elseif ($row[6] === 'confirmed') { + $alreadyConfirmed = true; + } else { + $row[6] = 'confirmed'; + } + } + $rows[] = $row; + } + + if (!$found) { + flock($fp, LOCK_UN); + fclose($fp); + return 'error'; + } + + if ($tokenExpired) { + flock($fp, LOCK_UN); + fclose($fp); + return 'expired'; + } + + if ($alreadyConfirmed) { + flock($fp, LOCK_UN); + fclose($fp); + return 'already'; + } + + // Rewrite file + rewind($fp); + ftruncate($fp, 0); + + foreach ($rows as $row) { + fputcsv($fp, $row, ',', '"', ''); + } + + flock($fp, LOCK_UN); + fclose($fp); + + return 'success'; +} + +/** + * Delete a signature by token (for GDPR compliance) + */ +function petitionDeleteSignature(string $csvPath, string $token): string { + if (!file_exists($csvPath)) { + return 'error'; + } + + $fp = fopen($csvPath, 'r+'); + if (!$fp) { + return 'error'; + } + + if (!flock($fp, LOCK_EX)) { + fclose($fp); + return 'error'; + } + + $rows = []; + $header = fgetcsv($fp, null, ',', '"', ''); + $rows[] = $header; + + $found = false; + + // Token is at index 7 + while (($row = fgetcsv($fp, null, ',', '"', '')) !== false) { + if (isset($row[7]) && $row[7] === $token) { + $found = true; + // Skip this row (don't add to $rows) to delete it + continue; + } + $rows[] = $row; + } + + if (!$found) { + flock($fp, LOCK_UN); + fclose($fp); + return 'error'; + } + + // Rewrite file without the deleted row + rewind($fp); + ftruncate($fp, 0); + + foreach ($rows as $row) { + fputcsv($fp, $row, ',', '"', ''); + } + + flock($fp, LOCK_UN); + fclose($fp); + + return 'success'; +} + +/** + * Check if email already exists in petition + */ +function petitionEmailExists(string $csvPath, string $email): bool { + if (!file_exists($csvPath)) { + return false; + } + + $fp = fopen($csvPath, 'r'); + if (!$fp) { + return false; + } + + if (flock($fp, LOCK_SH)) { + fgetcsv($fp, null, ',', '"', ''); // Skip header + + while (($row = fgetcsv($fp, null, ',', '"', '')) !== false) { + if (isset($row[1]) && strtolower($row[1]) === strtolower($email)) { + flock($fp, LOCK_UN); + fclose($fp); + return true; + } + } + + flock($fp, LOCK_UN); + } + + fclose($fp); + return false; +} + +/** + * Get all confirmed signatures + */ +function petitionGetConfirmedSignatures(string $csvPath): array { + if (!file_exists($csvPath)) { + return []; + } + + $signatures = []; + $fp = fopen($csvPath, 'r'); + + if (!$fp) { + return []; + } + + if (flock($fp, LOCK_SH)) { + fgetcsv($fp, null, ',', '"', ''); // Skip header + + // CSV format: timestamp, email, firstname, surname, region, display, status, token, ip_hash + while (($row = fgetcsv($fp, null, ',', '"', '')) !== false) { + if (isset($row[6]) && $row[6] === 'confirmed') { + $signatures[] = [ + 'timestamp' => (int)$row[0], + 'firstname' => $row[2], + 'surname' => $row[3], + 'region' => $row[4], + 'display' => $row[5] + ]; + } + } + + flock($fp, LOCK_UN); + } + + fclose($fp); + + // Sort newest first + usort($signatures, fn($a, $b) => $b['timestamp'] - $a['timestamp']); + + return $signatures; +} + +/** + * Format signature for display based on privacy preference + */ +function petitionFormatSignature(array $signature, Context $ctx): string { + $regions = petitionGetRegions($ctx); + $regionDisplay = $regions[$signature['region']] ?? $signature['region']; + + $fromLabel = petitionT($ctx, 'petition', 'from_region'); + + switch ($signature['display']) { + case 'anonymous': + $anonLabel = petitionT($ctx, 'petition', 'anonymous_name'); + return $anonLabel . ' ' . $fromLabel . ' ' . $regionDisplay; + + case 'full': + $fullName = htmlspecialchars($signature['firstname'] . ' ' . $signature['surname']); + return $fullName . ' ' . $fromLabel . ' ' . $regionDisplay; + + case 'semi': + default: + return htmlspecialchars($signature['firstname']) . ' ' . $fromLabel . ' ' . $regionDisplay; + } +} + +/** + * Format timestamp for display + */ +function petitionFormatDate(int $timestamp, Context $ctx): string { + $currentLang = $ctx->get('currentLang', 'no'); + + $months = [ + 'no' => ['januar', 'februar', 'mars', 'april', 'mai', 'juni', + 'juli', 'august', 'september', 'oktober', 'november', 'desember'], + 'en' => ['January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December'] + ]; + + $monthNames = $months[$currentLang] ?? $months['no']; + + $day = date('j', $timestamp); + $month = $monthNames[(int)date('n', $timestamp) - 1]; + $year = date('Y', $timestamp); + $time = date('H:i', $timestamp); + + if ($currentLang === 'en') { + return "{$month} {$day}, {$year}, {$time}"; + } + + return "{$day}. {$month} {$year}, {$time}"; +} + +/** + * Retry wrapper for email sending with exponential backoff + * Retries up to maxRetries times with increasing delays + */ +function petitionSendWithRetry(callable $sendFunction, int $maxRetries = 3): bool { + $delays = [2, 4, 8]; // Exponential backoff: 2s, 4s, 8s + + for ($attempt = 1; $attempt <= $maxRetries; $attempt++) { + $result = $sendFunction(); + + if ($result === true) { + if ($attempt > 1) { + // Log successful retry (no sensitive data) + error_log("Petition email succeeded on attempt {$attempt}/{$maxRetries}"); + } + return true; + } + + // If not the last attempt, wait before retrying + if ($attempt < $maxRetries) { + $delay = $delays[$attempt - 1] ?? 8; + sleep($delay); + } + } + + // All retries failed + error_log("Petition email failed after {$maxRetries} attempts"); + return false; +} + +/** + * Send confirmation email (with retry wrapper) + */ +function petitionSendConfirmationEmail(array $data, string $confirmUrl, string $petitionTitle, Context $ctx): bool { + return petitionSendWithRetry(function() use ($data, $confirmUrl, $petitionTitle, $ctx) { + return petitionSendConfirmationEmailInternal($data, $confirmUrl, $petitionTitle, $ctx); + }); +} + +/** + * Internal: Send confirmation email (single attempt) + */ +function petitionSendConfirmationEmailInternal(array $data, string $confirmUrl, string $petitionTitle, Context $ctx): bool { + // Decode HTML entities for plain-text email (e.g., – → –) + $petitionTitle = html_entity_decode($petitionTitle, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $smtpConfigPath = dirname(__DIR__, 2) . '/smtp-config.php'; + if (!file_exists($smtpConfigPath)) { + return false; + } + + $config = require $smtpConfigPath; + + if (!$config['enabled']) { + return false; + } + + // Get petition-specific overrides + $fromEmail = $config['petition']['from_email'] ?? $config['from_email']; + $fromName = $config['petition']['from_name'] ?? $config['from_name']; + + // Build email body + $greeting = petitionT($ctx, 'petition', 'email_greeting', ['name' => $data['firstname']]); + $thanks = petitionT($ctx, 'petition', 'email_thanks', ['title' => $petitionTitle]); + $confirmText = petitionT($ctx, 'petition', 'email_confirm'); + $expiryNotice = petitionT($ctx, 'petition', 'email_expiry_notice'); + $ignoreText = petitionT($ctx, 'petition', 'email_ignore'); + $rightsInfo = petitionT($ctx, 'petition', 'email_rights_info'); + $signature = petitionT($ctx, 'petition', 'email_signature'); + $org = petitionT($ctx, 'petition', 'email_org'); + + $emailBody = "{$greeting}\n\n"; + $emailBody .= "{$thanks}\n\n"; + $emailBody .= "{$confirmText}\n"; + $emailBody .= "{$confirmUrl}\n\n"; + $emailBody .= "{$expiryNotice}\n\n"; + $emailBody .= "{$ignoreText}\n\n"; + $emailBody .= "---\n\n"; + $emailBody .= "{$rightsInfo}\n\n"; + $emailBody .= "{$signature},\n"; + $emailBody .= "{$org}\n"; + + $subject = petitionT($ctx, 'petition', 'email_subject'); + + // Pre-flight check + $fp = @fsockopen($config['host'], $config['port'], $errno, $errstr, 10); + if (!$fp) { + // Note: Logging SMTP config only, no user data + error_log("Petition SMTP pre-flight failed: {$config['host']}:{$config['port']} - {$errno} - {$errstr}"); + return false; + } + fclose($fp); + + try { + require_once dirname(__DIR__, 2) . '/vendor/PHPMailer.Lite.php'; + + $mail = new \codeworxtech\PHPMailerLite\PHPMailerLite(); + + $mail->SetSMTPhost($config['host']); + $mail->SetSMTPport($config['port']); + $mail->SetSMTPuser($config['username']); + $mail->SetSMTPpass($config['password']); + + $recipientName = $data['firstname'] . ' ' . $data['surname']; + $mail->SetSender([$fromEmail => $fromName]); + $mail->AddRecipient([$data['email'] => $recipientName]); + $mail->SetSubject($subject); + $mail->SetBodyText($emailBody); + + ob_start(); + $sent = @$mail->Send('smtp'); + $output = ob_get_clean(); + + if (!$sent || stripos($output, 'error') !== false || stripos($output, '✗') !== false) { + // Note: Output is already sanitized with strip_tags, no user data exposed + error_log("Petition email send failed: " . strip_tags($output)); + return false; + } + + return true; + } catch (\Exception $e) { + // Note: Exception message doesn't contain user data + error_log("Petition email exception: " . $e->getMessage()); + return false; + } +} + +/** + * Get signature data by token + */ +function petitionGetSignatureByToken(string $csvPath, string $token): ?array { + if (!file_exists($csvPath)) { + return null; + } + + $fp = fopen($csvPath, 'r'); + if (!$fp) { + return null; + } + + $signature = null; + + if (flock($fp, LOCK_SH)) { + fgetcsv($fp, null, ',', '"', ''); // Skip header + + // CSV format: timestamp, email, firstname, surname, region, display, status, token, ip_hash + while (($row = fgetcsv($fp, null, ',', '"', '')) !== false) { + if (isset($row[7]) && $row[7] === $token) { + $signature = [ + 'email' => $row[1], + 'firstname' => $row[2], + 'surname' => $row[3] + ]; + break; + } + } + + flock($fp, LOCK_UN); + } + + fclose($fp); + return $signature; +} + +/** + * Send thank you email with delete link (with retry wrapper) + */ +function petitionSendThankYouEmail(string $token, string $deleteUrl, string $petitionTitle, string $csvPath, Context $ctx): bool { + return petitionSendWithRetry(function() use ($token, $deleteUrl, $petitionTitle, $csvPath, $ctx) { + return petitionSendThankYouEmailInternal($token, $deleteUrl, $petitionTitle, $csvPath, $ctx); + }); +} + +/** + * Internal: Send thank you email with delete link (single attempt) + */ +function petitionSendThankYouEmailInternal(string $token, string $deleteUrl, string $petitionTitle, string $csvPath, Context $ctx): bool { + // Decode HTML entities for plain-text email (e.g., – → –) + $petitionTitle = html_entity_decode($petitionTitle, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + $signature = petitionGetSignatureByToken($csvPath, $token); + if (!$signature) { + return false; + } + + $smtpConfigPath = dirname(__DIR__, 2) . '/smtp-config.php'; + if (!file_exists($smtpConfigPath)) { + return false; + } + + $config = require $smtpConfigPath; + + if (!$config['enabled']) { + return false; + } + + // Get petition-specific overrides + $fromEmail = $config['petition']['from_email'] ?? $config['from_email']; + $fromName = $config['petition']['from_name'] ?? $config['from_name']; + + // Build email body + $greeting = petitionT($ctx, 'petition', 'email_greeting', ['name' => $signature['firstname']]); + $thankYouText = petitionT($ctx, 'petition', 'email_thankyou_confirmed', ['title' => $petitionTitle]); + $deleteText = petitionT($ctx, 'petition', 'email_delete_info'); + $rightsInfo = petitionT($ctx, 'petition', 'email_rights_info'); + $emailSignature = petitionT($ctx, 'petition', 'email_signature'); + $org = petitionT($ctx, 'petition', 'email_org'); + + $emailBody = "{$greeting}\n\n"; + $emailBody .= "{$thankYouText}\n\n"; + $emailBody .= "{$deleteText}\n"; + $emailBody .= "{$deleteUrl}\n\n"; + $emailBody .= "---\n\n"; + $emailBody .= "{$rightsInfo}\n\n"; + $emailBody .= "{$emailSignature},\n"; + $emailBody .= "{$org}\n"; + + $subject = petitionT($ctx, 'petition', 'email_thankyou_subject'); + + // Pre-flight check + $fp = @fsockopen($config['host'], $config['port'], $errno, $errstr, 10); + if (!$fp) { + error_log("Petition SMTP pre-flight failed: {$config['host']}:{$config['port']} - {$errno} - {$errstr}"); + return false; + } + fclose($fp); + + try { + require_once dirname(__DIR__, 2) . '/vendor/PHPMailer.Lite.php'; + + $mail = new \codeworxtech\PHPMailerLite\PHPMailerLite(); + + $mail->SetSMTPhost($config['host']); + $mail->SetSMTPport($config['port']); + $mail->SetSMTPuser($config['username']); + $mail->SetSMTPpass($config['password']); + + $recipientName = $signature['firstname'] . ' ' . $signature['surname']; + $mail->SetSender([$fromEmail => $fromName]); + $mail->AddRecipient([$signature['email'] => $recipientName]); + $mail->SetSubject($subject); + $mail->SetBodyText($emailBody); + + ob_start(); + $sent = @$mail->Send('smtp'); + $output = ob_get_clean(); + + if (!$sent || stripos($output, 'error') !== false || stripos($output, '✗') !== false) { + error_log("Petition thank you email send failed: " . strip_tags($output)); + return false; + } + + return true; + } catch (\Exception $e) { + error_log("Petition thank you email exception: " . $e->getMessage()); + return false; + } +} + +/** + * Render the petition form HTML + */ +function petitionRenderForm(Context $ctx, array $formData, array $errors, bool $showForm): string { + $csrfToken = $_SESSION['csrf_token']; + $currentTime = time(); + $regions = petitionGetRegions($ctx); + + $html = '
'; + // $html .= '

' . htmlspecialchars(petitionT($ctx, 'petition', 'form_title')) . '

'; + + // Error messages + if (!empty($errors)) { + $html .= ''; + } + + if ($showForm) { + $html .= '
'; + + // Honeypot + $html .= ''; + + // Hidden fields + $html .= ''; + $html .= ''; + + // Name fields (firstname + surname on same row) + $html .= '
'; + + $html .= '
'; + $html .= ''; + $html .= ''; + $html .= '
'; + + $html .= '
'; + $html .= ''; + $html .= ''; + $html .= '
'; + + $html .= '
'; + + // Email field + $html .= '
'; + $html .= ''; + $html .= ''; + $html .= '' . htmlspecialchars(petitionT($ctx, 'petition', 'email_help')) . ''; + $html .= '
'; + + // Region dropdown + $html .= '
'; + $html .= ''; + $html .= ''; + $html .= '
'; + + // Display preference + $html .= '
'; + $html .= '' . htmlspecialchars(petitionT($ctx, 'petition', 'display_legend')) . ''; + + $semiChecked = ($formData['display'] === 'semi' || empty($formData['display'])) ? ' checked' : ''; + $html .= ''; + + $anonChecked = $formData['display'] === 'anonymous' ? ' checked' : ''; + $html .= ''; + + $fullChecked = $formData['display'] === 'full' ? ' checked' : ''; + $html .= ''; + + $html .= '
'; + + // Privacy summary box + $langPrefix = $ctx->get('langPrefix', ''); + $privacyUrl = $langPrefix . '/personvern/'; + $html .= '
'; + $html .= '

' . petitionT($ctx, 'petition', 'privacy_summary') . ' '; + $html .= '' . htmlspecialchars(petitionT($ctx, 'petition', 'privacy_policy_link')) . '

'; + $html .= '
'; + + // GDPR Consent checkbox + $consentChecked = isset($formData['gdpr_consent']) && $formData['gdpr_consent'] === 'on' ? ' checked' : ''; + $html .= ''; + + // Submit button + $html .= '
'; + $html .= ''; + $html .= '
'; + + $html .= '
'; + } + + $html .= '
'; + + return $html; +} + +/** + * Render the signatures list HTML + */ +function petitionRenderSignatures(array $signatures, Context $ctx): string { + $count = count($signatures); + + $countText = $count === 1 + ? petitionT($ctx, 'petition', 'signature_count', ['count' => $count]) + : petitionT($ctx, 'petition', 'signature_count_plural', ['count' => $count]); + + $html = '
'; + $html .= '

' . htmlspecialchars(petitionT($ctx, 'petition', 'signatures_title')) . ' (' . $count . ')

'; + + if ($count > 0) { + $html .= '

' . htmlspecialchars(petitionT($ctx, 'petition', 'newest_first')) . '

'; + $html .= ''; + } + + $html .= '
'; + + return $html; +} + +/** + * Resolve URL slug path to actual filesystem path + */ +function petitionResolvePageDir(Context $ctx): ?string { + $requestPath = $ctx->requestPath; + + if (empty($requestPath)) { + return $ctx->contentDir; + } + + $pathParts = explode('/', trim($requestPath, '/')); + $resolvedPath = $ctx->contentDir; + + foreach ($pathParts as $slug) { + $resolved = resolveSlugToFolder($resolvedPath, $slug); + if ($resolved === null) { + return null; + } + $resolvedPath .= '/' . $resolved; + } + + return $resolvedPath; +} + +/** + * Get petition data for the current page + * Can be called directly from PHP content files + */ +function petitionGetPageData(?Context $ctx): ?array { + static $cache = null; + + // Return cached result if already computed + if ($cache !== null) { + return $cache; + } + + if (!$ctx) { + return null; + } + + // Resolve the actual page directory from URL slugs + $pageDir = petitionResolvePageDir($ctx); + if (!$pageDir) { + return null; + } + + $metadata = loadMetadata($pageDir); + + // Only run if petition-form plugin is enabled + $plugins = $metadata['plugins'] ?? ''; + if (strpos($plugins, 'petition-form') === false) { + return null; + } + + // Use petition_id from metadata if set, otherwise derive from URL path + $petitionId = $metadata['petition_id'] ?? petitionGetIdFromPath($pageDir); + if (!$petitionId) { + return null; + } + + $csvPath = petitionGetCsvPath($petitionId); + // loadMetadata() already merges language-specific metadata via Hook::PROCESS_CONTENT + $petitionTitle = $metadata['title'] ?? $petitionId; + $thankYouPage = $metadata['thank_you_page'] ?? 'takk'; + + $formErrors = []; + $formData = ['firstname' => '', 'surname' => '', 'email' => '', 'region' => '', 'display' => 'semi']; + $showForm = true; + $confirmMessage = null; + + // Check for errors and form data from session (Post/Redirect/Get pattern) + if (isset($_SESSION['petition_errors'])) { + $formErrors = $_SESSION['petition_errors']; + unset($_SESSION['petition_errors']); + } + if (isset($_SESSION['petition_form_data'])) { + $formData = array_merge($formData, $_SESSION['petition_form_data']); + unset($_SESSION['petition_form_data']); + } + + // Handle confirmation (GET request with ?confirm=TOKEN) + if (isset($_GET['confirm']) && !empty($_GET['confirm'])) { + $token = preg_replace('/[^a-f0-9]/i', '', $_GET['confirm']); + $result = petitionConfirmSignature($csvPath, $token); + + switch ($result) { + case 'success': + $confirmMessage = ['type' => 'success', 'text' => petitionT($ctx, 'petition', 'confirm_success')]; + // Send thank you email with delete link + $langPrefix = $ctx->get('langPrefix', ''); + $currentPath = trim($ctx->requestPath, '/'); + $protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; + $host = $_SERVER['HTTP_HOST']; + $deleteUrl = "{$protocol}://{$host}{$langPrefix}/{$currentPath}/?delete={$token}#sign-now"; + petitionSendThankYouEmail($token, $deleteUrl, $petitionTitle, $csvPath, $ctx); + break; + case 'already': + $confirmMessage = ['type' => 'info', 'text' => petitionT($ctx, 'petition', 'confirm_already')]; + break; + case 'expired': + $confirmMessage = ['type' => 'error', 'text' => petitionT($ctx, 'petition', 'confirm_expired')]; + break; + default: + $confirmMessage = ['type' => 'error', 'text' => petitionT($ctx, 'petition', 'confirm_error')]; + } + } + + // Handle deletion (GET request with ?delete=TOKEN) + if (isset($_GET['delete']) && !empty($_GET['delete'])) { + $token = preg_replace('/[^a-f0-9]/i', '', $_GET['delete']); + $result = petitionDeleteSignature($csvPath, $token); + + switch ($result) { + case 'success': + $confirmMessage = ['type' => 'warning', 'text' => petitionT($ctx, 'petition', 'delete_success')]; + break; + default: + $confirmMessage = ['type' => 'error', 'text' => petitionT($ctx, 'petition', 'delete_error')]; + } + } + + // Handle form submission + if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['petition_submit'])) { + + // CSRF validation + if (!isset($_POST['csrf_token']) || !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) { + $formErrors[] = petitionT($ctx, 'petition', 'error_csrf'); + } + + // Honeypot check + if (!empty($_POST['website'])) { + $formErrors[] = petitionT($ctx, 'petition', 'error_honeypot'); + } + + // Time-based check + $formStartTime = isset($_POST['form_start_time']) ? (int)$_POST['form_start_time'] : 0; + if (time() - $formStartTime < 3) { + $formErrors[] = petitionT($ctx, 'petition', 'error_time_check'); + } + + // Referrer check + if (!empty($_SERVER['HTTP_REFERER'])) { + $referrer = parse_url($_SERVER['HTTP_REFERER'], PHP_URL_HOST); + $currentHost = $_SERVER['HTTP_HOST']; + if ($referrer && $referrer !== $currentHost && $referrer !== 'localhost') { + $formErrors[] = petitionT($ctx, 'petition', 'error_referrer'); + } + } + + // GDPR Consent validation + if (!isset($_POST['gdpr_consent']) || $_POST['gdpr_consent'] !== 'on') { + $formErrors[] = petitionT($ctx, 'petition', 'gdpr_consent_required'); + } + + // Session-based rate limiting (prevents rapid resubmits) + $lastSubmitTime = $_SESSION['last_petition_submit'] ?? 0; + if (time() - $lastSubmitTime < 60) { + $formErrors[] = petitionT($ctx, 'petition', 'error_rate_limit'); + } + + // IP-based rate limiting (prevents email bombing and spam) + // Max 3 attempts per 5 minutes per IP + if (empty($formErrors) && !petitionCheckIPRateLimit($petitionId, 3, 300)) { + $formErrors[] = petitionT($ctx, 'petition', 'error_ip_rate_limit'); + } + + // Get form data with normalization + $formData['firstname'] = trim($_POST['firstname'] ?? ''); + $formData['surname'] = trim($_POST['surname'] ?? ''); + $formData['email'] = strtolower(trim($_POST['email'] ?? '')); // Normalize email to lowercase + $formData['region'] = trim($_POST['region'] ?? ''); + $formData['display'] = trim($_POST['display'] ?? 'semi'); + $formData['gdpr_consent'] = $_POST['gdpr_consent'] ?? ''; + + // Unicode normalization for names (if available) + if (function_exists('normalizer_normalize')) { + $formData['firstname'] = normalizer_normalize($formData['firstname'], Normalizer::FORM_C); + $formData['surname'] = normalizer_normalize($formData['surname'], Normalizer::FORM_C); + } + + // Validation + if (empty($formData['firstname']) || strlen($formData['firstname']) < 2) { + $formErrors[] = petitionT($ctx, 'petition', 'firstname_required'); + } elseif (strlen($formData['firstname']) > 50) { + $formErrors[] = petitionT($ctx, 'petition', 'firstname_required'); + } + + if (empty($formData['surname']) || strlen($formData['surname']) < 2) { + $formErrors[] = petitionT($ctx, 'petition', 'surname_required'); + } elseif (strlen($formData['surname']) > 50) { + $formErrors[] = petitionT($ctx, 'petition', 'surname_required'); + } + + if (empty($formData['email']) || !filter_var($formData['email'], FILTER_VALIDATE_EMAIL)) { + $formErrors[] = petitionT($ctx, 'petition', 'email_required'); + } elseif (strlen($formData['email']) > 100) { + $formErrors[] = petitionT($ctx, 'petition', 'email_required'); + } + + // Validate region against allowed list + $validRegions = array_keys(petitionGetRegions($ctx)); + if (empty($formData['region']) || !in_array($formData['region'], $validRegions)) { + $formErrors[] = petitionT($ctx, 'petition', 'region_required'); + } + + // Validate display preference + if (!in_array($formData['display'], ['anonymous', 'semi', 'full'])) { + $formData['display'] = 'semi'; + } + + // Note: Duplicate email check now happens inside petitionAppendSignature() + // to prevent race conditions with file locking + + // If valid, save and send confirmation email + if (empty($formErrors)) { + $token = bin2hex(random_bytes(32)); + $ipHash = hash('sha256', $_SERVER['REMOTE_ADDR'] . $petitionId); + $currentTime = time(); + + $signatureData = [ + 'timestamp' => $currentTime, + 'email' => $formData['email'], + 'firstname' => $formData['firstname'], + 'surname' => $formData['surname'], + 'region' => $formData['region'], + 'display' => $formData['display'], + 'status' => 'pending', + 'token' => $token, + 'token_created' => $currentTime, + 'ip_hash' => $ipHash + ]; + + $appendResult = petitionAppendSignature($csvPath, $signatureData); + + if ($appendResult) { + // Build confirmation URL + $langPrefix = $ctx->get('langPrefix', ''); + $currentPath = trim($ctx->requestPath, '/'); + $protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; + $host = $_SERVER['HTTP_HOST']; + $confirmUrl = "{$protocol}://{$host}{$langPrefix}/{$currentPath}/?confirm={$token}#sign-now"; + + // Send confirmation email + if (petitionSendConfirmationEmail($signatureData, $confirmUrl, $petitionTitle, $ctx)) { + $_SESSION['last_petition_submit'] = time(); + + // Regenerate session ID to prevent session fixation + session_regenerate_id(true); + + // Redirect to thank you page + $thankYouUrl = "{$langPrefix}/{$currentPath}/{$thankYouPage}/"; + header("Location: {$thankYouUrl}"); + exit; + } else { + $formErrors[] = petitionT($ctx, 'petition', 'error_email_send'); + } + } else { + // Append failed - likely due to duplicate email (checked inside lock) + $formErrors[] = petitionT($ctx, 'petition', 'error_already_signed'); + } + } + + // If there are errors, store in session and redirect to show them + if (!empty($formErrors)) { + $_SESSION['petition_errors'] = $formErrors; + $_SESSION['petition_form_data'] = $formData; + + $langPrefix = $ctx->get('langPrefix', ''); + $currentPath = trim($ctx->requestPath, '/'); + $redirectUrl = "{$langPrefix}/{$currentPath}/#sign-now"; + header("Location: {$redirectUrl}"); + exit; + } + } + + // Get confirmed signatures + $signatures = petitionGetConfirmedSignatures($csvPath); + + // Build HTML + $formHtml = ''; + + // Show confirmation message if present + if ($confirmMessage) { + $formHtml .= ''; + } + + $formHtml .= petitionRenderForm($ctx, $formData, $formErrors, $showForm); + $signaturesHtml = petitionRenderSignatures($signatures, $ctx); + + // Cache and return results + $cache = [ + 'count' => count($signatures), + 'form' => $formHtml, + 'signatures' => $signaturesHtml + ]; + + return $cache; +} + +// --- Main Hook (for TEMPLATE_VARS) --- + +Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) { + $data = petitionGetPageData($ctx); + + if ($data) { + $vars['petition_count'] = $data['count']; + $vars['petition_form'] = $data['form']; + $vars['petition_signatures'] = $data['signatures']; + } + + return $vars; +});