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,184 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Artwork;
use App\Services\ArtworkOriginalFileLocator;
use App\Services\Uploads\UploadStorageService;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
final class AuditArtworkDownloadFilesCommand extends Command
{
protected $signature = 'artworks:audit-download-files
{--id= : Audit only this artwork ID}
{--limit= : Stop after processing this many artworks}
{--chunk=500 : Number of artworks to scan per batch}
{--restore-missing : Copy missing local originals from object storage when available}';
protected $description = 'Scan artworks in descending ID order and report missing local download files with full URLs.';
public function handle(ArtworkOriginalFileLocator $locator, UploadStorageService $storage): int
{
$artworkId = $this->option('id') !== null ? max(1, (int) $this->option('id')) : null;
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
$chunkSize = max(1, min((int) $this->option('chunk'), 2000));
$restoreMissing = (bool) $this->option('restore-missing');
$this->info(sprintf(
'Starting download file audit. order=desc include_trashed=yes chunk=%d limit=%s restore_missing=%s',
$chunkSize,
$limit !== null ? (string) $limit : 'all',
$restoreMissing ? 'yes' : 'no',
));
$processed = 0;
$missing = 0;
$unresolved = 0;
$restored = 0;
$restoreFailed = 0;
$lastSeenId = null;
do {
$artworks = $this->nextChunk($artworkId, $chunkSize, $lastSeenId);
if ($artworks->isEmpty()) {
break;
}
foreach ($artworks as $artwork) {
if ($limit !== null && $processed >= $limit) {
break 2;
}
$localPath = $locator->resolveLocalPath($artwork);
$missingReason = null;
if ($localPath === '') {
$missingReason = 'unresolved_local_path';
$unresolved++;
} elseif (! File::isFile($localPath)) {
$missingReason = 'missing_local_file';
}
if ($missingReason !== null) {
$objectPath = $locator->resolveObjectPath($artwork);
$objectUrl = $locator->resolveObjectUrl($artwork);
$missing++;
$this->warn(sprintf('Artwork %d %s', (int) $artwork->id, $missingReason));
$this->line(' artwork_url: ' . route('art.show', [
'id' => (int) $artwork->id,
'slug' => (string) ($artwork->slug ?? ''),
]));
$this->line(' download_url: ' . route('art.download', ['id' => (int) $artwork->id]));
if ($objectPath !== '') {
$this->line(' object_path: ' . $objectPath);
}
if ($objectUrl !== null && $objectUrl !== '') {
$this->line(' object_url: ' . $objectUrl);
}
if ($localPath !== '') {
$this->line(' local_path: ' . $localPath);
}
if ($restoreMissing && $missingReason === 'missing_local_file' && $localPath !== '') {
$restoreResult = $this->restoreLocalFile($storage, $objectPath, $localPath);
if ($restoreResult === 'restored') {
$restored++;
$this->info(' restore: restored from object storage');
} elseif ($restoreResult === 'object_missing') {
$restoreFailed++;
$this->warn(' restore: object storage file not found');
} else {
$restoreFailed++;
$this->warn(' restore: failed to copy object to local path');
}
}
$this->line('');
}
$processed++;
}
$lastSeenId = (int) $artworks->last()->id;
} while (true);
$this->info(sprintf(
'Download file audit complete. processed=%d missing=%d unresolved=%d restored=%d restore_failed=%d',
$processed,
$missing,
$unresolved,
$restored,
$restoreFailed,
));
return self::SUCCESS;
}
/**
* @return Collection<int, Artwork>
*/
private function nextChunk(?int $artworkId, int $chunkSize, ?int $lastSeenId): Collection
{
$query = Artwork::query()
->withTrashed()
->select(['id', 'slug', 'file_path', 'hash', 'file_ext'])
->orderByDesc('id');
if ($artworkId !== null) {
$query->whereKey($artworkId);
} elseif ($lastSeenId !== null) {
$query->where('id', '<', $lastSeenId);
}
return $query->limit($chunkSize)->get();
}
private function restoreLocalFile(UploadStorageService $storage, string $objectPath, string $localPath): string
{
if ($objectPath === '') {
return 'object_missing';
}
$disk = Storage::disk($storage->objectDiskName());
if (! $disk->exists($objectPath)) {
return 'object_missing';
}
$stream = $disk->readStream($objectPath);
if (! is_resource($stream)) {
return 'failed';
}
File::ensureDirectoryExists(dirname($localPath));
$target = fopen($localPath, 'wb');
if (! is_resource($target)) {
fclose($stream);
return 'failed';
}
try {
$copied = stream_copy_to_stream($stream, $target);
} finally {
fclose($stream);
fclose($target);
}
if ($copied === false || $copied <= 0 || ! File::isFile($localPath)) {
return 'failed';
}
return 'restored';
}
}

View File

