Vodiči

Symfony: Extract Brand Kits to Safely Draft Landing Pages from Client Websites

Symfony: Izdvajanje kompleta brenda za sigurno izradu nacrta odredišnih stranica s web-stranica klijenata

Kupac unosi svoju web-stranicu u vaš obrazac za uvođenje i očekuje da sljedeći zaslon djeluje poznato. Primamljiva implementacija jest dohvatiti logotip, kopirati nekoliko boja i ubrizgati rezultat u predložak. Tako i nepouzdani URL-ovi, neispravan CSS i krhke integracije dospijevaju u produkciju.

Sigurniji dizajn izvučeni brend tretira kao dokaz, a ne kao izvršivu prezentaciju. API Brand Kit Extractor prikuplja vizualni identitet javne stranice, dok Symfony aplikacija provjerava odgovor, pohranjuje inertni nacrt i zahtijeva odobrenje prije objave.

Ovaj vodič izrađuje taj tijek rada uz PHP 8.3, Symfony, HttpClient, Doctrine i determinističke testove. Ekstrakcija ostaje sinkrona kako bi primjer ostao fokusiran. Njegova vremenska ograničenja i proračun ponovnih pokušaja namjerno su mali; red čekanja bolja je nadogradnja ako uvođenje mora ostati responzivno tijekom uzvodnih kašnjenja.

Pribavite pristup prije pisanja integracijskog koda

  1. Registrirajte se na https://ai.mihajlo.mk/register, ili upotrijebite https://ai.mihajlo.mk/login ako već imate račun.
  2. Otvorite stranicu usluge Brand Kit Extractor. Odaberite dostupni Free, Plus ili Pro plan i dovršite njegovu aktivaciju.
  3. Otvorite službenu dokumentaciju usluge. Pronađite ploču Service token i kopirajte njezin token ograničen na uslugu.
  4. Pohranite taj token u konfiguraciju podržanu varijablama okruženja. Njegova regeneracija opoziva prethodno aktivni token, stoga rotacija tokena mora uključivati ažuriranje svake postavljene instance koja koristi uslugu.

Ova usluga zahtijeva autentikaciju. Prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Bearer autentikacija je poželjnija jer se nizovi upita često pojavljuju u zapisnicima pristupa i sustavima za nadzor.

Točna operacija je POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit. Njezino JSON tijelo sadrži jedno polje, url. Potvrdite pristup minimalnim zahtjevom:

curl --request POST \
  'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit' \
  --header 'Authorization: Bearer YOUR_SERVICE_TOKEN' \
  --header 'Content-Type: application/json' \
  --data '{"url":"https://example.com"}'

Stavite stvarnu vjerodajnicu u .env.local za lokalni razvoj. Nemojte predati tu datoteku u repozitorij. U produkciji ubrizgajte istu varijablu kroz spremište tajni platforme za postavljanje.

# .env
BRAND_KIT_ENDPOINT=https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit

# .env.local
BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN

Stvorite granicu Symfony projekta

Aplikacija treba PHP 8.3 ili noviji, Composer, podržanu Symfony aplikaciju i konfiguriranu Doctrine bazu podataka. Instalirajte samo ovdje korištene komponente:

composer require symfony/http-client symfony/orm-pack doctrine/doctrine-migrations-bundle
composer require --dev symfony/test-pack

Relevantna struktura projekta namjerno je skromna:

src/
  Controller/OnboardingBrandDraftController.php
  Entity/LandingThemeDraft.php
  BrandKit/BrandKit.php
  BrandKit/BrandKitException.php
  BrandKit/BrandKitExtractor.php
  BrandKit/BrandKitMapper.php
tests/
  BrandKit/BrandKitExtractorTest.php

Symfony ubrizgava krajnju točku i token bez uključivanja bilo koje vrijednosti u izvorni kod aplikacije:

# config/services.yaml
services:
  _defaults:
    autowire: true
    autoconfigure: true
    bind:
      $brandKitEndpoint: '%env(BRAND_KIT_ENDPOINT)%'
      $brandKitToken: '%env(BRAND_KIT_TOKEN)%'

Mapirajte odgovor u obrambeni domenski objekt

