fixed gallery

This commit is contained in:
2026-02-22 17:09:34 +01:00
parent 48e2055b6a
commit 5c97488e80
33 changed files with 2062 additions and 550 deletions

View File

@@ -54,12 +54,7 @@ class AvatarService
{
$this->assertImageManagerAvailable();
$this->assertStorageIsAllowed();
$this->assertSecureImageUpload($file);
$binary = file_get_contents($file->getRealPath());
if ($binary === false || $binary === '') {
throw new RuntimeException('Uploaded avatar file is empty or unreadable.');
}
$binary = $this->assertSecureImageUpload($file);
return $this->storeFromBinary($userId, $binary);
}
@@ -230,8 +225,12 @@ class AvatarService
}
}
private function assertSecureImageUpload(UploadedFile $file): void
private function assertSecureImageUpload(UploadedFile $file): string
{
if (! $file->isValid()) {
throw new RuntimeException('Avatar upload is not valid.');
}
$extension = strtolower((string) $file->getClientOriginalExtension());
if (!in_array($extension, self::ALLOWED_EXTENSIONS, true)) {
throw new RuntimeException('Unsupported avatar file extension.');
@@ -242,7 +241,12 @@ class AvatarService
throw new RuntimeException('Unsupported avatar MIME type.');
}
$binary = file_get_contents($file->getRealPath());
$uploadPath = (string) ($file->getRealPath() ?: $file->getPathname());
if ($uploadPath === '' || !is_readable($uploadPath)) {
throw new RuntimeException('Unable to resolve uploaded avatar path.');
}
$binary = file_get_contents($uploadPath);
if ($binary === false || $binary === '') {
throw new RuntimeException('Unable to read uploaded avatar data.');
}
@@ -257,5 +261,7 @@ class AvatarService
if (!is_array($dimensions) || ($dimensions[0] ?? 0) < 1 || ($dimensions[1] ?? 0) < 1) {
throw new RuntimeException('Uploaded avatar is not a valid image.');
}
return $binary;
}
}

View File

