Files
glchat/internal/server/api_cosmetics.go
T

868 lines
28 KiB
Go
Raw Normal View History

package server
import (
"bytes"
"context"
"io"
"net/http"
"strings"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/go-chi/chi/v5"
"glchat/internal/permissions"
"glchat/internal/store"
)
// Оформление профиля (AGENT.md 7.2, 7.4): галерея сервера с рамками и
// иконками, назначение их ролям и личные стили пользователя («для друзей» и
// пер-серверные) с поэлементным приоритетом.
const (
// maxCosmeticsPerGuild — предел галереи сервера.
maxCosmeticsPerGuild = 50
// maxCosmeticNameLength — длина имени элемента галереи.
maxCosmeticNameLength = 40
// scopeGlobal — область личного стиля «для друзей».
scopeGlobal = "global"
)
type cosmeticPayload struct {
ID string `json:"id"`
GuildID string `json:"guild_id"`
Kind string `json:"kind"`
Name string `json:"name"`
FileID string `json:"file_id"`
CreatedAt string `json:"created_at"`
}
type cosmeticListOutput struct {
Body struct {
Cosmetics []cosmeticPayload `json:"cosmetics"`
}
}
type cosmeticOutput struct {
Body struct {
Cosmetic cosmeticPayload `json:"cosmetic"`
}
}
// cosmeticsPayload — вычисленное оформление участника (AGENT.md 7.2).
type cosmeticsPayload struct {
FrameID string `json:"frame_id,omitempty"`
BadgeID string `json:"badge_id,omitempty"`
NickColor *int64 `json:"nick_color,omitempty"`
NickEffect string `json:"nick_effect,omitempty"`
}
// stylePayload — личный стиль профиля вместе с элементами, которые доступны
// пользователю: клиент показывает выбор только из них (7.2).
type stylePayload struct {
Scope string `json:"scope"`
GuildID string `json:"guild_id,omitempty"`
GuildName string `json:"guild_name,omitempty"`
// Style — записанный стиль области (пустой, если его нет).
Style cosmeticsPayload `json:"style"`
FrameIDs []string `json:"frame_ids"`
BadgeIDs []string `json:"badge_ids"`
NickColors []int64 `json:"nick_colors"`
Effects []string `json:"effects"`
}
type styleListOutput struct {
Body struct {
Styles []stylePayload `json:"styles"`
}
}
type styleOutput struct {
Body struct {
Style stylePayload `json:"style"`
}
}
// registerCosmeticRoutes описывает галерею оформления сервера и личные стили
// (AGENT.md 7.2, 7.4).
func (s *Server) registerCosmeticRoutes(api huma.API, router chi.Router) {
security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}
huma.Register(api, huma.Operation{
OperationID: "listGuildCosmetics",
Method: http.MethodGet,
Path: "/guilds/{guild_id}/cosmetics",
Summary: "Галерея оформления сервера",
Tags: []string{"Cosmetics"},
Security: security,
}, func(ctx context.Context, input *struct {
GuildID string `path:"guild_id"`
},
) (*cosmeticListOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ViewGuild)
if err != nil {
return nil, err
}
cosmetics, err := s.store.ListGuildCosmetics(ctx, guildID)
if err != nil {
return nil, humaError(err)
}
output := &cosmeticListOutput{}
output.Body.Cosmetics = make([]cosmeticPayload, 0, len(cosmetics))
for i := range cosmetics {
output.Body.Cosmetics = append(output.Body.Cosmetics, cosmeticFromStore(cosmetics[i]))
}
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "renameGuildCosmetic",
Method: http.MethodPatch,
Path: "/guilds/{guild_id}/cosmetics/{cosmetic_id}",
Summary: "Переименовать элемент галереи",
Tags: []string{"Cosmetics"},
Security: security,
}, func(ctx context.Context, input *struct {
GuildID string `path:"guild_id"`
CosmeticID string `path:"cosmetic_id"`
Body struct {
Name string `json:"name" minLength:"1" maxLength:"40"`
}
},
) (*cosmeticOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageRoles)
if err != nil {
return nil, err
}
cosmetic, err := s.requireGuildCosmetic(ctx, guildID, input.CosmeticID)
if err != nil {
return nil, err
}
name := strings.TrimSpace(input.Body.Name)
if name == "" || len([]rune(name)) > maxCosmeticNameLength {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "invalid cosmetic name")
}
updated, err := s.store.RenameGuildCosmetic(ctx, cosmetic.ID, name)
if err != nil {
return nil, humaError(err)
}
s.recordAudit(ctx, user, guildID, "cosmetic.update", "cosmetic", &cosmetic.ID, "")
s.dispatchGuildCosmeticsUpdate(guildID)
output := &cosmeticOutput{}
output.Body.Cosmetic = cosmeticFromStore(*updated)
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "deleteGuildCosmetic",
Method: http.MethodDelete,
Path: "/guilds/{guild_id}/cosmetics/{cosmetic_id}",
Summary: "Удалить элемент галереи",
Tags: []string{"Cosmetics"},
Security: security,
}, func(ctx context.Context, input *struct {
GuildID string `path:"guild_id"`
CosmeticID string `path:"cosmetic_id"`
},
) (*okOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageRoles)
if err != nil {
return nil, err
}
cosmetic, err := s.requireGuildCosmetic(ctx, guildID, input.CosmeticID)
if err != nil {
return nil, err
}
if err := s.store.DeleteGuildCosmetic(ctx, cosmetic.ID); err != nil {
return nil, humaError(err)
}
// Файл больше не нужен: галерея не должна копить мусор (AGENT.md 7.7).
s.deleteStoredFile(ctx, cosmetic.FileID)
s.recordAudit(ctx, user, guildID, "cosmetic.delete", "cosmetic", &cosmetic.ID, "")
s.dispatchGuildCosmeticsUpdate(guildID)
return newOKOutput(), nil
})
// Загрузка файла галереи — multipart, поэтому ручка живёт на роутере.
router.Post("/guilds/{guild_id}/cosmetics", s.handleCosmeticUpload)
s.registerStyleRoutes(api)
}
// handleCosmeticUpload принимает картинку рамки или иконки (AGENT.md 7.4).
func (s *Server) handleCosmeticUpload(w http.ResponseWriter, r *http.Request) {
user, _, ok := s.authenticate(w, r)
if !ok {
return
}
ctx := r.Context()
guildID, err := parseID("guild_id", chi.URLParam(r, "guild_id"))
if err != nil {
writeHumaAPIError(w, err)
return
}
resolved, err := s.guildPermissions(ctx, guildID, user)
if err != nil {
writeHumaAPIError(w, err)
return
}
if !resolved.Has(permissions.ManageRoles) {
httpxWriteJSONError(w, http.StatusForbidden, "perm.denied", "MANAGE_ROLES is required")
return
}
if count, err := s.store.CountGuildCosmetics(ctx, guildID); err != nil {
writeHumaAPIError(w, humaError(err))
return
} else if count >= maxCosmeticsPerGuild {
httpxWriteJSONError(w, http.StatusUnprocessableEntity, "limits.reached", "cosmetic gallery is full")
return
}
if err := r.ParseMultipartForm(maxMultipartMemory); err != nil { //nolint:gosec // объём ограничен ниже
httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", "malformed multipart body")
return
}
defer func() {
if r.MultipartForm != nil {
_ = r.MultipartForm.RemoveAll()
}
}()
kind := store.CosmeticKind(strings.TrimSpace(r.FormValue("kind")))
if kind != store.CosmeticFrame && kind != store.CosmeticBadge {
httpxWriteJSONError(w, http.StatusUnprocessableEntity, "validation.failed", "kind must be frame or badge")
return
}
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
name = string(kind)
}
if len([]rune(name)) > maxCosmeticNameLength {
httpxWriteJSONError(w, http.StatusUnprocessableEntity, "validation.failed", "invalid cosmetic name")
return
}
limit := s.mediaLimit(ctx, mediaCosmetic)
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 > limit {
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "cosmetic is too large")
return
}
data, err := io.ReadAll(io.LimitReader(file, limit+1))
if err != nil || int64(len(data)) > limit {
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "cosmetic 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", "cosmetic must be an image")
return
}
stored, err := s.saveUpload(ctx, store.File{
UploaderID: &user.ID,
GuildID: &guildID,
Filename: sanitizeFilename(header.Filename),
ContentType: contentType,
Purpose: "guild_cosmetic",
}, bytes.NewReader(data))
if err != nil {
writeHumaAPIError(w, err)
return
}
cosmetic, err := s.store.CreateGuildCosmetic(ctx, store.CreateGuildCosmeticParams{
GuildID: guildID,
Kind: kind,
Name: name,
FileID: stored.ID,
})
if err != nil {
s.deleteStoredFile(ctx, stored.ID)
writeHumaAPIError(w, humaError(err))
return
}
s.recordAudit(ctx, user, guildID, "cosmetic.create", "cosmetic", &cosmetic.ID, "")
s.dispatchGuildCosmeticsUpdate(guildID)
httpxWriteJSON(w, http.StatusOK, map[string]any{"cosmetic": cosmeticFromStore(*cosmetic)})
}
// registerStyleRoutes описывает личные стили профиля (AGENT.md 7.2).
func (s *Server) registerStyleRoutes(api huma.API) {
security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}
huma.Register(api, huma.Operation{
OperationID: "listMyStyles",
Method: http.MethodGet,
Path: "/users/@me/styles",
Summary: "Личные стили профиля и доступные элементы",
Tags: []string{"Cosmetics"},
Security: security,
}, func(ctx context.Context, _ *struct{}) (*styleListOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
styles, err := s.store.ListUserStyles(ctx, user.ID)
if err != nil {
return nil, humaError(err)
}
cosmetics, err := s.store.ListGrantedCosmetics(ctx, user.ID)
if err != nil {
return nil, humaError(err)
}
colors := store.NickColorPalette()
// Доступность элементов считается для каждой области отдельно (7.2):
// пер-серверный стиль ограничен ролями этого сервера, «для друзей» —
// ролями на любом сервере.
byScope := make(map[string]*store.UserStyle, len(styles))
for i := range styles {
byScope[scopeOfStyle(styles[i])] = &styles[i]
}
grants, err := s.store.CosmeticGrantsForUsers(ctx, []uint64{user.ID}, nil)
if err != nil {
return nil, humaError(err)
}
output := &styleListOutput{}
output.Body.Styles = append(output.Body.Styles,
styleFromStore(byScope[scopeGlobal], scopeGlobal, "",
grantedIDs(cosmetics, grants[user.ID].Frames, store.CosmeticFrame),
grantedIDs(cosmetics, grants[user.ID].Badges, store.CosmeticBadge),
colors))
guilds, err := s.store.ListGuildsForUser(ctx, user.ID)
if err != nil {
return nil, humaError(err)
}
for i := range guilds {
guildID := guilds[i].ID
scope := formatSnowflake(guildID)
scoped, err := s.store.CosmeticGrantsForUsers(ctx, []uint64{user.ID}, &guildID)
if err != nil {
return nil, humaError(err)
}
entry := scoped[user.ID]
output.Body.Styles = append(output.Body.Styles,
styleFromStore(byScope[scope], scope, guilds[i].Name,
grantedIDs(cosmetics, entry.GuildFrames, store.CosmeticFrame),
grantedIDs(cosmetics, entry.GuildBadges, store.CosmeticBadge),
colors))
}
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "putMyStyle",
Method: http.MethodPut,
Path: "/users/@me/styles/{scope}",
Summary: "Задать личный стиль профиля",
Tags: []string{"Cosmetics"},
Security: security,
}, func(ctx context.Context, input *struct {
Scope string `path:"scope"`
Body struct {
FrameID string `json:"frame_id,omitempty"`
BadgeID string `json:"badge_id,omitempty"`
NickColor *int64 `json:"nick_color,omitempty"`
NickEffect string `json:"nick_effect,omitempty"`
}
},
) (*styleOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
guildID, err := parseStyleScope(input.Scope)
if err != nil {
return nil, err
}
if guildID != nil {
// Пер-серверный стиль доступен только участнику сервера.
if _, _, err := s.requireGuildPermission(ctx, formatSnowflake(*guildID), user, permissions.ViewGuild); err != nil {
return nil, err
}
}
frameID, err := s.requiredCosmetic(ctx, user.ID, guildID, input.Body.FrameID, store.CosmeticFrame)
if err != nil {
return nil, err
}
badgeID, err := s.requiredCosmetic(ctx, user.ID, guildID, input.Body.BadgeID, store.CosmeticBadge)
if err != nil {
return nil, err
}
effect := input.Body.NickEffect
if effect == "" {
effect = string(store.NickEffectNone)
}
if !store.ValidNickEffect(effect) {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "unknown nick effect")
}
nickColor := input.Body.NickColor
if nickColor != nil && !store.InNickColorPalette(*nickColor) {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "nick color is not in the palette")
}
style, err := s.store.UpsertUserStyle(ctx, store.UpsertUserStyleParams{
UserID: user.ID,
GuildID: guildID,
FrameID: frameID,
BadgeID: badgeID,
NickColor: nickColor,
NickEffect: effect,
})
if err != nil {
return nil, humaError(err)
}
grants, err := s.store.CosmeticGrantsForUsers(ctx, []uint64{user.ID}, nil)
if err != nil {
return nil, humaError(err)
}
cosmetics, err := s.store.ListGrantedCosmetics(ctx, user.ID)
if err != nil {
return nil, humaError(err)
}
frames := grantedIDs(cosmetics, grants[user.ID].Frames, store.CosmeticFrame)
badges := grantedIDs(cosmetics, grants[user.ID].Badges, store.CosmeticBadge)
scope := scopeGlobal
guildName := ""
if guildID != nil {
scope = formatSnowflake(*guildID)
}
output := &styleOutput{}
output.Body.Style = styleFromStore(style, scope, guildName, frames, badges, store.NickColorPalette())
s.dispatchStyleUpdate(user.ID, guildID)
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "deleteMyStyle",
Method: http.MethodDelete,
Path: "/users/@me/styles/{scope}",
Summary: "Сбросить личный стиль профиля",
Tags: []string{"Cosmetics"},
Security: security,
}, func(ctx context.Context, input *struct {
Scope string `path:"scope"`
},
) (*okOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
guildID, err := parseStyleScope(input.Scope)
if err != nil {
return nil, err
}
if err := s.store.DeleteUserStyle(ctx, user.ID, guildID); err != nil {
return nil, humaError(err)
}
s.dispatchStyleUpdate(user.ID, guildID)
return newOKOutput(), nil
})
}
// scopeOfStyle — область записанного стиля: `global` или id сервера.
func scopeOfStyle(style store.UserStyle) string {
if style.GuildID == nil {
return scopeGlobal
}
return formatSnowflake(*style.GuildID)
}
// parseStyleScope разбирает область стиля: `global` или идентификатор сервера.
func parseStyleScope(scope string) (*uint64, error) {
if scope == scopeGlobal {
return nil, nil
}
guildID, err := parseID("scope", scope)
if err != nil {
return nil, err
}
return &guildID, nil
}
// requiredCosmetic проверяет, что элемент галереи существует и выдан ролями
// пользователя (AGENT.md 7.2: выбирать можно только выданное).
func (s *Server) requiredCosmetic(ctx context.Context, userID uint64, guildID *uint64, rawID string, kind store.CosmeticKind) (*uint64, error) {
if rawID == "" {
return nil, nil
}
id, err := parseID("cosmetic_id", rawID)
if err != nil {
return nil, err
}
cosmetic, err := s.store.GetGuildCosmetic(ctx, id)
if err != nil {
return nil, humaError(err)
}
if cosmetic.Kind != kind {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "cosmetic kind mismatch")
}
grants, err := s.store.CosmeticGrantsForUsers(ctx, []uint64{userID}, guildID)
if err != nil {
return nil, humaError(err)
}
entry := grants[userID]
granted := entry.Frames
if kind == store.CosmeticBadge {
granted = entry.Badges
}
// Пер-серверный стиль ограничен ролями этого сервера.
if guildID != nil {
granted = entry.GuildFrames
if kind == store.CosmeticBadge {
granted = entry.GuildBadges
}
}
if !granted[id] {
return nil, humaErrorStatus(http.StatusForbidden, "cosmetic.not_granted", "cosmetic is not granted by your roles")
}
return &id, nil
}
// requireGuildCosmetic достаёт элемент галереи и проверяет его сервер.
func (s *Server) requireGuildCosmetic(ctx context.Context, guildID uint64, rawID string) (*store.GuildCosmetic, error) {
id, err := parseID("cosmetic_id", rawID)
if err != nil {
return nil, err
}
cosmetic, err := s.store.GetGuildCosmetic(ctx, id)
if err != nil {
return nil, humaError(err)
}
if cosmetic.GuildID != guildID {
return nil, humaErrorStatus(http.StatusNotFound, "not_found", "cosmetic not found")
}
return cosmetic, nil
}
// grantedIDs оставляет только выданные роли элементы нужного вида.
func grantedIDs(cosmetics []store.GuildCosmetic, granted map[uint64]bool, kind store.CosmeticKind) []string {
ids := make([]string, 0, len(granted))
for _, cosmetic := range cosmetics {
if cosmetic.Kind == kind && granted[cosmetic.ID] {
ids = append(ids, formatSnowflake(cosmetic.ID))
}
}
return ids
}
// dispatchGuildCosmeticsUpdate сообщает участникам, что галерея изменилась.
func (s *Server) dispatchGuildCosmeticsUpdate(guildID uint64) {
if s.gateway == nil {
return
}
s.dispatchGuildEvent(guildID, "GUILD_COSMETICS_UPDATE", map[string]any{
"guild_id": formatSnowflake(guildID),
})
}
// dispatchStyleUpdate сообщает всем сессиям пользователя о смене стиля.
func (s *Server) dispatchStyleUpdate(userID uint64, guildID *uint64) {
if s.gateway == nil {
return
}
payload := map[string]any{"user_id": formatSnowflake(userID)}
if guildID != nil {
payload["guild_id"] = formatSnowflake(*guildID)
}
s.gateway.SendToUser(userID, "USER_STYLE_UPDATE", payload)
}
// cosmeticFromStore собирает ответ по элементу галереи.
func cosmeticFromStore(cosmetic store.GuildCosmetic) cosmeticPayload {
return cosmeticPayload{
ID: formatSnowflake(cosmetic.ID),
GuildID: formatSnowflake(cosmetic.GuildID),
Kind: string(cosmetic.Kind),
Name: cosmetic.Name,
FileID: formatSnowflake(cosmetic.FileID),
CreatedAt: cosmetic.CreatedAt.UTC().Format(time.RFC3339),
}
}
// cosmeticsFromEffective собирает вычисленное оформление участника.
func cosmeticsFromEffective(effective store.EffectiveCosmetics) *cosmeticsPayload {
if effective.IsEmpty() {
return nil
}
payload := &cosmeticsPayload{NickEffect: effective.NickEffect}
if effective.FrameID != nil {
payload.FrameID = formatSnowflake(*effective.FrameID)
}
if effective.BadgeID != nil {
payload.BadgeID = formatSnowflake(*effective.BadgeID)
}
if effective.NickColor != nil {
payload.NickColor = effective.NickColor
}
if payload.NickEffect == string(store.NickEffectNone) {
payload.NickEffect = ""
}
return payload
}
// styleFromStore собирает ответ по области стиля: даже без записанного стиля
// запись отдаётся клиенту — в ней список доступных элементов (AGENT.md 7.2).
func styleFromStore(style *store.UserStyle, scope, guildName string, frames, badges []string, colors []int64) stylePayload {
payload := stylePayload{
Scope: scope,
GuildName: guildName,
FrameIDs: frames,
BadgeIDs: badges,
NickColors: colors,
Effects: effectNames(),
}
if scope != scopeGlobal {
payload.GuildID = scope
}
if payload.FrameIDs == nil {
payload.FrameIDs = []string{}
}
if payload.BadgeIDs == nil {
payload.BadgeIDs = []string{}
}
if payload.NickColors == nil {
payload.NickColors = []int64{}
}
if style == nil {
return payload
}
if style.FrameID != nil {
payload.Style.FrameID = formatSnowflake(*style.FrameID)
}
if style.BadgeID != nil {
payload.Style.BadgeID = formatSnowflake(*style.BadgeID)
}
if style.NickColor != nil {
payload.Style.NickColor = style.NickColor
}
if style.NickEffect != "" && style.NickEffect != string(store.NickEffectNone) {
payload.Style.NickEffect = style.NickEffect
}
return payload
}
// effectNames — встроенный набор эффектов ника.
func effectNames() []string {
effects := store.NickEffects()
names := make([]string, 0, len(effects))
for _, effect := range effects {
names = append(names, string(effect))
}
return names
}
// roleCosmeticsInput — оформление роли из запроса: пустая строка снимает
// элемент, `nil` оставляет как было (AGENT.md 7.4).
type roleCosmeticsInput struct {
FrameID string
BadgeID string
NickColor *int64
NickEffect string
FrameSet bool
BadgeSet bool
ColorSet bool
EffectSet bool
}
// applyRoleCosmetics проверяет и сохраняет оформление роли. Рамка и иконка
// обязаны принадлежать галерее этого сервера.
func (s *Server) applyRoleCosmetics(ctx context.Context, guildID uint64, role *store.Role, input roleCosmeticsInput) (*store.Role, error) {
params := store.UpdateRoleParams{}
if input.FrameSet {
if input.FrameID == "" {
params.ClearFrame = true
} else {
cosmetic, err := s.requireGuildCosmetic(ctx, guildID, input.FrameID)
if err != nil {
return nil, err
}
if cosmetic.Kind != store.CosmeticFrame {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "cosmetic is not a frame")
}
params.CosmeticFrameID = &cosmetic.ID
}
}
if input.BadgeSet {
if input.BadgeID == "" {
params.ClearBadge = true
} else {
cosmetic, err := s.requireGuildCosmetic(ctx, guildID, input.BadgeID)
if err != nil {
return nil, err
}
if cosmetic.Kind != store.CosmeticBadge {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "cosmetic is not a badge")
}
params.CosmeticBadgeID = &cosmetic.ID
}
}
if input.ColorSet {
if input.NickColor == nil {
params.ClearNickColor = true
} else {
if !store.InNickColorPalette(*input.NickColor) {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "nick color is not in the palette")
}
params.NickColor = input.NickColor
}
}
if input.EffectSet {
effect := input.NickEffect
if effect == "" {
effect = string(store.NickEffectNone)
}
if !store.ValidNickEffect(effect) {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "unknown nick effect")
}
params.NickEffect = &effect
}
if params.CosmeticFrameID == nil && params.CosmeticBadgeID == nil && params.NickColor == nil &&
params.NickEffect == nil && !params.ClearFrame && !params.ClearBadge && !params.ClearNickColor {
return role, nil
}
updated, err := s.store.UpdateRole(ctx, role.ID, params)
if err != nil {
return nil, humaError(err)
}
return updated, nil
}
// optionalString разыменовывает необязательную строку из тела запроса.
func optionalString(value *string) string {
if value == nil {
return ""
}
return *value
}
// 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
}
limit := s.mediaLimit(ctx, mediaGuildImage)
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 > limit {
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "background is too large")
return
}
data, err := io.ReadAll(io.LimitReader(file, limit+1))
if err != nil || int64(len(data)) > limit {
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, 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, nil)
s.dispatchChannelEvent(ctx, channelID, "CHANNEL_UPDATE", payload)
httpxWriteJSON(w, http.StatusOK, map[string]any{"channel": payload})
}