DarkRiDDeR9 мин

PHP. Как отдать приватный файл владельцу и не сделать uploads публичной папкой

PHPБезопасность

Симптом: личный документ открывается по прямому URL из /uploads без повторной проверки пользователя. Цена ошибки — ссылка становится фактическим правом доступа и может раскрыть файл не тому человеку. Файл можно проверить при загрузке и всё равно потерять контроль над ним при выдаче. Типичный путь выглядит так: пользователь прикрепил документ, приложение положило его в /uploads, а ссылка стала чем-то вроде /uploads/ivan-passport.pdf. Теперь имя файла одновременно является адресом и фактически проверкой доступа. Для личного документа это слишком много ответственности у одной строки.

Здесь разбираю один вопрос: как дать владельцу скачать приватный PDF, если сам файл лежит вне веб-корня? Это небольшой PHP 7.2-пример для внутренних документов. Он не пытается строить файловый сервис, а показывает границу: маршрут приложения решает доступ, файловая система хранит байты.

У файла должны быть две разные сущности

Пользовательский документ имеет понятное имя — «счёт за март.pdf». Хранилищу оно не нужно. Ему нужен стабильный ключ, который создаёт приложение: например, 32 шестнадцатеричных символа с расширением .pdf. В базе связываем ключ с владельцем и типом. HTTP-маршрут принимает только числовой ID записи, ищет её вместе с владельцем и уже потом открывает путь.

Схема приватной выдачи: запрос к маршруту проходит авторизацию, запись в базе связывает владельца с ключом, PHP читает файл из закрытого каталога и отправляет ответ.
Прямой путь к файлу не выдаётся браузеру. Авторизация остаётся до чтения с диска.
СлойЧто в нём хранимЧего в нём нет
Таблица documentsid, owner_id, storage_key, статусПубличного URL и пути, собранного из имени пользователя
Закрытый каталогФайл по ключу, созданному приложениемОригинального имени и логики авторизации
Маршрут /documents/{id}/downloadПроверку текущего пользователя и HTTP-ответСвободного параметра path из запроса
БраузерСодержимое файла после успешного ответаСведений о расположении файла на сервере

Небольшой обработчик PDF

Для ясности пример обслуживает только PDF. MIME-тип в ответе задан кодом, а не переписан из имени или запроса. Имя в Content-Disposition тоже фиксировано: задача заметки — доступ, а не универсальная передача пользовательских названий через заголовок. В реальном интерфейсе красивое имя можно хранить отдельно и добавлять в заголовок только после нормализации.

<?php

function sendPrivatePdf(PDO $pdo, int $documentId, int $currentUserId): void
{
    $query = $pdo->prepare(
        'SELECT storage_key
         FROM documents
         WHERE id = :id AND owner_id = :owner_id AND status = :status'
    );
    $query->execute([
        ':id' => $documentId,
        ':owner_id' => $currentUserId,
        ':status' => 'ready',
    ]);
    $document = $query->fetch(PDO::FETCH_ASSOC);

    if (!$document) {
        http_response_code(404);
        exit;
    }

    $key = (string)$document['storage_key'];
    if (!preg_match('/\\A[a-f0-9]{32}\\.pdf\\z/', $key)) {
        error_log('Некорректный ключ документа ' . $documentId);
        http_response_code(404);
        exit;
    }

    $path = '/var/app/private-uploads/' . $key;
    if (!is_file($path)) {
        error_log('Не найден файл для документа ' . $documentId);
        http_response_code(404);
        exit;
    }

    header('Content-Type: application/pdf');
    header('Content-Disposition: attachment; filename="document.pdf"');
    header('Content-Length: ' . filesize($path));

    readfile($path);
    exit;
}

SQL-запрос проверяет владельца вместе с ID документа. Поэтому путь на диске не зависит от значения из URL. Регулярное выражение кажется избыточным, но оно защищает код от испорченной записи в базе и фиксирует контракт ключа рядом с местом, где ключ превращается в путь. Если запись чужая или отсутствует, пример отвечает одинаковым 404; это решение уменьшает различие ответов, но журналировать такие случаи всё равно полезно.

Как воспроизвести проверку

На тестовой базе достаточно двух пользователей: Анны и Бориса. Создаём запись документа Анны со статусом ready и кладём тестовый PDF с соответствующим ключом в закрытый каталог. Затем повторяем одни и те же действия из двух сессий. Здесь важен не красивый экран, а наблюдаемые HTTP-ответы и отсутствие прямой ссылки на каталог.

  1. Анна запрашивает /documents/42/download: получает 200, заголовок Content-Type: application/pdf и байты тестового файла.
  2. Борис запрашивает тот же URL: получает 404, а тело файла не попадает в ответ.
  3. Запрос к предполагаемому пути /uploads/<storage_key> не должен находить файл, потому что каталог не лежит в веб-корне.
  4. Удаляем файл на диске при сохранённой записи: получаем 404 и запись в серверном журнале без абсолютного пути в ответе пользователю.
  5. Пробуем передать в URL похожий ID или строку вместо числа: роутер должен отклонить запрос до вызова функции.

Что будет, если оставить прямую ссылку

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

Ограничения этого решения

У readfile простая задача — отдать содержимое файла в ответ. В примере нет поддержки диапазонов, кеширования, ограничения частоты загрузок и фоновой выдачи больших файлов. Для небольших PDF это хорошая стартовая точка. Для видео, больших архивов или заметного трафика потребуется передать доставку веб-серверу или файловому хранилищу, но проверку доступа и сопоставление ID с ключом нельзя потерять по дороге.

Загрузка и выдача связаны, но не должны быть одной функцией. При загрузке приложение выбирает допустимый формат и ключ; при выдаче — проверяет владельца и формирует HTTP-ответ до любого вывода. PHP Manual отдельно напоминает, что header() вызывается до отправки тела ответа; поэтому в обработчике не должно быть случайного HTML или отладочного echo раньше заголовков.

Проверяемые источники