Implement creator studio and upload updates

This commit is contained in:
2026-04-04 10:12:02 +02:00
parent 1da7d3bf88
commit 0b216b7ecd
15107 changed files with 31206 additions and 626514 deletions

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps;
use DateTimeInterface;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
abstract class AbstractSitemapBuilder implements SitemapBuilder
{
protected function contentTypeSlugs(): array
{
return array_values((array) config('sitemaps.content_type_slugs', []));
}
protected function newest(mixed ...$timestamps): ?DateTimeInterface
{
$filtered = array_values(array_filter(array_map(fn (mixed $value): ?Carbon => $this->dateTime($value), $timestamps)));
if ($filtered === []) {
return null;
}
usort($filtered, static fn (DateTimeInterface $left, DateTimeInterface $right): int => $left < $right ? 1 : -1);
return $filtered[0];
}
protected function dateTime(mixed $value): ?Carbon
{
if ($value instanceof Carbon) {
return $value;
}
if ($value instanceof DateTimeInterface) {
return Carbon::instance($value);
}
if (is_string($value) && trim($value) !== '') {
return Carbon::parse($value);
}
return null;
}
protected function absoluteUrl(?string $url): ?string
{
$value = trim((string) $url);
if ($value === '') {
return null;
}
if (Str::startsWith($value, ['http://', 'https://'])) {
return $value;
}
return url($value);
}
protected function image(?string $url, ?string $title = null): ?SitemapImage
{
$absolute = $this->absoluteUrl($url);
if ($absolute === null) {
return null;
}
return new SitemapImage($absolute, $title !== '' ? $title : null);
}
/**
* @param array<int, SitemapImage|null> $images
* @return list<SitemapImage>
*/
protected function images(array $images): array
{
return array_values(array_filter($images, static fn (mixed $image): bool => $image instanceof SitemapImage));
}
}

View File

