diff --git a/AGENTS.md b/AGENTS.md index 580db8d0..f5f9eef3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,11 +1,37 @@ -# FNA_V3维护入口 +# fnb 项目维护入口 + +项目工作先读 GemDocs AI版项目总览、维护协议及 `/Users/hellcat/Documents/GemDocs/AI版/10-项目/fnb.md`。线上事务优先经白色机 SSH;网页与订阅 HTTP 访问须符合 GemDocs 的反代规则。 + +订阅检测已独立为 SubCheck,入口见 [SubCheck](/Users/hellcat/Documents/GemCodes/subcheck/AGENTS.md);cfn来源适配与调查见 [CFN.md](/Users/hellcat/Documents/GemCodes/subcheck/docs/CFN.md)。fnb不再保存检测实现。代码、凭据、业务数据及并行修改按当前任务授权处理。 + +## FNA_V3 本地副本维护 + +本目录为独立 FNA_V3 副本,身份与环境以 GemDocs AI版 `10-项目/fna_v3.md` 为准;上述 FN_B 入口保留来源背景,不代表授权修改 FN_B。文章广告实现、配置与验收见 [广告维护说明](docs/maintenance/ADVERTISING.md)。 + +SEO 的只读审计、优化顺序与逐项验收统一维护在 [SEO 优化建议与执行清单](docs/maintenance/SEO_PLAN.md),2026-10-22 建立。清单中的建议不代表已实施,后续按用户指定项目推进并更新本文件所链接的唯一正文。 + +## CFN系绘图接手 + +2026-10-23用户明确将此前由张天承担的绘图工作交给“画师【CFN系】”(personnel-position:cd029d9db8454ec68172b221ff35e6f6)。FNA_V3主程继续开发工作,后续绘图由该画师岗位承接,避免重复制作。本记录仅转交绘图范围,不改变开发或上线权限;未向原聊天发送消息。 + +风格已从原聊天最近作品及本地成图核实:现代主义剪纸拼贴、哑光色块、轻微丝网印刷颗粒、成熟动物比例、非对称构图和大块留白。参考文件在本项目output/imagegen/:freenode-dog-dalmatian-20261023.png、freenode-dog-dachshund-20261023.png、freenode-dogs-dance-20261023.png;同名txt保存完整提示词。延续视觉语言,每张更换主题或构图,不照搬布局。 + +接手首张试画及提示词:/Users/hellcat/Documents/Codex/2026-10-23/cfn/outputs/cfn-tuxedo-cat-20261023.png 和同名txt。用户已认可风格并继续扩充;2026-10-26按授权与后续猫狗作品一起归档到Resource V2,并接入本地FNA_V3,未发布到线上。归档和栏目配图的唯一维护入口见[本地插画资源与配图](docs/maintenance/ILLUSTRATIONS.md)。 + +教程栏目现为“入门教程”(/client)和“综合教程”(/advanced,原进阶教程)。栏目改名、首篇重写、DNS进阶文章及内容回退见[教程栏目维护](docs/maintenance/TUTORIALS.md)。原七篇旧教程均已处理:迁移文归进阶,其余按Android、iOS、Mac和Linux重写;另有Windows纯入门与DNS进阶文章。正文、图解和关联已本地验收,本地内容不代表线上发布。 ## FNA 静态配图副本 -2026-10-30起Resource保留原稿、图池和固定分配,FNA保存按内容去重的公开展示副本,由本站Nginx直接提供。同步、创建时校验、撤回、缓存与部署验收唯一正文见[静态配图副本](docs/maintenance/LOCAL_ILLUSTRATIONS.md)。用户访问图片不调用Resource、不执行PHP修复。 +2026-10-30起Resource保留原稿、图池和固定分配,FNA保存按内容去重的公开展示副本,由本站Nginx直接提供。同步、创建时校验、撤回、缓存与部署验收唯一正文见[静态配图副本](docs/maintenance/LOCAL_ILLUSTRATIONS.md)。用户访问图片不调用Resource、不执行PHP修复。2026-10-31明确源端删除/停用不影响FNA独立副本或已有文章关联,修复与恢复原图记录见[独立副本保留修复](docs/maintenance/INDEPENDENT_ILLUSTRATIONS_FIX_2026-10-31.md)。 ## AI 文章写作(必读) -文章写作和发布先读docs/content/WRITING.md及docs/maintenance/EDITORIAL_API.md。项目身份和访问边界先读GemDocs AI版项目总览、维护协议及10-项目/fna_v3.md。 +后续为 FNA_V3 选题、写作、重写、调整分类或撤下文章时,必须先读[AI写作与文章关联索引](docs/content/WRITING.md)。它是文章用途、稳定地址和正文关系的唯一登记入口。公开文章禁止出现“核对范围”“未实测”等内部核验/验收说明,具体禁止项与发布前检查按该写作规范执行;相关事实只留项目维护记录。写作时复用相关的下载、入门、进阶和排错内容,自然设置内链,并检查相关旧文是否需要补入口;完成后同步正文源、运行数据库和该索引,验收真实目标与锚点。未创建的文章不能编造链接,本地生效不能写成已上线;不自动开展全站重写或发布。 + +## 独立文章发布 API + +用户已明确文章直接发布到 FNA_V3,不经过 ATOM。实现、调用契约、凭据边界及验收见[独立文章 API](docs/maintenance/EDITORIAL_API.md)。后续写稿使用本站草稿→签名预览→按hash发布→回执核对流程;相关旧文内链修改可同批提交。不要再用临时SQL脚本发布普通编辑文章。旧文首次接管必须显式指定原文章ID并保留URL;发布结果未知先查原请求UUID。代码已于2026-10-29随正式切换部署,生产临时编辑密钥在教程迁移后删除、接口默认关闭;后续按明确写作/发布任务临时启用并在完成后关闭,不扩大线上访问或发布范围。正式部署、备份、Git SHA及验收统一见[发布记录](docs/maintenance/RELEASE_PREFLIGHT_2026-10-28.md#正式切换2026-10-29)。 插画完成后按[自动交付](docs/maintenance/ILLUSTRATION_DELIVERY.md)提交批准批次;不要再手工上传发布/编辑图池或把保存草稿当作上线完成。 + +周期文章按[固定发布封装](docs/maintenance/EDITORIAL_RELEASE.md)执行:本机SSH必须经白色机到正式业务服务器,使用服务器进程内API,不临时开放公网接口。每周三篇、每天15:00检查、错过窗口回补及文章随机展示时间见[周期写作](docs/maintenance/WEEKLY_TUTORIALS.md);这一流程替代周期写作中的手动开关API步骤,不扩大其他实例或全站改版权限。 diff --git a/app/Console/Commands/EditorialRelease.php b/app/Console/Commands/EditorialRelease.php new file mode 100644 index 00000000..ca26f05c --- /dev/null +++ b/app/Console/Commands/EditorialRelease.php @@ -0,0 +1,40 @@ +environment('testing')) { + abort_unless(app()->environment('production') + && realpath(base_path()) === '/www/wwwroot/clashfreenode-new.com' + && rtrim(config('editorial.base_url'), '/') === 'https://clashfreenode.com' + && function_exists('posix_geteuid') && posix_getpwuid(posix_geteuid())['name'] === 'nginx', 403, 'Server binding mismatch'); + } + $raw = stream_get_contents(STDIN, 48 * 1024 * 1024 + 1); + abort_unless(strlen($raw) <= 48 * 1024 * 1024, 413); + $input = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); + abort_unless(is_array($input), 422); + $result = app(ReleaseRunner::class)->run($input); + $this->line(json_encode(['ok' => true, 'data' => $result], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)); + + return ($result['phase'] ?? '') === 'published_pending' ? 2 : 0; + } catch (\Throwable $e) { + $status = $e instanceof HttpExceptionInterface ? $e->getStatusCode() : 500; + $this->line(json_encode(['ok' => false, 'status' => $status, 'error' => 'Release operation failed; use the same release_id to inspect or resume. No credential details are exposed.'])); + + return 1; + } + } +} diff --git a/app/Http/Controllers/Api/Editorial/EditorialController.php b/app/Http/Controllers/Api/Editorial/EditorialController.php index 189b0e37..91fab551 100644 --- a/app/Http/Controllers/Api/Editorial/EditorialController.php +++ b/app/Http/Controllers/Api/Editorial/EditorialController.php @@ -32,6 +32,7 @@ public function source(int $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'), + 'created_at' => $a->created_at, 'display_published_at' => $a->sDisplayPublishedAt, 'cover' => $a->sThumb, 'show_cover' => (bool) $a->bPageThumb, 'like_growth' => ['seven_day_remaining' => (int) $a->i7like, 'yearly' => (int) $a->i365like, 'one_year_target' => (int) $a->iYearLikeTarget, 'one_year_added' => (int) $a->iYearLikeAdded, @@ -140,7 +141,7 @@ public function preview(string $draft, string $key, Publisher $p) } $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]); + 'created_at' => now(), 'sDisplayPublishedAt' => now()->subSeconds($item['display_offset_seconds'] ?? 0), '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()); diff --git a/app/Http/Middleware/EditorialAuthentication.php b/app/Http/Middleware/EditorialAuthentication.php index c88c3b28..1ae75c57 100644 --- a/app/Http/Middleware/EditorialAuthentication.php +++ b/app/Http/Middleware/EditorialAuthentication.php @@ -15,7 +15,9 @@ public function handle(Request $request, Closure $next) 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; + $process = PHP_SAPI === 'cli' ? config('editorial.process_credential') : null; + $credential = $process ?? (is_file($path) ? json_decode(file_get_contents($path), true) : null); + abort_if($process && (! is_int($process['expires_at'] ?? null) || $process['expires_at'] <= time()), 401, 'Process session expired'); abort_unless(is_array($credential) && ($credential['environment'] ?? null) === app()->environment() && ($credential['project'] ?? null) === 'fna-v3' diff --git a/app/Models/Articles.php b/app/Models/Articles.php index 1c940349..dd9486a4 100755 --- a/app/Models/Articles.php +++ b/app/Models/Articles.php @@ -38,7 +38,7 @@ protected function sCreatedAtDiff(): Attribute { return Attribute::get(function () { // 1. 别直接用 $this->created_at,防止它还没被实例化成 Carbon 对象 - $oDate = \Carbon\Carbon::parse($this->created_at); + $oDate = \Carbon\Carbon::parse($this->attributes['sDisplayPublishedAt'] ?? $this->created_at); $oNow = \Carbon\Carbon::now(); // 2. 计算小时差(强制转整数) diff --git a/app/Services/Editorial/ProcessApi.php b/app/Services/Editorial/ProcessApi.php new file mode 100644 index 00000000..f4ae4cc8 --- /dev/null +++ b/app/Services/Editorial/ProcessApi.php @@ -0,0 +1,66 @@ +credential = ['schema' => 'gemdocs.credential.v1', 'project' => 'fna-v3', + 'environment' => app()->environment(), 'base_url' => config('editorial.base_url'), + 'expires_at' => time() + 600, 'fields' => ['api_key' => 'fna_ed_'.bin2hex(random_bytes(32))]]; + } + + public function call(string $method, string $path, array $data = [], ?UploadedFile $image = null): array + { + abort_unless(in_array($method, ['GET', 'POST']) && preg_match('~\A[a-zA-Z0-9_/-]+\z~', $path), 422); + $response = $this->dispatch($method, '/api/editorial/v1/'.$path, $data, $image); + $body = json_decode($response->getContent(), true); + abort_unless($response->isSuccessful(), $response->getStatusCode(), 'Editorial API rejected operation; inspect state before retry'); + abort_unless(is_array($body), 502, 'Unexpected API response'); + + return $body; + } + + public function preview(string $url): string + { + $base = rtrim(config('editorial.base_url'), '/'); + abort_unless(str_starts_with($url, $base.'/api/editorial/v1/preview/'), 422, 'Preview origin mismatch'); + $response = $this->dispatch('GET', substr($url, strlen($base))); + abort_unless($response->isSuccessful() && str_contains($response->headers->get('Content-Type', ''), 'text/html') + && str_contains($response->headers->get('X-Robots-Tag', ''), 'noindex'), 502, 'Preview render failed'); + + return $response->getContent(); + } + + private function dispatch(string $method, string $path, array $data = [], ?UploadedFile $image = null) + { + abort_unless(time() < $this->credential['expires_at'], 401, 'Process session expired'); + $before = ['editorial.enabled' => config('editorial.enabled'), + 'editorial.process_credential' => config('editorial.process_credential')]; + $originalRequest = app('request'); + config(['editorial.enabled' => true, 'editorial.process_credential' => $this->credential]); + try { + $server = ['HTTP_ACCEPT' => 'application/json', 'HTTP_AUTHORIZATION' => 'Bearer '.$this->credential['fields']['api_key'], + 'CONTENT_TYPE' => 'application/json', 'REMOTE_ADDR' => '127.0.0.1']; + $request = Request::create(rtrim(config('editorial.base_url'), '/').$path, $method, [], [], + $image ? ['image' => $image] : [], $server, $image ? null : json_encode($data)); + $kernel = app(Kernel::class); + $response = $kernel->handle($request); + $kernel->terminate($request, $response); + + return $response; + } finally { + config($before); + app()->instance('request', $originalRequest); + } + } +} diff --git a/app/Services/Editorial/Publisher.php b/app/Services/Editorial/Publisher.php index 272cb57f..9d869907 100644 --- a/app/Services/Editorial/Publisher.php +++ b/app/Services/Editorial/Publisher.php @@ -23,6 +23,7 @@ public function draft(array $input): array '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.*.display_time_randomize' => 'sometimes|boolean', 'articles.*.adopt_article_id' => 'sometimes|integer|min:1', 'articles.*.like_settings' => 'sometimes|array:seven_day,yearly,one_year', 'articles.*.like_settings.seven_day' => 'sometimes|integer|min:0|max:1000000', @@ -64,6 +65,7 @@ public function draft(array $input): array abort_if($doc->article_id && ! $article, 409, 'Managed article was removed'); if ($article) { abort_if(array_key_exists('like_settings', $row), 422, 'Like settings are only accepted for new articles'); + abort_if(array_key_exists('display_time_randomize', $row), 422, 'Display time settings are only accepted for new articles'); 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'); @@ -84,7 +86,8 @@ public function draft(array $input): array 'base_article_hash' => $article ? $this->articleHash($article) : null, ]; if (! $article) { - // Freeze the random defaults once per draft, so a retry/publication cannot redraw them. + // Freeze display offset and like defaults once; retries cannot redraw them. + $item['display_offset_seconds'] = ($row['display_time_randomize'] ?? true) ? random_int(0, 21600) : 0; $item['like_settings'] = [ 'seven_day' => $row['like_settings']['seven_day'] ?? random_int(5, 35), 'yearly' => $row['like_settings']['yearly'] ?? 50, @@ -114,7 +117,7 @@ public function getDraft(string $id): array 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', 'like_settings' => $i['like_settings'] ?? null, + 'path' => '/a/'.$i['slug'].'.html', 'display_offset_seconds' => $i['display_offset_seconds'] ?? 0, 'like_settings' => $i['like_settings'] ?? null, 'preview_url' => $base.URL::temporarySignedRoute('editorial.preview', now()->addMinutes(config('editorial.preview_minutes')), ['draft' => $id, 'key' => $i['content_key']], absolute: false)], $items)]; } @@ -189,7 +192,8 @@ public function publish(string $id, array $input): array 'i7like' => $likes['seven_day'], 'i365like' => $likes['yearly'], 'iYearLikeTarget' => $likes['one_year'], 'iYearLikeAdded' => 0, 'sYearLikeStartedAt' => $likes['one_year'] > 0 ? now() : null, - 'iIsTop' => 0, 'created_at' => now()]); + 'iIsTop' => 0, 'created_at' => now(), + 'sDisplayPublishedAt' => now()->subSeconds($item['display_offset_seconds'] ?? 0)]); } $oldTags = $this->tags($articleId); DB::table('article_tag')->where('iArticleId', $articleId)->delete(); diff --git a/app/Services/Editorial/ReleaseRunner.php b/app/Services/Editorial/ReleaseRunner.php new file mode 100644 index 00000000..3110dab8 --- /dev/null +++ b/app/Services/Editorial/ReleaseRunner.php @@ -0,0 +1,204 @@ +api->call('GET', 'catalog'); + } + if ($op === 'source') { + abort_unless(is_int($input['article_id'] ?? null) && $input['article_id'] > 0, 422); + + return $this->api->call('GET', 'source-articles/'.$input['article_id']); + } + $id = $input['release_id'] ?? ''; + abort_unless(is_string($id) && Str::isUuid($id), 422, 'release_id must be a UUID'); + $root = config('editorial.release_directory', storage_path('app/private/editorial-releases')); + if (! is_dir($root)) { + mkdir($root, 0700, true); + } + $lock = fopen($root.'/runner.lock', 'c'); + abort_unless($lock && flock($lock, LOCK_EX | LOCK_NB), 409, 'Another release operation is running'); + try { + $file = $root.'/'.$id.'.json'; + $state = is_file($file) ? json_decode(file_get_contents($file), true, 512, JSON_THROW_ON_ERROR) : null; + if ($op === 'prepare') { + $bundle = ['articles' => $input['articles'] ?? [], 'assets' => $input['assets'] ?? []]; + $bundleHash = app(Publisher::class)->hash($bundle); + abort_if($state && $state['bundle_hash'] !== $bundleHash, 409, 'Release ID reused with changed bundle'); + if (! $state) { + $state = ['release_id' => $id, 'bundle_hash' => $bundleHash, 'draft_id' => $id, + 'publication_request_id' => (string) Str::uuid(), 'phase' => 'preparing']; + $this->save($file, $state); // Persist IDs before any API mutation. + } + $articles = $this->assets($bundle); + $draft = $this->api->call('POST', 'drafts', ['request_id' => $id, 'articles' => $articles]); + $state['draft'] = $draft; + if ($state['phase'] === 'preparing') { + $state['phase'] = 'prepared'; + } + $this->save($file, $state); + + return $state; + } + abort_unless($state, 404, 'Release not found; retry prepare using the original bundle'); + if ($op === 'status') { + return $this->receipt($file, $state); + } + $draft = $this->api->call('GET', 'drafts/'.$state['draft_id']); + if ($op === 'review') { + $previews = []; + foreach ($draft['articles'] as $article) { + $previews[$article['content_key']] = $this->api->preview($article['preview_url']); + } + $state['review'] = ['hash' => $draft['hash'], 'digest' => app(Publisher::class)->hash($previews), 'rendered_at' => gmdate('c')]; + $state['phase'] = $draft['status'] === 'published' ? 'published' : 'reviewed'; + $this->save($file, $state); + + return $state + ['previews' => $previews]; + } + abort_unless(isset($state['review']) + && hash_equals($state['review']['hash'], $input['expected_hash'] ?? '') + && hash_equals($state['review']['digest'], $input['review_digest'] ?? '') + && hash_equals($draft['hash'], $state['review']['hash']), 409, 'Review the frozen draft before publishing'); + // Query first even after a lost response or interrupted local state update. + $state = $this->receipt($file, $state); + if (! isset($state['receipt'])) { + $state['phase'] = 'publishing'; + $this->save($file, $state); + $state['receipt'] = $this->api->call('POST', 'drafts/'.$state['draft_id'].'/publish', + ['request_id' => $state['publication_request_id'], 'expected_hash' => $state['review']['hash']]); + $this->save($file, $state); + } + $receiptId = $state['receipt']['publication_id']; + $state['receipt'] = $this->api->call('POST', 'publications/'.$receiptId.'/synchronize'); + + return $this->verify($file, $state); + } finally { + flock($lock, LOCK_UN); + fclose($lock); + } + } + + private function receipt(string $file, array $state): array + { + try { + $state['receipt'] = $this->api->call('GET', 'publications/'.$state['publication_request_id']); + } catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) { + if ($e->getStatusCode() !== 404) { + throw $e; + } + // Another publisher may have completed this same draft with its own UUID. + $draft = $this->api->call('GET', 'drafts/'.$state['draft_id']); + if ($draft['publication_id']) { + $state['receipt'] = $this->api->call('GET', 'publications/'.$draft['publication_id']); + } else { + return $state; + } + } + + return $this->verify($file, $state); + } + + private function verify(string $file, array $state): array + { + $timestamp = \Illuminate\Support\Facades\DB::table('editorial_publications')->where('id', $state['receipt']['publication_id'])->value('created_at'); + $state['published_at'] = \Carbon\CarbonImmutable::parse($timestamp, config('app.timezone'))->toIso8601String(); + $readback = []; + foreach ($state['receipt']['articles'] as $article) { + $source = $this->api->call('GET', 'source-articles/'.$article['article_id']); + $version = $this->api->call('GET', 'articles/'.$article['content_key'].'/versions/'.$article['revision'])['content']; + $imagesValid = true; + $resolve = function (string $html) use (&$imagesValid) { + return preg_replace_callback('/asset:([a-f0-9]{64})/', function ($m) use (&$imagesValid) { + $extension = \Illuminate\Support\Facades\DB::table('editorial_assets')->where('sha256', $m[1])->value('extension'); + $url = '/images/editorial/'.$m[1].'.'.$extension; + $path = public_path(ltrim($url, '/')); + $imagesValid = $imagesValid && is_file($path) && hash_file('sha256', $path) === $m[1]; + + return $url; + }, $html); + }; + $expectedHtml = $resolve($version['html']); + $expectedCover = $version['cover'] ? $resolve($version['cover']) : $version['cover']; + $matches = $source['body_html'] === $expectedHtml && $source['title'] === $version['title'] + && $source['summary'] === $version['summary'] && $source['slug'] === $version['slug'] + && $source['category_slug'] === $version['category_slug'] && $source['cover'] === $expectedCover + && $source['show_cover'] === $version['show_cover'] && $imagesValid; + $readback[] = ['content_key' => $article['content_key'], 'article_id' => $article['article_id'], + 'revision_matches' => (int) $source['management']['published_revision'] === (int) $article['revision'], + 'content_matches' => $matches, + 'body_sha256' => hash('sha256', $source['body_html']), 'like_growth' => $source['like_growth']]; + } + $state['readback'] = $readback; + $done = collect($state['receipt']['effects'])->every(fn ($e) => ($e['status'] ?? '') === 'done'); + $state['phase'] = $done && collect($readback)->every(fn ($r) => $r['revision_matches'] && $r['content_matches']) ? 'complete' : 'published_pending'; + $this->save($file, $state); + + return $state; + } + + private function assets(array $bundle): array + { + abort_unless(is_array($bundle['assets']) && count($bundle['assets']) <= 10, 422); + $replacements = []; + foreach ($bundle['assets'] as $asset) { + abort_unless(preg_match('/\A[a-z0-9_-]{1,80}\z/', $asset['name'] ?? '') && ! isset($replacements['file:'.$asset['name']]), 422, 'Invalid or duplicate asset name'); + $bytes = base64_decode($asset['base64'] ?? '', true); + abort_unless($bytes !== false && strlen($bytes) <= 8 * 1024 * 1024, 422, 'Invalid asset'); + $temp = tempnam(sys_get_temp_dir(), 'editorial-upload-'); + try { + file_put_contents($temp, $bytes); + chmod($temp, 0600); + $result = $this->api->call('POST', 'assets', [], new UploadedFile($temp, $asset['name'], null, null, true)); + $replacements['file:'.$asset['name']] = $result['reference']; + } finally { + @unlink($temp); + } + } + $articles = $bundle['articles']; + abort_unless(is_array($articles) && count($articles) >= 1 && count($articles) <= 10, 422); + foreach ($articles as &$article) { + foreach (['body', 'cover'] as $field) { + if (isset($article[$field])) { + $article[$field] = preg_replace_callback('/file:([a-z0-9_-]+)/', function ($m) use ($replacements) { + abort_unless(isset($replacements[$m[0]]), 422, 'Missing bundled asset'); + + return $replacements[$m[0]]; + }, $article[$field]); + } + } + } + + return $articles; + } + + private function save(string $file, array $state): void + { + $temp = $file.'.'.bin2hex(random_bytes(8)); + try { + $handle = fopen($temp, 'x'); + chmod($temp, 0600); + $bytes = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + if (fwrite($handle, $bytes) !== strlen($bytes) || ! fflush($handle) || ! fsync($handle)) { + throw new \RuntimeException('Release state write failed'); + } + fclose($handle); + if (! rename($temp, $file)) { + throw new \RuntimeException('Release state replacement failed'); + } + } finally { + @unlink($temp); + } + } +} diff --git a/database/migrations/2026_10_31_150000_add_article_display_timestamp.php b/database/migrations/2026_10_31_150000_add_article_display_timestamp.php new file mode 100644 index 00000000..455804f9 --- /dev/null +++ b/database/migrations/2026_10_31_150000_add_article_display_timestamp.php @@ -0,0 +1,18 @@ + $table->timestamp('sDisplayPublishedAt')->nullable()); + } + + public function down(): void + { + Schema::table('articles', fn (Blueprint $table) => $table->dropColumn('sDisplayPublishedAt')); + } +}; diff --git a/docs/content/WRITING.md b/docs/content/WRITING.md index a59dbe03..c80cbe88 100644 --- a/docs/content/WRITING.md +++ b/docs/content/WRITING.md @@ -4,6 +4,8 @@ # AI 写作与文章关联索引 发布已采用本站[独立文章 API](../maintenance/EDITORIAL_API.md),不经过ATOM。AI保留项目正文源,通过API保存冻结草稿、取得签名预览、发布确认的hash并核对回执;本索引仍由写作AI同任务维护,API不会自动改写它。 +2026-10-31用户另行授权综合教程每周随机三篇的周期写作与发布,Codex任务已建立,自2026-11-02当周开始。随机计划、额度、幂等台账与执行状态唯一见[周期写作](../maintenance/WEEKLY_TUTORIALS.md);这一授权不扩大为全站重写或其他实例发布。 + 本文是写作流程、文章用途及文章关系的唯一维护入口。文章正文保存在各自源文件,数据库是页面实际读取的内容;栏目实现、渲染与备份方法见[教程维护](../maintenance/TUTORIALS.md),SEO项目状态见[SEO清单](../maintenance/SEO_PLAN.md)。不要在这些文档中再复制一份持续更新的文章关系表。 ## 1 写作前先确定读者要完成的任务 @@ -25,6 +27,10 @@ ## 1 写作前先确定读者要完成的任务 自然、准确地表达;必要时用真实操作图、流程图、配置示例。软件版本、下载入口与兼容要求使用当次核对的官方来源;来源核验、页面检查和设备实测分别记录,未做实测不写“实测”。 +### 周期文章的展示时间(2026-10-31) + +用户要求每天北京时间15:00检查周期计划,新文章展示日期从实际发布时间随机向前偏移0~21600秒(含端点)。API在新文草稿中冻结`display_offset_seconds`,首次发布计算独立的`sDisplayPublishedAt`;列表和详情沿原有日期显示样式使用该字段,未设置时使用真实创建时间。实际`created_at`、发布回执及点赞起点不改;重试、旧文编辑和回退不重抽、不改旧文展示时间。`display_time_randomize`只允许新建时设置,默认true,显式false为零偏移。不要把随机时间或内部执行过程写进公开正文。 + ### AI 新文章的点赞参数(2026-10-29) 后续AI通过本站独立文章API**新建文章**时使用以下默认设置,由API在保存草稿时为每篇独立取值并冻结;相同请求重试、查询预览及发布不重新抽取。重写、接管、回退已有文章不重置原参数、点赞数或已完成进度,不自动给现有文章批量补额度。 diff --git a/docs/maintenance/EDITORIAL_API.md b/docs/maintenance/EDITORIAL_API.md index 6d296c2c..d2418acd 100644 --- a/docs/maintenance/EDITORIAL_API.md +++ b/docs/maintenance/EDITORIAL_API.md @@ -1,5 +1,7 @@ # FNA_V3 独立文章 API +2026-10-31新增[经白色机的固定发布封装](EDITORIAL_RELEASE.md):周期写作使用本机SSH经白色机到业务服务器,由`editorial:release`进程调用本页原API路由。公网编辑API保持关闭,进程权限不落盘;无需逐次修改生产.env或启用公网密钥。以下HTTP契约继续有效,服务器命令复用同一套验证、事务和回执。 + 状态:2026-10-27本地实施并联调;2026-10-29已随独立test预览提交、推送和部署,10篇教程真实API迁移通过后,test编辑接口已关闭且临时密钥移除。正式站现已切换,正式10篇教程亦通过同一API迁移;生产临时密钥用后删除,编辑接口默认关闭,后续明确写作/发布任务再按绑定环境启用并完成后关闭。test执行和回执沿[发布记录](RELEASE_PREFLIGHT_2026-10-28.md#当前门槛先test预览确认后再切正式2026-10-29),日常本地调用不受影响。用户明确采用本站独立接收 API,不经过 ATOM;不新增 ATOM 依赖。入口为 `http://fna-v3.loc/api/editorial/v1`,生产地址尚未指定。 ## 职责与数据 diff --git a/docs/maintenance/EDITORIAL_RELEASE.md b/docs/maintenance/EDITORIAL_RELEASE.md new file mode 100644 index 00000000..b5fcbbba --- /dev/null +++ b/docs/maintenance/EDITORIAL_RELEASE.md @@ -0,0 +1,43 @@ +# 经白色机的固定文章发布流程 + +## 设计 + +维护脚本:`tools/editorial/release.py`(本机传输)、`tools/editorial/ssh_askpass.py`(只向OpenSSH认证组件提供登记密码)、`editorial:release`(业务服务器命令)。现有文章API负责资产、草稿、版本、链接校验、原子发布、回执和搜索缓存同步,契约见[EDITORIAL_API.md](EDITORIAL_API.md)。本封装不另写文章SQL,不经ATOM。 + +固定路线:本机SSH→VPS-001白色机→VPS-003 `/www/wwwroot/clashfreenode-new.com`。SSH强制ProxyCommand、既有主机密钥核验,禁用配置文件和已有连接复用,不接受自定义目标/命令,也没有直连回退。白色机仅转发SSH,不保存稿件、凭据或运行定时器。 + +远端固定以nginx运行`php artisan editorial:release`,命令校验production环境、真实项目路径和base_url。JSON由stdin传递。服务器内通过Laravel HTTP Kernel调用原有API路由,经过原认证、校验和控制器;无需本机或公网HTTP。运行身份使用现有服务器权限,不改SSH策略或管理员凭据。 + +进程内临时API认证值只保存在内存,寿命10分钟,每次内部请求后恢复原配置,结束/断开杀进程即消失;不写.env或公用credential.json,不重载配置、不启用公网编辑入口。签名预览也在同一命令内渲染,公网API关闭时这些预览URL不可从外部打开。旧API的手动操作契约仍保留,周期发布改用本封装。 + +## 调用 + +本机在项目目录执行,输出文件包含私有预览,权限600,不提交Git: + +```sh +python3 tools/editorial/release.py catalog --input /私有目录/empty.json --output /私有目录/catalog.json +python3 tools/editorial/release.py source --input /私有目录/source.json --output /私有目录/source-result.json +python3 tools/editorial/release.py prepare --input /私有目录/bundle.json --output /私有目录/prepared.json +python3 tools/editorial/release.py review --input /私有目录/id.json --output /私有目录/review.json +python3 tools/editorial/release.py publish --input /私有目录/publish.json --output /私有目录/published.json +python3 tools/editorial/release.py status --input /私有目录/id.json --output /私有目录/status.json +``` + +- empty.json为`{}`;source.json为`{"article_id":真实整数ID}`。catalog/source只读,不创建release。 +- bundle.json含`release_id`(UUID)、`articles`(原API字段)及可选`assets`。每个asset为`{"name":"diagram","base64":"实际PNG/JPEG/WebP/GIF字节的base64"}`,正文/封面用`file:diagram`占位;程序上传后替换为asset引用。单图≤8MiB、最多10图、总输入≤48MiB;不接收SVG,既有本站SVG可以按真实路径引用。新画的技术SVG可在本地渲染为PNG后通过资产API上传,无需为了配图部署程序。 +- id.json仅`{"release_id":"原UUID"}`。服务器先保存稳定草稿ID/发布UUID,再调用API;相同bundle重复prepare不新建版本或重新抽取参数,相同release_id改正文拒绝409。改稿应显式创建新release并使用当前版本。 +- review.json的`data.previews`含原模板渲染的HTML,`data.review`含hash和digest。先阅读及检查冻结版本;程序渲染通过不等于人类/浏览器视觉验收。正式HTML内含线上资源链接,禁止直接在本机浏览器打开加载;视觉检查在本地对应稿件完成。必须线上页面验收时仍按项目访问批准规则到Windows,不以取预览为由越界。 +- publish.json为`{"release_id":"原UUID","expected_hash":"review中hash","review_digest":"review中digest"}`,不得盲填或以另一份稿件替换。程序先查回执;已发表则只重试后处理,草稿版本冲突返回失败。 +- status会从API重新查询并核对公开正文,不只读本地台账。`phase=complete`要求内容/版本/上传图哈希和缓存/搜索/sitemap状态通过;`published_pending`代表正文已提交但后续仍待处理,退出码2,不能据此另发正文。返回`published_at`来自服务器真实发布事务时间。 + +本机工具只输出结果文件路径及状态,不回显SSH认证、正文或预览。SSH断开、超时或非JSON输出都保存未知/失败状态,不自动重放写入;用原release_id查status。不存在草稿时沿原bundle恢复prepare。 + +## 状态与恢复 + +服务器台账`storage/app/private/editorial-releases/UUID.json`,权限600;原子替换并fsync,文件锁防并发,系统会在进程退出时释放锁。保存IDs先于写入API。台账记录prepared/reviewed/publishing/complete或published_pending;唯一发表事实仍以API事务回执为准。服务器台账损坏/丢失时须从原请求UUID和API回执恢复,不能另起ID重发。 + +回读校验包括真实版本、标题、摘要、slug、栏目、封面、正文哈希及内容寻址图文件SHA;点赞参数随结果返回供确认。文章完整HTTP页面及外部链接状态按任务另外验证,不能把内部Kernel渲染说成外部网络/浏览器验收。没有真实新稿时不向正式站发布演练文章。 + +上线前仅执行`2026_10_31_150000_add_article_display_timestamp.php`迁移(新增可空`sDisplayPublishedAt`);旧文显示回退原时间。新文草稿冻结0~21600秒偏移,首次发布计算展示时间;保留created_at、实际回执、点赞起点,旧文改稿不改展示日期。回滚程序保留新增可空列和历史发布记录,不删业务数据。 + +验证与当前部署状态见[2026-10-31发布记录](EDITORIAL_RELEASE_2026-10-31.md)。周期调度和额度唯一见[WEEKLY_TUTORIALS.md](WEEKLY_TUTORIALS.md)。 diff --git a/docs/maintenance/EDITORIAL_RELEASE_2026-10-31.md b/docs/maintenance/EDITORIAL_RELEASE_2026-10-31.md new file mode 100644 index 00000000..4e428a4b --- /dev/null +++ b/docs/maintenance/EDITORIAL_RELEASE_2026-10-31.md @@ -0,0 +1,5 @@ +# 文章发布封装实施记录(2026-10-31) + +范围:经白色机的固定SSH传输、服务器进程内API调用、可恢复发布台账、发布回读、三篇/周且每日15:00检查的本机写作计划,以及新文独立展示时间。保留已有数据、图池、广告、并行改动和现行调度。不在正式站发布演练文章,不恢复test。 + +当前阶段:本地实现及隔离验证进行中,尚未部署。最终提交、备份和服务器验收结果在本任务完成时追加。 diff --git a/docs/maintenance/WEEKLY_TUTORIALS.md b/docs/maintenance/WEEKLY_TUTORIALS.md new file mode 100644 index 00000000..5c90a199 --- /dev/null +++ b/docs/maintenance/WEEKLY_TUTORIALS.md @@ -0,0 +1,26 @@ +# 综合教程周期写作 + +2026-10-31用户最终规则:每周三篇,每天北京时间15:00检查;文章展示时间随机往前0~6小时,精确到秒。原每周两篇、早晚两个窗口的规则已替换。Codex heartbeat `fna`,名称“FNA 综合教程|每周三篇”,ACTIVE,绑定本聊天,自2026-11-02当周开始。调度已修改,首轮实际写作尚未执行。 + +## 执行入口 + +项目根运行 `python3 tools/editorial/weekly.py due`。未到期、当日已发表或本周额度满则安静退出。台账唯一位置为`storage/app/private/weekly-tutorials/state.json`,Git忽略;不保存凭据。电脑/Codex需开启,不保证关机仍执行或开机立即补跑。 + +每个自然周(Asia/Shanghai,周一至周日),在周一/二、周三/四、周五/六/日三组里各随机选一天,计划时间均为15:00,三篇间隔至少24小时。首次计划持久化,同一周不反复抽取。首周旧两篇计划尚未开始,已按最新规则重建三篇;具体日期以台账为准。 + +- `due`返回`write`后,先生成稳定release UUID,用`weekly.py claim --slot SLOT --release-id UUID`锁定任务,再写稿。每次最多一篇,同一天不连发。 +- `resume`表示已有未决release;先用[发布封装](EDITORIAL_RELEASE.md)的status查服务器,再沿原bundle、ID、草稿继续,不能创建另一篇。 +- `synchronize`表示正文已经发布、后处理待完成;沿原publish请求重试同步,不重发正文。 +- 发布完成或正文已提交但后处理pending时,用`weekly.py record --slot SLOT --receipt 私有发布结果.json`回写。只接受匹配release_id和真实published回执;用服务器事务时间计数,不用展示日期或本机收到结果的时间。 +- 漏掉的本周时段在下次15:00检查时补一篇;未开始的往周欠额不累计。跨周未决操作先查原回执,真实成功记入实际发表周,避免重复或计错额度。 +- 台账丢失、损坏或锁冲突时停止,先恢复/核对,不按空周补发。仅人工明确放弃无已发表结果的任务后可标记abandoned。人工另写文章只有明确登记替代时才占周期槽位。 + +## 内容与发布边界 + +写作规范、稳定文章与关联表唯一来源为[WRITING.md](../content/WRITING.md)。每篇先查正式目录与源稿,避免重复,官方一手资料核实事实,图解与缩略图沿现有风格。自然补下载/入门/专题内链,必要时只改直接相关旧文。公开内容不出现内部核验、未实测等说明,不编造设备实测。 + +新文点赞参数与展示偏移在API草稿中冻结;真实创建时间、回执和点赞起点仍真实记录。正文源、图片、渲染稿、关联表同任务维护。 + +生产固定经本机SSH→白色机VPS-001→正式业务服务器VPS-003。使用[固定发布命令](EDITORIAL_RELEASE.md),公网编辑API保持关闭,不在白色机部署写作服务。普通文章和图片通过内容API,源码/记录另做Git留档;程序/公共资源变更才单独Git部署,保留并行改动,不扩大成全站改版,不重建test。 + +用户授权周期内正常选题、写作、相关链接和正式发布,不逐篇再次确认。资料、权限或工具不足时保留草稿并报告真实缺口。只有有意义的完成、失败或需用户操作时通知,成功给标题和真实URL,不发每日检查流水。调度变更必须通过automation_update,不能手改调度文件或加系统cron替代。 diff --git a/tests/Feature/EditorialApiTest.php b/tests/Feature/EditorialApiTest.php index 9243b452..8e6d506d 100644 --- a/tests/Feature/EditorialApiTest.php +++ b/tests/Feature/EditorialApiTest.php @@ -75,6 +75,7 @@ protected function setUp(): void }); (require database_path('migrations/2026_10_27_190000_create_editorial_tables.php'))->up(); (require database_path('migrations/2026_10_29_200000_add_one_year_article_likes.php'))->up(); + (require database_path('migrations/2026_10_31_150000_add_article_display_timestamp.php'))->up(); DB::table('categories')->insert([ ['id' => 1, 'sSlug' => 'freenode', 'sName' => '资源'], ['id' => 3, 'sSlug' => 'windows', 'sName' => 'Windows'], ['id' => 10, 'sSlug' => 'advanced', 'sName' => '进阶教程'], diff --git a/tests/Feature/EditorialReleaseTest.php b/tests/Feature/EditorialReleaseTest.php new file mode 100644 index 00000000..3d8a89d6 --- /dev/null +++ b/tests/Feature/EditorialReleaseTest.php @@ -0,0 +1,265 @@ + 'sqlite', 'database.connections.sqlite.database' => ':memory:', + 'editorial.enabled' => false, 'editorial.base_url' => 'http://localhost', + 'scout.driver' => 'collection', 'responsecache.enabled' => false, 'cache.default' => 'array']); + DB::purge('sqlite'); + 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(); + (require database_path('migrations/2026_10_29_200000_add_one_year_article_likes.php'))->up(); + (require database_path('migrations/2026_10_31_150000_add_article_display_timestamp.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->releaseRoot = sys_get_temp_dir().'/fna-release-test-'.Str::uuid(); + config(['editorial.enabled' => false, 'editorial.release_directory' => $this->releaseRoot]); + } + + protected function tearDown(): void + { + foreach (glob($this->releaseRoot.'/*') ?: [] as $file) { + unlink($file); + } + @rmdir($this->releaseRoot); + parent::tearDown(); + } + + private function bundle(): array + { + return ['operation' => 'prepare', 'release_id' => (string) Str::uuid(), 'articles' => [[ + 'content_key' => 'release-example', 'expected_revision' => 0, 'slug' => 'release-example', + 'title' => '发布演练', 'summary' => '演练摘要', 'category_slug' => 'advanced', + 'body' => '
正文测试。
', + ]]]; + } + + public function test_release_retry_after_lost_response_preserves_ids_and_single_publication(): void + { + $runner = app(ReleaseRunner::class); + $bundle = $this->bundle(); + $prepared = $runner->run($bundle); + $repeat = $runner->run($bundle); + $this->assertSame($prepared['publication_request_id'], $repeat['publication_request_id']); + $this->assertSame(1, DB::table('editorial_documents')->value('revision')); + $this->assertFalse(config('editorial.enabled')); + $this->assertNull(config('editorial.process_credential')); + $review = $runner->run(['operation' => 'review', 'release_id' => $bundle['release_id']]); + $this->assertStringContainsString('正文测试', $review['previews']['release-example']); + $publish = ['operation' => 'publish', 'release_id' => $bundle['release_id'], + 'expected_hash' => $review['review']['hash'], 'review_digest' => $review['review']['digest']]; + // Server committed, but the caller lost its response before saving the local receipt. + app(ProcessApi::class)->call('POST', 'drafts/'.$prepared['draft_id'].'/publish', [ + 'request_id' => $prepared['publication_request_id'], 'expected_hash' => $review['review']['hash']]); + $done = $runner->run($publish); + $this->assertSame('complete', $done['phase']); + $again = $runner->run($publish); + $this->assertSame($done['receipt'], $again['receipt']); + $this->assertSame(1, DB::table('articles')->count()); + $this->assertSame(1, DB::table('editorial_publications')->count()); + $this->assertFalse(config('editorial.enabled')); + $this->assertNull(config('editorial.process_credential')); + $this->getJson('/api/editorial/v1/catalog')->assertStatus(503); + } + + public function test_release_changed_input_and_unreviewed_publish_fail_closed(): void + { + $runner = app(ReleaseRunner::class); + $bundle = $this->bundle(); + $runner->run($bundle); + try { + $runner->run(['operation' => 'publish', 'release_id' => $bundle['release_id']]); + $this->fail('Unreviewed publication accepted'); + } catch (HttpException $e) { + $this->assertSame(409, $e->getStatusCode()); + } + $bundle['articles'][0]['body'] .= '变动'; + try { + $runner->run($bundle); + $this->fail('Changed bundle accepted'); + } catch (HttpException $e) { + $this->assertSame(409, $e->getStatusCode()); + } + $this->assertSame(0, DB::table('articles')->count()); + $this->assertFalse(config('editorial.enabled')); + } + + public function test_release_lock_and_failure_restore_process_permission(): void + { + mkdir($this->releaseRoot, 0700, true); + $lock = fopen($this->releaseRoot.'/runner.lock', 'c'); + flock($lock, LOCK_EX); + try { + app(ReleaseRunner::class)->run($this->bundle()); + $this->fail('Concurrent release accepted'); + } catch (HttpException $e) { + $this->assertSame(409, $e->getStatusCode()); + } finally { + flock($lock, LOCK_UN); + fclose($lock); + } + $bundle = $this->bundle(); + $bundle['articles'][0]['category_slug'] = 'unknown'; + try { + app(ReleaseRunner::class)->run($bundle); + $this->fail('Unknown category accepted'); + } catch (HttpException $e) { + $this->assertSame(422, $e->getStatusCode()); + } + $this->assertFalse(config('editorial.enabled')); + $this->assertNull(config('editorial.process_credential')); + $this->assertSame(0, DB::table('articles')->count()); + } + + public function test_release_expired_process_credential_is_rejected(): void + { + config(['editorial.enabled' => true, 'editorial.process_credential' => ['expires_at' => time() - 1]]); + $this->getJson('/api/editorial/v1/catalog')->assertStatus(401); + } + + public function test_release_asset_batch_and_pending_effects_recover_without_republishing(): void + { + $bundle = $this->bundle(); + $bytes = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aXZkAAAAASUVORK5CYII='); + $sha = hash('sha256', $bytes); + $bundle['assets'] = [['name' => 'diagram', 'base64' => base64_encode($bytes)]]; + $bundle['articles'][0]['body'] .= '