mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
fix(webhook): validate events and types, sort events and show delivery URLs
webhook create --events pushh created a webhook that fired on nothing, since Gitea drops unknown events, and unknown types or a Slack webhook without a channel failed with raw HTTP 422 errors. --events and --type now accept only what Gitea knows, list it in help and complete it, and a Slack webhook needs --config channel=. Events came back in a different order on every call; they are sorted now. Webhooks can be told apart again: list, view and JSON show the delivery URL without user information and query, and without the path for chat services whose paths carry tokens; view --reveal-url shows it whole. Tables collapse complete event groups such as pull_request (8). On a terminal, webhook edit no longer needs an authorization flag: since Gitea drops the stored header on every edit and never returns it, it asks for the header again and keeps it, and an empty answer suits webhooks without one. Without a terminal the edit still requires the flags. Edit flags lose the create defaults, the unsupported secret flags are hidden on edit, deletions name the webhook and its scope, and mutations print ✓ lines. terminal.View gains Bodyless for resources without a body. Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
8 files changed
+666
-248
No files matched your search
@@ -4,17 +4,25 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
gitea "gitea.dev/sdk"
|
||||
"gitea.dev/tea/internal/transport"
|
||||
)
|
||||
|
||||
// Webhook is safe webhook metadata. Delivery URLs and credentials are omitted.
|
||||
// Webhook is webhook metadata. Its delivery URL omits credentials unless revealed; signing secrets
|
||||
// and authorization headers are never returned.
|
||||
type Webhook struct {
|
||||
ID int64 `json:"id"`
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url"`
|
||||
ContentType string `json:"content_type"`
|
||||
Active bool `json:"active"`
|
||||
Events []string `json:"events"`
|
||||
BranchFilter string `json:"branch_filter"`
|
||||
@@ -22,14 +30,52 @@ type Webhook struct {
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func webhookFrom(hook *gitea.Hook) Webhook {
|
||||
events := hook.Events
|
||||
// WebhookEvents are the events that webhooks accept. issues and pull_request select every event of
|
||||
// their group; issues_only and pull_request_only select the events of the issue or pull request itself.
|
||||
var WebhookEvents = []string{
|
||||
"create", "delete", "fork", "push",
|
||||
"issues", "issues_only", "issue_assign", "issue_label", "issue_milestone", "issue_comment",
|
||||
"pull_request", "pull_request_only", "pull_request_assign", "pull_request_label", "pull_request_milestone",
|
||||
"pull_request_comment", "pull_request_review", "pull_request_sync", "pull_request_review_request",
|
||||
"wiki", "repository", "release", "package", "status", "workflow_run", "workflow_job",
|
||||
}
|
||||
|
||||
// WebhookTypes are the adapters that deliver webhooks.
|
||||
var WebhookTypes = []string{"gitea", "gogs", "slack", "discord", "dingtalk", "telegram", "msteams", "feishu", "matrix", "wechatwork", "packagist"}
|
||||
|
||||
// tokenPaths are the adapters whose delivery URL paths carry access tokens.
|
||||
var tokenPaths = []string{"slack", "discord", "telegram", "msteams", "feishu"}
|
||||
|
||||
// redactURL drops the credentials a delivery URL may carry: user information, the query, and the
|
||||
// path of adapters with a token in it. "…" marks what was dropped.
|
||||
func redactURL(raw, adapter string) string {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Host == "" {
|
||||
return ""
|
||||
}
|
||||
path := parsed.EscapedPath()
|
||||
if slices.Contains(tokenPaths, adapter) && path != "" && path != "/" {
|
||||
path = "/…"
|
||||
}
|
||||
redacted := parsed.Scheme + "://" + parsed.Host + path
|
||||
if parsed.RawQuery != "" {
|
||||
redacted += "?…"
|
||||
}
|
||||
return redacted
|
||||
}
|
||||
|
||||
func webhookFrom(hook *gitea.Hook, reveal bool) Webhook {
|
||||
events := slices.Sorted(slices.Values(hook.Events))
|
||||
if events == nil {
|
||||
events = []string{}
|
||||
}
|
||||
address := hook.Config["url"]
|
||||
if !reveal {
|
||||
address = redactURL(address, hook.Type)
|
||||
}
|
||||
return Webhook{
|
||||
ID: hook.ID, Type: hook.Type, Active: hook.Active, Events: events, BranchFilter: hook.BranchFilter,
|
||||
CreatedAt: timestamp(hook.Created), UpdatedAt: timestamp(hook.Updated),
|
||||
ID: hook.ID, Type: hook.Type, URL: address, ContentType: hook.Config["content_type"], Active: hook.Active,
|
||||
Events: events, BranchFilter: hook.BranchFilter, CreatedAt: timestamp(hook.Created), UpdatedAt: timestamp(hook.Updated),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,30 +99,36 @@ type WebhookOptions struct {
|
||||
type Webhooks struct {
|
||||
Client *transport.Client
|
||||
Scope WebhookScope
|
||||
// RevealURL returns delivery URLs whole, with the credentials they may carry.
|
||||
RevealURL bool
|
||||
}
|
||||
|
||||
func (s Webhooks) from(hook *gitea.Hook) Webhook {
|
||||
return webhookFrom(hook, s.RevealURL)
|
||||
}
|
||||
|
||||
// List returns a page of hook metadata.
|
||||
func (s Webhooks) List(ctx context.Context, limit, page int) ([]Webhook, error) {
|
||||
hooks, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.Hook, *gitea.Response, error) {
|
||||
opts := gitea.ListHooksOptions{ListOptions: listOptions(limit, page)}
|
||||
switch {
|
||||
case s.Scope.Global:
|
||||
return c.Hooks.ListGlobalHooks(ctx, gitea.ListGlobalHooksOptions{ListOptions: opts.ListOptions})
|
||||
case s.Scope.Account:
|
||||
return c.Hooks.ListMyHooks(ctx, opts)
|
||||
case s.Scope.Organization != "":
|
||||
return c.Hooks.ListOrgHooks(ctx, s.Scope.Organization, opts)
|
||||
}
|
||||
owner, name, err := splitRepository(s.Scope.Repository)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return c.Hooks.ListRepoHooks(ctx, owner, name, opts)
|
||||
func (s Webhooks) List(ctx context.Context, limit, page int) (Page[Webhook], error) {
|
||||
hooks, err := listPage(limit, page, func(limit, page int) ([]*gitea.Hook, http.Header, error) {
|
||||
hooks, resp, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.Hook, *gitea.Response, error) {
|
||||
opts := gitea.ListHooksOptions{ListOptions: listOptions(limit, page)}
|
||||
switch {
|
||||
case s.Scope.Global:
|
||||
return c.Hooks.ListGlobalHooks(ctx, gitea.ListGlobalHooksOptions{ListOptions: opts.ListOptions})
|
||||
case s.Scope.Account:
|
||||
return c.Hooks.ListMyHooks(ctx, opts)
|
||||
case s.Scope.Organization != "":
|
||||
return c.Hooks.ListOrgHooks(ctx, s.Scope.Organization, opts)
|
||||
}
|
||||
owner, name, err := splitRepository(s.Scope.Repository)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return c.Hooks.ListRepoHooks(ctx, owner, name, opts)
|
||||
})
|
||||
return hooks, headerOf(resp), err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return convertAll(hooks, webhookFrom), nil
|
||||
return convertPage(hooks, s.from), err
|
||||
}
|
||||
|
||||
func (s Webhooks) get(ctx context.Context, id int64) (*gitea.Hook, error) {
|
||||
@@ -95,7 +147,7 @@ func (s Webhooks) get(ctx context.Context, id int64) (*gitea.Hook, error) {
|
||||
}
|
||||
return c.Hooks.GetRepoHook(ctx, owner, name, id)
|
||||
})
|
||||
return hook, err
|
||||
return hook, Classify(err, "webhook", strconv.FormatInt(id, 10), cmp.Or(s.Scope.Repository, s.Scope.Organization))
|
||||
}
|
||||
|
||||
// View retrieves hook metadata without exposing its delivery configuration.
|
||||
@@ -104,7 +156,7 @@ func (s Webhooks) View(ctx context.Context, id int64) (Webhook, error) {
|
||||
if err != nil {
|
||||
return Webhook{}, err
|
||||
}
|
||||
return webhookFrom(hook), nil
|
||||
return s.from(hook), nil
|
||||
}
|
||||
|
||||
// Create registers a new webhook.
|
||||
@@ -136,7 +188,7 @@ func (s Webhooks) Create(ctx context.Context, opts WebhookOptions) (Webhook, err
|
||||
if err != nil {
|
||||
return Webhook{}, err
|
||||
}
|
||||
return webhookFrom(hook), nil
|
||||
return s.from(hook), nil
|
||||
}
|
||||
|
||||
// Edit preserves settings that Gitea replaces rather than patching individually.
|
||||
@@ -189,7 +241,7 @@ func (s Webhooks) Edit(ctx context.Context, id int64, opts WebhookOptions) (Webh
|
||||
if err != nil {
|
||||
return Webhook{}, err
|
||||
}
|
||||
return webhookFrom(hook), nil
|
||||
return s.from(hook), nil
|
||||
}
|
||||
|
||||
// Delete removes a webhook from the selected scope.
|
||||
|
||||
@@ -133,7 +133,7 @@ func TestWebhookScopesSelectEndpoints(t *testing.T) {
|
||||
service := Webhooks{Client: client, Scope: tc.scope}
|
||||
items, err := service.List(context.Background(), 10, 2)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []Webhook{{ID: 3, Type: "gitea", Events: []string{}}}, items)
|
||||
require.Equal(t, []Webhook{{ID: 3, Type: "gitea", Events: []string{}}}, items.Items)
|
||||
require.NoError(t, service.Delete(context.Background(), 3))
|
||||
require.Equal(t, []string{"GET " + tc.path + "?limit=10&page=2", "DELETE " + tc.path + "/3"}, requests)
|
||||
})
|
||||
@@ -142,9 +142,32 @@ func TestWebhookScopesSelectEndpoints(t *testing.T) {
|
||||
|
||||
func TestWebhookFromMapsTimestamps(t *testing.T) {
|
||||
hook := decode[gitea.Hook](t, `{"id":1,"type":"gitea","active":true,"events":["push"],"branch_filter":"main",
|
||||
"created_at":"2026-10-01T12:00:00Z","updated_at":"2026-10-02T12:00:00+02:00","config":{"url":"https://private.invalid"}}`)
|
||||
"created_at":"2026-10-01T12:00:00Z","updated_at":"2026-10-02T12:00:00+02:00","config":{"url":"https://hooks.invalid/ci","content_type":"form"}}`)
|
||||
require.Equal(t, Webhook{
|
||||
ID: 1, Type: "gitea", Active: true, Events: []string{"push"}, BranchFilter: "main",
|
||||
ID: 1, Type: "gitea", URL: "https://hooks.invalid/ci", ContentType: "form", Active: true, Events: []string{"push"}, BranchFilter: "main",
|
||||
CreatedAt: "2026-10-01T12:00:00Z", UpdatedAt: "2026-10-02T12:00:00+02:00",
|
||||
}, webhookFrom(hook))
|
||||
}, webhookFrom(hook, false))
|
||||
}
|
||||
|
||||
func TestWebhookFromSortsEvents(t *testing.T) {
|
||||
hook := decode[gitea.Hook](t, `{"id":1,"type":"gitea","events":["push","issues","issue_label","create"]}`)
|
||||
require.Equal(t, []string{"create", "issue_label", "issues", "push"}, webhookFrom(hook, false).Events)
|
||||
}
|
||||
|
||||
func TestWebhookFromRedactsDeliveryURLs(t *testing.T) {
|
||||
for _, tc := range []struct{ adapter, raw, want string }{
|
||||
{"gitea", "https://ci.example.com/hooks/gitea", "https://ci.example.com/hooks/gitea"},
|
||||
{"gitea", "https://user:pass@ci.example.com/hook?token=private", "https://ci.example.com/hook?…"},
|
||||
{"packagist", "https://packagist.org/api/update-package?username=a&apiToken=private", "https://packagist.org/api/update-package?…"},
|
||||
{"slack", "https://hooks.slack.com/services/T0/B0/private", "https://hooks.slack.com/…"},
|
||||
{"discord", "https://discord.com/api/webhooks/1/private", "https://discord.com/…"},
|
||||
{"telegram", "https://api.telegram.org/botprivate/sendMessage?chat_id=1", "https://api.telegram.org/…?…"},
|
||||
{"matrix", "https://matrix.example.com/_matrix/client/v3/rooms/!room/send/m.room.message", "https://matrix.example.com/_matrix/client/v3/rooms/!room/send/m.room.message"},
|
||||
} {
|
||||
t.Run(tc.adapter, func(t *testing.T) {
|
||||
hook := &gitea.Hook{ID: 1, Type: tc.adapter, Config: map[string]string{"url": tc.raw}}
|
||||
require.Equal(t, tc.want, webhookFrom(hook, false).URL)
|
||||
require.Equal(t, tc.raw, webhookFrom(hook, true).URL)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user