manager = extension_loaded('gd') ? new ImageManager(new GdDriver()) : new ImageManager(new ImagickDriver()); } catch (\Throwable) { $this->manager = null; } } public function store(Request $request): JsonResponse { $this->authorizeStaff($request); $validated = $request->validate([ 'slot' => ['nullable', 'string', 'in:cover,body'], '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']; $slot = $this->normalizeSlot($validated['slot'] ?? null); try { $stored = $this->storeMediaFile($file, $slot); $this->forgetAssetCache(); return response()->json([ 'success' => true, 'slot' => $slot, '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('Academy lesson media upload failed', [ 'user_id' => (int) ($request->user()?->id ?? 0), 'message' => $e->getMessage(), ]); return response()->json([ 'error' => 'Upload failed', 'message' => 'Could not upload lesson media right now.', ], 500); } } public function destroy(Request $request): JsonResponse { $this->authorizeStaff($request); $validated = $request->validate([ 'path' => ['required', 'string', 'max:2048'], ]); $this->deleteMediaFile((string) $validated['path']); $this->forgetAssetCache(); return response()->json([ 'success' => true, ]); } public function assets(Request $request): JsonResponse { $this->authorizeStaff($request); $validated = $request->validate([ 'limit' => ['nullable', 'integer', 'min:1', 'max:48'], 'page' => ['nullable', 'integer', 'min:1'], 'q' => ['nullable', 'string', 'max:100'], ]); $limit = (int) ($validated['limit'] ?? 24); $page = (int) ($validated['page'] ?? 1); $query = Str::lower(trim((string) ($validated['q'] ?? ''))); $manifest = $this->academyAssetManifest(); if ($query !== '') { $manifest = $manifest->filter(function (array $item) use ($query): bool { return Str::contains($item['search_text'], $query); })->values(); } $total = $manifest->count(); $lastPage = max(1, (int) ceil(max($total, 1) / max($limit, 1))); $page = min(max($page, 1), $lastPage); $items = $manifest ->forPage($page, $limit) ->values() ->map(function (array $item): array { return [ 'path' => $item['path'], 'url' => $item['url'], 'name' => $item['name'], 'slot' => $item['slot'], 'modified_at' => $item['modified_at'] ? now()->setTimestamp($item['modified_at'])->toIso8601String() : null, ]; }) ->all(); return response()->json([ 'success' => true, 'items' => $items, 'pagination' => [ 'page' => $page, 'per_page' => $limit, 'total' => $total, 'last_page' => $lastPage, 'has_more' => $page < $lastPage, ], ]); } /** * @return array{path:string,width:int,height:int,size_bytes:int} */ private function storeMediaFile(UploadedFile $file, string $slot): array { $this->assertImageManager(); $this->assertStorageIsAllowed(); $constraints = $this->mediaConstraints($slot); $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 < $constraints['min_width'] || $height < $constraints['min_height']) { throw new RuntimeException(sprintf( 'Image is too small. Minimum required size is %dx%d.', $constraints['min_width'], $constraints['min_height'], )); } $image = $this->manager->read($raw)->scaleDown(width: $constraints['max_width'], height: $constraints['max_height']); $encoded = (string) $image->encode(new WebpEncoder(85)); $hash = hash('sha256', $encoded); $path = $this->mediaPath($hash, $slot); $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 authorizeStaff(Request $request): void { abort_unless((bool) $request->user()?->hasStaffAccess(), 403); } private function mediaDiskName(): string { return (string) config('uploads.object_storage.disk', 's3'); } private function mediaPath(string $hash, string $slot): string { $folder = $slot === 'body' ? 'body' : 'covers'; return sprintf( 'academy/lessons/%s/%s/%s/%s.webp', $folder, 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 academyAssetManifest(): Collection { return Cache::remember($this->academyAssetCacheKey(), now()->addMinutes(self::ASSET_CACHE_TTL_MINUTES), function (): Collection { $disk = Storage::disk($this->mediaDiskName()); return collect($disk->allFiles('academy/lessons')) ->filter(fn (string $path): bool => Str::endsWith(Str::lower($path), ['.webp', '.jpg', '.jpeg', '.png'])) ->map(function (string $path) use ($disk): array { $modifiedAt = null; try { $modifiedAt = $disk->lastModified($path); } catch (\Throwable) { $modifiedAt = null; } $folder = Str::contains($path, '/body/') ? 'body' : (Str::contains($path, '/covers/') ? 'cover' : 'asset'); return [ 'path' => $path, 'url' => $this->publicUrlForPath($path), 'name' => $this->humanAssetName($path), 'slot' => $folder, 'modified_at' => $modifiedAt ? (int) $modifiedAt : null, 'search_text' => Str::lower(implode(' ', [$path, $folder, $this->humanAssetName($path)])), ]; }) ->sortByDesc(fn (array $item): int => (int) ($item['modified_at'] ?? 0)) ->values(); }); } private function academyAssetCacheKey(): string { return 'academy.lesson.assets.' . md5($this->mediaDiskName()); } private function forgetAssetCache(): void { Cache::forget($this->academyAssetCacheKey()); } private function humanAssetName(string $path): string { $filename = pathinfo($path, PATHINFO_FILENAME); $clean = trim(str_replace(['-', '_'], ' ', $filename)); return $clean !== '' ? Str::headline($clean) : 'Academy image'; } private function safeFileSize($disk, string $path): ?int { try { $size = $disk->size($path); return is_int($size) ? $size : null; } catch (\Throwable) { return null; } } private function deleteMediaFile(string $path): void { $trimmed = ltrim(trim($path), '/'); if ($trimmed === '' || ! Str::startsWith($trimmed, ['academy/lessons/covers/', 'academy/lessons/body/'])) { return; } Storage::disk($this->mediaDiskName())->delete($trimmed); } private function normalizeSlot(mixed $slot): string { return Str::lower(trim((string) $slot)) === 'body' ? 'body' : 'cover'; } /** * @return array{min_width:int,min_height:int,max_width:int,max_height:int} */ private function mediaConstraints(string $slot): array { if ($slot === 'body') { return [ 'min_width' => 64, 'min_height' => 64, 'max_width' => 2400, 'max_height' => 2400, ]; } return [ 'min_width' => 1200, 'min_height' => 630, 'max_width' => 2200, 'max_height' => 1400, ]; } private function assertImageManager(): void { if ($this->manager !== null) { return; } throw new RuntimeException('Image processing is not available on this environment.'); } private function assertStorageIsAllowed(): void { $disk = Storage::disk($this->mediaDiskName()); if (! method_exists($disk, 'put')) { throw new RuntimeException('Object storage is not configured for academy lesson uploads.'); } } }