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); $thumbnailPayload = $this->maybeCreateThumbnail($file, $scopeFolder); $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, 'thumbnail_path' => $thumbnailPayload['path'] ?? null, 'thumbnail_mime_type' => $thumbnailPayload['mime'] ?? null, 'thumbnail_size_bytes' => $thumbnailPayload['size'] ?? null, '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 thumbnail(Attachment $attachment): Response { if (!$this->canViewAttachment($attachment)) { abort(404); } if (!$attachment->thumbnail_path) { abort(404); } $disk = Storage::disk($attachment->disk); if (!$disk->exists($attachment->thumbnail_path)) { abort(404); } $mime = $attachment->thumbnail_mime_type ?: 'image/jpeg'; return $disk->response($attachment->thumbnail_path, null, [ '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 { $isImage = str_starts_with((string) $attachment->mime_type, 'image/'); 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", 'thumbnail_url' => $attachment->thumbnail_path ? "/api/attachments/{$attachment->id}/thumbnail" : null, 'is_image' => $isImage, 'created_at' => $attachment->created_at?->toIso8601String(), ]; } private function maybeCreateThumbnail($file, string $scopeFolder): ?array { $enabled = $this->settingBool('attachments.create_thumbnails', true); if (!$enabled) { return null; } $mime = $file->getMimeType() ?? ''; if (!str_starts_with($mime, 'image/')) { return null; } $maxWidth = $this->settingInt('attachments.thumbnail_max_width', 300); $maxHeight = $this->settingInt('attachments.thumbnail_max_height', 300); if ($maxWidth <= 0 || $maxHeight <= 0) { return null; } $sourcePath = $file->getPathname(); $info = @getimagesize($sourcePath); if (!$info) { return null; } [$width, $height] = $info; if ($width <= 0 || $height <= 0) { return null; } if ($width <= $maxWidth && $height <= $maxHeight) { return null; } $ratio = min($maxWidth / $width, $maxHeight / $height); $targetWidth = max(1, (int) round($width * $ratio)); $targetHeight = max(1, (int) round($height * $ratio)); $sourceImage = $this->createImageFromFile($sourcePath, $mime); if (!$sourceImage) { return null; } $thumbImage = imagecreatetruecolor($targetWidth, $targetHeight); if (!$thumbImage) { imagedestroy($sourceImage); return null; } if (in_array($mime, ['image/png', 'image/gif'], true)) { imagecolortransparent($thumbImage, imagecolorallocatealpha($thumbImage, 0, 0, 0, 127)); imagealphablending($thumbImage, false); imagesavealpha($thumbImage, true); } imagecopyresampled( $thumbImage, $sourceImage, 0, 0, 0, 0, $targetWidth, $targetHeight, $width, $height ); $quality = $this->settingInt('attachments.thumbnail_quality', 85); $thumbBinary = $this->renderImageBinary($thumbImage, $mime, $quality); imagedestroy($sourceImage); imagedestroy($thumbImage); if ($thumbBinary === null) { return null; } $filename = Str::uuid()->toString(); $extension = strtolower((string) $file->getClientOriginalExtension()); if ($extension !== '') { $filename .= ".{$extension}"; } $disk = 'local'; $thumbPath = "attachments/{$scopeFolder}/thumbs/{$filename}"; Storage::disk($disk)->put($thumbPath, $thumbBinary); return [ 'path' => $thumbPath, 'mime' => $mime, 'size' => strlen($thumbBinary), ]; } private function createImageFromFile(string $path, string $mime) { return match ($mime) { 'image/jpeg', 'image/jpg' => @imagecreatefromjpeg($path), 'image/png' => @imagecreatefrompng($path), 'image/gif' => @imagecreatefromgif($path), 'image/webp' => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($path) : null, default => null, }; } private function renderImageBinary($image, string $mime, int $quality): ?string { ob_start(); $success = false; if (in_array($mime, ['image/jpeg', 'image/jpg'], true)) { $success = imagejpeg($image, null, max(10, min(95, $quality))); } elseif ($mime === 'image/png') { $compression = (int) round(9 - (max(10, min(95, $quality)) / 100) * 9); $success = imagepng($image, null, $compression); } elseif ($mime === 'image/gif') { $success = imagegif($image); } elseif ($mime === 'image/webp' && function_exists('imagewebp')) { $success = imagewebp($image, null, max(10, min(95, $quality))); } $data = ob_get_clean(); if (!$success) { return null; } return $data !== false ? $data : null; } private function settingBool(string $key, bool $default): bool { $value = Setting::query()->where('key', $key)->value('value'); if ($value === null) { return $default; } return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true); } private function settingInt(string $key, int $default): int { $value = Setting::query()->where('key', $key)->value('value'); if ($value === null) { return $default; } return (int) $value; } }