package push import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "os" "time" "github.com/rs/zerolog/log" "golang.org/x/oauth2/google" "github.com/treytartt/honeydue-api/internal/config" ) const ( // fcmV1EndpointFmt is the FCM HTTP v1 API endpoint template. fcmV1EndpointFmt = "https://fcm.googleapis.com/v1/projects/%s/messages:send" // fcmScope is the OAuth 2.0 scope required for FCM HTTP v1 API. fcmScope = "https://www.googleapis.com/auth/firebase.messaging" ) // FCMClient handles communication with Firebase Cloud Messaging // using the HTTP v1 API and OAuth 2.0 service account authentication. type FCMClient struct { projectID string endpoint string httpClient *http.Client } // --- Request types (FCM v1 API) --- // fcmV1Request is the top-level request body for the FCM v1 API. type fcmV1Request struct { Message *fcmV1Message `json:"message"` } // fcmV1Message represents a single FCM v1 message. type fcmV1Message struct { Token string `json:"token"` Notification *FCMNotification `json:"notification,omitempty"` Data map[string]string `json:"data,omitempty"` Android *fcmAndroidConfig `json:"android,omitempty"` } // FCMNotification represents the notification payload. type FCMNotification struct { Title string `json:"title,omitempty"` Body string `json:"body,omitempty"` } // fcmAndroidConfig provides Android-specific message configuration. type fcmAndroidConfig struct { Priority string `json:"priority,omitempty"` } // --- Response types (FCM v1 API) --- // fcmV1Response is the successful response from the FCM v1 API. type fcmV1Response struct { Name string `json:"name"` // e.g. "projects/myproject/messages/0:1234567890" } // fcmV1ErrorResponse is the error response from the FCM v1 API. type fcmV1ErrorResponse struct { Error fcmV1Error `json:"error"` } // fcmV1Error contains the structured error details. type fcmV1Error struct { Code int `json:"code"` Message string `json:"message"` Status string `json:"status"` Details json.RawMessage `json:"details,omitempty"` } // --- Error types --- // FCMErrorCode represents well-known FCM v1 error codes for programmatic handling. type FCMErrorCode string const ( FCMErrUnregistered FCMErrorCode = "UNREGISTERED" FCMErrQuotaExceeded FCMErrorCode = "QUOTA_EXCEEDED" FCMErrUnavailable FCMErrorCode = "UNAVAILABLE" FCMErrInternal FCMErrorCode = "INTERNAL" FCMErrInvalidArgument FCMErrorCode = "INVALID_ARGUMENT" FCMErrSenderIDMismatch FCMErrorCode = "SENDER_ID_MISMATCH" FCMErrThirdPartyAuth FCMErrorCode = "THIRD_PARTY_AUTH_ERROR" ) // FCMSendError is a structured error returned when an individual FCM send fails. type FCMSendError struct { Token string StatusCode int ErrorCode FCMErrorCode Message string } func (e *FCMSendError) Error() string { return fmt.Sprintf("FCM send failed for token %s: %s (status %d, code %s)", truncateToken(e.Token), e.Message, e.StatusCode, e.ErrorCode) } // IsUnregistered returns true if the device token is no longer valid and should be removed. func (e *FCMSendError) IsUnregistered() bool { return e.ErrorCode == FCMErrUnregistered } // --- OAuth 2.0 transport --- // oauth2BearerTransport is an http.RoundTripper that attaches an OAuth 2.0 Bearer // token to every outgoing request. The token source handles refresh automatically. type oauth2BearerTransport struct { base http.RoundTripper getToken func() (string, error) } func (t *oauth2BearerTransport) RoundTrip(req *http.Request) (*http.Response, error) { accessToken, err := t.getToken() if err != nil { return nil, fmt.Errorf("failed to obtain OAuth 2.0 token for FCM: %w", err) } r := req.Clone(req.Context()) r.Header.Set("Authorization", "Bearer "+accessToken) return t.base.RoundTrip(r) } // --- Client construction --- // NewFCMClient creates a new FCM client using the HTTP v1 API with OAuth 2.0 // service account authentication. It accepts either a path to a service account // JSON file or the raw JSON content directly via config. func NewFCMClient(cfg *config.PushConfig) (*FCMClient, error) { if cfg.FCMProjectID == "" { return nil, fmt.Errorf("FCM project ID not configured (set FCM_PROJECT_ID)") } credJSON, err := resolveServiceAccountJSON(cfg) if err != nil { return nil, err } // Create OAuth 2.0 credentials with the FCM messaging scope. // The google library handles automatic token refresh. creds, err := google.CredentialsFromJSON(context.Background(), credJSON, fcmScope) if err != nil { return nil, fmt.Errorf("failed to parse FCM service account credentials: %w", err) } // Build an HTTP client that automatically attaches and refreshes OAuth tokens. transport := &oauth2BearerTransport{ base: http.DefaultTransport, getToken: func() (string, error) { tok, err := creds.TokenSource.Token() if err != nil { return "", err } return tok.AccessToken, nil }, } httpClient := &http.Client{ Timeout: 30 * time.Second, Transport: transport, } endpoint := fmt.Sprintf(fcmV1EndpointFmt, cfg.FCMProjectID) log.Info(). Str("project_id", cfg.FCMProjectID). Str("endpoint", endpoint). Msg("FCM v1 client initialized with OAuth 2.0") return &FCMClient{ projectID: cfg.FCMProjectID, endpoint: endpoint, httpClient: httpClient, }, nil } // resolveServiceAccountJSON returns the service account JSON bytes from config. func resolveServiceAccountJSON(cfg *config.PushConfig) ([]byte, error) { if cfg.FCMServiceAccountJSON != "" { return []byte(cfg.FCMServiceAccountJSON), nil } if cfg.FCMServiceAccountPath != "" { data, err := os.ReadFile(cfg.FCMServiceAccountPath) if err != nil { return nil, fmt.Errorf("failed to read FCM service account file %s: %w", cfg.FCMServiceAccountPath, err) } return data, nil } return nil, fmt.Errorf("FCM service account not configured (set FCM_SERVICE_ACCOUNT_PATH or FCM_SERVICE_ACCOUNT_JSON)") } // --- Sending --- // Send sends a push notification to Android devices via the FCM HTTP v1 API. // The v1 API requires one request per device token, so this iterates over all tokens. // The method signature is kept identical to the previous legacy implementation // so callers do not need to change. func (c *FCMClient) Send(ctx context.Context, tokens []string, title, message string, data map[string]string) error { if len(tokens) == 0 { return nil } var sendErrors []error successCount := 0 for _, token := range tokens { err := c.sendOne(ctx, token, title, message, data) if err != nil { log.Error(). Err(err). Str("token", truncateToken(token)). Msg("FCM v1 notification failed") sendErrors = append(sendErrors, err) continue } successCount++ log.Debug(). Str("token", truncateToken(token)). Msg("FCM v1 notification sent successfully") } log.Info(). Int("total", len(tokens)). Int("success", successCount). Int("failed", len(sendErrors)). Msg("FCM v1 batch send complete") if len(sendErrors) > 0 && successCount == 0 { return fmt.Errorf("all FCM notifications failed: first error: %w", sendErrors[0]) } return nil } // sendOne sends a single FCM v1 message to one device token. func (c *FCMClient) sendOne(ctx context.Context, token, title, message string, data map[string]string) error { reqBody := fcmV1Request{ Message: &fcmV1Message{ Token: token, Notification: &FCMNotification{ Title: title, Body: message, }, Data: data, Android: &fcmAndroidConfig{ Priority: "HIGH", }, }, } body, err := json.Marshal(reqBody) if err != nil { return fmt.Errorf("failed to marshal FCM v1 message: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(body)) if err != nil { return fmt.Errorf("failed to create FCM v1 request: %w", err) } req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send FCM v1 request: %w", err) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("failed to read FCM v1 response body: %w", err) } if resp.StatusCode == http.StatusOK { return nil } // Parse the error response for structured error information. return parseFCMV1Error(token, resp.StatusCode, respBody) } // parseFCMV1Error extracts a structured FCMSendError from the v1 API error response. func parseFCMV1Error(token string, statusCode int, body []byte) *FCMSendError { var errResp fcmV1ErrorResponse if err := json.Unmarshal(body, &errResp); err != nil { return &FCMSendError{ Token: token, StatusCode: statusCode, Message: fmt.Sprintf("unparseable error response: %s", string(body)), } } return &FCMSendError{ Token: token, StatusCode: statusCode, ErrorCode: FCMErrorCode(errResp.Error.Status), Message: errResp.Error.Message, } }