Запобігання спаму за допомогою ШІ
Будь-хто може надіслати відгук. Це можуть бути роботи, спамери тощо. Ми можемо додати "капчу" до форми, щоб хоч якось захиститися від роботів, або скористатися API сторонніх розробників.
Я вирішив скористатися великою мовною моделлю (англ. Large Language Model), щоб визначати, чи є коментар спамом, і продемонструвати, як використовувати ШІ в застосунку Symfony та як виконувати такі дорогі виклики поза основним потоком.
Отримання ключа API для ШІ
Symfony AI підтримує багатьох постачальників моделей: OpenAI, Anthropic, Google Gemini, Mistral і навіть локальні моделі за допомогою Ollama. У цьому розділі використовується OpenAI: зареєструйтеся на platform.openai.com і створіть ключ API. Якщо ви віддаєте перевагу іншому постачальнику, код залишається тим самим; змінюється лише конфігурація.
Залежність від Symfony AI Bundle
Замість того щоб самостійно викликати HTTP API моделі, ми використаємо Symfony AI Bundle. Він надає абстракцію платформи для постачальників моделей (кожен постачальник постачається як власний пакет-міст) і агента, який обгортає модель для виконання викликів; а також користується всіма інструментами налагодження Symfony, як-от інтеграцією з Symfony Profiler:
1
$ symfony composer req symfony/ai-bundle symfony/ai-agent symfony/ai-open-ai-platform
Note
Symfony AI — це молодий набір компонентів, який усе ще є експериментальним: його API можуть розвиватися швидше, ніж решта Symfony.
Рецепт моста OpenAI вже налаштував для нас платформу; він посилається на змінну середовища OPENAI_API_KEY (і додав для неї порожнє значення за замовчуванням у .env):
1 2 3 4
ai:
platform:
openai:
api_key: '%env(OPENAI_API_KEY)%'
Налаштуйте на її основі агента за замовчуванням:
1 2 3 4 5
ai:
agent:
default:
platform: 'ai.platform.openai'
model: 'gpt-5-mini'
Використання змінних середовища
Ми, звичайно, не хочемо жорстко кодувати значення ключа в конфігурації; саме тому воно читається зі змінної середовища OPENAI_API_KEY.
Надалі кожен розробник має встановити "реальну" змінну середовища або зберегти її значення у файлі .env.local:
1
OPENAI_API_KEY=sk-...
Для продакшн слід визначити "реальну" змінну середовища.
Це працює добре, але управління багатьма змінними середовища може стати громіздким. На випадок, коли мова заходить про зберігання конфіденційних даних, Symfony має "кращу" альтернативу.
Зберігання конфіденційних даних
Замість того щоб використовувати безліч змінних середовища, в Symfony є vault, в якому можна зберігати безліч конфіденційних даних. Однією з ключових можливостей є можливість фіксації vault в репозиторії (але без ключа для його відкриття). Ще однією чудовою особливістю є можливість керувати окремим vault у кожному середовищі.
Конфіденційні дані — це замасковані змінні середовища.
Додайте ключ OpenAI API у vault:
1
$ symfony console secrets:set OPENAI_API_KEY
1 2 3 4
Please type the secret value:
>
[OK] Secret "OPENAI_API_KEY" encrypted in "config/secrets/dev/"; you can commit it.
Оскільки ми вперше запускаємо цю команду, вона згенерувала два ключі в каталозі config/secret/dev/. Далі, у тому ж каталозі, був збережений секретний рядок OPENAI_API_KEY.
Для розробки, ви можете зафіксувати vault з конфіденційними даними разом з ключами, згенерованими в каталозі config/secret/dev/.
Конфіденційні дані також можна перевизначити, встановивши змінну середовища з тим же ім'ям.
Щоб прочитати конфіденційні дані з vault, використовуйте secrets:reveal:
1
$ symfony console secrets:reveal OPENAI_API_KEY
Розробка класу перевірки на спам
Створіть новий клас в src/ із назвою SpamChecker, щоб обгорнути логіку запиту до моделі про те, чи є коментар спамом:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
namespace App;
use App\Entity\Comment;
use Symfony\AI\Agent\AgentInterface;
use Symfony\AI\Platform\Exception\ExceptionInterface;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
class SpamChecker
{
public function __construct(
private AgentInterface $agent,
) {
}
/**
* @return int Spam score: 0: not spam, 1: maybe spam, 2: blatant spam
*/
public function getSpamScore(Comment $comment, array $context): int
{
$messages = new MessageBag(
Message::forSystem(<<<PROMPT
You moderate comments submitted to a conference guestbook.
Classify the comment as "ham", "maybe spam", or "blatant spam".
Only answer with the classification.
PROMPT),
Message::ofUser(sprintf(<<<COMMENT
IP: %s
User agent: %s
Author: %s (%s)
Comment: %s
COMMENT,
$context['user_ip'] ?? '',
$context['user_agent'] ?? '',
$comment->getAuthor(),
$comment->getEmail(),
$comment->getText(),
)),
);
try {
$answer = strtolower($this->agent->call($messages)->getContent());
} catch (ExceptionInterface) {
// when the model cannot answer, let a human moderate the comment
return 1;
}
return match (true) {
str_contains($answer, 'blatant spam') => 2,
str_contains($answer, 'maybe spam') => 1,
default => 0,
};
}
}
Системний промпт повідомляє моделі її роль і обмежує її відповіді; повідомлення користувача містить коментар і контекст його надсилання (IP-адреса, user agent).
Метод getSpamScore() повертає 3 значення в залежності від відповіді моделі:
2: якщо коментар є "явним спамом";1: якщо коментар може бути спамом, або коли модель недоступна;0: якщо коментар не є спамом (ham).
Вивід моделі — це вільний текст, навіть коли промпт його обмежує: розбирайте його вільно (приведіть до нижнього регістру, використовуйте str_contains()). А коли модель узагалі не може відповісти, замість того щоб завершуватися помилкою, повертайтеся до модерації людиною: ШІ має допомагати адміністратору, а не блокувати гостьову книгу.
Tip
Спробуйте надіслати коментар, який виглядає як явний спам, як-от "Buy cheap watches at http://example.com/!!!", щоб побачити модель у дії.
Перевірка коментарів на спам
Одним із найпростіших способів перевірки на спам, при відправці нового коментаря, є виклик засобу перевірки на спам перед збереженням даних в базі даних:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
--- i/src/Controller/ConferenceController.php
+++ w/src/Controller/ConferenceController.php
@@ -7,7 +7,8 @@ use App\Entity\Conference;
use App\Form\CommentType;
use App\Repository\CommentRepository;
use App\Repository\ConferenceRepository;
+use App\SpamChecker;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
@@ -34,8 +35,9 @@ final class ConferenceController extends AbstractController
Request $request,
#[MapEntity(mapping: ['slug' => 'slug'])]
Conference $conference,
CommentRepository $commentRepository,
+ SpamChecker $spamChecker,
#[Autowire('%photo_dir%')] string $photoDir,
#[MapQueryParameter] int $offset = 0,
): Response {
$comment = new Comment();
@@ -48,6 +50,17 @@ final class ConferenceController extends AbstractController
}
$this->entityManager->persist($comment);
+
+ $context = [
+ 'user_ip' => $request->getClientIp(),
+ 'user_agent' => $request->headers->get('user-agent'),
+ 'referrer' => $request->headers->get('referer'),
+ 'permalink' => $request->getUri(),
+ ];
+ if (2 === $spamChecker->getSpamScore($comment, $context)) {
+ throw new \RuntimeException('Blatant spam, go away!');
+ }
+
$this->entityManager->flush();
return $this->redirectToRoute('conference', ['slug' => $conference->getSlug()]);
Перевірте, чи все працює.
Обмеження частоти надсилання коментарів
Виявлення спаму захищає вебсайт від витончених спамерів. Додатковим і значно дешевшим захистом є обмеження того, як швидко той самий клієнт може надсилати коментарі: ніхто легітимно не публікує десятки коментарів на годину в гостьовій книзі.
Додайте компонент Symfony Rate Limiter:
1
$ symfony composer req rate-limiter
Налаштуйте обмежувач, який приймає щонайбільше 5 коментарів на годину від того самого клієнта:
1 2 3 4 5 6 7 8 9 10 11 12
framework:
rate_limiter:
comment_submission:
policy: 'fixed_window'
limit: 5
interval: '1 hour'
when@test:
framework:
rate_limiter:
comment_submission:
limit: 1000
Автоматизовані тести легітимно надсилають багато коментарів за короткий проміжок часу, тож ліміт підвищено для середовища test.
Застосуйте обмежувач до надсилання коментарів за допомогою атрибута #[RateLimit]; за замовчуванням він ідентифікує клієнтів за їхньою IP-адресою:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
--- i/src/Controller/ConferenceController.php
+++ w/src/Controller/ConferenceController.php
@@ -15,6 +15,7 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
+use Symfony\Component\HttpKernel\Attribute\RateLimit;
use Symfony\Component\Routing\Attribute\Route;
final class ConferenceController extends AbstractController
@@ -31,6 +32,7 @@ final class ConferenceController extends AbstractController
]);
}
+ #[RateLimit('comment_submission', methods: ['POST'])]
#[Route('/conference/{slug}', name: 'conference')]
public function show(
Request $request,
Зверніть увагу на аргумент methods: перегляд сторінки конференції — це запит GET, і його не можна обмежувати; обмежуються лише надсилання коментарів (запити POST).
Коли ліміт досягнуто, Symfony автоматично повертає відповідь 429 Too Many Requests із HTTP-заголовком Retry-After, який повідомляє клієнту, коли він може повторити спробу.
Той самий компонент також захищає форму входу адміністратора від атак перебором; увімкнення обмеження частоти входу (англ. login throttling) на firewall займає один рядок:
1 2 3 4 5 6 7 8 9 10
--- i/config/packages/security.yaml
+++ w/config/packages/security.yaml
@@ -19,6 +19,7 @@ security:
main:
lazy: true
provider: app_user_provider
+ login_throttling: ~
form_login:
login_path: app_login
check_path: app_login
За замовчуванням Symfony блокує IP після 5 невдалих спроб входу з тим самим іменем користувача протягом хвилини (успішний вхід скидає лічильник). Використовуйте параметри max_attempts та interval, щоб налаштувати політику.
Управління конфіденційними даними в продакшн
Для продакшн Upsun підтримує налаштування чутливих змінних середовища:
1
$ symfony cloud:variable:create --sensitive=1 --level=project -y --name=env:OPENAI_API_KEY --value=sk-abcdef
Але, як обговорювалося вище, використання механізму Symfony для збереження конфіденційних даних може бути кращим варіантом. Не з точки зору безпеки, а з точки зору управління конфіденційними даними командою проекту. Усі конфіденційні дані зберігаються у сховищі, і єдиною змінною середовища, якою потрібно керувати для продакшн, є ключ дешифрування. Це дозволяє будь-якому члену команди додавати конфіденційні дані у продакшн, навіть якщо у нього немає доступу до продакшн серверів. Проте, налаштування цього процесу потребує дещо більшої обізнаності.
По-перше, згенеруйте пару ключів для використання в продакшн:
1
$ symfony console secrets:generate-keys --env=prod
On Linux and similiar OSes, use
APP_RUNTIME_ENV=prodinstead of--env=prodas this avoids compiling the application for theprodenvironment:1$ APP_RUNTIME_ENV=prod symfony console secrets:generate-keys
Повторно додайте секретний рядок OpenAI API у vault продакшн, але тепер з його продакшн значенням:
1
$ symfony console secrets:set OPENAI_API_KEY --env=prod
Останній крок полягає в тому, щоб відправити ключ дешифрування у Upsun, встановивши чутливу змінну:
1
$ symfony cloud:variable:create --sensitive=1 --level=project -y --name=env:SYMFONY_DECRYPTION_SECRET --value=`php -r 'echo base64_encode(include("config/secrets/prod/prod.decrypt.private.php"));'`
Ви можете додати й зафіксувати всі файли; файл з ключем дешифрування було автоматично додано у файл .gitignore, тож його ніколи не буде зафіксовано. Для більшої безпеки можна видалити його з вашого локального комп'ютера, якщо проект вже розгорнуто:
1
$ rm -f config/secrets/prod/prod.decrypt.private.php