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