Doc Media - PlutoGr/TravelAgency GitHub Wiki

Media

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, оригинал и все превью удаляются из хранилища.

gRPC

Сервер MediaService (порт 8081), вызывает Catalog:

  • GetMediaFiles(ids): владелец, размеры, флаг публичности и назначение purpose (tour-image или general). Неизвестные и удалённые id в ответ не попадают;
  • MarkMediaFilesPublic(ids): ставит IsPublic = true только активным файлам с purpose = tour-image. Другое назначение, удалённые и неизвестные id остаются без изменений. Флаг не снимается, повтор безопасен.

Фото тура в Catalog

Простой пример. Менеджер сохраняет фото тура:

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
⚠️ **GitHub.com Fallback** ⚠️