From 18682242ccc5a997de44c634367fce06164c68f9 Mon Sep 17 00:00:00 2001 From: kenstir Date: Thu, 9 Apr 2026 14:09:16 -0400 Subject: [PATCH 01/17] Log only first 8B of commit and first 10B of date on startup --- buildinfo.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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") From 8f342f2512124b65622da452c6e9db628d006b05 Mon Sep 17 00:00:00 2001 From: kenstir Date: Thu, 9 Apr 2026 15:48:12 -0400 Subject: [PATCH 02/17] Add TokenStore and some tests --- go.mod | 4 +- token_store.go | 54 +++++++++++++++++++++ token_store_test.go | 111 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 token_store.go create mode 100644 token_store_test.go 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/token_store.go b/token_store.go new file mode 100644 index 0000000..1d1604d --- /dev/null +++ b/token_store.go @@ -0,0 +1,54 @@ +package main + +import ( + "encoding/json" + "time" +) + +const MaxEntries = 3 + +type TokenEntry struct { + Token string `json:"tok"` + AddedAt time.Time `json:"added_at"` +} + +type TokenStore struct { + Entries []TokenEntry `json:"tokens"` +} + +func NewTokenStore() *TokenStore { + return &TokenStore{ + Entries: make([]TokenEntry, 0, MaxEntries), + } +} + +func (cm *TokenStore) AddToken(token string) { + cm.AddTokenEntry(TokenEntry{ + Token: token, + AddedAt: time.Now().UTC().Truncate(time.Second), + }) +} + +func (cm *TokenStore) AddTokenEntry(entry TokenEntry) { + if len(cm.Entries) >= MaxEntries { + cm.Entries = cm.Entries[1:] + } + cm.Entries = append(cm.Entries, entry) +} + +func (cm *TokenStore) FindToken(token string) *TokenEntry { + for i := len(cm.Entries) - 1; i >= 0; i-- { + if cm.Entries[i].Token == token { + return &cm.Entries[i] + } + } + return nil +} + +func (cm *TokenStore) ToJSON() ([]byte, error) { + return json.Marshal(cm) +} + +func (cm *TokenStore) FromJSON(data []byte) error { + return json.Unmarshal(data, cm) +} diff --git a/token_store_test.go b/token_store_test.go new file mode 100644 index 0000000..9c136f7 --- /dev/null +++ b/token_store_test.go @@ -0,0 +1,111 @@ +package main + +import ( + "testing" + "time" + + "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("token-" + string(rune(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-" + string(rune(2)) + if diff := cmp.Diff(wantFirst, firstToken); diff != "" { + t.Errorf("mismatch (-want +got): %s", diff) + } + + lastToken := ts.Entries[len(ts.Entries)-1].Token + wantLast := "token-" + string(rune(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: time.Date(2026, 4, 9, 13, 15, 0, 0, time.UTC), + }) + + data, err := ts.ToJSON() + if err != nil { + t.Fatal(err) + } + if len(data) == 0 { + t.Fatal("expected non-empty JSON") + } + got := string(data) + want := `{"tokens":[{"tok":"token-1","added_at":"2026-04-09T13:15:00Z"}]}` + 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") + } +} From 8ae6aa3f6a5d14e1126d47d88bc79ee1616f6596 Mon Sep 17 00:00:00 2001 From: kenstir Date: Thu, 9 Apr 2026 17:54:19 -0400 Subject: [PATCH 03/17] Add NewTokenStoreFromString --- token_store.go | 21 +++++++++++++++++++++ token_store_test.go | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/token_store.go b/token_store.go index 1d1604d..ed9d05a 100644 --- a/token_store.go +++ b/token_store.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "strings" "time" ) @@ -22,6 +23,12 @@ func NewTokenStore() *TokenStore { } } +func NewTokenStoreFromString(str string) *TokenStore { + ts := NewTokenStore() + ts.FromString(str) + return ts +} + func (cm *TokenStore) AddToken(token string) { cm.AddTokenEntry(TokenEntry{ Token: token, @@ -52,3 +59,17 @@ func (cm *TokenStore) ToJSON() ([]byte, error) { func (cm *TokenStore) FromJSON(data []byte) error { return json.Unmarshal(data, cm) } + +// FromString creates a TokenStore from a string, which might be a single string token or a JSON object. +func (cm *TokenStore) FromString(str string) { + // if it looks like a JSON object, try to parse it + if strings.HasPrefix(str, "{") && strings.HasSuffix(str, "}") { + err := cm.FromJSON([]byte(str)) + if err == nil { + return + } + } + + // treat it as a single token string + cm.AddToken(str) +} diff --git a/token_store_test.go b/token_store_test.go index 9c136f7..19b4087 100644 --- a/token_store_test.go +++ b/token_store_test.go @@ -109,3 +109,44 @@ func TestFromJSONInvalid(t *testing.T) { 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 TestFromStringJSONSingleToken(t *testing.T) { + ts := NewTokenStoreFromString(`{"tokens":[{"tok":"token-1","added_at":"2026-04-09T13:15:00Z"}]}`) + want := 1 + got := len(ts.Entries) + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("mismatch (-want +got): %s", diff) + } +} + +func TestFromStringJSONMultipleTokens(t *testing.T) { + ts := NewTokenStoreFromString(`{"tokens":[{"tok":"token-1","added_at":"2026-04-08T13:15:00Z"},{"tok":"token-2","added_at":"2026-04-09T13:16:00Z"}]}`) + want := 2 + got := len(ts.Entries) + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("mismatch (-want +got): %s", diff) + } +} + +func TestFromStringThatLooksLikeJSON(t *testing.T) { + ts := NewTokenStoreFromString("{xyzzy}") + 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 + wantToken := "{xyzzy}" + if diff := cmp.Diff(wantToken, token); diff != "" { + t.Errorf("mismatch (-want +got): %s", diff) + } +} From 86b8cbb85643a255793ddd8d2b5b7da0e598adf7 Mon Sep 17 00:00:00 2001 From: kenstir Date: Thu, 9 Apr 2026 19:08:29 -0400 Subject: [PATCH 04/17] Handle either string or JSON as token param --- sendmsg.go | 61 ++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 39 insertions(+), 22 deletions(-) diff --git a/sendmsg.go b/sendmsg.go index ca86735..b885e71 100644 --- a/sendmsg.go +++ b/sendmsg.go @@ -8,6 +8,7 @@ import ( "os" "sort" "strings" + "time" firebase "firebase.google.com/go/v4" "firebase.google.com/go/v4/errorutils" @@ -35,17 +36,23 @@ 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 == "" { +// determine the HTTP status code for the response, and a result label for the measurement +func (srv *ServiceData) sendMessageResult(err error) (result string, httpStatusCode int) { + if err == nil { + httpStatusCode = http.StatusOK + result = "ok" + } else if err == ErrEmptyToken { httpStatusCode = http.StatusBadRequest result = "EmptyToken" } else if err != nil { @@ -68,16 +75,20 @@ func (srv *ServiceData) trackSendMessage(token string, err error) (string, int) 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) { +func (srv *ServiceData) sendMessage(entry TokenEntry, title string, body string, notificationType string, username string) (string, string, int, error) { // send the message response := "" var err error = nil - if token != "" { + cutoff := time.Now().UTC().Add(-365 * 24 * time.Hour) + if entry.Token == "" { + err = ErrEmptyToken + } else if entry.AddedAt.Before(cutoff) { + err = ErrExpiredToken + } else { response, err = srv.fcmClient.Send(context.Background(), &messaging.Message{ Data: map[string]string{ HemlockNotificationTypeKey: notificationType, @@ -92,10 +103,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.sendMessageResult(err) + srv.notificationsSent.WithLabelValues(result).Inc() return response, result, httpStatusCode, err } @@ -122,9 +134,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 report it as an error because we want to + // track EmptyToken requests, i.e. for users without the mobile apps + tokenData := r.FormValue("token") // should be required username := r.FormValue("username") @@ -155,16 +167,21 @@ 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 + 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) + w.WriteHeader(httpStatusCode) + fmt.Fprintf(w, "%s\n", err.Error()) + } else { + fmt.Fprintf(w, "%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) } - 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) } func createServiceData(credentialsFile string) (*ServiceData, error) { From b9aa4b045db231c3d8172d5d492ddfc0e4541c02 Mon Sep 17 00:00:00 2001 From: kenstir Date: Fri, 10 Apr 2026 11:23:26 -0400 Subject: [PATCH 05/17] Refactor and rename resultAndCodeFromError --- sendmsg.go | 63 +++++++++++++++++++++++++++++------------------------- 1 file changed, 34 insertions(+), 29 deletions(-) diff --git a/sendmsg.go b/sendmsg.go index b885e71..0a1ffc8 100644 --- a/sendmsg.go +++ b/sendmsg.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "fmt" "log/slog" "net/http" @@ -48,39 +49,41 @@ type ServiceData struct { } // determine the HTTP status code for the response, and a result label for the measurement -func (srv *ServiceData) sendMessageResult(err error) (result string, httpStatusCode int) { +func (srv *ServiceData) resultAndCodeFromError(err error) (result string, httpStatusCode int) { + // handle local errors if err == nil { - httpStatusCode = http.StatusOK - result = "ok" - } else if err == ErrEmptyToken { - 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" - } + 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" } return result, httpStatusCode } -// send a notification +// send one notification func (srv *ServiceData) sendMessage(entry TokenEntry, title string, body string, notificationType string, username string) (string, string, int, error) { - // send the message response := "" var err error = nil cutoff := time.Now().UTC().Add(-365 * 24 * time.Hour) @@ -106,7 +109,7 @@ func (srv *ServiceData) sendMessage(entry TokenEntry, title string, body string, Token: entry.Token, }) } - result, httpStatusCode := srv.sendMessageResult(err) + result, httpStatusCode := srv.resultAndCodeFromError(err) srv.notificationsSent.WithLabelValues(result).Inc() return response, result, httpStatusCode, err } @@ -180,7 +183,9 @@ func (srv *ServiceData) sendHandler(w http.ResponseWriter, r *http.Request) { } else { fmt.Fprintf(w, "%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) + 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) } } From 7ba4d467cfdfa8fa3e5af34d162f0a8dc728872b Mon Sep 17 00:00:00 2001 From: kenstir Date: Fri, 10 Apr 2026 12:23:33 -0400 Subject: [PATCH 06/17] Use const for cutoff time --- sendmsg.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sendmsg.go b/sendmsg.go index 0a1ffc8..d71c3ca 100644 --- a/sendmsg.go +++ b/sendmsg.go @@ -25,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 @@ -86,7 +89,7 @@ func (srv *ServiceData) resultAndCodeFromError(err error) (result string, httpSt func (srv *ServiceData) sendMessage(entry TokenEntry, title string, body string, notificationType string, username string) (string, string, int, error) { response := "" var err error = nil - cutoff := time.Now().UTC().Add(-365 * 24 * time.Hour) + cutoff := time.Now().UTC().Add(-TokenExpirationCutoff) if entry.Token == "" { err = ErrEmptyToken } else if entry.AddedAt.Before(cutoff) { From 0e59580f8abed9b9d95379611db98210fc97a168 Mon Sep 17 00:00:00 2001 From: kenstir Date: Fri, 10 Apr 2026 12:25:53 -0400 Subject: [PATCH 07/17] Tweak commentary --- sendmsg.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sendmsg.go b/sendmsg.go index d71c3ca..6815aef 100644 --- a/sendmsg.go +++ b/sendmsg.go @@ -140,8 +140,8 @@ func (srv *ServiceData) sendHandler(w http.ResponseWriter, r *http.Request) { return } - // tokenData is "required", but we don't report it as an error because we want to - // track EmptyToken requests, i.e. for users without the mobile apps + // 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 From dcb86e872ed36392f04d0c225fd685bfe623d215 Mon Sep 17 00:00:00 2001 From: kenstir Date: Fri, 10 Apr 2026 12:32:26 -0400 Subject: [PATCH 08/17] Trim ws before unmarshal per copilot review --- token_store.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/token_store.go b/token_store.go index ed9d05a..75f6fa1 100644 --- a/token_store.go +++ b/token_store.go @@ -63,8 +63,9 @@ func (cm *TokenStore) FromJSON(data []byte) error { // FromString creates a TokenStore from a string, which might be a single string token or a JSON object. func (cm *TokenStore) FromString(str string) { // if it looks like a JSON object, try to parse it - if strings.HasPrefix(str, "{") && strings.HasSuffix(str, "}") { - err := cm.FromJSON([]byte(str)) + trimmed := strings.TrimSpace(str) + if strings.HasPrefix(trimmed, "{") && strings.HasSuffix(trimmed, "}") { + err := cm.FromJSON([]byte(trimmed)) if err == nil { return } From a7167f1ad3f836a60bb0cdc2678ae37e9089f3c5 Mon Sep 17 00:00:00 2001 From: kenstir Date: Fri, 10 Apr 2026 12:35:56 -0400 Subject: [PATCH 09/17] Avoid rune() for test strings --- token_store_test.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/token_store_test.go b/token_store_test.go index 19b4087..da77cde 100644 --- a/token_store_test.go +++ b/token_store_test.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "testing" "time" @@ -21,7 +22,7 @@ func TestAddToken(t *testing.T) { func TestAddTooManyTokens(t *testing.T) { ts := NewTokenStore() for i := 0; i <= MaxEntries+1; i++ { - ts.AddToken("token-" + string(rune(i))) + ts.AddToken(fmt.Sprintf("token-%d", i)) } want := MaxEntries @@ -31,13 +32,13 @@ func TestAddTooManyTokens(t *testing.T) { } firstToken := ts.Entries[0].Token - wantFirst := "token-" + string(rune(2)) + 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 := "token-" + string(rune(MaxEntries+1)) + wantLast := fmt.Sprintf("token-%d", MaxEntries+1) if diff := cmp.Diff(wantLast, lastToken); diff != "" { t.Errorf("mismatch (-want +got): %s", diff) } From 81c62d8f9cd87817308e5d0572dd111714354a90 Mon Sep 17 00:00:00 2001 From: kenstir Date: Fri, 10 Apr 2026 12:52:59 -0400 Subject: [PATCH 10/17] Send only 1 http response for multiple tokens --- sendmsg.go | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/sendmsg.go b/sendmsg.go index 6815aef..b30ac57 100644 --- a/sendmsg.go +++ b/sendmsg.go @@ -177,19 +177,30 @@ func (srv *ServiceData) sendHandler(w http.ResponseWriter, r *http.Request) { 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) - w.WriteHeader(httpStatusCode) - fmt.Fprintf(w, "%s\n", err.Error()) + if !hasError { + hasError = true + errorStatusCode = httpStatusCode + } + fmt.Fprintf(&responseBody, "%s\n", err.Error()) } else { - fmt.Fprintf(w, "%s\n", response) + 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) + } + fmt.Fprint(w, responseBody.String()) } func createServiceData(credentialsFile string) (*ServiceData, error) { From 0c5654b3f24b8b410908f13b0072e197debe9ffc Mon Sep 17 00:00:00 2001 From: kenstir Date: Fri, 10 Apr 2026 17:59:26 -0400 Subject: [PATCH 11/17] Changed the names used for serialization --- token_store.go | 4 ++-- token_store_test.go | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/token_store.go b/token_store.go index 75f6fa1..d9b4351 100644 --- a/token_store.go +++ b/token_store.go @@ -9,12 +9,12 @@ import ( const MaxEntries = 3 type TokenEntry struct { - Token string `json:"tok"` + Token string `json:"token"` AddedAt time.Time `json:"added_at"` } type TokenStore struct { - Entries []TokenEntry `json:"tokens"` + Entries []TokenEntry `json:"entries"` } func NewTokenStore() *TokenStore { diff --git a/token_store_test.go b/token_store_test.go index da77cde..0b96687 100644 --- a/token_store_test.go +++ b/token_store_test.go @@ -80,7 +80,7 @@ func TestToJSON(t *testing.T) { t.Fatal("expected non-empty JSON") } got := string(data) - want := `{"tokens":[{"tok":"token-1","added_at":"2026-04-09T13:15:00Z"}]}` + want := `{"entries":[{"token":"token-1","added_at":"2026-04-09T13:15:00Z"}]}` if diff := cmp.Diff(want, got); diff != "" { t.Errorf("mismatch (-want +got): %s", diff) } @@ -121,7 +121,7 @@ func TestFromStringSingleToken(t *testing.T) { } func TestFromStringJSONSingleToken(t *testing.T) { - ts := NewTokenStoreFromString(`{"tokens":[{"tok":"token-1","added_at":"2026-04-09T13:15:00Z"}]}`) + ts := NewTokenStoreFromString(`{"entries":[{"token":"token-1","added_at":"2026-04-09T13:15:00Z"}]}`) want := 1 got := len(ts.Entries) if diff := cmp.Diff(want, got); diff != "" { @@ -130,7 +130,7 @@ func TestFromStringJSONSingleToken(t *testing.T) { } func TestFromStringJSONMultipleTokens(t *testing.T) { - ts := NewTokenStoreFromString(`{"tokens":[{"tok":"token-1","added_at":"2026-04-08T13:15:00Z"},{"tok":"token-2","added_at":"2026-04-09T13:16:00Z"}]}`) + ts := NewTokenStoreFromString(`{"entries":[{"token":"token-1","added_at":"2026-04-08T13:15:00Z"},{"token":"token-2","added_at":"2026-04-09T13:16:00Z"}]}`) want := 2 got := len(ts.Entries) if diff := cmp.Diff(want, got); diff != "" { From 32581975b2e0200f40b769c98035e5c94efa5121 Mon Sep 17 00:00:00 2001 From: kenstir Date: Sun, 12 Apr 2026 12:06:44 -0400 Subject: [PATCH 12/17] Change TokenEntry to use unix time --- sendmsg.go | 4 ++-- token_store.go | 6 +++--- token_store_test.go | 9 ++++----- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/sendmsg.go b/sendmsg.go index b30ac57..889ae73 100644 --- a/sendmsg.go +++ b/sendmsg.go @@ -89,10 +89,10 @@ func (srv *ServiceData) resultAndCodeFromError(err error) (result string, httpSt func (srv *ServiceData) sendMessage(entry TokenEntry, title string, body string, notificationType string, username string) (string, string, int, error) { response := "" var err error = nil - cutoff := time.Now().UTC().Add(-TokenExpirationCutoff) + cutoff := time.Now().UTC().Add(-TokenExpirationCutoff).Unix() if entry.Token == "" { err = ErrEmptyToken - } else if entry.AddedAt.Before(cutoff) { + } else if entry.AddedAt < cutoff { err = ErrExpiredToken } else { response, err = srv.fcmClient.Send(context.Background(), &messaging.Message{ diff --git a/token_store.go b/token_store.go index d9b4351..99f6fef 100644 --- a/token_store.go +++ b/token_store.go @@ -9,8 +9,8 @@ import ( const MaxEntries = 3 type TokenEntry struct { - Token string `json:"token"` - AddedAt time.Time `json:"added_at"` + Token string `json:"token"` + AddedAt int64 `json:"added_at"` } type TokenStore struct { @@ -32,7 +32,7 @@ func NewTokenStoreFromString(str string) *TokenStore { func (cm *TokenStore) AddToken(token string) { cm.AddTokenEntry(TokenEntry{ Token: token, - AddedAt: time.Now().UTC().Truncate(time.Second), + AddedAt: time.Now().Unix(), }) } diff --git a/token_store_test.go b/token_store_test.go index 0b96687..e3a1643 100644 --- a/token_store_test.go +++ b/token_store_test.go @@ -3,7 +3,6 @@ package main import ( "fmt" "testing" - "time" "github.com/google/go-cmp/cmp" ) @@ -69,7 +68,7 @@ func TestToJSON(t *testing.T) { ts := NewTokenStore() ts.AddTokenEntry(TokenEntry{ Token: "token-1", - AddedAt: time.Date(2026, 4, 9, 13, 15, 0, 0, time.UTC), + AddedAt: 1712664900, // 2024-04-09T13:15:00Z }) data, err := ts.ToJSON() @@ -80,7 +79,7 @@ func TestToJSON(t *testing.T) { t.Fatal("expected non-empty JSON") } got := string(data) - want := `{"entries":[{"token":"token-1","added_at":"2026-04-09T13:15:00Z"}]}` + want := `{"entries":[{"token":"token-1","added_at":1712664900}]}` if diff := cmp.Diff(want, got); diff != "" { t.Errorf("mismatch (-want +got): %s", diff) } @@ -121,7 +120,7 @@ func TestFromStringSingleToken(t *testing.T) { } func TestFromStringJSONSingleToken(t *testing.T) { - ts := NewTokenStoreFromString(`{"entries":[{"token":"token-1","added_at":"2026-04-09T13:15:00Z"}]}`) + ts := NewTokenStoreFromString(`{"entries":[{"token":"token-1","added_at":1712664900}]}`) want := 1 got := len(ts.Entries) if diff := cmp.Diff(want, got); diff != "" { @@ -130,7 +129,7 @@ func TestFromStringJSONSingleToken(t *testing.T) { } func TestFromStringJSONMultipleTokens(t *testing.T) { - ts := NewTokenStoreFromString(`{"entries":[{"token":"token-1","added_at":"2026-04-08T13:15:00Z"},{"token":"token-2","added_at":"2026-04-09T13:16:00Z"}]}`) + ts := NewTokenStoreFromString(`{"entries":[{"token":"token-1","added_at":1712578500},{"token":"token-2","added_at":1712664960}]}`) want := 2 got := len(ts.Entries) if diff := cmp.Diff(want, got); diff != "" { From fce0ca9bf5a4bdf56dc7ba0ba727ea168af6086f Mon Sep 17 00:00:00 2001 From: kenstir Date: Sun, 12 Apr 2026 20:04:57 -0400 Subject: [PATCH 13/17] Switch to base64url-encoded storage because EG corrupts user settings that contain double quotes. --- token_store.go | 50 +++++++++++++++++++++++++-------------------- token_store_test.go | 19 ++++++++++------- 2 files changed, 40 insertions(+), 29 deletions(-) diff --git a/token_store.go b/token_store.go index 99f6fef..ef0b733 100644 --- a/token_store.go +++ b/token_store.go @@ -1,6 +1,7 @@ package main import ( + "encoding/base64" "encoding/json" "strings" "time" @@ -8,6 +9,9 @@ import ( const MaxEntries = 3 +// prefix for all v2 encoded tokens, base64url-encoded string '{"entries":[' +const V2EncodedTokenPrefix = "eyJlbnRyaWVzIjpb" + type TokenEntry struct { Token string `json:"token"` AddedAt int64 `json:"added_at"` @@ -29,48 +33,50 @@ func NewTokenStoreFromString(str string) *TokenStore { return ts } -func (cm *TokenStore) AddToken(token string) { - cm.AddTokenEntry(TokenEntry{ +func (ts *TokenStore) AddToken(token string) { + ts.AddTokenEntry(TokenEntry{ Token: token, AddedAt: time.Now().Unix(), }) } -func (cm *TokenStore) AddTokenEntry(entry TokenEntry) { - if len(cm.Entries) >= MaxEntries { - cm.Entries = cm.Entries[1:] +func (ts *TokenStore) AddTokenEntry(entry TokenEntry) { + if len(ts.Entries) >= MaxEntries { + ts.Entries = ts.Entries[1:] } - cm.Entries = append(cm.Entries, entry) + ts.Entries = append(ts.Entries, entry) } -func (cm *TokenStore) FindToken(token string) *TokenEntry { - for i := len(cm.Entries) - 1; i >= 0; i-- { - if cm.Entries[i].Token == token { - return &cm.Entries[i] +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 (cm *TokenStore) ToJSON() ([]byte, error) { - return json.Marshal(cm) +func (ts *TokenStore) ToJSON() ([]byte, error) { + return json.Marshal(ts) } -func (cm *TokenStore) FromJSON(data []byte) error { - return json.Unmarshal(data, cm) +func (ts *TokenStore) FromJSON(data []byte) error { + return json.Unmarshal(data, ts) } -// FromString creates a TokenStore from a string, which might be a single string token or a JSON object. -func (cm *TokenStore) FromString(str string) { - // if it looks like a JSON object, try to parse it - trimmed := strings.TrimSpace(str) - if strings.HasPrefix(trimmed, "{") && strings.HasSuffix(trimmed, "}") { - err := cm.FromJSON([]byte(trimmed)) +// 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 := base64.URLEncoding.DecodeString(str) if err == nil { - return + err = ts.FromJSON(decoded) + if err == nil { + return + } } } // treat it as a single token string - cm.AddToken(str) + ts.AddToken(str) } diff --git a/token_store_test.go b/token_store_test.go index e3a1643..1e4f5d3 100644 --- a/token_store_test.go +++ b/token_store_test.go @@ -1,6 +1,7 @@ package main import ( + "encoding/base64" "fmt" "testing" @@ -119,8 +120,10 @@ func TestFromStringSingleToken(t *testing.T) { } } -func TestFromStringJSONSingleToken(t *testing.T) { - ts := NewTokenStoreFromString(`{"entries":[{"token":"token-1","added_at":1712664900}]}`) +func TestFromStringV2SingleToken(t *testing.T) { + json := `{"entries":[{"token":"token-1","added_at":1712664900}]}` + encoded := base64.URLEncoding.EncodeToString([]byte(json)) + ts := NewTokenStoreFromString(encoded) want := 1 got := len(ts.Entries) if diff := cmp.Diff(want, got); diff != "" { @@ -129,7 +132,9 @@ func TestFromStringJSONSingleToken(t *testing.T) { } func TestFromStringJSONMultipleTokens(t *testing.T) { - ts := NewTokenStoreFromString(`{"entries":[{"token":"token-1","added_at":1712578500},{"token":"token-2","added_at":1712664960}]}`) + json := `{"entries":[{"token":"token-1","added_at":1712578500},{"token":"token-2","added_at":1712664960}]}` + encoded := base64.URLEncoding.EncodeToString([]byte(json)) + ts := NewTokenStoreFromString(encoded) want := 2 got := len(ts.Entries) if diff := cmp.Diff(want, got); diff != "" { @@ -137,16 +142,16 @@ func TestFromStringJSONMultipleTokens(t *testing.T) { } } -func TestFromStringThatLooksLikeJSON(t *testing.T) { - ts := NewTokenStoreFromString("{xyzzy}") +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 - wantToken := "{xyzzy}" - if diff := cmp.Diff(wantToken, token); diff != "" { + if diff := cmp.Diff(str, token); diff != "" { t.Errorf("mismatch (-want +got): %s", diff) } } From ae8c3c6d632001bbdd8667a9dddd52fadb0764f0 Mon Sep 17 00:00:00 2001 From: kenstir Date: Sun, 12 Apr 2026 20:16:28 -0400 Subject: [PATCH 14/17] Switch to RawURLEncoding to omit padding --- token_store.go | 2 +- token_store_test.go | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/token_store.go b/token_store.go index ef0b733..4d07981 100644 --- a/token_store.go +++ b/token_store.go @@ -68,7 +68,7 @@ func (ts *TokenStore) FromJSON(data []byte) error { 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 := base64.URLEncoding.DecodeString(str) + decoded, err := base64.RawURLEncoding.DecodeString(str) if err == nil { err = ts.FromJSON(decoded) if err == nil { diff --git a/token_store_test.go b/token_store_test.go index 1e4f5d3..f69f355 100644 --- a/token_store_test.go +++ b/token_store_test.go @@ -12,6 +12,7 @@ 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 != "" { @@ -113,6 +114,7 @@ func TestFromJSONInvalid(t *testing.T) { func TestFromStringSingleToken(t *testing.T) { ts := NewTokenStoreFromString("token-1") + want := 1 got := len(ts.Entries) if diff := cmp.Diff(want, got); diff != "" { @@ -122,8 +124,9 @@ func TestFromStringSingleToken(t *testing.T) { func TestFromStringV2SingleToken(t *testing.T) { json := `{"entries":[{"token":"token-1","added_at":1712664900}]}` - encoded := base64.URLEncoding.EncodeToString([]byte(json)) + encoded := base64.RawURLEncoding.EncodeToString([]byte(json)) ts := NewTokenStoreFromString(encoded) + want := 1 got := len(ts.Entries) if diff := cmp.Diff(want, got); diff != "" { @@ -133,8 +136,9 @@ func TestFromStringV2SingleToken(t *testing.T) { func TestFromStringJSONMultipleTokens(t *testing.T) { json := `{"entries":[{"token":"token-1","added_at":1712578500},{"token":"token-2","added_at":1712664960}]}` - encoded := base64.URLEncoding.EncodeToString([]byte(json)) + encoded := base64.RawURLEncoding.EncodeToString([]byte(json)) ts := NewTokenStoreFromString(encoded) + want := 2 got := len(ts.Entries) if diff := cmp.Diff(want, got); diff != "" { @@ -145,6 +149,7 @@ func TestFromStringJSONMultipleTokens(t *testing.T) { func TestFromStringThatLooksLikeV2(t *testing.T) { str := V2EncodedTokenPrefix + "xyzzy" ts := NewTokenStoreFromString(str) + want := 1 got := len(ts.Entries) if diff := cmp.Diff(want, got); diff != "" { From 9ea24c1ab3a8b7d4dda4bca480b3161add02f913 Mon Sep 17 00:00:00 2001 From: kenstir Date: Mon, 13 Apr 2026 10:02:43 -0400 Subject: [PATCH 15/17] Add v2 note to README --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) 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 From d96d1c8e7e08e4a0aafbfe9f5dc7b7ccf63de4dc Mon Sep 17 00:00:00 2001 From: kenstir Date: Mon, 13 Apr 2026 10:07:16 -0400 Subject: [PATCH 16/17] Tweak commentary --- token_store.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/token_store.go b/token_store.go index 4d07981..8e1858b 100644 --- a/token_store.go +++ b/token_store.go @@ -9,7 +9,7 @@ import ( const MaxEntries = 3 -// prefix for all v2 encoded tokens, base64url-encoded string '{"entries":[' +// prefix on all v2 base64url-encoded tokens, which when decoded is `{"entries":[` const V2EncodedTokenPrefix = "eyJlbnRyaWVzIjpb" type TokenEntry struct { From dd165db42141a0bbee54259a6cd5d3317c7e4134 Mon Sep 17 00:00:00 2001 From: kenstir Date: Mon, 13 Apr 2026 10:45:06 -0400 Subject: [PATCH 17/17] Add TestEncodingIsCompatible This test uses a known good encoding to ensure that the encoder/decoder used is compatible with other platform implementations. --- token_store.go | 6 +++++- token_store_test.go | 32 ++++++++++++++++++++++++-------- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/token_store.go b/token_store.go index 8e1858b..e99b12c 100644 --- a/token_store.go +++ b/token_store.go @@ -9,6 +9,10 @@ import ( 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" @@ -68,7 +72,7 @@ func (ts *TokenStore) FromJSON(data []byte) error { 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 := base64.RawURLEncoding.DecodeString(str) + decoded, err := V2DecodeString(str) if err == nil { err = ts.FromJSON(decoded) if err == nil { diff --git a/token_store_test.go b/token_store_test.go index f69f355..e016976 100644 --- a/token_store_test.go +++ b/token_store_test.go @@ -1,7 +1,6 @@ package main import ( - "encoding/base64" "fmt" "testing" @@ -70,16 +69,13 @@ func TestToJSON(t *testing.T) { ts := NewTokenStore() ts.AddTokenEntry(TokenEntry{ Token: "token-1", - AddedAt: 1712664900, // 2024-04-09T13:15:00Z + AddedAt: 1712664900, }) data, err := ts.ToJSON() if err != nil { t.Fatal(err) } - if len(data) == 0 { - t.Fatal("expected non-empty JSON") - } got := string(data) want := `{"entries":[{"token":"token-1","added_at":1712664900}]}` if diff := cmp.Diff(want, got); diff != "" { @@ -122,9 +118,29 @@ func TestFromStringSingleToken(t *testing.T) { } } +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 := base64.RawURLEncoding.EncodeToString([]byte(json)) + encoded := V2EncodeString([]byte(json)) ts := NewTokenStoreFromString(encoded) want := 1 @@ -134,9 +150,9 @@ func TestFromStringV2SingleToken(t *testing.T) { } } -func TestFromStringJSONMultipleTokens(t *testing.T) { +func TestFromStringV2MultipleTokens(t *testing.T) { json := `{"entries":[{"token":"token-1","added_at":1712578500},{"token":"token-2","added_at":1712664960}]}` - encoded := base64.RawURLEncoding.EncodeToString([]byte(json)) + encoded := V2EncodeString([]byte(json)) ts := NewTokenStoreFromString(encoded) want := 2