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
+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)})
}