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, $name) => ($e['status'] ?? '') === 'done' || ($name === 'indexnow' && ($e['status'] ?? '') === 'skipped')); $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); } } }