@@ -1,28 +1,75 @@
<?php
namespace App\Services;
use App\Services\ThumbnailService;
use App\Models\Artwork;
class ThumbnailPresenter
{
private const MISSING_BASE = 'https://files.skinbase.org/default';
private const WIDTHS = [
'xs' => 160,
'sm' => 320,
'thumb' => 320,
'md' => 640,
'lg' => 1280,
'xl' => 1920,
'sq' => 400,
];
private const HEIGHTS = [
'xs' => 90,
'sm' => 180,
'thumb' => 180,
'md' => 360,
'lg' => 720,
'xl' => 1080,
'sq' => 400,
];
/**
* Present thumbnail data for an item which may be a model or an array.
* Returns ['id' => int|null, 'title' => string, 'url' => string, 'srcset' => string|null]
*/
public static function present($item, string $size = 'md'): array
{
$uext = 'jpg';
$isEloquent = $item instanceof \Illuminate\Database\Eloquent\Model;
$size = self::normalizeSize($size);
$id = null;
$title = '';
if ($item instanceof Artwork) {
$id = $item->id;
$title = (string) $item->title;
$url = self::resolveArtworkUrl($item, $size);
return [
'id' => $id,
'title' => $title,
'url' => $url,
'width' => self::WIDTHS[$size] ?? null,
'height' => self::HEIGHTS[$size] ?? null,
'srcset' => self::buildSrcsetFromArtwork($item),
];
}
$uext = 'webp';
$isEloquent = $item instanceof \Illuminate\Database\Eloquent\Model;
if ($isEloquent) {
$id = $item->id ?? null;
$title = $item->name ?? '';
$title = $item->title ?? ($item->name ?? '');
$url = $item->thumb_url ?? $item->thumb ?? '';
$srcset = $item->thumb_srcset ?? null;
return ['id' => $id, 'title' => $title, 'url' => $url, 'srcset' => $srcset];
if (empty($url)) {
$url = self::missingUrl($size);
}
return [
'id' => $id,
'title' => $title,
'url' => $url,
'width' => self::WIDTHS[$size] ?? null,
'height' => self::HEIGHTS[$size] ?? null,
'srcset' => $srcset,
];
}
// If it's an object but not an Eloquent model (e.g. stdClass row), cast to array
@@ -35,15 +82,87 @@ class ThumbnailPresenter
// If array contains direct hash/thumb_ext, use CDN fromHash
$hash = $item['hash'] ?? null;
$thumbExt = $item['thumb_ext'] ?? ($item['ext'] ?? $uext);
$thumbExt = 'webp';
if (!empty($hash) && !empty($thumbExt)) {
$url = ThumbnailService::fromHash($hash, $thumbExt, $size) ?: ThumbnailService::url(null, $id, $thumbExt, 6);
$srcset = ThumbnailService::srcsetFromHash($hash, $thumbExt);
return ['id' => $id, 'title' => $title, 'url' => $url, 'srcset' => $srcset];
if (empty($url)) {
$url = self::missingUrl($size);
}
return [
'id' => $id,
'title' => $title,
'url' => $url,
'width' => self::WIDTHS[$size] ?? null,
'height' => self::HEIGHTS[$size] ?? null,
'srcset' => $srcset,
];
}
// Fallback: ask ThumbnailService to resolve by id or file path
$url = ThumbnailService::url(null, $id, $uext, 6);
return ['id' => $id, 'title' => $title, 'url' => $url, 'srcset' => null];
if (empty($url)) {
$url = self::missingUrl($size);
}
return [
'id' => $id,
'title' => $title,
'url' => $url,
'width' => self::WIDTHS[$size] ?? null,
'height' => self::HEIGHTS[$size] ?? null,
'srcset' => null,
];
}
public static function srcsetForArtwork(Artwork $artwork): string
{
return self::buildSrcsetFromArtwork($artwork);
}
private static function resolveArtworkUrl(Artwork $artwork, string $size): string
{
$hash = $artwork->hash ?? null;
if (!empty($hash)) {
$url = ThumbnailService::fromHash((string) $hash, 'webp', $size);
if (!empty($url)) {
return $url;
}
}
$filePath = $artwork->file_path ?? $artwork->file_name ?? null;
if (!empty($filePath)) {
$cdn = rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/');
$path = ltrim((string) $filePath, '/');
$pathWithoutExt = preg_replace('/\.[^.]+$/', '', $path);
return sprintf('%s/%s/%s.webp', $cdn, $size, $pathWithoutExt);
}
return self::missingUrl($size);
}
private static function buildSrcsetFromArtwork(Artwork $artwork): string
{
$md = self::resolveArtworkUrl($artwork, 'md');
$lg = self::resolveArtworkUrl($artwork, 'lg');
$xl = self::resolveArtworkUrl($artwork, 'xl');
return implode(', ', [
$md . ' 640w',
$lg . ' 1280w',
$xl . ' 1920w',
]);
}
private static function normalizeSize(string $size): string
{
$size = strtolower(trim($size));
return array_key_exists($size, self::WIDTHS) ? $size : 'md';
}
private static function missingUrl(string $size): string
{
return sprintf('%s/missing_%s.webp', self::MISSING_BASE, $size);
}
}

View File

