withoutTrashed()->with([ 'user' => fn ($query) => $query ->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived']) ->with(['rank', 'roles']), 'attachments.extension', 'attachments.group', ]); $threadParam = $request->query('thread'); if (is_string($threadParam)) { $threadId = $this->parseIriId($threadParam); if ($threadId !== null) { $query->where('thread_id', $threadId); } } $posts = $query ->oldest('created_at') ->get() ->map(fn (Post $post) => $this->serializePost($post)); return response()->json($posts); } public function store(Request $request): JsonResponse { $data = $request->validate([ 'body' => ['required', 'string'], 'thread' => ['required', 'string'], ]); $threadId = $this->parseIriId($data['thread']); $thread = Thread::findOrFail($threadId); $post = Post::create([ 'thread_id' => $thread->id, 'user_id' => $request->user()?->id, 'body' => $data['body'], ]); app(AuditLogger::class)->log($request, 'post.created', $post, [ 'thread_id' => $thread->id, ]); $post->loadMissing([ 'user' => fn ($query) => $query ->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived']) ->with(['rank', 'roles']), 'attachments.extension', 'attachments.group', ]); return response()->json($this->serializePost($post), 201); } public function destroy(Request $request, Post $post): JsonResponse { $reason = $request->input('reason'); $reasonText = $request->input('reason_text'); app(AuditLogger::class)->log($request, 'post.deleted', $post, [ 'thread_id' => $post->thread_id, 'reason' => $reason, 'reason_text' => $reasonText, ]); $post->deleted_by = $request->user()?->id; $post->save(); $post->delete(); return response()->json(null, 204); } public function update(Request $request, Post $post): JsonResponse { $user = $request->user(); if (!$user) { return response()->json(['message' => 'Unauthorized.'], 401); } $isAdmin = $user->roles()->where('name', 'ROLE_ADMIN')->exists(); if (!$isAdmin && $post->user_id !== $user->id) { return response()->json(['message' => 'Not authorized to edit posts.'], 403); } $data = $request->validate([ 'body' => ['required', 'string'], ]); $post->body = $data['body']; $post->save(); $post->refresh(); app(AuditLogger::class)->log($request, 'post.edited', $post, [ 'thread_id' => $post->thread_id, ]); $post->loadMissing([ 'user' => fn ($query) => $query ->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived']) ->with(['rank', 'roles']), 'attachments.extension', 'attachments.group', ]); return response()->json($this->serializePost($post)); } private function parseIriId(?string $value): ?int { if (!$value) { return null; } if (preg_match('#/threads/(\d+)$#', $value, $matches)) { return (int) $matches[1]; } if (is_numeric($value)) { return (int) $value; } return null; } private function serializePost(Post $post): array { $attachments = $post->relationLoaded('attachments') ? $post->attachments : collect(); $bodyHtml = $this->renderBody($post->body, $attachments); return [ 'id' => $post->id, 'body' => $post->body, 'body_html' => $bodyHtml, 'thread' => "/api/threads/{$post->thread_id}", 'user_id' => $post->user_id, 'user_name' => $post->user?->name, 'user_avatar_url' => $post->user?->avatar_path ? Storage::url($post->user->avatar_path) : null, 'user_posts_count' => ($post->user?->posts_count ?? 0) + ($post->user?->threads_count ?? 0), 'user_created_at' => $post->user?->created_at?->toIso8601String(), 'user_location' => $post->user?->location, 'user_thanks_given_count' => $post->user?->thanks_given_count ?? 0, 'user_thanks_received_count' => $post->user?->thanks_received_count ?? 0, 'user_rank_name' => $post->user?->rank?->name, 'user_rank_badge_type' => $post->user?->rank?->badge_type, 'user_rank_badge_text' => $post->user?->rank?->badge_text, 'user_rank_badge_url' => $post->user?->rank?->badge_image_path ? Storage::url($post->user->rank->badge_image_path) : null, 'user_rank_color' => $post->user?->rank?->color, 'user_group_color' => $this->resolveGroupColor($post->user), 'created_at' => $post->created_at?->toIso8601String(), 'updated_at' => $post->updated_at?->toIso8601String(), 'attachments' => $post->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; } }