Files
ulitka/WIP.md
2026-10-02 09:18:15 +03:00

12 KiB
Raw Permalink Blame History

WIP — вложения (2026-10-02)

Сделано за сегодня: модуль file (хранение файлов), модуль issue-attachment (вложения задач) и вложения в комментариях. Всё в ветке feature/attachments (не запушена), миграции к БД не применялись.

Архитектура

Метаданные файла хранятся отдельно от того, к чему файл прикреплён:

file  ←──  issue_attachment    (issueId, fileId)    — модуль issue-attachment
      ←──  comment_attachment  (commentId, fileId)  — модуль comment
  • file ничего не знает о владельцах. Новый владелец = своя таблица связей
    • регистрация в FileOwnerRegistry; сам file при этом не меняется.
  • Удаление задачи/комментария удаляет связи каскадом в БД, а файл без единой связи (сирота) удаляет GC.
  • Флоу для клиента в два шага: загрузить файл → прикрепить по fileId.

Почему не полиморфная связь ownerType + ownerId: нет FK, нет каскадов, разные типы ключей (Issue.id — Int, остальные — uuid).

Миграции

Миграция Что
20261002045355_file таблица file
20261002050156_issue_attachment таблица issue_attachment
20261002060755_comment_attachment таблица comment_attachment

Собраны через prisma migrate diff без подключения к БД. Применятся при деплое (prisma migrate deploy в CMD Dockerfile).

Модуль file — src/modules/file/

API

Метод Путь Что
POST /api/files загрузка, multipart, поле file → FileDto
GET /api/files/:id/download скачивание (поток)
DELETE /api/files/:id удалить файл (только загрузивший), 204

FileDto: { id, fileName, mimeType, size, uploaderId, createdAt }. storageKey наружу не отдаётся.

Устройство

  • domain:
    • StoredFile — не File, чтобы не путать с глобальным Web API File;
    • VO FileName: убирает путь и управляющие символы, обрезает до 255 с сохранением расширения;
    • порты IFileStorage, IFileTypeDetector.
  • application:
    • use-case'ы upload / download / delete / get-files;
    • FileAccessService, FileOwnerRegistry, FileGcService.
  • infrastructure:
    • FilePrismaRepository;
    • хранилища LocalFileStorage (dev/тесты) и S3FileStorage (S3 / RustFS / MinIO), выбираются в file-storage.factory.ts;
    • FileTypeDetector на file-type.
  • presentation: FileController, хелпер contentDisposition (RFC 5987, кириллица), GraphQL-тип File.

Загрузка

  • multer пишет во временный файл (os.tmpdir()), а не в память. Лимит — FILE_MAX_SIZE_MB, при превышении Nest отвечает 413.
  • defParamCharset: 'utf8' — иначе multer ломает кириллицу в именах файлов.
  • MIME определяется по содержимому (magic bytes). Нераспознанное (HTML, SVG, текст) сохраняется как application/octet-stream.
  • Ключ в хранилище — files/{randomUUID}. Имя от пользователя в ключ не попадает.
  • Порядок записи: объект → строка в БД. Если запись строки упала, объект удаляется сразу.

Скачивание и безопасность

  • Inline отдаются только image/png|jpeg|gif|webp, всё остальное — как attachment. Плюс X-Content-Type-Options: nosniff — защита от stored XSS.
  • Файл виден загрузившему и всем, кому виден хоть один его владелец (задача, комментарий). Иначе 404, чтобы не раскрывать существование файла.
  • TransformResponseInterceptor пропускает StreamableFile. Без этого обёртка {data, code} ломала скачивание.

Прикрепление (для модулей-владельцев)

  • FileAccessService.getAttachableFile(id, userId): прикрепить можно только свой файл. Невидимый файл → 404, чужой → 403. Иначе файл, увиденный через одну задачу, можно было бы перенести в другую.
  • GetFilesUseCase — метаданные файлов по id, для раскрытия связей в DTO.

GC файлов-сирот

  • FileGcService, @Cron(EVERY_HOUR). Подключён ScheduleModule в app.module.ts.
  • Удаляет файлы старше FILE_ORPHAN_TTL_HOURS (по умолчанию 24), на которые не ссылается ни один зарегистрированный владелец.
  • Порядок удаления: объект → строка. Удаление объекта идемпотентно, поэтому при сбое следующий запуск доделает работу. Тот же порядок в DeleteFileUseCase.
  • Безопасен при нескольких инстансах приложения.

⚠️ Новый владелец файлов обязан зарегистрироваться в FileOwnerRegistry (onModuleInit), иначе GC удалит его файлы как сирот.

Модуль issue-attachment — src/modules/issue-attachment/

