Implement academy analytics, billing, and web stories updates

This commit is contained in:
2026-05-26 07:27:29 +02:00
parent 456c3d6bb0
commit 0b33a1b074
177 changed files with 27360 additions and 2685 deletions

View File

@@ -26,6 +26,11 @@ final class AcademyLessonMediaApiController extends Controller
private const ASSET_CACHE_TTL_MINUTES = 15;
private const RESPONSIVE_VARIANT_WIDTHS = [
'thumb' => 480,
'md' => 960,
];
private ?ImageManager $manager = null;
public function __construct()
@@ -68,6 +73,18 @@ final class AcademyLessonMediaApiController extends Controller
'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',
@@ -161,7 +178,7 @@ final class AcademyLessonMediaApiController extends Controller
}
/**
* @return array{path:string,width:int,height:int,size_bytes:int}
* @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
{
@@ -202,14 +219,99 @@ final class AcademyLessonMediaApiController extends Controller
));
}
$image = $this->manager->read($raw)->scaleDown(width: $constraints['max_width'], height: $constraints['max_height']);
$encoded = (string) $image->encode(new WebpEncoder(85));
$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());
$written = $disk->put($path, $encoded, [
$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',
@@ -218,13 +320,6 @@ final class AcademyLessonMediaApiController extends Controller
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
@@ -255,6 +350,54 @@ final class AcademyLessonMediaApiController extends Controller
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/');
}
/**
* @param array<int, array{path:string,width:int|null}> $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 {
@@ -262,6 +405,7 @@ final class AcademyLessonMediaApiController extends Controller
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;
@@ -323,7 +467,14 @@ final class AcademyLessonMediaApiController extends Controller
return;
}
Storage::disk($this->mediaDiskName())->delete($trimmed);
$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
@@ -346,8 +497,8 @@ final class AcademyLessonMediaApiController extends Controller
}
return [
'min_width' => 1200,
'min_height' => 630,
'min_width' => 600,
'min_height' => 315,
'max_width' => 2200,
'max_height' => 1400,
];