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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user