WebOrbiton
v1.0.0.9

Publisium

487 lines · 22.3 KB
  1. <?php
  2. ​
  3. declare(strict_types=1);
  4. ​
  5. require_once __DIR__ . '/database.php';
  6. require_once __DIR__ . '/language.php';
  7. require_once __DIR__ . '/antibot.php';
  8. require_once __DIR__ . '/csrf.php';
  9. require_once __DIR__ . '/ai.php';
  10. ​
  11. final class ContactForm
  12. {
  13. public const PROVIDERS = [
  14. 'mail' => 'PHP mail(), built in and works on most hosting',
  15. 'resend' => 'Resend (API)',
  16. 'brevo' => 'Brevo (API)',
  17. ];
  18. ​
  19. public const BLOCK_TYPE = 'contact_form';
  20. ​
  21. private const API_PROVIDERS = ['resend', 'brevo'];
  22. private const SETTING_KEYS = ['contact_enabled', 'contact_recipient', 'contact_provider', 'contact_from_email', 'contact_from_name', 'contact_include_ip', 'contact_topics', 'contact_subject_mode'];
  23. public const SUBJECT_MODES = [
  24. 'free' => 'Visitors type their own subject',
  25. 'list_other' => 'Pick from the list below or choose "Other…" and type their own',
  26. 'list' => 'Only the subjects from the list below',
  27. ];
  28. private const MAX_TOPICS = 30;
  29. private const MAX_NAME = 100;
  30. private const MAX_EMAIL = 190;
  31. private const MAX_SUBJECT = 150;
  32. private const MAX_MESSAGE = 5000;
  33. private const FLASH_KEY = 'contact_form_flash';
  34. ​
  35. private static ?array $settings = null;
  36. ​
  37. public static function settings(): array
  38. {
  39. if (self::$settings !== null) {
  40. return self::$settings;
  41. }
  42. ​
  43. $settings = [
  44. 'contact_enabled' => '0',
  45. 'contact_recipient' => '',
  46. 'contact_provider' => 'mail',
  47. 'contact_from_email' => '',
  48. 'contact_from_name' => '',
  49. 'contact_include_ip' => '1',
  50. 'contact_topics' => '',
  51. 'contact_subject_mode' => '',
  52. ];
  53. ​
  54. try {
  55. $placeholders = implode(',', array_fill(0, count(self::SETTING_KEYS), '?'));
  56. $statement = Database::site()->prepare('SELECT setting_key, setting_value FROM site_settings WHERE setting_key IN (' . $placeholders . ')');
  57. $statement->execute(self::SETTING_KEYS);
  58. foreach ($statement->fetchAll(PDO::FETCH_KEY_PAIR) as $key => $value) {
  59. $settings[$key] = (string) $value;
  60. }
  61. } catch (PDOException $exception) {
  62. return self::$settings = $settings;
  63. }
  64. ​
  65. if (!isset(self::PROVIDERS[$settings['contact_provider']])) {
  66. $settings['contact_provider'] = 'mail';
  67. }
  68. ​
  69. return self::$settings = $settings;
  70. }
  71. ​
  72. public static function isEnabled(): bool
  73. {
  74. $settings = self::settings();
  75. ​
  76. return $settings['contact_enabled'] === '1' && filter_var($settings['contact_recipient'], FILTER_VALIDATE_EMAIL) !== false;
  77. }
  78. ​
  79. public static function defaultFromEmail(): string
  80. {
  81. $host = (string) (parse_url((string) Config::get('APP_URL', ''), PHP_URL_HOST) ?: ($_SERVER['HTTP_HOST'] ?? 'localhost'));
  82. $host = preg_replace('/^www\./i', '', strtolower($host)) ?: 'localhost';
  83. ​
  84. return 'no-reply@' . $host;
  85. }
  86. ​
  87. public static function subjectMode(): string
  88. {
  89. $mode = self::settings()['contact_subject_mode'];
  90. if (!isset(self::SUBJECT_MODES[$mode])) {
  91. $mode = self::settings()['contact_topics'] !== '' ? 'list' : 'free';
  92. }
  93. ​
  94. return $mode;
  95. }
  96. ​
  97. private static function activeSubjectMode(): string
  98. {
  99. $mode = self::subjectMode();
  100. ​
  101. return $mode !== 'free' && self::topics() === [] ? 'free' : $mode;
  102. }
  103. ​
  104. public static function topics(?string $raw = null): array
  105. {
  106. $topics = [];
  107. foreach (preg_split('/\R/', $raw ?? self::settings()['contact_topics']) ?: [] as $line) {
  108. [$label, $recipient] = array_pad(array_map('trim', explode('|', $line, 2)), 2, '');
  109. $label = self::singleLine($label, self::MAX_SUBJECT);
  110. if ($label === '') {
  111. continue;
  112. }
  113. $topics[] = ['label' => $label, 'recipient' => $recipient];
  114. if (count($topics) >= self::MAX_TOPICS) {
  115. break;
  116. }
  117. }
  118. ​
  119. return $topics;
  120. }
  121. ​
  122. public static function keyState(string $provider): array
  123. {
  124. return Ai::keyState(self::secretName($provider));
  125. }
  126. ​
  127. public static function saveFromRequest(PDO $db, array $post): ?string
  128. {
  129. $recipient = trim((string) ($post['contact_recipient'] ?? ''));
  130. $fromEmail = trim((string) ($post['contact_from_email'] ?? ''));
  131. $provider = (string) ($post['contact_provider'] ?? 'mail');
  132. $enabled = isset($post['contact_enabled']);
  133. ​
  134. if (!isset(self::PROVIDERS[$provider])) {
  135. $provider = 'mail';
  136. }
  137. if ($recipient !== '' && filter_var($recipient, FILTER_VALIDATE_EMAIL) === false) {
  138. return 'Check the email address that receives messages, it doesn’t look right.';
  139. }
  140. if ($fromEmail !== '' && filter_var($fromEmail, FILTER_VALIDATE_EMAIL) === false) {
  141. return 'Check the "Send from" address, it doesn’t look right.';
  142. }
  143. if ($enabled && $recipient === '') {
  144. return 'Add the email address where messages should go.';
  145. }
  146. ​
  147. $topicLines = [];
  148. if (isset($post['contact_topics_submitted'])) {
  149. $labels = is_array($post['contact_topic_label'] ?? null) ? array_values($post['contact_topic_label']) : [];
  150. $emails = is_array($post['contact_topic_email'] ?? null) ? array_values($post['contact_topic_email']) : [];
  151. foreach ($labels as $index => $label) {
  152. $label = str_replace('|', '/', (string) $label);
  153. $email = trim((string) ($emails[$index] ?? ''));
  154. $topicLines[] = $label . ($email !== '' ? ' | ' . $email : '');
  155. }
  156. }
  157. $topics = self::topics(isset($post['contact_topics_submitted']) ? implode("\n", $topicLines) : self::settings()['contact_topics']);
  158. foreach ($topics as $topic) {
  159. if ($topic['recipient'] !== '' && filter_var($topic['recipient'], FILTER_VALIDATE_EMAIL) === false) {
  160. return 'The topic "' . $topic['label'] . '" has an email address that doesn’t look right.';
  161. }
  162. }
  163. $topicsText = implode("\n", array_map(
  164. static fn(array $topic): string => $topic['label'] . ($topic['recipient'] !== '' ? ' | ' . $topic['recipient'] : ''),
  165. $topics
  166. ));
  167. ​
  168. foreach (self::API_PROVIDERS as $apiProvider) {
  169. $key = trim((string) ($post['contact_key_' . $apiProvider] ?? ''));
  170. try {
  171. if (isset($post['contact_key_' . $apiProvider . '_remove'])) {
  172. Ai::forgetSecret(self::secretName($apiProvider));
  173. } elseif ($key !== '') {
  174. Ai::storeSecret(self::secretName($apiProvider), $key);
  175. }
  176. } catch (Throwable $exception) {
  177. return 'We couldn’t save the ' . self::PROVIDERS[$apiProvider] . ' key: ' . $exception->getMessage();
  178. }
  179. }
  180. ​
  181. if ($enabled && in_array($provider, self::API_PROVIDERS, true) && self::apiKey($provider) === null) {
  182. return 'Add an API key for ' . self::PROVIDERS[$provider] . ' or pick a different email service.';
  183. }
  184. ​
  185. $statement = $db->prepare(
  186. 'INSERT INTO site_settings (setting_key, setting_value) VALUES (:key, :value) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)'
  187. );
  188. $values = [
  189. 'contact_enabled' => $enabled ? '1' : '0',
  190. 'contact_recipient' => $recipient,
  191. 'contact_provider' => $provider,
  192. 'contact_from_email' => $fromEmail,
  193. 'contact_from_name' => self::singleLine((string) ($post['contact_from_name'] ?? ''), self::MAX_NAME),
  194. 'contact_include_ip' => isset($post['contact_include_ip']) ? '1' : '0',
  195. 'contact_topics' => $topicsText,
  196. 'contact_subject_mode' => isset(self::SUBJECT_MODES[(string) ($post['contact_subject_mode'] ?? '')]) ? (string) $post['contact_subject_mode'] : self::subjectMode(),
  197. ];
  198. foreach ($values as $key => $value) {
  199. $statement->execute(['key' => $key, 'value' => $value]);
  200. }
  201. self::$settings = null;
  202. ​
  203. return null;
  204. }
  205. ​
  206. public static function renderBlock(): string
  207. {
  208. if (!self::isEnabled() || session_status() !== PHP_SESSION_ACTIVE) {
  209. return '';
  210. }
  211. ​
  212. $flash = $_SESSION[self::FLASH_KEY] ?? null;
  213. unset($_SESSION[self::FLASH_KEY]);
  214. $old = is_array($flash['old'] ?? null) ? $flash['old'] : [];
  215. $value = static fn(string $field): string => htmlspecialchars((string) ($old[$field] ?? ''), ENT_QUOTES);
  216. $text = static fn(string $key, string $default): string => htmlspecialchars(Language::get($key, $default), ENT_QUOTES);
  217. $returnTo = htmlspecialchars((string) ($_SERVER['REQUEST_URI'] ?? ''), ENT_QUOTES);
  218. ​
  219. $html = '<section class="contact-form" id="contact-form">';
  220. if (is_array($flash) && isset($flash['message'])) {
  221. $html .= '<div class="contact-form-' . ($flash['type'] === 'success' ? 'success' : 'error') . '" role="status">' . htmlspecialchars((string) $flash['message']) . '</div>';
  222. }
  223. ​
  224. $html .= '<form method="post" action="contact-send.php">'
  225. . Csrf::field()
  226. . AntiBot::field('contact')
  227. . '<input type="hidden" name="return_to" value="' . $returnTo . '">'
  228. . '<div class="contact-form-row">'
  229. . '<div><label for="contact-name">' . $text('contact_name', 'Your name') . '</label>'
  230. . '<input id="contact-name" type="text" name="name" required maxlength="' . self::MAX_NAME . '" autocomplete="name" value="' . $value('name') . '"></div>'
  231. . '<div><label for="contact-email">' . $text('contact_email', 'Your email') . '</label>'
  232. . '<input id="contact-email" type="email" name="email" required maxlength="' . self::MAX_EMAIL . '" autocomplete="email" value="' . $value('email') . '"></div>'
  233. . '</div>'
  234. . self::subjectField($old, $text)
  235. . '<label for="contact-message">' . $text('contact_message', 'Message') . '</label>'
  236. . '<textarea id="contact-message" name="message" rows="6" required maxlength="' . self::MAX_MESSAGE . '">' . $value('message') . '</textarea>'
  237. . '<div class="antibot-box">'
  238. . '<div class="antibot-image">' . AntiBot::image('contact') . '</div>'
  239. . '<label for="contact-antibot-answer">' . $text('contact_security_code', 'Security code') . '</label>'
  240. . '<input id="contact-antibot-answer" type="text" name="antibot_answer" required inputmode="text" autocomplete="off" maxlength="6" spellcheck="false">'
  241. . '</div>'
  242. . '<button type="submit">' . $text('contact_send', 'Send message') . '</button>'
  243. . '</form></section>';
  244. ​
  245. return $html;
  246. }
  247. ​
  248. private static function subjectField(array $old, callable $text): string
  249. {
  250. $mode = self::activeSubjectMode();
  251. $subjectValue = htmlspecialchars((string) ($old['subject'] ?? ''), ENT_QUOTES);
  252. ​
  253. if ($mode === 'free') {
  254. return '<label for="contact-subject">' . $text('contact_subject', 'Subject') . '</label>'
  255. . '<input id="contact-subject" type="text" name="subject" maxlength="' . self::MAX_SUBJECT . '" value="' . $subjectValue . '">';
  256. }
  257. ​
  258. $selected = (string) ($old['topic'] ?? '');
  259. $html = '<label for="contact-topic">' . $text('contact_topic', 'Topic') . '</label>'
  260. . '<select id="contact-topic" name="topic" required>'
  261. . '<option value="" disabled' . ($selected === '' ? ' selected' : '') . '>' . $text('contact_topic_choose', 'Choose a topic') . '</option>';
  262. foreach (self::topics() as $index => $topic) {
  263. $html .= '<option value="' . $index . '"' . ($selected === (string) $index ? ' selected' : '') . '>' . htmlspecialchars($topic['label']) . '</option>';
  264. }
  265. ​
  266. if ($mode !== 'list_other') {
  267. return $html . '</select>';
  268. }
  269. ​
  270. $html .= '<option value="other"' . ($selected === 'other' ? ' selected' : '') . '>' . $text('contact_topic_other', 'Other…') . '</option></select>'
  271. . '<div class="contact-form-other" id="contact-subject-other">'
  272. . '<label for="contact-subject">' . $text('contact_subject_other', 'Your subject') . '</label>'
  273. . '<input id="contact-subject" type="text" name="subject" maxlength="' . self::MAX_SUBJECT . '" value="' . $subjectValue . '">'
  274. . '</div>'
  275. . '<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>';
  276. ​
  277. return $html;
  278. }
  279. ​
  280. public static function handleSubmission(array $post): array
  281. {
  282. $old = [
  283. 'name' => self::singleLine((string) ($post['name'] ?? ''), self::MAX_NAME),
  284. 'email' => self::singleLine((string) ($post['email'] ?? ''), self::MAX_EMAIL),
  285. 'subject' => self::singleLine((string) ($post['subject'] ?? ''), self::MAX_SUBJECT),
  286. 'topic' => preg_match('/^(\d{1,2}|other)$/', (string) ($post['topic'] ?? '')) === 1 ? (string) $post['topic'] : '',
  287. 'message' => mb_substr(trim(str_replace("\r\n", "\n", (string) ($post['message'] ?? ''))), 0, self::MAX_MESSAGE),
  288. ];
  289. ​
  290. $mode = self::activeSubjectMode();
  291. $recipient = null;
  292. if ($mode === 'free') {
  293. $old['topic'] = '';
  294. } elseif ($mode === 'list_other' && $old['topic'] === 'other') {
  295. if ($old['subject'] === '') {
  296. return self::fail(Language::get('contact_subject_other_missing', 'Please enter your subject.'), $old);
  297. }
  298. } else {
  299. $topic = $old['topic'] !== '' && $old['topic'] !== 'other' ? (self::topics()[(int) $old['topic']] ?? null) : null;
  300. if ($topic === null) {
  301. return self::fail(Language::get('contact_topic_missing', 'Please choose a topic.'), $old);
  302. }
  303. $old['subject'] = $topic['label'];
  304. $recipient = $topic['recipient'] !== '' ? $topic['recipient'] : null;
  305. }
  306. ​
  307. if (!self::isEnabled()) {
  308. return self::fail(Language::get('contact_unavailable', 'The contact form is not available right now.'), $old);
  309. }
  310. ​
  311. if (!Csrf::verify($post['csrf_token'] ?? null)
  312. || !AntiBot::verify('contact', $post['antibot_answer'] ?? null, $post['antibot_started'] ?? null, $post['website'] ?? null)) {
  313. return self::fail(Language::get('contact_security_failed', 'The security code was wrong. Please try again.'), $old);
  314. }
  315. ​
  316. if ($old['name'] === '' || $old['message'] === '' || filter_var($old['email'], FILTER_VALIDATE_EMAIL) === false) {
  317. return self::fail(Language::get('contact_invalid', 'Please enter your name, a valid email address and a message.'), $old);
  318. }
  319. ​
  320. require_once __DIR__ . '/login-throttle.php';
  321. $wait = LoginThrottle::secondsUntilAllowed('contact', $old['email']);
  322. if ($wait > 0) {
  323. return self::fail(Language::get('contact_rate_limited', 'You have sent too many messages. Please try again later.'), $old);
  324. }
  325. ​
  326. $error = self::send($old, $recipient);
  327. if ($error !== null) {
  328. error_log('Publisium: contact form delivery failed: ' . $error);
  329. ​
  330. return self::fail(Language::get('contact_send_failed', 'Your message could not be sent. Please try again later.'), $old);
  331. }
  332. ​
  333. LoginThrottle::recordFailure('contact', $old['email']);
  334. $_SESSION[self::FLASH_KEY] = ['type' => 'success', 'message' => Language::get('contact_sent', 'Thank you! Your message has been sent.')];
  335. ​
  336. return ['ok' => true];
  337. }
  338. ​
  339. private static function fail(string $message, array $old): array
  340. {
  341. $_SESSION[self::FLASH_KEY] = ['type' => 'error', 'message' => $message, 'old' => $old];
  342. ​
  343. return ['ok' => false];
  344. }
  345. ​
  346. private static function send(array $message, ?string $recipient = null): ?string
  347. {
  348. $settings = self::settings();
  349. $to = $recipient ?? $settings['contact_recipient'];
  350. $siteName = self::singleLine((string) Config::get('APP_NAME', 'Publisium'), self::MAX_NAME);
  351. $fromEmail = filter_var($settings['contact_from_email'], FILTER_VALIDATE_EMAIL) !== false ? $settings['contact_from_email'] : self::defaultFromEmail();
  352. $fromName = $settings['contact_from_name'] !== '' ? $settings['contact_from_name'] : $siteName;
  353. $subject = '[' . $siteName . '] ' . ($message['subject'] !== '' ? $message['subject'] : 'Contact form message');
  354. $body = 'Name: ' . $message['name'] . "\n"
  355. . 'Email: ' . $message['email'] . "\n"
  356. . ($message['subject'] !== '' ? ($message['topic'] !== '' && $message['topic'] !== 'other' ? 'Topic: ' : 'Subject: ') . $message['subject'] . "\n" : '')
  357. . 'Page: ' . self::singleLine((string) ($_SERVER['HTTP_REFERER'] ?? ''), 300) . "\n"
  358. . ($settings['contact_include_ip'] === '1' ? 'IP: ' . self::singleLine((string) ($_SERVER['REMOTE_ADDR'] ?? ''), 45) . "\n" : '')
  359. . "\n" . $message['message'] . "\n";
  360. ​
  361. return match ($settings['contact_provider']) {
  362. 'resend' => self::sendResend($to, $fromEmail, $fromName, $message, $subject, $body),
  363. 'brevo' => self::sendBrevo($to, $fromEmail, $fromName, $message, $subject, $body),
  364. default => self::sendMail($to, $fromEmail, $fromName, $message, $subject, $body),
  365. };
  366. }
  367. ​
  368. private static function sendMail(string $to, string $fromEmail, string $fromName, array $message, string $subject, string $body): ?string
  369. {
  370. $headers = [
  371. 'From' => mb_encode_mimeheader(self::displayName($fromName), 'UTF-8') . ' <' . $fromEmail . '>',
  372. 'Reply-To' => mb_encode_mimeheader(self::displayName($message['name']), 'UTF-8') . ' <' . $message['email'] . '>',
  373. 'MIME-Version' => '1.0',
  374. 'Content-Type' => 'text/plain; charset=UTF-8',
  375. 'Content-Transfer-Encoding' => '8bit',
  376. 'X-Mailer' => 'Publisium',
  377. ];
  378. ​
  379. $encodedSubject = mb_encode_mimeheader($subject, 'UTF-8');
  380. $sent = @mail($to, $encodedSubject, $body, $headers, '-f' . $fromEmail) || @mail($to, $encodedSubject, $body, $headers);
  381. ​
  382. return $sent ? null : 'mail() returned false. Check that the server can send email.';
  383. }
  384. ​
  385. private static function sendResend(string $to, string $fromEmail, string $fromName, array $message, string $subject, string $body): ?string
  386. {
  387. $key = self::apiKey('resend');
  388. if ($key === null) {
  389. return 'Add your Resend API key first.';
  390. }
  391. ​
  392. [$status, $response] = self::post('https://api.resend.com/emails', ['Authorization: Bearer ' . $key], [
  393. 'from' => self::displayName($fromName) . ' <' . $fromEmail . '>',
  394. 'to' => [$to],
  395. 'reply_to' => $message['email'],
  396. 'subject' => $subject,
  397. 'text' => $body,
  398. ]);
  399. ​
  400. return $status >= 200 && $status < 300 ? null : 'Resend responded with HTTP ' . $status . ': ' . $response;
  401. }
  402. ​
  403. private static function sendBrevo(string $to, string $fromEmail, string $fromName, array $message, string $subject, string $body): ?string
  404. {
  405. $key = self::apiKey('brevo');
  406. if ($key === null) {
  407. return 'Add your Brevo API key first.';
  408. }
  409. ​
  410. [$status, $response] = self::post('https://api.brevo.com/v3/smtp/email', ['api-key: ' . $key], [
  411. 'sender' => ['name' => self::displayName($fromName), 'email' => $fromEmail],
  412. 'to' => [['email' => $to]],
  413. 'replyTo' => ['email' => $message['email'], 'name' => self::displayName($message['name'])],
  414. 'subject' => $subject,
  415. 'textContent' => $body,
  416. ]);
  417. ​
  418. return $status >= 200 && $status < 300 ? null : 'Brevo responded with HTTP ' . $status . ': ' . $response;
  419. }
  420. ​
  421. private static function post(string $url, array $headers, array $payload): array
  422. {
  423. $body = (string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE);
  424. $headers[] = 'Content-Type: application/json';
  425. $headers[] = 'Accept: application/json';
  426. ​
  427. if (function_exists('curl_init')) {
  428. $curl = curl_init($url);
  429. curl_setopt_array($curl, [
  430. CURLOPT_POST => true,
  431. CURLOPT_POSTFIELDS => $body,
  432. CURLOPT_HTTPHEADER => $headers,
  433. CURLOPT_RETURNTRANSFER => true,
  434. CURLOPT_CONNECTTIMEOUT => 10,
  435. CURLOPT_TIMEOUT => 20,
  436. CURLOPT_FOLLOWLOCATION => false,
  437. ]);
  438. $response = curl_exec($curl);
  439. $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
  440. ​
  441. return [$status, is_string($response) ? substr($response, 0, 300) : curl_error($curl)];
  442. }
  443. ​
  444. $context = stream_context_create(['http' => [
  445. 'method' => 'POST',
  446. 'header' => implode("\r\n", $headers),
  447. 'content' => $body,
  448. 'timeout' => 20,
  449. 'ignore_errors' => true,
  450. 'follow_location' => 0,
  451. ]]);
  452. $response = @file_get_contents($url, false, $context);
  453. $status = 0;
  454. foreach ($http_response_header ?? [] as $line) {
  455. if (preg_match('#^HTTP/\S+\s+(\d{3})#', $line, $match) === 1) {
  456. $status = (int) $match[1];
  457. }
  458. }
  459. ​
  460. return [$status, is_string($response) ? substr($response, 0, 300) : 'no response'];
  461. }
  462. ​
  463. private static function apiKey(string $provider): ?string
  464. {
  465. $key = Ai::apiKey(self::secretName($provider));
  466. ​
  467. return $key !== null && $key !== '' ? $key : null;
  468. }
  469. ​
  470. private static function secretName(string $provider): string
  471. {
  472. return 'mail_' . $provider;
  473. }
  474. ​
  475. private static function displayName(string $name): string
  476. {
  477. $clean = trim((string) preg_replace('/[<>"\\\\,;:@]+/u', ' ', $name));
  478. ​
  479. return $clean !== '' ? $clean : 'Website visitor';
  480. }
  481. ​
  482. private static function singleLine(string $value, int $limit): string
  483. {
  484. return mb_substr(trim((string) preg_replace('/[\x00-\x1F\x7F]+/u', ' ', $value)), 0, $limit);
  485. }
  486. }
  487. ​