diff --git a/api/api.go b/api/api.go index 8ae8a43..3ba2149 100644 --- a/api/api.go +++ b/api/api.go @@ -49,7 +49,7 @@ func WithPort(port string) APIOption { var apiInstance *APIv1 var once sync.Once -func NewAPI(debug bool, options ...APIOption) *APIv1 { +func New(debug bool, options ...APIOption) *APIv1 { once.Do(func() { apiInstance = &APIv1{ engine: ConfigureRouter(debug), diff --git a/api/api_test.go b/api/api_test.go index 9de5083..27b1d15 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -12,15 +12,15 @@ import ( func TestRouteRegistration(t *testing.T) { // Initialize the API and set it to debug mode for testing - apiInstance := api.NewAPI(true) + apiInstance := api.New(true) // Check if Fcm implements APIRouteRegistrar and register its routes // TODO: update this with actual plugin - fcmPlugin := &push.Fcm{} - if registrar, ok := interface{}(fcmPlugin).(api.APIRouteRegistrar); ok { + pushPlugin := &push.PushOutput{} + if registrar, ok := interface{}(pushPlugin).(api.APIRouteRegistrar); ok { registrar.RegisterRoutes() } else { - t.Fatal("push.Fcm does NOT implement APIRouteRegistrar") + t.Fatal("pushPlugin does NOT implement APIRouteRegistrar") } // Create a test request to one of the registered routes diff --git a/cmd/snek/main.go b/cmd/snek/main.go index e3a11ad..27f8e43 100644 --- a/cmd/snek/main.go +++ b/cmd/snek/main.go @@ -105,7 +105,7 @@ func main() { } // Create API instance with debug disabled - apiInstance := api.NewAPI(false, + apiInstance := api.New(true, api.WithGroup("/v1"), api.WithPort("8080")) @@ -130,6 +130,10 @@ func main() { if output == nil { logger.Fatalf("unknown output: %s", cfg.Output) } + // Check if output plugin implements APIRouteRegistrar + if registrar, ok := interface{}(output).(api.APIRouteRegistrar); ok { + registrar.RegisterRoutes() + } pipe.AddOutput(output) // Start API after plugins are configured diff --git a/fcm/message.go b/fcm/message.go new file mode 100644 index 0000000..eaddfd0 --- /dev/null +++ b/fcm/message.go @@ -0,0 +1,99 @@ +package fcm + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + + "github.com/blinklabs-io/snek/internal/logging" +) + +type Message struct { + MessageContent `json:"message"` +} + +type MessageContent struct { + Token string `json:"token"` + Notification *NotificationContent `json:"notification,omitempty"` + Data map[string]interface{} `json:"data,omitempty"` +} + +type NotificationContent struct { + Title string `json:"title"` + Body string `json:"body"` +} + +type MessageOption func(*MessageContent) + +func WithData(data map[string]interface{}) MessageOption { + return func(m *MessageContent) { + m.Data = data + } +} + +func WithNotification(title string, body string) MessageOption { + return func(m *MessageContent) { + m.Notification = &NotificationContent{ + Title: title, + Body: body, + } + } +} + +func NewMessage(token string, opts ...MessageOption) *Message { + if token == "" { + logging.GetLogger().Fatalf("Token is mandatory for FCM message") + } + + msg := &Message{ + MessageContent: MessageContent{ + Token: token, + }, + } + for _, opt := range opts { + opt(&msg.MessageContent) + } + return msg +} + +func Send(accessToken string, projectId string, msg *Message) error { + + fcmEndpoint := fmt.Sprintf("https://fcm.googleapis.com/v1/projects/%s/messages:send", projectId) + + // Convert the message to JSON + payload, err := json.Marshal(msg) + if err != nil { + return err + } + + fmt.Println(string(payload)) + + // Create a new HTTP request + req, err := http.NewRequest("POST", fcmEndpoint, bytes.NewBuffer(payload)) + if err != nil { + return err + } + + // Set headers + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Content-Type", "application/json") + + // Execute the request + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + // Check for errors in the response + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return errors.New(string(body)) + } + + return nil +} diff --git a/go.mod b/go.mod index d540655..1df63cd 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/kelseyhightower/envconfig v1.4.0 github.com/stretchr/testify v1.8.4 go.uber.org/zap v1.26.0 + golang.org/x/oauth2 v0.11.0 gopkg.in/yaml.v2 v2.4.0 ) @@ -16,6 +17,8 @@ require ( // replace github.com/blinklabs-io/gouroboros v0.52.0 => ../gouroboros require ( + cloud.google.com/go/compute v1.20.1 // indirect + cloud.google.com/go/compute/metadata v0.2.3 // indirect github.com/bytedance/sonic v1.10.1 // indirect github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect github.com/chenzhuoyu/iasm v0.9.0 // indirect @@ -29,6 +32,7 @@ require ( github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/golang/protobuf v1.5.3 // indirect github.com/jinzhu/copier v0.4.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.5 // indirect @@ -50,6 +54,7 @@ require ( golang.org/x/net v0.17.0 // indirect golang.org/x/sys v0.13.0 // indirect golang.org/x/text v0.13.0 // indirect + google.golang.org/appengine v1.6.7 // indirect google.golang.org/protobuf v1.31.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 42649c3..0138bbd 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +cloud.google.com/go/compute v1.20.1 h1:6aKEtlUiwEpJzM001l0yFkpXmUVXaN8W+fbkb2AZNbg= +cloud.google.com/go/compute v1.20.1/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM= +cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= +cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= github.com/blinklabs-io/gouroboros v0.54.0 h1:ZRp+L7Xb2wRn3N02a7EQ8jpBfqL6FKjnpauJdCiHEqE= github.com/blinklabs-io/gouroboros v0.54.0/go.mod h1:ID2Lq1XtYrBvmk/y+yQiX45sZiV8n+urOmT0s46d2+U= github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= @@ -37,9 +41,12 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8= github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= @@ -103,19 +110,29 @@ go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.5.0 h1:jpGode6huXQxcskEIpOCvrU+tzo81b6+oFLUYXWtH/Y= golang.org/x/arch v0.5.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/oauth2 v0.11.0 h1:vPL4xzxBM4niKCW6g9whtaWVXTJf1U5e4aZxxFx/gbU= +golang.org/x/oauth2 v0.11.0/go.mod h1:LdF7O/8bLR/qWK9DrpXmbHLTouvRHK0SgJl0GmDBchk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/output/push/api_routes.go b/output/push/api_routes.go new file mode 100644 index 0000000..938f443 --- /dev/null +++ b/output/push/api_routes.go @@ -0,0 +1,32 @@ +// Copyright 2023 Blink Labs, LLC. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package push + +import ( + "github.com/blinklabs-io/snek/api" +) + +func (p *PushOutput) RegisterRoutes() { + apiInstance := api.GetInstance() + + apiInstance.AddRoute("POST", "/fcm", storeFCMToken) + apiInstance.AddRoute("POST", "/fcm/", storeFCMToken) + + apiInstance.AddRoute("GET", "/fcm/:token", readFCMToken) + apiInstance.AddRoute("GET", "/fcm/:token/", readFCMToken) + + apiInstance.AddRoute("DELETE", "/fcm/:token", deleteFCMToken) + apiInstance.AddRoute("DELETE", "/fcm/:token/", deleteFCMToken) +} diff --git a/output/push/api_routes_test.go b/output/push/api_routes_test.go new file mode 100644 index 0000000..6f68962 --- /dev/null +++ b/output/push/api_routes_test.go @@ -0,0 +1,130 @@ +package push_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/blinklabs-io/snek/api" + "github.com/blinklabs-io/snek/output/push" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +func setupRouter() *gin.Engine { + apiInstance := api.New(false) + p := &push.PushOutput{} + p.RegisterRoutes() // This will internally get the API instance and register the routes for push + return apiInstance.Engine() +} + +func TestStoreFCMToken(t *testing.T) { + router := setupRouter() + + t.Run("Valid JSON input", func(t *testing.T) { + jsonStr := `{"FCMToken": "abcd1234"}` + req, _ := http.NewRequest("POST", "/fcm", strings.NewReader(jsonStr)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusCreated, w.Code) + + tokens := push.GetFcmTokens() + assert.Contains(t, tokens, "abcd1234") + }) + + t.Run("Store 2 tokens JSON input", func(t *testing.T) { + jsonStr := `{"FCMToken": "abcd1234"}` + req, _ := http.NewRequest("POST", "/fcm", strings.NewReader(jsonStr)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusCreated, w.Code) + + tokens := push.GetFcmTokens() + assert.Contains(t, tokens, "abcd1234") + + jsonStr = `{"FCMToken": "abcd0000"}` + req, _ = http.NewRequest("POST", "/fcm", strings.NewReader(jsonStr)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusCreated, w.Code) + + tokens = push.GetFcmTokens() + assert.Contains(t, tokens, "abcd0000") + }) + + t.Run("Invalid JSON input", func(t *testing.T) { + jsonStr := `{"invalid_field": "abcd1234"}` + req, _ := http.NewRequest("POST", "/fcm", strings.NewReader(jsonStr)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) +} + +func TestReadFCMToken(t *testing.T) { + router := setupRouter() + + // Prepopulate the FCMTokens map for the read test + push.GetFcmTokens()["abcd1234"] = "abcd1234" + + t.Run("Token exists", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/fcm/abcd1234", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "abcd1234") + }) + + t.Run("Token does not exist", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/fcm/nonexistenttoken", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) + }) +} + +func TestDeleteFCMToken(t *testing.T) { + router := setupRouter() + + // Prepopulate the FCMTokens map for the delete test + push.GetFcmTokens()["abcd1234"] = "abcd1234" + + t.Run("Token exists and is deleted", func(t *testing.T) { + req, _ := http.NewRequest("DELETE", "/fcm/abcd1234", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNoContent, w.Code) + + tokens := push.GetFcmTokens() + _, exists := tokens["abcd1234"] + assert.False(t, exists, "Token should be deleted") + }) + + t.Run("Token does not exist", func(t *testing.T) { + req, _ := http.NewRequest("DELETE", "/fcm/nonexistenttoken", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) + }) + + t.Run("Deleting already deleted token", func(t *testing.T) { + req, _ := http.NewRequest("DELETE", "/fcm/abcd1234", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) + }) +} diff --git a/output/push/fcm.go b/output/push/fcm.go deleted file mode 100644 index 5cc5f61..0000000 --- a/output/push/fcm.go +++ /dev/null @@ -1,69 +0,0 @@ -package push - -import ( - "net/http" - - "github.com/blinklabs-io/snek/api" - "github.com/gin-gonic/gin" -) - -// TODO implement FCM storage -var fcmTokens = make(map[string]string) - -var tokenRequest struct { - FCMToken string `json:"fcmToken" binding:"required"` -} - -type Fcm struct { -} - -func (f *Fcm) RegisterRoutes() { - apiInstance := api.GetInstance() - - apiInstance.AddRoute("POST", "/fcm", storeFCMToken) - apiInstance.AddRoute("POST", "/fcm/", storeFCMToken) - - apiInstance.AddRoute("GET", "/fcm/:token", readFCMToken) - apiInstance.AddRoute("GET", "/fcm/:token/", readFCMToken) - - apiInstance.AddRoute("DELETE", "/fcm/:token", deleteFCMToken) - apiInstance.AddRoute("DELETE", "/fcm/:token/", deleteFCMToken) -} - -// TODO: update this with actual storage and implementation -func storeFCMToken(c *gin.Context) { - // Handle the creation of an FCM token - if err := c.ShouldBindJSON(&tokenRequest); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Use the FCM token as the map key - fcmTokens[tokenRequest.FCMToken] = tokenRequest.FCMToken - c.Status(http.StatusCreated) -} - -// TODO: update this with actual storage and implementation -func readFCMToken(c *gin.Context) { - token := c.Param("token") - storedToken, exists := fcmTokens[token] - if !exists { - c.Status(http.StatusNotFound) - return - } - c.JSON(http.StatusOK, gin.H{"fcmToken": storedToken}) -} - -// TODO: update this with actual storage and implementation -func deleteFCMToken(c *gin.Context) { - token := c.Param("token") - // Check if the token exists - _, exists := fcmTokens[token] - if !exists { - c.Status(http.StatusNotFound) - return - } - // Delete the FCM token - delete(fcmTokens, token) - c.Status(http.StatusNoContent) -} diff --git a/output/push/fcm_repository.go b/output/push/fcm_repository.go new file mode 100644 index 0000000..c79c6a3 --- /dev/null +++ b/output/push/fcm_repository.go @@ -0,0 +1,89 @@ +// Copyright 2023 Blink Labs, LLC. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package push + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +type TokenStore struct { + FCMTokens map[string]string +} + +// TokenRequest represents a request containing an FCM token. +type TokenRequest struct { + FCMToken string `json:"fcmToken" binding:"required"` +} + +// TODO add support for persistence +var fcmStore *TokenStore + +func init() { + fcmStore = newTokenStore() +} + +func newTokenStore() *TokenStore { + return &TokenStore{ + FCMTokens: make(map[string]string), + } +} + +func getTokenStore() *TokenStore { + return fcmStore +} + +func storeFCMToken(c *gin.Context) { + var req TokenRequest + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + store := getTokenStore() + store.FCMTokens[req.FCMToken] = req.FCMToken + c.Status(http.StatusCreated) +} + +func readFCMToken(c *gin.Context) { + token := c.Param("token") + store := getTokenStore() + storedToken, exists := store.FCMTokens[token] + if !exists { + c.Status(http.StatusNotFound) + return + } + c.JSON(http.StatusOK, gin.H{"fcmToken": storedToken}) +} + +func deleteFCMToken(c *gin.Context) { + token := c.Param("token") + store := getTokenStore() + _, exists := store.FCMTokens[token] + if exists { + delete(store.FCMTokens, token) + c.Status(http.StatusNoContent) + } else { + c.Status(http.StatusNotFound) + } +} + +// GetFcmTokens returns the current in-memory FCM tokens +func GetFcmTokens() map[string]string { + store := getTokenStore() + return store.FCMTokens +} diff --git a/output/push/options.go b/output/push/options.go new file mode 100644 index 0000000..dee0a7d --- /dev/null +++ b/output/push/options.go @@ -0,0 +1,29 @@ +// Copyright 2023 Blink Labs, LLC. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package push + +type PushOptionFunc func(*PushOutput) + +func WithServiceAccountFilePath(serviceAccountFilePath string) PushOptionFunc { + return func(o *PushOutput) { + o.serviceAccountFilePath = serviceAccountFilePath + } +} + +func WithAccessTokenUrl(url string) PushOptionFunc { + return func(o *PushOutput) { + o.accessTokenUrl = url + } +} diff --git a/output/push/plugin.go b/output/push/plugin.go new file mode 100644 index 0000000..dc5e2b3 --- /dev/null +++ b/output/push/plugin.go @@ -0,0 +1,59 @@ +// Copyright 2023 Blink Labs, LLC. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package push + +import ( + "github.com/blinklabs-io/snek/plugin" +) + +var cmdlineOptions struct { + serviceAccountFilePath string + accessTokenUrl string +} + +func init() { + plugin.Register( + plugin.PluginEntry{ + Type: plugin.PluginTypeOutput, + Name: "push", + Description: "Send push notifications for events", + NewFromOptionsFunc: NewFromCmdlineOptions, + Options: []plugin.PluginOption{ // Define any options if needed + { + Name: "serviceAccountFilePath", + Type: plugin.PluginOptionTypeString, + Description: "specifies the path to the service account file", + DefaultValue: "", + Dest: &(cmdlineOptions.serviceAccountFilePath), + }, + { + Name: "accessTokenUrl", + Type: plugin.PluginOptionTypeString, + Description: "specifies the url to get access token from", + DefaultValue: "https://www.googleapis.com/auth/firebase.messaging", + Dest: &(cmdlineOptions.accessTokenUrl), + }, + }, + }, + ) +} + +func NewFromCmdlineOptions() plugin.Plugin { + p := New( + WithAccessTokenUrl(cmdlineOptions.accessTokenUrl), + WithServiceAccountFilePath(cmdlineOptions.serviceAccountFilePath), + ) + return p +} diff --git a/output/push/push.go b/output/push/push.go new file mode 100644 index 0000000..94a5104 --- /dev/null +++ b/output/push/push.go @@ -0,0 +1,241 @@ +// Copyright 2023 Blink Labs, LLC. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package push + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/blinklabs-io/snek/event" + "github.com/blinklabs-io/snek/fcm" + "github.com/blinklabs-io/snek/input/chainsync" + "github.com/blinklabs-io/snek/internal/logging" + "golang.org/x/oauth2/google" +) + +type PushOutput struct { + errorChan chan error + eventChan chan event.Event + accessToken string + accessTokenUrl string + projectID string + serviceAccountFilePath string + fcmTokens []string +} + +type Notification struct { + Tokens []string `json:"tokens"` + Platform int `json:"platform"` + Message string `json:"message"` +} + +type PushPayload struct { + Notifications []Notification `json:"notifications"` +} + +func New(options ...PushOptionFunc) *PushOutput { + p := &PushOutput{ + errorChan: make(chan error), + eventChan: make(chan event.Event, 10), + } + for _, option := range options { + option(p) + } + + if err := p.GetProjectId(); err != nil { + logging.GetLogger().Fatalf("Failed to get project ID: %v", err) + } + return p +} + +func (p *PushOutput) Start() error { + logger := logging.GetLogger() + logger.Infof("starting push notification server") + go func() { + for { + evt, ok := <-p.eventChan + // Channel has been closed, which means we're shutting down + if !ok { + return + } + // Get access token per each event + if err := p.GetAccessToken(); err != nil { + return + } + + switch evt.Type { + case "chainsync.block": + payload := evt.Payload + if payload == nil { + panic(fmt.Errorf("ERROR: %v", payload)) + } + + be := payload.(chainsync.BlockEvent) + fmt.Println("Snek") + fmt.Printf("New Block!\nBlockNumber: %d, SlotNumber: %d\nHash: %s", + be.BlockNumber, + be.SlotNumber, + be.BlockHash, + ) + + case "chainsync.rollback": + payload := evt.Payload + if payload == nil { + panic(fmt.Errorf("ERROR: %v", payload)) + } + + re := payload.(chainsync.RollbackEvent) + fmt.Println("Snek") + fmt.Printf("Rollback!\nSlotNumber: %d\nBlockHash: %s", + re.SlotNumber, + re.BlockHash, + ) + case "chainsync.transaction": + payload := evt.Payload + if payload == nil { + panic(fmt.Errorf("ERROR: %v", payload)) + } + + te := payload.(chainsync.TransactionEvent) + + // Create notification message + title := "Snek" + body := fmt.Sprintf("New Transaction!\nBlockNumber: %d, SlotNumber: %d\nInputs: %d, Outputs: %d\nHash: %s", + te.BlockNumber, + te.SlotNumber, + len(te.Inputs), + len(te.Outputs), + te.TransactionHash, + ) + + // Send notification + p.processFcmNotifications(title, body) + + default: + fmt.Println("Snek") + fmt.Printf("New Event!\nEvent: %v", evt) + } + } + }() + return nil +} + +// AddNewFcmTokens adds only the new FCM tokens to the fcmTokens slice +func (p *PushOutput) AddNewFcmTokens() { + tokenMap := GetFcmTokens() + existingTokens := make(map[string]bool, len(p.fcmTokens)) + + for _, token := range p.fcmTokens { + existingTokens[token] = true + } + + for _, newToken := range tokenMap { + if _, exists := existingTokens[newToken]; !exists { + p.fcmTokens = append(p.fcmTokens, newToken) + } + } +} + +func (p *PushOutput) processFcmNotifications(title, body string) { + // Fetch new FCM tokens and add to p.fcmTokens + p.AddNewFcmTokens() + + // If no FCM tokens exist, log and exit + if len(p.fcmTokens) == 0 { + logging.GetLogger().Warnln("No FCM tokens found. Skipping notification.") + return + } + + // Send notification to each FCM token + for _, fcmToken := range p.fcmTokens { + msg := fcm.NewMessage( + fcmToken, + fcm.WithNotification(title, body), + ) + + if err := fcm.Send(p.accessToken, p.projectID, msg); err != nil { + logging.GetLogger().Errorf("Failed to send message to token %s: %v", fcmToken, err) + continue + } + logging.GetLogger().Infof("Message sent successfully to token %s!", fcmToken) + } +} + +func (p *PushOutput) GetAccessToken() error { + data, err := os.ReadFile(p.serviceAccountFilePath) + if err != nil { + logging.GetLogger().Fatalf("Failed to read the credential file: %v", err) + return err + } + + conf, err := google.JWTConfigFromJSON(data, p.accessTokenUrl) + if err != nil { + logging.GetLogger().Fatalf("Failed to parse the credential file: %v", err) + return err + } + + token, err := conf.TokenSource(context.Background()).Token() + if err != nil { + logging.GetLogger().Fatalf("Failed to get token: %v", err) + return err + } + + fmt.Println(token.AccessToken) + p.accessToken = token.AccessToken + return nil +} + +// Get project ID from file +func (p *PushOutput) GetProjectId() error { + data, err := os.ReadFile(p.serviceAccountFilePath) + if err != nil { + logging.GetLogger().Fatalf("Failed to read the credential file: %v", err) + return err + } + + // Get project ID from file + var v map[string]any + if err := json.Unmarshal(data, &v); err != nil { + logging.GetLogger().Fatalf("Failed to parse the credential file: %v", err) + return err + } + p.projectID = v["project_id"].(string) + + return nil +} + +// Stop the embedded output +func (p *PushOutput) Stop() error { + close(p.eventChan) + close(p.errorChan) + return nil +} + +// ErrorChan returns the input error channel +func (p *PushOutput) ErrorChan() chan error { + return p.errorChan +} + +// InputChan returns the input event channel +func (p *PushOutput) InputChan() chan<- event.Event { + return p.eventChan +} + +// OutputChan always returns nil +func (p *PushOutput) OutputChan() <-chan event.Event { + return nil +}