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
1565 lines
53 KiB
PHP
1565 lines
53 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, 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., – → –)
|
||
$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';
|
||
$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;
|
||
});
|