feat(files): загрузка и выдача вложений

- POST /api/v1/channels/{id}/files (multipart, ATTACH_FILES): файл пишется на
  диск под идентификатором, в БД хранятся метаданные и sha256; имя файла
  очищается от путей и управляющих символов, объём ограничен MAX_UPLOAD_SIZE;
- GET/HEAD /files/{id}: содержимое с проверкой прав на комнату сообщения
  (невидимая комната и чужие вложения → 404), ETag, immutable-кэш,
  Content-Disposition с RFC 5987 для не-ASCII имён;
- вложения привязываются к сообщению при отправке (attachment_ids), сироты
  ищутся через store.ListOrphanFiles для обслуживания;
- GET /api/v1/files/{id} — метаданные файла для клиента;
- chi-ручки теперь умеют отдавать huma-ошибки в общем конверте
  (writeHumaAPIError), иначе 404 превращался в 500;
- READY отдаёт реальные read states пользователя.

Тесты: загрузка, скачивание участником, скрытая комната → 404, вложение в
сообщении, проверка подписи ETag.
This commit is contained in:
2026-09-19 23:36:16 +03:00
parent 63bf1e63b5
commit 0e39b8e027
25 changed files with 1162 additions and 39 deletions
+9 -2
View File
@@ -172,6 +172,11 @@ interface SnapshotGuildInput {
id: string;
name: string;
is_main?: boolean;
/** Владелец сервера: по умолчанию текущий пользователь (`user-1`). */
owner_id?: string;
icon_file_id?: string;
my_permissions?: string[];
my_nickname?: string;
channels?: {
id: string;
name: string;
@@ -196,8 +201,10 @@ export function makeReadySnapshot(guilds: SnapshotGuildInput[] = []) {
guilds: guilds.map((guild) => ({
id: guild.id,
name: guild.name,
owner_id: 'user-1',
owner_id: guild.owner_id ?? 'user-1',
is_main: guild.is_main ?? false,
...(guild.icon_file_id === undefined ? {} : { icon_file_id: guild.icon_file_id }),
...(guild.my_nickname === undefined ? {} : { my_nickname: guild.my_nickname }),
channels: (guild.channels ?? []).map((channel, index) => ({
id: channel.id,
name: channel.name,
@@ -210,7 +217,7 @@ export function makeReadySnapshot(guilds: SnapshotGuildInput[] = []) {
roles: [],
member_ids: ['user-1'],
my_role_ids: [],
my_permissions: [],
my_permissions: guild.my_permissions ?? [],
})),
dm_channels: [],
read_states: [],