feat(cosmetics): оформление профиля — галерея, роли и личные стили

- миграция 00013: guild_cosmetics (рамки и иконки) и user_styles («для друзей»
  и пер-серверные стили) (AGENT.md 7.2, 7.4)
- store: CRUD галереи, личные стили и вычисление оформления поэлементно
  (роль → пер-серверный стиль → «для друзей» → дефолт) с откатом, когда роль
  больше не выдаёт элемент
- API: галерея сервера под MANAGE_ROLES, оформление роли в create/update,
  GET/PUT/DELETE /users/@me/styles с доступными элементами по областям
- клиент: редактор личного стиля с предпросмотром, галерея оформления в
  настройках сервера, оформление роли в разделе «Роли»
- рендер: рамка под аватаром, иконка рядом с ником, цвет и встроенные эффекты
  ника (CSS, при reduced-motion статично) в ленте и списке участников
This commit is contained in:
2026-09-21 01:28:48 +03:00
parent 2e038a1d3f
commit 48e30a1897
25 changed files with 3542 additions and 41 deletions
+753
View File
@@ -0,0 +1,753 @@
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
// maxCosmeticSize — предел размера картинки оформления.
maxCosmeticSize = 5 << 20
// 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
}
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 > maxCosmeticSize {
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "cosmetic is too large")
return
}
data, err := io.ReadAll(io.LimitReader(file, maxCosmeticSize+1))
if err != nil || int64(len(data)) > maxCosmeticSize {
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],
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]
payload := styleFromStore(byScope[scope],
grantedIDs(cosmetics, entry.GuildFrames, store.CosmeticFrame),
grantedIDs(cosmetics, entry.GuildBadges, store.CosmeticBadge),
colors)
payload.GuildName = guilds[i].Name
output.Body.Styles = append(output.Body.Styles, payload)
}
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)
output := &styleOutput{}
output.Body.Style = styleFromStore(style, 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 собирает ответ по личному стилю.
func styleFromStore(style *store.UserStyle, frames, badges []string, colors []int64) stylePayload {
payload := stylePayload{
Scope: scopeGlobal,
FrameIDs: frames,
BadgeIDs: badges,
NickColors: colors,
Effects: effectNames(),
}
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.GuildID != nil {
payload.Scope = formatSnowflake(*style.GuildID)
payload.GuildID = formatSnowflake(*style.GuildID)
}
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
}
+67 -7
View File
@@ -41,6 +41,11 @@ type rolePayload struct {
IsDefault bool `json:"is_default"`
Hoist bool `json:"hoist"`
Mentionable bool `json:"mentionable"`
// Оформление роли (AGENT.md 7.4): рамка, иконка, цвет и эффект ника.
CosmeticFrameID string `json:"cosmetic_frame_id,omitempty"`
CosmeticBadgeID string `json:"cosmetic_badge_id,omitempty"`
NickColor *int64 `json:"nick_color,omitempty"`
NickEffect string `json:"nick_effect,omitempty"`
}
type memberPayload struct {
@@ -55,6 +60,8 @@ type memberPayload struct {
JoinedAt string `json:"joined_at"`
TimeoutUntil string `json:"timeout_until,omitempty"`
RoleIDs []string `json:"role_ids"`
// Вычисленное оформление участника на этом сервере (AGENT.md 7.2).
Cosmetics *cosmeticsPayload `json:"cosmetics,omitempty"`
}
type guildDetailPayload struct {
@@ -99,7 +106,7 @@ func channelFromStore(channel store.Channel, resolved *permissions.Resolved) cha
}
func roleFromStore(role store.Role) rolePayload {
return rolePayload{
payload := rolePayload{
ID: formatSnowflake(role.ID),
Name: role.Name,
Color: role.Color,
@@ -108,7 +115,18 @@ func roleFromStore(role store.Role) rolePayload {
IsDefault: role.IsDefault,
Hoist: role.Hoist,
Mentionable: role.Mentionable,
NickColor: role.NickColor,
}
if role.CosmeticFrameID != nil {
payload.CosmeticFrameID = formatSnowflake(*role.CosmeticFrameID)
}
if role.CosmeticBadgeID != nil {
payload.CosmeticBadgeID = formatSnowflake(*role.CosmeticBadgeID)
}
if role.NickEffect != "" && role.NickEffect != string(store.NickEffectNone) {
payload.NickEffect = role.NickEffect
}
return payload
}
type guildOutput struct {
@@ -743,13 +761,20 @@ func (s *Server) registerMemberRoutes(api huma.API) {
if err != nil {
return nil, humaError(err)
}
// Оформление считается поэлементно для всего сервера (AGENT.md 7.2).
cosmetics, err := s.store.EffectiveCosmeticsForGuild(ctx, guildID)
if err != nil {
return nil, humaError(err)
}
output := &memberListOutput{}
output.Body.Members = make([]memberPayload, 0, len(profiles))
for _, profile := range profiles {
if len(output.Body.Members) >= input.Limit {
break
}
output.Body.Members = append(output.Body.Members, memberFromProfile(profile))
payload := memberFromProfile(profile)
payload.Cosmetics = cosmeticsFromEffective(cosmetics[profile.UserID])
output.Body.Members = append(output.Body.Members, payload)
}
return output, nil
})
@@ -1098,11 +1123,15 @@ func (s *Server) registerRoleRoutes(api huma.API) {
}, func(ctx context.Context, input *struct {
GuildID string `path:"guild_id"`
Body struct {
Name string `json:"name" minLength:"1" maxLength:"64"`
Permissions string `json:"permissions,omitempty"`
Color int64 `json:"color,omitempty" minimum:"0" maximum:"16777215"`
Hoist bool `json:"hoist,omitempty"`
Mentionable bool `json:"mentionable,omitempty"`
Name string `json:"name" minLength:"1" maxLength:"64"`
Permissions string `json:"permissions,omitempty"`
Color int64 `json:"color,omitempty" minimum:"0" maximum:"16777215"`
Hoist bool `json:"hoist,omitempty"`
Mentionable bool `json:"mentionable,omitempty"`
CosmeticFrameID string `json:"cosmetic_frame_id,omitempty"`
CosmeticBadgeID string `json:"cosmetic_badge_id,omitempty"`
NickColor *int64 `json:"nick_color,omitempty"`
NickEffect string `json:"nick_effect,omitempty"`
}
},
) (*roleOutput, error) {
@@ -1136,6 +1165,19 @@ func (s *Server) registerRoleRoutes(api huma.API) {
if err != nil {
return nil, humaError(err)
}
// Оформление роли (AGENT.md 7.4) сохраняем тем же обработчиком правки.
if role, err = s.applyRoleCosmetics(ctx, guildID, role, roleCosmeticsInput{
FrameID: input.Body.CosmeticFrameID,
BadgeID: input.Body.CosmeticBadgeID,
NickColor: input.Body.NickColor,
NickEffect: input.Body.NickEffect,
FrameSet: input.Body.CosmeticFrameID != "",
BadgeSet: input.Body.CosmeticBadgeID != "",
ColorSet: input.Body.NickColor != nil,
EffectSet: input.Body.NickEffect != "",
}); err != nil {
return nil, err
}
s.invalidateGuild(guildID)
s.recordAudit(ctx, user, guildID, "role.create", "role", &role.ID, "")
s.dispatchGuildEvent(guildID, "ROLE_CREATE", roleFromStore(*role))
@@ -1161,6 +1203,11 @@ func (s *Server) registerRoleRoutes(api huma.API) {
Position *int `json:"position,omitempty" minimum:"0" maximum:"1000"`
Hoist *bool `json:"hoist,omitempty"`
Mentionable *bool `json:"mentionable,omitempty"`
// Оформление роли (AGENT.md 7.4): пустая строка снимает элемент.
CosmeticFrameID *string `json:"cosmetic_frame_id,omitempty"`
CosmeticBadgeID *string `json:"cosmetic_badge_id,omitempty"`
NickColor *int64 `json:"nick_color,omitempty"`
NickEffect *string `json:"nick_effect,omitempty"`
}
},
) (*roleOutput, error) {
@@ -1208,6 +1255,19 @@ func (s *Server) registerRoleRoutes(api huma.API) {
if err != nil {
return nil, humaError(err)
}
// Оформление роли (AGENT.md 7.4): рамка, иконка, цвет и эффект ника.
if updated, err = s.applyRoleCosmetics(ctx, guildID, updated, roleCosmeticsInput{
FrameID: optionalString(input.Body.CosmeticFrameID),
BadgeID: optionalString(input.Body.CosmeticBadgeID),
NickColor: input.Body.NickColor,
NickEffect: optionalString(input.Body.NickEffect),
FrameSet: input.Body.CosmeticFrameID != nil,
BadgeSet: input.Body.CosmeticBadgeID != nil,
ColorSet: input.Body.NickColor != nil,
EffectSet: input.Body.NickEffect != nil,
}); err != nil {
return nil, err
}
s.invalidateGuild(guildID)
s.recordAudit(ctx, user, guildID, "role.update", "role", &roleID, "")
s.dispatchGuildEvent(guildID, "ROLE_UPDATE", roleFromStore(*updated))
+12
View File
@@ -36,6 +36,11 @@ type profilePayload struct {
Timezone string `json:"timezone"`
// LastSeenAt — когда пользователя видели последний раз (для друзей).
LastSeenAt string `json:"last_seen_at,omitempty"`
// CreatedAt — дата регистрации: показывается в чужом профиле (AGENT.md 7.2).
CreatedAt string `json:"created_at,omitempty"`
// Cosmetics — оформление «для друзей»: применяется в DM, друзьях и
// глобальных местах (AGENT.md 7.2).
Cosmetics *cosmeticsPayload `json:"cosmetics,omitempty"`
}
// profileFromUser собирает профиль; состояние 2FA читается из сервиса
@@ -81,6 +86,9 @@ func profileFromUser(user *store.User, includePrivate bool) profilePayload {
if user.LastSeenAt != nil {
payload.LastSeenAt = user.LastSeenAt.UTC().Format(time.RFC3339)
}
if !user.CreatedAt.IsZero() {
payload.CreatedAt = user.CreatedAt.UTC().Format(time.RFC3339)
}
return payload
}
@@ -359,6 +367,10 @@ func (s *Server) registerUserRoutes(api huma.API) {
}
output := &userOutput{}
output.Body.User = s.profileFromUser(ctx, user, false)
// Оформление «для друзей»: в чужом профиле видно глобальный стиль (7.2).
if cosmetics, err := s.store.EffectiveCosmeticsForUser(ctx, user.ID); err == nil {
output.Body.User.Cosmetics = cosmeticsFromEffective(cosmetics)
}
return output, nil
})
+304
View File
@@ -0,0 +1,304 @@
package server
import (
"bytes"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"glchat/internal/store"
)
// Тесты оформления профиля (AGENT.md 7.2, 7.4): галерея сервера, оформление
// роли, личные стили и поэлементный приоритет с откатом при потере роли.
// uploadCosmetic загружает рамку или иконку в галерею сервера.
func uploadCosmetic(t *testing.T, srv *Server, guildID, kind, name string, cookie *http.Cookie) (id string, status int) {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
if err := writer.WriteField("kind", kind); err != nil {
t.Fatalf("multipart kind: %v", err)
}
if err := writer.WriteField("name", name); err != nil {
t.Fatalf("multipart name: %v", err)
}
part, err := writer.CreateFormFile("file", name+".png")
if err != nil {
t.Fatalf("multipart file: %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/guilds/"+guildID+"/cosmetics", &body)
request.Header.Set("Content-Type", writer.FormDataContentType())
request.AddCookie(cookie)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, request)
if rec.Code != http.StatusOK {
return "", rec.Code
}
payload := decodeResponse[struct {
Cosmetic struct {
ID string `json:"id"`
} `json:"cosmetic"`
}](t, rec)
return payload.Cosmetic.ID, rec.Code
}
type effectiveCosmetics struct {
FrameID string `json:"frame_id"`
BadgeID string `json:"badge_id"`
NickColor *int64 `json:"nick_color"`
NickEffect string `json:"nick_effect"`
}
// memberCosmetics читает вычисленное оформление участника из списка участников.
func memberCosmetics(t *testing.T, srv *Server, guildID, userID string, cookie *http.Cookie) effectiveCosmetics {
t.Helper()
rec := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/members", "", cookie)
if rec.Code != http.StatusOK {
t.Fatalf("members = %d (%s)", rec.Code, rec.Body.String())
}
list := decodeResponse[struct {
Members []struct {
UserID string `json:"user_id"`
Cosmetics *effectiveCosmetics `json:"cosmetics"`
} `json:"members"`
}](t, rec)
for _, member := range list.Members {
if member.UserID == userID {
if member.Cosmetics == nil {
return effectiveCosmetics{}
}
return *member.Cosmetics
}
}
t.Fatalf("участник %s не найден", userID)
return effectiveCosmetics{}
}
// cosmeticsFixture — сервер с владельцем и участником.
func cosmeticsFixture(t *testing.T) (srv *Server, owner, member *http.Cookie, guildID, memberID string) {
t.Helper()
srv, owner, member, guildID, memberID = banFixture(t)
return srv, owner, member, guildID, memberID
}
// styleScopePath собирает путь личного стиля.
func styleScopePath(scope string) string {
return "/api/v1/users/@me/styles/" + url.PathEscape(scope)
}
func TestCosmeticGalleryLifecycle(t *testing.T) {
srv, owner, member, guildID, _ := cosmeticsFixture(t)
frameID, status := uploadCosmetic(t, srv, guildID, "frame", "Золотая", owner)
if status != http.StatusOK || frameID == "" {
t.Fatalf("загрузка рамки: статус %d", status)
}
badgeID, status := uploadCosmetic(t, srv, guildID, "badge", "Щит", owner)
if status != http.StatusOK || badgeID == "" {
t.Fatalf("загрузка иконки: статус %d", status)
}
// Галерею видят участники сервера, а не посторонние.
list := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/cosmetics", "", member)
if list.Code != http.StatusOK {
t.Fatalf("список галереи = %d (%s)", list.Code, list.Body.String())
}
payload := decodeResponse[struct {
Cosmetics []struct {
ID string `json:"id"`
Kind string `json:"kind"`
Name string `json:"name"`
} `json:"cosmetics"`
}](t, list)
if len(payload.Cosmetics) != 2 {
t.Fatalf("в галерее %d элементов, ожидалось 2", len(payload.Cosmetics))
}
// Участник без MANAGE_ROLES не может пополнять галерею.
if _, status := uploadCosmetic(t, srv, guildID, "frame", "Чужая", member); status != http.StatusForbidden {
t.Fatalf("загрузка участником: статус %d, ожидался 403", status)
}
// Переименование и удаление.
renamed := doJSON(t, srv, http.MethodPatch, "/api/v1/guilds/"+guildID+"/cosmetics/"+badgeID,
`{"name":"Новый щит"}`, owner)
if renamed.Code != http.StatusOK {
t.Fatalf("переименование = %d (%s)", renamed.Code, renamed.Body.String())
}
deleted := doJSON(t, srv, http.MethodDelete, "/api/v1/guilds/"+guildID+"/cosmetics/"+badgeID, "", owner)
if deleted.Code != http.StatusOK {
t.Fatalf("удаление = %d (%s)", deleted.Code, deleted.Body.String())
}
after := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/cosmetics", "", owner)
remaining := decodeResponse[struct {
Cosmetics []struct {
ID string `json:"id"`
} `json:"cosmetics"`
}](t, after)
if len(remaining.Cosmetics) != 1 || remaining.Cosmetics[0].ID != frameID {
t.Fatalf("после удаления осталось %d элементов", len(remaining.Cosmetics))
}
}
func TestRoleCosmeticsAndPriority(t *testing.T) {
srv, owner, member, guildID, memberID := cosmeticsFixture(t)
frameID, _ := uploadCosmetic(t, srv, guildID, "frame", "Золотая", owner)
badgeID, _ := uploadCosmetic(t, srv, guildID, "badge", "Щит", owner)
// Роль с рамкой и цветом ника, вторая роль — с иконкой: элементы
// складываются поэлементно (AGENT.md 7.2, 7.4).
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guildID+"/roles",
`{"name":"Хранитель","cosmetic_frame_id":"`+frameID+`","nick_color":15158332}`, owner)
if created.Code != http.StatusOK {
t.Fatalf("создание роли = %d (%s)", created.Code, created.Body.String())
}
role := decodeResponse[struct {
Role struct {
ID string `json:"id"`
CosmeticFrameID string `json:"cosmetic_frame_id"`
NickColor *int64 `json:"nick_color"`
} `json:"role"`
}](t, created)
if role.Role.CosmeticFrameID != frameID || role.Role.NickColor == nil {
t.Fatalf("оформление роли не сохранилось: %s", created.Body.String())
}
second := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guildID+"/roles",
`{"name":"Щитоносец","cosmetic_badge_id":"`+badgeID+`"}`, owner)
roleWithBadge := decodeResponse[struct {
Role struct {
ID string `json:"id"`
} `json:"role"`
}](t, second).Role.ID
for _, roleID := range []string{role.Role.ID, roleWithBadge} {
granted := doJSON(t, srv, http.MethodPut,
"/api/v1/guilds/"+guildID+"/members/"+memberID+"/roles/"+roleID, "", owner)
if granted.Code != http.StatusOK {
t.Fatalf("выдача роли %s = %d (%s)", roleID, granted.Code, granted.Body.String())
}
}
// Оформление ролей применяется: рамка, иконка и цвет ника.
effective := memberCosmetics(t, srv, guildID, memberID, member)
if effective.FrameID != frameID || effective.BadgeID != badgeID {
t.Fatalf("оформление ролей: %+v", effective)
}
if effective.NickColor == nil || *effective.NickColor != 15158332 {
t.Fatalf("цвет ника из роли = %v", effective.NickColor)
}
// Личный стиль добавляет эффект ника, элементы ролей остаются.
style := doJSON(t, srv, http.MethodPut, styleScopePath(guildID),
`{"frame_id":"`+frameID+`","badge_id":"`+badgeID+`","nick_effect":"glow"}`, member)
if style.Code != http.StatusOK {
t.Fatalf("личный стиль = %d (%s)", style.Code, style.Body.String())
}
effective = memberCosmetics(t, srv, guildID, memberID, member)
if effective.FrameID != frameID || effective.BadgeID != badgeID || effective.NickEffect != "glow" {
t.Fatalf("после личного стиля: %+v", effective)
}
// Потеря роли с рамкой: рамка исчезает, иконка и эффект остаются.
revoked := doJSON(t, srv, http.MethodDelete,
"/api/v1/guilds/"+guildID+"/members/"+memberID+"/roles/"+role.Role.ID, "", owner)
if revoked.Code != http.StatusOK {
t.Fatalf("снятие роли = %d (%s)", revoked.Code, revoked.Body.String())
}
effective = memberCosmetics(t, srv, guildID, memberID, member)
if effective.FrameID != "" {
t.Fatalf("рамка осталась после снятия роли: %+v", effective)
}
if effective.BadgeID != badgeID || effective.NickEffect != "glow" {
t.Fatalf("иконка или эффект потерялись: %+v", effective)
}
// Потеря роли с иконкой откатывает и личный элемент: он больше не выдан.
if rec := doJSON(t, srv, http.MethodDelete,
"/api/v1/guilds/"+guildID+"/members/"+memberID+"/roles/"+roleWithBadge, "", owner); rec.Code != http.StatusOK {
t.Fatalf("снятие второй роли = %d", rec.Code)
}
effective = memberCosmetics(t, srv, guildID, memberID, member)
if effective.BadgeID != "" {
t.Fatalf("иконка осталась без роли: %+v", effective)
}
if effective.NickEffect != "glow" {
t.Fatalf("эффект ника потерялся: %q", effective.NickEffect)
}
}
func TestPersonalStyleAvailability(t *testing.T) {
srv, owner, member, guildID, memberID := cosmeticsFixture(t)
frameID, _ := uploadCosmetic(t, srv, guildID, "frame", "Золотая", owner)
// Рамка не выдана ролями: выбрать её нельзя.
if rec := doJSON(t, srv, http.MethodPut, styleScopePath(guildID),
`{"frame_id":"`+frameID+`"}`, member); rec.Code != http.StatusForbidden {
t.Fatalf("недоступная рамка = %d (%s)", rec.Code, rec.Body.String())
}
// Цвет вне палитры отклоняется.
if rec := doJSON(t, srv, http.MethodPut, styleScopePath("global"),
`{"nick_color":1}`, member); rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("цвет вне палитры = %d (%s)", rec.Code, rec.Body.String())
}
// Неизвестный эффект отклоняется.
if rec := doJSON(t, srv, http.MethodPut, styleScopePath("global"),
`{"nick_effect":"rainbow"}`, member); rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("неизвестный эффект = %d (%s)", rec.Code, rec.Body.String())
}
// Выдаём роль с рамкой и иконкой.
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guildID+"/roles",
`{"name":"Хранитель","cosmetic_frame_id":"`+frameID+`"}`, owner)
roleID := decodeResponse[struct {
Role struct {
ID string `json:"id"`
} `json:"role"`
}](t, created).Role.ID
if rec := doJSON(t, srv, http.MethodPut,
"/api/v1/guilds/"+guildID+"/members/"+memberID+"/roles/"+roleID, "", owner); rec.Code != http.StatusOK {
t.Fatalf("выдача роли = %d", rec.Code)
}
// Теперь рамка доступна и в пер-серверном, и в глобальном стиле.
for _, scope := range []string{guildID, "global"} {
rec := doJSON(t, srv, http.MethodPut, styleScopePath(scope),
`{"frame_id":"`+frameID+`","nick_effect":"gradient"}`, member)
if rec.Code != http.StatusOK {
t.Fatalf("стиль %s = %d (%s)", scope, rec.Code, rec.Body.String())
}
}
// Список стилей отдаёт доступные элементы и встроенный набор эффектов.
styles := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/styles", "", member)
list := decodeResponse[struct {
Styles []struct {
Scope string `json:"scope"`
FrameIDs []string `json:"frame_ids"`
Effects []string `json:"effects"`
} `json:"styles"`
}](t, styles)
if len(list.Styles) != 2 {
t.Fatalf("стилей %d, ожидалось 2", len(list.Styles))
}
if len(list.Styles[0].FrameIDs) != 1 || list.Styles[0].FrameIDs[0] != frameID {
t.Fatalf("доступные рамки: %+v", list.Styles[0].FrameIDs)
}
if len(list.Styles[0].Effects) != len(store.NickEffects()) {
t.Fatalf("эффектов %d", len(list.Styles[0].Effects))
}
// Сброс стиля: глобальный стиль больше не применяется.
if rec := doJSON(t, srv, http.MethodDelete, styleScopePath("global"), "", member); rec.Code != http.StatusOK {
t.Fatalf("сброс стиля = %d", rec.Code)
}
}
+1
View File
@@ -153,6 +153,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
s.registerVoiceRoutes(s.api)
s.registerSoundsRoutes(s.api, apiRouter)
s.registerWebhookRoutes(s.api, apiRouter)
s.registerCosmeticRoutes(s.api, apiRouter)
s.registerVoiceWebhook(apiRouter)
}
apiRouter.Get("/openapi.json", s.handleOpenAPI)