innhold/custom/plugins/page/petition-form.php
Ruben 36591e7438 Add per-form Listmonk list UUIDs to newsletter plugin
Add support for specifying Listmonk list UUIDs per form instance
Update petition form to use metadata-defined UUIDs
Add success confirmation message to newsletter forms
Update documentation with new functionality
2026-02-07 15:34:43 +01:00

1565 lines
53 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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, array $listUuids = []): 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;
}
$uuids = !empty($listUuids) ? $listUuids : $config['list_uuids'];
// Log the UUIDs being used
error_log("Listmonk attempting subscription with UUIDs: " . implode(', ', $uuids));
$payload = json_encode([
'email' => $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., &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';
$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 .= '<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;
});