Wire admin studio SSR and search infrastructure

This commit is contained in:
2026-05-01 11:46:06 +02:00
parent 257b0dbef6
commit 18cea8b0f0
329 changed files with 197465 additions and 2741 deletions

View File

@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Enums\UserRole;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Models\Story;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class AdminController extends Controller
{
// ── Dashboard ────────────────────────────────────────────────────────────
public function dashboard(): Response
{
$stats = [
'total_users' => User::count(),
'new_users_today' => User::whereDate('created_at', today())->count(),
'staff_count' => User::whereIn('role', ['admin', 'manager', 'editorial'])->count(),
'moderator_count' => User::where('role', 'moderator')->count(),
];
return Inertia::render('Admin/Dashboard', [
'stats' => $stats,
]);
}
// ── Users ─────────────────────────────────────────────────────────────────
public function users(Request $request): Response
{
$search = $request->string('search')->trim()->toString();
$roleFilter = $request->string('role')->trim()->toString();
$query = User::select('id', 'name', 'username', 'email', 'role', 'created_at', 'is_active')
->orderByDesc('created_at');
if ($search !== '') {
$query->where(function ($q) use ($search): void {
$q->where('name', 'like', "%{$search}%")
->orWhere('username', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
if ($roleFilter !== '' && $roleFilter !== 'all') {
$query->where('role', $roleFilter);
}
$users = $query->paginate(50)->withQueryString();
return Inertia::render('Admin/Users/Index', [
'users' => $users,
'filters' => ['search' => $search, 'role' => $roleFilter],
'roles' => collect(UserRole::cases())->map(fn ($r) => [
'value' => $r->value,
'label' => $r->label(),
'badge' => $r->badgeClass(),
]),
]);
}
// ── Promote / Demote ──────────────────────────────────────────────────────
public function updateRole(Request $request, User $user): RedirectResponse
{
$request->validate([
'role' => ['required', 'string', 'in:' . implode(',', array_column(UserRole::cases(), 'value'))],
]);
/** @var \App\Models\User $actor */
$actor = $request->user();
// Only admins can set the 'admin' role.
if ($request->input('role') === UserRole::Admin->value && ! $actor->isAdmin()) {
abort(403, 'Only admins can grant the Admin role.');
}
// Prevent self-demotion.
if ($actor->id === $user->id) {
return back()->with('error', 'You cannot change your own role.');
}
$user->update(['role' => $request->input('role')]);
return back()->with('success', "Role updated to \"{$request->input('role')}\" for {$user->name}.");
}
// ── Stories ───────────────────────────────────────────────────────────────
public function stories(Request $request): Response
{
$stories = Story::with('creator:id,name,username')
->select('id', 'title', 'status', 'published_at', 'creator_id')
->orderByDesc('created_at')
->paginate(50)
->withQueryString();
return Inertia::render('Admin/Stories', [
'stories' => $stories,
]);
}
// ── Artworks ──────────────────────────────────────────────────────────────
public function artworks(Request $request): Response
{
$artworks = Artwork::with('user:id,name,username')
->select('id', 'title', 'artwork_status', 'created_at', 'user_id', 'hash', 'thumb_ext')
->orderByDesc('created_at')
->paginate(50)
->withQueryString();
// Normalise status field and add thumb URL
$artworks->getCollection()->transform(function ($artwork) {
return [
'id' => $artwork->id,
'title' => $artwork->title,
'status' => $artwork->artwork_status,
'thumb' => $artwork->thumbUrl('sm') ?? null,
'created_at' => $artwork->created_at,
'user' => $artwork->user,
];
});
return Inertia::render('Admin/Artworks', [
'artworks' => $artworks,
]);
}
// ── Username Queue ────────────────────────────────────────────────────────
public function usernameQueue(): Response
{
return Inertia::render('Admin/UsernameQueue');
}
// ── Upload Queue ──────────────────────────────────────────────────────────
public function uploadQueue(): Response
{
return Inertia::render('Admin/UploadQueue');
}
// ── Settings ──────────────────────────────────────────────────────────────
public function settings(): Response
{
return Inertia::render('Admin/Settings', [
'settings' => [],
]);
}
}

View File

@@ -37,8 +37,12 @@ class PostSearchController extends Controller
->where('status', Post::STATUS_PUBLISHED)
->paginate($perPage, 'page', $page);
// Load relations
$results->load($this->feedService->publicEagerLoads());
if ($viewerId) {
$results->getCollection()->loadExists([
'saves as viewer_saved' => fn ($saveQuery) => $saveQuery->where('user_id', $viewerId),
]);
}
$formatted = $results->getCollection()
->map(fn ($post) => $this->feedService->formatPost($post, $viewerId))
@@ -57,6 +61,9 @@ class PostSearchController extends Controller
} catch (\Exception $e) {
// Fallback: basic LIKE search on body
$paginated = Post::with($this->feedService->publicEagerLoads())
->withExists([
'saves as viewer_saved' => fn ($saveQuery) => $saveQuery->where('user_id', $viewerId),
])
->where('status', Post::STATUS_PUBLISHED)
->where('visibility', Post::VISIBILITY_PUBLIC)
->where(function ($q) use ($query) {

View File

@@ -10,6 +10,7 @@ use App\Models\User;
use Carbon\CarbonInterface;
use App\Services\ThumbnailPresenter;
use App\Support\UsernamePolicy;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@@ -48,19 +49,15 @@ final class ProfileApiController extends Controller
},
])
->where('user_id', $user->id)
->whereNull('deleted_at');
->whereNull('artworks.deleted_at');
if (! $isOwner) {
$query->where('is_public', true)->where('is_approved', true)->whereNotNull('published_at');
$query->where('artworks.is_public', true)
->where('artworks.is_approved', true)
->whereNotNull('artworks.published_at');
}
$query = match ($sort) {
'trending' => $query->orderByDesc('ranking_score'),
'rising' => $query->orderByDesc('heat_score'),
'views' => $query->orderByDesc('view_count'),
'favs' => $query->orderByDesc('favourite_count'),
default => $query->orderByDesc('published_at'),
};
$query = $this->applyArtworkSort($query, $sort);
$perPage = 24;
$paginator = $query->cursorPaginate($perPage);
@@ -185,6 +182,30 @@ final class ProfileApiController extends Controller
return null;
}
private function applyArtworkSort(Builder $query, string $sort): Builder
{
$statsColumn = match ($sort) {
'trending' => 'profile_artwork_stats.ranking_score',
'rising' => 'profile_artwork_stats.heat_score',
'views' => 'profile_artwork_stats.views',
'favs' => 'profile_artwork_stats.favorites',
default => null,
};
if ($statsColumn !== null) {
return $query
->leftJoin('artwork_stats as profile_artwork_stats', 'profile_artwork_stats.artwork_id', '=', 'artworks.id')
->select('artworks.*')
->orderByDesc($statsColumn)
->orderByDesc('artworks.published_at')
->orderByDesc('artworks.id');
}
return $query
->orderByDesc('artworks.published_at')
->orderByDesc('artworks.id');
}
/**
* @return array<string, mixed>
*/

View File

@@ -117,7 +117,7 @@ final class SimilarArtworksController extends Controller
$filterParts[] = '(' . $tagFilter . ')';
} elseif ($categorySlugs !== []) {
$catFilter = implode(' OR ', array_map(
fn (string $c): string => 'category = "' . addslashes($c) . '"',
fn (string $c): string => '(category = "' . addslashes($c) . '" OR categories = "' . addslashes($c) . '")',
$categorySlugs
));
$filterParts[] = '(' . $catFilter . ')';

View File

@@ -11,7 +11,10 @@ use App\Http\Requests\Uploads\UploadChunkRequest;
use App\Http\Requests\Uploads\UploadCancelRequest;
use App\Http\Requests\Uploads\UploadStatusRequest;
use App\Jobs\GenerateDerivativesJob;
use App\Jobs\AnalyzeArtworkAiAssistJob;
use App\Jobs\IndexArtworkJob;
use App\Jobs\AutoTagArtworkJob;
use App\Jobs\DetectArtworkMaturityJob;
use App\Jobs\GenerateArtworkEmbeddingJob;
use App\Repositories\Uploads\UploadSessionRepository;
use App\Services\Uploads\UploadChunkService;
@@ -19,6 +22,7 @@ use App\Services\Uploads\UploadCancelService;
use App\Services\Uploads\UploadAuditService;
use App\Services\Uploads\UploadPipelineService;
use App\Services\Uploads\UploadQuotaService;
use App\Services\Uploads\UploadQueueService;
use App\Services\Uploads\UploadSessionStatus;
use App\Services\Uploads\UploadStatusService;
use Illuminate\Support\Facades\DB;
@@ -82,11 +86,13 @@ final class UploadController extends Controller
UploadFinishRequest $request,
UploadPipelineService $pipeline,
UploadSessionRepository $sessions,
UploadAuditService $audit
UploadAuditService $audit,
UploadQueueService $queue
) {
$user = $request->user();
$sessionId = (string) $request->validated('session_id');
$artworkId = (int) $request->validated('artwork_id');
$batchItemId = (int) $request->validated('batch_item_id', 0);
$originalFileName = $request->validated('file_name');
$archiveSessionId = $request->validated('archive_session_id');
$archiveOriginalFileName = $request->validated('archive_file_name');
@@ -97,16 +103,33 @@ final class UploadController extends Controller
$session = $sessions->getOrFail($sessionId);
$request->artwork();
$request->batchItem();
$failResponse = function (int $statusCode, string $message, ?string $reason = null) use ($queue, $user, $batchItemId) {
if ($batchItemId > 0) {
$queue->markItemFailedForUser($user, $batchItemId, $reason ?? 'upload_failed', $message);
}
return response()->json(array_filter([
'message' => $message,
'reason' => $reason,
], static fn (mixed $value): bool => $value !== null), $statusCode);
};
$validated = $pipeline->validateAndHash($sessionId);
if (! $validated->validation->ok || ! $validated->hash) {
return response()->json([
'message' => 'Upload validation failed.',
'reason' => $validated->validation->reason,
], Response::HTTP_UNPROCESSABLE_ENTITY);
return $failResponse(
Response::HTTP_UNPROCESSABLE_ENTITY,
'Upload validation failed.',
$validated->validation->reason
);
}
if ($pipeline->originalHashExists($validated->hash)) {
if ($batchItemId > 0) {
$queue->markItemFailedForUser($user, $batchItemId, 'duplicate_hash', 'Duplicate upload is not allowed. This file already exists.');
}
return response()->json([
'message' => 'Duplicate upload is not allowed. This file already exists.',
'reason' => 'duplicate_hash',
@@ -116,28 +139,31 @@ final class UploadController extends Controller
$scan = $pipeline->scan($sessionId);
if (! $scan->ok) {
return response()->json([
'message' => 'Upload scan failed.',
'reason' => $scan->reason,
], Response::HTTP_UNPROCESSABLE_ENTITY);
return $failResponse(
Response::HTTP_UNPROCESSABLE_ENTITY,
'Upload scan failed.',
$scan->reason
);
}
$validatedArchive = null;
if (is_string($archiveSessionId) && trim($archiveSessionId) !== '') {
$validatedArchive = $pipeline->validateAndHashArchive($archiveSessionId);
if (! $validatedArchive->validation->ok || ! $validatedArchive->hash) {
return response()->json([
'message' => 'Archive validation failed.',
'reason' => $validatedArchive->validation->reason,
], Response::HTTP_UNPROCESSABLE_ENTITY);
return $failResponse(
Response::HTTP_UNPROCESSABLE_ENTITY,
'Archive validation failed.',
$validatedArchive->validation->reason
);
}
$archiveScan = $pipeline->scan($archiveSessionId);
if (! $archiveScan->ok) {
return response()->json([
'message' => 'Archive scan failed.',
'reason' => $archiveScan->reason,
], Response::HTTP_UNPROCESSABLE_ENTITY);
return $failResponse(
Response::HTTP_UNPROCESSABLE_ENTITY,
'Archive scan failed.',
$archiveScan->reason
);
}
}
@@ -150,18 +176,20 @@ final class UploadController extends Controller
$validatedScreenshot = $pipeline->validateAndHash($screenshotSessionId);
if (! $validatedScreenshot->validation->ok || ! $validatedScreenshot->hash) {
return response()->json([
'message' => 'Screenshot validation failed.',
'reason' => $validatedScreenshot->validation->reason,
], Response::HTTP_UNPROCESSABLE_ENTITY);
return $failResponse(
Response::HTTP_UNPROCESSABLE_ENTITY,
'Screenshot validation failed.',
$validatedScreenshot->validation->reason
);
}
$screenshotScan = $pipeline->scan($screenshotSessionId);
if (! $screenshotScan->ok) {
return response()->json([
'message' => 'Screenshot scan failed.',
'reason' => $screenshotScan->reason,
], Response::HTTP_UNPROCESSABLE_ENTITY);
return $failResponse(
Response::HTTP_UNPROCESSABLE_ENTITY,
'Screenshot scan failed.',
$screenshotScan->reason
);
}
$validatedAdditionalScreenshots[] = [
@@ -172,7 +200,7 @@ final class UploadController extends Controller
}
try {
$status = DB::transaction(function () use ($pipeline, $sessionId, $validated, $artworkId, $originalFileName, $archiveSessionId, $validatedArchive, $archiveOriginalFileName, $validatedAdditionalScreenshots) {
$status = DB::transaction(function () use ($pipeline, $sessionId, $validated, $artworkId, $originalFileName, $archiveSessionId, $validatedArchive, $archiveOriginalFileName, $validatedAdditionalScreenshots, $queue, $batchItemId) {
if ((bool) config('uploads.queue_derivatives', false)) {
GenerateDerivativesJob::dispatch(
$sessionId,
@@ -182,8 +210,14 @@ final class UploadController extends Controller
is_string($archiveSessionId) ? $archiveSessionId : null,
$validatedArchive?->hash,
is_string($archiveOriginalFileName) ? $archiveOriginalFileName : null,
$validatedAdditionalScreenshots
$validatedAdditionalScreenshots,
$batchItemId > 0 ? $batchItemId : null
)->afterCommit();
if ($batchItemId > 0) {
$queue->markItemProcessingQueued($batchItemId);
}
return 'queued';
}
@@ -198,9 +232,15 @@ final class UploadController extends Controller
$validatedAdditionalScreenshots
);
if ($batchItemId > 0) {
$queue->markItemMediaProcessed($batchItemId);
}
// Derivatives are available now; dispatch AI auto-tagging.
AutoTagArtworkJob::dispatch($artworkId, $validated->hash)->afterCommit();
DetectArtworkMaturityJob::dispatch($artworkId, $validated->hash)->afterCommit();
GenerateArtworkEmbeddingJob::dispatch($artworkId, $validated->hash)->afterCommit();
AnalyzeArtworkAiAssistJob::dispatch($artworkId)->afterCommit();
return UploadSessionStatus::PROCESSED;
});
@@ -224,6 +264,10 @@ final class UploadController extends Controller
'error' => $e->getMessage(),
]);
if ($batchItemId > 0) {
$queue->markItemFailedForUser($user, $batchItemId, 'upload_finish_failed', $e->getMessage());
}
return response()->json([
'message' => 'Upload finish failed.',
], Response::HTTP_INTERNAL_SERVER_ERROR);
@@ -588,6 +632,7 @@ final class UploadController extends Controller
'world_submissions' => ['nullable', 'array', 'max:12'],
'world_submissions.*.world_id' => ['required', 'integer', 'exists:worlds,id'],
'world_submissions.*.note' => ['nullable', 'string', 'max:1000'],
'world_submissions.*.source_surface' => ['nullable', 'string', 'max:80'],
]);
$mode = $validated['mode'] ?? 'now';
@@ -676,14 +721,7 @@ final class UploadController extends Controller
$artwork->published_at = null;
$artwork->save();
try {
$artwork->unsearchable();
} catch (\Throwable $e) {
Log::warning('Failed to remove scheduled artwork from search index', [
'artwork_id' => (int) $artwork->id,
'error' => $e->getMessage(),
]);
}
IndexArtworkJob::dispatch((int) $artwork->id);
return response()->json([
'success' => true,
@@ -704,18 +742,7 @@ final class UploadController extends Controller
$artwork->publish_at = null;
$artwork->save();
try {
if ((bool) $artwork->is_public && (bool) $artwork->is_approved && !empty($artwork->published_at)) {
$artwork->searchable();
} else {
$artwork->unsearchable();
}
} catch (\Throwable $e) {
Log::warning('Failed to sync artwork search index after publish', [
'artwork_id' => (int) $artwork->id,
'error' => $e->getMessage(),
]);
}
IndexArtworkJob::dispatch((int) $artwork->id);
// Record upload activity event
try {
@@ -784,6 +811,7 @@ final class UploadController extends Controller
'world_submissions' => ['nullable', 'array', 'max:12'],
'world_submissions.*.world_id' => ['required', 'integer', 'exists:worlds,id'],
'world_submissions.*.note' => ['nullable', 'string', 'max:1000'],
'world_submissions.*.source_surface' => ['nullable', 'string', 'max:80'],
]);
if (! ctype_digit($id)) {

View File

@@ -6,8 +6,10 @@ namespace App\Http\Controllers;
use App\Models\Artwork;
use App\Models\ArtworkDownload;
use App\Services\ArtworkOriginalFileLocator;
use App\Services\ArtworkStatsService;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
@@ -38,16 +40,18 @@ final class ArtworkDownloadController extends Controller
public function __construct(
private readonly ArtworkStatsService $stats,
private readonly ArtworkOriginalFileLocator $originalFiles,
) {}
public function __invoke(Request $request, int $id): BinaryFileResponse
public function __invoke(Request $request, int $id): BinaryFileResponse|Response
{
$artwork = Artwork::query()->find($id);
if (! $artwork) {
abort(404);
}
$filePath = $this->resolveOriginalPath($artwork);
$filePath = $this->originalFiles->resolveLocalPath($artwork);
$ext = strtolower(ltrim((string) pathinfo($filePath, PATHINFO_EXTENSION), '.'));
if ($filePath === '' || ! in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
@@ -76,36 +80,59 @@ final class ArtworkDownloadController extends Controller
abort(404);
}
$downloadName = $this->buildDownloadFilename((string) $artwork->file_name, $ext);
// X-Accel-Redirect is safe only when nginx is explicitly configured to
// map the internal URI to the originals root. Otherwise fallback to the
// normal Laravel download response.
$accelUri = $this->resolveAccelUri($filePath);
if ($accelUri !== null) {
return response('', 200, [
'X-Accel-Redirect' => $accelUri,
'Content-Type' => 'application/octet-stream',
'Content-Disposition' => 'attachment; filename="' . addslashes($downloadName) . '"',
'X-Content-Type-Options' => 'nosniff',
]);
}
return response()->download($filePath, $downloadName);
}
private function resolveOriginalPath(Artwork $artwork): string
private function resolveAccelUri(string $filePath): ?string
{
$relative = trim((string) $artwork->file_path, '/');
$prefix = trim((string) config('uploads.object_storage.prefix', 'artworks'), '/') . '/original/';
if ($relative !== '' && str_starts_with($relative, $prefix)) {
$suffix = substr($relative, strlen($prefix));
$root = rtrim((string) config('uploads.local_originals_root'), DIRECTORY_SEPARATOR);
return $root . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, (string) $suffix);
if (! config('app.download_accel_enabled')) {
return null;
}
$hash = strtolower((string) $artwork->hash);
$ext = strtolower(ltrim((string) $artwork->file_ext, '.'));
if (! $this->isValidHash($hash) || $ext === '') {
return '';
$accelBase = rtrim((string) config('app.download_accel_path', ''), '/');
if ($accelBase === '') {
return null;
}
$root = rtrim((string) config('uploads.local_originals_root'), DIRECTORY_SEPARATOR);
if ($root === '') {
return null;
}
return $root
. DIRECTORY_SEPARATOR . substr($hash, 0, 2)
. DIRECTORY_SEPARATOR . substr($hash, 2, 2)
. DIRECTORY_SEPARATOR . $hash . '.' . $ext;
$normalizedRoot = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $root);
$normalizedFilePath = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $filePath);
$rootPrefix = $normalizedRoot . DIRECTORY_SEPARATOR;
if (! str_starts_with($normalizedFilePath, $rootPrefix)) {
Log::warning('Artwork download accel path skipped because file is outside originals root.', [
'resolved_path' => $filePath,
'originals_root' => $root,
]);
return null;
}
$relativePath = substr($normalizedFilePath, strlen($normalizedRoot));
if ($relativePath === false || $relativePath === '') {
return null;
}
return $accelBase . str_replace(DIRECTORY_SEPARATOR, '/', $relativePath);
}
private function recordDownload(Request $request, int $artworkId): void
@@ -139,11 +166,6 @@ final class ArtworkDownloadController extends Controller
Artwork::query()->whereKey($artworkId)->increment('download_count');
}
private function isValidHash(string $hash): bool
{
return $hash !== '' && preg_match('/^[a-f0-9]+$/', $hash) === 1;
}
private function buildDownloadFilename(string $fileName, string $ext): string
{
$name = trim($fileName);

View File

@@ -2,200 +2,23 @@
namespace App\Http\Controllers;
use App\Models\Category;
use App\Services\ThumbnailService;
use App\Services\CategoryDirectoryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class CategoryController extends Controller
{
public function __construct(
private readonly CategoryDirectoryService $directory,
) {}
public function index(Request $request): JsonResponse
{
$search = trim((string) $request->query('q', ''));
$sort = (string) $request->query('sort', 'popular');
$page = max(1, (int) $request->query('page', 1));
$perPage = min(60, max(12, (int) $request->query('per_page', 24)));
$categories = collect(Cache::remember('categories.directory.v1', 3600, function (): array {
$publishedArtworkScope = DB::table('artwork_category as artwork_category')
->join('artworks as artworks', 'artworks.id', '=', 'artwork_category.artwork_id')
->leftJoin('artwork_stats as artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->whereColumn('artwork_category.category_id', 'categories.id')
->where('artworks.is_public', true)
->where('artworks.is_approved', true)
->whereNull('artworks.deleted_at');
$categories = Category::query()
->select([
'categories.id',
'categories.content_type_id',
'categories.parent_id',
'categories.name',
'categories.slug',
])
->selectSub(
(clone $publishedArtworkScope)->selectRaw('COUNT(DISTINCT artworks.id)'),
'artwork_count'
)
->selectSub(
(clone $publishedArtworkScope)
->whereNotNull('artworks.hash')
->whereNotNull('artworks.thumb_ext')
->orderByDesc(DB::raw('COALESCE(artwork_stats.views, 0)'))
->orderByDesc(DB::raw('COALESCE(artwork_stats.favorites, 0)'))
->orderByDesc(DB::raw('COALESCE(artwork_stats.downloads, 0)'))
->orderByDesc(DB::raw('COALESCE(artworks.published_at, artworks.created_at)'))
->orderByDesc('artworks.id')
->limit(1)
->select('artworks.hash'),
'cover_hash'
)
->selectSub(
(clone $publishedArtworkScope)
->whereNotNull('artworks.hash')
->whereNotNull('artworks.thumb_ext')
->orderByDesc(DB::raw('COALESCE(artwork_stats.views, 0)'))
->orderByDesc(DB::raw('COALESCE(artwork_stats.favorites, 0)'))
->orderByDesc(DB::raw('COALESCE(artwork_stats.downloads, 0)'))
->orderByDesc(DB::raw('COALESCE(artworks.published_at, artworks.created_at)'))
->orderByDesc('artworks.id')
->limit(1)
->select('artworks.thumb_ext'),
'cover_ext'
)
->selectSub(
(clone $publishedArtworkScope)
->selectRaw('COALESCE(SUM(COALESCE(artwork_stats.views, 0) + (COALESCE(artwork_stats.favorites, 0) * 3) + (COALESCE(artwork_stats.downloads, 0) * 2)), 0)'),
'popular_score'
)
->with(['contentType:id,name,slug'])
->active()
->orderBy('categories.name')
->get();
return $this->transformCategories($categories);
}));
$filtered = $this->filterAndSortCategories($categories, $search, $sort);
$total = $filtered->count();
$lastPage = max(1, (int) ceil($total / $perPage));
$currentPage = min($page, $lastPage);
$offset = ($currentPage - 1) * $perPage;
$pageItems = $filtered->slice($offset, $perPage)->values();
$popularCategories = $this->filterAndSortCategories($categories, '', 'popular')->take(4)->values();
return response()->json([
'data' => $pageItems,
'meta' => [
'current_page' => $currentPage,
'last_page' => $lastPage,
'per_page' => $perPage,
'total' => $total,
],
'summary' => [
'total_categories' => $categories->count(),
'total_artworks' => $categories->sum(fn (array $category): int => (int) ($category['artwork_count'] ?? 0)),
],
'popular_categories' => $search === '' ? $popularCategories : [],
]);
}
/**
* @param Collection<int, array<string, mixed>> $categories
* @return Collection<int, array<string, mixed>>
*/
private function filterAndSortCategories(Collection $categories, string $search, string $sort): Collection
{
$filtered = $categories;
if ($search !== '') {
$needle = mb_strtolower($search);
$filtered = $filtered->filter(function (array $category) use ($needle): bool {
return str_contains(mb_strtolower((string) ($category['name'] ?? '')), $needle);
});
}
return $filtered->sort(function (array $left, array $right) use ($sort): int {
if ($sort === 'az') {
return strcasecmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? ''));
}
if ($sort === 'artworks') {
$countCompare = ((int) ($right['artwork_count'] ?? 0)) <=> ((int) ($left['artwork_count'] ?? 0));
return $countCompare !== 0
? $countCompare
: strcasecmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? ''));
}
$scoreCompare = ((int) ($right['popular_score'] ?? 0)) <=> ((int) ($left['popular_score'] ?? 0));
if ($scoreCompare !== 0) {
return $scoreCompare;
}
$countCompare = ((int) ($right['artwork_count'] ?? 0)) <=> ((int) ($left['artwork_count'] ?? 0));
if ($countCompare !== 0) {
return $countCompare;
}
return strcasecmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? ''));
})->values();
}
/**
* @param Collection<int, Category> $categories
* @return array<int, array<string, mixed>>
*/
private function transformCategories(Collection $categories): array
{
$categoryMap = $categories->keyBy('id');
$pathCache = [];
$buildPath = function (Category $category) use (&$buildPath, &$pathCache, $categoryMap): string {
if (isset($pathCache[$category->id])) {
return $pathCache[$category->id];
}
if ($category->parent_id && $categoryMap->has($category->parent_id)) {
$pathCache[$category->id] = $buildPath($categoryMap->get($category->parent_id)) . '/' . $category->slug;
return $pathCache[$category->id];
}
$pathCache[$category->id] = $category->slug;
return $pathCache[$category->id];
};
return $categories
->map(function (Category $category) use ($buildPath): array {
$contentTypeSlug = strtolower((string) ($category->contentType?->slug ?? 'categories'));
$path = $buildPath($category);
$coverImage = null;
if (! empty($category->cover_hash) && ! empty($category->cover_ext)) {
$coverImage = ThumbnailService::fromHash((string) $category->cover_hash, (string) $category->cover_ext, 'md');
}
return [
'id' => (int) $category->id,
'name' => (string) $category->name,
'slug' => (string) $category->slug,
'url' => '/' . $contentTypeSlug . '/' . $path,
'content_type' => [
'name' => (string) ($category->contentType?->name ?? 'Categories'),
'slug' => $contentTypeSlug,
],
'cover_image' => $coverImage ?: 'https://files.skinbase.org/default/missing_md.webp',
'artwork_count' => (int) ($category->artwork_count ?? 0),
'popular_score' => (int) ($category->popular_score ?? 0),
];
})
->values()
->all();
return response()->json($this->directory->getDirectoryPayload(
(string) $request->query('q', ''),
(string) $request->query('sort', 'popular'),
(int) $request->query('page', 1),
(int) $request->query('per_page', 24),
));
}
}

