From 5e36c46b298673076eb92057388d9502fd7de9a5 Mon Sep 17 00:00:00 2001 From: hellcat Date: Sat, 24 Oct 2026 20:12:06 +0800 Subject: [PATCH] Accept immutable reviewed content from ATOM with idempotent site publication --- codex/output/content-center/README.md | 5 + laravel/.env.example | 6 + laravel/app/ContentCenter/ArticleArtifact.php | 35 ++++++ .../app/ContentCenter/ContentAuthenticate.php | 25 ++++ .../app/ContentCenter/ContentController.php | 22 ++++ laravel/app/ContentCenter/ContentHttp.php | 36 ++++++ .../app/ContentCenter/ProtectLegacyImport.php | 16 +++ laravel/app/ContentCenter/Publication.php | 104 +++++++++++++++++ laravel/bootstrap/app.php | 18 +-- laravel/config/content_center.php | 17 +++ laravel/content-center/routes.php | 10 ++ laravel/tests/Feature/ContentCenterTest.php | 109 ++++++++++++++++++ 12 files changed, 396 insertions(+), 7 deletions(-) create mode 100644 codex/output/content-center/README.md create mode 100644 laravel/app/ContentCenter/ArticleArtifact.php create mode 100644 laravel/app/ContentCenter/ContentAuthenticate.php create mode 100644 laravel/app/ContentCenter/ContentController.php create mode 100644 laravel/app/ContentCenter/ContentHttp.php create mode 100644 laravel/app/ContentCenter/ProtectLegacyImport.php create mode 100644 laravel/app/ContentCenter/Publication.php create mode 100644 laravel/config/content_center.php create mode 100644 laravel/content-center/routes.php create mode 100644 laravel/tests/Feature/ContentCenterTest.php diff --git a/codex/output/content-center/README.md b/codex/output/content-center/README.md new file mode 100644 index 00000000..bd90c7ab --- /dev/null +++ b/codex/output/content-center/README.md @@ -0,0 +1,5 @@ +# TTC 内容中心接收端 + +接口和跨项目契约唯一正文:[ATOM内容中心](/Users/hellcat/Documents/GemCodes/atom/codex/output/docs/content-center.md)。 +本项目新增 laravel/app/ContentCenter、content-center/routes.php,由bootstrap/app.php独立加载;不改原文章控制器、商家规则、旧后台或并行导航/样式。预览不写文章,发布须匹配人工批准的最终稿哈希,并复用既有Ranking发布器。启用新流程时旧文章接收API410,其他站点不变。 +测试使用独立临时站点及模拟构建器,不发布真实文章;本次没有人工批准生产稿件,AI服务尚未配置。 diff --git a/laravel/.env.example b/laravel/.env.example index 6fb3de65..64779f74 100644 --- a/laravel/.env.example +++ b/laravel/.env.example @@ -64,3 +64,9 @@ AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" + +# Content center service credentials, never browser login credentials. +CONTENT_ENVIRONMENT=local +CONTENT_CENTER_ENABLED=false +CONTENT_ATOM_URL= +CONTENT_ATOM_KEY= diff --git a/laravel/app/ContentCenter/ArticleArtifact.php b/laravel/app/ContentCenter/ArticleArtifact.php new file mode 100644 index 00000000..cf38fe87 --- /dev/null +++ b/laravel/app/ContentCenter/ArticleArtifact.php @@ -0,0 +1,35 @@ + 'required|string|max:160', 'revision' => 'required|string|size:64', 'generation' => 'required|integer|min:1', 'title' => 'required|string|max:200', 'text' => 'present|string|max:100000', 'sent_at' => 'required|date', 'images' => 'present|array|max:30', 'images.*.sha256' => 'required|regex:/^[a-f0-9]{64}$/D', 'images.*.mime' => 'required|in:image/png,image/jpeg,image/webp,image/gif', 'images.*.bytes' => 'required|integer|min:1|max:20971520'])->validate(); + $code = 'telefox-'.substr(hash('sha256', $data['source_key']), 0, 24); + $body = "
\n".implode("\n", array_map(fn ($s) => '

'.htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').'

