feat: add rich text help article editor
This commit is contained in:
@@ -118,6 +118,7 @@ class ContentsController
|
||||
'title' => trim((string)$request->input('title', '')),
|
||||
'summary' => trim((string)$request->input('summary', '')),
|
||||
'keywords' => (array)$request->input('keywords', []),
|
||||
'content_html' => (string)$request->input('content_html', ''),
|
||||
'content_blocks' => (array)$request->input('content_blocks', []),
|
||||
'is_recommended' => (bool)$request->input('is_recommended', false),
|
||||
'is_enabled' => (bool)$request->input('is_enabled', true),
|
||||
|
||||
@@ -160,6 +160,17 @@ class ContentService
|
||||
}
|
||||
|
||||
return array_map(function (array $item) {
|
||||
$contentBlocks = $this->decodeJsonConfig($item['content_blocks_json'] ?? '', []);
|
||||
$contentHtml = trim((string)($item['content_html'] ?? ''));
|
||||
if ($contentHtml === '') {
|
||||
$contentHtml = $this->contentBlocksToHtml($contentBlocks);
|
||||
} else {
|
||||
$contentHtml = $this->sanitizeHelpArticleHtml($contentHtml);
|
||||
}
|
||||
if (!$contentBlocks && $contentHtml !== '') {
|
||||
$contentBlocks = $this->contentBlocksFromHtml($contentHtml);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int)$item['id'],
|
||||
'category' => (string)$item['category'],
|
||||
@@ -171,7 +182,8 @@ class ContentService
|
||||
'is_recommended' => (bool)$item['is_recommended'],
|
||||
'is_enabled' => (bool)$item['is_enabled'],
|
||||
'sort_order' => (int)$item['sort_order'],
|
||||
'content_blocks' => $this->decodeJsonConfig($item['content_blocks_json'] ?? '', []),
|
||||
'content_html' => $contentHtml,
|
||||
'content_blocks' => $contentBlocks,
|
||||
];
|
||||
}, $query->select()->toArray());
|
||||
}
|
||||
@@ -227,11 +239,19 @@ class ContentService
|
||||
$title = trim((string)($payload['title'] ?? ''));
|
||||
$summary = trim((string)($payload['summary'] ?? ''));
|
||||
$keywords = $this->normalizeStringList($payload['keywords'] ?? [], []);
|
||||
$contentHtml = $this->sanitizeHelpArticleHtml((string)($payload['content_html'] ?? ''));
|
||||
$contentBlocks = $this->normalizeStringList($payload['content_blocks'] ?? [], []);
|
||||
$isRecommended = !empty($payload['is_recommended']) ? 1 : 0;
|
||||
$isEnabled = array_key_exists('is_enabled', $payload) ? (!empty($payload['is_enabled']) ? 1 : 0) : 1;
|
||||
$sortOrder = (int)($payload['sort_order'] ?? 0);
|
||||
|
||||
if ($contentHtml !== '') {
|
||||
$contentBlocks = $this->contentBlocksFromHtml($contentHtml);
|
||||
}
|
||||
if ($contentHtml === '' && $contentBlocks) {
|
||||
$contentHtml = $this->contentBlocksToHtml($contentBlocks);
|
||||
}
|
||||
|
||||
if ($title === '' || $summary === '') {
|
||||
throw new \RuntimeException('文章标题和摘要不能为空');
|
||||
}
|
||||
@@ -248,6 +268,7 @@ class ContentService
|
||||
'title' => $title,
|
||||
'summary' => $summary,
|
||||
'keywords_json' => json_encode($keywords, JSON_UNESCAPED_UNICODE),
|
||||
'content_html' => $contentHtml,
|
||||
'content_blocks_json' => json_encode($contentBlocks, JSON_UNESCAPED_UNICODE),
|
||||
'is_recommended' => $isRecommended,
|
||||
'is_enabled' => $isEnabled,
|
||||
@@ -402,6 +423,7 @@ class ContentService
|
||||
{
|
||||
$exists = Db::query(sprintf("SHOW TABLES LIKE '%s'", self::HELP_TABLE));
|
||||
if ($exists) {
|
||||
$this->ensureHelpArticlesContentHtmlColumn();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -412,6 +434,7 @@ class ContentService
|
||||
title VARCHAR(255) NOT NULL DEFAULT '',
|
||||
summary VARCHAR(500) NOT NULL DEFAULT '',
|
||||
keywords_json LONGTEXT NULL,
|
||||
content_html LONGTEXT NULL,
|
||||
content_blocks_json LONGTEXT NULL,
|
||||
is_recommended TINYINT(1) NOT NULL DEFAULT 0,
|
||||
is_enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
@@ -497,12 +520,14 @@ class ContentService
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
foreach ($this->defaultHelpArticles() as $index => $item) {
|
||||
$contentBlocks = $item['content_blocks'];
|
||||
Db::name(self::HELP_TABLE)->insert([
|
||||
'category' => $item['category'],
|
||||
'title' => $item['title'],
|
||||
'summary' => $item['summary'],
|
||||
'keywords_json' => json_encode($item['keywords'], JSON_UNESCAPED_UNICODE),
|
||||
'content_blocks_json' => json_encode($item['content_blocks'], JSON_UNESCAPED_UNICODE),
|
||||
'content_html' => $this->contentBlocksToHtml($contentBlocks),
|
||||
'content_blocks_json' => json_encode($contentBlocks, JSON_UNESCAPED_UNICODE),
|
||||
'is_recommended' => !empty($item['is_recommended']) ? 1 : 0,
|
||||
'is_enabled' => 1,
|
||||
'sort_order' => $item['sort_order'] ?? ($index + 1) * 10,
|
||||
@@ -728,6 +753,166 @@ class ContentService
|
||||
return $normalized ?: $default;
|
||||
}
|
||||
|
||||
private function ensureHelpArticlesContentHtmlColumn(): void
|
||||
{
|
||||
$exists = Db::query(sprintf("SHOW COLUMNS FROM %s LIKE 'content_html'", self::HELP_TABLE));
|
||||
if ($exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
Db::execute(sprintf(
|
||||
"ALTER TABLE %s ADD COLUMN content_html LONGTEXT NULL AFTER keywords_json",
|
||||
self::HELP_TABLE
|
||||
));
|
||||
}
|
||||
|
||||
private function sanitizeHelpArticleHtml(string $html): string
|
||||
{
|
||||
$html = trim($html);
|
||||
if ($html === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!class_exists(\DOMDocument::class)) {
|
||||
return $this->sanitizeHelpArticleHtmlFallback($html);
|
||||
}
|
||||
|
||||
$document = new \DOMDocument('1.0', 'UTF-8');
|
||||
$previous = libxml_use_internal_errors(true);
|
||||
$loaded = $document->loadHTML('<?xml encoding="UTF-8"><div>' . $html . '</div>', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($previous);
|
||||
|
||||
if (!$loaded || !$document->documentElement) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$this->sanitizeHelpArticleNode($document->documentElement);
|
||||
|
||||
$output = '';
|
||||
foreach ($document->documentElement->childNodes as $childNode) {
|
||||
$output .= $document->saveHTML($childNode);
|
||||
}
|
||||
|
||||
return trim($output);
|
||||
}
|
||||
|
||||
private function sanitizeHelpArticleNode(\DOMNode $node): void
|
||||
{
|
||||
$allowedTags = ['p', 'br', 'strong', 'em', 'u', 's', 'h2', 'h3', 'h4', 'ul', 'ol', 'li', 'blockquote', 'a', 'img'];
|
||||
|
||||
for ($index = $node->childNodes->length - 1; $index >= 0; $index--) {
|
||||
$child = $node->childNodes->item($index);
|
||||
if (!$child) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($child instanceof \DOMElement) {
|
||||
$tagName = strtolower($child->tagName);
|
||||
if (!in_array($tagName, $allowedTags, true)) {
|
||||
if (in_array($tagName, ['script', 'style', 'iframe', 'object', 'embed'], true)) {
|
||||
$node->removeChild($child);
|
||||
continue;
|
||||
}
|
||||
$this->sanitizeHelpArticleNode($child);
|
||||
while ($child->firstChild) {
|
||||
$node->insertBefore($child->firstChild, $child);
|
||||
}
|
||||
$node->removeChild($child);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($tagName === 'img' && !$this->isSafeHelpArticleUrl(trim((string)$child->getAttribute('src')))) {
|
||||
$node->removeChild($child);
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->sanitizeHelpArticleAttributes($child, $tagName);
|
||||
$this->sanitizeHelpArticleNode($child);
|
||||
} elseif ($child->nodeType !== XML_TEXT_NODE) {
|
||||
$node->removeChild($child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function sanitizeHelpArticleAttributes(\DOMElement $node, string $tagName): void
|
||||
{
|
||||
$href = trim((string)$node->getAttribute('href'));
|
||||
$src = trim((string)$node->getAttribute('src'));
|
||||
$alt = trim((string)$node->getAttribute('alt'));
|
||||
$title = trim((string)$node->getAttribute('title'));
|
||||
|
||||
while ($node->attributes->length > 0) {
|
||||
$node->removeAttribute($node->attributes->item(0)->name);
|
||||
}
|
||||
|
||||
if ($tagName === 'a') {
|
||||
if ($this->isSafeHelpArticleUrl($href)) {
|
||||
$node->setAttribute('href', $href);
|
||||
$node->setAttribute('target', '_blank');
|
||||
$node->setAttribute('rel', 'noopener noreferrer');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ($tagName !== 'img') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->isSafeHelpArticleUrl($src)) {
|
||||
$node->setAttribute('src', $src);
|
||||
}
|
||||
|
||||
if ($alt !== '') {
|
||||
$node->setAttribute('alt', $this->limitHelpArticleAttribute($alt));
|
||||
}
|
||||
if ($title !== '') {
|
||||
$node->setAttribute('title', $this->limitHelpArticleAttribute($title));
|
||||
}
|
||||
$node->setAttribute('loading', 'lazy');
|
||||
}
|
||||
|
||||
private function limitHelpArticleAttribute(string $value): string
|
||||
{
|
||||
return function_exists('mb_substr') ? mb_substr($value, 0, 120) : substr($value, 0, 120);
|
||||
}
|
||||
|
||||
private function sanitizeHelpArticleHtmlFallback(string $html): string
|
||||
{
|
||||
$html = strip_tags($html, '<p><br><strong><em><u><s><h2><h3><h4><ul><ol><li><blockquote><a><img>');
|
||||
$html = preg_replace('/\s+on[a-z]+\s*=\s*(["\']).*?\1/i', '', $html) ?? '';
|
||||
$html = preg_replace('/\s+(href|src)\s*=\s*(["\'])\s*(javascript|data):.*?\2/i', '', $html) ?? '';
|
||||
return trim($html);
|
||||
}
|
||||
|
||||
private function isSafeHelpArticleUrl(string $url): bool
|
||||
{
|
||||
if ($url === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool)preg_match('/^(https?:\/\/|\/(?!\/)|#)/i', $url);
|
||||
}
|
||||
|
||||
private function contentBlocksFromHtml(string $html): array
|
||||
{
|
||||
$html = preg_replace('/<\/(h2|h3|h4|p|li|blockquote)>/i', "\n", $html) ?? $html;
|
||||
$text = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
|
||||
return array_values(array_filter(array_map(
|
||||
fn ($item) => trim((string)preg_replace('/\s+/u', ' ', $item)),
|
||||
preg_split('/\R/u', $text) ?: []
|
||||
), fn ($item) => $item !== ''));
|
||||
}
|
||||
|
||||
private function contentBlocksToHtml(array $blocks): string
|
||||
{
|
||||
return implode('', array_map(
|
||||
fn ($item) => '<p>' . htmlspecialchars((string)$item, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</p>',
|
||||
$this->normalizeStringList($blocks, [])
|
||||
));
|
||||
}
|
||||
|
||||
private function defaultHomeConfig(): array
|
||||
{
|
||||
return [
|
||||
@@ -1093,12 +1278,14 @@ class ContentService
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$contentBlocks = $definition['content_blocks'];
|
||||
return (int)Db::name(self::HELP_TABLE)->insertGetId([
|
||||
'category' => $definition['category'],
|
||||
'title' => $definition['title'],
|
||||
'summary' => $definition['summary'],
|
||||
'keywords_json' => json_encode($definition['keywords'], JSON_UNESCAPED_UNICODE),
|
||||
'content_blocks_json' => json_encode($definition['content_blocks'], JSON_UNESCAPED_UNICODE),
|
||||
'content_html' => $this->contentBlocksToHtml($contentBlocks),
|
||||
'content_blocks_json' => json_encode($contentBlocks, JSON_UNESCAPED_UNICODE),
|
||||
'is_recommended' => !empty($definition['is_recommended']) ? 1 : 0,
|
||||
'is_enabled' => 1,
|
||||
'sort_order' => (int)$definition['sort_order'],
|
||||
|
||||
@@ -1391,6 +1391,7 @@ CREATE TABLE help_articles (
|
||||
title VARCHAR(255) NOT NULL DEFAULT '',
|
||||
summary VARCHAR(500) NOT NULL DEFAULT '',
|
||||
keywords_json LONGTEXT NULL,
|
||||
content_html LONGTEXT NULL,
|
||||
content_blocks_json LONGTEXT NULL,
|
||||
is_recommended TINYINT(1) NOT NULL DEFAULT 0,
|
||||
is_enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
|
||||
Reference in New Issue
Block a user