{ "index": 352, "slug": "editorial-2018-03-field-safe-uploads", "title": "PHP. Как отдать приватный файл владельцу и не сделать uploads публичной папкой", "excerpt": "Разбираем контролируемую выдачу документа: путь хранится вне веб-корня, доступ проверяется по записи в базе, а браузер получает содержимое только после авторизации.", "contentHtml": "
Симптом: личный документ открывается по прямому URL из /uploads без повторной проверки пользователя. Цена ошибки — ссылка становится фактическим правом доступа и может раскрыть файл не тому человеку. Файл можно проверить при загрузке и всё равно потерять контроль над ним при выдаче. Типичный путь выглядит так: пользователь прикрепил документ, приложение положило его в /uploads, а ссылка стала чем-то вроде /uploads/ivan-passport.pdf. Теперь имя файла одновременно является адресом и фактически проверкой доступа. Для личного документа это слишком много ответственности у одной строки.
Здесь разбираю один вопрос: как дать владельцу скачать приватный PDF, если сам файл лежит вне веб-корня? Это небольшой PHP 7.2-пример для внутренних документов. Он не пытается строить файловый сервис, а показывает границу: маршрут приложения решает доступ, файловая система хранит байты.
\nПользовательский документ имеет понятное имя — «счёт за март.pdf». Хранилищу оно не нужно. Ему нужен стабильный ключ, который создаёт приложение: например, 32 шестнадцатеричных символа с расширением .pdf. В базе связываем ключ с владельцем и типом. HTTP-маршрут принимает только числовой ID записи, ищет её вместе с владельцем и уже потом открывает путь.
| Слой | Что в нём храним | Чего в нём нет |
|---|---|---|
Таблица documents | id, owner_id, storage_key, статус | Публичного URL и пути, собранного из имени пользователя |
| Закрытый каталог | Файл по ключу, созданному приложением | Оригинального имени и логики авторизации |
Маршрут /documents/{id}/download | Проверку текущего пользователя и HTTP-ответ | Свободного параметра path из запроса |
| Браузер | Содержимое файла после успешного ответа | Сведений о расположении файла на сервере |
Для ясности пример обслуживает только PDF. MIME-тип в ответе задан кодом, а не переписан из имени или запроса. Имя в Content-Disposition тоже фиксировано: задача заметки — доступ, а не универсальная передача пользовательских названий через заголовок. В реальном интерфейсе красивое имя можно хранить отдельно и добавлять в заголовок только после нормализации.
<?php\n\nfunction sendPrivatePdf(PDO $pdo, int $documentId, int $currentUserId): void\n{\n $query = $pdo->prepare(\n 'SELECT storage_key\n FROM documents\n WHERE id = :id AND owner_id = :owner_id AND status = :status'\n );\n $query->execute([\n ':id' => $documentId,\n ':owner_id' => $currentUserId,\n ':status' => 'ready',\n ]);\n $document = $query->fetch(PDO::FETCH_ASSOC);\n\n if (!$document) {\n http_response_code(404);\n exit;\n }\n\n $key = (string)$document['storage_key'];\n if (!preg_match('/\\\\A[a-f0-9]{32}\\\\.pdf\\\\z/', $key)) {\n error_log('Некорректный ключ документа ' . $documentId);\n http_response_code(404);\n exit;\n }\n\n $path = '/var/app/private-uploads/' . $key;\n if (!is_file($path)) {\n error_log('Не найден файл для документа ' . $documentId);\n http_response_code(404);\n exit;\n }\n\n header('Content-Type: application/pdf');\n header('Content-Disposition: attachment; filename="document.pdf"');\n header('Content-Length: ' . filesize($path));\n\n readfile($path);\n exit;\n}\nSQL-запрос проверяет владельца вместе с ID документа. Поэтому путь на диске не зависит от значения из URL. Регулярное выражение кажется избыточным, но оно защищает код от испорченной записи в базе и фиксирует контракт ключа рядом с местом, где ключ превращается в путь. Если запись чужая или отсутствует, пример отвечает одинаковым 404; это решение уменьшает различие ответов, но журналировать такие случаи всё равно полезно.
На тестовой базе достаточно двух пользователей: Анны и Бориса. Создаём запись документа Анны со статусом ready и кладём тестовый PDF с соответствующим ключом в закрытый каталог. Затем повторяем одни и те же действия из двух сессий. Здесь важен не красивый экран, а наблюдаемые HTTP-ответы и отсутствие прямой ссылки на каталог.
/documents/42/download: получает 200, заголовок Content-Type: application/pdf и байты тестового файла.404, а тело файла не попадает в ответ./uploads/<storage_key> не должен находить файл, потому что каталог не лежит в веб-корне.404 и запись в серверном журнале без абсолютного пути в ответе пользователю.Для публичной картинки прямой URL может быть нормальным контрактом. Для чека, договора или личного вложения он смешивает хранение с авторизацией: проверка пользователя происходит один раз при создании ссылки, а дальше файл живёт по адресу сам по себе. Закрытый каталог и маршрут не делают систему неуязвимой, зато возвращают проверку доступа в приложение, где есть пользователь, роль, статус документа и журнал.
\nУ readfile простая задача — отдать содержимое файла в ответ. В примере нет поддержки диапазонов, кеширования, ограничения частоты загрузок и фоновой выдачи больших файлов. Для небольших PDF это хорошая стартовая точка. Для видео, больших архивов или заметного трафика потребуется передать доставку веб-серверу или файловому хранилищу, но проверку доступа и сопоставление ID с ключом нельзя потерять по дороге.
Загрузка и выдача связаны, но не должны быть одной функцией. При загрузке приложение выбирает допустимый формат и ключ; при выдаче — проверяет владельца и формирует HTTP-ответ до любого вывода. PHP Manual отдельно напоминает, что header() вызывается до отправки тела ответа; поэтому в обработчике не должно быть случайного HTML или отладочного echo раньше заголовков.