Symfony: Извлечете бренд-комплети за безбедно изготвување целни страници од веб-страниците на клиентите
Клиентот ја внесува својата веб-страница во вашата форма за воведување и очекува следниот екран да изгледа познато. Примамливата имплементација е да се извлече лого, да се копираат неколку бои и резултатот да се вметне во шаблон. Така и недоверливите URL-адреси, неправилниот CSS и кревките интеграции стигнуваат до продукција.
Побезбеден дизајн го третира извлечениот бренд како доказ, а не како извршна презентација. API-то Brand Kit Extractor го собира визуелниот идентитет на јавната страница, додека Symfony апликацијата го валидира одговорот, зачувува неактивен нацрт и бара одобрување пред објавување.
Овој туторијал го гради тој работен тек со PHP 8.3, Symfony, HttpClient, Doctrine и детерминистички тестови. Извлекувањето останува синхроно за примерот да остане фокусиран. Неговите временски ограничувања и буџетот за повторни обиди се намерно мали; редица е подобро проширување ако воведувањето мора да остане одзивно при доцнења од надворешниот систем.
Обезбедете пристап пред да пишувате код за интеграција
- Регистрирајте се на https://ai.mihajlo.mk/register или користете https://ai.mihajlo.mk/login ако веќе имате сметка.
- Отворете ја страницата на услугата Brand Kit Extractor. Изберете достапен Free, Plus или Pro план и завршете ја неговата активација.
- Отворете ја официјалната документација на услугата. Најдете го панелот Service token и копирајте го неговиот токен ограничен на услугата.
- Зачувајте го тој токен во конфигурација поддржана од променливи на околината. Неговото регенерирање го поништува претходно активниот токен, па ротацијата на токени мора да вклучува ажурирање на секоја распоредена инстанца што ја користи услугата.
Оваа услуга бара автентикација. Прифаќа Bearer токен, заглавие X-API-Token или параметар за пребарување token. Bearer автентикацијата е попожелна бидејќи низите за пребарување често се појавуваат во дневниците за пристап и системите за надгледување.
Точната операција е POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit. Нејзиното JSON тело содржи едно поле, url. Потврдете го пристапот со минимално барање:
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"}'
Ставете го вистинскиот акредитив во .env.local за локален развој. Не ја предавајте таа датотека во репозиториум. Во продукција, внесете ја истата променлива преку складиштето за тајни на платформата за распоредување.
# .env
BRAND_KIT_ENDPOINT=https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit
# .env.local
BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN
Создадете ја границата на Symfony проектот
На апликацијата ѝ треба PHP 8.3 или понов, Composer, поддржана Symfony апликација и конфигурирана Doctrine база на податоци. Инсталирајте ги само компонентите што се користат овде:
composer require symfony/http-client symfony/orm-pack doctrine/doctrine-migrations-bundle
composer require --dev symfony/test-pack
Релевантната структура на проектот е намерно скромна:
src/
Controller/OnboardingBrandDraftController.php
Entity/LandingThemeDraft.php
BrandKit/BrandKit.php
BrandKit/BrandKitException.php
BrandKit/BrandKitExtractor.php
BrandKit/BrandKitMapper.php
tests/
BrandKit/BrandKitExtractorTest.php
Symfony ги внесува крајната точка и токенот без ниту една вредност да стане дел од изворниот код на апликацијата:
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
bind:
$brandKitEndpoint: '%env(BRAND_KIT_ENDPOINT)%'
$brandKitToken: '%env(BRAND_KIT_TOKEN)%'
Пресликајте го одговорот во одбранбен доменски објект
Услугата враќа име на бренд, логоа, бои, фонтови, слики, профили на социјални мрежи и CSS променливи. Оддалечениот JSON сè уште мора да се третира како недоверлив влез. Валидирајте го секој задолжителен дел пред зачувување, ограничете ја неговата големина и длабочина и одбијте CSS токени што би можеле да завршат декларација или да вчитаат надворешен ресурс.
Следните DTO и мапер користат канонски внатрешни имиња на полиња. Ако официјалната документација го промени обликот на одговорот, променете го овој единствен мапер наместо да протекуваат транспортни детали низ целата апликација.
<?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.');
}
}
}
Оваа граница ја валидира структурата без да се преправа дека оддалечените логоа или слики се безбедни за вметнување. Нивните URL-адреси остануваат метаподатоци на кандидати. Подоцнежен процес на одобрување може да преземе одобрени средства преку контролирана медиумска линија.
Повикајте го екстракторот со ограничени повторни обиди
Клиентот повторува при минливи транспортни неуспеси, ограничувања на стапката и избрани неуспеси на порталот. Не повторува автентикација, валидација или други трајни грешки на клиентот. Повратното чекање е ограничено бидејќи оваа имплементација работи во HTTP барање.
<?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);
}
}
Дневниците содржат статус и број на обид, но никогаш токен, тело на одговор или URL-адреса на клиентот. Така оперативните сигнали остануваат корисни без дневниците да се претворат во секундарно складиште на податоци за клиентите.
Зачувајте неактивен нацрт што може да се прегледа
Нацртот не треба да стане активен само затоа што извлекувањето успеало. Зачувајте ги нормализираните податоци како JSON со експлицитна состојба 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;
}
}
Генерирајте и применете ја миграцијата по конфигурирањето на базата на податоци:
php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrate --no-interaction
Додајте ја крајната точка за воведување
Контролерот прифаќа JSON како {"website_url":"https://customer.example"}. Тој одбива акредитиви во URL-адресите, неподдржани шеми, localhost и приватни или резервирани буквални IP-адреси. Бидејќи услугата од трета страна го извршува преземањето, вашата апликација не отвора врска со хостот на клиентот; ограничувањето сепак го спроведува правилото на производот за јавна веб-страница.
<?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;
}
}
Тестирајте ја границата без мрежни повици
MockHttpClient ги прави патеките за успех и неуспех детерминистички. Тестот за автентикација исто така докажува дека трајните неуспеси не се повторуваат.
<?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"}'
Зацврстување за продукција и чести неуспеси
Заштитете ја рутата за воведување со вашиот постоечки контекст на автентициран клиент и ограничувач на стапката. Додајте CSRF заштита ако формата во прелистувач користи автентикација со колачиња. Шифрирајте го складирањето во базата на податоци каде што е потребно, применете правила за задржување и авторизирајте го секое подоцнежно читање според сопственоста на закупецот или сметката.
Никогаш не вметнувајте зачуван CSS во страница со конкатенација на низи. Генерирајте декларации само од валидираната мапа својство/вредност, HTML-екранирајте го прикажаниот текст и држете го прегледот зад рестриктивна Политика за безбедност на содржината. Не вчитувајте автоматски оддалечени логоа, слики, фонтови или URL-адреси на социјални мрежи. Прикажете ги за преглед, а потоа проксирајте или увезете одобрени средства преку посебно валидирана линија.
Следете структурирани броења за успешно извлекување, rate_limited, transport, authentication, request_rejected и invalid_response. Алармирајте за одржливи соодноси на неуспех наместо за поединечни неуспеси на страници на клиенти. Нагол пораст на грешки при автентикација по распоредување обично значи дека околината содржи стар токен; запомнете дека регенерирањето го поништило.
422 од вашиот контролер значи дека испратената URL-адреса на веб-страницата не поминала локална валидација. Надворешен 401 или 403 укажува на конфигурација на токенот или активација на планот и не смее да се повторува. 429 претставува притисок од квота или стапка; вратете привремен неуспех и дозволете му на корисникот да се обиде повторно подоцна. Неправилниот JSON или променетиот облик на одговорот треба да завршат со неуспех пред Doctrine да запише што било.
За поголем сообраќај, преместете го extract() во Symfony Messenger обработувач. Прво зачувајте мал запис за барањето, испратете го неговиот идентификатор и направете го обработувачот идемпотентен со уникатен клуч за барање. Не ги зголемувајте едноставно HTTP временските ограничувања: тоа троши работници, а притоа создава полошо искуство при воведување.
Конечна листа за проверка
- Активираниот план и токенот ограничен на услугата припаѓаат на Brand Kit Extractor.
- Барањето ја користи точната POST крајна точка и го испраќа само предвиденото поле
url. - Вистинскиот токен постои само во тајна конфигурација поддржана од променливи на околината.
- Времетраењето на поврзувањето, вкупното времетраење, бројот на повторни обиди и повратното чекање се ограничени.
- Неуспесите на автентикација и валидација никогаш не се повторуваат.
- Сите седум делови со податоци за брендот се валидираат пред зачувување.
- Зачуваната тема останува
pending_reviewи не може сама да се објави. - Оддалечените средства и CSS не се прикажуваат како доверлива содржина.
- Тестовите поминуваат со
MockHttpClient, а миграциите успеваат во околината за распоредување. - Дневниците и метриките ги изложуваат категориите на неуспех без да изложуваат токени, тела на одговори или URL-адреси на клиенти.
Трајната поука е дека извлекувањето на бренд треба да ја скрати работата на дизајнот без да ја заобиколи уредничката контрола. Штом оддалечениот доказ ќе премине строга Symfony граница, апликацијата може да им понуди на клиентите позната почетна точка, додека конечната целна страница останува намерна, прегледлива и безбедна.