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:
@@ -0,0 +1,7 @@
|
||||
-- +goose Up
|
||||
-- Фон комнаты (AGENT.md 7.5, 7.7): картинка за лентой сообщений. Хранится
|
||||
-- обычной загрузкой, поэтому анимированные GIF/APNG/WebP не перекодируются.
|
||||
ALTER TABLE channels ADD COLUMN background_file_id INTEGER REFERENCES files (id) ON DELETE SET NULL;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE channels DROP COLUMN background_file_id;
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -304,6 +304,7 @@ func (s *Server) requireFileAccess(ctx context.Context, rawFileID string, user *
|
||||
file.Purpose == "sound" || file.Purpose == "webhook_avatar" {
|
||||
return file, nil
|
||||
}
|
||||
// Фон комнаты виден тем, кто видит саму комнату (проверка ниже по каналу).
|
||||
if file.ChannelID != nil {
|
||||
if _, _, _, err := s.requireChannelPermission(ctx, formatSnowflake(*file.ChannelID), user, permissions.ViewChannel); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -30,6 +30,8 @@ type channelPayload struct {
|
||||
CanView bool `json:"can_view"`
|
||||
CanSend bool `json:"can_send"`
|
||||
CanConnect bool `json:"can_connect"`
|
||||
// BackgroundFileID — фон комнаты (AGENT.md 7.5, 7.7).
|
||||
BackgroundFileID string `json:"background_file_id,omitempty"`
|
||||
}
|
||||
|
||||
type rolePayload struct {
|
||||
@@ -97,6 +99,9 @@ func channelFromStore(channel store.Channel, resolved *permissions.Resolved) cha
|
||||
if channel.ParentID != nil {
|
||||
payload.ParentID = formatSnowflake(*channel.ParentID)
|
||||
}
|
||||
if channel.BackgroundFileID != nil {
|
||||
payload.BackgroundFileID = formatSnowflake(*channel.BackgroundFileID)
|
||||
}
|
||||
if resolved != nil {
|
||||
payload.CanView = resolved.CanViewChannel()
|
||||
payload.CanSend = resolved.Can(permissions.SendMessages)
|
||||
|
||||
@@ -393,3 +393,88 @@ func TestFriendsListCarriesCosmetics(t *testing.T) {
|
||||
}
|
||||
_ = guildID
|
||||
}
|
||||
|
||||
func TestChannelBackgroundUploadAndDelete(t *testing.T) {
|
||||
srv, owner, member, guildID, _ := cosmeticsFixture(t)
|
||||
channels := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/channels", "", owner)
|
||||
channelID := decodeResponse[struct {
|
||||
Channels []struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
} `json:"channels"`
|
||||
}](t, channels).Channels[0].ID
|
||||
|
||||
// Загрузка фона владельцем: multipart, картинка.
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
part, err := writer.CreateFormFile("file", "bg.png")
|
||||
if err != nil {
|
||||
t.Fatalf("multipart: %v", err)
|
||||
}
|
||||
if _, err := part.Write(pngBytes()); err != nil {
|
||||
t.Fatalf("multipart write: %v", err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("multipart close: %v", err)
|
||||
}
|
||||
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost,
|
||||
"/api/v1/channels/"+channelID+"/background", &body)
|
||||
request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
request.AddCookie(owner)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, request)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("загрузка фона = %d (%s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
uploaded := decodeResponse[struct {
|
||||
Channel struct {
|
||||
BackgroundFileID string `json:"background_file_id"`
|
||||
} `json:"channel"`
|
||||
}](t, rec)
|
||||
if uploaded.Channel.BackgroundFileID == "" {
|
||||
t.Fatalf("фон не применился: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// Участник без MANAGE_CHANNEL_BACKGROUND фон не меняет.
|
||||
request = httptest.NewRequestWithContext(t.Context(), http.MethodPost,
|
||||
"/api/v1/channels/"+channelID+"/background", &body)
|
||||
request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
request.AddCookie(member)
|
||||
denied := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(denied, request)
|
||||
if denied.Code != http.StatusForbidden {
|
||||
t.Fatalf("фон участником = %d, ожидался 403 (%s)", denied.Code, denied.Body.String())
|
||||
}
|
||||
|
||||
// Фон виден в списке комнат сервера.
|
||||
list := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/channels", "", member)
|
||||
payload := decodeResponse[struct {
|
||||
Channels []struct {
|
||||
ID string `json:"id"`
|
||||
BackgroundFileID string `json:"background_file_id"`
|
||||
} `json:"channels"`
|
||||
}](t, list)
|
||||
found := false
|
||||
for _, channel := range payload.Channels {
|
||||
if channel.ID == channelID && channel.BackgroundFileID == uploaded.Channel.BackgroundFileID {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("фон не виден участнику: %s", list.Body.String())
|
||||
}
|
||||
|
||||
// Снятие фона.
|
||||
removed := doJSON(t, srv, http.MethodDelete, "/api/v1/channels/"+channelID+"/background", "", owner)
|
||||
if removed.Code != http.StatusOK {
|
||||
t.Fatalf("снятие фона = %d (%s)", removed.Code, removed.Body.String())
|
||||
}
|
||||
after := decodeResponse[struct {
|
||||
Channel struct {
|
||||
BackgroundFileID string `json:"background_file_id"`
|
||||
} `json:"channel"`
|
||||
}](t, removed)
|
||||
if after.Channel.BackgroundFileID != "" {
|
||||
t.Fatalf("фон не снят: %s", removed.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +154,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
||||
s.registerSoundsRoutes(s.api, apiRouter)
|
||||
s.registerWebhookRoutes(s.api, apiRouter)
|
||||
s.registerCosmeticRoutes(s.api, apiRouter)
|
||||
s.registerChannelBackgroundRoutes(apiRouter)
|
||||
s.registerVoiceWebhook(apiRouter)
|
||||
}
|
||||
apiRouter.Get("/openapi.json", s.handleOpenAPI)
|
||||
|
||||
+27
-13
@@ -27,9 +27,11 @@ type Channel struct {
|
||||
SlowmodeSeconds int
|
||||
NSFW bool
|
||||
Background string
|
||||
VoiceStatus string
|
||||
UserLimit int
|
||||
CreatedAt time.Time
|
||||
// BackgroundFileID — фон комнаты: файл-картинка (AGENT.md 7.5).
|
||||
BackgroundFileID *uint64
|
||||
VoiceStatus string
|
||||
UserLimit int
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type ChannelOverride struct {
|
||||
@@ -54,7 +56,7 @@ type CreateChannelParams struct {
|
||||
}
|
||||
|
||||
const channelColumns = `id, guild_id, type, name, description, position, parent_id,
|
||||
slowmode_seconds, nsfw, background, voice_status, user_limit, created_at`
|
||||
slowmode_seconds, nsfw, background, background_file_id, voice_status, user_limit, created_at`
|
||||
|
||||
func (s *Store) CreateChannel(ctx context.Context, params CreateChannelParams) (*Channel, error) {
|
||||
if params.ID == 0 {
|
||||
@@ -95,8 +97,11 @@ type UpdateChannelParams struct {
|
||||
SlowmodeSeconds *int
|
||||
NSFW *bool
|
||||
Background *string
|
||||
VoiceStatus *string
|
||||
UserLimit *int
|
||||
// BackgroundFileID задаёт новый фон, ClearBackground снимает его.
|
||||
BackgroundFileID *uint64
|
||||
ClearBackground bool
|
||||
VoiceStatus *string
|
||||
UserLimit *int
|
||||
}
|
||||
|
||||
func (s *Store) UpdateChannel(ctx context.Context, id uint64, params UpdateChannelParams) (*Channel, error) {
|
||||
@@ -133,6 +138,13 @@ func (s *Store) UpdateChannel(ctx context.Context, id uint64, params UpdateChann
|
||||
sets = append(sets, "background = ?")
|
||||
args = append(args, nullableString(*params.Background))
|
||||
}
|
||||
if params.BackgroundFileID != nil {
|
||||
sets = append(sets, "background_file_id = ?")
|
||||
args = append(args, int64(*params.BackgroundFileID))
|
||||
}
|
||||
if params.ClearBackground {
|
||||
sets = append(sets, "background_file_id = NULL")
|
||||
}
|
||||
if params.VoiceStatus != nil {
|
||||
sets = append(sets, "voice_status = ?")
|
||||
args = append(args, *params.VoiceStatus)
|
||||
@@ -207,16 +219,17 @@ func (s *Store) ListChannelOverrides(ctx context.Context, channelID uint64) ([]C
|
||||
|
||||
func scanChannel(scanner interface{ Scan(...any) error }) (*Channel, error) {
|
||||
var (
|
||||
channel Channel
|
||||
guildID sql.NullInt64
|
||||
parentID sql.NullInt64
|
||||
nsfw int
|
||||
background sql.NullString
|
||||
createdAt string
|
||||
channel Channel
|
||||
guildID sql.NullInt64
|
||||
parentID sql.NullInt64
|
||||
nsfw int
|
||||
background sql.NullString
|
||||
backgroundID sql.NullInt64
|
||||
createdAt string
|
||||
)
|
||||
err := scanner.Scan(&channel.ID, &guildID, &channel.Type, &channel.Name, &channel.Description,
|
||||
&channel.Position, &parentID, &channel.SlowmodeSeconds, &nsfw, &background,
|
||||
&channel.VoiceStatus, &channel.UserLimit, &createdAt)
|
||||
&backgroundID, &channel.VoiceStatus, &channel.UserLimit, &createdAt)
|
||||
if err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
@@ -226,6 +239,7 @@ func scanChannel(scanner interface{ Scan(...any) error }) (*Channel, error) {
|
||||
if background.Valid {
|
||||
channel.Background = background.String
|
||||
}
|
||||
channel.BackgroundFileID = optionalID(backgroundID)
|
||||
channel.CreatedAt = parseTimestamp(createdAt)
|
||||
return &channel, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user