View File

@@ -39,7 +39,7 @@ class FavoriteController extends Controller
if ($slice !== []) {
$arts = Artwork::query()
->whereIn('id', $slice)
->with(['user.profile', 'categories'])
->with(['user.profile', 'categories.contentType'])
->withCount(['favourites', 'comments'])
->get()
->keyBy('id');

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Schema;
final class LegacyArtworkPhotoController extends Controller
{
private const BASE62_CHARS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
private const THUMB_SIZE_MAP = [
0 => 'xs',
1 => 'xs',
2 => 'xs',
3 => 'sm',
4 => 'sm',
5 => 'sm',
6 => 'md',
];
private static ?bool $hasLegacyIdColumn = null;
public function __invoke(string $encoded, string $size, string $extension): RedirectResponse
{
$artworkId = $this->decodeBase62($encoded);
$sizeCode = (int) $size;
abort_if($artworkId === null || $artworkId < 1, 404);
$artwork = $this->resolveArtwork($artworkId);
abort_unless($artwork !== null, 404);
$targetUrl = $sizeCode === 7
? $this->resolveOriginalUrl($artwork)
: $artwork->thumbUrl(self::THUMB_SIZE_MAP[$sizeCode] ?? 'md');
abort_if(empty($targetUrl), 404);
return redirect()->away($targetUrl, 301);
}
private function decodeBase62(string $value): ?int
{
if ($value === '') {
return null;
}
$alphabet = array_flip(str_split(self::BASE62_CHARS));
$decoded = 0;
foreach (str_split($value) as $character) {
if (! array_key_exists($character, $alphabet)) {
return null;
}
$decoded = ($decoded * 62) + $alphabet[$character];
}
return $decoded;
}
private function resolveArtwork(int $artworkId): ?Artwork
{
return Artwork::query()
->select(['id', 'hash', 'thumb_ext', 'file_ext', 'file_path', 'is_public', 'is_approved', 'published_at'])
->where(function (Builder $query) use ($artworkId): void {
$query->where('id', $artworkId);
if ($this->hasLegacyIdColumn()) {
$query->orWhere('legacy_id', $artworkId);
}
})
->where('is_public', true)
->where('is_approved', true)
->whereNotNull('published_at')
->first();
}
private function resolveOriginalUrl(Artwork $artwork): ?string
{
$cdn = rtrim((string) config('cdn.files_url', 'https://cdn.skinbase.org'), '/');
$filePath = trim((string) ($artwork->file_path ?? ''), '/');
if ($filePath !== '') {
return $cdn . '/' . $filePath;
}
$hash = strtolower((string) preg_replace('/[^a-f0-9]/i', '', (string) ($artwork->hash ?? '')));
$ext = ltrim((string) ($artwork->file_ext ?: $artwork->thumb_ext ?: 'webp'), '.');
if ($hash === '') {
return $artwork->thumbUrl('xl') ?? $artwork->thumbUrl('lg') ?? $artwork->thumbUrl('md');
}
$prefix = trim((string) config('uploads.object_storage.prefix', 'artworks'), '/');
$firstDir = substr($hash, 0, 2);
$secondDir = substr($hash, 2, 2);
return sprintf('%s/%s/original/%s/%s/%s.%s', $cdn, $prefix, $firstDir, $secondDir, $hash, $ext);
}
private function hasLegacyIdColumn(): bool
{
if (self::$hasLegacyIdColumn === null) {
self::$hasLegacyIdColumn = Schema::hasColumn('artworks', 'legacy_id');
}
return self::$hasLegacyIdColumn;
}
}

View File

@@ -43,8 +43,7 @@ final class DiscoverFeedController extends Controller
$artworks = Cache::remember('rss:discover:trending', 600, fn () =>
Artwork::public()->published()
->with(['user:id,username', 'categories:id,name,slug,content_type_id'])
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->orderByDesc('artwork_stats.trending_score_7d')
->orderByDesc('artworks.trending_score_7d')
->orderByDesc('artworks.published_at')
->select('artworks.*')
->limit(RSSFeedBuilder::FEED_LIMIT)

View File

@@ -86,8 +86,7 @@ final class ExploreFeedController extends Controller
return match ($mode) {
'trending' => $query
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->orderByDesc('artwork_stats.trending_score_7d')
->orderByDesc('artworks.trending_score_7d')
->orderByDesc('artworks.published_at')
->select('artworks.*')
->limit(RSSFeedBuilder::FEED_LIMIT)

View File

@@ -26,6 +26,9 @@ final class AiBiographyAdminController extends Controller
public function index(Request $request): Response
{
$isAdminSurface = $request->routeIs('admin.cp.ai-biography.*');
$routePrefix = $isAdminSurface ? 'admin.cp.ai-biography.' : 'cp.ai-biography.';
$filters = $this->filters($request);
$records = $this->recordsQuery($filters)
@@ -33,7 +36,7 @@ final class AiBiographyAdminController extends Controller
->withQueryString()
->through(fn (CreatorAiBiography $record): array => $this->mapRecord($record));
return Inertia::render('Moderation/AiBiographyAdmin', [
return Inertia::render($isAdminSurface ? 'Admin/AiBiography' : 'Moderation/AiBiographyAdmin', [
'title' => 'AI Biography Review',
'records' => $records,
'filters' => $filters,
@@ -72,14 +75,14 @@ final class AiBiographyAdminController extends Controller
],
],
'endpoints' => [
'index' => route('cp.ai-biography.index'),
'rebuildPattern' => route('cp.ai-biography.rebuild', ['user' => '__USER__']),
'approvePattern' => route('cp.ai-biography.approve', ['biography' => '__BIOGRAPHY__']),
'flagPattern' => route('cp.ai-biography.flag', ['biography' => '__BIOGRAPHY__']),
'hidePattern' => route('cp.ai-biography.hide', ['biography' => '__BIOGRAPHY__']),
'showPattern' => route('cp.ai-biography.show', ['biography' => '__BIOGRAPHY__']),
'index' => route($routePrefix . 'index'),
'rebuildPattern' => route($routePrefix . 'rebuild', ['user' => '__USER__']),
'approvePattern' => route($routePrefix . 'approve', ['biography' => '__BIOGRAPHY__']),
'flagPattern' => route($routePrefix . 'flag', ['biography' => '__BIOGRAPHY__']),
'hidePattern' => route($routePrefix . 'hide', ['biography' => '__BIOGRAPHY__']),
'showPattern' => route($routePrefix . 'show', ['biography' => '__BIOGRAPHY__']),
],
])->rootView('moderation');
])->rootView($isAdminSurface ? 'admin' : 'moderation');
}
public function rebuild(User $user): JsonResponse

View File

@@ -23,18 +23,21 @@ class FeaturedArtworkAdminController extends Controller
{
}
public function index(): Response
public function index(Request $request): Response
{
return Inertia::render('Collection/FeaturedArtworksAdmin', array_merge(
$isAdminSurface = $request->routeIs('admin.artworks.featured.*');
$routePrefix = $isAdminSurface ? 'admin.artworks.featured.' : 'admin.cp.artworks.featured.';
return Inertia::render($isAdminSurface ? 'Admin/FeaturedArtworks' : 'Collection/FeaturedArtworksAdmin', array_merge(
$this->featuredArtworks->pageProps(),
[
'endpoints' => [
'search' => route('admin.cp.artworks.featured.search'),
'store' => route('admin.cp.artworks.featured.store'),
'updatePattern' => route('admin.cp.artworks.featured.update', ['feature' => '__FEATURE__']),
'togglePattern' => route('admin.cp.artworks.featured.toggle', ['feature' => '__FEATURE__']),
'forceHeroPattern' => route('admin.cp.artworks.featured.force-hero', ['feature' => '__FEATURE__']),
'destroyPattern' => route('admin.cp.artworks.featured.delete', ['feature' => '__FEATURE__']),
'search' => route($routePrefix . 'search'),
'store' => route($routePrefix . 'store'),
'updatePattern' => route($routePrefix . 'update', ['feature' => '__FEATURE__']),
'togglePattern' => route($routePrefix . 'toggle', ['feature' => '__FEATURE__']),
'forceHeroPattern' => route($routePrefix . 'force-hero', ['feature' => '__FEATURE__']),
'destroyPattern' => route($routePrefix . 'delete', ['feature' => '__FEATURE__']),
],
'capabilities' => [
'forceHeroEnabled' => $this->hasForceHeroColumn(),
@@ -42,11 +45,11 @@ class FeaturedArtworkAdminController extends Controller
'seo' => [
'title' => 'Featured Artworks — Skinbase Nova',
'description' => 'Editorial controls for homepage featured artworks and the current hero winner.',
'canonical' => route('admin.cp.artworks.featured.main'),
'canonical' => route($routePrefix . 'main'),
'robots' => 'noindex,follow',
],
],
))->rootView('collections');
))->rootView($isAdminSurface ? 'admin' : 'collections');
}
public function search(Request $request): JsonResponse

View File

@@ -4,58 +4,61 @@ declare(strict_types=1);
namespace App\Http\Controllers;
use App\Services\Sitemaps\SitemapBuildService;
use App\Services\Sitemaps\PublishedSitemapResolver;
use App\Services\Sitemaps\SitemapXmlRenderer;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Illuminate\Http\Response;
final class SitemapController extends Controller
{
public function __construct(
private readonly SitemapBuildService $build,
private readonly PublishedSitemapResolver $published,
private readonly SitemapXmlRenderer $renderer,
) {
}
public function index(): Response
public function index(): Response|BinaryFileResponse
{
if ((bool) config('sitemaps.delivery.prefer_published_release', true)) {
$published = $this->published->resolveIndex();
if ($published !== null) {
return $this->renderer->xmlResponse($published['content']);
}
// 1. Static file written by the build/generate commands.
// On production nginx serves this directly via try_files without reaching PHP.
// On dev / misconfigured servers we stream it with sendfile — no RAM load.
$path = public_path('sitemap.xml');
if (file_exists($path)) {
return $this->xmlFileResponse($path);
}
abort_unless((bool) config('sitemaps.delivery.fallback_to_live_build', true), 404);
// 2. Published release (release management pipeline fallback).
$published = $this->published->resolveIndex();
if ($published !== null) {
return $this->renderer->xmlResponse($published['content']);
}
$built = $this->build->buildIndex(
force: false,
persist: (bool) config('sitemaps.refresh.build_on_request', true),
);
return $this->renderer->xmlResponse($built['content']);
throw new NotFoundHttpException();
}
public function show(string $name): Response
public function show(string $name): Response|BinaryFileResponse
{
if ((bool) config('sitemaps.delivery.prefer_published_release', true)) {
$published = $this->published->resolveNamed($name);
if ($published !== null) {
return $this->renderer->xmlResponse($published['content']);
}
// 1. Static file.
$path = public_path('sitemaps/' . $name . '.xml');
if (file_exists($path)) {
return $this->xmlFileResponse($path);
}
abort_unless((bool) config('sitemaps.delivery.fallback_to_live_build', true), 404);
// 2. Published release.
$published = $this->published->resolveNamed($name);
if ($published !== null) {
return $this->renderer->xmlResponse($published['content']);
}
$built = $this->build->buildNamed(
$name,
force: false,
persist: (bool) config('sitemaps.refresh.build_on_request', true),
);
throw new NotFoundHttpException();
}
abort_if($built === null, 404);
return $this->renderer->xmlResponse($built['content']);
private function xmlFileResponse(string $absolutePath): BinaryFileResponse
{
return response()->file($absolutePath, [
'Content-Type' => 'application/xml; charset=UTF-8',
'Cache-Control' => 'public, max-age=' . max(60, (int) config('sitemaps.cache_ttl_seconds', 900)),
]);
}
}

View File

@@ -228,7 +228,7 @@ class StoryController extends Controller
'scheduled_for' => $resolved['scheduled_for'],
'meta_title' => $validated['meta_title'] ?? $validated['title'],
'meta_description' => $validated['meta_description'] ?? Str::limit(strip_tags((string) $validated['excerpt']), 160),
'canonical_url' => $validated['canonical_url'] ?? null,
'canonical_url' => null,
'og_image' => $validated['og_image'] ?? ($validated['cover_image'] ?? null),
'submitted_for_review_at' => $resolved['status'] === 'pending_review' ? now() : null,
]);
@@ -244,7 +244,7 @@ class StoryController extends Controller
->with('status', 'Story published.');
}
return redirect()->route('creator.stories.edit', ['story' => $story->id])
return redirect()->route('studio.stories.edit', ['story' => $story->id])
->with('status', $resolved['status'] === 'pending_review' ? 'Story submitted for review.' : 'Draft saved.');
}
@@ -320,7 +320,7 @@ class StoryController extends Controller
'scheduled_for' => $resolved['scheduled_for'],
'meta_title' => $validated['meta_title'] ?? $validated['title'],
'meta_description' => $validated['meta_description'] ?? Str::limit(strip_tags((string) $validated['excerpt']), 160),
'canonical_url' => $validated['canonical_url'] ?? null,
'canonical_url' => null,
'og_image' => $validated['og_image'] ?? ($validated['cover_image'] ?? null),
'submitted_for_review_at' => $resolved['status'] === 'pending_review' ? ($story->submitted_for_review_at ?? now()) : $story->submitted_for_review_at,
]);
@@ -499,7 +499,6 @@ class StoryController extends Controller
'tags_csv' => ['nullable', 'string', 'max:500'],
'meta_title' => ['nullable', 'string', 'max:255'],
'meta_description' => ['nullable', 'string', 'max:300'],
'canonical_url' => ['nullable', 'url', 'max:500'],
'og_image' => ['nullable', 'string', 'max:500'],
]);
@@ -532,7 +531,7 @@ class StoryController extends Controller
'scheduled_for' => $workflow['scheduled_for'],
'meta_title' => $validated['meta_title'] ?? $title,
'meta_description' => $validated['meta_description'] ?? Str::limit((string) ($validated['excerpt'] ?? ''), 160),
'canonical_url' => $validated['canonical_url'] ?? null,
'canonical_url' => null,
'og_image' => $validated['og_image'] ?? ($validated['cover_image'] ?? null),
'submitted_for_review_at' => $workflow['status'] === 'pending_review' ? now() : null,
]);
@@ -571,7 +570,6 @@ class StoryController extends Controller
'tags_csv' => ['nullable', 'string', 'max:500'],
'meta_title' => ['nullable', 'string', 'max:255'],
'meta_description' => ['nullable', 'string', 'max:300'],
'canonical_url' => ['nullable', 'url', 'max:500'],
'og_image' => ['nullable', 'string', 'max:500'],
]);
@@ -605,7 +603,7 @@ class StoryController extends Controller
'scheduled_for' => $workflow['scheduled_for'],
'meta_title' => $validated['meta_title'] ?? $story->meta_title ?? $title,
'meta_description' => $validated['meta_description'] ?? $story->meta_description,
'canonical_url' => $validated['canonical_url'] ?? $story->canonical_url,
'canonical_url' => null,
'og_image' => $validated['og_image'] ?? $story->og_image,
'submitted_for_review_at' => $workflow['status'] === 'pending_review' ? ($story->submitted_for_review_at ?? now()) : $story->submitted_for_review_at,
]);
@@ -642,7 +640,6 @@ class StoryController extends Controller
'tags_csv' => ['nullable', 'string', 'max:500'],
'meta_title' => ['nullable', 'string', 'max:255'],
'meta_description' => ['nullable', 'string', 'max:300'],
'canonical_url' => ['nullable', 'url', 'max:500'],
'og_image' => ['nullable', 'string', 'max:500'],
'status' => ['nullable', Rule::in(['draft', 'pending_review', 'published', 'scheduled', 'archived', 'rejected'])],
'scheduled_for' => ['nullable', 'date'],
@@ -673,7 +670,7 @@ class StoryController extends Controller
'status' => 'draft',
'meta_title' => $validated['meta_title'] ?? $title,
'meta_description' => $validated['meta_description'] ?? Str::limit((string) ($validated['excerpt'] ?? ''), 160),
'canonical_url' => $validated['canonical_url'] ?? null,
'canonical_url' => null,
'og_image' => $validated['og_image'] ?? ($validated['cover_image'] ?? null),
]);
} else {
@@ -697,7 +694,7 @@ class StoryController extends Controller
'status' => $nextStatus,
'meta_title' => $validated['meta_title'] ?? $story->meta_title,
'meta_description' => $validated['meta_description'] ?? $story->meta_description,
'canonical_url' => $validated['canonical_url'] ?? $story->canonical_url,
'canonical_url' => null,
'og_image' => $validated['og_image'] ?? $story->og_image,
'scheduled_for' => ! empty($validated['scheduled_for']) ? now()->parse((string) $validated['scheduled_for']) : $story->scheduled_for,
]);
@@ -897,7 +894,6 @@ class StoryController extends Controller
'tags_csv' => ['nullable', 'string', 'max:500'],
'meta_title' => ['nullable', 'string', 'max:255'],
'meta_description' => ['nullable', 'string', 'max:300'],
'canonical_url' => ['nullable', 'url', 'max:500'],
'og_image' => ['nullable', 'string', 'max:500'],
]);
}

