PHP на бэкенде: HTTP, безопасность, форматирование
Тринадцать PHP-сниппетов для серверной стороны: настоящий IP клиента за прокси, хеширование bcrypt, лимитер на Redis, повторы с экспоненциальной задержкой и джиттером, разбор BCP-47, очистка UTF-8 и маскирование токенов.
PHP по-прежнему обрабатывает заметную долю веб-запросов, и здесь собрано то, что находится ближе всего к самому запросу: кто его прислал, принимать ли его и как ответить, ничего не утекая.
IP клиента — единственное, чему нельзя верить
$_SERVER['REMOTE_ADDR'] содержит адрес того, кто установил соединение, — а за балансировщиком или CDN это и есть балансировщик. Обычное решение — читать X-Forwarded-For, и обычная ошибка — читать его безусловно: заголовок присылает клиент, поэтому атакующий подставит туда любой адрес и обойдёт все ваши лимиты и баны по IP. Заголовок имеет смысл, только когда соединение пришло от прокси, который контролируете вы, и нужное значение — последнее, дописанное вашей инфраструктурой, а не первое в списке.
Пароли, токены и вопрос времени
password_hash() с алгоритмом по умолчанию — правильный ответ для паролей, а password_verify() сам сравнивает за постоянное время. Ловушка в другом месте: сравнение API-ключей, подписей вебхуков и токенов сессий через == выдаёт их содержимое через тайминг, потому что сравнение строк выходит на первом же различающемся байте. Именно для этого существует hash_equals().
Маскирование токена для показа — вторая половина той же задачи: секреты чаще всего утекают через логи и экраны поддержки, а первых и последних символов достаточно, чтобы опознать ключ, не раскрывая его.
Лимиты и повторы — две стороны одного
Лимитер на Redis отвечает на вопрос «принимать ли этот запрос», а повтор с задержкой — на вопрос «что делать, когда чужой лимитер отказал мне». Пропускают обычно джиттер: без него все клиенты, упавшие в один момент, повторяют попытку тоже в один момент, и восстановление превращается во вторую аварию. Добавление случайности к задержке — и есть всё лечение.
Кодировки — место обитания невидимых багов
Невалидный UTF-8 о себе не сообщает. Он проходит через приложение незамеченным и всплывает ошибкой базы, сломанным JSON-ответом или белой страницей — как правило, далеко от места, где попал внутрь. Санитизация на границе дешевле последующего расследования.
Корректный разбор языкового тега BCP-47 важен по той же причине: en-GB, en_gb и en означают слегка разное, а наивное сравнение строк с захардкоженным списком тихо ломается для всех, кто в него не попал.
GET-запрос через cURL
Классический рецепт PHP для получения JSON от внешнего API без каких-либо дополнительных зависимостей.
Как это работает: curl_init открывает соединение с URL, CURLOPT_RETURNTRANSFER заставляет curl_exec вернуть тело ответа строкой вместо вывода на экран, а curl_getinfo после выполнения читает HTTP-статус код.
<?php
function httpGet(string $url, array $headers = []): array
{
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
$response = curl_exec($ch);
$statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return ['status' => $statusCode, 'body' => json_decode($response, true)];
}
// Example usage
$result = httpGet('https://api.example.com/users/1', ['Accept: application/json']);
if ($result['status'] === 200) {
echo $result['body']['name'];
}
Форматирование байтов в читаемый размер
Превращает количество байтов в "1.5 MB", "820 KB" и т.д. — стандартный хелпер для отображения размера файла.
Как это работает: log($bytes, 1024) определяет, в какую степень 1024 попадает количество байтов (0 = байты, 1 = КБ, 2 = МБ...), после чего значение делится на эту степень для получения отображаемого числа.
<?php
function formatBytes(int $bytes, int $precision = 2): string
{
if ($bytes <= 0) {
return '0 B';
}
$units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
$power = min((int) floor(log($bytes, 1024)), count($units) - 1);
return round($bytes / (1024 ** $power), $precision) . ' ' . $units[$power];
}
// Example usage
formatBytes(1536); // "1.5 KB"
formatBytes(1073741824); // "1 GB"
Получение реального IP клиента за прокси/CDN
Читает настоящий IP посетителя из заголовков CDN/прокси (Cloudflare, X-Forwarded-For и т.д.), с фолбэком на $_SERVER['REMOTE_ADDR'], и отбрасывает приватные/поддельные значения.
Как это работает: заголовки прокси/CDN проверяются в порядке доверия (сначала собственный заголовок Cloudflare, поскольку его нельзя подделать, если Cloudflare стоит перед приложением), и каждый кандидат проверяется как настоящий публичный IP, прежде чем ему довериться — приватные диапазоны (10.x, 192.168.x и т.д.) отбрасываются, чтобы клиент не мог подделать X-Forwarded-For: 10.0.0.1 и спрятаться за внутренним на вид адресом.
<?php
function getClientIp(): ?string
{
$headers = ['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_CLIENT_IP'];
foreach ($headers as $header) {
$value = $_SERVER[$header] ?? null;
if (!$value) {
continue;
}
// X-Forwarded-For can contain a comma-separated chain; the first entry is the original client
foreach (explode(',', $value) as $candidate) {
$ip = trim($candidate);
if (isPublicIp($ip)) {
return $ip;
}
}
}
return $_SERVER['REMOTE_ADDR'] ?? null;
}
function isPublicIp(string $ip): bool
{
return (bool) filter_var(
$ip,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
);
}
// Example usage
$ip = getClientIp(); // "203.0.113.42" — never a spoofed private/loopback address
Хеширование паролей bcrypt (PHP)
Хеширует и проверяет пароли через нативный PHP password_hash с bcrypt (cost 12). Включает проверку необходимости повторного хеширования при изменении параметров.
Как это работает: password_hash с алгоритмом PASSWORD_BCRYPT и cost: 12 — хороший баланс безопасности и производительности (~250мс). password_needs_rehash позволяет постепенно обновлять старые хеши при логине без принудительной смены паролей.
<?php
const HASH_COST = 12;
function hashPassword(string $password): string
{
return password_hash($password, PASSWORD_BCRYPT, ['cost' => HASH_COST]);
}
function verifyPassword(string $password, string $hash): bool
{
return password_verify($password, $hash);
}
function needsRehash(string $hash): bool
{
return password_needs_rehash($hash, PASSWORD_BCRYPT, ['cost' => HASH_COST]);
}
// Example usage — регистрация
$hash = hashPassword($_POST['password']);
// Сохрани $hash в БД
// Авторизация
$user = $db->findByEmail($email);
if (!verifyPassword($plainPassword, $user['password_hash'])) {
throw new \Exception('Invalid credentials');
}
// Обновить хеш если изменились параметры (повышение cost)
if (needsRehash($user['password_hash'])) {
$db->updatePasswordHash($user['id'], hashPassword($plainPassword));
}
Цветной аватар с инициалами (HTML/CSS)
Рендерит круглый аватар с инициалами пользователя и цветом, полученным из его имени — лёгкий фолбэк, когда нет фото профиля.
Как это работает: имя хэшируется в оттенок (0-359°), поэтому один и тот же человек всегда получает один и тот же цвет, а инициалы — это просто первая буква имени и фамилии.
<?php
function buildInitials(?string $firstName, ?string $lastName): string
{
$first = trim((string) $firstName);
$last = trim((string) $lastName);
if ($first === '' && $last === '') {
return 'U';
}
return mb_strtoupper(mb_substr($first, 0, 1) . mb_substr($last, 0, 1));
}
function avatarColor(string $name): string
{
$hash = 0;
for ($i = 0; $i < mb_strlen($name); $i++) {
$hash = mb_ord(mb_substr($name, $i, 1)) + (($hash << 5) - $hash);
}
$hue = abs($hash) % 360;
return "hsl({$hue}, 80%, 60%)";
}
function renderAvatar(?string $firstName, ?string $lastName, int $size = 36): string
{
$initials = buildInitials($firstName, $lastName);
$color = avatarColor(trim($firstName . $lastName) ?: 'user');
return sprintf(
'<div style="width:%1$dpx;height:%1$dpx;border-radius:9999px;background:%2$s;color:#fff;display:flex;align-items:center;justify-content:center;font-weight:600;">%3$s</div>',
$size,
$color,
htmlspecialchars($initials)
);
}
// Example usage
echo renderAvatar('Alex', 'Rel'); // circular "AR" badge, color derived from the name
Получение геолокации по IP (ipapi.co)
Определяет город и страну посетителя по его IP-адресу через бесплатный API ipapi.co.
Как это работает: GET-запрос к ipapi.co/{ip}/json/ возвращает данные геолокации в формате JSON. Если оставить $ip пустым, API само определит IP вызывающей стороны — удобно для определения текущего посетителя на сервере.
<?php
function getIpInfo(string $ip = ''): array
{
$url = 'https://ipapi.co/' . $ip . '/json/';
$response = file_get_contents($url);
return json_decode($response, true) ?? [];
}
// Example usage
$info = getIpInfo(); // empty $ip -> detects the server's own IP
echo $info['city'] . ', ' . $info['country_name'];
$info = getIpInfo('8.8.8.8'); // or look up a specific IP
echo $info['timezone'];
Маскирование токена/API-ключа для безопасного отображения
Показывает только первые и последние 4 символа секрета (например, API-ключа), чтобы его можно было безопасно логировать или отображать в интерфейсе.
Как это работает: сохраняются первые и последние 4 символа, а всё остальное заменяется звёздочками; очень короткие секреты (8 символов и меньше) маскируются полностью, чтобы не утекло ничего значимого.
<?php
function getTokenPreview(?string $token): ?string
{
if (empty($token)) {
return null;
}
$length = strlen($token);
if ($length <= 8) {
return str_repeat('*', $length);
}
return substr($token, 0, 4) . str_repeat('*', 4) . substr($token, -4);
}
// Example usage
getTokenPreview('sk_live_51Hh2eZC9x8f3JvQwErTyUi'); // "sk_l****EiUi"
getTokenPreview('short'); // "*****"
Разбор языкового тега BCP-47
Приводит языковые коды вроде en-GB, pt_BR или sr_RS (как их присылают браузеры, Telegram и т.д.) к паре язык + регион.
Как это работает: нижние подчёркивания приводятся к дефисам, тег разбивается на части, первая часть — код языка, а любая часть из 2 букв или 3 цифр после неё считается регионом.
<?php
function parseLanguageTag(?string $tag): array
{
if (empty($tag)) {
return ['language' => null, 'region' => null, 'canonical' => null];
}
$parts = array_values(array_filter(explode('-', str_replace('_', '-', trim($tag)))));
if (empty($parts)) {
return ['language' => null, 'region' => null, 'canonical' => null];
}
$language = strtolower($parts[0]);
$region = null;
foreach (array_slice($parts, 1) as $part) {
if (preg_match('/^[A-Za-z]{2}$/', $part)) {
$region = strtoupper($part);
break;
}
if (preg_match('/^\d{3}$/', $part)) {
$region = $part;
break;
}
}
return [
'language' => $language,
'region' => $region,
'canonical' => $region ? "{$language}-{$region}" : $language,
];
}
// Example usage
parseLanguageTag('en-GB'); // ['language' => 'en', 'region' => 'GB', 'canonical' => 'en-GB']
parseLanguageTag('pt_BR'); // ['language' => 'pt', 'region' => 'BR', 'canonical' => 'pt-BR']
Rate Limiting через Redis (PHP)
Простой rate limiter на Redis — ограничивает количество запросов от пользователя за скользящее временное окно. Атомарный инкремент через INCR/EXPIRE без дополнительных зависимостей.
Как это работает: при каждом запросе атомарно инкрементируется счётчик в Redis с ключом, включающим идентификатор пользователя и временное окно. При первом обращении устанавливается TTL. Если счётчик превысил лимит — возвращается true (заблокирован).
<?php
function isRateLimited(Redis $redis, string $key, int $maxRequests, int $windowSeconds): bool
{
$current = $redis->incr($key);
if ($current === 1) {
$redis->expire($key, $windowSeconds);
}
return $current > $maxRequests;
}
// Example usage — 10 запросов в минуту на пользователя
function checkUserRateLimit(Redis $redis, int $userId): bool
{
$window = (int) floor(time() / 60); // сброс каждую минуту
$key = "rate:user:{$userId}:{$window}";
return isRateLimited($redis, $key, 10, 60);
}
// В контроллере / вебхук-обработчике
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
if (checkUserRateLimit($redis, $userId)) {
http_response_code(429);
header('Retry-After: 60');
exit(json_encode(['error' => 'Too many requests']));
}
Повтор операции с экспоненциальной задержкой и джиттером
Повторяет нестабильный вызов API с увеличивающимися задержками между попытками плюс случайный джиттер, чтобы много клиентов не повторяли запрос синхронно.
Как это работает: каждая повторная попытка удваивает задержку (200мс, 400мс, 800мс...) с ограничением в $maxDelayMs, плюс случайный джиттер 0-20% сверху. Джиттер важен, потому что если 1000 клиентов одновременно получат ошибку и повторят попытку через ровно одинаковую фиксированную задержку, они все снова ударят по серверу в один и тот же момент.
<?php
function retryWithBackoff(callable $operation, int $maxAttempts = 3, int $baseDelayMs = 200, int $maxDelayMs = 2000): mixed
{
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
try {
return $operation();
} catch (\Throwable $e) {
if ($attempt >= $maxAttempts) {
throw $e;
}
$delay = min($maxDelayMs, $baseDelayMs * 2 ** ($attempt - 1));
$jitter = random_int(0, (int) ($delay * 0.2));
usleep(($delay + $jitter) * 1000);
}
}
throw new \RuntimeException('Unreachable');
}
// Example usage
$response = retryWithBackoff(fn () => Http::get('https://api.example.com/data')->throw());
Приведение строки к валидному UTF-8
Убирает или исправляет некорректные последовательности байтов, чтобы строку всегда можно было безопасно сохранить, залогировать или вернуть как JSON.
Как это работает: конвертация строки из UTF-8 в UTF-8 через mb_convert_encoding заставляет PHP заменить любые некорректные последовательности байтов вместо того, чтобы оставить их как есть — именно из-за них json_encode() иначе молча возвращает false.
<?php
function sanitizeUtf8(mixed $value): ?string
{
if ($value === null || !is_string($value)) {
return null;
}
if ($value === '') {
return '';
}
$sanitized = mb_convert_encoding($value, 'UTF-8', 'UTF-8');
return $sanitized !== false ? $sanitized : null;
}
// Example usage — prevents json_encode() from silently failing on bad input
$clean = sanitizeUtf8($userSuppliedString);
echo json_encode(['name' => $clean]);
Генерация безопасного случайного пароля
Создаёт криптографически безопасный случайный пароль с помощью random_bytes() и bin2hex() в PHP.
Как это работает: random_bytes() берёт байты из криптографически безопасного генератора ОС, а bin2hex() превращает их в печатаемую hex-строку вдвое длиннее исходного количества байт — поэтому $length / 2 байт дают пароль ровно из $length символов.
<?php
function generatePassword(int $length = 12): string
{
return bin2hex(random_bytes((int) ceil($length / 2)));
}
$password = generatePassword(); // e.g. "a4c9b72f3d88"
Валидация email-адреса
Правильный встроенный способ проверить формат email в PHP — без самодельного регулярного выражения.
Как это работает: FILTER_VALIDATE_EMAIL следует синтаксическим правилам RFC 822/2822 и обрабатывает крайние случаи (закавыченные локальные части, IP-literal домены и т.д.) намного надёжнее, чем регулярное выражение, которое большинство разработчиков написали бы вручную.
<?php
function isValidEmail(string $email): bool
{
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
}
// Example usage
isValidEmail('[email protected]'); // true
isValidEmail('not-an-email'); // false
Другие подборки сниппетов
Контактная информация
Оставьте заявку или напишите напрямую в телеграм. Я отвечу в течение 24 часов.