324 lines
10 KiB
Go
324 lines
10 KiB
Go
|
|
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> или <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)
|
||
|
|
}
|
||
|
|
}
|