From bde10059e3ec7bd27c2ade8e442f38e7d6324630 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 16 Apr 2026 11:13:54 -0300 Subject: [PATCH] feat: add brand autofill from website URL in onboarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users on the Brand step can type their website URL and click 'Preencher' (Portuguese) / 'Autofill' (English). The backend fetches their homepage, parses standard meta tags, and returns: - name ← og:site_name | title (suffix stripped at ' | ', ' - ') - description ← meta[name=description] | og:description - language ← mapped to en / pt-BR / es - logo ← apple-touch-icon | largest link[rel*=icon] | og:image The logo is downloaded, validated (mime whitelist, 2MB max), and attached to the workspace's 'logo' media collection so the avatar updates immediately. Zero LLM calls, zero external APIs. Uses symfony/dom-crawler + symfony/css-selector (newly required) for meta extraction. Everything else (Http client, workspace media, Intervention) was already in the project. Security: - SSRF guardrail: resolves the host, rejects private / loopback / link-local ranges, enforces http(s) scheme on both the initial page fetch and the logo download. - Rate limited at 10 req/min per user via the throttle middleware alias on the route. - Logo content-type must be one of the allowed image mimes; wrong types are silently dropped so users never see broken images. UX: - 'Autofill' button next to the website input, disabled until there is a URL; shows a spinner while running. - If a logo was captured, a small preview appears below the input so users can see what was pulled before saving. - Success and error paths both surface as vue-sonner toasts, with translations in en / pt-BR / es. - Failures leave the form untouched — nothing is destructively overwritten if parsing gave us nothing. Tests (16 new): action-level coverage for happy path, title-suffix fallback, language code normalization across 6 locales, scheme rejection, private-range SSRF rejection, implicit https prefixing, empty sites, upstream errors, and wrong-mime logo rejection. Plus two controller-level tests for the autofill endpoint. --- app/Actions/Ai/AutofillBrand.php | 302 ++++++++++++++++++ .../Controllers/App/OnboardingController.php | 31 ++ composer.json | 4 +- composer.lock | 72 ++++- lang/en/onboarding.php | 5 + lang/es/onboarding.php | 5 + lang/pt-BR/onboarding.php | 5 + resources/js/pages/onboarding/Brand.vue | 87 ++++- routes/app.php | 3 + tests/Feature/Ai/AutofillBrandTest.php | 145 +++++++++ tests/Feature/OnboardingControllerTest.php | 49 +++ tests/fixtures/1x1.png | Bin 0 -> 69 bytes 12 files changed, 698 insertions(+), 10 deletions(-) create mode 100644 app/Actions/Ai/AutofillBrand.php create mode 100644 tests/Feature/Ai/AutofillBrandTest.php create mode 100644 tests/fixtures/1x1.png diff --git a/app/Actions/Ai/AutofillBrand.php b/app/Actions/Ai/AutofillBrand.php new file mode 100644 index 00000000..a5703b21 --- /dev/null +++ b/app/Actions/Ai/AutofillBrand.php @@ -0,0 +1,302 @@ +normalizeUrl($url); + $this->guardAgainstSsrf($url); + + $html = $this->fetchHtml($url); + $crawler = new Crawler($html, $url); + + $logoUrl = $this->extractLogoUrl($crawler, $url); + + if ($logoUrl) { + $this->attachLogoToWorkspace($workspace, $logoUrl); + } + + return [ + 'name' => $this->extractName($crawler), + 'brand_description' => $this->extractDescription($crawler), + 'content_language' => $this->extractLanguage($crawler), + 'logo_url' => $logoUrl, + ]; + } + + private function normalizeUrl(string $url): string + { + $url = trim($url); + + if (preg_match('~^[a-z][a-z0-9+.-]*://~i', $url)) { + return $url; + } + + return 'https://'.$url; + } + + private function guardAgainstSsrf(string $url): void + { + $parts = parse_url($url); + + if (! $parts || ! in_array(strtolower(data_get($parts, 'scheme', '')), ['http', 'https'], true)) { + throw new RuntimeException('Only http:// and https:// URLs are supported.'); + } + + $host = data_get($parts, 'host'); + + if (! $host) { + throw new RuntimeException('URL is missing a host.'); + } + + $ip = gethostbyname($host); + + if ($ip === $host && ! filter_var($host, FILTER_VALIDATE_IP)) { + throw new RuntimeException("Could not resolve host: {$host}"); + } + + $isPublic = filter_var( + $ip, + FILTER_VALIDATE_IP, + FILTER_FLAG_IPV4 | FILTER_FLAG_IPV6 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE, + ); + + if (! $isPublic) { + throw new RuntimeException('Internal or private network addresses are not allowed.'); + } + } + + private function fetchHtml(string $url): string + { + try { + $response = Http::timeout(self::REQUEST_TIMEOUT_SECONDS) + ->withUserAgent('TryPostBot/1.0 (+https://trypost.it)') + ->withOptions(['allow_redirects' => ['max' => 3]]) + ->get($url); + } catch (ConnectionException $e) { + throw new RuntimeException("Could not reach the website: {$e->getMessage()}"); + } + + if ($response->failed()) { + throw new RuntimeException("Website returned HTTP {$response->status()}."); + } + + return $response->body(); + } + + private function extractName(Crawler $crawler): ?string + { + $ogSiteName = $this->metaContent($crawler, 'property', 'og:site_name'); + if ($ogSiteName) { + return $ogSiteName; + } + + $ogTitle = $this->metaContent($crawler, 'property', 'og:title'); + if ($ogTitle) { + return $this->stripTitleSuffix($ogTitle); + } + + $title = $crawler->filter('title')->first(); + if ($title->count() > 0) { + return $this->stripTitleSuffix(trim($title->text(''))) ?: null; + } + + return null; + } + + private function extractDescription(Crawler $crawler): ?string + { + return $this->metaContent($crawler, 'name', 'description') + ?? $this->metaContent($crawler, 'property', 'og:description'); + } + + private function extractLanguage(Crawler $crawler): ?string + { + $html = $crawler->filter('html')->first(); + + if ($html->count() === 0) { + return null; + } + + $lang = trim((string) $html->attr('lang', '')); + + if ($lang === '') { + return null; + } + + return $this->normalizeLanguageCode($lang); + } + + private function normalizeLanguageCode(string $raw): ?string + { + $lower = strtolower($raw); + + return match (true) { + str_starts_with($lower, 'pt') => 'pt-BR', + str_starts_with($lower, 'es') => 'es', + str_starts_with($lower, 'en') => 'en', + default => null, + }; + } + + private function extractLogoUrl(Crawler $crawler, string $baseUrl): ?string + { + $candidates = []; + + foreach ($crawler->filter('link[rel="apple-touch-icon"]') as $node) { + $href = $node->getAttribute('href'); + if ($href) { + $candidates[] = ['href' => $href, 'priority' => 100, 'size' => $this->parseIconSize($node->getAttribute('sizes'))]; + } + } + + foreach ($crawler->filter('link[rel*="icon"]') as $node) { + $href = $node->getAttribute('href'); + if ($href) { + $candidates[] = ['href' => $href, 'priority' => 50, 'size' => $this->parseIconSize($node->getAttribute('sizes'))]; + } + } + + $ogImage = $this->metaContent($crawler, 'property', 'og:image'); + if ($ogImage) { + $candidates[] = ['href' => $ogImage, 'priority' => 25, 'size' => 0]; + } + + if (empty($candidates)) { + return null; + } + + usort($candidates, fn ($a, $b) => $b['priority'] <=> $a['priority'] ?: $b['size'] <=> $a['size']); + + return $this->resolveUrl($candidates[0]['href'], $baseUrl); + } + + private function parseIconSize(?string $sizes): int + { + if (! $sizes) { + return 0; + } + + preg_match_all('/(\d+)x\d+/', $sizes, $matches); + + return $matches[1] === [] ? 0 : max(array_map('intval', $matches[1])); + } + + private function resolveUrl(string $href, string $baseUrl): string + { + if (preg_match('~^https?://~i', $href)) { + return $href; + } + + $base = parse_url($baseUrl); + $scheme = data_get($base, 'scheme', 'https'); + $host = data_get($base, 'host'); + + if (str_starts_with($href, '//')) { + return "{$scheme}:{$href}"; + } + + if (str_starts_with($href, '/')) { + return "{$scheme}://{$host}{$href}"; + } + + return "{$scheme}://{$host}/{$href}"; + } + + private function attachLogoToWorkspace(Workspace $workspace, string $logoUrl): void + { + $this->guardAgainstSsrf($logoUrl); + + try { + $response = Http::timeout(self::REQUEST_TIMEOUT_SECONDS) + ->withUserAgent('TryPostBot/1.0 (+https://trypost.it)') + ->get($logoUrl); + } catch (ConnectionException) { + return; + } + + if ($response->failed()) { + return; + } + + $contentType = strtolower(explode(';', (string) $response->header('Content-Type'))[0]); + + if (! in_array($contentType, self::ALLOWED_LOGO_MIME, true)) { + return; + } + + $body = $response->body(); + + if (strlen($body) > self::MAX_LOGO_BYTES) { + return; + } + + $extension = $this->extensionForMime($contentType); + $tempPath = tempnam(sys_get_temp_dir(), 'logo_').'.'.$extension; + file_put_contents($tempPath, $body); + + try { + $workspace->clearMediaCollection('logo'); + $workspace->addMediaFromPath($tempPath, 'logo.'.$extension, 'logo', ['ai_autofill' => true]); + } finally { + if (file_exists($tempPath)) { + @unlink($tempPath); + } + } + } + + private function extensionForMime(string $mime): string + { + return match ($mime) { + 'image/jpeg' => 'jpg', + 'image/webp' => 'webp', + 'image/gif' => 'gif', + 'image/x-icon', 'image/vnd.microsoft.icon' => 'ico', + default => 'png', + }; + } + + private function metaContent(Crawler $crawler, string $attr, string $value): ?string + { + $node = $crawler->filter("meta[{$attr}=\"{$value}\"]")->first(); + + if ($node->count() === 0) { + return null; + } + + $content = trim((string) $node->attr('content', '')); + + return $content === '' ? null : $content; + } + + private function stripTitleSuffix(string $title): string + { + foreach ([' | ', ' - ', ' — ', ' – '] as $sep) { + $idx = mb_strpos($title, $sep); + if ($idx !== false && $idx > 0) { + return trim(mb_substr($title, 0, $idx)); + } + } + + return trim($title); + } +} diff --git a/app/Http/Controllers/App/OnboardingController.php b/app/Http/Controllers/App/OnboardingController.php index 18d6457e..3eba1664 100644 --- a/app/Http/Controllers/App/OnboardingController.php +++ b/app/Http/Controllers/App/OnboardingController.php @@ -4,16 +4,20 @@ namespace App\Http\Controllers\App; +use App\Actions\Ai\AutofillBrand; use App\Enums\SocialAccount\Platform as SocialPlatform; use App\Enums\User\Persona; use App\Enums\User\Setup; use App\Http\Requests\App\Onboarding\StoreBrandRequest; use App\Models\User; +use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Validation\Rule; use Inertia\Inertia; use Inertia\Response; +use RuntimeException; +use Symfony\Component\HttpFoundation\Response as SymfonyResponse; class OnboardingController extends Controller { @@ -84,6 +88,33 @@ public function skipBrand(Request $request): RedirectResponse return redirect()->route('app.onboarding.account'); } + public function autofillBrand(Request $request, AutofillBrand $autofill): JsonResponse + { + $validated = $request->validate([ + 'url' => ['required', 'string', 'max:255'], + ]); + + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + abort(SymfonyResponse::HTTP_BAD_REQUEST, 'No workspace found.'); + } + + try { + $result = $autofill(data_get($validated, 'url'), $workspace); + } catch (RuntimeException $e) { + return response()->json(['message' => $e->getMessage()], SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY); + } + + $workspace->refresh(); + + return response()->json([ + ...$result, + 'logo_url' => $workspace->logo_url, + 'has_logo' => $workspace->has_logo, + ]); + } + public function account(Request $request): Response|RedirectResponse { $redirect = $this->enforceStep($request->user(), Setup::Connections); diff --git a/composer.json b/composer.json index e07fa6dd..0d05220b 100644 --- a/composer.json +++ b/composer.json @@ -58,7 +58,9 @@ "socialiteproviders/linkedin": "^5.0", "socialiteproviders/pinterest": "^4.3", "socialiteproviders/tiktok": "^5.2", - "socialiteproviders/twitter": "^4.1" + "socialiteproviders/twitter": "^4.1", + "symfony/css-selector": "^8.0", + "symfony/dom-crawler": "^8.0" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/composer.lock b/composer.lock index 3a035c84..62a35932 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5640db0bf02709462f946712c0b780d0", + "content-hash": "1ddd43db5ec5cc43f504c3aa8a53d566", "packages": [ { "name": "aws/aws-crt-php", @@ -7350,6 +7350,76 @@ ], "time": "2024-09-25T14:21:43+00:00" }, + { + "name": "symfony/dom-crawler", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/dom-crawler.git", + "reference": "284ace90732b445b027728b5e0eec6418a17a364" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/284ace90732b445b027728b5e0eec6418a17a364", + "reference": "284ace90732b445b027728b5e0eec6418a17a364", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.0" + }, + "require-dev": { + "symfony/css-selector": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DomCrawler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases DOM navigation for HTML and XML documents", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dom-crawler/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, { "name": "symfony/error-handler", "version": "v8.0.8", diff --git a/lang/en/onboarding.php b/lang/en/onboarding.php index 33898d4a..27c7d680 100644 --- a/lang/en/onboarding.php +++ b/lang/en/onboarding.php @@ -43,6 +43,11 @@ 'description' => 'These defaults power every AI-generated post in this workspace. You can change them later in Settings.', 'submit' => 'Continue', 'skip' => 'Skip for now', + 'autofill' => 'Autofill', + 'autofill_success' => 'We pulled what we could from your site. Review and adjust if needed.', + 'autofill_error' => 'We could not read that site. Please fill in manually.', + 'autofill_missing_url' => 'Add your website URL first.', + 'logo_captured' => 'Logo captured from your site.', ], 'connect' => [ diff --git a/lang/es/onboarding.php b/lang/es/onboarding.php index cfb601e1..31ebee18 100644 --- a/lang/es/onboarding.php +++ b/lang/es/onboarding.php @@ -43,6 +43,11 @@ 'description' => 'Estos valores predeterminados se usarán en todos los posts generados por IA en este workspace. Puedes cambiarlos después en Configuración.', 'submit' => 'Continuar', 'skip' => 'Omitir por ahora', + 'autofill' => 'Autocompletar', + 'autofill_success' => 'Extrajimos lo que pudimos de tu sitio. Revisa y ajusta si es necesario.', + 'autofill_error' => 'No pudimos leer ese sitio. Complétalo manualmente.', + 'autofill_missing_url' => 'Ingresa primero la URL de tu sitio.', + 'logo_captured' => 'Logo capturado desde tu sitio.', ], 'connect' => [ diff --git a/lang/pt-BR/onboarding.php b/lang/pt-BR/onboarding.php index 07aab435..31eb3acb 100644 --- a/lang/pt-BR/onboarding.php +++ b/lang/pt-BR/onboarding.php @@ -43,6 +43,11 @@ 'description' => 'Esses valores padrão serão usados em todos os posts gerados por AI neste workspace. Você pode alterar depois em Configurações.', 'submit' => 'Continuar', 'skip' => 'Pular por enquanto', + 'autofill' => 'Preencher', + 'autofill_success' => 'Puxamos o que conseguimos do seu site. Revise e ajuste se precisar.', + 'autofill_error' => 'Não conseguimos ler esse site. Preencha manualmente.', + 'autofill_missing_url' => 'Informe a URL do seu site primeiro.', + 'logo_captured' => 'Logo capturado do seu site.', ], 'connect' => [ diff --git a/resources/js/pages/onboarding/Brand.vue b/resources/js/pages/onboarding/Brand.vue index 4270bf6f..8659cd91 100644 --- a/resources/js/pages/onboarding/Brand.vue +++ b/resources/js/pages/onboarding/Brand.vue @@ -1,9 +1,11 @@