Туториали

Secure Contact Forms: Native PHP 8.3 Email Validation with Smart Caching

Безбедни контактни форми: природна PHP 8.3 валидација на е-пошта со паметно кеширање

Формуларот за контакт може да биде совршено валиден HTML, а сепак да собира адреси што не можат да примат одговор. Валидацијата во прелистувачот ги фаќа очигледните печатни грешки, но не ги проверува домените, MX-записите, сигналите од давателите или практичниот ризик за испорака. Резултатот е познат: неодговорени прашања, залудно дополнително следење и шумни поднесувања преку формуларот.

Во ова упатство се создава компактна апликација со Native PHP 8.3 што локално валидира адреса, ја користи услугата Email Validator, ги кешира успешните проценки и продолжува да прифаќа легитимни пораки кога оддалечената услуга е привремено недостапна. Прифатените поднесувања се чуваат во SQLite, додека неизвесните поднесувања се означуваат за подоцнежна проверка наместо тивко да се отфрлат.

Добијте пристап пред да напишете код за интеграција

  1. Регистрирајте се на https://ai.mihajlo.mk/register, или користете https://ai.mihajlo.mk/login ако веќе имате сметка.
  2. Отворете ја страницата на услугата Email Validator.
  3. Изберете го достапниот план Free, Plus или Pro и завршете ја неговата активација.
  4. Отворете ја официјалната документација на услугата.
  5. Најдете го панелот Service token и копирајте го токенот наменет за услугата.

Оваа услуга бара автентикација. Токенот се испраќа преку параметарот за барање token={serviceToken}. Повторното генерирање на токенот го поништува претходно активниот токен, затоа усогласете ја ротацијата со распоредувањето и веднаш ажурирајте ја секоја активна инстанца.

Точното барање е GET https://ai.mihajlo.mk/api/email-validator/v1/check-email. Покрај токенот, бара и параметар за барање email. Направете едно минимално барање пред да ја изградите функционалноста:

curl --get 'https://ai.mihajlo.mk/api/email-validator/v1/check-email' \
  --data-urlencode '[email protected]' \
  --data-urlencode 'token=YOUR_SERVICE_TOKEN'

Споредете го вратениот JSON со официјалната документација. Особено, забележете ги документираните вредности и скалата што ги користат status, score и recommendation. Доставениот договор ги именува овие полиња, но не оправдува нагаѓање на нивните enum-вредности или опсегот на резултатот.

Чувајте ги ингеренцијата и вашата експлицитна политика на апликацијата во конфигурација поддржана од променливи на околината. Не ја додавајте оваа датотека во верзионирање:

EMAIL_VALIDATOR_TOKEN=YOUR_SERVICE_TOKEN
EMAIL_VALIDATOR_ALLOWED_STATUSES=REPLACE_WITH_DOCUMENTED_ACCEPTED_STATUS
EMAIL_VALIDATOR_ALLOWED_RECOMMENDATIONS=REPLACE_WITH_DOCUMENTED_ACCEPTED_RECOMMENDATION
EMAIL_VALIDATOR_MIN_SCORE=REPLACE_WITH_A_THRESHOLD_IN_THE_DOCUMENTED_SCALE
EMAIL_VALIDATOR_CACHE_KEY=REPLACE_WITH_A_LONG_RANDOM_SECRET
EMAIL_VALIDATOR_CACHE_TTL=21600
APP_DB_PATH=/srv/contact-form/var/app.sqlite

Важно е вредностите на политиката да останат конфигурирачки. Така апликацијата може да го користи одговорот на услугата без да вградува недокументирани претпоставки за значењето на одреден збор или број.

Архитектура и политика при неуспех

Патеката на барањето намерно останува синхрона, бидејќи на посетителот му е потребна непосредна одлука. PHP прво извршува евтина локална валидација, потоа го проверува кешот и дури потоа ја повикува услугата. Успешниот одговор се мапира во типизиран доменски објект пред кој било код за политика да го види.

  • Проценка одобрена од политиката ја зачувува пораката како verified.
  • Одбивање според политиката враќа неутрална грешка за валидација и не зачувува ништо.
  • Грешка при транспорт, невалиден одговор, исцрпена оперативна состојба или друг неизвесен резултат ја зачувува пораката како deferred.
  • Се кешираат само структурно валидни одговори од услугата.

