Prepare isolated FNA V3 preview without new advertising
27
app/Console/Commands/EditorialReindex.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
61
app/Console/Commands/EditorialRequest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
46
app/Console/Commands/EditorialSetup.php
Normal 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;
|
||||
}
|
||||
}
|
||||
148
app/Http/Controllers/Api/Editorial/EditorialController.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
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);
|
||||
$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 (!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";
|
||||
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);
|
||||
$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 (!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";
|
||||
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);
|
||||
$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 (!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(
|
||||
|
||||
@ -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;
|
||||
|
||||
43
app/Http/Middleware/EditorialAuthentication.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
15
app/Services/ArticleResponseCacheProfile.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
102
app/Services/Editorial/ContentRenderer.php
Normal 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", ' ', $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);
|
||||
}
|
||||
}
|
||||
80
app/Services/Editorial/PublicationEffects.php
Normal 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)];
|
||||
}
|
||||
}
|
||||
297
app/Services/Editorial/Publisher.php
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
108
app/Services/IllustrationPool.php
Normal 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
@ -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
@ -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'],
|
||||
];
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
1
public/build/assets/app-BCx10ygF.css
Normal file
1
public/build/assets/app-Ba5joxXD.css
Normal 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}
|
||||
1
public/build/assets/app-D96dFcq0.css
Normal 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}
|
||||
9
public/build/assets/app-DDSp2ths.js
Normal file
BIN
public/build/assets/fa-brands-400-BfBXV7Mm.woff2
Normal file
BIN
public/build/assets/fa-regular-400-BVHPE7da.woff2
Normal file
BIN
public/build/assets/fa-solid-900-8GirhLYJ.woff2
Normal file
BIN
public/build/assets/fa-v4compatibility-DnhYSyY-.woff2
Normal file
BIN
public/build/assets/iconfont-B3tZHDj4.ttf
Normal file
BIN
public/build/assets/iconfont-D7mxe_Lh.woff
Normal file
49
public/build/manifest.json
Normal 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
@ -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;
|
||||
}
|
||||
1
public/images/tutorials/clash-meta-android-cover.svg
Normal 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 |
1
public/images/tutorials/dns-cover.svg
Normal 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 |
1
public/images/tutorials/mac-client-guide-cover.svg
Normal 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 |
1
public/images/tutorials/migration-cover.svg
Normal 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 |
1
public/images/tutorials/quantumult-x-ios-cover.svg
Normal 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 |
1
public/images/tutorials/system-proxy-tun-cover.svg
Normal 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 |
1
public/images/tutorials/v2rayng-cover.svg
Normal 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 |
BIN
public/images/tutorials/verge-import.png
Normal file
|
After Width: | Height: | Size: 91 KiB |
1
public/images/tutorials/verge-linux-cover.svg
Normal 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 |
1
public/images/tutorials/verge-mac-cover.svg
Normal 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 |
BIN
public/images/tutorials/verge-system-proxy.png
Normal file
|
After Width: | Height: | Size: 156 KiB |
1
public/images/tutorials/windows-cover.svg
Normal 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 |
@ -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
|
||||
|
||||
|
||||
@ -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' : '' }}">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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');
|
||||
|
||||
335
tests/Feature/EditorialApiTest.php
Normal 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\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(' ', $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());
|
||||
}
|
||||
}
|
||||
39
tests/Feature/IllustrationEnvironmentTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
80
tests/Feature/LatestFreeNodeHomeTest.php
Normal 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')));
|
||||
}
|
||||
}
|
||||