with(['extension', 'group']) ->withoutTrashed(); $threadParam = $request->query('thread'); $postParam = $request->query('post'); if ($threadParam) { $threadId = $this->parseThreadId($threadParam); if ($threadId !== null) { $query->where('thread_id', $threadId); } } if ($postParam) { $postId = $this->parsePostId($postParam); if ($postId !== null) { $query->where('post_id', $postId); } } $attachments = $query ->latest('created_at') ->get() ->map(fn (Attachment $attachment) => $this->serializeAttachment($attachment)); return response()->json($attachments); } public function store(Request $request): JsonResponse { $user = $request->user(); if (!$user) { return response()->json(['message' => 'Unauthorized.'], 401); } $data = $request->validate([ 'thread' => ['nullable', 'string'], 'post' => ['nullable', 'string'], 'file' => ['required', 'file'], ]); $threadId = $this->parseThreadId($data['thread'] ?? null); $postId = $this->parsePostId($data['post'] ?? null); if (($threadId && $postId) || (!$threadId && !$postId)) { return response()->json(['message' => 'Provide either thread or post.'], 422); } $thread = null; $post = null; if ($threadId) { $thread = Thread::query()->findOrFail($threadId); if (!$this->canManageAttachments($user, $thread->user_id)) { return response()->json(['message' => 'Not authorized to add attachments.'], 403); } } if ($postId) { $post = Post::query()->findOrFail($postId); if (!$this->canManageAttachments($user, $post->user_id)) { return response()->json(['message' => 'Not authorized to add attachments.'], 403); } } $file = $request->file('file'); if (!$file) { return response()->json(['message' => 'File missing.'], 422); } $mime = $file->getMimeType() ?? 'application/octet-stream'; $extension = strtolower((string) $file->getClientOriginalExtension()); $extensionRow = $this->resolveExtension($extension); if (!$extensionRow || !$extensionRow->group || !$extensionRow->group->is_active) { return response()->json(['message' => 'File type not allowed.'], 422); } $group = $extensionRow->group; if (!$this->matchesAllowed($mime, $extensionRow->allowed_mimes)) { return response()->json(['message' => 'File type not allowed.'], 422); } $maxSizeBytes = (int) $group->max_size_kb * 1024; if ($file->getSize() > $maxSizeBytes) { return response()->json(['message' => 'File exceeds allowed size.'], 422); } $scopeFolder = $threadId ? "threads/{$threadId}" : "posts/{$postId}"; $filename = Str::uuid()->toString(); if ($extension !== '') { $filename .= ".{$extension}"; } $disk = 'local'; $path = "attachments/{$scopeFolder}/{$filename}"; Storage::disk($disk)->putFileAs("attachments/{$scopeFolder}", $file, $filename); $attachment = Attachment::create([ 'thread_id' => $threadId, 'post_id' => $postId, 'attachment_extension_id' => $extensionRow->id, 'attachment_group_id' => $group->id, 'user_id' => $user->id, 'disk' => $disk, 'path' => $path, 'original_name' => $file->getClientOriginalName(), 'extension' => $extension !== '' ? $extension : null, 'mime_type' => $mime, 'size_bytes' => (int) $file->getSize(), ]); $attachment->loadMissing(['extension', 'group']); return response()->json($this->serializeAttachment($attachment), 201); } public function show(Attachment $attachment): JsonResponse { if (!$this->canViewAttachment($attachment)) { return response()->json(['message' => 'Not found.'], 404); } $attachment->loadMissing(['extension', 'group']); return response()->json($this->serializeAttachment($attachment)); } public function download(Attachment $attachment): Response { if (!$this->canViewAttachment($attachment)) { abort(404); } $disk = Storage::disk($attachment->disk); if (!$disk->exists($attachment->path)) { abort(404); } $mime = $attachment->mime_type ?: 'application/octet-stream'; return $disk->download($attachment->path, $attachment->original_name, [ 'Content-Type' => $mime, ]); } public function destroy(Request $request, Attachment $attachment): JsonResponse { $user = $request->user(); if (!$user) { return response()->json(['message' => 'Unauthorized.'], 401); } if (!$this->canManageAttachments($user, $attachment->user_id)) { return response()->json(['message' => 'Not authorized to delete attachments.'], 403); } $attachment->delete(); return response()->json(null, 204); } private function resolveExtension(string $extension): ?AttachmentExtension { if ($extension === '') { return null; } return AttachmentExtension::query() ->where('extension', strtolower($extension)) ->with('group') ->first(); } private function matchesAllowed(string $value, ?array $allowed): bool { if (!$allowed || count($allowed) === 0) { return true; } $normalized = strtolower(trim($value)); foreach ($allowed as $entry) { if (strtolower(trim((string) $entry)) === $normalized) { return true; } } return false; } private function parseThreadId(?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 parsePostId(?string $value): ?int { if (!$value) { return null; } if (preg_match('#/posts/(\d+)$#', $value, $matches)) { return (int) $matches[1]; } if (is_numeric($value)) { return (int) $value; } return null; } private function canViewAttachment(Attachment $attachment): bool { if ($attachment->trashed()) { return false; } if ($attachment->thread_id) { $thread = Thread::withTrashed()->find($attachment->thread_id); return $thread && !$thread->trashed(); } if ($attachment->post_id) { $post = Post::withTrashed()->find($attachment->post_id); if (!$post || $post->trashed()) { return false; } $thread = Thread::withTrashed()->find($post->thread_id); return $thread && !$thread->trashed(); } return false; } private function canManageAttachments($user, ?int $ownerId): bool { if (!$user) { return false; } if ($user->roles()->where('name', 'ROLE_ADMIN')->exists()) { return true; } return $ownerId !== null && $ownerId === $user->id; } private function serializeAttachment(Attachment $attachment): array { return [ 'id' => $attachment->id, 'thread_id' => $attachment->thread_id, 'post_id' => $attachment->post_id, 'extension' => $attachment->extension, 'group' => $attachment->group ? [ 'id' => $attachment->group->id, 'name' => $attachment->group->name, 'category' => $attachment->group->category, 'max_size_kb' => $attachment->group->max_size_kb, ] : null, 'original_name' => $attachment->original_name, 'mime_type' => $attachment->mime_type, 'size_bytes' => $attachment->size_bytes, 'download_url' => "/api/attachments/{$attachment->id}/download", 'created_at' => $attachment->created_at?->toIso8601String(), ]; } }