manager = extension_loaded('gd') ? new ImageManager(new GdDriver()) : new ImageManager(new ImagickDriver()); } catch (\Throwable) { $this->manager = null; } } public function store(Request $request): JsonResponse { $this->authorizeNews($request); $validated = $request->validate([ 'image' => [ 'required', 'file', 'image', 'max:' . self::MAX_FILE_SIZE_KB, 'mimes:jpg,jpeg,png,webp', 'mimetypes:image/jpeg,image/png,image/webp', ], ]); /** @var UploadedFile $file */ $file = $validated['image']; try { $stored = $this->storeMediaFile($file); return response()->json([ 'success' => true, 'path' => $stored['path'], 'url' => $this->publicUrlForPath($stored['path']), 'width' => $stored['width'], 'height' => $stored['height'], 'mime_type' => 'image/webp', 'size_bytes' => $stored['size_bytes'], ]); } catch (RuntimeException $e) { return response()->json([ 'error' => 'Validation failed', 'message' => $e->getMessage(), ], 422); } catch (\Throwable $e) { logger()->error('News media upload failed', [ 'user_id' => (int) ($request->user()?->id ?? 0), 'message' => $e->getMessage(), ]); return response()->json([ 'error' => 'Upload failed', 'message' => 'Could not upload image right now.', ], 500); } } public function destroy(Request $request): JsonResponse { $this->authorizeNews($request); $validated = $request->validate([ 'path' => ['required', 'string', 'max:2048'], ]); $this->deleteMediaFile((string) $validated['path']); return response()->json([ 'success' => true, ]); } /** * @return array{path:string,width:int,height:int,size_bytes:int} */ private function storeMediaFile(UploadedFile $file): array { $this->assertImageManager(); $this->assertStorageIsAllowed(); $uploadPath = (string) ($file->getRealPath() ?: $file->getPathname()); if ($uploadPath === '' || ! is_readable($uploadPath)) { throw new RuntimeException('Unable to resolve uploaded image path.'); } $raw = file_get_contents($uploadPath); if ($raw === false || $raw === '') { throw new RuntimeException('Unable to read uploaded image.'); } $finfo = new \finfo(FILEINFO_MIME_TYPE); $mime = strtolower((string) $finfo->buffer($raw)); if (! in_array($mime, self::ALLOWED_MIME_TYPES, true)) { throw new RuntimeException('Unsupported image mime type.'); } $size = @getimagesizefromstring($raw); if (! is_array($size) || ($size[0] ?? 0) < 1 || ($size[1] ?? 0) < 1) { throw new RuntimeException('Uploaded file is not a valid image.'); } $width = (int) ($size[0] ?? 0); $height = (int) ($size[1] ?? 0); if ($width < self::MIN_WIDTH || $height < self::MIN_HEIGHT) { throw new RuntimeException(sprintf( 'Image is too small. Minimum required size is %dx%d.', self::MIN_WIDTH, self::MIN_HEIGHT, )); } $image = $this->manager->read($raw)->scaleDown(width: self::MAX_WIDTH, height: self::MAX_HEIGHT); $encoded = (string) $image->encode(new WebpEncoder(85)); $hash = hash('sha256', $encoded); $path = $this->mediaPath($hash); $disk = Storage::disk($this->mediaDiskName()); $written = $disk->put($path, $encoded, [ 'visibility' => 'public', 'CacheControl' => 'public, max-age=31536000, immutable', 'ContentType' => 'image/webp', ]); if ($written !== true) { throw new RuntimeException('Unable to store image in object storage.'); } return [ 'path' => $path, 'width' => (int) $image->width(), 'height' => (int) $image->height(), 'size_bytes' => strlen($encoded), ]; } private function authorizeNews(Request $request): void { abort_unless($request->user() && ($request->user()->isAdmin() || $request->user()->isModerator()), 403); } private function mediaDiskName(): string { return (string) config('uploads.object_storage.disk', 's3'); } private function mediaPath(string $hash): string { return sprintf( 'news/covers/%s/%s/%s.webp', substr($hash, 0, 2), substr($hash, 2, 2), $hash, ); } private function publicUrlForPath(string $path): string { return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/'); } private function deleteMediaFile(string $path): void { $trimmed = ltrim(trim($path), '/'); if ($trimmed === '' || ! Str::startsWith($trimmed, 'news/covers/')) { return; } Storage::disk($this->mediaDiskName())->delete($trimmed); } private function assertImageManager(): void { if ($this->manager !== null) { return; } throw new RuntimeException('Image processing is not available on this environment.'); } private function assertStorageIsAllowed(): void { if (! app()->environment('production')) { return; } $diskName = $this->mediaDiskName(); if (in_array($diskName, ['local', 'public'], true)) { throw new RuntimeException('Production news media storage must use object storage, not local/public disks.'); } } }