Usluga vraća naziv brenda, logotipe, boje, fontove, slike, društvene profile i CSS varijable. Udaljeni JSON i dalje se mora tretirati kao nepouzdan unos. Provjerite svaki obavezni odjeljak prije pohrane, ograničite njegovu veličinu i dubinu te odbijte CSS tokene koji bi mogli prekinuti deklaraciju ili učitati vanjski resurs.

Sljedeći DTO i mapper koriste kanonske interne nazive polja. Ako službena dokumentacija promijeni oblik odgovora, promijenite ovaj jedan mapper umjesto da detalje prijenosa raspršite po aplikaciji.

<?php
// src/BrandKit/BrandKit.php
namespace App\BrandKit;

final readonly class BrandKit
{
    public function __construct(
        public string $brandName,
        public array $logos,
        public array $colors,
        public array $fonts,
        public array $imagery,
        public array $socialProfiles,
        public array $cssVariables,
    ) {}

    public function toArray(): array
    {
        return [
            'brand_name' => $this->brandName,
            'logos' => $this->logos,
            'colors' => $this->colors,
            'fonts' => $this->fonts,
            'imagery' => $this->imagery,
            'social_profiles' => $this->socialProfiles,
            'css_variables' => $this->cssVariables,
        ];
    }
}

// src/BrandKit/BrandKitMapper.php
namespace App\BrandKit;

final class BrandKitMapper
{
    public static function fromApi(array $payload): BrandKit
    {
        $name = $payload['brand_name'] ?? null;

        if (!is_string($name) || trim($name) === '' || strlen($name) > 200) {
            throw new BrandKitException('invalid_response', 'Invalid brand name.');
        }

        foreach ([
            'logos', 'colors', 'fonts', 'imagery',
            'social_profiles', 'css_variables',
        ] as $field) {
            if (!isset($payload[$field]) || !is_array($payload[$field])) {
                throw new BrandKitException(
                    'invalid_response',
                    sprintf('Missing or invalid response field: %s', $field)
                );
            }
        }

        $counter = 0;
        foreach (['logos', 'colors', 'fonts', 'imagery', 'social_profiles'] as $field) {
            self::validateTree($payload[$field], 0, $counter);
        }

        $css = [];
        foreach ($payload['css_variables'] as $property => $value) {
            if (
                !is_string($property)
                || !preg_match('/^--[a-z0-9-]{1,80}$/i', $property)
                || !is_string($value)
                || strlen($value) > 160
                || preg_match('/[;{}]|url\s*\(|expression\s*\(/i', $value)
            ) {
                throw new BrandKitException(
                    'invalid_response',
                    'Unsafe CSS variable returned by upstream.'
                );
            }

            $css[$property] = $value;
        }

        return new BrandKit(
            trim($name),
            $payload['logos'],
            $payload['colors'],
            $payload['fonts'],
            $payload['imagery'],
            $payload['social_profiles'],
            $css,
        );
    }

    private static function validateTree(mixed $value, int $depth, int &$counter): void
    {
        if ($depth > 4 || ++$counter > 500) {
            throw new BrandKitException('invalid_response', 'Response is too large.');
        }

        if (is_array($value)) {
            foreach ($value as $child) {
                self::validateTree($child, $depth + 1, $counter);
            }
            return;
        }

        if (
            !(is_string($value) || is_int($value) || is_bool($value)
                || $value === null || (is_float($value) && is_finite($value)))
            || (is_string($value) && strlen($value) > 2048)
        ) {
            throw new BrandKitException('invalid_response', 'Invalid response value.');
        }
    }
}

Ova granica provjerava strukturu bez pretvaranja da su udaljeni logotipi ili slike sigurni za ugrađivanje. Njihovi URL-ovi ostaju metapodaci kandidata. Kasniji postupak odobravanja može preuzeti odobrenu imovinu kroz kontrolirani medijski cjevovod.

Pozovite ekstraktor s ograničenim ponovnim pokušajima

Klijent ponovno pokušava kod prolaznih transportnih neuspjeha, ograničenja stope i odabranih neuspjeha pristupnika. Ne pokušava ponovno autentikaciju, validaciju ni druge trajne pogreške klijenta. Odgoda je ograničena jer se ova implementacija izvršava unutar HTTP zahtjeva.

<?php
// src/BrandKit/BrandKitException.php
namespace App\BrandKit;

final class BrandKitException extends \RuntimeException
{
    public function __construct(
        public readonly string $kind,
        string $message,
        ?\Throwable $previous = null,
    ) {
        parent::__construct($message, 0, $previous);
    }
}

