Compare commits

...

18 Commits

Author SHA1 Message Date
18b772a4ba Merge branch 'develop' 2026-05-08 21:53:03 +02:00
ff96ef796e chore: commit remaining workspace changes 2026-05-08 21:51:29 +02:00
8d108b8a76 Homepage: add stable intro copy; mark footer utility links data-nosnippet; add render test 2026-05-08 21:51:28 +02:00
6b83d76cd1 SEO: gallery VisualArtwork contentUrl -> single best URL; update gallery unit test 2026-05-08 21:51:28 +02:00
0c5dde9b22 Featured artworks thumbnails 2026-05-06 19:11:31 +02:00
82f2b1f660 Add tests for featured thumbnail generation; apply Pint formatting and related edits 2026-05-06 18:55:40 +02:00
7a8bc8e22a Wire homepage hero to featured thumbnail family; add featured-picture component 2026-05-06 18:55:20 +02:00
8fa3adf4df Add job and artisan command for generating featured thumbnails 2026-05-06 18:55:08 +02:00
bd8a5c14a0 Add FeaturedArtworkThumbnailGenerator and FeaturedArtworkSelector 2026-05-06 18:54:57 +02:00
2c2c0f6722 Add Artwork model convenience methods for featured thumbnails 2026-05-06 18:54:31 +02:00
ee24111d59 Add featured thumbnail config and ArtworkFeaturedImagePath helper 2026-05-06 18:54:18 +02:00
a3cfc6c17f feat(academy): prepare AI Academy v1 for production enablement 2026-05-03 19:59:27 +02:00
90e93f0d42 Fixes 2026-05-03 09:21:13 +02:00
44354e5bea News: normalize category select values; fix Studio news editor category persistence; add CSV→SQL generator and news_dates.sql 2026-05-03 09:12:38 +02:00
a9dfa6ea11 Refine SEO, uploads, and deploy handling 2026-05-02 10:48:08 +02:00
b6be6ed2ac chore: commit regenerated ssr assets 2026-05-02 09:37:28 +02:00
caf1464aa5 chore: commit current workspace changes 2026-05-02 09:37:14 +02:00
494dbce452 Merge branch 'develop' 2026-04-06 09:38:24 +02:00
447 changed files with 456244 additions and 7174 deletions

View File

@@ -172,7 +172,9 @@ class NewsController extends Controller
$userId = Auth::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;
}
@@ -185,7 +187,9 @@ class NewsController extends Controller
$article->incrementViews();
$request->session()->put($session, true);
if ($canReadSession) {
$request->session()->put($session, true);
}
}
private function sidebarData(): array

View File

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

View File

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

View File

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

View File

@@ -107,7 +107,7 @@ export default function ArtworkMaturityQueue() {
], [stats])
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" />
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.16),transparent_36%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.88))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">

View File

@@ -56,6 +56,7 @@ export default function Topbar({ user = null }) {
<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="/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>
<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"

View File

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

View File

@@ -245,6 +245,7 @@
data-username="{{ Auth::user()->username ?? '' }}"
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-moderation-url="{{ in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true) ? '/moderation' : '' }}"
@endif
></div>
@include('layouts.nova.toolbar')

View File

@@ -310,6 +310,7 @@
@php
$toolbarUsername = strtolower((string) (Auth::user()->username ?? ''));
$routeUpload = Route::has('upload') ? route('upload') : '/upload';
$routeModeration = '/moderation';
$routeDashboard = Route::has('dashboard') ? route('dashboard') : '/dashboard';
$routeMyArtworks = Route::has('studio.artworks') ? route('studio.artworks') : '/studio/artworks';
$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>
Studio
</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 }}">
<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
@@ -401,13 +408,6 @@
Settings
</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>
<form method="POST" action="{{ route('logout') }}" class="mb-1">
@csrf

View File

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

View File