View File

@@ -158,6 +158,7 @@ final class StudioArtworksApiController extends Controller
'world_submissions' => 'sometimes|array|max:12',
'world_submissions.*.world_id' => 'required|integer|exists:worlds,id',
'world_submissions.*.note' => 'nullable|string|max:1000',
'world_submissions.*.source_surface' => 'nullable|string|max:80',
'evolution_target_artwork_id' => 'sometimes|nullable|integer|min:1',
'evolution_relation_type' => 'sometimes|nullable|string|in:remake_of,remaster_of,revision_of,inspired_by,variation_of',
'evolution_note' => 'sometimes|nullable|string|max:1200',
@@ -284,16 +285,8 @@ final class StudioArtworksApiController extends Controller
}
}
// Reindex in Meilisearch
try {
if ((bool) $artwork->is_public && (bool) $artwork->is_approved && $artwork->published_at) {
$artwork->searchable();
} else {
$artwork->unsearchable();
}
} catch (\Throwable $e) {
// Meilisearch may be unavailable
}
// Reindex in Meilisearch — dispatches IndexArtworkJob which writes directly, no Scout hop.
$this->searchIndexer->update($artwork);
// Reload relationships for response
$artwork->load(['categories.contentType', 'tags', 'group', 'primaryAuthor.profile', 'contributors.user.profile']);

