Yandex Metrika
sanches.free 1 просмотр

Отдача файла на скачивание в PHP: заголовки, readfile и безопасный путь

Зачем отдельный скрипт

В проекте на «1С‑Битрикс» часто нужно отдать счёт, выгрузку или вложение так, чтобы вкладка не открыла PDF «как страницу», а предложила сохранить файл. Для этого достаточно пары заголовков и потокового чтения с диска; важнее не ошибиться с путём и не превратить обработчик в «лазейку» по каталогам сервера.

Чего не делать

Учебные примеры иногда склеивают реальный путь из $_GET и корня скрипта. На бою параметр запроса нельзя считать частью пути без жёсткого ограничения: достаточно цепочки ../, чтобы выйти за пределы выбранной папки. Надёжнее хранить соответствие «символический ключ → канонический путь» в коде или в базе и проверять файл через realpath относительно разрешённого каталога хранения.

Минимально безопасная схема

Ниже — вымышленный каталог /var/app/secure_drop/ вне публичного document_root и белый список логических имён. Имя для диалога сохранения задаётся отдельно, расширение берётся из реального файла, а не из ввода пользователя.

declare(strict_types=1);

$trustedRoot = realpath('/var/app/secure_drop');
if ($trustedRoot === false) {
    header('HTTP/1.1 500 Internal Server Error', true);
    exit;
}

$logicalKey = isset($_GET['asset']) ? (string) $_GET['asset'] : '';
$allowed = [
    'price_pdf' => 'export/invoice_phase17.pdf',
    'stock_csv' => 'reports/stock_take_snapshot.csv',
];

if (!isset($allowed[$logicalKey])) {
    header('HTTP/1.1 404 Not Found', true, 404);
    exit;
}

$candidate = realpath($trustedRoot . '/' . $allowed[$logicalKey]);
if ($candidate === false || strncmp($candidate, $trustedRoot, strlen($trustedRoot)) !== 0) {
    header('HTTP/1.1 404 Not Found', true, 404);
    exit;
}

$downloadLabel = isset($_GET['label']) ? basename((string) $_GET['label']) : basename($candidate);
$fileSize = filesize($candidate);
if ($fileSize === false) {
    header('HTTP/1.1 500 Internal Server Error', true);
    exit;
}

$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($candidate) ?: 'application/octet-stream';

header('Content-Description: File Transfer');
header('Content-Type: ' . $mime);
header('Content-Disposition: attachment; filename="' . str_replace('"', '', $downloadLabel) . '"');
header('Expires: 0');
header('Cache-Control: must-revalidate, private');
header('Pragma: public');
header('Content-Length: ' . (string) $fileSize);

readfile($candidate);

Служба статистики и буфер

Если скрипт выполняется уже после того, как ядро или компонент успели что‑то вывести, клиент получит повреждённый файл. В сценариях внутри Битрикс при «поздней» отдаче имеет смысл остановить буферизацию так, как требует ваш шаблон: например, $APPLICATION->RestartBuffer() и сброс уровней ob_* до чистого вывода. Для крупных объектов можно рассмотреть потоковую отдачу частями, но readfile уже использует внутренние чанки и в большинстве задач достаточен.

ЧПУ и веб‑сервер

Красивый URL вида /export/offer обычно сводят к реальному входному скрипту через правила переписывания. Главное — чтобы запрос всё равно попадал в контролируемый PHP‑обработчик, а не открывал статику из неожиданного каталога.

Кратко

  • Путь к файлу на диске строите только из доверенных данных и проверяйте границу каталога через realpath.
  • Имя в диалоге сохранения нормализуйте (basename, безопасные символы); расширение берите из факта на диске.
  • Задайте тип, длину тела и Content-Disposition: attachment, чтобы браузер не «угадал» превью.
  • Следите, чтобы до бинарного тела не уехал HTML окружения.

Не хотите копаться сами?

Починю за 1-3 дня. Без предоплаты — оплата по результату.

15+ лет опыта с 1С-Битрикс · Без предоплаты · 7 дней гарантии