Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion internal/cli/notify_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,11 @@ func runNotify(ctx context.Context, args []string, stdin io.Reader, stdout, stde
if webhook == "" {
return withCode(exitUsage, errors.New("provide the webhook URL with --webhook-url-stdin, or run this command in a terminal"))
}
if err := notify.ValidateWebhookURL(webhook); err != nil {
normalized, _, err := notify.NormalizeWebhookURL(webhook)
if err != nil {
return withCode(exitUsage, err)
}
webhook = normalized
discord.Enabled = true
if *events != "" {
parsed, parseErr := parseNotifyEvents(*events)
Expand Down
13 changes: 13 additions & 0 deletions internal/cli/notify_cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,16 @@ type failingSender struct{}
func (failingSender) Send(context.Context, notify.Event) error {
return errors.New("Discord is unavailable")
}

func TestNotifyDiscordStdinAcceptsDoublePastedWebhook(t *testing.T) {
paths := notifyTestPaths(t)
stubNotifySend(t, nil)
code, _, stderr := runNotifyCommand(t, paths, cliTestWebhook+cliTestWebhook+"\n", "discord", "--webhook-url-stdin")
if code != exitSuccess {
t.Fatalf("exit %d: %s", code, stderr)
}
credentials, _ := config.LoadCredentials(paths.Credentials)
if credentials.Notifications.DiscordWebhookURL != cliTestWebhook {
t.Fatalf("stored webhook = %q", credentials.Notifications.DiscordWebhookURL)
}
}
38 changes: 38 additions & 0 deletions internal/notify/discord.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,44 @@ func ValidateWebhookURL(raw string) error {
return nil
}

var webhookInText = regexp.MustCompile(`(?i:https://(?:(?:ptb|canary)\.)?discord(?:app)?\.com)/api/(?:v[0-9]+/)?webhooks/[0-9]{15,22}/[A-Za-z0-9_.-]{20,}`)

// NormalizeWebhookURL extracts the webhook URL from pasted text. Terminals
// can deliver a paste twice (the terminal pastes and the input field also
// handles Ctrl+V), and people copy URLs with quotes or angle brackets.
func NormalizeWebhookURL(raw string) (webhook string, duplicated bool, err error) {
// Separate URLs that were glued together ("...TOKENhttps://...").
text := strings.ReplaceAll(raw, "https://", " https://")
matches := webhookInText.FindAllString(text, -1)
if len(matches) == 0 {
trimmed := strings.Trim(strings.TrimSpace(raw), "<>\"'`")
if strings.Count(strings.ToLower(trimmed), "https://") > 1 {
return "", false, errors.New("the pasted text contains more than one URL; paste the webhook URL once")
}
return "", false, ValidateWebhookURL(trimmed)
}
for _, match := range matches[1:] {
if match != matches[0] {
return "", false, errors.New("two different webhook URLs were pasted; paste only the one you want to use")
}
}
leftover := text
for _, match := range matches {
leftover = strings.Replace(leftover, match, "", 1)
}
leftover = strings.Trim(leftover, " \t\r\n<>\"'`")
switch {
case strings.HasPrefix(leftover, "?"):
return "", false, errors.New("webhook URLs with options after \"?\" (such as thread_id) are not supported; use the plain webhook URL")
case leftover != "":
return "", false, errors.New("the webhook URL has extra text around it; paste only the URL copied from Discord")
}
if err := ValidateWebhookURL(matches[0]); err != nil {
return "", false, err
}
return matches[0], len(matches) > 1, nil
}

// MaskWebhookURL shows enough of a webhook to recognise it without exposing
// its token.
func MaskWebhookURL(raw string) string {
Expand Down
65 changes: 65 additions & 0 deletions internal/notify/normalize_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package notify

import (
"strings"
"testing"
)

func TestNormalizeWebhookURLAcceptsCommonPastes(t *testing.T) {
cases := map[string]struct {
input string
duplicated bool
}{
"plain": {testWebhook, false},
"surrounding whitespace": {" " + testWebhook + " \r\n", false},
"angle brackets": {"<" + testWebhook + ">", false},
"quotes": {`"` + testWebhook + `"`, false},
"pasted twice glued": {testWebhook + testWebhook, true},
"pasted twice with a space": {testWebhook + " " + testWebhook, true},
"pasted three times": {strings.Repeat(testWebhook, 3), true},
"uppercase host": {strings.Replace(testWebhook, "discord.com", "Discord.com", 1), false},
"canary host": {strings.Replace(testWebhook, "discord.com", "canary.discord.com", 1), false},
"versioned api": {strings.Replace(testWebhook, "/api/", "/api/v10/", 1), false},
}
for name, testCase := range cases {
got, duplicated, err := NormalizeWebhookURL(testCase.input)
if err != nil {
t.Errorf("%s: unexpected error %v", name, err)
continue
}
if ValidateWebhookURL(got) != nil || strings.Contains(got, " ") || strings.Count(got, "https://") != 1 {
t.Errorf("%s: normalized URL is not a single valid webhook: %q", name, got)
}
if duplicated != testCase.duplicated {
t.Errorf("%s: duplicated = %v, want %v", name, duplicated, testCase.duplicated)
}
}
}

func TestNormalizeWebhookURLRejectsAmbiguousInput(t *testing.T) {
other := strings.Replace(testWebhook, "123456789012345678", "876543210987654321", 1)
cases := map[string]struct {
input string
message string
}{
"two different webhooks": {testWebhook + other, "two different"},
"thread query": {testWebhook + "?thread_id=123", "thread_id"},
"trailing text": {testWebhook + " please", "extra text"},
"not discord": {"https://example.com/api/webhooks/123456789012345678/abcdefghijklmnopqrstuvwxyz", "discord.com"},
"truncated token": {"https://discord.com/api/webhooks/123456789012345678/short", "does not look like"},
"two non-webhook URLs": {"https://discord.com/channels/1/2https://discord.com/channels/3/4", "more than one URL"},
}
for name, testCase := range cases {
_, _, err := NormalizeWebhookURL(testCase.input)
if err == nil {
t.Errorf("%s: expected an error", name)
continue
}
if !strings.Contains(err.Error(), testCase.message) {
t.Errorf("%s: error %q does not mention %q", name, err, testCase.message)
}
if strings.Contains(err.Error(), "secret") {
t.Errorf("%s: error leaks the token: %v", name, err)
}
}
}
8 changes: 7 additions & 1 deletion internal/wizard/notifications.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,16 @@ func ConfigureDiscord(ctx context.Context, prompt *Prompter, current config.Disc
}
continue
}
if err := notify.ValidateWebhookURL(webhook); err != nil {
normalized, duplicated, err := notify.NormalizeWebhookURL(webhook)
if err != nil {
prompt.Fail(err.Error())
continue
}
webhook = normalized
if duplicated {
prompt.Note("The URL was pasted more than once; using it once.")
}
prompt.Success("Webhook " + notify.MaskWebhookURL(webhook))

presetIndex := 0
for index, preset := range eventPresets {
Expand Down
24 changes: 24 additions & 0 deletions internal/wizard/notifications_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,27 @@ func TestConfigureDiscordFailedTestCanBeSkipped(t *testing.T) {
t.Fatalf("output:\n%s", output.String())
}
}

func TestConfigureDiscordAcceptsDoublePastedWebhook(t *testing.T) {
var output bytes.Buffer
var sentTo string
prompt := NewPrompter(strings.NewReader(wizardTestWebhook+wizardTestWebhook+"\n\n\n"), &output)
setup, err := ConfigureDiscord(context.Background(), prompt, config.DiscordNotifications{}, "",
func(_ context.Context, webhook string, _ config.DiscordNotifications, _ notify.Event) error {
sentTo = webhook
return nil
})
if err != nil {
t.Fatalf("double-pasted webhook was rejected: %v\n%s", err, output.String())
}
if setup.WebhookURL != wizardTestWebhook || sentTo != wizardTestWebhook {
t.Fatalf("webhook = %q, test sent to %q", setup.WebhookURL, sentTo)
}
text := output.String()
if !strings.Contains(text, "pasted more than once") || !strings.Contains(text, "/api/webhooks/123456789012345678/") {
t.Fatalf("missing duplicate note or masked confirmation:\n%s", text)
}
if strings.Contains(text, "secret-token") {
t.Fatal("webhook token was shown")
}
}
Loading