Загрузка аватара обычно начинается с одного поля формы и вызова move_uploaded_file. Ошибка становится заметна позже: каталог uploads оказывается доступен из веб-корня, имя файла совпадает с уже существующим, а проверка сводится к .jpg. В итоге сервер принимает решение по данным, которые прислал браузер. Давайте соберём минимальный маршрут, где каждое такое решение видно в коде.
Вопрос этой заметки один: как принять только JPEG и PNG для аватара, не превращая имя и MIME-тип из формы в правило безопасности? Пример рассчитан на PHP 7.2. Он не заменяет антивирус и не умеет обрабатывать документы; его задача уже — дать узкий и проверяемый вход для изображения. Цена ошибки — файл в веб-корне, который можно открыть или выполнить не по назначению.
Сначала договоримся о результате
Форма передаёт один файл avatar. Мы принимаем не более 2 МБ, только image/jpeg и image/png, а затем ограничиваем ширину и высоту. В базе или профиле хранится ключ, который придумало приложение, например 7f4a...c2.png. Исходное имя можно показать пользователю после отдельной обработки, но оно не участвует в пути на диске.
| Проверка | Что она отвечает | Что делаем при отказе |
|---|---|---|
UPLOAD_ERR_OK | PHP полностью принял часть запроса | Не читаем временный путь, показываем понятную ошибку загрузки |
| Лимит 2 МБ | Файл укладывается в договор аватара | Не переносим файл и не пытаемся уменьшать его вслепую |
finfo_file | Какой MIME-тип определён по временному файлу | Отклоняем тип, которого нет в белом списке |
| Размеры изображения | Подходит ли картинка для интерфейса | Отклоняем слишком маленькое или слишком большое изображение |
| Сгенерированный ключ | Куда именно будет записан файл | Никогда не составляем путь из исходного имени |
Обработчик без скрытого шага
Проверка $_FILES["avatar"]["error"] должна идти первой. PHP кладёт в это поле код доставки: если загрузка не завершилась, временный файл нельзя считать нормальным входом. Затем я сравниваю размер и запускаю Fileinfo для временного файла. Поле type из $_FILES здесь намеренно не используется: его прислал клиент.
<?php
function storeAvatar(array $file, string $privateDir): array
{
if (!isset($file['error'], $file['tmp_name'], $file['size'])) {
throw new RuntimeException('Поле avatar передано в неверном формате');
}
if ($file['error'] !== UPLOAD_ERR_OK) {
throw new RuntimeException('PHP не принял файл: код ' . $file['error']);
}
$maxBytes = 2 * 1024 * 1024;
if ((int)$file['size'] > $maxBytes) {
throw new RuntimeException('Аватар больше 2 МБ');
}
$finfo = finfo_open(FILEINFO_MIME_TYPE);
if ($finfo === false) {
throw new RuntimeException('Расширение Fileinfo недоступно');
}
$mime = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
$allowed = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
];
if (!is_string($mime) || !isset($allowed[$mime])) {
throw new RuntimeException('Нужен JPEG или PNG');
}
$size = getimagesize($file['tmp_name']);
if ($size === false) {
throw new RuntimeException('Не удалось прочитать размеры изображения');
}
list($width, $height) = $size;
if ($width < 64 || $height < 64 || $width > 3000 || $height > 3000) {
throw new RuntimeException('Размеры изображения вне допустимого диапазона');
}
$storageKey = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];
$target = rtrim($privateDir, DIRECTORY_SEPARATOR)
. DIRECTORY_SEPARATOR . $storageKey;
if (!move_uploaded_file($file['tmp_name'], $target)) {
throw new RuntimeException('Не удалось сохранить аватар');
}
return [
'storageKey' => $storageKey,
'mime' => $mime,
'width' => $width,
'height' => $height,
];
}
Почему порядок проверок важнее набора функций
У move_uploaded_file есть собственная проверка: исходный путь должен быть файлом, пришедшим через HTTP POST. Это полезная граница, но она не говорит, что перед нами именно изображение для аватара. Поэтому перенос стоит последним. До него мы принимаем решение по коду ошибки, размеру, серверному определению MIME-типа и проектным размерам.
Вызов getimagesize нужен здесь только для размеров. В документации PHP отдельно сказано не использовать его как проверку того, что файл является корректным изображением; для определения типа подходит Fileinfo. Это хороший пример узкой ответственности: одна функция отвечает за признаки файла, другая — за параметры картинки, а не за всё сразу.
Минимальная форма и проверка руками
<form method="post" enctype="multipart/form-data" action="/profile/avatar.php">
<input type="file" name="avatar" accept="image/jpeg,image/png" required>
<button type="submit">Сохранить аватар</button>
</form>
Атрибут accept помогает интерфейсу, но не заменяет серверную проверку. После подключения обработчика я бы не ограничивался одним удачным JPEG. Нужны четыре коротких сценария: нормальный JPEG, PNG, текстовый файл с расширением .jpg и картинка больше лимита. Для каждого фиксируем HTTP-ответ, наличие или отсутствие файла в хранилище и запись ключа в профиле.
Порядок запуска
- Создать отдельный каталог для файлов за пределами веб-корня и дать PHP права только на нужную операцию записи.
- Подключить форму с
multipart/form-dataи передать$_FILES["avatar"]в функцию. - После успешного вызова сохранить только
storageKey, MIME-тип и размеры рядом с пользователем. - Проверить отрицательные сценарии: при любой ошибке ни файл, ни ссылка на него не должны появиться в профиле.
- Отдельно решить, как читать аватар пользователю: прямой URL подходит лишь для действительно публичной картинки.
Граница этого примера
Код не сканирует файл на вредоносное содержимое и не защищает форму от CSRF. Он также не делает миниатюры: если добавить внешний конвертер, появится отдельная граница с лимитами, тайм-аутами и обновлением библиотек. Для аватаров я бы сначала запустил ровно этот узкий маршрут, измерил ошибки и только потом усложнял обработку.