318 lines
20 KiB
PHP
318 lines
20 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Editorial;
|
|
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\URL;
|
|
use Illuminate\Support\Facades\Validator;
|
|
|
|
final class Publisher
|
|
{
|
|
public function __construct(private ContentRenderer $renderer) {}
|
|
|
|
public function draft(array $input): array
|
|
{
|
|
$data = Validator::make($input, [
|
|
'request_id' => 'required|uuid', 'articles' => 'required|array|min:1|max:'.config('editorial.max_batch'),
|
|
'articles.*.content_key' => ['required', 'string', 'max:100', 'regex:/\A[a-z0-9][a-z0-9_-]*\z/'],
|
|
'articles.*.expected_revision' => 'required|integer|min:0',
|
|
'articles.*.slug' => ['required', 'string', 'max:180', 'regex:/\A[a-z0-9][a-z0-9_-]*\z/'],
|
|
'articles.*.title' => 'required|string|max:200', 'articles.*.summary' => 'required|string|max:255',
|
|
'articles.*.category_slug' => 'required|string|max:50',
|
|
'articles.*.format' => 'sometimes|in:markdown,html', 'articles.*.body' => 'required|string|max:500000',
|
|
'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.*.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',
|
|
'articles.*.like_settings.yearly' => 'sometimes|integer|min:0|max:365',
|
|
'articles.*.like_settings.one_year' => 'sometimes|integer|min:0|max:1000000',
|
|
])->validate();
|
|
$data['operation'] = $input['_operation'] ?? 'draft';
|
|
$hash = $this->hash($data);
|
|
DB::transaction(function () use ($data, $hash) {
|
|
$inserted = DB::table('editorial_drafts')->insertOrIgnore([
|
|
'id' => $data['request_id'], 'payload_hash' => $hash, 'items' => '[]',
|
|
'status' => 'draft', 'created_at' => now(), 'updated_at' => now(),
|
|
]);
|
|
$draft = DB::table('editorial_drafts')->where('id', $data['request_id'])->lockForUpdate()->first();
|
|
abort_unless($draft && hash_equals($draft->payload_hash, $hash), 409, 'Request ID reused with different content');
|
|
if (! $inserted) {
|
|
return;
|
|
}
|
|
$rows = $data['articles'];
|
|
usort($rows, fn ($a, $b) => strcmp($a['content_key'], $b['content_key']));
|
|
abort_unless(count(array_unique(array_column($rows, 'content_key'))) === count($rows)
|
|
&& count(array_unique(array_column($rows, 'slug'))) === count($rows), 422, 'Duplicate article in batch');
|
|
$items = [];
|
|
foreach ($rows as $row) {
|
|
$key = $row['content_key'];
|
|
DB::table('editorial_documents')->insertOrIgnore(['content_key' => $key, 'revision' => 0, 'created_at' => now(), 'updated_at' => now()]);
|
|
$doc = DB::table('editorial_documents')->where('content_key', $key)->lockForUpdate()->first();
|
|
abort_unless((int) $doc->revision === (int) $row['expected_revision'], 409, 'Revision changed: '.$key);
|
|
$article = $doc->article_id ? DB::table('articles')->where('id', $doc->article_id)->lockForUpdate()->first() : null;
|
|
if (isset($row['adopt_article_id'])) {
|
|
abort_unless(! $doc->article_id || (int) $doc->article_id === (int) $row['adopt_article_id'], 409, 'Content key already bound');
|
|
$article = DB::table('articles')->where('id', $row['adopt_article_id'])->lockForUpdate()->first();
|
|
abort_unless($article && ! DB::table('editorial_documents')->where('article_id', $article->id)->where('content_key', '!=', $key)->exists(), 409, 'Article missing or already managed');
|
|
if (! $doc->article_id) {
|
|
DB::table('editorial_documents')->where('content_key', $key)->update(['article_id' => $article->id, 'published_revision' => 0]);
|
|
DB::table('editorial_versions')->insert(['content_key' => $key, 'revision' => 0, 'snapshot' => json_encode($this->baseline($key, $article)), 'created_at' => now()]);
|
|
}
|
|
}
|
|
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_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');
|
|
}
|
|
$collision = DB::table('articles')->where('sSlug', $row['slug'])->first();
|
|
abort_if($collision && (! $article || $collision->id !== $article->id), 409, 'URL belongs to another article; explicit adoption required');
|
|
$category = $this->category($row['category_slug']);
|
|
$tags = $row['tag_ids'] ?? ($article ? $this->tags($article->id) : []);
|
|
$tags = array_values(array_unique(array_map('intval', $tags)));
|
|
sort($tags);
|
|
abort_unless(DB::table('tags')->whereIn('id', $tags)->count() === count($tags), 422, 'Unknown tag ID');
|
|
$item = [
|
|
'content_key' => $key, 'revision' => $doc->revision + 1, 'slug' => $row['slug'],
|
|
'title' => $row['title'], 'summary' => $row['summary'], 'category_slug' => $category->sSlug,
|
|
'format' => $row['format'] ?? 'markdown', 'body' => $row['body'], 'tag_ids' => $tags,
|
|
'cover' => array_key_exists('cover', $row) ? $row['cover'] : ($article->sThumb ?? null),
|
|
'show_cover' => (bool) ($row['show_cover'] ?? ($article->bPageThumb ?? false)),
|
|
'base_article_hash' => $article ? $this->articleHash($article) : null,
|
|
];
|
|
if (! $article) {
|
|
// Freeze the random defaults once per draft, so a retry/publication cannot redraw them.
|
|
$item['like_settings'] = [
|
|
'seven_day' => $row['like_settings']['seven_day'] ?? random_int(5, 35),
|
|
'yearly' => $row['like_settings']['yearly'] ?? 50,
|
|
'one_year' => $row['like_settings']['one_year'] ?? random_int(100, 500),
|
|
];
|
|
}
|
|
$item['html'] = $this->renderer->render($item);
|
|
if ($item['cover']) {
|
|
$this->renderer->render(['format' => 'html', 'body' => '<img src="'.htmlspecialchars($item['cover'], ENT_QUOTES).'">']);
|
|
}
|
|
DB::table('editorial_documents')->where('content_key', $key)->update(['revision' => $item['revision'], 'updated_at' => now()]);
|
|
$items[] = $item;
|
|
}
|
|
$this->validateLinks($items);
|
|
DB::table('editorial_drafts')->where('id', $data['request_id'])->update(['items' => json_encode($items, JSON_UNESCAPED_UNICODE), 'updated_at' => now()]);
|
|
}, 3);
|
|
|
|
return $this->getDraft($data['request_id']);
|
|
}
|
|
|
|
public function getDraft(string $id): array
|
|
{
|
|
$d = DB::table('editorial_drafts')->where('id', $id)->first();
|
|
abort_unless($d, 404);
|
|
$items = json_decode($d->items, true);
|
|
$base = rtrim(config('editorial.base_url'), '/');
|
|
|
|
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,
|
|
'preview_url' => $base.URL::temporarySignedRoute('editorial.preview', now()->addMinutes(config('editorial.preview_minutes')), ['draft' => $id, 'key' => $i['content_key']], absolute: false)], $items)];
|
|
}
|
|
|
|
public function article(string $key): array
|
|
{
|
|
$doc = DB::table('editorial_documents')->where('content_key', $key)->first();
|
|
abort_unless($doc, 404);
|
|
$article = $doc->article_id ? DB::table('articles')->where('id', $doc->article_id)->first() : null;
|
|
|
|
return ['content_key' => $key, 'latest_revision' => $doc->revision, 'published_revision' => $doc->published_revision,
|
|
'article_id' => $doc->article_id, 'path' => $article ? '/a/'.$article->sSlug.'.html' : null,
|
|
'versions' => DB::table('editorial_versions')->where('content_key', $key)->orderByDesc('revision')->get(['revision', 'created_at'])->toArray()];
|
|
}
|
|
|
|
public function rollback(string $key, array $input): array
|
|
{
|
|
$data = Validator::make($input, ['request_id' => 'required|uuid', 'expected_revision' => 'required|integer|min:0', 'target_revision' => 'required|integer|min:0'])->validate();
|
|
$v = DB::table('editorial_versions')->where('content_key', $key)->where('revision', $data['target_revision'])->first();
|
|
abort_unless($v, 404, 'Published version unavailable');
|
|
$item = json_decode($v->snapshot, true);
|
|
$item = array_intersect_key($item, array_flip(['content_key', 'slug', 'title', 'summary', 'category_slug', 'format', 'body', 'tag_ids', 'cover', 'show_cover']));
|
|
$item['expected_revision'] = $data['expected_revision'];
|
|
|
|
return $this->draft(['request_id' => $data['request_id'], 'articles' => [$item], '_operation' => 'rollback:'.$key.':'.$data['target_revision']]);
|
|
}
|
|
|
|
public function publish(string $id, array $input): array
|
|
{
|
|
$data = Validator::make($input, ['request_id' => 'required|uuid', 'expected_hash' => 'required|string|size:64'])->validate();
|
|
$opHash = $this->hash(['draft_id' => $id, 'expected_hash' => $data['expected_hash']]);
|
|
$publication = DB::transaction(function () use ($id, $data, $opHash) {
|
|
$existing = DB::table('editorial_publications')->where('id', $data['request_id'])->lockForUpdate()->first();
|
|
if ($existing) {
|
|
abort_unless(hash_equals($existing->payload_hash, $opHash), 409, 'Publish request ID reused');
|
|
|
|
return $existing->id;
|
|
}
|
|
$draft = DB::table('editorial_drafts')->where('id', $id)->lockForUpdate()->first();
|
|
abort_unless($draft, 404);
|
|
abort_unless(hash_equals($draft->payload_hash, $data['expected_hash']), 409, 'Previewed draft hash changed');
|
|
if ($draft->status === 'published') {
|
|
return $draft->publication_id;
|
|
}
|
|
$items = json_decode($draft->items, true);
|
|
$documents = [];
|
|
foreach ($items as $item) {
|
|
$doc = DB::table('editorial_documents')->where('content_key', $item['content_key'])->lockForUpdate()->first();
|
|
abort_unless($doc && (int) $doc->revision === $item['revision'], 409, 'Draft superseded: '.$item['content_key']);
|
|
$current = $doc->article_id ? DB::table('articles')->where('id', $doc->article_id)->lockForUpdate()->first() : null;
|
|
abort_unless(($current ? $this->articleHash($current) : null) === $item['base_article_hash'], 409, 'Article edited outside this draft');
|
|
abort_if(! $current && DB::table('articles')->where('sSlug', $item['slug'])->exists(), 409, 'URL now belongs to another article');
|
|
$this->category($item['category_slug']);
|
|
abort_unless(DB::table('tags')->whereIn('id', $item['tag_ids'])->count() === count($item['tag_ids']), 422, 'Tag removed since preview');
|
|
$documents[$item['content_key']] = $doc;
|
|
}
|
|
$this->validateLinks($items, true);
|
|
$published = [];
|
|
foreach ($items as $item) {
|
|
$doc = $documents[$item['content_key']];
|
|
$html = $this->renderer->publicImages($item['html']);
|
|
$cover = $item['cover'] ? $this->renderer->publicImages($item['cover']) : null;
|
|
$fields = ['sTitle' => $item['title'], 'sTitleSub' => $item['summary'], 'sSlug' => $item['slug'],
|
|
'sContent' => $html, 'sThumb' => $cover, 'iCategoryId' => $this->category($item['category_slug'])->id,
|
|
'iStatus' => 1, 'bPageThumb' => $item['show_cover'], 'updated_at' => now()];
|
|
if ($doc->article_id) {
|
|
$articleId = $doc->article_id;
|
|
DB::table('articles')->where('id', $articleId)->update($fields);
|
|
} else {
|
|
// Older frozen drafts retain their original zero defaults.
|
|
$likes = $item['like_settings'] ?? ['seven_day' => 0, 'yearly' => 0, 'one_year' => 0];
|
|
$articleId = DB::table('articles')->insertGetId($fields + ['iUserId' => 1, 'iViews' => 0, 'iLikeCount' => 0,
|
|
'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()]);
|
|
}
|
|
$oldTags = $this->tags($articleId);
|
|
DB::table('article_tag')->where('iArticleId', $articleId)->delete();
|
|
foreach ($item['tag_ids'] as $tag) {
|
|
DB::table('article_tag')->insert(['iArticleId' => $articleId, 'iTagId' => $tag]);
|
|
}
|
|
foreach (array_unique(array_merge($oldTags, $item['tag_ids'])) as $tag) {
|
|
DB::table('tags')->where('id', $tag)->update(['iArticleCount' => DB::table('article_tag')->where('iTagId', $tag)->count()]);
|
|
}
|
|
DB::table('editorial_documents')->where('content_key', $item['content_key'])->update(['article_id' => $articleId, 'published_revision' => $item['revision'], 'updated_at' => now()]);
|
|
DB::table('editorial_versions')->insert(['content_key' => $item['content_key'], 'revision' => $item['revision'], 'snapshot' => json_encode($item, JSON_UNESCAPED_UNICODE), 'created_at' => now()]);
|
|
$published[] = ['content_key' => $item['content_key'], 'article_id' => $articleId, 'revision' => $item['revision'],
|
|
'url' => rtrim(config('editorial.base_url'), '/').'/a/'.$item['slug'].'.html'];
|
|
}
|
|
DB::table('editorial_publications')->insert(['id' => $data['request_id'], 'draft_id' => $id, 'payload_hash' => $opHash,
|
|
'receipt' => json_encode(['publication_id' => $data['request_id'], 'status' => 'published', 'articles' => $published], JSON_UNESCAPED_UNICODE), 'created_at' => now(), 'updated_at' => now()]);
|
|
DB::table('editorial_drafts')->where('id', $id)->update(['status' => 'published', 'publication_id' => $data['request_id'], 'updated_at' => now()]);
|
|
|
|
return $data['request_id'];
|
|
}, 3);
|
|
|
|
return app(PublicationEffects::class)->synchronize($publication);
|
|
}
|
|
|
|
public function receipt(string $id): array
|
|
{
|
|
$row = DB::table('editorial_publications')->where('id', $id)->first();
|
|
abort_unless($row, 404);
|
|
|
|
return json_decode($row->receipt, true) + ['effects' => json_decode($row->effects ?? 'null', true)];
|
|
}
|
|
|
|
public function category(string $slug): object
|
|
{
|
|
$c = DB::table('categories')->where('sSlug', $slug)->where('iStatus', 1)->first();
|
|
abort_unless($c && ! in_array($slug, ['freenode', 'shadowrocketids']), 422, 'Unknown or non-editorial category');
|
|
|
|
return $c;
|
|
}
|
|
|
|
public function articleHash(object $a): string
|
|
{
|
|
return $this->hash(['fields' => array_intersect_key((array) $a, array_flip(['sTitle', 'sTitleSub', 'sSlug', 'sContent', 'sThumb', 'iCategoryId', 'iStatus', 'bPageThumb'])), 'tag_ids' => $this->tags($a->id)]);
|
|
}
|
|
|
|
private function tags(int $id): array
|
|
{
|
|
return DB::table('article_tag')->where('iArticleId', $id)->orderBy('iTagId')->pluck('iTagId')->map(fn ($v) => (int) $v)->toArray();
|
|
}
|
|
|
|
private function baseline(string $key, object $a): array
|
|
{
|
|
$category = DB::table('categories')->where('id', $a->iCategoryId)->value('sSlug');
|
|
|
|
return ['content_key' => $key, 'slug' => $a->sSlug, 'title' => $a->sTitle, 'summary' => $a->sTitleSub ?? '',
|
|
'category_slug' => $category, 'format' => 'html', 'body' => $a->sContent, 'tag_ids' => $this->tags($a->id), 'cover' => $a->sThumb, 'show_cover' => (bool) $a->bPageThumb];
|
|
}
|
|
|
|
public function hash(array $data): string
|
|
{
|
|
$sort = function (&$value) use (&$sort) {
|
|
if (! is_array($value)) {
|
|
return;
|
|
}
|
|
if (! array_is_list($value)) {
|
|
ksort($value);
|
|
}
|
|
foreach ($value as &$child) {
|
|
$sort($child);
|
|
}
|
|
};
|
|
$sort($data);
|
|
|
|
return hash('sha256', json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
|
|
}
|
|
|
|
private function validateLinks(array $items, bool $includeIncoming = false): void
|
|
{
|
|
$targets = [];
|
|
foreach ($items as $i) {
|
|
$targets[$i['slug']] = $i['html'];
|
|
}
|
|
$sources = array_column($items, 'html');
|
|
if ($includeIncoming) {
|
|
// Only inspect incoming links affected by this batch, not unrelated legacy problems.
|
|
foreach (DB::table('articles')->where('iStatus', 1)->whereNotIn('sSlug', array_keys($targets))->get(['sContent']) as $a) {
|
|
$dom = $this->renderer->document($a->sContent ?? '');
|
|
foreach ($dom->getElementsByTagName('a') as $link) {
|
|
$href = $link->getAttribute('href');
|
|
if (preg_match('~\A/a/([a-z0-9_-]+)\.html(?:#.*)?\z~', $href, $m) && isset($targets[$m[1]])) {
|
|
$sources[] = '<a href="'.htmlspecialchars($href, ENT_QUOTES).'">incoming</a>';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
foreach ($sources as $source) {
|
|
$dom = $this->renderer->document($source);
|
|
foreach ($dom->getElementsByTagName('a') as $link) {
|
|
$href = $link->getAttribute('href');
|
|
if (str_starts_with($href, '#') && strlen($href) > 1) {
|
|
abort_unless($dom->getElementById(rawurldecode(substr($href, 1))), 422, 'Missing local anchor: '.$href);
|
|
} elseif (str_starts_with($href, '/a/')) {
|
|
abort_unless(preg_match('~\A/a/([a-z0-9_-]+)\.html(?:#([^?]*))?\z~', $href, $m), 422, 'Invalid article path');
|
|
$target = $targets[$m[1]] ?? DB::table('articles')->where('sSlug', $m[1])->where('iStatus', 1)->value('sContent');
|
|
abort_unless(is_string($target), 422, 'Unpublished article target: '.$href);
|
|
if (! empty($m[2])) {
|
|
abort_unless($this->renderer->document($target)->getElementById(rawurldecode($m[2])), 422, 'Missing target anchor: '.$href);
|
|
}
|
|
} elseif (str_starts_with($href, '/') && ! str_starts_with($href, '/images/') && $href !== '/css/tutorials.css' && $href !== '/') {
|
|
$segments = explode('/', trim($href, '/'));
|
|
if ($segments[0] === 'tag' && count($segments) === 2) {
|
|
abort_unless(DB::table('tags')->where('sSlug', $segments[1])->exists(), 422, 'Unknown tag link');
|
|
} else {
|
|
abort_unless(count($segments) <= 2, 422, 'Invalid category link');
|
|
$c = DB::table('categories')->where('sSlug', end($segments))->where('iStatus', 1)->first();
|
|
abort_unless($c, 422, 'Unknown category link');
|
|
if (count($segments) === 2) {
|
|
abort_unless(DB::table('categories')->where('id', $c->iParentId)->value('sSlug') === $segments[0], 422, 'Wrong category parent');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|