All checks were successful
build-and-push / build (push) Successful in 35s
- 직급에 인턴 추가(기본 할당량 15), 직책(position)은 UI에서 제거(컬럼은 유지) - 초과근무: 유저 신청 제거 → 관리자 근무관리에서 실제 출퇴근 기록 기반 자동 집계 - 로그아웃: infra 공통 LOGOUT_URL(/me로 전달) 사용 → oauth2-proxy 종료 + Keycloak end-session - (이전 커밋 포함) Device 등록 + FCM HTTP v1 sender + notify 연동 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
196 lines
5.7 KiB
Go
196 lines
5.7 KiB
Go
// Package push sends FCM (Firebase Cloud Messaging) notifications via the HTTP v1
|
|
// API using a service-account JSON — no third-party SDK, stdlib only.
|
|
//
|
|
// It is OPTIONAL: if no credentials are configured the Sender is "disabled" and
|
|
// Send is a no-op (logged). This lets spin run without Firebase until the client
|
|
// provides a service account.
|
|
package push
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto"
|
|
"crypto/rsa"
|
|
"crypto/sha256"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type serviceAccount struct {
|
|
ProjectID string `json:"project_id"`
|
|
PrivateKey string `json:"private_key"`
|
|
ClientEmail string `json:"client_email"`
|
|
TokenURI string `json:"token_uri"`
|
|
}
|
|
|
|
// Sender holds the FCM credentials and a cached OAuth access token.
|
|
type Sender struct {
|
|
sa *serviceAccount
|
|
key *rsa.PrivateKey
|
|
mu sync.Mutex
|
|
token string
|
|
expires time.Time
|
|
client *http.Client
|
|
}
|
|
|
|
// New builds a Sender from a service-account JSON file path. An empty path or a
|
|
// read error yields a disabled Sender (Send is a no-op).
|
|
func New(credsPath string) *Sender {
|
|
if strings.TrimSpace(credsPath) == "" {
|
|
log.Printf("push: FCM disabled (no credentials configured)")
|
|
return &Sender{}
|
|
}
|
|
raw, err := os.ReadFile(credsPath)
|
|
if err != nil {
|
|
log.Printf("push: FCM disabled (cannot read %s: %v)", credsPath, err)
|
|
return &Sender{}
|
|
}
|
|
var sa serviceAccount
|
|
if err := json.Unmarshal(raw, &sa); err != nil || sa.PrivateKey == "" || sa.ClientEmail == "" {
|
|
log.Printf("push: FCM disabled (invalid service account JSON)")
|
|
return &Sender{}
|
|
}
|
|
key, err := parsePrivateKey(sa.PrivateKey)
|
|
if err != nil {
|
|
log.Printf("push: FCM disabled (bad private key: %v)", err)
|
|
return &Sender{}
|
|
}
|
|
if sa.TokenURI == "" {
|
|
sa.TokenURI = "https://oauth2.googleapis.com/token"
|
|
}
|
|
log.Printf("push: FCM enabled (project=%s)", sa.ProjectID)
|
|
return &Sender{sa: &sa, key: key, client: &http.Client{Timeout: 10 * time.Second}}
|
|
}
|
|
|
|
// Enabled reports whether real sending is configured.
|
|
func (s *Sender) Enabled() bool { return s != nil && s.sa != nil }
|
|
|
|
// Send delivers a notification to each token. No-op when disabled. Errors per
|
|
// token are logged but don't abort the batch.
|
|
func (s *Sender) Send(ctx context.Context, tokens []string, title, body, link string) {
|
|
if !s.Enabled() || len(tokens) == 0 {
|
|
return
|
|
}
|
|
tok, err := s.accessToken(ctx)
|
|
if err != nil {
|
|
log.Printf("push: token error: %v", err)
|
|
return
|
|
}
|
|
endpoint := fmt.Sprintf("https://fcm.googleapis.com/v1/projects/%s/messages:send", s.sa.ProjectID)
|
|
for _, t := range tokens {
|
|
msg := map[string]any{
|
|
"message": map[string]any{
|
|
"token": t,
|
|
"notification": map[string]string{"title": title, "body": body},
|
|
"data": map[string]string{"link": link},
|
|
},
|
|
}
|
|
b, _ := json.Marshal(msg)
|
|
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(b))
|
|
req.Header.Set("Authorization", "Bearer "+tok)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := s.client.Do(req)
|
|
if err != nil {
|
|
log.Printf("push: send error: %v", err)
|
|
continue
|
|
}
|
|
if resp.StatusCode >= 300 {
|
|
rb, _ := io.ReadAll(resp.Body)
|
|
log.Printf("push: FCM %d: %s", resp.StatusCode, string(rb))
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
}
|
|
|
|
// accessToken returns a cached OAuth2 access token, minting a new one via the
|
|
// service-account JWT grant when expired.
|
|
func (s *Sender) accessToken(ctx context.Context) (string, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.token != "" && time.Now().Before(s.expires.Add(-60*time.Second)) {
|
|
return s.token, nil
|
|
}
|
|
now := time.Now()
|
|
claims := map[string]any{
|
|
"iss": s.sa.ClientEmail,
|
|
"scope": "https://www.googleapis.com/auth/firebase.messaging",
|
|
"aud": s.sa.TokenURI,
|
|
"iat": now.Unix(),
|
|
"exp": now.Add(time.Hour).Unix(),
|
|
}
|
|
assertion, err := s.signJWT(claims)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
form := url.Values{}
|
|
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
|
|
form.Set("assertion", assertion)
|
|
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, s.sa.TokenURI, strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
resp, err := s.client.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
var out struct {
|
|
AccessToken string `json:"access_token"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
Error string `json:"error"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
return "", err
|
|
}
|
|
if out.AccessToken == "" {
|
|
return "", fmt.Errorf("token exchange failed: %s", out.Error)
|
|
}
|
|
s.token = out.AccessToken
|
|
s.expires = now.Add(time.Duration(out.ExpiresIn) * time.Second)
|
|
return s.token, nil
|
|
}
|
|
|
|
func (s *Sender) signJWT(claims map[string]any) (string, error) {
|
|
header := map[string]string{"alg": "RS256", "typ": "JWT"}
|
|
hb, _ := json.Marshal(header)
|
|
cb, _ := json.Marshal(claims)
|
|
signingInput := b64(hb) + "." + b64(cb)
|
|
h := sha256.Sum256([]byte(signingInput))
|
|
sig, err := rsa.SignPKCS1v15(nil, s.key, crypto.SHA256, h[:])
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig), nil
|
|
}
|
|
|
|
func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
|
|
|
|
func parsePrivateKey(pemStr string) (*rsa.PrivateKey, error) {
|
|
block, _ := pem.Decode([]byte(pemStr))
|
|
if block == nil {
|
|
return nil, errors.New("no PEM block")
|
|
}
|
|
if k, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
|
|
return k, nil
|
|
}
|
|
keyAny, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
k, ok := keyAny.(*rsa.PrivateKey)
|
|
if !ok {
|
|
return nil, errors.New("not an RSA private key")
|
|
}
|
|
return k, nil
|
|
}
|