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

Документы и файлы

Панель платформы хранит двоичные документы (PDF, офисные файлы, изображения, тексты) в S3-совместимом хранилище и выдаёт их с изоляцией по tenant'у и безопасными заголовками отдачи. Статья описывает хранилище, API документов, правила определения типа и отдачи содержимого, а также связь документов с артефактами Control Plane. Для администраторов и разработчиков интеграций.

Замороженный периметр

Документы — часть platform-api (профиль compose platform, заморожен: TAI-ADR-0034, TAI-ADR-0039).

Хранилище

В поставке compose хранилище — MinIO внутри docker-сети, наружу не публикуется:

Сервис Что делает
minio S3-сервер, данные в томе platform_minio, лимит памяти MINIO_MEM_LIMIT (256m)
minio-bootstrap одноразовый: mc mb --ignore-existing local/$S3_BUCKET

Конфигурация platform-api:

Переменная Значение в compose Назначение
S3_ENDPOINT_URL http://minio:9000 адрес S3 API
S3_BUCKET ${S3_BUCKET:-platform} бакет
S3_REGION us-east-1 регион (для MinIO формальный)
S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY из .env ключи доступа; в compose они же — root-учётка MinIO

Внешнее S3 вместо MinIO

platform-api работает с любым S3-совместимым хранилищем. Чтобы использовать внешнее, задайте S3_ENDPOINT_URL, S3_BUCKET, S3_REGION и ключи с правами на чтение, запись и удаление объектов бакета; сервисы minio и minio-bootstrap в этом случае не нужны. В compose root-ключи MinIO совпадают с ключами приложения — в промышленной инсталляции с MinIO заведите для platform-api отдельного пользователя с политикой на один бакет.

Раскладка ключей

Что Ключ объекта
Документ documents/{tenant_id}/{document_id}/{filename}
Аватар avatars/{user_id}/{uuid}.{ext}

Tenant и id в ключе — для удобства эксплуатации. Изоляция держится на колонке tenant_id в базе, а не на ключе: знание ключа не даёт доступа к объекту.

Модель документа

Таблица documents в базе platform:

Поле Смысл
id UUID документа
tenant_id tenant загрузившего
uploaded_by пользователь панели
key ключ объекта в S3 (уникален)
filename очищенное имя файла
content_type тип, определённый по байтам
size_bytes, sha256 размер и хеш содержимого
purpose назначение, по умолчанию document (консоль пишет task-attachment)
source произвольная метка источника (до 128 символов)
created_at, deleted_at время создания и мягкого удаления

API

