iykyka/core-backend/push.go

227 lines
6.1 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"sync"
"time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"gorm.io/gorm"
)
const fcmMessagingScope = "https://www.googleapis.com/auth/firebase.messaging"
// FCM_SERVER_KEY = legacy HTTP API (often disabled in new Firebase projects).
// Prefer FCM HTTP v1 via service account:
// FCM_SERVICE_ACCOUNT_FILE=/path/to.json
// or FCM_SERVICE_ACCOUNT_JSON='{"type":"service_account",...}'
func fcmServerKey() string {
return strings.TrimSpace(os.Getenv("FCM_SERVER_KEY"))
}
func loadFCMServiceAccountJSON() ([]byte, error) {
if path := strings.TrimSpace(os.Getenv("FCM_SERVICE_ACCOUNT_FILE")); path != "" {
b, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read FCM_SERVICE_ACCOUNT_FILE: %w", err)
}
return bytes.TrimSpace(b), nil
}
if raw := strings.TrimSpace(os.Getenv("FCM_SERVICE_ACCOUNT_JSON")); raw != "" {
return []byte(raw), nil
}
return nil, nil
}
type fcmServiceAccount struct {
ProjectID string `json:"project_id"`
}
type fcmLegacyPayload struct {
To string `json:"to,omitempty"`
Registration []string `json:"registration_ids,omitempty"`
Priority string `json:"priority"`
Notification map[string]string `json:"notification"`
Data map[string]string `json:"data,omitempty"`
}
type fcmV1MessageRequest struct {
Message fcmV1Message `json:"message"`
}
type fcmV1Message struct {
Token string `json:"token"`
Notification map[string]string `json:"notification,omitempty"`
Data map[string]string `json:"data,omitempty"`
}
var (
fcmTokenMu sync.Mutex
fcmTokenSource oauth2.TokenSource
fcmProjectID string
)
func fcmV1Ready(ctx context.Context) (projectID string, ts oauth2.TokenSource, err error) {
fcmTokenMu.Lock()
defer fcmTokenMu.Unlock()
if fcmTokenSource != nil && fcmProjectID != "" {
return fcmProjectID, fcmTokenSource, nil
}
raw, err := loadFCMServiceAccountJSON()
if err != nil {
return "", nil, err
}
if len(raw) == 0 {
return "", nil, nil
}
var sa fcmServiceAccount
if err := json.Unmarshal(raw, &sa); err != nil {
return "", nil, fmt.Errorf("parse service account json: %w", err)
}
if strings.TrimSpace(sa.ProjectID) == "" {
return "", nil, fmt.Errorf("service account json missing project_id")
}
creds, err := google.CredentialsFromJSON(ctx, raw, fcmMessagingScope)
if err != nil {
return "", nil, fmt.Errorf("fcm credentials: %w", err)
}
fcmProjectID = sa.ProjectID
fcmTokenSource = creds.TokenSource
return fcmProjectID, fcmTokenSource, nil
}
func collectFCMRegistrationIDs(tokens []DeviceToken) []string {
regIDs := make([]string, 0, len(tokens))
for _, t := range tokens {
if strings.HasPrefix(t.Token, "install:") {
continue
}
regIDs = append(regIDs, t.Token)
}
return regIDs
}
func notifyUser(db *gorm.DB, userID uint, title, body string, data map[string]string) (sent int, skippedReason string, err error) {
var tokens []DeviceToken
db.Where("user_id = ?", userID).Find(&tokens)
if len(tokens) == 0 {
return 0, "no_device_tokens", nil
}
regIDs := collectFCMRegistrationIDs(tokens)
if len(regIDs) == 0 {
runtimeMetrics.recordPush(0, true)
return 0, "only_placeholder_tokens", nil
}
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
defer cancel()
projectID, ts, v1err := fcmV1Ready(ctx)
if v1err != nil {
runtimeMetrics.recordPush(0, true)
return 0, "", v1err
}
if projectID != "" && ts != nil {
n, err := sendFCMv1(ctx, projectID, ts, regIDs, title, body, data)
if err != nil {
runtimeMetrics.recordPush(0, true)
return 0, "", err
}
runtimeMetrics.recordPush(n, false)
return n, "", nil
}
// Legacy fallback when service account is not configured.
key := fcmServerKey()
if key == "" {
runtimeMetrics.recordPush(0, true)
return 0, "fcm_not_configured", nil
}
n, err := sendFCMLegacy(key, regIDs, title, body, data)
if err != nil {
runtimeMetrics.recordPush(0, true)
return 0, "", err
}
runtimeMetrics.recordPush(n, false)
return n, "", nil
}
func sendFCMv1(ctx context.Context, projectID string, ts oauth2.TokenSource, regIDs []string, title, body string, data map[string]string) (int, error) {
tok, err := ts.Token()
if err != nil {
return 0, fmt.Errorf("fcm access token: %w", err)
}
client := &http.Client{Timeout: 10 * time.Second}
url := fmt.Sprintf("https://fcm.googleapis.com/v1/projects/%s/messages:send", projectID)
sent := 0
var lastErr error
for _, reg := range regIDs {
payload := fcmV1MessageRequest{
Message: fcmV1Message{
Token: reg,
Notification: map[string]string{"title": title, "body": body},
Data: data,
},
}
raw, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(raw))
if err != nil {
return sent, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
resp, err := client.Do(req)
if err != nil {
lastErr = err
continue
}
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
resp.Body.Close()
if resp.StatusCode >= 300 {
lastErr = fmt.Errorf("fcm v1 returned %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
continue
}
sent++
}
if sent == 0 && lastErr != nil {
return 0, lastErr
}
return sent, nil
}
func sendFCMLegacy(key string, regIDs []string, title, body string, data map[string]string) (int, error) {
payload := fcmLegacyPayload{
Registration: regIDs,
Priority: "high",
Notification: map[string]string{"title": title, "body": body},
Data: data,
}
raw, _ := json.Marshal(payload)
req, err := http.NewRequest(http.MethodPost, "https://fcm.googleapis.com/fcm/send", bytes.NewReader(raw))
if err != nil {
return 0, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "key="+key)
client := &http.Client{Timeout: 8 * time.Second}
resp, err := client.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return 0, fmt.Errorf("fcm returned %d", resp.StatusCode)
}
return len(regIDs), nil
}