View File

@@ -124,6 +124,23 @@ final class StudioController extends Controller
]);
}
public function uploadQueue(Request $request): Response
{
$queue = app(\App\Services\Uploads\UploadQueueService::class)->listPayload(
$request->user(),
$request->only(['batch_id', 'status', 'sort'])
);
return Inertia::render('Studio/StudioUploadQueue', [
'title' => 'Upload Queue',
'description' => 'Upload multiple artworks, track processing, and publish only when each draft is ready.',
'queue' => $queue,
'contentTypes' => $this->getCategories(),
'chunkSize' => (int) config('uploads.chunk.max_bytes', 5242880),
'chunkRequestTimeoutMs' => (int) config('uploads.chunk.request_timeout_ms', 45000),
]);
}
/**
* Archived (/studio/archived)
*/

View File

@@ -0,0 +1,231 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
use Intervention\Image\Encoders\WebpEncoder;
use Intervention\Image\ImageManager;
use RuntimeException;
final class StudioNewsMediaApiController extends Controller
{
private const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
private const MAX_FILE_SIZE_KB = 6144;
private const MAX_WIDTH = 2200;
private const MAX_HEIGHT = 1400;
private const MIN_WIDTH = 1200;
private const MIN_HEIGHT = 630;
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->authorizeNews($request);
$validated = $request->validate([
'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'];
try {
$stored = $this->storeMediaFile($file);
return response()->json([
'success' => true,
'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('News media upload failed', [
'user_id' => (int) ($request->user()?->id ?? 0),
'message' => $e->getMessage(),
]);
return response()->json([
'error' => 'Upload failed',
'message' => 'Could not upload image right now.',
], 500);
}
}
public function destroy(Request $request): JsonResponse
{
$this->authorizeNews($request);
$validated = $request->validate([
'path' => ['required', 'string', 'max:2048'],
]);
$this->deleteMediaFile((string) $validated['path']);
return response()->json([
'success' => true,
]);
}
/**
* @return array{path:string,width:int,height:int,size_bytes:int}
*/
private function storeMediaFile(UploadedFile $file): array
{
$this->assertImageManager();
$this->assertStorageIsAllowed();
$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 < self::MIN_WIDTH || $height < self::MIN_HEIGHT) {
throw new RuntimeException(sprintf(
'Image is too small. Minimum required size is %dx%d.',
self::MIN_WIDTH,
self::MIN_HEIGHT,
));
}
$image = $this->manager->read($raw)->scaleDown(width: self::MAX_WIDTH, height: self::MAX_HEIGHT);
$encoded = (string) $image->encode(new WebpEncoder(85));
$hash = hash('sha256', $encoded);
$path = $this->mediaPath($hash);
$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 authorizeNews(Request $request): void
{
abort_unless($request->user() && ($request->user()->isAdmin() || $request->user()->isModerator()), 403);
}
private function mediaDiskName(): string
{
return (string) config('uploads.object_storage.disk', 's3');
}
private function mediaPath(string $hash): string
{
return sprintf(
'news/covers/%s/%s/%s.webp',
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 deleteMediaFile(string $path): void
{
$trimmed = ltrim(trim($path), '/');
if ($trimmed === '' || ! Str::startsWith($trimmed, 'news/covers/')) {
return;
}
Storage::disk($this->mediaDiskName())->delete($trimmed);
}
private function assertImageManager(): void
{
if ($this->manager !== null) {
return;
}
throw new RuntimeException('Image processing is not available on this environment.');
}
private function assertStorageIsAllowed(): void
{
if (! app()->environment('production')) {
return;
}
$diskName = $this->mediaDiskName();
if (in_array($diskName, ['local', 'public'], true)) {
throw new RuntimeException('Production news media storage must use object storage, not local/public disks.');
}
}
}

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller;
use App\Services\Uploads\UploadQueueService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
final class StudioUploadQueueApiController extends Controller
{
public function index(Request $request, UploadQueueService $queue): JsonResponse
{
return response()->json(
$queue->listPayload($request->user(), $request->only(['batch_id', 'status', 'sort']))
);
}
public function store(Request $request, UploadQueueService $queue): JsonResponse
{
$validated = $request->validate([
'name' => ['nullable', 'string', 'max:160'],
'files' => ['required', 'array', 'min:1', 'max:50'],
'files.*.name' => ['required', 'string', 'max:255'],
'defaults' => ['nullable', 'array'],
'defaults.category_id' => ['nullable', 'integer', 'exists:categories,id'],
'defaults.tags' => ['nullable', 'array', 'max:' . (int) config('tags.max_user_tags', 30)],
'defaults.tags.*' => ['string', 'max:64'],
'defaults.visibility' => ['nullable', 'string', 'in:public,unlisted,private'],
'defaults.is_mature' => ['nullable', 'boolean'],
'defaults.group' => ['nullable', 'string', 'max:90'],
]);
$batch = $queue->createBatch(
$request->user(),
(array) $validated['files'],
(array) ($validated['defaults'] ?? []),
Arr::get($validated, 'name')
);
return response()->json([
'batch' => [
'id' => (int) $batch->id,
'name' => $batch->name,
],
'items' => $batch->items->map(fn ($item): array => [
'id' => (int) $item->id,
'artwork_id' => (int) $item->artwork_id,
'original_filename' => (string) $item->original_filename,
])->values()->all(),
'queue' => $queue->listPayload($request->user(), ['batch_id' => (int) $batch->id]),
], 201);
}
public function markFailed(Request $request, int $id, UploadQueueService $queue): JsonResponse
{
$validated = $request->validate([
'error_code' => ['nullable', 'string', 'max:64'],
'error_message' => ['nullable', 'string', 'max:4000'],
]);
$queue->markItemFailedForUser(
$request->user(),
$id,
(string) ($validated['error_code'] ?? 'upload_failed'),
(string) ($validated['error_message'] ?? 'Upload failed before processing completed.')
);
return response()->json(['ok' => true]);
}
public function bulk(Request $request, UploadQueueService $queue): JsonResponse
{
$validated = $request->validate([
'action' => ['required', 'string', 'in:publish,delete,apply_category,apply_tags,set_visibility,generate_ai'],
'item_ids' => ['required', 'array', 'min:1', 'max:200'],
'item_ids.*' => ['integer'],
'params' => ['nullable', 'array'],
'params.category_id' => ['nullable', 'integer', 'exists:categories,id'],
'params.tags' => ['nullable', 'array', 'max:' . (int) config('tags.max_user_tags', 30)],
'params.tags.*' => ['string', 'max:64'],
'params.visibility' => ['nullable', 'string', 'in:public,unlisted,private'],
'confirm' => ['required_if:action,delete', 'string'],
]);
if (($validated['action'] ?? '') === 'delete' && ($validated['confirm'] ?? '') !== 'DELETE') {
return response()->json([
'errors' => ['You must type DELETE to confirm draft deletion.'],
'success' => 0,
'failed' => count((array) ($validated['item_ids'] ?? [])),
], 422);
}
$result = $queue->bulkAction(
$request->user(),
(string) $validated['action'],
(array) $validated['item_ids'],
(array) ($validated['params'] ?? [])
);
return response()->json($result, $result['success'] > 0 ? 200 : 422);
}
public function retry(Request $request, int $id, UploadQueueService $queue): JsonResponse
{
$queue->retryProcessingForUser($request->user(), $id);
return response()->json(['ok' => true]);
}
}

View File

@@ -38,9 +38,11 @@ use App\Services\UserSuggestionService;
use App\Services\Countries\CountryCatalogService;
use App\Services\Maturity\ArtworkMaturityService;
use App\Services\ThumbnailPresenter;
use App\Services\Worlds\WorldRewardService;
use App\Services\XPService;
use App\Services\UsernameApprovalService;
use App\Services\Profile\CreatorJourneyService;
use App\Services\Profile\WorldProfileHistoryService;
use App\Services\UserStatsService;
use App\Support\AvatarUrl;
use App\Support\CoverUrl;
@@ -66,6 +68,7 @@ class ProfileController extends Controller
'artworks',
'stories',
'achievements',
'worlds',
'collections',
'about',
'stats',
@@ -87,6 +90,8 @@ class ProfileController extends Controller
private readonly CountryCatalogService $countryCatalog,
private readonly UserSuggestionService $userSuggestions,
private readonly CreatorJourneyService $creatorJourney,
private readonly WorldRewardService $worldRewards,
private readonly WorldProfileHistoryService $worldProfileHistory,
)
{
}
@@ -1267,6 +1272,10 @@ class ProfileController extends Controller
->mapWithKeys(fn (string $tab) => [$tab => url('/@' . $usernameSlug . '/' . $tab)])
->all();
$achievementSummary = $this->achievements->summary((int) $user->id);
$worldRewardSummary = $this->worldRewards->summaryForUser($user);
$worldHistory = $isOwner
? $this->worldProfileHistory->ownerPayloadForUser($user)
: $this->worldProfileHistory->publicPayloadForUser($user);
$leaderboardRank = $this->leaderboards->creatorRankSummary((int) $user->id);
$groupContributionHistory = $this->buildGroupContributionHistory($user);
$journey = $this->creatorJourney->publicPayloadForUser($user);
@@ -1342,6 +1351,8 @@ class ProfileController extends Controller
'creatorStories' => $creatorStories->values(),
'collections' => $profileCollectionsPayload,
'achievements' => $achievementSummary,
'worldRewards' => $worldRewardSummary,
'worldHistory' => $worldHistory,
'leaderboardRank' => $leaderboardRank,
'journey' => $journey,
'groupContributionHistory' => $groupContributionHistory,

View File

@@ -3,10 +3,10 @@
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\ArtworkDownload;
use Illuminate\Support\Str;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class TodayDownloadsController extends Controller
{
@@ -15,20 +15,40 @@ class TodayDownloadsController extends Controller
$hits = 30;
$today = Carbon::now()->toDateString();
$artworkVisibilityScope = function ($q) {
$q->public()->published()->whereNull('deleted_at');
};
$hasTodayDownloads = ArtworkDownload::query()
->whereHas('artwork', $artworkVisibilityScope)
->whereDate('created_at', $today)
->exists();
$query = ArtworkDownload::with([
'artwork.user:id,name,username',
'artwork.user.profile:user_id,avatar_hash',
'artwork.categories:id,name,slug',
])
->whereDate('created_at', $today)
->whereHas('artwork', function ($q) {
$q->public()->published()->whereNull('deleted_at');
})
->whereHas('artwork', $artworkVisibilityScope)
->selectRaw('artwork_id, COUNT(*) as num_downloads')
->groupBy('artwork_id')
->orderByDesc('num_downloads');
if ($hasTodayDownloads) {
$query->whereDate('created_at', $today);
} else {
$fallbackDownloadIds = ArtworkDownload::query()
->whereHas('artwork', $artworkVisibilityScope)
->orderByDesc('created_at')
->limit(1000)
->pluck('id');
if ($fallbackDownloadIds->isEmpty()) {
$query->whereRaw('1 = 0');
} else {
$query->whereIn('id', $fallbackDownloadIds->all());
}
}
$paginator = $query->paginate($hits)->withQueryString();
$paginator->getCollection()->transform(function ($row) {
@@ -61,7 +81,7 @@ class TodayDownloadsController extends Controller
$categoryId = $primaryCategory->id ?? null;
$categoryName = $primaryCategory->name ?? '';
$categorySlug = $primaryCategory->slug ?? '';
$avatarHash = $art->user->profile->avatar_hash ?? null;
$avatarHash = $art->user?->profile?->avatar_hash;
return (object) [
'id' => $art->id ?? null,
@@ -87,8 +107,15 @@ class TodayDownloadsController extends Controller
];
});
$page_title = 'Today Downloaded Artworks';
$page_title = $hasTodayDownloads
? 'Today Downloaded Artworks'
: 'Most Downloaded from Latest 1000 Downloads';
return view('web.downloads.today', ['page_title' => $page_title, 'artworks' => $paginator]);
return view('web.downloads.today', [
'page_title' => $page_title,
'artworks' => $paginator,
'display_date' => $today,
'is_fallback_window' => ! $hasTodayDownloads,
]);
}
}

