Към съдържанието
PN Scripts

Разработка

Скрипт за формата за контакт на статичен сайт

  • PN Scripts
  • 11 мин четене

Статичният сайт, на чист HTML или направен с генератор като Hugo, Eleventy или Astro, е бърз, евтин и труден за чупене. После някой поиска форма за контакт, а статичният сайт няма къде да я изпрати. Обичайните отговори са външна услуга за форми, която прекарва всяко съобщение през чужди сървъри и често взема месечна такса, или функция в облака (serverless), което значи още една платформа за учене. Ако сайтът е на обикновен споделен хостинг, има по-прост вариант: хостингът вече изпълнява PHP. Тук правим малък скрипт, един PHP файл, и показваме какво трябва да направи правилно: проверка на полетата, спам, заглавки на писмото, доставка и съобщения, които не бива да се губят. Всичко тук е пуснато с PHP 8.4 и тествано в Chrome.

#Какво трябва да прави скриптът

  • Да приема формата само от вашия сайт и само с POST.
  • Да спира повечето спам, без да кара хората да решават пъзели.
  • Да проверява всяко поле и да обяснява проблемите с думи, които хората разбират.
  • Да изгражда писмо, което не може да бъде превърнато в спам препращач.
  • Да го изпраща така, че да стигне във входящата ви поща, а не в спама.
  • Никога да не губи съобщение, дори когато пощенският сървър не работи.
  • Да работи с JavaScript (грешките до полетата) и без него (обикновена страница).

#Скриптът

<?php
// contact.php: the endpoint for a static site's contact form.
const SECRET = 'replace-with-a-long-random-string';   // php -r 'echo bin2hex(random_bytes(32));'
const ALLOWED_ORIGINS = ['https://www.example.com'];
const TO = 'office@example.com';
const FROM = 'website@example.com';   // an address on your own domain, so SPF and DMARC pass
const THANK_YOU = '/thank-you.html';

function token(int $time): string
{
    return $time.'.'.hash_hmac('sha256', (string) $time, SECRET);
}

function token_age(string $token, int $now): ?int
{
    [$time, $mac] = array_pad(explode('.', $token, 2), 2, '');

    return ctype_digit($time) && hash_equals(hash_hmac('sha256', $time, SECRET), $mac) ? $now - (int) $time : null;
}

function one_line(string $value): string
{
    return trim(preg_replace('/[\r\n\t]+/', ' ', $value));   // nothing can start a new header
}

function reply(int $status, array $errors = []): never
{
    if (str_contains($_SERVER['HTTP_ACCEPT'] ?? '', 'application/json')) {
        http_response_code($status);
        header('Content-Type: application/json');
        exit(json_encode(['ok' => $errors === [], 'errors' => $errors], JSON_UNESCAPED_UNICODE));
    }
    if ($errors === []) {
        header('Location: '.THANK_YOU, true, 303);
        exit;
    }
    http_response_code($status);
    exit('<p>'.implode('</p><p>', array_map('htmlspecialchars', $errors)).'</p><p><a href="https://pnscripts.com/bg/contact.html">Back to the form</a></p>');
}

if ($_SERVER['REQUEST_METHOD'] === 'GET') {
    header('Content-Type: application/json');
    header('Cache-Control: no-store');
    exit(json_encode(['token' => token(time())]));
}

$origin = $_SERVER['HTTP_ORIGIN'] ?? null;
if ($origin !== null && ! in_array($origin, ALLOWED_ORIGINS, true)) {
    reply(403, ['form' => 'This form cannot be sent from another site.']);
}
if (($_POST['website'] ?? '') !== '') {
    reply(200);   // the honeypot: only a bot fills in the hidden field; pretend it worked
}
$token = (string) ($_POST['token'] ?? '');
if ($token !== '') {   // set by the page's script; a visitor without JavaScript has none
    $age = token_age($token, time());
    if ($age === null || $age < 3 || $age > 86400) {
        reply(422, ['form' => 'The form was sent too quickly, or the page is too old. Please reload it and try again.']);
    }
}

$name = one_line((string) ($_POST['name'] ?? ''));
$email = one_line((string) ($_POST['email'] ?? ''));
$message = trim((string) ($_POST['message'] ?? ''));
$errors = [];
if ($name === '' || mb_strlen($name) > 100) {
    $errors['name'] = 'Please enter your name.';
}
if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
    $errors['email'] = 'Please enter a valid e-mail address.';
}
if (mb_strlen($message) < 10 || mb_strlen($message) > 5000) {
    $errors['message'] = 'The message should be 10 to 5000 characters long.';
} elseif (preg_match_all('~https?://~i', $message) > 2) {
    $errors['message'] = 'Please include no more than two links.';
}
if ($errors !== []) {
    reply(422, $errors);
}

