Wire admin studio SSR and search infrastructure
This commit is contained in:
184
app/Console/Commands/AuditArtworkDownloadFilesCommand.php
Normal file
184
app/Console/Commands/AuditArtworkDownloadFilesCommand.php
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
181
app/Console/Commands/ForceIndexArtworkCommand.php
Normal file
181
app/Console/Commands/ForceIndexArtworkCommand.php
Normal 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();
|
||||
}
|
||||
}
|
||||
133
app/Console/Commands/GenerateSitemapsCommand.php
Normal file
133
app/Console/Commands/GenerateSitemapsCommand.php
Normal 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)));
|
||||
}
|
||||
}
|
||||
217
app/Console/Commands/HashLegacyPlainPasswordsCommand.php
Normal file
217
app/Console/Commands/HashLegacyPlainPasswordsCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
77
app/Console/Commands/InspectArtworkOriginalCommand.php
Normal file
77
app/Console/Commands/InspectArtworkOriginalCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
305
app/Console/Commands/InspectArtworkSearchIndexCommand.php
Normal file
305
app/Console/Commands/InspectArtworkSearchIndexCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
279
app/Console/Commands/ReconcileArtworkSearchIndexCommand.php
Normal file
279
app/Console/Commands/ReconcileArtworkSearchIndexCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
40
app/Console/Commands/SendTestMail.php
Normal file
40
app/Console/Commands/SendTestMail.php
Normal 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;
|
||||
}
|
||||
}
|
||||
714
app/Console/Commands/ZipUnsupportedArtworkOriginalsCommand.php
Normal file
714
app/Console/Commands/ZipUnsupportedArtworkOriginalsCommand.php
Normal 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 ?? ''),
|
||||
]));
|
||||
}
|
||||
}
|
||||
@@ -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
47
app/Enums/UserRole.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
160
app/Http/Controllers/Admin/AdminController.php
Normal file
160
app/Http/Controllers/Admin/AdminController.php
Normal 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' => [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
*/
|
||||
|
||||
@@ -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 . ')';
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
116
app/Http/Controllers/Legacy/LegacyArtworkPhotoController.php
Normal file
116
app/Http/Controllers/Legacy/LegacyArtworkPhotoController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
231
app/Http/Controllers/Studio/StudioNewsMediaApiController.php
Normal file
231
app/Http/Controllers/Studio/StudioNewsMediaApiController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
113
app/Http/Controllers/Studio/StudioUploadQueueApiController.php
Normal file
113
app/Http/Controllers/Studio/StudioUploadQueueApiController.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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', '')));
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) . ')';
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, [
|
||||
|
||||
27
app/Http/Middleware/EnsureStaffAccess.php
Normal file
27
app/Http/Middleware/EnsureStaffAccess.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
24
app/Mail/TestMail.php
Normal 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>");
|
||||
}
|
||||
}
|
||||
@@ -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'] ?? '') : '')
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
|
||||
43
app/Models/UploadBatch.php
Normal file
43
app/Models/UploadBatch.php
Normal 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');
|
||||
}
|
||||
}
|
||||
66
app/Models/UploadBatchItem.php
Normal file
66
app/Models/UploadBatchItem.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
82
app/Services/ArtworkOriginalFileLocator.php
Normal file
82
app/Services/ArtworkOriginalFileLocator.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
211
app/Services/CategoryDirectoryService.php
Normal file
211
app/Services/CategoryDirectoryService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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' => [
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (0–100 %) 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 (10–100 %) 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 (0–100); fall back to sensible defaults.
|
||||
// pos_x / pos_y are stored as percentages (0–100); 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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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];
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
63
app/Services/Sitemaps/SitemapStaticPublisher.php
Normal file
63
app/Services/Sitemaps/SitemapStaticPublisher.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user