', explode("\n", $data['text'])))."\n
\n\n"; + foreach ($data['images'] as $image) { + $body .= '![频道图片](/content-assets/'.$image['sha256'].self::extension($image['mime']).")\n\n"; + } + $meta = ['title' => $data['title'], 'createTime' => $data['sent_at'], 'permalink' => '/jccp/'.$code.'/', 'articleCode' => $code, 'tags' => ['频道导入'], 'sourceKey' => $data['source_key']]; + $document = ['meta' => $meta, 'body' => $body]; + if (class_exists(\App\Services\Ranking\ArticleDocument::class)) { + $document = \App\Services\Ranking\ArticleDocument::enrich($document); + } + + return ['source_key' => $data['source_key'], 'revision' => $data['revision'], 'generation' => $data['generation'], 'article_code' => $code, 'relative_path' => 'jccp/'.$code.'.md', 'permalink' => $meta['permalink'], 'markdown' => "---\n".Yaml::dump($document['meta'], 6, 2)."---\n".$document['body'], 'images' => $data['images']]; + } + + public static function extension(string $mime): string + { + return ['image/png' => '.png', 'image/jpeg' => '.jpg', 'image/webp' => '.webp', 'image/gif' => '.gif'][$mime]; + } + + public static function hash(array $artifact): string + { + return hash('sha256', json_encode($artifact, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + } +} diff --git a/laravel/app/ContentCenter/ContentAuthenticate.php b/laravel/app/ContentCenter/ContentAuthenticate.php new file mode 100644 index 00000000..deb0a10a --- /dev/null +++ b/laravel/app/ContentCenter/ContentAuthenticate.php @@ -0,0 +1,25 @@ +header('X-Content-Time'); + $nonce = (string) $r->header('X-Content-Nonce'); + abort_unless(ctype_digit($time) && abs(time() - (int) $time) <= 300 && preg_match('/^[a-f0-9]{32}$/D', $nonce), 401); + abort_unless($r->header('X-Content-Environment') === config('content_center.environment'), 401); + $data = $time."\n".$nonce."\n".$r->method()."\n".$r->getRequestUri()."\n".hash('sha256', $r->getContent()); + abort_unless(hash_equals(hash_hmac('sha256', $data, $key), (string) $r->header('X-Content-Signature')), 401); + abort_unless(Cache::add('content-nonce:'.$role.':'.$nonce, true, 600), 409); + + return $next($r); + } +} diff --git a/laravel/app/ContentCenter/ContentController.php b/laravel/app/ContentCenter/ContentController.php new file mode 100644 index 00000000..c2fcc326 --- /dev/null +++ b/laravel/app/ContentCenter/ContentController.php @@ -0,0 +1,22 @@ +build($request->all()); + + return response()->json(['environment' => config('content_center.environment'), 'artifact' => $artifact, 'artifact_hash' => ArticleArtifact::hash($artifact)]); + } + + public function publish(Request $request) + { + abort_unless(config('content_center.enabled'), 503); + + return response()->json(['environment' => config('content_center.environment'), 'receipt' => app(Publication::class)->publish($request->all())]); + } +} diff --git a/laravel/app/ContentCenter/ContentHttp.php b/laravel/app/ContentCenter/ContentHttp.php new file mode 100644 index 00000000..dc3f95d9 --- /dev/null +++ b/laravel/app/ContentCenter/ContentHttp.php @@ -0,0 +1,36 @@ +connectTimeout(10)->withoutRedirecting()->withOptions(['progress' => static function ($total, $received) { + if ($total > 26214400 || $received > 26214400) { + throw new RuntimeException('响应过大。'); + } + }])->withHeaders(['X-Content-Time' => $time, 'X-Content-Nonce' => $nonce, 'X-Content-Environment' => config('content_center.environment'), 'X-Content-Signature' => $signature, 'Accept' => 'application/json'])->withBody($body, 'application/json')->send($method, rtrim($base, '/').$path); + if (! $response->successful()) { + throw new RuntimeException('内容服务请求未成功。'); + } + + return $response; + } catch (\Throwable) { + throw new RuntimeException('内容服务暂不可用,请按同一发布编号重试。'); + } + } +} diff --git a/laravel/app/ContentCenter/ProtectLegacyImport.php b/laravel/app/ContentCenter/ProtectLegacyImport.php new file mode 100644 index 00000000..7143349b --- /dev/null +++ b/laravel/app/ContentCenter/ProtectLegacyImport.php @@ -0,0 +1,16 @@ +is('api/article/receive/schema'), 410, 'TTC文章接收已迁至ATOM内容审核流程。'); + + return $next($request); + } +} diff --git a/laravel/app/ContentCenter/Publication.php b/laravel/app/ContentCenter/Publication.php new file mode 100644 index 00000000..8c4a1ac8 --- /dev/null +++ b/laravel/app/ContentCenter/Publication.php @@ -0,0 +1,104 @@ + 'required|uuid', 'approved_hash' => 'required|regex:/^[a-f0-9]{64}$/D', 'input' => 'required|array'])->validate(); + $artifact = app(ArticleArtifact::class)->build($data['input']); + $hash = ArticleArtifact::hash($artifact); + abort_unless(hash_equals($hash, $data['approved_hash']), 409, '待发稿已变化,必须重新审核。'); + $publisher = new SitePublisher(config('content_center.site_root'), config('content_center.node')); + + return $publisher->locked(function () use ($data, $artifact, $hash, $publisher) { + $directory = config('content_center.site_root').'/laravel/storage/app/private/content-center/publications'; + if (! is_dir($directory)) { + mkdir($directory, 0700, true); + } + $receipt = $directory.'/'.hash('sha256', $artifact['source_key']).'.json'; + $previous = is_file($receipt) ? json_decode(file_get_contents($receipt), true, flags: JSON_THROW_ON_ERROR) : null; + $byApproval = $directory.'/'.$data['approval_id'].'.json'; + $done = is_file($byApproval) ? json_decode(file_get_contents($byApproval), true, flags: JSON_THROW_ON_ERROR) : null; + if ($done) { + abort_unless(hash_equals($done['artifact_hash'], $hash), 409); + if ($done['status'] === 'published') { + return $done; + } + } + abort_if($previous && $previous['generation'] > $artifact['generation'], 409, '较新版本已发布或正在发布。'); + if ($previous && $previous['approval_id'] === $data['approval_id']) { + abort_unless(hash_equals($previous['artifact_hash'], $hash), 409); + if ($previous['status'] === 'published') { + return $previous; + } + } + $path = $publisher->root.'/vuepress/docs/'.$artifact['relative_path']; + if (is_file($path) && (! $previous || $previous['source_key'] !== $artifact['source_key'])) { + throw new RuntimeException('文章编号冲突,未覆盖现有文章。'); + } + $images = $publisher->root.'/vuepress/docs/.vuepress/public/content-assets'; + if (! is_dir($images)) { + mkdir($images, 0755, true); + } + foreach ($artifact['images'] as $image) { + $file = $images.'/'.$image['sha256'].ArticleArtifact::extension($image['mime']); + if (is_file($file) && hash_file('sha256', $file) === $image['sha256']) { + continue; + } + $response = app(ContentHttp::class)->request(config('content_center.atom_url'), config('content_center.atom_key'), 'GET', '/api/content/v1/assets/'.$image['sha256']); + $bytes = $response->body(); + if (strlen($bytes) !== $image['bytes'] || strlen($bytes) > config('content_center.max_image_bytes') || hash('sha256', $bytes) !== $image['sha256'] || ! @getimagesizefromstring($bytes) || (new \finfo(FILEINFO_MIME_TYPE))->buffer($bytes) !== $image['mime']) { + throw new RuntimeException('图片校验未通过。'); + } + $this->atomic($file, $bytes, 0644); + } + $record = ['source_key' => $artifact['source_key'], 'approval_id' => $data['approval_id'], 'artifact_hash' => $hash, 'generation' => $artifact['generation'], 'permalink' => $artifact['permalink'], 'status' => 'building']; + $this->atomic($receipt, json_encode($record, JSON_THROW_ON_ERROR), 0600); + $old = is_file($path) ? file_get_contents($path) : null; + try { + $this->atomic($path, $artifact['markdown'], 0644); + (new WeeklyRanking($publisher))->renderCurrent(); + $publisher->build(); + } catch (\Throwable) { + if ($old === null) { + @unlink($path); + } else { + $this->atomic($path, $old, 0644); + } + throw new RuntimeException('网站构建失败,原文章已恢复;按同一发布编号重试。'); + } + $record['status'] = 'published'; + $record['published_at'] = now()->toIso8601String(); + $this->atomic($receipt, json_encode($record, JSON_THROW_ON_ERROR), 0600); + $this->atomic($byApproval, json_encode($record, JSON_THROW_ON_ERROR), 0600); + + return $record; + }); + } + + private function atomic(string $path, string $data, int $mode): void + { + if (! is_dir(dirname($path))) { + mkdir(dirname($path), 0755, true); + } + $tmp = tempnam(dirname($path), '.content-'); + try { + if (file_put_contents($tmp, $data) !== strlen($data)) { + throw new RuntimeException('写入失败。'); + }chmod($tmp, $mode); + if (! rename($tmp, $path)) { + throw new RuntimeException('替换失败。'); + } + } finally { + if (is_file($tmp)) { + unlink($tmp); + } + } + } +} diff --git a/laravel/bootstrap/app.php b/laravel/bootstrap/app.php index 94cc5421..276dec1b 100755 --- a/laravel/bootstrap/app.php +++ b/laravel/bootstrap/app.php @@ -9,15 +9,19 @@ return Application::configure(basePath: dirname(__DIR__)) web: __DIR__.'/../routes/web.php', commands: __DIR__.'/../routes/console.php', health: '/up', + then: function () { + \Illuminate\Support\Facades\Route::middleware('api')->group(base_path('content-center/routes.php')); + }, ) ->withMiddleware(function (Middleware $middleware) { - $middleware->alias([ - //'test' => \App\Http\Middleware\Test::class, - ]); - $middleware->validateCsrfTokens(except: [ - 'api/*', - 'cmd/*', - ]); + $middleware->append(\App\ContentCenter\ProtectLegacyImport::class); + $middleware->alias([ + //'test' => \App\Http\Middleware\Test::class, + ]); + $middleware->validateCsrfTokens(except: [ + 'api/*', + 'cmd/*', + ]); }) ->withExceptions(function (Exceptions $exceptions) { // diff --git a/laravel/config/content_center.php b/laravel/config/content_center.php new file mode 100644 index 00000000..473ff16d --- /dev/null +++ b/laravel/config/content_center.php @@ -0,0 +1,17 @@ + env('CONTENT_ENVIRONMENT', 'local'), + 'enabled' => (bool) env('CONTENT_CENTER_ENABLED', false), + 'telefox_url' => env('CONTENT_TELEFOX_URL', ''), + 'telefox_token' => env('CONTENT_TELEFOX_TOKEN', ''), + 'ttc_url' => env('CONTENT_TTC_URL', ''), + 'ttc_key' => env('CONTENT_TTC_KEY', ''), + 'ai_key' => env('CONTENT_AI_KEY', ''), + 'atom_url' => env('CONTENT_ATOM_URL', ''), + 'atom_key' => env('CONTENT_ATOM_KEY', ''), + 'max_image_bytes' => 20971520, + 'asset_directory' => storage_path('app/private/content-center/assets'), + 'site_root' => dirname(base_path()), + 'node' => env('vue_path_node', 'node'), +]; diff --git a/laravel/content-center/routes.php b/laravel/content-center/routes.php new file mode 100644 index 00000000..306192bf --- /dev/null +++ b/laravel/content-center/routes.php @@ -0,0 +1,10 @@ +middleware([ContentAuthenticate::class.':atom', 'throttle:30,1'])->group(function () { + Route::post('/preview', [ContentController::class, 'preview']); + Route::post('/publish', [ContentController::class, 'publish']); +}); diff --git a/laravel/tests/Feature/ContentCenterTest.php b/laravel/tests/Feature/ContentCenterTest.php new file mode 100644 index 00000000..d2ac59bd --- /dev/null +++ b/laravel/tests/Feature/ContentCenterTest.php @@ -0,0 +1,109 @@ +make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap(); + + return $app; + } + + private string $root; + + protected function setUp(): void + { + parent::setUp(); + \Illuminate\Support\Facades\URL::forceRootUrl('http://ttc.loc'); + Http::preventStrayRequests(); + $this->root = sys_get_temp_dir().'/ttc-content-test-'.Str::uuid(); + File::makeDirectory($this->root.'/vuepress/node_modules/.bin', 0755, true); + File::makeDirectory($this->root.'/vuepress/docs/jccp', 0755, true); + File::makeDirectory($this->root.'/laravel/storage/app/weekly-ranking', 0755, true); + file_put_contents($this->root.'/vuepress/node_modules/.bin/vuepress', ' 'local', 'content_center.enabled' => true, 'content_center.atom_key' => str_repeat('k', 40), 'content_center.atom_url' => 'http://atom.loc', 'content_center.site_root' => $this->root, 'content_center.node' => PHP_BINARY, 'cache.default' => 'array']); + } + + protected function tearDown(): void + { + File::deleteDirectory($this->root); + parent::tearDown(); + } + + private function input(int $generation = 1): array + { + return ['source_key' => 'telegram:-1001234567890:album:99', 'revision' => str_repeat('a', 64), 'generation' => $generation, 'title' => '测评标题', 'text' => ' {{ danger }}', 'sent_at' => '2026-10-24 08:01:01', 'images' => []]; + } + + private function signed(string $path, array $data, ?string $nonce = null) + { + $body = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $time = (string) time(); + $nonce ??= bin2hex(random_bytes(16)); + $sig = hash_hmac('sha256', $time."\n".$nonce."\nPOST\n".$path."\n".hash('sha256', $body), str_repeat('k', 40)); + + return $this->call('POST', $path, [], [], [], ['CONTENT_TYPE' => 'application/json', 'HTTP_ACCEPT' => 'application/json', 'HTTP_X_CONTENT_TIME' => $time, 'HTTP_X_CONTENT_NONCE' => $nonce, 'HTTP_X_CONTENT_ENVIRONMENT' => 'local', 'HTTP_X_CONTENT_SIGNATURE' => $sig], $body); + } + + public function test_preview_authenticates_and_does_not_write_or_build(): void + { + $this->postJson('/api/content/v1/preview', $this->input())->assertUnauthorized(); + $nonce = bin2hex(random_bytes(16)); + $response = $this->signed('/api/content/v1/preview', $this->input(), $nonce)->assertOk(); + $markdown = $response->json('artifact.markdown'); + $this->assertStringContainsString('<script>', $markdown); + $this->assertStringContainsString('
', $markdown); + $this->assertSame([], File::files($this->root.'/vuepress/docs/jccp')); + $this->signed('/api/content/v1/preview', $this->input(), $nonce)->assertStatus(409); + } + + public function test_legacy_receive_cannot_bypass_content_review(): void + { + $this->postJson('/api/article/receive/schema', ['cmd' => 'new'])->assertStatus(410); + $this->assertSame([], File::files($this->root.'/vuepress/docs/jccp')); + } + + public function test_wrong_hash_cannot_publish(): void + { + $this->signed('/api/content/v1/publish', ['approval_id' => (string) Str::uuid(), 'approved_hash' => str_repeat('0', 64), 'input' => $this->input()])->assertStatus(409); + $this->assertSame([], File::files($this->root.'/vuepress/docs/jccp')); + } + + public function test_approved_publication_is_idempotent_and_old_generation_cannot_overwrite_new(): void + { + $input = $this->input(20); + $artifact = app(ArticleArtifact::class)->build($input); + $payload = ['approval_id' => (string) Str::uuid(), 'approved_hash' => ArticleArtifact::hash($artifact), 'input' => $input]; + $one = $this->signed('/api/content/v1/publish', $payload)->assertOk()->assertJsonPath('receipt.status', 'published'); + $this->signed('/api/content/v1/publish', $payload)->assertOk()->assertJsonPath('receipt.approval_id', $payload['approval_id']); + $this->assertSame(1, count(File::files($this->root.'/vuepress/docs/jccp'))); + $old = $this->input(10); + $this->signed('/api/content/v1/publish', ['approval_id' => (string) Str::uuid(), 'approved_hash' => ArticleArtifact::hash(app(ArticleArtifact::class)->build($old)), 'input' => $old])->assertStatus(409); + } + + public function test_failed_build_restores_source_and_preserves_previous_dist(): void + { + File::makeDirectory($this->root.'/vuepress/docs/.vuepress/dist', 0755, true); + file_put_contents($this->root.'/vuepress/docs/.vuepress/dist/index.html', 'previous'); + file_put_contents($this->root.'/vuepress/node_modules/.bin/vuepress', 'input(); + $artifact = app(ArticleArtifact::class)->build($input); + try { + app(Publication::class)->publish(['approval_id' => (string) Str::uuid(), 'approved_hash' => ArticleArtifact::hash($artifact), 'input' => $input]); + $this->fail('Expected build failure'); + } catch (\RuntimeException) { + $this->assertFileDoesNotExist($this->root.'/vuepress/docs/'.$artifact['relative_path']); + $this->assertSame('previous', file_get_contents($this->root.'/vuepress/docs/.vuepress/dist/index.html')); + } + } +}