Патеката за одложување е намерна. Одбивањето на секое прашање за време на мрежен инцидент прави формуларот за контакт целосно да зависи од достапноста на друг систем. Прифаќањето неизвесни пораки го зачувува трудот на корисникот, а зачуваната состојба му овозможува на операторот подоцна повторно да ги провери. Не испраќајте автоматски одговори на одложени адреси пред да бидат прегледани.

Проектот ја користи следнава структура:

contact-form/
├── composer.json
├── database/schema.sql
├── public/index.php
├── src/
│   ├── CurlTransport.php
│   ├── EmailAssessment.php
│   ├── EmailPolicy.php
│   └── EmailValidator.php
├── tests/EmailValidatorTest.php
└── var/

Поставете PHP, SQLite и PHPUnit

PHP 8.3, cURL, PDO SQLite, JSON, Composer и командниот клиент за SQLite се предусловите. PHPUnit 11 поддржува PHP 8.3 и е единствената развојна зависност:

{
  "require": {
    "php": ">=8.3",
    "ext-curl": "*",
    "ext-json": "*",
    "ext-pdo": "*",
    "ext-pdo_sqlite": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  },
  "autoload-dev": {
    "psr-4": {
      "Tests\\": "tests/"
    }
  }
}
CREATE TABLE email_cache (
    cache_key TEXT PRIMARY KEY,
    payload TEXT NOT NULL,
    expires_at INTEGER NOT NULL
);

CREATE TABLE contacts (
    id TEXT PRIMARY KEY,
    name TEXT NOT NULL,
    email TEXT NOT NULL,
    message TEXT NOT NULL,
    validation_state TEXT NOT NULL,
    api_metadata TEXT,
    created_at TEXT NOT NULL
);
composer install
mkdir -p var
chmod 700 var
sqlite3 var/app.sqlite < database/schema.sql
composer dump-autoload

Базата на податоци и нејзиниот родителски директориум мора да бидат запишливи за корисникот PHP-FPM, но не смеат да се наоѓаат под public/.

Мапирајте го API-одговорот на границата

Апликацијата ги користи сите пет договорени области на одговорот: status, score, recommendation, checks и quota. Непознатите дополнителни полиња се игнорираат. Недостасувачките или погрешно типизираните задолжителни полиња го претвораат одговорот во контролиран неуспех наместо да пропуштат лабаво типизирани податоци во деловната логика.

<?php
// src/EmailAssessment.php
declare(strict_types=1);

namespace App;

final readonly class EmailAssessment
{
    public function __construct(
        public string $status,
        public float $score,
        public string $recommendation,
        public array $checks,
        public array $quota,
    ) {}

    public static function fromArray(array $data): self
    {
        foreach (['status', 'score', 'recommendation', 'checks', 'quota'] as $field) {
            if (!array_key_exists($field, $data)) {
                throw new \UnexpectedValueException("Missing API field: {$field}");
            }
        }

        if (!is_string($data['status']) ||
            !is_string($data['recommendation']) ||
            !is_numeric($data['score']) ||
            !is_array($data['checks']) ||
            !is_array($data['quota'])) {
            throw new \UnexpectedValueException('Invalid Email Validator response types');
        }

        $score = (float) $data['score'];
        if (!is_finite($score)) {
            throw new \UnexpectedValueException('Non-finite score');
        }

        return new self(
            trim($data['status']),
            $score,
            trim($data['recommendation']),
            $data['checks'],
            $data['quota'],
        );
    }

    public function toArray(): array
    {
        return [
            'status' => $this->status,
            'score' => $this->score,
            'recommendation' => $this->recommendation,
            'checks' => $this->checks,
            'quota' => $this->quota,
        ];
    }
}

Изградете ограничен cURL-транспорт со свесност за повторни обиди