@@ -4,95 +4,271 @@ declare(strict_types=1);
namespace App\Console\Commands;
use App\Jobs\Sitemaps\BuildSitemapReleaseJob;
use App\Models\Artwork;
use App\Services\Sitemaps\SitemapBuildService;
use App\Services\Sitemaps\SitemapPublishService;
use App\Services\Sitemaps\SitemapImage;
use App\Services\Sitemaps\SitemapIndexItem;
use App\Services\Sitemaps\SitemapUrl;
use App\Services\ThumbnailPresenter;
use Illuminate\Console\Command;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
final class BuildSitemapsCommand extends Command
{
protected $signature = 'skinbase:sitemaps:build
{--only=* : Limit the build to one or more sitemap families}
{--release= : Override the generated release id}
{--shards : Show per-shard output in the command report}
{--queue : Dispatch the release build to the queue}
{--force : Accepted for backward compatibility; release builds are always fresh}
{--clear : Accepted for backward compatibility; release builds are isolated}
{--dry-run : Build a release artifact set without activating it}';
{--only=* : Limit to specific sitemap families (comma or space separated)}
{--disk= : Override the target filesystem disk (default: sitemaps.static_publish.disk)}
{--progress : Show a progress bar tracking processed URLs for each family}';
protected $description = 'Build a versioned sitemap release artifact set.';
protected $description = 'Build all sitemaps and write them as static .xml files to public/.';
public function handle(SitemapBuildService $build, SitemapPublishService $publish): int
public function handle(SitemapBuildService $build): int
{
$startedAt = microtime(true);
$families = $this->selectedFamilies($build);
$releaseId = ($value = $this->option('release')) !== null && trim((string) $value) !== '' ? trim((string) $value) : null;
$totalStart = microtime(true);
$families = $this->selectedFamilies($build);
if ($families === []) {
$this->error('No valid sitemap families were selected.');
$this->error('No valid sitemap families selected.');
return self::INVALID;
}
$showShards = (bool) $this->option('shards');
$diskName = (string) ($this->option('disk') ?: config('sitemaps.static_publish.disk', 'sitemaps_public'));
$disk = Storage::disk($diskName);
$written = 0;
$failed = 0;
if ((bool) $this->option('queue')) {
BuildSitemapReleaseJob::dispatch($families, $releaseId);
$this->info('Queued sitemap release build' . ($releaseId !== null ? ' for [' . $releaseId . '].' : '.'));
$this->info('Disk: ' . $diskName);
$this->info('Families: ' . implode(', ', $families));
$this->newLine();
return self::SUCCESS;
}
try {
$manifest = $publish->buildRelease($families, $releaseId);
} catch (\Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$totalUrls = 0;
$totalDocuments = 0;
// ── Root sitemap index ────────────────────────────────────────────
$t = microtime(true);
$this->line(' Building sitemap index…');
$index = $build->buildIndex(force: true, persist: false, families: $families);
$disk->put('sitemap.xml', $index['content']);
$written++;
$this->line(sprintf(
' <info>✔</info> sitemap.xml %d entries <comment>%.3fs</comment>',
$index['url_count'],
microtime(true) - $t,
));
// ── Per-family documents ──────────────────────────────────────────
foreach ($families as $family) {
$names = (array) data_get($manifest, 'families.' . $family . '.documents', []);
$familyUrls = 0;
$familyStart = microtime(true);
if (! $showShards) {
$this->line('Building family [' . $family . '] with ' . count($names) . ' document(s).');
$this->newLine();
if ($family === 'artworks') {
// Direct MySQL path — no cursor-scan shard window computation
[$shardNames, $fw, $ff] = $this->buildArtworksDirect($disk, (bool) $this->option('progress'));
$written += $fw;
$failed += $ff;
$this->line(sprintf(
' <fg=cyan>artworks</> done %d file(s) <comment>%.3fs</comment>',
$fw,
microtime(true) - $familyStart,
));
continue;
}
foreach ($names as $name) {
$documentType = str_ends_with((string) $name, '-index') ? 'index' : ((string) $family === (string) config('sitemaps.news.google_variant_name', 'news-google') ? 'google-news' : 'urlset');
$familyUrls += (int) data_get($manifest, 'families.' . $family . '.url_count', 0);
$totalUrls += (int) data_get($manifest, 'families.' . $family . '.url_count', 0);
$totalDocuments++;
$names = $build->canonicalDocumentNamesForFamily($family);
if ($showShards || ! str_contains((string) $name, '-000')) {
$this->line(sprintf(' <fg=cyan>%s</> (%d document(s))', $family, count($names)));
foreach ($names as $documentName) {
$t = microtime(true);
$this->line(sprintf(' Building %s…', $documentName));
$built = $build->buildNamed($documentName, force: true, persist: false);
if ($built === null) {
$this->line(sprintf(' <comment></comment> %s <fg=red>SKIPPED</> (builder returned null)', $documentName));
$failed++;
continue;
}
$disk->put('sitemaps/' . $documentName . '.xml', $built['content']);
$written++;
$this->line(sprintf(
' <info>✔</info> %s %d URLs <comment>%.3fs</comment>',
$documentName . '.xml',
$built['url_count'] ?? 0,
microtime(true) - $t,
));
}
$this->line(sprintf(
' <fg=cyan>%s</> done <comment>%.3fs</comment>',
$family,
microtime(true) - $familyStart,
));
}
// ── Summary ───────────────────────────────────────────────────────
$this->newLine();
$this->info(sprintf(
'Done: %d file(s) written, %d failed total <comment>%.3fs</comment>',
$written,
$failed,
microtime(true) - $totalStart,
));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* Stream artworks directly from MySQL using chunkById avoids cursor-scan shard windows.
*
* @return array{0: list<string>, 1: int, 2: int} [shardNames, written, failed]
*/
private function buildArtworksDirect(Filesystem $disk, bool $showProgress = false): array
{
$chunkSize = max(1, (int) config('sitemaps.shards.artworks.size', 10_000));
$padLen = max(1, (int) config('sitemaps.shards.zero_pad_length', 4));
$shardNum = 0;
$shardNames = [];
$written = 0;
$failed = 0;
$baseQuery = Artwork::query()
->public()
->published();
$total = $showProgress ? (clone $baseQuery)->count() : null;
$this->line(sprintf(
' <fg=cyan>artworks</> (chunk size: %d%s)',
$chunkSize,
$total !== null ? ', total: ' . number_format($total) : '',
));
$bar = null;
if ($total !== null) {
$bar = $this->output->createProgressBar($total);
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% shard %message% elapsed: %elapsed:6s% mem: %memory:6s%');
$bar->setMessage('—');
$bar->start();
$this->newLine();
}
$baseQuery
->select(['id', 'slug', 'title', 'updated_at', 'published_at', 'created_at', 'hash', 'file_path', 'file_name'])
->orderBy('id')
->chunkById($chunkSize, function ($artworks) use ($disk, $padLen, $bar, &$shardNum, &$shardNames, &$written, &$failed): void {
$shardNum++;
$t = microtime(true);
$name = 'artworks-' . str_pad((string) $shardNum, $padLen, '0', STR_PAD_LEFT);
if ($bar !== null) {
$bar->setMessage($name);
} else {
$this->line(sprintf(' Building %s (%d rows)…', $name, $artworks->count()));
}
/** @var list<SitemapUrl> $items */
$items = $artworks
->map(fn (Artwork $artwork): ?SitemapUrl => $this->artworkSitemapUrl($artwork))
->filter()
->values()
->all();
$xml = view('sitemaps.urlset', [
'items' => $items,
'hasImages' => collect($items)->contains(fn (SitemapUrl $item): bool => $item->images !== []),
])->render();
if (! $disk->put('sitemaps/' . $name . '.xml', $xml)) {
if ($bar !== null) {
$bar->advance($artworks->count());
$this->newLine();
}
$this->line(sprintf(' <comment></comment> %s <fg=red>WRITE FAILED</>', $name . '.xml'));
$failed++;
return;
}
$shardNames[] = $name;
$written++;
if ($bar !== null) {
$bar->advance($artworks->count());
} else {
$this->line(sprintf(
' - %s [%s]',
$name,
$documentType,
' <info>✔</info> %s %d URLs <comment>%.3fs</comment>',
$name . '.xml',
count($items),
microtime(true) - $t,
));
}
}
});
$this->info(sprintf('Family [%s] complete: urls=%d documents=%d', $family, (int) data_get($manifest, 'families.' . $family . '.url_count', 0), count($names)));
if ($bar !== null) {
$bar->setMessage('done');
$bar->finish();
$this->newLine();
}
$totalDocuments++;
$this->info(sprintf(
'Sitemap release [%s] complete: families=%d documents=%d urls=%d status=%s duration=%.2fs',
(string) $manifest['release_id'],
(int) data_get($manifest, 'totals.families', 0),
(int) data_get($manifest, 'totals.documents', 0),
(int) data_get($manifest, 'totals.urls', 0),
(string) ($manifest['status'] ?? 'built'),
microtime(true) - $startedAt,
if ($shardNames === []) {
return [[], 0, $failed];
}
// Write artworks-index.xml when there are multiple shards (matches SitemapShardService behaviour)
if (count($shardNames) > 1) {
$t = microtime(true);
$indexItems = array_map(
fn (string $n): SitemapIndexItem => new SitemapIndexItem(url('/sitemaps/' . $n . '.xml')),
$shardNames,
);
$indexXml = view('sitemaps.index', ['items' => $indexItems])->render();
$disk->put('sitemaps/artworks-index.xml', $indexXml);
$written++;
$this->line(sprintf(
' <info>✔</info> artworks-index.xml %d shards <comment>%.3fs</comment>',
count($shardNames),
microtime(true) - $t,
));
}
return [$shardNames, $written, $failed];
}
private function artworkSitemapUrl(Artwork $artwork): ?SitemapUrl
{
$slug = Str::slug((string) ($artwork->slug ?: $artwork->title));
if ($slug === '') {
$slug = (string) $artwork->id;
}
$preview = ThumbnailPresenter::present($artwork, 'xl');
$images = [];
if (! empty($preview['url'])) {
$images[] = new SitemapImage((string) $preview['url'], $artwork->title ?: null);
}
$timestamps = array_filter(array_map(
static fn (mixed $v): ?Carbon => $v instanceof Carbon ? $v : (is_string($v) ? Carbon::parse($v) : null),
[$artwork->updated_at, $artwork->published_at, $artwork->created_at],
));
$this->line('Sitemap index complete');
return self::SUCCESS;
usort($timestamps, static fn (Carbon $a, Carbon $b): int => $b->timestamp <=> $a->timestamp);
return new SitemapUrl(
route('art.show', ['id' => (int) $artwork->id, 'slug' => $slug]),
$timestamps[0] ?? null,
$images,
);
}
/**

View File

@@ -58,7 +58,9 @@ class ConfigureMeilisearchIndex extends Command
'maturity_status',
'has_missing_thumbnails',
'category',
'categories',
'content_type',
'content_types',
'published_as_type',
'tags',
'author_id',

View File

@@ -42,12 +42,13 @@ class ExportLegacyPasswordsCommand extends Command
DB::connection('legacy')
->table('users')
->select(['user_id', 'password2', 'password'])
->where('should_migrate', 1)
->orderBy('user_id')
->chunk($chunk, function ($rows) use (&$lines, &$exported, $now) {
foreach ($rows as $r) {
$id = (int) ($r->user_id ?? 0);
$hash = trim((string) ($r->password2 ?: $r->password ?: ''));
if ($id === 0 || $hash === '') {
if ($id === 0 || $hash === '' || $hash === 'abc123') {
continue;
}

View File

@@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Artwork;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Meilisearch\Client as MeilisearchClient;
/**
* Directly write a single artwork into the Meilisearch index, bypassing the queue.
*
* Useful when:
* - A rebuild was run but the queue worker was not consuming the `search` queue.
* - A specific artwork is missing from the live index and you want it visible immediately.
* - You need to force-push a corrected document after schema changes.
*
* Usage:
* php artisan artworks:search-force-index 69810
* php artisan artworks:search-force-index # interactive prompt
* php artisan artworks:search-force-index 69810 --dry-run
* php artisan artworks:search-force-index 69810 --force # index even if not public/approved/published
*/
final class ForceIndexArtworkCommand extends Command
{
protected $signature = 'artworks:search-force-index
{artwork_id? : The artwork ID to force-index}
{--index= : Override the Meilisearch index name}
{--dry-run : Show what would be sent without actually writing}
{--force : Index the document even when the artwork is not public/approved/published}
{--no-cache-bump : Skip bumping the explore cache version after indexing}';
protected $description = 'Directly push a single artwork into Meilisearch, bypassing the queue.';
public function handle(MeilisearchClient $client): int
{
$artworkId = $this->resolveArtworkId();
if ($artworkId === null) {
$this->error('An artwork ID is required.');
return self::FAILURE;
}
$isDryRun = (bool) $this->option('dry-run');
$forceIndex = (bool) $this->option('force');
$this->line(sprintf(
'%sForce-indexing artwork #%d into Meilisearch%s…',
$isDryRun ? '[DRY RUN] ' : '',
$artworkId,
$forceIndex ? ' (--force: eligibility check bypassed)' : '',
));
// ── 1. Load artwork with all relations required for toSearchableArray() ──
$artwork = Artwork::query()
->with(['user', 'group', 'tags', 'categories.contentType', 'stats', 'awardStat'])
->find($artworkId);
if ($artwork === null) {
$this->error("Artwork #{$artworkId} was not found in the database.");
return self::FAILURE;
}
$this->comment('Artwork');
$this->line(sprintf(
' id=%d title="%s" public=%s approved=%s published_at=%s',
(int) $artwork->id,
(string) ($artwork->title ?? ''),
$artwork->is_public ? 'yes' : 'no',
$artwork->is_approved ? 'yes' : 'no',
$artwork->published_at?->toIso8601String() ?? 'null',
));
// ── 2. Eligibility check ─────────────────────────────────────────────────
$shouldBeIndexed = $artwork->is_public && $artwork->is_approved && $artwork->published_at !== null;
if (! $shouldBeIndexed && ! $forceIndex) {
$this->warn(sprintf(
'Artwork #%d is not eligible for the public index (is_public=%s, is_approved=%s, published_at=%s). ' .
'Use --force to index it anyway, or fix the artwork status first.',
$artworkId,
$artwork->is_public ? 'true' : 'false',
$artwork->is_approved ? 'true' : 'false',
$artwork->published_at?->toIso8601String() ?? 'null',
));
return self::FAILURE;
}
if (! $shouldBeIndexed && $forceIndex) {
$this->warn('Artwork is not normally eligible but --force was passed; indexing anyway.');
}
// ── 3. Build the Meilisearch document ────────────────────────────────────
$document = $artwork->toSearchableArray();
$this->comment('Generated document');
$this->line(json_encode($document, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
$this->newLine();
// ── 4. Resolve index name ────────────────────────────────────────────────
$indexName = $this->resolveIndexName($artwork);
$this->line("Target index: {$indexName}");
if ($isDryRun) {
$this->info('[DRY RUN] Document was NOT written to Meilisearch. Remove --dry-run to execute.');
return self::SUCCESS;
}
// ── 5. Write directly to Meilisearch (no queue) ──────────────────────────
try {
$taskResult = $client->index($indexName)->addDocuments([$document]);
$taskUid = $taskResult['taskUid'] ?? $taskResult['uid'] ?? 'n/a';
$this->info(sprintf(
'Document written to Meilisearch. Task uid: %s',
is_scalar($taskUid) ? (string) $taskUid : json_encode($taskUid),
));
} catch (\Throwable $e) {
$this->error('Meilisearch write failed: ' . $e->getMessage());
return self::FAILURE;
}
// ── 6. Bump explore cache version ────────────────────────────────────────
if (! $this->option('no-cache-bump')) {
try {
$newVersion = ((int) Cache::get('explore.cache.version', 1)) + 1;
Cache::forever('explore.cache.version', $newVersion);
$this->line("Explore cache version bumped to {$newVersion}.");
} catch (\Throwable $e) {
$this->warn('Could not bump explore cache version: ' . $e->getMessage());
}
}
// ── 7. Summary ────────────────────────────────────────────────────────────
$this->newLine();
$this->info(sprintf(
'Artwork #%d ("%s") has been pushed to index "%s" directly.',
(int) $artwork->id,
(string) ($artwork->title ?? ''),
$indexName,
));
$this->line('The artwork should now appear on browse and search pages.');
$this->line('If Meilisearch was still processing the task you can verify with:');
$this->line(sprintf(' php artisan artworks:search-inspect %d', $artworkId));
return self::SUCCESS;
}
private function resolveArtworkId(): ?int
{
$argument = $this->argument('artwork_id');
if ($argument !== null && $argument !== '') {
return max(1, (int) $argument);
}
if (! $this->input->isInteractive()) {
return null;
}
$answer = $this->ask('Artwork ID');
if ($answer === null || trim($answer) === '') {
return null;
}
return max(1, (int) $answer);
}
private function resolveIndexName(Artwork $artwork): string
{
$override = trim((string) $this->option('index'));
if ($override !== '') {
return $override;
}
return $artwork->searchableAs();
}
}

View File

@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Sitemaps\SitemapBuildService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
/**
* Builds all sitemap documents and writes them as static .xml files to the
* public disk (default: public/sitemap.xml and public/sitemaps/{name}.xml).
*
* Nginx can then serve those files directly (try_files $uri @php) without
* hitting PHP at all. The SitemapController falls back to these same files
* on the PHP path if a request does reach it before a static file exists.
*
* Run manually: php artisan skinbase:sitemaps:generate
* With filtering: php artisan skinbase:sitemaps:generate --only=artworks,users
*/
final class GenerateSitemapsCommand extends Command
{
protected $signature = 'skinbase:sitemaps:generate
{--only=* : Limit to specific sitemap families (comma or space separated)}
{--disk= : Override the target filesystem disk (default: sitemaps.static_publish.disk)}';
protected $description = 'Build all sitemaps and write them as static .xml files to public/.';
public function handle(SitemapBuildService $build): int
{
$totalS tart = microtime(true);
$families = $this->selectedFamilies($build);
if ($families === []) {
$this->error('No valid sitemap families selected.');
return self::INVALID;
}
$diskName = (string) ($this->option('disk') ?: config('sitemaps.static_publish.disk', 'sitemaps_public'));
$disk = Storage::disk($diskName);
$written = 0;
$failed = 0;
$this->info('Disk: ' . $diskName);
$this->info('Families: ' . implode(', ', $families));
$this->newLine();
// ── Root sitemap index ────────────────────────────────────────────
$t = microtime(true);
$index = $build->buildIndex(force: true, persist: false, families: $families);
$disk->put('sitemap.xml', $index['content']);
$written++;
$this->line(sprintf(
' <info>✔</info> sitemap.xml %d entries <comment>%.3fs</comment>',
$index['url_count'],
microtime(true) - $t,
));
// ── Per-family documents ──────────────────────────────────────────
foreach ($families as $family) {
$familyStart = microtime(true);
$names = $build->canonicalDocumentNamesForFamily($family);
$this->newLine();
$this->line(sprintf(' <fg=cyan>%s</> (%d document(s))', $family, count($names)));
foreach ($names as $documentName) {
$t = microtime(true);
$built = $build->buildNamed($documentName, force: true, persist: false);
if ($built === null) {
$this->line(sprintf(' <comment></comment> %s <fg=red>SKIPPED</> (builder returned null)', $documentName));
$failed++;
continue;
}
$path = 'sitemaps/' . $documentName . '.xml';
$disk->put($path, $built['content']);
$written++;
$this->line(sprintf(
' <info>✔</info> %s %d URLs <comment>%.3fs</comment>',
$documentName . '.xml',
$built['url_count'] ?? 0,
microtime(true) - $t,
));
}
$this->line(sprintf(
' <fg=cyan>%s</> done <comment>%.3fs</comment>',
$family,
microtime(true) - $familyStart,
));
}
// ── Summary ───────────────────────────────────────────────────────
$this->newLine();
$this->info(sprintf(
'Done: %d file(s) written, %d failed total <comment>%.3fs</comment>',
$written,
$failed,
microtime(true) - $totalStart,
));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
/** @return list<string> */
private function selectedFamilies(SitemapBuildService $build): array
{
$only = [];
foreach ((array) $this->option('only') as $value) {
foreach (explode(',', (string) $value) as $family) {
$normalized = trim($family);
if ($normalized !== '') {
$only[] = $normalized;
}
}
}
$enabled = $build->enabledFamilies();
if ($only === []) {
return $enabled;
}
return array_values(array_filter($enabled, fn (string $f): bool => in_array($f, $only, true)));
}
}

View File

@@ -0,0 +1,217 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* Reads plain-text passwords from the legacy `users` table, bcrypt-hashes
* them, and writes a SQL UPDATE file ready to run against the new database.
*
* For users whose password is 'abc123' a strong random password is generated
* first so they are not left with a known weak credential.
*
* Usage:
* php artisan skinbase:hash-legacy-plain-passwords
* php artisan skinbase:hash-legacy-plain-passwords --out=storage/app/hashed-passwords.sql
* php artisan skinbase:hash-legacy-plain-passwords --chunk=1000
*/
class HashLegacyPlainPasswordsCommand extends Command
{
protected $signature = 'skinbase:hash-legacy-plain-passwords
{--out= : Output SQL file path (default: storage/app/hashed-plain-passwords.sql)}
{--chunk=500 : Chunk size for reading legacy users}
{--legacy-connection=legacy : Name of the legacy DB connection}
{--legacy-table=users : Name of the legacy users table}
{--dry-run : Print row count without writing the SQL file}';
protected $description = 'Hash plain-text legacy passwords with bcrypt and export UPDATE SQL. Randomises weak \'abc123\' passwords.';
// Characters for random password generation (no ambiguous l/1/0/O)
private const UPPER = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
private const LOWER = 'abcdefghjkmnpqrstuvwxyz';
private const DIGITS = '23456789';
private const SPECIAL = '!@#$%^&*';
public function handle(): int
{
$outPath = $this->option('out') ?: storage_path('app/hashed-plain-passwords.sql');
$chunk = max(1, (int) ($this->option('chunk') ?? 500));
$legacyConn = (string) ($this->option('legacy-connection') ?? 'legacy');
$legacyTable = (string) ($this->option('legacy-table') ?? 'users');
$dryRun = (bool) $this->option('dry-run');
// Verify legacy connection is available
try {
DB::connection($legacyConn)->getPdo();
} catch (\Throwable $e) {
$this->error('Cannot connect to legacy DB: ' . $e->getMessage());
return self::FAILURE;
}
$now = now()->format('Y-m-d H:i:s');
$newDbName = DB::getDatabaseName();
$lines = [];
$lines[] = '-- Hashed plain-password export';
$lines[] = '-- Generated: ' . $now;
$lines[] = '-- Source: legacy DB (read-only) — passwords bcrypt-hashed for Laravel';
$lines[] = '-- WARNING: this file contains sensitive data. Delete after applying.';
$lines[] = '';
$lines[] = 'SET NAMES utf8mb4;';
$lines[] = 'USE `' . $newDbName . '`;';
$lines[] = 'START TRANSACTION;';
$lines[] = '';
$processed = 0;
$randomised = 0;
$skipped = 0;
$chunkNum = 0;
// Count total for progress bar
$total = DB::connection($legacyConn)
->table($legacyTable)
->where('should_migrate', 1)
->count();
$this->info("Legacy DB: {$total} users with should_migrate=1 found.");
$this->info("Output : " . ($dryRun ? '(dry-run, no file)' : $outPath));
$this->newLine();
$bar = $this->output->createProgressBar($total);
$bar->setFormat(" %current%/%max% [%bar%] %percent:3s%% mem:%memory:6s%\n %message%");
$bar->setMessage('Starting…');
$bar->start();
DB::connection($legacyConn)
->table($legacyTable)
->select(['user_id', 'password'])
->where('should_migrate', 1)
->orderBy('user_id')
->chunk($chunk, function ($rows) use (&$lines, &$processed, &$randomised, &$skipped, &$chunkNum, $now, $bar, $chunk) {
$chunkNum++;
$bar->setMessage("chunk #{$chunkNum} (chunk size {$chunk})");
foreach ($rows as $row) {
$userId = (int) ($row->user_id ?? 0);
$plain = trim((string) ($row->password ?? ''));
if ($userId <= 0 || $plain === '') {
$bar->setMessage("user_id={$userId} SKIPPED (empty)");
$bar->advance();
$skipped++;
continue;
}
// Skip entries that already look like a bcrypt / argon hash
if (preg_match('/^\$2[aby]\$|^\$argon2/', $plain)) {
$lines[] = "-- USER ID: {$userId} (already hashed — skipped)";
$lines[] = '';
$bar->setMessage("user_id={$userId} SKIPPED (already hashed)");
$bar->advance();
$skipped++;
continue;
}
$commentPlain = $plain;
$tag = 'hashed';
if ($plain === 'abc123') {
$newPlain = $this->generateStrongPassword();
$commentPlain = "abc123 => {$newPlain}";
$plain = $newPlain;
$tag = 'RANDOMISED (was abc123)';
$randomised++;
}
$bcrypt = Hash::make($plain);
$escaped = str_replace(['\\', "'"], ['\\\\', "\\'"], $bcrypt);
$lines[] = "-- USER ID: {$userId} PASS: {$commentPlain}";
$lines[] = "SAVEPOINT sp_{$userId};";
$lines[] = "UPDATE `users` SET `password` = '{$escaped}' WHERE `id` = {$userId};";
$lines[] = '';
$bar->setMessage("user_id={$userId} {$tag}");
$bar->advance();
$processed++;
}
});
$bar->setMessage("Done.");
$bar->finish();
$this->newLine(2);
$lines[] = 'COMMIT;';
$lines[] = '';
$lines[] = "-- Total processed : {$processed}";
$lines[] = "-- Passwords randomised (abc123) : {$randomised}";
$lines[] = "-- Rows skipped (empty / already hashed) : {$skipped}";
$this->table(
['Metric', 'Count'],
[
['Processed (hashed)', $processed],
['Randomised (abc123)', $randomised],
['Skipped', $skipped],
['Total should_migrate=1', $total],
]
);
if ($dryRun) {
$this->info('Dry-run mode — SQL file not written.');
return self::SUCCESS;
}
$dir = dirname($outPath);
if (!is_dir($dir) && !mkdir($dir, 0750, true)) {
$this->error("Cannot create output directory: {$dir}");
return self::FAILURE;
}
$sql = implode("\n", $lines) . "\n";
if (file_put_contents($outPath, $sql) === false) {
$this->error("Cannot write SQL file: {$outPath}");
return self::FAILURE;
}
$this->info("SQL written to: {$outPath}");
return self::SUCCESS;
}
/**
* Generate a cryptographically random strong password.
* Format: 4 upper + 4 lower + 3 digits + 2 special = 13 chars, then shuffled.
*/
private function generateStrongPassword(): string
{
$password = '';
$password .= $this->randomChars(self::UPPER, 4);
$password .= $this->randomChars(self::LOWER, 4);
$password .= $this->randomChars(self::DIGITS, 3);
$password .= $this->randomChars(self::SPECIAL, 2);
// Shuffle with a cryptographically random permutation
$chars = str_split($password);
for ($i = count($chars) - 1; $i > 0; $i--) {
$j = random_int(0, $i);
[$chars[$i], $chars[$j]] = [$chars[$j], $chars[$i]];
}
return implode('', $chars);
}
private function randomChars(string $pool, int $count): string
{
$out = '';
$max = strlen($pool) - 1;
for ($i = 0; $i < $count; $i++) {
$out .= $pool[random_int(0, $max)];
}
return $out;
}
}

View File

@@ -1010,7 +1010,7 @@ class HealthCheckCommand extends Command
private function checkScheduler(): void
{
// The scheduler tick key is written by Kernel::schedule() via a ->then() callback.
// The scheduler tick key is written by the scheduled health:tick command.
// If Redis is not the cache driver, we can't check it.
if (config('cache.default') !== 'redis' && config('queue.default') !== 'redis') {
$this->warn_check('scheduler', 'Scheduler check requires Redis cache or queue — skipping in this environment.');

View File

@@ -2,7 +2,6 @@
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@@ -87,6 +86,8 @@ class ImportLegacyNewsCommand extends Command
'is_pinned' => ($row->type ?? 0) == 2,
'views' => $row->views ?? 0,
'canonical_url' => '/legacy/news/' . ($row->news_id ?? ''),
'legacy_news_id' => isset($row->news_id) ? (int) $row->news_id : null,
'comments_enabled' => false,
];
if ($dryRun) {

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Artwork;
use App\Services\ArtworkOriginalFileLocator;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
final class InspectArtworkOriginalCommand extends Command
{
protected $signature = 'artworks:inspect-original
{--artwork-id= : Artwork ID to inspect}
{--id= : Legacy alias for artwork ID}';
protected $description = 'Show which original artwork file path resolves for an artwork and print the output URLs.';
public function handle(ArtworkOriginalFileLocator $locator): int
{
$artworkId = $this->resolveArtworkIdOption();
if ($artworkId === null) {
$this->error('Provide --artwork-id=ID.');
return self::FAILURE;
}
$artwork = Artwork::query()
->withTrashed()
->select(['id', 'slug', 'file_name', 'file_path', 'hash', 'file_ext'])
->find($artworkId);
if (! $artwork) {
$this->error(sprintf('Artwork %d not found.', $artworkId));
return self::FAILURE;
}
$localPath = $locator->resolveLocalPath($artwork);
$objectPath = $locator->resolveObjectPath($artwork);
$objectUrl = $locator->resolveObjectUrl($artwork);
$downloadUrl = route('art.download', ['id' => (int) $artwork->id]);
$artworkUrl = route('art.show', [
'id' => (int) $artwork->id,
'slug' => (string) ($artwork->slug ?? ''),
]);
$this->line('artwork_id: ' . (string) $artwork->id);
$this->line('file_name: ' . (string) ($artwork->file_name ?? ''));
$this->line('file_ext: ' . (string) ($artwork->file_ext ?? ''));
$this->line('stored_file_path: ' . (string) ($artwork->file_path ?? ''));
$this->line('source_file: ' . ($localPath !== '' ? $localPath : '(unresolved local path)'));
$this->line('source_file_exists: ' . (File::isFile($localPath) ? 'yes' : 'no'));
$this->line('source_object: ' . ($objectPath !== '' ? $objectPath : '(unresolved object path)'));
$this->line('output_url: ' . ($objectUrl !== null && $objectUrl !== '' ? $objectUrl : '(unresolved object url)'));
$this->line('download_url: ' . $downloadUrl);
$this->line('artwork_url: ' . $artworkUrl);
return self::SUCCESS;
}
private function resolveArtworkIdOption(): ?int
{
$artworkId = $this->option('artwork-id');
if ($artworkId !== null) {
return max(1, (int) $artworkId);
}
$legacyId = $this->option('id');
if ($legacyId !== null) {
return max(1, (int) $legacyId);
}
return null;
}
}

View File

@@ -0,0 +1,305 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Artwork;
use Illuminate\Console\Command;
use Meilisearch\Client as MeilisearchClient;
final class InspectArtworkSearchIndexCommand extends Command
{
protected $signature = 'artworks:search-inspect
{artwork_id? : The artwork ID to inspect}
{--index= : Override the Meilisearch index name}
{--generated-only : Only print the locally generated search document}
{--live-only : Only print the live document fetched from Meilisearch}
{--json : Print the inspection payload as raw JSON}';
protected $description = 'Inspect the generated Scout payload and live Meilisearch document for a single artwork.';
public function handle(MeilisearchClient $client): int
{
if ($this->option('generated-only') && $this->option('live-only')) {
$this->error('Use either --generated-only or --live-only, not both together.');
return self::FAILURE;
}
$artworkId = $this->resolveArtworkId();
if ($artworkId === null) {
$this->error('An artwork ID is required.');
return self::FAILURE;
}
$artwork = Artwork::query()
->with(['user', 'group', 'tags', 'categories.contentType', 'stats', 'awardStat'])
->find($artworkId);
if ($artwork === null && ! $this->option('live-only')) {
$this->error("Artwork #{$artworkId} was not found.");
return self::FAILURE;
}
$indexName = $this->resolveIndexName($artwork);
$inspection = [
'artwork_id' => $artworkId,
'index' => $indexName,
'queue_runtime' => $this->queueRuntimeSummary(),
'artwork' => $artwork ? $this->artworkSummary($artwork) : null,
'generated_document' => null,
'live_document' => null,
'documents_match' => null,
'live_fetch_error' => null,
'diagnosis' => [],
];
if (! $this->option('live-only') && $artwork !== null) {
$inspection['generated_document'] = $artwork->toSearchableArray();
}
if (! $this->option('generated-only')) {
try {
$inspection['live_document'] = $client->index($indexName)->getDocument($artworkId);
} catch (\Throwable $exception) {
$inspection['live_fetch_error'] = $exception->getMessage();
}
}
if (is_array($inspection['generated_document']) && is_array($inspection['live_document'])) {
$inspection['documents_match'] = $this->normalizeForComparison($inspection['generated_document'])
=== $this->normalizeForComparison($inspection['live_document']);
}
$inspection['diagnosis'] = $this->buildDiagnosis($artwork, $inspection);
$this->renderInspection($inspection);
if ($inspection['generated_document'] === null && $inspection['live_document'] === null) {
return self::FAILURE;
}
return self::SUCCESS;
}
private function resolveArtworkId(): ?int
{
$argument = $this->argument('artwork_id');
if ($argument !== null && $argument !== '') {
return max(1, (int) $argument);
}
if (! $this->input->isInteractive()) {
return null;
}
$answer = $this->ask('Artwork ID');
if ($answer === null || trim($answer) === '') {
return null;
}
return max(1, (int) $answer);
}
private function resolveIndexName(?Artwork $artwork): string
{
$override = trim((string) $this->option('index'));
if ($override !== '') {
return $override;
}
if ($artwork !== null) {
return $artwork->searchableAs();
}
return (string) config('scout.prefix', '') . 'artworks';
}
/**
* @return array<string, mixed>
*/
private function artworkSummary(Artwork $artwork): array
{
return [
'id' => (int) $artwork->id,
'title' => (string) ($artwork->title ?? ''),
'slug' => (string) ($artwork->slug ?? ''),
'is_public' => (bool) $artwork->is_public,
'is_approved' => (bool) $artwork->is_approved,
'published_at' => $artwork->published_at?->toIso8601String(),
'should_be_indexed' => (bool) ($artwork->is_public && $artwork->is_approved && $artwork->published_at !== null),
'searchable_index' => $artwork->searchableAs(),
];
}
/**
* @return array<string, string>
*/
private function queueRuntimeSummary(): array
{
return [
'queue_default_connection' => (string) config('queue.default', 'sync'),
'scout_queue_connection' => (string) config('scout.queue.connection', (string) config('queue.default', 'sync')),
'scout_queue_name' => (string) config('scout.queue.queue', 'default'),
];
}
/**
* @param array<string, mixed> $inspection
* @return list<string>
*/
private function buildDiagnosis(?Artwork $artwork, array $inspection): array
{
$messages = [];
$queueDefault = (string) data_get($inspection, 'queue_runtime.queue_default_connection', 'sync');
$scoutQueueConnection = (string) data_get($inspection, 'queue_runtime.scout_queue_connection', $queueDefault);
$scoutQueueName = (string) data_get($inspection, 'queue_runtime.scout_queue_name', 'default');
if ($artwork === null) {
$messages[] = 'Artwork row was not found locally, so only a direct live-index check was possible.';
return $messages;
}
$shouldBeIndexed = (bool) ($artwork->is_public && $artwork->is_approved && $artwork->published_at !== null);
if (! $shouldBeIndexed) {
$messages[] = 'This artwork should not exist in Meilisearch right now because it is not simultaneously public, approved, and published.';
}
if (is_string($inspection['live_fetch_error'] ?? null) && str_contains(strtolower((string) $inspection['live_fetch_error']), 'not found')) {
$messages[] = 'The live Meilisearch document is missing from the inspected index.';
if ($shouldBeIndexed) {
$messages[] = 'That usually means one of three things: the artwork has not been indexed yet, the Scout sync worker has not processed the job, or you are inspecting the wrong index name/prefix.';
if ($scoutQueueConnection !== $queueDefault) {
$messages[] = sprintf(
'This runtime is using queue.default=%s but Scout sync uses scout.queue.connection=%s on queue=%s. If workers only consume %s, Meilisearch updates will never be processed.',
$queueDefault,
$scoutQueueConnection,
$scoutQueueName,
$queueDefault,
);
if ($scoutQueueConnection === 'database') {
$messages[] = sprintf(
'In this configuration, artwork indexing writes are likely sitting on the database queue. Either run a worker for that backend, for example: php artisan queue:work database --queue=%s, or align SCOUT_QUEUE_CONNECTION with your main queue backend.',
$scoutQueueName,
);
}
} else {
$messages[] = sprintf(
'Scout is configured to use queue connection %s and queue name %s. Make sure at least one worker actively consumes that exact queue.',
$scoutQueueConnection,
$scoutQueueName,
);
}
$messages[] = 'If this artwork should be searchable now, requeue it with: php artisan artworks:search-reindex-recent or run a full rebuild with: php artisan artworks:search-rebuild';
}
}
if (($inspection['documents_match'] ?? null) === false) {
$messages[] = 'The local generated document and the live Meilisearch document differ, so the live index is stale or from a different schema/version.';
}
if ($messages === []) {
$messages[] = 'No obvious indexing problem was detected from this inspection output.';
}
return $messages;
}
/**
* @param array<string, mixed> $inspection
*/
private function renderInspection(array $inspection): void
{
$jsonFlags = JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES;
if ((bool) $this->option('json')) {
$this->line((string) json_encode($inspection, $jsonFlags));
return;
}
$this->info(sprintf(
'Artwork search inspect — artwork #%d, index %s',
(int) $inspection['artwork_id'],
(string) $inspection['index'],
));
$this->newLine();
if (is_array($inspection['artwork'])) {
$this->comment('Artwork');
$this->line((string) json_encode($inspection['artwork'], $jsonFlags));
$this->newLine();
}
if (is_array($inspection['queue_runtime'])) {
$this->comment('Queue runtime');
$this->line((string) json_encode($inspection['queue_runtime'], $jsonFlags));
$this->newLine();
}
if ($inspection['documents_match'] !== null) {
$this->line('Generated/live document match: ' . ($inspection['documents_match'] ? 'yes' : 'no'));
$this->newLine();
}
if (is_array($inspection['diagnosis']) && $inspection['diagnosis'] !== []) {
$this->comment('Diagnosis');
foreach ($inspection['diagnosis'] as $message) {
$this->line('- ' . $message);
}
$this->newLine();
}
if (is_array($inspection['generated_document'])) {
$this->comment('Generated search document');
$this->line((string) json_encode($inspection['generated_document'], $jsonFlags));
$this->newLine();
}
if (is_array($inspection['live_document'])) {
$this->comment('Live Meilisearch document');
$this->line((string) json_encode($inspection['live_document'], $jsonFlags));
$this->newLine();
}
if (is_string($inspection['live_fetch_error']) && $inspection['live_fetch_error'] !== '') {
$this->warn('Live document fetch failed: ' . $inspection['live_fetch_error']);
}
}
/**
* @param mixed $value
* @return mixed
*/
private function normalizeForComparison(mixed $value): mixed
{
if (! is_array($value)) {
return $value;
}
foreach ($value as $key => $item) {
$value[$key] = $this->normalizeForComparison($item);
}
if (array_is_list($value)) {
return $value;
}
ksort($value);
return $value;
}
}

View File

@@ -28,6 +28,9 @@ final class PublishSitemapsCommand extends Command
return self::SUCCESS;
}
$startedAt = microtime(true);
$this->line('<fg=cyan>Building sitemap release...</>');
try {
$manifest = $publish->publish(is_string($releaseId) && $releaseId !== '' ? $releaseId : null);
} catch (\Throwable $exception) {
@@ -36,11 +39,59 @@ final class PublishSitemapsCommand extends Command
return self::FAILURE;
}
$elapsed = microtime(true) - $startedAt;
// Per-family table (shown with -v or higher)
if ($this->output->isVerbose()) {
$rows = [];
foreach ((array) data_get($manifest, 'families', []) as $family => $info) {
$rows[] = [
$family,
(int) data_get($info, 'url_count', 0),
(int) data_get($info, 'shard_count', 0),
count((array) data_get($info, 'documents', [])),
(string) data_get($info, 'type', 'urlset'),
];
}
$this->table(['Family', 'URLs', 'Shards', 'Docs', 'Type'], $rows);
}
// Validation detail (shown with -vv or higher)
if ($this->output->isVeryVerbose()) {
$validation = (array) data_get($manifest, 'validation', []);
$checks = (array) data_get($validation, 'checks', []);
if ($checks !== []) {
$this->line('<fg=yellow>Validation checks:</>');
$checkRows = [];
foreach ($checks as $check => $result) {
$ok = (bool) data_get($result, 'ok', true);
$checkRows[] = [
$check,
$ok ? '<fg=green>OK</>' : '<fg=red>FAIL</>',
(string) data_get($result, 'message', ''),
];
}
$this->table(['Check', 'Status', 'Message'], $checkRows);
}
}
// Static publish result
$staticResult = (array) data_get($manifest, 'static_published', []);
if ($staticResult !== [] && $this->output->isVerbose()) {
$this->line(sprintf(
'<fg=cyan>Static files written to public/:</> written=%d skipped=%d',
(int) data_get($staticResult, 'written', 0),
(int) data_get($staticResult, 'skipped', 0),
));
}
$this->info(sprintf(
'Published sitemap release [%s] with %d families and %d documents.',
'Published sitemap release [%s] %d families, %d documents, %d URLs (%.2fs)',
(string) $manifest['release_id'],
(int) data_get($manifest, 'totals.families', 0),
(int) data_get($manifest, 'totals.documents', 0),
(int) data_get($manifest, 'totals.urls', 0),
$elapsed,
));
return self::SUCCESS;

View File

@@ -5,26 +5,205 @@ declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\ArtworkSearchIndexer;
use App\Models\Artwork;
use Illuminate\Console\Command;
use Meilisearch\Client as MeilisearchClient;
class RebuildArtworkSearchIndex extends Command
{
protected $signature = 'artworks:search-rebuild {--chunk=500 : Number of artworks per chunk}';
protected $description = 'Re-queue all artworks for Meilisearch indexing (non-blocking, chunk-based).';
protected $signature = 'artworks:search-rebuild
{--chunk=500 : Number of artworks per chunk}
{--limit= : Stop after processing this many artworks (useful for testing)}
{--reverse : Process artworks newest-first (highest ID first)}
{--sync : Write directly to Meilisearch (no queue) and show per-artwork results}';
protected $description = 'Re-queue all artworks for Meilisearch indexing (non-blocking, chunk-based). Use --sync for verbose direct writes.';
public function __construct(private readonly ArtworkSearchIndexer $indexer)
{
parent::__construct();
}
public function handle(): int
public function handle(MeilisearchClient $client): int
{
$chunk = (int) $this->option('chunk');
$chunk = max(1, (int) $this->option('chunk'));
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
$reverse = (bool) $this->option('reverse');
$sync = (bool) $this->option('sync');
$this->info("Dispatching index jobs in chunks of {$chunk}");
$this->indexer->rebuildAll($chunk);
$this->info('All jobs dispatched. Workers will process them asynchronously.');
if ($sync) {
return $this->handleSync($client, $chunk, $limit, $reverse);
}
return $this->handleQueue($chunk, $limit, $reverse);
}
// ── Queue mode (default) ──────────────────────────────────────────────────
private function handleQueue(int $chunk, ?int $limit, bool $reverse): int
{
$uncapped = Artwork::query()->public()->published()->count();
$total = $limit !== null ? min($limit, $uncapped) : $uncapped;
if ($total === 0) {
$this->warn('No public, published artworks matched the rebuild query. Nothing was queued.');
return self::SUCCESS;
}
$estimatedChunks = (int) ceil($total / $chunk);
$this->info(sprintf(
'Queueing Meilisearch rebuild for %d artwork(s) in %d chunk(s) of up to %d%s%s.',
$total,
$estimatedChunks,
$chunk,
$reverse ? ', newest first' : '',
$limit !== null ? " (limit {$limit})" : '',
));
$this->line('This command only dispatches queue jobs. Workers process the actual indexing asynchronously.');
$bar = $this->output->createProgressBar($total);
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%%');
$bar->start();
$startedAt = microtime(true);
$stats = $this->indexer->rebuildAll(
$chunk,
function (int $chunkNumber, int $chunkCount, int $dispatched, int $totalItems, int $firstId, int $lastId) use ($bar): void {
$bar->advance($chunkCount);
if ($this->output->isVerbose()) {
$bar->clear();
$this->line(sprintf(
'Chunk %d queued %d artwork(s) [ids %d-%d] (%d/%d dispatched).',
$chunkNumber,
$chunkCount,
$firstId,
$lastId,
$dispatched,
$totalItems,
));
$bar->display();
}
},
$reverse,
$limit,
);
$bar->finish();
$this->newLine(2);
$elapsed = microtime(true) - $startedAt;
$this->info(sprintf(
'Queued %d artwork(s) across %d chunk(s) in %.2f seconds.',
$stats['dispatched'],
$stats['chunks'],
$elapsed,
));
$this->line('Workers will process the actual Meilisearch writes asynchronously.');
if ($this->output->isVerbose()) {
$this->line('Tip: use -v for per-chunk output, or monitor Horizon/queue workers for completion.');
}
return self::SUCCESS;
}
// ── Sync mode (--sync) ────────────────────────────────────────────────────
private function handleSync(MeilisearchClient $client, int $chunk, ?int $limit, bool $reverse): int
{
$this->info(sprintf(
'<options=bold>[SYNC MODE]</> Writing directly to Meilisearch%s%s — no queue involved.',
$reverse ? ', newest first' : '',
$limit !== null ? ", limit {$limit}" : '',
));
$this->newLine();
$query = Artwork::with([
'user', 'group', 'tags', 'categories.contentType', 'stats', 'awardStat',
])
->withoutGlobalScopes() // include non-public so we can report "why not"
->whereNotNull('id'); // all artworks
if ($reverse) {
$query->orderByDesc('id');
} else {
$query->orderBy('id');
}
if ($limit !== null) {
$query->limit($limit);
}
$total = (clone $query)->count();
$indexed = 0;
$removed = 0;
$failed = 0;
$processed = 0;
$startedAt = microtime(true);
$bar = $this->output->createProgressBar($total);
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%%');
$bar->start();
$query->chunk($chunk, function ($artworks) use ($client, $bar, &$indexed, &$removed, &$failed, &$processed): void {
foreach ($artworks as $artwork) {
$processed++;
$id = (int) $artwork->id;
$title = (string) ($artwork->title ?? '(no title)');
// Determine eligibility and reason
$reasons = [];
if (! $artwork->is_public) { $reasons[] = 'not public'; }
if (! $artwork->is_approved) { $reasons[] = 'not approved'; }
if ($artwork->published_at === null) { $reasons[] = 'not published'; }
if ($artwork->deleted_at !== null) { $reasons[] = 'soft-deleted'; }
$eligible = empty($reasons);
try {
$indexName = $artwork->searchableAs();
if ($eligible) {
$document = $artwork->toSearchableArray();
$client->index($indexName)->addDocuments([$document]);
$indexed++;
$bar->clear();
$this->line(sprintf(' <info>✓ indexed</info> #%d "%s"', $id, $title));
} else {
$client->index($indexName)->deleteDocument($id);
$removed++;
$bar->clear();
$this->line(sprintf(' <comment> removed</comment> #%d "%s" [%s]', $id, $title, implode(', ', $reasons)));
}
} catch (\Throwable $e) {
$failed++;
$bar->clear();
$this->line(sprintf(' <error>✗ failed</error> #%d "%s" %s', $id, $title, $e->getMessage()));
}
$bar->advance();
}
});
$bar->finish();
$this->newLine(2);
$elapsed = microtime(true) - $startedAt;
$this->info(sprintf(
'Done in %.2f s — %d indexed, %d removed from index, %d failed (of %d processed).',
$elapsed,
$indexed,
$removed,
$failed,
$processed,
));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
}

View File

@@ -0,0 +1,279 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Jobs\DeleteArtworkFromIndexJob;
use App\Jobs\IndexArtworkJob;
use App\Models\Artwork;
use Illuminate\Console\Command;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Meilisearch\Client as MeilisearchClient;
final class ReconcileArtworkSearchIndexCommand extends Command
{
protected $signature = 'artworks:search-reconcile
{--id=* : Specific artwork IDs to inspect instead of scanning the full catalog}
{--after-id=0 : Resume scanning after this artwork id in the chosen sort direction}
{--chunk=200 : Number of artworks per chunk}
{--limit=0 : Stop after this many artworks (0 = no limit)}
{--recent-minutes=0 : Only inspect artworks touched recently by created_at, updated_at, or published_at}
{--reverse : Process highest artwork ids first}
{--repair : Apply fixes instead of reporting only}
{--queue : When repairing, dispatch queue jobs instead of writing directly to Meilisearch}
{--remove-unexpected : Remove live documents for artworks that should not be indexed}
{--no-cache-bump : Skip bumping explore cache version after repairs}}';
protected $description = 'Audit the artwork Meilisearch index against the database and repair missing or stale documents.';
public function handle(MeilisearchClient $client): int
{
$chunk = max(1, (int) $this->option('chunk'));
$limit = max(0, (int) $this->option('limit'));
$afterId = max(0, (int) $this->option('after-id'));
$recentMinutes = max(0, (int) $this->option('recent-minutes'));
$ids = array_values(array_unique(array_filter(array_map('intval', (array) $this->option('id')), static fn (int $id): bool => $id > 0)));
$reverse = (bool) $this->option('reverse');
$repair = (bool) $this->option('repair');
$queue = (bool) $this->option('queue');
$removeUnexpected = (bool) $this->option('remove-unexpected');
$bumpCache = ! (bool) $this->option('no-cache-bump');
if ($queue && ! $repair) {
$this->error('The --queue option requires --repair.');
return self::FAILURE;
}
$query = Artwork::query()
->withoutGlobalScopes()
->with(['user', 'group', 'tags', 'categories.contentType', 'stats', 'awardStat'])
->when($afterId > 0, function ($builder) use ($afterId, $reverse): void {
$builder->where('id', $reverse ? '<' : '>', $afterId);
})
->orderBy('id', $reverse ? 'desc' : 'asc');
if ($ids === [] && $recentMinutes > 0) {
$cutoff = Carbon::now()->subMinutes($recentMinutes);
$query->where(function ($builder) use ($cutoff): void {
$builder->where('created_at', '>=', $cutoff)
->orWhere('updated_at', '>=', $cutoff)
->orWhere('published_at', '>=', $cutoff);
});
}
if ($ids !== []) {
$query->whereIn('id', $ids);
}
$uncappedTotal = (clone $query)->count();
if ($limit > 0) {
$query->limit($limit);
}
$total = $limit > 0 ? min($limit, $uncappedTotal) : $uncappedTotal;
if ($total === 0) {
$this->warn('No artworks matched the reconcile query.');
return self::SUCCESS;
}
$this->info(sprintf(
'%sReconciling %d artwork(s)%s%s%s%s.',
$repair ? '[REPAIR] ' : '[REPORT] ',
$total,
$ids !== [] ? ' for selected ids' : '',
$recentMinutes > 0 && $ids === [] ? sprintf(' touched in the last %d minute(s)', $recentMinutes) : '',
$reverse ? ' newest first' : '',
$queue ? ' using queued repair jobs' : '',
));
$bar = $this->output->createProgressBar($total);
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%%');
$bar->start();
$stats = [
'processed' => 0,
'ok' => 0,
'missing' => 0,
'stale' => 0,
'unexpected' => 0,
'repaired' => 0,
'failed' => 0,
];
$indexName = null;
$chunkMethod = $reverse ? 'chunkByIdDesc' : 'chunkById';
$query->{$chunkMethod}($chunk, function ($artworks) use ($client, $repair, $queue, $removeUnexpected, $bar, &$stats, &$indexName): void {
foreach ($artworks as $artwork) {
$stats['processed']++;
$indexName ??= $artwork->searchableAs();
$artworkId = (int) $artwork->id;
$eligible = $this->shouldBeIndexed($artwork);
$generatedDocument = $eligible ? $artwork->toSearchableArray() : null;
$liveDocument = null;
$liveMissing = false;
try {
$liveDocument = $client->index($indexName)->getDocument($artworkId);
} catch (\Throwable $exception) {
if ($this->isMissingDocumentError($exception)) {
$liveMissing = true;
} else {
$stats['failed']++;
$bar->clear();
$this->line(sprintf(' <error>error</error> #%d %s', $artworkId, $exception->getMessage()));
$bar->display();
$bar->advance();
continue;
}
}
$status = 'ok';
if ($eligible) {
if ($liveMissing) {
$status = 'missing';
$stats['missing']++;
} elseif (! $this->documentsMatch($generatedDocument, $liveDocument)) {
$status = 'stale';
$stats['stale']++;
} else {
$stats['ok']++;
}
} else {
if (! $liveMissing) {
$status = 'unexpected';
$stats['unexpected']++;
} else {
$stats['ok']++;
}
}
if ($status !== 'ok') {
$bar->clear();
$this->line(sprintf(' <comment>%s</comment> #%d %s', $status, $artworkId, (string) ($artwork->slug ?? '')));
$bar->display();
}
if ($repair) {
try {
if (in_array($status, ['missing', 'stale'], true)) {
$this->repairIndexDocument($client, $artwork, $generatedDocument ?? [], $queue);
$stats['repaired']++;
} elseif ($status === 'unexpected' && $removeUnexpected) {
$this->repairUnexpectedDocument($client, $artworkId, $indexName, $queue);
$stats['repaired']++;
}
} catch (\Throwable $exception) {
$stats['failed']++;
$bar->clear();
$this->line(sprintf(' <error>repair failed</error> #%d %s', $artworkId, $exception->getMessage()));
$bar->display();
}
}
$bar->advance();
}
}, 'id');
$bar->finish();
$this->newLine(2);
if ($repair && $stats['repaired'] > 0 && $bumpCache) {
$newVersion = ((int) Cache::get('explore.cache.version', 1)) + 1;
Cache::forever('explore.cache.version', $newVersion);
$this->line("Explore cache version bumped to {$newVersion}.");
}
$this->table(
['processed', 'ok', 'missing', 'stale', 'unexpected', 'repaired', 'failed'],
[[
$stats['processed'],
$stats['ok'],
$stats['missing'],
$stats['stale'],
$stats['unexpected'],
$stats['repaired'],
$stats['failed'],
]]
);
if (! $repair) {
$this->line('Run again with --repair to fix missing/stale documents directly.');
} elseif (! $removeUnexpected && $stats['unexpected'] > 0) {
$this->line('Unexpected live documents were only reported. Re-run with --remove-unexpected to delete them.');
}
return $stats['failed'] > 0 ? self::FAILURE : self::SUCCESS;
}
private function shouldBeIndexed(Artwork $artwork): bool
{
return (bool) ($artwork->is_public && $artwork->is_approved && $artwork->published_at !== null && $artwork->published_at->lte(Carbon::now()) && $artwork->deleted_at === null);
}
/**
* @param array<string, mixed> $generatedDocument
* @param mixed $liveDocument
*/
private function documentsMatch(array $generatedDocument, mixed $liveDocument): bool
{
if (! is_array($liveDocument)) {
return false;
}
return $this->normalizeForComparison($generatedDocument) === $this->normalizeForComparison($liveDocument);
}
/**
* @param array<string, mixed> $document
*/
private function repairIndexDocument(MeilisearchClient $client, Artwork $artwork, array $document, bool $queue): void
{
if ($queue) {
IndexArtworkJob::dispatch((int) $artwork->id);
return;
}
$client->index($artwork->searchableAs())->addDocuments([$document]);
}
private function repairUnexpectedDocument(MeilisearchClient $client, int $artworkId, string $indexName, bool $queue): void
{
if ($queue) {
DeleteArtworkFromIndexJob::dispatch($artworkId);
return;
}
$client->index($indexName)->deleteDocument($artworkId);
}
private function isMissingDocumentError(\Throwable $exception): bool
{
return str_contains(strtolower($exception->getMessage()), 'not found');
}
/**
* @param array<string, mixed> $document
* @return array<string, mixed>
*/
private function normalizeForComparison(array $document): array
{
$normalized = Arr::sortRecursive($document);
unset($normalized['_formatted']);
return $normalized;
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
use App\Mail\TestMail;
class SendTestMail extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'mail:send-test {email?} {--body=}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Send a test email to the given address or MAIL_USERNAME';
public function handle(): int
{
$email = $this->argument('email') ?? env('MAIL_USERNAME') ?? 'gregor@klevze.com';
$body = $this->option('body') ?? "This is a test email sent by php artisan mail:send-test.";
try {
Mail::to($email)->send(new TestMail($body));
} catch (\Exception $e) {
$this->error('Failed to send mail: ' . $e->getMessage());
return 1;
}
$this->info("Test mail sent to {$email}");
return 0;
}
}

View File

@@ -0,0 +1,714 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Artwork;
use App\Services\ArtworkOriginalFileLocator;
use App\Services\Uploads\UploadStorageService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;
use ZipArchive;
final class ZipUnsupportedArtworkOriginalsCommand extends Command
{
protected $signature = 'artworks:zip-unsupported-originals
{--artwork-id= : Process only this artwork ID}
{--id= : Process only this artwork ID}
{--limit= : Stop after processing this many artworks}
{--chunk=200 : Number of artworks to scan per batch}
{--force : Rebuild the zip even when the artwork currently points at a supported extension or an existing zip}
{--delete-original-object : Delete the previous original object from object storage after repointing the artwork}
{--dry-run : Report candidate artworks without writing files or updating metadata}';
protected $description = 'Wrap artwork originals with unsupported file extensions into zip archives and update artwork metadata.';
private const ZIP_MIME = 'application/zip';
/**
* Extensions that can stay as-is because they are already images or well-known archives.
*
* @var list<string>
*/
private const SUPPORTED_EXTENSIONS = [
'jpg',
'jpeg',
'png',
'gif',
'webp',
'bmp',
'tif',
'tiff',
'svg',
'avif',
'heic',
'heif',
'ico',
'jfif',
'zip',
'rar',
'7z',
'7zip',
'tar',
'gz',
'tgz',
'bz2',
'xz',
];
public function handle(ArtworkOriginalFileLocator $locator, UploadStorageService $storage): int
{
$artworkId = $this->resolveArtworkIdOption();
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
$chunkSize = max(1, min((int) $this->option('chunk'), 1000));
$force = (bool) $this->option('force');
$deleteOriginalObject = (bool) $this->option('delete-original-object');
$dryRun = (bool) $this->option('dry-run');
$this->info(sprintf(
'Starting unsupported artwork original zip pass. chunk=%d limit=%s dry_run=%s force=%s delete_original_object=%s',
$chunkSize,
$limit !== null ? (string) $limit : 'all',
$dryRun ? 'yes' : 'no',
$force ? 'yes' : 'no',
$deleteOriginalObject ? 'yes' : 'no',
));
$query = Artwork::query()
->withTrashed()
->select(['id', 'title', 'slug', 'file_name', 'file_path', 'hash', 'file_ext', 'mime_type', 'file_size'])
->orderBy('id');
if ($artworkId !== null) {
$query->whereKey($artworkId);
}
$processed = 0;
$skippedSupported = 0;
$skippedUnresolved = 0;
$skippedMissingSource = 0;
$wouldFixMetadata = 0;
$wouldConvert = 0;
$metadataFixed = 0;
$converted = 0;
$failed = 0;
$query->chunkById($chunkSize, function ($artworks) use (
$locator,
$storage,
$limit,
$force,
$deleteOriginalObject,
$dryRun,
&$processed,
&$skippedSupported,
&$skippedUnresolved,
&$skippedMissingSource,
&$wouldFixMetadata,
&$wouldConvert,
&$metadataFixed,
&$converted,
&$failed,
) {
foreach ($artworks as $artwork) {
if ($limit !== null && $processed >= $limit) {
return false;
}
try {
$result = $this->processArtwork($artwork, $locator, $storage, $dryRun, $deleteOriginalObject, $force);
match ($result) {
'skipped_supported' => $skippedSupported++,
'skipped_unresolved' => $skippedUnresolved++,
'skipped_missing_source' => $skippedMissingSource++,
'would_fix_metadata' => $wouldFixMetadata++,
'would_convert' => $wouldConvert++,
'fixed_metadata' => $metadataFixed++,
'converted' => $converted++,
default => null,
};
} catch (Throwable $exception) {
$failed++;
$this->warn(sprintf('Artwork %d failed: %s', (int) $artwork->id, $exception->getMessage()));
}
$processed++;
}
return true;
});
$this->info(sprintf(
'Unsupported artwork original zip pass complete. processed=%d skipped_supported=%d skipped_unresolved=%d skipped_missing_source=%d would_fix_metadata=%d would_convert=%d metadata_fixed=%d converted=%d failed=%d',
$processed,
$skippedSupported,
$skippedUnresolved,
$skippedMissingSource,
$wouldFixMetadata,
$wouldConvert,
$metadataFixed,
$converted,
$failed,
));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
private function resolveArtworkIdOption(): ?int
{
$artworkId = $this->option('artwork-id');
if ($artworkId !== null) {
return max(1, (int) $artworkId);
}
$legacyId = $this->option('id');
if ($legacyId !== null) {
return max(1, (int) $legacyId);
}
return null;
}
private function processArtwork(Artwork $artwork, ArtworkOriginalFileLocator $locator, UploadStorageService $storage, bool $dryRun, bool $deleteOriginalObject, bool $force): string
{
$metadataExtension = $this->normalizeExtension((string) $artwork->file_ext);
if (! $force && $this->isSupportedExtension($metadataExtension)) {
return 'skipped_supported';
}
$resolvedLocalPath = $locator->resolveLocalPath($artwork);
$resolvedObjectPath = $locator->resolveObjectPath($artwork);
$hash = strtolower(trim((string) $artwork->hash));
if (! $this->isValidHash($hash)) {
$this->line(sprintf('Artwork %d skipped: invalid or missing hash.', (int) $artwork->id));
$this->writeArtworkContext($artwork);
return 'skipped_unresolved';
}
$targetLocalPath = $storage->localOriginalPath($hash, $hash . '.zip');
$targetObjectPath = $storage->objectPathForVariant('original', $hash, $hash . '.zip');
$source = $this->prepareSourceFile($resolvedLocalPath, $resolvedObjectPath, $storage, $hash, $force);
if ($source === null) {
$this->line(sprintf('Artwork %d skipped: source file not found.', (int) $artwork->id));
$this->writeArtworkContext($artwork);
$this->writeVerbosePaths($resolvedLocalPath, $targetLocalPath, $resolvedObjectPath, $targetObjectPath);
return 'skipped_missing_source';
}
$sourceExtension = $this->detectSourceExtension($source['path'], $resolvedObjectPath);
if (! $force && $this->isSupportedExtension($sourceExtension)) {
if ($dryRun) {
$this->line(sprintf(
'Artwork %d would fix metadata only: file_ext=%s -> %s',
(int) $artwork->id,
$metadataExtension !== '' ? $metadataExtension : '(empty)',
$sourceExtension,
));
$this->writeArtworkContext($artwork);
return 'would_fix_metadata';
}
$size = $this->detectFileSize($source['path'], $artwork->file_size);
$mime = $this->detectMimeType($source['path'], $artwork->mime_type, $sourceExtension);
$updatedFileName = $this->resolveFileNameWithExtension((string) ($artwork->file_name ?? ''), $sourceExtension);
$this->persistArtworkMetadata((int) $artwork->id, $resolvedObjectPath !== '' ? $resolvedObjectPath : null, $sourceExtension, $mime, $size, $updatedFileName);
$this->info(sprintf(
'Artwork %d metadata fixed: file_ext=%s -> %s',
(int) $artwork->id,
$metadataExtension !== '' ? $metadataExtension : '(empty)',
$sourceExtension,
));
$this->writeArtworkContext($artwork);
return 'fixed_metadata';
}
if ($force && $this->isSupportedExtension($sourceExtension)) {
$this->line(sprintf(
'Artwork %d skipped: force requested but no non-archive source was found.',
(int) $artwork->id,
));
$this->writeArtworkContext($artwork);
$this->writeVerbosePaths($source['path'], $targetLocalPath, $resolvedObjectPath, $targetObjectPath);
return 'skipped_supported';
}
try {
if ($dryRun) {
$this->line(sprintf(
'Artwork %d would be archived: file_ext=%s -> zip',
(int) $artwork->id,
$metadataExtension !== '' ? $metadataExtension : '(empty)',
));
$this->writeArtworkContext($artwork);
$this->writeVerbosePaths($source['path'], $targetLocalPath, $resolvedObjectPath, $targetObjectPath);
return 'would_convert';
}
$archiveEntryName = $this->resolveArchiveEntryName($artwork, $metadataExtension, $sourceExtension);
$temporaryZipPath = $this->createZipArchive($source['path'], $archiveEntryName);
try {
$this->publishZipArchive($temporaryZipPath, $targetLocalPath, $targetObjectPath, $storage);
$size = (int) (filesize($targetLocalPath) ?: 0);
$updatedFileName = $this->resolveFileNameWithExtension((string) ($artwork->file_name ?? ''), 'zip');
$this->persistArtworkMetadata((int) $artwork->id, $targetObjectPath, 'zip', self::ZIP_MIME, $size, $updatedFileName);
$this->deleteLegacySource($resolvedLocalPath, $targetLocalPath, $resolvedObjectPath, $targetObjectPath, $storage, $deleteOriginalObject);
} catch (Throwable $exception) {
$this->cleanupTargetArtifacts($targetLocalPath, $targetObjectPath, $storage);
throw $exception;
} finally {
File::delete($temporaryZipPath);
}
$this->info(sprintf(
'Artwork %d archived to zip: file_ext=%s -> zip',
(int) $artwork->id,
$metadataExtension !== '' ? $metadataExtension : '(empty)',
));
$this->writeArtworkContext($artwork);
$deletedOldObjectPath = $deleteOriginalObject && $resolvedObjectPath !== '' && $resolvedObjectPath !== $targetObjectPath
? $resolvedObjectPath
: '';
$keptOldObjectPath = ! $deleteOriginalObject && $resolvedObjectPath !== '' && $resolvedObjectPath !== $targetObjectPath
? $resolvedObjectPath
: '';
$this->writeVerbosePaths($source['path'], $targetLocalPath, $resolvedObjectPath, $targetObjectPath, $deletedOldObjectPath, $keptOldObjectPath);
return 'converted';
} finally {
if ($source['temporary']) {
File::delete($source['path']);
}
}
}
/**
* @return array{path: string, temporary: bool}|null
*/
private function prepareSourceFile(string $resolvedLocalPath, string $resolvedObjectPath, UploadStorageService $storage, string $hash, bool $force): ?array
{
if ($force) {
$forcedSourcePath = $this->resolveForceSourcePath($hash);
if ($forcedSourcePath !== '') {
return [
'path' => $forcedSourcePath,
'temporary' => false,
];
}
}
if ($resolvedLocalPath !== '' && File::isFile($resolvedLocalPath)) {
return [
'path' => $resolvedLocalPath,
'temporary' => false,
];
}
$backupSourcePath = $this->resolveReadonlyBackupSourcePath($resolvedObjectPath);
if ($backupSourcePath !== '' && File::isFile($backupSourcePath)) {
return [
'path' => $backupSourcePath,
'temporary' => false,
];
}
if ($resolvedObjectPath === '') {
return null;
}
$disk = Storage::disk($storage->objectDiskName());
if (! $disk->exists($resolvedObjectPath)) {
return null;
}
$stream = $disk->readStream($resolvedObjectPath);
if (! is_resource($stream)) {
throw new RuntimeException('Unable to open source object stream.');
}
$temporaryPath = tempnam(sys_get_temp_dir(), 'art-src-');
if ($temporaryPath === false) {
fclose($stream);
throw new RuntimeException('Unable to allocate a temporary source file.');
}
$target = fopen($temporaryPath, 'wb');
if (! is_resource($target)) {
fclose($stream);
File::delete($temporaryPath);
throw new RuntimeException('Unable to open a temporary source file for writing.');
}
try {
$copied = stream_copy_to_stream($stream, $target);
} finally {
fclose($stream);
fclose($target);
}
if ($copied === false || $copied <= 0 || ! File::isFile($temporaryPath)) {
File::delete($temporaryPath);
return null;
}
return [
'path' => $temporaryPath,
'temporary' => true,
];
}
private function createZipArchive(string $sourcePath, string $archiveEntryName): string
{
$temporaryPath = tempnam(sys_get_temp_dir(), 'art-zip-');
if ($temporaryPath === false) {
throw new RuntimeException('Unable to allocate a temporary zip file.');
}
$zip = new ZipArchive();
$opened = $zip->open($temporaryPath, ZipArchive::CREATE | ZipArchive::OVERWRITE);
if ($opened !== true) {
File::delete($temporaryPath);
throw new RuntimeException('Unable to create zip archive.');
}
try {
if (! $zip->addFile($sourcePath, $archiveEntryName)) {
throw new RuntimeException('Unable to add artwork original to zip archive.');
}
} finally {
$zip->close();
}
if (! File::isFile($temporaryPath)) {
throw new RuntimeException('Zip archive was not written to disk.');
}
return $temporaryPath;
}
private function publishZipArchive(string $temporaryZipPath, string $targetLocalPath, string $targetObjectPath, UploadStorageService $storage): void
{
File::ensureDirectoryExists(dirname($targetLocalPath));
File::delete($targetLocalPath);
if (! File::copy($temporaryZipPath, $targetLocalPath)) {
throw new RuntimeException('Unable to write local zip archive.');
}
$storage->putObjectFromPath($targetLocalPath, $targetObjectPath, self::ZIP_MIME);
}
private function cleanupTargetArtifacts(string $targetLocalPath, string $targetObjectPath, UploadStorageService $storage): void
{
$storage->deleteLocalFile($targetLocalPath);
$storage->deleteObject($targetObjectPath);
}
private function deleteLegacySource(string $resolvedLocalPath, string $targetLocalPath, string $resolvedObjectPath, string $targetObjectPath, UploadStorageService $storage, bool $deleteOriginalObject): void
{
if ($resolvedLocalPath !== '' && $this->samePath($resolvedLocalPath, $targetLocalPath) === false) {
$storage->deleteLocalFile($resolvedLocalPath);
}
if ($deleteOriginalObject && $resolvedObjectPath !== '' && $resolvedObjectPath !== $targetObjectPath) {
$storage->deleteObject($resolvedObjectPath);
}
}
private function persistArtworkMetadata(int $artworkId, ?string $filePath, string $fileExt, string $mimeType, int $fileSize, ?string $fileName = null): void
{
$values = [
'file_path' => $filePath,
'file_ext' => $fileExt,
'mime_type' => $mimeType,
'file_size' => max(0, $fileSize),
'updated_at' => now(),
];
if ($fileName !== null && trim($fileName) !== '') {
$values['file_name'] = $fileName;
}
DB::table('artworks')
->where('id', $artworkId)
->update($values);
}
private function resolveArchiveEntryName(Artwork $artwork, string $metadataExtension, string $sourceExtension): string
{
$candidate = trim((string) pathinfo((string) $artwork->file_name, PATHINFO_FILENAME));
$candidate = str_replace(['/', '\\'], '-', $candidate);
$candidate = trim((string) preg_replace('/[\x00-\x1F\x7F]/', '', $candidate));
$candidate = trim($candidate, ". \t\n\r\0\x0B");
$extension = $sourceExtension !== '' ? $sourceExtension : $metadataExtension;
if ($candidate !== '' && $candidate !== '.' && $candidate !== '..') {
return $extension !== ''
? $candidate . '.' . $extension
: $candidate;
}
if ($extension !== '') {
return (string) $artwork->hash . '.' . $extension;
}
return ((string) $artwork->hash !== '' ? (string) $artwork->hash : 'artwork') . '.bin';
}
private function detectSourceExtension(string $resolvedLocalPath, string $resolvedObjectPath): string
{
$path = $resolvedLocalPath !== '' ? $resolvedLocalPath : $resolvedObjectPath;
return $this->normalizeExtension((string) pathinfo($path, PATHINFO_EXTENSION));
}
private function detectMimeType(string $resolvedLocalPath, ?string $fallbackMimeType, string $extension): string
{
if ($resolvedLocalPath !== '' && File::isFile($resolvedLocalPath)) {
$detected = File::mimeType($resolvedLocalPath);
if (is_string($detected) && $detected !== '') {
return $detected;
}
}
$fallback = trim((string) $fallbackMimeType);
if ($fallback !== '') {
return $fallback;
}
return match ($extension) {
'jpg', 'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'webp' => 'image/webp',
'bmp' => 'image/bmp',
'tif', 'tiff' => 'image/tiff',
'svg' => 'image/svg+xml',
'avif' => 'image/avif',
'heic' => 'image/heic',
'heif' => 'image/heif',
'ico' => 'image/x-icon',
'zip' => self::ZIP_MIME,
'rar' => 'application/vnd.rar',
'7z', '7zip' => 'application/x-7z-compressed',
'tar' => 'application/x-tar',
'gz', 'tgz' => 'application/gzip',
'bz2' => 'application/x-bzip2',
'xz' => 'application/x-xz',
default => 'application/octet-stream',
};
}
private function detectFileSize(string $resolvedLocalPath, ?int $fallbackSize): int
{
if ($resolvedLocalPath !== '' && File::isFile($resolvedLocalPath)) {
$size = filesize($resolvedLocalPath);
if ($size !== false) {
return (int) $size;
}
}
return max(0, (int) $fallbackSize);
}
private function resolveFileNameWithExtension(string $fileName, string $extension): string
{
$name = trim($fileName);
$name = str_replace(['/', '\\'], '-', $name);
$name = preg_replace('/[\x00-\x1F\x7F]/', '', $name) ?? '';
$name = preg_replace('/\s+/', ' ', $name) ?? '';
$name = trim((string) $name, ". \t\n\r\0\x0B");
$baseName = trim((string) pathinfo($name, PATHINFO_FILENAME), ". \t\n\r\0\x0B");
if ($baseName === '') {
$baseName = 'artwork';
}
$normalizedExtension = $this->normalizeExtension($extension);
return $normalizedExtension !== ''
? $baseName . '.' . $normalizedExtension
: $baseName;
}
private function normalizeExtension(string $extension): string
{
return strtolower(ltrim(trim($extension), '.'));
}
private function isSupportedExtension(string $extension): bool
{
return $extension !== '' && in_array($extension, self::SUPPORTED_EXTENSIONS, true);
}
private function isValidHash(string $hash): bool
{
return $hash !== '' && preg_match('/^[a-f0-9]+$/', $hash) === 1;
}
private function samePath(string $left, string $right): bool
{
$normalizedLeft = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $left);
$normalizedRight = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $right);
return $normalizedLeft === $normalizedRight;
}
private function resolveForceSourcePath(string $hash): string
{
if (! $this->isValidHash($hash)) {
return '';
}
foreach ($this->candidateOriginalRoots() as $root) {
$candidatePath = $this->findNonZipSourceInRoot($root, $hash);
if ($candidatePath !== '') {
return $candidatePath;
}
}
return '';
}
/**
* @return list<string>
*/
private function candidateOriginalRoots(): array
{
$roots = [
trim((string) config('uploads.local_originals_root', '')),
trim((string) config('uploads.readonly_backup_originals_root', '')),
];
$normalizedRoots = [];
foreach ($roots as $root) {
if ($root === '') {
continue;
}
$normalizedRoot = rtrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $root), DIRECTORY_SEPARATOR);
if ($normalizedRoot === '' || in_array($normalizedRoot, $normalizedRoots, true)) {
continue;
}
$normalizedRoots[] = $normalizedRoot;
}
return $normalizedRoots;
}
private function findNonZipSourceInRoot(string $root, string $hash): string
{
$directory = $root
. DIRECTORY_SEPARATOR . substr($hash, 0, 2)
. DIRECTORY_SEPARATOR . substr($hash, 2, 2);
if (! File::isDirectory($directory)) {
return '';
}
$matches = File::glob($directory . DIRECTORY_SEPARATOR . $hash . '.*');
if (! is_array($matches)) {
return '';
}
foreach ($matches as $path) {
if (! is_string($path) || ! File::isFile($path)) {
continue;
}
$extension = $this->normalizeExtension((string) pathinfo($path, PATHINFO_EXTENSION));
if ($extension === '' || $extension === 'zip') {
continue;
}
return $path;
}
return '';
}
private function resolveReadonlyBackupSourcePath(string $resolvedObjectPath): string
{
$root = trim((string) config('uploads.readonly_backup_originals_root', ''));
if ($root === '' || $resolvedObjectPath === '') {
return '';
}
$normalizedRoot = rtrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $root), DIRECTORY_SEPARATOR);
$filename = (string) pathinfo($resolvedObjectPath, PATHINFO_BASENAME);
$hash = strtolower((string) pathinfo($filename, PATHINFO_FILENAME));
$extension = $this->normalizeExtension((string) pathinfo($filename, PATHINFO_EXTENSION));
if (! $this->isValidHash($hash) || $extension === '') {
return '';
}
return $normalizedRoot
. DIRECTORY_SEPARATOR . substr($hash, 0, 2)
. DIRECTORY_SEPARATOR . substr($hash, 2, 2)
. DIRECTORY_SEPARATOR . $hash . '.' . $extension;
}
private function writeVerbosePaths(
string $sourcePath,
string $targetLocalPath,
string $sourceObjectPath = '',
string $targetObjectPath = '',
string $deletedOldObjectPath = '',
string $keptOldObjectPath = '',
): void
{
$displaySourcePath = $sourcePath !== '' ? $sourcePath : '(unresolved local source path)';
$this->line(' source_file: ' . $displaySourcePath);
if ($sourceObjectPath !== '') {
$this->line(' source_object: ' . $sourceObjectPath, null, OutputInterface::VERBOSITY_VERBOSE);
}
$this->line(' new_zip_file: ' . $targetLocalPath);
if ($targetObjectPath !== '') {
$this->line(' new_zip_object: ' . $targetObjectPath);
}
if ($deletedOldObjectPath !== '') {
$this->line(' deleted_old_object: ' . $deletedOldObjectPath, null, OutputInterface::VERBOSITY_VERBOSE);
}
if ($keptOldObjectPath !== '') {
$this->line(' kept_original_object: ' . $keptOldObjectPath);
}
}
private function writeArtworkContext(Artwork $artwork): void
{
$this->line(' title: ' . trim((string) ($artwork->title ?? '')));
$this->line(' artwork_url: ' . route('art.show', [
'id' => (int) $artwork->id,
'slug' => (string) ($artwork->slug ?? ''),
]));
}
}

View File

@@ -24,11 +24,6 @@ use App\Console\Commands\RecalculateTrendingCommand;
use App\Console\Commands\RecalculateRankingsCommand;
use App\Console\Commands\MetricsSnapshotHourlyCommand;
use App\Console\Commands\RecalculateHeatCommand;
use App\Jobs\UpdateLeaderboardsJob;
use App\Jobs\RebuildTrendingNovaCardsJob;
use App\Jobs\RecalculateRisingNovaCardsJob;
use App\Jobs\RankComputeArtworkScoresJob;
use App\Jobs\RankBuildListsJob;
use App\Uploads\Commands\CleanupUploadsCommand;
use App\Console\Commands\NormalizeArtworkSlugsCommand;
use App\Console\Commands\PublishScheduledArtworksCommand;
@@ -40,7 +35,10 @@ use App\Console\Commands\PublishSitemapsCommand;
use App\Console\Commands\RollbackSitemapReleaseCommand;
use App\Console\Commands\SyncCollectionLifecycleCommand;
use App\Console\Commands\ValidateSitemapsCommand;
use App\Jobs\Sitemaps\CleanupSitemapReleasesJob;
use App\Console\Commands\AuditArtworkDownloadFilesCommand;
use App\Console\Commands\InspectArtworkOriginalCommand;
use App\Console\Commands\ZipUnsupportedArtworkOriginalsCommand;
use App\Console\Commands\SendTestMail;
class Kernel extends ConsoleKernel
{
@@ -68,6 +66,10 @@ class Kernel extends ConsoleKernel
PublishScheduledNovaCardsCommand::class,
SyncCollectionLifecycleCommand::class,
ValidateSitemapsCommand::class,
AuditArtworkDownloadFilesCommand::class,
InspectArtworkOriginalCommand::class,
ZipUnsupportedArtworkOriginalsCommand::class,
SendTestMail::class,
DispatchCollectionMaintenanceCommand::class,
BackfillArtworkEmbeddingsCommand::class,
BackfillArtworkVectorIndexCommand::class,
@@ -92,6 +94,7 @@ class Kernel extends ConsoleKernel
\App\Console\Commands\AuditOrphanedArtworksCommand::class,
\App\Console\Commands\FlagLegacyUsersForMigrationCommand::class,
\App\Console\Commands\ExportLegacyPasswordsCommand::class,
\App\Console\Commands\HashLegacyPlainPasswordsCommand::class,
\App\Console\Commands\GenerateAiBiographyCommand::class,
\App\Console\Commands\InspectAiBiographyCommand::class,
\App\Console\Commands\ReviewQueueAiBiographyCommand::class,
@@ -103,119 +106,8 @@ class Kernel extends ConsoleKernel
*/
protected function schedule(\Illuminate\Console\Scheduling\Schedule $schedule): void
{
$schedule->command('uploads:cleanup')->dailyAt('03:00');
$schedule->command('skinbase:sitemaps:publish --sync')
->everySixHours()
->name('sitemaps-publish')
->withoutOverlapping()
->runInBackground();
$schedule->command('skinbase:sitemaps:validate')
->dailyAt('04:45')
->name('sitemaps-validate')
->withoutOverlapping()
->runInBackground();
$schedule->job(new CleanupSitemapReleasesJob)
->dailyAt('05:00')
->name('sitemaps-cleanup')
->withoutOverlapping()
->runInBackground();
// Publish artworks whose scheduled publish_at has passed
$schedule->command('artworks:publish-scheduled')
->everyMinute()
->name('publish-scheduled-artworks')
->withoutOverlapping(2) // prevent overlap up to 2 minutes
->runInBackground();
$schedule->command('news:publish-scheduled')
->everyMinute()
->name('publish-scheduled-news')
->withoutOverlapping(2)
->runInBackground();
$schedule->command('nova-cards:publish-scheduled')
->everyMinute()
->name('publish-scheduled-nova-cards')
->withoutOverlapping(2)
->runInBackground();
$schedule->command('collections:sync-lifecycle')
->everyTenMinutes()
->name('sync-collection-lifecycle')
->withoutOverlapping()
->runInBackground();
$schedule->command('collections:dispatch-maintenance')
->hourly()
->name('dispatch-collection-maintenance')
->withoutOverlapping()
->runInBackground();
$schedule->command('analytics:aggregate-similar-artworks')->dailyAt('03:10');
$schedule->command('analytics:aggregate-feed')->dailyAt('03:20');
$schedule->command('analytics:aggregate-tag-interactions')->dailyAt('03:30');
// Recalculate trending scores every 30 minutes (staggered to reduce peak load)
$schedule->command('skinbase:recalculate-trending --period=24h')->everyThirtyMinutes();
$schedule->command('skinbase:recalculate-trending --period=7d --skip-index')->everyThirtyMinutes()->runInBackground();
// ── Ranking system (rank_v1) ────────────────────────────────────────
// Step 1: compute per-artwork scores every hour at :05
$schedule->job(new RankComputeArtworkScoresJob)->hourlyAt(5)->runInBackground();
// Step 2: build ranked lists every hour at :15 (after scores are ready)
$schedule->job(new RankBuildListsJob)->hourlyAt(15)->withoutOverlapping()->runInBackground();
// ── Ranking Engine V2 — runs every 30 min ──────────────────────────
$schedule->command('nova:recalculate-rankings --sync-rank-scores')
->everyThirtyMinutes()
->name('ranking-v2')
->withoutOverlapping()
->runInBackground();
$schedule->job(new UpdateLeaderboardsJob)
->hourlyAt(20)
->name('leaderboards-refresh')
->withoutOverlapping()
->runInBackground();
$schedule->job(new RebuildTrendingNovaCardsJob)
->hourlyAt(25)
->name('nova-cards-trending-refresh')
->withoutOverlapping()
->runInBackground();
// ── Rising Engine (Heat / Momentum) ─────────────────────────────────
// Step 1: snapshot metric totals every hour at :00
$schedule->command('nova:metrics-snapshot-hourly')
->hourly()
->name('metrics-snapshot-hourly')
->withoutOverlapping()
->runInBackground();
// Step 2: recalculate heat scores every 15 minutes
$schedule->command('nova:recalculate-heat')
->everyFifteenMinutes()
->name('recalculate-heat')
->withoutOverlapping()
->runInBackground();
// Step 2b: bust Nova Cards v3 rising feed cache to stay in sync
$schedule->job(new RecalculateRisingNovaCardsJob)
->everyFifteenMinutes()
->name('nova-cards-rising-cache-refresh')
->withoutOverlapping()
->runInBackground();
// Step 3: prune old snapshots daily at 04:00
$schedule->command('nova:prune-metric-snapshots --keep-days=7')
->dailyAt('04:00');
$schedule->command('skinbase:sync-countries')
->monthlyOn(1, '03:40')
->name('sync-countries')
->withoutOverlapping()
->runInBackground();
// ── Scheduler health heartbeat ──────────────────────────────────────
// Stamps a Redis key each minute so `health:check --only=scheduler` can
// verify cron is alive. The key expires after 5 minutes so a dead cron
// will naturally cause the check to warn/fail.
$schedule->command('health:tick')
->everyMinute()
->name('health-scheduler-tick');
// The active runtime schedule lives in routes/console.php via bootstrap/app.php.
// Keep the kernel empty so recurring work is not registered twice.
}
/**

47
app/Enums/UserRole.php Normal file
View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Enums;
enum UserRole: string
{
case User = 'user';
case Creator = 'creator';
case Moderator = 'moderator';
case Editorial = 'editorial';
case Manager = 'manager';
case Admin = 'admin';
/** Roles that grant access to the /admin panel. */
public static function staffRoles(): array
{
return [self::Editorial, self::Manager, self::Admin];
}
/** Human-friendly label. */
public function label(): string
{
return match ($this) {
self::User => 'User',
self::Creator => 'Creator',
self::Moderator => 'Moderator',
self::Editorial => 'Editorial',
self::Manager => 'Manager',
self::Admin => 'Admin',
};
}
/** Badge color class (Tailwind). */
public function badgeClass(): string
{
return match ($this) {
self::User => 'bg-slate-500/20 text-slate-300',
self::Creator => 'bg-sky-500/20 text-sky-300',
self::Moderator => 'bg-violet-500/20 text-violet-300',
self::Editorial => 'bg-teal-500/20 text-teal-300',
self::Manager => 'bg-amber-500/20 text-amber-300',
self::Admin => 'bg-rose-500/20 text-rose-300',
};
}
}

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')

View File

@@ -60,6 +60,16 @@ final class AutoTagArtworkJob implements ShouldQueue
return;
}
// Dedup: skip if this artwork+hash was already successfully tagged (7-day window).
// This prevents redundant processing when the job is enqueued multiple times.
try {
if (Redis::exists($this->processedKey($this->artworkId, $this->hash))) {
return;
}
} catch (\Throwable) {
// Redis unavailable — proceed without dedup guard.
}
$artwork = Artwork::query()->with(['categories.contentType'])->find($this->artworkId);
if (! $artwork) {
return;

View File

@@ -6,6 +6,7 @@ namespace App\Jobs;
use App\Models\Artwork;
use Illuminate\Bus\Queueable;
use Meilisearch\Client as MeilisearchClient;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
@@ -22,14 +23,16 @@ class DeleteArtworkFromIndexJob implements ShouldQueue
public int $tries = 3;
public int $timeout = 30;
public function __construct(public readonly int $artworkId) {}
public function handle(): void
public function __construct(public readonly int $artworkId)
{
// Create a bare model instance just to call unsearchable() with the right ID.
$artwork = new Artwork();
$artwork->id = $this->artworkId;
$artwork->unsearchable();
$this->afterCommit = true;
}
public function handle(MeilisearchClient $client): void
{
// Delete directly from the Meilisearch index — no Scout after_commit hop.
$indexName = (new Artwork())->searchableAs();
$client->index($indexName)->deleteDocument($this->artworkId);
}
public function failed(\Throwable $e): void

View File

@@ -89,7 +89,7 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue
}
try {
$imageUrl = $imageUrlBuilder->fromHash($sourceHash, (string) ($artwork->thumb_ext ?: 'webp'));
$imageUrl = $imageUrlBuilder->fromArtwork($artwork);
if ($imageUrl === null) {
return;
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Jobs;
use App\Services\Uploads\UploadQueueService;
use App\Services\Uploads\UploadPipelineService;
use App\Jobs\AnalyzeArtworkAiAssistJob;
use App\Jobs\AutoTagArtworkJob;
@@ -30,11 +31,12 @@ final class GenerateDerivativesJob implements ShouldQueue
private readonly ?string $archiveSessionId = null,
private readonly ?string $archiveHash = null,
private readonly ?string $archiveOriginalFileName = null,
private readonly array $additionalScreenshotSessions = []
private readonly array $additionalScreenshotSessions = [],
private readonly ?int $batchItemId = null,
) {
}
public function handle(UploadPipelineService $pipeline): void
public function handle(UploadPipelineService $pipeline, UploadQueueService $queue): void
{
$pipeline->processAndPublish(
$this->sessionId,
@@ -47,10 +49,27 @@ final class GenerateDerivativesJob implements ShouldQueue
$this->additionalScreenshotSessions
);
if ($this->batchItemId) {
$queue->markItemMediaProcessed($this->batchItemId);
}
// Auto-tagging is async and must never block publish.
AutoTagArtworkJob::dispatch($this->artworkId, $this->hash)->afterCommit();
DetectArtworkMaturityJob::dispatch($this->artworkId, $this->hash)->afterCommit();
GenerateArtworkEmbeddingJob::dispatch($this->artworkId, $this->hash)->afterCommit();
AnalyzeArtworkAiAssistJob::dispatch($this->artworkId)->afterCommit();
}
public function failed(\Throwable $exception): void
{
if (! $this->batchItemId) {
return;
}
app(UploadQueueService::class)->markItemFailed(
$this->batchItemId,
'derivatives_failed',
$exception->getMessage()
);
}
}

View File

@@ -11,35 +11,51 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Meilisearch\Client as MeilisearchClient;
/**
* Queued job: index (or re-index) a single Artwork in Meilisearch.
*
* Writes directly to the Meilisearch HTTP API instead of going through
* Scout's searchable() / MakeSearchable pipeline. This avoids the
* after_commit double-dispatch problem and ensures the document lands
* in the index within this job's execution, with no extra queue hop.
*/
class IndexArtworkJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 30;
public int $timeout = 60;
public function __construct(public readonly int $artworkId) {}
public function handle(): void
public function __construct(public readonly int $artworkId)
{
$artwork = Artwork::with(['user', 'tags', 'categories', 'stats', 'awardStat'])
->find($this->artworkId);
$this->afterCommit = true;
}
public function handle(MeilisearchClient $client): void
{
$artwork = Artwork::with([
'user',
'group',
'tags',
'categories.contentType',
'stats',
'awardStat',
])->find($this->artworkId);
if (! $artwork) {
return;
}
if (! $artwork->is_public || ! $artwork->is_approved || ! $artwork->published_at) {
// Not public/approved — ensure it is removed from the index.
$artwork->unsearchable();
// Not eligible — remove from index if present.
$client->index($artwork->searchableAs())->deleteDocument($this->artworkId);
return;
}
$artwork->searchable();
$document = $artwork->toSearchableArray();
$client->index($artwork->searchableAs())->addDocuments([$document]);
}
public function failed(\Throwable $e): void

View File

@@ -46,33 +46,22 @@ final class RecBuildItemPairsFromFavouritesJob implements ShouldQueue
->pluck('cnt', 'artwork_id')
->all();
// ── Accumulate co-occurrence counts across all users ──
$coOccurrenceCounts = [];
// ── Rebuild weights from scratch to avoid cross-run accumulation ──
DB::table('rec_item_pairs')->delete();
DB::table('artwork_favourites')
->select('user_id')
->groupBy('user_id')
->orderBy('user_id')
->chunk($this->userBatchSize, function ($userRows) use ($favCap, &$coOccurrenceCounts) {
->chunk($this->userBatchSize, function ($userRows) use ($favCap) {
$userIds = [];
foreach ($userRows as $row) {
$pairs = $this->pairsForUser((int) $row->user_id, $favCap);
foreach ($pairs as $pair) {
$key = $pair[0] . ':' . $pair[1];
$coOccurrenceCounts[$key] = ($coOccurrenceCounts[$key] ?? 0) + 1;
}
$userIds[] = (int) $row->user_id;
}
$this->flushPairCountChunk($this->pairCountsForUsers($userIds, $favCap));
});
// ── Normalize to cosine-like scores and flush ──
$normalized = [];
foreach ($coOccurrenceCounts as $key => $count) {
[$a, $b] = explode(':', $key);
$likesA = $this->artworkLikeCounts[(int) $a] ?? 1;
$likesB = $this->artworkLikeCounts[(int) $b] ?? 1;
$normalized[$key] = $count / sqrt($likesA * $likesB);
}
$this->flushPairs($normalized);
}
/** @var array<int, int> artwork_id => total favourite count */
@@ -93,6 +82,56 @@ final class RecBuildItemPairsFromFavouritesJob implements ShouldQueue
->map(fn ($id) => (int) $id)
->all();
return $this->pairsForArtworkIds($artworkIds);
}
/**
* Collect chunk-local pair counts using one capped favourites query for the chunk.
*
* @param list<int> $userIds
* @return array<string, int>
*/
private function pairCountsForUsers(array $userIds, int $cap): array
{
if ($userIds === []) {
return [];
}
$rankedFavourites = DB::query()
->fromSub(
DB::table('artwork_favourites')
->selectRaw('user_id, artwork_id, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC, artwork_id DESC) as favourite_rank')
->whereIn('user_id', $userIds),
'ranked_favourites'
)
->where('favourite_rank', '<=', $cap)
->orderBy('user_id')
->orderBy('favourite_rank')
->get(['user_id', 'artwork_id']);
$artworksByUser = [];
foreach ($rankedFavourites as $row) {
$artworksByUser[(int) $row->user_id][] = (int) $row->artwork_id;
}
$pairCounts = [];
foreach ($artworksByUser as $artworkIds) {
foreach ($this->pairsForArtworkIds($artworkIds) as [$a, $b]) {
$key = $this->pairKey($a, $b);
$pairCounts[$key] = ($pairCounts[$key] ?? 0) + 1;
}
}
return $pairCounts;
}
/**
* @param list<int> $artworkIds
* @return list<array{0: int, 1: int}>
*/
private function pairsForArtworkIds(array $artworkIds): array
{
$count = count($artworkIds);
if ($count < 2) {
return [];
@@ -112,28 +151,50 @@ final class RecBuildItemPairsFromFavouritesJob implements ShouldQueue
}
/**
* Upsert normalized pair weights into rec_item_pairs.
* Upsert one chunk of pair counts into rec_item_pairs.
*
* Uses Laravel's DB-agnostic upsert (works on MySQL, Postgres, SQLite).
*
* @param array<string, float> $upserts key = "a:b", value = cosine-normalized weight
* @param array<string, int> $pairCounts key = "a:b", value = chunk-local co-occurrence count
*/
private function flushPairs(array $upserts): void
private function flushPairCountChunk(array $pairCounts): void
{
if ($upserts === []) {
if ($pairCounts === []) {
return;
}
$now = now();
foreach (array_chunk($upserts, 500, preserve_keys: true) as $chunk) {
foreach (array_chunk($pairCounts, 500, preserve_keys: true) as $chunk) {
$pairIds = [];
$aIds = [];
$bIds = [];
foreach ($chunk as $key => $count) {
[$a, $b] = $this->pairIdsFromKey($key);
$pairIds[$key] = [$a, $b];
$aIds[] = $a;
$bIds[] = $b;
}
$existingWeights = DB::table('rec_item_pairs')
->whereIn('a_artwork_id', array_values(array_unique($aIds)))
->whereIn('b_artwork_id', array_values(array_unique($bIds)))
->get(['a_artwork_id', 'b_artwork_id', 'weight'])
->mapWithKeys(fn ($row): array => [
$this->pairKey((int) $row->a_artwork_id, (int) $row->b_artwork_id) => (float) $row->weight,
])
->all();
$rows = [];
foreach ($chunk as $key => $weight) {
[$a, $b] = explode(':', $key);
foreach ($chunk as $key => $count) {
[$a, $b] = $pairIds[$key];
$likesA = $this->artworkLikeCounts[$a] ?? 1;
$likesB = $this->artworkLikeCounts[$b] ?? 1;
$deltaWeight = $count / sqrt($likesA * $likesB);
$rows[] = [
'a_artwork_id' => (int) $a,
'b_artwork_id' => (int) $b,
'weight' => $weight,
'a_artwork_id' => $a,
'b_artwork_id' => $b,
'weight' => ($existingWeights[$key] ?? 0.0) + $deltaWeight,
'updated_at' => $now,
];
}
@@ -145,4 +206,19 @@ final class RecBuildItemPairsFromFavouritesJob implements ShouldQueue
);
}
}
private function pairKey(int $a, int $b): string
{
return $a . ':' . $b;
}
/**
* @return array{0: int, 1: int}
*/
private function pairIdsFromKey(string $key): array
{
[$a, $b] = explode(':', $key, 2);
return [(int) $a, (int) $b];
}
}

24
app/Mail/TestMail.php Normal file
View File

@@ -0,0 +1,24 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class TestMail extends Mailable
{
use Queueable, SerializesModels;
public string $body;
public function __construct(string $body = 'This is a test email from Skinbase.')
{
$this->body = $body;
}
public function build()
{
return $this->subject('Skinbase Test Mail')->html("<p>{$this->body}</p>");
}
}

View File

@@ -28,6 +28,19 @@ class Artwork extends Model
{
use HasFactory, SoftDeletes, Searchable;
/**
* Override Scout's bootSearchable to skip the ModelObserver (which fires MakeSearchable
* on every save). We still register SearchableScope and Builder macros so that
* scout:import and Builder::searchable() continue to work.
* All indexing is managed explicitly via IndexArtworkJob.
*/
public static function bootSearchable(): void
{
static::addGlobalScope(new \Laravel\Scout\SearchableScope);
(new static)->registerSearchableMacros();
// ModelObserver intentionally omitted — indexing is handled by IndexArtworkJob.
}
public const PUBLISHED_AS_USER = 'user';
public const PUBLISHED_AS_GROUP = 'group';
@@ -254,6 +267,11 @@ class Artwork extends Model
return $this->hasMany(WorldSubmission::class)->orderByDesc('reviewed_at')->orderByDesc('created_at');
}
public function worldRewardGrants(): HasMany
{
return $this->hasMany(WorldRewardGrant::class)->orderByDesc('granted_at')->orderByDesc('id');
}
public function isPublishedByGroup(): bool
{
return $this->publishedAsType() === self::PUBLISHED_AS_GROUP;
@@ -409,6 +427,14 @@ class Artwork extends Model
$stat = $this->stats;
$awardStat = $this->awardStat;
$publishedSortAt = $this->published_at ?? $this->created_at;
$sortedCategories = $this->categories->sortBy(
fn ($category) => sprintf(
'%010d|%s|%010d',
(int) ($category->sort_order ?? 999999999),
strtolower((string) ($category->name ?? '')),
(int) ($category->id ?? 0)
)
)->values();
// Orientation derived from pixel dimensions
$orientation = 'square';
@@ -425,8 +451,22 @@ class Artwork extends Model
? $this->width . 'x' . $this->height
: '';
// Primary category slug (first attached category)
$primaryCategory = $this->categories->first();
// Primary category slug follows the same sort_order-first semantics used by page presenters.
$primaryCategory = $sortedCategories->first();
$categorySlugs = $sortedCategories
->pluck('slug')
->filter()
->map(static fn ($slug) => (string) $slug)
->unique()
->values()
->all();
$contentTypeSlugs = $sortedCategories
->map(static fn ($category) => $category->contentType?->slug)
->filter()
->map(static fn ($slug) => (string) $slug)
->unique()
->values()
->all();
$category = $primaryCategory?->slug ?? '';
$content_type = $primaryCategory?->contentType?->slug ?? '';
@@ -442,7 +482,9 @@ class Artwork extends Model
'author_name' => $this->group?->name ?? $this->user?->name ?? 'Skinbase',
'published_as_type' => $this->publishedAsType(),
'category' => $category,
'categories' => $categorySlugs,
'content_type' => $content_type,
'content_types' => $contentTypeSlugs,
'tags' => $tags,
'ai_clip_tags' => collect((array) ($this->clip_tags_json ?? []))
->map(static fn ($row) => is_array($row) ? (string) ($row['tag'] ?? '') : '')

View File

@@ -83,6 +83,6 @@ final class Country extends Model
return null;
}
return '/gfx/flags/shiny/24/'.rawurlencode($iso2).'.png';
return rtrim((string) \config('cdn.files_url', ''), '/').'/images/flags/shiny/24/'.rawurlencode($iso2).'.png';
}
}

View File

@@ -19,11 +19,13 @@ final class Tag extends Model
'name',
'slug',
'usage_count',
'artworks_count',
'is_active',
];
protected $casts = [
'usage_count' => 'integer',
'artworks_count' => 'integer',
'is_active' => 'boolean',
];

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
final class UploadBatch extends Model
{
public const STATUS_UPLOADING = 'uploading';
public const STATUS_PROCESSING = 'processing';
public const STATUS_COMPLETED = 'completed';
public const STATUS_COMPLETED_WITH_ERRORS = 'completed_with_errors';
public const STATUS_CANCELLED = 'cancelled';
protected $fillable = [
'user_id',
'name',
'status',
'total_items',
'processed_items',
'failed_items',
'published_items',
'defaults_json',
];
protected $casts = [
'defaults_json' => 'array',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function items(): HasMany
{
return $this->hasMany(UploadBatchItem::class)->orderBy('id');
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class UploadBatchItem extends Model
{
public const STATUS_UPLOADED = 'uploaded';
public const STATUS_PROCESSING = 'processing';
public const STATUS_NEEDS_METADATA = 'needs_metadata';
public const STATUS_NEEDS_REVIEW = 'needs_review';
public const STATUS_READY = 'ready';
public const STATUS_FAILED = 'failed';
public const STATUS_PUBLISHED = 'published';
public const STATUS_DELETED = 'deleted';
public const STAGE_QUEUED = 'queued';
public const STAGE_STORED = 'stored';
public const STAGE_THUMBNAILS = 'thumbnails';
public const STAGE_VISION_ANALYSIS = 'vision_analysis';
public const STAGE_MATURITY_CHECK = 'maturity_check';
public const STAGE_METADATA_SUGGESTIONS = 'metadata_suggestions';
public const STAGE_FINALIZED = 'finalized';
protected $fillable = [
'upload_batch_id',
'user_id',
'artwork_id',
'original_filename',
'status',
'processing_stage',
'error_code',
'error_message',
'metadata_completeness',
'is_ready_to_publish',
'uploaded_at',
'processed_at',
'published_at',
];
protected $casts = [
'is_ready_to_publish' => 'boolean',
'uploaded_at' => 'datetime',
'processed_at' => 'datetime',
'published_at' => 'datetime',
];
public function batch(): BelongsTo
{
return $this->belongsTo(UploadBatch::class, 'upload_batch_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
}

View File

@@ -265,6 +265,11 @@ class User extends Authenticatable
return $this->hasMany(UserActivity::class, 'user_id');
}
public function worldRewardGrants(): HasMany
{
return $this->hasMany(WorldRewardGrant::class, 'user_id')->orderByDesc('granted_at')->orderByDesc('id');
}
public function achievements(): BelongsToMany
{
return $this->belongsToMany(Achievement::class, 'user_achievements', 'user_id', 'achievement_id')
@@ -385,6 +390,25 @@ class User extends Authenticatable
return $this->hasRole('admin') || $this->hasLegacyPrivilegeFlag('isAdmin');
}
public function isManager(): bool
{
return $this->hasRole('manager');
}
public function isEditorial(): bool
{
return $this->hasRole('editorial');
}
/**
* Returns true for any role that grants access to the /admin panel
* (admin, manager, editorial).
*/
public function hasStaffAccess(): bool
{
return $this->isAdmin() || $this->isManager() || $this->isEditorial();
}
public function isModerator(): bool
{
return $this->hasRole('moderator') || $this->hasLegacyPrivilegeFlag('isModerator');

View File

@@ -23,6 +23,7 @@ class UserActivity extends Model
public const TYPE_FAVOURITE = 'favourite';
public const TYPE_FOLLOW = 'follow';
public const TYPE_ACHIEVEMENT = 'achievement';
public const TYPE_WORLD_REWARD = 'world_reward';
public const TYPE_FORUM_POST = 'forum_post';
public const TYPE_FORUM_REPLY = 'forum_reply';
@@ -30,6 +31,7 @@ class UserActivity extends Model
public const ENTITY_ARTWORK_COMMENT = 'artwork_comment';
public const ENTITY_USER = 'user';
public const ENTITY_ACHIEVEMENT = 'achievement';
public const ENTITY_WORLD_REWARD = 'world_reward';
public const ENTITY_FORUM_THREAD = 'forum_thread';
public const ENTITY_FORUM_POST = 'forum_post';

View File

@@ -17,6 +17,7 @@ use App\Models\ArtworkReaction;
use App\Models\ContentType;
use App\Models\GroupRelease;
use App\Models\GroupReleaseContributor;
use App\Models\HomepageAnnouncement;
use App\Observers\ArtworkAwardObserver;
use App\Observers\ArtworkCommentObserver;
use App\Observers\ArtworkFeatureObserver;
@@ -26,12 +27,15 @@ use App\Observers\ArtworkReactionObserver;
use App\Observers\ContentTypeObserver;
use App\Observers\GroupReleaseContributorObserver;
use App\Observers\GroupReleaseObserver;
use App\Observers\HomepageAnnouncementObserver;
use App\Services\Upload\Contracts\UploadDraftServiceInterface;
use App\Services\Upload\UploadDraftService;
use App\Services\ContentTypes\ContentTypeSlugResolver;
use App\Services\Worlds\WorldService;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\View;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
@@ -120,6 +124,7 @@ class AppServiceProvider extends ServiceProvider
ContentType::observe(ContentTypeObserver::class);
GroupRelease::observe(GroupReleaseObserver::class);
GroupReleaseContributor::observe(GroupReleaseContributorObserver::class);
HomepageAnnouncement::observe(HomepageAnnouncementObserver::class);
// ── OAuth / SocialiteProviders ──────────────────────────────────────
Event::listen(
@@ -157,6 +162,7 @@ class AppServiceProvider extends ServiceProvider
$displayName = null;
$userId = null;
$toolbarContentTypes = collect();
$toolbarActiveCampaign = null;
$request = request();
$canReadSessionAuth = $request instanceof \Illuminate\Http\Request
&& $request->hasSession()
@@ -173,39 +179,78 @@ class AppServiceProvider extends ServiceProvider
$toolbarContentTypes = collect();
}
try {
$toolbarActiveCampaign = $this->app
->make(WorldService::class)
->navigationCampaign();
} catch (\Throwable $e) {
$toolbarActiveCampaign = null;
}
if ($authUser) {
$authUser->loadMissing('profile');
$userId = (int) $authUser->id;
try {
$uploadCount = DB::table('artworks')->where('user_id', $userId)->count();
$ttl = (int) config('toolbar.cache_ttl_seconds', 30);
$stats = Cache::remember("toolbar:{$userId}", $ttl, function () use ($userId) {
$toolbarStats = DB::table('users')
->select('id')
->selectSub(
DB::table('artworks')
->selectRaw('COUNT(*)')
->whereColumn('user_id', 'users.id'),
'upload_count'
)
->selectSub(
DB::table('artwork_favourites')
->selectRaw('COUNT(*)')
->whereColumn('user_id', 'users.id'),
'fav_count'
)
->selectSub(
DB::table('notifications')
->selectRaw('COUNT(*)')
->whereColumn('user_id', 'users.id')
->whereNull('read_at'),
'notice_count'
)
->where('id', $userId)
->first();
$uploadCount = (int) ($toolbarStats->upload_count ?? 0);
$favCount = (int) ($toolbarStats->fav_count ?? 0);
$noticeCount = (int) ($toolbarStats->notice_count ?? 0);
$msgCount = (int) DB::table('conversation_participants as cp')
->join('messages as m', 'm.conversation_id', '=', 'cp.conversation_id')
->where('cp.user_id', $userId)
->whereNull('cp.left_at')
->whereNull('m.deleted_at')
->where('m.sender_id', '!=', $userId)
->where(function ($q) {
$q->whereNull('cp.last_read_at')
->orWhereColumn('m.created_at', '>', 'cp.last_read_at');
})
->count();
return [
'upload_count' => $uploadCount,
'fav_count' => $favCount,
'notice_count' => $noticeCount,
'msg_count' => $msgCount,
];
});
$uploadCount = (int) ($stats['upload_count'] ?? 0);
$favCount = (int) ($stats['fav_count'] ?? 0);
$noticeCount = (int) ($stats['notice_count'] ?? 0);
$msgCount = (int) ($stats['msg_count'] ?? 0);
} catch (\Throwable $e) {
$uploadCount = 0;
}
try {
$favCount = DB::table('artwork_favourites')->where('user_id', $userId)->count();
} catch (\Throwable $e) {
$favCount = 0;
}
try {
$msgCount = (int) DB::table('conversation_participants as cp')
->join('messages as m', 'm.conversation_id', '=', 'cp.conversation_id')
->where('cp.user_id', $userId)
->whereNull('cp.left_at')
->whereNull('m.deleted_at')
->where('m.sender_id', '!=', $userId)
->where(function ($q) {
$q->whereNull('cp.last_read_at')
->orWhereColumn('m.created_at', '>', 'cp.last_read_at');
})
->count();
} catch (\Throwable $e) {
$msgCount = 0;
}
try {
$noticeCount = DB::table('notifications')->where('user_id', $userId)->whereNull('read_at')->count();
} catch (\Throwable $e) {
$noticeCount = 0;
}
@@ -216,17 +261,11 @@ class AppServiceProvider extends ServiceProvider
$receivedCommentsCount = 0;
}
try {
$profile = DB::table('user_profiles')->where('user_id', $userId)->first();
$avatarHash = $profile->avatar_hash ?? null;
} catch (\Throwable $e) {
$avatarHash = null;
}
$avatarHash = $authUser->profile?->avatar_hash;
$displayName = $authUser->name ?: ($authUser->username ?? '');
}
$view->with(compact('userId','uploadCount', 'favCount', 'msgCount', 'noticeCount', 'receivedCommentsCount', 'avatarHash', 'displayName', 'toolbarContentTypes'));
$view->with(compact('userId','uploadCount', 'favCount', 'msgCount', 'noticeCount', 'receivedCommentsCount', 'avatarHash', 'displayName', 'toolbarContentTypes', 'toolbarActiveCampaign'));
});
// Replace the framework HandleCors with our ConditionalCors so the

View File

@@ -11,6 +11,7 @@ use App\Models\ForumPost;
use App\Models\ForumThread;
use App\Models\User;
use App\Models\UserActivity;
use App\Models\WorldRewardGrant;
use App\Support\AvatarUrl;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
@@ -20,7 +21,7 @@ use Illuminate\Support\Str;
final class UserActivityService
{
public const DEFAULT_PER_PAGE = 20;
private const FEED_SCHEMA_VERSION = 2;
private const FEED_SCHEMA_VERSION = 3;
private const FILTER_ALL = 'all';
private const FILTER_UPLOADS = 'uploads';
@@ -65,6 +66,11 @@ final class UserActivityService
return $this->log($userId, UserActivity::TYPE_ACHIEVEMENT, UserActivity::ENTITY_ACHIEVEMENT, $achievementId, $meta);
}
public function logWorldReward(int $userId, int $worldRewardGrantId, array $meta = []): ?UserActivity
{
return $this->log($userId, UserActivity::TYPE_WORLD_REWARD, UserActivity::ENTITY_WORLD_REWARD, $worldRewardGrantId, $meta);
}
public function logForumPost(int $userId, int $threadId, array $meta = []): ?UserActivity
{
return $this->log($userId, UserActivity::TYPE_FORUM_POST, UserActivity::ENTITY_FORUM_THREAD, $threadId, $meta);
@@ -220,6 +226,14 @@ final class UserActivityService
->values()
->all();
$worldRewardIds = $rows
->filter(fn (UserActivity $activity): bool => $activity->entity_type === UserActivity::ENTITY_WORLD_REWARD)
->pluck('entity_id')
->map(fn (mixed $id): int => (int) $id)
->unique()
->values()
->all();
return [
'artworks' => empty($artworkIds)
? collect()
@@ -245,7 +259,7 @@ final class UserActivityService
? collect()
: User::query()
->with('profile:user_id,avatar_hash')
->withCount('artworks')
->with('statistics:user_id,uploads_count')
->whereIn('id', $userIds)
->where('is_active', true)
->whereNull('deleted_at')
@@ -276,6 +290,13 @@ final class UserActivityService
->whereHas('thread', fn ($query) => $query->where('visibility', 'public')->whereNull('deleted_at'))
->get()
->keyBy('id'),
'world_rewards' => empty($worldRewardIds)
? collect()
: WorldRewardGrant::query()
->with(['world', 'artwork'])
->whereIn('id', $worldRewardIds)
->get()
->keyBy('id'),
];
}
@@ -299,6 +320,7 @@ final class UserActivityService
UserActivity::TYPE_REPLY => $this->formatCommentActivity($base, $activity, $related),
UserActivity::TYPE_FOLLOW => $this->formatFollowActivity($base, $activity, $related),
UserActivity::TYPE_ACHIEVEMENT => $this->formatAchievementActivity($base, $activity, $related),
UserActivity::TYPE_WORLD_REWARD => $this->formatWorldRewardActivity($base, $activity, $related),
UserActivity::TYPE_FORUM_POST,
UserActivity::TYPE_FORUM_REPLY => $this->formatForumActivity($base, $activity, $related),
default => null,
@@ -374,6 +396,37 @@ final class UserActivityService
];
}
private function formatWorldRewardActivity(array $base, UserActivity $activity, array $related): ?array
{
/** @var WorldRewardGrant|null $grant */
$grant = $related['world_rewards']->get((int) $activity->entity_id);
if (! $grant || ! $grant->world) {
return null;
}
return [
...$base,
'world_reward' => [
'id' => (int) $grant->id,
'reward_type' => $grant->reward_type->value,
'reward_label' => $grant->reward_type->label(),
'badge_label' => trim($grant->world->title . ' ' . $grant->reward_type->label()),
'tone' => $grant->reward_type->tone(),
'world' => [
'id' => (int) $grant->world->id,
'title' => (string) $grant->world->title,
'url' => $grant->world->publicUrl(),
],
'artwork' => $grant->artwork ? [
'id' => (int) $grant->artwork->id,
'title' => (string) ($grant->artwork->title ?? 'Artwork'),
'url' => route('art.show', ['id' => (int) $grant->artwork->id, 'slug' => $grant->artwork->slug ?: Str::slug((string) $grant->artwork->title)]),
] : null,
'note' => (string) ($grant->note ?? ''),
],
];
}
private function formatForumActivity(array $base, UserActivity $activity, array $related): ?array
{
if ($activity->type === UserActivity::TYPE_FORUM_POST) {
@@ -510,7 +563,7 @@ final class UserActivityService
return ['label' => 'Moderator', 'tone' => 'amber'];
}
if ((int) ($user->artworks_count ?? 0) > 0) {
if ((int) ($user->statistics?->uploads_count ?? $user->artworks_count ?? 0) > 0) {
return ['label' => 'Creator', 'tone' => 'sky'];
}
@@ -533,6 +586,7 @@ final class UserActivityService
UserActivity::TYPE_FAVOURITE,
UserActivity::TYPE_FOLLOW,
UserActivity::TYPE_ACHIEVEMENT,
UserActivity::TYPE_WORLD_REWARD,
UserActivity::TYPE_FORUM_POST,
UserActivity::TYPE_FORUM_REPLY,
],

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Services\Uploads\UploadStorageService;
use Illuminate\Support\Facades\Storage;
final class ArtworkOriginalFileLocator
{
public function __construct(
private readonly UploadStorageService $storage,
) {}
public function resolveLocalPath(Artwork $artwork): string
{
$objectPath = $this->resolveObjectPath($artwork);
$prefix = $this->originalObjectPrefix();
if ($objectPath !== '' && str_starts_with($objectPath, $prefix)) {
$suffix = substr($objectPath, strlen($prefix));
$root = rtrim($this->storage->localOriginalsRoot(), DIRECTORY_SEPARATOR);
return $root . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, (string) $suffix);
}
$hash = strtolower((string) $artwork->hash);
$ext = strtolower(ltrim((string) $artwork->file_ext, '.'));
if (! $this->isValidHash($hash) || $ext === '') {
return '';
}
$root = rtrim($this->storage->localOriginalsRoot(), DIRECTORY_SEPARATOR);
return $root
. DIRECTORY_SEPARATOR . substr($hash, 0, 2)
. DIRECTORY_SEPARATOR . substr($hash, 2, 2)
. DIRECTORY_SEPARATOR . $hash . '.' . $ext;
}
public function resolveObjectPath(Artwork $artwork): string
{
$relative = trim((string) $artwork->file_path, '/');
$prefix = $this->originalObjectPrefix();
if ($relative !== '' && str_starts_with($relative, $prefix)) {
return $relative;
}
$hash = strtolower((string) $artwork->hash);
$ext = strtolower(ltrim((string) $artwork->file_ext, '.'));
if (! $this->isValidHash($hash) || $ext === '') {
return '';
}
return $this->storage->objectPathForVariant('original', $hash, $hash . '.' . $ext);
}
public function resolveObjectUrl(Artwork $artwork): ?string
{
$objectPath = $this->resolveObjectPath($artwork);
if ($objectPath === '') {
return null;
}
return Storage::disk($this->storage->objectDiskName())->url($objectPath);
}
private function originalObjectPrefix(): string
{
return trim($this->storage->objectBasePrefix(), '/') . '/original/';
}
private function isValidHash(string $hash): bool
{
return $hash !== '' && preg_match('/^[a-f0-9]+$/', $hash) === 1;
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Services;
use App\Jobs\DeleteArtworkFromIndexJob;
use App\Jobs\IndexArtworkJob;
use App\Models\Artwork;
use Closure;
use Illuminate\Support\Facades\Log;
/**
@@ -43,19 +44,63 @@ final class ArtworkSearchIndexer
/**
* Rebuild the entire artworks index in background chunks.
* Run via: php artisan artworks:search-rebuild
*
* @param Closure(int, int, int, int, int, int): void|null $onChunk
* @return array{total:int, dispatched:int, chunks:int}
*/
public function rebuildAll(int $chunkSize = 500): void
public function rebuildAll(int $chunkSize = 500, ?Closure $onChunk = null, bool $reverse = false, ?int $limit = null): array
{
Artwork::with(['user', 'tags', 'categories', 'stats', 'awardStat'])
$query = Artwork::query()
->public()
->published()
->orderBy('id')
->chunk($chunkSize, function ($artworks): void {
->published();
if ($reverse) {
$query->orderByDesc('id');
} else {
$query->orderBy('id');
}
if ($limit !== null) {
$query->limit($limit);
}
$total = (clone $query)->count();
$dispatched = 0;
$chunks = 0;
$query
->with(['user', 'tags', 'categories', 'stats', 'awardStat'])
->chunk($chunkSize, function ($artworks) use (&$chunks, &$dispatched, $total, $onChunk): void {
$chunks++;
$count = $artworks->count();
$firstId = (int) ($artworks->first()?->id ?? 0);
$lastId = (int) ($artworks->last()?->id ?? 0);
foreach ($artworks as $artwork) {
IndexArtworkJob::dispatch($artwork->id);
$dispatched++;
}
if ($onChunk !== null) {
$onChunk($chunks, $count, $dispatched, $total, $firstId, $lastId);
}
});
Log::info('ArtworkSearchIndexer::rebuildAll — jobs dispatched');
Log::info('ArtworkSearchIndexer::rebuildAll — jobs dispatched', [
'total' => $total,
'dispatched' => $dispatched,
'chunks' => $chunks,
'chunk_size' => $chunkSize,
'reverse' => $reverse,
'limit' => $limit,
]);
return [
'total' => $total,
'dispatched' => $dispatched,
'chunks' => $chunks,
];
}
}

View File

@@ -55,7 +55,7 @@ final class ArtworkSearchService
}
if (! empty($filters['category'])) {
$filterParts[] = 'category = "' . addslashes((string) $filters['category']) . '"';
$filterParts[] = $this->categoryFilterClause((string) $filters['category']);
}
if (! empty($filters['orientation'])) {
@@ -90,7 +90,7 @@ final class ArtworkSearchService
return $results;
}
$page = max(1, (int) request()->get('page', 1));
$page = $this->currentPage();
$candidateCount = $this->determineSearchCandidatePoolSize($perPage, $page);
$fallbackResults = Artwork::search($q ?: '')
->options($options)
@@ -108,7 +108,7 @@ final class ArtworkSearchService
public function searchWithThumbnailPreference(array $options, int $perPage, bool $excludeMissing = false, ?int $page = null): LengthAwarePaginator
{
$page = max(1, $page ?? (int) request()->get('page', 1));
$page = max(1, $page ?? $this->currentPage());
$candidateCount = $this->determineSearchCandidatePoolSize($perPage, $page);
$results = Artwork::search('')
->options($this->viewerAwareOptions($options))
@@ -139,7 +139,7 @@ final class ArtworkSearchService
}
$sort = in_array($sort, self::TAG_SORTS, true) ? $sort : 'popular';
$cacheKey = "search.tag.{$slug}.{$sort}.{$perPage}.{$this->viewerCacheSegment()}.page." . request()->get('page', 1);
$cacheKey = "search.tag.{$slug}.{$sort}.{$perPage}.{$this->viewerCacheSegment()}.page." . $this->currentPage();
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($tag, $perPage, $sort) {
$query = Artwork::query()
@@ -180,12 +180,12 @@ final class ArtworkSearchService
*/
public function byCategory(string $cat, int $perPage = 24, array $filters = []): LengthAwarePaginator
{
$page = (int) request()->get('page', 1);
$page = $this->currentPage();
$cacheKey = "search.cat.catalog-visible.v2.{$cat}.{$this->viewerCacheSegment()}.page." . $page;
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($cat, $perPage, $page) {
return $this->searchWithThumbnailPreference([
'filter' => self::BASE_FILTER . ' AND category = "' . addslashes($cat) . '"',
'filter' => self::BASE_FILTER . ' AND ' . $this->categoryFilterClause($cat),
'sort' => ['created_at:desc'],
], $perPage, false, $page);
});
@@ -226,15 +226,15 @@ final class ArtworkSearchService
public function categoryPageSort(string $categorySlug, string $sort = 'trending', int $perPage = 24): LengthAwarePaginator
{
$sort = array_key_exists($sort, self::CATEGORY_SORT_FIELDS) ? $sort : 'trending';
$page = (int) request()->get('page', 1);
$page = $this->currentPage();
$ttl = self::CATEGORY_SORT_TTL[$sort] ?? self::CACHE_TTL;
$cacheKey = "category.catalog-visible.v2.{$categorySlug}.{$sort}.{$this->viewerCacheSegment()}.{$page}";
return Cache::remember($cacheKey, $ttl, function () use ($categorySlug, $sort, $perPage) {
return $this->searchWithThumbnailPreference([
'filter' => self::BASE_FILTER . ' AND category = "' . addslashes($categorySlug) . '"',
'filter' => self::BASE_FILTER . ' AND ' . $this->categoryFilterClause($categorySlug),
'sort' => self::CATEGORY_SORT_FIELDS[$sort],
], $perPage, false, (int) request()->get('page', 1));
], $perPage, false, $this->currentPage());
});
}
@@ -247,15 +247,15 @@ final class ArtworkSearchService
public function contentTypePageSort(string $contentTypeSlug, string $sort = 'trending', int $perPage = 24): LengthAwarePaginator
{
$sort = array_key_exists($sort, self::CATEGORY_SORT_FIELDS) ? $sort : 'trending';
$page = (int) request()->get('page', 1);
$page = $this->currentPage();
$ttl = self::CATEGORY_SORT_TTL[$sort] ?? self::CACHE_TTL;
$cacheKey = "content_type.catalog-visible.v2.{$contentTypeSlug}.{$sort}.{$this->viewerCacheSegment()}.{$page}";
return Cache::remember($cacheKey, $ttl, function () use ($contentTypeSlug, $sort, $perPage) {
return $this->searchWithThumbnailPreference([
'filter' => self::BASE_FILTER . ' AND content_type = "' . addslashes($contentTypeSlug) . '"',
'filter' => self::BASE_FILTER . ' AND ' . $this->contentTypeFilterClause($contentTypeSlug),
'sort' => self::CATEGORY_SORT_FIELDS[$sort],
], $perPage, false, (int) request()->get('page', 1));
], $perPage, false, $this->currentPage());
});
}
@@ -295,7 +295,7 @@ final class ArtworkSearchService
*/
public function popular(int $perPage = 24): LengthAwarePaginator
{
return Cache::remember('search.popular.' . $this->viewerCacheSegment() . '.page.' . request()->get('page', 1), self::CACHE_TTL, function () use ($perPage) {
return Cache::remember('search.popular.' . $this->viewerCacheSegment() . '.page.' . $this->currentPage(), self::CACHE_TTL, function () use ($perPage) {
return Artwork::search('')
->options($this->viewerAwareOptions([
'filter' => self::BASE_FILTER,
@@ -310,7 +310,7 @@ final class ArtworkSearchService
*/
public function recent(int $perPage = 24): LengthAwarePaginator
{
return Cache::remember('search.recent.' . $this->viewerCacheSegment() . '.page.' . request()->get('page', 1), self::CACHE_TTL, function () use ($perPage) {
return Cache::remember('search.recent.' . $this->viewerCacheSegment() . '.page.' . $this->currentPage(), self::CACHE_TTL, function () use ($perPage) {
return Artwork::search('')
->options($this->viewerAwareOptions([
'filter' => self::BASE_FILTER,
@@ -330,7 +330,7 @@ final class ArtworkSearchService
*/
public function discoverTrending(int $perPage = 24): LengthAwarePaginator
{
$page = (int) request()->get('page', 1);
$page = $this->currentPage();
$windowDays = $this->timeWindow->getTrendingWindowDays(30);
$cutoff = now()->subDays($windowDays)->toDateString();
// Include window in cache key so adaptive expansions surface immediately
@@ -352,7 +352,7 @@ final class ArtworkSearchService
*/
public function discoverRising(int $perPage = 24): LengthAwarePaginator
{
$page = (int) request()->get('page', 1);
$page = $this->currentPage();
$windowDays = $this->timeWindow->getTrendingWindowDays(30);
$cutoff = now()->subDays($windowDays)->toDateString();
$cacheKey = "discover.rising.{$windowDays}d.{$this->viewerCacheSegment()}.{$page}";
@@ -370,7 +370,7 @@ final class ArtworkSearchService
*/
public function discoverFresh(int $perPage = 24): LengthAwarePaginator
{
$page = (int) request()->get('page', 1);
$page = $this->currentPage();
return Cache::remember("discover.fresh.{$this->viewerCacheSegment()}.{$page}", self::CACHE_TTL, function () use ($perPage) {
return $this->searchWithThumbnailPreference([
'filter' => self::BASE_FILTER,
@@ -384,7 +384,7 @@ final class ArtworkSearchService
*/
public function discoverTopRated(int $perPage = 24): LengthAwarePaginator
{
$page = (int) request()->get('page', 1);
$page = $this->currentPage();
return Cache::remember("discover.top-rated.{$this->viewerCacheSegment()}.{$page}", self::CACHE_TTL, function () use ($perPage) {
return $this->searchWithThumbnailPreference([
'filter' => self::BASE_FILTER,
@@ -398,7 +398,7 @@ final class ArtworkSearchService
*/
public function discoverMostDownloaded(int $perPage = 24): LengthAwarePaginator
{
$page = (int) request()->get('page', 1);
$page = $this->currentPage();
return Cache::remember("discover.most-downloaded.{$this->viewerCacheSegment()}.{$page}", self::CACHE_TTL, function () use ($perPage) {
return $this->searchWithThumbnailPreference([
'filter' => self::BASE_FILTER,
@@ -441,6 +441,11 @@ final class ArtworkSearchService
return $options;
}
private function currentPage(): int
{
return max(1, (int) request()->query('page', 1));
}
private function shouldFallbackToViewerVisibilityFiltering(LengthAwarePaginator $results): bool
{
if ($results->total() > 0) {
@@ -468,7 +473,7 @@ final class ArtworkSearchService
}
$catFilter = implode(' OR ', array_map(
fn (string $c): string => 'category = "' . addslashes($c) . '"',
fn (string $c): string => $this->categoryFilterClause($c),
array_slice($categorySlugs, 0, 3)
));
@@ -494,6 +499,20 @@ final class ArtworkSearchService
return in_array($field, $allowed, true) ? [$field, $dir] : [null, 'desc'];
}
private function categoryFilterClause(string $categorySlug): string
{
$quoted = addslashes($categorySlug);
return '(category = "' . $quoted . '" OR categories = "' . $quoted . '")';
}
private function contentTypeFilterClause(string $contentTypeSlug): string
{
$quoted = addslashes($contentTypeSlug);
return '(content_type = "' . $quoted . '" OR content_types = "' . $quoted . '")';
}
private function rerankSearchCollectionByThumbnailHealth(Collection $items, bool $excludeMissing): Collection
{
if ($items->isEmpty()) {

View File

@@ -214,43 +214,7 @@ class ArtworkService
*/
public function getArtworksByCategoryPath(array $slugs, int $perPage, string $sort = 'latest'): CursorPaginator
{
if (empty($slugs)) {
$e = new ModelNotFoundException();
$e->setModel(Category::class);
throw $e;
}
$parts = array_values(array_map('strtolower', $slugs));
$contentTypeSlug = array_shift($parts);
$contentType = $this->resolveContentTypeOrFail((string) $contentTypeSlug);
if (empty($parts)) {
$e = new ModelNotFoundException();
$e->setModel(Category::class, []);
throw $e;
}
// Resolve the category path from roots downward within the content type.
$current = Category::where('content_type_id', $contentType->id)
->whereNull('parent_id')
->where('slug', array_shift($parts))
->first();
if (! $current) {
$e = new ModelNotFoundException();
$e->setModel(Category::class, $slugs);
throw $e;
}
foreach ($parts as $slug) {
$current = $current->children()->where('slug', $slug)->first();
if (! $current) {
$e = new ModelNotFoundException();
$e->setModel(Category::class, $slugs);
throw $e;
}
}
$current = $this->resolveCategoryByPath($slugs);
$categoryIds = $this->categoryAndDescendantIds($current);
@@ -262,6 +226,69 @@ class ArtworkService
return $query->cursorPaginate($perPage);
}
/**
* Resolve a category path within a content type using one category query.
*
* @param array<int, string> $slugs
* @throws ModelNotFoundException
*/
public function resolveCategoryByPath(array $slugs): Category
{
if (empty($slugs)) {
$e = new ModelNotFoundException();
$e->setModel(Category::class);
throw $e;
}
$parts = array_values(array_map('strtolower', $slugs));
$contentTypeSlug = array_shift($parts);
$contentType = $this->resolveContentTypeOrFail((string) $contentTypeSlug);
if (empty($parts)) {
$e = new ModelNotFoundException();
$e->setModel(Category::class, []);
throw $e;
}
$categories = Category::query()
->where('content_type_id', $contentType->id)
->get();
$categoriesByParent = [];
foreach ($categories as $category) {
$parentId = $category->parent_id !== null ? (int) $category->parent_id : 0;
$categoriesByParent[$parentId][strtolower((string) $category->slug)] = $category;
$category->setRelation('contentType', $contentType);
}
$current = null;
$parentId = 0;
foreach ($parts as $slug) {
$next = $categoriesByParent[$parentId][$slug] ?? null;
if (! $next instanceof Category) {
$e = new ModelNotFoundException();
$e->setModel(Category::class, $slugs);
throw $e;
}
if ($current instanceof Category) {
$next->setRelation('parent', $current);
}
$current = $next;
$parentId = (int) $current->id;
}
if (! $current instanceof Category) {
$e = new ModelNotFoundException();
$e->setModel(Category::class, $slugs);
throw $e;
}
return $current;
}
/**
* Collect category id plus all descendant category ids.
*

View File

@@ -6,6 +6,7 @@ namespace App\Services\Artworks;
use App\Models\ActivityEvent;
use App\Models\Artwork;
use App\Jobs\IndexArtworkJob;
use App\Services\Activity\UserActivityService;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
@@ -131,15 +132,28 @@ class ArtworkPublicationService
private function syncSearch(Artwork $artwork): void
{
if (! method_exists($artwork, 'searchable')) {
$artworkId = (int) $artwork->id;
$sync = function () use ($artworkId): void {
try {
IndexArtworkJob::dispatchSync($artworkId);
} catch (\Throwable $exception) {
Log::error('ArtworkPublicationService immediate Meilisearch sync failed; queueing fallback job.', [
'artwork_id' => $artworkId,
'error' => $exception->getMessage(),
]);
IndexArtworkJob::dispatch($artworkId);
}
};
if (DB::transactionLevel() > 0) {
DB::afterCommit($sync);
return;
}
try {
$artwork->searchable();
} catch (\Throwable $e) {
Log::warning("PublishScheduled: scout reindex failed for #{$artwork->id}: {$e->getMessage()}");
}
$sync();
}
private function recordActivity(Artwork $artwork): void

View File

@@ -0,0 +1,211 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Category;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
final class CategoryDirectoryService
{
public function getDirectoryPayload(string $search = '', string $sort = 'popular', int $page = 1, int $perPage = 24): array
{
$search = trim($search);
$sort = $this->normalizeSort($sort);
$page = max(1, $page);
$perPage = min(60, max(12, $perPage));
$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 [
'data' => $pageItems->all(),
'meta' => [
'current_page' => $currentPage,
'last_page' => $lastPage,
'per_page' => $perPage,
'total' => $total,
],
'summary' => [
'total_categories' => $categories->count(),
'total_artworks' => $categories->sum(static fn (array $category): int => (int) ($category['artwork_count'] ?? 0)),
],
'popular_categories' => $search === '' ? $popularCategories->all() : [],
'request' => [
'query' => $search,
'sort' => $sort,
'page' => $currentPage,
'per_page' => $perPage,
],
];
}
private function normalizeSort(string $sort): string
{
return in_array($sort, ['popular', 'az', 'artworks'], true) ? $sort : 'popular';
}
/**
* @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(static function (array $category) use ($needle): bool {
return str_contains(mb_strtolower((string) ($category['name'] ?? '')), $needle);
});
}
return $filtered->sort(static 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(static 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();
}
}

View File

@@ -149,7 +149,7 @@ final class CommunityActivityService
$query
->select('id', 'name', 'username', 'role', 'is_active', 'created_at')
->with('profile:user_id,avatar_hash')
->withCount('artworks');
->with('statistics:user_id,uploads_count');
},
])
->whereHas('actor', fn ($query) => $query->where('is_active', true)->whereNull('deleted_at'))
@@ -210,7 +210,7 @@ final class CommunityActivityService
: User::query()
->select('id', 'name', 'username', 'role', 'is_active', 'created_at')
->with('profile:user_id,avatar_hash')
->withCount('artworks')
->with('statistics:user_id,uploads_count')
->whereIn('id', $targetUserIds)
->where('is_active', true)
->whereNull('deleted_at')
@@ -242,7 +242,7 @@ final class CommunityActivityService
$query
->select('id', 'name', 'username', 'role', 'is_active', 'created_at')
->with('profile:user_id,avatar_hash')
->withCount('artworks');
->with('statistics:user_id,uploads_count');
},
'artwork' => function ($query) {
$query->select('id', 'user_id', 'title', 'slug', 'hash', 'thumb_ext', 'published_at', 'deleted_at', 'is_public', 'is_approved');
@@ -271,7 +271,7 @@ final class CommunityActivityService
$query
->select('id', 'name', 'username', 'role', 'is_active', 'created_at')
->with('profile:user_id,avatar_hash')
->withCount('artworks');
->with('statistics:user_id,uploads_count');
},
'comment' => function ($query) {
$query
@@ -281,7 +281,7 @@ final class CommunityActivityService
$userQuery
->select('id', 'name', 'username', 'role', 'is_active', 'created_at')
->with('profile:user_id,avatar_hash')
->withCount('artworks');
->with('statistics:user_id,uploads_count');
},
'artwork' => function ($artworkQuery) {
$artworkQuery->select('id', 'user_id', 'title', 'slug', 'hash', 'thumb_ext', 'published_at', 'deleted_at', 'is_public', 'is_approved');
@@ -408,13 +408,13 @@ final class CommunityActivityService
$query
->select('id', 'name', 'username', 'role', 'is_active', 'created_at')
->with('profile:user_id,avatar_hash')
->withCount('artworks');
->with('statistics:user_id,uploads_count');
},
'mentionedUser' => function ($query) {
$query
->select('id', 'name', 'username', 'role', 'is_active', 'created_at')
->with('profile:user_id,avatar_hash')
->withCount('artworks');
->with('statistics:user_id,uploads_count');
},
'comment' => function ($query) {
$query
@@ -424,7 +424,7 @@ final class CommunityActivityService
$userQuery
->select('id', 'name', 'username', 'role', 'is_active', 'created_at')
->with('profile:user_id,avatar_hash')
->withCount('artworks');
->with('statistics:user_id,uploads_count');
},
'artwork' => function ($artworkQuery) {
$artworkQuery->select('id', 'user_id', 'title', 'slug', 'hash', 'thumb_ext', 'published_at', 'deleted_at', 'is_public', 'is_approved');
@@ -489,7 +489,7 @@ final class CommunityActivityService
return ['label' => 'Moderator', 'tone' => 'amber'];
}
if ((int) ($user->artworks_count ?? 0) > 0) {
if ((int) ($user->statistics?->uploads_count ?? 0) > 0) {
return ['label' => 'Creator', 'tone' => 'sky'];
}

View File

@@ -11,6 +11,7 @@ use App\Models\BlogPost;
use App\Services\ThumbnailPresenter;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
@@ -50,7 +51,6 @@ final class ErrorSuggestionService
return Cache::remember("error_suggestions.similar_tags.{$slug}.{$limit}", self::CACHE_TTL, function () use ($slug, $limit, $prefix) {
return Tag::query()
->withCount('artworks')
->where('slug', '!=', $slug)
->where(function ($q) use ($prefix, $slug) {
$q->where('slug', 'like', $prefix . '%')
@@ -70,7 +70,6 @@ final class ErrorSuggestionService
return Cache::remember("error_suggestions.tags.{$limit}", self::CACHE_TTL, function () use ($limit) {
return Tag::query()
->withCount('artworks')
->orderByDesc('artworks_count')
->limit($limit)
->get(['id', 'name', 'slug', 'artworks_count']);
@@ -84,14 +83,17 @@ final class ErrorSuggestionService
$limit = min($limit, 6);
return Cache::remember("error_suggestions.creators.{$limit}", self::CACHE_TTL, function () use ($limit) {
return User::query()
->with('profile')
->withCount(['artworks' => fn ($q) => $q->public()->published()])
->having('artworks_count', '>', 0)
->orderByDesc('artworks_count')
return DB::table('users as u')
->join('user_statistics as us', 'us.user_id', '=', 'u.id')
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
->select('u.id', 'u.name', 'u.username', 'up.avatar_hash', DB::raw('us.uploads_count as artworks_count'))
->where('u.is_active', true)
->whereNull('u.deleted_at')
->where('us.uploads_count', '>', 0)
->orderByDesc('us.uploads_count')
->limit($limit)
->get(['users.id', 'users.name', 'users.username'])
->map(fn (User $u) => $this->creatorCard($u, $u->artworks_count));
->get()
->map(fn ($u) => $this->creatorCardFromRow($u));
});
}
@@ -102,14 +104,17 @@ final class ErrorSuggestionService
$limit = min($limit, 6);
return Cache::remember("error_suggestions.creators.recent.{$limit}", self::CACHE_TTL, function () use ($limit) {
return User::query()
->with('profile')
->withCount(['artworks' => fn ($q) => $q->public()->published()])
->having('artworks_count', '>', 0)
->orderByDesc('users.id')
return DB::table('users as u')
->join('user_statistics as us', 'us.user_id', '=', 'u.id')
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
->select('u.id', 'u.name', 'u.username', 'up.avatar_hash', DB::raw('us.uploads_count as artworks_count'))
->where('u.is_active', true)
->whereNull('u.deleted_at')
->where('us.uploads_count', '>', 0)
->orderByDesc('u.id')
->limit($limit)
->get(['users.id', 'users.name', 'users.username'])
->map(fn (User $u) => $this->creatorCard($u, $u->artworks_count));
->get()
->map(fn ($u) => $this->creatorCardFromRow($u));
});
}
@@ -166,4 +171,20 @@ final class ErrorSuggestionService
'artworks_count' => $artworksCount,
];
}
private function creatorCardFromRow(object $u): array
{
return [
'id' => (int) $u->id,
'name' => $u->name ?: $u->username,
'username' => $u->username,
'url' => '/@' . $u->username,
'avatar_url' => \App\Support\AvatarUrl::forUser(
(int) $u->id,
$u->avatar_hash ?? null,
64
),
'artworks_count' => (int) ($u->artworks_count ?? 0),
];
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Services;
use App\Models\Artwork;
use App\Models\Category;
use App\Models\Group;
use App\Jobs\IndexArtworkJob;
use App\Models\Tag;
use App\Models\User;
use Illuminate\Pagination\LengthAwarePaginator;
@@ -392,17 +393,6 @@ class GroupArtworkReviewService
private function syncSearchIndex(Artwork $artwork): void
{
try {
if ((bool) $artwork->is_public && (bool) $artwork->is_approved && ! empty($artwork->published_at)) {
$artwork->searchable();
} else {
$artwork->unsearchable();
}
} catch (\Throwable $exception) {
Log::warning('Failed to sync artwork search index for group review workflow', [
'artwork_id' => (int) $artwork->id,
'error' => $exception->getMessage(),
]);
}
IndexArtworkJob::dispatch((int) $artwork->id);
}
}

View File

@@ -23,6 +23,7 @@ class GroupCardService
{
$owner = $group->relationLoaded('owner') ? $group->owner : $group->owner()->with('profile')->first();
$recruitment = $this->recruitment->payloadForGroup($group);
$viewerRole = $viewer ? $group->activeRoleFor($viewer) : null;
$canManage = $viewer ? $group->canManage($viewer) : false;
$canManageMembers = $viewer ? $group->canManageMembers($viewer) : false;
$canPublishArtworks = $viewer ? $group->canPublishArtworks($viewer) : false;
@@ -117,9 +118,13 @@ class GroupCardService
$badges,
))),
'viewer' => [
'role' => $viewer ? $group->activeRoleFor($viewer) : null,
'role_label' => $viewer ? Group::displayRole($group->activeRoleFor($viewer)) : null,
'is_following' => $viewer ? $this->follows->isFollowing($group, $viewer) : false,
'role' => $viewerRole,
'role_label' => $viewerRole ? Group::displayRole($viewerRole) : null,
'is_following' => $viewer
? (array_key_exists('viewer_is_following', $group->getAttributes())
? (bool) $group->viewer_is_following
: $this->follows->isFollowing($group, $viewer))
: false,
'permission_overrides' => $viewer ? $group->permissionOverridesFor($viewer) : [],
],
'urls' => [

View File

@@ -8,9 +8,12 @@ use App\Models\Artwork;
use App\Models\Group;
use App\Models\GroupChallenge;
use App\Models\GroupChallengeArtwork;
use App\Models\GroupChallengeOutcome;
use App\Models\User;
use App\Support\ThumbnailPresenter;
use App\Services\ThumbnailPresenter;
use App\Services\Worlds\WorldRewardService;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
@@ -22,6 +25,7 @@ class GroupChallengeService
private readonly GroupActivityService $activity,
private readonly GroupMediaService $media,
private readonly NotificationService $notifications,
private readonly WorldRewardService $worldRewards,
) {
}
@@ -83,22 +87,29 @@ class GroupChallengeService
$challenge->visibility === GroupChallenge::VISIBILITY_PUBLIC ? 'public' : 'internal',
);
return $challenge->fresh(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile']);
return $challenge->fresh(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'outcomes.artwork.user.profile']);
}
public function update(GroupChallenge $challenge, User $actor, array $attributes): GroupChallenge
{
$coverPath = null;
$oldCoverPath = $challenge->cover_path;
$before = $challenge->only(['title', 'summary', 'description', 'visibility', 'participation_scope', 'status', 'rules_text', 'submission_instructions', 'judging_mode', 'linked_collection_id', 'linked_project_id', 'featured_artwork_id']);
$before = [
...$challenge->only(['title', 'summary', 'description', 'visibility', 'participation_scope', 'status', 'rules_text', 'submission_instructions', 'judging_mode', 'linked_collection_id', 'linked_project_id', 'featured_artwork_id']),
'outcomes_count' => $challenge->outcomes()->count(),
];
try {
DB::transaction(function () use ($challenge, $attributes, &$coverPath): void {
DB::transaction(function () use ($challenge, $actor, $attributes, &$coverPath): void {
if (($attributes['cover_file'] ?? null) instanceof UploadedFile) {
$coverPath = $this->media->storeUploadedEntityImage($challenge->group, $attributes['cover_file'], 'challenges');
}
$title = trim((string) ($attributes['title'] ?? $challenge->title));
$featuredArtworkId = array_key_exists('featured_artwork_id', $attributes)
? $this->normalizeArtworkId($challenge->group, $attributes['featured_artwork_id'])
: $challenge->featured_artwork_id;
$challenge->fill([
'title' => $title,
'slug' => $title !== $challenge->title ? $this->makeUniqueSlug($title, (int) $challenge->id) : $challenge->slug,
@@ -115,8 +126,18 @@ class GroupChallengeService
'judging_mode' => array_key_exists('judging_mode', $attributes) ? $this->nullableString($attributes['judging_mode']) : $challenge->judging_mode,
'linked_collection_id' => array_key_exists('linked_collection_id', $attributes) ? $this->normalizeCollectionId($challenge->group, $attributes['linked_collection_id']) : $challenge->linked_collection_id,
'linked_project_id' => array_key_exists('linked_project_id', $attributes) ? $this->normalizeProjectId($challenge->group, $attributes['linked_project_id']) : $challenge->linked_project_id,
'featured_artwork_id' => array_key_exists('featured_artwork_id', $attributes) ? $this->normalizeArtworkId($challenge->group, $attributes['featured_artwork_id']) : $challenge->featured_artwork_id,
'featured_artwork_id' => $featuredArtworkId,
])->save();
if (array_key_exists('outcomes', $attributes)) {
$canonicalWinnerArtworkId = $this->syncOutcomes($challenge, $actor, (array) ($attributes['outcomes'] ?? []), $featuredArtworkId);
if ((int) ($challenge->featured_artwork_id ?? 0) !== (int) ($canonicalWinnerArtworkId ?? 0)) {
$challenge->forceFill([
'featured_artwork_id' => $canonicalWinnerArtworkId,
])->save();
}
}
});
} catch (\Throwable $exception) {
$this->media->deleteIfManaged($coverPath);
@@ -137,10 +158,15 @@ class GroupChallengeService
'group_challenge',
(int) $challenge->id,
$before,
$challenge->only(['title', 'summary', 'description', 'visibility', 'participation_scope', 'status', 'rules_text', 'submission_instructions', 'judging_mode', 'linked_collection_id', 'linked_project_id', 'featured_artwork_id'])
[
...$challenge->only(['title', 'summary', 'description', 'visibility', 'participation_scope', 'status', 'rules_text', 'submission_instructions', 'judging_mode', 'linked_collection_id', 'linked_project_id', 'featured_artwork_id']),
'outcomes_count' => $challenge->outcomes()->count(),
]
);
return $challenge->fresh(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'artworks.primaryAuthor.profile']);
$this->worldRewards->syncLinkedChallengeRewardsForChallenge($challenge);
return $challenge->fresh(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'artworks.primaryAuthor.profile', 'outcomes.artwork.user.profile']);
}
public function publish(GroupChallenge $challenge, User $actor): GroupChallenge
@@ -191,7 +217,9 @@ class GroupChallengeService
}
}
return $challenge->fresh(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'artworks.primaryAuthor.profile']);
$this->worldRewards->syncLinkedChallengeRewardsForChallenge($challenge);
return $challenge->fresh(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'artworks.primaryAuthor.profile', 'outcomes.artwork.user.profile']);
}
public function attachArtwork(GroupChallenge $challenge, Artwork $artwork, User $actor): GroupChallenge
@@ -224,7 +252,9 @@ class GroupChallengeService
['artwork_id' => (int) $artwork->id]
);
return $challenge->fresh(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'artworks.primaryAuthor.profile']);
$this->worldRewards->syncLinkedChallengeRewardsForChallenge($challenge);
return $challenge->fresh(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'artworks.primaryAuthor.profile', 'outcomes.artwork.user.profile']);
}
public function publicListing(Group $group, ?User $viewer = null, int $limit = 12): array
@@ -289,16 +319,18 @@ class GroupChallengeService
public function detailPayload(GroupChallenge $challenge, ?User $viewer = null): array
{
$challenge->loadMissing(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'artworks.primaryAuthor.profile']);
$challenge->loadMissing(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'artworks.primaryAuthor.profile', 'outcomes.artwork.user.profile']);
$primaryWinnerArtwork = $this->primaryWinnerArtwork($challenge) ?? $challenge->featuredArtwork;
return array_merge($this->mapPublicChallenge($challenge), [
'description' => $challenge->description,
'rules_text' => $challenge->rules_text,
'submission_instructions' => $challenge->submission_instructions,
'featured_artwork' => $challenge->featuredArtwork ? [
'id' => (int) $challenge->featuredArtwork->id,
'title' => $challenge->featuredArtwork->title,
'url' => route('art.show', ['id' => $challenge->featuredArtwork->id, 'slug' => $challenge->featuredArtwork->slug ?: $challenge->featuredArtwork->id]),
'featured_artwork' => $primaryWinnerArtwork ? [
'id' => (int) $primaryWinnerArtwork->id,
'title' => $primaryWinnerArtwork->title,
'url' => route('art.show', ['id' => $primaryWinnerArtwork->id, 'slug' => $primaryWinnerArtwork->slug ?: $primaryWinnerArtwork->id]),
] : null,
'artworks' => $challenge->artworks->map(fn (Artwork $artwork): array => [
'id' => (int) $artwork->id,
@@ -306,11 +338,16 @@ class GroupChallengeService
'thumb' => ThumbnailPresenter::present($artwork, 'md')['url'] ?? $artwork->thumbUrl('md'),
'url' => route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug ?: $artwork->id]),
])->values()->all(),
'outcomes' => $challenge->outcomes->map(fn (GroupChallengeOutcome $outcome): array => $this->mapOutcomeForEditor($outcome))->values()->all(),
'outcome_sections' => $this->outcomeSectionsPayload($challenge),
'outcome_counts' => $this->outcomeCounts($challenge),
]);
}
public function mapPublicChallenge(GroupChallenge $challenge): array
{
$challenge->loadMissing(['group', 'outcomes']);
return [
'id' => (int) $challenge->id,
'title' => (string) $challenge->title,
@@ -324,6 +361,7 @@ class GroupChallengeService
'end_at' => $challenge->end_at?->toISOString(),
'rules_text' => $challenge->rules_text,
'entry_count' => (int) $challenge->artworkLinks()->count(),
'outcome_counts' => $this->outcomeCounts($challenge),
'url' => route('groups.challenges.show', ['group' => $challenge->group, 'challenge' => $challenge]),
];
}
@@ -363,6 +401,196 @@ class GroupChallengeService
return $challenge->group->hasActiveMember($actor) && (int) $artwork->group_id === (int) $challenge->group_id;
}
private function syncOutcomes(GroupChallenge $challenge, User $actor, array $rows, ?int $fallbackFeaturedArtworkId = null): ?int
{
$normalized = collect($rows)
->values()
->map(function (mixed $row, int $index): ?array {
if (! is_array($row)) {
return null;
}
$artworkId = (int) ($row['artwork_id'] ?? 0);
$outcomeType = trim((string) ($row['outcome_type'] ?? ''));
if ($artworkId < 1 || $outcomeType === '') {
return null;
}
return [
'artwork_id' => $artworkId,
'outcome_type' => $outcomeType,
'position' => isset($row['position']) && (int) $row['position'] > 0 ? (int) $row['position'] : null,
'sort_order' => max(0, (int) ($row['sort_order'] ?? $index)),
'title_override' => $this->nullableString($row['title_override'] ?? null),
'note' => $this->nullableString($row['note'] ?? null),
];
})
->filter()
->values();
$pairs = $normalized
->map(fn (array $row): string => $row['artwork_id'] . '|' . $row['outcome_type']);
if ($pairs->count() !== $pairs->unique()->count()) {
throw ValidationException::withMessages([
'outcomes' => 'Each artwork can only receive a given outcome type once per challenge.',
]);
}
$artworkIds = $normalized->pluck('artwork_id')->unique()->values();
$validArtworkIds = $artworkIds->isEmpty()
? collect()
: $challenge->artworkLinks()
->whereIn('artwork_id', $artworkIds->all())
->pluck('artwork_id')
->map(fn ($id): int => (int) $id)
->values();
if ($artworkIds->diff($validArtworkIds)->isNotEmpty()) {
throw ValidationException::withMessages([
'outcomes' => 'Challenge outcomes can only reference artworks already attached as challenge entries.',
]);
}
$artworksById = $artworkIds->isEmpty()
? collect()
: Artwork::query()
->whereIn('id', $artworkIds->all())
->get(['id', 'user_id'])
->keyBy('id');
GroupChallengeOutcome::query()
->where('group_challenge_id', (int) $challenge->id)
->delete();
if ($normalized->isEmpty()) {
return $fallbackFeaturedArtworkId;
}
$challenge->outcomes()->createMany($normalized->map(function (array $row) use ($actor, $artworksById): array {
/** @var Artwork|null $artwork */
$artwork = $artworksById->get($row['artwork_id']);
return [
'artwork_id' => $row['artwork_id'],
'user_id' => (int) ($artwork?->user_id ?? 0) > 0 ? (int) $artwork->user_id : null,
'outcome_type' => $row['outcome_type'],
'position' => $row['position'],
'sort_order' => $row['sort_order'],
'title_override' => $row['title_override'],
'note' => $row['note'],
'awarded_by_user_id' => (int) $actor->id,
'awarded_at' => now(),
];
})->all());
$winner = $normalized
->sortBy([
fn (array $row): int => $row['outcome_type'] === GroupChallengeOutcome::TYPE_WINNER ? 0 : 1,
fn (array $row): int => (int) $row['sort_order'],
fn (array $row): int => (int) ($row['position'] ?? PHP_INT_MAX),
])
->first(fn (array $row): bool => $row['outcome_type'] === GroupChallengeOutcome::TYPE_WINNER);
return $winner['artwork_id'] ?? $fallbackFeaturedArtworkId;
}
private function primaryWinnerArtwork(GroupChallenge $challenge): ?Artwork
{
/** @var GroupChallengeOutcome|null $winner */
$winner = $challenge->outcomes
->first(fn (GroupChallengeOutcome $outcome): bool => $outcome->outcome_type === GroupChallengeOutcome::TYPE_WINNER && $outcome->artwork !== null);
return $winner?->artwork;
}
private function outcomeCounts(GroupChallenge $challenge): array
{
$challenge->loadMissing('outcomes');
return collect(GroupChallengeOutcome::supportedTypes())
->mapWithKeys(fn (string $type): array => [$type => $challenge->outcomes->where('outcome_type', $type)->count()])
->all();
}
private function outcomeSectionsPayload(GroupChallenge $challenge): array
{
$challenge->loadMissing(['outcomes.artwork.user.profile']);
$sections = [];
foreach (GroupChallengeOutcome::supportedTypes() as $type) {
$items = $challenge->outcomes
->where('outcome_type', $type)
->values();
if ($items->isEmpty()) {
continue;
}
$sections[$type] = [
'type' => $type,
'label' => $this->outcomeSectionLabel($type, $items->count()),
'items' => $items->map(fn (GroupChallengeOutcome $outcome): array => $this->mapOutcomeItem($outcome))->all(),
];
}
return $sections;
}
private function outcomeSectionLabel(string $type, int $count): string
{
return match ($type) {
GroupChallengeOutcome::TYPE_WINNER => $count === 1 ? 'Winner' : 'Winners',
GroupChallengeOutcome::TYPE_FINALIST => 'Finalists',
GroupChallengeOutcome::TYPE_RUNNER_UP => $count === 1 ? 'Runner-up' : 'Runner-up',
GroupChallengeOutcome::TYPE_HONORABLE_MENTION => 'Honorable Mentions',
GroupChallengeOutcome::TYPE_FEATURED => 'Featured Entries',
default => GroupChallengeOutcome::labelForType($type),
};
}
private function mapOutcomeForEditor(GroupChallengeOutcome $outcome): array
{
return [
'id' => (int) $outcome->id,
'artwork_id' => (int) $outcome->artwork_id,
'outcome_type' => (string) $outcome->outcome_type,
'position' => $outcome->position,
'sort_order' => (int) $outcome->sort_order,
'title_override' => (string) ($outcome->title_override ?? ''),
'note' => (string) ($outcome->note ?? ''),
'artwork_title' => (string) ($outcome->artwork?->title ?? ''),
];
}
private function mapOutcomeItem(GroupChallengeOutcome $outcome): array
{
$artwork = $outcome->artwork;
$creator = $artwork?->user;
$statusLabel = $outcome->title_override ?: GroupChallengeOutcome::labelForType((string) $outcome->outcome_type);
return [
'id' => (int) $outcome->id,
'artwork_id' => (int) ($artwork?->id ?? 0),
'outcome_type' => (string) $outcome->outcome_type,
'position' => $outcome->position,
'title' => (string) ($artwork?->title ?: 'Untitled artwork'),
'subtitle' => (string) ($creator?->name ?: $creator?->username ?: ''),
'description' => (string) ($outcome->note ?: Str::limit(trim(strip_tags((string) ($artwork?->description ?? ''))), 140)),
'url' => $artwork ? route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug ?: $artwork->id]) : null,
'image' => $artwork ? (ThumbnailPresenter::present($artwork, 'md')['url'] ?? $artwork->thumbUrl('md')) : null,
'status' => (string) $outcome->outcome_type,
'status_label' => $statusLabel,
'context_label' => 'Challenge outcome',
'meta' => array_values(array_filter([
$outcome->position ? 'Place ' . $outcome->position : null,
$outcome->awarded_at?->format('M j, Y'),
])),
];
}
private function makeUniqueSlug(string $source, ?int $ignoreId = null): string
{
$base = Str::slug(Str::limit($source, 150, '')) ?: 'challenge';

View File

@@ -69,7 +69,7 @@ class GroupDiscoveryService
public function publicListing(?User $viewer, string $surface = 'featured', int $page = 1, int $perPage = 24): LengthAwarePaginator
{
$groups = $this->publicGroupBaseQuery()->get();
$groups = $this->publicGroupBaseQuery($viewer)->get();
$sorted = $this->sortGroups($groups, $surface);
$page = max(1, $page);
@@ -89,7 +89,7 @@ class GroupDiscoveryService
public function surfaceCards(?User $viewer = null, string $surface = 'featured', int $limit = 6): array
{
return $this->sortGroups($this->publicGroupBaseQuery()->get(), $surface)
return $this->sortGroups($this->publicGroupBaseQuery($viewer)->get(), $surface)
->take(max(1, $limit))
->map(fn (Group $group): array => $this->cards->mapGroupCard($group, $viewer))
->values()
@@ -104,7 +104,7 @@ class GroupDiscoveryService
return [];
}
$groups = $this->publicGroupBaseQuery()
$groups = $this->publicGroupBaseQuery($viewer)
->where(function (Builder $builder) use ($normalized): void {
$builder->whereRaw('LOWER(name) LIKE ?', ['%' . $normalized . '%'])
->orWhereRaw('LOWER(slug) LIKE ?', ['%' . $normalized . '%'])
@@ -191,9 +191,9 @@ class GroupDiscoveryService
];
}
private function publicGroupBaseQuery(): Builder
private function publicGroupBaseQuery(?User $viewer = null): Builder
{
return Group::query()
$query = Group::query()
->with(['owner.profile', 'recruitmentProfile', 'discoveryMetric', 'members', 'badges'])
->withCount([
'members as active_members_count' => fn (Builder $query) => $query->where('status', Group::STATUS_ACTIVE),
@@ -203,7 +203,9 @@ class GroupDiscoveryService
'releases as recent_public_releases_count' => fn (Builder $query) => $query
->where('visibility', GroupRelease::VISIBILITY_PUBLIC)
->where('status', GroupRelease::STATUS_RELEASED)
->where('released_at', '>=', now()->subDays(60)),
->where('released_at', '>=', now()->subDays(45)),
'artworks as approved_group_artworks_count' => fn (Builder $query) => $query
->where('group_review_status', 'approved'),
'projects as public_projects_count' => fn (Builder $query) => $query
->where('visibility', GroupProject::VISIBILITY_PUBLIC)
->whereIn('status', [GroupProject::STATUS_ACTIVE, GroupProject::STATUS_REVIEW, GroupProject::STATUS_RELEASED]),
@@ -225,6 +227,14 @@ class GroupDiscoveryService
->where('status', GroupRelease::STATUS_RELEASED),
], 'released_at')
->public();
if ($viewer) {
$query->withExists([
'follows as viewer_is_following' => fn (Builder $followQuery) => $followQuery->where('user_id', $viewer->id),
]);
}
return $query;
}
private function sortGroups(Collection $groups, string $surface): Collection

View File

@@ -84,16 +84,29 @@ class GroupReputationService
public function trustSignals(Group $group): array
{
$releaseCount = (int) $group->releases()->where('status', GroupRelease::STATUS_RELEASED)->count();
$recentReleaseCount = (int) $group->releases()
->where('status', GroupRelease::STATUS_RELEASED)
->where('released_at', '>=', now()->subDays(45))
->count();
$activeMembers = (int) $group->members()->where('status', Group::STATUS_ACTIVE)->count() + 1;
$approvedArtworks = (int) Artwork::query()
->where('group_id', $group->id)
->where('group_review_status', 'approved')
->count();
$releaseCount = isset($group->public_releases_count)
? (int) $group->public_releases_count
: (int) $group->releases()->where('status', GroupRelease::STATUS_RELEASED)->count();
$recentReleaseCount = isset($group->recent_public_releases_count)
? (int) $group->recent_public_releases_count
: (int) $group->releases()
->where('status', GroupRelease::STATUS_RELEASED)
->where('released_at', '>=', now()->subDays(45))
->count();
$activeMembers = (isset($group->active_members_count)
? (int) $group->active_members_count
: ($group->relationLoaded('members')
? (int) $group->members->where('status', Group::STATUS_ACTIVE)->count()
: (int) $group->members()->where('status', Group::STATUS_ACTIVE)->count())) + 1;
$approvedArtworks = isset($group->approved_group_artworks_count)
? (int) $group->approved_group_artworks_count
: (int) Artwork::query()
->where('group_id', $group->id)
->where('group_review_status', 'approved')
->count();
$signals = [];
@@ -165,10 +178,15 @@ class GroupReputationService
public function groupBadges(Group $group, int $limit = 6): array
{
return $group->badges()
->latest('awarded_at')
->limit(max(1, min(24, $limit)))
->get()
$badges = $group->relationLoaded('badges')
? $group->badges->sortByDesc(fn (GroupBadge $badge) => $badge->awarded_at?->getTimestamp() ?? 0)
->take(max(1, min(24, $limit)))
: $group->badges()
->latest('awarded_at')
->limit(max(1, min(24, $limit)))
->get();
return $badges
->map(fn (GroupBadge $badge): array => [
'key' => (string) $badge->badge_key,
'label' => $this->badgeLabel('group', (string) $badge->badge_key),
@@ -382,16 +400,40 @@ class GroupReputationService
private function awardMemberBadges(Group $group): void
{
$stats = GroupContributorStat::query()->where('group_id', $group->id)->get();
$userIds = $stats->pluck('user_id')->map(static fn ($id): int => (int) $id)->unique()->values();
$projectLeadIds = GroupProject::query()
->where('group_id', $group->id)
->whereIn('lead_user_id', $userIds)
->pluck('lead_user_id')
->map(static fn ($id): int => (int) $id)
->flip();
$assetCounts = $group->assets()
->selectRaw('uploaded_by_user_id, COUNT(*) as aggregate')
->whereIn('uploaded_by_user_id', $userIds)
->groupBy('uploaded_by_user_id')
->pluck('aggregate', 'uploaded_by_user_id');
$foundingMemberIds = GroupMember::query()
->where('group_id', $group->id)
->whereIn('user_id', $userIds)
->when($group->created_at, fn ($query) => $query->where('accepted_at', '<=', $group->created_at->copy()->addDays(30)))
->pluck('user_id')
->map(static fn ($id): int => (int) $id)
->flip();
foreach ($stats as $stat) {
$userId = (int) $stat->user_id;
$this->awardMemberBadge($group, (int) $stat->user_id, 'first_group_contribution', (int) $stat->credited_artworks_count >= 1);
$this->awardMemberBadge($group, (int) $stat->user_id, 'ten_group_contributions', (int) $stat->credited_artworks_count >= 10);
$this->awardMemberBadge($group, (int) $stat->user_id, 'release_contributor', (int) $stat->release_count >= 1);
$this->awardMemberBadge($group, (int) $stat->user_id, 'project_lead', GroupProject::query()->where('group_id', $group->id)->where('lead_user_id', $stat->user_id)->exists());
$this->awardMemberBadge($group, (int) $stat->user_id, 'reliable_reviewer', (int) $stat->review_actions_count >= 5);
$this->awardMemberBadge($group, (int) $stat->user_id, 'long_term_collaborator', ((int) $stat->project_count + (int) $stat->release_count) >= 5);
$this->awardMemberBadge($group, (int) $stat->user_id, 'founding_member', $this->isFoundingMember($group, (int) $stat->user_id));
$this->awardMemberBadge($group, (int) $stat->user_id, 'asset_builder', $group->assets()->where('uploaded_by_user_id', $stat->user_id)->count() >= 3);
$this->awardMemberBadge($group, $userId, 'project_lead', $projectLeadIds->has($userId));
$this->awardMemberBadge($group, $userId, 'reliable_reviewer', (int) $stat->review_actions_count >= 5);
$this->awardMemberBadge($group, $userId, 'long_term_collaborator', ((int) $stat->project_count + (int) $stat->release_count) >= 5);
$this->awardMemberBadge($group, $userId, 'founding_member', (int) $group->owner_user_id === $userId || $foundingMemberIds->has($userId));
$this->awardMemberBadge($group, $userId, 'asset_builder', (int) ($assetCounts[$userId] ?? 0) >= 3);
}
}

View File

@@ -145,20 +145,32 @@ class NovaCardRenderService
private function paintOverlay($image, array $project, int $width, int $height): void
{
$style = (string) Arr::get($project, 'background.overlay_style', 'dark-soft');
$alpha = match ($style) {
'dark-strong' => 72,
'dark-soft' => 92,
'light-soft' => 108,
default => null,
};
if ($alpha === null) {
if ($style === 'none') {
return;
}
// Respect the opacity slider (0100 %) that the CSS preview applies to the overlay div.
$opacityPct = max(0, min(100, (int) Arr::get($project, 'background.opacity', 50)));
$scale = $opacityPct / 100.0;
// Top/bottom gradient stop opacities — matches overlayStyle() in NovaCardCanvasPreview.jsx:
// dark-soft: linear-gradient(180deg, rgba(2,6,23,0.18), rgba(2,6,23,0.48))
// dark-strong: linear-gradient(180deg, rgba(2,6,23,0.38), rgba(2,6,23,0.68))
// light-soft: linear-gradient(180deg, rgba(255,255,255,0.08), rgba(255,255,255,0.22))
[$topA, $botA] = match ($style) {
'dark-strong' => [0.38, 0.68],
'light-soft' => [0.08, 0.22],
default => [0.18, 0.48], // dark-soft
};
$rgb = $style === 'light-soft' ? [255, 255, 255] : [0, 0, 0];
$overlay = imagecolorallocatealpha($image, $rgb[0], $rgb[1], $rgb[2], $alpha);
imagefilledrectangle($image, 0, 0, $width, $height, $overlay);
// Draw a scanline gradient to match the CSS linear-gradient overlay.
for ($y = 0; $y < $height; $y++) {
$alpha = ($topA + ($botA - $topA) * ($y / $height)) * $scale;
$gdAlpha = max(0, min(127, (int) round((1.0 - $alpha) * 127)));
$color = imagecolorallocatealpha($image, $rgb[0], $rgb[1], $rgb[2], $gdAlpha);
imageline($image, 0, $y, $width - 1, $y, $color);
}
}
// ─── Text rendering (FreeType / GD fallback) ─────────────────────────────
@@ -167,10 +179,15 @@ class NovaCardRenderService
{
$fontPreset = (string) Arr::get($project, 'typography.font_preset', 'modern-sans');
$fontFile = $this->resolveFont($fontPreset);
$textColor = $this->allocateHex($image, (string) Arr::get($project, 'typography.text_color', '#ffffff'));
$accentColor = $this->allocateHex($image, (string) Arr::get($project, 'typography.accent_color', Arr::get($project, 'typography.text_color', '#ffffff')));
// Apply text_opacity (10100 %) to both text and accent colours, matching CSS blockStyle().
$textOpacityPct = max(10, min(100, (int) Arr::get($project, 'typography.text_opacity', 100)));
$textAlpha = (int) round((1.0 - $textOpacityPct / 100.0) * 127);
[$tr, $tg, $tb] = $this->hexToRgb((string) Arr::get($project, 'typography.text_color', '#ffffff'));
[$ar, $ag, $ab] = $this->hexToRgb((string) Arr::get($project, 'typography.accent_color', Arr::get($project, 'typography.text_color', '#ffffff')));
$textColor = imagecolorallocatealpha($image, $tr, $tg, $tb, $textAlpha);
$accentColor = imagecolorallocatealpha($image, $ar, $ag, $ab, $textAlpha);
$alignment = (string) Arr::get($project, 'layout.alignment', 'center');
$lhMulti = (float) Arr::get($project, 'typography.line_height', 1.35);
$lhMulti = (float) Arr::get($project, 'typography.line_height', 1.2);
$shadow = (string) Arr::get($project, 'typography.shadow_preset', 'soft');
$paddingRatio = match ((string) Arr::get($project, 'layout.padding', 'comfortable')) {
@@ -400,15 +417,22 @@ class NovaCardRenderService
foreach (array_slice($decorations, 0, (int) config('nova_cards.validation.max_decorations', 6)) as $index => $decoration) {
$glyph = (string) Arr::get($decoration, 'glyph', '•');
// pos_x / pos_y are stored as percentages (0100); fall back to sensible defaults.
// pos_x / pos_y are stored as percentages (0100); when absent, fall back to
// `placement` field — mirroring placementStyles in NovaCardCanvasPreview.jsx.
$xPct = Arr::get($decoration, 'pos_x');
$yPct = Arr::get($decoration, 'pos_y');
$x = $xPct !== null
? (int) round((float) $xPct / 100 * $width)
: (int) round(($index % 2 === 0 ? 0.12 : 0.82) * $width);
$y = $yPct !== null
? (int) round((float) $yPct / 100 * $height)
: (int) round((0.14 + ($index * 0.1)) * $height);
if ($xPct !== null && $yPct !== null) {
$x = (int) round((float) $xPct / 100 * $width);
$y = (int) round((float) $yPct / 100 * $height);
} else {
$placement = (string) Arr::get($decoration, 'placement', 'top-right');
$x = str_contains($placement, 'left') ? (int) round(0.12 * $width)
: (str_contains($placement, 'right') ? (int) round(0.88 * $width)
: (int) round(0.50 * $width));
$y = str_contains($placement, 'top') ? (int) round(0.12 * $height)
: (str_contains($placement, 'bottom') ? (int) round(0.88 * $height)
: (int) round(0.50 * $height));
}
// Canvas clamp: max(18, min(size, 64)) matching NovaCardCanvasPreview.
$rawSize = max(18, min((int) Arr::get($decoration, 'size', 28), 64));

View File

@@ -22,7 +22,10 @@ class PostFeedService
?int $viewerId,
int $page = 1,
): array {
$baseQuery = Post::with($this->eagerLoads())
$baseQuery = $this->applyViewerSaveState(
Post::with($this->eagerLoads()),
$viewerId,
)
->where('user_id', $profileUser->id)
->visibleTo($viewerId);
@@ -80,6 +83,8 @@ class PostFeedService
->visibleTo($viewer->id)
->orderByDesc('created_at');
$query = $this->applyViewerSaveState($query, $viewer->id);
if ($filter === 'shares') $query->where('type', Post::TYPE_ARTWORK_SHARE);
elseif ($filter === 'text') $query->where('type', Post::TYPE_TEXT);
elseif ($filter === 'uploads') $query->where('type', Post::TYPE_UPLOAD);
@@ -109,7 +114,10 @@ class PostFeedService
): array {
$tag = mb_strtolower($tag);
$paginated = Post::with($this->eagerLoads())
$paginated = $this->applyViewerSaveState(
Post::with($this->eagerLoads()),
$viewerId,
)
->whereHas('hashtags', fn ($q) => $q->where('tag', $tag))
->visibleTo($viewerId)
->orderByDesc('created_at')
@@ -132,7 +140,10 @@ class PostFeedService
public function getSavedFeed(User $viewer, int $page = 1): array
{
$paginated = Post::with($this->eagerLoads())
$paginated = $this->applyViewerSaveState(
Post::with($this->eagerLoads()),
$viewer->id,
)
->whereHas('saves', fn ($q) => $q->where('user_id', $viewer->id))
->where('status', Post::STATUS_PUBLISHED)
->orderByDesc('created_at')
@@ -174,6 +185,17 @@ class PostFeedService
return $this->eagerLoads();
}
private function applyViewerSaveState($query, ?int $viewerId)
{
if (! $viewerId) {
return $query;
}
return $query->withExists([
'saves as viewer_saved' => fn ($saveQuery) => $saveQuery->where('user_id', $viewerId),
]);
}
/**
* Penalize runs of 5+ posts from the same author by deferring them to the end.
*/
@@ -223,8 +245,9 @@ class PostFeedService
$viewerLiked = $viewerSaved = false;
if ($viewerId) {
$viewerLiked = $post->reactions->where('user_id', $viewerId)->where('reaction', 'like')->isNotEmpty();
// saves are lazy-loaded only when needed; check if relation is loaded
if ($post->relationLoaded('saves')) {
if (array_key_exists('viewer_saved', $post->getAttributes())) {
$viewerSaved = (bool) $post->getAttribute('viewer_saved');
} elseif ($post->relationLoaded('saves')) {
$viewerSaved = $post->saves->where('user_id', $viewerId)->isNotEmpty();
} else {
$viewerSaved = $post->saves()->where('user_id', $viewerId)->exists();

View File

@@ -52,6 +52,9 @@ class PostTrendingService
// Load posts preserving ranked order
$posts = Post::with($this->feedService->publicEagerLoads())
->withExists([
'saves as viewer_saved' => fn ($saveQuery) => $saveQuery->where('user_id', $viewerId),
])
->whereIn('id', $pageIds)
->get()
->keyBy('id');

View File

@@ -204,17 +204,22 @@ class UserPreferenceBuilder
return [];
}
// Sample recent artworks to avoid full scan
$rows = DB::table('artworks as a')
->join('artwork_tag as at', 'at.artwork_id', '=', 'a.id')
->join('tags as t', 't.id', '=', 'at.tag_id')
->whereIn('a.user_id', $creatorIds)
->where('a.is_public', true)
->where('a.is_approved', true)
->where('t.is_active', true)
->whereNull('a.deleted_at')
->orderByDesc('a.published_at')
// Sample the 500 most-recent artworks first (subquery), then count tags.
// ORDER BY must not appear on a non-aggregated column inside GROUP BY
// (MySQL only_full_group_by mode rejects it).
$recentIds = DB::table('artworks')
->whereIn('user_id', $creatorIds)
->where('is_public', true)
->where('is_approved', true)
->whereNull('deleted_at')
->orderByDesc('published_at')
->limit(500)
->select('id');
$rows = DB::table('artwork_tag as at')
->joinSub($recentIds, 'a', 'a.id', '=', 'at.artwork_id')
->join('tags as t', 't.id', '=', 'at.tag_id')
->where('t.is_active', true)
->selectRaw('t.slug, COUNT(*) as cnt')
->groupBy('t.id', 't.slug')
->get();

View File

@@ -35,10 +35,7 @@ abstract class AbstractIdShardableSitemapBuilder extends AbstractSitemapBuilder
public function lastModified(): ?DateTimeInterface
{
return $this->newest(...array_map(
fn (SitemapUrl $item): ?DateTimeInterface => $item->lastModified,
$this->items(),
));
return $this->dateTime((clone $this->query())->max($this->lastModifiedColumn()));
}
public function totalItems(): int
@@ -69,10 +66,16 @@ abstract class AbstractIdShardableSitemapBuilder extends AbstractSitemapBuilder
public function lastModifiedForShard(int $shard): ?DateTimeInterface
{
return $this->newest(...array_map(
fn (SitemapUrl $item): ?DateTimeInterface => $item->lastModified,
$this->itemsForShard($shard),
));
$window = $this->shardWindow($shard);
if ($window === null) {
return null;
}
return $this->dateTime(
$this->applyShardWindow($window['from'], $window['to'])
->max($this->lastModifiedColumn()),
);
}
/**
@@ -132,4 +135,9 @@ abstract class AbstractIdShardableSitemapBuilder extends AbstractSitemapBuilder
{
return $this->idColumn();
}
protected function lastModifiedColumn(): string
{
return 'updated_at';
}
}

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Services\Sitemaps;
use Illuminate\Support\Facades\Cache;
final class PublishedSitemapResolver
{
public function __construct(private readonly SitemapReleaseManager $releases)
@@ -23,9 +25,23 @@ final class PublishedSitemapResolver
*/
public function resolveNamed(string $requestedName): ?array
{
$manifest = $this->releases->activeManifest();
$releaseId = Cache::remember(
'sitemaps:active-release-id',
60,
fn (): ?string => $this->releases->activeReleaseId(),
);
if ($manifest === null) {
if (! is_string($releaseId) || $releaseId === '') {
return null;
}
$manifest = Cache::remember(
'sitemaps:manifest:' . $releaseId,
3600,
fn (): ?array => $this->releases->readManifest($releaseId),
);
if (! is_array($manifest)) {
return null;
}
@@ -36,13 +52,27 @@ final class PublishedSitemapResolver
private function resolveDocumentName(string $documentName): ?array
{
$releaseId = $this->releases->activeReleaseId();
$releaseId = Cache::remember(
'sitemaps:active-release-id',
60,
fn (): ?string => $this->releases->activeReleaseId(),
);
if ($releaseId === null) {
if (! is_string($releaseId) || $releaseId === '') {
return null;
}
$content = $this->releases->getDocument($releaseId, $documentName);
$ttl = max((int) config('sitemaps.cache_ttl_seconds', 900), 3600);
$cacheKey = 'sitemaps:doc:' . $releaseId . ':' . $documentName;
$content = Cache::get($cacheKey);
if (! is_string($content) || $content === '') {
$content = $this->releases->getDocument($releaseId, $documentName);
if (is_string($content) && $content !== '') {
Cache::put($cacheKey, $content, $ttl);
}
}
return is_string($content) && $content !== ''
? ['content' => $content, 'release_id' => $releaseId, 'document_name' => $documentName]

View File

@@ -13,6 +13,7 @@ final class SitemapPublishService
private readonly SitemapReleaseCleanupService $cleanup,
private readonly SitemapReleaseManager $releases,
private readonly SitemapReleaseValidator $validator,
private readonly SitemapStaticPublisher $staticPublisher,
) {
}
@@ -59,7 +60,12 @@ final class SitemapPublishService
$this->releases->activate($releaseId);
$deleted = $this->cleanup->cleanup();
return $manifest + ['cleanup_deleted' => $deleted];
$staticResult = [];
if ($this->staticPublisher->enabled()) {
$staticResult = $this->staticPublisher->publish($releaseId);
}
return $manifest + ['cleanup_deleted' => $deleted, 'static_published' => $staticResult];
});
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Services\Sitemaps;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
@@ -70,6 +71,8 @@ final class SitemapReleaseManager
];
$this->atomicJsonWrite($this->activePointerPath(), $payload);
Cache::forget('sitemaps:active-release-id');
}
public function activeReleaseId(): ?string

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps;
use Illuminate\Support\Facades\Storage;
/**
* Writes every document from a published release to the public disk so nginx
* can serve sitemap.xml and sitemaps/{name}.xml as plain static files,
* bypassing PHP entirely on subsequent requests.
*/
final class SitemapStaticPublisher
{
public function __construct(private readonly SitemapReleaseManager $releases)
{
}
public function enabled(): bool
{
return (bool) config('sitemaps.static_publish.enabled', true);
}
/**
* Copy all documents from the given release to the public disk.
*
* @return array{written: int, skipped: int}
*/
public function publish(string $releaseId): array
{
$manifest = $this->releases->readManifest($releaseId);
if ($manifest === null) {
return ['written' => 0, 'skipped' => 0];
}
$disk = Storage::disk($this->publicDisk());
$documents = (array) ($manifest['documents'] ?? []);
$written = 0;
$skipped = 0;
foreach ($documents as $documentName => $relativePath) {
$content = $this->releases->getDocument($releaseId, (string) $documentName);
if (! is_string($content) || $content === '') {
$skipped++;
continue;
}
$disk->put((string) $relativePath, $content);
$written++;
}
return ['written' => $written, 'skipped' => $skipped];
}
private function publicDisk(): string
{
return (string) config('sitemaps.static_publish.disk', 'sitemaps_public');
}
}

View File

@@ -332,7 +332,7 @@ class SmartCollectionService
match ($field) {
'tags' => $query->whereHas('tags', function (Builder $builder) use ($value): void {
$builder->where('tags.slug', (string) $value)
->orWhere('tags.name', 'like', '%' . (string) $value . '%');
->orWhere('tags.name', (string) $value);
}),
'category' => $query->whereHas('categories', function (Builder $builder) use ($value): void {
$builder->where('categories.slug', (string) $value)

View File

@@ -129,9 +129,12 @@ final class CreatorStudioCalendarService
$days[] = [
'date' => $key,
'day' => $date->day,
'label' => $date->format('D, M j'),
'is_current_month' => $date->month === $focusDate->month,
'count' => $items->count(),
'items' => $items->take(3)->all(),
'overflow_count' => max(0, $items->count() - 4),
'detail_items' => $items->all(),
'items' => $items->take(4)->all(),
];
}

View File

@@ -73,7 +73,8 @@ final class StudioArtworkQueryService
// Category filter
if (!empty($filters['category'])) {
$filterParts[] = 'category = "' . addslashes((string) $filters['category']) . '"';
$quoted = addslashes((string) $filters['category']);
$filterParts[] = '(category = "' . $quoted . '" OR categories = "' . $quoted . '")';
}
// Tag filter

Some files were not shown because too many files have changed in this diff Show More