Current state with latest updates
This commit is contained in:
@@ -4,6 +4,15 @@ APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
|
||||
SECURITY_REPORT_ENABLED=true
|
||||
SECURITY_REPORT_NOTIFY_EMAIL=
|
||||
SECURITY_REPORT_SCAN_NPM=true
|
||||
SECURITY_REPORT_SCAN_COMPOSER=true
|
||||
SECURITY_REPORT_STORE_RAW=true
|
||||
SECURITY_REPORT_MAX_RAW_KB=512
|
||||
SECURITY_REPORT_FAIL_ON_HIGH=false
|
||||
SECURITY_REPORT_FAIL_ON_CRITICAL=false
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
@@ -49,10 +49,10 @@ final class BuildSitemapsCommand extends Command
|
||||
$t = microtime(true);
|
||||
$this->line(' Building sitemap index…');
|
||||
$index = $build->buildIndex(force: true, persist: false, families: $families);
|
||||
$disk->put('sitemap.xml', $index['content']);
|
||||
$disk->put('sitemaps/sitemap.xml', $index['content']);
|
||||
$written++;
|
||||
$this->line(sprintf(
|
||||
' <info>✔</info> sitemap.xml %d entries <comment>%.3fs</comment>',
|
||||
' <info>✔</info> sitemaps/sitemap.xml %d entries <comment>%.3fs</comment>',
|
||||
$index['url_count'],
|
||||
microtime(true) - $t,
|
||||
));
|
||||
@@ -112,6 +112,36 @@ final class BuildSitemapsCommand extends Command
|
||||
));
|
||||
}
|
||||
|
||||
foreach ($build->enabledGroupIndexes() as $groupName => $groupFamilies) {
|
||||
foreach ($groupFamilies as $family) {
|
||||
if (! in_array($family, $families, true)) {
|
||||
continue 2;
|
||||
}
|
||||
}
|
||||
|
||||
$t = microtime(true);
|
||||
$this->line(sprintf(' Building grouped sitemap %s…', $groupName));
|
||||
|
||||
$built = $build->buildNamed($groupName, force: true, persist: false);
|
||||
|
||||
if ($built === null) {
|
||||
$this->line(sprintf(' <comment>–</comment> %s.xml <fg=red>SKIPPED</> (group builder returned null)', $groupName));
|
||||
$failed++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$disk->put('sitemaps/' . $groupName . '.xml', $built['content']);
|
||||
$written++;
|
||||
|
||||
$this->line(sprintf(
|
||||
' <info>✔</info> %s %d entries <comment>%.3fs</comment>',
|
||||
$groupName . '.xml',
|
||||
$built['url_count'] ?? 0,
|
||||
microtime(true) - $t,
|
||||
));
|
||||
}
|
||||
|
||||
// ── Summary ───────────────────────────────────────────────────────
|
||||
$this->newLine();
|
||||
$this->info(sprintf(
|
||||
@@ -295,4 +325,4 @@ final class BuildSitemapsCommand extends Command
|
||||
|
||||
return array_values(array_filter($enabled, fn (string $family): bool => in_array($family, $only, true)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,35 @@ final class GenerateSitemapsCommand extends Command
|
||||
));
|
||||
}
|
||||
|
||||
foreach ($build->enabledGroupIndexes() as $groupName => $groupFamilies) {
|
||||
foreach ($groupFamilies as $family) {
|
||||
if (! in_array($family, $families, true)) {
|
||||
continue 2;
|
||||
}
|
||||
}
|
||||
|
||||
$t = microtime(true);
|
||||
$built = $build->buildNamed($groupName, force: true, persist: false);
|
||||
|
||||
if ($built === null) {
|
||||
$this->line(sprintf(' <comment>–</comment> %s.xml <fg=red>SKIPPED</> (group builder returned null)', $groupName));
|
||||
$failed++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = 'sitemaps/' . $groupName . '.xml';
|
||||
$disk->put($path, $built['content']);
|
||||
$written++;
|
||||
|
||||
$this->line(sprintf(
|
||||
' <info>✔</info> %s %d entries <comment>%.3fs</comment>',
|
||||
$groupName . '.xml',
|
||||
$built['url_count'] ?? 0,
|
||||
microtime(true) - $t,
|
||||
));
|
||||
}
|
||||
|
||||
// ── Summary ───────────────────────────────────────────────────────
|
||||
$this->newLine();
|
||||
$this->info(sprintf(
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Notifications\SecurityReportDangerNotification;
|
||||
use App\Services\SecurityReport\SecurityReportScanner;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
final class SecurityReportScanCommand extends Command
|
||||
{
|
||||
protected $signature = 'security:scan
|
||||
{--notify : Send configured email notification when high or critical findings exist}
|
||||
{--triggered-by=artisan : Mark the scan source}
|
||||
{--user-id= : Associate the scan with a specific user id}';
|
||||
|
||||
protected $description = 'Run Composer and npm security audits and store a private admin report.';
|
||||
|
||||
public function handle(SecurityReportScanner $scanner): int
|
||||
{
|
||||
if (! (bool) config('security-report.enabled', true)) {
|
||||
$this->warn('Security report scanning is disabled.');
|
||||
|
||||
return self::INVALID;
|
||||
}
|
||||
|
||||
$this->info('Running security report scan...');
|
||||
|
||||
$report = $scanner->scan(
|
||||
(string) $this->option('triggered-by'),
|
||||
$this->option('user-id') !== null ? (int) $this->option('user-id') : null,
|
||||
);
|
||||
|
||||
if ($report->status === 'failed') {
|
||||
$this->error('Security scan failed: ' . (string) ($report->error_message ?? 'Unknown error'));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['Status', 'Critical', 'High', 'Medium', 'Low', 'Unknown', 'Composer outdated', 'npm outdated'],
|
||||
[[
|
||||
$report->status,
|
||||
$report->total_critical,
|
||||
$report->total_high,
|
||||
$report->total_medium,
|
||||
$report->total_low,
|
||||
$report->total_unknown,
|
||||
$report->composer_outdated_count,
|
||||
$report->npm_outdated_count,
|
||||
]],
|
||||
);
|
||||
|
||||
if ((bool) $this->option('notify') && $report->hasDangerFindings()) {
|
||||
$this->sendDangerNotification($report);
|
||||
$this->info('Danger notification sent.');
|
||||
}
|
||||
|
||||
if ($report->hasCriticalFindings() && (bool) config('security-report.fail_on.critical', false)) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($report->hasHighFindings() && (bool) config('security-report.fail_on.high', false)) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function sendDangerNotification(\App\Models\SecurityReport $report): void
|
||||
{
|
||||
$email = trim((string) config('security-report.notify_email', ''));
|
||||
|
||||
if ($email === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::route('mail', $email)
|
||||
->notify(new SecurityReportDangerNotification($report));
|
||||
}
|
||||
}
|
||||
@@ -363,8 +363,17 @@ final class AcademyBillingController extends Controller
|
||||
/** @var User|null $user */
|
||||
$user = $request->user();
|
||||
$currentTier = $this->access->currentTier($user);
|
||||
$seo = \app(SeoFactory::class)
|
||||
->simplePage(
|
||||
'Academy Subscription Confirmed — Skinbase',
|
||||
'Payment confirmation for your Skinbase Academy subscription.',
|
||||
\route('academy.billing.success'),
|
||||
false,
|
||||
)
|
||||
->toArray();
|
||||
|
||||
return \Inertia\Inertia::render('Academy/Billing/Success', [
|
||||
'seo' => $seo,
|
||||
'message' => 'Payment is being confirmed. Your access will update automatically.',
|
||||
'currentTier' => $currentTier,
|
||||
'isSubscribed' => $user instanceof User ? $this->access->hasActiveAcademySubscription($user) : false,
|
||||
@@ -381,8 +390,17 @@ final class AcademyBillingController extends Controller
|
||||
public function cancel(): \Inertia\Response
|
||||
{
|
||||
\abort_unless((bool) \config('academy.enabled', true), 404);
|
||||
$seo = \app(SeoFactory::class)
|
||||
->simplePage(
|
||||
'Academy Billing Canceled — Skinbase',
|
||||
'Checkout was canceled before starting a Skinbase Academy subscription.',
|
||||
\route('academy.billing.cancel'),
|
||||
false,
|
||||
)
|
||||
->toArray();
|
||||
|
||||
return \Inertia\Inertia::render('Academy/Billing/Cancel', [
|
||||
'seo' => $seo,
|
||||
'message' => 'Checkout was canceled. No payment was made.',
|
||||
'links' => [
|
||||
'pricing' => \route('academy.pricing'),
|
||||
@@ -495,10 +513,19 @@ final class AcademyBillingController extends Controller
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
$subscription = $this->academySubscription($user);
|
||||
$seo = \app(SeoFactory::class)
|
||||
->simplePage(
|
||||
'Academy Subscription Account — Skinbase',
|
||||
'Manage your Skinbase Academy subscription and billing access.',
|
||||
\route('academy.billing.account'),
|
||||
false,
|
||||
)
|
||||
->toArray();
|
||||
|
||||
$activePlan = $this->activePlan($user);
|
||||
|
||||
return \Inertia\Inertia::render('Academy/Billing/Account', [
|
||||
'seo' => $seo,
|
||||
'currentTier' => $this->access->currentTier($user),
|
||||
'isSubscribed' => $this->access->hasActiveAcademySubscription($user),
|
||||
'activePlan' => $activePlan ? [
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Services\Academy\AcademyAccessService;
|
||||
use App\Services\Academy\AcademyInteractionService;
|
||||
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use App\Support\Seo\SeoDataBuilder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
@@ -44,6 +45,13 @@ final class AcademyChallengeController extends Controller
|
||||
route('academy.challenges.index'),
|
||||
)
|
||||
->toArray();
|
||||
$seo = SeoDataBuilder::fromArray($seo)
|
||||
->breadcrumbs([
|
||||
['name' => 'Academy', 'url' => route('academy.index')],
|
||||
['name' => 'Challenges', 'url' => route('academy.challenges.index')],
|
||||
])
|
||||
->build()
|
||||
->toArray();
|
||||
|
||||
return Inertia::render('Academy/List', [
|
||||
'pageType' => 'challenges',
|
||||
@@ -94,12 +102,24 @@ final class AcademyChallengeController extends Controller
|
||||
'submitted_at' => $submission->submitted_at?->toISOString(),
|
||||
])->values()->all();
|
||||
|
||||
$seo = app(SeoFactory::class)->collectionPage(
|
||||
$challenge->title . ' — Skinbase Academy',
|
||||
Str::limit((string) ($challenge->excerpt ?? $challenge->description ?? ''), 160, '...'),
|
||||
route('academy.challenges.show', ['slug' => $challenge->slug]),
|
||||
$challenge->cover_image,
|
||||
)->toArray();
|
||||
$canonical = route('academy.challenges.show', ['slug' => $challenge->slug]);
|
||||
$description = Str::limit((string) ($challenge->excerpt ?? $challenge->description ?? ''), 160, '...');
|
||||
$seo = SeoDataBuilder::fromArray(
|
||||
app(SeoFactory::class)->collectionPage(
|
||||
$challenge->title . ' — Skinbase Academy',
|
||||
$description,
|
||||
$canonical,
|
||||
$challenge->cover_image,
|
||||
)->toArray()
|
||||
)
|
||||
->breadcrumbs([
|
||||
['name' => 'Academy', 'url' => route('academy.index')],
|
||||
['name' => 'Challenges', 'url' => route('academy.challenges.index')],
|
||||
['name' => (string) $challenge->title, 'url' => $canonical],
|
||||
])
|
||||
->addJsonLd($this->challengeStructuredData($payload, $canonical, $description))
|
||||
->build()
|
||||
->toArray();
|
||||
|
||||
$interaction = $this->interactions->getInteractionState($request->user(), AcademyAnalyticsContentType::CHALLENGE, (int) $challenge->id);
|
||||
|
||||
@@ -128,4 +148,46 @@ final class AcademyChallengeController extends Controller
|
||||
],
|
||||
])->rootView('academy');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function challengeStructuredData(array $payload, string $canonical, string $description): array
|
||||
{
|
||||
$image = trim((string) ($payload['cover_image'] ?? ''));
|
||||
$imageUrl = $image !== '' && preg_match('/^https?:\/\//i', $image) === 1 ? $image : ($image !== '' ? url($image) : null);
|
||||
$requiredTags = array_values((array) ($payload['required_tags'] ?? []));
|
||||
$status = strtolower(trim((string) ($payload['status'] ?? '')));
|
||||
$eventStatus = match ($status) {
|
||||
'scheduled' => 'https://schema.org/EventScheduled',
|
||||
'active', 'voting' => 'https://schema.org/EventInProgress',
|
||||
'completed', 'archived' => 'https://schema.org/EventCompleted',
|
||||
default => null,
|
||||
};
|
||||
|
||||
return array_filter([
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'Event',
|
||||
'name' => (string) ($payload['title'] ?? 'Skinbase Academy challenge'),
|
||||
'description' => $description,
|
||||
'url' => $canonical,
|
||||
'image' => $imageUrl,
|
||||
'startDate' => $payload['starts_at'] ?? null,
|
||||
'endDate' => $payload['ends_at'] ?? null,
|
||||
'eventStatus' => $eventStatus,
|
||||
'eventAttendanceMode' => 'https://schema.org/OnlineEventAttendanceMode',
|
||||
'location' => [
|
||||
'@type' => 'VirtualLocation',
|
||||
'url' => $canonical,
|
||||
],
|
||||
'organizer' => [
|
||||
'@type' => 'Organization',
|
||||
'name' => config('seo.site_name', 'Skinbase'),
|
||||
'url' => url('/'),
|
||||
],
|
||||
'keywords' => $requiredTags !== [] ? $requiredTags : null,
|
||||
'isAccessibleForFree' => (string) ($payload['access_level'] ?? 'free') === 'free',
|
||||
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ final class AcademyChallengeSubmissionController extends Controller
|
||||
'Submit to ' . $challenge->title . ' — Skinbase Academy',
|
||||
'Attach one of your artworks to this Academy challenge submission.',
|
||||
route('academy.challenges.submit', ['slug' => $challenge->slug]),
|
||||
null,
|
||||
false,
|
||||
)->toArray();
|
||||
|
||||
return Inertia::render('Academy/ChallengeSubmit', [
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Services\Academy\AcademyCourseNavigationService;
|
||||
use App\Services\Academy\AcademyCourseProgressService;
|
||||
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use App\Support\Seo\SeoDataBuilder;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
@@ -37,6 +38,7 @@ final class AcademyCourseController extends Controller
|
||||
'difficulty' => ['nullable', 'string', 'max:40'],
|
||||
'access' => ['nullable', 'string', 'max:40'],
|
||||
]);
|
||||
$hasActiveFilters = filled($filters['difficulty'] ?? null) || filled($filters['access'] ?? null);
|
||||
|
||||
$query = AcademyCourse::query()->published()->ordered();
|
||||
|
||||
@@ -77,6 +79,13 @@ final class AcademyCourseController extends Controller
|
||||
)
|
||||
->toArray();
|
||||
|
||||
if ($hasActiveFilters) {
|
||||
$seo = SeoDataBuilder::fromArray($seo)
|
||||
->indexable(false)
|
||||
->build()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
return Inertia::render('Academy/CoursesIndex', [
|
||||
'seo' => $seo,
|
||||
'title' => 'Academy courses',
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Services\Academy\AcademyCourseNavigationService;
|
||||
use App\Services\Academy\AcademyCourseProgressService;
|
||||
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use App\Support\Seo\SeoDataBuilder;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
@@ -52,7 +53,7 @@ final class AcademyCourseLessonController extends Controller
|
||||
->all();
|
||||
|
||||
$payload = $this->access->courseLessonPayload($courseLesson, $request->user(), true);
|
||||
$canonical = route('academy.courses.lessons.show', ['course' => $course->slug, 'lesson' => $lesson->slug]);
|
||||
$canonical = route('academy.lessons.show', ['slug' => $lesson->slug]);
|
||||
$description = Str::limit(trim((string) ($lesson->seo_description ?? $lesson->excerpt ?? 'Skinbase Academy course lesson.')), 160, '...');
|
||||
$seo = app(SeoFactory::class)->academyLessonPage(
|
||||
(string) ($lesson->seo_title ?? ($lesson->title . ' — ' . $course->title)),
|
||||
@@ -70,6 +71,10 @@ final class AcademyCourseLessonController extends Controller
|
||||
$lesson->updated_at?->toAtomString(),
|
||||
(string) $course->title,
|
||||
)->toArray();
|
||||
$seo = SeoDataBuilder::fromArray($seo)
|
||||
->indexable(false)
|
||||
->build()
|
||||
->toArray();
|
||||
|
||||
$interaction = $this->interactions->getInteractionState($request->user(), AcademyAnalyticsContentType::LESSON, (int) $lesson->id);
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Services\Academy\AcademyCacheService;
|
||||
use App\Services\Academy\AcademyInteractionService;
|
||||
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use App\Support\Seo\SeoDataBuilder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -37,6 +38,9 @@ final class AcademyLessonController extends Controller
|
||||
'category' => ['nullable', 'string', 'max:140'],
|
||||
'difficulty' => ['nullable', 'string', 'max:40'],
|
||||
]);
|
||||
$hasActiveFilters = filled($filters['q'] ?? null)
|
||||
|| filled($filters['category'] ?? null)
|
||||
|| filled($filters['difficulty'] ?? null);
|
||||
|
||||
$query = AcademyLesson::query()
|
||||
->with('category')
|
||||
@@ -78,6 +82,13 @@ final class AcademyLessonController extends Controller
|
||||
)
|
||||
->toArray();
|
||||
|
||||
if ($hasActiveFilters) {
|
||||
$seo = SeoDataBuilder::fromArray($seo)
|
||||
->indexable(false)
|
||||
->build()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
return Inertia::render('Academy/List', [
|
||||
'pageType' => 'lessons',
|
||||
'title' => 'Academy lessons',
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Services\Academy\AcademyInteractionService;
|
||||
use App\Services\Academy\AcademyPopularityService;
|
||||
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use App\Support\Seo\SeoDataBuilder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -41,6 +42,10 @@ final class AcademyPromptController extends Controller
|
||||
'difficulty' => ['nullable', 'string', 'max:40'],
|
||||
'tag' => ['nullable', 'string', 'max:60'],
|
||||
]);
|
||||
$hasActiveFilters = filled($filters['q'] ?? null)
|
||||
|| filled($filters['category'] ?? null)
|
||||
|| filled($filters['difficulty'] ?? null)
|
||||
|| filled($filters['tag'] ?? null);
|
||||
|
||||
$query = AcademyPromptTemplate::query()
|
||||
->with('category')
|
||||
@@ -87,6 +92,13 @@ final class AcademyPromptController extends Controller
|
||||
)
|
||||
->toArray();
|
||||
|
||||
if ($hasActiveFilters) {
|
||||
$seo = SeoDataBuilder::fromArray($seo)
|
||||
->indexable(false)
|
||||
->build()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
return Inertia::render('Academy/List', [
|
||||
'pageType' => 'prompts',
|
||||
'promptView' => 'library',
|
||||
@@ -203,6 +215,13 @@ final class AcademyPromptController extends Controller
|
||||
)
|
||||
->toArray();
|
||||
|
||||
if ($selectedPeriod['value'] !== '30d') {
|
||||
$seo = SeoDataBuilder::fromArray($seo)
|
||||
->indexable(false)
|
||||
->build()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
return Inertia::render('Academy/List', [
|
||||
'pageType' => 'prompts',
|
||||
'promptView' => 'popular',
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Services\Academy\AcademyAccessService;
|
||||
use App\Services\Academy\AcademyInteractionService;
|
||||
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use App\Support\Seo\SeoDataBuilder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
@@ -44,6 +45,13 @@ final class AcademyPromptPackController extends Controller
|
||||
route('academy.packs.index'),
|
||||
)
|
||||
->toArray();
|
||||
$seo = SeoDataBuilder::fromArray($seo)
|
||||
->breadcrumbs([
|
||||
['name' => 'Academy', 'url' => route('academy.index')],
|
||||
['name' => 'Prompt Packs', 'url' => route('academy.packs.index')],
|
||||
])
|
||||
->build()
|
||||
->toArray();
|
||||
|
||||
return Inertia::render('Academy/List', [
|
||||
'pageType' => 'packs',
|
||||
@@ -79,12 +87,24 @@ final class AcademyPromptPackController extends Controller
|
||||
->firstOrFail();
|
||||
|
||||
$payload = $this->access->packPayload($pack, $request->user(), true);
|
||||
$seo = app(SeoFactory::class)->collectionPage(
|
||||
$pack->title . ' — Skinbase Academy',
|
||||
Str::limit((string) ($pack->excerpt ?? $pack->description ?? ''), 160, '...'),
|
||||
route('academy.packs.show', ['slug' => $pack->slug]),
|
||||
$pack->cover_image,
|
||||
)->toArray();
|
||||
$canonical = route('academy.packs.show', ['slug' => $pack->slug]);
|
||||
$description = Str::limit((string) ($pack->excerpt ?? $pack->description ?? ''), 160, '...');
|
||||
$seo = SeoDataBuilder::fromArray(
|
||||
app(SeoFactory::class)->collectionPage(
|
||||
$pack->title . ' — Skinbase Academy',
|
||||
$description,
|
||||
$canonical,
|
||||
$pack->cover_image,
|
||||
)->toArray()
|
||||
)
|
||||
->breadcrumbs([
|
||||
['name' => 'Academy', 'url' => route('academy.index')],
|
||||
['name' => 'Prompt Packs', 'url' => route('academy.packs.index')],
|
||||
['name' => (string) $pack->title, 'url' => $canonical],
|
||||
])
|
||||
->addJsonLd($this->packStructuredData($payload, $canonical, $description))
|
||||
->build()
|
||||
->toArray();
|
||||
|
||||
$interaction = $this->interactions->getInteractionState($request->user(), AcademyAnalyticsContentType::PROMPT_PACK, (int) $pack->id);
|
||||
|
||||
@@ -112,4 +132,58 @@ final class AcademyPromptPackController extends Controller
|
||||
],
|
||||
])->rootView('academy');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function packStructuredData(array $payload, string $canonical, string $description): array
|
||||
{
|
||||
$image = trim((string) ($payload['cover_image'] ?? ''));
|
||||
$imageUrl = $image !== '' && preg_match('/^https?:\/\//i', $image) === 1 ? $image : ($image !== '' ? url($image) : null);
|
||||
$keywords = array_values((array) ($payload['tags'] ?? []));
|
||||
$isFree = (string) ($payload['access_level'] ?? 'free') === 'free';
|
||||
$promptEntries = collect((array) ($payload['prompts'] ?? []))
|
||||
->map(function (array $prompt): ?array {
|
||||
$title = trim((string) ($prompt['title'] ?? ''));
|
||||
$slug = trim((string) ($prompt['slug'] ?? ''));
|
||||
|
||||
if ($title === '' || $slug === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'@type' => 'ListItem',
|
||||
'position' => null,
|
||||
'item' => [
|
||||
'@type' => 'CreativeWork',
|
||||
'name' => $title,
|
||||
'url' => route('academy.prompts.show', ['slug' => $slug]),
|
||||
],
|
||||
];
|
||||
})
|
||||
->filter()
|
||||
->values()
|
||||
->map(function (array $item, int $index): array {
|
||||
$item['position'] = $index + 1;
|
||||
|
||||
return $item;
|
||||
})
|
||||
->all();
|
||||
|
||||
return array_filter([
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => ['CreativeWork', 'LearningResource'],
|
||||
'name' => (string) ($payload['title'] ?? 'Skinbase Academy prompt pack'),
|
||||
'description' => $description,
|
||||
'url' => $canonical,
|
||||
'image' => $imageUrl,
|
||||
'keywords' => $keywords !== [] ? $keywords : null,
|
||||
'isAccessibleForFree' => $isFree,
|
||||
'hasPart' => $promptEntries !== [] ? [
|
||||
'@type' => 'ItemList',
|
||||
'itemListElement' => $promptEntries,
|
||||
] : null,
|
||||
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\RunSecurityReportScanJob;
|
||||
use App\Models\SecurityReport;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
final class SecurityReportController extends Controller
|
||||
{
|
||||
public function index(): Response
|
||||
{
|
||||
abort_unless((bool) config('security-report.enabled', true), 404);
|
||||
|
||||
$latest = SecurityReport::query()->latest('id')->with('user:id,name,username')->first();
|
||||
$reports = SecurityReport::query()
|
||||
->with('user:id,name,username')
|
||||
->latest('id')
|
||||
->paginate(20)
|
||||
->through(fn (SecurityReport $report): array => $this->mapListItem($report));
|
||||
|
||||
return Inertia::render('Admin/System/SecurityReportIndex', [
|
||||
'latest' => $latest ? $this->mapDetail($latest) : null,
|
||||
'reports' => $reports,
|
||||
'canRunScan' => true,
|
||||
])->rootView('moderation');
|
||||
}
|
||||
|
||||
public function show(SecurityReport $securityReport): Response
|
||||
{
|
||||
abort_unless((bool) config('security-report.enabled', true), 404);
|
||||
|
||||
$securityReport->load('user:id,name,username');
|
||||
|
||||
return Inertia::render('Admin/System/SecurityReportShow', [
|
||||
'report' => $this->mapDetail($securityReport),
|
||||
])->rootView('moderation');
|
||||
}
|
||||
|
||||
public function run(Request $request): RedirectResponse
|
||||
{
|
||||
abort_unless((bool) config('security-report.enabled', true), 404);
|
||||
|
||||
RunSecurityReportScanJob::dispatch($request->user()?->id);
|
||||
|
||||
return redirect()
|
||||
->route('admin.system.security-report.index')
|
||||
->with('success', 'Security scan has been queued.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function mapListItem(SecurityReport $report): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $report->id,
|
||||
'status' => (string) $report->status,
|
||||
'risk_label' => (string) $report->risk_label,
|
||||
'finished_at' => optional($report->finished_at)?->toIso8601String(),
|
||||
'total_critical' => (int) $report->total_critical,
|
||||
'total_high' => (int) $report->total_high,
|
||||
'total_medium' => (int) $report->total_medium,
|
||||
'total_low' => (int) $report->total_low,
|
||||
'composer_outdated_count' => (int) $report->composer_outdated_count,
|
||||
'npm_outdated_count' => (int) $report->npm_outdated_count,
|
||||
'show_url' => route('admin.system.security-report.show', ['securityReport' => $report]),
|
||||
'triggered_by' => (string) ($report->triggered_by ?? ''),
|
||||
'user' => $report->user ? [
|
||||
'id' => (int) $report->user->id,
|
||||
'name' => (string) $report->user->name,
|
||||
'username' => (string) ($report->user->username ?? ''),
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function mapDetail(SecurityReport $report): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $report->id,
|
||||
'status' => (string) $report->status,
|
||||
'risk_label' => (string) $report->risk_label,
|
||||
'started_at' => optional($report->started_at)?->toIso8601String(),
|
||||
'finished_at' => optional($report->finished_at)?->toIso8601String(),
|
||||
'composer_critical' => (int) $report->composer_critical,
|
||||
'composer_high' => (int) $report->composer_high,
|
||||
'composer_medium' => (int) $report->composer_medium,
|
||||
'composer_low' => (int) $report->composer_low,
|
||||
'composer_unknown' => (int) $report->composer_unknown,
|
||||
'npm_critical' => (int) $report->npm_critical,
|
||||
'npm_high' => (int) $report->npm_high,
|
||||
'npm_moderate' => (int) $report->npm_moderate,
|
||||
'npm_low' => (int) $report->npm_low,
|
||||
'npm_info' => (int) $report->npm_info,
|
||||
'npm_unknown' => (int) $report->npm_unknown,
|
||||
'total_critical' => (int) $report->total_critical,
|
||||
'total_high' => (int) $report->total_high,
|
||||
'total_medium' => (int) $report->total_medium,
|
||||
'total_low' => (int) $report->total_low,
|
||||
'total_unknown' => (int) $report->total_unknown,
|
||||
'composer_outdated_count' => (int) $report->composer_outdated_count,
|
||||
'npm_outdated_count' => (int) $report->npm_outdated_count,
|
||||
'summary' => $report->summary ?? [],
|
||||
'triggered_by' => (string) ($report->triggered_by ?? ''),
|
||||
'error_message' => (string) ($report->error_message ?? ''),
|
||||
'show_url' => route('admin.system.security-report.show', ['securityReport' => $report]),
|
||||
'index_url' => route('admin.system.security-report.index'),
|
||||
'composer_audit' => $report->composer_audit,
|
||||
'composer_outdated' => $report->composer_outdated,
|
||||
'npm_audit' => $report->npm_audit,
|
||||
'npm_outdated' => $report->npm_outdated,
|
||||
'composer_advisories' => $report->composerAdvisories(),
|
||||
'npm_vulnerabilities' => $report->npmVulnerabilities(),
|
||||
'user' => $report->user ? [
|
||||
'id' => (int) $report->user->id,
|
||||
'name' => (string) $report->user->name,
|
||||
'username' => (string) ($report->user->username ?? ''),
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -48,10 +48,7 @@ class MessageSearchController extends Controller
|
||||
$estimated = 0;
|
||||
|
||||
try {
|
||||
$client = new Client(
|
||||
config('scout.meilisearch.host'),
|
||||
config('scout.meilisearch.key')
|
||||
);
|
||||
$client = app(Client::class);
|
||||
|
||||
$prefix = (string) config('scout.prefix', '');
|
||||
$indexName = $prefix . (string) config('messaging.search.index', 'messages');
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\Sitemaps\PublishedSitemapResolver;
|
||||
use App\Services\Sitemaps\SitemapBuildService;
|
||||
use App\Services\Sitemaps\SitemapXmlRenderer;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
@@ -14,34 +15,38 @@ final class SitemapController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PublishedSitemapResolver $published,
|
||||
private readonly SitemapBuildService $build,
|
||||
private readonly SitemapXmlRenderer $renderer,
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(): Response|BinaryFileResponse
|
||||
{
|
||||
// 1. Static file written by the build/generate commands.
|
||||
// On production nginx serves this directly via try_files without reaching PHP.
|
||||
// On dev / misconfigured servers we stream it with sendfile — no RAM load.
|
||||
$path = public_path('sitemap.xml');
|
||||
if (file_exists($path)) {
|
||||
return $this->xmlFileResponse($path);
|
||||
}
|
||||
|
||||
// 2. Published release (release management pipeline fallback).
|
||||
// 1. Published release (release management pipeline fallback).
|
||||
$published = $this->published->resolveIndex();
|
||||
if ($published !== null) {
|
||||
return $this->renderer->xmlResponse($published['content']);
|
||||
}
|
||||
|
||||
// 2. Live-build fallback when no published sitemap is available.
|
||||
if ((bool) config('sitemaps.delivery.fallback_to_live_build', true)) {
|
||||
$built = $this->build->buildIndex(force: true, persist: false);
|
||||
|
||||
return $this->renderer->xmlResponse($built['content']);
|
||||
}
|
||||
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
|
||||
public function show(string $name): Response|BinaryFileResponse
|
||||
{
|
||||
if ($name === 'sitemap') {
|
||||
return $this->index();
|
||||
}
|
||||
|
||||
// 1. Static file.
|
||||
$path = public_path('sitemaps/' . $name . '.xml');
|
||||
if (file_exists($path)) {
|
||||
if ((bool) config('sitemaps.pre_generated.enabled', true) && file_exists($path)) {
|
||||
return $this->xmlFileResponse($path);
|
||||
}
|
||||
|
||||
@@ -51,6 +56,13 @@ final class SitemapController extends Controller
|
||||
return $this->renderer->xmlResponse($published['content']);
|
||||
}
|
||||
|
||||
if ((bool) config('sitemaps.delivery.fallback_to_live_build', true)) {
|
||||
$built = $this->build->buildNamed($name, force: true, persist: false);
|
||||
if ($built !== null) {
|
||||
return $this->renderer->xmlResponse($built['content']);
|
||||
}
|
||||
}
|
||||
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
|
||||
@@ -61,4 +73,4 @@ final class SitemapController extends Controller
|
||||
'Cache-Control' => 'public, max-age=' . max(60, (int) config('sitemaps.cache_ttl_seconds', 900)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Services\GroupDiscoveryService;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\View\View;
|
||||
use cPad\Plugins\News\Models\NewsArticle;
|
||||
@@ -19,6 +20,12 @@ final class SearchController extends Controller
|
||||
{
|
||||
private const ALLOWED_SORTS = ['latest', 'popular', 'likes', 'downloads'];
|
||||
|
||||
/** Reject requests with an absurd number of query params before touching search/DB. */
|
||||
private const MAX_QUERY_PARAMS = 15;
|
||||
|
||||
/** Reject requests with an absurdly long query string before touching search/DB. */
|
||||
private const MAX_QUERY_STRING_LENGTH = 500;
|
||||
|
||||
public function __construct(
|
||||
private readonly ArtworkSearchService $search,
|
||||
private readonly GroupDiscoveryService $groups,
|
||||
@@ -26,6 +33,8 @@ final class SearchController extends Controller
|
||||
|
||||
public function index(Request $request): View|RedirectResponse
|
||||
{
|
||||
$this->rejectMalformedQuery($request);
|
||||
|
||||
$canonicalQuery = $this->canonicalQueryParameters($request);
|
||||
$canonicalUrl = $this->canonicalSearchUrl($request, $canonicalQuery);
|
||||
|
||||
@@ -110,6 +119,21 @@ final class SearchController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bail out before any search/DB work for junk requests — e.g. scripted
|
||||
* floods that repeat/nest query params (group=all&page=..&sort=.. etc.).
|
||||
*/
|
||||
private function rejectMalformedQuery(Request $request): void
|
||||
{
|
||||
$query = $request->query();
|
||||
|
||||
if (count($query) > self::MAX_QUERY_PARAMS
|
||||
|| strlen((string) $request->getQueryString()) > self::MAX_QUERY_STRING_LENGTH
|
||||
) {
|
||||
abort(Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int|string>
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Notifications\SecurityReportDangerNotification;
|
||||
use App\Services\SecurityReport\SecurityReportScanner;
|
||||
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\Notification;
|
||||
|
||||
final class RunSecurityReportScanJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(public ?int $userId = null)
|
||||
{
|
||||
}
|
||||
|
||||
public function handle(SecurityReportScanner $scanner): void
|
||||
{
|
||||
if (! (bool) config('security-report.enabled', true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$report = $scanner->scan('manual', $this->userId);
|
||||
|
||||
if (! $report->hasDangerFindings()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$email = trim((string) config('security-report.notify_email', ''));
|
||||
if ($email === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::route('mail', $email)
|
||||
->notify(new SecurityReportDangerNotification($report));
|
||||
}
|
||||
}
|
||||
+52
-3
@@ -13,7 +13,6 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Laravel\Scout\Searchable;
|
||||
use Laravel\Scout\SearchableScope;
|
||||
|
||||
@@ -73,6 +72,8 @@ class Artwork extends Model
|
||||
'has_missing_thumbnails',
|
||||
'missing_thumbnail_variants_json',
|
||||
'thumbnails_checked_at',
|
||||
'featured_thumbnail_variants_json',
|
||||
'featured_thumbnails_checked_at',
|
||||
'file_size',
|
||||
'mime_type',
|
||||
'width',
|
||||
@@ -154,6 +155,8 @@ class Artwork extends Model
|
||||
'published_at' => 'datetime',
|
||||
'missing_thumbnail_variants_json' => 'array',
|
||||
'thumbnails_checked_at' => 'datetime',
|
||||
'featured_thumbnail_variants_json' => 'array',
|
||||
'featured_thumbnails_checked_at' => 'datetime',
|
||||
'published_as_type' => 'string',
|
||||
'published_as_id' => 'integer',
|
||||
'publish_at' => 'datetime',
|
||||
@@ -271,18 +274,64 @@ class Artwork extends Model
|
||||
?? 'https://files.skinbase.org/default/missing_xl.webp';
|
||||
}
|
||||
|
||||
/**
|
||||
* Audit state of `featured_thumbnail_variants_json`, distinguishing "never checked"
|
||||
* from "checked, nothing found":
|
||||
*
|
||||
* - `null` → not audited yet (FeaturedArtworkThumbnailGenerator has never
|
||||
* run plan()/generate() for this artwork).
|
||||
* - `[]` → audited, but no featured variant exists in object storage.
|
||||
* - non-empty string[] → audited; these variant names are known to exist.
|
||||
*
|
||||
* A value that fails to decode as an array (e.g. legacy/malformed data) is treated
|
||||
* the same as `null` — "not audited" — rather than throwing or reporting variants
|
||||
* that were never actually confirmed to exist.
|
||||
*
|
||||
* @return array{status: 'not_audited'|'no_variants'|'available', variants: list<string>}
|
||||
*/
|
||||
public function featuredThumbnailAuditState(): array
|
||||
{
|
||||
$raw = $this->featured_thumbnail_variants_json;
|
||||
|
||||
if (! is_array($raw)) {
|
||||
return ['status' => 'not_audited', 'variants' => []];
|
||||
}
|
||||
|
||||
$variants = array_values(array_filter($raw, 'is_string'));
|
||||
|
||||
if ($variants === []) {
|
||||
return ['status' => 'no_variants', 'variants' => []];
|
||||
}
|
||||
|
||||
return ['status' => 'available', 'variants' => $variants];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a dedicated featured-hero variant is known to exist in object storage.
|
||||
*
|
||||
* This reads precomputed state from `featured_thumbnail_variants_json` (see
|
||||
* `featuredThumbnailAuditState()`) rather than checking the remote disk directly —
|
||||
* remote existence checks must only happen from `FeaturedArtworkThumbnailGenerator`
|
||||
* (admin commands / queued jobs), never during public homepage rendering, since a
|
||||
* live `Storage::exists()` per variant is a synchronous network round trip.
|
||||
*/
|
||||
public function hasFeaturedThumbnail(?string $variant = null): bool
|
||||
{
|
||||
if (empty($this->hash)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$known = $this->featuredThumbnailAuditState()['variants'];
|
||||
|
||||
if ($known === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$helper = app(ArtworkFeaturedImagePath::class);
|
||||
$variants = $variant !== null ? [$helper->normalizeVariant($variant)] : $helper->variantNames();
|
||||
$disk = Storage::disk((string) config('uploads.object_storage.disk', 's3'));
|
||||
|
||||
foreach ($variants as $variantName) {
|
||||
if ($disk->exists($helper->objectPath($this, $variantName))) {
|
||||
if (in_array($variantName, $known, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
final class SecurityReport extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'status',
|
||||
'started_at',
|
||||
'finished_at',
|
||||
'composer_critical',
|
||||
'composer_high',
|
||||
'composer_medium',
|
||||
'composer_low',
|
||||
'composer_unknown',
|
||||
'npm_critical',
|
||||
'npm_high',
|
||||
'npm_moderate',
|
||||
'npm_low',
|
||||
'npm_info',
|
||||
'npm_unknown',
|
||||
'total_critical',
|
||||
'total_high',
|
||||
'total_medium',
|
||||
'total_low',
|
||||
'total_unknown',
|
||||
'composer_outdated_count',
|
||||
'npm_outdated_count',
|
||||
'summary',
|
||||
'composer_audit',
|
||||
'composer_outdated',
|
||||
'npm_audit',
|
||||
'npm_outdated',
|
||||
'error_message',
|
||||
'triggered_by',
|
||||
'user_id',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'started_at' => 'datetime',
|
||||
'finished_at' => 'datetime',
|
||||
'summary' => 'array',
|
||||
'composer_audit' => 'array',
|
||||
'composer_outdated' => 'array',
|
||||
'npm_audit' => 'array',
|
||||
'npm_outdated' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function hasCriticalFindings(): bool
|
||||
{
|
||||
return $this->total_critical > 0;
|
||||
}
|
||||
|
||||
public function hasHighFindings(): bool
|
||||
{
|
||||
return $this->total_high > 0;
|
||||
}
|
||||
|
||||
public function hasDangerFindings(): bool
|
||||
{
|
||||
return $this->hasCriticalFindings() || $this->hasHighFindings();
|
||||
}
|
||||
|
||||
public function getRiskLabelAttribute(): string
|
||||
{
|
||||
if ($this->total_critical > 0) {
|
||||
return 'Critical';
|
||||
}
|
||||
|
||||
if ($this->total_high > 0) {
|
||||
return 'High';
|
||||
}
|
||||
|
||||
if ($this->total_medium > 0) {
|
||||
return 'Medium';
|
||||
}
|
||||
|
||||
if ($this->total_low > 0) {
|
||||
return 'Low';
|
||||
}
|
||||
|
||||
return 'Clean';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function composerAdvisories(): array
|
||||
{
|
||||
$advisories = $this->composer_audit['advisories'] ?? [];
|
||||
$items = [];
|
||||
|
||||
foreach ($advisories as $package => $packageAdvisories) {
|
||||
if (! is_array($packageAdvisories)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($packageAdvisories as $advisory) {
|
||||
if (! is_array($advisory)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$items[] = [
|
||||
'package' => (string) $package,
|
||||
'severity' => strtolower((string) ($advisory['severity'] ?? 'unknown')),
|
||||
'title' => (string) ($advisory['title'] ?? $advisory['advisoryId'] ?? 'Unknown advisory'),
|
||||
'cve' => (string) ($advisory['cve'] ?? $advisory['link'] ?? ''),
|
||||
'affected_versions' => (string) ($advisory['affectedVersions'] ?? $advisory['affected_versions'] ?? ''),
|
||||
'reported_at' => (string) ($advisory['reportedAt'] ?? ''),
|
||||
'link' => (string) ($advisory['link'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function npmVulnerabilities(): array
|
||||
{
|
||||
$vulnerabilities = $this->npm_audit['vulnerabilities'] ?? [];
|
||||
$items = [];
|
||||
|
||||
foreach ($vulnerabilities as $package => $vulnerability) {
|
||||
if (! is_array($vulnerability)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$via = collect((array) ($vulnerability['via'] ?? []))
|
||||
->first(fn (mixed $item): bool => is_array($item));
|
||||
|
||||
$items[] = [
|
||||
'package' => (string) $package,
|
||||
'severity' => strtolower((string) ($vulnerability['severity'] ?? 'unknown')),
|
||||
'title' => is_array($via) ? (string) ($via['title'] ?? 'Unknown advisory') : 'Unknown advisory',
|
||||
'cve' => is_array($via) ? (string) ($via['cve'] ?? '') : '',
|
||||
'range' => (string) ($vulnerability['range'] ?? ''),
|
||||
'fix_available' => is_array($vulnerability['fixAvailable'] ?? null) ? (string) (($vulnerability['fixAvailable']['name'] ?? '') . '@' . ($vulnerability['fixAvailable']['version'] ?? '')) : ((bool) ($vulnerability['fixAvailable'] ?? false) ? 'Yes' : ''),
|
||||
'url' => is_array($via) ? (string) ($via['url'] ?? '') : '',
|
||||
];
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\SecurityReport;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
final class SecurityReportDangerNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(private readonly SecurityReport $report)
|
||||
{
|
||||
}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage())
|
||||
->subject('Skinbase Security Report Alert')
|
||||
->greeting('Security report alert')
|
||||
->line('High or critical dependency vulnerabilities were detected in the latest private security scan.')
|
||||
->line('Status: ' . $this->report->status)
|
||||
->line('Critical: ' . $this->report->total_critical)
|
||||
->line('High: ' . $this->report->total_high)
|
||||
->line('Medium: ' . $this->report->total_medium)
|
||||
->line('Low: ' . $this->report->total_low)
|
||||
->action('Open Security Report', url('/moderation/system/security-report/' . $this->report->id))
|
||||
->line('This report is private and intended for administrators only.');
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Jobs\GenerateFeaturedArtworkThumbnailsJob;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkFeature;
|
||||
use App\Services\HomepageService;
|
||||
@@ -21,12 +22,14 @@ final class ArtworkFeatureObserver
|
||||
public function created(ArtworkFeature $feature): void
|
||||
{
|
||||
$this->homepage->clearFeaturedAndMedalCaches();
|
||||
$this->queueFeaturedThumbnailRefresh($feature);
|
||||
$this->queueCreatorRebuild($feature);
|
||||
}
|
||||
|
||||
public function updated(ArtworkFeature $feature): void
|
||||
{
|
||||
$this->homepage->clearFeaturedAndMedalCaches();
|
||||
$this->queueFeaturedThumbnailRefresh($feature);
|
||||
$this->queueCreatorRebuild($feature);
|
||||
}
|
||||
|
||||
@@ -39,6 +42,7 @@ final class ArtworkFeatureObserver
|
||||
public function restored(ArtworkFeature $feature): void
|
||||
{
|
||||
$this->homepage->clearFeaturedAndMedalCaches();
|
||||
$this->queueFeaturedThumbnailRefresh($feature);
|
||||
$this->queueCreatorRebuild($feature);
|
||||
}
|
||||
|
||||
@@ -48,6 +52,31 @@ final class ArtworkFeatureObserver
|
||||
$this->queueCreatorRebuild($feature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the featured hero variants (and their DB-persisted existence state)
|
||||
* are ready before the next homepage guest-cache warm cycle picks this
|
||||
* artwork up as the hero winner, so the public request never has to check
|
||||
* the remote disk itself.
|
||||
*/
|
||||
private function queueFeaturedThumbnailRefresh(ArtworkFeature $feature): void
|
||||
{
|
||||
$artworkId = (int) $feature->artwork_id;
|
||||
|
||||
if ($artworkId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$artwork = $feature->relationLoaded('artwork')
|
||||
? $feature->artwork
|
||||
: Artwork::withTrashed()->find($artworkId);
|
||||
|
||||
if (! $artwork instanceof Artwork || empty($artwork->hash) || empty($artwork->file_ext)) {
|
||||
return;
|
||||
}
|
||||
|
||||
GenerateFeaturedArtworkThumbnailsJob::dispatch($artworkId);
|
||||
}
|
||||
|
||||
private function queueCreatorRebuild(ArtworkFeature $feature): void
|
||||
{
|
||||
$artwork = $feature->relationLoaded('artwork')
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Observers;
|
||||
|
||||
use App\Events\Achievements\AchievementCheckRequested;
|
||||
use App\Models\Artwork;
|
||||
use App\Jobs\GenerateFeaturedArtworkThumbnailsJob;
|
||||
use App\Jobs\RecComputeSimilarByTagsJob;
|
||||
use App\Jobs\RecComputeSimilarHybridJob;
|
||||
use App\Jobs\Posts\AutoUploadPostJob;
|
||||
@@ -15,6 +16,7 @@ use App\Services\Profile\CreatorJourneyService;
|
||||
use App\Services\UserStatsService;
|
||||
use App\Services\XPService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Syncs artwork documents to Meilisearch on every relevant model event.
|
||||
@@ -61,6 +63,25 @@ class ArtworkObserver
|
||||
|
||||
$this->indexer->update($artwork);
|
||||
|
||||
// A changed hash/extension means any previously persisted
|
||||
// featured_thumbnail_variants_json now refers to the WRONG object paths (they
|
||||
// were computed from the old hash) — the variant names would still read as
|
||||
// "available" while pointing at objects that don't exist under the new hash,
|
||||
// which would surface as a broken hero image. Reset the audit state immediately
|
||||
// (via a direct query, not a model save, to avoid re-entering this observer) so
|
||||
// hasFeaturedThumbnail() safely reports "not audited" until the queued job below
|
||||
// re-verifies and repopulates it; the public homepage never blocks on that check.
|
||||
if ($artwork->wasChanged(['hash', 'file_ext'])) {
|
||||
DB::table('artworks')->where('id', $artwork->id)->update([
|
||||
'featured_thumbnail_variants_json' => null,
|
||||
'featured_thumbnails_checked_at' => null,
|
||||
]);
|
||||
|
||||
if (! empty($artwork->hash) && ! empty($artwork->file_ext) && $artwork->features()->exists()) {
|
||||
GenerateFeaturedArtworkThumbnailsJob::dispatch((int) $artwork->id, true);
|
||||
}
|
||||
}
|
||||
|
||||
// §7.5 On-demand: recompute similarity when tags/categories could have changed.
|
||||
// The pivot sync happens outside this observer, so we dispatch on every
|
||||
// meaningful update and let the job be idempotent (cheap if nothing changed).
|
||||
@@ -146,7 +167,7 @@ class ArtworkObserver
|
||||
|
||||
private function shouldClearFeaturedCaches(Artwork $artwork): bool
|
||||
{
|
||||
if (! $artwork->wasChanged(['published_at', 'is_public', 'is_approved', 'deleted_at', 'has_missing_thumbnails'])) {
|
||||
if (! $artwork->wasChanged(['published_at', 'is_public', 'is_approved', 'deleted_at', 'has_missing_thumbnails', 'hash', 'file_ext'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,21 @@ class AppServiceProvider extends ServiceProvider
|
||||
$app->make(NullSubjectDetector::class),
|
||||
]);
|
||||
});
|
||||
|
||||
// Override Scout's default Meilisearch client binding: Scout registers it
|
||||
// with no HTTP timeout, so a slow/overloaded Meilisearch leaves PHP-FPM
|
||||
// workers blocked forever in curl_exec(), draining the whole pool under
|
||||
// a search traffic spike. Fail fast instead.
|
||||
$this->app->singleton(\Meilisearch\Client::class, function ($app) {
|
||||
$config = $app['config']->get('scout.meilisearch');
|
||||
|
||||
$httpClient = new \GuzzleHttp\Client([
|
||||
'connect_timeout' => 2,
|
||||
'timeout' => 5,
|
||||
]);
|
||||
|
||||
return new \Meilisearch\Client($config['host'], $config['key'], $httpClient);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,6 +126,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
$this->configureUploadRateLimiters();
|
||||
$this->configureMessagingRateLimiters();
|
||||
$this->configureDownloadRateLimiter();
|
||||
$this->configureSearchRateLimiter();
|
||||
$this->configureVectorSearchRateLimiter();
|
||||
$this->configureArtworkRateLimiters();
|
||||
$this->configureNovaCardRateLimiters();
|
||||
$this->configureReactionRateLimiters();
|
||||
@@ -468,6 +485,36 @@ class AppServiceProvider extends ServiceProvider
|
||||
});
|
||||
}
|
||||
|
||||
private function configureSearchRateLimiter(): void
|
||||
{
|
||||
RateLimiter::for('search', function (Request $request): array {
|
||||
$userId = $request->user()?->id;
|
||||
|
||||
// Search fans out to Meilisearch + DB queries per request, so IP
|
||||
// limits are kept tight to blunt scripted floods of /search traffic.
|
||||
return [
|
||||
Limit::perMinute(20)->by('search:user:' . ($userId ?? 'guest')),
|
||||
Limit::perMinute(30)->by('search:ip:' . $request->ip()),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
private function configureVectorSearchRateLimiter(): void
|
||||
{
|
||||
RateLimiter::for('vector-search', function (Request $request): array {
|
||||
$userId = $request->user()?->id;
|
||||
|
||||
// Each hit here can trigger synchronous outbound HTTP to the vision
|
||||
// vector gateway (image download + similarity search), so keep the
|
||||
// per-IP allowance tight — unlike cached list endpoints, a flood of
|
||||
// distinct artwork IDs can't be absorbed by cache alone.
|
||||
return [
|
||||
Limit::perMinute(30)->by('vector-search:user:' . ($userId ?? 'guest')),
|
||||
Limit::perMinute(20)->by('vector-search:ip:' . $request->ip()),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
private function configureArtworkRateLimiters(): void
|
||||
{
|
||||
RateLimiter::for('artwork-awards', function (Request $request): array {
|
||||
|
||||
@@ -7,8 +7,10 @@ namespace App\Services\Images;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\Cdn\ArtworkCdnPurgeService;
|
||||
use App\Services\ArtworkOriginalFileLocator;
|
||||
use App\Services\HomepageService;
|
||||
use App\Services\Uploads\UploadStorageService;
|
||||
use App\Support\ArtworkFeaturedImagePath;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
|
||||
@@ -28,6 +30,7 @@ final class FeaturedArtworkThumbnailGenerator
|
||||
private readonly ArtworkOriginalFileLocator $locator,
|
||||
private readonly UploadStorageService $storage,
|
||||
private readonly ArtworkCdnPurgeService $cdnPurge,
|
||||
private readonly HomepageService $homepage,
|
||||
) {
|
||||
try {
|
||||
$this->manager = extension_loaded('gd')
|
||||
@@ -59,6 +62,8 @@ final class FeaturedArtworkThumbnailGenerator
|
||||
$missing[] = $variant;
|
||||
}
|
||||
|
||||
$this->persistVariantState($artwork, $existing);
|
||||
|
||||
return [
|
||||
'existing' => $existing,
|
||||
'missing' => $missing,
|
||||
@@ -66,6 +71,52 @@ final class FeaturedArtworkThumbnailGenerator
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Record which featured variants are known to exist so the public homepage can read
|
||||
* this state instead of checking the remote disk during a request.
|
||||
*
|
||||
* saveQuietly() intentionally bypasses model observers (ArtworkObserver /
|
||||
* ArtworkFeatureObserver never fire for this write), so cache invalidation for the
|
||||
* homepage hero cannot be delegated to them here — it has to happen inline, and only
|
||||
* when the persisted availability actually changed for an artwork that is currently
|
||||
* an active feature (otherwise routine --all/--missing-only audits over thousands of
|
||||
* non-featured artworks would repeatedly invalidate the guest payload cache for no
|
||||
* reason and cause a stampede).
|
||||
*
|
||||
* @param list<string> $existingVariants
|
||||
*/
|
||||
private function persistVariantState(Artwork $artwork, array $existingVariants): void
|
||||
{
|
||||
$existingVariants = array_values(array_unique($existingVariants));
|
||||
sort($existingVariants);
|
||||
|
||||
$previousVariants = (array) ($artwork->featured_thumbnail_variants_json ?? []);
|
||||
sort($previousVariants);
|
||||
|
||||
$changed = $previousVariants !== $existingVariants;
|
||||
|
||||
$artwork->forceFill([
|
||||
'featured_thumbnail_variants_json' => $existingVariants,
|
||||
'featured_thumbnails_checked_at' => now(),
|
||||
])->saveQuietly();
|
||||
|
||||
if ($changed && $this->isActivelyFeatured($artwork)) {
|
||||
$this->homepage->clearFeaturedAndMedalCaches();
|
||||
}
|
||||
}
|
||||
|
||||
private function isActivelyFeatured(Artwork $artwork): bool
|
||||
{
|
||||
return DB::table('artwork_features')
|
||||
->where('artwork_id', $artwork->id)
|
||||
->where('is_active', true)
|
||||
->whereNull('deleted_at')
|
||||
->where(function ($query): void {
|
||||
$query->whereNull('expires_at')->orWhere('expires_at', '>', now());
|
||||
})
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{existing:list<string>,missing:list<string>,target_variants:list<string>,generated:int,skipped:int,generated_variants:list<string>,generated_paths:list<string>,failed:array<string,string>}
|
||||
*/
|
||||
@@ -129,6 +180,10 @@ final class FeaturedArtworkThumbnailGenerator
|
||||
]);
|
||||
}
|
||||
|
||||
if ($generatedVariants !== []) {
|
||||
$this->persistVariantState($artwork, array_values(array_unique([...$plan['existing'], ...$generatedVariants])));
|
||||
}
|
||||
|
||||
return $plan + [
|
||||
'generated' => count($generatedVariants),
|
||||
'skipped' => max(0, count($targetVariants) - count($generatedVariants) - count($failed)) + count($plan['existing']),
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\SecurityReport;
|
||||
|
||||
use App\Models\SecurityReport;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Throwable;
|
||||
|
||||
final class SecurityReportScanner
|
||||
{
|
||||
public function scan(string $triggeredBy = 'artisan', ?int $userId = null): SecurityReport
|
||||
{
|
||||
$report = SecurityReport::query()->create([
|
||||
'status' => 'running',
|
||||
'started_at' => now(),
|
||||
'triggered_by' => $triggeredBy,
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
|
||||
try {
|
||||
$composerAudit = null;
|
||||
$composerOutdated = null;
|
||||
$npmAudit = null;
|
||||
$npmOutdated = null;
|
||||
|
||||
if ((bool) config('security-report.scan.composer', true)) {
|
||||
$composerAudit = $this->runJsonCommand((array) config('security-report.commands.composer_audit', []));
|
||||
$composerOutdated = $this->runJsonCommand((array) config('security-report.commands.composer_outdated', []));
|
||||
}
|
||||
|
||||
if ((bool) config('security-report.scan.npm', true)) {
|
||||
$npmAudit = $this->runJsonCommand((array) config('security-report.commands.npm_audit', []));
|
||||
$npmOutdated = $this->runJsonCommand((array) config('security-report.commands.npm_outdated', []));
|
||||
}
|
||||
|
||||
$normalized = $this->summarizePayloads($composerAudit, $composerOutdated, $npmAudit, $npmOutdated);
|
||||
$status = ($normalized['total_critical'] > 0 || $normalized['total_high'] > 0 || $normalized['total_medium'] > 0 || $normalized['total_low'] > 0)
|
||||
? 'completed_with_findings'
|
||||
: 'completed';
|
||||
|
||||
$report->update(array_merge($normalized, [
|
||||
'status' => $status,
|
||||
'finished_at' => now(),
|
||||
'composer_audit' => $this->shouldStoreRaw() ? $this->limitRaw($composerAudit) : null,
|
||||
'composer_outdated' => $this->shouldStoreRaw() ? $this->limitRaw($composerOutdated) : null,
|
||||
'npm_audit' => $this->shouldStoreRaw() ? $this->limitRaw($npmAudit) : null,
|
||||
'npm_outdated' => $this->shouldStoreRaw() ? $this->limitRaw($npmOutdated) : null,
|
||||
]));
|
||||
|
||||
return $report->fresh();
|
||||
} catch (Throwable $exception) {
|
||||
Log::error('Security report scan failed', [
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
$report->update([
|
||||
'status' => 'failed',
|
||||
'finished_at' => now(),
|
||||
'error_message' => $this->sanitizeText($exception->getMessage()),
|
||||
]);
|
||||
|
||||
return $report->fresh();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $composerAudit
|
||||
* @param array<string, mixed>|null $composerOutdated
|
||||
* @param array<string, mixed>|null $npmAudit
|
||||
* @param array<string, mixed>|null $npmOutdated
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function summarizePayloads(?array $composerAudit, ?array $composerOutdated, ?array $npmAudit, ?array $npmOutdated): array
|
||||
{
|
||||
$composerCounts = $this->summarizeComposerAudit($composerAudit);
|
||||
$npmCounts = $this->summarizeNpmAudit($npmAudit);
|
||||
$composerOutdatedCount = $this->countComposerOutdated($composerOutdated);
|
||||
$npmOutdatedCount = $this->countNpmOutdated($npmOutdated);
|
||||
|
||||
$totalCritical = $composerCounts['critical'] + $npmCounts['critical'];
|
||||
$totalHigh = $composerCounts['high'] + $npmCounts['high'];
|
||||
$totalMedium = $composerCounts['medium'] + $npmCounts['moderate'];
|
||||
$totalLow = $composerCounts['low'] + $npmCounts['low'];
|
||||
$totalUnknown = $composerCounts['unknown'] + $npmCounts['unknown'] + $npmCounts['info'];
|
||||
|
||||
return [
|
||||
'composer_critical' => $composerCounts['critical'],
|
||||
'composer_high' => $composerCounts['high'],
|
||||
'composer_medium' => $composerCounts['medium'],
|
||||
'composer_low' => $composerCounts['low'],
|
||||
'composer_unknown' => $composerCounts['unknown'],
|
||||
'npm_critical' => $npmCounts['critical'],
|
||||
'npm_high' => $npmCounts['high'],
|
||||
'npm_moderate' => $npmCounts['moderate'],
|
||||
'npm_low' => $npmCounts['low'],
|
||||
'npm_info' => $npmCounts['info'],
|
||||
'npm_unknown' => $npmCounts['unknown'],
|
||||
'total_critical' => $totalCritical,
|
||||
'total_high' => $totalHigh,
|
||||
'total_medium' => $totalMedium,
|
||||
'total_low' => $totalLow,
|
||||
'total_unknown' => $totalUnknown,
|
||||
'composer_outdated_count' => $composerOutdatedCount,
|
||||
'npm_outdated_count' => $npmOutdatedCount,
|
||||
'summary' => [
|
||||
'total' => [
|
||||
'critical' => $totalCritical,
|
||||
'high' => $totalHigh,
|
||||
'medium' => $totalMedium,
|
||||
'low' => $totalLow,
|
||||
'unknown' => $totalUnknown,
|
||||
],
|
||||
'composer' => $composerCounts,
|
||||
'npm' => $npmCounts,
|
||||
'outdated' => [
|
||||
'composer' => $composerOutdatedCount,
|
||||
'npm' => $npmOutdatedCount,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $audit
|
||||
* @return array{critical:int,high:int,medium:int,low:int,unknown:int}
|
||||
*/
|
||||
public function summarizeComposerAudit(?array $audit): array
|
||||
{
|
||||
$counts = [
|
||||
'critical' => 0,
|
||||
'high' => 0,
|
||||
'medium' => 0,
|
||||
'low' => 0,
|
||||
'unknown' => 0,
|
||||
];
|
||||
|
||||
if (! is_array($audit)) {
|
||||
return $counts;
|
||||
}
|
||||
|
||||
$advisories = $audit['advisories'] ?? [];
|
||||
|
||||
foreach ($advisories as $packageAdvisories) {
|
||||
if (! is_array($packageAdvisories)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($packageAdvisories as $advisory) {
|
||||
if (! is_array($advisory)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$severity = strtolower((string) ($advisory['severity'] ?? 'unknown'));
|
||||
if (array_key_exists($severity, $counts)) {
|
||||
$counts[$severity]++;
|
||||
} else {
|
||||
$counts['unknown']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $audit
|
||||
* @return array{critical:int,high:int,moderate:int,low:int,info:int,unknown:int}
|
||||
*/
|
||||
public function summarizeNpmAudit(?array $audit): array
|
||||
{
|
||||
$counts = [
|
||||
'critical' => 0,
|
||||
'high' => 0,
|
||||
'moderate' => 0,
|
||||
'low' => 0,
|
||||
'info' => 0,
|
||||
'unknown' => 0,
|
||||
];
|
||||
|
||||
if (! is_array($audit)) {
|
||||
return $counts;
|
||||
}
|
||||
|
||||
if (isset($audit['metadata']['vulnerabilities']) && is_array($audit['metadata']['vulnerabilities'])) {
|
||||
$vulnerabilities = $audit['metadata']['vulnerabilities'];
|
||||
|
||||
$counts['critical'] = (int) ($vulnerabilities['critical'] ?? 0);
|
||||
$counts['high'] = (int) ($vulnerabilities['high'] ?? 0);
|
||||
$counts['moderate'] = (int) ($vulnerabilities['moderate'] ?? 0);
|
||||
$counts['low'] = (int) ($vulnerabilities['low'] ?? 0);
|
||||
$counts['info'] = (int) ($vulnerabilities['info'] ?? 0);
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
$vulnerabilities = $audit['vulnerabilities'] ?? [];
|
||||
|
||||
foreach ($vulnerabilities as $vulnerability) {
|
||||
if (! is_array($vulnerability)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$severity = strtolower((string) ($vulnerability['severity'] ?? 'unknown'));
|
||||
if (array_key_exists($severity, $counts)) {
|
||||
$counts[$severity]++;
|
||||
} else {
|
||||
$counts['unknown']++;
|
||||
}
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $command
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function runJsonCommand(array $command): ?array
|
||||
{
|
||||
if ($command === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$process = new Process(
|
||||
$command,
|
||||
base_path(),
|
||||
null,
|
||||
null,
|
||||
(float) config('security-report.timeout_seconds', 180),
|
||||
);
|
||||
|
||||
$process->run();
|
||||
|
||||
$output = trim($process->getOutput());
|
||||
$errorOutput = trim($process->getErrorOutput());
|
||||
|
||||
if ($output === '') {
|
||||
return [
|
||||
'_status' => $errorOutput !== '' ? 'no_json_output' : 'empty_output',
|
||||
'_exit_code' => $process->getExitCode(),
|
||||
'_error' => $errorOutput !== '' ? $this->sanitizeText(mb_substr($errorOutput, 0, 5000)) : null,
|
||||
];
|
||||
}
|
||||
|
||||
$json = json_decode($output, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE || ! is_array($json)) {
|
||||
return [
|
||||
'_status' => 'invalid_json',
|
||||
'_exit_code' => $process->getExitCode(),
|
||||
'_json_error' => json_last_error_msg(),
|
||||
'_output_preview' => $this->sanitizeText(mb_substr($output, 0, 5000)),
|
||||
'_error_preview' => $this->sanitizeText(mb_substr($errorOutput, 0, 5000)),
|
||||
];
|
||||
}
|
||||
|
||||
$json['_exit_code'] = $process->getExitCode();
|
||||
|
||||
return $this->sanitizeArray($json);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $outdated
|
||||
*/
|
||||
private function countComposerOutdated(?array $outdated): int
|
||||
{
|
||||
if (! is_array($outdated)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return isset($outdated['installed']) && is_array($outdated['installed'])
|
||||
? count($outdated['installed'])
|
||||
: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $outdated
|
||||
*/
|
||||
private function countNpmOutdated(?array $outdated): int
|
||||
{
|
||||
if (! is_array($outdated)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return count(array_filter(
|
||||
$outdated,
|
||||
static fn (mixed $value, mixed $key): bool => is_array($value) && ! str_starts_with((string) $key, '_'),
|
||||
ARRAY_FILTER_USE_BOTH,
|
||||
));
|
||||
}
|
||||
|
||||
private function shouldStoreRaw(): bool
|
||||
{
|
||||
return (bool) config('security-report.store_raw', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $data
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function limitRaw(?array $data): ?array
|
||||
{
|
||||
if ($data === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sanitized = $this->sanitizeArray($data);
|
||||
$maxBytes = max(64, (int) config('security-report.max_raw_kb', 512)) * 1024;
|
||||
$json = json_encode($sanitized, JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE);
|
||||
|
||||
if (! is_string($json)) {
|
||||
return [
|
||||
'_status' => 'raw_encode_failed',
|
||||
];
|
||||
}
|
||||
|
||||
if (strlen($json) <= $maxBytes) {
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
return [
|
||||
'_status' => 'truncated',
|
||||
'_max_kb' => (int) config('security-report.max_raw_kb', 512),
|
||||
'_preview' => mb_substr($json, 0, $maxBytes),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $data
|
||||
* @return array<mixed>
|
||||
*/
|
||||
private function sanitizeArray(array $data): array
|
||||
{
|
||||
$sanitized = [];
|
||||
|
||||
foreach ($data as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$sanitized[$key] = $this->sanitizeArray($value);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
$sanitized[$key] = $this->sanitizeText($value);
|
||||
continue;
|
||||
}
|
||||
|
||||
$sanitized[$key] = $value;
|
||||
}
|
||||
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
private function sanitizeText(string $value): string
|
||||
{
|
||||
$normalized = str_replace(["\r\n", "\r"], "\n", $value);
|
||||
$normalized = str_replace(base_path(), '[project-root]', $normalized);
|
||||
$normalized = preg_replace('/[A-Z]:\\\\[^\s"\']+/', '[path]', $normalized) ?? $normalized;
|
||||
|
||||
return trim($normalized);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps\Builders;
|
||||
|
||||
use App\Services\Sitemaps\AbstractSitemapBuilder;
|
||||
use App\Services\Sitemaps\SitemapUrlBuilder;
|
||||
use DateTimeInterface;
|
||||
|
||||
final class AcademyPagesSitemapBuilder extends AbstractSitemapBuilder
|
||||
{
|
||||
public function __construct(private readonly SitemapUrlBuilder $urls)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'academy-pages';
|
||||
}
|
||||
|
||||
public function items(): array
|
||||
{
|
||||
if (! (bool) config('academy.enabled', true)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
$this->urls->staticRoute('/academy'),
|
||||
$this->urls->staticRoute('/academy/pricing'),
|
||||
];
|
||||
}
|
||||
|
||||
public function lastModified(): ?DateTimeInterface
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,10 @@ final class AcademyPromptsSitemapBuilder extends AbstractSitemapBuilder
|
||||
return [];
|
||||
}
|
||||
|
||||
$items = [$this->urls->staticRoute('/academy/prompts')];
|
||||
$items = [
|
||||
$this->urls->staticRoute('/academy/prompts'),
|
||||
$this->urls->staticRoute('/academy/prompts/popular'),
|
||||
];
|
||||
|
||||
$details = AcademyPromptTemplate::query()
|
||||
->active()
|
||||
@@ -45,4 +48,4 @@ final class AcademyPromptsSitemapBuilder extends AbstractSitemapBuilder
|
||||
{
|
||||
return $this->dateTime(AcademyPromptTemplate::query()->active()->published()->max('updated_at'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,8 +24,6 @@ final class StaticPagesSitemapBuilder extends AbstractSitemapBuilder
|
||||
{
|
||||
$items = [
|
||||
$this->urls->staticRoute('/'),
|
||||
$this->urls->staticRoute('/academy'),
|
||||
$this->urls->staticRoute('/academy/pricing'),
|
||||
$this->urls->staticRoute('/web-stories'),
|
||||
$this->urls->staticRoute('/faq'),
|
||||
$this->urls->staticRoute('/rules-and-guidelines'),
|
||||
@@ -61,4 +59,4 @@ final class StaticPagesSitemapBuilder extends AbstractSitemapBuilder
|
||||
{
|
||||
return $this->dateTime(Page::query()->published()->max('updated_at'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +102,14 @@ final class PublishedSitemapResolver
|
||||
}
|
||||
}
|
||||
|
||||
foreach ((array) ($manifest['groups'] ?? []) as $groupName => $group) {
|
||||
$entryName = (string) ($group['entry_name'] ?? '');
|
||||
|
||||
if ($requestedName === $groupName && $entryName !== '') {
|
||||
return $entryName;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,23 @@ final class SitemapBuildService
|
||||
*/
|
||||
public function buildNamed(string $name, bool $force = false, bool $persist = true): ?array
|
||||
{
|
||||
$groupFamilies = $this->groupFamilies($name);
|
||||
if ($groupFamilies !== null) {
|
||||
$built = $this->cache->remember(
|
||||
$name,
|
||||
fn (): string => $this->renderer->renderIndex($this->index->itemsForFamilies($groupFamilies)),
|
||||
$force,
|
||||
$persist,
|
||||
);
|
||||
|
||||
return $built + [
|
||||
'type' => SitemapTarget::TYPE_INDEX,
|
||||
'url_count' => count($this->index->itemsForFamilies($groupFamilies)),
|
||||
'shard_count' => 0,
|
||||
'name' => $name,
|
||||
];
|
||||
}
|
||||
|
||||
$target = $this->shards->resolve($this->registry, $name);
|
||||
|
||||
if ($target === null) {
|
||||
@@ -111,6 +128,24 @@ final class SitemapBuildService
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>|null
|
||||
*/
|
||||
public function groupFamilies(string $name): ?array
|
||||
{
|
||||
$families = $this->index->activeGroupIndexes($this->enabledFamilies())[$name] ?? null;
|
||||
|
||||
return is_array($families) && $families !== [] ? $families : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<string>>
|
||||
*/
|
||||
public function enabledGroupIndexes(): array
|
||||
{
|
||||
return $this->index->activeGroupIndexes($this->enabledFamilies());
|
||||
}
|
||||
|
||||
private function renderTarget(SitemapTarget $target): string
|
||||
{
|
||||
if ($target->type === SitemapTarget::TYPE_INDEX) {
|
||||
@@ -140,4 +175,4 @@ final class SitemapBuildService
|
||||
|
||||
return count($target->builder->items());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeInterface;
|
||||
|
||||
final class SitemapIndexService
|
||||
{
|
||||
public function __construct(
|
||||
@@ -18,8 +21,25 @@ final class SitemapIndexService
|
||||
public function items(?array $families = null): array
|
||||
{
|
||||
$items = [];
|
||||
$selectedFamilies = $families ?? (array) config('sitemaps.enabled', []);
|
||||
$groupedFamilies = [];
|
||||
|
||||
foreach ($this->activeGroupIndexes($selectedFamilies) as $groupName => $groupFamilies) {
|
||||
$items[] = new SitemapIndexItem(
|
||||
url('/sitemaps/' . $groupName . '.xml'),
|
||||
$this->lastModifiedForFamilies($groupFamilies),
|
||||
);
|
||||
|
||||
foreach ($groupFamilies as $family) {
|
||||
$groupedFamilies[$family] = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($selectedFamilies as $name) {
|
||||
if (isset($groupedFamilies[(string) $name])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($families ?? (array) config('sitemaps.enabled', []) as $name) {
|
||||
$builder = $this->registry->get((string) $name);
|
||||
|
||||
if ($builder === null) {
|
||||
@@ -35,6 +55,30 @@ final class SitemapIndexService
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $families
|
||||
* @return list<SitemapIndexItem>
|
||||
*/
|
||||
public function itemsForFamilies(array $families): array
|
||||
{
|
||||
$items = [];
|
||||
|
||||
foreach ($families as $family) {
|
||||
$builder = $this->registry->get($family);
|
||||
|
||||
if ($builder === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$items[] = new SitemapIndexItem(
|
||||
url('/sitemaps/' . $this->shards->rootEntryName($builder) . '.xml'),
|
||||
$builder->lastModified(),
|
||||
);
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<SitemapIndexItem>
|
||||
*/
|
||||
@@ -58,4 +102,69 @@ final class SitemapIndexService
|
||||
$builder->lastModified(),
|
||||
)];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $selectedFamilies
|
||||
* @return array<string, list<string>>
|
||||
*/
|
||||
public function activeGroupIndexes(array $selectedFamilies): array
|
||||
{
|
||||
$selectedLookup = array_fill_keys($selectedFamilies, true);
|
||||
$groups = [];
|
||||
|
||||
foreach ((array) config('sitemaps.group_indexes', []) as $groupName => $groupFamilies) {
|
||||
if (! is_string($groupName) || $groupName === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$validFamilies = array_values(array_filter(
|
||||
(array) $groupFamilies,
|
||||
fn (mixed $family): bool => is_string($family) && $family !== '' && $this->registry->get($family) !== null,
|
||||
));
|
||||
|
||||
if ($validFamilies === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($validFamilies as $family) {
|
||||
if (! isset($selectedLookup[$family])) {
|
||||
continue 2;
|
||||
}
|
||||
}
|
||||
|
||||
$groups[$groupName] = $validFamilies;
|
||||
}
|
||||
|
||||
return $groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $families
|
||||
*/
|
||||
private function lastModifiedForFamilies(array $families): ?DateTimeInterface
|
||||
{
|
||||
$latest = null;
|
||||
|
||||
foreach ($families as $family) {
|
||||
$builder = $this->registry->get($family);
|
||||
|
||||
if ($builder === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lastModified = $builder->lastModified();
|
||||
|
||||
if ($lastModified !== null) {
|
||||
$candidate = $lastModified instanceof DateTimeInterface
|
||||
? $lastModified
|
||||
: new DateTimeImmutable((string) $lastModified);
|
||||
|
||||
if ($latest === null || $candidate->getTimestamp() > $latest->getTimestamp()) {
|
||||
$latest = $candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $latest;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +130,7 @@ final class SitemapPublishService
|
||||
$releaseId ??= $this->releases->generateReleaseId();
|
||||
|
||||
$familyManifest = [];
|
||||
$groupManifest = [];
|
||||
$documents = [
|
||||
SitemapCacheService::INDEX_DOCUMENT => $this->releases->documentRelativePath(SitemapCacheService::INDEX_DOCUMENT),
|
||||
];
|
||||
@@ -180,15 +181,43 @@ final class SitemapPublishService
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($this->build->enabledGroupIndexes() as $groupName => $groupFamilies) {
|
||||
foreach ($groupFamilies as $family) {
|
||||
if (! in_array($family, $selectedFamilies, true)) {
|
||||
continue 2;
|
||||
}
|
||||
}
|
||||
|
||||
$built = $this->build->buildNamed($groupName, true, false);
|
||||
|
||||
if ($built === null) {
|
||||
throw new \RuntimeException('Failed to build sitemap group [' . $groupName . '].');
|
||||
}
|
||||
|
||||
$this->releases->putDocument($releaseId, $groupName, (string) $built['content']);
|
||||
$documents[$groupName] = $this->releases->documentRelativePath($groupName);
|
||||
|
||||
$groupManifest[$groupName] = [
|
||||
'name' => $groupName,
|
||||
'entry_name' => $groupName,
|
||||
'families' => $groupFamilies,
|
||||
'documents' => [$groupName],
|
||||
'url_count' => (int) $built['url_count'],
|
||||
'type' => SitemapTarget::TYPE_INDEX,
|
||||
];
|
||||
}
|
||||
|
||||
$manifest = [
|
||||
'release_id' => $releaseId,
|
||||
'status' => 'built',
|
||||
'built_at' => now()->toAtomString(),
|
||||
'published_at' => null,
|
||||
'families' => $familyManifest,
|
||||
'groups' => $groupManifest,
|
||||
'documents' => $documents,
|
||||
'totals' => [
|
||||
'families' => count($familyManifest),
|
||||
'groups' => count($groupManifest),
|
||||
'documents' => count($documents),
|
||||
'urls' => $totalUrls,
|
||||
],
|
||||
@@ -204,4 +233,4 @@ final class SitemapPublishService
|
||||
|
||||
return $manifest;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Services\Sitemaps\Builders\ArtworksSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\AcademyChallengesSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\AcademyCoursesSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\AcademyLessonsSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\AcademyPagesSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\AcademyPacksSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\AcademyPromptsSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\CardsSitemapBuilder;
|
||||
@@ -33,6 +34,7 @@ final class SitemapRegistry
|
||||
|
||||
public function __construct(
|
||||
ArtworksSitemapBuilder $artworks,
|
||||
AcademyPagesSitemapBuilder $academyPages,
|
||||
AcademyCoursesSitemapBuilder $academyCourses,
|
||||
AcademyLessonsSitemapBuilder $academyLessons,
|
||||
AcademyPromptsSitemapBuilder $academyPrompts,
|
||||
@@ -54,6 +56,7 @@ final class SitemapRegistry
|
||||
) {
|
||||
$this->builders = [
|
||||
$artworks->name() => $artworks,
|
||||
$academyPages->name() => $academyPages,
|
||||
$academyCourses->name() => $academyCourses,
|
||||
$academyLessons->name() => $academyLessons,
|
||||
$academyPrompts->name() => $academyPrompts,
|
||||
@@ -87,4 +90,4 @@ final class SitemapRegistry
|
||||
{
|
||||
return $this->builders[$name] ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ final class SitemapReleaseValidator
|
||||
|
||||
$errors = [];
|
||||
$families = (array) ($manifest['families'] ?? []);
|
||||
$groups = (array) ($manifest['groups'] ?? []);
|
||||
$documents = (array) ($manifest['documents'] ?? []);
|
||||
|
||||
$rootContent = $this->releases->getDocument($releaseId, SitemapCacheService::INDEX_DOCUMENT);
|
||||
@@ -41,10 +42,9 @@ final class SitemapReleaseValidator
|
||||
$errors[] = 'Root sitemap.xml is missing or invalid.';
|
||||
} else {
|
||||
$rootLocs = $this->extractLocs($rootXml, 'sitemap');
|
||||
$expectedRootLocs = array_map(
|
||||
fn (string $entryName): string => url('/sitemaps/' . $entryName . '.xml'),
|
||||
array_values(array_map(static fn (array $family): string => (string) ($family['entry_name'] ?? ''), $families)),
|
||||
);
|
||||
$expectedRoot = $this->build->buildIndex(true, false, array_keys($families));
|
||||
$expectedRootXml = $this->loadXml((string) $expectedRoot['content']);
|
||||
$expectedRootLocs = $expectedRootXml ? $this->extractLocs($expectedRootXml, 'sitemap') : [];
|
||||
|
||||
if ($rootLocs !== $expectedRootLocs) {
|
||||
$errors[] = 'Root sitemap index does not match the manifest family entries.';
|
||||
@@ -147,13 +147,48 @@ final class SitemapReleaseValidator
|
||||
}
|
||||
}
|
||||
|
||||
$groupReports = [];
|
||||
|
||||
foreach ($groups as $groupName => $group) {
|
||||
$groupErrors = [];
|
||||
$documentName = (string) ($group['entry_name'] ?? $groupName);
|
||||
$artifact = $this->releases->getDocument($releaseId, $documentName);
|
||||
|
||||
if (! is_string($artifact) || $artifact === '') {
|
||||
$groupErrors[] = 'Missing artifact [' . $documentName . '].';
|
||||
} else {
|
||||
$artifactXml = $this->loadXml($artifact);
|
||||
$expected = $this->build->buildNamed($documentName, true, false);
|
||||
$expectedXml = $expected !== null ? $this->loadXml((string) $expected['content']) : null;
|
||||
|
||||
if ($artifactXml === null || $expectedXml === null) {
|
||||
$groupErrors[] = 'Invalid XML in group artifact [' . $documentName . '].';
|
||||
} elseif ($this->extractLocs($artifactXml, 'sitemap') !== $this->extractLocs($expectedXml, 'sitemap')) {
|
||||
$groupErrors[] = 'Group index artifact [' . $documentName . '] does not match expected sitemap references.';
|
||||
}
|
||||
}
|
||||
|
||||
$groupReports[] = [
|
||||
'group' => $groupName,
|
||||
'documents' => count((array) ($group['documents'] ?? [])),
|
||||
'url_count' => (int) ($group['url_count'] ?? 0),
|
||||
'errors' => $groupErrors,
|
||||
];
|
||||
|
||||
foreach ($groupErrors as $groupError) {
|
||||
$errors[] = $groupName . ': ' . $groupError;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => $errors === [],
|
||||
'release_id' => $releaseId,
|
||||
'errors' => $errors,
|
||||
'families' => $reports,
|
||||
'groups' => $groupReports,
|
||||
'totals' => [
|
||||
'families' => count($families),
|
||||
'groups' => count($groups),
|
||||
'documents' => count($documents),
|
||||
'urls' => array_sum(array_map(static fn (array $family): int => (int) ($family['url_count'] ?? 0), $families)),
|
||||
'shards' => array_sum(array_map(static fn (array $family): int => (int) ($family['shard_count'] ?? 0), $families)),
|
||||
@@ -255,4 +290,4 @@ final class SitemapReleaseValidator
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Writes every document from a published release to the public disk so nginx
|
||||
* can serve sitemap.xml and sitemaps/{name}.xml as plain static files,
|
||||
* bypassing PHP entirely on subsequent requests.
|
||||
* can serve sitemaps/{name}.xml as plain static files, bypassing PHP on
|
||||
* subsequent child-sitemap requests. The root /sitemap.xml stays dynamic.
|
||||
*/
|
||||
final class SitemapStaticPublisher
|
||||
{
|
||||
@@ -50,6 +50,7 @@ final class SitemapStaticPublisher
|
||||
}
|
||||
|
||||
$disk->put((string) $relativePath, $content);
|
||||
|
||||
$written++;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,15 +29,7 @@ final class SitemapValidationService
|
||||
? array_values(array_filter($onlyFamilies, fn (string $family): bool => $this->registry->get($family) !== null))
|
||||
: $this->build->enabledFamilies();
|
||||
|
||||
$expectedIndexLocs = array_map(
|
||||
static fn (SitemapIndexItem $item): string => $item->loc,
|
||||
array_values(array_filter(
|
||||
$this->index->items(),
|
||||
fn (SitemapIndexItem $item): bool => $this->isFamilySelected($families, $item->loc),
|
||||
)),
|
||||
);
|
||||
|
||||
$indexBuild = $this->build->buildIndex(true, false);
|
||||
$indexBuild = $this->build->buildIndex(true, false, $families);
|
||||
$indexErrors = [];
|
||||
$indexXml = $this->loadXml($indexBuild['content']);
|
||||
|
||||
@@ -45,6 +37,7 @@ final class SitemapValidationService
|
||||
$indexErrors[] = 'The main sitemap index XML could not be parsed.';
|
||||
}
|
||||
|
||||
$expectedIndexLocs = $this->extractLocsFromContent((string) $indexBuild['content'], 'sitemap');
|
||||
$actualIndexLocs = $indexXml ? $this->extractLocs($indexXml, 'sitemap') : [];
|
||||
if ($indexXml !== null && $actualIndexLocs !== $expectedIndexLocs) {
|
||||
$indexErrors[] = 'Main sitemap index child references do not match the expected shard-aware manifest.';
|
||||
@@ -214,15 +207,14 @@ final class SitemapValidationService
|
||||
return $locs;
|
||||
}
|
||||
|
||||
private function isFamilySelected(array $families, string $loc): bool
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function extractLocsFromContent(string $content, string $nodeName): array
|
||||
{
|
||||
foreach ($families as $family) {
|
||||
if (str_contains($loc, '/sitemaps/' . $family . '.xml') || str_contains($loc, '/sitemaps/' . $family . '-')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
$document = $this->loadXml($content);
|
||||
|
||||
return false;
|
||||
return $document === null ? [] : $this->extractLocs($document, $nodeName);
|
||||
}
|
||||
|
||||
private function urlError(string $family, string $loc): ?string
|
||||
@@ -283,4 +275,4 @@ final class SitemapValidationService
|
||||
|
||||
return $user === null ? 'Non-public user URL emitted' : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,10 +58,12 @@ final class AiArtworkVectorSearchService
|
||||
*/
|
||||
private function downloadArtworkImage(Artwork $artwork, string $url): ?array
|
||||
{
|
||||
// Runs synchronously in the web request path — keep the budget tight
|
||||
// so a slow origin can't pin an FPM worker for 20s+.
|
||||
$response = Http::accept('*/*')
|
||||
->connectTimeout(5)
|
||||
->timeout(20)
|
||||
->retry(1, 200, throw: false)
|
||||
->connectTimeout(2)
|
||||
->timeout(6)
|
||||
->retry(0)
|
||||
->get($url);
|
||||
|
||||
if (! $response->ok()) {
|
||||
@@ -91,6 +93,10 @@ final class AiArtworkVectorSearchService
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($this->client->circuitOpen()) {
|
||||
throw new RuntimeException('Vector gateway temporarily unavailable (circuit open after a recent failure).');
|
||||
}
|
||||
|
||||
$fileFailure = null;
|
||||
|
||||
try {
|
||||
|
||||
@@ -7,11 +7,14 @@ namespace App\Services\Vision;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
final class VectorGatewayClient
|
||||
{
|
||||
private const CIRCUIT_KEY = 'vision.vector_gateway.circuit_open';
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return (bool) config('vision.vector_gateway.enabled', true)
|
||||
@@ -19,9 +22,25 @@ final class VectorGatewayClient
|
||||
&& $this->apiKey() !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* True while the gateway is presumed down after a recent failure — callers
|
||||
* on the request path should skip the network round trip entirely.
|
||||
*/
|
||||
public function circuitOpen(): bool
|
||||
{
|
||||
return Cache::has(self::CIRCUIT_KEY);
|
||||
}
|
||||
|
||||
public function tripCircuit(): void
|
||||
{
|
||||
$seconds = max(1, (int) config('vision.vector_gateway.circuit_breaker_seconds', 30));
|
||||
Cache::put(self::CIRCUIT_KEY, true, $seconds);
|
||||
}
|
||||
|
||||
public function upsertByUrl(string $imageUrl, int|string $id, array $metadata = []): array
|
||||
{
|
||||
$response = $this->postJson(
|
||||
$this->request(),
|
||||
$this->url((string) config('vision.vector_gateway.upsert_endpoint', '/vectors/upsert')),
|
||||
[
|
||||
'url' => $imageUrl,
|
||||
@@ -65,15 +84,24 @@ final class VectorGatewayClient
|
||||
*/
|
||||
public function searchByUrl(string $imageUrl, int $limit = 5): array
|
||||
{
|
||||
$response = $this->postJson(
|
||||
$this->url((string) config('vision.vector_gateway.search_endpoint', '/vectors/search')),
|
||||
[
|
||||
'url' => $imageUrl,
|
||||
'limit' => max(1, $limit),
|
||||
]
|
||||
);
|
||||
$this->guardCircuit();
|
||||
|
||||
try {
|
||||
$response = $this->postJson(
|
||||
$this->searchRequest(),
|
||||
$this->url((string) config('vision.vector_gateway.search_endpoint', '/vectors/search')),
|
||||
[
|
||||
'url' => $imageUrl,
|
||||
'limit' => max(1, $limit),
|
||||
]
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$this->tripCircuit();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
$this->tripCircuit();
|
||||
throw new RuntimeException($this->failureMessage('Vector search', $response));
|
||||
}
|
||||
|
||||
@@ -85,16 +113,24 @@ final class VectorGatewayClient
|
||||
*/
|
||||
public function searchByFileContents(string $contents, string $filename, int $limit = 5): array
|
||||
{
|
||||
$response = $this->request()
|
||||
->attach('file', $contents, $filename)
|
||||
->post(
|
||||
$this->url((string) config('vision.vector_gateway.search_file_endpoint', '/vectors/search/file')),
|
||||
[
|
||||
'limit' => max(1, $limit),
|
||||
]
|
||||
);
|
||||
$this->guardCircuit();
|
||||
|
||||
try {
|
||||
$response = $this->searchRequest()
|
||||
->attach('file', $contents, $filename)
|
||||
->post(
|
||||
$this->url((string) config('vision.vector_gateway.search_file_endpoint', '/vectors/search/file')),
|
||||
[
|
||||
'limit' => max(1, $limit),
|
||||
]
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$this->tripCircuit();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
$this->tripCircuit();
|
||||
throw new RuntimeException($this->failureMessage('Vector search', $response));
|
||||
}
|
||||
|
||||
@@ -122,6 +158,7 @@ final class VectorGatewayClient
|
||||
public function deleteByIds(array $ids): array
|
||||
{
|
||||
$response = $this->postJson(
|
||||
$this->request(),
|
||||
$this->url((string) config('vision.vector_gateway.delete_endpoint', '/vectors/delete')),
|
||||
[
|
||||
'ids' => array_values(array_map(static fn (int|string $id): string => (string) $id, $ids)),
|
||||
@@ -137,6 +174,10 @@ final class VectorGatewayClient
|
||||
return is_array($json) ? $json : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Used by upsert/delete — only ever called from queued/console indexing
|
||||
* jobs, so a more generous budget is fine.
|
||||
*/
|
||||
private function request(): PendingRequest
|
||||
{
|
||||
if (! $this->isConfigured()) {
|
||||
@@ -156,12 +197,42 @@ final class VectorGatewayClient
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Used by search — runs synchronously inside web requests, so it gets a
|
||||
* tight timeout budget and no retries to avoid pinning FPM workers.
|
||||
*/
|
||||
private function searchRequest(): PendingRequest
|
||||
{
|
||||
if (! $this->isConfigured()) {
|
||||
throw new RuntimeException('Vision vector gateway is not configured. Set VISION_VECTOR_GATEWAY_URL and VISION_VECTOR_GATEWAY_API_KEY.');
|
||||
}
|
||||
|
||||
return Http::acceptJson()
|
||||
->withHeaders([
|
||||
'X-API-Key' => $this->apiKey(),
|
||||
])
|
||||
->connectTimeout(max(1, (int) config('vision.vector_gateway.search_connect_timeout_seconds', 2)))
|
||||
->timeout(max(1, (int) config('vision.vector_gateway.search_timeout_seconds', 6)))
|
||||
->retry(
|
||||
max(0, (int) config('vision.vector_gateway.search_retries', 0)),
|
||||
max(0, (int) config('vision.vector_gateway.retry_delay_ms', 250)),
|
||||
throw: false,
|
||||
);
|
||||
}
|
||||
|
||||
private function guardCircuit(): void
|
||||
{
|
||||
if ($this->circuitOpen()) {
|
||||
throw new RuntimeException('Vector gateway temporarily unavailable (circuit open after a recent failure).');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function postJson(string $url, array $payload): Response
|
||||
private function postJson(PendingRequest $request, string $url, array $payload): Response
|
||||
{
|
||||
$response = $this->request()->post($url, $payload);
|
||||
$response = $request->post($url, $payload);
|
||||
|
||||
if (! $response instanceof Response) {
|
||||
throw new RuntimeException('Vector gateway request did not return an HTTP response.');
|
||||
|
||||
@@ -2073,6 +2073,8 @@
|
||||
"resources/js/Pages/Admin/HomepageAnnouncements/Index.jsx": [],
|
||||
"resources/js/Pages/Admin/Settings.jsx": [],
|
||||
"resources/js/Pages/Admin/Stories.jsx": [],
|
||||
"resources/js/Pages/Admin/System/SecurityReportIndex.jsx": [],
|
||||
"resources/js/Pages/Admin/System/SecurityReportShow.jsx": [],
|
||||
"resources/js/Pages/Admin/UploadQueue.jsx": [],
|
||||
"resources/js/Pages/Admin/UsernameQueue.jsx": [],
|
||||
"resources/js/Pages/Admin/Users/Index.jsx": [],
|
||||
|
||||
+484
-357
File diff suppressed because one or more lines are too long
@@ -114,6 +114,9 @@
|
||||
"pestphp/pest-plugin": true,
|
||||
"php-http/discovery": true
|
||||
},
|
||||
"audit": {
|
||||
"abandoned": "report"
|
||||
},
|
||||
"platform": {
|
||||
"ext-pcntl": "8.4.0",
|
||||
"ext-posix": "8.4.0"
|
||||
|
||||
Generated
+371
-346
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'enabled' => env('SECURITY_REPORT_ENABLED', true),
|
||||
|
||||
'notify_email' => env('SECURITY_REPORT_NOTIFY_EMAIL', env('MAIL_FROM_ADDRESS')),
|
||||
|
||||
'scan' => [
|
||||
'composer' => env('SECURITY_REPORT_SCAN_COMPOSER', true),
|
||||
'npm' => env('SECURITY_REPORT_SCAN_NPM', true),
|
||||
],
|
||||
|
||||
'store_raw' => env('SECURITY_REPORT_STORE_RAW', true),
|
||||
|
||||
'max_raw_kb' => (int) env('SECURITY_REPORT_MAX_RAW_KB', 512),
|
||||
|
||||
'fail_on' => [
|
||||
'high' => env('SECURITY_REPORT_FAIL_ON_HIGH', false),
|
||||
'critical' => env('SECURITY_REPORT_FAIL_ON_CRITICAL', false),
|
||||
],
|
||||
|
||||
'commands' => [
|
||||
'composer_audit' => ['composer', 'audit', '--format=json', '--locked'],
|
||||
'composer_outdated' => ['composer', 'outdated', '--direct', '--format=json'],
|
||||
'npm_audit' => ['npm', 'audit', '--json'],
|
||||
'npm_outdated' => ['npm', 'outdated', '--json'],
|
||||
],
|
||||
|
||||
'timeout_seconds' => 180,
|
||||
];
|
||||
+7
-1
@@ -83,6 +83,12 @@ return [
|
||||
|
||||
'enabled' => [
|
||||
'artworks',
|
||||
'academy-pages',
|
||||
'academy-courses',
|
||||
'academy-lessons',
|
||||
'academy-prompts',
|
||||
'academy-packs',
|
||||
'academy-challenges',
|
||||
'users',
|
||||
'tags',
|
||||
'categories',
|
||||
@@ -114,4 +120,4 @@ return [
|
||||
'legal-privacy',
|
||||
'legal-cookies',
|
||||
],
|
||||
];
|
||||
];
|
||||
|
||||
@@ -83,6 +83,19 @@ return [
|
||||
'connect_timeout_seconds' => (int) env('VISION_VECTOR_GATEWAY_CONNECT_TIMEOUT', 5),
|
||||
'retries' => (int) env('VISION_VECTOR_GATEWAY_RETRIES', 1),
|
||||
'retry_delay_ms' => (int) env('VISION_VECTOR_GATEWAY_RETRY_DELAY_MS', 250),
|
||||
|
||||
// Tighter budget for the read/search path, which runs synchronously inside
|
||||
// web requests (unlike upsert/delete, which only run from queued/console
|
||||
// indexing jobs). Keeps a slow or unreachable gateway from pinning FPM
|
||||
// workers for 20s+ per request.
|
||||
'search_timeout_seconds' => (int) env('VISION_VECTOR_GATEWAY_SEARCH_TIMEOUT', 6),
|
||||
'search_connect_timeout_seconds' => (int) env('VISION_VECTOR_GATEWAY_SEARCH_CONNECT_TIMEOUT', 2),
|
||||
'search_retries' => (int) env('VISION_VECTOR_GATEWAY_SEARCH_RETRIES', 0),
|
||||
|
||||
// Once a search call fails, stop attempting new ones for this many seconds
|
||||
// (circuit breaker) — avoids every concurrent request for a different
|
||||
// artwork independently blocking on the same downed/slow gateway.
|
||||
'circuit_breaker_seconds' => (int) env('VISION_VECTOR_GATEWAY_CIRCUIT_SECONDS', 30),
|
||||
'upsert_endpoint' => env('VISION_VECTOR_GATEWAY_UPSERT_ENDPOINT', '/vectors/upsert'),
|
||||
'upsert_file_endpoint' => env('VISION_VECTOR_GATEWAY_UPSERT_FILE_ENDPOINT', '/vectors/upsert/file'),
|
||||
'search_endpoint' => env('VISION_VECTOR_GATEWAY_SEARCH_ENDPOINT', '/vectors/search'),
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('security_reports', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('status', 32)->default('completed');
|
||||
$table->timestamp('started_at')->nullable();
|
||||
$table->timestamp('finished_at')->nullable();
|
||||
|
||||
$table->unsignedInteger('composer_critical')->default(0);
|
||||
$table->unsignedInteger('composer_high')->default(0);
|
||||
$table->unsignedInteger('composer_medium')->default(0);
|
||||
$table->unsignedInteger('composer_low')->default(0);
|
||||
$table->unsignedInteger('composer_unknown')->default(0);
|
||||
|
||||
$table->unsignedInteger('npm_critical')->default(0);
|
||||
$table->unsignedInteger('npm_high')->default(0);
|
||||
$table->unsignedInteger('npm_moderate')->default(0);
|
||||
$table->unsignedInteger('npm_low')->default(0);
|
||||
$table->unsignedInteger('npm_info')->default(0);
|
||||
$table->unsignedInteger('npm_unknown')->default(0);
|
||||
|
||||
$table->unsignedInteger('total_critical')->default(0);
|
||||
$table->unsignedInteger('total_high')->default(0);
|
||||
$table->unsignedInteger('total_medium')->default(0);
|
||||
$table->unsignedInteger('total_low')->default(0);
|
||||
$table->unsignedInteger('total_unknown')->default(0);
|
||||
|
||||
$table->unsignedInteger('composer_outdated_count')->default(0);
|
||||
$table->unsignedInteger('npm_outdated_count')->default(0);
|
||||
|
||||
$table->json('summary')->nullable();
|
||||
$table->json('composer_audit')->nullable();
|
||||
$table->json('composer_outdated')->nullable();
|
||||
$table->json('npm_audit')->nullable();
|
||||
$table->json('npm_outdated')->nullable();
|
||||
|
||||
$table->longText('error_message')->nullable();
|
||||
|
||||
$table->string('triggered_by', 64)->nullable();
|
||||
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('status');
|
||||
$table->index('finished_at');
|
||||
$table->index(['total_critical', 'total_high']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('security_reports');
|
||||
}
|
||||
};
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('artworks', function (Blueprint $table): void {
|
||||
if (! Schema::hasColumn('artworks', 'featured_thumbnail_variants_json')) {
|
||||
$table->json('featured_thumbnail_variants_json')
|
||||
->nullable()
|
||||
->after('thumbnails_checked_at');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('artworks', 'featured_thumbnails_checked_at')) {
|
||||
$table->timestamp('featured_thumbnails_checked_at')
|
||||
->nullable()
|
||||
->after('featured_thumbnail_variants_json');
|
||||
}
|
||||
});
|
||||
|
||||
Schema::table('artworks', function (Blueprint $table): void {
|
||||
if (Schema::hasColumn('artworks', 'featured_thumbnails_checked_at')) {
|
||||
$table->index('featured_thumbnails_checked_at', 'artworks_featured_thumbnails_checked_idx');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('artworks', function (Blueprint $table): void {
|
||||
try {
|
||||
$table->dropIndex('artworks_featured_thumbnails_checked_idx');
|
||||
} catch (Throwable) {
|
||||
}
|
||||
|
||||
$columns = [];
|
||||
|
||||
foreach (['featured_thumbnail_variants_json', 'featured_thumbnails_checked_at'] as $column) {
|
||||
if (Schema::hasColumn('artworks', $column)) {
|
||||
$columns[] = $column;
|
||||
}
|
||||
}
|
||||
|
||||
if ($columns !== []) {
|
||||
$table->dropColumn($columns);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
# -----------------------------------------------------------------------
|
||||
# Rate limiting for /search and the AI vector-search endpoints
|
||||
#
|
||||
# Laravel already throttles these routes at the application layer
|
||||
# (see App\Providers\AppServiceProvider::configureSearchRateLimiter /
|
||||
# configureVectorSearchRateLimiter), but that still costs one PHP-FPM
|
||||
# worker per request just to run the rate limiter and reject the
|
||||
# request. This nginx-level limiter rejects floods with a 503 before
|
||||
# they ever reach FPM, which is what actually protects worker capacity
|
||||
# during a scripted flood or bot storm.
|
||||
#
|
||||
# Setup:
|
||||
# 1. Add the `limit_req_zone` and `limit_req_status` lines to the
|
||||
# `http {}` block (nginx.conf or conf.d/00-rate-limit-zones.conf) —
|
||||
# zones MUST be declared at http level, not inside server {}.
|
||||
# 2. Include the `location` blocks below inside the relevant
|
||||
# `server {}` block, ABOVE the general `location ~ \.php$` /
|
||||
# PHP-FPM passthrough.
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
# --- Add to the http {} block ---------------------------------------------
|
||||
# limit_req_zone $binary_remote_addr zone=search_zone:10m rate=20r/m;
|
||||
# limit_req_zone $binary_remote_addr zone=ai_search_zone:10m rate=10r/m;
|
||||
# limit_req_status 429;
|
||||
# limit_req_log_level warn;
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Human search traffic: /search page + /api/search/* (Meilisearch-backed,
|
||||
# cheap once app-level caching is warm — burst allowance covers pagination
|
||||
# clicks / autocomplete without tripping on normal use).
|
||||
location ~ ^/(search|api/search) {
|
||||
limit_req zone=search_zone burst=15 nodelay;
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
||||
# AI similarity / image-search endpoints: each request can trigger an
|
||||
# outbound HTTP call to the vision vector gateway (see
|
||||
# App\Services\Vision\VectorGatewayClient), so keep the burst tight —
|
||||
# a flood here is the "consuming FPM workers" scenario from the slow log.
|
||||
location ~ ^/api/(art/[0-9]+/similar-ai|search/image) {
|
||||
limit_req zone=ai_search_zone burst=5 nodelay;
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,9 @@
|
||||
"build": "vite build && vite build --ssr",
|
||||
"build:ssr": "vite build --ssr",
|
||||
"dev": "vite",
|
||||
"security:audit": "npm audit --audit-level=moderate",
|
||||
"security:audit-json": "npm audit --json",
|
||||
"security:outdated": "npm outdated --json",
|
||||
"test:ui": "vitest run",
|
||||
"test:e2e": "playwright test",
|
||||
"test:routes": "playwright test tests/e2e/routes.spec.ts",
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
User-agent: *
|
||||
Disallow:
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://skinbase.top/sitemap.xml
|
||||
Sitemap: https://skinbase.org/sitemap.xml
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<sitemap>
|
||||
<loc>http://skinbase26.test/sitemaps/artworks.xml</loc>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>http://skinbase26.test/sitemaps/academy-lessons.xml</loc>
|
||||
<lastmod>2026-05-03T19:03:23+02:00</lastmod>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>http://skinbase26.test/sitemaps/academy-prompts.xml</loc>
|
||||
<lastmod>2026-05-03T19:03:23+02:00</lastmod>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>http://skinbase26.test/sitemaps/academy-packs.xml</loc>
|
||||
<lastmod>2026-05-03T19:03:23+02:00</lastmod>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>http://skinbase26.test/sitemaps/academy-challenges.xml</loc>
|
||||
<lastmod>2026-05-03T19:03:23+02:00</lastmod>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>http://skinbase26.test/sitemaps/users.xml</loc>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>http://skinbase26.test/sitemaps/tags.xml</loc>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>http://skinbase26.test/sitemaps/categories.xml</loc>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>http://skinbase26.test/sitemaps/collections.xml</loc>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>http://skinbase26.test/sitemaps/cards.xml</loc>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>http://skinbase26.test/sitemaps/stories.xml</loc>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>http://skinbase26.test/sitemaps/news.xml</loc>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>http://skinbase26.test/sitemaps/news-google.xml</loc>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>http://skinbase26.test/sitemaps/forum-index.xml</loc>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>http://skinbase26.test/sitemaps/forum-categories.xml</loc>
|
||||
<lastmod>2026-05-03T19:00:04+02:00</lastmod>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>http://skinbase26.test/sitemaps/forum-threads.xml</loc>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>http://skinbase26.test/sitemaps/static-pages.xml</loc>
|
||||
</sitemap>
|
||||
</sitemapindex>
|
||||
@@ -48,6 +48,7 @@ const buildAdminNavGroups = (isAdmin) => [
|
||||
label: 'System',
|
||||
items: [
|
||||
...(isAdmin ? [{ label: 'Auth Audit', href: '/moderation/auth-audit', icon: 'fa-solid fa-user-shield' }] : []),
|
||||
...(isAdmin ? [{ label: 'Security Report', href: '/moderation/system/security-report', icon: 'fa-solid fa-shield-virus' }] : []),
|
||||
{ label: 'Settings', href: '/moderation/settings', icon: 'fa-solid fa-gear' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
import { Head, Link, useForm, usePage } from '@inertiajs/react'
|
||||
import { Link, useForm, usePage } from '@inertiajs/react'
|
||||
import AccessBadge from '../../../components/academy/billing/AccessBadge'
|
||||
import SeoHead from '../../../components/seo/SeoHead'
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return null
|
||||
@@ -11,7 +12,7 @@ function formatDate(iso) {
|
||||
}
|
||||
}
|
||||
|
||||
export default function AcademyBillingAccount({ currentTier, isSubscribed, subscription, activePlan = null, links = {} }) {
|
||||
export default function AcademyBillingAccount({ seo, currentTier, isSubscribed, subscription, activePlan = null, links = {} }) {
|
||||
const { flash, auth } = usePage().props
|
||||
const { data, setData, post, processing } = useForm({
|
||||
issue_type: 'billing',
|
||||
@@ -84,7 +85,7 @@ export default function AcademyBillingAccount({ currentTier, isSubscribed, subsc
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.14),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(251,191,36,0.14),_transparent_26%),linear-gradient(180deg,_#07111f_0%,_#0f172a_45%,_#111827_100%)] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<Head title="Academy Subscription" />
|
||||
<SeoHead seo={seo || {}} title="Academy Subscription" />
|
||||
|
||||
<div className="mx-auto max-w-[1280px] space-y-8">
|
||||
{flash?.error ? (
|
||||
@@ -280,4 +281,4 @@ export default function AcademyBillingAccount({ currentTier, isSubscribed, subsc
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React from 'react'
|
||||
import { Head, Link } from '@inertiajs/react'
|
||||
import { Link } from '@inertiajs/react'
|
||||
import SeoHead from '../../../components/seo/SeoHead'
|
||||
|
||||
export default function AcademyBillingCancel({ message, links = {} }) {
|
||||
export default function AcademyBillingCancel({ seo, message, links = {} }) {
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(251,191,36,0.14),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(148,163,184,0.14),_transparent_26%),linear-gradient(180deg,_#07111f_0%,_#0f172a_45%,_#111827_100%)] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<Head title="Academy Billing Canceled" />
|
||||
<SeoHead seo={seo || {}} title="Academy Billing Canceled" />
|
||||
|
||||
<div className="mx-auto max-w-[920px] space-y-8">
|
||||
<section className="rounded-[40px] border border-white/10 bg-[linear-gradient(135deg,rgba(7,17,31,0.95),rgba(12,24,45,0.9),rgba(67,20,7,0.78))] p-8 shadow-[0_32px_100px_rgba(2,6,23,0.42)] md:p-10">
|
||||
@@ -20,4 +21,4 @@ export default function AcademyBillingCancel({ message, links = {} }) {
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import React from 'react'
|
||||
import { Head, Link, usePage, useForm } from '@inertiajs/react'
|
||||
import { Link, usePage, useForm } from '@inertiajs/react'
|
||||
import AccessBadge from '../../../components/academy/billing/AccessBadge'
|
||||
import SeoHead from '../../../components/seo/SeoHead'
|
||||
|
||||
export default function AcademyBillingSuccess({ currentTier, isSubscribed, links = {} }) {
|
||||
export default function AcademyBillingSuccess({ seo, currentTier, isSubscribed, links = {} }) {
|
||||
const { auth } = usePage().props
|
||||
const sessionId = usePage().props.sessionId || null
|
||||
const userEmail = auth?.user?.email ?? null
|
||||
const { data, setData, post, processing } = useForm({ message: '', session_id: sessionId })
|
||||
return (
|
||||
<main className="flex min-h-screen items-center bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.14),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(16,185,129,0.14),_transparent_24%),linear-gradient(180deg,_#07111f_0%,_#0f172a_45%,_#111827_100%)] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<Head title="Subscription Confirmed" />
|
||||
<SeoHead seo={seo || {}} title="Academy Subscription Confirmed" />
|
||||
|
||||
<div className="mx-auto w-full max-w-[640px] space-y-6">
|
||||
<section className="rounded-[40px] border border-emerald-300/20 bg-[linear-gradient(135deg,rgba(7,17,31,0.95),rgba(12,24,45,0.92),rgba(6,78,59,0.82))] p-8 shadow-[0_32px_100px_rgba(2,6,23,0.42)] md:p-10">
|
||||
@@ -65,4 +66,4 @@ export default function AcademyBillingSuccess({ currentTier, isSubscribed, links
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import { Head, useForm } from '@inertiajs/react'
|
||||
import { useForm } from '@inertiajs/react'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
|
||||
@@ -17,7 +17,6 @@ export default function AcademyChallengeSubmit({ seo, challenge, artworks, submi
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.15),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(251,191,36,0.16),_transparent_24%),linear-gradient(180deg,_#0f172a_0%,_#111827_100%)] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<Head title={`Submit to ${challenge.title}`} />
|
||||
<SeoHead seo={seo || {}} title={`Submit to ${challenge.title}`} description={challenge.excerpt || challenge.description} />
|
||||
|
||||
<div className="mx-auto max-w-[960px] space-y-6">
|
||||
@@ -65,4 +64,4 @@ export default function AcademyChallengeSubmit({ seo, challenge, artworks, submi
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,13 +106,26 @@ function MetricCard({ label, value, accent }) {
|
||||
}
|
||||
|
||||
function FeaturedCourseCard({ course }) {
|
||||
const cover = course?.cover_image_url || course?.teaser_image_url || course?.cover_image || course?.teaser_image || ''
|
||||
const cover = course?.cover_image_thumb_url || course?.cover_image_url || course?.teaser_image_thumb_url || course?.teaser_image_url || course?.cover_image || course?.teaser_image || ''
|
||||
const coverSrcSet = course?.cover_image_srcset || course?.teaser_image_srcset || ''
|
||||
|
||||
return (
|
||||
<Link href={course.public_url} className="group relative overflow-hidden rounded-[28px] border border-sky-200/12 bg-[linear-gradient(180deg,rgba(15,23,42,0.9),rgba(15,23,42,0.72))] transition hover:-translate-y-1 hover:border-sky-300/24 hover:shadow-[0_24px_72px_rgba(2,6,23,0.3)]">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(125,211,252,0.14),transparent_24%),linear-gradient(135deg,transparent_0%,transparent_48%,rgba(125,211,252,0.05)_48%,rgba(125,211,252,0.05)_52%,transparent_52%,transparent_100%)] opacity-80" />
|
||||
<div className="relative h-44 overflow-hidden bg-[linear-gradient(135deg,rgba(14,165,233,0.24),rgba(15,23,42,0.92))]">
|
||||
{cover ? <img src={cover} alt="" aria-hidden="true" className="h-full w-full object-cover" /> : null}
|
||||
{cover ? (
|
||||
<img
|
||||
src={cover}
|
||||
srcSet={coverSrcSet || undefined}
|
||||
sizes="(min-width: 1280px) 31vw, (min-width: 768px) 50vw, 100vw"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
fetchPriority="low"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,transparent,rgba(2,6,23,0.82))]" />
|
||||
<div className="absolute left-4 top-4 flex flex-wrap gap-2">
|
||||
<span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-100">{course.difficulty}</span>
|
||||
@@ -552,4 +565,4 @@ export default function AcademyIndex({ seo, pricingUrl, academyAccess = null, li
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -628,7 +628,7 @@ function PromptFilledExampleCard({ example, analytics, contentId, index }) {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{example?.prompt ? <pre className="mt-5 whitespace-pre-wrap rounded-[24px] border border-white/10 bg-slate-950/80 p-4 text-sm leading-7 text-slate-100">{example.prompt}</pre> : null}
|
||||
{example?.prompt ? <pre className="mt-5 max-w-full overflow-x-auto whitespace-pre-wrap break-words rounded-[24px] border border-white/10 bg-slate-950/80 p-4 text-sm leading-7 text-slate-100 [overflow-wrap:anywhere]">{example.prompt}</pre> : null}
|
||||
|
||||
{example?.negative_prompt ? (
|
||||
<div className="mt-4 rounded-[24px] border border-white/10 bg-slate-950/60 p-4">
|
||||
@@ -803,7 +803,7 @@ function PromptHelperPromptCard({ helperPrompt, analytics, contentId }) {
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Prompt text</p>
|
||||
<PromptCopyButton prompt={helperPrompt.prompt} label="Copy helper prompt" analytics={analytics} contentId={contentId} eventType="academy_prompt_helper_copy" metadata={{ copy_type: 'helper_prompt', helper_prompt_title: helperPrompt.title || '', source: 'prompt_helper' }} />
|
||||
</div>
|
||||
<pre className="mt-4 whitespace-pre-wrap rounded-[22px] border border-white/10 bg-slate-950/70 p-4 text-sm leading-7 text-slate-100">{helperPrompt.prompt}</pre>
|
||||
<pre className="mt-4 max-w-full overflow-x-auto whitespace-pre-wrap break-words rounded-[22px] border border-white/10 bg-slate-950/70 p-4 text-sm leading-7 text-slate-100 [overflow-wrap:anywhere]">{helperPrompt.prompt}</pre>
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
@@ -839,7 +839,7 @@ function PromptVariantCard({ variant, analytics, contentId }) {
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Variant prompt</p>
|
||||
<PromptCopyButton prompt={variant.prompt} label="Copy variant" analytics={analytics} contentId={contentId} eventType="academy_prompt_variant_copy" metadata={{ copy_type: 'prompt_variant', variant_title: variant.title || '', source: 'prompt_variant' }} />
|
||||
</div>
|
||||
<pre className="mt-4 whitespace-pre-wrap rounded-[22px] border border-white/10 bg-slate-950/70 p-4 text-sm leading-7 text-slate-100">{variant.prompt}</pre>
|
||||
<pre className="mt-4 max-w-full overflow-x-auto whitespace-pre-wrap break-words rounded-[22px] border border-white/10 bg-slate-950/70 p-4 text-sm leading-7 text-slate-100 [overflow-wrap:anywhere]">{variant.prompt}</pre>
|
||||
</div>
|
||||
|
||||
{variant.negative_prompt ? (
|
||||
@@ -848,7 +848,7 @@ function PromptVariantCard({ variant, analytics, contentId }) {
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Negative prompt</p>
|
||||
<PromptCopyButton prompt={variant.negative_prompt} label="Copy negative" analytics={analytics} contentId={contentId} eventType="academy_prompt_variant_negative_copy" metadata={{ copy_type: 'prompt_variant_negative', variant_title: variant.title || '', source: 'prompt_variant' }} />
|
||||
</div>
|
||||
<pre className="mt-4 whitespace-pre-wrap rounded-[22px] border border-white/10 bg-slate-950/70 p-4 text-sm leading-7 text-slate-200">{variant.negative_prompt}</pre>
|
||||
<pre className="mt-4 max-w-full overflow-x-auto whitespace-pre-wrap break-words rounded-[22px] border border-white/10 bg-slate-950/70 p-4 text-sm leading-7 text-slate-200 [overflow-wrap:anywhere]">{variant.negative_prompt}</pre>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1082,7 +1082,7 @@ function AiComparisonSection({ block }) {
|
||||
</div>
|
||||
<PromptCopyButton prompt={payload.prompt} />
|
||||
</div>
|
||||
<pre className="mt-4 whitespace-pre-wrap rounded-[22px] border border-white/10 bg-slate-950/70 p-4 text-sm leading-7 text-slate-100">{payload.prompt}</pre>
|
||||
<pre className="mt-4 max-w-full overflow-x-auto whitespace-pre-wrap break-words rounded-[22px] border border-white/10 bg-slate-950/70 p-4 text-sm leading-7 text-slate-100 [overflow-wrap:anywhere]">{payload.prompt}</pre>
|
||||
{hasNegativePrompt ? (
|
||||
<div className="mt-4 rounded-[22px] border border-white/10 bg-white/[0.03] p-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Negative prompt</p>
|
||||
@@ -1735,7 +1735,7 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(59,130,246,0.14),_transparent_26%),linear-gradient(180deg,_#0b1220_0%,_#111827_46%,_#0f172a_100%)] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<SeoHead seo={seo || {}} title={item?.title} description={item?.excerpt || item?.description} />
|
||||
|
||||
<div className="mx-auto max-w-[1320px] space-y-6">
|
||||
<div className="mx-auto min-w-0 max-w-[1320px] space-y-6">
|
||||
{flash.success ? <div className="rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-100">{flash.success}</div> : null}
|
||||
{flash.error ? <div className="rounded-2xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm text-rose-100">{flash.error}</div> : null}
|
||||
|
||||
@@ -2017,13 +2017,13 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
|
||||
</div>
|
||||
</div>
|
||||
) : pageType === 'prompt' ? (
|
||||
<div className="space-y-8">
|
||||
<section className="relative overflow-hidden rounded-[40px] border border-rose-200/12 bg-[linear-gradient(150deg,rgba(244,63,94,0.14),rgba(15,23,42,0.96)_36%,rgba(45,212,191,0.14))] shadow-[0_24px_90px_rgba(15,23,42,0.34)]">
|
||||
<div className="min-w-0 max-w-full space-y-8 overflow-x-clip">
|
||||
<section className="relative w-full min-w-0 max-w-full overflow-hidden rounded-[40px] border border-rose-200/12 bg-[linear-gradient(150deg,rgba(244,63,94,0.14),rgba(15,23,42,0.96)_36%,rgba(45,212,191,0.14))] shadow-[0_24px_90px_rgba(15,23,42,0.34)]">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_14%,rgba(251,113,133,0.15),transparent_24%),linear-gradient(90deg,rgba(255,255,255,0.04)_1px,transparent_1px),linear-gradient(180deg,rgba(255,255,255,0.04)_1px,transparent_1px)] bg-[length:auto,24px_24px,24px_24px] opacity-75" />
|
||||
<div className="absolute -left-8 top-10 h-36 w-36 rounded-full bg-rose-300/16 blur-3xl" />
|
||||
<div className="absolute -right-10 bottom-0 h-40 w-40 rounded-full bg-cyan-300/14 blur-3xl" />
|
||||
|
||||
<div className="relative grid gap-6 p-5 md:p-6 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.72fr)] lg:p-7">
|
||||
<div className="relative grid min-w-0 grid-cols-1 gap-6 p-5 md:p-6 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.72fr)] lg:p-7">
|
||||
<div className="min-w-0">
|
||||
{academyBreadcrumbs.length ? (
|
||||
<div className="mb-5">
|
||||
@@ -2092,7 +2092,7 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
|
||||
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:pt-2">
|
||||
<div className="grid min-w-0 w-full gap-4 lg:pt-2">
|
||||
<div className="flex h-full flex-col rounded-[30px] border border-white/10 bg-black/20 p-4 shadow-[0_18px_42px_rgba(2,6,23,0.18)] backdrop-blur-sm md:p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-rose-100/80">Preview artwork</p>
|
||||
@@ -2102,7 +2102,7 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
|
||||
<button
|
||||
type="button"
|
||||
onClick={openPromptPreviewImage}
|
||||
className="group mt-4 flex min-h-[420px] flex-1 flex-col overflow-hidden rounded-[28px] border border-white/10 bg-black/30 text-left shadow-[0_24px_80px_rgba(2,6,23,0.26)] transition hover:border-sky-300/25 focus:outline-none focus:ring-2 focus:ring-sky-300/35 lg:min-h-[640px]"
|
||||
className="group mt-4 flex min-h-[420px] w-full min-w-0 flex-1 flex-col overflow-hidden rounded-[28px] border border-white/10 bg-black/30 text-left shadow-[0_24px_80px_rgba(2,6,23,0.26)] transition hover:border-sky-300/25 focus:outline-none focus:ring-2 focus:ring-sky-300/35 lg:min-h-[640px]"
|
||||
disabled={!promptPreviewImage}
|
||||
aria-label={promptPreviewImage ? `Open preview image for ${item.title}` : 'Preview image unavailable'}
|
||||
>
|
||||
@@ -2135,8 +2135,8 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div className="space-y-8">
|
||||
<div className="grid min-w-0 grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div className="min-w-0 space-y-8">
|
||||
{!promptHasFullAccess && (promptPreviewImage || promptPublicExamples.length) ? (
|
||||
<section className="academy-public-examples rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.045),rgba(148,163,184,0.03))] p-6 text-slate-200 shadow-[0_24px_70px_rgba(2,6,23,0.2)] md:p-8">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
@@ -2228,7 +2228,7 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<pre className="mt-4 whitespace-pre-wrap rounded-[24px] border border-white/10 bg-slate-950/80 p-4 text-sm leading-7 text-slate-100 md:p-5">{promptBody || 'Prompt text is not available yet.'}</pre>
|
||||
<pre className="mt-4 max-w-full overflow-x-auto whitespace-pre-wrap break-words rounded-[24px] border border-white/10 bg-slate-950/80 p-4 text-sm leading-7 text-slate-100 [overflow-wrap:anywhere] md:p-5">{promptBody || 'Prompt text is not available yet.'}</pre>
|
||||
{!promptHasFullAccess ? (
|
||||
<div className="mt-4 rounded-[24px] border border-amber-300/20 bg-amber-300/10 p-4 text-amber-50">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-amber-100/80">{promptUnlockTitle || 'Unlock the full prompt'}</p>
|
||||
@@ -2251,7 +2251,7 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
|
||||
metadata={{ copy_type: 'negative_prompt', source: 'prompt_body' }}
|
||||
/>
|
||||
</div>
|
||||
<pre className="mt-4 whitespace-pre-wrap rounded-[24px] border border-white/10 bg-slate-950/70 p-4 text-sm leading-7 text-slate-200 md:p-5">{item.negative_prompt}</pre>
|
||||
<pre className="mt-4 max-w-full overflow-x-auto whitespace-pre-wrap break-words rounded-[24px] border border-white/10 bg-slate-950/70 p-4 text-sm leading-7 text-slate-200 [overflow-wrap:anywhere] md:p-5">{item.negative_prompt}</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -2401,7 +2401,7 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<aside className="space-y-6 lg:sticky lg:top-6 lg:self-start">
|
||||
<aside className="min-w-0 space-y-6 lg:sticky lg:top-6 lg:self-start">
|
||||
{lessonTags.length ? (
|
||||
<section className="rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.84))] p-6 text-slate-200 shadow-[0_18px_50px_rgba(2,6,23,0.18)]">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Microtags</p>
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import React from 'react'
|
||||
import { Head, router, usePage } from '@inertiajs/react'
|
||||
import AdminLayout from '../../../Layouts/AdminLayout'
|
||||
|
||||
function formatTimestamp(value) {
|
||||
if (!value) {
|
||||
return 'Not finished'
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(value))
|
||||
}
|
||||
|
||||
function SummaryCard({ label, value, tone }) {
|
||||
const tones = {
|
||||
red: 'border-rose-400/15 bg-rose-500/10 text-rose-100',
|
||||
orange: 'border-orange-400/15 bg-orange-500/10 text-orange-100',
|
||||
yellow: 'border-amber-400/15 bg-amber-500/10 text-amber-100',
|
||||
blue: 'border-sky-400/15 bg-sky-500/10 text-sky-100',
|
||||
slate: 'border-white/[0.07] bg-white/[0.02] text-white',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rounded-2xl border p-5 ${tones[tone] ?? tones.slate}`}>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">{label}</p>
|
||||
<p className="mt-3 text-3xl font-semibold tracking-tight">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RiskBadge({ label }) {
|
||||
const tone = label === 'Critical'
|
||||
? 'border-rose-400/20 bg-rose-500/10 text-rose-200'
|
||||
: label === 'High'
|
||||
? 'border-orange-400/20 bg-orange-500/10 text-orange-200'
|
||||
: label === 'Medium'
|
||||
? 'border-amber-400/20 bg-amber-500/10 text-amber-200'
|
||||
: label === 'Low'
|
||||
? 'border-sky-400/20 bg-sky-500/10 text-sky-200'
|
||||
: 'border-emerald-400/20 bg-emerald-500/10 text-emerald-200'
|
||||
|
||||
return <span className={`inline-flex rounded-full border px-2.5 py-1 text-xs font-semibold uppercase tracking-[0.16em] ${tone}`}>{label}</span>
|
||||
}
|
||||
|
||||
export default function SecurityReportIndex({ latest, reports, canRunScan }) {
|
||||
const flash = usePage().props.flash ?? {}
|
||||
const items = reports?.data ?? []
|
||||
|
||||
const runScan = () => {
|
||||
router.post('/moderation/system/security-report/run')
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout title="Security Report" subtitle="Private Composer and npm vulnerability reporting for administrators.">
|
||||
<Head title="Admin · Security Report" />
|
||||
|
||||
<div className="space-y-6">
|
||||
{flash.success ? (
|
||||
<div className="rounded-2xl border border-emerald-400/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100">
|
||||
{flash.success}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{latest ? (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-5">
|
||||
<SummaryCard label="Critical" value={latest.total_critical} tone="red" />
|
||||
<SummaryCard label="High" value={latest.total_high} tone="orange" />
|
||||
<SummaryCard label="Medium" value={latest.total_medium} tone="yellow" />
|
||||
<SummaryCard label="Low" value={latest.total_low} tone="blue" />
|
||||
<SummaryCard label="Unknown" value={latest.total_unknown} tone="slate" />
|
||||
</div>
|
||||
|
||||
<div className="rounded-[28px] border border-white/[0.07] bg-white/[0.02] p-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Latest scan</p>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<RiskBadge label={latest.risk_label} />
|
||||
<span className="text-sm text-slate-400">{latest.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
{canRunScan ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={runScan}
|
||||
className="rounded-2xl bg-rose-500/80 px-5 py-3 text-sm font-semibold text-white transition hover:bg-rose-500"
|
||||
>
|
||||
Run Scan
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<dl className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div>
|
||||
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Started</dt>
|
||||
<dd className="mt-2 text-sm text-white">{formatTimestamp(latest.started_at)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Finished</dt>
|
||||
<dd className="mt-2 text-sm text-white">{formatTimestamp(latest.finished_at)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Triggered by</dt>
|
||||
<dd className="mt-2 text-sm text-white">{latest.triggered_by || 'Unknown'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Composer outdated</dt>
|
||||
<dd className="mt-2 text-sm text-white">{latest.composer_outdated_count}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">npm outdated</dt>
|
||||
<dd className="mt-2 text-sm text-white">{latest.npm_outdated_count}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Requested by</dt>
|
||||
<dd className="mt-2 text-sm text-white">{latest.user ? (latest.user.username ? `@${latest.user.username}` : latest.user.name) : 'System'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="mt-6">
|
||||
<a href={latest.show_url} className="inline-flex rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-medium text-white transition hover:bg-white/[0.08]">
|
||||
View Details
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-[28px] border border-amber-400/20 bg-amber-500/10 p-6 text-amber-100">
|
||||
<p className="text-sm font-semibold">No security report has been generated yet.</p>
|
||||
<p className="mt-2 text-sm text-amber-50/80">Run <code className="rounded bg-black/20 px-1.5 py-0.5">php artisan security:scan --notify</code> or use the manual scan button after the queue worker is running.</p>
|
||||
{canRunScan ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={runScan}
|
||||
className="mt-4 rounded-2xl bg-rose-500/80 px-5 py-3 text-sm font-semibold text-white transition hover:bg-rose-500"
|
||||
>
|
||||
Run Scan
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-[28px] border border-white/[0.07] bg-white/[0.02]">
|
||||
<div className="border-b border-white/[0.06] px-5 py-4">
|
||||
<h2 className="text-sm font-bold uppercase tracking-[0.18em] text-slate-400">Scan History</h2>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[980px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.07] text-left text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">
|
||||
<th className="px-5 py-4">ID</th>
|
||||
<th className="px-5 py-4">Finished</th>
|
||||
<th className="px-5 py-4">Status</th>
|
||||
<th className="px-5 py-4">Risk</th>
|
||||
<th className="px-5 py-4">Critical</th>
|
||||
<th className="px-5 py-4">High</th>
|
||||
<th className="px-5 py-4">Medium</th>
|
||||
<th className="px-5 py-4">Low</th>
|
||||
<th className="px-5 py-4">Outdated</th>
|
||||
<th className="px-5 py-4"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.05]">
|
||||
{items.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={10} className="px-5 py-12 text-center text-slate-500">No reports recorded yet.</td>
|
||||
</tr>
|
||||
) : items.map((report) => (
|
||||
<tr key={report.id} className="transition hover:bg-white/[0.025]">
|
||||
<td className="px-5 py-4 text-white">#{report.id}</td>
|
||||
<td className="px-5 py-4 text-slate-300">{formatTimestamp(report.finished_at)}</td>
|
||||
<td className="px-5 py-4 text-slate-300">{report.status}</td>
|
||||
<td className="px-5 py-4"><RiskBadge label={report.risk_label} /></td>
|
||||
<td className="px-5 py-4 text-slate-300">{report.total_critical}</td>
|
||||
<td className="px-5 py-4 text-slate-300">{report.total_high}</td>
|
||||
<td className="px-5 py-4 text-slate-300">{report.total_medium}</td>
|
||||
<td className="px-5 py-4 text-slate-300">{report.total_low}</td>
|
||||
<td className="px-5 py-4 text-slate-300">Composer: {report.composer_outdated_count}, npm: {report.npm_outdated_count}</td>
|
||||
<td className="px-5 py-4">
|
||||
<a href={report.show_url} className="text-sky-200 transition hover:text-sky-100">Details</a>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{reports?.last_page > 1 ? (
|
||||
<div className="flex items-center justify-between border-t border-white/[0.06] px-5 py-4">
|
||||
<p className="text-xs text-slate-500">Showing {reports.from}–{reports.to} of {reports.total} reports</p>
|
||||
<div className="flex gap-1">
|
||||
{reports.links.map((link, index) => (
|
||||
link.url ? (
|
||||
<button
|
||||
key={`${link.label}-${index}`}
|
||||
type="button"
|
||||
onClick={() => router.get(link.url, {}, { preserveScroll: true, preserveState: true })}
|
||||
className={`rounded-lg px-3 py-1.5 text-xs transition ${link.active ? 'bg-rose-500/20 font-semibold text-rose-300' : 'text-slate-500 hover:bg-white/[0.06] hover:text-white'}`}
|
||||
dangerouslySetInnerHTML={{ __html: link.label }}
|
||||
/>
|
||||
) : (
|
||||
<span key={`${link.label}-${index}`} className="rounded-lg px-3 py-1.5 text-xs text-slate-700" dangerouslySetInnerHTML={{ __html: link.label }} />
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import React from 'react'
|
||||
import { Head } from '@inertiajs/react'
|
||||
import AdminLayout from '../../../Layouts/AdminLayout'
|
||||
|
||||
function formatTimestamp(value) {
|
||||
if (!value) {
|
||||
return 'Not recorded'
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'medium',
|
||||
}).format(new Date(value))
|
||||
}
|
||||
|
||||
function JsonPanel({ title, value }) {
|
||||
return (
|
||||
<div className="rounded-[28px] border border-white/[0.07] bg-white/[0.02] p-5">
|
||||
<h2 className="text-sm font-bold uppercase tracking-[0.18em] text-slate-400">{title}</h2>
|
||||
<pre className="nova-scrollbar mt-4 max-h-[28rem] overflow-auto rounded-2xl bg-slate-950/80 p-4 text-[11px] leading-5 text-slate-200">
|
||||
{JSON.stringify(value ?? {}, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FindingTable({ title, rows, columns }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[28px] border border-white/[0.07] bg-white/[0.02]">
|
||||
<div className="border-b border-white/[0.06] px-5 py-4">
|
||||
<h2 className="text-sm font-bold uppercase tracking-[0.18em] text-slate-400">{title}</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[900px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.07] text-left text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">
|
||||
{columns.map((column) => (
|
||||
<th key={column.key} className="px-5 py-4">{column.label}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.05]">
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-5 py-12 text-center text-slate-500">No entries in this section.</td>
|
||||
</tr>
|
||||
) : rows.map((row, index) => (
|
||||
<tr key={`${row.package}-${index}`} className="align-top transition hover:bg-white/[0.025]">
|
||||
{columns.map((column) => (
|
||||
<td key={column.key} className="px-5 py-4 text-slate-300 break-words">
|
||||
{row[column.key] || '—'}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SecurityReportShow({ report }) {
|
||||
return (
|
||||
<AdminLayout title={`Security Report #${report.id}`} subtitle="Private dependency audit details for administrators only.">
|
||||
<Head title={`Admin · Security Report #${report.id}`} />
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-[28px] border border-white/[0.07] bg-white/[0.02] p-6">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Status</p>
|
||||
<p className="mt-2 text-sm text-white">{report.status}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Risk</p>
|
||||
<p className="mt-2 text-sm text-white">{report.risk_label}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Started</p>
|
||||
<p className="mt-2 text-sm text-white">{formatTimestamp(report.started_at)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Finished</p>
|
||||
<p className="mt-2 text-sm text-white">{formatTimestamp(report.finished_at)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Triggered by</p>
|
||||
<p className="mt-2 text-sm text-white">{report.triggered_by || 'Unknown'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Requested by</p>
|
||||
<p className="mt-2 text-sm text-white">{report.user ? (report.user.username ? `@${report.user.username}` : report.user.name) : 'System'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Composer outdated</p>
|
||||
<p className="mt-2 text-sm text-white">{report.composer_outdated_count}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">npm outdated</p>
|
||||
<p className="mt-2 text-sm text-white">{report.npm_outdated_count}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{report.error_message ? (
|
||||
<div className="mt-6 rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
|
||||
{report.error_message}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-6">
|
||||
<a href={report.index_url} className="inline-flex rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-medium text-white transition hover:bg-white/[0.08]">
|
||||
Back to Reports
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-5">
|
||||
<div className="rounded-2xl border border-rose-400/15 bg-rose-500/10 p-5 text-rose-100">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-rose-200/70">Critical</p>
|
||||
<p className="mt-3 text-3xl font-semibold">{report.total_critical}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-orange-400/15 bg-orange-500/10 p-5 text-orange-100">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-orange-200/70">High</p>
|
||||
<p className="mt-3 text-3xl font-semibold">{report.total_high}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-amber-400/15 bg-amber-500/10 p-5 text-amber-100">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-amber-200/70">Medium</p>
|
||||
<p className="mt-3 text-3xl font-semibold">{report.total_medium}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-sky-400/15 bg-sky-500/10 p-5 text-sky-100">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-sky-200/70">Low</p>
|
||||
<p className="mt-3 text-3xl font-semibold">{report.total_low}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/[0.07] bg-white/[0.02] p-5 text-white">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Unknown</p>
|
||||
<p className="mt-3 text-3xl font-semibold">{report.total_unknown}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FindingTable
|
||||
title="Composer Advisories"
|
||||
rows={report.composer_advisories ?? []}
|
||||
columns={[
|
||||
{ key: 'package', label: 'Package' },
|
||||
{ key: 'severity', label: 'Severity' },
|
||||
{ key: 'title', label: 'Title' },
|
||||
{ key: 'cve', label: 'CVE / Ref' },
|
||||
{ key: 'affected_versions', label: 'Affected versions' },
|
||||
{ key: 'link', label: 'Source URL' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<FindingTable
|
||||
title="npm Vulnerabilities"
|
||||
rows={report.npm_vulnerabilities ?? []}
|
||||
columns={[
|
||||
{ key: 'package', label: 'Package' },
|
||||
{ key: 'severity', label: 'Severity' },
|
||||
{ key: 'title', label: 'Title' },
|
||||
{ key: 'cve', label: 'CVE' },
|
||||
{ key: 'range', label: 'Affected range' },
|
||||
{ key: 'fix_available', label: 'Fix available' },
|
||||
{ key: 'url', label: 'Source URL' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<JsonPanel title="Composer Audit Raw JSON" value={report.composer_audit} />
|
||||
<JsonPanel title="npm Audit Raw JSON" value={report.npm_audit} />
|
||||
<JsonPanel title="Composer Outdated Raw JSON" value={report.composer_outdated} />
|
||||
<JsonPanel title="npm Outdated Raw JSON" value={report.npm_outdated} />
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
@@ -324,7 +324,6 @@ export default function FeaturedArtworksAdmin() {
|
||||
const { props } = usePage()
|
||||
const composerRef = React.useRef(null)
|
||||
const rosterRef = React.useRef(null)
|
||||
const loadMoreRef = React.useRef(null)
|
||||
const endpoints = props.endpoints || {}
|
||||
const capabilities = props.capabilities || {}
|
||||
const seo = props.seo || {}
|
||||
@@ -344,7 +343,7 @@ export default function FeaturedArtworksAdmin() {
|
||||
const [selectedArtwork, setSelectedArtwork] = React.useState(null)
|
||||
const [editingId, setEditingId] = React.useState(null)
|
||||
const [form, setForm] = React.useState(emptyForm())
|
||||
const [visibleCount, setVisibleCount] = React.useState(PAGE_SIZE)
|
||||
const [currentPage, setCurrentPage] = React.useState(1)
|
||||
|
||||
React.useEffect(() => {
|
||||
setEntries(Array.isArray(props.entries) ? props.entries : [])
|
||||
@@ -353,7 +352,7 @@ export default function FeaturedArtworksAdmin() {
|
||||
}, [props.entries, props.stats, props.winner])
|
||||
|
||||
React.useEffect(() => {
|
||||
setVisibleCount(PAGE_SIZE)
|
||||
setCurrentPage(1)
|
||||
}, [deferredListQuery, filter, sortDirection, sortKey])
|
||||
|
||||
function scrollToSection(ref) {
|
||||
@@ -631,27 +630,13 @@ export default function FeaturedArtworksAdmin() {
|
||||
.sort((left, right) => compareEntries(left, right, sortKey, sortDirection))
|
||||
}, [deferredListQuery, entries, filter, sortDirection, sortKey])
|
||||
|
||||
const visibleEntries = React.useMemo(() => filteredEntries.slice(0, visibleCount), [filteredEntries, visibleCount])
|
||||
const hasMoreEntries = visibleEntries.length < filteredEntries.length
|
||||
const totalPages = Math.max(1, Math.ceil(filteredEntries.length / PAGE_SIZE))
|
||||
const visibleEntries = React.useMemo(() => filteredEntries.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE), [filteredEntries, currentPage])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!hasMoreEntries || typeof IntersectionObserver === 'undefined' || !loadMoreRef.current) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver((observerEntries) => {
|
||||
if (observerEntries.some((observerEntry) => observerEntry.isIntersecting)) {
|
||||
setVisibleCount((current) => Math.min(current + PAGE_SIZE, filteredEntries.length))
|
||||
}
|
||||
}, { rootMargin: '320px 0px' })
|
||||
|
||||
observer.observe(loadMoreRef.current)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [filteredEntries.length, hasMoreEntries, visibleCount])
|
||||
|
||||
function loadMoreEntries() {
|
||||
setVisibleCount((current) => Math.min(current + PAGE_SIZE, filteredEntries.length))
|
||||
function goToPage(next) {
|
||||
const page = Math.max(1, Math.min(totalPages, next))
|
||||
setCurrentPage(page)
|
||||
if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -900,20 +885,27 @@ export default function FeaturedArtworksAdmin() {
|
||||
</article>
|
||||
))}
|
||||
|
||||
{hasMoreEntries ? (
|
||||
<div ref={loadMoreRef} className="rounded-[24px] border border-dashed border-white/10 bg-black/20 px-5 py-6 text-center">
|
||||
<div className="text-sm text-slate-300">Loading more rows as you reach the bottom.</div>
|
||||
{filteredEntries.length > PAGE_SIZE ? (
|
||||
<div className="mt-6 flex items-center justify-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadMoreEntries}
|
||||
className="mt-4 rounded-full border border-white/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-slate-100 transition hover:border-white/20 hover:bg-white/5"
|
||||
onClick={() => goToPage(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="rounded-full border border-white/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-slate-100 transition disabled:opacity-50 disabled:cursor-not-allowed hover:border-white/20 hover:bg-white/5"
|
||||
>
|
||||
Load 24 more
|
||||
Previous
|
||||
</button>
|
||||
|
||||
<div className="text-sm text-slate-300">Page {currentPage} of {totalPages}</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goToPage(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="rounded-full border border-white/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-slate-100 transition disabled:opacity-50 disabled:cursor-not-allowed hover:border-white/20 hover:bg-white/5"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
) : filteredEntries.length > PAGE_SIZE ? (
|
||||
<div className="text-center text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">
|
||||
All matching rows loaded
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
@if(request()->hasSession() && ! request()->attributes->get('skinbase.session_skipped'))
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}" />
|
||||
@endif
|
||||
@vite(['resources/js/academy.jsx'])
|
||||
<script type="module" src="{{ Vite::asset('resources/js/academy.jsx') }}"></script>
|
||||
<style>
|
||||
body.page-academy main { padding-top: 4rem; }
|
||||
</style>
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
request()->is(...array_merge(['browse', 'tags', 'tags/*'], $toolbarContentTypeSlugs)) => 'browse',
|
||||
request()->is('groups', 'groups/*') => 'groups',
|
||||
request()->is('creators', 'creators/*', 'stories', 'stories/*', 'following', 'leaderboard') => 'creators',
|
||||
request()->is('academy', 'academy/*') => 'academy',
|
||||
request()->is('forum', 'forum/*', 'news', 'news/*') => 'community',
|
||||
default => null,
|
||||
};
|
||||
@@ -193,6 +194,40 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(config('academy.enabled', true))
|
||||
<div class="relative">
|
||||
<button class="inline-flex items-center gap-1 px-3 py-2 rounded-lg transition-colors {{ $navSection === 'academy' ? 'text-white bg-white/10' : 'hover:text-white hover:bg-white/5' }}"
|
||||
data-dd="academy"
|
||||
{{ $navSection === 'academy' ? 'aria-current=page' : '' }}>
|
||||
Academy
|
||||
<svg class="w-4 h-4 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6" /></svg>
|
||||
</button>
|
||||
<div id="dd-academy" class="dd-menu absolute left-0 mt-1 w-56 rounded-xl bg-panel border border-panel shadow-sb overflow-hidden">
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('academy.index') }}">
|
||||
<i class="fa-solid fa-graduation-cap w-4 text-center text-sb-muted"></i>Academy Home
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('academy.courses.index') }}">
|
||||
<i class="fa-solid fa-road w-4 text-center text-sb-muted"></i>Courses
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('academy.lessons.index') }}">
|
||||
<i class="fa-solid fa-book-open w-4 text-center text-sb-muted"></i>Lessons
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('academy.prompts.index') }}">
|
||||
<i class="fa-solid fa-wand-magic-sparkles w-4 text-center text-sb-muted"></i>Prompts
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('academy.packs.index') }}">
|
||||
<i class="fa-solid fa-box-archive w-4 text-center text-sb-muted"></i>Packs
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('academy.challenges.index') }}">
|
||||
<i class="fa-solid fa-trophy w-4 text-center text-sb-muted"></i>Challenges
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm border-t border-white/5 bg-white/[0.02] hover:bg-white/5" href="{{ route('academy.pricing') }}">
|
||||
<i class="fa-solid fa-credit-card w-4 text-center text-amber-200"></i>Pricing
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- COMMUNITY --}}
|
||||
<div class="relative">
|
||||
<button class="inline-flex items-center gap-1 px-3 py-2 rounded-lg transition-colors {{ $navSection === 'community' ? 'text-white bg-white/10' : 'hover:text-white hover:bg-white/5' }}"
|
||||
@@ -578,6 +613,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(config('academy.enabled', true))
|
||||
<div class="pt-1">
|
||||
<button type="button" data-mobile-section-toggle aria-controls="mobileSectionAcademy" aria-expanded="{{ request()->is('academy', 'academy/*') ? 'true' : 'false' }}" class="w-full flex items-center justify-between py-2.5 px-3 rounded-lg text-[11px] font-semibold uppercase tracking-widest text-sb-muted hover:bg-white/5">
|
||||
<span>Academy</span>
|
||||
<i data-mobile-section-icon class="fa-solid fa-chevron-down text-xs transition-transform {{ request()->is('academy', 'academy/*') ? 'rotate-180' : '' }}"></i>
|
||||
</button>
|
||||
<div id="mobileSectionAcademy" data-mobile-section-panel class="{{ request()->is('academy', 'academy/*') ? '' : 'hidden ' }}mt-0.5 space-y-0.5">
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg {{ request()->routeIs('academy.index') ? 'bg-white/10 text-white' : 'hover:bg-white/5' }}" href="{{ route('academy.index') }}"><i class="fa-solid fa-graduation-cap w-4 text-center text-sb-muted"></i>Academy Home</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg {{ request()->routeIs('academy.courses.*') ? 'bg-white/10 text-white' : 'hover:bg-white/5' }}" href="{{ route('academy.courses.index') }}"><i class="fa-solid fa-road w-4 text-center text-sb-muted"></i>Courses</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg {{ request()->routeIs('academy.lessons.*') ? 'bg-white/10 text-white' : 'hover:bg-white/5' }}" href="{{ route('academy.lessons.index') }}"><i class="fa-solid fa-book-open w-4 text-center text-sb-muted"></i>Lessons</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg {{ request()->routeIs('academy.prompts.*') ? 'bg-white/10 text-white' : 'hover:bg-white/5' }}" href="{{ route('academy.prompts.index') }}"><i class="fa-solid fa-wand-magic-sparkles w-4 text-center text-sb-muted"></i>Prompts</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg {{ request()->routeIs('academy.packs.*') ? 'bg-white/10 text-white' : 'hover:bg-white/5' }}" href="{{ route('academy.packs.index') }}"><i class="fa-solid fa-box-archive w-4 text-center text-sb-muted"></i>Packs</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg {{ request()->routeIs('academy.challenges.*') ? 'bg-white/10 text-white' : 'hover:bg-white/5' }}" href="{{ route('academy.challenges.index') }}"><i class="fa-solid fa-trophy w-4 text-center text-sb-muted"></i>Challenges</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg {{ request()->routeIs('academy.pricing', 'academy.billing.pricing') ? 'bg-white/10 text-white' : 'hover:bg-white/5' }}" href="{{ route('academy.pricing') }}"><i class="fa-solid fa-credit-card w-4 text-center text-sb-muted"></i>Pricing</a>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="pt-1">
|
||||
<button type="button" data-mobile-section-toggle aria-controls="mobileSectionCommunity" aria-expanded="false" class="w-full flex items-center justify-between py-2.5 px-3 rounded-lg text-[11px] font-semibold uppercase tracking-widest text-sb-muted hover:bg-white/5">
|
||||
<span>Community</span>
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ Route::middleware(['web', 'throttle:300,1'])
|
||||
->whereNumber('id')
|
||||
->name('api.art.similar');
|
||||
|
||||
Route::middleware(['web', 'throttle:120,1'])
|
||||
Route::middleware(['web', 'throttle:vector-search'])
|
||||
->get('art/{id}/similar-ai', \App\Http\Controllers\Api\SimilarAiArtworksController::class)
|
||||
->whereNumber('id')
|
||||
->name('api.art.similar-ai');
|
||||
|
||||
+9
-1
@@ -192,7 +192,7 @@ Schedule::command('nova:recalculate-heat')
|
||||
// active scheduler in this app is defined in routes/console.php, not Kernel.
|
||||
|
||||
// Generate static sitemap XML files that nginx can serve directly without PHP.
|
||||
// The generate command writes public/sitemap.xml + public/sitemaps/{name}.xml.
|
||||
// The generate command writes public/sitemaps/sitemap.xml + public/sitemaps/{name}.xml.
|
||||
Schedule::command('skinbase:sitemaps:generate')
|
||||
->cron('30 10,22 * * *')
|
||||
->name('sitemaps-generate')
|
||||
@@ -211,6 +211,14 @@ Schedule::command('skinbase:sitemaps:validate')
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
|
||||
if ((bool) config('security-report.enabled', true)) {
|
||||
Schedule::command('security:scan --notify')
|
||||
->weeklyOn(1, '05:15')
|
||||
->name('security-report-weekly-scan')
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
}
|
||||
|
||||
// Keep the old release-pipeline cleanup running so stale release artifacts are pruned.
|
||||
|
||||
Schedule::job(new \App\Jobs\Sitemaps\CleanupSitemapReleasesJob())
|
||||
|
||||
+13
-1
@@ -419,6 +419,7 @@ Route::get('/collections/program/{programKey}', [\App\Http\Controllers\Web\Colle
|
||||
Route::get('/collections/recommended', [\App\Http\Controllers\Web\CollectionDiscoveryController::class, 'recommended'])
|
||||
->name('collections.recommended');
|
||||
Route::get('/collections/search', [\App\Http\Controllers\Web\CollectionDiscoveryController::class, 'search'])
|
||||
->middleware('throttle:search')
|
||||
->name('collections.search');
|
||||
|
||||
Route::get('/groups', [\App\Http\Controllers\GroupController::class, 'index'])->name('groups.index');
|
||||
@@ -1046,7 +1047,9 @@ require __DIR__.'/auth.php';
|
||||
require __DIR__.'/legacy.php';
|
||||
|
||||
// ── SEARCH ────────────────────────────────────────────────────────────────────
|
||||
Route::get('/search', [\App\Http\Controllers\Web\SearchController::class, 'index'])->name('search');
|
||||
Route::get('/search', [\App\Http\Controllers\Web\SearchController::class, 'index'])
|
||||
->middleware('throttle:search')
|
||||
->name('search');
|
||||
|
||||
// ── MISC ──────────────────────────────────────────────────────────────────────
|
||||
Route::view('/data-deletion', 'privacy.data-deletion')->name('privacy.data_deletion');
|
||||
@@ -1108,6 +1111,14 @@ Route::middleware(['auth', 'admin.access'])
|
||||
Route::get('/uploads', [AdminController::class, 'uploadQueue'])->name('uploads');
|
||||
Route::get('/settings', [AdminController::class, 'settings'])->name('settings');
|
||||
Route::middleware('admin.role')->get('/auth-audit', [AdminController::class, 'authAudit'])->name('auth-audit');
|
||||
Route::middleware('admin.role')
|
||||
->prefix('system/security-report')
|
||||
->name('system.security-report.')
|
||||
->group(function (): void {
|
||||
Route::get('/', [\App\Http\Controllers\Admin\SecurityReportController::class, 'index'])->name('index');
|
||||
Route::post('/run', [\App\Http\Controllers\Admin\SecurityReportController::class, 'run'])->name('run');
|
||||
Route::get('/{securityReport}', [\App\Http\Controllers\Admin\SecurityReportController::class, 'show'])->whereNumber('securityReport')->name('show');
|
||||
});
|
||||
|
||||
Route::middleware(['artwork.maturity.access'])
|
||||
->prefix('ai-biography')
|
||||
@@ -1280,6 +1291,7 @@ Route::middleware(['auth'])
|
||||
->name('feed.saved');
|
||||
|
||||
Route::get('/feed/search', [\App\Http\Controllers\Web\Posts\SearchFeedController::class, 'index'])
|
||||
->middleware('throttle:search')
|
||||
->name('feed.search');
|
||||
|
||||
// ── CONTENT BROWSER (artwork / category universal router) ─────────────────────
|
||||
|
||||
@@ -412,6 +412,10 @@ test('removing a medal dispatches artwork reindexing', function () {
|
||||
});
|
||||
|
||||
test('cache invalidation occurs after medal updates', function () {
|
||||
// HomepageService::clearFeaturedAndMedalCaches() forgets homepage.hero.{segment},
|
||||
// homepage.community-favorites.8.{segment} and homepage.hall-of-fame.8.{segment} for
|
||||
// each viewer-visibility segment (see HomepageService::viewerCacheSegment()) — not the
|
||||
// bare, unsegmented keys this test originally asserted against pre-segmentation.
|
||||
$homepage = app(HomepageService::class);
|
||||
$service = app(ArtworkAwardService::class);
|
||||
$user = User::factory()->create(['created_at' => now()->subDays(30), 'email_verified_at' => now()]);
|
||||
@@ -420,15 +424,24 @@ test('cache invalidation occurs after medal updates', function () {
|
||||
|
||||
Config::set('homepage.guest_payload_key', $guestPayloadKey);
|
||||
|
||||
Cache::put('homepage.hero', ['stale' => true], 600);
|
||||
Cache::put('homepage.community-favorites.8', ['stale' => true], 600);
|
||||
Cache::put('homepage.hall-of-fame.8', ['stale' => true], 600);
|
||||
$segments = ['visibility-hide', 'visibility-blur', 'visibility-show'];
|
||||
$segmentedKeys = collect($segments)
|
||||
->flatMap(fn (string $segment): array => [
|
||||
"homepage.hero.{$segment}",
|
||||
"homepage.community-favorites.8.{$segment}",
|
||||
"homepage.hall-of-fame.8.{$segment}",
|
||||
])
|
||||
->all();
|
||||
|
||||
foreach ($segmentedKeys as $key) {
|
||||
Cache::put($key, ['stale' => true], 600);
|
||||
}
|
||||
Cache::store($homepage->guestPayloadCacheStoreName())->put($guestPayloadKey, ['stale' => true], 600);
|
||||
|
||||
$service->award($artwork, $user, 'gold');
|
||||
|
||||
expect(Cache::get('homepage.hero'))->toBeNull()
|
||||
->and(Cache::get('homepage.community-favorites.8'))->toBeNull()
|
||||
->and(Cache::get('homepage.hall-of-fame.8'))->toBeNull()
|
||||
->and(Cache::store($homepage->guestPayloadCacheStoreName())->get($guestPayloadKey))->toBeNull();
|
||||
foreach ($segmentedKeys as $key) {
|
||||
expect(Cache::get($key))->toBeNull();
|
||||
}
|
||||
expect(Cache::store($homepage->guestPayloadCacheStoreName())->get($guestPayloadKey))->toBeNull();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\GenerateFeaturedArtworkThumbnailsJob;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function makeAuditableArtwork(array $attributes = []): Artwork
|
||||
{
|
||||
return Artwork::factory()->create(array_merge([
|
||||
'user_id' => User::factory()->create()->id,
|
||||
'hash' => str_repeat('a', 64),
|
||||
'file_ext' => 'png',
|
||||
'thumb_ext' => 'webp',
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
test('null featured_thumbnail_variants_json means not audited yet', function () {
|
||||
$artwork = makeAuditableArtwork();
|
||||
|
||||
expect($artwork->featured_thumbnail_variants_json)->toBeNull()
|
||||
->and($artwork->featuredThumbnailAuditState())->toBe(['status' => 'not_audited', 'variants' => []])
|
||||
->and($artwork->hasFeaturedThumbnail())->toBeFalse()
|
||||
->and($artwork->hasFeaturedThumbnail('desktop'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('empty array means audited with no featured variants available', function () {
|
||||
$artwork = makeAuditableArtwork();
|
||||
$artwork->forceFill([
|
||||
'featured_thumbnail_variants_json' => [],
|
||||
'featured_thumbnails_checked_at' => now(),
|
||||
])->saveQuietly();
|
||||
|
||||
$fresh = $artwork->fresh();
|
||||
|
||||
expect($fresh->featured_thumbnail_variants_json)->toBe([])
|
||||
->and($fresh->featuredThumbnailAuditState())->toBe(['status' => 'no_variants', 'variants' => []])
|
||||
->and($fresh->hasFeaturedThumbnail())->toBeFalse();
|
||||
});
|
||||
|
||||
test('a populated array means audited with those variants known to be available', function () {
|
||||
$artwork = makeAuditableArtwork();
|
||||
$artwork->forceFill([
|
||||
'featured_thumbnail_variants_json' => ['desktop', 'desktop_xl'],
|
||||
'featured_thumbnails_checked_at' => now(),
|
||||
])->saveQuietly();
|
||||
|
||||
$fresh = $artwork->fresh();
|
||||
|
||||
expect($fresh->featuredThumbnailAuditState())->toBe(['status' => 'available', 'variants' => ['desktop', 'desktop_xl']])
|
||||
->and($fresh->hasFeaturedThumbnail('desktop'))->toBeTrue()
|
||||
->and($fresh->hasFeaturedThumbnail('desktop_xl'))->toBeTrue()
|
||||
->and($fresh->hasFeaturedThumbnail('mobile'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('malformed json in the column is treated as not audited rather than throwing', function () {
|
||||
$artwork = makeAuditableArtwork();
|
||||
|
||||
// Bypass the array cast to write a raw, non-JSON-array value directly, simulating
|
||||
// legacy or corrupted data that predates this column's introduction.
|
||||
DB::table('artworks')->where('id', $artwork->id)->update([
|
||||
'featured_thumbnail_variants_json' => '"not-an-array"',
|
||||
]);
|
||||
|
||||
$fresh = $artwork->fresh();
|
||||
|
||||
expect($fresh->featuredThumbnailAuditState())->toBe(['status' => 'not_audited', 'variants' => []])
|
||||
->and($fresh->hasFeaturedThumbnail())->toBeFalse();
|
||||
});
|
||||
|
||||
test('non-string entries in the variants array are ignored rather than matched', function () {
|
||||
$artwork = makeAuditableArtwork();
|
||||
|
||||
DB::table('artworks')->where('id', $artwork->id)->update([
|
||||
'featured_thumbnail_variants_json' => json_encode(['desktop', 42, null, ['nested' => true]]),
|
||||
]);
|
||||
|
||||
$fresh = $artwork->fresh();
|
||||
|
||||
expect($fresh->featuredThumbnailAuditState())->toBe(['status' => 'available', 'variants' => ['desktop']])
|
||||
->and($fresh->hasFeaturedThumbnail('desktop'))->toBeTrue()
|
||||
->and($fresh->hasFeaturedThumbnail('tablet'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('an artwork without a hash never reports a featured thumbnail regardless of audit state', function () {
|
||||
$artwork = makeAuditableArtwork(['hash' => null, 'file_ext' => null]);
|
||||
$artwork->forceFill([
|
||||
'featured_thumbnail_variants_json' => ['desktop'],
|
||||
'featured_thumbnails_checked_at' => now(),
|
||||
])->saveQuietly();
|
||||
|
||||
expect($artwork->fresh()->hasFeaturedThumbnail('desktop'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('changing an artwork hash resets stale featured thumbnail audit state and queues regeneration for featured artworks', function () {
|
||||
Queue::fake();
|
||||
|
||||
$artwork = makeAuditableArtwork([
|
||||
'hash' => str_repeat('b', 64),
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$artwork->forceFill([
|
||||
'featured_thumbnail_variants_json' => ['desktop', 'desktop_xl'],
|
||||
'featured_thumbnails_checked_at' => now(),
|
||||
])->saveQuietly();
|
||||
|
||||
DB::table('artwork_features')->insert([
|
||||
'artwork_id' => $artwork->id,
|
||||
'featured_at' => now()->subHour(),
|
||||
'expires_at' => null,
|
||||
'priority' => 500,
|
||||
'label' => null,
|
||||
'note' => null,
|
||||
'is_active' => true,
|
||||
'created_by' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
'deleted_at' => null,
|
||||
]);
|
||||
|
||||
// Old hash reported 2 available variants — that state is now stale because the
|
||||
// object paths it referred to were computed from the old hash.
|
||||
expect($artwork->fresh()->hasFeaturedThumbnail('desktop'))->toBeTrue();
|
||||
|
||||
$artwork->hash = str_repeat('c', 64);
|
||||
$artwork->save();
|
||||
|
||||
$fresh = $artwork->fresh();
|
||||
|
||||
expect($fresh->featured_thumbnail_variants_json)->toBeNull()
|
||||
->and($fresh->featured_thumbnails_checked_at)->toBeNull()
|
||||
->and($fresh->hasFeaturedThumbnail('desktop'))->toBeFalse();
|
||||
|
||||
Queue::assertPushed(GenerateFeaturedArtworkThumbnailsJob::class);
|
||||
});
|
||||
|
||||
test('changing hash on an artwork with no active feature row does not queue regeneration', function () {
|
||||
Queue::fake();
|
||||
|
||||
$artwork = makeAuditableArtwork(['hash' => str_repeat('d', 64)]);
|
||||
$artwork->forceFill([
|
||||
'featured_thumbnail_variants_json' => ['desktop'],
|
||||
'featured_thumbnails_checked_at' => now(),
|
||||
])->saveQuietly();
|
||||
|
||||
$artwork->hash = str_repeat('e', 64);
|
||||
$artwork->save();
|
||||
|
||||
expect($artwork->fresh()->featured_thumbnail_variants_json)->toBeNull();
|
||||
Queue::assertNotPushed(GenerateFeaturedArtworkThumbnailsJob::class);
|
||||
});
|
||||
@@ -10,8 +10,6 @@ use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
use Klevze\ControlPanel\Models\Admin\AdminVerification;
|
||||
use Klevze\ControlPanel\Core\Structs\MenuRootItem;
|
||||
use Klevze\ControlPanel\Framework\Core\Menu as ControlPanelMenu;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@@ -81,20 +79,35 @@ it('blocks non staff users from the featured artworks admin area', function ():
|
||||
->assertRedirect(route('cp.login'));
|
||||
});
|
||||
|
||||
it('registers the featured artworks entry in the cpad menu', function (): void {
|
||||
$sidebarMenu = collect(app(ControlPanelMenu::class)->getSidebarMenu());
|
||||
it('resolves featured artwork admin routes to the current moderation surface, not the legacy cpad menu target', function (): void {
|
||||
// The original version of this test asserted that the legacy cpad sidebar (built by
|
||||
// packages/klevze/Plugins/Artworks/ServiceProvider::boot()) had a "Featured Artworks"
|
||||
// item under the "Artworks" root pointing at admin.cp.artworks.featured.main. That
|
||||
// item was removed once featured-artwork management moved to the /moderation surface
|
||||
// (routes/web.php, name admin.artworks.featured.*) — admin.cp.artworks.featured.main
|
||||
// now exists only as a redirect alias to /moderation/artworks/featured
|
||||
// (packages/klevze/Plugins/Artworks/Routes/admin.php:24), and the new surface's
|
||||
// navigation is owned by the React/Inertia frontend, not this PHP-built sidebar.
|
||||
//
|
||||
// The sidebar itself can't be asserted on here at all: ServiceProvider::boot() only
|
||||
// registers menu items when shouldRegisterControlPanelUi() is true, which explicitly
|
||||
// returns false whenever app()->runningInConsole() is true — and `php artisan test`
|
||||
// always runs in console, so getSidebarMenu() is empty for every test in this suite,
|
||||
// regardless of this change. That's an environment property of the legacy menu
|
||||
// builder, not something this test can meaningfully exercise.
|
||||
//
|
||||
// What's left to verify, and is exercised by other tests in this file (see "renders
|
||||
// the featured artworks admin index..." and "clears homepage hero cache..."), is that
|
||||
// the legacy route still redirects to the canonical surface rather than 404ing or
|
||||
// rendering the retired page directly.
|
||||
expect(route('admin.artworks.featured.main'))->toBe(url('/moderation/artworks/featured'));
|
||||
expect(route('admin.cp.artworks.featured.main'))->toBe(url('/cp/artworks/featured'));
|
||||
|
||||
$editorialRoot = $sidebarMenu
|
||||
->first(fn ($item): bool => $item instanceof MenuRootItem && $item->getName() === 'Artworks');
|
||||
$admin = createControlPanelAdmin();
|
||||
|
||||
expect($editorialRoot)->toBeInstanceOf(MenuRootItem::class);
|
||||
|
||||
$featuredItem = collect($editorialRoot->getItems())
|
||||
->first(fn ($item): bool => ($item->name ?? null) === 'Featured Artworks');
|
||||
|
||||
expect($featuredItem)->not->toBeNull()
|
||||
->and($featuredItem->mainRoute)->toBe('admin.cp.artworks.featured.main')
|
||||
->and($featuredItem->icon)->toBe('fas fa-star');
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->get(route('admin.cp.artworks.featured.main'))
|
||||
->assertRedirect('/moderation/artworks/featured');
|
||||
});
|
||||
|
||||
it('renders the featured artworks admin index with the current winner summary', function (): void {
|
||||
@@ -108,17 +121,20 @@ it('renders the featured artworks admin index with the current winner summary',
|
||||
medalScore($higherMedal, 12);
|
||||
medalScore($runnerUp, 3);
|
||||
|
||||
// admin.cp.artworks.featured.main (legacy cpad alias) now just redirects to the
|
||||
// canonical /moderation surface (packages/klevze/Plugins/Artworks/Routes/admin.php:24),
|
||||
// so the page itself is rendered at admin.artworks.featured.main.
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->get(route('admin.cp.artworks.featured.main'))
|
||||
->get(route('admin.artworks.featured.main'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Collection/FeaturedArtworksAdmin')
|
||||
->component('Moderation/FeaturedArtworks')
|
||||
->where('winner.artwork.id', $higherMedal->id)
|
||||
->where('winner.medals.score_30d', 12)
|
||||
->where('winner.selection_reason', 'Tied on priority, won on higher 30-day medal score.')
|
||||
->where('entries.0.is_winner', true)
|
||||
->where('entries.0.artwork.id', $higherMedal->id)
|
||||
->where('endpoints.store', route('admin.cp.artworks.featured.store')));
|
||||
->where('endpoints.store', route('admin.artworks.featured.store')));
|
||||
});
|
||||
|
||||
it('allows admins to create featured rows', function (): void {
|
||||
@@ -259,11 +275,12 @@ it('marks expired and ineligible rows on the index page', function (): void {
|
||||
featureRow($privateArtwork, ['priority' => 300]);
|
||||
featureRow($expiredArtwork, ['priority' => 200, 'expires_at' => now()->subMinute()]);
|
||||
|
||||
// See note above: the cpad alias redirects, so the page renders at admin.artworks.featured.main.
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->get(route('admin.cp.artworks.featured.main'))
|
||||
->get(route('admin.artworks.featured.main'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Collection/FeaturedArtworksAdmin')
|
||||
->component('Moderation/FeaturedArtworks')
|
||||
->where('entries.0.artwork.id', $privateArtwork->id)
|
||||
->where('entries.0.eligibility.is_eligible', false)
|
||||
->where('entries.0.eligibility.reasons.0', 'Private')
|
||||
@@ -274,10 +291,30 @@ it('marks expired and ineligible rows on the index page', function (): void {
|
||||
});
|
||||
|
||||
it('clears homepage hero cache after create update toggle and delete actions', function (): void {
|
||||
// HomepageService::clearFeaturedAndMedalCaches() forgets homepage.hero.{segment} for
|
||||
// each viewer-visibility segment (not a bare "homepage.hero" key) — the segmentation
|
||||
// was introduced by a prior commit (see HomepageService::viewerCacheSegment()). Assert
|
||||
// against the real segmented keys instead of the pre-segmentation key this test used.
|
||||
$heroCacheKeys = [
|
||||
'homepage.hero.visibility-hide',
|
||||
'homepage.hero.visibility-blur',
|
||||
'homepage.hero.visibility-show',
|
||||
];
|
||||
$seedHeroCache = function () use ($heroCacheKeys): void {
|
||||
foreach ($heroCacheKeys as $key) {
|
||||
Cache::put($key, ['stale' => true], 600);
|
||||
}
|
||||
};
|
||||
$assertHeroCacheCleared = function () use ($heroCacheKeys): void {
|
||||
foreach ($heroCacheKeys as $key) {
|
||||
expect(Cache::has($key))->toBeFalse();
|
||||
}
|
||||
};
|
||||
|
||||
$admin = createControlPanelAdmin();
|
||||
$artwork = adminArtwork();
|
||||
|
||||
Cache::put('homepage.hero', ['stale' => true], 600);
|
||||
$seedHeroCache();
|
||||
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->postJson(route('admin.cp.artworks.featured.store'), [
|
||||
@@ -289,11 +326,11 @@ it('clears homepage hero cache after create update toggle and delete actions', f
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
expect(Cache::has('homepage.hero'))->toBeFalse();
|
||||
$assertHeroCacheCleared();
|
||||
|
||||
$feature = ArtworkFeature::query()->firstOrFail();
|
||||
|
||||
Cache::put('homepage.hero', ['stale' => true], 600);
|
||||
$seedHeroCache();
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->patchJson(route('admin.cp.artworks.featured.update', ['feature' => $feature->id]), [
|
||||
'priority' => 110,
|
||||
@@ -302,17 +339,17 @@ it('clears homepage hero cache after create update toggle and delete actions', f
|
||||
'is_active' => true,
|
||||
])
|
||||
->assertOk();
|
||||
expect(Cache::has('homepage.hero'))->toBeFalse();
|
||||
$assertHeroCacheCleared();
|
||||
|
||||
Cache::put('homepage.hero', ['stale' => true], 600);
|
||||
$seedHeroCache();
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->patchJson(route('admin.cp.artworks.featured.toggle', ['feature' => $feature->id]))
|
||||
->assertOk();
|
||||
expect(Cache::has('homepage.hero'))->toBeFalse();
|
||||
$assertHeroCacheCleared();
|
||||
|
||||
Cache::put('homepage.hero', ['stale' => true], 600);
|
||||
$seedHeroCache();
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->deleteJson(route('admin.cp.artworks.featured.delete', ['feature' => $feature->id]))
|
||||
->assertOk();
|
||||
expect(Cache::has('homepage.hero'))->toBeFalse();
|
||||
$assertHeroCacheCleared();
|
||||
});
|
||||
@@ -6,6 +6,7 @@ use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use App\Services\ArtworkService;
|
||||
use App\Services\HomepageService;
|
||||
use App\Services\Images\FeaturedArtworkThumbnailGenerator;
|
||||
use App\Support\ArtworkFeaturedImagePath;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
@@ -303,6 +304,7 @@ test('homepage hero payload uses the forced hero artwork when one is set', funct
|
||||
|
||||
test('homepage renders featured hero picture and preload from dedicated featured thumbnails', function () {
|
||||
Cache::flush();
|
||||
app(HomepageService::class)->clearFeaturedAndMedalCaches();
|
||||
Storage::fake('s3');
|
||||
config([
|
||||
'uploads.object_storage.disk' => 's3',
|
||||
@@ -335,10 +337,19 @@ test('homepage renders featured hero picture and preload from dedicated featured
|
||||
|
||||
$paths = app(ArtworkFeaturedImagePath::class);
|
||||
|
||||
// Featured-thumbnail existence is precomputed by FeaturedArtworkThumbnailGenerator
|
||||
// (queued off the request path) and persisted on the artwork row; the homepage
|
||||
// request only reads that state, so seed both the fake disk (for URL building)
|
||||
// and the DB column here rather than relying on a live Storage::exists() check.
|
||||
foreach ($paths->variantNames() as $variant) {
|
||||
Storage::disk('s3')->put($paths->objectPath($artwork, $variant), 'featured-image');
|
||||
}
|
||||
|
||||
$artwork->forceFill([
|
||||
'featured_thumbnail_variants_json' => $paths->variantNames(),
|
||||
'featured_thumbnails_checked_at' => now(),
|
||||
])->saveQuietly();
|
||||
|
||||
$desktopUrl = $paths->url($artwork, 'desktop');
|
||||
$desktopXlUrl = $paths->url($artwork, 'desktop_xl');
|
||||
$mobileXsUrl = $paths->url($artwork, 'mobile_xs');
|
||||
@@ -355,6 +366,125 @@ test('homepage renders featured hero picture and preload from dedicated featured
|
||||
->assertSee('fetchpriority="high"', false);
|
||||
});
|
||||
|
||||
test('featured hero image resolution never checks the remote disk', function () {
|
||||
Cache::flush();
|
||||
app(HomepageService::class)->clearFeaturedAndMedalCaches();
|
||||
Storage::fake('s3');
|
||||
config([
|
||||
'uploads.object_storage.disk' => 's3',
|
||||
'cdn.files_url' => 'https://files.skinbase.org',
|
||||
]);
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$artwork = makeFeaturedArtwork([
|
||||
'user_id' => $owner->id,
|
||||
'title' => 'Hero Without Live Storage Checks',
|
||||
'hash' => str_repeat('e', 64),
|
||||
'file_ext' => 'png',
|
||||
'thumb_ext' => 'webp',
|
||||
]);
|
||||
|
||||
DB::table('artwork_features')->insert([
|
||||
'artwork_id' => $artwork->id,
|
||||
'featured_at' => now()->subHour(),
|
||||
'expires_at' => null,
|
||||
'priority' => 900,
|
||||
'label' => null,
|
||||
'note' => null,
|
||||
'is_active' => true,
|
||||
'force_hero' => true,
|
||||
'created_by' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
'deleted_at' => null,
|
||||
]);
|
||||
|
||||
$paths = app(ArtworkFeaturedImagePath::class);
|
||||
|
||||
$artwork->forceFill([
|
||||
'featured_thumbnail_variants_json' => $paths->variantNames(),
|
||||
'featured_thumbnails_checked_at' => now(),
|
||||
])->saveQuietly();
|
||||
|
||||
// Note: no files are ever written to the fake "s3" disk. If hasFeaturedThumbnail()
|
||||
// fell back to a live Storage::exists() check, these URLs would resolve to null;
|
||||
// since it reads the DB column instead, they resolve correctly with zero disk I/O.
|
||||
$hero = app(HomepageService::class)->getHeroArtwork();
|
||||
|
||||
expect($hero)->not->toBeNull()
|
||||
->and($hero['id'])->toBe($artwork->id)
|
||||
->and($hero['featured_image']['variants']['desktop'])->toBe($paths->url($artwork, 'desktop'))
|
||||
->and($hero['featured_image']['variants']['desktop_xl'])->toBe($paths->url($artwork, 'desktop_xl'));
|
||||
|
||||
expect($artwork->fresh()->hasFeaturedThumbnail('desktop'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('generating featured thumbnail metadata invalidates the stale hero cache so the next resolution uses it', function () {
|
||||
Cache::flush();
|
||||
app(HomepageService::class)->clearFeaturedAndMedalCaches();
|
||||
Storage::fake('s3');
|
||||
config([
|
||||
'uploads.object_storage.disk' => 's3',
|
||||
'cdn.files_url' => 'https://files.skinbase.org',
|
||||
]);
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$artwork = makeFeaturedArtwork([
|
||||
'user_id' => $owner->id,
|
||||
'title' => 'Hero Metadata Refresh',
|
||||
'hash' => str_repeat('f', 64),
|
||||
'file_ext' => 'png',
|
||||
'thumb_ext' => 'webp',
|
||||
]);
|
||||
|
||||
DB::table('artwork_features')->insert([
|
||||
'artwork_id' => $artwork->id,
|
||||
'featured_at' => now()->subHour(),
|
||||
'expires_at' => null,
|
||||
'priority' => 900,
|
||||
'label' => null,
|
||||
'note' => null,
|
||||
'is_active' => true,
|
||||
'force_hero' => true,
|
||||
'created_by' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
'deleted_at' => null,
|
||||
]);
|
||||
|
||||
$homepage = app(HomepageService::class);
|
||||
$paths = app(ArtworkFeaturedImagePath::class);
|
||||
|
||||
// 1. Hero cache is built while featured metadata is unavailable: no variants have
|
||||
// been persisted yet, so hasFeaturedThumbnail() reports false for every variant and
|
||||
// the payload falls back to the non-featured thumbnail.
|
||||
$heroBeforeGeneration = $homepage->getHeroArtwork();
|
||||
expect($heroBeforeGeneration)->not->toBeNull()
|
||||
->and($heroBeforeGeneration['id'])->toBe($artwork->id)
|
||||
->and($heroBeforeGeneration['featured_image']['variants']['desktop'])->toBeNull();
|
||||
|
||||
// 2. Featured-thumbnail metadata is generated. The generator's plan() (also used by
|
||||
// the skinbase:featured-thumbnails:generate command and GenerateFeaturedArtworkThumbnailsJob)
|
||||
// discovers the now-present files and persists featured_thumbnail_variants_json via
|
||||
// saveQuietly() — which does NOT fire ArtworkObserver/ArtworkFeatureObserver, so any
|
||||
// cache invalidation has to come from the generator itself.
|
||||
foreach ($paths->variantNames() as $variant) {
|
||||
Storage::disk('s3')->put($paths->objectPath($artwork, $variant), 'featured-image');
|
||||
}
|
||||
app(FeaturedArtworkThumbnailGenerator::class)->plan($artwork->fresh());
|
||||
|
||||
// 3. The relevant hero cache (all three viewer-visibility segments) was invalidated as
|
||||
// a side effect of that generation, without a full application cache flush.
|
||||
foreach (['visibility-hide', 'visibility-blur', 'visibility-show'] as $segment) {
|
||||
expect(Cache::has("homepage.hero.{$segment}"))->toBeFalse();
|
||||
}
|
||||
|
||||
// 4. The next homepage resolution rebuilds the cache and now uses the featured thumbnail.
|
||||
$heroAfterGeneration = $homepage->getHeroArtwork();
|
||||
expect($heroAfterGeneration['featured_image']['variants']['desktop'])
|
||||
->toBe($paths->url($artwork, 'desktop'));
|
||||
});
|
||||
|
||||
test('community favorites returns artworks ordered by recent medal score', function () {
|
||||
$owner = User::factory()->create();
|
||||
$leader = makeFeaturedArtwork(['user_id' => $owner->id, 'title' => 'Leader']);
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\SecurityReport;
|
||||
|
||||
use App\Models\SecurityReport;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class SecurityReportAdminTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config(['security-report.enabled' => true]);
|
||||
}
|
||||
|
||||
public function test_guest_cannot_open_security_report_page(): void
|
||||
{
|
||||
$this->get('/moderation/system/security-report')
|
||||
->assertRedirect(route('login'));
|
||||
}
|
||||
|
||||
public function test_normal_user_cannot_open_security_report_page(): void
|
||||
{
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get('/moderation/system/security-report')
|
||||
->assertRedirect(route('index'));
|
||||
}
|
||||
|
||||
public function test_staff_non_admin_cannot_open_security_report_page(): void
|
||||
{
|
||||
$manager = User::factory()->create(['role' => 'manager']);
|
||||
|
||||
$this->actingAs($manager)
|
||||
->get('/moderation/system/security-report')
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_admin_can_open_security_report_index_and_detail(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$report = SecurityReport::query()->create([
|
||||
'status' => 'completed_with_findings',
|
||||
'started_at' => now()->subMinute(),
|
||||
'finished_at' => now(),
|
||||
'total_critical' => 1,
|
||||
'total_high' => 2,
|
||||
'total_medium' => 3,
|
||||
'total_low' => 4,
|
||||
'total_unknown' => 0,
|
||||
'composer_outdated_count' => 5,
|
||||
'npm_outdated_count' => 6,
|
||||
'triggered_by' => 'artisan',
|
||||
'summary' => [
|
||||
'total' => ['critical' => 1, 'high' => 2, 'medium' => 3, 'low' => 4, 'unknown' => 0],
|
||||
],
|
||||
'composer_audit' => ['advisories' => ['laravel/framework' => [['severity' => 'high', 'title' => 'Test advisory']]]],
|
||||
'npm_audit' => ['vulnerabilities' => ['vite' => ['severity' => 'moderate', 'via' => [['title' => 'Moderate issue']]]]],
|
||||
]);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get('/moderation/system/security-report')
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Admin/System/SecurityReportIndex')
|
||||
->where('latest.id', $report->id)
|
||||
->where('latest.total_critical', 1)
|
||||
->where('reports.data.0.id', $report->id));
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get('/moderation/system/security-report/' . $report->id)
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Admin/System/SecurityReportShow')
|
||||
->where('report.id', $report->id)
|
||||
->where('report.total_high', 2)
|
||||
->where('report.composer_advisories.0.package', 'laravel/framework'));
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,12 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\AcademyChallenge;
|
||||
use App\Models\AcademyCourse;
|
||||
use App\Models\AcademyCourseLesson;
|
||||
use App\Models\AcademyLesson;
|
||||
use App\Models\AcademyPromptPack;
|
||||
use App\Models\AcademyPromptTemplate;
|
||||
use App\Models\Category;
|
||||
use App\Models\Collection;
|
||||
use App\Models\ContentType;
|
||||
@@ -46,12 +52,24 @@ it('renders the sitemap index and every child sitemap endpoint', function (): vo
|
||||
expect($indexXml)->not->toBeFalse();
|
||||
expect($indexResponse->getContent())
|
||||
->toContain(url('/sitemaps/artworks.xml'))
|
||||
->toContain(url('/sitemaps/academy-pages.xml'))
|
||||
->toContain(url('/sitemaps/academy-courses.xml'))
|
||||
->toContain(url('/sitemaps/academy-lessons.xml'))
|
||||
->toContain(url('/sitemaps/academy-prompts.xml'))
|
||||
->toContain(url('/sitemaps/academy-packs.xml'))
|
||||
->toContain(url('/sitemaps/academy-challenges.xml'))
|
||||
->toContain(url('/sitemaps/static-pages.xml'))
|
||||
->toContain(url('/sitemaps/forum-threads.xml'))
|
||||
->toContain(url('/sitemaps/news-google.xml'));
|
||||
|
||||
foreach ([
|
||||
'artworks',
|
||||
'academy-pages',
|
||||
'academy-courses',
|
||||
'academy-lessons',
|
||||
'academy-prompts',
|
||||
'academy-packs',
|
||||
'academy-challenges',
|
||||
'users',
|
||||
'tags',
|
||||
'categories',
|
||||
@@ -139,8 +157,36 @@ it('includes only public canonical urls and exposes the sitemap in robots txt',
|
||||
->toContain('http://skinbase26.test/')
|
||||
->toContain('/about')
|
||||
->toContain('/pages/community-handbook')
|
||||
->not->toContain('/academy')
|
||||
->not->toContain('/academy/pricing')
|
||||
->not->toContain('/pages/about');
|
||||
|
||||
$academyPagesXml = $this->get('/sitemaps/academy-pages.xml')->assertOk()->getContent();
|
||||
expect($academyPagesXml)
|
||||
->toContain(route('academy.index'))
|
||||
->toContain(route('academy.pricing'));
|
||||
|
||||
$academyCoursesXml = $this->get('/sitemaps/academy-courses.xml')->assertOk()->getContent();
|
||||
expect($academyCoursesXml)
|
||||
->toContain($fixtures['academy_course_url']);
|
||||
|
||||
$academyLessonsXml = $this->get('/sitemaps/academy-lessons.xml')->assertOk()->getContent();
|
||||
expect($academyLessonsXml)
|
||||
->toContain($fixtures['academy_lesson_url'])
|
||||
->not->toContain($fixtures['academy_course_lesson_url']);
|
||||
|
||||
$academyPromptsXml = $this->get('/sitemaps/academy-prompts.xml')->assertOk()->getContent();
|
||||
expect($academyPromptsXml)
|
||||
->toContain(route('academy.prompts.popular'))
|
||||
->toContain($fixtures['academy_prompt_url'])
|
||||
->not->toContain(route('academy.prompts.popular', ['period' => '7d']));
|
||||
|
||||
$academyPacksXml = $this->get('/sitemaps/academy-packs.xml')->assertOk()->getContent();
|
||||
expect($academyPacksXml)->toContain($fixtures['academy_pack_url']);
|
||||
|
||||
$academyChallengesXml = $this->get('/sitemaps/academy-challenges.xml')->assertOk()->getContent();
|
||||
expect($academyChallengesXml)->toContain($fixtures['academy_challenge_url']);
|
||||
|
||||
$robots = $this->get('/robots.txt')
|
||||
->assertOk()
|
||||
->assertHeader('Content-Type', 'text/plain; charset=UTF-8')
|
||||
@@ -149,6 +195,38 @@ it('includes only public canonical urls and exposes the sitemap in robots txt',
|
||||
expect($robots)->toContain('Sitemap: http://skinbase26.test/sitemap.xml');
|
||||
});
|
||||
|
||||
it('includes only canonical academy urls in academy sitemap families', function (): void {
|
||||
$fixtures = seedSitemapFixtures();
|
||||
|
||||
$academyCoursesXml = $this->get('/sitemaps/academy-courses.xml')->assertOk()->getContent();
|
||||
expect($academyCoursesXml)
|
||||
->toContain(route('academy.courses.index'))
|
||||
->toContain($fixtures['academy_course_url']);
|
||||
|
||||
$academyLessonsXml = $this->get('/sitemaps/academy-lessons.xml')->assertOk()->getContent();
|
||||
expect($academyLessonsXml)
|
||||
->toContain(route('academy.lessons.index'))
|
||||
->toContain($fixtures['academy_lesson_url'])
|
||||
->not->toContain($fixtures['academy_course_lesson_url']);
|
||||
|
||||
$academyPromptsXml = $this->get('/sitemaps/academy-prompts.xml')->assertOk()->getContent();
|
||||
expect($academyPromptsXml)
|
||||
->toContain(route('academy.prompts.index'))
|
||||
->toContain(route('academy.prompts.popular'))
|
||||
->toContain($fixtures['academy_prompt_url'])
|
||||
->not->toContain(route('academy.prompts.popular', ['period' => '7d']));
|
||||
|
||||
$academyPacksXml = $this->get('/sitemaps/academy-packs.xml')->assertOk()->getContent();
|
||||
expect($academyPacksXml)
|
||||
->toContain(route('academy.packs.index'))
|
||||
->toContain($fixtures['academy_pack_url']);
|
||||
|
||||
$academyChallengesXml = $this->get('/sitemaps/academy-challenges.xml')->assertOk()->getContent();
|
||||
expect($academyChallengesXml)
|
||||
->toContain(route('academy.challenges.index'))
|
||||
->toContain($fixtures['academy_challenge_url']);
|
||||
});
|
||||
|
||||
it('returns 404 for unknown sitemap names', function (): void {
|
||||
$this->get('/sitemaps/not-a-real-sitemap.xml')->assertNotFound();
|
||||
});
|
||||
@@ -547,6 +625,71 @@ function seedSitemapFixtures(): array
|
||||
'last_post_at' => now()->subHours(2),
|
||||
]);
|
||||
|
||||
$academyCourse = AcademyCourse::query()->create([
|
||||
'title' => 'Sitemap Academy Course',
|
||||
'slug' => 'sitemap-academy-course',
|
||||
'excerpt' => 'An academy course for sitemap coverage.',
|
||||
'description' => 'Course sitemap description',
|
||||
'access_level' => 'free',
|
||||
'difficulty' => 'beginner',
|
||||
'status' => 'published',
|
||||
'published_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$academyLesson = AcademyLesson::query()->create([
|
||||
'title' => 'Sitemap Academy Lesson',
|
||||
'slug' => 'sitemap-academy-lesson',
|
||||
'excerpt' => 'An academy lesson for sitemap coverage.',
|
||||
'content' => 'Lesson sitemap content',
|
||||
'difficulty' => 'beginner',
|
||||
'access_level' => 'free',
|
||||
'lesson_type' => 'article',
|
||||
'active' => true,
|
||||
'published_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
AcademyCourseLesson::query()->create([
|
||||
'course_id' => $academyCourse->id,
|
||||
'lesson_id' => $academyLesson->id,
|
||||
'order_num' => 0,
|
||||
'is_required' => true,
|
||||
]);
|
||||
|
||||
$academyPrompt = AcademyPromptTemplate::query()->create([
|
||||
'title' => 'Sitemap Academy Prompt',
|
||||
'slug' => 'sitemap-academy-prompt',
|
||||
'excerpt' => 'An academy prompt for sitemap coverage.',
|
||||
'prompt' => 'Create a cinematic moonlit portrait.',
|
||||
'difficulty' => 'beginner',
|
||||
'access_level' => 'free',
|
||||
'active' => true,
|
||||
'published_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$academyPack = AcademyPromptPack::query()->create([
|
||||
'title' => 'Sitemap Academy Pack',
|
||||
'slug' => 'sitemap-academy-pack',
|
||||
'excerpt' => 'An academy pack for sitemap coverage.',
|
||||
'description' => 'Pack sitemap description',
|
||||
'access_level' => 'free',
|
||||
'active' => true,
|
||||
'published_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$academyChallenge = AcademyChallenge::query()->create([
|
||||
'title' => 'Sitemap Academy Challenge',
|
||||
'slug' => 'sitemap-academy-challenge',
|
||||
'excerpt' => 'An academy challenge for sitemap coverage.',
|
||||
'description' => 'Challenge sitemap description',
|
||||
'brief' => 'Create a hero image.',
|
||||
'rules' => 'Keep it public.',
|
||||
'access_level' => 'free',
|
||||
'status' => AcademyChallenge::STATUS_ACTIVE,
|
||||
'starts_at' => now()->subDay(),
|
||||
'ends_at' => now()->addDay(),
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
return [
|
||||
'artwork_url' => route('art.show', [
|
||||
'id' => $artwork->id,
|
||||
@@ -556,6 +699,12 @@ function seedSitemapFixtures(): array
|
||||
'profile_url' => route('profile.show', ['username' => 'sitemapuser']),
|
||||
'collection_url' => route('profile.collections.show', ['username' => 'sitemapuser', 'slug' => 'showcase-set']),
|
||||
'card_url' => route('cards.show', ['slug' => 'clarity-card', 'id' => $card->id]),
|
||||
'academy_course_url' => route('academy.courses.show', ['course' => $academyCourse->slug]),
|
||||
'academy_lesson_url' => route('academy.lessons.show', ['slug' => $academyLesson->slug]),
|
||||
'academy_course_lesson_url' => route('academy.courses.lessons.show', ['course' => $academyCourse->slug, 'lesson' => $academyLesson->slug]),
|
||||
'academy_prompt_url' => route('academy.prompts.show', ['slug' => $academyPrompt->slug]),
|
||||
'academy_pack_url' => route('academy.packs.show', ['slug' => $academyPack->slug]),
|
||||
'academy_challenge_url' => route('academy.challenges.show', ['slug' => $academyChallenge->slug]),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -696,4 +845,4 @@ function extractUrlLocs(string $xml): array
|
||||
static fn ($node): string => trim((string) $node),
|
||||
$nodes,
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\SecurityReport;
|
||||
|
||||
use App\Services\SecurityReport\SecurityReportScanner;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class SecurityReportScannerTest extends TestCase
|
||||
{
|
||||
public function test_it_summarizes_npm_metadata_vulnerability_counts(): void
|
||||
{
|
||||
$scanner = new SecurityReportScanner();
|
||||
|
||||
$counts = $scanner->summarizeNpmAudit([
|
||||
'metadata' => [
|
||||
'vulnerabilities' => [
|
||||
'critical' => 1,
|
||||
'high' => 2,
|
||||
'moderate' => 3,
|
||||
'low' => 4,
|
||||
'info' => 5,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertSame(1, $counts['critical']);
|
||||
self::assertSame(2, $counts['high']);
|
||||
self::assertSame(3, $counts['moderate']);
|
||||
self::assertSame(4, $counts['low']);
|
||||
self::assertSame(5, $counts['info']);
|
||||
}
|
||||
|
||||
public function test_it_summarizes_composer_advisory_severity_counts(): void
|
||||
{
|
||||
$scanner = new SecurityReportScanner();
|
||||
|
||||
$counts = $scanner->summarizeComposerAudit([
|
||||
'advisories' => [
|
||||
'laravel/framework' => [
|
||||
['severity' => 'critical'],
|
||||
['severity' => 'high'],
|
||||
],
|
||||
'symfony/http-foundation' => [
|
||||
['severity' => 'medium'],
|
||||
['severity' => 'low'],
|
||||
['severity' => 'unexpected'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertSame(1, $counts['critical']);
|
||||
self::assertSame(1, $counts['high']);
|
||||
self::assertSame(1, $counts['medium']);
|
||||
self::assertSame(1, $counts['low']);
|
||||
self::assertSame(1, $counts['unknown']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user