Транспортот повторува само мрежни неуспеси, HTTP 429 и неуспеси на серверската страна. Грешките при автентикација и другите 4xx-одговори не се повторуваат, бидејќи повторувањето нема да поправи невалиден токен или барање. Повратното чекање е кратко и ограничено, така што едно поднесување преку формуларот не може неограничено да зафаќа PHP worker.

<?php
// src/CurlTransport.php
declare(strict_types=1);

namespace App;

final class CurlTransport
{
    public function get(string $endpoint, array $query): array
    {
        $url = $endpoint . '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986);
        $lastError = 'request failed';

        for ($attempt = 0; $attempt < 3; $attempt++) {
            $headers = [];
            $handle = curl_init($url);

            curl_setopt_array($handle, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CONNECTTIMEOUT_MS => 1000,
                CURLOPT_TIMEOUT_MS => 3000,
                CURLOPT_HTTPHEADER => ['Accept: application/json'],
                CURLOPT_USERAGENT => 'contact-form/1.0',
                CURLOPT_HEADERFUNCTION => static function (
                    \CurlHandle $curl,
                    string $line
                ) use (&$headers): int {
                    $parts = explode(':', $line, 2);
                    if (count($parts) === 2) {
                        $headers[strtolower(trim($parts[0]))] = trim($parts[1]);
                    }
                    return strlen($line);
                },
            ]);

            $body = curl_exec($handle);
            $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
            $curlError = curl_error($handle);
            curl_close($handle);

            if ($body !== false && $status >= 200 && $status < 300) {
                $decoded = json_decode($body, true, 64, JSON_THROW_ON_ERROR);
                if (!is_array($decoded)) {
                    throw new \UnexpectedValueException('Expected a JSON object');
                }
                return $decoded;
            }

            $retryable = $body === false || $status === 429 || $status >= 500;
            $lastError = $body === false ? $curlError : "HTTP {$status}";

            if (!$retryable || $attempt === 2) {
                throw new \RuntimeException("Email validation failed: {$lastError}");
            }

            $retryAfter = isset($headers['retry-after']) &&
                ctype_digit($headers['retry-after'])
                ? min(2, (int) $headers['retry-after'])
                : 0;

            $delayMicroseconds = $retryAfter > 0
                ? $retryAfter * 1_000_000
                : (100_000 * (2 ** $attempt)) + random_int(0, 50_000);

            usleep($delayMicroseconds);
        }

        throw new \RuntimeException("Email validation failed: {$lastError}");
    }
}

Одговор 429 може да укажува на кратко ограничување на стапката или исцрпено одобрение. Транспортот почитува мал нумерички Retry-After, но ограничувањето од три обиди спречува прекумерна вредност од серверот да го блокира барањето. Постојаните проблеми со квотата завршуваат на патеката за одложување и треба да активираат оперативно предупредување.

Додадете кеширање што ја зема предвид приватноста и доменска политика

Е-поштенските адреси се лични податоци. Затоа клучевите на кешот користат HMAC со клуч наместо да чуваат адреса или лесно повратен обичен hash во табелата со кеш. Успешните проценки добиваат конечен TTL; грешките при транспорт никогаш не се кешираат.

<?php
// src/EmailValidator.php
declare(strict_types=1);

namespace App;

final class EmailValidator
{
    private const ENDPOINT =
        'https://ai.mihajlo.mk/api/email-validator/v1/check-email';

    public function __construct(
        private \PDO $db,
        private CurlTransport $transport,
        private string $token,
        private string $cacheSecret,
        private int $ttl,
    ) {}

