483 lines
15 KiB
PHP
483 lines
15 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Models\Attachment;
|
|
use App\Models\AttachmentExtension;
|
|
use App\Models\Post;
|
|
use App\Models\Setting;
|
|
use App\Models\Thread;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
|
|
class AttachmentController extends Controller
|
|
{
|
|
public function index(Request $request): JsonResponse
|
|
{
|
|
$query = Attachment::query()
|
|
->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;
|
|
}
|
|
}
|