, warnings: list, page_count: int} */ public function validate(WorldWebStory $story): array { $story->loadMissing('orderedPages'); $pages = $story->orderedPages->where('active', true)->values(); $errors = []; $warnings = []; if (trim((string) $story->title) === '') { $errors[] = 'Story title is required.'; } if (trim((string) $story->slug) === '') { $errors[] = 'Story slug is required.'; } if (trim((string) $story->poster_portrait_path) === '') { $errors[] = 'Poster portrait image is required.'; } if (trim((string) $story->publisher_logo_path) === '') { $errors[] = 'Publisher logo is required.'; } if ($pages->count() < 5) { $errors[] = 'A published web story must have at least 5 active pages.'; } if ($pages->count() > 10) { $errors[] = 'A published web story may not have more than 10 active pages.'; } foreach ($pages as $page) { $pageNumber = (int) $page->position; $body = trim((string) $page->body); $headline = trim((string) $page->headline); if (in_array((string) $page->background_type, [WorldWebStoryPage::BACKGROUND_IMAGE, WorldWebStoryPage::BACKGROUND_VIDEO], true) && trim((string) ($page->background_mobile_path ?: $page->background_path)) === '') { $errors[] = sprintf('Page %d is missing required background media.', $pageNumber); } if (mb_strlen($body) > 180) { $errors[] = sprintf('Page %d body exceeds 180 characters.', $pageNumber); } if (trim((string) $page->alt_text) === '') { $errors[] = sprintf('Page %d is missing alt text.', $pageNumber); } if ($headline === '' && $body === '') { $warnings[] = sprintf('Page %d has no story text.', $pageNumber); } if (filled($page->cta_label) || filled($page->cta_url)) { if (! filled($page->cta_label) || ! filled($page->cta_url)) { $errors[] = sprintf('Page %d CTA requires both label and URL.', $pageNumber); } elseif (! $this->isAllowedCtaUrl((string) $page->cta_url)) { $errors[] = sprintf('Page %d CTA URL is not allowed.', $pageNumber); } } } return [ 'valid' => $errors === [], 'errors' => array_values(array_unique($errors)), 'warnings' => array_values(array_unique($warnings)), 'page_count' => $pages->count(), ]; } /** * @param array $page */ public function validatePagePayload(array $page): array { $errors = []; $position = (int) ($page['position'] ?? 0); $body = trim((string) ($page['body'] ?? '')); $backgroundType = (string) ($page['background_type'] ?? WorldWebStoryPage::BACKGROUND_IMAGE); $backgroundPath = trim((string) ($page['background_mobile_path'] ?? $page['background_path'] ?? '')); $altText = trim((string) ($page['alt_text'] ?? '')); $ctaUrl = trim((string) ($page['cta_url'] ?? '')); $ctaLabel = trim((string) ($page['cta_label'] ?? '')); if ($body !== '' && mb_strlen($body) > 180) { $errors['body'] = sprintf('Page %d body exceeds 180 characters.', max(1, $position)); } if (in_array($backgroundType, [WorldWebStoryPage::BACKGROUND_IMAGE, WorldWebStoryPage::BACKGROUND_VIDEO], true) && $backgroundPath === '') { $errors['background_path'] = 'Background media is required for image and video pages.'; } if ($altText === '') { $errors['alt_text'] = 'Alt text is required.'; } if (($ctaUrl !== '' || $ctaLabel !== '') && ($ctaUrl === '' || $ctaLabel === '')) { $errors['cta'] = 'CTA label and URL must both be present.'; } if ($ctaUrl !== '' && ! $this->isAllowedCtaUrl($ctaUrl)) { $errors['cta_url'] = 'CTA URL must stay on Skinbase or use a relative path.'; } return $errors; } public function assertPublishable(WorldWebStory $story): void { $result = $this->validate($story); if ($result['valid']) { return; } throw ValidationException::withMessages([ 'story' => $result['errors'], ]); } public function isAllowedCtaUrl(string $url): bool { $value = trim($url); if ($value === '') { return false; } if (Str::startsWith($value, ['/'])) { return true; } $parts = parse_url($value); $host = strtolower((string) Arr::get($parts, 'host', '')); if ($host === '') { return false; } $allowedHosts = array_filter([ strtolower((string) parse_url((string) config('app.url'), PHP_URL_HOST)), 'skinbase.org', 'www.skinbase.org', 'skinbase.top', 'www.skinbase.top', ]); foreach ($allowedHosts as $allowedHost) { if ($host === $allowedHost || Str::endsWith($host, '.' . $allowedHost)) { return true; } } return false; } }