View File

@@ -4,10 +4,12 @@ declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Enums\ReactionType;
use App\Http\Controllers\Controller;
use App\Http\Resources\ArtworkResource;
use App\Models\Artwork;
use App\Models\ArtworkComment;
use Illuminate\Support\Facades\DB;
use App\Services\ContentSanitizer;
use App\Services\ThumbnailPresenter;
use App\Services\ErrorSuggestionService;
@@ -21,6 +23,8 @@ use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Str;
use Illuminate\View\View;
use Inertia\Inertia;
use Inertia\Response as InertiaResponse;
final class ArtworkPageController extends Controller
{
@@ -29,7 +33,7 @@ final class ArtworkPageController extends Controller
private readonly ArtworkMaturityService $maturity,
) {}
public function show(Request $request, int $id, ?string $slug = null): View|RedirectResponse|Response
public function show(Request $request, int $id, ?string $slug = null): View|RedirectResponse|Response|InertiaResponse
{
// ── Step 1: check existence including soft-deleted ─────────────────
$raw = Artwork::withTrashed()->where('id', $id)->first();
@@ -181,8 +185,8 @@ final class ArtworkPageController extends Controller
$itemSlug = (string) $item->id;
}
$sm = ThumbnailPresenter::present($item, 'sm');
$md = ThumbnailPresenter::present($item, 'md');
$lg = ThumbnailPresenter::present($item, 'lg');
return $this->maturity->decoratePayload([
'id' => (int) $item->id,
@@ -192,8 +196,8 @@ final class ArtworkPageController extends Controller
'publisher_type' => $item->group ? 'group' : 'user',
'publisher_id' => $item->group ? (int) $item->group->id : (int) ($item->user?->id ?? 0),
'url' => route('art.show', ['id' => $item->id, 'slug' => $itemSlug]),
'thumb' => $md['url'] ?? null,
'thumb_srcset' => ($md['url'] ?? '') . ' 640w, ' . ($lg['url'] ?? '') . ' 1280w',
'thumb' => $sm['url'] ?? null,
'thumb_srcset' => ($sm['url'] ?? '') . ' 320w, ' . ($md['url'] ?? '') . ' 640w',
], $item, request()->user());
})
->values()
@@ -249,20 +253,65 @@ final class ArtworkPageController extends Controller
->values()
->all();
return view('artworks.show', [
'artwork' => $artwork,
'artworkData' => $artworkData,
'presentMd' => $thumbMd,
'presentLg' => $thumbLg,
'presentXl' => $thumbXl,
'presentSq' => $thumbSq,
'meta' => $meta,
'seo' => $seo,
'useUnifiedSeo' => true,
'relatedItems' => $related,
'comments' => $comments,
'groupSummary' => $groupSummary,
]);
$canReadSession = $request->hasSession() && ! $request->attributes->get('skinbase.session_skipped');
$userId = ($canReadSession && $request->user() !== null) ? (int) $request->user()->id : null;
return Inertia::render('ArtworkPage', [
'artwork' => $artworkData,
'presentMd' => $thumbMd,
'presentLg' => $thumbLg,
'presentXl' => $thumbXl,
'presentSq' => $thumbSq,
'related' => $related,
'canonicalUrl' => $canonical,
'comments' => $comments,
'groupSummary' => $groupSummary,
'isAuthenticated' => $userId !== null,
'reactionTotals' => $this->artworkReactionTotals((int) $artwork->id, $userId),
'seo' => $seo,
])->rootView('artworks.show');
}
/**
* Build per-slug reaction totals for the given artwork, including
* whether the given user has each reaction (mine=true).
*
* Mirrors ReactionController::getTotals() so the page can render
* the correct state without a separate client-side fetch on first load.
*/
private function artworkReactionTotals(int $artworkId, ?int $userId): array
{
$rows = DB::table('artwork_reactions')
->where('artwork_id', $artworkId)
->selectRaw('reaction, COUNT(*) as total')
->groupBy('reaction')
->get()
->keyBy('reaction');
$totals = [];
foreach (ReactionType::cases() as $type) {
$slug = $type->value;
$count = (int) ($rows[$slug]->total ?? 0);
$mine = false;
if ($userId !== null && $count > 0) {
$mine = DB::table('artwork_reactions')
->where('artwork_id', $artworkId)
->where('reaction', $slug)
->where('user_id', $userId)
->exists();
}
$totals[$slug] = [
'emoji' => $type->emoji(),
'label' => $type->label(),
'count' => $count,
'mine' => $mine,
];
}
return $totals;
}
/** Silently catch suggestion query failures so error page never crashes. */

