Wire admin studio SSR and search infrastructure

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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