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 @@