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 = ' ' . htmlspecialchars(petitionT($ctx, 'petition', 'newest_first')) . '' . htmlspecialchars(petitionT($ctx, 'petition', 'form_title')) . '
';
+
+ // Error messages
+ if (!empty($errors)) {
+ $html .= '';
+ foreach ($errors as $error) {
+ $html .= '
';
+ $html .= '' . htmlspecialchars(petitionT($ctx, 'petition', 'signatures_title')) . ' (' . $count . ')
';
+
+ if ($count > 0) {
+ $html .= '';
+
+ foreach ($signatures as $sig) {
+ $html .= '
';
+ }
+
+ $html .= '