// Package push sends FCM (Firebase Cloud Messaging) notifications via the HTTP v1 // API using a service-account JSON — no third-party SDK, stdlib only. // // It is OPTIONAL: if no credentials are configured the Sender is "disabled" and // Send is a no-op (logged). This lets spin run without Firebase until the client // provides a service account. package push import ( "bytes" "context" "crypto" "crypto/rsa" "crypto/sha256" "crypto/x509" "encoding/base64" "encoding/json" "encoding/pem" "errors" "fmt" "io" "log" "net/http" "net/url" "os" "strings" "sync" "time" ) type serviceAccount struct { ProjectID string `json:"project_id"` PrivateKey string `json:"private_key"` ClientEmail string `json:"client_email"` TokenURI string `json:"token_uri"` } // Sender holds the FCM credentials and a cached OAuth access token. type Sender struct { sa *serviceAccount key *rsa.PrivateKey mu sync.Mutex token string expires time.Time client *http.Client } // New builds a Sender from a service-account JSON file path. An empty path or a // read error yields a disabled Sender (Send is a no-op). func New(credsPath string) *Sender { if strings.TrimSpace(credsPath) == "" { log.Printf("push: FCM disabled (no credentials configured)") return &Sender{} } raw, err := os.ReadFile(credsPath) if err != nil { log.Printf("push: FCM disabled (cannot read %s: %v)", credsPath, err) return &Sender{} } var sa serviceAccount if err := json.Unmarshal(raw, &sa); err != nil || sa.PrivateKey == "" || sa.ClientEmail == "" { log.Printf("push: FCM disabled (invalid service account JSON)") return &Sender{} } key, err := parsePrivateKey(sa.PrivateKey) if err != nil { log.Printf("push: FCM disabled (bad private key: %v)", err) return &Sender{} } if sa.TokenURI == "" { sa.TokenURI = "https://oauth2.googleapis.com/token" } log.Printf("push: FCM enabled (project=%s)", sa.ProjectID) return &Sender{sa: &sa, key: key, client: &http.Client{Timeout: 10 * time.Second}} } // Enabled reports whether real sending is configured. func (s *Sender) Enabled() bool { return s != nil && s.sa != nil } // Send delivers a notification to each token. No-op when disabled. Errors per // token are logged but don't abort the batch. func (s *Sender) Send(ctx context.Context, tokens []string, title, body, link string) { if !s.Enabled() || len(tokens) == 0 { return } tok, err := s.accessToken(ctx) if err != nil { log.Printf("push: token error: %v", err) return } endpoint := fmt.Sprintf("https://fcm.googleapis.com/v1/projects/%s/messages:send", s.sa.ProjectID) for _, t := range tokens { msg := map[string]any{ "message": map[string]any{ "token": t, "notification": map[string]string{"title": title, "body": body}, "data": map[string]string{"link": link}, }, } b, _ := json.Marshal(msg) req, _ := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(b)) req.Header.Set("Authorization", "Bearer "+tok) req.Header.Set("Content-Type", "application/json") resp, err := s.client.Do(req) if err != nil { log.Printf("push: send error: %v", err) continue } if resp.StatusCode >= 300 { rb, _ := io.ReadAll(resp.Body) log.Printf("push: FCM %d: %s", resp.StatusCode, string(rb)) } resp.Body.Close() } } // accessToken returns a cached OAuth2 access token, minting a new one via the // service-account JWT grant when expired. func (s *Sender) accessToken(ctx context.Context) (string, error) { s.mu.Lock() defer s.mu.Unlock() if s.token != "" && time.Now().Before(s.expires.Add(-60*time.Second)) { return s.token, nil } now := time.Now() claims := map[string]any{ "iss": s.sa.ClientEmail, "scope": "https://www.googleapis.com/auth/firebase.messaging", "aud": s.sa.TokenURI, "iat": now.Unix(), "exp": now.Add(time.Hour).Unix(), } assertion, err := s.signJWT(claims) if err != nil { return "", err } form := url.Values{} form.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer") form.Set("assertion", assertion) req, _ := http.NewRequestWithContext(ctx, http.MethodPost, s.sa.TokenURI, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := s.client.Do(req) if err != nil { return "", err } defer resp.Body.Close() var out struct { AccessToken string `json:"access_token"` ExpiresIn int `json:"expires_in"` Error string `json:"error"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return "", err } if out.AccessToken == "" { return "", fmt.Errorf("token exchange failed: %s", out.Error) } s.token = out.AccessToken s.expires = now.Add(time.Duration(out.ExpiresIn) * time.Second) return s.token, nil } func (s *Sender) signJWT(claims map[string]any) (string, error) { header := map[string]string{"alg": "RS256", "typ": "JWT"} hb, _ := json.Marshal(header) cb, _ := json.Marshal(claims) signingInput := b64(hb) + "." + b64(cb) h := sha256.Sum256([]byte(signingInput)) sig, err := rsa.SignPKCS1v15(nil, s.key, crypto.SHA256, h[:]) if err != nil { return "", err } return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig), nil } func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } func parsePrivateKey(pemStr string) (*rsa.PrivateKey, error) { block, _ := pem.Decode([]byte(pemStr)) if block == nil { return nil, errors.New("no PEM block") } if k, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { return k, nil } keyAny, err := x509.ParsePKCS8PrivateKey(block.Bytes) if err != nil { return nil, err } k, ok := keyAny.(*rsa.PrivateKey) if !ok { return nil, errors.New("not an RSA private key") } return k, nil }