@@ -41,7 +41,7 @@ SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
# Skinbase Nova conditional public sessions
# Skinbase conditional public sessions
SKINBASE_CONDITIONAL_SESSIONS_ENABLED=true
SKINBASE_SKIP_ANONYMOUS_PUBLIC_GET_SESSIONS=true
SKINBASE_SKIP_BOT_PUBLIC_GET_SESSIONS=true
@@ -298,7 +298,7 @@ REGISTRATION_IP_PER_DAY_LIMIT=20
REGISTRATION_EMAIL_PER_MINUTE_LIMIT=6
REGISTRATION_EMAIL_COOLDOWN_MINUTES=30
REGISTRATION_VERIFY_TOKEN_TTL_HOURS=24
REGISTRATION_ENABLE_TURNSTILE=true
TURNSTILE_ENABLED=false
REGISTRATION_DISPOSABLE_DOMAINS_ENABLED=true
REGISTRATION_TURNSTILE_SUSPICIOUS_ATTEMPTS=2
REGISTRATION_TURNSTILE_ATTEMPT_WINDOW_MINUTES=30
@@ -306,6 +306,7 @@ REGISTRATION_EMAIL_GLOBAL_SEND_PER_MINUTE=30
REGISTRATION_MONTHLY_EMAIL_LIMIT=10000
TURNSTILE_SITE_KEY=
TURNSTILE_SECRET_KEY=
TURNSTILE_FAIL_OPEN=false
TURNSTILE_VERIFY_URL=https://challenges.cloudflare.com/turnstile/v0/siteverify
TURNSTILE_TIMEOUT=5

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ use Illuminate\Support\Facades\Storage;
/**
* Builds all sitemap documents and writes them as static .xml files to the
* public disk (default: public/sitemap.xml and public/sitemaps/{name}.xml).
* public disk (default: public/sitemaps/sitemap.xml and public/sitemaps/{name}.xml).
*
* Nginx can then serve those files directly (try_files $uri @php) without
* hitting PHP at all. The SitemapController falls back to these same files
@@ -25,11 +25,11 @@ final class GenerateSitemapsCommand extends Command
{--only=* : Limit to specific sitemap families (comma or space separated)}
{--disk= : Override the target filesystem disk (default: sitemaps.static_publish.disk)}';
protected $description = 'Build all sitemaps and write them as static .xml files to public/.';
protected $description = 'Build all sitemaps and write them as static .xml files to the configured public sitemap disk.';
public function handle(SitemapBuildService $build): int
{
$totalS tart = microtime(true);
$totalStart = microtime(true);
$families = $this->selectedFamilies($build);
if ($families === []) {
@@ -50,10 +50,10 @@ final class GenerateSitemapsCommand extends Command
// ── Root sitemap index ────────────────────────────────────────────
$t = microtime(true);
$index = $build->buildIndex(force: true, persist: false, families: $families);
$disk->put('sitemap.xml', $index['content']);
$disk->put('sitemaps/sitemap.xml', $index['content']);
$written++;
$this->line(sprintf(
' <info>✔</info> sitemap.xml %d entries <comment>%.3fs</comment>',
' <info>✔</info> sitemaps/sitemap.xml %d entries <comment>%.3fs</comment>',
$index['url_count'],
microtime(true) - $t,
));

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ use App\Console\Commands\BackfillArtworkVectorIndexCommand;
use App\Console\Commands\IndexArtworkVectorsCommand;
use App\Console\Commands\SearchArtworkVectorsCommand;
use App\Console\Commands\AggregateSimilarArtworkAnalyticsCommand;
use App\Console\Commands\AcademyCoursesSyncFoundationsCommand;
use App\Console\Commands\AggregateFeedAnalyticsCommand;
use App\Console\Commands\AggregateTagInteractionAnalyticsCommand;
use App\Console\Commands\SeedTagInteractionDemoCommand;
@@ -71,6 +72,7 @@ class Kernel extends ConsoleKernel
ZipUnsupportedArtworkOriginalsCommand::class,
SendTestMail::class,
DispatchCollectionMaintenanceCommand::class,
AcademyCoursesSyncFoundationsCommand::class,
BackfillArtworkEmbeddingsCommand::class,
BackfillArtworkVectorIndexCommand::class,
IndexArtworkVectorsCommand::class,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,42 @@
<?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 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);
return response()->json([
'ok' => true,
'completed' => true,
'completed_at' => $record->completed_at?->toISOString(),
]);
}
}

View File

@@ -0,0 +1,116 @@
<?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\AcademyCacheService;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
final class AcademyPromptController extends Controller
{
public function __construct(
private readonly AcademyAccessService $access,
private readonly AcademyCacheService $cache,
) {
}
public function index(Request $request): Response
{
abort_unless((bool) config('academy.enabled', true), 404);
$filters = $request->validate([
'q' => ['nullable', 'string', 'max:120'],
'category' => ['nullable', 'string', 'max:140'],
'difficulty' => ['nullable', 'string', 'max:40'],
'tag' => ['nullable', 'string', 'max:60'],
]);
$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()));
$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();
return Inertia::render('Academy/List', [
'pageType' => 'prompts',
'title' => 'Prompt library',
'description' => 'Reusable prompt templates for wallpapers, worlds, mascots, covers, and digital art workflows.',
'seo' => $seo,
'items' => $prompts,
'filters' => $filters,
'categories' => $this->cache->categoriesByType('prompt'),
'pricingUrl' => route('academy.pricing'),
])->rootView('collections');
}
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();
return Inertia::render('Academy/Show', [
'pageType' => 'prompt',
'item' => $payload,
'seo' => $seo,
'pricingUrl' => route('academy.pricing'),
'saveUrl' => $request->user() ? route('academy.prompts.save', ['prompt' => $prompt->id]) : null,
'unsaveUrl' => $request->user() ? route('academy.prompts.unsave', ['prompt' => $prompt->id]) : null,
'saved' => $request->user()?->academySavedPrompts()->where('prompt_template_id', $prompt->id)->exists() ?? false,
])->rootView('collections');
}
}

View File

@@ -0,0 +1,82 @@
<?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\Support\Seo\SeoFactory;
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)
{
}
public function index(Request $request): Response
{
abort_unless((bool) config('academy.enabled', true), 404);
$packs = AcademyPromptPack::query()
->with('prompts')
->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();
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'),
])->rootView('collections');
}
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);
$seo = app(SeoFactory::class)->collectionPage(
$pack->title . ' — Skinbase Academy',
Str::limit((string) ($pack->excerpt ?? $pack->description ?? ''), 160, '...'),
route('academy.packs.show', ['slug' => $pack->slug]),
$pack->cover_image,
)->toArray();
return Inertia::render('Academy/Show', [
'pageType' => 'pack',
'item' => $payload,
'seo' => $seo,
'pricingUrl' => route('academy.pricing'),
])->rootView('collections');
}
}

View File

@@ -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,
]);
}
}

View File

@@ -6,11 +6,19 @@ namespace App\Http\Controllers\Admin;
use App\Enums\UserRole;
use App\Http\Controllers\Controller;
use App\Models\AuthAuditLog;
use App\Models\Artwork;
use App\Models\Report;
use App\Models\Story;
use App\Models\Upload;
use App\Models\User;
use App\Support\Moderation\ReportTargetResolver;
use Illuminate\Database\Query\Builder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia;
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 ─────────────────────────────────────────────────────────────────
public function users(Request $request): Response
@@ -157,4 +411,115 @@ final class AdminController extends Controller
'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;
}
}
}

View File

@@ -30,7 +30,19 @@ class LatestCommentsApiController extends Controller
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) {
$q->public()->published()->whereNull('deleted_at');
})

View File

@@ -30,12 +30,7 @@ class PostTrendingFeedController extends Controller
$result = $this->trendingService->getTrending($viewer, $page);
$formatted = array_map(
fn ($post) => $this->feedService->formatPost($post, $viewer),
$result['data'],
);
return response()->json(['data' => array_values($formatted), 'meta' => $result['meta']]);
return response()->json($result);
}
public function hashtag(Request $request, string $tag): JsonResponse

View File

@@ -13,9 +13,11 @@ use App\Support\UsernamePolicy;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Pagination\Cursor;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use UnexpectedValueException;
/**
* ProfileApiController
@@ -59,8 +61,23 @@ final class ProfileApiController extends Controller
$query = $this->applyArtworkSort($query, $sort);
$perPage = 24;
$paginator = $query->cursorPaginate($perPage);
$perPage = 24;
$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())
->map(fn (Artwork $art) => $this->mapArtworkCardPayload($art))
@@ -196,14 +213,15 @@ final class ProfileApiController extends Controller
return $query
->leftJoin('artwork_stats as profile_artwork_stats', 'profile_artwork_stats.artwork_id', '=', 'artworks.id')
->select('artworks.*')
->orderByDesc($statsColumn)
->orderByDesc('artworks.published_at')
->orderByDesc('artworks.id');
->selectRaw('COALESCE(' . $statsColumn . ', 0) as cursor_sort_value')
->orderByDesc('cursor_sort_value')
->orderByDesc('published_at')
->orderByDesc('id');
}
return $query
->orderByDesc('artworks.published_at')
->orderByDesc('artworks.id');
->orderByDesc('published_at')
->orderByDesc('id');
}
/**

View File

@@ -30,15 +30,14 @@ final class UploadVisionSuggestController extends Controller
public function __invoke(int $id, Request $request): JsonResponse
{
if (! $this->vision->isEnabled()) {
return response()->json(['tags' => [], 'vision_enabled' => false]);
}
$artwork = Artwork::query()->findOrFail($id);
$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

View File

@@ -200,7 +200,7 @@ final class ArtworkDownloadController extends Controller
$host = preg_replace('/^www\./', '', $host) ?? '';
if ($host === '' || in_array($host, ['localhost', '127.0.0.1'], true) || str_ends_with($host, '.test')) {
return 'skinbase.top';
return 'skinbase.org';
}
return $host;

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use App\Services\Auth\AuthAuditLogger;
use App\Services\Security\CaptchaVerifier;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -17,6 +18,7 @@ class AuthenticatedSessionController extends Controller
*/
public function __construct(
private readonly CaptchaVerifier $captchaVerifier,
private readonly AuthAuditLogger $authAuditLogger,
) {
}
@@ -35,9 +37,22 @@ class AuthenticatedSessionController extends Controller
{
$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();
$user = $request->authenticatedUser();
if ($user && $request->authenticatedViaUsername() && ! $user->hasCompletedOnboarding()) {
$request->session()->put('username_login_upgrade', true);

View File

@@ -4,17 +4,24 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\Auth\AuthAuditLogger;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules;
use Illuminate\View\View;
class NewPasswordController extends Controller
{
public function __construct(
private readonly AuthAuditLogger $authAuditLogger,
) {
}
/**
* Display the password reset view.
*/
@@ -30,17 +37,36 @@ class NewPasswordController extends Controller
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
$validator = Validator::make($request->all(), [
'token' => ['required'],
'email' => ['required', 'email'],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
// Here we will attempt to reset the user's password. If it is successful we
// will update the password on an actual user model and persist it to the
// database. Otherwise we will parse the error and return the response.
if ($validator->fails()) {
$this->authAuditLogger->log(
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(
$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) {
$user->forceFill([
'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
// 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.
return $status == Password::PASSWORD_RESET
$success = $status === Password::PASSWORD_RESET;
$this->authAuditLogger->log(
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))
: back()->withInput($request->only('email'))
: back()->withInput(['email' => $email])
->withErrors(['email' => __($status)]);
}
}

View File

@@ -3,13 +3,21 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\Auth\AuthAuditLogger;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\Validator;
use Illuminate\View\View;
class PasswordResetLinkController extends Controller
{
public function __construct(
private readonly AuthAuditLogger $authAuditLogger,
) {
}
/**
* Display the password reset link request view.
*/
@@ -25,20 +33,45 @@ class PasswordResetLinkController extends Controller
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
$validator = Validator::make($request->all(), [
'email' => ['required', 'email'],
]);
// We will send the password reset link to this user. Once we have attempted
// to send the link, we will examine the response then see the message we
// need to show to the user. Finally, we'll send out a proper response.
if ($validator->fails()) {
$this->authAuditLogger->log(
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(
$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()->withInput($request->only('email'))
: back()->withInput(['email' => $email])
->withErrors(['email' => __($status)]);
}
}

View File

@@ -6,6 +6,7 @@ use App\Jobs\SendVerificationEmailJob;
use App\Http\Controllers\Controller;
use App\Models\EmailSendEvent;
use App\Models\User;
use App\Services\Auth\AuthAuditLogger;
use App\Services\Auth\DisposableEmailService;
use App\Services\Auth\RegistrationVerificationTokenService;
use App\Services\Security\CaptchaVerifier;
@@ -15,6 +16,8 @@ use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Illuminate\Support\Str;
use Illuminate\View\View;
@@ -25,6 +28,7 @@ class RegisteredUserController extends Controller
private readonly TurnstileVerifier $turnstileVerifier,
private readonly DisposableEmailService $disposableEmailService,
private readonly RegistrationVerificationTokenService $verificationTokenService,
private readonly AuthAuditLogger $authAuditLogger,
)
{
}
@@ -34,10 +38,16 @@ class RegisteredUserController extends Controller
*/
public function create(Request $request): View
{
$cspNonce = $this->resolveCspNonce($request);
return view('auth.register', [
'prefillEmail' => (string) $request->query('email', ''),
'requiresCaptcha' => $this->shouldRequireCaptcha($request->ip()),
'captcha' => $this->captchaVerifier->frontendConfig(),
'turnstile' => [
'enabled' => $this->turnstileVerifier->isEnabled(),
'siteKey' => $this->turnstileVerifier->siteKey(),
'scriptUrl' => $this->turnstileVerifier->scriptUrl(),
'cspNonce' => $cspNonce,
],
]);
}
@@ -59,41 +69,67 @@ class RegisteredUserController extends Controller
*/
public function store(Request $request): RedirectResponse
{
$turnstileResponse = (string) ($request->input('turnstile_token') ?: $request->input('cf-turnstile-response', ''));
$rules = [
'email' => ['required', 'string', 'lowercase', 'email', 'max:255'],
'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']));
$ip = $request->ip();
$this->trackRegisterAttempt($ip);
if ($this->shouldRequireCaptcha($ip)) {
$verified = $this->captchaVerifier->verify(
(string) $request->input($this->captchaVerifier->inputName(), ''),
$ip
if ($this->turnstileVerifier->isEnabled() && ! $this->turnstileVerifier->verify($turnstileResponse, $ip)) {
$this->authAuditLogger->log(
eventType: 'register',
request: $request,
status: 'failed',
reason: 'captcha_failed',
identifier: $email,
);
if ($this->turnstileVerifier->isEnabled()) {
$verified = $this->turnstileVerifier->verify(
(string) $request->input($this->captchaVerifier->inputName(), ''),
$ip
);
}
if (! $verified) {
return back()
->withInput($request->except('website'))
->withErrors(['captcha' => 'Captcha verification failed. Please try again.']);
}
return back()
->withInput($request->except('website', 'turnstile_token', 'cf-turnstile-response'))
->withErrors(['turnstile_token' => 'Security verification failed. Please try again.']);
}
if ($this->disposableEmailService->isDisposableEmail($email)) {
$this->logEmailEvent($email, $ip, null, 'blocked', 'disposable');
$this->authAuditLogger->log(
eventType: 'register',
request: $request,
status: 'failed',
reason: 'disposable_email',
identifier: $email,
);
return back()
->withInput($request->except('website'))
@@ -103,6 +139,15 @@ class RegisteredUserController extends Controller
$user = User::query()->where('email', $email)->first();
if ($user && $user->hasCompletedOnboarding()) {
$this->authAuditLogger->log(
eventType: 'register',
request: $request,
status: 'failed',
reason: 'email_exists',
identifier: $email,
user: $user,
);
return back()
->withInput($request->except('website'))
->withErrors(['email' => 'An account with this email already exists.']);
@@ -136,6 +181,15 @@ class RegisteredUserController extends Controller
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'
|| (bool) $user->needs_password_reset;
@@ -213,23 +267,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
{
if (! $this->captchaVerifier->isEnabled() && ! $this->turnstileVerifier->isEnabled()) {
@@ -336,4 +373,28 @@ class RegisteredUserController extends Controller
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;
}
}

View File

@@ -21,7 +21,19 @@ class LatestCommentsController extends Controller
// Build initial (first-page, type=all) data for React SSR props
$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) {
$q->public()->published()->whereNull('deleted_at');
})

View File

@@ -98,7 +98,7 @@ class ForumController extends Controller
$thread->loadMissing([
'category:id,name,slug',
'user:id,name',
'user:id,name,username',
'user.profile:user_id,avatar_hash',
]);
@@ -116,7 +116,7 @@ class ForumController extends Controller
$opPost = ForumPost::query()
->where('thread_id', $thread->id)
->with([
'user:id,name',
'user:id,name,username',
'user.profile:user_id,avatar_hash',
'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)
->when($opPost, fn ($query) => $query->where('id', '!=', $opPost->id))
->with([
'user:id,name',
'user:id,name,username',
'user.profile:user_id,avatar_hash',
'attachments:id,post_id,file_path,file_size,mime_type,width,height',
])
@@ -148,7 +148,7 @@ class ForumController extends Controller
if ($quotePostId > 0) {
$quotedPost = ForumPost::query()
->where('thread_id', $thread->id)
->with('user:id,name')
->with('user:id,name,username')
->find($quotePostId);
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Moderation\Traffic;
use App\Http\Controllers\Controller;
use App\Services\Traffic\OnlineVisitorRepository;
use Illuminate\Http\JsonResponse;
use Illuminate\View\View;
final class OnlineVisitorsController extends Controller
{
public function __construct(private readonly OnlineVisitorRepository $visitors)
{
}
public function index(): View
{
$summary = $this->visitors->summary();
$visitors = $this->visitors->all();
$activePages = $this->visitors->activePages();
return view('moderation.traffic.online', [
'summary' => $summary,
'visitors' => $visitors,
'activePages' => $activePages,
'generatedAt' => now()->toIso8601String(),
'dataUrl' => route('moderation.traffic.online.data'),
]);
}
public function data(): JsonResponse
{
return response()->json([
'summary' => $this->visitors->summary(),
'visitors' => $this->visitors->all(),
'active_pages' => $this->visitors->activePages(),
'generated_at' => now()->toIso8601String(),
]);
}
}

View File

@@ -3,7 +3,6 @@
namespace App\Http\Controllers\News;
use App\Http\Controllers\Controller;
use App\Models\NewsArticleComment;
use App\Models\User;
use App\Services\News\NewsService;
use Illuminate\Http\Request;
@@ -147,40 +146,14 @@ class NewsController extends Controller
// Track view (once per session / IP)
$this->trackView($request, $article);
// Related articles (same category, excluding current)
$related = NewsArticle::with('author', 'category')
->published()
->when($article->category_id, fn ($q) => $q->where('category_id', $article->category_id))
->where('id', '!=', $article->id)
->editorialOrder()
->limit(config('news.related_limit', 4))
->get();
$comments = collect();
$commentsCount = 0;
if ($article->commentsAreEnabled()) {
$comments = NewsArticleComment::query()
->where('article_id', $article->id)
->whereNull('parent_id')
->where('status', 'visible')
->with(['user.profile'])
->orderBy('created_at')
->orderBy('id')
->get();
$commentsCount = (int) NewsArticleComment::query()
->where('article_id', $article->id)
->where('status', 'visible')
->count();
}
$articleData = $this->news->publicArticleShowData($article, $request->user());
return view('news.show', [
'article' => $article,
'related' => $related,
'relatedEntities' => $this->news->resolveRelatedEntities($article, $request->user()),
'comments' => $comments,
'commentsCount' => $commentsCount,
'related' => $articleData['related'],
'relatedEntities' => $articleData['relatedEntities'],
'comments' => $articleData['comments'],
'commentsCount' => $articleData['commentsCount'],
] + $this->sidebarData());
}
@@ -194,7 +167,9 @@ class NewsController extends Controller
$userId = Auth::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;
}
@@ -207,7 +182,9 @@ class NewsController extends Controller
$article->incrementViews();
$request->session()->put($session, true);
if ($canReadSession) {
$request->session()->put($session, true);
}
}
private function sidebarData(): array

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,308 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Models\AcademyCourse;
use App\Models\AcademyCourseLesson;
use App\Models\AcademyCourseSection;
use App\Models\AcademyLesson;
use App\Services\Academy\AcademyCacheService;
use App\Services\Academy\AcademyCourseLessonOrderingService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
final class AcademyCourseBuilderController extends Controller
{
public function __construct(
private readonly AcademyCacheService $cache,
private readonly AcademyCourseLessonOrderingService $courseLessonOrdering,
)
{
}
public function edit(AcademyCourse $academyCourse): Response
{
$academyCourse->load(['sections', 'courseLessons.section', 'courseLessons.lesson.category']);
return Inertia::render('Admin/Academy/CourseBuilder', [
'course' => $this->serializeCourse($academyCourse),
'sections' => $academyCourse->sections
->sortBy([['order_num', 'asc'], ['id', 'asc']])
->values()
->map(fn (AcademyCourseSection $section): array => $this->serializeSection($section))
->all(),
'courseLessons' => $academyCourse->courseLessons
->sortBy([['order_num', 'asc'], ['id', 'asc']])
->values()
->map(fn (AcademyCourseLesson $courseLesson): array => $this->serializeCourseLesson($courseLesson))
->all(),
'availableLessons' => AcademyLesson::query()
->with('category')
->orderBy('title')
->get()
->map(fn (AcademyLesson $lesson): array => [
'id' => (int) $lesson->id,
'title' => (string) $lesson->title,
'slug' => (string) $lesson->slug,
'excerpt' => (string) ($lesson->excerpt ?? ''),
'difficulty' => (string) $lesson->difficulty,
'access_level' => (string) $lesson->access_level,
'active' => (bool) $lesson->active,
'published_at' => $lesson->published_at?->toISOString(),
'category' => $lesson->category ? (string) $lesson->category->name : '',
'attached' => $academyCourse->courseLessons->contains(fn (AcademyCourseLesson $courseLesson): bool => (int) $courseLesson->lesson_id === (int) $lesson->id),
])
->values()
->all(),
'routes' => [
'index' => route('admin.academy.courses.index'),
'edit' => route('admin.academy.courses.edit', ['academyCourse' => $academyCourse]),
'preview' => route('academy.courses.show', ['course' => $academyCourse->slug]),
'sectionStore' => route('admin.academy.courses.sections.store', ['academyCourse' => $academyCourse]),
'attachLesson' => route('admin.academy.courses.lessons.attach', ['academyCourse' => $academyCourse]),
'reorder' => route('admin.academy.courses.reorder', ['academyCourse' => $academyCourse]),
],
]);
}
public function storeSection(Request $request, AcademyCourse $academyCourse): RedirectResponse
{
$data = $request->validate([
'title' => ['required', 'string', 'max:180'],
'slug' => ['nullable', 'string', 'max:180'],
'description' => ['nullable', 'string'],
'order_num' => ['nullable', 'integer', 'min:0'],
'is_visible' => ['nullable', 'boolean'],
]);
$academyCourse->sections()->create([
'title' => $data['title'],
'slug' => filled($data['slug'] ?? null) ? $data['slug'] : Str::slug($data['title']),
'description' => $data['description'] ?? null,
'order_num' => (int) ($data['order_num'] ?? ($academyCourse->sections()->max('order_num') + 1)),
'is_visible' => (bool) ($data['is_visible'] ?? true),
]);
$this->cache->clearAll();
return back()->with('success', 'Course section created.');
}
public function updateSection(Request $request, AcademyCourse $academyCourse, AcademyCourseSection $academyCourseSection): RedirectResponse
{
abort_unless((int) $academyCourseSection->course_id === (int) $academyCourse->id, 404);
$data = $request->validate([
'title' => ['required', 'string', 'max:180'],
'slug' => ['nullable', 'string', 'max:180'],
'description' => ['nullable', 'string'],
'order_num' => ['nullable', 'integer', 'min:0'],
'is_visible' => ['nullable', 'boolean'],
]);
$academyCourseSection->forceFill([
'title' => $data['title'],
'slug' => filled($data['slug'] ?? null) ? $data['slug'] : Str::slug($data['title']),
'description' => $data['description'] ?? null,
'order_num' => (int) ($data['order_num'] ?? 0),
'is_visible' => (bool) ($data['is_visible'] ?? true),
])->save();
$this->cache->clearAll();
return back()->with('success', 'Course section updated.');
}
public function destroySection(AcademyCourse $academyCourse, AcademyCourseSection $academyCourseSection): RedirectResponse
{
abort_unless((int) $academyCourseSection->course_id === (int) $academyCourse->id, 404);
$academyCourseSection->delete();
$this->cache->clearAll();
return back()->with('success', 'Course section deleted.');
}
public function attachLesson(Request $request, AcademyCourse $academyCourse): RedirectResponse
{
$data = $request->validate([
'lesson_id' => ['required', 'integer', 'exists:academy_lessons,id'],
'section_id' => ['nullable', 'integer', 'exists:academy_course_sections,id'],
'order_num' => ['nullable', 'integer', 'min:0'],
'is_required' => ['nullable', 'boolean'],
'access_override' => ['nullable', 'string', 'in:free,premium,creator,pro'],
'unlock_after_lesson_id' => ['nullable', 'integer', 'exists:academy_lessons,id'],
]);
if (AcademyCourseLesson::query()->where('course_id', $academyCourse->id)->where('lesson_id', $data['lesson_id'])->exists()) {
return back()->with('error', 'That lesson is already attached to this course.');
}
$sectionId = $data['section_id'] ?? null;
if ($sectionId !== null) {
abort_unless(AcademyCourseSection::query()->where('course_id', $academyCourse->id)->whereKey($sectionId)->exists(), 404);
}
AcademyCourseLesson::query()->create([
'course_id' => $academyCourse->id,
'section_id' => $sectionId,
'lesson_id' => (int) $data['lesson_id'],
'order_num' => (int) ($data['order_num'] ?? ($academyCourse->courseLessons()->max('order_num') + 1)),
'is_required' => (bool) ($data['is_required'] ?? true),
'access_override' => $data['access_override'] ?? null,
'unlock_after_lesson_id' => $data['unlock_after_lesson_id'] ?? null,
]);
$this->courseLessonOrdering->syncCourse($academyCourse);
$this->syncCourseCounts($academyCourse);
return back()->with('success', 'Lesson attached to course.');
}
public function updateCourseLesson(Request $request, AcademyCourse $academyCourse, AcademyCourseLesson $academyCourseLesson): RedirectResponse
{
abort_unless((int) $academyCourseLesson->course_id === (int) $academyCourse->id, 404);
$data = $request->validate([
'section_id' => ['nullable', 'integer', 'exists:academy_course_sections,id'],
'order_num' => ['nullable', 'integer', 'min:0'],
'is_required' => ['nullable', 'boolean'],
'access_override' => ['nullable', 'string', 'in:free,premium,creator,pro'],
'unlock_after_lesson_id' => ['nullable', 'integer', 'exists:academy_lessons,id'],
]);
$sectionId = $data['section_id'] ?? null;
if ($sectionId !== null) {
abort_unless(AcademyCourseSection::query()->where('course_id', $academyCourse->id)->whereKey($sectionId)->exists(), 404);
}
$academyCourseLesson->forceFill([
'section_id' => $sectionId,
'order_num' => (int) ($data['order_num'] ?? 0),
'is_required' => (bool) ($data['is_required'] ?? true),
'access_override' => $data['access_override'] ?? null,
'unlock_after_lesson_id' => $data['unlock_after_lesson_id'] ?? null,
])->save();
$this->courseLessonOrdering->syncCourse($academyCourse);
$this->syncCourseCounts($academyCourse);
return back()->with('success', 'Course lesson updated.');
}
public function detachLesson(AcademyCourse $academyCourse, AcademyCourseLesson $academyCourseLesson): RedirectResponse
{
abort_unless((int) $academyCourseLesson->course_id === (int) $academyCourse->id, 404);
$academyCourseLesson->delete();
$this->courseLessonOrdering->syncCourse($academyCourse);
$this->syncCourseCounts($academyCourse);
return back()->with('success', 'Lesson removed from course.');
}
public function reorder(Request $request, AcademyCourse $academyCourse): RedirectResponse
{
$data = $request->validate([
'sections' => ['nullable', 'array'],
'sections.*.id' => ['required', 'integer', 'exists:academy_course_sections,id'],
'sections.*.order_num' => ['required', 'integer', 'min:0'],
'lessons' => ['nullable', 'array'],
'lessons.*.id' => ['required', 'integer', 'exists:academy_course_lessons,id'],
'lessons.*.order_num' => ['required', 'integer', 'min:0'],
'lessons.*.section_id' => ['nullable', 'integer', 'exists:academy_course_sections,id'],
]);
foreach ((array) ($data['sections'] ?? []) as $sectionData) {
AcademyCourseSection::query()
->where('course_id', $academyCourse->id)
->whereKey((int) $sectionData['id'])
->update(['order_num' => (int) $sectionData['order_num']]);
}
foreach ((array) ($data['lessons'] ?? []) as $lessonData) {
AcademyCourseLesson::query()
->where('course_id', $academyCourse->id)
->whereKey((int) $lessonData['id'])
->update([
'order_num' => (int) $lessonData['order_num'],
'section_id' => $lessonData['section_id'] ?? null,
]);
}
$this->courseLessonOrdering->syncCourse($academyCourse);
$this->syncCourseCounts($academyCourse);
return back()->with('success', 'Course order updated.');
}
private function syncCourseCounts(AcademyCourse $academyCourse): void
{
$academyCourse->forceFill([
'lessons_count_cache' => $academyCourse->courseLessons()->count(),
])->save();
$this->cache->clearAll();
}
private function serializeCourse(AcademyCourse $course): array
{
return [
'id' => (int) $course->id,
'title' => (string) $course->title,
'slug' => (string) $course->slug,
'subtitle' => (string) ($course->subtitle ?? ''),
'excerpt' => (string) ($course->excerpt ?? ''),
'description' => (string) ($course->description ?? ''),
'access_level' => (string) $course->access_level,
'difficulty' => (string) $course->difficulty,
'status' => (string) $course->status,
'lessons_count_cache' => (int) ($course->lessons_count_cache ?? 0),
'cover_image' => (string) ($course->cover_image ?? ''),
'published_at' => $course->published_at?->toISOString(),
];
}
private function serializeSection(AcademyCourseSection $section): array
{
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),
'updateUrl' => route('admin.academy.courses.sections.update', ['academyCourse' => $section->course_id, 'academyCourseSection' => $section]),
'destroyUrl' => route('admin.academy.courses.sections.destroy', ['academyCourse' => $section->course_id, 'academyCourseSection' => $section]),
];
}
private function serializeCourseLesson(AcademyCourseLesson $courseLesson): array
{
$lesson = $courseLesson->lesson;
return [
'id' => (int) $courseLesson->id,
'section_id' => $courseLesson->section_id ? (int) $courseLesson->section_id : null,
'lesson_id' => (int) $courseLesson->lesson_id,
'title' => (string) ($lesson?->title ?? ''),
'slug' => (string) ($lesson?->slug ?? ''),
'excerpt' => (string) ($lesson?->excerpt ?? ''),
'difficulty' => (string) ($lesson?->difficulty ?? ''),
'access_level' => (string) ($lesson?->access_level ?? ''),
'category' => (string) ($lesson?->category?->name ?? ''),
'order_num' => (int) ($courseLesson->order_num ?? 0),
'is_required' => (bool) $courseLesson->is_required,
'access_override' => $courseLesson->access_override,
'unlock_after_lesson_id' => $courseLesson->unlock_after_lesson_id ? (int) $courseLesson->unlock_after_lesson_id : null,
'updateUrl' => route('admin.academy.courses.lessons.update', ['academyCourse' => $courseLesson->course_id, 'academyCourseLesson' => $courseLesson]),
'destroyUrl' => route('admin.academy.courses.lessons.destroy', ['academyCourse' => $courseLesson->course_id, 'academyCourseLesson' => $courseLesson]),
];
}
}

View File

@@ -0,0 +1,373 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
use Intervention\Image\Encoders\WebpEncoder;
use Intervention\Image\ImageManager;
use RuntimeException;
final class AcademyLessonMediaApiController extends Controller
{
private const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
private const MAX_FILE_SIZE_KB = 6144;
private const ASSET_CACHE_TTL_MINUTES = 15;
private ?ImageManager $manager = null;
public function __construct()
{
try {
$this->manager = extension_loaded('gd')
? new ImageManager(new GdDriver())
: new ImageManager(new ImagickDriver());
} catch (\Throwable) {
$this->manager = null;
}
}
public function store(Request $request): JsonResponse
{
$this->authorizeStaff($request);
$validated = $request->validate([
'slot' => ['nullable', 'string', 'in:cover,body'],
'image' => [
'required',
'file',
'image',
'max:' . self::MAX_FILE_SIZE_KB,
'mimes:jpg,jpeg,png,webp',
'mimetypes:image/jpeg,image/png,image/webp',
],
]);
/** @var UploadedFile $file */
$file = $validated['image'];
$slot = $this->normalizeSlot($validated['slot'] ?? null);
try {
$stored = $this->storeMediaFile($file, $slot);
$this->forgetAssetCache();
return response()->json([
'success' => true,
'slot' => $slot,
'path' => $stored['path'],
'url' => $this->publicUrlForPath($stored['path']),
'width' => $stored['width'],
'height' => $stored['height'],
'mime_type' => 'image/webp',
'size_bytes' => $stored['size_bytes'],
]);
} catch (RuntimeException $e) {
return response()->json([
'error' => 'Validation failed',
'message' => $e->getMessage(),
], 422);
} catch (\Throwable $e) {
logger()->error('Academy lesson media upload failed', [
'user_id' => (int) ($request->user()?->id ?? 0),
'message' => $e->getMessage(),
]);
return response()->json([
'error' => 'Upload failed',
'message' => 'Could not upload lesson media right now.',
], 500);
}
}
public function destroy(Request $request): JsonResponse
{
$this->authorizeStaff($request);
$validated = $request->validate([
'path' => ['required', 'string', 'max:2048'],
]);
$this->deleteMediaFile((string) $validated['path']);
$this->forgetAssetCache();
return response()->json([
'success' => true,
]);
}
public function assets(Request $request): JsonResponse
{
$this->authorizeStaff($request);
$validated = $request->validate([
'limit' => ['nullable', 'integer', 'min:1', 'max:48'],
'page' => ['nullable', 'integer', 'min:1'],
'q' => ['nullable', 'string', 'max:100'],
]);
$limit = (int) ($validated['limit'] ?? 24);
$page = (int) ($validated['page'] ?? 1);
$query = Str::lower(trim((string) ($validated['q'] ?? '')));
$manifest = $this->academyAssetManifest();
if ($query !== '') {
$manifest = $manifest->filter(function (array $item) use ($query): bool {
return Str::contains($item['search_text'], $query);
})->values();
}
$total = $manifest->count();
$lastPage = max(1, (int) ceil(max($total, 1) / max($limit, 1)));
$page = min(max($page, 1), $lastPage);
$items = $manifest
->forPage($page, $limit)
->values()
->map(function (array $item): array {
return [
'path' => $item['path'],
'url' => $item['url'],
'name' => $item['name'],
'slot' => $item['slot'],
'modified_at' => $item['modified_at'] ? now()->setTimestamp($item['modified_at'])->toIso8601String() : null,
];
})
->all();
return response()->json([
'success' => true,
'items' => $items,
'pagination' => [
'page' => $page,
'per_page' => $limit,
'total' => $total,
'last_page' => $lastPage,
'has_more' => $page < $lastPage,
],
]);
}
/**
* @return array{path:string,width:int,height:int,size_bytes:int}
*/
private function storeMediaFile(UploadedFile $file, string $slot): array
{
$this->assertImageManager();
$this->assertStorageIsAllowed();
$constraints = $this->mediaConstraints($slot);
$uploadPath = (string) ($file->getRealPath() ?: $file->getPathname());
if ($uploadPath === '' || ! is_readable($uploadPath)) {
throw new RuntimeException('Unable to resolve uploaded image path.');
}
$raw = file_get_contents($uploadPath);
if ($raw === false || $raw === '') {
throw new RuntimeException('Unable to read uploaded image.');
}
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mime = strtolower((string) $finfo->buffer($raw));
if (! in_array($mime, self::ALLOWED_MIME_TYPES, true)) {
throw new RuntimeException('Unsupported image mime type.');
}
$size = @getimagesizefromstring($raw);
if (! is_array($size) || ($size[0] ?? 0) < 1 || ($size[1] ?? 0) < 1) {
throw new RuntimeException('Uploaded file is not a valid image.');
}
$width = (int) ($size[0] ?? 0);
$height = (int) ($size[1] ?? 0);
if ($width < $constraints['min_width'] || $height < $constraints['min_height']) {
throw new RuntimeException(sprintf(
'Image is too small. Minimum required size is %dx%d.',
$constraints['min_width'],
$constraints['min_height'],
));
}
$image = $this->manager->read($raw)->scaleDown(width: $constraints['max_width'], height: $constraints['max_height']);
$encoded = (string) $image->encode(new WebpEncoder(85));
$hash = hash('sha256', $encoded);
$path = $this->mediaPath($hash, $slot);
$disk = Storage::disk($this->mediaDiskName());
$written = $disk->put($path, $encoded, [
'visibility' => 'public',
'CacheControl' => 'public, max-age=31536000, immutable',
'ContentType' => 'image/webp',
]);
if ($written !== true) {
throw new RuntimeException('Unable to store image in object storage.');
}
return [
'path' => $path,
'width' => (int) $image->width(),
'height' => (int) $image->height(),
'size_bytes' => strlen($encoded),
];
}
private function authorizeStaff(Request $request): void
{
abort_unless((bool) $request->user()?->hasStaffAccess(), 403);
}
private function mediaDiskName(): string
{
return (string) config('uploads.object_storage.disk', 's3');
}
private function mediaPath(string $hash, string $slot): string
{
$folder = $slot === 'body' ? 'body' : 'covers';
return sprintf(
'academy/lessons/%s/%s/%s/%s.webp',
$folder,
substr($hash, 0, 2),
substr($hash, 2, 2),
$hash,
);
}
private function publicUrlForPath(string $path): string
{
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/');
}
private function academyAssetManifest(): Collection
{
return Cache::remember($this->academyAssetCacheKey(), now()->addMinutes(self::ASSET_CACHE_TTL_MINUTES), function (): Collection {
$disk = Storage::disk($this->mediaDiskName());
return collect($disk->allFiles('academy/lessons'))
->filter(fn (string $path): bool => Str::endsWith(Str::lower($path), ['.webp', '.jpg', '.jpeg', '.png']))
->map(function (string $path) use ($disk): array {
$modifiedAt = null;
try {
$modifiedAt = $disk->lastModified($path);
} catch (\Throwable) {
$modifiedAt = null;
}
$folder = Str::contains($path, '/body/') ? 'body' : (Str::contains($path, '/covers/') ? 'cover' : 'asset');
return [
'path' => $path,
'url' => $this->publicUrlForPath($path),
'name' => $this->humanAssetName($path),
'slot' => $folder,
'modified_at' => $modifiedAt ? (int) $modifiedAt : null,
'search_text' => Str::lower(implode(' ', [$path, $folder, $this->humanAssetName($path)])),
];
})
->sortByDesc(fn (array $item): int => (int) ($item['modified_at'] ?? 0))
->values();
});
}
private function academyAssetCacheKey(): string
{
return 'academy.lesson.assets.' . md5($this->mediaDiskName());
}
private function forgetAssetCache(): void
{
Cache::forget($this->academyAssetCacheKey());
}
private function humanAssetName(string $path): string
{
$filename = pathinfo($path, PATHINFO_FILENAME);
$clean = trim(str_replace(['-', '_'], ' ', $filename));
return $clean !== '' ? Str::headline($clean) : 'Academy image';
}
private function safeFileSize($disk, string $path): ?int
{
try {
$size = $disk->size($path);
return is_int($size) ? $size : null;
} catch (\Throwable) {
return null;
}
}
private function deleteMediaFile(string $path): void
{
$trimmed = ltrim(trim($path), '/');
if ($trimmed === '' || ! Str::startsWith($trimmed, ['academy/lessons/covers/', 'academy/lessons/body/'])) {
return;
}
Storage::disk($this->mediaDiskName())->delete($trimmed);
}
private function normalizeSlot(mixed $slot): string
{
return Str::lower(trim((string) $slot)) === 'body' ? 'body' : 'cover';
}
/**
* @return array{min_width:int,min_height:int,max_width:int,max_height:int}
*/
private function mediaConstraints(string $slot): array
{
if ($slot === 'body') {
return [
'min_width' => 64,
'min_height' => 64,
'max_width' => 2400,
'max_height' => 2400,
];
}
return [
'min_width' => 1200,
'min_height' => 630,
'max_width' => 2200,
'max_height' => 1400,
];
}
private function assertImageManager(): void
{
if ($this->manager !== null) {
return;
}
throw new RuntimeException('Image processing is not available on this environment.');
}
private function assertStorageIsAllowed(): void
{
$disk = Storage::disk($this->mediaDiskName());
if (! method_exists($disk, 'put')) {
throw new RuntimeException('Object storage is not configured for academy lesson uploads.');
}
}
}

View File

@@ -108,10 +108,10 @@ class CollectionInsightsController extends Controller
'bulkActions' => route('settings.collections.bulk-actions'),
],
'seo' => [
'title' => 'Collections Dashboard — Skinbase Nova',
'title' => 'Collections Dashboard — Skinbase',
'description' => 'Overview of collection lifecycle, quality, activity, and upcoming collection campaigns.',
'canonical' => route('settings.collections.dashboard'),
'robots' => 'noindex,follow',
'robots' => 'index,follow',
],
])->rootView('collections');
}
@@ -127,10 +127,10 @@ class CollectionInsightsController extends Controller
'historyUrl' => route('settings.collections.history', ['collection' => $collection->id]),
'dashboardUrl' => route('settings.collections.dashboard'),
'seo' => [
'title' => sprintf('%s Analytics — Skinbase Nova', $collection->title),
'title' => sprintf('%s Analytics — Skinbase', $collection->title),
'description' => sprintf('Analytics and performance history for the %s collection.', $collection->title),
'canonical' => route('settings.collections.analytics', ['collection' => $collection->id]),
'robots' => 'noindex,follow',
'robots' => 'index,follow',
],
])->rootView('collections');
}
@@ -150,10 +150,10 @@ class CollectionInsightsController extends Controller
'analyticsUrl' => route('settings.collections.analytics', ['collection' => $collection->id]),
'restorePattern' => route('settings.collections.history.restore', ['collection' => $collection->id, 'history' => '__HISTORY__']),
'seo' => [
'title' => sprintf('%s History — Skinbase Nova', $collection->title),
'title' => sprintf('%s History — Skinbase', $collection->title),
'description' => sprintf('Audit history and lifecycle changes for the %s collection.', $collection->title),
'canonical' => route('settings.collections.history', ['collection' => $collection->id]),
'robots' => 'noindex,follow',
'robots' => 'index,follow',
],
])->rootView('collections');
}

