WebOrbiton
v1.0.0.0

StocketBase

441 lines · 21.7 KB
  1. <?php
  2. ​
  3. declare(strict_types=1);
  4. ​
  5. require_once __DIR__ . '/config.php';
  6. require_once __DIR__ . '/site-front.php';
  7. require_once __DIR__ . '/language.php';
  8. require_once __DIR__ . '/csrf.php';
  9. require_once __DIR__ . '/antibot.php';
  10. require_once __DIR__ . '/login-throttle.php';
  11. ​
  12. final class ContactForm
  13. {
  14. public const PROVIDERS = ['php_mail', 'resend', 'postmark', 'store_mailer'];
  15. public const SUBJECT_MODES = ['free', 'list_other', 'list_only'];
  16. public const MAX_SUBJECTS = 30;
  17. private const OTHER_VALUE = '__other';
  18. private const SUBJECT_MAX = 150;
  19. private const NAME_MAX = 100;
  20. private const MESSAGE_MIN = 10;
  21. private const MESSAGE_MAX = 5000;
  22. ​
  23. private static ?string $error = null;
  24. private static array $old = [];
  25. ​
  26. public static function isEnabled(?array $settings = null): bool
  27. {
  28. $settings ??= SiteFront::settings();
  29. ​
  30. return ($settings['contact_enabled'] ?? '0') === '1'
  31. && filter_var((string) ($settings['contact_recipient_email'] ?? ''), FILTER_VALIDATE_EMAIL) !== false;
  32. }
  33. ​
  34. public static function subjectEntries(string $raw): array
  35. {
  36. $decoded = json_decode($raw, true);
  37. $rows = [];
  38. if (is_array($decoded)) {
  39. foreach ($decoded as $item) {
  40. if (is_array($item)) {
  41. $rows[] = ['name' => (string) ($item['name'] ?? ''), 'email' => (string) ($item['email'] ?? '')];
  42. }
  43. }
  44. } else {
  45. foreach (preg_split('/\R/', $raw) ?: [] as $line) {
  46. $rows[] = ['name' => $line, 'email' => ''];
  47. }
  48. }
  49. ​
  50. return self::normalizeSubjectEntries($rows);
  51. }
  52. ​
  53. public static function normalizeSubjectEntries(array $rows): array
  54. {
  55. $entries = [];
  56. $seen = [];
  57. foreach ($rows as $row) {
  58. $name = trim((string) preg_replace('/\s+/u', ' ', strip_tags((string) ($row['name'] ?? ''))));
  59. $name = mb_substr($name, 0, 100);
  60. if ($name === '' || isset($seen[mb_strtolower($name)])) {
  61. continue;
  62. }
  63. $email = trim((string) ($row['email'] ?? ''));
  64. $email = $email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL) !== false && mb_strlen($email) <= 190 ? $email : '';
  65. $seen[mb_strtolower($name)] = true;
  66. $entries[] = ['name' => $name, 'email' => $email];
  67. if (count($entries) >= self::MAX_SUBJECTS) {
  68. break;
  69. }
  70. }
  71. ​
  72. return $entries;
  73. }
  74. ​
  75. public static function parseSubjects(string $raw): array
  76. {
  77. return array_column(self::subjectEntries($raw), 'name');
  78. }
  79. ​
  80. private static function recipientForSubject(array $settings, string $subject): string
  81. {
  82. foreach (self::subjectEntries((string) ($settings['contact_subjects'] ?? '')) as $entry) {
  83. if ($entry['name'] === $subject && $entry['email'] !== '') {
  84. return $entry['email'];
  85. }
  86. }
  87. ​
  88. return (string) $settings['contact_recipient_email'];
  89. }
  90. ​
  91. private static function subjectMode(array $settings): string
  92. {
  93. $mode = in_array($settings['contact_subject_mode'] ?? 'free', self::SUBJECT_MODES, true) ? (string) $settings['contact_subject_mode'] : 'free';
  94. ​
  95. return $mode !== 'free' && self::parseSubjects((string) ($settings['contact_subjects'] ?? '')) === [] ? 'free' : $mode;
  96. }
  97. ​
  98. private static function resolveSubject(array $settings): ?string
  99. {
  100. $mode = self::subjectMode($settings);
  101. $choice = trim((string) ($_POST['contact_subject_choice'] ?? ''));
  102. $custom = trim((string) preg_replace('/[\r\n\t]+/', ' ', (string) ($_POST['contact_subject'] ?? '')));
  103. self::$old['subject_choice'] = $choice;
  104. self::$old['subject'] = $custom;
  105. ​
  106. if ($mode === 'free' || ($mode === 'list_other' && $choice === self::OTHER_VALUE)) {
  107. return $custom !== '' && mb_strlen($custom) <= self::SUBJECT_MAX ? $custom : null;
  108. }
  109. ​
  110. return in_array($choice, self::parseSubjects((string) ($settings['contact_subjects'] ?? '')), true) ? $choice : null;
  111. }
  112. ​
  113. public static function handleRequest(string $pageTitle): void
  114. {
  115. if (!self::isEnabled()) {
  116. return;
  117. }
  118. ​
  119. if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST' || ($_POST['action'] ?? '') !== 'contact_submit') {
  120. AntiBot::refresh('contact');
  121. return;
  122. }
  123. ​
  124. $name = trim((string) preg_replace('/[\r\n\t]+/', ' ', (string) ($_POST['contact_name'] ?? '')));
  125. $email = trim((string) ($_POST['contact_email'] ?? ''));
  126. $message = trim(str_replace("\r\n", "\n", (string) ($_POST['contact_message'] ?? '')));
  127. self::$old = ['name' => $name, 'email' => $email, 'message' => $message];
  128. $subject = self::resolveSubject(SiteFront::settings());
  129. ​
  130. $clientIp = (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
  131. ​
  132. if (!Csrf::verify($_POST['csrf_token'] ?? null)) {
  133. self::$error = Language::get('form_security_failed', 'Security check failed, please try again.');
  134. } elseif (($wait = LoginThrottle::secondsUntilAllowed('contact', $clientIp)) > 0) {
  135. 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)));
  136. } elseif (!AntiBot::verify('contact', $_POST['antibot_answer'] ?? null, $_POST['antibot_started'] ?? null, $_POST['website'] ?? null)) {
  137. self::$error = Language::get('contact_error_antibot', 'The security code is incorrect. Please try again.');
  138. } elseif ($name === '' || mb_strlen($name) > self::NAME_MAX) {
  139. self::$error = Language::get('contact_error_name', 'Please enter your name.');
  140. } elseif ($email === '' || mb_strlen($email) > 190 || filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
  141. self::$error = Language::get('contact_error_email', 'Please enter a valid email address.');
  142. } elseif ($subject === null) {
  143. self::$error = Language::get('contact_error_subject', 'Please choose or enter a subject.');
  144. } elseif (mb_strlen($message) < self::MESSAGE_MIN || mb_strlen($message) > self::MESSAGE_MAX) {
  145. self::$error = sprintf(Language::get('contact_error_message', 'Your message must be between %d and %d characters long.'), self::MESSAGE_MIN, self::MESSAGE_MAX);
  146. }
  147. ​
  148. if (self::$error !== null) {
  149. AntiBot::refresh('contact');
  150. return;
  151. }
  152. ​
  153. LoginThrottle::recordFailure('contact', $clientIp);
  154. ​
  155. if (!self::send($name, $email, (string) $subject, $message, $pageTitle, $clientIp)) {
  156. self::$error = Language::get('contact_error_send', 'Your message could not be sent right now. Please try again later.');
  157. AntiBot::refresh('contact');
  158. return;
  159. }
  160. ​
  161. header('Location: ' . self::returnUrl('sent') . '#contact-form');
  162. exit;
  163. }
  164. ​
  165. public static function render(string $title): string
  166. {
  167. if (!self::isEnabled()) {
  168. return '';
  169. }
  170. ​
  171. $sent = ($_GET['contact'] ?? '') === 'sent';
  172. $html = '<section class="contact-form-block" id="contact-form">';
  173. if (trim($title) !== '') {
  174. $html .= '<h2 class="contact-form-title">' . htmlspecialchars($title) . '</h2>';
  175. }
  176. ​
  177. if ($sent) {
  178. $html .= '<p class="contact-form-alert contact-form-alert-success" role="status">'
  179. . htmlspecialchars(Language::get('contact_success', 'Thank you! Your message has been sent.')) . '</p>';
  180. }
  181. if (self::$error !== null) {
  182. $html .= '<p class="contact-form-alert contact-form-alert-error" role="alert">' . htmlspecialchars(self::$error) . '</p>';
  183. }
  184. ​
  185. $old = static fn(string $key): string => htmlspecialchars((string) (self::$old[$key] ?? ''), ENT_QUOTES);
  186. ​
  187. $html .= '<form method="post" action="#contact-form" class="contact-form">'
  188. . Csrf::field()
  189. . '<input type="hidden" name="action" value="contact_submit">'
  190. . AntiBot::field('contact')
  191. . '<label for="contact-name">' . htmlspecialchars(Language::get('contact_name_label', 'Your name')) . '</label>'
  192. . '<input type="text" id="contact-name" name="contact_name" required maxlength="' . self::NAME_MAX . '" autocomplete="name" value="' . $old('name') . '">'
  193. . '<label for="contact-email">' . htmlspecialchars(Language::get('contact_email_label', 'Your email')) . '</label>'
  194. . '<input type="email" id="contact-email" name="contact_email" required maxlength="190" autocomplete="email" value="' . $old('email') . '">'
  195. . self::renderSubjectField()
  196. . '<label for="contact-message">' . htmlspecialchars(Language::get('contact_message_label', 'Message')) . '</label>'
  197. . '<textarea id="contact-message" name="contact_message" rows="6" required minlength="' . self::MESSAGE_MIN . '" maxlength="' . self::MESSAGE_MAX . '">' . $old('message') . '</textarea>'
  198. . '<div class="antibot-box contact-antibot">'
  199. . '<div class="antibot-image">' . AntiBot::image('contact') . '</div>'
  200. . '<label for="contact-antibot">' . htmlspecialchars(Language::get('auth_security_code', 'Security code')) . '</label>'
  201. . '<input id="contact-antibot" type="text" name="antibot_answer" required inputmode="text" autocomplete="off" maxlength="6" spellcheck="false">'
  202. . '</div>'
  203. . '<button type="submit" class="unlock-btn contact-submit">' . htmlspecialchars(Language::get('contact_send_button', 'Send message')) . '</button>'
  204. . '</form>'
  205. . '</section>';
  206. ​
  207. return $html;
  208. }
  209. ​
  210. private static function renderSubjectField(): string
  211. {
  212. $settings = SiteFront::settings();
  213. $mode = self::subjectMode($settings);
  214. $label = '<label for="contact-subject">' . htmlspecialchars(Language::get('contact_subject_label', 'Subject')) . '</label>';
  215. $customValue = htmlspecialchars((string) (self::$old['subject'] ?? ''), ENT_QUOTES);
  216. $customInput = static fn(string $id, bool $required, string $extra = ''): string => '<input type="text" id="' . $id . '" name="contact_subject" maxlength="' . self::SUBJECT_MAX . '"'
  217. . ($required ? ' required' : '') . $extra . ' value="' . $customValue . '">';
  218. ​
  219. if ($mode === 'free') {
  220. return $label . $customInput('contact-subject', true);
  221. }
  222. ​
  223. $selected = (string) (self::$old['subject_choice'] ?? '');
  224. $options = '<option value="" disabled' . ($selected === '' ? ' selected' : '') . '>' . htmlspecialchars(Language::get('contact_subject_choose', 'Choose a subject…')) . '</option>';
  225. foreach (self::parseSubjects((string) ($settings['contact_subjects'] ?? '')) as $subject) {
  226. $options .= '<option value="' . htmlspecialchars($subject, ENT_QUOTES) . '"' . ($selected === $subject ? ' selected' : '') . '>' . htmlspecialchars($subject) . '</option>';
  227. }
  228. ​
  229. if ($mode === 'list_only') {
  230. return $label . '<select id="contact-subject" name="contact_subject_choice" required>' . $options . '</select>';
  231. }
  232. ​
  233. $otherSelected = $selected === self::OTHER_VALUE;
  234. $options .= '<option value="' . self::OTHER_VALUE . '"' . ($otherSelected ? ' selected' : '') . '>' . htmlspecialchars(Language::get('contact_subject_other', 'Other…')) . '</option>';
  235. ​
  236. return $label
  237. . '<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>'
  238. . $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) . '"');
  239. }
  240. ​
  241. private static function returnUrl(string $state): string
  242. {
  243. $requestUri = (string) ($_SERVER['REQUEST_URI'] ?? '/');
  244. $path = (string) (parse_url($requestUri, PHP_URL_PATH) ?: '/');
  245. parse_str((string) (parse_url($requestUri, PHP_URL_QUERY) ?? ''), $query);
  246. unset($query['contact']);
  247. $query['contact'] = $state;
  248. ​
  249. return $path . '?' . http_build_query($query);
  250. }
  251. ​
  252. private static function send(string $name, string $email, string $visitorSubject, string $message, string $pageTitle, string $clientIp): bool
  253. {
  254. $settings = SiteFront::settings();
  255. $siteName = (string) ($settings['site_name'] ?? 'Website');
  256. $recipient = self::recipientForSubject($settings, $visitorSubject);
  257. $provider = in_array($settings['contact_provider'] ?? 'php_mail', self::PROVIDERS, true) ? (string) $settings['contact_provider'] : 'php_mail';
  258. ​
  259. $subject = str_replace(
  260. ['{subject}', '{name}', '{site}'],
  261. [mb_substr($visitorSubject, 0, self::SUBJECT_MAX), mb_substr($name, 0, 60), $siteName],
  262. Language::get('contact_email_subject', '{subject} — message from {name} ({site})')
  263. );
  264. $html = self::emailBody($siteName, $name, $email, $visitorSubject, $message, $pageTitle, $clientIp);
  265. ​
  266. try {
  267. return match ($provider) {
  268. 'resend' => self::sendResend($settings, $recipient, $subject, $html, $email, $siteName),
  269. 'postmark' => self::sendPostmark($settings, $recipient, $subject, $html, $email, $siteName),
  270. 'store_mailer' => self::sendStoreMailer($recipient, $subject, $html, $email),
  271. default => self::sendPhpMail($settings, $recipient, $subject, $html, $email, $siteName),
  272. };
  273. } catch (Throwable $exception) {
  274. error_log('StocketBase contact form: ' . $exception->getMessage());
  275. ​
  276. return false;
  277. }
  278. }
  279. ​
  280. private static function emailBody(string $siteName, string $name, string $email, string $visitorSubject, string $message, string $pageTitle, string $clientIp): string
  281. {
  282. $row = static fn(string $label, string $value): string => '<tr><td style="padding:6px 12px 6px 0;color:#64748b;vertical-align:top;white-space:nowrap;">'
  283. . htmlspecialchars($label) . '</td><td style="padding:6px 0;color:#0f172a;">' . $value . '</td></tr>';
  284. ​
  285. 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;">'
  286. . '<div style="max-width:600px;margin:0 auto;background:#ffffff;border:1px solid #e2e8f0;border-radius:12px;padding:28px;">'
  287. . '<h1 style="margin:0 0 18px;font-size:19px;color:#0f172a;">' . htmlspecialchars(Language::get('contact_email_heading', 'New contact form message')) . '</h1>'
  288. . '<table style="border-collapse:collapse;font-size:14px;margin:0 0 18px;">'
  289. . $row(Language::get('contact_name_label', 'Your name'), htmlspecialchars($name))
  290. . $row(Language::get('contact_email_label', 'Your email'), '<a href="mailto:' . htmlspecialchars($email) . '" style="color:#2563eb;">' . htmlspecialchars($email) . '</a>')
  291. . $row(Language::get('contact_subject_label', 'Subject'), '<strong>' . htmlspecialchars($visitorSubject) . '</strong>')
  292. . $row(Language::get('contact_email_page', 'Page'), htmlspecialchars($pageTitle))
  293. . $row('IP', htmlspecialchars($clientIp))
  294. . $row(Language::get('contact_email_date', 'Date'), htmlspecialchars(date('Y-m-d H:i')))
  295. . '</table>'
  296. . '<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>'
  297. . '<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>'
  298. . '</div></body></html>';
  299. }
  300. ​
  301. private static function fromEmail(array $settings): string
  302. {
  303. foreach ([(string) ($settings['contact_from_email'] ?? ''), (string) ($settings['mail_from_email'] ?? '')] as $candidate) {
  304. $candidate = trim($candidate);
  305. if ($candidate !== '' && filter_var($candidate, FILTER_VALIDATE_EMAIL) !== false) {
  306. return $candidate;
  307. }
  308. }
  309. ​
  310. $host = (string) parse_url((string) Config::get('APP_URL', ''), PHP_URL_HOST);
  311. $host = preg_replace('/^www\./i', '', $host) ?: 'localhost';
  312. ​
  313. return 'no-reply@' . $host;
  314. }
  315. ​
  316. private static function sendStoreMailer(string $to, string $subject, string $html, string $replyTo): bool
  317. {
  318. require_once __DIR__ . '/mailer.php';
  319. ​
  320. return Mailer::send($to, $subject, $html, $replyTo);
  321. }
  322. ​
  323. private static function sendPhpMail(array $settings, string $to, string $subject, string $html, string $replyTo, string $siteName): bool
  324. {
  325. if (!function_exists('mail')) {
  326. error_log('StocketBase contact form: PHP mail() is not available on this server.');
  327. ​
  328. return false;
  329. }
  330. ​
  331. $from = self::fromEmail($settings);
  332. $boundary = 'sb_' . bin2hex(random_bytes(12));
  333. $headers = [
  334. 'From' => mb_encode_mimeheader($siteName, 'UTF-8') . ' <' . $from . '>',
  335. 'Reply-To' => $replyTo,
  336. 'MIME-Version' => '1.0',
  337. 'Content-Type' => 'multipart/alternative; boundary="' . $boundary . '"',
  338. 'X-Mailer' => 'StocketBase',
  339. ];
  340. $body = self::multipartBody($boundary, self::plainTextFromHtml($html), $html);
  341. $encodedSubject = mb_encode_mimeheader($subject, 'UTF-8');
  342. ​
  343. if (preg_match('/^[A-Za-z0-9._+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/', $from) === 1 && @mail($to, $encodedSubject, $body, $headers, '-f' . $from)) {
  344. return true;
  345. }
  346. ​
  347. return @mail($to, $encodedSubject, $body, $headers);
  348. }
  349. ​
  350. private static function multipartBody(string $boundary, string $text, string $html): string
  351. {
  352. return '--' . $boundary . "\n"
  353. . "Content-Type: text/plain; charset=UTF-8\n"
  354. . "Content-Transfer-Encoding: base64\n\n"
  355. . chunk_split(base64_encode($text), 76, "\n")
  356. . '--' . $boundary . "\n"
  357. . "Content-Type: text/html; charset=UTF-8\n"
  358. . "Content-Transfer-Encoding: base64\n\n"
  359. . chunk_split(base64_encode($html), 76, "\n")
  360. . '--' . $boundary . "--\n";
  361. }
  362. ​
  363. private static function plainTextFromHtml(string $html): string
  364. {
  365. $text = (string) preg_replace('#<(br|/p|/tr|/h1|/div|/li)\b[^>]*>#i', "\n", $html);
  366. $text = (string) preg_replace('#</td>\s*<td[^>]*>#i', ': ', $text);
  367. $text = html_entity_decode(strip_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8');
  368. $text = (string) preg_replace("/[ \t]+/", ' ', $text);
  369. $text = (string) preg_replace("/\n\s*\n\s*\n+/", "\n\n", $text);
  370. ​
  371. return trim($text) . "\n";
  372. }
  373. ​
  374. private static function sendResend(array $settings, string $to, string $subject, string $html, string $replyTo, string $siteName): bool
  375. {
  376. $apiKey = trim((string) (($settings['contact_resend_api_key'] ?? '') !== '' ? $settings['contact_resend_api_key'] : ($settings['mail_resend_api_key'] ?? '')));
  377. if ($apiKey === '') {
  378. error_log('StocketBase contact form: Resend API key is missing.');
  379. ​
  380. return false;
  381. }
  382. ​
  383. return self::postJson('https://api.resend.com/emails', ['Authorization: Bearer ' . $apiKey], [
  384. 'from' => $siteName . ' <' . self::fromEmail($settings) . '>',
  385. 'to' => [$to],
  386. 'subject' => $subject,
  387. 'html' => $html,
  388. 'reply_to' => $replyTo,
  389. ]);
  390. }
  391. ​
  392. private static function sendPostmark(array $settings, string $to, string $subject, string $html, string $replyTo, string $siteName): bool
  393. {
  394. $token = trim((string) (($settings['contact_postmark_token'] ?? '') !== '' ? $settings['contact_postmark_token'] : ($settings['mail_postmark_server_token'] ?? '')));
  395. if ($token === '') {
  396. error_log('StocketBase contact form: Postmark server token is missing.');
  397. ​
  398. return false;
  399. }
  400. ​
  401. return self::postJson('https://api.postmarkapp.com/email', ['X-Postmark-Server-Token: ' . $token], [
  402. 'From' => $siteName . ' <' . self::fromEmail($settings) . '>',
  403. 'To' => $to,
  404. 'Subject' => $subject,
  405. 'HtmlBody' => $html,
  406. 'ReplyTo' => $replyTo,
  407. 'MessageStream' => 'outbound',
  408. ]);
  409. }
  410. ​
  411. private static function postJson(string $url, array $headers, array $payload): bool
  412. {
  413. if (!function_exists('curl_init')) {
  414. error_log('StocketBase contact form: cURL is not available.');
  415. ​
  416. return false;
  417. }
  418. ​
  419. $curl = curl_init($url);
  420. curl_setopt_array($curl, [
  421. CURLOPT_POST => true,
  422. CURLOPT_POSTFIELDS => (string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
  423. CURLOPT_HTTPHEADER => array_merge(['Content-Type: application/json', 'Accept: application/json'], $headers),
  424. CURLOPT_RETURNTRANSFER => true,
  425. CURLOPT_CONNECTTIMEOUT => 10,
  426. CURLOPT_TIMEOUT => 20,
  427. ]);
  428. $response = curl_exec($curl);
  429. $status = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
  430. curl_close($curl);
  431. ​
  432. if ($response === false || $status < 200 || $status >= 300) {
  433. error_log('StocketBase contact form: provider returned HTTP ' . $status . ' — ' . mb_substr((string) $response, 0, 300));
  434. ​
  435. return false;
  436. }
  437. ​
  438. return true;
  439. }
  440. }
  441. ​