Add news article comments and reactions

This commit is contained in:
2026-05-01 11:43:49 +02:00
parent 874f8feb9c
commit 28e7e46e13
22 changed files with 20083 additions and 26 deletions

View File

@@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class ExportLegacyNewsCommentsSqlCommand extends Command
{
protected $signature = 'news:comments-export-legacy-sql
{--path=database/sql/news_article_comments_legacy_import.sql : Output SQL file path}
{--skip-empty : Skip comments with empty or whitespace-only content}
{--table= : Override legacy source table name (defaults to auto-detect news_comment/news_comments)}';
protected $description = 'Generate a production-safe SQL file for legacy news comments import';
public function handle(): int
{
try {
DB::connection('legacy')->getPdo();
} catch (\Throwable $exception) {
$this->error('Cannot connect to legacy database: ' . $exception->getMessage());
return self::FAILURE;
}
$legacyTable = $this->resolveLegacyTable();
if ($legacyTable === null) {
$this->error('Legacy table `news_comment` or `news_comments` was not found.');
return self::FAILURE;
}
$outputPath = $this->resolveOutputPath((string) $this->option('path'));
$skipEmpty = (bool) $this->option('skip-empty');
$directory = dirname($outputPath);
if (! is_dir($directory)) {
mkdir($directory, 0777, true);
}
$handle = fopen($outputPath, 'wb');
if ($handle === false) {
$this->error('Unable to write SQL file: ' . $outputPath);
return self::FAILURE;
}
$written = 0;
$skippedEmpty = 0;
$legacyNewsIds = [];
fwrite($handle, "-- Legacy news comments import generated at " . now()->toDateTimeString() . PHP_EOL);
fwrite($handle, "START TRANSACTION;" . PHP_EOL . PHP_EOL);
DB::connection('legacy')
->table($legacyTable)
->orderBy('comment_id')
->chunk(500, function ($rows) use ($handle, $skipEmpty, &$written, &$skippedEmpty, &$legacyNewsIds): void {
foreach ($rows as $row) {
$legacyId = (int) ($row->comment_id ?? 0);
$legacyNewsId = (int) ($row->news_id ?? 0);
$legacyUserId = (int) ($row->user_id ?? 0);
$body = trim((string) ($row->message ?? ''));
if ($legacyId < 1 || $legacyNewsId < 1) {
continue;
}
if ($body === '') {
if ($skipEmpty) {
$skippedEmpty++;
continue;
}
$body = '[no content]';
}
$legacyNewsIds[$legacyNewsId] = $legacyNewsId;
$authorName = trim((string) ($row->author ?? ''));
$timestamp = $this->normalizeTimestamp($row->posted ?? null);
$renderedBody = nl2br(e($body));
$userExpression = $legacyUserId > 0
? "CASE WHEN EXISTS (SELECT 1 FROM users WHERE users.id = {$legacyUserId} AND users.deleted_at IS NULL) THEN {$legacyUserId} ELSE NULL END"
: 'NULL';
$statement = "INSERT IGNORE INTO news_article_comments (legacy_id, legacy_user_id, article_id, user_id, parent_id, author_name, body, rendered_body, status, legacy_posted_at, created_at, updated_at, deleted_at)\n"
. "SELECT {$legacyId}, " . ($legacyUserId > 0 ? (string) $legacyUserId : 'NULL') . ", news_articles.id, {$userExpression}, NULL, " . $this->quote($authorName !== '' ? $authorName : null) . ", " . $this->quote($body) . ", " . $this->quote($renderedBody) . ", 'visible', " . $this->quote($timestamp) . ", " . $this->quote($timestamp) . ", " . $this->quote($timestamp) . ", NULL\n"
. "FROM news_articles\n"
. "WHERE news_articles.legacy_news_id = {$legacyNewsId}\n"
. "LIMIT 1;\n\n";
fwrite($handle, $statement);
$written++;
}
});
if ($legacyNewsIds !== []) {
foreach (array_chunk(array_values($legacyNewsIds), 250) as $chunk) {
fwrite($handle, 'UPDATE news_articles SET comments_enabled = 1 WHERE legacy_news_id IN (' . implode(', ', array_map('intval', $chunk)) . ');' . PHP_EOL);
}
fwrite($handle, PHP_EOL);
}
fwrite($handle, 'COMMIT;' . PHP_EOL);
fclose($handle);
$this->info('SQL export written to ' . $outputPath);
$this->table(
['Result', 'Count'],
[
['Statements written', $written],
['Skipped - empty body', $skippedEmpty],
['Articles enabled for comments', count($legacyNewsIds)],
]
);
return self::SUCCESS;
}
private function resolveLegacyTable(): ?string
{
$configured = trim((string) $this->option('table'));
if ($configured !== '') {
return DB::connection('legacy')->getSchemaBuilder()->hasTable($configured) ? $configured : null;
}
foreach (['news_comment', 'news_comments'] as $candidate) {
if (DB::connection('legacy')->getSchemaBuilder()->hasTable($candidate)) {
return $candidate;
}
}
return null;
}
private function resolveOutputPath(string $path): string
{
$trimmed = trim($path);
if ($trimmed === '') {
return base_path('database/sql/news_article_comments_legacy_import.sql');
}
if (preg_match('/^[A-Za-z]:\\\\|^\\\\\\\\|^\//', $trimmed) === 1) {
return $trimmed;
}
return base_path(str_replace(['/', '\\\\'], DIRECTORY_SEPARATOR, $trimmed));
}
private function normalizeTimestamp(mixed $value): string
{
$raw = trim((string) ($value ?? ''));
if ($raw === '' || str_starts_with($raw, '0000-00-00')) {
return now()->toDateTimeString();
}
try {
return Carbon::parse($raw)->toDateTimeString();
} catch (\Throwable) {
return now()->toDateTimeString();
}
}
private function quote(?string $value): string
{
if ($value === null) {
return 'NULL';
}
$escaped = str_replace(
["\\", "\0", "\n", "\r", "\x1a", "'"],
["\\\\", "\\0", "\\n", "\\r", "\\Z", "\\'"],
$value,
);
return "'{$escaped}'";
}
}

