'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' => '']); } 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[] = 'incoming'; } } } } 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'); } } } } } } }