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 ?? ''),
|
||||
]));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user