Prepare isolated FNA V3 preview without new advertising

This commit is contained in:
Codex 2026-10-29 18:30:21 +08:00
parent 0792297bb8
commit 76141453bb
51 changed files with 1701 additions and 65 deletions

View File

@ -0,0 +1,27 @@
<?php
namespace App\Console\Commands;
use App\Services\Editorial\PublicationEffects;
use Illuminate\Console\Command;
final class EditorialReindex extends Command
{
protected $signature = 'editorial:reindex';
protected $description = 'Initialize the isolated FNA_V3 site search index';
public function handle(PublicationEffects $effects): int
{
try {
$result = $effects->index();
$this->line(json_encode($result, JSON_UNESCAPED_UNICODE));
return $result['status'] === 'done' ? 0 : 1;
} catch (\Throwable) {
$this->error('Search index synchronization failed.');
return 1;
}
}
}

View File

@ -0,0 +1,61 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
final class EditorialRequest extends Command
{
protected $signature = 'editorial:request {method} {path} {--payload=} {--image=}';
protected $description = 'Call this environment\'s editorial API without exposing the credential';
public function handle(): int
{
$path = $this->argument('path');
$method = strtoupper($this->argument('method'));
if (! preg_match('~\A[a-zA-Z0-9_/-]+\z~', $path) || str_contains($path, '..') || ! in_array($method, ['GET', 'POST'])) {
$this->error('Invalid API operation.');
return 1;
}
$file = config('editorial.credential_file');
$credential = is_file($file) ? json_decode(file_get_contents($file), true) : null;
if (! $credential || ($credential['environment'] ?? '') !== app()->environment() || ($credential['project'] ?? '') !== 'fna-v3'
|| ($credential['base_url'] ?? '') !== config('editorial.base_url')) {
$this->error('Credential binding mismatch.');
return 1;
}
$base = rtrim(config('editorial.base_url'), '/');
if (app()->environment('local') && ! in_array(parse_url($base, PHP_URL_HOST), ['fna-v3.loc', 'localhost', '127.0.0.1'])) {
$this->error('Local client refuses a nonlocal target.');
return 1;
}
try {
$request = Http::withOptions(['proxy' => '', 'allow_redirects' => false])->withToken($credential['fields']['api_key'])->acceptJson()->connectTimeout(3)->timeout(30);
if ($this->option('image')) {
$image = $this->option('image');
if (! is_file($image)) {
$this->error('Image not found.');
return 1;
}
$response = $request->attach('image', fopen($image, 'r'), basename($image))->post($base.'/api/editorial/v1/'.$path);
} else {
$payload = $this->option('payload') ? json_decode(file_get_contents($this->option('payload')), true, 512, JSON_THROW_ON_ERROR) : [];
$response = $request->send($method, $base.'/api/editorial/v1/'.$path, $method === 'GET' ? [] : ['json' => $payload]);
}
$data = $response->json();
$this->line(json_encode(['http_status' => $response->status(), 'data' => $data], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
return $response->successful() ? 0 : 1;
} catch (\Throwable) {
$this->error('Editorial request failed; credential and request headers were not displayed.');
return 1;
}
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
final class EditorialSetup extends Command
{
protected $signature = 'editorial:setup';
protected $description = 'Create a private credential for the local FNA_V3 editorial API';
public function handle(): int
{
$c = config('database.connections.mysql');
if (! app()->environment('local') || $c['database'] !== 'fna_v3' || ! in_array($c['host'], ['localhost', '127.0.0.1'])) {
$this->error('Local FNA_V3 only. Production credentials require separate provisioning.');
return 1;
}
$path = config('editorial.credential_file');
if (is_file($path)) {
$this->info('Credential already exists; not replaced.');
return 0;
}
if (! is_dir(dirname($path))) {
mkdir(dirname($path), 0700, true);
}
$data = ['project' => 'fna-v3', 'environment' => 'local', 'base_url' => config('editorial.base_url'), 'schema' => 'gemdocs.credential.v1', 'fields' => ['api_key' => 'fna_ed_'.bin2hex(random_bytes(32))]];
$old = umask(0077);
$file = fopen($path, 'x');
umask($old);
if (! $file) {
$this->error('Credential could not be created.');
return 1;
}
fwrite($file, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
fclose($file);
chmod($path, 0600);
$this->info('Local API credential created in private storage. Secret not displayed.');
return 0;
}
}

View File

@ -0,0 +1,148 @@
<?php
namespace App\Http\Controllers\Api\Editorial;
use App\Http\Controllers\Controller;
use App\Models\Articles;
use App\Models\Categories;
use App\Services\Editorial\ContentRenderer;
use App\Services\Editorial\PublicationEffects;
use App\Services\Editorial\Publisher;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\URL;
final class EditorialController extends Controller
{
public function catalog()
{
return response()->json(['categories' => DB::table('categories')->where('iStatus', 1)->whereNotIn('sSlug', ['freenode', 'shadowrocketids'])->get(['id', 'sName', 'sSlug', 'iParentId']),
'articles' => DB::table('articles')->leftJoin('editorial_documents', 'articles.id', '=', 'editorial_documents.article_id')
->whereNotIn('iCategoryId', DB::table('categories')->whereIn('sSlug', ['freenode', 'shadowrocketids'])->pluck('id'))
->get(['articles.id', 'sTitle', 'sSlug', 'iCategoryId', 'iStatus', 'content_key', 'revision', 'published_revision'])]);
}
public function source(int $article)
{
$a = DB::table('articles')->where('id', $article)->first();
abort_unless($a, 404);
$category = DB::table('categories')->where('id', $a->iCategoryId)->first();
abort_unless($category && ! in_array($category->sSlug, ['freenode', 'shadowrocketids']), 422, 'Not an editorial article');
return response()->json(['article_id' => $a->id, 'title' => $a->sTitle, 'summary' => $a->sTitleSub,
'slug' => $a->sSlug, 'body_html' => $a->sContent, 'category_slug' => $category->sSlug,
'tag_ids' => DB::table('article_tag')->where('iArticleId', $a->id)->pluck('iTagId'),
'cover' => $a->sThumb, 'show_cover' => (bool) $a->bPageThumb,
'management' => DB::table('editorial_documents')->where('article_id', $a->id)->first()]);
}
public function store(Request $r, Publisher $p)
{
return response()->json($p->draft($r->all()), 201);
}
public function draft(string $draft, Publisher $p)
{
return response()->json($p->getDraft($draft));
}
public function publish(Request $r, string $draft, Publisher $p)
{
return response()->json($p->publish($draft, $r->all()));
}
public function article(string $key, Publisher $p)
{
return response()->json($p->article($key));
}
public function rollback(Request $r, string $key, Publisher $p)
{
return response()->json($p->rollback($key, $r->all()), 201);
}
public function receipt(string $publication, Publisher $p)
{
return response()->json($p->receipt($publication));
}
public function synchronize(string $publication, PublicationEffects $e)
{
return response()->json($e->synchronize($publication));
}
public function version(string $key, int $revision)
{
$v = DB::table('editorial_versions')->where('content_key', $key)->where('revision', $revision)->first();
abort_unless($v, 404);
return response()->json(['content_key' => $key, 'revision' => $revision, 'content' => json_decode($v->snapshot, true)]);
}
public function upload(Request $r, ContentRenderer $renderer)
{
$r->validate(['image' => 'required|file|max:8192|mimetypes:image/png,image/jpeg,image/webp,image/gif']);
$file = $r->file('image');
$info = @getimagesize($file->getRealPath());
abort_unless($info && $info[0] <= 12000 && $info[1] <= 12000 && $info[0] * $info[1] <= 40000000, 422, 'Image dimensions invalid');
$mime = $info['mime'];
$ext = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/webp' => 'webp', 'image/gif' => 'gif'][$mime] ?? null;
abort_unless($ext && $file->getMimeType() === $mime, 422, 'Invalid image encoding');
$sha = hash_file('sha256', $file->getRealPath());
$dest = $renderer->privateImage($sha);
if (! is_dir(dirname($dest))) {
mkdir(dirname($dest), 0700, true);
}
if (! is_file($dest)) {
$temp = $dest.'.'.bin2hex(random_bytes(8));
if (! copy($file->getRealPath(), $temp) || ! rename($temp, $dest)) {
throw new \RuntimeException('Image storage failed');
}
chmod($dest, 0600);
}
DB::table('editorial_assets')->insertOrIgnore(['sha256' => $sha, 'extension' => $ext, 'mime' => $mime, 'bytes' => filesize($dest), 'created_at' => now()]);
return response()->json(['sha256' => $sha, 'reference' => 'asset:'.$sha, 'mime' => $mime, 'width' => $info[0], 'height' => $info[1]], 201);
}
public function asset(string $sha, ContentRenderer $renderer)
{
abort_unless(config('editorial.enabled'), 503);
$a = DB::table('editorial_assets')->where('sha256', $sha)->first();
abort_unless($a && is_file($renderer->privateImage($sha)), 404);
return response()->file($renderer->privateImage($sha), ['Content-Type' => $a->mime, 'Cache-Control' => 'private, no-store', 'X-Robots-Tag' => 'noindex, nofollow', 'X-Content-Type-Options' => 'nosniff']);
}
public function preview(string $draft, string $key, Publisher $p)
{
abort_unless(config('editorial.enabled'), 503);
$record = DB::table('editorial_drafts')->where('id', $draft)->first();
abort_unless($record, 404);
$items = json_decode($record->items, true);
$item = collect($items)->firstWhere('content_key', $key);
abort_unless($item, 404);
$expires = now()->addMinutes(config('editorial.preview_minutes'));
$base = rtrim(config('editorial.base_url'), '/');
$html = preg_replace_callback('/asset:([a-f0-9]{64})/', fn ($m) => $base.URL::temporarySignedRoute('editorial.preview-asset', $expires, ['sha' => $m[1]], absolute: false), $item['html']);
// Forward links between articles in this batch stay within the private preview.
foreach ($items as $target) {
$preview = $base.URL::temporarySignedRoute('editorial.preview', $expires, ['draft' => $draft, 'key' => $target['content_key']], absolute: false);
$pattern = '~href="/a/'.preg_quote($target['slug'], '~').'\.html(#[^"]*)?"~';
$html = preg_replace_callback($pattern, fn ($m) => 'href="'.htmlspecialchars($preview, ENT_QUOTES).($m[1] ?? '').'"', $html);
}
$cover = $item['cover'];
if ($cover && preg_match('/\Aasset:([a-f0-9]{64})\z/', $cover, $m)) {
$cover = $base.URL::temporarySignedRoute('editorial.preview-asset', $expires, ['sha' => $m[1]], absolute: false);
}
$a = new Articles;
$a->forceFill(['id' => 0, 'sTitle' => $item['title'], 'sTitleSub' => $item['summary'], 'sSlug' => $item['slug'], 'sContent' => $html,
'created_at' => now(), 'iLikeCount' => 0, 'bPageThumb' => $item['show_cover'] ?? false, 'sThumb' => $cover]);
$category = Categories::where('sSlug', $item['category_slug'])->with('oParent')->firstOrFail();
$a->setRelation('oCategory', $category);
$a->setRelation('oTags', collect());
return response()->view('pages.articles.default.show', ['oArticle' => $a, 'bEditorialPreview' => true, 'editorialRevision' => $item['revision'], 'editorialDraftStatus' => $record->status])
->withHeaders(['Cache-Control' => 'private, no-store', 'X-Robots-Tag' => 'noindex, nofollow', 'Referrer-Policy' => 'no-referrer']);
}
}

View File

@ -80,6 +80,10 @@ public function receive(Request $oRequest)
$oArticle->sContent = $sSchema;
$oArticle->sTitle = $sTitle;
$oArticle->sTitleSub = mb_substr(strip_tags($sSchema), 0, 150, 'utf-8');
if (\App\Services\IllustrationPool::enabled()) {
$pool = config('illustrations.categories.'.$oCategory->id);
if ($pool) $oArticle->sThumb = \App\Services\IllustrationPool::forArticle($pool, $sSlug);
}
$oArticle->save();
@ -111,23 +115,28 @@ public function receiveShadowrocketids(Request $oRequest)
$oThumbNum = ArticleThumbNum::firstOrCreate(["author_code" => $sSubKey, "type" => "shadowrocketids"]);
$iThumbNum = $oThumbNum->num;
$sImagesApi = "https://".config("path.url_resource_base")."/api/images/get/url-by-subkey";
$aApiArg = [
"sSubKey" => $sSubKey,
"iNum" => $iThumbNum+1,
];
$sApiResult = HttpV2::make($sImagesApi, "post")->withToken()->setDataA($aApiArg)->send();
$aApiResult = json_decode($sApiResult, true);
if (!isset($aApiResult["isDone"]) || !$aApiResult["isDone"]) {
echo $sApiResult;
return response()->json($oFlow->fail("{$sObjName}的图片api调用失败"));
if (\App\Services\IllustrationPool::enabled()) {
$sThumbPath = \App\Services\IllustrationPool::forArticle('shadowrocketids', $sSlug);
$sThumbNumTrue = $iThumbNum + 1;
} else {
$sImagesApi = "https://".config("path.url_resource_base")."/api/images/get/url-by-subkey";
$aApiArg = [
"sSubKey" => $sSubKey,
"iNum" => $iThumbNum+1,
];
$sApiResult = HttpV2::make($sImagesApi, "post")->withToken()->setDataA($aApiArg)->send();
$aApiResult = json_decode($sApiResult, true);
if (!isset($aApiResult["isDone"]) || !$aApiResult["isDone"]) {
echo $sApiResult;
return response()->json($oFlow->fail("{$sObjName}的图片api调用失败"));
}
$sThumbPath = $aApiResult["aData"]["sFileUrl"];
$sThumbNumTrue = $aApiResult["aData"]["iTrueNum"];
}
$sThumbPath = $aApiResult["aData"]["sFileUrl"];
$sThumbNumTrue = $aApiResult["aData"]["iTrueNum"];
$oCategories = Categories::where("sSlug", "shadowrocketids")->first();
$oArticle = Articles::where('sSlug', $sSlug)->first();
@ -185,23 +194,28 @@ public function receiveFreenode(Request $oRequest)
$oThumbNum = ArticleFreenodeThumbNum::firstOrCreate(["author_code" => $sSubKey]);
$iThumbNum = $oThumbNum->num;
$sImagesApi = "https://".config("path.url_resource_base")."/api/images/get/url-by-subkey";
$aApiArg = [
"sSubKey" => $sSubKey,
"iNum" => $iThumbNum+1,
];
$sApiResult = HttpV2::make($sImagesApi, "post")->withToken()->setDataA($aApiArg)->send();
$aApiResult = json_decode($sApiResult, true);
if (!isset($aApiResult["isDone"]) || !$aApiResult["isDone"]) {
echo $sApiResult;
return response()->json($oFlow->fail("{$sObjName}的图片api调用失败"));
if (\App\Services\IllustrationPool::enabled()) {
$sThumbPath = \App\Services\IllustrationPool::forArticle('freenode', $sSlug);
$sThumbNumTrue = $iThumbNum + 1;
} else {
$sImagesApi = "https://".config("path.url_resource_base")."/api/images/get/url-by-subkey";
$aApiArg = [
"sSubKey" => $sSubKey,
"iNum" => $iThumbNum+1,
];
$sApiResult = HttpV2::make($sImagesApi, "post")->withToken()->setDataA($aApiArg)->send();
$aApiResult = json_decode($sApiResult, true);
if (!isset($aApiResult["isDone"]) || !$aApiResult["isDone"]) {
echo $sApiResult;
return response()->json($oFlow->fail("{$sObjName}的图片api调用失败"));
}
$sThumbPath = $aApiResult["aData"]["sFileUrl"];
$sThumbNumTrue = $aApiResult["aData"]["iTrueNum"];
}
$sThumbPath = $aApiResult["aData"]["sFileUrl"];
$sThumbNumTrue = $aApiResult["aData"]["iTrueNum"];
$oCategories = Categories::where("sSlug", "freenode")->first();
$oArticle = Articles::where('sSlug', $sSlug)->first();
@ -235,7 +249,7 @@ public function receiveFreenode(Request $oRequest)
$oIndexNow = new IndexNowService();
$bIndexR = $oIndexNow->pushUrls([$sArticleUrl.".html"]);
TeleSlave::notify()->send($sArticleUrl." 发布成功 | "."indexNow推送:".$bIndexR); // indexR是true或false,发送到tg什么都没有?
TeleSlave::notify()->send($sArticleUrl.".html"." 发布成功 | "."indexNow推送:".$bIndexR); // indexR是true或false,发送到tg什么都没有?
return response()->json($oFlow->done());
}
@ -258,23 +272,28 @@ public function receiveFreenode_laji(Request $oRequest)
$oThumbNum = ArticleFreenodeThumbNum::firstOrCreate(["author_code" => $sImgAuthor]);
$iThumbNum = $oThumbNum->num;
$sImagesApi = "https://".config("path.url_resource_base")."/api/images/get/url-by-num";
$aApiArg = [
"sAuthorCode" => $sImgAuthor,
"iNum" => $iThumbNum+1,
];
$sApiResult = HttpV2::make($sImagesApi, "post")->withToken()->setDataA($aApiArg)->send();
$aApiResult = json_decode($sApiResult, true);
if (!isset($aApiResult["isDone"]) || !$aApiResult["isDone"]) {
echo $sApiResult;
return response()->json($oFlow->fail("{$sObjName}的图片api调用失败"));
if (\App\Services\IllustrationPool::enabled()) {
$sThumbPath = \App\Services\IllustrationPool::forArticle('freenode', $sSlug);
$sThumbNumTrue = $iThumbNum + 1;
} else {
$sImagesApi = "https://".config("path.url_resource_base")."/api/images/get/url-by-num";
$aApiArg = [
"sAuthorCode" => $sImgAuthor,
"iNum" => $iThumbNum+1,
];
$sApiResult = HttpV2::make($sImagesApi, "post")->withToken()->setDataA($aApiArg)->send();
$aApiResult = json_decode($sApiResult, true);
if (!isset($aApiResult["isDone"]) || !$aApiResult["isDone"]) {
echo $sApiResult;
return response()->json($oFlow->fail("{$sObjName}的图片api调用失败"));
}
$sThumbPath = $aApiResult["aData"]["sFileUrl"];
$sThumbNumTrue = $aApiResult["aData"]["iTrueNum"];
}
$sThumbPath = $aApiResult["aData"]["sFileUrl"];
$sThumbNumTrue = $aApiResult["aData"]["iTrueNum"];
$oCategories = Categories::where("sSlug", "freenode")->first();
$oArticle = Articles::updateOrCreate(

View File

@ -47,6 +47,17 @@ public function listByTag(Request $oRequest, string $sSlug)
return view('pages.articles.default.index', $xData);
}
public function home(Request $oRequest)
{
$sSlug = $this->oArticlesService->getLatestFreeNodeSlug();
if ($sSlug === null) {
return view('pages.articles.default.index', ['oArticles' => collect()]);
}
return $this->show($sSlug, $oRequest);
}
public function list(Request $oRequest, string $sSlug1, string $sSlug2 = null)
{
$sSlug = $sSlug2 ?? $sSlug1;

View File

@ -0,0 +1,43 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
final class EditorialAuthentication
{
public function handle(Request $request, Closure $next)
{
abort_unless(config('editorial.enabled'), 503, 'Editorial API disabled');
if (app()->environment('local')) {
$db = config('database.connections.'.config('database.default'));
abort_unless(($db['database'] ?? '') === 'fna_v3' && in_array($db['host'] ?? '', ['localhost', '127.0.0.1']) && empty($db['url']), 503, 'Local editorial database binding mismatch');
}
$path = config('editorial.credential_file');
$credential = is_file($path) ? json_decode(file_get_contents($path), true) : null;
abort_unless(is_array($credential)
&& ($credential['environment'] ?? null) === app()->environment()
&& ($credential['project'] ?? null) === 'fna-v3'
&& ($credential['schema'] ?? null) === 'gemdocs.credential.v1'
&& ($credential['base_url'] ?? null) === config('editorial.base_url')
&& preg_match('/\Afna_ed_[a-f0-9]{64}\z/', $credential['fields']['api_key'] ?? ''), 503, 'Editorial credential unavailable');
abort_unless(hash_equals($credential['fields']['api_key'], $request->bearerToken() ?? ''), 401, 'Invalid editorial credential');
try {
$response = $next($request);
} catch (\Illuminate\Database\QueryException $e) {
return response()->json(['message' => 'Concurrent or incompatible database write; query current status before retry'], 409);
} catch (\Throwable $e) {
if ($e instanceof \Symfony\Component\HttpKernel\Exception\HttpExceptionInterface || $e instanceof \Illuminate\Validation\ValidationException) {
throw $e;
}
\Illuminate\Support\Facades\Log::error('Editorial operation failed', ['exception_type' => get_class($e)]);
return response()->json(['message' => 'Editorial operation failed; query the request ID before retrying'], 500);
}
$response->headers->set('Cache-Control', 'private, no-store');
$response->headers->set('X-Robots-Tag', 'noindex, nofollow');
return $response;
}
}

View File

@ -14,6 +14,12 @@
class Articles extends Model
{
protected $guarded = [];
public function getSThumbAttribute($value)
{
return \App\Services\IllustrationPool::thumbnail($this->attributes, $value);
}
protected $appends = ['s_created_at_diff'];
use Searchable;
@ -56,7 +62,7 @@ public function shouldBeSearchable(): bool
public function searchableAs(): string
{
return 'articles';
return config('editorial.search_index') ?: 'articles';
}
public function toSearchableArray(): array

View File

@ -0,0 +1,15 @@
<?php
namespace App\Services;
use Illuminate\Http\Request;
use Spatie\ResponseCache\CacheProfiles\CacheAllSuccessfulGetRequests;
class ArticleResponseCacheProfile extends CacheAllSuccessfulGetRequests
{
public function enabled(Request $request): bool
{
// 首页跟随最新免费节点文章,不能沿用整页的七天缓存。
return ! $request->is('/', 'api/editorial/*') && parent::enabled($request);
}
}

View File

@ -226,6 +226,16 @@ public function getTagArticles(string $sSlug, string $sResultType)
return $xResult;
}
public function getLatestFreeNodeSlug(): ?string
{
return Articles::where('iStatus', 1)
->whereHas('oCategory', fn ($query) => $query->where('sSlug', 'freenode'))
->where('created_at', '<=', now())
->orderByDesc('created_at')
->orderByDesc('id')
->value('sSlug');
}
public function getCategoryArticles(string $sSlug, string $sResultType)
{
$oCategory = Categories::where('sSlug', $sSlug)->firstOrFail();
@ -254,6 +264,7 @@ public function getCategoryArticles(string $sSlug, string $sResultType)
} else {
$aResult = [
'oCategory' => $oCategory,
'iArticleCount' => isset($oCategory->aBanner) ? Articles::whereIn('iCategoryId', $aCategoryIds)->count() : null,
'oArticles' => $oArticles,
];
}

View File

@ -0,0 +1,102 @@
<?php
namespace App\Services\Editorial;
use DOMDocument;
use Illuminate\Support\Facades\DB;
use League\CommonMark\GithubFlavoredMarkdownConverter;
final class ContentRenderer
{
public function render(array $item): string
{
$html = $item['format'] === 'html' ? $item['body'] : (string) (new GithubFlavoredMarkdownConverter([
'html_input' => 'allow', 'allow_unsafe_links' => false,
]))->convert($item['body']);
$dom = $this->document($html);
$allowed = explode(' ', 'html body p div span h1 h2 h3 h4 h5 h6 strong b em i u s del small blockquote pre code ul ol li hr br a img figure figcaption table thead tbody tfoot tr th td nav section details summary link');
$attrs = explode(' ', 'href src alt title class id width height loading decoding rel target colspan rowspan aria-label aria-hidden');
foreach ($dom->getElementsByTagName('*') as $node) {
abort_unless(in_array($node->nodeName, $allowed), 422, 'Unsupported HTML element: '.$node->nodeName);
foreach ($node->attributes as $a) {
abort_unless(in_array($a->name, $attrs), 422, 'Unsupported HTML attribute: '.$a->name);
}
if ($node->hasAttribute('href')) {
$href = $node->getAttribute('href');
abort_unless(preg_match('~\A(?:https://[^\s]+|/(?!/)[^\s]*|\#[^\s]*)\z~u', $href), 422, 'Unsupported link URL');
if (str_starts_with($href, 'https://') && parse_url($href, PHP_URL_HOST) === parse_url(config('editorial.base_url'), PHP_URL_HOST)) {
abort(422, 'Use a root-relative path for internal links');
}
if (str_starts_with($href, '/images/')) {
abort_unless(! str_contains($href, '..') && is_file(public_path(ltrim($href, '/'))), 422, 'Image link unavailable');
}
}
if ($node->nodeName === 'link') {
abort_unless($node->getAttribute('rel') === 'stylesheet' && $node->getAttribute('href') === '/css/tutorials.css', 422, 'Unsupported stylesheet');
}
if ($node->hasAttribute('src')) {
$src = $node->getAttribute('src');
if (preg_match('/\Aasset:([a-f0-9]{64})\z/', $src, $m)) {
abort_unless(DB::table('editorial_assets')->where('sha256', $m[1])->exists(), 422, 'Unknown uploaded image');
} else {
abort_unless(preg_match('~\A/images/(?!.*\.\.)([a-zA-Z0-9_./-]+)\.(png|jpg|jpeg|webp|gif|svg)\z~', $src)
&& is_file(public_path(ltrim($src, '/'))), 422, 'Images must be uploaded or existing local assets');
}
}
if ($node->getAttribute('target') === '_blank') {
$node->setAttribute('rel', 'noopener noreferrer');
}
}
$body = $dom->getElementsByTagName('body')->item(0);
$html = '';
foreach ($body->childNodes as $child) {
$html .= $dom->saveHTML($child);
}
if (! str_contains($html, 'class="tutorial-body"')) {
$html = '<link rel="stylesheet" href="/css/tutorials.css"><div class="tutorial-body">'.$html.'</div>';
}
// Existing article Blade calls nl2br. Preserve code newlines as entities.
$html = preg_replace_callback('/<pre\b[^>]*>.*?<\/pre>/s', fn ($m) => str_replace("\n", '&#10;', $m[0]), $html);
return str_replace(["\r", "\n"], '', $html);
}
public function document(string $html): DOMDocument
{
$dom = new DOMDocument('1.0', 'UTF-8');
$previous = libxml_use_internal_errors(true);
$dom->loadHTML('<?xml encoding="UTF-8"><html><body>'.$html.'</body></html>', LIBXML_NONET);
libxml_clear_errors();
libxml_use_internal_errors($previous);
return $dom;
}
public function publicImages(string $html): string
{
return preg_replace_callback('/asset:([a-f0-9]{64})/', function ($m) {
$asset = DB::table('editorial_assets')->where('sha256', $m[1])->first();
abort_unless($asset && is_file($this->privateImage($asset->sha256)), 422, 'Uploaded image unavailable');
$url = '/images/editorial/'.$asset->sha256.'.'.$asset->extension;
$destination = public_path(ltrim($url, '/'));
if (! is_dir(dirname($destination))) {
mkdir(dirname($destination), 0755, true);
}
if (! is_file($destination)) {
$tmp = $destination.'.'.bin2hex(random_bytes(6)).'.tmp';
if (! copy($this->privateImage($asset->sha256), $tmp) || ! rename($tmp, $destination)) {
throw new \RuntimeException('Image publication failed');
}
chmod($destination, 0644);
}
abort_unless(hash_file('sha256', $destination) === $asset->sha256, 422, 'Image checksum mismatch');
return $url;
}, $html);
}
public function privateImage(string $sha): string
{
return storage_path('app/private/editorial/assets/'.$sha);
}
}

View File

@ -0,0 +1,80 @@
<?php
namespace App\Services\Editorial;
use App\Models\Articles;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Meilisearch\Client;
use Spatie\ResponseCache\Facades\ResponseCache;
final class PublicationEffects
{
public function synchronize(string $id): array
{
DB::transaction(function () use ($id) {
$row = DB::table('editorial_publications')->where('id', $id)->lockForUpdate()->first();
abort_unless($row, 404);
$receipt = json_decode($row->receipt, true);
$effects = json_decode($row->effects ?? '{}', true) ?: [];
if (($effects['cache']['status'] ?? '') !== 'done') {
try {
ResponseCache::clear();
Cache::forget('article_order_likes');
$effects['cache'] = ['status' => 'done'];
} catch (\Throwable) {
$effects['cache'] = ['status' => 'pending', 'reason' => 'Cache refresh failed; retry synchronization'];
}
}
if (($effects['search']['status'] ?? '') !== 'done') {
try {
$ids = array_column($receipt['articles'], 'article_id');
$effects['search'] = $this->index($ids);
} catch (\Throwable) {
$effects['search'] = ['status' => 'pending', 'reason' => 'Search synchronization failed; retry synchronization'];
}
}
// Sitemap is generated from current DB rows, no external submission is performed.
$effects['sitemap'] = ['status' => ($effects['cache']['status'] ?? '') === 'done' ? 'done' : 'pending', 'method' => 'database-driven'];
DB::table('editorial_publications')->where('id', $id)->update(['effects' => json_encode($effects), 'updated_at' => now()]);
});
return app(Publisher::class)->receipt($id);
}
public function index(?array $ids = null): array
{
$driver = config('scout.driver');
if (in_array($driver, ['collection', 'database'])) {
return ['status' => 'done', 'method' => 'live-database'];
}
if ($driver !== 'meilisearch') {
return ['status' => 'pending', 'reason' => 'Configure a supported site search driver'];
}
$name = config('editorial.search_index');
$host = config('scout.meilisearch.host');
if (! $name || (app()->environment('local') && (! in_array(parse_url($host, PHP_URL_HOST), ['localhost', '127.0.0.1', '::1']) || ! str_starts_with($name, 'fna_v3_')))) {
return ['status' => 'pending', 'reason' => 'An isolated search index is required'];
}
$client = new Client($host, config('scout.meilisearch.key'));
$index = $client->index($name);
$wait = function ($task) use ($client) {
$result = $client->waitForTask($task['taskUid'], 10000, 50);
if (($result['status'] ?? '') !== 'succeeded') {
throw new \RuntimeException('Index task failed');
}
};
$wait($index->updateSortableAttributes(['iIsTop', 'created_at']));
$wait($index->updateFilterableAttributes(['iStatus', 'iCategoryId']));
$query = Articles::query()->where('iStatus', 1);
if ($ids !== null) {
$query->whereIn('id', $ids);
}
$documents = $query->get()->map(fn ($a) => $a->toSearchableArray() + ['created_at' => $a->created_at?->timestamp])->all();
if ($documents) {
$wait($index->addDocuments($documents, 'id'));
}
return ['status' => 'done', 'method' => 'meilisearch', 'index' => $name, 'documents' => count($documents)];
}
}

View File

@ -0,0 +1,297 @@
<?php
namespace App\Services\Editorial;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Facades\Validator;
final class Publisher
{
public function __construct(private ContentRenderer $renderer) {}
public function draft(array $input): array
{
$data = Validator::make($input, [
'request_id' => 'required|uuid', 'articles' => 'required|array|min:1|max:'.config('editorial.max_batch'),
'articles.*.content_key' => ['required', 'string', 'max:100', 'regex:/\A[a-z0-9][a-z0-9_-]*\z/'],
'articles.*.expected_revision' => 'required|integer|min:0',
'articles.*.slug' => ['required', 'string', 'max:180', 'regex:/\A[a-z0-9][a-z0-9_-]*\z/'],
'articles.*.title' => 'required|string|max:200', 'articles.*.summary' => 'required|string|max:255',
'articles.*.category_slug' => 'required|string|max:50',
'articles.*.format' => 'sometimes|in:markdown,html', 'articles.*.body' => 'required|string|max:500000',
'articles.*.tag_ids' => 'sometimes|array|max:20', 'articles.*.tag_ids.*' => 'integer|min:1',
'articles.*.cover' => 'sometimes|nullable|string|max:255',
'articles.*.show_cover' => 'sometimes|boolean',
'articles.*.adopt_article_id' => 'sometimes|integer|min:1',
])->validate();
$data['operation'] = $input['_operation'] ?? 'draft';
$hash = $this->hash($data);
DB::transaction(function () use ($data, $hash) {
$inserted = DB::table('editorial_drafts')->insertOrIgnore([
'id' => $data['request_id'], 'payload_hash' => $hash, 'items' => '[]',
'status' => 'draft', 'created_at' => now(), 'updated_at' => now(),
]);
$draft = DB::table('editorial_drafts')->where('id', $data['request_id'])->lockForUpdate()->first();
abort_unless($draft && hash_equals($draft->payload_hash, $hash), 409, 'Request ID reused with different content');
if (! $inserted) {
return;
}
$rows = $data['articles'];
usort($rows, fn ($a, $b) => strcmp($a['content_key'], $b['content_key']));
abort_unless(count(array_unique(array_column($rows, 'content_key'))) === count($rows)
&& count(array_unique(array_column($rows, 'slug'))) === count($rows), 422, 'Duplicate article in batch');
$items = [];
foreach ($rows as $row) {
$key = $row['content_key'];
DB::table('editorial_documents')->insertOrIgnore(['content_key' => $key, 'revision' => 0, 'created_at' => now(), 'updated_at' => now()]);
$doc = DB::table('editorial_documents')->where('content_key', $key)->lockForUpdate()->first();
abort_unless((int) $doc->revision === (int) $row['expected_revision'], 409, 'Revision changed: '.$key);
$article = $doc->article_id ? DB::table('articles')->where('id', $doc->article_id)->lockForUpdate()->first() : null;
if (isset($row['adopt_article_id'])) {
abort_unless(! $doc->article_id || (int) $doc->article_id === (int) $row['adopt_article_id'], 409, 'Content key already bound');
$article = DB::table('articles')->where('id', $row['adopt_article_id'])->lockForUpdate()->first();
abort_unless($article && ! DB::table('editorial_documents')->where('article_id', $article->id)->where('content_key', '!=', $key)->exists(), 409, 'Article missing or already managed');
if (! $doc->article_id) {
DB::table('editorial_documents')->where('content_key', $key)->update(['article_id' => $article->id, 'published_revision' => 0]);
DB::table('editorial_versions')->insert(['content_key' => $key, 'revision' => 0, 'snapshot' => json_encode($this->baseline($key, $article)), 'created_at' => now()]);
}
}
abort_if($doc->article_id && ! $article, 409, 'Managed article was removed');
if ($article) {
abort_unless($article->sSlug === $row['slug'], 409, 'Stable article URL cannot change');
$sourceCategory = DB::table('categories')->where('id', $article->iCategoryId)->value('sSlug');
abort_if(in_array($sourceCategory, ['freenode', 'shadowrocketids']), 422, 'Resource articles cannot be adopted by the editorial API');
}
$collision = DB::table('articles')->where('sSlug', $row['slug'])->first();
abort_if($collision && (! $article || $collision->id !== $article->id), 409, 'URL belongs to another article; explicit adoption required');
$category = $this->category($row['category_slug']);
$tags = $row['tag_ids'] ?? ($article ? $this->tags($article->id) : []);
$tags = array_values(array_unique(array_map('intval', $tags)));
sort($tags);
abort_unless(DB::table('tags')->whereIn('id', $tags)->count() === count($tags), 422, 'Unknown tag ID');
$item = [
'content_key' => $key, 'revision' => $doc->revision + 1, 'slug' => $row['slug'],
'title' => $row['title'], 'summary' => $row['summary'], 'category_slug' => $category->sSlug,
'format' => $row['format'] ?? 'markdown', 'body' => $row['body'], 'tag_ids' => $tags,
'cover' => array_key_exists('cover', $row) ? $row['cover'] : ($article->sThumb ?? null),
'show_cover' => (bool) ($row['show_cover'] ?? ($article->bPageThumb ?? false)),
'base_article_hash' => $article ? $this->articleHash($article) : null,
];
$item['html'] = $this->renderer->render($item);
if ($item['cover']) {
$this->renderer->render(['format' => 'html', 'body' => '<img src="'.htmlspecialchars($item['cover'], ENT_QUOTES).'">']);
}
DB::table('editorial_documents')->where('content_key', $key)->update(['revision' => $item['revision'], 'updated_at' => now()]);
$items[] = $item;
}
$this->validateLinks($items);
DB::table('editorial_drafts')->where('id', $data['request_id'])->update(['items' => json_encode($items, JSON_UNESCAPED_UNICODE), 'updated_at' => now()]);
}, 3);
return $this->getDraft($data['request_id']);
}
public function getDraft(string $id): array
{
$d = DB::table('editorial_drafts')->where('id', $id)->first();
abort_unless($d, 404);
$items = json_decode($d->items, true);
$base = rtrim(config('editorial.base_url'), '/');
return ['draft_id' => $id, 'hash' => $d->payload_hash, 'status' => $d->status, 'publication_id' => $d->publication_id,
'articles' => array_map(fn ($i) => ['content_key' => $i['content_key'], 'revision' => $i['revision'], 'title' => $i['title'],
'path' => '/a/'.$i['slug'].'.html', 'preview_url' => $base.URL::temporarySignedRoute('editorial.preview', now()->addMinutes(config('editorial.preview_minutes')), ['draft' => $id, 'key' => $i['content_key']], absolute: false)], $items)];
}
public function article(string $key): array
{
$doc = DB::table('editorial_documents')->where('content_key', $key)->first();
abort_unless($doc, 404);
$article = $doc->article_id ? DB::table('articles')->where('id', $doc->article_id)->first() : null;
return ['content_key' => $key, 'latest_revision' => $doc->revision, 'published_revision' => $doc->published_revision,
'article_id' => $doc->article_id, 'path' => $article ? '/a/'.$article->sSlug.'.html' : null,
'versions' => DB::table('editorial_versions')->where('content_key', $key)->orderByDesc('revision')->get(['revision', 'created_at'])->toArray()];
}
public function rollback(string $key, array $input): array
{
$data = Validator::make($input, ['request_id' => 'required|uuid', 'expected_revision' => 'required|integer|min:0', 'target_revision' => 'required|integer|min:0'])->validate();
$v = DB::table('editorial_versions')->where('content_key', $key)->where('revision', $data['target_revision'])->first();
abort_unless($v, 404, 'Published version unavailable');
$item = json_decode($v->snapshot, true);
$item = array_intersect_key($item, array_flip(['content_key', 'slug', 'title', 'summary', 'category_slug', 'format', 'body', 'tag_ids', 'cover', 'show_cover']));
$item['expected_revision'] = $data['expected_revision'];
return $this->draft(['request_id' => $data['request_id'], 'articles' => [$item], '_operation' => 'rollback:'.$key.':'.$data['target_revision']]);
}
public function publish(string $id, array $input): array
{
$data = Validator::make($input, ['request_id' => 'required|uuid', 'expected_hash' => 'required|string|size:64'])->validate();
$opHash = $this->hash(['draft_id' => $id, 'expected_hash' => $data['expected_hash']]);
$publication = DB::transaction(function () use ($id, $data, $opHash) {
$existing = DB::table('editorial_publications')->where('id', $data['request_id'])->lockForUpdate()->first();
if ($existing) {
abort_unless(hash_equals($existing->payload_hash, $opHash), 409, 'Publish request ID reused');
return $existing->id;
}
$draft = DB::table('editorial_drafts')->where('id', $id)->lockForUpdate()->first();
abort_unless($draft, 404);
abort_unless(hash_equals($draft->payload_hash, $data['expected_hash']), 409, 'Previewed draft hash changed');
if ($draft->status === 'published') {
return $draft->publication_id;
}
$items = json_decode($draft->items, true);
$documents = [];
foreach ($items as $item) {
$doc = DB::table('editorial_documents')->where('content_key', $item['content_key'])->lockForUpdate()->first();
abort_unless($doc && (int) $doc->revision === $item['revision'], 409, 'Draft superseded: '.$item['content_key']);
$current = $doc->article_id ? DB::table('articles')->where('id', $doc->article_id)->lockForUpdate()->first() : null;
abort_unless(($current ? $this->articleHash($current) : null) === $item['base_article_hash'], 409, 'Article edited outside this draft');
abort_if(! $current && DB::table('articles')->where('sSlug', $item['slug'])->exists(), 409, 'URL now belongs to another article');
$this->category($item['category_slug']);
abort_unless(DB::table('tags')->whereIn('id', $item['tag_ids'])->count() === count($item['tag_ids']), 422, 'Tag removed since preview');
$documents[$item['content_key']] = $doc;
}
$this->validateLinks($items, true);
$published = [];
foreach ($items as $item) {
$doc = $documents[$item['content_key']];
$html = $this->renderer->publicImages($item['html']);
$cover = $item['cover'] ? $this->renderer->publicImages($item['cover']) : null;
$fields = ['sTitle' => $item['title'], 'sTitleSub' => $item['summary'], 'sSlug' => $item['slug'],
'sContent' => $html, 'sThumb' => $cover, 'iCategoryId' => $this->category($item['category_slug'])->id,
'iStatus' => 1, 'bPageThumb' => $item['show_cover'], 'updated_at' => now()];
if ($doc->article_id) {
$articleId = $doc->article_id;
DB::table('articles')->where('id', $articleId)->update($fields);
} else {
$articleId = DB::table('articles')->insertGetId($fields + ['iUserId' => 1, 'iViews' => 0, 'iLikeCount' => 0, 'i7like' => 0, 'i365like' => 0, 'iIsTop' => 0, 'created_at' => now()]);
}
$oldTags = $this->tags($articleId);
DB::table('article_tag')->where('iArticleId', $articleId)->delete();
foreach ($item['tag_ids'] as $tag) {
DB::table('article_tag')->insert(['iArticleId' => $articleId, 'iTagId' => $tag]);
}
foreach (array_unique(array_merge($oldTags, $item['tag_ids'])) as $tag) {
DB::table('tags')->where('id', $tag)->update(['iArticleCount' => DB::table('article_tag')->where('iTagId', $tag)->count()]);
}
DB::table('editorial_documents')->where('content_key', $item['content_key'])->update(['article_id' => $articleId, 'published_revision' => $item['revision'], 'updated_at' => now()]);
DB::table('editorial_versions')->insert(['content_key' => $item['content_key'], 'revision' => $item['revision'], 'snapshot' => json_encode($item, JSON_UNESCAPED_UNICODE), 'created_at' => now()]);
$published[] = ['content_key' => $item['content_key'], 'article_id' => $articleId, 'revision' => $item['revision'],
'url' => rtrim(config('editorial.base_url'), '/').'/a/'.$item['slug'].'.html'];
}
DB::table('editorial_publications')->insert(['id' => $data['request_id'], 'draft_id' => $id, 'payload_hash' => $opHash,
'receipt' => json_encode(['publication_id' => $data['request_id'], 'status' => 'published', 'articles' => $published], JSON_UNESCAPED_UNICODE), 'created_at' => now(), 'updated_at' => now()]);
DB::table('editorial_drafts')->where('id', $id)->update(['status' => 'published', 'publication_id' => $data['request_id'], 'updated_at' => now()]);
return $data['request_id'];
}, 3);
return app(PublicationEffects::class)->synchronize($publication);
}
public function receipt(string $id): array
{
$row = DB::table('editorial_publications')->where('id', $id)->first();
abort_unless($row, 404);
return json_decode($row->receipt, true) + ['effects' => json_decode($row->effects ?? 'null', true)];
}
public function category(string $slug): object
{
$c = DB::table('categories')->where('sSlug', $slug)->where('iStatus', 1)->first();
abort_unless($c && ! in_array($slug, ['freenode', 'shadowrocketids']), 422, 'Unknown or non-editorial category');
return $c;
}
public function articleHash(object $a): string
{
return $this->hash(['fields' => array_intersect_key((array) $a, array_flip(['sTitle', 'sTitleSub', 'sSlug', 'sContent', 'sThumb', 'iCategoryId', 'iStatus', 'bPageThumb'])), 'tag_ids' => $this->tags($a->id)]);
}
private function tags(int $id): array
{
return DB::table('article_tag')->where('iArticleId', $id)->orderBy('iTagId')->pluck('iTagId')->map(fn ($v) => (int) $v)->toArray();
}
private function baseline(string $key, object $a): array
{
$category = DB::table('categories')->where('id', $a->iCategoryId)->value('sSlug');
return ['content_key' => $key, 'slug' => $a->sSlug, 'title' => $a->sTitle, 'summary' => $a->sTitleSub ?? '',
'category_slug' => $category, 'format' => 'html', 'body' => $a->sContent, 'tag_ids' => $this->tags($a->id), 'cover' => $a->sThumb, 'show_cover' => (bool) $a->bPageThumb];
}
public function hash(array $data): string
{
$sort = function (&$value) use (&$sort) {
if (! is_array($value)) {
return;
}
if (! array_is_list($value)) {
ksort($value);
}
foreach ($value as &$child) {
$sort($child);
}
};
$sort($data);
return hash('sha256', json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
}
private function validateLinks(array $items, bool $includeIncoming = false): void
{
$targets = [];
foreach ($items as $i) {
$targets[$i['slug']] = $i['html'];
}
$sources = array_column($items, 'html');
if ($includeIncoming) {
// Only inspect incoming links affected by this batch, not unrelated legacy problems.
foreach (DB::table('articles')->where('iStatus', 1)->whereNotIn('sSlug', array_keys($targets))->get(['sContent']) as $a) {
$dom = $this->renderer->document($a->sContent ?? '');
foreach ($dom->getElementsByTagName('a') as $link) {
$href = $link->getAttribute('href');
if (preg_match('~\A/a/([a-z0-9_-]+)\.html(?:#.*)?\z~', $href, $m) && isset($targets[$m[1]])) {
$sources[] = '<a href="'.htmlspecialchars($href, ENT_QUOTES).'">incoming</a>';
}
}
}
}
foreach ($sources as $source) {
$dom = $this->renderer->document($source);
foreach ($dom->getElementsByTagName('a') as $link) {
$href = $link->getAttribute('href');
if (str_starts_with($href, '#') && strlen($href) > 1) {
abort_unless($dom->getElementById(rawurldecode(substr($href, 1))), 422, 'Missing local anchor: '.$href);
} elseif (str_starts_with($href, '/a/')) {
abort_unless(preg_match('~\A/a/([a-z0-9_-]+)\.html(?:#([^?]*))?\z~', $href, $m), 422, 'Invalid article path');
$target = $targets[$m[1]] ?? DB::table('articles')->where('sSlug', $m[1])->where('iStatus', 1)->value('sContent');
abort_unless(is_string($target), 422, 'Unpublished article target: '.$href);
if (! empty($m[2])) {
abort_unless($this->renderer->document($target)->getElementById(rawurldecode($m[2])), 422, 'Missing target anchor: '.$href);
}
} elseif (str_starts_with($href, '/') && ! str_starts_with($href, '/images/') && $href !== '/css/tutorials.css' && $href !== '/') {
$segments = explode('/', trim($href, '/'));
if ($segments[0] === 'tag' && count($segments) === 2) {
abort_unless(DB::table('tags')->where('sSlug', $segments[1])->exists(), 422, 'Unknown tag link');
} else {
abort_unless(count($segments) <= 2, 422, 'Invalid category link');
$c = DB::table('categories')->where('sSlug', end($segments))->where('iStatus', 1)->first();
abort_unless($c, 422, 'Unknown category link');
if (count($segments) === 2) {
abort_unless(DB::table('categories')->where('id', $c->iParentId)->value('sSlug') === $segments[0], 422, 'Wrong category parent');
}
}
}
}
}
}
}

View File

@ -0,0 +1,108 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
final class IllustrationPool
{
public static function enabled(): bool
{
$base = match (app()->environment()) {
'local' => 'http://resource.loc:8612',
'production' => 'https://resource-v2.gemcook.net',
'staging' => 'https://test.clashfreenode.com/resource-v2',
default => null,
};
return $base !== null && config('illustrations.enabled')
&& config('illustrations.base_url') === $base;
}
private static function request(string $method, string $path, array $body = []): array
{
if (!self::enabled()) throw new \RuntimeException('Illustration service is disabled or not bound to this environment');
$file = config('illustrations.credential_file');
if (!$file || !is_file($file)) throw new \RuntimeException('Illustration credential unavailable');
$credential = json_decode(file_get_contents($file), true, 512, JSON_THROW_ON_ERROR);
if (($credential['project'] ?? '') !== 'fna-v3' || ($credential['base_url'] ?? '') !== config('illustrations.base_url')
|| !preg_match('/\Arsv2_[a-f0-9]{64}\z/D', $credential['token'] ?? '')) {
throw new \RuntimeException('Invalid illustration credential configuration');
}
try {
$response = Http::withOptions(['proxy' => '', 'allow_redirects' => false])
->withToken($credential['token'])->acceptJson()->withUserAgent('FNA-V3/ResourceV2')
->connectTimeout(2)->timeout(8)->send($method, config('illustrations.base_url').'/api/v2/'.$path, ['json' => $body]);
if (!$response->successful() || !is_array($response->json('data'))) throw new \RuntimeException();
return $response->json('data');
} catch (\Throwable $e) {
// Never include request headers, credential bytes or response bodies in exceptions.
throw new \RuntimeException('Resource V2 illustration request failed');
}
}
public static function snapshot(string $pool): array
{
return Cache::store('file')->remember('illustrations.v2.'.$pool, 60, function () use ($pool) {
try {
$data = self::request('GET', 'pools/'.$pool);
$seen = [];
while (!empty($data['next'])) {
$after = $data['next'];
if (isset($seen[$after])) throw new \RuntimeException('Resource pagination did not advance');
$seen[$after] = true;
$page = self::request('GET', 'pools/'.$pool.'?after='.rawurlencode($after));
$data['selections'] = array_replace($data['selections'], $page['selections']);
$data['unavailable'] = array_replace($data['unavailable'] ?? [], $page['unavailable'] ?? []);
$data['next'] = $page['next'];
}
Cache::store('file')->forever('illustrations.v2.last-good.'.$pool, $data);
return $data;
} catch (\Throwable $e) {
$saved = Cache::store('file')->get('illustrations.v2.last-good.'.$pool);
if (is_array($saved)) return $saved;
throw $e;
}
});
}
public static function forget(string $pool): void
{
Cache::store('file')->forget('illustrations.v2.'.$pool);
}
public static function assign(string $pool, array $keys): array
{
$data = self::request('POST', 'pools/'.$pool.'/selections', ['keys' => $keys]);
self::forget($pool);
return $data['selections'];
}
public static function forArticle(string $pool, string $slug): string
{
return self::assign($pool, [$slug])[$slug]['url'];
}
public static function thumbnail(array $article, ?string $original): ?string
{
if (!self::enabled()) return $original;
$pool = config('illustrations.categories.'.($article['iCategoryId'] ?? ''));
if (!$pool || empty($article['sSlug'])) return $original;
try {
$snapshot = self::snapshot($pool);
$reason = $snapshot['unavailable'][$article['sSlug']] ?? null;
if ($reason) {
// Repair accidental physical loss, never override deliberate publication/access withdrawal.
if (in_array($reason, ['file_missing', 'file_corrupt', 'file_symlink'], true)) {
try { return self::forArticle($pool, $article['sSlug']); }
catch (\Throwable $e) { return null; }
}
return null;
}
return $snapshot['selections'][$article['sSlug']]['url'] ?? $original;
} catch (\Throwable $e) {
// A failed resource service must not invent or reassign an article's image.
return $original;
}
}
}

10
config/editorial.php Normal file
View File

@ -0,0 +1,10 @@
<?php
return [
'enabled' => env('EDITORIAL_API_ENABLED', env('APP_ENV') === 'local'),
'base_url' => env('EDITORIAL_BASE_URL', env('APP_ENV') === 'local' ? 'http://fna-v3.loc' : env('APP_URL')),
'credential_file' => storage_path('app/private/editorial/credential.json'),
'preview_minutes' => 30,
'max_batch' => 10,
'search_index' => env('EDITORIAL_SEARCH_INDEX', env('APP_ENV') === 'local' ? 'fna_v3_articles' : null),
];

8
config/illustrations.php Normal file
View File

@ -0,0 +1,8 @@
<?php
return [
'enabled' => (bool) env('RESOURCE_V2_ILLUSTRATIONS_ENABLED', env('LOCAL_ILLUSTRATIONS_ENABLED', env('APP_ENV') === 'local')),
'base_url' => env('RESOURCE_V2_URL', 'http://resource.loc:8612'),
'credential_file' => env('RESOURCE_V2_CREDENTIAL_FILE'),
'categories' => [1 => 'freenode', 9 => 'shadowrocketids'],
];

View File

@ -13,7 +13,7 @@
* You can provide your own class given that it implements the
* CacheProfile interface.
*/
'cache_profile' => Spatie\ResponseCache\CacheProfiles\CacheAllSuccessfulGetRequests::class,
'cache_profile' => App\Services\ArticleResponseCacheProfile::class,
// 'cache_profile' => Spatie\ResponseCache\CacheProfiles\CacheAllRequests::class,
// 'cache_profile' => \App\Support\CustomCacheProfile::class,
//'cache_profile' => \App\Support\BrutalCacheProfile::class,

View File

@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('editorial_documents', function (Blueprint $t) {
$t->string('content_key', 100)->primary();
$t->unsignedBigInteger('article_id')->nullable()->unique();
$t->unsignedInteger('revision')->default(0);
$t->unsignedInteger('published_revision')->nullable();
$t->timestamps();
});
Schema::create('editorial_drafts', function (Blueprint $t) {
$t->uuid('id')->primary();
$t->string('payload_hash', 64);
$t->longText('items');
$t->string('status', 20)->default('draft');
$t->uuid('publication_id')->nullable();
$t->timestamps();
});
Schema::create('editorial_versions', function (Blueprint $t) {
$t->id();
$t->string('content_key', 100);
$t->unsignedInteger('revision');
$t->longText('snapshot');
$t->timestamp('created_at');
$t->unique(['content_key', 'revision']);
});
Schema::create('editorial_publications', function (Blueprint $t) {
$t->uuid('id')->primary();
$t->uuid('draft_id')->unique();
$t->string('payload_hash', 64);
$t->longText('receipt');
$t->longText('effects')->nullable();
$t->timestamps();
});
Schema::create('editorial_assets', function (Blueprint $t) {
$t->string('sha256', 64)->primary();
$t->string('extension', 5);
$t->string('mime', 40);
$t->unsignedInteger('bytes');
$t->timestamp('created_at');
});
}
public function down(): void
{
foreach (['editorial_assets', 'editorial_publications', 'editorial_versions', 'editorial_drafts', 'editorial_documents'] as $table) {
Schema::dropIfExists($table);
}
}
};

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
:root{--btn-primary:#A758A5;--color-primary:#A758A5;--href:#0d6efd}.site-main{min-height:1000px}.menua-checked{color:var(--color-primary)}.menua:hover,.menuaSub:hover{color:var(--color-primary)!important}.widget-border{background-image:linear-gradient(to right,#7e427c,#995097,#a359a1,#7e427c)!important}.header .navbar-brand img{max-height:50px}.header .navbar-brand{max-width:250px}

View File

@ -0,0 +1 @@
:root{--btn-primary:#00A8B1;--color-primary:#00A8B1;--href:#0d6efd}.site-main{min-height:1000px}.menua-checked{color:var(--color-primary)}.menua:hover,.menuaSub:hover{color:var(--color-primary)!important}.widget-border{background-image:linear-gradient(to right,#00a8b1,#83c4c8,#bae2dd,#83c4c8)}.header .navbar-brand img{max-height:50px}.header .navbar-brand{max-width:250px}

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,49 @@
{
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-brands-400.woff2": {
"file": "assets/fa-brands-400-BfBXV7Mm.woff2",
"src": "node_modules/@fortawesome/fontawesome-free/webfonts/fa-brands-400.woff2"
},
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-regular-400.woff2": {
"file": "assets/fa-regular-400-BVHPE7da.woff2",
"src": "node_modules/@fortawesome/fontawesome-free/webfonts/fa-regular-400.woff2"
},
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.woff2": {
"file": "assets/fa-solid-900-8GirhLYJ.woff2",
"src": "node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.woff2"
},
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-v4compatibility.woff2": {
"file": "assets/fa-v4compatibility-DnhYSyY-.woff2",
"src": "node_modules/@fortawesome/fontawesome-free/webfonts/fa-v4compatibility.woff2"
},
"resources/css/app.css": {
"file": "assets/app-BCx10ygF.css",
"src": "resources/css/app.css"
},
"resources/fonts/iconfont/iconfont.ttf": {
"file": "assets/iconfont-B3tZHDj4.ttf",
"src": "resources/fonts/iconfont/iconfont.ttf"
},
"resources/fonts/iconfont/iconfont.woff": {
"file": "assets/iconfont-D7mxe_Lh.woff",
"src": "resources/fonts/iconfont/iconfont.woff"
},
"resources/js/app.js": {
"file": "assets/app-DDSp2ths.js",
"name": "app",
"src": "resources/js/app.js",
"isEntry": true
},
"resources/sites/cfn/css/app.css": {
"file": "assets/app-Ba5joxXD.css",
"src": "resources/sites/cfn/css/app.css",
"isEntry": true,
"name": "app",
"names": [
"app.css"
]
},
"resources/sites/vfn/css/app.css": {
"file": "assets/app-D96dFcq0.css",
"src": "resources/sites/vfn/css/app.css"
}
}

21
public/css/tutorials.css Normal file
View File

@ -0,0 +1,21 @@
.tutorial-body{font-size:16px;line-height:1.95;overflow-wrap:anywhere;color:inherit}.tutorial-body p{margin:0 0 1.35em}.tutorial-body h2{font-size:1.42em;line-height:1.55;margin:2.4em 0 1em;padding-top:.35em;scroll-margin-top:100px}.tutorial-body h3{font-size:1.13em;margin:1.6em 0 .8em}.tutorial-body .guide-meta{font-size:12px;letter-spacing:.05em;color:#64748b;margin-bottom:24px}.tutorial-body .guide-note{border-left:3px solid #4088a4;background:#eff7fa;padding:18px 22px;border-radius:0 8px 8px 0;margin:26px 0;color:#263d4c}.tutorial-body .guide-note p{margin:.5em 0 0}.tutorial-body .guide-toc{background:#f5f7fa;border:1px solid #e0e7ee;padding:18px 22px;border-radius:8px;margin:26px 0;display:flex;gap:8px 18px;flex-wrap:wrap}.tutorial-body .guide-toc strong{flex-basis:100%}.tutorial-body a{color:#176e92;text-underline-offset:3px}.tutorial-body .guide-figure,.tutorial-body .guide-diagram{margin:30px 0}.tutorial-body .guide-figure img{display:block;width:100%;height:auto;border:1px solid #dce4eb;border-radius:8px}.tutorial-body figcaption{font-size:12px;line-height:1.75;color:#64748b;margin-top:12px;text-align:left}.tutorial-body .guide-diagram{padding:22px;background:#f4f8fb;border:1px solid #dce6ef;border-radius:10px;color:#243e50}.tutorial-body .diagram-label{font-size:12px;font-weight:700;letter-spacing:.07em;color:#47748a;margin-bottom:18px}.tutorial-body .guide-flow{display:flex;align-items:center;gap:7px}.tutorial-body .guide-flow>div{flex:1;min-width:0;text-align:center;background:#fff;border:1px solid #bdd3df;border-radius:6px;padding:14px 6px;font-size:13px;line-height:1.7}.tutorial-body .guide-flow small{display:block;font-size:11px;color:#547084}.tutorial-body .guide-flow>span{color:#5c8399}.tutorial-body .guide-branch{margin-top:12px;padding:12px 16px;background:#fff6eb;border-left:3px solid #ca9460;font-size:13px;line-height:1.9}.tutorial-body .table-wrap{overflow-x:auto;margin:24px 0}.tutorial-body table{width:100%;border-collapse:collapse;font-size:14px;line-height:1.8;margin:0}.tutorial-body th,.tutorial-body td{border:1px solid #dce4eb;padding:12px 14px;text-align:left;vertical-align:top}.tutorial-body th{background:#eff4f8;color:#30495c}.tutorial-body td:first-child{min-width:100px}.tutorial-body pre{max-width:100%;overflow:auto;background:#182a3b;color:#e2eef7;border-radius:8px;padding:22px;font-size:12px;line-height:1.8;white-space:pre}.tutorial-body pre code{background:transparent;color:inherit;padding:0;white-space:pre}.tutorial-body code{font-size:.9em}.tutorial-body ul,.tutorial-body ol{padding-left:1.6em;margin-bottom:1.5em}.tutorial-body li{margin:.5em 0}.tutorial-body .guide-footnote{font-size:12px;color:#64748b;border-top:1px solid #dce4eb;margin-top:36px;padding-top:18px}.tutorial-category{padding:24px 28px;margin:0 0 24px;border-radius:8px;background:var(--bs-body-bg,#fff);border-left:3px solid #4088a4}.tutorial-category h1{font-size:24px;margin:0 0 8px}.tutorial-category p{margin:0;color:#708090;line-height:1.8}@media(max-width:600px){.tutorial-body{font-size:15px}.tutorial-body h2{font-size:1.3em}.tutorial-body .guide-diagram{padding:16px}.tutorial-body .guide-flow{flex-direction:column;gap:5px}.tutorial-body .guide-flow>div{width:100%;padding:10px}.tutorial-body .guide-flow>span{transform:rotate(90deg)}.tutorial-body th,.tutorial-body td{padding:10px}.tutorial-body .guide-note{padding:14px 16px}}@media(prefers-color-scheme:dark){body.dark .tutorial-body .guide-meta{color:#a5b8c9}}
/* Restore ordered tutorial steps after the site reset. */
.tutorial-body ol{list-style-type:decimal}
/* Article headings: keep sections separate and replace the legacy blue bar. */
.post-content .tutorial-body h2,
.post-content .tutorial-body h3 {
display: block;
}
.post-content .tutorial-body h3 {
padding: 0 0 0 .75em;
border-left: 3px solid var(--color-primary);
line-height: 1.65;
text-align: left;
scroll-margin-top: 100px;
}
.post-content .tutorial-body h3::before {
content: none;
display: none;
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540" role="img" aria-label="Clash Meta for Android 入门:配置、节点与连接"><rect width="960" height="540" fill="#edf2fa"/><rect x="55" y="62" width="6" height="412" fill="#526eaa"/><g font-family="Arial, PingFang SC, Microsoft YaHei, sans-serif"><text x="97" y="130" font-size="24" fill="#627b9f" letter-spacing="3">ANDROID · 开始使用</text><text x="97" y="245" font-size="58" fill="#293e65" font-weight="700">Clash Meta 安卓入门</text><text x="100" y="318" font-size="30" fill="#5b6d89">导入配置 · 选择节点 · 开始连接</text><path d="M100 403H725" stroke="#a8b9d8" stroke-width="3"/><circle cx="100" cy="403" r="9" fill="#526eaa"/><circle cx="410" cy="403" r="9" fill="#526eaa"/><circle cx="725" cy="403" r="9" fill="#526eaa"/></g></svg>

After

Width:  |  Height:  |  Size: 854 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540"><rect width="960" height="540" fill="#eaf2f7"/><rect x="55" y="62" width="6" height="412" fill="#347c99"/><g font-family="Arial, PingFang SC, Microsoft YaHei, sans-serif"><text x="97" y="130" font-size="24" fill="#47748a" letter-spacing="3">MIHOMO · 综合教程</text><text x="97" y="265" font-size="60" fill="#18394d" font-weight="700">DNS 防泄漏</text><text x="100" y="327" font-size="30" fill="#547084">接管 · 加密 · 出口</text><path d="M100 403H725" stroke="#92b6c9" stroke-width="3"/><circle cx="100" cy="403" r="9" fill="#347c99"/><circle cx="410" cy="403" r="9" fill="#347c99"/><circle cx="725" cy="403" r="9" fill="#347c99"/></g></svg>

After

Width:  |  Height:  |  Size: 743 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540" role="img" aria-label="Mac 代理客户端下载指南:ClashX 与 Clash Verge Rev 怎么选"><rect width="960" height="540" fill="#edf3f7"/><rect x="55" y="62" width="6" height="412" fill="#4b738a"/><g font-family="Arial, PingFang SC, Microsoft YaHei, sans-serif"><text x="97" y="130" font-size="29" fill="#4b738a">选择与下载</text><text x="97" y="255" font-size="60" fill="#283b46" font-weight="700">Mac 客户端指南</text><text x="100" y="322" font-size="30" fill="#4b738a">系统要求 · 芯片架构 · 官方来源</text><path d="M100 403H790" stroke="#4b738a" opacity=".45" stroke-width="3"/><circle cx="100" cy="403" r="9" fill="#4b738a"/><circle cx="445" cy="403" r="9" fill="#4b738a"/><circle cx="790" cy="403" r="9" fill="#4b738a"/></g></svg>

After

Width:  |  Height:  |  Size: 851 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540"><rect width="960" height="540" fill="#eaf2f7"/><rect x="55" y="62" width="6" height="412" fill="#347c99"/><g font-family="Arial, PingFang SC, Microsoft YaHei, sans-serif"><text x="97" y="130" font-size="24" fill="#47748a" letter-spacing="3">WINDOWS · 迁移指南</text><text x="97" y="265" font-size="60" fill="#18394d" font-weight="700">迁移到 Verge Rev</text><text x="100" y="327" font-size="30" fill="#547084">保留订阅与自定义配置</text><path d="M100 403H725" stroke="#92b6c9" stroke-width="3"/><circle cx="100" cy="403" r="9" fill="#347c99"/><circle cx="410" cy="403" r="9" fill="#347c99"/><circle cx="725" cy="403" r="9" fill="#347c99"/></g></svg>

After

Width:  |  Height:  |  Size: 754 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540" role="img" aria-label="Quantumult X 使用教程:iOS 安装、订阅导入与连接"><rect width="960" height="540" fill="#f1f0f8"/><rect x="55" y="62" width="6" height="412" fill="#655c8e"/><g font-family="Arial, PingFang SC, Microsoft YaHei, sans-serif"><text x="97" y="130" font-size="29" fill="#655c8e">iPhone / iPad 入门</text><text x="97" y="255" font-size="60" fill="#283b46" font-weight="700">Quantumult X</text><text x="100" y="322" font-size="30" fill="#655c8e">安装 · 资源解析 · 连接</text><path d="M100 403H790" stroke="#655c8e" opacity=".45" stroke-width="3"/><circle cx="100" cy="403" r="9" fill="#655c8e"/><circle cx="445" cy="403" r="9" fill="#655c8e"/><circle cx="790" cy="403" r="9" fill="#655c8e"/></g></svg>

After

Width:  |  Height:  |  Size: 829 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540"><rect width="960" height="540" fill="#eaf2f7"/><rect x="55" y="62" width="6" height="412" fill="#347c99"/><g font-family="Arial, PingFang SC, Microsoft YaHei, sans-serif"><text x="97" y="130" font-size="24" fill="#47748a" letter-spacing="3">代理设置 · 综合教程</text><text x="97" y="265" font-size="56" fill="#18394d" font-weight="700">系统代理与 TUN</text><text x="100" y="327" font-size="30" fill="#547084">接管范围 · 使用选择 · 排错</text><path d="M100 403H725" stroke="#92b6c9" stroke-width="3"/><circle cx="100" cy="403" r="9" fill="#347c99"/><circle cx="410" cy="403" r="9" fill="#347c99"/><circle cx="725" cy="403" r="9" fill="#347c99"/></g></svg>

After

Width:  |  Height:  |  Size: 767 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540" role="img" aria-label="v2rayNG 安卓入门:安装、订阅、连接"><rect width="960" height="540" fill="#edf5f1"/><rect x="55" y="62" width="6" height="412" fill="#427d65"/><g font-family="Arial, PingFang SC, Microsoft YaHei, sans-serif"><text x="97" y="130" font-size="24" fill="#507e6b" letter-spacing="3">ANDROID · 开始使用</text><text x="97" y="255" font-size="64" fill="#234d3b" font-weight="700">v2rayNG 安卓入门</text><text x="100" y="322" font-size="30" fill="#547467">安装 · 导入订阅 · 第一次连接</text><path d="M100 403H725" stroke="#9abaaa" stroke-width="3"/><circle cx="100" cy="403" r="9" fill="#427d65"/><circle cx="410" cy="403" r="9" fill="#427d65"/><circle cx="725" cy="403" r="9" fill="#427d65"/></g></svg>

After

Width:  |  Height:  |  Size: 839 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540" role="img" aria-label="Clash Verge Rev 使用教程:Linux 安装、订阅与代理设置"><rect width="960" height="540" fill="#f8f2e8"/><rect x="55" y="62" width="6" height="412" fill="#907044"/><g font-family="Arial, PingFang SC, Microsoft YaHei, sans-serif"><text x="97" y="130" font-size="29" fill="#907044">Linux 入门教程</text><text x="97" y="255" font-size="60" fill="#283b46" font-weight="700">Clash Verge Rev</text><text x="100" y="322" font-size="30" fill="#907044">DEB / RPM · 订阅 · 终端代理</text><path d="M100 403H790" stroke="#907044" opacity=".45" stroke-width="3"/><circle cx="100" cy="403" r="9" fill="#907044"/><circle cx="445" cy="403" r="9" fill="#907044"/><circle cx="790" cy="403" r="9" fill="#907044"/></g></svg>

After

Width:  |  Height:  |  Size: 838 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540" role="img" aria-label="Clash Verge Rev 使用教程:Mac 安装、订阅与系统代理"><rect width="960" height="540" fill="#edf5f1"/><rect x="55" y="62" width="6" height="412" fill="#427d65"/><g font-family="Arial, PingFang SC, Microsoft YaHei, sans-serif"><text x="97" y="130" font-size="29" fill="#427d65">Mac 入门教程</text><text x="97" y="255" font-size="60" fill="#283b46" font-weight="700">Clash Verge Rev</text><text x="100" y="322" font-size="30" fill="#427d65">安装 · 订阅 · 系统代理</text><path d="M100 403H790" stroke="#427d65" opacity=".45" stroke-width="3"/><circle cx="100" cy="403" r="9" fill="#427d65"/><circle cx="445" cy="403" r="9" fill="#427d65"/><circle cx="790" cy="403" r="9" fill="#427d65"/></g></svg>

After

Width:  |  Height:  |  Size: 831 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540"><rect width="960" height="540" fill="#eaf2f7"/><rect x="55" y="62" width="6" height="412" fill="#347c99"/><g font-family="Arial, PingFang SC, Microsoft YaHei, sans-serif"><text x="97" y="130" font-size="24" fill="#47748a" letter-spacing="3">WINDOWS · 开始使用</text><text x="97" y="265" font-size="60" fill="#18394d" font-weight="700">从安装到连接验证</text><text x="100" y="327" font-size="30" fill="#547084">Clash Verge Rev</text><path d="M100 403H725" stroke="#92b6c9" stroke-width="3"/><circle cx="100" cy="403" r="9" fill="#347c99"/><circle cx="410" cy="403" r="9" fill="#347c99"/><circle cx="725" cy="403" r="9" fill="#347c99"/></g></svg>

After

Width:  |  Height:  |  Size: 744 B

View File

@ -6,6 +6,7 @@
'sOgType' => 'website',
'sOgImg' => '/sites/'.config("app.site_code_short").'/siteimg.jpg',
'sRobots' => 'index, follow',
'bEditorialPreview' => false,
])
<!DOCTYPE html>
@ -16,7 +17,7 @@
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<meta name="csrf-token" content="{{ csrf_token() }}">
<meta name="slug" content="{{ $sSlug }}">
<meta name="robots" content="{{ $sRobots }}">
<meta name="robots" content="{{ app()->environment('staging') ? 'noindex, nofollow' : $sRobots }}">
@php
$sTodayDate = now()->format('Y年m月d日'); //有问题?
@ -48,7 +49,7 @@
@stack('styles')
@if(config('app.site_code_short') == 'cfn')
@if(! app()->environment('staging') && ! $bEditorialPreview && config('app.site_code_short') == 'cfn')
<script defer src="https://umami.gemcook.net/script.js" data-website-id="c553e118-942e-4d6b-9e6d-9d7e696ab5fd"></script>
@endif

View File

@ -28,8 +28,7 @@ class="d-none logo-dark nc-no-lazy"
@php
$bIsMatched = request()->is($oNav->sSlug) || request()->is($oNav->sSlug . '/*');
$bIsHomeFirst = request()->is('/') && $loop->first;
$sActiveAttr = ($bIsMatched || $bIsHomeFirst) ? 'menua-checked' : '';
$sActiveAttr = $bIsMatched ? 'menua-checked' : '';
@endphp
<li class=" menu-item menu-item-type-taxonomy menu-item-object-category current-post-ancestor current-menu-parent current-post-parent {{ $oNav->aChildren->isNotEmpty() ? 'menu-item-has-children' : '' }}">

View File

@ -1,8 +1,19 @@
<x-app-layout
:sSlug="$oCategory->sSlug ?? ''"
:sTitle="isset($oCategory) && in_array($oCategory->sSlug, ['client', 'advanced']) ? $oCategory->sName . ' - ' . config('app.name') : config('app.title') . ' - ' . config('app.name')"
:sDesc="isset($oCategory) && in_array($oCategory->sSlug, ['client', 'advanced']) ? $oCategory->sDescription : config('app.desc')"
>
<main class="site-main">
<div class="container">
@if (isset($oCategory) && in_array($oCategory->sSlug, ['client', 'advanced']))
<link rel="stylesheet" href="/css/tutorials.css">
@if (!isset($oCategory->aBanner) || count($oArticles) == 0)
<section class="tutorial-category" aria-label="教程栏目介绍">
<h1>{{ $oCategory->sName }}</h1>
<p>{{ $oCategory->sDescription }}</p>
</section>
@endif
@endif
@if (count($oArticles) == 0)
{{-- Empty State --}}
<div class="content-error d-flex flex-fill align-items-center h-v-80">
@ -37,7 +48,7 @@
</div>
</div>
<div class="cover-body">
<h1 class="cover-title">{{$oCategory->sSlug}}<sup class="text-light mx-1">{{count($oArticles)}}</sup></h1>
<h1 class="cover-title">{{ in_array($oCategory->sSlug, ['client', 'advanced']) ? $oCategory->sName : $oCategory->sSlug }}<sup class="text-light mx-1">{{ $iArticleCount ?? count($oArticles) }}</sup></h1>
</div>
</div>
@endisset
@ -58,7 +69,7 @@
<div class="sidebar col-lg-4 d-none d-lg-block ms-lg-auto">
@include('components.order-likes')
<!-- @include('components.generated-tags-cloud') -->
@include('components.banners')
@include('components.banners')
</div>
</div>

View File

@ -1,7 +1,8 @@
@php
$iNoIndexDays = (int) ($oArticle->oCategory->aOpt['noindexday'] ?? 0);
$bIsExpired = $iNoIndexDays > 0 && $oArticle->created_at->addDays($iNoIndexDays)->isPast();
$sRobotsStatus = $bIsExpired ? 'noindex, follow' : 'index, follow';
$bIsExpired = $iNoIndexDays > 0 && $oArticle->created_at->copy()->addDays($iNoIndexDays)->isPast();
$bIsHome = request()->routeIs('articles.home');
$sRobotsStatus = ! $bIsHome && $bIsExpired ? 'noindex, follow' : 'index, follow';
@endphp
<x-app-layout
@ -10,7 +11,8 @@
:sDesc="$oArticle->sTitleSub"
sOgType="article"
:sOgImg="$oArticle->sThumb"
:sRobots="$sRobotsStatus"
:sRobots="($bEditorialPreview ?? false) ? 'noindex, nofollow' : $sRobotsStatus"
:bEditorialPreview="$bEditorialPreview ?? false"
>
{{--
@ -20,6 +22,9 @@
--}}
<main class="site-main">
@if($bEditorialPreview ?? false)
<div class="container"><div class="alert alert-info">草稿预览 · 版本 {{ $editorialRevision }} · {{ ($editorialDraftStatus ?? 'draft') === 'published' ? '此版本已有发布记录' : '尚未发布' }}</div></div>
@endif
<div class="container">
<div class="d-none d-md-flex breadcrumbs text-muted mb-2">
<span itemprop="itemListElement">
@ -32,7 +37,7 @@
<i class="text-primary iconfont icon-dot"></i>
</span>
<span itemprop="itemListElement">
<a href="{{ $oArticle->oCategory->oParent->sSlug }}" itemprop="item">
<a href="{{ url($oArticle->oCategory->oParent->sSlug) }}" itemprop="item">
<span itemprop="name">{{ $oArticle->oCategory->oParent->sName }}</span>
</a>
</span>
@ -41,7 +46,7 @@
<i class="text-primary iconfont icon-dot"></i>
</span>
<span itemprop="itemListElement">
<a href="{{ $oArticle->oCategory->sSlug }}" itemprop="item">
<a href="{{ url($oArticle->oCategory->sSlug) }}" itemprop="item">
<span itemprop="name">{{ $oArticle->oCategory->sName }}</span>
</a>
</span>
@ -59,7 +64,7 @@
<div class="post-meta text-xs">
<a href="/"><i class="text-sm iconfont icon-user1 me-1"></i>{{ config('app.name') }}</a>
<i class="text-primary iconfont icon-dot mx-2"></i>
<span class="text-muted">{{ $oArticle->sCreatedAtDiff }}</span>
<span class="text-muted">发布于 {{ $oArticle->sCreatedAtDiff }}</span>
</div>
</div>
<div class="post-content">
@ -71,6 +76,7 @@
{!! nl2br($oArticle->sContent) !!}
</div>
<div class="post-tags mt-3 mt-md-4">
<div class="">
@foreach($oArticle->oTags as $oTag)
@ -79,6 +85,7 @@
</div>
</div>
@unless($bEditorialPreview ?? false)
<div class="post-actions d-flex align-items-center my-3 my-md-4">
<a href="javascript:;"
class="post-like-button btn btn-light btn-w-md btn-rounded me-2"
@ -103,6 +110,7 @@ class="post-like-button btn btn-light btn-w-md btn-rounded me-2"
</div>
</template>
</div>
@endunless
</div>
</div>
@ -110,7 +118,9 @@ class="post-like-button btn btn-light btn-w-md btn-rounded me-2"
</div>
<div class="sidebar col-lg-4 d-none d-lg-block ms-lg-auto ">
@unless($bEditorialPreview ?? false)
@include('components.order-likes')
@endunless
<!-- @include('components.generated-tags-cloud') -->
@include('components.banners')
</div>

View File

@ -27,3 +27,24 @@
});
// AI editorial API: independent of legacy feed ingestion and ATOM.
Route::prefix('editorial/v1')->middleware([\App\Http\Middleware\EditorialAuthentication::class, 'throttle:60,1'])->group(function () {
$controller = \App\Http\Controllers\Api\Editorial\EditorialController::class;
Route::get('catalog', [$controller, 'catalog']);
Route::get('source-articles/{article}', [$controller, 'source'])->whereNumber('article');
Route::post('assets', [$controller, 'upload']);
Route::get('assets/{sha}', [$controller, 'asset'])->where('sha', '[a-f0-9]{64}');
Route::post('drafts', [$controller, 'store']);
Route::get('drafts/{draft}', [$controller, 'draft'])->whereUuid('draft');
Route::post('drafts/{draft}/publish', [$controller, 'publish'])->whereUuid('draft');
Route::get('articles/{key}', [$controller, 'article']);
Route::get('articles/{key}/versions/{revision}', [$controller, 'version'])->whereNumber('revision');
Route::post('articles/{key}/rollback', [$controller, 'rollback']);
Route::get('publications/{publication}', [$controller, 'receipt'])->whereUuid('publication');
Route::post('publications/{publication}/synchronize', [$controller, 'synchronize'])->whereUuid('publication');
});
Route::get('editorial/v1/preview/{draft}/{key}', [\App\Http\Controllers\Api\Editorial\EditorialController::class, 'preview'])
->middleware(['signed:relative', 'throttle:60,1'])->whereUuid('draft')->name('editorial.preview');
Route::get('editorial/v1/preview-assets/{sha}', [\App\Http\Controllers\Api\Editorial\EditorialController::class, 'asset'])
->middleware(['signed:relative', 'throttle:120,1'])->where('sha', '[a-f0-9]{64}')->name('editorial.preview-asset');

View File

@ -26,8 +26,7 @@
//Route::post('/api/freenode/receive', [ApiV1FreenodeController::class, 'receive']);
Route::post('/api/article/find', [ApiV1ArticlesController::class, 'find']);
Route::get('/', [ArticlesController::class, 'list'])
->defaults('sSlug', 'freenode')
Route::get('/', [ArticlesController::class, 'home'])
->name('articles.home');
Route::get('/like/{iId}', [ArticlesController::class, 'like'])->name('article.like');

View File

@ -0,0 +1,335 @@
<?php
namespace Tests\Feature;
use App\Services\ArticleResponseCacheProfile;
use App\Services\Editorial\ContentRenderer;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Tests\TestCase;
#[\PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses]
#[\PHPUnit\Framework\Attributes\PreserveGlobalState(false)]
class EditorialApiTest extends TestCase
{
private string $credential;
private string $token;
private array $uploaded = [];
protected function setUp(): void
{
parent::setUp();
config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => ':memory:',
'editorial.enabled' => true, 'editorial.base_url' => 'http://localhost',
'scout.driver' => 'collection', 'responsecache.enabled' => false, 'cache.default' => 'array']);
DB::purge('sqlite');
$this->credential = tempnam(sys_get_temp_dir(), 'editorial-test-');
$this->token = 'fna_ed_'.str_repeat('a', 64);
file_put_contents($this->credential, json_encode(['project' => 'fna-v3', 'environment' => 'testing', 'base_url' => 'http://localhost', 'schema' => 'gemdocs.credential.v1', 'fields' => ['api_key' => $this->token]]));
config(['editorial.credential_file' => $this->credential]);
Http::preventStrayRequests();
Schema::create('categories', function (Blueprint $t) {
$t->id();
$t->string('sSlug');
$t->string('sName');
$t->integer('iParentId')->default(0);
$t->integer('iStatus')->default(1);
$t->integer('iSort')->default(0);
$t->integer('iShowChildren')->default(0);
$t->string('sHtmlClass')->nullable();
$t->text('aOpt')->nullable();
});
Schema::create('articles', function (Blueprint $t) {
$t->id();
$t->integer('iCategoryId');
$t->integer('iUserId')->default(1);
$t->string('sSlug')->unique();
$t->string('sTitle');
$t->string('sTitleSub')->nullable();
$t->text('sContent');
$t->string('sThumb')->nullable();
$t->integer('iStatus')->default(1);
$t->boolean('bPageThumb')->default(false);
foreach (['iViews', 'iLikeCount', 'i7like', 'i365like', 'iIsTop'] as $field) {
$t->integer($field)->default(0);
}
$t->timestamp('sLastLikedAt')->nullable();
$t->timestamps();
});
Schema::create('tags', function (Blueprint $t) {
$t->id();
$t->string('sSlug');
$t->string('sName');
$t->integer('iArticleCount')->default(0);
});
Schema::create('article_tag', function (Blueprint $t) {
$t->integer('iArticleId');
$t->integer('iTagId');
});
(require database_path('migrations/2026_10_27_190000_create_editorial_tables.php'))->up();
DB::table('categories')->insert([
['id' => 1, 'sSlug' => 'freenode', 'sName' => '资源'], ['id' => 3, 'sSlug' => 'windows', 'sName' => 'Windows'],
['id' => 10, 'sSlug' => 'advanced', 'sName' => '进阶教程'],
]);
DB::table('tags')->insert(['id' => 1, 'sSlug' => 'clash', 'sName' => 'Clash']);
$this->withHeaders(['Authorization' => 'Bearer '.$this->token]);
}
protected function tearDown(): void
{
@unlink($this->credential);
foreach ($this->uploaded as $sha) {
@unlink(app(ContentRenderer::class)->privateImage($sha));
foreach (glob(public_path('images/editorial/'.$sha.'.*')) as $path) {
@unlink($path);
}
}
parent::tearDown();
}
private function item(string $key = 'example', int $revision = 0): array
{
return ['content_key' => $key, 'expected_revision' => $revision, 'slug' => $key, 'title' => '教程标题',
'summary' => '一篇操作教程', 'category_slug' => 'windows', 'body' => '<h2 id="install">安装</h2>'."\n\n正文", 'tag_ids' => [1]];
}
private function draft(array $items, ?string $id = null): array
{
return $this->postJson('/api/editorial/v1/drafts', ['request_id' => $id ?? (string) Str::uuid(), 'articles' => $items])->assertCreated()->json();
}
private function publish(array $draft, ?string $id = null)
{
return $this->postJson('/api/editorial/v1/drafts/'.$draft['draft_id'].'/publish', ['request_id' => $id ?? (string) Str::uuid(), 'expected_hash' => $draft['hash']]);
}
public function test_existing_tutorial_flowchart_small_labels_are_supported(): void
{
$item = $this->item();
$item['body'] = '<figure class="guide-diagram"><div class="guide-flow"><div><b>导入订阅</b><small>本站免费节点</small></div></div></figure>';
$d = $this->draft([$item]);
$this->get($d['articles'][0]['preview_url'])->assertOk()->assertSee('<small>本站免费节点</small>', false);
}
public function test_authentication_and_environment_binding_and_cache_bypass(): void
{
$this->withHeaders(['Authorization' => 'Bearer invalid'])->getJson('/api/editorial/v1/catalog')->assertUnauthorized();
$this->withHeaders(['Authorization' => 'Bearer '.$this->token]);
$this->getJson('/api/editorial/v1/catalog')->assertOk()->assertHeader('Cache-Control', 'no-store, private');
config(['responsecache.enabled' => true]);
$this->assertFalse(app(ArticleResponseCacheProfile::class)->enabled(Request::create('/api/editorial/v1/catalog')));
$this->assertTrue(app(ArticleResponseCacheProfile::class)->enabled(Request::create('/windows')));
file_put_contents($this->credential, json_encode(['project' => 'fna-v3', 'environment' => 'production', 'schema' => 'gemdocs.credential.v1', 'fields' => ['api_key' => $this->token]]));
$this->getJson('/api/editorial/v1/catalog')->assertStatus(503);
}
public function test_draft_is_private_and_idempotent_with_signed_real_template_preview(): void
{
$id = (string) Str::uuid();
$draft = $this->draft([$this->item()], $id);
$this->assertSame(0, DB::table('articles')->count());
$again = $this->draft([$this->item()], $id);
$this->assertSame($draft['hash'], $again['hash']);
$this->assertSame(1, DB::table('editorial_documents')->value('revision'));
$this->postJson('/api/editorial/v1/drafts', ['request_id' => $id, 'articles' => [$this->item('different')]])->assertConflict();
$preview = $this->get($draft['articles'][0]['preview_url'])->assertOk()->assertSee('草稿预览')->assertSee('教程标题')->assertDontSee('umami.gemcook.net');
$preview->assertHeader('X-Robots-Tag', 'noindex, nofollow');
$this->get('/api/editorial/v1/preview/'.$id.'/example')->assertForbidden();
$this->travel(31)->minutes();
$this->get($draft['articles'][0]['preview_url'])->assertForbidden();
$this->travelBack();
}
public function test_publish_is_atomic_and_replay_does_not_create_duplicates(): void
{
$a = $this->item('first');
$a['body'] .= "\n\n[下一步](/a/second.html#install)";
$d = $this->draft([$a, $this->item('second')]);
$id = (string) Str::uuid();
$r = $this->publish($d, $id)->assertOk()->assertJsonPath('status', 'published')->assertJsonPath('effects.search.status', 'done');
$this->assertSame(2, DB::table('articles')->count());
$this->assertSame(2, DB::table('article_tag')->count());
$this->publish($d, $id)->assertOk()->assertJsonPath('publication_id', $id);
$this->publish($d)->assertOk()->assertJsonPath('publication_id', $id);
$this->assertSame(2, DB::table('articles')->count());
$this->assertSame(1, DB::table('editorial_publications')->count());
$this->getJson('/api/editorial/v1/publications/'.$id)->assertOk()->assertJsonPath('status', 'published');
$this->getJson('/api/editorial/v1/articles/first')->assertOk()->assertJsonPath('published_revision', 1);
}
public function test_wrong_hash_stale_drafts_and_expected_revision_conflicts_are_rejected(): void
{
$d1 = $this->draft([$this->item()]);
$this->postJson('/api/editorial/v1/drafts/'.$d1['draft_id'].'/publish', ['request_id' => (string) Str::uuid(), 'expected_hash' => str_repeat('0', 64)])->assertConflict();
$this->postJson('/api/editorial/v1/drafts', ['request_id' => (string) Str::uuid(), 'articles' => [$this->item()]])->assertConflict();
$d2 = $this->draft([$this->item('example', 1)]);
$this->publish($d1)->assertConflict();
$this->publish($d2)->assertOk();
$this->assertSame(2, DB::table('editorial_documents')->value('published_revision'));
}
public function test_invalid_links_anchors_categories_and_html_roll_back_entire_batch(): void
{
foreach ([
['body', '[失效](/a/missing.html)'], ['body', '[失效](#absent)'],
['body', '[错误分类](/wrong/windows)'],
['body', '<img src="https://example.com/a.png">'], ['body', '<a href="https://example.com" onclick="alert(1)">x</a>'],
['category_slug', 'freenode'], ['tag_ids', [999]],
] as [$field, $value]) {
$bad = $this->item('bad');
$bad[$field] = $value;
$this->postJson('/api/editorial/v1/drafts', ['request_id' => (string) Str::uuid(), 'articles' => [$this->item('good'), $bad]])->assertUnprocessable();
$this->assertSame(0, DB::table('editorial_documents')->count());
$this->assertSame(0, DB::table('editorial_drafts')->count());
}
}
public function test_explicit_adoption_preserves_identity_counters_and_original_revision(): void
{
$id = DB::table('articles')->insertGetId(['sSlug' => 'existing', 'sTitle' => '旧文', 'sTitleSub' => '旧摘要', 'sContent' => '<p>旧正文</p>', 'iCategoryId' => 3, 'iLikeCount' => 19, 'iViews' => 30, 'created_at' => '2020-01-01 00:00:00']);
$item = $this->item('existing');
$this->postJson('/api/editorial/v1/drafts', ['request_id' => (string) Str::uuid(), 'articles' => [$item]])->assertConflict();
$item['adopt_article_id'] = $id;
$d = $this->draft([$item]);
$this->assertSame('旧文', DB::table('articles')->value('sTitle'));
$this->publish($d)->assertOk();
$this->assertSame($id, DB::table('editorial_documents')->value('article_id'));
$this->assertSame(19, DB::table('articles')->value('iLikeCount'));
$this->assertSame('2020-01-01 00:00:00', DB::table('articles')->value('created_at'));
$this->assertSame(2, DB::table('editorial_versions')->count());
$this->getJson('/api/editorial/v1/articles/existing/versions/0')->assertOk()->assertJsonPath('content.title', '旧文');
}
public function test_external_edit_and_removed_target_prevent_publish_without_partial_changes(): void
{
$first = $this->draft([$this->item()]);
$this->publish($first)->assertOk();
$d = $this->draft([$this->item('example', 1), $this->item('another')]);
DB::table('articles')->where('sSlug', 'example')->update(['sTitle' => '并行修改']);
$this->publish($d)->assertConflict();
$this->assertSame(1, DB::table('articles')->count());
$this->assertSame('并行修改', DB::table('articles')->value('sTitle'));
}
public function test_existing_incoming_anchor_cannot_be_broken(): void
{
$d = $this->draft([$this->item('target')]);
$this->publish($d)->assertOk();
DB::table('articles')->insert(['sSlug' => 'legacy', 'sTitle' => '旧入口', 'sContent' => '<a href="/a/target.html#install">安装</a>', 'iCategoryId' => 3]);
$changed = $this->item('target', 1);
$changed['body'] = '<p>删除原锚点</p>';
$next = $this->draft([$changed]);
$this->publish($next)->assertUnprocessable();
$this->assertStringContainsString('id="install"', DB::table('articles')->where('sSlug', 'target')->value('sContent'));
}
public function test_rollback_creates_new_reviewable_revision_and_preserves_url(): void
{
$one = $this->draft([$this->item()]);
$this->publish($one)->assertOk();
$changed = $this->item('example', 1);
$changed['title'] = '第二版';
$two = $this->draft([$changed]);
$this->publish($two)->assertOk();
$request = ['request_id' => (string) Str::uuid(), 'target_revision' => 1, 'expected_revision' => 2];
$rollback = $this->postJson('/api/editorial/v1/articles/example/rollback', $request)->assertCreated()->json();
$this->postJson('/api/editorial/v1/articles/example/rollback', $request)->assertCreated();
$this->assertSame('第二版', DB::table('articles')->value('sTitle'));
$this->publish($rollback)->assertOk()->assertJsonPath('articles.0.revision', 3);
$this->assertSame('教程标题', DB::table('articles')->value('sTitle'));
$this->assertSame('example', DB::table('articles')->value('sSlug'));
}
public function test_uploaded_images_stay_private_until_publication_and_code_newlines_survive(): void
{
// Valid raster fixture, no external fetch or image toolkit required.
$bytes = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQ0AAAAASUVORK5CYII=');
$file = UploadedFile::fake()->createWithContent('pixel.png', $bytes);
$asset = $this->post('/api/editorial/v1/assets', ['image' => $file])->assertCreated()->json();
$this->uploaded[] = $asset['sha256'];
$this->assertFileDoesNotExist(public_path('images/editorial/'.$asset['sha256'].'.png'));
$item = $this->item();
$item['body'] .= "\n\n![示例](".$asset['reference'].")\n\n```yaml\none: 1\ntwo: 2\n```";
$d = $this->draft([$item]);
$this->get($d['articles'][0]['preview_url'])->assertOk()->assertSee('preview-assets');
$this->publish($d)->assertOk();
$this->assertFileExists(public_path('images/editorial/'.$asset['sha256'].'.png'));
$body = DB::table('articles')->value('sContent');
$this->assertStringContainsString('&#10;', $body);
$this->assertStringNotContainsString("\n", $body);
$this->assertStringContainsString('/images/editorial/', $body);
}
public function test_search_failure_returns_published_receipt_and_can_be_retried(): void
{
config(['scout.driver' => 'unsupported']);
$d = $this->draft([$this->item()]);
$r = $this->publish($d)->assertOk()->assertJsonPath('status', 'published')->assertJsonPath('effects.search.status', 'pending')->json();
config(['scout.driver' => 'collection']);
$this->postJson('/api/editorial/v1/publications/'.$r['publication_id'].'/synchronize')->assertOk()->assertJsonPath('effects.search.status', 'done');
$this->assertSame(1, DB::table('articles')->count());
}
public function test_preview_privacy_even_when_response_cache_is_enabled(): void
{
config(['responsecache.enabled' => true]);
$d = $this->draft([$this->item()]);
$this->get($d['articles'][0]['preview_url'])->assertOk();
$this->withHeaders(['Authorization' => 'Bearer '.$this->token])->getJson('/api/editorial/v1/catalog')->assertOk();
$this->withHeaders(['Authorization' => 'Bearer invalid'])->getJson('/api/editorial/v1/catalog')->assertUnauthorized();
config(['editorial.enabled' => false]);
$this->get($d['articles'][0]['preview_url'])->assertStatus(503);
}
public function test_target_removed_after_preview_blocks_entire_publication(): void
{
$targetId = DB::table('articles')->insertGetId(['sSlug' => 'target', 'sTitle' => '引用目标', 'sContent' => '<h2 id="step">操作</h2>', 'iCategoryId' => 3]);
$first = $this->item('first');
$first['body'] .= "\n\n[步骤](/a/target.html#step)";
$d = $this->draft([$first, $this->item('second')]);
DB::table('articles')->where('id', $targetId)->delete();
$this->publish($d)->assertUnprocessable();
$this->assertSame(0, DB::table('articles')->count());
$this->assertSame(0, DB::table('editorial_publications')->count());
}
public function test_unmanaged_article_can_be_read_before_explicit_adoption_and_cover_can_be_cleared(): void
{
$id = DB::table('articles')->insertGetId(['sSlug' => 'existing', 'sTitle' => '旧文', 'sTitleSub' => '旧摘要', 'sContent' => '<p>正文</p>', 'sThumb' => 'https://example.com/old.png', 'bPageThumb' => true, 'iCategoryId' => 3]);
$this->getJson('/api/editorial/v1/source-articles/'.$id)->assertOk()->assertJsonPath('show_cover', true)->assertJsonPath('body_html', '<p>正文</p>');
$item = $this->item('existing');
$item['adopt_article_id'] = $id;
$item['cover'] = null;
$item['show_cover'] = false;
$d = $this->draft([$item]);
$this->publish($d)->assertOk();
$this->assertNull(DB::table('articles')->value('sThumb'));
$this->assertSame(0, DB::table('articles')->value('bPageThumb'));
$this->getJson('/api/editorial/v1/articles/existing/versions/0')->assertJsonPath('content.show_cover', true);
$resourceId = DB::table('articles')->insertGetId(['sSlug' => 'resource', 'sTitle' => '自动资源', 'sContent' => '<p>resource</p>', 'iCategoryId' => 1]);
$resource = $this->item('resource');
$resource['adopt_article_id'] = $resourceId;
$this->postJson('/api/editorial/v1/drafts', ['request_id' => (string) Str::uuid(), 'articles' => [$resource]])->assertUnprocessable();
$this->getJson('/api/editorial/v1/source-articles/'.$resourceId)->assertUnprocessable();
}
public function test_raw_script_markup_is_escaped_and_publish_request_id_cannot_be_rebound(): void
{
$item = $this->item();
$item['body'] .= "\n\n<script>alert(1)</script>";
$d = $this->draft([$item]);
$this->get($d['articles'][0]['preview_url'])->assertOk()->assertDontSee('<script>alert(1)</script>', false);
$request = (string) Str::uuid();
$this->publish($d, $request)->assertOk();
$next = $this->draft([$this->item('other')]);
$this->publish($next, $request)->assertConflict();
$this->assertSame(1, DB::table('articles')->count());
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace Tests\Feature;
use App\Services\IllustrationPool;
use PHPUnit\Framework\Attributes\PreserveGlobalState;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
use Tests\TestCase;
#[RunTestsInSeparateProcesses]
#[PreserveGlobalState(false)]
class IllustrationEnvironmentTest extends TestCase
{
public function test_production_requires_enabled_flag_and_registered_https_origin(): void
{
$this->app['env'] = 'production';
config(['illustrations.enabled' => true, 'illustrations.base_url' => 'http://resource.loc:8612']);
$this->assertFalse(IllustrationPool::enabled());
config(['illustrations.base_url' => 'https://resource-v2.gemcook.net']);
$this->assertTrue(IllustrationPool::enabled());
config(['illustrations.enabled' => false]);
$this->assertFalse(IllustrationPool::enabled());
}
public function test_local_and_unregistered_environments_cannot_use_production_origin(): void
{
$this->app['env'] = 'local';
config(['illustrations.enabled' => true, 'illustrations.base_url' => 'https://resource-v2.gemcook.net']);
$this->assertFalse(IllustrationPool::enabled());
config(['illustrations.base_url' => 'http://resource.loc:8612']);
$this->assertTrue(IllustrationPool::enabled());
$this->app['env'] = 'staging';
$this->assertFalse(IllustrationPool::enabled());
config(['illustrations.base_url' => 'https://test.clashfreenode.com/resource-v2']);
$this->assertTrue(IllustrationPool::enabled());
$this->app['env'] = 'production';
$this->assertFalse(IllustrationPool::enabled());
}
}

View File

@ -0,0 +1,80 @@
<?php
namespace Tests\Feature;
use App\Services\ArticlesService;
use App\Services\ArticleResponseCacheProfile;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use PHPUnit\Framework\Attributes\PreserveGlobalState;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
use Tests\TestCase;
#[RunTestsInSeparateProcesses]
#[PreserveGlobalState(false)]
class LatestFreeNodeHomeTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => ':memory:']);
DB::purge('sqlite');
Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->string('sSlug');
});
Schema::create('articles', function (Blueprint $table) {
$table->id();
$table->string('sSlug');
$table->integer('iCategoryId');
$table->integer('iStatus')->default(1);
$table->integer('iIsTop')->default(0);
$table->timestamps();
});
DB::table('categories')->insert([['id' => 1, 'sSlug' => 'freenode'], ['id' => 2, 'sSlug' => 'client']]);
$this->travelTo(\Carbon\Carbon::parse('2026-10-27 12:00:00', 'UTC'));
}
protected function tearDown(): void
{
$this->travelBack();
parent::tearDown();
}
public function test_latest_date_wins_over_pins_and_ignores_other_categories_drafts_and_future(): void
{
foreach ([
[1, 'old-pinned', 1, 1, 1, '2026-10-20 00:00:00'],
[2, 'latest-free', 1, 1, 0, '2026-10-21 00:00:00'],
[3, 'tutorial', 2, 1, 0, '2026-10-27 01:00:00'],
[4, 'draft', 1, 0, 0, '2026-10-27 02:00:00'],
[5, 'future', 1, 1, 0, '2026-10-28 00:00:00'],
] as [$id, $slug, $category, $status, $pin, $date]) {
DB::table('articles')->insert(['id' => $id, 'sSlug' => $slug, 'iCategoryId' => $category,
'iStatus' => $status, 'iIsTop' => $pin, 'created_at' => $date]);
}
$this->assertSame('latest-free', app(ArticlesService::class)->getLatestFreeNodeSlug());
DB::table('articles')->insert(['sSlug' => 'new-free', 'iCategoryId' => 1, 'created_at' => '2026-10-27 03:00:00']);
$this->assertSame('new-free', app(ArticlesService::class)->getLatestFreeNodeSlug());
}
public function test_empty_pool_and_equal_dates_are_deterministic(): void
{
$service = app(ArticlesService::class);
$this->assertNull($service->getLatestFreeNodeSlug());
foreach (['first', 'second'] as $slug) {
DB::table('articles')->insert(['sSlug' => $slug, 'iCategoryId' => 1, 'created_at' => '2026-10-21 00:00:00']);
}
$this->assertSame('second', $service->getLatestFreeNodeSlug());
}
public function test_home_bypasses_cache_and_categories_keep_existing_cache_policy(): void
{
config(['responsecache.enabled' => true]);
$profile = app(ArticleResponseCacheProfile::class);
$this->assertFalse($profile->enabled(Request::create('/')));
$this->assertTrue($profile->enabled(Request::create('/freenode')));
}
}