Compare commits

...
53 Commits
Author SHA1 Message Date
klevze 69edffdfee Rebuild Inertia SSR assets after the upload and artwork UI changes.
Replace hashed SSR bundles so server-rendered pages match the current frontend.
2026-09-20 14:51:12 +02:00
klevze fe1054aa32 Update the optimization roadmap through the latest milestones.
Keep the production optimization plan aligned with the work already landed on develop.
2026-09-20 14:51:12 +02:00
klevze a206981914 Wire new routes and environment keys for the recent modules.
Register AdSense, artwork review, comment captcha, lazy collections, and upload review props, and document the matching env vars and CLI commands.
2026-09-20 14:51:04 +02:00
klevze 02861b5a2f Expand profile gender options and cover country plus activity persistence.
Allow the broader gender enum and add tests for country saves and discovery event recording.
2026-09-20 14:50:59 +02:00
klevze 1b8853b0c9 Harden legacy user profile lookups.
Keep the old user controller aligned with current profile fields and safer query handling.
2026-09-20 14:50:59 +02:00
klevze 12da3c5081 Tighten artwork hourly snapshot window queries.
Reduce snapshot work to the active hour window so metrics aggregation stays cheaper on the hot path.
2026-09-20 14:50:59 +02:00
klevze 0b6f3a95e3 Limit anonymous community activity queries to the latest page.
Skip unused activity sources and historical over-fetching for guests while keeping authenticated feeds filterable.
2026-09-20 14:50:58 +02:00
klevze 5d703b8da4 Warn newer creators that uploads go through review first.
Show a level-based notice on /upload without revealing the internal approved-artwork threshold, and mention the usual 48-hour review window.
2026-09-20 14:50:39 +02:00
klevze 6b00652a20 Allow artworks to have one primary category and extra secondaries.
Keep artwork_category as membership, store a deterministic primary_category_id, and let upload plus studio pick up to five additional same-type categories without changing canonical URLs.
2026-09-20 14:50:12 +02:00
klevze ce0f278bac Add a read-only Google AdSense analytics module for admins.
Connect AdSense over OAuth, sync entities and daily reports locally, and show placement performance in the admin panel without calling the Management API from public pages.
2026-09-20 14:49:48 +02:00
klevze 04a60a981e Keep AdSense rendering limited to anonymous visitors.
Show guest-only ad units on public pages and skip the AdSense script entirely for signed-in users.
2026-09-20 14:49:34 +02:00
klevze 822b96e92a Lazy-load profile collections until the tab is opened.
Keep the profile payload smaller on first paint and fetch collections through a dedicated API when the collections tab is needed.
2026-09-20 14:49:33 +02:00
klevze a5e51617a6 Extract related artwork lookup into a dedicated service.
Reuse the same related-artwork ranking on similar-art pages and keep category and tag matches from duplicating results.
2026-09-20 14:49:32 +02:00
klevze 1053a38eee Harden vision vector search and embedding jobs.
Add point search, circuit-breaking, and safer gateway limits so artwork similarity lookups fail closed instead of hanging the request path.
2026-09-20 14:49:19 +02:00
klevze 04b8160d9e Track original artwork file availability more accurately.
Resolve local and object-storage originals, persist download status, and audit missing files without guessing from a single path.
2026-09-20 14:49:19 +02:00
klevze 0f52803b05 Send new artwork uploads through a trust-based review policy.
Require review for untrusted accounts, add admin artwork review APIs, and keep queued or auto-trusted publishes from counting as established history.
2026-09-20 14:49:06 +02:00
klevze 31c87e4977 Move model observers onto attributes and tidy provider bindings.
Register observers with ObservedBy, bind the comment spam classifier, and keep AppServiceProvider focused on application wiring.
2026-09-20 14:49:00 +02:00
klevze 586c44ba74 Add comment spam classification and captcha checks.
Score artwork comments with local signatures plus Together AI, and optionally require Turnstile before posting.
2026-09-20 14:48:50 +02:00
klevze 37bacc1334 Defer Carbon package discovery until it is needed.
Keep Laravel from auto-discovering nesbot/carbon so the app can boot the lazy Carbon provider instead.
2026-09-20 14:48:37 +02:00
test 58e5b3f648 Expose safe production build metadata 2026-08-30 12:16:22 +02:00
test aba2017273 Update production deploy safeguards 2026-08-30 12:02:25 +02:00
test 937f484cc9 Defer profile world history until needed 2026-08-30 12:02:07 +02:00
test 5c80402aed fix 2026-08-30 11:06:07 +02:00
test e7c878525d Update optimization roadmap through M19 2026-08-30 09:33:36 +02:00
test 6a6900435c Require reproducible production deploy sources 2026-08-30 09:33:36 +02:00
test 7c38ffff57 Finalize Supervisor-owned SSR deployment 2026-08-30 09:28:44 +02:00
test 71972afb87 Make profile formatting hydration-safe 2026-08-30 09:28:07 +02:00
klevze 5db36cb29f Align group profile summary contract 2026-08-30 08:48:03 +02:00
klevze 7805baa17d Collapse profile group contribution queries 2026-08-30 07:58:38 +02:00
klevze dba1f73e01 Defer profile favourites until needed 2026-08-30 07:30:15 +02:00
klevze 187292a374 Make XP progress formatting hydration-safe 2026-08-29 21:28:26 +02:00
klevze 99d94c9333 Make profile SSR formatting deterministic 2026-08-29 20:24:38 +02:00
klevze 872f420190 Defer profile featured artworks 2026-08-29 16:33:36 +02:00
klevze ff80f5bf97 Rollback comments observer margin 2026-08-29 14:12:51 +02:00
klevze 29e6dee609 Reduce deferred comments observer margin 2026-08-29 13:53:00 +02:00
klevze 2f8f7472ca tests cleanup 2026-08-29 13:49:13 +02:00
klevze 771f9f4350 Document M1-M18 production optimization status.
Record what landed, what is in flight, and what remains so later deploys can pick up the plan without archaeology.
2026-08-29 12:25:50 +02:00
klevze 4c409ceb81 Rebuild Inertia SSR artifacts after deferred artwork UI.
Keep bootstrap/ssr in sync with the client Vite build shipped in this stack.
2026-08-29 12:25:50 +02:00
klevze ab8fa6d845 Fix Windows/WSL production deploy hang and permissions.
Skip slow Git worktree walks on /mnt, run Vite on Windows npm, reuse Windows SSH keys, and stop rewriting the klevze-owned public app symlink that skinbase cannot replace.
2026-08-29 12:25:50 +02:00
klevze bf9ca12469 Add a public profile followers tab.
Expose follower lists on public profiles with a dedicated tab and API path instead of only showing counts.
2026-08-29 12:25:49 +02:00
klevze 1d350c9bca Defer similar-AI recommendations until the rail is near view.
ArtworkPage already imports this hook; ship the implementation so similar-AI work stays off the initial render.
2026-08-29 12:25:49 +02:00
klevze 437d943827 Prefetch artwork prev/next navigation off the critical path.
Move neighbor lookup into a dedicated service and let the viewer load adjacent artworks after first paint instead of blocking the detail request.
2026-08-29 12:25:49 +02:00
klevze bf902f509a Batch-load comment reaction counts on artwork comments.
Avoid per-comment reaction queries on the deferred comments API by aggregating counts in one service call.
2026-08-29 12:25:37 +02:00
klevze bfcbfec357 Cache Schema::hasTable lookups on artwork JSON.
Artwork detail was paying repeated information_schema hits per request. Keep public detail edge-case behavior covered by regression tests.
2026-08-29 12:25:37 +02:00
klevze b9beb8f5c5 Speed legacy photo serving and skip tracking on download routes.
Tighten thumbnail/legacy photo handling and exclude download/photo traffic from session visitor tracking so those hot paths stay cheap.
2026-08-29 12:25:37 +02:00
klevze e9cf754b37 Enable nginx X-Accel for original artwork downloads.
Hand originals to nginx after auth so PHP is not in the byte path. Keep DOWNLOAD_ACCEL_ENABLED off until the internal location is verified.
2026-08-29 12:25:37 +02:00
klevze fb560fb7dd Add HTTP performance log analyzer (M12.6).
Parse rotated nginx JSON access logs into host/route timings so production hotspots can be measured without ad-hoc one-off scripts.
2026-08-29 12:25:27 +02:00
klevze 4ab315e9d7 Defer artwork comments from initial render 2026-08-29 12:07:08 +02:00
klevze 8a80aae21e Ship production optimization M1-M12.5A: queues, metrics, HTTP observability, and vector search reliability.
Keep similar-ai from tripping the global circuit on a lone URL 502, clamp Qdrant search to 100, and add Server-Timing plus slow-request logging. Studio shared props, Academy S3 exists caching, heat chunking, and Redis/scheduler hygiene stay in this rollout.
2026-08-25 07:58:47 +02:00
klevze f52879edbb Current state with latest updates 2026-08-23 13:28:34 +02:00
klevze 5af95f6533 Optimize academy 2026-06-09 13:16:01 +02:00
klevze f89ee937c0 fixed sanitazer and academy 2026-06-05 16:53:20 +02:00
klevze 15870ddb1f Allow heading tags (h1-h6) in ContentSanitizer so news editor headings render 2026-06-04 07:52:57 +02:00
735 changed files with 61659 additions and 8291 deletions
+113
View File
@@ -4,6 +4,23 @@ APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
# Maximum secondary artwork categories in addition to the primary category.
CATEGORIES_MAX_SECONDARY=5
# Artwork original downloads: PHP fallback unless nginx X-Accel is configured.
# Leave false until the internal location exists and has been verified.
DOWNLOAD_ACCEL_ENABLED=false
DOWNLOAD_ACCEL_PATH=/internal/originals
SECURITY_REPORT_ENABLED=true
SECURITY_REPORT_NOTIFY_EMAIL=
SECURITY_REPORT_SCAN_NPM=true
SECURITY_REPORT_SCAN_COMPOSER=true
SECURITY_REPORT_STORE_RAW=true
SECURITY_REPORT_MAX_RAW_KB=512
SECURITY_REPORT_FAIL_ON_HIGH=false
SECURITY_REPORT_FAIL_ON_CRITICAL=false
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
@@ -20,6 +37,11 @@ LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
# M12 — slow Laravel HTTP log (threshold only; disable for zero overhead)
HTTP_SLOW_REQUEST_LOG_ENABLED=false
HTTP_SLOW_REQUEST_MS=750
HTTP_SLOW_REQUEST_LOG_DAYS=14
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
@@ -53,6 +75,48 @@ BROADCAST_CONNECTION=reverb
FILESYSTEM_DISK=local
QUEUE_CONNECTION=redis
# Comment spam protection: observe records scores without changing visibility.
COMMENT_SPAM_ENABLED=true
COMMENT_SPAM_MODE=enforce
COMMENT_SPAM_AI_ENABLED=true
COMMENT_SPAM_AI_PROVIDER=together
# Supported: Prism-ML/Ternary-Bonsai-27B or meta-llama/Llama-3.2-3B-Instruct-Turbo
COMMENT_SPAM_AI_MODEL=meta-llama/Llama-3.2-3B-Instruct-Turbo
COMMENT_SPAM_AI_TIMEOUT=5
TOGETHER_API_KEY=
TOGETHER_API_BASE_URL=https://api.together.xyz/v1
COMMENT_SPAM_LOCAL_SAFE_MAX=29
COMMENT_SPAM_LOCAL_SPAM_MIN=70
COMMENT_SPAM_AI_SPAM_MIN=70
COMMENT_SPAM_SIGNATURE_CACHE_MINUTES=1440
COMMENT_TURNSTILE_ENABLED=false
COMMENT_TURNSTILE_SITE_KEY=
COMMENT_TURNSTILE_SECRET_KEY=
COMMENT_TURNSTILE_RISK_THRESHOLD=40
COMMENT_TURNSTILE_FAIL_OPEN=false
# Must exceed Horizon supervisor-default timeout (960s). Production used 90s
# and nightly RecComputeSimilar* jobs failed with MaxAttemptsExceeded.
REDIS_QUEUE_RETRY_AFTER=1080
# Collection maintenance / forum AI jobs have no Horizon consumer.
# Leave false until a dedicated supervisor exists (M7).
COLLECTIONS_V5_DISPATCH_ENABLED=false
FORUM_QUEUE_DISPATCH_ENABLED=false
ONLINE_VISITOR_INDEX_READ_LIMIT=2000
ONLINE_VISITOR_INDEX_PRUNE_ENABLED=false
ONLINE_VISITOR_INDEX_PRUNE_SCAN_COUNT=500
ONLINE_VISITOR_INDEX_PRUNE_MAX_BATCHES=200
ONLINE_VISITOR_INDEX_PRUNE_SLEEP_MS=25
# RankBuildScopeListsJob unique lock (seconds). Must exceed queue wait + 300s timeout.
RANK_SCOPE_JOB_UNIQUE_FOR=21600
# Hourly artwork metric snapshots (heat / rising / monthly leaderboards).
# Production currently holds ~7 days because the scheduler passed --keep-days=7.
# Monthly leaderboards and Studio 30d views need 30 days of hourly history.
ARTWORK_METRIC_HOURLY_RETENTION_DAYS=30
ARTWORK_METRIC_HOURLY_PRUNE_CHUNK=5000
ARTWORK_METRIC_HOURLY_PRUNE_SLEEP_MS=50
MESSAGING_REALTIME=true
MESSAGING_BROADCAST_QUEUE=broadcasts
MESSAGING_TYPING_TTL=8
@@ -92,6 +156,13 @@ UPLOAD_CHUNK_REQUEST_TIMEOUT_MS=45000
UPLOAD_RATE_CHUNK_USER=180
UPLOAD_RATE_CHUNK_IP=360
# New accounts without this many moderator/legacy published artworks go to review.
# Queued uploads and trusted_auto publishes do not count, so a new user's 6th upload stays hidden.
UPLOAD_MIN_APPROVED_UPLOADS=5
UPLOAD_MIN_ACCOUNT_AGE_DAYS=7
UPLOAD_MAX_UNTRUSTED_LEVEL=1
UPLOAD_BOT_RISK_REVIEW_THRESHOLD=40
# Draft abuse prevention controls
SKINBASE_MAX_DRAFTS=10
SKINBASE_MAX_DRAFT_STORAGE_MB=1024
@@ -114,6 +185,11 @@ VISION_VECTOR_GATEWAY_RETRIES=1
VISION_VECTOR_GATEWAY_RETRY_DELAY_MS=250
VISION_VECTOR_GATEWAY_UPSERT_ENDPOINT=/vectors/upsert
VISION_VECTOR_GATEWAY_SEARCH_ENDPOINT=/vectors/search
VISION_VECTOR_GATEWAY_SEARCH_FILE_ENDPOINT=/vectors/search/file
VISION_VECTOR_GATEWAY_SEARCH_TIMEOUT=6
VISION_VECTOR_GATEWAY_SEARCH_CONNECT_TIMEOUT=2
VISION_VECTOR_GATEWAY_SEARCH_RETRIES=0
VISION_VECTOR_GATEWAY_CIRCUIT_SECONDS=30
VISION_VECTOR_GATEWAY_DELETE_ENDPOINT=/vectors/delete
VISION_VECTOR_GATEWAY_COLLECTIONS_ENDPOINT=/vectors/collections
@@ -329,6 +405,30 @@ TURNSTILE_FAIL_OPEN=false
TURNSTILE_VERIFY_URL=https://challenges.cloudflare.com/turnstile/v0/siteverify
TURNSTILE_TIMEOUT=5
ENHANCE_DISK=public
ENHANCE_SOURCE_PREFIX=enhance/sources
ENHANCE_OUTPUT_PREFIX=enhance/outputs
ENHANCE_PREVIEW_PREFIX=enhance/previews
ENHANCE_ENGINE=stub
ENHANCE_MAX_UPLOAD_MB=20
ENHANCE_MAX_INPUT_WIDTH=4096
ENHANCE_MAX_INPUT_HEIGHT=4096
ENHANCE_MAX_OUTPUT_WIDTH=8192
ENHANCE_MAX_OUTPUT_HEIGHT=8192
ENHANCE_DAILY_LIMIT=10
ENHANCE_QUEUE=default
ENHANCE_COMPLETED_EXPIRES_AFTER_DAYS=30
ENHANCE_FAILED_EXPIRES_AFTER_DAYS=7
ENHANCE_DELETED_FILE_GRACE_DAYS=1
ENHANCE_CLEANUP_CHUNK_SIZE=100
ENHANCE_STUCK_PROCESSING_AFTER_MINUTES=30
ENHANCE_STUCK_QUEUED_AFTER_MINUTES=60
ENHANCE_STUB_SHOW_WARNING=true
ENHANCE_WORKER_URL=
ENHANCE_WORKER_TIMEOUT=300
ENHANCE_WORKER_TOKEN=
ENHANCE_WORKER_MAX_DOWNLOAD_MB=60
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
@@ -375,6 +475,19 @@ GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=/auth/google/callback
# Google AdSense Management API (admin analytics, read-only)
# Redirect URI must match the Google Cloud OAuth client exactly.
ADSENSE_CLIENT_ID=
ADSENSE_CLIENT_SECRET=
ADSENSE_REDIRECT_URI=https://skinbase.org/admin/adsense/oauth/callback
ADSENSE_SYNC_ENABLED=true
# Optional light theme feature
# Set LIGHT_THEME_ENABLED=true to allow a light theme in the client-side toggle.
# Set LIGHT_THEME_SHOW_SWITCH=true to display the theme switch in the toolbar.
LIGHT_THEME_ENABLED=false
LIGHT_THEME_SHOW_SWITCH=false
# Discord — https://discord.com/developers/applications
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
+2
View File
@@ -13,6 +13,7 @@
/.deploy
/.zed
/auth.json
/build-info.json
/node_modules
/public/build
/public/hot
@@ -45,6 +46,7 @@
/storage/*.tar.xz
/storage/*.tar
/storage/*.tgz
/var/deploy/
/vendor
Homestead.json
Homestead.yaml
@@ -92,9 +92,9 @@ final class AcademyBillingHealthCommand extends Command
*/
private function buildReport(): array
{
$stripeKey = (string) config('cashier.key', '');
$stripeSecret = (string) config('cashier.secret', env('STRIPE_SECRET', ''));
$webhookSecret = (string) config('cashier.webhook.secret', env('STRIPE_WEBHOOK_SECRET', ''));
$stripeKey = $this->configuredString(config('cashier.key'));
$stripeSecret = $this->firstConfiguredString(config('cashier.secret'), env('STRIPE_SECRET'));
$webhookSecret = $this->firstConfiguredString(config('cashier.webhook.secret'), env('STRIPE_WEBHOOK_SECRET'));
$currency = trim((string) config('cashier.currency', env('CASHIER_CURRENCY', '')));
$currencyLocale = trim((string) config('cashier.currency_locale', env('CASHIER_CURRENCY_LOCALE', '')));
$academyEnabled = (bool) config('academy.enabled', true);
@@ -285,4 +285,21 @@ final class AcademyBillingHealthCommand extends Command
return self::SUCCESS;
}
private function firstConfiguredString(mixed ...$values): string
{
foreach ($values as $value) {
$value = $this->configuredString($value);
if ($value !== '') {
return $value;
}
}
return '';
}
private function configuredString(mixed $value): string
{
return is_string($value) ? trim($value) : '';
}
}
+137
View File
@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\AdsenseConnection;
use App\Services\Adsense\AdsenseSyncResult;
use App\Services\Adsense\AdsenseSyncService;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
final class AdsenseSyncCommand extends Command
{
protected $signature = 'adsense:sync
{--days= : Number of trailing days to synchronize, including today}
{--from= : Inclusive start date (YYYY-MM-DD)}
{--to= : Inclusive end date (YYYY-MM-DD)}
{--entities-only : Synchronize ad units and custom channels without reports}
{--force : Run even when ADSENSE_SYNC_ENABLED is false}';
protected $description = 'Synchronize Google AdSense entities and daily reporting into Skinbase';
public function handle(AdsenseSyncService $sync): int
{
if (! (bool) config('adsense.sync_enabled', true) && ! $this->option('force')) {
$this->warn('AdSense synchronization is disabled. Use --force to run anyway.');
return self::SUCCESS;
}
try {
[$from, $to] = $this->resolveRange();
} catch (\InvalidArgumentException $exception) {
$this->error($exception->getMessage());
return self::INVALID;
}
$connection = AdsenseConnection::current();
if ($connection === null || ! $connection->hasRefreshToken()) {
$this->error('Google AdSense is not connected.');
return self::FAILURE;
}
if ($connection->status === AdsenseConnection::STATUS_RECONNECT_REQUIRED) {
$this->error('AdSense authorization expired or was revoked. Reconnect Google AdSense.');
return self::FAILURE;
}
if ((string) $connection->account_resource_name === '') {
$this->error('No AdSense account is selected.');
return self::FAILURE;
}
$entitiesOnly = (bool) $this->option('entities-only');
$result = $sync->sync($connection, $from, $to, $entitiesOnly);
$this->renderResult($result, $entitiesOnly);
if ($result->reconnectRequired) {
$this->error('AdSense authorization expired or was revoked. Reconnect Google AdSense.');
return self::FAILURE;
}
if ($result->failedReports !== []) {
$this->warn('Synchronization completed with partial report failures: '.implode(', ', $result->failedReports));
return self::SUCCESS;
}
$this->info('Synchronization completed successfully.');
return self::SUCCESS;
}
/**
* @return array{0: Carbon, 1: Carbon}
*/
private function resolveRange(): array
{
$days = $this->option('days');
$from = $this->option('from');
$to = $this->option('to');
if ($days !== null && ($from !== null || $to !== null)) {
throw new \InvalidArgumentException('The --days option cannot be combined with --from or --to.');
}
if (($from !== null) !== ($to !== null)) {
throw new \InvalidArgumentException('Both --from and --to are required for a custom range.');
}
if (is_string($from) && is_string($to)) {
$start = Carbon::parse($from)->startOfDay();
$end = Carbon::parse($to)->startOfDay();
if ($end->lt($start)) {
throw new \InvalidArgumentException('The --to date must be on or after --from.');
}
return [$start, $end];
}
$trailingDays = $days !== null ? max(1, (int) $days) : 3;
$end = now()->startOfDay();
$start = $end->copy()->subDays($trailingDays - 1);
return [$start, $end];
}
private function renderResult(AdsenseSyncResult $result, bool $entitiesOnly): void
{
$this->line('AdSense account: '.($result->accountDisplayName !== '' ? $result->accountDisplayName : 'unknown'));
$this->line('Range: '.$result->from.' -> '.$result->to);
$this->newLine();
$this->line('Entities:');
$this->line(' Ad units: '.$result->adUnits);
$this->line(' Custom channels: '.$result->customChannels);
if ($entitiesOnly) {
return;
}
$this->newLine();
$this->line('Reports:');
$this->line(' Total: '.$result->totalRows);
$this->line(' Ad units: '.$result->adUnitRows);
$this->line(' Custom channels: '.$result->customChannelRows);
$this->line(' Platform: '.$result->platformRows);
$this->line(' Countries: '.$result->countryRows);
$this->newLine();
}
}
@@ -18,6 +18,9 @@ final class AuditArtworkDownloadFilesCommand extends Command
{--id= : Audit only this artwork ID}
{--limit= : Stop after processing this many artworks}
{--chunk=500 : Number of artworks to scan per batch}
{--status= : Only inspect records with this persisted download status}
{--missing-only : Only report unresolved records}
{--mark-status : Persist the resolved availability status (explicit write)}
{--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.';
@@ -28,6 +31,8 @@ final class AuditArtworkDownloadFilesCommand extends Command
$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');
$markStatus = (bool) $this->option('mark-status');
$statusFilter = $this->option('status');
$this->info(sprintf(
'Starting download file audit. order=desc include_trashed=yes chunk=%d limit=%s restore_missing=%s',
@@ -41,10 +46,11 @@ final class AuditArtworkDownloadFilesCommand extends Command
$unresolved = 0;
$restored = 0;
$restoreFailed = 0;
$classifications = [];
$lastSeenId = null;
do {
$artworks = $this->nextChunk($artworkId, $chunkSize, $lastSeenId);
$artworks = $this->nextChunk($artworkId, $chunkSize, $lastSeenId, is_string($statusFilter) ? $statusFilter : null);
if ($artworks->isEmpty()) {
break;
}
@@ -54,41 +60,59 @@ final class AuditArtworkDownloadFilesCommand extends Command
break 2;
}
$localPath = $locator->resolveLocalPath($artwork);
$missingReason = null;
if ($localPath === '') {
$missingReason = 'unresolved_local_path';
$resolution = $locator->resolve($artwork);
$classification = match ($resolution['status']) {
'available' => strtoupper((string) $resolution['source']).'_OK',
'pending' => 'PENDING',
'unknown' => 'AMBIGUOUS',
default => 'MISSING_EVERYWHERE',
};
$classifications[$classification] = ($classifications[$classification] ?? 0) + 1;
$localPath = (string) $resolution['path'];
$missingReason = $resolution['status'] === 'missing' ? 'missing_everywhere' : null;
if ($missingReason !== null) {
$unresolved++;
} elseif (! File::isFile($localPath)) {
$missingReason = 'missing_local_file';
}
if ($markStatus) {
$this->persistStatus($artwork, $resolution);
}
if ($missingReason === null && (bool) $this->option('missing-only')) {
$processed++;
continue;
}
if ($missingReason === null) {
$this->line(sprintf('Artwork %d %s source=%s', (int) $artwork->id, $classification, (string) $resolution['source']));
}
if ($missingReason !== null) {
$objectPath = $locator->resolveObjectPath($artwork);
$objectPath = (string) $resolution['object_key'];
$objectUrl = $locator->resolveObjectUrl($artwork);
$missing++;
$this->warn(sprintf('Artwork %d %s', (int) $artwork->id, $missingReason));
$this->line(' artwork_url: ' . route('art.show', [
$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]));
$this->line(' download_url: '.route('art.download', ['id' => (int) $artwork->id]));
if ($objectPath !== '') {
$this->line(' object_path: ' . $objectPath);
$this->line(' object_path: '.$objectPath);
}
if ($objectUrl !== null && $objectUrl !== '') {
$this->line(' object_url: ' . $objectUrl);
$this->line(' object_url: '.$objectUrl);
}
if ($localPath !== '') {
$this->line(' local_path: ' . $localPath);
$this->line(' local_path: '.$localPath);
}
if ($restoreMissing && $missingReason === 'missing_local_file' && $localPath !== '') {
if ($restoreMissing && $missingReason === 'missing_everywhere' && $localPath !== '') {
$restoreResult = $this->restoreLocalFile($storage, $objectPath, $localPath);
if ($restoreResult === 'restored') {
@@ -120,6 +144,9 @@ final class AuditArtworkDownloadFilesCommand extends Command
$restored,
$restoreFailed,
));
foreach ($classifications as $classification => $count) {
$this->line(sprintf('classification=%s count=%d', $classification, $count));
}
return self::SUCCESS;
}
@@ -127,11 +154,15 @@ final class AuditArtworkDownloadFilesCommand extends Command
/**
* @return Collection<int, Artwork>
*/
private function nextChunk(?int $artworkId, int $chunkSize, ?int $lastSeenId): Collection
private function nextChunk(?int $artworkId, int $chunkSize, ?int $lastSeenId, ?string $status): Collection
{
if ($artworkId !== null && $lastSeenId !== null) {
return collect();
}
$query = Artwork::query()
->withTrashed()
->select(['id', 'slug', 'file_path', 'hash', 'file_ext'])
->select(['id', 'slug', 'file_name', 'file_path', 'hash', 'file_ext'])
->orderByDesc('id');
if ($artworkId !== null) {
@@ -140,9 +171,30 @@ final class AuditArtworkDownloadFilesCommand extends Command
$query->where('id', '<', $lastSeenId);
}
if ($status !== null && $status !== '') {
$query->where('download_status', $status);
}
return $query->limit($chunkSize)->get();
}
/** @param array{status:string,source:?string} $resolution */
private function persistStatus(Artwork $artwork, array $resolution): void
{
$status = match ($resolution['status']) {
'available' => 'available',
'pending' => 'pending',
'unknown' => 'unknown',
default => 'missing',
};
$artwork->forceFill([
'download_status' => $status,
'download_source' => $resolution['source'],
'download_checked_at' => now(),
])->saveQuietly();
}
private function restoreLocalFile(UploadStorageService $storage, string $objectPath, string $localPath): string
{
if ($objectPath === '') {
@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Artwork;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
final class BackfillArtworkPrimaryCategoryCommand extends Command
{
protected $signature = 'artworks:backfill-primary-category
{--dry-run : Report without writing (default unless --apply is passed)}
{--report : Print category-count distribution and conflicts}
{--apply : Write primary_category_id for artworks with exactly one category}
{--force : Overwrite existing primary_category_id values}';
protected $description = 'Backfill artworks.primary_category_id from unique artwork_category memberships';
public function handle(): int
{
$apply = (bool) $this->option('apply');
$force = (bool) $this->option('force');
$report = (bool) $this->option('report') || ! $apply;
if ($report) {
$this->printDistribution();
}
$conflicts = $this->conflictArtworkIds();
$zeros = $this->zeroCategoryArtworkIds();
$eligible = $this->eligibleArtworkIds($force);
$this->info('Zero-category artworks: '.$zeros->count());
$this->info('Single-category eligible for backfill: '.$eligible->count());
$this->info('Multi-category conflicts (not auto-resolved): '.$conflicts->count());
if ($conflicts->isNotEmpty()) {
$this->warn('Multi-category artwork IDs (first 50): '.$conflicts->take(50)->implode(', '));
}
if (! $apply) {
$this->comment('Dry run only. Re-run with --apply to write primary_category_id for single-category artworks.');
return self::SUCCESS;
}
$updated = 0;
foreach ($eligible->chunk(500) as $chunk) {
$rows = DB::table('artwork_category')
->select('artwork_id', 'category_id')
->whereIn('artwork_id', $chunk->all())
->get()
->groupBy('artwork_id');
foreach ($rows as $artworkId => $memberships) {
if ($memberships->count() !== 1) {
continue;
}
$query = Artwork::query()->where('id', (int) $artworkId);
if (! $force) {
$query->whereNull('primary_category_id');
}
$updated += $query->update([
'primary_category_id' => (int) $memberships->first()->category_id,
]);
}
}
$this->info("Updated {$updated} artwork(s).");
return self::SUCCESS;
}
private function printDistribution(): void
{
$distribution = DB::query()
->fromSub(function ($query): void {
$query->from('artwork_category')
->select('artwork_id', DB::raw('COUNT(*) as category_count'))
->groupBy('artwork_id');
}, 'x')
->select('category_count', DB::raw('COUNT(*) as artworks'))
->groupBy('category_count')
->orderBy('category_count')
->get();
$this->table(['category_count', 'artworks'], $distribution->map(fn ($row): array => [
(int) $row->category_count,
(int) $row->artworks,
])->all());
}
/**
* @return Collection<int, int>
*/
private function eligibleArtworkIds(bool $force)
{
$query = DB::table('artwork_category')
->select('artwork_id')
->groupBy('artwork_id')
->havingRaw('COUNT(*) = 1');
if (! $force) {
$query->whereIn('artwork_id', Artwork::query()->whereNull('primary_category_id')->select('id'));
}
return $query->pluck('artwork_id')->map(fn ($id): int => (int) $id);
}
/**
* @return Collection<int, int>
*/
private function conflictArtworkIds()
{
return DB::table('artwork_category')
->select('artwork_id')
->groupBy('artwork_id')
->havingRaw('COUNT(*) > 1')
->orderByRaw('COUNT(*) DESC')
->pluck('artwork_id')
->map(fn ($id): int => (int) $id);
}
/**
* @return Collection<int, int>
*/
private function zeroCategoryArtworkIds()
{
return Artwork::query()
->whereDoesntHave('categories')
->pluck('id')
->map(fn ($id): int => (int) $id);
}
}
+33 -3
View File
@@ -49,10 +49,10 @@ final class BuildSitemapsCommand extends Command
$t = microtime(true);
$this->line(' Building sitemap index…');
$index = $build->buildIndex(force: true, persist: false, families: $families);
$disk->put('sitemap.xml', $index['content']);
$disk->put('sitemaps/sitemap.xml', $index['content']);
$written++;
$this->line(sprintf(
' <info>✔</info> sitemap.xml %d entries <comment>%.3fs</comment>',
' <info>✔</info> sitemaps/sitemap.xml %d entries <comment>%.3fs</comment>',
$index['url_count'],
microtime(true) - $t,
));
@@ -112,6 +112,36 @@ final class BuildSitemapsCommand extends Command
));
}
foreach ($build->enabledGroupIndexes() as $groupName => $groupFamilies) {
foreach ($groupFamilies as $family) {
if (! in_array($family, $families, true)) {
continue 2;
}
}
$t = microtime(true);
$this->line(sprintf(' Building grouped sitemap %s…', $groupName));
$built = $build->buildNamed($groupName, force: true, persist: false);
if ($built === null) {
$this->line(sprintf(' <comment>–</comment> %s.xml <fg=red>SKIPPED</> (group builder returned null)', $groupName));
$failed++;
continue;
}
$disk->put('sitemaps/' . $groupName . '.xml', $built['content']);
$written++;
$this->line(sprintf(
' <info>✔</info> %s %d entries <comment>%.3fs</comment>',
$groupName . '.xml',
$built['url_count'] ?? 0,
microtime(true) - $t,
));
}
// ── Summary ───────────────────────────────────────────────────────
$this->newLine();
$this->info(sprintf(
@@ -295,4 +325,4 @@ final class BuildSitemapsCommand extends Command
return array_values(array_filter($enabled, fn (string $family): bool => in_array($family, $only, true)));
}
}
}
@@ -18,6 +18,12 @@ class DispatchCollectionMaintenanceCommand extends Command
public function handle(CollectionBackgroundJobService $jobs): int
{
if (! (bool) config('collections.v5.queue.dispatch_enabled', false)) {
$this->warn('Collection maintenance dispatch is disabled (COLLECTIONS_V5_DISPATCH_ENABLED). No jobs queued.');
return self::SUCCESS;
}
$runHealth = (bool) $this->option('health');
$runRecommendations = (bool) $this->option('recommendations');
$runDuplicates = (bool) $this->option('duplicates');
@@ -0,0 +1,290 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Enhance;
use App\Models\EnhanceJob;
use App\Services\Enhance\EnhanceStorageService;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Throwable;
final class CleanupEnhanceJobsCommand extends Command
{
protected $signature = 'enhance:cleanup
{--dry-run : Preview cleanup actions only}
{--force : Delete files and update records}
{--only= : Restrict cleanup to expired, deleted, failed, or orphaned}
{--days= : Override retention days for failed or deleted cleanup}';
protected $description = 'Safely clean expired, deleted, failed, and orphaned Enhance files.';
public function __construct(
private readonly EnhanceStorageService $storage,
) {
parent::__construct();
}
public function handle(): int
{
$target = strtolower(trim((string) $this->option('only')));
$validTargets = ['', 'expired', 'deleted', 'failed', 'orphaned'];
if (! in_array($target, $validTargets, true)) {
$this->error('The --only option must be one of: expired, deleted, failed, orphaned.');
return self::FAILURE;
}
if ((bool) $this->option('dry-run') && (bool) $this->option('force')) {
$this->error('Use either --dry-run or --force, not both.');
return self::FAILURE;
}
$dryRun = (bool) $this->option('dry-run') || ! (bool) $this->option('force');
$daysOverride = $this->option('days');
$selectedTarget = $target !== '' ? $target : 'all';
Log::info('enhance.cleanup.started', [
'dry_run' => $dryRun,
'target' => $selectedTarget,
'days_override' => $daysOverride,
]);
if ($dryRun) {
$this->warn('Running in dry-run mode. No files will be deleted.');
}
$rows = [];
if ($target === '' || $target === 'expired') {
$expired = $this->cleanupExpiredCompletedJobs($dryRun);
$rows[] = ['expired', $expired['jobs'], $expired['files'], $dryRun ? 'dry-run' : 'cleaned'];
}
if ($target === '' || $target === 'failed') {
$failed = $this->cleanupFailedJobs($dryRun, $daysOverride);
$rows[] = ['failed', $failed['jobs'], $failed['files'], $dryRun ? 'dry-run' : 'cleaned'];
}
if ($target === '' || $target === 'deleted') {
$deleted = $this->cleanupSoftDeletedJobs($dryRun, $daysOverride);
$rows[] = ['deleted', $deleted['jobs'], $deleted['files'], $dryRun ? 'dry-run' : 'cleaned'];
}
if ($target === 'orphaned') {
$orphaned = $this->scanOrphanedFiles($dryRun);
$rows[] = ['orphaned', $orphaned['files'], $orphaned['deleted'], $dryRun ? 'dry-run' : 'deleted'];
if ($orphaned['unsupported']) {
$this->warn('Orphaned file scan was skipped because the configured disk does not support safe listing.');
}
foreach ($orphaned['sample'] as $path) {
$this->line(' - ' . $path);
}
}
if ($rows !== []) {
$this->table(['Target', 'Jobs/Files', 'Files deleted', 'Mode'], $rows);
}
Log::info('enhance.cleanup.completed', [
'dry_run' => $dryRun,
'target' => $selectedTarget,
'rows' => $rows,
]);
$this->info('Enhance cleanup finished.');
return self::SUCCESS;
}
private function cleanupExpiredCompletedJobs(bool $dryRun): array
{
$query = EnhanceJob::query()
->where('status', EnhanceJob::STATUS_COMPLETED)
->whereNotNull('expires_at')
->where('expires_at', '<=', now());
return $this->cleanupJobs($query, $dryRun, 'expired', fn (): array => [
'status' => EnhanceJob::STATUS_EXPIRED,
]);
}
private function cleanupFailedJobs(bool $dryRun, mixed $daysOverride): array
{
$days = $this->resolveDays($daysOverride, (int) config('enhance.lifecycle.failed_expires_after_days', 7));
$cutoff = now()->subDays($days);
$query = EnhanceJob::query()
->where('status', EnhanceJob::STATUS_FAILED)
->where(function (Builder $builder) use ($cutoff): void {
$builder
->where('finished_at', '<=', $cutoff)
->orWhere(function (Builder $fallback) use ($cutoff): void {
$fallback->whereNull('finished_at')->where('created_at', '<=', $cutoff);
});
});
return $this->cleanupJobs($query, $dryRun, 'failed-expired');
}
private function cleanupSoftDeletedJobs(bool $dryRun, mixed $daysOverride): array
{
$days = $this->resolveDays($daysOverride, (int) config('enhance.lifecycle.deleted_file_grace_days', 1));
$cutoff = now()->subDays($days);
$query = EnhanceJob::withTrashed()
->whereNotNull('deleted_at')
->where('deleted_at', '<=', $cutoff);
return $this->cleanupJobs($query, $dryRun, 'deleted-grace');
}
private function cleanupJobs(Builder $query, bool $dryRun, string $reason, ?callable $attributes = null): array
{
$result = ['jobs' => 0, 'files' => 0];
$chunkSize = max(1, (int) config('enhance.lifecycle.cleanup_chunk_size', 100));
$query->chunkById($chunkSize, function ($jobs) use (&$result, $dryRun, $reason, $attributes): void {
foreach ($jobs as $job) {
$result['jobs']++;
$result['files'] += count($this->enhanceJobPaths($job));
if ($dryRun) {
continue;
}
$deleteResult = $this->storage->deleteFilesForJob($job);
$metadata = is_array($job->metadata) ? $job->metadata : [];
$job->forceFill(array_merge(
$this->cleanupAttributesForJob($job),
$attributes ? $attributes($job) : [],
[
'metadata' => array_merge($metadata, [
'cleanup' => [
'files_removed_at' => now()->toIso8601String(),
'reason' => $reason,
'deleted' => $deleteResult['deleted'],
],
]),
],
))->save();
}
});
return $result;
}
private function scanOrphanedFiles(bool $dryRun): array
{
$disk = $this->storage->diskName();
$knownPaths = array_fill_keys(array_map(
static fn (string $path): string => ltrim($path, '/'),
$this->storage->listKnownJobPaths(),
), true);
$sample = [];
$result = [
'files' => 0,
'deleted' => 0,
'unsupported' => false,
'sample' => [],
];
foreach ($this->enhancePrefixes() as $prefix) {
try {
$files = Storage::disk($disk)->allFiles($prefix);
} catch (Throwable) {
$result['unsupported'] = true;
return $result;
}
foreach ($files as $file) {
$normalized = ltrim($file, '/');
if (isset($knownPaths[$normalized])) {
continue;
}
$result['files']++;
if (count($sample) < 20) {
$sample[] = $normalized;
}
if (! $dryRun && $this->storage->safeDelete($disk, $normalized)) {
$result['deleted']++;
}
}
}
$result['sample'] = $sample;
return $result;
}
private function cleanupAttributesForJob(EnhanceJob $job): array
{
$attributes = [];
if ($this->storage->isEnhancePath($job->source_path)) {
$attributes['source_disk'] = null;
$attributes['source_path'] = null;
$attributes['source_hash'] = null;
}
if ($this->storage->isEnhancePath($job->output_path)) {
$attributes['output_disk'] = null;
$attributes['output_path'] = null;
$attributes['output_hash'] = null;
$attributes['output_width'] = null;
$attributes['output_height'] = null;
$attributes['output_filesize'] = null;
$attributes['output_mime'] = null;
}
if ($this->storage->isEnhancePath($job->preview_path)) {
$attributes['preview_disk'] = null;
$attributes['preview_path'] = null;
}
return $attributes;
}
private function enhanceJobPaths(EnhanceJob $job): array
{
return array_values(array_filter([
$this->storage->isEnhancePath($job->source_path) ? trim((string) $job->source_path) : null,
$this->storage->isEnhancePath($job->output_path) ? trim((string) $job->output_path) : null,
$this->storage->isEnhancePath($job->preview_path) ? trim((string) $job->preview_path) : null,
]));
}
private function enhancePrefixes(): array
{
return array_values(array_filter(array_unique(array_map(
static fn (string $prefix): string => trim($prefix, '/'),
[
(string) config('enhance.source_prefix', 'enhance/sources'),
(string) config('enhance.output_prefix', 'enhance/outputs'),
(string) config('enhance.preview_prefix', 'enhance/previews'),
],
))));
}
private function resolveDays(mixed $daysOverride, int $default): int
{
if ($daysOverride === null || $daysOverride === '') {
return max(0, $default);
}
return max(0, (int) $daysOverride);
}
}
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Enhance;
use App\Models\EnhanceJob;
use Illuminate\Console\Command;
final class EnhanceHealthCommand extends Command
{
protected $signature = 'enhance:health {--json : Output machine-readable JSON}';
protected $description = 'Report operational health and lifecycle metrics for Enhance jobs.';
public function handle(): int
{
$payload = $this->payload();
if ((bool) $this->option('json')) {
$this->line(json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
return self::SUCCESS;
}
$this->info('Enhance health');
$this->newLine();
$this->table(['Metric', 'Value'], [
['Configured engine', $payload['engine']],
['Configured queue', $payload['queue']],
['Worker URL configured', $payload['worker_configured'] ? 'yes' : 'no'],
['Storage disk', $payload['storage_disk']],
['Total jobs', $payload['counts']['total']],
['Pending jobs', $payload['counts']['pending']],
['Queued jobs', $payload['counts']['queued']],
['Processing jobs', $payload['counts']['processing']],
['Completed jobs', $payload['counts']['completed']],
['Failed jobs', $payload['counts']['failed']],
['Cancelled jobs', $payload['counts']['cancelled']],
['Expired jobs', $payload['counts']['expired']],
['Stuck queued jobs', $payload['health']['stuck_queued']],
['Stuck processing jobs', $payload['health']['stuck_processing']],
['Jobs created today', $payload['today']['created']],
['Jobs completed today', $payload['today']['completed']],
['Jobs failed today', $payload['today']['failed']],
['Average processing time today', $payload['today']['average_processing_seconds'] ?? '—'],
]);
return self::SUCCESS;
}
private function payload(): array
{
$todayStart = now()->startOfDay();
$todayEnd = now()->endOfDay();
$stuckQueuedCutoff = now()->subMinutes((int) config('enhance.health.stuck_queued_after_minutes', 60));
$stuckProcessingCutoff = now()->subMinutes((int) config('enhance.health.stuck_processing_after_minutes', 30));
$counts = [
'total' => EnhanceJob::query()->count(),
'pending' => EnhanceJob::query()->where('status', EnhanceJob::STATUS_PENDING)->count(),
'queued' => EnhanceJob::query()->where('status', EnhanceJob::STATUS_QUEUED)->count(),
'processing' => EnhanceJob::query()->where('status', EnhanceJob::STATUS_PROCESSING)->count(),
'completed' => EnhanceJob::query()->where('status', EnhanceJob::STATUS_COMPLETED)->count(),
'failed' => EnhanceJob::query()->where('status', EnhanceJob::STATUS_FAILED)->count(),
'cancelled' => EnhanceJob::query()->where('status', EnhanceJob::STATUS_CANCELLED)->count(),
'expired' => EnhanceJob::query()->where('status', EnhanceJob::STATUS_EXPIRED)->count(),
];
return [
'engine' => (string) config('enhance.default_engine', EnhanceJob::ENGINE_STUB),
'queue' => (string) config('enhance.queue', 'default'),
'worker_configured' => trim((string) config('enhance.external_worker.url', '')) !== '',
'storage_disk' => (string) config('enhance.disk', 'public'),
'counts' => $counts,
'health' => [
'stuck_queued' => EnhanceJob::query()
->where('status', EnhanceJob::STATUS_QUEUED)
->whereNotNull('queued_at')
->where('queued_at', '<=', $stuckQueuedCutoff)
->count(),
'stuck_processing' => EnhanceJob::query()
->where('status', EnhanceJob::STATUS_PROCESSING)
->whereNotNull('started_at')
->where('started_at', '<=', $stuckProcessingCutoff)
->count(),
],
'today' => [
'created' => EnhanceJob::query()->whereBetween('created_at', [$todayStart, $todayEnd])->count(),
'completed' => EnhanceJob::query()
->where('status', EnhanceJob::STATUS_COMPLETED)
->whereBetween('finished_at', [$todayStart, $todayEnd])
->count(),
'failed' => EnhanceJob::query()
->where('status', EnhanceJob::STATUS_FAILED)
->whereBetween('finished_at', [$todayStart, $todayEnd])
->count(),
'average_processing_seconds' => ($average = EnhanceJob::query()
->whereNotNull('processing_seconds')
->whereBetween('finished_at', [$todayStart, $todayEnd])
->avg('processing_seconds')) !== null
? round((float) $average, 2)
: null,
],
];
}
}
@@ -0,0 +1,290 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Enhance;
use App\Models\EnhanceJob;
use App\Services\Enhance\EnhanceProcessorFactory;
use App\Services\Enhance\EnhanceStorageService;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Throwable;
final class EnhanceRunCommand extends Command
{
protected $signature = 'enhance:run
{--id= : Process specific job ID(s), comma-separated}
{--limit=1 : Max pending/queued jobs to pick up from the queue (0 = all)}
{--engine= : Override the processing engine for this run (stub, external_worker)}
{--failed : Also include failed jobs when scanning the queue}
{--dry-run : Show what would be processed without executing}';
protected $description = 'Synchronously process pending enhance jobs inline — useful for debugging with -v / -vv / -vvv.';
private const PROCESSABLE_STATUSES = [
EnhanceJob::STATUS_PENDING,
EnhanceJob::STATUS_QUEUED,
EnhanceJob::STATUS_PROCESSING,
EnhanceJob::STATUS_FAILED,
];
public function __construct(
private readonly EnhanceProcessorFactory $processorFactory,
private readonly EnhanceStorageService $storage,
) {
parent::__construct();
}
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$engineOverride = trim((string) $this->option('engine'));
$idOption = trim((string) $this->option('id'));
$limit = max(0, (int) $this->option('limit'));
$includeFailed = (bool) $this->option('failed');
if ($engineOverride !== '' && ! in_array($engineOverride, [EnhanceJob::ENGINE_STUB, EnhanceJob::ENGINE_EXTERNAL_WORKER], true)) {
$this->error("Unknown engine override: {$engineOverride}. Use 'stub' or 'external_worker'.");
return self::FAILURE;
}
$jobs = $this->resolveJobs($idOption, $limit, $includeFailed);
if ($jobs->isEmpty()) {
$this->info('No eligible enhance jobs found.');
return self::SUCCESS;
}
$this->info(sprintf('Found %d job(s) to process.', $jobs->count()));
$this->newLine();
if ($dryRun) {
$this->warn('Dry-run mode — no jobs will be processed.');
foreach ($jobs as $job) {
$engine = $engineOverride !== '' ? "{$engineOverride} (overridden)" : $job->engine;
$this->line(sprintf(
' [dry-run] Job #%d status=%-12s engine=%-18s scale=%dx mode=%-14s user_id=%d',
$job->id,
$job->status,
$engine,
$job->scale,
$job->mode,
$job->user_id,
));
}
return self::SUCCESS;
}
$processed = 0;
$failed = 0;
foreach ($jobs as $job) {
if ($this->processJob($job, $engineOverride)) {
$processed++;
} else {
$failed++;
}
$this->newLine();
}
$this->info(sprintf('Done: %d completed, %d failed.', $processed, $failed));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
private function resolveJobs(string $idOption, int $limit, bool $includeFailed): Collection
{
if ($idOption !== '') {
$ids = array_filter(array_map('intval', explode(',', $idOption)));
return EnhanceJob::query()
->whereIn('id', $ids)
->whereIn('status', self::PROCESSABLE_STATUSES)
->get();
}
$statuses = [EnhanceJob::STATUS_PENDING, EnhanceJob::STATUS_QUEUED, EnhanceJob::STATUS_PROCESSING];
if ($includeFailed) {
$statuses[] = EnhanceJob::STATUS_FAILED;
}
$query = EnhanceJob::query()->whereIn('status', $statuses)->oldest();
if ($limit > 0) {
$query->limit($limit);
}
return $query->get();
}
private function processJob(EnhanceJob $job, string $engineOverride): bool
{
$engine = $engineOverride !== '' ? $engineOverride : (string) $job->engine;
$this->line(sprintf('<comment>--- Job #%d ---</comment>', $job->id));
$this->line(sprintf(' Status : %s', $job->status));
$this->line(sprintf(' Engine : %s%s', $engine, $engineOverride !== '' ? ' (overridden)' : ''));
$this->line(sprintf(' Scale : %dx', $job->scale));
$this->line(sprintf(' Mode : %s', $job->mode));
$this->line(sprintf(' User : #%d', $job->user_id));
if ($this->output->isVerbose()) {
$this->line(sprintf(
' Source : disk=%-10s path=%s',
$job->source_disk ?: '(default)',
$job->source_path ?: '—',
));
$this->line(sprintf(
' Input : %dx%d size=%s mime=%s',
(int) $job->input_width,
(int) $job->input_height,
$this->formatBytes((int) $job->input_filesize),
$job->input_mime ?: '—',
));
if ($job->error_message !== null) {
$this->warn(sprintf(' Previous error: %s', $job->error_message));
}
}
if (! in_array($job->status, self::PROCESSABLE_STATUSES, true)) {
$this->warn(sprintf(' Skipping: status "%s" is not processable.', $job->status));
return false;
}
$job->forceFill([
'status' => EnhanceJob::STATUS_PROCESSING,
'started_at' => now(),
'finished_at' => null,
'error_message' => null,
])->save();
$started = microtime(true);
$completedExpiryDays = (int) config('enhance.lifecycle.completed_expires_after_days', 30);
try {
$this->line(' Processing...');
$processor = $this->processorFactory->make($engine);
$result = $processor->process($job);
if ($this->output->isVerbose()) {
$this->line(sprintf(
' Output : %dx%d size=%s mime=%s',
$result->width,
$result->height,
$this->formatBytes($result->filesize),
$result->mime,
));
$this->line(sprintf(
' Stored : disk=%-10s path=%s',
$result->disk,
$result->path,
));
}
$this->line(' Generating preview...');
$preview = $this->storage->createPreviewFromStoredOutput($job, $result->disk, $result->path) ?? [];
$outputHash = null;
$outputContents = Storage::disk($result->disk)->get($result->path);
if (is_string($outputContents) && $outputContents !== '') {
$outputHash = hash('sha256', $outputContents);
}
$job->forceFill([
'status' => EnhanceJob::STATUS_COMPLETED,
'output_disk' => $result->disk,
'output_path' => $result->path,
'output_hash' => $outputHash,
'output_width' => $result->width,
'output_height' => $result->height,
'output_filesize' => $result->filesize,
'output_mime' => $result->mime,
'metadata' => array_merge($job->metadata ?? [], $result->metadata ?? []),
'processing_seconds' => (int) round(microtime(true) - $started),
'finished_at' => now(),
'expires_at' => $completedExpiryDays > 0 ? now()->addDays($completedExpiryDays) : null,
] + $preview)->save();
$elapsed = round(microtime(true) - $started, 2);
$this->info(sprintf(' Completed in %.2fs', $elapsed));
if ($this->output->isVerbose() && ! empty($result->metadata)) {
$this->line(' Metadata:');
foreach ($result->metadata as $key => $value) {
$display = is_scalar($value) ? (string) $value : json_encode($value);
$this->line(sprintf(' %-30s %s', $key . ':', $display));
}
}
return true;
} catch (Throwable $exception) {
$elapsed = round(microtime(true) - $started, 2);
$job->forceFill([
'status' => EnhanceJob::STATUS_FAILED,
'error_message' => Str::limit($exception->getMessage(), 1000),
'processing_seconds' => (int) round(microtime(true) - $started),
'finished_at' => now(),
])->save();
$this->error(sprintf(' Failed in %.2fs: %s', $elapsed, $exception->getMessage()));
if ($this->output->isVerbose()) {
$this->line(sprintf(' Exception : %s', get_class($exception)));
$this->line(sprintf(' At : %s:%d', $exception->getFile(), $exception->getLine()));
$previous = $exception->getPrevious();
if ($previous !== null) {
$this->line(sprintf(' Caused by : %s: %s', get_class($previous), $previous->getMessage()));
}
}
if ($this->output->isVeryVerbose()) {
$this->line(' Stack trace:');
$frames = array_slice(explode("\n", $exception->getTraceAsString()), 0, 25);
foreach ($frames as $frame) {
$this->line(' ' . $frame);
}
}
Log::warning('enhance.run.command.failed', [
'enhance_job_id' => $job->id,
'engine' => $engine,
'message' => $exception->getMessage(),
'exception' => get_class($exception),
]);
return false;
}
}
private function formatBytes(int $bytes): string
{
if ($bytes < 1024) {
return $bytes . 'B';
}
if ($bytes < 1_048_576) {
return round($bytes / 1024, 1) . 'KB';
}
return round($bytes / 1_048_576, 1) . 'MB';
}
}
@@ -48,13 +48,32 @@ final class GenerateSitemapsCommand extends Command
$this->newLine();
// ── Root sitemap index ────────────────────────────────────────────
// Write several paths so nginx `location = /sitemap.xml` and child
// listings stay in sync. `sitemaps/index.xml` is the canonical generated
// index: it is a new filename, so a scheduler user can create it even
// when a stale `sitemaps/sitemap.xml` is owned by another account.
$t = microtime(true);
$index = $build->buildIndex(force: true, persist: false, families: $families);
$disk->put('sitemaps/sitemap.xml', $index['content']);
$written++;
$indexPaths = ['sitemaps/index.xml', 'sitemaps/sitemap.xml', 'sitemap.xml'];
$indexWritten = 0;
foreach ($indexPaths as $path) {
if ($this->writeXml($disk, $path, $index['content'])) {
$written++;
$indexWritten++;
}
}
if ($indexWritten === 0) {
$this->error('Failed to write any root sitemap index file. Check ownership of public/sitemap.xml and public/sitemaps/.');
return self::FAILURE;
}
$this->line(sprintf(
' <info>✔</info> sitemaps/sitemap.xml %d entries <comment>%.3fs</comment>',
' <info>✔</info> sitemap index %d entries %d path(s) <comment>%.3fs</comment>',
$index['url_count'],
$indexWritten,
microtime(true) - $t,
));
@@ -78,7 +97,12 @@ final class GenerateSitemapsCommand extends Command
}
$path = 'sitemaps/' . $documentName . '.xml';
$disk->put($path, $built['content']);
if (! $this->writeXml($disk, $path, $built['content'])) {
$this->line(sprintf(' <fg=red>✖</> %s write failed', $documentName . '.xml'));
$failed++;
continue;
}
$written++;
$this->line(sprintf(
@@ -96,6 +120,40 @@ final class GenerateSitemapsCommand extends Command
));
}
foreach ($build->enabledGroupIndexes() as $groupName => $groupFamilies) {
foreach ($groupFamilies as $family) {
if (! in_array($family, $families, true)) {
continue 2;
}
}
$t = microtime(true);
$built = $build->buildNamed($groupName, force: true, persist: false);
if ($built === null) {
$this->line(sprintf(' <comment>–</comment> %s.xml <fg=red>SKIPPED</> (group builder returned null)', $groupName));
$failed++;
continue;
}
$path = 'sitemaps/' . $groupName . '.xml';
if (! $this->writeXml($disk, $path, $built['content'])) {
$this->line(sprintf(' <fg=red>✖</> %s.xml write failed', $groupName));
$failed++;
continue;
}
$written++;
$this->line(sprintf(
' <info>✔</info> %s %d entries <comment>%.3fs</comment>',
$groupName . '.xml',
$built['url_count'] ?? 0,
microtime(true) - $t,
));
}
// ── Summary ───────────────────────────────────────────────────────
$this->newLine();
$this->info(sprintf(
@@ -130,4 +188,17 @@ final class GenerateSitemapsCommand extends Command
return array_values(array_filter($enabled, fn (string $f): bool => in_array($f, $only, true)));
}
private function writeXml(\Illuminate\Contracts\Filesystem\Filesystem $disk, string $path, string $content): bool
{
$ok = $disk->put($path, $content);
if ($ok !== true) {
$this->warn(' Could not write '.$path);
return false;
}
return true;
}
}
@@ -7,34 +7,137 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Prune old hourly metric snapshots to prevent unbounded table growth.
* Prune old hourly metric snapshots in bounded batches.
*
* Usage: php artisan nova:prune-metric-snapshots
* php artisan nova:prune-metric-snapshots --keep-days=7
* Usage:
* php artisan nova:prune-metric-snapshots
* php artisan nova:prune-metric-snapshots --keep-days=30 --chunk=5000 --dry-run
*/
class PruneMetricSnapshotsCommand extends Command
{
protected $signature = 'nova:prune-metric-snapshots
{--keep-days=7 : Keep snapshots for this many days}';
{--keep-days= : Keep snapshots for this many days (default: config metrics.hourly_snapshot_retention_days)}
{--chunk= : Rows to delete per batch (default: config metrics.hourly_snapshot_prune_chunk)}
{--sleep-ms= : Pause between batches in milliseconds}
{--max-batches=0 : Stop after N batches (0 = until done)}
{--dry-run : Count rows that would be deleted without deleting}';
protected $description = 'Delete old hourly metric snapshots beyond the retention window';
protected $description = 'Delete old hourly metric snapshots beyond the retention window (batched)';
public function handle(): int
{
$keepDays = (int) $this->option('keep-days');
$cutoff = now()->subDays($keepDays);
$keepDays = $this->resolveKeepDays();
$chunk = $this->resolvePositiveInt('chunk', (int) config('metrics.hourly_snapshot_prune_chunk', 5000), 1);
$sleepMs = $this->resolveNonNegativeInt('sleep-ms', (int) config('metrics.hourly_snapshot_prune_sleep_ms', 50));
$maxBatches = max(0, (int) $this->option('max-batches'));
$dryRun = (bool) $this->option('dry-run');
$cutoff = now()->subDays($keepDays);
$deleted = DB::table('artwork_metric_snapshots_hourly')
if ($keepDays < 1) {
$this->error('keep-days must be >= 1.');
return self::FAILURE;
}
$eligible = (int) DB::table('artwork_metric_snapshots_hourly')
->where('bucket_hour', '<', $cutoff)
->delete();
->count();
$this->info("Pruned {$deleted} snapshot rows older than {$keepDays} days.");
$this->info(sprintf(
'[nova:prune-metric-snapshots] cutoff=%s keep_days=%d eligible=%d chunk=%d sleep_ms=%d max_batches=%s%s',
$cutoff->toDateTimeString(),
$keepDays,
$eligible,
$chunk,
$sleepMs,
$maxBatches === 0 ? 'unlimited' : (string) $maxBatches,
$dryRun ? ' (dry-run)' : ''
));
if ($dryRun) {
Log::info('[nova:prune-metric-snapshots] dry-run', [
'eligible' => $eligible,
'keep_days' => $keepDays,
'cutoff' => $cutoff->toDateTimeString(),
]);
return self::SUCCESS;
}
$deleted = 0;
$batches = 0;
while (true) {
if ($maxBatches > 0 && $batches >= $maxBatches) {
$this->warn("Stopped after max-batches={$maxBatches}.");
break;
}
$ids = DB::table('artwork_metric_snapshots_hourly')
->where('bucket_hour', '<', $cutoff)
->orderBy('id')
->limit($chunk)
->pluck('id');
if ($ids->isEmpty()) {
break;
}
$batchDeleted = DB::table('artwork_metric_snapshots_hourly')
->whereIn('id', $ids->all())
->delete();
$deleted += $batchDeleted;
$batches++;
Log::info('[nova:prune-metric-snapshots] batch', [
'batch' => $batches,
'deleted' => $batchDeleted,
'deleted_total' => $deleted,
]);
if ($sleepMs > 0) {
usleep($sleepMs * 1000);
}
}
$this->info("Pruned {$deleted} snapshot rows older than {$keepDays} days in {$batches} batch(es).");
Log::info('[nova:prune-metric-snapshots] completed', [
'deleted' => $deleted,
'deleted' => $deleted,
'batches' => $batches,
'keep_days' => $keepDays,
'cutoff' => $cutoff->toDateTimeString(),
'eligible_at_start' => $eligible,
]);
return self::SUCCESS;
}
private function resolveKeepDays(): int
{
$option = $this->option('keep-days');
if ($option === null || $option === '') {
return (int) config('metrics.hourly_snapshot_retention_days', 30);
}
return (int) $option;
}
private function resolvePositiveInt(string $option, int $default, int $minimum): int
{
$raw = $this->option($option);
$value = ($raw === null || $raw === '') ? $default : (int) $raw;
return max($minimum, $value);
}
private function resolveNonNegativeInt(string $option, int $default): int
{
$raw = $this->option($option);
$value = ($raw === null || $raw === '') ? $default : (int) $raw;
return max(0, $value);
}
}
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Traffic\OnlineVisitorRepository;
use App\Services\Traffic\PresenceIndexPruner;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
class PruneOnlineVisitorIndexCommand extends Command
{
protected $signature = 'skinbase:prune-online-visitor-index
{--dry-run : Count stale members without SREM}
{--execute : Remove stale members}
{--scan-count= : SSCAN COUNT}
{--max-batches= : Batches this run}
{--max-members= : Members to inspect this run}
{--sleep-ms= : Pause between batches}
{--time-limit= : Stop after N seconds (0 = none)}';
protected $description = 'Remove stale members from the online-visitor Redis set (SSCAN, never SMEMBERS)';
public function handle(PresenceIndexPruner $pruner): int
{
$dryRun = (bool) $this->option('dry-run');
$execute = (bool) $this->option('execute');
if ($dryRun === $execute) {
$this->error('Pass exactly one of --dry-run or --execute.');
return self::FAILURE;
}
$scanCount = $this->intOption('scan-count', (int) config('traffic.online_visitors.prune_scan_count', 500));
$maxBatches = $this->intOption('max-batches', (int) config('traffic.online_visitors.prune_max_batches', 200));
$maxMembers = $this->intOption('max-members', (int) config('traffic.online_visitors.prune_max_members', 100000));
$sleepMs = $this->intOption('sleep-ms', (int) config('traffic.online_visitors.prune_sleep_ms', 25));
$timeLimit = $this->intOption('time-limit', 0);
$scard = (int) Redis::scard(OnlineVisitorRepository::INDEX_KEY);
$this->info(sprintf(
'[prune-online-visitor-index] key=%s prefix=%s scard=%d scan_count=%d max_batches=%d max_members=%d sleep_ms=%d %s',
OnlineVisitorRepository::INDEX_KEY,
(string) config('database.redis.options.prefix'),
$scard,
$scanCount,
$maxBatches,
$maxMembers,
$sleepMs,
$dryRun ? 'dry-run' : 'execute'
));
$result = $pruner->prune(
$scanCount,
$maxBatches,
$maxMembers,
$sleepMs,
$dryRun,
$timeLimit > 0 ? $timeLimit : null,
);
$this->info(sprintf(
'scanned=%d stale=%d live=%d removed=%d batches=%d',
$result['scanned'],
$result['stale'],
$result['live'],
$result['removed'],
$result['batches']
));
Log::info('[prune-online-visitor-index] completed', $result + ['scard_before' => $scard]);
return self::SUCCESS;
}
private function intOption(string $name, int $default): int
{
$raw = $this->option($name);
if ($raw === null || $raw === '') {
return $default;
}
return max(0, (int) $raw);
}
}
@@ -7,6 +7,7 @@ namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use App\Services\News\NewsService;
use cPad\Plugins\News\Models\NewsArticle;
final class PublishScheduledNewsCommand extends Command
@@ -17,6 +18,11 @@ final class PublishScheduledNewsCommand extends Command
protected $description = 'Publish scheduled News articles whose publish time has passed.';
public function __construct(private readonly NewsService $news)
{
parent::__construct();
}
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
@@ -60,11 +66,7 @@ final class PublishScheduledNewsCommand extends Command
return;
}
$article->forceFill([
'editorial_status' => NewsArticle::EDITORIAL_STATUS_PUBLISHED,
'status' => 'published',
'published_at' => $article->published_at ?? $now,
])->save();
$this->news->publish($article);
$published++;
$this->line(sprintf('Published News article #%d: "%s"', $article->id, $article->title));
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Jobs\RecComputeSimilarByTagsJob;
use App\Support\Queues\QueuedJobClassMatcher;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
/**
* Deployment cleanup: remove waiting RecComputeSimilarByTagsJob payloads only.
*
* Does not run from the scheduler. Operator must pass --dry-run or --execute.
*
* Usage:
* php artisan skinbase:purge-queued-rec-tags --dry-run
* php artisan skinbase:purge-queued-rec-tags --execute
*/
class PurgeQueuedRecComputeTagsCommand extends Command
{
protected $signature = 'skinbase:purge-queued-rec-tags
{--queue=default : Redis queue name (waiting list only)}
{--dry-run : Count matches without removing}
{--execute : Remove matched waiting payloads}';
protected $description = 'Remove waiting RecComputeSimilarByTagsJob payloads from a Redis queue (opt-in)';
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$execute = (bool) $this->option('execute');
if ($dryRun === $execute) {
$this->error('Pass exactly one of --dry-run or --execute.');
return self::FAILURE;
}
$queue = (string) $this->option('queue');
if (! preg_match('/^[A-Za-z0-9_-]+$/', $queue)) {
$this->error('Invalid queue name.');
return self::FAILURE;
}
$key = 'queues:'.$queue;
$target = RecComputeSimilarByTagsJob::class;
$redis = Redis::connection();
$len = (int) $redis->llen($key);
$matchedPayloads = [];
$chunk = 200;
$this->info(sprintf(
'[purge-queued-rec-tags] queue=%s waiting=%d target=%s %s',
$queue,
$len,
$target,
$dryRun ? 'dry-run' : 'execute'
));
for ($start = 0; $start < $len; $start += $chunk) {
$rows = $redis->lrange($key, $start, min($start + $chunk - 1, $len - 1));
foreach ($rows as $raw) {
if (QueuedJobClassMatcher::isClass((string) $raw, $target)) {
$matchedPayloads[] = (string) $raw;
}
}
}
$matched = count($matchedPayloads);
$removed = 0;
if (! $dryRun) {
foreach ($matchedPayloads as $raw) {
$removed += (int) $redis->lrem($key, 1, $raw);
}
}
$this->info(sprintf('matched=%d removed=%d preserved_other=%s', $matched, $removed, $dryRun ? 'yes' : 'yes'));
Log::info('[purge-queued-rec-tags] completed', [
'queue' => $queue,
'waiting' => $len,
'matched' => $matched,
'removed' => $removed,
'dry_run' => $dryRun,
]);
return self::SUCCESS;
}
}
+27 -21
View File
@@ -57,7 +57,7 @@ class RecalculateHeatCommand extends Command
$updatedCount = 0;
$skippedCount = 0;
// Process in chunks using artwork IDs that have at least one snapshot in the smoothing window
// Distinct IDs only — do not hydrate the full 24h snapshot window in one query.
$artworkIds = DB::table('artwork_metric_snapshots_hourly')
->whereBetween('bucket_hour', [$lookbackStart, $currentHour])
->distinct()
@@ -68,27 +68,33 @@ class RecalculateHeatCommand extends Command
return self::SUCCESS;
}
// Load all snapshots for the lookback window in bulk
$snapshots = DB::table('artwork_metric_snapshots_hourly')
->whereBetween('bucket_hour', [$lookbackStart, $currentHour])
->whereIn('artwork_id', $artworkIds)
->orderBy('bucket_hour')
->get()
->groupBy('artwork_id');
// Load artwork published_at dates for age factor (use published_at, fall back to created_at)
$artworkDates = DB::table('artworks')
->whereIn('id', $artworkIds)
->whereNull('deleted_at')
->where('is_approved', true)
->select('id', 'published_at', 'created_at')
->get()
->mapWithKeys(fn ($row) => [
$row->id => \Carbon\Carbon::parse($row->published_at ?? $row->created_at),
]);
// Process in chunks
foreach ($artworkIds->chunk($chunk) as $chunkIds) {
$snapshots = DB::table('artwork_metric_snapshots_hourly')
->select([
'artwork_id',
'bucket_hour',
'views_count',
'downloads_count',
'favourites_count',
'comments_count',
'shares_count',
])
->whereBetween('bucket_hour', [$lookbackStart, $currentHour])
->whereIn('artwork_id', $chunkIds)
->orderBy('bucket_hour')
->get()
->groupBy('artwork_id');
$artworkDates = DB::table('artworks')
->whereIn('id', $chunkIds)
->whereNull('deleted_at')
->where('is_approved', true)
->select('id', 'published_at', 'created_at')
->get()
->mapWithKeys(fn ($row) => [
$row->id => \Carbon\Carbon::parse($row->published_at ?? $row->created_at),
]);
$upsertRows = [];
foreach ($chunkIds as $artworkId) {
@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Predis\Client as PredisClient;
class RedisCleanupLegacyPrefixCommand extends Command
{
protected $signature = 'skinbase:redis-cleanup-legacy-prefix
{--dry-run : SCAN and report only}
{--execute : UNLINK matching obsolete keys}
{--max-keys=500 : Cap keys this run}';
protected $description = 'UNLINK obsolete skinbasenova-database-* and skinbasenova_horizon:* keys only';
private const LEGACY_PREFIXES = [
'skinbasenova-database-',
'skinbasenova_horizon:',
];
private const PROTECTED_PREFIXES = [
'skinbase-database-',
'skinbase_horizon:',
];
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$execute = (bool) $this->option('execute');
if ($dryRun === $execute) {
$this->error('Pass exactly one of --dry-run or --execute.');
return self::FAILURE;
}
$maxKeys = max(1, (int) $this->option('max-keys'));
$currentPrefix = (string) config('database.redis.options.prefix');
$horizonPrefix = (string) config('horizon.prefix');
$this->info(sprintf(
'[redis-cleanup-legacy-prefix] current_prefix=%s horizon_prefix=%s max_keys=%d %s',
$currentPrefix,
$horizonPrefix,
$maxKeys,
$dryRun ? 'dry-run' : 'execute'
));
if (in_array($currentPrefix, self::LEGACY_PREFIXES, true) || in_array($horizonPrefix, self::LEGACY_PREFIXES, true)) {
$this->error('Current process still uses a legacy prefix. Aborting.');
return self::FAILURE;
}
$client = $this->unprefixedClient();
$found = [];
$cursor = '0';
do {
[$cursor, $keys] = $client->scan($cursor, ['COUNT' => 200, 'MATCH' => '*']);
foreach ($keys as $key) {
$key = (string) $key;
if ($this->isProtected($key)) {
continue;
}
if (! $this->isLegacy($key)) {
continue;
}
$found[] = $key;
if (count($found) >= $maxKeys) {
$cursor = '0';
break;
}
}
} while ($cursor !== '0' && $cursor !== 0);
$this->info('matched='.count($found));
foreach (array_slice($found, 0, 20) as $key) {
$this->line('key='.$key);
}
if (count($found) > 20) {
$this->line('... truncated listing');
}
$unlinked = 0;
if (! $dryRun && $found !== []) {
foreach (array_chunk($found, 50) as $chunk) {
$unlinked += (int) $client->unlink(...$chunk);
}
}
$this->info('unlinked='.$unlinked);
Log::info('[redis-cleanup-legacy-prefix] completed', [
'matched' => count($found),
'unlinked' => $unlinked,
'dry_run' => $dryRun,
]);
return self::SUCCESS;
}
private function isLegacy(string $key): bool
{
foreach (self::LEGACY_PREFIXES as $prefix) {
if (str_starts_with($key, $prefix)) {
return true;
}
}
return false;
}
private function isProtected(string $key): bool
{
foreach (self::PROTECTED_PREFIXES as $prefix) {
if (str_starts_with($key, $prefix)) {
return true;
}
}
return false;
}
private function unprefixedClient(): PredisClient
{
$redis = config('database.redis.default', []);
return new PredisClient([
'scheme' => 'tcp',
'host' => $redis['host'] ?? '127.0.0.1',
'port' => (int) ($redis['port'] ?? 6379),
'password' => $redis['password'] ?? null,
'database' => (int) ($redis['database'] ?? 0),
]);
}
}
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Support\Redis\OrphanQueueCleanup;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use RuntimeException;
class RedisCleanupOrphansCommand extends Command
{
protected $signature = 'skinbase:redis-cleanup-orphans
{target : forum-moderation, forum-security, or collections}
{--dry-run : Inspect and report without UNLINK}
{--execute : UNLINK the waiting queue and notify list}
{--force : Allow unexpected sampled job classes}';
protected $description = 'UNLINK an orphan Redis queue after producer and class checks (operator only)';
public function handle(OrphanQueueCleanup $cleanup): int
{
$dryRun = (bool) $this->option('dry-run');
$execute = (bool) $this->option('execute');
if ($dryRun === $execute) {
$this->error('Pass exactly one of --dry-run or --execute.');
return self::FAILURE;
}
$target = (string) $this->argument('target');
$force = (bool) $this->option('force');
$this->info(sprintf(
'[redis-cleanup-orphans] prefix=%s connection=default target=%s force=%s mode=%s',
(string) config('database.redis.options.prefix'),
$target,
$force ? 'yes' : 'no',
$dryRun ? 'dry-run' : 'execute'
));
try {
$plan = $execute
? $cleanup->execute($target, $force)
: $cleanup->plan($target, $force);
} catch (RuntimeException $e) {
$this->error($e->getMessage());
Log::warning('[redis-cleanup-orphans] refused', ['target' => $target, 'error' => $e->getMessage()]);
return self::FAILURE;
}
$this->line('logical_key='.$plan['logical_key']);
$this->line('notify_key='.$plan['notify_key']);
$this->line('llen='.$plan['llen'].' reserved='.$plan['reserved'].' delayed='.$plan['delayed']);
$this->line('sampled='.$plan['sampled'].' est_bytes='.$plan['est_bytes']);
$this->line('classes='.json_encode($plan['classes']));
if ($plan['unexpected_classes'] !== []) {
$this->warn('unexpected_classes='.json_encode($plan['unexpected_classes']));
}
if ($plan['errors'] !== []) {
$this->error('errors='.implode(',', $plan['errors']));
}
if ($execute) {
$this->info('unlinked='.($plan['unlinked'] ?? 0));
}
Log::info('[redis-cleanup-orphans] completed', [
'target' => $target,
'dry_run' => $dryRun,
'llen' => $plan['llen'],
'errors' => $plan['errors'],
'unlinked' => $plan['unlinked'] ?? 0,
]);
return ($plan['errors'] === [] || $dryRun) ? self::SUCCESS : self::FAILURE;
}
}
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Notifications\SecurityReportDangerNotification;
use App\Services\SecurityReport\SecurityReportScanner;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Notification;
final class SecurityReportScanCommand extends Command
{
protected $signature = 'security:scan
{--notify : Send configured email notification when high or critical findings exist}
{--triggered-by=artisan : Mark the scan source}
{--user-id= : Associate the scan with a specific user id}';
protected $description = 'Run Composer and npm security audits and store a private admin report.';
public function handle(SecurityReportScanner $scanner): int
{
if (! (bool) config('security-report.enabled', true)) {
$this->warn('Security report scanning is disabled.');
return self::INVALID;
}
$this->info('Running security report scan...');
$report = $scanner->scan(
(string) $this->option('triggered-by'),
$this->option('user-id') !== null ? (int) $this->option('user-id') : null,
);
if ($report->status === 'failed') {
$this->error('Security scan failed: ' . (string) ($report->error_message ?? 'Unknown error'));
return self::FAILURE;
}
$this->table(
['Status', 'Critical', 'High', 'Medium', 'Low', 'Unknown', 'Composer outdated', 'npm outdated'],
[[
$report->status,
$report->total_critical,
$report->total_high,
$report->total_medium,
$report->total_low,
$report->total_unknown,
$report->composer_outdated_count,
$report->npm_outdated_count,
]],
);
if ((bool) $this->option('notify') && $report->hasDangerFindings()) {
$this->sendDangerNotification($report);
$this->info('Danger notification sent.');
}
if ($report->hasCriticalFindings() && (bool) config('security-report.fail_on.critical', false)) {
return self::FAILURE;
}
if ($report->hasHighFindings() && (bool) config('security-report.fail_on.high', false)) {
return self::FAILURE;
}
return self::SUCCESS;
}
private function sendDangerNotification(\App\Models\SecurityReport $report): void
{
$email = trim((string) config('security-report.notify_email', ''));
if ($email === '') {
return;
}
Notification::route('mail', $email)
->notify(new SecurityReportDangerNotification($report));
}
}
@@ -0,0 +1,10 @@
<?php
namespace App\Contracts\Moderation;
use App\Data\Moderation\CommentSpamClassification;
interface CommentSpamClassifier
{
public function classify(string $content): CommentSpamClassification;
}
@@ -0,0 +1,15 @@
<?php
namespace App\Data\Moderation;
final class CommentSpamClassification
{
public function __construct(
public readonly bool $spam,
public readonly float $confidence,
public readonly string $reason,
public readonly string $provider,
public readonly string $model,
public readonly int $latencyMs,
) {}
}
@@ -12,7 +12,14 @@ use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory;
use Laravel\Cashier\Checkout;
use Laravel\Cashier\Subscription;
use Stripe\Exception\InvalidRequestException;
use Illuminate\Support\Facades\Log;
use App\Mail\AcademyAccessIssue;
use App\Models\StaffApplication;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
final class AcademyBillingController extends Controller
{
public function __construct(
@@ -48,6 +55,7 @@ final class AcademyBillingController extends Controller
'activePlanKey' => $activePlan['key'] ?? null,
'activePlanLabel' => $activePlan['label'] ?? null,
'catalog' => $this->catalog(),
'missingRemote' => $this->plans->missingRemotePriceIds(),
'links' => [
'login' => \route('login'),
'pricing' => \route('academy.pricing'),
@@ -64,7 +72,7 @@ final class AcademyBillingController extends Controller
'isGuest' => $user === null,
'isSubscriber' => $user?->hasAcademyCreatorAccess() || $user?->hasAcademyProAccess(),
],
])->rootView('collections');
])->rootView('academy');
}
public function checkout(\Illuminate\Http\Request $request): Checkout|\Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse
@@ -110,6 +118,46 @@ final class AcademyBillingController extends Controller
}
if ($this->access->hasActiveAcademySubscription($user)) {
// If the user already has an Academy subscription, allow an in-place upgrade
// (e.g. Creator -> Pro) by swapping the subscription to the requested price.
$subscription = $this->academySubscription($user);
$currentPlan = $this->activePlan($user);
// If current plan exists and the requested plan ranks higher, perform swap.
if ($currentPlan !== null && ($this->planRank((string) $plan['tier']) > $this->planRank((string) $currentPlan['tier']))) {
try {
if ($subscription instanceof Subscription) {
$subscription->swap((string) $plan['stripe_price_id']);
}
return \redirect()->route('academy.billing.account')->with('success', 'Subscription upgraded — your new plan is active.');
} catch (\Throwable $e) {
$context = [
'user_id' => $user->id ?? null,
'user_email' => $user->email ?? null,
'stripe_id' => $user->stripe_id ?? null,
'route' => 'academy.billing.checkout',
'attempt' => 'swap_subscription',
'plan_key' => $plan['key'] ?? null,
'plan_price_id' => $plan['stripe_price_id'] ?? null,
'request_ip' => $request->ip(),
'user_agent' => $request->header('User-Agent'),
'exception_class' => \get_class($e),
'exception_message' => $e->getMessage(),
'exception_code' => $e->getCode(),
'exception_trace' => \method_exists($e, 'getTraceAsString') ? $e->getTraceAsString() : null,
];
if (method_exists($e, 'getStripeCode')) {
$context['stripe_code'] = $e->getStripeCode();
}
Log::error('Academy billing: failed to swap subscription for upgrade', $context);
return $this->checkoutErrorResponse($request, $e);
}
}
return \redirect()->route('academy.billing.portal');
}
@@ -133,8 +181,91 @@ final class AcademyBillingController extends Controller
'academy_tier' => (string) $plan['tier'],
],
]);
} catch (InvalidRequestException $e) {
// Stripe returned a request error (e.g. missing/deleted customer). Try to recover once by
// clearing stored `stripe_id`, recreating the customer in Stripe and retrying the checkout.
if (str_contains($e->getMessage(), 'No such customer')) {
try {
$user->forceFill(['stripe_id' => null])->save();
// Create a fresh Stripe customer and persist the id
if (method_exists($user, 'createAsStripeCustomer')) {
$user->createAsStripeCustomer();
} else {
// fallback to createOrGet behavior
$user->createOrGetStripeCustomer();
}
return $user
->newSubscription($this->plans->subscriptionName(), (string) $plan['stripe_price_id'])
->withMetadata([
'skinbase_module' => 'academy',
'user_id' => (string) $user->id,
'academy_plan' => (string) $plan['key'],
'academy_tier' => (string) $plan['tier'],
])
->checkout([
'success_url' => \route('academy.billing.success').'?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => \route('academy.billing.cancel'),
'allow_promotion_codes' => true,
'metadata' => [
'skinbase_module' => 'academy',
'user_id' => (string) $user->id,
'academy_plan' => (string) $plan['key'],
'academy_tier' => (string) $plan['tier'],
],
]);
} catch (\Throwable $inner) {
$context = [
'user_id' => $user->id ?? null,
'user_email' => $user->email ?? null,
'stripe_id' => $user->stripe_id ?? null,
'route' => 'academy.billing.checkout',
'attempt' => 'recreate_customer_and_checkout',
'plan_key' => $plan['key'] ?? null,
'plan_price_id' => $plan['stripe_price_id'] ?? null,
'request_ip' => $request->ip(),
'user_agent' => $request->header('User-Agent'),
'exception_class' => \get_class($inner),
'exception_message' => $inner->getMessage(),
'exception_code' => $inner->getCode(),
'exception_trace' => \method_exists($inner, 'getTraceAsString') ? $inner->getTraceAsString() : null,
];
if (method_exists($inner, 'getStripeCode')) {
$context['stripe_code'] = $inner->getStripeCode();
}
Log::error('Academy billing: failed to recover Stripe customer and start checkout', $context);
return $this->checkoutErrorResponse($request, $inner);
}
}
// Not a recoverable customer-missing error; rethrow to be handled below
throw $e;
} catch (\Throwable $exception) {
\report($exception);
$context = [
'user_id' => $user->id ?? null,
'user_email' => $user->email ?? null,
'stripe_id' => $user->stripe_id ?? null,
'route' => 'academy.billing.checkout',
'attempt' => 'start_checkout',
'plan_key' => $plan['key'] ?? null,
'plan_price_id' => $plan['stripe_price_id'] ?? null,
'request_ip' => $request->ip(),
'user_agent' => $request->header('User-Agent'),
'exception_class' => \get_class($exception),
'exception_message' => $exception->getMessage(),
'exception_code' => $exception->getCode(),
'exception_trace' => \method_exists($exception, 'getTraceAsString') ? $exception->getTraceAsString() : null,
];
if (method_exists($exception, 'getStripeCode')) {
$context['stripe_code'] = $exception->getStripeCode();
}
Log::error('Academy billing: unexpected error starting checkout', $context);
return $this->checkoutErrorResponse($request, $exception);
}
@@ -161,7 +292,68 @@ final class AcademyBillingController extends Controller
return \redirect()->route('academy.billing.account')->with('error', 'No Stripe billing profile is connected to this account yet.');
}
return $user->redirectToBillingPortal(\route('academy.billing.account'));
try {
return $user->redirectToBillingPortal(\route('academy.billing.account'));
} catch (\Exception $e) {
// If the Stripe customer was deleted or invalid, attempt a recovery similar to checkout.
if ($e instanceof \Stripe\Exception\InvalidRequestException && str_contains($e->getMessage(), 'No such customer')) {
try {
$user->forceFill(['stripe_id' => null])->save();
if (method_exists($user, 'createAsStripeCustomer')) {
$user->createAsStripeCustomer();
} else {
$user->createOrGetStripeCustomer();
}
return $user->redirectToBillingPortal(\route('academy.billing.account'));
} catch (\Throwable $inner) {
$context = [
'user_id' => $user->id ?? null,
'user_email' => $user->email ?? null,
'stripe_id' => $user->stripe_id ?? null,
'route' => 'academy.billing.portal',
'attempt' => 'recreate_customer_and_redirect',
'request_ip' => $request->ip(),
'user_agent' => $request->header('User-Agent'),
'exception_class' => \get_class($inner),
'exception_message' => $inner->getMessage(),
'exception_code' => $inner->getCode(),
'exception_trace' => \method_exists($inner, 'getTraceAsString') ? $inner->getTraceAsString() : null,
];
if (method_exists($inner, 'getStripeCode')) {
$context['stripe_code'] = $inner->getStripeCode();
}
Log::error('Academy billing: failed to recover Stripe customer and open billing portal', $context);
return \redirect()->route('academy.billing.account')->with('error', 'Could not open the subscription manager. Please email [email protected] with your account details and checkout session id if available.');
}
}
$context = [
'user_id' => $user->id ?? null,
'user_email' => $user->email ?? null,
'stripe_id' => $user->stripe_id ?? null,
'route' => 'academy.billing.portal',
'attempt' => 'redirect_to_portal',
'request_ip' => $request->ip(),
'user_agent' => $request->header('User-Agent'),
'exception_class' => \get_class($e),
'exception_message' => $e->getMessage(),
'exception_code' => $e->getCode(),
'exception_trace' => \method_exists($e, 'getTraceAsString') ? $e->getTraceAsString() : null,
];
if (method_exists($e, 'getStripeCode')) {
$context['stripe_code'] = $e->getStripeCode();
}
Log::error('Academy billing: could not open Stripe billing portal', $context);
return \redirect()->route('academy.billing.account')->with('error', 'Could not open the subscription manager. Please email [email protected] with your account details and checkout session id if available.');
}
}
public function success(\Illuminate\Http\Request $request): \Inertia\Response
@@ -171,8 +363,17 @@ final class AcademyBillingController extends Controller
/** @var User|null $user */
$user = $request->user();
$currentTier = $this->access->currentTier($user);
$seo = \app(SeoFactory::class)
->simplePage(
'Academy Subscription Confirmed — Skinbase',
'Payment confirmation for your Skinbase Academy subscription.',
\route('academy.billing.success'),
false,
)
->toArray();
return \Inertia\Inertia::render('Academy/Billing/Success', [
'seo' => $seo,
'message' => 'Payment is being confirmed. Your access will update automatically.',
'currentTier' => $currentTier,
'isSubscribed' => $user instanceof User ? $this->access->hasActiveAcademySubscription($user) : false,
@@ -180,23 +381,129 @@ final class AcademyBillingController extends Controller
'pricing' => \route('academy.pricing'),
'account' => $user ? \route('academy.billing.account') : null,
'academy' => \route('academy.index'),
'reportIssue' => $user ? \route('academy.billing.report_issue') : null,
],
'sessionId' => $request->query('session_id'),
])->rootView('collections');
])->rootView('academy');
}
public function cancel(): \Inertia\Response
{
\abort_unless((bool) \config('academy.enabled', true), 404);
$seo = \app(SeoFactory::class)
->simplePage(
'Academy Billing Canceled — Skinbase',
'Checkout was canceled before starting a Skinbase Academy subscription.',
\route('academy.billing.cancel'),
false,
)
->toArray();
return \Inertia\Inertia::render('Academy/Billing/Cancel', [
'seo' => $seo,
'message' => 'Checkout was canceled. No payment was made.',
'links' => [
'pricing' => \route('academy.pricing'),
'academy' => \route('academy.index'),
],
])->rootView('collections');
])->rootView('academy');
}
public function reportIssue(\Illuminate\Http\Request $request): \Illuminate\Http\RedirectResponse
{
/** @var User|null $user */
$user = $request->user();
if (! $user instanceof User) {
return redirect()->route('login');
}
$validated = $request->validate([
'message' => ['nullable', 'string', 'max:2000'],
'session_id' => ['nullable', 'string'],
'issue_type' => ['nullable', 'string', 'in:billing,payment,upgrade,downgrade,cancel,access,other'],
'contact_email' => ['nullable', 'email:rfc', 'max:255'],
]);
$payload = [
'id' => (string) Str::uuid(),
'submitted_at' => now()->toISOString(),
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
'data' => [
'topic' => 'contact',
'name' => (string) ($user->name ?: $user->username ?: 'Academy billing user'),
'email' => (string) ($validated['contact_email'] ?? $user->email),
'message' => $validated['message'] ?? null,
'issue_type' => $validated['issue_type'] ?? 'billing',
'session_id' => $validated['session_id'] ?? $request->query('session_id'),
'source' => 'academy_billing',
'user_id' => (string) $user->id,
'account_email' => (string) $user->email,
'current_url' => $request->fullUrl(),
],
];
try {
try {
Storage::append('staff_applications.jsonl', json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
} catch (\Throwable $e) {
// best-effort store; do not fail the user when file storage is unavailable
}
$application = null;
try {
$application = StaffApplication::create([
'id' => $payload['id'],
'topic' => 'contact',
'name' => $payload['data']['name'],
'email' => $payload['data']['email'],
'role' => 'academy_billing_support',
'portfolio' => null,
'message' => $payload['data']['message'],
'payload' => $payload,
'ip' => $payload['ip'],
'user_agent' => $payload['user_agent'],
]);
} catch (\Throwable $e) {
// ignore DB errors and fall back to a lightweight model for mail
}
$to = config('mail.from.address');
if ($to) {
if (! $application) {
$application = new StaffApplication([
'topic' => 'contact',
'name' => $payload['data']['name'],
'email' => $payload['data']['email'],
'role' => 'academy_billing_support',
'message' => $payload['data']['message'],
'payload' => $payload,
'ip' => $payload['ip'],
'user_agent' => $payload['user_agent'],
]);
$application->id = $payload['id'];
$application->created_at = now();
}
Mail::to($to)->send(new AcademyAccessIssue(
$user,
$payload['data']['message'] ?? null,
$payload['data']['session_id'] ?? null,
$payload['data']['issue_type'] ?? null,
$payload['data']['email'] ?? null,
));
}
return redirect()->back()->with('success', 'Support request sent — we will verify and activate your access shortly.');
} catch (\Throwable $e) {
report($e);
return redirect()->back()->with('error', 'Could not send the support request. Please try again later or email [email protected].');
}
}
public function account(\Illuminate\Http\Request $request): \Inertia\Response
{
@@ -206,10 +513,19 @@ final class AcademyBillingController extends Controller
/** @var User $user */
$user = $request->user();
$subscription = $this->academySubscription($user);
$seo = \app(SeoFactory::class)
->simplePage(
'Academy Subscription Account — Skinbase',
'Manage your Skinbase Academy subscription and billing access.',
\route('academy.billing.account'),
false,
)
->toArray();
$activePlan = $this->activePlan($user);
return \Inertia\Inertia::render('Academy/Billing/Account', [
'seo' => $seo,
'currentTier' => $this->access->currentTier($user),
'isSubscribed' => $this->access->hasActiveAcademySubscription($user),
'activePlan' => $activePlan ? [
@@ -230,8 +546,10 @@ final class AcademyBillingController extends Controller
'portal' => \route('academy.billing.portal'),
'pricing' => \route('academy.pricing'),
'academy' => \route('academy.index'),
'checkout' => \route('academy.billing.checkout'),
'reportIssue' => \route('academy.billing.report_issue'),
],
])->rootView('collections');
])->rootView('academy');
}
/**
@@ -279,6 +597,7 @@ final class AcademyBillingController extends Controller
'price_display' => $plan['price_display'],
'configured' => $plan['configured'],
'price_id_valid' => $plan['price_id_valid'],
'remote_price_exists' => $plan['remote_price_exists'] ?? false,
]] : [];
return [
@@ -419,4 +738,4 @@ final class AcademyBillingController extends Controller
return \redirect()->route('academy.pricing')->with('error', $message);
}
}
}
@@ -10,6 +10,7 @@ use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyInteractionService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory;
use App\Support\Seo\SeoDataBuilder;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Inertia\Inertia;
@@ -44,6 +45,13 @@ final class AcademyChallengeController extends Controller
route('academy.challenges.index'),
)
->toArray();
$seo = SeoDataBuilder::fromArray($seo)
->breadcrumbs([
['name' => 'Academy', 'url' => route('academy.index')],
['name' => 'Challenges', 'url' => route('academy.challenges.index')],
])
->build()
->toArray();
return Inertia::render('Academy/List', [
'pageType' => 'challenges',
@@ -64,7 +72,7 @@ final class AcademyChallengeController extends Controller
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('collections');
])->rootView('academy');
}
public function show(Request $request, string $slug): Response
@@ -94,12 +102,24 @@ final class AcademyChallengeController extends Controller
'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();
$canonical = route('academy.challenges.show', ['slug' => $challenge->slug]);
$description = Str::limit((string) ($challenge->excerpt ?? $challenge->description ?? ''), 160, '...');
$seo = SeoDataBuilder::fromArray(
app(SeoFactory::class)->collectionPage(
$challenge->title . ' — Skinbase Academy',
$description,
$canonical,
$challenge->cover_image,
)->toArray()
)
->breadcrumbs([
['name' => 'Academy', 'url' => route('academy.index')],
['name' => 'Challenges', 'url' => route('academy.challenges.index')],
['name' => (string) $challenge->title, 'url' => $canonical],
])
->addJsonLd($this->challengeStructuredData($payload, $canonical, $description))
->build()
->toArray();
$interaction = $this->interactions->getInteractionState($request->user(), AcademyAnalyticsContentType::CHALLENGE, (int) $challenge->id);
@@ -126,6 +146,48 @@ final class AcademyChallengeController extends Controller
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => (bool) ($payload['locked'] ?? false),
],
])->rootView('collections');
])->rootView('academy');
}
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function challengeStructuredData(array $payload, string $canonical, string $description): array
{
$image = trim((string) ($payload['cover_image'] ?? ''));
$imageUrl = $image !== '' && preg_match('/^https?:\/\//i', $image) === 1 ? $image : ($image !== '' ? url($image) : null);
$requiredTags = array_values((array) ($payload['required_tags'] ?? []));
$status = strtolower(trim((string) ($payload['status'] ?? '')));
$eventStatus = match ($status) {
'scheduled' => 'https://schema.org/EventScheduled',
'active', 'voting' => 'https://schema.org/EventInProgress',
'completed', 'archived' => 'https://schema.org/EventCompleted',
default => null,
};
return array_filter([
'@context' => 'https://schema.org',
'@type' => 'Event',
'name' => (string) ($payload['title'] ?? 'Skinbase Academy challenge'),
'description' => $description,
'url' => $canonical,
'image' => $imageUrl,
'startDate' => $payload['starts_at'] ?? null,
'endDate' => $payload['ends_at'] ?? null,
'eventStatus' => $eventStatus,
'eventAttendanceMode' => 'https://schema.org/OnlineEventAttendanceMode',
'location' => [
'@type' => 'VirtualLocation',
'url' => $canonical,
],
'organizer' => [
'@type' => 'Organization',
'name' => config('seo.site_name', 'Skinbase'),
'url' => url('/'),
],
'keywords' => $requiredTags !== [] ? $requiredTags : null,
'isAccessibleForFree' => (string) ($payload['access_level'] ?? 'free') === 'free',
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []);
}
}
@@ -35,6 +35,8 @@ final class AcademyChallengeSubmissionController extends Controller
'Submit to ' . $challenge->title . ' — Skinbase Academy',
'Attach one of your artworks to this Academy challenge submission.',
route('academy.challenges.submit', ['slug' => $challenge->slug]),
null,
false,
)->toArray();
return Inertia::render('Academy/ChallengeSubmit', [
@@ -47,7 +49,7 @@ final class AcademyChallengeSubmissionController extends Controller
'published_at' => $artwork->published_at?->toISOString(),
])->values()->all(),
'submitUrl' => route('academy.challenges.submit.store', ['slug' => $challenge->slug]),
])->rootView('collections');
])->rootView('academy');
}
public function store(StoreAcademyChallengeSubmissionRequest $request, string $slug): RedirectResponse
@@ -63,4 +65,4 @@ final class AcademyChallengeSubmissionController extends Controller
return redirect()->route('academy.challenges.show', ['slug' => $challenge->slug])
->with('success', 'Challenge submission received and queued for review.');
}
}
}
@@ -14,6 +14,7 @@ use App\Services\Academy\AcademyCourseNavigationService;
use App\Services\Academy\AcademyCourseProgressService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory;
use App\Support\Seo\SeoDataBuilder;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
@@ -37,6 +38,7 @@ final class AcademyCourseController extends Controller
'difficulty' => ['nullable', 'string', 'max:40'],
'access' => ['nullable', 'string', 'max:40'],
]);
$hasActiveFilters = filled($filters['difficulty'] ?? null) || filled($filters['access'] ?? null);
$query = AcademyCourse::query()->published()->ordered();
@@ -77,6 +79,13 @@ final class AcademyCourseController extends Controller
)
->toArray();
if ($hasActiveFilters) {
$seo = SeoDataBuilder::fromArray($seo)
->indexable(false)
->build()
->toArray();
}
return Inertia::render('Academy/CoursesIndex', [
'seo' => $seo,
'title' => 'Academy courses',
@@ -85,6 +94,13 @@ final class AcademyCourseController extends Controller
'featuredCourses' => $featuredCourses->all(),
'filters' => $filters,
'pricingUrl' => route('academy.pricing'),
'lessonsUrl' => route('academy.lessons.index'),
'promptLibraryUrl' => route('academy.prompts.index'),
'academyAccess' => array_merge($this->access->accessSummary($request->user()), [
'billingUrl' => $request->user() && (bool) config('academy_billing.enabled', false)
? route('academy.billing.account')
: route('academy.pricing'),
]),
'analytics' => [
'enabled' => true,
'contentType' => null,
@@ -95,7 +111,7 @@ final class AcademyCourseController extends Controller
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('collections');
])->rootView('academy');
}
public function show(Request $request, AcademyCourse $course): Response
@@ -211,6 +227,6 @@ final class AcademyCourseController extends Controller
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => false,
],
])->rootView('collections');
])->rootView('academy');
}
}
}
@@ -13,6 +13,7 @@ use App\Services\Academy\AcademyCourseNavigationService;
use App\Services\Academy\AcademyCourseProgressService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory;
use App\Support\Seo\SeoDataBuilder;
use Illuminate\Support\Str;
use Illuminate\Http\Request;
use Inertia\Inertia;
@@ -52,7 +53,7 @@ final class AcademyCourseLessonController extends Controller
->all();
$payload = $this->access->courseLessonPayload($courseLesson, $request->user(), true);
$canonical = route('academy.courses.lessons.show', ['course' => $course->slug, 'lesson' => $lesson->slug]);
$canonical = route('academy.lessons.show', ['slug' => $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)),
@@ -70,6 +71,10 @@ final class AcademyCourseLessonController extends Controller
$lesson->updated_at?->toAtomString(),
(string) $course->title,
)->toArray();
$seo = SeoDataBuilder::fromArray($seo)
->indexable(false)
->build()
->toArray();
$interaction = $this->interactions->getInteractionState($request->user(), AcademyAnalyticsContentType::LESSON, (int) $lesson->id);
@@ -119,6 +124,6 @@ final class AcademyCourseLessonController extends Controller
],
'outline' => $courseOutline,
],
])->rootView('collections');
])->rootView('academy');
}
}
}
@@ -60,10 +60,16 @@ final class AcademyHomeController extends Controller
return Inertia::render('Academy/Index', [
'seo' => $seo,
'pricingUrl' => route('academy.pricing'),
'academyAccess' => array_merge($this->access->accessSummary($request->user()), [
'billingUrl' => $request->user() && (bool) config('academy_billing.enabled', false)
? route('academy.billing.account')
: route('academy.pricing'),
]),
'links' => [
'lessons' => route('academy.lessons.index'),
'courses' => route('academy.courses.index'),
'prompts' => route('academy.prompts.index'),
'promptPopular' => route('academy.prompts.popular'),
'packs' => route('academy.packs.index'),
'challenges' => route('academy.challenges.index'),
],
@@ -92,6 +98,6 @@ final class AcademyHomeController extends Controller
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('collections');
])->rootView('academy');
}
}
}
@@ -13,6 +13,8 @@ use App\Services\Academy\AcademyCacheService;
use App\Services\Academy\AcademyInteractionService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory;
use App\Support\Seo\SeoDataBuilder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Inertia\Inertia;
@@ -27,7 +29,7 @@ final class AcademyLessonController extends Controller
private readonly AcademyInteractionService $interactions,
) {}
public function index(Request $request): Response
public function index(Request $request): Response|JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
@@ -36,6 +38,9 @@ final class AcademyLessonController extends Controller
'category' => ['nullable', 'string', 'max:140'],
'difficulty' => ['nullable', 'string', 'max:40'],
]);
$hasActiveFilters = filled($filters['q'] ?? null)
|| filled($filters['category'] ?? null)
|| filled($filters['difficulty'] ?? null);
$query = AcademyLesson::query()
->with('category')
@@ -65,6 +70,10 @@ final class AcademyLessonController extends Controller
$this->analytics->trackSearch((string) $filters['q'], (int) $lessons->total(), array_filter($filters), $request);
}
if ($request->expectsJson()) {
return response()->json($lessons);
}
$seo = app(SeoFactory::class)
->collectionListing(
'Academy Lessons — Skinbase',
@@ -73,15 +82,33 @@ final class AcademyLessonController extends Controller
)
->toArray();
if ($hasActiveFilters) {
$seo = SeoDataBuilder::fromArray($seo)
->indexable(false)
->build()
->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,
'breadcrumbs' => [
['label' => 'Academy', 'href' => route('academy.index')],
['label' => 'Lessons', 'href' => route('academy.lessons.index')],
],
'items' => $lessons,
'filters' => $filters,
'categories' => $this->cache->categoriesByType('lesson'),
'pricingUrl' => route('academy.pricing'),
'coursesUrl' => route('academy.courses.index'),
'promptLibraryUrl' => route('academy.prompts.index'),
'academyAccess' => array_merge($this->access->accessSummary($request->user()), [
'billingUrl' => $request->user() && (bool) config('academy_billing.enabled', false)
? route('academy.billing.account')
: route('academy.pricing'),
]),
'analytics' => [
'enabled' => true,
'contentType' => filled($filters['q'] ?? null) ? AcademyAnalyticsContentType::SEARCH : null,
@@ -96,7 +123,7 @@ final class AcademyLessonController extends Controller
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('collections');
])->rootView('academy');
}
public function show(Request $request, string $slug): Response
@@ -204,6 +231,6 @@ final class AcademyLessonController extends Controller
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => (bool) ($payload['locked'] ?? false),
],
])->rootView('collections');
])->rootView('academy');
}
}
@@ -79,6 +79,6 @@ final class AcademyPricingController extends Controller
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('collections');
])->rootView('academy');
}
}
}
@@ -10,11 +10,14 @@ use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyAnalyticsService;
use App\Services\Academy\AcademyCacheService;
use App\Services\Academy\AcademyInteractionService;
use App\Services\Academy\AcademyPopularityService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory;
use App\Support\Seo\SeoDataBuilder;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
@@ -25,6 +28,7 @@ final class AcademyPromptController extends Controller
private readonly AcademyCacheService $cache,
private readonly AcademyAnalyticsService $analytics,
private readonly AcademyInteractionService $interactions,
private readonly AcademyPopularityService $popularity,
) {
}
@@ -38,6 +42,10 @@ final class AcademyPromptController extends Controller
'difficulty' => ['nullable', 'string', 'max:40'],
'tag' => ['nullable', 'string', 'max:60'],
]);
$hasActiveFilters = filled($filters['q'] ?? null)
|| filled($filters['category'] ?? null)
|| filled($filters['difficulty'] ?? null)
|| filled($filters['tag'] ?? null);
$query = AcademyPromptTemplate::query()
->with('category')
@@ -84,18 +92,41 @@ final class AcademyPromptController extends Controller
)
->toArray();
if ($hasActiveFilters) {
$seo = SeoDataBuilder::fromArray($seo)
->indexable(false)
->build()
->toArray();
}
return Inertia::render('Academy/List', [
'pageType' => 'prompts',
'promptView' => 'library',
'title' => 'Prompt library',
'description' => 'Reusable prompt templates for wallpapers, worlds, mascots, covers, and digital art workflows.',
'seo' => $seo,
'breadcrumbs' => [
['label' => 'Academy', 'href' => route('academy.index')],
['label' => 'Prompt Library', 'href' => route('academy.prompts.index')],
],
'items' => $prompts,
'filters' => $filters,
'categories' => $this->cache->categoriesByType('prompt'),
'pricingUrl' => route('academy.pricing'),
'coursesUrl' => route('academy.courses.index'),
'packsUrl' => route('academy.packs.index'),
'promptPopularUrl' => route('academy.prompts.popular'),
'promptLibraryUrl' => route('academy.prompts.index'),
'academyAccess' => array_merge($this->access->accessSummary($request->user()), [
'billingUrl' => $request->user() && (bool) config('academy_billing.enabled', false)
? route('academy.billing.account')
: route('academy.pricing'),
]),
'featuredPrompts' => $this->featuredPromptPayloads($request->user()),
'popularPrompts' => $this->popularPromptPayloads($request->user()),
'analytics' => [
'enabled' => true,
'contentType' => filled($filters['q'] ?? null) ? AcademyAnalyticsContentType::SEARCH : null,
'contentType' => AcademyAnalyticsContentType::PROMPT_LIBRARY,
'contentId' => null,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_prompts_index',
@@ -107,7 +138,194 @@ final class AcademyPromptController extends Controller
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('collections');
])->rootView('academy');
}
public function popular(Request $request): Response
{
abort_unless((bool) config('academy.enabled', true), 404);
$validated = $request->validate([
'period' => ['nullable', 'string', 'in:7d,30d,90d'],
]);
$selectedPeriod = $this->selectedPopularPromptPeriod($validated['period'] ?? null);
$from = now()->subDays($selectedPeriod['days'] - 1)->startOfDay();
$to = now()->endOfDay();
$rows = DB::query()
->fromSub(
$this->popularity->queryBetween($from, $to)
->where('content_type', AcademyAnalyticsContentType::PROMPT)
->whereNotNull('content_id')
->selectRaw('content_id, sum(views) as views, sum(prompt_copies) as prompt_copies, sum(popularity_score) as popularity_score')
->groupBy('content_id'),
'prompt_rankings'
)
->orderByDesc('popularity_score')
->orderByDesc('prompt_copies')
->orderByDesc('views')
->paginate(12)
->withQueryString();
$prompts = AcademyPromptTemplate::query()
->with('category')
->active()
->published()
->whereIn('id', $rows->pluck('content_id')->map(static fn ($value): int => (int) $value)->all())
->get()
->keyBy('id');
$baseRank = (($rows->currentPage() - 1) * $rows->perPage());
$rows->setCollection(
$rows->getCollection()
->values()
->map(function (object $row, int $index) use ($prompts, $request, $baseRank, $selectedPeriod): ?array {
$prompt = $prompts->get((int) $row->content_id);
if (! $prompt instanceof AcademyPromptTemplate) {
return null;
}
$payload = $this->access->promptPayload($prompt, $request->user());
$payload['ranking'] = [
'rank' => $baseRank + $index + 1,
'views' => max(0, (int) ($row->views ?? 0)),
'prompt_copies' => max(0, (int) ($row->prompt_copies ?? 0)),
'popularity_score' => round((float) ($row->popularity_score ?? 0), 2),
];
$payload['spotlight'] = [
'eyebrow' => max(0, (int) ($row->prompt_copies ?? 0)) > 0
? sprintf('%d copies %s', (int) $row->prompt_copies, $selectedPeriod['eyebrow_suffix'])
: sprintf('%d views %s', (int) $row->views, $selectedPeriod['eyebrow_suffix']),
];
return $payload;
})
->filter()
->values()
);
$seo = app(SeoFactory::class)
->collectionListing(
sprintf('%s Prompts — Skinbase Academy', $selectedPeriod['title_prefix']),
sprintf('See which Skinbase Academy prompt templates are driving the most views and copies %s.', $selectedPeriod['description_suffix']),
route('academy.prompts.popular', $request->query()),
)
->toArray();
if ($selectedPeriod['value'] !== '30d') {
$seo = SeoDataBuilder::fromArray($seo)
->indexable(false)
->build()
->toArray();
}
return Inertia::render('Academy/List', [
'pageType' => 'prompts',
'promptView' => 'popular',
'title' => sprintf('%s prompts', $selectedPeriod['title_prefix']),
'description' => sprintf('The prompt templates getting the most momentum from views and copies across the Academy %s.', $selectedPeriod['description_suffix']),
'seo' => $seo,
'breadcrumbs' => [
['label' => 'Academy', 'href' => route('academy.index')],
['label' => 'Prompt Library', 'href' => route('academy.prompts.index')],
['label' => 'Popular Prompts', 'href' => route('academy.prompts.popular')],
],
'items' => $rows,
'filters' => [],
'categories' => [],
'pricingUrl' => route('academy.pricing'),
'coursesUrl' => route('academy.courses.index'),
'packsUrl' => route('academy.packs.index'),
'promptPopularUrl' => route('academy.prompts.popular'),
'promptLibraryUrl' => route('academy.prompts.index'),
'academyAccess' => array_merge($this->access->accessSummary($request->user()), [
'billingUrl' => $request->user() && (bool) config('academy_billing.enabled', false)
? route('academy.billing.account')
: route('academy.pricing'),
]),
'popularPeriod' => [
'value' => $selectedPeriod['value'],
'label' => $selectedPeriod['label'],
'description' => $selectedPeriod['description'],
],
'popularPeriods' => collect($this->popularPromptPeriods())
->map(fn (array $period): array => [
'value' => $period['value'],
'label' => $period['label'],
'description' => $period['description'],
'href' => route('academy.prompts.popular', ['period' => $period['value']]),
'active' => $period['value'] === $selectedPeriod['value'],
])
->values()
->all(),
'featuredPrompts' => $this->featuredPromptPayloads($request->user()),
'popularPrompts' => [],
'analytics' => [
'enabled' => true,
'contentType' => AcademyAnalyticsContentType::PROMPT_POPULAR,
'contentId' => null,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_prompts_popular',
'trackingKey' => sprintf('period:%s', $selectedPeriod['value']),
'metadata' => [
'period' => $selectedPeriod['value'],
'period_days' => $selectedPeriod['days'],
],
'search' => null,
'isPremium' => false,
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('academy');
}
/**
* @return array<int, array<string, mixed>>
*/
private function popularPromptPeriods(): array
{
return [
[
'value' => '7d',
'days' => 7,
'label' => '7 days',
'description' => 'Fresh momentum from the last 7 days.',
'title_prefix' => 'Top 7-day',
'description_suffix' => 'in the last 7 days',
'eyebrow_suffix' => 'in the last 7 days',
],
[
'value' => '30d',
'days' => 30,
'label' => '30 days',
'description' => 'The default monthly view of prompt momentum.',
'title_prefix' => 'Popular',
'description_suffix' => 'this month',
'eyebrow_suffix' => 'this month',
],
[
'value' => '90d',
'days' => 90,
'label' => '90 days',
'description' => 'Longer-running prompt momentum across the quarter.',
'title_prefix' => 'Top 90-day',
'description_suffix' => 'in the last 90 days',
'eyebrow_suffix' => 'in the last 90 days',
],
];
}
/**
* @return array<string, mixed>
*/
private function selectedPopularPromptPeriod(?string $value): array
{
return collect($this->popularPromptPeriods())
->first(fn (array $period): bool => $period['value'] === $value)
?? $this->popularPromptPeriods()[1];
}
public function show(Request $request, string $slug): Response
@@ -167,7 +385,7 @@ final class AcademyPromptController extends Controller
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => (bool) ($payload['locked'] ?? false),
],
])->rootView('collections');
])->rootView('academy');
}
/**
@@ -201,4 +419,70 @@ final class AcademyPromptController extends Controller
],
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []);
}
}
/**
* @return array<int, array<string, mixed>>
*/
private function featuredPromptPayloads(mixed $viewer, int $limit = 4): array
{
return collect($this->cache->featuredPrompts())
->take($limit)
->map(function (AcademyPromptTemplate $prompt) use ($viewer): array {
$payload = $this->access->promptPayload($prompt, $viewer);
$payload['spotlight'] = [
'eyebrow' => $prompt->prompt_of_week ? 'Prompt of the week' : 'Featured pick',
];
return $payload;
})
->values()
->all();
}
/**
* @return array<int, array<string, mixed>>
*/
private function popularPromptPayloads(mixed $viewer, int $limit = 4): array
{
$rows = $this->popularity->queryBetween(now()->subDays(29)->startOfDay(), now()->endOfDay())
->where('content_type', AcademyAnalyticsContentType::PROMPT)
->whereNotNull('content_id')
->selectRaw('content_id, sum(views) as views, sum(prompt_copies) as prompt_copies, sum(popularity_score) as popularity_score')
->groupBy('content_id')
->orderByDesc('popularity_score')
->limit($limit)
->get();
if ($rows->isEmpty()) {
return [];
}
$prompts = AcademyPromptTemplate::query()
->with('category')
->active()
->published()
->whereIn('id', $rows->pluck('content_id')->all())
->get()
->keyBy('id');
return $rows->map(function ($row) use ($prompts, $viewer): ?array {
$prompt = $prompts->get((int) $row->content_id);
if (! $prompt instanceof AcademyPromptTemplate) {
return null;
}
$payload = $this->access->promptPayload($prompt, $viewer);
$copies = max(0, (int) ($row->prompt_copies ?? 0));
$views = max(0, (int) ($row->views ?? 0));
$payload['spotlight'] = [
'eyebrow' => $copies > 0 ? sprintf('%d copies this month', $copies) : sprintf('%d views this month', $views),
];
return $payload;
})
->filter()
->values()
->all();
}
}
@@ -10,6 +10,7 @@ use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyInteractionService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory;
use App\Support\Seo\SeoDataBuilder;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Inertia\Inertia;
@@ -29,7 +30,6 @@ final class AcademyPromptPackController extends Controller
abort_unless((bool) config('academy.enabled', true), 404);
$packs = AcademyPromptPack::query()
->with('prompts')
->active()
->published()
->latest('published_at')
@@ -45,6 +45,13 @@ final class AcademyPromptPackController extends Controller
route('academy.packs.index'),
)
->toArray();
$seo = SeoDataBuilder::fromArray($seo)
->breadcrumbs([
['name' => 'Academy', 'url' => route('academy.index')],
['name' => 'Prompt Packs', 'url' => route('academy.packs.index')],
])
->build()
->toArray();
return Inertia::render('Academy/List', [
'pageType' => 'packs',
@@ -57,7 +64,7 @@ final class AcademyPromptPackController extends Controller
'pricingUrl' => route('academy.pricing'),
'analytics' => [
'enabled' => true,
'contentType' => null,
'contentType' => AcademyAnalyticsContentType::PROMPT_PACK_LIBRARY,
'contentId' => null,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_packs_index',
@@ -65,7 +72,7 @@ final class AcademyPromptPackController extends Controller
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('collections');
])->rootView('academy');
}
public function show(Request $request, string $slug): Response
@@ -80,12 +87,24 @@ final class AcademyPromptPackController extends Controller
->firstOrFail();
$payload = $this->access->packPayload($pack, $request->user(), true);
$seo = app(SeoFactory::class)->collectionPage(
$pack->title . ' — Skinbase Academy',
Str::limit((string) ($pack->excerpt ?? $pack->description ?? ''), 160, '...'),
route('academy.packs.show', ['slug' => $pack->slug]),
$pack->cover_image,
)->toArray();
$canonical = route('academy.packs.show', ['slug' => $pack->slug]);
$description = Str::limit((string) ($pack->excerpt ?? $pack->description ?? ''), 160, '...');
$seo = SeoDataBuilder::fromArray(
app(SeoFactory::class)->collectionPage(
$pack->title . ' — Skinbase Academy',
$description,
$canonical,
$pack->cover_image,
)->toArray()
)
->breadcrumbs([
['name' => 'Academy', 'url' => route('academy.index')],
['name' => 'Prompt Packs', 'url' => route('academy.packs.index')],
['name' => (string) $pack->title, 'url' => $canonical],
])
->addJsonLd($this->packStructuredData($payload, $canonical, $description))
->build()
->toArray();
$interaction = $this->interactions->getInteractionState($request->user(), AcademyAnalyticsContentType::PROMPT_PACK, (int) $pack->id);
@@ -111,6 +130,60 @@ final class AcademyPromptPackController extends Controller
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => (bool) ($payload['locked'] ?? false),
],
])->rootView('collections');
])->rootView('academy');
}
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function packStructuredData(array $payload, string $canonical, string $description): array
{
$image = trim((string) ($payload['cover_image'] ?? ''));
$imageUrl = $image !== '' && preg_match('/^https?:\/\//i', $image) === 1 ? $image : ($image !== '' ? url($image) : null);
$keywords = array_values((array) ($payload['tags'] ?? []));
$isFree = (string) ($payload['access_level'] ?? 'free') === 'free';
$promptEntries = collect((array) ($payload['prompts'] ?? []))
->map(function (array $prompt): ?array {
$title = trim((string) ($prompt['title'] ?? ''));
$slug = trim((string) ($prompt['slug'] ?? ''));
if ($title === '' || $slug === '') {
return null;
}
return [
'@type' => 'ListItem',
'position' => null,
'item' => [
'@type' => 'CreativeWork',
'name' => $title,
'url' => route('academy.prompts.show', ['slug' => $slug]),
],
];
})
->filter()
->values()
->map(function (array $item, int $index): array {
$item['position'] = $index + 1;
return $item;
})
->all();
return array_filter([
'@context' => 'https://schema.org',
'@type' => ['CreativeWork', 'LearningResource'],
'name' => (string) ($payload['title'] ?? 'Skinbase Academy prompt pack'),
'description' => $description,
'url' => $canonical,
'image' => $imageUrl,
'keywords' => $keywords !== [] ? $keywords : null,
'isAccessibleForFree' => $isFree,
'hasPart' => $promptEntries !== [] ? [
'@type' => 'ItemList',
'itemListElement' => $promptEntries,
] : null,
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []);
}
}
@@ -0,0 +1,266 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Jobs\SyncAdsenseJob;
use App\Models\AdsenseConnection;
use App\Services\Adsense\AdsenseAnalyticsQueryService;
use App\Services\Adsense\AdsenseApiClient;
use App\Services\Adsense\AdsenseLogSanitizer;
use App\Services\Adsense\AdsenseOAuthService;
use App\Services\Adsense\Exceptions\AdsenseApiException;
use App\Services\Adsense\Exceptions\AdsenseAuthorizationException;
use App\Services\Adsense\Exceptions\AdsenseOAuthException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Inertia\Inertia;
use Inertia\Response;
final class AdsenseAnalyticsController extends Controller
{
public function __construct(
private readonly AdsenseOAuthService $oauth,
private readonly AdsenseApiClient $api,
private readonly AdsenseAnalyticsQueryService $query,
) {}
public function index(Request $request): Response
{
$connection = AdsenseConnection::current();
$range = $this->query->resolveRange(
$request->query('period'),
$request->string('from')->toString() ?: null,
$request->string('to')->toString() ?: null,
);
$dashboard = null;
if ($connection !== null && $connection->isConnected() && filled($connection->account_resource_name)) {
$dashboard = $this->query->dashboard(
$connection,
$range['from'],
$range['to'],
$range['previous_from'],
$range['previous_to'],
);
}
return Inertia::render('Admin/Adsense/Index', [
'connection' => $this->connectionPayload($connection),
'configured' => $this->oauth->isConfigured(),
'pendingAccounts' => $request->session()->get(AdsenseOAuthService::SESSION_PENDING_ACCOUNTS_KEY, []),
'recommendedPlacements' => array_values((array) config('adsense.recommended_placement_names', [])),
'range' => [
'period' => $range['period'],
'from' => $range['from']->toDateString(),
'to' => $range['to']->toDateString(),
'previous_from' => $range['previous_from']->toDateString(),
'previous_to' => $range['previous_to']->toDateString(),
],
'dashboard' => $dashboard,
'routes' => [
'index' => route('admin.adsense.index'),
'connect' => route('admin.adsense.connect'),
'account' => route('admin.adsense.account'),
'sync' => route('admin.adsense.sync'),
'disconnect' => route('admin.adsense.disconnect'),
],
]);
}
public function connect(Request $request): RedirectResponse
{
if (! $this->oauth->isConfigured()) {
return redirect()
->route('admin.adsense.index')
->with('error', 'Google AdSense OAuth is not configured.');
}
try {
return redirect()->away($this->oauth->authorizationUrl($request, true));
} catch (AdsenseOAuthException $exception) {
return redirect()
->route('admin.adsense.index')
->with('error', $exception->getMessage());
}
}
public function callback(Request $request): RedirectResponse
{
try {
$code = $this->oauth->assertValidCallback($request);
$tokens = $this->oauth->exchangeAuthorizationCode($code);
$connection = AdsenseConnection::current() ?? new AdsenseConnection;
$connection->status = AdsenseConnection::STATUS_PENDING;
$this->oauth->persistTokens($connection, $tokens, $request->user()?->id);
$accounts = $this->api->listAccounts($connection);
} catch (AdsenseOAuthException|AdsenseAuthorizationException|AdsenseApiException $exception) {
Log::warning('AdSense OAuth callback failed.', AdsenseLogSanitizer::context([
'message' => $exception->getMessage(),
]));
return redirect()
->route('admin.adsense.index')
->with('error', $exception->getMessage());
}
$normalized = collect($accounts)
->map(function (array $account): ?array {
$name = (string) ($account['name'] ?? '');
if ($name === '') {
return null;
}
return [
'resource_name' => $name,
'display_name' => (string) ($account['displayName'] ?? $name),
];
})
->filter()
->values()
->all();
if ($normalized === []) {
$connection->status = AdsenseConnection::STATUS_ERROR;
$connection->last_error = 'No Google AdSense accounts were available for this Google account.';
$connection->save();
$request->session()->forget(AdsenseOAuthService::SESSION_PENDING_ACCOUNTS_KEY);
return redirect()
->route('admin.adsense.index')
->with('error', $connection->last_error);
}
if (count($normalized) === 1) {
$this->assignAccount($connection, $normalized[0]['resource_name'], $normalized[0]['display_name']);
$this->queueAdsenseSync($connection, 30);
$request->session()->forget(AdsenseOAuthService::SESSION_PENDING_ACCOUNTS_KEY);
return redirect()
->route('admin.adsense.index')
->with('success', 'Google AdSense connected. Initial synchronization has started.');
}
$request->session()->put(AdsenseOAuthService::SESSION_PENDING_ACCOUNTS_KEY, $normalized);
$connection->status = AdsenseConnection::STATUS_PENDING;
$connection->save();
return redirect()
->route('admin.adsense.index')
->with('warning', 'Select the Google AdSense account to use for Skinbase analytics.');
}
public function selectAccount(Request $request): RedirectResponse
{
$validated = $request->validate([
'account_resource_name' => ['required', 'string', 'max:128'],
]);
$connection = AdsenseConnection::current();
if ($connection === null || ! $connection->hasRefreshToken()) {
return redirect()
->route('admin.adsense.index')
->with('error', 'Connect Google AdSense before selecting an account.');
}
$pending = collect($request->session()->get(AdsenseOAuthService::SESSION_PENDING_ACCOUNTS_KEY, []));
$selected = $pending->firstWhere('resource_name', $validated['account_resource_name']);
if (! is_array($selected)) {
return redirect()
->route('admin.adsense.index')
->with('error', 'The selected AdSense account is not available.');
}
$this->assignAccount(
$connection,
(string) $selected['resource_name'],
(string) ($selected['display_name'] ?? $selected['resource_name']),
);
$this->queueAdsenseSync($connection, 30);
$request->session()->forget(AdsenseOAuthService::SESSION_PENDING_ACCOUNTS_KEY);
return redirect()
->route('admin.adsense.index')
->with('success', 'AdSense account selected. Initial synchronization has started.');
}
public function sync(Request $request): RedirectResponse
{
$connection = AdsenseConnection::current();
if ($connection === null || ! $connection->isConnected()) {
return redirect()
->route('admin.adsense.index')
->with('error', $connection?->needsReconnect()
? 'AdSense authorization expired or was revoked. Reconnect Google AdSense.'
: 'Google AdSense is not connected.');
}
$this->queueAdsenseSync($connection, 3);
return redirect()
->route('admin.adsense.index')
->with('success', 'AdSense synchronization has been queued.');
}
public function disconnect(): RedirectResponse
{
$connection = AdsenseConnection::current();
if ($connection !== null) {
$this->oauth->disconnect($connection);
}
return redirect()
->route('admin.adsense.index')
->with('success', 'Google AdSense has been disconnected. Historical statistics were kept.');
}
/**
* @return array<string, mixed>|null
*/
private function connectionPayload(?AdsenseConnection $connection): ?array
{
if ($connection === null) {
return null;
}
return [
'status' => $connection->status,
'account_resource_name' => $connection->account_resource_name,
'account_display_name' => $connection->account_display_name,
'connected_at' => optional($connection->connected_at)?->toIso8601String(),
'last_sync_attempt_at' => optional($connection->last_sync_attempt_at)?->toIso8601String(),
'last_successful_sync_at' => optional($connection->last_successful_sync_at)?->toIso8601String(),
'last_error' => $connection->last_error,
'needs_reconnect' => $connection->needsReconnect(),
];
}
private function assignAccount(AdsenseConnection $connection, string $resourceName, string $displayName): void
{
$connection->account_resource_name = $resourceName;
$connection->account_display_name = $displayName;
$connection->status = AdsenseConnection::STATUS_CONNECTED;
$connection->last_error = null;
if ($connection->connected_at === null) {
$connection->connected_at = now();
}
$connection->save();
}
private function queueAdsenseSync(AdsenseConnection $connection, int $days): void
{
$to = now()->startOfDay();
$from = $to->copy()->subDays(max(1, $days) - 1);
SyncAdsenseJob::dispatch(
(int) $connection->id,
$from->toDateString(),
$to->toDateString(),
);
}
}
@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Jobs\RunSecurityReportScanJob;
use App\Models\SecurityReport;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class SecurityReportController extends Controller
{
public function index(): Response
{
abort_unless((bool) config('security-report.enabled', true), 404);
$latest = SecurityReport::query()->latest('id')->with('user:id,name,username')->first();
$reports = SecurityReport::query()
->with('user:id,name,username')
->latest('id')
->paginate(20)
->through(fn (SecurityReport $report): array => $this->mapListItem($report));
return Inertia::render('Admin/System/SecurityReportIndex', [
'latest' => $latest ? $this->mapDetail($latest) : null,
'reports' => $reports,
'canRunScan' => true,
])->rootView('moderation');
}
public function show(SecurityReport $securityReport): Response
{
abort_unless((bool) config('security-report.enabled', true), 404);
$securityReport->load('user:id,name,username');
return Inertia::render('Admin/System/SecurityReportShow', [
'report' => $this->mapDetail($securityReport),
])->rootView('moderation');
}
public function run(Request $request): RedirectResponse
{
abort_unless((bool) config('security-report.enabled', true), 404);
RunSecurityReportScanJob::dispatch($request->user()?->id);
return redirect()
->route('admin.system.security-report.index')
->with('success', 'Security scan has been queued.');
}
/**
* @return array<string, mixed>
*/
private function mapListItem(SecurityReport $report): array
{
return [
'id' => (int) $report->id,
'status' => (string) $report->status,
'risk_label' => (string) $report->risk_label,
'finished_at' => optional($report->finished_at)?->toIso8601String(),
'total_critical' => (int) $report->total_critical,
'total_high' => (int) $report->total_high,
'total_medium' => (int) $report->total_medium,
'total_low' => (int) $report->total_low,
'composer_outdated_count' => (int) $report->composer_outdated_count,
'npm_outdated_count' => (int) $report->npm_outdated_count,
'show_url' => route('admin.system.security-report.show', ['securityReport' => $report]),
'triggered_by' => (string) ($report->triggered_by ?? ''),
'user' => $report->user ? [
'id' => (int) $report->user->id,
'name' => (string) $report->user->name,
'username' => (string) ($report->user->username ?? ''),
] : null,
];
}
/**
* @return array<string, mixed>
*/
private function mapDetail(SecurityReport $report): array
{
return [
'id' => (int) $report->id,
'status' => (string) $report->status,
'risk_label' => (string) $report->risk_label,
'started_at' => optional($report->started_at)?->toIso8601String(),
'finished_at' => optional($report->finished_at)?->toIso8601String(),
'composer_critical' => (int) $report->composer_critical,
'composer_high' => (int) $report->composer_high,
'composer_medium' => (int) $report->composer_medium,
'composer_low' => (int) $report->composer_low,
'composer_unknown' => (int) $report->composer_unknown,
'npm_critical' => (int) $report->npm_critical,
'npm_high' => (int) $report->npm_high,
'npm_moderate' => (int) $report->npm_moderate,
'npm_low' => (int) $report->npm_low,
'npm_info' => (int) $report->npm_info,
'npm_unknown' => (int) $report->npm_unknown,
'total_critical' => (int) $report->total_critical,
'total_high' => (int) $report->total_high,
'total_medium' => (int) $report->total_medium,
'total_low' => (int) $report->total_low,
'total_unknown' => (int) $report->total_unknown,
'composer_outdated_count' => (int) $report->composer_outdated_count,
'npm_outdated_count' => (int) $report->npm_outdated_count,
'summary' => $report->summary ?? [],
'triggered_by' => (string) ($report->triggered_by ?? ''),
'error_message' => (string) ($report->error_message ?? ''),
'show_url' => route('admin.system.security-report.show', ['securityReport' => $report]),
'index_url' => route('admin.system.security-report.index'),
'composer_audit' => $report->composer_audit,
'composer_outdated' => $report->composer_outdated,
'npm_audit' => $report->npm_audit,
'npm_outdated' => $report->npm_outdated,
'composer_advisories' => $report->composerAdvisories(),
'npm_vulnerabilities' => $report->npmVulnerabilities(),
'user' => $report->user ? [
'id' => (int) $report->user->id,
'name' => (string) $report->user->name,
'username' => (string) ($report->user->username ?? ''),
] : null,
];
}
}
@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Jobs\IndexArtworkJob;
use App\Models\Artwork;
use App\Services\NotificationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class ArtworkModerationController extends Controller
{
public function pending(): JsonResponse
{
$artworks = Artwork::query()
->with([
'user:id,name,username,level,email',
'tags:id,name,slug',
'categories:id,name,slug',
])
->where('artwork_status', 'review')
->where('is_approved', false)
->latest('created_at')
->limit(100)
->get([
'id', 'user_id', 'title', 'description', 'hash', 'thumb_ext',
'artwork_status', 'moderation_note', 'created_at', 'slug',
'is_mature', 'maturity_status', 'visibility',
])
->map(fn (Artwork $artwork): array => $this->present($artwork))
->values();
return response()->json(['data' => $artworks], Response::HTTP_OK);
}
public function approve(int $id, Request $request, NotificationService $notifications): JsonResponse
{
$artwork = Artwork::query()->with('user')->where('artwork_status', 'review')->find($id);
if (! $artwork) {
return response()->json(['message' => 'Artwork not found in review queue.'], Response::HTTP_NOT_FOUND);
}
$artwork->forceFill([
'is_approved' => true,
'is_public' => $artwork->visibility !== Artwork::VISIBILITY_PRIVATE,
'artwork_status' => 'published',
'published_at' => now(),
'approval_source' => 'moderator',
'moderated_at' => now(),
'moderated_by' => $request->user()->id,
'moderation_note' => $request->input('note'),
])->save();
IndexArtworkJob::dispatch((int) $artwork->id);
if ($artwork->user) {
$notifications->notifyArtworkApproved($artwork->user, $request->user(), $artwork);
}
return response()->json(['success' => true, 'id' => $artwork->id, 'status' => 'published']);
}
public function reject(int $id, Request $request, NotificationService $notifications): JsonResponse
{
$artwork = Artwork::query()->with('user')->where('artwork_status', 'review')->find($id);
if (! $artwork) {
return response()->json(['message' => 'Artwork not found in review queue.'], Response::HTTP_NOT_FOUND);
}
$note = (string) $request->input('note', 'Rejected during upload moderation.');
$artwork->forceFill([
'is_approved' => false,
'is_public' => false,
'artwork_status' => 'rejected',
'published_at' => null,
'moderated_at' => now(),
'moderated_by' => $request->user()->id,
'moderation_note' => $note,
])->save();
IndexArtworkJob::dispatch((int) $artwork->id);
if ($artwork->user) {
$notifications->notifyArtworkRejected($artwork->user, $request->user(), $artwork, $note);
}
return response()->json(['success' => true, 'id' => $artwork->id, 'status' => 'rejected']);
}
private function present(Artwork $artwork): array
{
$previewUrl = $artwork->thumbUrl('md') ?: $artwork->thumbUrl('sm');
$previewLgUrl = $artwork->thumbUrl('lg') ?: $previewUrl;
return [
'id' => (int) $artwork->id,
'title' => (string) ($artwork->title ?: '(untitled artwork)'),
'description' => (string) ($artwork->description ?? ''),
'type' => 'artwork',
'preview_url' => $previewUrl,
'preview_lg_url' => $previewLgUrl,
'tags' => $artwork->tags
->map(fn ($tag): string => trim((string) ($tag->name ?: $tag->slug)))
->filter()
->values()
->all(),
'categories' => $artwork->categories
->map(fn ($category): string => trim((string) ($category->name ?: $category->slug)))
->filter()
->values()
->all(),
'user' => $artwork->user ? [
'id' => (int) $artwork->user->id,
'name' => (string) $artwork->user->name,
'username' => $artwork->user->username,
] : null,
'slug' => $artwork->slug,
'is_mature' => (bool) $artwork->is_mature,
'maturity_status' => $artwork->maturity_status,
'visibility' => $artwork->visibility,
'moderation_note' => $artwork->moderation_note,
'created_at' => optional($artwork->created_at)?->toISOString(),
];
}
}
@@ -8,33 +8,51 @@ use App\Http\Controllers\Controller;
use App\Models\Upload;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
final class UploadModerationController extends Controller
{
public function pending(): JsonResponse
{
$uploads = Upload::query()
->with(['user:id,name,username', 'category:id,name,slug'])
->where('status', 'draft')
->where('moderation_status', 'pending')
->orderBy('created_at')
->get([
'id',
'user_id',
'type',
'status',
'processing_state',
'title',
'preview_path',
'created_at',
'moderation_status',
]);
->get()
->map(fn (Upload $upload): array => $this->present($upload))
->values();
return response()->json([
'data' => $uploads,
], Response::HTTP_OK);
}
public function preview(string $id): StreamedResponse|JsonResponse
{
$upload = Upload::query()
->where('status', 'draft')
->where('moderation_status', 'pending')
->find($id);
if (! $upload) {
return response()->json(['message' => 'Upload not found.'], Response::HTTP_NOT_FOUND);
}
$path = $this->safePreviewPath($upload);
if ($path === null || ! Storage::disk('local')->exists($path)) {
return response()->json(['message' => 'Preview not found.'], Response::HTTP_NOT_FOUND);
}
return Storage::disk('local')->response($path, 'preview.webp', [
'Content-Type' => 'image/webp',
'Cache-Control' => 'private, max-age=120',
]);
}
public function approve(string $id, Request $request): JsonResponse
{
$upload = Upload::query()->find($id);
@@ -80,4 +98,56 @@ final class UploadModerationController extends Controller
'moderation_status' => (string) $upload->moderation_status,
], Response::HTTP_OK);
}
private function present(Upload $upload): array
{
$tags = collect($upload->tags ?? [])
->map(function (mixed $tag): string {
if (is_array($tag)) {
return trim((string) ($tag['name'] ?? $tag['slug'] ?? ''));
}
return trim((string) $tag);
})
->filter()
->values()
->all();
$hasPreview = $this->safePreviewPath($upload) !== null;
return [
'id' => (string) $upload->id,
'title' => (string) ($upload->title ?: '(untitled upload)'),
'description' => (string) ($upload->description ?? ''),
'type' => (string) ($upload->type ?? 'image'),
'preview_url' => $hasPreview
? URL::temporarySignedRoute('api.admin.uploads.preview', now()->addMinutes(30), ['id' => $upload->id])
: null,
'preview_lg_url' => $hasPreview
? URL::temporarySignedRoute('api.admin.uploads.preview', now()->addMinutes(30), ['id' => $upload->id])
: null,
'tags' => $tags,
'categories' => $upload->category?->name ? [(string) $upload->category->name] : [],
'user' => $upload->user ? [
'id' => (int) $upload->user->id,
'name' => (string) $upload->user->name,
'username' => $upload->user->username,
] : null,
'nsfw' => (bool) $upload->nsfw,
'license' => $upload->license,
'created_at' => optional($upload->created_at)?->toISOString(),
];
}
private function safePreviewPath(Upload $upload): ?string
{
$path = str_replace('\\', '/', ltrim((string) $upload->preview_path, '/'));
$expectedPrefix = 'tmp/drafts/'.$upload->id.'/';
if ($path === '' || str_contains($path, '..') || ! str_starts_with($path, $expectedPrefix)) {
return null;
}
return $path;
}
}
@@ -3,14 +3,17 @@
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\Activity\UserActivityService;
use App\Models\ActivityEvent;
use App\Models\Artwork;
use App\Models\ArtworkComment;
use App\Models\User;
use App\Models\UserMention;
use App\Notifications\ArtworkCommentedNotification;
use App\Notifications\ArtworkMentionedNotification;
use App\Services\Activity\UserActivityService;
use App\Services\CommentReactionService;
use App\Services\ContentSanitizer;
use App\Services\Moderation\CommentSpamService;
use App\Support\AvatarUrl;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
@@ -30,6 +33,11 @@ class ArtworkCommentController extends Controller
{
private const MAX_LENGTH = 10_000;
public function __construct(
private readonly CommentReactionService $commentReactions,
private readonly CommentSpamService $commentSpam,
) {}
// ─────────────────────────────────────────────────────────────────────────
// List
// ─────────────────────────────────────────────────────────────────────────
@@ -38,14 +46,14 @@ class ArtworkCommentController extends Controller
{
$artwork = Artwork::public()->published()->findOrFail($artworkId);
$page = max(1, (int) $request->query('page', 1));
$perPage = 20;
$page = max(1, (int) $request->query('page', 1));
$perPage = 20;
// Only fetch top-level comments (no parent). Replies are recursively eager-loaded.
$comments = ArtworkComment::with([
'user', 'user.profile',
'approvedReplies',
])
'user', 'user.profile',
'approvedReplies',
])
->where('artwork_id', $artwork->id)
->where('is_approved', true)
->whereNull('parent_id')
@@ -53,15 +61,17 @@ class ArtworkCommentController extends Controller
->paginate($perPage, ['*'], 'page', $page);
$userId = $request->user()?->id;
$items = $comments->getCollection()->map(fn ($c) => $this->formatComment($c, $userId, true));
$commentIds = $this->commentIds($comments->getCollection());
$reactionTotals = $this->commentReactions->forComments($commentIds, $userId);
$items = $comments->getCollection()->map(fn ($c) => $this->formatComment($c, $userId, true, $reactionTotals));
return response()->json([
'data' => $items,
'meta' => [
'current_page' => $comments->currentPage(),
'last_page' => $comments->lastPage(),
'total' => $comments->total(),
'per_page' => $comments->perPage(),
'last_page' => $comments->lastPage(),
'total' => $comments->total(),
'per_page' => $comments->perPage(),
],
]);
}
@@ -75,7 +85,7 @@ class ArtworkCommentController extends Controller
$artwork = Artwork::public()->published()->findOrFail($artworkId);
$request->validate([
'content' => ['required', 'string', 'min:1', 'max:' . self::MAX_LENGTH],
'content' => ['required', 'string', 'min:1', 'max:'.self::MAX_LENGTH],
'parent_id' => ['nullable', 'integer', 'exists:artwork_comments,id'],
]);
@@ -104,41 +114,54 @@ class ArtworkCommentController extends Controller
$rendered = ContentSanitizer::render($raw);
$comment = ArtworkComment::create([
'artwork_id' => $artwork->id,
'user_id' => $request->user()->id,
'parent_id' => $parentId,
'content' => $raw, // legacy column (plain text fallback)
'raw_content' => $raw,
'artwork_id' => $artwork->id,
'user_id' => $request->user()->id,
'parent_id' => $parentId,
'content' => $raw, // legacy column (plain text fallback)
'raw_content' => $raw,
'rendered_content' => $rendered,
'is_approved' => true, // auto-approve; extend with moderation as needed
'is_approved' => true,
]);
$moderation = $this->commentSpam->moderate($comment, $request->user());
// Bust the comments cache for this user's 'all' feed
Cache::forget('comments.latest.all.page1');
$comment->load(['user', 'user.profile']);
$this->notifyRecipients($artwork, $comment, $request->user(), $parentId ? (int) $parentId : null);
if ($comment->is_approved) {
$this->notifyRecipients($artwork, $comment, $request->user(), $parentId ? (int) $parentId : null);
}
// Record activity event (fire-and-forget; never break the response)
try {
\App\Models\ActivityEvent::record(
actorId: $request->user()->id,
type: \App\Models\ActivityEvent::TYPE_COMMENT,
targetType: \App\Models\ActivityEvent::TARGET_ARTWORK,
targetId: $artwork->id,
ActivityEvent::record(
actorId: $request->user()->id,
type: ActivityEvent::TYPE_COMMENT,
targetType: ActivityEvent::TARGET_ARTWORK,
targetId: $artwork->id,
);
} catch (\Throwable) {}
} catch (\Throwable) {
}
try {
app(UserActivityService::class)->logComment(
(int) $request->user()->id,
(int) $comment->id,
$parentId !== null,
['artwork_id' => (int) $artwork->id],
);
} catch (\Throwable) {}
if ($comment->is_approved) {
app(UserActivityService::class)->logComment(
(int) $request->user()->id,
(int) $comment->id,
$parentId !== null,
['artwork_id' => (int) $artwork->id],
);
}
} catch (\Throwable) {
}
return response()->json(['data' => $this->formatComment($comment, $request->user()->id, false)], 201);
$reactionTotals = $this->commentReactions->forComments([$comment->id], $request->user()->id);
return response()->json([
'data' => $this->formatComment($comment, $request->user()->id, false, $reactionTotals),
'moderation' => ['status' => $moderation['status']],
], 201);
}
// ─────────────────────────────────────────────────────────────────────────
@@ -153,10 +176,10 @@ class ArtworkCommentController extends Controller
Gate::authorize('update', $comment);
$request->validate([
'content' => ['required', 'string', 'min:1', 'max:' . self::MAX_LENGTH],
'content' => ['required', 'string', 'min:1', 'max:'.self::MAX_LENGTH],
]);
$raw = $request->input('content');
$raw = $request->input('content');
$errors = ContentSanitizer::validate($raw);
if ($errors) {
return response()->json(['errors' => ['content' => $errors]], 422);
@@ -165,8 +188,8 @@ class ArtworkCommentController extends Controller
$rendered = ContentSanitizer::render($raw);
$comment->update([
'content' => $raw,
'raw_content' => $raw,
'content' => $raw,
'raw_content' => $raw,
'rendered_content' => $rendered,
]);
@@ -197,36 +220,37 @@ class ArtworkCommentController extends Controller
// Helpers
// ─────────────────────────────────────────────────────────────────────────
private function formatComment(ArtworkComment $c, ?int $currentUserId, bool $includeReplies = false): array
private function formatComment(ArtworkComment $c, ?int $currentUserId, bool $includeReplies = false, array $reactionTotals = []): array
{
$user = $c->user;
$userId = (int) ($c->user_id ?? 0);
$user = $c->user;
$userId = (int) ($c->user_id ?? 0);
$avatarHash = $user?->profile?->avatar_hash ?? null;
$data = [
'id' => $c->id,
'parent_id' => $c->parent_id,
'raw_content' => $c->raw_content ?? $c->content,
'id' => $c->id,
'parent_id' => $c->parent_id,
'raw_content' => $c->raw_content ?? $c->content,
'rendered_content' => $this->renderCommentContent($c),
'created_at' => $c->created_at?->toIso8601String(),
'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null,
'can_edit' => $currentUserId === $userId,
'can_delete' => $currentUserId === $userId,
'created_at' => $c->created_at?->toIso8601String(),
'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null,
'can_edit' => $currentUserId === $userId,
'can_delete' => $currentUserId === $userId,
'user' => [
'id' => $userId,
'username' => $user?->username,
'display' => $user?->username ?? $user?->name ?? 'User',
'profile_url' => $user?->username ? '/@' . $user->username : '/profile/' . $userId,
'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64),
'level' => (int) ($user?->level ?? 1),
'rank' => (string) ($user?->rank ?? 'Newbie'),
'id' => $userId,
'username' => $user?->username,
'display' => $user?->username ?? $user?->name ?? 'User',
'profile_url' => $user?->username ? '/@'.$user->username : '/profile/'.$userId,
'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64),
'level' => (int) ($user?->level ?? 1),
'rank' => (string) ($user?->rank ?? 'Newbie'),
],
'reactions' => $reactionTotals[(int) $c->id] ?? [],
];
if ($includeReplies && $c->relationLoaded('approvedReplies')) {
$data['replies'] = $c->approvedReplies->map(fn ($r) => $this->formatComment($r, $currentUserId, true))->values()->toArray();
$data['replies'] = $c->approvedReplies->map(fn ($r) => $this->formatComment($r, $currentUserId, true, $reactionTotals))->values()->toArray();
} elseif ($includeReplies && $c->relationLoaded('replies')) {
$data['replies'] = $c->replies->map(fn ($r) => $this->formatComment($r, $currentUserId, true))->values()->toArray();
$data['replies'] = $c->replies->map(fn ($r) => $this->formatComment($r, $currentUserId, true, $reactionTotals))->values()->toArray();
} else {
$data['replies'] = [];
}
@@ -234,6 +258,24 @@ class ArtworkCommentController extends Controller
return $data;
}
/** @param iterable<int, ArtworkComment> $comments */
private function commentIds(iterable $comments): array
{
$ids = [];
$walk = function (iterable $items) use (&$walk, &$ids): void {
foreach ($items as $comment) {
$ids[] = (int) $comment->id;
if ($comment->relationLoaded('approvedReplies')) {
$walk($comment->approvedReplies);
}
}
};
$walk($comments);
return array_values(array_unique($ids));
}
private function renderCommentContent(ArtworkComment $comment): string
{
$rawContent = (string) ($comment->raw_content ?? $comment->content ?? '');
+10 -5
View File
@@ -1,13 +1,14 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Artworks\ArtworkCreateRequest;
use App\Http\Resources\ArtworkListResource;
use App\Http\Resources\ArtworkResource;
use App\Services\ArtworkService;
use App\Services\Artworks\ArtworkDraftService;
use App\Models\Category;
use App\Services\Artworks\ArtworkDraftService;
use App\Services\ArtworkService;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -29,9 +30,12 @@ class ArtworkController extends Controller
$user = $request->user();
$data = $request->validated();
$categoryId = isset($data['category']) && ctype_digit((string) $data['category'])
? (int) $data['category']
: null;
$categoryId = isset($data['primary_category_id']) && ctype_digit((string) $data['primary_category_id'])
? (int) $data['primary_category_id']
: (isset($data['category']) && ctype_digit((string) $data['category'])
? (int) $data['category']
: null);
$secondaryCategoryIds = array_values(array_map('intval', $data['secondary_category_ids'] ?? []));
$result = $drafts->createDraft(
$user,
@@ -40,6 +44,7 @@ class ArtworkController extends Controller
$categoryId,
(bool) ($data['is_mature'] ?? false),
$data['group'] ?? null,
$secondaryCategoryIds,
);
return response()->json([
@@ -5,11 +5,15 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\ArtworkResource;
use App\Models\Artwork;
use App\Services\ArtworkNavigationService;
use App\Services\ThumbnailPresenter;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Str;
use Illuminate\Http\Request;
class ArtworkNavigationController extends Controller
{
public function __construct(private readonly ArtworkNavigationService $navigation) {}
/**
* GET /api/artworks/navigation/{id}
*
@@ -26,35 +30,11 @@ class ArtworkNavigationController extends Controller
'prev_id' => null, 'next_id' => null,
'prev_url' => null, 'next_url' => null,
'prev_slug' => null, 'next_slug' => null,
'prev_preview' => null, 'next_preview' => null,
]);
}
$scope = Artwork::published()
->select(['id', 'title', 'slug'])
->where('user_id', $artwork->user_id);
$prev = (clone $scope)->where('id', '<', $id)->orderByDesc('id')->first();
$next = (clone $scope)->where('id', '>', $id)->orderBy('id')->first();
// Infinite loop: wrap around when reaching the first or last artwork
if (! $prev) {
$prev = (clone $scope)->where('id', '!=', $id)->orderByDesc('id')->first();
}
if (! $next) {
$next = (clone $scope)->where('id', '!=', $id)->orderBy('id')->first();
}
$prevSlug = $prev ? (Str::slug($prev->slug ?: $prev->title) ?: (string) $prev->id) : null;
$nextSlug = $next ? (Str::slug($next->slug ?: $next->title) ?: (string) $next->id) : null;
return response()->json([
'prev_id' => $prev?->id,
'next_id' => $next?->id,
'prev_url' => $prev ? url('/art/' . $prev->id . '/' . $prevSlug) : null,
'next_url' => $next ? url('/art/' . $next->id . '/' . $nextSlug) : null,
'prev_slug' => $prevSlug,
'next_slug' => $nextSlug,
]);
return response()->json($this->navigation->navigationFor($artwork));
}
/**
@@ -62,8 +42,12 @@ class ArtworkNavigationController extends Controller
*
* Returns full artwork resource by numeric ID for client-side (no-reload) navigation.
*/
public function pageData(int $id): JsonResponse
public function pageData(Request $request, int $id): JsonResponse
{
if ($request->boolean('prefetch')) {
return $this->prefetchData($id);
}
$artwork = Artwork::with(['user.profile', 'categories.contentType', 'categories.parent.contentType', 'tags', 'stats'])
->published()
->find($id);
@@ -73,7 +57,30 @@ class ArtworkNavigationController extends Controller
}
$resource = (new ArtworkResource($artwork))->toArray(request());
$resource['navigation'] = $this->navigation->navigationFor($artwork);
return response()->json($resource);
}
private function prefetchData(int $id): JsonResponse
{
$artwork = Artwork::query()
->published()
->select(['id', 'title', 'slug', 'hash', 'file_path', 'file_name'])
->find($id);
if (! $artwork) {
return response()->json(['error' => 'Not found'], 404);
}
return response()->json([
'id' => (int) $artwork->id,
'title' => (string) $artwork->title,
'slug' => (string) $artwork->slug,
'thumbs' => [
'md' => ThumbnailPresenter::present($artwork, 'md'),
'lg' => ThumbnailPresenter::present($artwork, 'lg'),
],
]);
}
}
@@ -30,6 +30,7 @@ final class ArtworkTagController extends Controller
$queueConnection = (string) config('queue.default', 'sync');
$visionEnabled = (bool) config('vision.enabled', true);
$autoTaggingEnabled = (bool) config('vision.auto_tagging.enabled', false);
$queuedCount = 0;
$failedCount = 0;
@@ -56,7 +57,7 @@ final class ArtworkTagController extends Controller
$triggered = false;
$shouldTrigger = request()->boolean('trigger', false);
if ($shouldTrigger && $visionEnabled && ! empty($artwork->hash) && $queuedCount === 0) {
if ($shouldTrigger && $visionEnabled && $autoTaggingEnabled && ! empty($artwork->hash) && $queuedCount === 0) {
AutoTagArtworkJob::dispatch((int) $artwork->id, (string) $artwork->hash);
$triggered = true;
$queuedCount = max(1, $queuedCount);
@@ -89,6 +90,7 @@ final class ArtworkTagController extends Controller
'queued_jobs' => $queuedCount,
'failed_jobs' => $failedCount,
'triggered' => $triggered,
'auto_tagging_enabled' => $autoTaggingEnabled,
'ai_tag_count' => (int) $tags->where('is_ai', true)->count(),
'total_tag_count' => (int) $tags->count(),
],
@@ -174,7 +174,7 @@ final class DiscoveryNegativeSignalController extends Controller
}
/**
* @param array<string, mixed> $meta
* @param array<string, mixed> $meta
*/
private function recordFeedbackEvent(int $userId, int $artworkId, string $eventType, ?string $algoVersion = null, array $meta = []): void
{
@@ -182,10 +182,9 @@ final class DiscoveryNegativeSignalController extends Controller
return;
}
$categoryId = DB::table('artwork_category')
->where('artwork_id', $artworkId)
->orderBy('category_id')
->value('category_id');
$categoryId = DB::table('artworks')
->where('id', $artworkId)
->value('primary_category_id');
DB::table('user_discovery_events')->insert([
'event_id' => (string) Str::uuid(),
+20 -40
View File
@@ -5,12 +5,10 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\FollowService;
use App\Support\AvatarUrl;
use App\Support\UsernamePolicy;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
/**
* API endpoints for the follow system.
@@ -72,26 +70,17 @@ final class FollowController extends Controller
{
$target = $this->resolveUser($username);
$perPage = min((int) $request->query('per_page', 24), 100);
$page = max(1, (int) $request->query('page', 1));
$rows = DB::table('user_followers as uf')
->join('users as u', 'u.id', '=', 'uf.follower_id')
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
->where('uf.user_id', $target->id)
->whereNull('u.deleted_at')
->orderByDesc('uf.created_at')
->select([
'u.id', 'u.username', 'u.name',
'up.avatar_hash',
'uf.created_at as followed_at',
])
->paginate($perPage)
->through(fn ($row) => [
'id' => $row->id,
'username' => $row->username,
'display_name'=> $row->username ?? $row->name,
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 50),
'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)),
'followed_at' => $row->followed_at,
$rows = $this->followService
->paginatedFollowers((int) $target->id, $perPage, $page)
->through(fn (array $row) => [
'id' => $row['id'],
'username' => $row['username'],
'display_name' => $row['display_name'],
'avatar_url' => $row['avatar_url'],
'profile_url' => $row['profile_url'],
'followed_at' => $row['followed_at'],
]);
return response()->json($rows);
@@ -103,26 +92,17 @@ final class FollowController extends Controller
{
$target = $this->resolveUser($username);
$perPage = min((int) $request->query('per_page', 24), 100);
$page = max(1, (int) $request->query('page', 1));
$rows = DB::table('user_followers as uf')
->join('users as u', 'u.id', '=', 'uf.user_id')
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
->where('uf.follower_id', $target->id)
->whereNull('u.deleted_at')
->orderByDesc('uf.created_at')
->select([
'u.id', 'u.username', 'u.name',
'up.avatar_hash',
'uf.created_at as followed_at',
])
->paginate($perPage)
->through(fn ($row) => [
'id' => $row->id,
'username' => $row->username,
'display_name'=> $row->username ?? $row->name,
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 50),
'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)),
'followed_at' => $row->followed_at,
$rows = $this->followService
->paginatedFollowing((int) $target->id, $perPage, $page)
->through(fn (array $row) => [
'id' => $row['id'],
'username' => $row['username'],
'display_name' => $row['display_name'],
'avatar_url' => $row['avatar_url'],
'profile_url' => $row['profile_url'],
'followed_at' => $row['followed_at'],
]);
return response()->json($rows);
@@ -53,7 +53,12 @@ class LinkPreviewController extends Controller
return response()->json(['error' => 'Invalid URL.'], 422);
}
// Resolve hostname and block private/loopback IPs (SSRF protection)
// Resolve hostname and block private/loopback IPs (SSRF protection).
// NOTE: This check is not atomic with Guzzle's own DNS resolution — a
// DNS rebinding attack could theoretically pass this check and then
// resolve to an internal IP when Guzzle makes the actual request.
// Risk is low (requires attacker-controlled DNS with very short TTL),
// but this is a known limitation of the current approach.
$resolved = gethostbyname($host);
if ($this->isBlockedIp($resolved)) {
return response()->json(['error' => 'URL not allowed.'], 422);
@@ -48,10 +48,7 @@ class MessageSearchController extends Controller
$estimated = 0;
try {
$client = new Client(
config('scout.meilisearch.host'),
config('scout.meilisearch.key')
);
$client = app(Client::class);
$prefix = (string) config('scout.prefix', '');
$indexName = $prefix . (string) config('messaging.search.index', 'messages');
@@ -7,9 +7,11 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Models\User;
use Carbon\CarbonInterface;
use App\Services\CollectionService;
use App\Services\Maturity\ArtworkMaturityService;
use App\Services\ThumbnailPresenter;
use App\Support\UsernamePolicy;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -25,6 +27,28 @@ use UnexpectedValueException;
*/
final class ProfileApiController extends Controller
{
public function __construct(private readonly CollectionService $collections) {}
/**
* GET /api/profile/{username}/collections
* Returns the profile collection cards without rendering the profile page.
*/
public function collections(Request $request, string $username): JsonResponse
{
$user = $this->resolveUser($username);
if (! $user) {
return response()->json(['error' => 'User not found'], 404);
}
$viewer = $request->user();
$isOwner = $viewer && (int) $viewer->id === (int) $user->id;
$profileCollections = $this->collections->getProfileCollections($user, $viewer);
return response()->json([
'data' => $this->collections->mapCollectionCardPayloads($profileCollections, $isOwner, $viewer),
]);
}
/**
* GET /api/profile/{username}/artworks
* Returns cursor-paginated artworks for the profile page tabs.
@@ -38,18 +62,18 @@ final class ProfileApiController extends Controller
}
$isOwner = Auth::check() && Auth::id() === $user->id;
$sort = $request->input('sort', 'latest');
$sort = $request->input('sort', 'latest');
$query = Artwork::with([
'user:id,name,username,level,rank',
'user.profile:user_id,avatar_hash',
'user:id,name,username,level,rank',
'user.profile:user_id,avatar_hash',
'group:id,name,slug,avatar_path',
'stats:artwork_id,views,downloads,favorites',
'categories' => function ($query) {
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
->with(['contentType:id,slug,name']);
},
])
'stats:artwork_id,views,downloads,favorites',
'categories' => function ($query) {
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
->with(['contentType:id,slug,name']);
},
])
->where('user_id', $user->id)
->whereNull('artworks.deleted_at');
@@ -84,9 +108,9 @@ final class ProfileApiController extends Controller
->values();
return response()->json([
'data' => $data,
'data' => $data,
'next_cursor' => $paginator->nextCursor()?->encode(),
'has_more' => $paginator->hasMorePages(),
'has_more' => $paginator->hasMorePages(),
]);
}
@@ -106,16 +130,20 @@ final class ProfileApiController extends Controller
return response()->json(['error' => 'User not found'], 404);
}
$perPage = 24;
$perPage = 12;
$offset = max(0, (int) base64_decode((string) $request->input('cursor', ''), true));
$favIds = DB::table($favouriteTable . ' as af')
$favIds = DB::table($favouriteTable.' as af')
->join('artworks as a', 'a.id', '=', 'af.artwork_id')
->where('af.user_id', $user->id)
->whereNull('a.deleted_at')
->where('a.is_public', true)
->where('a.is_approved', true)
->whereNotNull('a.published_at')
->when(app(ArtworkMaturityService::class)->viewerPreferences($request->user())['visibility'] === ArtworkMaturityService::VIEW_HIDE, function ($query): void {
$query->whereRaw('COALESCE(a.is_mature, 0) = 0')
->whereRaw("COALESCE(a.maturity_status, 'clear') != ?", [ArtworkMaturityService::STATUS_SUSPECTED]);
})
->orderByDesc('af.created_at')
->orderByDesc('af.artwork_id')
->offset($offset)
@@ -123,22 +151,22 @@ final class ProfileApiController extends Controller
->pluck('a.id');
$hasMore = $favIds->count() > $perPage;
$favIds = $favIds->take($perPage);
$favIds = $favIds->take($perPage);
if ($favIds->isEmpty()) {
return response()->json(['data' => [], 'next_cursor' => null, 'has_more' => false]);
}
$indexed = Artwork::with([
'user:id,name,username,level,rank',
'user.profile:user_id,avatar_hash',
'user:id,name,username,level,rank',
'user.profile:user_id,avatar_hash',
'group:id,name,slug,avatar_path',
'stats:artwork_id,views,downloads,favorites',
'categories' => function ($query) {
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
->with(['contentType:id,slug,name']);
},
])
'stats:artwork_id,views,downloads,favorites',
'categories' => function ($query) {
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
->with(['contentType:id,slug,name']);
},
])
->whereIn('id', $favIds)
->get()
->keyBy('id');
@@ -149,9 +177,9 @@ final class ProfileApiController extends Controller
->values();
return response()->json([
'data' => $data,
'data' => $data,
'next_cursor' => $hasMore ? base64_encode((string) ($offset + $perPage)) : null,
'has_more' => $hasMore,
'has_more' => $hasMore,
]);
}
@@ -177,7 +205,7 @@ final class ProfileApiController extends Controller
}
return response()->json([
'stats' => $stats,
'stats' => $stats,
'follower_count' => $followerCount,
]);
}
@@ -185,6 +213,7 @@ final class ProfileApiController extends Controller
private function resolveUser(string $username): ?User
{
$normalized = UsernamePolicy::normalize($username);
return User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first();
}
@@ -203,17 +232,17 @@ final class ProfileApiController extends Controller
{
$statsColumn = match ($sort) {
'trending' => 'profile_artwork_stats.ranking_score',
'rising' => 'profile_artwork_stats.heat_score',
'views' => 'profile_artwork_stats.views',
'favs' => 'profile_artwork_stats.favorites',
default => null,
'rising' => 'profile_artwork_stats.heat_score',
'views' => 'profile_artwork_stats.views',
'favs' => 'profile_artwork_stats.favorites',
default => null,
};
if ($statsColumn !== null) {
return $query
->leftJoin('artwork_stats as profile_artwork_stats', 'profile_artwork_stats.artwork_id', '=', 'artworks.id')
->select('artworks.*')
->selectRaw('COALESCE(' . $statsColumn . ', 0) as cursor_sort_value')
->selectRaw('COALESCE('.$statsColumn.', 0) as cursor_sort_value')
->orderByDesc('cursor_sort_value')
->orderByDesc('published_at')
->orderByDesc('id');
@@ -230,7 +259,7 @@ final class ProfileApiController extends Controller
private function mapArtworkCardPayload(Artwork $art): array
{
$present = ThumbnailPresenter::present($art, 'md');
$category = $art->categories->first();
$category = $art->resolvedPrimaryCategory();
$contentType = $category?->contentType;
$stats = $art->stats;
$group = $art->group;
@@ -240,12 +269,14 @@ final class ProfileApiController extends Controller
$avatarUrl = $isGroupPublisher ? $group->avatarUrl() : ($art->user?->profile?->avatar_url ?? null);
$profileUrl = $isGroupPublisher
? $group->publicUrl()
: ($username ? '/@' . $username : null);
: ($username ? '/@'.$username : null);
$publisherType = $isGroupPublisher ? 'group' : 'user';
return [
'id' => $art->id,
'name' => $art->title,
'picture' => $art->file_name,
'datum' => $this->formatIsoDate($art->published_at),
'thumb' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'width' => $art->width,
@@ -6,10 +6,12 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\Vision\VectorGatewayException;
use App\Services\Vision\VectorService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use RuntimeException;
use Illuminate\Support\Facades\Log;
use Throwable;
final class SimilarAiArtworksController extends Controller
{
@@ -35,11 +37,12 @@ final class SimilarAiArtworksController extends Controller
try {
$items = $this->vectors->similarToArtwork($artwork, $limit);
} catch (RuntimeException $e) {
} catch (Throwable $e) {
$this->logSimilarityFailure($artwork->id, $e);
return response()->json([
'data' => [],
'reason' => 'vector_gateway_error',
'message' => $e->getMessage(),
], 502);
}
@@ -52,4 +55,19 @@ final class SimilarAiArtworksController extends Controller
],
]);
}
private function logSimilarityFailure(int $artworkId, Throwable $e): void
{
$gateway = $e instanceof VectorGatewayException ? $e : null;
Log::warning('Vector similarity search failed', [
'artwork_id' => $artworkId,
'route' => 'api.art.similar-ai',
'failure_stage' => $gateway?->operation ?? 'unknown',
'exception_class' => $e::class,
'http_status' => $gateway?->httpStatus,
'circuit_worthy' => $gateway?->circuitWorthy ?? false,
'circuit_open' => app(\App\Services\Vision\VectorGatewayClient::class)->circuitOpen(),
]);
}
}
+177 -91
View File
@@ -5,50 +5,58 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Uploads\UploadCancelRequest;
use App\Http\Requests\Uploads\UploadChunkRequest;
use App\Http\Requests\Uploads\UploadFinishRequest;
use App\Http\Requests\Uploads\UploadInitRequest;
use App\Http\Requests\Uploads\UploadChunkRequest;
use App\Http\Requests\Uploads\UploadCancelRequest;
use App\Http\Requests\Uploads\UploadStatusRequest;
use App\Jobs\GenerateDerivativesJob;
use App\Jobs\AnalyzeArtworkAiAssistJob;
use App\Jobs\IndexArtworkJob;
use App\Jobs\AutoTagArtworkJob;
use App\Jobs\DetectArtworkMaturityJob;
use App\Jobs\GenerateArtworkEmbeddingJob;
use App\Jobs\GenerateDerivativesJob;
use App\Jobs\IndexArtworkJob;
use App\Models\ActivityEvent;
use App\Models\Artwork;
use App\Models\Group;
use App\Models\Tag;
use App\Notifications\NewArtworkReviewNotification;
use App\Repositories\Uploads\UploadSessionRepository;
use App\Services\Uploads\UploadChunkService;
use App\Services\Uploads\UploadCancelService;
use App\Services\Uploads\UploadAuditService;
use App\Services\Uploads\UploadPipelineService;
use App\Services\Uploads\UploadQuotaService;
use App\Services\Uploads\UploadQueueService;
use App\Services\Uploads\UploadSessionStatus;
use App\Services\Uploads\UploadStatusService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use App\Services\Upload\Contracts\UploadDraftServiceInterface;
use Carbon\Carbon;
use App\Uploads\Jobs\VirusScanJob;
use App\Uploads\Services\PublishService;
use App\Services\Activity\UserActivityService;
use App\Services\ArtworkAttributionService;
use App\Services\Artworks\ArtworkCategoryService;
use App\Services\GroupArtworkReviewService;
use App\Services\Maturity\ArtworkMaturityService;
use App\Services\Moderation\ArtworkUploadPolicy;
use App\Services\Upload\Contracts\UploadDraftServiceInterface;
use App\Services\Uploads\UploadAuditService;
use App\Services\Uploads\UploadCancelService;
use App\Services\Uploads\UploadChunkService;
use App\Services\Uploads\UploadPipelineService;
use App\Services\Uploads\UploadQueueService;
use App\Services\Uploads\UploadQuotaService;
use App\Services\Uploads\UploadSessionStatus;
use App\Services\Uploads\UploadStatusService;
use App\Services\Worlds\WorldSubmissionService;
use App\Support\ArtworkDescriptionContentValidator;
use App\Uploads\Exceptions\DraftQuotaException;
use App\Uploads\Exceptions\UploadNotFoundException;
use App\Uploads\Exceptions\UploadOwnershipException;
use App\Uploads\Exceptions\UploadPublishValidationException;
use App\Uploads\Jobs\VirusScanJob;
use App\Uploads\Services\ArchiveInspectorService;
use App\Uploads\Services\DraftQuotaService;
use App\Uploads\Exceptions\DraftQuotaException;
use App\Models\Artwork;
use App\Models\Group;
use App\Services\GroupArtworkReviewService;
use App\Services\Worlds\WorldSubmissionService;
use App\Uploads\Services\PublishService;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
final class UploadController extends Controller
{
@@ -57,8 +65,7 @@ final class UploadController extends Controller
UploadPipelineService $pipeline,
UploadQuotaService $quota,
UploadAuditService $audit
)
{
) {
$user = $request->user();
try {
@@ -237,10 +244,19 @@ final class UploadController extends Controller
}
// Derivatives are available now; dispatch AI auto-tagging.
AutoTagArtworkJob::dispatch($artworkId, $validated->hash)->afterCommit();
DetectArtworkMaturityJob::dispatch($artworkId, $validated->hash)->afterCommit();
GenerateArtworkEmbeddingJob::dispatch($artworkId, $validated->hash)->afterCommit();
AnalyzeArtworkAiAssistJob::dispatch($artworkId)->afterCommit();
if ((bool) config('vision.auto_tagging.enabled', false)) {
AutoTagArtworkJob::dispatch($artworkId, $validated->hash)->afterCommit();
}
if ((bool) config('vision.upload.maturity.enabled', false)) {
DetectArtworkMaturityJob::dispatch($artworkId, $validated->hash)->afterCommit();
}
if ((bool) config('vision.upload.embeddings.enabled', true)) {
GenerateArtworkEmbeddingJob::dispatch($artworkId, $validated->hash)->afterCommit();
}
if ((bool) config('vision.upload.ai_assist.enabled', false)) {
AnalyzeArtworkAiAssistJob::dispatch($artworkId)->afterCommit();
}
return UploadSessionStatus::PROCESSED;
});
@@ -295,7 +311,7 @@ final class UploadController extends Controller
'realpath' => $chunkFile->getRealPath(),
]);
}
} catch (\Throwable $e) {
} catch (Throwable $e) {
logger()->warning('Chunk upload debug logging failed', ['error' => $e->getMessage()]);
}
@@ -323,7 +339,7 @@ final class UploadController extends Controller
'total_bytes' => $result->totalBytes,
'progress' => $result->progress,
], Response::HTTP_OK);
} catch (\Throwable $e) {
} catch (Throwable $e) {
logger()->warning('Upload chunk failed', [
'session_id' => (string) $request->input('session_id'),
'error' => $e->getMessage(),
@@ -373,7 +389,7 @@ final class UploadController extends Controller
'session_id' => $result['session_id'],
'status' => $result['status'],
], Response::HTTP_OK);
} catch (\Throwable $e) {
} catch (Throwable $e) {
logger()->warning('Upload cancel failed', [
'session_id' => (string) $request->input('session_id'),
'error' => $e->getMessage(),
@@ -502,6 +518,7 @@ final class UploadController extends Controller
return response()->json($response, Response::HTTP_OK);
} catch (Throwable $e) {
logger()->error('Upload preload failed', ['error' => $e->getMessage()]);
return response()->json(['message' => 'Preload failed.'], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
@@ -528,14 +545,18 @@ final class UploadController extends Controller
$validated = $request->validate([
'title' => ['nullable', 'string', 'max:255'],
'category_id' => ['nullable', 'exists:categories,id'],
'secondary_category_ids' => ['nullable', 'array', 'max:'.(int) config('categories.max_secondary_categories', 5)],
'secondary_category_ids.*' => ['integer', 'exists:categories,id'],
'description' => ['nullable', 'string'],
'tags' => ['nullable', 'array'],
'license' => ['nullable', 'string'],
'nsfw' => ['nullable', 'boolean'],
]);
$this->ensureValidArtworkDescription($validated);
$updates = [];
foreach (['title', 'category_id', 'description', 'tags', 'license', 'nsfw'] as $field) {
foreach (['title', 'category_id', 'secondary_category_ids', 'description', 'tags', 'license', 'nsfw'] as $field) {
if (array_key_exists($field, $validated)) {
$updates[$field] = $validated[$field];
}
@@ -545,7 +566,7 @@ final class UploadController extends Controller
foreach ($updates as $field => $value) {
$current = $upload->{$field} ?? null;
if ($field === 'tags') {
if ($field === 'tags' || $field === 'secondary_category_ids') {
$current = $current ? json_decode((string) $current, true) : null;
}
@@ -563,6 +584,10 @@ final class UploadController extends Controller
$dirty['tags'] = json_encode($dirty['tags']);
}
if (array_key_exists('secondary_category_ids', $dirty)) {
$dirty['secondary_category_ids'] = json_encode($dirty['secondary_category_ids']);
}
if (! empty($dirty)) {
$dirty['updated_at'] = now();
DB::table('uploads')->where('id', $id)->update($dirty);
@@ -604,23 +629,26 @@ final class UploadController extends Controller
], Response::HTTP_OK);
}
public function publish(string $id, Request $request, PublishService $publishService, ArtworkAttributionService $attribution, ArtworkMaturityService $maturity, WorldSubmissionService $submissions)
public function publish(string $id, Request $request, PublishService $publishService, ArtworkAttributionService $attribution, ArtworkMaturityService $maturity, WorldSubmissionService $submissions, ArtworkUploadPolicy $uploadPolicy)
{
$user = $request->user();
$validated = $request->validate([
'title' => ['nullable', 'string', 'max:150'],
'title' => ['nullable', 'string', 'max:150'],
'description' => ['nullable', 'string'],
'category' => ['nullable', 'integer', 'exists:categories,id'],
'tags' => ['nullable', 'array', 'max:' . (int) config('tags.max_user_tags', 30)],
'tags.*' => ['string', 'max:64'],
'is_mature' => ['nullable', 'boolean'],
'nsfw' => ['nullable', 'boolean'],
'category' => ['nullable', 'integer', 'exists:categories,id'],
'primary_category_id' => ['nullable', 'integer', 'exists:categories,id'],
'secondary_category_ids' => ['nullable', 'array', 'max:'.(int) config('categories.max_secondary_categories', 5)],
'secondary_category_ids.*' => ['integer', 'exists:categories,id'],
'tags' => ['nullable', 'array', 'max:'.(int) config('tags.max_user_tags', 30)],
'tags.*' => ['string', 'max:64'],
'is_mature' => ['nullable', 'boolean'],
'nsfw' => ['nullable', 'boolean'],
// Scheduled-publishing fields
'mode' => ['nullable', 'string', 'in:now,schedule'],
'publish_at' => ['nullable', 'string', 'date'],
'timezone' => ['nullable', 'string', 'max:64'],
'visibility' => ['nullable', 'string', 'in:public,unlisted,private'],
'mode' => ['nullable', 'string', 'in:now,schedule'],
'publish_at' => ['nullable', 'string', 'date'],
'timezone' => ['nullable', 'string', 'max:64'],
'visibility' => ['nullable', 'string', 'in:public,unlisted,private'],
'group' => ['nullable', 'string', 'max:90'],
'primary_author_user_id' => ['nullable', 'integer', 'min:1'],
'contributor_user_ids' => ['nullable', 'array', 'max:20'],
@@ -635,21 +663,23 @@ final class UploadController extends Controller
'world_submissions.*.source_surface' => ['nullable', 'string', 'max:80'],
]);
$mode = $validated['mode'] ?? 'now';
$this->ensureValidArtworkDescription($validated);
$mode = $validated['mode'] ?? 'now';
$visibility = $validated['visibility'] ?? 'public';
// Resolve the UTC publish_at datetime for schedule mode
$publishAt = null;
if ($mode === 'schedule' && ! empty($validated['publish_at'])) {
try {
$publishAt = \Carbon\Carbon::parse($validated['publish_at'])->utc();
$publishAt = Carbon::parse($validated['publish_at'])->utc();
// Must be at least 1 minute in the future (server-side guard)
if ($publishAt->lte(now()->addMinute())) {
return response()->json([
'message' => 'Scheduled publish time must be at least 1 minute in the future.',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
} catch (\Throwable) {
} catch (Throwable) {
return response()->json(['message' => 'Invalid publish_at datetime.'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
}
@@ -682,22 +712,25 @@ final class UploadController extends Controller
if (array_key_exists('is_mature', $validated) || array_key_exists('nsfw', $validated)) {
$artwork->is_mature = (bool) ($validated['is_mature'] ?? $validated['nsfw'] ?? false);
}
$artwork->slug = Str::limit($slugBase, 160, '');
$artwork->slug = Str::limit($slugBase, 160, '');
$artwork->artwork_timezone = $validated['timezone'] ?? null;
$artwork->uploaded_by_user_id = $artwork->uploaded_by_user_id ?: (int) $user->id;
$artwork->primary_author_user_id = $artwork->primary_author_user_id ?: (int) $user->id;
// Sync category if provided
$categoryId = isset($validated['category']) ? (int) $validated['category'] : null;
if ($categoryId && \App\Models\Category::where('id', $categoryId)->exists()) {
$artwork->categories()->sync([$categoryId]);
$categoryId = isset($validated['primary_category_id'])
? (int) $validated['primary_category_id']
: (isset($validated['category']) ? (int) $validated['category'] : null);
$secondaryCategoryIds = array_values(array_map('intval', $validated['secondary_category_ids'] ?? []));
if ($categoryId) {
app(ArtworkCategoryService::class)
->sync($artwork, $categoryId, $secondaryCategoryIds, 'user', false);
}
// Sync tags if provided
if (!empty($validated['tags']) && is_array($validated['tags'])) {
if (! empty($validated['tags']) && is_array($validated['tags'])) {
$tagIds = [];
foreach ($validated['tags'] as $tagSlug) {
$tag = \App\Models\Tag::firstOrCreate(
$tag = Tag::firstOrCreate(
['slug' => Str::slug($tagSlug)],
['name' => $tagSlug, 'is_active' => true, 'usage_count' => 0]
);
@@ -713,56 +746,95 @@ final class UploadController extends Controller
if ($mode === 'schedule' && $publishAt) {
// Scheduled: store publish_at but don't make public yet
$artwork->visibility = $visibility;
$artwork->is_public = false;
$artwork->is_approved = true;
$artwork->publish_at = $publishAt;
$artwork->visibility = $visibility;
$artwork->is_public = false;
$artwork->is_approved = true;
$artwork->publish_at = $publishAt;
$artwork->artwork_status = 'scheduled';
$artwork->published_at = null;
$artwork->published_at = null;
$artwork->save();
IndexArtworkJob::dispatch((int) $artwork->id);
return response()->json([
'success' => true,
'artwork_id' => (int) $artwork->id,
'status' => 'scheduled',
'slug' => (string) $artwork->slug,
'publish_at' => $publishAt->toISOString(),
'success' => true,
'artwork_id' => (int) $artwork->id,
'status' => 'scheduled',
'slug' => (string) $artwork->slug,
'publish_at' => $publishAt->toISOString(),
'published_at' => null,
], Response::HTTP_OK);
}
// Publish immediately
$artwork->visibility = $visibility;
$artwork->is_public = ($visibility !== 'private');
$artwork->is_approved = true;
$artwork->published_at = now();
// New and suspicious accounts are quarantined. Never let the client
// decide that an artwork is approved.
$policy = $uploadPolicy->assess($user, $artwork);
if ($policy['requires_review']) {
$artwork->visibility = $visibility;
$artwork->is_public = false;
$artwork->is_approved = false;
$artwork->approval_source = null;
$artwork->artwork_status = 'review';
$artwork->published_at = null;
$artwork->publish_at = null;
$artwork->moderated_at = null;
$artwork->moderated_by = null;
$artwork->moderation_note = implode(', ', $policy['reasons']);
$artwork->save();
$notifyEmail = trim((string) config('uploads.moderation.notify_email', ''));
if ($notifyEmail !== '') {
try {
Notification::route('mail', $notifyEmail)->notify(
new NewArtworkReviewNotification($artwork, $user, $policy['reasons'])
);
} catch (Throwable) {
// Email failure must not expose the artwork.
}
}
return response()->json([
'success' => true,
'artwork_id' => (int) $artwork->id,
'status' => 'submitted_for_review',
'message' => 'Upload received and queued for moderation.',
'review_reasons' => $policy['reasons'],
], Response::HTTP_OK);
}
// Publish immediately for trusted users only.
$artwork->visibility = $visibility;
$artwork->is_public = ($visibility !== 'private');
$artwork->is_approved = true;
$artwork->approval_source = 'trusted_auto';
$artwork->published_at = now();
$artwork->artwork_status = 'published';
$artwork->publish_at = null;
$artwork->publish_at = null;
$artwork->save();
IndexArtworkJob::dispatch((int) $artwork->id);
// Record upload activity event
try {
\App\Models\ActivityEvent::record(
actorId: (int) $user->id,
type: \App\Models\ActivityEvent::TYPE_UPLOAD,
targetType: \App\Models\ActivityEvent::TARGET_ARTWORK,
targetId: (int) $artwork->id,
ActivityEvent::record(
actorId: (int) $user->id,
type: ActivityEvent::TYPE_UPLOAD,
targetType: ActivityEvent::TARGET_ARTWORK,
targetId: (int) $artwork->id,
);
} catch (\Throwable) {}
} catch (Throwable) {
}
try {
app(UserActivityService::class)->logUpload((int) $user->id, (int) $artwork->id);
} catch (\Throwable) {}
} catch (Throwable) {
}
return response()->json([
'success' => true,
'artwork_id' => (int) $artwork->id,
'status' => 'published',
'slug' => (string) $artwork->slug,
'success' => true,
'artwork_id' => (int) $artwork->id,
'status' => 'published',
'slug' => (string) $artwork->slug,
'published_at' => optional($artwork->published_at)->toISOString(),
], Response::HTTP_OK);
}
@@ -771,11 +843,11 @@ final class UploadController extends Controller
$upload = $publishService->publish($id, $user);
return response()->json([
'success' => true,
'upload_id' => (string) $upload->id,
'status' => (string) $upload->status,
'success' => true,
'upload_id' => (string) $upload->id,
'status' => (string) $upload->status,
'published_at' => optional($upload->published_at)->toISOString(),
'final_path' => (string) ($upload->final_path ?? ''),
'final_path' => (string) ($upload->final_path ?? ''),
], Response::HTTP_OK);
} catch (UploadOwnershipException $e) {
return response()->json(['message' => $e->getMessage()], Response::HTTP_FORBIDDEN);
@@ -794,7 +866,10 @@ final class UploadController extends Controller
'title' => ['nullable', 'string', 'max:150'],
'description' => ['nullable', 'string'],
'category' => ['nullable', 'integer', 'exists:categories,id'],
'tags' => ['nullable', 'array', 'max:' . (int) config('tags.max_user_tags', 30)],
'primary_category_id' => ['nullable', 'integer', 'exists:categories,id'],
'secondary_category_ids' => ['nullable', 'array', 'max:'.(int) config('categories.max_secondary_categories', 5)],
'secondary_category_ids.*' => ['integer', 'exists:categories,id'],
'tags' => ['nullable', 'array', 'max:'.(int) config('tags.max_user_tags', 30)],
'tags.*' => ['string', 'max:64'],
'is_mature' => ['nullable', 'boolean'],
'nsfw' => ['nullable', 'boolean'],
@@ -814,6 +889,8 @@ final class UploadController extends Controller
'world_submissions.*.source_surface' => ['nullable', 'string', 'max:80'],
]);
$this->ensureValidArtworkDescription($validated);
if (! ctype_digit($id)) {
return response()->json(['message' => 'Artwork review submission requires an artwork draft id.'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
@@ -842,4 +919,13 @@ final class UploadController extends Controller
'group_review_status' => (string) $artwork->group_review_status,
], Response::HTTP_OK);
}
private function ensureValidArtworkDescription(array $validated): void
{
foreach (ArtworkDescriptionContentValidator::errors($validated['description'] ?? null) as $message) {
throw ValidationException::withMessages([
'description' => [$message],
]);
}
}
}
@@ -8,11 +8,13 @@ use App\Models\Artwork;
use App\Models\ArtworkDownload;
use App\Services\ArtworkOriginalFileLocator;
use App\Services\ArtworkStatsService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
@@ -43,19 +45,76 @@ final class ArtworkDownloadController extends Controller
private readonly ArtworkOriginalFileLocator $originalFiles,
) {}
public function __invoke(Request $request, int $id): BinaryFileResponse|Response
public function __invoke(Request $request, int $id): BinaryFileResponse|RedirectResponse|Response
{
$artwork = Artwork::query()->find($id);
if (! $artwork) {
abort(404);
return $this->notFound();
}
$filePath = $this->originalFiles->resolveLocalPath($artwork);
$ext = strtolower(ltrim((string) pathinfo($filePath, PATHINFO_EXTENSION), '.'));
$resolution = $this->originalFiles->resolve($artwork);
$filePath = (string) $resolution['path'];
$objectKey = (string) $resolution['object_key'];
$ext = strtolower((string) ($resolution['file_ext'] ?: pathinfo($filePath !== '' ? $filePath : $objectKey, PATHINFO_EXTENSION)));
if ($filePath === '' || ! in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
abort(404);
if (($filePath === '' && $objectKey === '') || ! in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
return $this->notFound();
}
if (! $resolution['exists']) {
Log::warning('Artwork original file missing for download.', [
'artwork_id' => $artwork->id,
'ext' => $ext,
'download_status' => $artwork->download_status,
'canonical_local_exists' => $filePath !== '' && File::isFile($filePath),
'object_exists' => false,
'resolution_status' => $resolution['status'],
]);
return $this->notFound();
}
$downloadName = $this->buildDownloadFilename((string) $artwork->file_name, $ext);
$temporaryUrl = null;
if ($resolution['source'] === 'object') {
try {
$temporaryUrl = Storage::disk((string) $resolution['disk'])->temporaryUrl(
$objectKey,
now()->addMinutes(5),
[
'ResponseContentDisposition' => 'attachment; filename="'.addslashes($downloadName).'"',
],
);
} catch (\Throwable $exception) {
Log::warning('Artwork object temporary URL generation failed.', [
'artwork_id' => $artwork->id,
'disk' => $resolution['disk'],
'object_key' => $objectKey,
'object_exists' => true,
'object_size' => $resolution['size'],
'exception_class' => $exception::class,
'exception_message' => mb_substr($exception->getMessage(), 0, 300),
]);
return $this->notFound();
}
if (! is_string($temporaryUrl) || trim($temporaryUrl) === '') {
Log::warning('Artwork object temporary URL generation failed.', [
'artwork_id' => $artwork->id,
'disk' => $resolution['disk'],
'object_key' => $objectKey,
'resolution_status' => $resolution['status'],
'object_exists' => true,
'object_size' => $resolution['size'],
'exception_class' => 'InvalidTemporaryUrl',
'exception_message' => 'Storage adapter did not return a temporary URL.',
]);
return $this->notFound();
}
}
$this->recordDownload($request, $artwork->id);
@@ -70,18 +129,14 @@ final class ArtworkDownloadController extends Controller
]);
}
if (! File::isFile($filePath)) {
Log::warning('Artwork original file missing for download.', [
'artwork_id' => $artwork->id,
'ext' => $ext,
'resolved_path' => $filePath,
]);
abort(404);
if (array_key_exists('download_status', $artwork->getAttributes())) {
$artwork->forceFill([
'download_status' => 'available',
'download_source' => $resolution['source'],
'download_checked_at' => now(),
])->saveQuietly();
}
$downloadName = $this->buildDownloadFilename((string) $artwork->file_name, $ext);
// X-Accel-Redirect is safe only when nginx is explicitly configured to
// map the internal URI to the originals root. Otherwise fallback to the
// normal Laravel download response.
@@ -90,14 +145,26 @@ final class ArtworkDownloadController extends Controller
return response('', 200, [
'X-Accel-Redirect' => $accelUri,
'Content-Type' => 'application/octet-stream',
'Content-Disposition' => 'attachment; filename="' . addslashes($downloadName) . '"',
'Content-Disposition' => 'attachment; filename="'.addslashes($downloadName).'"',
'X-Content-Type-Options' => 'nosniff',
]);
}
if ($resolution['source'] === 'object') {
return redirect()->away($temporaryUrl);
}
return response()->download($filePath, $downloadName);
}
private function notFound(): Response
{
return response('', 404, [
'Content-Type' => 'text/plain; charset=UTF-8',
'Cache-Control' => 'no-store',
]);
}
private function resolveAccelUri(string $filePath): ?string
{
if (! config('app.download_accel_enabled')) {
@@ -116,7 +183,7 @@ final class ArtworkDownloadController extends Controller
$normalizedRoot = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $root);
$normalizedFilePath = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $filePath);
$rootPrefix = $normalizedRoot . DIRECTORY_SEPARATOR;
$rootPrefix = $normalizedRoot.DIRECTORY_SEPARATOR;
if (! str_starts_with($normalizedFilePath, $rootPrefix)) {
Log::warning('Artwork download accel path skipped because file is outside originals root.', [
@@ -132,7 +199,7 @@ final class ArtworkDownloadController extends Controller
return null;
}
return $accelBase . str_replace(DIRECTORY_SEPARATOR, '/', $relativePath);
return $accelBase.str_replace(DIRECTORY_SEPARATOR, '/', $relativePath);
}
private function recordDownload(Request $request, int $artworkId): void
@@ -187,10 +254,10 @@ final class ArtworkDownloadController extends Controller
$brandSuffix = $this->downloadBrandSuffix();
if ($brandSuffix !== '' && ! Str::contains(Str::lower($baseName), Str::lower($brandSuffix))) {
$baseName .= ' (' . $brandSuffix . ')';
$baseName .= ' ('.$brandSuffix.')';
}
return $baseName . '.' . $ext;
return $baseName.'.'.$ext;
}
private function downloadBrandSuffix(): string
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Artwork;
use App\Services\Enhance\EnhanceService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use RuntimeException;
final class ArtworkEnhanceController extends Controller
{
public function __construct(
private readonly EnhanceService $enhanceService,
) {
}
public function store(Request $request, int $artwork): RedirectResponse
{
$artwork = Artwork::query()->findOrFail($artwork);
$actor = $request->user();
abort_unless($actor !== null, 403);
$isOwner = (int) $artwork->user_id === (int) $actor->id;
$isStaff = $actor->isAdmin() || $actor->isModerator();
abort_unless($isOwner || $isStaff, 403);
$validated = $request->validate([
'scale' => ['required', 'integer', Rule::in((array) config('enhance.allowed_scales', [2, 4]))],
'mode' => ['required', 'string', Rule::in((array) config('enhance.allowed_modes', ['standard', 'artwork', 'photo', 'illustration']))],
]);
try {
$job = $this->enhanceService->createFromArtwork($actor, $artwork, $validated);
} catch (RuntimeException $exception) {
return redirect()
->route('enhance.create', ['artwork' => $artwork->id])
->withErrors([
'source' => $exception->getMessage(),
]);
}
return redirect()
->route('enhance.show', ['enhanceJob' => $job])
->with('success', 'Artwork enhance job created.');
}
}
@@ -42,6 +42,7 @@ class RegisteredUserController extends Controller
return view('auth.register', [
'prefillEmail' => (string) $request->query('email', ''),
'page_canonical' => route('register'),
'turnstile' => [
'enabled' => $this->turnstileVerifier->isEnabled(),
'siteKey' => $this->turnstileVerifier->siteKey(),
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
final class BuildInfoController extends Controller
{
public function __invoke(): JsonResponse
{
$path = base_path('build-info.json');
if (! is_file($path) || ! is_readable($path)) {
return response()->json(['error' => 'Build info unavailable'], 404)
->header('Cache-Control', 'no-store, max-age=0');
}
$decoded = json_decode((string) file_get_contents($path), true);
if (! is_array($decoded)) {
return response()->json(['error' => 'Build info unavailable'], 404)
->header('Cache-Control', 'no-store, max-age=0');
}
$commit = trim((string) ($decoded['git_sha'] ?? ''));
$release = trim((string) ($decoded['release_id'] ?? ''));
$builtAt = trim((string) ($decoded['deployed_at_utc'] ?? ''));
if ($commit === '' || $release === '' || $builtAt === '') {
return response()->json(['error' => 'Build info unavailable'], 404)
->header('Cache-Control', 'no-store, max-age=0');
}
return response()->json([
'environment' => app()->environment(),
'commit' => $commit,
'built_at' => $builtAt,
'release' => $release,
])->header('Cache-Control', 'no-store, max-age=0');
}
}
@@ -3,12 +3,14 @@
namespace App\Http\Controllers\Dashboard;
use App\Http\Controllers\Controller;
use App\Http\Requests\Manage\ManageArtworkDestroyRequest;
use App\Http\Requests\Manage\ManageArtworkEditRequest;
use App\Http\Requests\Manage\ManageArtworkUpdateRequest;
use App\Http\Requests\Manage\ManageArtworkDestroyRequest;
use App\Models\Artwork;
use App\Services\Artworks\ArtworkCategoryService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class ManageController extends Controller
{
@@ -17,14 +19,12 @@ class ManageController extends Controller
$userId = $request->user()->id;
$perPage = 50;
$categorySub = DB::table('artwork_category as ac')
->join('categories as c', 'ac.category_id', '=', 'c.id')
->select('ac.artwork_id', DB::raw('MIN(c.name) as category_name'))
->groupBy('ac.artwork_id');
$categorySub = DB::table('categories as c')
->select('c.id as category_id', 'c.name as category_name');
$query = DB::table('artworks as a')
->leftJoinSub($categorySub, 'cat', function ($join) {
$join->on('a.id', '=', 'cat.artwork_id');
$join->on('a.primary_category_id', '=', 'cat.category_id');
})
->leftJoin('artwork_stats as s', 'a.id', '=', 's.artwork_id')
->where('a.user_id', $userId)
@@ -52,8 +52,8 @@ class ManageController extends Controller
{
$artwork = $request->artwork();
$selectedCategory = DB::table('artwork_category')->where('artwork_id', (int)$id)->value('category_id');
$artwork->category = $selectedCategory;
$selectedCategory = (int) ($artwork->primary_category_id ?: DB::table('artwork_category')->where('artwork_id', (int) $id)->value('category_id'));
$artwork->category = $selectedCategory ?: null;
$categories = DB::table('categories')
->where('content_type_id', 0)
@@ -64,7 +64,7 @@ class ManageController extends Controller
return view('manage.edit', [
'artwork' => $artwork,
'categories' => $categories,
'page_title' => 'Edit Artwork: ' . ($artwork->title ?? ''),
'page_title' => 'Edit Artwork: '.($artwork->title ?? ''),
]);
}
@@ -91,14 +91,14 @@ class ManageController extends Controller
$update['fname'] = basename($attPath);
}
DB::table('artworks')->where('id', (int)$id)->update($update);
DB::table('artworks')->where('id', (int) $id)->update($update);
if (isset($data['section'])) {
DB::table('artwork_category')->where('artwork_id', (int)$id)->delete();
DB::table('artwork_category')->insert([
'artwork_id' => (int)$id,
'category_id' => (int)$data['section'],
]);
$model = Artwork::query()->find((int) $id);
if ($model) {
app(ArtworkCategoryService::class)
->sync($model, (int) $data['section'], [], 'moderator');
}
}
return redirect()->route('manage')->with('status', 'Artwork was successfully updated.');
@@ -108,14 +108,14 @@ class ManageController extends Controller
{
$artwork = $request->artwork();
if (!empty($artwork->fname)) {
Storage::delete('public/uploads/attachments/' . $artwork->fname);
if (! empty($artwork->fname)) {
Storage::delete('public/uploads/attachments/'.$artwork->fname);
}
if (!empty($artwork->picture)) {
Storage::delete('public/uploads/artworks/' . $artwork->picture);
if (! empty($artwork->picture)) {
Storage::delete('public/uploads/artworks/'.$artwork->picture);
}
DB::table('artworks')->where('id', (int)$id)->delete();
DB::table('artworks')->where('id', (int) $id)->delete();
return redirect()->route('manage')->with('status', 'Artwork deleted.');
}
+208
View File
@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Artwork;
use App\Models\EnhanceJob;
use App\Services\Enhance\EnhanceService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
final class EnhanceController extends Controller
{
public function __construct(
private readonly EnhanceService $enhanceService,
) {
}
public function index(Request $request): Response
{
$this->authorize('viewAny', EnhanceJob::class);
$jobs = EnhanceJob::query()
->where('user_id', (int) $request->user()->id)
->with('artwork:id,title,slug')
->latest('id')
->paginate(12)
->withQueryString()
->through(fn (EnhanceJob $job): array => $this->serializeJobListItem($job));
$latestCompleted = EnhanceJob::query()
->where('user_id', (int) $request->user()->id)
->where('status', EnhanceJob::STATUS_COMPLETED)
->latest('finished_at')
->limit(4)
->get()
->map(fn (EnhanceJob $job): array => $this->serializeJobListItem($job))
->all();
return Inertia::render('Enhance/Index', [
'title' => 'Skinbase Enhance',
'jobs' => $jobs,
'latestCompleted' => $latestCompleted,
'createUrl' => route('enhance.create'),
'indexUrl' => route('enhance.index'),
'dailyLimit' => (int) config('enhance.daily_limit', 10),
'enhanceConfig' => $this->enhanceService->frontendConfig(),
]);
}
public function create(Request $request): Response
{
$this->authorize('create', EnhanceJob::class);
$selectedArtwork = null;
if (($artworkId = (int) $request->integer('artwork')) > 0) {
$artwork = Artwork::query()
->select(['id', 'user_id', 'title', 'slug'])
->findOrFail($artworkId);
$actor = $request->user();
abort_unless($actor !== null, 403);
$isOwner = (int) $artwork->user_id === (int) $actor->id;
$isStaff = $actor->isAdmin() || $actor->isModerator();
abort_unless($isOwner || $isStaff, 403);
$selectedArtwork = [
'id' => $artwork->id,
'title' => $artwork->title,
'show_url' => route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug]),
'store_url' => route('artworks.enhance.store', ['artwork' => $artwork->id]),
];
}
return Inertia::render('Enhance/Create', [
'title' => 'Skinbase Enhance',
'options' => $this->optionsPayload(),
'storeUrl' => route('enhance.store'),
'indexUrl' => route('enhance.index'),
'maxUploadMb' => (int) config('enhance.max_upload_mb', 20),
'selectedArtwork' => $selectedArtwork,
'enhanceConfig' => $this->enhanceService->frontendConfig(),
]);
}
public function store(Request $request): RedirectResponse
{
$this->authorize('create', EnhanceJob::class);
$validated = $request->validate([
'image' => ['required', 'file', 'mimetypes:image/jpeg,image/png,image/webp', 'max:' . ((int) config('enhance.max_upload_mb', 20) * 1024)],
'scale' => ['required', 'integer', Rule::in((array) config('enhance.allowed_scales', [2, 4]))],
'mode' => ['required', 'string', Rule::in((array) config('enhance.allowed_modes', ['standard', 'artwork', 'photo', 'illustration']))],
]);
$job = $this->enhanceService->createFromUpload($request->user(), $request->file('image'), $validated);
return redirect()
->route('enhance.show', ['enhanceJob' => $job])
->with('success', 'Enhance job created.');
}
public function show(EnhanceJob $enhanceJob): Response
{
$this->authorize('view', $enhanceJob);
$enhanceJob->loadMissing('artwork:id,title,slug');
return Inertia::render('Enhance/Show', [
'title' => 'Enhance Job',
'job' => $this->serializeJobDetail($enhanceJob),
'indexUrl' => route('enhance.index'),
'createUrl' => route('enhance.create'),
'enhanceConfig' => $this->enhanceService->frontendConfig(),
]);
}
public function retry(EnhanceJob $enhanceJob): RedirectResponse
{
$this->authorize('retry', $enhanceJob);
$job = $this->enhanceService->retry($enhanceJob);
return redirect()
->route('enhance.show', ['enhanceJob' => $job])
->with('success', 'Enhance job queued again.');
}
public function destroy(EnhanceJob $enhanceJob): RedirectResponse
{
$this->authorize('delete', $enhanceJob);
$this->enhanceService->delete($enhanceJob);
return redirect()
->route('enhance.index')
->with('success', 'Enhance job deleted.');
}
private function optionsPayload(): array
{
return [
'modes' => array_map(fn (string $mode): array => [
'value' => $mode,
'label' => ucfirst($mode),
], (array) config('enhance.allowed_modes', [])),
'scales' => array_map(fn (int $scale): array => [
'value' => $scale,
'label' => $scale . 'x',
], array_map('intval', (array) config('enhance.allowed_scales', []))),
];
}
private function serializeJobListItem(EnhanceJob $job): array
{
return [
'id' => $job->id,
'status' => (string) $job->status,
'engine' => (string) $job->engine,
'mode' => (string) $job->mode,
'scale' => (int) $job->scale,
'source_url' => $job->sourceUrl(),
'output_url' => $job->outputUrl(),
'preview_url' => $job->previewUrl(),
'input_width' => (int) ($job->input_width ?? 0),
'input_height' => (int) ($job->input_height ?? 0),
'output_width' => (int) ($job->output_width ?? 0),
'output_height' => (int) ($job->output_height ?? 0),
'error_message' => $job->error_message,
'processing_seconds' => $job->processing_seconds,
'created_at' => optional($job->created_at)?->toIso8601String(),
'finished_at' => optional($job->finished_at)?->toIso8601String(),
'show_url' => route('enhance.show', ['enhanceJob' => $job]),
'artwork' => $job->artwork ? [
'id' => $job->artwork->id,
'title' => $job->artwork->title,
'slug' => $job->artwork->slug,
'url' => route('art.show', ['id' => $job->artwork->id, 'slug' => $job->artwork->slug]),
] : null,
];
}
private function serializeJobDetail(EnhanceJob $job): array
{
return $this->serializeJobListItem($job) + [
'input_filesize' => (int) ($job->input_filesize ?? 0),
'input_mime' => $job->input_mime,
'output_filesize' => (int) ($job->output_filesize ?? 0),
'output_mime' => $job->output_mime,
'metadata' => $job->metadata ?? [],
'queued_at' => optional($job->queued_at)?->toIso8601String(),
'started_at' => optional($job->started_at)?->toIso8601String(),
'deleted_at' => optional($job->deleted_at)?->toIso8601String(),
'expires_at' => optional($job->expires_at)?->toIso8601String(),
'retry_url' => route('enhance.retry', ['enhanceJob' => $job]),
'delete_url' => route('enhance.destroy', ['enhanceJob' => $job]),
'download_url' => $job->outputUrl(),
'can_retry' => auth()->user()?->can('retry', $job) ?? false,
'can_delete' => auth()->user()?->can('delete', $job) ?? false,
];
}
}
+19 -2
View File
@@ -79,6 +79,7 @@ class GroupController extends Controller
{
$this->authorize('view', $group);
$section = in_array($section, ['overview', 'artworks', 'collections', 'members', 'about', 'posts', 'projects', 'releases', 'challenges', 'events', 'activity'], true) ? $section : 'overview';
$viewer = $request->user();
$group->loadMissing('owner.profile');
$members = collect($this->memberships->mapMembers($group, $viewer))
@@ -89,7 +90,8 @@ class GroupController extends Controller
return Inertia::render('Group/GroupShow', [
'group' => $groupPayload,
'section' => in_array($section, ['overview', 'artworks', 'collections', 'members', 'about', 'posts', 'projects', 'releases', 'challenges', 'events', 'activity'], true) ? $section : 'overview',
'section' => $section,
'seo' => $this->seoPayload($group, $section),
'featuredArtworks' => $this->groups->featuredArtworkCards($group),
'artworks' => $this->groups->publicArtworkCards($group),
'featuredCollections' => $this->groups->featuredCollectionCards($group, $viewer),
@@ -140,4 +142,19 @@ class GroupController extends Controller
{
return $this->show($request, $group, 'activity');
}
}
private function seoPayload(Group $group, string $section): array
{
$canonical = $section === 'overview'
? route('groups.show', ['group' => $group])
: route('groups.section', ['group' => $group, 'section' => $section]);
$sectionLabel = $section === 'overview' ? '' : ' '.ucfirst($section);
return [
'title' => trim($group->name.$sectionLabel.' - Skinbase'),
'description' => $group->headline ?: $group->bio ?: 'Skinbase group',
'canonical' => $canonical,
'og_url' => $canonical,
];
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Internal;
use App\Http\Controllers\Controller;
use App\Models\EnhanceJob;
use App\Services\Enhance\EnhanceStorageService;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Throwable;
final class EnhanceSourceController extends Controller
{
public function __construct(
private readonly EnhanceStorageService $storage,
) {
}
public function show(Request $request, EnhanceJob $enhanceJob): Response
{
abort_unless($request->hasValidSignature(), 403);
abort_unless($this->storage->isEnhancePath($enhanceJob->source_path), 404);
try {
$binary = $this->storage->fetchSourceBinary($enhanceJob);
} catch (Throwable) {
abort(404);
}
return response($binary, 200, [
'Content-Type' => trim((string) ($enhanceJob->input_mime ?: 'application/octet-stream')),
'Content-Length' => (string) strlen($binary),
'Cache-Control' => 'private, max-age=60',
'Content-Disposition' => 'inline; filename="enhance-source-' . $enhanceJob->id . '"',
]);
}
}
@@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
use App\Models\Artwork;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Schema;
final class LegacyArtworkPhotoController extends Controller
@@ -26,25 +27,39 @@ final class LegacyArtworkPhotoController extends Controller
private static ?bool $hasLegacyIdColumn = null;
public function __invoke(string $encoded, string $size, string $extension): RedirectResponse
public function __invoke(string $encoded, string $size, string $extension): RedirectResponse|Response
{
$artworkId = $this->decodeBase62($encoded);
$sizeCode = (int) $size;
abort_if($artworkId === null || $artworkId < 1, 404);
if ($artworkId === null || $artworkId < 1) {
return $this->notFound();
}
$artwork = $this->resolveArtwork($artworkId);
abort_unless($artwork !== null, 404);
if ($artwork === null) {
return $this->notFound();
}
$targetUrl = $sizeCode === 7
? $this->resolveOriginalUrl($artwork)
: $artwork->thumbUrl(self::THUMB_SIZE_MAP[$sizeCode] ?? 'md');
abort_if(empty($targetUrl), 404);
if ($targetUrl === null || $targetUrl === '') {
return $this->notFound();
}
return redirect()->away($targetUrl, 301);
}
private function notFound(): Response
{
return response('', 404, [
'Content-Type' => 'text/plain; charset=UTF-8',
'Cache-Control' => 'no-store',
]);
}
private function decodeBase62(string $value): ?int
{
if ($value === '') {
@@ -86,11 +101,6 @@ final class LegacyArtworkPhotoController extends Controller
{
$cdn = rtrim((string) config('cdn.files_url', 'https://cdn.skinbase.org'), '/');
$filePath = trim((string) ($artwork->file_path ?? ''), '/');
if ($filePath !== '') {
return $cdn . '/' . $filePath;
}
$hash = strtolower((string) preg_replace('/[^a-f0-9]/i', '', (string) ($artwork->hash ?? '')));
$ext = ltrim((string) ($artwork->file_ext ?: $artwork->thumb_ext ?: 'webp'), '.');
@@ -98,6 +108,10 @@ final class LegacyArtworkPhotoController extends Controller
return $artwork->thumbUrl('xl') ?? $artwork->thumbUrl('lg') ?? $artwork->thumbUrl('md');
}
if ($filePath !== '') {
return $cdn . '/' . $filePath;
}
$prefix = trim((string) config('uploads.object_storage.prefix', 'artworks'), '/');
$firstDir = substr($hash, 0, 2);
$secondDir = substr($hash, 2, 2);
@@ -3,11 +3,12 @@
namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Artwork;
use App\Models\ArtworkDownload;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use App\Services\ThumbnailPresenter;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class TodayDownloadsController extends Controller
{
@@ -35,16 +36,16 @@ class TodayDownloadsController extends Controller
$art = $row->artwork ?? null;
// If Eloquent didn't eager load artwork (group queries sometimes don't), fetch it
if (! $art && isset($row->artwork_id)) {
$art = \App\Models\Artwork::find($row->artwork_id);
$art = Artwork::find($row->artwork_id);
}
$name = $art->title ?? null;
$picture = $art->file_name ?? null;
$ext = pathinfo($picture ?? '', PATHINFO_EXTENSION) ?: 'jpg';
$encoded = null; // legacy encoding unavailable; leave null
$present = $art ? \App\Services\ThumbnailPresenter::present($art, 'md') : null;
$present = $art ? ThumbnailPresenter::present($art, 'md') : null;
$thumb = $present ? $present['url'] : 'https://files.skinbase.org/default/missing_md.webp';
$categoryId = $art->categories->first()->id ?? null;
$categoryId = $art->resolvedPrimaryCategory()?->id;
return (object) [
'id' => $art->id ?? null,
+78 -33
View File
@@ -3,12 +3,12 @@
namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use App\Services\AvatarService;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Schema;
class UserController extends Controller
{
@@ -37,7 +37,7 @@ class UserController extends Controller
}
} else {
// Map legacy form fields into the modern schema.
$data = $request->only(['name','web','country_code','signature','description','about_me']);
$data = $request->only(['name', 'web', 'country_code', 'signature', 'description', 'about_me']);
// Core user column: `name`
if (isset($data['name'])) {
@@ -46,24 +46,43 @@ class UserController extends Controller
// Collect other profile updates to persist into `user_profiles` when available
$profileUpdates = [];
if (!empty($data['web'])) $profileUpdates['website'] = $data['web'];
if (!empty($data['signature'])) $profileUpdates['signature'] = $data['signature'];
if (!empty($data['description'])) $profileUpdates['description'] = $data['description'];
if (!empty($data['about_me'])) $profileUpdates['about'] = $data['about_me'];
if (!empty($data['country_code'])) $profileUpdates['country_code'] = $data['country_code'];
if (! empty($data['web'])) {
$profileUpdates['website'] = $data['web'];
}
if (! empty($data['signature'])) {
$profileUpdates['signature'] = $data['signature'];
}
if (! empty($data['description'])) {
$profileUpdates['description'] = $data['description'];
}
if (! empty($data['about_me'])) {
$profileUpdates['about'] = $data['about_me'];
}
if (! empty($data['country_code'])) {
$profileUpdates['country_code'] = $data['country_code'];
}
$d1 = $request->input('date1');
$d2 = $request->input('date2');
$d3 = $request->input('date3');
if ($d1 && $d2 && $d3) {
$profileUpdates['birthdate'] = sprintf('%04d-%02d-%02d', (int)$d3, (int)$d2, (int)$d1);
$profileUpdates['birthdate'] = sprintf('%04d-%02d-%02d', (int) $d3, (int) $d2, (int) $d1);
}
$userGender = $request->input('gender', $user->gender);
if (!empty($userGender)) {
$g = strtolower($userGender);
$map = ['m' => 'M', 'f' => 'F', 'n' => 'X', 'x' => 'X'];
$profileUpdates['gender'] = $map[$g] ?? strtoupper($userGender);
if ($userGender !== null && $userGender !== '') {
$g = strtolower(trim((string) $userGender));
$map = [
'm' => 'M',
'male' => 'M',
'f' => 'F',
'female' => 'F',
'n' => 'X',
'x' => 'X',
'na' => 'X',
'n/a' => 'X',
];
$profileUpdates['gender'] = $map[$g] ?? null;
}
$profileUpdates['mlist'] = $request->has('newsletter') ? 1 : 0;
@@ -79,19 +98,27 @@ class UserController extends Controller
}
}
$allowedLegacyMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if ($request->hasFile('personal_picture')) {
$f = $request->file('personal_picture');
$name = $user->id . '.' . $f->getClientOriginalExtension();
$f->move(public_path('user-picture'), $name);
$profileUpdates['cover_image'] = $name;
$user->picture = $name;
if (in_array($f->getMimeType(), $allowedLegacyMimes, true)) {
$ext = $f->guessExtension() ?: 'jpg';
$name = $user->id.'.'.$ext;
$f->move(public_path('user-picture'), $name);
$profileUpdates['cover_image'] = $name;
$user->picture = $name;
}
}
if ($request->hasFile('emotion_icon')) {
$f = $request->file('emotion_icon');
$name = $user->id . '.' . $f->getClientOriginalExtension();
$f->move(public_path('emotion'), $name);
$user->eicon = $name;
if (in_array($f->getMimeType(), $allowedLegacyMimes, true)) {
$ext = $f->guessExtension() ?: 'jpg';
$name = $user->id.'.'.$ext;
$f->move(public_path('emotion'), $name);
$user->eicon = $name;
}
}
// Save core user fields
@@ -99,9 +126,9 @@ class UserController extends Controller
// Persist profile updates into `user_profiles` when available, otherwise fallback to `users` table
try {
if (!empty($profileUpdates) && Schema::hasTable('user_profiles')) {
if (! empty($profileUpdates) && Schema::hasTable('user_profiles')) {
DB::table('user_profiles')->updateOrInsert(['user_id' => $user->id], $profileUpdates + ['updated_at' => now(), 'created_at' => now()]);
} elseif (!empty($profileUpdates)) {
} elseif (! empty($profileUpdates)) {
DB::table('users')->where('id', $user->id)->update($profileUpdates);
}
} catch (\Throwable $e) {
@@ -135,15 +162,33 @@ class UserController extends Controller
$profile = DB::table('user_profiles')->where('user_id', $user->id)->first();
if ($profile) {
// map modern profile fields onto the legacy user properties/helpers used by the view
if (isset($profile->website)) $user->homepage = $profile->website;
if (isset($profile->about)) $user->about_me = $profile->about;
if (isset($profile->birthdate)) $user->birth = $profile->birthdate;
if (isset($profile->gender)) $user->gender = $profile->gender;
if (isset($profile->country_code)) $user->country_code = $profile->country_code;
if (isset($profile->avatar_hash)) $user->icon = $profile->avatar_hash;
if (isset($profile->cover_image)) $user->picture = $profile->cover_image;
if (isset($profile->signature)) $user->signature = $profile->signature;
if (isset($profile->description)) $user->description = $profile->description;
if (isset($profile->website)) {
$user->homepage = $profile->website;
}
if (isset($profile->about)) {
$user->about_me = $profile->about;
}
if (isset($profile->birthdate)) {
$user->birth = $profile->birthdate;
}
if (isset($profile->gender)) {
$user->gender = $profile->gender;
}
if (isset($profile->country_code)) {
$user->country_code = $profile->country_code;
}
if (isset($profile->avatar_hash)) {
$user->icon = $profile->avatar_hash;
}
if (isset($profile->cover_image)) {
$user->picture = $profile->cover_image;
}
if (isset($profile->signature)) {
$user->signature = $profile->signature;
}
if (isset($profile->description)) {
$user->description = $profile->description;
}
}
}
} catch (\Throwable $e) {
@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Moderation;
use App\Http\Controllers\Controller;
use App\Models\EnhanceJob;
use App\Services\Enhance\EnhanceService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class ModerationEnhanceController extends Controller
{
public function __construct(
private readonly EnhanceService $enhanceService,
) {
}
public function index(Request $request): Response
{
$filters = [
'status' => trim((string) $request->query('status', 'all')),
'engine' => trim((string) $request->query('engine', 'all')),
'mode' => trim((string) $request->query('mode', 'all')),
'scale' => trim((string) $request->query('scale', 'all')),
'user' => trim((string) $request->query('user', '')),
'date_from' => trim((string) $request->query('date_from', '')),
'date_to' => trim((string) $request->query('date_to', '')),
];
$jobs = EnhanceJob::query()
->with(['user:id,name,username', 'artwork:id,title,slug'])
->when($filters['status'] !== '' && $filters['status'] !== 'all', fn ($query) => $query->where('status', $filters['status']))
->when($filters['engine'] !== '' && $filters['engine'] !== 'all', fn ($query) => $query->where('engine', $filters['engine']))
->when($filters['mode'] !== '' && $filters['mode'] !== 'all', fn ($query) => $query->where('mode', $filters['mode']))
->when($filters['scale'] !== '' && $filters['scale'] !== 'all', fn ($query) => $query->where('scale', (int) $filters['scale']))
->when($filters['user'] !== '', function ($query) use ($filters): void {
$query->whereHas('user', function ($userQuery) use ($filters): void {
$userQuery
->where('name', 'like', '%' . $filters['user'] . '%')
->orWhere('username', 'like', '%' . $filters['user'] . '%');
});
})
->when($filters['date_from'] !== '', fn ($query) => $query->whereDate('created_at', '>=', $filters['date_from']))
->when($filters['date_to'] !== '', fn ($query) => $query->whereDate('created_at', '<=', $filters['date_to']))
->latest('id')
->paginate(20)
->withQueryString()
->through(fn (EnhanceJob $job): array => $this->serializeJob($job));
return Inertia::render('Moderation/Enhance/Index', [
'title' => 'Enhance Jobs',
'jobs' => $jobs,
'filters' => $filters,
'options' => [
'statuses' => ['all', 'pending', 'queued', 'processing', 'completed', 'failed', 'cancelled', 'expired'],
'engines' => ['all', 'stub', 'external_worker'],
'modes' => array_merge(['all'], (array) config('enhance.allowed_modes', [])),
'scales' => array_merge(['all'], array_map('intval', (array) config('enhance.allowed_scales', []))),
],
'indexUrl' => route('admin.enhance.index'),
'enhanceConfig' => $this->enhanceService->frontendConfig(),
])->rootView('moderation');
}
public function show(EnhanceJob $enhanceJob): Response
{
$enhanceJob->loadMissing(['user:id,name,username', 'artwork:id,title,slug']);
return Inertia::render('Moderation/Enhance/Show', [
'title' => 'Enhance Job #' . $enhanceJob->id,
'job' => $this->serializeJob($enhanceJob, true),
'indexUrl' => route('admin.enhance.index'),
'enhanceConfig' => $this->enhanceService->frontendConfig(),
])->rootView('moderation');
}
public function retry(EnhanceJob $enhanceJob): RedirectResponse
{
$this->authorize('retry', $enhanceJob);
$job = $this->enhanceService->retry($enhanceJob);
return redirect()
->route('admin.enhance.show', ['enhanceJob' => $job])
->with('success', 'Enhance job queued again.');
}
public function markFailed(Request $request, EnhanceJob $enhanceJob): RedirectResponse
{
$this->authorize('markFailed', $enhanceJob);
$job = $this->enhanceService->markFailedByModerator($enhanceJob, $request->user());
return redirect()
->route('admin.enhance.show', ['enhanceJob' => $job])
->with('success', 'Enhance job marked as failed.');
}
public function destroy(EnhanceJob $enhanceJob): RedirectResponse
{
$this->authorize('delete', $enhanceJob);
$this->enhanceService->delete($enhanceJob);
return redirect()
->route('admin.enhance.index')
->with('success', 'Enhance job deleted.');
}
private function serializeJob(EnhanceJob $job, bool $detailed = false): array
{
return [
'id' => $job->id,
'status' => (string) $job->status,
'engine' => (string) $job->engine,
'mode' => (string) $job->mode,
'scale' => (int) $job->scale,
'source_url' => $job->sourceUrl(),
'output_url' => $job->outputUrl(),
'preview_url' => $job->previewUrl(),
'input_width' => (int) ($job->input_width ?? 0),
'input_height' => (int) ($job->input_height ?? 0),
'input_filesize' => (int) ($job->input_filesize ?? 0),
'input_mime' => $job->input_mime,
'output_width' => (int) ($job->output_width ?? 0),
'output_height' => (int) ($job->output_height ?? 0),
'output_filesize' => (int) ($job->output_filesize ?? 0),
'output_mime' => $job->output_mime,
'processing_seconds' => $job->processing_seconds,
'error_message' => $job->error_message,
'metadata' => $job->metadata ?? [],
'created_at' => optional($job->created_at)?->toIso8601String(),
'queued_at' => optional($job->queued_at)?->toIso8601String(),
'started_at' => optional($job->started_at)?->toIso8601String(),
'finished_at' => optional($job->finished_at)?->toIso8601String(),
'expires_at' => optional($job->expires_at)?->toIso8601String(),
'user' => $job->user ? [
'id' => $job->user->id,
'name' => $job->user->name,
'username' => $job->user->username,
] : null,
'artwork' => $job->artwork ? [
'id' => $job->artwork->id,
'title' => $job->artwork->title,
'slug' => $job->artwork->slug,
'url' => route('art.show', ['id' => $job->artwork->id, 'slug' => $job->artwork->slug]),
] : null,
'show_url' => route('admin.enhance.show', ['enhanceJob' => $job]),
'download_url' => $job->outputUrl(),
'retry_url' => route('admin.enhance.retry', ['enhanceJob' => $job]),
'mark_failed_url' => route('admin.enhance.mark-failed', ['enhanceJob' => $job]),
'delete_url' => route('admin.enhance.destroy', ['enhanceJob' => $job]),
'can_retry' => $job->status === EnhanceJob::STATUS_FAILED,
'can_mark_failed' => in_array($job->status, [EnhanceJob::STATUS_PENDING, EnhanceJob::STATUS_QUEUED, EnhanceJob::STATUS_PROCESSING], true),
'detailed' => $detailed,
];
}
}
@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Moderation;
use App\Http\Controllers\Controller;
use App\Models\StaffApplication;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class StaffApplicationsController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'q' => trim((string) $request->query('q', '')),
'topic' => trim((string) $request->query('topic', 'all')),
];
$query = StaffApplication::query()->latest('created_at');
if ($filters['q'] !== '') {
$search = $filters['q'];
$query->where(function ($builder) use ($search): void {
$builder
->where('name', 'like', '%' . $search . '%')
->orWhere('email', 'like', '%' . $search . '%')
->orWhere('role', 'like', '%' . $search . '%')
->orWhere('message', 'like', '%' . $search . '%');
});
}
if ($filters['topic'] !== '' && $filters['topic'] !== 'all') {
$query->where('topic', $filters['topic']);
}
$items = $query
->paginate(20)
->withQueryString()
->through(fn (StaffApplication $application): array => $this->serializeApplication($application));
$stats = [
'total' => StaffApplication::query()->count(),
'applications' => StaffApplication::query()->where('topic', 'application')->count(),
'bug' => StaffApplication::query()->where('topic', 'bug')->count(),
'contact' => StaffApplication::query()->where('topic', 'contact')->count(),
'other' => StaffApplication::query()->whereNotIn('topic', ['application', 'bug', 'contact'])->count(),
];
$topics = StaffApplication::query()
->select('topic')
->distinct()
->orderBy('topic')
->pluck('topic')
->values()
->all();
return Inertia::render('Moderation/StaffApplications/Index', [
'title' => 'Staff Applications',
'items' => $items,
'filters' => $filters,
'stats' => $stats,
'topics' => $topics,
'endpoints' => [
'index' => route('admin.staff-applications.index'),
],
])->rootView('moderation');
}
public function show(StaffApplication $staffApplication): Response
{
return Inertia::render('Moderation/StaffApplications/Show', [
'title' => 'Staff Application',
'item' => $this->serializeApplication($staffApplication, true),
'backUrl' => route('admin.staff-applications.index'),
])->rootView('moderation');
}
private function serializeApplication(StaffApplication $application, bool $detailed = false): array
{
return [
'id' => (string) $application->id,
'topic' => (string) ($application->topic ?: 'contact'),
'name' => (string) ($application->name ?: 'Unknown'),
'email' => (string) ($application->email ?: ''),
'role' => $application->role,
'portfolio' => $application->portfolio,
'message' => $application->message,
'ip' => $application->ip,
'user_agent' => $application->user_agent,
'created_at' => optional($application->created_at)?->toIso8601String(),
'payload' => $detailed ? ($application->payload ?? []) : [],
'show_url' => route('admin.staff-applications.show', ['staffApplication' => $application]),
];
}
}
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Moderation;
use App\Http\Controllers\Controller;
use App\Models\Story;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class StoriesController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'q' => trim((string) $request->query('q', '')),
'status' => trim((string) $request->query('status', 'all')),
];
$query = Story::query()
->with('creator:id,name,username')
->latest('created_at')
->latest('id');
if ($filters['q'] !== '') {
$search = $filters['q'];
$query->where(function ($builder) use ($search): void {
$builder
->where('title', 'like', '%' . $search . '%')
->orWhere('slug', 'like', '%' . $search . '%')
->orWhereHas('creator', function ($creatorQuery) use ($search): void {
$creatorQuery
->where('name', 'like', '%' . $search . '%')
->orWhere('username', 'like', '%' . $search . '%');
});
});
}
if ($filters['status'] !== '' && $filters['status'] !== 'all') {
$query->where('status', $filters['status']);
}
$stories = $query
->paginate(24)
->withQueryString()
->through(fn (Story $story): array => $this->serializeStory($story));
$statsQuery = Story::query();
if ($filters['q'] !== '') {
$search = $filters['q'];
$statsQuery->where(function ($builder) use ($search): void {
$builder
->where('title', 'like', '%' . $search . '%')
->orWhere('slug', 'like', '%' . $search . '%')
->orWhereHas('creator', function ($creatorQuery) use ($search): void {
$creatorQuery
->where('name', 'like', '%' . $search . '%')
->orWhere('username', 'like', '%' . $search . '%');
});
});
}
$stats = [
'total' => (clone $statsQuery)->count(),
'published' => (clone $statsQuery)->where('status', 'published')->count(),
'draft' => (clone $statsQuery)->where('status', 'draft')->count(),
'scheduled' => (clone $statsQuery)->where('status', 'scheduled')->count(),
'pending_review' => (clone $statsQuery)->where('status', 'pending_review')->count(),
'archived' => (clone $statsQuery)->where('status', 'archived')->count(),
];
return Inertia::render('Moderation/Stories', [
'title' => 'Stories',
'stories' => $stories,
'filters' => $filters,
'stats' => $stats,
'endpoints' => [
'index' => route('admin.stories'),
],
])->rootView('moderation');
}
private function serializeStory(Story $story): array
{
return [
'id' => (int) $story->id,
'title' => (string) ($story->title ?: 'Untitled story'),
'slug' => (string) $story->slug,
'excerpt' => $story->excerpt,
'status' => (string) ($story->status ?: 'draft'),
'published_at' => optional($story->published_at)?->toIso8601String(),
'created_at' => optional($story->created_at)?->toIso8601String(),
'cover_url' => $story->coverUrl,
'public_url' => $story->url,
'open_url' => $story->status === 'published' ? $story->url : null,
'creator' => $story->creator ? [
'id' => (int) $story->creator->id,
'name' => (string) $story->creator->name,
'username' => (string) $story->creator->username,
] : null,
];
}
}
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Moderation;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia;
use Inertia\Response;
final class UsernameQueueController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'q' => trim((string) $request->query('q', '')),
'status' => trim((string) $request->query('status', 'pending')),
];
$requestColumns = Schema::hasTable('username_approval_requests')
? Schema::getColumnListing('username_approval_requests')
: [];
$query = DB::table('username_approval_requests as requests')
->leftJoin('users', 'users.id', '=', 'requests.user_id')
->select([
'requests.id',
'requests.user_id',
'requests.requested_username',
'requests.status',
'requests.context',
'requests.similar_to',
'requests.review_note',
'requests.reviewed_at',
'requests.created_at',
'users.username as current_username',
'users.name as current_name',
])
->orderByDesc('requests.created_at');
if ($filters['status'] !== '' && $filters['status'] !== 'all') {
$query->where('requests.status', $filters['status']);
}
if ($filters['q'] !== '') {
$search = $filters['q'];
$query->where(function ($builder) use ($search): void {
$builder
->where('requests.requested_username', 'like', '%' . $search . '%')
->orWhere('requests.context', 'like', '%' . $search . '%')
->orWhere('users.username', 'like', '%' . $search . '%')
->orWhere('users.name', 'like', '%' . $search . '%');
});
}
$requests = $query->paginate(20)->withQueryString()->through(function ($row): array {
return [
'id' => (int) $row->id,
'user_id' => $row->user_id !== null ? (int) $row->user_id : null,
'requested_username' => (string) $row->requested_username,
'status' => (string) ($row->status ?? 'pending'),
'context' => $row->context ?? null,
'similar_to' => $row->similar_to ?? null,
'review_note' => $row->review_note ?? null,
'reviewed_at' => $this->serializeTimestamp($row->reviewed_at ?? null),
'created_at' => $this->serializeTimestamp($row->created_at ?? null),
'current_username' => $row->current_username,
'current_name' => $row->current_name,
'approve_url' => route('api.admin.usernames.approve', ['id' => $row->id]),
'reject_url' => route('api.admin.usernames.reject', ['id' => $row->id]),
];
});
$stats = [
'total' => Schema::hasTable('username_approval_requests') ? DB::table('username_approval_requests')->count() : 0,
'pending' => Schema::hasTable('username_approval_requests') ? DB::table('username_approval_requests')->where('status', 'pending')->count() : 0,
'approved' => Schema::hasTable('username_approval_requests') ? DB::table('username_approval_requests')->where('status', 'approved')->count() : 0,
'rejected' => Schema::hasTable('username_approval_requests') ? DB::table('username_approval_requests')->where('status', 'rejected')->count() : 0,
];
return Inertia::render('Moderation/UsernameQueue', [
'title' => 'Username Queue',
'requests' => $requests,
'stats' => $stats,
'filters' => $filters,
'options' => [
'statuses' => [
['value' => 'all', 'label' => 'All statuses'],
['value' => 'pending', 'label' => 'Pending'],
['value' => 'approved', 'label' => 'Approved'],
['value' => 'rejected', 'label' => 'Rejected'],
],
],
'endpoints' => [
'index' => route('admin.usernames'),
'refresh' => route('admin.usernames'),
],
])->rootView('moderation');
}
private function serializeTimestamp(mixed $value): ?string
{
if ($value === null || $value === '') {
return null;
}
try {
return \Illuminate\Support\Carbon::parse((string) $value)->toIso8601String();
} catch (\Throwable) {
return null;
}
}
}
+40 -7
View File
@@ -132,6 +132,32 @@ class NewsController extends Controller
] + $this->sidebarData());
}
// -----------------------------------------------------------------------
// Type page — /news/type/{type}
// -----------------------------------------------------------------------
public function type(Request $request, string $type): View
{
$typeLabels = \cPad\Plugins\News\Models\NewsArticle::TYPE_LABELS;
abort_unless(array_key_exists($type, $typeLabels), 404);
$label = $typeLabels[$type];
$perPage = config('news.articles_per_page', 12);
$articles = NewsArticle::with('author', 'category')
->published()
->where('type', $type)
->editorialOrder()
->paginate($perPage);
return view('news.type', [
'type' => $type,
'typeLabel' => $label,
'articles' => $articles,
] + $this->sidebarData());
}
// -----------------------------------------------------------------------
// Article page — /news/{slug}
// -----------------------------------------------------------------------
@@ -173,14 +199,21 @@ class NewsController extends Controller
return;
}
NewsView::create([
'article_id' => $article->id,
'user_id' => $userId,
'ip' => $ip,
'created_at' => now(),
]);
try {
NewsView::create([
'article_id' => $article->id,
'user_id' => $userId,
'ip' => $ip,
'created_at' => now(),
]);
$article->incrementViews();
$article->incrementViews();
} catch (\Illuminate\Database\QueryException $e) {
// Unique constraint violation — duplicate view, skip silently.
if (($e->errorInfo[1] ?? 0) !== 1062) {
throw $e;
}
}
if ($canReadSession) {
$request->session()->put($session, true);
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\News;
use App\Http\Controllers\Controller;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
use cPad\Plugins\News\Models\NewsArticle;
class NewsRssController extends Controller
@@ -14,13 +15,17 @@ class NewsRssController extends Controller
*/
public function feed(): Response
{
$articles = NewsArticle::with('author', 'category')
->published()
->orderByDesc('published_at')
->limit(config('news.rss_limit', 25))
->get();
$ttl = max(60, (int) config('news.rss_cache_ttl', 300));
$xml = $this->buildRss($articles);
$xml = Cache::remember('news.rss.feed', $ttl, function (): string {
$articles = NewsArticle::with('author', 'category')
->published()
->orderByDesc('published_at')
->limit(config('news.rss_limit', 25))
->get();
return $this->buildRss($articles);
});
return response($xml, 200, [
'Content-Type' => 'application/rss+xml; charset=UTF-8',
@@ -6,11 +6,13 @@ namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Models\AcademyContentMetricDaily;
use App\Models\AcademyEvent;
use App\Models\AcademySearchLog;
use App\Services\Academy\AcademyAnalyticsContentResolver;
use App\Services\Academy\AcademyContentIntelligenceService;
use App\Services\Academy\AcademyPopularityService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\AcademyAnalytics\AcademyAnalyticsEventType;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
@@ -29,6 +31,9 @@ final class AcademyAdminAnalyticsController extends Controller
public function overview(Request $request): Response
{
[$from, $to, $range] = $this->resolveDateRange($request);
$promptLibraryCurrent = $this->contentSummary(AcademyAnalyticsContentType::PROMPT_LIBRARY, $from, $to);
[$previousFrom, $previousTo] = $this->previousRange($from, $to);
$promptLibraryPrevious = $this->contentSummary(AcademyAnalyticsContentType::PROMPT_LIBRARY, $previousFrom, $previousTo);
$summary = $this->metricsQuery($from, $to)
->selectRaw('sum(views) as views, sum(unique_visitors) as unique_visitors, sum(user_views) as user_views, sum(guest_views) as guest_views, sum(subscriber_views) as subscriber_views, sum(prompt_copies) as prompt_copies, sum(likes) as likes, sum(saves) as saves, sum(completions) as completions, sum(starts) as starts, sum(upgrade_clicks) as upgrade_clicks')
@@ -50,6 +55,21 @@ final class AcademyAdminAnalyticsController extends Controller
'courseStarts' => (int) ($summary?->starts ?? 0),
'upgradeClicks' => (int) ($summary?->upgrade_clicks ?? 0),
],
'promptLibraryTrend' => [
'current' => $promptLibraryCurrent,
'previous' => $promptLibraryPrevious,
'deltas' => [
'views' => $this->percentDelta((int) $promptLibraryCurrent['views'], (int) $promptLibraryPrevious['views']),
'uniqueVisitors' => $this->percentDelta((int) $promptLibraryCurrent['uniqueVisitors'], (int) $promptLibraryPrevious['uniqueVisitors']),
'engagedViews' => $this->percentDelta((int) $promptLibraryCurrent['engagedViews'], (int) $promptLibraryPrevious['engagedViews']),
'engagementRate' => $this->percentDelta((float) $promptLibraryCurrent['engagementRate'], (float) $promptLibraryPrevious['engagementRate']),
],
'range' => [
'current' => ['from' => $from->toDateString(), 'to' => $to->toDateString()],
'previous' => ['from' => $previousFrom->toDateString(), 'to' => $previousTo->toDateString()],
],
],
'popularPromptPeriodUsage' => $this->popularPromptPeriodUsage($from, $to),
'topContent' => $this->serializeContentRows($this->popularity->topContent($from, $to, 8)),
'topWeek' => $this->serializeContentRows($this->popularity->topContent(now()->subDays(6)->startOfDay(), now()->endOfDay(), 8)),
]);
@@ -65,6 +85,11 @@ final class AcademyAdminAnalyticsController extends Controller
return $this->renderContentPage($request, AcademyAnalyticsContentType::PROMPT, 'Prompt analytics', 'Copy-heavy prompt performance, save rates, and upgrade interest.');
}
public function promptLibrary(Request $request): Response
{
return $this->renderContentPage($request, AcademyAnalyticsContentType::PROMPT_LIBRARY, 'Prompt library analytics', 'Discovery and engagement on the public /academy/prompts library page.');
}
public function lessons(Request $request): Response
{
return $this->renderContentPage($request, AcademyAnalyticsContentType::LESSON, 'Lesson analytics', 'Lesson engagement, starts, completions, and drop-off signals.');
@@ -333,9 +358,14 @@ final class AcademyAdminAnalyticsController extends Controller
'access' => $access,
'content_type' => $contentType,
],
'summary' => $contentType === AcademyAnalyticsContentType::PROMPT_LIBRARY
? $this->contentSummary(AcademyAnalyticsContentType::PROMPT_LIBRARY, $from, $to)
: null,
'rows' => $serializedRows,
'contentTypeOptions' => [
['value' => '', 'label' => 'All content'],
['value' => AcademyAnalyticsContentType::PROMPT_LIBRARY, 'label' => 'Prompt library'],
['value' => AcademyAnalyticsContentType::PROMPT_PACK_LIBRARY, 'label' => 'Prompt pack library'],
['value' => AcademyAnalyticsContentType::PROMPT, 'label' => 'Prompts'],
['value' => AcademyAnalyticsContentType::LESSON, 'label' => 'Lessons'],
['value' => AcademyAnalyticsContentType::COURSE, 'label' => 'Courses'],
@@ -359,7 +389,134 @@ final class AcademyAdminAnalyticsController extends Controller
private function metricsQuery(Carbon $from, Carbon $to)
{
return AcademyContentMetricDaily::query()
->whereBetween('date', [$from->toDateString(), $to->toDateString()]);
->whereBetween('date', [$from->copy()->startOfDay(), $to->copy()->endOfDay()]);
}
/**
* @return array<string, int|float>
*/
private function contentSummary(string $contentType, Carbon $from, Carbon $to): array
{
$query = $this->metricsQuery($from, $to)
->where('content_type', $contentType);
if (! AcademyAnalyticsContentType::requiresContentId($contentType)) {
$query->whereNull('content_id');
}
$summary = $query
->selectRaw('sum(views) as views, sum(unique_visitors) as unique_visitors, sum(engaged_views) as engaged_views, sum(scroll_50) as scroll_50, sum(scroll_75) as scroll_75, sum(scroll_100) as scroll_100, avg(avg_engaged_seconds) as avg_engaged_seconds, sum(popularity_score) as popularity_score')
->first();
$uniqueVisitors = max(0, (int) ($summary?->unique_visitors ?? 0));
$engagedViews = max(0, (int) ($summary?->engaged_views ?? 0));
$scroll100 = max(0, (int) ($summary?->scroll_100 ?? 0));
return [
'views' => max(0, (int) ($summary?->views ?? 0)),
'uniqueVisitors' => $uniqueVisitors,
'engagedViews' => $engagedViews,
'scroll50' => max(0, (int) ($summary?->scroll_50 ?? 0)),
'scroll75' => max(0, (int) ($summary?->scroll_75 ?? 0)),
'scroll100' => $scroll100,
'avgEngagedSeconds' => round((float) ($summary?->avg_engaged_seconds ?? 0), 1),
'popularityScore' => round((float) ($summary?->popularity_score ?? 0), 2),
'engagementRate' => $uniqueVisitors > 0 ? round(($engagedViews / $uniqueVisitors) * 100, 1) : 0.0,
'deepScrollRate' => $uniqueVisitors > 0 ? round(($scroll100 / $uniqueVisitors) * 100, 1) : 0.0,
];
}
/**
* @return array{0: Carbon, 1: Carbon}
*/
private function previousRange(Carbon $from, Carbon $to): array
{
$days = $from->copy()->startOfDay()->diffInDays($to->copy()->startOfDay()) + 1;
return [
$from->copy()->subDays($days)->startOfDay(),
$from->copy()->subDay()->endOfDay(),
];
}
private function percentDelta(int|float $current, int|float $previous): ?float
{
if ((float) $previous === 0.0) {
return (float) $current === 0.0 ? 0.0 : null;
}
return round((((float) $current - (float) $previous) / (float) $previous) * 100, 1);
}
/**
* @return array{totalViews:int,totalVisitors:int,periods:list<array<string,int|float|string>>}
*/
private function popularPromptPeriodUsage(Carbon $from, Carbon $to): array
{
$events = AcademyEvent::query()
->whereBetween('occurred_at', [$from, $to])
->where('event_type', AcademyAnalyticsEventType::PAGE_VIEW)
->where('content_type', AcademyAnalyticsContentType::PROMPT_POPULAR)
->get(['visitor_id', 'metadata']);
$summary = [];
$totalViews = 0;
$visitorBuckets = [];
foreach ($events as $event) {
$metadata = is_array($event->metadata) ? $event->metadata : [];
$period = trim((string) ($metadata['period'] ?? ''));
if ($period === '') {
continue;
}
$days = max(0, (int) ($metadata['period_days'] ?? 0));
if (! isset($summary[$period])) {
$summary[$period] = [
'period' => $period,
'label' => sprintf('%s days', $days > 0 ? $days : (int) preg_replace('/\D+/', '', $period)),
'views' => 0,
'uniqueVisitors' => 0,
'share' => 0.0,
'days' => $days,
];
$visitorBuckets[$period] = [];
}
$summary[$period]['views']++;
$totalViews++;
$visitorId = trim((string) ($event->visitor_id ?? ''));
if ($visitorId !== '') {
$visitorBuckets[$period][$visitorId] = true;
}
}
$totalVisitors = 0;
foreach ($summary as $period => &$row) {
$uniqueVisitors = count($visitorBuckets[$period] ?? []);
$row['uniqueVisitors'] = $uniqueVisitors;
$row['share'] = $totalViews > 0 ? round((((int) $row['views']) / $totalViews) * 100, 1) : 0.0;
$totalVisitors += $uniqueVisitors;
}
unset($row);
usort($summary, static function (array $left, array $right): int {
if ((int) $right['views'] === (int) $left['views']) {
return ((int) $left['days']) <=> ((int) $right['days']);
}
return ((int) $right['views']) <=> ((int) $left['views']);
});
return [
'totalViews' => $totalViews,
'totalVisitors' => $totalVisitors,
'periods' => array_values($summary),
];
}
/**
@@ -440,6 +597,7 @@ final class AcademyAdminAnalyticsController extends Controller
['label' => 'Overview', 'href' => route('admin.academy.analytics.overview')],
['label' => 'Intelligence', 'href' => route('admin.academy.analytics.intelligence')],
['label' => 'Content', 'href' => route('admin.academy.analytics.content')],
['label' => 'Prompt Library', 'href' => route('admin.academy.analytics.prompt-library')],
['label' => 'Prompts', 'href' => route('admin.academy.analytics.prompts')],
['label' => 'Lessons', 'href' => route('admin.academy.analytics.lessons')],
['label' => 'Courses', 'href' => route('admin.academy.analytics.courses')],
@@ -17,6 +17,7 @@ use App\Models\AcademyBadge;
use App\Models\AcademyCategory;
use App\Models\AcademyChallenge;
use App\Models\AcademyChallengeSubmission;
use App\Models\AcademyContentMetricDaily;
use App\Models\AcademyCourse;
use App\Models\AcademyCourseLesson;
use App\Models\AcademyCourseSection;
@@ -31,6 +32,7 @@ use App\Services\Academy\AcademyAdminBillingOverviewService;
use App\Services\Academy\AcademyCacheService;
use App\Services\Academy\AcademyCourseLessonOrderingService;
use App\Services\Academy\AcademyLessonMarkdownRenderer;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
@@ -604,34 +606,48 @@ final class AcademyAdminController extends Controller
$meta = $this->resourceMeta($resource);
$search = trim((string) request()->query('search', ''));
$query = $meta['model']::query();
$filters = [
'search' => $search,
];
$summary = null;
if ($resource === 'courses') {
$query->withCount('courseLessons');
if ($search !== '') {
$query->where(function ($builder) use ($search): void {
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
$builder->where('title', 'like', $like)
->orWhere('slug', 'like', $like)
->orWhere('subtitle', 'like', $like)
->orWhere('excerpt', 'like', $like)
->orWhere('description', 'like', $like);
});
$this->applyCourseAdminSearch($query, $search);
}
$query->orderByDesc('is_featured')
->orderBy('order_num')
->orderByDesc('updated_at')
->orderByDesc('id');
} elseif ($resource === 'prompts') {
$query->with('category');
$query->withSum(['metrics as total_views' => function ($builder): void {
$builder->where('content_type', AcademyAnalyticsContentType::PROMPT);
}], 'views');
$promptFilters = [
'category' => (string) request()->query('category', 'all'),
'featured' => (string) request()->query('featured', 'all'),
'prompt_of_week' => (string) request()->query('prompt_of_week', 'all'),
'active' => (string) request()->query('active', 'all'),
'access_level' => (string) request()->query('access_level', 'all'),
'difficulty' => (string) request()->query('difficulty', 'all'),
'order' => (string) request()->query('order', 'updated_desc'),
];
$filters = array_merge($filters, $promptFilters);
$this->applyPromptAdminSearch($query, $search);
$this->applyPromptAdminFilters($query, $promptFilters);
$this->applyPromptAdminOrdering($query, $promptFilters['order']);
$summary = $this->promptAdminSummary($search, $promptFilters);
} else {
$query->latest('updated_at');
}
if ($resource === 'prompts') {
$query->with('category');
}
if ($resource === 'lessons') {
$query->with('courses:id,title');
}
@@ -646,48 +662,191 @@ final class AcademyAdminController extends Controller
'items' => $items,
'columns' => $meta['columns'],
'createUrl' => route($meta['route_base'].'.create'),
'filters' => [
'search' => $search,
],
'filters' => $filters,
'filterOptions' => $resource === 'prompts' ? [
'categories' => $this->promptAdminCategoryFilterOptions(),
'difficulty' => $this->filterOptionsWithAll($this->difficultyOptions(), 'All difficulties'),
'access' => $this->filterOptionsWithAll($this->accessOptions(), 'All access levels'),
'featured' => [
['value' => 'all', 'label' => 'Any featured state'],
['value' => 'yes', 'label' => 'Featured only'],
['value' => 'no', 'label' => 'Not featured'],
],
'promptOfWeek' => [
['value' => 'all', 'label' => 'Any weekly state'],
['value' => 'yes', 'label' => 'Prompt of the week'],
['value' => 'no', 'label' => 'Not prompt of the week'],
],
'active' => [
['value' => 'all', 'label' => 'Any visibility state'],
['value' => 'active', 'label' => 'Active only'],
['value' => 'inactive', 'label' => 'Inactive only'],
],
'order' => [
['value' => 'updated_desc', 'label' => 'Updated newest'],
['value' => 'updated_asc', 'label' => 'Updated oldest'],
['value' => 'views_desc', 'label' => 'Most viewed'],
['value' => 'title_asc', 'label' => 'Title A-Z'],
['value' => 'title_desc', 'label' => 'Title Z-A'],
['value' => 'access_asc', 'label' => 'Access'],
['value' => 'difficulty_asc', 'label' => 'Difficulty'],
['value' => 'featured_desc', 'label' => 'Featured first'],
],
] : null,
'summary' => $resource === 'courses' ? [
'total' => (int) $items->total(),
'published' => (int) (clone $meta['model']::query())->when($search !== '', function ($builder) use ($search): void {
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
$builder->where(function ($inner) use ($like): void {
$inner->where('title', 'like', $like)
->orWhere('slug', 'like', $like)
->orWhere('subtitle', 'like', $like)
->orWhere('excerpt', 'like', $like)
->orWhere('description', 'like', $like);
});
})->where('status', AcademyCourse::STATUS_PUBLISHED)->count(),
'featured' => (int) (clone $meta['model']::query())->when($search !== '', function ($builder) use ($search): void {
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
$builder->where(function ($inner) use ($like): void {
$inner->where('title', 'like', $like)
->orWhere('slug', 'like', $like)
->orWhere('subtitle', 'like', $like)
->orWhere('excerpt', 'like', $like)
->orWhere('description', 'like', $like);
});
})->where('is_featured', true)->count(),
'drafts' => (int) (clone $meta['model']::query())->when($search !== '', function ($builder) use ($search): void {
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
$builder->where(function ($inner) use ($like): void {
$inner->where('title', 'like', $like)
->orWhere('slug', 'like', $like)
->orWhere('subtitle', 'like', $like)
->orWhere('excerpt', 'like', $like)
->orWhere('description', 'like', $like);
});
})->where('status', AcademyCourse::STATUS_DRAFT)->count(),
] : null,
'published' => (int) (clone $meta['model']::query())->tap(fn ($builder) => $this->applyCourseAdminSearch($builder, $search))->where('status', AcademyCourse::STATUS_PUBLISHED)->count(),
'featured' => (int) (clone $meta['model']::query())->tap(fn ($builder) => $this->applyCourseAdminSearch($builder, $search))->where('is_featured', true)->count(),
'drafts' => (int) (clone $meta['model']::query())->tap(fn ($builder) => $this->applyCourseAdminSearch($builder, $search))->where('status', AcademyCourse::STATUS_DRAFT)->count(),
] : $summary,
]);
}
private function applyCourseAdminSearch($query, string $search): void
{
if ($search === '') {
return;
}
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
$query->where(function ($builder) use ($like): void {
$builder->where('title', 'like', $like)
->orWhere('slug', 'like', $like)
->orWhere('subtitle', 'like', $like)
->orWhere('excerpt', 'like', $like)
->orWhere('description', 'like', $like);
});
}
private function applyPromptAdminSearch($query, string $search): void
{
if ($search === '') {
return;
}
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
$query->where(function ($builder) use ($like): void {
$builder->where('title', 'like', $like)
->orWhere('slug', 'like', $like)
->orWhere('excerpt', 'like', $like)
->orWhere('prompt', 'like', $like)
->orWhere('negative_prompt', 'like', $like)
->orWhere('usage_notes', 'like', $like)
->orWhere('workflow_notes', 'like', $like)
->orWhereHas('category', function ($categoryQuery) use ($like): void {
$categoryQuery->where('name', 'like', $like);
});
});
}
private function applyPromptAdminFilters($query, array $filters, bool $includeAccessFilter = true): void
{
$category = (string) ($filters['category'] ?? 'all');
$featured = (string) ($filters['featured'] ?? 'all');
$promptOfWeek = (string) ($filters['prompt_of_week'] ?? 'all');
$active = (string) ($filters['active'] ?? 'all');
$accessLevel = (string) ($filters['access_level'] ?? 'all');
$difficulty = (string) ($filters['difficulty'] ?? 'all');
if ($category === 'uncategorized') {
$query->whereNull('category_id');
} elseif ($category !== '' && $category !== 'all' && ctype_digit($category)) {
$query->where('category_id', (int) $category);
}
if ($featured === 'yes') {
$query->where('featured', true);
} elseif ($featured === 'no') {
$query->where('featured', false);
}
if ($promptOfWeek === 'yes') {
$query->where('prompt_of_week', true);
} elseif ($promptOfWeek === 'no') {
$query->where('prompt_of_week', false);
}
if ($active === 'active') {
$query->where('active', true);
} elseif ($active === 'inactive') {
$query->where('active', false);
}
if ($includeAccessFilter && in_array($accessLevel, ['free', 'creator', 'pro'], true)) {
$query->where('access_level', $accessLevel);
}
if (in_array($difficulty, array_column($this->difficultyOptions(), 'value'), true)) {
$query->where('difficulty', $difficulty);
}
}
private function applyPromptAdminOrdering($query, string $order): void
{
match ($order) {
'updated_asc' => $query->orderBy('updated_at')->orderBy('id'),
'views_desc' => $query->orderByDesc('total_views')->orderByDesc('updated_at')->orderByDesc('id'),
'title_asc' => $query->orderBy('title')->orderByDesc('updated_at'),
'title_desc' => $query->orderByDesc('title')->orderByDesc('updated_at'),
'access_asc' => $query->orderByRaw("FIELD(access_level, 'free', 'creator', 'pro')")->orderBy('title'),
'difficulty_asc' => $query->orderBy('difficulty')->orderBy('title'),
'featured_desc' => $query->orderByDesc('featured')->orderByDesc('prompt_of_week')->orderBy('title'),
default => $query->orderByDesc('updated_at')->orderByDesc('id'),
};
}
private function promptAdminSummary(string $search, array $filters): array
{
$summaryQuery = AcademyPromptTemplate::query();
$accessSummaryQuery = AcademyPromptTemplate::query();
$this->applyPromptAdminSearch($summaryQuery, $search);
$this->applyPromptAdminFilters($summaryQuery, $filters);
$this->applyPromptAdminSearch($accessSummaryQuery, $search);
$this->applyPromptAdminFilters($accessSummaryQuery, $filters, false);
return [
'total' => (int) $summaryQuery->count(),
'active' => (int) (clone $summaryQuery)->where('active', true)->count(),
'featured' => (int) (clone $summaryQuery)->where('featured', true)->count(),
'promptOfWeek' => (int) (clone $summaryQuery)->where('prompt_of_week', true)->count(),
'access' => [
'free' => (int) (clone $accessSummaryQuery)->where('access_level', 'free')->count(),
'creator' => (int) (clone $accessSummaryQuery)->where('access_level', 'creator')->count(),
'pro' => (int) (clone $accessSummaryQuery)->where('access_level', 'pro')->count(),
],
];
}
private function promptAdminCategoryFilterOptions(): array
{
return AcademyCategory::query()
->where('type', 'prompt')
->orderBy('order_num')
->orderBy('name')
->get()
->map(fn (AcademyCategory $category): array => ['value' => (string) $category->id, 'label' => $category->name])
->prepend(['value' => 'uncategorized', 'label' => 'Uncategorized'])
->prepend(['value' => 'all', 'label' => 'All categories'])
->values()
->all();
}
private function filterOptionsWithAll(array $options, string $allLabel): array
{
return collect($options)
->map(fn (array $option): array => [
'value' => (string) ($option['value'] ?? ''),
'label' => (string) ($option['label'] ?? $option['value'] ?? ''),
])
->prepend(['value' => 'all', 'label' => $allLabel])
->values()
->all();
}
private function renderForm(string $resource, Model $record): Response
{
$meta = $this->resourceMeta($resource);
@@ -767,6 +926,17 @@ final class AcademyAdminController extends Controller
];
}
if ($resource === 'challenges') {
return [
'links' => array_filter([
'preview' => $record->exists ? route('academy.challenges.show', ['slug' => $record->slug]) : null,
]),
'coverUploadUrl' => route('api.studio.academy.lessons.media.upload'),
'coverDeleteUrl' => route('api.studio.academy.lessons.media.destroy'),
'coverCdnBaseUrl' => rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'),
];
}
if ($resource !== 'lessons') {
return [];
}
@@ -882,7 +1052,7 @@ final class AcademyAdminController extends Controller
'singular' => 'prompt template',
'subtitle' => 'Manage prompt previews, premium prompts, and prompt of the week.',
'route_base' => 'admin.academy.prompts',
'columns' => ['title', 'difficulty', 'access_level', 'prompt_of_week', 'active'],
'columns' => ['title', 'category_name', 'difficulty', 'access_level', 'prompt_of_week', 'active'],
'fields' => [
['name' => 'category_id', 'label' => 'Category', 'type' => 'select', 'options' => $this->categoryOptions('prompt')],
['name' => 'title', 'label' => 'Title', 'type' => 'text'],
@@ -896,6 +1066,7 @@ final class AcademyAdminController extends Controller
['name' => 'placeholders', 'label' => 'Placeholders JSON', 'type' => 'json'],
['name' => 'helper_prompts', 'label' => 'Helper Prompts JSON', 'type' => 'json'],
['name' => 'prompt_variants', 'label' => 'Prompt Variants JSON', 'type' => 'json'],
['name' => 'filled_examples', 'label' => 'Filled Examples JSON', 'type' => 'json'],
['name' => 'difficulty', 'label' => 'Difficulty', 'type' => 'select', 'options' => $this->difficultyOptions()],
['name' => 'access_level', 'label' => 'Access', 'type' => 'select', 'options' => $this->accessOptions()],
['name' => 'aspect_ratio', 'label' => 'Aspect Ratio', 'type' => 'text'],
@@ -1037,6 +1208,7 @@ final class AcademyAdminController extends Controller
'active' => (bool) $model->active,
'preview_image_url' => $this->resolvePromptPreviewImageUrl((string) ($model->preview_image ?? '')),
'comparisons_count' => count($this->serializePromptToolNotes((array) ($model->tool_notes ?? []))),
'views_count' => (int) ($model->total_views ?? 0),
'tags' => array_values(array_filter(array_map(static fn ($tag): string => trim((string) $tag), (array) ($model->tags ?? [])))),
'updated_at' => optional($model->updated_at)->toIso8601String(),
'preview_url' => route('academy.prompts.show', ['slug' => $model->slug]),
@@ -1156,6 +1328,7 @@ final class AcademyAdminController extends Controller
'placeholders' => $this->encodePrettyJsonForForm($record->placeholders),
'helper_prompts' => $this->encodePrettyJsonForForm($record->helper_prompts),
'prompt_variants' => $this->encodePrettyJsonForForm($record->prompt_variants),
'filled_examples' => $this->encodePrettyJsonForForm($record->filled_examples),
'difficulty' => (string) ($record->difficulty ?? 'beginner'),
'access_level' => (string) ($record->access_level ?? 'free'),
'aspect_ratio' => (string) ($record->aspect_ratio ?? ''),
@@ -1200,6 +1373,7 @@ final class AcademyAdminController extends Controller
'voting_starts_at' => optional($record->voting_starts_at)?->format('Y-m-d\TH:i'),
'voting_ends_at' => optional($record->voting_ends_at)?->format('Y-m-d\TH:i'),
'cover_image' => (string) ($record->cover_image ?? ''),
'cover_image_url' => $this->resolveLessonCoverImageUrl((string) ($record->cover_image ?? '')),
'prize_text' => (string) ($record->prize_text ?? ''),
'required_tags' => implode(', ', (array) ($record->required_tags ?? [])),
'allowed_categories' => implode(', ', (array) ($record->allowed_categories ?? [])),
@@ -2162,6 +2336,7 @@ final class AcademyAdminController extends Controller
$validated['placeholders'] = $this->normalizePromptPlaceholders($validated['placeholders'] ?? null);
$validated['helper_prompts'] = $this->normalizePromptHelperPrompts($validated['helper_prompts'] ?? null);
$validated['prompt_variants'] = $this->normalizePromptVariants($validated['prompt_variants'] ?? null);
$validated['filled_examples'] = $this->normalizePromptFilledExamples($validated['filled_examples'] ?? null);
$validated['tool_notes'] = $this->normalizePromptToolNotes((array) ($validated['tool_notes'] ?? []));
$previousToolNotes = $this->normalizePromptToolNotes((array) ($prompt?->tool_notes ?? []));
@@ -2370,6 +2545,56 @@ final class AcademyAdminController extends Controller
->all();
}
/**
* @return array<int, array<string, mixed>>
*/
private function normalizePromptFilledExamples(mixed $filledExamples): array
{
if (! is_array($filledExamples)) {
return [];
}
return collect($filledExamples)
->filter(static fn ($example): bool => is_array($example))
->map(function (array $example): array {
return [
'title' => $this->nullableTrimmedString($example['title'] ?? null),
'description' => $this->nullableTrimmedString($example['description'] ?? null),
'placeholder_values' => collect(is_array($example['placeholder_values'] ?? null) ? $example['placeholder_values'] : [])
->mapWithKeys(function ($value, $key): array {
$normalizedKey = trim((string) $key);
if ($normalizedKey === '') {
return [];
}
$normalizedValue = $this->normalizePromptJsonValue($value);
if ($normalizedValue === null || $normalizedValue === '' || $normalizedValue === []) {
return [];
}
return [$normalizedKey => $normalizedValue];
})
->all(),
'prompt' => $this->nullableTrimmedString($example['prompt'] ?? null),
'negative_prompt' => $this->nullableTrimmedString($example['negative_prompt'] ?? null),
];
})
->filter(function (array $example): bool {
return collect([
$example['title'] ?? null,
$example['description'] ?? null,
$example['prompt'] ?? null,
$example['negative_prompt'] ?? null,
$example['placeholder_values'] ?? null,
])->contains(fn ($item): bool => $item !== null && $item !== '' && $item !== []);
})
->take(5)
->values()
->all();
}
/**
* @return array<int, string>
*/
@@ -27,8 +27,10 @@ class FeaturedArtworkAdminController extends Controller
{
$isAdminSurface = $request->routeIs('admin.artworks.featured.*');
$routePrefix = $isAdminSurface ? 'admin.artworks.featured.' : 'admin.cp.artworks.featured.';
$pageName = $isAdminSurface ? 'Moderation/FeaturedArtworks' : 'Collection/FeaturedArtworksAdmin';
$rootView = $isAdminSurface ? 'moderation' : 'collections';
return Inertia::render($isAdminSurface ? 'Admin/FeaturedArtworks' : 'Collection/FeaturedArtworksAdmin', array_merge(
return Inertia::render($pageName, array_merge(
$this->featuredArtworks->pageProps(),
[
'endpoints' => [
@@ -49,7 +51,7 @@ class FeaturedArtworkAdminController extends Controller
'robots' => 'index,follow',
],
],
))->rootView($isAdminSurface ? 'admin' : 'collections');
))->rootView($rootView);
}
public function search(Request $request): JsonResponse
@@ -215,4 +217,4 @@ class FeaturedArtworkAdminController extends Controller
{
return Schema::hasColumn('artwork_features', 'force_hero');
}
}
}
+33 -9
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Controllers;
use App\Services\Sitemaps\PublishedSitemapResolver;
use App\Services\Sitemaps\SitemapBuildService;
use App\Services\Sitemaps\SitemapXmlRenderer;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -14,34 +15,50 @@ final class SitemapController extends Controller
{
public function __construct(
private readonly PublishedSitemapResolver $published,
private readonly SitemapBuildService $build,
private readonly SitemapXmlRenderer $renderer,
) {
}
public function index(): Response|BinaryFileResponse
{
// 1. Static file written by the build/generate commands.
// On production nginx serves this directly via try_files without reaching PHP.
// On dev / misconfigured servers we stream it with sendfile — no RAM load.
$path = public_path('sitemap.xml');
if (file_exists($path)) {
return $this->xmlFileResponse($path);
if ((bool) config('sitemaps.pre_generated.enabled', true)) {
foreach ([
public_path('sitemaps/index.xml'),
public_path('sitemaps/sitemap.xml'),
public_path('sitemap.xml'),
] as $path) {
if (is_file($path) && is_readable($path)) {
return $this->xmlFileResponse($path);
}
}
}
// 2. Published release (release management pipeline fallback).
// 1. Published release (release management pipeline fallback).
$published = $this->published->resolveIndex();
if ($published !== null) {
return $this->renderer->xmlResponse($published['content']);
}
// 2. Live-build fallback when no published sitemap is available.
if ((bool) config('sitemaps.delivery.fallback_to_live_build', true)) {
$built = $this->build->buildIndex(force: true, persist: false);
return $this->renderer->xmlResponse($built['content']);
}
throw new NotFoundHttpException();
}
public function show(string $name): Response|BinaryFileResponse
{
if ($name === 'sitemap') {
return $this->index();
}
// 1. Static file.
$path = public_path('sitemaps/' . $name . '.xml');
if (file_exists($path)) {
if ((bool) config('sitemaps.pre_generated.enabled', true) && file_exists($path)) {
return $this->xmlFileResponse($path);
}
@@ -51,6 +68,13 @@ final class SitemapController extends Controller
return $this->renderer->xmlResponse($published['content']);
}
if ((bool) config('sitemaps.delivery.fallback_to_live_build', true)) {
$built = $this->build->buildNamed($name, force: true, persist: false);
if ($built !== null) {
return $this->renderer->xmlResponse($built['content']);
}
}
throw new NotFoundHttpException();
}
@@ -61,4 +85,4 @@ final class SitemapController extends Controller
'Cache-Control' => 'public, max-age=' . max(60, (int) config('sitemaps.cache_ttl_seconds', 900)),
]);
}
}
}
@@ -6,26 +6,34 @@ namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Models\ArtworkVersion;
use App\Models\Category;
use App\Models\ContentType;
use App\Models\ArtworkVersion;
use App\Services\ArtworkEvolutionService;
use App\Services\Cdn\ArtworkCdnPurgeService;
use App\Services\ArtworkSearchIndexer;
use App\Notifications\NewArtworkReviewNotification;
use App\Repositories\Uploads\ArtworkFileRepository;
use App\Services\ArtworkAttributionService;
use App\Services\ArtworkEvolutionService;
use App\Services\Artworks\ArtworkCategoryService;
use App\Services\Artworks\ArtworkPublicationService;
use App\Services\TagService;
use App\Services\ArtworkSearchIndexer;
use App\Services\ArtworkVersioningService;
use App\Services\Cdn\ArtworkCdnPurgeService;
use App\Services\Moderation\ArtworkUploadPolicy;
use App\Services\Studio\StudioArtworkQueryService;
use App\Services\Studio\StudioBulkActionService;
use App\Services\Tags\TagDiscoveryService;
use App\Services\TagService;
use App\Services\Uploads\UploadDerivativesService;
use App\Services\Uploads\UploadStorageService;
use App\Services\Worlds\WorldSubmissionService;
use App\Support\ArtworkDescriptionContentValidator;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
@@ -44,6 +52,7 @@ final class StudioArtworksApiController extends Controller
private readonly TagService $tagService,
private readonly ArtworkCdnPurgeService $cdnPurge,
private readonly ArtworkPublicationService $artworkPublication,
private readonly ArtworkCategoryService $artworkCategories,
) {}
/**
@@ -73,9 +82,9 @@ final class StudioArtworksApiController extends Controller
'data' => $items,
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
],
]);
}
@@ -87,14 +96,14 @@ final class StudioArtworksApiController extends Controller
public function bulk(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'action' => 'required|string|in:publish,unpublish,archive,unarchive,delete,change_category,add_tags,remove_tags',
'action' => 'required|string|in:publish,unpublish,archive,unarchive,delete,change_category,add_tags,remove_tags',
'artwork_ids' => 'required|array|min:1|max:200',
'artwork_ids.*' => 'integer',
'params' => 'sometimes|array',
'params' => 'sometimes|array',
'params.category_id' => 'sometimes|integer|exists:categories,id',
'params.tag_ids' => 'sometimes|array',
'params.tag_ids.*' => 'integer|exists:tags,id',
'confirm' => 'required_if:action,delete|string',
'params.tag_ids' => 'sometimes|array',
'params.tag_ids.*' => 'integer|exists:tags,id',
'confirm' => 'required_if:action,delete|string',
]);
if ($validator->fails()) {
@@ -126,23 +135,26 @@ final class StudioArtworksApiController extends Controller
* PUT /api/studio/artworks/{id}
* Update artwork details (title, description, visibility).
*/
public function update(Request $request, int $id, ArtworkAttributionService $attribution, WorldSubmissionService $submissions): JsonResponse
public function update(Request $request, int $id, ArtworkAttributionService $attribution, WorldSubmissionService $submissions, ArtworkUploadPolicy $uploadPolicy): JsonResponse
{
$artwork = $request->user()->artworks()->findOrFail($id);
$evolution = app(ArtworkEvolutionService::class);
$validated = $request->validate([
'title' => 'sometimes|string|max:255',
'title' => 'sometimes|string|max:255',
'description' => 'sometimes|nullable|string|max:5000',
'is_public' => 'sometimes|boolean',
'visibility' => 'sometimes|string|in:public,unlisted,private',
'mode' => 'sometimes|string|in:now,schedule',
'publish_at' => 'sometimes|nullable|string|date',
'timezone' => 'sometimes|nullable|string|max:64',
'is_public' => 'sometimes|boolean',
'visibility' => 'sometimes|string|in:public,unlisted,private',
'mode' => 'sometimes|string|in:now,schedule',
'publish_at' => 'sometimes|nullable|string|date',
'timezone' => 'sometimes|nullable|string|max:64',
'category_id' => 'sometimes|nullable|integer|exists:categories,id',
'primary_category_id' => 'sometimes|nullable|integer|exists:categories,id',
'secondary_category_ids' => 'sometimes|array|max:'.(int) config('categories.max_secondary_categories', 5),
'secondary_category_ids.*' => 'integer|exists:categories,id',
'content_type_id' => 'sometimes|nullable|integer|exists:content_types,id',
'tags' => 'sometimes|array|max:' . (int) config('tags.max_user_tags', 30),
'tags.*' => 'string|max:64',
'tags' => 'sometimes|array|max:'.(int) config('tags.max_user_tags', 30),
'tags.*' => 'string|max:64',
'title_source' => 'sometimes|nullable|string|in:manual,ai_generated,ai_applied,mixed',
'description_source' => 'sometimes|nullable|string|in:manual,ai_generated,ai_applied,mixed',
'tags_source' => 'sometimes|nullable|string|in:manual,ai_generated,ai_applied,mixed',
@@ -164,6 +176,8 @@ final class StudioArtworksApiController extends Controller
'evolution_note' => 'sometimes|nullable|string|max:1200',
]);
$this->ensureValidArtworkDescription($validated);
$hasAttributionUpdates = array_key_exists('group', $validated)
|| array_key_exists('primary_author_user_id', $validated)
|| array_key_exists('contributor_user_ids', $validated)
@@ -207,14 +221,18 @@ final class StudioArtworksApiController extends Controller
// Extract tags and category before updating core fields
$tags = $validated['tags'] ?? null;
$categoryId = $validated['category_id'] ?? null;
$categoryId = $validated['primary_category_id'] ?? $validated['category_id'] ?? null;
$secondaryCategoryIds = array_key_exists('secondary_category_ids', $validated)
? array_values(array_map('intval', $validated['secondary_category_ids'] ?? []))
: [];
$hasSecondaryCategoryUpdate = array_key_exists('secondary_category_ids', $validated);
$contentTypeId = $validated['content_type_id'] ?? null;
$evolutionPayload = [
'target_artwork_id' => $validated['evolution_target_artwork_id'] ?? null,
'relation_type' => $validated['evolution_relation_type'] ?? null,
'note' => $validated['evolution_note'] ?? null,
];
unset($validated['tags'], $validated['category_id'], $validated['content_type_id'], $validated['visibility'], $validated['mode'], $validated['publish_at'], $validated['timezone'], $validated['group'], $validated['primary_author_user_id'], $validated['contributor_user_ids'], $validated['contributor_credits'], $validated['world_submissions']);
unset($validated['tags'], $validated['category_id'], $validated['primary_category_id'], $validated['secondary_category_ids'], $validated['content_type_id'], $validated['visibility'], $validated['mode'], $validated['publish_at'], $validated['timezone'], $validated['group'], $validated['primary_author_user_id'], $validated['contributor_user_ids'], $validated['contributor_credits'], $validated['world_submissions']);
unset($validated['evolution_target_artwork_id'], $validated['evolution_relation_type'], $validated['evolution_note']);
$validated['visibility'] = $visibility;
@@ -228,9 +246,17 @@ final class StudioArtworksApiController extends Controller
$validated['artwork_status'] = 'scheduled';
} else {
$validated['is_public'] = $visibility !== Artwork::VISIBILITY_PRIVATE;
$validated['is_approved'] = true;
$policy = $uploadPolicy->assess($request->user(), $artwork);
$validated['is_approved'] = ! $policy['requires_review'];
$validated['approval_source'] = $policy['requires_review'] ? null : 'trusted_auto';
$validated['publish_at'] = null;
$validated['artwork_status'] = 'published';
$validated['artwork_status'] = $policy['requires_review'] ? 'review' : 'published';
if ($policy['requires_review']) {
$validated['is_public'] = false;
$validated['published_at'] = null;
$validated['moderation_note'] = implode(', ', $policy['reasons']);
}
if (($validated['is_public'] ?? false) && ! $artwork->published_at) {
$validated['published_at'] = now();
@@ -246,10 +272,19 @@ final class StudioArtworksApiController extends Controller
}
$artwork->update($validated);
$requiresReview = ($policy['requires_review'] ?? false) === true;
// Sync category
if ($categoryId !== null) {
$artwork->categories()->sync([(int) $categoryId]);
if ($categoryId !== null || $hasSecondaryCategoryUpdate) {
try {
$this->artworkCategories->sync(
$artwork,
$categoryId !== null ? (int) $categoryId : (int) $artwork->primary_category_id,
$hasSecondaryCategoryUpdate ? $secondaryCategoryIds : ($categoryId !== null ? [] : null),
(string) ($validated['category_source'] ?? $artwork->category_source ?? 'manual'),
);
} catch (ValidationException $exception) {
return response()->json(['errors' => $exception->errors()], 422);
}
}
// Sync tags through the shared tag service so pivot source/usage rules stay valid.
@@ -288,24 +323,40 @@ final class StudioArtworksApiController extends Controller
// Reindex in Meilisearch — dispatches IndexArtworkJob which writes directly, no Scout hop.
$this->searchIndexer->update($artwork);
if ($requiresReview) {
$notifyEmail = trim((string) config('uploads.moderation.notify_email', ''));
if ($notifyEmail !== '') {
try {
Notification::route('mail', $notifyEmail)->notify(
new NewArtworkReviewNotification($artwork, $request->user(), $policy['reasons'])
);
} catch (\Throwable) {
// Keep the artwork private if notification delivery fails.
}
}
}
// Reload relationships for response
$artwork->load(['categories.contentType', 'tags', 'group', 'primaryAuthor.profile', 'contributors.user.profile']);
$primaryCategory = $artwork->categories->first();
$artwork->load(['categories.contentType', 'primaryCategory.contentType', 'tags', 'group', 'primaryAuthor.profile', 'contributors.user.profile']);
$primaryCategory = $artwork->resolvedPrimaryCategory();
return response()->json([
'success' => true,
'status' => $requiresReview ? 'submitted_for_review' : 'published',
'message' => $requiresReview ? 'Artwork saved and queued for moderation.' : 'Artwork updated successfully.',
'review_reasons' => $requiresReview ? $policy['reasons'] : [],
'artwork' => [
'id' => $artwork->id,
'title' => $artwork->title,
'description' => $artwork->description,
'is_public' => (bool) $artwork->is_public,
'visibility' => $artwork->visibility ?: ((bool) $artwork->is_public ? Artwork::VISIBILITY_PUBLIC : Artwork::VISIBILITY_PRIVATE),
'publish_mode' => $artwork->artwork_status === 'scheduled' ? 'schedule' : 'now',
'publish_at' => $artwork->publish_at?->toIso8601String(),
'artwork_status' => $artwork->artwork_status,
'id' => $artwork->id,
'title' => $artwork->title,
'description' => $artwork->description,
'is_public' => (bool) $artwork->is_public,
'visibility' => $artwork->visibility ?: ((bool) $artwork->is_public ? Artwork::VISIBILITY_PUBLIC : Artwork::VISIBILITY_PRIVATE),
'publish_mode' => $artwork->artwork_status === 'scheduled' ? 'schedule' : 'now',
'publish_at' => $artwork->publish_at?->toIso8601String(),
'artwork_status' => $artwork->artwork_status,
'artwork_timezone' => $artwork->artwork_timezone,
'slug' => $artwork->slug,
'group_slug' => $artwork->group?->slug,
'slug' => $artwork->slug,
'group_slug' => $artwork->group?->slug,
'primary_author_user_id' => (int) ($artwork->primary_author_user_id ?: $artwork->user_id),
'contributor_user_ids' => $artwork->contributors->pluck('user_id')->map(fn ($contributorId): int => (int) $contributorId)->values()->all(),
'contributor_credits' => $artwork->contributors->map(fn ($contributor): array => [
@@ -314,11 +365,18 @@ final class StudioArtworksApiController extends Controller
'is_primary' => (bool) $contributor->is_primary,
])->values()->all(),
'content_type_id' => $primaryCategory?->contentType?->id,
'category_id' => $primaryCategory?->id,
'tags' => $artwork->tags->map(fn ($t) => ['id' => $t->id, 'name' => $t->name, 'slug' => $t->slug])->values()->all(),
'title_source' => $artwork->title_source ?: 'manual',
'category_id' => $primaryCategory?->id,
'primary_category_id' => $primaryCategory?->id,
'secondary_category_ids' => $artwork->categories
->reject(fn ($category): bool => (int) $category->id === (int) ($primaryCategory?->id ?? 0))
->pluck('id')
->map(fn ($id): int => (int) $id)
->values()
->all(),
'tags' => $artwork->tags->map(fn ($t) => ['id' => $t->id, 'name' => $t->name, 'slug' => $t->slug])->values()->all(),
'title_source' => $artwork->title_source ?: 'manual',
'description_source' => $artwork->description_source ?: 'manual',
'tags_source' => $artwork->tags_source ?: 'manual',
'tags_source' => $artwork->tags_source ?: 'manual',
'category_source' => $artwork->category_source ?: 'manual',
'evolution_relation' => $evolution->editorRelation($artwork, $request->user()),
],
@@ -326,6 +384,15 @@ final class StudioArtworksApiController extends Controller
]);
}
private function ensureValidArtworkDescription(array $validated): void
{
foreach (ArtworkDescriptionContentValidator::errors($validated['description'] ?? null) as $message) {
throw ValidationException::withMessages([
'description' => [$message],
]);
}
}
public function evolutionOptions(Request $request, int $id): JsonResponse
{
$artwork = $request->user()->artworks()->findOrFail($id);
@@ -411,18 +478,18 @@ final class StudioArtworksApiController extends Controller
return response()->json([
'artwork' => [
'id' => $artwork->id,
'id' => $artwork->id,
'title' => $artwork->title,
'slug' => $artwork->slug,
'slug' => $artwork->slug,
],
'analytics' => [
'views' => (int) ($stats?->views ?? 0),
'favourites' => (int) ($stats?->favorites ?? 0),
'shares' => (int) ($stats?->shares_count ?? 0),
'comments' => (int) ($stats?->comments_count ?? 0),
'downloads' => (int) ($stats?->downloads ?? 0),
'ranking_score' => (float) ($stats?->ranking_score ?? 0),
'heat_score' => (float) ($stats?->heat_score ?? 0),
'views' => (int) ($stats?->views ?? 0),
'favourites' => (int) ($stats?->favorites ?? 0),
'shares' => (int) ($stats?->shares_count ?? 0),
'comments' => (int) ($stats?->comments_count ?? 0),
'downloads' => (int) ($stats?->downloads ?? 0),
'ranking_score' => (float) ($stats?->ranking_score ?? 0),
'heat_score' => (float) ($stats?->heat_score ?? 0),
'engagement_velocity' => (float) ($stats?->engagement_velocity ?? 0),
],
]);
@@ -437,28 +504,28 @@ final class StudioArtworksApiController extends Controller
$stats = $artwork->stats ?? null;
return [
'id' => $artwork->id,
'title' => $artwork->title,
'slug' => $artwork->slug,
'thumb_url' => $artwork->thumbUrl('md') ?? '/images/placeholder.jpg',
'is_public' => (bool) $artwork->is_public,
'visibility' => $artwork->visibility ?: ((bool) $artwork->is_public ? Artwork::VISIBILITY_PUBLIC : Artwork::VISIBILITY_PRIVATE),
'is_approved' => (bool) $artwork->is_approved,
'published_at' => $artwork->published_at?->toIso8601String(),
'publish_at' => $artwork->publish_at?->toIso8601String(),
'artwork_status' => $artwork->artwork_status,
'created_at' => $artwork->created_at?->toIso8601String(),
'deleted_at' => $artwork->deleted_at?->toIso8601String(),
'category' => $artwork->categories->first()?->name,
'category_slug' => $artwork->categories->first()?->slug,
'tags' => $artwork->tags->pluck('slug')->values()->all(),
'views' => (int) ($stats?->views ?? 0),
'favourites' => (int) ($stats?->favorites ?? 0),
'shares' => (int) ($stats?->shares_count ?? 0),
'comments' => (int) ($stats?->comments_count ?? 0),
'downloads' => (int) ($stats?->downloads ?? 0),
'ranking_score' => (float) ($stats?->ranking_score ?? 0),
'heat_score' => (float) ($stats?->heat_score ?? 0),
'id' => $artwork->id,
'title' => $artwork->title,
'slug' => $artwork->slug,
'thumb_url' => $artwork->thumbUrl('md') ?? '/images/placeholder.jpg',
'is_public' => (bool) $artwork->is_public,
'visibility' => $artwork->visibility ?: ((bool) $artwork->is_public ? Artwork::VISIBILITY_PUBLIC : Artwork::VISIBILITY_PRIVATE),
'is_approved' => (bool) $artwork->is_approved,
'published_at' => $artwork->published_at?->toIso8601String(),
'publish_at' => $artwork->publish_at?->toIso8601String(),
'artwork_status' => $artwork->artwork_status,
'created_at' => $artwork->created_at?->toIso8601String(),
'deleted_at' => $artwork->deleted_at?->toIso8601String(),
'category' => $artwork->resolvedPrimaryCategory()?->name,
'category_slug' => $artwork->resolvedPrimaryCategory()?->slug,
'tags' => $artwork->tags->pluck('slug')->values()->all(),
'views' => (int) ($stats?->views ?? 0),
'favourites' => (int) ($stats?->favorites ?? 0),
'shares' => (int) ($stats?->shares_count ?? 0),
'comments' => (int) ($stats?->comments_count ?? 0),
'downloads' => (int) ($stats?->downloads ?? 0),
'ranking_score' => (float) ($stats?->ranking_score ?? 0),
'heat_score' => (float) ($stats?->heat_score ?? 0),
];
}
@@ -487,7 +554,7 @@ final class StudioArtworksApiController extends Controller
$previousSnapshot = $this->versioningService->captureArtworkSnapshot($artwork);
$request->validate([
'file' => 'required|file|mimes:jpeg,jpg,png,webp|max:51200', // 50 MB
'file' => 'required|file|mimes:jpeg,jpg,png,webp|max:51200', // 50 MB
'change_note' => 'sometimes|nullable|string|max:500',
]);
@@ -498,43 +565,43 @@ final class StudioArtworksApiController extends Controller
return response()->json(['success' => false, 'error' => $e->getMessage()], 429);
}
$file = $request->file('file');
$file = $request->file('file');
$tempPath = $file->getRealPath();
$hash = hash_file('sha256', $tempPath);
$hash = hash_file('sha256', $tempPath);
// Reject identical files early (before any disk writes)
if ($artwork->hash === $hash) {
return response()->json([
'success' => false,
'error' => 'The uploaded file is identical to the current version.',
'error' => 'The uploaded file is identical to the current version.',
], 422);
}
try {
$derivatives = app(\App\Services\Uploads\UploadDerivativesService::class);
$storage = app(\App\Services\Uploads\UploadStorageService::class);
$artworkFiles = app(\App\Repositories\Uploads\ArtworkFileRepository::class);
$derivatives = app(UploadDerivativesService::class);
$storage = app(UploadStorageService::class);
$artworkFiles = app(ArtworkFileRepository::class);
// 1. Store original on disk (preserve extension when possible)
$originalAsset = $derivatives->storeOriginal($tempPath, $hash);
$originalPath = $originalAsset['local_path'];
$origFilename = basename($originalPath);
$originalAsset = $derivatives->storeOriginal($tempPath, $hash);
$originalPath = $originalAsset['local_path'];
$origFilename = basename($originalPath);
$originalRelative = $storage->sectionRelativePath('original', $hash, $origFilename);
$origMime = File::exists($originalPath) ? File::mimeType($originalPath) : 'application/octet-stream';
$origMime = File::exists($originalPath) ? File::mimeType($originalPath) : 'application/octet-stream';
$artworkFiles->upsert($artwork->id, 'orig', $originalRelative, $origMime, (int) filesize($originalPath));
// 2. Generate thumbnails (xs/sm/md/lg/xl)
$publicAssets = $derivatives->generatePublicDerivatives($tempPath, $hash);
foreach ($publicAssets as $variant => $asset) {
$relativePath = $storage->sectionRelativePath($variant, $hash, $hash . '.webp');
$relativePath = $storage->sectionRelativePath($variant, $hash, $hash.'.webp');
$artworkFiles->upsert($artwork->id, $variant, $relativePath, 'image/webp', (int) ($asset['size'] ?? 0));
}
// 3. Get new dimensions
$dims = @getimagesize($tempPath);
$width = is_array($dims) && isset($dims[0]) ? (int) $dims[0] : $artwork->width;
$dims = @getimagesize($tempPath);
$width = is_array($dims) && isset($dims[0]) ? (int) $dims[0] : $artwork->width;
$height = is_array($dims) && isset($dims[1]) ? (int) $dims[1] : $artwork->height;
$size = (int) filesize($originalPath);
$size = (int) filesize($originalPath);
// 4. Update the artwork's file-serving fields (hash drives thumbnail URLs)
$origExt = strtolower(pathinfo($originalPath, PATHINFO_EXTENSION) ?: '');
@@ -547,7 +614,7 @@ final class StudioArtworksApiController extends Controller
if ($clientName !== '') {
$clientExt = strtolower((string) pathinfo($clientName, PATHINFO_EXTENSION));
if ($clientExt === '' && $origExt !== '') {
$clientName .= '.' . $origExt;
$clientName .= '.'.$origExt;
}
$displayFileName = $clientName;
@@ -558,11 +625,11 @@ final class StudioArtworksApiController extends Controller
'file_path' => '',
'file_size' => $size,
'mime_type' => $origMime,
'hash' => $hash,
'file_ext' => $origExt,
'hash' => $hash,
'file_ext' => $origExt,
'thumb_ext' => 'webp',
'width' => max(1, $width),
'height' => max(1, $height),
'width' => max(1, $width),
'height' => max(1, $height),
]);
// 5. Create version record from the new full media snapshot.
@@ -581,7 +648,7 @@ final class StudioArtworksApiController extends Controller
} catch (\Throwable $e) {
Log::warning('ArtworkVersioningService: Meilisearch reindex failed', [
'artwork_id' => $artwork->id,
'error' => $e->getMessage(),
'error' => $e->getMessage(),
]);
}
@@ -589,16 +656,17 @@ final class StudioArtworksApiController extends Controller
$this->purgeCdnCache($artwork, $hash);
return response()->json([
'success' => true,
'success' => true,
'version_number' => $version->version_number,
'requires_reapproval' => (bool) $artwork->requires_reapproval,
] + $this->mediaPayload($artwork));
} catch (\Throwable $e) {
Log::error('replaceFile: processing error', [
'artwork_id' => $artwork->id,
'error' => $e->getMessage(),
'error' => $e->getMessage(),
]);
return response()->json(['success' => false, 'error' => 'File processing failed: ' . $e->getMessage()], 500);
return response()->json(['success' => false, 'error' => 'File processing failed: '.$e->getMessage()], 500);
}
}
@@ -666,8 +734,8 @@ final class StudioArtworksApiController extends Controller
return response()->json(['success' => false, 'error' => $e->getMessage()], 429);
}
$derivatives = app(\App\Services\Uploads\UploadDerivativesService::class);
$storage = app(\App\Services\Uploads\UploadStorageService::class);
$derivatives = app(UploadDerivativesService::class);
$storage = app(UploadStorageService::class);
$cleanupLocalPaths = [];
$cleanupObjectPaths = [];
@@ -754,7 +822,7 @@ final class StudioArtworksApiController extends Controller
$allScreenshotDescriptors = [];
foreach (array_values(array_merge($existingScreenshotDescriptors, $newScreenshotDescriptors)) as $index => $descriptor) {
$descriptor['variant'] = 'shot' . ($index + 1);
$descriptor['variant'] = 'shot'.($index + 1);
$allScreenshotDescriptors[] = $descriptor;
}
@@ -850,7 +918,7 @@ final class StudioArtworksApiController extends Controller
return response()->json([
'success' => false,
'error' => 'Revision update failed: ' . $exception->getMessage(),
'error' => 'Revision update failed: '.$exception->getMessage(),
], 500);
}
}
@@ -861,27 +929,27 @@ final class StudioArtworksApiController extends Controller
*/
public function versions(Request $request, int $id): JsonResponse
{
$artwork = $request->user()->artworks()->findOrFail($id);
$artwork = $request->user()->artworks()->findOrFail($id);
$versions = $artwork->versions()->reorder()->orderByDesc('version_number')->get();
return response()->json([
'artwork' => [
'id' => $artwork->id,
'title' => $artwork->title,
'id' => $artwork->id,
'title' => $artwork->title,
'version_count' => (int) ($artwork->version_count ?? 1),
],
'versions' => $versions->map(fn (ArtworkVersion $v) => [
'id' => $v->id,
'id' => $v->id,
'version_number' => $v->version_number,
'file_path' => $v->file_path,
'file_hash' => $v->file_hash,
'width' => $v->width,
'height' => $v->height,
'file_size' => $v->file_size,
'file_name' => (string) data_get($v->snapshot_json, 'artwork.file_name', ''),
'change_note' => $v->change_note,
'is_current' => $v->is_current,
'created_at' => $v->created_at?->toIso8601String(),
'file_path' => $v->file_path,
'file_hash' => $v->file_hash,
'width' => $v->width,
'height' => $v->height,
'file_size' => $v->file_size,
'file_name' => (string) data_get($v->snapshot_json, 'artwork.file_name', ''),
'change_note' => $v->change_note,
'is_current' => $v->is_current,
'created_at' => $v->created_at?->toIso8601String(),
])->values(),
]);
}
@@ -906,17 +974,18 @@ final class StudioArtworksApiController extends Controller
// Reindex
try {
$this->searchIndexer->update($artwork);
} catch (\Throwable) {}
} catch (\Throwable) {
}
return response()->json([
'success' => true,
'success' => true,
'version_number' => $newVersion->version_number,
'message' => "Version {$version->version_number} has been restored as version {$newVersion->version_number}.",
'message' => "Version {$version->version_number} has been restored as version {$newVersion->version_number}.",
] + $this->mediaPayload($artwork));
} catch (TooManyRequestsHttpException $e) {
return response()->json(['success' => false, 'error' => $e->getMessage()], 429);
} catch (\Throwable $e) {
return response()->json(['success' => false, 'error' => 'Restore failed: ' . $e->getMessage()], 500);
return response()->json(['success' => false, 'error' => 'Restore failed: '.$e->getMessage()], 500);
}
}
@@ -926,7 +995,7 @@ final class StudioArtworksApiController extends Controller
* This is best-effort; failures are logged but never fatal.
* Configure a CDN purge webhook via ARTWORK_CDN_PURGE_URL if needed.
*/
private function purgeCdnCache(\App\Models\Artwork $artwork, string $oldHash): void
private function purgeCdnCache(Artwork $artwork, string $oldHash): void
{
try {
$this->cdnPurge->purgeArtworkHashVariants($oldHash, 'webp', ['xs', 'sm', 'md', 'lg', 'xl', 'sq'], [
@@ -939,7 +1008,7 @@ final class StudioArtworksApiController extends Controller
}
/**
* @param array<string, mixed> $snapshot
* @param array<string, mixed> $snapshot
* @return array<string, array<string, mixed>>
*/
private function snapshotFilesByVariant(array $snapshot): array
@@ -951,7 +1020,7 @@ final class StudioArtworksApiController extends Controller
}
/**
* @param array<string, mixed> $snapshot
* @param array<string, mixed> $snapshot
* @return array<string, array{path: string, mime: string, size: int}>
*/
private function currentPublicAssetsFromSnapshot(array $snapshot): array
@@ -969,7 +1038,7 @@ final class StudioArtworksApiController extends Controller
}
/**
* @param array<string, mixed>|null $file
* @param array<string, mixed>|null $file
* @return array<string, mixed>|null
*/
private function existingVariantDescriptor(?array $file, string $fallbackVariant): ?array
@@ -990,7 +1059,7 @@ final class StudioArtworksApiController extends Controller
}
/**
* @param array<string, mixed> $stored
* @param array<string, mixed> $stored
* @return array<string, mixed>
*/
private function storedOriginalDescriptor(array $stored, string $variant): array
@@ -1016,7 +1085,7 @@ final class StudioArtworksApiController extends Controller
$candidateExt = strtolower((string) pathinfo($candidate, PATHINFO_EXTENSION));
if ($candidateExt === '' && $ext !== '') {
$candidate .= '.' . ltrim($ext, '.');
$candidate .= '.'.ltrim($ext, '.');
}
return $candidate;
@@ -1046,7 +1115,7 @@ final class StudioArtworksApiController extends Controller
}
/**
* @param array<string, mixed> $snapshot
* @param array<string, mixed> $snapshot
* @return array<int, array<string, mixed>>
*/
private function screenshotAssetsFromSnapshot(array $snapshot): array
@@ -1058,11 +1127,11 @@ final class StudioArtworksApiController extends Controller
->values()
->map(function (array $file, int $index) use ($base): array {
$path = trim((string) ($file['path'] ?? ''), '/');
$url = $base . '/' . $path;
$url = $base.'/'.$path;
return [
'id' => (string) ($file['variant'] ?? ('shot' . ($index + 1))),
'label' => 'Screenshot ' . ($index + 1),
'id' => (string) ($file['variant'] ?? ('shot'.($index + 1))),
'label' => 'Screenshot '.($index + 1),
'url' => $url,
'thumb_url' => $url,
'mime_type' => (string) ($file['mime'] ?? 'image/jpeg'),
@@ -5,26 +5,27 @@ declare(strict_types=1);
namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller;
use App\Models\Group;
use App\Models\ContentType;
use App\Models\Group;
use App\Services\ArtworkEvolutionService;
use App\Services\Artworks\ArtworkPublicationService;
use App\Services\GroupMembershipService;
use App\Services\GroupService;
use App\Services\Studio\CreatorStudioActivityService;
use App\Services\Studio\CreatorStudioAnalyticsService;
use App\Services\Studio\CreatorStudioAssetService;
use App\Services\Studio\CreatorStudioCalendarService;
use App\Services\Studio\CreatorStudioChallengeService;
use App\Services\Studio\CreatorStudioCommentService;
use App\Services\Studio\CreatorStudioContentService;
use App\Services\Studio\CreatorStudioFollowersService;
use App\Services\Studio\CreatorStudioGrowthService;
use App\Services\Studio\CreatorStudioActivityService;
use App\Services\Studio\CreatorStudioInboxService;
use App\Services\Studio\CreatorStudioOverviewService;
use App\Services\Studio\CreatorStudioPreferenceService;
use App\Services\Studio\CreatorStudioChallengeService;
use App\Services\Studio\CreatorStudioSearchService;
use App\Services\Studio\CreatorStudioScheduledService;
use App\Services\Studio\CreatorStudioSearchService;
use App\Services\Uploads\UploadQueueService;
use App\Services\Worlds\WorldSubmissionService;
use App\Support\CoverUrl;
use Illuminate\Http\RedirectResponse;
@@ -95,7 +96,13 @@ final class StudioController extends Controller
{
$provider = $this->content->provider('artworks');
$prefs = $this->preferences->forUser($request->user());
$listing = $this->content->list($request->user(), $request->only(['q', 'sort', 'bucket', 'page', 'per_page', 'content_type', 'category', 'tag']), null, 'artworks');
$filters = $request->only(['q', 'sort', 'bucket', 'page', 'per_page', 'content_type', 'category', 'tag']);
if (! $request->filled('sort')) {
$filters['sort'] = 'published_desc';
}
$listing = $this->content->list($request->user(), $filters, null, 'artworks');
$listing['default_view'] = $prefs['default_content_view'];
return Inertia::render('Studio/StudioArtworks', [
@@ -126,7 +133,7 @@ final class StudioController extends Controller
public function uploadQueue(Request $request): Response
{
$queue = app(\App\Services\Uploads\UploadQueueService::class)->listPayload(
$queue = app(UploadQueueService::class)->listPayload(
$request->user(),
$request->only(['batch_id', 'status', 'sort'])
);
@@ -355,7 +362,7 @@ final class StudioController extends Controller
'cover_url' => $user->cover_hash && $user->cover_ext ? CoverUrl::forUser($user->cover_hash, $user->cover_ext, time()) : null,
'cover_position' => (int) ($user->cover_position ?? 50),
'followers' => (int) ($user->statistics?->followers_count ?? 0),
'profile_url' => '/@' . strtolower((string) $user->username),
'profile_url' => '/@'.strtolower((string) $user->username),
'social_links' => $socialLinks,
],
'moduleSummaries' => $this->content->moduleSummaries($user),
@@ -443,13 +450,13 @@ final class StudioController extends Controller
{
$user = $request->user();
$artwork = $user->artworks()
->with(['stats', 'categories.contentType', 'tags', 'artworkAiAssist', 'group.members', 'primaryAuthor.profile', 'contributors.user.profile'])
->with(['stats', 'categories.contentType', 'primaryCategory.contentType', 'tags', 'artworkAiAssist', 'group.members', 'primaryAuthor.profile', 'contributors.user.profile'])
->findOrFail($id);
$artwork = app(ArtworkPublicationService::class)->publishIfDue($artwork);
$artwork->loadMissing(['stats', 'categories.contentType', 'tags', 'artworkAiAssist', 'group.members', 'primaryAuthor.profile', 'contributors.user.profile']);
$artwork->loadMissing(['stats', 'categories.contentType', 'primaryCategory.contentType', 'tags', 'artworkAiAssist', 'group.members', 'primaryAuthor.profile', 'contributors.user.profile']);
$primaryCategory = $artwork->categories->first();
$primaryCategory = $artwork->resolvedPrimaryCategory();
$availableGroups = app(GroupService::class)->studioOptionsForUser($user);
$membershipService = app(GroupMembershipService::class);
$contributorOptionsByGroup = [];
@@ -464,30 +471,30 @@ final class StudioController extends Controller
}
return Inertia::render('Studio/StudioArtworkEdit', [
'artwork' => [
'id' => $artwork->id,
'title' => $artwork->title,
'slug' => $artwork->slug,
'description' => $artwork->description,
'is_public' => (bool) $artwork->is_public,
'visibility' => $artwork->visibility ?: ((bool) $artwork->is_public ? 'public' : 'private'),
'is_approved' => (bool) $artwork->is_approved,
'publish_mode' => $artwork->artwork_status === 'scheduled' ? 'schedule' : 'now',
'publish_at' => $artwork->publish_at?->toIso8601String(),
'artwork_status' => $artwork->artwork_status,
'artwork' => [
'id' => $artwork->id,
'title' => $artwork->title,
'slug' => $artwork->slug,
'description' => $artwork->description,
'is_public' => (bool) $artwork->is_public,
'visibility' => $artwork->visibility ?: ((bool) $artwork->is_public ? 'public' : 'private'),
'is_approved' => (bool) $artwork->is_approved,
'publish_mode' => $artwork->artwork_status === 'scheduled' ? 'schedule' : 'now',
'publish_at' => $artwork->publish_at?->toIso8601String(),
'artwork_status' => $artwork->artwork_status,
'artwork_timezone' => $artwork->artwork_timezone,
'thumb_url' => $artwork->thumbUrl('md'),
'thumb_url_lg' => $artwork->thumbUrl('lg'),
'download_url' => route('art.download', ['id' => $artwork->id]),
'file_name' => $artwork->file_name,
'file_ext' => $artwork->file_ext,
'file_size' => $artwork->file_size,
'width' => $artwork->width,
'height' => $artwork->height,
'mime_type' => $artwork->mime_type,
'thumb_url' => $artwork->thumbUrl('md'),
'thumb_url_lg' => $artwork->thumbUrl('lg'),
'download_url' => route('art.download', ['id' => $artwork->id]),
'file_name' => $artwork->file_name,
'file_ext' => $artwork->file_ext,
'file_size' => $artwork->file_size,
'width' => $artwork->width,
'height' => $artwork->height,
'mime_type' => $artwork->mime_type,
'has_archive_file' => $this->artworkHasArchiveFile((int) $artwork->id),
'screenshots' => $this->screenshotAssetsForArtwork((int) $artwork->id),
'group_slug' => $artwork->group?->slug,
'screenshots' => $this->screenshotAssetsForArtwork((int) $artwork->id),
'group_slug' => $artwork->group?->slug,
'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_credits' => $artwork->contributors->map(fn ($contributor): array => [
@@ -496,19 +503,27 @@ final class StudioController extends Controller
'is_primary' => (bool) $contributor->is_primary,
])->values()->all(),
'content_type_id' => $primaryCategory?->contentType?->id,
'category_id' => $primaryCategory?->id,
'category_id' => $primaryCategory?->id,
'primary_category_id' => $primaryCategory?->id,
'parent_category_id' => $primaryCategory?->parent_id ? $primaryCategory->parent_id : $primaryCategory?->id,
'sub_category_id' => $primaryCategory?->parent_id ? $primaryCategory->id : null,
'categories' => $artwork->categories->map(fn ($c) => ['id' => $c->id, 'name' => $c->name, 'slug' => $c->slug])->values()->all(),
'tags' => $artwork->tags->map(fn ($t) => ['id' => $t->id, 'name' => $t->name, 'slug' => $t->slug])->values()->all(),
'ai_status' => $artwork->ai_status,
'title_source' => $artwork->title_source ?: 'manual',
'sub_category_id' => $primaryCategory?->parent_id ? $primaryCategory->id : null,
'secondary_category_ids' => $artwork->categories
->reject(fn ($category): bool => (int) $category->id === (int) ($primaryCategory?->id ?? 0))
->pluck('id')
->map(fn ($id): int => (int) $id)
->values()
->all(),
'max_secondary_categories' => (int) config('categories.max_secondary_categories', 5),
'categories' => $artwork->categories->map(fn ($c) => ['id' => $c->id, 'name' => $c->name, 'slug' => $c->slug, 'is_primary' => (int) $c->id === (int) ($primaryCategory?->id ?? 0)])->values()->all(),
'tags' => $artwork->tags->map(fn ($t) => ['id' => $t->id, 'name' => $t->name, 'slug' => $t->slug])->values()->all(),
'ai_status' => $artwork->ai_status,
'title_source' => $artwork->title_source ?: 'manual',
'description_source' => $artwork->description_source ?: 'manual',
'tags_source' => $artwork->tags_source ?: 'manual',
'tags_source' => $artwork->tags_source ?: 'manual',
'category_source' => $artwork->category_source ?: 'manual',
'evolution_relation' => app(ArtworkEvolutionService::class)->editorRelation($artwork, $user),
// Versioning
'version_count' => (int) ($artwork->version_count ?? 1),
'version_count' => (int) ($artwork->version_count ?? 1),
'requires_reapproval' => (bool) $artwork->requires_reapproval,
],
'worldSubmissionOptions' => app(WorldSubmissionService::class)->artworkSubmissionOptions($artwork, $user),
@@ -532,19 +547,19 @@ final class StudioController extends Controller
return Inertia::render('Studio/StudioArtworkAnalytics', [
'artwork' => [
'id' => $artwork->id,
'title' => $artwork->title,
'slug' => $artwork->slug,
'thumb_url' => $artwork->thumbUrl('md'),
'id' => $artwork->id,
'title' => $artwork->title,
'slug' => $artwork->slug,
'thumb_url' => $artwork->thumbUrl('md'),
],
'analytics' => [
'views' => (int) ($stats?->views ?? 0),
'favourites' => (int) ($stats?->favorites ?? 0),
'shares' => (int) ($stats?->shares_count ?? 0),
'comments' => (int) ($stats?->comments_count ?? 0),
'downloads' => (int) ($stats?->downloads ?? 0),
'ranking_score' => (float) ($stats?->ranking_score ?? 0),
'heat_score' => (float) ($stats?->heat_score ?? 0),
'views' => (int) ($stats?->views ?? 0),
'favourites' => (int) ($stats?->favorites ?? 0),
'shares' => (int) ($stats?->shares_count ?? 0),
'comments' => (int) ($stats?->comments_count ?? 0),
'downloads' => (int) ($stats?->downloads ?? 0),
'ranking_score' => (float) ($stats?->ranking_score ?? 0),
'heat_score' => (float) ($stats?->heat_score ?? 0),
'engagement_velocity' => (float) ($stats?->engagement_velocity ?? 0),
],
]);
@@ -563,16 +578,16 @@ final class StudioController extends Controller
$data = $this->analytics->overview($user, $rangeDays);
return Inertia::render('Studio/StudioAnalytics', [
'totals' => $data['totals'],
'topContent' => $data['top_content'],
'moduleBreakdown' => $data['module_breakdown'],
'viewsTrend' => $data['views_trend'],
'engagementTrend' => $data['engagement_trend'],
'totals' => $data['totals'],
'topContent' => $data['top_content'],
'moduleBreakdown' => $data['module_breakdown'],
'viewsTrend' => $data['views_trend'],
'engagementTrend' => $data['engagement_trend'],
'publishingTimeline' => $data['publishing_timeline'],
'comparison' => $data['comparison'],
'insightBlocks' => $data['insight_blocks'],
'rangeDays' => $data['range_days'],
'recentComments' => $this->overview->recentComments($user, 8),
'comparison' => $data['comparison'],
'insightBlocks' => $data['insight_blocks'],
'rangeDays' => $data['range_days'],
'recentComments' => $this->overview->recentComments($user, 8),
]);
}
@@ -580,16 +595,16 @@ final class StudioController extends Controller
{
return ContentType::with(['rootCategories.children'])->ordered()->get()->map(function ($ct) {
return [
'id' => $ct->id,
'id' => $ct->id,
'name' => $ct->name,
'slug' => $ct->slug,
'categories' => $ct->rootCategories->map(function ($c) {
return [
'id' => $c->id,
'name' => $c->name,
'slug' => $c->slug,
'id' => $c->id,
'name' => $c->name,
'slug' => $c->slug,
'children' => $c->children->map(fn ($ch) => [
'id' => $ch->id,
'id' => $ch->id,
'name' => $ch->name,
'slug' => $ch->slug,
])->values()->all(),
@@ -636,11 +651,11 @@ final class StudioController extends Controller
return null;
}
$url = $base . '/' . $path;
$url = $base.'/'.$path;
return [
'id' => (string) ($row->variant ?? ('shot' . ($index + 1))),
'label' => 'Screenshot ' . ($index + 1),
'id' => (string) ($row->variant ?? ('shot'.($index + 1))),
'label' => 'Screenshot '.($index + 1),
'url' => $url,
'thumb_url' => $url,
'mime_type' => (string) ($row->mime ?? 'image/jpeg'),
@@ -5,7 +5,9 @@ declare(strict_types=1);
namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\News\NewsService;
use App\Support\AvatarUrl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -46,6 +48,8 @@ final class StudioNewsController extends Controller
{
$this->authorizeNews($request);
$user = $request->user();
return Inertia::render('Studio/StudioNewsEditor', [
'title' => 'Create article',
'description' => 'Draft a new News story with editorial workflow, SEO metadata, and related entity links.',
@@ -61,11 +65,14 @@ final class StudioNewsController extends Controller
'storeUrl' => route('studio.news.store'),
'coverUploadUrl' => route('api.studio.news.media.upload'),
'coverDeleteUrl' => route('api.studio.news.media.destroy'),
'bodyMediaUploadUrl' => route('api.studio.news.media.upload'),
'bodyMediaDeleteUrl' => route('api.studio.news.media.destroy'),
'coverCdnBaseUrl' => rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'),
'entitySearchUrl' => route('studio.news.entity-search'),
'categoriesUrl' => route('studio.news.categories'),
'tagsUrl' => route('studio.news.tags'),
'defaultAuthor' => $this->news->searchEntities('user', (string) $request->user()->username)[0] ?? null,
'defaultAuthor' => $this->mapDefaultAuthor($user),
'defaultPublishedAt' => now()->format('Y-m-d\TH:i'),
]);
}
@@ -96,6 +103,8 @@ final class StudioNewsController extends Controller
'relationTypeOptions' => $this->news->relationTypeOptions(),
'coverUploadUrl' => route('api.studio.news.media.upload'),
'coverDeleteUrl' => route('api.studio.news.media.destroy'),
'bodyMediaUploadUrl' => route('api.studio.news.media.upload'),
'bodyMediaDeleteUrl' => route('api.studio.news.media.destroy'),
'coverCdnBaseUrl' => rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'),
'updateUrl' => route('studio.news.update', ['article' => $article->id]),
'destroyUrl' => route('studio.news.destroy', ['article' => $article->id]),
@@ -250,6 +259,29 @@ final class StudioNewsController extends Controller
]);
}
private function mapDefaultAuthor(mixed $user): ?array
{
if (! $user instanceof User) {
return null;
}
$user->loadMissing('profile');
return [
'id' => (int) $user->id,
'entity_type' => 'user',
'entity_label' => 'User',
'title' => (string) ($user->name ?: $user->username),
'subtitle' => $user->username ? '@' . $user->username : null,
'description' => Str::limit(trim((string) ($user->profile?->bio ?? '')), 120),
'url' => $user->username ? route('profile.show', ['username' => $user->username]) : null,
'image' => null,
'avatar' => AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash ?? null, 96),
'context_label' => 'Profile',
'meta' => [],
];
}
public function storeCategory(Request $request): RedirectResponse
{
$this->authorizeNews($request);
@@ -377,10 +409,41 @@ final class StudioNewsController extends Controller
'og_image' => ['nullable', 'string', 'max:2048'],
'relations' => ['nullable', 'array', 'max:12'],
'relations.*.entity_type' => ['required_with:relations', Rule::in(array_column($this->news->relationTypeOptions(), 'value'))],
'relations.*.entity_id' => ['required_with:relations', 'integer', 'min:1'],
'relations.*.entity_id' => ['nullable', 'integer', 'min:1'],
'relations.*.external_url' => ['nullable', 'string', 'max:2048'],
'relations.*.context_label' => ['nullable', 'string', 'max:120'],
]);
$relationErrors = [];
foreach ((array) ($validated['relations'] ?? []) as $index => $relation) {
$entityType = trim(Str::lower((string) ($relation['entity_type'] ?? '')));
if ($entityType === NewsService::RELATION_SOURCE) {
$externalUrl = $this->normalizeExternalRelationUrl($relation['external_url'] ?? null);
if ($externalUrl === null) {
$relationErrors["relations.{$index}.external_url"] = 'Source relations need a valid URL.';
continue;
}
$validated['relations'][$index]['entity_id'] = null;
$validated['relations'][$index]['external_url'] = $externalUrl;
continue;
}
if ((int) ($relation['entity_id'] ?? 0) < 1) {
$relationErrors["relations.{$index}.entity_id"] = 'Select a related entity.';
}
$validated['relations'][$index]['external_url'] = null;
}
if ($relationErrors !== []) {
throw ValidationException::withMessages($relationErrors);
}
if (($validated['editorial_status'] ?? null) === NewsArticle::EDITORIAL_STATUS_SCHEDULED && empty($validated['published_at'])) {
throw ValidationException::withMessages([
'published_at' => 'Scheduled articles need a publish date and time.',
@@ -390,6 +453,25 @@ final class StudioNewsController extends Controller
return $validated;
}
private function normalizeExternalRelationUrl(mixed $value): ?string
{
$url = trim((string) ($value ?? ''));
if ($url === '') {
return null;
}
if (preg_match('/^\[[^\]]+\]\((https?:\/\/[^)]+)\)$/i', $url, $matches) === 1) {
$url = trim((string) ($matches[1] ?? ''));
}
if ($url === '' || filter_var($url, FILTER_VALIDATE_URL) === false) {
return null;
}
return Str::limit($url, 2048, '');
}
private function tagPayload(): array
{
return NewsTag::query()
@@ -46,6 +46,7 @@ final class StudioNewsMediaApiController extends Controller
'size_bytes' => $stored['size_bytes'],
'mobile_url' => $stored['mobile_url'],
'desktop_url' => $stored['desktop_url'],
'large_url' => $stored['large_url'],
'srcset' => $stored['srcset'],
]);
} catch (RuntimeException $e) {
File diff suppressed because it is too large Load Diff
@@ -3,7 +3,10 @@
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Models\ArtworkDownload;
use App\Services\ThumbnailPresenter;
use App\Support\AvatarUrl;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
@@ -54,7 +57,7 @@ class TodayDownloadsController extends Controller
$paginator->getCollection()->transform(function ($row) {
$art = $row->artwork ?? null;
if (! $art && isset($row->artwork_id)) {
$art = \App\Models\Artwork::find($row->artwork_id);
$art = Artwork::find($row->artwork_id);
}
if (! $art) {
@@ -75,9 +78,9 @@ class TodayDownloadsController extends Controller
$picture = $art->file_name ?? null;
$ext = pathinfo($picture ?? '', PATHINFO_EXTENSION) ?: 'jpg';
$encoded = null;
$present = $art ? \App\Services\ThumbnailPresenter::present($art, 'md') : null;
$present = $art ? ThumbnailPresenter::present($art, 'md') : null;
$thumb = $present ? $present['url'] : 'https://files.skinbase.org/default/missing_md.webp';
$primaryCategory = $art->categories->first();
$primaryCategory = $art->resolvedPrimaryCategory();
$categoryId = $primaryCategory->id ?? null;
$categoryName = $primaryCategory->name ?? '';
$categorySlug = $primaryCategory->slug ?? '';
@@ -98,7 +101,7 @@ class TodayDownloadsController extends Controller
'category_slug' => $categorySlug,
'uname' => $art->user->name ?? 'Skinbase',
'username' => $art->user->username ?? '',
'avatar_url' => \App\Support\AvatarUrl::forUser((int) ($art->user->id ?? 0), $avatarHash, 64),
'avatar_url' => AvatarUrl::forUser((int) ($art->user->id ?? 0), $avatarHash, 64),
'width' => $art->width,
'height' => $art->height,
'published_at' => $art->published_at,
@@ -50,7 +50,8 @@ class TopAuthorsController extends Controller
});
$page_title = 'Top Creators';
$page_canonical = route('creators.top');
return view('web.authors.top', compact('page_title', 'authors', 'metric'));
return view('web.authors.top', compact('page_title', 'page_canonical', 'authors', 'metric'));
}
}
@@ -8,20 +8,19 @@ use App\Enums\ReactionType;
use App\Http\Controllers\Controller;
use App\Http\Resources\ArtworkResource;
use App\Models\Artwork;
use App\Models\ArtworkComment;
use Illuminate\Support\Facades\DB;
use App\Services\ContentSanitizer;
use App\Services\ThumbnailPresenter;
use App\Models\Category;
use App\Services\ArtworkNavigationService;
use App\Services\ArtworkRelatedService;
use App\Services\ErrorSuggestionService;
use App\Services\GroupService;
use App\Services\Maturity\ArtworkMaturityService;
use App\Services\ThumbnailPresenter;
use App\Support\Seo\SeoFactory;
use App\Support\AvatarUrl;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\View\View;
use Inertia\Inertia;
@@ -32,6 +31,8 @@ final class ArtworkPageController extends Controller
public function __construct(
private readonly GroupService $groups,
private readonly ArtworkMaturityService $maturity,
private readonly ArtworkNavigationService $navigation,
private readonly ArtworkRelatedService $relatedArtworks,
) {}
public function show(Request $request, int $id, ?string $slug = null): View|RedirectResponse|Response|InertiaResponse
@@ -42,6 +43,7 @@ final class ArtworkPageController extends Controller
if (! $raw) {
// Artwork never existed → contextual 404
$suggestions = app(ErrorSuggestionService::class);
return response(view('errors.contextual.artwork-not-found', [
'trendingArtworks' => $this->safeSuggestions(fn () => $suggestions->trendingArtworks()),
]), 404);
@@ -76,36 +78,44 @@ final class ArtworkPageController extends Controller
->limit(6)
->get()
->map(function (Artwork $a) {
$slug = \Illuminate\Support\Str::slug((string) ($a->slug ?: $a->title)) ?: (string) $a->id;
$md = \App\Services\ThumbnailPresenter::present($a, 'md');
$slug = Str::slug((string) ($a->slug ?: $a->title)) ?: (string) $a->id;
$md = ThumbnailPresenter::present($a, 'md');
return [
'id' => $a->id,
'title' => html_entity_decode((string) $a->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'id' => $a->id,
'title' => html_entity_decode((string) $a->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'author' => html_entity_decode((string) ($a->user?->name ?: $a->user?->username ?: 'Artist'), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'url' => route('art.show', ['id' => $a->id, 'slug' => $slug]),
'thumb' => $md['url'] ?? null,
'url' => route('art.show', ['id' => $a->id, 'slug' => $slug]),
'thumb' => $md['url'] ?? null,
];
});
});
}
return response(view('errors.contextual.artwork-not-found', [
'message' => 'This artwork is not publicly available.',
'isForbidden' => true,
'creatorArtworks' => $creatorArtworks,
'creatorUsername' => $creatorUsername,
'message' => 'This artwork is not publicly available.',
'isForbidden' => true,
'creatorArtworks' => $creatorArtworks,
'creatorUsername' => $creatorUsername,
'trendingArtworks' => $this->safeSuggestions(fn () => $suggestions->trendingArtworks()),
]), 403);
}
// ── Step 2: full load with all relations ───────────────────────────
$artwork = Artwork::with(['user.profile', 'group.owner.profile', 'uploadedBy.profile', 'primaryAuthor.profile', 'contributors.user.profile', 'categories.contentType', 'categories.parent.contentType', 'tags', 'stats', 'awardStat'])
->where('id', $id)
->public()
->published()
->firstOrFail();
// ── Step 2: hydrate the already-validated model ────────────────────
// The initial withTrashed lookup is intentionally retained so that
// missing, deleted, private, and unpublished artwork keep their
// distinct responses. Reusing that model avoids a second identical
// primary-key lookup on the public path.
$artwork = $raw;
$artwork->load(['user.profile', 'group.owner.profile', 'uploadedBy.profile', 'primaryAuthor.profile', 'contributors.user.profile', 'categories.contentType', 'categories.parent.contentType', 'primaryCategory.contentType', 'primaryCategory.parent.contentType', 'tags', 'stats', 'awardStat']);
$this->loadCategoryAncestors($artwork->categories);
$this->loadCategoryAncestors(
$artwork->categories
->concat(collect([$artwork->resolvedPrimaryCategory()]))
->filter()
->unique('id')
->values()
);
$canonicalSlug = Str::slug((string) ($artwork->slug ?: $artwork->title));
if ($canonicalSlug === '') {
@@ -119,12 +129,15 @@ final class ArtworkPageController extends Controller
], 301);
}
$thumbMd = ThumbnailPresenter::present($artwork, 'md');
$thumbLg = ThumbnailPresenter::present($artwork, 'lg');
$thumbXl = ThumbnailPresenter::present($artwork, 'xl');
$thumbSq = ThumbnailPresenter::present($artwork, 'sq');
$artworkData = (new ArtworkResource($artwork))->toArray($request);
$navigationData = $this->navigation->navigationFor($artwork);
// ArtworkResource is the canonical thumbnail projection. Reuse its
// exact values for page props and SEO instead of presenting each
// variant a second time in the controller.
$thumbMd = $artworkData['thumbs']['md'] ?? [];
$thumbLg = $artworkData['thumbs']['lg'] ?? [];
$thumbXl = $artworkData['thumbs']['xl'] ?? [];
$thumbSq = $artworkData['thumbs']['sq'] ?? [];
$groupSummary = null;
if ($artwork->group) {
@@ -151,37 +164,7 @@ final class ArtworkPageController extends Controller
'xl' => $thumbXl,
], $canonical, $this->artworkBreadcrumbs($artwork, $canonical))->toArray();
$categoryIds = $artwork->categories->pluck('id')->filter()->values();
$tagIds = $artwork->tags->pluck('id')->filter()->values();
$related = Artwork::query()
->with(['user', 'group', 'categories.contentType'])
->whereKeyNot($artwork->id)
->public()
->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, $request->user()))
->where(function ($query) use ($artwork, $categoryIds, $tagIds): void {
$query->where('user_id', $artwork->user_id);
if ($artwork->group_id) {
$query->orWhere('group_id', $artwork->group_id);
}
if ($categoryIds->isNotEmpty()) {
$query->orWhereHas('categories', function ($categoryQuery) use ($categoryIds): void {
$categoryQuery->whereIn('categories.id', $categoryIds->all());
});
}
if ($tagIds->isNotEmpty()) {
$query->orWhereHas('tags', function ($tagQuery) use ($tagIds): void {
$tagQuery->whereIn('tags.id', $tagIds->all());
});
}
})
->latest('published_at')
->limit(12)
->get()
$related = $this->relatedArtworks->related($artwork, $request->user(), 12)
->map(function (Artwork $item): array {
$itemSlug = Str::slug((string) ($item->slug ?: $item->title));
if ($itemSlug === '') {
@@ -200,91 +183,28 @@ final class ArtworkPageController extends Controller
'publisher_id' => $item->group ? (int) $item->group->id : (int) ($item->user?->id ?? 0),
'url' => route('art.show', ['id' => $item->id, 'slug' => $itemSlug]),
'thumb' => $sm['url'] ?? null,
'thumb_srcset' => ($sm['url'] ?? '') . ' 320w, ' . ($md['url'] ?? '') . ' 640w',
'thumb_srcset' => ($sm['url'] ?? '').' 320w, '.($md['url'] ?? '').' 640w',
], $item, request()->user());
})
->values()
->all();
$approvedComments = ArtworkComment::query()
->with('user.profile')
->where('artwork_id', $artwork->id)
->where('is_approved', true)
->orderBy('created_at')
->limit(500)
->get();
$commentsByParent = $approvedComments->groupBy(
static fn (ArtworkComment $comment): string => $comment->parent_id === null
? 'root'
: (string) $comment->parent_id
);
// Recursive helper to format a comment and its nested replies.
$formatComment = null;
$formatComment = function (ArtworkComment $c) use (&$formatComment, $commentsByParent): array {
/** @var Collection<int, ArtworkComment> $replies */
$replies = $commentsByParent->get((string) $c->id, collect());
$user = $c->user;
$userId = (int) ($c->user_id ?? 0);
$avatarHash = $user?->profile?->avatar_hash ?? null;
$canPublishLinks = (int) ($user?->level ?? 1) > 1 && strtolower((string) ($user?->rank ?? 'Newbie')) !== 'newbie';
$rawContent = (string) ($c->raw_content ?? $c->content ?? '');
$renderedContent = $c->rendered_content;
if (! is_string($renderedContent) || trim($renderedContent) === '') {
$renderedContent = $rawContent !== ''
? ContentSanitizer::render($rawContent)
: nl2br(e(strip_tags((string) ($c->content ?? ''))));
}
return [
'id' => $c->id,
'parent_id' => $c->parent_id,
'content' => html_entity_decode((string) $c->content, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'raw_content' => $c->raw_content ?? $c->content,
'rendered_content' => ContentSanitizer::sanitizeRenderedHtml($renderedContent, $canPublishLinks),
'created_at' => $c->created_at?->toIso8601String(),
'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null,
'user' => [
'id' => $userId,
'name' => $user?->name,
'username' => $user?->username,
'display' => $user?->username ?? $user?->name ?? 'User',
'profile_url' => $user?->username ? '/@' . $user->username : ($userId > 0 ? '/profile/' . $userId : null),
'avatar_url' => $avatarHash !== null
? AvatarUrl::forUser($userId, $avatarHash, 64)
: AvatarUrl::default(),
'level' => (int) ($user?->level ?? 1),
'rank' => (string) ($user?->rank ?? 'Newbie'),
],
'replies' => $replies->map($formatComment)->values()->all(),
];
};
$comments = $commentsByParent
->get('root', collect())
->map($formatComment)
->values()
->all();
$canReadSession = $request->hasSession() && ! $request->attributes->get('skinbase.session_skipped');
$userId = ($canReadSession && $request->user() !== null) ? (int) $request->user()->id : null;
$viewerId = ($canReadSession && $request->user() !== null) ? (int) $request->user()->id : null;
return Inertia::render('ArtworkPage', [
'artwork' => $artworkData,
'presentMd' => $thumbMd,
'presentLg' => $thumbLg,
'presentXl' => $thumbXl,
'presentSq' => $thumbSq,
'related' => $related,
'canonicalUrl' => $canonical,
'comments' => $comments,
'groupSummary' => $groupSummary,
'isAuthenticated' => $userId !== null,
'reactionTotals' => $this->artworkReactionTotals((int) $artwork->id, $userId),
'seo' => $seo,
'artwork' => $artworkData,
'navigation' => $navigationData,
'presentMd' => $thumbMd,
'presentLg' => $thumbLg,
'presentXl' => $thumbXl,
'presentSq' => $thumbSq,
'related' => $related,
'canonicalUrl' => $canonical,
'groupSummary' => $groupSummary,
'isAuthenticated' => $viewerId !== null,
'reactionTotals' => $this->artworkReactionTotals((int) $artwork->id, $viewerId),
'seo' => $seo,
])->rootView('artworks.show');
}
@@ -306,7 +226,7 @@ final class ArtworkPageController extends Controller
$totals = [];
foreach (ReactionType::cases() as $type) {
$slug = $type->value;
$slug = $type->value;
$count = (int) ($rows[$slug]->total ?? 0);
$mine = false;
@@ -322,7 +242,7 @@ final class ArtworkPageController extends Controller
'emoji' => $type->emoji(),
'label' => $type->label(),
'count' => $count,
'mine' => $mine,
'mine' => $mine,
];
}
@@ -343,7 +263,7 @@ final class ArtworkPageController extends Controller
->values();
if ($missingParentIds->isNotEmpty()) {
$fetchedParents = \App\Models\Category::query()
$fetchedParents = Category::query()
->with('contentType')
->whereIn('id', $missingParentIds->all())
->get()
@@ -369,12 +289,7 @@ final class ArtworkPageController extends Controller
*/
private function artworkBreadcrumbs(Artwork $artwork, string $canonical): array
{
$primaryCategory = $artwork->categories
->sortBy(fn ($category) => [
(int) ($category->sort_order ?? 0),
(string) ($category->name ?? ''),
])
->first();
$primaryCategory = $artwork->resolvedPrimaryCategory();
if ($primaryCategory === null) {
return [
@@ -399,7 +314,7 @@ final class ArtworkPageController extends Controller
if ($contentTypeSlug !== '' && $contentTypeName !== '') {
$breadcrumbs[] = [
'name' => $contentTypeName,
'url' => url('/' . $contentTypeSlug),
'url' => url('/'.$contentTypeSlug),
];
}
@@ -416,7 +331,7 @@ final class ArtworkPageController extends Controller
$pathSegments[] = $slug;
$breadcrumbs[] = [
'name' => $name,
'url' => url('/' . $contentTypeSlug . '/' . implode('/', $pathSegments)),
'url' => url('/'.$contentTypeSlug.'/'.implode('/', $pathSegments)),
];
}
@@ -65,6 +65,7 @@ final class DiscoverController extends Controller
return view('web.discover.index', [
'artworks' => $results,
'page_title' => 'Trending Artworks',
'page_canonical' => $this->canonicalRoute('discover.trending'),
'section' => 'trending',
'description' => 'The most-viewed artworks on Skinbase over the past 7 days.',
'icon' => 'fa-fire',
@@ -97,6 +98,7 @@ final class DiscoverController extends Controller
return view('web.discover.index', [
'artworks' => $results,
'page_title' => 'Rising Now',
'page_canonical' => $this->canonicalRoute('discover.rising'),
'section' => 'rising',
'description' => 'Fastest growing artworks right now.',
'icon' => 'fa-rocket',
@@ -119,6 +121,7 @@ final class DiscoverController extends Controller
return view('web.discover.index', [
'artworks' => $results,
'page_title' => 'Fresh Uploads',
'page_canonical' => $this->canonicalRoute('discover.fresh'),
'section' => 'fresh',
'description' => 'The latest artworks just uploaded to Skinbase.',
'icon' => 'fa-bolt',
@@ -138,6 +141,7 @@ final class DiscoverController extends Controller
return view('web.discover.index', [
'artworks' => $results,
'page_title' => 'Top Rated Artworks',
'page_canonical' => $this->canonicalRoute('discover.top-rated'),
'section' => 'top-rated',
'description' => 'The most-loved artworks on Skinbase, ranked by community favourites.',
'icon' => 'fa-medal',
@@ -157,6 +161,7 @@ final class DiscoverController extends Controller
return view('web.discover.index', [
'artworks' => $results,
'page_title' => 'Most Downloaded',
'page_canonical' => $this->canonicalRoute('discover.most-downloaded'),
'section' => 'most-downloaded',
'description' => 'All-time most downloaded artworks on Skinbase.',
'icon' => 'fa-download',
@@ -178,9 +183,9 @@ final class DiscoverController extends Controller
'categories:id,name,slug,content_type_id,parent_id,sort_order',
'categories.contentType:id,slug,name',
])
->whereRaw('MONTH(published_at) = ?', [$today->month])
->whereRaw('DAY(published_at) = ?', [$today->day])
->whereRaw('YEAR(published_at) < ?', [$today->year])
->whereMonth('published_at', $today->month)
->whereDay('published_at', $today->day)
->whereYear('published_at', '<', $today->year)
->orderMissingThumbnailsLast()
->orderByDesc('published_at')
->paginate($perPage)
@@ -191,6 +196,7 @@ final class DiscoverController extends Controller
return view('web.discover.index', [
'artworks' => $artworks,
'page_title' => 'On This Day',
'page_canonical' => $this->canonicalRoute('discover.on-this-day'),
'section' => 'on-this-day',
'description' => 'Artworks published on ' . $today->format('F j') . ' in previous years.',
'icon' => 'fa-calendar-day',
@@ -246,6 +252,7 @@ final class DiscoverController extends Controller
return view('web.creators.rising', [
'creators' => $creators,
'page_title' => 'Rising Creators — Skinbase',
'page_canonical' => $this->canonicalRoute('creators.rising'),
]);
}
@@ -327,6 +334,7 @@ final class DiscoverController extends Controller
return view('web.discover.index', [
'artworks' => collect(),
'page_title' => 'Following Feed',
'page_canonical' => $this->canonicalRoute('discover.following'),
'section' => 'following',
'description' => 'Follow some creators to see their work here.',
'icon' => 'fa-user-group',
@@ -366,6 +374,7 @@ final class DiscoverController extends Controller
return view('web.discover.index', [
'artworks' => $artworks,
'page_title' => 'Following Feed',
'page_canonical' => $this->canonicalRoute('discover.following'),
'section' => 'following',
'description' => 'The latest artworks from creators you follow.',
'icon' => 'fa-user-group',
@@ -388,6 +397,11 @@ final class DiscoverController extends Controller
return ! $items || $items->isEmpty();
}
private function canonicalRoute(string $routeName): string
{
return route($routeName);
}
private function paginatorHasNoRisingMomentum($paginator): bool
{
if (! is_object($paginator) || ! method_exists($paginator, 'getCollection')) {
@@ -11,6 +11,7 @@ use App\Services\GroupDiscoveryService;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Arr;
use Illuminate\View\View;
use cPad\Plugins\News\Models\NewsArticle;
@@ -19,6 +20,12 @@ final class SearchController extends Controller
{
private const ALLOWED_SORTS = ['latest', 'popular', 'likes', 'downloads'];
/** Reject requests with an absurd number of query params before touching search/DB. */
private const MAX_QUERY_PARAMS = 15;
/** Reject requests with an absurdly long query string before touching search/DB. */
private const MAX_QUERY_STRING_LENGTH = 500;
public function __construct(
private readonly ArtworkSearchService $search,
private readonly GroupDiscoveryService $groups,
@@ -26,6 +33,8 @@ final class SearchController extends Controller
public function index(Request $request): View|RedirectResponse
{
$this->rejectMalformedQuery($request);
$canonicalQuery = $this->canonicalQueryParameters($request);
$canonicalUrl = $this->canonicalSearchUrl($request, $canonicalQuery);
@@ -110,6 +119,21 @@ final class SearchController extends Controller
]);
}
/**
* Bail out before any search/DB work for junk requests — e.g. scripted
* floods that repeat/nest query params (group=all&page=..&sort=.. etc.).
*/
private function rejectMalformedQuery(Request $request): void
{
$query = $request->query();
if (count($query) > self::MAX_QUERY_PARAMS
|| strlen((string) $request->getQueryString()) > self::MAX_QUERY_STRING_LENGTH
) {
abort(Response::HTTP_BAD_REQUEST);
}
}
/**
* @return array<string, int|string>
*/
@@ -35,8 +35,8 @@ final class SimilarArtworksPageController extends Controller
private const QDRANT_LIMIT = 120;
public function __construct(
private readonly VectorService $vectors,
private readonly ArtworkMaturityService $maturity,
private readonly VectorService $vectors,
private readonly ArtworkMaturityService $maturity,
private readonly HybridSimilarArtworksService $hybridService,
) {}
@@ -57,53 +57,53 @@ final class SimilarArtworksPageController extends Controller
$baseUrl = url("/art/{$id}/similar");
// ── Normalise source artwork for the view ──────────────────────────────
$primaryCat = $source->categories->sortBy('sort_order')->first();
$sourceMd = ThumbnailPresenter::present($source, 'md');
$sourceLg = ThumbnailPresenter::present($source, 'lg');
$primaryCat = $source->categories->sortBy('sort_order')->first();
$sourceMd = ThumbnailPresenter::present($source, 'md');
$sourceLg = ThumbnailPresenter::present($source, 'lg');
$sourceTitle = html_entity_decode((string) ($source->title ?? 'Artwork'), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$sourceUrl = route('art.show', ['id' => $source->id, 'slug' => $source->slug]);
$sourceUrl = route('art.show', ['id' => $source->id, 'slug' => $source->slug]);
$sourceCard = (object) [
'id' => $source->id,
'title' => $sourceTitle,
'url' => $sourceUrl,
'thumb_md' => $sourceMd['url'] ?? null,
'thumb_lg' => $sourceLg['url'] ?? null,
'thumb_srcset' => $sourceMd['srcset'] ?? $sourceMd['url'] ?? null,
'author_name' => $source->user?->name ?? 'Artist',
'author_username' => $source->user?->username ?? '',
'author_profile_url'=> $source->user?->username ? '/@' . $source->user->username : null,
'author_avatar' => AvatarUrl::forUser(
(int) ($source->user_id ?? 0),
$source->user?->profile?->avatar_hash ?? null,
80
),
'category_name' => $primaryCat?->name ?? '',
'category_slug' => $primaryCat?->slug ?? '',
'id' => $source->id,
'title' => $sourceTitle,
'url' => $sourceUrl,
'thumb_md' => $sourceMd['url'] ?? null,
'thumb_lg' => $sourceLg['url'] ?? null,
'thumb_srcset' => $sourceMd['srcset'] ?? $sourceMd['url'] ?? null,
'author_name' => $source->user?->name ?? 'Artist',
'author_username' => $source->user?->username ?? '',
'author_profile_url' => $source->user?->username ? '/@'.$source->user->username : null,
'author_avatar' => AvatarUrl::forUser(
(int) ($source->user_id ?? 0),
$source->user?->profile?->avatar_hash ?? null,
80
),
'category_name' => $primaryCat?->name ?? '',
'category_slug' => $primaryCat?->slug ?? '',
'content_type_name' => $primaryCat?->contentType?->name ?? '',
'content_type_slug' => $primaryCat?->contentType?->slug ?? '',
'browse_url' => $primaryCat?->contentType?->slug ? url('/' . $primaryCat->contentType->slug) : url('/explore'),
'tag_slugs' => $source->tags->pluck('slug')->take(5)->all(),
'width' => $source->width ?? null,
'height' => $source->height ?? null,
'browse_url' => $primaryCat?->contentType?->slug ? url('/'.$primaryCat->contentType->slug) : url('/explore'),
'tag_slugs' => $source->tags->pluck('slug')->take(5)->all(),
'width' => $source->width ?? null,
'height' => $source->height ?? null,
];
return view('gallery.similar', [
'sourceArtwork' => $sourceCard,
'gallery_type' => 'similar',
'gallery_nav_section' => 'artworks',
'mainCategories' => collect(),
'subcategories' => collect(),
'contentType' => null,
'category' => null,
'spotlight' => collect(),
'current_sort' => 'trending',
'sort_options' => [],
'page_title' => 'Similar to "' . $sourceTitle . '" — Skinbase',
'page_meta_description' => 'Discover artworks similar to "' . $sourceTitle . '" on Skinbase.',
'page_canonical' => $baseUrl,
'page_robots' => 'index,follow',
'breadcrumbs' => collect([
'sourceArtwork' => $sourceCard,
'gallery_type' => 'similar',
'gallery_nav_section' => 'artworks',
'mainCategories' => collect(),
'subcategories' => collect(),
'contentType' => null,
'category' => null,
'spotlight' => collect(),
'current_sort' => 'trending',
'sort_options' => [],
'page_title' => 'Similar to "'.$sourceTitle.'" — Skinbase',
'page_meta_description' => 'Discover artworks similar to "'.$sourceTitle.'" on Skinbase.',
'page_canonical' => $baseUrl,
'page_robots' => 'index,follow',
'breadcrumbs' => collect([
(object) ['name' => 'Explore', 'url' => '/explore'],
(object) ['name' => $sourceTitle, 'url' => $sourceUrl],
(object) ['name' => 'Similar Artworks', 'url' => $baseUrl],
@@ -130,37 +130,37 @@ final class SimilarArtworksPageController extends Controller
])
->findOrFail($id);
$page = max(1, (int) $request->query('page', 1));
$page = max(1, (int) $request->query('page', 1));
$baseUrl = url("/art/{$id}/similar");
[$artworks, $similaritySource] = $this->resolveSimilarArtworks($source, $page, $baseUrl);
$galleryItems = $artworks->getCollection()->map(fn ($art) => [
'id' => $art->id ?? null,
'name' => $art->name ?? null,
'thumb' => $art->thumb_url ?? $art->thumb ?? null,
'thumb_srcset' => $art->thumb_srcset ?? null,
'uname' => $art->uname ?? '',
'username' => $art->username ?? $art->uname ?? '',
'avatar_url' => $art->avatar_url ?? null,
'id' => $art->id ?? null,
'name' => $art->name ?? null,
'thumb' => $art->thumb_url ?? $art->thumb ?? null,
'thumb_srcset' => $art->thumb_srcset ?? null,
'uname' => $art->uname ?? '',
'username' => $art->username ?? $art->uname ?? '',
'avatar_url' => $art->avatar_url ?? null,
'category_name' => $art->category_name ?? '',
'category_slug' => $art->category_slug ?? '',
'slug' => $art->slug ?? '',
'width' => $art->width ?? null,
'height' => $art->height ?? null,
'maturity' => $art->maturity ?? null,
'slug' => $art->slug ?? '',
'width' => $art->width ?? null,
'height' => $art->height ?? null,
'maturity' => $art->maturity ?? null,
])->values();
$galleryItems = collect($this->maturity->filterPayloadItems($galleryItems->all(), $request->user()))->values();
return response()->json([
'data' => $galleryItems,
'data' => $galleryItems,
'similarity_source' => $similaritySource,
'total' => $artworks->total(),
'current_page' => $artworks->currentPage(),
'last_page' => $artworks->lastPage(),
'next_page_url' => $artworks->nextPageUrl(),
'prev_page_url' => $artworks->previousPageUrl(),
'total' => $artworks->total(),
'current_page' => $artworks->currentPage(),
'last_page' => $artworks->lastPage(),
'next_page_url' => $artworks->nextPageUrl(),
'prev_page_url' => $artworks->previousPageUrl(),
]);
}
@@ -171,16 +171,13 @@ final class SimilarArtworksPageController extends Controller
*/
private function resolveSimilarArtworks(Artwork $source, int $page, string $baseUrl): array
{
// Priority 1 — Qdrant visual (vision) similarity
// Priority 1 — Qdrant visual (vision) similarity.
// Paginate IDs before hydrating/presenting full Artwork models so a
// request only pays the relation/presenter cost for the current page.
if ($this->vectors->isConfigured()) {
$qdrantItems = $this->resolveViaQdrant($source);
if ($qdrantItems !== null && $qdrantItems->isNotEmpty()) {
$paginator = $this->paginateCollection(
$qdrantItems->map(fn ($a) => $this->presentArtwork($a)),
$page,
$baseUrl,
);
return [$paginator, 'visual'];
$qdrantPaginator = $this->resolveViaQdrant($source, $page, $baseUrl);
if ($qdrantPaginator !== null && $qdrantPaginator->total() > 0) {
return [$qdrantPaginator, 'visual'];
}
}
@@ -192,54 +189,106 @@ final class SimilarArtworksPageController extends Controller
$page,
$baseUrl,
);
return [$paginator, 'hybrid'];
}
// Priority 3 — Meilisearch tag/category overlap
$paginator = $this->meilisearchFallback($source, $page);
return [$paginator, 'tags'];
}
/**
* Query Qdrant via VectorGateway, then re-hydrate full Artwork models
* (so we have category/dimension data for the masonry grid).
* Query Qdrant via VectorGateway, preserve relevance order, then paginate
* candidate IDs before hydrating full Artwork models for the current page.
*
* Returns null when the gateway call fails, so the caller can fall through.
* The light eligibility query keeps the existing public/published semantics
* for cached Qdrant results without loading relations for every candidate.
* Returns null when the gateway call fails or no eligible candidates remain,
* allowing the caller to continue to the existing hybrid/tag fallbacks.
*/
private function resolveViaQdrant(Artwork $source): ?Collection
{
private function resolveViaQdrant(
Artwork $source,
int $page,
string $baseUrl,
): ?LengthAwarePaginator {
try {
$raw = $this->vectors->similarToArtwork($source, self::QDRANT_LIMIT);
} catch (RuntimeException) {
return null;
}
if (empty($raw)) {
if ($raw === []) {
return null;
}
// Preserve Qdrant relevance order; IDs are already filtered to public+published
$orderedIds = array_column($raw, 'id');
// Preserve Qdrant relevance order while normalising IDs defensively.
$orderedIds = collect(array_column($raw, 'id'))
->map(static fn ($id): int => is_numeric($id) ? (int) $id : 0)
->filter(static fn (int $id): bool => $id > 0 && $id !== (int) $source->id)
->unique()
->values();
$artworks = Artwork::query()
->whereIn('id', $orderedIds)
->where('id', '!=', $source->id) // belt-and-braces exclusion
if ($orderedIds->isEmpty()) {
return null;
}
// Keep cached Qdrant results honest if an artwork has since become
// private/unpublished. Fetch IDs only; do not hydrate relations yet.
$eligibleSet = Artwork::query()
->whereIn('id', $orderedIds->all())
->public()
->published()
->with([
'categories:id,slug,name,content_type_id',
'categories.contentType:id,name,slug',
'user:id,name,username',
'user.profile:user_id,avatar_hash',
'group:id,name,slug,avatar_path',
])
->get()
->keyBy('id');
->pluck('id')
->mapWithKeys(static fn ($id): array => [(int) $id => true]);
return collect($orderedIds)
->map(fn (int $id) => $artworks->get($id))
->filter()
$eligibleIds = $orderedIds
->filter(static fn (int $id): bool => $eligibleSet->has($id))
->values();
if ($eligibleIds->isEmpty()) {
return null;
}
$perPage = self::PER_PAGE;
$total = $eligibleIds->count();
$pageIds = $eligibleIds->forPage($page, $perPage)->values();
$items = collect();
if ($pageIds->isNotEmpty()) {
$artworks = Artwork::query()
->whereIn('id', $pageIds->all())
->public()
->published()
->with([
'categories:id,slug,name,content_type_id',
'categories.contentType:id,name,slug',
'user:id,name,username',
'user.profile:user_id,avatar_hash',
'group:id,name,slug,avatar_path',
])
->get()
->keyBy('id');
$items = $pageIds
->map(fn (int $id) => $artworks->get($id))
->filter()
->map(fn (Artwork $artwork) => $this->presentArtwork($artwork))
->values();
}
return new LengthAwarePaginator(
$items,
$total,
$perPage,
$page,
[
'path' => $baseUrl,
'query' => [],
],
);
}
/**
@@ -247,26 +296,26 @@ final class SimilarArtworksPageController extends Controller
*/
private function meilisearchFallback(Artwork $source, int $page): LengthAwarePaginator
{
$tagSlugs = $source->tags->pluck('slug')->values()->all();
$tagSlugs = $source->tags->pluck('slug')->values()->all();
$categorySlugs = $source->categories->pluck('slug')->values()->all();
$filterParts = [
'is_public = true',
'is_approved = true',
'id != ' . $source->id,
'id != '.$source->id,
];
if ($tagSlugs !== []) {
$quoted = array_map(fn (string $t): string => 'tags = "' . addslashes($t) . '"', $tagSlugs);
$filterParts[] = '(' . implode(' OR ', $quoted) . ')';
$quoted = array_map(fn (string $t): string => 'tags = "'.addslashes($t).'"', $tagSlugs);
$filterParts[] = '('.implode(' OR ', $quoted).')';
} elseif ($categorySlugs !== []) {
$quoted = array_map(fn (string $c): string => '(category = "' . addslashes($c) . '" OR categories = "' . addslashes($c) . '")', $categorySlugs);
$filterParts[] = '(' . implode(' OR ', $quoted) . ')';
$quoted = array_map(fn (string $c): string => '(category = "'.addslashes($c).'" OR categories = "'.addslashes($c).'")', $categorySlugs);
$filterParts[] = '('.implode(' OR ', $quoted).')';
}
$results = Artwork::search('')->options([
'filter' => implode(' AND ', $filterParts),
'sort' => ['trending_score_7d:desc', 'created_at:desc'],
'sort' => ['trending_score_7d:desc', 'created_at:desc'],
])->paginate(self::PER_PAGE, 'page', $page);
$results->getCollection()->load([
@@ -287,15 +336,15 @@ final class SimilarArtworksPageController extends Controller
*/
private function paginateCollection(
Collection $items,
int $page,
string $path,
int $page,
string $path,
): LengthAwarePaginator {
$perPage = self::PER_PAGE;
$total = $items->count();
$slice = $items->forPage($page, $perPage)->values();
$total = $items->count();
$slice = $items->forPage($page, $perPage)->values();
return new LengthAwarePaginator($slice, $total, $perPage, $page, [
'path' => $path,
'path' => $path,
'query' => [],
]);
}
@@ -304,9 +353,9 @@ final class SimilarArtworksPageController extends Controller
private function presentArtwork(Artwork $artwork): object
{
$primary = $artwork->categories->sortBy('sort_order')->first();
$present = ThumbnailPresenter::present($artwork, 'md');
$group = $artwork->group;
$primary = $artwork->categories->sortBy('sort_order')->first();
$present = ThumbnailPresenter::present($artwork, 'md');
$group = $artwork->group;
$isGroupPublisher = $group !== null;
$avatarUrl = $isGroupPublisher
? $group->avatarUrl()
@@ -317,33 +366,33 @@ final class SimilarArtworksPageController extends Controller
);
$displayName = $isGroupPublisher ? ($group->name ?? 'Skinbase') : ($artwork->user?->name ?? 'Skinbase');
$username = $isGroupPublisher ? '' : ($artwork->user?->username ?? '');
$profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null);
$profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@'.$username : null);
return (object) $this->maturity->decoratePayload([
'id' => $artwork->id,
'name' => $artwork->title,
'id' => $artwork->id,
'name' => $artwork->title,
'content_type_name' => $primary?->contentType?->name ?? '',
'content_type_slug' => $primary?->contentType?->slug ?? '',
'category_name' => $primary?->name ?? '',
'category_slug' => $primary?->slug ?? '',
'thumb_url' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'uname' => $displayName,
'username' => $username,
'avatar_url' => $avatarUrl,
'profile_url' => $profileUrl,
'category_name' => $primary?->name ?? '',
'category_slug' => $primary?->slug ?? '',
'thumb_url' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'uname' => $displayName,
'username' => $username,
'avatar_url' => $avatarUrl,
'profile_url' => $profileUrl,
'published_as_type' => $isGroupPublisher ? 'group' : 'user',
'publisher' => [
'publisher' => [
'type' => $isGroupPublisher ? 'group' : 'user',
'name' => $displayName,
'username' => $username,
'avatar_url' => $avatarUrl,
'profile_url' => $profileUrl,
],
'published_at' => $artwork->published_at,
'slug' => $artwork->slug ?? '',
'width' => $artwork->width ?? null,
'height' => $artwork->height ?? null,
'published_at' => $artwork->published_at,
'slug' => $artwork->slug ?? '',
'width' => $artwork->width ?? null,
'height' => $artwork->height ?? null,
], $artwork, request()->user());
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Negligible-overhead request duration header for production HTTP profiling.
* Does not log queries or payloads.
*/
final class AddServerTiming
{
public function handle(Request $request, Closure $next): Response
{
$started = hrtime(true);
$response = $next($request);
$durationMs = (hrtime(true) - $started) / 1_000_000;
if (headers_sent()) {
return $response;
}
$metrics = ['app;desc="Laravel";dur='.number_format($durationMs, 1, '.', '')];
$ssrMs = $request->attributes->get('http.ssr_ms');
if (is_numeric($ssrMs) && (float) $ssrMs >= 0) {
$metrics[] = 'ssr;desc="Inertia SSR";dur='.number_format((float) $ssrMs, 1, '.', '');
}
$metric = implode(', ', $metrics);
$existing = $response->headers->get('Server-Timing');
$response->headers->set(
'Server-Timing',
$existing ? $existing.', '.$metric : $metric,
);
return $response;
}
}
@@ -0,0 +1,65 @@
<?php
namespace App\Http\Middleware;
use Closure;
use cPad\Plugins\Forum\Services\Security\BotProtectionService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Symfony\Component\HttpFoundation\Response;
class CommentCaptchaMiddleware
{
public function __construct(private readonly BotProtectionService $botProtection) {}
public function handle(Request $request, Closure $next): Response
{
if (! (bool) config('comment_spam.captcha.enabled', false)) {
return $next($request);
}
$assessment = $this->botProtection->assess($request, 'comment_create');
if ((bool) ($assessment['blocked'] ?? false)) {
return response()->json(['message' => 'Suspicious activity detected.', 'errors' => ['bot' => ['Suspicious activity detected.']]], 429);
}
$threshold = (int) config('comment_spam.captcha.threshold', 40);
if ((int) ($assessment['risk_score'] ?? 0) < $threshold) {
return $next($request);
}
$token = (string) ($request->input('comment-turnstile-response') ?: $request->header('X-Turnstile-Token', ''));
$valid = false;
if ($token !== '') {
try {
$valid = (bool) Http::asForm()->timeout(5)->post(
(string) config('comment_spam.captcha.verify_url'),
['secret' => (string) config('comment_spam.captcha.secret_key'), 'response' => $token, 'remoteip' => $request->ip()],
)->json('success', false);
} catch (\Throwable) {
$valid = (bool) config('comment_spam.captcha.fail_open', false);
}
}
if ($valid) {
return $next($request);
}
$payload = [
'message' => 'Complete the captcha challenge to continue.',
'errors' => ['captcha' => ['Complete the captcha challenge to continue.']],
'requires_captcha' => true,
'captcha' => [
'provider' => 'turnstile',
'siteKey' => (string) config('comment_spam.captcha.site_key'),
'inputName' => 'comment-turnstile-response',
'scriptUrl' => (string) config('comment_spam.captcha.script_url'),
],
'captcha_provider' => 'turnstile',
'captcha_site_key' => (string) config('comment_spam.captcha.site_key'),
'captcha_input' => 'comment-turnstile-response',
'captcha_script_url' => (string) config('comment_spam.captcha.script_url'),
];
return response()->json($payload, 422);
}
}
+10 -3
View File
@@ -98,6 +98,15 @@ final class HandleInertiaRequests extends Middleware
? $request->session()->get($key)
: null;
if ($user !== null && ! $user->relationLoaded('profile')) {
$user->load('profile');
}
$studioGroups = [];
if ($user !== null && str_starts_with($request->path(), 'studio')) {
$studioGroups = app(GroupService::class)->studioOptionsForUser($user);
}
return array_merge(parent::share($request), [
'auth' => [
'user' => $user ? [
@@ -134,9 +143,7 @@ final class HandleInertiaRequests extends Middleware
'group_assets' => (bool) config('features.group_assets', true),
'group_activity_feed' => (bool) config('features.group_activity_feed', true),
],
'studio_groups' => $user
? app(GroupService::class)->studioOptionsForUser($user)
: [],
'studio_groups' => $studioGroups,
]);
}
}
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Support\Http\HttpUriNormalizer;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Inertia\Support\Header as InertiaHeader;
use Symfony\Component\HttpFoundation\Response;
final class LogSlowHttpRequest
{
public function __construct(private readonly HttpUriNormalizer $uris)
{
}
public function handle(Request $request, Closure $next): Response
{
if (! (bool) config('http_observability.slow_request.enabled', false)) {
return $next($request);
}
$started = hrtime(true);
$response = $next($request);
$durationMs = (hrtime(true) - $started) / 1_000_000;
if (app()->environment('testing')) {
$override = config('http_observability.slow_request.test_duration_ms');
if (is_numeric($override)) {
$durationMs = (float) $override;
}
}
$threshold = max(1, (int) config('http_observability.slow_request.threshold_ms', 750));
if ($durationMs < $threshold) {
return $response;
}
$route = $request->route();
$routeUri = is_object($route) && method_exists($route, 'uri')
? (string) $route->uri()
: $this->uris->normalize($request->path());
$payload = [
'time' => now()->toIso8601String(),
'method' => $request->getMethod(),
'route_name' => is_object($route) ? $route->getName() : null,
'route_uri' => $routeUri,
'status' => $response->getStatusCode(),
'duration_ms' => round($durationMs, 1),
'peak_memory_mb' => round(memory_get_peak_usage(true) / 1048576, 2),
'authenticated' => $request->user() !== null,
'inertia' => $this->isInertia($request, $response),
];
Log::channel('slow-http')->info(json_encode($payload, JSON_UNESCAPED_SLASHES));
return $response;
}
private function isInertia(Request $request, Response $response): bool
{
if ($request->headers->has(InertiaHeader::INERTIA)) {
return true;
}
return $response->headers->has(InertiaHeader::INERTIA)
|| $response->headers->get('Vary') === 'X-Inertia'
|| str_contains((string) $response->headers->get('Vary'), 'X-Inertia');
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class SecurityHeaders
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$response->headers->set('X-Frame-Options', 'SAMEORIGIN');
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
return $response;
}
}
@@ -69,6 +69,13 @@ final class TrackOnlineVisitor
'email/*',
'logout',
'up',
'rss/*',
'rss-feeds',
'robots.txt',
'sitemap.xml',
'sitemaps/*',
'download/artwork/*',
'photo/*',
])) {
return false;
}
@@ -111,7 +111,7 @@ class UpsertAcademyLessonRequest extends FormRequest
'cover_image' => ['nullable', 'string', 'max:2048'],
'article_cover_image' => ['nullable', 'string', 'max:2048'],
'tags' => ['nullable', 'array'],
'tags.*' => ['string', 'max:100'],
'tags.*' => ['string', 'max:200'],
'video_url' => ['nullable', 'string', 'max:2048'],
'reading_minutes' => ['required', 'integer', 'min:1', 'max:999'],
'featured' => ['required', 'boolean'],
@@ -27,6 +27,7 @@ class UpsertAcademyPromptTemplateRequest extends FormRequest
'placeholders' => $this->normalizePlaceholders($this->input('placeholders')),
'helper_prompts' => $this->normalizeHelperPrompts($this->input('helper_prompts')),
'prompt_variants' => $this->normalizePromptVariants($this->input('prompt_variants')),
'filled_examples' => $this->normalizeFilledExamples($this->input('filled_examples')),
'tool_notes' => collect($this->input('tool_notes', []))
->filter(static fn ($note): bool => is_array($note) || is_string($note))
->map(function ($note): array|string {
@@ -59,8 +60,10 @@ class UpsertAcademyPromptTemplateRequest extends FormRequest
$promptId = $this->route('academyPromptTemplate')?->id;
return [
'category_id' => ['nullable', 'integer', 'exists:academy_categories,id'],
'new_category_name' => ['nullable', 'string', 'max:120'],
// Require either an existing category selection or a new category name.
'category_id' => ['nullable', 'integer', 'exists:academy_categories,id', 'required_without:new_category_name'],
'new_category_name' => ['nullable', 'string', 'max:120', 'required_without:category_id'],
'title' => ['required', 'string', 'max:180'],
'slug' => ['required', 'string', 'max:180', Rule::unique('academy_prompt_templates', 'slug')->ignore($promptId)],
'excerpt' => ['nullable', 'string'],
@@ -112,6 +115,12 @@ class UpsertAcademyPromptTemplateRequest extends FormRequest
'prompt_variants.*.risk_notes' => ['nullable', 'array'],
'prompt_variants.*.risk_notes.*' => ['nullable', 'string'],
'prompt_variants.*.active' => ['nullable', 'boolean'],
'filled_examples' => ['nullable', 'array', 'max:5'],
'filled_examples.*.title' => ['nullable', 'string', 'max:180'],
'filled_examples.*.description' => ['nullable', 'string'],
'filled_examples.*.placeholder_values' => ['nullable', 'array'],
'filled_examples.*.prompt' => ['required_with:filled_examples', 'string'],
'filled_examples.*.negative_prompt' => ['nullable', 'string'],
'difficulty' => ['required', 'string', Rule::in((array) config('academy.difficulty_levels', []))],
'access_level' => ['required', 'string', Rule::in(['free', 'creator', 'pro'])],
'aspect_ratio' => ['nullable', 'string', 'max:20'],
@@ -283,6 +292,53 @@ class UpsertAcademyPromptTemplateRequest extends FormRequest
->all();
}
private function normalizeFilledExamples(mixed $value): mixed
{
$value = $this->decodeStructuredInput($value);
if ($value === null) {
return [];
}
if (! is_array($value)) {
return $value;
}
$value = $this->normalizeStructuredObjectList($value, ['title', 'description', 'placeholder_values', 'prompt', 'negative_prompt']);
return collect($value)
->values()
->map(function ($example): mixed {
if (! is_array($example)) {
return $example;
}
return [
'title' => $this->normalizeOptionalString($example['title'] ?? null),
'description' => $this->normalizeOptionalString($example['description'] ?? null),
'placeholder_values' => is_array($example['placeholder_values'] ?? null) ? $example['placeholder_values'] : [],
'prompt' => $this->normalizeOptionalString($example['prompt'] ?? null),
'negative_prompt' => $this->normalizeOptionalString($example['negative_prompt'] ?? null),
];
})
->filter(function ($example): bool {
if (! is_array($example)) {
return true;
}
return collect([
$example['title'] ?? null,
$example['description'] ?? null,
$example['prompt'] ?? null,
$example['negative_prompt'] ?? null,
$example['placeholder_values'] ?? null,
])->contains(fn ($item): bool => $item !== null && $item !== '' && $item !== []);
})
->take(5)
->values()
->all();
}
private function normalizePromptVariants(mixed $value): mixed
{
$value = $this->decodeStructuredInput($value);
@@ -4,7 +4,9 @@ declare(strict_types=1);
namespace App\Http\Requests\Artworks;
use App\Support\ArtworkDescriptionContentValidator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Validator;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final class ArtworkCreateRequest extends FormRequest
@@ -25,6 +27,9 @@ final class ArtworkCreateRequest extends FormRequest
'title' => 'required|string|max:150',
'description' => 'nullable|string',
'category' => 'nullable|integer|exists:categories,id',
'primary_category_id' => 'nullable|integer|exists:categories,id',
'secondary_category_ids' => 'nullable|array|max:'.(int) config('categories.max_secondary_categories', 5),
'secondary_category_ids.*' => 'integer|exists:categories,id',
'tags' => 'nullable|string|max:200',
'license' => 'nullable|boolean',
'is_mature' => 'nullable|boolean',
@@ -32,9 +37,18 @@ final class ArtworkCreateRequest extends FormRequest
];
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
foreach (ArtworkDescriptionContentValidator::errors($this->input('description')) as $message) {
$validator->errors()->add('description', $message);
}
});
}
private function denyAsNotFound(): void
{
throw new NotFoundHttpException();
throw new NotFoundHttpException;
}
private function logUnauthorized(string $reason): void
@@ -3,7 +3,9 @@
namespace App\Http\Requests\Dashboard;
use App\Models\Artwork;
use App\Support\ArtworkDescriptionContentValidator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Validator;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class UpdateArtworkRequest extends FormRequest
@@ -45,6 +47,15 @@ class UpdateArtworkRequest extends FormRequest
];
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
foreach (ArtworkDescriptionContentValidator::errors($this->input('description')) as $message) {
$validator->errors()->add('description', $message);
}
});
}
public function artwork(): Artwork
{
if (! $this->artwork) {
@@ -4,8 +4,10 @@ declare(strict_types=1);
namespace App\Http\Requests\Manage;
use App\Support\ArtworkDescriptionContentValidator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Validator;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final class ManageArtworkUpdateRequest extends FormRequest
@@ -48,6 +50,15 @@ final class ManageArtworkUpdateRequest extends FormRequest
];
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
foreach (ArtworkDescriptionContentValidator::errors($this->input('description')) as $message) {
$validator->errors()->add('description', $message);
}
});
}
public function artwork(): object
{
if (! $this->artwork) {
@@ -4,8 +4,10 @@ declare(strict_types=1);
namespace App\Http\Requests\Studio;
use App\Support\ArtworkDescriptionContentValidator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
final class ApplyArtworkAiAssistRequest extends FormRequest
{
@@ -21,14 +23,26 @@ final class ApplyArtworkAiAssistRequest extends FormRequest
'title_mode' => ['sometimes', Rule::in(['replace', 'insert'])],
'description' => ['sometimes', 'nullable', 'string', 'max:5000'],
'description_mode' => ['sometimes', Rule::in(['replace', 'append'])],
'tags' => ['sometimes', 'array', 'max:' . (int) config('tags.max_user_tags', 30)],
'tags' => ['sometimes', 'array', 'max:'.(int) config('tags.max_user_tags', 30)],
'tags.*' => ['string', 'max:64'],
'tag_mode' => ['sometimes', Rule::in(['add', 'replace', 'remove'])],
'category_id' => ['sometimes', 'nullable', 'integer', 'exists:categories,id'],
'primary_category_id' => ['sometimes', 'nullable', 'integer', 'exists:categories,id'],
'secondary_category_ids' => ['sometimes', 'array', 'max:'.(int) config('categories.max_secondary_categories', 5)],
'secondary_category_ids.*' => ['integer', 'exists:categories,id'],
'content_type_id' => ['sometimes', 'nullable', 'integer', 'exists:content_types,id'],
'similar_actions' => ['sometimes', 'array', 'max:10'],
'similar_actions.*.artwork_id' => ['required_with:similar_actions', 'integer'],
'similar_actions.*.state' => ['required_with:similar_actions', Rule::in(['ignored', 'reviewed'])],
];
}
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
foreach (ArtworkDescriptionContentValidator::errors($this->input('description')) as $message) {
$validator->errors()->add('description', $message);
}
});
}
}
+134 -108
View File
@@ -1,9 +1,10 @@
<?php
namespace App\Http\Resources;
use App\Models\World;
use App\Models\WorldRelation;
use App\Models\WorldSubmission;
use App\Models\World;
use App\Services\ArtworkEvolutionService;
use App\Services\ContentSanitizer;
use App\Services\Maturity\ArtworkMaturityService;
@@ -12,15 +13,19 @@ use App\Services\Worlds\WorldRewardService;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
class ArtworkResource extends JsonResource
{
/** @var array<string, bool> */
private array $tablePresence = [];
/**
* Transform the resource into an array.
*/
public function toArray($request): array
{
$this->resource->loadMissing(['group', 'uploadedBy.profile', 'primaryAuthor.profile', 'contributors.user.profile', 'worldSubmissions.world', 'worldRewardGrants.world']);
$this->resource->loadMissing(['group', 'uploadedBy.profile', 'primaryAuthor.profile', 'contributors.user.profile', 'worldSubmissions.world']);
$md = ThumbnailPresenter::present($this->resource, 'md');
$lg = ThumbnailPresenter::present($this->resource, 'lg');
@@ -28,21 +33,19 @@ class ArtworkResource extends JsonResource
$sq = ThumbnailPresenter::present($this->resource, 'sq');
$screenshots = $this->resolveScreenshotAssets();
$canonicalSlug = \Illuminate\Support\Str::slug((string) ($this->slug ?: $this->title));
$canonicalSlug = Str::slug((string) ($this->slug ?: $this->title));
if ($canonicalSlug === '') {
$canonicalSlug = (string) $this->id;
}
$followerCount = 0;
if (!empty($this->user?->id)) {
if (Schema::hasTable('user_statistics')) {
$followerCount = (int) DB::table('user_statistics')
->where('user_id', (int) $this->user->id)
->value('followers_count');
}
if (! empty($this->user?->id)) {
$followerCount = (int) DB::table('user_statistics')
->where('user_id', (int) $this->user->id)
->value('followers_count');
// Legacy fallback for environments where new tables are unavailable.
if (($followerCount <= 0) && Schema::hasTable('friends_list')) {
if (($followerCount <= 0) && $this->hasTable('friends_list')) {
$followerCount = (int) DB::table('friends_list')
->where('friend_id', (int) $this->user->id)
->count();
@@ -58,37 +61,35 @@ class ArtworkResource extends JsonResource
$viewerAward = null;
$isOwner = $viewerId > 0 && $viewerId === (int) ($this->user?->id ?? 0);
$bookmarksCount = Schema::hasTable('artwork_bookmarks')
? (int) DB::table('artwork_bookmarks')->where('artwork_id', (int) $this->id)->count()
: 0;
$bookmarksCount = (int) DB::table('artwork_bookmarks')
->where('artwork_id', (int) $this->id)
->count();
if ($viewerId > 0) {
if (Schema::hasTable('artwork_likes')) {
if ($this->hasTable('artwork_likes')) {
$isLiked = DB::table('artwork_likes')
->where('user_id', $viewerId)
->where('artwork_id', (int) $this->id)
->exists();
}
if (Schema::hasTable('artwork_bookmarks')) {
$isBookmarked = DB::table('artwork_bookmarks')
->where('user_id', $viewerId)
->where('artwork_id', (int) $this->id)
->exists();
}
$isBookmarked = DB::table('artwork_bookmarks')
->where('user_id', $viewerId)
->where('artwork_id', (int) $this->id)
->exists();
$isFavorited = DB::table('artwork_favourites')
->where('user_id', $viewerId)
->where('artwork_id', (int) $this->id)
->exists();
if (!empty($this->user?->id)) {
if (Schema::hasTable('user_followers')) {
if (! empty($this->user?->id)) {
if ($this->hasTable('user_followers')) {
$isFollowing = DB::table('user_followers')
->where('user_id', (int) $this->user->id)
->where('follower_id', $viewerId)
->exists();
} elseif (Schema::hasTable('friends_list')) {
} elseif ($this->hasTable('friends_list')) {
// Legacy fallback only.
$isFollowing = DB::table('friends_list')
->where('user_id', $viewerId)
@@ -97,14 +98,14 @@ class ArtworkResource extends JsonResource
}
}
if (!empty($this->group?->id) && Schema::hasTable('group_follows')) {
if (! empty($this->group?->id) && $this->hasTable('group_follows')) {
$isFollowingGroup = DB::table('group_follows')
->where('group_id', (int) $this->group->id)
->where('user_id', $viewerId)
->exists();
}
if (Schema::hasTable('artwork_medals')) {
if ($this->hasTable('artwork_medals')) {
$viewerAward = DB::table('artwork_medals')
->where('user_id', $viewerId)
->where('artwork_id', (int) $this->id)
@@ -122,7 +123,7 @@ class ArtworkResource extends JsonResource
'id' => (int) ($user->id ?? 0),
'name' => html_entity_decode((string) ($user->name ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'username' => (string) ($user->username ?? ''),
'profile_url' => ! empty($user->username) ? '/@' . $user->username : null,
'profile_url' => ! empty($user->username) ? '/@'.$user->username : null,
'avatar_url' => $user->profile?->avatar_url,
];
};
@@ -147,7 +148,7 @@ class ArtworkResource extends JsonResource
'slug' => (string) ($this->user?->username ?? ''),
'headline' => '',
'avatar_url' => $this->user?->profile?->avatar_url,
'profile_url' => $this->user?->username ? '/@' . $this->user->username : null,
'profile_url' => $this->user?->username ? '/@'.$this->user->username : null,
'followers_count' => $followerCount,
'follow_url' => null,
'unfollow_url' => null,
@@ -176,6 +177,8 @@ class ArtworkResource extends JsonResource
'title' => $decode($this->title),
'description' => $decode($this->description),
'description_html' => $this->renderDescriptionHtml(),
'download_status' => (string) ($this->download_status ?? 'unknown'),
'download_source' => $this->download_source,
'dimensions' => [
'width' => (int) ($this->width ?? 0),
'height' => (int) ($this->height ?? 0),
@@ -198,7 +201,7 @@ class ArtworkResource extends JsonResource
'id' => (int) ($this->user?->id ?? 0),
'name' => html_entity_decode((string) ($this->user?->name ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'username' => (string) ($this->user?->username ?? ''),
'profile_url' => $this->user?->username ? '/@' . $this->user->username : null,
'profile_url' => $this->user?->username ? '/@'.$this->user->username : null,
'avatar_url' => $this->user?->profile?->avatar_url,
'level' => (int) ($this->user?->level ?? 1),
'rank' => (string) ($this->user?->rank ?? 'Newbie'),
@@ -255,20 +258,40 @@ class ArtworkResource extends JsonResource
'maturity' => app(ArtworkMaturityService::class)->presentation($this->resource, $request->user()),
'evolution' => app(ArtworkEvolutionService::class)->publicPayload($this->resource, $request->user()),
'world_participation' => $this->resolveWorldParticipation(),
'categories' => $this->categories->map(fn ($category) => [
'id' => (int) $category->id,
'slug' => (string) $category->slug,
'name' => html_entity_decode((string) $category->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'content_type_slug' => (string) ($category->contentType?->slug ?? ''),
'url' => $category->contentType ? $category->url : null,
'parent' => $category->parent ? [
'id' => (int) $category->parent->id,
'slug' => (string) $category->parent->slug,
'name' => html_entity_decode((string) $category->parent->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'content_type_slug' => (string) ($category->parent->contentType?->slug ?? ''),
'url' => $category->parent->contentType ? $category->parent->url : null,
] : null,
])->values(),
'category' => ($primary = $this->resolvedPrimaryCategory()) ? [
'id' => (int) $primary->id,
'slug' => (string) $primary->slug,
'name' => html_entity_decode((string) $primary->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'content_type_slug' => (string) ($primary->contentType?->slug ?? ''),
'url' => $primary->contentType ? $primary->url : null,
'is_primary' => true,
] : null,
'primary_category' => ($primary = $this->resolvedPrimaryCategory()) ? [
'id' => (int) $primary->id,
'slug' => (string) $primary->slug,
'name' => html_entity_decode((string) $primary->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'content_type_slug' => (string) ($primary->contentType?->slug ?? ''),
'url' => $primary->contentType ? $primary->url : null,
] : null,
'categories' => $this->categories->map(function ($category) {
$primaryId = (int) ($this->primary_category_id ?? 0);
return [
'id' => (int) $category->id,
'slug' => (string) $category->slug,
'name' => html_entity_decode((string) $category->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'content_type_slug' => (string) ($category->contentType?->slug ?? ''),
'url' => $category->contentType ? $category->url : null,
'is_primary' => $primaryId > 0 && (int) $category->id === $primaryId,
'parent' => $category->parent ? [
'id' => (int) $category->parent->id,
'slug' => (string) $category->parent->slug,
'name' => html_entity_decode((string) $category->parent->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'content_type_slug' => (string) ($category->parent->contentType?->slug ?? ''),
'url' => $category->parent->contentType ? $category->parent->url : null,
] : null,
];
})->values(),
'tags' => $this->tags->map(fn ($tag) => [
'id' => (int) $tag->id,
'slug' => (string) $tag->slug,
@@ -279,10 +302,6 @@ class ArtworkResource extends JsonResource
private function resolveScreenshotAssets(): array
{
if (! Schema::hasTable('artwork_files')) {
return [];
}
return DB::table('artwork_files')
->where('artwork_id', (int) $this->id)
->where('variant', 'like', 'shot%')
@@ -293,9 +312,9 @@ class ArtworkResource extends JsonResource
$url = $this->objectUrl($path);
return [
'id' => (string) ($row->variant ?? ('shot' . ($index + 1))),
'id' => (string) ($row->variant ?? ('shot'.($index + 1))),
'variant' => (string) ($row->variant ?? ''),
'label' => 'Screenshot ' . ($index + 1),
'label' => 'Screenshot '.($index + 1),
'url' => $url,
'thumb_url' => $url,
'mime_type' => (string) ($row->mime ?? 'image/jpeg'),
@@ -316,7 +335,7 @@ class ArtworkResource extends JsonResource
$base = rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/');
return $base . '/' . $trimmedPath;
return $base.'/'.$trimmedPath;
}
private function renderDescriptionHtml(): string
@@ -339,73 +358,75 @@ class ArtworkResource extends JsonResource
$items = collect();
$participationWorlds = collect();
if (Schema::hasTable('world_relations') && Schema::hasTable('worlds')) {
$relations = WorldRelation::query()
->with('world')
->where('related_type', WorldRelation::TYPE_ARTWORK)
->where('related_id', (int) $this->id)
->get()
->filter(fn (WorldRelation $relation): bool => $relation->world !== null && $relation->world->isPubliclyVisible())
->values();
$relations = WorldRelation::query()
->with('world')
->where('related_type', WorldRelation::TYPE_ARTWORK)
->where('related_id', (int) $this->id)
->get()
->filter(fn (WorldRelation $relation): bool => $relation->world !== null && $relation->world->isPubliclyVisible())
->values();
$participationWorlds = $participationWorlds->concat($relations->pluck('world')->filter());
$participationWorlds = $participationWorlds->concat(
$relations->pluck('world')->filter()
);
$items = $items->concat(
$relations->map(function (WorldRelation $relation): array {
$world = $relation->world;
$items = $items->concat(
$relations->map(function (WorldRelation $relation): array {
$world = $relation->world;
return [
'world_id' => (int) $relation->world_id,
'world_title' => (string) $world->title,
'world_slug' => (string) $world->slug,
'world_url' => $world->publicUrl(),
'badge_label' => 'Part of ' . $world->title,
'status' => 'curated',
'status_label' => 'Curated',
'tone' => 'curated',
'sort_priority' => 1,
];
})
);
}
return [
'world_id' => (int) $relation->world_id,
'world_title' => (string) $world->title,
'world_slug' => (string) $world->slug,
'world_url' => $world->publicUrl(),
'badge_label' => 'Part of '.$world->title,
'status' => 'curated',
'status_label' => 'Curated',
'tone' => 'curated',
'sort_priority' => 1,
];
})
);
if (Schema::hasTable('world_submissions')) {
$liveSubmissions = $this->worldSubmissions
->filter(function (WorldSubmission $submission): bool {
return (string) $submission->status === WorldSubmission::STATUS_LIVE
&& $submission->world !== null
&& $submission->world->isPubliclyVisible();
})
->values();
$liveSubmissions = $this->worldSubmissions
->filter(function (WorldSubmission $submission): bool {
return (string) $submission->status === WorldSubmission::STATUS_LIVE
&& $submission->world !== null
&& $submission->world->isPubliclyVisible();
})
->values();
$participationWorlds = $participationWorlds->concat($liveSubmissions->pluck('world')->filter());
World::primeCanonicalEditionIds($participationWorlds->pluck('recurrence_key')->all());
$participationWorlds = $participationWorlds->concat(
$liveSubmissions->pluck('world')->filter()
);
$items = $items->concat(
$liveSubmissions->map(function (WorldSubmission $submission): array {
$world = $submission->world;
$isFeatured = (bool) $submission->is_featured;
World::primeCanonicalEditionIds(
$participationWorlds->pluck('recurrence_key')->all()
);
return [
'world_id' => (int) $submission->world_id,
'world_title' => (string) $world->title,
'world_slug' => (string) $world->slug,
'world_url' => $world->publicUrl(),
'badge_label' => ($isFeatured ? 'Featured in ' : 'Part of ') . $world->title,
'status' => (string) $submission->status,
'status_label' => $isFeatured ? 'Featured' : 'Community submission',
'tone' => $isFeatured ? 'featured' : 'community',
'sort_priority' => $isFeatured ? 0 : 2,
];
})
);
} elseif ($participationWorlds->isNotEmpty()) {
World::primeCanonicalEditionIds($participationWorlds->pluck('recurrence_key')->all());
}
$items = $items->concat(
$liveSubmissions->map(function (WorldSubmission $submission): array {
$world = $submission->world;
$isFeatured = (bool) $submission->is_featured;
if (Schema::hasTable('world_reward_grants')) {
$items = $items->concat(app(WorldRewardService::class)->artworkRewardBadges($this->resource));
}
return [
'world_id' => (int) $submission->world_id,
'world_title' => (string) $world->title,
'world_slug' => (string) $world->slug,
'world_url' => $world->publicUrl(),
'badge_label' => ($isFeatured ? 'Featured in ' : 'Part of ').$world->title,
'status' => (string) $submission->status,
'status_label' => $isFeatured ? 'Featured' : 'Community submission',
'tone' => $isFeatured ? 'featured' : 'community',
'sort_priority' => $isFeatured ? 0 : 2,
];
})
);
$items = $items->concat(
app(WorldRewardService::class)
->artworkRewardBadges($this->resource)
);
return $items
->sortBy('sort_priority')
@@ -421,6 +442,11 @@ class ArtworkResource extends JsonResource
->all();
}
private function hasTable(string $table): bool
{
return $this->tablePresence[$table] ??= Schema::hasTable($table);
}
private function authorCanPublishLinks(): bool
{
$level = (int) ($this->user?->level ?? 1);
+4
View File
@@ -54,6 +54,10 @@ final class AutoTagArtworkJob implements ShouldQueue
public function handle(TagService $tagService, TagNormalizer $normalizer, ?VisionService $vision = null): void
{
if (! (bool) config('vision.auto_tagging.enabled', false)) {
return;
}
$vision ??= app(VisionService::class);
if (! $vision->isEnabled()) {
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Concerns;
/**
* M1 added $afterArtworkId to RecCompute* jobs. Payloads serialized before that
* property existed leave a typed property uninitialized on unserialize, which
* fatals on first access. Constructor defaults do not apply to unserialize.
*/
trait RestoresAfterArtworkIdCursor
{
protected function restoreAfterArtworkIdCursor(): void
{
if (! isset($this->afterArtworkId)) {
$this->afterArtworkId = null;
}
}
protected function afterArtworkId(): ?int
{
$this->restoreAfterArtworkIdCursor();
return $this->afterArtworkId;
}
}
+119
View File
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Enhance;
use App\Models\EnhanceJob;
use App\Services\Enhance\EnhanceProcessorFactory;
use App\Services\Enhance\EnhanceStorageService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Throwable;
final class ProcessEnhanceJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 2;
public int $timeout = 300;
public function __construct(
private readonly int $enhanceJobId,
) {
$queue = (string) config('enhance.queue', 'default');
if ($queue !== '') {
$this->onQueue($queue);
}
}
public function handle(EnhanceProcessorFactory $factory, EnhanceStorageService $storage): void
{
$enhanceJob = EnhanceJob::query()->find($this->enhanceJobId);
if (! $enhanceJob instanceof EnhanceJob) {
return;
}
if (! in_array($enhanceJob->status, [EnhanceJob::STATUS_PENDING, EnhanceJob::STATUS_QUEUED, EnhanceJob::STATUS_PROCESSING, EnhanceJob::STATUS_FAILED], true)) {
return;
}
$enhanceJob->forceFill([
'status' => EnhanceJob::STATUS_PROCESSING,
'started_at' => now(),
'finished_at' => null,
'error_message' => null,
])->save();
Log::info('enhance.job.processing', [
'enhance_job_id' => $enhanceJob->id,
'user_id' => $enhanceJob->user_id,
'engine' => $enhanceJob->engine,
]);
$started = microtime(true);
$completedExpiryDays = (int) config('enhance.lifecycle.completed_expires_after_days', 30);
try {
$processor = $factory->make((string) $enhanceJob->engine);
$result = $processor->process($enhanceJob);
$preview = $storage->createPreviewFromStoredOutput($enhanceJob, $result->disk, $result->path) ?? [];
$outputHash = null;
$outputContents = Storage::disk($result->disk)->get($result->path);
if (is_string($outputContents) && $outputContents !== '') {
$outputHash = hash('sha256', $outputContents);
}
$enhanceJob->forceFill([
'status' => EnhanceJob::STATUS_COMPLETED,
'output_disk' => $result->disk,
'output_path' => $result->path,
'output_hash' => $outputHash,
'output_width' => $result->width,
'output_height' => $result->height,
'output_filesize' => $result->filesize,
'output_mime' => $result->mime,
'metadata' => array_merge($enhanceJob->metadata ?? [], $result->metadata ?? []),
'processing_seconds' => (int) round(microtime(true) - $started),
'finished_at' => now(),
'expires_at' => $completedExpiryDays > 0 ? now()->addDays($completedExpiryDays) : null,
] + $preview)->save();
Log::info('enhance.job.completed', [
'enhance_job_id' => $enhanceJob->id,
'user_id' => $enhanceJob->user_id,
'processing_seconds' => $enhanceJob->processing_seconds,
]);
} catch (Throwable $exception) {
report($exception);
$enhanceJob->forceFill([
'status' => EnhanceJob::STATUS_FAILED,
'error_message' => Str::limit($exception->getMessage(), 1000),
'processing_seconds' => (int) round(microtime(true) - $started),
'finished_at' => now(),
])->save();
Log::warning('enhance.job.failed', [
'enhance_job_id' => $enhanceJob->id,
'user_id' => $enhanceJob->user_id,
'message' => $exception->getMessage(),
]);
throw $exception;
}
}
}
+167 -13
View File
@@ -7,9 +7,8 @@ namespace App\Jobs;
use App\Models\Artwork;
use App\Models\ArtworkEmbedding;
use App\Services\Vision\ArtworkEmbeddingClient;
use App\Services\Vision\ArtworkVisionImageUrl;
use App\Services\Vision\ArtworkVectorIndexService;
use App\Services\Vision\VectorService;
use App\Services\Vision\ArtworkVisionImageUrl;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@@ -49,8 +48,7 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue
ArtworkEmbeddingClient $client,
ArtworkVisionImageUrl $imageUrlBuilder,
ArtworkVectorIndexService $vectors,
): void
{
): void {
if (! (bool) config('recommendations.embedding.enabled', true)) {
return;
}
@@ -78,17 +76,73 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue
->where('model_version', $modelVersion)
->first();
if ($existing && (string) ($existing->source_hash ?? '') === $sourceHash) {
if (
$existing
&& (string) ($existing->source_hash ?? '') === $sourceHash
&& $this->vectorIndexCoversEmbedding(
$artwork,
$existing
)
) {
return;
}
}
$lockKey = $this->lockKey($artwork->id, $model, $modelVersion);
$lockKey = $this->lockKey(
$artwork->id,
$model,
$modelVersion
);
if (! $this->acquireLock($lockKey)) {
return;
}
try {
/*
* Another worker may have generated or indexed the
* embedding between our first read and lock acquisition.
* Re-read both records under the lock.
*/
if (! $this->force) {
$artwork->refresh();
$existing = ArtworkEmbedding::query()
->where('artwork_id', $artwork->id)
->where('model', $model)
->where('model_version', $modelVersion)
->first();
if (
$existing
&& (string) ($existing->source_hash ?? '')
=== $sourceHash
) {
if (
$this->vectorIndexCoversEmbedding(
$artwork,
$existing
)
) {
return;
}
$storedVector = $this->decodeStoredVector(
$existing
);
if ($storedVector !== []) {
$this->upsertVectorIndex(
$vectors,
$artwork,
$storedVector
);
return;
}
}
}
$imageUrl = $imageUrlBuilder->fromArtwork($artwork);
if ($imageUrl === null) {
return;
@@ -121,23 +175,33 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue
]
);
$this->upsertVectorIndex($vectors, $artwork);
$this->upsertVectorIndex(
$vectors,
$artwork,
$normalized
);
} finally {
$this->releaseLock($lockKey);
}
}
/**
* @param array<int, float> $vector
*/
private function upsertVectorIndex(
ArtworkVectorIndexService $vectors,
Artwork $artwork
): void
{
Artwork $artwork,
array $vector
): void {
if (! $vectors->isConfigured()) {
return;
}
try {
$vectors->upsertArtwork($artwork);
$vectors->upsertArtworkVector(
$artwork,
$vector
);
} catch (\Throwable $e) {
Log::warning('GenerateArtworkEmbeddingJob vector upsert failed', [
'artwork_id' => (int) $artwork->id,
@@ -146,8 +210,96 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue
}
}
private function vectorIndexCoversEmbedding(
Artwork $artwork,
ArtworkEmbedding $embedding
): bool {
$indexedAt = $artwork->last_vector_indexed_at;
$generatedAt = $embedding->generated_at;
if ($indexedAt === null || $generatedAt === null) {
return false;
}
return $indexedAt->greaterThanOrEqualTo(
$generatedAt
);
}
/**
* @param array<int, float> $vector
* @return array<int, float>
*/
private function decodeStoredVector(
ArtworkEmbedding $embedding
): array {
try {
$decoded = json_decode(
(string) $embedding->embedding_json,
true,
512,
JSON_THROW_ON_ERROR
);
} catch (\Throwable) {
return [];
}
if (! is_array($decoded) || $decoded === []) {
return [];
}
$vector = [];
foreach ($decoded as $value) {
if (! is_int($value) && ! is_float($value)) {
return [];
}
$value = (float) $value;
if (! is_finite($value)) {
return [];
}
$vector[] = $value;
}
$count = count($vector);
$minDim = max(
1,
(int) config(
'recommendations.embedding.min_dim',
64
)
);
$maxDim = max(
$minDim,
(int) config(
'recommendations.embedding.max_dim',
4096
)
);
if (
$count < $minDim
|| $count > $maxDim
) {
return [];
}
if (
(int) $embedding->dim > 0
&& $count !== (int) $embedding->dim
) {
return [];
}
return $vector;
}
/**
* @param array<int, float> $vector
* @return array<int, float>
*/
private function normalize(array $vector): array
@@ -162,12 +314,13 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue
}
$norm = sqrt($sumSquares);
return array_map(static fn (float $value): float => $value / $norm, $vector);
}
private function lockKey(int $artworkId, string $model, string $version): string
{
return 'artwork-embedding:lock:' . $artworkId . ':' . $model . ':' . $version;
return 'artwork-embedding:lock:'.$artworkId.':'.$model.':'.$version;
}
private function acquireLock(string $key): bool
@@ -177,6 +330,7 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue
if ($didSet) {
Redis::expire($key, 1800);
}
return (bool) $didSet;
} catch (\Throwable) {
return true;
+12 -4
View File
@@ -54,10 +54,18 @@ final class GenerateDerivativesJob implements ShouldQueue
}
// Auto-tagging is async and must never block publish.
AutoTagArtworkJob::dispatch($this->artworkId, $this->hash)->afterCommit();
DetectArtworkMaturityJob::dispatch($this->artworkId, $this->hash)->afterCommit();
GenerateArtworkEmbeddingJob::dispatch($this->artworkId, $this->hash)->afterCommit();
AnalyzeArtworkAiAssistJob::dispatch($this->artworkId)->afterCommit();
if ((bool) config('vision.auto_tagging.enabled', false)) {
AutoTagArtworkJob::dispatch($this->artworkId, $this->hash)->afterCommit();
}
if ((bool) config('vision.upload.maturity.enabled', false)) {
DetectArtworkMaturityJob::dispatch($this->artworkId, $this->hash)->afterCommit();
}
if ((bool) config('vision.upload.embeddings.enabled', true)) {
GenerateArtworkEmbeddingJob::dispatch($this->artworkId, $this->hash)->afterCommit();
}
if ((bool) config('vision.upload.ai_assist.enabled', false)) {
AnalyzeArtworkAiAssistJob::dispatch($this->artworkId)->afterCommit();
}
}
public function failed(\Throwable $exception): void
+15 -1
View File
@@ -6,6 +6,7 @@ namespace App\Jobs;
use App\Models\Artwork;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
@@ -21,16 +22,29 @@ use Meilisearch\Client as MeilisearchClient;
* 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, ShouldBeUniqueUntilProcessing
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 60;
/**
* Unique while queued so Meilisearch isn't flooded. handle() loads the
* artwork from DB, so a dropped duplicate still indexes current state.
* UniqueUntilProcessing: a change after the worker starts can enqueue again.
*/
public int $uniqueFor = 120;
public function __construct(public readonly int $artworkId)
{
$this->afterCommit = true;
$this->onQueue((string) config('scout.queue.queue', 'search'));
}
public function uniqueId(): string
{
return (string) $this->artworkId;
}
public function handle(MeilisearchClient $client): void
+17 -2
View File
@@ -6,6 +6,7 @@ namespace App\Jobs;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
@@ -15,14 +16,28 @@ use Illuminate\Queue\SerializesModels;
* Queued job: index (or re-index) a single User in Meilisearch.
* Dispatched by UserStatsService whenever stats change.
*/
class IndexUserJob implements ShouldQueue
class IndexUserJob implements ShouldQueue, ShouldBeUniqueUntilProcessing
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 30;
public function __construct(public readonly int $userId) {}
/**
* Unique while queued. handle() loads the user from DB so the queued job
* still reflects later stats changes. uniqueFor covers a dead worker lock.
*/
public int $uniqueFor = 60;
public function __construct(public readonly int $userId)
{
$this->onQueue((string) config('scout.queue.queue', 'search'));
}
public function uniqueId(): string
{
return (string) $this->userId;
}
public function handle(): void
{
+6 -8
View File
@@ -4,8 +4,8 @@ declare(strict_types=1);
namespace App\Jobs;
use App\Services\Recommendations\UserInterestProfileService;
use App\Services\Recommendations\SessionRecoService;
use App\Services\Recommendations\UserInterestProfileService;
use Carbon\CarbonImmutable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -30,7 +30,7 @@ final class IngestUserDiscoveryEventJob implements ShouldQueue
public array $backoff = [5, 30, 120];
/**
* @param array<string, mixed> $meta
* @param array<string, mixed> $meta
*/
public function __construct(
public readonly string $eventId,
@@ -40,8 +40,7 @@ final class IngestUserDiscoveryEventJob implements ShouldQueue
public readonly string $algoVersion,
public readonly string $occurredAt,
public readonly array $meta = []
) {
}
) {}
public function handle(UserInterestProfileService $profileService, SessionRecoService $sessionRecoService): void
{
@@ -70,10 +69,9 @@ final class IngestUserDiscoveryEventJob implements ShouldQueue
$eventVersion = (string) config('discovery.event_version', 'event-v1');
$eventWeight = (float) ((array) config('discovery.weights', []))[$this->eventType] ?? 1.0;
$categoryId = DB::table('artwork_category')
->where('artwork_id', $this->artworkId)
->orderBy('category_id')
->value('category_id');
$categoryId = DB::table('artworks')
->where('id', $this->artworkId)
->value('primary_category_id');
$insertPayload = [
'event_id' => $this->eventId,

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