package server import ( "bytes" "context" "errors" "io" "net/http" "regexp" "strings" "github.com/danielgtaylor/huma/v2" "github.com/go-chi/chi/v5" "glchat/internal/permissions" "glchat/internal/store" ) // Лимиты кастомных эмодзи (AGENT.md 7.12). const ( maxEmojiSize = 512 << 10 maxEmojisPerGuild = 100 ) // emojiNamePattern — допустимое имя эмодзи. var emojiNamePattern = regexp.MustCompile(`^[a-zA-Z0-9_]{2,32}$`) type emojiPayload struct { ID string `json:"id"` Name string `json:"name"` FileID string `json:"file_id"` Animated bool `json:"animated"` URL string `json:"url"` // Markdown-представление для вставки в текст и реакции. Token string `json:"token"` } type emojiListOutput struct { Body struct { Emojis []emojiPayload `json:"emojis"` } } type emojiOutput struct { Body struct { Emoji emojiPayload `json:"emoji"` } } // registerEmojiRoutes описывает кастомные эмодзи сервера (AGENT.md 7.12). func (s *Server) registerEmojiRoutes(api huma.API, router chi.Router) { security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}} huma.Register(api, huma.Operation{ OperationID: "listGuildEmojis", Method: http.MethodGet, Path: "/guilds/{guild_id}/emojis", Summary: "Эмодзи сервера", Tags: []string{"Emojis"}, Security: security, }, func(ctx context.Context, input *struct { GuildID string `path:"guild_id"` }, ) (*emojiListOutput, 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 } emojis, err := s.store.ListGuildEmojis(ctx, guildID, maxEmojisPerGuild) if err != nil { return nil, humaError(err) } output := &emojiListOutput{} output.Body.Emojis = make([]emojiPayload, 0, len(emojis)) for i := range emojis { output.Body.Emojis = append(output.Body.Emojis, s.emojiPayload(&emojis[i])) } return output, nil }) huma.Register(api, huma.Operation{ OperationID: "updateGuildEmoji", Method: http.MethodPatch, Path: "/guilds/{guild_id}/emojis/{emoji_id}", Summary: "Переименовать эмодзи", Tags: []string{"Emojis"}, Security: security, }, func(ctx context.Context, input *struct { GuildID string `path:"guild_id"` EmojiID string `path:"emoji_id"` Body struct { Name string `json:"name" minLength:"2" maxLength:"32"` } }, ) (*emojiOutput, error) { user, _, err := requireUser(ctx) if err != nil { return nil, err } guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageEmojis) if err != nil { return nil, err } emojiID, err := parseID("emoji_id", input.EmojiID) if err != nil { return nil, err } emoji, err := s.store.GetGuildEmoji(ctx, emojiID) if err != nil { return nil, humaError(err) } if emoji.GuildID != guildID { return nil, humaErrorStatus(http.StatusNotFound, "not_found", "emoji not found") } name := strings.TrimSpace(input.Body.Name) if !emojiNamePattern.MatchString(name) { return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "emoji name must match [a-zA-Z0-9_]{2,32}") } updated, err := s.store.RenameGuildEmoji(ctx, emojiID, name) if err != nil { if errors.Is(err, store.ErrConflict) { return nil, humaErrorStatus(http.StatusConflict, "emoji.name_taken", "эмодзи с таким именем уже есть") } return nil, humaError(err) } s.recordAudit(ctx, user, guildID, "emoji.update", "emoji", &emojiID, "") s.dispatchEmojisUpdate(ctx, guildID) output := &emojiOutput{} output.Body.Emoji = s.emojiPayload(updated) return output, nil }) huma.Register(api, huma.Operation{ OperationID: "deleteGuildEmoji", Method: http.MethodDelete, Path: "/guilds/{guild_id}/emojis/{emoji_id}", Summary: "Удалить эмодзи", Tags: []string{"Emojis"}, Security: security, }, func(ctx context.Context, input *struct { GuildID string `path:"guild_id"` EmojiID string `path:"emoji_id"` }, ) (*okOutput, error) { user, _, err := requireUser(ctx) if err != nil { return nil, err } guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageEmojis) if err != nil { return nil, err } emojiID, err := parseID("emoji_id", input.EmojiID) if err != nil { return nil, err } emoji, err := s.store.GetGuildEmoji(ctx, emojiID) if err != nil { return nil, humaError(err) } if emoji.GuildID != guildID { return nil, humaErrorStatus(http.StatusNotFound, "not_found", "emoji not found") } if err := s.store.DeleteGuildEmoji(ctx, emojiID); err != nil { return nil, humaError(err) } // Файл эмодзи больше не нужен: удаляем запись и содержимое. s.deleteStoredFile(ctx, emoji.FileID) s.recordAudit(ctx, user, guildID, "emoji.delete", "emoji", &emojiID, "") s.dispatchEmojisUpdate(ctx, guildID) return newOKOutput(), nil }) // Загрузка — multipart, поэтому chi-ручка (AGENT.md 8.2). router.Post("/guilds/{guild_id}/emojis", s.handleEmojiUpload) } // handleEmojiUpload принимает PNG/JPEG/WebP/GIF до 512 КБ (AGENT.md 7.12). func (s *Server) handleEmojiUpload(w http.ResponseWriter, r *http.Request) { currentUser, _, ok := s.authenticate(w, r) if !ok { return } ctx := r.Context() guildID, _, err := s.requireGuildPermission(ctx, chi.URLParam(r, "guild_id"), currentUser, permissions.ManageEmojis) if err != nil { writeHumaAPIError(w, err) return } count, err := s.store.CountGuildEmojis(ctx, guildID) if err != nil { writeHumaAPIError(w, humaError(err)) return } if count >= maxEmojisPerGuild { httpxWriteJSONError(w, http.StatusUnprocessableEntity, "emojis.limit_reached", "на сервере достигнут лимит эмодзи") return } r.Body = http.MaxBytesReader(w, r.Body, maxEmojiSize+maxMultipartOverhead) if err := r.ParseMultipartForm(maxMultipartMemory); err != nil { //nolint:gosec // объём ограничен MaxBytesReader httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", "malformed multipart body") return } defer func() { if r.MultipartForm != nil { _ = r.MultipartForm.RemoveAll() } }() name := strings.TrimSpace(r.FormValue("name")) if !emojiNamePattern.MatchString(name) { httpxWriteJSONError(w, http.StatusUnprocessableEntity, "validation.failed", "имя эмодзи должно состоять из латиницы, цифр и подчёркиваний (2–32 символа)") 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 > maxEmojiSize { httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "эмодзи больше 512 КБ") return } data, err := io.ReadAll(io.LimitReader(file, maxEmojiSize+1)) if err != nil || len(data) > maxEmojiSize { httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "эмодзи больше 512 КБ") return } contentType := header.Header.Get("Content-Type") if !strings.HasPrefix(contentType, "image/") { contentType = http.DetectContentType(data) } switch { case strings.HasPrefix(contentType, "image/png"), strings.HasPrefix(contentType, "image/jpeg"), strings.HasPrefix(contentType, "image/webp"), strings.HasPrefix(contentType, "image/gif"): default: httpxWriteJSONError(w, http.StatusUnprocessableEntity, "validation.failed", "поддерживаются PNG, JPEG, WebP и GIF") return } animated := strings.Contains(contentType, "gif") || strings.Contains(contentType, "webp") stored, err := s.saveUpload(ctx, store.File{ UploaderID: ¤tUser.ID, GuildID: &guildID, Filename: sanitizeFilename(header.Filename), ContentType: contentType, Purpose: "emoji", }, bytes.NewReader(data)) if err != nil { writeHumaAPIError(w, err) return } emoji, err := s.store.CreateGuildEmoji(ctx, store.CreateGuildEmojiParams{ GuildID: guildID, Name: name, FileID: stored.ID, Animated: animated, CreatorID: currentUser.ID, }) if err != nil { s.deleteStoredFile(ctx, stored.ID) if errors.Is(err, store.ErrConflict) { httpxWriteJSONError(w, http.StatusConflict, "emoji.name_taken", "эмодзи с таким именем уже есть") return } writeHumaAPIError(w, humaError(err)) return } s.recordAudit(ctx, currentUser, guildID, "emoji.create", "emoji", &emoji.ID, "") s.dispatchEmojisUpdate(ctx, guildID) httpxWriteJSON(w, http.StatusOK, map[string]any{"emoji": s.emojiPayload(emoji)}) } // emojiPayload собирает эмодзи для API: токен вида <:name:id> или . func (s *Server) emojiPayload(emoji *store.GuildEmoji) emojiPayload { fileID := formatSnowflake(emoji.FileID) token := "<:" + emoji.Name + ":" + fileID + ">" if emoji.Animated { token = "" } return emojiPayload{ ID: formatSnowflake(emoji.ID), Name: emoji.Name, FileID: fileID, Animated: emoji.Animated, URL: s.cfg.FilesURL() + "/" + fileID, Token: token, } } // dispatchEmojisUpdate сообщает участникам сервера об изменении набора эмодзи. func (s *Server) dispatchEmojisUpdate(ctx context.Context, guildID uint64) { if s.gateway == nil { return } emojis, err := s.store.ListGuildEmojis(ctx, guildID, maxEmojisPerGuild) if err != nil { return } payload := make([]emojiPayload, 0, len(emojis)) for i := range emojis { payload = append(payload, s.emojiPayload(&emojis[i])) } members, err := s.store.ListGuildMembers(ctx, guildID) if err != nil { return } event := map[string]any{"guild_id": formatSnowflake(guildID), "emojis": payload} for _, member := range members { s.gateway.SendToUser(member.UserID, "GUILD_EMOJIS_UPDATE", event) } }