diff --git a/cmd/ybfeed/main.go b/cmd/ybfeed/main.go index 6c60ae2..0f97375 100644 --- a/cmd/ybfeed/main.go +++ b/cmd/ybfeed/main.go @@ -1,8 +1,6 @@ package main import ( - "fmt" - "net/http" "os" "github.com/urfave/cli/v2" @@ -78,23 +76,15 @@ func run() { makeDataDirectory(dataDir) // Start HTTP Server - r := http.NewServeMux() - - api := handlers.ApiHandler{ - BasePath: dataDir, - Version: version, - MaxBodySize: maxBodySize * 1024 * 1024, - } - - r.HandleFunc("/api/", api.ApiHandleFunc) - r.HandleFunc("/", handlers.RootHandlerFunc) - - slog.Info("ybFeed starting", slog.String("version", version), slog.String("data_dir", dataDir), slog.Int("port", HTTP_PORT), slog.Int("max-upload-size", maxBodySize)) - err := http.ListenAndServe(fmt.Sprintf(":%d", HTTP_PORT), r) + api, err := handlers.NewApiHandler(dataDir) if err != nil { - slog.Error("Unable to start HTTP server", slog.String("error", err.Error())) - os.Exit(1) + return } + api.Version = version + api.MaxBodySize = maxBodySize * 1024 * 1024 + api.HttpPort = HTTP_PORT + + api.StartServer() } func makeDataDirectory(dir string) { diff --git a/go.mod b/go.mod index 33f952d..3898d3b 100644 --- a/go.mod +++ b/go.mod @@ -3,17 +3,19 @@ module github.com/ybizeul/ybfeed go 1.21 require ( + github.com/Appboy/webpush-go v0.0.0-20221006204155-f206645c3cb7 github.com/davecgh/go-spew v1.1.1 + github.com/go-chi/chi/v5 v5.0.10 github.com/google/uuid v1.3.0 - github.com/sirupsen/logrus v1.9.3 github.com/urfave/cli/v2 v2.25.7 golang.org/x/exp v0.0.0-20230811145659-89c5cff77bcb ) require ( github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect + github.com/go-chi/chi v1.5.4 // indirect + github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/stretchr/testify v1.8.4 // indirect github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect - golang.org/x/sys v0.1.0 // indirect + golang.org/x/crypto v0.0.0-20190131182504-b8fe1690c613 // indirect ) diff --git a/go.sum b/go.sum index 4c33405..63ea7dc 100644 --- a/go.sum +++ b/go.sum @@ -1,30 +1,24 @@ +github.com/Appboy/webpush-go v0.0.0-20221006204155-f206645c3cb7 h1:llWykCnBcqW1sbTI11bXzbFOkd/U4/Og64h/ifcwjPU= +github.com/Appboy/webpush-go v0.0.0-20221006204155-f206645c3cb7/go.mod h1:3IpCGyYxgZWbmm8zBOfp4C01dGq0AhGPTz3TT0Vv3k0= github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi v1.5.4 h1:QHdzF2szwjqVV4wmByUnTcsbIg7UGaQ0tPF2t5GcAIs= +github.com/go-chi/chi v1.5.4/go.mod h1:uaf8YgoFazUOkPBG7fxPftUylNumIev9awIWOENIuEg= +github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk= +github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs= github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= +golang.org/x/crypto v0.0.0-20190131182504-b8fe1690c613 h1:MQ/ZZiDsUapFFiMS+vzwXkCTeEKaum+Do5rINYJDmxc= +golang.org/x/crypto v0.0.0-20190131182504-b8fe1690c613/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/exp v0.0.0-20230811145659-89c5cff77bcb h1:mIKbk8weKhSeLH2GmUTrvx8CjkyJmnU1wFmg59CUjFA= golang.org/x/exp v0.0.0-20230811145659-89c5cff77bcb/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/feed/feedConfig.go b/internal/feed/feedConfig.go index af6641e..badc19e 100644 --- a/internal/feed/feedConfig.go +++ b/internal/feed/feedConfig.go @@ -7,14 +7,15 @@ import ( "path" "time" + "github.com/Appboy/webpush-go" "golang.org/x/exp/slog" ) type FeedConfig struct { - Secret string `json:"secret"` - PIN *PIN `json:"pin,omitempty"` - - feed *Feed + Secret string `json:"secret"` + PIN *PIN `json:"pin,omitempty"` + Subscriptions []webpush.Subscription + feed *Feed } type PIN struct { @@ -118,6 +119,20 @@ func (config *FeedConfig) SetPIN(s string) error { return nil } +func (config *FeedConfig) AddSubscription(s webpush.Subscription) error { + for _, t := range config.Subscriptions { + if s.Endpoint == t.Endpoint && s.Keys.Auth == t.Keys.Auth && s.Keys.P256dh == t.Keys.P256dh { + return nil + } + } + config.Subscriptions = append(config.Subscriptions, s) + err := config.Write() + if err != nil { + return err + } + return nil +} + func (p *PIN) IsValid(s string) error { if p.Expiration.Before(time.Now()) { code := 401 diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 0fce5dc..fd94752 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -9,18 +9,24 @@ import ( "net/url" "os" "path" - "strings" "time" "golang.org/x/exp/slog" + "github.com/Appboy/webpush-go" + "github.com/go-chi/chi/middleware" + "github.com/go-chi/chi/v5" "github.com/ybizeul/ybfeed/internal/feed" "github.com/ybizeul/ybfeed/internal/utils" "github.com/ybizeul/ybfeed/web/ui" ) -var handler = http.FileServer(http.FS(ui.GetUiFs())) +var webUiHandler = http.FileServer(http.FS(ui.GetUiFs())) +// RootHandlerFunc figures out how to handle incoming HTTP requests. +// If the requests points to an existing file in web UI (CSS, JS, etc) +// then it serves this file from webUiHandler, otherwise it returns +// index.html for proper react routing func RootHandlerFunc(w http.ResponseWriter, r *http.Request) { slog.Default().WithGroup("http").Debug("Root request", slog.Any("request", r)) p := r.URL.Path @@ -39,14 +45,13 @@ func RootHandlerFunc(w http.ResponseWriter, r *http.Request) { if err != nil { slog.Error("Unable to get web ui fs", slog.String("error", err.Error())) } - if len(matches) == 1 { - handler.ServeHTTP(w, r) + webUiHandler.ServeHTTP(w, r) return } // - // For everything else, it goes to index.html + // Everything else goes to index.html // content, err := fs.ReadFile(ui, "index.html") @@ -61,41 +66,105 @@ type ApiHandler struct { BasePath string Version string MaxBodySize int + Config APIConfig + HttpPort int +} + +type APIConfig struct { + NotificationSettings NotificationSettings `json:"notification,omitempty"` +} +type NotificationSettings struct { + VAPIDPublicKey string + VAPIDPrivateKey string } -func NewApiHandler(basePath string) *ApiHandler { +func NewApiHandler(basePath string) (*ApiHandler, error) { os.MkdirAll(basePath, 0700) - return &ApiHandler{ + + // Check configuration + var config = &APIConfig{} + d, err := os.ReadFile(path.Join(basePath, "config.json")) + if err != nil { + if !os.IsNotExist(err) { + return nil, err + } else { + privateKey, publicKey, err := webpush.GenerateVAPIDKeys() + if err != nil { + return nil, err + } + config.NotificationSettings = NotificationSettings{ + VAPIDPublicKey: publicKey, + VAPIDPrivateKey: privateKey, + } + } + } else { + err = json.Unmarshal(d, config) + if err != nil { + return nil, err + } + } + result := &ApiHandler{ BasePath: basePath, + Config: *config, } -} -func (api *ApiHandler) ApiHandleFunc(w http.ResponseWriter, r *http.Request) { - p := strings.TrimSuffix(r.URL.Path, "/") - split := strings.Split(p, "/") + result.WriteConfig() - w.Header().Add("ybFeed-Version", api.Version) + return result, nil +} +func (api *ApiHandler) WriteConfig() error { + b, err := json.Marshal(api.Config) + if err != nil { + return err + } - if len(split) == 4 { - if r.Method == "GET" { - api.feedHandlerFunc(w, r) - } else if r.Method == "POST" { - api.feedPostHandlerFunc(w, r) - } else if r.Method == "PATCH" { - api.feedPatchHandlerFunc(w, r) - } - } else if len(split) == 5 { - if r.Method == "GET" { - api.feedItemHandlerFunc(w, r) - } else if r.Method == "DELETE" { - api.feedItemDeleteHandlerFunc(w, r) - } - } else if len(split) == 2 { - w.WriteHeader(200) - return - } else { - utils.CloseWithCodeAndMessage(w, 400, "Malformed request") + err = os.WriteFile(path.Join(api.BasePath, "config.json"), b, 0600) + if err != nil { + return err } + return nil +} +func (api *ApiHandler) StartServer() { + r := api.GetServer() + http.ListenAndServe(fmt.Sprintf(":%d", api.HttpPort), r) + err := http.ListenAndServe(fmt.Sprintf(":%d", api.HttpPort), r) + if err != nil { + slog.Error("Unable to start HTTP server", + slog.String("error", err.Error())) + os.Exit(1) + } +} +func (api *ApiHandler) GetServer() *chi.Mux { + r := chi.NewRouter() + r.Use(middleware.Logger) + + r.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + w.Header().Add("ybFeed-Version", api.Version) + w.Header().Add("ybFeed-VAPIDPublicKey", api.Config.NotificationSettings.VAPIDPublicKey) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + }) + r.Route("/api/feed", func(r chi.Router) { + r.Get("/{feedName}", api.feedHandlerFunc) + r.Post("/{feedName}", api.feedPostHandlerFunc) + r.Patch("/{feedName}", api.feedPatchHandlerFunc) + + r.Get("/{feedName}/{itemName}", api.feedItemHandlerFunc) + r.Delete("/{feedName}/{itemName}", api.feedItemDeleteHandlerFunc) + + r.Post("/{feedName}/subscription", api.feedSubscriptionHandlerFunc) + }) + r.Get("/*", RootHandlerFunc) + + slog.Info("ybFeed starting", + slog.String("version", api.Version), + slog.String("data_dir", api.BasePath), + slog.Int("port", api.HttpPort), + slog.Int("max-upload-size", api.MaxBodySize)) + + return r } func (api *ApiHandler) feedHandlerFunc(w http.ResponseWriter, r *http.Request) { @@ -103,18 +172,18 @@ func (api *ApiHandler) feedHandlerFunc(w http.ResponseWriter, r *http.Request) { secret, fromURL := utils.GetSecret(r) - feedName, err := feed.FeedNameFromPath(r.URL.Path) + feedName, _ := url.QueryUnescape(chi.URLParam(r, "feedName")) - if err != nil { + if feedName == "" { utils.CloseWithCodeAndMessage(w, 500, "Unable to obtain feed name") } - p, err := feed.GetFeed(path.Join(api.BasePath, *feedName)) + p, err := feed.GetFeed(path.Join(api.BasePath, feedName)) if err != nil { yberr := err.(*feed.FeedError) if yberr.Code == 404 { - p, err = feed.NewFeed(api.BasePath, *feedName) + p, err = feed.NewFeed(api.BasePath, feedName) secret = p.Config.Secret if err != nil { yberr := err.(*feed.FeedError) @@ -127,7 +196,7 @@ func (api *ApiHandler) feedHandlerFunc(w http.ResponseWriter, r *http.Request) { } } - result, err := feed.GetPublicFeed(api.BasePath, *feedName, p.Config.Secret) + result, err := feed.GetPublicFeed(api.BasePath, feedName, p.Config.Secret) err = p.IsSecretValid(secret) @@ -140,7 +209,7 @@ func (api *ApiHandler) feedHandlerFunc(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, &http.Cookie{ Name: "Secret", Value: result.Secret, - Path: fmt.Sprintf("/api/feed/%s", *feedName), + Path: fmt.Sprintf("/api/feed/%s", feedName), Expires: time.Now().Add(time.Hour * 24 * 365 * 10), }) @@ -148,7 +217,7 @@ func (api *ApiHandler) feedHandlerFunc(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, &http.Cookie{ Name: "Secret", Value: result.Secret, - Path: fmt.Sprintf("/api/feed/%s", *feedName), + Path: fmt.Sprintf("/api/feed/%s", feedName), Expires: time.Now().Add(time.Hour * 24 * 365 * 10), }) } @@ -165,9 +234,11 @@ func (api *ApiHandler) feedPatchHandlerFunc(w http.ResponseWriter, r *http.Reque slog.Default().WithGroup("http").Debug("Feed API Set PIN request", slog.Any("request", r)) secret, _ := utils.GetSecret(r) - feedName, err := feed.FeedNameFromPath(r.URL.Path) - - f, err := feed.GetFeed(path.Join(api.BasePath, *feedName)) + feedName, _ := url.QueryUnescape(chi.URLParam(r, "feedName")) + if feedName == "" { + utils.CloseWithCodeAndMessage(w, 500, "Unable to obtain feed name") + } + f, err := feed.GetFeed(path.Join(api.BasePath, feedName)) err = f.IsSecretValid(secret) @@ -195,9 +266,12 @@ func (api *ApiHandler) feedItemHandlerFunc(w http.ResponseWriter, r *http.Reques secret, _ := utils.GetSecret(r) - feedName, err := feed.FeedNameFromPath(r.URL.Path) + feedName, _ := url.QueryUnescape(chi.URLParam(r, "feedName")) + if feedName == "" { + utils.CloseWithCodeAndMessage(w, 500, "Unable to obtain feed name") + } - f, err := feed.GetFeed(path.Join(api.BasePath, *feedName)) + f, err := feed.GetFeed(path.Join(api.BasePath, feedName)) if err != nil { yberr := err.(*feed.FeedError) @@ -213,14 +287,11 @@ func (api *ApiHandler) feedItemHandlerFunc(w http.ResponseWriter, r *http.Reques return } - fileNameElement := strings.Split(r.URL.Path, "/")[4] - feedItem, err := url.QueryUnescape(fileNameElement) + feedItem, _ := url.QueryUnescape(chi.URLParam(r, "itemName")) - if err != nil { - w.WriteHeader(500) - w.Write([]byte(fmt.Sprintf("Unable to parse file name '%s'", fileNameElement))) + if feedItem == "" { + utils.CloseWithCodeAndMessage(w, 500, "Unable to obtain feed item") } - content, err := f.GetItem(feedItem) if err != nil { @@ -236,9 +307,12 @@ func (api *ApiHandler) feedPostHandlerFunc(w http.ResponseWriter, r *http.Reques secret, _ := utils.GetSecret(r) - feedName, err := feed.FeedNameFromPath(r.URL.Path) + feedName, _ := url.QueryUnescape(chi.URLParam(r, "feedName")) + if feedName == "" { + utils.CloseWithCodeAndMessage(w, 500, "Unable to obtain feed name") + } - f, err := feed.GetFeed(path.Join(api.BasePath, *feedName)) + f, err := feed.GetFeed(path.Join(api.BasePath, feedName)) if err != nil { yberr := err.(*feed.FeedError) @@ -257,7 +331,6 @@ func (api *ApiHandler) feedPostHandlerFunc(w http.ResponseWriter, r *http.Reques contentType := r.Header.Get("Content-type") err = f.AddItem(contentType, http.MaxBytesReader(w, r.Body, int64(api.MaxBodySize))) - //err = f.AddItem(contentType, r.Body) if err != nil { yberr := err.(*feed.FeedError) @@ -265,6 +338,17 @@ func (api *ApiHandler) feedPostHandlerFunc(w http.ResponseWriter, r *http.Reques return } + // Send push notifications + for _, subscription := range f.Config.Subscriptions { + resp, _ := webpush.SendNotification([]byte(fmt.Sprintf("New item posted to feed %s", f.Name())), &subscription, &webpush.Options{ + Subscriber: "example@example.com", // Do not include "mailto:" + VAPIDPublicKey: api.Config.NotificationSettings.VAPIDPublicKey, + VAPIDPrivateKey: api.Config.NotificationSettings.VAPIDPrivateKey, + TTL: 30, + }) + defer resp.Body.Close() + } + w.Write([]byte("OK")) } @@ -273,9 +357,12 @@ func (api *ApiHandler) feedItemDeleteHandlerFunc(w http.ResponseWriter, r *http. secret, _ := utils.GetSecret(r) - feedName, err := feed.FeedNameFromPath(r.URL.Path) + feedName, _ := url.QueryUnescape(chi.URLParam(r, "feedName")) + if feedName == "" { + utils.CloseWithCodeAndMessage(w, 500, "Unable to obtain feed name") + } - f, err := feed.GetFeed(path.Join(api.BasePath, *feedName)) + f, err := feed.GetFeed(path.Join(api.BasePath, feedName)) if err != nil { yberr := err.(*feed.FeedError) @@ -291,16 +378,69 @@ func (api *ApiHandler) feedItemDeleteHandlerFunc(w http.ResponseWriter, r *http. return } - item, err := url.QueryUnescape(strings.Split(r.URL.Path, "/")[4]) + feedItem, _ := url.QueryUnescape(chi.URLParam(r, "itemName")) + if feedItem == "" { + utils.CloseWithCodeAndMessage(w, 500, "Unable to obtain feed item") + } + + err = f.RemoveItem(feedItem) if err != nil { - utils.CloseWithCodeAndMessage(w, 500, "Unable to unescape query string") + yberr := err.(*feed.FeedError) + utils.CloseWithCodeAndMessage(w, yberr.Code, yberr.Error()) return } - err = f.RemoveItem(item) + w.Write([]byte("Item Removed")) +} + +func (api *ApiHandler) feedSubscriptionHandlerFunc(w http.ResponseWriter, r *http.Request) { + + slog.Default().WithGroup("http").Debug("Feed subscription request", slog.Any("request", r)) + + secret, _ := utils.GetSecret(r) + + feedName, _ := url.QueryUnescape(chi.URLParam(r, "feedName")) + if feedName == "" { + utils.CloseWithCodeAndMessage(w, 500, "Unable to obtain feed name") + } + + f, err := feed.GetFeed(path.Join(api.BasePath, feedName)) + if err != nil { yberr := err.(*feed.FeedError) utils.CloseWithCodeAndMessage(w, yberr.Code, yberr.Error()) return } - w.Write([]byte("Item Removed")) + + err = f.IsSecretValid(secret) + + if err != nil { + yberr := err.(*feed.FeedError) + utils.CloseWithCodeAndMessage(w, yberr.Code, yberr.Error()) + return + } + + body, err := io.ReadAll(r.Body) + + defer r.Body.Close() + + if err != nil { + utils.CloseWithCodeAndMessage(w, 500, "Unable to read subscription") + return + } + + var s webpush.Subscription + + err = json.Unmarshal(body, &s) + + if err != nil { + utils.CloseWithCodeAndMessage(w, 500, "Unable to parse subscription") + return + } + + err = f.Config.AddSubscription(s) + + if err != nil { + utils.CloseWithCodeAndMessage(w, 500, "Unable to add subscription") + return + } } diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index df14444..a63348c 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -48,12 +48,16 @@ const ( AuthTypeFail ) -func (t APITestRequest) performRequest() *http.Response { +func (t APITestRequest) performRequest() (*http.Response, error) { const goodSecret = "b90e516e-b256-41ff-a84e-a9e8d5b6fe30" const badSecret = "foo" - api := NewApiHandler(path.Join(baseDir, dataDir)) + api, err := NewApiHandler(path.Join(baseDir, dataDir)) + if err != nil { + return nil, err + } api.MaxBodySize = 5 * 1024 * 1024 + r := api.GetServer() authQuery := "" switch t.queryAuthType { @@ -88,9 +92,10 @@ func (t APITestRequest) performRequest() *http.Response { } w := httptest.NewRecorder() - api.ApiHandleFunc(w, req) + r.ServeHTTP(w, req) + //api.ApiHandleFunc(w, req) - return w.Result() + return w.Result(), nil } func TestCreateFeed(t *testing.T) { @@ -100,7 +105,7 @@ func TestCreateFeed(t *testing.T) { os.RemoveAll(path.Join(baseDir, dataDir, feedName)) }) - res := APITestRequest{ + res, _ := APITestRequest{ method: http.MethodGet, feed: feedName, body: nil, @@ -124,7 +129,7 @@ func TestCreateFeed(t *testing.T) { } func TestGetFeedNoCredentials(t *testing.T) { - res := APITestRequest{ + res, _ := APITestRequest{ method: http.MethodGet, body: nil, }.performRequest() @@ -147,7 +152,7 @@ func TestGetFeedNoCredentials(t *testing.T) { } func TestGetFeedBadCookie(t *testing.T) { - res := APITestRequest{ + res, _ := APITestRequest{ method: http.MethodGet, body: nil, cookieAuthType: AuthTypeFail, @@ -160,7 +165,7 @@ func TestGetFeedBadCookie(t *testing.T) { func TestGetFeedCookieAuth(t *testing.T) { - res := APITestRequest{ + res, _ := APITestRequest{ method: http.MethodGet, feed: testFeedName, body: nil, @@ -173,7 +178,7 @@ func TestGetFeedCookieAuth(t *testing.T) { } func TestGetFeedBadQuerySecret(t *testing.T) { - res := APITestRequest{ + res, _ := APITestRequest{ method: http.MethodGet, body: nil, queryAuthType: AuthTypeFail, @@ -185,7 +190,7 @@ func TestGetFeedBadQuerySecret(t *testing.T) { } func TestGetFeedURLAuth(t *testing.T) { - res := APITestRequest{ + res, _ := APITestRequest{ method: http.MethodGet, body: nil, queryAuthType: AuthTypeAuth, @@ -199,7 +204,7 @@ func TestGetFeedURLAuth(t *testing.T) { func TestGetFeedItemNoCredentials(t *testing.T) { const item = "Pasted Image 1.png" - res := APITestRequest{ + res, _ := APITestRequest{ method: http.MethodGet, item: item, body: nil, @@ -213,7 +218,7 @@ func TestGetFeedItemNoCredentials(t *testing.T) { func TestGetFeedItem(t *testing.T) { const item = "Pasted Image 1.png" - res := APITestRequest{ + res, _ := APITestRequest{ method: http.MethodGet, item: item, body: nil, @@ -228,7 +233,7 @@ func TestGetFeedItem(t *testing.T) { func TestGetFeedItemNonExistentFeed(t *testing.T) { const item = "Pasted Image 1.png" - res := APITestRequest{ + res, _ := APITestRequest{ method: http.MethodGet, feed: "foo", item: item, @@ -240,7 +245,7 @@ func TestGetFeedItemNonExistentFeed(t *testing.T) { } func TestGetFeedItemNonExistent(t *testing.T) { - res := APITestRequest{ + res, _ := APITestRequest{ method: http.MethodGet, item: "foo", cookieAuthType: AuthTypeAuth, @@ -260,7 +265,7 @@ func TestSetPinNoCredentials(t *testing.T) { buf := bytes.NewBuffer([]byte(pin)) - res := APITestRequest{ + res, _ := APITestRequest{ method: http.MethodPatch, body: buf, }.performRequest() @@ -285,7 +290,7 @@ func TestSetPin(t *testing.T) { buf := bytes.NewBuffer([]byte(pin)) - res := APITestRequest{ + res, _ := APITestRequest{ method: http.MethodPatch, body: buf, cookieAuthType: AuthTypeAuth, @@ -321,7 +326,7 @@ func TestSetBadPin(t *testing.T) { buf := bytes.NewBuffer([]byte(pin)) - res := APITestRequest{ + res, _ := APITestRequest{ method: http.MethodPatch, body: buf, cookieAuthType: AuthTypeAuth, @@ -342,7 +347,7 @@ func TestAddAndRemoveContent(t *testing.T) { reader, err := os.Open(filePath) - res := APITestRequest{ + res, _ := APITestRequest{ method: http.MethodPost, body: reader, cookieAuthType: AuthTypeAuth, @@ -363,7 +368,7 @@ func TestAddAndRemoveContent(t *testing.T) { } // Delete request - res = APITestRequest{ + res, _ = APITestRequest{ method: http.MethodDelete, item: "Pasted Image 2.png", cookieAuthType: AuthTypeAuth, @@ -382,7 +387,7 @@ func TestAddAndRemoveContent(t *testing.T) { func TestAddContentTooBig(t *testing.T) { b := bytes.NewBuffer(make([]byte, 6*1024*1024)) - res := APITestRequest{ + res, _ := APITestRequest{ method: http.MethodPost, body: b, cookieAuthType: AuthTypeAuth, @@ -399,7 +404,7 @@ func TestAddContentTooBig(t *testing.T) { } func TestAddContentWrongContentType(t *testing.T) { - res := APITestRequest{ + res, _ := APITestRequest{ method: http.MethodPost, cookieAuthType: AuthTypeAuth, contentType: "foo/bar", @@ -415,7 +420,7 @@ func TestAddContentWrongContentType(t *testing.T) { } func TestAddContentNonExistentFeed(t *testing.T) { - res := APITestRequest{ + res, _ := APITestRequest{ method: http.MethodPost, feed: "foo", contentType: "foo/bar", @@ -430,7 +435,7 @@ func TestAddContentNonExistentFeed(t *testing.T) { } } func TestRemoveNonExistentContent(t *testing.T) { - res := APITestRequest{ + res, _ := APITestRequest{ method: http.MethodDelete, cookieAuthType: AuthTypeAuth, item: "foo", @@ -446,7 +451,7 @@ func TestRemoveNonExistentContent(t *testing.T) { } func TestRemoveItemNonExistentFeed(t *testing.T) { - res := APITestRequest{ + res, _ := APITestRequest{ method: http.MethodDelete, feed: "foo", item: "foo", diff --git a/test/data/config.json b/test/data/config.json new file mode 100644 index 0000000..8062e97 --- /dev/null +++ b/test/data/config.json @@ -0,0 +1 @@ +{"notification":{"VAPIDPublicKey":"BGJXIXiReax3xUeu-4bAUa4EWKmvnpp6y5JVXrRBLcpsDKNWrh8eXNEYQMMlyDzrFvR1ZhlD3GLIx3Lc_lIiuVQ","VAPIDPrivateKey":"wiTTLnH8z29xDQDmvw647lg2cMHt76a8ycYyQ6uK20A"}} \ No newline at end of file diff --git a/test/data/test/config.json b/test/data/test/config.json index 78ee859..e05eed1 100644 --- a/test/data/test/config.json +++ b/test/data/test/config.json @@ -1 +1 @@ -{"secret":"b90e516e-b256-41ff-a84e-a9e8d5b6fe30"} \ No newline at end of file +{"secret":"b90e516e-b256-41ff-a84e-a9e8d5b6fe30","Subscriptions":null} \ No newline at end of file diff --git a/web/ui/public/service-worker.js b/web/ui/public/service-worker.js new file mode 100644 index 0000000..6700446 --- /dev/null +++ b/web/ui/public/service-worker.js @@ -0,0 +1,12 @@ +/*eslint no-restricted-globals: ["error"]*/ +self.addEventListener('push', event => { + console.log('[Service Worker] Push Received.'); + console.log(`[Service Worker] Push had this data: "${event.data.text()}"`); + + const title = 'YBFeed Notification'; + const options = { + body: event.data.text(), + }; + + event.waitUntil(self.registration.showNotification(title, options)); +}); diff --git a/web/ui/src/App.tsx b/web/ui/src/App.tsx index 3d29e5d..3d6ff73 100644 --- a/web/ui/src/App.tsx +++ b/web/ui/src/App.tsx @@ -46,11 +46,11 @@ const App: React.FC = () => { }, [windowQuery.matches]); useEffect(() => { - fetch("/api") + fetch("/") .then(r => { let v = r.headers.get("Ybfeed-Version") if (v !== null) { - setVersion(v) + setVersion(v) } }) }) diff --git a/web/ui/src/YBFeed/YBFeedComponent.tsx b/web/ui/src/YBFeed/YBFeedComponent.tsx index daf2f38..b205414 100644 --- a/web/ui/src/YBFeed/YBFeedComponent.tsx +++ b/web/ui/src/YBFeed/YBFeedComponent.tsx @@ -7,7 +7,7 @@ import { Button, Form, Input, Dropdown, Space, Modal, Row, Col } from 'antd'; import { message } from 'antd'; import type { MenuProps } from 'antd'; -import { FeedConnector, YBBreadCrumb, YBPasteCard, FeedItems, FeedItem } from '.' +import { FeedConnector, YBBreadCrumb, YBPasteCard, FeedItems, FeedItem, NotificationToggle } from '.' import { LinkOutlined, @@ -27,6 +27,8 @@ export function FeedComponent() { const [authenticated,setAuthenticated] = useState(undefined) const [updateGeneration,setUpdateGeneration] = useState(0) const [fatal, setFatal] = useState(null) + const [vapid, setVapid] = useState(undefined) + const connection = new FeedConnector() // // Creating links to feed @@ -111,7 +113,10 @@ export function FeedComponent() { useEffect( () => { + // Update feed every 2s const interval = window.setInterval(update,2000) + + // Authenticate feed if a secret is found in URL const secret = searchParams.get("secret") if (secret) { console.log("test") @@ -130,6 +135,17 @@ export function FeedComponent() { else { update() } + + // Set web notification public key + fetch("/api/feed/"+encodeURIComponent(feedParam),{cache: "no-cache"}) + .then(r => { + if (r.status === 200) { + var v = r.headers.get("Ybfeed-Vapidpublickey") + if (v) { + setVapid(v) + } + } + }) return () => { window.clearInterval(interval) } @@ -185,21 +201,26 @@ export function FeedComponent() { message.error(e.message) }) } - + return ( <> {goTo? :""} {authenticated===true? - - - + + {vapid? + + :""} + + + + :""} diff --git a/web/ui/src/YBFeed/YBNotificationToggle.tsx b/web/ui/src/YBFeed/YBNotificationToggle.tsx new file mode 100644 index 0000000..b744188 --- /dev/null +++ b/web/ui/src/YBFeed/YBNotificationToggle.tsx @@ -0,0 +1,122 @@ +import { useState, useEffect } from 'react' + +import { Button, message } from 'antd' + +import { + BellOutlined + } from '@ant-design/icons'; + +import { FeedConnector } from '.' + +interface NotificationToggleProps { + vapid: string + feedName: string +} + +export function NotificationToggle(props:NotificationToggleProps) { + const {vapid, feedName} = props + const [notificationsOn,setNotificationsOn] = useState(false) + const [loading,setLoading] = useState(false) + async function subscribe(): Promise { + return new Promise((resolve, reject) => { + if (!vapid) { + reject("VAPID not declared") + } + const connection = new FeedConnector() + + navigator.serviceWorker.ready + .then(function(registration) { + return registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapid), + }); + }) + .then(function(subscription) { + if (subscription) { + connection.AddSubscription(feedName,JSON.stringify(subscription)) + .then((r) => { + resolve(true) + }) + } + else { + reject("Unable to subscribe (empty subscription)") + } + }) + .catch(err => console.error(err)); + }) + } + + function urlBase64ToUint8Array(base64String: string) { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding) + .replace(/-/g, '+') + .replace(/_/g, '/'); + const rawData = window.atob(base64); + return Uint8Array.from([...rawData].map(char => char.charCodeAt(0))); + } + + const toggleNotifications = (e: any) => { + if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('service-worker.js'); + navigator.serviceWorker.ready + .then(function(registration) { + return registration.pushManager.getSubscription(); + }) + .then(function(subscription) { + if (subscription) { + if (notificationsOn) { + subscription.unsubscribe() + .then((b) => { + if (b) { + setNotificationsOn(false) + } + }) + .catch((e) => { + console.log(e) + message.error("Error") + }) + } + } + else { + setLoading(true) + subscribe() + .then((b) => { + setLoading(false) + if (b) { + setNotificationsOn(true) + } + }) + .catch(e => { + setLoading(false) + console.log(e) + message.error("Error") + }) + } + }); + } + } + + useEffect(() => { + if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('service-worker.js'); + navigator.serviceWorker.ready + .then(function(registration) { + return registration.pushManager.getSubscription(); + }) + .then(function(subscription) { + if (subscription) { + setNotificationsOn(true) + } + }) + } + }) + + return( +