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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion buildinfo.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
module kenstir.net/hemlock-sendmsg
module github.com/kenstir/hemlock-sendmsg

go 1.22.2

Expand All @@ -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
Expand Down
126 changes: 81 additions & 45 deletions sendmsg.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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
}

Expand All @@ -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")
Expand Down Expand Up @@ -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)

Comment thread
kenstir marked this conversation as resolved.
// 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)
}
Comment thread
kenstir marked this conversation as resolved.

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) {
Expand Down
86 changes: 86 additions & 0 deletions token_store.go
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
kenstir marked this conversation as resolved.
}

// 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)
Comment thread
kenstir marked this conversation as resolved.
if err == nil {
err = ts.FromJSON(decoded)
if err == nil {
Comment thread
kenstir marked this conversation as resolved.
return
}
}
}

// treat it as a single token string
ts.AddToken(str)
}
Loading
Loading