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 }