manager = extension_loaded('gd') ? new ImageManager(new GdDriver()) : new ImageManager(new ImagickDriver()); } catch (\Throwable) { $this->manager = null; } } public function upload(Request $request): JsonResponse { $user = $request->user(); if (! $user) { return response()->json(['error' => 'Unauthorized'], 401); } $validated = $request->validate([ 'cover' => [ '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['cover']; try { $stored = $this->storeCoverFile($file); $this->deleteCoverFile((string) $user->cover_hash, (string) $user->cover_ext); $user->forceFill([ 'cover_hash' => $stored['hash'], 'cover_ext' => $stored['ext'], 'cover_position' => 50, ])->save(); return response()->json([ 'success' => true, 'cover_url' => CoverUrl::forUser($user->cover_hash, $user->cover_ext, time()), 'cover_position' => (int) $user->cover_position, ]); } catch (RuntimeException $e) { return response()->json([ 'error' => 'Validation failed', 'message' => $e->getMessage(), ], 422); } catch (\Throwable $e) { logger()->error('Profile cover upload failed', [ 'user_id' => (int) $user->id, 'message' => $e->getMessage(), ]); return response()->json(['error' => 'Processing failed'], 500); } } public function updatePosition(Request $request): JsonResponse { $user = $request->user(); if (! $user) { return response()->json(['error' => 'Unauthorized'], 401); } $validated = $request->validate([ 'position' => ['required', 'integer', 'min:0', 'max:100'], ]); if (! $user->cover_hash || ! $user->cover_ext) { return response()->json(['error' => 'No cover image to update.'], 422); } $user->forceFill([ 'cover_position' => (int) $validated['position'], ])->save(); return response()->json([ 'success' => true, 'cover_position' => (int) $user->cover_position, ]); } public function destroy(Request $request): JsonResponse { $user = $request->user(); if (! $user) { return response()->json(['error' => 'Unauthorized'], 401); } $this->deleteCoverFile((string) $user->cover_hash, (string) $user->cover_ext); $user->forceFill([ 'cover_hash' => null, 'cover_ext' => null, 'cover_position' => 50, ])->save(); return response()->json([ 'success' => true, 'cover_url' => null, 'cover_position' => 50, ]); } private function storageRoot(): string { return rtrim((string) config('uploads.storage_root'), DIRECTORY_SEPARATOR); } private function coverDirectory(string $hash): string { $p1 = substr($hash, 0, 2); $p2 = substr($hash, 2, 2); return $this->storageRoot() . DIRECTORY_SEPARATOR . 'covers' . DIRECTORY_SEPARATOR . $p1 . DIRECTORY_SEPARATOR . $p2; } private function coverPath(string $hash, string $ext): string { return $this->coverDirectory($hash) . DIRECTORY_SEPARATOR . $hash . '.' . $ext; } /** * @return array{hash: string, ext: string} */ private function storeCoverFile(UploadedFile $file): array { $this->assertImageManager(); $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_UPLOAD_WIDTH || $height < self::MIN_UPLOAD_HEIGHT) { throw new RuntimeException(sprintf( 'Image is too small. Minimum required size is %dx%d.', self::MIN_UPLOAD_WIDTH, self::MIN_UPLOAD_HEIGHT, )); } $ext = $mime === 'image/jpeg' ? 'jpg' : ($mime === 'image/png' ? 'png' : 'webp'); $image = $this->manager->read($raw); $processed = $image->cover(self::TARGET_WIDTH, self::TARGET_HEIGHT, 'center'); $encoded = $this->encodeByExtension($processed, $ext); $hash = hash('sha256', $encoded); $dir = $this->coverDirectory($hash); if (! File::exists($dir)) { File::makeDirectory($dir, 0755, true); } File::put($this->coverPath($hash, $ext), $encoded); return ['hash' => $hash, 'ext' => $ext]; } private function encodeByExtension($image, string $ext): string { return match ($ext) { 'jpg' => (string) $image->encode(new JpegEncoder(85)), 'png' => (string) $image->encode(new PngEncoder()), default => (string) $image->encode(new WebpEncoder(85)), }; } private function deleteCoverFile(string $hash, string $ext): void { $trimHash = trim($hash); $trimExt = strtolower(trim($ext)); if ($trimHash === '' || $trimExt === '') { return; } $path = $this->coverPath($trimHash, $trimExt); if (is_file($path)) { @unlink($path); } } private function assertImageManager(): void { if ($this->manager !== null) { return; } throw new RuntimeException('Image processing is not available on this environment.'); } }