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:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user