View File

@@ -20,6 +20,8 @@ use Illuminate\Pagination\AbstractCursorPaginator;
class BrowseGalleryController extends \App\Http\Controllers\Controller
{
private const CACHE_VERSION = 'v4';
/**
* Meilisearch sort-field arrays per sort alias.
* First element is primary sort; subsequent elements are tie-breakers.
@@ -28,18 +30,18 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
// ── Nova sort aliases ─────────────────────────────────────────────────
// trending_score_24h only covers artworks ≤ 7 days old; use 7d score
// and favorites_count as fallbacks so older artworks don't all tie at 0.
'trending' => ['trending_score_24h:desc', 'trending_score_7d:desc', 'favorites_count:desc', 'created_at:desc'],
'trending' => ['trending_score_24h:desc', 'trending_score_7d:desc', 'favorites_count:desc', 'published_at_ts:desc'],
// "New & Hot": 30-day trending window surfaces recently-active artworks.
'fresh' => ['trending_score_7d:desc', 'favorites_count:desc', 'created_at:desc'],
'fresh' => ['published_at_ts:desc', 'trending_score_7d:desc', 'favorites_count:desc'],
'top-rated' => ['awards_received_count:desc', 'favorites_count:desc'],
'favorited' => ['favorites_count:desc', 'trending_score_24h:desc'],
'downloaded' => ['downloads_count:desc', 'trending_score_24h:desc'],
'oldest' => ['created_at:asc'],
'favorited' => ['favorites_count:desc', 'trending_score_24h:desc', 'published_at_ts:desc'],
'downloaded' => ['downloads_count:desc', 'trending_score_24h:desc', 'published_at_ts:desc'],
'oldest' => ['published_at_ts:asc'],
// ── Legacy aliases (backward compat) ──────────────────────────────────
'latest' => ['created_at:desc'],
'popular' => ['views:desc', 'favorites_count:desc'],
'liked' => ['likes:desc', 'favorites_count:desc'],
'downloads' => ['downloads:desc', 'downloads_count:desc'],
'latest' => ['published_at_ts:desc'],
'popular' => ['views:desc', 'favorites_count:desc', 'published_at_ts:desc'],
'liked' => ['likes:desc', 'favorites_count:desc', 'published_at_ts:desc'],
'downloads' => ['downloads:desc', 'downloads_count:desc', 'published_at_ts:desc'],
];
/**
@@ -66,6 +68,7 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
private const SORT_OPTIONS = [
['value' => 'trending', 'label' => '🔥 Trending'],
['value' => 'fresh', 'label' => '🆕 Fresh'],
['value' => 'latest', 'label' => '🕐 Latest'],
['value' => 'top-rated', 'label' => '⭐ Top Rated'],
['value' => 'favorited', 'label' => '❤️ Most Favorited'],
['value' => 'downloaded', 'label' => '⬇ Most Downloaded'],
@@ -88,11 +91,11 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
$ttl = self::SORT_TTL_MAP[$sort] ?? 300;
$artworks = Cache::remember(
"browse.all.catalog-visible.v2.{$sort}.{$page}",
"browse.all.catalog-visible." . self::CACHE_VERSION . ".{$sort}.{$page}",
$ttl,
fn () => $this->search->searchWithThumbnailPreference([
'filter' => 'is_public = true AND is_approved = true',
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
'sort' => self::SORT_MAP[$sort] ?? ['published_at_ts:desc'],
], $perPage, false, $page)
);
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
@@ -150,11 +153,11 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
$normalizedPath = trim((string) $path, '/');
if ($normalizedPath === '') {
$artworks = Cache::remember(
"gallery.ct.catalog-visible.v2.{$contentSlug}.{$sort}.{$page}",
"gallery.ct.catalog-visible." . self::CACHE_VERSION . ".{$contentSlug}.{$sort}.{$page}",
$ttl,
fn () => $this->search->searchWithThumbnailPreference([
'filter' => 'is_public = true AND is_approved = true AND content_type = "' . $contentSlug . '"',
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
'filter' => 'is_public = true AND is_approved = true AND ' . $this->contentTypeFilterClause($contentSlug),
'sort' => self::SORT_MAP[$sort] ?? ['published_at_ts:desc'],
], $perPage, false, $page)
);
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
@@ -192,16 +195,14 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
}
$categorySlugs = $this->categoryFilterSlugs($category);
$categoryFilter = collect($categorySlugs)
->map(fn (string $slug) => 'category = "' . addslashes($slug) . '"')
->implode(' OR ');
$filterExpression = $this->categoryPageFilterExpression($contentSlug, $categorySlugs);
$artworks = Cache::remember(
'gallery.cat.catalog-visible.v2.' . md5($contentSlug . '|' . implode('|', $categorySlugs)) . ".{$sort}.{$page}",
'gallery.cat.catalog-visible.' . self::CACHE_VERSION . '.' . md5($contentSlug . '|' . implode('|', $categorySlugs)) . ".{$sort}.{$page}",
$ttl,
fn () => $this->search->searchWithThumbnailPreference([
'filter' => 'is_public = true AND is_approved = true AND (' . $categoryFilter . ')',
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
'filter' => $filterExpression,
'sort' => self::SORT_MAP[$sort] ?? ['published_at_ts:desc'],
], $perPage, false, $page)
);
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
@@ -369,6 +370,31 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
return array_values(array_unique($slugs));
}
private function categoryFilterClause(string $categorySlug): string
{
$quoted = addslashes($categorySlug);
return '(category = "' . $quoted . '" OR categories = "' . $quoted . '")';
}
private function categoryPageFilterExpression(string $contentTypeSlug, array $categorySlugs): string
{
$categoryFilter = collect($categorySlugs)
->map(fn (string $slug) => $this->categoryFilterClause($slug))
->implode(' OR ');
return 'is_public = true AND is_approved = true AND '
. $this->contentTypeFilterClause($contentTypeSlug)
. ' AND (' . $categoryFilter . ')';
}
private function contentTypeFilterClause(string $contentTypeSlug): string
{
$quoted = addslashes($contentTypeSlug);
return '(content_type = "' . $quoted . '" OR content_types = "' . $quoted . '")';
}
private function resolvePerPage(Request $request): int
{
$limit = (int) $request->query('limit', 0);
@@ -393,7 +419,7 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
private function mainCategories(): Collection
{
return $this->contentTypeResolver
->publicContentTypes()
->toolbarContentTypes()
->map(function (ContentType $type) {
return (object) [
'id' => $type->id,

View File

@@ -5,21 +5,24 @@ namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Services\ArtworkService;
use App\Services\CategoryDirectoryService;
use App\Models\Category;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class CategoryController extends Controller
{
protected ArtworkService $artworkService;
protected CategoryDirectoryService $categoryDirectory;
public function __construct(ArtworkService $artworkService)
public function __construct(ArtworkService $artworkService, CategoryDirectoryService $categoryDirectory)
{
$this->artworkService = $artworkService;
$this->categoryDirectory = $categoryDirectory;
}
public function index(Request $request)
{
return $this->browseCategories();
return $this->browseCategories($request);
}
public function show(Request $request, $id, $slug = null, $group = null)
@@ -58,20 +61,7 @@ class CategoryController extends Controller
}
try {
$category = Category::whereHas('contentType', function ($q) use ($contentTypeSlug) {
$q->where('slug', strtolower($contentTypeSlug));
})->whereNull('parent_id')->where('slug', strtolower($parts[0] ?? ''))->first();
if ($category && count($parts) > 1) {
$cur = $category;
foreach (array_slice($parts, 1) as $slugPart) {
$cur = $cur->children()->where('slug', strtolower($slugPart))->first();
if (! $cur) {
abort(404);
}
}
$category = $cur;
}
$category = $this->artworkService->resolveCategoryByPath($slugs);
} catch (\Throwable $e) {
$category = null;
}
@@ -109,12 +99,19 @@ class CategoryController extends Controller
));
}
public function browseCategories()
public function browseCategories(Request $request)
{
$pageTitle = 'All Categories Wallpapers, Skins & Digital Art | Skinbase';
$pageDescription = 'Browse all categories on Skinbase including wallpapers, skins, themes, and digital art collections.';
$payload = $this->categoryDirectory->getDirectoryPayload(
(string) $request->query('q', ''),
(string) $request->query('sort', 'popular'),
(int) $request->query('page', 1),
(int) $request->query('per_page', 24),
);
return view('web.categories', [
'initialPayload' => $payload,
'page_title' => $pageTitle,
'page_meta_description' => $pageDescription,
'page_canonical' => url('/categories'),

View File

@@ -176,6 +176,7 @@ final class DiscoverController extends Controller
'user:id,name',
'user.profile:user_id,avatar_hash',
'categories:id,name,slug,content_type_id,parent_id,sort_order',
'categories.contentType:id,slug,name',
])
->whereRaw('MONTH(published_at) = ?', [$today->month])
->whereRaw('DAY(published_at) = ?', [$today->day])
@@ -551,6 +552,7 @@ final class DiscoverController extends Controller
'user.profile:user_id,avatar_hash',
'group:id,name,slug,avatar_path',
'categories:id,name,slug,content_type_id,parent_id,sort_order',
'categories.contentType:id,slug,name',
])
->get()
->keyBy('id');

View File

@@ -18,6 +18,7 @@ use App\Services\ThumbnailPresenter;
use Illuminate\Http\Request;
use Illuminate\Pagination\AbstractCursorPaginator;
use Illuminate\Pagination\AbstractPaginator;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
@@ -238,12 +239,102 @@ final class ExploreController extends Controller
return $this->byType($request, $type);
}
// ── /explore/best (Hall of Fame) ────────────────────────────────────
/**
* Hall of Fame: all-time highest-medal artworks, ranked by prestige.
*
* Algorithm:
* 1. Primary: score_total DESC (all-time weighted medal score: gold×5 + silver×3 + bronze×1)
* 2. Secondary: gold_count DESC (prestige tiebreak golds are rarer and more deliberate)
* 3. Tertiary: favorites_count DESC (overall community love)
*
* Only artworks published 30 days ago are eligible so freshly-viral
* pieces don't crowd out genuine all-time standouts.
*
* Cache TTL is 1 hour rankings shift slowly for the HoF.
*/
public function hallOfFame(Request $request)
{
$perPage = 24;
$page = max(1, (int) $request->query('page', 1));
$minAge = now()->subDays(30);
$maturityUser = $request->user();
$cacheVersion = $this->cacheVersion();
$viewerSegment = $maturityUser ? 'auth.' . $maturityUser->id : 'guest';
$cacheKey = "explore.hall-of-fame.v{$cacheVersion}.{$viewerSegment}.p{$page}";
$paginator = Cache::remember($cacheKey, 3600, function () use ($perPage, $page, $minAge, $maturityUser): LengthAwarePaginator {
$query = Artwork::query()
->public()
->published()
->tap(fn ($b) => $this->maturity->applyViewerFilter($b, $maturityUser))
->withoutMissingThumbnails()
->with([
'user:id,name,username',
'user.profile:user_id,avatar_hash',
'group:id,name,slug,headline,avatar_path,followers_count',
'categories:id,name,slug,content_type_id,sort_order',
'categories.contentType:id,name,slug',
'awardStat:artwork_id,gold_count,silver_count,bronze_count,score_total',
'stats:artwork_id,favorites',
])
->leftJoin('artwork_medal_stats as hof', 'hof.artwork_id', '=', 'artworks.id')
->leftJoin('artwork_stats as hof_stats', 'hof_stats.artwork_id', '=', 'artworks.id')
->select('artworks.*')
// Must have at least one medal
->whereRaw('COALESCE(hof.score_total, 0) > 0')
// Minimum 30-day age to exclude freshly-viral pieces
->where('artworks.published_at', '<=', $minAge)
// Ranking: prestige-weighted medal score, then gold count, then favorites
->orderByRaw('COALESCE(hof.score_total, 0) DESC')
->orderByRaw('COALESCE(hof.gold_count, 0) DESC')
->orderByRaw('COALESCE(hof_stats.favorites, 0) DESC');
return $query->paginate($perPage, ['artworks.*'], 'page', $page)
->withPath(url('/explore/best'));
});
$paginator->getCollection()->transform(fn (Artwork $a) => $this->presentArtwork($a));
$mainCategories = $this->mainCategories();
$seo = $this->paginationSeo($request, url('/explore/best'), $paginator);
return view('gallery.index', [
'gallery_type' => 'browse',
'gallery_nav_section' => 'artworks',
'mainCategories' => $mainCategories,
'subcategories' => $mainCategories,
'contentType' => null,
'category' => null,
'artworks' => $paginator,
'spotlight' => collect(),
'hide_rank_tabs' => true,
'current_sort' => 'top-rated',
'sort_options' => [],
'hero_title' => 'Hall of Fame',
'hero_description' => 'All-time medal standouts ranked by prestige — the artworks the community has honoured most across the years.',
'breadcrumbs' => collect([
(object) ['name' => 'Explore', 'url' => '/explore'],
(object) ['name' => 'Hall of Fame', 'url' => '/explore/best'],
]),
'page_title' => 'Hall of Fame — All-Time Best Artworks - Skinbase',
'page_meta_description' => 'The highest-medal artworks of all time on Skinbase, ranked by gold, silver and bronze prestige.',
'page_meta_keywords' => 'hall of fame, best artworks, top rated, medals, skinbase',
'page_canonical' => $seo['canonical'],
'page_rel_prev' => $seo['prev'],
'page_rel_next' => $seo['next'],
'page_robots' => 'index,follow',
]);
}
// ── Helpers ──────────────────────────────────────────────────────────
private function mainCategories(): Collection
{
$categories = $this->contentTypeResolver
->publicContentTypes()
->toolbarContentTypes()
->map(fn ($ct) => (object) [
'name' => $ct->name,
'slug' => $ct->slug,
@@ -311,7 +402,8 @@ final class ExploreController extends Controller
];
if ($contentType !== null && $contentType !== '') {
$filterParts[] = 'content_type = "' . addslashes($contentType) . '"';
$quoted = addslashes($contentType);
$filterParts[] = '(content_type = "' . $quoted . '" OR content_types = "' . $quoted . '")';
}
$orientation = strtolower(trim((string) $request->query('orientation', '')));

View File

@@ -22,6 +22,7 @@ class FeaturedArtworksController extends Controller
public function index(Request $request)
{
$perPage = 39;
$viewer = $request->user();
$type = (int) ($request->query('type', 4));
@@ -31,32 +32,32 @@ class FeaturedArtworksController extends Controller
$artworks = $this->artworks->getFeaturedArtworks($typeFilter, $perPage);
$artworks->setCollection(
collect($this->maturity->filterPayloadItems($artworks->getCollection()->map(function (Artwork $artwork): array {
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
$categoryName = $primaryCategory->name ?? '';
$categorySlug = $primaryCategory->slug ?? '';
$gid = $primaryCategory ? ((int) $primaryCategory->id % 5) * 5 : 0;
$present = \App\Services\ThumbnailPresenter::present($artwork, 'md');
$username = $artwork->user->username ?? $artwork->user->name ?? 'Skinbase';
collect($this->maturity->filterPayloadItems($artworks->getCollection()->map(function (Artwork $artwork) use ($viewer): array {
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
$categoryName = $primaryCategory->name ?? '';
$categorySlug = $primaryCategory->slug ?? '';
$gid = $primaryCategory ? ((int) $primaryCategory->id % 5) * 5 : 0;
$present = \App\Services\ThumbnailPresenter::present($artwork, 'md');
$username = $artwork->user->username ?? $artwork->user->name ?? 'Skinbase';
return $this->maturity->decoratePayload([
'id' => $artwork->id,
'name' => $artwork->title,
'slug' => $artwork->slug,
'url' => url('/art/' . $artwork->id . '/' . Str::slug($artwork->title ?? 'artwork')),
'content_type_name' => $primaryCategory?->contentType?->name ?? '',
'content_type_slug' => $primaryCategory?->contentType?->slug ?? '',
'category_name' => $categoryName,
'category_slug' => $categorySlug,
'gid_num' => $gid,
'thumb_url' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'width' => $artwork->width,
'height' => $artwork->height,
'uname' => $artwork->user->name ?? 'Skinbase',
'username' => $username,
], $artwork, $request->user());
})->values()->all(), $request->user()))
return $this->maturity->decoratePayload([
'id' => $artwork->id,
'name' => $artwork->title,
'slug' => $artwork->slug,
'url' => url('/art/' . $artwork->id . '/' . Str::slug($artwork->title ?? 'artwork')),
'content_type_name' => $primaryCategory?->contentType?->name ?? '',
'content_type_slug' => $primaryCategory?->contentType?->slug ?? '',
'category_name' => $categoryName,
'category_slug' => $categorySlug,
'gid_num' => $gid,
'thumb_url' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'width' => $artwork->width,
'height' => $artwork->height,
'uname' => $artwork->user->name ?? 'Skinbase',
'username' => $username,
], $artwork, $viewer);
})->values()->all(), $viewer))
->map(static fn (array $item): object => (object) $item)
->values()
);

View File

@@ -3,12 +3,15 @@
namespace App\Http\Controllers\Web\Posts;
use App\Http\Controllers\Controller;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class FollowingFeedController extends Controller
{
public function __construct(private SeoFactory $seoFactory) {}
/**
* GET /feed/following
* Renders the Following Feed Inertia page.
@@ -16,6 +19,13 @@ class FollowingFeedController extends Controller
*/
public function index(Request $request): Response
{
$seo = $this->seoFactory->simplePage(
title: 'Following Feed — ' . config('seo.site_name', 'Skinbase'),
description: 'Posts from creators you follow on Skinbase.',
canonical: url('/feed/following'),
indexable: false,
);
return Inertia::render('Feed/FollowingFeed', [
'auth' => [
'user' => $request->user() ? [
@@ -25,6 +35,7 @@ class FollowingFeedController extends Controller
'avatar' => $request->user()->profile?->avatar_url ?? null,
] : null,
],
'seo' => $seo,
]);
}
}

View File

@@ -3,15 +3,26 @@
namespace App\Http\Controllers\Web\Posts;
use App\Http\Controllers\Controller;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class HashtagFeedController extends Controller
{
public function __construct(private SeoFactory $seoFactory) {}
/** GET /tags/{tag} */
public function index(Request $request, string $tag): Response
{
$normalTag = strtolower($tag);
$seo = $this->seoFactory->simplePage(
title: '#' . $normalTag . ' — ' . config('seo.site_name', 'Skinbase'),
description: 'Explore posts tagged with #' . $normalTag . ' on Skinbase.',
canonical: url('/tags/' . rawurlencode($normalTag)),
);
return Inertia::render('Feed/HashtagFeed', [
'auth' => $request->user() ? [
'user' => [
@@ -21,7 +32,8 @@ class HashtagFeedController extends Controller
'avatar' => $request->user()->profile?->avatar_url ?? null,
],
] : null,
'tag' => strtolower($tag),
'tag' => $normalTag,
'seo' => $seo,
]);
}
}

View File

@@ -3,15 +3,25 @@
namespace App\Http\Controllers\Web\Posts;
use App\Http\Controllers\Controller;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class SavedFeedController extends Controller
{
public function __construct(private SeoFactory $seoFactory) {}
/** GET /feed/saved */
public function index(Request $request): Response
{
$seo = $this->seoFactory->simplePage(
title: 'Saved Posts — ' . config('seo.site_name', 'Skinbase'),
description: 'Your saved posts on Skinbase.',
canonical: url('/feed/saved'),
indexable: false,
);
return Inertia::render('Feed/SavedFeed', [
'auth' => [
'user' => [
@@ -21,6 +31,7 @@ class SavedFeedController extends Controller
'avatar' => $request->user()->profile?->avatar_url ?? null,
],
],
'seo' => $seo,
]);
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Web\Posts;
use App\Http\Controllers\Controller;
use App\Services\Posts\PostHashtagService;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Inertia\Inertia;
@@ -11,7 +12,10 @@ use Inertia\Response;
class SearchFeedController extends Controller
{
public function __construct(private PostHashtagService $hashtagService) {}
public function __construct(
private PostHashtagService $hashtagService,
private SeoFactory $seoFactory,
) {}
/** GET /feed/search */
public function index(Request $request): Response
@@ -22,6 +26,12 @@ class SearchFeedController extends Controller
fn () => $this->hashtagService->trending(10, 24)
);
$seo = $this->seoFactory->simplePage(
title: 'Search Posts — ' . config('seo.site_name', 'Skinbase'),
description: 'Search posts, hashtags and creators on Skinbase.',
canonical: url('/feed/search'),
);
return Inertia::render('Feed/SearchFeed', [
'auth' => $request->user() ? [
'user' => [
@@ -31,8 +41,9 @@ class SearchFeedController extends Controller
'avatar' => $request->user()->profile?->avatar_url ?? null,
],
] : null,
'initialQuery' => $request->query('q', ''),
'trendingHashtags' => $trendingHashtags,
'initialQuery' => $request->query('q', ''),
'trendingHashtags' => $trendingHashtags,
'seo' => $seo,
]);
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Web\Posts;
use App\Http\Controllers\Controller;
use App\Services\Posts\PostHashtagService;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Inertia\Inertia;
@@ -11,13 +12,22 @@ use Inertia\Response;
class TrendingFeedController extends Controller
{
public function __construct(private PostHashtagService $hashtagService) {}
public function __construct(
private PostHashtagService $hashtagService,
private SeoFactory $seoFactory,
) {}
/** GET /feed/trending */
public function index(Request $request): Response
{
$trendingHashtags = Cache::remember('trending_hashtags', 300, fn () => $this->hashtagService->trending(10, 24));
$seo = $this->seoFactory->simplePage(
title: 'Trending Posts — ' . config('seo.site_name', 'Skinbase'),
description: 'Discover the most popular and engaging posts on Skinbase right now.',
canonical: url('/feed/trending'),
);
return Inertia::render('Feed/TrendingFeed', [
'auth' => $request->user() ? [
'user' => [
@@ -28,6 +38,7 @@ class TrendingFeedController extends Controller
],
] : null,
'trendingHashtags' => $trendingHashtags,
'seo' => $seo,
]);
}
}

View File

@@ -9,21 +9,32 @@ use App\Http\Resources\ArtworkListResource;
use App\Services\ArtworkSearchService;
use App\Services\GroupDiscoveryService;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\View\View;
use cPad\Plugins\News\Models\NewsArticle;
final class SearchController extends Controller
{
private const ALLOWED_SORTS = ['latest', 'popular', 'likes', 'downloads'];
public function __construct(
private readonly ArtworkSearchService $search,
private readonly GroupDiscoveryService $groups,
) {}
public function index(Request $request): View
public function index(Request $request): View|RedirectResponse
{
$q = trim((string) $request->query('q', ''));
$sort = $request->query('sort', 'latest');
$canonicalQuery = $this->canonicalQueryParameters($request);
$canonicalUrl = $this->canonicalSearchUrl($request, $canonicalQuery);
if ($request->fullUrl() !== $canonicalUrl) {
return redirect()->to($canonicalUrl, 301);
}
$q = (string) ($canonicalQuery['q'] ?? '');
$sort = (string) ($canonicalQuery['sort'] ?? 'latest');
$hasQuery = $q !== '';
$sortMap = [
@@ -98,4 +109,81 @@ final class SearchController extends Controller
'page_robots' => 'noindex,follow',
]);
}
/**
* @return array<string, int|string>
*/
private function canonicalQueryParameters(Request $request): array
{
$q = $this->normalizeSearchQuery($request->query('q', ''));
if ($q === '') {
return [];
}
$params = ['q' => $q];
$sort = $this->normalizeSort($request->query('sort', 'latest'));
$page = $this->normalizePage($request->query('page', 1));
if ($sort !== 'latest') {
$params['sort'] = $sort;
}
if ($page > 1) {
$params['page'] = $page;
}
return $params;
}
/**
* @param array<string, int|string> $params
*/
private function canonicalSearchUrl(Request $request, array $params): string
{
$query = Arr::query($params);
return $query === '' ? $request->url() : $request->url() . '?' . $query;
}
private function normalizeSearchQuery(mixed $value): string
{
$query = html_entity_decode($this->firstScalarValue($value), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$query = preg_replace('/(?:\?|&)(?:amp;)?(?:page|sort|filter|group|id|txtfilter|q)=.*$/i', '', $query) ?? $query;
$query = preg_replace('/\s+/u', ' ', $query) ?? $query;
return trim($query, " \t\n\r\0\x0B?&");
}
private function normalizeSort(mixed $value): string
{
$sort = strtolower($this->firstScalarValue($value));
$sort = preg_replace('/(?:\?|&).*/', '', $sort) ?? $sort;
return in_array($sort, self::ALLOWED_SORTS, true) ? $sort : 'latest';
}
private function normalizePage(mixed $value): int
{
$page = $this->firstScalarValue($value);
if (preg_match('/\d+/', $page, $matches) !== 1) {
return 1;
}
return max(1, (int) $matches[0]);
}
private function firstScalarValue(mixed $value): string
{
if (is_array($value)) {
$value = reset($value);
}
if (! is_scalar($value) && $value !== null) {
return '';
}
return trim((string) $value);
}
}

View File

@@ -259,7 +259,7 @@ final class SimilarArtworksPageController extends Controller
$quoted = array_map(fn (string $t): string => 'tags = "' . addslashes($t) . '"', $tagSlugs);
$filterParts[] = '(' . implode(' OR ', $quoted) . ')';
} elseif ($categorySlugs !== []) {
$quoted = array_map(fn (string $c): string => 'category = "' . addslashes($c) . '"', $categorySlugs);
$quoted = array_map(fn (string $c): string => '(category = "' . addslashes($c) . '" OR categories = "' . addslashes($c) . '")', $categorySlugs);
$filterParts[] = '(' . implode(' OR ', $quoted) . ')';
}

View File

@@ -54,7 +54,7 @@ final class TagController extends Controller
$artworks->getCollection()->each(fn($m) => $m->loadMissing(['user.profile', 'categories']));
// Sidebar: main content type links (same as browse gallery)
$mainCategories = ContentType::ordered()->get(['name', 'slug'])
$mainCategories = ContentType::ordered()->where('hide_from_menu', false)->get(['name', 'slug'])
->map(fn ($type) => (object) [
'id' => $type->id,
'name' => $type->name,

View File

@@ -5,9 +5,9 @@ declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\World;
use App\Services\Worlds\WorldService;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
@@ -32,16 +32,47 @@ final class WorldController extends Controller
]))->rootView('collections');
}
public function show(Request $request, World $world): Response
public function show(Request $request, string $world): Response|RedirectResponse
{
abort_unless($world->isPubliclyVisible(), 404);
$resolution = $this->worlds->resolvePublicWorld($world);
$resolvedWorld = $resolution['world'] ?? null;
$payload = $this->worlds->publicShowPayload($world, $request->user());
abort_unless($resolvedWorld !== null, 404);
if (! empty($resolution['redirect'])) {
return redirect()->to((string) $resolution['redirect'], 301);
}
$payload = $this->worlds->publicShowPayload($resolvedWorld, $request->user());
$seo = app(SeoFactory::class)->collectionPage(
$world->seo_title ?: ($world->title . ' — Skinbase Nova'),
$world->seo_description ?: ($world->summary ?: $world->description ?: 'Seasonal and editorial discovery world on Skinbase Nova.'),
route('worlds.show', ['world' => $world->slug]),
$world->ogImageUrl(),
$resolvedWorld->seo_title ?: ($resolvedWorld->title . ' — Skinbase Nova'),
$resolvedWorld->seo_description ?: ($resolvedWorld->summary ?: $resolvedWorld->description ?: 'Seasonal and editorial discovery world on Skinbase Nova.'),
$this->worlds->canonicalPublicUrl($resolvedWorld),
$resolvedWorld->ogImageUrl(),
)->toArray();
return Inertia::render('World/WorldShow', array_merge($payload, [
'seo' => $seo,
]))->rootView('collections');
}
public function showEdition(Request $request, string $world, int $year): Response|RedirectResponse
{
$resolution = $this->worlds->resolvePublicEdition($world, $year);
$resolvedWorld = $resolution['world'] ?? null;
abort_unless($resolvedWorld !== null, 404);
if (! empty($resolution['redirect'])) {
return redirect()->to((string) $resolution['redirect'], 301);
}
$payload = $this->worlds->publicShowPayload($resolvedWorld, $request->user());
$seo = app(SeoFactory::class)->collectionPage(
$resolvedWorld->seo_title ?: ($resolvedWorld->title . ' — Skinbase Nova'),
$resolvedWorld->seo_description ?: ($resolvedWorld->summary ?: $resolvedWorld->description ?: 'Seasonal and editorial discovery world on Skinbase Nova.'),
$this->worlds->canonicalPublicUrl($resolvedWorld),
$resolvedWorld->ogImageUrl(),
)->toArray();
return Inertia::render('World/WorldShow', array_merge($payload, [

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class EnsureStaffAccess
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (! $user || ! $user->hasStaffAccess()) {
if ($request->expectsJson() || $request->header('X-Inertia')) {
abort(Response::HTTP_FORBIDDEN, 'Forbidden.');
}
return redirect()->route('home')->with('error', 'You do not have access to this area.');
}
return $next($request);
}
}

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Http\Middleware;
use App\Services\GroupService;
use App\Support\AvatarUrl;
use Closure;
use Illuminate\Http\Request;
use Inertia\Middleware;
@@ -30,6 +32,10 @@ final class HandleInertiaRequests extends Middleware
return 'leaderboard';
}
if (str_starts_with($request->path(), 'admin') || str_starts_with($request->path(), 'moderation')) {
return 'admin';
}
if (str_starts_with($request->path(), 'studio')) {
return 'studio';
}
@@ -57,6 +63,11 @@ final class HandleInertiaRequests extends Middleware
return 'feed.hashtag';
}
// Forum pages
if (str_starts_with($request->path(), 'forum')) {
return 'forum';
}
return $this->rootView;
}
@@ -65,6 +76,20 @@ final class HandleInertiaRequests extends Middleware
return parent::version($request);
}
public function handle(Request $request, Closure $next): mixed
{
$response = parent::handle($request, $next);
// Prevent browsers from caching authenticated full-page SSR responses.
// Without this, a hard reload can replay stale SSR HTML from the browser
// cache instead of fetching fresh data from the server.
if ($request->user() !== null) {
$response->headers->set('Cache-Control', 'no-store, private');
}
return $response;
}
public function share(Request $request): array
{
$canReadSessionAuth = $this->canReadSessionAuth($request);
@@ -75,7 +100,11 @@ final class HandleInertiaRequests extends Middleware
'user' => $user ? [
'id' => $user->id,
'name' => $user->name,
'avatar_url' => $user->profile?->avatar_url ?: AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash, 64),
'is_admin' => $user->isAdmin(),
'is_manager' => $user->isManager(),
'is_editorial' => $user->isEditorial(),
'is_staff' => $user->hasStaffAccess(),
'is_moderator' => $user->isModerator(),
] : null,
],