    public function check(string $email): EmailAssessment
    {
        $normalized = strtolower(trim($email));
        $key = hash_hmac('sha256', $normalized, $this->cacheSecret);

        $query = $this->db->prepare(
            'SELECT payload FROM email_cache
             WHERE cache_key = :key AND expires_at > :now'
        );
        $query->execute(['key' => $key, 'now' => time()]);
        $cached = $query->fetchColumn();

        if (is_string($cached)) {
            try {
                return EmailAssessment::fromArray(
                    json_decode($cached, true, 64, JSON_THROW_ON_ERROR)
                );
            } catch (\Throwable) {
                $delete = $this->db->prepare(
                    'DELETE FROM email_cache WHERE cache_key = :key'
                );
                $delete->execute(['key' => $key]);
            }
        }

        $payload = $this->transport->get(self::ENDPOINT, [
            'email' => $normalized,
            'token' => $this->token,
        ]);
        $assessment = EmailAssessment::fromArray($payload);

        $write = $this->db->prepare(
            'INSERT INTO email_cache(cache_key, payload, expires_at)
             VALUES(:key, :payload, :expires)
             ON CONFLICT(cache_key) DO UPDATE SET
               payload = excluded.payload,
               expires_at = excluded.expires_at'
        );
        $write->execute([
            'key' => $key,
            'payload' => json_encode(
                $assessment->toArray(),
                JSON_THROW_ON_ERROR
            ),
            'expires' => time() + $this->ttl,
        ]);

        return $assessment;
    }
}
<?php
// src/EmailPolicy.php
declare(strict_types=1);

namespace App;

final readonly class EmailPolicy
{
    public function __construct(
        private array $allowedStatuses,
        private array $allowedRecommendations,
        private float $minimumScore,
    ) {}

    public function decide(EmailAssessment $result): string
    {
        if ($result->quota === []) {
            return 'deferred';
        }

        if (!in_array($result->status, $this->allowedStatuses, true) ||
            !in_array(
                $result->recommendation,
                $this->allowedRecommendations,
                true
            ) ||
            $result->score < $this->minimumScore ||
            $this->containsExplicitFailure($result->checks)) {
            return 'rejected';
        }

        return 'verified';
    }

    private function containsExplicitFailure(array $checks): bool
    {
        foreach ($checks as $value) {
            if ($value === false) {
                return true;
            }
            if (is_array($value) && $this->containsExplicitFailure($value)) {
                return true;
            }
        }
        return false;
    }
}

Оваа политика е намерно строга: секој експлицитен Boolean-неуспех во checks ја одбива адресата. Ако документираниот одговор содржи информативни Boolean-полиња што легитимно може да бидат false, заменете го рекурзивното правило со именувана allowlist од документираните клучеви за проверки.

Поврзете го валидаторот со формуларот за контакт

Предниот контролер наметнува CSRF-заштита, ограничувања на должината на внесот, локална валидација на синтаксата и подготвени изјави за базата на податоци. Никогаш не им прикажува детали за давателот на посетителите и никогаш не го евидентира токенот.

<?php
// public/index.php
declare(strict_types=1);

use App\CurlTransport;
use App\EmailPolicy;
use App\EmailValidator;

require dirname(__DIR__) . '/vendor/autoload.php';
session_start();

$required = static function (string $name): string {
    $value = getenv($name);
    if ($value === false || trim($value) === '') {
        throw new RuntimeException("Missing environment variable: {$name}");
    }
    return trim($value);
};

$db = new PDO('sqlite:' . $required('APP_DB_PATH'), null, null, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);

$validator = new EmailValidator(
    $db,
    new CurlTransport(),
    $required('EMAIL_VALIDATOR_TOKEN'),
    $required('EMAIL_VALIDATOR_CACHE_KEY'),
    (int) $required('EMAIL_VALIDATOR_CACHE_TTL'),
);

$policy = new EmailPolicy(
    array_map('trim', explode(',', $required('EMAIL_VALIDATOR_ALLOWED_STATUSES'))),
    array_map(
        'trim',
        explode(',', $required('EMAIL_VALIDATOR_ALLOWED_RECOMMENDATIONS'))
    ),
    (float) $required('EMAIL_VALIDATOR_MIN_SCORE'),
);

