v1.0.0.6
Publisium
- <?php
-
- declare(strict_types=1);
-
- require_once __DIR__ . '/database.php';
- require_once __DIR__ . '/language.php';
- require_once __DIR__ . '/antibot.php';
- require_once __DIR__ . '/csrf.php';
- require_once __DIR__ . '/ai.php';
-
- final class ContactForm
- {
- public const PROVIDERS = [
- 'mail' => 'PHP mail(), built in and works on most hosting',
- 'resend' => 'Resend (API)',
- 'brevo' => 'Brevo (API)',
- ];
-
- public const BLOCK_TYPE = 'contact_form';
-
- private const API_PROVIDERS = ['resend', 'brevo'];
- private const SETTING_KEYS = ['contact_enabled', 'contact_recipient', 'contact_provider', 'contact_from_email', 'contact_from_name', 'contact_include_ip', 'contact_topics', 'contact_subject_mode'];
- public const SUBJECT_MODES = [
- 'free' => 'Visitors type their own subject',
- 'list_other' => 'Pick from the list below or choose "Other…" and type their own',
- 'list' => 'Only the subjects from the list below',
- ];
- private const MAX_TOPICS = 30;
- private const MAX_NAME = 100;
- private const MAX_EMAIL = 190;
- private const MAX_SUBJECT = 150;
- private const MAX_MESSAGE = 5000;
- private const FLASH_KEY = 'contact_form_flash';
-
- private static ?array $settings = null;
-
- public static function settings(): array
- {
- if (self::$settings !== null) {
- return self::$settings;
- }
-
- $settings = [
- 'contact_enabled' => '0',
- 'contact_recipient' => '',
- 'contact_provider' => 'mail',
- 'contact_from_email' => '',
- 'contact_from_name' => '',
- 'contact_include_ip' => '1',
- 'contact_topics' => '',
- 'contact_subject_mode' => '',
- ];
-
- try {
- $placeholders = implode(',', array_fill(0, count(self::SETTING_KEYS), '?'));
- $statement = Database::site()->prepare('SELECT setting_key, setting_value FROM site_settings WHERE setting_key IN (' . $placeholders . ')');
- $statement->execute(self::SETTING_KEYS);
- foreach ($statement->fetchAll(PDO::FETCH_KEY_PAIR) as $key => $value) {
- $settings[$key] = (string) $value;
- }
- } catch (PDOException $exception) {
- return self::$settings = $settings;
- }
-
- if (!isset(self::PROVIDERS[$settings['contact_provider']])) {
- $settings['contact_provider'] = 'mail';
- }
-
- return self::$settings = $settings;
- }
-
- public static function isEnabled(): bool
- {
- $settings = self::settings();
-
- return $settings['contact_enabled'] === '1' && filter_var($settings['contact_recipient'], FILTER_VALIDATE_EMAIL) !== false;
- }
-
- public static function defaultFromEmail(): string
- {
- $host = (string) (parse_url((string) Config::get('APP_URL', ''), PHP_URL_HOST) ?: ($_SERVER['HTTP_HOST'] ?? 'localhost'));
- $host = preg_replace('/^www\./i', '', strtolower($host)) ?: 'localhost';
-
- return 'no-reply@' . $host;
- }
-
- public static function subjectMode(): string
- {
- $mode = self::settings()['contact_subject_mode'];
- if (!isset(self::SUBJECT_MODES[$mode])) {
- $mode = self::settings()['contact_topics'] !== '' ? 'list' : 'free';
- }
-
- return $mode;
- }
-
- private static function activeSubjectMode(): string
- {
- $mode = self::subjectMode();
-
- return $mode !== 'free' && self::topics() === [] ? 'free' : $mode;
- }
-
- public static function topics(?string $raw = null): array
- {
- $topics = [];
- foreach (preg_split('/\R/', $raw ?? self::settings()['contact_topics']) ?: [] as $line) {
- [$label, $recipient] = array_pad(array_map('trim', explode('|', $line, 2)), 2, '');
- $label = self::singleLine($label, self::MAX_SUBJECT);
- if ($label === '') {
- continue;
- }
- $topics[] = ['label' => $label, 'recipient' => $recipient];
- if (count($topics) >= self::MAX_TOPICS) {
- break;
- }
- }
-
- return $topics;
- }
-
- public static function keyState(string $provider): array
- {
- return Ai::keyState(self::secretName($provider));
- }
-
- public static function saveFromRequest(PDO $db, array $post): ?string
- {
- $recipient = trim((string) ($post['contact_recipient'] ?? ''));
- $fromEmail = trim((string) ($post['contact_from_email'] ?? ''));
- $provider = (string) ($post['contact_provider'] ?? 'mail');
- $enabled = isset($post['contact_enabled']);
-
- if (!isset(self::PROVIDERS[$provider])) {
- $provider = 'mail';
- }
- if ($recipient !== '' && filter_var($recipient, FILTER_VALIDATE_EMAIL) === false) {
- return 'Check the email address that receives messages, it doesn’t look right.';
- }
- if ($fromEmail !== '' && filter_var($fromEmail, FILTER_VALIDATE_EMAIL) === false) {
- return 'Check the "Send from" address, it doesn’t look right.';
- }
- if ($enabled && $recipient === '') {
- return 'Add the email address where messages should go.';
- }
-
- $topicLines = [];
- if (isset($post['contact_topics_submitted'])) {
- $labels = is_array($post['contact_topic_label'] ?? null) ? array_values($post['contact_topic_label']) : [];
- $emails = is_array($post['contact_topic_email'] ?? null) ? array_values($post['contact_topic_email']) : [];
- foreach ($labels as $index => $label) {
- $label = str_replace('|', '/', (string) $label);
- $email = trim((string) ($emails[$index] ?? ''));
- $topicLines[] = $label . ($email !== '' ? ' | ' . $email : '');
- }
- }
- $topics = self::topics(isset($post['contact_topics_submitted']) ? implode("\n", $topicLines) : self::settings()['contact_topics']);
- foreach ($topics as $topic) {
- if ($topic['recipient'] !== '' && filter_var($topic['recipient'], FILTER_VALIDATE_EMAIL) === false) {
- return 'The topic "' . $topic['label'] . '" has an email address that doesn’t look right.';
- }
- }
- $topicsText = implode("\n", array_map(
- static fn(array $topic): string => $topic['label'] . ($topic['recipient'] !== '' ? ' | ' . $topic['recipient'] : ''),
- $topics
- ));
-
- foreach (self::API_PROVIDERS as $apiProvider) {
- $key = trim((string) ($post['contact_key_' . $apiProvider] ?? ''));
- try {
- if (isset($post['contact_key_' . $apiProvider . '_remove'])) {
- Ai::forgetSecret(self::secretName($apiProvider));
- } elseif ($key !== '') {
- Ai::storeSecret(self::secretName($apiProvider), $key);
- }
- } catch (Throwable $exception) {
- return 'We couldn’t save the ' . self::PROVIDERS[$apiProvider] . ' key: ' . $exception->getMessage();
- }
- }
-
- if ($enabled && in_array($provider, self::API_PROVIDERS, true) && self::apiKey($provider) === null) {
- return 'Add an API key for ' . self::PROVIDERS[$provider] . ' or pick a different email service.';
- }
-
- $statement = $db->prepare(
- 'INSERT INTO site_settings (setting_key, setting_value) VALUES (:key, :value) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)'
- );
- $values = [
- 'contact_enabled' => $enabled ? '1' : '0',
- 'contact_recipient' => $recipient,
- 'contact_provider' => $provider,
- 'contact_from_email' => $fromEmail,
- 'contact_from_name' => self::singleLine((string) ($post['contact_from_name'] ?? ''), self::MAX_NAME),
- 'contact_include_ip' => isset($post['contact_include_ip']) ? '1' : '0',
- 'contact_topics' => $topicsText,
- 'contact_subject_mode' => isset(self::SUBJECT_MODES[(string) ($post['contact_subject_mode'] ?? '')]) ? (string) $post['contact_subject_mode'] : self::subjectMode(),
- ];
- foreach ($values as $key => $value) {
- $statement->execute(['key' => $key, 'value' => $value]);
- }
- self::$settings = null;
-
- return null;
- }
-
- public static function renderBlock(): string
- {
- if (!self::isEnabled() || session_status() !== PHP_SESSION_ACTIVE) {
- return '';
- }
-
- $flash = $_SESSION[self::FLASH_KEY] ?? null;
- unset($_SESSION[self::FLASH_KEY]);
- $old = is_array($flash['old'] ?? null) ? $flash['old'] : [];
- $value = static fn(string $field): string => htmlspecialchars((string) ($old[$field] ?? ''), ENT_QUOTES);
- $text = static fn(string $key, string $default): string => htmlspecialchars(Language::get($key, $default), ENT_QUOTES);
- $returnTo = htmlspecialchars((string) ($_SERVER['REQUEST_URI'] ?? ''), ENT_QUOTES);
-
- $html = '<section class="contact-form" id="contact-form">';
- if (is_array($flash) && isset($flash['message'])) {
- $html .= '<div class="contact-form-' . ($flash['type'] === 'success' ? 'success' : 'error') . '" role="status">' . htmlspecialchars((string) $flash['message']) . '</div>';
- }
-
- $html .= '<form method="post" action="contact-send.php">'
- . Csrf::field()
- . AntiBot::field('contact')
- . '<input type="hidden" name="return_to" value="' . $returnTo . '">'
- . '<div class="contact-form-row">'
- . '<div><label for="contact-name">' . $text('contact_name', 'Your name') . '</label>'
- . '<input id="contact-name" type="text" name="name" required maxlength="' . self::MAX_NAME . '" autocomplete="name" value="' . $value('name') . '"></div>'
- . '<div><label for="contact-email">' . $text('contact_email', 'Your email') . '</label>'
- . '<input id="contact-email" type="email" name="email" required maxlength="' . self::MAX_EMAIL . '" autocomplete="email" value="' . $value('email') . '"></div>'
- . '</div>'
- . self::subjectField($old, $text)
- . '<label for="contact-message">' . $text('contact_message', 'Message') . '</label>'
- . '<textarea id="contact-message" name="message" rows="6" required maxlength="' . self::MAX_MESSAGE . '">' . $value('message') . '</textarea>'
- . '<div class="antibot-box">'
- . '<div class="antibot-image">' . AntiBot::image('contact') . '</div>'
- . '<label for="contact-antibot-answer">' . $text('contact_security_code', 'Security code') . '</label>'
- . '<input id="contact-antibot-answer" type="text" name="antibot_answer" required inputmode="text" autocomplete="off" maxlength="6" spellcheck="false">'
- . '</div>'
- . '<button type="submit">' . $text('contact_send', 'Send message') . '</button>'
- . '</form></section>';
-
- return $html;
- }
-
- private static function subjectField(array $old, callable $text): string
- {
- $mode = self::activeSubjectMode();
- $subjectValue = htmlspecialchars((string) ($old['subject'] ?? ''), ENT_QUOTES);
-
- if ($mode === 'free') {
- return '<label for="contact-subject">' . $text('contact_subject', 'Subject') . '</label>'
- . '<input id="contact-subject" type="text" name="subject" maxlength="' . self::MAX_SUBJECT . '" value="' . $subjectValue . '">';
- }
-
- $selected = (string) ($old['topic'] ?? '');
- $html = '<label for="contact-topic">' . $text('contact_topic', 'Topic') . '</label>'
- . '<select id="contact-topic" name="topic" required>'
- . '<option value="" disabled' . ($selected === '' ? ' selected' : '') . '>' . $text('contact_topic_choose', 'Choose a topic') . '</option>';
- foreach (self::topics() as $index => $topic) {
- $html .= '<option value="' . $index . '"' . ($selected === (string) $index ? ' selected' : '') . '>' . htmlspecialchars($topic['label']) . '</option>';
- }
-
- if ($mode !== 'list_other') {
- return $html . '</select>';
- }
-
- $html .= '<option value="other"' . ($selected === 'other' ? ' selected' : '') . '>' . $text('contact_topic_other', 'Other…') . '</option></select>'
- . '<div class="contact-form-other" id="contact-subject-other">'
- . '<label for="contact-subject">' . $text('contact_subject_other', 'Your subject') . '</label>'
- . '<input id="contact-subject" type="text" name="subject" maxlength="' . self::MAX_SUBJECT . '" value="' . $subjectValue . '">'
- . '</div>'
- . '<script>(function(){var s=document.getElementById("contact-topic"),b=document.getElementById("contact-subject-other"),i=document.getElementById("contact-subject");if(!s||!b||!i){return;}function t(){var o=s.value==="other";b.hidden=!o;i.required=o;}s.addEventListener("change",t);t();})();</script>';
-
- return $html;
- }
-
- public static function handleSubmission(array $post): array
- {
- $old = [
- 'name' => self::singleLine((string) ($post['name'] ?? ''), self::MAX_NAME),
- 'email' => self::singleLine((string) ($post['email'] ?? ''), self::MAX_EMAIL),
- 'subject' => self::singleLine((string) ($post['subject'] ?? ''), self::MAX_SUBJECT),
- 'topic' => preg_match('/^(\d{1,2}|other)$/', (string) ($post['topic'] ?? '')) === 1 ? (string) $post['topic'] : '',
- 'message' => mb_substr(trim(str_replace("\r\n", "\n", (string) ($post['message'] ?? ''))), 0, self::MAX_MESSAGE),
- ];
-
- $mode = self::activeSubjectMode();
- $recipient = null;
- if ($mode === 'free') {
- $old['topic'] = '';
- } elseif ($mode === 'list_other' && $old['topic'] === 'other') {
- if ($old['subject'] === '') {
- return self::fail(Language::get('contact_subject_other_missing', 'Please enter your subject.'), $old);
- }
- } else {
- $topic = $old['topic'] !== '' && $old['topic'] !== 'other' ? (self::topics()[(int) $old['topic']] ?? null) : null;
- if ($topic === null) {
- return self::fail(Language::get('contact_topic_missing', 'Please choose a topic.'), $old);
- }
- $old['subject'] = $topic['label'];
- $recipient = $topic['recipient'] !== '' ? $topic['recipient'] : null;
- }
-
- if (!self::isEnabled()) {
- return self::fail(Language::get('contact_unavailable', 'The contact form is not available right now.'), $old);
- }
-
- if (!Csrf::verify($post['csrf_token'] ?? null)
- || !AntiBot::verify('contact', $post['antibot_answer'] ?? null, $post['antibot_started'] ?? null, $post['website'] ?? null)) {
- return self::fail(Language::get('contact_security_failed', 'The security code was wrong. Please try again.'), $old);
- }
-
- if ($old['name'] === '' || $old['message'] === '' || filter_var($old['email'], FILTER_VALIDATE_EMAIL) === false) {
- return self::fail(Language::get('contact_invalid', 'Please enter your name, a valid email address and a message.'), $old);
- }
-
- require_once __DIR__ . '/login-throttle.php';
- $wait = LoginThrottle::secondsUntilAllowed('contact', $old['email']);
- if ($wait > 0) {
- return self::fail(Language::get('contact_rate_limited', 'You have sent too many messages. Please try again later.'), $old);
- }
-
- $error = self::send($old, $recipient);
- if ($error !== null) {
- error_log('Publisium: contact form delivery failed: ' . $error);
-
- return self::fail(Language::get('contact_send_failed', 'Your message could not be sent. Please try again later.'), $old);
- }
-
- LoginThrottle::recordFailure('contact', $old['email']);
- $_SESSION[self::FLASH_KEY] = ['type' => 'success', 'message' => Language::get('contact_sent', 'Thank you! Your message has been sent.')];
-
- return ['ok' => true];
- }
-
- private static function fail(string $message, array $old): array
- {
- $_SESSION[self::FLASH_KEY] = ['type' => 'error', 'message' => $message, 'old' => $old];
-
- return ['ok' => false];
- }
-
- private static function send(array $message, ?string $recipient = null): ?string
- {
- $settings = self::settings();
- $to = $recipient ?? $settings['contact_recipient'];
- $siteName = self::singleLine((string) Config::get('APP_NAME', 'Publisium'), self::MAX_NAME);
- $fromEmail = filter_var($settings['contact_from_email'], FILTER_VALIDATE_EMAIL) !== false ? $settings['contact_from_email'] : self::defaultFromEmail();
- $fromName = $settings['contact_from_name'] !== '' ? $settings['contact_from_name'] : $siteName;
- $subject = '[' . $siteName . '] ' . ($message['subject'] !== '' ? $message['subject'] : 'Contact form message');
- $body = 'Name: ' . $message['name'] . "\n"
- . 'Email: ' . $message['email'] . "\n"
- . ($message['subject'] !== '' ? ($message['topic'] !== '' && $message['topic'] !== 'other' ? 'Topic: ' : 'Subject: ') . $message['subject'] . "\n" : '')
- . 'Page: ' . self::singleLine((string) ($_SERVER['HTTP_REFERER'] ?? ''), 300) . "\n"
- . ($settings['contact_include_ip'] === '1' ? 'IP: ' . self::singleLine((string) ($_SERVER['REMOTE_ADDR'] ?? ''), 45) . "\n" : '')
- . "\n" . $message['message'] . "\n";
-
- return match ($settings['contact_provider']) {
- 'resend' => self::sendResend($to, $fromEmail, $fromName, $message, $subject, $body),
- 'brevo' => self::sendBrevo($to, $fromEmail, $fromName, $message, $subject, $body),
- default => self::sendMail($to, $fromEmail, $fromName, $message, $subject, $body),
- };
- }
-
- private static function sendMail(string $to, string $fromEmail, string $fromName, array $message, string $subject, string $body): ?string
- {
- $headers = [
- 'From' => mb_encode_mimeheader(self::displayName($fromName), 'UTF-8') . ' <' . $fromEmail . '>',
- 'Reply-To' => mb_encode_mimeheader(self::displayName($message['name']), 'UTF-8') . ' <' . $message['email'] . '>',
- 'MIME-Version' => '1.0',
- 'Content-Type' => 'text/plain; charset=UTF-8',
- 'Content-Transfer-Encoding' => '8bit',
- 'X-Mailer' => 'Publisium',
- ];
-
- $encodedSubject = mb_encode_mimeheader($subject, 'UTF-8');
- $sent = @mail($to, $encodedSubject, $body, $headers, '-f' . $fromEmail) || @mail($to, $encodedSubject, $body, $headers);
-
- return $sent ? null : 'mail() returned false. Check that the server can send email.';
- }
-
- private static function sendResend(string $to, string $fromEmail, string $fromName, array $message, string $subject, string $body): ?string
- {
- $key = self::apiKey('resend');
- if ($key === null) {
- return 'Add your Resend API key first.';
- }
-
- [$status, $response] = self::post('https://api.resend.com/emails', ['Authorization: Bearer ' . $key], [
- 'from' => self::displayName($fromName) . ' <' . $fromEmail . '>',
- 'to' => [$to],
- 'reply_to' => $message['email'],
- 'subject' => $subject,
- 'text' => $body,
- ]);
-
- return $status >= 200 && $status < 300 ? null : 'Resend responded with HTTP ' . $status . ': ' . $response;
- }
-
- private static function sendBrevo(string $to, string $fromEmail, string $fromName, array $message, string $subject, string $body): ?string
- {
- $key = self::apiKey('brevo');
- if ($key === null) {
- return 'Add your Brevo API key first.';
- }
-
- [$status, $response] = self::post('https://api.brevo.com/v3/smtp/email', ['api-key: ' . $key], [
- 'sender' => ['name' => self::displayName($fromName), 'email' => $fromEmail],
- 'to' => [['email' => $to]],
- 'replyTo' => ['email' => $message['email'], 'name' => self::displayName($message['name'])],
- 'subject' => $subject,
- 'textContent' => $body,
- ]);
-
- return $status >= 200 && $status < 300 ? null : 'Brevo responded with HTTP ' . $status . ': ' . $response;
- }
-
- private static function post(string $url, array $headers, array $payload): array
- {
- $body = (string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE);
- $headers[] = 'Content-Type: application/json';
- $headers[] = 'Accept: application/json';
-
- if (function_exists('curl_init')) {
- $curl = curl_init($url);
- curl_setopt_array($curl, [
- CURLOPT_POST => true,
- CURLOPT_POSTFIELDS => $body,
- CURLOPT_HTTPHEADER => $headers,
- CURLOPT_RETURNTRANSFER => true,
- CURLOPT_CONNECTTIMEOUT => 10,
- CURLOPT_TIMEOUT => 20,
- CURLOPT_FOLLOWLOCATION => false,
- ]);
- $response = curl_exec($curl);
- $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
-
- return [$status, is_string($response) ? substr($response, 0, 300) : curl_error($curl)];
- }
-
- $context = stream_context_create(['http' => [
- 'method' => 'POST',
- 'header' => implode("\r\n", $headers),
- 'content' => $body,
- 'timeout' => 20,
- 'ignore_errors' => true,
- 'follow_location' => 0,
- ]]);
- $response = @file_get_contents($url, false, $context);
- $status = 0;
- foreach ($http_response_header ?? [] as $line) {
- if (preg_match('#^HTTP/\S+\s+(\d{3})#', $line, $match) === 1) {
- $status = (int) $match[1];
- }
- }
-
- return [$status, is_string($response) ? substr($response, 0, 300) : 'no response'];
- }
-
- private static function apiKey(string $provider): ?string
- {
- $key = Ai::apiKey(self::secretName($provider));
-
- return $key !== null && $key !== '' ? $key : null;
- }
-
- private static function secretName(string $provider): string
- {
- return 'mail_' . $provider;
- }
-
- private static function displayName(string $name): string
- {
- $clean = trim((string) preg_replace('/[<>"\\\\,;:@]+/u', ' ', $name));
-
- return $clean !== '' ? $clean : 'Website visitor';
- }
-
- private static function singleLine(string $value, int $limit): string
- {
- return mb_substr(trim((string) preg_replace('/[\x00-\x1F\x7F]+/u', ' ', $value)), 0, $limit);
- }
- }
-