WebOrbiton
v2.0.0.0

Publisium

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