Doc Media - PlutoGr/TravelAgency GitHub Wiki
src/Services/Media/. Загрузка файлов, превью и отдача картинок. Файлы лежат в S3-совместимом хранилище (сейчас MinIO, bucket media), сведения о них в базе travel_media.
Менеджер добавляет фото в тур:
POST /api/v1/media/upload?purpose=tour-image
Content-Type: multipart/form-data (поле file: beach.jpg, 2400×1600)
→ 201 { "id": "9d0e…", "contentType": "image/jpeg", "width": 2400, "height": 1600,
"thumbnails": [ {…200…}, {…800…}, {…1600…} ], "isPublic": false }Пока тур черновик, фото видно только владельцу и админу через /api/v1/media/manage/files/9d0e…/w800. После публикации тура то же фото открыто всем по /api/v1/media/files/9d0e…/w800. Если фото заменяют у уже опубликованного тура, новые файлы открываются при сохранении списка, не дожидаясь повторной публикации. Подробно: Картинки.
MediaFile: Id, OriginalFileName, ContentType, SizeBytes, StorageKey, OwnerId, Status (Active, Deleted), UploadedAt, IsPublic, Width, Height, Purpose (general или tour-image).
MediaFileThumbnail: StorageKey, Width, Height, SizeCode (w200, w800, w1600 у фото туров; пусто у превью общих файлов).
Ключ в хранилище: <id владельца>/<id файла>/<имя файла>, превью рядом с суффиксом -preview-w800 или -thumb-200.
| Общий файл | Фото тура (purpose=tour-image) |
|
|---|---|---|
| кто может | любой вошедший | Manager, Admin |
| типы | jpeg, png, webp, gif, pdf | jpeg, png, webp |
| размер | до 10 МБ | до 10 МБ |
| превью | ширина 200 и 800, только если оригинал шире | w200, w800, w1600, вписываются в ширину без увеличения |
Содержимое файла проверяется: заявленный тип должен совпадать с настоящим, картинка должна читаться (ImageSharp). Превью сохраняются в формате оригинала. nginx пропускает тело до 12 МБ, больше получает 413.
В ответе нет адресов: фронтенд строит их сам по id (src/utils/media.ts).
| Путь | Кто | Условие | Кэш |
|---|---|---|---|
GET /media/files/{id}/{w200|w800|w1600} |
все | файл IsPublic, иначе 404 |
public, max-age=31536000, immutable |
GET /media/manage/files/{id}/{size} |
владелец файла или Admin, иначе 403 | private, no-store |
|
GET /media/{id} |
вошедший | публичный файл только при Purpose = tour-image; иначе владелец, Manager или Admin. Чужой файл: 404, тем же телом, что у отсутствующего или удалённого |
ответ, кроме публичного фото тура: private, no-store
|
Файлы отдаются потоком из хранилища через сервис, прямых ссылок на MinIO нет.
GET /media/{id} проверяет доступ по порядку: файл публичный и Purpose равен tour-image; текущий пользователь является владельцем; у пользователя роль Manager или Admin. Публичный файл с другим purpose, в том числе general, первую проверку не проходит и читается по общим правилам. Если ни одно условие не выполнено, ответ 404 с тем же телом, что и для неизвестного id: по коду и тексту нельзя понять, существует ли файл. Ответ, который не является публичным фото тура, уходит с Cache-Control: private, no-store. Аноним получает 401.
Флаг IsPublic («публичный») только ставится и никогда не снимается. Так задумано: фото остаётся публичным, если его удалили, убрали из тура через PUT images или тур сняли с публикации (unpublish). Ни один из этих шагов не вызывает Media. Поэтому карточка снятого тура в избранном продолжает показывать обложку, а адрес картинки, который уже попал в кэш браузера, не начинает отвечать 404.
DELETE /media/{id}: только владелец (иначе 403). Запись получает Status = Deleted, оригинал и все превью удаляются из хранилища.
Сервер MediaService (порт 8081), вызывает Catalog:
-
GetMediaFiles(ids): владелец, размеры, флаг публичности и назначениеpurpose(tour-imageилиgeneral). Неизвестные и удалённые id в ответ не попадают; -
MarkMediaFilesPublic(ids): ставитIsPublic = trueтолько активным файлам сpurpose = tour-image. Другое назначение, удалённые и неизвестные id остаются без изменений. Флаг не снимается, повтор безопасен.
Простой пример. Менеджер сохраняет фото тура:
PUT /api/v1/catalog/manage/tours/{id}/images
{ "images": [ { "mediaFileId": "9d0e…", "sortOrder": 0, "isCover": true } ] }
→ 422 images.notFound, если файл 9d0e… чужой, удалён, загружен как general или не существуетPUT images и POST publish до сохранения получают файлы через GetMediaFiles и проверяют каждый (TourImageRules.LoadOwnedAsync, CanAttach):
- владелец файла совпадает с владельцем тура;
-
purposeравенtour-image. Пустое назначение (Media, который ещё не передаёт поле) не проходит проверку.
Чужой, удалённый, не того назначения и отсутствующий файл дают один ответ: 422 images.notFound. По коду и тексту нельзя понять, существует ли файл. Отдельного кода для чужого файла нет.
После проверки Catalog вызывает MarkMediaFilesPublic: при publish и при PUT images у тура в статусе Published. Сначала вызов, потом сохранение: если Media недоступен, ответ 503, тур и его фото не меняются. Подробно: Публикация тура.
Для понимания. Право сделать файл публичным проверяют обе стороны: сервис, который вызывает (Catalog, проверяет владельца и назначение), и сервис, который хранит данные (Media, открывает только активные tour-image). Ошибка в одном из них не откроет чужие или служебные файлы: вторая проверка остаётся. Такой подход называют «защита в глубину».
-
presignесть в сервисе, но Gateway его не пропускает (404). -
BucketInitializerпри старте создаёт bucket, если его нет. Bucket закрыт для анонимного доступа. - Сидер (
Seeding:DemoCatalogилиASPNETCORE_SEED_DATA) кладёт демо-фото с фиксированными id для демо-туров. - Замена MinIO на облачное хранилище (#11) затронет только
IStorageService.
| Ключ | Что |
|---|---|
ConnectionStrings__MediaDb |
база |
Storage__ServiceUrl |
адрес хранилища, например http://minio:9000
|
Storage__AccessKey, Storage__SecretKey
|
<ключ доступа>, <секретный ключ>; только в .env на сервере, см. Секреты
|
Storage__BucketName |
media |
Upload__MaxFileSizeBytes, Upload__AllowedMimeTypes, Upload__ThumbnailWidths
|
ограничения загрузки |
GrpcSettings__InternalServiceToken |
сервисный токен gRPC |