Перейти к содержимому

Загрузка файла с формы - приём, проверки, прогресс, хранение

Принимаем файл от посетителя: отправка с показом хода загрузки, проверки на стороне сервера, безопасное сохранение и чистка незавершённых загрузок.

Что нужно знать заранее

Проверки на стороне браузера не гарантируют вообще ничего и защитой не считаются. Запрос легко повторить любым инструментом, поэтому размер, тип и содержимое файла проверяются на сервере заново.

Файл больше предела приёма на сервере не доезжает до кода вовсе. Запрос приходит пустым, без единого сообщения об ошибке, и выглядит это как молчаливо не сработавшая форма.

Имя файла, присланное посетителем, совершенно не годится как имя для диска. Его чистят или заменяют, иначе в имени приезжают пути, кавычки и расширения, которых на сайте быть не должно.

Шаги

  1. Отправить файл отдельным запросом и показать посетителю ход этой загрузки.
  2. Проверить в контроллере ключ сеанса до любой работы с файлом.
  3. Проверить размер файла, расширение по белому списку и содержимое самой картинки.
  4. Сохранить файл штатными средствами платформы и связать его с нужной записью.
  5. Чистить по расписанию те файлы, которые так и не привязались ни к одной записи.

Решение

Отправляем файл с показом хода загрузки:

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 и веб-сервера согласованно между собой.

На сервер попал исполняемый файл.

Расширение файла проверялось по чёрному списку запретов или не проверялось вовсе. Разрешают короткий белый список расширений и дополнительно проверяют содержимое картинок.

Форма отправляется со стороннего сайта.

Обработчик приёма файла не проверяет ключ сеанса перед его сохранением. Проверку ключа сеанса делают самой первой строкой обработчика загрузки файла.

В каталоге загрузок копятся файлы без записей.

Посетители бросают форму после выбора файла, а привязка так и не происходит. Неприкаянные файлы удаляют по расписанию через день или два после загрузки.

Имя файла на диске содержит путь и лишние символы.

Имя файла взято из запроса как есть и подставлено прямо в путь сохранения. Имя для диска генерируют сами, а исходное хранят отдельным полем записи.

Частые вопросы

Где проверять размер файла?

На сервере обязательно, в браузере - дополнительно ради удобства. Браузерная проверка экономит трафик, но защитой не является.

Почему пустой запрос при большом файле?

Файл отсекается на уровне сервера до выполнения кода. Помогает согласованная правка пределов приёма и понятное сообщение при пустом запросе.

Как проверить, что картинка настоящая?

Штатной проверкой изображений: она смотрит содержимое, а не имя. Переименованный исполняемый файл такую проверку не проходит.

Хранить файлы в загрузках или вне корня?

Публичные - в загрузках средствами платформы, закрытые - вне корня с отдачей через код. Второй способ нужен для документов, доступных не всем.

Нужен ли отдельный контроллер для загрузки?

Да, отдельное действие проще проверять и ограничивать по частоте. Заодно оно не мешает остальной обработке формы.

Смежное

Первоисточник