699 lines
24 KiB
PHP
699 lines
24 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\Artwork;
|
|
use App\Models\Category;
|
|
use App\Models\Collection;
|
|
use App\Models\ContentType;
|
|
use App\Models\NovaCard;
|
|
use App\Models\NovaCardCategory;
|
|
use App\Models\NovaCardTemplate;
|
|
use App\Models\Page;
|
|
use App\Models\Tag;
|
|
use App\Models\User;
|
|
use Illuminate\Database\Schema\Blueprint;
|
|
use Illuminate\Support\Facades\Artisan;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
|
|
beforeEach(function (): void {
|
|
config([
|
|
'app.url' => 'http://skinbase26.test',
|
|
'sitemaps.pre_generated.enabled' => false,
|
|
'sitemaps.pre_generated.prefer' => false,
|
|
'sitemaps.delivery.prefer_published_release' => true,
|
|
'sitemaps.delivery.fallback_to_live_build' => true,
|
|
]);
|
|
Cache::flush();
|
|
|
|
ensureNewsSchema();
|
|
ensureForumSchema();
|
|
});
|
|
|
|
it('renders the sitemap index and every child sitemap endpoint', function (): void {
|
|
seedSitemapFixtures();
|
|
|
|
$indexResponse = $this->get('/sitemap.xml')
|
|
->assertOk()
|
|
->assertHeader('Content-Type', 'application/xml; charset=UTF-8');
|
|
|
|
$indexXml = simplexml_load_string($indexResponse->getContent());
|
|
|
|
expect($indexXml)->not->toBeFalse();
|
|
expect($indexResponse->getContent())
|
|
->toContain(url('/sitemaps/artworks.xml'))
|
|
->toContain(url('/sitemaps/static-pages.xml'))
|
|
->toContain(url('/sitemaps/forum-threads.xml'))
|
|
->toContain(url('/sitemaps/news-google.xml'));
|
|
|
|
foreach ([
|
|
'artworks',
|
|
'users',
|
|
'tags',
|
|
'categories',
|
|
'collections',
|
|
'cards',
|
|
'stories',
|
|
'news',
|
|
'news-google',
|
|
'forum-index',
|
|
'forum-categories',
|
|
'forum-threads',
|
|
'static-pages',
|
|
] as $name) {
|
|
$response = $this->get('/sitemaps/' . $name . '.xml')
|
|
->assertOk()
|
|
->assertHeader('Content-Type', 'application/xml; charset=UTF-8');
|
|
|
|
expect(simplexml_load_string($response->getContent()))->not->toBeFalse();
|
|
}
|
|
});
|
|
|
|
it('includes only public canonical urls and exposes the sitemap in robots txt', function (): void {
|
|
$fixtures = seedSitemapFixtures();
|
|
|
|
$artworksXml = $this->get('/sitemaps/artworks.xml')->assertOk()->getContent();
|
|
expect($artworksXml)
|
|
->toContain($fixtures['artwork_url'])
|
|
->toContain('<image:image>')
|
|
->not->toContain('/art/' . $fixtures['private_artwork_id'] . '/');
|
|
|
|
$usersXml = $this->get('/sitemaps/users.xml')->assertOk()->getContent();
|
|
expect($usersXml)
|
|
->toContain($fixtures['profile_url'])
|
|
->not->toContain('/@inactivecreator');
|
|
|
|
$tagsXml = $this->get('/sitemaps/tags.xml')->assertOk()->getContent();
|
|
expect($tagsXml)
|
|
->toContain('/tag/featured-tag')
|
|
->not->toContain('/tag/inactive-tag');
|
|
|
|
$categoriesXml = $this->get('/sitemaps/categories.xml')->assertOk()->getContent();
|
|
expect($categoriesXml)
|
|
->toContain('/categories')
|
|
->toContain('/photography')
|
|
->toContain('/photography/portraits');
|
|
|
|
$collectionsXml = $this->get('/sitemaps/collections.xml')->assertOk()->getContent();
|
|
expect($collectionsXml)->toContain($fixtures['collection_url']);
|
|
|
|
$cardsXml = $this->get('/sitemaps/cards.xml')->assertOk()->getContent();
|
|
expect($cardsXml)
|
|
->toContain($fixtures['card_url'])
|
|
->toContain('<image:image>');
|
|
|
|
$storiesXml = $this->get('/sitemaps/stories.xml')->assertOk()->getContent();
|
|
expect($storiesXml)
|
|
->toContain('/stories/creator-spotlight')
|
|
->toContain('<image:image>');
|
|
|
|
$newsXml = $this->get('/sitemaps/news.xml')->assertOk()->getContent();
|
|
expect($newsXml)
|
|
->toContain('/news/platform-update')
|
|
->toContain('<image:image>');
|
|
|
|
$googleNewsXml = $this->get('/sitemaps/news-google.xml')->assertOk()->getContent();
|
|
expect($googleNewsXml)
|
|
->toContain('<news:news>')
|
|
->toContain('<news:title>Platform Update</news:title>');
|
|
|
|
$forumIndexXml = $this->get('/sitemaps/forum-index.xml')->assertOk()->getContent();
|
|
expect($forumIndexXml)->toContain('/forum');
|
|
|
|
$forumCategoriesXml = $this->get('/sitemaps/forum-categories.xml')->assertOk()->getContent();
|
|
expect($forumCategoriesXml)
|
|
->toContain('/forum/category/general')
|
|
->toContain('/forum/general-board');
|
|
|
|
$forumThreadsXml = $this->get('/sitemaps/forum-threads.xml')->assertOk()->getContent();
|
|
expect($forumThreadsXml)
|
|
->toContain('/forum/topic/welcome-thread')
|
|
->not->toContain('#reply-content');
|
|
|
|
$staticPagesXml = $this->get('/sitemaps/static-pages.xml')->assertOk()->getContent();
|
|
expect($staticPagesXml)
|
|
->toContain('http://skinbase26.test/')
|
|
->toContain('/about')
|
|
->toContain('/pages/community-handbook')
|
|
->not->toContain('/pages/about');
|
|
|
|
$robots = $this->get('/robots.txt')
|
|
->assertOk()
|
|
->assertHeader('Content-Type', 'text/plain; charset=UTF-8')
|
|
->getContent();
|
|
|
|
expect($robots)->toContain('Sitemap: http://skinbase26.test/sitemap.xml');
|
|
});
|
|
|
|
it('returns 404 for unknown sitemap names', function (): void {
|
|
$this->get('/sitemaps/not-a-real-sitemap.xml')->assertNotFound();
|
|
});
|
|
|
|
it('lists shard urls in the sitemap index and serves shard compatibility indexes', function (): void {
|
|
config([
|
|
'sitemaps.shards.enabled' => true,
|
|
'sitemaps.shards.artworks.size' => 1,
|
|
'sitemaps.shards.users.size' => 1,
|
|
]);
|
|
|
|
$fixtures = seedSitemapFixtures();
|
|
$secondCreator = User::factory()->create([
|
|
'username' => 'sitemapuser2',
|
|
'is_active' => true,
|
|
]);
|
|
|
|
$secondArtwork = Artwork::factory()->create([
|
|
'user_id' => $secondCreator->id,
|
|
'title' => 'Second Sitemap Artwork',
|
|
'slug' => 'second-sitemap-artwork',
|
|
'hash' => str_repeat('d', 32),
|
|
'file_path' => 'artworks/original/dd/dd/' . str_repeat('d', 32) . '.jpg',
|
|
'is_public' => true,
|
|
'is_approved' => true,
|
|
'published_at' => now()->subHours(12),
|
|
]);
|
|
|
|
$indexXml = $this->get('/sitemap.xml')->assertOk()->getContent();
|
|
expect($indexXml)
|
|
->toContain('/sitemaps/artworks-index.xml')
|
|
->toContain('/sitemaps/users-index.xml');
|
|
|
|
$artworksCompatibilityIndex = $this->get('/sitemaps/artworks.xml')->assertOk()->getContent();
|
|
expect($artworksCompatibilityIndex)
|
|
->toContain('/sitemaps/artworks-0001.xml')
|
|
->toContain('/sitemaps/artworks-0002.xml');
|
|
|
|
$artworksShardOne = $this->get('/sitemaps/artworks-1.xml')->assertOk()->getContent();
|
|
$artworksShardTwo = $this->get('/sitemaps/artworks-2.xml')->assertOk()->getContent();
|
|
|
|
expect($artworksShardOne)
|
|
->toContain($fixtures['artwork_url'])
|
|
->not->toContain('/art/' . $secondArtwork->id . '/');
|
|
expect($artworksShardTwo)->toContain('/art/' . $secondArtwork->id . '/');
|
|
|
|
$allArtworkLocs = array_merge(extractUrlLocs($artworksShardOne), extractUrlLocs($artworksShardTwo));
|
|
expect(array_unique($allArtworkLocs))->toHaveCount(count($allArtworkLocs));
|
|
|
|
$this->get('/sitemaps/artworks-99.xml')->assertNotFound();
|
|
});
|
|
|
|
it('build command creates a validated sitemap release artifact set', function (): void {
|
|
Storage::fake('local');
|
|
|
|
config([
|
|
'sitemaps.shards.enabled' => true,
|
|
'sitemaps.shards.artworks.size' => 1,
|
|
'sitemaps.shards.users.size' => 1,
|
|
'sitemaps.releases.disk' => 'local',
|
|
'sitemaps.releases.path' => 'sitemaps',
|
|
]);
|
|
|
|
seedSitemapFixtures();
|
|
$secondCreator = User::factory()->create([
|
|
'username' => 'warmbuilder',
|
|
'is_active' => true,
|
|
]);
|
|
Artwork::factory()->create([
|
|
'user_id' => $secondCreator->id,
|
|
'title' => 'Warm Builder Artwork',
|
|
'slug' => 'warm-builder-artwork',
|
|
'hash' => str_repeat('e', 32),
|
|
'file_path' => 'artworks/original/ee/ee/' . str_repeat('e', 32) . '.jpg',
|
|
'is_public' => true,
|
|
'is_approved' => true,
|
|
'published_at' => now()->subHours(6),
|
|
]);
|
|
|
|
$code = Artisan::call('skinbase:sitemaps:build', [
|
|
'--force' => true,
|
|
'--shards' => true,
|
|
]);
|
|
|
|
expect($code)->toBe(0)
|
|
->and(Artisan::output())
|
|
->toContain('Family [artworks] complete')
|
|
->toContain('Family [users] complete')
|
|
->toContain('Sitemap release [')
|
|
->toContain('status=validated');
|
|
|
|
$releases = app(\App\Services\Sitemaps\SitemapReleaseManager::class)->listReleases();
|
|
|
|
expect($releases)->toHaveCount(1)
|
|
->and($releases[0]['status'])->toBe('validated');
|
|
|
|
$releaseId = $releases[0]['release_id'];
|
|
|
|
Storage::disk('local')->assertExists('sitemaps/releases/' . $releaseId . '/sitemap.xml');
|
|
Storage::disk('local')->assertExists('sitemaps/releases/' . $releaseId . '/sitemaps/artworks-index.xml');
|
|
Storage::disk('local')->assertExists('sitemaps/releases/' . $releaseId . '/sitemaps/artworks-0001.xml');
|
|
Storage::disk('local')->assertExists('sitemaps/releases/' . $releaseId . '/manifest.json');
|
|
});
|
|
|
|
it('publish and validate commands serve the active sitemap release', function (): void {
|
|
Storage::fake('local');
|
|
|
|
config([
|
|
'sitemaps.shards.enabled' => true,
|
|
'sitemaps.shards.artworks.size' => 1,
|
|
'sitemaps.shards.users.size' => 1,
|
|
'sitemaps.releases.disk' => 'local',
|
|
'sitemaps.releases.path' => 'sitemaps',
|
|
]);
|
|
|
|
seedSitemapFixtures();
|
|
$secondCreator = User::factory()->create([
|
|
'username' => 'validatoruser',
|
|
'is_active' => true,
|
|
]);
|
|
Artwork::factory()->create([
|
|
'user_id' => $secondCreator->id,
|
|
'title' => 'Validator Artwork',
|
|
'slug' => 'validator-artwork',
|
|
'hash' => str_repeat('f', 32),
|
|
'file_path' => 'artworks/original/ff/ff/' . str_repeat('f', 32) . '.jpg',
|
|
'is_public' => true,
|
|
'is_approved' => true,
|
|
'published_at' => now()->subHours(5),
|
|
]);
|
|
|
|
$publishCode = Artisan::call('skinbase:sitemaps:publish');
|
|
|
|
expect($publishCode)->toBe(0)
|
|
->and(Artisan::output())->toContain('Published sitemap release');
|
|
|
|
$activeRelease = app(\App\Services\Sitemaps\SitemapReleaseManager::class)->activeReleaseId();
|
|
expect($activeRelease)->not->toBeNull();
|
|
|
|
$indexXml = $this->get('/sitemap.xml')->assertOk()->getContent();
|
|
expect($indexXml)
|
|
->toContain('/sitemaps/artworks-index.xml')
|
|
->toContain('/sitemaps/users-index.xml');
|
|
|
|
$artworksXml = $this->get('/sitemaps/artworks.xml')->assertOk()->getContent();
|
|
expect($artworksXml)->toContain('/sitemaps/artworks-0001.xml');
|
|
|
|
$code = Artisan::call('skinbase:sitemaps:validate', ['--active' => true]);
|
|
|
|
expect($code)->toBe(0)
|
|
->and(Artisan::output())
|
|
->toContain('Family [artworks]')
|
|
->toContain('Family [users]')
|
|
->toContain('Sitemap validation passed.');
|
|
});
|
|
|
|
it('can rollback sitemap delivery to the previous published release', function (): void {
|
|
Storage::fake('local');
|
|
|
|
config([
|
|
'sitemaps.shards.enabled' => true,
|
|
'sitemaps.shards.artworks.size' => 1,
|
|
'sitemaps.shards.users.size' => 1,
|
|
'sitemaps.releases.disk' => 'local',
|
|
'sitemaps.releases.path' => 'sitemaps',
|
|
]);
|
|
|
|
$fixtures = seedSitemapFixtures();
|
|
|
|
expect(Artisan::call('skinbase:sitemaps:publish'))->toBe(0);
|
|
$firstRelease = app(\App\Services\Sitemaps\SitemapReleaseManager::class)->activeReleaseId();
|
|
|
|
$secondCreator = User::factory()->create([
|
|
'username' => 'rollbackuser',
|
|
'is_active' => true,
|
|
]);
|
|
|
|
$newArtwork = Artwork::factory()->create([
|
|
'user_id' => $secondCreator->id,
|
|
'title' => 'Rollback Artwork',
|
|
'slug' => 'rollback-artwork',
|
|
'hash' => str_repeat('9', 32),
|
|
'file_path' => 'artworks/original/99/99/' . str_repeat('9', 32) . '.jpg',
|
|
'is_public' => true,
|
|
'is_approved' => true,
|
|
'published_at' => now()->subHours(2),
|
|
]);
|
|
|
|
expect(Artisan::call('skinbase:sitemaps:publish'))->toBe(0);
|
|
$secondRelease = app(\App\Services\Sitemaps\SitemapReleaseManager::class)->activeReleaseId();
|
|
|
|
expect($secondRelease)->not->toBe($firstRelease);
|
|
|
|
$currentIndex = $this->get('/sitemaps/artworks.xml')->assertOk()->getContent();
|
|
expect($currentIndex)->toContain('/sitemaps/artworks-0002.xml');
|
|
|
|
expect(Artisan::call('skinbase:sitemaps:rollback', ['release' => $firstRelease]))->toBe(0);
|
|
|
|
$rolledBackIndex = $this->get('/sitemaps/artworks.xml')->assertOk()->getContent();
|
|
expect($rolledBackIndex)
|
|
->toContain($fixtures['artwork_url'])
|
|
->not->toContain('/art/' . $newArtwork->id . '/');
|
|
});
|
|
|
|
/**
|
|
* @return array<string, int|string>
|
|
*/
|
|
function seedSitemapFixtures(): array
|
|
{
|
|
$creator = User::factory()->create([
|
|
'username' => 'sitemapuser',
|
|
'is_active' => true,
|
|
]);
|
|
|
|
$inactiveCreator = User::factory()->create([
|
|
'username' => 'inactivecreator',
|
|
'is_active' => false,
|
|
]);
|
|
|
|
$contentType = ContentType::query()->create([
|
|
'name' => 'Photography',
|
|
'slug' => 'photography',
|
|
'description' => 'Photography on Skinbase',
|
|
'order' => 1,
|
|
]);
|
|
|
|
$category = Category::query()->create([
|
|
'content_type_id' => $contentType->id,
|
|
'parent_id' => null,
|
|
'name' => 'Portraits',
|
|
'slug' => 'portraits',
|
|
'description' => 'Portrait photography',
|
|
'is_active' => true,
|
|
'sort_order' => 1,
|
|
]);
|
|
|
|
$publicTag = Tag::factory()->create([
|
|
'name' => 'Featured Tag',
|
|
'slug' => 'featured-tag',
|
|
'usage_count' => 10,
|
|
'is_active' => true,
|
|
]);
|
|
|
|
$inactiveTag = Tag::factory()->inactive()->create([
|
|
'name' => 'Inactive Tag',
|
|
'slug' => 'inactive-tag',
|
|
'usage_count' => 10,
|
|
]);
|
|
|
|
$artwork = Artwork::factory()->create([
|
|
'user_id' => $creator->id,
|
|
'title' => 'Sitemap Artwork',
|
|
'slug' => 'sitemap-artwork',
|
|
'hash' => str_repeat('a', 32),
|
|
'file_path' => 'artworks/original/aa/aa/' . str_repeat('a', 32) . '.jpg',
|
|
'is_public' => true,
|
|
'is_approved' => true,
|
|
'published_at' => now()->subDay(),
|
|
]);
|
|
$artwork->categories()->attach($category->id);
|
|
$artwork->tags()->attach($publicTag->id, ['source' => 'user']);
|
|
|
|
$privateArtwork = Artwork::factory()->create([
|
|
'user_id' => $inactiveCreator->id,
|
|
'title' => 'Private Artwork',
|
|
'slug' => 'private-artwork',
|
|
'is_public' => false,
|
|
'is_approved' => true,
|
|
'published_at' => now()->subDay(),
|
|
]);
|
|
$privateArtwork->tags()->attach($inactiveTag->id, ['source' => 'user']);
|
|
|
|
$collection = Collection::factory()->create([
|
|
'user_id' => $creator->id,
|
|
'title' => 'Showcase Set',
|
|
'slug' => 'showcase-set',
|
|
'canonical_collection_id' => null,
|
|
]);
|
|
|
|
$cardCategory = NovaCardCategory::query()->create([
|
|
'slug' => 'mindset',
|
|
'name' => 'Mindset',
|
|
'description' => 'Mindset cards',
|
|
'active' => true,
|
|
'order_num' => 0,
|
|
]);
|
|
|
|
$cardTemplate = NovaCardTemplate::query()->create([
|
|
'slug' => 'sitemap-template',
|
|
'name' => 'Sitemap Template',
|
|
'description' => 'Sitemap test template',
|
|
'config_json' => ['font_preset' => 'modern-sans'],
|
|
'supported_formats' => ['square'],
|
|
'active' => true,
|
|
'official' => true,
|
|
'order_num' => 0,
|
|
]);
|
|
|
|
$card = NovaCard::query()->create([
|
|
'user_id' => $creator->id,
|
|
'category_id' => $cardCategory->id,
|
|
'template_id' => $cardTemplate->id,
|
|
'title' => 'Clarity Card',
|
|
'slug' => 'clarity-card',
|
|
'quote_text' => 'Clarity is a public card.',
|
|
'description' => 'A public card used for the sitemap test.',
|
|
'format' => NovaCard::FORMAT_SQUARE,
|
|
'project_json' => ['layout' => ['layout' => 'quote_heavy']],
|
|
'render_version' => 1,
|
|
'background_type' => 'gradient',
|
|
'preview_path' => 'cards/previews/clarity-card.webp',
|
|
'visibility' => NovaCard::VISIBILITY_PUBLIC,
|
|
'status' => NovaCard::STATUS_PUBLISHED,
|
|
'moderation_status' => NovaCard::MOD_APPROVED,
|
|
'published_at' => now()->subDay(),
|
|
]);
|
|
|
|
insertFiltered('stories', [
|
|
'slug' => 'creator-spotlight',
|
|
'title' => 'Creator Spotlight',
|
|
'excerpt' => 'A public story for the sitemap test.',
|
|
'content' => 'Story body',
|
|
'cover_image' => '/images/stories/creator-spotlight.webp',
|
|
'creator_id' => $creator->id,
|
|
'author_id' => null,
|
|
'featured' => true,
|
|
'status' => 'published',
|
|
'published_at' => now()->subDay(),
|
|
]);
|
|
|
|
Page::factory()->create([
|
|
'slug' => 'about',
|
|
'title' => 'About Skinbase',
|
|
'is_published' => true,
|
|
'published_at' => now()->subMonth(),
|
|
]);
|
|
|
|
Page::factory()->create([
|
|
'slug' => 'community-handbook',
|
|
'title' => 'Community Handbook',
|
|
'is_published' => true,
|
|
'published_at' => now()->subWeek(),
|
|
]);
|
|
|
|
$newsCategoryId = insertFiltered('news_categories', [
|
|
'name' => 'Updates',
|
|
'slug' => 'updates',
|
|
'is_active' => true,
|
|
]);
|
|
|
|
insertFiltered('news_articles', [
|
|
'title' => 'Platform Update',
|
|
'slug' => 'platform-update',
|
|
'excerpt' => 'A news article for the sitemap test.',
|
|
'content' => 'News body',
|
|
'cover_image' => 'news/platform-update.webp',
|
|
'author_id' => $creator->id,
|
|
'category_id' => $newsCategoryId,
|
|
'status' => 'published',
|
|
'published_at' => now()->subDay(),
|
|
'is_featured' => true,
|
|
'views' => 10,
|
|
]);
|
|
|
|
$forumCategoryId = insertFiltered('forum_categories', [
|
|
'name' => 'General',
|
|
'title' => 'General',
|
|
'slug' => 'general',
|
|
'description' => 'General forum category',
|
|
'is_active' => true,
|
|
'parent_id' => null,
|
|
'position' => 1,
|
|
]);
|
|
|
|
$forumBoardId = insertFiltered('forum_boards', [
|
|
'category_id' => $forumCategoryId,
|
|
'legacy_category_id' => null,
|
|
'title' => 'General Board',
|
|
'slug' => 'general-board',
|
|
'description' => 'General board',
|
|
'position' => 1,
|
|
'is_active' => true,
|
|
'is_read_only' => false,
|
|
]);
|
|
|
|
insertFiltered('forum_topics', [
|
|
'board_id' => $forumBoardId,
|
|
'user_id' => $creator->id,
|
|
'title' => 'Welcome Thread',
|
|
'slug' => 'welcome-thread',
|
|
'views' => 1,
|
|
'replies_count' => 0,
|
|
'is_pinned' => false,
|
|
'is_locked' => false,
|
|
'is_deleted' => false,
|
|
'last_post_at' => now()->subHours(2),
|
|
]);
|
|
|
|
return [
|
|
'artwork_url' => route('art.show', [
|
|
'id' => $artwork->id,
|
|
'slug' => Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: (string) $artwork->id,
|
|
]),
|
|
'private_artwork_id' => $privateArtwork->id,
|
|
'profile_url' => route('profile.show', ['username' => 'sitemapuser']),
|
|
'collection_url' => route('profile.collections.show', ['username' => 'sitemapuser', 'slug' => 'showcase-set']),
|
|
'card_url' => route('cards.show', ['slug' => 'clarity-card', 'id' => $card->id]),
|
|
];
|
|
}
|
|
|
|
function ensureNewsSchema(): void
|
|
{
|
|
if (! Schema::hasTable('news_categories')) {
|
|
Schema::create('news_categories', function (Blueprint $table): void {
|
|
$table->id();
|
|
$table->string('name');
|
|
$table->string('slug')->unique();
|
|
$table->boolean('is_active')->default(true);
|
|
$table->timestamps();
|
|
});
|
|
}
|
|
|
|
if (! Schema::hasTable('news_articles')) {
|
|
Schema::create('news_articles', function (Blueprint $table): void {
|
|
$table->id();
|
|
$table->string('title');
|
|
$table->string('slug')->unique();
|
|
$table->text('excerpt')->nullable();
|
|
$table->longText('content')->nullable();
|
|
$table->string('cover_image')->nullable();
|
|
$table->foreignId('author_id')->nullable();
|
|
$table->foreignId('category_id')->nullable();
|
|
$table->string('status')->default('draft');
|
|
$table->timestamp('published_at')->nullable();
|
|
$table->boolean('is_featured')->default(false);
|
|
$table->unsignedInteger('views')->default(0);
|
|
$table->softDeletes();
|
|
$table->timestamps();
|
|
});
|
|
}
|
|
}
|
|
|
|
function ensureForumSchema(): void
|
|
{
|
|
if (! Schema::hasTable('forum_categories')) {
|
|
Schema::create('forum_categories', function (Blueprint $table): void {
|
|
$table->id();
|
|
$table->string('name');
|
|
$table->string('title')->nullable();
|
|
$table->string('slug')->unique();
|
|
$table->text('description')->nullable();
|
|
$table->boolean('is_active')->default(true);
|
|
$table->foreignId('parent_id')->nullable();
|
|
$table->integer('position')->default(0);
|
|
$table->timestamps();
|
|
});
|
|
} else {
|
|
ensureColumn('forum_categories', 'title', fn (Blueprint $table) => $table->string('title')->nullable());
|
|
ensureColumn('forum_categories', 'description', fn (Blueprint $table) => $table->text('description')->nullable());
|
|
ensureColumn('forum_categories', 'is_active', fn (Blueprint $table) => $table->boolean('is_active')->default(true));
|
|
}
|
|
|
|
if (! Schema::hasTable('forum_boards')) {
|
|
Schema::create('forum_boards', function (Blueprint $table): void {
|
|
$table->id();
|
|
$table->foreignId('category_id');
|
|
$table->foreignId('legacy_category_id')->nullable();
|
|
$table->string('title');
|
|
$table->string('slug')->unique();
|
|
$table->text('description')->nullable();
|
|
$table->integer('position')->default(0);
|
|
$table->boolean('is_active')->default(true);
|
|
$table->boolean('is_read_only')->default(false);
|
|
$table->timestamps();
|
|
});
|
|
}
|
|
|
|
if (! Schema::hasTable('forum_topics')) {
|
|
Schema::create('forum_topics', function (Blueprint $table): void {
|
|
$table->id();
|
|
$table->foreignId('board_id');
|
|
$table->foreignId('user_id')->nullable();
|
|
$table->foreignId('artwork_id')->nullable();
|
|
$table->unsignedBigInteger('legacy_thread_id')->nullable();
|
|
$table->string('title');
|
|
$table->string('slug')->unique();
|
|
$table->unsignedInteger('views')->default(0);
|
|
$table->unsignedInteger('replies_count')->default(0);
|
|
$table->boolean('is_pinned')->default(false);
|
|
$table->boolean('is_locked')->default(false);
|
|
$table->boolean('is_deleted')->default(false);
|
|
$table->unsignedBigInteger('last_post_id')->nullable();
|
|
$table->timestamp('last_post_at')->nullable();
|
|
$table->timestamps();
|
|
});
|
|
}
|
|
}
|
|
|
|
function ensureColumn(string $table, string $column, Closure $callback): void
|
|
{
|
|
if (! Schema::hasColumn($table, $column)) {
|
|
Schema::table($table, $callback);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $attributes
|
|
*/
|
|
function insertFiltered(string $table, array $attributes): int
|
|
{
|
|
$columns = array_flip(Schema::getColumnListing($table));
|
|
$payload = [];
|
|
|
|
foreach ($attributes as $column => $value) {
|
|
if (isset($columns[$column])) {
|
|
$payload[$column] = $value;
|
|
}
|
|
}
|
|
|
|
if (isset($columns['created_at']) && ! array_key_exists('created_at', $payload)) {
|
|
$payload['created_at'] = now();
|
|
}
|
|
|
|
if (isset($columns['updated_at']) && ! array_key_exists('updated_at', $payload)) {
|
|
$payload['updated_at'] = now();
|
|
}
|
|
|
|
return (int) DB::table($table)->insertGetId($payload);
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
function extractUrlLocs(string $xml): array
|
|
{
|
|
$document = simplexml_load_string($xml);
|
|
|
|
if ($document === false) {
|
|
return [];
|
|
}
|
|
|
|
$nodes = $document->xpath('//*[local-name()="url"]/*[local-name()="loc"]') ?: [];
|
|
|
|
return array_values(array_filter(array_map(
|
|
static fn ($node): string => trim((string) $node),
|
|
$nodes,
|
|
)));
|
|
} |