Current state with latest updates
This commit is contained in:
@@ -7,8 +7,10 @@ namespace App\Services\Images;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\Cdn\ArtworkCdnPurgeService;
|
||||
use App\Services\ArtworkOriginalFileLocator;
|
||||
use App\Services\HomepageService;
|
||||
use App\Services\Uploads\UploadStorageService;
|
||||
use App\Support\ArtworkFeaturedImagePath;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
|
||||
@@ -28,6 +30,7 @@ final class FeaturedArtworkThumbnailGenerator
|
||||
private readonly ArtworkOriginalFileLocator $locator,
|
||||
private readonly UploadStorageService $storage,
|
||||
private readonly ArtworkCdnPurgeService $cdnPurge,
|
||||
private readonly HomepageService $homepage,
|
||||
) {
|
||||
try {
|
||||
$this->manager = extension_loaded('gd')
|
||||
@@ -59,6 +62,8 @@ final class FeaturedArtworkThumbnailGenerator
|
||||
$missing[] = $variant;
|
||||
}
|
||||
|
||||
$this->persistVariantState($artwork, $existing);
|
||||
|
||||
return [
|
||||
'existing' => $existing,
|
||||
'missing' => $missing,
|
||||
@@ -66,6 +71,52 @@ final class FeaturedArtworkThumbnailGenerator
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Record which featured variants are known to exist so the public homepage can read
|
||||
* this state instead of checking the remote disk during a request.
|
||||
*
|
||||
* saveQuietly() intentionally bypasses model observers (ArtworkObserver /
|
||||
* ArtworkFeatureObserver never fire for this write), so cache invalidation for the
|
||||
* homepage hero cannot be delegated to them here — it has to happen inline, and only
|
||||
* when the persisted availability actually changed for an artwork that is currently
|
||||
* an active feature (otherwise routine --all/--missing-only audits over thousands of
|
||||
* non-featured artworks would repeatedly invalidate the guest payload cache for no
|
||||
* reason and cause a stampede).
|
||||
*
|
||||
* @param list<string> $existingVariants
|
||||
*/
|
||||
private function persistVariantState(Artwork $artwork, array $existingVariants): void
|
||||
{
|
||||
$existingVariants = array_values(array_unique($existingVariants));
|
||||
sort($existingVariants);
|
||||
|
||||
$previousVariants = (array) ($artwork->featured_thumbnail_variants_json ?? []);
|
||||
sort($previousVariants);
|
||||
|
||||
$changed = $previousVariants !== $existingVariants;
|
||||
|
||||
$artwork->forceFill([
|
||||
'featured_thumbnail_variants_json' => $existingVariants,
|
||||
'featured_thumbnails_checked_at' => now(),
|
||||
])->saveQuietly();
|
||||
|
||||
if ($changed && $this->isActivelyFeatured($artwork)) {
|
||||
$this->homepage->clearFeaturedAndMedalCaches();
|
||||
}
|
||||
}
|
||||
|
||||
private function isActivelyFeatured(Artwork $artwork): bool
|
||||
{
|
||||
return DB::table('artwork_features')
|
||||
->where('artwork_id', $artwork->id)
|
||||
->where('is_active', true)
|
||||
->whereNull('deleted_at')
|
||||
->where(function ($query): void {
|
||||
$query->whereNull('expires_at')->orWhere('expires_at', '>', now());
|
||||
})
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{existing:list<string>,missing:list<string>,target_variants:list<string>,generated:int,skipped:int,generated_variants:list<string>,generated_paths:list<string>,failed:array<string,string>}
|
||||
*/
|
||||
@@ -129,6 +180,10 @@ final class FeaturedArtworkThumbnailGenerator
|
||||
]);
|
||||
}
|
||||
|
||||
if ($generatedVariants !== []) {
|
||||
$this->persistVariantState($artwork, array_values(array_unique([...$plan['existing'], ...$generatedVariants])));
|
||||
}
|
||||
|
||||
return $plan + [
|
||||
'generated' => count($generatedVariants),
|
||||
'skipped' => max(0, count($targetVariants) - count($generatedVariants) - count($failed)) + count($plan['existing']),
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\SecurityReport;
|
||||
|
||||
use App\Models\SecurityReport;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Throwable;
|
||||
|
||||
final class SecurityReportScanner
|
||||
{
|
||||
public function scan(string $triggeredBy = 'artisan', ?int $userId = null): SecurityReport
|
||||
{
|
||||
$report = SecurityReport::query()->create([
|
||||
'status' => 'running',
|
||||
'started_at' => now(),
|
||||
'triggered_by' => $triggeredBy,
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
|
||||
try {
|
||||
$composerAudit = null;
|
||||
$composerOutdated = null;
|
||||
$npmAudit = null;
|
||||
$npmOutdated = null;
|
||||
|
||||
if ((bool) config('security-report.scan.composer', true)) {
|
||||
$composerAudit = $this->runJsonCommand((array) config('security-report.commands.composer_audit', []));
|
||||
$composerOutdated = $this->runJsonCommand((array) config('security-report.commands.composer_outdated', []));
|
||||
}
|
||||
|
||||
if ((bool) config('security-report.scan.npm', true)) {
|
||||
$npmAudit = $this->runJsonCommand((array) config('security-report.commands.npm_audit', []));
|
||||
$npmOutdated = $this->runJsonCommand((array) config('security-report.commands.npm_outdated', []));
|
||||
}
|
||||
|
||||
$normalized = $this->summarizePayloads($composerAudit, $composerOutdated, $npmAudit, $npmOutdated);
|
||||
$status = ($normalized['total_critical'] > 0 || $normalized['total_high'] > 0 || $normalized['total_medium'] > 0 || $normalized['total_low'] > 0)
|
||||
? 'completed_with_findings'
|
||||
: 'completed';
|
||||
|
||||
$report->update(array_merge($normalized, [
|
||||
'status' => $status,
|
||||
'finished_at' => now(),
|
||||
'composer_audit' => $this->shouldStoreRaw() ? $this->limitRaw($composerAudit) : null,
|
||||
'composer_outdated' => $this->shouldStoreRaw() ? $this->limitRaw($composerOutdated) : null,
|
||||
'npm_audit' => $this->shouldStoreRaw() ? $this->limitRaw($npmAudit) : null,
|
||||
'npm_outdated' => $this->shouldStoreRaw() ? $this->limitRaw($npmOutdated) : null,
|
||||
]));
|
||||
|
||||
return $report->fresh();
|
||||
} catch (Throwable $exception) {
|
||||
Log::error('Security report scan failed', [
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
$report->update([
|
||||
'status' => 'failed',
|
||||
'finished_at' => now(),
|
||||
'error_message' => $this->sanitizeText($exception->getMessage()),
|
||||
]);
|
||||
|
||||
return $report->fresh();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $composerAudit
|
||||
* @param array<string, mixed>|null $composerOutdated
|
||||
* @param array<string, mixed>|null $npmAudit
|
||||
* @param array<string, mixed>|null $npmOutdated
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function summarizePayloads(?array $composerAudit, ?array $composerOutdated, ?array $npmAudit, ?array $npmOutdated): array
|
||||
{
|
||||
$composerCounts = $this->summarizeComposerAudit($composerAudit);
|
||||
$npmCounts = $this->summarizeNpmAudit($npmAudit);
|
||||
$composerOutdatedCount = $this->countComposerOutdated($composerOutdated);
|
||||
$npmOutdatedCount = $this->countNpmOutdated($npmOutdated);
|
||||
|
||||
$totalCritical = $composerCounts['critical'] + $npmCounts['critical'];
|
||||
$totalHigh = $composerCounts['high'] + $npmCounts['high'];
|
||||
$totalMedium = $composerCounts['medium'] + $npmCounts['moderate'];
|
||||
$totalLow = $composerCounts['low'] + $npmCounts['low'];
|
||||
$totalUnknown = $composerCounts['unknown'] + $npmCounts['unknown'] + $npmCounts['info'];
|
||||
|
||||
return [
|
||||
'composer_critical' => $composerCounts['critical'],
|
||||
'composer_high' => $composerCounts['high'],
|
||||
'composer_medium' => $composerCounts['medium'],
|
||||
'composer_low' => $composerCounts['low'],
|
||||
'composer_unknown' => $composerCounts['unknown'],
|
||||
'npm_critical' => $npmCounts['critical'],
|
||||
'npm_high' => $npmCounts['high'],
|
||||
'npm_moderate' => $npmCounts['moderate'],
|
||||
'npm_low' => $npmCounts['low'],
|
||||
'npm_info' => $npmCounts['info'],
|
||||
'npm_unknown' => $npmCounts['unknown'],
|
||||
'total_critical' => $totalCritical,
|
||||
'total_high' => $totalHigh,
|
||||
'total_medium' => $totalMedium,
|
||||
'total_low' => $totalLow,
|
||||
'total_unknown' => $totalUnknown,
|
||||
'composer_outdated_count' => $composerOutdatedCount,
|
||||
'npm_outdated_count' => $npmOutdatedCount,
|
||||
'summary' => [
|
||||
'total' => [
|
||||
'critical' => $totalCritical,
|
||||
'high' => $totalHigh,
|
||||
'medium' => $totalMedium,
|
||||
'low' => $totalLow,
|
||||
'unknown' => $totalUnknown,
|
||||
],
|
||||
'composer' => $composerCounts,
|
||||
'npm' => $npmCounts,
|
||||
'outdated' => [
|
||||
'composer' => $composerOutdatedCount,
|
||||
'npm' => $npmOutdatedCount,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $audit
|
||||
* @return array{critical:int,high:int,medium:int,low:int,unknown:int}
|
||||
*/
|
||||
public function summarizeComposerAudit(?array $audit): array
|
||||
{
|
||||
$counts = [
|
||||
'critical' => 0,
|
||||
'high' => 0,
|
||||
'medium' => 0,
|
||||
'low' => 0,
|
||||
'unknown' => 0,
|
||||
];
|
||||
|
||||
if (! is_array($audit)) {
|
||||
return $counts;
|
||||
}
|
||||
|
||||
$advisories = $audit['advisories'] ?? [];
|
||||
|
||||
foreach ($advisories as $packageAdvisories) {
|
||||
if (! is_array($packageAdvisories)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($packageAdvisories as $advisory) {
|
||||
if (! is_array($advisory)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$severity = strtolower((string) ($advisory['severity'] ?? 'unknown'));
|
||||
if (array_key_exists($severity, $counts)) {
|
||||
$counts[$severity]++;
|
||||
} else {
|
||||
$counts['unknown']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $audit
|
||||
* @return array{critical:int,high:int,moderate:int,low:int,info:int,unknown:int}
|
||||
*/
|
||||
public function summarizeNpmAudit(?array $audit): array
|
||||
{
|
||||
$counts = [
|
||||
'critical' => 0,
|
||||
'high' => 0,
|
||||
'moderate' => 0,
|
||||
'low' => 0,
|
||||
'info' => 0,
|
||||
'unknown' => 0,
|
||||
];
|
||||
|
||||
if (! is_array($audit)) {
|
||||
return $counts;
|
||||
}
|
||||
|
||||
if (isset($audit['metadata']['vulnerabilities']) && is_array($audit['metadata']['vulnerabilities'])) {
|
||||
$vulnerabilities = $audit['metadata']['vulnerabilities'];
|
||||
|
||||
$counts['critical'] = (int) ($vulnerabilities['critical'] ?? 0);
|
||||
$counts['high'] = (int) ($vulnerabilities['high'] ?? 0);
|
||||
$counts['moderate'] = (int) ($vulnerabilities['moderate'] ?? 0);
|
||||
$counts['low'] = (int) ($vulnerabilities['low'] ?? 0);
|
||||
$counts['info'] = (int) ($vulnerabilities['info'] ?? 0);
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
$vulnerabilities = $audit['vulnerabilities'] ?? [];
|
||||
|
||||
foreach ($vulnerabilities as $vulnerability) {
|
||||
if (! is_array($vulnerability)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$severity = strtolower((string) ($vulnerability['severity'] ?? 'unknown'));
|
||||
if (array_key_exists($severity, $counts)) {
|
||||
$counts[$severity]++;
|
||||
} else {
|
||||
$counts['unknown']++;
|
||||
}
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $command
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function runJsonCommand(array $command): ?array
|
||||
{
|
||||
if ($command === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$process = new Process(
|
||||
$command,
|
||||
base_path(),
|
||||
null,
|
||||
null,
|
||||
(float) config('security-report.timeout_seconds', 180),
|
||||
);
|
||||
|
||||
$process->run();
|
||||
|
||||
$output = trim($process->getOutput());
|
||||
$errorOutput = trim($process->getErrorOutput());
|
||||
|
||||
if ($output === '') {
|
||||
return [
|
||||
'_status' => $errorOutput !== '' ? 'no_json_output' : 'empty_output',
|
||||
'_exit_code' => $process->getExitCode(),
|
||||
'_error' => $errorOutput !== '' ? $this->sanitizeText(mb_substr($errorOutput, 0, 5000)) : null,
|
||||
];
|
||||
}
|
||||
|
||||
$json = json_decode($output, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE || ! is_array($json)) {
|
||||
return [
|
||||
'_status' => 'invalid_json',
|
||||
'_exit_code' => $process->getExitCode(),
|
||||
'_json_error' => json_last_error_msg(),
|
||||
'_output_preview' => $this->sanitizeText(mb_substr($output, 0, 5000)),
|
||||
'_error_preview' => $this->sanitizeText(mb_substr($errorOutput, 0, 5000)),
|
||||
];
|
||||
}
|
||||
|
||||
$json['_exit_code'] = $process->getExitCode();
|
||||
|
||||
return $this->sanitizeArray($json);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $outdated
|
||||
*/
|
||||
private function countComposerOutdated(?array $outdated): int
|
||||
{
|
||||
if (! is_array($outdated)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return isset($outdated['installed']) && is_array($outdated['installed'])
|
||||
? count($outdated['installed'])
|
||||
: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $outdated
|
||||
*/
|
||||
private function countNpmOutdated(?array $outdated): int
|
||||
{
|
||||
if (! is_array($outdated)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return count(array_filter(
|
||||
$outdated,
|
||||
static fn (mixed $value, mixed $key): bool => is_array($value) && ! str_starts_with((string) $key, '_'),
|
||||
ARRAY_FILTER_USE_BOTH,
|
||||
));
|
||||
}
|
||||
|
||||
private function shouldStoreRaw(): bool
|
||||
{
|
||||
return (bool) config('security-report.store_raw', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $data
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function limitRaw(?array $data): ?array
|
||||
{
|
||||
if ($data === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sanitized = $this->sanitizeArray($data);
|
||||
$maxBytes = max(64, (int) config('security-report.max_raw_kb', 512)) * 1024;
|
||||
$json = json_encode($sanitized, JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE);
|
||||
|
||||
if (! is_string($json)) {
|
||||
return [
|
||||
'_status' => 'raw_encode_failed',
|
||||
];
|
||||
}
|
||||
|
||||
if (strlen($json) <= $maxBytes) {
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
return [
|
||||
'_status' => 'truncated',
|
||||
'_max_kb' => (int) config('security-report.max_raw_kb', 512),
|
||||
'_preview' => mb_substr($json, 0, $maxBytes),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $data
|
||||
* @return array<mixed>
|
||||
*/
|
||||
private function sanitizeArray(array $data): array
|
||||
{
|
||||
$sanitized = [];
|
||||
|
||||
foreach ($data as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$sanitized[$key] = $this->sanitizeArray($value);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
$sanitized[$key] = $this->sanitizeText($value);
|
||||
continue;
|
||||
}
|
||||
|
||||
$sanitized[$key] = $value;
|
||||
}
|
||||
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
private function sanitizeText(string $value): string
|
||||
{
|
||||
$normalized = str_replace(["\r\n", "\r"], "\n", $value);
|
||||
$normalized = str_replace(base_path(), '[project-root]', $normalized);
|
||||
$normalized = preg_replace('/[A-Z]:\\\\[^\s"\']+/', '[path]', $normalized) ?? $normalized;
|
||||
|
||||
return trim($normalized);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps\Builders;
|
||||
|
||||
use App\Services\Sitemaps\AbstractSitemapBuilder;
|
||||
use App\Services\Sitemaps\SitemapUrlBuilder;
|
||||
use DateTimeInterface;
|
||||
|
||||
final class AcademyPagesSitemapBuilder extends AbstractSitemapBuilder
|
||||
{
|
||||
public function __construct(private readonly SitemapUrlBuilder $urls)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'academy-pages';
|
||||
}
|
||||
|
||||
public function items(): array
|
||||
{
|
||||
if (! (bool) config('academy.enabled', true)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
$this->urls->staticRoute('/academy'),
|
||||
$this->urls->staticRoute('/academy/pricing'),
|
||||
];
|
||||
}
|
||||
|
||||
public function lastModified(): ?DateTimeInterface
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,10 @@ final class AcademyPromptsSitemapBuilder extends AbstractSitemapBuilder
|
||||
return [];
|
||||
}
|
||||
|
||||
$items = [$this->urls->staticRoute('/academy/prompts')];
|
||||
$items = [
|
||||
$this->urls->staticRoute('/academy/prompts'),
|
||||
$this->urls->staticRoute('/academy/prompts/popular'),
|
||||
];
|
||||
|
||||
$details = AcademyPromptTemplate::query()
|
||||
->active()
|
||||
@@ -45,4 +48,4 @@ final class AcademyPromptsSitemapBuilder extends AbstractSitemapBuilder
|
||||
{
|
||||
return $this->dateTime(AcademyPromptTemplate::query()->active()->published()->max('updated_at'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,8 +24,6 @@ final class StaticPagesSitemapBuilder extends AbstractSitemapBuilder
|
||||
{
|
||||
$items = [
|
||||
$this->urls->staticRoute('/'),
|
||||
$this->urls->staticRoute('/academy'),
|
||||
$this->urls->staticRoute('/academy/pricing'),
|
||||
$this->urls->staticRoute('/web-stories'),
|
||||
$this->urls->staticRoute('/faq'),
|
||||
$this->urls->staticRoute('/rules-and-guidelines'),
|
||||
@@ -61,4 +59,4 @@ final class StaticPagesSitemapBuilder extends AbstractSitemapBuilder
|
||||
{
|
||||
return $this->dateTime(Page::query()->published()->max('updated_at'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +102,14 @@ final class PublishedSitemapResolver
|
||||
}
|
||||
}
|
||||
|
||||
foreach ((array) ($manifest['groups'] ?? []) as $groupName => $group) {
|
||||
$entryName = (string) ($group['entry_name'] ?? '');
|
||||
|
||||
if ($requestedName === $groupName && $entryName !== '') {
|
||||
return $entryName;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,23 @@ final class SitemapBuildService
|
||||
*/
|
||||
public function buildNamed(string $name, bool $force = false, bool $persist = true): ?array
|
||||
{
|
||||
$groupFamilies = $this->groupFamilies($name);
|
||||
if ($groupFamilies !== null) {
|
||||
$built = $this->cache->remember(
|
||||
$name,
|
||||
fn (): string => $this->renderer->renderIndex($this->index->itemsForFamilies($groupFamilies)),
|
||||
$force,
|
||||
$persist,
|
||||
);
|
||||
|
||||
return $built + [
|
||||
'type' => SitemapTarget::TYPE_INDEX,
|
||||
'url_count' => count($this->index->itemsForFamilies($groupFamilies)),
|
||||
'shard_count' => 0,
|
||||
'name' => $name,
|
||||
];
|
||||
}
|
||||
|
||||
$target = $this->shards->resolve($this->registry, $name);
|
||||
|
||||
if ($target === null) {
|
||||
@@ -111,6 +128,24 @@ final class SitemapBuildService
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>|null
|
||||
*/
|
||||
public function groupFamilies(string $name): ?array
|
||||
{
|
||||
$families = $this->index->activeGroupIndexes($this->enabledFamilies())[$name] ?? null;
|
||||
|
||||
return is_array($families) && $families !== [] ? $families : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<string>>
|
||||
*/
|
||||
public function enabledGroupIndexes(): array
|
||||
{
|
||||
return $this->index->activeGroupIndexes($this->enabledFamilies());
|
||||
}
|
||||
|
||||
private function renderTarget(SitemapTarget $target): string
|
||||
{
|
||||
if ($target->type === SitemapTarget::TYPE_INDEX) {
|
||||
@@ -140,4 +175,4 @@ final class SitemapBuildService
|
||||
|
||||
return count($target->builder->items());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeInterface;
|
||||
|
||||
final class SitemapIndexService
|
||||
{
|
||||
public function __construct(
|
||||
@@ -18,8 +21,25 @@ final class SitemapIndexService
|
||||
public function items(?array $families = null): array
|
||||
{
|
||||
$items = [];
|
||||
$selectedFamilies = $families ?? (array) config('sitemaps.enabled', []);
|
||||
$groupedFamilies = [];
|
||||
|
||||
foreach ($this->activeGroupIndexes($selectedFamilies) as $groupName => $groupFamilies) {
|
||||
$items[] = new SitemapIndexItem(
|
||||
url('/sitemaps/' . $groupName . '.xml'),
|
||||
$this->lastModifiedForFamilies($groupFamilies),
|
||||
);
|
||||
|
||||
foreach ($groupFamilies as $family) {
|
||||
$groupedFamilies[$family] = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($selectedFamilies as $name) {
|
||||
if (isset($groupedFamilies[(string) $name])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($families ?? (array) config('sitemaps.enabled', []) as $name) {
|
||||
$builder = $this->registry->get((string) $name);
|
||||
|
||||
if ($builder === null) {
|
||||
@@ -35,6 +55,30 @@ final class SitemapIndexService
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $families
|
||||
* @return list<SitemapIndexItem>
|
||||
*/
|
||||
public function itemsForFamilies(array $families): array
|
||||
{
|
||||
$items = [];
|
||||
|
||||
foreach ($families as $family) {
|
||||
$builder = $this->registry->get($family);
|
||||
|
||||
if ($builder === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$items[] = new SitemapIndexItem(
|
||||
url('/sitemaps/' . $this->shards->rootEntryName($builder) . '.xml'),
|
||||
$builder->lastModified(),
|
||||
);
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<SitemapIndexItem>
|
||||
*/
|
||||
@@ -58,4 +102,69 @@ final class SitemapIndexService
|
||||
$builder->lastModified(),
|
||||
)];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $selectedFamilies
|
||||
* @return array<string, list<string>>
|
||||
*/
|
||||
public function activeGroupIndexes(array $selectedFamilies): array
|
||||
{
|
||||
$selectedLookup = array_fill_keys($selectedFamilies, true);
|
||||
$groups = [];
|
||||
|
||||
foreach ((array) config('sitemaps.group_indexes', []) as $groupName => $groupFamilies) {
|
||||
if (! is_string($groupName) || $groupName === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$validFamilies = array_values(array_filter(
|
||||
(array) $groupFamilies,
|
||||
fn (mixed $family): bool => is_string($family) && $family !== '' && $this->registry->get($family) !== null,
|
||||
));
|
||||
|
||||
if ($validFamilies === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($validFamilies as $family) {
|
||||
if (! isset($selectedLookup[$family])) {
|
||||
continue 2;
|
||||
}
|
||||
}
|
||||
|
||||
$groups[$groupName] = $validFamilies;
|
||||
}
|
||||
|
||||
return $groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $families
|
||||
*/
|
||||
private function lastModifiedForFamilies(array $families): ?DateTimeInterface
|
||||
{
|
||||
$latest = null;
|
||||
|
||||
foreach ($families as $family) {
|
||||
$builder = $this->registry->get($family);
|
||||
|
||||
if ($builder === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lastModified = $builder->lastModified();
|
||||
|
||||
if ($lastModified !== null) {
|
||||
$candidate = $lastModified instanceof DateTimeInterface
|
||||
? $lastModified
|
||||
: new DateTimeImmutable((string) $lastModified);
|
||||
|
||||
if ($latest === null || $candidate->getTimestamp() > $latest->getTimestamp()) {
|
||||
$latest = $candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $latest;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +130,7 @@ final class SitemapPublishService
|
||||
$releaseId ??= $this->releases->generateReleaseId();
|
||||
|
||||
$familyManifest = [];
|
||||
$groupManifest = [];
|
||||
$documents = [
|
||||
SitemapCacheService::INDEX_DOCUMENT => $this->releases->documentRelativePath(SitemapCacheService::INDEX_DOCUMENT),
|
||||
];
|
||||
@@ -180,15 +181,43 @@ final class SitemapPublishService
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($this->build->enabledGroupIndexes() as $groupName => $groupFamilies) {
|
||||
foreach ($groupFamilies as $family) {
|
||||
if (! in_array($family, $selectedFamilies, true)) {
|
||||
continue 2;
|
||||
}
|
||||
}
|
||||
|
||||
$built = $this->build->buildNamed($groupName, true, false);
|
||||
|
||||
if ($built === null) {
|
||||
throw new \RuntimeException('Failed to build sitemap group [' . $groupName . '].');
|
||||
}
|
||||
|
||||
$this->releases->putDocument($releaseId, $groupName, (string) $built['content']);
|
||||
$documents[$groupName] = $this->releases->documentRelativePath($groupName);
|
||||
|
||||
$groupManifest[$groupName] = [
|
||||
'name' => $groupName,
|
||||
'entry_name' => $groupName,
|
||||
'families' => $groupFamilies,
|
||||
'documents' => [$groupName],
|
||||
'url_count' => (int) $built['url_count'],
|
||||
'type' => SitemapTarget::TYPE_INDEX,
|
||||
];
|
||||
}
|
||||
|
||||
$manifest = [
|
||||
'release_id' => $releaseId,
|
||||
'status' => 'built',
|
||||
'built_at' => now()->toAtomString(),
|
||||
'published_at' => null,
|
||||
'families' => $familyManifest,
|
||||
'groups' => $groupManifest,
|
||||
'documents' => $documents,
|
||||
'totals' => [
|
||||
'families' => count($familyManifest),
|
||||
'groups' => count($groupManifest),
|
||||
'documents' => count($documents),
|
||||
'urls' => $totalUrls,
|
||||
],
|
||||
@@ -204,4 +233,4 @@ final class SitemapPublishService
|
||||
|
||||
return $manifest;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Services\Sitemaps\Builders\ArtworksSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\AcademyChallengesSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\AcademyCoursesSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\AcademyLessonsSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\AcademyPagesSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\AcademyPacksSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\AcademyPromptsSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\CardsSitemapBuilder;
|
||||
@@ -33,6 +34,7 @@ final class SitemapRegistry
|
||||
|
||||
public function __construct(
|
||||
ArtworksSitemapBuilder $artworks,
|
||||
AcademyPagesSitemapBuilder $academyPages,
|
||||
AcademyCoursesSitemapBuilder $academyCourses,
|
||||
AcademyLessonsSitemapBuilder $academyLessons,
|
||||
AcademyPromptsSitemapBuilder $academyPrompts,
|
||||
@@ -54,6 +56,7 @@ final class SitemapRegistry
|
||||
) {
|
||||
$this->builders = [
|
||||
$artworks->name() => $artworks,
|
||||
$academyPages->name() => $academyPages,
|
||||
$academyCourses->name() => $academyCourses,
|
||||
$academyLessons->name() => $academyLessons,
|
||||
$academyPrompts->name() => $academyPrompts,
|
||||
@@ -87,4 +90,4 @@ final class SitemapRegistry
|
||||
{
|
||||
return $this->builders[$name] ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ final class SitemapReleaseValidator
|
||||
|
||||
$errors = [];
|
||||
$families = (array) ($manifest['families'] ?? []);
|
||||
$groups = (array) ($manifest['groups'] ?? []);
|
||||
$documents = (array) ($manifest['documents'] ?? []);
|
||||
|
||||
$rootContent = $this->releases->getDocument($releaseId, SitemapCacheService::INDEX_DOCUMENT);
|
||||
@@ -41,10 +42,9 @@ final class SitemapReleaseValidator
|
||||
$errors[] = 'Root sitemap.xml is missing or invalid.';
|
||||
} else {
|
||||
$rootLocs = $this->extractLocs($rootXml, 'sitemap');
|
||||
$expectedRootLocs = array_map(
|
||||
fn (string $entryName): string => url('/sitemaps/' . $entryName . '.xml'),
|
||||
array_values(array_map(static fn (array $family): string => (string) ($family['entry_name'] ?? ''), $families)),
|
||||
);
|
||||
$expectedRoot = $this->build->buildIndex(true, false, array_keys($families));
|
||||
$expectedRootXml = $this->loadXml((string) $expectedRoot['content']);
|
||||
$expectedRootLocs = $expectedRootXml ? $this->extractLocs($expectedRootXml, 'sitemap') : [];
|
||||
|
||||
if ($rootLocs !== $expectedRootLocs) {
|
||||
$errors[] = 'Root sitemap index does not match the manifest family entries.';
|
||||
@@ -147,13 +147,48 @@ final class SitemapReleaseValidator
|
||||
}
|
||||
}
|
||||
|
||||
$groupReports = [];
|
||||
|
||||
foreach ($groups as $groupName => $group) {
|
||||
$groupErrors = [];
|
||||
$documentName = (string) ($group['entry_name'] ?? $groupName);
|
||||
$artifact = $this->releases->getDocument($releaseId, $documentName);
|
||||
|
||||
if (! is_string($artifact) || $artifact === '') {
|
||||
$groupErrors[] = 'Missing artifact [' . $documentName . '].';
|
||||
} else {
|
||||
$artifactXml = $this->loadXml($artifact);
|
||||
$expected = $this->build->buildNamed($documentName, true, false);
|
||||
$expectedXml = $expected !== null ? $this->loadXml((string) $expected['content']) : null;
|
||||
|
||||
if ($artifactXml === null || $expectedXml === null) {
|
||||
$groupErrors[] = 'Invalid XML in group artifact [' . $documentName . '].';
|
||||
} elseif ($this->extractLocs($artifactXml, 'sitemap') !== $this->extractLocs($expectedXml, 'sitemap')) {
|
||||
$groupErrors[] = 'Group index artifact [' . $documentName . '] does not match expected sitemap references.';
|
||||
}
|
||||
}
|
||||
|
||||
$groupReports[] = [
|
||||
'group' => $groupName,
|
||||
'documents' => count((array) ($group['documents'] ?? [])),
|
||||
'url_count' => (int) ($group['url_count'] ?? 0),
|
||||
'errors' => $groupErrors,
|
||||
];
|
||||
|
||||
foreach ($groupErrors as $groupError) {
|
||||
$errors[] = $groupName . ': ' . $groupError;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => $errors === [],
|
||||
'release_id' => $releaseId,
|
||||
'errors' => $errors,
|
||||
'families' => $reports,
|
||||
'groups' => $groupReports,
|
||||
'totals' => [
|
||||
'families' => count($families),
|
||||
'groups' => count($groups),
|
||||
'documents' => count($documents),
|
||||
'urls' => array_sum(array_map(static fn (array $family): int => (int) ($family['url_count'] ?? 0), $families)),
|
||||
'shards' => array_sum(array_map(static fn (array $family): int => (int) ($family['shard_count'] ?? 0), $families)),
|
||||
@@ -255,4 +290,4 @@ final class SitemapReleaseValidator
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Writes every document from a published release to the public disk so nginx
|
||||
* can serve sitemap.xml and sitemaps/{name}.xml as plain static files,
|
||||
* bypassing PHP entirely on subsequent requests.
|
||||
* can serve sitemaps/{name}.xml as plain static files, bypassing PHP on
|
||||
* subsequent child-sitemap requests. The root /sitemap.xml stays dynamic.
|
||||
*/
|
||||
final class SitemapStaticPublisher
|
||||
{
|
||||
@@ -50,6 +50,7 @@ final class SitemapStaticPublisher
|
||||
}
|
||||
|
||||
$disk->put((string) $relativePath, $content);
|
||||
|
||||
$written++;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,15 +29,7 @@ final class SitemapValidationService
|
||||
? array_values(array_filter($onlyFamilies, fn (string $family): bool => $this->registry->get($family) !== null))
|
||||
: $this->build->enabledFamilies();
|
||||
|
||||
$expectedIndexLocs = array_map(
|
||||
static fn (SitemapIndexItem $item): string => $item->loc,
|
||||
array_values(array_filter(
|
||||
$this->index->items(),
|
||||
fn (SitemapIndexItem $item): bool => $this->isFamilySelected($families, $item->loc),
|
||||
)),
|
||||
);
|
||||
|
||||
$indexBuild = $this->build->buildIndex(true, false);
|
||||
$indexBuild = $this->build->buildIndex(true, false, $families);
|
||||
$indexErrors = [];
|
||||
$indexXml = $this->loadXml($indexBuild['content']);
|
||||
|
||||
@@ -45,6 +37,7 @@ final class SitemapValidationService
|
||||
$indexErrors[] = 'The main sitemap index XML could not be parsed.';
|
||||
}
|
||||
|
||||
$expectedIndexLocs = $this->extractLocsFromContent((string) $indexBuild['content'], 'sitemap');
|
||||
$actualIndexLocs = $indexXml ? $this->extractLocs($indexXml, 'sitemap') : [];
|
||||
if ($indexXml !== null && $actualIndexLocs !== $expectedIndexLocs) {
|
||||
$indexErrors[] = 'Main sitemap index child references do not match the expected shard-aware manifest.';
|
||||
@@ -214,15 +207,14 @@ final class SitemapValidationService
|
||||
return $locs;
|
||||
}
|
||||
|
||||
private function isFamilySelected(array $families, string $loc): bool
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function extractLocsFromContent(string $content, string $nodeName): array
|
||||
{
|
||||
foreach ($families as $family) {
|
||||
if (str_contains($loc, '/sitemaps/' . $family . '.xml') || str_contains($loc, '/sitemaps/' . $family . '-')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
$document = $this->loadXml($content);
|
||||
|
||||
return false;
|
||||
return $document === null ? [] : $this->extractLocs($document, $nodeName);
|
||||
}
|
||||
|
||||
private function urlError(string $family, string $loc): ?string
|
||||
@@ -283,4 +275,4 @@ final class SitemapValidationService
|
||||
|
||||
return $user === null ? 'Non-public user URL emitted' : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,10 +58,12 @@ final class AiArtworkVectorSearchService
|
||||
*/
|
||||
private function downloadArtworkImage(Artwork $artwork, string $url): ?array
|
||||
{
|
||||
// Runs synchronously in the web request path — keep the budget tight
|
||||
// so a slow origin can't pin an FPM worker for 20s+.
|
||||
$response = Http::accept('*/*')
|
||||
->connectTimeout(5)
|
||||
->timeout(20)
|
||||
->retry(1, 200, throw: false)
|
||||
->connectTimeout(2)
|
||||
->timeout(6)
|
||||
->retry(0)
|
||||
->get($url);
|
||||
|
||||
if (! $response->ok()) {
|
||||
@@ -91,6 +93,10 @@ final class AiArtworkVectorSearchService
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($this->client->circuitOpen()) {
|
||||
throw new RuntimeException('Vector gateway temporarily unavailable (circuit open after a recent failure).');
|
||||
}
|
||||
|
||||
$fileFailure = null;
|
||||
|
||||
try {
|
||||
|
||||
@@ -7,11 +7,14 @@ namespace App\Services\Vision;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
final class VectorGatewayClient
|
||||
{
|
||||
private const CIRCUIT_KEY = 'vision.vector_gateway.circuit_open';
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return (bool) config('vision.vector_gateway.enabled', true)
|
||||
@@ -19,9 +22,25 @@ final class VectorGatewayClient
|
||||
&& $this->apiKey() !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* True while the gateway is presumed down after a recent failure — callers
|
||||
* on the request path should skip the network round trip entirely.
|
||||
*/
|
||||
public function circuitOpen(): bool
|
||||
{
|
||||
return Cache::has(self::CIRCUIT_KEY);
|
||||
}
|
||||
|
||||
public function tripCircuit(): void
|
||||
{
|
||||
$seconds = max(1, (int) config('vision.vector_gateway.circuit_breaker_seconds', 30));
|
||||
Cache::put(self::CIRCUIT_KEY, true, $seconds);
|
||||
}
|
||||
|
||||
public function upsertByUrl(string $imageUrl, int|string $id, array $metadata = []): array
|
||||
{
|
||||
$response = $this->postJson(
|
||||
$this->request(),
|
||||
$this->url((string) config('vision.vector_gateway.upsert_endpoint', '/vectors/upsert')),
|
||||
[
|
||||
'url' => $imageUrl,
|
||||
@@ -65,15 +84,24 @@ final class VectorGatewayClient
|
||||
*/
|
||||
public function searchByUrl(string $imageUrl, int $limit = 5): array
|
||||
{
|
||||
$response = $this->postJson(
|
||||
$this->url((string) config('vision.vector_gateway.search_endpoint', '/vectors/search')),
|
||||
[
|
||||
'url' => $imageUrl,
|
||||
'limit' => max(1, $limit),
|
||||
]
|
||||
);
|
||||
$this->guardCircuit();
|
||||
|
||||
try {
|
||||
$response = $this->postJson(
|
||||
$this->searchRequest(),
|
||||
$this->url((string) config('vision.vector_gateway.search_endpoint', '/vectors/search')),
|
||||
[
|
||||
'url' => $imageUrl,
|
||||
'limit' => max(1, $limit),
|
||||
]
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$this->tripCircuit();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
$this->tripCircuit();
|
||||
throw new RuntimeException($this->failureMessage('Vector search', $response));
|
||||
}
|
||||
|
||||
@@ -85,16 +113,24 @@ final class VectorGatewayClient
|
||||
*/
|
||||
public function searchByFileContents(string $contents, string $filename, int $limit = 5): array
|
||||
{
|
||||
$response = $this->request()
|
||||
->attach('file', $contents, $filename)
|
||||
->post(
|
||||
$this->url((string) config('vision.vector_gateway.search_file_endpoint', '/vectors/search/file')),
|
||||
[
|
||||
'limit' => max(1, $limit),
|
||||
]
|
||||
);
|
||||
$this->guardCircuit();
|
||||
|
||||
try {
|
||||
$response = $this->searchRequest()
|
||||
->attach('file', $contents, $filename)
|
||||
->post(
|
||||
$this->url((string) config('vision.vector_gateway.search_file_endpoint', '/vectors/search/file')),
|
||||
[
|
||||
'limit' => max(1, $limit),
|
||||
]
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$this->tripCircuit();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
$this->tripCircuit();
|
||||
throw new RuntimeException($this->failureMessage('Vector search', $response));
|
||||
}
|
||||
|
||||
@@ -122,6 +158,7 @@ final class VectorGatewayClient
|
||||
public function deleteByIds(array $ids): array
|
||||
{
|
||||
$response = $this->postJson(
|
||||
$this->request(),
|
||||
$this->url((string) config('vision.vector_gateway.delete_endpoint', '/vectors/delete')),
|
||||
[
|
||||
'ids' => array_values(array_map(static fn (int|string $id): string => (string) $id, $ids)),
|
||||
@@ -137,6 +174,10 @@ final class VectorGatewayClient
|
||||
return is_array($json) ? $json : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Used by upsert/delete — only ever called from queued/console indexing
|
||||
* jobs, so a more generous budget is fine.
|
||||
*/
|
||||
private function request(): PendingRequest
|
||||
{
|
||||
if (! $this->isConfigured()) {
|
||||
@@ -156,12 +197,42 @@ final class VectorGatewayClient
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Used by search — runs synchronously inside web requests, so it gets a
|
||||
* tight timeout budget and no retries to avoid pinning FPM workers.
|
||||
*/
|
||||
private function searchRequest(): PendingRequest
|
||||
{
|
||||
if (! $this->isConfigured()) {
|
||||
throw new RuntimeException('Vision vector gateway is not configured. Set VISION_VECTOR_GATEWAY_URL and VISION_VECTOR_GATEWAY_API_KEY.');
|
||||
}
|
||||
|
||||
return Http::acceptJson()
|
||||
->withHeaders([
|
||||
'X-API-Key' => $this->apiKey(),
|
||||
])
|
||||
->connectTimeout(max(1, (int) config('vision.vector_gateway.search_connect_timeout_seconds', 2)))
|
||||
->timeout(max(1, (int) config('vision.vector_gateway.search_timeout_seconds', 6)))
|
||||
->retry(
|
||||
max(0, (int) config('vision.vector_gateway.search_retries', 0)),
|
||||
max(0, (int) config('vision.vector_gateway.retry_delay_ms', 250)),
|
||||
throw: false,
|
||||
);
|
||||
}
|
||||
|
||||
private function guardCircuit(): void
|
||||
{
|
||||
if ($this->circuitOpen()) {
|
||||
throw new RuntimeException('Vector gateway temporarily unavailable (circuit open after a recent failure).');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function postJson(string $url, array $payload): Response
|
||||
private function postJson(PendingRequest $request, string $url, array $payload): Response
|
||||
{
|
||||
$response = $this->request()->post($url, $payload);
|
||||
$response = $request->post($url, $payload);
|
||||
|
||||
if (! $response instanceof Response) {
|
||||
throw new RuntimeException('Vector gateway request did not return an HTTP response.');
|
||||
|
||||
Reference in New Issue
Block a user