// src/BrandKit/BrandKitExtractor.php
namespace App\BrandKit;

use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final class BrandKitExtractor
{
    public function __construct(
        private HttpClientInterface $http,
        private LoggerInterface $logger,
        private string $brandKitToken,
        private string $brandKitEndpoint,
        private int $maxRetries = 2,
    ) {}

    public function extract(string $url): BrandKit
    {
        for ($attempt = 0; $attempt <= $this->maxRetries; ++$attempt) {
            try {
                $response = $this->http->request('POST', $this->brandKitEndpoint, [
                    'headers' => [
                        'Authorization' => 'Bearer '.$this->brandKitToken,
                        'Accept' => 'application/json',
                    ],
                    'json' => ['url' => $url],
                    'timeout' => 12.0,
                    'max_duration' => 20.0,
                ]);

                $status = $response->getStatusCode();
            } catch (TransportExceptionInterface $e) {
                if ($attempt === $this->maxRetries) {
                    throw new BrandKitException('transport', 'Extractor unavailable.', $e);
                }

                $this->backoff($attempt, null);
                continue;
            }

            if (in_array($status, [429, 502, 503, 504], true)) {
                $retryAfter = $response->getHeaders(false)['retry-after'][0] ?? null;
                $response->getContent(false);

                $this->logger->warning('Brand extraction deferred by upstream.', [
                    'status' => $status,
                    'attempt' => $attempt + 1,
                ]);

                if ($attempt < $this->maxRetries) {
                    $this->backoff($attempt, $retryAfter);
                    continue;
                }

                $kind = $status === 429 ? 'rate_limited' : 'upstream';
                throw new BrandKitException($kind, 'Extractor temporarily unavailable.');
            }

            if ($status === 401 || $status === 403) {
                $response->getContent(false);
                throw new BrandKitException('authentication', 'Extractor authentication failed.');
            }

            if ($status < 200 || $status >= 300) {
                $response->getContent(false);
                throw new BrandKitException('request_rejected', 'Extraction request rejected.');
            }

            try {
                $decoded = json_decode(
                    $response->getContent(false),
                    true,
                    512,
                    JSON_THROW_ON_ERROR
                );
            } catch (\JsonException $e) {
                throw new BrandKitException('invalid_response', 'Invalid upstream JSON.', $e);
            }

            if (!is_array($decoded)) {
                throw new BrandKitException('invalid_response', 'Unexpected upstream response.');
            }

            return BrandKitMapper::fromApi($decoded);
        }

        throw new BrandKitException('upstream', 'Extractor unavailable.');
    }

    private function backoff(int $attempt, ?string $retryAfter): void
    {
        $milliseconds = ctype_digit((string) $retryAfter)
            ? min(2000, (int) $retryAfter * 1000)
            : min(2000, 250 * (2 ** $attempt) + random_int(0, 100));

        usleep($milliseconds * 1000);
    }
}

Zapisnici sadrže status i broj pokušaja, nikada token, tijelo odgovora ni URL kupca. Time operativni signali ostaju korisni bez pretvaranja zapisnika u sekundarno spremište podataka kupaca.

Pohranite inertni nacrt koji se može pregledati

Nacrt ne bi trebao postati aktivan samo zato što je ekstrakcija uspjela. Pohranite normalizirane podatke kao JSON s eksplicitnim stanjem pending_review.

<?php
// src/Entity/LandingThemeDraft.php
namespace App\Entity;

use App\BrandKit\BrandKit;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class LandingThemeDraft
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 2048)]
    private string $sourceUrl;

    #[ORM\Column(type: Types::JSON)]
    private array $brandKit;

    #[ORM\Column(length: 24)]
    private string $status = 'pending_review';

    #[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
    private \DateTimeImmutable $createdAt;

    public function __construct(string $sourceUrl, BrandKit $brandKit)
    {
        $this->sourceUrl = $sourceUrl;
        $this->brandKit = $brandKit->toArray();
        $this->createdAt = new \DateTimeImmutable();
    }

    public function id(): ?int
    {
        return $this->id;
    }
}

Generirajte i primijenite migraciju nakon konfiguriranja baze podataka:

php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrate --no-interaction

Dodajte krajnju točku za uvođenje

