Compare commits

...
71 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
klevze 0b33a1b074 Implement academy analytics, billing, and web stories updates 2026-05-26 07:27:29 +02:00
klevze 456c3d6bb0 Fix: remove Academy families from sitemaps; show total prompts in Academy prompt library; fix void closure in AcademyAdminController 2026-05-11 21:56:38 +02:00
klevze ff96ef796e chore: commit remaining workspace changes 2026-05-08 21:51:29 +02:00
klevze 8d108b8a76 Homepage: add stable intro copy; mark footer utility links data-nosnippet; add render test 2026-05-08 21:51:28 +02:00
klevze 6b83d76cd1 SEO: gallery VisualArtwork contentUrl -> single best URL; update gallery unit test 2026-05-08 21:51:28 +02:00
klevze 0c5dde9b22 Featured artworks thumbnails 2026-05-06 19:11:31 +02:00
klevze 82f2b1f660 Add tests for featured thumbnail generation; apply Pint formatting and related edits 2026-05-06 18:55:40 +02:00
klevze 7a8bc8e22a Wire homepage hero to featured thumbnail family; add featured-picture component 2026-05-06 18:55:20 +02:00
klevze 8fa3adf4df Add job and artisan command for generating featured thumbnails 2026-05-06 18:55:08 +02:00
klevze bd8a5c14a0 Add FeaturedArtworkThumbnailGenerator and FeaturedArtworkSelector 2026-05-06 18:54:57 +02:00
klevze 2c2c0f6722 Add Artwork model convenience methods for featured thumbnails 2026-05-06 18:54:31 +02:00
klevze ee24111d59 Add featured thumbnail config and ArtworkFeaturedImagePath helper 2026-05-06 18:54:18 +02:00
klevze a3cfc6c17f feat(academy): prepare AI Academy v1 for production enablement 2026-05-03 19:59:27 +02:00
klevze 90e93f0d42 Fixes 2026-05-03 09:21:13 +02:00
klevze 44354e5bea News: normalize category select values; fix Studio news editor category persistence; add CSV→SQL generator and news_dates.sql 2026-05-03 09:12:38 +02:00
klevze a9dfa6ea11 Refine SEO, uploads, and deploy handling 2026-05-02 10:48:08 +02:00
klevze b6be6ed2ac chore: commit regenerated ssr assets 2026-05-02 09:37:28 +02:00
klevze caf1464aa5 chore: commit current workspace changes 2026-05-02 09:37:14 +02:00
1148 changed files with 541772 additions and 14521 deletions
@@ -172,7 +172,9 @@ class NewsController extends Controller
$userId = Auth::id(); $userId = Auth::id();
$session = 'news_view_' . $article->id; $session = 'news_view_' . $article->id;
if ($request->session()->has($session)) { $canReadSession = $request->hasSession() && ! $request->attributes->get('skinbase.session_skipped');
if ($canReadSession && $request->session()->has($session)) {
return; return;
} }
@@ -185,7 +187,9 @@ class NewsController extends Controller
$article->incrementViews(); $article->incrementViews();
$request->session()->put($session, true); if ($canReadSession) {
$request->session()->put($session, true);
}
} }
private function sidebarData(): array private function sidebarData(): array
@@ -102,7 +102,7 @@ final class SimilarArtworksPageController extends Controller
'page_title' => 'Similar to "' . $sourceTitle . '" — Skinbase', 'page_title' => 'Similar to "' . $sourceTitle . '" — Skinbase',
'page_meta_description' => 'Discover artworks similar to "' . $sourceTitle . '" on Skinbase.', 'page_meta_description' => 'Discover artworks similar to "' . $sourceTitle . '" on Skinbase.',
'page_canonical' => $baseUrl, 'page_canonical' => $baseUrl,
'page_robots' => 'noindex,follow', 'page_robots' => 'index,follow',
'breadcrumbs' => collect([ 'breadcrumbs' => collect([
(object) ['name' => 'Explore', 'url' => '/explore'], (object) ['name' => 'Explore', 'url' => '/explore'],
(object) ['name' => $sourceTitle, 'url' => $sourceUrl], (object) ['name' => $sourceTitle, 'url' => $sourceUrl],
@@ -92,7 +92,7 @@ final class SitemapCacheService
{ {
$prefix = trim((string) config('sitemaps.pre_generated.path', 'generated-sitemaps'), '/'); $prefix = trim((string) config('sitemaps.pre_generated.path', 'generated-sitemaps'), '/');
$segments = $name === self::INDEX_DOCUMENT $segments = $name === self::INDEX_DOCUMENT
? [$prefix, 'sitemap.xml'] ? [$prefix, 'sitemaps', 'sitemap.xml']
: [$prefix, 'sitemaps', $name . '.xml']; : [$prefix, 'sitemaps', $name . '.xml'];
return implode('/', array_values(array_filter($segments, static fn (string $segment): bool => $segment !== ''))); return implode('/', array_values(array_filter($segments, static fn (string $segment): bool => $segment !== '')));
@@ -124,7 +124,7 @@ final class SitemapReleaseManager
public function documentRelativePath(string $documentName): string public function documentRelativePath(string $documentName): string
{ {
return $documentName === SitemapCacheService::INDEX_DOCUMENT return $documentName === SitemapCacheService::INDEX_DOCUMENT
? 'sitemap.xml' ? 'sitemaps/sitemap.xml'
: 'sitemaps/' . $documentName . '.xml'; : 'sitemaps/' . $documentName . '.xml';
} }
@@ -107,7 +107,7 @@ export default function ArtworkMaturityQueue() {
], [stats]) ], [stats])
return ( return (
<div className="mx-auto max-w-7xl px-4 pb-16 pt-8 sm:px-6 lg:px-8"> <div className="w-full pb-16 pt-8">
<Head title="Artwork Maturity Queue" /> <Head title="Artwork Maturity Queue" />
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.16),transparent_36%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.88))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]"> <section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.16),transparent_36%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.88))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
@@ -56,6 +56,7 @@ export default function Topbar({ user = null }) {
<div className="border-t border-neutral-700" /> <div className="border-t border-neutral-700" />
<a href={user.uploadUrl} className="block px-4 py-2 text-sm hover:bg-white/5">Upload</a> <a href={user.uploadUrl} className="block px-4 py-2 text-sm hover:bg-white/5">Upload</a>
<a href="/studio/artworks" className="block px-4 py-2 text-sm hover:bg-white/5">Studio</a> <a href="/studio/artworks" className="block px-4 py-2 text-sm hover:bg-white/5">Studio</a>
{user.moderationUrl ? <a href={user.moderationUrl} className="block px-4 py-2 text-sm hover:bg-white/5">Moderation</a> : null}
<a href="/dashboard" className="block px-4 py-2 text-sm hover:bg-white/5">Dashboard</a> <a href="/dashboard" className="block px-4 py-2 text-sm hover:bg-white/5">Dashboard</a>
<div className="border-t border-neutral-700" /> <div className="border-t border-neutral-700" />
<a href="/logout" className="block px-4 py-2 text-sm text-red-400 hover:bg-white/5" <a href="/logout" className="block px-4 py-2 text-sm text-red-400 hover:bg-white/5"
@@ -13,6 +13,7 @@ function mount() {
username: container.dataset.username || '', username: container.dataset.username || '',
avatarUrl: container.dataset.avatarUrl || null, avatarUrl: container.dataset.avatarUrl || null,
uploadUrl: container.dataset.uploadUrl || '/upload', uploadUrl: container.dataset.uploadUrl || '/upload',
moderationUrl: container.dataset.moderationUrl || null,
} }
: null : null
@@ -245,6 +245,7 @@
data-username="{{ Auth::user()->username ?? '' }}" data-username="{{ Auth::user()->username ?? '' }}"
data-avatar-url="{{ \App\Support\AvatarUrl::forUser((int) Auth::id(), optional(Auth::user()->profile)->avatar_hash, 64) }}" data-avatar-url="{{ \App\Support\AvatarUrl::forUser((int) Auth::id(), optional(Auth::user()->profile)->avatar_hash, 64) }}"
data-upload-url="{{ Route::has('upload') ? route('upload') : '/upload' }}" data-upload-url="{{ Route::has('upload') ? route('upload') : '/upload' }}"
data-moderation-url="{{ in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true) ? '/moderation' : '' }}"
@endif @endif
></div> ></div>
@include('layouts.nova.toolbar') @include('layouts.nova.toolbar')
@@ -310,6 +310,7 @@
@php @php
$toolbarUsername = strtolower((string) (Auth::user()->username ?? '')); $toolbarUsername = strtolower((string) (Auth::user()->username ?? ''));
$routeUpload = Route::has('upload') ? route('upload') : '/upload'; $routeUpload = Route::has('upload') ? route('upload') : '/upload';
$routeModeration = '/moderation';
$routeDashboard = Route::has('dashboard') ? route('dashboard') : '/dashboard'; $routeDashboard = Route::has('dashboard') ? route('dashboard') : '/dashboard';
$routeMyArtworks = Route::has('studio.artworks') ? route('studio.artworks') : '/studio/artworks'; $routeMyArtworks = Route::has('studio.artworks') ? route('studio.artworks') : '/studio/artworks';
$routeMyStories = Route::has('creator.stories.index') ? route('creator.stories.index') : '/creator/stories'; $routeMyStories = Route::has('creator.stories.index') ? route('creator.stories.index') : '/creator/stories';
@@ -376,6 +377,12 @@
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-palette text-xs text-sb-muted"></i></span> <span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-palette text-xs text-sb-muted"></i></span>
Studio Studio
</a> </a>
@if(in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true))
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ $routeModeration }}">
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-user-shield text-xs text-sb-muted"></i></span>
Moderation
</a>
@endif
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ $routeDashboard }}"> <a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ $routeDashboard }}">
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-table-columns text-xs text-sb-muted"></i></span> <span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-table-columns text-xs text-sb-muted"></i></span>
Dashboard Dashboard
@@ -401,13 +408,6 @@
Settings Settings
</a> </a>
@if(in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true) && \Illuminate\Support\Facades\Route::has('admin.usernames.moderation'))
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('admin.usernames.moderation') }}">
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-user-shield text-xs text-sb-muted"></i></span>
Moderation
</a>
@endif
<div class="border-t border-panel mt-1 mb-1"></div> <div class="border-t border-panel mt-1 mb-1"></div>
<form method="POST" action="{{ route('logout') }}" class="mb-1"> <form method="POST" action="{{ route('logout') }}" class="mb-1">
@csrf @csrf
@@ -5,6 +5,7 @@
$hero_description = "We're always grateful for volunteers who want to help."; $hero_description = "We're always grateful for volunteers who want to help.";
$center_content = true; $center_content = true;
$center_max = '3xl'; $center_max = '3xl';
$errors = $errors ?? new \Illuminate\Support\ViewErrorBag();
@endphp @endphp
@section('page-content') @section('page-content')
+135 -2
View File
@@ -4,6 +4,23 @@ APP_KEY=
APP_DEBUG=true APP_DEBUG=true
APP_URL=http://localhost 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_LOCALE=en
APP_FALLBACK_LOCALE=en APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US APP_FAKER_LOCALE=en_US
@@ -20,6 +37,11 @@ LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug 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_CONNECTION=mysql
DB_HOST=127.0.0.1 DB_HOST=127.0.0.1
DB_PORT=3306 DB_PORT=3306
@@ -41,7 +63,7 @@ SESSION_ENCRYPT=false
SESSION_PATH=/ SESSION_PATH=/
SESSION_DOMAIN=null SESSION_DOMAIN=null
# Skinbase Nova conditional public sessions # Skinbase conditional public sessions
SKINBASE_CONDITIONAL_SESSIONS_ENABLED=true SKINBASE_CONDITIONAL_SESSIONS_ENABLED=true
SKINBASE_SKIP_ANONYMOUS_PUBLIC_GET_SESSIONS=true SKINBASE_SKIP_ANONYMOUS_PUBLIC_GET_SESSIONS=true
SKINBASE_SKIP_BOT_PUBLIC_GET_SESSIONS=true SKINBASE_SKIP_BOT_PUBLIC_GET_SESSIONS=true
@@ -53,6 +75,48 @@ BROADCAST_CONNECTION=reverb
FILESYSTEM_DISK=local FILESYSTEM_DISK=local
QUEUE_CONNECTION=redis 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_REALTIME=true
MESSAGING_BROADCAST_QUEUE=broadcasts MESSAGING_BROADCAST_QUEUE=broadcasts
MESSAGING_TYPING_TTL=8 MESSAGING_TYPING_TTL=8
@@ -92,6 +156,13 @@ UPLOAD_CHUNK_REQUEST_TIMEOUT_MS=45000
UPLOAD_RATE_CHUNK_USER=180 UPLOAD_RATE_CHUNK_USER=180
UPLOAD_RATE_CHUNK_IP=360 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 # Draft abuse prevention controls
SKINBASE_MAX_DRAFTS=10 SKINBASE_MAX_DRAFTS=10
SKINBASE_MAX_DRAFT_STORAGE_MB=1024 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_RETRY_DELAY_MS=250
VISION_VECTOR_GATEWAY_UPSERT_ENDPOINT=/vectors/upsert VISION_VECTOR_GATEWAY_UPSERT_ENDPOINT=/vectors/upsert
VISION_VECTOR_GATEWAY_SEARCH_ENDPOINT=/vectors/search 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_DELETE_ENDPOINT=/vectors/delete
VISION_VECTOR_GATEWAY_COLLECTIONS_ENDPOINT=/vectors/collections VISION_VECTOR_GATEWAY_COLLECTIONS_ENDPOINT=/vectors/collections
@@ -210,6 +286,25 @@ YOLO_HTTP_RETRIES=1
YOLO_HTTP_RETRY_DELAY_MS=200 YOLO_HTTP_RETRY_DELAY_MS=200
YOLO_PHOTOGRAPHY_ONLY=true YOLO_PHOTOGRAPHY_ONLY=true
# Academy feature flags
SKINBASE_ACADEMY_ENABLED=true
SKINBASE_ACADEMY_PAYMENTS_ENABLED=false
ACADEMY_BILLING_ENABLED=false
ACADEMY_STRIPE_SUBSCRIPTION_NAME=academy
# Stripe / Cashier
STRIPE_KEY=pk_test_xxx
STRIPE_SECRET=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
CASHIER_CURRENCY=eur
CASHIER_CURRENCY_LOCALE=sl_SI
# Academy billing price IDs
ACADEMY_CREATOR_MONTHLY_PRICE_ID=price_xxx
ACADEMY_PRO_MONTHLY_PRICE_ID=price_xxx
# Stripe expects real price object IDs that start with price_, not product IDs like prod_...
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Production examples (uncomment and adjust) # Production examples (uncomment and adjust)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -298,7 +393,7 @@ REGISTRATION_IP_PER_DAY_LIMIT=20
REGISTRATION_EMAIL_PER_MINUTE_LIMIT=6 REGISTRATION_EMAIL_PER_MINUTE_LIMIT=6
REGISTRATION_EMAIL_COOLDOWN_MINUTES=30 REGISTRATION_EMAIL_COOLDOWN_MINUTES=30
REGISTRATION_VERIFY_TOKEN_TTL_HOURS=24 REGISTRATION_VERIFY_TOKEN_TTL_HOURS=24
REGISTRATION_ENABLE_TURNSTILE=true TURNSTILE_ENABLED=false
REGISTRATION_DISPOSABLE_DOMAINS_ENABLED=true REGISTRATION_DISPOSABLE_DOMAINS_ENABLED=true
REGISTRATION_TURNSTILE_SUSPICIOUS_ATTEMPTS=2 REGISTRATION_TURNSTILE_SUSPICIOUS_ATTEMPTS=2
REGISTRATION_TURNSTILE_ATTEMPT_WINDOW_MINUTES=30 REGISTRATION_TURNSTILE_ATTEMPT_WINDOW_MINUTES=30
@@ -306,9 +401,34 @@ REGISTRATION_EMAIL_GLOBAL_SEND_PER_MINUTE=30
REGISTRATION_MONTHLY_EMAIL_LIMIT=10000 REGISTRATION_MONTHLY_EMAIL_LIMIT=10000
TURNSTILE_SITE_KEY= TURNSTILE_SITE_KEY=
TURNSTILE_SECRET_KEY= TURNSTILE_SECRET_KEY=
TURNSTILE_FAIL_OPEN=false
TURNSTILE_VERIFY_URL=https://challenges.cloudflare.com/turnstile/v0/siteverify TURNSTILE_VERIFY_URL=https://challenges.cloudflare.com/turnstile/v0/siteverify
TURNSTILE_TIMEOUT=5 TURNSTILE_TIMEOUT=5
ENHANCE_DISK=public
ENHANCE_SOURCE_PREFIX=enhance/sources
ENHANCE_OUTPUT_PREFIX=enhance/outputs
ENHANCE_PREVIEW_PREFIX=enhance/previews
ENHANCE_ENGINE=stub
ENHANCE_MAX_UPLOAD_MB=20
ENHANCE_MAX_INPUT_WIDTH=4096
ENHANCE_MAX_INPUT_HEIGHT=4096
ENHANCE_MAX_OUTPUT_WIDTH=8192
ENHANCE_MAX_OUTPUT_HEIGHT=8192
ENHANCE_DAILY_LIMIT=10
ENHANCE_QUEUE=default
ENHANCE_COMPLETED_EXPIRES_AFTER_DAYS=30
ENHANCE_FAILED_EXPIRES_AFTER_DAYS=7
ENHANCE_DELETED_FILE_GRACE_DAYS=1
ENHANCE_CLEANUP_CHUNK_SIZE=100
ENHANCE_STUCK_PROCESSING_AFTER_MINUTES=30
ENHANCE_STUCK_QUEUED_AFTER_MINUTES=60
ENHANCE_STUB_SHOW_WARNING=true
ENHANCE_WORKER_URL=
ENHANCE_WORKER_TIMEOUT=300
ENHANCE_WORKER_TOKEN=
ENHANCE_WORKER_MAX_DOWNLOAD_MB=60
AWS_ACCESS_KEY_ID= AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY= AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1 AWS_DEFAULT_REGION=us-east-1
@@ -355,6 +475,19 @@ GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=/auth/google/callback 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 — https://discord.com/developers/applications
DISCORD_CLIENT_ID= DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET= DISCORD_CLIENT_SECRET=
+2
View File
@@ -13,6 +13,7 @@
/.deploy /.deploy
/.zed /.zed
/auth.json /auth.json
/build-info.json
/node_modules /node_modules
/public/build /public/build
/public/hot /public/hot
@@ -45,6 +46,7 @@
/storage/*.tar.xz /storage/*.tar.xz
/storage/*.tar /storage/*.tar
/storage/*.tgz /storage/*.tgz
/var/deploy/
/vendor /vendor
Homestead.json Homestead.json
Homestead.yaml Homestead.yaml
@@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\AcademyContentMetricDaily;
use App\Models\AcademyEvent;
use App\Models\AcademyLike;
use App\Models\AcademySave;
use App\Models\AcademySearchLog;
use App\Models\AcademyUserProgress;
use App\Support\AcademyAnalytics\AcademyAnalyticsEventType;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Schema;
final class AcademyAnalyticsHealthCommand extends Command
{
protected $signature = 'academy:analytics-health {--json : Output machine-readable JSON}';
protected $description = 'Inspect Academy analytics collection health, rollup freshness, and privacy safeguards';
private const RETENTION_DAYS = 180;
public function handle(): int
{
$report = $this->buildReport();
if ((bool) $this->option('json')) {
$this->line(json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) ?: '{}');
return self::SUCCESS;
}
$this->line('Academy Analytics Health Check');
$this->line('==============================');
$this->newLine();
$this->line(sprintf('Events last 24h: %d', $report['events_last_24h']));
$this->line(sprintf('Events last 7d: %d', $report['events_last_7d']));
$this->line(sprintf('Latest event: %s', $report['latest_event_at'] ?? 'none'));
$this->line(sprintf('Latest rollup date: %s', $report['latest_rollup_date'] ?? 'none'));
$this->line(sprintf('Search logs: %d', $report['search_logs']));
$this->line(sprintf('Search clicks: %d', $report['search_clicks']));
$this->line(sprintf('Likes: %d', $report['likes']));
$this->line(sprintf('Saves: %d', $report['saves']));
$this->line(sprintf('Progress records: %d', $report['progress_records']));
$this->line(sprintf('Prompt copies: %d', $report['prompt_copies']));
$this->line(sprintf('Upgrade clicks: %d', $report['upgrade_clicks']));
$this->line(sprintf('Human events: %d', $report['human_events']));
$this->line(sprintf('Bot/admin events: %d', $report['bot_admin_events']));
$this->line(sprintf('Recent daily metric rows: %d', $report['recent_daily_metric_rows']));
$this->line(sprintf('Raw IP storage detected: %s', $report['raw_ip_storage_detected'] ? 'yes' : 'no'));
$this->line(sprintf('Events older than retention: %d', $report['events_older_than_retention']));
$this->newLine();
foreach ($report['warnings'] as $warning) {
$this->warn(sprintf('WARNING: %s', $warning));
}
$this->info(sprintf('Status: %s', $report['status']));
return self::SUCCESS;
}
/**
* @return array<string, mixed>
*/
private function buildReport(): array
{
$now = now();
$last24Hours = $now->copy()->subDay();
$last7Days = $now->copy()->subDays(7);
$retentionCutoff = $now->copy()->subDays(self::RETENTION_DAYS);
$warnings = [];
$rawIpStorageDetected = $this->rawIpStorageDetected();
$eventsTableExists = Schema::hasTable('academy_events');
$metricsTableExists = Schema::hasTable('academy_content_metrics_daily');
$searchLogsTableExists = Schema::hasTable('academy_search_logs');
$likesTableExists = Schema::hasTable('academy_likes');
$savesTableExists = Schema::hasTable('academy_saves');
$progressTableExists = Schema::hasTable('academy_user_progress');
$latestEvent = $eventsTableExists ? AcademyEvent::query()->latest('occurred_at')->value('occurred_at') : null;
$latestRollup = $metricsTableExists ? AcademyContentMetricDaily::query()->latest('date')->value('date') : null;
$searchLogCount = $searchLogsTableExists ? AcademySearchLog::query()->count() : 0;
$searchClickCount = $searchLogsTableExists ? AcademySearchLog::query()->whereNotNull('clicked_content_id')->count() : 0;
$eventsOlderThanRetention = $eventsTableExists ? AcademyEvent::query()->where('occurred_at', '<', $retentionCutoff)->count() : 0;
$recentDailyMetricRows = $metricsTableExists
? AcademyContentMetricDaily::query()->whereBetween('date', [$now->copy()->subDays(6)->toDateString(), $now->toDateString()])->count()
: 0;
$report = [
'events_last_24h' => $eventsTableExists ? AcademyEvent::query()->where('occurred_at', '>=', $last24Hours)->count() : 0,
'events_last_7d' => $eventsTableExists ? AcademyEvent::query()->where('occurred_at', '>=', $last7Days)->count() : 0,
'latest_event_at' => $latestEvent ? Carbon::parse((string) $latestEvent)->toDateTimeString() : null,
'latest_rollup_date' => $latestRollup ? Carbon::parse((string) $latestRollup)->toDateString() : null,
'search_logs' => $searchLogCount,
'search_clicks' => $searchClickCount,
'likes' => $likesTableExists ? AcademyLike::query()->count() : 0,
'saves' => $savesTableExists ? AcademySave::query()->count() : 0,
'progress_records' => $progressTableExists ? AcademyUserProgress::query()->count() : 0,
'prompt_copies' => $eventsTableExists ? AcademyEvent::query()->where('event_type', AcademyAnalyticsEventType::PROMPT_COPY)->count() : 0,
'upgrade_clicks' => $eventsTableExists ? AcademyEvent::query()->where('event_type', AcademyAnalyticsEventType::UPGRADE_CLICK)->count() : 0,
'human_events' => $eventsTableExists ? AcademyEvent::query()->where('is_bot', false)->where('is_admin', false)->where('is_suspicious', false)->count() : 0,
'bot_admin_events' => $eventsTableExists ? AcademyEvent::query()->where(function ($query): void {
$query->where('is_bot', true)->orWhere('is_admin', true)->orWhere('is_suspicious', true);
})->count() : 0,
'raw_ip_storage_detected' => $rawIpStorageDetected,
'events_older_than_retention' => $eventsOlderThanRetention,
'recent_daily_metric_rows' => $recentDailyMetricRows,
'retention_days' => self::RETENTION_DAYS,
'tables_present' => [
'academy_events' => $eventsTableExists,
'academy_content_metrics_daily' => $metricsTableExists,
'academy_search_logs' => $searchLogsTableExists,
'academy_likes' => $likesTableExists,
'academy_saves' => $savesTableExists,
'academy_user_progress' => $progressTableExists,
],
'warnings' => [],
'status' => 'OK',
];
foreach ($report['tables_present'] as $table => $present) {
if (! $present) {
$warnings[] = sprintf('Analytics table %s is missing.', $table);
}
}
if ($report['events_last_24h'] === 0) {
$warnings[] = 'No events received in last 24 hours.';
}
if ($report['events_last_7d'] === 0) {
$warnings[] = 'No events received in last 7 days.';
}
if ($report['latest_rollup_date'] === null) {
$warnings[] = 'No rollup rows exist yet.';
} elseif ($report['latest_rollup_date'] !== $now->toDateString()) {
$warnings[] = 'Rollup has not run for today.';
}
if ($searchLogCount > 0 && $searchClickCount === 0) {
$warnings[] = 'Search clicks are zero although search logs exist.';
}
if ($eventsOlderThanRetention > 0) {
$warnings[] = 'Raw events older than configured retention period exist.';
}
if ($recentDailyMetricRows === 0) {
$warnings[] = 'No daily metrics exist for recent days.';
}
if ($rawIpStorageDetected) {
$warnings[] = 'Raw IP storage indicators were found in Academy analytics tables.';
}
$report['warnings'] = $warnings;
$report['status'] = $warnings === [] ? 'OK' : 'WARNING';
return $report;
}
private function rawIpStorageDetected(): bool
{
foreach (['academy_events', 'academy_search_logs', 'academy_content_metrics_daily', 'academy_likes', 'academy_saves', 'academy_user_progress'] as $table) {
if (! Schema::hasTable($table)) {
continue;
}
foreach (['ip', 'ip_address', 'visitor_ip', 'raw_ip', 'remote_addr'] as $column) {
if (Schema::hasColumn($table, $column)) {
return true;
}
}
}
return false;
}
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\AcademyEvent;
use Illuminate\Console\Command;
final class AcademyAnalyticsPruneEventsCommand extends Command
{
protected $signature = 'academy:analytics-prune-events {--days=180}';
protected $description = 'Delete old raw Academy analytics events while keeping daily rollups';
public function handle(): int
{
$days = max(1, (int) $this->option('days'));
$deleted = AcademyEvent::query()
->where('occurred_at', '<', now()->subDays($days)->startOfDay())
->delete();
$this->info(sprintf('Pruned %d Academy analytics event(s).', $deleted));
return self::SUCCESS;
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\AcademyContentMetricDaily;
use App\Services\Academy\AcademyPopularityService;
use Illuminate\Console\Command;
final class AcademyAnalyticsRecalculatePopularityCommand extends Command
{
protected $signature = 'academy:analytics-recalculate-popularity {--days=30}';
protected $description = 'Recalculate Academy daily popularity and conversion scores';
public function __construct(private readonly AcademyPopularityService $popularity)
{
parent::__construct();
}
public function handle(): int
{
$days = max(1, (int) $this->option('days'));
AcademyContentMetricDaily::query()
->where('date', '>=', now()->subDays($days - 1)->toDateString())
->chunkById(500, function ($rows): void {
foreach ($rows as $row) {
$row->forceFill([
'popularity_score' => $this->popularity->calculatePopularityScore($row->toArray()),
'conversion_score' => $this->popularity->calculateConversionScore($row->toArray()),
])->save();
}
});
$this->info(sprintf('Recalculated Academy popularity for the last %d day(s).', $days));
return self::SUCCESS;
}
}
@@ -0,0 +1,258 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\AcademyContentMetricDaily;
use App\Models\AcademyEvent;
use App\Models\AcademyLike;
use App\Models\AcademySave;
use App\Models\AcademySearchLog;
use App\Services\Academy\AcademyPopularityService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use Carbon\CarbonPeriod;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
final class AcademyAnalyticsRollupCommand extends Command
{
protected $signature = 'academy:analytics-rollup {--date=} {--from=} {--to=}';
protected $description = 'Aggregate Academy analytics raw events and interaction records into daily content metrics';
public function __construct(private readonly AcademyPopularityService $popularity)
{
parent::__construct();
}
public function handle(): int
{
[$from, $to] = $this->resolveRange();
foreach (CarbonPeriod::create($from, $to) as $date) {
$this->rollupDate(Carbon::parse($date));
$this->line(sprintf('Rolled up Academy analytics for %s.', Carbon::parse($date)->toDateString()));
}
return self::SUCCESS;
}
private function rollupDate(Carbon $date): void
{
$start = $date->copy()->startOfDay();
$end = $date->copy()->endOfDay();
$metrics = [];
$uniqueVisitors = [];
$engagedDurations = [];
AcademyEvent::query()
->whereBetween('occurred_at', [$start, $end])
->orderBy('id')
->chunkById(1000, function ($events) use (&$metrics, &$uniqueVisitors, &$engagedDurations): void {
foreach ($events as $event) {
if ($event->is_bot || $event->is_admin || $event->is_suspicious) {
continue;
}
$key = $this->metricKey((string) ($event->content_type ?? ''), $event->content_id ? (int) $event->content_id : null);
$this->ensureMetric($metrics, (string) ($event->content_type ?? ''), $event->content_id ? (int) $event->content_id : null, $key);
$visitorKey = $event->user_id ? sprintf('user:%d', (int) $event->user_id) : trim((string) ($event->visitor_id ?? ''));
if ($visitorKey !== '') {
$uniqueVisitors[$key][$visitorKey] = true;
}
$eventType = (string) $event->event_type;
if (in_array($eventType, ['academy_page_view', 'academy_content_view', 'academy_lesson_view', 'academy_course_view', 'academy_prompt_pack_view', 'academy_challenge_view'], true)) {
$metrics[$key]['views']++;
if ($event->is_logged_in) {
$metrics[$key]['user_views']++;
} else {
$metrics[$key]['guest_views']++;
}
if ($event->is_subscriber) {
$metrics[$key]['subscriber_views']++;
}
}
if ($eventType === 'academy_engaged_view') {
$metrics[$key]['engaged_views']++;
$engagedDurations[$key][] = max(0, (int) ($event->metadata['engaged_seconds'] ?? 15));
}
if ($eventType === 'academy_scroll_50') {
$metrics[$key]['scroll_50']++;
}
if ($eventType === 'academy_scroll_75') {
$metrics[$key]['scroll_75']++;
}
if ($eventType === 'academy_scroll_100') {
$metrics[$key]['scroll_100']++;
}
if ($eventType === 'academy_prompt_copy') {
$metrics[$key]['prompt_copies']++;
}
if ($eventType === 'academy_prompt_negative_copy') {
$metrics[$key]['negative_prompt_copies']++;
}
if (in_array($eventType, ['academy_lesson_started', 'academy_course_started', 'academy_challenge_started'], true)) {
$metrics[$key]['starts']++;
}
if (in_array($eventType, ['academy_lesson_completed', 'academy_course_completed', 'academy_challenge_submitted'], true)) {
$metrics[$key]['completions']++;
}
if ($eventType === 'academy_upgrade_click') {
$metrics[$key]['upgrade_clicks']++;
}
if ($eventType === 'academy_premium_preview_view') {
$metrics[$key]['premium_preview_views']++;
}
if ($eventType === 'academy_search_result_click') {
$metrics[$key]['search_clicks']++;
$searchKey = $this->metricKey(AcademyAnalyticsContentType::SEARCH, null);
$this->ensureMetric($metrics, AcademyAnalyticsContentType::SEARCH, null, $searchKey);
$metrics[$searchKey]['search_clicks']++;
}
}
});
foreach (AcademyLike::query()->whereBetween('created_at', [$start, $end])->get() as $like) {
$key = $this->metricKey((string) $like->content_type, (int) $like->content_id);
$this->ensureMetric($metrics, (string) $like->content_type, (int) $like->content_id, $key);
$metrics[$key]['likes']++;
}
foreach (AcademySave::query()->whereBetween('created_at', [$start, $end])->get() as $save) {
$key = $this->metricKey((string) $save->content_type, (int) $save->content_id);
$this->ensureMetric($metrics, (string) $save->content_type, (int) $save->content_id, $key);
$metrics[$key]['saves']++;
}
foreach (AcademySearchLog::query()->whereBetween('created_at', [$start, $end])->get() as $searchLog) {
$key = $this->metricKey(AcademyAnalyticsContentType::SEARCH, null);
$this->ensureMetric($metrics, AcademyAnalyticsContentType::SEARCH, null, $key);
$metrics[$key]['search_impressions']++;
if ((int) $searchLog->results_count === 0) {
$metrics[$key]['bounce_count']++;
}
$visitorKey = $searchLog->user_id ? sprintf('user:%d', (int) $searchLog->user_id) : trim((string) ($searchLog->visitor_id ?? ''));
if ($visitorKey !== '') {
$uniqueVisitors[$key][$visitorKey] = true;
}
}
foreach ($metrics as $key => $metric) {
$metric['unique_visitors'] = isset($uniqueVisitors[$key]) ? count($uniqueVisitors[$key]) : 0;
$metric['avg_engaged_seconds'] = isset($engagedDurations[$key]) && $engagedDurations[$key] !== []
? (int) round(array_sum($engagedDurations[$key]) / count($engagedDurations[$key]))
: null;
$metric['bounce_count'] = max((int) ($metric['bounce_count'] ?? 0), max(0, (int) $metric['views'] - (int) $metric['engaged_views']));
$metric['popularity_score'] = $this->popularity->calculatePopularityScore($metric);
$metric['conversion_score'] = $this->popularity->calculateConversionScore($metric);
AcademyContentMetricDaily::query()->upsert([
array_merge($metric, [
'date' => $date->copy()->startOfDay(),
'created_at' => now(),
'updated_at' => now(),
]),
], ['date', 'content_type', 'content_id'], [
'views',
'unique_visitors',
'guest_views',
'user_views',
'subscriber_views',
'engaged_views',
'scroll_50',
'scroll_75',
'scroll_100',
'likes',
'saves',
'prompt_copies',
'negative_prompt_copies',
'starts',
'completions',
'upgrade_clicks',
'premium_preview_views',
'search_impressions',
'search_clicks',
'bounce_count',
'avg_engaged_seconds',
'popularity_score',
'conversion_score',
'updated_at',
]);
}
}
/**
* @param array<string, array<string, mixed>> $metrics
*/
private function ensureMetric(array &$metrics, string $contentType, ?int $contentId, string $key): void
{
if (isset($metrics[$key])) {
return;
}
$metrics[$key] = [
'content_type' => $contentType,
'content_id' => $contentId,
'views' => 0,
'unique_visitors' => 0,
'guest_views' => 0,
'user_views' => 0,
'subscriber_views' => 0,
'engaged_views' => 0,
'scroll_50' => 0,
'scroll_75' => 0,
'scroll_100' => 0,
'likes' => 0,
'saves' => 0,
'prompt_copies' => 0,
'negative_prompt_copies' => 0,
'starts' => 0,
'completions' => 0,
'upgrade_clicks' => 0,
'premium_preview_views' => 0,
'search_impressions' => 0,
'search_clicks' => 0,
'bounce_count' => 0,
'avg_engaged_seconds' => null,
'popularity_score' => 0,
'conversion_score' => 0,
];
}
private function metricKey(string $contentType, ?int $contentId): string
{
return sprintf('%s:%s', $contentType, $contentId ?? 'none');
}
/**
* @return array{0: Carbon, 1: Carbon}
*/
private function resolveRange(): array
{
$date = $this->option('date');
$from = $this->option('from');
$to = $this->option('to');
if (is_string($date) && trim($date) !== '') {
$resolved = Carbon::parse($date)->startOfDay();
return [$resolved, $resolved->copy()];
}
$resolvedFrom = is_string($from) && trim($from) !== ''
? Carbon::parse($from)->startOfDay()
: now()->subDay()->startOfDay();
$resolvedTo = is_string($to) && trim($to) !== ''
? Carbon::parse($to)->startOfDay()
: $resolvedFrom->copy();
return [$resolvedFrom, $resolvedTo];
}
}
@@ -0,0 +1,305 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Academy\AcademyBillingPlanService;
use Illuminate\Console\Command;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Schema;
final class AcademyBillingHealthCommand extends Command
{
protected $signature = 'academy:billing-health
{--json : Output machine-readable JSON}
{--strict : Exit non-zero when blocking issues are found}';
protected $description = 'Inspect Academy Stripe billing deployment readiness, config completeness, and Cashier route wiring';
public function __construct(
private readonly AcademyBillingPlanService $plans,
) {
parent::__construct();
}
public function handle(): int
{
$report = $this->buildReport();
if ((bool) $this->option('json')) {
$this->line((string) json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
return $this->exitCodeFor($report);
}
$this->line('Academy Billing Health Check');
$this->line('============================');
$this->newLine();
$this->line(sprintf('Environment: %s', $report['environment']));
$this->line(sprintf('App URL: %s', $report['app_url'] ?? 'unset'));
$this->line(sprintf('Academy enabled: %s', $report['academy_enabled'] ? 'yes' : 'no'));
$this->line(sprintf('Academy billing enabled: %s', $report['academy_billing_enabled'] ? 'yes' : 'no'));
$this->line(sprintf('Subscription name: %s', $report['subscription_name']));
$this->line(sprintf('Cashier path: %s', $report['cashier_path']));
$this->line(sprintf('Cashier webhook route: %s', $report['routes']['cashier_webhook']['present'] ? ($report['routes']['cashier_webhook']['url'] ?? 'present') : 'missing'));
$this->line(sprintf('Academy pricing route: %s', $report['routes']['academy_pricing']['present'] ? ($report['routes']['academy_pricing']['url'] ?? 'present') : 'missing'));
$this->line(sprintf('Academy billing account route: %s', $report['routes']['academy_billing_account']['present'] ? ($report['routes']['academy_billing_account']['url'] ?? 'present') : 'missing'));
$this->line(sprintf('Stripe key configured: %s', $report['stripe']['publishable_key_configured'] ? 'yes' : 'no'));
$this->line(sprintf('Stripe secret configured: %s', $report['stripe']['secret_key_configured'] ? 'yes' : 'no'));
$this->line(sprintf('Webhook secret configured: %s', $report['stripe']['webhook_secret_configured'] ? 'yes' : 'no'));
$this->line(sprintf('Cashier currency: %s', $report['stripe']['currency'] ?: 'unset'));
$this->line(sprintf('Cashier locale: %s', $report['stripe']['currency_locale'] ?: 'unset'));
$this->line(sprintf('Configured plans: %d', $report['configured_plan_count']));
$this->line(sprintf('Plans missing Stripe price IDs: %d', count($report['missing_plan_keys'])));
$this->line(sprintf('Billing tables present: %s', $report['tables']['subscriptions'] && $report['tables']['subscription_items'] && $report['tables']['academy_billing_events'] ? 'yes' : 'no'));
$this->line(sprintf('User billing columns present: %s', $report['users_billing_columns_present'] ? 'yes' : 'no'));
$this->newLine();
foreach ($report['blockers'] as $blocker) {
$this->error(sprintf('BLOCKER: %s', $blocker));
}
foreach ($report['warnings'] as $warning) {
$this->warn(sprintf('WARNING: %s', $warning));
}
if ($report['plan_summaries'] !== []) {
$this->newLine();
$this->line('Plans');
$this->line('-----');
foreach ($report['plan_summaries'] as $plan) {
$this->line(sprintf(
'%s: tier=%s interval=%s price_id=%s',
$plan['key'],
$plan['tier'],
$plan['interval'],
$plan['configured'] ? 'configured' : 'missing'
));
}
}
$this->newLine();
$this->info(sprintf('Status: %s', $report['status']));
return $this->exitCodeFor($report);
}
/**
* @return array<string, mixed>
*/
private function buildReport(): array
{
$stripeKey = $this->configuredString(config('cashier.key'));
$stripeSecret = $this->firstConfiguredString(config('cashier.secret'), env('STRIPE_SECRET'));
$webhookSecret = $this->firstConfiguredString(config('cashier.webhook.secret'), env('STRIPE_WEBHOOK_SECRET'));
$currency = trim((string) config('cashier.currency', env('CASHIER_CURRENCY', '')));
$currencyLocale = trim((string) config('cashier.currency_locale', env('CASHIER_CURRENCY_LOCALE', '')));
$academyEnabled = (bool) config('academy.enabled', true);
$billingEnabled = $this->plans->enabled();
$missingPlanKeys = $this->plans->missingPriceIds();
$routes = [
'cashier_webhook' => $this->routeStatus('cashier.webhook'),
'academy_pricing' => $this->routeStatus('academy.pricing'),
'academy_billing_account' => $this->routeStatus('academy.billing.account'),
'academy_billing_portal' => $this->routeStatus('academy.billing.portal'),
'admin_academy_billing' => $this->routeStatus('admin.academy.billing'),
];
$tables = [
'users' => Schema::hasTable('users'),
'subscriptions' => Schema::hasTable('subscriptions'),
'subscription_items' => Schema::hasTable('subscription_items'),
'academy_billing_events' => Schema::hasTable('academy_billing_events'),
];
$userBillingColumns = [
'stripe_id' => $tables['users'] && Schema::hasColumn('users', 'stripe_id'),
'pm_type' => $tables['users'] && Schema::hasColumn('users', 'pm_type'),
'pm_last_four' => $tables['users'] && Schema::hasColumn('users', 'pm_last_four'),
'trial_ends_at' => $tables['users'] && Schema::hasColumn('users', 'trial_ends_at'),
];
$planSummaries = collect(array_keys($this->plans->plans()))
->map(function (string $key): array {
$plan = $this->plans->plan($key);
return [
'key' => $key,
'tier' => (string) ($plan['tier'] ?? 'free'),
'interval' => (string) ($plan['interval'] ?? 'monthly'),
'configured' => (bool) ($plan['configured'] ?? false),
];
})
->values()
->all();
$blockers = [];
$warnings = [];
if (! $academyEnabled) {
$warnings[] = 'SKINBASE_ACADEMY_ENABLED is disabled, so billing cannot be reached by users.';
}
if (! $billingEnabled) {
$warnings[] = 'ACADEMY_BILLING_ENABLED is disabled. Checkout routes will stay unavailable until rollout is enabled.';
}
if (! $this->isConfiguredSecret($stripeKey, 'pk_')) {
$blockers[] = 'STRIPE_KEY is missing or still using a placeholder value.';
}
if (! $this->isConfiguredSecret($stripeSecret, 'sk_')) {
$blockers[] = 'STRIPE_SECRET is missing or still using a placeholder value.';
}
if (! $this->isConfiguredSecret($webhookSecret, 'whsec_')) {
$blockers[] = 'STRIPE_WEBHOOK_SECRET is missing or still using a placeholder value.';
}
if ($currency === '') {
$blockers[] = 'CASHIER_CURRENCY is not configured.';
}
if ($currencyLocale === '') {
$warnings[] = 'CASHIER_CURRENCY_LOCALE is not configured.';
}
if ($missingPlanKeys !== []) {
$blockers[] = 'Stripe price IDs are missing for: '.implode(', ', $missingPlanKeys).'.';
}
if (! $routes['cashier_webhook']['present']) {
$blockers[] = 'Cashier webhook route is missing; Stripe cannot sync subscriptions.';
}
if (! $routes['academy_pricing']['present']) {
$blockers[] = 'Academy pricing route is missing.';
}
if (! $routes['academy_billing_account']['present']) {
$blockers[] = 'Academy billing account route is missing.';
}
foreach ($tables as $table => $present) {
if (! $present) {
$blockers[] = sprintf('Required billing table %s is missing.', $table);
}
}
foreach ($userBillingColumns as $column => $present) {
if (! $present) {
$blockers[] = sprintf('Required users.%s billing column is missing.', $column);
}
}
if (! $routes['admin_academy_billing']['present']) {
$warnings[] = 'Moderation Academy billing overview route is missing.';
}
if (Arr::where($planSummaries, fn (array $plan): bool => $plan['configured'] === false) === []) {
$warnings[] = 'All configured Academy plans have Stripe price IDs. Verify they are live-mode IDs before production rollout.';
}
$invalidPlanKeys = collect(array_keys($this->plans->plans()))
->filter(function (string $key): bool {
$plan = $this->plans->plan($key);
return $plan !== null && ($plan['configured'] ?? false) && ! ($plan['price_id_valid'] ?? false);
})
->values()
->all();
if ($invalidPlanKeys !== []) {
$blockers[] = 'Stripe price IDs are malformed for: '.implode(', ', $invalidPlanKeys).'. Use real price object IDs that start with price_.';
}
$status = $blockers !== []
? 'BLOCKED'
: ($warnings !== [] ? 'WARNING' : 'OK');
return [
'environment' => app()->environment(),
'app_url' => config('app.url'),
'academy_enabled' => $academyEnabled,
'academy_billing_enabled' => $billingEnabled,
'subscription_name' => $this->plans->subscriptionName(),
'cashier_path' => (string) config('cashier.path', 'stripe'),
'stripe' => [
'publishable_key_configured' => $this->isConfiguredSecret($stripeKey, 'pk_'),
'secret_key_configured' => $this->isConfiguredSecret($stripeSecret, 'sk_'),
'webhook_secret_configured' => $this->isConfiguredSecret($webhookSecret, 'whsec_'),
'currency' => $currency,
'currency_locale' => $currencyLocale,
],
'configured_plan_count' => count($planSummaries),
'missing_plan_keys' => $missingPlanKeys,
'invalid_plan_keys' => $invalidPlanKeys,
'plan_summaries' => $planSummaries,
'routes' => $routes,
'tables' => $tables,
'user_billing_columns' => $userBillingColumns,
'users_billing_columns_present' => ! in_array(false, $userBillingColumns, true),
'blockers' => array_values(array_unique($blockers)),
'warnings' => array_values(array_unique($warnings)),
'status' => $status,
];
}
/**
* @return array{present: bool, url: string|null}
*/
private function routeStatus(string $name): array
{
if (! Route::has($name)) {
return [
'present' => false,
'url' => null,
];
}
return [
'present' => true,
'url' => route($name),
];
}
private function isConfiguredSecret(string $value, string $expectedPrefix): bool
{
$value = trim($value);
if ($value === '' || ! str_starts_with($value, $expectedPrefix)) {
return false;
}
return ! str_contains(strtolower($value), 'xxx');
}
/**
* @param array<string, mixed> $report
*/
private function exitCodeFor(array $report): int
{
if ((bool) $this->option('strict') && $report['status'] === 'BLOCKED') {
return self::FAILURE;
}
return self::SUCCESS;
}
private function firstConfiguredString(mixed ...$values): string
{
foreach ($values as $value) {
$value = $this->configuredString($value);
if ($value !== '') {
return $value;
}
}
return '';
}
private function configuredString(mixed $value): string
{
return is_string($value) ? trim($value) : '';
}
}
@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\AcademyCourse;
use App\Models\AcademyCourseLesson;
use App\Models\AcademyCourseSection;
use App\Models\AcademyLesson;
use App\Services\Academy\AcademyCacheService;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
final class AcademyCoursesSyncFoundationsCommand extends Command
{
protected $signature = 'academy:courses:sync-foundations';
protected $description = 'Create or update the default AI-Assisted Digital Art Foundations Academy course.';
public function __construct(private readonly AcademyCacheService $cache)
{
parent::__construct();
}
public function handle(): int
{
$course = AcademyCourse::query()->updateOrCreate(
['slug' => 'ai-assisted-digital-art-foundations'],
[
'title' => 'AI-Assisted Digital Art Foundations',
'subtitle' => 'A guided path through prompting, publishing, and better Skinbase-ready workflows.',
'excerpt' => 'Learn the foundations of AI-assisted digital art, from better prompts and ethical rules to preparing, tagging, and publishing artwork on Skinbase.',
'description' => 'A starter course for Skinbase creators who want a structured path from core AI-art concepts to cleaner publishing-ready results.',
'access_level' => 'free',
'difficulty' => 'beginner',
'status' => 'published',
'is_featured' => true,
'order_num' => 1,
'published_at' => now(),
],
);
$sectionOrder = [
'Introduction',
'Prompting Basics',
'Publishing on Skinbase',
'Workflow and Quality',
];
$sections = collect($sectionOrder)->mapWithKeys(function (string $title, int $index) use ($course): array {
$section = AcademyCourseSection::query()->updateOrCreate(
['course_id' => $course->id, 'slug' => Str::slug($title)],
[
'title' => $title,
'order_num' => $index,
'is_visible' => true,
],
);
return [$title => $section];
});
$lessonMap = [
'Introduction' => [
'what-is-ai-assisted-digital-art',
'ai-ethics-and-skinbase-upload-rules',
'ai-generated-vs-ai-assisted-artwork',
],
'Prompting Basics' => [
'prompting-basics-for-skinbase-creators',
'how-to-write-better-wallpaper-prompts',
'understanding-style-mood-lighting-and-composition',
],
'Publishing on Skinbase' => [
'how-to-prepare-ai-artwork-for-upload',
'how-to-choose-better-tags-and-categories',
],
'Workflow and Quality' => [
'how-to-avoid-common-ai-image-problems',
'from-idea-to-artwork-a-simple-skinbase-workflow',
],
];
$orderNum = 0;
foreach ($lessonMap as $sectionTitle => $slugs) {
$section = $sections->get($sectionTitle);
foreach ($slugs as $slug) {
$lesson = AcademyLesson::query()->where('slug', $slug)->first();
if (! $lesson instanceof AcademyLesson) {
$this->warn(sprintf('Skipped missing lesson [%s].', $slug));
continue;
}
AcademyCourseLesson::query()->updateOrCreate(
[
'course_id' => $course->id,
'lesson_id' => $lesson->id,
],
[
'section_id' => $section?->id,
'order_num' => $orderNum,
'is_required' => true,
],
);
$orderNum++;
}
}
$course->forceFill([
'lessons_count_cache' => AcademyCourseLesson::query()->where('course_id', $course->id)->count(),
])->save();
$this->cache->clearAll();
$this->info('AI-Assisted Digital Art Foundations course synced.');
return self::SUCCESS;
}
}
+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} {--id= : Audit only this artwork ID}
{--limit= : Stop after processing this many artworks} {--limit= : Stop after processing this many artworks}
{--chunk=500 : Number of artworks to scan per batch} {--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}'; {--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.'; 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; $limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
$chunkSize = max(1, min((int) $this->option('chunk'), 2000)); $chunkSize = max(1, min((int) $this->option('chunk'), 2000));
$restoreMissing = (bool) $this->option('restore-missing'); $restoreMissing = (bool) $this->option('restore-missing');
$markStatus = (bool) $this->option('mark-status');
$statusFilter = $this->option('status');
$this->info(sprintf( $this->info(sprintf(
'Starting download file audit. order=desc include_trashed=yes chunk=%d limit=%s restore_missing=%s', '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; $unresolved = 0;
$restored = 0; $restored = 0;
$restoreFailed = 0; $restoreFailed = 0;
$classifications = [];
$lastSeenId = null; $lastSeenId = null;
do { do {
$artworks = $this->nextChunk($artworkId, $chunkSize, $lastSeenId); $artworks = $this->nextChunk($artworkId, $chunkSize, $lastSeenId, is_string($statusFilter) ? $statusFilter : null);
if ($artworks->isEmpty()) { if ($artworks->isEmpty()) {
break; break;
} }
@@ -54,41 +60,59 @@ final class AuditArtworkDownloadFilesCommand extends Command
break 2; break 2;
} }
$localPath = $locator->resolveLocalPath($artwork); $resolution = $locator->resolve($artwork);
$missingReason = null; $classification = match ($resolution['status']) {
'available' => strtoupper((string) $resolution['source']).'_OK',
if ($localPath === '') { 'pending' => 'PENDING',
$missingReason = 'unresolved_local_path'; '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++; $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) { if ($missingReason !== null) {
$objectPath = $locator->resolveObjectPath($artwork); $objectPath = (string) $resolution['object_key'];
$objectUrl = $locator->resolveObjectUrl($artwork); $objectUrl = $locator->resolveObjectUrl($artwork);
$missing++; $missing++;
$this->warn(sprintf('Artwork %d %s', (int) $artwork->id, $missingReason)); $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, 'id' => (int) $artwork->id,
'slug' => (string) ($artwork->slug ?? ''), '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 !== '') { if ($objectPath !== '') {
$this->line(' object_path: ' . $objectPath); $this->line(' object_path: '.$objectPath);
} }
if ($objectUrl !== null && $objectUrl !== '') { if ($objectUrl !== null && $objectUrl !== '') {
$this->line(' object_url: ' . $objectUrl); $this->line(' object_url: '.$objectUrl);
} }
if ($localPath !== '') { 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); $restoreResult = $this->restoreLocalFile($storage, $objectPath, $localPath);
if ($restoreResult === 'restored') { if ($restoreResult === 'restored') {
@@ -120,6 +144,9 @@ final class AuditArtworkDownloadFilesCommand extends Command
$restored, $restored,
$restoreFailed, $restoreFailed,
)); ));
foreach ($classifications as $classification => $count) {
$this->line(sprintf('classification=%s count=%d', $classification, $count));
}
return self::SUCCESS; return self::SUCCESS;
} }
@@ -127,11 +154,15 @@ final class AuditArtworkDownloadFilesCommand extends Command
/** /**
* @return Collection<int, Artwork> * @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() $query = Artwork::query()
->withTrashed() ->withTrashed()
->select(['id', 'slug', 'file_path', 'hash', 'file_ext']) ->select(['id', 'slug', 'file_name', 'file_path', 'hash', 'file_ext'])
->orderByDesc('id'); ->orderByDesc('id');
if ($artworkId !== null) { if ($artworkId !== null) {
@@ -140,9 +171,30 @@ final class AuditArtworkDownloadFilesCommand extends Command
$query->where('id', '<', $lastSeenId); $query->where('id', '<', $lastSeenId);
} }
if ($status !== null && $status !== '') {
$query->where('download_status', $status);
}
return $query->limit($chunkSize)->get(); 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 private function restoreLocalFile(UploadStorageService $storage, string $objectPath, string $localPath): string
{ {
if ($objectPath === '') { 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); $t = microtime(true);
$this->line(' Building sitemap index…'); $this->line(' Building sitemap index…');
$index = $build->buildIndex(force: true, persist: false, families: $families); $index = $build->buildIndex(force: true, persist: false, families: $families);
$disk->put('sitemap.xml', $index['content']); $disk->put('sitemaps/sitemap.xml', $index['content']);
$written++; $written++;
$this->line(sprintf( $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'], $index['url_count'],
microtime(true) - $t, 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 ─────────────────────────────────────────────────────── // ── Summary ───────────────────────────────────────────────────────
$this->newLine(); $this->newLine();
$this->info(sprintf( $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))); return array_values(array_filter($enabled, fn (string $family): bool => in_array($family, $only, true)));
} }
} }
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\WorldWebStory;
use App\Services\WebStories\WorldWebStoryAssetService;
use Illuminate\Console\Command;
final class BuildWorldWebStoryAssetsCommand extends Command
{
protected $signature = 'skinbase:webstories:build-assets
{story? : Story ID or slug}
{--published : Limit batch mode to published stories}
{--visible : Limit batch mode to stories currently visible on the public site}
{--limit=100 : Maximum stories to process in batch mode}
{--force : Rebuild already populated asset paths}
{--dry-run : Report changes without saving them}';
protected $description = 'Backfill poster, logo, and page background assets for World Web Stories';
public function handle(WorldWebStoryAssetService $assets): int
{
$storyKey = $this->argument('story');
$force = (bool) $this->option('force');
$dryRun = (bool) $this->option('dry-run');
if ($storyKey !== null && trim((string) $storyKey) !== '') {
$story = $this->resolveStory((string) $storyKey);
if (! $story instanceof WorldWebStory) {
$this->error(sprintf('Web story [%s] was not found.', (string) $storyKey));
return self::FAILURE;
}
return $this->buildOne($assets, $story, $force, $dryRun);
}
return $this->buildBatch($assets, $force, $dryRun, max(1, (int) $this->option('limit')));
}
private function buildOne(WorldWebStoryAssetService $assets, WorldWebStory $story, bool $force, bool $dryRun): int
{
$result = $assets->buildAssets($story, force: $force, dryRun: $dryRun);
$this->line(sprintf('Story [%d] %s', (int) $story->id, (string) $story->slug));
$this->line($result['updated'] ? 'Assets updated.' : 'No asset changes needed.');
foreach ((array) $result['story'] as $field => $value) {
$this->line(sprintf(' - story.%s = %s', (string) $field, (string) $value));
}
foreach ((array) $result['pages'] as $pageId => $changes) {
foreach ((array) $changes as $field => $value) {
$this->line(sprintf(' - page.%d.%s = %s', (int) $pageId, (string) $field, (string) $value));
}
}
return self::SUCCESS;
}
private function buildBatch(WorldWebStoryAssetService $assets, bool $force, bool $dryRun, int $limit): int
{
$processed = 0;
$updated = 0;
$this->storyQuery()
->limit($limit)
->get()
->each(function (WorldWebStory $story) use ($assets, $force, $dryRun, &$processed, &$updated): void {
$processed++;
$result = $assets->buildAssets($story, force: $force, dryRun: $dryRun);
if ($result['updated']) {
$updated++;
}
$this->line(sprintf('[%d] %s -> %s', (int) $story->id, (string) $story->slug, $result['updated'] ? 'updated' : 'unchanged'));
});
$this->info(sprintf('Done. processed=%d updated=%d', $processed, $updated));
return self::SUCCESS;
}
private function storyQuery()
{
return WorldWebStory::query()
->when((bool) $this->option('published'), fn ($query) => $query->published())
->when((bool) $this->option('visible'), fn ($query) => $query->visible())
->orderByDesc('published_at')
->orderByDesc('id');
}
private function resolveStory(string $value): ?WorldWebStory
{
return WorldWebStory::query()
->when(is_numeric($value), fn ($query) => $query->where('id', (int) $value), fn ($query) => $query->where('slug', $value))
->first();
}
}
@@ -18,6 +18,12 @@ class DispatchCollectionMaintenanceCommand extends Command
public function handle(CollectionBackgroundJobService $jobs): int 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'); $runHealth = (bool) $this->option('health');
$runRecommendations = (bool) $this->option('recommendations'); $runRecommendations = (bool) $this->option('recommendations');
$runDuplicates = (bool) $this->option('duplicates'); $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';
}
}
@@ -0,0 +1,390 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\AcademyPromptTemplate;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
use Throwable;
final class GenerateAcademyPromptThumbnailsCommand extends Command
{
private const PROMPT_PREVIEW_PREFIX = 'academy-prompts/previews';
/**
* @var array<string, int>
*/
private const VARIANT_WIDTHS = [
'thumb' => 480,
'md' => 960,
];
private const PREVIEW_WEBP_QUALITY = 84;
private const LESSON_MEDIA_WEBP_QUALITY = 85;
protected $signature = 'academy:prompts:generate-missing-thumbnails
{--id=* : Restrict to one or more prompt IDs}
{--slug=* : Restrict to one or more prompt slugs}
{--limit= : Stop after processing this many prompts}
{--force : Regenerate variants even when they already exist}
{--dry-run : Report planned thumbnail work without writing files or saving prompt JSON}';
protected $description = 'Generate missing prompt preview and comparison thumbnails for existing Academy prompts';
public function handle(): int
{
if (! function_exists('imagecreatefromstring') || ! function_exists('imagewebp')) {
$this->error('GD WebP support is required to generate prompt thumbnails.');
return self::FAILURE;
}
$ids = collect((array) $this->option('id'))
->map(static fn (mixed $id): int => (int) $id)
->filter(static fn (int $id): bool => $id > 0)
->values()
->all();
$slugs = collect((array) $this->option('slug'))
->map(static fn (mixed $slug): string => trim((string) $slug))
->filter(static fn (string $slug): bool => $slug !== '')
->values()
->all();
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
$force = (bool) $this->option('force');
$dryRun = (bool) $this->option('dry-run');
$query = AcademyPromptTemplate::query()
->select(['id', 'slug', 'title', 'preview_image', 'tool_notes'])
->orderBy('id');
if ($ids !== []) {
$query->whereIn('id', $ids);
}
if ($slugs !== []) {
$query->whereIn('slug', $slugs);
}
$processed = 0;
$changed = 0;
$generatedVariants = 0;
$plannedVariants = 0;
$skipped = 0;
$failed = 0;
$query->chunkById(100, function ($prompts) use ($limit, $force, $dryRun, &$processed, &$changed, &$generatedVariants, &$plannedVariants, &$skipped, &$failed) {
foreach ($prompts as $prompt) {
if ($limit !== null && $processed >= $limit) {
return false;
}
try {
$result = $this->backfillPrompt($prompt, $force, $dryRun);
$generatedVariants += (int) ($result['generated_variants'] ?? 0);
$plannedVariants += (int) ($result['planned_variants'] ?? 0);
if (($result['changed'] ?? false) === true) {
$changed++;
} else {
$skipped++;
}
} catch (Throwable $e) {
$failed++;
$this->warn(sprintf('Prompt %d (%s) failed: %s', (int) $prompt->id, (string) $prompt->slug, $e->getMessage()));
}
$processed++;
}
return true;
});
$this->info(sprintf(
'Prompt thumbnail backfill complete. processed=%d changed=%d generated_variants=%d planned_variants=%d skipped=%d failed=%d',
$processed,
$changed,
$generatedVariants,
$plannedVariants,
$skipped,
$failed,
));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* @return array{changed:bool,generated_variants:int,planned_variants:int}
*/
private function backfillPrompt(AcademyPromptTemplate $prompt, bool $force, bool $dryRun): array
{
$generatedVariants = 0;
$plannedVariants = 0;
$changed = false;
$previewResult = $this->ensureManagedImageVariants((string) ($prompt->preview_image ?? ''), $force, $dryRun);
$generatedVariants += $previewResult['generated_variants'];
$plannedVariants += $previewResult['planned_variants'];
$changed = $changed || $previewResult['changed'];
$notes = is_array($prompt->tool_notes) ? $prompt->tool_notes : [];
$nextNotes = [];
foreach ($notes as $note) {
if (! is_array($note)) {
$nextNotes[] = $note;
continue;
}
$noteResult = $this->ensurePromptComparisonNoteVariants($note, $force, $dryRun);
$generatedVariants += $noteResult['generated_variants'];
$plannedVariants += $noteResult['planned_variants'];
$changed = $changed || $noteResult['changed'];
$nextNotes[] = $noteResult['note'];
}
if ($changed && ! $dryRun && $nextNotes !== $notes) {
$prompt->forceFill([
'tool_notes' => $nextNotes,
])->save();
}
return [
'changed' => $changed,
'generated_variants' => $generatedVariants,
'planned_variants' => $plannedVariants,
];
}
/**
* @param array<string, mixed> $note
* @return array{note:array<string, mixed>,changed:bool,generated_variants:int,planned_variants:int}
*/
private function ensurePromptComparisonNoteVariants(array $note, bool $force, bool $dryRun): array
{
$imagePath = trim((string) ($note['image_path'] ?? ''));
if (! $this->isManagedLessonMediaPath($imagePath)) {
return [
'note' => $note,
'changed' => false,
'generated_variants' => 0,
'planned_variants' => 0,
];
}
$variants = $this->ensureManagedImageVariants($imagePath, $force, $dryRun);
$thumbPath = $variants['thumb_path'] ?? '';
if ($thumbPath === '') {
$thumbPath = $imagePath;
}
$nextNote = $note;
$currentThumbPath = trim((string) ($note['thumb_path'] ?? ''));
if ($currentThumbPath !== $thumbPath) {
$nextNote['thumb_path'] = $thumbPath;
$variants['changed'] = true;
}
return [
'note' => $nextNote,
'changed' => (bool) $variants['changed'],
'generated_variants' => (int) $variants['generated_variants'],
'planned_variants' => (int) $variants['planned_variants'],
];
}
/**
* @return array{thumb_path:string,changed:bool,generated_variants:int,planned_variants:int}
*/
private function ensureManagedImageVariants(string $path, bool $force, bool $dryRun): array
{
$path = trim($path);
if (! $this->isManagedPromptPreviewPath($path) && ! $this->isManagedLessonMediaPath($path)) {
return [
'thumb_path' => '',
'changed' => false,
'generated_variants' => 0,
'planned_variants' => 0,
];
}
$source = $this->openManagedImage($path);
try {
$generatedVariants = 0;
$plannedVariants = 0;
$changed = false;
$thumbPath = $path;
foreach (self::VARIANT_WIDTHS as $variant => $targetWidth) {
$status = $this->ensureVariantForWidth(
$source['image'],
$source['width'],
$source['height'],
$path,
$variant,
$targetWidth,
$force,
$dryRun,
);
if ($variant === 'thumb' && $source['width'] > $targetWidth) {
$thumbPath = $this->variantPath($path, 'thumb');
}
if ($status === 'generated') {
$generatedVariants++;
$changed = true;
}
if ($status === 'planned') {
$plannedVariants++;
$changed = true;
}
}
return [
'thumb_path' => $thumbPath,
'changed' => $changed,
'generated_variants' => $generatedVariants,
'planned_variants' => $plannedVariants,
];
} finally {
imagedestroy($source['image']);
}
}
/**
* @return array{image:\GdImage,width:int,height:int}
*/
private function openManagedImage(string $path): array
{
$disk = Storage::disk($this->storageDisk());
if (! $disk->exists($path)) {
throw new RuntimeException(sprintf('Source image is missing: %s', $path));
}
$binary = $disk->get($path);
if (! is_string($binary) || $binary === '') {
throw new RuntimeException(sprintf('Source image could not be read: %s', $path));
}
$image = @imagecreatefromstring($binary);
if (! $image instanceof \GdImage) {
throw new RuntimeException(sprintf('Source image is not a supported raster image: %s', $path));
}
if (! imageistruecolor($image)) {
imagepalettetotruecolor($image);
}
imagealphablending($image, true);
imagesavealpha($image, true);
return [
'image' => $image,
'width' => imagesx($image),
'height' => imagesy($image),
];
}
private function ensureVariantForWidth(\GdImage $source, int $sourceWidth, int $sourceHeight, string $sourcePath, string $variant, int $targetWidth, bool $force, bool $dryRun): string
{
if ($sourceWidth <= $targetWidth || $sourceWidth < 1 || $sourceHeight < 1) {
return 'skipped';
}
$variantPath = $this->variantPath($sourcePath, $variant);
$disk = Storage::disk($this->storageDisk());
if (! $force && $disk->exists($variantPath)) {
return 'skipped';
}
if ($dryRun) {
return 'planned';
}
$targetHeight = max(1, (int) round(($sourceHeight / $sourceWidth) * $targetWidth));
$canvas = imagecreatetruecolor($targetWidth, $targetHeight);
if (! $canvas instanceof \GdImage) {
throw new RuntimeException(sprintf('Could not allocate variant canvas for %s', $sourcePath));
}
imagealphablending($canvas, false);
imagesavealpha($canvas, true);
$transparent = imagecolorallocatealpha($canvas, 0, 0, 0, 127);
imagefilledrectangle($canvas, 0, 0, $targetWidth, $targetHeight, $transparent);
imagecopyresampled($canvas, $source, 0, 0, 0, 0, $targetWidth, $targetHeight, $sourceWidth, $sourceHeight);
try {
ob_start();
$converted = imagewebp($canvas, null, $this->qualityForPath($sourcePath));
$webpBinary = ob_get_clean();
if (! $converted || ! is_string($webpBinary) || $webpBinary === '') {
throw new RuntimeException(sprintf('Could not encode %s variant for %s', $variant, $sourcePath));
}
$disk->put($variantPath, $webpBinary, ['visibility' => 'public']);
} finally {
imagedestroy($canvas);
}
return 'generated';
}
private function variantPath(string $path, string $variant): string
{
$directory = pathinfo($path, PATHINFO_DIRNAME);
$filename = pathinfo($path, PATHINFO_FILENAME);
$baseFilename = preg_replace('/-(thumb|md)$/', '', $filename) ?? $filename;
return sprintf('%s/%s-%s.webp', $directory, $baseFilename, $variant);
}
private function isManagedPromptPreviewPath(string $path): bool
{
return $this->isLocalPath($path) && str_starts_with($path, self::PROMPT_PREVIEW_PREFIX . '/');
}
private function isManagedLessonMediaPath(string $path): bool
{
return $this->isLocalPath($path)
&& (str_starts_with($path, 'academy/lessons/body/') || str_starts_with($path, 'academy/lessons/covers/'));
}
private function isLocalPath(string $path): bool
{
return $path !== ''
&& ! str_starts_with($path, 'http://')
&& ! str_starts_with($path, 'https://')
&& ! str_starts_with($path, '/');
}
private function storageDisk(): string
{
return (string) config('uploads.object_storage.disk', 's3');
}
private function qualityForPath(string $path): int
{
return $this->isManagedPromptPreviewPath($path)
? self::PREVIEW_WEBP_QUALITY
: self::LESSON_MEDIA_WEBP_QUALITY;
}
}
@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Jobs\GenerateFeaturedArtworkThumbnailsJob;
use App\Models\Artwork;
use App\Services\Featured\FeaturedArtworkSelector;
use App\Services\Images\FeaturedArtworkThumbnailGenerator;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\LazyCollection;
final class GenerateFeaturedArtworkThumbnailsCommand extends Command
{
protected $signature = 'skinbase:featured-thumbnails:generate
{--artwork=* : Restrict generation to one or more artwork IDs}
{--only-featured : Restrict generation to currently selected featured artworks}
{--missing-only : Only generate artworks missing at least one featured variant}
{--all : Process all artworks with a hash and source extension}
{--limit=0 : Cap the number of artworks processed}
{--queue : Dispatch background jobs instead of generating inline}
{--force : Regenerate all featured variants even when they already exist}
{--dry-run : Report planned generation without writing files}';
protected $description = 'Generate dedicated featured artwork CDN thumbnails for the homepage hero';
public function __construct(
private readonly FeaturedArtworkSelector $selector,
private readonly FeaturedArtworkThumbnailGenerator $generator,
) {
parent::__construct();
}
public function handle(): int
{
$artworkIds = collect((array) $this->option('artwork'))
->map(static fn (mixed $id): int => (int) $id)
->filter(static fn (int $id): bool => $id > 0)
->values()
->all();
$force = (bool) $this->option('force');
$dryRun = (bool) $this->option('dry-run');
$queue = (bool) $this->option('queue');
$limit = max(0, (int) $this->option('limit'));
$all = (bool) $this->option('all');
$explicitOnlyFeatured = (bool) $this->option('only-featured');
$missingOnly = $force ? false : ((bool) $this->option('missing-only') || ($artworkIds === [] && ! $all));
if ($all && $explicitOnlyFeatured) {
$this->error('Use either --all or --only-featured, not both.');
return self::INVALID;
}
if ($queue && $dryRun) {
$this->error('Use either --queue or --dry-run, not both.');
return self::INVALID;
}
$onlyFeatured = $artworkIds === [] && ! $all;
if ($explicitOnlyFeatured) {
$onlyFeatured = true;
}
$processed = 0;
$queued = 0;
$generatedVariants = 0;
$skipped = 0;
$failed = 0;
foreach ($this->candidateArtworks($artworkIds, $onlyFeatured) as $artwork) {
if ($limit > 0 && $processed >= $limit) {
break;
}
$processed++;
$plan = $this->generator->plan($artwork, $force);
$targetVariants = (array) ($plan['target_variants'] ?? []);
if ($missingOnly && ! $force && $targetVariants === []) {
$skipped++;
continue;
}
if ($dryRun) {
$this->line(sprintf(
'[dry-run] artwork=%d variants=%s',
(int) $artwork->id,
$targetVariants === [] ? 'none' : implode(',', $targetVariants),
));
if ($targetVariants === []) {
$skipped++;
} else {
$generatedVariants += count($targetVariants);
}
continue;
}
if ($queue) {
GenerateFeaturedArtworkThumbnailsJob::dispatch((int) $artwork->id, $force);
$queued++;
continue;
}
try {
$result = $this->generator->generate($artwork, $force);
$generatedVariants += (int) ($result['generated'] ?? 0);
$skipped += count((array) ($result['target_variants'] ?? [])) === 0 ? 1 : 0;
if (($result['failed'] ?? []) !== []) {
$failed++;
$this->warn(sprintf(
'Artwork %d failed for variants: %s',
(int) $artwork->id,
implode(', ', array_keys((array) $result['failed'])),
));
}
} catch (\Throwable $exception) {
$failed++;
$this->warn(sprintf('Artwork %d failed: %s', (int) $artwork->id, $exception->getMessage()));
}
}
$mode = $dryRun ? 'dry-run' : ($queue ? 'queued' : 'generated');
$this->info(sprintf(
'Featured artwork thumbnail %s complete: processed=%d queued=%d generated_variants=%d skipped=%d failed=%d',
$mode,
$processed,
$queued,
$generatedVariants,
$skipped,
$failed,
));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* @param list<int> $artworkIds
* @return LazyCollection<int, Artwork>
*/
private function candidateArtworks(array $artworkIds, bool $onlyFeatured): LazyCollection
{
if ($artworkIds !== []) {
return Artwork::query()
->withTrashed()
->whereIn('id', $artworkIds)
->whereNotNull('hash')
->where('hash', '!=', '')
->whereNotNull('file_ext')
->where('file_ext', '!=', '')
->orderByDesc('id')
->cursor();
}
$query = $onlyFeatured
? $this->selector->querySelectedArtworks()
: Artwork::query()
->select('artworks.*')
->withTrashed()
->whereNotNull('hash')
->where('hash', '!=', '')
->whereNotNull('file_ext')
->where('file_ext', '!=', '');
return $this->orderedCursor($query);
}
/**
* @return LazyCollection<int, Artwork>
*/
private function orderedCursor(Builder $query): LazyCollection
{
return $query
->orderByDesc('artworks.id')
->cursor();
}
}
@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Cdn\ArtworkCdnPurgeService;
use App\Services\News\NewsCoverImageService;
use App\Support\News\NewsCoverImage;
use Illuminate\Console\Command;
use cPad\Plugins\News\Models\NewsArticle;
final class GenerateNewsCoverThumbnailsCommand extends Command
{
protected $signature = 'news:generate-cover-thumbnails {--id=* : Restrict to one or more news article IDs} {--force : Regenerate variants even when they already exist}';
protected $description = 'Generate missing responsive cover thumbnails for managed news cover images';
public function __construct(
private readonly NewsCoverImageService $covers,
private readonly ArtworkCdnPurgeService $cdnPurge,
)
{
parent::__construct();
}
public function handle(): int
{
$ids = collect((array) $this->option('id'))
->map(static fn (mixed $id): int => (int) $id)
->filter(static fn (int $id): bool => $id > 0)
->values()
->all();
$force = (bool) $this->option('force');
$query = NewsArticle::query()
->select(['id', 'title', 'cover_image'])
->whereNotNull('cover_image')
->where('cover_image', '!=', '');
if ($ids !== []) {
$query->whereIn('id', $ids);
}
$generated = 0;
$skipped = 0;
$failed = 0;
$purged = 0;
$query->orderBy('id')->chunkById(100, function ($articles) use (&$generated, &$skipped, &$failed, &$purged, $force): void {
foreach ($articles as $article) {
$path = trim((string) $article->cover_image);
if (! NewsCoverImage::isManagedPath($path)) {
$skipped++;
continue;
}
try {
$result = $this->covers->ensureVariants($path, $force);
} catch (\Throwable $e) {
$failed++;
$this->warn(sprintf('Article %d failed: %s', (int) $article->id, $e->getMessage()));
continue;
}
if (($result['generated'] ?? 0) > 0) {
$generated++;
if ($force && $this->purgeVariantCache($path, (int) $article->id)) {
$purged++;
}
continue;
}
$skipped++;
}
});
$this->info(sprintf('News cover thumbnail generation complete: generated=%d skipped=%d failed=%d purged=%d', $generated, $skipped, $failed, $purged));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
private function purgeVariantCache(string $path, int $articleId): bool
{
$variantPaths = array_values(array_map(
static fn (string $variant): string => NewsCoverImage::variantPath($path, $variant),
array_keys(NewsCoverImage::VARIANTS),
));
return $this->cdnPurge->purgeArtworkObjectPaths($variantPaths, [
'article_id' => $articleId,
'reason' => 'news_cover_thumbnails_regenerated',
]);
}
}
@@ -10,7 +10,7 @@ use Illuminate\Support\Facades\Storage;
/** /**
* Builds all sitemap documents and writes them as static .xml files to the * Builds all sitemap documents and writes them as static .xml files to the
* public disk (default: public/sitemap.xml and public/sitemaps/{name}.xml). * public disk (default: public/sitemaps/sitemap.xml and public/sitemaps/{name}.xml).
* *
* Nginx can then serve those files directly (try_files $uri @php) without * Nginx can then serve those files directly (try_files $uri @php) without
* hitting PHP at all. The SitemapController falls back to these same files * hitting PHP at all. The SitemapController falls back to these same files
@@ -25,11 +25,11 @@ final class GenerateSitemapsCommand extends Command
{--only=* : Limit to specific sitemap families (comma or space separated)} {--only=* : Limit to specific sitemap families (comma or space separated)}
{--disk= : Override the target filesystem disk (default: sitemaps.static_publish.disk)}'; {--disk= : Override the target filesystem disk (default: sitemaps.static_publish.disk)}';
protected $description = 'Build all sitemaps and write them as static .xml files to public/.'; protected $description = 'Build all sitemaps and write them as static .xml files to the configured public sitemap disk.';
public function handle(SitemapBuildService $build): int public function handle(SitemapBuildService $build): int
{ {
$totalS tart = microtime(true); $totalStart = microtime(true);
$families = $this->selectedFamilies($build); $families = $this->selectedFamilies($build);
if ($families === []) { if ($families === []) {
@@ -48,13 +48,32 @@ final class GenerateSitemapsCommand extends Command
$this->newLine(); $this->newLine();
// ── Root sitemap index ──────────────────────────────────────────── // ── 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); $t = microtime(true);
$index = $build->buildIndex(force: true, persist: false, families: $families); $index = $build->buildIndex(force: true, persist: false, families: $families);
$disk->put('sitemap.xml', $index['content']); $indexPaths = ['sitemaps/index.xml', 'sitemaps/sitemap.xml', 'sitemap.xml'];
$written++; $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( $this->line(sprintf(
' <info>✔</info> sitemap.xml %d entries <comment>%.3fs</comment>', ' <info>✔</info> sitemap index %d entries %d path(s) <comment>%.3fs</comment>',
$index['url_count'], $index['url_count'],
$indexWritten,
microtime(true) - $t, microtime(true) - $t,
)); ));
@@ -78,7 +97,12 @@ final class GenerateSitemapsCommand extends Command
} }
$path = 'sitemaps/' . $documentName . '.xml'; $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++; $written++;
$this->line(sprintf( $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 ─────────────────────────────────────────────────────── // ── Summary ───────────────────────────────────────────────────────
$this->newLine(); $this->newLine();
$this->info(sprintf( $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))); 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;
}
} }
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\World;
use App\Services\WebStories\WorldWebStoryGenerator;
use Illuminate\Console\Command;
use Illuminate\Validation\ValidationException;
final class GenerateWorldWebStoriesCommand extends Command
{
protected $signature = 'skinbase:webstories:generate
{world? : World ID or slug}
{--all : Generate stories in batch mode}
{--pages=7 : Number of pages to generate (5-10)}
{--limit=25 : Maximum worlds to process in batch mode}
{--force : Rebuild an existing story for the target world}
{--publish : Publish immediately after generation if validation passes}
{--dry-run : Preview generation without saving anything}';
protected $description = 'Generate standalone AMP Web Stories from Skinbase Worlds';
public function handle(WorldWebStoryGenerator $generator): int
{
$worldKey = $this->argument('world');
$force = (bool) $this->option('force');
$publish = (bool) $this->option('publish');
$dryRun = (bool) $this->option('dry-run');
$pages = max(5, min(10, (int) $this->option('pages')));
if ($worldKey !== null && trim((string) $worldKey) !== '') {
$world = $this->resolveWorld((string) $worldKey);
if (! $world instanceof World) {
$this->error(sprintf('World [%s] was not found.', (string) $worldKey));
return self::FAILURE;
}
return $this->generateOne($generator, $world, $pages, $force, $publish, $dryRun);
}
if (! (bool) $this->option('all')) {
$this->error('Provide a world ID/slug or pass --all for batch generation.');
return self::INVALID;
}
return $this->generateBatch($generator, $pages, $force, $publish, $dryRun, max(1, (int) $this->option('limit')));
}
private function generateOne(WorldWebStoryGenerator $generator, World $world, int $pages, bool $force, bool $publish, bool $dryRun): int
{
try {
$result = $generator->generateFromWorld($world, null, $pages, $force, $publish, $dryRun);
} catch (ValidationException $exception) {
foreach ($exception->errors() as $messages) {
foreach ($messages as $message) {
$this->error((string) $message);
}
}
return self::FAILURE;
}
$story = $result['story'];
$validation = $result['validation'];
$this->info(sprintf(
'%s story for world [%s] -> /web-stories/%s (%d pages)',
$result['created'] ? 'Created' : 'Updated',
(string) $world->slug,
(string) $story->slug,
(int) $validation['page_count'],
));
foreach ((array) $validation['warnings'] as $warning) {
$this->warn(' - ' . $warning);
}
foreach ((array) $validation['errors'] as $error) {
$this->error(' - ' . $error);
}
return $validation['valid'] || ! $publish ? self::SUCCESS : self::FAILURE;
}
private function generateBatch(WorldWebStoryGenerator $generator, int $pages, bool $force, bool $publish, bool $dryRun, int $limit): int
{
$processed = 0;
$created = 0;
$updated = 0;
$failed = 0;
$query = World::query()
->published()
->orderByDesc('published_at')
->orderByDesc('id');
if (! $force) {
$query->whereDoesntHave('webStories');
}
$query->limit($limit)->get()->each(function (World $world) use ($generator, $pages, $force, $publish, $dryRun, &$processed, &$created, &$updated, &$failed): void {
$processed++;
try {
$result = $generator->generateFromWorld($world, null, $pages, $force, $publish, $dryRun);
$result['created'] ? $created++ : $updated++;
$this->line(sprintf('[%d] %s -> %s', (int) $world->id, (string) $world->slug, (string) $result['story']->slug));
} catch (ValidationException $exception) {
$failed++;
$first = collect($exception->errors())->flatten()->first();
$this->error(sprintf('[%d] %s failed: %s', (int) $world->id, (string) $world->slug, (string) $first));
}
});
$this->info(sprintf('Done. processed=%d created=%d updated=%d failed=%d', $processed, $created, $updated, $failed));
return $failed === 0 ? self::SUCCESS : self::FAILURE;
}
private function resolveWorld(string $value): ?World
{
return World::query()
->when(is_numeric($value), fn ($query) => $query->where('id', (int) $value), fn ($query) => $query->where('slug', $value))
->first();
}
}
+50 -2
View File
@@ -5,8 +5,10 @@ declare(strict_types=1);
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\Artwork; use App\Models\Artwork;
use App\Services\Sitemaps\SitemapReleaseManager;
use App\Services\Vision\ArtworkVisionImageUrl; use App\Services\Vision\ArtworkVisionImageUrl;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis; use Illuminate\Support\Facades\Redis;
@@ -25,10 +27,10 @@ use Throwable;
class HealthCheckCommand extends Command class HealthCheckCommand extends Command
{ {
protected $signature = 'health:check protected $signature = 'health:check
{--only= : Run only a named check (mysql|redis|cache|meilisearch|qdrant|reverb|vision|horizon|webserver|phpfpm|paths|ram|disk|load|s3|failed_jobs|queue_backlog|ssl|scheduler|log_errors|app)} {--only= : Run only a named check (mysql|redis|cache|meilisearch|qdrant|reverb|vision|horizon|webserver|phpfpm|paths|ram|disk|load|s3|failed_jobs|queue_backlog|ssl|scheduler|sitemap|log_errors|app)}
{--json : Output results as JSON}'; {--json : Output results as JSON}';
protected $description = 'Check health of all critical services (MySQL, Redis, Cache, Meilisearch, Qdrant, Reverb, Vision, Horizon, Nginx, PHP-FPM, writable paths, RAM, disk, load, S3/Contabo, failed jobs, queue backlog, SSL, scheduler, log errors, App).'; protected $description = 'Check health of all critical services (MySQL, Redis, Cache, Meilisearch, Qdrant, Reverb, Vision, Horizon, Nginx, PHP-FPM, writable paths, RAM, disk, load, S3/Contabo, failed jobs, queue backlog, SSL, scheduler, sitemap, log errors, App).';
/** Collected results: [name => [status, message, details]] */ /** Collected results: [name => [status, message, details]] */
private array $results = []; private array $results = [];
@@ -57,6 +59,7 @@ class HealthCheckCommand extends Command
'queue_backlog' => fn () => $this->checkQueueBacklog(), 'queue_backlog' => fn () => $this->checkQueueBacklog(),
'ssl' => fn () => $this->checkSsl(), 'ssl' => fn () => $this->checkSsl(),
'scheduler' => fn () => $this->checkScheduler(), 'scheduler' => fn () => $this->checkScheduler(),
'sitemap' => fn () => $this->checkSitemap(),
'log_errors' => fn () => $this->checkLogErrors(), 'log_errors' => fn () => $this->checkLogErrors(),
'app' => fn () => $this->checkApp(), 'app' => fn () => $this->checkApp(),
]; ];
@@ -1041,6 +1044,51 @@ class HealthCheckCommand extends Command
} }
} }
private function checkSitemap(): void
{
try {
$releases = app(SitemapReleaseManager::class)->listReleases();
if ($releases === []) {
$this->failCheck('sitemap', 'No sitemap releases found. Run `php artisan skinbase:sitemaps:publish` to build one.');
return;
}
$latest = $releases[0];
$releaseId = (string) ($latest['release_id'] ?? 'unknown');
$builtAtRaw = (string) ($latest['built_at'] ?? $latest['published_at'] ?? '');
if ($builtAtRaw === '') {
$this->warn_check('sitemap', "Latest sitemap release [{$releaseId}] is missing a build timestamp.", [
'release_id' => $releaseId,
'status' => (string) ($latest['status'] ?? 'unknown'),
]);
return;
}
$builtAt = Carbon::parse($builtAtRaw);
$ageSeconds = max(0, $builtAt->diffInSeconds(now()));
$builtAtLabel = $builtAt->toAtomString();
$details = [
'release_id' => $releaseId,
'built_at' => $builtAtLabel,
'age_seconds' => $ageSeconds,
'status' => (string) ($latest['status'] ?? 'unknown'),
];
$message = "Latest sitemap release [{$releaseId}] built at {$builtAtLabel} ({$ageSeconds}s ago).";
if ($ageSeconds > 72 * 3600) {
$this->failCheck('sitemap', 'Sitemap build is stale — ' . $message, $details);
} elseif ($ageSeconds > 36 * 3600) {
$this->warn_check('sitemap', 'Sitemap build is getting old — ' . $message, $details);
} else {
$this->pass('sitemap', $message, $details);
}
} catch (Throwable $e) {
$this->warn_check('sitemap', 'Could not inspect sitemap releases: ' . $e->getMessage());
}
}
private function checkLogErrors(): void private function checkLogErrors(): void
{ {
$logFile = storage_path('logs/laravel.log'); $logFile = storage_path('logs/laravel.log');
@@ -7,34 +7,137 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; 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 * Usage:
* php artisan nova:prune-metric-snapshots --keep-days=7 * php artisan nova:prune-metric-snapshots
* php artisan nova:prune-metric-snapshots --keep-days=30 --chunk=5000 --dry-run
*/ */
class PruneMetricSnapshotsCommand extends Command class PruneMetricSnapshotsCommand extends Command
{ {
protected $signature = 'nova:prune-metric-snapshots 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 public function handle(): int
{ {
$keepDays = (int) $this->option('keep-days'); $keepDays = $this->resolveKeepDays();
$cutoff = now()->subDays($keepDays); $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) ->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', [ Log::info('[nova:prune-metric-snapshots] completed', [
'deleted' => $deleted, 'deleted' => $deleted,
'batches' => $batches,
'keep_days' => $keepDays, 'keep_days' => $keepDays,
'cutoff' => $cutoff->toDateTimeString(),
'eligible_at_start' => $eligible,
]); ]);
return self::SUCCESS; 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\Console\Command;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use App\Services\News\NewsService;
use cPad\Plugins\News\Models\NewsArticle; use cPad\Plugins\News\Models\NewsArticle;
final class PublishScheduledNewsCommand extends Command final class PublishScheduledNewsCommand extends Command
@@ -17,6 +18,11 @@ final class PublishScheduledNewsCommand extends Command
protected $description = 'Publish scheduled News articles whose publish time has passed.'; protected $description = 'Publish scheduled News articles whose publish time has passed.';
public function __construct(private readonly NewsService $news)
{
parent::__construct();
}
public function handle(): int public function handle(): int
{ {
$dryRun = (bool) $this->option('dry-run'); $dryRun = (bool) $this->option('dry-run');
@@ -60,11 +66,7 @@ final class PublishScheduledNewsCommand extends Command
return; return;
} }
$article->forceFill([ $this->news->publish($article);
'editorial_status' => NewsArticle::EDITORIAL_STATUS_PUBLISHED,
'status' => 'published',
'published_at' => $article->published_at ?? $now,
])->save();
$published++; $published++;
$this->line(sprintf('Published News article #%d: "%s"', $article->id, $article->title)); $this->line(sprintf('Published News article #%d: "%s"', $article->id, $article->title));
@@ -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; $updatedCount = 0;
$skippedCount = 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') $artworkIds = DB::table('artwork_metric_snapshots_hourly')
->whereBetween('bucket_hour', [$lookbackStart, $currentHour]) ->whereBetween('bucket_hour', [$lookbackStart, $currentHour])
->distinct() ->distinct()
@@ -68,27 +68,33 @@ class RecalculateHeatCommand extends Command
return self::SUCCESS; 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) { 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 = []; $upsertRows = [];
foreach ($chunkIds as $artworkId) { 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,34 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\LeaderboardService;
use Illuminate\Console\Command;
class RefreshLeaderboardsCommand extends Command
{
protected $signature = 'leaderboards:refresh';
protected $description = 'Refresh all leaderboard rows and clear leaderboard caches.';
public function __construct(private readonly LeaderboardService $leaderboards)
{
parent::__construct();
}
public function handle(): int
{
$this->info('Refreshing leaderboards …');
$results = $this->leaderboards->refreshAll();
$updated = collect($results)
->flatten(1)
->sum(fn (int $count): int => $count);
$this->info("Done. Updated: {$updated} leaderboard row(s).");
return self::SUCCESS;
}
}
@@ -0,0 +1,502 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Artwork;
use App\Repositories\Uploads\ArtworkFileRepository;
use App\Services\ArtworkOriginalFileLocator;
use App\Services\Cdn\ArtworkCdnPurgeService;
use App\Services\Uploads\UploadDerivativesService;
use App\Services\Uploads\UploadStorageService;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Throwable;
final class RepairArtworkThumbnailsCommand extends Command
{
private const SOURCE_IMAGE_EXTENSIONS = [
'avif',
'bmp',
'gif',
'jpg',
'jpeg',
'png',
'tif',
'tiff',
'webp',
];
protected $signature = 'artworks:repair-missing-thumbnails
{--id= : Repair only this artwork ID}
{--limit= : Stop after processing this many artworks}
{--chunk=200 : Number of artworks to scan per batch}
{--variant=* : Specific thumbnail variants to repair (defaults to all configured derivatives)}
{--only-missing-flagged : Scan only artworks already marked with has_missing_thumbnails=1}
{--csv= : Optional path to write a CSV report}
{--force : Regenerate the selected variants even when they already exist}
{--dry-run : Report repairs without writing files}';
protected $description = 'Scan artworks from newest to oldest, detect missing CDN thumbnails, and rebuild only the missing derivatives from local source files.';
public function handle(
UploadStorageService $storage,
UploadDerivativesService $derivatives,
ArtworkFileRepository $artworkFiles,
ArtworkOriginalFileLocator $locator,
ArtworkCdnPurgeService $cdnPurge,
): int {
$artworkId = $this->option('id') !== null ? max(1, (int) $this->option('id')) : null;
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
$chunkSize = max(1, min((int) $this->option('chunk'), 1000));
$onlyMissingFlagged = (bool) $this->option('only-missing-flagged');
$csvPath = trim((string) $this->option('csv'));
$force = (bool) $this->option('force');
$dryRun = (bool) $this->option('dry-run');
$allVariants = $this->resolveConfiguredVariants();
$selectedVariants = $this->resolveSelectedVariants($allVariants);
if ($selectedVariants === []) {
return self::FAILURE;
}
$auditColumnsAvailable = Schema::hasColumns('artworks', [
'has_missing_thumbnails',
'missing_thumbnail_variants_json',
'thumbnails_checked_at',
]);
if ($onlyMissingFlagged && ! $auditColumnsAvailable) {
$this->error('The --only-missing-flagged option requires thumbnail audit columns on the artworks table.');
return self::FAILURE;
}
$diskName = $storage->objectDiskName();
$disk = Storage::disk($diskName);
$csvHandle = $this->openCsvHandle($csvPath);
$baseQuery = $this->baseQuery($onlyMissingFlagged);
$totalCandidates = $this->resolveTotalCandidates($baseQuery, $artworkId, $limit);
$progressBar = $totalCandidates > 0 ? $this->output->createProgressBar($totalCandidates) : null;
$this->info(sprintf(
'Starting thumbnail repair. order=id_desc include_trashed=yes disk=%s variants=%s chunk=%d limit=%s flagged_only=%s force=%s dry_run=%s csv=%s',
$diskName,
implode(',', $selectedVariants),
$chunkSize,
$limit !== null ? (string) $limit : 'all',
$onlyMissingFlagged ? 'yes' : 'no',
$force ? 'yes' : 'no',
$dryRun ? 'yes' : 'no',
$csvPath !== '' ? $csvPath : 'off',
));
if ($progressBar !== null) {
$progressBar->start();
}
$processed = 0;
$healthy = 0;
$planned = 0;
$repaired = 0;
$failed = 0;
$lastSeenId = null;
try {
do {
$artworks = $this->nextChunk($baseQuery, $artworkId, $chunkSize, $lastSeenId);
if ($artworks->isEmpty()) {
break;
}
foreach ($artworks as $artwork) {
if ($limit !== null && $processed >= $limit) {
break 2;
}
try {
$targetVariants = $force
? $selectedVariants
: $this->resolveMissingVariants($artwork, $selectedVariants, $storage, $disk);
if ($targetVariants === []) {
$healthy++;
$processed++;
$this->writeCsvRow($csvHandle, [
'artwork_id' => (int) $artwork->id,
'status' => 'healthy',
'variants' => '',
'source_file' => '',
'message' => '',
]);
$progressBar?->advance();
continue;
}
$sourcePath = $this->resolveLocalSourcePath($artwork, $locator);
if ($sourcePath === '') {
throw new \RuntimeException('No local original source file was found in the configured artwork roots.');
}
if ($dryRun) {
$planned++;
$this->line(sprintf(
'Artwork %d would repair thumbnails: %s',
(int) $artwork->id,
implode(',', $targetVariants),
));
$this->line(' source_file: ' . $sourcePath);
$this->writeCsvRow($csvHandle, [
'artwork_id' => (int) $artwork->id,
'status' => 'planned',
'variants' => implode(',', $targetVariants),
'source_file' => $sourcePath,
'message' => '',
]);
$processed++;
$progressBar?->advance();
continue;
}
$assets = $derivatives->generateSelectedPublicDerivatives($sourcePath, (string) $artwork->hash, $targetVariants);
if ($assets === []) {
throw new \RuntimeException('No thumbnail assets were generated for the requested variants.');
}
DB::transaction(function () use ($artwork, $assets, $artworkFiles, $storage, $disk, $allVariants, $auditColumnsAvailable): void {
foreach ($assets as $variant => $asset) {
$artworkFiles->upsert((int) $artwork->id, (string) $variant, $asset['path'], $asset['mime'], $asset['size']);
}
$update = [
'thumb_ext' => 'webp',
];
if ($auditColumnsAvailable) {
$remainingMissing = $this->resolveMissingVariants($artwork, $allVariants, $storage, $disk);
$update['has_missing_thumbnails'] = $remainingMissing !== [];
$update['missing_thumbnail_variants_json'] = $remainingMissing === []
? null
: json_encode(array_values($remainingMissing), JSON_UNESCAPED_SLASHES);
$update['thumbnails_checked_at'] = now();
}
Artwork::query()->withTrashed()->whereKey($artwork->id)->update($update);
});
$cdnPurge->purgeArtworkObjectPaths(array_map(
static fn (array $asset): string => (string) $asset['path'],
array_values($assets),
), [
'artwork_id' => (int) $artwork->id,
'reason' => 'thumbnail_repair',
]);
$repaired++;
$this->info(sprintf(
'Artwork %d repaired thumbnails: %s',
(int) $artwork->id,
implode(',', array_keys($assets)),
));
$this->writeCsvRow($csvHandle, [
'artwork_id' => (int) $artwork->id,
'status' => 'repaired',
'variants' => implode(',', array_keys($assets)),
'source_file' => $sourcePath,
'message' => '',
]);
} catch (Throwable $exception) {
$failed++;
$this->warn(sprintf('Artwork %d repair failed: %s', (int) $artwork->id, $exception->getMessage()));
$this->writeCsvRow($csvHandle, [
'artwork_id' => (int) $artwork->id,
'status' => 'failed',
'variants' => isset($targetVariants) && is_array($targetVariants) ? implode(',', $targetVariants) : '',
'source_file' => isset($sourcePath) ? (string) $sourcePath : '',
'message' => $exception->getMessage(),
]);
}
$processed++;
$progressBar?->advance();
}
$lastSeenId = (int) $artworks->last()->id;
} while (true);
} finally {
if ($progressBar !== null) {
$progressBar->finish();
$this->newLine(2);
}
if (is_resource($csvHandle)) {
fclose($csvHandle);
}
}
$this->info(sprintf(
'Thumbnail repair complete. processed=%d healthy=%d planned=%d repaired=%d failed=%d',
$processed,
$healthy,
$planned,
$repaired,
$failed,
));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* @return Collection<int, Artwork>
*/
private function nextChunk(mixed $baseQuery, ?int $artworkId, int $chunkSize, ?int $lastSeenId): Collection
{
$query = clone $baseQuery;
if ($artworkId !== null) {
$query->whereKey($artworkId);
} elseif ($lastSeenId !== null) {
$query->where('id', '<', $lastSeenId);
}
return $query->limit($chunkSize)->get();
}
private function baseQuery(bool $onlyMissingFlagged): mixed
{
$query = Artwork::query()
->withTrashed()
->select(['id', 'slug', 'hash', 'file_path', 'file_ext', 'thumb_ext'])
->whereNotNull('hash')
->where('hash', '!=', '')
->orderByDesc('id');
if ($onlyMissingFlagged) {
$query->where('has_missing_thumbnails', true);
}
return $query;
}
private function resolveTotalCandidates(mixed $baseQuery, ?int $artworkId, ?int $limit): int
{
$countQuery = clone $baseQuery;
if ($artworkId !== null) {
$countQuery->whereKey($artworkId);
}
$count = (int) $countQuery->count();
if ($limit !== null) {
return min($count, $limit);
}
return $count;
}
/**
* @return list<string>
*/
private function resolveConfiguredVariants(): array
{
return array_values(array_filter(array_map(
static fn ($variant): string => strtolower(trim((string) $variant)),
array_keys((array) config('uploads.derivatives', [])),
)));
}
/**
* @param list<string> $configuredVariants
* @return list<string>
*/
private function resolveSelectedVariants(array $configuredVariants): array
{
if ($configuredVariants === []) {
$this->error('No thumbnail variants are configured. Check uploads.derivatives.');
return [];
}
$requested = (array) $this->option('variant');
if ($requested === []) {
return $configuredVariants;
}
$normalizedRequested = array_values(array_unique(array_filter(array_map(
static fn ($variant): string => strtolower(trim((string) $variant)),
$requested,
))));
$invalid = array_values(array_diff($normalizedRequested, $configuredVariants));
if ($invalid !== []) {
$this->error('Unknown thumbnail variants: ' . implode(', ', $invalid));
$this->line('Configured variants: ' . implode(', ', $configuredVariants));
return [];
}
return $normalizedRequested;
}
/**
* @param list<string> $variants
* @return list<string>
*/
private function resolveMissingVariants(Artwork $artwork, array $variants, UploadStorageService $storage, mixed $disk): array
{
$hash = strtolower((string) preg_replace('/[^a-z0-9]/', '', (string) ($artwork->hash ?? '')));
if ($hash === '') {
return $variants;
}
$missing = [];
foreach ($variants as $variant) {
$objectPath = $storage->objectPathForVariant($variant, $hash, $hash . '.webp');
if (! $disk->exists($objectPath)) {
$missing[] = $variant;
}
}
return $missing;
}
private function resolveLocalSourcePath(Artwork $artwork, ArtworkOriginalFileLocator $locator): string
{
$hash = strtolower((string) ($artwork->hash ?? ''));
if (! $this->isValidHash($hash)) {
return '';
}
$preferred = $locator->resolveLocalPath($artwork);
if ($this->isUsableSourceFile($preferred)) {
return $preferred;
}
foreach ($this->candidateOriginalRoots() as $root) {
$candidatePath = $this->findNonZipSourceInRoot($root, $hash);
if ($candidatePath !== '') {
return $candidatePath;
}
}
return '';
}
/**
* @return list<string>
*/
private function candidateOriginalRoots(): array
{
$roots = [
trim((string) config('uploads.local_originals_root', '')),
trim((string) config('uploads.readonly_backup_originals_root', '')),
];
$normalizedRoots = [];
foreach ($roots as $root) {
if ($root === '') {
continue;
}
$normalizedRoot = rtrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $root), DIRECTORY_SEPARATOR);
if ($normalizedRoot === '' || in_array($normalizedRoot, $normalizedRoots, true)) {
continue;
}
$normalizedRoots[] = $normalizedRoot;
}
return $normalizedRoots;
}
private function findNonZipSourceInRoot(string $root, string $hash): string
{
$directory = $root
. DIRECTORY_SEPARATOR . substr($hash, 0, 2)
. DIRECTORY_SEPARATOR . substr($hash, 2, 2);
if (! File::isDirectory($directory)) {
return '';
}
$matches = File::glob($directory . DIRECTORY_SEPARATOR . $hash . '.*');
if (! is_array($matches)) {
return '';
}
foreach ($matches as $path) {
if ($this->isUsableSourceFile($path)) {
return $path;
}
}
return '';
}
private function isUsableSourceFile(string $path): bool
{
if ($path === '' || ! File::isFile($path)) {
return false;
}
$extension = strtolower((string) pathinfo($path, PATHINFO_EXTENSION));
if ($extension === '' || ! in_array($extension, self::SOURCE_IMAGE_EXTENSIONS, true)) {
return false;
}
$mime = strtolower((string) (File::mimeType($path) ?? ''));
return str_starts_with($mime, 'image/');
}
private function isValidHash(string $hash): bool
{
return $hash !== '' && preg_match('/^[a-f0-9]+$/', $hash) === 1;
}
/**
* @return resource|null
*/
private function openCsvHandle(string $csvPath)
{
if ($csvPath === '') {
return null;
}
File::ensureDirectoryExists(dirname($csvPath));
$handle = fopen($csvPath, 'wb');
if (! is_resource($handle)) {
throw new \RuntimeException('Unable to open CSV output path for writing: ' . $csvPath);
}
fputcsv($handle, ['artwork_id', 'status', 'variants', 'source_file', 'message']);
return $handle;
}
/**
* @param resource|null $csvHandle
* @param array<string, scalar|null> $row
*/
private function writeCsvRow($csvHandle, array $row): void
{
if (! is_resource($csvHandle)) {
return;
}
fputcsv($csvHandle, [
$row['artwork_id'] ?? '',
$row['status'] ?? '',
$row['variants'] ?? '',
$row['source_file'] ?? '',
$row['message'] ?? '',
]);
}
}
@@ -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,162 @@
<?php
namespace App\Console\Commands;
use App\Jobs\SendVerificationEmailJob;
use App\Mail\RegistrationVerificationMail;
use App\Models\EmailSendEvent;
use App\Models\User;
use App\Services\Auth\RegistrationEmailQuotaService;
use App\Services\Auth\RegistrationVerificationTokenService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
class SendUserVerificationEmailCommand extends Command
{
protected $signature = 'user:send-verification-email
{userId : The user ID that should receive the verification email}
{--now : Send immediately instead of queueing the existing verification job}
{--force : Allow sending even if the user is already verified}';
protected $description = 'Send the registration verification email to a specific user ID.';
public function __construct(
private readonly RegistrationVerificationTokenService $tokenService,
private readonly RegistrationEmailQuotaService $quotaService,
) {
parent::__construct();
}
public function handle(): int
{
$userId = (int) $this->argument('userId');
if ($userId < 1) {
$this->error('The user ID must be a positive integer.');
return self::FAILURE;
}
$user = User::query()->find($userId);
if (! $user) {
$this->error("User {$userId} was not found.");
return self::FAILURE;
}
$email = strtolower(trim((string) $user->email));
if ($email === '') {
$this->error("User {$userId} does not have an email address.");
return self::FAILURE;
}
if ($user->email_verified_at !== null && ! $this->option('force')) {
$this->error("User {$userId} already has a verified email address. Use --force to send anyway.");
return self::FAILURE;
}
$token = $this->tokenService->createForUser($userId);
$event = EmailSendEvent::query()->create([
'type' => 'verify_email',
'email' => $email,
'ip' => null,
'user_id' => $userId,
'status' => $this->option('now') ? 'pending' : 'queued',
'reason' => null,
'created_at' => now(),
]);
if ($this->option('now')) {
return $this->sendNow($user, $event, $token);
}
SendVerificationEmailJob::dispatch(
emailEventId: (int) $event->id,
email: $email,
token: $token,
userId: $userId,
ip: null,
);
$this->markVerificationEmailSent($user);
$this->info("Queued verification email for user {$userId} <{$email}>.");
return self::SUCCESS;
}
private function sendNow(User $user, EmailSendEvent $event, string $token): int
{
if (! $this->acquireGlobalSendSlot()) {
$this->updateEvent($event, 'blocked', 'rate_limited');
$this->error('The global verification email rate limit is currently exhausted. Try again in a minute.');
return self::FAILURE;
}
if ($this->quotaService->isExceeded()) {
$this->updateEvent($event, 'blocked', 'quota');
$this->error('The monthly registration email quota is exceeded.');
return self::FAILURE;
}
try {
Mail::to($user->email)->send(new RegistrationVerificationMail($token));
} catch (\Throwable $exception) {
$this->updateEvent($event, 'failed', 'send_error');
$this->error('Failed to send the verification email: ' . $exception->getMessage());
return self::FAILURE;
}
$this->quotaService->incrementSentCount();
$this->updateEvent($event, 'sent', null);
$this->markVerificationEmailSent($user);
$email = strtolower(trim((string) $user->email));
$this->info("Sent verification email to user {$user->id} <{$email}>.");
return self::SUCCESS;
}
private function acquireGlobalSendSlot(): bool
{
$key = 'registration:verification-email:global';
$maxPerMinute = max(1, (int) config('registration.email_global_send_per_minute', 30));
return RateLimiter::attempt($key, $maxPerMinute, static fn () => true, 60);
}
private function updateEvent(EmailSendEvent $event, string $status, ?string $reason): void
{
EmailSendEvent::query()
->whereKey($event->getKey())
->update([
'status' => $status,
'reason' => $reason,
]);
}
private function markVerificationEmailSent(User $user): void
{
$now = now();
$windowStartedAt = $user->verification_send_window_started_at;
if (! $windowStartedAt || $windowStartedAt->lt($now->copy()->subDay())) {
$user->verification_send_window_started_at = $now;
$user->verification_send_count_24h = 1;
} else {
$user->verification_send_count_24h = ((int) $user->verification_send_count_24h) + 1;
}
$user->last_verification_sent_at = $now;
$user->save();
}
}
@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\WorldWebStory;
use App\Services\WebStories\WorldWebStoryValidationService;
use Illuminate\Console\Command;
use Symfony\Component\Process\Process;
final class ValidateWorldWebStoriesCommand extends Command
{
protected $signature = 'skinbase:webstories:validate
{story? : Story ID or slug}
{--published : Limit batch mode to published stories}
{--visible : Limit batch mode to publicly visible stories}
{--limit=100 : Maximum stories to validate in batch mode}
{--amp : Also run amphtml-validator against the public story URL}
{--fail-warnings : Treat validation warnings as failures}';
protected $description = 'Validate World Web Stories for publish safety and optional AMP validity';
public function handle(WorldWebStoryValidationService $validation): int
{
$storyKey = $this->argument('story');
if ($storyKey !== null && trim((string) $storyKey) !== '') {
$story = $this->resolveStory((string) $storyKey);
if (! $story instanceof WorldWebStory) {
$this->error(sprintf('Web story [%s] was not found.', (string) $storyKey));
return self::FAILURE;
}
return $this->validateOne($validation, $story);
}
return $this->validateBatch($validation, max(1, (int) $this->option('limit')));
}
private function validateOne(WorldWebStoryValidationService $validation, WorldWebStory $story): int
{
$result = $validation->validate($story);
$ampErrors = $this->ampErrors($story);
$this->line(sprintf('Story [%d] %s', (int) $story->id, (string) $story->slug));
foreach ((array) $result['warnings'] as $warning) {
$this->warn(' - ' . $warning);
}
foreach ((array) $result['errors'] as $error) {
$this->error(' - ' . $error);
}
foreach ($ampErrors as $ampError) {
$this->error(' - AMP: ' . $ampError);
}
if ($result['valid'] && $ampErrors === []) {
$this->info('Validation passed.');
return self::SUCCESS;
}
return self::FAILURE;
}
private function validateBatch(WorldWebStoryValidationService $validation, int $limit): int
{
$processed = 0;
$failed = 0;
$this->storyQuery()
->limit($limit)
->get()
->each(function (WorldWebStory $story) use ($validation, &$processed, &$failed): void {
$processed++;
$result = $validation->validate($story);
$ampErrors = $this->ampErrors($story);
$warningsFail = (bool) $this->option('fail-warnings') && count((array) $result['warnings']) > 0;
$hasFailure = ! $result['valid'] || $warningsFail || $ampErrors !== [];
if ($hasFailure) {
$failed++;
}
$this->line(sprintf('[%d] %s -> %s', (int) $story->id, (string) $story->slug, $hasFailure ? 'invalid' : 'valid'));
foreach ((array) $result['warnings'] as $warning) {
$this->warn(' - ' . $warning);
}
foreach ((array) $result['errors'] as $error) {
$this->error(' - ' . $error);
}
foreach ($ampErrors as $ampError) {
$this->error(' - AMP: ' . $ampError);
}
});
$this->info(sprintf('Done. processed=%d failed=%d', $processed, $failed));
return $failed === 0 ? self::SUCCESS : self::FAILURE;
}
private function storyQuery()
{
return WorldWebStory::query()
->when((bool) $this->option('published'), fn ($query) => $query->published())
->when((bool) $this->option('visible'), fn ($query) => $query->visible())
->orderByDesc('published_at')
->orderByDesc('id');
}
/**
* @return list<string>
*/
private function ampErrors(WorldWebStory $story): array
{
if (! (bool) $this->option('amp')) {
return [];
}
if (! $story->exists || ! $story->publicUrl()) {
return ['Story has no public URL to validate.'];
}
$probe = new Process(['npx', 'amphtml-validator', '--version'], base_path(), null, null, 60);
$probe->run();
if (! $probe->isSuccessful()) {
return ['amphtml-validator is not available via npx.'];
}
$process = new Process(['npx', 'amphtml-validator', $story->publicUrl()], base_path(), null, null, 120);
$process->run();
if ($process->isSuccessful()) {
return [];
}
$output = trim($process->getErrorOutput() ?: $process->getOutput());
if ($output === '') {
return ['AMP validator failed without output.'];
}
$lines = preg_split('/\r\n|\r|\n/', $output);
return $lines === false || $lines === [] ? ['AMP validator failed.'] : $lines;
}
private function resolveStory(string $value): ?WorldWebStory
{
return WorldWebStory::query()
->when(is_numeric($value), fn ($query) => $query->where('id', (int) $value), fn ($query) => $query->where('slug', $value))
->first();
}
}
+8
View File
@@ -11,6 +11,7 @@ use App\Console\Commands\BackfillArtworkVectorIndexCommand;
use App\Console\Commands\IndexArtworkVectorsCommand; use App\Console\Commands\IndexArtworkVectorsCommand;
use App\Console\Commands\SearchArtworkVectorsCommand; use App\Console\Commands\SearchArtworkVectorsCommand;
use App\Console\Commands\AggregateSimilarArtworkAnalyticsCommand; use App\Console\Commands\AggregateSimilarArtworkAnalyticsCommand;
use App\Console\Commands\AcademyCoursesSyncFoundationsCommand;
use App\Console\Commands\AggregateFeedAnalyticsCommand; use App\Console\Commands\AggregateFeedAnalyticsCommand;
use App\Console\Commands\AggregateTagInteractionAnalyticsCommand; use App\Console\Commands\AggregateTagInteractionAnalyticsCommand;
use App\Console\Commands\SeedTagInteractionDemoCommand; use App\Console\Commands\SeedTagInteractionDemoCommand;
@@ -30,10 +31,13 @@ use App\Console\Commands\PublishScheduledArtworksCommand;
use App\Console\Commands\PublishScheduledNewsCommand; use App\Console\Commands\PublishScheduledNewsCommand;
use App\Console\Commands\PublishScheduledNovaCardsCommand; use App\Console\Commands\PublishScheduledNovaCardsCommand;
use App\Console\Commands\BuildSitemapsCommand; use App\Console\Commands\BuildSitemapsCommand;
use App\Console\Commands\BuildWorldWebStoryAssetsCommand;
use App\Console\Commands\ListSitemapReleasesCommand; use App\Console\Commands\ListSitemapReleasesCommand;
use App\Console\Commands\GenerateWorldWebStoriesCommand;
use App\Console\Commands\PublishSitemapsCommand; use App\Console\Commands\PublishSitemapsCommand;
use App\Console\Commands\RollbackSitemapReleaseCommand; use App\Console\Commands\RollbackSitemapReleaseCommand;
use App\Console\Commands\SyncCollectionLifecycleCommand; use App\Console\Commands\SyncCollectionLifecycleCommand;
use App\Console\Commands\ValidateWorldWebStoriesCommand;
use App\Console\Commands\ValidateSitemapsCommand; use App\Console\Commands\ValidateSitemapsCommand;
use App\Console\Commands\AuditArtworkDownloadFilesCommand; use App\Console\Commands\AuditArtworkDownloadFilesCommand;
use App\Console\Commands\InspectArtworkOriginalCommand; use App\Console\Commands\InspectArtworkOriginalCommand;
@@ -57,6 +61,9 @@ class Kernel extends ConsoleKernel
\App\Console\Commands\ResetAllUserPasswords::class, \App\Console\Commands\ResetAllUserPasswords::class,
CleanupUploadsCommand::class, CleanupUploadsCommand::class,
BuildSitemapsCommand::class, BuildSitemapsCommand::class,
GenerateWorldWebStoriesCommand::class,
BuildWorldWebStoryAssetsCommand::class,
ValidateWorldWebStoriesCommand::class,
PublishSitemapsCommand::class, PublishSitemapsCommand::class,
ListSitemapReleasesCommand::class, ListSitemapReleasesCommand::class,
RollbackSitemapReleaseCommand::class, RollbackSitemapReleaseCommand::class,
@@ -71,6 +78,7 @@ class Kernel extends ConsoleKernel
ZipUnsupportedArtworkOriginalsCommand::class, ZipUnsupportedArtworkOriginalsCommand::class,
SendTestMail::class, SendTestMail::class,
DispatchCollectionMaintenanceCommand::class, DispatchCollectionMaintenanceCommand::class,
AcademyCoursesSyncFoundationsCommand::class,
BackfillArtworkEmbeddingsCommand::class, BackfillArtworkEmbeddingsCommand::class,
BackfillArtworkVectorIndexCommand::class, BackfillArtworkVectorIndexCommand::class,
IndexArtworkVectorsCommand::class, IndexArtworkVectorsCommand::class,
@@ -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,
) {}
}
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Services\Academy\AcademyAnalyticsContentResolver;
use App\Services\Academy\AcademyAnalyticsService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\AcademyAnalytics\AcademyAnalyticsEventType;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
final class AcademyAnalyticsEventController extends Controller
{
public function __construct(
private readonly AcademyAnalyticsService $analytics,
private readonly AcademyAnalyticsContentResolver $resolver,
) {
}
public function store(Request $request): JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
abort_unless($request->expectsJson() || $request->isJson(), 422);
$validated = $request->validate([
'event_type' => ['required', 'string', Rule::in(AcademyAnalyticsEventType::values())],
'content_type' => ['nullable', 'string', Rule::in(AcademyAnalyticsContentType::values())],
'content_id' => ['nullable', 'integer', 'min:1'],
'metadata' => ['nullable', 'array'],
'visitor_id' => ['nullable', 'string', 'max:120'],
'session_id' => ['nullable', 'string', 'max:120'],
'url' => ['nullable', 'string', 'max:4000'],
'route_name' => ['nullable', 'string', 'max:255'],
'referrer' => ['nullable', 'string', 'max:4000'],
'utm_source' => ['nullable', 'string', 'max:255'],
'utm_medium' => ['nullable', 'string', 'max:255'],
'utm_campaign' => ['nullable', 'string', 'max:255'],
]);
if (isset($validated['metadata']) && strlen((string) json_encode($validated['metadata'])) > 8192) {
return response()->json([
'message' => 'Metadata payload is too large.',
], 422);
}
$contentType = $validated['content_type'] ?? null;
$contentId = $validated['content_id'] ?? null;
if ($contentType !== null && AcademyAnalyticsContentType::requiresContentId($contentType) && $contentId === null) {
return response()->json([
'message' => 'content_id is required for this content type.',
], 422);
}
if ($contentType !== null && $contentId !== null && ! $this->resolver->exists($contentType, (int) $contentId)) {
return response()->json([
'message' => 'Unknown Academy analytics content target.',
], 422);
}
if (($validated['event_type'] ?? null) === AcademyAnalyticsEventType::SEARCH_RESULT_CLICK) {
validator([
'content_type' => $contentType,
'content_id' => $contentId,
'metadata' => $validated['metadata'] ?? [],
], [
'content_type' => ['required', 'string', Rule::in([
AcademyAnalyticsContentType::PROMPT,
AcademyAnalyticsContentType::LESSON,
AcademyAnalyticsContentType::COURSE,
AcademyAnalyticsContentType::PROMPT_PACK,
AcademyAnalyticsContentType::CHALLENGE,
])],
'content_id' => ['required', 'integer', 'min:1'],
'metadata.query' => ['required', 'string', 'max:120'],
'metadata.normalized_query' => ['required', 'string', 'max:120'],
'metadata.results_count' => ['required', 'integer', 'min:0'],
'metadata.position' => ['nullable', 'integer', 'min:1'],
'metadata.source' => ['nullable', 'string', 'max:120'],
'metadata.filters' => ['nullable', 'array'],
])->validate();
}
$this->analytics->track($validated, $request->user(), $request);
return response()->json([
'ok' => true,
]);
}
}
@@ -0,0 +1,741 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyBillingPlanService;
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(
private readonly AcademyAccessService $access,
private readonly AcademyBillingPlanService $plans,
) {}
public function pricing(\Illuminate\Http\Request $request): \Inertia\Response
{
\abort_unless((bool) \config('academy.enabled', true), 404);
$this->plans->assertConfigured();
/** @var User|null $user */
$user = $request->user();
$canonical = \route('academy.pricing');
$seo = \app(SeoFactory::class)
->collectionPage(
'Skinbase AI Academy Pricing — Skinbase',
'Compare Skinbase AI Academy Creator and Pro tiers, start free, and manage premium access through Stripe billing.',
$canonical,
)
->toArray();
$seo['og_type'] = 'website';
$activePlan = $user instanceof User ? $this->activePlan($user) : null;
return \Inertia\Inertia::render('Academy/Billing/Pricing', [
'seo' => $seo,
'billingEnabled' => $this->plans->enabled(),
'currentTier' => $this->access->currentTier($user),
'isSubscribed' => $user instanceof User ? $this->access->hasActiveAcademySubscription($user) : false,
'activePlanKey' => $activePlan['key'] ?? null,
'activePlanLabel' => $activePlan['label'] ?? null,
'catalog' => $this->catalog(),
'missingRemote' => $this->plans->missingRemotePriceIds(),
'links' => [
'login' => \route('login'),
'pricing' => \route('academy.pricing'),
'billingAccount' => $user ? \route('academy.billing.account') : null,
'checkout' => $user ? \route('academy.billing.checkout') : null,
],
'analytics' => [
'enabled' => true,
'contentType' => AcademyAnalyticsContentType::UPGRADE,
'contentId' => null,
'eventUrl' => \route('academy.analytics.events.store'),
'pageName' => 'academy_billing_pricing',
'isPremium' => false,
'isGuest' => $user === null,
'isSubscriber' => $user?->hasAcademyCreatorAccess() || $user?->hasAcademyProAccess(),
],
])->rootView('academy');
}
public function checkout(\Illuminate\Http\Request $request): Checkout|\Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse
{
\abort_unless((bool) \config('academy.enabled', true), 404);
if (! $this->plans->enabled()) {
return $this->billingDisabledResponse($request, 'Academy billing is not enabled yet.');
}
$this->plans->assertConfigured();
$user = $request->user();
if (! $user instanceof User) {
return \redirect()->route('login');
}
if ($user->email_verified_at === null) {
throw \Illuminate\Validation\ValidationException::withMessages([
'plan' => 'Verify your email address before starting Academy billing.',
]);
}
$validated = $request->validate([
'plan' => ['required', 'string'],
]);
$plan = $this->plans->plan((string) $validated['plan']);
if ($plan === null) {
throw \Illuminate\Validation\ValidationException::withMessages([
'plan' => 'Select a valid Academy billing plan.',
]);
}
if (! ($plan['configured'] ?? false)) {
return $this->missingPriceIdResponse($request, (string) $plan['key']);
}
if (! ($plan['price_id_valid'] ?? false)) {
return $this->invalidPriceIdResponse($request, (string) $plan['key']);
}
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');
}
try {
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 (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) {
$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);
}
}
public function checkoutLegacy(\Illuminate\Http\Request $request, string $plan): Checkout|\Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse
{
$request->merge([
'plan' => $this->plans->normalizePlanKey($plan),
]);
return $this->checkout($request);
}
public function portal(\Illuminate\Http\Request $request): \Illuminate\Http\RedirectResponse
{
\abort_unless((bool) \config('academy.enabled', true), 404);
\abort_unless($this->plans->enabled(), 404);
/** @var User|null $user */
$user = $request->user();
if (! $user instanceof User || \blank($user->stripe_id)) {
return \redirect()->route('academy.billing.account')->with('error', 'No Stripe billing profile is connected to this account yet.');
}
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
{
\abort_unless((bool) \config('academy.enabled', true), 404);
/** @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,
'links' => [
'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('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('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
{
\abort_unless((bool) \config('academy.enabled', true), 404);
\abort_unless($this->plans->enabled(), 404);
/** @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 ? [
'key' => $activePlan['key'],
'label' => $activePlan['label'],
'price_display' => $activePlan['price_display'] ?? null,
'tier' => $activePlan['tier'],
] : null,
'subscription' => $subscription ? [
'name' => $subscription->type,
'status' => $subscription->stripe_status,
'active' => $subscription->active(),
'onGracePeriod' => $subscription->onGracePeriod(),
'endsAt' => $subscription->ends_at?->toISOString(),
'priceIds' => $subscription->items->pluck('stripe_price')->filter()->values()->all(),
] : null,
'links' => [
'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('academy');
}
/**
* @return array<int, array<string, mixed>>
*/
private function catalog(): array
{
$definitions = [
'creator' => [
'name' => 'Creator',
'description' => 'Entry premium access for prompt systems, creator lessons, and saved Academy workflows.',
'badge' => 'Paid',
'featured' => false,
'features' => [
'Creator lessons and walkthroughs',
'Full Creator prompt templates',
'Prompt save and reuse flows',
'Upgrade path into Pro later',
],
],
'pro' => [
'name' => 'Pro',
'description' => 'Full Academy access across Creator and Pro lessons, prompts, and future premium drops.',
'badge' => 'Recommended',
'featured' => true,
'features' => [
'Everything in Creator',
'Advanced Pro lessons and prompt systems',
'Priority access to future Academy premium features',
'Stripe billing portal for upgrades and invoices',
],
],
];
return collect($definitions)
->map(function (array $definition, string $tier): array {
$plan = $this->plans->plan($tier.'_monthly');
$plans = $plan !== null ? [[
'key' => $plan['key'],
'label' => $plan['label'],
'interval' => $plan['interval'],
'amount' => $plan['amount'],
'currency' => $plan['currency'],
'price_display' => $plan['price_display'],
'configured' => $plan['configured'],
'price_id_valid' => $plan['price_id_valid'],
'remote_price_exists' => $plan['remote_price_exists'] ?? false,
]] : [];
return [
'tier' => $tier,
'name' => $definition['name'],
'description' => $definition['description'],
'badge' => $definition['badge'],
'featured' => $definition['featured'],
'features' => $definition['features'],
'plans' => $plans,
];
})
->values()
->all();
}
private function academySubscription(User $user): ?Subscription
{
$subscription = $user->subscription($this->plans->subscriptionName());
return $subscription instanceof Subscription
? $subscription->loadMissing('items')
: null;
}
/**
* @return array<string, mixed>|null
*/
private function activePlan(User $user): ?array
{
$subscription = $this->academySubscription($user);
if (! $subscription instanceof Subscription || (! $subscription->active() && ! $subscription->onGracePeriod())) {
return null;
}
$matchedPlan = null;
foreach ($subscription->items as $item) {
$priceId = trim((string) $item->stripe_price);
if ($priceId === '') {
continue;
}
$plan = $this->plans->planForPriceId($priceId);
if ($plan === null) {
continue;
}
if ($matchedPlan === null || $this->planRank((string) $plan['tier']) > $this->planRank((string) $matchedPlan['tier'])) {
$matchedPlan = $plan;
}
}
if ($matchedPlan !== null) {
return $matchedPlan;
}
$fallbackPriceId = trim((string) $subscription->stripe_price);
return $fallbackPriceId !== '' ? $this->plans->planForPriceId($fallbackPriceId) : null;
}
private function planRank(string $tier): int
{
return match (strtolower(trim($tier))) {
'admin' => 40,
'pro' => 30,
'creator' => 20,
default => 10,
};
}
private function billingDisabledResponse(\Illuminate\Http\Request $request, string $message): \Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse
{
$payload = [
'ok' => false,
'code' => 'academy_payments_disabled',
'message' => $message,
];
if ($request->expectsJson()) {
return \response()->json($payload, 423);
}
return \redirect()->route('academy.pricing')->with('error', $message);
}
private function missingPriceIdResponse(\Illuminate\Http\Request $request, string $planKey): \Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse
{
$message = 'The selected Academy plan is not configured yet. Please try again later.';
if ($request->expectsJson()) {
return \response()->json([
'ok' => false,
'code' => 'academy_billing_price_missing',
'message' => $message,
'plan' => $planKey,
], 422);
}
return \redirect()->route('academy.pricing')->with('error', $message);
}
private function invalidPriceIdResponse(\Illuminate\Http\Request $request, string $planKey): \Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse
{
$message = 'The selected Academy plan is misconfigured. Please contact support before continuing.';
if ($request->expectsJson()) {
return \response()->json([
'ok' => false,
'code' => 'academy_billing_price_invalid',
'message' => $message,
'plan' => $planKey,
], 422);
}
return \redirect()->route('academy.pricing')->with('error', $message);
}
private function checkoutErrorResponse(\Illuminate\Http\Request $request, \Throwable $exception): \Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse
{
$message = 'Academy checkout could not be started right now.';
if (app()->hasDebugModeEnabled() && trim($exception->getMessage()) !== '') {
$message .= ' '.$exception->getMessage();
}
if ($request->expectsJson()) {
return \response()->json([
'ok' => false,
'code' => 'academy_billing_checkout_failed',
'message' => $message,
], 422);
}
return \redirect()->route('academy.pricing')->with('error', $message);
}
}
@@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Models\AcademyChallenge;
use App\Services\Academy\AcademyAccessService;
use App\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;
use Inertia\Response;
final class AcademyChallengeController extends Controller
{
public function __construct(
private readonly AcademyAccessService $access,
private readonly AcademyInteractionService $interactions,
)
{
}
public function index(Request $request): Response
{
abort_unless((bool) config('academy.enabled', true), 404);
abort_unless((bool) config('academy.challenges_enabled', true), 404);
$challenges = AcademyChallenge::query()
->publiclyVisible()
->latest('starts_at')
->paginate(12)
->withQueryString();
$challenges->getCollection()->transform(fn (AcademyChallenge $challenge): array => $this->access->challengePayload($challenge, $request->user(), true));
$seo = app(SeoFactory::class)
->collectionListing(
'Academy Challenges — Skinbase',
'Join Academy creative briefs, review accepted submissions, and explore current and archived AI Academy challenges.',
route('academy.challenges.index'),
)
->toArray();
$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',
'title' => 'Academy challenges',
'description' => 'Creative briefs for wallpapers, worlds, covers, and prompt-driven visual experiments.',
'seo' => $seo,
'items' => $challenges,
'filters' => [],
'categories' => [],
'pricingUrl' => route('academy.pricing'),
'analytics' => [
'enabled' => true,
'contentType' => null,
'contentId' => null,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_challenges_index',
'isPremium' => false,
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('academy');
}
public function show(Request $request, string $slug): Response
{
abort_unless((bool) config('academy.enabled', true), 404);
abort_unless((bool) config('academy.challenges_enabled', true), 404);
$challenge = AcademyChallenge::query()
->with(['submissions' => fn ($query) => $query->approved()->with(['user:id,name,username', 'artwork'])])
->publiclyVisible()
->where('slug', $slug)
->firstOrFail();
$payload = $this->access->challengePayload($challenge, $request->user(), true);
$payload['submissions'] = $challenge->submissions->map(fn ($submission): array => [
'id' => (int) $submission->id,
'user' => $submission->user ? [
'id' => (int) $submission->user->id,
'name' => (string) $submission->user->name,
'username' => (string) ($submission->user->username ?? ''),
] : null,
'artwork' => $submission->artwork ? [
'id' => (int) $submission->artwork->id,
'title' => (string) ($submission->artwork->title ?? 'Untitled artwork'),
'thumb_url' => $submission->artwork->thumbUrl('md'),
] : null,
'submitted_at' => $submission->submitted_at?->toISOString(),
])->values()->all();
$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);
return Inertia::render('Academy/Show', [
'pageType' => 'challenge',
'item' => $payload,
'seo' => $seo,
'pricingUrl' => route('academy.pricing'),
'submitUrl' => $request->user() ? route('academy.challenges.submit', ['slug' => $challenge->slug]) : null,
'interaction' => $interaction,
'interactionRoutes' => [
'like' => route('academy.interactions.like'),
'save' => route('academy.interactions.save'),
],
'loginUrl' => route('login'),
'analytics' => [
'enabled' => true,
'contentType' => AcademyAnalyticsContentType::CHALLENGE,
'contentId' => (int) $challenge->id,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_challenge_show',
'isPremium' => (string) ($challenge->access_level ?? 'free') !== 'free',
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => (bool) ($payload['locked'] ?? false),
],
])->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 !== []);
}
}
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Http\Requests\Academy\StoreAcademyChallengeSubmissionRequest;
use App\Models\AcademyChallenge;
use App\Models\Artwork;
use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyChallengeService;
use App\Support\Seo\SeoFactory;
use Inertia\Inertia;
use Inertia\Response;
use Illuminate\Http\RedirectResponse;
final class AcademyChallengeSubmissionController extends Controller
{
public function __construct(
private readonly AcademyAccessService $access,
private readonly AcademyChallengeService $challenges,
) {
}
public function create(string $slug): Response
{
abort_unless((bool) config('academy.enabled', true), 404);
abort_unless((bool) config('academy.challenges_enabled', true), 404);
$challenge = AcademyChallenge::query()->where('slug', $slug)->firstOrFail();
abort_unless($this->access->canAccessChallenge(request()->user(), $challenge), 403);
$seo = app(SeoFactory::class)->collectionPage(
'Submit to ' . $challenge->title . ' — Skinbase Academy',
'Attach one of your artworks to this Academy challenge submission.',
route('academy.challenges.submit', ['slug' => $challenge->slug]),
null,
false,
)->toArray();
return Inertia::render('Academy/ChallengeSubmit', [
'seo' => $seo,
'challenge' => $this->access->challengePayload($challenge, request()->user(), true),
'artworks' => $this->challenges->eligibleArtworkOptions(request()->user())->map(fn (Artwork $artwork): array => [
'id' => (int) $artwork->id,
'title' => (string) ($artwork->title ?? 'Untitled artwork'),
'thumb_url' => $artwork->thumbUrl('md'),
'published_at' => $artwork->published_at?->toISOString(),
])->values()->all(),
'submitUrl' => route('academy.challenges.submit.store', ['slug' => $challenge->slug]),
])->rootView('academy');
}
public function store(StoreAcademyChallengeSubmissionRequest $request, string $slug): RedirectResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
abort_unless((bool) config('academy.challenges_enabled', true), 404);
$challenge = AcademyChallenge::query()->where('slug', $slug)->firstOrFail();
$artwork = Artwork::query()->findOrFail((int) $request->validated('artwork_id'));
$this->challenges->submit($request->user(), $challenge, $artwork, $request->validated());
return redirect()->route('academy.challenges.show', ['slug' => $challenge->slug])
->with('success', 'Challenge submission received and queued for review.');
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
final class AcademyCheckoutController extends Controller
{
public function store(Request $request, string $plan): JsonResponse|RedirectResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
// TODO: Replace this placeholder with Laravel Cashier + Stripe Checkout when academy payments are enabled.
if (! (bool) config('academy.payments_enabled', false)) {
$payload = [
'ok' => false,
'code' => 'academy_payments_disabled',
'message' => 'Academy payments are disabled for this launch phase.',
'plan' => $plan,
];
if ($request->expectsJson()) {
return response()->json($payload, 423);
}
return redirect()->route('academy.pricing')->with('error', $payload['message']);
}
return response()->json([
'ok' => false,
'code' => 'academy_checkout_not_implemented',
'message' => 'Checkout is not implemented yet.',
'plan' => $plan,
], 501);
}
}
@@ -0,0 +1,232 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Models\AcademyCourse;
use App\Models\AcademyCourseLesson;
use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyInteractionService;
use App\Services\Academy\AcademyCacheService;
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;
final class AcademyCourseController extends Controller
{
public function __construct(
private readonly AcademyAccessService $access,
private readonly AcademyCacheService $cache,
private readonly AcademyCourseNavigationService $navigation,
private readonly AcademyCourseProgressService $progress,
private readonly AcademyInteractionService $interactions,
) {
}
public function index(Request $request): Response
{
abort_unless((bool) config('academy.enabled', true), 404);
$filters = $request->validate([
'difficulty' => ['nullable', 'string', 'max:40'],
'access' => ['nullable', 'string', 'max:40'],
]);
$hasActiveFilters = filled($filters['difficulty'] ?? null) || filled($filters['access'] ?? null);
$query = AcademyCourse::query()->published()->ordered();
if (filled($filters['difficulty'] ?? null)) {
$query->where('difficulty', $filters['difficulty']);
}
if (filled($filters['access'] ?? null)) {
$query->where('access_level', $filters['access']);
}
$courses = $query->paginate(12)->withQueryString();
$courses->getCollection()->transform(function (AcademyCourse $course) use ($request): array {
return $this->access->coursePayload($course, $request->user(), [
'progress' => $this->progress->getProgress($request->user(), $course),
]);
});
$featuredCourses = collect($this->cache->featuredCourses())->map(fn (AcademyCourse $course): array => $this->access->coursePayload($course, $request->user(), [
'progress' => $this->progress->getProgress($request->user(), $course),
]))->values();
$seoCourses = $featuredCourses
->concat(collect($courses->items()))
->unique(fn (array $course): string => (string) ($course['slug'] ?? ''))
->values();
$seo = app(SeoFactory::class)
->academyCourseListingPage(
'Academy Courses — Skinbase',
'Follow guided Skinbase AI Academy courses built from reusable lessons, chapters, and creator workflows.',
route('academy.courses.index', $request->query()),
$seoCourses,
[
['name' => 'Academy', 'url' => route('academy.index')],
['name' => 'Courses', 'url' => route('academy.courses.index')],
],
)
->toArray();
if ($hasActiveFilters) {
$seo = SeoDataBuilder::fromArray($seo)
->indexable(false)
->build()
->toArray();
}
return Inertia::render('Academy/CoursesIndex', [
'seo' => $seo,
'title' => 'Academy courses',
'description' => 'Guided learning paths built from reusable Academy lessons and creator workflows.',
'items' => $courses,
'featuredCourses' => $featuredCourses->all(),
'filters' => $filters,
'pricingUrl' => route('academy.pricing'),
'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,
'contentId' => null,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_courses_index',
'isPremium' => false,
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('academy');
}
public function show(Request $request, AcademyCourse $course): Response
{
abort_unless((bool) config('academy.enabled', true), 404);
abort_unless($course->isPublished(), 404);
$course->load(['sections', 'courseLessons.section', 'courseLessons.lesson.category']);
$progress = $this->progress->getProgress($request->user(), $course);
$completedLessonIds = $request->user() ? $this->progress->getCompletedLessonIds($request->user(), $course) : [];
$orderedLessons = $this->navigation->orderedCourseLessons($course);
$stepMeta = $orderedLessons
->values()
->mapWithKeys(fn (AcademyCourseLesson $courseLesson, int $index): array => [
$courseLesson->id => [
'course_step_number' => $index + 1,
'course_step_label' => sprintf('Step %02d', $index + 1),
],
]);
$sections = $course->sections
->sortBy([['order_num', 'asc'], ['id', 'asc']])
->values()
->map(function ($section) use ($completedLessonIds, $orderedLessons, $request, $stepMeta): array {
$sectionLessons = $orderedLessons
->where('section_id', $section->id)
->values()
->map(fn (AcademyCourseLesson $courseLesson): array => $this->access->courseLessonPayload($courseLesson, $request->user(), false, [
'completed_lesson_ids' => $completedLessonIds,
...((array) $stepMeta->get($courseLesson->id, [])),
]))
->all();
return [
'id' => (int) $section->id,
'title' => (string) $section->title,
'slug' => (string) ($section->slug ?? ''),
'description' => (string) ($section->description ?? ''),
'order_num' => (int) ($section->order_num ?? 0),
'is_visible' => (bool) ($section->is_visible ?? true),
'lessons' => $sectionLessons,
];
})
->all();
$unsectionedLessons = $orderedLessons
->whereNull('section_id')
->values()
->map(fn (AcademyCourseLesson $courseLesson): array => $this->access->courseLessonPayload($courseLesson, $request->user(), false, [
'completed_lesson_ids' => $completedLessonIds,
...((array) $stepMeta->get($courseLesson->id, [])),
]))
->all();
$coursePayload = $this->access->coursePayload($course, $request->user(), ['progress' => $progress]);
$courseKeywords = collect(explode(',', (string) ($course->meta_keywords ?? '')))
->map(fn (string $keyword): string => trim($keyword))
->filter()
->values()
->all();
$courseImage = (string) ($coursePayload['cover_image_url'] ?? $coursePayload['teaser_image_url'] ?? $course->og_image ?? $course->cover_image ?? $course->teaser_image ?? '');
$seo = app(SeoFactory::class)
->academyCoursePage(
(string) ($course->seo_title ?: ($course->title . ' — Skinbase Academy')),
(string) ($course->seo_description ?: $course->excerpt ?: 'Skinbase Academy course'),
route('academy.courses.show', ['course' => $course->slug]),
$courseImage,
[
['name' => 'Academy', 'url' => route('academy.index')],
['name' => 'Courses', 'url' => route('academy.courses.index')],
['name' => (string) $course->title, 'url' => route('academy.courses.show', ['course' => $course->slug])],
],
$courseKeywords,
$course->published_at?->toAtomString(),
$course->updated_at?->toAtomString(),
(string) ($course->access_level ?? ''),
(string) ($course->difficulty ?? ''),
(int) ($course->estimated_minutes ?? 0),
$orderedLessons
->values()
->map(fn (AcademyCourseLesson $courseLesson): array => $this->access->courseLessonPayload($courseLesson, $request->user(), false, [
'completed_lesson_ids' => $completedLessonIds,
...((array) $stepMeta->get($courseLesson->id, [])),
]))
->all(),
)
->toArray();
$interaction = $this->interactions->getInteractionState($request->user(), AcademyAnalyticsContentType::COURSE, (int) $course->id);
return Inertia::render('Academy/CoursesShow', [
'seo' => $seo,
'course' => $coursePayload,
'sections' => $sections,
'unsectionedLessons' => $unsectionedLessons,
'pricingUrl' => route('academy.pricing'),
'startUrl' => $request->user() ? route('academy.courses.start', ['course' => $course->slug]) : null,
'interaction' => $interaction,
'interactionRoutes' => [
'like' => route('academy.interactions.like'),
'save' => route('academy.interactions.save'),
],
'loginUrl' => route('login'),
'analytics' => [
'enabled' => true,
'contentType' => AcademyAnalyticsContentType::COURSE,
'contentId' => (int) $course->id,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_course_show',
'isPremium' => (string) ($course->access_level ?? 'free') !== 'free',
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => false,
],
])->rootView('academy');
}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Models\AcademyCourse;
use App\Services\Academy\AcademyProgressService;
use App\Services\Academy\AcademyCourseProgressService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
final class AcademyCourseEnrollmentController extends Controller
{
public function __construct(
private readonly AcademyCourseProgressService $progress,
private readonly AcademyProgressService $academyProgress,
) {
}
public function start(Request $request, AcademyCourse $course): RedirectResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
abort_unless($course->isPublished(), 404);
$this->academyProgress->startCourse($request->user(), (int) $course->id, $request);
$continueLesson = $this->progress->getContinueLesson($request->user(), $course);
if ($continueLesson?->lesson) {
return redirect()->route('academy.courses.lessons.show', ['course' => $course->slug, 'lesson' => $continueLesson->lesson->slug]);
}
return redirect()->route('academy.courses.show', ['course' => $course->slug]);
}
}
@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Models\AcademyCourse;
use App\Models\AcademyLesson;
use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyInteractionService;
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;
use Inertia\Response;
final class AcademyCourseLessonController extends Controller
{
public function __construct(
private readonly AcademyAccessService $access,
private readonly AcademyCourseNavigationService $navigation,
private readonly AcademyCourseProgressService $progress,
private readonly AcademyInteractionService $interactions,
) {
}
public function show(Request $request, AcademyCourse $course, AcademyLesson $lesson): Response
{
abort_unless((bool) config('academy.enabled', true), 404);
abort_unless($course->isPublished(), 404);
$course->load(['sections', 'courseLessons.section', 'courseLessons.lesson.category']);
$courseLesson = $this->navigation->findCourseLesson($course, $lesson);
abort_unless($courseLesson instanceof \App\Models\AcademyCourseLesson, 404);
if ($request->user()) {
$this->progress->updateLastLesson($request->user(), $course, $lesson);
$this->progress->markCourseCompletedIfFinished($request->user(), $course);
}
$progress = $this->progress->getProgress($request->user(), $course);
$previousLesson = $this->navigation->previousLesson($course, $lesson);
$nextLesson = $this->navigation->nextLesson($course, $lesson);
$courseOutline = $this->navigation->orderedCourseLessons($course)
->map(fn (\App\Models\AcademyCourseLesson $entry): array => $this->access->courseLessonPayload($entry, $request->user()))
->values()
->all();
$payload = $this->access->courseLessonPayload($courseLesson, $request->user(), true);
$canonical = route('academy.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)),
$description,
$canonical,
(string) ($payload['article_cover_image_url'] ?? $payload['cover_image_url'] ?? $lesson->cover_image ?? ''),
[
['name' => 'Academy', 'url' => route('academy.index')],
['name' => 'Courses', 'url' => route('academy.courses.index')],
['name' => (string) $course->title, 'url' => route('academy.courses.show', ['course' => $course->slug])],
['name' => (string) $lesson->title, 'url' => $canonical],
],
array_values((array) ($payload['tags'] ?? [])),
$lesson->published_at?->toAtomString(),
$lesson->updated_at?->toAtomString(),
(string) $course->title,
)->toArray();
$seo = SeoDataBuilder::fromArray($seo)
->indexable(false)
->build()
->toArray();
$interaction = $this->interactions->getInteractionState($request->user(), AcademyAnalyticsContentType::LESSON, (int) $lesson->id);
return Inertia::render('Academy/Show', [
'pageType' => 'lesson',
'item' => $payload,
'relatedLessons' => [],
'relatedCourses' => [],
'previousLesson' => $previousLesson ? $this->access->courseLessonPayload($previousLesson, $request->user()) : null,
'nextLesson' => $nextLesson ? $this->access->courseLessonPayload($nextLesson, $request->user()) : null,
'seo' => $seo,
'pricingUrl' => route('academy.pricing'),
'completeUrl' => $request->user() ? route('academy.lessons.complete', ['lesson' => $lesson->id]) : null,
'completed' => $request->user()?->academyLessonProgress()->where('lesson_id', $lesson->id)->whereNotNull('completed_at')->exists() ?? false,
'interaction' => $interaction,
'interactionRoutes' => [
'like' => route('academy.interactions.like'),
'save' => route('academy.interactions.save'),
],
'loginUrl' => route('login'),
'progressRoutes' => [
'startLesson' => $request->user() ? route('academy.progress.lesson.start') : null,
],
'analytics' => [
'enabled' => true,
'contentType' => AcademyAnalyticsContentType::LESSON,
'contentId' => (int) $lesson->id,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_course_lesson_show',
'isPremium' => (string) ($payload['access_level'] ?? 'free') !== 'free',
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => (bool) ($payload['locked'] ?? false),
],
'courseContext' => [
'id' => (int) $course->id,
'title' => (string) $course->title,
'slug' => (string) $course->slug,
'subtitle' => (string) ($course->subtitle ?? ''),
'showUrl' => route('academy.courses.show', ['course' => $course->slug]),
'completePayload' => ['course_id' => $course->id],
'progress' => [
'percent' => (int) ($progress['progress_percent'] ?? 0),
'completedRequired' => (int) ($progress['completed_required'] ?? 0),
'totalRequired' => (int) ($progress['total_required'] ?? 0),
'completed' => (bool) ($progress['completed'] ?? false),
],
'outline' => $courseOutline,
],
])->rootView('academy');
}
}
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Models\AcademyChallenge;
use App\Models\AcademyCourse;
use App\Models\AcademyLesson;
use App\Models\AcademyPromptTemplate;
use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyCacheService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class AcademyHomeController extends Controller
{
public function __construct(
private readonly AcademyAccessService $access,
private readonly AcademyCacheService $cache,
) {
}
public function index(Request $request): Response
{
abort_unless((bool) config('academy.enabled', true), 404);
$canonical = route('academy.index');
$seo = app(SeoFactory::class)
->collectionPage(
'Skinbase AI Academy — Skinbase',
'Learn AI-powered creativity for wallpapers, digital art, skins, news covers, and visual worlds inside Skinbase.',
$canonical,
)
->toArray();
$seo['og_type'] = 'website';
$home = $this->cache->homePayload(function (): array {
return [
'featuredLessons' => $this->cache->featuredLessons(),
'featuredCourses' => $this->cache->featuredCourses(),
'featuredPrompts' => $this->cache->featuredPrompts(),
'featuredChallenges' => (bool) config('academy.challenges_enabled', true)
? $this->cache->featuredChallenges()
: [],
'lessonCount' => AcademyLesson::query()->active()->published()->count(),
'courseCount' => AcademyCourse::query()->published()->count(),
'promptCount' => AcademyPromptTemplate::query()->active()->published()->count(),
'challengeCount' => (bool) config('academy.challenges_enabled', true)
? AcademyChallenge::query()->publiclyVisible()->count()
: 0,
];
});
return Inertia::render('Academy/Index', [
'seo' => $seo,
'pricingUrl' => route('academy.pricing'),
'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'),
],
'featureFlags' => [
'paymentsEnabled' => (bool) config('academy.payments_enabled', false),
'challengesEnabled' => (bool) config('academy.challenges_enabled', true),
'badgesEnabled' => (bool) config('academy.badges_enabled', true),
],
'stats' => [
'lessonCount' => (int) $home['lessonCount'],
'courseCount' => (int) $home['courseCount'],
'promptCount' => (int) $home['promptCount'],
'challengeCount' => (int) $home['challengeCount'],
],
'featuredCourses' => collect($home['featuredCourses'])->map(fn (AcademyCourse $course): array => $this->access->coursePayload($course, $request->user()))->values()->all(),
'featuredLessons' => collect($home['featuredLessons'])->map(fn (AcademyLesson $lesson): array => $this->access->lessonPayload($lesson, $request->user()))->values()->all(),
'featuredPrompts' => collect($home['featuredPrompts'])->map(fn (AcademyPromptTemplate $prompt): array => $this->access->promptPayload($prompt, $request->user()))->values()->all(),
'featuredChallenges' => collect($home['featuredChallenges'])->map(fn (AcademyChallenge $challenge): array => $this->access->challengePayload($challenge, $request->user(), true))->values()->all(),
'analytics' => [
'enabled' => true,
'contentType' => AcademyAnalyticsContentType::HOME,
'contentId' => null,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_home',
'isPremium' => false,
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('academy');
}
}
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Services\Academy\AcademyInteractionService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use InvalidArgumentException;
final class AcademyInteractionController extends Controller
{
public function __construct(private readonly AcademyInteractionService $interactions)
{
}
public function like(Request $request): JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
$validated = $this->validatePayload($request);
try {
$payload = $this->interactions->toggleLike($request->user(), (string) $validated['content_type'], (int) $validated['content_id'], $request);
} catch (InvalidArgumentException $exception) {
return response()->json(['message' => $exception->getMessage()], 422);
}
return response()->json($payload);
}
public function save(Request $request): JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
$validated = $this->validatePayload($request);
try {
$payload = $this->interactions->toggleSave($request->user(), (string) $validated['content_type'], (int) $validated['content_id'], $request);
} catch (InvalidArgumentException $exception) {
return response()->json(['message' => $exception->getMessage()], 422);
}
return response()->json($payload);
}
/**
* @return array<string, mixed>
*/
private function validatePayload(Request $request): array
{
return $request->validate([
'content_type' => ['required', 'string', Rule::in([
AcademyAnalyticsContentType::PROMPT,
AcademyAnalyticsContentType::LESSON,
AcademyAnalyticsContentType::COURSE,
AcademyAnalyticsContentType::PROMPT_PACK,
AcademyAnalyticsContentType::CHALLENGE,
])],
'content_id' => ['required', 'integer', 'min:1'],
]);
}
}
@@ -0,0 +1,236 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Models\AcademyCourse;
use App\Models\AcademyLesson;
use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyAnalyticsService;
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;
use Inertia\Response;
final class AcademyLessonController extends Controller
{
public function __construct(
private readonly AcademyAccessService $access,
private readonly AcademyCacheService $cache,
private readonly AcademyAnalyticsService $analytics,
private readonly AcademyInteractionService $interactions,
) {}
public function index(Request $request): Response|JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
$filters = $request->validate([
'q' => ['nullable', 'string', 'max:120'],
'category' => ['nullable', 'string', 'max:140'],
'difficulty' => ['nullable', 'string', 'max:40'],
]);
$hasActiveFilters = filled($filters['q'] ?? null)
|| filled($filters['category'] ?? null)
|| filled($filters['difficulty'] ?? null);
$query = AcademyLesson::query()
->with('category')
->active()
->published()
->orderedForCourse();
if (filled($filters['q'] ?? null)) {
$query->where(function ($builder) use ($filters): void {
$builder->where('title', 'like', '%'.$filters['q'].'%')
->orWhere('excerpt', 'like', '%'.$filters['q'].'%');
});
}
if (filled($filters['category'] ?? null)) {
$query->whereHas('category', fn ($builder) => $builder->where('slug', $filters['category']));
}
if (filled($filters['difficulty'] ?? null)) {
$query->where('difficulty', $filters['difficulty']);
}
$lessons = $query->paginate(12)->withQueryString();
$lessons->getCollection()->transform(fn (AcademyLesson $lesson): array => $this->access->lessonPayload($lesson, $request->user()));
if (filled($filters['q'] ?? null)) {
$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',
'Browse Skinbase AI Academy lessons covering prompting, workflow cleanup, ethics, and upload-ready creative workflows.',
route('academy.lessons.index', $request->query()),
)
->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,
'contentId' => null,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_lessons_index',
'search' => filled($filters['q'] ?? null) ? [
'query' => (string) $filters['q'],
'resultsCount' => (int) $lessons->total(),
] : null,
'isPremium' => false,
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('academy');
}
public function show(Request $request, string $slug): Response
{
abort_unless((bool) config('academy.enabled', true), 404);
$lesson = AcademyLesson::query()
->with(['category', 'activeBlocks.activeComparisonResults'])
->active()
->published()
->where('slug', $slug)
->firstOrFail();
$payload = $this->access->lessonPayload($lesson, $request->user(), true);
$courseQuery = AcademyLesson::query()
->with('category')
->active()
->published();
if (filled($lesson->series_name)) {
$courseQuery->where('series_name', $lesson->series_name);
} elseif ($lesson->category_id !== null) {
$courseQuery->where('category_id', $lesson->category_id);
} else {
$courseQuery->whereKey($lesson->id);
}
$courseLessons = $courseQuery
->orderedForCourse()
->get()
->filter(fn (AcademyLesson $courseLesson): bool => $this->access->canAccessLesson($request->user(), $courseLesson))
->values();
$currentIndex = $courseLessons->search(fn (AcademyLesson $courseLesson): bool => $courseLesson->is($lesson));
$previousLesson = is_int($currentIndex) && $currentIndex > 0
? $courseLessons->get($currentIndex - 1)
: null;
$nextLesson = is_int($currentIndex) && $currentIndex < ($courseLessons->count() - 1)
? $courseLessons->get($currentIndex + 1)
: null;
$relatedLessons = $courseLessons
->reject(fn (AcademyLesson $courseLesson): bool => $courseLesson->is($lesson))
->take(6)
->map(fn (AcademyLesson $relatedLesson): array => $this->access->lessonPayload($relatedLesson, $request->user()))
->values()
->all();
$relatedCourses = AcademyCourse::query()
->published()
->ordered()
->whereHas('courseLessons', fn ($builder) => $builder->where('lesson_id', $lesson->id))
->limit(3)
->get()
->map(fn (AcademyCourse $course): array => $this->access->coursePayload($course, $request->user()))
->values()
->all();
$canonical = route('academy.lessons.show', ['slug' => $lesson->slug]);
$description = Str::limit(trim((string) ($lesson->seo_description ?? $lesson->excerpt ?? 'Skinbase Academy lesson.')), 160, '...');
$seo = app(SeoFactory::class)->academyLessonPage(
(string) ($lesson->seo_title ?? ($lesson->title.' — Skinbase Academy')),
$description,
$canonical,
(string) ($payload['article_cover_image_url'] ?? $payload['cover_image_url'] ?? $lesson->cover_image ?? ''),
[
['name' => 'Academy', 'url' => route('academy.index')],
['name' => 'Lessons', 'url' => route('academy.lessons.index')],
['name' => (string) $lesson->title, 'url' => $canonical],
],
array_values((array) ($payload['tags'] ?? [])),
$lesson->published_at?->toAtomString(),
$lesson->updated_at?->toAtomString(),
(string) ($lesson->series_name ?: $lesson->category?->name ?: 'Academy'),
)->toArray();
$interaction = $this->interactions->getInteractionState($request->user(), AcademyAnalyticsContentType::LESSON, (int) $lesson->id);
return Inertia::render('Academy/Show', [
'pageType' => 'lesson',
'item' => $payload,
'relatedLessons' => $relatedLessons,
'relatedCourses' => $relatedCourses,
'previousLesson' => $previousLesson ? $this->access->lessonPayload($previousLesson, $request->user()) : null,
'nextLesson' => $nextLesson ? $this->access->lessonPayload($nextLesson, $request->user()) : null,
'seo' => $seo,
'pricingUrl' => route('academy.pricing'),
'completeUrl' => $request->user() ? route('academy.lessons.complete', ['lesson' => $lesson->id]) : null,
'completed' => $request->user()?->academyLessonProgress()->where('lesson_id', $lesson->id)->whereNotNull('completed_at')->exists() ?? false,
'interaction' => $interaction,
'interactionRoutes' => [
'like' => route('academy.interactions.like'),
'save' => route('academy.interactions.save'),
],
'loginUrl' => route('login'),
'progressRoutes' => [
'startLesson' => $request->user() ? route('academy.progress.lesson.start') : null,
],
'analytics' => [
'enabled' => true,
'contentType' => AcademyAnalyticsContentType::LESSON,
'contentId' => (int) $lesson->id,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_lesson_show',
'isPremium' => (string) ($lesson->access_level ?? 'free') !== 'free',
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => (bool) ($payload['locked'] ?? false),
],
])->rootView('academy');
}
}
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class AcademyPricingController extends Controller
{
public function index(Request $request): Response
{
abort_unless((bool) config('academy.enabled', true), 404);
$canonical = route('academy.pricing');
$seo = app(SeoFactory::class)
->collectionPage(
'Skinbase AI Academy Pricing — Skinbase',
'Compare Skinbase AI Academy Explorer, Creator, and Pro plans and preview premium access before payments go live.',
$canonical,
)
->toArray();
$seo['og_type'] = 'website';
return Inertia::render('Academy/Pricing', [
'seo' => $seo,
'paymentsEnabled' => (bool) config('academy.payments_enabled', false),
'plans' => [
[
'name' => 'Explorer',
'price' => '€0',
'interval' => '/ month',
'badge' => 'Free',
'features' => [
'Beginner AI lessons',
'Basic prompting tutorials',
'Public prompt previews',
'Public Academy challenges',
],
],
[
'name' => 'Creator',
'price' => '€4.99',
'interval' => '/ month',
'badge' => 'Premium',
'features' => [
'Full prompt library',
'Premium lessons',
'Prompt packs',
'Saved prompt collections',
],
],
[
'name' => 'Pro',
'price' => '€9.99',
'interval' => '/ month',
'badge' => 'Advanced',
'features' => [
'Advanced AI workflows',
'World Builder kits',
'Pro challenges',
'Profile highlight badge',
],
],
],
'analytics' => [
'enabled' => true,
'contentType' => AcademyAnalyticsContentType::UPGRADE,
'contentId' => null,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_pricing',
'isPremium' => false,
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('academy');
}
}
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Models\AcademyCourse;
use App\Models\AcademyLesson;
use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyProgressService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class AcademyProgressController extends Controller
{
public function __construct(
private readonly AcademyAccessService $access,
private readonly AcademyProgressService $progress,
) {
}
public function startLesson(Request $request): JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
$validated = $request->validate([
'lesson_id' => ['required', 'integer', 'min:1'],
'course_id' => ['nullable', 'integer', 'min:1'],
]);
$lesson = AcademyLesson::query()->findOrFail((int) $validated['lesson_id']);
abort_unless($this->access->canAccessLesson($request->user(), $lesson), 403);
$courseId = $request->filled('course_id') ? (int) $validated['course_id'] : null;
if ($courseId !== null) {
$course = AcademyCourse::query()->published()->findOrFail($courseId);
abort_unless($course->courseLessons()->where('lesson_id', $lesson->id)->exists(), 403);
}
$record = $this->progress->startLesson($request->user(), (int) $lesson->id, $courseId, $request);
return response()->json([
'ok' => true,
'status' => (string) $record->status,
]);
}
public function complete(Request $request, AcademyLesson $lesson): JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
abort_unless($this->access->canAccessLesson($request->user(), $lesson), 403);
$course = null;
if ($request->filled('course_id')) {
$course = AcademyCourse::query()->published()->find($request->integer('course_id'));
}
$record = $this->progress->markLessonComplete($request->user(), $lesson, $course, $request);
return response()->json([
'ok' => true,
'completed' => true,
'completed_at' => $record->completed_at?->toISOString(),
]);
}
public function completeLesson(Request $request): JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
$validated = $request->validate([
'lesson_id' => ['required', 'integer', 'min:1'],
'course_id' => ['nullable', 'integer', 'min:1'],
]);
$lesson = AcademyLesson::query()->findOrFail((int) $validated['lesson_id']);
return $this->complete($request, $lesson);
}
public function startCourse(Request $request): JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
$validated = $request->validate([
'course_id' => ['required', 'integer', 'min:1'],
]);
$course = AcademyCourse::query()->published()->findOrFail((int) $validated['course_id']);
$record = $this->progress->startCourse($request->user(), (int) $course->id, $request);
return response()->json([
'ok' => true,
'status' => (string) $record->status,
'progress_percent' => (int) $record->progress_percent,
]);
}
public function completeCourse(Request $request): JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
$validated = $request->validate([
'course_id' => ['required', 'integer', 'min:1'],
]);
$course = AcademyCourse::query()->published()->findOrFail((int) $validated['course_id']);
$record = $this->progress->completeCourse($request->user(), (int) $course->id, $request);
return response()->json([
'ok' => true,
'status' => (string) $record->status,
'progress_percent' => (int) $record->progress_percent,
'completed' => (string) $record->status === 'completed',
]);
}
}
@@ -0,0 +1,488 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Models\AcademyPromptTemplate;
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;
final class AcademyPromptController extends Controller
{
public function __construct(
private readonly AcademyAccessService $access,
private readonly AcademyCacheService $cache,
private readonly AcademyAnalyticsService $analytics,
private readonly AcademyInteractionService $interactions,
private readonly AcademyPopularityService $popularity,
) {
}
public function index(Request $request): Response|JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
$filters = $request->validate([
'q' => ['nullable', 'string', 'max:120'],
'category' => ['nullable', 'string', 'max:140'],
'difficulty' => ['nullable', 'string', 'max:40'],
'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')
->active()
->published()
->latest('published_at');
if (filled($filters['q'] ?? null)) {
$query->where(function ($builder) use ($filters): void {
$builder->where('title', 'like', '%' . $filters['q'] . '%')
->orWhere('excerpt', 'like', '%' . $filters['q'] . '%');
});
}
if (filled($filters['category'] ?? null)) {
$query->whereHas('category', fn ($builder) => $builder->where('slug', $filters['category']));
}
if (filled($filters['difficulty'] ?? null)) {
$query->where('difficulty', $filters['difficulty']);
}
if (filled($filters['tag'] ?? null)) {
$tag = $filters['tag'];
$query->whereJsonContains('tags', $tag);
}
$prompts = $query->paginate(12)->withQueryString();
$prompts->getCollection()->transform(fn (AcademyPromptTemplate $prompt): array => $this->access->promptPayload($prompt, $request->user()));
if (filled($filters['q'] ?? null)) {
$this->analytics->trackSearch((string) $filters['q'], (int) $prompts->total(), array_filter($filters), $request);
}
if ($request->expectsJson()) {
return response()->json($prompts);
}
$seo = app(SeoFactory::class)
->collectionListing(
'Academy Prompts — Skinbase',
'Browse AI prompt templates for wallpapers, worlds, editorial covers, robots, pixel art, and creator workflows.',
route('academy.prompts.index', $request->query()),
)
->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' => AcademyAnalyticsContentType::PROMPT_LIBRARY,
'contentId' => null,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_prompts_index',
'search' => filled($filters['q'] ?? null) ? [
'query' => (string) $filters['q'],
'resultsCount' => (int) $prompts->total(),
] : null,
'isPremium' => false,
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->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
{
abort_unless((bool) config('academy.enabled', true), 404);
$prompt = AcademyPromptTemplate::query()
->with('category')
->active()
->published()
->where('slug', $slug)
->firstOrFail();
$payload = $this->access->promptPayload($prompt, $request->user(), true);
$canonical = route('academy.prompts.show', ['slug' => $prompt->slug]);
$description = Str::limit(trim((string) ($prompt->seo_description ?? $prompt->excerpt ?? 'Skinbase Academy prompt template.')), 160, '...');
$seo = app(SeoFactory::class)->collectionPage(
(string) ($prompt->seo_title ?? ($prompt->title . ' — Skinbase Academy')),
$description,
$canonical,
$payload['preview_image'] ?? null,
)->toArray();
$existingSchemas = $seo['json_ld'] ?? [];
if (! is_array($existingSchemas) || ! array_is_list($existingSchemas)) {
$existingSchemas = [$existingSchemas];
}
$seo['json_ld'] = [
...$existingSchemas,
$this->promptStructuredData($payload, $canonical, $description),
];
$canSavePrompt = $request->user() !== null && $this->access->canAccessPrompt($request->user(), $prompt);
$interaction = $this->interactions->getInteractionState($request->user(), AcademyAnalyticsContentType::PROMPT, (int) $prompt->id);
return Inertia::render('Academy/Show', [
'pageType' => 'prompt',
'item' => $payload,
'seo' => $seo,
'pricingUrl' => route('academy.pricing'),
'saveUrl' => $canSavePrompt ? route('academy.prompts.save', ['prompt' => $prompt->id]) : null,
'unsaveUrl' => $canSavePrompt ? route('academy.prompts.unsave', ['prompt' => $prompt->id]) : null,
'saved' => $canSavePrompt ? ($request->user()?->academySavedPrompts()->where('prompt_template_id', $prompt->id)->exists() ?? false) : false,
'interaction' => $interaction,
'interactionRoutes' => [
'like' => route('academy.interactions.like'),
'save' => route('academy.interactions.save'),
],
'loginUrl' => route('login'),
'analytics' => [
'enabled' => true,
'contentType' => AcademyAnalyticsContentType::PROMPT,
'contentId' => (int) $prompt->id,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_prompt_show',
'isPremium' => (string) ($prompt->access_level ?? 'free') !== 'free',
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => (bool) ($payload['locked'] ?? false),
],
])->rootView('academy');
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function promptStructuredData(array $payload, string $canonical, string $description): array
{
$imageUrls = array_values(array_unique(array_filter([
$payload['preview_image'] ?? null,
...collect((array) ($payload['public_examples'] ?? []))
->map(fn (array $example): ?string => $example['image_url'] ?? $example['thumb_url'] ?? null)
->filter()
->values()
->all(),
], fn (mixed $value): bool => is_string($value) && $value !== '')));
$isFree = (string) ($payload['access_level'] ?? 'free') === 'free';
return array_filter([
'@context' => 'https://schema.org',
'@type' => ['CreativeWork', 'LearningResource'],
'name' => (string) ($payload['title'] ?? 'Skinbase Academy prompt'),
'description' => $description,
'url' => $canonical,
'image' => $imageUrls !== [] ? $imageUrls : null,
'isAccessibleForFree' => $isFree,
'hasPart' => $isFree ? null : [
'@type' => 'WebPageElement',
'isAccessibleForFree' => false,
'cssSelector' => '.academy-paywalled-content',
],
], 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();
}
}
@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Models\AcademyPromptPack;
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;
use Inertia\Response;
final class AcademyPromptPackController extends Controller
{
public function __construct(
private readonly AcademyAccessService $access,
private readonly AcademyInteractionService $interactions,
)
{
}
public function index(Request $request): Response
{
abort_unless((bool) config('academy.enabled', true), 404);
$packs = AcademyPromptPack::query()
->active()
->published()
->latest('published_at')
->paginate(12)
->withQueryString();
$packs->getCollection()->transform(fn (AcademyPromptPack $pack): array => $this->access->packPayload($pack, $request->user()));
$seo = app(SeoFactory::class)
->collectionListing(
'Academy Prompt Packs — Skinbase',
'Preview bundled prompt packs for Skinbase wallpapers, worlds, mascots, and editorial workflows.',
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',
'title' => 'Prompt packs',
'description' => 'Curated prompt bundles with free previews and premium pack access tiers.',
'seo' => $seo,
'items' => $packs,
'filters' => [],
'categories' => [],
'pricingUrl' => route('academy.pricing'),
'analytics' => [
'enabled' => true,
'contentType' => AcademyAnalyticsContentType::PROMPT_PACK_LIBRARY,
'contentId' => null,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_packs_index',
'isPremium' => false,
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('academy');
}
public function show(Request $request, string $slug): Response
{
abort_unless((bool) config('academy.enabled', true), 404);
$pack = AcademyPromptPack::query()
->with(['prompts.category'])
->active()
->published()
->where('slug', $slug)
->firstOrFail();
$payload = $this->access->packPayload($pack, $request->user(), true);
$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);
return Inertia::render('Academy/Show', [
'pageType' => 'pack',
'item' => $payload,
'seo' => $seo,
'pricingUrl' => route('academy.pricing'),
'interaction' => $interaction,
'interactionRoutes' => [
'like' => route('academy.interactions.like'),
'save' => route('academy.interactions.save'),
],
'loginUrl' => route('login'),
'analytics' => [
'enabled' => true,
'contentType' => AcademyAnalyticsContentType::PROMPT_PACK,
'contentId' => (int) $pack->id,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_pack_show',
'isPremium' => (string) ($pack->access_level ?? 'free') !== 'free',
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => (bool) ($payload['locked'] ?? false),
],
])->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,47 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Models\AcademyPromptTemplate;
use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyProgressService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class AcademyPromptSaveController extends Controller
{
public function __construct(
private readonly AcademyAccessService $access,
private readonly AcademyProgressService $progress,
) {
}
public function store(Request $request, AcademyPromptTemplate $prompt): JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
abort_unless($this->access->canAccessPrompt($request->user(), $prompt), 403);
$this->progress->savePrompt($request->user(), $prompt);
return response()->json([
'ok' => true,
'saved' => true,
]);
}
public function destroy(Request $request, AcademyPromptTemplate $prompt): JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
abort_unless($this->access->canAccessPrompt($request->user(), $prompt), 403);
$this->progress->unsavePrompt($request->user(), $prompt);
return response()->json([
'ok' => true,
'saved' => false,
]);
}
}
@@ -6,11 +6,19 @@ namespace App\Http\Controllers\Admin;
use App\Enums\UserRole; use App\Enums\UserRole;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\AuthAuditLog;
use App\Models\Artwork; use App\Models\Artwork;
use App\Models\Report;
use App\Models\Story; use App\Models\Story;
use App\Models\Upload;
use App\Models\User; use App\Models\User;
use App\Support\Moderation\ReportTargetResolver;
use Illuminate\Database\Query\Builder;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@@ -32,6 +40,252 @@ final class AdminController extends Controller
]); ]);
} }
public function dailyActivity(Request $request, ReportTargetResolver $reportTargets): Response
{
$selectedDate = $this->resolveActivityDate($request);
$periodStart = $selectedDate->copy()->startOfDay();
$periodEnd = $selectedDate->copy()->endOfDay();
$users = User::query()
->select('id', 'name', 'username', 'email', 'role', 'created_at')
->whereBetween('created_at', [$periodStart, $periodEnd])
->orderByDesc('created_at')
->limit(25)
->get()
->map(fn (User $user): array => [
'id' => (int) $user->id,
'name' => (string) $user->name,
'username' => $user->username,
'email' => (string) $user->email,
'role' => (string) $user->role,
'created_at' => optional($user->created_at)?->toISOString(),
])
->values();
$artworks = Artwork::query()
->with('user:id,name,username')
->select('id', 'title', 'artwork_status', 'created_at', 'user_id', 'hash', 'thumb_ext')
->whereBetween('created_at', [$periodStart, $periodEnd])
->orderByDesc('created_at')
->limit(25)
->get()
->map(fn (Artwork $artwork): array => [
'id' => (int) $artwork->id,
'title' => (string) ($artwork->title ?? 'Untitled artwork'),
'status' => (string) ($artwork->artwork_status ?? 'unknown'),
'thumb' => $artwork->thumbUrl('sm') ?? null,
'created_at' => optional($artwork->created_at)?->toISOString(),
'user' => $artwork->user ? [
'id' => (int) $artwork->user->id,
'name' => (string) $artwork->user->name,
'username' => $artwork->user->username,
] : null,
])
->values();
$stories = Story::query()
->with('creator:id,name,username')
->select('id', 'title', 'status', 'created_at', 'published_at', 'creator_id')
->whereBetween('created_at', [$periodStart, $periodEnd])
->orderByDesc('created_at')
->limit(25)
->get()
->map(fn (Story $story): array => [
'id' => (int) $story->id,
'title' => (string) ($story->title ?? 'Untitled story'),
'status' => (string) ($story->status ?? 'draft'),
'created_at' => optional($story->created_at)?->toISOString(),
'published_at' => optional($story->published_at)?->toISOString(),
'creator' => $story->creator ? [
'id' => (int) $story->creator->id,
'name' => (string) $story->creator->name,
'username' => $story->creator->username,
] : null,
])
->values();
$uploads = Schema::hasTable('uploads')
? Upload::query()
->select('id', 'user_id', 'type', 'status', 'processing_state', 'title', 'created_at', 'moderation_status', 'moderated_at', 'moderated_by', 'moderation_note')
->where(function ($query) use ($periodStart, $periodEnd): void {
$query->whereBetween('created_at', [$periodStart, $periodEnd])
->orWhereBetween('moderated_at', [$periodStart, $periodEnd]);
})
->orderByDesc('created_at')
->limit(40)
->get()
->map(fn (Upload $upload): array => [
'id' => (string) $upload->id,
'user_id' => $upload->user_id !== null ? (int) $upload->user_id : null,
'title' => (string) ($upload->title ?? 'Untitled upload'),
'type' => (string) ($upload->type ?? 'unknown'),
'status' => (string) ($upload->status ?? 'unknown'),
'processing_state' => (string) ($upload->processing_state ?? 'unknown'),
'moderation_status' => (string) ($upload->moderation_status ?? 'unknown'),
'created_at' => optional($upload->created_at)?->toISOString(),
'moderated_at' => optional($upload->moderated_at)?->toISOString(),
'moderated_by' => $upload->moderated_by !== null ? (int) $upload->moderated_by : null,
'moderation_note' => $upload->moderation_note,
])
->values()
: collect();
$reports = Schema::hasTable('reports')
? Report::query()
->with(['reporter:id,username', 'lastModeratedBy:id,username'])
->where(function ($query) use ($periodStart, $periodEnd): void {
$query->whereBetween('created_at', [$periodStart, $periodEnd])
->orWhereBetween('last_moderated_at', [$periodStart, $periodEnd]);
})
->orderByDesc('created_at')
->limit(30)
->get()
->map(fn (Report $report): array => [
'id' => (int) $report->id,
'status' => (string) $report->status,
'reason' => (string) $report->reason,
'target_type' => (string) $report->target_type,
'target_id' => (int) $report->target_id,
'created_at' => optional($report->created_at)?->toISOString(),
'last_moderated_at' => optional($report->last_moderated_at)?->toISOString(),
'moderator_note' => $report->moderator_note,
'reporter' => $report->reporter ? [
'id' => (int) $report->reporter->id,
'username' => (string) $report->reporter->username,
] : null,
'last_moderated_by' => $report->lastModeratedBy ? [
'id' => (int) $report->lastModeratedBy->id,
'username' => (string) $report->lastModeratedBy->username,
] : null,
'target' => $reportTargets->summarize($report),
])
->values()
: collect();
$usernameRequests = Schema::hasTable('username_approval_requests')
? (function () use ($periodStart, $periodEnd) {
$requestColumns = Schema::getColumnListing('username_approval_requests');
$selects = [
'requests.id',
'requests.user_id',
'requests.requested_username',
'requests.status',
'requests.created_at',
'users.username as current_username',
'users.name as current_name',
];
if (in_array('context', $requestColumns, true)) {
$selects[] = 'requests.context';
}
if (in_array('similar_to', $requestColumns, true)) {
$selects[] = 'requests.similar_to';
}
if (in_array('review_note', $requestColumns, true)) {
$selects[] = 'requests.review_note';
}
if (in_array('reviewed_at', $requestColumns, true)) {
$selects[] = 'requests.reviewed_at';
}
$query = DB::table('username_approval_requests as requests')
->leftJoin('users', 'users.id', '=', 'requests.user_id')
->select($selects)
->where(function (Builder $query) use ($periodStart, $periodEnd, $requestColumns): void {
$query->whereBetween('requests.created_at', [$periodStart, $periodEnd]);
if (in_array('reviewed_at', $requestColumns, true)) {
$query->orWhereBetween('requests.reviewed_at', [$periodStart, $periodEnd]);
}
})
->orderByDesc('requests.created_at')
->limit(30);
return $query
->get()
->map(fn ($row): array => [
'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,
'reason' => $row->review_note ?? null,
'created_at' => $this->serializeDatabaseTimestamp($row->created_at),
'reviewed_at' => $this->serializeDatabaseTimestamp($row->reviewed_at ?? null),
'current_username' => $row->current_username,
'current_name' => $row->current_name,
])
->values();
})()
: collect();
$authEvents = Schema::hasTable('auth_audit_logs')
? AuthAuditLog::query()
->with('user:id,name,username,email,role')
->select('id', 'user_id', 'event_type', 'identifier', 'status', 'reason', 'ip', 'created_at')
->whereBetween('created_at', [$periodStart, $periodEnd])
->orderByDesc('created_at')
->limit(30)
->get()
->map(fn (AuthAuditLog $log): array => [
'id' => (int) $log->id,
'event_type' => (string) $log->event_type,
'identifier' => $log->identifier,
'status' => (string) $log->status,
'reason' => $log->reason,
'ip' => $log->ip,
'created_at' => optional($log->created_at)?->toISOString(),
'user' => $log->user ? [
'id' => (int) $log->user->id,
'name' => (string) $log->user->name,
'username' => $log->user->username,
'email' => (string) $log->user->email,
'role' => (string) $log->user->role,
] : null,
])
->values()
: collect();
return Inertia::render('Admin/DailyActivity', [
'selectedDate' => $selectedDate->toDateString(),
'summary' => [
'new_users' => $users->count(),
'new_artworks' => $artworks->count(),
'new_stories' => $stories->count(),
'upload_events' => $uploads->count(),
'report_events' => $reports->count(),
'username_events' => $usernameRequests->count(),
'auth_events' => $authEvents->count(),
'moderated_uploads' => $uploads->filter(fn (array $upload): bool => ! empty($upload['moderated_at']))->count(),
'moderated_reports' => $reports->filter(fn (array $report): bool => ! empty($report['last_moderated_at']))->count(),
],
'queues' => [
'pending_uploads' => Schema::hasTable('uploads')
? Upload::query()->where('status', 'draft')->where('moderation_status', 'pending')->count()
: 0,
'open_reports' => Schema::hasTable('reports')
? Report::query()->where('status', 'open')->count()
: 0,
'pending_username_requests' => Schema::hasTable('username_approval_requests')
? DB::table('username_approval_requests')->where('status', 'pending')->count()
: 0,
],
'sections' => [
'users' => $users,
'artworks' => $artworks,
'stories' => $stories,
'uploads' => $uploads,
'reports' => $reports,
'username_requests' => $usernameRequests,
'auth_events' => $authEvents,
],
]);
}
// ── Users ───────────────────────────────────────────────────────────────── // ── Users ─────────────────────────────────────────────────────────────────
public function users(Request $request): Response public function users(Request $request): Response
@@ -157,4 +411,115 @@ final class AdminController extends Controller
'settings' => [], 'settings' => [],
]); ]);
} }
public function authAudit(Request $request): Response
{
abort_unless($request->user()?->isAdmin(), 403, 'Only admins can access this area.');
$search = $request->string('search')->trim()->toString();
$eventType = $request->string('event')->trim()->toString();
$status = $request->string('status')->trim()->toString();
$query = AuthAuditLog::query()
->with('user:id,name,username,email,role')
->latest('created_at')
->latest('id');
if ($search !== '') {
$query->where(function ($builder) use ($search): void {
$builder
->where('identifier', 'like', "%{$search}%")
->orWhere('ip', 'like', "%{$search}%")
->orWhere('reason', 'like', "%{$search}%")
->orWhereHas('user', function ($userQuery) use ($search): void {
$userQuery
->where('name', 'like', "%{$search}%")
->orWhere('username', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
});
}
if ($eventType !== '' && $eventType !== 'all') {
$query->where('event_type', $eventType);
}
if ($status !== '' && $status !== 'all') {
$query->where('status', $status);
}
$logs = $query->paginate(50)->withQueryString()->through(function (AuthAuditLog $log): array {
return [
'id' => $log->id,
'event_type' => $log->event_type,
'identifier' => $log->identifier,
'status' => $log->status,
'reason' => $log->reason,
'ip' => $log->ip,
'user_agent' => $log->user_agent,
'metadata' => $log->metadata ?? [],
'created_at' => $log->created_at,
'user' => $log->user ? [
'id' => $log->user->id,
'name' => $log->user->name,
'username' => $log->user->username,
'email' => $log->user->email,
'role' => $log->user->role,
] : null,
];
});
return Inertia::render('Admin/AuthAudit', [
'logs' => $logs,
'filters' => [
'search' => $search,
'event' => $eventType,
'status' => $status,
],
'eventOptions' => [
['value' => 'all', 'label' => 'All events'],
['value' => 'login', 'label' => 'Login'],
['value' => 'register', 'label' => 'Register'],
['value' => 'forgot_password', 'label' => 'Forgot password'],
['value' => 'reset_password', 'label' => 'Reset password'],
],
'statusOptions' => [
['value' => 'all', 'label' => 'All statuses'],
['value' => 'success', 'label' => 'Success'],
['value' => 'failed', 'label' => 'Failed'],
],
]);
}
private function resolveActivityDate(Request $request): Carbon
{
$date = $request->string('date')->trim()->toString();
if ($date === '') {
return today();
}
try {
return Carbon::createFromFormat('Y-m-d', $date)->startOfDay();
} catch (\Throwable) {
return today();
}
}
private function serializeDatabaseTimestamp(mixed $value): ?string
{
if ($value === null || $value === '') {
return null;
}
if ($value instanceof Carbon) {
return $value->toISOString();
}
try {
return Carbon::parse((string) $value)->toISOString();
} catch (\Throwable) {
return null;
}
}
} }
@@ -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 App\Models\Upload;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
final class UploadModerationController extends Controller final class UploadModerationController extends Controller
{ {
public function pending(): JsonResponse public function pending(): JsonResponse
{ {
$uploads = Upload::query() $uploads = Upload::query()
->with(['user:id,name,username', 'category:id,name,slug'])
->where('status', 'draft') ->where('status', 'draft')
->where('moderation_status', 'pending') ->where('moderation_status', 'pending')
->orderBy('created_at') ->orderBy('created_at')
->get([ ->get()
'id', ->map(fn (Upload $upload): array => $this->present($upload))
'user_id', ->values();
'type',
'status',
'processing_state',
'title',
'preview_path',
'created_at',
'moderation_status',
]);
return response()->json([ return response()->json([
'data' => $uploads, 'data' => $uploads,
], Response::HTTP_OK); ], 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 public function approve(string $id, Request $request): JsonResponse
{ {
$upload = Upload::query()->find($id); $upload = Upload::query()->find($id);
@@ -80,4 +98,56 @@ final class UploadModerationController extends Controller
'moderation_status' => (string) $upload->moderation_status, 'moderation_status' => (string) $upload->moderation_status,
], Response::HTTP_OK); ], 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; namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Services\Activity\UserActivityService; use App\Models\ActivityEvent;
use App\Models\Artwork; use App\Models\Artwork;
use App\Models\ArtworkComment; use App\Models\ArtworkComment;
use App\Models\User; use App\Models\User;
use App\Models\UserMention; use App\Models\UserMention;
use App\Notifications\ArtworkCommentedNotification; use App\Notifications\ArtworkCommentedNotification;
use App\Notifications\ArtworkMentionedNotification; use App\Notifications\ArtworkMentionedNotification;
use App\Services\Activity\UserActivityService;
use App\Services\CommentReactionService;
use App\Services\ContentSanitizer; use App\Services\ContentSanitizer;
use App\Services\Moderation\CommentSpamService;
use App\Support\AvatarUrl; use App\Support\AvatarUrl;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -30,6 +33,11 @@ class ArtworkCommentController extends Controller
{ {
private const MAX_LENGTH = 10_000; private const MAX_LENGTH = 10_000;
public function __construct(
private readonly CommentReactionService $commentReactions,
private readonly CommentSpamService $commentSpam,
) {}
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
// List // List
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
@@ -38,14 +46,14 @@ class ArtworkCommentController extends Controller
{ {
$artwork = Artwork::public()->published()->findOrFail($artworkId); $artwork = Artwork::public()->published()->findOrFail($artworkId);
$page = max(1, (int) $request->query('page', 1)); $page = max(1, (int) $request->query('page', 1));
$perPage = 20; $perPage = 20;
// Only fetch top-level comments (no parent). Replies are recursively eager-loaded. // Only fetch top-level comments (no parent). Replies are recursively eager-loaded.
$comments = ArtworkComment::with([ $comments = ArtworkComment::with([
'user', 'user.profile', 'user', 'user.profile',
'approvedReplies', 'approvedReplies',
]) ])
->where('artwork_id', $artwork->id) ->where('artwork_id', $artwork->id)
->where('is_approved', true) ->where('is_approved', true)
->whereNull('parent_id') ->whereNull('parent_id')
@@ -53,15 +61,17 @@ class ArtworkCommentController extends Controller
->paginate($perPage, ['*'], 'page', $page); ->paginate($perPage, ['*'], 'page', $page);
$userId = $request->user()?->id; $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([ return response()->json([
'data' => $items, 'data' => $items,
'meta' => [ 'meta' => [
'current_page' => $comments->currentPage(), 'current_page' => $comments->currentPage(),
'last_page' => $comments->lastPage(), 'last_page' => $comments->lastPage(),
'total' => $comments->total(), 'total' => $comments->total(),
'per_page' => $comments->perPage(), 'per_page' => $comments->perPage(),
], ],
]); ]);
} }
@@ -75,7 +85,7 @@ class ArtworkCommentController extends Controller
$artwork = Artwork::public()->published()->findOrFail($artworkId); $artwork = Artwork::public()->published()->findOrFail($artworkId);
$request->validate([ $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'], 'parent_id' => ['nullable', 'integer', 'exists:artwork_comments,id'],
]); ]);
@@ -104,41 +114,54 @@ class ArtworkCommentController extends Controller
$rendered = ContentSanitizer::render($raw); $rendered = ContentSanitizer::render($raw);
$comment = ArtworkComment::create([ $comment = ArtworkComment::create([
'artwork_id' => $artwork->id, 'artwork_id' => $artwork->id,
'user_id' => $request->user()->id, 'user_id' => $request->user()->id,
'parent_id' => $parentId, 'parent_id' => $parentId,
'content' => $raw, // legacy column (plain text fallback) 'content' => $raw, // legacy column (plain text fallback)
'raw_content' => $raw, 'raw_content' => $raw,
'rendered_content' => $rendered, '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 // Bust the comments cache for this user's 'all' feed
Cache::forget('comments.latest.all.page1'); Cache::forget('comments.latest.all.page1');
$comment->load(['user', 'user.profile']); $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) // Record activity event (fire-and-forget; never break the response)
try { try {
\App\Models\ActivityEvent::record( ActivityEvent::record(
actorId: $request->user()->id, actorId: $request->user()->id,
type: \App\Models\ActivityEvent::TYPE_COMMENT, type: ActivityEvent::TYPE_COMMENT,
targetType: \App\Models\ActivityEvent::TARGET_ARTWORK, targetType: ActivityEvent::TARGET_ARTWORK,
targetId: $artwork->id, targetId: $artwork->id,
); );
} catch (\Throwable) {} } catch (\Throwable) {
}
try { try {
app(UserActivityService::class)->logComment( if ($comment->is_approved) {
(int) $request->user()->id, app(UserActivityService::class)->logComment(
(int) $comment->id, (int) $request->user()->id,
$parentId !== null, (int) $comment->id,
['artwork_id' => (int) $artwork->id], $parentId !== null,
); ['artwork_id' => (int) $artwork->id],
} catch (\Throwable) {} );
}
} 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); Gate::authorize('update', $comment);
$request->validate([ $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); $errors = ContentSanitizer::validate($raw);
if ($errors) { if ($errors) {
return response()->json(['errors' => ['content' => $errors]], 422); return response()->json(['errors' => ['content' => $errors]], 422);
@@ -165,8 +188,8 @@ class ArtworkCommentController extends Controller
$rendered = ContentSanitizer::render($raw); $rendered = ContentSanitizer::render($raw);
$comment->update([ $comment->update([
'content' => $raw, 'content' => $raw,
'raw_content' => $raw, 'raw_content' => $raw,
'rendered_content' => $rendered, 'rendered_content' => $rendered,
]); ]);
@@ -197,36 +220,37 @@ class ArtworkCommentController extends Controller
// Helpers // 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; $user = $c->user;
$userId = (int) ($c->user_id ?? 0); $userId = (int) ($c->user_id ?? 0);
$avatarHash = $user?->profile?->avatar_hash ?? null; $avatarHash = $user?->profile?->avatar_hash ?? null;
$data = [ $data = [
'id' => $c->id, 'id' => $c->id,
'parent_id' => $c->parent_id, 'parent_id' => $c->parent_id,
'raw_content' => $c->raw_content ?? $c->content, 'raw_content' => $c->raw_content ?? $c->content,
'rendered_content' => $this->renderCommentContent($c), 'rendered_content' => $this->renderCommentContent($c),
'created_at' => $c->created_at?->toIso8601String(), 'created_at' => $c->created_at?->toIso8601String(),
'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null, 'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null,
'can_edit' => $currentUserId === $userId, 'can_edit' => $currentUserId === $userId,
'can_delete' => $currentUserId === $userId, 'can_delete' => $currentUserId === $userId,
'user' => [ 'user' => [
'id' => $userId, 'id' => $userId,
'username' => $user?->username, 'username' => $user?->username,
'display' => $user?->username ?? $user?->name ?? 'User', 'display' => $user?->username ?? $user?->name ?? 'User',
'profile_url' => $user?->username ? '/@' . $user->username : '/profile/' . $userId, 'profile_url' => $user?->username ? '/@'.$user->username : '/profile/'.$userId,
'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64), 'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64),
'level' => (int) ($user?->level ?? 1), 'level' => (int) ($user?->level ?? 1),
'rank' => (string) ($user?->rank ?? 'Newbie'), 'rank' => (string) ($user?->rank ?? 'Newbie'),
], ],
'reactions' => $reactionTotals[(int) $c->id] ?? [],
]; ];
if ($includeReplies && $c->relationLoaded('approvedReplies')) { 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')) { } 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 { } else {
$data['replies'] = []; $data['replies'] = [];
} }
@@ -234,6 +258,24 @@ class ArtworkCommentController extends Controller
return $data; 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 private function renderCommentContent(ArtworkComment $comment): string
{ {
$rawContent = (string) ($comment->raw_content ?? $comment->content ?? ''); $rawContent = (string) ($comment->raw_content ?? $comment->content ?? '');
+10 -5
View File
@@ -1,13 +1,14 @@
<?php <?php
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Artworks\ArtworkCreateRequest; use App\Http\Requests\Artworks\ArtworkCreateRequest;
use App\Http\Resources\ArtworkListResource; use App\Http\Resources\ArtworkListResource;
use App\Http\Resources\ArtworkResource; use App\Http\Resources\ArtworkResource;
use App\Services\ArtworkService;
use App\Services\Artworks\ArtworkDraftService;
use App\Models\Category; use App\Models\Category;
use App\Services\Artworks\ArtworkDraftService;
use App\Services\ArtworkService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -29,9 +30,12 @@ class ArtworkController extends Controller
$user = $request->user(); $user = $request->user();
$data = $request->validated(); $data = $request->validated();
$categoryId = isset($data['category']) && ctype_digit((string) $data['category']) $categoryId = isset($data['primary_category_id']) && ctype_digit((string) $data['primary_category_id'])
? (int) $data['category'] ? (int) $data['primary_category_id']
: null; : (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( $result = $drafts->createDraft(
$user, $user,
@@ -40,6 +44,7 @@ class ArtworkController extends Controller
$categoryId, $categoryId,
(bool) ($data['is_mature'] ?? false), (bool) ($data['is_mature'] ?? false),
$data['group'] ?? null, $data['group'] ?? null,
$secondaryCategoryIds,
); );
return response()->json([ return response()->json([
@@ -5,11 +5,15 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Resources\ArtworkResource; use App\Http\Resources\ArtworkResource;
use App\Models\Artwork; use App\Models\Artwork;
use App\Services\ArtworkNavigationService;
use App\Services\ThumbnailPresenter;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Support\Str; use Illuminate\Http\Request;
class ArtworkNavigationController extends Controller class ArtworkNavigationController extends Controller
{ {
public function __construct(private readonly ArtworkNavigationService $navigation) {}
/** /**
* GET /api/artworks/navigation/{id} * GET /api/artworks/navigation/{id}
* *
@@ -26,35 +30,11 @@ class ArtworkNavigationController extends Controller
'prev_id' => null, 'next_id' => null, 'prev_id' => null, 'next_id' => null,
'prev_url' => null, 'next_url' => null, 'prev_url' => null, 'next_url' => null,
'prev_slug' => null, 'next_slug' => null, 'prev_slug' => null, 'next_slug' => null,
'prev_preview' => null, 'next_preview' => null,
]); ]);
} }
$scope = Artwork::published() return response()->json($this->navigation->navigationFor($artwork));
->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,
]);
} }
/** /**
@@ -62,8 +42,12 @@ class ArtworkNavigationController extends Controller
* *
* Returns full artwork resource by numeric ID for client-side (no-reload) navigation. * 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']) $artwork = Artwork::with(['user.profile', 'categories.contentType', 'categories.parent.contentType', 'tags', 'stats'])
->published() ->published()
->find($id); ->find($id);
@@ -73,7 +57,30 @@ class ArtworkNavigationController extends Controller
} }
$resource = (new ArtworkResource($artwork))->toArray(request()); $resource = (new ArtworkResource($artwork))->toArray(request());
$resource['navigation'] = $this->navigation->navigationFor($artwork);
return response()->json($resource); 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'); $queueConnection = (string) config('queue.default', 'sync');
$visionEnabled = (bool) config('vision.enabled', true); $visionEnabled = (bool) config('vision.enabled', true);
$autoTaggingEnabled = (bool) config('vision.auto_tagging.enabled', false);
$queuedCount = 0; $queuedCount = 0;
$failedCount = 0; $failedCount = 0;
@@ -56,7 +57,7 @@ final class ArtworkTagController extends Controller
$triggered = false; $triggered = false;
$shouldTrigger = request()->boolean('trigger', 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); AutoTagArtworkJob::dispatch((int) $artwork->id, (string) $artwork->hash);
$triggered = true; $triggered = true;
$queuedCount = max(1, $queuedCount); $queuedCount = max(1, $queuedCount);
@@ -89,6 +90,7 @@ final class ArtworkTagController extends Controller
'queued_jobs' => $queuedCount, 'queued_jobs' => $queuedCount,
'failed_jobs' => $failedCount, 'failed_jobs' => $failedCount,
'triggered' => $triggered, 'triggered' => $triggered,
'auto_tagging_enabled' => $autoTaggingEnabled,
'ai_tag_count' => (int) $tags->where('is_ai', true)->count(), 'ai_tag_count' => (int) $tags->where('is_ai', true)->count(),
'total_tag_count' => (int) $tags->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 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; return;
} }
$categoryId = DB::table('artwork_category') $categoryId = DB::table('artworks')
->where('artwork_id', $artworkId) ->where('id', $artworkId)
->orderBy('category_id') ->value('primary_category_id');
->value('category_id');
DB::table('user_discovery_events')->insert([ DB::table('user_discovery_events')->insert([
'event_id' => (string) Str::uuid(), '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\Http\Controllers\Controller;
use App\Models\User; use App\Models\User;
use App\Services\FollowService; use App\Services\FollowService;
use App\Support\AvatarUrl;
use App\Support\UsernamePolicy; use App\Support\UsernamePolicy;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
/** /**
* API endpoints for the follow system. * API endpoints for the follow system.
@@ -72,26 +70,17 @@ final class FollowController extends Controller
{ {
$target = $this->resolveUser($username); $target = $this->resolveUser($username);
$perPage = min((int) $request->query('per_page', 24), 100); $perPage = min((int) $request->query('per_page', 24), 100);
$page = max(1, (int) $request->query('page', 1));
$rows = DB::table('user_followers as uf') $rows = $this->followService
->join('users as u', 'u.id', '=', 'uf.follower_id') ->paginatedFollowers((int) $target->id, $perPage, $page)
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id') ->through(fn (array $row) => [
->where('uf.user_id', $target->id) 'id' => $row['id'],
->whereNull('u.deleted_at') 'username' => $row['username'],
->orderByDesc('uf.created_at') 'display_name' => $row['display_name'],
->select([ 'avatar_url' => $row['avatar_url'],
'u.id', 'u.username', 'u.name', 'profile_url' => $row['profile_url'],
'up.avatar_hash', 'followed_at' => $row['followed_at'],
'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,
]); ]);
return response()->json($rows); return response()->json($rows);
@@ -103,26 +92,17 @@ final class FollowController extends Controller
{ {
$target = $this->resolveUser($username); $target = $this->resolveUser($username);
$perPage = min((int) $request->query('per_page', 24), 100); $perPage = min((int) $request->query('per_page', 24), 100);
$page = max(1, (int) $request->query('page', 1));
$rows = DB::table('user_followers as uf') $rows = $this->followService
->join('users as u', 'u.id', '=', 'uf.user_id') ->paginatedFollowing((int) $target->id, $perPage, $page)
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id') ->through(fn (array $row) => [
->where('uf.follower_id', $target->id) 'id' => $row['id'],
->whereNull('u.deleted_at') 'username' => $row['username'],
->orderByDesc('uf.created_at') 'display_name' => $row['display_name'],
->select([ 'avatar_url' => $row['avatar_url'],
'u.id', 'u.username', 'u.name', 'profile_url' => $row['profile_url'],
'up.avatar_hash', 'followed_at' => $row['followed_at'],
'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,
]); ]);
return response()->json($rows); return response()->json($rows);
@@ -11,11 +11,21 @@ use App\Services\ThumbnailPresenter;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Contracts\Pagination\Paginator;
class LatestCommentsApiController extends Controller class LatestCommentsApiController extends Controller
{ {
private const PER_PAGE = 20; private const PER_PAGE = 20;
private function paginationMeta(Paginator $paginator): array
{
return [
'current_page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
'has_more' => $paginator->hasMorePages(),
];
}
public function index(Request $request): JsonResponse public function index(Request $request): JsonResponse
{ {
$type = $request->query('type', 'all'); $type = $request->query('type', 'all');
@@ -30,7 +40,19 @@ class LatestCommentsApiController extends Controller
return response()->json(['error' => 'Unauthenticated'], 401); return response()->json(['error' => 'Unauthenticated'], 401);
} }
$query = ArtworkComment::with(['user', 'user.profile', 'artwork']) $query = ArtworkComment::query()
->select([
'artwork_comments.id',
'artwork_comments.artwork_id',
'artwork_comments.user_id',
'artwork_comments.content',
'artwork_comments.created_at',
])
->with([
'user:id,username,name',
'user.profile:user_id,avatar_hash',
'artwork:id,title,slug,hash,file_path,file_name',
])
->whereHas('artwork', function ($q) { ->whereHas('artwork', function ($q) {
$q->public()->published()->whereNull('deleted_at'); $q->public()->published()->whereNull('deleted_at');
}) })
@@ -54,15 +76,21 @@ class LatestCommentsApiController extends Controller
$cacheKey = 'comments.latest.all.page1'; $cacheKey = 'comments.latest.all.page1';
$ttl = 120; // 2 minutes $ttl = 120; // 2 minutes
$paginator = Cache::remember($cacheKey, $ttl, fn () => $query->paginate(self::PER_PAGE)); $paginator = Cache::remember($cacheKey, $ttl, fn () => $query
->orderByDesc('artwork_comments.id')
->simplePaginate(self::PER_PAGE));
} else { } else {
$paginator = $query->paginate(self::PER_PAGE); $paginator = $query
->orderByDesc('artwork_comments.id')
->simplePaginate(self::PER_PAGE);
} }
break; break;
} }
if (! isset($paginator)) { if (! isset($paginator)) {
$paginator = $query->paginate(self::PER_PAGE); $paginator = $query
->orderByDesc('artwork_comments.id')
->simplePaginate(self::PER_PAGE);
} }
$items = $paginator->getCollection()->map(function (ArtworkComment $c) { $items = $paginator->getCollection()->map(function (ArtworkComment $c) {
@@ -101,13 +129,7 @@ class LatestCommentsApiController extends Controller
return response()->json([ return response()->json([
'data' => $items, 'data' => $items,
'meta' => [ 'meta' => $this->paginationMeta($paginator),
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
'has_more' => $paginator->hasMorePages(),
],
]); ]);
} }
} }
@@ -53,7 +53,12 @@ class LinkPreviewController extends Controller
return response()->json(['error' => 'Invalid URL.'], 422); 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); $resolved = gethostbyname($host);
if ($this->isBlockedIp($resolved)) { if ($this->isBlockedIp($resolved)) {
return response()->json(['error' => 'URL not allowed.'], 422); return response()->json(['error' => 'URL not allowed.'], 422);
@@ -48,10 +48,7 @@ class MessageSearchController extends Controller
$estimated = 0; $estimated = 0;
try { try {
$client = new Client( $client = app(Client::class);
config('scout.meilisearch.host'),
config('scout.meilisearch.key')
);
$prefix = (string) config('scout.prefix', ''); $prefix = (string) config('scout.prefix', '');
$indexName = $prefix . (string) config('messaging.search.index', 'messages'); $indexName = $prefix . (string) config('messaging.search.index', 'messages');
@@ -30,12 +30,7 @@ class PostTrendingFeedController extends Controller
$result = $this->trendingService->getTrending($viewer, $page); $result = $this->trendingService->getTrending($viewer, $page);
$formatted = array_map( return response()->json($result);
fn ($post) => $this->feedService->formatPost($post, $viewer),
$result['data'],
);
return response()->json(['data' => array_values($formatted), 'meta' => $result['meta']]);
} }
public function hashtag(Request $request, string $tag): JsonResponse public function hashtag(Request $request, string $tag): JsonResponse
@@ -7,15 +7,19 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Artwork; use App\Models\Artwork;
use App\Models\User; use App\Models\User;
use Carbon\CarbonInterface; use App\Services\CollectionService;
use App\Services\Maturity\ArtworkMaturityService;
use App\Services\ThumbnailPresenter; use App\Services\ThumbnailPresenter;
use App\Support\UsernamePolicy; use App\Support\UsernamePolicy;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Pagination\Cursor;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use UnexpectedValueException;
/** /**
* ProfileApiController * ProfileApiController
@@ -23,6 +27,28 @@ use Illuminate\Support\Facades\Schema;
*/ */
final class ProfileApiController extends Controller 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 * GET /api/profile/{username}/artworks
* Returns cursor-paginated artworks for the profile page tabs. * Returns cursor-paginated artworks for the profile page tabs.
@@ -36,18 +62,18 @@ final class ProfileApiController extends Controller
} }
$isOwner = Auth::check() && Auth::id() === $user->id; $isOwner = Auth::check() && Auth::id() === $user->id;
$sort = $request->input('sort', 'latest'); $sort = $request->input('sort', 'latest');
$query = Artwork::with([ $query = Artwork::with([
'user:id,name,username,level,rank', 'user:id,name,username,level,rank',
'user.profile:user_id,avatar_hash', 'user.profile:user_id,avatar_hash',
'group:id,name,slug,avatar_path', 'group:id,name,slug,avatar_path',
'stats:artwork_id,views,downloads,favorites', 'stats:artwork_id,views,downloads,favorites',
'categories' => function ($query) { 'categories' => function ($query) {
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order') $query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
->with(['contentType:id,slug,name']); ->with(['contentType:id,slug,name']);
}, },
]) ])
->where('user_id', $user->id) ->where('user_id', $user->id)
->whereNull('artworks.deleted_at'); ->whereNull('artworks.deleted_at');
@@ -59,17 +85,32 @@ final class ProfileApiController extends Controller
$query = $this->applyArtworkSort($query, $sort); $query = $this->applyArtworkSort($query, $sort);
$perPage = 24; $perPage = 24;
$paginator = $query->cursorPaginate($perPage); $cursor = Cursor::fromEncoded($request->input('cursor'));
try {
$paginator = (clone $query)->cursorPaginate($perPage, ['*'], 'cursor', $cursor);
} catch (UnexpectedValueException) {
$originalCursor = $request->query('cursor');
$request->query->remove('cursor');
try {
$paginator = (clone $query)->cursorPaginate($perPage, ['*'], 'cursor', null);
} finally {
if ($originalCursor !== null) {
$request->query->set('cursor', $originalCursor);
}
}
}
$data = collect($paginator->items()) $data = collect($paginator->items())
->map(fn (Artwork $art) => $this->mapArtworkCardPayload($art)) ->map(fn (Artwork $art) => $this->mapArtworkCardPayload($art))
->values(); ->values();
return response()->json([ return response()->json([
'data' => $data, 'data' => $data,
'next_cursor' => $paginator->nextCursor()?->encode(), 'next_cursor' => $paginator->nextCursor()?->encode(),
'has_more' => $paginator->hasMorePages(), 'has_more' => $paginator->hasMorePages(),
]); ]);
} }
@@ -89,16 +130,20 @@ final class ProfileApiController extends Controller
return response()->json(['error' => 'User not found'], 404); return response()->json(['error' => 'User not found'], 404);
} }
$perPage = 24; $perPage = 12;
$offset = max(0, (int) base64_decode((string) $request->input('cursor', ''), true)); $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') ->join('artworks as a', 'a.id', '=', 'af.artwork_id')
->where('af.user_id', $user->id) ->where('af.user_id', $user->id)
->whereNull('a.deleted_at') ->whereNull('a.deleted_at')
->where('a.is_public', true) ->where('a.is_public', true)
->where('a.is_approved', true) ->where('a.is_approved', true)
->whereNotNull('a.published_at') ->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.created_at')
->orderByDesc('af.artwork_id') ->orderByDesc('af.artwork_id')
->offset($offset) ->offset($offset)
@@ -106,22 +151,22 @@ final class ProfileApiController extends Controller
->pluck('a.id'); ->pluck('a.id');
$hasMore = $favIds->count() > $perPage; $hasMore = $favIds->count() > $perPage;
$favIds = $favIds->take($perPage); $favIds = $favIds->take($perPage);
if ($favIds->isEmpty()) { if ($favIds->isEmpty()) {
return response()->json(['data' => [], 'next_cursor' => null, 'has_more' => false]); return response()->json(['data' => [], 'next_cursor' => null, 'has_more' => false]);
} }
$indexed = Artwork::with([ $indexed = Artwork::with([
'user:id,name,username,level,rank', 'user:id,name,username,level,rank',
'user.profile:user_id,avatar_hash', 'user.profile:user_id,avatar_hash',
'group:id,name,slug,avatar_path', 'group:id,name,slug,avatar_path',
'stats:artwork_id,views,downloads,favorites', 'stats:artwork_id,views,downloads,favorites',
'categories' => function ($query) { 'categories' => function ($query) {
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order') $query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
->with(['contentType:id,slug,name']); ->with(['contentType:id,slug,name']);
}, },
]) ])
->whereIn('id', $favIds) ->whereIn('id', $favIds)
->get() ->get()
->keyBy('id'); ->keyBy('id');
@@ -132,9 +177,9 @@ final class ProfileApiController extends Controller
->values(); ->values();
return response()->json([ return response()->json([
'data' => $data, 'data' => $data,
'next_cursor' => $hasMore ? base64_encode((string) ($offset + $perPage)) : null, 'next_cursor' => $hasMore ? base64_encode((string) ($offset + $perPage)) : null,
'has_more' => $hasMore, 'has_more' => $hasMore,
]); ]);
} }
@@ -160,7 +205,7 @@ final class ProfileApiController extends Controller
} }
return response()->json([ return response()->json([
'stats' => $stats, 'stats' => $stats,
'follower_count' => $followerCount, 'follower_count' => $followerCount,
]); ]);
} }
@@ -168,6 +213,7 @@ final class ProfileApiController extends Controller
private function resolveUser(string $username): ?User private function resolveUser(string $username): ?User
{ {
$normalized = UsernamePolicy::normalize($username); $normalized = UsernamePolicy::normalize($username);
return User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first(); return User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first();
} }
@@ -186,24 +232,25 @@ final class ProfileApiController extends Controller
{ {
$statsColumn = match ($sort) { $statsColumn = match ($sort) {
'trending' => 'profile_artwork_stats.ranking_score', 'trending' => 'profile_artwork_stats.ranking_score',
'rising' => 'profile_artwork_stats.heat_score', 'rising' => 'profile_artwork_stats.heat_score',
'views' => 'profile_artwork_stats.views', 'views' => 'profile_artwork_stats.views',
'favs' => 'profile_artwork_stats.favorites', 'favs' => 'profile_artwork_stats.favorites',
default => null, default => null,
}; };
if ($statsColumn !== null) { if ($statsColumn !== null) {
return $query return $query
->leftJoin('artwork_stats as profile_artwork_stats', 'profile_artwork_stats.artwork_id', '=', 'artworks.id') ->leftJoin('artwork_stats as profile_artwork_stats', 'profile_artwork_stats.artwork_id', '=', 'artworks.id')
->select('artworks.*') ->select('artworks.*')
->orderByDesc($statsColumn) ->selectRaw('COALESCE('.$statsColumn.', 0) as cursor_sort_value')
->orderByDesc('artworks.published_at') ->orderByDesc('cursor_sort_value')
->orderByDesc('artworks.id'); ->orderByDesc('published_at')
->orderByDesc('id');
} }
return $query return $query
->orderByDesc('artworks.published_at') ->orderByDesc('published_at')
->orderByDesc('artworks.id'); ->orderByDesc('id');
} }
/** /**
@@ -212,7 +259,7 @@ final class ProfileApiController extends Controller
private function mapArtworkCardPayload(Artwork $art): array private function mapArtworkCardPayload(Artwork $art): array
{ {
$present = ThumbnailPresenter::present($art, 'md'); $present = ThumbnailPresenter::present($art, 'md');
$category = $art->categories->first(); $category = $art->resolvedPrimaryCategory();
$contentType = $category?->contentType; $contentType = $category?->contentType;
$stats = $art->stats; $stats = $art->stats;
$group = $art->group; $group = $art->group;
@@ -222,12 +269,14 @@ final class ProfileApiController extends Controller
$avatarUrl = $isGroupPublisher ? $group->avatarUrl() : ($art->user?->profile?->avatar_url ?? null); $avatarUrl = $isGroupPublisher ? $group->avatarUrl() : ($art->user?->profile?->avatar_url ?? null);
$profileUrl = $isGroupPublisher $profileUrl = $isGroupPublisher
? $group->publicUrl() ? $group->publicUrl()
: ($username ? '/@' . $username : null); : ($username ? '/@'.$username : null);
$publisherType = $isGroupPublisher ? 'group' : 'user'; $publisherType = $isGroupPublisher ? 'group' : 'user';
return [ return [
'id' => $art->id, 'id' => $art->id,
'name' => $art->title, 'name' => $art->title,
'picture' => $art->file_name,
'datum' => $this->formatIsoDate($art->published_at),
'thumb' => $present['url'], 'thumb' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'], 'thumb_srcset' => $present['srcset'] ?? $present['url'],
'width' => $art->width, 'width' => $art->width,
@@ -6,10 +6,12 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Artwork; use App\Models\Artwork;
use App\Services\Vision\VectorGatewayException;
use App\Services\Vision\VectorService; use App\Services\Vision\VectorService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use RuntimeException; use Illuminate\Support\Facades\Log;
use Throwable;
final class SimilarAiArtworksController extends Controller final class SimilarAiArtworksController extends Controller
{ {
@@ -35,11 +37,12 @@ final class SimilarAiArtworksController extends Controller
try { try {
$items = $this->vectors->similarToArtwork($artwork, $limit); $items = $this->vectors->similarToArtwork($artwork, $limit);
} catch (RuntimeException $e) { } catch (Throwable $e) {
$this->logSimilarityFailure($artwork->id, $e);
return response()->json([ return response()->json([
'data' => [], 'data' => [],
'reason' => 'vector_gateway_error', 'reason' => 'vector_gateway_error',
'message' => $e->getMessage(),
], 502); ], 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; namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; 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\UploadFinishRequest;
use App\Http\Requests\Uploads\UploadInitRequest; 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\Http\Requests\Uploads\UploadStatusRequest;
use App\Jobs\GenerateDerivativesJob;
use App\Jobs\AnalyzeArtworkAiAssistJob; use App\Jobs\AnalyzeArtworkAiAssistJob;
use App\Jobs\IndexArtworkJob;
use App\Jobs\AutoTagArtworkJob; use App\Jobs\AutoTagArtworkJob;
use App\Jobs\DetectArtworkMaturityJob; use App\Jobs\DetectArtworkMaturityJob;
use App\Jobs\GenerateArtworkEmbeddingJob; 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\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\Activity\UserActivityService;
use App\Services\ArtworkAttributionService; use App\Services\ArtworkAttributionService;
use App\Services\Artworks\ArtworkCategoryService;
use App\Services\GroupArtworkReviewService;
use App\Services\Maturity\ArtworkMaturityService; 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\UploadNotFoundException;
use App\Uploads\Exceptions\UploadOwnershipException; use App\Uploads\Exceptions\UploadOwnershipException;
use App\Uploads\Exceptions\UploadPublishValidationException; use App\Uploads\Exceptions\UploadPublishValidationException;
use App\Uploads\Jobs\VirusScanJob;
use App\Uploads\Services\ArchiveInspectorService; use App\Uploads\Services\ArchiveInspectorService;
use App\Uploads\Services\DraftQuotaService; use App\Uploads\Services\DraftQuotaService;
use App\Uploads\Exceptions\DraftQuotaException; use App\Uploads\Services\PublishService;
use App\Models\Artwork; use Carbon\Carbon;
use App\Models\Group; use Illuminate\Http\Request;
use App\Services\GroupArtworkReviewService; use Illuminate\Support\Facades\DB;
use App\Services\Worlds\WorldSubmissionService; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
final class UploadController extends Controller final class UploadController extends Controller
{ {
@@ -57,8 +65,7 @@ final class UploadController extends Controller
UploadPipelineService $pipeline, UploadPipelineService $pipeline,
UploadQuotaService $quota, UploadQuotaService $quota,
UploadAuditService $audit UploadAuditService $audit
) ) {
{
$user = $request->user(); $user = $request->user();
try { try {
@@ -237,10 +244,19 @@ final class UploadController extends Controller
} }
// Derivatives are available now; dispatch AI auto-tagging. // Derivatives are available now; dispatch AI auto-tagging.
AutoTagArtworkJob::dispatch($artworkId, $validated->hash)->afterCommit(); if ((bool) config('vision.auto_tagging.enabled', false)) {
DetectArtworkMaturityJob::dispatch($artworkId, $validated->hash)->afterCommit(); AutoTagArtworkJob::dispatch($artworkId, $validated->hash)->afterCommit();
GenerateArtworkEmbeddingJob::dispatch($artworkId, $validated->hash)->afterCommit(); }
AnalyzeArtworkAiAssistJob::dispatch($artworkId)->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; return UploadSessionStatus::PROCESSED;
}); });
@@ -295,7 +311,7 @@ final class UploadController extends Controller
'realpath' => $chunkFile->getRealPath(), 'realpath' => $chunkFile->getRealPath(),
]); ]);
} }
} catch (\Throwable $e) { } catch (Throwable $e) {
logger()->warning('Chunk upload debug logging failed', ['error' => $e->getMessage()]); logger()->warning('Chunk upload debug logging failed', ['error' => $e->getMessage()]);
} }
@@ -323,7 +339,7 @@ final class UploadController extends Controller
'total_bytes' => $result->totalBytes, 'total_bytes' => $result->totalBytes,
'progress' => $result->progress, 'progress' => $result->progress,
], Response::HTTP_OK); ], Response::HTTP_OK);
} catch (\Throwable $e) { } catch (Throwable $e) {
logger()->warning('Upload chunk failed', [ logger()->warning('Upload chunk failed', [
'session_id' => (string) $request->input('session_id'), 'session_id' => (string) $request->input('session_id'),
'error' => $e->getMessage(), 'error' => $e->getMessage(),
@@ -373,7 +389,7 @@ final class UploadController extends Controller
'session_id' => $result['session_id'], 'session_id' => $result['session_id'],
'status' => $result['status'], 'status' => $result['status'],
], Response::HTTP_OK); ], Response::HTTP_OK);
} catch (\Throwable $e) { } catch (Throwable $e) {
logger()->warning('Upload cancel failed', [ logger()->warning('Upload cancel failed', [
'session_id' => (string) $request->input('session_id'), 'session_id' => (string) $request->input('session_id'),
'error' => $e->getMessage(), 'error' => $e->getMessage(),
@@ -502,6 +518,7 @@ final class UploadController extends Controller
return response()->json($response, Response::HTTP_OK); return response()->json($response, Response::HTTP_OK);
} catch (Throwable $e) { } catch (Throwable $e) {
logger()->error('Upload preload failed', ['error' => $e->getMessage()]); logger()->error('Upload preload failed', ['error' => $e->getMessage()]);
return response()->json(['message' => 'Preload failed.'], Response::HTTP_INTERNAL_SERVER_ERROR); return response()->json(['message' => 'Preload failed.'], Response::HTTP_INTERNAL_SERVER_ERROR);
} }
} }
@@ -528,14 +545,18 @@ final class UploadController extends Controller
$validated = $request->validate([ $validated = $request->validate([
'title' => ['nullable', 'string', 'max:255'], 'title' => ['nullable', 'string', 'max:255'],
'category_id' => ['nullable', 'exists:categories,id'], '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'], 'description' => ['nullable', 'string'],
'tags' => ['nullable', 'array'], 'tags' => ['nullable', 'array'],
'license' => ['nullable', 'string'], 'license' => ['nullable', 'string'],
'nsfw' => ['nullable', 'boolean'], 'nsfw' => ['nullable', 'boolean'],
]); ]);
$this->ensureValidArtworkDescription($validated);
$updates = []; $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)) { if (array_key_exists($field, $validated)) {
$updates[$field] = $validated[$field]; $updates[$field] = $validated[$field];
} }
@@ -545,7 +566,7 @@ final class UploadController extends Controller
foreach ($updates as $field => $value) { foreach ($updates as $field => $value) {
$current = $upload->{$field} ?? null; $current = $upload->{$field} ?? null;
if ($field === 'tags') { if ($field === 'tags' || $field === 'secondary_category_ids') {
$current = $current ? json_decode((string) $current, true) : null; $current = $current ? json_decode((string) $current, true) : null;
} }
@@ -563,6 +584,10 @@ final class UploadController extends Controller
$dirty['tags'] = json_encode($dirty['tags']); $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)) { if (! empty($dirty)) {
$dirty['updated_at'] = now(); $dirty['updated_at'] = now();
DB::table('uploads')->where('id', $id)->update($dirty); DB::table('uploads')->where('id', $id)->update($dirty);
@@ -604,23 +629,26 @@ final class UploadController extends Controller
], Response::HTTP_OK); ], 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(); $user = $request->user();
$validated = $request->validate([ $validated = $request->validate([
'title' => ['nullable', 'string', 'max:150'], 'title' => ['nullable', 'string', 'max:150'],
'description' => ['nullable', 'string'], 'description' => ['nullable', 'string'],
'category' => ['nullable', 'integer', 'exists:categories,id'], 'category' => ['nullable', 'integer', 'exists:categories,id'],
'tags' => ['nullable', 'array', 'max:' . (int) config('tags.max_user_tags', 30)], 'primary_category_id' => ['nullable', 'integer', 'exists:categories,id'],
'tags.*' => ['string', 'max:64'], 'secondary_category_ids' => ['nullable', 'array', 'max:'.(int) config('categories.max_secondary_categories', 5)],
'is_mature' => ['nullable', 'boolean'], 'secondary_category_ids.*' => ['integer', 'exists:categories,id'],
'nsfw' => ['nullable', 'boolean'], '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 // Scheduled-publishing fields
'mode' => ['nullable', 'string', 'in:now,schedule'], 'mode' => ['nullable', 'string', 'in:now,schedule'],
'publish_at' => ['nullable', 'string', 'date'], 'publish_at' => ['nullable', 'string', 'date'],
'timezone' => ['nullable', 'string', 'max:64'], 'timezone' => ['nullable', 'string', 'max:64'],
'visibility' => ['nullable', 'string', 'in:public,unlisted,private'], 'visibility' => ['nullable', 'string', 'in:public,unlisted,private'],
'group' => ['nullable', 'string', 'max:90'], 'group' => ['nullable', 'string', 'max:90'],
'primary_author_user_id' => ['nullable', 'integer', 'min:1'], 'primary_author_user_id' => ['nullable', 'integer', 'min:1'],
'contributor_user_ids' => ['nullable', 'array', 'max:20'], 'contributor_user_ids' => ['nullable', 'array', 'max:20'],
@@ -635,21 +663,23 @@ final class UploadController extends Controller
'world_submissions.*.source_surface' => ['nullable', 'string', 'max:80'], 'world_submissions.*.source_surface' => ['nullable', 'string', 'max:80'],
]); ]);
$mode = $validated['mode'] ?? 'now'; $this->ensureValidArtworkDescription($validated);
$mode = $validated['mode'] ?? 'now';
$visibility = $validated['visibility'] ?? 'public'; $visibility = $validated['visibility'] ?? 'public';
// Resolve the UTC publish_at datetime for schedule mode // Resolve the UTC publish_at datetime for schedule mode
$publishAt = null; $publishAt = null;
if ($mode === 'schedule' && ! empty($validated['publish_at'])) { if ($mode === 'schedule' && ! empty($validated['publish_at'])) {
try { 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) // Must be at least 1 minute in the future (server-side guard)
if ($publishAt->lte(now()->addMinute())) { if ($publishAt->lte(now()->addMinute())) {
return response()->json([ return response()->json([
'message' => 'Scheduled publish time must be at least 1 minute in the future.', 'message' => 'Scheduled publish time must be at least 1 minute in the future.',
], Response::HTTP_UNPROCESSABLE_ENTITY); ], Response::HTTP_UNPROCESSABLE_ENTITY);
} }
} catch (\Throwable) { } catch (Throwable) {
return response()->json(['message' => 'Invalid publish_at datetime.'], Response::HTTP_UNPROCESSABLE_ENTITY); 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)) { if (array_key_exists('is_mature', $validated) || array_key_exists('nsfw', $validated)) {
$artwork->is_mature = (bool) ($validated['is_mature'] ?? $validated['nsfw'] ?? false); $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->artwork_timezone = $validated['timezone'] ?? null;
$artwork->uploaded_by_user_id = $artwork->uploaded_by_user_id ?: (int) $user->id; $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; $artwork->primary_author_user_id = $artwork->primary_author_user_id ?: (int) $user->id;
// Sync category if provided $categoryId = isset($validated['primary_category_id'])
$categoryId = isset($validated['category']) ? (int) $validated['category'] : null; ? (int) $validated['primary_category_id']
if ($categoryId && \App\Models\Category::where('id', $categoryId)->exists()) { : (isset($validated['category']) ? (int) $validated['category'] : null);
$artwork->categories()->sync([$categoryId]); $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 // Sync tags if provided
if (!empty($validated['tags']) && is_array($validated['tags'])) { if (! empty($validated['tags']) && is_array($validated['tags'])) {
$tagIds = []; $tagIds = [];
foreach ($validated['tags'] as $tagSlug) { foreach ($validated['tags'] as $tagSlug) {
$tag = \App\Models\Tag::firstOrCreate( $tag = Tag::firstOrCreate(
['slug' => Str::slug($tagSlug)], ['slug' => Str::slug($tagSlug)],
['name' => $tagSlug, 'is_active' => true, 'usage_count' => 0] ['name' => $tagSlug, 'is_active' => true, 'usage_count' => 0]
); );
@@ -713,56 +746,95 @@ final class UploadController extends Controller
if ($mode === 'schedule' && $publishAt) { if ($mode === 'schedule' && $publishAt) {
// Scheduled: store publish_at but don't make public yet // Scheduled: store publish_at but don't make public yet
$artwork->visibility = $visibility; $artwork->visibility = $visibility;
$artwork->is_public = false; $artwork->is_public = false;
$artwork->is_approved = true; $artwork->is_approved = true;
$artwork->publish_at = $publishAt; $artwork->publish_at = $publishAt;
$artwork->artwork_status = 'scheduled'; $artwork->artwork_status = 'scheduled';
$artwork->published_at = null; $artwork->published_at = null;
$artwork->save(); $artwork->save();
IndexArtworkJob::dispatch((int) $artwork->id); IndexArtworkJob::dispatch((int) $artwork->id);
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'artwork_id' => (int) $artwork->id, 'artwork_id' => (int) $artwork->id,
'status' => 'scheduled', 'status' => 'scheduled',
'slug' => (string) $artwork->slug, 'slug' => (string) $artwork->slug,
'publish_at' => $publishAt->toISOString(), 'publish_at' => $publishAt->toISOString(),
'published_at' => null, 'published_at' => null,
], Response::HTTP_OK); ], Response::HTTP_OK);
} }
// Publish immediately // New and suspicious accounts are quarantined. Never let the client
$artwork->visibility = $visibility; // decide that an artwork is approved.
$artwork->is_public = ($visibility !== 'private'); $policy = $uploadPolicy->assess($user, $artwork);
$artwork->is_approved = true; if ($policy['requires_review']) {
$artwork->published_at = now(); $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->artwork_status = 'published';
$artwork->publish_at = null; $artwork->publish_at = null;
$artwork->save(); $artwork->save();
IndexArtworkJob::dispatch((int) $artwork->id); IndexArtworkJob::dispatch((int) $artwork->id);
// Record upload activity event // Record upload activity event
try { try {
\App\Models\ActivityEvent::record( ActivityEvent::record(
actorId: (int) $user->id, actorId: (int) $user->id,
type: \App\Models\ActivityEvent::TYPE_UPLOAD, type: ActivityEvent::TYPE_UPLOAD,
targetType: \App\Models\ActivityEvent::TARGET_ARTWORK, targetType: ActivityEvent::TARGET_ARTWORK,
targetId: (int) $artwork->id, targetId: (int) $artwork->id,
); );
} catch (\Throwable) {} } catch (Throwable) {
}
try { try {
app(UserActivityService::class)->logUpload((int) $user->id, (int) $artwork->id); app(UserActivityService::class)->logUpload((int) $user->id, (int) $artwork->id);
} catch (\Throwable) {} } catch (Throwable) {
}
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'artwork_id' => (int) $artwork->id, 'artwork_id' => (int) $artwork->id,
'status' => 'published', 'status' => 'published',
'slug' => (string) $artwork->slug, 'slug' => (string) $artwork->slug,
'published_at' => optional($artwork->published_at)->toISOString(), 'published_at' => optional($artwork->published_at)->toISOString(),
], Response::HTTP_OK); ], Response::HTTP_OK);
} }
@@ -771,11 +843,11 @@ final class UploadController extends Controller
$upload = $publishService->publish($id, $user); $upload = $publishService->publish($id, $user);
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'upload_id' => (string) $upload->id, 'upload_id' => (string) $upload->id,
'status' => (string) $upload->status, 'status' => (string) $upload->status,
'published_at' => optional($upload->published_at)->toISOString(), 'published_at' => optional($upload->published_at)->toISOString(),
'final_path' => (string) ($upload->final_path ?? ''), 'final_path' => (string) ($upload->final_path ?? ''),
], Response::HTTP_OK); ], Response::HTTP_OK);
} catch (UploadOwnershipException $e) { } catch (UploadOwnershipException $e) {
return response()->json(['message' => $e->getMessage()], Response::HTTP_FORBIDDEN); return response()->json(['message' => $e->getMessage()], Response::HTTP_FORBIDDEN);
@@ -794,7 +866,10 @@ final class UploadController extends Controller
'title' => ['nullable', 'string', 'max:150'], 'title' => ['nullable', 'string', 'max:150'],
'description' => ['nullable', 'string'], 'description' => ['nullable', 'string'],
'category' => ['nullable', 'integer', 'exists:categories,id'], 'category' => ['nullable', 'integer', 'exists:categories,id'],
'tags' => ['nullable', 'array', 'max:' . (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'], 'tags.*' => ['string', 'max:64'],
'is_mature' => ['nullable', 'boolean'], 'is_mature' => ['nullable', 'boolean'],
'nsfw' => ['nullable', 'boolean'], 'nsfw' => ['nullable', 'boolean'],
@@ -814,6 +889,8 @@ final class UploadController extends Controller
'world_submissions.*.source_surface' => ['nullable', 'string', 'max:80'], 'world_submissions.*.source_surface' => ['nullable', 'string', 'max:80'],
]); ]);
$this->ensureValidArtworkDescription($validated);
if (! ctype_digit($id)) { if (! ctype_digit($id)) {
return response()->json(['message' => 'Artwork review submission requires an artwork draft id.'], Response::HTTP_UNPROCESSABLE_ENTITY); 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, 'group_review_status' => (string) $artwork->group_review_status,
], Response::HTTP_OK); ], Response::HTTP_OK);
} }
private function ensureValidArtworkDescription(array $validated): void
{
foreach (ArtworkDescriptionContentValidator::errors($validated['description'] ?? null) as $message) {
throw ValidationException::withMessages([
'description' => [$message],
]);
}
}
} }
@@ -30,15 +30,14 @@ final class UploadVisionSuggestController extends Controller
public function __invoke(int $id, Request $request): JsonResponse public function __invoke(int $id, Request $request): JsonResponse
{ {
if (! $this->vision->isEnabled()) {
return response()->json(['tags' => [], 'vision_enabled' => false]);
}
$artwork = Artwork::query()->findOrFail($id); $artwork = Artwork::query()->findOrFail($id);
$this->authorizeOrNotFound($request->user(), $artwork); $this->authorizeOrNotFound($request->user(), $artwork);
$limit = (int) $request->query('limit', 10);
return response()->json($this->vision->suggestTags($artwork, $this->normalizer, $limit)); return response()->json([
'tags' => [],
'vision_enabled' => false,
'reason' => 'disabled',
]);
} }
private function authorizeOrNotFound(mixed $user, Artwork $artwork): void private function authorizeOrNotFound(mixed $user, Artwork $artwork): void
@@ -8,11 +8,13 @@ use App\Models\Artwork;
use App\Models\ArtworkDownload; use App\Models\ArtworkDownload;
use App\Services\ArtworkOriginalFileLocator; use App\Services\ArtworkOriginalFileLocator;
use App\Services\ArtworkStatsService; use App\Services\ArtworkStatsService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\BinaryFileResponse;
@@ -43,19 +45,76 @@ final class ArtworkDownloadController extends Controller
private readonly ArtworkOriginalFileLocator $originalFiles, 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); $artwork = Artwork::query()->find($id);
if (! $artwork) { if (! $artwork) {
abort(404); return $this->notFound();
} }
$filePath = $this->originalFiles->resolveLocalPath($artwork); $resolution = $this->originalFiles->resolve($artwork);
$ext = strtolower(ltrim((string) pathinfo($filePath, PATHINFO_EXTENSION), '.')); $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)) { if (($filePath === '' && $objectKey === '') || ! in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
abort(404); 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); $this->recordDownload($request, $artwork->id);
@@ -70,18 +129,14 @@ final class ArtworkDownloadController extends Controller
]); ]);
} }
if (! File::isFile($filePath)) { if (array_key_exists('download_status', $artwork->getAttributes())) {
Log::warning('Artwork original file missing for download.', [ $artwork->forceFill([
'artwork_id' => $artwork->id, 'download_status' => 'available',
'ext' => $ext, 'download_source' => $resolution['source'],
'resolved_path' => $filePath, 'download_checked_at' => now(),
]); ])->saveQuietly();
abort(404);
} }
$downloadName = $this->buildDownloadFilename((string) $artwork->file_name, $ext);
// X-Accel-Redirect is safe only when nginx is explicitly configured to // X-Accel-Redirect is safe only when nginx is explicitly configured to
// map the internal URI to the originals root. Otherwise fallback to the // map the internal URI to the originals root. Otherwise fallback to the
// normal Laravel download response. // normal Laravel download response.
@@ -90,14 +145,26 @@ final class ArtworkDownloadController extends Controller
return response('', 200, [ return response('', 200, [
'X-Accel-Redirect' => $accelUri, 'X-Accel-Redirect' => $accelUri,
'Content-Type' => 'application/octet-stream', 'Content-Type' => 'application/octet-stream',
'Content-Disposition' => 'attachment; filename="' . addslashes($downloadName) . '"', 'Content-Disposition' => 'attachment; filename="'.addslashes($downloadName).'"',
'X-Content-Type-Options' => 'nosniff', 'X-Content-Type-Options' => 'nosniff',
]); ]);
} }
if ($resolution['source'] === 'object') {
return redirect()->away($temporaryUrl);
}
return response()->download($filePath, $downloadName); 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 private function resolveAccelUri(string $filePath): ?string
{ {
if (! config('app.download_accel_enabled')) { if (! config('app.download_accel_enabled')) {
@@ -116,7 +183,7 @@ final class ArtworkDownloadController extends Controller
$normalizedRoot = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $root); $normalizedRoot = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $root);
$normalizedFilePath = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $filePath); $normalizedFilePath = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $filePath);
$rootPrefix = $normalizedRoot . DIRECTORY_SEPARATOR; $rootPrefix = $normalizedRoot.DIRECTORY_SEPARATOR;
if (! str_starts_with($normalizedFilePath, $rootPrefix)) { if (! str_starts_with($normalizedFilePath, $rootPrefix)) {
Log::warning('Artwork download accel path skipped because file is outside originals root.', [ 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 null;
} }
return $accelBase . str_replace(DIRECTORY_SEPARATOR, '/', $relativePath); return $accelBase.str_replace(DIRECTORY_SEPARATOR, '/', $relativePath);
} }
private function recordDownload(Request $request, int $artworkId): void private function recordDownload(Request $request, int $artworkId): void
@@ -187,10 +254,10 @@ final class ArtworkDownloadController extends Controller
$brandSuffix = $this->downloadBrandSuffix(); $brandSuffix = $this->downloadBrandSuffix();
if ($brandSuffix !== '' && ! Str::contains(Str::lower($baseName), Str::lower($brandSuffix))) { if ($brandSuffix !== '' && ! Str::contains(Str::lower($baseName), Str::lower($brandSuffix))) {
$baseName .= ' (' . $brandSuffix . ')'; $baseName .= ' ('.$brandSuffix.')';
} }
return $baseName . '.' . $ext; return $baseName.'.'.$ext;
} }
private function downloadBrandSuffix(): string private function downloadBrandSuffix(): string
@@ -200,7 +267,7 @@ final class ArtworkDownloadController extends Controller
$host = preg_replace('/^www\./', '', $host) ?? ''; $host = preg_replace('/^www\./', '', $host) ?? '';
if ($host === '' || in_array($host, ['localhost', '127.0.0.1'], true) || str_ends_with($host, '.test')) { if ($host === '' || in_array($host, ['localhost', '127.0.0.1'], true) || str_ends_with($host, '.test')) {
return 'skinbase.top'; return 'skinbase.org';
} }
return $host; return $host;
@@ -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.');
}
}
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest; use App\Http\Requests\Auth\LoginRequest;
use App\Services\Auth\AuthAuditLogger;
use App\Services\Security\CaptchaVerifier; use App\Services\Security\CaptchaVerifier;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -17,6 +18,7 @@ class AuthenticatedSessionController extends Controller
*/ */
public function __construct( public function __construct(
private readonly CaptchaVerifier $captchaVerifier, private readonly CaptchaVerifier $captchaVerifier,
private readonly AuthAuditLogger $authAuditLogger,
) { ) {
} }
@@ -35,9 +37,22 @@ class AuthenticatedSessionController extends Controller
{ {
$request->authenticate(); $request->authenticate();
$user = $request->authenticatedUser();
$this->authAuditLogger->log(
eventType: 'login',
request: $request,
status: 'success',
identifier: (string) $request->input('email'),
user: $user,
metadata: [
'via' => $request->authenticatedViaUsername() ? 'username' : 'email',
'remember' => $request->boolean('remember'),
],
);
$request->session()->regenerate(); $request->session()->regenerate();
$user = $request->authenticatedUser();
if ($user && $request->authenticatedViaUsername() && ! $user->hasCompletedOnboarding()) { if ($user && $request->authenticatedViaUsername() && ! $user->hasCompletedOnboarding()) {
$request->session()->put('username_login_upgrade', true); $request->session()->put('username_login_upgrade', true);
@@ -4,17 +4,24 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\User; use App\Models\User;
use App\Services\Auth\AuthAuditLogger;
use Illuminate\Auth\Events\PasswordReset; use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password; use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\Rules; use Illuminate\Validation\Rules;
use Illuminate\View\View; use Illuminate\View\View;
class NewPasswordController extends Controller class NewPasswordController extends Controller
{ {
public function __construct(
private readonly AuthAuditLogger $authAuditLogger,
) {
}
/** /**
* Display the password reset view. * Display the password reset view.
*/ */
@@ -30,17 +37,36 @@ class NewPasswordController extends Controller
*/ */
public function store(Request $request): RedirectResponse public function store(Request $request): RedirectResponse
{ {
$request->validate([ $validator = Validator::make($request->all(), [
'token' => ['required'], 'token' => ['required'],
'email' => ['required', 'email'], 'email' => ['required', 'email'],
'password' => ['required', 'confirmed', Rules\Password::defaults()], 'password' => ['required', 'confirmed', Rules\Password::defaults()],
]); ]);
// Here we will attempt to reset the user's password. If it is successful we if ($validator->fails()) {
// will update the password on an actual user model and persist it to the $this->authAuditLogger->log(
// database. Otherwise we will parse the error and return the response. eventType: 'reset_password',
request: $request,
status: 'failed',
reason: 'validation_failed',
identifier: (string) $request->input('email'),
metadata: ['fields' => array_keys($validator->errors()->toArray())],
);
$validator->validate();
}
$validated = $validator->validated();
$email = strtolower(trim((string) $validated['email']));
$user = User::query()->whereRaw('LOWER(email) = ?', [$email])->first();
$status = Password::reset( $status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'), [
'email' => $email,
'password' => (string) $validated['password'],
'password_confirmation' => (string) $request->input('password_confirmation'),
'token' => (string) $validated['token'],
],
function (User $user) use ($request) { function (User $user) use ($request) {
$user->forceFill([ $user->forceFill([
'password' => Hash::make($request->password), 'password' => Hash::make($request->password),
@@ -51,12 +77,20 @@ class NewPasswordController extends Controller
} }
); );
// If the password was successfully reset, we will redirect the user back to $success = $status === Password::PASSWORD_RESET;
// the application's home authenticated view. If there is an error we can
// redirect them back to where they came from with their error message. $this->authAuditLogger->log(
return $status == Password::PASSWORD_RESET eventType: 'reset_password',
request: $request,
status: $success ? 'success' : 'failed',
reason: strtolower((string) $status),
identifier: $email,
user: $user,
);
return $success
? redirect()->route('login')->with('status', __($status)) ? redirect()->route('login')->with('status', __($status))
: back()->withInput($request->only('email')) : back()->withInput(['email' => $email])
->withErrors(['email' => __($status)]); ->withErrors(['email' => __($status)]);
} }
} }
@@ -3,13 +3,21 @@
namespace App\Http\Controllers\Auth; namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\Auth\AuthAuditLogger;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password; use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\Validator;
use Illuminate\View\View; use Illuminate\View\View;
class PasswordResetLinkController extends Controller class PasswordResetLinkController extends Controller
{ {
public function __construct(
private readonly AuthAuditLogger $authAuditLogger,
) {
}
/** /**
* Display the password reset link request view. * Display the password reset link request view.
*/ */
@@ -25,20 +33,45 @@ class PasswordResetLinkController extends Controller
*/ */
public function store(Request $request): RedirectResponse public function store(Request $request): RedirectResponse
{ {
$request->validate([ $validator = Validator::make($request->all(), [
'email' => ['required', 'email'], 'email' => ['required', 'email'],
]); ]);
// We will send the password reset link to this user. Once we have attempted if ($validator->fails()) {
// to send the link, we will examine the response then see the message we $this->authAuditLogger->log(
// need to show to the user. Finally, we'll send out a proper response. eventType: 'forgot_password',
request: $request,
status: 'failed',
reason: 'validation_failed',
identifier: (string) $request->input('email'),
metadata: ['fields' => array_keys($validator->errors()->toArray())],
);
$validator->validate();
}
$validated = $validator->validated();
$email = strtolower(trim((string) $validated['email']));
$user = User::query()->whereRaw('LOWER(email) = ?', [$email])->first();
$status = Password::sendResetLink( $status = Password::sendResetLink(
$request->only('email') ['email' => $email]
); );
return $status == Password::RESET_LINK_SENT $success = $status === Password::RESET_LINK_SENT;
$this->authAuditLogger->log(
eventType: 'forgot_password',
request: $request,
status: $success ? 'success' : 'failed',
reason: strtolower((string) $status),
identifier: $email,
user: $user,
);
return $success
? back()->with('status', __($status)) ? back()->with('status', __($status))
: back()->withInput($request->only('email')) : back()->withInput(['email' => $email])
->withErrors(['email' => __($status)]); ->withErrors(['email' => __($status)]);
} }
} }
@@ -6,6 +6,7 @@ use App\Jobs\SendVerificationEmailJob;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\EmailSendEvent; use App\Models\EmailSendEvent;
use App\Models\User; use App\Models\User;
use App\Services\Auth\AuthAuditLogger;
use App\Services\Auth\DisposableEmailService; use App\Services\Auth\DisposableEmailService;
use App\Services\Auth\RegistrationVerificationTokenService; use App\Services\Auth\RegistrationVerificationTokenService;
use App\Services\Security\CaptchaVerifier; use App\Services\Security\CaptchaVerifier;
@@ -15,6 +16,8 @@ use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\View\View; use Illuminate\View\View;
@@ -25,6 +28,7 @@ class RegisteredUserController extends Controller
private readonly TurnstileVerifier $turnstileVerifier, private readonly TurnstileVerifier $turnstileVerifier,
private readonly DisposableEmailService $disposableEmailService, private readonly DisposableEmailService $disposableEmailService,
private readonly RegistrationVerificationTokenService $verificationTokenService, private readonly RegistrationVerificationTokenService $verificationTokenService,
private readonly AuthAuditLogger $authAuditLogger,
) )
{ {
} }
@@ -34,10 +38,17 @@ class RegisteredUserController extends Controller
*/ */
public function create(Request $request): View public function create(Request $request): View
{ {
$cspNonce = $this->resolveCspNonce($request);
return view('auth.register', [ return view('auth.register', [
'prefillEmail' => (string) $request->query('email', ''), 'prefillEmail' => (string) $request->query('email', ''),
'requiresCaptcha' => $this->shouldRequireCaptcha($request->ip()), 'page_canonical' => route('register'),
'captcha' => $this->captchaVerifier->frontendConfig(), 'turnstile' => [
'enabled' => $this->turnstileVerifier->isEnabled(),
'siteKey' => $this->turnstileVerifier->siteKey(),
'scriptUrl' => $this->turnstileVerifier->scriptUrl(),
'cspNonce' => $cspNonce,
],
]); ]);
} }
@@ -59,41 +70,67 @@ class RegisteredUserController extends Controller
*/ */
public function store(Request $request): RedirectResponse public function store(Request $request): RedirectResponse
{ {
$turnstileResponse = (string) ($request->input('turnstile_token') ?: $request->input('cf-turnstile-response', ''));
$rules = [ $rules = [
'email' => ['required', 'string', 'lowercase', 'email', 'max:255'], 'email' => ['required', 'string', 'lowercase', 'email', 'max:255'],
'website' => ['nullable', 'max:0'], 'website' => ['nullable', 'max:0'],
'turnstile_token' => ['nullable', 'string'],
'cf-turnstile-response' => [$this->turnstileVerifier->isEnabled() ? 'required_without:turnstile_token' : 'nullable', 'string'],
]; ];
$rules[$this->captchaVerifier->inputName()] = ['nullable', 'string'];
$validated = $request->validate($rules); $validator = Validator::make($request->all(), $rules);
if ($validator->fails()) {
$errors = $validator->errors()->toArray();
if (array_key_exists('cf-turnstile-response', $errors) && ! array_key_exists('turnstile_token', $errors)) {
$errors['turnstile_token'] = $errors['cf-turnstile-response'];
unset($errors['cf-turnstile-response']);
}
$this->authAuditLogger->log(
eventType: 'register',
request: $request,
status: 'failed',
reason: 'validation_failed',
identifier: (string) $request->input('email'),
metadata: ['fields' => array_keys($errors)],
);
throw ValidationException::withMessages($errors);
}
$validated = $validator->validated();
$email = strtolower(trim((string) $validated['email'])); $email = strtolower(trim((string) $validated['email']));
$ip = $request->ip(); $ip = $request->ip();
$this->trackRegisterAttempt($ip); $this->trackRegisterAttempt($ip);
if ($this->shouldRequireCaptcha($ip)) { if ($this->turnstileVerifier->isEnabled() && ! $this->turnstileVerifier->verify($turnstileResponse, $ip)) {
$verified = $this->captchaVerifier->verify( $this->authAuditLogger->log(
(string) $request->input($this->captchaVerifier->inputName(), ''), eventType: 'register',
$ip request: $request,
status: 'failed',
reason: 'captcha_failed',
identifier: $email,
); );
if ($this->turnstileVerifier->isEnabled()) { return back()
$verified = $this->turnstileVerifier->verify( ->withInput($request->except('website', 'turnstile_token', 'cf-turnstile-response'))
(string) $request->input($this->captchaVerifier->inputName(), ''), ->withErrors(['turnstile_token' => 'Security verification failed. Please try again.']);
$ip
);
}
if (! $verified) {
return back()
->withInput($request->except('website'))
->withErrors(['captcha' => 'Captcha verification failed. Please try again.']);
}
} }
if ($this->disposableEmailService->isDisposableEmail($email)) { if ($this->disposableEmailService->isDisposableEmail($email)) {
$this->logEmailEvent($email, $ip, null, 'blocked', 'disposable'); $this->logEmailEvent($email, $ip, null, 'blocked', 'disposable');
$this->authAuditLogger->log(
eventType: 'register',
request: $request,
status: 'failed',
reason: 'disposable_email',
identifier: $email,
);
return back() return back()
->withInput($request->except('website')) ->withInput($request->except('website'))
@@ -103,6 +140,15 @@ class RegisteredUserController extends Controller
$user = User::query()->where('email', $email)->first(); $user = User::query()->where('email', $email)->first();
if ($user && $user->hasCompletedOnboarding()) { if ($user && $user->hasCompletedOnboarding()) {
$this->authAuditLogger->log(
eventType: 'register',
request: $request,
status: 'failed',
reason: 'email_exists',
identifier: $email,
user: $user,
);
return back() return back()
->withInput($request->except('website')) ->withInput($request->except('website'))
->withErrors(['email' => 'An account with this email already exists.']); ->withErrors(['email' => 'An account with this email already exists.']);
@@ -136,6 +182,15 @@ class RegisteredUserController extends Controller
Auth::login($user); Auth::login($user);
$this->authAuditLogger->log(
eventType: 'register',
request: $request,
status: 'success',
reason: $user->wasRecentlyCreated ? 'user_created' : 'resume_onboarding',
identifier: $email,
user: $user,
);
$needsPasswordSetup = strtolower((string) ($user->onboarding_step ?? '')) !== 'password' $needsPasswordSetup = strtolower((string) ($user->onboarding_step ?? '')) !== 'password'
|| (bool) $user->needs_password_reset; || (bool) $user->needs_password_reset;
@@ -213,23 +268,6 @@ class RegisteredUserController extends Controller
]); ]);
} }
private function shouldRequireCaptcha(?string $ip): bool
{
if (! $this->captchaVerifier->isEnabled()) {
if (! $this->turnstileVerifier->isEnabled()) {
return false;
}
if (! (bool) config('registration.enable_turnstile', true)) {
return false;
}
return $this->turnstileVerifier->isEnabled() && $this->shouldRequireCaptchaForIp($ip);
}
return $this->shouldRequireCaptchaForIp($ip);
}
private function shouldRequireCaptchaForIp(?string $ip): bool private function shouldRequireCaptchaForIp(?string $ip): bool
{ {
if (! $this->captchaVerifier->isEnabled() && ! $this->turnstileVerifier->isEnabled()) { if (! $this->captchaVerifier->isEnabled() && ! $this->turnstileVerifier->isEnabled()) {
@@ -336,4 +374,28 @@ class RegisteredUserController extends Controller
return $remaining >= 0 ? 0 : abs((int) $remaining); return $remaining >= 0 ? 0 : abs((int) $remaining);
} }
private function resolveCspNonce(Request $request): ?string
{
$candidates = [
$request->attributes->get('csp_nonce'),
$request->attributes->get('cspNonce'),
$request->headers->get('X-CSP-Nonce'),
$request->server('HTTP_X_CSP_NONCE'),
];
foreach ($candidates as $candidate) {
if (! is_string($candidate)) {
continue;
}
$nonce = trim($candidate);
if ($nonce !== '') {
return $nonce;
}
}
return null;
}
} }
@@ -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');
}
}
@@ -10,23 +10,46 @@ use App\Services\ThumbnailPresenter;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Contracts\Pagination\Paginator;
class LatestCommentsController extends Controller class LatestCommentsController extends Controller
{ {
private const PER_PAGE = 20; private const PER_PAGE = 20;
private function paginationMeta(Paginator $paginator): array
{
return [
'current_page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
'has_more' => $paginator->hasMorePages(),
];
}
public function index(Request $request) public function index(Request $request)
{ {
$page_title = 'Latest Comments'; $page_title = 'Latest Comments';
// Build initial (first-page, type=all) data for React SSR props // Build initial (first-page, type=all) data for React SSR props
$initialData = Cache::remember('comments.latest.all.page1', 120, function () { $initialData = Cache::remember('comments.latest.all.page1', 120, function () {
return ArtworkComment::with(['user', 'user.profile', 'artwork']) return ArtworkComment::query()
->select([
'artwork_comments.id',
'artwork_comments.artwork_id',
'artwork_comments.user_id',
'artwork_comments.content',
'artwork_comments.created_at',
])
->with([
'user:id,username,name',
'user.profile:user_id,avatar_hash',
'artwork:id,title,slug,hash,file_path,file_name',
])
->whereHas('artwork', function ($q) { ->whereHas('artwork', function ($q) {
$q->public()->published()->whereNull('deleted_at'); $q->public()->published()->whereNull('deleted_at');
}) })
->orderByDesc('artwork_comments.created_at') ->orderByDesc('artwork_comments.created_at')
->paginate(self::PER_PAGE); ->orderByDesc('artwork_comments.id')
->simplePaginate(self::PER_PAGE);
}); });
$items = $initialData->getCollection()->map(function (ArtworkComment $c) { $items = $initialData->getCollection()->map(function (ArtworkComment $c) {
@@ -64,13 +87,7 @@ class LatestCommentsController extends Controller
$props = [ $props = [
'initialComments' => $items->values()->all(), 'initialComments' => $items->values()->all(),
'initialMeta' => [ 'initialMeta' => $this->paginationMeta($initialData),
'current_page' => $initialData->currentPage(),
'last_page' => $initialData->lastPage(),
'per_page' => $initialData->perPage(),
'total' => $initialData->total(),
'has_more' => $initialData->hasMorePages(),
],
'isAuthenticated' => (bool) auth()->user(), 'isAuthenticated' => (bool) auth()->user(),
]; ];
@@ -3,12 +3,14 @@
namespace App\Http\Controllers\Dashboard; namespace App\Http\Controllers\Dashboard;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Manage\ManageArtworkDestroyRequest;
use App\Http\Requests\Manage\ManageArtworkEditRequest; use App\Http\Requests\Manage\ManageArtworkEditRequest;
use App\Http\Requests\Manage\ManageArtworkUpdateRequest; 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\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class ManageController extends Controller class ManageController extends Controller
{ {
@@ -17,14 +19,12 @@ class ManageController extends Controller
$userId = $request->user()->id; $userId = $request->user()->id;
$perPage = 50; $perPage = 50;
$categorySub = DB::table('artwork_category as ac') $categorySub = DB::table('categories as c')
->join('categories as c', 'ac.category_id', '=', 'c.id') ->select('c.id as category_id', 'c.name as category_name');
->select('ac.artwork_id', DB::raw('MIN(c.name) as category_name'))
->groupBy('ac.artwork_id');
$query = DB::table('artworks as a') $query = DB::table('artworks as a')
->leftJoinSub($categorySub, 'cat', function ($join) { ->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') ->leftJoin('artwork_stats as s', 'a.id', '=', 's.artwork_id')
->where('a.user_id', $userId) ->where('a.user_id', $userId)
@@ -52,8 +52,8 @@ class ManageController extends Controller
{ {
$artwork = $request->artwork(); $artwork = $request->artwork();
$selectedCategory = DB::table('artwork_category')->where('artwork_id', (int)$id)->value('category_id'); $selectedCategory = (int) ($artwork->primary_category_id ?: DB::table('artwork_category')->where('artwork_id', (int) $id)->value('category_id'));
$artwork->category = $selectedCategory; $artwork->category = $selectedCategory ?: null;
$categories = DB::table('categories') $categories = DB::table('categories')
->where('content_type_id', 0) ->where('content_type_id', 0)
@@ -64,7 +64,7 @@ class ManageController extends Controller
return view('manage.edit', [ return view('manage.edit', [
'artwork' => $artwork, 'artwork' => $artwork,
'categories' => $categories, '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); $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'])) { if (isset($data['section'])) {
DB::table('artwork_category')->where('artwork_id', (int)$id)->delete(); $model = Artwork::query()->find((int) $id);
DB::table('artwork_category')->insert([ if ($model) {
'artwork_id' => (int)$id, app(ArtworkCategoryService::class)
'category_id' => (int)$data['section'], ->sync($model, (int) $data['section'], [], 'moderator');
]); }
} }
return redirect()->route('manage')->with('status', 'Artwork was successfully updated.'); return redirect()->route('manage')->with('status', 'Artwork was successfully updated.');
@@ -108,14 +108,14 @@ class ManageController extends Controller
{ {
$artwork = $request->artwork(); $artwork = $request->artwork();
if (!empty($artwork->fname)) { if (! empty($artwork->fname)) {
Storage::delete('public/uploads/attachments/' . $artwork->fname); Storage::delete('public/uploads/attachments/'.$artwork->fname);
} }
if (!empty($artwork->picture)) { if (! empty($artwork->picture)) {
Storage::delete('public/uploads/artworks/' . $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.'); 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,
];
}
}
@@ -98,7 +98,7 @@ class ForumController extends Controller
$thread->loadMissing([ $thread->loadMissing([
'category:id,name,slug', 'category:id,name,slug',
'user:id,name', 'user:id,name,username',
'user.profile:user_id,avatar_hash', 'user.profile:user_id,avatar_hash',
]); ]);
@@ -116,7 +116,7 @@ class ForumController extends Controller
$opPost = ForumPost::query() $opPost = ForumPost::query()
->where('thread_id', $thread->id) ->where('thread_id', $thread->id)
->with([ ->with([
'user:id,name', 'user:id,name,username',
'user.profile:user_id,avatar_hash', 'user.profile:user_id,avatar_hash',
'attachments:id,post_id,file_path,file_size,mime_type,width,height', 'attachments:id,post_id,file_path,file_size,mime_type,width,height',
]) ])
@@ -128,7 +128,7 @@ class ForumController extends Controller
->where('thread_id', $thread->id) ->where('thread_id', $thread->id)
->when($opPost, fn ($query) => $query->where('id', '!=', $opPost->id)) ->when($opPost, fn ($query) => $query->where('id', '!=', $opPost->id))
->with([ ->with([
'user:id,name', 'user:id,name,username',
'user.profile:user_id,avatar_hash', 'user.profile:user_id,avatar_hash',
'attachments:id,post_id,file_path,file_size,mime_type,width,height', 'attachments:id,post_id,file_path,file_size,mime_type,width,height',
]) ])
@@ -148,7 +148,7 @@ class ForumController extends Controller
if ($quotePostId > 0) { if ($quotePostId > 0) {
$quotedPost = ForumPost::query() $quotedPost = ForumPost::query()
->where('thread_id', $thread->id) ->where('thread_id', $thread->id)
->with('user:id,name') ->with('user:id,name,username')
->find($quotePostId); ->find($quotePostId);
} }
+19 -2
View File
@@ -79,6 +79,7 @@ class GroupController extends Controller
{ {
$this->authorize('view', $group); $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(); $viewer = $request->user();
$group->loadMissing('owner.profile'); $group->loadMissing('owner.profile');
$members = collect($this->memberships->mapMembers($group, $viewer)) $members = collect($this->memberships->mapMembers($group, $viewer))
@@ -89,7 +90,8 @@ class GroupController extends Controller
return Inertia::render('Group/GroupShow', [ return Inertia::render('Group/GroupShow', [
'group' => $groupPayload, '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), 'featuredArtworks' => $this->groups->featuredArtworkCards($group),
'artworks' => $this->groups->publicArtworkCards($group), 'artworks' => $this->groups->publicArtworkCards($group),
'featuredCollections' => $this->groups->featuredCollectionCards($group, $viewer), 'featuredCollections' => $this->groups->featuredCollectionCards($group, $viewer),
@@ -140,4 +142,19 @@ class GroupController extends Controller
{ {
return $this->show($request, $group, 'activity'); 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 App\Models\Artwork;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
final class LegacyArtworkPhotoController extends Controller final class LegacyArtworkPhotoController extends Controller
@@ -26,25 +27,39 @@ final class LegacyArtworkPhotoController extends Controller
private static ?bool $hasLegacyIdColumn = null; 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); $artworkId = $this->decodeBase62($encoded);
$sizeCode = (int) $size; $sizeCode = (int) $size;
abort_if($artworkId === null || $artworkId < 1, 404); if ($artworkId === null || $artworkId < 1) {
return $this->notFound();
}
$artwork = $this->resolveArtwork($artworkId); $artwork = $this->resolveArtwork($artworkId);
abort_unless($artwork !== null, 404); if ($artwork === null) {
return $this->notFound();
}
$targetUrl = $sizeCode === 7 $targetUrl = $sizeCode === 7
? $this->resolveOriginalUrl($artwork) ? $this->resolveOriginalUrl($artwork)
: $artwork->thumbUrl(self::THUMB_SIZE_MAP[$sizeCode] ?? 'md'); : $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); 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 private function decodeBase62(string $value): ?int
{ {
if ($value === '') { if ($value === '') {
@@ -86,11 +101,6 @@ final class LegacyArtworkPhotoController extends Controller
{ {
$cdn = rtrim((string) config('cdn.files_url', 'https://cdn.skinbase.org'), '/'); $cdn = rtrim((string) config('cdn.files_url', 'https://cdn.skinbase.org'), '/');
$filePath = trim((string) ($artwork->file_path ?? ''), '/'); $filePath = trim((string) ($artwork->file_path ?? ''), '/');
if ($filePath !== '') {
return $cdn . '/' . $filePath;
}
$hash = strtolower((string) preg_replace('/[^a-f0-9]/i', '', (string) ($artwork->hash ?? ''))); $hash = strtolower((string) preg_replace('/[^a-f0-9]/i', '', (string) ($artwork->hash ?? '')));
$ext = ltrim((string) ($artwork->file_ext ?: $artwork->thumb_ext ?: 'webp'), '.'); $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'); return $artwork->thumbUrl('xl') ?? $artwork->thumbUrl('lg') ?? $artwork->thumbUrl('md');
} }
if ($filePath !== '') {
return $cdn . '/' . $filePath;
}
$prefix = trim((string) config('uploads.object_storage.prefix', 'artworks'), '/'); $prefix = trim((string) config('uploads.object_storage.prefix', 'artworks'), '/');
$firstDir = substr($hash, 0, 2); $firstDir = substr($hash, 0, 2);
$secondDir = substr($hash, 2, 2); $secondDir = substr($hash, 2, 2);
@@ -3,11 +3,12 @@
namespace App\Http\Controllers\Legacy; namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\Request; use App\Models\Artwork;
use App\Models\ArtworkDownload; use App\Models\ArtworkDownload;
use Illuminate\Support\Facades\Storage; use App\Services\ThumbnailPresenter;
use Illuminate\Support\Str;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class TodayDownloadsController extends Controller class TodayDownloadsController extends Controller
{ {
@@ -35,16 +36,16 @@ class TodayDownloadsController extends Controller
$art = $row->artwork ?? null; $art = $row->artwork ?? null;
// If Eloquent didn't eager load artwork (group queries sometimes don't), fetch it // If Eloquent didn't eager load artwork (group queries sometimes don't), fetch it
if (! $art && isset($row->artwork_id)) { if (! $art && isset($row->artwork_id)) {
$art = \App\Models\Artwork::find($row->artwork_id); $art = Artwork::find($row->artwork_id);
} }
$name = $art->title ?? null; $name = $art->title ?? null;
$picture = $art->file_name ?? null; $picture = $art->file_name ?? null;
$ext = pathinfo($picture ?? '', PATHINFO_EXTENSION) ?: 'jpg'; $ext = pathinfo($picture ?? '', PATHINFO_EXTENSION) ?: 'jpg';
$encoded = null; // legacy encoding unavailable; leave null $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'; $thumb = $present ? $present['url'] : 'https://files.skinbase.org/default/missing_md.webp';
$categoryId = $art->categories->first()->id ?? null; $categoryId = $art->resolvedPrimaryCategory()?->id;
return (object) [ return (object) [
'id' => $art->id ?? null, 'id' => $art->id ?? null,
+78 -33
View File
@@ -3,12 +3,12 @@
namespace App\Http\Controllers\Legacy; namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller; 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 App\Services\AvatarService;
use Carbon\Carbon; 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 class UserController extends Controller
{ {
@@ -37,7 +37,7 @@ class UserController extends Controller
} }
} else { } else {
// Map legacy form fields into the modern schema. // 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` // Core user column: `name`
if (isset($data['name'])) { if (isset($data['name'])) {
@@ -46,24 +46,43 @@ class UserController extends Controller
// Collect other profile updates to persist into `user_profiles` when available // Collect other profile updates to persist into `user_profiles` when available
$profileUpdates = []; $profileUpdates = [];
if (!empty($data['web'])) $profileUpdates['website'] = $data['web']; if (! empty($data['web'])) {
if (!empty($data['signature'])) $profileUpdates['signature'] = $data['signature']; $profileUpdates['website'] = $data['web'];
if (!empty($data['description'])) $profileUpdates['description'] = $data['description']; }
if (!empty($data['about_me'])) $profileUpdates['about'] = $data['about_me']; if (! empty($data['signature'])) {
if (!empty($data['country_code'])) $profileUpdates['country_code'] = $data['country_code']; $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'); $d1 = $request->input('date1');
$d2 = $request->input('date2'); $d2 = $request->input('date2');
$d3 = $request->input('date3'); $d3 = $request->input('date3');
if ($d1 && $d2 && $d3) { 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); $userGender = $request->input('gender', $user->gender);
if (!empty($userGender)) { if ($userGender !== null && $userGender !== '') {
$g = strtolower($userGender); $g = strtolower(trim((string) $userGender));
$map = ['m' => 'M', 'f' => 'F', 'n' => 'X', 'x' => 'X']; $map = [
$profileUpdates['gender'] = $map[$g] ?? strtoupper($userGender); '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; $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')) { if ($request->hasFile('personal_picture')) {
$f = $request->file('personal_picture'); $f = $request->file('personal_picture');
$name = $user->id . '.' . $f->getClientOriginalExtension(); if (in_array($f->getMimeType(), $allowedLegacyMimes, true)) {
$f->move(public_path('user-picture'), $name); $ext = $f->guessExtension() ?: 'jpg';
$profileUpdates['cover_image'] = $name; $name = $user->id.'.'.$ext;
$user->picture = $name; $f->move(public_path('user-picture'), $name);
$profileUpdates['cover_image'] = $name;
$user->picture = $name;
}
} }
if ($request->hasFile('emotion_icon')) { if ($request->hasFile('emotion_icon')) {
$f = $request->file('emotion_icon'); $f = $request->file('emotion_icon');
$name = $user->id . '.' . $f->getClientOriginalExtension(); if (in_array($f->getMimeType(), $allowedLegacyMimes, true)) {
$f->move(public_path('emotion'), $name); $ext = $f->guessExtension() ?: 'jpg';
$user->eicon = $name; $name = $user->id.'.'.$ext;
$f->move(public_path('emotion'), $name);
$user->eicon = $name;
}
} }
// Save core user fields // 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 // Persist profile updates into `user_profiles` when available, otherwise fallback to `users` table
try { 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()]); 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); DB::table('users')->where('id', $user->id)->update($profileUpdates);
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
@@ -135,15 +162,33 @@ class UserController extends Controller
$profile = DB::table('user_profiles')->where('user_id', $user->id)->first(); $profile = DB::table('user_profiles')->where('user_id', $user->id)->first();
if ($profile) { if ($profile) {
// map modern profile fields onto the legacy user properties/helpers used by the view // 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->website)) {
if (isset($profile->about)) $user->about_me = $profile->about; $user->homepage = $profile->website;
if (isset($profile->birthdate)) $user->birth = $profile->birthdate; }
if (isset($profile->gender)) $user->gender = $profile->gender; if (isset($profile->about)) {
if (isset($profile->country_code)) $user->country_code = $profile->country_code; $user->about_me = $profile->about;
if (isset($profile->avatar_hash)) $user->icon = $profile->avatar_hash; }
if (isset($profile->cover_image)) $user->picture = $profile->cover_image; if (isset($profile->birthdate)) {
if (isset($profile->signature)) $user->signature = $profile->signature; $user->birth = $profile->birthdate;
if (isset($profile->description)) $user->description = $profile->description; }
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) { } 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,
];
}
}

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