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

215 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.