diff --git a/README.md b/README.md index a8fe902..6f24c43 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,12 @@ where usrname='hemlock' and s.name='hemlock.push_notification_data'; (1 row) ``` +NOTE: As of v2, the token may be a bare token or a base64url-encoded set of tokens. If the value starts with "eyJl" +it is likely base64url-encoded. You can decode and pretty-print the contents with this command: +```bash +echo "$token" | basenc --base64url -d | jq . +``` + Collecting Metrics ------------------ GET /metrics diff --git a/buildinfo.go b/buildinfo.go index b645c6b..d3ab313 100644 --- a/buildinfo.go +++ b/buildinfo.go @@ -30,7 +30,10 @@ func readBuildInfo() (string, error) { } programName := filepath.Base(execPath) - if builtBy != "goreleaser" { + if builtBy == "goreleaser" { + commit = safeSubstr(commit, 8) + date = safeSubstr(date, 10) + } else { info, ok := debug.ReadBuildInfo() if !ok { return "", fmt.Errorf("ReadBuildInfo failed") diff --git a/go.mod b/go.mod index eed3912..9e38ca0 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module kenstir.net/hemlock-sendmsg +module github.com/kenstir/hemlock-sendmsg go 1.22.2 @@ -8,6 +8,8 @@ require ( google.golang.org/api v0.170.0 ) +require github.com/google/go-cmp v0.6.0 + require ( cloud.google.com/go v0.112.1 // indirect cloud.google.com/go/compute v1.24.0 // indirect diff --git a/sendmsg.go b/sendmsg.go index ca86735..889ae73 100644 --- a/sendmsg.go +++ b/sendmsg.go @@ -2,12 +2,14 @@ package main import ( "context" + "errors" "fmt" "log/slog" "net/http" "os" "sort" "strings" + "time" firebase "firebase.google.com/go/v4" "firebase.google.com/go/v4/errorutils" @@ -23,6 +25,9 @@ import ( const HemlockNotificationTypeKey = "hemlock.t" const HemlockNotificationUsernameKey = "hemlock.u" +// Cutoff time for tokens; if a token was added before this time, we consider it expired +const TokenExpirationCutoff = 365 * 24 * time.Hour + // NB: This list of notification types (Android notification channelIds) must be kept in sync in 3 places: // * hemlock (android): core/src/main/java/org/evergreen_ils/data/PushNotification.kt // * hemlock-ios: Source/Models/PushNotification.swift @@ -35,49 +40,61 @@ var HemlockNotificationTypes = map[string]bool{ "pmc": true, } +var ( + ErrEmptyToken = fmt.Errorf("empty token") + ErrExpiredToken = fmt.Errorf("token too old") +) + type ServiceData struct { fcmClient *messaging.Client notificationsSent *prometheus.CounterVec } -// categorize the result of sendMessage and record metric -func (srv *ServiceData) trackSendMessage(token string, err error) (string, int) { - httpStatusCode := http.StatusOK - result := "ok" - if token == "" { - httpStatusCode = http.StatusBadRequest - result = "EmptyToken" - } else if err != nil { - httpStatusCode = http.StatusInternalServerError - if resp := errorutils.HTTPResponse(err); resp != nil { - httpStatusCode = resp.StatusCode - } - result = "" - if messaging.IsUnregistered(err) { - result = "Unregistered" - // should remove token from db - } else if errorutils.IsUnavailable(err) { - result = "Unavailable" - // should retry in an hour - } else if messaging.IsInternal(err) { - result = "InternalError" - } else if messaging.IsInvalidArgument(err) { - result = "InvalidArgument" - } else { - result = "UnknownError" - } +// determine the HTTP status code for the response, and a result label for the measurement +func (srv *ServiceData) resultAndCodeFromError(err error) (result string, httpStatusCode int) { + // handle local errors + if err == nil { + return "ok", http.StatusOK + } else if errors.Is(err, ErrEmptyToken) { + return "EmptyToken", http.StatusBadRequest + } else if errors.Is(err, ErrExpiredToken) { + return "ExpiredToken", http.StatusBadRequest + } + + // it's an FCM error; use the FCM response status code if available + httpStatusCode = http.StatusInternalServerError + if resp := errorutils.HTTPResponse(err); resp != nil { + httpStatusCode = resp.StatusCode + } + + // determine appropriate result label for metrics + if messaging.IsUnregistered(err) { + result = "Unregistered" + // should remove token from db + } else if errorutils.IsUnavailable(err) { + result = "Unavailable" + // should retry in an hour + } else if messaging.IsInternal(err) { + result = "InternalError" + } else if messaging.IsInvalidArgument(err) { + result = "InvalidArgument" + } else { + result = "UnknownError" } - srv.notificationsSent.WithLabelValues(result).Inc() return result, httpStatusCode } -// send a notification -func (srv *ServiceData) sendMessage(token string, title string, body string, notificationType string, username string) (string, string, int, error) { - // send the message +// send one notification +func (srv *ServiceData) sendMessage(entry TokenEntry, title string, body string, notificationType string, username string) (string, string, int, error) { response := "" var err error = nil - if token != "" { + cutoff := time.Now().UTC().Add(-TokenExpirationCutoff).Unix() + if entry.Token == "" { + err = ErrEmptyToken + } else if entry.AddedAt < cutoff { + err = ErrExpiredToken + } else { response, err = srv.fcmClient.Send(context.Background(), &messaging.Message{ Data: map[string]string{ HemlockNotificationTypeKey: notificationType, @@ -92,10 +109,11 @@ func (srv *ServiceData) sendMessage(token string, title string, body string, not ChannelID: notificationType, }, }, - Token: token, + Token: entry.Token, }) } - result, httpStatusCode := srv.trackSendMessage(token, err) + result, httpStatusCode := srv.resultAndCodeFromError(err) + srv.notificationsSent.WithLabelValues(result).Inc() return response, result, httpStatusCode, err } @@ -122,9 +140,9 @@ func (srv *ServiceData) sendHandler(w http.ResponseWriter, r *http.Request) { return } - // token is "required", but we want to keep track of requests made without one, - // to count users without the mobile app - token := r.FormValue("token") + // tokenData is "required", but we don't require it because we want to track + // EmptyToken requests, i.e. notifications for users without the mobile app + tokenData := r.FormValue("token") // should be required username := r.FormValue("username") @@ -155,16 +173,34 @@ func (srv *ServiceData) sendHandler(w http.ResponseWriter, r *http.Request) { logLevel = slog.LevelInfo } - // send the message - response, result, httpStatusCode, err := srv.sendMessage(token, title, body, notificationType, username) - if err != nil { - slog.Error("Failed to send notification", "result", result, "code", httpStatusCode, "err", err) - w.WriteHeader(httpStatusCode) - fmt.Fprintf(w, "%s\n", err.Error()) - } else { - fmt.Fprintf(w, "%s\n", response) + // v2: handle either a single token or a JSON object with multiple tokens + tokenStore := NewTokenStoreFromString(tokenData) + + // send a message for each token + var responseBody strings.Builder + hasError := false + errorStatusCode := http.StatusInternalServerError + for _, entry := range tokenStore.Entries { + response, result, httpStatusCode, err := srv.sendMessage(entry, title, body, notificationType, username) + if err != nil { + slog.Error("Failed to send notification", "result", result, "code", httpStatusCode, "err", err) + if !hasError { + hasError = true + errorStatusCode = httpStatusCode + } + fmt.Fprintf(&responseBody, "%s\n", err.Error()) + } else { + fmt.Fprintf(&responseBody, "%s\n", response) + } + slog.Log(r.Context(), logLevel, fmt.Sprintf("%s %s", r.Method, r.URL.Path), + "result", result, "code", httpStatusCode, "username", username, + "title", title, "type", notificationType, "body", body, "token", entry.Token) + } + + if hasError { + w.WriteHeader(errorStatusCode) } - slog.Log(r.Context(), logLevel, fmt.Sprintf("%s %s", r.Method, r.URL.Path), "result", result, "code", httpStatusCode, "username", username, "title", title, "type", notificationType, "body", body, "token", token) + fmt.Fprint(w, responseBody.String()) } func createServiceData(credentialsFile string) (*ServiceData, error) { diff --git a/token_store.go b/token_store.go new file mode 100644 index 0000000..e99b12c --- /dev/null +++ b/token_store.go @@ -0,0 +1,86 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "strings" + "time" +) + +const MaxEntries = 3 + +// decoder/encoder for v2 tokens, declared here so they can be tested as compatible +var V2DecodeString = base64.RawURLEncoding.DecodeString +var V2EncodeString = base64.RawURLEncoding.EncodeToString + +// prefix on all v2 base64url-encoded tokens, which when decoded is `{"entries":[` +const V2EncodedTokenPrefix = "eyJlbnRyaWVzIjpb" + +type TokenEntry struct { + Token string `json:"token"` + AddedAt int64 `json:"added_at"` +} + +type TokenStore struct { + Entries []TokenEntry `json:"entries"` +} + +func NewTokenStore() *TokenStore { + return &TokenStore{ + Entries: make([]TokenEntry, 0, MaxEntries), + } +} + +func NewTokenStoreFromString(str string) *TokenStore { + ts := NewTokenStore() + ts.FromString(str) + return ts +} + +func (ts *TokenStore) AddToken(token string) { + ts.AddTokenEntry(TokenEntry{ + Token: token, + AddedAt: time.Now().Unix(), + }) +} + +func (ts *TokenStore) AddTokenEntry(entry TokenEntry) { + if len(ts.Entries) >= MaxEntries { + ts.Entries = ts.Entries[1:] + } + ts.Entries = append(ts.Entries, entry) +} + +func (ts *TokenStore) FindToken(token string) *TokenEntry { + for i := len(ts.Entries) - 1; i >= 0; i-- { + if ts.Entries[i].Token == token { + return &ts.Entries[i] + } + } + return nil +} + +func (ts *TokenStore) ToJSON() ([]byte, error) { + return json.Marshal(ts) +} + +func (ts *TokenStore) FromJSON(data []byte) error { + return json.Unmarshal(data, ts) +} + +// FromString creates a TokenStore from a string, either a plain PN token (v1) or a base64url-encoded JSON TS object (v2). +func (ts *TokenStore) FromString(str string) { + // if it looks like a v2 encoded object, try to parse it + if strings.HasPrefix(str, V2EncodedTokenPrefix) { + decoded, err := V2DecodeString(str) + if err == nil { + err = ts.FromJSON(decoded) + if err == nil { + return + } + } + } + + // treat it as a single token string + ts.AddToken(str) +} diff --git a/token_store_test.go b/token_store_test.go new file mode 100644 index 0000000..e016976 --- /dev/null +++ b/token_store_test.go @@ -0,0 +1,178 @@ +package main + +import ( + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestAddToken(t *testing.T) { + ts := NewTokenStore() + token := "test-token-1" + ts.AddToken(token) + + want := 1 + got := len(ts.Entries) + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("mismatch (-want +got): %s", diff) + } +} + +func TestAddTooManyTokens(t *testing.T) { + ts := NewTokenStore() + for i := 0; i <= MaxEntries+1; i++ { + ts.AddToken(fmt.Sprintf("token-%d", i)) + } + + want := MaxEntries + got := len(ts.Entries) + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("mismatch (-want +got): %s", diff) + } + + firstToken := ts.Entries[0].Token + wantFirst := "token-2" + if diff := cmp.Diff(wantFirst, firstToken); diff != "" { + t.Errorf("mismatch (-want +got): %s", diff) + } + + lastToken := ts.Entries[len(ts.Entries)-1].Token + wantLast := fmt.Sprintf("token-%d", MaxEntries+1) + if diff := cmp.Diff(wantLast, lastToken); diff != "" { + t.Errorf("mismatch (-want +got): %s", diff) + } +} + +func TestFindToken(t *testing.T) { + ts := NewTokenStore() + ts.AddToken("token-1") + ts.AddToken("token-2") + + found := ts.FindToken("token-2") + if diff := cmp.Diff("token-2", found.Token); diff != "" { + t.Errorf("mismatch (-want +got): %s", diff) + } +} + +func TestFindTokenNotFound(t *testing.T) { + ts := NewTokenStore() + ts.AddToken("token-1") + + found := ts.FindToken("nonexistent") + if found != nil { + t.Errorf("expected nil, got %v", found) + } +} + +func TestToJSON(t *testing.T) { + ts := NewTokenStore() + ts.AddTokenEntry(TokenEntry{ + Token: "token-1", + AddedAt: 1712664900, + }) + + data, err := ts.ToJSON() + if err != nil { + t.Fatal(err) + } + got := string(data) + want := `{"entries":[{"token":"token-1","added_at":1712664900}]}` + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("mismatch (-want +got): %s", diff) + } +} + +func TestFromJSON(t *testing.T) { + original := NewTokenStore() + original.AddToken("token-1") + original.AddToken("token-2") + + data, _ := original.ToJSON() + + ts := NewTokenStore() + err := ts.FromJSON(data) + if err != nil { + t.Fatal(err) + } + if len(ts.Entries) != 2 { + t.Errorf("expected 2 entries, got %d", len(ts.Entries)) + } +} + +func TestFromJSONInvalid(t *testing.T) { + ts := NewTokenStore() + err := ts.FromJSON([]byte("invalid json")) + if err == nil { + t.Fatal("expected error for invalid JSON") + } +} + +func TestFromStringSingleToken(t *testing.T) { + ts := NewTokenStoreFromString("token-1") + + want := 1 + got := len(ts.Entries) + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("mismatch (-want +got): %s", diff) + } +} + +func TestEncodingIsCompatible(t *testing.T) { + // Check that the implementation we are using is compatible with other implementations, + // that is, base64-url-encoding with no padding. + json := `{"a":"??~"}` + want := "eyJhIjoiPz9-In0" // plain base64 would be "eyJhIjoiPz9+In0=" + + encoded := V2EncodeString([]byte(json)) + if diff := cmp.Diff(want, encoded); diff != "" { + t.Errorf("mismatch (-want +got): %s", diff) + } + + decoded, err := V2DecodeString(encoded) + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(json, string(decoded)); diff != "" { + t.Errorf("mismatch (-want +got): %s", diff) + } +} + +func TestFromStringV2SingleToken(t *testing.T) { + json := `{"entries":[{"token":"token-1","added_at":1712664900}]}` + encoded := V2EncodeString([]byte(json)) + ts := NewTokenStoreFromString(encoded) + + want := 1 + got := len(ts.Entries) + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("mismatch (-want +got): %s", diff) + } +} + +func TestFromStringV2MultipleTokens(t *testing.T) { + json := `{"entries":[{"token":"token-1","added_at":1712578500},{"token":"token-2","added_at":1712664960}]}` + encoded := V2EncodeString([]byte(json)) + ts := NewTokenStoreFromString(encoded) + + want := 2 + got := len(ts.Entries) + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("mismatch (-want +got): %s", diff) + } +} + +func TestFromStringThatLooksLikeV2(t *testing.T) { + str := V2EncodedTokenPrefix + "xyzzy" + ts := NewTokenStoreFromString(str) + + want := 1 + got := len(ts.Entries) + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("mismatch (-want +got): %s", diff) + } + token := ts.Entries[0].Token + if diff := cmp.Diff(str, token); diff != "" { + t.Errorf("mismatch (-want +got): %s", diff) + } +}