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
+1 -6
View File
@@ -137,12 +137,7 @@ export function UserPanel() {
</button>
</div>
<Menu
open={menuOpen}
label={t('user.menu.label')}
onClose={closeMenu}
anchorRef={triggerRef}
>
<Menu open={menuOpen} label={t('user.menu.label')} onClose={closeMenu} anchorRef={triggerRef}>
<MenuGroupLabel>{displayName}</MenuGroupLabel>
<MenuItem icon="👤" onClick={goTo('/settings/account/profile')}>
{t('user.menu.account')}
@@ -26,8 +26,7 @@ export function GuildGeneralSection({ guild, canEdit }: GuildGeneralSectionProps
const [saved, setSaved] = useState(false);
const save = useMutation({
mutationFn: () =>
updateGuild(guild.id, { name: name.trim(), description: description.trim() }),
mutationFn: () => updateGuild(guild.id, { name: name.trim(), description: description.trim() }),
onSuccess: (updated) => {
queryClient.setQueryData(guildQueryKey(guild.id), updated);
// Рейка и сайдбар берут имя из снапшота — обновляем и его.
@@ -1,4 +1,4 @@
import { useEffect, useState, type FormEvent } from 'react';
import { useState, type FormEvent } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -29,23 +29,18 @@ export function GuildNicknameSection({
}: GuildNicknameSectionProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [nickname, setNickname] = useState(currentNickname);
const [edited, setEdited] = useState(false);
// `null` — поле ещё не трогали, поэтому показываем ник из снапшота/участников
// (он может прийти уже после монтирования секции).
const [typed, setTyped] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
// Ник может прийти из списка участников уже после монтирования секции:
// подтягиваем его, пока пользователь не начал править поле сам.
useEffect(() => {
if (!edited) {
setNickname(currentNickname);
}
}, [currentNickname, edited]);
const nickname = typed ?? currentNickname;
const save = useMutation({
mutationFn: (value: string) => updateGuildMember(guildId, userId ?? '', { nickname: value }),
onSuccess: (_result, value) => {
useSessionStore.getState().setMyNickname(guildId, value);
void queryClient.invalidateQueries({ queryKey: guildMembersQueryKey(guildId) });
setTyped(null);
setSaved(true);
},
});
@@ -81,10 +76,7 @@ export function GuildNicknameSection({
value={nickname}
maxLength={32}
disabled={userId === undefined}
onChange={(event) => {
setEdited(true);
setNickname(event.target.value);
}}
onChange={(event) => setTyped(event.target.value)}
/>
{save.isError ? <ErrorNotice error={save.error} /> : null}
@@ -54,7 +54,10 @@ export default function ServersSettingsPage() {
}
}, [sessionGuilds.length, myGuilds.data, upsertGuild]);
const guilds = sessionGuilds.length > 0 ? sessionGuilds : (myGuilds.data ?? []);
const guilds = useMemo(
() => (sessionGuilds.length > 0 ? sessionGuilds : (myGuilds.data ?? [])),
[sessionGuilds, myGuilds.data],
);
const active = useMemo(
() => (routeGuildId === undefined ? null : (guilds.find((g) => g.id === routeGuildId) ?? null)),
[guilds, routeGuildId],