Compare commits

..

32 Commits

Author SHA1 Message Date
5af95f6533 Optimize academy 2026-06-09 13:16:01 +02:00
f89ee937c0 fixed sanitazer and academy 2026-06-05 16:53:20 +02:00
15870ddb1f Allow heading tags (h1-h6) in ContentSanitizer so news editor headings render 2026-06-04 07:52:57 +02:00
0b33a1b074 Implement academy analytics, billing, and web stories updates 2026-05-26 07:27:29 +02:00
456c3d6bb0 Fix: remove Academy families from sitemaps; show total prompts in Academy prompt library; fix void closure in AcademyAdminController 2026-05-11 21:56:38 +02:00
ff96ef796e chore: commit remaining workspace changes 2026-05-08 21:51:29 +02:00
8d108b8a76 Homepage: add stable intro copy; mark footer utility links data-nosnippet; add render test 2026-05-08 21:51:28 +02:00
6b83d76cd1 SEO: gallery VisualArtwork contentUrl -> single best URL; update gallery unit test 2026-05-08 21:51:28 +02:00
0c5dde9b22 Featured artworks thumbnails 2026-05-06 19:11:31 +02:00
82f2b1f660 Add tests for featured thumbnail generation; apply Pint formatting and related edits 2026-05-06 18:55:40 +02:00
7a8bc8e22a Wire homepage hero to featured thumbnail family; add featured-picture component 2026-05-06 18:55:20 +02:00
8fa3adf4df Add job and artisan command for generating featured thumbnails 2026-05-06 18:55:08 +02:00
bd8a5c14a0 Add FeaturedArtworkThumbnailGenerator and FeaturedArtworkSelector 2026-05-06 18:54:57 +02:00
2c2c0f6722 Add Artwork model convenience methods for featured thumbnails 2026-05-06 18:54:31 +02:00
ee24111d59 Add featured thumbnail config and ArtworkFeaturedImagePath helper 2026-05-06 18:54:18 +02:00
a3cfc6c17f feat(academy): prepare AI Academy v1 for production enablement 2026-05-03 19:59:27 +02:00
90e93f0d42 Fixes 2026-05-03 09:21:13 +02:00
44354e5bea News: normalize category select values; fix Studio news editor category persistence; add CSV→SQL generator and news_dates.sql 2026-05-03 09:12:38 +02:00
a9dfa6ea11 Refine SEO, uploads, and deploy handling 2026-05-02 10:48:08 +02:00
b6be6ed2ac chore: commit regenerated ssr assets 2026-05-02 09:37:28 +02:00
caf1464aa5 chore: commit current workspace changes 2026-05-02 09:37:14 +02:00
79235133f0 Remove obsolete project SQL dump 2026-05-01 11:47:10 +02:00
396712bb3d Sync deploy mirror and upstream error page 2026-05-01 11:46:56 +02:00
18cea8b0f0 Wire admin studio SSR and search infrastructure 2026-05-01 11:46:06 +02:00
257b0dbef6 Build world campaigns rewards and recaps 2026-05-01 11:44:41 +02:00
28e7e46e13 Add news article comments and reactions 2026-05-01 11:43:49 +02:00
874f8feb9c Add homepage announcement module 2026-05-01 11:43:08 +02:00
961d21e91e Optimize anonymous public sessions 2026-05-01 11:42:10 +02:00
35011001ba Replace native selects with NovaSelect 2026-05-01 07:45:37 +02:00
67be537c86 new test files 2026-04-25 08:36:03 +02:00
19d5a9ed3e removed files 2026-04-25 08:35:52 +02:00
c8c7a4d100 cleanup project
Removed unused files
2026-04-25 08:25:04 +02:00
2479 changed files with 740630 additions and 1018954 deletions

View File

@@ -41,6 +41,14 @@ SESSION_ENCRYPT=false
SESSION_PATH=/ SESSION_PATH=/
SESSION_DOMAIN=null SESSION_DOMAIN=null
# Skinbase Nova conditional public sessions
SKINBASE_CONDITIONAL_SESSIONS_ENABLED=true
SKINBASE_SKIP_ANONYMOUS_PUBLIC_GET_SESSIONS=true
SKINBASE_SKIP_BOT_PUBLIC_GET_SESSIONS=true
# Debug only; do not enable permanently in production
SKINBASE_SESSION_DEBUG_HEADER=false
BROADCAST_CONNECTION=reverb BROADCAST_CONNECTION=reverb
FILESYSTEM_DISK=local FILESYSTEM_DISK=local
QUEUE_CONNECTION=redis QUEUE_CONNECTION=redis

View File

@@ -4,6 +4,7 @@ namespace App\Console\Commands;
use App\Models\Artwork; use App\Models\Artwork;
use App\Models\ActivityEvent; use App\Models\ActivityEvent;
use App\Jobs\IndexArtworkJob;
use App\Services\Activity\UserActivityService; use App\Services\Activity\UserActivityService;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -85,14 +86,8 @@ class PublishScheduledArtworksCommand extends Command
$artwork->artwork_status = 'published'; $artwork->artwork_status = 'published';
$artwork->save(); $artwork->save();
// Trigger Meilisearch reindex via Scout (if searchable trait present) // Trigger Meilisearch reindex directly — no Scout hop.
if (method_exists($artwork, 'searchable')) { IndexArtworkJob::dispatch((int) $artwork->id);
try {
$artwork->searchable();
} catch (\Throwable $e) {
Log::warning("PublishScheduled: scout reindex failed for #{$artwork->id}: {$e->getMessage()}");
}
}
// Record activity event // Record activity event
try { try {

View File

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

View File

@@ -11,7 +11,10 @@ use App\Http\Requests\Uploads\UploadChunkRequest;
use App\Http\Requests\Uploads\UploadCancelRequest; use App\Http\Requests\Uploads\UploadCancelRequest;
use App\Http\Requests\Uploads\UploadStatusRequest; use App\Http\Requests\Uploads\UploadStatusRequest;
use App\Jobs\GenerateDerivativesJob; use App\Jobs\GenerateDerivativesJob;
use App\Jobs\AnalyzeArtworkAiAssistJob;
use App\Jobs\IndexArtworkJob;
use App\Jobs\AutoTagArtworkJob; use App\Jobs\AutoTagArtworkJob;
use App\Jobs\DetectArtworkMaturityJob;
use App\Jobs\GenerateArtworkEmbeddingJob; use App\Jobs\GenerateArtworkEmbeddingJob;
use App\Repositories\Uploads\UploadSessionRepository; use App\Repositories\Uploads\UploadSessionRepository;
use App\Services\Uploads\UploadChunkService; use App\Services\Uploads\UploadChunkService;
@@ -19,6 +22,7 @@ use App\Services\Uploads\UploadCancelService;
use App\Services\Uploads\UploadAuditService; use App\Services\Uploads\UploadAuditService;
use App\Services\Uploads\UploadPipelineService; use App\Services\Uploads\UploadPipelineService;
use App\Services\Uploads\UploadQuotaService; use App\Services\Uploads\UploadQuotaService;
use App\Services\Uploads\UploadQueueService;
use App\Services\Uploads\UploadSessionStatus; use App\Services\Uploads\UploadSessionStatus;
use App\Services\Uploads\UploadStatusService; use App\Services\Uploads\UploadStatusService;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -43,6 +47,7 @@ use App\Uploads\Exceptions\DraftQuotaException;
use App\Models\Artwork; use App\Models\Artwork;
use App\Models\Group; use App\Models\Group;
use App\Services\GroupArtworkReviewService; use App\Services\GroupArtworkReviewService;
use App\Services\Worlds\WorldSubmissionService;
use Illuminate\Support\Str; use Illuminate\Support\Str;
final class UploadController extends Controller final class UploadController extends Controller
@@ -81,11 +86,13 @@ final class UploadController extends Controller
UploadFinishRequest $request, UploadFinishRequest $request,
UploadPipelineService $pipeline, UploadPipelineService $pipeline,
UploadSessionRepository $sessions, UploadSessionRepository $sessions,
UploadAuditService $audit UploadAuditService $audit,
UploadQueueService $queue
) { ) {
$user = $request->user(); $user = $request->user();
$sessionId = (string) $request->validated('session_id'); $sessionId = (string) $request->validated('session_id');
$artworkId = (int) $request->validated('artwork_id'); $artworkId = (int) $request->validated('artwork_id');
$batchItemId = (int) $request->validated('batch_item_id', 0);
$originalFileName = $request->validated('file_name'); $originalFileName = $request->validated('file_name');
$archiveSessionId = $request->validated('archive_session_id'); $archiveSessionId = $request->validated('archive_session_id');
$archiveOriginalFileName = $request->validated('archive_file_name'); $archiveOriginalFileName = $request->validated('archive_file_name');
@@ -96,16 +103,33 @@ final class UploadController extends Controller
$session = $sessions->getOrFail($sessionId); $session = $sessions->getOrFail($sessionId);
$request->artwork(); $request->artwork();
$request->batchItem();
$failResponse = function (int $statusCode, string $message, ?string $reason = null) use ($queue, $user, $batchItemId) {
if ($batchItemId > 0) {
$queue->markItemFailedForUser($user, $batchItemId, $reason ?? 'upload_failed', $message);
}
return response()->json(array_filter([
'message' => $message,
'reason' => $reason,
], static fn (mixed $value): bool => $value !== null), $statusCode);
};
$validated = $pipeline->validateAndHash($sessionId); $validated = $pipeline->validateAndHash($sessionId);
if (! $validated->validation->ok || ! $validated->hash) { if (! $validated->validation->ok || ! $validated->hash) {
return response()->json([ return $failResponse(
'message' => 'Upload validation failed.', Response::HTTP_UNPROCESSABLE_ENTITY,
'reason' => $validated->validation->reason, 'Upload validation failed.',
], Response::HTTP_UNPROCESSABLE_ENTITY); $validated->validation->reason
);
} }
if ($pipeline->originalHashExists($validated->hash)) { if ($pipeline->originalHashExists($validated->hash)) {
if ($batchItemId > 0) {
$queue->markItemFailedForUser($user, $batchItemId, 'duplicate_hash', 'Duplicate upload is not allowed. This file already exists.');
}
return response()->json([ return response()->json([
'message' => 'Duplicate upload is not allowed. This file already exists.', 'message' => 'Duplicate upload is not allowed. This file already exists.',
'reason' => 'duplicate_hash', 'reason' => 'duplicate_hash',
@@ -115,28 +139,31 @@ final class UploadController extends Controller
$scan = $pipeline->scan($sessionId); $scan = $pipeline->scan($sessionId);
if (! $scan->ok) { if (! $scan->ok) {
return response()->json([ return $failResponse(
'message' => 'Upload scan failed.', Response::HTTP_UNPROCESSABLE_ENTITY,
'reason' => $scan->reason, 'Upload scan failed.',
], Response::HTTP_UNPROCESSABLE_ENTITY); $scan->reason
);
} }
$validatedArchive = null; $validatedArchive = null;
if (is_string($archiveSessionId) && trim($archiveSessionId) !== '') { if (is_string($archiveSessionId) && trim($archiveSessionId) !== '') {
$validatedArchive = $pipeline->validateAndHashArchive($archiveSessionId); $validatedArchive = $pipeline->validateAndHashArchive($archiveSessionId);
if (! $validatedArchive->validation->ok || ! $validatedArchive->hash) { if (! $validatedArchive->validation->ok || ! $validatedArchive->hash) {
return response()->json([ return $failResponse(
'message' => 'Archive validation failed.', Response::HTTP_UNPROCESSABLE_ENTITY,
'reason' => $validatedArchive->validation->reason, 'Archive validation failed.',
], Response::HTTP_UNPROCESSABLE_ENTITY); $validatedArchive->validation->reason
);
} }
$archiveScan = $pipeline->scan($archiveSessionId); $archiveScan = $pipeline->scan($archiveSessionId);
if (! $archiveScan->ok) { if (! $archiveScan->ok) {
return response()->json([ return $failResponse(
'message' => 'Archive scan failed.', Response::HTTP_UNPROCESSABLE_ENTITY,
'reason' => $archiveScan->reason, 'Archive scan failed.',
], Response::HTTP_UNPROCESSABLE_ENTITY); $archiveScan->reason
);
} }
} }
@@ -149,18 +176,20 @@ final class UploadController extends Controller
$validatedScreenshot = $pipeline->validateAndHash($screenshotSessionId); $validatedScreenshot = $pipeline->validateAndHash($screenshotSessionId);
if (! $validatedScreenshot->validation->ok || ! $validatedScreenshot->hash) { if (! $validatedScreenshot->validation->ok || ! $validatedScreenshot->hash) {
return response()->json([ return $failResponse(
'message' => 'Screenshot validation failed.', Response::HTTP_UNPROCESSABLE_ENTITY,
'reason' => $validatedScreenshot->validation->reason, 'Screenshot validation failed.',
], Response::HTTP_UNPROCESSABLE_ENTITY); $validatedScreenshot->validation->reason
);
} }
$screenshotScan = $pipeline->scan($screenshotSessionId); $screenshotScan = $pipeline->scan($screenshotSessionId);
if (! $screenshotScan->ok) { if (! $screenshotScan->ok) {
return response()->json([ return $failResponse(
'message' => 'Screenshot scan failed.', Response::HTTP_UNPROCESSABLE_ENTITY,
'reason' => $screenshotScan->reason, 'Screenshot scan failed.',
], Response::HTTP_UNPROCESSABLE_ENTITY); $screenshotScan->reason
);
} }
$validatedAdditionalScreenshots[] = [ $validatedAdditionalScreenshots[] = [
@@ -171,7 +200,7 @@ final class UploadController extends Controller
} }
try { try {
$status = DB::transaction(function () use ($pipeline, $sessionId, $validated, $artworkId, $originalFileName, $archiveSessionId, $validatedArchive, $archiveOriginalFileName, $validatedAdditionalScreenshots) { $status = DB::transaction(function () use ($pipeline, $sessionId, $validated, $artworkId, $originalFileName, $archiveSessionId, $validatedArchive, $archiveOriginalFileName, $validatedAdditionalScreenshots, $queue, $batchItemId) {
if ((bool) config('uploads.queue_derivatives', false)) { if ((bool) config('uploads.queue_derivatives', false)) {
GenerateDerivativesJob::dispatch( GenerateDerivativesJob::dispatch(
$sessionId, $sessionId,
@@ -181,8 +210,14 @@ final class UploadController extends Controller
is_string($archiveSessionId) ? $archiveSessionId : null, is_string($archiveSessionId) ? $archiveSessionId : null,
$validatedArchive?->hash, $validatedArchive?->hash,
is_string($archiveOriginalFileName) ? $archiveOriginalFileName : null, is_string($archiveOriginalFileName) ? $archiveOriginalFileName : null,
$validatedAdditionalScreenshots $validatedAdditionalScreenshots,
$batchItemId > 0 ? $batchItemId : null
)->afterCommit(); )->afterCommit();
if ($batchItemId > 0) {
$queue->markItemProcessingQueued($batchItemId);
}
return 'queued'; return 'queued';
} }
@@ -197,9 +232,15 @@ final class UploadController extends Controller
$validatedAdditionalScreenshots $validatedAdditionalScreenshots
); );
if ($batchItemId > 0) {
$queue->markItemMediaProcessed($batchItemId);
}
// Derivatives are available now; dispatch AI auto-tagging. // Derivatives are available now; dispatch AI auto-tagging.
AutoTagArtworkJob::dispatch($artworkId, $validated->hash)->afterCommit(); AutoTagArtworkJob::dispatch($artworkId, $validated->hash)->afterCommit();
DetectArtworkMaturityJob::dispatch($artworkId, $validated->hash)->afterCommit();
GenerateArtworkEmbeddingJob::dispatch($artworkId, $validated->hash)->afterCommit(); GenerateArtworkEmbeddingJob::dispatch($artworkId, $validated->hash)->afterCommit();
AnalyzeArtworkAiAssistJob::dispatch($artworkId)->afterCommit();
return UploadSessionStatus::PROCESSED; return UploadSessionStatus::PROCESSED;
}); });
@@ -223,6 +264,10 @@ final class UploadController extends Controller
'error' => $e->getMessage(), 'error' => $e->getMessage(),
]); ]);
if ($batchItemId > 0) {
$queue->markItemFailedForUser($user, $batchItemId, 'upload_finish_failed', $e->getMessage());
}
return response()->json([ return response()->json([
'message' => 'Upload finish failed.', 'message' => 'Upload finish failed.',
], Response::HTTP_INTERNAL_SERVER_ERROR); ], Response::HTTP_INTERNAL_SERVER_ERROR);
@@ -559,7 +604,7 @@ final class UploadController extends Controller
], Response::HTTP_OK); ], Response::HTTP_OK);
} }
public function publish(string $id, Request $request, PublishService $publishService, ArtworkAttributionService $attribution, ArtworkMaturityService $maturity) public function publish(string $id, Request $request, PublishService $publishService, ArtworkAttributionService $attribution, ArtworkMaturityService $maturity, WorldSubmissionService $submissions)
{ {
$user = $request->user(); $user = $request->user();
@@ -567,7 +612,7 @@ final class UploadController extends Controller
'title' => ['nullable', 'string', 'max:150'], 'title' => ['nullable', 'string', 'max:150'],
'description' => ['nullable', 'string'], 'description' => ['nullable', 'string'],
'category' => ['nullable', 'integer', 'exists:categories,id'], 'category' => ['nullable', 'integer', 'exists:categories,id'],
'tags' => ['nullable', 'array', 'max:15'], 'tags' => ['nullable', 'array', 'max:' . (int) config('tags.max_user_tags', 30)],
'tags.*' => ['string', 'max:64'], 'tags.*' => ['string', 'max:64'],
'is_mature' => ['nullable', 'boolean'], 'is_mature' => ['nullable', 'boolean'],
'nsfw' => ['nullable', 'boolean'], 'nsfw' => ['nullable', 'boolean'],
@@ -584,6 +629,9 @@ final class UploadController extends Controller
'contributor_credits.*.user_id' => ['required', 'integer', 'min:1'], 'contributor_credits.*.user_id' => ['required', 'integer', 'min:1'],
'contributor_credits.*.credit_role' => ['nullable', 'string', 'max:80'], 'contributor_credits.*.credit_role' => ['nullable', 'string', 'max:80'],
'contributor_credits.*.is_primary' => ['nullable', 'boolean'], 'contributor_credits.*.is_primary' => ['nullable', 'boolean'],
'world_submissions' => ['nullable', 'array', 'max:12'],
'world_submissions.*.world_id' => ['required', 'integer', 'exists:worlds,id'],
'world_submissions.*.note' => ['nullable', 'string', 'max:1000'],
]); ]);
$mode = $validated['mode'] ?? 'now'; $mode = $validated['mode'] ?? 'now';
@@ -660,6 +708,7 @@ final class UploadController extends Controller
$artwork->save(); $artwork->save();
$maturity->applyUploaderDeclaration($artwork, (bool) $artwork->is_mature); $maturity->applyUploaderDeclaration($artwork, (bool) $artwork->is_mature);
$artwork = $attribution->apply($artwork->fresh(['group.members']), $user, $validated); $artwork = $attribution->apply($artwork->fresh(['group.members']), $user, $validated);
$submissions->syncForArtwork($artwork->fresh(), $user, (array) ($validated['world_submissions'] ?? []));
if ($mode === 'schedule' && $publishAt) { if ($mode === 'schedule' && $publishAt) {
// Scheduled: store publish_at but don't make public yet // Scheduled: store publish_at but don't make public yet
@@ -671,14 +720,7 @@ final class UploadController extends Controller
$artwork->published_at = null; $artwork->published_at = null;
$artwork->save(); $artwork->save();
try { IndexArtworkJob::dispatch((int) $artwork->id);
$artwork->unsearchable();
} catch (\Throwable $e) {
Log::warning('Failed to remove scheduled artwork from search index', [
'artwork_id' => (int) $artwork->id,
'error' => $e->getMessage(),
]);
}
return response()->json([ return response()->json([
'success' => true, 'success' => true,
@@ -699,18 +741,7 @@ final class UploadController extends Controller
$artwork->publish_at = null; $artwork->publish_at = null;
$artwork->save(); $artwork->save();
try { IndexArtworkJob::dispatch((int) $artwork->id);
if ((bool) $artwork->is_public && (bool) $artwork->is_approved && !empty($artwork->published_at)) {
$artwork->searchable();
} else {
$artwork->unsearchable();
}
} catch (\Throwable $e) {
Log::warning('Failed to sync artwork search index after publish', [
'artwork_id' => (int) $artwork->id,
'error' => $e->getMessage(),
]);
}
// Record upload activity event // Record upload activity event
try { try {
@@ -754,7 +785,7 @@ final class UploadController extends Controller
} }
} }
public function submitForReview(string $id, Request $request, GroupArtworkReviewService $reviews) public function submitForReview(string $id, Request $request, GroupArtworkReviewService $reviews, WorldSubmissionService $submissions)
{ {
$user = $request->user(); $user = $request->user();
@@ -762,7 +793,7 @@ final class UploadController extends Controller
'title' => ['nullable', 'string', 'max:150'], 'title' => ['nullable', 'string', 'max:150'],
'description' => ['nullable', 'string'], 'description' => ['nullable', 'string'],
'category' => ['nullable', 'integer', 'exists:categories,id'], 'category' => ['nullable', 'integer', 'exists:categories,id'],
'tags' => ['nullable', 'array', 'max:15'], 'tags' => ['nullable', 'array', 'max:' . (int) config('tags.max_user_tags', 30)],
'tags.*' => ['string', 'max:64'], 'tags.*' => ['string', 'max:64'],
'is_mature' => ['nullable', 'boolean'], 'is_mature' => ['nullable', 'boolean'],
'nsfw' => ['nullable', 'boolean'], 'nsfw' => ['nullable', 'boolean'],
@@ -776,6 +807,9 @@ final class UploadController extends Controller
'contributor_credits.*.user_id' => ['required', 'integer', 'min:1'], 'contributor_credits.*.user_id' => ['required', 'integer', 'min:1'],
'contributor_credits.*.credit_role' => ['nullable', 'string', 'max:80'], 'contributor_credits.*.credit_role' => ['nullable', 'string', 'max:80'],
'contributor_credits.*.is_primary' => ['nullable', 'boolean'], 'contributor_credits.*.is_primary' => ['nullable', 'boolean'],
'world_submissions' => ['nullable', 'array', 'max:12'],
'world_submissions.*.world_id' => ['required', 'integer', 'exists:worlds,id'],
'world_submissions.*.note' => ['nullable', 'string', 'max:1000'],
]); ]);
if (! ctype_digit($id)) { if (! ctype_digit($id)) {
@@ -797,6 +831,7 @@ final class UploadController extends Controller
} }
$artwork = $reviews->submit($group, $artwork, $user, $validated); $artwork = $reviews->submit($group, $artwork, $user, $validated);
$submissions->syncForArtwork($artwork->fresh(), $user, (array) ($validated['world_submissions'] ?? []));
return response()->json([ return response()->json([
'success' => true, 'success' => true,

View File

@@ -172,7 +172,9 @@ class NewsController extends Controller
$userId = Auth::id(); $userId = Auth::id();
$session = 'news_view_' . $article->id; $session = 'news_view_' . $article->id;
if ($request->session()->has($session)) { $canReadSession = $request->hasSession() && ! $request->attributes->get('skinbase.session_skipped');
if ($canReadSession && $request->session()->has($session)) {
return; return;
} }
@@ -185,8 +187,10 @@ class NewsController extends Controller
$article->incrementViews(); $article->incrementViews();
if ($canReadSession) {
$request->session()->put($session, true); $request->session()->put($session, true);
} }
}
private function sidebarData(): array private function sidebarData(): array
{ {

View File

@@ -265,16 +265,8 @@ final class StudioArtworksApiController extends Controller
} }
} }
// Reindex in Meilisearch // Reindex in Meilisearch — dispatches IndexArtworkJob which writes directly, no Scout hop.
try { $this->searchIndexer->update($artwork);
if ((bool) $artwork->is_public && (bool) $artwork->is_approved && $artwork->published_at) {
$artwork->searchable();
} else {
$artwork->unsearchable();
}
} catch (\Throwable $e) {
// Meilisearch may be unavailable
}
// Reload relationships for response // Reload relationships for response
$artwork->load(['categories.contentType', 'tags', 'group', 'primaryAuthor.profile', 'contributors.user.profile']); $artwork->load(['categories.contentType', 'tags', 'group', 'primaryAuthor.profile', 'contributors.user.profile']);

View File

@@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
use App\Models\Group; use App\Models\Group;
use App\Models\ContentType; use App\Models\ContentType;
use App\Services\ArtworkEvolutionService; use App\Services\ArtworkEvolutionService;
use App\Services\Artworks\ArtworkPublicationService;
use App\Services\GroupMembershipService; use App\Services\GroupMembershipService;
use App\Services\GroupService; use App\Services\GroupService;
use App\Services\Studio\CreatorStudioAnalyticsService; use App\Services\Studio\CreatorStudioAnalyticsService;
@@ -24,10 +25,12 @@ use App\Services\Studio\CreatorStudioPreferenceService;
use App\Services\Studio\CreatorStudioChallengeService; use App\Services\Studio\CreatorStudioChallengeService;
use App\Services\Studio\CreatorStudioSearchService; use App\Services\Studio\CreatorStudioSearchService;
use App\Services\Studio\CreatorStudioScheduledService; use App\Services\Studio\CreatorStudioScheduledService;
use App\Services\Worlds\WorldSubmissionService;
use App\Support\CoverUrl; use App\Support\CoverUrl;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@@ -74,7 +77,7 @@ final class StudioController extends Controller
public function content(Request $request): Response public function content(Request $request): Response
{ {
$prefs = $this->preferences->forUser($request->user()); $prefs = $this->preferences->forUser($request->user());
$listing = $this->content->list($request->user(), $request->only(['module', 'bucket', 'q', 'sort', 'page', 'per_page', 'category', 'tag', 'visibility', 'activity_state', 'stale'])); $listing = $this->content->list($request->user(), $request->only(['module', 'bucket', 'q', 'sort', 'page', 'per_page', 'content_type', 'category', 'tag', 'visibility', 'activity_state', 'stale']));
$listing['default_view'] = $prefs['default_content_view']; $listing['default_view'] = $prefs['default_content_view'];
return Inertia::render('Studio/StudioContentIndex', [ return Inertia::render('Studio/StudioContentIndex', [
@@ -92,7 +95,7 @@ final class StudioController extends Controller
{ {
$provider = $this->content->provider('artworks'); $provider = $this->content->provider('artworks');
$prefs = $this->preferences->forUser($request->user()); $prefs = $this->preferences->forUser($request->user());
$listing = $this->content->list($request->user(), $request->only(['q', 'sort', 'bucket', 'page', 'per_page', 'category', 'tag']), null, 'artworks'); $listing = $this->content->list($request->user(), $request->only(['q', 'sort', 'bucket', 'page', 'per_page', 'content_type', 'category', 'tag']), null, 'artworks');
$listing['default_view'] = $prefs['default_content_view']; $listing['default_view'] = $prefs['default_content_view'];
return Inertia::render('Studio/StudioArtworks', [ return Inertia::render('Studio/StudioArtworks', [
@@ -121,6 +124,23 @@ final class StudioController extends Controller
]); ]);
} }
public function uploadQueue(Request $request): Response
{
$queue = app(\App\Services\Uploads\UploadQueueService::class)->listPayload(
$request->user(),
$request->only(['batch_id', 'status', 'sort'])
);
return Inertia::render('Studio/StudioUploadQueue', [
'title' => 'Upload Queue',
'description' => 'Upload multiple artworks, track processing, and publish only when each draft is ready.',
'queue' => $queue,
'contentTypes' => $this->getCategories(),
'chunkSize' => (int) config('uploads.chunk.max_bytes', 5242880),
'chunkRequestTimeoutMs' => (int) config('uploads.chunk.request_timeout_ms', 45000),
]);
}
/** /**
* Archived (/studio/archived) * Archived (/studio/archived)
*/ */
@@ -426,6 +446,9 @@ final class StudioController extends Controller
->with(['stats', 'categories.contentType', 'tags', 'artworkAiAssist', 'group.members', 'primaryAuthor.profile', 'contributors.user.profile']) ->with(['stats', 'categories.contentType', 'tags', 'artworkAiAssist', 'group.members', 'primaryAuthor.profile', 'contributors.user.profile'])
->findOrFail($id); ->findOrFail($id);
$artwork = app(ArtworkPublicationService::class)->publishIfDue($artwork);
$artwork->loadMissing(['stats', 'categories.contentType', 'tags', 'artworkAiAssist', 'group.members', 'primaryAuthor.profile', 'contributors.user.profile']);
$primaryCategory = $artwork->categories->first(); $primaryCategory = $artwork->categories->first();
$availableGroups = app(GroupService::class)->studioOptionsForUser($user); $availableGroups = app(GroupService::class)->studioOptionsForUser($user);
$membershipService = app(GroupMembershipService::class); $membershipService = app(GroupMembershipService::class);
@@ -455,11 +478,15 @@ final class StudioController extends Controller
'artwork_timezone' => $artwork->artwork_timezone, 'artwork_timezone' => $artwork->artwork_timezone,
'thumb_url' => $artwork->thumbUrl('md'), 'thumb_url' => $artwork->thumbUrl('md'),
'thumb_url_lg' => $artwork->thumbUrl('lg'), 'thumb_url_lg' => $artwork->thumbUrl('lg'),
'download_url' => route('art.download', ['id' => $artwork->id]),
'file_name' => $artwork->file_name, 'file_name' => $artwork->file_name,
'file_ext' => $artwork->file_ext,
'file_size' => $artwork->file_size, 'file_size' => $artwork->file_size,
'width' => $artwork->width, 'width' => $artwork->width,
'height' => $artwork->height, 'height' => $artwork->height,
'mime_type' => $artwork->mime_type, 'mime_type' => $artwork->mime_type,
'has_archive_file' => $this->artworkHasArchiveFile((int) $artwork->id),
'screenshots' => $this->screenshotAssetsForArtwork((int) $artwork->id),
'group_slug' => $artwork->group?->slug, 'group_slug' => $artwork->group?->slug,
'primary_author_user_id' => (int) ($artwork->primary_author_user_id ?: $artwork->user_id), 'primary_author_user_id' => (int) ($artwork->primary_author_user_id ?: $artwork->user_id),
'contributor_user_ids' => $artwork->contributors->pluck('user_id')->map(fn ($id): int => (int) $id)->values()->all(), 'contributor_user_ids' => $artwork->contributors->pluck('user_id')->map(fn ($id): int => (int) $id)->values()->all(),
@@ -484,6 +511,7 @@ final class StudioController extends Controller
'version_count' => (int) ($artwork->version_count ?? 1), 'version_count' => (int) ($artwork->version_count ?? 1),
'requires_reapproval' => (bool) $artwork->requires_reapproval, 'requires_reapproval' => (bool) $artwork->requires_reapproval,
], ],
'worldSubmissionOptions' => app(WorldSubmissionService::class)->artworkSubmissionOptions($artwork, $user),
'contentTypes' => $this->getCategories(), 'contentTypes' => $this->getCategories(),
'groupOptions' => $availableGroups, 'groupOptions' => $availableGroups,
'contributorOptionsByGroup' => $contributorOptionsByGroup, 'contributorOptionsByGroup' => $contributorOptionsByGroup,
@@ -588,4 +616,51 @@ final class StudioController extends Controller
default => 'studio.index', default => 'studio.index',
}; };
} }
private function screenshotAssetsForArtwork(int $artworkId): array
{
if (! Schema::hasTable('artwork_files')) {
return [];
}
$base = rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/');
return DB::table('artwork_files')
->where('artwork_id', $artworkId)
->where('variant', 'like', 'shot%')
->orderBy('variant')
->get(['variant', 'path', 'mime', 'size'])
->map(function ($row, int $index) use ($base): ?array {
$path = trim((string) ($row->path ?? ''), '/');
if ($path === '') {
return null;
}
$url = $base . '/' . $path;
return [
'id' => (string) ($row->variant ?? ('shot' . ($index + 1))),
'label' => 'Screenshot ' . ($index + 1),
'url' => $url,
'thumb_url' => $url,
'mime_type' => (string) ($row->mime ?? 'image/jpeg'),
'size' => (int) ($row->size ?? 0),
];
})
->filter()
->values()
->all();
}
private function artworkHasArchiveFile(int $artworkId): bool
{
if (! Schema::hasTable('artwork_files')) {
return false;
}
return DB::table('artwork_files')
->where('artwork_id', $artworkId)
->where('variant', 'orig_archive')
->exists();
}
} }

View File

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

View File

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

View File

