- 根因:dev 数据库 membership 插件被停用(PluginRecord enabled 为空),其 post.rendered 过滤器未运行,[paid] 标签对所有访客裸露;与缓存无关 - 已恢复 membership 启用并清页面缓存,实测游客看到 teaser - 防御:PostContentRenderer 渲染后兜底剥离裸 [paid]/[/paid] 标签,即使会员插件被停用也不会泄漏到页面 - 回归测试:渲染结果永不含 [paid]
159 lines
5.2 KiB
PHP
159 lines
5.2 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
|
||
namespace App\Blog\Services;
|
||
|
||
use App\Models\Post;
|
||
use Illuminate\Support\Facades\Cache;
|
||
use League\CommonMark\Environment\Environment;
|
||
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
|
||
use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
|
||
use League\CommonMark\MarkdownConverter;
|
||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||
|
||
class PostContentRenderer
|
||
{
|
||
private MarkdownConverter $converter;
|
||
|
||
public function __construct()
|
||
{
|
||
$environment = new Environment([
|
||
'html_input' => 'allow',
|
||
'allow_unsafe_links' => true,
|
||
'max_nesting_level' => 100,
|
||
]);
|
||
$environment->addExtension(new CommonMarkCoreExtension);
|
||
$environment->addExtension(new GithubFlavoredMarkdownExtension);
|
||
|
||
$this->converter = new MarkdownConverter($environment);
|
||
}
|
||
|
||
/**
|
||
* 将文章内容按格式渲染为 HTML。
|
||
* markdown -> HTML;html 原样返回。两种格式都会解析附件令牌。
|
||
*/
|
||
public function render(Post $post): string
|
||
{
|
||
$content = $post->content_format === 'markdown'
|
||
? $this->toHtml($post->content)
|
||
: $post->content;
|
||
|
||
$html = $this->renderShortcodes($content, $post);
|
||
|
||
// 插件钩子:付费内容过滤、AI 处理等
|
||
$html = app(\App\Blog\Support\PluginManager::class)->applyFilters('post.rendered', $html, $post);
|
||
|
||
// 兜底:任何情况下不把裸 [paid] 标签输出到页面(如会员插件被停用时)
|
||
return preg_replace('/\[(\/?paid)\]/i', '', $html);
|
||
}
|
||
|
||
/**
|
||
* 渲染并提取目录(TOC)。
|
||
*
|
||
* @return array{html: string, toc: array<int, array{id: string, text: string, level: int}>}
|
||
*/
|
||
public function renderWithToc(Post $post): array
|
||
{
|
||
$html = $this->render($post);
|
||
|
||
$toc = [];
|
||
$index = 0;
|
||
|
||
$html = preg_replace_callback(
|
||
'/<h([2-6])([^>]*)>(.*?)<\/h\1>/is',
|
||
function (array $m) use (&$toc, &$index): string {
|
||
$level = (int) $m[1];
|
||
$attrs = $m[2];
|
||
$text = trim(strip_tags($m[3]));
|
||
$id = 'toc-'.(++$index);
|
||
|
||
$toc[] = ['id' => $id, 'text' => $text, 'level' => $level];
|
||
|
||
// 已存在 id 则保留原 id,否则注入锚点
|
||
if (preg_match('/id="([^"]+)"/', $attrs, $idMatch)) {
|
||
$toc[array_key_last($toc)]['id'] = $idMatch[1];
|
||
|
||
return $m[0];
|
||
}
|
||
|
||
return '<h'.$level.$attrs.' id="'.$id.'">'.$m[3].'</h'.$level.'>';
|
||
},
|
||
$html
|
||
);
|
||
|
||
return ['html' => $html, 'toc' => $toc];
|
||
}
|
||
|
||
public function toHtml(string $markdown): string
|
||
{
|
||
return $this->converter->convert($markdown)->getContent();
|
||
}
|
||
|
||
/**
|
||
* 解析附件引用。
|
||
*
|
||
* 兼容两种写法:
|
||
* - 老 sablog HTML 内容: [attach=xx] / [img=xx]
|
||
* - markdown 转换导入: {{attach:xx}} / {{img:xx}}([] 是 markdown 保留字符,转换时改为令牌)
|
||
*
|
||
* xx 是 sablog attachmentid;找不到时回退到按出现顺序的第 N 个附件。
|
||
*/
|
||
public function renderShortcodes(string $html, Post $post): string
|
||
{
|
||
$pattern = '/\[(attach|img)=(\d+)\]|\{\{(attach|img):(\d+)\}\}/';
|
||
|
||
if (! preg_match_all($pattern, $html, $matches, PREG_SET_ORDER)) {
|
||
return $html;
|
||
}
|
||
|
||
// 短代码引用的是 sablog 全局附件 id,可能属于其他文章,需要全局查
|
||
$byLegacyId = Cache::remember('attach.legacy_ids', now()->addHour(), function () {
|
||
return Media::query()
|
||
->where('collection_name', 'attachments')
|
||
->get()
|
||
->keyBy(fn ($m) => (int) $m->getCustomProperty('legacy_attachmentid'));
|
||
});
|
||
|
||
// 当前文章媒体用于索引回退
|
||
$ordered = $post->getMedia('attachments')->values();
|
||
|
||
foreach ($matches as $match) {
|
||
$full = $match[0];
|
||
$type = $match[1] !== '' ? $match[1] : $match[3];
|
||
$id = (int) ($match[2] !== '' ? $match[2] : $match[4]);
|
||
|
||
$media = $byLegacyId->get($id);
|
||
|
||
if (! $media) {
|
||
// 兼容按索引引用的旧写法:第 $id 个附件(从 1 开始)
|
||
$media = $ordered->get($id - 1);
|
||
}
|
||
|
||
if (! $media) {
|
||
// 找不到媒体时移除残留短代码,避免把 [attach=999] 当正文显示
|
||
$html = str_replace($full, '', $html);
|
||
continue;
|
||
}
|
||
|
||
$replacement = $type === 'img'
|
||
? sprintf('<img src="%s" alt="%s" loading="lazy" />', $media->getUrl(), e($media->name))
|
||
: sprintf('<a href="%s">%s</a>', $media->getUrl(), e($media->file_name));
|
||
|
||
$html = str_replace($full, $replacement, $html);
|
||
}
|
||
|
||
return $html;
|
||
}
|
||
|
||
/**
|
||
* 把老 sablog HTML 里的 [attach=xx]/[img=xx] 换成 markdown 安全令牌,
|
||
* 供 HTML -> markdown 转换前调用。
|
||
*/
|
||
public function toMarkdownSafeTokens(string $html): string
|
||
{
|
||
return preg_replace('/\[(attach|img)=(\d+)\]/', '{{$1:$2}}', $html);
|
||
}
|
||
}
|