Google Chat webhook sender and settings API (Feature 19) #165
@@ -693,13 +693,16 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
|
||||
`{"url", "space_label"}`, both required. The URL must be exactly
|
||||
`https://chat.googleapis.com/v1/spaces/{space}/messages?key=…&token=…`:
|
||||
that scheme and host spelled so, no port or user info, the space id
|
||||
letters, digits, `-` or `_`, and only the query parameters `key` and
|
||||
`token`, each once and at least 16 characters. The space label is 1–100
|
||||
letters, digits, `-` or `_`, and the raw query exactly
|
||||
`key=…&token=…` (in that order, no empty segment, no second `?`, no
|
||||
other parameter), each decoding to at least 16 characters of letters,
|
||||
digits, `-`, `_`, `.`, `~` or `=`. The space label is 1–100
|
||||
characters. A refused body is 400 `invalid_request` (not a JSON object
|
||||
of the two fields) or `invalid_webhook`. Saving replaces any stored
|
||||
webhook and clears its test outcome.
|
||||
- `GET /api/v1/settings/chat-webhook` answers `{configured, space_label,
|
||||
url_masked, last_test_at, last_test_status, updated_at}`; `url_masked`
|
||||
- `GET /api/v1/settings/chat-webhook` answers `{space_label, url_masked,
|
||||
last_test_at, last_test_status}`, so `url_masked` is `null` exactly when
|
||||
no webhook is stored; `url_masked`
|
||||
shows the space id and only the last four characters of the decoded
|
||||
token (`…/spaces/AAQA…/messages?key=…&token=…ZxQ=`). Absent values are
|
||||
`null`.
|
||||
|
||||
@@ -59,9 +59,8 @@ var (
|
||||
)
|
||||
|
||||
// Settings is the stored webhook as the API shows it: never the URL, only
|
||||
// its masked form.
|
||||
// its masked form. URLMasked is nil when no webhook is stored.
|
||||
type Settings struct {
|
||||
Configured bool `json:"configured"`
|
||||
SpaceLabel *string `json:"space_label"`
|
||||
// URLMasked is the webhook URL without its key and with only the last
|
||||
// few characters of its token.
|
||||
@@ -70,7 +69,6 @@ type Settings struct {
|
||||
// test message is sent to this webhook.
|
||||
LastTestAt *string `json:"last_test_at"`
|
||||
LastTestStatus *string `json:"last_test_status"`
|
||||
UpdatedAt *string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TestResult is the outcome of a test message.
|
||||
@@ -143,8 +141,8 @@ func NewService(st *store.Store, box *secretbox.Box, publicURL *url.URL, transpo
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Get returns the stored webhook's settings; Configured is false and every
|
||||
// other field nil before one is saved.
|
||||
// Get returns the stored webhook's settings; every field is nil before one
|
||||
// is saved.
|
||||
func (s *Service) Get(ctx context.Context) (Settings, error) {
|
||||
row, err := db.New(s.st.Reader).GetChatSettings(ctx)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -440,14 +438,12 @@ func (s *Service) timestamp() string {
|
||||
}
|
||||
|
||||
func settingsFrom(row db.ChatSetting, hook Webhook) Settings {
|
||||
masked, updated := hook.Masked(), row.UpdatedAt
|
||||
masked := hook.Masked()
|
||||
return Settings{
|
||||
Configured: true,
|
||||
SpaceLabel: nullString(row.SpaceLabel),
|
||||
URLMasked: &masked,
|
||||
LastTestAt: nullString(row.LastTestAt),
|
||||
LastTestStatus: nullString(row.LastTestStatus),
|
||||
UpdatedAt: &updated,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@ func TestPutGetStoresSealedWebhook(t *testing.T) {
|
||||
svc, st := newTestService(t, newFakeChat(t))
|
||||
|
||||
got, err := svc.Get(ctx)
|
||||
if err != nil || got.Configured || got.SpaceLabel != nil || got.URLMasked != nil || got.LastTestAt != nil {
|
||||
if err != nil || got.SpaceLabel != nil || got.URLMasked != nil || got.LastTestAt != nil {
|
||||
t.Fatalf("Get unconfigured = %+v, %v", got, err)
|
||||
}
|
||||
|
||||
@@ -180,7 +180,7 @@ func TestPutGetStoresSealedWebhook(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !put.Configured || *put.SpaceLabel != "Family dinner" || *put.URLMasked != "https://chat.googleapis.com/v1/spaces/"+testSpace+"/messages?key=…&token=…ZxQ=" || put.LastTestAt != nil || put.LastTestStatus != nil {
|
||||
if *put.SpaceLabel != "Family dinner" || *put.URLMasked != "https://chat.googleapis.com/v1/spaces/"+testSpace+"/messages?key=…&token=…ZxQ=" || put.LastTestAt != nil || put.LastTestStatus != nil {
|
||||
t.Errorf("Put = %+v", put)
|
||||
}
|
||||
got, err = svc.Get(ctx)
|
||||
|
||||
+31
-20
@@ -28,6 +28,10 @@ const (
|
||||
// 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.
|
||||
@@ -39,8 +43,9 @@ type Webhook struct {
|
||||
|
||||
// ParseWebhook validates raw as
|
||||
// https://chat.googleapis.com/v1/spaces/{space}/messages?key=…&token=…,
|
||||
// with exactly the query parameters key and token, each once and at least
|
||||
// MinCredentialLength characters. Its error never quotes raw.
|
||||
// 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 == "":
|
||||
@@ -65,28 +70,34 @@ func ParseWebhook(raw string) (Webhook, error) {
|
||||
if m == nil {
|
||||
return Webhook{}, errors.New("path must be /v1/spaces/{space}/messages")
|
||||
}
|
||||
q, err := url.ParseQuery(u.RawQuery)
|
||||
if err != nil {
|
||||
return Webhook{}, errors.New("query is not valid")
|
||||
// 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 ?")
|
||||
}
|
||||
for name, values := range q {
|
||||
if name != "key" && name != "token" {
|
||||
return Webhook{}, errors.New("query must hold only key and token")
|
||||
}
|
||||
if len(values) != 1 {
|
||||
return Webhook{}, fmt.Errorf("query must hold %s once", name)
|
||||
}
|
||||
segments := strings.Split(u.RawQuery, "&")
|
||||
if len(segments) != 2 {
|
||||
return Webhook{}, errors.New("query must be exactly key=…&token=…")
|
||||
}
|
||||
for _, name := range []string{"key", "token"} {
|
||||
v := q.Get(name)
|
||||
if v == "" {
|
||||
return Webhook{}, fmt.Errorf("query must hold a %s", name)
|
||||
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: q.Get("token")}, nil
|
||||
return Webhook{raw: raw, space: m[1], token: creds[1]}, nil
|
||||
}
|
||||
|
||||
// String returns the full webhook URL, a secret.
|
||||
@@ -98,7 +109,7 @@ func (w Webhook) String() string {
|
||||
// characters of its token, such as
|
||||
// "https://chat.googleapis.com/v1/spaces/AAQA1234/messages?key=…&token=…x3Dk".
|
||||
func (w Webhook) Masked() string {
|
||||
// MinCredentialLength bytes are at least maskedTail runes.
|
||||
token := []rune(w.token)
|
||||
return webhookPrefix + w.space + "/messages?key=…&token=…" + string(token[len(token)-maskedTail:])
|
||||
// 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:]
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ func TestParseWebhookAccepts(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, raw := range []string{
|
||||
testHook,
|
||||
"https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?token=" + testToken + "&key=" + testKey,
|
||||
"https://chat.googleapis.com/v1/spaces/AAAA_x-y/messages?key=" + testKey + "&token=" + testToken,
|
||||
} {
|
||||
hook, err := ParseWebhook(raw)
|
||||
@@ -55,6 +54,14 @@ func TestParseWebhookRejects(t *testing.T) {
|
||||
"no token": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + testKey,
|
||||
"empty token": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + testKey + "&token=",
|
||||
"short key": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=abc&token=" + testToken,
|
||||
"token first": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?token=" + testToken + "&key=" + testKey,
|
||||
"empty segment": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + testKey + "&&token=" + testToken,
|
||||
"leading ampersand": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?&" + q[1:],
|
||||
"trailing ampersand": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q + "&",
|
||||
"second question": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q + "?x",
|
||||
"multi-byte token": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + testKey + "&token=" + strings.Repeat("%F0%9F%98%80", 4),
|
||||
"multi-byte key": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + strings.Repeat("%F0%9F%98%80", 4) + "&token=" + testToken,
|
||||
"plus in token": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + testKey + "&token=" + testToken + "+",
|
||||
"repeated key": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q + "&key=" + testKey,
|
||||
"extra parameter": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q + "&threadKey=x",
|
||||
"bad escape in query": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q + "%zz",
|
||||
|
||||
@@ -131,7 +131,7 @@ func TestChatWebhookAPI(t *testing.T) {
|
||||
}
|
||||
|
||||
rec, body := call(http.MethodGet, chatWebhookPath, "")
|
||||
if rec.Code != http.StatusOK || body["configured"] != false || body["url_masked"] != nil || body["space_label"] != nil {
|
||||
if rec.Code != http.StatusOK || body["url_masked"] != nil || body["space_label"] != nil || body["last_test_at"] != nil || body["last_test_status"] != nil || len(body) != 4 {
|
||||
t.Errorf("GET unconfigured = %d %v", rec.Code, body)
|
||||
}
|
||||
rec, body = call(http.MethodPost, chatWebhookTestPath, "")
|
||||
@@ -160,8 +160,8 @@ func TestChatWebhookAPI(t *testing.T) {
|
||||
|
||||
rec, body = call(http.MethodPut, chatWebhookPath, `{"url":"`+testChatHook+`","space_label":"Family dinner"}`)
|
||||
wantMasked := "https://chat.googleapis.com/v1/spaces/AAQA1b2C3d4/messages?key=…&token=…ZxQ="
|
||||
if rec.Code != http.StatusOK || body["configured"] != true || body["space_label"] != "Family dinner" || body["url_masked"] != wantMasked ||
|
||||
body["last_test_at"] != nil || body["last_test_status"] != nil {
|
||||
if rec.Code != http.StatusOK || body["space_label"] != "Family dinner" || body["url_masked"] != wantMasked ||
|
||||
body["last_test_at"] != nil || body["last_test_status"] != nil || len(body) != 4 {
|
||||
t.Errorf("PUT = %d %v", rec.Code, body)
|
||||
}
|
||||
if _, ok := body["url"]; ok {
|
||||
|
||||
Reference in New Issue
Block a user