Документы и файлы¶
Панель платформы хранит двоичные документы (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…"}
Порядок удаления выбран так, чтобы сбой не оставлял «висящий» доступ:
- Строка помечается удалённой (
deleted_at) — документ сразу исчезает из API. - Объект удаляется из 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:
Кнопка «Прикрепить файл» на карточке задачи в консоли делает два шага:
загружает документ с 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.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 |