Compare commits

..

29 Commits

Author SHA1 Message Date
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
157c6d49e8 Sanitize browse categories HTML output 2026-04-25 08:12:46 +02:00
a0b903f09d UI: use free FontAwesome globe icon for Worlds menu item 2026-04-18 17:13:28 +02:00
2215 changed files with 693116 additions and 1017436 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;
}
$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; 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,7 +187,9 @@ class NewsController extends Controller
$article->incrementViews(); $article->incrementViews();
$request->session()->put($session, true); if ($canReadSession) {
$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
@@ -290,7 +298,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,6 +306,7 @@ 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

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,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()); $this->line(sprintf(
' <info>✔</info> sitemap.xml %d entries <comment>%.3fs</comment>',
return self::FAILURE; $index['url_count'],
} microtime(true) - $t,
));
$totalUrls = 0;
$totalDocuments = 0;
// ── Per-family documents ──────────────────────────────────────────
foreach ($families as $family) { foreach ($families as $family) {
$names = (array) data_get($manifest, 'families.' . $family . '.documents', []); $familyStart = microtime(true);
$familyUrls = 0;
if (! $showShards) { $this->newLine();
$this->line('Building family [' . $family . '] with ' . count($names) . ' document(s).');
if ($family === 'artworks') {
// Direct MySQL path — no cursor-scan shard window computation
[$shardNames, $fw, $ff] = $this->buildArtworksDirect($disk, (bool) $this->option('progress'));
$written += $fw;
$failed += $ff;
$this->line(sprintf(
' <fg=cyan>artworks</> done %d file(s) <comment>%.3fs</comment>',
$fw,
microtime(true) - $familyStart,
));
continue;
} }
foreach ($names as $name) { $names = $build->canonicalDocumentNamesForFamily($family);
$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(' <fg=cyan>%s</> (%d document(s))', $family, count($names)));
foreach ($names as $documentName) {
$t = microtime(true);
$this->line(sprintf(' Building %s…', $documentName));
$built = $build->buildNamed($documentName, force: true, persist: false);
if ($built === null) {
$this->line(sprintf(' <comment></comment> %s <fg=red>SKIPPED</> (builder returned null)', $documentName));
$failed++;
continue;
}
$disk->put('sitemaps/' . $documentName . '.xml', $built['content']);
$written++;
$this->line(sprintf(
' <info>✔</info> %s %d URLs <comment>%.3fs</comment>',
$documentName . '.xml',
$built['url_count'] ?? 0,
microtime(true) - $t,
));
}
$this->line(sprintf(
' <fg=cyan>%s</> done <comment>%.3fs</comment>',
$family,
microtime(true) - $familyStart,
));
}
// ── Summary ───────────────────────────────────────────────────────
$this->newLine();
$this->info(sprintf(
'Done: %d file(s) written, %d failed total <comment>%.3fs</comment>',
$written,
$failed,
microtime(true) - $totalStart,
));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* Stream artworks directly from MySQL using chunkById avoids cursor-scan shard windows.
*
* @return array{0: list<string>, 1: int, 2: int} [shardNames, written, failed]
*/
private function buildArtworksDirect(Filesystem $disk, bool $showProgress = false): array
{
$chunkSize = max(1, (int) config('sitemaps.shards.artworks.size', 10_000));
$padLen = max(1, (int) config('sitemaps.shards.zero_pad_length', 4));
$shardNum = 0;
$shardNames = [];
$written = 0;
$failed = 0;
$baseQuery = Artwork::query()
->public()
->published();
$total = $showProgress ? (clone $baseQuery)->count() : null;
$this->line(sprintf(
' <fg=cyan>artworks</> (chunk size: %d%s)',
$chunkSize,
$total !== null ? ', total: ' . number_format($total) : '',
));
$bar = null;
if ($total !== null) {
$bar = $this->output->createProgressBar($total);
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% shard %message% elapsed: %elapsed:6s% mem: %memory:6s%');
$bar->setMessage('—');
$bar->start();
$this->newLine();
}
$baseQuery
->select(['id', 'slug', 'title', 'updated_at', 'published_at', 'created_at', 'hash', 'file_path', 'file_name'])
->orderBy('id')
->chunkById($chunkSize, function ($artworks) use ($disk, $padLen, $bar, &$shardNum, &$shardNames, &$written, &$failed): void {
$shardNum++;
$t = microtime(true);
$name = 'artworks-' . str_pad((string) $shardNum, $padLen, '0', STR_PAD_LEFT);
if ($bar !== null) {
$bar->setMessage($name);
} else {
$this->line(sprintf(' Building %s (%d rows)…', $name, $artworks->count()));
}
/** @var list<SitemapUrl> $items */
$items = $artworks
->map(fn (Artwork $artwork): ?SitemapUrl => $this->artworkSitemapUrl($artwork))
->filter()
->values()
->all();
$xml = view('sitemaps.urlset', [
'items' => $items,
'hasImages' => collect($items)->contains(fn (SitemapUrl $item): bool => $item->images !== []),
])->render();
if (! $disk->put('sitemaps/' . $name . '.xml', $xml)) {
if ($bar !== null) {
$bar->advance($artworks->count());
$this->newLine();
}
$this->line(sprintf(' <comment></comment> %s <fg=red>WRITE FAILED</>', $name . '.xml'));
$failed++;
return;
}
$shardNames[] = $name;
$written++;
if ($bar !== null) {
$bar->advance($artworks->count());
} else {
$this->line(sprintf( $this->line(sprintf(
' - %s [%s]', ' <info>✔</info> %s %d URLs <comment>%.3fs</comment>',
$name, $name . '.xml',
$documentType, count($items),
microtime(true) - $t,
)); ));
} }
} });
$this->info(sprintf('Family [%s] complete: urls=%d documents=%d', $family, (int) data_get($manifest, 'families.' . $family . '.url_count', 0), count($names))); if ($bar !== null) {
$bar->setMessage('done');
$bar->finish();
$this->newLine();
} }
$totalDocuments++;
$this->info(sprintf( if ($shardNames === []) {
'Sitemap release [%s] complete: families=%d documents=%d urls=%d status=%s duration=%.2fs', return [[], 0, $failed];
(string) $manifest['release_id'], }
(int) data_get($manifest, 'totals.families', 0),
(int) data_get($manifest, 'totals.documents', 0), // Write artworks-index.xml when there are multiple shards (matches SitemapShardService behaviour)
(int) data_get($manifest, 'totals.urls', 0), if (count($shardNames) > 1) {
(string) ($manifest['status'] ?? 'built'), $t = microtime(true);
microtime(true) - $startedAt, $indexItems = array_map(
fn (string $n): SitemapIndexItem => new SitemapIndexItem(url('/sitemaps/' . $n . '.xml')),
$shardNames,
);
$indexXml = view('sitemaps.index', ['items' => $indexItems])->render();
$disk->put('sitemaps/artworks-index.xml', $indexXml);
$written++;
$this->line(sprintf(
' <info>✔</info> artworks-index.xml %d shards <comment>%.3fs</comment>',
count($shardNames),
microtime(true) - $t,
));
}
return [$shardNames, $written, $failed];
}
private function artworkSitemapUrl(Artwork $artwork): ?SitemapUrl
{
$slug = Str::slug((string) ($artwork->slug ?: $artwork->title));
if ($slug === '') {
$slug = (string) $artwork->id;
}
$preview = ThumbnailPresenter::present($artwork, 'xl');
$images = [];
if (! empty($preview['url'])) {
$images[] = new SitemapImage((string) $preview['url'], $artwork->title ?: null);
}
$timestamps = array_filter(array_map(
static fn (mixed $v): ?Carbon => $v instanceof Carbon ? $v : (is_string($v) ? Carbon::parse($v) : null),
[$artwork->updated_at, $artwork->published_at, $artwork->created_at],
)); ));
$this->line('Sitemap index complete');
return self::SUCCESS; usort($timestamps, static fn (Carbon $a, Carbon $b): int => $b->timestamp <=> $a->timestamp);
return new SitemapUrl(
route('art.show', ['id' => (int) $artwork->id, 'slug' => $slug]),
$timestamps[0] ?? null,
$images,
);
} }
/** /**

View File

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

View File

@@ -1010,7 +1010,7 @@ class HealthCheckCommand extends Command
private function checkScheduler(): void 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.');

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

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

View File

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

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

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

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Enums;
enum WorldRewardType: string
{
case Participant = 'participant';
case Featured = 'featured';
case Finalist = 'finalist';
case Winner = 'winner';
case Spotlight = 'spotlight';
public function label(): string
{
return match ($this) {
self::Participant => 'Participant',
self::Featured => 'Featured',
self::Finalist => 'Finalist',
self::Winner => 'Winner',
self::Spotlight => 'Spotlight',
};
}
public function tone(): string
{
return match ($this) {
self::Participant => 'sky',
self::Featured => 'amber',
self::Finalist => 'violet',
self::Winner => 'emerald',
self::Spotlight => 'rose',
};
}
public function source(): string
{
return match ($this) {
self::Participant, self::Featured => 'automatic',
self::Finalist, self::Winner, self::Spotlight => 'manual',
};
}
public function isAutomatic(): bool
{
return $this->source() === 'automatic';
}
public function xpReward(): int
{
return match ($this) {
self::Participant => 20,
self::Featured => 40,
self::Finalist => 70,
self::Winner => 120,
self::Spotlight => 55,
};
}
}

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Models\AcademyChallenge;
use App\Services\Academy\AcademyAccessService;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
final class AcademyChallengeController extends Controller
{
public function __construct(private readonly AcademyAccessService $access)
{
}
public function index(Request $request): Response
{
abort_unless((bool) config('academy.enabled', true), 404);
abort_unless((bool) config('academy.challenges_enabled', true), 404);
$challenges = AcademyChallenge::query()
->publiclyVisible()
->latest('starts_at')
->paginate(12)
->withQueryString();
$challenges->getCollection()->transform(fn (AcademyChallenge $challenge): array => $this->access->challengePayload($challenge, $request->user(), true));
$seo = app(SeoFactory::class)
->collectionListing(
'Academy Challenges — Skinbase',
'Join Academy creative briefs, review accepted submissions, and explore current and archived AI Academy challenges.',
route('academy.challenges.index'),
)
->toArray();
return Inertia::render('Academy/List', [
'pageType' => 'challenges',
'title' => 'Academy challenges',
'description' => 'Creative briefs for wallpapers, worlds, covers, and prompt-driven visual experiments.',
'seo' => $seo,
'items' => $challenges,
'filters' => [],
'categories' => [],
'pricingUrl' => route('academy.pricing'),
])->rootView('collections');
}
public function show(Request $request, string $slug): Response
{
abort_unless((bool) config('academy.enabled', true), 404);
abort_unless((bool) config('academy.challenges_enabled', true), 404);
$challenge = AcademyChallenge::query()
->with(['submissions' => fn ($query) => $query->approved()->with(['user:id,name,username', 'artwork'])])
->publiclyVisible()
->where('slug', $slug)
->firstOrFail();
$payload = $this->access->challengePayload($challenge, $request->user(), true);
$payload['submissions'] = $challenge->submissions->map(fn ($submission): array => [
'id' => (int) $submission->id,
'user' => $submission->user ? [
'id' => (int) $submission->user->id,
'name' => (string) $submission->user->name,
'username' => (string) ($submission->user->username ?? ''),
] : null,
'artwork' => $submission->artwork ? [
'id' => (int) $submission->artwork->id,
'title' => (string) ($submission->artwork->title ?? 'Untitled artwork'),
'thumb_url' => $submission->artwork->thumbUrl('md'),
] : null,
'submitted_at' => $submission->submitted_at?->toISOString(),
])->values()->all();
$seo = app(SeoFactory::class)->collectionPage(
$challenge->title . ' — Skinbase Academy',
Str::limit((string) ($challenge->excerpt ?? $challenge->description ?? ''), 160, '...'),
route('academy.challenges.show', ['slug' => $challenge->slug]),
$challenge->cover_image,
)->toArray();
return Inertia::render('Academy/Show', [
'pageType' => 'challenge',
'item' => $payload,
'seo' => $seo,
'pricingUrl' => route('academy.pricing'),
'submitUrl' => $request->user() ? route('academy.challenges.submit', ['slug' => $challenge->slug]) : null,
])->rootView('collections');
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Http\Requests\Academy\StoreAcademyChallengeSubmissionRequest;
use App\Models\AcademyChallenge;
use App\Models\Artwork;
use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyChallengeService;
use App\Support\Seo\SeoFactory;
use Inertia\Inertia;
use Inertia\Response;
use Illuminate\Http\RedirectResponse;
final class AcademyChallengeSubmissionController extends Controller
{
public function __construct(
private readonly AcademyAccessService $access,
private readonly AcademyChallengeService $challenges,
) {
}
public function create(string $slug): Response
{
abort_unless((bool) config('academy.enabled', true), 404);
abort_unless((bool) config('academy.challenges_enabled', true), 404);
$challenge = AcademyChallenge::query()->where('slug', $slug)->firstOrFail();
abort_unless($this->access->canAccessChallenge(request()->user(), $challenge), 403);
$seo = app(SeoFactory::class)->collectionPage(
'Submit to ' . $challenge->title . ' — Skinbase Academy',
'Attach one of your artworks to this Academy challenge submission.',
route('academy.challenges.submit', ['slug' => $challenge->slug]),
)->toArray();
return Inertia::render('Academy/ChallengeSubmit', [
'seo' => $seo,
'challenge' => $this->access->challengePayload($challenge, request()->user(), true),
'artworks' => $this->challenges->eligibleArtworkOptions(request()->user())->map(fn (Artwork $artwork): array => [
'id' => (int) $artwork->id,
'title' => (string) ($artwork->title ?? 'Untitled artwork'),
'thumb_url' => $artwork->thumbUrl('md'),
'published_at' => $artwork->published_at?->toISOString(),
])->values()->all(),
'submitUrl' => route('academy.challenges.submit.store', ['slug' => $challenge->slug]),
])->rootView('collections');
}
public function store(StoreAcademyChallengeSubmissionRequest $request, string $slug): RedirectResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
abort_unless((bool) config('academy.challenges_enabled', true), 404);
$challenge = AcademyChallenge::query()->where('slug', $slug)->firstOrFail();
$artwork = Artwork::query()->findOrFail((int) $request->validated('artwork_id'));
$this->challenges->submit($request->user(), $challenge, $artwork, $request->validated());
return redirect()->route('academy.challenges.show', ['slug' => $challenge->slug])
->with('success', 'Challenge submission received and queued for review.');
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
final class AcademyCheckoutController extends Controller
{
public function store(Request $request, string $plan): JsonResponse|RedirectResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
// TODO: Replace this placeholder with Laravel Cashier + Stripe Checkout when academy payments are enabled.
if (! (bool) config('academy.payments_enabled', false)) {
$payload = [
'ok' => false,
'code' => 'academy_payments_disabled',
'message' => 'Academy payments are disabled for this launch phase.',
'plan' => $plan,
];
if ($request->expectsJson()) {
return response()->json($payload, 423);
}
return redirect()->route('academy.pricing')->with('error', $payload['message']);
}
return response()->json([
'ok' => false,
'code' => 'academy_checkout_not_implemented',
'message' => 'Checkout is not implemented yet.',
'plan' => $plan,
], 501);
}
}

View File

@@ -0,0 +1,184 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Models\AcademyCourse;
use App\Models\AcademyCourseLesson;
use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyCacheService;
use App\Services\Academy\AcademyCourseNavigationService;
use App\Services\Academy\AcademyCourseProgressService;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class AcademyCourseController extends Controller
{
public function __construct(
private readonly AcademyAccessService $access,
private readonly AcademyCacheService $cache,
private readonly AcademyCourseNavigationService $navigation,
private readonly AcademyCourseProgressService $progress,
) {
}
public function index(Request $request): Response
{
abort_unless((bool) config('academy.enabled', true), 404);
$filters = $request->validate([
'difficulty' => ['nullable', 'string', 'max:40'],
'access' => ['nullable', 'string', 'max:40'],
]);
$query = AcademyCourse::query()->published()->ordered();
if (filled($filters['difficulty'] ?? null)) {
$query->where('difficulty', $filters['difficulty']);
}
if (filled($filters['access'] ?? null)) {
$query->where('access_level', $filters['access']);
}
$courses = $query->paginate(12)->withQueryString();
$courses->getCollection()->transform(function (AcademyCourse $course) use ($request): array {
return $this->access->coursePayload($course, $request->user(), [
'progress' => $this->progress->getProgress($request->user(), $course),
]);
});
$featuredCourses = collect($this->cache->featuredCourses())->map(fn (AcademyCourse $course): array => $this->access->coursePayload($course, $request->user(), [
'progress' => $this->progress->getProgress($request->user(), $course),
]))->values();
$seoCourses = $featuredCourses
->concat(collect($courses->items()))
->unique(fn (array $course): string => (string) ($course['slug'] ?? ''))
->values();
$seo = app(SeoFactory::class)
->academyCourseListingPage(
'Academy Courses — Skinbase',
'Follow guided Skinbase AI Academy courses built from reusable lessons, chapters, and creator workflows.',
route('academy.courses.index', $request->query()),
$seoCourses,
[
['name' => 'Academy', 'url' => route('academy.index')],
['name' => 'Courses', 'url' => route('academy.courses.index')],
],
)
->toArray();
return Inertia::render('Academy/CoursesIndex', [
'seo' => $seo,
'title' => 'Academy courses',
'description' => 'Guided learning paths built from reusable Academy lessons and creator workflows.',
'items' => $courses,
'featuredCourses' => $featuredCourses->all(),
'filters' => $filters,
'pricingUrl' => route('academy.pricing'),
])->rootView('collections');
}
public function show(Request $request, AcademyCourse $course): Response
{
abort_unless((bool) config('academy.enabled', true), 404);
abort_unless($course->isPublished(), 404);
$course->load(['sections', 'courseLessons.section', 'courseLessons.lesson.category']);
$progress = $this->progress->getProgress($request->user(), $course);
$completedLessonIds = $request->user() ? $this->progress->getCompletedLessonIds($request->user(), $course) : [];
$orderedLessons = $this->navigation->orderedCourseLessons($course);
$stepMeta = $orderedLessons
->values()
->mapWithKeys(fn (AcademyCourseLesson $courseLesson, int $index): array => [
$courseLesson->id => [
'course_step_number' => $index + 1,
'course_step_label' => sprintf('Step %02d', $index + 1),
],
]);
$sections = $course->sections
->sortBy([['order_num', 'asc'], ['id', 'asc']])
->values()
->map(function ($section) use ($completedLessonIds, $orderedLessons, $request, $stepMeta): array {
$sectionLessons = $orderedLessons
->where('section_id', $section->id)
->values()
->map(fn (AcademyCourseLesson $courseLesson): array => $this->access->courseLessonPayload($courseLesson, $request->user(), false, [
'completed_lesson_ids' => $completedLessonIds,
...((array) $stepMeta->get($courseLesson->id, [])),
]))
->all();
return [
'id' => (int) $section->id,
'title' => (string) $section->title,
'slug' => (string) ($section->slug ?? ''),
'description' => (string) ($section->description ?? ''),
'order_num' => (int) ($section->order_num ?? 0),
'is_visible' => (bool) ($section->is_visible ?? true),
'lessons' => $sectionLessons,
];
})
->all();
$unsectionedLessons = $orderedLessons
->whereNull('section_id')
->values()
->map(fn (AcademyCourseLesson $courseLesson): array => $this->access->courseLessonPayload($courseLesson, $request->user(), false, [
'completed_lesson_ids' => $completedLessonIds,
...((array) $stepMeta->get($courseLesson->id, [])),
]))
->all();
$coursePayload = $this->access->coursePayload($course, $request->user(), ['progress' => $progress]);
$courseKeywords = collect(explode(',', (string) ($course->meta_keywords ?? '')))
->map(fn (string $keyword): string => trim($keyword))
->filter()
->values()
->all();
$courseImage = (string) ($coursePayload['cover_image_url'] ?? $coursePayload['teaser_image_url'] ?? $course->og_image ?? $course->cover_image ?? $course->teaser_image ?? '');
$seo = app(SeoFactory::class)
->academyCoursePage(
(string) ($course->seo_title ?: ($course->title . ' — Skinbase Academy')),
(string) ($course->seo_description ?: $course->excerpt ?: 'Skinbase Academy course'),
route('academy.courses.show', ['course' => $course->slug]),
$courseImage,
[
['name' => 'Academy', 'url' => route('academy.index')],
['name' => 'Courses', 'url' => route('academy.courses.index')],
['name' => (string) $course->title, 'url' => route('academy.courses.show', ['course' => $course->slug])],
],
$courseKeywords,
$course->published_at?->toAtomString(),
$course->updated_at?->toAtomString(),
(string) ($course->access_level ?? ''),
(string) ($course->difficulty ?? ''),
(int) ($course->estimated_minutes ?? 0),
$orderedLessons
->values()
->map(fn (AcademyCourseLesson $courseLesson): array => $this->access->courseLessonPayload($courseLesson, $request->user(), false, [
'completed_lesson_ids' => $completedLessonIds,
...((array) $stepMeta->get($courseLesson->id, [])),
]))
->all(),
)
->toArray();
return Inertia::render('Academy/CoursesShow', [
'seo' => $seo,
'course' => $coursePayload,
'sections' => $sections,
'unsectionedLessons' => $unsectionedLessons,
'pricingUrl' => route('academy.pricing'),
'startUrl' => $request->user() ? route('academy.courses.start', ['course' => $course->slug]) : null,
])->rootView('collections');
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Models\AcademyCourse;
use App\Services\Academy\AcademyCourseProgressService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
final class AcademyCourseEnrollmentController extends Controller
{
public function __construct(private readonly AcademyCourseProgressService $progress)
{
}
public function start(Request $request, AcademyCourse $course): RedirectResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
abort_unless($course->isPublished(), 404);
$this->progress->markEnrollmentStarted($request->user(), $course);
$continueLesson = $this->progress->getContinueLesson($request->user(), $course);
if ($continueLesson?->lesson) {
return redirect()->route('academy.courses.lessons.show', ['course' => $course->slug, 'lesson' => $continueLesson->lesson->slug]);
}
return redirect()->route('academy.courses.show', ['course' => $course->slug]);
}
}

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Models\AcademyCourse;
use App\Models\AcademyLesson;
use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyCourseNavigationService;
use App\Services\Academy\AcademyCourseProgressService;
use App\Support\Seo\SeoFactory;
use Illuminate\Support\Str;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class AcademyCourseLessonController extends Controller
{
public function __construct(
private readonly AcademyAccessService $access,
private readonly AcademyCourseNavigationService $navigation,
private readonly AcademyCourseProgressService $progress,
) {
}
public function show(Request $request, AcademyCourse $course, AcademyLesson $lesson): Response
{
abort_unless((bool) config('academy.enabled', true), 404);
abort_unless($course->isPublished(), 404);
$course->load(['sections', 'courseLessons.section', 'courseLessons.lesson.category']);
$courseLesson = $this->navigation->findCourseLesson($course, $lesson);
abort_unless($courseLesson instanceof \App\Models\AcademyCourseLesson, 404);
if ($request->user()) {
$this->progress->updateLastLesson($request->user(), $course, $lesson);
$this->progress->markCourseCompletedIfFinished($request->user(), $course);
}
$progress = $this->progress->getProgress($request->user(), $course);
$previousLesson = $this->navigation->previousLesson($course, $lesson);
$nextLesson = $this->navigation->nextLesson($course, $lesson);
$courseOutline = $this->navigation->orderedCourseLessons($course)
->map(fn (\App\Models\AcademyCourseLesson $entry): array => $this->access->courseLessonPayload($entry, $request->user()))
->values()
->all();
$payload = $this->access->courseLessonPayload($courseLesson, $request->user(), true);
$canonical = route('academy.courses.lessons.show', ['course' => $course->slug, 'lesson' => $lesson->slug]);
$description = Str::limit(trim((string) ($lesson->seo_description ?? $lesson->excerpt ?? 'Skinbase Academy course lesson.')), 160, '...');
$seo = app(SeoFactory::class)->academyLessonPage(
(string) ($lesson->seo_title ?? ($lesson->title . ' — ' . $course->title)),
$description,
$canonical,
(string) ($payload['article_cover_image_url'] ?? $payload['cover_image_url'] ?? $lesson->cover_image ?? ''),
[
['name' => 'Academy', 'url' => route('academy.index')],
['name' => 'Courses', 'url' => route('academy.courses.index')],
['name' => (string) $course->title, 'url' => route('academy.courses.show', ['course' => $course->slug])],
['name' => (string) $lesson->title, 'url' => $canonical],
],
array_values((array) ($payload['tags'] ?? [])),
$lesson->published_at?->toAtomString(),
$lesson->updated_at?->toAtomString(),
(string) $course->title,
)->toArray();
return Inertia::render('Academy/Show', [
'pageType' => 'lesson',
'item' => $payload,
'relatedLessons' => [],
'relatedCourses' => [],
'previousLesson' => $previousLesson ? $this->access->courseLessonPayload($previousLesson, $request->user()) : null,
'nextLesson' => $nextLesson ? $this->access->courseLessonPayload($nextLesson, $request->user()) : null,
'seo' => $seo,
'pricingUrl' => route('academy.pricing'),
'completeUrl' => $request->user() ? route('academy.lessons.complete', ['lesson' => $lesson->id]) : null,
'completed' => $request->user()?->academyLessonProgress()->where('lesson_id', $lesson->id)->whereNotNull('completed_at')->exists() ?? false,
'courseContext' => [
'id' => (int) $course->id,
'title' => (string) $course->title,
'slug' => (string) $course->slug,
'subtitle' => (string) ($course->subtitle ?? ''),
'showUrl' => route('academy.courses.show', ['course' => $course->slug]),
'completePayload' => ['course_id' => $course->id],
'progress' => [
'percent' => (int) ($progress['progress_percent'] ?? 0),
'completedRequired' => (int) ($progress['completed_required'] ?? 0),
'totalRequired' => (int) ($progress['total_required'] ?? 0),
'completed' => (bool) ($progress['completed'] ?? false),
],
'outline' => $courseOutline,
],
])->rootView('collections');
}
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Models\AcademyChallenge;
use App\Models\AcademyCourse;
use App\Models\AcademyLesson;
use App\Models\AcademyPromptTemplate;
use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyCacheService;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class AcademyHomeController extends Controller
{
public function __construct(
private readonly AcademyAccessService $access,
private readonly AcademyCacheService $cache,
) {
}
public function index(Request $request): Response
{
abort_unless((bool) config('academy.enabled', true), 404);
$canonical = route('academy.index');
$seo = app(SeoFactory::class)
->collectionPage(
'Skinbase AI Academy — Skinbase',
'Learn AI-powered creativity for wallpapers, digital art, skins, news covers, and visual worlds inside Skinbase.',
$canonical,
)
->toArray();
$seo['og_type'] = 'website';
$home = $this->cache->homePayload(function (): array {
return [
'featuredLessons' => $this->cache->featuredLessons(),
'featuredCourses' => $this->cache->featuredCourses(),
'featuredPrompts' => $this->cache->featuredPrompts(),
'featuredChallenges' => (bool) config('academy.challenges_enabled', true)
? $this->cache->featuredChallenges()
: [],
'lessonCount' => AcademyLesson::query()->active()->published()->count(),
'courseCount' => AcademyCourse::query()->published()->count(),
'promptCount' => AcademyPromptTemplate::query()->active()->published()->count(),
'challengeCount' => (bool) config('academy.challenges_enabled', true)
? AcademyChallenge::query()->publiclyVisible()->count()
: 0,
];
});
return Inertia::render('Academy/Index', [
'seo' => $seo,
'pricingUrl' => route('academy.pricing'),
'links' => [
'lessons' => route('academy.lessons.index'),
'courses' => route('academy.courses.index'),
'prompts' => route('academy.prompts.index'),
'packs' => route('academy.packs.index'),
'challenges' => route('academy.challenges.index'),
],
'featureFlags' => [
'paymentsEnabled' => (bool) config('academy.payments_enabled', false),
'challengesEnabled' => (bool) config('academy.challenges_enabled', true),
'badgesEnabled' => (bool) config('academy.badges_enabled', true),
],
'stats' => [
'lessonCount' => (int) $home['lessonCount'],
'courseCount' => (int) $home['courseCount'],
'promptCount' => (int) $home['promptCount'],
'challengeCount' => (int) $home['challengeCount'],
],
'featuredCourses' => collect($home['featuredCourses'])->map(fn (AcademyCourse $course): array => $this->access->coursePayload($course, $request->user()))->values()->all(),
'featuredLessons' => collect($home['featuredLessons'])->map(fn (AcademyLesson $lesson): array => $this->access->lessonPayload($lesson, $request->user()))->values()->all(),
'featuredPrompts' => collect($home['featuredPrompts'])->map(fn (AcademyPromptTemplate $prompt): array => $this->access->promptPayload($prompt, $request->user()))->values()->all(),
'featuredChallenges' => collect($home['featuredChallenges'])->map(fn (AcademyChallenge $challenge): array => $this->access->challengePayload($challenge, $request->user(), true))->values()->all(),
])->rootView('collections');
}
}

View File

@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Models\AcademyCourse;
use App\Models\AcademyLesson;
use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyCacheService;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
final class AcademyLessonController extends Controller
{
public function __construct(
private readonly AcademyAccessService $access,
private readonly AcademyCacheService $cache,
) {}
public function index(Request $request): Response
{
abort_unless((bool) config('academy.enabled', true), 404);
$filters = $request->validate([
'q' => ['nullable', 'string', 'max:120'],
'category' => ['nullable', 'string', 'max:140'],
'difficulty' => ['nullable', 'string', 'max:40'],
]);
$query = AcademyLesson::query()
->with('category')
->active()
->published()
->orderedForCourse();
if (filled($filters['q'] ?? null)) {
$query->where(function ($builder) use ($filters): void {
$builder->where('title', 'like', '%'.$filters['q'].'%')
->orWhere('excerpt', 'like', '%'.$filters['q'].'%');
});
}
if (filled($filters['category'] ?? null)) {
$query->whereHas('category', fn ($builder) => $builder->where('slug', $filters['category']));
}
if (filled($filters['difficulty'] ?? null)) {
$query->where('difficulty', $filters['difficulty']);
}
$lessons = $query->paginate(12)->withQueryString();
$lessons->getCollection()->transform(fn (AcademyLesson $lesson): array => $this->access->lessonPayload($lesson, $request->user()));
$seo = app(SeoFactory::class)
->collectionListing(
'Academy Lessons — Skinbase',
'Browse Skinbase AI Academy lessons covering prompting, workflow cleanup, ethics, and upload-ready creative workflows.',
route('academy.lessons.index', $request->query()),
)
->toArray();
return Inertia::render('Academy/List', [
'pageType' => 'lessons',
'title' => 'Academy lessons',
'description' => 'Step-by-step tutorials and workflow guides for AI-assisted creative work on Skinbase.',
'seo' => $seo,
'items' => $lessons,
'filters' => $filters,
'categories' => $this->cache->categoriesByType('lesson'),
'pricingUrl' => route('academy.pricing'),
])->rootView('collections');
}
public function show(Request $request, string $slug): Response
{
abort_unless((bool) config('academy.enabled', true), 404);
$lesson = AcademyLesson::query()
->with(['category', 'activeBlocks.activeComparisonResults'])
->active()
->published()
->where('slug', $slug)
->firstOrFail();
$payload = $this->access->lessonPayload($lesson, $request->user(), true);
$courseQuery = AcademyLesson::query()
->with('category')
->active()
->published();
if (filled($lesson->series_name)) {
$courseQuery->where('series_name', $lesson->series_name);
} elseif ($lesson->category_id !== null) {
$courseQuery->where('category_id', $lesson->category_id);
} else {
$courseQuery->whereKey($lesson->id);
}
$courseLessons = $courseQuery
->orderedForCourse()
->get()
->filter(fn (AcademyLesson $courseLesson): bool => $this->access->canAccessLesson($request->user(), $courseLesson))
->values();
$currentIndex = $courseLessons->search(fn (AcademyLesson $courseLesson): bool => $courseLesson->is($lesson));
$previousLesson = is_int($currentIndex) && $currentIndex > 0
? $courseLessons->get($currentIndex - 1)
: null;
$nextLesson = is_int($currentIndex) && $currentIndex < ($courseLessons->count() - 1)
? $courseLessons->get($currentIndex + 1)
: null;
$relatedLessons = $courseLessons
->reject(fn (AcademyLesson $courseLesson): bool => $courseLesson->is($lesson))
->take(6)
->map(fn (AcademyLesson $relatedLesson): array => $this->access->lessonPayload($relatedLesson, $request->user()))
->values()
->all();
$relatedCourses = AcademyCourse::query()
->published()
->ordered()
->whereHas('courseLessons', fn ($builder) => $builder->where('lesson_id', $lesson->id))
->limit(3)
->get()
->map(fn (AcademyCourse $course): array => $this->access->coursePayload($course, $request->user()))
->values()
->all();
$canonical = route('academy.lessons.show', ['slug' => $lesson->slug]);
$description = Str::limit(trim((string) ($lesson->seo_description ?? $lesson->excerpt ?? 'Skinbase Academy lesson.')), 160, '...');
$seo = app(SeoFactory::class)->academyLessonPage(
(string) ($lesson->seo_title ?? ($lesson->title.' — Skinbase Academy')),
$description,
$canonical,
(string) ($payload['article_cover_image_url'] ?? $payload['cover_image_url'] ?? $lesson->cover_image ?? ''),
[
['name' => 'Academy', 'url' => route('academy.index')],
['name' => 'Lessons', 'url' => route('academy.lessons.index')],
['name' => (string) $lesson->title, 'url' => $canonical],
],
array_values((array) ($payload['tags'] ?? [])),
$lesson->published_at?->toAtomString(),
$lesson->updated_at?->toAtomString(),
(string) ($lesson->series_name ?: $lesson->category?->name ?: 'Academy'),
)->toArray();
return Inertia::render('Academy/Show', [
'pageType' => 'lesson',
'item' => $payload,
'relatedLessons' => $relatedLessons,
'relatedCourses' => $relatedCourses,
'previousLesson' => $previousLesson ? $this->access->lessonPayload($previousLesson, $request->user()) : null,
'nextLesson' => $nextLesson ? $this->access->lessonPayload($nextLesson, $request->user()) : null,
'seo' => $seo,
'pricingUrl' => route('academy.pricing'),
'completeUrl' => $request->user() ? route('academy.lessons.complete', ['lesson' => $lesson->id]) : null,
'completed' => $request->user()?->academyLessonProgress()->where('lesson_id', $lesson->id)->whereNotNull('completed_at')->exists() ?? false,
])->rootView('collections');
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Support\Seo\SeoFactory;
use Inertia\Inertia;
use Inertia\Response;
final class AcademyPricingController extends Controller
{
public function index(): Response
{
abort_unless((bool) config('academy.enabled', true), 404);
$canonical = route('academy.pricing');
$seo = app(SeoFactory::class)
->collectionPage(
'Skinbase AI Academy Pricing — Skinbase',
'Compare Skinbase AI Academy Explorer, Creator, and Pro plans and preview premium access before payments go live.',
$canonical,
)
->toArray();
$seo['og_type'] = 'website';
return Inertia::render('Academy/Pricing', [
'seo' => $seo,
'paymentsEnabled' => (bool) config('academy.payments_enabled', false),
'plans' => [
[
'name' => 'Explorer',
'price' => '€0',
'interval' => '/ month',
'badge' => 'Free',
'features' => [
'Beginner AI lessons',
'Basic prompting tutorials',
'Public prompt previews',
'Public Academy challenges',
],
],
[
'name' => 'Creator',
'price' => '€4.99',
'interval' => '/ month',
'badge' => 'Premium',
'features' => [
'Full prompt library',
'Premium lessons',
'Prompt packs',
'Saved prompt collections',
],
],
[
'name' => 'Pro',
'price' => '€9.99',
'interval' => '/ month',
'badge' => 'Advanced',
'features' => [
'Advanced AI workflows',
'World Builder kits',
'Pro challenges',
'Profile highlight badge',
],
],
],
])->rootView('collections');
}
}

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