View File

@@ -14,6 +14,7 @@ class VerifyCsrfToken extends Middleware
protected $except = [
'chat_post',
'chat_post/*',
'api/art/*/view',
// Apple Sign In removed — no special CSRF exception required
];
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Requests\Uploads;
use App\Models\Artwork;
use App\Models\UploadBatchItem;
use App\Repositories\Uploads\UploadSessionRepository;
use App\Services\Uploads\UploadTokenService;
use Illuminate\Foundation\Http\FormRequest;
@@ -13,6 +14,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final class UploadFinishRequest extends FormRequest
{
private ?Artwork $artwork = null;
private ?UploadBatchItem $batchItem = null;
public function authorize(): bool
{
@@ -97,6 +99,22 @@ final class UploadFinishRequest extends FormRequest
$this->denyAsNotFound();
}
$batchItemId = (int) $this->input('batch_item_id');
if ($batchItemId > 0) {
$batchItem = UploadBatchItem::query()->find($batchItemId);
if (! $batchItem || (int) $batchItem->user_id !== (int) $user->id) {
$this->logUnauthorized('batch_item_not_owned_or_missing');
$this->denyAsNotFound();
}
if ((int) ($batchItem->artwork_id ?? 0) > 0 && (int) $batchItem->artwork_id !== $artworkId) {
$this->logUnauthorized('batch_item_artwork_mismatch');
$this->denyAsNotFound();
}
$this->batchItem = $batchItem;
}
$this->artwork = $artwork;
return true;
@@ -109,6 +127,7 @@ final class UploadFinishRequest extends FormRequest
'artwork_id' => 'required|integer',
'upload_token' => 'nullable|string|min:40|max:200',
'file_name' => 'nullable|string|max:255',
'batch_item_id' => 'nullable|integer|min:1',
'archive_session_id' => 'nullable|uuid|different:session_id',
'archive_file_name' => 'nullable|string|max:255',
'additional_screenshot_sessions' => 'nullable|array|max:4',
@@ -126,6 +145,11 @@ final class UploadFinishRequest extends FormRequest
return $this->artwork;
}
public function batchItem(): ?UploadBatchItem
{
return $this->batchItem;
}
private function denyAsNotFound(): void
{
throw new NotFoundHttpException();

View File

@@ -7,6 +7,7 @@ use App\Services\ArtworkEvolutionService;
use App\Services\ContentSanitizer;
use App\Services\Maturity\ArtworkMaturityService;
use App\Services\ThumbnailPresenter;
use App\Services\Worlds\WorldRewardService;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
@@ -18,7 +19,7 @@ class ArtworkResource extends JsonResource
*/
public function toArray($request): array
{
$this->resource->loadMissing(['group', 'uploadedBy.profile', 'primaryAuthor.profile', 'contributors.user.profile', 'worldSubmissions.world']);
$this->resource->loadMissing(['group', 'uploadedBy.profile', 'primaryAuthor.profile', 'contributors.user.profile', 'worldSubmissions.world', 'worldRewardGrants.world']);
$md = ThumbnailPresenter::present($this->resource, 'md');
$lg = ThumbnailPresenter::present($this->resource, 'lg');
@@ -389,6 +390,10 @@ class ArtworkResource extends JsonResource
);
}
if (Schema::hasTable('world_reward_grants')) {
$items = $items->concat(app(WorldRewardService::class)->artworkRewardBadges($this->resource));
}
return $items
->sortBy('sort_priority')
->groupBy('world_id')