Отдельный модуль, как comment: зависит от issue, а issue о нём не знает. Иначе получался бы цикл issue ↔ attachment.

API

Метод Путь Что
GET /api/issues/:issueId/attachments список → IssueAttachmentDto[]
POST /api/issues/:issueId/attachments { fileId } → IssueAttachmentDto
DELETE /api/issues/:issueId/attachments/:fileId открепить, 204 (файл удалит GC)

IssueAttachmentDto: { issueId, file: FileDto, attachedAt }.

GraphQL: поле IssueDetail.attachments. Его добавляет IssueAttachmentResolver через @ResolveField, issue-модуль не тронут. В списке задач (Issue) этого поля нет, чтобы не было N+1.

Правила

  • Прикреплять и откреплять может любой, кому видна задача (то же правило, что для правки задачи), но прикрепить можно только свой файл.
  • Повторное прикрепление идемпотентно (upsert).
  • Если задачу или файл удалили между проверкой и вставкой (FK violation), ответ 404.
  • IssueAttachmentFileOwnerService регистрируется в реестре. Видимость файла проверяется через IIssueRepository.findById, поэтому правило «reporter/assignee» живёт в одном месте.

Вложения в комментариях — модуль comment

Вложения — часть агрегата Comment (fileIds). Отдельных эндпоинтов нет:

POST  /api/issues/:issueId/comments  { "content": "...", "fileIds": ["…"] }
PATCH /api/comments/:id              { "content": "...", "fileIds": [...] }
  • Ответы. CommentDto.attachments: FileDto[] — в create, update, списке и SSE-событиях.
  • Правка. fileIds при update задаёт набор целиком; не передан — вложения не меняются.
    • Синхронизация связей идёт в одной транзакции с правкой текста.
    • Изменение только набора файлов тоже считается правкой (editedAt, comment.updated).
    • Проверяются только добавленные файлы.
  • Ограничения: максимум Comment.MAX_ATTACHMENTS = 10, дубли убираются.
  • Порядок — по времени загрузки. id файлов — uuid v7, поэтому сортировка по строке совпадает с порядком загрузки.
  • Мягкое удаление комментария снимает связи, файлы забирает GC. В ответе у удалённого комментария attachments: [].
  • CommentAttachmentFileOwnerService регистрируется в реестре. Файл виден тем, кому видна задача неудалённого комментария с этим файлом.
  • CommentAuthorExpanderService переименован в CommentExpanderService: он раскрывает и авторов, и файлы (параллельно).

Прочие изменения

  • Зависимости: @aws-sdk/client-s3, file-type, @nestjs/schedule; dev — @types/multer.
  • tsconfig.json: multer добавлен в types, иначе не виден Express.Multer.File.
  • .gitignore / .dockerignore: добавлен /storage — каталог локального хранилища.

Переменные окружения

FILE_STORAGE=s3                # local (по умолчанию) | s3
FILE_STORAGE_LOCAL_DIR=./storage
FILE_MAX_SIZE_MB=25
FILE_ORPHAN_TTL_HOURS=24

S3_ENDPOINT=http://rustfs:9000 # для AWS можно не задавать
S3_REGION=us-east-1
S3_BUCKET=ulitka
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...
S3_FORCE_PATH_STYLE=true       # для RustFS/MinIO

В .env были закомментированы RUSTFS_*. Адаптер использует общие S3_*.

Проверено

  • 154 unit-теста проходят. Типы, линтер и сборка чистые.
  • Приложение стартует, DI резолвится, новые маршруты без сессии отвечают 401. В schema.gql появились File, IssueAttachment, IssueDetail.attachments.
  • Не проверено: сценарий целиком с авторизацией, реальной БД и S3 (upload → attach → download → GC).

TODO / открытые вопросы

  • Перед деплоем: создать бакет в RustFS (приложение его не создаёт), прописать FILE_STORAGE=s3 и S3_* на сервере.
  • Прогнать e2e: загрузка → прикрепление → скачивание другим участником → открепление → GC.
  • Влить feature/attachments в main. Push в main запускает деплой — сначала бакет и переменные окружения.
  • Лимит числа вложений у задачи (у комментария — 10).
  • SSE-события для вложений задачи (по образцу CommentEventsService).
  • Комментарий только с файлами, без текста? Сейчас текст обязателен.
  • Вложения комментариев в GraphQL — когда появятся комментарии в GraphQL.
  • GC при большом числе файлов: сейчас каждый запуск проходит по всем старым файлам. При сотнях тысяч файлов — переписать на NOT EXISTS.
  • Маловероятная гонка: прикрепление файла, загруженного больше 24 ч назад, ровно в момент прохода GC может его потерять.
  • Позже: отдавать скачивание redirect'ом на presigned GET вместо стрима через Node.