$_SESSION['csrf'] ??= bin2hex(random_bytes(32));
$message = '';
$httpStatus = 200;

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $token = (string) ($_POST['csrf'] ?? '');
    $name = trim((string) ($_POST['name'] ?? ''));
    $email = strtolower(trim((string) ($_POST['email'] ?? '')));
    $body = trim((string) ($_POST['message'] ?? ''));

    if (!hash_equals($_SESSION['csrf'], $token)) {
        $httpStatus = 403;
        $message = 'Please reload the form and try again.';
    } elseif (
        $name === '' || strlen($name) > 120 ||
        strlen($email) > 254 ||
        !filter_var($email, FILTER_VALIDATE_EMAIL) ||
        $body === '' || strlen($body) > 5000
    ) {
        $httpStatus = 422;
        $message = 'Please check the submitted fields.';
    } else {
        $state = 'deferred';
        $metadata = null;

        try {
            $assessment = $validator->check($email);
            $state = $policy->decide($assessment);
            $metadata = json_encode(
                $assessment->toArray(),
                JSON_THROW_ON_ERROR
            );
        } catch (Throwable $error) {
            error_log(json_encode([
                'event' => 'email_validation_unavailable',
                'exception' => $error::class,
            ], JSON_THROW_ON_ERROR));
        }

        if ($state === 'rejected') {
            $httpStatus = 422;
            $message = 'Please provide another email address.';
        } else {
            $insert = $db->prepare(
                'INSERT INTO contacts
                 (id, name, email, message, validation_state,
                  api_metadata, created_at)
                 VALUES
                 (:id, :name, :email, :message, :state,
                  :metadata, :created)'
            );
            $insert->execute([
                'id' => bin2hex(random_bytes(16)),
                'name' => $name,
                'email' => $email,
                'message' => $body,
                'state' => $state,
                'metadata' => $metadata,
                'created' => gmdate(DATE_ATOM),
            ]);

            http_response_code(202);
            echo '<p>Thank you. Your message has been received.</p>';
            exit;
        }
    }
}

http_response_code($httpStatus);
$escape = static fn(string $value): string =>
    htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
?>
<?php if ($message !== ''): ?>
<p><?= $escape($message) ?></p>
<?php endif; ?>
<form method="post">
  <input type="hidden" name="csrf"
         value="<?= $escape($_SESSION['csrf']) ?>">
  <label>Name <input name="name" maxlength="120" required></label>
  <label>Email
    <input name="email" type="email" maxlength="254" required>
  </label>
  <label>Message
    <textarea name="message" maxlength="5000" required></textarea>
  </label>
  <button type="submit">Send</button>
</form>

Тестирајте ги патеките за кеширање и неуспех детерминистички

Тестовите не смеат да зависат од достапноста на мрежата ниту да трошат квота. Правењето на транспортот non-final, или издвојувањето интерфејс во поголема кодна база, овозможува детерминистичка замена. Овој тест потврдува дека кешираниот резултат спречува второ HTTP-барање:

<?php
// tests/EmailValidatorTest.php
declare(strict_types=1);

namespace Tests;

use App\CurlTransport;
use App\EmailValidator;
use PHPUnit\Framework\TestCase;

final class FakeTransport extends CurlTransport
{
    public int $calls = 0;

    public function __construct(private array $response) {}

    public function get(string $endpoint, array $query): array
    {
        $this->calls++;
        return $this->response;
    }
}

final class EmailValidatorTest extends TestCase
{
    public function testValidResponseIsMappedAndCached(): void
    {
        $db = new \PDO('sqlite::memory:');
        $db->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
        $db->exec(
            'CREATE TABLE email_cache (
                cache_key TEXT PRIMARY KEY,
                payload TEXT NOT NULL,
                expires_at INTEGER NOT NULL
            )'
        );

        $fake = new FakeTransport([
            'status' => 'documented-status',
            'score' => 80,
            'recommendation' => 'documented-recommendation',
            'checks' => ['syntax' => true, 'mx' => true],
            'quota' => ['remaining' => 10],
        ]);

        $validator = new EmailValidator(
            $db,
            $fake,
            'test-token',
            'test-cache-secret',
            3600,
        );

        $first = $validator->check('[email protected]');
        $second = $validator->check('[email protected]');

        self::assertSame('documented-status', $first->status);
        self::assertSame($first->toArray(), $second->toArray());
        self::assertSame(1, $fake->calls);
    }

    public function testMalformedResponseIsRejected(): void
    {
        $this->expectException(\UnexpectedValueException::class);
        \App\EmailAssessment::fromArray(['status' => 'incomplete']);
    }
}
vendor/bin/phpunit --testdox

