Files
MealQuest/internal/chat/webhook.go
T
2026-10-09 18:04:05 -04:00

116 lines
4.2 KiB
Go

package chat
import (
"errors"
"fmt"
"net/url"
"regexp"
"strings"
)
const (
// webhookPrefix is how every accepted webhook URL starts, compared
// exactly: no other scheme, host spelling, port or user info.
webhookPrefix = "https://chat.googleapis.com/v1/spaces/"
// webhookHost is the only host a webhook URL may name.
webhookHost = "chat.googleapis.com"
// MaxWebhookURLLength bounds an accepted webhook URL.
MaxWebhookURLLength = 2048
// MinCredentialLength is the shortest key or token accepted, so the
// masked URL's token tail never gives away most of it.
MinCredentialLength = 16
// maskedTail is how many trailing characters of the token the masked
// URL shows.
maskedTail = 4
)
// webhookPath is the path of a space's incoming webhook; the group is the
// space id.
var webhookPath = regexp.MustCompile(`^/v1/spaces/([A-Za-z0-9_-]{1,128})/messages$`)
// credentialChars is the alphabet of a decoded key or token: URL-safe
// ASCII, so its length in bytes is its length in characters.
var credentialChars = regexp.MustCompile(`^[A-Za-z0-9_.~=-]*$`)
// Webhook is a validated Google Chat incoming webhook URL. Its String is
// the full URL, a secret: it is only ever sealed for storage or sent to
// Google Chat, never logged, shown or put in an error.
type Webhook struct {
raw string
space string
token string
}
// ParseWebhook validates raw as
// https://chat.googleapis.com/v1/spaces/{space}/messages?key=…&token=…,
// whose raw query is exactly key=…&token=…, in that order, each decoding to
// at least MinCredentialLength characters of credentialChars. Its error
// never quotes raw.
func ParseWebhook(raw string) (Webhook, error) {
switch {
case raw == "":
return Webhook{}, errors.New("is required")
case len(raw) > MaxWebhookURLLength:
return Webhook{}, fmt.Errorf("must be at most %d characters", MaxWebhookURLLength)
case strings.ContainsFunc(raw, func(r rune) bool { return r <= ' ' || r == 0x7f }):
return Webhook{}, errors.New("must not contain spaces or control characters")
case !strings.HasPrefix(raw, webhookPrefix):
return Webhook{}, errors.New("must be a Google Chat incoming webhook URL starting with " + webhookPrefix)
case strings.Contains(raw, "#"):
return Webhook{}, errors.New("must not have a fragment")
}
u, err := url.Parse(raw)
if err != nil {
return Webhook{}, errors.New("is not a valid URL")
}
if u.Scheme != "https" || u.Host != webhookHost || u.User != nil || u.Opaque != "" || u.RawPath != "" {
return Webhook{}, errors.New("must be a Google Chat incoming webhook URL starting with " + webhookPrefix)
}
m := webhookPath.FindStringSubmatch(u.Path)
if m == nil {
return Webhook{}, errors.New("path must be /v1/spaces/{space}/messages")
}
// The raw query is exactly key=…&token=…: no empty segment, no second
// "?", no other or repeated parameter.
if strings.Contains(u.RawQuery, "?") {
return Webhook{}, errors.New("query must not contain ?")
}
segments := strings.Split(u.RawQuery, "&")
if len(segments) != 2 {
return Webhook{}, errors.New("query must be exactly key=…&token=…")
}
creds := make([]string, 2)
for i, name := range []string{"key", "token"} {
escaped, ok := strings.CutPrefix(segments[i], name+"=")
if !ok {
return Webhook{}, errors.New("query must be exactly key=…&token=…")
}
v, err := url.QueryUnescape(escaped)
if err != nil {
return Webhook{}, errors.New("query is not valid")
}
if !credentialChars.MatchString(v) {
return Webhook{}, fmt.Errorf("%s must be letters, digits, -, _, ., ~ or =", name)
}
if len(v) < MinCredentialLength {
return Webhook{}, fmt.Errorf("%s must be at least %d characters", name, MinCredentialLength)
}
creds[i] = v
}
return Webhook{raw: raw, space: m[1], token: creds[1]}, nil
}
// String returns the full webhook URL, a secret.
func (w Webhook) String() string {
return w.raw
}
// Masked returns the URL with its key hidden and only the last few
// characters of its token, such as
// "https://chat.googleapis.com/v1/spaces/AAQA1234/messages?key=…&token=…x3Dk".
func (w Webhook) Masked() string {
// The token is ASCII of at least MinCredentialLength bytes, so the
// tail is maskedTail of its many characters.
return webhookPrefix + w.space + "/messages?key=…&token=…" + w.token[len(w.token)-maskedTail:]
}