View File

@@ -0,0 +1,229 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class ImportLegacyNewsCommentsCommand extends Command
{
protected $signature = 'news:comments-import-legacy
{--dry-run : Preview only no writes to DB}
{--chunk=500 : Rows to process per batch}
{--skip-empty : Skip comments with empty or whitespace-only content}
{--table= : Override legacy source table name (defaults to auto-detect news_comment/news_comments)}';
protected $description = 'Import legacy news comments into news_article_comments';
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$chunk = max(1, (int) $this->option('chunk'));
$skipEmpty = (bool) $this->option('skip-empty');
try {
DB::connection('legacy')->getPdo();
} catch (\Throwable $exception) {
$this->error('Cannot connect to legacy database: ' . $exception->getMessage());
return self::FAILURE;
}
$legacyTable = $this->resolveLegacyTable();
if ($legacyTable === null) {
$this->error('Legacy table `news_comment` or `news_comments` was not found.');
return self::FAILURE;
}
if (! DB::getSchemaBuilder()->hasTable('news_article_comments')) {
$this->error('Target table `news_article_comments` is missing. Run migrations first.');
return self::FAILURE;
}
if (! DB::getSchemaBuilder()->hasColumn('news_articles', 'legacy_news_id')) {
$this->error('Column `news_articles.legacy_news_id` is missing. Run migrations first.');
return self::FAILURE;
}
if ($dryRun) {
$this->warn('[DRY-RUN] No data will be written.');
}
$articleMap = DB::table('news_articles')
->whereNotNull('legacy_news_id')
->pluck('id', 'legacy_news_id')
->mapWithKeys(fn ($articleId, $legacyId): array => [(int) $legacyId => (int) $articleId])
->all();
$validUserIds = DB::table('users')
->whereNull('deleted_at')
->pluck('id')
->flip()
->all();
$alreadyImported = DB::table('news_article_comments')
->whereNotNull('legacy_id')
->pluck('legacy_id')
->flip()
->all();
$total = DB::connection('legacy')->table($legacyTable)->count();
if ($total === 0) {
$this->warn('No legacy news comments found.');
return self::SUCCESS;
}
$stats = [
'imported' => 0,
'skipped_duplicate' => 0,
'skipped_article' => 0,
'skipped_empty' => 0,
'users_unmapped' => 0,
'errors' => 0,
];
$touchedArticleIds = [];
DB::connection('legacy')
->table($legacyTable)
->orderBy('comment_id')
->chunk($chunk, function ($rows) use (&$alreadyImported, $articleMap, $validUserIds, $dryRun, $skipEmpty, &$stats, &$touchedArticleIds): void {
$inserts = [];
foreach ($rows as $row) {
$legacyId = (int) ($row->comment_id ?? 0);
$legacyNewsId = (int) ($row->news_id ?? 0);
$legacyUserId = (int) ($row->user_id ?? 0);
$body = trim((string) ($row->message ?? ''));
if ($legacyId < 1) {
$stats['errors']++;
continue;
}
if (isset($alreadyImported[$legacyId])) {
$stats['skipped_duplicate']++;
continue;
}
if ($body === '') {
if ($skipEmpty) {
$stats['skipped_empty']++;
continue;
}
$body = '[no content]';
}
$articleId = $articleMap[$legacyNewsId] ?? null;
if (! $articleId) {
$stats['skipped_article']++;
continue;
}
$resolvedUserId = isset($validUserIds[$legacyUserId]) ? $legacyUserId : null;
if ($resolvedUserId === null && $legacyUserId > 0) {
$stats['users_unmapped']++;
}
$timestamp = $this->normalizeTimestamp($row->posted ?? null);
$authorName = trim((string) ($row->author ?? ''));
$record = [
'legacy_id' => $legacyId,
'legacy_user_id' => $legacyUserId > 0 ? $legacyUserId : null,
'article_id' => $articleId,
'user_id' => $resolvedUserId,
'parent_id' => null,
'author_name' => $authorName !== '' ? $authorName : null,
'body' => $body,
'rendered_body' => nl2br(e($body)),
'status' => 'visible',
'legacy_posted_at' => $timestamp,
'created_at' => $timestamp,
'updated_at' => $timestamp,
'deleted_at' => null,
];
if (! $dryRun) {
$inserts[] = $record;
$alreadyImported[$legacyId] = true;
$touchedArticleIds[$articleId] = $articleId;
}
$stats['imported']++;
}
if (! $dryRun && $inserts !== []) {
try {
DB::table('news_article_comments')->insert($inserts);
} catch (\Throwable) {
foreach ($inserts as $insert) {
try {
DB::table('news_article_comments')->insertOrIgnore([$insert]);
} catch (\Throwable) {
$stats['errors']++;
}
}
}
}
});
if (! $dryRun && $touchedArticleIds !== []) {
DB::table('news_articles')
->whereIn('id', array_values($touchedArticleIds))
->update(['comments_enabled' => true]);
}
$this->table(
['Result', 'Count'],
[
['Imported', $stats['imported']],
['Skipped - already imported', $stats['skipped_duplicate']],
['Skipped - article missing', $stats['skipped_article']],
['Skipped - empty body', $stats['skipped_empty']],
['Imported with unmapped user', $stats['users_unmapped']],
['Errors', $stats['errors']],
]
);
return $stats['errors'] > 0 ? self::FAILURE : self::SUCCESS;
}
private function resolveLegacyTable(): ?string
{
$configured = trim((string) $this->option('table'));
if ($configured !== '') {
return DB::connection('legacy')->getSchemaBuilder()->hasTable($configured) ? $configured : null;
}
foreach (['news_comment', 'news_comments'] as $candidate) {
if (DB::connection('legacy')->getSchemaBuilder()->hasTable($candidate)) {
return $candidate;
}
}
return null;
}
private function normalizeTimestamp(mixed $value): string
{
$raw = trim((string) ($value ?? ''));
if ($raw === '' || str_starts_with($raw, '0000-00-00')) {
return now()->toDateTimeString();
}
try {
return Carbon::parse($raw)->toDateTimeString();
} catch (\Throwable) {
return now()->toDateTimeString();
}
}
}

