Current state with latest updates
This commit is contained in:
@@ -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>
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user