package push import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strings" "time" "golang.org/x/oauth2" "golang.org/x/oauth2/google" ) const fcmMessagingScope = "https://www.googleapis.com/auth/firebase.messaging" // fcmClient sends notifications through the FCM HTTP v1 API. type fcmClient struct { projectID string tokenSource oauth2.TokenSource http *http.Client } // newFCMClient returns a configured client, or nil (no error) when FCM is not // configured. An error is returned only when provided credentials are invalid. func newFCMClient(cfg Config) (*fcmClient, error) { raw := strings.TrimSpace(cfg.FCMServiceAccountJSON) if raw == "" { return nil, nil } creds, err := google.CredentialsFromJSON(context.Background(), []byte(raw), fcmMessagingScope) if err != nil { return nil, fmt.Errorf("parse fcm service account: %w", err) } projectID := strings.TrimSpace(cfg.FCMProjectID) if projectID == "" { projectID = creds.ProjectID } if projectID == "" { // Fall back to parsing the JSON directly for project_id. var sa struct { ProjectID string `json:"project_id"` } _ = json.Unmarshal([]byte(raw), &sa) projectID = strings.TrimSpace(sa.ProjectID) } if projectID == "" { return nil, fmt.Errorf("fcm project id missing (set FCM_PROJECT_ID or include project_id in service account)") } return &fcmClient{ projectID: projectID, tokenSource: creds.TokenSource, http: &http.Client{Timeout: 10 * time.Second}, }, nil } type fcmMessage struct { Message fcmMessageBody `json:"message"` } type fcmMessageBody struct { Token string `json:"token"` Notification fcmNotification `json:"notification"` Data map[string]string `json:"data,omitempty"` } type fcmNotification struct { Title string `json:"title,omitempty"` Body string `json:"body,omitempty"` } func (c *fcmClient) send(ctx context.Context, deviceToken string, n Notification) error { accessToken, err := c.tokenSource.Token() if err != nil { return fmt.Errorf("fcm access token: %w", err) } payload := fcmMessage{ Message: fcmMessageBody{ Token: deviceToken, Notification: fcmNotification{ Title: n.Title, Body: n.Body, }, Data: n.Data, }, } body, err := json.Marshal(payload) if err != nil { return err } url := fmt.Sprintf("https://fcm.googleapis.com/v1/projects/%s/messages:send", c.projectID) req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+accessToken.AccessToken) req.Header.Set("Content-Type", "application/json") resp, err := c.http.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 200 && resp.StatusCode < 300 { return nil } respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) // A 404 (NOT_FOUND) or UNREGISTERED error means the token is dead. if resp.StatusCode == http.StatusNotFound || fcmIsUnregistered(respBody) { return errTokenUnregistered } return fmt.Errorf("fcm send status %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody))) } func fcmIsUnregistered(body []byte) bool { var parsed struct { Error struct { Status string `json:"status"` Details []struct { ErrorCode string `json:"errorCode"` } `json:"details"` } `json:"error"` } if err := json.Unmarshal(body, &parsed); err != nil { return false } if parsed.Error.Status == "NOT_FOUND" { return true } for _, d := range parsed.Error.Details { if d.ErrorCode == "UNREGISTERED" { return true } } return false }