feat(webhooks): вебхуки комнат — бэкенд, клиент и настройки комнаты

- миграция 00012: таблица webhooks, снимок имени и аватара в messages (AGENT.md 7.11)
- API: список/создание/правка/удаление и пересоздание токена (MANAGE_WEBHOOKS,
  step-up при создании, аудит), загрузка аватара отдельной multipart-ручкой
- исполнение POST /webhooks/{id}/{token} без сессии: content, username,
  avatar_url, файлы создателя вебхука, лимит 30/мин на вебхук
- клиент: пункт настроек «Комната» со списком вебхуков, копированием ссылки,
  пересозданием токена и удалением
- сообщения вебхуков: имя, аватар и значок в ленте вместо «неизвестного автора»
- fix: неполный ответ REST больше не затирает данные READY-снапшота
This commit is contained in:
2026-09-21 00:26:50 +03:00
parent 4ea2965892
commit 2e038a1d3f
32 changed files with 2494 additions and 23 deletions
+2 -1
View File
@@ -300,7 +300,8 @@ func (s *Server) requireFileAccess(ctx context.Context, rawFileID string, user *
}
// Аватары и баннеры видны всем авторизованным: они показываются в списках
// участников и друзей (AGENT.md 7.2).
if file.Purpose == "avatar" || file.Purpose == "banner" || file.Purpose == "emoji" || file.Purpose == "sound" {
if file.Purpose == "avatar" || file.Purpose == "banner" || file.Purpose == "emoji" ||
file.Purpose == "sound" || file.Purpose == "webhook_avatar" {
return file, nil
}
if file.ChannelID != nil {
+10
View File
@@ -58,6 +58,11 @@ type messagePayload struct {
Mentions []string `json:"mentions"`
Reactions []reactionPayload `json:"reactions"`
CreatedAt string `json:"created_at"`
// Поля вебхука (AGENT.md 7.11): у таких сообщений нет автора-пользователя,
// а имя и аватар отправителя лежат в самом сообщении.
WebhookID string `json:"webhook_id,omitempty"`
WebhookName string `json:"webhook_name,omitempty"`
WebhookAvatar string `json:"webhook_avatar,omitempty"`
}
type messageListOutput struct {
@@ -861,6 +866,11 @@ func (s *Server) messagePayload(ctx context.Context, message *store.Message, vie
if message.AuthorID != nil {
payload.AuthorID = formatSnowflake(*message.AuthorID)
}
if message.WebhookID != nil {
payload.WebhookID = formatSnowflake(*message.WebhookID)
}
payload.WebhookName = message.WebhookName
payload.WebhookAvatar = message.WebhookAvatar
if message.ReplyToID != nil {
payload.ReplyToID = formatSnowflake(*message.ReplyToID)
}
+576
View File
@@ -0,0 +1,576 @@
package server
import (
"bytes"
"context"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/go-chi/chi/v5"
"glchat/internal/permissions"
"glchat/internal/store"
)
// Лимиты вебхуков (AGENT.md 7.11, 8.6).
const (
// maxWebhooksPerChannel — сколько вебхуков можно завести в одной комнате.
maxWebhooksPerChannel = 20
// maxWebhookNameLength — длина имени вебхука.
maxWebhookNameLength = 80
// webhookRateLimit — 30 сообщений в минуту на вебхук.
webhookRateLimit = 30
// maxWebhookAvatarLength — предел для ссылки на аватар в запросе.
maxWebhookAvatarLength = 512
// maxWebhookAvatarSize — предел размера картинки аватара (2 МБ).
maxWebhookAvatarSize = 2 << 20
)
type webhookPayload struct {
ID string `json:"id"`
GuildID string `json:"guild_id"`
ChannelID string `json:"channel_id"`
Name string `json:"name"`
AvatarFileID string `json:"avatar_file_id,omitempty"`
Token string `json:"token"`
CreatedBy string `json:"created_by,omitempty"`
CreatedAt string `json:"created_at"`
// URL — готовая ссылка исполнения: клиенту не нужно собирать её самому.
URL string `json:"url"`
}
type webhookListOutput struct {
Body struct {
Webhooks []webhookPayload `json:"webhooks"`
}
}
type webhookOutput struct {
Body struct {
Webhook webhookPayload `json:"webhook"`
}
}
type webhookAvatarPayload struct {
FileID string `json:"file_id,omitempty"`
Name string `json:"name"`
URL string `json:"url,omitempty"`
}
type webhookMessageOutput struct {
Body struct {
Message messagePayload `json:"message"`
Webhook webhookAvatarPayload `json:"webhook"`
}
}
// registerWebhookRoutes описывает вебхуки комнат (AGENT.md 7.11): управление
// требует MANAGE_WEBHOOKS и step-up, исполнение доступно без сессии по токену.
func (s *Server) registerWebhookRoutes(api huma.API, router chi.Router) {
security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}
// Аватар вебхука — multipart, поэтому ручка живёт на роутере (huma не
// принимает формы): файл хранится обычной загрузкой с purpose.
router.Post("/channels/{channel_id}/webhooks/avatar", s.handleWebhookAvatarUpload)
huma.Register(api, huma.Operation{
OperationID: "listChannelWebhooks",
Method: http.MethodGet,
Path: "/channels/{channel_id}/webhooks",
Summary: "Вебхуки комнаты",
Tags: []string{"Webhooks"},
Security: security,
}, func(ctx context.Context, input *struct {
ChannelID string `path:"channel_id"`
},
) (*webhookListOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
channelID, _, _, err := s.requireChannelPermission(ctx, input.ChannelID, user, permissions.ManageWebhooks)
if err != nil {
return nil, err
}
webhooks, err := s.store.ListChannelWebhooks(ctx, channelID)
if err != nil {
return nil, humaError(err)
}
output := &webhookListOutput{}
output.Body.Webhooks = make([]webhookPayload, 0, len(webhooks))
for i := range webhooks {
output.Body.Webhooks = append(output.Body.Webhooks, s.webhookPayload(&webhooks[i]))
}
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "createWebhook",
Method: http.MethodPost,
Path: "/channels/{channel_id}/webhooks",
Summary: "Создать вебхук",
Tags: []string{"Webhooks"},
Security: security,
}, func(ctx context.Context, input *struct {
ChannelID string `path:"channel_id"`
Body struct {
Name string `json:"name" minLength:"1" maxLength:"80"`
AvatarFileID string `json:"avatar_file_id,omitempty"`
// Step-up обязателен для создания вебхука (AGENT.md 9.3).
StepUpPassword string `json:"step_up_password,omitempty"`
StepUpTOTP string `json:"step_up_totp,omitempty"`
}
},
) (*webhookOutput, error) {
user, session, err := requireUser(ctx)
if err != nil {
return nil, err
}
channelID, _, channel, err := s.requireChannelPermission(ctx, input.ChannelID, user, permissions.ManageWebhooks)
if err != nil {
return nil, err
}
if channel.GuildID == nil || channel.Type != store.ChannelText {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "webhooks can be created only in text channels")
}
if err := s.auth.RequireStepUp(ctx, user, session, input.Body.StepUpPassword, input.Body.StepUpTOTP); err != nil {
return nil, humaError(err)
}
name := strings.TrimSpace(input.Body.Name)
if name == "" || len([]rune(name)) > maxWebhookNameLength {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "invalid webhook name")
}
existing, err := s.store.ListChannelWebhooks(ctx, channelID)
if err != nil {
return nil, humaError(err)
}
if len(existing) >= maxWebhooksPerChannel {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "limits.reached", "too many webhooks in this channel")
}
avatar, err := s.webhookAvatarFile(ctx, user.ID, input.Body.AvatarFileID)
if err != nil {
return nil, err
}
token, err := newWebhookToken()
if err != nil {
return nil, humaError(err)
}
webhook, err := s.store.CreateWebhook(ctx, store.CreateWebhookParams{
GuildID: *channel.GuildID,
ChannelID: channelID,
Name: name,
AvatarFileID: avatar,
Token: token,
CreatedBy: user.ID,
})
if err != nil {
return nil, humaError(err)
}
s.recordAudit(ctx, user, *channel.GuildID, "webhook.create", "webhook", &webhook.ID, "")
output := &webhookOutput{}
output.Body.Webhook = s.webhookPayload(webhook)
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "updateWebhook",
Method: http.MethodPatch,
Path: "/webhooks/{webhook_id}",
Summary: "Изменить вебхук или пересоздать токен",
Tags: []string{"Webhooks"},
Security: security,
}, func(ctx context.Context, input *struct {
WebhookID string `path:"webhook_id"`
Body struct {
Name string `json:"name,omitempty" maxLength:"80"`
AvatarFileID string `json:"avatar_file_id,omitempty"`
ClearAvatar bool `json:"clear_avatar,omitempty"`
RegenerateToken bool `json:"regenerate_token,omitempty"`
}
},
) (*webhookOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
webhook, err := s.requireWebhookPermission(ctx, input.WebhookID, user, permissions.ManageWebhooks)
if err != nil {
return nil, err
}
params := store.UpdateWebhookParams{
Name: webhook.Name,
AvatarFileID: webhook.AvatarFileID,
Token: webhook.Token,
}
if name := strings.TrimSpace(input.Body.Name); name != "" {
if len([]rune(name)) > maxWebhookNameLength {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "invalid webhook name")
}
params.Name = name
}
if input.Body.ClearAvatar {
params.AvatarFileID = nil
}
if input.Body.AvatarFileID != "" {
avatar, err := s.webhookAvatarFile(ctx, user.ID, input.Body.AvatarFileID)
if err != nil {
return nil, err
}
params.AvatarFileID = avatar
}
if input.Body.RegenerateToken {
token, err := newWebhookToken()
if err != nil {
return nil, humaError(err)
}
params.Token = token
}
updated, err := s.store.UpdateWebhook(ctx, webhook.ID, params)
if err != nil {
return nil, humaError(err)
}
s.recordAudit(ctx, user, webhook.GuildID, "webhook.update", "webhook", &webhook.ID, "")
output := &webhookOutput{}
output.Body.Webhook = s.webhookPayload(updated)
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "deleteWebhook",
Method: http.MethodDelete,
Path: "/webhooks/{webhook_id}",
Summary: "Удалить вебхук",
Tags: []string{"Webhooks"},
Security: security,
}, func(ctx context.Context, input *struct {
WebhookID string `path:"webhook_id"`
},
) (*okOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
webhook, err := s.requireWebhookPermission(ctx, input.WebhookID, user, permissions.ManageWebhooks)
if err != nil {
return nil, err
}
if err := s.store.DeleteWebhook(ctx, webhook.ID); err != nil {
return nil, humaError(err)
}
s.recordAudit(ctx, user, webhook.GuildID, "webhook.delete", "webhook", &webhook.ID, "")
return newOKOutput(), nil
})
s.registerWebhookExecution(api)
}
// registerWebhookExecution описывает исполнение вебхука: ручка работает без
// пользовательской сессии, единственный секрет — токен в адресе.
func (s *Server) registerWebhookExecution(api huma.API) {
huma.Register(api, huma.Operation{
OperationID: "executeWebhook",
Method: http.MethodPost,
Path: "/webhooks/{webhook_id}/{token}",
Summary: "Отправить сообщение через вебхук",
Tags: []string{"Webhooks"},
}, func(ctx context.Context, input *struct {
WebhookID string `path:"webhook_id"`
Token string `path:"token"`
Body struct {
Content string `json:"content,omitempty" maxLength:"4000"`
Username string `json:"username,omitempty" maxLength:"80"`
AvatarURL string `json:"avatar_url,omitempty" maxLength:"512"`
FileIDs []string `json:"file_ids,omitempty" maxItems:"20"`
}
},
) (*webhookMessageOutput, error) {
webhookID, err := parseID("webhook_id", input.WebhookID)
if err != nil {
return nil, err
}
// Лимит 30 сообщений в минуту на вебхук (AGENT.md 8.6).
if allowed, retryAfter := s.webhookLimiter.Allow(formatSnowflake(webhookID)); !allowed {
return nil, rateLimitedError(retryAfter)
}
webhook, err := s.store.GetWebhook(ctx, webhookID)
if err != nil {
return nil, humaError(err)
}
// Токен сравниваем в постоянном времени: он и есть пароль вебхука.
if subtle.ConstantTimeCompare([]byte(webhook.Token), []byte(input.Token)) != 1 {
return nil, humaErrorStatus(http.StatusUnauthorized, "webhook.unauthorized", "invalid webhook token")
}
channel, err := s.store.GetChannel(ctx, webhook.ChannelID)
if err != nil {
return nil, humaError(err)
}
if channel.Type != store.ChannelText {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "webhooks post only to text channels")
}
content := strings.TrimSpace(input.Body.Content)
attachments, err := s.webhookAttachments(ctx, webhook, input.Body.FileIDs)
if err != nil {
return nil, err
}
if content == "" && len(attachments) == 0 {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "message must have content or attachments")
}
settings, err := s.store.InstanceSettings(ctx)
if err != nil {
return nil, humaError(err)
}
if len([]rune(content)) > settings.MaxMessageLength {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "message is too long")
}
name, avatar, err := webhookIdentity(webhook, input.Body.Username, input.Body.AvatarURL)
if err != nil {
return nil, err
}
mentions := s.extractMentions(ctx, webhook.ChannelID, content)
if len(mentions) > maxMentionsPerMessage {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "too many mentions in one message")
}
message, err := s.store.CreateMessage(ctx, store.CreateMessageParams{
ChannelID: webhook.ChannelID,
Content: content,
Type: store.MessageWebhook,
Attachments: attachments,
Mentions: mentions,
WebhookID: &webhook.ID,
WebhookName: name,
WebhookAvatar: avatar,
})
if err != nil {
return nil, humaError(err)
}
for _, attachment := range attachments {
if err := s.store.AttachFileToMessage(ctx, attachment.FileID, message.ID); err != nil {
s.logger.WarnContext(ctx, "failed to attach webhook file to message",
slog.String("file_id", formatSnowflake(attachment.FileID)), slog.Any("error", err))
}
}
payload, err := s.messagePayload(ctx, message, 0)
if err != nil {
return nil, err
}
s.dispatchChannelEvent(ctx, webhook.ChannelID, "MESSAGE_CREATE", payload)
output := &webhookMessageOutput{}
output.Body.Message = payload
output.Body.Webhook = webhookAvatarPayload{Name: name, URL: avatar}
if webhook.AvatarFileID != nil {
output.Body.Webhook.FileID = formatSnowflake(*webhook.AvatarFileID)
}
return output, nil
})
}
// webhookIdentity выбирает имя и аватар сообщения: переопределение из запроса
// важнее настроек вебхука (AGENT.md 7.11).
func webhookIdentity(webhook *store.Webhook, username, avatarURL string) (string, string, error) {
name := strings.TrimSpace(username)
if name == "" {
name = webhook.Name
}
if len([]rune(name)) > maxWebhookNameLength {
return "", "", humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "invalid webhook username")
}
avatar := strings.TrimSpace(avatarURL)
switch {
case avatar == "":
if webhook.AvatarFileID != nil {
avatar = fileContentPath(*webhook.AvatarFileID)
}
case strings.HasPrefix(avatar, "/files/"):
// Ссылка на файл инстанса: принимаем как есть.
case isAbsoluteHTTPURL(avatar):
// Внешняя картинка: её отдаёт чужой сервер, поэтому только http(s).
default:
return "", "", humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "invalid webhook avatar url")
}
if len(avatar) > maxWebhookAvatarLength {
return "", "", humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "invalid webhook avatar url")
}
return name, avatar, nil
}
// webhookAvatarFile проверяет, что файл аватара загружен этим пользователем
// (AGENT.md 7.7): чужие загрузки в вебхук не попадают.
func (s *Server) webhookAvatarFile(ctx context.Context, userID uint64, rawID string) (*uint64, error) {
if rawID == "" {
return nil, nil
}
fileID, err := parseID("avatar_file_id", rawID)
if err != nil {
return nil, err
}
file, err := s.store.GetFile(ctx, fileID)
if err != nil {
return nil, humaError(err)
}
if file.UploaderID == nil || *file.UploaderID != userID {
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "avatar belongs to another user")
}
if !strings.HasPrefix(file.ContentType, "image/") {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "webhook avatar must be an image")
}
return &fileID, nil
}
// webhookAttachments превращает ссылки на файлы в вложения сообщения. Брать
// можно только файлы, загруженные создателем вебхука: чужой файл не должен
// попадать в комнату в обход прав (AGENT.md 7.7).
func (s *Server) webhookAttachments(ctx context.Context, webhook *store.Webhook, rawIDs []string) ([]store.Attachment, error) {
if len(rawIDs) == 0 {
return nil, nil
}
attachments := make([]store.Attachment, 0, len(rawIDs))
for _, raw := range rawIDs {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
continue
}
// Принимаем и идентификатор, и ссылку вида /files/{id}.
trimmed = strings.TrimPrefix(trimmed, "/files/")
fileID, err := parseID("file_ids", trimmed)
if err != nil {
return nil, err
}
file, err := s.store.GetFile(ctx, fileID)
if err != nil {
return nil, humaError(err)
}
if webhook.CreatedBy == nil || file.UploaderID == nil || *file.UploaderID != *webhook.CreatedBy {
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "file belongs to another user")
}
attachments = append(attachments, store.Attachment{
FileID: file.ID,
Filename: file.Filename,
ContentType: file.ContentType,
SizeBytes: file.SizeBytes,
Width: file.Width,
Height: file.Height,
})
}
return attachments, nil
}
// requireWebhookPermission ищет вебхук и проверяет права на управление им.
func (s *Server) requireWebhookPermission(ctx context.Context, rawID string, user *store.User, permission permissions.Permission) (*store.Webhook, error) {
webhookID, err := parseID("webhook_id", rawID)
if err != nil {
return nil, err
}
webhook, err := s.store.GetWebhook(ctx, webhookID)
if err != nil {
return nil, humaError(err)
}
if _, _, _, err := s.requireChannelPermission(ctx, formatSnowflake(webhook.ChannelID), user, permission); err != nil {
return nil, err
}
return webhook, nil
}
// webhookPayload собирает ответ клиенту.
func (s *Server) webhookPayload(webhook *store.Webhook) webhookPayload {
payload := webhookPayload{
ID: formatSnowflake(webhook.ID),
GuildID: formatSnowflake(webhook.GuildID),
ChannelID: formatSnowflake(webhook.ChannelID),
Name: webhook.Name,
Token: webhook.Token,
CreatedAt: webhook.CreatedAt.UTC().Format(time.RFC3339),
URL: s.cfg.BaseURL() + "/api/v1/webhooks/" + formatSnowflake(webhook.ID) + "/" + webhook.Token,
}
if webhook.AvatarFileID != nil {
payload.AvatarFileID = formatSnowflake(*webhook.AvatarFileID)
}
if webhook.CreatedBy != nil {
payload.CreatedBy = formatSnowflake(*webhook.CreatedBy)
}
return payload
}
// newWebhookToken генерирует токен исполнения криптографическим источником.
func newWebhookToken() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
// isAbsoluteHTTPURL проверяет, что ссылка ведёт на http(s)-ресурс.
func isAbsoluteHTTPURL(value string) bool {
parsed, err := url.Parse(value)
if err != nil {
return false
}
return (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Host != ""
}
// fileContentPath — путь выдачи файла на этом инстансе.
func fileContentPath(fileID uint64) string {
return "/files/" + formatSnowflake(fileID)
}
// handleWebhookAvatarUpload принимает картинку аватара вебхука: право
// MANAGE_WEBHOOKS, изображение не больше 2 МБ (AGENT.md 7.11).
func (s *Server) handleWebhookAvatarUpload(w http.ResponseWriter, r *http.Request) {
user, _, ok := s.authenticate(w, r)
if !ok {
return
}
ctx := r.Context()
channelID, _, channel, err := s.requireChannelPermission(ctx, chi.URLParam(r, "channel_id"), user, permissions.ManageWebhooks)
if err != nil {
writeHumaAPIError(w, err)
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 > maxWebhookAvatarSize {
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "webhook avatar is too large")
return
}
// Читаем в память: нужно убедиться, что это действительно изображение.
data, err := io.ReadAll(io.LimitReader(file, maxWebhookAvatarSize+1))
if err != nil || int64(len(data)) > maxWebhookAvatarSize {
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "webhook avatar 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", "webhook avatar must be an image")
return
}
stored, err := s.saveUpload(ctx, store.File{
UploaderID: &user.ID,
GuildID: channel.GuildID,
ChannelID: &channelID,
Filename: sanitizeFilename(header.Filename),
ContentType: contentType,
Purpose: "webhook_avatar",
}, bytes.NewReader(data))
if err != nil {
writeHumaAPIError(w, err)
return
}
httpxWriteJSON(w, http.StatusOK, map[string]any{"file": s.uploadPayload(stored)})
}
+4
View File
@@ -59,6 +59,8 @@ type Server struct {
soundboardLimiter *httpx.RateLimiter
// inviteLimiter — 10 приглашений в сутки на пользователя (AGENT.md 8.6).
inviteLimiter *httpx.RateLimiter
// webhookLimiter — 30 сообщений в минуту на вебхук (AGENT.md 7.11, 8.6).
webhookLimiter *httpx.RateLimiter
// slowmode — время последней отправки в комнату для режима медленной
// отправки; словарь ограничен по размеру (AGENT.md 7.5).
slowmodeMu sync.Mutex
@@ -108,6 +110,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
editLimiter: httpx.NewRateLimiter(10, 10),
soundboardLimiter: httpx.NewRateLimiterWindow(3, 10*time.Second, 3),
inviteLimiter: httpx.NewRateLimiterWindow(10, 24*time.Hour, 10),
webhookLimiter: httpx.NewRateLimiterWindow(webhookRateLimit, time.Minute, webhookRateLimit),
slowmode: map[string]time.Time{},
presence: map[uint64]time.Time{},
webhookSeen: map[string]time.Time{},
@@ -149,6 +152,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
s.registerEmojiRoutes(s.api, apiRouter)
s.registerVoiceRoutes(s.api)
s.registerSoundsRoutes(s.api, apiRouter)
s.registerWebhookRoutes(s.api, apiRouter)
s.registerVoiceWebhook(apiRouter)
}
apiRouter.Get("/openapi.json", s.handleOpenAPI)
+404
View File
@@ -0,0 +1,404 @@
package server
import (
"bytes"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"glchat/internal/store"
)
// Тесты вебхуков (AGENT.md 7.11): управление требует MANAGE_WEBHOOKS и step-up,
// исполнение работает без сессии, уважает токен и лимит 30 сообщений в минуту.
type webhookTestFixture struct {
srv *Server
owner *http.Cookie
outsider *http.Cookie
guildID string
channelID string
}
func newWebhookFixture(t *testing.T) webhookTestFixture {
t.Helper()
srv, _ := newTestServer(t)
owner := registerAndLogin(t, srv, "hook_owner", "hook-owner@example.com")
outsider := registerAndLogin(t, srv, "hook_guest", "hook-guest@example.com")
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Вебхуки"}`, owner)
guild := decodeResponse[struct {
Guild struct {
ID string `json:"id"`
} `json:"guild"`
}](t, created)
if guild.Guild.ID == "" {
t.Fatalf("guild not created: %s", created.Body.String())
}
channels := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/channels", "", owner)
list := decodeResponse[struct {
Channels []struct {
ID string `json:"id"`
Type string `json:"type"`
} `json:"channels"`
}](t, channels)
channelID := ""
for _, channel := range list.Channels {
if channel.Type == "text" {
channelID = channel.ID
break
}
}
if channelID == "" {
t.Fatalf("text channel not found: %s", channels.Body.String())
}
return webhookTestFixture{
srv: srv, owner: owner, outsider: outsider,
guildID: guild.Guild.ID, channelID: channelID,
}
}
// stepUp подтверждает личность: создание вебхука требует step-up (AGENT.md 9.3).
func (f webhookTestFixture) stepUp(t *testing.T, cookie *http.Cookie) {
t.Helper()
rec := doJSON(t, f.srv, http.MethodPost, "/api/v1/auth/step-up",
`{"password":"correct-horse-battery"}`, cookie)
if rec.Code != http.StatusOK {
t.Fatalf("step-up = %d (%s)", rec.Code, rec.Body.String())
}
}
// createWebhookFor создаёт вебхук и возвращает его id, токен и ссылку.
func (f webhookTestFixture) createWebhookFor(t *testing.T, name string) (id, token, url string) {
t.Helper()
f.stepUp(t, f.owner)
rec := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.channelID+"/webhooks",
`{"name":"`+name+`"}`, f.owner)
if rec.Code != http.StatusOK {
t.Fatalf("create webhook = %d (%s)", rec.Code, rec.Body.String())
}
payload := decodeResponse[struct {
Webhook struct {
ID string `json:"id"`
Token string `json:"token"`
URL string `json:"url"`
} `json:"webhook"`
}](t, rec)
if payload.Webhook.ID == "" || payload.Webhook.Token == "" {
t.Fatalf("webhook payload is incomplete: %s", rec.Body.String())
}
return payload.Webhook.ID, payload.Webhook.Token, payload.Webhook.URL
}
func TestWebhookExecuteWithoutSession(t *testing.T) {
f := newWebhookFixture(t)
id, token, url := f.createWebhookFor(t, "Деплой")
if !strings.HasSuffix(url, token) || !strings.Contains(url, "/api/v1/webhooks/") {
t.Fatalf("unexpected webhook url: %s", url)
}
executePath := "/api/v1/webhooks/" + id + "/" + token
// Исполнение без сессии: единственный секрет — токен в адресе.
body := `{"content":"привет из CI","username":"Сборка"}`
rec := doJSON(t, f.srv, http.MethodPost, executePath, body)
if rec.Code != http.StatusOK {
t.Fatalf("execute = %d (%s)", rec.Code, rec.Body.String())
}
payload := decodeResponse[struct {
Message struct {
ID string `json:"id"`
Content string `json:"content"`
Type string `json:"type"`
AuthorID string `json:"author_id"`
WebhookName string `json:"webhook_name"`
} `json:"message"`
}](t, rec)
if payload.Message.Type != "webhook" {
t.Fatalf("type = %q, want webhook", payload.Message.Type)
}
if payload.Message.AuthorID != "" {
t.Fatalf("webhook message must have no author, got %q", payload.Message.AuthorID)
}
if payload.Message.WebhookName != "Сборка" {
t.Fatalf("webhook_name = %q, want Сборка", payload.Message.WebhookName)
}
// Сообщение видно в истории комнаты обычным участникам сервера.
history := doJSON(t, f.srv, http.MethodGet, "/api/v1/channels/"+f.channelID+"/messages", "", f.owner)
list := decodeResponse[struct {
Messages []struct {
ID string `json:"id"`
WebhookName string `json:"webhook_name"`
} `json:"messages"`
}](t, history)
found := false
for _, message := range list.Messages {
if message.ID == payload.Message.ID && message.WebhookName == "Сборка" {
found = true
}
}
if !found {
t.Fatalf("webhook message not in history: %s", history.Body.String())
}
}
func TestWebhookRejectsWrongToken(t *testing.T) {
f := newWebhookFixture(t)
id, token, _ := f.createWebhookFor(t, "Секрет")
rec := doJSON(t, f.srv, http.MethodPost, "/api/v1/webhooks/"+id+"/"+strings.Repeat("0", len(token)),
`{"content":"подделка"}`)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("wrong token = %d, want 401 (%s)", rec.Code, rec.Body.String())
}
if code := errorCodeOf(t, rec); code != "webhook.unauthorized" {
t.Fatalf("error code = %q", code)
}
}
func TestWebhookRateLimit(t *testing.T) {
f := newWebhookFixture(t)
id, token, _ := f.createWebhookFor(t, "Лимит")
path := "/api/v1/webhooks/" + id + "/" + token
for i := 0; i < webhookRateLimit; i++ {
if rec := doJSON(t, f.srv, http.MethodPost, path, `{"content":"строка"}`); rec.Code != http.StatusOK {
t.Fatalf("message %d = %d (%s)", i+1, rec.Code, rec.Body.String())
}
}
rec := doJSON(t, f.srv, http.MethodPost, path, `{"content":"лишнее"}`)
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("rate limit = %d, want 429 (%s)", rec.Code, rec.Body.String())
}
if code := errorCodeOf(t, rec); code != "rate_limited" {
t.Fatalf("error code = %q", code)
}
}
func TestWebhookPermissionsAndStepUp(t *testing.T) {
f := newWebhookFixture(t)
// Посторонний не видит комнату и не может завести в ней вебхук.
for _, method := range []string{http.MethodGet, http.MethodPost} {
rec := doJSON(t, f.srv, method, "/api/v1/channels/"+f.channelID+"/webhooks",
`{"name":"Чужой"}`, f.outsider)
if rec.Code != http.StatusNotFound && rec.Code != http.StatusForbidden {
t.Fatalf("outsider %s = %d (%s)", method, rec.Code, rec.Body.String())
}
}
// Свежая сессия step-up не проходила: создание отклоняется.
rec := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.channelID+"/webhooks",
`{"name":"Без подтверждения"}`, f.owner)
if rec.Code != http.StatusForbidden {
t.Fatalf("create without step-up = %d (%s)", rec.Code, rec.Body.String())
}
if code := errorCodeOf(t, rec); code != "auth.step_up_required" {
t.Fatalf("error code = %q", code)
}
// После подтверждения паролем вебхук создаётся.
f.stepUp(t, f.owner)
rec = doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.channelID+"/webhooks",
`{"name":"С подтверждением"}`, f.owner)
if rec.Code != http.StatusOK {
t.Fatalf("create after step-up = %d (%s)", rec.Code, rec.Body.String())
}
}
func TestWebhookUpdateRotatesTokenAndDelete(t *testing.T) {
f := newWebhookFixture(t)
id, token, _ := f.createWebhookFor(t, "Старое имя")
renamed := doJSON(t, f.srv, http.MethodPatch, "/api/v1/webhooks/"+id, `{"name":"Новое имя"}`, f.owner)
if renamed.Code != http.StatusOK {
t.Fatalf("rename = %d (%s)", renamed.Code, renamed.Body.String())
}
rotated := doJSON(t, f.srv, http.MethodPatch, "/api/v1/webhooks/"+id, `{"regenerate_token":true}`, f.owner)
if rotated.Code != http.StatusOK {
t.Fatalf("rotate = %d (%s)", rotated.Code, rotated.Body.String())
}
payload := decodeResponse[struct {
Webhook struct {
Name string `json:"name"`
Token string `json:"token"`
} `json:"webhook"`
}](t, rotated)
if payload.Webhook.Name != "Новое имя" {
t.Fatalf("name = %q", payload.Webhook.Name)
}
if payload.Webhook.Token == token {
t.Fatal("token must be regenerated")
}
// Старый токен больше не работает, новый — работает.
if rec := doJSON(t, f.srv, http.MethodPost, "/api/v1/webhooks/"+id+"/"+token,
`{"content":"старый токен"}`); rec.Code != http.StatusUnauthorized {
t.Fatalf("old token = %d, want 401", rec.Code)
}
if rec := doJSON(t, f.srv, http.MethodPost, "/api/v1/webhooks/"+id+"/"+payload.Webhook.Token,
`{"content":"новый токен"}`); rec.Code != http.StatusOK {
t.Fatalf("new token = %d (%s)", rec.Code, rec.Body.String())
}
deleted := doJSON(t, f.srv, http.MethodDelete, "/api/v1/webhooks/"+id, "", f.owner)
if deleted.Code != http.StatusOK {
t.Fatalf("delete = %d (%s)", deleted.Code, deleted.Body.String())
}
if rec := doJSON(t, f.srv, http.MethodPost, "/api/v1/webhooks/"+id+"/"+payload.Webhook.Token,
`{"content":"после удаления"}`); rec.Code != http.StatusNotFound {
t.Fatalf("execute after delete = %d, want 404", rec.Code)
}
}
func TestWebhookValidation(t *testing.T) {
f := newWebhookFixture(t)
id, token, _ := f.createWebhookFor(t, "Проверки")
path := "/api/v1/webhooks/" + id + "/" + token
cases := []struct {
name string
body string
want int
}{
{"пустое сообщение", `{}`, http.StatusUnprocessableEntity},
{"слишком длинное", `{"content":"` + strings.Repeat("я", 5000) + `"}`, http.StatusUnprocessableEntity},
{"плохой аватар", `{"content":"текст","avatar_url":"javascript:alert(1)"}`, http.StatusUnprocessableEntity},
{"неизвестный файл", `{"content":"текст","file_ids":["1"]}`, http.StatusNotFound},
}
for _, item := range cases {
rec := doJSON(t, f.srv, http.MethodPost, path, item.body)
if rec.Code != item.want {
t.Fatalf("%s: статус %d, ожидался %d (%s)", item.name, rec.Code, item.want, rec.Body.String())
}
}
}
func TestWebhookRejectsForeignFilesAndVoiceChannels(t *testing.T) {
f := newWebhookFixture(t)
id, token, _ := f.createWebhookFor(t, "Вложения")
// Файл загружен другим пользователем: вложение вебхука отклоняется.
me := doJSON(t, f.srv, http.MethodGet, "/api/v1/users/@me", "", f.outsider)
outsiderID := decodeResponse[struct {
User struct {
ID string `json:"id"`
} `json:"user"`
}](t, me).User.ID
outsider, err := parseID("user_id", outsiderID)
if err != nil {
t.Fatalf("parse outsider id: %v", err)
}
channel, err := parseID("channel_id", f.channelID)
if err != nil {
t.Fatalf("parse channel id: %v", err)
}
file, err := f.srv.store.CreateFile(t.Context(), store.CreateFileParams{
UploaderID: outsider,
ChannelID: &channel,
Filename: "чужой.txt",
ContentType: "text/plain",
SizeBytes: 3,
StoragePath: "files/чужой.txt",
})
if err != nil {
t.Fatalf("create file: %v", err)
}
rec := doJSON(t, f.srv, http.MethodPost, "/api/v1/webhooks/"+id+"/"+token,
`{"content":"вложение","file_ids":["`+formatSnowflake(file.ID)+`"]}`)
if rec.Code != http.StatusForbidden {
t.Fatalf("foreign file = %d, want 403 (%s)", rec.Code, rec.Body.String())
}
// Вебхук можно завести только в текстовой комнате.
voice := doJSON(t, f.srv, http.MethodPost, "/api/v1/guilds/"+f.guildID+"/channels",
`{"name":"Голос","type":"voice"}`, f.owner)
voiceID := decodeResponse[struct {
Channel struct {
ID string `json:"id"`
} `json:"channel"`
}](t, voice).Channel.ID
if voiceID == "" {
t.Fatalf("voice channel not created: %s", voice.Body.String())
}
rec = doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+voiceID+"/webhooks",
`{"name":"В голосовой"}`, f.owner)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("webhook in voice channel = %d, want 422 (%s)", rec.Code, rec.Body.String())
}
}
func TestWebhookAvatarUploadAndUse(t *testing.T) {
f := newWebhookFixture(t)
// Загрузка аватара: право MANAGE_WEBHOOKS у владельца есть.
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", "hook.png")
if err != nil {
t.Fatalf("multipart: %v", err)
}
if _, err := part.Write(pngBytes()); err != nil {
t.Fatalf("multipart write: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("multipart close: %v", err)
}
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost,
"/api/v1/channels/"+f.channelID+"/webhooks/avatar", &body)
request.Header.Set("Content-Type", writer.FormDataContentType())
request.AddCookie(f.owner)
rec := httptest.NewRecorder()
f.srv.Handler().ServeHTTP(rec, request)
if rec.Code != http.StatusOK {
t.Fatalf("avatar upload = %d (%s)", rec.Code, rec.Body.String())
}
uploaded := decodeResponse[struct {
File struct {
FileID string `json:"file_id"`
} `json:"file"`
}](t, rec)
if uploaded.File.FileID == "" {
t.Fatalf("avatar file id is empty: %s", rec.Body.String())
}
f.stepUp(t, f.owner)
created := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.channelID+"/webhooks",
`{"name":"С аватаром","avatar_file_id":"`+uploaded.File.FileID+`"}`, f.owner)
if created.Code != http.StatusOK {
t.Fatalf("create with avatar = %d (%s)", created.Code, created.Body.String())
}
webhook := decodeResponse[struct {
Webhook struct {
ID string `json:"id"`
Token string `json:"token"`
AvatarFileID string `json:"avatar_file_id"`
} `json:"webhook"`
}](t, created)
if webhook.Webhook.AvatarFileID != uploaded.File.FileID {
t.Fatalf("avatar_file_id = %q, want %q", webhook.Webhook.AvatarFileID, uploaded.File.FileID)
}
// Сообщение вебхука несёт ссылку на аватар: её видит клиент.
sent := doJSON(t, f.srv, http.MethodPost,
"/api/v1/webhooks/"+webhook.Webhook.ID+"/"+webhook.Webhook.Token, `{"content":"с аватаром"}`)
if sent.Code != http.StatusOK {
t.Fatalf("execute = %d (%s)", sent.Code, sent.Body.String())
}
payload := decodeResponse[struct {
Message struct {
WebhookAvatar string `json:"webhook_avatar"`
} `json:"message"`
}](t, sent)
if payload.Message.WebhookAvatar != "/files/"+uploaded.File.FileID {
t.Fatalf("webhook_avatar = %q", payload.Message.WebhookAvatar)
}
// Аватар вебхука доступен участникам сервера: файл отдаётся по ссылке.
avatar := doJSON(t, f.srv, http.MethodGet, "/files/"+uploaded.File.FileID, "", f.owner)
if avatar.Code != http.StatusOK {
t.Fatalf("avatar download = %d", avatar.Code)
}
}