$subject = '=?UTF-8?B?'.base64_encode('Contact form: '.$name).'?=';
$headers = ['From: '.FROM, 'Reply-To: '.$email, 'MIME-Version: 1.0', 'Content-Type: text/plain; charset=UTF-8'];
$body = "Name: $name\nE-mail: $email\n\n$message\n";
if (! mail(TO, $subject, $body, implode("\r\n", $headers))) {
    // Keep the message outside the web root and send it later, instead of losing it.
    $file = __DIR__.'/../outbox/'.date('Ymd-His').'-'.bin2hex(random_bytes(4)).'.json';
    file_put_contents($file, json_encode(compact('name', 'email', 'message'), JSON_UNESCAPED_UNICODE));
}
reply(200);

Сложете го до HTML страниците, а папката outbox едно ниво по-горе, извън публичната папка, за да не може никой да изтегли чакащите съобщения. Сменете тайния ключ с дълъг случаен низ; той никога не напуска сървъра.

#Спам: три проверки преди всяка валидация

Произход. Браузърите пращат заглавка Origin при POST от страница. Ако тя назовава друг сайт, формата е копирана или вградена другаде и скриптът я отказва с 403. Ботовете, които пращат директно, често изобщо не пращат Origin, затова тази проверка е само първата от три.

Капан (honeypot). Формата има поле „Website“ в скрит абзац. Хората никога не го виждат; простите ботове попълват всяко поле, което намерят. Когато е попълнено, скриптът отговаря „изпратено“ и не изпраща нищо, така че ботът няма причина да опитва отново.

Капан за времето. Когато страницата се зареди, скриптът ѝ иска от сървъра знак (token): текущото време и HMAC подпис за него. Формата връща знака обратно. Знак, който е подправен, по-стар от ден или по-млад от три секунди, се отказва, защото никой човек не попълва форма за контакт за по-малко от три секунди. Подписът се проверява с hash_equals(), която отнема едно и също време, независимо дали грешен е първият или последният знак. Посетител без JavaScript няма знак; формата пак работи за него, а капанът и другите проверки остават.

Добавете ограничение на връзките (повечето спам носи няколко) и, при натоварен сайт, ограничение от няколко съобщения на посетител на час. Бройте ги по хеш на IP адреса със сол, а не по самия адрес, за да не е броячът дневник на посетителите ви. CAPTCHA може да дойде последна, ако спамът пак минава.

#Заглавки, които не могат да бъдат отвлечени

Класическата атака срещу формите за контакт е вмъкването на заглавки. Ако поле стигне до заглавка на писмото и съдържа нов ред, останалото от реда става нова заглавка. Име като Ana, нов ред и Bcc: хиляди адреси превръща формата ви в изпращач на спам с репутацията на вашия домейн зад него.

Скриптът затваря това по три начина. one_line() заменя новите редове в името и имейла с интервали. Имейлът трябва да мине FILTER_VALIDATE_EMAIL, която отказва всеки нов ред. А темата, единствената заглавка с името на посетителя, е кодирана в base64, така че каквото и да съдържа името, остава вътре в нея. Изпратихме точно тази атака: съобщението пристигна с „Ana Bcc: victim@example.net“ като безобиден текст в темата и тялото и без допълнителна заглавка.

#Писма, които стигат

Адресът в From трябва да е на вашия домейн. Изкушаващо е там да сложите адреса на посетителя, за да отиде „Отговор“ при него, но сървърът ви няма право да праща поща от името на gmail.com или abv.bg: проверките SPF и DMARC се провалят и писмото отива в спама или се отказва. Пращайте от адрес като website@вашиятдомейн, а адреса на посетителя сложете в Reply-To. Тогава отговорът работи както очаквате. Уверете се, че домейнът ви има записи SPF и DMARC; ръководството ни за фирмена поща на собствен домейн ги обяснява.

mail() на PHP държи примера кратък, но предава писмото на локалната пощенска система на хостинга, а върнатата стойност казва само, че писмото е прието за доставка, не че е пристигнало. Където можете, пращайте през SMTP сървъра на истинска пощенска кутия на вашия домейн, с вход, през TLS. Тогава писмото излиза през същия сървър като обикновената ви поща и е подписано по същия начин.

#Никога не губете съобщение

Форма за контакт, която казва „изпратено“, докато съобщението изчезва, е по-лоша от липсата на форма. Когато mail() се провали, скриптът записва съобщението в папката outbox като малък JSON файл, а посетителят пак вижда, че е получено. Тествахме го с команда за поща, която винаги се проваля: посетителят видя успех, а съобщението чакаше в outbox. Ежедневна задача в cron може да опитва да изпраща чакащите съобщения отново и да ви предупреди, ако някое е старо. Трийте ги след изпращане; те съдържат лични данни.

#Формата, с JavaScript и без него

<form action="/contact.php" method="post" id="contact">
  <input type="hidden" name="token">
  <p hidden><label>Website <input name="website" tabindex="-1" autocomplete="off"></label></p>
  <p><label>Name <input name="name" required maxlength="100"></label></p>
  <p><label>E-mail <input name="email" type="email" required></label></p>
  <p><label>Message <textarea name="message" required minlength="10"></textarea></label></p>
  <button>Send</button>
