Files

324 lines
10 KiB
Go
Raw Permalink Normal View History

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 (
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
}
limit := s.mediaLimit(ctx, mediaEmoji)
r.Body = http.MaxBytesReader(w, r.Body, limit+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 > limit {
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "эмодзи больше 512 КБ")
return
}
data, err := io.ReadAll(io.LimitReader(file, limit+1))
if err != nil || int64(len(data)) > limit {
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: &currentUser.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> или <a:name:id>.
func (s *Server) emojiPayload(emoji *store.GuildEmoji) emojiPayload {
fileID := formatSnowflake(emoji.FileID)
token := "<:" + emoji.Name + ":" + fileID + ">"
if emoji.Animated {
token = "<a:" + emoji.Name + ":" + fileID + ">"
}
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)
}
}