diff --git a/docs/content/doc/advanced/config-cheat-sheet.en-us.md b/docs/content/doc/advanced/config-cheat-sheet.en-us.md index 2306dd2281e61..9d25aff559c72 100644 --- a/docs/content/doc/advanced/config-cheat-sheet.en-us.md +++ b/docs/content/doc/advanced/config-cheat-sheet.en-us.md @@ -131,7 +131,7 @@ Values containing `#` or `;` must be quoted using `` ` `` or `"""`. - `REACTIONS`: All available reactions. Allow users react with different emoji's. - `DEFAULT_SHOW_FULL_NAME`: **false**: Whether the full name of the users should be shown where possible. If the full name isn't set, the username will be used. - `SEARCH_REPO_DESCRIPTION`: **true**: Whether to search within description at repository search on explore page. -- `USE_SERVICE_WORKER`: **true**: Whether to enable a Service Worker to cache frontend assets. +- `USE_SERVICE_WORKER`: **true**: Whether to enable a Service Worker to cache frontend assets and enable push notifications on supported browsers. ### UI - Admin (`ui.admin`) @@ -289,6 +289,8 @@ set name for unique queues. Individual queues will default to - `INSTALL_LOCK`: **false**: Disallow access to the install page. - `SECRET_KEY`: **\**: Global secret key. This should be changed. +- `WEB_PUSH_PUBLIC_KEY`: **\**: VAPID key pair used for Web Push notifications +- `WEB_PUSH_PRIVATE_KEY`: **\**: VAPID key pair used for Web Push notifications - `LOGIN_REMEMBER_DAYS`: **7**: Cookie lifetime, in days. - `COOKIE_USERNAME`: **gitea\_awesome**: Name of the cookie used to store the current username. - `COOKIE_REMEMBER_NAME`: **gitea\_incredible**: Name of cookie used to store authentication diff --git a/go.mod b/go.mod index 0930b0d168a9b..436082180ed55 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/BurntSushi/toml v0.3.1 github.com/PuerkitoBio/goquery v1.5.0 github.com/RoaringBitmap/roaring v0.4.21 // indirect + github.com/SherClockHolmes/webpush-go v1.1.0 github.com/bgentry/speakeasy v0.1.0 // indirect github.com/blevesearch/bleve v0.8.1 github.com/blevesearch/blevex v0.0.0-20180227211930-4b158bb555a3 // indirect diff --git a/go.sum b/go.sum index 5944cbb5e7725..81154824580d4 100644 --- a/go.sum +++ b/go.sum @@ -56,6 +56,8 @@ github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/RoaringBitmap/roaring v0.4.21 h1:WJ/zIlNX4wQZ9x8Ey33O1UaD9TCTakYsdLFSBcTwH+8= github.com/RoaringBitmap/roaring v0.4.21/go.mod h1:D0gp8kJQgE1A4LQ5wFLggQEyvDi06Mq5mKs52e1TwOo= +github.com/SherClockHolmes/webpush-go v1.1.0 h1:WjWbwo0Bf1Cbd8Yr0myrpYYlcN7VvQz/TVmUTjxL35g= +github.com/SherClockHolmes/webpush-go v1.1.0/go.mod h1:Jbd13H6kOFZubRMAaEHQS+e0EpP/aSHtLKeo9gsyO5k= github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= github.com/Unknwon/com v0.0.0-20190321035513-0fed4efef755/go.mod h1:voKvFVpXBJxdIPeqjoJuLK+UVcRlo/JLjeToGxPYu68= @@ -637,6 +639,7 @@ go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= golang.org/x/crypto v0.0.0-20180820150726-614d502a4dac/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190131182504-b8fe1690c613/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190320223903-b7391e95e576/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index cad7f05f15989..66cdb89e4e3ea 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -202,8 +202,10 @@ var migrations = []Migration{ NewMigration("Add EmailHash Table", addEmailHashTable), // v134 -> v135 NewMigration("Refix merge base for merged pull requests", refixMergeBase), - // v135 -> 136 + // v135 -> v136 NewMigration("Add OrgID column to Labels table", addOrgIDLabelColumn), + // v136 -> v137 + NewMigration("Add WebPushSubscription table", addWebPushSubcriptionTable), } // GetCurrentDBVersion returns the current db version diff --git a/models/migrations/v136.go b/models/migrations/v136.go new file mode 100644 index 0000000000000..30d5ef159c160 --- /dev/null +++ b/models/migrations/v136.go @@ -0,0 +1,24 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package migrations + +import ( + "code.gitea.io/gitea/modules/timeutil" + "xorm.io/xorm" +) + +func addWebPushSubcriptionTable(x *xorm.Engine) error { + type WebPushSubscription struct { + ID int64 `xorm:"pk autoincr"` + UserID int64 `xorm:"INDEX"` + + Endpoint string `xorm:"UNIQUE"` + Auth string + P256DH string + + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + } + return x.Sync2(new(WebPushSubscription)) +} diff --git a/models/models.go b/models/models.go index c818c651007b4..097e162dd4566 100644 --- a/models/models.go +++ b/models/models.go @@ -125,6 +125,7 @@ func init() { new(Task), new(LanguageStat), new(EmailHash), + new(WebPushSubscription), ) gonicNames := []string{"SSL", "UID"} diff --git a/models/notification.go b/models/notification.go index 8d74ac129f01e..632735054d4bf 100644 --- a/models/notification.go +++ b/models/notification.go @@ -7,9 +7,11 @@ package models import ( "fmt" "path" + "strconv" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/timeutil" @@ -116,6 +118,97 @@ func GetNotifications(opts FindNotificationOptions) (NotificationList, error) { return getNotifications(x, opts) } +// GetEligibleNotificationParticipants returns a list of users, as well as the issue, who are eligible to +// receive a new or updated notification. +// receiverID > 0 just targets the one user. +func GetEligibleNotificationParticipants(issue *Issue, notificationAuthorID, receiverID int64) (map[int64]struct{}, error) { + sess := x.NewSession() + defer sess.Close() + if err := sess.Begin(); err != nil { + return nil, err + } + + result, err := getEligibleNotificationParticipants(sess, issue, notificationAuthorID, receiverID) + if err != nil { + return nil, err + } + + err = sess.Commit() + if err != nil { + return nil, err + } + + return result, err +} + +func getEligibleNotificationParticipants(e Engine, issue *Issue, notificationAuthorID, receiverID int64) (map[int64]struct{}, error) { + var toNotify map[int64]struct{} + + if receiverID > 0 { + toNotify = make(map[int64]struct{}, 1) + toNotify[receiverID] = struct{}{} + return toNotify, nil + + } + + toNotify = make(map[int64]struct{}, 32) + issueWatches, err := getIssueWatchersIDs(e, issue.ID, true) + if err != nil { + return nil, err + } + for _, id := range issueWatches { + toNotify[id] = struct{}{} + } + + repoWatches, err := getRepoWatchersIDs(e, issue.RepoID) + if err != nil { + return nil, err + } + for _, id := range repoWatches { + toNotify[id] = struct{}{} + } + issueParticipants, err := issue.getParticipantIDsByIssue(e) + if err != nil { + return nil, err + } + for _, id := range issueParticipants { + toNotify[id] = struct{}{} + } + + // dont notify user who cause notification + delete(toNotify, notificationAuthorID) + // filter out explicit unwatch on issue + issueUnWatches, err := getIssueWatchersIDs(e, issue.ID, false) + if err != nil { + return nil, err + } + for _, id := range issueUnWatches { + delete(toNotify, id) + } + + err = issue.loadRepo(e) + if err != nil { + return nil, err + } + units := issue.Repo.Units + issue.Repo.Units = nil // <- Not sure why this was here before refactoring, but we put it back later + defer func() { + issue.Repo.Units = units + }() + + // Filter out those who can't view the linked issue/PR, but were previously involved + for userID := range toNotify { + if issue.IsPull && !issue.Repo.checkUnitUser(e, userID, false, UnitTypePullRequests) { + delete(toNotify, userID) + } + if !issue.IsPull && !issue.Repo.checkUnitUser(e, userID, false, UnitTypeIssues) { + delete(toNotify, userID) + } + } + + return toNotify, nil +} + // CreateOrUpdateIssueNotifications creates an issue notification // for each watcher, or updates it if already exists // receiverID > 0 just send to reciver, else send to all watcher @@ -135,9 +228,7 @@ func CreateOrUpdateIssueNotifications(issueID, commentID, notificationAuthorID, func createOrUpdateIssueNotifications(e Engine, issueID, commentID, notificationAuthorID, receiverID int64) error { // init - var toNotify map[int64]struct{} notifications, err := getNotificationsByIssueID(e, issueID) - if err != nil { return err } @@ -147,63 +238,24 @@ func createOrUpdateIssueNotifications(e Engine, issueID, commentID, notification return err } - if receiverID > 0 { - toNotify = make(map[int64]struct{}, 1) - toNotify[receiverID] = struct{}{} - } else { - toNotify = make(map[int64]struct{}, 32) - issueWatches, err := getIssueWatchersIDs(e, issueID, true) - if err != nil { - return err - } - for _, id := range issueWatches { - toNotify[id] = struct{}{} - } - - repoWatches, err := getRepoWatchersIDs(e, issue.RepoID) - if err != nil { - return err - } - for _, id := range repoWatches { - toNotify[id] = struct{}{} - } - issueParticipants, err := issue.getParticipantIDsByIssue(e) - if err != nil { - return err - } - for _, id := range issueParticipants { - toNotify[id] = struct{}{} - } - - // dont notify user who cause notification - delete(toNotify, notificationAuthorID) - // explicit unwatch on issue - issueUnWatches, err := getIssueWatchersIDs(e, issueID, false) - if err != nil { - return err - } - for _, id := range issueUnWatches { - delete(toNotify, id) - } - } - - err = issue.loadRepo(e) + toNotify, err := getEligibleNotificationParticipants(e, issue, notificationAuthorID, receiverID) if err != nil { return err } + if len(toNotify) == 0 { + return nil + } // notify for userID := range toNotify { - issue.Repo.Units = nil - if issue.IsPull && !issue.Repo.checkUnitUser(e, userID, false, UnitTypePullRequests) { - continue - } - if !issue.IsPull && !issue.Repo.checkUnitUser(e, userID, false, UnitTypeIssues) { - continue + + err := notificationSendWebPushNotification(userID, issue, commentID) + if err != nil { + log.Error("problem sending webhook notification: %v", err) } - if notificationExists(notifications, issue.ID, userID) { - if err = updateIssueNotification(e, userID, issue.ID, commentID, notificationAuthorID); err != nil { + if notificationExists(notifications, issueID, userID) { + if err = updateIssueNotification(e, userID, issueID, commentID, notificationAuthorID); err != nil { return err } continue @@ -779,3 +831,23 @@ func UpdateNotificationStatuses(user *User, currentStatus NotificationStatus, de Update(n) return err } + +func notificationSendWebPushNotification(userID int64, issue *Issue, commentID int64) error { + issueType := "issue" + if issue.IsPull { + issueType = "pull request" + } + + anchorURL := "" + if commentID != 0 { + anchorURL = "#issuecomment-" + strconv.FormatInt(commentID, 10) + } + + notificationPayload := &structs.NotificationPayload{ + Title: setting.AppName + " - " + issue.Repo.MustOwner().Name + "/" + issue.Repo.Name, + Text: "New activity on " + issueType + " #" + strconv.FormatInt(issue.Index, 10) + " " + issue.Title + ".\nClick to open.", + URL: issue.HTMLURL() + anchorURL, + } + err := SendWebPushNotificationToUser(userID, notificationPayload) + return err +} diff --git a/models/webpush.go b/models/webpush.go new file mode 100644 index 0000000000000..4e36e40d5fcbc --- /dev/null +++ b/models/webpush.go @@ -0,0 +1,130 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package models + +import ( + "encoding/json" + "net/http" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/timeutil" + + "github.com/SherClockHolmes/webpush-go" +) + +// WebPushSubscription represents a HTML5 Web Push Subscription used for background notifications. +type WebPushSubscription struct { + ID int64 `xorm:"pk autoincr"` + UserID int64 `xorm:"INDEX"` + + Endpoint string `xorm:"UNIQUE"` + Auth string + P256DH string + + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` +} + +// CreateWebPushSubscription creates a row on the push subscriptions table. +func CreateWebPushSubscription(userID int64, subscription *structs.NotificationWebPushSubscription) error { + return createWebPushSubscription(x, userID, subscription) +} + +func createWebPushSubscription(e Engine, userID int64, subscription *structs.NotificationWebPushSubscription) error { + webPushSubscription := &WebPushSubscription{ + UserID: userID, + Endpoint: subscription.Endpoint, + Auth: subscription.Auth, + P256DH: subscription.P256DH, + } + + _, err := e.Insert(webPushSubscription) + return err +} + +// GetWebPushSubscriptionsByUserID gets all the Web Push subscriptions for a given user +func GetWebPushSubscriptionsByUserID(userID int64) ([]*WebPushSubscription, error) { + return getWebPushSubscriptionsByUserID(x, userID) +} + +func getWebPushSubscriptionsByUserID(e Engine, userID int64) ([]*WebPushSubscription, error) { + subscriptions := make([]*WebPushSubscription, 0) + err := e.Where("user_id = ?", userID).Find(&subscriptions) + return subscriptions, err +} + +// DeleteWebPushSubscription deletes a given Web Push subscription by ID +func DeleteWebPushSubscription(subscriptionID int64) error { + return deleteWebPushSubscription(x, subscriptionID) +} + +func deleteWebPushSubscription(e Engine, subscriptionID int64) error { + _, err := e.Delete(&WebPushSubscription{ID: subscriptionID}) + return err +} + +// SendWebPushNotificationToUser sends a background Web Push notification to any of the user's +// enrolled browsers. +// It will also remove any failed (expired) subscriptions. +func SendWebPushNotificationToUser(userID int64, payload *structs.NotificationPayload) error { + userSubscriptions, err := GetWebPushSubscriptionsByUserID(userID) + if err != nil { + return err + } + + for _, userSubscription := range userSubscriptions { + subscription := &structs.NotificationWebPushSubscription{ + Endpoint: userSubscription.Endpoint, + Auth: userSubscription.Auth, + P256DH: userSubscription.P256DH, + } + resp, err := SendWebPushNotification(subscription, payload) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 && resp.StatusCode <= 499 { + // This is a bad subscription. It may have expired (410 Gone). + err = DeleteWebPushSubscription(userSubscription.ID) + if err != nil { + log.Error("could not delete Web Push subscription: %v", err) + return err + } + } + } + return nil +} + +// SendWebPushNotification sends a background Web Push notification to any of the user's +// enrolled browsers. +// The HTTP status code indicates success. err is for internal problems. +func SendWebPushNotification(subscription *structs.NotificationWebPushSubscription, payload *structs.NotificationPayload) (*http.Response, error) { + webPushSubscription := &webpush.Subscription{ + Endpoint: subscription.Endpoint, + Keys: webpush.Keys{ + Auth: subscription.Auth, + P256dh: subscription.P256DH, + }, + } + + pushPayload, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + resp, err := webpush.SendNotification(pushPayload, webPushSubscription, &webpush.Options{ + VAPIDPublicKey: setting.WebPushPublicKey, + VAPIDPrivateKey: setting.WebPushPrivateKey, + TTL: 30, + }) + if err != nil { + return resp, err + } + + defer resp.Body.Close() + return resp, nil +} diff --git a/modules/setting/setting.go b/modules/setting/setting.go index 74f6da38d1564..0d3863febaaa3 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -26,6 +26,7 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/user" + webpush "github.com/SherClockHolmes/webpush-go" shellquote "github.com/kballard/go-shellquote" version "github.com/mcuadros/go-version" "github.com/unknwon/cae/zip" @@ -147,6 +148,8 @@ var ( // Security settings InstallLock bool SecretKey string + WebPushPublicKey string + WebPushPrivateKey string LogInRememberDays int CookieUserName string CookieRememberName string @@ -816,6 +819,36 @@ func NewContext() { PasswordHashAlgo = sec.Key("PASSWORD_HASH_ALGO").MustString("pbkdf2") CSRFCookieHTTPOnly = sec.Key("CSRF_COOKIE_HTTP_ONLY").MustBool(true) + WebPushPublicKey = sec.Key("WEB_PUSH_PUBLIC_KEY").String() + WebPushPrivateKey = sec.Key("WEB_PUSH_PRIVATE_KEY").String() + if WebPushPrivateKey == "" || WebPushPublicKey == "" { + log.Info("No VAPID (Web Push) keypair detected. Generating and saving to app.ini") + WebPushPrivateKey, WebPushPublicKey, err = webpush.GenerateVAPIDKeys() + if err != nil { + log.Fatal("error generating VAPID keypair: %v", err) + return + } + + cfg := ini.Empty() + if com.IsFile(CustomConf) { + if err := cfg.Append(CustomConf); err != nil { + log.Error("failed to load custom conf %s: %v", CustomConf, err) + return + } + } + cfg.Section("security").Key("WEB_PUSH_PUBLIC_KEY").SetValue(WebPushPublicKey) + cfg.Section("security").Key("WEB_PUSH_PRIVATE_KEY").SetValue(WebPushPrivateKey) + + if err := os.MkdirAll(filepath.Dir(CustomConf), os.ModePerm); err != nil { + log.Fatal("failed to create '%s': %v", CustomConf, err) + return + } + if err := cfg.SaveTo(CustomConf); err != nil { + log.Fatal("error saving generated VAPID keypair to custom config: %v", err) + return + } + } + InternalToken = loadInternalToken(sec) cfgdata := sec.Key("PASSWORD_COMPLEXITY").Strings(",") diff --git a/modules/structs/notifications.go b/modules/structs/notifications.go index b6c9774a97ad8..dc5923e7ec593 100644 --- a/modules/structs/notifications.go +++ b/modules/structs/notifications.go @@ -31,3 +31,18 @@ type NotificationSubject struct { type NotificationCount struct { New int64 `json:"new"` } + +// NotificationWebPushSubscription represents a HTML5 Web Push Subscription used for background notifications. +type NotificationWebPushSubscription struct { + Endpoint string `json:"endpoint"` + Auth string `json:"auth"` + P256DH string `json:"p256dh"` +} + +// NotificationPayload marks a JSON payload sent in a push event to the JS service worker. +// This is used for background notifications. +type NotificationPayload struct { + Title string `json:"title"` + Text string `json:"text"` + URL string `json:"url"` +} diff --git a/modules/templates/helper.go b/modules/templates/helper.go index b5b49874276db..27a9ca1201434 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -70,6 +70,9 @@ func NewFuncMap() []template.FuncMap { "AppDomain": func() string { return setting.Domain }, + "WebPushPublicKey": func() string { + return setting.WebPushPublicKey + }, "DisableGravatar": func() bool { return setting.DisableGravatar }, diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index bce3bf2452832..8a4a31210a68b 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -523,6 +523,8 @@ func RegisterRoutes(m *macaron.Macaron) { Get(notify.GetThread). Patch(notify.ReadThread) }, reqToken()) + // Can also make use of session-based authentication, hence reqToken isn't used as above: + m.Post("/notifications/subscription", bind(api.NotificationWebPushSubscription{}), notify.NewWebPushSubscription) // Users m.Group("/users", func() { diff --git a/routers/api/v1/notify/notifications.go b/routers/api/v1/notify/notifications.go index 71dd7d949267f..0849b616ed4b2 100644 --- a/routers/api/v1/notify/notifications.go +++ b/routers/api/v1/notify/notifications.go @@ -6,9 +6,12 @@ package notify import ( "net/http" + "strings" "code.gitea.io/gitea/models" "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" ) @@ -22,3 +25,65 @@ func NewAvailable(ctx *context.APIContext) { // "$ref": "#/responses/NotificationCount" ctx.JSON(http.StatusOK, api.NotificationCount{New: models.CountUnread(ctx.User)}) } + +// NewWebPushSubscription check if unread notifications exist +func NewWebPushSubscription(ctx *context.APIContext, input api.NotificationWebPushSubscription) { + // swagger:operation POST /notifications/subscription notification notifyNewWebPushSubscription + // --- + // summary: Create a Web Push subscription for the current user to receieve push notifications. + // This will also produce a test notification to ensure the given details are valid. + // consumes: + // - application/json + // parameters: + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/NotificationWebPushSubscription" + // responses: + // "201": + // description: The Web Push subscription was tested and saved successfully. + // "422": + // description: Required fields were missing or the provided subscription could not be tested successfully. + + if !ctx.IsSigned { + ctx.Context.Error(http.StatusUnauthorized) + return + } + + if input.Endpoint == "" || input.Auth == "" || input.P256DH == "" { + ctx.Status(http.StatusUnprocessableEntity) + return + } + + testPayload := &api.NotificationPayload{ + Title: setting.AppName, + Text: "This is a test notification from Gitea. If you're reading this notifications are working. Hooray!", + URL: setting.AppURL, + } + resp, err := models.SendWebPushNotification(&input, testPayload) + if err != nil { + // An invalid key causes a mathematical error. This is the user's fault. + if strings.Contains(err.Error(), "key is not a valid point on the curve") { + ctx.Status(http.StatusUnprocessableEntity) + return + } + // Otherwise it could be a network problem. + ctx.Status(http.StatusInternalServerError) + return + } + + // Web Push returns 201 on success. + if resp.StatusCode != http.StatusCreated { + ctx.Status(http.StatusUnprocessableEntity) + return + } + + err = models.CreateWebPushSubscription(ctx.User.ID, &input) + if err != nil { + log.Error("could not create web push: %v", err) + ctx.Status(http.StatusInternalServerError) + return + } + + ctx.Status(http.StatusCreated) +} diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index 4bb649616a01a..8f66c3d739728 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -137,4 +137,7 @@ type swaggerParameterBodies struct { // in:body CreateOAuth2ApplicationOptions api.CreateOAuth2ApplicationOptions + + // in:body + NotificationWebPushSubscription api.NotificationWebPushSubscription } diff --git a/templates/base/head.tmpl b/templates/base/head.tmpl index e0765d59d30ed..ae7874369d4f9 100644 --- a/templates/base/head.tmpl +++ b/templates/base/head.tmpl @@ -12,6 +12,7 @@ navigator.serviceWorker.register('{{AppSubUrl}}/serviceworker.js').then(function(registration) { // Registration was successful console.info('ServiceWorker registration successful with scope: ', registration.scope); + window.serviceWorkerRegistration = registration; }, function(err) { // registration failed :( console.info('ServiceWorker registration failed: ', err); @@ -86,6 +87,8 @@ window.config = { AppSubUrl: '{{AppSubUrl}}', StaticUrlPrefix: '{{StaticUrlPrefix}}', + WebPushPublicKey: '{{WebPushPublicKey}}', + ServiceWorkerEnabled: {{if UseServiceWorker}}true{{else}}false{{end}}, csrf: '{{.CsrfToken}}', HighlightJS: {{if .RequireHighlightJS}}true{{else}}false{{end}}, Minicolors: {{if .RequireMinicolors}}true{{else}}false{{end}}, diff --git a/templates/pwa/serviceworker_js.tmpl b/templates/pwa/serviceworker_js.tmpl index 7117555dd237c..a951fbdd062ce 100644 --- a/templates/pwa/serviceworker_js.tmpl +++ b/templates/pwa/serviceworker_js.tmpl @@ -74,3 +74,25 @@ self.addEventListener('fetch', function (event) { ) ); }); + +self.addEventListener('push', function (event) { + var eventPayload = event.data.json(); + var options = { + body: eventPayload.text, + vibrate: [100, 50, 100], + data: { + url: eventPayload.url + } + }; + + event.waitUntil(self.registration.showNotification(eventPayload.title, options)); +}); + +self.addEventListener('notificationclick', function (event) { + var notification = event.notification; + var url = notification.data.url; + + clients.openWindow(url); + notification.close(); +}); + diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 8b10a759901d3..9ced87ff4a926 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -534,6 +534,35 @@ } } }, + "/notifications/subscription": { + "post": { + "consumes": [ + "application/json" + ], + "tags": [ + "notification" + ], + "summary": "Create a Web Push subscription for the current user to receieve push notifications. This will also produce a test notification to ensure the given details are valid.", + "operationId": "notifyNewWebPushSubscription", + "parameters": [ + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/NotificationWebPushSubscription" + } + } + ], + "responses": { + "201": { + "description": "The Web Push subscription was tested and saved successfully." + }, + "422": { + "description": "Required fields were missing or the provided subscription could not be tested successfully." + } + } + } + }, "/notifications/threads/{id}": { "get": { "consumes": [ @@ -12603,6 +12632,25 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "NotificationWebPushSubscription": { + "type": "object", + "title": "NotificationWebPushSubscription represents a HTML5 Web Push Subscription used for background notifications.", + "properties": { + "auth": { + "type": "string", + "x-go-name": "Auth" + }, + "endpoint": { + "type": "string", + "x-go-name": "Endpoint" + }, + "p256dh": { + "type": "string", + "x-go-name": "P256DH" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "OAuth2Application": { "type": "object", "title": "OAuth2Application represents an OAuth2 application.", @@ -14427,7 +14475,7 @@ "parameterBodies": { "description": "parameterBodies", "schema": { - "$ref": "#/definitions/CreateOAuth2ApplicationOptions" + "$ref": "#/definitions/NotificationWebPushSubscription" } }, "redirect": { diff --git a/templates/user/notification/notification.tmpl b/templates/user/notification/notification.tmpl index c4f744a291738..27517bd608a46 100644 --- a/templates/user/notification/notification.tmpl +++ b/templates/user/notification/notification.tmpl @@ -2,7 +2,15 @@
-

{{.i18n.Tr "notification.notifications"}}

+
+
+

{{.i18n.Tr "notification.notifications"}}

+
+
+
+
+
+