View File

@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\NewsArticleComment;
use App\Models\User;
use App\Services\News\NewsArticleCommentService;
use App\Support\AvatarUrl;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use cPad\Plugins\News\Models\NewsArticle;
final class NewsArticleCommentController extends Controller
{
private const MAX_LENGTH = 4_000;
public function __construct(private readonly NewsArticleCommentService $comments)
{
}
public function index(Request $request, int $articleId): JsonResponse
{
$article = $this->resolveArticle($articleId);
$page = max(1, (int) $request->query('page', 1));
$perPage = 20;
$comments = NewsArticleComment::query()
->with(['user.profile', 'visibleReplies'])
->where('article_id', $article->id)
->where('status', 'visible')
->whereNull('parent_id')
->orderByDesc('created_at')
->orderByDesc('id')
->paginate($perPage, ['*'], 'page', $page);
$viewer = $request->user();
$items = $comments->getCollection()->map(
fn (NewsArticleComment $comment): array => $this->formatComment($comment, $viewer, $article, true)
);
return response()->json([
'data' => $items,
'meta' => [
'current_page' => $comments->currentPage(),
'last_page' => $comments->lastPage(),
'total' => $comments->total(),
'per_page' => $comments->perPage(),
],
]);
}
public function store(Request $request, int $articleId): JsonResponse
{
$article = $this->resolveArticle($articleId);
$data = $request->validate([
'content' => ['required', 'string', 'min:2', 'max:' . self::MAX_LENGTH],
'parent_id' => ['nullable', 'integer', 'exists:news_article_comments,id'],
]);
$parent = null;
if (! empty($data['parent_id'])) {
$parent = NewsArticleComment::query()
->where('article_id', $article->id)
->where('status', 'visible')
->find((int) $data['parent_id']);
if (! $parent) {
return response()->json([
'errors' => [
'content' => ['The comment you are replying to is no longer available.'],
],
], 422);
}
}
$comment = $this->comments->create($article, $request->user(), (string) $data['content'], $parent);
return response()->json([
'data' => $this->formatComment($comment, $request->user(), $article, false),
], 201);
}
public function destroy(Request $request, int $articleId, int $commentId): JsonResponse
{
$article = $this->resolveArticle($articleId);
$comment = NewsArticleComment::query()
->where('article_id', $article->id)
->findOrFail($commentId);
$this->comments->delete($comment, $request->user());
return response()->json([
'message' => 'Comment deleted.',
]);
}
private function resolveArticle(int $articleId): NewsArticle
{
return NewsArticle::query()
->published()
->whereKey($articleId)
->firstOrFail();
}
private function formatComment(NewsArticleComment $comment, ?User $viewer, NewsArticle $article, bool $includeReplies = false): array
{
$user = $comment->user;
$displayName = $user?->username ?? $user?->name ?? $comment->author_name ?? 'Former member';
$avatarHash = $user?->profile?->avatar_hash ?? null;
$data = [
'id' => $comment->id,
'parent_id' => $comment->parent_id,
'raw_content' => (string) ($comment->body ?? ''),
'rendered_content' => $comment->getDisplayHtml(),
'created_at' => $comment->created_at?->toIso8601String(),
'time_ago' => $comment->created_at ? Carbon::parse($comment->created_at)->diffForHumans() : null,
'can_delete' => $this->canDelete($comment, $article, $viewer),
'user' => [
'id' => $user?->id,
'username' => $user?->username,
'display' => $displayName,
'profile_url' => $user?->username ? '/@' . $user->username : null,
'avatar_url' => $user ? AvatarUrl::forUser((int) $user->id, $avatarHash, 64) : null,
'level' => (int) ($user?->level ?? 1),
'rank' => (string) ($user?->rank ?? 'Newbie'),
],
'replies' => [],
];
if ($includeReplies) {
$replies = $comment->relationLoaded('visibleReplies')
? $comment->visibleReplies
: collect();
$data['replies'] = $replies
->map(fn (NewsArticleComment $reply): array => $this->formatComment($reply, $viewer, $article, true))
->values()
->all();
}
return $data;
}
private function canDelete(NewsArticleComment $comment, NewsArticle $article, ?User $viewer): bool
{
if (! $viewer) {
return false;
}
return (int) $comment->user_id === (int) $viewer->id
|| (int) $article->author_id === (int) $viewer->id
|| $viewer->isAdmin()
|| $viewer->isModerator();
}
}

