$email, 'name' => $name, 'list_uuids' => $uuids ]); $url = $config['url'] . '/api/public/subscription'; $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10 ]); $response = curl_exec($ch); $curlError = curl_error($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($curlError) { error_log("Listmonk curl error: {$curlError}"); return false; } if ($httpCode < 200 || $httpCode >= 300) { error_log("Listmonk subscription failed: HTTP {$httpCode}, URL: {$url}, Response: {$response}"); return false; } error_log("Listmonk subscription success for {$email}"); return true; } /** * Check IP-based rate limiting to prevent email bombing and spam * Returns true if IP is allowed, false if rate limit exceeded */ function petitionCheckIPRateLimit(string $petitionId, int $maxAttempts = 3, int $windowSeconds = 300): bool { $ipAddress = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; $ipHash = hash('sha256', $ipAddress . $petitionId); $rateLimitFile = dirname(__DIR__, 2) . '/data/petition-rate-limit.csv'; $dir = dirname($rateLimitFile); if (!is_dir($dir)) { mkdir($dir, 0750, true); } $currentTime = time(); $cutoffTime = $currentTime - $windowSeconds; // Create file if it doesn't exist if (!file_exists($rateLimitFile)) { touch($rateLimitFile); chmod($rateLimitFile, 0644); } $fp = fopen($rateLimitFile, 'a+'); if (!$fp) { // If we can't open file, allow request (fail open for availability) return true; } if (!flock($fp, LOCK_EX)) { fclose($fp); return true; } // Read existing attempts rewind($fp); $attempts = []; $otherEntries = []; while (($row = fgetcsv($fp, null, ',', '"', '')) !== false) { if (!isset($row[0]) || !isset($row[1])) { continue; } $timestamp = (int)$row[0]; $storedIpHash = $row[1]; // Clean up old entries (older than window) if ($timestamp < $cutoffTime) { continue; } // Separate our IP's attempts from others if ($storedIpHash === $ipHash) { $attempts[] = $timestamp; } else { $otherEntries[] = $row; } } // Check if rate limit exceeded $recentAttempts = count($attempts); if ($recentAttempts >= $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}"; } /** * Log email send attempt to SMTP log file * Used for tracking failed sends and enabling retry via CLI */ function petitionLogEmail(string $type, string $petitionId, string $email, bool $success, string $errorMessage = ''): void { $logPath = dirname(__DIR__, 2) . '/data/smtp-log.csv'; $dir = dirname($logPath); if (!is_dir($dir)) { mkdir($dir, 0750, true); } $isNewFile = !file_exists($logPath); $fp = fopen($logPath, 'a'); if (!$fp) { return; } if (flock($fp, LOCK_EX)) { if ($isNewFile) { fputcsv($fp, ['timestamp', 'type', 'petition_id', 'email', 'status', 'error_message'], ',', '"', ''); } fputcsv($fp, [ time(), $type, $petitionId, $email, $success ? 'success' : 'failed', $errorMessage ], ',', '"', ''); flock($fp, LOCK_UN); } fclose($fp); } /** * 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, string $petitionId, Context $ctx): bool { $result = petitionSendWithRetry(function() use ($data, $confirmUrl, $petitionTitle, $ctx, &$errorMessage) { return petitionSendConfirmationEmailInternal($data, $confirmUrl, $petitionTitle, $ctx, $errorMessage); }); // Log the final result petitionLogEmail('confirmation', $petitionId, $data['email'], $result, $result ? '' : ($errorMessage ?? 'Unknown error')); return $result; } /** * Internal: Send confirmation email (single attempt) */ function petitionSendConfirmationEmailInternal(array $data, string $confirmUrl, string $petitionTitle, Context $ctx, ?string &$errorMessage = null): 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)) { $errorMessage = 'SMTP config not found'; return false; } $config = require $smtpConfigPath; if (!$config['enabled']) { $errorMessage = 'SMTP disabled'; 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'); // Get petition-specific SMTP settings (allows separate SMTP account for better deliverability) $smtpHost = $config['petition']['host'] ?? $config['host']; $smtpPort = $config['petition']['port'] ?? $config['port']; $smtpUser = $config['petition']['username'] ?? $config['username']; $smtpPass = $config['petition']['password'] ?? $config['password']; // Pre-flight check $fp = @fsockopen($smtpHost, $smtpPort, $errno, $errstr, 10); if (!$fp) { $errorMessage = "Connection failed: {$errno} - {$errstr}"; error_log("Petition SMTP pre-flight failed: {$smtpHost}:{$smtpPort} - {$errno} - {$errstr}"); return false; } fclose($fp); try { require_once dirname(__DIR__, 2) . '/vendor/PHPMailer.Lite.php'; $mail = new \codeworxtech\PHPMailerLite\PHPMailerLite(); $mail->SetSMTPhost($smtpHost); $mail->SetSMTPport($smtpPort); $mail->SetSMTPuser($smtpUser); $mail->SetSMTPpass($smtpPass); $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) { $errorMessage = strip_tags($output) ?: 'Send returned false'; error_log("Petition email send failed: " . strip_tags($output)); return false; } return true; } catch (\Exception $e) { $errorMessage = $e->getMessage(); 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; } /** * Get pending signature data by email address * Returns signature data if found and pending, null otherwise */ function petitionGetPendingSignatureByEmail(string $csvPath, string $email): ?array { if (!file_exists($csvPath)) { return null; } $fp = fopen($csvPath, 'r'); if (!$fp) { return null; } $signature = null; $email = strtolower(trim($email)); if (flock($fp, LOCK_SH)) { fgetcsv($fp, null, ',', '"', ''); // Skip header // CSV format: timestamp, email, firstname, surname, region, display, status, token, token_created, ip_hash while (($row = fgetcsv($fp, null, ',', '"', '')) !== false) { if (isset($row[1]) && strtolower($row[1]) === $email) { $signature = [ 'email' => $row[1], 'firstname' => $row[2], 'surname' => $row[3], 'region' => $row[4], 'display' => $row[5], 'status' => $row[6], 'token' => $row[7], 'token_created' => $row[8] ?? 0 ]; break; } } flock($fp, LOCK_UN); } fclose($fp); return $signature; } /** * Update token for an existing signature (for resend functionality) */ function petitionUpdateSignatureToken(string $csvPath, string $email, string $newToken): bool { if (!file_exists($csvPath)) { return false; } $fp = fopen($csvPath, 'r+'); if (!$fp) { return false; } if (!flock($fp, LOCK_EX)) { fclose($fp); return false; } $rows = []; $header = fgetcsv($fp, null, ',', '"', ''); $rows[] = $header; $found = false; $email = strtolower(trim($email)); $currentTime = time(); while (($row = fgetcsv($fp, null, ',', '"', '')) !== false) { if (isset($row[1]) && strtolower($row[1]) === $email && $row[6] === 'pending') { $row[7] = $newToken; // Update token $row[8] = $currentTime; // Update token_created $found = true; } $rows[] = $row; } if (!$found) { flock($fp, LOCK_UN); fclose($fp); return false; } // Rewrite file rewind($fp); ftruncate($fp, 0); foreach ($rows as $row) { fputcsv($fp, $row, ',', '"', ''); } flock($fp, LOCK_UN); fclose($fp); return true; } /** * Send thank you email with delete link (with retry wrapper) */ function petitionSendThankYouEmail(string $token, string $deleteUrl, string $petitionTitle, string $petitionId, string $csvPath, Context $ctx): bool { $signature = petitionGetSignatureByToken($csvPath, $token); $email = $signature['email'] ?? 'unknown'; $result = petitionSendWithRetry(function() use ($token, $deleteUrl, $petitionTitle, $csvPath, $ctx, &$errorMessage) { return petitionSendThankYouEmailInternal($token, $deleteUrl, $petitionTitle, $csvPath, $ctx, $errorMessage); }); // Log the final result petitionLogEmail('thankyou', $petitionId, $email, $result, $result ? '' : ($errorMessage ?? 'Unknown error')); return $result; } /** * Internal: Send thank you email with delete link (single attempt) */ function petitionSendThankYouEmailInternal(string $token, string $deleteUrl, string $petitionTitle, string $csvPath, Context $ctx, ?string &$errorMessage = null): 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) { $errorMessage = 'Signature not found'; return false; } $smtpConfigPath = dirname(__DIR__, 2) . '/smtp-config.php'; if (!file_exists($smtpConfigPath)) { $errorMessage = 'SMTP config not found'; return false; } $config = require $smtpConfigPath; if (!$config['enabled']) { $errorMessage = 'SMTP disabled'; 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'); // Get petition-specific SMTP settings (allows separate SMTP account for better deliverability) $smtpHost = $config['petition']['host'] ?? $config['host']; $smtpPort = $config['petition']['port'] ?? $config['port']; $smtpUser = $config['petition']['username'] ?? $config['username']; $smtpPass = $config['petition']['password'] ?? $config['password']; // Pre-flight check $fp = @fsockopen($smtpHost, $smtpPort, $errno, $errstr, 10); if (!$fp) { $errorMessage = "Connection failed: {$errno} - {$errstr}"; error_log("Petition SMTP pre-flight failed: {$smtpHost}:{$smtpPort} - {$errno} - {$errstr}"); return false; } fclose($fp); try { require_once dirname(__DIR__, 2) . '/vendor/PHPMailer.Lite.php'; $mail = new \codeworxtech\PHPMailerLite\PHPMailerLite(); $mail->SetSMTPhost($smtpHost); $mail->SetSMTPport($smtpPort); $mail->SetSMTPuser($smtpUser); $mail->SetSMTPpass($smtpPass); $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) { $errorMessage = strip_tags($output) ?: 'Send returned false'; error_log("Petition thank you email send failed: " . strip_tags($output)); return false; } return true; } catch (\Exception $e) { $errorMessage = $e->getMessage(); 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 + Newsletter subscription $consentChecked = isset($formData['gdpr_consent']) && $formData['gdpr_consent'] === 'on' ? ' checked' : ''; $newsletterChecked = isset($formData['newsletter']) && $formData['newsletter'] === '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 // Use petition_title if set (for subpages), otherwise fall back to page title $petitionTitle = $metadata['petition_title'] ?? $metadata['title'] ?? $petitionId; $thankYouPage = $metadata['thank_you_page'] ?? 'takk'; $newsletterListUuids = !empty($metadata['newsletter_list_uuids']) ? array_map('trim', explode(',', $metadata['newsletter_list_uuids'])) : []; $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, $petitionId, $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 resend confirmation request (POST from resend page) if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['petition_resend'])) { $resendEmail = strtolower(trim($_POST['resend_email'] ?? '')); // Build petition URL for the result message $langPrefix = $ctx->get('langPrefix', ''); $currentPath = trim($ctx->requestPath, '/'); $petitionPath = preg_replace('#/(takk|send-bekreftelse-pa-nytt)$#', '', $currentPath); $petitionUrl = "{$langPrefix}/{$petitionPath}/#sign-now"; // Rate limit check (reuse existing IP rate limiting) if (!petitionCheckIPRateLimit($petitionId . '-resend', 3, 300)) { $confirmMessage = ['type' => 'error', 'text' => petitionT($ctx, 'petition', 'resend_rate_limit')]; } elseif (empty($resendEmail) || !filter_var($resendEmail, FILTER_VALIDATE_EMAIL)) { $confirmMessage = ['type' => 'error', 'text' => petitionT($ctx, 'petition', 'email_required')]; } else { // Look up signature by email and attempt to resend if pending $signature = petitionGetPendingSignatureByEmail($csvPath, $resendEmail); if ($signature !== null && $signature['status'] === 'pending') { // Generate new token and send email $newToken = bin2hex(random_bytes(32)); if (petitionUpdateSignatureToken($csvPath, $resendEmail, $newToken)) { $protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; $host = $_SERVER['HTTP_HOST']; $confirmUrl = "{$protocol}://{$host}{$langPrefix}/{$petitionPath}/?confirm={$newToken}#sign-now"; $signatureData = [ 'email' => $signature['email'], 'firstname' => $signature['firstname'], 'surname' => $signature['surname'] ]; // Send email (ignore result - show same message regardless) petitionSendConfirmationEmail($signatureData, $confirmUrl, $petitionTitle, $petitionId, $ctx); } } // Always show the same generic message (privacy: don't reveal if email exists) $resultText = petitionT($ctx, 'petition', 'resend_result', ['petition_url' => $petitionUrl]); $confirmMessage = ['type' => 'info', 'text' => $resultText, 'html' => true]; } // Store message in session and redirect back (PRG pattern) $_SESSION['petition_resend_message'] = $confirmMessage; header("Location: {$langPrefix}/{$currentPath}/"); exit; } // 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, $petitionId, $ctx)) { // Subscribe to newsletter if opted in (fire and forget - don't block petition) $newsletterOptIn = isset($_POST['newsletter']) && $_POST['newsletter'] === 'on'; error_log("Newsletter checkbox: " . ($newsletterOptIn ? 'checked' : 'not checked')); if ($newsletterOptIn) { $fullName = $formData['firstname'] . ' ' . $formData['surname']; petitionSubscribeToNewsletter($formData['email'], $fullName, $newsletterListUuids); } $_SESSION['last_petition_submit'] = time(); $_SESSION['petition_subscribed_newsletter'] = $newsletterOptIn; // 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']; } // Check for newsletter subscription flag (for thank you page) if (isset($_SESSION['petition_subscribed_newsletter'])) { $vars['petition_subscribed_newsletter'] = $_SESSION['petition_subscribed_newsletter']; unset($_SESSION['petition_subscribed_newsletter']); } // Check for resend confirmation message (for thank you page) if (isset($_SESSION['petition_resend_message'])) { $vars['petition_resend_message'] = $_SESSION['petition_resend_message']; unset($_SESSION['petition_resend_message']); } return $vars; });