@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps\Builders;
use App\Services\Sitemaps\AbstractSitemapBuilder;
use App\Services\Sitemaps\ShardableSitemapBuilder;
use App\Services\Sitemaps\SitemapUrl;
use DateTimeInterface;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
abstract class AbstractIdShardableSitemapBuilder extends AbstractSitemapBuilder implements ShardableSitemapBuilder
{
/**
* @return Builder<Model>
*/
abstract protected function query(): Builder;
abstract protected function shardConfigKey(): string;
abstract protected function mapRecord(Model $record): ?SitemapUrl;
public function items(): array
{
return $this->query()
->orderBy($this->idColumn())
->cursor()
->map(fn (Model $record): ?SitemapUrl => $this->mapRecord($record))
->filter()
->values()
->all();
}
public function lastModified(): ?DateTimeInterface
{
return $this->newest(...array_map(
fn (SitemapUrl $item): ?DateTimeInterface => $item->lastModified,
$this->items(),
));
}
public function totalItems(): int
{
return (clone $this->query())->count();
}
public function shardSize(): int
{
return max(1, (int) \data_get(\config('sitemaps.shards', []), $this->shardConfigKey() . '.size', 10000));
}
public function itemsForShard(int $shard): array
{
$window = $this->shardWindow($shard);
if ($window === null) {
return [];
}
return $this->applyShardWindow($window['from'], $window['to'])
->get()
->map(fn (Model $record): ?SitemapUrl => $this->mapRecord($record))
->filter()
->values()
->all();
}
public function lastModifiedForShard(int $shard): ?DateTimeInterface
{
return $this->newest(...array_map(
fn (SitemapUrl $item): ?DateTimeInterface => $item->lastModified,
$this->itemsForShard($shard),
));
}
/**
* @return array{from: int, to: int}|null
*/
protected function shardWindow(int $shard): ?array
{
if ($shard < 1) {
return null;
}
$size = $this->shardSize();
$current = 0;
$from = null;
$to = null;
$windowQuery = (clone $this->query())
->setEagerLoads([])
->select([$this->idColumn()])
->orderBy($this->idColumn());
foreach ($windowQuery->cursor() as $record) {
$current++;
if ((int) ceil($current / $size) !== $shard) {
continue;
}
$recordId = (int) $record->getAttribute($this->idColumn());
$from ??= $recordId;
$to = $recordId;
}
if ($from === null || $to === null) {
return null;
}
return ['from' => $from, 'to' => $to];
}
/**
* @return Builder<Model>
*/
protected function applyShardWindow(int $from, int $to): Builder
{
return (clone $this->query())
->whereBetween($this->qualifiedIdColumn(), [$from, $to])
->orderBy($this->idColumn());
}
protected function idColumn(): string
{
return 'id';
}
protected function qualifiedIdColumn(): string
{
return $this->idColumn();
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps\Builders;
use App\Models\Artwork;
use App\Services\Sitemaps\SitemapUrl;
use App\Services\Sitemaps\SitemapUrlBuilder;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
final class ArtworksSitemapBuilder extends AbstractIdShardableSitemapBuilder
{
public function __construct(private readonly SitemapUrlBuilder $urls)
{
}
public function name(): string
{
return 'artworks';
}
protected function shardConfigKey(): string
{
return 'artworks';
}
protected function mapRecord(Model $record): ?SitemapUrl
{
return $this->urls->artwork($record);
}
protected function query(): Builder
{
return Artwork::query()
->public()
->published();
}
protected function qualifiedIdColumn(): string
{
return 'artworks.id';
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps\Builders;
use App\Models\NovaCard;
use App\Services\Sitemaps\SitemapUrl;
use App\Services\Sitemaps\SitemapUrlBuilder;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
final class CardsSitemapBuilder extends AbstractIdShardableSitemapBuilder
{
public function __construct(private readonly SitemapUrlBuilder $urls)
{
}
public function name(): string
{
return 'cards';
}
protected function shardConfigKey(): string
{
return 'cards';
}
protected function mapRecord(Model $record): ?SitemapUrl
{
return $this->urls->card($record);
}
protected function query(): Builder
{
return NovaCard::query()
->publiclyVisible()
->orderBy('id');
}
protected function qualifiedIdColumn(): string
{
return 'nova_cards.id';
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps\Builders;
use App\Models\Category;
use App\Models\ContentType;
use App\Services\Sitemaps\AbstractSitemapBuilder;
use App\Services\Sitemaps\SitemapUrlBuilder;
use DateTimeInterface;
final class CategoriesSitemapBuilder extends AbstractSitemapBuilder
{
public function __construct(private readonly SitemapUrlBuilder $urls)
{
}
public function name(): string
{
return 'categories';
}
public function items(): array
{
$items = [$this->urls->categoryDirectory()];
$contentTypes = ContentType::query()
->whereIn('slug', $this->contentTypeSlugs())
->ordered()
->get();
foreach ($contentTypes as $contentType) {
$items[] = $this->urls->contentType($contentType);
}
$categories = Category::query()
->with('contentType')
->active()
->whereHas('contentType', fn ($query) => $query->whereIn('slug', $this->contentTypeSlugs()))
->orderBy('content_type_id')
->orderBy('parent_id')
->orderBy('sort_order')
->orderBy('name')
->get();
foreach ($categories as $category) {
$items[] = $this->urls->category($category);
}
return $items;
}
public function lastModified(): ?DateTimeInterface
{
return $this->dateTime(Category::query()
->active()
->max('updated_at'));
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps\Builders;
use App\Models\Collection;
use App\Services\Sitemaps\SitemapUrl;
use App\Services\Sitemaps\SitemapUrlBuilder;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
final class CollectionsSitemapBuilder extends AbstractIdShardableSitemapBuilder
{
public function __construct(private readonly SitemapUrlBuilder $urls)
{
}
public function name(): string
{
return 'collections';
}
protected function shardConfigKey(): string
{
return 'collections';
}
protected function mapRecord(Model $record): ?SitemapUrl
{
return $this->urls->collection($record);
}
protected function query(): Builder
{
return Collection::query()
->with('user:id,username')
->public()
->whereNull('canonical_collection_id')
->orderBy('id');
}
protected function qualifiedIdColumn(): string
{
return 'collections.id';
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps\Builders;
use App\Services\Sitemaps\AbstractSitemapBuilder;
use App\Services\Sitemaps\SitemapUrlBuilder;
use DateTimeInterface;
use cPad\Plugins\Forum\Models\ForumBoard;
use cPad\Plugins\Forum\Models\ForumCategory;
final class ForumCategoriesSitemapBuilder extends AbstractSitemapBuilder
{
public function __construct(private readonly SitemapUrlBuilder $urls)
{
}
public function name(): string
{
return 'forum-categories';
}
public function items(): array
{
$items = [];
foreach (ForumCategory::query()->active()->ordered()->get() as $category) {
$items[] = $this->urls->forumCategory($category);
}
foreach (ForumBoard::query()->active()->ordered()->get() as $board) {
$items[] = $this->urls->forumBoard($board);
}
return $items;
}
public function lastModified(): ?DateTimeInterface
{
return $this->newest(
ForumCategory::query()->active()->max('updated_at'),
ForumBoard::query()->active()->max('updated_at'),
);
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps\Builders;
use App\Services\Sitemaps\AbstractSitemapBuilder;
use App\Services\Sitemaps\SitemapUrlBuilder;
use DateTimeInterface;
final class ForumIndexSitemapBuilder extends AbstractSitemapBuilder
{
public function __construct(private readonly SitemapUrlBuilder $urls)
{
}
public function name(): string
{
return 'forum-index';
}
public function items(): array
{
return [$this->urls->forumIndex()];
}
public function lastModified(): ?DateTimeInterface
{
return null;
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps\Builders;
use App\Services\Sitemaps\SitemapUrl;
use App\Services\Sitemaps\SitemapUrlBuilder;
use cPad\Plugins\Forum\Models\ForumTopic;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
final class ForumThreadsSitemapBuilder extends AbstractIdShardableSitemapBuilder
{
public function __construct(private readonly SitemapUrlBuilder $urls)
{
}
public function name(): string
{
return 'forum-threads';
}
protected function shardConfigKey(): string
{
return 'forum-threads';
}
protected function mapRecord(Model $record): ?SitemapUrl
{
return $this->urls->forumTopic($record);
}
protected function query(): Builder
{
return ForumTopic::query()
->visible()
->whereHas('board', fn ($query) => $query->active())
->orderBy('id');
}
protected function qualifiedIdColumn(): string
{
return 'forum_topics.id';
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps\Builders;
use App\Services\Sitemaps\AbstractSitemapBuilder;
use App\Services\Sitemaps\GoogleNewsSitemapUrl;
use DateTimeInterface;
use cPad\Plugins\News\Models\NewsArticle;
final class GoogleNewsSitemapBuilder extends AbstractSitemapBuilder
{
public function name(): string
{
return (string) \config('sitemaps.news.google_variant_name', 'news-google');
}
public function items(): array
{
return NewsArticle::query()
->published()
->where('published_at', '>=', now()->subHours(max(1, (int) \config('sitemaps.news.google_lookback_hours', 48))))
->orderByDesc('published_at')
->limit(max(1, (int) \config('sitemaps.news.google_max_items', 1000)))
->get()
->map(function (NewsArticle $article): ?GoogleNewsSitemapUrl {
if (trim((string) $article->slug) === '' || $article->published_at === null) {
return null;
}
return new GoogleNewsSitemapUrl(
route('news.show', ['slug' => $article->slug]),
trim((string) $article->title),
$article->published_at,
(string) \config('sitemaps.news.google_publication_name', 'Skinbase Nova'),
(string) \config('sitemaps.news.google_language', 'en'),
);
})
->filter(fn (?GoogleNewsSitemapUrl $item): bool => $item !== null && $item->title !== '')
->values()
->all();
}
public function lastModified(): ?DateTimeInterface
{
return $this->dateTime(NewsArticle::query()
->published()
->where('published_at', '>=', now()->subHours(max(1, (int) \config('sitemaps.news.google_lookback_hours', 48))))
->max('published_at'));
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps\Builders;
use App\Services\Sitemaps\AbstractSitemapBuilder;
use App\Services\Sitemaps\SitemapUrl;
use App\Services\Sitemaps\SitemapUrlBuilder;
use DateTimeInterface;
use cPad\Plugins\News\Models\NewsArticle;
final class NewsSitemapBuilder extends AbstractSitemapBuilder
{
public function __construct(private readonly SitemapUrlBuilder $urls)
{
}
public function name(): string
{
return 'news';
}
public function items(): array
{
return NewsArticle::query()
->published()
->orderBy('id')
->cursor()
->map(fn (NewsArticle $article): ?SitemapUrl => $this->urls->news($article))
->filter()
->values()
->all();
}
public function lastModified(): ?DateTimeInterface
{
return $this->dateTime(NewsArticle::query()
->published()
->max('updated_at'));
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps\Builders;
use App\Models\Page;
use App\Services\Sitemaps\AbstractSitemapBuilder;
use App\Services\Sitemaps\SitemapUrlBuilder;
use DateTimeInterface;
final class StaticPagesSitemapBuilder extends AbstractSitemapBuilder
{
public function __construct(private readonly SitemapUrlBuilder $urls)
{
}
public function name(): string
{
return 'static-pages';
}
public function items(): array
{
$items = [
$this->urls->staticRoute('/'),
$this->urls->staticRoute('/faq'),
$this->urls->staticRoute('/rules-and-guidelines'),
$this->urls->staticRoute('/privacy-policy'),
$this->urls->staticRoute('/terms-of-service'),
$this->urls->staticRoute('/staff'),
];
$marketingPages = Page::query()
->published()
->whereIn('slug', ['about', 'help'])
->get()
->keyBy('slug');
if ($marketingPages->has('about')) {
$items[] = $this->urls->page($marketingPages['about'], '/about');
}
if ($marketingPages->has('help')) {
$items[] = $this->urls->page($marketingPages['help'], '/help');
}
$excluded = array_values((array) config('sitemaps.static_page_excluded_slugs', []));
foreach (Page::query()->published()->whereNotIn('slug', $excluded)->orderBy('slug')->get() as $page) {
$items[] = $this->urls->page($page, '/pages/' . $page->slug);
}
return $items;
}
public function lastModified(): ?DateTimeInterface
{
return $this->dateTime(Page::query()->published()->max('updated_at'));
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps\Builders;
use App\Models\Story;
use App\Services\Sitemaps\SitemapUrl;
use App\Services\Sitemaps\SitemapUrlBuilder;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
final class StoriesSitemapBuilder extends AbstractIdShardableSitemapBuilder
{
public function __construct(private readonly SitemapUrlBuilder $urls)
{
}
public function name(): string
{
return 'stories';
}
protected function shardConfigKey(): string
{
return 'stories';
}
protected function mapRecord(Model $record): ?SitemapUrl
{
return $this->urls->story($record);
}
protected function query(): Builder
{
return Story::query()
->published()
->orderBy('id');
}
protected function qualifiedIdColumn(): string
{
return 'stories.id';
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps\Builders;
use App\Models\Tag;
use App\Services\Sitemaps\AbstractSitemapBuilder;
use App\Services\Sitemaps\SitemapUrl;
use App\Services\Sitemaps\SitemapUrlBuilder;
use DateTimeInterface;
final class TagsSitemapBuilder extends AbstractSitemapBuilder
{
public function __construct(private readonly SitemapUrlBuilder $urls)
{
}
public function name(): string
{
return 'tags';
}
public function items(): array
{
return Tag::query()
->where('is_active', true)
->where('usage_count', '>', 0)
->whereHas('artworks', fn ($query) => $query->public()->published())
->orderByDesc('usage_count')
->orderBy('slug')
->get()
->map(fn (Tag $tag): SitemapUrl => $this->urls->tag($tag))
->values()
->all();
}
public function lastModified(): ?DateTimeInterface
{
return $this->dateTime(Tag::query()
->where('is_active', true)
->where('usage_count', '>', 0)
->max('updated_at'));
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps\Builders;
use App\Models\Artwork;
use App\Models\Collection;
use App\Models\NovaCard;
use App\Models\Story;
use App\Models\User;
use App\Services\Sitemaps\SitemapUrl;
use App\Services\Sitemaps\SitemapUrlBuilder;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
final class UsersSitemapBuilder extends AbstractIdShardableSitemapBuilder
{
public function __construct(private readonly SitemapUrlBuilder $urls)
{
}
public function name(): string
{
return 'users';
}
protected function shardConfigKey(): string
{
return 'users';
}
protected function mapRecord(Model $record): ?SitemapUrl
{
return $this->urls->profile($record, $record->updated_at);
}
protected function query(): Builder
{
return User::query()
->where('is_active', true)
->whereNull('deleted_at')
->whereNotNull('username')
->where('username', '!=', '')
->where(function (Builder $builder): void {
$builder->whereExists(
Artwork::query()
->selectRaw('1')
->public()
->published()
->whereColumn('artworks.user_id', 'users.id')
)->orWhereExists(
Collection::query()
->selectRaw('1')
->public()
->whereNull('canonical_collection_id')
->whereColumn('collections.user_id', 'users.id')
)->orWhereExists(
NovaCard::query()
->selectRaw('1')
->publiclyVisible()
->whereColumn('nova_cards.user_id', 'users.id')
)->orWhereExists(
Story::query()
->selectRaw('1')
->published()
->whereColumn('stories.creator_id', 'users.id')
);
});
}
protected function qualifiedIdColumn(): string
{
return 'users.id';
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps;
use DateTimeInterface;
final class GoogleNewsSitemapUrl
{
public function __construct(
public readonly string $loc,
public readonly string $title,
public readonly DateTimeInterface $publicationDate,
public readonly string $publicationName,
public readonly string $publicationLanguage,
) {
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps;
final class PublishedSitemapResolver
{
public function __construct(private readonly SitemapReleaseManager $releases)
{
}
/**
* @return array{content: string, release_id: string, document_name: string}|null
*/
public function resolveIndex(): ?array
{
return $this->resolveDocumentName(SitemapCacheService::INDEX_DOCUMENT);
}
/**
* @return array{content: string, release_id: string, document_name: string}|null
*/
public function resolveNamed(string $requestedName): ?array
{
$manifest = $this->releases->activeManifest();
if ($manifest === null) {
return null;
}
$documentName = $this->canonicalDocumentName($requestedName, $manifest);
return $documentName !== null ? $this->resolveDocumentName($documentName) : null;
}
private function resolveDocumentName(string $documentName): ?array
{
$releaseId = $this->releases->activeReleaseId();
if ($releaseId === null) {
return null;
}
$content = $this->releases->getDocument($releaseId, $documentName);
return is_string($content) && $content !== ''
? ['content' => $content, 'release_id' => $releaseId, 'document_name' => $documentName]
: null;
}
private function canonicalDocumentName(string $requestedName, array $manifest): ?string
{
$documents = (array) ($manifest['documents'] ?? []);
if (isset($documents[$requestedName])) {
return $requestedName;
}
foreach ((array) ($manifest['families'] ?? []) as $familyName => $family) {
$entryName = (string) ($family['entry_name'] ?? '');
if ($requestedName === $familyName && $entryName !== '') {
return $entryName;
}
if (preg_match('/^' . preg_quote((string) $familyName, '/') . '-([0-9]+)$/', $requestedName, $matches)) {
$number = (int) $matches[1];
$candidate = sprintf('%s-%04d', $familyName, $number);
if (in_array($candidate, (array) ($family['shards'] ?? []), true)) {
return $candidate;
}
}
}
return null;
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps;
use DateTimeInterface;
interface ShardableSitemapBuilder extends SitemapBuilder
{
public function totalItems(): int;
public function shardSize(): int;
/**
* @return list<SitemapUrl>
*/
public function itemsForShard(int $shard): array;
public function lastModifiedForShard(int $shard): ?DateTimeInterface;
}

View File

@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps;
use App\Services\Sitemaps\Builders\GoogleNewsSitemapBuilder;
final class SitemapBuildService
{
public function __construct(
private readonly SitemapCacheService $cache,
private readonly SitemapIndexService $index,
private readonly SitemapRegistry $registry,
private readonly SitemapShardService $shards,
private readonly SitemapXmlRenderer $renderer,
) {
}
/**
* @return array{content: string, source: string, type: string, url_count: int, shard_count: int, name: string}
*/
public function buildIndex(bool $force = false, bool $persist = true, ?array $families = null): array
{
$built = $this->cache->remember(
SitemapCacheService::INDEX_DOCUMENT,
fn (): string => $this->renderer->renderIndex($this->index->items($families)),
$force,
$persist,
);
return $built + [
'type' => SitemapTarget::TYPE_INDEX,
'url_count' => count($this->index->items($families)),
'shard_count' => 0,
'name' => SitemapCacheService::INDEX_DOCUMENT,
];
}
/**
* @return array{content: string, source: string, type: string, url_count: int, shard_count: int, name: string}|null
*/
public function buildNamed(string $name, bool $force = false, bool $persist = true): ?array
{
$target = $this->shards->resolve($this->registry, $name);
if ($target === null) {
return null;
}
$built = $this->cache->remember(
$target->documentName,
fn (): string => $this->renderTarget($target),
$force,
$persist,
);
return $built + [
'type' => $target->type,
'url_count' => $this->urlCount($target),
'shard_count' => $target->totalShards,
'name' => $target->documentName,
];
}
/**
* @return list<string>
*/
public function documentNamesForFamily(string $family, bool $includeCompatibilityIndex = true): array
{
$builder = $this->registry->get($family);
if ($builder === null) {
return [];
}
$names = $this->shards->canonicalDocumentNamesForBuilder($builder);
if ($includeCompatibilityIndex && $builder instanceof ShardableSitemapBuilder && $this->shards->shardCount($builder) > 1) {
array_unshift($names, $builder->name());
foreach (range(1, $this->shards->shardCount($builder)) as $shard) {
array_unshift($names, $builder->name() . '-' . $shard);
}
}
return array_values(array_unique($names));
}
/**
* @return list<string>
*/
public function canonicalDocumentNamesForFamily(string $family): array
{
$builder = $this->registry->get($family);
if ($builder === null) {
return [];
}
return $this->shards->canonicalDocumentNamesForBuilder($builder);
}
/**
* @return list<string>
*/
public function enabledFamilies(): array
{
return array_values(array_filter(
(array) config('sitemaps.enabled', []),
fn (mixed $name): bool => is_string($name) && $this->registry->get($name) !== null,
));
}
private function renderTarget(SitemapTarget $target): string
{
if ($target->type === SitemapTarget::TYPE_INDEX) {
return $this->renderer->renderIndex($this->index->itemsForBuilder($target->builder));
}
if ($target->builder instanceof GoogleNewsSitemapBuilder || $target->type === SitemapTarget::TYPE_GOOGLE_NEWS) {
return $this->renderer->renderGoogleNewsUrlset($target->builder->items());
}
if ($target->builder instanceof ShardableSitemapBuilder && $target->shardNumber !== null) {
return $this->renderer->renderUrlset($target->builder->itemsForShard($target->shardNumber));
}
return $this->renderer->renderUrlset($target->builder->items());
}
private function urlCount(SitemapTarget $target): int
{
if ($target->type === SitemapTarget::TYPE_INDEX) {
return count($this->index->itemsForBuilder($target->builder));
}
if ($target->builder instanceof ShardableSitemapBuilder && $target->shardNumber !== null) {
return count($target->builder->itemsForShard($target->shardNumber));
}
return count($target->builder->items());
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps;
use DateTimeInterface;
interface SitemapBuilder
{
public function name(): string;
/**
* @return list<SitemapUrl>
*/
public function items(): array;
public function lastModified(): ?DateTimeInterface;
}

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps;
use Closure;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
final class SitemapCacheService
{
public const INDEX_DOCUMENT = 'index';
/**
* @return array{content: string, source: string}|null
*/
public function get(string $name): ?array
{
if ($this->preferPreGenerated()) {
$preGenerated = $this->getPreGenerated($name);
if ($preGenerated !== null) {
return $preGenerated;
}
}
$cached = Cache::get($this->cacheKey($name));
if (is_string($cached) && $cached !== '') {
return ['content' => $cached, 'source' => 'cache'];
}
if (! $this->preferPreGenerated()) {
return $this->getPreGenerated($name);
}
return null;
}
/**
* @return array{content: string, source: string}
*/
public function remember(string $name, Closure $builder, bool $force = false, bool $persist = true): array
{
if (! $force) {
$existing = $this->get($name);
if ($existing !== null) {
return $existing;
}
}
$content = (string) $builder();
if ($persist) {
$this->store($name, $content);
}
return ['content' => $content, 'source' => 'built'];
}
public function store(string $name, string $content): void
{
Cache::put(
$this->cacheKey($name),
$content,
now()->addSeconds(max(60, (int) config('sitemaps.cache_ttl_seconds', 900))),
);
if ($this->preGeneratedEnabled()) {
Storage::disk($this->disk())->put($this->documentPath($name), $content);
}
}
public function clear(array $names): int
{
$cleared = 0;
foreach (array_values(array_unique($names)) as $name) {
Cache::forget($this->cacheKey($name));
if ($this->preGeneratedEnabled()) {
Storage::disk($this->disk())->delete($this->documentPath($name));
}
$cleared++;
}
return $cleared;
}
public function documentPath(string $name): string
{
$prefix = trim((string) config('sitemaps.pre_generated.path', 'generated-sitemaps'), '/');
$segments = $name === self::INDEX_DOCUMENT
? [$prefix, 'sitemap.xml']
: [$prefix, 'sitemaps', $name . '.xml'];
return implode('/', array_values(array_filter($segments, static fn (string $segment): bool => $segment !== '')));
}
private function cacheKey(string $name): string
{
return 'sitemaps:v2:' . $name;
}
/**
* @return array{content: string, source: string}|null
*/
private function getPreGenerated(string $name): ?array
{
if (! $this->preGeneratedEnabled()) {
return null;
}
$disk = Storage::disk($this->disk());
$path = $this->documentPath($name);
if (! $disk->exists($path)) {
return null;
}
$content = $disk->get($path);
if (! is_string($content) || $content === '') {
return null;
}
return ['content' => $content, 'source' => 'pre-generated'];
}
private function disk(): string
{
return (string) config('sitemaps.pre_generated.disk', 'local');
}
private function preGeneratedEnabled(): bool
{
return (bool) config('sitemaps.pre_generated.enabled', true);
}
private function preferPreGenerated(): bool
{
return $this->preGeneratedEnabled() && (bool) config('sitemaps.pre_generated.prefer', false);
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps;
final readonly class SitemapImage
{
public function __construct(
public string $loc,
public ?string $title = null,
) {}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps;
use DateTimeInterface;
final readonly class SitemapIndexItem
{
public function __construct(
public string $loc,
public ?DateTimeInterface $lastModified = null,
) {}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps;
final class SitemapIndexService
{
public function __construct(
private readonly SitemapRegistry $registry,
private readonly SitemapShardService $shards,
) {
}
/**
* @return list<SitemapIndexItem>
*/
public function items(?array $families = null): array
{
$items = [];
foreach ($families ?? (array) config('sitemaps.enabled', []) as $name) {
$builder = $this->registry->get((string) $name);
if ($builder === null) {
continue;
}
$items[] = new SitemapIndexItem(
url('/sitemaps/' . $this->shards->rootEntryName($builder) . '.xml'),
$builder->lastModified(),
);
}
return $items;
}
/**
* @return list<SitemapIndexItem>
*/
public function itemsForBuilder(SitemapBuilder $builder): array
{
if ($builder instanceof ShardableSitemapBuilder && $this->shards->shardCount($builder) > 1) {
$items = [];
foreach (range(1, $this->shards->shardCount($builder)) as $shard) {
$items[] = new SitemapIndexItem(
url('/sitemaps/' . $this->shards->canonicalShardName($builder->name(), $shard) . '.xml'),
$builder->lastModifiedForShard($shard),
);
}
return $items;
}
return [new SitemapIndexItem(
url('/sitemaps/' . $this->shards->rootEntryName($builder) . '.xml'),
$builder->lastModified(),
)];
}
}

View File

@@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps;
use Illuminate\Support\Facades\Cache;
final class SitemapPublishService
{
public function __construct(
private readonly SitemapBuildService $build,
private readonly SitemapReleaseCleanupService $cleanup,
private readonly SitemapReleaseManager $releases,
private readonly SitemapReleaseValidator $validator,
) {
}
/**
* @param list<string>|null $families
* @return array<string, mixed>
*/
public function buildRelease(?array $families = null, ?string $releaseId = null): array
{
return $this->withLock(function () use ($families, $releaseId): array {
return $this->buildReleaseUnlocked($families, $releaseId);
});
}
/**
* @return array<string, mixed>
*/
public function publish(?string $releaseId = null): array
{
return $this->withLock(function () use ($releaseId): array {
$manifest = $releaseId !== null
? $this->releases->readManifest($releaseId)
: $this->buildReleaseUnlocked();
if ($manifest === null) {
throw new \RuntimeException('Sitemap release [' . $releaseId . '] does not exist.');
}
$releaseId = (string) $manifest['release_id'];
$validation = $this->validator->validate($releaseId);
if (! ($validation['ok'] ?? false)) {
$manifest['status'] = 'failed';
$manifest['validation'] = $validation;
$this->releases->writeManifest($releaseId, $manifest);
throw new \RuntimeException('Sitemap release validation failed.');
}
$manifest['status'] = 'published';
$manifest['published_at'] = now()->toAtomString();
$manifest['validation'] = $validation;
$this->releases->writeManifest($releaseId, $manifest);
$this->releases->activate($releaseId);
$deleted = $this->cleanup->cleanup();
return $manifest + ['cleanup_deleted' => $deleted];
});
}
/**
* @return array<string, mixed>
*/
public function rollback(?string $releaseId = null): array
{
return $this->withLock(function () use ($releaseId): array {
if ($releaseId === null) {
$activeReleaseId = $this->releases->activeReleaseId();
foreach ($this->releases->listReleases() as $release) {
if ((string) ($release['status'] ?? '') === 'published' && (string) ($release['release_id'] ?? '') !== $activeReleaseId) {
$releaseId = (string) $release['release_id'];
break;
}
}
}
if (! is_string($releaseId) || $releaseId === '') {
throw new \RuntimeException('No rollback release is available.');
}
$manifest = $this->releases->readManifest($releaseId);
if ($manifest === null) {
throw new \RuntimeException('Rollback release [' . $releaseId . '] not found.');
}
$this->releases->activate($releaseId);
return $manifest + ['rolled_back_at' => now()->toAtomString()];
});
}
/**
* @template TReturn
* @param callable(): TReturn $callback
* @return TReturn
*/
private function withLock(callable $callback): mixed
{
$lock = Cache::lock('sitemaps:publish-flow', max(30, (int) config('sitemaps.releases.lock_seconds', 900)));
if (! $lock->get()) {
throw new \RuntimeException('Another sitemap build or publish operation is already running.');
}
try {
return $callback();
} finally {
$lock->release();
}
}
/**
* @param list<string>|null $families
* @return array<string, mixed>
*/
private function buildReleaseUnlocked(?array $families = null, ?string $releaseId = null): array
{
$selectedFamilies = $families ?: $this->build->enabledFamilies();
$releaseId ??= $this->releases->generateReleaseId();
$familyManifest = [];
$documents = [
SitemapCacheService::INDEX_DOCUMENT => $this->releases->documentRelativePath(SitemapCacheService::INDEX_DOCUMENT),
];
$totalUrls = 0;
$rootIndex = $this->build->buildIndex(true, false, $selectedFamilies);
$this->releases->putDocument($releaseId, SitemapCacheService::INDEX_DOCUMENT, (string) $rootIndex['content']);
foreach ($selectedFamilies as $family) {
$builder = app(SitemapRegistry::class)->get($family);
if ($builder === null) {
continue;
}
$canonicalNames = $this->build->canonicalDocumentNamesForFamily($family);
$shardNames = [];
$urlCount = 0;
foreach ($canonicalNames as $documentName) {
$built = $this->build->buildNamed($documentName, true, false);
if ($built === null) {
throw new \RuntimeException('Failed to build sitemap document [' . $documentName . '].');
}
$this->releases->putDocument($releaseId, $documentName, (string) $built['content']);
$documents[$documentName] = $this->releases->documentRelativePath($documentName);
if ($built['type'] !== SitemapTarget::TYPE_INDEX) {
$urlCount += (int) $built['url_count'];
}
if (str_starts_with($documentName, $family . '-') && ! str_ends_with($documentName, '-index')) {
$shardNames[] = $documentName;
}
}
$totalUrls += $urlCount;
$familyManifest[$family] = [
'family' => $family,
'entry_name' => app(SitemapShardService::class)->rootEntryName($builder),
'documents' => $canonicalNames,
'shards' => $shardNames,
'url_count' => $urlCount,
'shard_count' => count($shardNames),
'type' => $builder->name() === (string) config('sitemaps.news.google_variant_name', 'news-google')
? SitemapTarget::TYPE_GOOGLE_NEWS
: (count($shardNames) > 0 ? SitemapTarget::TYPE_INDEX : SitemapTarget::TYPE_URLSET),
];
}
$manifest = [
'release_id' => $releaseId,
'status' => 'built',
'built_at' => now()->toAtomString(),
'published_at' => null,
'families' => $familyManifest,
'documents' => $documents,
'totals' => [
'families' => count($familyManifest),
'documents' => count($documents),
'urls' => $totalUrls,
],
];
$this->releases->writeManifest($releaseId, $manifest);
$validation = $this->validator->validate($releaseId);
$manifest['status'] = ($validation['ok'] ?? false) ? 'validated' : 'failed';
$manifest['validation'] = $validation;
$this->releases->writeManifest($releaseId, $manifest);
$this->releases->writeBuildReport($releaseId, $validation);
return $manifest;
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps;
use App\Services\Sitemaps\Builders\ArtworksSitemapBuilder;
use App\Services\Sitemaps\Builders\CardsSitemapBuilder;
use App\Services\Sitemaps\Builders\CategoriesSitemapBuilder;
use App\Services\Sitemaps\Builders\CollectionsSitemapBuilder;
use App\Services\Sitemaps\Builders\ForumCategoriesSitemapBuilder;
use App\Services\Sitemaps\Builders\ForumIndexSitemapBuilder;
use App\Services\Sitemaps\Builders\ForumThreadsSitemapBuilder;
use App\Services\Sitemaps\Builders\GoogleNewsSitemapBuilder;
use App\Services\Sitemaps\Builders\NewsSitemapBuilder;
use App\Services\Sitemaps\Builders\StaticPagesSitemapBuilder;
use App\Services\Sitemaps\Builders\StoriesSitemapBuilder;
use App\Services\Sitemaps\Builders\TagsSitemapBuilder;
use App\Services\Sitemaps\Builders\UsersSitemapBuilder;
final class SitemapRegistry
{
/**
* @var array<string, SitemapBuilder>
*/
private array $builders;
public function __construct(
ArtworksSitemapBuilder $artworks,
UsersSitemapBuilder $users,
TagsSitemapBuilder $tags,
CategoriesSitemapBuilder $categories,
CollectionsSitemapBuilder $collections,
CardsSitemapBuilder $cards,
StoriesSitemapBuilder $stories,
NewsSitemapBuilder $news,
GoogleNewsSitemapBuilder $googleNews,
ForumIndexSitemapBuilder $forumIndex,
ForumCategoriesSitemapBuilder $forumCategories,
ForumThreadsSitemapBuilder $forumThreads,
StaticPagesSitemapBuilder $staticPages,
) {
$this->builders = [
$artworks->name() => $artworks,
$users->name() => $users,
$tags->name() => $tags,
$categories->name() => $categories,
$collections->name() => $collections,
$cards->name() => $cards,
$stories->name() => $stories,
$news->name() => $news,
$googleNews->name() => $googleNews,
$forumIndex->name() => $forumIndex,
$forumCategories->name() => $forumCategories,
$forumThreads->name() => $forumThreads,
$staticPages->name() => $staticPages,
];
}
/**
* @return array<string, SitemapBuilder>
*/
public function all(): array
{
return $this->builders;
}
public function get(string $name): ?SitemapBuilder
{
return $this->builders[$name] ?? null;
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps;
final class SitemapReleaseCleanupService
{
public function __construct(private readonly SitemapReleaseManager $releases)
{
}
public function cleanup(): int
{
$activeReleaseId = $this->releases->activeReleaseId();
$successfulKeep = max(1, (int) config('sitemaps.releases.retain_successful', 3));
$failedKeep = max(0, (int) config('sitemaps.releases.retain_failed', 2));
$successfulSeen = 0;
$failedSeen = 0;
$deleted = 0;
foreach ($this->releases->listReleases() as $release) {
$releaseId = (string) ($release['release_id'] ?? '');
if ($releaseId === '' || $releaseId === $activeReleaseId) {
continue;
}
$status = (string) ($release['status'] ?? 'built');
if ($status === 'published') {
$successfulSeen++;
if ($successfulSeen > $successfulKeep) {
$this->releases->deleteRelease($releaseId);
$deleted++;
}
continue;
}
$failedSeen++;
if ($failedSeen > $failedKeep) {
$this->releases->deleteRelease($releaseId);
$deleted++;
}
}
return $deleted;
}
}

View File

@@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
final class SitemapReleaseManager
{
public function generateReleaseId(): string
{
return now()->utc()->format('YmdHis') . '-' . Str::lower((string) Str::ulid());
}
public function releaseExists(string $releaseId): bool
{
return Storage::disk($this->disk())->exists($this->manifestPath($releaseId));
}
public function putDocument(string $releaseId, string $documentName, string $content): void
{
Storage::disk($this->disk())->put($this->releaseDocumentPath($releaseId, $documentName), $content);
}
public function getDocument(string $releaseId, string $documentName): ?string
{
$disk = Storage::disk($this->disk());
$path = $this->releaseDocumentPath($releaseId, $documentName);
if (! $disk->exists($path)) {
return null;
}
$content = $disk->get($path);
return is_string($content) && $content !== '' ? $content : null;
}
public function writeManifest(string $releaseId, array $manifest): void
{
Storage::disk($this->disk())->put($this->manifestPath($releaseId), json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
}
public function readManifest(string $releaseId): ?array
{
$disk = Storage::disk($this->disk());
$path = $this->manifestPath($releaseId);
if (! $disk->exists($path)) {
return null;
}
$decoded = json_decode((string) $disk->get($path), true);
return is_array($decoded) ? $decoded : null;
}
public function writeBuildReport(string $releaseId, array $report): void
{
Storage::disk($this->disk())->put($this->buildReportPath($releaseId), json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
}
public function activate(string $releaseId): void
{
$payload = [
'release_id' => $releaseId,
'activated_at' => now()->toAtomString(),
];
$this->atomicJsonWrite($this->activePointerPath(), $payload);
}
public function activeReleaseId(): ?string
{
$disk = Storage::disk($this->disk());
$path = $this->activePointerPath();
if (! $disk->exists($path)) {
return null;
}
$decoded = json_decode((string) $disk->get($path), true);
return is_array($decoded) && is_string($decoded['release_id'] ?? null)
? $decoded['release_id']
: null;
}
public function activeManifest(): ?array
{
$releaseId = $this->activeReleaseId();
return $releaseId !== null ? $this->readManifest($releaseId) : null;
}
/**
* @return list<array<string, mixed>>
*/
public function listReleases(): array
{
$releases = [];
foreach (Storage::disk($this->disk())->allDirectories($this->releasesRootPath()) as $directory) {
$releaseId = basename($directory);
$manifest = $this->readManifest($releaseId);
if ($manifest !== null) {
$releases[] = $manifest;
}
}
usort($releases, static fn (array $left, array $right): int => strcmp((string) ($right['release_id'] ?? ''), (string) ($left['release_id'] ?? '')));
return $releases;
}
public function deleteRelease(string $releaseId): void
{
Storage::disk($this->disk())->deleteDirectory($this->releaseRootPath($releaseId));
}
public function documentRelativePath(string $documentName): string
{
return $documentName === SitemapCacheService::INDEX_DOCUMENT
? 'sitemap.xml'
: 'sitemaps/' . $documentName . '.xml';
}
public function releaseDocumentPath(string $releaseId, string $documentName): string
{
return $this->releaseRootPath($releaseId) . '/' . $this->documentRelativePath($documentName);
}
public function manifestPath(string $releaseId): string
{
return $this->releaseRootPath($releaseId) . '/manifest.json';
}
public function buildReportPath(string $releaseId): string
{
return $this->releaseRootPath($releaseId) . '/build-report.json';
}
private function releaseRootPath(string $releaseId): string
{
return $this->releasesRootPath() . '/' . $releaseId;
}
private function releasesRootPath(): string
{
return trim((string) config('sitemaps.releases.path', 'sitemaps'), '/') . '/releases';
}
private function activePointerPath(): string
{
return trim((string) config('sitemaps.releases.path', 'sitemaps'), '/') . '/active.json';
}
private function disk(): string
{
return (string) config('sitemaps.releases.disk', 'local');
}
private function atomicJsonWrite(string $relativePath, array $payload): void
{
$disk = Storage::disk($this->disk());
$json = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
try {
$absolutePath = $disk->path($relativePath);
$directory = dirname($absolutePath);
if (! is_dir($directory)) {
mkdir($directory, 0755, true);
}
$temporaryPath = $absolutePath . '.tmp';
file_put_contents($temporaryPath, $json, LOCK_EX);
rename($temporaryPath, $absolutePath);
} catch (\Throwable) {
$disk->put($relativePath, $json);
}
}
}

View File

@@ -0,0 +1,258 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps;
use DOMDocument;
use DOMXPath;
final class SitemapReleaseValidator
{
public function __construct(
private readonly SitemapBuildService $build,
private readonly SitemapReleaseManager $releases,
) {
}
/**
* @return array<string, mixed>
*/
public function validate(string $releaseId): array
{
$manifest = $this->releases->readManifest($releaseId);
if ($manifest === null) {
return [
'ok' => false,
'release_id' => $releaseId,
'errors' => ['Release manifest not found.'],
];
}
$errors = [];
$families = (array) ($manifest['families'] ?? []);
$documents = (array) ($manifest['documents'] ?? []);
$rootContent = $this->releases->getDocument($releaseId, SitemapCacheService::INDEX_DOCUMENT);
$rootXml = is_string($rootContent) ? $this->loadXml($rootContent) : null;
if ($rootXml === null) {
$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)),
);
if ($rootLocs !== $expectedRootLocs) {
$errors[] = 'Root sitemap index does not match the manifest family entries.';
}
}
$reports = [];
foreach ($families as $familyName => $family) {
$familyErrors = [];
$familyWarnings = [];
$seenLocs = [];
$duplicates = [];
foreach ((array) ($family['documents'] ?? []) as $documentName) {
$artifact = $this->releases->getDocument($releaseId, (string) $documentName);
if (! is_string($artifact) || $artifact === '') {
$familyErrors[] = 'Missing artifact [' . $documentName . '].';
continue;
}
$artifactXml = $this->loadXml($artifact);
if ($artifactXml === null) {
$familyErrors[] = 'Invalid XML in artifact [' . $documentName . '].';
continue;
}
$expected = $documentName === SitemapCacheService::INDEX_DOCUMENT
? $this->build->buildIndex(true, false, array_keys($families))
: $this->build->buildNamed((string) $documentName, true, false);
if ($expected === null) {
$familyErrors[] = 'Unable to rebuild expected document [' . $documentName . '] for validation.';
continue;
}
$expectedXml = $this->loadXml((string) $expected['content']);
if ($expectedXml === null) {
$familyErrors[] = 'Expected document [' . $documentName . '] could not be parsed.';
continue;
}
if ((string) $expected['type'] === SitemapTarget::TYPE_INDEX) {
if ($this->extractLocs($artifactXml, 'sitemap') !== $this->extractLocs($expectedXml, 'sitemap')) {
$familyErrors[] = 'Index artifact [' . $documentName . '] does not match expected sitemap references.';
}
continue;
}
$artifactLocs = $this->extractLocs($artifactXml, 'url');
$expectedLocs = $this->extractLocs($expectedXml, 'url');
if ($artifactLocs !== $expectedLocs) {
$familyErrors[] = 'URL artifact [' . $documentName . '] does not match expected canonical URLs.';
}
foreach ($artifactLocs as $loc) {
if (isset($seenLocs[$loc])) {
$duplicates[$loc] = true;
}
$seenLocs[$loc] = true;
$urlError = $this->urlError($loc);
if ($urlError !== null) {
$familyErrors[] = $urlError . ' [' . $loc . ']';
}
}
if ((string) $familyName === (string) config('sitemaps.news.google_variant_name', 'news-google')) {
if ($this->extractNewsTitles($artifactXml) === []) {
$familyErrors[] = 'Google News sitemap contains no valid news:title elements.';
}
}
foreach ($this->extractImageLocs($artifactXml) as $imageLoc) {
if (! preg_match('/^https?:\/\//i', $imageLoc)) {
$familyWarnings[] = 'Non-absolute image URL [' . $imageLoc . ']';
}
}
}
if ($duplicates !== []) {
$familyErrors[] = 'Duplicate URLs detected across family artifacts.';
}
$reports[] = [
'family' => $familyName,
'documents' => count((array) ($family['documents'] ?? [])),
'url_count' => (int) ($family['url_count'] ?? 0),
'shard_count' => (int) ($family['shard_count'] ?? 0),
'errors' => $familyErrors,
'warnings' => $familyWarnings,
];
foreach ($familyErrors as $familyError) {
$errors[] = $familyName . ': ' . $familyError;
}
}
return [
'ok' => $errors === [],
'release_id' => $releaseId,
'errors' => $errors,
'families' => $reports,
'totals' => [
'families' => count($families),
'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)),
],
];
}
private function loadXml(string $content): ?DOMDocument
{
$document = new DOMDocument();
$previous = libxml_use_internal_errors(true);
$loaded = $document->loadXML($content);
libxml_clear_errors();
libxml_use_internal_errors($previous);
return $loaded ? $document : null;
}
/**
* @return list<string>
*/
private function extractLocs(DOMDocument $document, string $nodeName): array
{
$xpath = new DOMXPath($document);
$nodes = $xpath->query('//*[local-name()="' . $nodeName . '"]/*[local-name()="loc"]');
$locs = [];
foreach ($nodes ?: [] as $node) {
$value = trim((string) $node->textContent);
if ($value !== '') {
$locs[] = $value;
}
}
return $locs;
}
/**
* @return list<string>
*/
private function extractImageLocs(DOMDocument $document): array
{
$xpath = new DOMXPath($document);
$nodes = $xpath->query('//*[local-name()="image"]/*[local-name()="loc"]');
$locs = [];
foreach ($nodes ?: [] as $node) {
$value = trim((string) $node->textContent);
if ($value !== '') {
$locs[] = $value;
}
}
return $locs;
}
/**
* @return list<string>
*/
private function extractNewsTitles(DOMDocument $document): array
{
$xpath = new DOMXPath($document);
$nodes = $xpath->query('//*[local-name()="title"]');
$titles = [];
foreach ($nodes ?: [] as $node) {
$value = trim((string) $node->textContent);
if ($value !== '') {
$titles[] = $value;
}
}
return $titles;
}
private function urlError(string $loc): ?string
{
$parts = parse_url($loc);
if (! is_array($parts) || ! isset($parts['scheme'], $parts['host'])) {
return 'Non-absolute URL emitted';
}
if (($parts['query'] ?? '') !== '') {
return 'Query-string URL emitted';
}
if (($parts['fragment'] ?? '') !== '') {
return 'Fragment URL emitted';
}
$path = '/' . ltrim((string) ($parts['path'] ?? '/'), '/');
foreach ((array) config('sitemaps.validation.forbidden_paths', []) as $forbidden) {
if ($forbidden !== '/' && str_contains($path, (string) $forbidden)) {
return 'Non-public path emitted';
}
}
return null;
}
}

View File

@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps;
final class SitemapShardService
{
public function rootEntryName(SitemapBuilder $builder): string
{
$shardCount = $this->shardCount($builder);
if ($builder instanceof ShardableSitemapBuilder && ($shardCount > 1 || $this->forceFamilyIndexes())) {
return $this->familyIndexName($builder->name());
}
return $builder->name();
}
public function shardCount(SitemapBuilder $builder): int
{
if (! $builder instanceof ShardableSitemapBuilder || ! $this->enabledFor($builder->name())) {
return 1;
}
$totalItems = $builder->totalItems();
if ($totalItems <= 0) {
return 0;
}
return max(1, (int) ceil($totalItems / max(1, $builder->shardSize())));
}
/**
* @return list<string>
*/
public function indexNamesForBuilder(SitemapBuilder $builder): array
{
$shardCount = $this->shardCount($builder);
if ($builder instanceof ShardableSitemapBuilder && $shardCount > 1) {
return array_map(fn (int $shard): string => $this->canonicalShardName($builder->name(), $shard), range(1, $shardCount));
}
return [$builder->name()];
}
/**
* @return list<string>
*/
public function canonicalDocumentNamesForBuilder(SitemapBuilder $builder): array
{
$names = $this->indexNamesForBuilder($builder);
if ($builder instanceof ShardableSitemapBuilder && ($this->shardCount($builder) > 1 || $this->forceFamilyIndexes())) {
array_unshift($names, $this->familyIndexName($builder->name()));
}
return array_values(array_unique($names));
}
public function familyIndexName(string $baseName): string
{
return $baseName . '-index';
}
public function canonicalShardName(string $baseName, int $shard): string
{
return sprintf('%s-%s', $baseName, str_pad((string) $shard, $this->padLength(), '0', STR_PAD_LEFT));
}
public function resolve(SitemapRegistry $registry, string $name): ?SitemapTarget
{
$builder = $registry->get($name);
if ($builder !== null) {
$shardCount = $this->shardCount($builder);
if ($builder instanceof ShardableSitemapBuilder && ($shardCount > 1 || $this->forceFamilyIndexes())) {
return new SitemapTarget($name, $this->familyIndexName($builder->name()), $builder->name(), SitemapTarget::TYPE_INDEX, $builder, null, $shardCount);
}
return new SitemapTarget($name, $builder->name(), $builder->name(), $this->targetType($builder), $builder);
}
if (preg_match('/^(.+)-index$/', $name, $indexMatches)) {
$baseName = (string) $indexMatches[1];
$builder = $registry->get($baseName);
if (! $builder instanceof ShardableSitemapBuilder) {
return null;
}
$shardCount = $this->shardCount($builder);
if ($shardCount < 1) {
return null;
}
return new SitemapTarget($name, $this->familyIndexName($baseName), $baseName, SitemapTarget::TYPE_INDEX, $builder, null, $shardCount);
}
if (! preg_match('/^(.+)-([0-9]{1,})$/', $name, $matches)) {
return null;
}
$baseName = (string) $matches[1];
$shardNumber = (int) $matches[2];
$builder = $registry->get($baseName);
if (! $builder instanceof ShardableSitemapBuilder) {
return null;
}
$shardCount = $this->shardCount($builder);
if ($shardCount < 2 || $shardNumber > $shardCount) {
return null;
}
return new SitemapTarget($name, $this->canonicalShardName($baseName, $shardNumber), $baseName, SitemapTarget::TYPE_URLSET, $builder, $shardNumber, $shardCount);
}
private function forceFamilyIndexes(): bool
{
return (bool) config('sitemaps.shards.force_family_indexes', false);
}
private function padLength(): int
{
return max(1, (int) config('sitemaps.shards.zero_pad_length', 4));
}
private function targetType(SitemapBuilder $builder): string
{
return $builder->name() === (string) config('sitemaps.news.google_variant_name', 'news-google')
? SitemapTarget::TYPE_GOOGLE_NEWS
: SitemapTarget::TYPE_URLSET;
}
private function enabledFor(string $family): bool
{
if (! (bool) config('sitemaps.shards.enabled', true)) {
return false;
}
return (int) data_get(config('sitemaps.shards', []), $family . '.size', 0) > 0;
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps;
final class SitemapTarget
{
public const TYPE_INDEX = 'index';
public const TYPE_URLSET = 'urlset';
public const TYPE_GOOGLE_NEWS = 'google-news';
public function __construct(
public readonly string $requestedName,
public readonly string $documentName,
public readonly string $baseName,
public readonly string $type,
public readonly SitemapBuilder $builder,
public readonly ?int $shardNumber = null,
public readonly int $totalShards = 1,
) {
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps;
use DateTimeInterface;
final readonly class SitemapUrl
{
/**
* @param list<SitemapImage> $images
*/
public function __construct(
public string $loc,
public ?DateTimeInterface $lastModified = null,
public array $images = [],
) {}
}

View File

@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps;
use DateTimeInterface;
use App\Models\Artwork;
use App\Models\Category;
use App\Models\Collection;
use App\Models\ContentType;
use App\Models\NovaCard;
use App\Models\Page;
use App\Models\Story;
use App\Models\Tag;
use App\Models\User;
use App\Services\ThumbnailPresenter;
use cPad\Plugins\Forum\Models\ForumBoard;
use cPad\Plugins\Forum\Models\ForumCategory;
use cPad\Plugins\Forum\Models\ForumTopic;
use cPad\Plugins\News\Models\NewsArticle;
use Illuminate\Support\Str;
final class SitemapUrlBuilder extends AbstractSitemapBuilder
{
public function name(): string
{
return 'url-builder';
}
public function items(): array
{
return [];
}
public function lastModified(): ?\Carbon\CarbonInterface
{
return null;
}
public function artwork(Artwork $artwork): ?SitemapUrl
{
$slug = Str::slug((string) ($artwork->slug ?: $artwork->title));
if ($slug === '') {
$slug = (string) $artwork->id;
}
$preview = ThumbnailPresenter::present($artwork, 'xl');
return new SitemapUrl(
route('art.show', ['id' => (int) $artwork->id, 'slug' => $slug]),
$this->newest($artwork->updated_at, $artwork->published_at, $artwork->created_at),
$this->images([
$this->image($preview['url'] ?? null, (string) $artwork->title),
]),
);
}
public function profile(User $user, ?DateTimeInterface $lastModified = null): ?SitemapUrl
{
$username = strtolower(trim((string) $user->username));
if ($username === '') {
return null;
}
return new SitemapUrl(
route('profile.show', ['username' => $username]),
$this->newest($lastModified, $user->updated_at, $user->created_at),
);
}
public function tag(Tag $tag): SitemapUrl
{
return new SitemapUrl(
route('tags.show', ['tag' => $tag->slug]),
$this->newest($tag->updated_at, $tag->created_at),
);
}
public function categoryDirectory(): SitemapUrl
{
return new SitemapUrl(route('categories.index'));
}
public function contentType(ContentType $contentType): SitemapUrl
{
return new SitemapUrl(
url('/' . strtolower((string) $contentType->slug)),
$this->newest($contentType->updated_at, $contentType->created_at),
);
}
public function category(Category $category): SitemapUrl
{
return new SitemapUrl(
$this->absoluteUrl($category->url) ?? url('/'),
$this->newest($category->updated_at, $category->created_at),
);
}
public function collection(Collection $collection): ?SitemapUrl
{
$username = strtolower(trim((string) $collection->user?->username));
if ($username === '' || trim((string) $collection->slug) === '') {
return null;
}
return new SitemapUrl(
route('profile.collections.show', [
'username' => $username,
'slug' => $collection->slug,
]),
$this->newest($collection->updated_at, $collection->published_at, $collection->created_at),
);
}
public function card(NovaCard $card): ?SitemapUrl
{
if (trim((string) $card->slug) === '') {
return null;
}
return new SitemapUrl(
$card->publicUrl(),
$this->newest($card->updated_at, $card->published_at, $card->created_at),
$this->images([
$this->image($card->ogPreviewUrl() ?: $card->previewUrl(), (string) $card->title),
]),
);
}
public function story(Story $story): ?SitemapUrl
{
if (trim((string) $story->slug) === '') {
return null;
}
return new SitemapUrl(
$story->url,
$this->newest($story->updated_at, $story->published_at, $story->created_at),
$this->images([
$this->image($story->cover_url ?: $story->og_image, (string) $story->title),
]),
);
}
public function news(NewsArticle $article): ?SitemapUrl
{
if (trim((string) $article->slug) === '') {
return null;
}
return new SitemapUrl(
route('news.show', ['slug' => $article->slug]),
$this->newest($article->updated_at, $article->published_at, $article->created_at),
$this->images([
$this->image($article->cover_url ?: $article->effectiveOgImage, (string) $article->title),
]),
);
}
public function forumIndex(): SitemapUrl
{
return new SitemapUrl(route('forum.index'));
}
public function forumCategory(ForumCategory $category): SitemapUrl
{
return new SitemapUrl(
route('forum.category.show', ['categorySlug' => $category->slug]),
$this->newest($category->updated_at, $category->created_at),
);
}
public function forumBoard(ForumBoard $board): SitemapUrl
{
return new SitemapUrl(
route('forum.board.show', ['boardSlug' => $board->slug]),
$this->newest($board->updated_at, $board->created_at),
);
}
public function forumTopic(ForumTopic $topic): SitemapUrl
{
return new SitemapUrl(
route('forum.topic.show', ['topic' => $topic->slug]),
$this->newest($topic->last_post_at, $topic->updated_at, $topic->created_at),
);
}
public function staticRoute(string $path, ?\Carbon\CarbonInterface $lastModified = null): SitemapUrl
{
return new SitemapUrl(
url($path),
$lastModified,
);
}
public function page(Page $page, string $path): SitemapUrl
{
return new SitemapUrl(
url($path),
$this->newest($page->updated_at, $page->published_at, $page->created_at),
);
}
}

View File

@@ -0,0 +1,286 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps;
use App\Models\Artwork;
use App\Models\User;
use DOMDocument;
use DOMXPath;
final class SitemapValidationService
{
public function __construct(
private readonly SitemapBuildService $build,
private readonly SitemapIndexService $index,
private readonly SitemapRegistry $registry,
private readonly SitemapShardService $shards,
) {
}
/**
* @param list<string> $onlyFamilies
* @return array<string, mixed>
*/
public function validate(array $onlyFamilies = []): array
{
$families = $onlyFamilies !== []
? 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);
$indexErrors = [];
$indexXml = $this->loadXml($indexBuild['content']);
if ($indexXml === null) {
$indexErrors[] = 'The main sitemap index XML could not be parsed.';
}
$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.';
}
$familyReports = [];
$duplicates = [];
$seenUrls = [];
$totalUrlCount = 0;
$totalShardCount = 0;
foreach ($families as $family) {
$builder = $this->registry->get($family);
if ($builder === null) {
continue;
}
$report = [
'family' => $family,
'documents' => 0,
'url_count' => 0,
'shard_count' => max(1, $this->shards->shardCount($builder)),
'errors' => [],
'warnings' => [],
];
$totalShardCount += $report['shard_count'];
foreach ($this->build->documentNamesForFamily($family, true) as $name) {
$built = $this->build->buildNamed($name, true, false);
if ($built === null) {
$report['errors'][] = 'Unable to resolve sitemap [' . $name . '].';
continue;
}
$document = $this->loadXml($built['content']);
if ($document === null) {
$report['errors'][] = 'Invalid XML emitted for [' . $name . '].';
continue;
}
$report['documents']++;
if ($built['type'] === SitemapTarget::TYPE_INDEX) {
$expectedFamilyLocs = array_map(
static fn (SitemapIndexItem $item): string => $item->loc,
$this->index->itemsForBuilder($builder),
);
$actualFamilyLocs = $this->extractLocs($document, 'sitemap');
if ($actualFamilyLocs !== $expectedFamilyLocs) {
$report['errors'][] = 'Shard compatibility index [' . $name . '] does not reference the expected shard URLs.';
}
continue;
}
$locs = $this->extractLocs($document, 'url');
$report['url_count'] += count($locs);
$totalUrlCount += count($locs);
foreach ($locs as $loc) {
if (isset($seenUrls[$loc])) {
$duplicates[$loc] = ($duplicates[$loc] ?? 1) + 1;
}
$seenUrls[$loc] = true;
$reason = $this->urlError($family, $loc);
if ($reason !== null) {
$report['errors'][] = $reason . ' [' . $loc . ']';
}
}
foreach ($this->extractImageLocs($document) as $imageLoc) {
if (! preg_match('/^https?:\/\//i', $imageLoc)) {
$report['warnings'][] = 'Non-absolute image URL found [' . $imageLoc . ']';
}
}
}
$familyReports[] = $report;
}
return [
'ok' => $indexErrors === [] && $this->familyErrors($familyReports) === [] && $duplicates === [],
'index' => [
'errors' => $indexErrors,
'url_count' => count($actualIndexLocs),
],
'families' => $familyReports,
'duplicates' => array_keys($duplicates),
'totals' => [
'families' => count($familyReports),
'documents' => array_sum(array_map(static fn (array $report): int => (int) $report['documents'], $familyReports)),
'urls' => $totalUrlCount,
'shards' => $totalShardCount,
],
];
}
/**
* @param list<array<string, mixed>> $reports
* @return list<string>
*/
private function familyErrors(array $reports): array
{
$errors = [];
foreach ($reports as $report) {
foreach ((array) ($report['errors'] ?? []) as $error) {
$errors[] = (string) $error;
}
}
return $errors;
}
private function loadXml(string $content): ?DOMDocument
{
$document = new DOMDocument();
$previous = libxml_use_internal_errors(true);
$loaded = $document->loadXML($content);
libxml_clear_errors();
libxml_use_internal_errors($previous);
return $loaded ? $document : null;
}
/**
* @return list<string>
*/
private function extractLocs(DOMDocument $document, string $nodeName): array
{
$xpath = new DOMXPath($document);
$nodes = $xpath->query('//*[local-name()="' . $nodeName . '"]/*[local-name()="loc"]');
$locs = [];
foreach ($nodes ?: [] as $node) {
$value = trim((string) $node->textContent);
if ($value !== '') {
$locs[] = $value;
}
}
return $locs;
}
/**
* @return list<string>
*/
private function extractImageLocs(DOMDocument $document): array
{
$xpath = new DOMXPath($document);
$nodes = $xpath->query('//*[local-name()="image"]/*[local-name()="loc"]');
$locs = [];
foreach ($nodes ?: [] as $node) {
$value = trim((string) $node->textContent);
if ($value !== '') {
$locs[] = $value;
}
}
return $locs;
}
private function isFamilySelected(array $families, string $loc): bool
{
foreach ($families as $family) {
if (str_contains($loc, '/sitemaps/' . $family . '.xml') || str_contains($loc, '/sitemaps/' . $family . '-')) {
return true;
}
}
return false;
}
private function urlError(string $family, string $loc): ?string
{
$parts = parse_url($loc);
if (! is_array($parts) || ! isset($parts['scheme'], $parts['host'])) {
return 'Non-absolute URL emitted';
}
if (($parts['query'] ?? '') !== '') {
return 'Query-string URL emitted';
}
if (($parts['fragment'] ?? '') !== '') {
return 'Fragment URL emitted';
}
$path = '/' . ltrim((string) ($parts['path'] ?? '/'), '/');
foreach ((array) config('sitemaps.validation.forbidden_paths', []) as $forbidden) {
if ($forbidden !== '/' && str_contains($path, (string) $forbidden)) {
return 'Non-public path emitted';
}
}
return match ($family) {
'artworks' => $this->validateArtworkUrl($path),
'users' => $this->validateUserUrl($path),
default => null,
};
}
private function validateArtworkUrl(string $path): ?string
{
if (! preg_match('~^/art/(\d+)(?:/[^/?#]+)?$~', $path, $matches)) {
return 'Non-canonical artwork URL emitted';
}
$artwork = Artwork::query()->public()->published()->find((int) $matches[1]);
return $artwork === null ? 'Non-public artwork URL emitted' : null;
}
private function validateUserUrl(string $path): ?string
{
if (! preg_match('#^/@([A-Za-z0-9_\-]+)$#', $path, $matches)) {
return 'Non-canonical user URL emitted';
}
$username = strtolower((string) $matches[1]);
$user = User::query()
->where('is_active', true)
->whereNull('deleted_at')
->whereRaw('LOWER(username) = ?', [$username])
->first();
return $user === null ? 'Non-public user URL emitted' : null;
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps;
use Illuminate\Http\Response;
final class SitemapXmlRenderer
{
/**
* @param list<SitemapIndexItem> $items
*/
public function renderIndex(array $items): string
{
return \view('sitemaps.index', [
'items' => $items,
])->render();
}
/**
* @param list<SitemapUrl> $items
*/
public function renderUrlset(array $items): string
{
return \view('sitemaps.urlset', [
'items' => $items,
'hasImages' => \collect($items)->contains(fn (SitemapUrl $item): bool => $item->images !== []),
])->render();
}
/**
* @param list<GoogleNewsSitemapUrl> $items
*/
public function renderGoogleNewsUrlset(array $items): string
{
return \view('sitemaps.news-urlset', [
'items' => $items,
])->render();
}
public function xmlResponse(string $content): Response
{
return \response($content, 200, [
'Content-Type' => 'application/xml; charset=UTF-8',
'Cache-Control' => 'public, max-age=' . max(60, (int) \config('sitemaps.cache_ttl_seconds', 900)),
]);
}
}