v1.0.0.0
StocketBase
- <?php
-
- declare(strict_types=1);
-
- require_once __DIR__ . '/config.php';
- require_once __DIR__ . '/site-front.php';
- require_once __DIR__ . '/language.php';
- require_once __DIR__ . '/csrf.php';
- require_once __DIR__ . '/antibot.php';
- require_once __DIR__ . '/login-throttle.php';
-
- final class ContactForm
- {
- public const PROVIDERS = ['php_mail', 'resend', 'postmark', 'store_mailer'];
- public const SUBJECT_MODES = ['free', 'list_other', 'list_only'];
- public const MAX_SUBJECTS = 30;
- private const OTHER_VALUE = '__other';
- private const SUBJECT_MAX = 150;
- private const NAME_MAX = 100;
- private const MESSAGE_MIN = 10;
- private const MESSAGE_MAX = 5000;
-
- private static ?string $error = null;
- private static array $old = [];
-
- public static function isEnabled(?array $settings = null): bool
- {
- $settings ??= SiteFront::settings();
-
- return ($settings['contact_enabled'] ?? '0') === '1'
- && filter_var((string) ($settings['contact_recipient_email'] ?? ''), FILTER_VALIDATE_EMAIL) !== false;
- }
-
- public static function subjectEntries(string $raw): array
- {
- $decoded = json_decode($raw, true);
- $rows = [];
- if (is_array($decoded)) {
- foreach ($decoded as $item) {
- if (is_array($item)) {
- $rows[] = ['name' => (string) ($item['name'] ?? ''), 'email' => (string) ($item['email'] ?? '')];
- }
- }
- } else {
- foreach (preg_split('/\R/', $raw) ?: [] as $line) {
- $rows[] = ['name' => $line, 'email' => ''];
- }
- }
-
- return self::normalizeSubjectEntries($rows);
- }
-
- public static function normalizeSubjectEntries(array $rows): array
- {
- $entries = [];
- $seen = [];
- foreach ($rows as $row) {
- $name = trim((string) preg_replace('/\s+/u', ' ', strip_tags((string) ($row['name'] ?? ''))));
- $name = mb_substr($name, 0, 100);
- if ($name === '' || isset($seen[mb_strtolower($name)])) {
- continue;
- }
- $email = trim((string) ($row['email'] ?? ''));
- $email = $email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL) !== false && mb_strlen($email) <= 190 ? $email : '';
- $seen[mb_strtolower($name)] = true;
- $entries[] = ['name' => $name, 'email' => $email];
- if (count($entries) >= self::MAX_SUBJECTS) {
- break;
- }
- }
-
- return $entries;
- }
-
- public static function parseSubjects(string $raw): array
- {
- return array_column(self::subjectEntries($raw), 'name');
- }
-
- private static function recipientForSubject(array $settings, string $subject): string
- {
- foreach (self::subjectEntries((string) ($settings['contact_subjects'] ?? '')) as $entry) {
- if ($entry['name'] === $subject && $entry['email'] !== '') {
- return $entry['email'];
- }
- }
-
- return (string) $settings['contact_recipient_email'];
- }
-
- private static function subjectMode(array $settings): string
- {
- $mode = in_array($settings['contact_subject_mode'] ?? 'free', self::SUBJECT_MODES, true) ? (string) $settings['contact_subject_mode'] : 'free';
-
- return $mode !== 'free' && self::parseSubjects((string) ($settings['contact_subjects'] ?? '')) === [] ? 'free' : $mode;
- }
-
- private static function resolveSubject(array $settings): ?string
- {
- $mode = self::subjectMode($settings);
- $choice = trim((string) ($_POST['contact_subject_choice'] ?? ''));
- $custom = trim((string) preg_replace('/[\r\n\t]+/', ' ', (string) ($_POST['contact_subject'] ?? '')));
- self::$old['subject_choice'] = $choice;
- self::$old['subject'] = $custom;
-
- if ($mode === 'free' || ($mode === 'list_other' && $choice === self::OTHER_VALUE)) {
- return $custom !== '' && mb_strlen($custom) <= self::SUBJECT_MAX ? $custom : null;
- }
-
- return in_array($choice, self::parseSubjects((string) ($settings['contact_subjects'] ?? '')), true) ? $choice : null;
- }
-
- public static function handleRequest(string $pageTitle): void
- {
- if (!self::isEnabled()) {
- return;
- }
-
- if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST' || ($_POST['action'] ?? '') !== 'contact_submit') {
- AntiBot::refresh('contact');
- return;
- }
-
- $name = trim((string) preg_replace('/[\r\n\t]+/', ' ', (string) ($_POST['contact_name'] ?? '')));
- $email = trim((string) ($_POST['contact_email'] ?? ''));
- $message = trim(str_replace("\r\n", "\n", (string) ($_POST['contact_message'] ?? '')));
- self::$old = ['name' => $name, 'email' => $email, 'message' => $message];
- $subject = self::resolveSubject(SiteFront::settings());
-
- $clientIp = (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
-
- if (!Csrf::verify($_POST['csrf_token'] ?? null)) {
- self::$error = Language::get('form_security_failed', 'Security check failed, please try again.');
- } elseif (($wait = LoginThrottle::secondsUntilAllowed('contact', $clientIp)) > 0) {
- self::$error = sprintf(Language::get('contact_error_rate_limit', 'You have sent too many messages. Please try again in about %d min.'), max(1, (int) ceil($wait / 60)));
- } elseif (!AntiBot::verify('contact', $_POST['antibot_answer'] ?? null, $_POST['antibot_started'] ?? null, $_POST['website'] ?? null)) {
- self::$error = Language::get('contact_error_antibot', 'The security code is incorrect. Please try again.');
- } elseif ($name === '' || mb_strlen($name) > self::NAME_MAX) {
- self::$error = Language::get('contact_error_name', 'Please enter your name.');
- } elseif ($email === '' || mb_strlen($email) > 190 || filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
- self::$error = Language::get('contact_error_email', 'Please enter a valid email address.');
- } elseif ($subject === null) {
- self::$error = Language::get('contact_error_subject', 'Please choose or enter a subject.');
- } elseif (mb_strlen($message) < self::MESSAGE_MIN || mb_strlen($message) > self::MESSAGE_MAX) {
- self::$error = sprintf(Language::get('contact_error_message', 'Your message must be between %d and %d characters long.'), self::MESSAGE_MIN, self::MESSAGE_MAX);
- }
-
- if (self::$error !== null) {
- AntiBot::refresh('contact');
- return;
- }
-
- LoginThrottle::recordFailure('contact', $clientIp);
-
- if (!self::send($name, $email, (string) $subject, $message, $pageTitle, $clientIp)) {
- self::$error = Language::get('contact_error_send', 'Your message could not be sent right now. Please try again later.');
- AntiBot::refresh('contact');
- return;
- }
-
- header('Location: ' . self::returnUrl('sent') . '#contact-form');
- exit;
- }
-
- public static function render(string $title): string
- {
- if (!self::isEnabled()) {
- return '';
- }
-
- $sent = ($_GET['contact'] ?? '') === 'sent';
- $html = '<section class="contact-form-block" id="contact-form">';
- if (trim($title) !== '') {
- $html .= '<h2 class="contact-form-title">' . htmlspecialchars($title) . '</h2>';
- }
-
- if ($sent) {
- $html .= '<p class="contact-form-alert contact-form-alert-success" role="status">'
- . htmlspecialchars(Language::get('contact_success', 'Thank you! Your message has been sent.')) . '</p>';
- }
- if (self::$error !== null) {
- $html .= '<p class="contact-form-alert contact-form-alert-error" role="alert">' . htmlspecialchars(self::$error) . '</p>';
- }
-
- $old = static fn(string $key): string => htmlspecialchars((string) (self::$old[$key] ?? ''), ENT_QUOTES);
-
- $html .= '<form method="post" action="#contact-form" class="contact-form">'
- . Csrf::field()
- . '<input type="hidden" name="action" value="contact_submit">'
- . AntiBot::field('contact')
- . '<label for="contact-name">' . htmlspecialchars(Language::get('contact_name_label', 'Your name')) . '</label>'
- . '<input type="text" id="contact-name" name="contact_name" required maxlength="' . self::NAME_MAX . '" autocomplete="name" value="' . $old('name') . '">'
- . '<label for="contact-email">' . htmlspecialchars(Language::get('contact_email_label', 'Your email')) . '</label>'
- . '<input type="email" id="contact-email" name="contact_email" required maxlength="190" autocomplete="email" value="' . $old('email') . '">'
- . self::renderSubjectField()
- . '<label for="contact-message">' . htmlspecialchars(Language::get('contact_message_label', 'Message')) . '</label>'
- . '<textarea id="contact-message" name="contact_message" rows="6" required minlength="' . self::MESSAGE_MIN . '" maxlength="' . self::MESSAGE_MAX . '">' . $old('message') . '</textarea>'
- . '<div class="antibot-box contact-antibot">'
- . '<div class="antibot-image">' . AntiBot::image('contact') . '</div>'
- . '<label for="contact-antibot">' . htmlspecialchars(Language::get('auth_security_code', 'Security code')) . '</label>'
- . '<input id="contact-antibot" type="text" name="antibot_answer" required inputmode="text" autocomplete="off" maxlength="6" spellcheck="false">'
- . '</div>'
- . '<button type="submit" class="unlock-btn contact-submit">' . htmlspecialchars(Language::get('contact_send_button', 'Send message')) . '</button>'
- . '</form>'
- . '</section>';
-
- return $html;
- }
-
- private static function renderSubjectField(): string
- {
- $settings = SiteFront::settings();
- $mode = self::subjectMode($settings);
- $label = '<label for="contact-subject">' . htmlspecialchars(Language::get('contact_subject_label', 'Subject')) . '</label>';
- $customValue = htmlspecialchars((string) (self::$old['subject'] ?? ''), ENT_QUOTES);
- $customInput = static fn(string $id, bool $required, string $extra = ''): string => '<input type="text" id="' . $id . '" name="contact_subject" maxlength="' . self::SUBJECT_MAX . '"'
- . ($required ? ' required' : '') . $extra . ' value="' . $customValue . '">';
-
- if ($mode === 'free') {
- return $label . $customInput('contact-subject', true);
- }
-
- $selected = (string) (self::$old['subject_choice'] ?? '');
- $options = '<option value="" disabled' . ($selected === '' ? ' selected' : '') . '>' . htmlspecialchars(Language::get('contact_subject_choose', 'Choose a subject…')) . '</option>';
- foreach (self::parseSubjects((string) ($settings['contact_subjects'] ?? '')) as $subject) {
- $options .= '<option value="' . htmlspecialchars($subject, ENT_QUOTES) . '"' . ($selected === $subject ? ' selected' : '') . '>' . htmlspecialchars($subject) . '</option>';
- }
-
- if ($mode === 'list_only') {
- return $label . '<select id="contact-subject" name="contact_subject_choice" required>' . $options . '</select>';
- }
-
- $otherSelected = $selected === self::OTHER_VALUE;
- $options .= '<option value="' . self::OTHER_VALUE . '"' . ($otherSelected ? ' selected' : '') . '>' . htmlspecialchars(Language::get('contact_subject_other', 'Other…')) . '</option>';
-
- return $label
- . '<select id="contact-subject" name="contact_subject_choice" required onchange="var c=document.getElementById(\'contact-subject-custom\');var o=this.value===\'' . self::OTHER_VALUE . '\';c.hidden=!o;c.required=o;if(o){c.focus();}">' . $options . '</select>'
- . $customInput('contact-subject-custom', $otherSelected, ($otherSelected ? '' : ' hidden') . ' placeholder="' . htmlspecialchars(Language::get('contact_subject_custom_placeholder', 'Type your subject'), ENT_QUOTES) . '" aria-label="' . htmlspecialchars(Language::get('contact_subject_label', 'Subject'), ENT_QUOTES) . '"');
- }
-
- private static function returnUrl(string $state): string
- {
- $requestUri = (string) ($_SERVER['REQUEST_URI'] ?? '/');
- $path = (string) (parse_url($requestUri, PHP_URL_PATH) ?: '/');
- parse_str((string) (parse_url($requestUri, PHP_URL_QUERY) ?? ''), $query);
- unset($query['contact']);
- $query['contact'] = $state;
-
- return $path . '?' . http_build_query($query);
- }
-
- private static function send(string $name, string $email, string $visitorSubject, string $message, string $pageTitle, string $clientIp): bool
- {
- $settings = SiteFront::settings();
- $siteName = (string) ($settings['site_name'] ?? 'Website');
- $recipient = self::recipientForSubject($settings, $visitorSubject);
- $provider = in_array($settings['contact_provider'] ?? 'php_mail', self::PROVIDERS, true) ? (string) $settings['contact_provider'] : 'php_mail';
-
- $subject = str_replace(
- ['{subject}', '{name}', '{site}'],
- [mb_substr($visitorSubject, 0, self::SUBJECT_MAX), mb_substr($name, 0, 60), $siteName],
- Language::get('contact_email_subject', '{subject} — message from {name} ({site})')
- );
- $html = self::emailBody($siteName, $name, $email, $visitorSubject, $message, $pageTitle, $clientIp);
-
- try {
- return match ($provider) {
- 'resend' => self::sendResend($settings, $recipient, $subject, $html, $email, $siteName),
- 'postmark' => self::sendPostmark($settings, $recipient, $subject, $html, $email, $siteName),
- 'store_mailer' => self::sendStoreMailer($recipient, $subject, $html, $email),
- default => self::sendPhpMail($settings, $recipient, $subject, $html, $email, $siteName),
- };
- } catch (Throwable $exception) {
- error_log('StocketBase contact form: ' . $exception->getMessage());
-
- return false;
- }
- }
-
- private static function emailBody(string $siteName, string $name, string $email, string $visitorSubject, string $message, string $pageTitle, string $clientIp): string
- {
- $row = static fn(string $label, string $value): string => '<tr><td style="padding:6px 12px 6px 0;color:#64748b;vertical-align:top;white-space:nowrap;">'
- . htmlspecialchars($label) . '</td><td style="padding:6px 0;color:#0f172a;">' . $value . '</td></tr>';
-
- return '<!DOCTYPE html><html><head><meta charset="utf-8"></head><body style="margin:0;padding:24px;background:#f1f5f9;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;">'
- . '<div style="max-width:600px;margin:0 auto;background:#ffffff;border:1px solid #e2e8f0;border-radius:12px;padding:28px;">'
- . '<h1 style="margin:0 0 18px;font-size:19px;color:#0f172a;">' . htmlspecialchars(Language::get('contact_email_heading', 'New contact form message')) . '</h1>'
- . '<table style="border-collapse:collapse;font-size:14px;margin:0 0 18px;">'
- . $row(Language::get('contact_name_label', 'Your name'), htmlspecialchars($name))
- . $row(Language::get('contact_email_label', 'Your email'), '<a href="mailto:' . htmlspecialchars($email) . '" style="color:#2563eb;">' . htmlspecialchars($email) . '</a>')
- . $row(Language::get('contact_subject_label', 'Subject'), '<strong>' . htmlspecialchars($visitorSubject) . '</strong>')
- . $row(Language::get('contact_email_page', 'Page'), htmlspecialchars($pageTitle))
- . $row('IP', htmlspecialchars($clientIp))
- . $row(Language::get('contact_email_date', 'Date'), htmlspecialchars(date('Y-m-d H:i')))
- . '</table>'
- . '<div style="padding:16px;border-radius:8px;background:#f8fafc;border:1px solid #e2e8f0;color:#0f172a;font-size:14.5px;line-height:1.6;">' . nl2br(htmlspecialchars($message)) . '</div>'
- . '<p style="margin:18px 0 0;color:#94a3b8;font-size:12px;">' . htmlspecialchars(Language::get('contact_email_reply_hint', 'Reply to this email to answer the sender directly.')) . ' — ' . htmlspecialchars($siteName) . '</p>'
- . '</div></body></html>';
- }
-
- private static function fromEmail(array $settings): string
- {
- foreach ([(string) ($settings['contact_from_email'] ?? ''), (string) ($settings['mail_from_email'] ?? '')] as $candidate) {
- $candidate = trim($candidate);
- if ($candidate !== '' && filter_var($candidate, FILTER_VALIDATE_EMAIL) !== false) {
- return $candidate;
- }
- }
-
- $host = (string) parse_url((string) Config::get('APP_URL', ''), PHP_URL_HOST);
- $host = preg_replace('/^www\./i', '', $host) ?: 'localhost';
-
- return 'no-reply@' . $host;
- }
-
- private static function sendStoreMailer(string $to, string $subject, string $html, string $replyTo): bool
- {
- require_once __DIR__ . '/mailer.php';
-
- return Mailer::send($to, $subject, $html, $replyTo);
- }
-
- private static function sendPhpMail(array $settings, string $to, string $subject, string $html, string $replyTo, string $siteName): bool
- {
- if (!function_exists('mail')) {
- error_log('StocketBase contact form: PHP mail() is not available on this server.');
-
- return false;
- }
-
- $from = self::fromEmail($settings);
- $boundary = 'sb_' . bin2hex(random_bytes(12));
- $headers = [
- 'From' => mb_encode_mimeheader($siteName, 'UTF-8') . ' <' . $from . '>',
- 'Reply-To' => $replyTo,
- 'MIME-Version' => '1.0',
- 'Content-Type' => 'multipart/alternative; boundary="' . $boundary . '"',
- 'X-Mailer' => 'StocketBase',
- ];
- $body = self::multipartBody($boundary, self::plainTextFromHtml($html), $html);
- $encodedSubject = mb_encode_mimeheader($subject, 'UTF-8');
-
- if (preg_match('/^[A-Za-z0-9._+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/', $from) === 1 && @mail($to, $encodedSubject, $body, $headers, '-f' . $from)) {
- return true;
- }
-
- return @mail($to, $encodedSubject, $body, $headers);
- }
-
- private static function multipartBody(string $boundary, string $text, string $html): string
- {
- return '--' . $boundary . "\n"
- . "Content-Type: text/plain; charset=UTF-8\n"
- . "Content-Transfer-Encoding: base64\n\n"
- . chunk_split(base64_encode($text), 76, "\n")
- . '--' . $boundary . "\n"
- . "Content-Type: text/html; charset=UTF-8\n"
- . "Content-Transfer-Encoding: base64\n\n"
- . chunk_split(base64_encode($html), 76, "\n")
- . '--' . $boundary . "--\n";
- }
-
- private static function plainTextFromHtml(string $html): string
- {
- $text = (string) preg_replace('#<(br|/p|/tr|/h1|/div|/li)\b[^>]*>#i', "\n", $html);
- $text = (string) preg_replace('#</td>\s*<td[^>]*>#i', ': ', $text);
- $text = html_entity_decode(strip_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8');
- $text = (string) preg_replace("/[ \t]+/", ' ', $text);
- $text = (string) preg_replace("/\n\s*\n\s*\n+/", "\n\n", $text);
-
- return trim($text) . "\n";
- }
-
- private static function sendResend(array $settings, string $to, string $subject, string $html, string $replyTo, string $siteName): bool
- {
- $apiKey = trim((string) (($settings['contact_resend_api_key'] ?? '') !== '' ? $settings['contact_resend_api_key'] : ($settings['mail_resend_api_key'] ?? '')));
- if ($apiKey === '') {
- error_log('StocketBase contact form: Resend API key is missing.');
-
- return false;
- }
-
- return self::postJson('https://api.resend.com/emails', ['Authorization: Bearer ' . $apiKey], [
- 'from' => $siteName . ' <' . self::fromEmail($settings) . '>',
- 'to' => [$to],
- 'subject' => $subject,
- 'html' => $html,
- 'reply_to' => $replyTo,
- ]);
- }
-
- private static function sendPostmark(array $settings, string $to, string $subject, string $html, string $replyTo, string $siteName): bool
- {
- $token = trim((string) (($settings['contact_postmark_token'] ?? '') !== '' ? $settings['contact_postmark_token'] : ($settings['mail_postmark_server_token'] ?? '')));
- if ($token === '') {
- error_log('StocketBase contact form: Postmark server token is missing.');
-
- return false;
- }
-
- return self::postJson('https://api.postmarkapp.com/email', ['X-Postmark-Server-Token: ' . $token], [
- 'From' => $siteName . ' <' . self::fromEmail($settings) . '>',
- 'To' => $to,
- 'Subject' => $subject,
- 'HtmlBody' => $html,
- 'ReplyTo' => $replyTo,
- 'MessageStream' => 'outbound',
- ]);
- }
-
- private static function postJson(string $url, array $headers, array $payload): bool
- {
- if (!function_exists('curl_init')) {
- error_log('StocketBase contact form: cURL is not available.');
-
- return false;
- }
-
- $curl = curl_init($url);
- curl_setopt_array($curl, [
- CURLOPT_POST => true,
- CURLOPT_POSTFIELDS => (string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
- CURLOPT_HTTPHEADER => array_merge(['Content-Type: application/json', 'Accept: application/json'], $headers),
- CURLOPT_RETURNTRANSFER => true,
- CURLOPT_CONNECTTIMEOUT => 10,
- CURLOPT_TIMEOUT => 20,
- ]);
- $response = curl_exec($curl);
- $status = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
- curl_close($curl);
-
- if ($response === false || $status < 200 || $status >= 300) {
- error_log('StocketBase contact form: provider returned HTTP ' . $status . ' — ' . mb_substr((string) $response, 0, 300));
-
- return false;
- }
-
- return true;
- }
- }
-