Маршруты группы documents под /api/v1/documents (снаружи — https://platform.example.com/platform/api/v1/documents). Все требуют Bearer Keycloak; tenant берётся из токена.

Метод и путь Кто может Что делает
POST /api/v1/documents вошедший пользователь (session) загрузить документ (multipart/form-data)
GET /api/v1/documents любой участник tenant'а список документов tenant'а
GET /api/v1/documents/{id} любой участник tenant'а метаданные
GET /api/v1/documents/{id}/content любой участник tenant'а содержимое потоком
DELETE /api/v1/documents/{id} загрузивший или org_admin удалить

Документ другого tenant'а неотличим от несуществующего: 404 DOCUMENT_NOT_FOUND.

Загрузка

curl -s https://platform.example.com/platform/api/v1/documents \
  -H "Authorization: Bearer $KC_TOKEN" \
  -F "file=@Спецификация.pdf" \
  -F "purpose=task-attachment" \
  -F "source=console"
{
  "id": "6f1c…",
  "tenant_id": "<tenant-id>",
  "uploaded_by": "<user-id>",
  "filename": "Спецификация.pdf",
  "content_type": "application/pdf",
  "size_bytes": 482133,
  "sha256": "9b2f…",
  "purpose": "task-attachment",
  "source": "console",
  "created_at": "2026-01-15T10:12:03Z",
  "content_path": "/api/v1/documents/6f1c…/content"
}

Правила загрузки:

Правило Поведение
Максимальный размер 50 МиБ; проверяется по мере чтения (чанками по 1 МиБ), превышение — 422 FILE_TOO_LARGE без дочитывания
Пустой файл 422 FILE_EMPTY
Имя файла берётся последний сегмент пути, всё кроме букв любого алфавита, цифр, _, . и - заменяется на _, длина до 200; пустое имя — document
purpose до 64 символов, очищается так же, как имя
Tenant tenant scope должен совпадать с tenant'ом session — иначе 403 TENANT_SCOPE_MISMATCH

Кириллица в именах сохраняется: «Извещение №1.pdf» станет Извещение_1.pdf.

Определение типа

content_type определяется по сигнатуре байтов (sniff_mime), а не по заголовку клиента — заявленный Content-Type не принимается на веру:

Что Как распознаётся
PDF, PNG, JPEG, GIF, WebP, архивы, OLE по magic bytes
DOCX, XLSX, PPTX ZIP с [Content_Types].xml, тип по его содержимому
текстовые форматы UTF-8-текст, уточняется расширением: csv, tsv, json, yaml/yml, xml, html/htm, svg, log, txt, md/markdown/rst
прочее application/octet-stream

Список

curl -s "https://platform.example.com/platform/api/v1/documents?limit=50&offset=0&purpose=task-attachment" \
  -H "Authorization: Bearer $KC_TOKEN" | jq '{total, ids: [.items[].id]}'

Параметры: limit 1–200 (по умолчанию 50), offset, purpose. Сортировка — по created_at и id по убыванию. Удалённые документы не показываются.

Отдача содержимого

curl -s -D - -o out.bin \
  "https://platform.example.com/platform/api/v1/documents/$DOC/content?download=true" \
  -H "Authorization: Bearer $KC_TOKEN"

Ответ отдаётся потоком из хранилища со следующими заголовками:

Заголовок Значение
Content-Type тип, определённый при загрузке
Content-Disposition inline или attachment; filename="<ASCII-вариант>"; filename*=UTF-8''<имя>
Content-Length размер объекта
ETag "<sha256>"
Cache-Control private, max-age=300
X-Content-Type-Options nosniff

inline разрешён только для белого списка типов:

Inline Всегда attachment
application/pdf, image/png, image/jpeg, image/gif, image/webp, text/plain, text/markdown, text/csv, application/json всё остальное, в том числе HTML и SVG

Почему HTML и SVG не показываются inline

Файл из хранилища отдаётся с домена платформы. HTML или SVG, открытый inline, исполнил бы скрипты в origin'е консоли — это XSS с доступом к сессии пользователя. Поэтому такие типы всегда уходят как attachment, nosniff запрещает браузеру переопределять тип, а просмотрщик консоли показывает HTML/SVG только как исходный текст.

Параметр ?download=true принудительно даёт attachment для любого типа.

Удаление

curl -s -X DELETE https://platform.example.com/platform/api/v1/documents/$DOC \
  -H "Authorization: Bearer $KC_TOKEN"
# {"id": "6f1c…"}

Порядок удаления выбран так, чтобы сбой не оставлял «висящий» доступ:

  1. Строка помечается удалённой (deleted_at) — документ сразу исчезает из API.
  2. Объект удаляется из S3 по принципу best effort: ошибка пишется в журнал (document_s3_delete_failed), но ответ всё равно успешный.

Чужой документ может удалить только org_admin; иначе — 403 DOCUMENT_FORBIDDEN.

Файлы и аватары

Группа files обслуживает аватары пользователей:

Метод и путь Что делает
POST /api/v1/users/me/avatar загрузить аватар: JPEG, PNG, WebP или GIF по заявленному типу, до 5 МиБ
DELETE /api/v1/users/me/avatar удалить аватар
GET /api/v1/files/{key} отдать файл по ключу

GET /api/v1/files/{key} отдаёт объект, только если он загружен в tenant вызывающего (есть строка в file_uploads с этим ключом и tenant'ом). Ключ не является capability: угаданный ключ чужого tenant'а даёт 404 FILE_NOT_FOUND.

Документы как артефакты Control Plane

Документы панели используются как хранилище содержимого для артефактов Control Plane. Артефакт ссылается на документ строкой в uri:

platform://documents/<document-id>

Кнопка «Прикрепить файл» на карточке задачи в консоли делает два шага: загружает документ с purpose=task-attachment и создаёт артефакт типа document с этим uri и метаданными documentId, contentType, sizeBytes, sha256. Просмотрщик консоли по uri читает метаданные и байты напрямую из platform-api.

sequenceDiagram
    participant U as Консоль
    participant A as platform-api
    participant S as S3 (MinIO)
    participant CP as Control Plane (через шлюз)
    U->>A: POST /api/v1/documents (file)
    A->>S: put documents/{tenant}/{id}/{name}
    A-->>U: {id, content_type, sha256, …}
    U->>CP: POST /api/v1/artifacts {type: document, uri: platform://documents/{id}, metadata}
    U->>A: GET /api/v1/documents/{id}/content
    A->>S: stream
    A-->>U: байты (inline/attachment)

Сам Control Plane содержимое документа не читает и не хранит: для него артефакт — ссылка. Доступ к байтам по-прежнему решает platform-api по tenant'у пользователя. Подробнее об артефактах — в Артефакты и комментарии.

Просмотрщик консоли

Тип Рендер Ограничение
PDF pdf.js на canvas первые 200 страниц
DOCX docx-preview в iframe sandbox без скриптов вёрстка приближённая
XLSX / XLS / CSV / TSV табличный просмотр, только чтение 1000 строк × 60 столбцов
Markdown рендер с санитизацией 2 МБ текста
JSON / YAML / XML / TXT / логи текст (JSON форматируется) 2 МБ
HTML / SVG только исходный текст —
PNG / JPEG / GIF / WebP изображение —
прочее (PPTX, DOC, архивы) кнопка «Скачать» —

Эксплуатация

  • Резервное копирование. Метаданные — в базе platform, содержимое — в томе platform_minio (или во внешнем S3). Бэкапить нужно оба согласованно: строка без объекта даёт 404 DOCUMENT_NOT_FOUND при чтении содержимого, объект без строки недоступен через API (см. Резервное копирование).
  • Осиротевшие объекты. Если удаление объекта из S3 не удалось, объект остаётся в бакете. Периодически сверяйте ключи documents/… бакета со строками documents (включая удалённые) и удаляйте лишнее.
  • Лимит тела на внешнем контуре. Загрузки до 50 МиБ должны проходить через прокси; если перед Caddy стоит балансировщик с меньшим лимитом тела, поднимите его.

Типичные проблемы

Симптом Причина Что делать
422 FILE_TOO_LARGE файл больше 50 МиБ разбить файл или хранить во внешней системе
Файл скачивается вместо показа тип не входит в белый список inline ожидаемое поведение (HTML, SVG, офисные форматы)
404 DOCUMENT_NOT_FOUND у существующего документа документ другого tenant'а, удалён, или объект пропал из S3 проверить tenant_id токена и наличие объекта
Ошибка 5xx при загрузке MinIO недоступен или бакет не создан docker compose --profile platform ps, логи minio-bootstrap

См. также