Files
laralog/app/Blog/Services/PostContentRenderer.php
T
ak c231f4af96 fix: [paid] 裸标签泄漏——核心渲染器兜底剥离 + 恢复 dev 库插件状态
- 根因:dev 数据库 membership 插件被停用(PluginRecord enabled 为空),其 post.rendered 过滤器未运行,[paid] 标签对所有访客裸露;与缓存无关
- 已恢复 membership 启用并清页面缓存,实测游客看到 teaser
- 防御:PostContentRenderer 渲染后兜底剥离裸 [paid]/[/paid] 标签,即使会员插件被停用也不会泄漏到页面
- 回归测试:渲染结果永不含 [paid]
2026-08-12 17:48:55 +08:00

159 lines
5.2 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 -> HTMLhtml 原样返回。两种格式都会解析附件令牌。
*/
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);
}
}