@@ -102,7 +102,7 @@ final class SimilarArtworksPageController extends Controller
'page_title' => 'Similar to "' . $sourceTitle . '" — Skinbase', 'page_title' => 'Similar to "' . $sourceTitle . '" — Skinbase',
'page_meta_description' => 'Discover artworks similar to "' . $sourceTitle . '" on Skinbase.', 'page_meta_description' => 'Discover artworks similar to "' . $sourceTitle . '" on Skinbase.',
'page_canonical' => $baseUrl, 'page_canonical' => $baseUrl,
'page_robots' => 'noindex,follow', 'page_robots' => 'index,follow',
'breadcrumbs' => collect([ 'breadcrumbs' => collect([
(object) ['name' => 'Explore', 'url' => '/explore'], (object) ['name' => 'Explore', 'url' => '/explore'],
(object) ['name' => $sourceTitle, 'url' => $sourceUrl], (object) ['name' => $sourceTitle, 'url' => $sourceUrl],

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\View\Middleware\ShareErrorsFromSession;
class ConditionalShareErrorsFromSession extends ShareErrorsFromSession
{
public function handle($request, Closure $next): mixed
{
if (! $request instanceof Request) {
return parent::handle($request, $next);
}
if ($request->attributes->get('skinbase.session_skipped') === true || ! $request->hasSession()) {
return $next($request);
}
return parent::handle($request, $next);
}
}

View File

@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Session\Middleware\StartSession;
use Symfony\Component\HttpFoundation\Response;
class ConditionalStartSession extends StartSession
{
public function handle($request, Closure $next): mixed
{
if (! $request instanceof Request || ! config('skinbase-sessions.enabled', true)) {
return parent::handle($request, $next);
}
if ($this->shouldSkipSession($request)) {
$request->attributes->set('skinbase.session_skipped', true);
$response = $next($request);
if ($response instanceof Response && config('skinbase-sessions.debug_header', false)) {
$response->headers->set('X-Skinbase-Session', 'skipped');
}
return $response;
}
$request->attributes->set('skinbase.session_skipped', false);
$response = parent::handle($request, $next);
if ($response instanceof Response && config('skinbase-sessions.debug_header', false)) {
$response->headers->set('X-Skinbase-Session', 'started');
}
return $response;
}
protected function shouldSkipSession(Request $request): bool
{
if (! $this->isSafeReadMethod($request)) {
return false;
}
if ($this->hasExistingSessionCookie($request)) {
return false;
}
if ($this->matchesAnyPath($request, config('skinbase-sessions.always_session_paths', []))) {
return false;
}
if (! $this->matchesAnyPath($request, config('skinbase-sessions.public_paths', []))) {
return false;
}
if (config('skinbase-sessions.skip_anonymous_public_get', true)) {
return true;
}
return config('skinbase-sessions.skip_known_crawlers_on_public_get', true)
&& $this->isKnownCrawler($request);
}
protected function isSafeReadMethod(Request $request): bool
{
return in_array($request->getMethod(), ['GET', 'HEAD'], true);
}
protected function hasExistingSessionCookie(Request $request): bool
{
$cookieName = config('session.cookie');
return is_string($cookieName)
&& $cookieName !== ''
&& $request->cookies->has($cookieName);
}
protected function matchesAnyPath(Request $request, array $patterns): bool
{
foreach ($patterns as $pattern) {
if (! is_string($pattern) || $pattern === '') {
continue;
}
if ($pattern === '/' && $request->path() === '/') {
return true;
}
$normalizedPattern = trim($pattern, '/');
if ($normalizedPattern !== '' && $request->is($normalizedPattern)) {
return true;
}
}
return false;
}
protected function isKnownCrawler(Request $request): bool
{
$userAgent = strtolower((string) $request->userAgent());
if ($userAgent === '') {
return false;
}
foreach (config('skinbase-sessions.bot_user_agent_keywords', []) as $keyword) {
$normalizedKeyword = strtolower((string) $keyword);
if ($normalizedKeyword !== '' && str_contains($userAgent, $normalizedKeyword)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
use Illuminate\Http\Request;
class ConditionalValidateCsrfToken extends ValidateCsrfToken
{
public function handle($request, Closure $next): mixed
{
if ($request instanceof Request && $request->attributes->get('skinbase.session_skipped') === true) {
return $next($request);
}
return parent::handle($request, $next);
}
}

View File

@@ -12,6 +12,15 @@ final class HandleInertiaRequests extends Middleware
{ {
protected $rootView = 'upload'; protected $rootView = 'upload';
protected function canReadSessionAuth(Request $request): bool
{
if ($request->attributes->get('skinbase.session_skipped') === true) {
return false;
}
return $request->hasSession();
}
/** /**
* Select the root Blade view based on route prefix. * Select the root Blade view based on route prefix.
*/ */
@@ -58,13 +67,16 @@ final class HandleInertiaRequests extends Middleware
public function share(Request $request): array public function share(Request $request): array
{ {
$canReadSessionAuth = $this->canReadSessionAuth($request);
$user = $canReadSessionAuth ? $request->user() : null;
return array_merge(parent::share($request), [ return array_merge(parent::share($request), [
'auth' => [ 'auth' => [
'user' => $request->user() ? [ 'user' => $user ? [
'id' => $request->user()->id, 'id' => $user->id,
'name' => $request->user()->name, 'name' => $user->name,
'is_admin' => $request->user()->isAdmin(), 'is_admin' => $user->isAdmin(),
'is_moderator' => $request->user()->isModerator(), 'is_moderator' => $user->isModerator(),
] : null, ] : null,
], ],
'cdn' => [ 'cdn' => [
@@ -84,8 +96,8 @@ final class HandleInertiaRequests extends Middleware
'group_assets' => (bool) config('features.group_assets', true), 'group_assets' => (bool) config('features.group_assets', true),
'group_activity_feed' => (bool) config('features.group_activity_feed', true), 'group_activity_feed' => (bool) config('features.group_activity_feed', true),
], ],
'studio_groups' => $request->user() 'studio_groups' => $user
? app(GroupService::class)->studioOptionsForUser($request->user()) ? app(GroupService::class)->studioOptionsForUser($user)
: [], : [],
]); ]);
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,6 +28,19 @@ class Artwork extends Model
{ {
use HasFactory, SoftDeletes, Searchable; use HasFactory, SoftDeletes, Searchable;
/**
* Override Scout's bootSearchable to skip the ModelObserver (which fires MakeSearchable
* on every save). We still register SearchableScope and Builder macros so that
* scout:import and Builder::searchable() continue to work.
* All indexing is managed explicitly via IndexArtworkJob.
*/
public static function bootSearchable(): void
{
static::addGlobalScope(new \Laravel\Scout\SearchableScope);
(new static)->registerSearchableMacros();
// ModelObserver intentionally omitted — indexing is handled by IndexArtworkJob.
}
public const PUBLISHED_AS_USER = 'user'; public const PUBLISHED_AS_USER = 'user';
public const PUBLISHED_AS_GROUP = 'group'; public const PUBLISHED_AS_GROUP = 'group';

View File

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

View File

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

View File

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

View File

@@ -151,6 +151,11 @@ class AppServiceProvider extends ServiceProvider
$displayName = null; $displayName = null;
$userId = null; $userId = null;
$toolbarContentTypes = collect(); $toolbarContentTypes = collect();
$request = request();
$canReadSessionAuth = $request instanceof \Illuminate\Http\Request
&& $request->hasSession()
&& $request->attributes->get('skinbase.session_skipped') !== true;
$authUser = $canReadSessionAuth ? Auth::user() : null;
try { try {
$toolbarContentTypes = $this->app $toolbarContentTypes = $this->app
@@ -162,8 +167,9 @@ class AppServiceProvider extends ServiceProvider
$toolbarContentTypes = collect(); $toolbarContentTypes = collect();
} }
if (Auth::check()) { if ($authUser) {
$userId = Auth::id(); $authUser->loadMissing('profile');
$userId = (int) $authUser->id;
try { try {
$uploadCount = DB::table('artworks')->where('user_id', $userId)->count(); $uploadCount = DB::table('artworks')->where('user_id', $userId)->count();
} catch (\Throwable $e) { } catch (\Throwable $e) {
@@ -200,19 +206,18 @@ class AppServiceProvider extends ServiceProvider
try { try {
$receivedCommentsCount = $this->app->make(ReceivedCommentsInboxService::class) $receivedCommentsCount = $this->app->make(ReceivedCommentsInboxService::class)
->unreadCountForUser(Auth::user()); ->unreadCountForUser($authUser);
} catch (\Throwable $e) { } catch (\Throwable $e) {
$receivedCommentsCount = 0; $receivedCommentsCount = 0;
} }
try { try {
$profile = DB::table('user_profiles')->where('user_id', $userId)->first(); $avatarHash = $authUser->profile?->avatar_hash;
$avatarHash = $profile->avatar_hash ?? null;
} catch (\Throwable $e) { } catch (\Throwable $e) {
$avatarHash = null; $avatarHash = null;
} }
$displayName = Auth::user()->name ?: (Auth::user()->username ?? ''); $displayName = $authUser->name ?: ($authUser->username ?? '');
} }
$view->with(compact('userId','uploadCount', 'favCount', 'msgCount', 'noticeCount', 'receivedCommentsCount', 'avatarHash', 'displayName', 'toolbarContentTypes')); $view->with(compact('userId','uploadCount', 'favCount', 'msgCount', 'noticeCount', 'receivedCommentsCount', 'avatarHash', 'displayName', 'toolbarContentTypes'));

View File

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

View File

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

View File

@@ -92,7 +92,7 @@ final class SitemapCacheService
{ {
$prefix = trim((string) config('sitemaps.pre_generated.path', 'generated-sitemaps'), '/'); $prefix = trim((string) config('sitemaps.pre_generated.path', 'generated-sitemaps'), '/');
$segments = $name === self::INDEX_DOCUMENT $segments = $name === self::INDEX_DOCUMENT
? [$prefix, 'sitemap.xml'] ? [$prefix, 'sitemaps', 'sitemap.xml']
: [$prefix, 'sitemaps', $name . '.xml']; : [$prefix, 'sitemaps', $name . '.xml'];
return implode('/', array_values(array_filter($segments, static fn (string $segment): bool => $segment !== ''))); return implode('/', array_values(array_filter($segments, static fn (string $segment): bool => $segment !== '')));

View File

@@ -124,7 +124,7 @@ final class SitemapReleaseManager
public function documentRelativePath(string $documentName): string public function documentRelativePath(string $documentName): string
{ {
return $documentName === SitemapCacheService::INDEX_DOCUMENT return $documentName === SitemapCacheService::INDEX_DOCUMENT
? 'sitemap.xml' ? 'sitemaps/sitemap.xml'
: 'sitemaps/' . $documentName . '.xml'; : 'sitemaps/' . $documentName . '.xml';
} }

View File

@@ -6,6 +6,7 @@ namespace App\Services\Studio;
use App\Models\Artwork; use App\Models\Artwork;
use App\Models\Tag; use App\Models\Tag;
use App\Jobs\IndexArtworkJob;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -156,12 +157,8 @@ final class StudioBulkActionService
*/ */
private function reindexArtworks(\Illuminate\Database\Eloquent\Collection $artworks): void private function reindexArtworks(\Illuminate\Database\Eloquent\Collection $artworks): void
{ {
try { foreach ($artworks as $artwork) {
$artworks->each->searchable(); IndexArtworkJob::dispatch((int) $artwork->id);
} catch (\Throwable $e) {
Log::warning('Studio: Failed to reindex artworks after bulk action', [
'error' => $e->getMessage(),
]);
} }
} }
} }

View File

@@ -0,0 +1,760 @@
<?php
declare(strict_types=1);
namespace App\Services\Uploads;
use App\Jobs\AnalyzeArtworkAiAssistJob;
use App\Jobs\AutoTagArtworkJob;
use App\Jobs\DetectArtworkMaturityJob;
use App\Jobs\GenerateArtworkEmbeddingJob;
use App\Models\Artwork;
use App\Models\UploadBatch;
use App\Models\UploadBatchItem;
use App\Models\User;
use App\Services\Artworks\ArtworkDraftService;
use App\Services\Artworks\ArtworkPublicationService;
use App\Services\Maturity\ArtworkMaturityService;
use App\Services\TagService;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
final class UploadQueueService
{
public function __construct(
private readonly ArtworkDraftService $artworkDrafts,
private readonly ArtworkPublicationService $publication,
private readonly TagService $tags,
) {
}
public function createBatch(User $user, array $files, array $defaults = [], ?string $name = null): UploadBatch
{
$normalizedFiles = collect($files)
->map(fn (mixed $file): array => [
'name' => trim((string) data_get($file, 'name', '')),
])
->filter(fn (array $file): bool => $file['name'] !== '')
->values();
if ($normalizedFiles->isEmpty()) {
throw ValidationException::withMessages([
'files' => ['Choose at least one file for the upload queue.'],
]);
}
if ($normalizedFiles->count() > 50) {
throw ValidationException::withMessages([
'files' => ['Bulk upload is limited to 50 files per batch in v1.'],
]);
}
$normalizedDefaults = $this->normalizeDefaults($defaults);
$batch = DB::transaction(function () use ($user, $normalizedFiles, $normalizedDefaults, $name): UploadBatch {
$batch = UploadBatch::query()->create([
'user_id' => (int) $user->id,
'name' => $this->normalizeBatchName($name, $normalizedFiles->count()),
'status' => UploadBatch::STATUS_UPLOADING,
'total_items' => $normalizedFiles->count(),
'defaults_json' => $normalizedDefaults === [] ? null : $normalizedDefaults,
]);
foreach ($normalizedFiles as $file) {
$draft = $this->artworkDrafts->createDraft(
$user,
$this->titleFromFilename($file['name']),
null,
Arr::get($normalizedDefaults, 'category_id'),
(bool) Arr::get($normalizedDefaults, 'is_mature', false),
Arr::get($normalizedDefaults, 'group')
);
$artwork = Artwork::query()->findOrFail($draft->artworkId);
$artwork->forceFill([
'visibility' => Arr::get($normalizedDefaults, 'visibility', Artwork::VISIBILITY_PUBLIC),
'is_public' => false,
'artwork_status' => 'draft',
])->saveQuietly();
$defaultTags = (array) Arr::get($normalizedDefaults, 'tags', []);
if ($defaultTags !== []) {
$this->tags->syncStudioTags($artwork, $defaultTags);
}
$batch->items()->create([
'user_id' => (int) $user->id,
'artwork_id' => (int) $artwork->id,
'original_filename' => $file['name'],
'status' => UploadBatchItem::STATUS_UPLOADED,
'processing_stage' => UploadBatchItem::STAGE_QUEUED,
'metadata_completeness' => $this->metadataCompleteness($artwork, false, false),
'is_ready_to_publish' => false,
]);
}
return $batch;
});
return $this->refreshBatch($batch->id);
}
public function listPayload(User $user, array $filters = []): array
{
$requestedBatchId = (int) ($filters['batch_id'] ?? 0);
$statusFilter = Str::lower(trim((string) ($filters['status'] ?? 'all')));
$sort = Str::lower(trim((string) ($filters['sort'] ?? 'newest')));
$recentBatches = UploadBatch::query()
->where('user_id', (int) $user->id)
->latest('id')
->limit(12)
->get();
$currentBatch = $requestedBatchId > 0
? $recentBatches->firstWhere('id', $requestedBatchId)
: $recentBatches->first();
if ($currentBatch) {
$currentBatch = $this->refreshBatch((int) $currentBatch->id);
}
$items = $currentBatch
? $currentBatch->items
->loadMissing(['artwork.categories.contentType', 'artwork.tags'])
->map(fn (UploadBatchItem $item): array => $this->presentItem($this->refreshItem((int) $item->id)))
: collect();
$filteredItems = $this->applyFiltersAndSorting($items, $statusFilter, $sort)->values();
$recentBatchPayloads = $recentBatches
->map(fn (UploadBatch $batch): UploadBatch => $batch->relationLoaded('items') ? $batch : $this->refreshBatch((int) $batch->id))
->map(fn (UploadBatch $batch): array => $this->presentBatch($batch))
->values()
->all();
return [
'filters' => [
'batch_id' => $currentBatch?->id,
'status' => $statusFilter,
'sort' => $sort,
],
'batches' => $recentBatchPayloads,
'current_batch' => $currentBatch ? $this->presentBatch($currentBatch) : null,
'items' => $filteredItems->all(),
'status_options' => [
['value' => 'all', 'label' => 'All'],
['value' => UploadBatchItem::STATUS_PROCESSING, 'label' => 'Processing'],
['value' => UploadBatchItem::STATUS_NEEDS_METADATA, 'label' => 'Needs metadata'],
['value' => UploadBatchItem::STATUS_NEEDS_REVIEW, 'label' => 'Needs review'],
['value' => UploadBatchItem::STATUS_READY, 'label' => 'Ready'],
['value' => UploadBatchItem::STATUS_FAILED, 'label' => 'Failed'],
['value' => UploadBatchItem::STATUS_PUBLISHED, 'label' => 'Published'],
],
'sort_options' => [
['value' => 'newest', 'label' => 'Newest first'],
['value' => 'oldest', 'label' => 'Oldest first'],
['value' => 'filename', 'label' => 'Filename'],
['value' => 'status', 'label' => 'Status'],
['value' => 'ready', 'label' => 'Ready first'],
['value' => 'failed', 'label' => 'Failed first'],
],
];
}
public function markItemProcessingQueued(int $itemId): UploadBatchItem
{
$item = $this->itemQuery()->findOrFail($itemId);
$item->forceFill([
'status' => UploadBatchItem::STATUS_PROCESSING,
'processing_stage' => UploadBatchItem::STAGE_THUMBNAILS,
'error_code' => null,
'error_message' => null,
'uploaded_at' => $item->uploaded_at ?: now(),
])->save();
$this->refreshBatch((int) $item->upload_batch_id);
return $item->fresh(['artwork.categories.contentType', 'artwork.tags']) ?? $item;
}
public function markItemMediaProcessed(int $itemId): UploadBatchItem
{
$item = $this->itemQuery()->findOrFail($itemId);
$item->forceFill([
'processing_stage' => UploadBatchItem::STAGE_MATURITY_CHECK,
'error_code' => null,
'error_message' => null,
'processed_at' => now(),
])->save();
$refreshed = $this->refreshItem((int) $item->id);
$this->refreshBatch((int) $item->upload_batch_id);
return $refreshed;
}
public function markItemFailed(int $itemId, ?string $errorCode, ?string $errorMessage): UploadBatchItem
{
$item = $this->itemQuery()->findOrFail($itemId);
$item->forceFill([
'status' => UploadBatchItem::STATUS_FAILED,
'error_code' => $this->nullableString($errorCode),
'error_message' => $this->nullableText($errorMessage),
'is_ready_to_publish' => false,
])->save();
$this->refreshBatch((int) $item->upload_batch_id);
return $item->fresh(['artwork.categories.contentType', 'artwork.tags']) ?? $item;
}
public function markItemFailedForUser(User $user, int $itemId, ?string $errorCode, ?string $errorMessage): UploadBatchItem
{
$item = $this->ownedItems($user, [$itemId])->first();
if (! $item) {
throw ValidationException::withMessages([
'item' => ['Upload queue item not found.'],
]);
}
return $this->markItemFailed((int) $item->id, $errorCode, $errorMessage);
}
public function refreshItem(int $itemId): UploadBatchItem
{
$item = $this->itemQuery()->findOrFail($itemId);
$state = $this->evaluateItemState($item);
$item->forceFill([
'status' => $state['status'],
'processing_stage' => $state['processing_stage'],
'metadata_completeness' => $state['metadata_completeness'],
'is_ready_to_publish' => $state['is_ready_to_publish'],
'published_at' => $state['published_at'],
'processed_at' => $state['processed_at'],
])->saveQuietly();
return $item->fresh(['artwork.categories.contentType', 'artwork.tags']) ?? $item;
}
public function refreshBatch(int $batchId): UploadBatch
{
$batch = UploadBatch::query()
->with(['items.artwork.categories.contentType', 'items.artwork.tags'])
->findOrFail($batchId);
$items = $batch->items->map(fn (UploadBatchItem $item): UploadBatchItem => $this->refreshItem((int) $item->id));
$summary = $this->summarizeItems($items);
$batch->forceFill([
'status' => $summary['status'],
'total_items' => $summary['total_items'],
'processed_items' => $summary['processed_items'],
'failed_items' => $summary['failed_items'],
'published_items' => $summary['published_items'],
])->saveQuietly();
return $batch->fresh(['items.artwork.categories.contentType', 'items.artwork.tags']) ?? $batch;
}
public function bulkAction(User $user, string $action, array $itemIds, array $params = []): array
{
$items = $this->ownedItems($user, $itemIds);
if ($items->isEmpty()) {
return [
'success' => 0,
'failed' => count($itemIds),
'errors' => ['No owned upload queue items were found.'],
];
}
$success = 0;
$failed = 0;
$errors = [];
foreach ($items as $item) {
try {
match ($action) {
'publish' => $this->publishItem($item),
'delete' => $this->deleteItem($item),
'apply_category' => $this->applyCategory($item, (int) ($params['category_id'] ?? 0)),
'apply_tags' => $this->applyTags($item, (array) ($params['tags'] ?? [])),
'set_visibility' => $this->setVisibility($item, (string) ($params['visibility'] ?? '')),
'generate_ai' => $this->retryProcessing($item),
default => throw ValidationException::withMessages([
'action' => ['Unsupported upload queue action.'],
]),
};
$success++;
} catch (ValidationException $exception) {
$failed++;
$errors[] = collect($exception->errors())->flatten()->first() ?: 'Action failed.';
} catch (\Throwable $exception) {
$failed++;
$errors[] = $exception->getMessage();
}
}
$batchIds = $items->pluck('upload_batch_id')->unique()->filter()->all();
foreach ($batchIds as $batchId) {
$this->refreshBatch((int) $batchId);
}
return [
'success' => $success,
'failed' => $failed,
'errors' => $errors,
];
}
public function retryProcessingForUser(User $user, int $itemId): UploadBatchItem
{
$item = $this->ownedItems($user, [$itemId])->first();
if (! $item) {
throw ValidationException::withMessages([
'item' => ['Upload queue item not found.'],
]);
}
return $this->retryProcessing($item);
}
private function retryProcessing(UploadBatchItem $item): UploadBatchItem
{
$artwork = $item->artwork;
if (! $artwork || trim((string) ($artwork->hash ?? '')) === '' || trim((string) ($artwork->file_path ?? '')) === '') {
throw ValidationException::withMessages([
'item' => ['This item cannot be retried safely. Re-upload the original file instead.'],
]);
}
$item->forceFill([
'status' => UploadBatchItem::STATUS_PROCESSING,
'processing_stage' => UploadBatchItem::STAGE_MATURITY_CHECK,
'error_code' => null,
'error_message' => null,
'is_ready_to_publish' => false,
])->save();
AutoTagArtworkJob::dispatch((int) $artwork->id, (string) $artwork->hash)->afterCommit();
DetectArtworkMaturityJob::dispatch((int) $artwork->id, (string) $artwork->hash)->afterCommit();
GenerateArtworkEmbeddingJob::dispatch((int) $artwork->id, (string) $artwork->hash)->afterCommit();
AnalyzeArtworkAiAssistJob::dispatch((int) $artwork->id, true)->afterCommit();
return $this->refreshItem((int) $item->id);
}
private function publishItem(UploadBatchItem $item): void
{
$item = $this->refreshItem((int) $item->id);
if (! $item->is_ready_to_publish || ! $item->artwork) {
throw ValidationException::withMessages([
'item' => ['Only ready queue items can be published.'],
]);
}
$artwork = $item->artwork;
$artwork->forceFill([
'is_approved' => true,
'visibility' => $artwork->visibility ?: Artwork::VISIBILITY_PUBLIC,
])->saveQuietly();
$this->publication->publishNow($artwork->fresh() ?? $artwork);
$item->forceFill([
'status' => UploadBatchItem::STATUS_PUBLISHED,
'processing_stage' => UploadBatchItem::STAGE_FINALIZED,
'is_ready_to_publish' => false,
'published_at' => now(),
])->save();
}
private function deleteItem(UploadBatchItem $item): void
{
if ($item->artwork && ! $item->artwork->trashed()) {
$item->artwork->delete();
}
$item->forceFill([
'status' => UploadBatchItem::STATUS_DELETED,
'processing_stage' => UploadBatchItem::STAGE_FINALIZED,
'is_ready_to_publish' => false,
])->save();
}
private function applyCategory(UploadBatchItem $item, int $categoryId): void
{
if ($categoryId <= 0 || ! $item->artwork) {
throw ValidationException::withMessages([
'category_id' => ['Choose a valid category.'],
]);
}
$item->artwork->categories()->sync([$categoryId]);
$this->refreshItem((int) $item->id);
}
private function applyTags(UploadBatchItem $item, array $tags): void
{
if (! $item->artwork) {
throw ValidationException::withMessages([
'tags' => ['Artwork not found for this queue item.'],
]);
}
$normalizedTags = collect($tags)
->map(fn (mixed $tag): string => trim((string) $tag))
->filter()
->values();
if ($normalizedTags->isEmpty()) {
throw ValidationException::withMessages([
'tags' => ['Enter at least one tag to apply.'],
]);
}
$mergedTags = collect($item->artwork->tags()->pluck('tags.slug')->all())
->merge($normalizedTags)
->map(fn (string $tag): string => trim($tag))
->filter()
->unique()
->values()
->all();
$this->tags->syncStudioTags($item->artwork->fresh(['tags']) ?? $item->artwork, $mergedTags);
$this->refreshItem((int) $item->id);
}
private function setVisibility(UploadBatchItem $item, string $visibility): void
{
if (! in_array($visibility, [Artwork::VISIBILITY_PUBLIC, Artwork::VISIBILITY_UNLISTED, Artwork::VISIBILITY_PRIVATE], true) || ! $item->artwork) {
throw ValidationException::withMessages([
'visibility' => ['Choose a valid visibility.'],
]);
}
$item->artwork->forceFill([
'visibility' => $visibility,
'is_public' => false,
])->saveQuietly();
$this->refreshItem((int) $item->id);
}
private function ownedItems(User $user, array $itemIds): EloquentCollection
{
$normalizedIds = collect($itemIds)
->map(fn (mixed $id): int => (int) $id)
->filter(fn (int $id): bool => $id > 0)
->values()
->all();
return $this->itemQuery()
->where('user_id', (int) $user->id)
->whereIn('id', $normalizedIds)
->get();
}
private function itemQuery()
{
return UploadBatchItem::query()->with(['artwork.categories.contentType', 'artwork.tags']);
}
private function applyFiltersAndSorting(Collection $items, string $statusFilter, string $sort): Collection
{
$filtered = $statusFilter === 'all'
? $items
: $items->filter(fn (array $item): bool => (string) ($item['status'] ?? '') === $statusFilter);
return match ($sort) {
'oldest' => $filtered->sortBy('created_at'),
'filename' => $filtered->sortBy(fn (array $item): string => Str::lower((string) ($item['original_filename'] ?? ''))),
'status' => $filtered->sortBy(fn (array $item): string => Str::lower((string) ($item['status'] ?? ''))),
'ready' => $filtered->sortByDesc(fn (array $item): int => (int) ($item['is_ready_to_publish'] ?? false)),
'failed' => $filtered->sortByDesc(fn (array $item): int => (int) ((string) ($item['status'] ?? '') === UploadBatchItem::STATUS_FAILED)),
default => $filtered->sortByDesc('created_at'),
};
}
private function summarizeItems(EloquentCollection $items): array
{
$statusCounts = $items->groupBy('status')->map->count();
$activeCount = (int) ($statusCounts[UploadBatchItem::STATUS_UPLOADED] ?? 0)
+ (int) ($statusCounts[UploadBatchItem::STATUS_PROCESSING] ?? 0);
$failedCount = (int) ($statusCounts[UploadBatchItem::STATUS_FAILED] ?? 0);
$publishedCount = (int) ($statusCounts[UploadBatchItem::STATUS_PUBLISHED] ?? 0);
$processedCount = $items->filter(fn (UploadBatchItem $item): bool => in_array((string) $item->status, [
UploadBatchItem::STATUS_NEEDS_METADATA,
UploadBatchItem::STATUS_NEEDS_REVIEW,
UploadBatchItem::STATUS_READY,
UploadBatchItem::STATUS_FAILED,
UploadBatchItem::STATUS_PUBLISHED,
UploadBatchItem::STATUS_DELETED,
], true))->count();
$status = UploadBatch::STATUS_COMPLETED;
if ($activeCount > 0) {
$status = $statusCounts[UploadBatchItem::STATUS_PROCESSING] ?? 0
? UploadBatch::STATUS_PROCESSING
: UploadBatch::STATUS_UPLOADING;
} elseif ($failedCount > 0) {
$status = UploadBatch::STATUS_COMPLETED_WITH_ERRORS;
}
return [
'status' => $status,
'total_items' => $items->count(),
'processed_items' => $processedCount,
'failed_items' => $failedCount,
'published_items' => $publishedCount,
'ready_items' => (int) ($statusCounts[UploadBatchItem::STATUS_READY] ?? 0),
'processing_items' => $activeCount,
'needs_metadata_items' => (int) ($statusCounts[UploadBatchItem::STATUS_NEEDS_METADATA] ?? 0),
'needs_review_items' => (int) ($statusCounts[UploadBatchItem::STATUS_NEEDS_REVIEW] ?? 0),
];
}
private function evaluateItemState(UploadBatchItem $item): array
{
$artwork = $item->artwork;
$isDeleted = (string) $item->status === UploadBatchItem::STATUS_DELETED || ($artwork?->trashed() ?? false);
$isPublished = $artwork
&& (string) ($artwork->artwork_status ?? '') === 'published'
&& $artwork->published_at !== null;
$hasProcessedMedia = $artwork
&& trim((string) ($artwork->file_name ?? '')) !== ''
&& trim((string) ($artwork->file_name ?? '')) !== 'pending'
&& trim((string) ($artwork->file_path ?? '')) !== ''
&& trim((string) ($artwork->hash ?? '')) !== '';
$title = trim((string) ($artwork?->title ?? ''));
$hasTitle = $title !== '';
$hasCategory = (bool) $artwork?->categories?->first();
$maturityStatus = Str::lower((string) ($artwork?->maturity_status ?? ArtworkMaturityService::STATUS_CLEAR));
$maturityAiStatus = Str::lower((string) ($artwork?->maturity_ai_status ?? ArtworkMaturityService::AI_STATUS_NOT_REQUESTED));
$aiStatus = Str::lower((string) ($artwork?->ai_status ?? ''));
$visionEnabled = (bool) config('vision.enabled', true);
$maturityPending = $visionEnabled && in_array($maturityAiStatus, [
ArtworkMaturityService::AI_STATUS_PENDING,
ArtworkMaturityService::AI_STATUS_NOT_REQUESTED,
], true);
$maturityFailed = $visionEnabled && $maturityAiStatus === ArtworkMaturityService::AI_STATUS_FAILED;
$needsReview = $maturityStatus === ArtworkMaturityService::STATUS_SUSPECTED || $maturityFailed;
$needsMetadata = ! $hasTitle || ! $hasCategory;
$blockingUploadFailure = ! $hasProcessedMedia && ($this->nullableString($item->error_code) !== null || $this->nullableText($item->error_message) !== null);
$isReadyToPublish = ! $isDeleted
&& ! $isPublished
&& ! $blockingUploadFailure
&& $hasProcessedMedia
&& ! $maturityPending
&& ! $needsReview
&& ! $needsMetadata
&& $artwork
&& (int) $artwork->user_id === (int) $item->user_id;
$status = match (true) {
$isDeleted => UploadBatchItem::STATUS_DELETED,
$isPublished => UploadBatchItem::STATUS_PUBLISHED,
$blockingUploadFailure => UploadBatchItem::STATUS_FAILED,
! $hasProcessedMedia || $maturityPending => UploadBatchItem::STATUS_PROCESSING,
$needsReview => UploadBatchItem::STATUS_NEEDS_REVIEW,
$needsMetadata => UploadBatchItem::STATUS_NEEDS_METADATA,
$isReadyToPublish => UploadBatchItem::STATUS_READY,
default => UploadBatchItem::STATUS_PROCESSING,
};
$processingStage = match (true) {
in_array($status, [UploadBatchItem::STATUS_DELETED, UploadBatchItem::STATUS_PUBLISHED, UploadBatchItem::STATUS_FAILED], true) => UploadBatchItem::STAGE_FINALIZED,
! $hasProcessedMedia && (string) $item->processing_stage === UploadBatchItem::STAGE_QUEUED => UploadBatchItem::STAGE_QUEUED,
! $hasProcessedMedia => UploadBatchItem::STAGE_THUMBNAILS,
$maturityPending => UploadBatchItem::STAGE_MATURITY_CHECK,
in_array($aiStatus, ['queued', 'processing'], true) => UploadBatchItem::STAGE_METADATA_SUGGESTIONS,
default => UploadBatchItem::STAGE_FINALIZED,
};
return [
'status' => $status,
'processing_stage' => $processingStage,
'metadata_completeness' => $this->metadataCompleteness($artwork, $hasProcessedMedia, ! $maturityPending && ! $maturityFailed),
'is_ready_to_publish' => $isReadyToPublish,
'published_at' => $isPublished ? ($item->published_at ?: now()) : null,
'processed_at' => $hasProcessedMedia ? ($item->processed_at ?: now()) : $item->processed_at,
];
}
private function presentBatch(UploadBatch $batch): array
{
$summary = $this->summarizeItems($batch->items);
return [
'id' => (int) $batch->id,
'name' => $batch->name,
'status' => $summary['status'],
'total_items' => $summary['total_items'],
'processed_items' => $summary['processed_items'],
'failed_items' => $summary['failed_items'],
'published_items' => $summary['published_items'],
'ready_items' => $summary['ready_items'],
'processing_items' => $summary['processing_items'],
'needs_metadata_items' => $summary['needs_metadata_items'],
'needs_review_items' => $summary['needs_review_items'],
'defaults' => $batch->defaults_json ?? [],
'created_at' => $this->iso($batch->created_at),
'updated_at' => $this->iso($batch->updated_at),
];
}
private function presentItem(UploadBatchItem $item): array
{
$artwork = $item->artwork;
$state = $this->evaluateItemState($item);
$maturityStatus = Str::lower((string) ($artwork?->maturity_status ?? ArtworkMaturityService::STATUS_CLEAR));
$maturityAiStatus = Str::lower((string) ($artwork?->maturity_ai_status ?? ArtworkMaturityService::AI_STATUS_NOT_REQUESTED));
$missing = [];
if (! $artwork || trim((string) ($artwork->file_path ?? '')) === '' || trim((string) ($artwork->hash ?? '')) === '') {
$missing[] = 'Processing incomplete';
}
if (! $artwork || trim((string) ($artwork->title ?? '')) === '') {
$missing[] = 'Missing title';
}
if (! $artwork?->categories?->first()) {
$missing[] = 'Missing category';
}
if ($maturityStatus === ArtworkMaturityService::STATUS_SUSPECTED) {
$missing[] = 'Needs maturity review';
} elseif ((bool) config('vision.enabled', true) && in_array($maturityAiStatus, [ArtworkMaturityService::AI_STATUS_PENDING, ArtworkMaturityService::AI_STATUS_NOT_REQUESTED], true)) {
$missing[] = 'Maturity analysis pending';
} elseif ((bool) config('vision.enabled', true) && $maturityAiStatus === ArtworkMaturityService::AI_STATUS_FAILED) {
$missing[] = 'Maturity check failed';
}
$canRetry = ! empty($artwork?->hash)
&& ! empty($artwork?->file_path)
&& ($state['status'] === UploadBatchItem::STATUS_FAILED || $maturityAiStatus === ArtworkMaturityService::AI_STATUS_FAILED);
return [
'id' => (int) $item->id,
'batch_id' => (int) $item->upload_batch_id,
'artwork_id' => $artwork?->id,
'title' => $artwork?->title ?: $this->titleFromFilename((string) $item->original_filename),
'original_filename' => $item->original_filename,
'status' => $state['status'],
'processing_stage' => $state['processing_stage'],
'metadata_completeness' => $state['metadata_completeness'],
'metadata_label' => $state['metadata_completeness'] . '% complete',
'is_ready_to_publish' => $state['is_ready_to_publish'],
'error_code' => $item->error_code,
'error_message' => $item->error_message,
'missing' => $missing,
'thumbnail_url' => $artwork?->thumbUrl('sm'),
'visibility' => $artwork?->visibility,
'maturity_status' => $maturityStatus,
'maturity_ai_status' => $maturityAiStatus,
'ai_status' => Str::lower((string) ($artwork?->ai_status ?? '')) ?: null,
'created_at' => $this->iso($item->created_at),
'updated_at' => $this->iso($item->updated_at),
'published_at' => $this->iso($item->published_at),
'edit_url' => $artwork ? route('studio.artworks.edit', ['id' => $artwork->id]) : null,
'public_url' => $artwork && $artwork->published_at ? route('art.show', ['id' => $artwork->id]) : null,
'actions' => [
'can_edit' => $artwork !== null && $state['status'] !== UploadBatchItem::STATUS_DELETED,
'can_publish' => $state['is_ready_to_publish'],
'can_delete' => ! in_array($state['status'], [UploadBatchItem::STATUS_DELETED, UploadBatchItem::STATUS_PUBLISHED], true),
'can_retry_processing' => $canRetry,
'can_generate_ai' => $artwork !== null && trim((string) ($artwork->hash ?? '')) !== '',
],
];
}
private function metadataCompleteness(?Artwork $artwork, bool $hasProcessedMedia, bool $maturityReady): int
{
$checks = [
$hasProcessedMedia,
trim((string) ($artwork?->title ?? '')) !== '',
(bool) $artwork?->categories?->first(),
$maturityReady,
];
return (int) round((collect($checks)->filter()->count() / count($checks)) * 100);
}
private function normalizeDefaults(array $defaults): array
{
$visibility = (string) ($defaults['visibility'] ?? Artwork::VISIBILITY_PUBLIC);
if (! in_array($visibility, [Artwork::VISIBILITY_PUBLIC, Artwork::VISIBILITY_UNLISTED, Artwork::VISIBILITY_PRIVATE], true)) {
$visibility = Artwork::VISIBILITY_PUBLIC;
}
return array_filter([
'category_id' => ($categoryId = (int) ($defaults['category_id'] ?? 0)) > 0 ? $categoryId : null,
'tags' => collect((array) ($defaults['tags'] ?? []))
->map(fn (mixed $tag): string => trim((string) $tag))
->filter()
->values()
->all(),
'visibility' => $visibility,
'is_mature' => (bool) ($defaults['is_mature'] ?? false),
'group' => $this->nullableString($defaults['group'] ?? null),
], static fn (mixed $value): bool => $value !== null && $value !== []);
}
private function titleFromFilename(string $filename): string
{
$base = pathinfo($filename, PATHINFO_FILENAME);
$normalized = Str::of($base)
->replace(['_', '-'], ' ')
->squish()
->trim();
return (string) ($normalized !== '' ? Str::limit((string) $normalized, 255, '') : 'Untitled artwork');
}
private function normalizeBatchName(?string $name, int $count): string
{
$normalized = trim((string) $name);
if ($normalized !== '') {
return Str::limit($normalized, 160, '');
}
return 'Upload Queue ' . now()->format('M j, Y g:i A') . ' (' . $count . ')';
}
private function iso(CarbonInterface|string|null $value): ?string
{
if ($value instanceof CarbonInterface) {
return $value->toIso8601String();
}
if (is_string($value) && trim($value) !== '') {
return $value;
}
return null;
}
private function nullableString(mixed $value): ?string
{
$normalized = trim((string) $value);
return $normalized === '' ? null : $normalized;
}
private function nullableText(mixed $value): ?string
{
$normalized = trim((string) $value);
return $normalized === '' ? null : Str::limit($normalized, 65535, '');
}
}

View File

@@ -1,8 +1,14 @@
<?php <?php
use App\Http\Middleware\ConditionalShareErrorsFromSession;
use App\Http\Middleware\ConditionalStartSession;
use App\Http\Middleware\ConditionalValidateCsrfToken;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;
return Application::configure(basePath: dirname(__DIR__)) return Application::configure(basePath: dirname(__DIR__))
->withRouting( ->withRouting(
@@ -13,9 +19,16 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
$middleware->web(replace: [
StartSession::class => ConditionalStartSession::class,
ShareErrorsFromSession::class => ConditionalShareErrorsFromSession::class,
ValidateCsrfToken::class => ConditionalValidateCsrfToken::class,
]);
$middleware->validateCsrfTokens(except: [ $middleware->validateCsrfTokens(except: [
'chat_post', 'chat_post',
'chat_post/*', 'chat_post/*',
'api/art/*/view',
]); ]);
$middleware->web(append: [ $middleware->web(append: [

View File

@@ -13,7 +13,7 @@ return [
| |
*/ */
'name' => env('APP_NAME', 'Laravel'), 'name' => env('APP_NAME', 'SkinbaseNova'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@@ -52,7 +52,7 @@ return [
| |
*/ */
'url' => env('APP_URL', 'http://localhost'), 'url' => env('APP_URL', 'https://skinbase.org'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@@ -65,7 +65,7 @@ return [
| |
*/ */
'timezone' => 'UTC', 'timezone' => 'Europe/Ljubljana',
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------

View File

@@ -16,7 +16,7 @@ return [
| |
*/ */
'default' => env('DB_CONNECTION', 'sqlite'), 'default' => env('DB_CONNECTION', 'mysql'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@@ -198,6 +198,19 @@ return [
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
], ],
'sessions' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_SESSION_DB', '2'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
], ],
]; ];

View File

@@ -201,7 +201,7 @@ return [
'defaults' => [ 'defaults' => [
'supervisor-default' => [ 'supervisor-default' => [
'connection' => 'redis', 'connection' => 'redis',
'queue' => ['default'], 'queue' => ['search', 'default'],
'balance' => 'auto', 'balance' => 'auto',
'autoScalingStrategy' => 'time', 'autoScalingStrategy' => 'time',
'maxProcesses' => 1, 'maxProcesses' => 1,

View File

@@ -0,0 +1,124 @@
<?php
return [
'enabled' => env('SKINBASE_CONDITIONAL_SESSIONS_ENABLED', true),
'skip_anonymous_public_get' => env('SKINBASE_SKIP_ANONYMOUS_PUBLIC_GET_SESSIONS', true),
'skip_known_crawlers_on_public_get' => env('SKINBASE_SKIP_BOT_PUBLIC_GET_SESSIONS', true),
'debug_header' => env('SKINBASE_SESSION_DEBUG_HEADER', false),
'public_paths' => [
'/',
'featured',
'uploads/latest',
'uploads/daily',
'members/photos',
'downloads/today',
'comments/monthly',
'discover',
'discover/*',
'explore',
'explore/*',
'blog',
'blog/*',
'pages/*',
'about',
'help',
'help/*',
'contact',
'faq',
'rules-and-guidelines',
'privacy-policy',
'terms-of-service',
'staff',
'bug-report',
'rss-feeds',
'rss',
'rss/*',
'news',
'news/*',
'worlds',
'worlds/*',
'creators',
'creators/*',
'stories',
'stories/*',
'tags',
'tags/*',
'categories',
'leaderboard',
'art',
'art/*',
'sitemap.xml',
'sitemaps/*',
'robots.txt',
],
'always_session_paths' => [
'login',
'logout',
'register',
'register/*',
'auth/*',
'forgot-password',
'reset-password',
'reset-password/*',
'confirm-password',
'email/verification-notification',
'verify-email',
'verify-email/*',
'setup/*',
'dashboard',
'dashboard/*',
'manage',
'studio',
'studio/*',
'upload',
'upload/*',
'settings',
'settings/*',
'messages',
'messages/*',
'worlds/create',
'cp',
'cp/*',
'admin',
'admin/*',
'api/me',
'api/auth/*',
],
'bot_user_agent_keywords' => [
'googlebot',
'bingbot',
'slurp',
'duckduckbot',
'baiduspider',
'yandexbot',
'sogou',
'exabot',
'facebot',
'facebookexternalhit',
'ia_archiver',
'semrushbot',
'ahrefsbot',
'mj12bot',
'dotbot',
'petalbot',
'applebot',
'twitterbot',
'linkedinbot',
'discordbot',
'telegrambot',
'whatsapp',
'crawler',
'spider',
'bot',
],
];

View File

@@ -0,0 +1,53 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('upload_batches', function (Blueprint $table) {
$table->bigIncrements('id');
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('name', 160)->nullable();
$table->string('status', 32)->default('uploading')->index();
$table->unsignedInteger('total_items')->default(0);
$table->unsignedInteger('processed_items')->default(0);
$table->unsignedInteger('failed_items')->default(0);
$table->unsignedInteger('published_items')->default(0);
$table->json('defaults_json')->nullable();
$table->timestamps();
$table->index(['user_id', 'created_at'], 'upload_batches_user_created_idx');
});
Schema::create('upload_batch_items', function (Blueprint $table) {
$table->bigIncrements('id');
$table->foreignId('upload_batch_id')->constrained('upload_batches')->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('artwork_id')->nullable()->constrained('artworks')->nullOnDelete();
$table->string('original_filename');
$table->string('status', 32)->default('uploaded')->index();
$table->string('processing_stage', 32)->default('queued')->index();
$table->string('error_code', 64)->nullable();
$table->text('error_message')->nullable();
$table->unsignedTinyInteger('metadata_completeness')->default(0);
$table->boolean('is_ready_to_publish')->default(false)->index();
$table->timestamp('uploaded_at')->nullable();
$table->timestamp('processed_at')->nullable();
$table->timestamp('published_at')->nullable();
$table->timestamps();
$table->index(['upload_batch_id', 'status'], 'upload_batch_items_batch_status_idx');
$table->index(['user_id', 'status'], 'upload_batch_items_user_status_idx');
});
}
public function down(): void
{
Schema::dropIfExists('upload_batch_items');
Schema::dropIfExists('upload_batches');
}
};

View File

@@ -1,10 +1,10 @@
[program:skinbase-queue] [program:skinbase-queue]
command=/usr/bin/php /var/www/skinbase/artisan queue:work --sleep=3 --tries=3 --timeout=90 --queue=search,forum-security,forum-moderation,vision,recommendations,discovery,mail,default command=/usr/bin/php /var/www/SkinbaseNova/artisan queue:work --sleep=3 --tries=3 --timeout=90 --queue=search,forum-security,forum-moderation,vision,recommendations,discovery,mail,default
process_name=%(program_name)s_%(process_num)02d process_name=%(program_name)s_%(process_num)02d
numprocs=1 numprocs=1
autostart=true autostart=true
autorestart=true autorestart=true
user=www-data user=www-data
redirect_stderr=true redirect_stderr=true
stdout_logfile=/var/log/skinbase/queue.log stdout_logfile=/var/log/skinbase_queue.log
stopwaitsecs=3600 stopwaitsecs=3600

View File

@@ -7,8 +7,8 @@ User=www-data
Group=www-data Group=www-data
Restart=always Restart=always
RestartSec=3 RestartSec=3
WorkingDirectory=/var/www/skinbase WorkingDirectory=/opt/www/virtual/SkinbaseNova
ExecStart=/usr/bin/php /var/www/skinbase/artisan queue:work --sleep=3 --tries=3 --timeout=90 --queue=search,forum-security,forum-moderation,vision,recommendations,discovery,mail,default ExecStart=/usr/bin/php /opt/www/virtual/SkinbaseNova/artisan queue:work --sleep=3 --tries=3 --timeout=90 --queue=search,forum-security,forum-moderation,vision,recommendations,discovery,mail,default
StandardOutput=syslog StandardOutput=syslog
StandardError=syslog StandardError=syslog
SyslogIdentifier=skinbase-queue SyslogIdentifier=skinbase-queue

View File

@@ -15,6 +15,7 @@ const baseNavGroups = [
label: 'Create', label: 'Create',
items: [ items: [
{ label: 'New Artwork', href: '/upload', icon: 'fa-solid fa-cloud-arrow-up' }, { label: 'New Artwork', href: '/upload', icon: 'fa-solid fa-cloud-arrow-up' },
{ label: 'Upload Queue', href: '/studio/upload-queue', icon: 'fa-solid fa-layer-group' },
{ label: 'New Card', href: '/studio/cards/create', icon: 'fa-solid fa-id-card' }, { label: 'New Card', href: '/studio/cards/create', icon: 'fa-solid fa-id-card' },
{ label: 'New Story', href: '/creator/stories/create', icon: 'fa-solid fa-feather-pointed' }, { label: 'New Story', href: '/creator/stories/create', icon: 'fa-solid fa-feather-pointed' },
{ label: 'New Collection', href: '/settings/collections/create', icon: 'fa-solid fa-layer-group' }, { label: 'New Collection', href: '/settings/collections/create', icon: 'fa-solid fa-layer-group' },
@@ -34,6 +35,7 @@ const baseNavGroups = [
label: 'Library', label: 'Library',
items: [ items: [
{ label: 'Drafts', href: '/studio/drafts', icon: 'fa-solid fa-file-pen' }, { label: 'Drafts', href: '/studio/drafts', icon: 'fa-solid fa-file-pen' },
{ label: 'Upload Queue', href: '/studio/upload-queue', icon: 'fa-solid fa-list-check' },
{ label: 'Scheduled', href: '/studio/scheduled', icon: 'fa-solid fa-calendar-days' }, { label: 'Scheduled', href: '/studio/scheduled', icon: 'fa-solid fa-calendar-days' },
{ label: 'Calendar', href: '/studio/calendar', icon: 'fa-solid fa-calendar-range' }, { label: 'Calendar', href: '/studio/calendar', icon: 'fa-solid fa-calendar-range' },
{ label: 'Archived', href: '/studio/archived', icon: 'fa-solid fa-box-archive' }, { label: 'Archived', href: '/studio/archived', icon: 'fa-solid fa-box-archive' },
@@ -168,25 +170,40 @@ export default function StudioLayout({ children, title, subtitle, actions }) {
const studioGroups = Array.isArray(props.studio_groups) ? props.studio_groups : [] const studioGroups = Array.isArray(props.studio_groups) ? props.studio_groups : []
const currentGroup = props.studioGroup || null const currentGroup = props.studioGroup || null
const canManageNews = Boolean(props.auth?.user?.is_admin || props.auth?.user?.is_moderator) const canManageNews = Boolean(props.auth?.user?.is_admin || props.auth?.user?.is_moderator)
const canManageWorlds = canManageNews
const navGroups = baseNavGroups.map((group) => { const navGroups = baseNavGroups.map((group) => {
if (!canManageNews || group.label !== 'Content') { if ((!canManageNews && !canManageWorlds) || group.label !== 'Content') {
return group return group
} }
const extraItems = []
if (canManageNews) {
extraItems.push({ label: 'News', href: '/studio/news', icon: 'fa-solid fa-newspaper' })
}
if (canManageWorlds) {
extraItems.push({ label: 'Worlds', href: '/studio/worlds', icon: 'fa-solid fa-globe' })
}
return { return {
...group, ...group,
items: [ items: [...group.items, ...extraItems],
...group.items,
{ label: 'News', href: '/studio/news', icon: 'fa-solid fa-newspaper' },
],
} }
}) })
const quickCreateItems = (canManageNews const quickCreatePool = [...baseQuickCreateItems]
? [...baseQuickCreateItems, { label: 'News Article', href: '/studio/news/create', icon: 'fa-solid fa-newspaper' }]
: baseQuickCreateItems if (canManageNews) {
).map((item) => { quickCreatePool.push({ label: 'News Article', href: '/studio/news/create', icon: 'fa-solid fa-newspaper' })
}
if (canManageWorlds) {
quickCreatePool.push({ label: 'World', href: '/studio/worlds/create', icon: 'fa-solid fa-globe' })
}
const quickCreateItems = quickCreatePool.map((item) => {
if (currentGroup?.urls && item.label === 'Artwork') { if (currentGroup?.urls && item.label === 'Artwork') {
return { ...item, href: currentGroup.urls?.studio_artworks ? `/upload?group=${currentGroup.slug}` : item.href } return { ...item, href: currentGroup.urls?.studio_artworks ? `/upload?group=${currentGroup.slug}` : item.href }
} }

View File

@@ -107,7 +107,7 @@ export default function ArtworkMaturityQueue() {
], [stats]) ], [stats])
return ( return (
<div className="mx-auto max-w-7xl px-4 pb-16 pt-8 sm:px-6 lg:px-8"> <div className="w-full pb-16 pt-8">
<Head title="Artwork Maturity Queue" /> <Head title="Artwork Maturity Queue" />
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.16),transparent_36%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.88))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]"> <section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.16),transparent_36%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.88))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">

View File

@@ -0,0 +1,941 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
const MIN_CHUNK_SIZE_BYTES = 256 * 1024
function formatDate(value) {
if (!value) return 'Just now'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return 'Just now'
return date.toLocaleString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
}
function formatPercent(value) {
const normalized = Number(value || 0)
if (!Number.isFinite(normalized)) return '0%'
return `${Math.max(0, Math.min(100, Math.round(normalized)))}%`
}
function parseTags(raw) {
return String(raw || '')
.split(/[\n,]+/)
.map((tag) => tag.trim())
.filter(Boolean)
}
function statusClasses(status) {
switch (status) {
case 'ready':
return 'border-emerald-400/30 bg-emerald-400/10 text-emerald-100'
case 'published':
return 'border-sky-400/30 bg-sky-400/10 text-sky-100'
case 'failed':
return 'border-rose-400/30 bg-rose-400/10 text-rose-100'
case 'needs_review':
case 'needs_metadata':
return 'border-amber-400/30 bg-amber-400/10 text-amber-100'
default:
return 'border-white/15 bg-white/5 text-slate-300'
}
}
function batchStatusClasses(status) {
switch (status) {
case 'completed':
return 'border-emerald-400/30 bg-emerald-400/10 text-emerald-100'
case 'completed_with_errors':
return 'border-amber-400/30 bg-amber-400/10 text-amber-100'
case 'processing':
return 'border-sky-400/30 bg-sky-400/10 text-sky-100'
default:
return 'border-white/15 bg-white/5 text-slate-300'
}
}
function noticeClasses(type) {
switch (type) {
case 'success':
return 'border-emerald-400/30 bg-emerald-400/10 text-emerald-100'
case 'warning':
return 'border-amber-400/30 bg-amber-400/10 text-amber-100'
default:
return 'border-rose-400/30 bg-rose-400/10 text-rose-100'
}
}
function humanStage(stage) {
return String(stage || 'queued').replace(/_/g, ' ')
}
function flattenCategories(contentTypes) {
return (Array.isArray(contentTypes) ? contentTypes : []).flatMap((type) => {
const parents = Array.isArray(type?.categories) ? type.categories : []
return parents.flatMap((category) => {
const children = Array.isArray(category?.children) ? category.children : []
if (children.length === 0) {
return [{
id: category.id,
label: `${type.name} / ${category.name}`,
}]
}
return children.map((child) => ({
id: child.id,
label: `${type.name} / ${category.name} / ${child.name}`,
}))
})
})
}
function SummaryCard({ label, value, hint }) {
return (
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{label}</p>
<p className="mt-3 text-3xl font-semibold text-white">{value}</p>
<p className="mt-2 text-sm text-slate-400">{hint}</p>
</div>
)
}
export default function StudioUploadQueue() {
const { props } = usePage()
const queueProp = props.queue || {}
const chunkSize = Math.max(MIN_CHUNK_SIZE_BYTES, Number(props.chunkSize || 0) || (5 * 1024 * 1024))
const chunkRequestTimeoutMs = Math.max(15000, Number(props.chunkRequestTimeoutMs || 0) || 45000)
const categoryOptions = useMemo(() => flattenCategories(props.contentTypes || []), [props.contentTypes])
const [queue, setQueue] = useState(queueProp)
const [selectedBatchId, setSelectedBatchId] = useState(queueProp?.filters?.batch_id ?? queueProp?.current_batch?.id ?? '')
const [statusFilter, setStatusFilter] = useState(queueProp?.filters?.status || 'all')
const [sort, setSort] = useState(queueProp?.filters?.sort || 'newest')
const [selectedIds, setSelectedIds] = useState([])
const [files, setFiles] = useState([])
const [uploading, setUploading] = useState(false)
const [uploadState, setUploadState] = useState({})
const [notice, setNotice] = useState(null)
const [busyAction, setBusyAction] = useState('')
const [defaults, setDefaults] = useState({
name: '',
categoryId: '',
tags: '',
visibility: 'public',
isMature: false,
})
const [bulkForm, setBulkForm] = useState({
categoryId: '',
tags: '',
visibility: 'public',
})
const fileInputRef = useRef(null)
const noticeTimeoutRef = useRef(null)
const items = Array.isArray(queue?.items) ? queue.items : []
const currentBatch = queue?.current_batch || null
const batches = Array.isArray(queue?.batches) ? queue.batches : []
const selectableIds = items
.filter((item) => item?.actions?.can_delete || item?.actions?.can_publish || item?.actions?.can_generate_ai)
.map((item) => Number(item.id))
const allSelected = selectableIds.length > 0 && selectableIds.every((id) => selectedIds.includes(id))
const activeProcessing = uploading || ['uploading', 'processing'].includes(String(currentBatch?.status || ''))
const pushNotice = (type, message) => {
setNotice({ type, message })
window.clearTimeout(noticeTimeoutRef.current)
noticeTimeoutRef.current = window.setTimeout(() => setNotice(null), 4500)
}
useEffect(() => () => window.clearTimeout(noticeTimeoutRef.current), [])
const syncSelectedIds = (queueItems) => {
const validIds = new Set((queueItems || []).map((item) => Number(item.id)))
setSelectedIds((current) => current.filter((id) => validIds.has(id)))
}
const loadQueue = async (overrides = {}) => {
const params = {
batch_id: overrides.batch_id ?? (selectedBatchId || undefined),
status: overrides.status ?? statusFilter,
sort: overrides.sort ?? sort,
}
try {
const response = await window.axios.get('/api/studio/upload-queue', { params })
const nextQueue = response.data || {}
setQueue(nextQueue)
setSelectedBatchId(nextQueue?.filters?.batch_id ?? '')
syncSelectedIds(nextQueue?.items || [])
return nextQueue
} catch (error) {
pushNotice('error', error?.response?.data?.message || 'Failed to refresh the upload queue.')
return null
}
}
useEffect(() => {
if (!activeProcessing || !selectedBatchId) return undefined
const timer = window.setInterval(() => {
loadQueue({ batch_id: selectedBatchId })
}, 3000)
return () => window.clearInterval(timer)
}, [activeProcessing, selectedBatchId, statusFilter, sort])
const uploadChunk = async (sessionId, uploadToken, blob, offset, totalSize) => {
const payload = new FormData()
payload.append('session_id', sessionId)
payload.append('offset', String(offset))
payload.append('chunk_size', String(blob.size))
payload.append('total_size', String(totalSize))
payload.append('chunk', blob)
payload.append('upload_token', uploadToken)
const response = await window.axios.post('/api/uploads/chunk', payload, {
timeout: chunkRequestTimeoutMs,
headers: { 'X-Upload-Token': uploadToken },
})
return response.data || {}
}
const uploadSingleFile = async (item, file) => {
const init = await window.axios.post('/api/uploads/init', { client: 'web' })
const sessionId = init?.data?.session_id
const uploadToken = init?.data?.upload_token
if (!sessionId || !uploadToken) {
throw new Error('Upload session initialization failed.')
}
let offset = 0
while (offset < file.size) {
const nextOffset = Math.min(offset + chunkSize, file.size)
const chunk = file.slice(offset, nextOffset)
const data = await uploadChunk(sessionId, uploadToken, chunk, offset, file.size)
offset = Number(data?.received_bytes ?? nextOffset)
const progress = Math.max(1, Math.min(100, Math.round((offset / file.size) * 100)))
setUploadState((current) => ({
...current,
[item.id]: {
...current[item.id],
status: 'uploading',
progress,
},
}))
}
await window.axios.post('/api/uploads/finish', {
session_id: sessionId,
artwork_id: item.artwork_id,
batch_item_id: item.id,
file_name: file.name,
upload_token: uploadToken,
}, {
headers: { 'X-Upload-Token': uploadToken },
})
setUploadState((current) => ({
...current,
[item.id]: {
status: 'processing',
progress: 100,
},
}))
}
const markItemFailed = async (itemId, error) => {
try {
await window.axios.post(`/api/studio/upload-queue/items/${itemId}/fail`, {
error_code: error?.response?.data?.reason || 'upload_failed',
error_message: error?.response?.data?.message || error?.message || 'Upload failed.',
})
} catch (markError) {
// Keep the original upload error as the visible one.
}
}
const startUpload = async () => {
if (files.length === 0) {
pushNotice('error', 'Choose at least one image file to start a batch.')
return
}
setUploading(true)
setBusyAction('create-batch')
try {
const response = await window.axios.post('/api/studio/upload-queue/batches', {
name: defaults.name || null,
files: files.map((file) => ({ name: file.name })),
defaults: {
category_id: defaults.categoryId ? Number(defaults.categoryId) : null,
tags: parseTags(defaults.tags),
visibility: defaults.visibility,
is_mature: Boolean(defaults.isMature),
},
})
const createdItems = Array.isArray(response?.data?.items) ? response.data.items : []
const batchId = response?.data?.batch?.id
if (!batchId || createdItems.length !== files.length) {
throw new Error('Batch registration did not return a usable file map.')
}
setQueue(response.data.queue || queue)
setSelectedBatchId(batchId)
setSelectedIds([])
for (let index = 0; index < createdItems.length; index += 1) {
const item = createdItems[index]
const file = files[index]
setUploadState((current) => ({
...current,
[item.id]: { status: 'queued', progress: 0 },
}))
try {
await uploadSingleFile(item, file)
} catch (error) {
await markItemFailed(item.id, error)
setUploadState((current) => ({
...current,
[item.id]: {
status: 'failed',
progress: current[item.id]?.progress || 0,
},
}))
}
await loadQueue({ batch_id: batchId })
}
setFiles([])
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
pushNotice('success', 'Upload batch created. Processing continues in the queue.')
} catch (error) {
pushNotice('error', error?.response?.data?.message || error?.message || 'Failed to create the upload batch.')
} finally {
setUploading(false)
setBusyAction('')
}
}
const handleSelectAll = () => {
if (allSelected) {
setSelectedIds([])
return
}
setSelectedIds(selectableIds)
}
const handleToggleSelected = (itemId) => {
setSelectedIds((current) => current.includes(itemId)
? current.filter((id) => id !== itemId)
: [...current, itemId])
}
const summarizePublishSelection = (ids) => {
const selectedItems = items.filter((item) => ids.includes(Number(item.id)))
const readyItems = selectedItems.filter((item) => item?.is_ready_to_publish)
const blockedItems = selectedItems.filter((item) => !item?.is_ready_to_publish)
const reviewBlockedCount = blockedItems.filter((item) => item?.status === 'needs_review').length
const metadataBlockedCount = blockedItems.filter((item) => item?.status === 'needs_metadata').length
const processingBlockedCount = blockedItems.filter((item) => item?.status === 'processing').length
const failedBlockedCount = blockedItems.filter((item) => item?.status === 'failed').length
return {
totalCount: selectedItems.length,
readyCount: readyItems.length,
blockedCount: blockedItems.length,
reviewBlockedCount,
metadataBlockedCount,
processingBlockedCount,
failedBlockedCount,
}
}
const confirmPublishSelection = (ids) => {
const summary = summarizePublishSelection(ids)
if (summary.totalCount === 0) {
pushNotice('warning', 'Select at least one queue item first.')
return false
}
if (summary.readyCount === 0) {
pushNotice('warning', 'None of the selected drafts are ready to publish yet.')
return false
}
const message = [
`Publish ${summary.readyCount} ready draft(s)?`,
`Selected: ${summary.totalCount}`,
`Ready now: ${summary.readyCount}`,
`Blocked and skipped: ${summary.blockedCount}`,
]
if (summary.reviewBlockedCount > 0) {
message.push(`Needs review: ${summary.reviewBlockedCount}`)
}
if (summary.metadataBlockedCount > 0) {
message.push(`Missing metadata: ${summary.metadataBlockedCount}`)
}
if (summary.processingBlockedCount > 0) {
message.push(`Still processing: ${summary.processingBlockedCount}`)
}
if (summary.failedBlockedCount > 0) {
message.push(`Failed items: ${summary.failedBlockedCount}`)
}
message.push('Blocked drafts will not be published.')
return window.confirm(message.join('\n'))
}
const runBulkAction = async (action, params = {}, ids = selectedIds) => {
if (!Array.isArray(ids) || ids.length === 0) {
pushNotice('warning', 'Select at least one queue item first.')
return
}
let confirmValue = undefined
if (action === 'publish') {
if (!confirmPublishSelection(ids)) {
return
}
}
if (action === 'delete') {
const value = window.prompt('Type DELETE to remove the selected drafts from the queue.')
if (value !== 'DELETE') {
return
}
confirmValue = value
}
setBusyAction(action)
try {
const response = await window.axios.post('/api/studio/upload-queue/bulk', {
action,
item_ids: ids,
params,
confirm: confirmValue,
})
const success = Number(response?.data?.success || 0)
const failed = Number(response?.data?.failed || 0)
if (failed > 0 && success === 0) {
pushNotice('error', response?.data?.errors?.[0] || 'The queue action failed.')
} else if (failed > 0) {
pushNotice('warning', `${success} item(s) updated. ${failed} item(s) could not be changed.`)
} else {
pushNotice('success', `${success} item(s) updated.`)
}
await loadQueue({ batch_id: selectedBatchId })
setSelectedIds([])
} catch (error) {
const message = error?.response?.data?.errors?.[0]
|| error?.response?.data?.message
|| 'The queue action failed.'
pushNotice('error', message)
} finally {
setBusyAction('')
}
}
const retryItem = async (itemId) => {
setBusyAction(`retry-${itemId}`)
try {
await window.axios.post(`/api/studio/upload-queue/items/${itemId}/retry`)
pushNotice('success', 'Background processing has been queued again for this draft.')
await loadQueue({ batch_id: selectedBatchId })
} catch (error) {
pushNotice('error', error?.response?.data?.message || 'Retry failed for this queue item.')
} finally {
setBusyAction('')
}
}
const handleBatchChange = async (event) => {
const nextBatchId = event.target.value
setSelectedBatchId(nextBatchId)
await loadQueue({ batch_id: nextBatchId || undefined })
}
const onDropFiles = (event) => {
event.preventDefault()
const dropped = Array.from(event.dataTransfer.files || [])
setFiles(dropped)
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="space-y-6">
{notice && (
<div className={`rounded-[24px] border px-4 py-3 text-sm ${noticeClasses(notice.type)}`}>
{notice.message}
</div>
)}
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.18),_transparent_28%),linear-gradient(135deg,_rgba(15,23,42,0.84),_rgba(2,6,23,0.96))] p-6 shadow-[0_20px_60px_rgba(2,6,23,0.3)]">
<div className="flex flex-col gap-6 xl:flex-row xl:items-start xl:justify-between">
<div className="max-w-3xl">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/70">Bulk upload drafts</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Start a batch, then let Studio handle the review queue.</h2>
<p className="mt-3 text-sm leading-6 text-slate-300">
Each file becomes a normal draft artwork. Upload transport happens now, thumbnail and maturity work continue in the background, and publishing stays blocked until the draft is actually ready.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:w-[380px]">
<SummaryCard label="Selected files" value={files.length} hint="Add multiple images to build a batch." />
<SummaryCard label="Current batch" value={currentBatch?.total_items || 0} hint={currentBatch ? `Status: ${String(currentBatch.status || 'uploading').replace(/_/g, ' ')}` : 'No active batch selected.'} />
</div>
</div>
<div className="mt-6 grid gap-4 lg:grid-cols-[1.2fr,0.8fr]">
<div
className="rounded-[28px] border border-dashed border-white/15 bg-white/[0.03] p-5 transition hover:border-sky-300/35"
onDragOver={(event) => event.preventDefault()}
onDrop={onDropFiles}
>
<div className="flex h-full flex-col justify-between gap-4">
<div>
<p className="text-sm font-semibold text-white">Drag multiple image files here</p>
<p className="mt-2 text-sm text-slate-400">PNG, JPG, and WebP files are supported through the normal upload pipeline. Each file becomes one draft artwork.</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="inline-flex items-center gap-2 rounded-full border border-sky-300/25 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/15"
>
<i className="fa-solid fa-cloud-arrow-up" />
Choose files
</button>
<input
ref={fileInputRef}
type="file"
multiple
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={(event) => setFiles(Array.from(event.target.files || []))}
/>
<span className="text-sm text-slate-500">{files.length > 0 ? `${files.length} file(s) ready` : 'Nothing selected yet'}</span>
</div>
{files.length > 0 && (
<div className="rounded-2xl border border-white/10 bg-slate-950/30 p-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Batch contents</p>
<div className="mt-3 max-h-48 space-y-2 overflow-y-auto pr-1 text-sm text-slate-300">
{files.map((file) => (
<div key={`${file.name}-${file.size}`} className="flex items-center justify-between gap-3 rounded-2xl border border-white/5 bg-white/[0.02] px-3 py-2">
<span className="truncate">{file.name}</span>
<span className="text-xs text-slate-500">{Math.max(1, Math.round(file.size / 1024))} KB</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<p className="text-sm font-semibold text-white">Shared defaults</p>
<div className="mt-4 space-y-4">
<label className="block text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Batch name</span>
<input
value={defaults.name}
onChange={(event) => setDefaults((current) => ({ ...current, name: event.target.value }))}
className="mt-2 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35"
placeholder="Optional batch label"
/>
</label>
<label className="block text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Category</span>
<select
value={defaults.categoryId}
onChange={(event) => setDefaults((current) => ({ ...current, categoryId: event.target.value }))}
className="mt-2 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35"
>
<option value="">No shared category</option>
{categoryOptions.map((option) => (
<option key={option.id} value={option.id} className="bg-slate-950">{option.label}</option>
))}
</select>
</label>
<label className="block text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Visibility when published</span>
<select
value={defaults.visibility}
onChange={(event) => setDefaults((current) => ({ ...current, visibility: event.target.value }))}
className="mt-2 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35"
>
<option value="public" className="bg-slate-950">Public</option>
<option value="unlisted" className="bg-slate-950">Unlisted</option>
<option value="private" className="bg-slate-950">Private</option>
</select>
</label>
<label className="block text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Shared tags</span>
<textarea
value={defaults.tags}
onChange={(event) => setDefaults((current) => ({ ...current, tags: event.target.value }))}
className="mt-2 min-h-[92px] w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35"
placeholder="fantasy, portrait, wallpaper"
/>
</label>
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-300">
<input
type="checkbox"
checked={defaults.isMature}
onChange={(event) => setDefaults((current) => ({ ...current, isMature: event.target.checked }))}
className="h-4 w-4 rounded border-white/20 bg-transparent"
/>
Mark all files as creator-declared mature
</label>
<button
type="button"
onClick={startUpload}
disabled={uploading || files.length === 0}
className="inline-flex w-full items-center justify-center gap-2 rounded-full border border-emerald-400/25 bg-emerald-400/10 px-4 py-3 text-sm font-semibold text-emerald-100 transition hover:border-emerald-400/45 hover:bg-emerald-400/15 disabled:cursor-not-allowed disabled:opacity-50"
>
<i className="fa-solid fa-play" />
{busyAction === 'create-batch' ? 'Creating batch...' : 'Start upload batch'}
</button>
</div>
</div>
</div>
</section>
<section className="rounded-[32px] border border-white/10 bg-white/[0.03] p-6">
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Queue view</p>
<h2 className="mt-2 text-xl font-semibold text-white">Review a batch, then work the drafts that actually need attention.</h2>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<label className="text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Batch</span>
<select
value={selectedBatchId}
onChange={handleBatchChange}
className="mt-2 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35"
>
<option value="">Latest batch</option>
{batches.map((batch) => (
<option key={batch.id} value={batch.id} className="bg-slate-950">
{batch.name || `Batch #${batch.id}`}
</option>
))}
</select>
</label>
<label className="text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Filter</span>
<select
value={statusFilter}
onChange={async (event) => {
const nextStatus = event.target.value
setStatusFilter(nextStatus)
await loadQueue({ status: nextStatus })
}}
className="mt-2 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35"
>
{(queue?.status_options || []).map((option) => (
<option key={option.value} value={option.value} className="bg-slate-950">{option.label}</option>
))}
</select>
</label>
<label className="text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Sort</span>
<select
value={sort}
onChange={async (event) => {
const nextSort = event.target.value
setSort(nextSort)
await loadQueue({ sort: nextSort })
}}
className="mt-2 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35"
>
{(queue?.sort_options || []).map((option) => (
<option key={option.value} value={option.value} className="bg-slate-950">{option.label}</option>
))}
</select>
</label>
</div>
</div>
{currentBatch && (
<div className="mt-6 grid gap-4 md:grid-cols-5">
<SummaryCard label="Batch status" value={String(currentBatch.status || 'uploading').replace(/_/g, ' ')} hint={`Updated ${formatDate(currentBatch.updated_at)}`} />
<SummaryCard label="Ready" value={currentBatch.ready_items || 0} hint="Can be published right now." />
<SummaryCard label="Processing" value={currentBatch.processing_items || 0} hint="Still moving through the pipeline." />
<SummaryCard label="Needs review" value={currentBatch.needs_review_items || 0} hint="Blocked on maturity or review." />
<SummaryCard label="Failed" value={currentBatch.failed_items || 0} hint="Needs retry or a fresh upload." />
</div>
)}
<div className="mt-6 rounded-[28px] border border-white/10 bg-slate-950/35 p-4">
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={handleSelectAll}
disabled={selectableIds.length === 0}
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-4 py-2 text-sm text-slate-200 transition hover:border-white/20 hover:bg-white/[0.06] disabled:cursor-not-allowed disabled:opacity-50"
>
<i className="fa-solid fa-check-double" />
{allSelected ? 'Clear selection' : 'Select visible'}
</button>
<span className="text-sm text-slate-500">{selectedIds.length} selected</span>
</div>
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => runBulkAction('publish')}
disabled={busyAction !== '' || selectedIds.length === 0}
className="inline-flex items-center gap-2 rounded-full border border-emerald-400/25 bg-emerald-400/10 px-4 py-2 text-sm font-semibold text-emerald-100 transition hover:border-emerald-400/40 hover:bg-emerald-400/15 disabled:cursor-not-allowed disabled:opacity-50"
>
Publish selected
</button>
<button
type="button"
onClick={() => runBulkAction('generate_ai')}
disabled={busyAction !== '' || selectedIds.length === 0}
className="inline-flex items-center gap-2 rounded-full border border-sky-300/25 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/15 disabled:cursor-not-allowed disabled:opacity-50"
>
Generate AI
</button>
<button
type="button"
onClick={() => runBulkAction('delete')}
disabled={busyAction !== '' || selectedIds.length === 0}
className="inline-flex items-center gap-2 rounded-full border border-rose-400/25 bg-rose-400/10 px-4 py-2 text-sm font-semibold text-rose-100 transition hover:border-rose-400/40 hover:bg-rose-400/15 disabled:cursor-not-allowed disabled:opacity-50"
>
Delete selected
</button>
</div>
</div>
<div className="mt-4 grid gap-3 lg:grid-cols-[1fr,1fr,auto,auto]">
<select
value={bulkForm.categoryId}
onChange={(event) => setBulkForm((current) => ({ ...current, categoryId: event.target.value }))}
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35"
>
<option value="">Apply category...</option>
{categoryOptions.map((option) => (
<option key={option.id} value={option.id} className="bg-slate-950">{option.label}</option>
))}
</select>
<input
value={bulkForm.tags}
onChange={(event) => setBulkForm((current) => ({ ...current, tags: event.target.value }))}
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35"
placeholder="Add shared tags to selection"
/>
<select
value={bulkForm.visibility}
onChange={(event) => setBulkForm((current) => ({ ...current, visibility: event.target.value }))}
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35"
>
<option value="public" className="bg-slate-950">Public</option>
<option value="unlisted" className="bg-slate-950">Unlisted</option>
<option value="private" className="bg-slate-950">Private</option>
</select>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => runBulkAction('apply_category', { category_id: Number(bulkForm.categoryId) })}
disabled={busyAction !== '' || selectedIds.length === 0 || !bulkForm.categoryId}
className="inline-flex items-center rounded-full border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200 transition hover:border-white/20 hover:bg-white/[0.06] disabled:cursor-not-allowed disabled:opacity-50"
>
Apply category
</button>
<button
type="button"
onClick={() => runBulkAction('apply_tags', { tags: parseTags(bulkForm.tags) })}
disabled={busyAction !== '' || selectedIds.length === 0 || parseTags(bulkForm.tags).length === 0}
className="inline-flex items-center rounded-full border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200 transition hover:border-white/20 hover:bg-white/[0.06] disabled:cursor-not-allowed disabled:opacity-50"
>
Apply tags
</button>
<button
type="button"
onClick={() => runBulkAction('set_visibility', { visibility: bulkForm.visibility })}
disabled={busyAction !== '' || selectedIds.length === 0}
className="inline-flex items-center rounded-full border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200 transition hover:border-white/20 hover:bg-white/[0.06] disabled:cursor-not-allowed disabled:opacity-50"
>
Set visibility
</button>
</div>
</div>
</div>
{items.length === 0 ? (
<div className="mt-6 rounded-[28px] border border-white/10 bg-white/[0.02] p-8 text-center text-sm text-slate-400">
No items match this view yet. Start a batch above or switch to another recent batch.
</div>
) : (
<div className="mt-6 grid gap-4 xl:grid-cols-2">
{items.map((item) => {
const localUpload = uploadState[item.id] || null
const progress = localUpload?.progress ?? null
const actionState = item.actions || {}
return (
<article key={item.id} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_18px_50px_rgba(3,7,18,0.18)]">
<div className="flex items-start gap-4">
<label className="mt-1 inline-flex items-center">
<input
type="checkbox"
checked={selectedIds.includes(Number(item.id))}
onChange={() => handleToggleSelected(Number(item.id))}
className="h-4 w-4 rounded border-white/20 bg-transparent"
/>
</label>
<div className="h-24 w-24 overflow-hidden rounded-2xl border border-white/10 bg-slate-950/60">
{item.thumbnail_url ? (
<img src={item.thumbnail_url} alt={item.title} className="h-full w-full object-cover" />
) : (
<div className="flex h-full items-center justify-center text-slate-500">
<i className="fa-solid fa-image text-2xl" />
</div>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${statusClasses(item.status)}`}>
{String(item.status || 'processing').replace(/_/g, ' ')}
</span>
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${batchStatusClasses(item.processing_stage === 'finalized' ? 'completed' : 'processing')}`}>
{humanStage(item.processing_stage)}
</span>
{item.is_ready_to_publish && (
<span className="inline-flex items-center rounded-full border border-emerald-400/30 bg-emerald-400/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-100">
Ready to publish
</span>
)}
</div>
<h3 className="mt-3 truncate text-lg font-semibold text-white">{item.title}</h3>
<p className="mt-1 truncate text-sm text-slate-400">{item.original_filename}</p>
<div className="mt-4 grid gap-2 text-sm text-slate-300 sm:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-slate-950/30 px-3 py-2">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Metadata</span>
<p className="mt-2 text-white">{item.metadata_label}</p>
</div>
<div className="rounded-2xl border border-white/10 bg-slate-950/30 px-3 py-2">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Updated</span>
<p className="mt-2 text-white">{formatDate(item.updated_at)}</p>
</div>
</div>
{typeof progress === 'number' && progress < 100 && (
<div className="mt-4 rounded-2xl border border-sky-300/20 bg-sky-300/10 px-3 py-3 text-sm text-sky-100">
Uploading now: {formatPercent(progress)}
</div>
)}
{Array.isArray(item.missing) && item.missing.length > 0 && (
<div className="mt-4 rounded-2xl border border-white/10 bg-slate-950/30 px-3 py-3 text-sm text-slate-300">
{item.missing.join(' • ')}
</div>
)}
{item.error_message && (
<div className="mt-4 rounded-2xl border border-rose-400/30 bg-rose-400/10 px-3 py-3 text-sm text-rose-100">
{item.error_message}
</div>
)}
<div className="mt-5 flex flex-wrap gap-2">
{actionState.can_edit && item.edit_url && (
<a href={item.edit_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-3 py-2 text-sm text-slate-200 transition hover:border-white/20 hover:bg-white/[0.06]">
<i className="fa-solid fa-pen-to-square" />
Edit in Studio
</a>
)}
{actionState.can_publish && (
<button
type="button"
onClick={() => runBulkAction('publish', {}, [item.id])}
disabled={busyAction !== ''}
className="inline-flex items-center gap-2 rounded-full border border-emerald-400/25 bg-emerald-400/10 px-3 py-2 text-sm font-semibold text-emerald-100 transition hover:border-emerald-400/40 hover:bg-emerald-400/15 disabled:cursor-not-allowed disabled:opacity-50"
>
<i className="fa-solid fa-rocket" />
Publish
</button>
)}
{actionState.can_generate_ai && (
<button
type="button"
onClick={() => runBulkAction('generate_ai', {}, [item.id])}
disabled={busyAction !== ''}
className="inline-flex items-center gap-2 rounded-full border border-sky-300/25 bg-sky-300/10 px-3 py-2 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/15 disabled:cursor-not-allowed disabled:opacity-50"
>
<i className="fa-solid fa-wand-magic-sparkles" />
Generate AI
</button>
)}
{actionState.can_retry_processing && (
<button
type="button"
onClick={() => retryItem(item.id)}
disabled={busyAction !== ''}
className="inline-flex items-center gap-2 rounded-full border border-amber-400/25 bg-amber-400/10 px-3 py-2 text-sm font-semibold text-amber-100 transition hover:border-amber-400/40 hover:bg-amber-400/15 disabled:cursor-not-allowed disabled:opacity-50"
>
<i className="fa-solid fa-rotate-right" />
Retry
</button>
)}
{actionState.can_delete && (
<button
type="button"
onClick={() => runBulkAction('delete', {}, [item.id])}
disabled={busyAction !== ''}
className="inline-flex items-center gap-2 rounded-full border border-rose-400/25 bg-rose-400/10 px-3 py-2 text-sm font-semibold text-rose-100 transition hover:border-rose-400/40 hover:bg-rose-400/15 disabled:cursor-not-allowed disabled:opacity-50"
>
<i className="fa-solid fa-trash" />
Delete draft
</button>
)}
</div>
</div>
</div>
</article>
)
})}
</div>
)}
</section>
</div>
</StudioLayout>
)
}

View File

@@ -56,6 +56,7 @@ export default function Topbar({ user = null }) {
<div className="border-t border-neutral-700" /> <div className="border-t border-neutral-700" />
<a href={user.uploadUrl} className="block px-4 py-2 text-sm hover:bg-white/5">Upload</a> <a href={user.uploadUrl} className="block px-4 py-2 text-sm hover:bg-white/5">Upload</a>
<a href="/studio/artworks" className="block px-4 py-2 text-sm hover:bg-white/5">Studio</a> <a href="/studio/artworks" className="block px-4 py-2 text-sm hover:bg-white/5">Studio</a>
{user.moderationUrl ? <a href={user.moderationUrl} className="block px-4 py-2 text-sm hover:bg-white/5">Moderation</a> : null}
<a href="/dashboard" className="block px-4 py-2 text-sm hover:bg-white/5">Dashboard</a> <a href="/dashboard" className="block px-4 py-2 text-sm hover:bg-white/5">Dashboard</a>
<div className="border-t border-neutral-700" /> <div className="border-t border-neutral-700" />
<a href="/logout" className="block px-4 py-2 text-sm text-red-400 hover:bg-white/5" <a href="/logout" className="block px-4 py-2 text-sm text-red-400 hover:bg-white/5"

View File

@@ -1,10 +1,12 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { usePage } from '@inertiajs/react'
import ProfileCoverEditor from './ProfileCoverEditor' import ProfileCoverEditor from './ProfileCoverEditor'
import LevelBadge from '../xp/LevelBadge' import LevelBadge from '../xp/LevelBadge'
import XPProgressBar from '../xp/XPProgressBar' import XPProgressBar from '../xp/XPProgressBar'
import FollowButton from '../social/FollowButton' import FollowButton from '../social/FollowButton'
import FollowersPreview from '../social/FollowersPreview' import FollowersPreview from '../social/FollowersPreview'
import MutualFollowersBadge from '../social/MutualFollowersBadge' import MutualFollowersBadge from '../social/MutualFollowersBadge'
import { shinyFlagUrl } from '../../utils/flagUrl'
function formatCompactNumber(value) { function formatCompactNumber(value) {
const numeric = Number(value ?? 0) const numeric = Number(value ?? 0)
@@ -12,11 +14,13 @@ function formatCompactNumber(value) {
} }
export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing, followerCount, recentFollowers = [], followContext = null, heroBgUrl, countryName, leaderboardRank, extraActions = null }) { export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing, followerCount, recentFollowers = [], followContext = null, heroBgUrl, countryName, leaderboardRank, extraActions = null }) {
const { props } = usePage()
const [following, setFollowing] = useState(viewerIsFollowing) const [following, setFollowing] = useState(viewerIsFollowing)
const [count, setCount] = useState(followerCount) const [count, setCount] = useState(followerCount)
const [editorOpen, setEditorOpen] = useState(false) const [editorOpen, setEditorOpen] = useState(false)
const [coverUrl, setCoverUrl] = useState(user?.cover_url || heroBgUrl || null) const [coverUrl, setCoverUrl] = useState(user?.cover_url || heroBgUrl || null)
const [coverPosition, setCoverPosition] = useState(Number.isFinite(user?.cover_position) ? user.cover_position : 50) const [coverPosition, setCoverPosition] = useState(Number.isFinite(user?.cover_position) ? user.cover_position : 50)
const flagUrl = shinyFlagUrl(profile?.country_code, props?.cdn?.files_url)
const uname = user.username || user.name || 'Unknown' const uname = user.username || user.name || 'Unknown'
const displayName = user.name || uname const displayName = user.name || uname
@@ -118,9 +122,9 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
{!isOwner ? <MutualFollowersBadge context={followContext} /> : null} {!isOwner ? <MutualFollowersBadge context={followContext} /> : null}
{countryName ? ( {countryName ? (
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-slate-300"> <span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-slate-300">
{profile?.country_code ? ( {flagUrl ? (
<img <img
src={`/gfx/flags/shiny/24/${encodeURIComponent(String(profile.country_code).toUpperCase())}.png`} src={flagUrl}
alt={countryName} alt={countryName}
className="h-auto w-4 rounded-sm" className="h-auto w-4 rounded-sm"
onError={(event) => { event.target.style.display = 'none' }} onError={(event) => { event.target.style.display = 'none' }}

View File

@@ -1,4 +1,6 @@
import React from 'react' import React from 'react'
import { usePage } from '@inertiajs/react'
import { shinyFlagUrl } from '../../../utils/flagUrl'
const SOCIAL_ICONS = { const SOCIAL_ICONS = {
twitter: { icon: 'fa-brands fa-x-twitter', label: 'X / Twitter' }, twitter: { icon: 'fa-brands fa-x-twitter', label: 'X / Twitter' },
@@ -170,10 +172,12 @@ function SectionCard({ icon, eyebrow, title, children, className = '' }) {
* Bio, social links, metadata - replaces old sidebar profile card. * Bio, social links, metadata - replaces old sidebar profile card.
*/ */
export default function TabAbout({ user, profile, stats, achievements, artworks, creatorStories, profileComments, socialLinks, countryName, followerCount, recentFollowers, leaderboardRank, groupContributionHistory }) { export default function TabAbout({ user, profile, stats, achievements, artworks, creatorStories, profileComments, socialLinks, countryName, followerCount, recentFollowers, leaderboardRank, groupContributionHistory }) {
const { props } = usePage()
const uname = user.username || user.name const uname = user.username || user.name
const displayName = user.name || uname const displayName = user.name || uname
const about = profile?.about const about = profile?.about
const website = profile?.website const website = profile?.website
const flagUrl = shinyFlagUrl(profile?.country_code, props?.cdn?.files_url)
const joinDate = user.created_at const joinDate = user.created_at
? new Date(user.created_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }) ? new Date(user.created_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })
@@ -250,9 +254,9 @@ export default function TabAbout({ user, profile, stats, achievements, artworks,
{countryName ? ( {countryName ? (
<InfoRow icon="fa-earth-americas" label="Country"> <InfoRow icon="fa-earth-americas" label="Country">
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
{profile?.country_code ? ( {flagUrl ? (
<img <img
src={`/gfx/flags/shiny/24/${encodeURIComponent(String(profile.country_code).toUpperCase())}.png`} src={flagUrl}
alt={countryName} alt={countryName}
className="h-auto w-4 rounded-sm" className="h-auto w-4 rounded-sm"
onError={(e) => { e.target.style.display = 'none' }} onError={(e) => { e.target.style.display = 'none' }}

View File

@@ -13,6 +13,7 @@ function mount() {
username: container.dataset.username || '', username: container.dataset.username || '',
avatarUrl: container.dataset.avatarUrl || null, avatarUrl: container.dataset.avatarUrl || null,
uploadUrl: container.dataset.uploadUrl || '/upload', uploadUrl: container.dataset.uploadUrl || '/upload',
moderationUrl: container.dataset.moderationUrl || null,
} }
: null : null

View File

@@ -0,0 +1,12 @@
export function shinyFlagUrl(countryCode, filesCdnUrl = '') {
const normalized = String(countryCode ?? '').trim().toUpperCase()
if (!/^[A-Z]{2}$/.test(normalized)) {
return null
}
const base = String(filesCdnUrl ?? '').replace(/\/+$/, '')
const relativePath = `/images/flags/shiny/24/${encodeURIComponent(normalized)}.png`
return base ? `${base}${relativePath}` : relativePath
}

View File

@@ -192,7 +192,7 @@
@if($countryName) @if($countryName)
<p class="text-[--sb-muted] text-sm mt-1 flex items-center justify-center sm:justify-start gap-1.5"> <p class="text-[--sb-muted] text-sm mt-1 flex items-center justify-center sm:justify-start gap-1.5">
@if($profile?->country_code) @if($profile?->country_code)
<img src="/gfx/flags/shiny/24/{{ rawurlencode($profile->country_code) }}.png" <img src="{{ rtrim((string) config('cdn.files_url', ''), '/') }}/images/flags/shiny/24/{{ rawurlencode($profile->country_code) }}.png"
alt="{{ e($countryName) }}" alt="{{ e($countryName) }}"
class="w-5 h-auto rounded-sm inline-block" class="w-5 h-auto rounded-sm inline-block"
onerror="this.style.display='none'"> onerror="this.style.display='none'">
@@ -434,7 +434,7 @@
<td>Country</td> <td>Country</td>
<td class="flex items-center justify-end gap-1.5"> <td class="flex items-center justify-end gap-1.5">
@if($profile?->country_code) @if($profile?->country_code)
<img src="/gfx/flags/shiny/24/{{ rawurlencode($profile->country_code) }}.png" <img src="{{ rtrim((string) config('cdn.files_url', ''), '/') }}/images/flags/shiny/24/{{ rawurlencode($profile->country_code) }}.png"
alt="{{ e($countryName) }}" alt="{{ e($countryName) }}"
class="w-4 h-auto rounded-sm" class="w-4 h-auto rounded-sm"
onerror="this.style.display='none'"> onerror="this.style.display='none'">

View File

@@ -21,6 +21,7 @@
$comments = $comments ?? []; $comments = $comments ?? [];
$groupSummary = $groupSummary ?? null; $groupSummary = $groupSummary ?? null;
$useUnifiedSeo = true; $useUnifiedSeo = true;
$canReadSessionAuth = request()->hasSession() && ! request()->attributes->get('skinbase.session_skipped');
@endphp @endphp
@push('head') @push('head')
@@ -49,7 +50,7 @@
data-canonical='@json($meta["canonical"])' data-canonical='@json($meta["canonical"])'
data-comments='@json($comments)' data-comments='@json($comments)'
data-group-summary='@json($groupSummary)' data-group-summary='@json($groupSummary)'
data-is-authenticated='@json(auth()->check())'> data-is-authenticated='@json($canReadSessionAuth && auth()->check())'>
</div> </div>
@vite(['resources/js/Pages/ArtworkPage.jsx']) @vite(['resources/js/Pages/ArtworkPage.jsx'])

View File

@@ -1,7 +1,9 @@
@extends('layouts.nova') @extends('layouts.nova')
@push('head') @push('head')
@if(request()->hasSession() && ! request()->attributes->get('skinbase.session_skipped'))
<meta name="csrf-token" content="{{ csrf_token() }}" /> <meta name="csrf-token" content="{{ csrf_token() }}" />
@endif
@vite(['resources/js/collections.jsx']) @vite(['resources/js/collections.jsx'])
<style> <style>
body.page-collections main { padding-top: 4rem; } body.page-collections main { padding-top: 4rem; }

View File

@@ -1,5 +1,7 @@
@php @php
$gridVersion = request()->query('grid') === 'v2' ? 'v2' : 'v1'; $gridVersion = request()->query('grid') === 'v2' ? 'v2' : 'v1';
$skinbaseSessionSkipped = request()->attributes->get('skinbase.session_skipped') === true;
$skinbaseCanUseSession = request()->hasSession() && ! $skinbaseSessionSkipped;
$deferToolbarSearch = request()->routeIs('index'); $deferToolbarSearch = request()->routeIs('index');
$deferFontAwesome = request()->routeIs('index'); $deferFontAwesome = request()->routeIs('index');
$deferWebManifest = request()->routeIs('index'); $deferWebManifest = request()->routeIs('index');
@@ -21,7 +23,9 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@if($skinbaseCanUseSession)
<meta name="csrf-token" content="{{ csrf_token() }}"> <meta name="csrf-token" content="{{ csrf_token() }}">
@endif
@if($shouldRenderBladeSeo) @if($shouldRenderBladeSeo)
@include('partials.seo.head', ['seo' => $seo ?? null]) @include('partials.seo.head', ['seo' => $seo ?? null])
@endif @endif
@@ -235,13 +239,14 @@
<!-- React Topbar mount point --> <!-- React Topbar mount point -->
<div id="topbar-root" <div id="topbar-root"
@auth @if($skinbaseCanUseSession && Auth::check())
data-user-id="{{ Auth::id() }}" data-user-id="{{ Auth::id() }}"
data-display-name="{{ Auth::user()->name ?? '' }}" data-display-name="{{ Auth::user()->name ?? '' }}"
data-username="{{ Auth::user()->username ?? '' }}" data-username="{{ Auth::user()->username ?? '' }}"
data-avatar-url="{{ \App\Support\AvatarUrl::forUser((int) Auth::id(), optional(Auth::user()->profile)->avatar_hash, 64) }}" data-avatar-url="{{ \App\Support\AvatarUrl::forUser((int) Auth::id(), optional(Auth::user()->profile)->avatar_hash, 64) }}"
data-upload-url="{{ Route::has('upload') ? route('upload') : '/upload' }}" data-upload-url="{{ Route::has('upload') ? route('upload') : '/upload' }}"
@endauth data-moderation-url="{{ in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true) ? '/moderation' : '' }}"
@endif
></div> ></div>
@include('layouts.nova.toolbar') @include('layouts.nova.toolbar')
<main class="flex-1 @yield('main-class', 'pt-16')"> <main class="flex-1 @yield('main-class', 'pt-16')">
@@ -252,9 +257,9 @@
{{-- Toast notifications (Alpine) --}} {{-- Toast notifications (Alpine) --}}
@php @php
$toastMessage = session('status') ?? session('error') ?? null; $toastMessage = $skinbaseCanUseSession ? (session('status') ?? session('error') ?? null) : null;
$toastType = session('error') ? 'error' : 'success'; $toastType = $skinbaseCanUseSession && session('error') ? 'error' : 'success';
$toastBorder = session('error') ? 'border-red-500' : 'border-green-500'; $toastBorder = $skinbaseCanUseSession && session('error') ? 'border-red-500' : 'border-green-500';
@endphp @endphp
@if($toastMessage) @if($toastMessage)
<div x-data="{show:true}" x-show="show" x-init="setTimeout(()=>show=false,4000)" x-cloak <div x-data="{show:true}" x-show="show" x-init="setTimeout(()=>show=false,4000)" x-cloak
@@ -262,7 +267,7 @@
<div class="max-w-sm w-full rounded-lg shadow-lg overflow-hidden bg-nova-600 border {{ $toastBorder }}"> <div class="max-w-sm w-full rounded-lg shadow-lg overflow-hidden bg-nova-600 border {{ $toastBorder }}">
<div class="px-4 py-3 flex items-start gap-3"> <div class="px-4 py-3 flex items-start gap-3">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
@if(session('error')) @if($skinbaseCanUseSession && session('error'))
<svg class="w-6 h-6 text-red-200" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 12H6"/></svg> <svg class="w-6 h-6 text-red-200" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 12H6"/></svg>
@else @else
<svg class="w-6 h-6 text-green-200" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg> <svg class="w-6 h-6 text-green-200" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>

View File

@@ -1,3 +1,9 @@
@php
$skinbaseCanUseSession = ($skinbaseCanUseSession ?? false) === true;
$skinbaseToolbarUser = $skinbaseCanUseSession ? Auth::user() : null;
$skinbaseToolbarCanAuth = $skinbaseToolbarUser !== null;
@endphp
<header id="nova-toolbar" class="fixed inset-x-0 top-0 z-50 h-16 bg-black/40 backdrop-blur border-b border-white/10"> <header id="nova-toolbar" class="fixed inset-x-0 top-0 z-50 h-16 bg-black/40 backdrop-blur border-b border-white/10">
<div class="mx-auto w-full h-full px-3 sm:px-4 flex items-center gap-2 sm:gap-3"> <div class="mx-auto w-full h-full px-3 sm:px-4 flex items-center gap-2 sm:gap-3">
@@ -19,7 +25,7 @@
<!-- Logo --> <!-- Logo -->
<a href="/" class="flex items-center gap-2 pr-2 shrink-0"> <a href="/" class="flex items-center gap-2 pr-2 shrink-0">
<img src="/gfx/sb_logo.webp" alt="" width="289" height="100" class="h-9 w-auto rounded-sm shadow-sm object-contain"> <img src="https://cdn.skinbase.org/images/sb_logo.webp" alt="" width="289" height="100" class="h-9 w-auto rounded-sm shadow-sm object-contain">
<span class="sr-only">Skinbase.org</span> <span class="sr-only">Skinbase.org</span>
</a> </a>
@@ -80,11 +86,11 @@
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/discover/on-this-day"> <a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/discover/on-this-day">
<i class="fa-solid fa-calendar-day w-4 text-center text-sb-muted"></i>On This Day <i class="fa-solid fa-calendar-day w-4 text-center text-sb-muted"></i>On This Day
</a> </a>
@auth @if($skinbaseToolbarCanAuth)
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('discover.for-you') }}"> <a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('discover.for-you') }}">
<i class="fa-solid fa-wand-magic-sparkles w-4 text-center"></i>For You <i class="fa-solid fa-wand-magic-sparkles w-4 text-center"></i>For You
</a> </a>
@endauth @endif
</div> </div>
</div> </div>
@@ -165,11 +171,11 @@
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/stories"> <a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/stories">
<i class="fa-solid fa-microphone w-4 text-center text-sb-muted"></i>Creator Stories <i class="fa-solid fa-microphone w-4 text-center text-sb-muted"></i>Creator Stories
</a> </a>
@auth @if($skinbaseToolbarCanAuth)
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('dashboard.following') }}"> <a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('dashboard.following') }}">
<i class="fa-solid fa-user-plus w-4 text-center text-sb-muted"></i>Following <i class="fa-solid fa-user-plus w-4 text-center text-sb-muted"></i>Following
</a> </a>
@endauth @endif
</div> </div>
</div> </div>
@@ -185,7 +191,7 @@
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('community.activity') }}"> <a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('community.activity') }}">
<i class="fa-solid fa-wave-square w-4 text-center text-sb-muted"></i>Activity Feed <i class="fa-solid fa-wave-square w-4 text-center text-sb-muted"></i>Activity Feed
</a> </a>
@auth @if($skinbaseToolbarCanAuth)
<a class="flex items-center justify-between gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('dashboard.comments.received') }}"> <a class="flex items-center justify-between gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('dashboard.comments.received') }}">
<span class="flex items-center gap-3"> <span class="flex items-center gap-3">
<i class="fa-solid fa-inbox w-4 text-center text-sb-muted"></i>Received Comments <i class="fa-solid fa-inbox w-4 text-center text-sb-muted"></i>Received Comments
@@ -194,7 +200,7 @@
<span class="rounded-full border border-cyan-400/25 bg-cyan-500/10 px-2 py-0.5 text-[11px] font-semibold text-cyan-200">{{ $receivedCommentsCount }}</span> <span class="rounded-full border border-cyan-400/25 bg-cyan-500/10 px-2 py-0.5 text-[11px] font-semibold text-cyan-200">{{ $receivedCommentsCount }}</span>
@endif @endif
</a> </a>
@endauth @endif
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/forum"> <a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/forum">
<i class="fa-solid fa-comments w-4 text-center text-sb-muted"></i>Forum <i class="fa-solid fa-comments w-4 text-center text-sb-muted"></i>Forum
</a> </a>
@@ -241,7 +247,7 @@
</div> </div>
</div> </div>
@auth @if($skinbaseToolbarCanAuth)
<!-- Notification icons --> <!-- Notification icons -->
<div class="hidden md:flex items-center gap-0.5 lg:gap-1 text-soft shrink-0"> <div class="hidden md:flex items-center gap-0.5 lg:gap-1 text-soft shrink-0">
<a href="{{ route('dashboard.favorites') }}" <a href="{{ route('dashboard.favorites') }}"
@@ -304,6 +310,7 @@
@php @php
$toolbarUsername = strtolower((string) (Auth::user()->username ?? '')); $toolbarUsername = strtolower((string) (Auth::user()->username ?? ''));
$routeUpload = Route::has('upload') ? route('upload') : '/upload'; $routeUpload = Route::has('upload') ? route('upload') : '/upload';
$routeModeration = '/moderation';
$routeDashboard = Route::has('dashboard') ? route('dashboard') : '/dashboard'; $routeDashboard = Route::has('dashboard') ? route('dashboard') : '/dashboard';
$routeMyArtworks = Route::has('studio.artworks') ? route('studio.artworks') : '/studio/artworks'; $routeMyArtworks = Route::has('studio.artworks') ? route('studio.artworks') : '/studio/artworks';
$routeMyStories = Route::has('creator.stories.index') ? route('creator.stories.index') : '/creator/stories'; $routeMyStories = Route::has('creator.stories.index') ? route('creator.stories.index') : '/creator/stories';
@@ -366,26 +373,24 @@
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-upload text-xs text-sb-muted"></i></span> <span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-upload text-xs text-sb-muted"></i></span>
Upload Upload
</a> </a>
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ $routeDashboard }}">
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-table-columns text-xs text-sb-muted"></i></span>
Dashboard
</a>
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/studio/artworks"> <a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/studio/artworks">
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-palette text-xs text-sb-muted"></i></span> <span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-palette text-xs text-sb-muted"></i></span>
Studio Studio
</a> </a>
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ $routeMyStories }}"> @if(in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true))
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-book-open text-xs text-sb-muted"></i></span> <a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ $routeModeration }}">
My Stories <span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-user-shield text-xs text-sb-muted"></i></span>
Moderation
</a> </a>
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ $routeDashboardFavorites }}"> @endif
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-heart text-xs text-sb-muted"></i></span> <a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ $routeDashboard }}">
My Favorites <span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-table-columns text-xs text-sb-muted"></i></span>
Dashboard
</a> </a>
<a class="flex items-center justify-between gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('dashboard.comments.received') }}"> <a class="flex items-center justify-between gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('dashboard.comments.received') }}">
<span class="flex items-center gap-3 min-w-0"> <span class="flex items-center gap-3 min-w-0">
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-inbox text-xs text-sb-muted"></i></span> <span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-inbox text-xs text-sb-muted"></i></span>
<span>Received Comments</span> <span>Comments</span>
</span> </span>
@if(($receivedCommentsCount ?? 0) > 0) @if(($receivedCommentsCount ?? 0) > 0)
<span class="rounded-full border border-cyan-400/25 bg-cyan-500/10 px-2 py-0.5 text-[11px] font-semibold text-cyan-200">{{ $receivedCommentsCount }}</span> <span class="rounded-full border border-cyan-400/25 bg-cyan-500/10 px-2 py-0.5 text-[11px] font-semibold text-cyan-200">{{ $receivedCommentsCount }}</span>
@@ -403,13 +408,6 @@
Settings Settings
</a> </a>
@if(in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true) && \Illuminate\Support\Facades\Route::has('admin.usernames.moderation'))
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('admin.usernames.moderation') }}">
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-user-shield text-xs text-sb-muted"></i></span>
Moderation
</a>
@endif
<div class="border-t border-panel mt-1 mb-1"></div> <div class="border-t border-panel mt-1 mb-1"></div>
<form method="POST" action="{{ route('logout') }}" class="mb-1"> <form method="POST" action="{{ route('logout') }}" class="mb-1">
@csrf @csrf
@@ -458,7 +456,7 @@
</a> </a>
</div> </div>
</details> </details>
@endauth @endif
</div> </div>
</header> </header>
@@ -466,9 +464,9 @@
<div class="hidden fixed inset-x-0 top-16 bottom-0 z-40 overflow-y-auto overscroll-contain bg-nova border-b border-panel p-4 shadow-sb" id="mobileMenu"> <div class="hidden fixed inset-x-0 top-16 bottom-0 z-40 overflow-y-auto overscroll-contain bg-nova border-b border-panel p-4 shadow-sb" id="mobileMenu">
<div class="space-y-0.5 text-sm text-soft"> <div class="space-y-0.5 text-sm text-soft">
@guest @if(! $skinbaseToolbarCanAuth)
<div class="my-2 border-t border-panel"></div> <div class="my-2 border-t border-panel"></div>
@endguest @endif
<div class="pt-1"> <div class="pt-1">
<button type="button" data-mobile-section-toggle aria-controls="mobileSectionDiscover" aria-expanded="true" class="w-full flex items-center justify-between py-2.5 px-3 rounded-lg text-[11px] font-semibold uppercase tracking-widest text-sb-muted hover:bg-white/5"> <button type="button" data-mobile-section-toggle aria-controls="mobileSectionDiscover" aria-expanded="true" class="w-full flex items-center justify-between py-2.5 px-3 rounded-lg text-[11px] font-semibold uppercase tracking-widest text-sb-muted hover:bg-white/5">
@@ -483,16 +481,19 @@
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/most-downloaded"><i class="fa-solid fa-download w-4 text-center text-sb-muted"></i>Most Downloaded</a> <a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/most-downloaded"><i class="fa-solid fa-download w-4 text-center text-sb-muted"></i>Most Downloaded</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('downloads.today') }}"><i class="fa-solid fa-arrow-down-short-wide w-4 text-center text-sb-muted"></i>Today Downloads</a> <a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('downloads.today') }}"><i class="fa-solid fa-arrow-down-short-wide w-4 text-center text-sb-muted"></i>Today Downloads</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/on-this-day"><i class="fa-solid fa-calendar-day w-4 text-center text-sb-muted"></i>On This Day</a> <a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/on-this-day"><i class="fa-solid fa-calendar-day w-4 text-center text-sb-muted"></i>On This Day</a>
@if($skinbaseToolbarCanAuth)
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('discover.for-you') }}"><i class="fa-solid fa-wand-magic-sparkles w-4 text-center"></i>For You</a>
@endif
</div> </div>
</div> </div>
<div class="pt-1"> <div class="pt-1">
<button type="button" data-mobile-section-toggle aria-controls="mobileSectionBrowse" aria-expanded="false" class="w-full flex items-center justify-between py-2.5 px-3 rounded-lg text-[11px] font-semibold uppercase tracking-widest text-sb-muted hover:bg-white/5"> <button type="button" data-mobile-section-toggle aria-controls="mobileSectionBrowse" aria-expanded="false" class="w-full flex items-center justify-between py-2.5 px-3 rounded-lg text-[11px] font-semibold uppercase tracking-widest text-sb-muted hover:bg-white/5">
<span>Browse</span> <span>Explore</span>
<i data-mobile-section-icon class="fa-solid fa-chevron-down text-xs transition-transform"></i> <i data-mobile-section-icon class="fa-solid fa-chevron-down text-xs transition-transform"></i>
</button> </button>
<div id="mobileSectionBrowse" data-mobile-section-panel class="hidden mt-0.5 space-y-0.5"> <div id="mobileSectionBrowse" data-mobile-section-panel class="hidden mt-0.5 space-y-0.5">
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/browse"><i class="fa-solid fa-border-all w-4 text-center text-sb-muted"></i>All Artworks</a> <a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/explore"><i class="fa-solid fa-border-all w-4 text-center text-sb-muted"></i>All Artworks</a>
@foreach($toolbarContentTypes as $contentType) @foreach($toolbarContentTypes as $contentType)
@php @php
$contentTypeSlug = strtolower((string) $contentType->slug); $contentTypeSlug = strtolower((string) $contentType->slug);
@@ -500,6 +501,7 @@
@endphp @endphp
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/{{ $contentTypeSlug }}"><i class="fa-solid {{ $contentTypeIcon }} w-4 text-center text-sb-muted"></i>{{ $contentType->name }}</a> <a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/{{ $contentTypeSlug }}"><i class="fa-solid {{ $contentTypeIcon }} w-4 text-center text-sb-muted"></i>{{ $contentType->name }}</a>
@endforeach @endforeach
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('categories.index') }}"><i class="fa-solid fa-folder-open w-4 text-center text-sb-muted"></i>Categories</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/tags"><i class="fa-solid fa-tags w-4 text-center text-sb-muted"></i>Tags</a> <a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/tags"><i class="fa-solid fa-tags w-4 text-center text-sb-muted"></i>Tags</a>
</div> </div>
</div> </div>
@@ -528,10 +530,9 @@
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/leaderboard"><i class="fa-solid fa-trophy w-4 text-center text-sb-muted"></i>Leaderboard</a> <a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/leaderboard"><i class="fa-solid fa-trophy w-4 text-center text-sb-muted"></i>Leaderboard</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/creators/rising"><i class="fa-solid fa-arrow-trend-up w-4 text-center text-sb-muted"></i>Rising Creators</a> <a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/creators/rising"><i class="fa-solid fa-arrow-trend-up w-4 text-center text-sb-muted"></i>Rising Creators</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/stories"><i class="fa-solid fa-microphone w-4 text-center text-sb-muted"></i>Creator Stories</a> <a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/stories"><i class="fa-solid fa-microphone w-4 text-center text-sb-muted"></i>Creator Stories</a>
@auth @if($skinbaseToolbarCanAuth)
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('creator.stories.index') }}"><i class="fa-solid fa-rectangle-list w-4 text-center text-sb-muted"></i>My Stories</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('dashboard.following') }}"><i class="fa-solid fa-user-plus w-4 text-center text-sb-muted"></i>Following</a> <a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('dashboard.following') }}"><i class="fa-solid fa-user-plus w-4 text-center text-sb-muted"></i>Following</a>
@endauth @endif
</div> </div>
</div> </div>
@@ -542,9 +543,9 @@
</button> </button>
<div id="mobileSectionCommunity" data-mobile-section-panel class="hidden mt-0.5 space-y-0.5"> <div id="mobileSectionCommunity" data-mobile-section-panel class="hidden mt-0.5 space-y-0.5">
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('community.activity') }}"><i class="fa-solid fa-wave-square w-4 text-center text-sb-muted"></i>Activity Feed</a> <a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('community.activity') }}"><i class="fa-solid fa-wave-square w-4 text-center text-sb-muted"></i>Activity Feed</a>
@auth @if($skinbaseToolbarCanAuth)
<a class="flex items-center justify-between gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('dashboard.comments.received') }}"><span class="flex items-center gap-3"><i class="fa-solid fa-inbox w-4 text-center text-sb-muted"></i>Received Comments</span>@if(($receivedCommentsCount ?? 0) > 0)<span class="rounded-full border border-cyan-400/25 bg-cyan-500/10 px-2 py-0.5 text-[11px] font-semibold text-cyan-200">{{ $receivedCommentsCount }}</span>@endif</a> <a class="flex items-center justify-between gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('dashboard.comments.received') }}"><span class="flex items-center gap-3"><i class="fa-solid fa-inbox w-4 text-center text-sb-muted"></i>Received Comments</span>@if(($receivedCommentsCount ?? 0) > 0)<span class="rounded-full border border-cyan-400/25 bg-cyan-500/10 px-2 py-0.5 text-[11px] font-semibold text-cyan-200">{{ $receivedCommentsCount }}</span>@endif</a>
@endauth @endif
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/forum"><i class="fa-solid fa-comments w-4 text-center text-sb-muted"></i>Forum</a> <a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/forum"><i class="fa-solid fa-comments w-4 text-center text-sb-muted"></i>Forum</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/news"><i class="fa-solid fa-newspaper w-4 text-center text-sb-muted"></i>News</a> <a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/news"><i class="fa-solid fa-newspaper w-4 text-center text-sb-muted"></i>News</a>
</div> </div>

View File

@@ -3,7 +3,9 @@
@section('title', 'Messages') @section('title', 'Messages')
@push('head') @push('head')
@if(request()->hasSession() && ! request()->attributes->get('skinbase.session_skipped'))
<meta name="csrf-token" content="{{ csrf_token() }}" /> <meta name="csrf-token" content="{{ csrf_token() }}" />
@endif
@endpush @endpush
@section('content') @section('content')

View File

@@ -1,7 +1,9 @@
@extends('layouts.nova') @extends('layouts.nova')
@push('head') @push('head')
@if(request()->hasSession() && ! request()->attributes->get('skinbase.session_skipped'))
<meta name="csrf-token" content="{{ csrf_token() }}" /> <meta name="csrf-token" content="{{ csrf_token() }}" />
@endif
@vite(['resources/js/moderation.jsx']) @vite(['resources/js/moderation.jsx'])
<style> <style>
body.page-moderation main { padding-top: 4rem; } body.page-moderation main { padding-top: 4rem; }

View File

@@ -1,7 +1,9 @@
@extends('layouts.nova') @extends('layouts.nova')
@push('head') @push('head')
@if(request()->hasSession() && ! request()->attributes->get('skinbase.session_skipped'))
<meta name="csrf-token" content="{{ csrf_token() }}" /> <meta name="csrf-token" content="{{ csrf_token() }}" />
@endif
@vite(['resources/js/settings.jsx']) @vite(['resources/js/settings.jsx'])
<style> <style>
body.page-settings main { padding-top: 4rem; } body.page-settings main { padding-top: 4rem; }

View File

@@ -1,7 +1,9 @@
@extends('layouts.nova') @extends('layouts.nova')
@push('head') @push('head')
@if(request()->hasSession() && ! request()->attributes->get('skinbase.session_skipped'))
<meta name="csrf-token" content="{{ csrf_token() }}" /> <meta name="csrf-token" content="{{ csrf_token() }}" />
@endif
@vite(['resources/js/studio.jsx']) @vite(['resources/js/studio.jsx'])
<style> <style>
body.page-studio main { padding-top: 2.3rem; } body.page-studio main { padding-top: 2.3rem; }

View File

@@ -1,7 +1,9 @@
@extends('layouts.nova') @extends('layouts.nova')
@push('head') @push('head')
@if(request()->hasSession() && ! request()->attributes->get('skinbase.session_skipped'))
<meta name="csrf-token" content="{{ csrf_token() }}" /> <meta name="csrf-token" content="{{ csrf_token() }}" />
@endif
<script> <script>
window.SKINBASE_FLAGS = Object.assign({}, window.SKINBASE_FLAGS || {}, { window.SKINBASE_FLAGS = Object.assign({}, window.SKINBASE_FLAGS || {}, {

View File

@@ -5,6 +5,7 @@
$hero_description = "We're always grateful for volunteers who want to help."; $hero_description = "We're always grateful for volunteers who want to help.";
$center_content = true; $center_content = true;
$center_max = '3xl'; $center_max = '3xl';
$errors = $errors ?? new \Illuminate\Support\ViewErrorBag();
@endphp @endphp
@section('page-content') @section('page-content')

View File

@@ -28,6 +28,7 @@ Route::middleware(['web', 'throttle:60,1'])->prefix('leaderboard')->name('api.le
Route::get('artworks', [\App\Http\Controllers\Api\LeaderboardController::class, 'artworks'])->name('artworks'); Route::get('artworks', [\App\Http\Controllers\Api\LeaderboardController::class, 'artworks'])->name('artworks');
Route::get('groups', [\App\Http\Controllers\Api\LeaderboardController::class, 'groups'])->name('groups'); Route::get('groups', [\App\Http\Controllers\Api\LeaderboardController::class, 'groups'])->name('groups');
Route::get('stories', [\App\Http\Controllers\Api\LeaderboardController::class, 'stories'])->name('stories'); Route::get('stories', [\App\Http\Controllers\Api\LeaderboardController::class, 'stories'])->name('stories');
Route::get('worlds', [\App\Http\Controllers\Api\LeaderboardController::class, 'worlds'])->name('worlds');
}); });
Route::middleware(['web', 'auth', 'creator.access'])->prefix('stories')->name('api.stories.')->group(function () { Route::middleware(['web', 'auth', 'creator.access'])->prefix('stories')->name('api.stories.')->group(function () {
@@ -49,7 +50,7 @@ Route::middleware(['web', 'auth', 'normalize.username'])->prefix('profile/cover'
// ── Per-artwork signal tracking (public) ──────────────────────────────────── // ── Per-artwork signal tracking (public) ────────────────────────────────────
// GET /api/art/{id}/similar → up to 12 similar artworks (Meilisearch) // GET /api/art/{id}/similar → up to 12 similar artworks (Meilisearch)
// POST /api/art/{id}/view → record a view (session-deduped, 5 per 10 min) // POST /api/art/{id}/view → record a page visit as a view
// POST /api/art/{id}/download → record a download, returns file URL (10/min) // POST /api/art/{id}/download → record a download, returns file URL (10/min)
Route::middleware(['web', 'throttle:300,1']) Route::middleware(['web', 'throttle:300,1'])
->get('art/{id}/similar', \App\Http\Controllers\Api\SimilarArtworksController::class) ->get('art/{id}/similar', \App\Http\Controllers\Api\SimilarArtworksController::class)
@@ -61,7 +62,7 @@ Route::middleware(['web', 'throttle:120,1'])
->whereNumber('id') ->whereNumber('id')
->name('api.art.similar-ai'); ->name('api.art.similar-ai');
Route::middleware(['web', 'throttle:5,10']) Route::middleware(['web', 'throttle:120,1'])
->post('art/{id}/view', \App\Http\Controllers\Api\ArtworkViewController::class) ->post('art/{id}/view', \App\Http\Controllers\Api\ArtworkViewController::class)
->middleware('forum.bot.protection:api_write') ->middleware('forum.bot.protection:api_write')
->whereNumber('id') ->whereNumber('id')
@@ -86,6 +87,27 @@ Route::middleware(['web', 'throttle:social-read'])
->where('username', '[A-Za-z0-9_-]{3,20}') ->where('username', '[A-Za-z0-9_-]{3,20}')
->name('api.profile.activity'); ->name('api.profile.activity');
Route::middleware(['web', 'throttle:social-read'])
->get('profile/{username}/journey', \App\Http\Controllers\Api\ProfileJourneyController::class)
->where('username', '[A-Za-z0-9_-]{3,20}')
->name('api.profile.journey');
// Public profile AI biography (read, included in profile payload via journey endpoint)
Route::middleware(['web', 'throttle:social-read'])
->get('profile/{username}/ai-biography', [\App\Http\Controllers\Api\ProfileAiBiographyController::class, 'show'])
->where('username', '[A-Za-z0-9_-]{3,20}')
->name('api.profile.ai-biography');
// Creator-facing AI biography management (authenticated)
Route::middleware(['web', 'auth'])->prefix('creator/profile/ai-biography')->name('api.creator.ai-biography.')->group(function () {
Route::get('/', [\App\Http\Controllers\Api\AiBiographyController::class, 'status'])->name('status');
Route::post('/generate', [\App\Http\Controllers\Api\AiBiographyController::class, 'generate'])->name('generate')->middleware('throttle:5,1');
Route::post('/regenerate', [\App\Http\Controllers\Api\AiBiographyController::class, 'regenerate'])->name('regenerate')->middleware('throttle:5,1');
Route::patch('/', [\App\Http\Controllers\Api\AiBiographyController::class, 'update'])->name('update')->middleware('throttle:20,1');
Route::post('/hide', [\App\Http\Controllers\Api\AiBiographyController::class, 'hide'])->name('hide');
Route::post('/show', [\App\Http\Controllers\Api\AiBiographyController::class, 'show'])->name('show');
});
Route::middleware(['web', 'throttle:social-read']) Route::middleware(['web', 'throttle:social-read'])
->get('comments', [\App\Http\Controllers\Api\SocialCompatibilityController::class, 'comments']) ->get('comments', [\App\Http\Controllers\Api\SocialCompatibilityController::class, 'comments'])
->name('api.social.comments.index'); ->name('api.social.comments.index');
@@ -117,6 +139,13 @@ Route::prefix('rank')->name('api.rank.')->middleware(['throttle:60,1'])->group(f
// ── Studio Pro API (authenticated) ───────────────────────────────────────────── // ── Studio Pro API (authenticated) ─────────────────────────────────────────────
Route::middleware(['web', 'auth'])->prefix('studio')->name('api.studio.')->group(function () { Route::middleware(['web', 'auth'])->prefix('studio')->name('api.studio.')->group(function () {
Route::post('events', [\App\Http\Controllers\Studio\StudioEventsApiController::class, 'store'])->name('events.store'); Route::post('events', [\App\Http\Controllers\Studio\StudioEventsApiController::class, 'store'])->name('events.store');
Route::get('upload-queue', [\App\Http\Controllers\Studio\StudioUploadQueueApiController::class, 'index'])->name('upload-queue.index');
Route::post('upload-queue/batches', [\App\Http\Controllers\Studio\StudioUploadQueueApiController::class, 'store'])->name('upload-queue.store');
Route::post('upload-queue/bulk', [\App\Http\Controllers\Studio\StudioUploadQueueApiController::class, 'bulk'])->name('upload-queue.bulk');
Route::post('upload-queue/items/{id}/fail', [\App\Http\Controllers\Studio\StudioUploadQueueApiController::class, 'markFailed'])->whereNumber('id')->name('upload-queue.items.fail');
Route::post('upload-queue/items/{id}/retry', [\App\Http\Controllers\Studio\StudioUploadQueueApiController::class, 'retry'])->whereNumber('id')->name('upload-queue.items.retry');
Route::post('worlds/media/upload', [\App\Http\Controllers\Studio\StudioWorldMediaApiController::class, 'store'])->middleware(['throttle:20,1', 'forum.bot.protection:api_write'])->name('worlds.media.upload');
Route::delete('worlds/media', [\App\Http\Controllers\Studio\StudioWorldMediaApiController::class, 'destroy'])->middleware(['throttle:20,1', 'forum.bot.protection:api_write'])->name('worlds.media.destroy');
Route::put('preferences', [\App\Http\Controllers\Studio\StudioPreferencesApiController::class, 'updatePreferences'])->name('preferences.settings'); Route::put('preferences', [\App\Http\Controllers\Studio\StudioPreferencesApiController::class, 'updatePreferences'])->name('preferences.settings');
Route::put('preferences/profile', [\App\Http\Controllers\Studio\StudioPreferencesApiController::class, 'updateProfile'])->name('preferences.profile'); Route::put('preferences/profile', [\App\Http\Controllers\Studio\StudioPreferencesApiController::class, 'updateProfile'])->name('preferences.profile');
Route::put('preferences/featured', [\App\Http\Controllers\Studio\StudioPreferencesApiController::class, 'updateFeatured'])->name('preferences.featured'); Route::put('preferences/featured', [\App\Http\Controllers\Studio\StudioPreferencesApiController::class, 'updateFeatured'])->name('preferences.featured');
@@ -138,6 +167,7 @@ Route::middleware(['web', 'auth'])->prefix('studio')->name('api.studio.')->group
Route::post('artworks/{id}/ai/events', [\App\Http\Controllers\Studio\StudioArtworkAiAssistApiController::class, 'event'])->whereNumber('id')->name('artworks.ai.events'); Route::post('artworks/{id}/ai/events', [\App\Http\Controllers\Studio\StudioArtworkAiAssistApiController::class, 'event'])->whereNumber('id')->name('artworks.ai.events');
Route::post('artworks/{id}/ai/regenerate', [\App\Http\Controllers\Studio\StudioArtworkAiAssistApiController::class, 'regenerate'])->whereNumber('id')->name('artworks.ai.regenerate'); Route::post('artworks/{id}/ai/regenerate', [\App\Http\Controllers\Studio\StudioArtworkAiAssistApiController::class, 'regenerate'])->whereNumber('id')->name('artworks.ai.regenerate');
Route::post('artworks/{id}/replace-file', [\App\Http\Controllers\Studio\StudioArtworksApiController::class, 'replaceFile'])->whereNumber('id')->name('artworks.replaceFile'); Route::post('artworks/{id}/replace-file', [\App\Http\Controllers\Studio\StudioArtworksApiController::class, 'replaceFile'])->whereNumber('id')->name('artworks.replaceFile');
Route::post('artworks/{id}/revise-media', [\App\Http\Controllers\Studio\StudioArtworksApiController::class, 'reviseMedia'])->whereNumber('id')->name('artworks.reviseMedia');
// Versioning // Versioning
Route::get('artworks/{id}/versions', [\App\Http\Controllers\Studio\StudioArtworksApiController::class, 'versions'])->whereNumber('id')->name('artworks.versions'); Route::get('artworks/{id}/versions', [\App\Http\Controllers\Studio\StudioArtworksApiController::class, 'versions'])->whereNumber('id')->name('artworks.versions');
Route::post('artworks/{id}/restore/{version_id}', [\App\Http\Controllers\Studio\StudioArtworksApiController::class, 'restoreVersion'])->whereNumber('id')->whereNumber('version_id')->name('artworks.restoreVersion'); Route::post('artworks/{id}/restore/{version_id}', [\App\Http\Controllers\Studio\StudioArtworksApiController::class, 'restoreVersion'])->whereNumber('id')->whereNumber('version_id')->name('artworks.restoreVersion');

View File

@@ -5,6 +5,7 @@ use Illuminate\Http\Request;
use App\Http\Controllers\User\ProfileController; use App\Http\Controllers\User\ProfileController;
use App\Models\ContentType; use App\Models\ContentType;
use App\Models\Group; use App\Models\Group;
use App\Models\World;
use App\Http\Controllers\User\AvatarController; use App\Http\Controllers\User\AvatarController;
use App\Http\Controllers\Dashboard\ArtworkController as DashboardArtworkController; use App\Http\Controllers\Dashboard\ArtworkController as DashboardArtworkController;
use App\Http\Controllers\Web\ArtworkPageController; use App\Http\Controllers\Web\ArtworkPageController;
@@ -20,6 +21,7 @@ use App\Http\Controllers\StoryController;
use App\Http\Controllers\Web\HomeController; use App\Http\Controllers\Web\HomeController;
use App\Http\Controllers\Web\FooterController; use App\Http\Controllers\Web\FooterController;
use App\Http\Controllers\Web\BugReportController; use App\Http\Controllers\Web\BugReportController;
use App\Http\Controllers\Web\WorldController;
use App\Http\Controllers\RobotsTxtController; use App\Http\Controllers\RobotsTxtController;
use App\Http\Controllers\SitemapController; use App\Http\Controllers\SitemapController;
use App\Http\Controllers\Web\StaffController; use App\Http\Controllers\Web\StaffController;
@@ -36,6 +38,7 @@ use App\Http\Controllers\RSS\CreatorFeedController;
use App\Http\Controllers\RSS\BlogFeedController; use App\Http\Controllers\RSS\BlogFeedController;
use App\Http\Controllers\Studio\StudioNewsController; use App\Http\Controllers\Studio\StudioNewsController;
use App\Http\Controllers\Studio\StudioController; use App\Http\Controllers\Studio\StudioController;
use App\Http\Controllers\Studio\StudioWorldController;
use App\Http\Controllers\DashboardController; use App\Http\Controllers\DashboardController;
use App\Http\Controllers\Community\LatestController; use App\Http\Controllers\Community\LatestController;
use App\Http\Controllers\User\MembersController; use App\Http\Controllers\User\MembersController;
@@ -113,6 +116,7 @@ Route::get('/help', \App\Http\Controllers\Web\HelpCenterPageController::class)->
Route::get('/help/studio', \App\Http\Controllers\Web\StudioHelpPageController::class)->name('help.studio'); Route::get('/help/studio', \App\Http\Controllers\Web\StudioHelpPageController::class)->name('help.studio');
Route::get('/help/upload', \App\Http\Controllers\Web\UploadHelpPageController::class)->name('help.upload'); Route::get('/help/upload', \App\Http\Controllers\Web\UploadHelpPageController::class)->name('help.upload');
Route::get('/help/cards', \App\Http\Controllers\Web\CardsHelpPageController::class)->name('help.cards'); Route::get('/help/cards', \App\Http\Controllers\Web\CardsHelpPageController::class)->name('help.cards');
Route::get('/help/worlds', \App\Http\Controllers\Web\WorldsHelpPageController::class)->name('help.worlds');
Route::get('/help/profile', \App\Http\Controllers\Web\ProfileHelpPageController::class)->name('help.profile'); Route::get('/help/profile', \App\Http\Controllers\Web\ProfileHelpPageController::class)->name('help.profile');
Route::get('/help/auth', \App\Http\Controllers\Web\AuthHelpPageController::class)->name('help.auth'); Route::get('/help/auth', \App\Http\Controllers\Web\AuthHelpPageController::class)->name('help.auth');
Route::get('/help/account', \App\Http\Controllers\Web\AccountHelpPageController::class)->name('help.account'); Route::get('/help/account', \App\Http\Controllers\Web\AccountHelpPageController::class)->name('help.account');
@@ -278,6 +282,24 @@ Route::get('/collections/featured', [\App\Http\Controllers\Web\CollectionDiscove
->name('collections.featured'); ->name('collections.featured');
Route::get('/collections/trending', [\App\Http\Controllers\Web\CollectionDiscoveryController::class, 'trending']) Route::get('/collections/trending', [\App\Http\Controllers\Web\CollectionDiscoveryController::class, 'trending'])
->name('collections.trending'); ->name('collections.trending');
Route::get('/worlds', [WorldController::class, 'index'])->name('worlds.index');
Route::get('/worlds/create', function (Request $request) {
$user = $request->user();
if (! $user) {
return redirect()->guest(route('login'));
}
if ($user->can('create', World::class)) {
return redirect()->route('studio.worlds.create', $request->query(), 302);
}
return redirect()->route('worlds.index', $request->query(), 302);
})
->name('worlds.create.redirect');
Route::get('/worlds/{world:slug}', [WorldController::class, 'show'])
->where('world', '^(?!create$)[a-z0-9]+(?:-[a-z0-9]+)*$')
->name('worlds.show');
Route::get('/collections/editorial', [\App\Http\Controllers\Web\CollectionDiscoveryController::class, 'editorial']) Route::get('/collections/editorial', [\App\Http\Controllers\Web\CollectionDiscoveryController::class, 'editorial'])
->name('collections.editorial'); ->name('collections.editorial');
Route::get('/collections/community', [\App\Http\Controllers\Web\CollectionDiscoveryController::class, 'community']) Route::get('/collections/community', [\App\Http\Controllers\Web\CollectionDiscoveryController::class, 'community'])
@@ -426,6 +448,7 @@ Route::middleware(['auth', 'ensure.onboarding.complete'])->prefix('studio')->nam
Route::get('/content', [StudioController::class, 'content'])->name('content'); Route::get('/content', [StudioController::class, 'content'])->name('content');
Route::get('/artworks', [StudioController::class, 'artworks'])->name('artworks'); Route::get('/artworks', [StudioController::class, 'artworks'])->name('artworks');
Route::get('/drafts', [StudioController::class, 'drafts'])->name('drafts'); Route::get('/drafts', [StudioController::class, 'drafts'])->name('drafts');
Route::get('/upload-queue', [StudioController::class, 'uploadQueue'])->name('upload-queue');
Route::get('/scheduled', [StudioController::class, 'scheduled'])->name('scheduled'); Route::get('/scheduled', [StudioController::class, 'scheduled'])->name('scheduled');
Route::get('/calendar', [StudioController::class, 'calendar'])->name('calendar'); Route::get('/calendar', [StudioController::class, 'calendar'])->name('calendar');
Route::get('/archived', [StudioController::class, 'archived'])->name('archived'); Route::get('/archived', [StudioController::class, 'archived'])->name('archived');
@@ -471,6 +494,25 @@ Route::middleware(['auth', 'ensure.onboarding.complete'])->prefix('studio')->nam
Route::post('/news/{article}/archive', [StudioNewsController::class, 'archive'])->whereNumber('article')->name('news.archive'); Route::post('/news/{article}/archive', [StudioNewsController::class, 'archive'])->whereNumber('article')->name('news.archive');
Route::post('/news/{article}/feature', [StudioNewsController::class, 'feature'])->whereNumber('article')->name('news.feature'); Route::post('/news/{article}/feature', [StudioNewsController::class, 'feature'])->whereNumber('article')->name('news.feature');
Route::post('/news/{article}/pin', [StudioNewsController::class, 'pin'])->whereNumber('article')->name('news.pin'); Route::post('/news/{article}/pin', [StudioNewsController::class, 'pin'])->whereNumber('article')->name('news.pin');
Route::get('/worlds', [StudioWorldController::class, 'index'])->name('worlds.index');
Route::get('/worlds/create', [StudioWorldController::class, 'create'])->name('worlds.create');
Route::post('/worlds', [StudioWorldController::class, 'store'])->name('worlds.store');
Route::get('/worlds/entity-search', [StudioWorldController::class, 'entitySearch'])->name('worlds.entity-search');
Route::get('/worlds/{world}/preview', [StudioWorldController::class, 'preview'])->whereNumber('world')->name('worlds.preview');
Route::get('/worlds/{world}/edit', [StudioWorldController::class, 'edit'])->whereNumber('world')->name('worlds.edit');
Route::patch('/worlds/{world}', [StudioWorldController::class, 'update'])->whereNumber('world')->name('worlds.update');
Route::post('/worlds/{world}/submissions/{submission}/approve', [StudioWorldController::class, 'approveSubmission'])->whereNumber('world')->whereNumber('submission')->name('worlds.submissions.approve');
Route::post('/worlds/{world}/submissions/{submission}/remove', [StudioWorldController::class, 'removeSubmission'])->whereNumber('world')->whereNumber('submission')->name('worlds.submissions.remove');
Route::post('/worlds/{world}/submissions/{submission}/block', [StudioWorldController::class, 'blockSubmission'])->whereNumber('world')->whereNumber('submission')->name('worlds.submissions.block');
Route::post('/worlds/{world}/submissions/{submission}/unblock', [StudioWorldController::class, 'unblockSubmission'])->whereNumber('world')->whereNumber('submission')->name('worlds.submissions.unblock');
Route::post('/worlds/{world}/submissions/{submission}/restore', [StudioWorldController::class, 'restoreSubmission'])->whereNumber('world')->whereNumber('submission')->name('worlds.submissions.restore');
Route::post('/worlds/{world}/submissions/{submission}/feature', [StudioWorldController::class, 'featureSubmission'])->whereNumber('world')->whereNumber('submission')->name('worlds.submissions.feature');
Route::post('/worlds/{world}/submissions/{submission}/unfeature', [StudioWorldController::class, 'unfeatureSubmission'])->whereNumber('world')->whereNumber('submission')->name('worlds.submissions.unfeature');
Route::post('/worlds/{world}/submissions/{submission}/pending', [StudioWorldController::class, 'pendingSubmission'])->whereNumber('world')->whereNumber('submission')->name('worlds.submissions.pending');
Route::post('/worlds/{world}/publish', [StudioWorldController::class, 'publish'])->whereNumber('world')->name('worlds.publish');
Route::post('/worlds/{world}/archive', [StudioWorldController::class, 'archive'])->whereNumber('world')->name('worlds.archive');
Route::post('/worlds/{world}/duplicate', [StudioWorldController::class, 'duplicate'])->whereNumber('world')->name('worlds.duplicate');
Route::post('/worlds/{world}/new-edition', [StudioWorldController::class, 'newEdition'])->whereNumber('world')->name('worlds.new-edition');
Route::get('/groups', [\App\Http\Controllers\Studio\GroupStudioController::class, 'index'])->name('groups.index'); Route::get('/groups', [\App\Http\Controllers\Studio\GroupStudioController::class, 'index'])->name('groups.index');
Route::get('/groups/create', [\App\Http\Controllers\Studio\GroupStudioController::class, 'create'])->name('groups.create'); Route::get('/groups/create', [\App\Http\Controllers\Studio\GroupStudioController::class, 'create'])->name('groups.create');
Route::post('/groups', [\App\Http\Controllers\Studio\GroupStudioController::class, 'store'])->name('groups.store'); Route::post('/groups', [\App\Http\Controllers\Studio\GroupStudioController::class, 'store'])->name('groups.store');
@@ -629,6 +671,15 @@ Route::middleware(['artwork.maturity.access'])->prefix('cp/maturity')->name('cp.
Route::post('/{artwork:id}/review', [\App\Http\Controllers\Settings\ArtworkMaturityAdminController::class, 'review'])->whereNumber('artwork')->name('review'); Route::post('/{artwork:id}/review', [\App\Http\Controllers\Settings\ArtworkMaturityAdminController::class, 'review'])->whereNumber('artwork')->name('review');
}); });
Route::middleware(['artwork.maturity.access'])->prefix('cp/ai-biography')->name('cp.ai-biography.')->group(function () {
Route::get('/', [\App\Http\Controllers\Settings\AiBiographyAdminController::class, 'index'])->name('index');
Route::post('/users/{user}/rebuild', [\App\Http\Controllers\Settings\AiBiographyAdminController::class, 'rebuild'])->whereNumber('user')->name('rebuild');
Route::post('/records/{biography}/approve', [\App\Http\Controllers\Settings\AiBiographyAdminController::class, 'approve'])->whereNumber('biography')->name('approve');
Route::post('/records/{biography}/flag', [\App\Http\Controllers\Settings\AiBiographyAdminController::class, 'flag'])->whereNumber('biography')->name('flag');
Route::post('/records/{biography}/hide', [\App\Http\Controllers\Settings\AiBiographyAdminController::class, 'hide'])->whereNumber('biography')->name('hide');
Route::post('/records/{biography}/show', [\App\Http\Controllers\Settings\AiBiographyAdminController::class, 'show'])->whereNumber('biography')->name('show');
});
// ── SETTINGS / PROFILE EDIT ─────────────────────────────────────────────────── // ── SETTINGS / PROFILE EDIT ───────────────────────────────────────────────────
Route::middleware(['auth', 'normalize.username', 'ensure.onboarding.complete'])->group(function () { Route::middleware(['auth', 'normalize.username', 'ensure.onboarding.complete'])->group(function () {
Route::get('/profile', fn () => redirect()->route('dashboard.profile', [], 301))->name('legacy.profile.redirect'); Route::get('/profile', fn () => redirect()->route('dashboard.profile', [], 301))->name('legacy.profile.redirect');
@@ -779,6 +830,7 @@ Route::middleware(['auth', 'ensure.onboarding.complete'])->group(function () {
'draftId' => null, 'draftId' => null,
'content_types' => $contentTypes, 'content_types' => $contentTypes,
'suggested_tags' => [], 'suggested_tags' => [],
'eligible_worlds' => app(\App\Services\Worlds\WorldSubmissionService::class)->eligibleWorldOptions($user),
'group_options' => $availableGroups, 'group_options' => $availableGroups,
'contributor_options_by_group' => $contributorOptionsByGroup, 'contributor_options_by_group' => $contributorOptionsByGroup,
'initial_group' => $initialGroupSlug !== '' ? $initialGroupSlug : null, 'initial_group' => $initialGroupSlug !== '' ? $initialGroupSlug : null,
@@ -838,6 +890,7 @@ Route::middleware(['auth', 'ensure.onboarding.complete'])->group(function () {
'draftId' => $id, 'draftId' => $id,
'content_types' => $contentTypes, 'content_types' => $contentTypes,
'suggested_tags' => [], 'suggested_tags' => [],
'eligible_worlds' => app(\App\Services\Worlds\WorldSubmissionService::class)->eligibleWorldOptions($user),
'group_options' => $availableGroups, 'group_options' => $availableGroups,
'contributor_options_by_group' => $contributorOptionsByGroup, 'contributor_options_by_group' => $contributorOptionsByGroup,
'initial_group' => $initialGroupSlug !== '' ? $initialGroupSlug : null, 'initial_group' => $initialGroupSlug !== '' ? $initialGroupSlug : null,

View File

@@ -41,6 +41,14 @@ SESSION_ENCRYPT=false
SESSION_PATH=/ SESSION_PATH=/
SESSION_DOMAIN=null SESSION_DOMAIN=null
# Skinbase conditional public sessions
SKINBASE_CONDITIONAL_SESSIONS_ENABLED=true
SKINBASE_SKIP_ANONYMOUS_PUBLIC_GET_SESSIONS=true
SKINBASE_SKIP_BOT_PUBLIC_GET_SESSIONS=true
# Debug only; do not enable permanently in production
SKINBASE_SESSION_DEBUG_HEADER=false
BROADCAST_CONNECTION=reverb BROADCAST_CONNECTION=reverb
FILESYSTEM_DISK=local FILESYSTEM_DISK=local
QUEUE_CONNECTION=redis QUEUE_CONNECTION=redis
@@ -202,6 +210,25 @@ YOLO_HTTP_RETRIES=1
YOLO_HTTP_RETRY_DELAY_MS=200 YOLO_HTTP_RETRY_DELAY_MS=200
YOLO_PHOTOGRAPHY_ONLY=true YOLO_PHOTOGRAPHY_ONLY=true
# Academy feature flags
SKINBASE_ACADEMY_ENABLED=true
SKINBASE_ACADEMY_PAYMENTS_ENABLED=false
ACADEMY_BILLING_ENABLED=false
ACADEMY_STRIPE_SUBSCRIPTION_NAME=academy
# Stripe / Cashier
STRIPE_KEY=pk_test_xxx
STRIPE_SECRET=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
CASHIER_CURRENCY=eur
CASHIER_CURRENCY_LOCALE=sl_SI
# Academy billing price IDs
ACADEMY_CREATOR_MONTHLY_PRICE_ID=price_xxx
ACADEMY_PRO_MONTHLY_PRICE_ID=price_xxx
# Stripe expects real price object IDs that start with price_, not product IDs like prod_...
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Production examples (uncomment and adjust) # Production examples (uncomment and adjust)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -290,7 +317,7 @@ REGISTRATION_IP_PER_DAY_LIMIT=20
REGISTRATION_EMAIL_PER_MINUTE_LIMIT=6 REGISTRATION_EMAIL_PER_MINUTE_LIMIT=6
REGISTRATION_EMAIL_COOLDOWN_MINUTES=30 REGISTRATION_EMAIL_COOLDOWN_MINUTES=30
REGISTRATION_VERIFY_TOKEN_TTL_HOURS=24 REGISTRATION_VERIFY_TOKEN_TTL_HOURS=24
REGISTRATION_ENABLE_TURNSTILE=true TURNSTILE_ENABLED=false
REGISTRATION_DISPOSABLE_DOMAINS_ENABLED=true REGISTRATION_DISPOSABLE_DOMAINS_ENABLED=true
REGISTRATION_TURNSTILE_SUSPICIOUS_ATTEMPTS=2 REGISTRATION_TURNSTILE_SUSPICIOUS_ATTEMPTS=2
REGISTRATION_TURNSTILE_ATTEMPT_WINDOW_MINUTES=30 REGISTRATION_TURNSTILE_ATTEMPT_WINDOW_MINUTES=30
@@ -298,9 +325,34 @@ REGISTRATION_EMAIL_GLOBAL_SEND_PER_MINUTE=30
REGISTRATION_MONTHLY_EMAIL_LIMIT=10000 REGISTRATION_MONTHLY_EMAIL_LIMIT=10000
TURNSTILE_SITE_KEY= TURNSTILE_SITE_KEY=
TURNSTILE_SECRET_KEY= TURNSTILE_SECRET_KEY=
TURNSTILE_FAIL_OPEN=false
TURNSTILE_VERIFY_URL=https://challenges.cloudflare.com/turnstile/v0/siteverify TURNSTILE_VERIFY_URL=https://challenges.cloudflare.com/turnstile/v0/siteverify
TURNSTILE_TIMEOUT=5 TURNSTILE_TIMEOUT=5
ENHANCE_DISK=public
ENHANCE_SOURCE_PREFIX=enhance/sources
ENHANCE_OUTPUT_PREFIX=enhance/outputs
ENHANCE_PREVIEW_PREFIX=enhance/previews
ENHANCE_ENGINE=stub
ENHANCE_MAX_UPLOAD_MB=20
ENHANCE_MAX_INPUT_WIDTH=4096
ENHANCE_MAX_INPUT_HEIGHT=4096
ENHANCE_MAX_OUTPUT_WIDTH=8192
ENHANCE_MAX_OUTPUT_HEIGHT=8192
ENHANCE_DAILY_LIMIT=10
ENHANCE_QUEUE=default
ENHANCE_COMPLETED_EXPIRES_AFTER_DAYS=30
ENHANCE_FAILED_EXPIRES_AFTER_DAYS=7
ENHANCE_DELETED_FILE_GRACE_DAYS=1
ENHANCE_CLEANUP_CHUNK_SIZE=100
ENHANCE_STUCK_PROCESSING_AFTER_MINUTES=30
ENHANCE_STUCK_QUEUED_AFTER_MINUTES=60
ENHANCE_STUB_SHOW_WARNING=true
ENHANCE_WORKER_URL=
ENHANCE_WORKER_TIMEOUT=300
ENHANCE_WORKER_TOKEN=
ENHANCE_WORKER_MAX_DOWNLOAD_MB=60
AWS_ACCESS_KEY_ID= AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY= AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1 AWS_DEFAULT_REGION=us-east-1
@@ -347,6 +399,12 @@ GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=/auth/google/callback GOOGLE_REDIRECT_URI=/auth/google/callback
# Optional light theme feature
# Set LIGHT_THEME_ENABLED=true to allow a light theme in the client-side toggle.
# Set LIGHT_THEME_SHOW_SWITCH=true to display the theme switch in the toolbar.
LIGHT_THEME_ENABLED=false
LIGHT_THEME_SHOW_SWITCH=false
# Discord — https://discord.com/developers/applications # Discord — https://discord.com/developers/applications
DISCORD_CLIENT_ID= DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET= DISCORD_CLIENT_SECRET=

1
.gitignore vendored
View File

@@ -10,6 +10,7 @@
/.nova /.nova
/.phpunit.cache /.phpunit.cache
/.vscode /.vscode
/.deploy
/.zed /.zed
/auth.json /auth.json
/node_modules /node_modules

View File

@@ -1,43 +0,0 @@
# PR Title
feat(auth): complete registration anti-spam + email quota protection
## Summary
Implements the registration anti-spam and quota hardening spec end-to-end for the email-first onboarding flow.
### What changed
- Added registration anti-spam config and disposable domain config.
- Added progressive Turnstile verification service and wiring.
- Added registration rate limiters and route middleware (`register-ip`, `register-ip-daily`).
- Implemented per-email cooldown and generic anti-enumeration responses.
- Added queued verification sending job with global throttle + quota circuit breaker.
- Added quota and disposable-domain services.
- Hardened verification tokens (hashed storage lookup, expiry, one-time use).
- Added/updated migrations:
- cooldown fields on `users`
- `email_send_events`
- `system_email_quota`
- token column hardening (`token` -> `token_hash`)
- rollout safety migration to ensure `user_verification_tokens` table exists
- Added models: `EmailSendEvent`, `SystemEmailQuota`.
- Added/updated auth registration tests and runbook docs.
## Verification
- `php artisan migrate`
- `php artisan test`
- Focused token hardening tests ✅ (`RegistrationTokenVerificationTest`)
## Notes
- Current local branch: `feat/registration-antispam-complete`
- Local commit: `b239af9`
- Push/PR creation is currently blocked because this repo has no configured git remote and `gh` CLI is not installed.
## Commands to finish PR after remote setup
```bash
git remote add origin <your-repo-url>
git push -u origin feat/registration-antispam-complete
```
Then open PR in your Git host UI using:
- Base: `main` (or your default branch)
- Compare: `feat/registration-antispam-complete`
- Body: copy this file

View File

@@ -352,6 +352,12 @@ YOLO may return the same tag list format as CLIP, or object detections:
php artisan queue:work --queue=default php artisan queue:work --queue=default
``` ```
- Scout/Meilisearch syncing uses the `search` queue, so artwork indexing workers must include that queue as well:
```bash
php artisan queue:work --queue=search,default
```
- If `VISION_QUEUE=vision`, run worker for that queue: - If `VISION_QUEUE=vision`, run worker for that queue:
```bash ```bash

View File

@@ -1,8 +0,0 @@
# TODO SKINBASE NOVA
## FORUM
- [ ] we need to add in a main search (toolbar) and a search in the forum (search bar in the forum page)
## ARTWORKS
- [ ] http://skinbase26.test/art/69601/testna-slika => we shouldnt display follow for yourself

View File

@@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\AcademyContentMetricDaily;
use App\Models\AcademyEvent;
use App\Models\AcademyLike;
use App\Models\AcademySave;
use App\Models\AcademySearchLog;
use App\Models\AcademyUserProgress;
use App\Support\AcademyAnalytics\AcademyAnalyticsEventType;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Schema;
final class AcademyAnalyticsHealthCommand extends Command
{
protected $signature = 'academy:analytics-health {--json : Output machine-readable JSON}';
protected $description = 'Inspect Academy analytics collection health, rollup freshness, and privacy safeguards';
private const RETENTION_DAYS = 180;
public function handle(): int
{
$report = $this->buildReport();
if ((bool) $this->option('json')) {
$this->line(json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) ?: '{}');
return self::SUCCESS;
}
$this->line('Academy Analytics Health Check');
$this->line('==============================');
$this->newLine();
$this->line(sprintf('Events last 24h: %d', $report['events_last_24h']));
$this->line(sprintf('Events last 7d: %d', $report['events_last_7d']));
$this->line(sprintf('Latest event: %s', $report['latest_event_at'] ?? 'none'));
$this->line(sprintf('Latest rollup date: %s', $report['latest_rollup_date'] ?? 'none'));
$this->line(sprintf('Search logs: %d', $report['search_logs']));
$this->line(sprintf('Search clicks: %d', $report['search_clicks']));
$this->line(sprintf('Likes: %d', $report['likes']));
$this->line(sprintf('Saves: %d', $report['saves']));
$this->line(sprintf('Progress records: %d', $report['progress_records']));
$this->line(sprintf('Prompt copies: %d', $report['prompt_copies']));
$this->line(sprintf('Upgrade clicks: %d', $report['upgrade_clicks']));
$this->line(sprintf('Human events: %d', $report['human_events']));
$this->line(sprintf('Bot/admin events: %d', $report['bot_admin_events']));
$this->line(sprintf('Recent daily metric rows: %d', $report['recent_daily_metric_rows']));
$this->line(sprintf('Raw IP storage detected: %s', $report['raw_ip_storage_detected'] ? 'yes' : 'no'));
$this->line(sprintf('Events older than retention: %d', $report['events_older_than_retention']));
$this->newLine();
foreach ($report['warnings'] as $warning) {
$this->warn(sprintf('WARNING: %s', $warning));
}
$this->info(sprintf('Status: %s', $report['status']));
return self::SUCCESS;
}
/**
* @return array<string, mixed>
*/
private function buildReport(): array
{
$now = now();
$last24Hours = $now->copy()->subDay();
$last7Days = $now->copy()->subDays(7);
$retentionCutoff = $now->copy()->subDays(self::RETENTION_DAYS);
$warnings = [];
$rawIpStorageDetected = $this->rawIpStorageDetected();
$eventsTableExists = Schema::hasTable('academy_events');
$metricsTableExists = Schema::hasTable('academy_content_metrics_daily');
$searchLogsTableExists = Schema::hasTable('academy_search_logs');
$likesTableExists = Schema::hasTable('academy_likes');
$savesTableExists = Schema::hasTable('academy_saves');
$progressTableExists = Schema::hasTable('academy_user_progress');
$latestEvent = $eventsTableExists ? AcademyEvent::query()->latest('occurred_at')->value('occurred_at') : null;
$latestRollup = $metricsTableExists ? AcademyContentMetricDaily::query()->latest('date')->value('date') : null;
$searchLogCount = $searchLogsTableExists ? AcademySearchLog::query()->count() : 0;
$searchClickCount = $searchLogsTableExists ? AcademySearchLog::query()->whereNotNull('clicked_content_id')->count() : 0;
$eventsOlderThanRetention = $eventsTableExists ? AcademyEvent::query()->where('occurred_at', '<', $retentionCutoff)->count() : 0;
$recentDailyMetricRows = $metricsTableExists
? AcademyContentMetricDaily::query()->whereBetween('date', [$now->copy()->subDays(6)->toDateString(), $now->toDateString()])->count()
: 0;
$report = [
'events_last_24h' => $eventsTableExists ? AcademyEvent::query()->where('occurred_at', '>=', $last24Hours)->count() : 0,
'events_last_7d' => $eventsTableExists ? AcademyEvent::query()->where('occurred_at', '>=', $last7Days)->count() : 0,
'latest_event_at' => $latestEvent ? Carbon::parse((string) $latestEvent)->toDateTimeString() : null,
'latest_rollup_date' => $latestRollup ? Carbon::parse((string) $latestRollup)->toDateString() : null,
'search_logs' => $searchLogCount,
'search_clicks' => $searchClickCount,
'likes' => $likesTableExists ? AcademyLike::query()->count() : 0,
'saves' => $savesTableExists ? AcademySave::query()->count() : 0,
'progress_records' => $progressTableExists ? AcademyUserProgress::query()->count() : 0,
'prompt_copies' => $eventsTableExists ? AcademyEvent::query()->where('event_type', AcademyAnalyticsEventType::PROMPT_COPY)->count() : 0,
'upgrade_clicks' => $eventsTableExists ? AcademyEvent::query()->where('event_type', AcademyAnalyticsEventType::UPGRADE_CLICK)->count() : 0,
'human_events' => $eventsTableExists ? AcademyEvent::query()->where('is_bot', false)->where('is_admin', false)->where('is_suspicious', false)->count() : 0,
'bot_admin_events' => $eventsTableExists ? AcademyEvent::query()->where(function ($query): void {
$query->where('is_bot', true)->orWhere('is_admin', true)->orWhere('is_suspicious', true);
})->count() : 0,
'raw_ip_storage_detected' => $rawIpStorageDetected,
'events_older_than_retention' => $eventsOlderThanRetention,
'recent_daily_metric_rows' => $recentDailyMetricRows,
'retention_days' => self::RETENTION_DAYS,
'tables_present' => [
'academy_events' => $eventsTableExists,
'academy_content_metrics_daily' => $metricsTableExists,
'academy_search_logs' => $searchLogsTableExists,
'academy_likes' => $likesTableExists,
'academy_saves' => $savesTableExists,
'academy_user_progress' => $progressTableExists,
],
'warnings' => [],
'status' => 'OK',
];
foreach ($report['tables_present'] as $table => $present) {
if (! $present) {
$warnings[] = sprintf('Analytics table %s is missing.', $table);
}
}
if ($report['events_last_24h'] === 0) {
$warnings[] = 'No events received in last 24 hours.';
}
if ($report['events_last_7d'] === 0) {
$warnings[] = 'No events received in last 7 days.';
}
if ($report['latest_rollup_date'] === null) {
$warnings[] = 'No rollup rows exist yet.';
} elseif ($report['latest_rollup_date'] !== $now->toDateString()) {
$warnings[] = 'Rollup has not run for today.';
}
if ($searchLogCount > 0 && $searchClickCount === 0) {
$warnings[] = 'Search clicks are zero although search logs exist.';
}
if ($eventsOlderThanRetention > 0) {
$warnings[] = 'Raw events older than configured retention period exist.';
}
if ($recentDailyMetricRows === 0) {
$warnings[] = 'No daily metrics exist for recent days.';
}
if ($rawIpStorageDetected) {
$warnings[] = 'Raw IP storage indicators were found in Academy analytics tables.';
}
$report['warnings'] = $warnings;
$report['status'] = $warnings === [] ? 'OK' : 'WARNING';
return $report;
}
private function rawIpStorageDetected(): bool
{
foreach (['academy_events', 'academy_search_logs', 'academy_content_metrics_daily', 'academy_likes', 'academy_saves', 'academy_user_progress'] as $table) {
if (! Schema::hasTable($table)) {
continue;
}
foreach (['ip', 'ip_address', 'visitor_ip', 'raw_ip', 'remote_addr'] as $column) {
if (Schema::hasColumn($table, $column)) {
return true;
}
}
}
return false;
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\AcademyEvent;
use Illuminate\Console\Command;
final class AcademyAnalyticsPruneEventsCommand extends Command
{
protected $signature = 'academy:analytics-prune-events {--days=180}';
protected $description = 'Delete old raw Academy analytics events while keeping daily rollups';
public function handle(): int
{
$days = max(1, (int) $this->option('days'));
$deleted = AcademyEvent::query()
->where('occurred_at', '<', now()->subDays($days)->startOfDay())
->delete();
$this->info(sprintf('Pruned %d Academy analytics event(s).', $deleted));
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\AcademyContentMetricDaily;
use App\Services\Academy\AcademyPopularityService;
use Illuminate\Console\Command;
final class AcademyAnalyticsRecalculatePopularityCommand extends Command
{
protected $signature = 'academy:analytics-recalculate-popularity {--days=30}';
protected $description = 'Recalculate Academy daily popularity and conversion scores';
public function __construct(private readonly AcademyPopularityService $popularity)
{
parent::__construct();
}
public function handle(): int
{
$days = max(1, (int) $this->option('days'));
AcademyContentMetricDaily::query()
->where('date', '>=', now()->subDays($days - 1)->toDateString())
->chunkById(500, function ($rows): void {
foreach ($rows as $row) {
$row->forceFill([
'popularity_score' => $this->popularity->calculatePopularityScore($row->toArray()),
'conversion_score' => $this->popularity->calculateConversionScore($row->toArray()),
])->save();
}
});
$this->info(sprintf('Recalculated Academy popularity for the last %d day(s).', $days));
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,258 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\AcademyContentMetricDaily;
use App\Models\AcademyEvent;
use App\Models\AcademyLike;
use App\Models\AcademySave;
use App\Models\AcademySearchLog;
use App\Services\Academy\AcademyPopularityService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use Carbon\CarbonPeriod;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
final class AcademyAnalyticsRollupCommand extends Command
{
protected $signature = 'academy:analytics-rollup {--date=} {--from=} {--to=}';
protected $description = 'Aggregate Academy analytics raw events and interaction records into daily content metrics';
public function __construct(private readonly AcademyPopularityService $popularity)
{
parent::__construct();
}
public function handle(): int
{
[$from, $to] = $this->resolveRange();
foreach (CarbonPeriod::create($from, $to) as $date) {
$this->rollupDate(Carbon::parse($date));
$this->line(sprintf('Rolled up Academy analytics for %s.', Carbon::parse($date)->toDateString()));
}
return self::SUCCESS;
}
private function rollupDate(Carbon $date): void
{
$start = $date->copy()->startOfDay();
$end = $date->copy()->endOfDay();
$metrics = [];
$uniqueVisitors = [];
$engagedDurations = [];
AcademyEvent::query()
->whereBetween('occurred_at', [$start, $end])
->orderBy('id')
->chunkById(1000, function ($events) use (&$metrics, &$uniqueVisitors, &$engagedDurations): void {
foreach ($events as $event) {
if ($event->is_bot || $event->is_admin || $event->is_suspicious) {
continue;
}
$key = $this->metricKey((string) ($event->content_type ?? ''), $event->content_id ? (int) $event->content_id : null);
$this->ensureMetric($metrics, (string) ($event->content_type ?? ''), $event->content_id ? (int) $event->content_id : null, $key);
$visitorKey = $event->user_id ? sprintf('user:%d', (int) $event->user_id) : trim((string) ($event->visitor_id ?? ''));
if ($visitorKey !== '') {
$uniqueVisitors[$key][$visitorKey] = true;
}
$eventType = (string) $event->event_type;
if (in_array($eventType, ['academy_page_view', 'academy_content_view', 'academy_lesson_view', 'academy_course_view', 'academy_prompt_pack_view', 'academy_challenge_view'], true)) {
$metrics[$key]['views']++;
if ($event->is_logged_in) {
$metrics[$key]['user_views']++;
} else {
$metrics[$key]['guest_views']++;
}
if ($event->is_subscriber) {
$metrics[$key]['subscriber_views']++;
}
}
if ($eventType === 'academy_engaged_view') {
$metrics[$key]['engaged_views']++;
$engagedDurations[$key][] = max(0, (int) ($event->metadata['engaged_seconds'] ?? 15));
}
if ($eventType === 'academy_scroll_50') {
$metrics[$key]['scroll_50']++;
}
if ($eventType === 'academy_scroll_75') {
$metrics[$key]['scroll_75']++;
}
if ($eventType === 'academy_scroll_100') {
$metrics[$key]['scroll_100']++;
}
if ($eventType === 'academy_prompt_copy') {
$metrics[$key]['prompt_copies']++;
}
if ($eventType === 'academy_prompt_negative_copy') {
$metrics[$key]['negative_prompt_copies']++;
}
if (in_array($eventType, ['academy_lesson_started', 'academy_course_started', 'academy_challenge_started'], true)) {
$metrics[$key]['starts']++;
}
if (in_array($eventType, ['academy_lesson_completed', 'academy_course_completed', 'academy_challenge_submitted'], true)) {
$metrics[$key]['completions']++;
}
if ($eventType === 'academy_upgrade_click') {
$metrics[$key]['upgrade_clicks']++;
}
if ($eventType === 'academy_premium_preview_view') {
$metrics[$key]['premium_preview_views']++;
}
if ($eventType === 'academy_search_result_click') {
$metrics[$key]['search_clicks']++;
$searchKey = $this->metricKey(AcademyAnalyticsContentType::SEARCH, null);
$this->ensureMetric($metrics, AcademyAnalyticsContentType::SEARCH, null, $searchKey);
$metrics[$searchKey]['search_clicks']++;
}
}
});
foreach (AcademyLike::query()->whereBetween('created_at', [$start, $end])->get() as $like) {
$key = $this->metricKey((string) $like->content_type, (int) $like->content_id);
$this->ensureMetric($metrics, (string) $like->content_type, (int) $like->content_id, $key);
$metrics[$key]['likes']++;
}
foreach (AcademySave::query()->whereBetween('created_at', [$start, $end])->get() as $save) {
$key = $this->metricKey((string) $save->content_type, (int) $save->content_id);
$this->ensureMetric($metrics, (string) $save->content_type, (int) $save->content_id, $key);
$metrics[$key]['saves']++;
}
foreach (AcademySearchLog::query()->whereBetween('created_at', [$start, $end])->get() as $searchLog) {
$key = $this->metricKey(AcademyAnalyticsContentType::SEARCH, null);
$this->ensureMetric($metrics, AcademyAnalyticsContentType::SEARCH, null, $key);
$metrics[$key]['search_impressions']++;
if ((int) $searchLog->results_count === 0) {
$metrics[$key]['bounce_count']++;
}
$visitorKey = $searchLog->user_id ? sprintf('user:%d', (int) $searchLog->user_id) : trim((string) ($searchLog->visitor_id ?? ''));
if ($visitorKey !== '') {
$uniqueVisitors[$key][$visitorKey] = true;
}
}
foreach ($metrics as $key => $metric) {
$metric['unique_visitors'] = isset($uniqueVisitors[$key]) ? count($uniqueVisitors[$key]) : 0;
$metric['avg_engaged_seconds'] = isset($engagedDurations[$key]) && $engagedDurations[$key] !== []
? (int) round(array_sum($engagedDurations[$key]) / count($engagedDurations[$key]))
: null;
$metric['bounce_count'] = max((int) ($metric['bounce_count'] ?? 0), max(0, (int) $metric['views'] - (int) $metric['engaged_views']));
$metric['popularity_score'] = $this->popularity->calculatePopularityScore($metric);
$metric['conversion_score'] = $this->popularity->calculateConversionScore($metric);
AcademyContentMetricDaily::query()->upsert([
array_merge($metric, [
'date' => $date->copy()->startOfDay(),
'created_at' => now(),
'updated_at' => now(),
]),
], ['date', 'content_type', 'content_id'], [
'views',
'unique_visitors',
'guest_views',
'user_views',
'subscriber_views',
'engaged_views',
'scroll_50',
'scroll_75',
'scroll_100',
'likes',
'saves',
'prompt_copies',
'negative_prompt_copies',
'starts',
'completions',
'upgrade_clicks',
'premium_preview_views',
'search_impressions',
'search_clicks',
'bounce_count',
'avg_engaged_seconds',
'popularity_score',
'conversion_score',
'updated_at',
]);
}
}
/**
* @param array<string, array<string, mixed>> $metrics
*/
private function ensureMetric(array &$metrics, string $contentType, ?int $contentId, string $key): void
{
if (isset($metrics[$key])) {
return;
}
$metrics[$key] = [
'content_type' => $contentType,
'content_id' => $contentId,
'views' => 0,
'unique_visitors' => 0,
'guest_views' => 0,
'user_views' => 0,
'subscriber_views' => 0,
'engaged_views' => 0,
'scroll_50' => 0,
'scroll_75' => 0,
'scroll_100' => 0,
'likes' => 0,
'saves' => 0,
'prompt_copies' => 0,
'negative_prompt_copies' => 0,
'starts' => 0,
'completions' => 0,
'upgrade_clicks' => 0,
'premium_preview_views' => 0,
'search_impressions' => 0,
'search_clicks' => 0,
'bounce_count' => 0,
'avg_engaged_seconds' => null,
'popularity_score' => 0,
'conversion_score' => 0,
];
}
private function metricKey(string $contentType, ?int $contentId): string
{
return sprintf('%s:%s', $contentType, $contentId ?? 'none');
}
/**
* @return array{0: Carbon, 1: Carbon}
*/
private function resolveRange(): array
{
$date = $this->option('date');
$from = $this->option('from');
$to = $this->option('to');
if (is_string($date) && trim($date) !== '') {
$resolved = Carbon::parse($date)->startOfDay();
return [$resolved, $resolved->copy()];
}
$resolvedFrom = is_string($from) && trim($from) !== ''
? Carbon::parse($from)->startOfDay()
: now()->subDay()->startOfDay();
$resolvedTo = is_string($to) && trim($to) !== ''
? Carbon::parse($to)->startOfDay()
: $resolvedFrom->copy();
return [$resolvedFrom, $resolvedTo];
}
}

View File

@@ -0,0 +1,305 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Academy\AcademyBillingPlanService;
use Illuminate\Console\Command;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Schema;
final class AcademyBillingHealthCommand extends Command
{
protected $signature = 'academy:billing-health
{--json : Output machine-readable JSON}
{--strict : Exit non-zero when blocking issues are found}';
protected $description = 'Inspect Academy Stripe billing deployment readiness, config completeness, and Cashier route wiring';
public function __construct(
private readonly AcademyBillingPlanService $plans,
) {
parent::__construct();
}
public function handle(): int
{
$report = $this->buildReport();
if ((bool) $this->option('json')) {
$this->line((string) json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
return $this->exitCodeFor($report);
}
$this->line('Academy Billing Health Check');
$this->line('============================');
$this->newLine();
$this->line(sprintf('Environment: %s', $report['environment']));
$this->line(sprintf('App URL: %s', $report['app_url'] ?? 'unset'));
$this->line(sprintf('Academy enabled: %s', $report['academy_enabled'] ? 'yes' : 'no'));
$this->line(sprintf('Academy billing enabled: %s', $report['academy_billing_enabled'] ? 'yes' : 'no'));
$this->line(sprintf('Subscription name: %s', $report['subscription_name']));
$this->line(sprintf('Cashier path: %s', $report['cashier_path']));
$this->line(sprintf('Cashier webhook route: %s', $report['routes']['cashier_webhook']['present'] ? ($report['routes']['cashier_webhook']['url'] ?? 'present') : 'missing'));
$this->line(sprintf('Academy pricing route: %s', $report['routes']['academy_pricing']['present'] ? ($report['routes']['academy_pricing']['url'] ?? 'present') : 'missing'));
$this->line(sprintf('Academy billing account route: %s', $report['routes']['academy_billing_account']['present'] ? ($report['routes']['academy_billing_account']['url'] ?? 'present') : 'missing'));
$this->line(sprintf('Stripe key configured: %s', $report['stripe']['publishable_key_configured'] ? 'yes' : 'no'));
$this->line(sprintf('Stripe secret configured: %s', $report['stripe']['secret_key_configured'] ? 'yes' : 'no'));
$this->line(sprintf('Webhook secret configured: %s', $report['stripe']['webhook_secret_configured'] ? 'yes' : 'no'));
$this->line(sprintf('Cashier currency: %s', $report['stripe']['currency'] ?: 'unset'));
$this->line(sprintf('Cashier locale: %s', $report['stripe']['currency_locale'] ?: 'unset'));
$this->line(sprintf('Configured plans: %d', $report['configured_plan_count']));
$this->line(sprintf('Plans missing Stripe price IDs: %d', count($report['missing_plan_keys'])));
$this->line(sprintf('Billing tables present: %s', $report['tables']['subscriptions'] && $report['tables']['subscription_items'] && $report['tables']['academy_billing_events'] ? 'yes' : 'no'));
$this->line(sprintf('User billing columns present: %s', $report['users_billing_columns_present'] ? 'yes' : 'no'));
$this->newLine();
foreach ($report['blockers'] as $blocker) {
$this->error(sprintf('BLOCKER: %s', $blocker));
}
foreach ($report['warnings'] as $warning) {
$this->warn(sprintf('WARNING: %s', $warning));
}
if ($report['plan_summaries'] !== []) {
$this->newLine();
$this->line('Plans');
$this->line('-----');
foreach ($report['plan_summaries'] as $plan) {
$this->line(sprintf(
'%s: tier=%s interval=%s price_id=%s',
$plan['key'],
$plan['tier'],
$plan['interval'],
$plan['configured'] ? 'configured' : 'missing'
));
}
}
$this->newLine();
$this->info(sprintf('Status: %s', $report['status']));
return $this->exitCodeFor($report);
}
/**
* @return array<string, mixed>
*/
private function buildReport(): array
{
$stripeKey = $this->configuredString(config('cashier.key'));
$stripeSecret = $this->firstConfiguredString(config('cashier.secret'), env('STRIPE_SECRET'));
$webhookSecret = $this->firstConfiguredString(config('cashier.webhook.secret'), env('STRIPE_WEBHOOK_SECRET'));
$currency = trim((string) config('cashier.currency', env('CASHIER_CURRENCY', '')));
$currencyLocale = trim((string) config('cashier.currency_locale', env('CASHIER_CURRENCY_LOCALE', '')));
$academyEnabled = (bool) config('academy.enabled', true);
$billingEnabled = $this->plans->enabled();
$missingPlanKeys = $this->plans->missingPriceIds();
$routes = [
'cashier_webhook' => $this->routeStatus('cashier.webhook'),
'academy_pricing' => $this->routeStatus('academy.pricing'),
'academy_billing_account' => $this->routeStatus('academy.billing.account'),
'academy_billing_portal' => $this->routeStatus('academy.billing.portal'),
'admin_academy_billing' => $this->routeStatus('admin.academy.billing'),
];
$tables = [
'users' => Schema::hasTable('users'),
'subscriptions' => Schema::hasTable('subscriptions'),
'subscription_items' => Schema::hasTable('subscription_items'),
'academy_billing_events' => Schema::hasTable('academy_billing_events'),
];
$userBillingColumns = [
'stripe_id' => $tables['users'] && Schema::hasColumn('users', 'stripe_id'),
'pm_type' => $tables['users'] && Schema::hasColumn('users', 'pm_type'),
'pm_last_four' => $tables['users'] && Schema::hasColumn('users', 'pm_last_four'),
'trial_ends_at' => $tables['users'] && Schema::hasColumn('users', 'trial_ends_at'),
];
$planSummaries = collect(array_keys($this->plans->plans()))
->map(function (string $key): array {
$plan = $this->plans->plan($key);
return [
'key' => $key,
'tier' => (string) ($plan['tier'] ?? 'free'),
'interval' => (string) ($plan['interval'] ?? 'monthly'),
'configured' => (bool) ($plan['configured'] ?? false),
];
})
->values()
->all();
$blockers = [];
$warnings = [];
if (! $academyEnabled) {
$warnings[] = 'SKINBASE_ACADEMY_ENABLED is disabled, so billing cannot be reached by users.';
}
if (! $billingEnabled) {
$warnings[] = 'ACADEMY_BILLING_ENABLED is disabled. Checkout routes will stay unavailable until rollout is enabled.';
}
if (! $this->isConfiguredSecret($stripeKey, 'pk_')) {
$blockers[] = 'STRIPE_KEY is missing or still using a placeholder value.';
}
if (! $this->isConfiguredSecret($stripeSecret, 'sk_')) {
$blockers[] = 'STRIPE_SECRET is missing or still using a placeholder value.';
}
if (! $this->isConfiguredSecret($webhookSecret, 'whsec_')) {
$blockers[] = 'STRIPE_WEBHOOK_SECRET is missing or still using a placeholder value.';
}
if ($currency === '') {
$blockers[] = 'CASHIER_CURRENCY is not configured.';
}
if ($currencyLocale === '') {
$warnings[] = 'CASHIER_CURRENCY_LOCALE is not configured.';
}
if ($missingPlanKeys !== []) {
$blockers[] = 'Stripe price IDs are missing for: '.implode(', ', $missingPlanKeys).'.';
}
if (! $routes['cashier_webhook']['present']) {
$blockers[] = 'Cashier webhook route is missing; Stripe cannot sync subscriptions.';
}
if (! $routes['academy_pricing']['present']) {
$blockers[] = 'Academy pricing route is missing.';
}
if (! $routes['academy_billing_account']['present']) {
$blockers[] = 'Academy billing account route is missing.';
}
foreach ($tables as $table => $present) {
if (! $present) {
$blockers[] = sprintf('Required billing table %s is missing.', $table);
}
}
foreach ($userBillingColumns as $column => $present) {
if (! $present) {
$blockers[] = sprintf('Required users.%s billing column is missing.', $column);
}
}
if (! $routes['admin_academy_billing']['present']) {
$warnings[] = 'Moderation Academy billing overview route is missing.';
}
if (Arr::where($planSummaries, fn (array $plan): bool => $plan['configured'] === false) === []) {
$warnings[] = 'All configured Academy plans have Stripe price IDs. Verify they are live-mode IDs before production rollout.';
}
$invalidPlanKeys = collect(array_keys($this->plans->plans()))
->filter(function (string $key): bool {
$plan = $this->plans->plan($key);
return $plan !== null && ($plan['configured'] ?? false) && ! ($plan['price_id_valid'] ?? false);
})
->values()
->all();
if ($invalidPlanKeys !== []) {
$blockers[] = 'Stripe price IDs are malformed for: '.implode(', ', $invalidPlanKeys).'. Use real price object IDs that start with price_.';
}
$status = $blockers !== []
? 'BLOCKED'
: ($warnings !== [] ? 'WARNING' : 'OK');
return [
'environment' => app()->environment(),
'app_url' => config('app.url'),
'academy_enabled' => $academyEnabled,
'academy_billing_enabled' => $billingEnabled,
'subscription_name' => $this->plans->subscriptionName(),
'cashier_path' => (string) config('cashier.path', 'stripe'),
'stripe' => [
'publishable_key_configured' => $this->isConfiguredSecret($stripeKey, 'pk_'),
'secret_key_configured' => $this->isConfiguredSecret($stripeSecret, 'sk_'),
'webhook_secret_configured' => $this->isConfiguredSecret($webhookSecret, 'whsec_'),
'currency' => $currency,
'currency_locale' => $currencyLocale,
],
'configured_plan_count' => count($planSummaries),
'missing_plan_keys' => $missingPlanKeys,
'invalid_plan_keys' => $invalidPlanKeys,
'plan_summaries' => $planSummaries,
'routes' => $routes,
'tables' => $tables,
'user_billing_columns' => $userBillingColumns,
'users_billing_columns_present' => ! in_array(false, $userBillingColumns, true),
'blockers' => array_values(array_unique($blockers)),
'warnings' => array_values(array_unique($warnings)),
'status' => $status,
];
}
/**
* @return array{present: bool, url: string|null}
*/
private function routeStatus(string $name): array
{
if (! Route::has($name)) {
return [
'present' => false,
'url' => null,
];
}
return [
'present' => true,
'url' => route($name),
];
}
private function isConfiguredSecret(string $value, string $expectedPrefix): bool
{
$value = trim($value);
if ($value === '' || ! str_starts_with($value, $expectedPrefix)) {
return false;
}
return ! str_contains(strtolower($value), 'xxx');
}
/**
* @param array<string, mixed> $report
*/
private function exitCodeFor(array $report): int
{
if ((bool) $this->option('strict') && $report['status'] === 'BLOCKED') {
return self::FAILURE;
}
return self::SUCCESS;
}
private function firstConfiguredString(mixed ...$values): string
{
foreach ($values as $value) {
$value = $this->configuredString($value);
if ($value !== '') {
return $value;
}
}
return '';
}
private function configuredString(mixed $value): string
{
return is_string($value) ? trim($value) : '';
}
}

View File

@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\AcademyCourse;
use App\Models\AcademyCourseLesson;
use App\Models\AcademyCourseSection;
use App\Models\AcademyLesson;
use App\Services\Academy\AcademyCacheService;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
final class AcademyCoursesSyncFoundationsCommand extends Command
{
protected $signature = 'academy:courses:sync-foundations';
protected $description = 'Create or update the default AI-Assisted Digital Art Foundations Academy course.';
public function __construct(private readonly AcademyCacheService $cache)
{
parent::__construct();
}
public function handle(): int
{
$course = AcademyCourse::query()->updateOrCreate(
['slug' => 'ai-assisted-digital-art-foundations'],
[
'title' => 'AI-Assisted Digital Art Foundations',
'subtitle' => 'A guided path through prompting, publishing, and better Skinbase-ready workflows.',
'excerpt' => 'Learn the foundations of AI-assisted digital art, from better prompts and ethical rules to preparing, tagging, and publishing artwork on Skinbase.',
'description' => 'A starter course for Skinbase creators who want a structured path from core AI-art concepts to cleaner publishing-ready results.',
'access_level' => 'free',
'difficulty' => 'beginner',
'status' => 'published',
'is_featured' => true,
'order_num' => 1,
'published_at' => now(),
],
);
$sectionOrder = [
'Introduction',
'Prompting Basics',
'Publishing on Skinbase',
'Workflow and Quality',
];
$sections = collect($sectionOrder)->mapWithKeys(function (string $title, int $index) use ($course): array {
$section = AcademyCourseSection::query()->updateOrCreate(
['course_id' => $course->id, 'slug' => Str::slug($title)],
[
'title' => $title,
'order_num' => $index,
'is_visible' => true,
],
);
return [$title => $section];
});
$lessonMap = [
'Introduction' => [
'what-is-ai-assisted-digital-art',
'ai-ethics-and-skinbase-upload-rules',
'ai-generated-vs-ai-assisted-artwork',
],
'Prompting Basics' => [
'prompting-basics-for-skinbase-creators',
'how-to-write-better-wallpaper-prompts',
'understanding-style-mood-lighting-and-composition',
],
'Publishing on Skinbase' => [
'how-to-prepare-ai-artwork-for-upload',
'how-to-choose-better-tags-and-categories',
],
'Workflow and Quality' => [
'how-to-avoid-common-ai-image-problems',
'from-idea-to-artwork-a-simple-skinbase-workflow',
],
];
$orderNum = 0;
foreach ($lessonMap as $sectionTitle => $slugs) {
$section = $sections->get($sectionTitle);
foreach ($slugs as $slug) {
$lesson = AcademyLesson::query()->where('slug', $slug)->first();
if (! $lesson instanceof AcademyLesson) {
$this->warn(sprintf('Skipped missing lesson [%s].', $slug));
continue;
}
AcademyCourseLesson::query()->updateOrCreate(
[
'course_id' => $course->id,
'lesson_id' => $lesson->id,
],
[
'section_id' => $section?->id,
'order_num' => $orderNum,
'is_required' => true,
],
);
$orderNum++;
}
}
$course->forceFill([
'lessons_count_cache' => AcademyCourseLesson::query()->where('course_id', $course->id)->count(),
])->save();
$this->cache->clearAll();
$this->info('AI-Assisted Digital Art Foundations course synced.');
return self::SUCCESS;
}
}

View File

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

View File

@@ -4,95 +4,271 @@ declare(strict_types=1);
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Jobs\Sitemaps\BuildSitemapReleaseJob; use App\Models\Artwork;
use App\Services\Sitemaps\SitemapBuildService; 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\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 final class BuildSitemapsCommand extends Command
{ {
protected $signature = 'skinbase:sitemaps:build protected $signature = 'skinbase:sitemaps:build
{--only=* : Limit the build to one or more sitemap families} {--only=* : Limit to specific sitemap families (comma or space separated)}
{--release= : Override the generated release id} {--disk= : Override the target filesystem disk (default: sitemaps.static_publish.disk)}
{--shards : Show per-shard output in the command report} {--progress : Show a progress bar tracking processed URLs for each family}';
{--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}';
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); $totalStart = microtime(true);
$families = $this->selectedFamilies($build); $families = $this->selectedFamilies($build);
$releaseId = ($value = $this->option('release')) !== null && trim((string) $value) !== '' ? trim((string) $value) : null;
if ($families === []) { if ($families === []) {
$this->error('No valid sitemap families were selected.'); $this->error('No valid sitemap families selected.');
return self::INVALID; 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')) { $this->info('Disk: ' . $diskName);
BuildSitemapReleaseJob::dispatch($families, $releaseId); $this->info('Families: ' . implode(', ', $families));
$this->info('Queued sitemap release build' . ($releaseId !== null ? ' for [' . $releaseId . '].' : '.')); $this->newLine();
return self::SUCCESS; // ── Root sitemap index ────────────────────────────────────────────
} $t = microtime(true);
$this->line(' Building sitemap index…');
try { $index = $build->buildIndex(force: true, persist: false, families: $families);
$manifest = $publish->buildRelease($families, $releaseId); $disk->put('sitemap.xml', $index['content']);
} catch (\Throwable $exception) { $written++;
$this->error($exception->getMessage());
return self::FAILURE;
}
$totalUrls = 0;
$totalDocuments = 0;
foreach ($families as $family) {
$names = (array) data_get($manifest, 'families.' . $family . '.documents', []);
$familyUrls = 0;
if (! $showShards) {
$this->line('Building family [' . $family . '] with ' . count($names) . ' document(s).');
}
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++;
if ($showShards || ! str_contains((string) $name, '-000')) {
$this->line(sprintf( $this->line(sprintf(
' - %s [%s]', ' <info>✔</info> sitemap.xml %d entries <comment>%.3fs</comment>',
$name, $index['url_count'],
$documentType, microtime(true) - $t,
));
// ── Per-family documents ──────────────────────────────────────────
foreach ($families as $family) {
$familyStart = microtime(true);
$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;
}
$names = $build->canonicalDocumentNamesForFamily($family);
$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,
));
} }
$this->info(sprintf('Family [%s] complete: urls=%d documents=%d', $family, (int) data_get($manifest, 'families.' . $family . '.url_count', 0), count($names))); // ── Summary ───────────────────────────────────────────────────────
} $this->newLine();
$totalDocuments++;
$this->info(sprintf( $this->info(sprintf(
'Sitemap release [%s] complete: families=%d documents=%d urls=%d status=%s duration=%.2fs', 'Done: %d file(s) written, %d failed total <comment>%.3fs</comment>',
(string) $manifest['release_id'], $written,
(int) data_get($manifest, 'totals.families', 0), $failed,
(int) data_get($manifest, 'totals.documents', 0), microtime(true) - $totalStart,
(int) data_get($manifest, 'totals.urls', 0),
(string) ($manifest['status'] ?? 'built'),
microtime(true) - $startedAt,
)); ));
$this->line('Sitemap index complete');
return self::SUCCESS; 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(
' <info>✔</info> %s %d URLs <comment>%.3fs</comment>',
$name . '.xml',
count($items),
microtime(true) - $t,
));
}
});
if ($bar !== null) {
$bar->setMessage('done');
$bar->finish();
$this->newLine();
}
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],
));
usort($timestamps, static fn (Carbon $a, Carbon $b): int => $b->timestamp <=> $a->timestamp);
return new SitemapUrl(
route('art.show', ['id' => (int) $artwork->id, 'slug' => $slug]),
$timestamps[0] ?? null,
$images,
);
} }
/** /**

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\WorldWebStory;
use App\Services\WebStories\WorldWebStoryAssetService;
use Illuminate\Console\Command;
final class BuildWorldWebStoryAssetsCommand extends Command
{
protected $signature = 'skinbase:webstories:build-assets
{story? : Story ID or slug}
{--published : Limit batch mode to published stories}
{--visible : Limit batch mode to stories currently visible on the public site}
{--limit=100 : Maximum stories to process in batch mode}
{--force : Rebuild already populated asset paths}
{--dry-run : Report changes without saving them}';
protected $description = 'Backfill poster, logo, and page background assets for World Web Stories';
public function handle(WorldWebStoryAssetService $assets): int
{
$storyKey = $this->argument('story');
$force = (bool) $this->option('force');
$dryRun = (bool) $this->option('dry-run');
if ($storyKey !== null && trim((string) $storyKey) !== '') {
$story = $this->resolveStory((string) $storyKey);
if (! $story instanceof WorldWebStory) {
$this->error(sprintf('Web story [%s] was not found.', (string) $storyKey));
return self::FAILURE;
}
return $this->buildOne($assets, $story, $force, $dryRun);
}
return $this->buildBatch($assets, $force, $dryRun, max(1, (int) $this->option('limit')));
}
private function buildOne(WorldWebStoryAssetService $assets, WorldWebStory $story, bool $force, bool $dryRun): int
{
$result = $assets->buildAssets($story, force: $force, dryRun: $dryRun);
$this->line(sprintf('Story [%d] %s', (int) $story->id, (string) $story->slug));
$this->line($result['updated'] ? 'Assets updated.' : 'No asset changes needed.');
foreach ((array) $result['story'] as $field => $value) {
$this->line(sprintf(' - story.%s = %s', (string) $field, (string) $value));
}
foreach ((array) $result['pages'] as $pageId => $changes) {
foreach ((array) $changes as $field => $value) {
$this->line(sprintf(' - page.%d.%s = %s', (int) $pageId, (string) $field, (string) $value));
}
}
return self::SUCCESS;
}
private function buildBatch(WorldWebStoryAssetService $assets, bool $force, bool $dryRun, int $limit): int
{
$processed = 0;
$updated = 0;
$this->storyQuery()
->limit($limit)
->get()
->each(function (WorldWebStory $story) use ($assets, $force, $dryRun, &$processed, &$updated): void {
$processed++;
$result = $assets->buildAssets($story, force: $force, dryRun: $dryRun);
if ($result['updated']) {
$updated++;
}
$this->line(sprintf('[%d] %s -> %s', (int) $story->id, (string) $story->slug, $result['updated'] ? 'updated' : 'unchanged'));
});
$this->info(sprintf('Done. processed=%d updated=%d', $processed, $updated));
return self::SUCCESS;
}
private function storyQuery()
{
return WorldWebStory::query()
->when((bool) $this->option('published'), fn ($query) => $query->published())
->when((bool) $this->option('visible'), fn ($query) => $query->visible())
->orderByDesc('published_at')
->orderByDesc('id');
}
private function resolveStory(string $value): ?WorldWebStory
{
return WorldWebStory::query()
->when(is_numeric($value), fn ($query) => $query->where('id', (int) $value), fn ($query) => $query->where('slug', $value))
->first();
}
}

View File

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

View File

@@ -0,0 +1,290 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Enhance;
use App\Models\EnhanceJob;
use App\Services\Enhance\EnhanceStorageService;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Throwable;
final class CleanupEnhanceJobsCommand extends Command
{
protected $signature = 'enhance:cleanup
{--dry-run : Preview cleanup actions only}
{--force : Delete files and update records}
{--only= : Restrict cleanup to expired, deleted, failed, or orphaned}
{--days= : Override retention days for failed or deleted cleanup}';
protected $description = 'Safely clean expired, deleted, failed, and orphaned Enhance files.';
public function __construct(
private readonly EnhanceStorageService $storage,
) {
parent::__construct();
}
public function handle(): int
{
$target = strtolower(trim((string) $this->option('only')));
$validTargets = ['', 'expired', 'deleted', 'failed', 'orphaned'];
if (! in_array($target, $validTargets, true)) {
$this->error('The --only option must be one of: expired, deleted, failed, orphaned.');
return self::FAILURE;
}
if ((bool) $this->option('dry-run') && (bool) $this->option('force')) {
$this->error('Use either --dry-run or --force, not both.');
return self::FAILURE;
}
$dryRun = (bool) $this->option('dry-run') || ! (bool) $this->option('force');
$daysOverride = $this->option('days');
$selectedTarget = $target !== '' ? $target : 'all';
Log::info('enhance.cleanup.started', [
'dry_run' => $dryRun,
'target' => $selectedTarget,
'days_override' => $daysOverride,
]);
if ($dryRun) {
$this->warn('Running in dry-run mode. No files will be deleted.');
}
$rows = [];
if ($target === '' || $target === 'expired') {
$expired = $this->cleanupExpiredCompletedJobs($dryRun);
$rows[] = ['expired', $expired['jobs'], $expired['files'], $dryRun ? 'dry-run' : 'cleaned'];
}
if ($target === '' || $target === 'failed') {
$failed = $this->cleanupFailedJobs($dryRun, $daysOverride);
$rows[] = ['failed', $failed['jobs'], $failed['files'], $dryRun ? 'dry-run' : 'cleaned'];
}
if ($target === '' || $target === 'deleted') {
$deleted = $this->cleanupSoftDeletedJobs($dryRun, $daysOverride);
$rows[] = ['deleted', $deleted['jobs'], $deleted['files'], $dryRun ? 'dry-run' : 'cleaned'];
}
if ($target === 'orphaned') {
$orphaned = $this->scanOrphanedFiles($dryRun);
$rows[] = ['orphaned', $orphaned['files'], $orphaned['deleted'], $dryRun ? 'dry-run' : 'deleted'];
if ($orphaned['unsupported']) {
$this->warn('Orphaned file scan was skipped because the configured disk does not support safe listing.');
}
foreach ($orphaned['sample'] as $path) {
$this->line(' - ' . $path);
}
}
if ($rows !== []) {
$this->table(['Target', 'Jobs/Files', 'Files deleted', 'Mode'], $rows);
}
Log::info('enhance.cleanup.completed', [
'dry_run' => $dryRun,
'target' => $selectedTarget,
'rows' => $rows,
]);
$this->info('Enhance cleanup finished.');
return self::SUCCESS;
}
private function cleanupExpiredCompletedJobs(bool $dryRun): array
{
$query = EnhanceJob::query()
->where('status', EnhanceJob::STATUS_COMPLETED)
->whereNotNull('expires_at')
->where('expires_at', '<=', now());
return $this->cleanupJobs($query, $dryRun, 'expired', fn (): array => [
'status' => EnhanceJob::STATUS_EXPIRED,
]);
}
private function cleanupFailedJobs(bool $dryRun, mixed $daysOverride): array
{
$days = $this->resolveDays($daysOverride, (int) config('enhance.lifecycle.failed_expires_after_days', 7));
$cutoff = now()->subDays($days);
$query = EnhanceJob::query()
->where('status', EnhanceJob::STATUS_FAILED)
->where(function (Builder $builder) use ($cutoff): void {
$builder
->where('finished_at', '<=', $cutoff)
->orWhere(function (Builder $fallback) use ($cutoff): void {
$fallback->whereNull('finished_at')->where('created_at', '<=', $cutoff);
});
});
return $this->cleanupJobs($query, $dryRun, 'failed-expired');
}
private function cleanupSoftDeletedJobs(bool $dryRun, mixed $daysOverride): array
{
$days = $this->resolveDays($daysOverride, (int) config('enhance.lifecycle.deleted_file_grace_days', 1));
$cutoff = now()->subDays($days);
$query = EnhanceJob::withTrashed()
->whereNotNull('deleted_at')
->where('deleted_at', '<=', $cutoff);
return $this->cleanupJobs($query, $dryRun, 'deleted-grace');
}
private function cleanupJobs(Builder $query, bool $dryRun, string $reason, ?callable $attributes = null): array
{
$result = ['jobs' => 0, 'files' => 0];
$chunkSize = max(1, (int) config('enhance.lifecycle.cleanup_chunk_size', 100));
$query->chunkById($chunkSize, function ($jobs) use (&$result, $dryRun, $reason, $attributes): void {
foreach ($jobs as $job) {
$result['jobs']++;
$result['files'] += count($this->enhanceJobPaths($job));
if ($dryRun) {
continue;
}
$deleteResult = $this->storage->deleteFilesForJob($job);
$metadata = is_array($job->metadata) ? $job->metadata : [];
$job->forceFill(array_merge(
$this->cleanupAttributesForJob($job),
$attributes ? $attributes($job) : [],
[
'metadata' => array_merge($metadata, [
'cleanup' => [
'files_removed_at' => now()->toIso8601String(),
'reason' => $reason,
'deleted' => $deleteResult['deleted'],
],
]),
],
))->save();
}
});
return $result;
}
private function scanOrphanedFiles(bool $dryRun): array
{
$disk = $this->storage->diskName();
$knownPaths = array_fill_keys(array_map(
static fn (string $path): string => ltrim($path, '/'),
$this->storage->listKnownJobPaths(),
), true);
$sample = [];
$result = [
'files' => 0,
'deleted' => 0,
'unsupported' => false,
'sample' => [],
];
foreach ($this->enhancePrefixes() as $prefix) {
try {
$files = Storage::disk($disk)->allFiles($prefix);
} catch (Throwable) {
$result['unsupported'] = true;
return $result;
}
foreach ($files as $file) {
$normalized = ltrim($file, '/');
if (isset($knownPaths[$normalized])) {
continue;
}
$result['files']++;
if (count($sample) < 20) {
$sample[] = $normalized;
}
if (! $dryRun && $this->storage->safeDelete($disk, $normalized)) {
$result['deleted']++;
}
}
}
$result['sample'] = $sample;
return $result;
}
private function cleanupAttributesForJob(EnhanceJob $job): array
{
$attributes = [];
if ($this->storage->isEnhancePath($job->source_path)) {
$attributes['source_disk'] = null;
$attributes['source_path'] = null;
$attributes['source_hash'] = null;
}
if ($this->storage->isEnhancePath($job->output_path)) {
$attributes['output_disk'] = null;
$attributes['output_path'] = null;
$attributes['output_hash'] = null;
$attributes['output_width'] = null;
$attributes['output_height'] = null;
$attributes['output_filesize'] = null;
$attributes['output_mime'] = null;
}
if ($this->storage->isEnhancePath($job->preview_path)) {
$attributes['preview_disk'] = null;
$attributes['preview_path'] = null;
}
return $attributes;
}
private function enhanceJobPaths(EnhanceJob $job): array
{
return array_values(array_filter([
$this->storage->isEnhancePath($job->source_path) ? trim((string) $job->source_path) : null,
$this->storage->isEnhancePath($job->output_path) ? trim((string) $job->output_path) : null,
$this->storage->isEnhancePath($job->preview_path) ? trim((string) $job->preview_path) : null,
]));
}
private function enhancePrefixes(): array
{
return array_values(array_filter(array_unique(array_map(
static fn (string $prefix): string => trim($prefix, '/'),
[
(string) config('enhance.source_prefix', 'enhance/sources'),
(string) config('enhance.output_prefix', 'enhance/outputs'),
(string) config('enhance.preview_prefix', 'enhance/previews'),
],
))));
}
private function resolveDays(mixed $daysOverride, int $default): int
{
if ($daysOverride === null || $daysOverride === '') {
return max(0, $default);
}
return max(0, (int) $daysOverride);
}
}

View File

@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Enhance;
use App\Models\EnhanceJob;
use Illuminate\Console\Command;
final class EnhanceHealthCommand extends Command
{
protected $signature = 'enhance:health {--json : Output machine-readable JSON}';
protected $description = 'Report operational health and lifecycle metrics for Enhance jobs.';
public function handle(): int
{
$payload = $this->payload();
if ((bool) $this->option('json')) {
$this->line(json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
return self::SUCCESS;
}
$this->info('Enhance health');
$this->newLine();
$this->table(['Metric', 'Value'], [
['Configured engine', $payload['engine']],
['Configured queue', $payload['queue']],
['Worker URL configured', $payload['worker_configured'] ? 'yes' : 'no'],
['Storage disk', $payload['storage_disk']],
['Total jobs', $payload['counts']['total']],
['Pending jobs', $payload['counts']['pending']],
['Queued jobs', $payload['counts']['queued']],
['Processing jobs', $payload['counts']['processing']],
['Completed jobs', $payload['counts']['completed']],
['Failed jobs', $payload['counts']['failed']],
['Cancelled jobs', $payload['counts']['cancelled']],
['Expired jobs', $payload['counts']['expired']],
['Stuck queued jobs', $payload['health']['stuck_queued']],
['Stuck processing jobs', $payload['health']['stuck_processing']],
['Jobs created today', $payload['today']['created']],
['Jobs completed today', $payload['today']['completed']],
['Jobs failed today', $payload['today']['failed']],
['Average processing time today', $payload['today']['average_processing_seconds'] ?? '—'],
]);
return self::SUCCESS;
}
private function payload(): array
{
$todayStart = now()->startOfDay();
$todayEnd = now()->endOfDay();
$stuckQueuedCutoff = now()->subMinutes((int) config('enhance.health.stuck_queued_after_minutes', 60));
$stuckProcessingCutoff = now()->subMinutes((int) config('enhance.health.stuck_processing_after_minutes', 30));
$counts = [
'total' => EnhanceJob::query()->count(),
'pending' => EnhanceJob::query()->where('status', EnhanceJob::STATUS_PENDING)->count(),
'queued' => EnhanceJob::query()->where('status', EnhanceJob::STATUS_QUEUED)->count(),
'processing' => EnhanceJob::query()->where('status', EnhanceJob::STATUS_PROCESSING)->count(),
'completed' => EnhanceJob::query()->where('status', EnhanceJob::STATUS_COMPLETED)->count(),
'failed' => EnhanceJob::query()->where('status', EnhanceJob::STATUS_FAILED)->count(),
'cancelled' => EnhanceJob::query()->where('status', EnhanceJob::STATUS_CANCELLED)->count(),
'expired' => EnhanceJob::query()->where('status', EnhanceJob::STATUS_EXPIRED)->count(),
];
return [
'engine' => (string) config('enhance.default_engine', EnhanceJob::ENGINE_STUB),
'queue' => (string) config('enhance.queue', 'default'),
'worker_configured' => trim((string) config('enhance.external_worker.url', '')) !== '',
'storage_disk' => (string) config('enhance.disk', 'public'),
'counts' => $counts,
'health' => [
'stuck_queued' => EnhanceJob::query()
->where('status', EnhanceJob::STATUS_QUEUED)
->whereNotNull('queued_at')
->where('queued_at', '<=', $stuckQueuedCutoff)
->count(),
'stuck_processing' => EnhanceJob::query()
->where('status', EnhanceJob::STATUS_PROCESSING)
->whereNotNull('started_at')
->where('started_at', '<=', $stuckProcessingCutoff)
->count(),
],
'today' => [
'created' => EnhanceJob::query()->whereBetween('created_at', [$todayStart, $todayEnd])->count(),
'completed' => EnhanceJob::query()
->where('status', EnhanceJob::STATUS_COMPLETED)
->whereBetween('finished_at', [$todayStart, $todayEnd])
->count(),
'failed' => EnhanceJob::query()
->where('status', EnhanceJob::STATUS_FAILED)
->whereBetween('finished_at', [$todayStart, $todayEnd])
->count(),
'average_processing_seconds' => ($average = EnhanceJob::query()
->whereNotNull('processing_seconds')
->whereBetween('finished_at', [$todayStart, $todayEnd])
->avg('processing_seconds')) !== null
? round((float) $average, 2)
: null,
],
];
}
}

View File

@@ -0,0 +1,290 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Enhance;
use App\Models\EnhanceJob;
use App\Services\Enhance\EnhanceProcessorFactory;
use App\Services\Enhance\EnhanceStorageService;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Throwable;
final class EnhanceRunCommand extends Command
{
protected $signature = 'enhance:run
{--id= : Process specific job ID(s), comma-separated}
{--limit=1 : Max pending/queued jobs to pick up from the queue (0 = all)}
{--engine= : Override the processing engine for this run (stub, external_worker)}
{--failed : Also include failed jobs when scanning the queue}
{--dry-run : Show what would be processed without executing}';
protected $description = 'Synchronously process pending enhance jobs inline — useful for debugging with -v / -vv / -vvv.';
private const PROCESSABLE_STATUSES = [
EnhanceJob::STATUS_PENDING,
EnhanceJob::STATUS_QUEUED,
EnhanceJob::STATUS_PROCESSING,
EnhanceJob::STATUS_FAILED,
];
public function __construct(
private readonly EnhanceProcessorFactory $processorFactory,
private readonly EnhanceStorageService $storage,
) {
parent::__construct();
}
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$engineOverride = trim((string) $this->option('engine'));
$idOption = trim((string) $this->option('id'));
$limit = max(0, (int) $this->option('limit'));
$includeFailed = (bool) $this->option('failed');
if ($engineOverride !== '' && ! in_array($engineOverride, [EnhanceJob::ENGINE_STUB, EnhanceJob::ENGINE_EXTERNAL_WORKER], true)) {
$this->error("Unknown engine override: {$engineOverride}. Use 'stub' or 'external_worker'.");
return self::FAILURE;
}
$jobs = $this->resolveJobs($idOption, $limit, $includeFailed);
if ($jobs->isEmpty()) {
$this->info('No eligible enhance jobs found.');
return self::SUCCESS;
}
$this->info(sprintf('Found %d job(s) to process.', $jobs->count()));
$this->newLine();
if ($dryRun) {
$this->warn('Dry-run mode — no jobs will be processed.');
foreach ($jobs as $job) {
$engine = $engineOverride !== '' ? "{$engineOverride} (overridden)" : $job->engine;
$this->line(sprintf(
' [dry-run] Job #%d status=%-12s engine=%-18s scale=%dx mode=%-14s user_id=%d',
$job->id,
$job->status,
$engine,
$job->scale,
$job->mode,
$job->user_id,
));
}
return self::SUCCESS;
}
$processed = 0;
$failed = 0;
foreach ($jobs as $job) {
if ($this->processJob($job, $engineOverride)) {
$processed++;
} else {
$failed++;
}
$this->newLine();
}
$this->info(sprintf('Done: %d completed, %d failed.', $processed, $failed));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
private function resolveJobs(string $idOption, int $limit, bool $includeFailed): Collection
{
if ($idOption !== '') {
$ids = array_filter(array_map('intval', explode(',', $idOption)));
return EnhanceJob::query()
->whereIn('id', $ids)
->whereIn('status', self::PROCESSABLE_STATUSES)
->get();
}
$statuses = [EnhanceJob::STATUS_PENDING, EnhanceJob::STATUS_QUEUED, EnhanceJob::STATUS_PROCESSING];
if ($includeFailed) {
$statuses[] = EnhanceJob::STATUS_FAILED;
}
$query = EnhanceJob::query()->whereIn('status', $statuses)->oldest();
if ($limit > 0) {
$query->limit($limit);
}
return $query->get();
}
private function processJob(EnhanceJob $job, string $engineOverride): bool
{
$engine = $engineOverride !== '' ? $engineOverride : (string) $job->engine;
$this->line(sprintf('<comment>--- Job #%d ---</comment>', $job->id));
$this->line(sprintf(' Status : %s', $job->status));
$this->line(sprintf(' Engine : %s%s', $engine, $engineOverride !== '' ? ' (overridden)' : ''));
$this->line(sprintf(' Scale : %dx', $job->scale));
$this->line(sprintf(' Mode : %s', $job->mode));
$this->line(sprintf(' User : #%d', $job->user_id));
if ($this->output->isVerbose()) {
$this->line(sprintf(
' Source : disk=%-10s path=%s',
$job->source_disk ?: '(default)',
$job->source_path ?: '—',
));
$this->line(sprintf(
' Input : %dx%d size=%s mime=%s',
(int) $job->input_width,
(int) $job->input_height,
$this->formatBytes((int) $job->input_filesize),
$job->input_mime ?: '—',
));
if ($job->error_message !== null) {
$this->warn(sprintf(' Previous error: %s', $job->error_message));
}
}
if (! in_array($job->status, self::PROCESSABLE_STATUSES, true)) {
$this->warn(sprintf(' Skipping: status "%s" is not processable.', $job->status));
return false;
}
$job->forceFill([
'status' => EnhanceJob::STATUS_PROCESSING,
'started_at' => now(),
'finished_at' => null,
'error_message' => null,
])->save();
$started = microtime(true);
$completedExpiryDays = (int) config('enhance.lifecycle.completed_expires_after_days', 30);
try {
$this->line(' Processing...');
$processor = $this->processorFactory->make($engine);
$result = $processor->process($job);
if ($this->output->isVerbose()) {
$this->line(sprintf(
' Output : %dx%d size=%s mime=%s',
$result->width,
$result->height,
$this->formatBytes($result->filesize),
$result->mime,
));
$this->line(sprintf(
' Stored : disk=%-10s path=%s',
$result->disk,
$result->path,
));
}
$this->line(' Generating preview...');
$preview = $this->storage->createPreviewFromStoredOutput($job, $result->disk, $result->path) ?? [];
$outputHash = null;
$outputContents = Storage::disk($result->disk)->get($result->path);
if (is_string($outputContents) && $outputContents !== '') {
$outputHash = hash('sha256', $outputContents);
}
$job->forceFill([
'status' => EnhanceJob::STATUS_COMPLETED,
'output_disk' => $result->disk,
'output_path' => $result->path,
'output_hash' => $outputHash,
'output_width' => $result->width,
'output_height' => $result->height,
'output_filesize' => $result->filesize,
'output_mime' => $result->mime,
'metadata' => array_merge($job->metadata ?? [], $result->metadata ?? []),
'processing_seconds' => (int) round(microtime(true) - $started),
'finished_at' => now(),
'expires_at' => $completedExpiryDays > 0 ? now()->addDays($completedExpiryDays) : null,
] + $preview)->save();
$elapsed = round(microtime(true) - $started, 2);
$this->info(sprintf(' Completed in %.2fs', $elapsed));
if ($this->output->isVerbose() && ! empty($result->metadata)) {
$this->line(' Metadata:');
foreach ($result->metadata as $key => $value) {
$display = is_scalar($value) ? (string) $value : json_encode($value);
$this->line(sprintf(' %-30s %s', $key . ':', $display));
}
}
return true;
} catch (Throwable $exception) {
$elapsed = round(microtime(true) - $started, 2);
$job->forceFill([
'status' => EnhanceJob::STATUS_FAILED,
'error_message' => Str::limit($exception->getMessage(), 1000),
'processing_seconds' => (int) round(microtime(true) - $started),
'finished_at' => now(),
])->save();
$this->error(sprintf(' Failed in %.2fs: %s', $elapsed, $exception->getMessage()));
if ($this->output->isVerbose()) {
$this->line(sprintf(' Exception : %s', get_class($exception)));
$this->line(sprintf(' At : %s:%d', $exception->getFile(), $exception->getLine()));
$previous = $exception->getPrevious();
if ($previous !== null) {
$this->line(sprintf(' Caused by : %s: %s', get_class($previous), $previous->getMessage()));
}
}
if ($this->output->isVeryVerbose()) {
$this->line(' Stack trace:');
$frames = array_slice(explode("\n", $exception->getTraceAsString()), 0, 25);
foreach ($frames as $frame) {
$this->line(' ' . $frame);
}
}
Log::warning('enhance.run.command.failed', [
'enhance_job_id' => $job->id,
'engine' => $engine,
'message' => $exception->getMessage(),
'exception' => get_class($exception),
]);
return false;
}
}
private function formatBytes(int $bytes): string
{
if ($bytes < 1024) {
return $bytes . 'B';
}
if ($bytes < 1_048_576) {
return round($bytes / 1024, 1) . 'KB';
}
return round($bytes / 1_048_576, 1) . 'MB';
}
}

View File

@@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class ExportLegacyNewsCommentsSqlCommand extends Command
{
protected $signature = 'news:comments-export-legacy-sql
{--path=database/sql/news_article_comments_legacy_import.sql : Output SQL file path}
{--skip-empty : Skip comments with empty or whitespace-only content}
{--table= : Override legacy source table name (defaults to auto-detect news_comment/news_comments)}';
protected $description = 'Generate a production-safe SQL file for legacy news comments import';
public function handle(): int
{
try {
DB::connection('legacy')->getPdo();
} catch (\Throwable $exception) {
$this->error('Cannot connect to legacy database: ' . $exception->getMessage());
return self::FAILURE;
}
$legacyTable = $this->resolveLegacyTable();
if ($legacyTable === null) {
$this->error('Legacy table `news_comment` or `news_comments` was not found.');
return self::FAILURE;
}
$outputPath = $this->resolveOutputPath((string) $this->option('path'));
$skipEmpty = (bool) $this->option('skip-empty');
$directory = dirname($outputPath);
if (! is_dir($directory)) {
mkdir($directory, 0777, true);
}
$handle = fopen($outputPath, 'wb');
if ($handle === false) {
$this->error('Unable to write SQL file: ' . $outputPath);
return self::FAILURE;
}
$written = 0;
$skippedEmpty = 0;
$legacyNewsIds = [];
fwrite($handle, "-- Legacy news comments import generated at " . now()->toDateTimeString() . PHP_EOL);
fwrite($handle, "START TRANSACTION;" . PHP_EOL . PHP_EOL);
DB::connection('legacy')
->table($legacyTable)
->orderBy('comment_id')
->chunk(500, function ($rows) use ($handle, $skipEmpty, &$written, &$skippedEmpty, &$legacyNewsIds): void {
foreach ($rows as $row) {
$legacyId = (int) ($row->comment_id ?? 0);
$legacyNewsId = (int) ($row->news_id ?? 0);
$legacyUserId = (int) ($row->user_id ?? 0);
$body = trim((string) ($row->message ?? ''));
if ($legacyId < 1 || $legacyNewsId < 1) {
continue;
}
if ($body === '') {
if ($skipEmpty) {
$skippedEmpty++;
continue;
}
$body = '[no content]';
}
$legacyNewsIds[$legacyNewsId] = $legacyNewsId;
$authorName = trim((string) ($row->author ?? ''));
$timestamp = $this->normalizeTimestamp($row->posted ?? null);
$renderedBody = nl2br(e($body));
$userExpression = $legacyUserId > 0
? "CASE WHEN EXISTS (SELECT 1 FROM users WHERE users.id = {$legacyUserId} AND users.deleted_at IS NULL) THEN {$legacyUserId} ELSE NULL END"
: 'NULL';
$statement = "INSERT IGNORE INTO news_article_comments (legacy_id, legacy_user_id, article_id, user_id, parent_id, author_name, body, rendered_body, status, legacy_posted_at, created_at, updated_at, deleted_at)\n"
. "SELECT {$legacyId}, " . ($legacyUserId > 0 ? (string) $legacyUserId : 'NULL') . ", news_articles.id, {$userExpression}, NULL, " . $this->quote($authorName !== '' ? $authorName : null) . ", " . $this->quote($body) . ", " . $this->quote($renderedBody) . ", 'visible', " . $this->quote($timestamp) . ", " . $this->quote($timestamp) . ", " . $this->quote($timestamp) . ", NULL\n"
. "FROM news_articles\n"
. "WHERE news_articles.legacy_news_id = {$legacyNewsId}\n"
. "LIMIT 1;\n\n";
fwrite($handle, $statement);
$written++;
}
});
if ($legacyNewsIds !== []) {
foreach (array_chunk(array_values($legacyNewsIds), 250) as $chunk) {
fwrite($handle, 'UPDATE news_articles SET comments_enabled = 1 WHERE legacy_news_id IN (' . implode(', ', array_map('intval', $chunk)) . ');' . PHP_EOL);
}
fwrite($handle, PHP_EOL);
}
fwrite($handle, 'COMMIT;' . PHP_EOL);
fclose($handle);
$this->info('SQL export written to ' . $outputPath);
$this->table(
['Result', 'Count'],
[
['Statements written', $written],
['Skipped - empty body', $skippedEmpty],
['Articles enabled for comments', count($legacyNewsIds)],
]
);
return self::SUCCESS;
}
private function resolveLegacyTable(): ?string
{
$configured = trim((string) $this->option('table'));
if ($configured !== '') {
return DB::connection('legacy')->getSchemaBuilder()->hasTable($configured) ? $configured : null;
}
foreach (['news_comment', 'news_comments'] as $candidate) {
if (DB::connection('legacy')->getSchemaBuilder()->hasTable($candidate)) {
return $candidate;
}
}
return null;
}
private function resolveOutputPath(string $path): string
{
$trimmed = trim($path);
if ($trimmed === '') {
return base_path('database/sql/news_article_comments_legacy_import.sql');
}
if (preg_match('/^[A-Za-z]:\\\\|^\\\\\\\\|^\//', $trimmed) === 1) {
return $trimmed;
}
return base_path(str_replace(['/', '\\\\'], DIRECTORY_SEPARATOR, $trimmed));
}
private function normalizeTimestamp(mixed $value): string
{
$raw = trim((string) ($value ?? ''));
if ($raw === '' || str_starts_with($raw, '0000-00-00')) {
return now()->toDateTimeString();
}
try {
return Carbon::parse($raw)->toDateTimeString();
} catch (\Throwable) {
return now()->toDateTimeString();
}
}
private function quote(?string $value): string
{
if ($value === null) {
return 'NULL';
}
$escaped = str_replace(
["\\", "\0", "\n", "\r", "\x1a", "'"],
["\\\\", "\\0", "\\n", "\\r", "\\Z", "\\'"],
$value,
);
return "'{$escaped}'";
}
}

View File

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

View File

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

View File

@@ -0,0 +1,390 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\AcademyPromptTemplate;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
use Throwable;
final class GenerateAcademyPromptThumbnailsCommand extends Command
{
private const PROMPT_PREVIEW_PREFIX = 'academy-prompts/previews';
/**
* @var array<string, int>
*/
private const VARIANT_WIDTHS = [
'thumb' => 480,
'md' => 960,
];
private const PREVIEW_WEBP_QUALITY = 84;
private const LESSON_MEDIA_WEBP_QUALITY = 85;
protected $signature = 'academy:prompts:generate-missing-thumbnails
{--id=* : Restrict to one or more prompt IDs}
{--slug=* : Restrict to one or more prompt slugs}
{--limit= : Stop after processing this many prompts}
{--force : Regenerate variants even when they already exist}
{--dry-run : Report planned thumbnail work without writing files or saving prompt JSON}';
protected $description = 'Generate missing prompt preview and comparison thumbnails for existing Academy prompts';
public function handle(): int
{
if (! function_exists('imagecreatefromstring') || ! function_exists('imagewebp')) {
$this->error('GD WebP support is required to generate prompt thumbnails.');
return self::FAILURE;
}
$ids = collect((array) $this->option('id'))
->map(static fn (mixed $id): int => (int) $id)
->filter(static fn (int $id): bool => $id > 0)
->values()
->all();
$slugs = collect((array) $this->option('slug'))
->map(static fn (mixed $slug): string => trim((string) $slug))
->filter(static fn (string $slug): bool => $slug !== '')
->values()
->all();
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
$force = (bool) $this->option('force');
$dryRun = (bool) $this->option('dry-run');
$query = AcademyPromptTemplate::query()
->select(['id', 'slug', 'title', 'preview_image', 'tool_notes'])
->orderBy('id');
if ($ids !== []) {
$query->whereIn('id', $ids);
}
if ($slugs !== []) {
$query->whereIn('slug', $slugs);
}
$processed = 0;
$changed = 0;
$generatedVariants = 0;
$plannedVariants = 0;
$skipped = 0;
$failed = 0;
$query->chunkById(100, function ($prompts) use ($limit, $force, $dryRun, &$processed, &$changed, &$generatedVariants, &$plannedVariants, &$skipped, &$failed) {
foreach ($prompts as $prompt) {
if ($limit !== null && $processed >= $limit) {
return false;
}
try {
$result = $this->backfillPrompt($prompt, $force, $dryRun);
$generatedVariants += (int) ($result['generated_variants'] ?? 0);
$plannedVariants += (int) ($result['planned_variants'] ?? 0);
if (($result['changed'] ?? false) === true) {
$changed++;
} else {
$skipped++;
}
} catch (Throwable $e) {
$failed++;
$this->warn(sprintf('Prompt %d (%s) failed: %s', (int) $prompt->id, (string) $prompt->slug, $e->getMessage()));
}
$processed++;
}
return true;
});
$this->info(sprintf(
'Prompt thumbnail backfill complete. processed=%d changed=%d generated_variants=%d planned_variants=%d skipped=%d failed=%d',
$processed,
$changed,
$generatedVariants,
$plannedVariants,
$skipped,
$failed,
));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* @return array{changed:bool,generated_variants:int,planned_variants:int}
*/
private function backfillPrompt(AcademyPromptTemplate $prompt, bool $force, bool $dryRun): array
{
$generatedVariants = 0;
$plannedVariants = 0;
$changed = false;
$previewResult = $this->ensureManagedImageVariants((string) ($prompt->preview_image ?? ''), $force, $dryRun);
$generatedVariants += $previewResult['generated_variants'];
$plannedVariants += $previewResult['planned_variants'];
$changed = $changed || $previewResult['changed'];
$notes = is_array($prompt->tool_notes) ? $prompt->tool_notes : [];
$nextNotes = [];
foreach ($notes as $note) {
if (! is_array($note)) {
$nextNotes[] = $note;
continue;
}
$noteResult = $this->ensurePromptComparisonNoteVariants($note, $force, $dryRun);
$generatedVariants += $noteResult['generated_variants'];
$plannedVariants += $noteResult['planned_variants'];
$changed = $changed || $noteResult['changed'];
$nextNotes[] = $noteResult['note'];
}
if ($changed && ! $dryRun && $nextNotes !== $notes) {
$prompt->forceFill([
'tool_notes' => $nextNotes,
])->save();
}
return [
'changed' => $changed,
'generated_variants' => $generatedVariants,
'planned_variants' => $plannedVariants,
];
}
/**
* @param array<string, mixed> $note
* @return array{note:array<string, mixed>,changed:bool,generated_variants:int,planned_variants:int}
*/
private function ensurePromptComparisonNoteVariants(array $note, bool $force, bool $dryRun): array
{
$imagePath = trim((string) ($note['image_path'] ?? ''));
if (! $this->isManagedLessonMediaPath($imagePath)) {
return [
'note' => $note,
'changed' => false,
'generated_variants' => 0,
'planned_variants' => 0,
];
}
$variants = $this->ensureManagedImageVariants($imagePath, $force, $dryRun);
$thumbPath = $variants['thumb_path'] ?? '';
if ($thumbPath === '') {
$thumbPath = $imagePath;
}
$nextNote = $note;
$currentThumbPath = trim((string) ($note['thumb_path'] ?? ''));
if ($currentThumbPath !== $thumbPath) {
$nextNote['thumb_path'] = $thumbPath;
$variants['changed'] = true;
}
return [
'note' => $nextNote,
'changed' => (bool) $variants['changed'],
'generated_variants' => (int) $variants['generated_variants'],
'planned_variants' => (int) $variants['planned_variants'],
];
}
/**
* @return array{thumb_path:string,changed:bool,generated_variants:int,planned_variants:int}
*/
private function ensureManagedImageVariants(string $path, bool $force, bool $dryRun): array
{
$path = trim($path);
if (! $this->isManagedPromptPreviewPath($path) && ! $this->isManagedLessonMediaPath($path)) {
return [
'thumb_path' => '',
'changed' => false,
'generated_variants' => 0,
'planned_variants' => 0,
];
}
$source = $this->openManagedImage($path);
try {
$generatedVariants = 0;
$plannedVariants = 0;
$changed = false;
$thumbPath = $path;
foreach (self::VARIANT_WIDTHS as $variant => $targetWidth) {
$status = $this->ensureVariantForWidth(
$source['image'],
$source['width'],
$source['height'],
$path,
$variant,
$targetWidth,
$force,
$dryRun,
);
if ($variant === 'thumb' && $source['width'] > $targetWidth) {
$thumbPath = $this->variantPath($path, 'thumb');
}
if ($status === 'generated') {
$generatedVariants++;
$changed = true;
}
if ($status === 'planned') {
$plannedVariants++;
$changed = true;
}
}
return [
'thumb_path' => $thumbPath,
'changed' => $changed,
'generated_variants' => $generatedVariants,
'planned_variants' => $plannedVariants,
];
} finally {
imagedestroy($source['image']);
}
}
/**
* @return array{image:\GdImage,width:int,height:int}
*/
private function openManagedImage(string $path): array
{
$disk = Storage::disk($this->storageDisk());
if (! $disk->exists($path)) {
throw new RuntimeException(sprintf('Source image is missing: %s', $path));
}
$binary = $disk->get($path);
if (! is_string($binary) || $binary === '') {
throw new RuntimeException(sprintf('Source image could not be read: %s', $path));
}
$image = @imagecreatefromstring($binary);
if (! $image instanceof \GdImage) {
throw new RuntimeException(sprintf('Source image is not a supported raster image: %s', $path));
}
if (! imageistruecolor($image)) {
imagepalettetotruecolor($image);
}
imagealphablending($image, true);
imagesavealpha($image, true);
return [
'image' => $image,
'width' => imagesx($image),
'height' => imagesy($image),
];
}
private function ensureVariantForWidth(\GdImage $source, int $sourceWidth, int $sourceHeight, string $sourcePath, string $variant, int $targetWidth, bool $force, bool $dryRun): string
{
if ($sourceWidth <= $targetWidth || $sourceWidth < 1 || $sourceHeight < 1) {
return 'skipped';
}
$variantPath = $this->variantPath($sourcePath, $variant);
$disk = Storage::disk($this->storageDisk());
if (! $force && $disk->exists($variantPath)) {
return 'skipped';
}
if ($dryRun) {
return 'planned';
}
$targetHeight = max(1, (int) round(($sourceHeight / $sourceWidth) * $targetWidth));
$canvas = imagecreatetruecolor($targetWidth, $targetHeight);
if (! $canvas instanceof \GdImage) {
throw new RuntimeException(sprintf('Could not allocate variant canvas for %s', $sourcePath));
}
imagealphablending($canvas, false);
imagesavealpha($canvas, true);
$transparent = imagecolorallocatealpha($canvas, 0, 0, 0, 127);
imagefilledrectangle($canvas, 0, 0, $targetWidth, $targetHeight, $transparent);
imagecopyresampled($canvas, $source, 0, 0, 0, 0, $targetWidth, $targetHeight, $sourceWidth, $sourceHeight);
try {
ob_start();
$converted = imagewebp($canvas, null, $this->qualityForPath($sourcePath));
$webpBinary = ob_get_clean();
if (! $converted || ! is_string($webpBinary) || $webpBinary === '') {
throw new RuntimeException(sprintf('Could not encode %s variant for %s', $variant, $sourcePath));
}
$disk->put($variantPath, $webpBinary, ['visibility' => 'public']);
} finally {
imagedestroy($canvas);
}
return 'generated';
}
private function variantPath(string $path, string $variant): string
{
$directory = pathinfo($path, PATHINFO_DIRNAME);
$filename = pathinfo($path, PATHINFO_FILENAME);
$baseFilename = preg_replace('/-(thumb|md)$/', '', $filename) ?? $filename;
return sprintf('%s/%s-%s.webp', $directory, $baseFilename, $variant);
}
private function isManagedPromptPreviewPath(string $path): bool
{
return $this->isLocalPath($path) && str_starts_with($path, self::PROMPT_PREVIEW_PREFIX . '/');
}
private function isManagedLessonMediaPath(string $path): bool
{
return $this->isLocalPath($path)
&& (str_starts_with($path, 'academy/lessons/body/') || str_starts_with($path, 'academy/lessons/covers/'));
}
private function isLocalPath(string $path): bool
{
return $path !== ''
&& ! str_starts_with($path, 'http://')
&& ! str_starts_with($path, 'https://')
&& ! str_starts_with($path, '/');
}
private function storageDisk(): string
{
return (string) config('uploads.object_storage.disk', 's3');
}
private function qualityForPath(string $path): int
{
return $this->isManagedPromptPreviewPath($path)
? self::PREVIEW_WEBP_QUALITY
: self::LESSON_MEDIA_WEBP_QUALITY;
}
}

View File

@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Jobs\GenerateFeaturedArtworkThumbnailsJob;
use App\Models\Artwork;
use App\Services\Featured\FeaturedArtworkSelector;
use App\Services\Images\FeaturedArtworkThumbnailGenerator;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\LazyCollection;
final class GenerateFeaturedArtworkThumbnailsCommand extends Command
{
protected $signature = 'skinbase:featured-thumbnails:generate
{--artwork=* : Restrict generation to one or more artwork IDs}
{--only-featured : Restrict generation to currently selected featured artworks}
{--missing-only : Only generate artworks missing at least one featured variant}
{--all : Process all artworks with a hash and source extension}
{--limit=0 : Cap the number of artworks processed}
{--queue : Dispatch background jobs instead of generating inline}
{--force : Regenerate all featured variants even when they already exist}
{--dry-run : Report planned generation without writing files}';
protected $description = 'Generate dedicated featured artwork CDN thumbnails for the homepage hero';
public function __construct(
private readonly FeaturedArtworkSelector $selector,
private readonly FeaturedArtworkThumbnailGenerator $generator,
) {
parent::__construct();
}
public function handle(): int
{
$artworkIds = collect((array) $this->option('artwork'))
->map(static fn (mixed $id): int => (int) $id)
->filter(static fn (int $id): bool => $id > 0)
->values()
->all();
$force = (bool) $this->option('force');
$dryRun = (bool) $this->option('dry-run');
$queue = (bool) $this->option('queue');
$limit = max(0, (int) $this->option('limit'));
$all = (bool) $this->option('all');
$explicitOnlyFeatured = (bool) $this->option('only-featured');
$missingOnly = $force ? false : ((bool) $this->option('missing-only') || ($artworkIds === [] && ! $all));
if ($all && $explicitOnlyFeatured) {
$this->error('Use either --all or --only-featured, not both.');
return self::INVALID;
}
if ($queue && $dryRun) {
$this->error('Use either --queue or --dry-run, not both.');
return self::INVALID;
}
$onlyFeatured = $artworkIds === [] && ! $all;
if ($explicitOnlyFeatured) {
$onlyFeatured = true;
}
$processed = 0;
$queued = 0;
$generatedVariants = 0;
$skipped = 0;
$failed = 0;
foreach ($this->candidateArtworks($artworkIds, $onlyFeatured) as $artwork) {
if ($limit > 0 && $processed >= $limit) {
break;
}
$processed++;
$plan = $this->generator->plan($artwork, $force);
$targetVariants = (array) ($plan['target_variants'] ?? []);
if ($missingOnly && ! $force && $targetVariants === []) {
$skipped++;
continue;
}
if ($dryRun) {
$this->line(sprintf(
'[dry-run] artwork=%d variants=%s',
(int) $artwork->id,
$targetVariants === [] ? 'none' : implode(',', $targetVariants),
));
if ($targetVariants === []) {
$skipped++;
} else {
$generatedVariants += count($targetVariants);
}
continue;
}
if ($queue) {
GenerateFeaturedArtworkThumbnailsJob::dispatch((int) $artwork->id, $force);
$queued++;
continue;
}
try {
$result = $this->generator->generate($artwork, $force);
$generatedVariants += (int) ($result['generated'] ?? 0);
$skipped += count((array) ($result['target_variants'] ?? [])) === 0 ? 1 : 0;
if (($result['failed'] ?? []) !== []) {
$failed++;
$this->warn(sprintf(
'Artwork %d failed for variants: %s',
(int) $artwork->id,
implode(', ', array_keys((array) $result['failed'])),
));
}
} catch (\Throwable $exception) {
$failed++;
$this->warn(sprintf('Artwork %d failed: %s', (int) $artwork->id, $exception->getMessage()));
}
}
$mode = $dryRun ? 'dry-run' : ($queue ? 'queued' : 'generated');
$this->info(sprintf(
'Featured artwork thumbnail %s complete: processed=%d queued=%d generated_variants=%d skipped=%d failed=%d',
$mode,
$processed,
$queued,
$generatedVariants,
$skipped,
$failed,
));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* @param list<int> $artworkIds
* @return LazyCollection<int, Artwork>
*/
private function candidateArtworks(array $artworkIds, bool $onlyFeatured): LazyCollection
{
if ($artworkIds !== []) {
return Artwork::query()
->withTrashed()
->whereIn('id', $artworkIds)
->whereNotNull('hash')
->where('hash', '!=', '')
->whereNotNull('file_ext')
->where('file_ext', '!=', '')
->orderByDesc('id')
->cursor();
}
$query = $onlyFeatured
? $this->selector->querySelectedArtworks()
: Artwork::query()
->select('artworks.*')
->withTrashed()
->whereNotNull('hash')
->where('hash', '!=', '')
->whereNotNull('file_ext')
->where('file_ext', '!=', '');
return $this->orderedCursor($query);
}
/**
* @return LazyCollection<int, Artwork>
*/
private function orderedCursor(Builder $query): LazyCollection
{
return $query
->orderByDesc('artworks.id')
->cursor();
}
}

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Cdn\ArtworkCdnPurgeService;
use App\Services\News\NewsCoverImageService;
use App\Support\News\NewsCoverImage;
use Illuminate\Console\Command;
use cPad\Plugins\News\Models\NewsArticle;
final class GenerateNewsCoverThumbnailsCommand extends Command
{
protected $signature = 'news:generate-cover-thumbnails {--id=* : Restrict to one or more news article IDs} {--force : Regenerate variants even when they already exist}';
protected $description = 'Generate missing responsive cover thumbnails for managed news cover images';
public function __construct(
private readonly NewsCoverImageService $covers,
private readonly ArtworkCdnPurgeService $cdnPurge,
)
{
parent::__construct();
}
public function handle(): int
{
$ids = collect((array) $this->option('id'))
->map(static fn (mixed $id): int => (int) $id)
->filter(static fn (int $id): bool => $id > 0)
->values()
->all();
$force = (bool) $this->option('force');
$query = NewsArticle::query()
->select(['id', 'title', 'cover_image'])
->whereNotNull('cover_image')
->where('cover_image', '!=', '');
if ($ids !== []) {
$query->whereIn('id', $ids);
}
$generated = 0;
$skipped = 0;
$failed = 0;
$purged = 0;
$query->orderBy('id')->chunkById(100, function ($articles) use (&$generated, &$skipped, &$failed, &$purged, $force): void {
foreach ($articles as $article) {
$path = trim((string) $article->cover_image);
if (! NewsCoverImage::isManagedPath($path)) {
$skipped++;
continue;
}
try {
$result = $this->covers->ensureVariants($path, $force);
} catch (\Throwable $e) {
$failed++;
$this->warn(sprintf('Article %d failed: %s', (int) $article->id, $e->getMessage()));
continue;
}
if (($result['generated'] ?? 0) > 0) {
$generated++;
if ($force && $this->purgeVariantCache($path, (int) $article->id)) {
$purged++;
}
continue;
}
$skipped++;
}
});
$this->info(sprintf('News cover thumbnail generation complete: generated=%d skipped=%d failed=%d purged=%d', $generated, $skipped, $failed, $purged));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
private function purgeVariantCache(string $path, int $articleId): bool
{
$variantPaths = array_values(array_map(
static fn (string $variant): string => NewsCoverImage::variantPath($path, $variant),
array_keys(NewsCoverImage::VARIANTS),
));
return $this->cdnPurge->purgeArtworkObjectPaths($variantPaths, [
'article_id' => $articleId,
'reason' => 'news_cover_thumbnails_regenerated',
]);
}
}

View File

@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Sitemaps\SitemapBuildService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
/**
* Builds all sitemap documents and writes them as static .xml files to the
* public disk (default: public/sitemaps/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 the configured public sitemap disk.';
public function handle(SitemapBuildService $build): int
{
$totalStart = 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('sitemaps/sitemap.xml', $index['content']);
$written++;
$this->line(sprintf(
' <info>✔</info> sitemaps/sitemap.xml %d entries <comment>%.3fs</comment>',
$index['url_count'],
microtime(true) - $t,
));
// ── Per-family documents ──────────────────────────────────────────
foreach ($families as $family) {
$familyStart = microtime(true);
$names = $build->canonicalDocumentNamesForFamily($family);
$this->newLine();
$this->line(sprintf(' <fg=cyan>%s</> (%d document(s))', $family, count($names)));
foreach ($names as $documentName) {
$t = microtime(true);
$built = $build->buildNamed($documentName, force: true, persist: false);
if ($built === null) {
$this->line(sprintf(' <comment></comment> %s <fg=red>SKIPPED</> (builder returned null)', $documentName));
$failed++;
continue;
}
$path = 'sitemaps/' . $documentName . '.xml';
$disk->put($path, $built['content']);
$written++;
$this->line(sprintf(
' <info>✔</info> %s %d URLs <comment>%.3fs</comment>',
$documentName . '.xml',
$built['url_count'] ?? 0,
microtime(true) - $t,
));
}
$this->line(sprintf(
' <fg=cyan>%s</> done <comment>%.3fs</comment>',
$family,
microtime(true) - $familyStart,
));
}
// ── Summary ───────────────────────────────────────────────────────
$this->newLine();
$this->info(sprintf(
'Done: %d file(s) written, %d failed total <comment>%.3fs</comment>',
$written,
$failed,
microtime(true) - $totalStart,
));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
/** @return list<string> */
private function selectedFamilies(SitemapBuildService $build): array
{
$only = [];
foreach ((array) $this->option('only') as $value) {
foreach (explode(',', (string) $value) as $family) {
$normalized = trim($family);
if ($normalized !== '') {
$only[] = $normalized;
}
}
}
$enabled = $build->enabledFamilies();
if ($only === []) {
return $enabled;
}
return array_values(array_filter($enabled, fn (string $f): bool => in_array($f, $only, true)));
}
}

View File

@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\World;
use App\Services\WebStories\WorldWebStoryGenerator;
use Illuminate\Console\Command;
use Illuminate\Validation\ValidationException;
final class GenerateWorldWebStoriesCommand extends Command
{
protected $signature = 'skinbase:webstories:generate
{world? : World ID or slug}
{--all : Generate stories in batch mode}
{--pages=7 : Number of pages to generate (5-10)}
{--limit=25 : Maximum worlds to process in batch mode}
{--force : Rebuild an existing story for the target world}
{--publish : Publish immediately after generation if validation passes}
{--dry-run : Preview generation without saving anything}';
protected $description = 'Generate standalone AMP Web Stories from Skinbase Worlds';
public function handle(WorldWebStoryGenerator $generator): int
{
$worldKey = $this->argument('world');
$force = (bool) $this->option('force');
$publish = (bool) $this->option('publish');
$dryRun = (bool) $this->option('dry-run');
$pages = max(5, min(10, (int) $this->option('pages')));
if ($worldKey !== null && trim((string) $worldKey) !== '') {
$world = $this->resolveWorld((string) $worldKey);
if (! $world instanceof World) {
$this->error(sprintf('World [%s] was not found.', (string) $worldKey));
return self::FAILURE;
}
return $this->generateOne($generator, $world, $pages, $force, $publish, $dryRun);
}
if (! (bool) $this->option('all')) {
$this->error('Provide a world ID/slug or pass --all for batch generation.');
return self::INVALID;
}
return $this->generateBatch($generator, $pages, $force, $publish, $dryRun, max(1, (int) $this->option('limit')));
}
private function generateOne(WorldWebStoryGenerator $generator, World $world, int $pages, bool $force, bool $publish, bool $dryRun): int
{
try {
$result = $generator->generateFromWorld($world, null, $pages, $force, $publish, $dryRun);
} catch (ValidationException $exception) {
foreach ($exception->errors() as $messages) {
foreach ($messages as $message) {
$this->error((string) $message);
}
}
return self::FAILURE;
}
$story = $result['story'];
$validation = $result['validation'];
$this->info(sprintf(
'%s story for world [%s] -> /web-stories/%s (%d pages)',
$result['created'] ? 'Created' : 'Updated',
(string) $world->slug,
(string) $story->slug,
(int) $validation['page_count'],
));
foreach ((array) $validation['warnings'] as $warning) {
$this->warn(' - ' . $warning);
}
foreach ((array) $validation['errors'] as $error) {
$this->error(' - ' . $error);
}
return $validation['valid'] || ! $publish ? self::SUCCESS : self::FAILURE;
}
private function generateBatch(WorldWebStoryGenerator $generator, int $pages, bool $force, bool $publish, bool $dryRun, int $limit): int
{
$processed = 0;
$created = 0;
$updated = 0;
$failed = 0;
$query = World::query()
->published()
->orderByDesc('published_at')
->orderByDesc('id');
if (! $force) {
$query->whereDoesntHave('webStories');
}
$query->limit($limit)->get()->each(function (World $world) use ($generator, $pages, $force, $publish, $dryRun, &$processed, &$created, &$updated, &$failed): void {
$processed++;
try {
$result = $generator->generateFromWorld($world, null, $pages, $force, $publish, $dryRun);
$result['created'] ? $created++ : $updated++;
$this->line(sprintf('[%d] %s -> %s', (int) $world->id, (string) $world->slug, (string) $result['story']->slug));
} catch (ValidationException $exception) {
$failed++;
$first = collect($exception->errors())->flatten()->first();
$this->error(sprintf('[%d] %s failed: %s', (int) $world->id, (string) $world->slug, (string) $first));
}
});
$this->info(sprintf('Done. processed=%d created=%d updated=%d failed=%d', $processed, $created, $updated, $failed));
return $failed === 0 ? self::SUCCESS : self::FAILURE;
}
private function resolveWorld(string $value): ?World
{
return World::query()
->when(is_numeric($value), fn ($query) => $query->where('id', (int) $value), fn ($query) => $query->where('slug', $value))
->first();
}
}

View File

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

View File

@@ -5,8 +5,10 @@ declare(strict_types=1);
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\Artwork; use App\Models\Artwork;
use App\Services\Sitemaps\SitemapReleaseManager;
use App\Services\Vision\ArtworkVisionImageUrl; use App\Services\Vision\ArtworkVisionImageUrl;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis; use Illuminate\Support\Facades\Redis;
@@ -25,10 +27,10 @@ use Throwable;
class HealthCheckCommand extends Command class HealthCheckCommand extends Command
{ {
protected $signature = 'health:check protected $signature = 'health:check
{--only= : Run only a named check (mysql|redis|cache|meilisearch|qdrant|reverb|vision|horizon|webserver|phpfpm|paths|ram|disk|load|s3|failed_jobs|queue_backlog|ssl|scheduler|log_errors|app)} {--only= : Run only a named check (mysql|redis|cache|meilisearch|qdrant|reverb|vision|horizon|webserver|phpfpm|paths|ram|disk|load|s3|failed_jobs|queue_backlog|ssl|scheduler|sitemap|log_errors|app)}
{--json : Output results as JSON}'; {--json : Output results as JSON}';
protected $description = 'Check health of all critical services (MySQL, Redis, Cache, Meilisearch, Qdrant, Reverb, Vision, Horizon, Nginx, PHP-FPM, writable paths, RAM, disk, load, S3/Contabo, failed jobs, queue backlog, SSL, scheduler, log errors, App).'; protected $description = 'Check health of all critical services (MySQL, Redis, Cache, Meilisearch, Qdrant, Reverb, Vision, Horizon, Nginx, PHP-FPM, writable paths, RAM, disk, load, S3/Contabo, failed jobs, queue backlog, SSL, scheduler, sitemap, log errors, App).';
/** Collected results: [name => [status, message, details]] */ /** Collected results: [name => [status, message, details]] */
private array $results = []; private array $results = [];
@@ -57,6 +59,7 @@ class HealthCheckCommand extends Command
'queue_backlog' => fn () => $this->checkQueueBacklog(), 'queue_backlog' => fn () => $this->checkQueueBacklog(),
'ssl' => fn () => $this->checkSsl(), 'ssl' => fn () => $this->checkSsl(),
'scheduler' => fn () => $this->checkScheduler(), 'scheduler' => fn () => $this->checkScheduler(),
'sitemap' => fn () => $this->checkSitemap(),
'log_errors' => fn () => $this->checkLogErrors(), 'log_errors' => fn () => $this->checkLogErrors(),
'app' => fn () => $this->checkApp(), 'app' => fn () => $this->checkApp(),
]; ];
@@ -1010,7 +1013,7 @@ class HealthCheckCommand extends Command
private function checkScheduler(): void 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 Redis is not the cache driver, we can't check it.
if (config('cache.default') !== 'redis' && config('queue.default') !== 'redis') { if (config('cache.default') !== 'redis' && config('queue.default') !== 'redis') {
$this->warn_check('scheduler', 'Scheduler check requires Redis cache or queue — skipping in this environment.'); $this->warn_check('scheduler', 'Scheduler check requires Redis cache or queue — skipping in this environment.');
@@ -1041,6 +1044,51 @@ class HealthCheckCommand extends Command
} }
} }
private function checkSitemap(): void
{
try {
$releases = app(SitemapReleaseManager::class)->listReleases();
if ($releases === []) {
$this->failCheck('sitemap', 'No sitemap releases found. Run `php artisan skinbase:sitemaps:publish` to build one.');
return;
}
$latest = $releases[0];
$releaseId = (string) ($latest['release_id'] ?? 'unknown');
$builtAtRaw = (string) ($latest['built_at'] ?? $latest['published_at'] ?? '');
if ($builtAtRaw === '') {
$this->warn_check('sitemap', "Latest sitemap release [{$releaseId}] is missing a build timestamp.", [
'release_id' => $releaseId,
'status' => (string) ($latest['status'] ?? 'unknown'),
]);
return;
}
$builtAt = Carbon::parse($builtAtRaw);
$ageSeconds = max(0, $builtAt->diffInSeconds(now()));
$builtAtLabel = $builtAt->toAtomString();
$details = [
'release_id' => $releaseId,
'built_at' => $builtAtLabel,
'age_seconds' => $ageSeconds,
'status' => (string) ($latest['status'] ?? 'unknown'),
];
$message = "Latest sitemap release [{$releaseId}] built at {$builtAtLabel} ({$ageSeconds}s ago).";
if ($ageSeconds > 72 * 3600) {
$this->failCheck('sitemap', 'Sitemap build is stale — ' . $message, $details);
} elseif ($ageSeconds > 36 * 3600) {
$this->warn_check('sitemap', 'Sitemap build is getting old — ' . $message, $details);
} else {
$this->pass('sitemap', $message, $details);
}
} catch (Throwable $e) {
$this->warn_check('sitemap', 'Could not inspect sitemap releases: ' . $e->getMessage());
}
}
private function checkLogErrors(): void private function checkLogErrors(): void
{ {
$logFile = storage_path('logs/laravel.log'); $logFile = storage_path('logs/laravel.log');

View File

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

View File

@@ -0,0 +1,229 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class ImportLegacyNewsCommentsCommand extends Command
{
protected $signature = 'news:comments-import-legacy
{--dry-run : Preview only no writes to DB}
{--chunk=500 : Rows to process per batch}
{--skip-empty : Skip comments with empty or whitespace-only content}
{--table= : Override legacy source table name (defaults to auto-detect news_comment/news_comments)}';
protected $description = 'Import legacy news comments into news_article_comments';
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$chunk = max(1, (int) $this->option('chunk'));
$skipEmpty = (bool) $this->option('skip-empty');
try {
DB::connection('legacy')->getPdo();
} catch (\Throwable $exception) {
$this->error('Cannot connect to legacy database: ' . $exception->getMessage());
return self::FAILURE;
}
$legacyTable = $this->resolveLegacyTable();
if ($legacyTable === null) {
$this->error('Legacy table `news_comment` or `news_comments` was not found.');
return self::FAILURE;
}
if (! DB::getSchemaBuilder()->hasTable('news_article_comments')) {
$this->error('Target table `news_article_comments` is missing. Run migrations first.');
return self::FAILURE;
}
if (! DB::getSchemaBuilder()->hasColumn('news_articles', 'legacy_news_id')) {
$this->error('Column `news_articles.legacy_news_id` is missing. Run migrations first.');
return self::FAILURE;
}
if ($dryRun) {
$this->warn('[DRY-RUN] No data will be written.');
}
$articleMap = DB::table('news_articles')
->whereNotNull('legacy_news_id')
->pluck('id', 'legacy_news_id')
->mapWithKeys(fn ($articleId, $legacyId): array => [(int) $legacyId => (int) $articleId])
->all();
$validUserIds = DB::table('users')
->whereNull('deleted_at')
->pluck('id')
->flip()
->all();
$alreadyImported = DB::table('news_article_comments')
->whereNotNull('legacy_id')
->pluck('legacy_id')
->flip()
->all();
$total = DB::connection('legacy')->table($legacyTable)->count();
if ($total === 0) {
$this->warn('No legacy news comments found.');
return self::SUCCESS;
}
$stats = [
'imported' => 0,
'skipped_duplicate' => 0,
'skipped_article' => 0,
'skipped_empty' => 0,
'users_unmapped' => 0,
'errors' => 0,
];
$touchedArticleIds = [];
DB::connection('legacy')
->table($legacyTable)
->orderBy('comment_id')
->chunk($chunk, function ($rows) use (&$alreadyImported, $articleMap, $validUserIds, $dryRun, $skipEmpty, &$stats, &$touchedArticleIds): void {
$inserts = [];
foreach ($rows as $row) {
$legacyId = (int) ($row->comment_id ?? 0);
$legacyNewsId = (int) ($row->news_id ?? 0);
$legacyUserId = (int) ($row->user_id ?? 0);
$body = trim((string) ($row->message ?? ''));
if ($legacyId < 1) {
$stats['errors']++;
continue;
}
if (isset($alreadyImported[$legacyId])) {
$stats['skipped_duplicate']++;
continue;
}
if ($body === '') {
if ($skipEmpty) {
$stats['skipped_empty']++;
continue;
}
$body = '[no content]';
}
$articleId = $articleMap[$legacyNewsId] ?? null;
if (! $articleId) {
$stats['skipped_article']++;
continue;
}
$resolvedUserId = isset($validUserIds[$legacyUserId]) ? $legacyUserId : null;
if ($resolvedUserId === null && $legacyUserId > 0) {
$stats['users_unmapped']++;
}
$timestamp = $this->normalizeTimestamp($row->posted ?? null);
$authorName = trim((string) ($row->author ?? ''));
$record = [
'legacy_id' => $legacyId,
'legacy_user_id' => $legacyUserId > 0 ? $legacyUserId : null,
'article_id' => $articleId,
'user_id' => $resolvedUserId,
'parent_id' => null,
'author_name' => $authorName !== '' ? $authorName : null,
'body' => $body,
'rendered_body' => nl2br(e($body)),
'status' => 'visible',
'legacy_posted_at' => $timestamp,
'created_at' => $timestamp,
'updated_at' => $timestamp,
'deleted_at' => null,
];
if (! $dryRun) {
$inserts[] = $record;
$alreadyImported[$legacyId] = true;
$touchedArticleIds[$articleId] = $articleId;
}
$stats['imported']++;
}
if (! $dryRun && $inserts !== []) {
try {
DB::table('news_article_comments')->insert($inserts);
} catch (\Throwable) {
foreach ($inserts as $insert) {
try {
DB::table('news_article_comments')->insertOrIgnore([$insert]);
} catch (\Throwable) {
$stats['errors']++;
}
}
}
}
});
if (! $dryRun && $touchedArticleIds !== []) {
DB::table('news_articles')
->whereIn('id', array_values($touchedArticleIds))
->update(['comments_enabled' => true]);
}
$this->table(
['Result', 'Count'],
[
['Imported', $stats['imported']],
['Skipped - already imported', $stats['skipped_duplicate']],
['Skipped - article missing', $stats['skipped_article']],
['Skipped - empty body', $stats['skipped_empty']],
['Imported with unmapped user', $stats['users_unmapped']],
['Errors', $stats['errors']],
]
);
return $stats['errors'] > 0 ? self::FAILURE : self::SUCCESS;
}
private function resolveLegacyTable(): ?string
{
$configured = trim((string) $this->option('table'));
if ($configured !== '') {
return DB::connection('legacy')->getSchemaBuilder()->hasTable($configured) ? $configured : null;
}
foreach (['news_comment', 'news_comments'] as $candidate) {
if (DB::connection('legacy')->getSchemaBuilder()->hasTable($candidate)) {
return $candidate;
}
}
return null;
}
private function normalizeTimestamp(mixed $value): string
{
$raw = trim((string) ($value ?? ''));
if ($raw === '' || str_starts_with($raw, '0000-00-00')) {
return now()->toDateTimeString();
}
try {
return Carbon::parse($raw)->toDateTimeString();
} catch (\Throwable) {
return now()->toDateTimeString();
}
}
}

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ namespace App\Console\Commands;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use App\Services\News\NewsService;
use cPad\Plugins\News\Models\NewsArticle; use cPad\Plugins\News\Models\NewsArticle;
final class PublishScheduledNewsCommand extends Command final class PublishScheduledNewsCommand extends Command
@@ -17,6 +18,11 @@ final class PublishScheduledNewsCommand extends Command
protected $description = 'Publish scheduled News articles whose publish time has passed.'; protected $description = 'Publish scheduled News articles whose publish time has passed.';
public function __construct(private readonly NewsService $news)
{
parent::__construct();
}
public function handle(): int public function handle(): int
{ {
$dryRun = (bool) $this->option('dry-run'); $dryRun = (bool) $this->option('dry-run');
@@ -60,11 +66,7 @@ final class PublishScheduledNewsCommand extends Command
return; return;
} }
$article->forceFill([ $this->news->publish($article);
'editorial_status' => NewsArticle::EDITORIAL_STATUS_PUBLISHED,
'status' => 'published',
'published_at' => $article->published_at ?? $now,
])->save();
$published++; $published++;
$this->line(sprintf('Published News article #%d: "%s"', $article->id, $article->title)); $this->line(sprintf('Published News article #%d: "%s"', $article->id, $article->title));

View File

@@ -28,6 +28,9 @@ final class PublishSitemapsCommand extends Command
return self::SUCCESS; return self::SUCCESS;
} }
$startedAt = microtime(true);
$this->line('<fg=cyan>Building sitemap release...</>');
try { try {
$manifest = $publish->publish(is_string($releaseId) && $releaseId !== '' ? $releaseId : null); $manifest = $publish->publish(is_string($releaseId) && $releaseId !== '' ? $releaseId : null);
} catch (\Throwable $exception) { } catch (\Throwable $exception) {
@@ -36,11 +39,59 @@ final class PublishSitemapsCommand extends Command
return self::FAILURE; 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( $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'], (string) $manifest['release_id'],
(int) data_get($manifest, 'totals.families', 0), (int) data_get($manifest, 'totals.families', 0),
(int) data_get($manifest, 'totals.documents', 0), (int) data_get($manifest, 'totals.documents', 0),
(int) data_get($manifest, 'totals.urls', 0),
$elapsed,
)); ));
return self::SUCCESS; return self::SUCCESS;

View File

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

View File

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

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\LeaderboardService;
use Illuminate\Console\Command;
class RefreshLeaderboardsCommand extends Command
{
protected $signature = 'leaderboards:refresh';
protected $description = 'Refresh all leaderboard rows and clear leaderboard caches.';
public function __construct(private readonly LeaderboardService $leaderboards)
{
parent::__construct();
}
public function handle(): int
{
$this->info('Refreshing leaderboards …');
$results = $this->leaderboards->refreshAll();
$updated = collect($results)
->flatten(1)
->sum(fn (int $count): int => $count);
$this->info("Done. Updated: {$updated} leaderboard row(s).");
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,502 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Artwork;
use App\Repositories\Uploads\ArtworkFileRepository;
use App\Services\ArtworkOriginalFileLocator;
use App\Services\Cdn\ArtworkCdnPurgeService;
use App\Services\Uploads\UploadDerivativesService;
use App\Services\Uploads\UploadStorageService;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Throwable;
final class RepairArtworkThumbnailsCommand extends Command
{
private const SOURCE_IMAGE_EXTENSIONS = [
'avif',
'bmp',
'gif',
'jpg',
'jpeg',
'png',
'tif',
'tiff',
'webp',
];
protected $signature = 'artworks:repair-missing-thumbnails
{--id= : Repair only this artwork ID}
{--limit= : Stop after processing this many artworks}
{--chunk=200 : Number of artworks to scan per batch}
{--variant=* : Specific thumbnail variants to repair (defaults to all configured derivatives)}
{--only-missing-flagged : Scan only artworks already marked with has_missing_thumbnails=1}
{--csv= : Optional path to write a CSV report}
{--force : Regenerate the selected variants even when they already exist}
{--dry-run : Report repairs without writing files}';
protected $description = 'Scan artworks from newest to oldest, detect missing CDN thumbnails, and rebuild only the missing derivatives from local source files.';
public function handle(
UploadStorageService $storage,
UploadDerivativesService $derivatives,
ArtworkFileRepository $artworkFiles,
ArtworkOriginalFileLocator $locator,
ArtworkCdnPurgeService $cdnPurge,
): 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'), 1000));
$onlyMissingFlagged = (bool) $this->option('only-missing-flagged');
$csvPath = trim((string) $this->option('csv'));
$force = (bool) $this->option('force');
$dryRun = (bool) $this->option('dry-run');
$allVariants = $this->resolveConfiguredVariants();
$selectedVariants = $this->resolveSelectedVariants($allVariants);
if ($selectedVariants === []) {
return self::FAILURE;
}
$auditColumnsAvailable = Schema::hasColumns('artworks', [
'has_missing_thumbnails',
'missing_thumbnail_variants_json',
'thumbnails_checked_at',
]);
if ($onlyMissingFlagged && ! $auditColumnsAvailable) {
$this->error('The --only-missing-flagged option requires thumbnail audit columns on the artworks table.');
return self::FAILURE;
}
$diskName = $storage->objectDiskName();
$disk = Storage::disk($diskName);
$csvHandle = $this->openCsvHandle($csvPath);
$baseQuery = $this->baseQuery($onlyMissingFlagged);
$totalCandidates = $this->resolveTotalCandidates($baseQuery, $artworkId, $limit);
$progressBar = $totalCandidates > 0 ? $this->output->createProgressBar($totalCandidates) : null;
$this->info(sprintf(
'Starting thumbnail repair. order=id_desc include_trashed=yes disk=%s variants=%s chunk=%d limit=%s flagged_only=%s force=%s dry_run=%s csv=%s',
$diskName,
implode(',', $selectedVariants),
$chunkSize,
$limit !== null ? (string) $limit : 'all',
$onlyMissingFlagged ? 'yes' : 'no',
$force ? 'yes' : 'no',
$dryRun ? 'yes' : 'no',
$csvPath !== '' ? $csvPath : 'off',
));
if ($progressBar !== null) {
$progressBar->start();
}
$processed = 0;
$healthy = 0;
$planned = 0;
$repaired = 0;
$failed = 0;
$lastSeenId = null;
try {
do {
$artworks = $this->nextChunk($baseQuery, $artworkId, $chunkSize, $lastSeenId);
if ($artworks->isEmpty()) {
break;
}
foreach ($artworks as $artwork) {
if ($limit !== null && $processed >= $limit) {
break 2;
}
try {
$targetVariants = $force
? $selectedVariants
: $this->resolveMissingVariants($artwork, $selectedVariants, $storage, $disk);
if ($targetVariants === []) {
$healthy++;
$processed++;
$this->writeCsvRow($csvHandle, [
'artwork_id' => (int) $artwork->id,
'status' => 'healthy',
'variants' => '',
'source_file' => '',
'message' => '',
]);
$progressBar?->advance();
continue;
}
$sourcePath = $this->resolveLocalSourcePath($artwork, $locator);
if ($sourcePath === '') {
throw new \RuntimeException('No local original source file was found in the configured artwork roots.');
}
if ($dryRun) {
$planned++;
$this->line(sprintf(
'Artwork %d would repair thumbnails: %s',
(int) $artwork->id,
implode(',', $targetVariants),
));
$this->line(' source_file: ' . $sourcePath);
$this->writeCsvRow($csvHandle, [
'artwork_id' => (int) $artwork->id,
'status' => 'planned',
'variants' => implode(',', $targetVariants),
'source_file' => $sourcePath,
'message' => '',
]);
$processed++;
$progressBar?->advance();
continue;
}
$assets = $derivatives->generateSelectedPublicDerivatives($sourcePath, (string) $artwork->hash, $targetVariants);
if ($assets === []) {
throw new \RuntimeException('No thumbnail assets were generated for the requested variants.');
}
DB::transaction(function () use ($artwork, $assets, $artworkFiles, $storage, $disk, $allVariants, $auditColumnsAvailable): void {
foreach ($assets as $variant => $asset) {
$artworkFiles->upsert((int) $artwork->id, (string) $variant, $asset['path'], $asset['mime'], $asset['size']);
}
$update = [
'thumb_ext' => 'webp',
];
if ($auditColumnsAvailable) {
$remainingMissing = $this->resolveMissingVariants($artwork, $allVariants, $storage, $disk);
$update['has_missing_thumbnails'] = $remainingMissing !== [];
$update['missing_thumbnail_variants_json'] = $remainingMissing === []
? null
: json_encode(array_values($remainingMissing), JSON_UNESCAPED_SLASHES);
$update['thumbnails_checked_at'] = now();
}
Artwork::query()->withTrashed()->whereKey($artwork->id)->update($update);
});
$cdnPurge->purgeArtworkObjectPaths(array_map(
static fn (array $asset): string => (string) $asset['path'],
array_values($assets),
), [
'artwork_id' => (int) $artwork->id,
'reason' => 'thumbnail_repair',
]);
$repaired++;
$this->info(sprintf(
'Artwork %d repaired thumbnails: %s',
(int) $artwork->id,
implode(',', array_keys($assets)),
));
$this->writeCsvRow($csvHandle, [
'artwork_id' => (int) $artwork->id,
'status' => 'repaired',
'variants' => implode(',', array_keys($assets)),
'source_file' => $sourcePath,
'message' => '',
]);
} catch (Throwable $exception) {
$failed++;
$this->warn(sprintf('Artwork %d repair failed: %s', (int) $artwork->id, $exception->getMessage()));
$this->writeCsvRow($csvHandle, [
'artwork_id' => (int) $artwork->id,
'status' => 'failed',
'variants' => isset($targetVariants) && is_array($targetVariants) ? implode(',', $targetVariants) : '',
'source_file' => isset($sourcePath) ? (string) $sourcePath : '',
'message' => $exception->getMessage(),
]);
}
$processed++;
$progressBar?->advance();
}
$lastSeenId = (int) $artworks->last()->id;
} while (true);
} finally {
if ($progressBar !== null) {
$progressBar->finish();
$this->newLine(2);
}
if (is_resource($csvHandle)) {
fclose($csvHandle);
}
}
$this->info(sprintf(
'Thumbnail repair complete. processed=%d healthy=%d planned=%d repaired=%d failed=%d',
$processed,
$healthy,
$planned,
$repaired,
$failed,
));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* @return Collection<int, Artwork>
*/
private function nextChunk(mixed $baseQuery, ?int $artworkId, int $chunkSize, ?int $lastSeenId): Collection
{
$query = clone $baseQuery;
if ($artworkId !== null) {
$query->whereKey($artworkId);
} elseif ($lastSeenId !== null) {
$query->where('id', '<', $lastSeenId);
}
return $query->limit($chunkSize)->get();
}
private function baseQuery(bool $onlyMissingFlagged): mixed
{
$query = Artwork::query()
->withTrashed()
->select(['id', 'slug', 'hash', 'file_path', 'file_ext', 'thumb_ext'])
->whereNotNull('hash')
->where('hash', '!=', '')
->orderByDesc('id');
if ($onlyMissingFlagged) {
$query->where('has_missing_thumbnails', true);
}
return $query;
}
private function resolveTotalCandidates(mixed $baseQuery, ?int $artworkId, ?int $limit): int
{
$countQuery = clone $baseQuery;
if ($artworkId !== null) {
$countQuery->whereKey($artworkId);
}
$count = (int) $countQuery->count();
if ($limit !== null) {
return min($count, $limit);
}
return $count;
}
/**
* @return list<string>
*/
private function resolveConfiguredVariants(): array
{
return array_values(array_filter(array_map(
static fn ($variant): string => strtolower(trim((string) $variant)),
array_keys((array) config('uploads.derivatives', [])),
)));
}
/**
* @param list<string> $configuredVariants
* @return list<string>
*/
private function resolveSelectedVariants(array $configuredVariants): array
{
if ($configuredVariants === []) {
$this->error('No thumbnail variants are configured. Check uploads.derivatives.');
return [];
}
$requested = (array) $this->option('variant');
if ($requested === []) {
return $configuredVariants;
}
$normalizedRequested = array_values(array_unique(array_filter(array_map(
static fn ($variant): string => strtolower(trim((string) $variant)),
$requested,
))));
$invalid = array_values(array_diff($normalizedRequested, $configuredVariants));
if ($invalid !== []) {
$this->error('Unknown thumbnail variants: ' . implode(', ', $invalid));
$this->line('Configured variants: ' . implode(', ', $configuredVariants));
return [];
}
return $normalizedRequested;
}
/**
* @param list<string> $variants
* @return list<string>
*/
private function resolveMissingVariants(Artwork $artwork, array $variants, UploadStorageService $storage, mixed $disk): array
{
$hash = strtolower((string) preg_replace('/[^a-z0-9]/', '', (string) ($artwork->hash ?? '')));
if ($hash === '') {
return $variants;
}
$missing = [];
foreach ($variants as $variant) {
$objectPath = $storage->objectPathForVariant($variant, $hash, $hash . '.webp');
if (! $disk->exists($objectPath)) {
$missing[] = $variant;
}
}
return $missing;
}
private function resolveLocalSourcePath(Artwork $artwork, ArtworkOriginalFileLocator $locator): string
{
$hash = strtolower((string) ($artwork->hash ?? ''));
if (! $this->isValidHash($hash)) {
return '';
}
$preferred = $locator->resolveLocalPath($artwork);
if ($this->isUsableSourceFile($preferred)) {
return $preferred;
}
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 ($this->isUsableSourceFile($path)) {
return $path;
}
}
return '';
}
private function isUsableSourceFile(string $path): bool
{
if ($path === '' || ! File::isFile($path)) {
return false;
}
$extension = strtolower((string) pathinfo($path, PATHINFO_EXTENSION));
if ($extension === '' || ! in_array($extension, self::SOURCE_IMAGE_EXTENSIONS, true)) {
return false;
}
$mime = strtolower((string) (File::mimeType($path) ?? ''));
return str_starts_with($mime, 'image/');
}
private function isValidHash(string $hash): bool
{
return $hash !== '' && preg_match('/^[a-f0-9]+$/', $hash) === 1;
}
/**
* @return resource|null
*/
private function openCsvHandle(string $csvPath)
{
if ($csvPath === '') {
return null;
}
File::ensureDirectoryExists(dirname($csvPath));
$handle = fopen($csvPath, 'wb');
if (! is_resource($handle)) {
throw new \RuntimeException('Unable to open CSV output path for writing: ' . $csvPath);
}
fputcsv($handle, ['artwork_id', 'status', 'variants', 'source_file', 'message']);
return $handle;
}
/**
* @param resource|null $csvHandle
* @param array<string, scalar|null> $row
*/
private function writeCsvRow($csvHandle, array $row): void
{
if (! is_resource($csvHandle)) {
return;
}
fputcsv($csvHandle, [
$row['artwork_id'] ?? '',
$row['status'] ?? '',
$row['variants'] ?? '',
$row['source_file'] ?? '',
$row['message'] ?? '',
]);
}
}

View File

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

View File

@@ -0,0 +1,162 @@
<?php
namespace App\Console\Commands;
use App\Jobs\SendVerificationEmailJob;
use App\Mail\RegistrationVerificationMail;
use App\Models\EmailSendEvent;
use App\Models\User;
use App\Services\Auth\RegistrationEmailQuotaService;
use App\Services\Auth\RegistrationVerificationTokenService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
class SendUserVerificationEmailCommand extends Command
{
protected $signature = 'user:send-verification-email
{userId : The user ID that should receive the verification email}
{--now : Send immediately instead of queueing the existing verification job}
{--force : Allow sending even if the user is already verified}';
protected $description = 'Send the registration verification email to a specific user ID.';
public function __construct(
private readonly RegistrationVerificationTokenService $tokenService,
private readonly RegistrationEmailQuotaService $quotaService,
) {
parent::__construct();
}
public function handle(): int
{
$userId = (int) $this->argument('userId');
if ($userId < 1) {
$this->error('The user ID must be a positive integer.');
return self::FAILURE;
}
$user = User::query()->find($userId);
if (! $user) {
$this->error("User {$userId} was not found.");
return self::FAILURE;
}
$email = strtolower(trim((string) $user->email));
if ($email === '') {
$this->error("User {$userId} does not have an email address.");
return self::FAILURE;
}
if ($user->email_verified_at !== null && ! $this->option('force')) {
$this->error("User {$userId} already has a verified email address. Use --force to send anyway.");
return self::FAILURE;
}
$token = $this->tokenService->createForUser($userId);
$event = EmailSendEvent::query()->create([
'type' => 'verify_email',
'email' => $email,
'ip' => null,
'user_id' => $userId,
'status' => $this->option('now') ? 'pending' : 'queued',
'reason' => null,
'created_at' => now(),
]);
if ($this->option('now')) {
return $this->sendNow($user, $event, $token);
}
SendVerificationEmailJob::dispatch(
emailEventId: (int) $event->id,
email: $email,
token: $token,
userId: $userId,
ip: null,
);
$this->markVerificationEmailSent($user);
$this->info("Queued verification email for user {$userId} <{$email}>.");
return self::SUCCESS;
}
private function sendNow(User $user, EmailSendEvent $event, string $token): int
{
if (! $this->acquireGlobalSendSlot()) {
$this->updateEvent($event, 'blocked', 'rate_limited');
$this->error('The global verification email rate limit is currently exhausted. Try again in a minute.');
return self::FAILURE;
}
if ($this->quotaService->isExceeded()) {
$this->updateEvent($event, 'blocked', 'quota');
$this->error('The monthly registration email quota is exceeded.');
return self::FAILURE;
}
try {
Mail::to($user->email)->send(new RegistrationVerificationMail($token));
} catch (\Throwable $exception) {
$this->updateEvent($event, 'failed', 'send_error');
$this->error('Failed to send the verification email: ' . $exception->getMessage());
return self::FAILURE;
}
$this->quotaService->incrementSentCount();
$this->updateEvent($event, 'sent', null);
$this->markVerificationEmailSent($user);
$email = strtolower(trim((string) $user->email));
$this->info("Sent verification email to user {$user->id} <{$email}>.");
return self::SUCCESS;
}
private function acquireGlobalSendSlot(): bool
{
$key = 'registration:verification-email:global';
$maxPerMinute = max(1, (int) config('registration.email_global_send_per_minute', 30));
return RateLimiter::attempt($key, $maxPerMinute, static fn () => true, 60);
}
private function updateEvent(EmailSendEvent $event, string $status, ?string $reason): void
{
EmailSendEvent::query()
->whereKey($event->getKey())
->update([
'status' => $status,
'reason' => $reason,
]);
}
private function markVerificationEmailSent(User $user): void
{
$now = now();
$windowStartedAt = $user->verification_send_window_started_at;
if (! $windowStartedAt || $windowStartedAt->lt($now->copy()->subDay())) {
$user->verification_send_window_started_at = $now;
$user->verification_send_count_24h = 1;
} else {
$user->verification_send_count_24h = ((int) $user->verification_send_count_24h) + 1;
}
$user->last_verification_sent_at = $now;
$user->save();
}
}

View File

@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\WorldWebStory;
use App\Services\WebStories\WorldWebStoryValidationService;
use Illuminate\Console\Command;
use Symfony\Component\Process\Process;
final class ValidateWorldWebStoriesCommand extends Command
{
protected $signature = 'skinbase:webstories:validate
{story? : Story ID or slug}
{--published : Limit batch mode to published stories}
{--visible : Limit batch mode to publicly visible stories}
{--limit=100 : Maximum stories to validate in batch mode}
{--amp : Also run amphtml-validator against the public story URL}
{--fail-warnings : Treat validation warnings as failures}';
protected $description = 'Validate World Web Stories for publish safety and optional AMP validity';
public function handle(WorldWebStoryValidationService $validation): int
{
$storyKey = $this->argument('story');
if ($storyKey !== null && trim((string) $storyKey) !== '') {
$story = $this->resolveStory((string) $storyKey);
if (! $story instanceof WorldWebStory) {
$this->error(sprintf('Web story [%s] was not found.', (string) $storyKey));
return self::FAILURE;
}
return $this->validateOne($validation, $story);
}
return $this->validateBatch($validation, max(1, (int) $this->option('limit')));
}
private function validateOne(WorldWebStoryValidationService $validation, WorldWebStory $story): int
{
$result = $validation->validate($story);
$ampErrors = $this->ampErrors($story);
$this->line(sprintf('Story [%d] %s', (int) $story->id, (string) $story->slug));
foreach ((array) $result['warnings'] as $warning) {
$this->warn(' - ' . $warning);
}
foreach ((array) $result['errors'] as $error) {
$this->error(' - ' . $error);
}
foreach ($ampErrors as $ampError) {
$this->error(' - AMP: ' . $ampError);
}
if ($result['valid'] && $ampErrors === []) {
$this->info('Validation passed.');
return self::SUCCESS;
}
return self::FAILURE;
}
private function validateBatch(WorldWebStoryValidationService $validation, int $limit): int
{
$processed = 0;
$failed = 0;
$this->storyQuery()
->limit($limit)
->get()
->each(function (WorldWebStory $story) use ($validation, &$processed, &$failed): void {
$processed++;
$result = $validation->validate($story);
$ampErrors = $this->ampErrors($story);
$warningsFail = (bool) $this->option('fail-warnings') && count((array) $result['warnings']) > 0;
$hasFailure = ! $result['valid'] || $warningsFail || $ampErrors !== [];
if ($hasFailure) {
$failed++;
}
$this->line(sprintf('[%d] %s -> %s', (int) $story->id, (string) $story->slug, $hasFailure ? 'invalid' : 'valid'));
foreach ((array) $result['warnings'] as $warning) {
$this->warn(' - ' . $warning);
}
foreach ((array) $result['errors'] as $error) {
$this->error(' - ' . $error);
}
foreach ($ampErrors as $ampError) {
$this->error(' - AMP: ' . $ampError);
}
});
$this->info(sprintf('Done. processed=%d failed=%d', $processed, $failed));
return $failed === 0 ? self::SUCCESS : self::FAILURE;
}
private function storyQuery()
{
return WorldWebStory::query()
->when((bool) $this->option('published'), fn ($query) => $query->published())
->when((bool) $this->option('visible'), fn ($query) => $query->visible())
->orderByDesc('published_at')
->orderByDesc('id');
}
/**
* @return list<string>
*/
private function ampErrors(WorldWebStory $story): array
{
if (! (bool) $this->option('amp')) {
return [];
}
if (! $story->exists || ! $story->publicUrl()) {
return ['Story has no public URL to validate.'];
}
$probe = new Process(['npx', 'amphtml-validator', '--version'], base_path(), null, null, 60);
$probe->run();
if (! $probe->isSuccessful()) {
return ['amphtml-validator is not available via npx.'];
}
$process = new Process(['npx', 'amphtml-validator', $story->publicUrl()], base_path(), null, null, 120);
$process->run();
if ($process->isSuccessful()) {
return [];
}
$output = trim($process->getErrorOutput() ?: $process->getOutput());
if ($output === '') {
return ['AMP validator failed without output.'];
}
$lines = preg_split('/\r\n|\r|\n/', $output);
return $lines === false || $lines === [] ? ['AMP validator failed.'] : $lines;
}
private function resolveStory(string $value): ?WorldWebStory
{
return WorldWebStory::query()
->when(is_numeric($value), fn ($query) => $query->where('id', (int) $value), fn ($query) => $query->where('slug', $value))
->first();
}
}

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