- redirects 表 + UrlRedirector:go_url() 站外链接改写为 /go/{hash}(确定性 hash、同 URL 复用、并发安全、缓存)
- /go/{hash} 302 跳转 + 点击计数 + 最后点击时间;停用状态 410(二次拦截);后台「外链中转」资源可查看点击量/启停/删除
- 接入点:正文外链(渲染器统一改写)、评论正文自动链接(comment_body)、评论作者网址、友链(侧边栏+友链页);站内/相对/mailto/tel/锚点不中转
- 测试 6 个(改写/计数/拦截/复用/渲染/评论正文);README 补充
183 lines
6.0 KiB
PHP
183 lines
6.0 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] 标签输出到页面(如会员插件被停用时)
|
||
$html = preg_replace('/\[(\/?paid)\]/i', '', $html);
|
||
|
||
// 外链中转:站外链接改写为 /go/{hash}(点击统计/拦截)
|
||
return $this->rewriteExternalLinks($html);
|
||
}
|
||
|
||
/**
|
||
* 把正文里的站外 <a href> 改写为 /go 中转地址(站内/相对/锚点/mailto 不变)。
|
||
*/
|
||
private function rewriteExternalLinks(string $html): string
|
||
{
|
||
return preg_replace_callback(
|
||
'/<a\s+([^>]*?href=["\'])([^"\']+)(["\'][^>]*)>(.*?)<\/a>/is',
|
||
function (array $m): string {
|
||
$url = html_entity_decode(trim($m[2]));
|
||
$go = go_url($url);
|
||
|
||
if ($go === $url) {
|
||
return $m[0];
|
||
}
|
||
|
||
return '<a '.$m[1].e($go).$m[3].'>'.$m[4].'</a>';
|
||
},
|
||
$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);
|
||
}
|
||
}
|