View File

@@ -92,10 +92,10 @@ class CollectionProgrammingController extends Controller
'surfaces' => route('settings.collections.surfaces.index'),
],
'seo' => [
'title' => 'Collection Programming — Skinbase Nova',
'title' => 'Collection Programming — Skinbase',
'description' => 'Staff programming tools for assignments, previews, eligibility diagnostics, and recommendation refreshes.',
'canonical' => route('staff.collections.programming'),
'robots' => 'noindex,follow',
'robots' => 'index,follow',
],
])->rootView('collections');
}

View File

@@ -66,10 +66,10 @@ class CollectionSurfaceController extends Controller
'batchEditorial' => route('settings.collections.surfaces.batch-editorial'),
],
'seo' => [
'title' => 'Collection Surfaces - Skinbase Nova',
'title' => 'Collection Surfaces - Skinbase',
'description' => 'Staff tools for homepage, discovery, and campaign collection surfaces.',
'canonical' => route('settings.collections.surfaces.index'),
'robots' => 'noindex,follow',
'robots' => 'index,follow',
],
])->rootView('collections');
}

View File

@@ -43,10 +43,10 @@ class FeaturedArtworkAdminController extends Controller
'forceHeroEnabled' => $this->hasForceHeroColumn(),
],
'seo' => [
'title' => 'Featured Artworks — Skinbase Nova',
'title' => 'Featured Artworks — Skinbase',
'description' => 'Editorial controls for homepage featured artworks and the current hero winner.',
'canonical' => route($routePrefix . 'main'),
'robots' => 'noindex,follow',
'robots' => 'index,follow',
],
],
))->rootView($isAdminSurface ? 'admin' : 'collections');

View File

@@ -198,6 +198,14 @@ class HomepageAnnouncementController extends Controller
return;
}
$backgroundDisk = $this->announcements->backgroundImageDisk();
if (Storage::disk($backgroundDisk)->exists($path)) {
Storage::disk($backgroundDisk)->delete($path);
return;
}
if (Storage::disk('public')->exists($path)) {
Storage::disk('public')->delete($path);
}
@@ -268,8 +276,8 @@ class HomepageAnnouncementController extends Controller
]);
}
$storedPath = 'homepage-announcements/' . pathinfo(Str::replace('\\', '/', $file->hashName()), PATHINFO_FILENAME) . '.webp';
Storage::disk('public')->put($storedPath, $webpBinary, ['visibility' => 'public']);
$storedPath = $this->announcements->backgroundImagePrefix() . '/' . pathinfo(Str::replace('\\', '/', $file->hashName()), PATHINFO_FILENAME) . '.webp';
Storage::disk($this->announcements->backgroundImageDisk())->put($storedPath, $webpBinary, ['visibility' => 'public']);
} finally {
imagedestroy($image);
}

View File

