diff --git a/README.md b/README.md index e4e4cba..91fab89 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,12 @@ php artisan workerman:serve stop - 失败重试:`WORKERMAN_MAX_TRIES`(默认 3),超时 `WORKERMAN_TIMEOUT`(默认 60s),重试耗尽进 `failed_jobs` 表 - 降级路径:`php artisan queue:work` 照常可用(同一队列) +## 外链中转(/go) + +- 正文外链、评论正文 URL、评论作者网址、友链统一改写为 `/go/{hash}`:302 跳转 + **点击统计**、可**停用拦截**(返回 410)、隐藏真实链接、为未来广告位预留 +- 站内链接/相对路径/mailto/tel/锚点不中转;同一 URL 复用同一短码 +- 后台「外链中转」可查看点击量、按状态筛选、启停 + ## 前台缓存 - 匿名 GET 页面整页缓存(首页/列表/文章/归档/标签等),TTL 默认 300 秒(`PAGE_CACHE_TTL`),命中时零数据库查询 diff --git a/app/Blog/Controllers/GoController.php b/app/Blog/Controllers/GoController.php new file mode 100644 index 0000000..0d1f08c --- /dev/null +++ b/app/Blog/Controllers/GoController.php @@ -0,0 +1,26 @@ +where('hash', $hash)->firstOrFail(); + + if (! $redirect->isActive()) { + abort(410); + } + + $redirect->hit(); + + return redirect()->away($redirect->target_url, 302); + } +} diff --git a/app/Blog/Services/PostContentRenderer.php b/app/Blog/Services/PostContentRenderer.php index c01a381..e24c0df 100644 --- a/app/Blog/Services/PostContentRenderer.php +++ b/app/Blog/Services/PostContentRenderer.php @@ -46,7 +46,31 @@ class PostContentRenderer $html = app(\App\Blog\Support\PluginManager::class)->applyFilters('post.rendered', $html, $post); // 兜底:任何情况下不把裸 [paid] 标签输出到页面(如会员插件被停用时) - return preg_replace('/\[(\/?paid)\]/i', '', $html); + $html = preg_replace('/\[(\/?paid)\]/i', '', $html); + + // 外链中转:站外链接改写为 /go/{hash}(点击统计/拦截) + return $this->rewriteExternalLinks($html); + } + + /** + * 把正文里的站外 改写为 /go 中转地址(站内/相对/锚点/mailto 不变)。 + */ + private function rewriteExternalLinks(string $html): string + { + return preg_replace_callback( + '/]*?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 ''.$m[4].''; + }, + $html + ); } /** diff --git a/app/Blog/Services/UrlRedirector.php b/app/Blog/Services/UrlRedirector.php new file mode 100644 index 0000000..7000d54 --- /dev/null +++ b/app/Blog/Services/UrlRedirector.php @@ -0,0 +1,72 @@ +isExternal($url)) { + return $url; + } + + return route('go.show', $this->hashFor($url)); + } + + public function isExternal(string $url): bool + { + if (! preg_match('#^https?://#i', $url)) { + return false; + } + + $host = parse_url($url, PHP_URL_HOST); + + if (! $host) { + return false; + } + + $internalHost = parse_url((string) config('app.url'), PHP_URL_HOST); + + return strcasecmp($host, (string) $internalHost) !== 0; + } + + /** + * 取 URL 对应的短 hash(确定性:同一 URL 复用同一条记录,并发安全)。 + */ + public function hashFor(string $url): string + { + return Cache::remember('go.hash.'.md5($url), now()->addDay(), function () use ($url) { + $hash = substr(hash('sha256', $url), 0, 10); + + for ($i = 0; $i < 3; $i++) { + try { + Redirect::create(['hash' => $hash, 'target_url' => $url]); + + return $hash; + } catch (UniqueConstraintViolationException) { + $existing = Redirect::where('target_url', $url)->first(); + + if ($existing) { + return $existing->hash; + } + + $hash = substr(hash('sha256', $url.$i), 0, 10); + } + } + + return $hash; + }); + } +} diff --git a/app/Filament/Resources/RedirectResource.php b/app/Filament/Resources/RedirectResource.php new file mode 100644 index 0000000..257d41c --- /dev/null +++ b/app/Filament/Resources/RedirectResource.php @@ -0,0 +1,90 @@ +columns([ + TextColumn::make('hash') + ->label('短码') + ->badge() + ->color('gray'), + TextColumn::make('target_url') + ->label('目标地址') + ->limit(50) + ->copyable() + ->searchable(), + TextColumn::make('clicks') + ->label('点击量') + ->numeric() + ->sortable(), + TextColumn::make('status') + ->label('状态') + ->badge() + ->formatStateUsing(fn ($state) => $state === 'active' ? '启用' : '停用') + ->color(fn ($state) => $state === 'active' ? 'success' : 'danger'), + TextColumn::make('last_clicked_at') + ->label('最后点击') + ->dateTime('Y-m-d H:i') + ->placeholder('—') + ->sortable(), + TextColumn::make('created_at') + ->label('创建时间') + ->dateTime('Y-m-d H:i') + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + \Filament\Tables\Filters\SelectFilter::make('status') + ->label('状态') + ->options(['active' => '启用', 'disabled' => '停用']), + ], \Filament\Tables\Enums\FiltersLayout::AboveContent) + ->recordActions([ + Action::make('toggle') + ->label(fn (Redirect $record) => $record->isActive() ? '停用' : '启用') + ->color(fn (Redirect $record) => $record->isActive() ? 'danger' : 'success') + ->action(fn (Redirect $record) => $record->update(['status' => $record->isActive() ? 'disabled' : 'active'])), + DeleteAction::make(), + ]) + ->recordUrl(null) + ->recordAction(null) + ->defaultSort('clicks', 'desc'); + } + + public static function getPages(): array + { + return [ + 'index' => ListRedirects::route('/'), + ]; + } +} diff --git a/app/Filament/Resources/RedirectResource/Pages/ListRedirects.php b/app/Filament/Resources/RedirectResource/Pages/ListRedirects.php new file mode 100644 index 0000000..5616a2b --- /dev/null +++ b/app/Filament/Resources/RedirectResource/Pages/ListRedirects.php @@ -0,0 +1,13 @@ + 'datetime', + ]; + + public function isActive(): bool + { + return $this->status === 'active'; + } + + public function hit(): void + { + $this->increment('clicks'); + $this->update(['last_clicked_at' => now()]); + } +} diff --git a/app/Support/helpers.php b/app/Support/helpers.php index 553cf94..40631ac 100644 --- a/app/Support/helpers.php +++ b/app/Support/helpers.php @@ -42,3 +42,31 @@ if (! function_exists('blog_setting')) { return \App\Models\Setting::get($key, $default); } } + +if (! function_exists('go_url')) { + /** + * 外链中转:站外链接改写为 /go/{hash}(点击统计/拦截),站内链接原样返回。 + */ + function go_url(string $url): string + { + return app(\App\Blog\Services\UrlRedirector::class)->goUrl($url); + } +} + +if (! function_exists('comment_body')) { + /** + * 评论正文:转义 + 自动链接 URL(走 /go 中转,nofollow)。 + */ + function comment_body(string $text): string + { + $text = e($text); + + $text = preg_replace_callback( + '#(https?://[^\s<>"\'()]+)#i', + fn (array $m) => ''.$m[1].'', + $text + ); + + return nl2br($text); + } +} diff --git a/database/migrations/2026_08_12_400000_create_redirects_table.php b/database/migrations/2026_08_12_400000_create_redirects_table.php new file mode 100644 index 0000000..e6645c9 --- /dev/null +++ b/database/migrations/2026_08_12_400000_create_redirects_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('hash', 16)->unique(); + $table->text('target_url'); + $table->unsignedBigInteger('clicks')->default(0); + $table->string('status', 20)->default('active'); // active / disabled + $table->timestamp('last_clicked_at')->nullable(); + $table->timestamps(); + + $table->index('target_url'); + }); + } + + public function down(): void + { + Schema::dropIfExists('redirects'); + } +}; diff --git a/resources/views/links.blade.php b/resources/views/links.blade.php index cb80ab6..42d9e35 100644 --- a/resources/views/links.blade.php +++ b/resources/views/links.blade.php @@ -8,7 +8,7 @@