206 lines
10 KiB
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);
|
|
}
|
|
}
|
|
}
|