Current state with latest updates

This commit is contained in:
2026-08-23 13:28:34 +02:00
parent 5af95f6533
commit f52879edbb
74 changed files with 5174 additions and 960 deletions
+33 -3
View File
@@ -49,10 +49,10 @@ final class BuildSitemapsCommand extends Command
$t = microtime(true);
$this->line(' Building sitemap index…');
$index = $build->buildIndex(force: true, persist: false, families: $families);
$disk->put('sitemap.xml', $index['content']);
$disk->put('sitemaps/sitemap.xml', $index['content']);
$written++;
$this->line(sprintf(
' <info>✔</info> sitemap.xml %d entries <comment>%.3fs</comment>',
' <info>✔</info> sitemaps/sitemap.xml %d entries <comment>%.3fs</comment>',
$index['url_count'],
microtime(true) - $t,
));
@@ -112,6 +112,36 @@ final class BuildSitemapsCommand extends Command
));
}
foreach ($build->enabledGroupIndexes() as $groupName => $groupFamilies) {
foreach ($groupFamilies as $family) {
if (! in_array($family, $families, true)) {
continue 2;
}
}
$t = microtime(true);
$this->line(sprintf(' Building grouped sitemap %s…', $groupName));
$built = $build->buildNamed($groupName, force: true, persist: false);
if ($built === null) {
$this->line(sprintf(' <comment>–</comment> %s.xml <fg=red>SKIPPED</> (group builder returned null)', $groupName));
$failed++;
continue;
}
$disk->put('sitemaps/' . $groupName . '.xml', $built['content']);
$written++;
$this->line(sprintf(
' <info>✔</info> %s %d entries <comment>%.3fs</comment>',
$groupName . '.xml',
$built['url_count'] ?? 0,
microtime(true) - $t,
));
}
// ── Summary ───────────────────────────────────────────────────────
$this->newLine();
$this->info(sprintf(
@@ -295,4 +325,4 @@ final class BuildSitemapsCommand extends Command
return array_values(array_filter($enabled, fn (string $family): bool => in_array($family, $only, true)));
}
}
}
@@ -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');
+23 -11
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Controllers;
use App\Services\Sitemaps\PublishedSitemapResolver;
use App\Services\Sitemaps\SitemapBuildService;
use App\Services\Sitemaps\SitemapXmlRenderer;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -14,34 +15,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>
*/
+47
View File
@@ -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
View File
@@ -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;
}
}
+160
View File
@@ -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.');
}
}
+29
View File
@@ -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')
+22 -1
View File
@@ -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;
}
+47
View File
@@ -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;
}
}
}
+36 -1
View File
@@ -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());
}
}
}
+111 -2
View File
@@ -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;
}
}
}
+4 -1
View File
@@ -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 {
+88 -17
View File
@@ -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.');