Перейти к содержанию

Пути и доступ к файлам

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

Результат урока — маршрут по серверному ID вложения. Он сначала устанавливает пользователя, затем выбирает разрешённую запись и только после этого обращается к файлу. Браузер не передаёт путь на диске.

Запись и размещение

Таблица uploads содержит id, owner_id и storage_name. ID является публичным обозначением объекта, owner_id связывает его с пользователем, а storage_name определяет внутреннее размещение. В нашем примере два последних имени похожи, но имеют разное назначение.

CREATE TABLE uploads (
    id TEXT PRIMARY KEY,
    owner_id INTEGER NOT NULL REFERENCES users(id),
    storage_name TEXT NOT NULL UNIQUE,
    created_at TEXT NOT NULL
);

Исходный filename не хранится как путь. Приложение не обещает восстановить название файла компьютера пользователя. Это уменьшает ненужный ввод и помогает сохранить небольшой договор функции: открыть собственное подготовленное изображение.

Папка instance/private_media находится вне static. Сервер Flask не должен выдавать её как публичное дерево. Доступ через отдельный обработчик позволяет применить то же правило владельца, что и к заметке.

Контролируемый маршрут

Ниже содержательная часть заключительного приложения. ID проверяется по формату UUID без дефисов; значение является параметром SQL, а имя файла берётся из серверной записи.

import re
from flask import send_from_directory

@app.get("/media/<file_id>")
def media(file_id):
    user = require_user()
    if not re.fullmatch(r"[a-f0-9]{32}", file_id):
        abort(404)
    item = db().execute(
        "SELECT storage_name FROM uploads WHERE id = ? AND owner_id = ?",
        (file_id, user["id"]),
    ).fetchone()
    if item is None:
        abort(404)
    name = item["storage_name"]
    if not re.fullmatch(r"[a-f0-9]{32}\.png", name):
        abort(404)
    return send_from_directory(
        PRIVATE_MEDIA, name, mimetype="image/png"
    )

Проверка имени в базе является дополнительным подтверждением договора хранения. Она не заменяет полномочие, поэтому owner_id уже участвует в запросе. Данные могли быть импортированы или повреждены; обработчик не обязан безусловно принимать любой внутренний путь.

Send_from_directory получает заранее заданную папку и относительное серверное имя. Не заменяйте его прямым open с request.args["path"]. Для безопасного соединения директорий и имён Werkzeug предоставляет свой договор; он описан в справочнике утилит.

Почему путь является отдельной границей

Строка с ../ имеет навигационное значение для файловой системы. Удаление одной последовательности плохо описывает все представления пути и особенности платформ. Если продукту нужен объект по ID, лучше вообще не принимать файловый адрес.

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

Серверная папка тоже требует прав на диске. Если другой процесс может подменять файлы и создавать символические ссылки внутри неё, предпосылка о контролируемом хранилище нарушена. HTTP-helper не устраняет уже полученный посторонний доступ к файловой системе.

Доступ Alice и Boris

После загрузки Alice появляется ID её вложения. При открытии этого адреса с её действующей сессией ожидается PNG. При такой же строке адреса и сессии Boris ожидается 404 без содержимого.

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

Анонимный запрос сначала требует входа. Не нужно делать изображения общедоступными только для упрощения HTML-тега img: браузер владельца отправит подходящую cookie к тому же origin. Если появилась настоящая функция публичного обмена, это будет отдельный вид объекта и полномочия.

Для отсутствующего файла с существующей записью send_from_directory вернёт отказ. Такое состояние свидетельствует о рассогласованности хранения, а не о том, что надо открыть произвольный запасной путь. Сопровождение должно сверить запись с данными и восстановлением.

Тип и кеш

Маршрут явно возвращает image/png. Содержимое подготовлено предыдущим уроком, а заголовок nosniff ограничивает изменение трактовки браузером. Эти условия работают вместе: объявление MIME без проверки байтов было бы недостаточным.

Личное изображение получает общий private, no-store. Публичный CDN не должен сохранять его как ресурс, доступный всем. Нельзя переносить сюда настройку долгого общего кеша статических CSS только потому, что оба ответа относятся к файлам.

Но уже открытая картинка остаётся доступной глазам пользователя. Сервер не может отозвать сделанный снимок или скопированные байты. Авторизация ограничивает будущие обращения, а не делает раскрытое содержание физически исчезающим.

Варианты проверки

Проверьте корректный ID владельца, тот же ID другого пользователя, неизвестный допустимый ID и строку неподходящего формата. Для каждого отказа важно отсутствие содержимого. Успешный ответ должен совпадать с нормализованным изображением, а не с произвольным исходником.

Отдельно проверьте прямой путь к instance. В нормальной конфигурации он не является публичным маршрутом. Если внешний сервер публикует весь корень проекта, право в media не спасёт: появился другой путь к тем же файлам.

Это объясняет связь приложения и деплоя. Веб-корень, права папки и контролируемый маршрут должны выражать одну модель. Код приложения нельзя оценивать отдельно от всех способов выдачи его хранилища.

Другой путь к тем же данным

Представим, что приложение правильно закрывает media, но внешний сервер настроен на корень всего проекта. Тогда запрос к instance может обойти Python. Для проверки размещения нужна карта всех публичных каталогов, а не только список функций app.py.

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

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

Для удаления файла понадобится отдельная операция с владельцем и согласованной записью. Не добавляйте параметр delete к GET-чтению изображения: это изменит безопасный метод и создаст новое действие без привычной проверки формы.

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

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