@@ -194,7 +194,7 @@ class StoryController extends Controller
'storyTypes' => $this->storyCategories(),
'page_title' => 'Create Story - Skinbase',
'page_meta_description' => 'Write and publish a creator story on Skinbase.',
'page_robots' => 'noindex,nofollow',
'page_robots' => 'index,nofollow',
]);
}
@@ -751,24 +751,42 @@ class StoryController extends Controller
], 422);
}
$disk = Storage::disk('public');
$base = 'stories/' . now()->format('Y/m') . '/' . Str::uuid();
$extension = strtolower((string) ($file->guessExtension() ?: $file->extension() ?: 'jpg'));
$originalPath = $base . '/original.' . $extension;
$thumbnailPath = $base . '/thumbnail.webp';
$mediumPath = $base . '/medium.webp';
try {
$this->assertStoryMediaStorageIsAllowed();
} catch (\RuntimeException $e) {
return response()->json([
'message' => $e->getMessage(),
], 422);
}
$stream = fopen($sourcePath, 'rb');
if ($stream === false) {
$raw = file_get_contents($sourcePath);
if ($raw === false || $raw === '') {
return response()->json([
'message' => 'Unable to process uploaded image. Please try again.',
], 422);
}
try {
$disk->put($originalPath, $stream);
} finally {
fclose($stream);
$hash = hash('sha256', $raw);
$originalExtension = strtolower((string) ($file->guessExtension() ?: $file->extension() ?: 'jpg'));
if ($originalExtension === 'jpeg') {
$originalExtension = 'jpg';
}
$originalPath = $this->storyMediaPath('original', $hash, $hash . '.' . $originalExtension);
$thumbnailPath = $this->storyMediaPath('sm', $hash);
$mediumPath = $this->storyMediaPath('md', $hash);
$disk = Storage::disk($this->storyMediaDiskName());
$written = $disk->put($originalPath, $raw, [
'visibility' => 'public',
'CacheControl' => 'public, max-age=31536000, immutable',
'ContentType' => (string) ($file->getMimeType() ?: 'application/octet-stream'),
]);
if ($written !== true) {
return response()->json([
'message' => 'Unable to store uploaded image. Please try again.',
], 500);
}
$storedThumbnails = false;
@@ -781,10 +799,20 @@ class StoryController extends Controller
$image = $manager->read($sourcePath);
$thumb = $image->scaleDown(width: 420);
$disk->put($thumbnailPath, (string) $thumb->encode(new WebpEncoder(82)));
$thumbEncoded = (string) $thumb->encode(new WebpEncoder(82));
$disk->put($thumbnailPath, $thumbEncoded, [
'visibility' => 'public',
'CacheControl' => 'public, max-age=31536000, immutable',
'ContentType' => 'image/webp',
]);
$medium = $image->scaleDown(width: 1200);
$disk->put($mediumPath, (string) $medium->encode(new WebpEncoder(85)));
$mediumEncoded = (string) $medium->encode(new WebpEncoder(85));
$disk->put($mediumPath, $mediumEncoded, [
'visibility' => 'public',
'CacheControl' => 'public, max-age=31536000, immutable',
'ContentType' => 'image/webp',
]);
$storedThumbnails = true;
} catch (\Throwable) {
$storedThumbnails = false;
@@ -792,17 +820,62 @@ class StoryController extends Controller
}
if (! $storedThumbnails) {
$disk->copy($originalPath, $thumbnailPath);
$disk->copy($originalPath, $mediumPath);
$disk->put($thumbnailPath, $raw, [
'visibility' => 'public',
'CacheControl' => 'public, max-age=31536000, immutable',
'ContentType' => (string) ($file->getMimeType() ?: 'application/octet-stream'),
]);
$disk->put($mediumPath, $raw, [
'visibility' => 'public',
'CacheControl' => 'public, max-age=31536000, immutable',
'ContentType' => (string) ($file->getMimeType() ?: 'application/octet-stream'),
]);
}
return response()->json([
'thumbnail_url' => $disk->url($thumbnailPath),
'medium_url' => $disk->url($mediumPath),
'original_url' => $disk->url($originalPath),
'thumbnail_url' => $this->storyMediaPublicUrl($thumbnailPath),
'medium_url' => $this->storyMediaPublicUrl($mediumPath),
'original_url' => $this->storyMediaPublicUrl($originalPath),
]);
}
private function storyMediaDiskName(): string
{
return (string) config('uploads.object_storage.disk', 's3');
}
private function storyMediaPath(string $variant, string $hash, ?string $filename = null): string
{
$cleanVariant = trim($variant, '/');
$cleanHash = strtolower((string) preg_replace('/[^a-f0-9]/', '', $hash));
$file = $filename ?? ($cleanHash . '.webp');
return sprintf(
'stories/%s/%s/%s/%s',
$cleanVariant,
substr($cleanHash, 0, 2),
substr($cleanHash, 2, 2),
ltrim($file, '/')
);
}
private function storyMediaPublicUrl(string $path): string
{
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/');
}
private function assertStoryMediaStorageIsAllowed(): void
{
if (! app()->environment('production')) {
return;
}
$diskName = $this->storyMediaDiskName();
if (in_array($diskName, ['local', 'public'], true)) {
throw new \RuntimeException('Production story media storage must use object storage, not local/public disks.');
}
}
public function tag(string $tag): View
{
$storyTag = StoryTag::query()->where('slug', $tag)->firstOrFail();

View File

@@ -50,10 +50,13 @@ final class StudioNewsController extends Controller
'title' => 'Create article',
'description' => 'Draft a new News story with editorial workflow, SEO metadata, and related entity links.',
'article' => null,
'oldInput' => $request->session()->getOldInput(),
'indexUrl' => route('studio.news.index'),
'typeOptions' => $this->news->articleTypeOptions(),
'statusOptions' => $this->news->editorialStatusOptions(),
'categoryOptions' => $this->news->categoryOptions(),
'tagOptions' => $this->news->tagOptions(),
'newsTagLimit' => 12,
'relationTypeOptions' => $this->news->relationTypeOptions(),
'storeUrl' => route('studio.news.store'),
'coverUploadUrl' => route('api.studio.news.media.upload'),
@@ -83,10 +86,13 @@ final class StudioNewsController extends Controller
'title' => 'Edit article',
'description' => 'Refine the story, tune SEO, and attach related Nova entities before publishing.',
'article' => $this->news->mapStudioArticle($article, $request->user()),
'oldInput' => $request->session()->getOldInput(),
'indexUrl' => route('studio.news.index'),
'typeOptions' => $this->news->articleTypeOptions(),
'statusOptions' => $this->news->editorialStatusOptions(),
'categoryOptions' => $this->news->categoryOptions(),
'tagOptions' => $this->news->tagOptions(),
'newsTagLimit' => 12,
'relationTypeOptions' => $this->news->relationTypeOptions(),
'coverUploadUrl' => route('api.studio.news.media.upload'),
'coverDeleteUrl' => route('api.studio.news.media.destroy'),
@@ -352,7 +358,7 @@ final class StudioNewsController extends Controller
'content' => ['required', 'string', 'max:500000'],
'cover_image' => ['nullable', 'string', 'max:2048'],
'type' => ['required', Rule::in(array_column($this->news->articleTypeOptions(), 'value'))],
'category_id' => ['nullable', 'integer', 'exists:news_categories,id'],
'category_id' => ['required', 'integer', 'exists:news_categories,id'],
'author_id' => ['nullable', 'integer', 'exists:users,id'],
'editorial_status' => ['required', Rule::in(array_column($this->news->editorialStatusOptions(), 'value'))],
'published_at' => ['nullable', 'date'],

View File

@@ -5,42 +5,15 @@ declare(strict_types=1);
namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller;
use App\Services\News\NewsCoverImageService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
use Intervention\Image\Encoders\WebpEncoder;
use Intervention\Image\ImageManager;
use RuntimeException;
final class StudioNewsMediaApiController extends Controller
{
private const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
private const MAX_FILE_SIZE_KB = 6144;
private const MAX_WIDTH = 2200;
private const MAX_HEIGHT = 1400;
private const MIN_WIDTH = 1200;
private const MIN_HEIGHT = 630;
private ?ImageManager $manager = null;
public function __construct()
public function __construct(private readonly NewsCoverImageService $covers)
{
try {
$this->manager = extension_loaded('gd')
? new ImageManager(new GdDriver())
: new ImageManager(new ImagickDriver());
} catch (\Throwable) {
$this->manager = null;
}
}
public function store(Request $request): JsonResponse
@@ -52,26 +25,28 @@ final class StudioNewsMediaApiController extends Controller
'required',
'file',
'image',
'max:' . self::MAX_FILE_SIZE_KB,
'max:' . $this->covers->maxFileSizeKb(),
'mimes:jpg,jpeg,png,webp',
'mimetypes:image/jpeg,image/png,image/webp',
],
]);
/** @var UploadedFile $file */
$file = $validated['image'];
try {
$stored = $this->storeMediaFile($file);
$stored = $this->covers->storeUploadedFile($file);
return response()->json([
'success' => true,
'path' => $stored['path'],
'url' => $this->publicUrlForPath($stored['path']),
'url' => $stored['url'],
'width' => $stored['width'],
'height' => $stored['height'],
'mime_type' => 'image/webp',
'size_bytes' => $stored['size_bytes'],
'mobile_url' => $stored['mobile_url'],
'desktop_url' => $stored['desktop_url'],
'srcset' => $stored['srcset'],
]);
} catch (RuntimeException $e) {
return response()->json([
@@ -99,7 +74,7 @@ final class StudioNewsMediaApiController extends Controller
'path' => ['required', 'string', 'max:2048'],
]);
$this->deleteMediaFile((string) $validated['path']);
$this->covers->deleteManagedFiles((string) $validated['path']);
return response()->json([
'success' => true,
@@ -176,56 +151,4 @@ final class StudioNewsMediaApiController extends Controller
{
abort_unless($request->user() && ($request->user()->isAdmin() || $request->user()->isModerator()), 403);
}
private function mediaDiskName(): string
{
return (string) config('uploads.object_storage.disk', 's3');
}
private function mediaPath(string $hash): string
{
return sprintf(
'news/covers/%s/%s/%s.webp',
substr($hash, 0, 2),
substr($hash, 2, 2),
$hash,
);
}
private function publicUrlForPath(string $path): string
{
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/');
}
private function deleteMediaFile(string $path): void
{
$trimmed = ltrim(trim($path), '/');
if ($trimmed === '' || ! Str::startsWith($trimmed, 'news/covers/')) {
return;
}
Storage::disk($this->mediaDiskName())->delete($trimmed);
}
private function assertImageManager(): void
{
if ($this->manager !== null) {
return;
}
throw new RuntimeException('Image processing is not available on this environment.');
}
private function assertStorageIsAllowed(): void
{
if (! app()->environment('production')) {
return;
}
$diskName = $this->mediaDiskName();
if (in_array($diskName, ['local', 'public'], true)) {
throw new RuntimeException('Production news media storage must use object storage, not local/public disks.');
}
}
}

View File

@@ -40,7 +40,7 @@ final class StudioWorldController extends Controller
return Inertia::render('Studio/StudioWorldsIndex', [
'title' => 'Worlds',
'description' => 'Create and manage seasonal, event, and campaign destinations across Skinbase Nova.',
'description' => 'Create and manage seasonal, event, and campaign destinations across Skinbase.',
'listing' => $this->worlds->studioListing($request->only(['q', 'status', 'type', 'per_page'])),
'analytics' => $this->analytics->portfolioReport(),
'statusOptions' => [
@@ -435,7 +435,7 @@ final class StudioWorldController extends Controller
$payload = $this->worlds->publicShowPayload($world, $request->user(), true);
$seo = app(SeoFactory::class)->collectionPage(
$world->seo_title ?: ($world->title . ' — Skinbase Nova Preview'),
$world->seo_title ?: ($world->title . ' — Skinbase Preview'),
$world->seo_description ?: ($world->summary ?: $world->description ?: 'Preview world page'),
route('studio.worlds.preview', ['world' => $world]),
$world->ogImageUrl(),

View File

@@ -116,9 +116,9 @@ class ProfileCollectionController extends Controller
$seo = app(SeoFactory::class)->collectionPage(
$collection->is_featured
? sprintf('Featured: %s by %s — Skinbase Nova', $collection->title, $collection->displayOwnerName())
: sprintf('%s by %s — Skinbase Nova', $collection->title, $collection->displayOwnerName()),
$collection->summary ?: $collection->description ?: sprintf('Explore the %s collection by %s on Skinbase Nova.', $collection->title, $collection->displayOwnerName()),
? sprintf('Featured: %s by %s — Skinbase', $collection->title, $collection->displayOwnerName())
: sprintf('%s by %s — Skinbase', $collection->title, $collection->displayOwnerName()),
$collection->summary ?: $collection->description ?: sprintf('Explore the %s collection by %s on Skinbase.', $collection->title, $collection->displayOwnerName()),
$collectionPayload['public_url'],
$collectionPayload['cover_image'],
$collection->visibility === Collection::VISIBILITY_PUBLIC,
@@ -202,8 +202,8 @@ class ProfileCollectionController extends Controller
$seriesDescription = $seriesMeta['description'];
$seo = app(SeoFactory::class)->collectionListing(
sprintf('Series: %s — Skinbase Nova', $seriesKey),
sprintf('Explore the %s collection series on Skinbase Nova.', $seriesKey),
sprintf('Series: %s — Skinbase', $seriesKey),
sprintf('Explore the %s collection series on Skinbase.', $seriesKey),
route('collections.series.show', ['seriesKey' => $seriesKey])
)->toArray();

View File

@@ -155,8 +155,8 @@ class SavedCollectionController extends Controller
'libraryUrl' => route('me.saved.collections'),
'browseUrl' => route('collections.featured'),
'seo' => [
'title' => $activeList ? sprintf('%s — Saved Collections — Skinbase Nova', $activeList->title) : 'Saved Collections — Skinbase Nova',
'description' => $activeList ? sprintf('Saved collections in the %s list on Skinbase Nova.', $activeList->title) : 'Your saved collections on Skinbase Nova.',
'title' => $activeList ? sprintf('%s — Saved Collections — Skinbase', $activeList->title) : 'Saved Collections — Skinbase',
'description' => $activeList ? sprintf('Saved collections in the %s list on Skinbase.', $activeList->title) : 'Your saved collections on Skinbase.',
'canonical' => $activeList ? route('me.saved.collections.lists.show', ['listSlug' => $activeList->slug]) : route('me.saved.collections'),
'robots' => 'noindex,follow',
],

View File

@@ -18,7 +18,7 @@ final class AccountHelpPageController extends Controller
$seo = app(SeoFactory::class)
->collectionPage(
'Account Settings Help — Skinbase',
'Learn how account settings, profile settings, email changes, password care, and creator preferences work on Skinbase Nova.',
'Learn how account settings, profile settings, email changes, password care, and creator preferences work on Skinbase.',
$canonical,
)
->toArray();

View File

@@ -18,6 +18,7 @@ use App\Services\Maturity\ArtworkMaturityService;
use App\Support\Seo\SeoFactory;
use App\Support\AvatarUrl;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
@@ -104,6 +105,8 @@ final class ArtworkPageController extends Controller
->published()
->firstOrFail();
$this->loadCategoryAncestors($artwork->categories);
$canonicalSlug = Str::slug((string) ($artwork->slug ?: $artwork->title));
if ($canonicalSlug === '') {
$canonicalSlug = (string) $artwork->id;
@@ -146,7 +149,7 @@ final class ArtworkPageController extends Controller
'md' => $thumbMd,
'lg' => $thumbLg,
'xl' => $thumbXl,
], $canonical)->toArray();
], $canonical, $this->artworkBreadcrumbs($artwork, $canonical))->toArray();
$categoryIds = $artwork->categories->pluck('id')->filter()->values();
$tagIds = $artwork->tags->pluck('id')->filter()->values();
@@ -203,10 +206,25 @@ final class ArtworkPageController extends Controller
->values()
->all();
// Recursive helper to format a comment and its nested replies
$approvedComments = ArtworkComment::query()
->with('user.profile')
->where('artwork_id', $artwork->id)
->where('is_approved', true)
->orderBy('created_at')
->limit(500)
->get();
$commentsByParent = $approvedComments->groupBy(
static fn (ArtworkComment $comment): string => $comment->parent_id === null
? 'root'
: (string) $comment->parent_id
);
// Recursive helper to format a comment and its nested replies.
$formatComment = null;
$formatComment = function (ArtworkComment $c) use (&$formatComment): array {
$replies = $c->relationLoaded('approvedReplies') ? $c->approvedReplies : collect();
$formatComment = function (ArtworkComment $c) use (&$formatComment, $commentsByParent): array {
/** @var Collection<int, ArtworkComment> $replies */
$replies = $commentsByParent->get((string) $c->id, collect());
$user = $c->user;
$userId = (int) ($c->user_id ?? 0);
$avatarHash = $user?->profile?->avatar_hash ?? null;
@@ -234,7 +252,9 @@ final class ArtworkPageController extends Controller
'username' => $user?->username,
'display' => $user?->username ?? $user?->name ?? 'User',
'profile_url' => $user?->username ? '/@' . $user->username : ($userId > 0 ? '/profile/' . $userId : null),
'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64),
'avatar_url' => $avatarHash !== null
? AvatarUrl::forUser($userId, $avatarHash, 64)
: AvatarUrl::default(),
'level' => (int) ($user?->level ?? 1),
'rank' => (string) ($user?->rank ?? 'Newbie'),
],
@@ -242,13 +262,8 @@ final class ArtworkPageController extends Controller
];
};
$comments = ArtworkComment::with(['user.profile', 'approvedReplies'])
->where('artwork_id', $artwork->id)
->where('is_approved', true)
->whereNull('parent_id')
->orderBy('created_at')
->limit(500)
->get()
$comments = $commentsByParent
->get('root', collect())
->map($formatComment)
->values()
->all();
@@ -314,6 +329,105 @@ final class ArtworkPageController extends Controller
return $totals;
}
private function loadCategoryAncestors(Collection $categories): void
{
$currentLevel = $categories->filter();
while ($currentLevel->isNotEmpty()) {
$fetchedParents = collect();
$missingParentIds = $currentLevel
->filter(static fn ($category) => $category->parent_id !== null && ! $category->relationLoaded('parent'))
->pluck('parent_id')
->filter()
->unique()
->values();
if ($missingParentIds->isNotEmpty()) {
$fetchedParents = \App\Models\Category::query()
->with('contentType')
->whereIn('id', $missingParentIds->all())
->get()
->keyBy('id');
$currentLevel->each(function ($category) use ($fetchedParents): void {
if ($category->parent_id !== null && ! $category->relationLoaded('parent')) {
$category->setRelation('parent', $fetchedParents->get($category->parent_id));
}
});
}
$currentLevel = $currentLevel
->map(static fn ($category) => $category->relationLoaded('parent') ? $category->getRelation('parent') : null)
->filter()
->unique('id')
->values();
}
}
/**
* @return array<int, array{name: string, url: string}>
*/
private function artworkBreadcrumbs(Artwork $artwork, string $canonical): array
{
$primaryCategory = $artwork->categories
->sortBy(fn ($category) => [
(int) ($category->sort_order ?? 0),
(string) ($category->name ?? ''),
])
->first();
if ($primaryCategory === null) {
return [
['name' => 'Explore', 'url' => url('/explore')],
['name' => html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'), 'url' => $canonical],
];
}
$contentType = $primaryCategory->contentType;
$chain = collect();
$current = $primaryCategory;
while ($current !== null) {
$chain->prepend($current);
$current = $current->relationLoaded('parent') ? $current->parent : null;
}
$breadcrumbs = [];
$contentTypeSlug = trim((string) ($contentType?->slug ?? ''));
$contentTypeName = trim((string) ($contentType?->name ?? ''));
if ($contentTypeSlug !== '' && $contentTypeName !== '') {
$breadcrumbs[] = [
'name' => $contentTypeName,
'url' => url('/' . $contentTypeSlug),
];
}
$pathSegments = [];
foreach ($chain as $category) {
$slug = trim((string) ($category->slug ?? ''));
$name = trim((string) ($category->name ?? ''));
if ($slug === '' || $name === '' || $contentTypeSlug === '') {
continue;
}
$pathSegments[] = $slug;
$breadcrumbs[] = [
'name' => $name,
'url' => url('/' . $contentTypeSlug . '/' . implode('/', $pathSegments)),
];
}
$breadcrumbs[] = [
'name' => html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'url' => $canonical,
];
return $breadcrumbs;
}
/** Silently catch suggestion query failures so error page never crashes. */
private function safeSuggestions(callable $fn): mixed
{

View File

@@ -18,7 +18,7 @@ final class AuthHelpPageController extends Controller
$seo = app(SeoFactory::class)
->collectionPage(
'Signup and Login Help — Skinbase',
'Learn how signup, login, password recovery, verification, and account access work on Skinbase Nova, with clear guidance for common access problems and practical next steps.',
'Learn how signup, login, password recovery, verification, and account access work on Skinbase, with clear guidance for common access problems and practical next steps.',
$canonical,
)
->toArray();
@@ -27,7 +27,7 @@ final class AuthHelpPageController extends Controller
return Inertia::render('Help/AuthHelpPage', [
'title' => 'Signup & Login Help',
'description' => 'Get clear help for account creation, sign-in, password recovery, verification basics, and common access problems on Skinbase Nova.',
'description' => 'Get clear help for account creation, sign-in, password recovery, verification basics, and common access problems on Skinbase.',
'seo' => $seo,
'links' => [
'help_home' => route('help'),

View File

@@ -148,7 +148,12 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
$ttl = self::SORT_TTL_MAP[$sort] ?? 300;
$mainCategories = $this->mainCategories();
$rootCategories = $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get();
$rootCategories = $contentType->rootCategories()
->with('contentType')
->orderBy('sort_order')
->orderBy('name')
->get();
$rootCategoryLinks = $this->buildCategoryLinkItems($rootCategories, $contentSlug);
$normalizedPath = trim((string) $path, '/');
if ($normalizedPath === '') {
@@ -160,13 +165,14 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
'sort' => self::SORT_MAP[$sort] ?? ['published_at_ts:desc'],
], $perPage, false, $page)
);
$this->loadGalleryArtworkRelations($artworks->getCollection());
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
$seo = $this->buildPaginationSeo($request, url('/' . $contentSlug), $artworks);
return view('gallery.index', [
'gallery_type' => 'content-type',
'mainCategories' => $mainCategories,
'subcategories' => $rootCategories,
'subcategories' => $rootCategoryLinks,
'contentType' => $contentType,
'category' => null,
'artworks' => $artworks,
@@ -175,10 +181,10 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
'hero_title' => $contentType->name,
'hero_description' => $contentType->description ?? ($contentType->name . ' artworks on Skinbase.'),
'breadcrumbs' => collect([
(object) ['name' => 'Explore', 'url' => '/browse'],
(object) ['name' => 'Explore', 'url' => route('explore.index')],
(object) ['name' => $contentType->name, 'url' => '/' . $contentSlug],
]),
'page_title' => $contentType->name . ' Skinbase Nova',
'page_title' => $contentType->name . ' Skinbase',
'page_meta_description' => $contentType->description ?? ('Discover the best ' . $contentType->name . ' artworks on Skinbase'),
'page_meta_keywords' => strtolower($contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography',
'page_canonical' => $seo['canonical'],
@@ -194,6 +200,8 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
abort(404);
}
$this->loadCategoryLineage($category);
$categorySlugs = $this->categoryFilterSlugs($category);
$filterExpression = $this->categoryPageFilterExpression($contentSlug, $categorySlugs);
@@ -205,20 +213,31 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
'sort' => self::SORT_MAP[$sort] ?? ['published_at_ts:desc'],
], $perPage, false, $page)
);
$this->loadGalleryArtworkRelations($artworks->getCollection());
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
$seo = $this->buildPaginationSeo($request, url('/' . $contentSlug . '/' . strtolower($category->full_slug_path)), $artworks);
$navigationCategory = $category->parent ?: $category;
$navigationPath = strtolower($navigationCategory->full_slug_path);
$subcategoryParent = (object) [
'id' => $navigationCategory->id,
'url' => $this->buildCategoryUrl($contentSlug, $navigationPath),
];
$subcategories = $navigationCategory->children()->orderBy('sort_order')->orderBy('name')->get();
$subcategories = $navigationCategory->children()
->with(['contentType', 'parent.contentType'])
->orderBy('sort_order')
->orderBy('name')
->get();
$subcategoryLinks = $this->buildCategoryLinkItems($subcategories, $contentSlug, $navigationPath);
if ($subcategories->isEmpty()) {
$subcategories = $rootCategories;
$subcategoryLinks = $rootCategoryLinks;
}
$breadcrumbs = collect(array_merge([
(object) [
'name' => 'Explore',
'url' => '/browse',
'url' => route('explore.index'),
],
(object) [
'name' => $contentType->name,
@@ -235,8 +254,8 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
return view('gallery.index', [
'gallery_type' => 'category',
'mainCategories' => $mainCategories,
'subcategories' => $subcategories,
'subcategory_parent' => $navigationCategory,
'subcategories' => $subcategoryLinks,
'subcategory_parent' => $subcategoryParent,
'contentType' => $contentType,
'category' => $category,
'artworks' => $artworks,
@@ -245,7 +264,7 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
'hero_title' => $category->name,
'hero_description' => $category->description ?? ($contentType->name . ' artworks on Skinbase.'),
'breadcrumbs' => $breadcrumbs,
'page_title' => $category->name . ' Skinbase Nova',
'page_title' => $category->name . ' Skinbase',
'page_meta_description' => $category->description ?? ('Discover the best ' . $category->name . ' ' . $contentType->name . ' artworks on Skinbase'),
'page_meta_keywords' => strtolower($contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography',
'page_canonical' => $seo['canonical'],
@@ -303,13 +322,12 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
$present = ThumbnailPresenter::present($artwork, 'md');
$group = $artwork->group;
$isGroupPublisher = $group !== null;
$avatarHash = $artwork->user?->profile?->avatar_hash ?? null;
$avatarUrl = $isGroupPublisher
? $group->avatarUrl()
: \App\Support\AvatarUrl::forUser(
(int) ($artwork->user_id ?? 0),
$artwork->user?->profile?->avatar_hash ?? null,
64
);
: ($avatarHash !== null
? \App\Support\AvatarUrl::forUser((int) ($artwork->user_id ?? 0), $avatarHash, 64)
: \App\Support\AvatarUrl::default());
$displayName = $isGroupPublisher ? ($group->name ?? 'Skinbase') : ($artwork->user?->name ?? 'Skinbase');
$username = $isGroupPublisher ? '' : ($artwork->user?->username ?? '');
$profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null);
@@ -317,6 +335,8 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
return (object) $this->maturity->decoratePayload([
'id' => $artwork->id,
'name' => $artwork->title,
'slug' => $artwork->slug,
'url' => route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug]),
'content_type_name' => $primaryCategory?->contentType?->name ?? '',
'content_type_slug' => $primaryCategory?->contentType?->slug ?? '',
'category_name' => $primaryCategory->name ?? '',
@@ -349,27 +369,74 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
*/
private function categoryFilterSlugs(Category $category): array
{
$category->loadMissing('descendants');
$slugs = [];
$stack = [$category];
$pendingParentIds = [$category->id];
while ($stack !== []) {
/** @var Category $current */
$current = array_pop($stack);
if (! empty($current->slug)) {
$slugs[] = Str::lower($current->slug);
}
if (! empty($category->slug)) {
$slugs[] = Str::lower($category->slug);
}
foreach ($current->children as $child) {
$child->loadMissing('descendants');
$stack[] = $child;
while ($pendingParentIds !== []) {
$children = Category::query()
->whereIn('parent_id', $pendingParentIds)
->get(['id', 'slug']);
$pendingParentIds = $children->pluck('id')->all();
foreach ($children as $child) {
if (! empty($child->slug)) {
$slugs[] = Str::lower($child->slug);
}
}
}
return array_values(array_unique($slugs));
}
private function loadCategoryLineage(Category $category): void
{
$current = $category;
while ($current !== null) {
$current->loadMissing(['contentType', 'parent']);
$current = $current->parent;
}
}
private function buildCategoryLinkItems(Collection $categories, string $contentSlug, ?string $basePath = null): Collection
{
$normalizedBasePath = trim(strtolower((string) $basePath), '/');
return $categories->map(function (Category $category) use ($contentSlug, $normalizedBasePath) {
return (object) [
'id' => $category->id,
'name' => $category->name,
'slug' => $category->slug,
'url' => $this->buildCategoryUrl($contentSlug, implode('/', array_filter([$normalizedBasePath, $category->slug]))),
];
});
}
private function buildCategoryUrl(string $contentSlug, ?string $path = null): string
{
$normalizedPath = trim(strtolower((string) $path), '/');
return '/' . implode('/', array_filter([$contentSlug, $normalizedPath]));
}
private function loadGalleryArtworkRelations(Collection $artworks): void
{
if ($artworks->isEmpty()) {
return;
}
$artworks->loadMissing([
'user.profile',
'group',
'categories.contentType',
]);
}
private function categoryFilterClause(string $categorySlug): string
{
$quoted = addslashes($categorySlug);

View File

@@ -18,7 +18,7 @@ final class CardsHelpPageController extends Controller
$seo = app(SeoFactory::class)
->collectionPage(
'Cards Help — Skinbase',
'Learn what Cards are on Skinbase Nova, how they differ from artworks, posts, and collections, and how to create, publish, and use them effectively in personal and Group workflows.',
'Learn what Cards are on Skinbase, how they differ from artworks, posts, and collections, and how to create, publish, and use them effectively in personal and Group workflows.',
$canonical,
)
->toArray();
@@ -27,7 +27,7 @@ final class CardsHelpPageController extends Controller
return Inertia::render('Help/CardsHelpPage', [
'title' => 'Cards Help',
'description' => 'Understand Cards as a distinct creative format on Skinbase Nova, with guidance for creation, publishing, ownership, design quality, and real-world use cases.',
'description' => 'Understand Cards as a distinct creative format on Skinbase, with guidance for creation, publishing, ownership, design quality, and real-world use cases.',
'seo' => $seo,
'links' => [
'help_home' => route('help'),

View File

@@ -52,9 +52,9 @@ class CollectionDiscoveryController extends Controller
$results = $this->search->publicSearch($filters, (int) config('collections.v5.search.public_per_page', 18));
$seo = app(SeoFactory::class)->collectionListing(
'Search Collections — Skinbase Nova',
'Search Collections — Skinbase',
filled($filters['q'] ?? null)
? sprintf('Search results for "%s" across public Skinbase Nova collections.', $filters['q'])
? sprintf('Search results for "%s" across public Skinbase collections.', $filters['q'])
: 'Browse public collections using filters for category, style, theme, color, quality tier, freshness, and programming metadata.',
$request->fullUrl(),
null,
@@ -65,7 +65,7 @@ class CollectionDiscoveryController extends Controller
'eyebrow' => 'Search',
'title' => 'Search collections',
'description' => filled($filters['q'] ?? null)
? sprintf('Search results for "%s" across public Skinbase Nova collections.', $filters['q'])
? sprintf('Search results for "%s" across public Skinbase collections.', $filters['q'])
: 'Browse public collections using filters for category, style, theme, color, quality tier, freshness, and programming metadata.',
'seo' => $seo,
'collections' => $this->collections->mapCollectionCardPayloads($results->items(), false, $request->user()),
@@ -100,7 +100,7 @@ class CollectionDiscoveryController extends Controller
viewer: $request->user(),
eyebrow: 'Discovery',
title: 'Featured collections',
description: 'A rotating set of standout galleries from across Skinbase Nova. Some are meticulously hand-sequenced. Others are smart collections that stay fresh as the creator publishes new work.',
description: 'A rotating set of standout galleries from across Skinbase. Some are meticulously hand-sequenced. Others are smart collections that stay fresh as the creator publishes new work.',
collections: $featuredCollections->isNotEmpty() ? $featuredCollections : $this->discovery->publicFeaturedCollections((int) config('collections.discovery.featured_limit', 18)),
communityCollections: $this->discovery->publicCollectionsByType(Collection::TYPE_COMMUNITY, 6),
editorialCollections: $this->discovery->publicCollectionsByType(Collection::TYPE_EDITORIAL, 6),
@@ -204,7 +204,7 @@ class CollectionDiscoveryController extends Controller
abort_if(! $program || collect($landing['collections'])->isEmpty(), 404);
$seo = app(SeoFactory::class)->collectionListing(
sprintf('%s — Skinbase Nova', $program['label']),
sprintf('%s — Skinbase', $program['label']),
$program['description'],
route('collections.program.show', ['programKey' => $program['key']]),
)->toArray();
@@ -239,7 +239,7 @@ class CollectionDiscoveryController extends Controller
$campaign = null,
) {
$seo = app(SeoFactory::class)->collectionListing(
sprintf('%s — Skinbase Nova', $title),
sprintf('%s — Skinbase', $title),
$description,
url()->current(),
)->toArray();

View File

@@ -92,12 +92,17 @@ final class ExploreController extends Controller
$artworks = $this->filterBrowsableArtworks($artworks);
// EGS: fill grid to minimum when uploads are sparse
$artworks = $this->gridFiller->fill($artworks, 0, $page);
$this->loadPresentationRelations($artworks->getCollection());
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
// EGS §11: featured spotlight row on page 1 only
$spotlightItems = ($page === 1 && EarlyGrowth::spotlightEnabled())
? $this->spotlight->getSpotlight(6)->map(fn ($a) => $this->presentArtwork($a))
: collect();
$spotlightItems = collect();
if ($page === 1 && EarlyGrowth::spotlightEnabled()) {
$spotlightItems = $this->spotlight->getSpotlight(6);
$this->loadPresentationRelations($spotlightItems);
$spotlightItems = $spotlightItems->map(fn ($a) => $this->presentArtwork($a));
}
$mainCategories = $this->mainCategories();
$seo = $this->paginationSeo($request, url('/explore'), $artworks);
@@ -165,12 +170,17 @@ final class ExploreController extends Controller
$artworks = $this->filterBrowsableArtworks($artworks);
// EGS: fill grid to minimum when uploads are sparse
$artworks = $this->gridFiller->fill($artworks, 0, $page);
$this->loadPresentationRelations($artworks->getCollection());
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
// EGS §11: featured spotlight row on page 1 only
$spotlightItems = ($page === 1 && EarlyGrowth::spotlightEnabled())
? $this->spotlight->getSpotlight(6)->map(fn ($a) => $this->presentArtwork($a))
: collect();
$spotlightItems = collect();
if ($page === 1 && EarlyGrowth::spotlightEnabled()) {
$spotlightItems = $this->spotlight->getSpotlight(6);
$this->loadPresentationRelations($spotlightItems);
$spotlightItems = $spotlightItems->map(fn ($a) => $this->presentArtwork($a));
}
$mainCategories = $this->mainCategories();
$contentType = null;
@@ -557,6 +567,13 @@ final class ExploreController extends Controller
], $artwork, request()->user());
}
private function loadPresentationRelations(mixed $artworks): void
{
if (is_object($artworks) && method_exists($artworks, 'loadMissing')) {
$artworks->loadMissing(['user.profile', 'group', 'categories.contentType']);
}
}
private function paginationSeo(Request $request, string $base, mixed $paginator): array
{
$q = $request->query();

View File

@@ -18,7 +18,7 @@ final class GroupFaqPageController extends Controller
$seo = app(SeoFactory::class)
->collectionPage(
'Groups FAQ — Skinbase',
'Fast answers to the most common Groups questions on Skinbase Nova, including roles, permissions, publishing, contributor credit, invites, workflows, and troubleshooting.',
'Fast answers to the most common Groups questions on Skinbase, including roles, permissions, publishing, contributor credit, invites, workflows, and troubleshooting.',
$canonical,
)
->toArray();
@@ -27,7 +27,7 @@ final class GroupFaqPageController extends Controller
return Inertia::render('Group/GroupFaqPage', [
'title' => 'Groups FAQ',
'description' => 'Quick answers about Groups, roles, permissions, publishing, contributor credit, invites, workflows, and troubleshooting on Skinbase Nova.',
'description' => 'Quick answers about Groups, roles, permissions, publishing, contributor credit, invites, workflows, and troubleshooting on Skinbase.',
'seo' => $seo,
'links' => [
'groups_directory' => route('groups.index'),

View File

@@ -18,7 +18,7 @@ final class GroupHelpPageController extends Controller
$seo = app(SeoFactory::class)
->collectionPage(
'Groups Guide, Help, and Best Practices — Skinbase',
'Learn how Groups work on Skinbase Nova, how shared publishing preserves contributor credit, and how to manage roles, releases, reviews, projects, and team workflows with confidence.',
'Learn how Groups work on Skinbase, how shared publishing preserves contributor credit, and how to manage roles, releases, reviews, projects, and team workflows with confidence.',
$canonical,
)
->toArray();
@@ -27,7 +27,7 @@ final class GroupHelpPageController extends Controller
return Inertia::render('Group/GroupHelpPage', [
'title' => 'Groups Help & Guide',
'description' => 'Everything creators need to understand Groups, publish collaboratively, preserve contributor credit, and build a healthy shared identity on Skinbase Nova.',
'description' => 'Everything creators need to understand Groups, publish collaboratively, preserve contributor credit, and build a healthy shared identity on Skinbase.',
'seo' => $seo,
'links' => [
'groups_directory' => route('groups.index'),

View File

@@ -18,7 +18,7 @@ final class GroupQuickstartPageController extends Controller
$seo = app(SeoFactory::class)
->collectionPage(
'Groups Quickstart — Skinbase',
'A fast, creator-friendly Groups quickstart for Skinbase Nova. Learn when to use a Group, create one, invite members, and publish your first Group artwork with correct contributor credit.',
'A fast, creator-friendly Groups quickstart for Skinbase. Learn when to use a Group, create one, invite members, and publish your first Group artwork with correct contributor credit.',
$canonical,
)
->toArray();

View File

@@ -18,7 +18,7 @@ final class HelpCenterPageController extends Controller
$seo = app(SeoFactory::class)
->collectionPage(
'Help Center — Skinbase',
'Find help, guides, quickstarts, FAQs, and troubleshooting for Skinbase Nova, including Groups, Studio, Upload, Cards, Profile, and account access.',
'Find help, guides, quickstarts, FAQs, and troubleshooting for Skinbase, including Groups, Studio, Upload, Cards, Profile, and account access.',
$canonical,
)
->toArray();
@@ -27,7 +27,7 @@ final class HelpCenterPageController extends Controller
return Inertia::render('Help/HelpCenterPage', [
'title' => 'Help Center',
'description' => 'Find guides, quickstarts, FAQs, and troubleshooting for Skinbase Nova in one structured help hub.',
'description' => 'Find guides, quickstarts, FAQs, and troubleshooting for Skinbase in one structured help hub.',
'seo' => $seo,
'links' => [
'studio_help' => route('help.studio'),

View File

@@ -8,14 +8,15 @@ use App\Http\Controllers\Controller;
use App\Services\HomepageService;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
final class HomeController extends Controller
{
public function __construct(private readonly HomepageService $homepage) {}
public function index(Request $request): \Illuminate\View\View
public function index(Request $request): Response
{
$user = $request->user();
$user = $request->user();
$sections = $user
? $this->homepage->allForUser($user)
: array_merge($this->homepage->all(), ['is_logged_in' => false]);
@@ -23,18 +24,24 @@ final class HomeController extends Controller
$hero = $sections['hero'];
$meta = [
'title' => 'Skinbase Digital Art & Wallpapers',
'title' => 'Skinbase Digital Art & Wallpapers',
'description' => 'Discover stunning digital art, wallpapers, and skins from a global community of creators. Browse trending works, fresh uploads, and beloved classics.',
'keywords' => 'wallpapers, digital art, skins, photography, community, wallpaper downloads',
'og_image' => $hero['thumb_lg'] ?? $hero['thumb'] ?? null,
'canonical' => url('/'),
'keywords' => 'wallpapers, digital art, skins, photography, community, wallpaper downloads',
'og_image' => $hero['featured_image']['preload_url'] ?? $hero['thumb_lg'] ?? $hero['thumb'] ?? null,
'canonical' => url('/'),
];
return view('web.home', [
$response = response()->view('web.home', [
'seo' => app(SeoFactory::class)->homepage($meta)->toArray(),
'useUnifiedSeo' => true,
'meta' => $meta,
'meta' => $meta,
'props' => $sections,
]);
if ($user) {
return $response->header('Cache-Control', 'private, no-store');
}
return $response->header('Cache-Control', 'public, max-age=60, s-maxage=300, stale-while-revalidate=600');
}
}

View File

@@ -60,12 +60,12 @@ class NovaCardsController extends Controller
return view('cards.index', [
'meta' => [
'title' => 'Nova Cards - Skinbase Nova',
'description' => 'Browse featured, trending, and latest Nova Cards. Discover beautiful quote cards, mood cards, and visual text art by the Skinbase Nova community.',
'title' => 'Cards - Skinbase',
'description' => 'Browse featured, trending, and latest Cards. Discover beautiful quote cards, mood cards, and visual text art by the Skinbase community.',
'canonical' => route('cards.index'),
'robots' => 'index,follow',
],
'heading' => 'Nova Cards',
'heading' => 'Cards',
'subheading' => (string) config('nova_cards.brand.subtitle'),
'cards' => $this->presenter->cards($latest->items()),
'pagination' => $latest,
@@ -90,13 +90,13 @@ class NovaCardsController extends Controller
return view('cards.index', [
'meta' => [
'title' => $category->name . ' Cards - Skinbase Nova',
'description' => $category->description ?: ('Browse ' . strtolower($category->name) . ' Nova Cards on Skinbase Nova.'),
'title' => $category->name . ' Cards - Skinbase',
'description' => $category->description ?: ('Browse ' . strtolower($category->name) . ' Cards on Skinbase.'),
'canonical' => route('cards.category', ['categorySlug' => $category->slug]),
'robots' => 'index,follow',
],
'heading' => $category->name,
'subheading' => $category->description ?: 'Explore this Nova Cards category.',
'subheading' => $category->description ?: 'Explore this Cards category.',
'cards' => $this->presenter->cards($cards->items()),
'pagination' => $cards,
'featuredCards' => [],
@@ -119,8 +119,8 @@ class NovaCardsController extends Controller
return view('cards.index', [
'meta' => [
'title' => 'Popular Cards - Skinbase Nova',
'description' => 'Browse the most liked, saved, and viewed Nova Cards on Skinbase Nova.',
'title' => 'Popular Cards - Skinbase',
'description' => 'Browse the most liked, saved, and viewed Cards on Skinbase.',
'canonical' => route('cards.popular'),
'robots' => 'index,follow',
],
@@ -153,13 +153,13 @@ class NovaCardsController extends Controller
return view('cards.index', [
'meta' => [
'title' => 'Rising Cards - Skinbase Nova',
'description' => 'Discover Nova Cards that are gaining traction right now — fresh creators and fast-rising saves and remixes.',
'title' => 'Rising Cards - Skinbase',
'description' => 'Discover Cards that are gaining traction right now — fresh creators and fast-rising saves and remixes.',
'canonical' => route('cards.rising'),
'robots' => 'index,follow',
],
'heading' => 'Rising',
'subheading' => 'Fresh Nova Cards gaining momentum right now.',
'subheading' => 'Fresh Cards gaining momentum right now.',
'cards' => $this->presenter->cards($paginated->items(), false, $request->user()),
'pagination' => $paginated,
'featuredCards' => [],
@@ -182,13 +182,13 @@ class NovaCardsController extends Controller
return view('cards.index', [
'meta' => [
'title' => 'Remixed Cards - Skinbase Nova',
'description' => 'Discover Nova Cards remixed from community originals with attribution and lineage.',
'title' => 'Remixed Cards - Skinbase',
'description' => 'Discover Cards remixed from community originals with attribution and lineage.',
'canonical' => route('cards.remixed'),
'robots' => 'index,follow',
],
'heading' => 'Remixed cards',
'subheading' => 'Community reinterpretations linked back to their original Nova Cards.',
'subheading' => 'Community reinterpretations linked back to their original Cards.',
'cards' => $this->presenter->cards($cards->items(), false, $request->user()),
'pagination' => $cards,
'featuredCards' => [],
@@ -214,8 +214,8 @@ class NovaCardsController extends Controller
return view('cards.index', [
'meta' => [
'title' => 'Best Remixes - Skinbase Nova',
'description' => 'Browse standout Nova Card remixes ranked by remix traction, saves, and likes.',
'title' => 'Best Remixes - Skinbase',
'description' => 'Browse standout Card remixes ranked by remix traction, saves, and likes.',
'canonical' => route('cards.remix-highlights'),
'robots' => 'index,follow',
],
@@ -295,13 +295,13 @@ class NovaCardsController extends Controller
return view('cards.index', [
'meta' => [
'title' => 'Editorial Picks - Nova Cards - Skinbase Nova',
'description' => 'Browse editorial Nova Cards picks, featured collections, and highlighted challenges.',
'title' => 'Editorial Picks - Cards - Skinbase',
'description' => 'Browse editorial Cards picks, featured collections, and highlighted challenges.',
'canonical' => route('cards.editorial'),
'robots' => 'index,follow',
],
'heading' => 'Editorial picks',
'subheading' => 'Curated Nova Cards, featured collections, and standout challenge surfaces chosen for quality and cohesion.',
'subheading' => 'Curated Cards, featured collections, and standout challenge surfaces chosen for quality and cohesion.',
'cards' => $this->presenter->cards($cards->items(), false, $request->user()),
'pagination' => $cards,
'featuredCards' => [],
@@ -329,13 +329,13 @@ class NovaCardsController extends Controller
return view('cards.index', [
'meta' => [
'title' => 'Seasonal Cards - Nova Cards - Skinbase Nova',
'description' => 'Browse seasonal and event-aware Nova Cards grouped by recurring moods, holidays, and time-of-year themes.',
'title' => 'Seasonal Cards - Cards - Skinbase',
'description' => 'Browse seasonal and event-aware Cards grouped by recurring moods, holidays, and time-of-year themes.',
'canonical' => route('cards.seasonal'),
'robots' => 'index,follow',
],
'heading' => 'Seasonal cards',
'subheading' => 'Discover Nova Cards grouped by recurring seasonal and campaign-style themes.',
'subheading' => 'Discover Cards grouped by recurring seasonal and campaign-style themes.',
'cards' => $this->presenter->cards($cards->items(), false, $request->user()),
'pagination' => $cards,
'featuredCards' => [],
@@ -363,13 +363,13 @@ class NovaCardsController extends Controller
return view('cards.challenges', [
'meta' => [
'title' => 'Card Challenges - Skinbase Nova',
'description' => 'Browse active and completed Nova Cards challenges, prompts, and winners.',
'title' => 'Card Challenges - Skinbase',
'description' => 'Browse active and completed Cards challenges, prompts, and winners.',
'canonical' => route('cards.challenges'),
'robots' => 'index,follow',
],
'heading' => 'Card challenges',
'subheading' => 'Official prompts and community challenge runs for Nova Cards creators.',
'subheading' => 'Official prompts and community challenge runs for Cards creators.',
'challenges' => $challenges,
]);
}
@@ -388,8 +388,8 @@ class NovaCardsController extends Controller
return view('cards.challenges', [
'meta' => [
'title' => $challenge->title . ' - Skinbase Nova',
'description' => $challenge->description ?: 'Browse entries for this Nova Cards challenge.',
'title' => $challenge->title . ' - Skinbase',
'description' => $challenge->description ?: 'Browse entries for this Cards challenge.',
'canonical' => route('cards.challenges.show', ['slug' => $challenge->slug]),
'robots' => 'index,follow',
],
@@ -410,8 +410,8 @@ class NovaCardsController extends Controller
{
return view('cards.resources', [
'meta' => [
'title' => 'Template Packs - Skinbase Nova',
'description' => 'Browse official Nova Cards template packs and starting points.',
'title' => 'Template Packs - Skinbase',
'description' => 'Browse official Cards template packs and starting points.',
'canonical' => route('cards.templates'),
'robots' => 'index,follow',
],
@@ -427,13 +427,13 @@ class NovaCardsController extends Controller
{
return view('cards.resources', [
'meta' => [
'title' => 'Asset Packs - Skinbase Nova',
'description' => 'Browse official Nova Cards asset packs for decorative and editorial layouts.',
'title' => 'Asset Packs - Skinbase',
'description' => 'Browse official Cards asset packs for decorative and editorial layouts.',
'canonical' => route('cards.assets'),
'robots' => 'index,follow',
],
'heading' => 'Asset packs',
'subheading' => 'Official decorative and editorial pack sets for the Nova Cards v2 editor.',
'subheading' => 'Official decorative and editorial pack sets for the Cards v2 editor.',
'packs' => collect($this->presenter->options()['asset_packs'] ?? []),
'templates' => collect(),
'resourceType' => 'asset',
@@ -447,8 +447,8 @@ class NovaCardsController extends Controller
return view('cards.index', [
'meta' => [
'title' => '#' . $tag->name . ' Cards - Skinbase Nova',
'description' => 'Browse Nova Cards tagged with #' . $tag->name . ' on Skinbase Nova.',
'title' => '#' . $tag->name . ' Cards - Skinbase',
'description' => 'Browse Cards tagged with #' . $tag->name . ' on Skinbase.',
'canonical' => route('cards.tag', ['tagSlug' => $tag->slug]),
'robots' => 'index,follow',
],
@@ -480,13 +480,13 @@ class NovaCardsController extends Controller
return view('cards.index', [
'meta' => [
'title' => $mood['label'] . ' Mood Cards - Skinbase Nova',
'description' => 'Browse Nova Cards grouped into the ' . strtolower((string) $mood['label']) . ' mood family on Skinbase Nova.',
'title' => $mood['label'] . ' Mood Cards - Skinbase',
'description' => 'Browse Cards grouped into the ' . strtolower((string) $mood['label']) . ' mood family on Skinbase.',
'canonical' => route('cards.mood', ['moodSlug' => $mood['key']]),
'robots' => 'index,follow',
],
'heading' => $mood['label'],
'subheading' => 'Discover Nova Cards grouped by a curated mood family using durable tag mappings.',
'subheading' => 'Discover Cards grouped by a curated mood family using durable tag mappings.',
'cards' => $this->presenter->cards($cards->items(), false, $request->user()),
'pagination' => $cards,
'featuredCards' => [],
@@ -514,13 +514,13 @@ class NovaCardsController extends Controller
return view('cards.index', [
'meta' => [
'title' => $style['label'] . ' Style Cards - Skinbase Nova',
'description' => 'Browse Nova Cards using the ' . strtolower((string) $style['label']) . ' style family on Skinbase Nova.',
'title' => $style['label'] . ' Style Cards - Skinbase',
'description' => 'Browse Cards using the ' . strtolower((string) $style['label']) . ' style family on Skinbase.',
'canonical' => route('cards.style', ['styleSlug' => $style['key']]),
'robots' => 'index,follow',
],
'heading' => $style['label'],
'subheading' => 'Discover Nova Cards grouped by a shared visual style family.',
'subheading' => 'Discover Cards grouped by a shared visual style family.',
'cards' => $this->presenter->cards($cards->items(), false, $request->user()),
'pagination' => $cards,
'featuredCards' => [],
@@ -548,13 +548,13 @@ class NovaCardsController extends Controller
return view('cards.index', [
'meta' => [
'title' => $palette['label'] . ' Palette Cards - Skinbase Nova',
'description' => 'Browse Nova Cards using the ' . strtolower((string) $palette['label']) . ' palette family on Skinbase Nova.',
'title' => $palette['label'] . ' Palette Cards - Skinbase',
'description' => 'Browse Cards using the ' . strtolower((string) $palette['label']) . ' palette family on Skinbase.',
'canonical' => route('cards.palette', ['paletteSlug' => $palette['key']]),
'robots' => 'index,follow',
],
'heading' => $palette['label'],
'subheading' => 'Discover Nova Cards grouped by shared palette families and color direction.',
'subheading' => 'Discover Cards grouped by shared palette families and color direction.',
'cards' => $this->presenter->cards($cards->items(), false, $request->user()),
'pagination' => $cards,
'featuredCards' => [],
@@ -580,8 +580,8 @@ class NovaCardsController extends Controller
return view('cards.index', array_merge($this->creatorPagePayload($request, $user), [
'meta' => [
'title' => '@' . $user->username . ' Cards - Skinbase Nova',
'description' => 'Browse Nova Cards created by @' . $user->username . ' on Skinbase Nova.',
'title' => '@' . $user->username . ' Cards - Skinbase',
'description' => 'Browse Cards created by @' . $user->username . ' on Skinbase.',
'canonical' => route('cards.creator', ['username' => strtolower((string) $user->username)]),
'robots' => 'index,follow',
],
@@ -602,13 +602,13 @@ class NovaCardsController extends Controller
return view('cards.index', array_merge($this->creatorPagePayload($request, $user), [
'meta' => [
'title' => '@' . $user->username . ' Portfolio - Skinbase Nova',
'description' => 'Browse the dedicated Nova Cards portfolio page for @' . $user->username . ' on Skinbase Nova.',
'title' => '@' . $user->username . ' Portfolio - Skinbase',
'description' => 'Browse the dedicated Cards portfolio page for @' . $user->username . ' on Skinbase.',
'canonical' => route('cards.creator.portfolio', ['username' => strtolower((string) $user->username)]),
'robots' => 'index,follow',
],
'heading' => '@' . $user->username . ' Portfolio',
'subheading' => 'A dedicated Nova Cards portfolio view for ' . ($user->name ?: '@' . $user->username) . '.',
'subheading' => 'A dedicated Cards portfolio view for ' . ($user->name ?: '@' . $user->username) . '.',
'context' => 'creator-portfolio',
]));
}
@@ -695,8 +695,8 @@ class NovaCardsController extends Controller
return view('cards.collection', [
'meta' => [
'title' => $collection->name . ' - Nova Cards Collection - Skinbase Nova',
'description' => $collection->description ?: 'Browse this curated Nova Cards collection.',
'title' => $collection->name . ' - Cards Collection - Skinbase',
'description' => $collection->description ?: 'Browse this curated Cards collection.',
'canonical' => route('cards.collections.show', ['slug' => $collection->slug, 'id' => $collection->id]),
'robots' => 'index,follow',
],
@@ -721,7 +721,7 @@ class NovaCardsController extends Controller
return view('cards.lineage', [
'meta' => [
'title' => $card->title . ' Lineage - Nova Cards - Skinbase Nova',
'title' => $card->title . ' Lineage - Cards - Skinbase',
'description' => 'Browse the remix lineage and related variants for this Nova Card.',
'canonical' => route('cards.lineage', ['slug' => $card->slug, 'id' => $card->id]),
'robots' => 'index,follow',
@@ -767,7 +767,7 @@ class NovaCardsController extends Controller
return view('cards.show', [
'card' => $this->presenter->card($card, true, $request->user()),
'meta' => [
'title' => $card->title . ' - Nova Cards - Skinbase Nova',
'title' => $card->title . ' - Cards - Skinbase',
'description' => $card->description ?: $card->quote_text,
'canonical' => route('cards.show', ['slug' => $card->slug, 'id' => $card->id]),
'robots' => $card->visibility === NovaCard::VISIBILITY_PUBLIC ? 'index,follow' : 'noindex,follow',

View File

@@ -18,7 +18,7 @@ final class ProfileHelpPageController extends Controller
$seo = app(SeoFactory::class)
->collectionPage(
'Profile Help — Skinbase',
'Learn how profiles work on Skinbase Nova, how they differ from Groups, and how to build a stronger personal identity with better setup, presentation, and creator-facing profile habits.',
'Learn how profiles work on Skinbase, how they differ from Groups, and how to build a stronger personal identity with better setup, presentation, and creator-facing profile habits.',
$canonical,
)
->toArray();

View File

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

View File

@@ -18,7 +18,7 @@ final class StudioHelpPageController extends Controller
$seo = app(SeoFactory::class)
->collectionPage(
'Studio Help — Skinbase',
'Learn how Studio works on Skinbase Nova, including drafts, publishing, personal versus Group context, artworks, cards, collections, and collaboration workflows.',
'Learn how Studio works on Skinbase, including drafts, publishing, personal versus Group context, artworks, cards, collections, and collaboration workflows.',
$canonical,
)
->toArray();
@@ -27,7 +27,7 @@ final class StudioHelpPageController extends Controller
return Inertia::render('Help/StudioHelpPage', [
'title' => 'Studio Help',
'description' => 'Understand Studio as the creative control center of Skinbase Nova, with guidance for drafts, publishing, artworks, cards, collections, and Group workflows.',
'description' => 'Understand Studio as the creative control center of Skinbase, with guidance for drafts, publishing, artworks, cards, collections, and Group workflows.',
'seo' => $seo,
'links' => [
'help_home' => route('help'),

View File

@@ -18,7 +18,7 @@ final class TroubleshootingHelpPageController extends Controller
$seo = app(SeoFactory::class)
->collectionPage(
'Troubleshooting Help — Skinbase',
'Use fast, support-oriented troubleshooting guidance for login issues, permissions confusion, publishing blockers, profile setup problems, and bug-report escalation on Skinbase Nova.',
'Use fast, support-oriented troubleshooting guidance for login issues, permissions confusion, publishing blockers, profile setup problems, and bug-report escalation on Skinbase.',
$canonical,
)
->toArray();

View File

@@ -18,7 +18,7 @@ final class UploadHelpPageController extends Controller
$seo = app(SeoFactory::class)
->collectionPage(
'Upload Help — Skinbase',
'Learn how uploading works on Skinbase Nova, including draft creation, metadata review, previews, personal versus Group context, contributor credit, publishing, and troubleshooting.',
'Learn how uploading works on Skinbase, including draft creation, metadata review, previews, personal versus Group context, contributor credit, publishing, and troubleshooting.',
$canonical,
)
->toArray();
@@ -27,7 +27,7 @@ final class UploadHelpPageController extends Controller
return Inertia::render('Help/UploadHelpPage', [
'title' => 'Upload Help',
'description' => 'Understand the full upload workflow on Skinbase Nova, from file submission and draft creation to metadata review, contributor credit, and final publish.',
'description' => 'Understand the full upload workflow on Skinbase, from file submission and draft creation to metadata review, contributor credit, and final publish.',
'seo' => $seo,
'links' => [
'help_home' => route('help'),

View File

@@ -22,7 +22,7 @@ final class WorldController extends Controller
{
$payload = $this->worlds->publicIndexPayload($request->user());
$seo = app(SeoFactory::class)->collectionListing(
'Worlds — Skinbase Nova',
'Worlds — Skinbase',
$payload['description'],
route('worlds.index'),
)->toArray();
@@ -45,8 +45,8 @@ final class WorldController extends Controller
$payload = $this->worlds->publicShowPayload($resolvedWorld, $request->user());
$seo = app(SeoFactory::class)->collectionPage(
$resolvedWorld->seo_title ?: ($resolvedWorld->title . ' — Skinbase Nova'),
$resolvedWorld->seo_description ?: ($resolvedWorld->summary ?: $resolvedWorld->description ?: 'Seasonal and editorial discovery world on Skinbase Nova.'),
$resolvedWorld->seo_title ?: ($resolvedWorld->title . ' — Skinbase'),
$resolvedWorld->seo_description ?: ($resolvedWorld->summary ?: $resolvedWorld->description ?: 'Seasonal and editorial discovery world on Skinbase.'),
$this->worlds->canonicalPublicUrl($resolvedWorld),
$resolvedWorld->ogImageUrl(),
)->toArray();
@@ -69,8 +69,8 @@ final class WorldController extends Controller
$payload = $this->worlds->publicShowPayload($resolvedWorld, $request->user());
$seo = app(SeoFactory::class)->collectionPage(
$resolvedWorld->seo_title ?: ($resolvedWorld->title . ' — Skinbase Nova'),
$resolvedWorld->seo_description ?: ($resolvedWorld->summary ?: $resolvedWorld->description ?: 'Seasonal and editorial discovery world on Skinbase Nova.'),
$resolvedWorld->seo_title ?: ($resolvedWorld->title . ' — Skinbase'),
$resolvedWorld->seo_description ?: ($resolvedWorld->summary ?: $resolvedWorld->description ?: 'Seasonal and editorial discovery world on Skinbase.'),
$this->worlds->canonicalPublicUrl($resolvedWorld),
$resolvedWorld->ogImageUrl(),
)->toArray();

View File

@@ -18,7 +18,7 @@ final class WorldsHelpPageController extends Controller
$seo = app(SeoFactory::class)
->collectionPage(
'Worlds Help — Skinbase',
'Learn how Worlds work on Skinbase Nova, including editorial purpose, attached content, section control, preview, publishing, recurrence, and homepage promotion.',
'Learn how Worlds work on Skinbase, including editorial purpose, attached content, section control, preview, publishing, recurrence, and homepage promotion.',
$canonical,
)
->toArray();
@@ -27,7 +27,7 @@ final class WorldsHelpPageController extends Controller
return Inertia::render('Help/WorldsHelpPage', [
'title' => 'Worlds Help',
'description' => 'A complete guide to creating, attaching content to, previewing, and publishing Worlds on Skinbase Nova.',
'description' => 'A complete guide to creating, attaching content to, previewing, and publishing Worlds on Skinbase.',
'seo' => $seo,
'links' => [
'help_home' => route('help'),

View File

@@ -6,6 +6,7 @@ namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\ViewErrorBag;
use Illuminate\View\Middleware\ShareErrorsFromSession;
class ConditionalShareErrorsFromSession extends ShareErrorsFromSession
@@ -17,6 +18,8 @@ class ConditionalShareErrorsFromSession extends ShareErrorsFromSession
}
if ($request->attributes->get('skinbase.session_skipped') === true || ! $request->hasSession()) {
$this->view->share('errors', new ViewErrorBag());
return $next($request);
}

View File

@@ -12,10 +12,22 @@ class ConditionalValidateCsrfToken extends ValidateCsrfToken
{
public function handle($request, Closure $next): mixed
{
if ($this->shouldBypassForTesting()) {
return $next($request);
}
if ($request instanceof Request && $request->attributes->get('skinbase.session_skipped') === true) {
return $next($request);
}
return parent::handle($request, $next);
}
private function shouldBypassForTesting(): bool
{
return app()->runningUnitTests()
|| app()->environment('testing')
|| defined('PHPUNIT_COMPOSER_INSTALL')
|| defined('__PHPUNIT_PHAR__');
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class EnsureAdminRole
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (! $user || ! $user->isAdmin()) {
abort(Response::HTTP_FORBIDDEN, 'Only admins can access this area.');
}
return $next($request);
}
}

View File

@@ -19,7 +19,7 @@ final class EnsureStaffAccess
abort(Response::HTTP_FORBIDDEN, 'Forbidden.');
}
return redirect()->route('home')->with('error', 'You do not have access to this area.');
return redirect()->route('index')->with('error', 'You do not have access to this area.');
}
return $next($request);

View File

@@ -112,6 +112,7 @@ final class HandleInertiaRequests extends Middleware
'files_url' => config('cdn.files_url'),
],
'features' => [
'academy' => (bool) config('academy.enabled', true),
'groups' => (bool) config('features.groups', true),
'groups_v1' => (bool) config('features.groups_v1', true),
'groups_v2' => (bool) config('features.groups_v2', true),

View File

@@ -22,30 +22,48 @@ class RedirectLegacyProfileSubdomain
return redirect()->to($this->targetUrl($request, $canonicalUsername), 301);
}
if ($this->shouldRedirectToCanonicalHost($request)) {
return redirect()->to($this->canonicalHostUrl($request), 301);
}
return $next($request);
}
private function resolveCanonicalUsername(Request $request): ?string
private function shouldRedirectToCanonicalHost(Request $request): bool
{
return $this->isSingleSubdomainOnConfiguredHost($request);
}
private function isSingleSubdomainOnConfiguredHost(Request $request): bool
{
$configuredHost = parse_url((string) config('app.url'), PHP_URL_HOST);
if (! is_string($configuredHost) || $configuredHost === '') {
return null;
return false;
}
$requestHost = strtolower($request->getHost());
$configuredHost = strtolower($configuredHost);
if ($requestHost === $configuredHost || ! str_ends_with($requestHost, '.' . $configuredHost)) {
return null;
return false;
}
$subdomain = substr($requestHost, 0, -strlen('.' . $configuredHost));
if ($subdomain === '' || str_contains($subdomain, '.')) {
return $subdomain !== '' && ! str_contains($subdomain, '.');
}
private function resolveCanonicalUsername(Request $request): ?string
{
if (! $this->isSingleSubdomainOnConfiguredHost($request)) {
return null;
}
$configuredHost = strtolower((string) parse_url((string) config('app.url'), PHP_URL_HOST));
$requestHost = strtolower($request->getHost());
$subdomain = substr($requestHost, 0, -strlen('.' . $configuredHost));
$candidate = UsernamePolicy::normalize($subdomain);
if ($candidate === '' || $this->isReservedSubdomain($candidate)) {
@@ -103,4 +121,16 @@ class RedirectLegacyProfileSubdomain
return $target;
}
private function canonicalHostUrl(Request $request): string
{
$target = rtrim((string) config('app.url'), '/') . $request->getPathInfo();
$query = $request->getQueryString();
if (is_string($query) && $query !== '') {
$target .= '?' . $query;
}
return $target;
}
}

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Services\Traffic\OnlineVisitorRepository;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class TrackOnlineVisitor
{
public function __construct(private readonly OnlineVisitorRepository $visitors)
{
}
public function handle(Request $request, Closure $next): Response
{
$shouldTrack = $this->shouldTrack($request);
$response = $next($request);
if ($shouldTrack) {
try {
$this->visitors->track($request);
} catch (\Throwable) {
// Presence tracking is best-effort only.
}
}
return $response;
}
private function shouldTrack(Request $request): bool
{
if (! in_array($request->getMethod(), ['GET', 'HEAD'], true)) {
return false;
}
if ($this->matchesAny($request, [
'build/*',
'storage/*',
'favicon.ico',
'livewire/*',
'_debugbar/*',
'telescope/*',
'horizon/*',
'moderation/*',
])) {
return false;
}
if ($request->path() === 'moderation/traffic/online/data') {
return false;
}
if ($this->matchesAny($request, [
'api/*',
'admin/*',
'dashboard*',
'studio*',
'settings*',
'messages*',
'creator*',
'login',
'register',
'forgot-password',
'reset-password/*',
'email/*',
'logout',
'up',
])) {
return false;
}
return ! $this->isStaticAssetPath($request->path());
}
/**
* @param array<int, string> $patterns
*/
private function matchesAny(Request $request, array $patterns): bool
{
foreach ($patterns as $pattern) {
if ($request->is($pattern)) {
return true;
}
}
return false;
}
private function isStaticAssetPath(string $path): bool
{
$normalizedPath = '/' . ltrim($path, '/');
if (in_array($normalizedPath, ['/robots.txt', '/sitemap.xml'], true) || str_starts_with($normalizedPath, '/sitemaps/')) {
return false;
}
return (bool) preg_match('/\.(?:css|js|mjs|map|png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|eot|otf|mp4|webm|mp3|wav|pdf|zip)$/i', $normalizedPath);
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Academy;
use Illuminate\Foundation\Http\FormRequest;
class StoreAcademyChallengeSubmissionRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
protected function prepareForValidation(): void
{
$this->merge([
'artwork_id' => $this->filled('artwork_id') ? (int) $this->input('artwork_id') : null,
'is_ai_generated' => $this->boolean('is_ai_generated'),
'is_ai_assisted' => $this->boolean('is_ai_assisted', true),
]);
}
public function rules(): array
{
return [
'artwork_id' => ['required', 'integer', 'exists:artworks,id'],
'prompt_used' => ['nullable', 'string'],
'workflow_notes' => ['nullable', 'string'],
'ai_tool_used' => ['nullable', 'string', 'max:120'],
'is_ai_generated' => ['required', 'boolean'],
'is_ai_assisted' => ['required', 'boolean'],
];
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Academy;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpsertAcademyBadgeRequest extends FormRequest
{
public function authorize(): bool
{
return (bool) $this->user()?->hasStaffAccess();
}
protected function prepareForValidation(): void
{
$this->merge([
'active' => $this->boolean('active', true),
'rules' => (array) $this->input('rules', []),
]);
}
public function rules(): array
{
$badgeId = $this->route('academyBadge')?->id;
return [
'name' => ['required', 'string', 'max:140'],
'slug' => ['required', 'string', 'max:160', Rule::unique('academy_badges', 'slug')->ignore($badgeId)],
'description' => ['nullable', 'string'],
'icon' => ['nullable', 'string', 'max:120'],
'badge_type' => ['required', 'string', 'max:80'],
'rules' => ['nullable', 'array'],
'active' => ['required', 'boolean'],
];
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Academy;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpsertAcademyCategoryRequest extends FormRequest
{
public function authorize(): bool
{
return (bool) $this->user()?->hasStaffAccess();
}
protected function prepareForValidation(): void
{
$this->merge([
'active' => $this->boolean('active', true),
'order_num' => $this->filled('order_num') ? (int) $this->input('order_num') : 0,
]);
}
public function rules(): array
{
$categoryId = $this->route('academyCategory')?->id;
return [
'type' => ['required', 'string', Rule::in(['lesson', 'prompt', 'challenge', 'pack'])],
'name' => ['required', 'string', 'max:120'],
'slug' => ['required', 'string', 'max:140', Rule::unique('academy_categories', 'slug')->ignore($categoryId)],
'description' => ['nullable', 'string'],
'icon' => ['nullable', 'string', 'max:120'],
'order_num' => ['required', 'integer', 'min:0', 'max:9999'],
'active' => ['required', 'boolean'],
];
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Academy;
use App\Models\AcademyChallenge;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpsertAcademyChallengeRequest extends FormRequest
{
public function authorize(): bool
{
return (bool) $this->user()?->hasStaffAccess();
}
protected function prepareForValidation(): void
{
$this->merge([
'required_tags' => array_values(array_filter((array) $this->input('required_tags', []))),
'allowed_categories' => array_values(array_filter((array) $this->input('allowed_categories', []))),
'featured' => $this->boolean('featured'),
'active' => $this->boolean('active', true),
]);
}
public function rules(): array
{
$challengeId = $this->route('academyChallenge')?->id;
return [
'title' => ['required', 'string', 'max:180'],
'slug' => ['required', 'string', 'max:180', Rule::unique('academy_challenges', 'slug')->ignore($challengeId)],
'excerpt' => ['nullable', 'string'],
'description' => ['nullable', 'string'],
'brief' => ['nullable', 'string'],
'rules' => ['nullable', 'string'],
'access_level' => ['required', 'string', Rule::in(['free', 'creator', 'pro'])],
'status' => ['required', 'string', Rule::in([
AcademyChallenge::STATUS_DRAFT,
AcademyChallenge::STATUS_SCHEDULED,
AcademyChallenge::STATUS_ACTIVE,
AcademyChallenge::STATUS_VOTING,
AcademyChallenge::STATUS_COMPLETED,
AcademyChallenge::STATUS_ARCHIVED,
])],
'starts_at' => ['nullable', 'date'],
'ends_at' => ['nullable', 'date'],
'voting_starts_at' => ['nullable', 'date'],
'voting_ends_at' => ['nullable', 'date'],
'cover_image' => ['nullable', 'string', 'max:2048'],
'prize_text' => ['nullable', 'string', 'max:180'],
'required_tags' => ['nullable', 'array'],
'required_tags.*' => ['string', 'max:60'],
'allowed_categories' => ['nullable', 'array'],
'allowed_categories.*' => ['string', 'max:80'],
'featured' => ['required', 'boolean'],
'active' => ['required', 'boolean'],
];
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Academy;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpsertAcademyCourseRequest extends FormRequest
{
public function authorize(): bool
{
return (bool) $this->user()?->hasStaffAccess();
}
protected function prepareForValidation(): void
{
$this->merge([
'is_featured' => $this->boolean('is_featured'),
'order_num' => $this->filled('order_num') ? (int) $this->input('order_num') : 0,
'estimated_minutes' => $this->filled('estimated_minutes') ? (int) $this->input('estimated_minutes') : null,
]);
}
public function rules(): array
{
$courseId = $this->route('academyCourse')?->id;
return [
'title' => ['required', 'string', 'max:180'],
'slug' => ['required', 'string', 'max:180', Rule::unique('academy_courses', 'slug')->ignore($courseId)],
'subtitle' => ['nullable', 'string', 'max:255'],
'excerpt' => ['nullable', 'string'],
'description' => ['nullable', 'string'],
'cover_image' => ['nullable', 'string', 'max:2048'],
'teaser_image' => ['nullable', 'string', 'max:2048'],
'access_level' => ['required', 'string', Rule::in(['free', 'premium', 'mixed'])],
'difficulty' => ['required', 'string', Rule::in(['beginner', 'intermediate', 'advanced'])],
'status' => ['required', 'string', Rule::in(['draft', 'review', 'published', 'archived'])],
'is_featured' => ['required', 'boolean'],
'order_num' => ['required', 'integer', 'min:0'],
'estimated_minutes' => ['nullable', 'integer', 'min:1', 'max:10000'],
'published_at' => ['nullable', 'date'],
'seo_title' => ['nullable', 'string', 'max:180'],
'seo_description' => ['nullable', 'string', 'max:255'],
'meta_keywords' => ['nullable', 'string'],
'og_title' => ['nullable', 'string', 'max:180'],
'og_description' => ['nullable', 'string', 'max:255'],
'og_image' => ['nullable', 'string', 'max:2048'],
];
}
}

View File

@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Academy;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Arr;
use Illuminate\Validation\Rule;
class UpsertAcademyLessonRequest extends FormRequest
{
public function authorize(): bool
{
return (bool) $this->user()?->hasStaffAccess();
}
protected function prepareForValidation(): void
{
$blocks = collect($this->input('blocks', []))
->filter(static fn ($block): bool => is_array($block))
->map(function (array $block): array {
$payload = Arr::wrap($block['payload'] ?? []);
$criteria = collect($payload['criteria'] ?? [])
->map(static fn ($criterion) => trim((string) $criterion))
->filter(static fn (string $criterion): bool => $criterion !== '')
->values()
->all();
$results = collect($block['comparison_results'] ?? [])
->filter(static fn ($result): bool => is_array($result))
->map(function (array $result): array {
return [
'id' => filled($result['id'] ?? null) ? (int) $result['id'] : null,
'provider' => $result['provider'] ?? null,
'model_name' => $result['model_name'] ?? null,
'image_path' => $result['image_path'] ?? null,
'thumb_path' => $result['thumb_path'] ?? null,
'settings' => $result['settings'] ?? null,
'strengths' => $result['strengths'] ?? null,
'weaknesses' => $result['weaknesses'] ?? null,
'best_for' => $result['best_for'] ?? null,
'score' => filled($result['score'] ?? null) ? (int) $result['score'] : null,
'sort_order' => filled($result['sort_order'] ?? null) ? (int) $result['sort_order'] : 0,
'active' => filter_var($result['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true,
];
})
->values()
->all();
return [
'id' => filled($block['id'] ?? null) ? (int) $block['id'] : null,
'type' => (string) ($block['type'] ?? 'ai_comparison'),
'title' => $block['title'] ?? null,
'payload' => [
'title' => $payload['title'] ?? null,
'intro' => $payload['intro'] ?? null,
'prompt' => $payload['prompt'] ?? null,
'negative_prompt' => $payload['negative_prompt'] ?? null,
'aspect_ratio' => $payload['aspect_ratio'] ?? null,
'criteria' => $criteria,
],
'sort_order' => filled($block['sort_order'] ?? null) ? (int) $block['sort_order'] : 0,
'active' => filter_var($block['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true,
'comparison_results' => $results,
];
})
->values()
->all();
$this->merge([
'lesson_number' => $this->filled('lesson_number') ? (int) $this->input('lesson_number') : null,
'course_order' => $this->filled('course_order') ? (int) $this->input('course_order') : null,
'content_source' => in_array((string) $this->input('content_source'), ['html', 'markdown'], true)
? (string) $this->input('content_source')
: ($this->filled('content_markdown') ? 'markdown' : 'html'),
'course_ids' => collect($this->input('course_ids', []))
->filter(static fn ($courseId): bool => filled($courseId))
->map(static fn ($courseId): int => (int) $courseId)
->unique()
->values()
->all(),
'tags' => array_values(array_filter((array) $this->input('tags', []))),
'reading_minutes' => $this->filled('reading_minutes') ? (int) $this->input('reading_minutes') : 5,
'featured' => $this->boolean('featured'),
'active' => $this->boolean('active', true),
'blocks' => $blocks,
]);
}
public function rules(): array
{
$lessonId = $this->route('academyLesson')?->id;
return [
'category_id' => ['nullable', 'integer', 'exists:academy_categories,id'],
'title' => ['required', 'string', 'max:180'],
'slug' => ['required', 'string', 'max:180', Rule::unique('academy_lessons', 'slug')->ignore($lessonId)],
'lesson_number' => ['nullable', 'integer', 'min:1'],
'course_order' => ['nullable', 'integer', 'min:1'],
'course_ids' => ['nullable', 'array'],
'course_ids.*' => ['integer', 'exists:academy_courses,id'],
'series_name' => ['nullable', 'string', 'max:120'],
'excerpt' => ['nullable', 'string'],
'content' => ['nullable', 'string'],
'content_markdown' => ['nullable', 'string'],
'content_source' => ['required', 'string', Rule::in(['html', 'markdown'])],
'difficulty' => ['required', 'string', Rule::in((array) config('academy.difficulty_levels', []))],
'access_level' => ['required', 'string', Rule::in(['free', 'creator', 'pro'])],
'lesson_type' => ['required', 'string', 'max:80'],
'cover_image' => ['nullable', 'string', 'max:2048'],
'article_cover_image' => ['nullable', 'string', 'max:2048'],
'tags' => ['nullable', 'array'],
'tags.*' => ['string', 'max:60'],
'video_url' => ['nullable', 'string', 'max:2048'],
'reading_minutes' => ['required', 'integer', 'min:1', 'max:999'],
'featured' => ['required', 'boolean'],
'active' => ['required', 'boolean'],
'published_at' => ['nullable', 'date'],
'seo_title' => ['nullable', 'string', 'max:180'],
'seo_description' => ['nullable', 'string', 'max:255'],
'blocks' => ['nullable', 'array'],
'blocks.*.id' => ['nullable', 'integer', 'exists:academy_lesson_blocks,id'],
'blocks.*.type' => ['required', 'string', Rule::in(['ai_comparison'])],
'blocks.*.title' => ['nullable', 'string', 'max:255'],
'blocks.*.payload' => ['nullable', 'array'],
'blocks.*.payload.title' => ['nullable', 'string', 'max:255'],
'blocks.*.payload.intro' => ['nullable', 'string'],
'blocks.*.payload.prompt' => ['nullable', 'string'],
'blocks.*.payload.negative_prompt' => ['nullable', 'string'],
'blocks.*.payload.aspect_ratio' => ['nullable', 'string', 'max:20'],
'blocks.*.payload.criteria' => ['nullable', 'array'],
'blocks.*.payload.criteria.*' => ['nullable', 'string', 'max:100'],
'blocks.*.sort_order' => ['required', 'integer', 'min:0'],
'blocks.*.active' => ['required', 'boolean'],
'blocks.*.comparison_results' => ['nullable', 'array'],
'blocks.*.comparison_results.*.id' => ['nullable', 'integer', 'exists:academy_ai_comparison_results,id'],
'blocks.*.comparison_results.*.provider' => ['nullable', 'string', 'max:100'],
'blocks.*.comparison_results.*.model_name' => ['nullable', 'string', 'max:150'],
'blocks.*.comparison_results.*.image_path' => ['required', 'string', 'max:500'],
'blocks.*.comparison_results.*.thumb_path' => ['nullable', 'string', 'max:500'],
'blocks.*.comparison_results.*.settings' => ['nullable', 'string'],
'blocks.*.comparison_results.*.strengths' => ['nullable', 'string'],
'blocks.*.comparison_results.*.weaknesses' => ['nullable', 'string'],
'blocks.*.comparison_results.*.best_for' => ['nullable', 'string'],
'blocks.*.comparison_results.*.score' => ['nullable', 'integer', 'min:1', 'max:10'],
'blocks.*.comparison_results.*.sort_order' => ['required', 'integer', 'min:0'],
'blocks.*.comparison_results.*.active' => ['required', 'boolean'],
];
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Academy;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpsertAcademyPromptPackRequest extends FormRequest
{
public function authorize(): bool
{
return (bool) $this->user()?->hasStaffAccess();
}
protected function prepareForValidation(): void
{
$this->merge([
'one_time_price_cents' => $this->filled('one_time_price_cents') ? (int) $this->input('one_time_price_cents') : null,
'featured' => $this->boolean('featured'),
'active' => $this->boolean('active', true),
'tags' => array_values(array_filter((array) $this->input('tags', []))),
'prompt_ids' => array_values(array_filter(array_map('intval', (array) $this->input('prompt_ids', [])))),
]);
}
public function rules(): array
{
$packId = $this->route('academyPromptPack')?->id;
return [
'title' => ['required', 'string', 'max:180'],
'slug' => ['required', 'string', 'max:180', Rule::unique('academy_prompt_packs', 'slug')->ignore($packId)],
'excerpt' => ['nullable', 'string'],
'description' => ['nullable', 'string'],
'access_level' => ['required', 'string', Rule::in(['free', 'creator', 'pro'])],
'one_time_price_cents' => ['nullable', 'integer', 'min:0'],
'currency' => ['required', 'string', 'size:3'],
'cover_image' => ['nullable', 'string', 'max:2048'],
'tags' => ['nullable', 'array'],
'tags.*' => ['string', 'max:60'],
'prompt_ids' => ['nullable', 'array'],
'prompt_ids.*' => ['integer', 'exists:academy_prompt_templates,id'],
'featured' => ['required', 'boolean'],
'active' => ['required', 'boolean'],
'published_at' => ['nullable', 'date'],
];
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Academy;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpsertAcademyPromptTemplateRequest extends FormRequest
{
public function authorize(): bool
{
return (bool) $this->user()?->hasStaffAccess();
}
protected function prepareForValidation(): void
{
$this->merge([
'featured' => $this->boolean('featured'),
'prompt_of_week' => $this->boolean('prompt_of_week'),
'active' => $this->boolean('active', true),
'new_category_name' => trim((string) $this->input('new_category_name', '')),
'tags' => array_values(array_filter((array) $this->input('tags', []))),
'tool_notes' => collect($this->input('tool_notes', []))
->filter(static fn ($note): bool => is_array($note) || is_string($note))
->map(function ($note): array|string {
if (is_string($note)) {
return $note;
}
return [
'provider' => $note['provider'] ?? null,
'model_name' => $note['model_name'] ?? null,
'notes' => $note['notes'] ?? null,
'strengths' => $note['strengths'] ?? null,
'weaknesses' => $note['weaknesses'] ?? null,
'best_for' => $note['best_for'] ?? null,
'image_path' => $note['image_path'] ?? null,
'thumb_path' => $note['thumb_path'] ?? null,
'settings' => $note['settings'] ?? null,
'score' => filled($note['score'] ?? null) ? (int) $note['score'] : null,
'active' => filter_var($note['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true,
];
})
->values()
->all(),
]);
}
public function rules(): array
{
$promptId = $this->route('academyPromptTemplate')?->id;
return [
'category_id' => ['nullable', 'integer', 'exists:academy_categories,id'],
'new_category_name' => ['nullable', 'string', 'max:120'],
'title' => ['required', 'string', 'max:180'],
'slug' => ['required', 'string', 'max:180', Rule::unique('academy_prompt_templates', 'slug')->ignore($promptId)],
'excerpt' => ['nullable', 'string'],
'prompt' => ['required', 'string'],
'negative_prompt' => ['nullable', 'string'],
'usage_notes' => ['nullable', 'string'],
'workflow_notes' => ['nullable', 'string'],
'difficulty' => ['required', 'string', Rule::in((array) config('academy.difficulty_levels', []))],
'access_level' => ['required', 'string', Rule::in(['free', 'creator', 'pro'])],
'aspect_ratio' => ['nullable', 'string', 'max:20'],
'tags' => ['nullable', 'array'],
'tags.*' => ['string', 'max:60'],
'tool_notes' => ['nullable', 'array'],
'tool_notes.*.provider' => ['nullable', 'string', 'max:100'],
'tool_notes.*.model_name' => ['nullable', 'string', 'max:150'],
'tool_notes.*.notes' => ['nullable', 'string'],
'tool_notes.*.strengths' => ['nullable', 'string'],
'tool_notes.*.weaknesses' => ['nullable', 'string'],
'tool_notes.*.best_for' => ['nullable', 'string'],
'tool_notes.*.image_path' => ['nullable', 'string', 'max:500'],
'tool_notes.*.thumb_path' => ['nullable', 'string', 'max:500'],
'tool_notes.*.settings' => ['nullable', 'string'],
'tool_notes.*.score' => ['nullable', 'integer', 'min:1', 'max:10'],
'tool_notes.*.active' => ['nullable', 'boolean'],
'preview_image' => ['nullable', 'string', 'max:2048'],
'preview_image_file' => ['nullable', 'file', 'image', 'mimes:jpg,jpeg,png,webp', 'max:5120'],
'featured' => ['required', 'boolean'],
'prompt_of_week' => ['required', 'boolean'],
'active' => ['required', 'boolean'],
'published_at' => ['nullable', 'date'],
'seo_title' => ['nullable', 'string', 'max:180'],
'seo_description' => ['nullable', 'string', 'max:255'],
];
}
}

View File

@@ -3,7 +3,9 @@
namespace App\Http\Requests\Auth;
use App\Models\User;
use App\Services\Auth\AuthAuditLogger;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
@@ -68,6 +70,16 @@ class LoginRequest extends FormRequest
if (! $user || ! Hash::check($password, (string) $user->password)) {
RateLimiter::hit($this->throttleKey());
app(AuthAuditLogger::class)->log(
eventType: 'login',
request: $this,
status: 'failed',
reason: 'invalid_credentials',
identifier: $identifier,
user: $user,
metadata: ['via' => $authenticatedVia]
);
throw ValidationException::withMessages([
'email' => trans('auth.failed'),
]);
@@ -90,6 +102,20 @@ class LoginRequest extends FormRequest
return $this->authenticatedVia === 'username';
}
protected function failedValidation(Validator $validator): void
{
app(AuthAuditLogger::class)->log(
eventType: 'login',
request: $this,
status: 'failed',
reason: 'validation_failed',
identifier: (string) $this->input('email'),
metadata: ['fields' => array_keys($validator->errors()->toArray())]
);
parent::failedValidation($validator);
}
/**
* Ensure the login request is not rate limited.
*
@@ -105,6 +131,15 @@ class LoginRequest extends FormRequest
$seconds = RateLimiter::availableIn($this->throttleKey());
app(AuthAuditLogger::class)->log(
eventType: 'login',
request: $this,
status: 'failed',
reason: 'rate_limited',
identifier: (string) $this->input('email'),
metadata: ['seconds' => $seconds]
);
throw ValidationException::withMessages([
'email' => trans('auth.throttle', [
'seconds' => $seconds,

View File

@@ -3,6 +3,7 @@ namespace App\Http\Resources;
use App\Models\WorldRelation;
use App\Models\WorldSubmission;
use App\Models\World;
use App\Services\ArtworkEvolutionService;
use App\Services\ContentSanitizer;
use App\Services\Maturity\ArtworkMaturityService;
@@ -336,58 +337,70 @@ class ArtworkResource extends JsonResource
private function resolveWorldParticipation(): array
{
$items = collect();
$participationWorlds = collect();
if (Schema::hasTable('world_relations') && Schema::hasTable('worlds')) {
$items = $items->concat(
WorldRelation::query()
->with('world')
->where('related_type', WorldRelation::TYPE_ARTWORK)
->where('related_id', (int) $this->id)
->get()
->filter(fn (WorldRelation $relation): bool => $relation->world !== null && $relation->world->isPubliclyVisible())
->map(function (WorldRelation $relation): array {
$world = $relation->world;
$relations = WorldRelation::query()
->with('world')
->where('related_type', WorldRelation::TYPE_ARTWORK)
->where('related_id', (int) $this->id)
->get()
->filter(fn (WorldRelation $relation): bool => $relation->world !== null && $relation->world->isPubliclyVisible())
->values();
return [
'world_id' => (int) $relation->world_id,
'world_title' => (string) $world->title,
'world_slug' => (string) $world->slug,
'world_url' => $world->publicUrl(),
'badge_label' => 'Part of ' . $world->title,
'status' => 'curated',
'status_label' => 'Curated',
'tone' => 'curated',
'sort_priority' => 1,
];
})
$participationWorlds = $participationWorlds->concat($relations->pluck('world')->filter());
$items = $items->concat(
$relations->map(function (WorldRelation $relation): array {
$world = $relation->world;
return [
'world_id' => (int) $relation->world_id,
'world_title' => (string) $world->title,
'world_slug' => (string) $world->slug,
'world_url' => $world->publicUrl(),
'badge_label' => 'Part of ' . $world->title,
'status' => 'curated',
'status_label' => 'Curated',
'tone' => 'curated',
'sort_priority' => 1,
];
})
);
}
if (Schema::hasTable('world_submissions')) {
$items = $items->concat(
$this->worldSubmissions
->filter(function (WorldSubmission $submission): bool {
return (string) $submission->status === WorldSubmission::STATUS_LIVE
&& $submission->world !== null
&& $submission->world->isPubliclyVisible();
})
->map(function (WorldSubmission $submission): array {
$world = $submission->world;
$isFeatured = (bool) $submission->is_featured;
$liveSubmissions = $this->worldSubmissions
->filter(function (WorldSubmission $submission): bool {
return (string) $submission->status === WorldSubmission::STATUS_LIVE
&& $submission->world !== null
&& $submission->world->isPubliclyVisible();
})
->values();
return [
'world_id' => (int) $submission->world_id,
'world_title' => (string) $world->title,
'world_slug' => (string) $world->slug,
'world_url' => $world->publicUrl(),
'badge_label' => ($isFeatured ? 'Featured in ' : 'Part of ') . $world->title,
'status' => (string) $submission->status,
'status_label' => $isFeatured ? 'Featured' : 'Community submission',
'tone' => $isFeatured ? 'featured' : 'community',
'sort_priority' => $isFeatured ? 0 : 2,
];
})
$participationWorlds = $participationWorlds->concat($liveSubmissions->pluck('world')->filter());
World::primeCanonicalEditionIds($participationWorlds->pluck('recurrence_key')->all());
$items = $items->concat(
$liveSubmissions->map(function (WorldSubmission $submission): array {
$world = $submission->world;
$isFeatured = (bool) $submission->is_featured;
return [
'world_id' => (int) $submission->world_id,
'world_title' => (string) $world->title,
'world_slug' => (string) $world->slug,
'world_url' => $world->publicUrl(),
'badge_label' => ($isFeatured ? 'Featured in ' : 'Part of ') . $world->title,
'status' => (string) $submission->status,
'status_label' => $isFeatured ? 'Featured' : 'Community submission',
'tone' => $isFeatured ? 'featured' : 'community',
'sort_priority' => $isFeatured ? 0 : 2,
];
})
);
} elseif ($participationWorlds->isNotEmpty()) {
World::primeCanonicalEditionIds($participationWorlds->pluck('recurrence_key')->all());
}
if (Schema::hasTable('world_reward_grants')) {

View File

@@ -48,7 +48,7 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue
public function handle(
ArtworkEmbeddingClient $client,
ArtworkVisionImageUrl $imageUrlBuilder,
VectorService|ArtworkVectorIndexService $vectors,
ArtworkVectorIndexService $vectors,
): void
{
if (! (bool) config('recommendations.embedding.enabled', true)) {
@@ -128,7 +128,7 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue
}
private function upsertVectorIndex(
VectorService|ArtworkVectorIndexService $vectors,
ArtworkVectorIndexService $vectors,
Artwork $artwork
): void
{

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Artwork;
use App\Services\Images\FeaturedArtworkThumbnailGenerator;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
final class GenerateFeaturedArtworkThumbnailsJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 3;
public int $timeout = 120;
public function __construct(
private readonly int $artworkId,
private readonly bool $force = false,
) {
$queue = (string) config('uploads.featured_thumbnails.queue', 'default');
if ($queue !== '') {
$this->onQueue($queue);
}
}
public function handle(
FeaturedArtworkThumbnailGenerator $generator,
): void {
$artwork = Artwork::withTrashed()->find($this->artworkId);
if (! $artwork instanceof Artwork) {
return;
}
$result = $generator->generate($artwork, $this->force);
if (($result['failed'] ?? []) !== []) {
Log::warning('Featured artwork thumbnail generation had partial failures', [
'artwork_id' => $artwork->id,
'failed_variants' => array_keys((array) $result['failed']),
]);
}
}
}

View File

@@ -91,9 +91,9 @@ final class IngestUserDiscoveryEventJob implements ShouldQueue
];
if (Schema::hasColumn('user_discovery_events', 'meta')) {
$insertPayload['meta'] = $this->meta;
$insertPayload['meta'] = $this->encodeMetaPayload();
} elseif (Schema::hasColumn('user_discovery_events', 'metadata')) {
$insertPayload['metadata'] = json_encode($this->meta, JSON_UNESCAPED_SLASHES);
$insertPayload['metadata'] = $this->encodeMetaPayload();
}
DB::table('user_discovery_events')->insertOrIgnore($insertPayload);
@@ -129,4 +129,12 @@ final class IngestUserDiscoveryEventJob implements ShouldQueue
throw $e;
}
}
private function encodeMetaPayload(): string
{
return (string) json_encode(
$this->meta,
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR
);
}
}

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