480, 'md' => 960, ]; private ?ImageManager $manager = null; public function __construct() { try { $this->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']), 'thumb_path' => $stored['thumb_path'], 'thumb_url' => $this->publicUrlForPath($stored['thumb_path']), 'thumb_width' => $stored['thumb_width'], 'thumb_height' => $stored['thumb_height'], 'medium_path' => $stored['medium_path'], 'medium_url' => $stored['medium_path'] !== '' ? $this->publicUrlForPath($stored['medium_path']) : null, 'medium_width' => $stored['medium_width'], 'medium_height' => $stored['medium_height'], 'srcset' => $this->buildResponsiveSrcset([ ['path' => $stored['thumb_path'], 'width' => $stored['thumb_width']], ['path' => $stored['medium_path'], 'width' => $stored['medium_width']], ]), '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,thumb_path:string,thumb_width:int,thumb_height:int,medium_path:string,medium_width:int|null,medium_height:int|null,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'], )); } $encodedImage = $this->encodeScaledMedia($raw, $constraints['max_width'], $constraints['max_height']); $encoded = $encodedImage['binary']; $hash = hash('sha256', $encoded); $path = $this->mediaPath($hash, $slot); $disk = Storage::disk($this->mediaDiskName()); $this->writeMediaBinary($disk, $path, $encoded); $thumbVariant = $this->storeResponsiveVariant( $disk, $raw, $constraints, $path, 'thumb', self::RESPONSIVE_VARIANT_WIDTHS['thumb'], $encodedImage['width'], $encodedImage['height'], ); $mediumVariant = $this->storeResponsiveVariant( $disk, $raw, $constraints, $path, 'md', self::RESPONSIVE_VARIANT_WIDTHS['md'], $encodedImage['width'], $encodedImage['height'], ); return [ 'path' => $path, 'thumb_path' => $thumbVariant['path'] ?? $path, 'thumb_width' => $thumbVariant['width'] ?? $encodedImage['width'], 'thumb_height' => $thumbVariant['height'] ?? $encodedImage['height'], 'medium_path' => $mediumVariant['path'] ?? '', 'medium_width' => $mediumVariant['width'] ?? null, 'medium_height' => $mediumVariant['height'] ?? null, 'width' => $encodedImage['width'], 'height' => $encodedImage['height'], 'size_bytes' => strlen($encoded), ]; } /** * @return array{binary:string,width:int,height:int} */ private function encodeScaledMedia(string $raw, int $maxWidth, int $maxHeight): array { $image = $this->manager->read($raw)->scaleDown(width: $maxWidth, height: $maxHeight); $encoded = (string) $image->encode(new WebpEncoder(85)); if ($encoded === '') { throw new RuntimeException('Unable to encode image to WebP.'); } return [ 'binary' => $encoded, 'width' => (int) $image->width(), 'height' => (int) $image->height(), ]; } /** * @param array{max_width:int,max_height:int} $constraints * @return array{path:string,width:int,height:int}|null */ private function storeResponsiveVariant($disk, string $raw, array $constraints, string $path, string $variant, int $targetWidth, int $sourceWidth, int $sourceHeight): ?array { if ($sourceWidth <= $targetWidth && $sourceHeight <= $constraints['max_height']) { return null; } $encodedVariant = $this->encodeScaledMedia($raw, $targetWidth, $constraints['max_height']); if ($encodedVariant['width'] >= $sourceWidth && $encodedVariant['height'] >= $sourceHeight) { return null; } $variantPath = $this->responsiveVariantPath($path, $variant); $this->writeMediaBinary($disk, $variantPath, $encodedVariant['binary']); return [ 'path' => $variantPath, 'width' => $encodedVariant['width'], 'height' => $encodedVariant['height'], ]; } private function writeMediaBinary($disk, string $path, string $binary): void { $written = $disk->put($path, $binary, [ 'visibility' => 'public', 'CacheControl' => 'public, max-age=31536000, immutable', 'ContentType' => 'image/webp', ]); if ($written !== true) { throw new RuntimeException('Unable to store image in object storage.'); } } 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, '/'); } /** * @param array $variants */ private function buildResponsiveSrcset(array $variants): ?string { $entries = collect($variants) ->filter(function (array $variant): bool { return trim((string) ($variant['path'] ?? '')) !== '' && (int) ($variant['width'] ?? 0) > 0; }) ->unique(fn (array $variant): string => trim((string) ($variant['path'] ?? ''))) ->map(fn (array $variant): string => sprintf('%s %dw', $this->publicUrlForPath((string) $variant['path']), (int) $variant['width'])) ->values() ->all(); return $entries !== [] ? implode(', ', $entries) : null; } private function responsiveVariantPath(string $path, string $variant): string { $directory = pathinfo($path, PATHINFO_DIRNAME); $filename = pathinfo($path, PATHINFO_FILENAME); return sprintf( '%s/%s-%s.webp', $directory === '.' ? '' : $directory, preg_replace('/-(thumb|md)$/', '', $filename) ?? $filename, $variant, ); } private function canonicalMediaPath(string $path): string { $directory = pathinfo($path, PATHINFO_DIRNAME); $filename = pathinfo($path, PATHINFO_FILENAME); $baseFilename = preg_replace('/-(thumb|md)$/', '', $filename) ?? $filename; return sprintf( '%s/%s.webp', $directory === '.' ? '' : $directory, $baseFilename, ); } private function isResponsiveVariantPath(string $path): bool { return preg_match('/-(thumb|md)\.webp$/i', $path) === 1; } 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'])) ->reject(fn (string $path): bool => $this->isResponsiveVariantPath($path)) ->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; } $basePath = $this->canonicalMediaPath($trimmed); $paths = [ $basePath, $this->responsiveVariantPath($basePath, 'thumb'), $this->responsiveVariantPath($basePath, 'md'), ]; Storage::disk($this->mediaDiskName())->delete(array_values(array_unique($paths))); } 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' => 600, 'min_height' => 315, '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.'); } } }