@@ -1,20 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Services;
use Illuminate\Support\Facades\Storage;
class ThumbnailService
{
// Use the thumbnails CDN host (HTTPS)
protected const CDN_HOST = 'https://files.skinbase.org';
/**
* CDN host is read from config/cdn.php FILES_CDN_URL env.
* Hardcoding the domain is forbidden per upload-agent spec §3A.
*/
protected static function cdnHost(): string
{
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/');
}
protected const VALID_SIZES = ['sm','md','lg','xl'];
/**
* Canonical size keys (upload-agent spec §8): thumb · sq · md · lg · xl
* 'sm' is kept as a backwards-compatible alias for 'thumb'.
*/
protected const VALID_SIZES = ['thumb', 'sq', 'sm', 'md', 'lg', 'xl'];
/** Size aliases: legacy 'sm' maps to the 'thumb' CDN directory. */
protected const SIZE_ALIAS = ['sm' => 'thumb'];
protected const THUMB_SIZES = [
'sm' => ['height' => 240, 'quality' => 78, 'dir' => 'sm'],
'md' => ['height' => 360, 'quality' => 82, 'dir' => 'md'],
'lg' => ['height' => 1200, 'quality' => 85, 'dir' => 'lg'],
'xl' => ['height' => 2400, 'quality' => 90, 'dir' => 'xl'],
'thumb' => ['height' => 320, 'quality' => 78, 'dir' => 'thumb'],
'sq' => ['height' => 512, 'quality' => 82, 'dir' => 'sq', 'square' => true],
'sm' => ['height' => 320, 'quality' => 78, 'dir' => 'thumb'], // alias for thumb
'md' => ['height' => 1024, 'quality' => 82, 'dir' => 'md'],
'lg' => ['height' => 1920, 'quality' => 85, 'dir' => 'lg'],
'xl' => ['height' => 2560, 'quality' => 90, 'dir' => 'xl'],
];
/**
@@ -26,7 +44,7 @@ class ThumbnailService
{
// If $filePath seems to be a content hash and $ext is provided, build directly
if (!empty($filePath) && !empty($ext) && preg_match('/^[0-9a-f]{16,128}$/i', $filePath)) {
$sizeKey = is_string($size) ? $size : (($size === 4) ? 'sm' : 'md');
$sizeKey = is_string($size) ? $size : (($size === 4) ? 'thumb' : 'md');
return self::fromHash($filePath, $ext, $sizeKey) ?: '';
}
@@ -39,7 +57,7 @@ class ThumbnailService
if ($art) {
$hash = $art->hash ?? null;
$extToUse = $ext ?? ($art->thumb_ext ?? null);
$sizeKey = is_string($size) ? $size : (($size === 4) ? 'sm' : 'md');
$sizeKey = is_string($size) ? $size : (($size === 4) ? 'thumb' : 'md');
if (!empty($hash) && !empty($extToUse)) {
return self::fromHash($hash, $extToUse, $sizeKey) ?: '';
}
@@ -68,11 +86,14 @@ class ThumbnailService
public static function fromHash(?string $hash, ?string $ext, string $sizeKey = 'md'): ?string
{
if (empty($hash) || empty($ext)) return null;
// Resolve alias (sm → thumb) then validate
$sizeKey = self::SIZE_ALIAS[$sizeKey] ?? $sizeKey;
$sizeKey = in_array($sizeKey, self::VALID_SIZES) ? $sizeKey : 'md';
$h = $hash;
$dir = self::THUMB_SIZES[$sizeKey]['dir'] ?? $sizeKey;
$h = $hash;
$h1 = substr($h, 0, 2);
$h2 = substr($h, 2, 2);
return sprintf('%s/%s/%s/%s/%s.%s', rtrim(self::CDN_HOST, '/'), $sizeKey, $h1, $h2, $h, $ext);
return sprintf('%s/%s/%s/%s/%s.%s', self::cdnHost(), $dir, $h1, $h2, $h, $ext);
}
/**
@@ -80,9 +101,9 @@ class ThumbnailService
*/
public static function srcsetFromHash(?string $hash, ?string $ext): ?string
{
$a = self::fromHash($hash, $ext, 'sm');
$b = self::fromHash($hash, $ext, 'md');
$a = self::fromHash($hash, $ext, 'thumb'); // 320px
$b = self::fromHash($hash, $ext, 'md'); // 1024px
if (!$a || !$b) return null;
return $a . ' 320w, ' . $b . ' 600w';
return $a . ' 320w, ' . $b . ' 1024w';
}
}