Kontroler prihvaća JSON poput {"website_url":"https://customer.example"}. Odbija vjerodajnice u URL-ovima, nepodržane sheme, localhost te privatne ili rezervirane doslovne IP adrese. Budući da uslugu dohvaćanja obavlja treća strana, vaša aplikacija ne otvara vezu s hostom kupca; ograničenje i dalje provodi pravilo proizvoda o javnoj web-stranici.

<?php
// src/Controller/OnboardingBrandDraftController.php
namespace App\Controller;

use App\BrandKit\BrandKitException;
use App\BrandKit\BrandKitExtractor;
use App\Entity\LandingThemeDraft;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;

final class OnboardingBrandDraftController
{
    #[Route('/api/onboarding/brand-drafts', methods: ['POST'])]
    public function __invoke(
        Request $request,
        BrandKitExtractor $extractor,
        EntityManagerInterface $entityManager,
    ): JsonResponse {
        try {
            $input = $request->toArray();
        } catch (\JsonException) {
            return new JsonResponse(['error' => 'invalid_json'], 400);
        }

        $url = $input['website_url'] ?? null;
        if (!is_string($url) || !$this->isPublicWebsiteUrl($url)) {
            return new JsonResponse(['error' => 'invalid_website_url'], 422);
        }

        try {
            $kit = $extractor->extract($url);
        } catch (BrandKitException $e) {
            $status = $e->kind === 'rate_limited' ? 503 : 502;

            return new JsonResponse(
                ['error' => 'brand_extraction_failed', 'reason' => $e->kind],
                $status
            );
        }

        $draft = new LandingThemeDraft($url, $kit);
        $entityManager->persist($draft);
        $entityManager->flush();

        return new JsonResponse([
            'draft_id' => $draft->id(),
            'status' => 'pending_review',
        ], 201);
    }

    private function isPublicWebsiteUrl(string $url): bool
    {
        if (strlen($url) > 2048 || filter_var($url, FILTER_VALIDATE_URL) === false) {
            return false;
        }

        $parts = parse_url($url);
        if (
            !is_array($parts)
            || !in_array($parts['scheme'] ?? '', ['http', 'https'], true)
            || !isset($parts['host'])
            || isset($parts['user'])
            || isset($parts['pass'])
            || strtolower($parts['host']) === 'localhost'
        ) {
            return false;
        }

        if (filter_var($parts['host'], FILTER_VALIDATE_IP) !== false) {
            return filter_var(
                $parts['host'],
                FILTER_VALIDATE_IP,
                FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
            ) !== false;
        }

        return filter_var(
            $parts['host'],
            FILTER_VALIDATE_DOMAIN,
            FILTER_FLAG_HOSTNAME
        ) !== false;
    }
}

Testirajte granicu bez mrežnih poziva

MockHttpClient čini putanje uspjeha i neuspjeha determinističkima. Test autentikacije također dokazuje da se trajni neuspjesi ne pokušavaju ponovno.

<?php
// tests/BrandKit/BrandKitExtractorTest.php
namespace App\Tests\BrandKit;

use App\BrandKit\BrandKitException;
use App\BrandKit\BrandKitExtractor;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class BrandKitExtractorTest extends TestCase
{
    public function testItMapsAValidKit(): void
    {
        $body = json_encode([
            'brand_name' => 'Example',
            'logos' => [],
            'colors' => ['primary' => '#123456'],
            'fonts' => ['Inter'],
            'imagery' => [],
            'social_profiles' => [],
            'css_variables' => ['--brand-primary' => '#123456'],
        ], JSON_THROW_ON_ERROR);

        $http = new MockHttpClient(function ($method, $url, $options) use ($body) {
            self::assertSame('POST', $method);
            self::assertSame(
                'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit',
                $url
            );
            self::assertStringContainsString(
                'Authorization: Bearer test-token',
                implode("\n", $options['headers'])
            );
            self::assertStringContainsString('example.com', $options['body']);

            return new MockResponse($body, ['http_code' => 200]);
        });

        $extractor = new BrandKitExtractor(
            $http,
            new NullLogger(),
            'test-token',
            'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit'
        );

        self::assertSame('Example', $extractor->extract('https://example.com')->brandName);
    }

    public function testAuthenticationFailureIsNotRetried(): void
    {
        $calls = 0;
        $http = new MockHttpClient(function () use (&$calls) {
            ++$calls;
            return new MockResponse('{}', ['http_code' => 401]);
        });

        $extractor = new BrandKitExtractor(
            $http,
            new NullLogger(),
            'invalid-token',
            'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit'
        );

        try {
            $extractor->extract('https://example.com');
            self::fail('Expected an authentication failure.');
        } catch (BrandKitException $e) {
            self::assertSame('authentication', $e->kind);
            self::assertSame(1, $calls);
        }
    }
}
php bin/phpunit
curl --request POST 'https://your-app.example/api/onboarding/brand-drafts' \
  --header 'Content-Type: application/json' \
  --data '{"website_url":"https://example.com"}'

