feat: add brand autofill from website URL in onboarding
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 ← <html lang> 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.
This commit is contained in:
parent
287c27b792
commit
bde10059e3
12 changed files with 698 additions and 10 deletions
302
app/Actions/Ai/AutofillBrand.php
Normal file
302
app/Actions/Ai/AutofillBrand.php
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Ai;
|
||||
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
|
||||
class AutofillBrand
|
||||
{
|
||||
private const REQUEST_TIMEOUT_SECONDS = 10;
|
||||
|
||||
private const MAX_LOGO_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
private const ALLOWED_LOGO_MIME = ['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/x-icon', 'image/vnd.microsoft.icon'];
|
||||
|
||||
/**
|
||||
* @return array{name: ?string, brand_description: ?string, content_language: ?string, logo_url: ?string}
|
||||
*/
|
||||
public function __invoke(string $url, Workspace $workspace): array
|
||||
{
|
||||
$url = $this->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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
72
composer.lock
generated
72
composer.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
import { Head, useForm } from '@inertiajs/vue3';
|
||||
import { IconLoader2, IconSparkles } from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { ref } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
|
||||
import { skipBrand, storeBrand } from '@/actions/App/Http/Controllers/App/OnboardingController';
|
||||
import { autofillBrand, skipBrand, storeBrand } from '@/actions/App/Http/Controllers/App/OnboardingController';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
|
@ -42,6 +44,10 @@ const form = useForm({
|
|||
});
|
||||
|
||||
const skipForm = useForm({});
|
||||
const isAutofilling = ref(false);
|
||||
const logoPreview = ref<string | null>(null);
|
||||
|
||||
const csrfToken = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? '';
|
||||
|
||||
const submit = () => {
|
||||
form.post(storeBrand.url());
|
||||
|
|
@ -51,7 +57,55 @@ const skip = () => {
|
|||
skipForm.post(skipBrand.url());
|
||||
};
|
||||
|
||||
const isSkipping = ref(false);
|
||||
const runAutofill = async () => {
|
||||
const url = form.brand_website.trim();
|
||||
|
||||
if (! url) {
|
||||
toast.error(trans('onboarding.brand.autofill_missing_url'));
|
||||
return;
|
||||
}
|
||||
|
||||
isAutofilling.value = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(autofillBrand.url(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
|
||||
if (! response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
toast.error(body.message ?? trans('onboarding.brand.autofill_error'));
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.brand_description) {
|
||||
form.brand_description = data.brand_description;
|
||||
}
|
||||
|
||||
if (data.content_language) {
|
||||
form.content_language = data.content_language;
|
||||
}
|
||||
|
||||
if (data.logo_url && data.has_logo) {
|
||||
logoPreview.value = data.logo_url;
|
||||
}
|
||||
|
||||
toast.success(trans('onboarding.brand.autofill_success'));
|
||||
} catch {
|
||||
toast.error(trans('onboarding.brand.autofill_error'));
|
||||
} finally {
|
||||
isAutofilling.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -64,12 +118,29 @@ const isSkipping = ref(false);
|
|||
<form class="flex flex-col gap-5" @submit.prevent="submit">
|
||||
<div class="grid gap-2">
|
||||
<Label for="brand_website">{{ $t('settings.brand.website') }}</Label>
|
||||
<Input
|
||||
id="brand_website"
|
||||
v-model="form.brand_website"
|
||||
type="url"
|
||||
:placeholder="trans('settings.brand.website_placeholder')"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
id="brand_website"
|
||||
v-model="form.brand_website"
|
||||
type="url"
|
||||
:placeholder="trans('settings.brand.website_placeholder')"
|
||||
class="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
:disabled="isAutofilling || !form.brand_website"
|
||||
@click="runAutofill"
|
||||
>
|
||||
<IconLoader2 v-if="isAutofilling" class="h-4 w-4 animate-spin" />
|
||||
<IconSparkles v-else class="h-4 w-4" />
|
||||
{{ $t('onboarding.brand.autofill') }}
|
||||
</Button>
|
||||
</div>
|
||||
<p v-if="logoPreview" class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<img :src="logoPreview" alt="" class="h-6 w-6 rounded object-cover" />
|
||||
{{ $t('onboarding.brand.logo_captured') }}
|
||||
</p>
|
||||
<InputError :message="form.errors.brand_website" />
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,9 @@
|
|||
Route::get('brand', [OnboardingController::class, 'brand'])->name('app.onboarding.brand');
|
||||
Route::post('brand', [OnboardingController::class, 'storeBrand'])->name('app.onboarding.brand.store');
|
||||
Route::post('brand/skip', [OnboardingController::class, 'skipBrand'])->name('app.onboarding.brand.skip');
|
||||
Route::post('brand/autofill', [OnboardingController::class, 'autofillBrand'])
|
||||
->middleware('throttle:10,1')
|
||||
->name('app.onboarding.brand.autofill');
|
||||
Route::get('account', [OnboardingController::class, 'account'])->name('app.onboarding.account');
|
||||
Route::post('account', [OnboardingController::class, 'storeAccount'])->name('app.onboarding.account.store');
|
||||
});
|
||||
|
|
|
|||
145
tests/Feature/Ai/AutofillBrandTest.php
Normal file
145
tests/Feature/Ai/AutofillBrandTest.php
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Actions\Ai\AutofillBrand;
|
||||
use App\Enums\User\Setup;
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Http\Client\Request as HttpRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create(['setup' => Setup::Brand]);
|
||||
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
|
||||
$this->user->update(['current_workspace_id' => $this->workspace->id]);
|
||||
});
|
||||
|
||||
test('extracts name, description, language, and logo from meta tags', function () {
|
||||
Http::fake([
|
||||
'example.com' => Http::response(<<<'HTML'
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<title>Acme Coffee | The best beans online</title>
|
||||
<meta name="description" content="Premium artisan coffee beans shipped worldwide.">
|
||||
<meta property="og:site_name" content="Acme Coffee">
|
||||
<meta property="og:image" content="https://example.com/og.png">
|
||||
<link rel="apple-touch-icon" href="https://example.com/apple-touch-icon.png">
|
||||
<link rel="icon" sizes="512x512" href="/icon-512.png">
|
||||
</head>
|
||||
<body>Welcome</body>
|
||||
</html>
|
||||
HTML, 200),
|
||||
'example.com/apple-touch-icon.png' => Http::response(file_get_contents(__DIR__.'/../../fixtures/1x1.png'), 200, ['Content-Type' => 'image/png']),
|
||||
]);
|
||||
|
||||
$result = (new AutofillBrand)('https://example.com', $this->workspace);
|
||||
|
||||
expect($result['name'])->toBe('Acme Coffee');
|
||||
expect($result['brand_description'])->toBe('Premium artisan coffee beans shipped worldwide.');
|
||||
expect($result['content_language'])->toBe('pt-BR');
|
||||
expect($result['logo_url'])->toBe('https://example.com/apple-touch-icon.png');
|
||||
|
||||
$this->workspace->refresh();
|
||||
expect($this->workspace->has_logo)->toBeTrue();
|
||||
});
|
||||
|
||||
test('falls back to title without og:site_name', function () {
|
||||
Http::fake([
|
||||
'example.com' => Http::response(<<<'HTML'
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Super SaaS | Landing page</title>
|
||||
<meta name="description" content="A simple product.">
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
HTML, 200),
|
||||
]);
|
||||
|
||||
$result = (new AutofillBrand)('https://example.com', $this->workspace);
|
||||
|
||||
expect($result['name'])->toBe('Super SaaS');
|
||||
expect($result['content_language'])->toBe('en');
|
||||
});
|
||||
|
||||
test('normalizes various language codes to supported locales', function (string $lang, ?string $expected) {
|
||||
Http::fake([
|
||||
'example.com' => Http::response("<html lang=\"{$lang}\"><head><title>X</title></head></html>", 200),
|
||||
]);
|
||||
|
||||
$result = (new AutofillBrand)('https://example.com', $this->workspace);
|
||||
|
||||
expect($result['content_language'])->toBe($expected);
|
||||
})->with([
|
||||
['pt', 'pt-BR'],
|
||||
['pt-PT', 'pt-BR'],
|
||||
['en-US', 'en'],
|
||||
['es-MX', 'es'],
|
||||
['fr', null],
|
||||
['ja-JP', null],
|
||||
]);
|
||||
|
||||
test('rejects non-http schemes', function () {
|
||||
expect(fn () => (new AutofillBrand)('ftp://example.com', $this->workspace))
|
||||
->toThrow(RuntimeException::class, 'Only http:// and https://');
|
||||
});
|
||||
|
||||
test('rejects private network addresses', function () {
|
||||
expect(fn () => (new AutofillBrand)('http://127.0.0.1', $this->workspace))
|
||||
->toThrow(RuntimeException::class, 'private');
|
||||
|
||||
expect(fn () => (new AutofillBrand)('http://192.168.1.1', $this->workspace))
|
||||
->toThrow(RuntimeException::class, 'private');
|
||||
});
|
||||
|
||||
test('adds https:// when scheme is missing', function () {
|
||||
Http::fake([
|
||||
'example.com' => Http::response('<html><head><title>ok</title></head></html>', 200),
|
||||
]);
|
||||
|
||||
(new AutofillBrand)('example.com', $this->workspace);
|
||||
|
||||
Http::assertSent(fn (HttpRequest $req) => str_starts_with($req->url(), 'https://example.com'));
|
||||
});
|
||||
|
||||
test('returns empty fields when site has no meta tags', function () {
|
||||
Http::fake([
|
||||
'example.com' => Http::response('<html><body></body></html>', 200),
|
||||
]);
|
||||
|
||||
$result = (new AutofillBrand)('https://example.com', $this->workspace);
|
||||
|
||||
expect($result['name'])->toBeNull();
|
||||
expect($result['brand_description'])->toBeNull();
|
||||
expect($result['content_language'])->toBeNull();
|
||||
expect($result['logo_url'])->toBeNull();
|
||||
});
|
||||
|
||||
test('throws when upstream site returns an error', function () {
|
||||
Http::fake([
|
||||
'example.com' => Http::response('', 500),
|
||||
]);
|
||||
|
||||
expect(fn () => (new AutofillBrand)('https://example.com', $this->workspace))
|
||||
->toThrow(RuntimeException::class, 'HTTP 500');
|
||||
});
|
||||
|
||||
test('skips logo that is too large or wrong mime', function () {
|
||||
Http::fake([
|
||||
'example.com' => Http::response(<<<'HTML'
|
||||
<html><head>
|
||||
<link rel="apple-touch-icon" href="https://example.com/malicious.exe">
|
||||
</head></html>
|
||||
HTML, 200),
|
||||
'example.com/malicious.exe' => Http::response('fake', 200, ['Content-Type' => 'application/octet-stream']),
|
||||
]);
|
||||
|
||||
(new AutofillBrand)('https://example.com', $this->workspace);
|
||||
|
||||
$this->workspace->refresh();
|
||||
expect($this->workspace->has_logo)->toBeFalse();
|
||||
});
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
use App\Models\SocialAccount;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create(['setup' => Setup::Role]);
|
||||
|
|
@ -159,6 +160,54 @@
|
|||
$response->assertSessionHasErrors('content_language');
|
||||
});
|
||||
|
||||
test('autofill brand returns extracted fields from the site', function () {
|
||||
$workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
||||
$this->user->update([
|
||||
'current_workspace_id' => $workspace->id,
|
||||
'setup' => Setup::Brand,
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'acme.com' => Http::response(<<<'HTML'
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<title>Acme | Best in class</title>
|
||||
<meta name="description" content="Tools for creators.">
|
||||
<meta property="og:site_name" content="Acme">
|
||||
</head>
|
||||
</html>
|
||||
HTML, 200),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->postJson(
|
||||
route('app.onboarding.brand.autofill'),
|
||||
['url' => 'https://acme.com'],
|
||||
);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJson([
|
||||
'name' => 'Acme',
|
||||
'brand_description' => 'Tools for creators.',
|
||||
'content_language' => 'pt-BR',
|
||||
]);
|
||||
});
|
||||
|
||||
test('autofill brand returns 422 when site cannot be reached', function () {
|
||||
$workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
||||
$this->user->update([
|
||||
'current_workspace_id' => $workspace->id,
|
||||
'setup' => Setup::Brand,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->postJson(
|
||||
route('app.onboarding.brand.autofill'),
|
||||
['url' => 'http://127.0.0.1'],
|
||||
);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
$response->assertJsonStructure(['message']);
|
||||
});
|
||||
|
||||
test('skip brand advances setup without saving workspace changes', function () {
|
||||
$workspace = Workspace::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
|
|
|
|||
BIN
tests/fixtures/1x1.png
vendored
Normal file
BIN
tests/fixtures/1x1.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 69 B |
Loading…
Reference in a new issue