feat(channels): фон комнаты и статичные превью при reduced-motion

- миграция 00014: background_file_id у комнаты (AGENT.md 7.5, 7.7)
- API: POST/DELETE /channels/{id}/background под MANAGE_CHANNEL_BACKGROUND,
  фон отдаётся в списке комнат и рассылается событием CHANNEL_UPDATE
- клиент: фон рисуется за лентой с затемнением, блок «Фон комнаты» в настройках
  комнаты (загрузка, замена, удаление)
- AnimatedImage: при «уменьшить движение» показывает статичный первый кадр,
  нарисованный на canvas, вместо анимации
- тесты: Go (загрузка, права, снятие) и Vitest (лента, настройки, reduced-motion)
This commit is contained in:
2026-09-21 18:08:21 +03:00
parent 91f415e36d
commit 0f0060e8a4
19 changed files with 733 additions and 16 deletions
+112
View File
@@ -756,3 +756,115 @@ func optionalString(value *string) string {
}
return *value
}
// maxChannelBackgroundSize — предел размера фона комнаты (AGENT.md 7.7).
const maxChannelBackgroundSize = 5 << 20
// registerChannelBackgroundRoutes описывает фон комнаты (AGENT.md 7.5, 7.7):
// картинка за лентой сообщений, право MANAGE_CHANNEL_BACKGROUND.
func (s *Server) registerChannelBackgroundRoutes(router chi.Router) {
router.Post("/channels/{channel_id}/background", s.handleChannelBackgroundUpload)
router.Delete("/channels/{channel_id}/background", s.handleChannelBackgroundDelete)
}
func (s *Server) handleChannelBackgroundUpload(w http.ResponseWriter, r *http.Request) {
user, _, ok := s.authenticate(w, r)
if !ok {
return
}
ctx := r.Context()
channelID, _, _, err := s.requireChannelPermission(ctx,
chi.URLParam(r, "channel_id"), user, permissions.ManageChannelBackground)
if err != nil {
writeHumaAPIError(w, err)
return
}
file, header, err := r.FormFile("file")
if err != nil {
httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", `multipart field "file" is required`)
return
}
defer func() { _ = file.Close() }()
if header.Size > maxChannelBackgroundSize {
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "background is too large")
return
}
data, err := io.ReadAll(io.LimitReader(file, maxChannelBackgroundSize+1))
if err != nil || int64(len(data)) > maxChannelBackgroundSize {
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "background is too large")
return
}
contentType := header.Header.Get("Content-Type")
if !strings.HasPrefix(contentType, "image/") {
contentType = http.DetectContentType(data)
}
if !strings.HasPrefix(contentType, "image/") {
httpxWriteJSONError(w, http.StatusUnprocessableEntity, "validation.failed", "background must be an image")
return
}
channel, err := s.store.GetChannel(ctx, channelID)
if err != nil {
writeHumaAPIError(w, humaError(err))
return
}
// Старый фон удаляем: загрузки не должны копиться (AGENT.md 7.7).
if previous := channel.BackgroundFileID; previous != nil && *previous != 0 {
s.deleteStoredFile(ctx, *previous)
}
stored, err := s.saveUpload(ctx, store.File{
UploaderID: &user.ID,
GuildID: channel.GuildID,
ChannelID: &channelID,
Filename: sanitizeFilename(header.Filename),
ContentType: contentType,
Purpose: "channel_background",
}, bytes.NewReader(data))
if err != nil {
writeHumaAPIError(w, err)
return
}
fileID := stored.ID
updated, err := s.store.UpdateChannel(ctx, channelID, store.UpdateChannelParams{BackgroundFileID: &fileID})
if err != nil {
s.deleteStoredFile(ctx, stored.ID)
writeHumaAPIError(w, humaError(err))
return
}
if channel.GuildID != nil {
s.invalidateGuild(*channel.GuildID)
s.recordAudit(ctx, user, *channel.GuildID, "channel.background", "channel", &channelID, "")
}
payload := channelFromStore(*updated, nil)
s.dispatchChannelEvent(ctx, channelID, "CHANNEL_UPDATE", payload)
httpxWriteJSON(w, http.StatusOK, map[string]any{"channel": payload})
}
func (s *Server) handleChannelBackgroundDelete(w http.ResponseWriter, r *http.Request) {
user, _, ok := s.authenticate(w, r)
if !ok {
return
}
ctx := r.Context()
channelID, _, channel, err := s.requireChannelPermission(ctx,
chi.URLParam(r, "channel_id"), user, permissions.ManageChannelBackground)
if err != nil {
writeHumaAPIError(w, err)
return
}
if previous := channel.BackgroundFileID; previous != nil && *previous != 0 {
s.deleteStoredFile(ctx, *previous)
}
updated, err := s.store.UpdateChannel(ctx, channelID, store.UpdateChannelParams{ClearBackground: true})
if err != nil {
writeHumaAPIError(w, humaError(err))
return
}
if channel.GuildID != nil {
s.invalidateGuild(*channel.GuildID)
s.recordAudit(ctx, user, *channel.GuildID, "channel.background_remove", "channel", &channelID, "")
}
payload := channelFromStore(*updated, nil)
s.dispatchChannelEvent(ctx, channelID, "CHANNEL_UPDATE", payload)
httpxWriteJSON(w, http.StatusOK, map[string]any{"channel": payload})
}