Add link to resend confirmation page from main form Add new resend confirmation page with form Implement backend logic to handle resend requests Add translations for new functionality Update thank you page with resend confirmation link
1569 lines
54 KiB
PHP
1569 lines
54 KiB
PHP
<?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., – → –)
|
||
$petitionTitle = html_entity_decode($petitionTitle, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||
$smtpConfigPath = dirname(__DIR__, 2) . '/smtp-config.php';
|
||
if (!file_exists($smtpConfigPath)) {
|
||
$errorMessage = 'SMTP config not found';
|
||
return false;
|
||
}
|
||
|
||
$config = require $smtpConfigPath;
|
||
|
||
if (!$config['enabled']) {
|
||
$errorMessage = 'SMTP disabled';
|
||
return false;
|
||
}
|
||
|
||
// Get petition-specific overrides
|
||
$fromEmail = $config['petition']['from_email'] ?? $config['from_email'];
|
||
$fromName = $config['petition']['from_name'] ?? $config['from_name'];
|
||
|
||
// Build email body
|
||
$greeting = petitionT($ctx, 'petition', 'email_greeting', ['name' => $data['firstname']]);
|
||
$thanks = petitionT($ctx, 'petition', 'email_thanks', ['title' => $petitionTitle]);
|
||
$confirmText = petitionT($ctx, 'petition', 'email_confirm');
|
||
$expiryNotice = petitionT($ctx, 'petition', 'email_expiry_notice');
|
||
$ignoreText = petitionT($ctx, 'petition', 'email_ignore');
|
||
$rightsInfo = petitionT($ctx, 'petition', 'email_rights_info');
|
||
$signature = petitionT($ctx, 'petition', 'email_signature');
|
||
$org = petitionT($ctx, 'petition', 'email_org');
|
||
|
||
$emailBody = "{$greeting}\n\n";
|
||
$emailBody .= "{$thanks}\n\n";
|
||
$emailBody .= "{$confirmText}\n";
|
||
$emailBody .= "{$confirmUrl}\n\n";
|
||
$emailBody .= "{$expiryNotice}\n\n";
|
||
$emailBody .= "{$ignoreText}\n\n";
|
||
$emailBody .= "---\n\n";
|
||
$emailBody .= "{$rightsInfo}\n\n";
|
||
$emailBody .= "{$signature},\n";
|
||
$emailBody .= "{$org}\n";
|
||
|
||
$subject = petitionT($ctx, 'petition', 'email_subject');
|
||
|
||
// Get petition-specific SMTP settings (allows separate SMTP account for better deliverability)
|
||
$smtpHost = $config['petition']['host'] ?? $config['host'];
|
||
$smtpPort = $config['petition']['port'] ?? $config['port'];
|
||
$smtpUser = $config['petition']['username'] ?? $config['username'];
|
||
$smtpPass = $config['petition']['password'] ?? $config['password'];
|
||
|
||
// Pre-flight check
|
||
$fp = @fsockopen($smtpHost, $smtpPort, $errno, $errstr, 10);
|
||
if (!$fp) {
|
||
$errorMessage = "Connection failed: {$errno} - {$errstr}";
|
||
error_log("Petition SMTP pre-flight failed: {$smtpHost}:{$smtpPort} - {$errno} - {$errstr}");
|
||
return false;
|
||
}
|
||
fclose($fp);
|
||
|
||
try {
|
||
require_once dirname(__DIR__, 2) . '/vendor/PHPMailer.Lite.php';
|
||
|
||
$mail = new \codeworxtech\PHPMailerLite\PHPMailerLite();
|
||
|
||
$mail->SetSMTPhost($smtpHost);
|
||
$mail->SetSMTPport($smtpPort);
|
||
$mail->SetSMTPuser($smtpUser);
|
||
$mail->SetSMTPpass($smtpPass);
|
||
|
||
$recipientName = $data['firstname'] . ' ' . $data['surname'];
|
||
$mail->SetSender([$fromEmail => $fromName]);
|
||
$mail->AddRecipient([$data['email'] => $recipientName]);
|
||
$mail->SetSubject($subject);
|
||
$mail->SetBodyText($emailBody);
|
||
|
||
ob_start();
|
||
$sent = @$mail->Send('smtp');
|
||
$output = ob_get_clean();
|
||
|
||
if (!$sent || stripos($output, 'error') !== false || stripos($output, '✗') !== false) {
|
||
$errorMessage = strip_tags($output) ?: 'Send returned false';
|
||
error_log("Petition email send failed: " . strip_tags($output));
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
} catch (\Exception $e) {
|
||
$errorMessage = $e->getMessage();
|
||
error_log("Petition email exception: " . $e->getMessage());
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get signature data by token
|
||
*/
|
||
function petitionGetSignatureByToken(string $csvPath, string $token): ?array {
|
||
if (!file_exists($csvPath)) {
|
||
return null;
|
||
}
|
||
|
||
$fp = fopen($csvPath, 'r');
|
||
if (!$fp) {
|
||
return null;
|
||
}
|
||
|
||
$signature = null;
|
||
|
||
if (flock($fp, LOCK_SH)) {
|
||
fgetcsv($fp, null, ',', '"', ''); // Skip header
|
||
|
||
// CSV format: timestamp, email, firstname, surname, region, display, status, token, ip_hash
|
||
while (($row = fgetcsv($fp, null, ',', '"', '')) !== false) {
|
||
if (isset($row[7]) && $row[7] === $token) {
|
||
$signature = [
|
||
'email' => $row[1],
|
||
'firstname' => $row[2],
|
||
'surname' => $row[3]
|
||
];
|
||
break;
|
||
}
|
||
}
|
||
|
||
flock($fp, LOCK_UN);
|
||
}
|
||
|
||
fclose($fp);
|
||
return $signature;
|
||
}
|
||
|
||
/**
|
||
* Get pending signature data by email address
|
||
* Returns signature data if found and pending, null otherwise
|
||
*/
|
||
function petitionGetPendingSignatureByEmail(string $csvPath, string $email): ?array {
|
||
if (!file_exists($csvPath)) {
|
||
return null;
|
||
}
|
||
|
||
$fp = fopen($csvPath, 'r');
|
||
if (!$fp) {
|
||
return null;
|
||
}
|
||
|
||
$signature = null;
|
||
$email = strtolower(trim($email));
|
||
|
||
if (flock($fp, LOCK_SH)) {
|
||
fgetcsv($fp, null, ',', '"', ''); // Skip header
|
||
|
||
// CSV format: timestamp, email, firstname, surname, region, display, status, token, token_created, ip_hash
|
||
while (($row = fgetcsv($fp, null, ',', '"', '')) !== false) {
|
||
if (isset($row[1]) && strtolower($row[1]) === $email) {
|
||
$signature = [
|
||
'email' => $row[1],
|
||
'firstname' => $row[2],
|
||
'surname' => $row[3],
|
||
'region' => $row[4],
|
||
'display' => $row[5],
|
||
'status' => $row[6],
|
||
'token' => $row[7],
|
||
'token_created' => $row[8] ?? 0
|
||
];
|
||
break;
|
||
}
|
||
}
|
||
|
||
flock($fp, LOCK_UN);
|
||
}
|
||
|
||
fclose($fp);
|
||
return $signature;
|
||
}
|
||
|
||
/**
|
||
* Update token for an existing signature (for resend functionality)
|
||
*/
|
||
function petitionUpdateSignatureToken(string $csvPath, string $email, string $newToken): bool {
|
||
if (!file_exists($csvPath)) {
|
||
return false;
|
||
}
|
||
|
||
$fp = fopen($csvPath, 'r+');
|
||
if (!$fp) {
|
||
return false;
|
||
}
|
||
|
||
if (!flock($fp, LOCK_EX)) {
|
||
fclose($fp);
|
||
return false;
|
||
}
|
||
|
||
$rows = [];
|
||
$header = fgetcsv($fp, null, ',', '"', '');
|
||
$rows[] = $header;
|
||
|
||
$found = false;
|
||
$email = strtolower(trim($email));
|
||
$currentTime = time();
|
||
|
||
while (($row = fgetcsv($fp, null, ',', '"', '')) !== false) {
|
||
if (isset($row[1]) && strtolower($row[1]) === $email && $row[6] === 'pending') {
|
||
$row[7] = $newToken; // Update token
|
||
$row[8] = $currentTime; // Update token_created
|
||
$found = true;
|
||
}
|
||
$rows[] = $row;
|
||
}
|
||
|
||
if (!$found) {
|
||
flock($fp, LOCK_UN);
|
||
fclose($fp);
|
||
return false;
|
||
}
|
||
|
||
// Rewrite file
|
||
rewind($fp);
|
||
ftruncate($fp, 0);
|
||
|
||
foreach ($rows as $row) {
|
||
fputcsv($fp, $row, ',', '"', '');
|
||
}
|
||
|
||
flock($fp, LOCK_UN);
|
||
fclose($fp);
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Send thank you email with delete link (with retry wrapper)
|
||
*/
|
||
function petitionSendThankYouEmail(string $token, string $deleteUrl, string $petitionTitle, string $petitionId, string $csvPath, Context $ctx): bool {
|
||
$signature = petitionGetSignatureByToken($csvPath, $token);
|
||
$email = $signature['email'] ?? 'unknown';
|
||
|
||
$result = petitionSendWithRetry(function() use ($token, $deleteUrl, $petitionTitle, $csvPath, $ctx, &$errorMessage) {
|
||
return petitionSendThankYouEmailInternal($token, $deleteUrl, $petitionTitle, $csvPath, $ctx, $errorMessage);
|
||
});
|
||
|
||
// Log the final result
|
||
petitionLogEmail('thankyou', $petitionId, $email, $result, $result ? '' : ($errorMessage ?? 'Unknown error'));
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* Internal: Send thank you email with delete link (single attempt)
|
||
*/
|
||
function petitionSendThankYouEmailInternal(string $token, string $deleteUrl, string $petitionTitle, string $csvPath, Context $ctx, ?string &$errorMessage = null): bool {
|
||
// Decode HTML entities for plain-text email (e.g., – → –)
|
||
$petitionTitle = html_entity_decode($petitionTitle, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||
|
||
$signature = petitionGetSignatureByToken($csvPath, $token);
|
||
if (!$signature) {
|
||
$errorMessage = 'Signature not found';
|
||
return false;
|
||
}
|
||
|
||
$smtpConfigPath = dirname(__DIR__, 2) . '/smtp-config.php';
|
||
if (!file_exists($smtpConfigPath)) {
|
||
$errorMessage = 'SMTP config not found';
|
||
return false;
|
||
}
|
||
|
||
$config = require $smtpConfigPath;
|
||
|
||
if (!$config['enabled']) {
|
||
$errorMessage = 'SMTP disabled';
|
||
return false;
|
||
}
|
||
|
||
// Get petition-specific overrides
|
||
$fromEmail = $config['petition']['from_email'] ?? $config['from_email'];
|
||
$fromName = $config['petition']['from_name'] ?? $config['from_name'];
|
||
|
||
// Build email body
|
||
$greeting = petitionT($ctx, 'petition', 'email_greeting', ['name' => $signature['firstname']]);
|
||
$thankYouText = petitionT($ctx, 'petition', 'email_thankyou_confirmed', ['title' => $petitionTitle]);
|
||
$deleteText = petitionT($ctx, 'petition', 'email_delete_info');
|
||
$rightsInfo = petitionT($ctx, 'petition', 'email_rights_info');
|
||
$emailSignature = petitionT($ctx, 'petition', 'email_signature');
|
||
$org = petitionT($ctx, 'petition', 'email_org');
|
||
|
||
$emailBody = "{$greeting}\n\n";
|
||
$emailBody .= "{$thankYouText}\n\n";
|
||
$emailBody .= "{$deleteText}\n";
|
||
$emailBody .= "{$deleteUrl}\n\n";
|
||
$emailBody .= "---\n\n";
|
||
$emailBody .= "{$rightsInfo}\n\n";
|
||
$emailBody .= "{$emailSignature},\n";
|
||
$emailBody .= "{$org}\n";
|
||
|
||
$subject = petitionT($ctx, 'petition', 'email_thankyou_subject');
|
||
|
||
// Get petition-specific SMTP settings (allows separate SMTP account for better deliverability)
|
||
$smtpHost = $config['petition']['host'] ?? $config['host'];
|
||
$smtpPort = $config['petition']['port'] ?? $config['port'];
|
||
$smtpUser = $config['petition']['username'] ?? $config['username'];
|
||
$smtpPass = $config['petition']['password'] ?? $config['password'];
|
||
|
||
// Pre-flight check
|
||
$fp = @fsockopen($smtpHost, $smtpPort, $errno, $errstr, 10);
|
||
if (!$fp) {
|
||
$errorMessage = "Connection failed: {$errno} - {$errstr}";
|
||
error_log("Petition SMTP pre-flight failed: {$smtpHost}:{$smtpPort} - {$errno} - {$errstr}");
|
||
return false;
|
||
}
|
||
fclose($fp);
|
||
|
||
try {
|
||
require_once dirname(__DIR__, 2) . '/vendor/PHPMailer.Lite.php';
|
||
|
||
$mail = new \codeworxtech\PHPMailerLite\PHPMailerLite();
|
||
|
||
$mail->SetSMTPhost($smtpHost);
|
||
$mail->SetSMTPport($smtpPort);
|
||
$mail->SetSMTPuser($smtpUser);
|
||
$mail->SetSMTPpass($smtpPass);
|
||
|
||
$recipientName = $signature['firstname'] . ' ' . $signature['surname'];
|
||
$mail->SetSender([$fromEmail => $fromName]);
|
||
$mail->AddRecipient([$signature['email'] => $recipientName]);
|
||
$mail->SetSubject($subject);
|
||
$mail->SetBodyText($emailBody);
|
||
|
||
ob_start();
|
||
$sent = @$mail->Send('smtp');
|
||
$output = ob_get_clean();
|
||
|
||
if (!$sent || stripos($output, 'error') !== false || stripos($output, '✗') !== false) {
|
||
$errorMessage = strip_tags($output) ?: 'Send returned false';
|
||
error_log("Petition thank you email send failed: " . strip_tags($output));
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
} catch (\Exception $e) {
|
||
$errorMessage = $e->getMessage();
|
||
error_log("Petition thank you email exception: " . $e->getMessage());
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Render the petition form HTML
|
||
*/
|
||
function petitionRenderForm(Context $ctx, array $formData, array $errors, bool $showForm): string {
|
||
$csrfToken = $_SESSION['csrf_token'];
|
||
$currentTime = time();
|
||
$regions = petitionGetRegions($ctx);
|
||
|
||
$html = '<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;
|
||
});
|