diff --git a/internal/database/migrations/00012_webhooks.sql b/internal/database/migrations/00012_webhooks.sql new file mode 100644 index 0000000..2a8316b --- /dev/null +++ b/internal/database/migrations/00012_webhooks.sql @@ -0,0 +1,31 @@ +-- +goose Up +-- Вебхуки серверов (AGENT.md 7.11): сообщения без пользовательской сессии. +-- Токен — секрет ссылки исполнения, его можно пересоздать. +CREATE TABLE webhooks ( + id INTEGER PRIMARY KEY, + guild_id INTEGER NOT NULL REFERENCES guilds (id) ON DELETE CASCADE, + channel_id INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, + name TEXT NOT NULL, + avatar_file_id INTEGER REFERENCES files (id) ON DELETE SET NULL, + token TEXT NOT NULL, + created_by INTEGER REFERENCES users (id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); +CREATE UNIQUE INDEX webhooks_token_idx ON webhooks (token); +CREATE INDEX webhooks_channel_idx ON webhooks (channel_id, id); +CREATE INDEX webhooks_guild_idx ON webhooks (guild_id, id); + +-- Сообщение вебхука: автора-пользователя нет. Имя и аватар сохраняем снимком, +-- чтобы удаление вебхука не меняло уже отправленные сообщения. +ALTER TABLE messages ADD COLUMN webhook_id INTEGER REFERENCES webhooks (id) ON DELETE SET NULL; +ALTER TABLE messages ADD COLUMN webhook_name TEXT NOT NULL DEFAULT ''; +ALTER TABLE messages ADD COLUMN webhook_avatar TEXT NOT NULL DEFAULT ''; + +-- +goose Down +ALTER TABLE messages DROP COLUMN webhook_avatar; +ALTER TABLE messages DROP COLUMN webhook_name; +ALTER TABLE messages DROP COLUMN webhook_id; +DROP INDEX webhooks_guild_idx; +DROP INDEX webhooks_channel_idx; +DROP INDEX webhooks_token_idx; +DROP TABLE webhooks; diff --git a/internal/server/api_files.go b/internal/server/api_files.go index 0d448f1..b628f72 100644 --- a/internal/server/api_files.go +++ b/internal/server/api_files.go @@ -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 { diff --git a/internal/server/api_messages.go b/internal/server/api_messages.go index 6848634..22ed28c 100644 --- a/internal/server/api_messages.go +++ b/internal/server/api_messages.go @@ -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) } diff --git a/internal/server/api_webhooks.go b/internal/server/api_webhooks.go new file mode 100644 index 0000000..2faa00d --- /dev/null +++ b/internal/server/api_webhooks.go @@ -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)}) +} diff --git a/internal/server/server.go b/internal/server/server.go index 4a44822..e688e34 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) diff --git a/internal/server/webhooks_test.go b/internal/server/webhooks_test.go new file mode 100644 index 0000000..4f75cc0 --- /dev/null +++ b/internal/server/webhooks_test.go @@ -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) + } +} diff --git a/internal/store/messages.go b/internal/store/messages.go index 6905f3b..6772f40 100644 --- a/internal/store/messages.go +++ b/internal/store/messages.go @@ -21,6 +21,9 @@ const ( MessageAction MessageType = "action" MessageScream MessageType = "scream" MessagePrivate MessageType = "private" + // MessageWebhook — сообщение вебхука (AGENT.md 7.11): у него нет автора, + // а имя и аватар берутся из снимка в самом сообщении. + MessageWebhook MessageType = "webhook" ) // IsPrivate сообщает, что сообщение видно только автору и адресатам. @@ -39,6 +42,10 @@ type Message struct { Attachments []Attachment Mentions []uint64 CreatedAt time.Time + // Поля вебхука (AGENT.md 7.11): идентификатор и снимок имени с аватаром. + WebhookID *uint64 + WebhookName string + WebhookAvatar string } // Attachment — метаданные вложения: файл регистрируется в таблице files. @@ -69,7 +76,8 @@ type ReadState struct { } const messageColumns = `id, channel_id, author_id, content, reply_to_id, type, - attachments_json, mentions_json, edited_at, pinned, created_at, content_lower` + attachments_json, mentions_json, edited_at, pinned, created_at, content_lower, + webhook_id, webhook_name, webhook_avatar` // CreateMessageParams — параметры нового сообщения. type CreateMessageParams struct { @@ -81,6 +89,10 @@ type CreateMessageParams struct { Type MessageType Attachments []Attachment Mentions []uint64 + // Заполняется только вебхуками: автора-пользователя у них нет. + WebhookID *uint64 + WebhookName string + WebhookAvatar string } // CreateMessage сохраняет сообщение; пустое содержимое без вложений запрещено @@ -106,11 +118,13 @@ func (s *Store) CreateMessage(ctx context.Context, params CreateMessageParams) ( } _, err = s.writer.ExecContext(ctx, ` INSERT INTO messages (id, channel_id, author_id, content, reply_to_id, type, - attachments_json, mentions_json, pinned, created_at, content_lower) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`, + attachments_json, mentions_json, pinned, created_at, content_lower, + webhook_id, webhook_name, webhook_avatar) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?)`, int64(params.ID), int64(params.ChannelID), author, params.Content, nullableID(params.ReplyToID), string(params.Type), string(attachments), string(mentions), - s.Now(), strings.ToLower(params.Content)) + s.Now(), strings.ToLower(params.Content), nullableID(params.WebhookID), + params.WebhookName, params.WebhookAvatar) if err != nil { return nil, mapError(err) } @@ -568,9 +582,11 @@ func scanMessage(scanner interface{ Scan(...any) error }) (*Message, error) { pinned int createdAt string contentLow string + webhookID sql.NullInt64 ) err := scanner.Scan(&message.ID, &message.ChannelID, &authorID, &message.Content, &replyToID, - &message.Type, &attachments, &mentions, &editedAt, &pinned, &createdAt, &contentLow) + &message.Type, &attachments, &mentions, &editedAt, &pinned, &createdAt, &contentLow, + &webhookID, &message.WebhookName, &message.WebhookAvatar) if err != nil { return nil, mapError(err) } @@ -582,6 +598,10 @@ func scanMessage(scanner interface{ Scan(...any) error }) (*Message, error) { value := uint64(replyToID.Int64) message.ReplyToID = &value } + if webhookID.Valid { + value := uint64(webhookID.Int64) + message.WebhookID = &value + } if err := json.Unmarshal([]byte(attachments), &message.Attachments); err != nil { message.Attachments = nil } diff --git a/internal/store/webhooks.go b/internal/store/webhooks.go new file mode 100644 index 0000000..e0b50c3 --- /dev/null +++ b/internal/store/webhooks.go @@ -0,0 +1,127 @@ +package store + +import ( + "context" + "database/sql" + "time" +) + +// Webhook — вебхук комнаты (AGENT.md 7.11): отправляет сообщения без сессии. +type Webhook struct { + ID uint64 + GuildID uint64 + ChannelID uint64 + Name string + AvatarFileID *uint64 + Token string + CreatedBy *uint64 + CreatedAt time.Time +} + +// CreateWebhookParams — параметры нового вебхука. +type CreateWebhookParams struct { + ID uint64 + GuildID uint64 + ChannelID uint64 + Name string + AvatarFileID *uint64 + Token string + CreatedBy uint64 +} + +// UpdateWebhookParams — новое состояние вебхука: имя, аватар и токен +// передаются целиком, чтобы пересоздание токена было обычной правкой. +type UpdateWebhookParams struct { + Name string + AvatarFileID *uint64 + Token string +} + +const webhookColumns = `id, guild_id, channel_id, name, avatar_file_id, token, + created_by, created_at` + +// CreateWebhook сохраняет вебхук комнаты. +func (s *Store) CreateWebhook(ctx context.Context, params CreateWebhookParams) (*Webhook, error) { + if params.ID == 0 { + params.ID = s.NextID() + } + _, err := s.writer.ExecContext(ctx, ` + INSERT INTO webhooks (id, guild_id, channel_id, name, avatar_file_id, token, created_by, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + int64(params.ID), int64(params.GuildID), int64(params.ChannelID), params.Name, + nullableID(params.AvatarFileID), params.Token, int64(params.CreatedBy), s.Now()) + if err != nil { + return nil, mapError(err) + } + return s.GetWebhook(ctx, params.ID) +} + +func (s *Store) GetWebhook(ctx context.Context, id uint64) (*Webhook, error) { + row := s.reader.QueryRowContext(ctx, `SELECT `+webhookColumns+` FROM webhooks WHERE id = ?`, int64(id)) + return scanWebhook(row) +} + +// ListChannelWebhooks возвращает вебхуки комнаты: их показывает панель +// управления комнатой (AGENT.md 7.11). +func (s *Store) ListChannelWebhooks(ctx context.Context, channelID uint64) ([]Webhook, error) { + rows, err := s.reader.QueryContext(ctx, + `SELECT `+webhookColumns+` FROM webhooks WHERE channel_id = ? ORDER BY id`, int64(channelID)) + if err != nil { + return nil, err + } + defer rows.Close() + + webhooks := make([]Webhook, 0, 8) + for rows.Next() { + webhook, err := scanWebhook(rows) + if err != nil { + return nil, err + } + webhooks = append(webhooks, *webhook) + } + return webhooks, rows.Err() +} + +// UpdateWebhook заменяет имя, аватар и токен вебхука. +func (s *Store) UpdateWebhook(ctx context.Context, id uint64, params UpdateWebhookParams) (*Webhook, error) { + result, err := s.writer.ExecContext(ctx, + `UPDATE webhooks SET name = ?, avatar_file_id = ?, token = ? WHERE id = ?`, + params.Name, nullableID(params.AvatarFileID), params.Token, int64(id)) + if err != nil { + return nil, mapError(err) + } + if affected, err := result.RowsAffected(); err == nil && affected == 0 { + return nil, ErrNotFound + } + return s.GetWebhook(ctx, id) +} + +// DeleteWebhook удаляет вебхук; уже отправленные сообщения остаются (в них +// хранится снимок имени и аватара). +func (s *Store) DeleteWebhook(ctx context.Context, id uint64) error { + result, err := s.writer.ExecContext(ctx, `DELETE FROM webhooks WHERE id = ?`, int64(id)) + if err != nil { + return mapError(err) + } + if affected, err := result.RowsAffected(); err == nil && affected == 0 { + return ErrNotFound + } + return nil +} + +func scanWebhook(scanner interface{ Scan(...any) error }) (*Webhook, error) { + var ( + webhook Webhook + avatar sql.NullInt64 + createdBy sql.NullInt64 + createdAt string + ) + if err := scanner.Scan(&webhook.ID, &webhook.GuildID, &webhook.ChannelID, &webhook.Name, &avatar, + &webhook.Token, &createdBy, &createdAt); err != nil { + return nil, mapError(err) + } + webhook.AvatarFileID = optionalID(avatar) + webhook.CreatedBy = optionalID(createdBy) + webhook.CreatedAt = parseTimestamp(createdAt) + return &webhook, nil +} diff --git a/web/src/api/messages.ts b/web/src/api/messages.ts index 92144df..ee03c88 100644 --- a/web/src/api/messages.ts +++ b/web/src/api/messages.ts @@ -42,6 +42,8 @@ export const messageTypes = [ 'scream', 'private', 'system', + /** Сообщение вебхука: автор — вебхук, а не пользователь (AGENT.md 7.11). */ + 'webhook', ] as const; export type MessageType = (typeof messageTypes)[number]; @@ -56,8 +58,13 @@ export interface Message { author_id?: string; content: string; reply_to_id?: string; - /** Вид сообщения: `default`, `action`, `whisper`, `scream`, `private`, `system`. */ + /** Вид сообщения: `default`, `action`, `whisper`, `scream`, `private`, `system`, `webhook`. */ type: string; + /** Поля вебхука: у таких сообщений нет `author_id` (AGENT.md 7.11). */ + webhook_id?: string; + webhook_name?: string; + /** Ссылка на аватар вебхука: `/files/{id}` или внешний http(s) URL. */ + webhook_avatar?: string; edited_at?: string; pinned: boolean; attachments: MessageAttachment[]; diff --git a/web/src/api/webhooks.ts b/web/src/api/webhooks.ts new file mode 100644 index 0000000..118892d --- /dev/null +++ b/web/src/api/webhooks.ts @@ -0,0 +1,168 @@ +import { ApiError, isApiErrorPayload, parsePayload, request } from './client'; + +/** + * Вебхуки комнат (AGENT.md §7.11): управление требует MANAGE_WEBHOOKS, + * исполнение идёт по ссылке с токеном и не требует сессии. Ссылку собирает + * сервер — в ней правильный домен инстанса. + */ + +export interface Webhook { + id: string; + guild_id: string; + channel_id: string; + name: string; + avatar_file_id?: string; + token: string; + created_by?: string; + created_at: string; + /** Готовая ссылка исполнения. */ + url: string; +} + +export const channelWebhooksQueryKey = (channelId: string) => + ['channels', channelId, 'webhooks'] as const; + +function asRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null ? (value as Record) : null; +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' && value !== '' ? value : undefined; +} + +/** Разбирает вебхук из ответа сервера, отбрасывая лишнее. */ +function parseWebhook(value: unknown): Webhook | null { + const record = asRecord(value); + const id = asString(record?.['id']); + const channelId = asString(record?.['channel_id']); + const url = asString(record?.['url']); + if (record === null || id === undefined || channelId === undefined || url === undefined) { + return null; + } + const webhook: Webhook = { + id, + channel_id: channelId, + guild_id: asString(record['guild_id']) ?? '', + name: asString(record['name']) ?? '', + token: asString(record['token']) ?? '', + created_at: asString(record['created_at']) ?? '', + url, + }; + const avatar = asString(record['avatar_file_id']); + const createdBy = asString(record['created_by']); + if (avatar !== undefined) { + webhook.avatar_file_id = avatar; + } + if (createdBy !== undefined) { + webhook.created_by = createdBy; + } + return webhook; +} + +function parseWebhookList(payload: unknown): Webhook[] { + const record = asRecord(payload); + const list = record?.['webhooks']; + if (!Array.isArray(list)) { + return []; + } + return list.map((item) => parseWebhook(item)).filter((item): item is Webhook => item !== null); +} + +/** `GET /channels/{id}/webhooks` — вебхуки комнаты (право MANAGE_WEBHOOKS). */ +export async function fetchChannelWebhooks( + channelId: string, + signal?: AbortSignal, +): Promise { + const payload = await request( + `/channels/${encodeURIComponent(channelId)}/webhooks`, + signal === undefined ? {} : { signal }, + ); + return parseWebhookList(payload); +} + +export interface CreateWebhookInput { + name: string; + avatar_file_id?: string; + /** Step-up: создание вебхука требует свежего подтверждения (AGENT.md 9.3). */ + step_up_password?: string; + step_up_totp?: string; +} + +/** `POST /channels/{id}/webhooks` — создать вебхук. */ +export async function createWebhook( + channelId: string, + input: CreateWebhookInput, +): Promise { + const payload = await request<{ webhook?: unknown }>( + `/channels/${encodeURIComponent(channelId)}/webhooks`, + { method: 'POST', body: input }, + ); + const webhook = parseWebhook(payload.webhook); + if (webhook === null) { + throw new Error('malformed webhook payload'); + } + return webhook; +} + +/** + * `POST /channels/{id}/webhooks/avatar` — картинка аватара (multipart, право + * MANAGE_WEBHOOKS). Тело — форма, поэтому запрос собирается напрямую. + */ +export async function uploadWebhookAvatar(channelId: string, file: File): Promise { + const form = new FormData(); + form.append('file', file); + const response = await fetch( + `/api/v1/channels/${encodeURIComponent(channelId)}/webhooks/avatar`, + { + method: 'POST', + credentials: 'same-origin', + headers: { Accept: 'application/json' }, + body: form, + }, + ); + const payload = parsePayload(await response.text(), response.headers.get('Content-Type')); + if (!response.ok) { + const envelope = + typeof payload === 'object' && payload !== null + ? (payload as { error?: unknown }).error + : undefined; + if (isApiErrorPayload(envelope)) { + throw new ApiError(response.status, envelope.code, envelope.message, envelope.details); + } + throw new ApiError(response.status, 'internal.error', 'unexpected response'); + } + const uploaded = (payload as { file?: { file_id?: unknown } } | null)?.file; + const fileId = uploaded?.file_id; + if (typeof fileId !== 'string' || fileId === '') { + throw new ApiError(response.status, 'internal.error', 'unexpected response'); + } + return fileId; +} + +export interface UpdateWebhookInput { + name?: string; + avatar_file_id?: string; + clear_avatar?: boolean; + regenerate_token?: boolean; +} + +/** `PATCH /webhooks/{id}` — переименовать, сменить аватар или токен. */ +export async function updateWebhook( + webhookId: string, + input: UpdateWebhookInput, +): Promise { + const payload = await request<{ webhook?: unknown }>( + `/webhooks/${encodeURIComponent(webhookId)}`, + { method: 'PATCH', body: input }, + ); + const webhook = parseWebhook(payload.webhook); + if (webhook === null) { + throw new Error('malformed webhook payload'); + } + return webhook; +} + +/** `DELETE /webhooks/{id}` — удалить вебхук. */ +export async function deleteWebhook(webhookId: string): Promise { + await request(`/webhooks/${encodeURIComponent(webhookId)}`, { method: 'DELETE' }); +} diff --git a/web/src/components/chat/MessageItem.tsx b/web/src/components/chat/MessageItem.tsx index b2d0d89..9604709 100644 --- a/web/src/components/chat/MessageItem.tsx +++ b/web/src/components/chat/MessageItem.tsx @@ -239,7 +239,11 @@ export function MessageItem({ const menuAnchor = useRef(null); const editorRef = useRef(null); - const author = authors.resolve(message.author_id); + // Сообщение вебхука (AGENT.md 7.11): автора-пользователя у него нет. + const author = + message.type === 'webhook' + ? authors.resolveWebhook(message) + : authors.resolve(message.author_id); const isMine = currentUserId !== null && message.author_id === currentUserId; const canEdit = isMine && canSend && !pending; const canDelete = !pending && (isMine || canManageMessages); diff --git a/web/src/components/chat/MessageList.tsx b/web/src/components/chat/MessageList.tsx index 7c8db7f..5b3db28 100644 --- a/web/src/components/chat/MessageList.tsx +++ b/web/src/components/chat/MessageList.tsx @@ -219,7 +219,10 @@ export function MessageList({ return null; } const headType = messageTypeOf(first.type); - const author = authors.resolve(row.group.authorId ?? undefined); + const author = + headType === 'webhook' + ? authors.resolveWebhook(first) + : authors.resolve(row.group.authorId ?? undefined); // У системных сообщений нет ни аватара, ни шапки автора, а действие // `/me` рисует имя внутри строки — шапка ему тоже не нужна. const showHeader = headType !== 'system' && headType !== 'action'; @@ -275,6 +278,7 @@ export function MessageList({ name={author.name} seed={author.id === '' ? row.key : author.id} fileId={author.avatarFileId} + url={author.avatarUrl} size="md" /> @@ -289,6 +293,14 @@ export function MessageList({ > {author.name} + {headType === 'webhook' ? ( + + {t('chat.webhookBadge')} + + ) : null} {author.isInstanceAdmin ? ( {t('chat.adminBadge')} diff --git a/web/src/components/chat/useAuthorDirectory.ts b/web/src/components/chat/useAuthorDirectory.ts index 290ef4d..d490624 100644 --- a/web/src/components/chat/useAuthorDirectory.ts +++ b/web/src/components/chat/useAuthorDirectory.ts @@ -20,6 +20,8 @@ export interface ChatAuthor { id: string; name: string; avatarFileId: string | undefined; + /** Готовая ссылка на аватар: у вебхуков нет файла в профиле (AGM 7.11). */ + avatarUrl?: string | undefined; colorHex: string | null; isInstanceAdmin: boolean; isMember: boolean; @@ -27,6 +29,15 @@ export interface ChatAuthor { export interface AuthorDirectory { resolve: (userId: string | undefined) => ChatAuthor; + /** + * Автор сообщения вебхука (AGENT.md 7.11): имя и аватар лежат в самом + * сообщении, поэтому справочник участников для них не нужен. + */ + resolveWebhook: (message: { + webhook_id?: string | undefined; + webhook_name?: string | undefined; + webhook_avatar?: string | undefined; + }) => ChatAuthor; resolveMention: (userId: string) => { name: string; isMe: boolean }; /** * Имя участника или `null`, если он неизвестен (например, вышел с сервера @@ -161,8 +172,26 @@ export function useAuthorDirectory(guildId: string | null, userIds: string[]): A }; }; + const resolveWebhook = (message: { + webhook_id?: string | undefined; + webhook_name?: string | undefined; + webhook_avatar?: string | undefined; + }): ChatAuthor => ({ + id: message.webhook_id ?? '', + name: + message.webhook_name === undefined || message.webhook_name === '' + ? t('chat.webhookAuthor') + : message.webhook_name, + avatarFileId: undefined, + avatarUrl: message.webhook_avatar, + colorHex: null, + isInstanceAdmin: false, + isMember: false, + }); + return { resolve, + resolveWebhook, resolveMention: (userId) => { const author = resolve(userId); return { name: author.name, isMe: userId === currentUserId }; diff --git a/web/src/components/ui/Avatar.tsx b/web/src/components/ui/Avatar.tsx index 0ede7ae..f679f1e 100644 --- a/web/src/components/ui/Avatar.tsx +++ b/web/src/components/ui/Avatar.tsx @@ -1,10 +1,25 @@ import { avatarUrl, hueFromId, initials } from '@/lib/identity'; +/** + * Безопасная ссылка на картинку аватара: файл инстанса или внешний http(s). + * Всё остальное (в том числе `javascript:`) игнорируется. + */ +function safeImageUrl(url: string | undefined): string | null { + if (url === undefined || url === '') { + return null; + } + return url.startsWith('/files/') || url.startsWith('https://') || url.startsWith('http://') + ? url + : null; +} + interface AvatarProps { name: string; /** Стабильный id для оттенка заглушки. */ seed: string; fileId?: string | undefined; + /** Готовая ссылка (аватар вебхука) вместо `fileId`. */ + url?: string | undefined; /** Локальный предпросмотр (свежевыбранный файл) вместо `fileId`. */ preview?: string | undefined; size?: 'sm' | 'md' | 'lg' | 'xl'; @@ -28,11 +43,12 @@ export function Avatar({ name, seed, fileId, + url, preview, size = 'md', shape = 'circle', }: AvatarProps) { - const src = preview ?? avatarUrl(fileId); + const src = preview ?? safeImageUrl(url) ?? avatarUrl(fileId); const hue = hueFromId(seed); const radius = shape === 'circle' ? 'rounded-full' : 'rounded-[var(--radius-lg)]'; diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 1f8f495..2c32e4f 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -108,6 +108,7 @@ "back": "Back", "copy": "Copy", "copied": "Copied", + "delete": "Delete", "yes": "Yes", "no": "No", "optional": "optional", @@ -247,7 +248,8 @@ "inviteLink": "Invite link", "inviteCopy": "Copy", "inviteCopied": "Copied", - "inviteNew": "New link" + "inviteNew": "New link", + "settingsChannel": "Channel settings" }, "voice": { "title": "Voice room", @@ -558,7 +560,9 @@ "channelCreated": "Room #{{name}} created", "voiceChannelCreated": "Voice room {{name}} created", "memberLeave": "{{name}} left the server" - } + }, + "webhookBadge": "webhook", + "webhookAuthor": "webhook" }, "user": { "panelLabel": "User panel", @@ -633,7 +637,8 @@ "server": "Server", "voice": "Audio & video", "language": "Language & region", - "instance": "Instance" + "instance": "Instance", + "channel": "Channel" }, "profile": { "avatar.change": "Change avatar", @@ -1032,6 +1037,29 @@ "locale": "Interface language", "localeHint": "Applies immediately.", "saved": "Region settings saved." + }, + "channel": { + "noGuild": "Pick a server to configure its channel.", + "noChannels": "This server has no text channels.", + "pick": "Channel", + "noPermission": "Only the server owner and members with MANAGE_WEBHOOKS can manage the channel.", + "webhooks": { + "title": "Webhooks", + "description": "A webhook posts messages to the channel without a user session: requests go to the tokenised URL.", + "empty": "No webhooks yet.", + "create": "Create webhook", + "name": "Webhook name", + "avatar": "Avatar", + "avatarHint": "PNG, JPEG, WebP or GIF up to 2 MB.", + "submit": "Create", + "rename": "Rename", + "rotate": "New link", + "url": "Webhook URL", + "createdAt": "created {{date}}", + "limit": "{{count}} used", + "deleteConfirm": "Delete the webhook? The link stops working, messages already sent stay.", + "stepUpBody": "Creating a webhook requires confirming your identity: enter your password{{totp}}." + } } } } diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 051acb0..50966e6 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -108,6 +108,7 @@ "back": "Назад", "copy": "Скопировать", "copied": "Скопировано", + "delete": "Удалить", "yes": "Да", "no": "Нет", "optional": "необязательно", @@ -247,7 +248,8 @@ "inviteLink": "Ссылка приглашения", "inviteCopy": "Скопировать", "inviteCopied": "Скопировано", - "inviteNew": "Новая ссылка" + "inviteNew": "Новая ссылка", + "settingsChannel": "Настройки комнаты" }, "voice": { "title": "Голосовая комната", @@ -558,7 +560,9 @@ "channelCreated": "Комната #{{name}} создана", "voiceChannelCreated": "Голосовая комната {{name}} создана", "memberLeave": "{{name}} покинул сервер" - } + }, + "webhookBadge": "вебхук", + "webhookAuthor": "вебхук" }, "user": { "panelLabel": "Панель пользователя", @@ -633,7 +637,8 @@ "server": "Сервер", "voice": "Аудио-видео", "language": "Язык и регион", - "instance": "Инстанс" + "instance": "Инстанс", + "channel": "Комната" }, "profile": { "avatar.change": "Сменить аватар", @@ -1032,6 +1037,29 @@ "locale": "Язык интерфейса", "localeHint": "Переключается сразу.", "saved": "Настройки региона сохранены." + }, + "channel": { + "noGuild": "Выберите сервер, чтобы настроить комнату.", + "noChannels": "В этом сервере нет текстовых комнат.", + "pick": "Комната", + "noPermission": "Управлять комнатой может владелец сервера и участники с правом MANAGE_WEBHOOKS.", + "webhooks": { + "title": "Вебхуки", + "description": "Вебхук отправляет сообщения в комнату без пользовательской сессии: запрос идёт на ссылку с токеном.", + "empty": "Вебхуков пока нет.", + "create": "Создать вебхук", + "name": "Имя вебхука", + "avatar": "Аватар", + "avatarHint": "PNG, JPEG, WebP или GIF до 2 МБ.", + "submit": "Создать", + "rename": "Переименовать", + "rotate": "Новая ссылка", + "url": "Ссылка вебхука", + "createdAt": "создан {{date}}", + "limit": "занято {{count}}", + "deleteConfirm": "Удалить вебхук? Ссылка перестанет работать, уже отправленные сообщения останутся.", + "stepUpBody": "Создание вебхука требует подтверждения личности: введите пароль{{totp}}." + } } } } diff --git a/web/src/lib/identity.ts b/web/src/lib/identity.ts index bc119f8..472afbf 100644 --- a/web/src/lib/identity.ts +++ b/web/src/lib/identity.ts @@ -52,6 +52,7 @@ export const PERMISSION_CONNECT_VOICE = 'CONNECT_VOICE'; export const PERMISSION_MUTE_MEMBERS = 'MUTE_MEMBERS'; export const PERMISSION_DEAFEN_MEMBERS = 'DEAFEN_MEMBERS'; export const PERMISSION_MOVE_MEMBERS = 'MOVE_MEMBERS'; +export const PERMISSION_MANAGE_WEBHOOKS = 'MANAGE_WEBHOOKS'; /** Есть ли у пользователя право с указанным именем. */ export function hasPermission(permissions: readonly string[], name: string): boolean { @@ -190,6 +191,25 @@ export function canManageEmojis( ); } +/** + * Может ли пользователь управлять вебхуками комнаты (AGENT.md 7.11): + * владелец, ADMINISTRATOR, MANAGE_WEBHOOKS или администратор инстанса. + */ +export function canManageWebhooks( + permissions: readonly string[], + ownerId: string, + userId: string | undefined, + isInstanceAdmin = false, +): boolean { + if (isInstanceAdmin || (userId !== undefined && userId === ownerId)) { + return true; + } + return ( + hasPermission(permissions, PERMISSION_ADMINISTRATOR) || + hasPermission(permissions, PERMISSION_MANAGE_WEBHOOKS) + ); +} + /** * Может ли пользователь банить участников (AGENT.md 7.17): владелец, * ADMINISTRATOR, BAN_MEMBERS или администратор инстанса. diff --git a/web/src/pages/app/AppLayout.tsx b/web/src/pages/app/AppLayout.tsx index 9b5f113..63ebfc7 100644 --- a/web/src/pages/app/AppLayout.tsx +++ b/web/src/pages/app/AppLayout.tsx @@ -134,6 +134,11 @@ export default function AppLayout() { if (guild === undefined || hydrated.current === guild.id) { return; } + // Пока запрос летел, мог прийти снапшот: READY — источник правды, и + // перезаписывать его ответом REST нельзя (права и оформление сервера). + if (useSessionStore.getState().guilds.some((item) => item.id === guild.id)) { + return; + } hydrated.current = guild.id; upsertGuild({ id: guild.id, diff --git a/web/src/pages/app/GuildView.tsx b/web/src/pages/app/GuildView.tsx index 16a8588..9569cbb 100644 --- a/web/src/pages/app/GuildView.tsx +++ b/web/src/pages/app/GuildView.tsx @@ -9,9 +9,10 @@ import { SearchPanel } from '@/components/chat/SearchPanel'; import { useAuthorDirectory } from '@/components/chat/useAuthorDirectory'; import { Card } from '@/components/ui/primitives'; import { VoiceRoomPanel } from '@/components/voice/VoiceRoomPanel'; -import { canManageMessages, canSendMessages } from '@/lib/identity'; +import { canManageMessages, canManageWebhooks, canSendMessages } from '@/lib/identity'; import { useMessagesStore } from '@/stores/messages'; import { useSessionStore } from '@/stores/session'; +import { useUiStore } from '@/stores/ui'; /** * Область контента комнаты: для текстовых комнат — чат (лента, поиск, пины), @@ -29,6 +30,7 @@ export default function GuildView() { const channel = guild?.channels.find((item) => item.id === params.channelId) ?? null; const channelId = channel?.id ?? null; + const openSettings = useUiStore((state) => state.openSettings); const [searchOpen, setSearchOpen] = useState(false); const [pinsOpen, setPinsOpen] = useState(false); const [jump, setJump] = useState<{ id: string; token: number } | null>(null); @@ -73,6 +75,13 @@ export default function GuildView() { const permissions = guild?.my_permissions ?? []; const isAdmin = sessionUser?.is_instance_admin === true; const canPin = canManageMessages(permissions, isAdmin) && canSendMessages(permissions, isAdmin); + // Настройки комнаты (вебхуки, AGENT.md 7.11) — владельцу и MANAGE_WEBHOOKS. + const canConfigure = canManageWebhooks( + permissions, + guild?.owner_id ?? '', + sessionUser?.id, + isAdmin, + ); // Счётчик пинов: пока панель не открывали, ориентируемся на загруженную ленту. const pinCount = pinsLoaded ? (pins?.length ?? 0) @@ -80,7 +89,10 @@ export default function GuildView() { return (
-
+
@@ -92,6 +104,24 @@ export default function GuildView() { )} {isVoice ? null : (
+ {canConfigure ? ( + + ) : null} + + + +
+ event.target.select()} + className="w-full rounded-[var(--radius-sm)] border border-border/60 bg-surface-1 px-2 py-1 font-mono text-xs text-fg" + /> + + ))} + + + {rotate.isError ? : null} + {remove.isError ? : null} + +
+ + + {t('settings.channel.webhooks.limit', { count: webhooks.data?.length ?? 0 })} + +
+ + + setOpen(false)} + > +
{ + event.preventDefault(); + setCreateError(null); + if (name.trim() === '') { + nameInput.current?.focus(); + return; + } + create.mutate({}); + }} + > + setName(event.target.value)} + maxLength={80} + required + /> + + {createError === null || stepUpOpen ? null : } +
+ + +
+ + + {stepUpOpen ? ( +
+

{t('settings.security.stepUpTitle')}

+

+ {t('settings.channel.webhooks.stepUpBody', { + totp: + currentUser.data?.totp_enabled === true ? t('settings.security.stepUpTotp') : '', + })} +

+
{ + event.preventDefault(); + confirmIdentity.mutate(); + }} + > + setStepUpPassword(event.target.value)} + required + /> + {currentUser.data?.totp_enabled === true ? ( + setStepUpCode(event.target.value)} + /> + ) : null} + {confirmIdentity.isError ? : null} +
+ +
+ +
+ ) : null} +
+ + setRenaming(null)} + > +
{ + event.preventDefault(); + if (renaming !== null && renameValue.trim() !== '') { + rename.mutate(renaming); + } + }} + > + setRenameValue(event.target.value)} + maxLength={80} + required + /> + {rename.isError ? : null} +
+ + +
+ +
+ + ); +} diff --git a/web/src/pages/settings/SettingsModal.tsx b/web/src/pages/settings/SettingsModal.tsx index f68148b..d704310 100644 --- a/web/src/pages/settings/SettingsModal.tsx +++ b/web/src/pages/settings/SettingsModal.tsx @@ -6,6 +6,7 @@ import { useCurrentUser } from '@/lib/hooks'; import { useSessionStore } from '@/stores/session'; import { useUiStore, type SettingsSection } from '@/stores/ui'; import AppearanceSettingsPage from '@/pages/settings/AppearanceSettingsPage'; +import { ChannelSettingsPage } from '@/pages/settings/ChannelSettingsPage'; import GuildSettingsPage from '@/pages/settings/GuildSettingsPage'; import InstanceSettingsPage from '@/pages/settings/InstanceSettingsPage'; import { LanguageRegionSection } from '@/pages/settings/LanguageRegionSection'; @@ -100,6 +101,7 @@ export function SettingsModal() { { id: 'voice', label: t('settings.tabs.voice') }, { id: 'language', label: t('settings.tabs.language') }, ...(canManageGuilds ? [{ id: 'server' as const, label: t('settings.tabs.server') }] : []), + ...(canManageGuilds ? [{ id: 'channel' as const, label: t('settings.tabs.channel') }] : []), ...(isAdmin ? [{ id: 'instance' as const, label: t('settings.tabs.instance') }] : []), ]; @@ -155,6 +157,7 @@ export function SettingsModal() { {section === 'voice' ? : null} {section === 'language' ? : null} {section === 'server' ? : null} + {section === 'channel' ? : null} {section === 'instance' ? : null} {section === 'profile' ? : null} diff --git a/web/src/stores/session.ts b/web/src/stores/session.ts index 1327fab..d29fad8 100644 --- a/web/src/stores/session.ts +++ b/web/src/stores/session.ts @@ -57,6 +57,8 @@ interface SessionState { selectedChannelId: string | null; /** Последний сервер, открытый в настройках (`/settings/servers/:guildId`). */ settingsGuildId: string | null; + /** Последняя комната, открытая в настройках комнаты (вебхуки, AGENT.md 7.11). */ + settingsChannelId: string | null; applyReady: (snapshot: GatewaySnapshot) => void; /** RESUMED приходит без снапшота — обновляем только то, что пришло. */ @@ -117,6 +119,8 @@ interface SessionState { selectChannel: (guildId: string | null, channelId: string | null) => void; /** Выбор сервера в настройках — отдельно от выбора в приложении. */ selectSettingsGuild: (guildId: string | null) => void; + /** Выбор комнаты в настройках комнаты. */ + selectSettingsChannel: (channelId: string | null) => void; reset: () => void; } @@ -129,6 +133,7 @@ interface SessionDefaults { selectedGuildId: null; selectedChannelId: null; settingsGuildId: null; + settingsChannelId: null; } const defaults: SessionDefaults = { @@ -140,8 +145,20 @@ const defaults: SessionDefaults = { selectedGuildId: null, selectedChannelId: null, settingsGuildId: null, + settingsChannelId: null, }; +/** Убирает свойства со значением `undefined`: они не должны затирать данные. */ +function withoutUndefined(value: T): Partial { + const result: Partial = {}; + for (const [key, item] of Object.entries(value)) { + if (item !== undefined) { + result[key as keyof T] = item as T[keyof T]; + } + } + return result; +} + function toSessionGuild(guild: GatewayGuild): SessionGuild { const summary: SessionGuild = { id: guild.id, @@ -254,9 +271,15 @@ export const useSessionStore = create((set, get) => ({ upsertGuild: (guild) => { const guilds = get().guilds; - const index = guilds.findIndex((item) => item.id === guild.id); + // Неполный ответ REST не должен затирать данные снапшота: свойства со + // значением `undefined` в слияние не попадают. + const patch = withoutUndefined(guild); + const index = guilds.findIndex((item) => item.id === patch.id); if (index === -1) { - set({ guilds: [...guilds, { ...guild, channels: [], emojis: [], sounds: [] }] }); + // Незнакомый сервер: сводки достаточно, комнаты догрузит сайдбар. + set({ + guilds: [...guilds, { ...patch, channels: [], emojis: [], sounds: [] } as SessionGuild], + }); return; } const existing = guilds[index]; @@ -266,7 +289,7 @@ export const useSessionStore = create((set, get) => ({ // Сводка из REST не знает ни комнат, ни эмодзи, ни звуков — накопленное не теряем. const merged: SessionGuild = { ...existing, - ...guild, + ...patch, channels: existing.channels, emojis: existing.emojis, sounds: existing.sounds, @@ -529,6 +552,10 @@ export const useSessionStore = create((set, get) => ({ set({ settingsGuildId: guildId }); }, + selectSettingsChannel: (channelId) => { + set({ settingsChannelId: channelId }); + }, + reset: () => { set({ ...defaults }); }, diff --git a/web/src/stores/ui.ts b/web/src/stores/ui.ts index e4c6672..b49099f 100644 --- a/web/src/stores/ui.ts +++ b/web/src/stores/ui.ts @@ -14,6 +14,8 @@ export const settingsSections = [ 'language', /** Администрирование выбранного сервера. */ 'server', + /** Настройки выбранной комнаты: вебхуки (AGENT.md 7.11). */ + 'channel', 'instance', ] as const; export type SettingsSection = (typeof settingsSections)[number]; @@ -28,6 +30,8 @@ export interface SettingsTarget { tab?: ProfileTab; /** Сервер для пункта «Сервер» и вкладки «Профиль → Сервер». */ guildId?: string | null; + /** Комната для пункта «Комната». */ + channelId?: string | null; } interface UiState { @@ -116,6 +120,9 @@ export const useUiStore = create((set, get) => ({ if (target?.guildId !== undefined) { useSessionStore.getState().selectSettingsGuild(target.guildId); } + if (target?.channelId !== undefined) { + useSessionStore.getState().selectSettingsChannel(target.channelId); + } set({ settingsOpen: true, ...(target?.section === undefined ? {} : { settingsSection: target.section }), diff --git a/web/tests/autocomplete.test.tsx b/web/tests/autocomplete.test.tsx index 443baea..0c75bc9 100644 --- a/web/tests/autocomplete.test.tsx +++ b/web/tests/autocomplete.test.tsx @@ -103,6 +103,14 @@ const authors: AuthorDirectory = { isInstanceAdmin: false, isMember: true, }), + resolveWebhook: () => ({ + id: '', + name: 'вебхук', + avatarFileId: undefined, + colorHex: null, + isInstanceAdmin: false, + isMember: false, + }), resolveMention: (userId) => ({ name: members.find((member) => member.user_id === userId)?.display_name ?? userId, isMe: false, diff --git a/web/tests/channelWebhooks.test.tsx b/web/tests/channelWebhooks.test.tsx new file mode 100644 index 0000000..8309629 --- /dev/null +++ b/web/tests/channelWebhooks.test.tsx @@ -0,0 +1,218 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { + bodyOf, + findRequest, + installFetch, + json, + makeReadySnapshot, + makeUser, + renderApp, + resetStores, + installGatewaySocket, + messagesRoutes, + type FetchRoute, +} from './helpers'; +import { useSessionStore } from '@/stores/session'; +import { useUiStore } from '@/stores/ui'; + +/** + * Вебхуки комнаты (AGENT.md 7.11): секция «Комната» в настройках — список, + * создание со step-up, пересоздание ссылки, копирование URL и удаление. + */ + +const user = makeUser({ is_instance_admin: true }); + +const channel = { id: 'c-1', name: 'общий', type: 'text' as const, position: 0 }; + +function webhook(overrides: Record = {}) { + return { + id: 'w-1', + guild_id: 'g-1', + channel_id: 'c-1', + name: 'Деплой', + token: 'token-1', + created_by: 'user-1', + created_at: '2026-09-20T10:00:00Z', + url: 'https://gl.test/api/v1/webhooks/w-1/token-1', + ...overrides, + }; +} + +function routes(overrides: { list?: unknown[] } = {}): FetchRoute[] { + return [ + { + match: '/api/v1/users/@me/guilds', + response: () => + json({ + guilds: [ + { + id: 'g-1', + name: 'Сервер', + owner_id: 'user-1', + is_main: true, + member_count: 1, + my_permissions: ['MANAGE_WEBHOOKS', 'ADMINISTRATOR'], + my_role_ids: [], + }, + ], + }), + }, + { match: '/api/v1/users/@me', response: () => json({ user }) }, + { match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) }, + { match: '/api/v1/guilds/g-1/channels', response: () => json({ channels: [channel] }) }, + { match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) }, + { match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) }, + { + match: '/api/v1/guilds/g-1', + response: () => + json({ + guild: { + id: 'g-1', + name: 'Сервер', + owner_id: 'user-1', + is_main: true, + member_count: 1, + my_role_ids: [], + my_permissions: ['MANAGE_WEBHOOKS', 'ADMINISTRATOR'], + channels: [channel], + roles: [], + }, + }), + }, + { + match: '/api/v1/channels/c-1/webhooks', + response: () => json({ webhooks: overrides.list ?? [webhook()] }), + }, + ...messagesRoutes('c-1', []), + ]; +} + +beforeEach(() => { + resetStores(); +}); + +/** Открывает настройки комнаты с готовым списком вебхуков. */ +async function openChannelSettings(list?: unknown[]) { + const fetchMock = installFetch(routes(list === undefined ? {} : { list })); + const snapshot = makeReadySnapshot([ + { + id: 'g-1', + name: 'Сервер', + is_main: true, + my_permissions: ['MANAGE_WEBHOOKS', 'ADMINISTRATOR'], + channels: [channel], + }, + ]); + const gateway = installGatewaySocket(snapshot); + renderApp('/app/g-1/c-1'); + await gateway.greet(); + await screen.findByTestId('message-list'); + await userEvent.click(await screen.findByTestId('channel-settings')); + const content = await screen.findByTestId('settings-content'); + await within(content).findByTestId('channel-webhooks'); + return { fetchMock, content }; +} + +describe('вебхуки комнаты', () => { + it('показывает список со ссылкой и копирует её', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }); + const { content } = await openChannelSettings(); + + const item = within(content).getByTestId('webhook-w-1'); + expect(within(item).getByText('Деплой')).toBeVisible(); + expect(within(item).getByTestId('webhook-url-w-1')).toHaveValue( + 'https://gl.test/api/v1/webhooks/w-1/token-1', + ); + + await userEvent.click(within(item).getByRole('button', { name: 'Скопировать' })); + expect(writeText).toHaveBeenCalledWith('https://gl.test/api/v1/webhooks/w-1/token-1'); + expect(await within(item).findByText('Скопировано')).toBeInTheDocument(); + }); + + it('создаёт вебхук и подтверждает личность по запросу сервера', async () => { + const { fetchMock, content } = await openChannelSettings([]); + // Первый раз сервер требует step-up: секция показывает форму подтверждения. + fetchMock.mockImplementationOnce(() => + Promise.resolve(json({ error: { code: 'auth.step_up_required', message: 'need' } }, 403)), + ); + fetchMock.mockImplementationOnce(() => + Promise.resolve(json({ error: { code: 'auth.step_up_required', message: 'need' } }, 403)), + ); + + await userEvent.click(within(content).getByTestId('webhook-create-open')); + const dialog = await screen.findByRole('dialog', { name: 'Создать вебхук' }); + await userEvent.type(within(dialog).getByLabelText('Имя вебхука'), 'Новый'); + await userEvent.click(within(dialog).getByRole('button', { name: 'Создать' })); + + const stepUpField = await within(dialog).findByLabelText('Ваш пароль'); + await userEvent.type(stepUpField, 'correct-horse-battery'); + await userEvent.click(within(dialog).getByRole('button', { name: 'Подтвердить' })); + + await waitFor(() => { + expect(findRequest(fetchMock, { url: '/auth/step-up', method: 'POST' })).toBeDefined(); + }); + await waitFor(() => { + const created = findRequest(fetchMock, { url: '/channels/c-1/webhooks', method: 'POST' }); + expect(bodyOf(created)).toMatchObject({ name: 'Новый' }); + }); + }); + + it('пересоздаёт ссылку и удаляет вебхук', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(true); + const { fetchMock, content } = await openChannelSettings(); + + const item = within(content).getByTestId('webhook-w-1'); + await userEvent.click(within(item).getByTestId('webhook-rotate-w-1')); + await waitFor(() => { + expect(findRequest(fetchMock, { url: '/webhooks/w-1', method: 'PATCH' })?.body).toMatchObject( + { regenerate_token: true }, + ); + }); + + await userEvent.click(within(item).getByTestId('webhook-delete-w-1')); + await waitFor(() => { + expect(findRequest(fetchMock, { url: '/webhooks/w-1', method: 'DELETE' })).toBeDefined(); + }); + }); + + it('не показывает секцию без права MANAGE_WEBHOOKS', async () => { + const fetchMock = installFetch(routes({ list: [{ ...webhook(), id: 'w-2' }] })); + const snapshot = makeReadySnapshot([ + { + id: 'g-1', + name: 'Сервер', + is_main: true, + // Права есть, но владелец — другой пользователь и админа нет. + owner_id: 'user-9', + my_permissions: [], + channels: [channel], + }, + ]); + const gateway = installGatewaySocket(snapshot); + renderApp('/app/g-1/c-1'); + await gateway.greet(); + await screen.findByTestId('message-list'); + + // Кнопки настроек комнаты нет вовсе: секция недоступна из интерфейса. + expect(screen.queryByTestId('channel-settings')).toBeNull(); + expect(useSessionStore.getState().guilds[0]?.my_permissions).toEqual([]); + await waitFor(() => { + expect(fetchMock).toBeDefined(); + }); + }); + + it('открывает настройки комнаты из шапки чата', async () => { + await openChannelSettings(); + + expect(useUiStore.getState().settingsOpen).toBe(true); + expect(useUiStore.getState().settingsSection).toBe('channel'); + expect(useSessionStore.getState().settingsChannelId).toBe('c-1'); + }); +}); diff --git a/web/tests/composerFormat.test.tsx b/web/tests/composerFormat.test.tsx index 90e12a0..c3145b8 100644 --- a/web/tests/composerFormat.test.tsx +++ b/web/tests/composerFormat.test.tsx @@ -33,6 +33,14 @@ const authors: AuthorDirectory = { isInstanceAdmin: false, isMember: true, }), + resolveWebhook: () => ({ + id: '', + name: 'вебхук', + avatarFileId: undefined, + colorHex: null, + isInstanceAdmin: false, + isMember: false, + }), resolveMention: (userId) => ({ name: userId === 'user-2' ? 'Bob' : userId, isMe: false }), loaded: true, }; diff --git a/web/tests/guildEmojis.test.tsx b/web/tests/guildEmojis.test.tsx index 14459df..4d175bd 100644 --- a/web/tests/guildEmojis.test.tsx +++ b/web/tests/guildEmojis.test.tsx @@ -74,6 +74,14 @@ const authors: AuthorDirectory = { isInstanceAdmin: false, isMember: true, }), + resolveWebhook: () => ({ + id: '', + name: 'вебхук', + avatarFileId: undefined, + colorHex: null, + isInstanceAdmin: false, + isMember: false, + }), resolveMention: (userId) => ({ name: userId, isMe: false }), resolveMemberName: (userId) => `user ${userId}`, loaded: true, diff --git a/web/tests/guildHeaderAndSounds.test.tsx b/web/tests/guildHeaderAndSounds.test.tsx index f8dba77..7579949 100644 --- a/web/tests/guildHeaderAndSounds.test.tsx +++ b/web/tests/guildHeaderAndSounds.test.tsx @@ -89,14 +89,22 @@ function routes(guild: Record = guildDetail()): FetchRoute[] { ]; } -/** Рендерит приложение с READY-снапшотом сервера. */ +/** + * Рендерит приложение с READY-снапшотом сервера. Права и владелец берутся из + * того же описания сервера, что и ответ REST: снапшот — источник правды. + */ async function bootApp(guild: Record = guildDetail()) { + const permissions = Array.isArray(guild.my_permissions) + ? (guild.my_permissions as string[]) + : ['MANAGE_GUILD', 'CREATE_INVITES']; + const ownerId = typeof guild.owner_id === 'string' ? guild.owner_id : 'user-1'; const snapshot = makeReadySnapshot([ { id: 'g-1', name: 'Сервер', is_main: true, - my_permissions: ['MANAGE_GUILD', 'CREATE_INVITES'], + owner_id: ownerId, + my_permissions: permissions, channels, voice_states: [makeVoiceState({ user_id: 'user-2', channel_id: 'c-voice' })], // Оформление приходит в снапшоте шлюза — так же, как в ReadyGuild. diff --git a/web/tests/messageTypes.test.tsx b/web/tests/messageTypes.test.tsx index 5270d64..311d9a5 100644 --- a/web/tests/messageTypes.test.tsx +++ b/web/tests/messageTypes.test.tsx @@ -21,10 +21,25 @@ const authors: AuthorDirectory = { isInstanceAdmin: false, isMember: true, }), + resolveWebhook: () => ({ + id: '', + name: 'вебхук', + avatarFileId: undefined, + colorHex: null, + isInstanceAdmin: false, + isMember: false, + }), resolveMention: (userId) => ({ name: userId === 'user-1' ? 'Аля' : 'Bob', isMe: false }), loaded: true, }; +/** Сообщение без автора-пользователя: так приходят сообщения вебхуков. */ +function authorlessMessage(overrides: Partial & { id: string }): Message { + const base = message(overrides); + delete base.author_id; + return base; +} + function message(overrides: Partial & { id: string }): Message { return { channel_id: 'c-1', @@ -196,3 +211,59 @@ describe('виды сообщений: действия', () => { expect(within(groups[1] as HTMLElement).getByText('Bob')).toBeVisible(); }); }); + +describe('сообщения вебхуков', () => { + it('показывает имя и аватар вебхука, а не «неизвестного автора»', () => { + const webhookAuthors: AuthorDirectory = { + ...authors, + resolveWebhook: (message) => ({ + id: message.webhook_id ?? '', + name: message.webhook_name ?? 'вебхук', + avatarFileId: undefined, + avatarUrl: message.webhook_avatar, + colorHex: null, + isInstanceAdmin: false, + isMember: false, + }), + }; + render( + undefined} + onLoadMore={() => undefined} + onReply={vi.fn()} + />, + ); + + const body = article('w-1'); + expect(body).toHaveAttribute('data-message-type', 'webhook'); + // Имя, значок и аватар рисует шапка группы (само сообщение — только текст). + const group = screen.getByTestId('message-group'); + expect(group).toHaveAttribute('data-message-type', 'webhook'); + expect(within(group).getByText('Деплой')).toBeVisible(); + expect(within(group).getByTestId('webhook-badge')).toHaveTextContent('вебхук'); + expect(group.querySelector('img')?.getAttribute('src')).toBe('/files/777'); + expect(within(group).queryByText('неизвестный')).toBeNull(); + }); +}); diff --git a/web/tests/session.test.ts b/web/tests/session.test.ts index f9b760a..e237edf 100644 --- a/web/tests/session.test.ts +++ b/web/tests/session.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { act } from '@testing-library/react'; import { parseGatewaySnapshot } from '@/api/gateway'; +import type { GuildSummary } from '@/api/types'; import { dispatchGatewayEvent, useGatewayStore } from '@/stores/gateway'; import { useSessionStore } from '@/stores/session'; import { makeReadySnapshot } from './helpers'; @@ -63,6 +64,46 @@ describe('session store', () => { expect(useGatewayStore.getState().heartbeatIntervalMs).toBe(45_000); }); + it('не затирает данные снапшота неполной сводкой REST', () => { + act(() => { + dispatchGatewayEvent({ + op: 0, + t: 'READY', + s: 1, + d: makeReadySnapshot([ + { + id: 'g-1', + name: 'Main', + owner_id: 'user-9', + is_main: true, + my_permissions: ['MANAGE_WEBHOOKS'], + channels: [{ id: 'c-1', name: 'general', position: 0 }], + }, + ]), + }); + }); + + // Ответ REST пришёл без части полей: undefined не должен перетирать снапшот. + act(() => { + useSessionStore.getState().upsertGuild({ + id: 'g-1', + name: 'Main из REST', + owner_id: undefined, + is_main: undefined, + member_count: undefined, + my_role_ids: undefined, + my_permissions: undefined, + } as unknown as GuildSummary); + }); + + const guild = useSessionStore.getState().guilds[0]; + expect(guild?.name).toBe('Main из REST'); + expect(guild?.owner_id).toBe('user-9'); + expect(guild?.is_main).toBe(true); + expect(guild?.my_permissions).toEqual(['MANAGE_WEBHOOKS']); + expect(guild?.channels.map((channel) => channel.id)).toEqual(['c-1']); + }); + it('keeps channels with can_send=false (only can_view=false is hidden)', () => { act(() => { dispatchGatewayEvent({ diff --git a/web/tests/systemMessages.test.tsx b/web/tests/systemMessages.test.tsx index 5ff24c3..f747e74 100644 --- a/web/tests/systemMessages.test.tsx +++ b/web/tests/systemMessages.test.tsx @@ -31,6 +31,14 @@ const authors: AuthorDirectory = { isInstanceAdmin: false, isMember: userId === 'user-2', }), + resolveWebhook: () => ({ + id: '', + name: 'вебхук', + avatarFileId: undefined, + colorHex: null, + isInstanceAdmin: false, + isMember: false, + }), resolveMention: (userId) => ({ name: userId === 'user-2' ? 'Боб' : userId, isMe: false }), resolveMemberName: (userId) => (userId === 'user-2' ? 'Боб' : null), loaded: true,