View File

@@ -7,6 +7,8 @@ use App\Http\Controllers\Controller;
use App\Models\ArtworkComment;
use App\Models\ArtworkReaction;
use App\Models\CommentReaction;
use App\Models\NewsArticleComment;
use App\Models\NewsArticleCommentReaction;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -77,6 +79,33 @@ class ReactionController extends Controller
);
}
public function newsCommentReactions(Request $request, int $commentId): JsonResponse
{
$this->resolveVisibleNewsComment($commentId);
return response()->json([
'entity_type' => 'news-comment',
'entity_id' => $commentId,
'totals' => $this->getTotals('news_article_comment_reactions', ['comment_id' => $commentId], $request->user()?->id),
]);
}
public function toggleNewsCommentReaction(Request $request, int $commentId): JsonResponse
{
$this->resolveVisibleNewsComment($commentId);
$slug = $this->validateReactionSlug($request);
return $this->toggle(
model: new NewsArticleCommentReaction(),
where: ['comment_id' => $commentId, 'user_id' => $request->user()->id, 'reaction' => $slug],
countWhere: ['comment_id' => $commentId],
entityId: $commentId,
entityType: 'news-comment',
userId: $request->user()->id,
slug: $slug,
);
}
// ─────────────────────────────────────────────────────────────────────────
// Shared internals
// ─────────────────────────────────────────────────────────────────────────
@@ -189,4 +218,13 @@ class ReactionController extends Controller
throw new ModelNotFoundException("No [{$table}] record found with id [{$id}].");
}
}
private function resolveVisibleNewsComment(int $commentId): NewsArticleComment
{
return NewsArticleComment::query()
->where('id', $commentId)
->where('status', 'visible')
->whereHas('article', fn ($query) => $query->published())
->firstOrFail();
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\News;
use App\Http\Controllers\Controller;
use App\Models\NewsArticleComment;
use App\Services\News\NewsArticleCommentService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use cPad\Plugins\News\Models\NewsArticle;
final class NewsArticleCommentController extends Controller
{
public function __construct(private readonly NewsArticleCommentService $comments)
{
}
public function store(Request $request, string $slug): RedirectResponse
{
$article = $this->resolveArticle($slug);
$data = $request->validate([
'body' => ['required', 'string', 'min:2', 'max:4000'],
]);
$this->comments->create($article, $request->user(), (string) $data['body']);
return redirect()->to(route('news.show', ['slug' => $article->slug]) . '#comments')->with('status', 'Comment posted.');
}
public function destroy(Request $request, string $slug, NewsArticleComment $comment): RedirectResponse
{
$article = $this->resolveArticle($slug);
abort_unless((int) $comment->article_id === (int) $article->id, 404);
$this->comments->delete($comment, $request->user());
return redirect()->to(route('news.show', ['slug' => $article->slug]) . '#comments')->with('status', 'Comment removed.');
}
private function resolveArticle(string $slug): NewsArticle
{
return NewsArticle::query()
->published()
->where('slug', $slug)
->firstOrFail();
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\News;
use App\Http\Controllers\Controller;
use App\Models\NewsArticleComment;
use App\Models\User;
use App\Services\News\NewsService;
use Illuminate\Http\Request;
@@ -155,10 +156,31 @@ class NewsController extends Controller
->limit(config('news.related_limit', 4))
->get();
$comments = collect();
$commentsCount = 0;
if ($article->commentsAreEnabled()) {
$comments = NewsArticleComment::query()
->where('article_id', $article->id)
->whereNull('parent_id')
->where('status', 'visible')
->with(['user.profile'])
->orderBy('created_at')
->orderBy('id')
->get();
$commentsCount = (int) NewsArticleComment::query()
->where('article_id', $article->id)
->where('status', 'visible')
->count();
}
return view('news.show', [
'article' => $article,
'related' => $related,
'relatedEntities' => $this->news->resolveRelatedEntities($article, $request->user()),
'comments' => $comments,
'commentsCount' => $commentsCount,
] + $this->sidebarData());
}

View File

@@ -56,6 +56,9 @@ final class StudioNewsController extends Controller
'tagOptions' => $this->news->tagOptions(),
'relationTypeOptions' => $this->news->relationTypeOptions(),
'storeUrl' => route('studio.news.store'),
'coverUploadUrl' => route('api.studio.news.media.upload'),
'coverDeleteUrl' => route('api.studio.news.media.destroy'),
'coverCdnBaseUrl' => rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'),
'entitySearchUrl' => route('studio.news.entity-search'),
'categoriesUrl' => route('studio.news.categories'),
'tagsUrl' => route('studio.news.tags'),
@@ -85,7 +88,11 @@ final class StudioNewsController extends Controller
'categoryOptions' => $this->news->categoryOptions(),
'tagOptions' => $this->news->tagOptions(),
'relationTypeOptions' => $this->news->relationTypeOptions(),
'coverUploadUrl' => route('api.studio.news.media.upload'),
'coverDeleteUrl' => route('api.studio.news.media.destroy'),
'coverCdnBaseUrl' => rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'),
'updateUrl' => route('studio.news.update', ['article' => $article->id]),
'destroyUrl' => route('studio.news.destroy', ['article' => $article->id]),
'previewUrl' => route('studio.news.preview', ['article' => $article->id]),
'publishUrl' => route('studio.news.publish', ['article' => $article->id]),
'archiveUrl' => route('studio.news.archive', ['article' => $article->id]),
@@ -115,6 +122,8 @@ final class StudioNewsController extends Controller
'article' => $article,
'related' => $related,
'relatedEntities' => $this->news->resolveRelatedEntities($article, $request->user()),
'comments' => collect(),
'commentsCount' => 0,
'previewMode' => true,
'previewCanonical' => route('studio.news.preview', ['article' => $article->id]),
'previewBackUrl' => route('studio.news.edit', ['article' => $article->id]),
@@ -127,7 +136,16 @@ final class StudioNewsController extends Controller
$this->news->updateArticle($article, $request->user(), $this->validateArticle($request, $article));
return back()->with('success', 'Article updated.');
return redirect()->route('studio.news.edit', ['article' => $article->id])->with('success', 'Article updated.');
}
public function destroy(Request $request, NewsArticle $article): RedirectResponse
{
$this->authorizeNews($request);
$this->news->deleteArticle($article);
return redirect()->route('studio.news.index')->with('success', 'Article moved to trash.');
}
public function publish(Request $request, NewsArticle $article): RedirectResponse
@@ -331,7 +349,7 @@ final class StudioNewsController extends Controller
'title' => ['required', 'string', 'max:255'],
'slug' => ['nullable', 'string', 'max:255'],
'excerpt' => ['nullable', 'string', 'max:800'],
'content' => ['required', 'string', 'max:50000'],
'content' => ['required', 'string', 'max:500000'],
'cover_image' => ['nullable', 'string', 'max:2048'],
'type' => ['required', Rule::in(array_column($this->news->articleTypeOptions(), 'value'))],
'category_id' => ['nullable', 'integer', 'exists:news_categories,id'],
@@ -340,12 +358,24 @@ final class StudioNewsController extends Controller
'published_at' => ['nullable', 'date'],
'is_featured' => ['nullable', 'boolean'],
'is_pinned' => ['nullable', 'boolean'],
'comments_enabled' => ['nullable', 'boolean'],
'tag_ids' => ['nullable', 'array'],
'tag_ids.*' => ['integer', 'exists:news_tags,id'],
'new_tag_names' => ['nullable', 'array', 'max:12'],
'new_tag_names.*' => ['string', 'max:80'],
'meta_title' => ['nullable', 'string', 'max:255'],
'meta_description' => ['nullable', 'string', 'max:300'],
'meta_keywords' => ['nullable', 'string', 'max:255'],
'canonical_url' => ['nullable', 'url', 'max:2048'],
'canonical_url' => ['nullable', 'string', 'max:2048', function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === '' || $value === null) {
return;
}
$isAbsolute = filter_var($value, FILTER_VALIDATE_URL) !== false;
$isRelative = str_starts_with($value, '/');
if (! $isAbsolute && ! $isRelative) {
$fail('The canonical URL must be a valid URL or a relative path starting with /.');
}
}],
'og_title' => ['nullable', 'string', 'max:255'],
'og_description' => ['nullable', 'string', 'max:300'],
'og_image' => ['nullable', 'string', 'max:2048'],

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Services\ContentSanitizer;
use cPad\Plugins\News\Models\NewsArticle;
class NewsArticleComment extends Model
{
use HasFactory;
use SoftDeletes;
protected $fillable = [
'legacy_id',
'article_id',
'user_id',
'parent_id',
'legacy_user_id',
'author_name',
'body',
'rendered_body',
'status',
'legacy_posted_at',
];
protected $casts = [
'legacy_id' => 'integer',
'article_id' => 'integer',
'user_id' => 'integer',
'parent_id' => 'integer',
'legacy_user_id' => 'integer',
'legacy_posted_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
'deleted_at' => 'datetime',
];
public function article(): BelongsTo
{
return $this->belongsTo(NewsArticle::class, 'article_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function parent(): BelongsTo
{
return $this->belongsTo(self::class, 'parent_id');
}
public function replies(): HasMany
{
return $this->hasMany(self::class, 'parent_id')->orderBy('created_at')->orderBy('id');
}
public function visibleReplies(): HasMany
{
return $this->hasMany(self::class, 'parent_id')
->where('status', 'visible')
->orderBy('created_at')
->orderBy('id')
->with(['user.profile', 'visibleReplies']);
}
public function reactions(): HasMany
{
return $this->hasMany(NewsArticleCommentReaction::class, 'comment_id');
}
public function getDisplayHtml(): string
{
$rendered = is_string($this->rendered_body) ? trim($this->rendered_body) : '';
$body = (string) ($this->body ?? '');
if ($rendered === '') {
$rendered = $body !== ''
? ContentSanitizer::render($body)
: nl2br(e($body));
}
return ContentSanitizer::sanitizeRenderedHtml($rendered, $this->authorCanPublishLinks());
}
private function authorCanPublishLinks(): bool
{
$level = (int) ($this->user?->level ?? 1);
$rank = strtolower((string) ($this->user?->rank ?? 'newbie'));
return $level > 1 && $rank !== 'newbie';
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class NewsArticleCommentReaction extends Model
{
public $timestamps = false;
protected $table = 'news_article_comment_reactions';
protected $fillable = ['comment_id', 'user_id', 'reaction'];
protected $casts = [
'comment_id' => 'integer',
'user_id' => 'integer',
'created_at' => 'datetime',
];
public function comment(): BelongsTo
{
return $this->belongsTo(NewsArticleComment::class, 'comment_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Services\News;
use App\Services\ContentSanitizer;
use App\Models\NewsArticleComment;
use App\Models\User;
use Illuminate\Validation\ValidationException;
use cPad\Plugins\News\Models\NewsArticle;
final class NewsArticleCommentService
{
public function create(NewsArticle $article, User $actor, string $body, ?NewsArticleComment $parent = null): NewsArticleComment
{
if (! $article->commentsAreEnabled()) {
throw ValidationException::withMessages([
'body' => 'Comments are disabled for this article.',
]);
}
$trimmedBody = trim($body);
$errors = ContentSanitizer::validate($trimmedBody);
if ($errors !== []) {
throw ValidationException::withMessages([
'body' => $errors,
]);
}
$comment = NewsArticleComment::query()->create([
'article_id' => (int) $article->id,
'user_id' => (int) $actor->id,
'parent_id' => $parent?->id,
'author_name' => trim((string) ($actor->name ?: $actor->username)),
'body' => $trimmedBody,
'rendered_body' => ContentSanitizer::sanitizeRenderedHtml(
ContentSanitizer::render($trimmedBody),
$this->actorCanPublishLinks($actor)
),
'status' => 'visible',
]);
return $comment->fresh(['user.profile', 'replies.user.profile']);
}
public function delete(NewsArticleComment $comment, User $actor): void
{
$article = $comment->article()->with('author')->first();
if (! $article || ! $this->canDelete($comment, $article, $actor)) {
throw ValidationException::withMessages([
'comment' => 'You are not allowed to remove this comment.',
]);
}
if ($comment->trashed()) {
return;
}
$comment->delete();
}
private function canDelete(NewsArticleComment $comment, NewsArticle $article, User $actor): bool
{
return (int) $comment->user_id === (int) $actor->id
|| (int) $article->author_id === (int) $actor->id
|| $actor->isAdmin()
|| $actor->isModerator();
}
private function actorCanPublishLinks(User $actor): bool
{
$level = (int) ($actor->level ?? 1);
$rank = strtolower((string) ($actor->rank ?? 'Newbie'));
return $level > 1 && $rank !== 'newbie';
}
}

View File

@@ -16,6 +16,7 @@ use App\Support\AvatarUrl;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use cPad\Plugins\News\Models\NewsArticle;
use cPad\Plugins\News\Models\NewsArticleRelation;
@@ -173,6 +174,7 @@ final class NewsService
'published_at' => \optional($article->published_at)?->toIso8601String(),
'is_featured' => (bool) $article->is_featured,
'is_pinned' => (bool) ($article->is_pinned ?? false),
'comments_enabled' => (bool) ($article->comments_enabled ?? false),
'category_id' => $article->category_id ? (int) $article->category_id : null,
'author_id' => (int) $article->author_id,
'author' => $article->author ? $this->mapUserLookupResult($article->author) : null,
@@ -209,6 +211,11 @@ final class NewsService
return $this->persistArticle($article, $editor, $data);
}
public function deleteArticle(NewsArticle $article): void
{
$article->delete();
}
public function publish(NewsArticle $article): NewsArticle
{
$article->forceFill([
@@ -313,6 +320,8 @@ final class NewsService
$title = 'Untitled News Article';
}
$previousCoverImage = trim((string) ($article->cover_image ?? ''));
$editorialStatus = $this->normalizeEditorialStatus((string) ($data['editorial_status'] ?? $article->editorial_status ?? NewsArticle::EDITORIAL_STATUS_DRAFT));
$publishedAt = $this->normalizePublishedAt($editorialStatus, $data['published_at'] ?? $article->published_at);
$authorId = (int) ($data['author_id'] ?? $article->author_id ?? $editor->id);
@@ -331,6 +340,7 @@ final class NewsService
'published_at' => $publishedAt,
'is_featured' => (bool) ($data['is_featured'] ?? false),
'is_pinned' => (bool) ($data['is_pinned'] ?? false),
'comments_enabled' => (bool) ($data['comments_enabled'] ?? false),
'meta_title' => $this->nullableText($data['meta_title'] ?? null),
'meta_description' => $this->nullableText($data['meta_description'] ?? null),
'meta_keywords' => $this->nullableText($data['meta_keywords'] ?? null),
@@ -340,9 +350,16 @@ final class NewsService
'og_image' => $this->nullableText($data['og_image'] ?? null),
]);
$article->save();
if (! $article->save()) {
throw new \RuntimeException('Failed to save NewsArticle.');
}
$article->tags()->sync(\collect($data['tag_ids'] ?? [])->map(fn (mixed $id): int => (int) $id)->filter()->all());
$nextCoverImage = trim((string) ($article->cover_image ?? ''));
if ($previousCoverImage !== '' && $previousCoverImage !== $nextCoverImage) {
$this->deleteManagedCoverImage($previousCoverImage);
}
$article->tags()->sync($this->resolveArticleTagIds($data));
$this->syncRelations($article, $data['relations'] ?? []);
return $article->fresh(['author.profile', 'category', 'tags', 'relatedEntities']);
@@ -365,6 +382,7 @@ final class NewsService
'is_pinned' => (bool) ($article->is_pinned ?? false),
'views' => (int) $article->views,
'edit_url' => route('studio.news.edit', ['article' => $article->id]),
'delete_url' => route('studio.news.destroy', ['article' => $article->id]),
'preview_url' => route('studio.news.preview', ['article' => $article->id]),
'public_url' => route('news.show', ['slug' => $article->slug]),
];
@@ -437,6 +455,45 @@ final class NewsService
return $text === '' ? null : $text;
}
private function resolveArticleTagIds(array $data): array
{
$existingIds = \collect($data['tag_ids'] ?? [])
->map(fn (mixed $id): int => (int) $id)
->filter()
->values();
$createdIds = \collect($data['new_tag_names'] ?? [])
->map(fn (mixed $name): string => trim(preg_replace('/\s+/', ' ', (string) $name) ?? ''))
->filter()
->unique(fn (string $name): string => Str::lower($name))
->map(function (string $name): ?int {
if (Str::slug($name) === '') {
return null;
}
return (int) NewsTag::findOrCreateByName($name)->id;
})
->filter()
->values();
return $existingIds
->merge($createdIds)
->unique()
->values()
->all();
}
private function deleteManagedCoverImage(string $path): void
{
$trimmed = ltrim(trim($path), '/');
if ($trimmed === '' || ! Str::startsWith($trimmed, 'news/covers/')) {
return;
}
Storage::disk((string) config('uploads.object_storage.disk', 's3'))->delete($trimmed);
}
private function searchGroups(string $query, ?User $viewer): array
{
return Group::query()