WebOrbiton
v1.0.0.0

StocketBase

445 lines · 15.5 KB
  1. <?php
  2. ​
  3. declare(strict_types=1);
  4. ​
  5. require_once __DIR__ . '/language.php';
  6. ​
  7. final class BlockEditor
  8. {
  9. private const ALLOWED_TYPES = [
  10. 'paragraph',
  11. 'heading2',
  12. 'heading3',
  13. 'image',
  14. 'video',
  15. 'quote',
  16. 'list',
  17. 'code',
  18. 'table',
  19. 'checklist',
  20. 'callout',
  21. 'separator',
  22. 'embed',
  23. ];
  24. ​
  25. private const SINGLE_USE_TYPES = ['contact_form'];
  26. ​
  27. private static bool $interactive = true;
  28. ​
  29. public static function sanitize(string $rawJson, bool $allowContactForm = false): string
  30. {
  31. $decoded = json_decode($rawJson, true);
  32. ​
  33. if (!is_array($decoded) || !isset($decoded['blocks']) || !is_array($decoded['blocks'])) {
  34. return json_encode(['blocks' => []]);
  35. }
  36. ​
  37. $cleanBlocks = [];
  38. $usedSingleTypes = [];
  39. $allowedTypes = $allowContactForm ? array_merge(self::ALLOWED_TYPES, ['contact_form']) : self::ALLOWED_TYPES;
  40. ​
  41. foreach ($decoded['blocks'] as $block) {
  42. if (!is_array($block) || !isset($block['type']) || !in_array($block['type'], $allowedTypes, true)) {
  43. continue;
  44. }
  45. ​
  46. if (in_array($block['type'], self::SINGLE_USE_TYPES, true)) {
  47. if (isset($usedSingleTypes[$block['type']])) {
  48. continue;
  49. }
  50. $usedSingleTypes[$block['type']] = true;
  51. }
  52. ​
  53. $data = is_array($block['data'] ?? null) ? $block['data'] : [];
  54. ​
  55. $cleanBlocks[] = [
  56. 'id' => is_string($block['id'] ?? null) ? substr($block['id'], 0, 40) : bin2hex(random_bytes(8)),
  57. 'type' => $block['type'],
  58. 'data' => self::sanitizeBlockData($block['type'], $data),
  59. ];
  60. }
  61. ​
  62. return json_encode(['blocks' => $cleanBlocks], JSON_UNESCAPED_UNICODE);
  63. }
  64. ​
  65. private static function sanitizeBlockData(string $type, array $data): array
  66. {
  67. return match ($type) {
  68. 'paragraph', 'quote' => [
  69. 'text' => self::cleanHtml((string) ($data['text'] ?? '')),
  70. ],
  71. 'heading2', 'heading3' => [
  72. 'text' => strip_tags((string) ($data['text'] ?? '')),
  73. ],
  74. 'image' => [
  75. 'src' => self::cleanUrl((string) ($data['src'] ?? '')),
  76. 'alt' => strip_tags((string) ($data['alt'] ?? '')),
  77. 'caption' => strip_tags((string) ($data['caption'] ?? '')),
  78. ],
  79. 'video' => [
  80. 'src' => self::cleanUrl((string) ($data['src'] ?? '')),
  81. 'caption' => strip_tags((string) ($data['caption'] ?? '')),
  82. ],
  83. 'list' => [
  84. 'style' => in_array($data['style'] ?? 'unordered', ['ordered', 'unordered'], true) ? $data['style'] : 'unordered',
  85. 'items' => array_map(
  86. static fn($item) => strip_tags((string) $item),
  87. is_array($data['items'] ?? null) ? $data['items'] : []
  88. ),
  89. ],
  90. 'code' => [
  91. 'language' => preg_replace('/[^a-zA-Z0-9_+-]/', '', (string) ($data['language'] ?? 'text')),
  92. 'code' => (string) ($data['code'] ?? ''),
  93. ],
  94. 'table' => [
  95. 'rows' => self::sanitizeTableRows(is_array($data['rows'] ?? null) ? $data['rows'] : []),
  96. ],
  97. 'checklist' => [
  98. 'items' => self::sanitizeChecklistItems(is_array($data['items'] ?? null) ? $data['items'] : []),
  99. ],
  100. 'callout' => [
  101. 'style' => in_array($data['style'] ?? 'info', ['info', 'warning', 'success', 'danger'], true) ? $data['style'] : 'info',
  102. 'text' => strip_tags((string) ($data['text'] ?? '')),
  103. ],
  104. 'embed' => [
  105. 'url' => self::cleanUrl((string) ($data['url'] ?? '')),
  106. ],
  107. 'separator' => [],
  108. 'contact_form' => [
  109. 'title' => mb_substr(trim(strip_tags((string) ($data['title'] ?? ''))), 0, 120),
  110. ],
  111. default => [],
  112. };
  113. }
  114. ​
  115. private static function sanitizeTableRows(array $rows): array
  116. {
  117. $clean = [];
  118. foreach ($rows as $row) {
  119. if (!is_array($row)) {
  120. continue;
  121. }
  122. $clean[] = array_map(static fn($cell) => strip_tags((string) $cell), $row);
  123. }
  124. ​
  125. return $clean;
  126. }
  127. ​
  128. private static function sanitizeChecklistItems(array $items): array
  129. {
  130. $clean = [];
  131. foreach ($items as $item) {
  132. if (!is_array($item)) {
  133. continue;
  134. }
  135. $clean[] = [
  136. 'text' => strip_tags((string) ($item['text'] ?? '')),
  137. 'checked' => (bool) ($item['checked'] ?? false),
  138. ];
  139. }
  140. ​
  141. return $clean;
  142. }
  143. ​
  144. private static function cleanUrl(string $url): string
  145. {
  146. $url = trim($url);
  147. if ($url === '') {
  148. return '';
  149. }
  150. ​
  151. if (str_starts_with($url, 'https://') || str_starts_with($url, 'http://')) {
  152. return filter_var($url, FILTER_SANITIZE_URL) ?: '';
  153. }
  154. ​
  155. if (preg_match('#^[a-zA-Z][a-zA-Z0-9+.-]*:#', $url) === 1) {
  156. return '';
  157. }
  158. ​
  159. if (str_starts_with($url, '//')) {
  160. return '';
  161. }
  162. ​
  163. $url = ltrim($url, '/');
  164. ​
  165. return filter_var($url, FILTER_SANITIZE_URL) ?: '';
  166. }
  167. ​
  168. private static function cleanHtml(string $text): string
  169. {
  170. if (trim($text) === '') {
  171. return '';
  172. }
  173. ​
  174. if (!class_exists(\DOMDocument::class)) {
  175. return strip_tags($text, '<b><strong><i><em><a><br><u><s><code>');
  176. }
  177. ​
  178. $allowedTags = ['b', 'strong', 'i', 'em', 'u', 's', 'code', 'a', 'span', 'br'];
  179. $allowedColors = '/^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/';
  180. ​
  181. $wrapped = '<?xml encoding="utf-8"?><div>' . $text . '</div>';
  182. ​
  183. $previous = libxml_use_internal_errors(true);
  184. $dom = new \DOMDocument();
  185. $dom->loadHTML($wrapped, LIBXML_NOERROR | LIBXML_NOWARNING | LIBXML_NOBLANKS);
  186. libxml_clear_errors();
  187. libxml_use_internal_errors($previous);
  188. ​
  189. $root = $dom->getElementsByTagName('div')->item(0);
  190. if ($root === null) {
  191. return '';
  192. }
  193. ​
  194. self::sanitizeNode($dom, $root, $allowedTags, $allowedColors);
  195. ​
  196. $html = '';
  197. foreach (iterator_to_array($root->childNodes) as $child) {
  198. $html .= $dom->saveHTML($child);
  199. }
  200. ​
  201. return $html;
  202. }
  203. ​
  204. private static function sanitizeNode(\DOMDocument $dom, \DOMNode $node, array $allowedTags, string $allowedColors): void
  205. {
  206. $children = iterator_to_array($node->childNodes);
  207. ​
  208. foreach ($children as $child) {
  209. if ($child instanceof \DOMText) {
  210. continue;
  211. }
  212. ​
  213. if (!$child instanceof \DOMElement) {
  214. $node->removeChild($child);
  215. continue;
  216. }
  217. ​
  218. $tag = strtolower($child->tagName);
  219. ​
  220. if (!in_array($tag, $allowedTags, true)) {
  221. while ($child->firstChild) {
  222. $node->insertBefore($child->firstChild, $child);
  223. }
  224. $node->removeChild($child);
  225. continue;
  226. }
  227. ​
  228. self::sanitizeAttributes($child, $tag, $allowedColors);
  229. self::sanitizeNode($dom, $child, $allowedTags, $allowedColors);
  230. }
  231. }
  232. ​
  233. private static function sanitizeAttributes(\DOMElement $element, string $tag, string $allowedColors): void
  234. {
  235. $keep = [];
  236. ​
  237. if ($tag === 'a') {
  238. $href = self::cleanUrl((string) $element->getAttribute('href'));
  239. if ($href !== '') {
  240. $keep['href'] = $href;
  241. $keep['rel'] = 'noopener noreferrer';
  242. $keep['target'] = '_blank';
  243. }
  244. }
  245. ​
  246. if ($tag === 'span') {
  247. $color = self::extractColor((string) $element->getAttribute('style'), $allowedColors);
  248. if ($color !== '') {
  249. $keep['style'] = 'color:' . $color;
  250. }
  251. }
  252. ​
  253. foreach (iterator_to_array($element->attributes ?? []) as $attribute) {
  254. $element->removeAttribute($attribute->name);
  255. }
  256. ​
  257. foreach ($keep as $name => $value) {
  258. $element->setAttribute($name, $value);
  259. }
  260. }
  261. ​
  262. private static function extractColor(string $style, string $allowedColors): string
  263. {
  264. if (preg_match('/color\s*:\s*(#[0-9a-fA-F]{3,6})/', $style, $matches) !== 1) {
  265. return '';
  266. }
  267. ​
  268. $color = $matches[1];
  269. ​
  270. return preg_match($allowedColors, $color) === 1 ? $color : '';
  271. }
  272. ​
  273. public static function readingTimeMinutes(string $blocksJson): int
  274. {
  275. $decoded = json_decode($blocksJson, true);
  276. if (!is_array($decoded) || !isset($decoded['blocks'])) {
  277. return 1;
  278. }
  279. ​
  280. $wordCount = 0;
  281. foreach ($decoded['blocks'] as $block) {
  282. $wordCount += self::blockWordCount(is_array($block) ? $block : []);
  283. }
  284. ​
  285. return max(1, (int) ceil($wordCount / 200));
  286. }
  287. ​
  288. private static function blockWordCount(array $block): int
  289. {
  290. $type = $block['type'] ?? '';
  291. $data = is_array($block['data'] ?? null) ? $block['data'] : [];
  292. ​
  293. if ($type === 'table') {
  294. $cells = [];
  295. foreach ($data['rows'] ?? [] as $row) {
  296. foreach (is_array($row) ? $row : [] as $cell) {
  297. $cells[] = (string) $cell;
  298. }
  299. }
  300. $text = implode(' ', $cells);
  301. } else {
  302. $text = match ($type) {
  303. 'paragraph', 'quote', 'heading2', 'heading3', 'callout' => strip_tags((string) ($data['text'] ?? '')),
  304. 'list' => implode(' ', array_map('strval', $data['items'] ?? [])),
  305. 'checklist' => implode(' ', array_map(static fn($item) => (string) ($item['text'] ?? ''), $data['items'] ?? [])),
  306. default => '',
  307. };
  308. }
  309. ​
  310. $text = trim($text);
  311. if ($text === '') {
  312. return 0;
  313. }
  314. ​
  315. return count(preg_split('/\s+/', $text));
  316. }
  317. ​
  318. public static function render(string $blocksJson, bool $interactive = true): string
  319. {
  320. $decoded = json_decode($blocksJson, true);
  321. if (!is_array($decoded) || !isset($decoded['blocks']) || !is_array($decoded['blocks'])) {
  322. return '';
  323. }
  324. ​
  325. self::$interactive = $interactive;
  326. $html = '';
  327. $renderedSingle = [];
  328. foreach ($decoded['blocks'] as $block) {
  329. if (!is_array($block)) {
  330. continue;
  331. }
  332. $type = (string) ($block['type'] ?? '');
  333. if (in_array($type, self::SINGLE_USE_TYPES, true)) {
  334. if (isset($renderedSingle[$type])) {
  335. continue;
  336. }
  337. $renderedSingle[$type] = true;
  338. }
  339. $html .= self::renderBlock($block);
  340. }
  341. self::$interactive = true;
  342. ​
  343. return $html;
  344. }
  345. ​
  346. public static function containsBlock(string $blocksJson, string $type): bool
  347. {
  348. $decoded = json_decode($blocksJson, true);
  349. if (!is_array($decoded) || !is_array($decoded['blocks'] ?? null)) {
  350. return false;
  351. }
  352. ​
  353. foreach ($decoded['blocks'] as $block) {
  354. if (is_array($block) && ($block['type'] ?? '') === $type) {
  355. return true;
  356. }
  357. }
  358. ​
  359. return false;
  360. }
  361. ​
  362. private static function renderContactForm(array $data): string
  363. {
  364. if (!self::$interactive) {
  365. return '<div class="callout callout-info">' . htmlspecialchars(Language::get('contact_block_placeholder', 'Contact form')) . '</div>';
  366. }
  367. ​
  368. require_once __DIR__ . '/contact-form.php';
  369. ​
  370. return ContactForm::render((string) ($data['title'] ?? ''));
  371. }
  372. ​
  373. private static function renderBlock(array $block): string
  374. {
  375. $type = $block['type'] ?? '';
  376. $data = $block['data'] ?? [];
  377. ​
  378. return match ($type) {
  379. 'paragraph' => '<p>' . ($data['text'] ?? '') . '</p>',
  380. 'quote' => '<blockquote>' . ($data['text'] ?? '') . '</blockquote>',
  381. 'heading2' => '<h2>' . htmlspecialchars((string) ($data['text'] ?? '')) . '</h2>',
  382. 'heading3' => '<h3>' . htmlspecialchars((string) ($data['text'] ?? '')) . '</h3>',
  383. 'image' => '<figure><img src="' . htmlspecialchars((string) ($data['src'] ?? '')) . '" alt="' . htmlspecialchars((string) ($data['alt'] ?? '')) . '">' .
  384. (($data['caption'] ?? '') !== '' ? '<figcaption>' . htmlspecialchars((string) $data['caption']) . '</figcaption>' : '') . '</figure>',
  385. 'video' => '<figure><video controls src="' . htmlspecialchars((string) ($data['src'] ?? '')) . '"></video>' .
  386. (($data['caption'] ?? '') !== '' ? '<figcaption>' . htmlspecialchars((string) $data['caption']) . '</figcaption>' : '') . '</figure>',
  387. 'list' => self::renderList($data),
  388. 'code' => self::renderCode($data),
  389. 'table' => self::renderTable($data),
  390. 'checklist' => self::renderChecklist($data),
  391. 'callout' => '<div class="callout callout-' . htmlspecialchars((string) ($data['style'] ?? 'info')) . '">' .
  392. htmlspecialchars((string) ($data['text'] ?? '')) . '</div>',
  393. 'embed' => '<div class="embed-wrapper"><iframe src="' . htmlspecialchars((string) ($data['url'] ?? '')) . '" loading="lazy"></iframe></div>',
  394. 'separator' => '<hr>',
  395. 'contact_form' => self::renderContactForm(is_array($data) ? $data : []),
  396. default => '',
  397. };
  398. }
  399. ​
  400. private static function renderCode(array $data): string
  401. {
  402. $language = preg_replace('/[^a-z0-9+#-]/', '', strtolower((string) ($data['language'] ?? 'text'))) ?: 'text';
  403. $copyLabel = htmlspecialchars(Language::get('code_copy', 'Copy'), ENT_QUOTES);
  404. $copiedLabel = htmlspecialchars(Language::get('code_copied', 'Copied'), ENT_QUOTES);
  405. ​
  406. return '<div class="code-block">' .
  407. '<button type="button" class="code-copy" data-label="' . $copyLabel . '" data-copied="' . $copiedLabel . '">' . $copyLabel . '</button>' .
  408. '<pre><code class="language-' . $language . '">' . htmlspecialchars((string) ($data['code'] ?? '')) . '</code></pre>' .
  409. '</div>';
  410. }
  411. ​
  412. private static function renderList(array $data): string
  413. {
  414. $tag = ($data['style'] ?? 'unordered') === 'ordered' ? 'ol' : 'ul';
  415. $items = '';
  416. foreach ($data['items'] ?? [] as $item) {
  417. $items .= '<li>' . htmlspecialchars((string) $item) . '</li>';
  418. }
  419. return '<' . $tag . '>' . $items . '</' . $tag . '>';
  420. }
  421. ​
  422. private static function renderTable(array $data): string
  423. {
  424. $rows = '';
  425. foreach ($data['rows'] ?? [] as $row) {
  426. $cells = '';
  427. foreach ($row as $cell) {
  428. $cells .= '<td>' . htmlspecialchars((string) $cell) . '</td>';
  429. }
  430. $rows .= '<tr>' . $cells . '</tr>';
  431. }
  432. return '<table><tbody>' . $rows . '</tbody></table>';
  433. }
  434. ​
  435. private static function renderChecklist(array $data): string
  436. {
  437. $items = '';
  438. foreach ($data['items'] ?? [] as $item) {
  439. $checked = !empty($item['checked']) ? 'checked disabled' : 'disabled';
  440. $items .= '<li><input type="checkbox" ' . $checked . '> ' . htmlspecialchars((string) ($item['text'] ?? '')) . '</li>';
  441. }
  442. return '<ul class="checklist">' . $items . '</ul>';
  443. }
  444. }
  445. ​