fn_b/app/Services/Editorial/ReleaseRunner.php

206 lines
10 KiB
PHP

<?php
namespace App\Services\Editorial;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Str;
final class ReleaseRunner
{
public function __construct(private ProcessApi $api) {}
public function run(array $input): array
{
$op = $input['operation'] ?? '';
abort_unless(in_array($op, ['catalog', 'source', 'prepare', 'review', 'publish', 'status']), 422, 'Unknown operation');
if ($op === 'catalog') {
return $this->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);
}
}
}