Додадете фокусирани тестови за HTTP 401 без повторен обид, повторен 429, оштетен JSON во кешот, експлицитна false-проверка, празен објект quota, гранични вредности на score, CSRF-одбивање и патеката за одложување во базата на податоци. Низите за лажниот одговор намерно се тест-фактури, а не тврдења за enum-вредностите во продукција.

Безбедност, набљудливост и распоредување

Прекинете го TLS на доверлив proxy, ограничете ја големината на телото на барањето и додадете ограничување на формуларот по IP или по сесија. Чувајте ги API-токенот, HMAC-тајната на кешот, базата на податоци и датотеките со променливи на околината надвор од коренот на документите. Никогаш не ја ставајте целосната URL-адреса на барањето во логови, бидејќи нејзината низа за барање го содржи токенот.

Корисни структурирани настани во логовите вклучуваат латентност на валидацијата, HTTP-статус, број на повторни обиди, погодок или промашување во кешот, конечна состојба и класа на исклучок. Хеширајте адреса со истиот HMAC со клуч ако е потребна корелација; не ја евидентирајте суровата адреса. Поставете предупредувања за постојани стапки на одложување, неуспеси при автентикација и одговори 429 поврзани со квота.

При распоредување, создадете ја шемата пред да го префрлите сообраќајот, внесете променливи на околината преку управувачот со процеси или складиштето за тајни, потврдете надворешен HTTPS-пристап и потврдете дека PHP-корисникот може да запишува само во наменетите SQLite-датотеки. Користете WAL-режим или мигрирајте на серверска база на податоци кога обемот на истовремени запишувања ќе го надрасне SQLite. Ротирајте го токенот на услугата со веднаш ажурирање на сите инстанци по повторното генерирање, бидејќи претходниот токен е поништен.

Вообичаени неуспеси и конечна проверка

  • Секое барање е одложено: проверете DNS, надворешен HTTPS, cURL-сертификати, истекувања на време, квота и структурирани логови за грешки.
  • Автентикацијата не успева: потврдете дека токенот наменет за услугата е активен и се испраќа преку параметарот за барање token.
  • Валидните адреси се одбиваат: проверете ги конфигурираните status, recommendation, скалата на score и документираното значење на Boolean-проверките.
  • Кешот никогаш нема погодок: потврдете нормализација, доследност на HMAC-тајната меѓу инстанците, запишливо складирање и позитивен TTL.
  • SQLite пријавува грешки за заклучување: скратете ги трансакциите, овозможете WAL каде што е соодветно или преместете ја споделената состојба во база на податоци наменета за истовремени запишувачи.
  1. Поднесете невалидно форматирана адреса и потврдете дека не се создаваат API-барање ниту запис за контакт.
  2. Поднесете документиран прифатлив случај и потврдете запис verified.
  3. Повторете го и потврдете погодок во кешот без друг оддалечен повик.
  4. Испробајте документиран ризичен случај и потврдете неутрален одговор 422.
  5. Оневозможете надворешен пристап и потврдете дека пораката се задржува како deferred.
  6. Симулирајте невалиден JSON и одговори 401, 429 и 5xx во автоматизирани тестови.
  7. Потврдете дека логовите не содржат ниту токен на услугата ниту сурови е-поштенски адреси.

Важната продукциска поука не е само да се повика endpoint за проверка на е-пошта. Таа е да се изгради граница околу него: експлицитна политика, дефанзивно мапирање, ограничени повторни обиди, кеширање свесно за приватноста, набљудливи состојби на неуспех и резервна опција што го почитува трудот на посетителот. Со овие делови, мал формулар за контакт станува значително посигурен без да стане непотребно комплициран.

Портрет на автор на блогот

Mihajlo

Јас сум Михајло - развивач поттикнат од љубопитност, дисциплина и постојаната желба да создадам нешто значајно. Споделувам увиди, упатства и бесплатни услуги за да им помогнам на другите да ја поедностават својата работа и да растат во постојано развивачкиот свет на софтверот и вештачката интелигенција.