Security: - Replace all binding: tags with validate: + c.Validate() in admin handlers - Add rate limiting to auth endpoints (login, register, password reset) - Add security headers (HSTS, XSS protection, nosniff, frame options) - Wire Google Pub/Sub token verification into webhook handler - Replace ParseUnverified with proper OIDC/JWKS key verification - Verify inner Apple JWS signatures in webhook handler - Add io.LimitReader (1MB) to all webhook body reads - Add ownership verification to file deletion - Move hardcoded admin credentials to env vars - Add uniqueIndex to User.Email - Hide ConfirmationCode from JSON serialization - Mask confirmation codes in admin responses - Use http.DetectContentType for upload validation - Fix path traversal in storage service - Replace os.Getenv with Viper in stripe service - Sanitize Redis URLs before logging - Separate DEBUG_FIXED_CODES from DEBUG flag - Reject weak SECRET_KEY in production - Add host check on /_next/* proxy routes - Use explicit localhost CORS origins in debug mode - Replace err.Error() with generic messages in all admin error responses Critical fixes: - Rewrite FCM to HTTP v1 API with OAuth 2.0 service account auth - Fix user_customuser -> auth_user table names in raw SQL - Fix dashboard verified query to use UserProfile model - Add escapeLikeWildcards() to prevent SQL wildcard injection Bug fixes: - Add bounds checks for days/expiring_soon query params (1-3650) - Add receipt_data/transaction_id empty-check to RestoreSubscription - Change Active bool -> *bool in device handler - Check all unchecked GORM/FindByIDWithProfile errors - Add validation for notification hour fields (0-23) - Add max=10000 validation on task description updates Transactions & data integrity: - Wrap registration flow in transaction - Wrap QuickComplete in transaction - Move image creation inside completion transaction - Wrap SetSpecialties in transaction - Wrap GetOrCreateToken in transaction - Wrap completion+image deletion in transaction Performance: - Batch completion summaries (2 queries vs 2N) - Reuse single http.Client in IAP validation - Cache dashboard counts (30s TTL) - Batch COUNT queries in admin user list - Add Limit(500) to document queries - Add reminder_stage+due_date filters to reminder queries - Parse AllowedTypes once at init - In-memory user cache in auth middleware (30s TTL) - Timezone change detection cache - Optimize P95 with per-endpoint sorted buffers - Replace crypto/md5 with hash/fnv for ETags Code quality: - Add sync.Once to all monitoring Stop()/Close() methods - Replace 8 fmt.Printf with zerolog in auth service - Log previously discarded errors - Standardize delete response shapes - Route hardcoded English through i18n - Remove FileURL from DocumentResponse (keep MediaURL only) - Thread user timezone through kanban board responses - Initialize empty slices to prevent null JSON - Extract shared field map for task Update/UpdateTx - Delete unused SoftDeleteModel, min(), formatCron, legacy handlers Worker & jobs: - Wire Asynq email infrastructure into worker - Register HandleReminderLogCleanup with daily 3AM cron - Use per-user timezone in HandleSmartReminder - Replace direct DB queries with repository calls - Delete legacy reminder handlers (~200 lines) - Delete unused task type constants Dependencies: - Replace archived jung-kurt/gofpdf with go-pdf/fpdf - Replace unmaintained gomail.v2 with wneessen/go-mail - Add TODO for Echo jwt v3 transitive dep removal Test infrastructure: - Fix MakeRequest/SeedLookupData error handling - Replace os.Exit(0) with t.Skip() in scope/consistency tests - Add 11 new FCM v1 tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
309 lines
8.8 KiB
Go
309 lines
8.8 KiB
Go
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,
|
|
}
|
|
}
|