363 lines
13 KiB
PHP
363 lines
13 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Models\Forum;
|
|
use App\Models\Thread;
|
|
use App\Actions\BbcodeFormatter;
|
|
use App\Models\Setting;
|
|
use App\Services\AuditLogger;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
class ThreadController extends Controller
|
|
{
|
|
public function index(Request $request): JsonResponse
|
|
{
|
|
$query = Thread::query()
|
|
->withoutTrashed()
|
|
->withCount('posts')
|
|
->withMax('posts', 'created_at')
|
|
->with([
|
|
'user' => fn ($query) => $query
|
|
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
|
->with(['rank', 'roles']),
|
|
'latestPost.user.rank',
|
|
'latestPost.user.roles',
|
|
]);
|
|
|
|
$forumParam = $request->query('forum');
|
|
if (is_string($forumParam)) {
|
|
$forumId = $this->parseIriId($forumParam);
|
|
if ($forumId !== null) {
|
|
$query->where('forum_id', $forumId);
|
|
}
|
|
}
|
|
|
|
$threads = $query
|
|
->orderByDesc(DB::raw('COALESCE(posts_max_created_at, threads.created_at)'))
|
|
->get()
|
|
->map(fn (Thread $thread) => $this->serializeThread($thread));
|
|
|
|
return response()->json($threads);
|
|
}
|
|
|
|
public function show(Thread $thread): JsonResponse
|
|
{
|
|
$thread->increment('views_count');
|
|
$thread->refresh();
|
|
$thread->loadMissing([
|
|
'user' => fn ($query) => $query
|
|
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
|
->with(['rank', 'roles']),
|
|
'attachments.extension',
|
|
'attachments.group',
|
|
'latestPost.user.rank',
|
|
'latestPost.user.roles',
|
|
])->loadCount('posts');
|
|
return response()->json($this->serializeThread($thread));
|
|
}
|
|
|
|
public function store(Request $request): JsonResponse
|
|
{
|
|
$data = $request->validate([
|
|
'title' => ['required', 'string'],
|
|
'body' => ['required', 'string'],
|
|
'forum' => ['required', 'string'],
|
|
]);
|
|
|
|
$forumId = $this->parseIriId($data['forum']);
|
|
$forum = Forum::findOrFail($forumId);
|
|
|
|
if ($forum->type !== 'forum') {
|
|
return response()->json(['message' => 'Threads can only be created inside forums.'], 422);
|
|
}
|
|
|
|
$thread = Thread::create([
|
|
'forum_id' => $forum->id,
|
|
'user_id' => $request->user()?->id,
|
|
'title' => $data['title'],
|
|
'body' => $data['body'],
|
|
]);
|
|
|
|
app(AuditLogger::class)->log($request, 'thread.created', $thread, [
|
|
'forum_id' => $forum->id,
|
|
'title' => $thread->title,
|
|
]);
|
|
|
|
$thread->loadMissing([
|
|
'user' => fn ($query) => $query
|
|
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
|
->with(['rank', 'roles']),
|
|
'attachments.extension',
|
|
'attachments.group',
|
|
'latestPost.user.rank',
|
|
'latestPost.user.roles',
|
|
])->loadCount('posts');
|
|
|
|
return response()->json($this->serializeThread($thread), 201);
|
|
}
|
|
|
|
public function destroy(Request $request, Thread $thread): JsonResponse
|
|
{
|
|
$reason = $request->input('reason');
|
|
$reasonText = $request->input('reason_text');
|
|
app(AuditLogger::class)->log($request, 'thread.deleted', $thread, [
|
|
'forum_id' => $thread->forum_id,
|
|
'title' => $thread->title,
|
|
'reason' => $reason,
|
|
'reason_text' => $reasonText,
|
|
]);
|
|
$thread->deleted_by = $request->user()?->id;
|
|
$thread->save();
|
|
$thread->delete();
|
|
|
|
return response()->json(null, 204);
|
|
}
|
|
|
|
public function update(Request $request, Thread $thread): JsonResponse
|
|
{
|
|
$user = $request->user();
|
|
if (!$user) {
|
|
return response()->json(['message' => 'Unauthorized.'], 401);
|
|
}
|
|
|
|
$isAdmin = $user->roles()->where('name', 'ROLE_ADMIN')->exists();
|
|
if (!$isAdmin && $thread->user_id !== $user->id) {
|
|
return response()->json(['message' => 'Not authorized to edit threads.'], 403);
|
|
}
|
|
|
|
$data = $request->validate([
|
|
'title' => ['sometimes', 'required', 'string'],
|
|
'body' => ['sometimes', 'required', 'string'],
|
|
]);
|
|
|
|
if (array_key_exists('title', $data)) {
|
|
$thread->title = $data['title'];
|
|
}
|
|
if (array_key_exists('body', $data)) {
|
|
$thread->body = $data['body'];
|
|
}
|
|
|
|
$thread->save();
|
|
$thread->refresh();
|
|
|
|
app(AuditLogger::class)->log($request, 'thread.edited', $thread, [
|
|
'forum_id' => $thread->forum_id,
|
|
'title' => $thread->title,
|
|
]);
|
|
|
|
$thread->loadMissing([
|
|
'user' => fn ($query) => $query
|
|
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
|
->with(['rank', 'roles']),
|
|
'attachments.extension',
|
|
'attachments.group',
|
|
'latestPost.user.rank',
|
|
'latestPost.user.roles',
|
|
])->loadCount('posts');
|
|
|
|
return response()->json($this->serializeThread($thread));
|
|
}
|
|
|
|
public function updateSolved(Request $request, Thread $thread): JsonResponse
|
|
{
|
|
$user = $request->user();
|
|
if (!$user) {
|
|
return response()->json(['message' => 'Unauthorized.'], 401);
|
|
}
|
|
|
|
$isAdmin = $user->roles()->where('name', 'ROLE_ADMIN')->exists();
|
|
if (!$isAdmin && $thread->user_id !== $user->id) {
|
|
return response()->json(['message' => 'Not authorized to update solved status.'], 403);
|
|
}
|
|
|
|
$data = $request->validate([
|
|
'solved' => ['required', 'boolean'],
|
|
]);
|
|
|
|
$thread->solved = $data['solved'];
|
|
$thread->save();
|
|
app(AuditLogger::class)->log($request, 'thread.solved_updated', $thread, [
|
|
'solved' => $thread->solved,
|
|
]);
|
|
$thread->refresh();
|
|
$thread->loadMissing([
|
|
'user' => fn ($query) => $query
|
|
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
|
->with(['rank', 'roles']),
|
|
'attachments.extension',
|
|
'attachments.group',
|
|
'latestPost.user.rank',
|
|
'latestPost.user.roles',
|
|
])->loadCount('posts');
|
|
|
|
return response()->json($this->serializeThread($thread));
|
|
}
|
|
|
|
private function parseIriId(?string $value): ?int
|
|
{
|
|
if (!$value) {
|
|
return null;
|
|
}
|
|
|
|
if (preg_match('#/forums/(\d+)$#', $value, $matches)) {
|
|
return (int) $matches[1];
|
|
}
|
|
|
|
if (is_numeric($value)) {
|
|
return (int) $value;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function serializeThread(Thread $thread): array
|
|
{
|
|
$attachments = $thread->relationLoaded('attachments') ? $thread->attachments : collect();
|
|
$bodyHtml = $this->renderBody($thread->body, $attachments);
|
|
return [
|
|
'id' => $thread->id,
|
|
'title' => $thread->title,
|
|
'body' => $thread->body,
|
|
'body_html' => $bodyHtml,
|
|
'solved' => (bool) $thread->solved,
|
|
'forum' => "/api/forums/{$thread->forum_id}",
|
|
'user_id' => $thread->user_id,
|
|
'posts_count' => ($thread->posts_count ?? 0) + 1,
|
|
'views_count' => $thread->views_count ?? 0,
|
|
'user_name' => $thread->user?->name,
|
|
'user_avatar_url' => $thread->user?->avatar_path
|
|
? Storage::url($thread->user->avatar_path)
|
|
: null,
|
|
'user_posts_count' => ($thread->user?->posts_count ?? 0) + ($thread->user?->threads_count ?? 0),
|
|
'user_created_at' => $thread->user?->created_at?->toIso8601String(),
|
|
'user_location' => $thread->user?->location,
|
|
'user_thanks_given_count' => $thread->user?->thanks_given_count ?? 0,
|
|
'user_thanks_received_count' => $thread->user?->thanks_received_count ?? 0,
|
|
'user_rank_name' => $thread->user?->rank?->name,
|
|
'user_rank_badge_type' => $thread->user?->rank?->badge_type,
|
|
'user_rank_badge_text' => $thread->user?->rank?->badge_text,
|
|
'user_rank_badge_url' => $thread->user?->rank?->badge_image_path
|
|
? Storage::url($thread->user->rank->badge_image_path)
|
|
: null,
|
|
'user_rank_color' => $thread->user?->rank?->color,
|
|
'user_group_color' => $this->resolveGroupColor($thread->user),
|
|
'last_post_at' => $thread->latestPost?->created_at?->toIso8601String()
|
|
?? $thread->created_at?->toIso8601String(),
|
|
'last_post_id' => $thread->latestPost?->id,
|
|
'last_post_user_id' => $thread->latestPost?->user_id ?? $thread->user_id,
|
|
'last_post_user_name' => $thread->latestPost?->user?->name
|
|
?? $thread->user?->name,
|
|
'last_post_user_rank_color' => $thread->latestPost?->user?->rank?->color
|
|
?? $thread->user?->rank?->color,
|
|
'last_post_user_group_color' => $this->resolveGroupColor($thread->latestPost?->user)
|
|
?? $this->resolveGroupColor($thread->user),
|
|
'created_at' => $thread->created_at?->toIso8601String(),
|
|
'updated_at' => $thread->updated_at?->toIso8601String(),
|
|
'attachments' => $thread->relationLoaded('attachments')
|
|
? $attachments
|
|
->map(fn ($attachment) => [
|
|
'id' => $attachment->id,
|
|
'group' => $attachment->group ? [
|
|
'id' => $attachment->group->id,
|
|
'name' => $attachment->group->name,
|
|
] : null,
|
|
'original_name' => $attachment->original_name,
|
|
'extension' => $attachment->extension,
|
|
'mime_type' => $attachment->mime_type,
|
|
'size_bytes' => $attachment->size_bytes,
|
|
'download_url' => "/api/attachments/{$attachment->id}/download",
|
|
'thumbnail_url' => $attachment->thumbnail_path
|
|
? "/api/attachments/{$attachment->id}/thumbnail"
|
|
: null,
|
|
'is_image' => str_starts_with((string) $attachment->mime_type, 'image/'),
|
|
'created_at' => $attachment->created_at?->toIso8601String(),
|
|
])
|
|
->values()
|
|
: [],
|
|
];
|
|
}
|
|
|
|
private function renderBody(string $body, $attachments): string
|
|
{
|
|
$replaced = $this->replaceAttachmentTags($body, $attachments);
|
|
return BbcodeFormatter::format($replaced);
|
|
}
|
|
|
|
private function replaceAttachmentTags(string $body, $attachments): string
|
|
{
|
|
if (!$attachments || count($attachments) === 0) {
|
|
return $body;
|
|
}
|
|
|
|
$map = [];
|
|
foreach ($attachments as $attachment) {
|
|
$name = strtolower($attachment->original_name ?? '');
|
|
if ($name !== '') {
|
|
$map[$name] = [
|
|
'url' => "/api/attachments/{$attachment->id}/download",
|
|
'mime' => $attachment->mime_type ?? '',
|
|
'thumb' => $attachment->thumbnail_path
|
|
? "/api/attachments/{$attachment->id}/thumbnail"
|
|
: null,
|
|
];
|
|
}
|
|
}
|
|
|
|
if (!$map) {
|
|
return $body;
|
|
}
|
|
|
|
return preg_replace_callback('/\\[attachment\\](.+?)\\[\\/attachment\\]/i', function ($matches) use ($map) {
|
|
$rawName = trim($matches[1]);
|
|
$key = strtolower($rawName);
|
|
if (!array_key_exists($key, $map)) {
|
|
return $matches[0];
|
|
}
|
|
$entry = $map[$key];
|
|
$url = $entry['url'];
|
|
$mime = $entry['mime'] ?? '';
|
|
if (str_starts_with($mime, 'image/') && $this->displayImagesInline()) {
|
|
if (!empty($entry['thumb'])) {
|
|
$thumb = $entry['thumb'];
|
|
return "[url={$url}][img]{$thumb}[/img][/url]";
|
|
}
|
|
return "[img]{$url}[/img]";
|
|
}
|
|
return "[url={$url}]{$rawName}[/url]";
|
|
}, $body) ?? $body;
|
|
}
|
|
|
|
private function displayImagesInline(): bool
|
|
{
|
|
$value = Setting::query()->where('key', 'attachments.display_images_inline')->value('value');
|
|
if ($value === null) {
|
|
return true;
|
|
}
|
|
return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true);
|
|
}
|
|
|
|
private function resolveGroupColor(?\App\Models\User $user): ?string
|
|
{
|
|
if (!$user) {
|
|
return null;
|
|
}
|
|
|
|
$roles = $user->roles;
|
|
if (!$roles) {
|
|
return null;
|
|
}
|
|
|
|
foreach ($roles->sortBy('name') as $role) {
|
|
if (!empty($role->color)) {
|
|
return $role->color;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|