- Added device token management API for mobile devices, including registration, unregistration, and listing of devices. - Implemented push notification functionality using FCM for Android and APNS for iOS. - Introduced new endpoints for device registration and management in the devices API. - Enhanced the configuration to support mobile push notifications with optional credentials for FCM and APNS. - Updated database schema to include a new table for storing device tokens. - Added integration tests for device management and push notification features.
146 lines
3.6 KiB
Go
146 lines
3.6 KiB
Go
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
|
|
}
|