ef871bcd96
- лимиты медиа живут в настройках инстанса (миграция 00016): аватары, оформление сервера, эмодзи, звуки и галерея; все загрузки берут предел оттуда, значения по умолчанию — из AGENT.md 7.7 - действия администратора: временный пароль (показывается один раз, сессии отзываются), выход со всех устройств, мягкое удаление пользователя (сообщения и аудит остаются), переименование и удаление любого сервера - админ-панель разбита на вкладки: обзор и здоровье, лимиты, пользователи, серверы, аудит; смена администраторов подтверждается личностью (step-up) - тесты: Go (действия администратора, лимит эмодзи из настроек) и Vitest (вкладки, сброс пароля, выход, удаление, переименование сервера)
427 lines
14 KiB
Go
427 lines
14 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/danielgtaylor/huma/v2"
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"glchat/internal/permissions"
|
|
"glchat/internal/store"
|
|
)
|
|
|
|
// Лимиты звуков сервера (AGENT.md 7.13).
|
|
const (
|
|
maxSoundboardSounds = 30
|
|
maxUISounds = 30
|
|
)
|
|
|
|
// soundEventPattern — допустимые имена событий звуковой палитры.
|
|
var soundEventPattern = regexp.MustCompile(`^[a-z_]{3,32}$`)
|
|
|
|
// soundNamePattern — имя звука (латиница, цифры, подчёркивания).
|
|
var soundNamePattern = regexp.MustCompile(`^[a-zA-Z0-9_]{2,32}$`)
|
|
|
|
type soundPayload struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
FileID string `json:"file_id"`
|
|
Kind string `json:"kind"`
|
|
Event string `json:"event,omitempty"`
|
|
Emoji string `json:"emoji,omitempty"`
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
type soundListOutput struct {
|
|
Body struct {
|
|
Sounds []soundPayload `json:"sounds"`
|
|
}
|
|
}
|
|
|
|
type soundOutput struct {
|
|
Body struct {
|
|
Sound soundPayload `json:"sound"`
|
|
}
|
|
}
|
|
|
|
// registerSoundsRoutes описывает саундборд и звуковую палитру (AGENT.md 7.13).
|
|
func (s *Server) registerSoundsRoutes(api huma.API, router chi.Router) {
|
|
security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}
|
|
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "listGuildSounds",
|
|
Method: http.MethodGet,
|
|
Path: "/guilds/{guild_id}/sounds",
|
|
Summary: "Звуки сервера (саундборд и палитра интерфейса)",
|
|
Tags: []string{"Sounds"},
|
|
Security: security,
|
|
}, func(ctx context.Context, input *struct {
|
|
GuildID string `path:"guild_id"`
|
|
Kind string `query:"kind,omitempty" enum:",soundboard,ui"`
|
|
},
|
|
) (*soundListOutput, 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
|
|
}
|
|
sounds, err := s.store.ListGuildSounds(ctx, guildID, store.SoundKind(input.Kind), 100)
|
|
if err != nil {
|
|
return nil, humaError(err)
|
|
}
|
|
output := &soundListOutput{}
|
|
output.Body.Sounds = make([]soundPayload, 0, len(sounds))
|
|
for i := range sounds {
|
|
output.Body.Sounds = append(output.Body.Sounds, s.soundPayload(&sounds[i]))
|
|
}
|
|
return output, nil
|
|
})
|
|
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "updateGuildSound",
|
|
Method: http.MethodPatch,
|
|
Path: "/guilds/{guild_id}/sounds/{sound_id}",
|
|
Summary: "Переименовать звук",
|
|
Tags: []string{"Sounds"},
|
|
Security: security,
|
|
}, func(ctx context.Context, input *struct {
|
|
GuildID string `path:"guild_id"`
|
|
SoundID string `path:"sound_id"`
|
|
Body struct {
|
|
Name string `json:"name" minLength:"2" maxLength:"32"`
|
|
Emoji string `json:"emoji,omitempty" maxLength:"8"`
|
|
}
|
|
},
|
|
) (*soundOutput, error) {
|
|
user, _, err := requireUser(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageSounds)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
soundID, err := parseID("sound_id", input.SoundID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sound, err := s.store.GetGuildSound(ctx, soundID)
|
|
if err != nil {
|
|
return nil, humaError(err)
|
|
}
|
|
if sound.GuildID != guildID {
|
|
return nil, humaErrorStatus(http.StatusNotFound, "not_found", "sound not found")
|
|
}
|
|
name := strings.TrimSpace(input.Body.Name)
|
|
if !soundNamePattern.MatchString(name) {
|
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "имя звука: латиница, цифры и подчёркивания (2–32)")
|
|
}
|
|
updated, err := s.store.RenameGuildSound(ctx, soundID, name, input.Body.Emoji)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrConflict) {
|
|
return nil, humaErrorStatus(http.StatusConflict, "sound.name_taken", "звук с таким именем уже есть")
|
|
}
|
|
return nil, humaError(err)
|
|
}
|
|
s.recordAudit(ctx, user, guildID, "sound.update", "sound", &soundID, "")
|
|
s.dispatchSoundsUpdate(ctx, guildID)
|
|
output := &soundOutput{}
|
|
output.Body.Sound = s.soundPayload(updated)
|
|
return output, nil
|
|
})
|
|
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "deleteGuildSound",
|
|
Method: http.MethodDelete,
|
|
Path: "/guilds/{guild_id}/sounds/{sound_id}",
|
|
Summary: "Удалить звук",
|
|
Tags: []string{"Sounds"},
|
|
Security: security,
|
|
}, func(ctx context.Context, input *struct {
|
|
GuildID string `path:"guild_id"`
|
|
SoundID string `path:"sound_id"`
|
|
},
|
|
) (*okOutput, error) {
|
|
user, _, err := requireUser(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageSounds)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
soundID, err := parseID("sound_id", input.SoundID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sound, err := s.store.GetGuildSound(ctx, soundID)
|
|
if err != nil {
|
|
return nil, humaError(err)
|
|
}
|
|
if sound.GuildID != guildID {
|
|
return nil, humaErrorStatus(http.StatusNotFound, "not_found", "sound not found")
|
|
}
|
|
if err := s.store.DeleteGuildSound(ctx, soundID); err != nil {
|
|
return nil, humaError(err)
|
|
}
|
|
s.deleteStoredFile(ctx, sound.FileID)
|
|
s.recordAudit(ctx, user, guildID, "sound.delete", "sound", &soundID, "")
|
|
s.dispatchSoundsUpdate(ctx, guildID)
|
|
return newOKOutput(), nil
|
|
})
|
|
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "playSoundboardSound",
|
|
Method: http.MethodPost,
|
|
Path: "/guilds/{guild_id}/sounds/{sound_id}/play",
|
|
Summary: "Проиграть звук саундборда в своей голосовой комнате",
|
|
Tags: []string{"Sounds"},
|
|
Security: security,
|
|
}, func(ctx context.Context, input *struct {
|
|
GuildID string `path:"guild_id"`
|
|
SoundID string `path:"sound_id"`
|
|
},
|
|
) (*okOutput, error) {
|
|
user, _, err := requireUser(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
guildID, resolved, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.UseSoundboard)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
soundID, err := parseID("sound_id", input.SoundID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sound, err := s.store.GetGuildSound(ctx, soundID)
|
|
if err != nil {
|
|
return nil, humaError(err)
|
|
}
|
|
if sound.GuildID != guildID || sound.Kind != store.SoundKindSoundboard {
|
|
return nil, humaErrorStatus(http.StatusNotFound, "not_found", "sound not found")
|
|
}
|
|
// Играть можно только из голосовой комнаты: звук слышат её участники.
|
|
voiceState, err := s.store.GetVoiceState(ctx, guildID, user.ID)
|
|
if err != nil {
|
|
return nil, humaErrorStatus(http.StatusForbidden, "voice.not_connected", "сначала войдите в голосовую комнату")
|
|
}
|
|
_ = resolved
|
|
if err := s.checkSoundboardRate(user.ID); err != nil {
|
|
return nil, err
|
|
}
|
|
payload := map[string]any{
|
|
"guild_id": formatSnowflake(guildID),
|
|
"channel_id": formatSnowflake(voiceState.ChannelID),
|
|
"sound_id": formatSnowflake(sound.ID),
|
|
"file_id": formatSnowflake(sound.FileID),
|
|
"name": sound.Name,
|
|
"user_id": formatSnowflake(user.ID),
|
|
"played_at": time.Now().UTC().Format(time.RFC3339),
|
|
}
|
|
// Событие получают только участники этой голосовой комнаты.
|
|
s.dispatchSoundboardPlay(ctx, guildID, voiceState.ChannelID, payload)
|
|
return newOKOutput(), nil
|
|
})
|
|
|
|
// Загрузка — multipart, поэтому chi-ручка.
|
|
router.Post("/guilds/{guild_id}/sounds", s.handleSoundUpload)
|
|
}
|
|
|
|
// handleSoundUpload принимает звук саундборда или палитры (AGENT.md 7.13).
|
|
func (s *Server) handleSoundUpload(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.ManageSounds)
|
|
if err != nil {
|
|
writeHumaAPIError(w, err)
|
|
return
|
|
}
|
|
|
|
kind := store.SoundKindSoundboard
|
|
if value := strings.TrimSpace(r.URL.Query().Get("kind")); value != "" {
|
|
kind = store.SoundKind(value)
|
|
}
|
|
if kind != store.SoundKindSoundboard && kind != store.SoundKindUI {
|
|
httpxWriteJSONError(w, http.StatusUnprocessableEntity, "validation.failed", "kind: soundboard или ui")
|
|
return
|
|
}
|
|
limit := maxSoundboardSounds
|
|
if kind == store.SoundKindUI {
|
|
limit = maxUISounds
|
|
}
|
|
count, err := s.store.CountGuildSounds(ctx, guildID, kind)
|
|
if err != nil {
|
|
writeHumaAPIError(w, humaError(err))
|
|
return
|
|
}
|
|
if count >= limit {
|
|
httpxWriteJSONError(w, http.StatusUnprocessableEntity, "sounds.limit_reached",
|
|
"на сервере достигнут лимит звуков")
|
|
return
|
|
}
|
|
|
|
sizeLimit := s.mediaLimit(ctx, mediaSound)
|
|
r.Body = http.MaxBytesReader(w, r.Body, sizeLimit+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 !soundNamePattern.MatchString(name) {
|
|
httpxWriteJSONError(w, http.StatusUnprocessableEntity, "validation.failed",
|
|
"имя звука: латиница, цифры и подчёркивания (2–32)")
|
|
return
|
|
}
|
|
event := strings.TrimSpace(r.FormValue("event"))
|
|
if kind == store.SoundKindUI {
|
|
if !soundEventPattern.MatchString(event) {
|
|
httpxWriteJSONError(w, http.StatusUnprocessableEntity, "validation.failed",
|
|
"для звука интерфейса укажите событие (например member_join)")
|
|
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 > sizeLimit {
|
|
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "звук больше 512 КБ")
|
|
return
|
|
}
|
|
data, err := io.ReadAll(io.LimitReader(file, sizeLimit+1))
|
|
if err != nil || int64(len(data)) > sizeLimit {
|
|
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "звук больше 512 КБ")
|
|
return
|
|
}
|
|
contentType := header.Header.Get("Content-Type")
|
|
if !strings.HasPrefix(contentType, "audio/") && !strings.HasPrefix(contentType, "video/ogg") {
|
|
contentType = http.DetectContentType(data)
|
|
}
|
|
switch {
|
|
case strings.HasPrefix(contentType, "audio/"),
|
|
strings.HasPrefix(contentType, "video/ogg"),
|
|
strings.HasPrefix(contentType, "application/ogg"):
|
|
default:
|
|
httpxWriteJSONError(w, http.StatusUnprocessableEntity, "validation.failed",
|
|
"поддерживаются MP3, OGG, WAV, WebM и M4A")
|
|
return
|
|
}
|
|
|
|
stored, err := s.saveUpload(ctx, store.File{
|
|
UploaderID: ¤tUser.ID,
|
|
GuildID: &guildID,
|
|
Filename: sanitizeFilename(header.Filename),
|
|
ContentType: contentType,
|
|
Purpose: "sound",
|
|
}, bytes.NewReader(data))
|
|
if err != nil {
|
|
writeHumaAPIError(w, err)
|
|
return
|
|
}
|
|
sound, err := s.store.CreateGuildSound(ctx, store.CreateGuildSoundParams{
|
|
GuildID: guildID,
|
|
Name: name,
|
|
FileID: stored.ID,
|
|
Kind: kind,
|
|
Event: event,
|
|
Emoji: strings.TrimSpace(r.FormValue("emoji")),
|
|
CreatorID: currentUser.ID,
|
|
})
|
|
if err != nil {
|
|
s.deleteStoredFile(ctx, stored.ID)
|
|
if errors.Is(err, store.ErrConflict) {
|
|
httpxWriteJSONError(w, http.StatusConflict, "sound.name_taken", "звук с таким именем уже есть")
|
|
return
|
|
}
|
|
writeHumaAPIError(w, humaError(err))
|
|
return
|
|
}
|
|
s.recordAudit(ctx, currentUser, guildID, "sound.create", "sound", &sound.ID, "")
|
|
s.dispatchSoundsUpdate(ctx, guildID)
|
|
httpxWriteJSON(w, http.StatusOK, map[string]any{"sound": s.soundPayload(sound)})
|
|
}
|
|
|
|
// soundPayload собирает звук для API.
|
|
func (s *Server) soundPayload(sound *store.GuildSound) soundPayload {
|
|
return soundPayload{
|
|
ID: formatSnowflake(sound.ID),
|
|
Name: sound.Name,
|
|
FileID: formatSnowflake(sound.FileID),
|
|
Kind: string(sound.Kind),
|
|
Event: sound.Event,
|
|
Emoji: sound.Emoji,
|
|
URL: s.cfg.FilesURL() + "/" + formatSnowflake(sound.FileID),
|
|
}
|
|
}
|
|
|
|
// checkSoundboardRate ограничивает частоту проигрывания (AGENT.md 8.6).
|
|
func (s *Server) checkSoundboardRate(userID uint64) error {
|
|
if allowed, retryAfter := s.soundboardLimiter.Allow("sound:" + formatSnowflake(userID)); !allowed {
|
|
return rateLimitedError(retryAfter)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// dispatchSoundboardPlay отправляет событие только участникам голосовой комнаты:
|
|
// звук воспроизводится локально, нагрузка на сервер нулевая (AGENT.md 7.13).
|
|
func (s *Server) dispatchSoundboardPlay(ctx context.Context, guildID, channelID uint64, payload map[string]any) {
|
|
if s.gateway == nil {
|
|
return
|
|
}
|
|
states, err := s.store.ListVoiceStates(ctx, guildID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, state := range states {
|
|
if state.ChannelID != channelID {
|
|
continue
|
|
}
|
|
s.gateway.SendToUser(state.UserID, "SOUNDBOARD_PLAY", payload)
|
|
}
|
|
}
|
|
|
|
// dispatchSoundsUpdate сообщает участникам сервера об изменении набора звуков.
|
|
func (s *Server) dispatchSoundsUpdate(ctx context.Context, guildID uint64) {
|
|
if s.gateway == nil {
|
|
return
|
|
}
|
|
sounds, err := s.store.ListGuildSounds(ctx, guildID, "", 100)
|
|
if err != nil {
|
|
return
|
|
}
|
|
payload := make([]soundPayload, 0, len(sounds))
|
|
for i := range sounds {
|
|
payload = append(payload, s.soundPayload(&sounds[i]))
|
|
}
|
|
members, err := s.store.ListGuildMembers(ctx, guildID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
event := map[string]any{"guild_id": formatSnowflake(guildID), "sounds": payload}
|
|
for _, member := range members {
|
|
s.gateway.SendToUser(member.UserID, "GUILD_SOUNDS_UPDATE", event)
|
|
}
|
|
}
|