Загрузка файла с формы - приём, проверки, прогресс, хранение
Принимаем файл от посетителя: отправка с показом хода загрузки, проверки на стороне сервера, безопасное сохранение и чистка незавершённых загрузок.
Что нужно знать заранее
Проверки на стороне браузера не гарантируют вообще ничего и защитой не считаются. Запрос легко повторить любым инструментом, поэтому размер, тип и содержимое файла проверяются на сервере заново.
Файл больше предела приёма на сервере не доезжает до кода вовсе. Запрос приходит пустым, без единого сообщения об ошибке, и выглядит это как молчаливо не сработавшая форма.
Имя файла, присланное посетителем, совершенно не годится как имя для диска. Его чистят или заменяют, иначе в имени приезжают пути, кавычки и расширения, которых на сайте быть не должно.
Шаги
- Отправить файл отдельным запросом и показать посетителю ход этой загрузки.
- Проверить в контроллере ключ сеанса до любой работы с файлом.
- Проверить размер файла, расширение по белому списку и содержимое самой картинки.
- Сохранить файл штатными средствами платформы и связать его с нужной записью.
- Чистить по расписанию те файлы, которые так и не привязались ни к одной записи.
Решение
Отправляем файл с показом хода загрузки:
const data = new FormData();data.append('file', input.files[0]);data.append('sessid', BX.bitrix_sessid());const xhr = new XMLHttpRequest();xhr.upload.onprogress = (e) => bar.style.width = (e.loaded / e.total * 100) + '%';xhr.open('POST', '/local/ajax/upload.php');xhr.send(data);Показ хода загрузки нужен уже на файлах в несколько мегабайт. Без него посетитель считает форму сломанной и отправляет её второй и третий раз.
Принимаем файл в контроллере:
$request = \Bitrix\Main\Context::getCurrent()->getRequest();if (!check_bitrix_sessid()) { die(json_encode(['error' => 'нет ключа сеанса'])); }$file = $request->getFile('file');if (!$file || $file['error'] !== UPLOAD_ERR_OK) { die(json_encode(['error' => 'файл не получен: ' . ($file['error'] ?? 'пусто')]));}Код ошибки загрузки говорит о причине больше, чем любые догадки. Он отличает превышение предела от прерванной передачи, и его стоит писать в ответ хотя бы на время отладки формы.
Проверяем файл на сервере:
$allowed = ['jpg', 'jpeg', 'png', 'pdf'];$ext = mb_strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));if (!in_array($ext, $allowed, true) || $file['size'] > 5 * 1024 * 1024) { die(json_encode(['error' => 'недопустимый файл']));}if (in_array($ext, ['jpg', 'jpeg', 'png'], true) && \CFile::CheckImageFile($file) !== null) { die(json_encode(['error' => 'это не картинка'])); // проверка содержимого}Белый список разрешённых расширений заметно безопаснее чёрного списка запретов. Список разрешённого короткий и понятный, а перечислить все опасные расширения не получится: они появляются быстрее, чем обновляется код.
Сохраняем файл и связываем с записью:
$fileId = \CFile::SaveFile($file, 'vendor/requests'); // каталог внутри загрузокif (!$fileId) { die(json_encode(['error' => 'не удалось сохранить'])); }RequestTable::update($requestId, ['FILE_ID' => $fileId]);echo json_encode(['ok' => true, 'file' => \CFile::GetPath($fileId)]);Сохранение средствами платформы даёт файлу своё имя и запись в таблице файлов. Дальше он удаляется, копируется и отдаётся штатными средствами, а не поиском по каталогу загрузок.
Чистим неприкаянные файлы:
$rs = \Bitrix\Main\FileTable::getList(['filter' => ['=MODULE_ID' => 'vendor', '<TIMESTAMP_X' => (new \Bitrix\Main\Type\DateTime())->add('-2 days')], 'select' => ['ID'], 'limit' => 200])->fetchAll();foreach ($rs as $row) { \CFile::Delete($row['ID']); } // только не привязанныеТипичные проблемы
Большой файл не загружается и ошибки нет.
Размер файла превышает предел приёма на сервере, и запрос приходит пустым. Пределы приёма поднимают в настройках PHP и веб-сервера согласованно между собой.
На сервер попал исполняемый файл.
Расширение файла проверялось по чёрному списку запретов или не проверялось вовсе. Разрешают короткий белый список расширений и дополнительно проверяют содержимое картинок.
Форма отправляется со стороннего сайта.
Обработчик приёма файла не проверяет ключ сеанса перед его сохранением. Проверку ключа сеанса делают самой первой строкой обработчика загрузки файла.
В каталоге загрузок копятся файлы без записей.
Посетители бросают форму после выбора файла, а привязка так и не происходит. Неприкаянные файлы удаляют по расписанию через день или два после загрузки.
Имя файла на диске содержит путь и лишние символы.
Имя файла взято из запроса как есть и подставлено прямо в путь сохранения. Имя для диска генерируют сами, а исходное хранят отдельным полем записи.
Частые вопросы
Где проверять размер файла?
На сервере обязательно, в браузере - дополнительно ради удобства. Браузерная проверка экономит трафик, но защитой не является.
Почему пустой запрос при большом файле?
Файл отсекается на уровне сервера до выполнения кода. Помогает согласованная правка пределов приёма и понятное сообщение при пустом запросе.
Как проверить, что картинка настоящая?
Штатной проверкой изображений: она смотрит содержимое, а не имя. Переименованный исполняемый файл такую проверку не проходит.
Хранить файлы в загрузках или вне корня?
Публичные - в загрузках средствами платформы, закрытые - вне корня с отдачей через код. Второй способ нужен для документов, доступных не всем.
Нужен ли отдельный контроллер для загрузки?
Да, отдельное действие проще проверять и ограничивать по частоте. Заодно оно не мешает остальной обработке формы.
Смежное
- AJAX и контроллеры - оглавление подтемы
- Отправка формы через AJAX: проверка полей, защита, ответ с ошибками - остальные поля той же формы
- AJAX-запрос: контроллер, свой файл и ответ в JSON - устройство приёмной точки
- Файлы из кода: сохранение, уменьшенные копии, удаление - работа с сохранёнными файлами
- Файл не загружается: пределы, права, типы, веб-сервер - разбор, когда файл не доезжает
- Отдача файла с проверкой прав: закрытые каталоги, заголовки, ссылки - закрытые файлы вне публичного корня
- Данные от посетителя: экранирование, проверка, сеанс - почему проверяют на сервере
- JS-ядро и интерфейсы - что доступно на стороне браузера