Skip to content

Commit c23277c

Browse files
committed
feat(push): persist FCM messages
Signed-off-by: Chris Gianelloni <wolf31o2@blinklabs.io>
1 parent 87842cc commit c23277c

File tree

5 files changed

+228
-48
lines changed

5 files changed

+228
-48
lines changed

output/push/fcm/message.go

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,6 @@ import (
2222
"fmt"
2323
"io"
2424
"net/http"
25-
"os"
26-
27-
"github.com/blinklabs-io/adder/internal/logging"
2825
)
2926

3027
type Message struct {
@@ -59,10 +56,9 @@ func WithNotification(title string, body string) MessageOption {
5956
}
6057
}
6158

62-
func NewMessage(token string, opts ...MessageOption) *Message {
59+
func NewMessage(token string, opts ...MessageOption) (*Message, error) {
6360
if token == "" {
64-
logging.GetLogger().Error("Token is mandatory for FCM message")
65-
os.Exit(1)
61+
return nil, errors.New("token is mandatory for FCM message")
6662
}
6763

6864
msg := &Message{
@@ -73,7 +69,7 @@ func NewMessage(token string, opts ...MessageOption) *Message {
7369
for _, opt := range opts {
7470
opt(&msg.MessageContent)
7571
}
76-
return msg
72+
return msg, nil
7773
}
7874

7975
func Send(accessToken string, projectId string, msg *Message) error {

output/push/fcm/message_test.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Copyright 2025 Blink Labs Software
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package fcm
16+
17+
import (
18+
"testing"
19+
20+
"github.com/stretchr/testify/assert"
21+
"github.com/stretchr/testify/require"
22+
)
23+
24+
func TestNewMessage(t *testing.T) {
25+
t.Run("empty token returns error", func(t *testing.T) {
26+
msg, err := NewMessage("")
27+
assert.Nil(t, msg)
28+
require.Error(t, err)
29+
assert.Contains(t, err.Error(), "token is mandatory")
30+
})
31+
32+
t.Run("valid token returns message", func(t *testing.T) {
33+
msg, err := NewMessage("valid-token-123")
34+
require.NoError(t, err)
35+
require.NotNil(t, msg)
36+
assert.Equal(t, "valid-token-123", msg.Token)
37+
})
38+
39+
t.Run("with notification option", func(t *testing.T) {
40+
msg, err := NewMessage(
41+
"valid-token-123",
42+
WithNotification("Test Title", "Test Body"),
43+
)
44+
require.NoError(t, err)
45+
require.NotNil(t, msg)
46+
require.NotNil(t, msg.Notification)
47+
assert.Equal(t, "Test Title", msg.Notification.Title)
48+
assert.Equal(t, "Test Body", msg.Notification.Body)
49+
})
50+
51+
t.Run("with data option", func(t *testing.T) {
52+
data := map[string]any{
53+
"key1": "value1",
54+
"key2": "value2",
55+
}
56+
msg, err := NewMessage(
57+
"valid-token-123",
58+
WithData(data),
59+
)
60+
require.NoError(t, err)
61+
require.NotNil(t, msg)
62+
assert.Equal(t, data, msg.Data)
63+
})
64+
}

output/push/fcm_repository.go

Lines changed: 124 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,21 @@
1515
package push
1616

1717
import (
18+
"encoding/json"
1819
"net/http"
20+
"os"
21+
"sync"
1922

2023
_ "github.com/blinklabs-io/adder/docs"
24+
"github.com/blinklabs-io/adder/internal/logging"
2125
"github.com/gin-gonic/gin"
2226
)
2327

2428
type TokenStore struct {
25-
FCMTokens map[string]string
29+
FCMTokens map[string]string `json:"fcm_tokens"`
30+
filePath string
31+
mu sync.RWMutex
32+
persistMutex sync.Mutex
2633
}
2734

2835
// TokenRequest represents a request containing an FCM token.
@@ -43,23 +50,116 @@ type ErrorResponse struct {
4350
Error string `json:"error"`
4451
}
4552

46-
// TODO add support for persistence (#335)
4753
var fcmStore *TokenStore
4854

4955
func init() {
50-
fcmStore = newTokenStore()
56+
fcmStore = newTokenStore("")
5157
}
5258

53-
func newTokenStore() *TokenStore {
54-
return &TokenStore{
59+
func newTokenStore(filePath string) *TokenStore {
60+
store := &TokenStore{
5561
FCMTokens: make(map[string]string),
62+
filePath: filePath,
63+
}
64+
// Load existing tokens if persistence is enabled
65+
if filePath != "" {
66+
store.loadTokens()
67+
}
68+
return store
69+
}
70+
71+
// SetPersistenceFile configures the file path for token persistence
72+
// If called with a non-empty path, tokens will be loaded from and saved to this file
73+
func SetPersistenceFile(filePath string) {
74+
if fcmStore == nil {
75+
fcmStore = newTokenStore(filePath)
76+
return
77+
}
78+
fcmStore.persistMutex.Lock()
79+
fcmStore.filePath = filePath
80+
fcmStore.persistMutex.Unlock()
81+
if filePath != "" {
82+
fcmStore.loadTokens()
5683
}
5784
}
5885

5986
func getTokenStore() *TokenStore {
6087
return fcmStore
6188
}
6289

90+
// loadTokens loads tokens from the persistence file
91+
func (s *TokenStore) loadTokens() {
92+
s.persistMutex.Lock()
93+
filePath := s.filePath
94+
s.persistMutex.Unlock()
95+
96+
if filePath == "" {
97+
return
98+
}
99+
100+
logger := logging.GetLogger()
101+
102+
data, err := os.ReadFile(filePath)
103+
if err != nil {
104+
if os.IsNotExist(err) {
105+
// File doesn't exist yet, that's fine for first run
106+
logger.Debug("FCM token persistence file does not exist yet", "path", filePath)
107+
return
108+
}
109+
logger.Error("failed to read FCM tokens from file", "error", err, "path", filePath)
110+
return
111+
}
112+
113+
var loadedStore struct {
114+
FCMTokens map[string]string `json:"fcm_tokens"`
115+
}
116+
if err := json.Unmarshal(data, &loadedStore); err != nil {
117+
logger.Error("failed to parse FCM tokens from file", "error", err, "path", filePath)
118+
return
119+
}
120+
121+
s.mu.Lock()
122+
if loadedStore.FCMTokens != nil {
123+
s.FCMTokens = loadedStore.FCMTokens
124+
}
125+
s.mu.Unlock()
126+
127+
logger.Info("loaded FCM tokens from persistence file", "count", len(loadedStore.FCMTokens), "path", filePath)
128+
}
129+
130+
// saveTokens saves tokens to the persistence file
131+
func (s *TokenStore) saveTokens() {
132+
s.persistMutex.Lock()
133+
filePath := s.filePath
134+
s.persistMutex.Unlock()
135+
136+
if filePath == "" {
137+
return
138+
}
139+
140+
logger := logging.GetLogger()
141+
142+
s.mu.RLock()
143+
data, err := json.MarshalIndent(struct {
144+
FCMTokens map[string]string `json:"fcm_tokens"`
145+
}{
146+
FCMTokens: s.FCMTokens,
147+
}, "", " ")
148+
s.mu.RUnlock()
149+
150+
if err != nil {
151+
logger.Error("failed to marshal FCM tokens", "error", err)
152+
return
153+
}
154+
155+
if err := os.WriteFile(filePath, data, 0o600); err != nil {
156+
logger.Error("failed to write FCM tokens to file", "error", err, "path", filePath)
157+
return
158+
}
159+
160+
logger.Debug("saved FCM tokens to persistence file", "path", filePath)
161+
}
162+
63163
// @Summary Store FCM Token
64164
// @Description Store a new FCM token
65165
// @Accept json
@@ -84,7 +184,10 @@ func storeFCMToken(c *gin.Context) {
84184
)
85185
return
86186
}
187+
store.mu.Lock()
87188
store.FCMTokens[req.FCMToken] = req.FCMToken
189+
store.mu.Unlock()
190+
store.saveTokens()
88191
c.Status(http.StatusCreated)
89192
}
90193

@@ -106,7 +209,9 @@ func readFCMToken(c *gin.Context) {
106209
)
107210
return
108211
}
212+
store.mu.RLock()
109213
storedToken, exists := store.FCMTokens[token]
214+
store.mu.RUnlock()
110215
if !exists {
111216
c.Status(http.StatusNotFound)
112217
return
@@ -132,20 +237,32 @@ func deleteFCMToken(c *gin.Context) {
132237
)
133238
return
134239
}
240+
store.mu.Lock()
135241
_, exists := store.FCMTokens[token]
136242
if exists {
137243
delete(store.FCMTokens, token)
244+
}
245+
store.mu.Unlock()
246+
if exists {
247+
store.saveTokens()
138248
c.Status(http.StatusNoContent)
139249
} else {
140250
c.Status(http.StatusNotFound)
141251
}
142252
}
143253

144-
// GetFcmTokens returns the current in-memory FCM tokens
254+
// GetFcmTokens returns a copy of the current in-memory FCM tokens
145255
func GetFcmTokens() map[string]string {
146256
store := getTokenStore()
147257
if store == nil {
148258
return make(map[string]string)
149259
}
150-
return store.FCMTokens
260+
store.mu.RLock()
261+
defer store.mu.RUnlock()
262+
// Return a copy to avoid race conditions
263+
tokens := make(map[string]string, len(store.FCMTokens))
264+
for k, v := range store.FCMTokens {
265+
tokens[k] = v
266+
}
267+
return tokens
151268
}

output/push/plugin.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,19 @@ func init() {
5252
}
5353

5454
func NewFromCmdlineOptions() plugin.Plugin {
55-
p := New(
55+
p, err := New(
5656
WithLogger(
5757
logging.GetLogger().With("plugin", "output.push"),
5858
),
5959
WithAccessTokenUrl(cmdlineOptions.accessTokenUrl),
6060
WithServiceAccountFilePath(cmdlineOptions.serviceAccountFilePath),
6161
)
62+
if err != nil {
63+
logging.GetLogger().Error(
64+
"failed to create push output plugin",
65+
"error", err,
66+
)
67+
return nil
68+
}
6269
return p
6370
}

0 commit comments

Comments
 (0)