fixed gallery
This commit is contained in:
193
app/Http/Controllers/Api/ArtworkInteractionController.php
Normal file
193
app/Http/Controllers/Api/ArtworkInteractionController.php
Normal file
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
final class ArtworkInteractionController extends Controller
|
||||
{
|
||||
public function favorite(Request $request, int $artworkId): JsonResponse
|
||||
{
|
||||
$this->toggleSimple(
|
||||
request: $request,
|
||||
table: 'user_favorites',
|
||||
keyColumns: ['user_id', 'artwork_id'],
|
||||
keyValues: ['user_id' => (int) $request->user()->id, 'artwork_id' => $artworkId],
|
||||
insertPayload: ['created_at' => now()],
|
||||
requiredTable: 'user_favorites'
|
||||
);
|
||||
|
||||
$this->syncArtworkStats($artworkId);
|
||||
|
||||
return response()->json($this->statusPayload((int) $request->user()->id, $artworkId));
|
||||
}
|
||||
|
||||
public function like(Request $request, int $artworkId): JsonResponse
|
||||
{
|
||||
$this->toggleSimple(
|
||||
request: $request,
|
||||
table: 'artwork_likes',
|
||||
keyColumns: ['user_id', 'artwork_id'],
|
||||
keyValues: ['user_id' => (int) $request->user()->id, 'artwork_id' => $artworkId],
|
||||
insertPayload: ['created_at' => now(), 'updated_at' => now()],
|
||||
requiredTable: 'artwork_likes'
|
||||
);
|
||||
|
||||
$this->syncArtworkStats($artworkId);
|
||||
|
||||
return response()->json($this->statusPayload((int) $request->user()->id, $artworkId));
|
||||
}
|
||||
|
||||
public function report(Request $request, int $artworkId): JsonResponse
|
||||
{
|
||||
if (! Schema::hasTable('artwork_reports')) {
|
||||
return response()->json(['message' => 'Reporting unavailable'], 422);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'reason' => ['nullable', 'string', 'max:1000'],
|
||||
]);
|
||||
|
||||
DB::table('artwork_reports')->updateOrInsert(
|
||||
[
|
||||
'artwork_id' => $artworkId,
|
||||
'reporter_user_id' => (int) $request->user()->id,
|
||||
],
|
||||
[
|
||||
'reason' => trim((string) ($data['reason'] ?? '')) ?: null,
|
||||
'reported_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]
|
||||
);
|
||||
|
||||
return response()->json(['ok' => true, 'reported' => true]);
|
||||
}
|
||||
|
||||
public function follow(Request $request, int $userId): JsonResponse
|
||||
{
|
||||
if (! Schema::hasTable('friends_list')) {
|
||||
return response()->json(['message' => 'Follow unavailable'], 422);
|
||||
}
|
||||
|
||||
$actorId = (int) $request->user()->id;
|
||||
if ($actorId === $userId) {
|
||||
return response()->json(['message' => 'Cannot follow yourself'], 422);
|
||||
}
|
||||
|
||||
$state = $request->boolean('state', true);
|
||||
|
||||
$query = DB::table('friends_list')
|
||||
->where('user_id', $actorId)
|
||||
->where('friend_id', $userId);
|
||||
|
||||
if ($state) {
|
||||
if (! $query->exists()) {
|
||||
DB::table('friends_list')->insert([
|
||||
'user_id' => $actorId,
|
||||
'friend_id' => $userId,
|
||||
'date_added' => now(),
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
$query->delete();
|
||||
}
|
||||
|
||||
$followersCount = (int) DB::table('friends_list')
|
||||
->where('friend_id', $userId)
|
||||
->count();
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'is_following' => $state,
|
||||
'followers_count' => $followersCount,
|
||||
]);
|
||||
}
|
||||
|
||||
private function toggleSimple(
|
||||
Request $request,
|
||||
string $table,
|
||||
array $keyColumns,
|
||||
array $keyValues,
|
||||
array $insertPayload,
|
||||
string $requiredTable
|
||||
): void {
|
||||
if (! Schema::hasTable($requiredTable)) {
|
||||
abort(422, 'Interaction unavailable');
|
||||
}
|
||||
|
||||
$state = $request->boolean('state', true);
|
||||
|
||||
$query = DB::table($table);
|
||||
foreach ($keyColumns as $column) {
|
||||
$query->where($column, $keyValues[$column]);
|
||||
}
|
||||
|
||||
if ($state) {
|
||||
if (! $query->exists()) {
|
||||
DB::table($table)->insert(array_merge($keyValues, $insertPayload));
|
||||
}
|
||||
} else {
|
||||
$query->delete();
|
||||
}
|
||||
}
|
||||
|
||||
private function syncArtworkStats(int $artworkId): void
|
||||
{
|
||||
if (! Schema::hasTable('artwork_stats')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$favorites = Schema::hasTable('user_favorites')
|
||||
? (int) DB::table('user_favorites')->where('artwork_id', $artworkId)->count()
|
||||
: 0;
|
||||
|
||||
$likes = Schema::hasTable('artwork_likes')
|
||||
? (int) DB::table('artwork_likes')->where('artwork_id', $artworkId)->count()
|
||||
: 0;
|
||||
|
||||
DB::table('artwork_stats')->updateOrInsert(
|
||||
['artwork_id' => $artworkId],
|
||||
[
|
||||
'favorites' => $favorites,
|
||||
'rating_count' => $likes,
|
||||
'updated_at' => now(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private function statusPayload(int $viewerId, int $artworkId): array
|
||||
{
|
||||
$isFavorited = Schema::hasTable('user_favorites')
|
||||
? DB::table('user_favorites')->where('user_id', $viewerId)->where('artwork_id', $artworkId)->exists()
|
||||
: false;
|
||||
|
||||
$isLiked = Schema::hasTable('artwork_likes')
|
||||
? DB::table('artwork_likes')->where('user_id', $viewerId)->where('artwork_id', $artworkId)->exists()
|
||||
: false;
|
||||
|
||||
$favorites = Schema::hasTable('user_favorites')
|
||||
? (int) DB::table('user_favorites')->where('artwork_id', $artworkId)->count()
|
||||
: 0;
|
||||
|
||||
$likes = Schema::hasTable('artwork_likes')
|
||||
? (int) DB::table('artwork_likes')->where('artwork_id', $artworkId)->count()
|
||||
: 0;
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'is_favorited' => $isFavorited,
|
||||
'is_liked' => $isLiked,
|
||||
'stats' => [
|
||||
'favorites' => $favorites,
|
||||
'likes' => $likes,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Services\LegacyService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ArtController extends Controller
|
||||
{
|
||||
@@ -18,29 +19,23 @@ class ArtController extends Controller
|
||||
|
||||
public function show(Request $request, $id, $slug = null)
|
||||
{
|
||||
// canonicalize to new artwork route when possible
|
||||
// Keep this controller for legacy comment posting and fallback only.
|
||||
// Canonical artwork page rendering is handled by ArtworkPageController.
|
||||
try {
|
||||
$art = \App\Models\Artwork::find((int)$id);
|
||||
if ($art && !empty($art->slug)) {
|
||||
if ($slug !== $art->slug) {
|
||||
// attempt to derive contentType and category for route
|
||||
$category = $art->categories()->with('contentType')->first();
|
||||
if ($category && $category->contentType) {
|
||||
$contentTypeSlug = $category->contentType->slug ?? 'other';
|
||||
$categoryPath = $category->slug ?? $category->category_name ?? 'other';
|
||||
return redirect(route('artworks.show', [
|
||||
'contentTypeSlug' => $contentTypeSlug,
|
||||
'categoryPath' => $categoryPath,
|
||||
'artwork' => $art->slug,
|
||||
]), 301);
|
||||
} elseif (!empty($art->slug)) {
|
||||
// fallback: redirect to artwork slug only (may be handled by router)
|
||||
return redirect('/' . $art->slug, 301);
|
||||
}
|
||||
$art = \App\Models\Artwork::find((int) $id);
|
||||
if ($art && $request->isMethod('get')) {
|
||||
$canonicalSlug = Str::slug((string) ($art->slug ?: $art->title));
|
||||
if ($canonicalSlug === '') {
|
||||
$canonicalSlug = (string) $art->id;
|
||||
}
|
||||
|
||||
return redirect()->route('art.show', [
|
||||
'id' => (int) $art->id,
|
||||
'slug' => $canonicalSlug,
|
||||
], 301);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore and continue rendering legacy view
|
||||
// keep legacy fallback below
|
||||
}
|
||||
if ($request->isMethod('post') && $request->input('action') === 'store_comment') {
|
||||
if (auth()->check()) {
|
||||
|
||||
116
app/Http/Controllers/Web/ArtworkPageController.php
Normal file
116
app/Http/Controllers/Web/ArtworkPageController.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\ArtworkResource;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
|
||||
final class ArtworkPageController extends Controller
|
||||
{
|
||||
public function show(Request $request, int $id, ?string $slug = null): View|RedirectResponse
|
||||
{
|
||||
$artwork = Artwork::with(['user.profile', 'categories.contentType', 'tags', 'stats'])
|
||||
->where('id', $id)
|
||||
->public()
|
||||
->published()
|
||||
->firstOrFail();
|
||||
|
||||
$canonicalSlug = Str::slug((string) ($artwork->slug ?: $artwork->title));
|
||||
if ($canonicalSlug === '') {
|
||||
$canonicalSlug = (string) $artwork->id;
|
||||
}
|
||||
|
||||
if ((string) $slug !== $canonicalSlug) {
|
||||
return redirect()->route('art.show', [
|
||||
'id' => $artwork->id,
|
||||
'slug' => $canonicalSlug,
|
||||
], 301);
|
||||
}
|
||||
|
||||
$thumbMd = ThumbnailPresenter::present($artwork, 'md');
|
||||
$thumbLg = ThumbnailPresenter::present($artwork, 'lg');
|
||||
$thumbXl = ThumbnailPresenter::present($artwork, 'xl');
|
||||
$thumbSq = ThumbnailPresenter::present($artwork, 'sq');
|
||||
|
||||
$artworkData = (new ArtworkResource($artwork))->toArray($request);
|
||||
|
||||
$canonical = route('art.show', ['id' => $artwork->id, 'slug' => $canonicalSlug]);
|
||||
$authorName = $artwork->user?->name ?: $artwork->user?->username ?: 'Artist';
|
||||
$description = Str::limit(trim(strip_tags((string) ($artwork->description ?? ''))), 160, '…');
|
||||
|
||||
$meta = [
|
||||
'title' => sprintf('%s by %s | Skinbase', (string) $artwork->title, (string) $authorName),
|
||||
'description' => $description !== '' ? $description : (string) $artwork->title,
|
||||
'canonical' => $canonical,
|
||||
'og_image' => $thumbXl['url'] ?? $thumbLg['url'] ?? null,
|
||||
'og_width' => $thumbXl['width'] ?? $thumbLg['width'] ?? null,
|
||||
'og_height' => $thumbXl['height'] ?? $thumbLg['height'] ?? null,
|
||||
];
|
||||
|
||||
$categoryIds = $artwork->categories->pluck('id')->filter()->values();
|
||||
$tagIds = $artwork->tags->pluck('id')->filter()->values();
|
||||
|
||||
$related = Artwork::query()
|
||||
->with(['user', 'categories.contentType'])
|
||||
->whereKeyNot($artwork->id)
|
||||
->public()
|
||||
->published()
|
||||
->where(function ($query) use ($artwork, $categoryIds, $tagIds): void {
|
||||
$query->where('user_id', $artwork->user_id);
|
||||
|
||||
if ($categoryIds->isNotEmpty()) {
|
||||
$query->orWhereHas('categories', function ($categoryQuery) use ($categoryIds): void {
|
||||
$categoryQuery->whereIn('categories.id', $categoryIds->all());
|
||||
});
|
||||
}
|
||||
|
||||
if ($tagIds->isNotEmpty()) {
|
||||
$query->orWhereHas('tags', function ($tagQuery) use ($tagIds): void {
|
||||
$tagQuery->whereIn('tags.id', $tagIds->all());
|
||||
});
|
||||
}
|
||||
})
|
||||
->latest('published_at')
|
||||
->limit(12)
|
||||
->get()
|
||||
->map(function (Artwork $item): array {
|
||||
$itemSlug = Str::slug((string) ($item->slug ?: $item->title));
|
||||
if ($itemSlug === '') {
|
||||
$itemSlug = (string) $item->id;
|
||||
}
|
||||
|
||||
$md = ThumbnailPresenter::present($item, 'md');
|
||||
$lg = ThumbnailPresenter::present($item, 'lg');
|
||||
|
||||
return [
|
||||
'id' => (int) $item->id,
|
||||
'title' => (string) $item->title,
|
||||
'author' => (string) ($item->user?->name ?: $item->user?->username ?: 'Artist'),
|
||||
'url' => route('art.show', ['id' => $item->id, 'slug' => $itemSlug]),
|
||||
'thumb' => $md['url'] ?? null,
|
||||
'thumb_srcset' => ($md['url'] ?? '') . ' 640w, ' . ($lg['url'] ?? '') . ' 1280w',
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return view('artworks.show', [
|
||||
'artwork' => $artwork,
|
||||
'artworkData' => $artworkData,
|
||||
'presentMd' => $thumbMd,
|
||||
'presentLg' => $thumbLg,
|
||||
'presentXl' => $thumbXl,
|
||||
'presentSq' => $thumbSq,
|
||||
'meta' => $meta,
|
||||
'relatedItems' => $related,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
<?php
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Http\Resources\MissingValue;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class ArtworkResource extends JsonResource
|
||||
{
|
||||
@@ -11,68 +13,105 @@ class ArtworkResource extends JsonResource
|
||||
*/
|
||||
public function toArray($request): array
|
||||
{
|
||||
if ($this instanceof MissingValue || $this->resource instanceof MissingValue) {
|
||||
return [];
|
||||
$md = ThumbnailPresenter::present($this->resource, 'md');
|
||||
$lg = ThumbnailPresenter::present($this->resource, 'lg');
|
||||
$xl = ThumbnailPresenter::present($this->resource, 'xl');
|
||||
$sq = ThumbnailPresenter::present($this->resource, 'sq');
|
||||
|
||||
$canonicalSlug = \Illuminate\Support\Str::slug((string) ($this->slug ?: $this->title));
|
||||
if ($canonicalSlug === '') {
|
||||
$canonicalSlug = (string) $this->id;
|
||||
}
|
||||
$get = function ($key) {
|
||||
$r = $this->resource;
|
||||
if ($r instanceof MissingValue || $r === null) {
|
||||
return null;
|
||||
}
|
||||
// Eloquent model: prefer getAttribute to avoid magic proxies
|
||||
if (method_exists($r, 'getAttribute')) {
|
||||
return $r->getAttribute($key);
|
||||
}
|
||||
if (is_array($r)) {
|
||||
return $r[$key] ?? null;
|
||||
}
|
||||
if (is_object($r)) {
|
||||
return $r->{$key} ?? null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
$hash = (string) ($get('hash') ?? '');
|
||||
$fileExt = (string) ($get('file_ext') ?? '');
|
||||
$filesBase = rtrim((string) config('cdn.files_url', ''), '/');
|
||||
|
||||
$buildOriginalUrl = static function (string $hashValue, string $extValue) use ($filesBase): ?string {
|
||||
$normalizedHash = strtolower((string) preg_replace('/[^a-f0-9]/', '', $hashValue));
|
||||
$normalizedExt = strtolower((string) preg_replace('/[^a-z0-9]/', '', $extValue));
|
||||
if ($normalizedHash === '' || $normalizedExt === '') return null;
|
||||
$h1 = substr($normalizedHash, 0, 2);
|
||||
$h2 = substr($normalizedHash, 2, 2);
|
||||
if ($h1 === '' || $h2 === '' || $filesBase === '') return null;
|
||||
$followerCount = (int) ($this->user?->profile?->followers_count ?? 0);
|
||||
if (($followerCount <= 0) && Schema::hasTable('friends_list') && !empty($this->user?->id)) {
|
||||
$followerCount = (int) DB::table('friends_list')
|
||||
->where('friend_id', (int) $this->user->id)
|
||||
->count();
|
||||
}
|
||||
|
||||
return sprintf('%s/originals/%s/%s/%s.%s', $filesBase, $h1, $h2, $normalizedHash, $normalizedExt);
|
||||
};
|
||||
$viewerId = (int) optional($request->user())->id;
|
||||
$isLiked = false;
|
||||
$isFavorited = false;
|
||||
$isFollowing = false;
|
||||
|
||||
if ($viewerId > 0) {
|
||||
if (Schema::hasTable('artwork_likes')) {
|
||||
$isLiked = DB::table('artwork_likes')
|
||||
->where('user_id', $viewerId)
|
||||
->where('artwork_id', (int) $this->id)
|
||||
->exists();
|
||||
}
|
||||
|
||||
if (Schema::hasTable('user_favorites')) {
|
||||
$isFavorited = DB::table('user_favorites')
|
||||
->where('user_id', $viewerId)
|
||||
->where('artwork_id', (int) $this->id)
|
||||
->exists();
|
||||
}
|
||||
|
||||
if (Schema::hasTable('friends_list') && !empty($this->user?->id)) {
|
||||
$isFollowing = DB::table('friends_list')
|
||||
->where('user_id', $viewerId)
|
||||
->where('friend_id', (int) $this->user->id)
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'slug' => $get('slug'),
|
||||
'title' => $get('title'),
|
||||
'description' => $get('description'),
|
||||
'width' => $get('width'),
|
||||
'height' => $get('height'),
|
||||
|
||||
// File URLs are derived from hash/ext (no DB path dependency)
|
||||
'id' => (int) $this->id,
|
||||
'slug' => (string) $this->slug,
|
||||
'title' => (string) $this->title,
|
||||
'description' => (string) ($this->description ?? ''),
|
||||
'dimensions' => [
|
||||
'width' => (int) ($this->width ?? 0),
|
||||
'height' => (int) ($this->height ?? 0),
|
||||
],
|
||||
'published_at' => optional($this->published_at)->toIsoString(),
|
||||
'canonical_url' => route('art.show', ['id' => (int) $this->id, 'slug' => $canonicalSlug]),
|
||||
'thumbs' => [
|
||||
'md' => $md,
|
||||
'lg' => $lg,
|
||||
'xl' => $xl,
|
||||
'sq' => $sq,
|
||||
],
|
||||
'file' => [
|
||||
'name' => $get('file_name') ?? null,
|
||||
'url' => $this->when(! empty($hash) && ! empty($fileExt), fn() => $buildOriginalUrl($hash, $fileExt)),
|
||||
'size' => $get('file_size') ?? null,
|
||||
'mime_type' => $get('mime_type') ?? null,
|
||||
'url' => $lg['url'] ?? null,
|
||||
'srcset' => ThumbnailPresenter::srcsetForArtwork($this->resource),
|
||||
'mime_type' => 'image/webp',
|
||||
],
|
||||
|
||||
'categories' => $this->whenLoaded('categories', function () {
|
||||
return $this->categories->map(fn($c) => [
|
||||
'slug' => $c->slug ?? null,
|
||||
'name' => $c->name ?? null,
|
||||
])->values();
|
||||
}),
|
||||
|
||||
'published_at' => $this->whenNotNull($get('published_at') ? $this->published_at->toAtomString() : null),
|
||||
|
||||
'urls' => [
|
||||
'canonical' => $get('canonical_url') ?? null,
|
||||
'user' => [
|
||||
'id' => (int) ($this->user?->id ?? 0),
|
||||
'name' => (string) ($this->user?->name ?? ''),
|
||||
'username' => (string) ($this->user?->username ?? ''),
|
||||
'profile_url' => $this->user?->username ? '/@' . $this->user->username : null,
|
||||
'avatar_url' => $this->user?->profile?->avatar_url,
|
||||
'followers_count' => $followerCount,
|
||||
],
|
||||
'viewer' => [
|
||||
'is_liked' => $isLiked,
|
||||
'is_favorited' => $isFavorited,
|
||||
'is_following_author' => $isFollowing,
|
||||
'is_authenticated' => $viewerId > 0,
|
||||
'id' => $viewerId > 0 ? $viewerId : null,
|
||||
],
|
||||
'stats' => [
|
||||
'views' => (int) ($this->stats?->views ?? 0),
|
||||
'downloads' => (int) ($this->stats?->downloads ?? 0),
|
||||
'favorites' => (int) ($this->stats?->favorites ?? 0),
|
||||
'likes' => (int) ($this->stats?->rating_count ?? 0),
|
||||
],
|
||||
'categories' => $this->categories->map(fn ($category) => [
|
||||
'id' => (int) $category->id,
|
||||
'slug' => (string) $category->slug,
|
||||
'name' => (string) $category->name,
|
||||
'content_type_slug' => (string) ($category->contentType?->slug ?? ''),
|
||||
])->values(),
|
||||
'tags' => $this->tags->map(fn ($tag) => [
|
||||
'id' => (int) $tag->id,
|
||||
'slug' => (string) $tag->slug,
|
||||
'name' => (string) $tag->name,
|
||||
])->values(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user