innhold/custom/plugins/page/petition-form.php

1570 lines
54 KiB
PHP
Raw Normal View History

<?php
/**
* Petition Form Plugin
*
* GDPR-compliant petition system with double opt-in email confirmation.
* File-based storage (CSV) with proper locking to prevent corruption.
*
* Usage: Add to metadata.ini:
* plugins = "petition-form"
* petition_id = "my-petition"
* thank_you_page = "takk" (optional, defaults to "takk")
*/
// Start session if not already started
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
// Generate CSRF token if not exists
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
// --- Helper Functions ---
/**
* Sanitize value to prevent CSV injection attacks
* Prefixes values starting with dangerous characters that could be interpreted as formulas
*/
function petitionSanitizeCSV(string $value): string {
if (empty($value)) {
return $value;
}
$firstChar = substr($value, 0, 1);
$dangerousChars = ['=', '+', '-', '@', "\t", "\r", "\n"];
if (in_array($firstChar, $dangerousChars, true)) {
return "'" . $value;
}
return $value;
}
/**
* Subscribe email to Listmonk newsletter lists via public API
* Listmonk handles double opt-in (sends its own confirmation email)
*/
function petitionSubscribeToNewsletter(string $email, string $name): bool {
$configPath = dirname(__DIR__, 2) . '/listmonk-config.php';
if (!file_exists($configPath)) {
error_log("Listmonk config not found: {$configPath}");
return false;
}
$config = require $configPath;
if (!$config['enabled']) {
error_log("Listmonk disabled in config");
return false;
}
// Log the UUIDs being used
error_log("Listmonk attempting subscription with UUIDs: " . implode(', ', $config['list_uuids']));
$payload = json_encode([
'email' => $email,
'name' => $name,
'list_uuids' => $config['list_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., &ndash; → )
$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, '&#10007;') !== 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., &ndash; → )
$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, '&#10007;') !== 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 = '<section class="petition-form">';
// $html .= '<h2>' . htmlspecialchars(petitionT($ctx, 'petition', 'form_title')) . '</h2>';
// Error messages
if (!empty($errors)) {
$html .= '<div class="form-errors" role="alert">';
$html .= '<ul>';
foreach ($errors as $error) {
$html .= '<li>' . htmlspecialchars($error) . '</li>';
}
$html .= '</ul>';
$html .= '</div>';
}
if ($showForm) {
$html .= '<form method="post" action="' . htmlspecialchars($_SERVER['REQUEST_URI']) . '" class="petition-form-inner">';
// Honeypot
$html .= '<div class="hp-field" aria-hidden="true" style="position:absolute;left:-9999px">';
$html .= '<label for="website">Website</label>';
$html .= '<input type="text" id="website" name="website" tabindex="-1" autocomplete="off">';
$html .= '</div>';
// Hidden fields
$html .= '<input type="hidden" name="csrf_token" value="' . htmlspecialchars($csrfToken) . '">';
$html .= '<input type="hidden" name="form_start_time" value="' . $currentTime . '">';
// Name fields (firstname + surname on same row)
$html .= '<div class="form-row">';
$html .= '<div class="form-group">';
$html .= '<label for="petition_firstname">' . htmlspecialchars(petitionT($ctx, 'petition', 'firstname_label')) . ' <span class="required">*</span></label>';
$html .= '<input type="text" id="petition_firstname" name="firstname" value="' . htmlspecialchars($formData['firstname']) . '" required maxlength="50" placeholder="' . htmlspecialchars(petitionT($ctx, 'petition', 'firstname_placeholder')) . '">';
$html .= '</div>';
$html .= '<div class="form-group">';
$html .= '<label for="petition_surname">' . htmlspecialchars(petitionT($ctx, 'petition', 'surname_label')) . ' <span class="required">*</span></label>';
$html .= '<input type="text" id="petition_surname" name="surname" value="' . htmlspecialchars($formData['surname']) . '" required maxlength="50" placeholder="' . htmlspecialchars(petitionT($ctx, 'petition', 'surname_placeholder')) . '">';
$html .= '</div>';
$html .= '</div>';
// Email field
$html .= '<div class="form-group">';
$html .= '<label for="petition_email">' . htmlspecialchars(petitionT($ctx, 'petition', 'email_label')) . ' <span class="required">*</span></label>';
$html .= '<input type="email" id="petition_email" name="email" value="' . htmlspecialchars($formData['email']) . '" required maxlength="100" placeholder="' . htmlspecialchars(petitionT($ctx, 'petition', 'email_placeholder')) . '">';
$html .= '<small>' . htmlspecialchars(petitionT($ctx, 'petition', 'email_help')) . '</small>';
$html .= '</div>';
// Region dropdown
$html .= '<div class="form-group">';
$html .= '<label for="petition_region">' . htmlspecialchars(petitionT($ctx, 'petition', 'region_label')) . ' <span class="required">*</span></label>';
$html .= '<select id="petition_region" name="region" required>';
$html .= '<option value="">' . htmlspecialchars(petitionT($ctx, 'petition', 'region_placeholder')) . '</option>';
foreach ($regions as $key => $label) {
$selected = $formData['region'] === $key ? ' selected' : '';
$html .= '<option value="' . htmlspecialchars($key) . '"' . $selected . '>' . htmlspecialchars($label) . '</option>';
}
$html .= '</select>';
$html .= '</div>';
// Display preference
$html .= '<fieldset class="form-group">';
$html .= '<legend>' . htmlspecialchars(petitionT($ctx, 'petition', 'display_legend')) . '</legend>';
$semiChecked = ($formData['display'] === 'semi' || empty($formData['display'])) ? ' checked' : '';
$html .= '<label class="radio-label">';
$html .= '<input type="radio" name="display" value="semi"' . $semiChecked . '>';
$html .= ' ' . htmlspecialchars(petitionT($ctx, 'petition', 'display_semi'));
$html .= '</label>';
$anonChecked = $formData['display'] === 'anonymous' ? ' checked' : '';
$html .= '<label class="radio-label">';
$html .= '<input type="radio" name="display" value="anonymous"' . $anonChecked . '>';
$html .= ' ' . htmlspecialchars(petitionT($ctx, 'petition', 'display_anonymous'));
$html .= '</label>';
$fullChecked = $formData['display'] === 'full' ? ' checked' : '';
$html .= '<label class="radio-label">';
$html .= '<input type="radio" name="display" value="full"' . $fullChecked . '>';
$html .= ' ' . htmlspecialchars(petitionT($ctx, 'petition', 'display_full'));
$html .= '</label>';
$html .= '</fieldset>';
// Privacy summary box
$langPrefix = $ctx->get('langPrefix', '');
$privacyUrl = $langPrefix . '/personvern/';
$html .= '<div class="form-group info-box info-box--yellow">';
$html .= '<p>' . petitionT($ctx, 'petition', 'privacy_summary') . ' ';
$html .= '<a href="' . htmlspecialchars($privacyUrl) . '" target="_blank">' . htmlspecialchars(petitionT($ctx, 'petition', 'privacy_policy_link')) . '</a></p>';
$html .= '</div>';
// GDPR Consent checkbox + Newsletter subscription
$consentChecked = isset($formData['gdpr_consent']) && $formData['gdpr_consent'] === 'on' ? ' checked' : '';
$newsletterChecked = isset($formData['newsletter']) && $formData['newsletter'] === 'on' ? ' checked' : '';
$html .= '<div class="form-group info-box info-box--green consent-group">';
$html .= '<label class="consent-label">';
$html .= '<input type="checkbox" name="gdpr_consent" id="gdpr_consent" required' . $consentChecked . '> ';
$html .= '<span>' . petitionT($ctx, 'petition', 'gdpr_consent_text') . ' <span class="required">*</span></span>';
$html .= '</label>';
$html .= '<label class="consent-label">';
$html .= '<input type="checkbox" name="newsletter" id="newsletter"' . $newsletterChecked . '> ';
$html .= '<span>' . htmlspecialchars(petitionT($ctx, 'petition', 'newsletter_subscribe')) . '</span>';
$html .= '</label>';
$html .= '</div>';
// Submit button
$html .= '<div class="form-group">';
$html .= '<button type="submit" name="petition_submit" class="button">' . htmlspecialchars(petitionT($ctx, 'petition', 'submit_button')) . '</button>';
$html .= '</div>';
$html .= '</form>';
}
$html .= '</section>';
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 = '<section class="petition-signatures">';
$html .= '<h2>' . htmlspecialchars(petitionT($ctx, 'petition', 'signatures_title')) . ' (' . $count . ')</h2>';
if ($count > 0) {
$html .= '<p class="signature-order">' . htmlspecialchars(petitionT($ctx, 'petition', 'newest_first')) . '</p>';
$html .= '<ul class="signature-list">';
foreach ($signatures as $sig) {
$html .= '<li>';
$html .= '<span class="signature-name">' . petitionFormatSignature($sig, $ctx) . '</span>';
$html .= '<span class="signature-date">' . petitionFormatDate($sig['timestamp'], $ctx) . '</span>';
$html .= '</li>';
}
$html .= '</ul>';
}
$html .= '</section>';
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';
$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 thank-you page)
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['petition_resend'])) {
$resendEmail = strtolower(trim($_POST['resend_email'] ?? ''));
// 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
$signature = petitionGetPendingSignatureByEmail($csvPath, $resendEmail);
if ($signature === null) {
// Email not found at all
$confirmMessage = ['type' => 'error', 'text' => petitionT($ctx, 'petition', 'resend_not_found')];
} elseif ($signature['status'] === 'confirmed') {
// Already confirmed
$confirmMessage = ['type' => 'info', 'text' => petitionT($ctx, 'petition', 'resend_already_confirmed')];
} else {
// Generate new token and update signature
$newToken = bin2hex(random_bytes(32));
if (petitionUpdateSignatureToken($csvPath, $resendEmail, $newToken)) {
// Build confirmation URL
$langPrefix = $ctx->get('langPrefix', '');
$currentPath = trim($ctx->requestPath, '/');
// Remove subpage suffixes to get petition base path
$currentPath = preg_replace('#/(takk|send-bekreftelse-pa-nytt)$#', '', $currentPath);
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'];
$confirmUrl = "{$protocol}://{$host}{$langPrefix}/{$currentPath}/?confirm={$newToken}#sign-now";
// Send confirmation email
$signatureData = [
'email' => $signature['email'],
'firstname' => $signature['firstname'],
'surname' => $signature['surname']
];
if (petitionSendConfirmationEmail($signatureData, $confirmUrl, $petitionTitle, $petitionId, $ctx)) {
$confirmMessage = ['type' => 'success', 'text' => petitionT($ctx, 'petition', 'resend_success')];
} else {
$confirmMessage = ['type' => 'error', 'text' => petitionT($ctx, 'petition', 'error_email_send')];
}
} else {
$confirmMessage = ['type' => 'error', 'text' => petitionT($ctx, 'petition', 'resend_not_found')];
}
}
}
// Store message in session and redirect back (PRG pattern)
$_SESSION['petition_resend_message'] = $confirmMessage;
$langPrefix = $ctx->get('langPrefix', '');
$currentPath = trim($ctx->requestPath, '/');
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);
}
$_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 .= '<div class="form-message form-message--' . $confirmMessage['type'] . '" role="alert">';
$formHtml .= '<p>' . htmlspecialchars($confirmMessage['text']) . '</p>';
$formHtml .= '</div>';
}
$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;
});