Jačanje za produkciju i česti neuspjesi

Zaštitite rutu za uvođenje postojećim autentificiranim kontekstom kupca i ograničivačem stope. Dodajte CSRF zaštitu ako obrazac preglednika koristi autentikaciju kolačićima. Šifrirajte pohranu baze podataka gdje je potrebno, primijenite pravila zadržavanja i autorizirajte svako kasnije čitanje prema vlasništvu zakupnika ili računa.

Nikada nemojte umetati pohranjeni CSS u stranicu spajanjem nizova. Generirajte deklaracije samo iz provjerene mape svojstvo/vrijednost, HTML-escapeajte prikazani tekst i držite pregled iza restriktivne politike sigurnosti sadržaja. Nemojte automatski učitavati udaljene logotipe, slike, fontove ni društvene URL-ove. Prikažite ih za pregled, a zatim posredujte ili uvezite odobrena sredstva kroz zasebno provjereni cjevovod.

Pratite strukturirane brojčane pokazatelje za uspješnu ekstrakciju, rate_limited, transport, authentication, request_rejected i invalid_response. Upozoravajte na trajne omjere neuspjeha, a ne na pojedinačne neuspjehe stranica kupaca. Nalet pogrešaka autentikacije nakon postavljanja obično znači da okruženje sadrži stari token; zapamtite da ga je regeneracija opozvala.

422 iz vašeg kontrolera znači da poslani URL web-stranice nije prošao lokalnu validaciju. Uzvodni 401 ili 403 upućuje na konfiguraciju tokena ili aktivaciju plana i ne smije se ponovno pokušavati. 429 predstavlja pritisak kvote ili ograničenja stope; vratite privremeni neuspjeh i dopustite korisniku da pokuša kasnije. Neispravan JSON ili promijenjeni oblik odgovora trebaju sigurno prekinuti proces prije nego što Doctrine išta upiše.

Za veći promet premjestite extract() u Symfony Messenger obrađivač. Najprije pohranite mali zapis zahtjeva, otpremite njegov identifikator i učinite obrađivač idempotentnim pomoću jedinstvenog ključa zahtjeva. Nemojte samo povećavati HTTP vremenska ograničenja: to zauzima radnike i pruža lošije iskustvo uvođenja.

Završni kontrolni popis za provjeru

  • Aktivirani plan i token ograničen na uslugu pripadaju Brand Kit Extractoru.
  • Zahtjev koristi točnu POST krajnju točku i šalje samo predviđeno polje url.
  • Stvarni token postoji samo u tajnoj konfiguraciji podržanoj varijablama okruženja.
  • Trajanje veze, ukupno trajanje, broj ponovnih pokušaja i odgoda su ograničeni.
  • Neuspjesi autentikacije i validacije nikada se ne pokušavaju ponovno.
  • Svih sedam odjeljaka podataka o brendu provjerava se prije pohrane.
  • Pohranjena tema ostaje pending_review i ne može se sama objaviti.
  • Udaljena sredstva i CSS ne prikazuju se kao pouzdan sadržaj.
  • Testovi prolaze s MockHttpClient, a migracije uspijevaju u okruženju za postavljanje.
  • Zapisnici i metrike otkrivaju kategorije neuspjeha bez otkrivanja tokena, tijela odgovora ili URL-ova kupaca.

Trajna pouka jest da ekstrakcija brenda treba skratiti dizajnerski rad bez zaobilaženja uredničke kontrole. Kada udaljeni dokaz prijeđe strogu Symfony granicu, aplikacija kupcima može ponuditi poznato polazište uz istodobno zadržavanje promišljene, pregledive i sigurne završne odredišne stranice.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.