</form>
<script>
  const form = document.getElementById("contact");
  fetch("/contact.php").then((r) => r.json()).then((d) => { form.elements.token.value = d.token; });
  form.addEventListener("submit", async (event) => {
    event.preventDefault();
    form.querySelectorAll(".error").forEach((p) => p.remove());
    const response = await fetch(form.action, { method: "POST", body: new FormData(form), headers: { Accept: "application/json" } });
    const result = await response.json();
    if (result.ok) {
      location.href = "https://pnscripts.com/bg/thank-you.html";
      return;
    }
    for (const [field, text] of Object.entries(result.errors)) {
      const p = Object.assign(document.createElement("p"), { className: "error", textContent: text });
      (form.elements.namedItem(field) ?? form.querySelector("button")).after(p);
    }
    form.querySelector(".error")?.previousElementSibling?.focus();
  });
</script>

Без JavaScript формата се изпраща нормално: при успех скриптът отговаря с пренасочване 303 към страницата за благодарност, така че презареждането ѝ не изпраща съобщението втори път; при грешки показва проста страница с проблемите и връзка назад. С JavaScript скриптът на страницата изпраща формата във фона с Accept: application/json, слага всяка грешка точно след полето ѝ и премества фокуса на първото. В Chrome без графичен интерфейс кратко съобщение и непълен адрес показаха две грешки до полетата си с фокус върху имейла; форма, изпратена веднага след зареждането, показа съобщението на капана за времето под бутона; а съобщение на български пристигна непокътнато и страницата премина към благодарността.

#Поверителност

Формата за контакт събира лични данни: име, адрес и каквото хората напишат. Споменете я в политиката си за поверителност: какво се събира, защо, къде отива и колко дълго го пазите. Дръжте дневниците без съдържанието на съобщенията, а outbox за кратко. Това е практическа бележка, а не правен съвет; за по-широката картина вижте ръководството ни за GDPR и бисквитките.

#Скрипт, който го прави

Развихме това в Pismonosha, скрипт за формите за контакт на статични сайтове на споделен хостинг: една папка до страниците, чист PHP без база данни и без Composer. Обработва няколко форми със собствени получатели и правила за полетата, прикачени файлове, чието съдържание трябва да отговаря на типа им, грешки на български и английски до всяко поле, капан за ботове, подписан капан за времето, ограничение на посетител, което никога не пази IP адреси, CAPTCHA по желание, SMTP със STARTTLS или SSL, папка outbox с повторно изпращане от cron и проверка от командния ред, която влиза в пощенския ви сървър, преди да пуснете формата. Тестван е с 24 теста PHPUnit, пълно изпълнение в Chrome срещу локален пощенски сървър и проверка на STARTTLS и SSL и е обявен като „Очаквайте скоро“.

#Често задавани въпроси

Трябва ли ми база данни за форма за контакт?

Не. Съобщението отива по имейл. Малка папка извън публичната част за съобщенията, които още не са изпратени, е достатъчна.

Защо писмата от формата ми отиват в спама?

Най-често защото адресът в From е на посетителя или на домейн, от чието име сървърът ви не може да праща. Пращайте от адрес на собствения си домейн, сложете посетителя в Reply-To и настройте SPF и DMARC.

Достатъчен ли е капанът срещу спам?

Срещу простите ботове в повечето случаи да. Съчетайте го с капан за времето, ограничение на връзките и ограничение на посетител; пазете CAPTCHA за сайтовете, на които спамът продължава и след това.

Работи ли формата без JavaScript?

Трябва. Изпращайте формата нормално, пренасочвайте към страница за благодарност при успех и показвайте проста страница с проблемите при грешки; нека скриптът подобрява това, а не да го замества.

#Източници

Още по темата

Още по темата

Коментари

Коментари

Напишете първия коментар.

Оставете коментар

Следваща стъпка

pnscripts.com/bg/contact

Пишете на екипа

Попитайте за статия или ни разкажете за проект, който искате да изградим. Отговаряме до един работен ден.

Пишете ни

Семейството PN Scripts

Другите сайтове на PN Scripts

Хостингът, игрите и блогът имат свои сайтове, поддържани от същата компания.

  • pnscripts.com

    PN Scripts

    Софтуерно инженерство

    Уеб и мобилни приложения, API и игри по поръчка, плюс нашите продукти и плъгини с отворен код.

  • games.pnscripts.com

    Игри

    Игри и игрови сървъри

    Домът на игрите на PN Scripts: безплатни браузърни игри, които се играят веднага, а игровите сървъри предстоят.

  • hosting.pnscripts.com

    Хостинг

    Хостинг и инфраструктура

    Споделен хостинг, KVM VPS, самостоятелни сървъри и домейни от същата компания, която изработва проекта ви.

  • blog.pnscripts.com

    Блог

    Статии и бележки

    Практични статии за разработка на софтуер, хостинг, отворен код и игри, писани от хората, които ги създават.

    Вие сте тук