summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorLibravatar Vyr Cossont <VyrCossont@users.noreply.github.com>2025-01-23 16:47:30 -0800
committerLibravatar GitHub <noreply@github.com>2025-01-23 16:47:30 -0800
commit5b765d734ee70f0a8a0790444d60969a727567f8 (patch)
treef76e05a6e5b22df17160be595c40e964bdbe5f22 /internal
parent[feature] Serve bot accounts over AP as Service instead of Person (#3672) (diff)
downloadgotosocial-5b765d734ee70f0a8a0790444d60969a727567f8.tar.xz
[feature] Push notifications (#3587)
* Update push subscription API model to be Mastodon 4.0 compatible * Add webpush-go dependency # Conflicts: # go.sum * Single-row table for storing instance's VAPID key pair * Generate VAPID key pair during startup * Add VAPID public key to instance info API * Return VAPID public key when registering an app * Store Web Push subscriptions in DB * Add Web Push sender (similar to email sender) * Add no-op push senders to most processor tests * Test Web Push notifications from workers * Delete Web Push subscriptions when account is deleted * Implement push subscription API * Linter fixes * Update Swagger * Fix enum to int migration * Fix GetVAPIDKeyPair * Create web push subscriptions table with indexes * Log Web Push server error messages * Send instance URL as Web Push JWT subject * Accept any 2xx code as a success * Fix malformed VAPID sub claim * Use packed notification flags * Remove unused date columns * Add notification type for update notifications Not used yet * Make GetVAPIDKeyPair idempotent and remove PutVAPIDKeyPair * Post-rebase fixes * go mod tidy * Special-case 400 errors other than 408/429 Most client errors should remove the subscription. * Improve titles, trim body to reasonable length * Disallow cleartext HTTP for Web Push servers * Fix lint * Remove redundant index on unique column Also removes redundant unique and notnull tags on ID column since these are implied by pk * Make realsender.go more readable * Use Tobi's style for wrapping errors * Restore treating all 5xx codes as temporary problems * Always load target account settings * Stub `policy` and `standard` * webpush.Sender: take type converter as ctor param * Move webpush.MockSender and noopSender into testrig
Diffstat (limited to 'internal')
-rw-r--r--internal/api/activitypub/emoji/emojiget_test.go8
-rw-r--r--internal/api/activitypub/users/user_test.go8
-rw-r--r--internal/api/auth/auth_test.go8
-rw-r--r--internal/api/client.go4
-rw-r--r--internal/api/client/accounts/account_test.go8
-rw-r--r--internal/api/client/admin/admin_test.go8
-rw-r--r--internal/api/client/bookmarks/bookmarks_test.go8
-rw-r--r--internal/api/client/exports/exports_test.go1
-rw-r--r--internal/api/client/favourites/favourites_test.go8
-rw-r--r--internal/api/client/filters/v1/filter_test.go8
-rw-r--r--internal/api/client/filters/v2/filter_test.go9
-rw-r--r--internal/api/client/followedtags/followedtags_test.go8
-rw-r--r--internal/api/client/followrequests/followrequest_test.go8
-rw-r--r--internal/api/client/import/import_test.go1
-rw-r--r--internal/api/client/instance/instance_test.go8
-rw-r--r--internal/api/client/lists/lists_test.go8
-rw-r--r--internal/api/client/media/mediacreate_test.go8
-rw-r--r--internal/api/client/media/mediaupdate_test.go8
-rw-r--r--internal/api/client/mutes/mutes_test.go8
-rw-r--r--internal/api/client/notifications/notifications_test.go8
-rw-r--r--internal/api/client/polls/polls_test.go25
-rw-r--r--internal/api/client/push/push.go49
-rw-r--r--internal/api/client/push/push_test.go110
-rw-r--r--internal/api/client/push/pushsubscriptiondelete.go64
-rw-r--r--internal/api/client/push/pushsubscriptiondelete_test.go83
-rw-r--r--internal/api/client/push/pushsubscriptionget.go71
-rw-r--r--internal/api/client/push/pushsubscriptionget_test.go102
-rw-r--r--internal/api/client/push/pushsubscriptionpost.go284
-rw-r--r--internal/api/client/push/pushsubscriptionpost_test.go346
-rw-r--r--internal/api/client/push/pushsubscriptionput.go232
-rw-r--r--internal/api/client/push/pushsubscriptionput_test.go176
-rw-r--r--internal/api/client/reports/reports_test.go8
-rw-r--r--internal/api/client/search/search_test.go8
-rw-r--r--internal/api/client/statuses/status_test.go8
-rw-r--r--internal/api/client/streaming/streaming_test.go8
-rw-r--r--internal/api/client/tags/tags_test.go8
-rw-r--r--internal/api/client/user/emailchange_test.go3
-rw-r--r--internal/api/client/user/user_test.go17
-rw-r--r--internal/api/fileserver/fileserver_test.go17
-rw-r--r--internal/api/model/instancev2.go10
-rw-r--r--internal/api/model/pushsubscription.go44
-rw-r--r--internal/api/model/webpushnotification.go52
-rw-r--r--internal/api/model/webpushsubscription.go157
-rw-r--r--internal/api/wellknown/webfinger/webfinger_test.go8
-rw-r--r--internal/api/wellknown/webfinger/webfingerget_test.go1
-rw-r--r--internal/cache/cache.go2
-rw-r--r--internal/cache/db.go53
-rw-r--r--internal/cache/invalidate.go10
-rw-r--r--internal/cache/size.go18
-rw-r--r--internal/config/config.go2
-rw-r--r--internal/config/defaults.go2
-rw-r--r--internal/config/helpers.gen.go58
-rw-r--r--internal/db/application.go3
-rw-r--r--internal/db/bundb/application.go10
-rw-r--r--internal/db/bundb/bundb.go5
-rw-r--r--internal/db/bundb/migrations/20241121121623_enum_strings_to_ints.go4
-rw-r--r--internal/db/bundb/migrations/20241124012635_add_vapid_key_pairs.go51
-rw-r--r--internal/db/bundb/migrations/20241124012636_add_web_push_subscriptions.go61
-rw-r--r--internal/db/bundb/notification_test.go2
-rw-r--r--internal/db/bundb/webpush.go270
-rw-r--r--internal/db/bundb/webpush_test.go81
-rw-r--r--internal/db/db.go1
-rw-r--r--internal/db/webpush.go54
-rw-r--r--internal/gtsmodel/notification.go29
-rw-r--r--internal/gtsmodel/vapidkeypair.go28
-rw-r--r--internal/gtsmodel/webpushsubscription.go82
-rw-r--r--internal/processing/account/delete.go6
-rw-r--r--internal/processing/admin/admin_test.go1
-rw-r--r--internal/processing/processor.go10
-rw-r--r--internal/processing/processor_test.go1
-rw-r--r--internal/processing/push/create.go65
-rw-r--r--internal/processing/push/delete.go39
-rw-r--r--internal/processing/push/get.go47
-rw-r--r--internal/processing/push/push.go85
-rw-r--r--internal/processing/push/update.go63
-rw-r--r--internal/processing/timeline/notification.go2
-rw-r--r--internal/processing/workers/fromclientapi_test.go82
-rw-r--r--internal/processing/workers/fromfediapi_test.go4
-rw-r--r--internal/processing/workers/surface.go2
-rw-r--r--internal/processing/workers/surfacenotify.go9
-rw-r--r--internal/processing/workers/surfacenotify_test.go1
-rw-r--r--internal/processing/workers/workers.go3
-rw-r--r--internal/text/substring.go45
-rw-r--r--internal/text/substring_test.go47
-rw-r--r--internal/transport/transport_test.go8
-rw-r--r--internal/typeutils/converter_test.go3
-rw-r--r--internal/typeutils/internaltofrontend.go45
-rw-r--r--internal/typeutils/internaltofrontend_test.go13
-rw-r--r--internal/webpush/realsender.go341
-rw-r--r--internal/webpush/realsender_test.go263
-rw-r--r--internal/webpush/sender.go54
-rw-r--r--internal/workers/workers.go11
92 files changed, 4003 insertions, 107 deletions
diff --git a/internal/api/activitypub/emoji/emojiget_test.go b/internal/api/activitypub/emoji/emojiget_test.go
index 0eb1f5931..7d3587fd8 100644
--- a/internal/api/activitypub/emoji/emojiget_test.go
+++ b/internal/api/activitypub/emoji/emojiget_test.go
@@ -88,7 +88,13 @@ func (suite *EmojiGetTestSuite) SetupTest() {
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil)
- suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager)
+ suite.processor = testrig.NewTestProcessor(
+ &suite.state,
+ suite.federator,
+ suite.emailSender,
+ testrig.NewNoopWebPushSender(),
+ suite.mediaManager,
+ )
suite.emojiModule = emoji.New(suite.processor)
testrig.StandardDBSetup(suite.db, suite.testAccounts)
testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
diff --git a/internal/api/activitypub/users/user_test.go b/internal/api/activitypub/users/user_test.go
index 10eb33937..d66fe8cf9 100644
--- a/internal/api/activitypub/users/user_test.go
+++ b/internal/api/activitypub/users/user_test.go
@@ -100,7 +100,13 @@ func (suite *UserStandardTestSuite) SetupTest() {
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil)
- suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager)
+ suite.processor = testrig.NewTestProcessor(
+ &suite.state,
+ suite.federator,
+ suite.emailSender,
+ testrig.NewNoopWebPushSender(),
+ suite.mediaManager,
+ )
testrig.StartWorkers(&suite.state, suite.processor.Workers())
suite.userModule = users.New(suite.processor)
diff --git a/internal/api/auth/auth_test.go b/internal/api/auth/auth_test.go
index c5ceba387..cfbdec7ec 100644
--- a/internal/api/auth/auth_test.go
+++ b/internal/api/auth/auth_test.go
@@ -91,7 +91,13 @@ func (suite *AuthStandardTestSuite) SetupTest() {
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../testrig/media")), suite.mediaManager)
suite.emailSender = testrig.NewEmailSender("../../../web/template/", nil)
- suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager)
+ suite.processor = testrig.NewTestProcessor(
+ &suite.state,
+ suite.federator,
+ suite.emailSender,
+ testrig.NewNoopWebPushSender(),
+ suite.mediaManager,
+ )
suite.authModule = auth.New(suite.db, suite.processor, suite.idp)
testrig.StandardDBSetup(suite.db, suite.testAccounts)
diff --git a/internal/api/client.go b/internal/api/client.go
index 60daddf87..3112aeea5 100644
--- a/internal/api/client.go
+++ b/internal/api/client.go
@@ -47,6 +47,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/api/client/notifications"
"github.com/superseriousbusiness/gotosocial/internal/api/client/polls"
"github.com/superseriousbusiness/gotosocial/internal/api/client/preferences"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/push"
"github.com/superseriousbusiness/gotosocial/internal/api/client/reports"
"github.com/superseriousbusiness/gotosocial/internal/api/client/search"
"github.com/superseriousbusiness/gotosocial/internal/api/client/statuses"
@@ -91,6 +92,7 @@ type Client struct {
notifications *notifications.Module // api/v1/notifications
polls *polls.Module // api/v1/polls
preferences *preferences.Module // api/v1/preferences
+ push *push.Module // api/v1/push
reports *reports.Module // api/v1/reports
search *search.Module // api/v1/search, api/v2/search
statuses *statuses.Module // api/v1/statuses
@@ -143,6 +145,7 @@ func (c *Client) Route(r *router.Router, m ...gin.HandlerFunc) {
c.notifications.Route(h)
c.polls.Route(h)
c.preferences.Route(h)
+ c.push.Route(h)
c.reports.Route(h)
c.search.Route(h)
c.statuses.Route(h)
@@ -183,6 +186,7 @@ func NewClient(state *state.State, p *processing.Processor) *Client {
notifications: notifications.New(p),
polls: polls.New(p),
preferences: preferences.New(p),
+ push: push.New(p),
reports: reports.New(p),
search: search.New(p),
statuses: statuses.New(p),
diff --git a/internal/api/client/accounts/account_test.go b/internal/api/client/accounts/account_test.go
index db212af22..e700ade78 100644
--- a/internal/api/client/accounts/account_test.go
+++ b/internal/api/client/accounts/account_test.go
@@ -100,7 +100,13 @@ func (suite *AccountStandardTestSuite) SetupTest() {
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
suite.sentEmails = make(map[string]string)
suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails)
- suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager)
+ suite.processor = testrig.NewTestProcessor(
+ &suite.state,
+ suite.federator,
+ suite.emailSender,
+ testrig.NewNoopWebPushSender(),
+ suite.mediaManager,
+ )
suite.accountsModule = accounts.New(suite.processor)
testrig.StandardDBSetup(suite.db, nil)
testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
diff --git a/internal/api/client/admin/admin_test.go b/internal/api/client/admin/admin_test.go
index 479f16f45..f44d48d78 100644
--- a/internal/api/client/admin/admin_test.go
+++ b/internal/api/client/admin/admin_test.go
@@ -106,7 +106,13 @@ func (suite *AdminStandardTestSuite) SetupTest() {
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
suite.sentEmails = make(map[string]string)
suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails)
- suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager)
+ suite.processor = testrig.NewTestProcessor(
+ &suite.state,
+ suite.federator,
+ suite.emailSender,
+ testrig.NewNoopWebPushSender(),
+ suite.mediaManager,
+ )
suite.adminModule = admin.New(&suite.state, suite.processor)
testrig.StandardDBSetup(suite.db, nil)
testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
diff --git a/internal/api/client/bookmarks/bookmarks_test.go b/internal/api/client/bookmarks/bookmarks_test.go
index 43c1eeee4..a11597f7c 100644
--- a/internal/api/client/bookmarks/bookmarks_test.go
+++ b/internal/api/client/bookmarks/bookmarks_test.go
@@ -114,7 +114,13 @@ func (suite *BookmarkTestSuite) SetupTest() {
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil)
- suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager)
+ suite.processor = testrig.NewTestProcessor(
+ &suite.state,
+ suite.federator,
+ suite.emailSender,
+ testrig.NewNoopWebPushSender(),
+ suite.mediaManager,
+ )
suite.statusModule = statuses.New(suite.processor)
suite.bookmarkModule = bookmarks.New(suite.processor)
}
diff --git a/internal/api/client/exports/exports_test.go b/internal/api/client/exports/exports_test.go
index 13f7bea05..55d873348 100644
--- a/internal/api/client/exports/exports_test.go
+++ b/internal/api/client/exports/exports_test.go
@@ -95,6 +95,7 @@ func (suite *ExportsTestSuite) SetupTest() {
&suite.state,
federator,
testrig.NewEmailSender("../../../../web/template/", nil),
+ testrig.NewNoopWebPushSender(),
mediaManager,
)
diff --git a/internal/api/client/favourites/favourites_test.go b/internal/api/client/favourites/favourites_test.go
index dcdc8fee2..7cfa205e3 100644
--- a/internal/api/client/favourites/favourites_test.go
+++ b/internal/api/client/favourites/favourites_test.go
@@ -98,7 +98,13 @@ func (suite *FavouritesStandardTestSuite) SetupTest() {
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil)
- suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager)
+ suite.processor = testrig.NewTestProcessor(
+ &suite.state,
+ suite.federator,
+ suite.emailSender,
+ testrig.NewNoopWebPushSender(),
+ suite.mediaManager,
+ )
suite.favModule = favourites.New(suite.processor)
}
diff --git a/internal/api/client/filters/v1/filter_test.go b/internal/api/client/filters/v1/filter_test.go
index 128426435..558f3d959 100644
--- a/internal/api/client/filters/v1/filter_test.go
+++ b/internal/api/client/filters/v1/filter_test.go
@@ -105,7 +105,13 @@ func (suite *FiltersTestSuite) SetupTest() {
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../../testrig/media")), suite.mediaManager)
suite.sentEmails = make(map[string]string)
suite.emailSender = testrig.NewEmailSender("../../../../../web/template/", suite.sentEmails)
- suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager)
+ suite.processor = testrig.NewTestProcessor(
+ &suite.state,
+ suite.federator,
+ suite.emailSender,
+ testrig.NewNoopWebPushSender(),
+ suite.mediaManager,
+ )
suite.filtersModule = filtersV1.New(suite.processor)
testrig.StandardDBSetup(suite.db, nil)
diff --git a/internal/api/client/filters/v2/filter_test.go b/internal/api/client/filters/v2/filter_test.go
index 20411c090..8301c67ad 100644
--- a/internal/api/client/filters/v2/filter_test.go
+++ b/internal/api/client/filters/v2/filter_test.go
@@ -103,9 +103,14 @@ func (suite *FiltersTestSuite) SetupTest() {
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../../testrig/media")), suite.mediaManager)
- suite.sentEmails = make(map[string]string)
suite.emailSender = testrig.NewEmailSender("../../../../../web/template/", suite.sentEmails)
- suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager)
+ suite.processor = testrig.NewTestProcessor(
+ &suite.state,
+ suite.federator,
+ suite.emailSender,
+ testrig.NewNoopWebPushSender(),
+ suite.mediaManager,
+ )
suite.filtersModule = filtersV2.New(suite.processor)
testrig.StandardDBSetup(suite.db, nil)
diff --git a/internal/api/client/followedtags/followedtags_test.go b/internal/api/client/followedtags/followedtags_test.go
index 89a61aca1..816e1d0cc 100644
--- a/internal/api/client/followedtags/followedtags_test.go
+++ b/internal/api/client/followedtags/followedtags_test.go
@@ -88,7 +88,13 @@ func (suite *FollowedTagsTestSuite) SetupTest() {
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
suite.sentEmails = make(map[string]string)
suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails)
- suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager)
+ suite.processor = testrig.NewTestProcessor(
+ &suite.state,
+ suite.federator,
+ suite.emailSender,
+ testrig.NewNoopWebPushSender(),
+ suite.mediaManager,
+ )
suite.followedTagsModule = followedtags.New(suite.processor)
testrig.StandardDBSetup(suite.db, nil)
diff --git a/internal/api/client/followrequests/followrequest_test.go b/internal/api/client/followrequests/followrequest_test.go
index 1faac2bbc..787d47c84 100644
--- a/internal/api/client/followrequests/followrequest_test.go
+++ b/internal/api/client/followrequests/followrequest_test.go
@@ -96,7 +96,13 @@ func (suite *FollowRequestStandardTestSuite) SetupTest() {
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil)
- suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager)
+ suite.processor = testrig.NewTestProcessor(
+ &suite.state,
+ suite.federator,
+ suite.emailSender,
+ testrig.NewNoopWebPushSender(),
+ suite.mediaManager,
+ )
suite.followRequestModule = followrequests.New(suite.processor)
testrig.StandardDBSetup(suite.db, nil)
testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
diff --git a/internal/api/client/import/import_test.go b/internal/api/client/import/import_test.go
index 5129f862e..fba83e1a3 100644
--- a/internal/api/client/import/import_test.go
+++ b/internal/api/client/import/import_test.go
@@ -92,6 +92,7 @@ func (suite *ImportTestSuite) SetupTest() {
&suite.state,
federator,
testrig.NewEmailSender("../../../../web/template/", nil),
+ testrig.NewNoopWebPushSender(),
mediaManager,
)
testrig.StartWorkers(&suite.state, processor.Workers())
diff --git a/internal/api/client/instance/instance_test.go b/internal/api/client/instance/instance_test.go
index 293c96020..f0427369b 100644
--- a/internal/api/client/instance/instance_test.go
+++ b/internal/api/client/instance/instance_test.go
@@ -99,7 +99,13 @@ func (suite *InstanceStandardTestSuite) SetupTest() {
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
suite.sentEmails = make(map[string]string)
suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails)
- suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager)
+ suite.processor = testrig.NewTestProcessor(
+ &suite.state,
+ suite.federator,
+ suite.emailSender,
+ testrig.NewNoopWebPushSender(),
+ suite.mediaManager,
+ )
suite.instanceModule = instance.New(suite.processor)
testrig.StandardDBSetup(suite.db, nil)
testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
diff --git a/internal/api/client/lists/lists_test.go b/internal/api/client/lists/lists_test.go
index ea5adbb5c..5fd2304c7 100644
--- a/internal/api/client/lists/lists_test.go
+++ b/internal/api/client/lists/lists_test.go
@@ -99,7 +99,13 @@ func (suite *ListsStandardTestSuite) SetupTest() {
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil)
- suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager)
+ suite.processor = testrig.NewTestProcessor(
+ &suite.state,
+ suite.federator,
+ suite.emailSender,
+ testrig.NewNoopWebPushSender(),
+ suite.mediaManager,
+ )
suite.listsModule = lists.New(suite.processor)
testrig.StandardDBSetup(suite.db, nil)
diff --git a/internal/api/client/media/mediacreate_test.go b/internal/api/client/media/mediacreate_test.go
index 2eec8341f..d26f2bb7a 100644
--- a/internal/api/client/media/mediacreate_test.go
+++ b/internal/api/client/media/mediacreate_test.go
@@ -104,7 +104,13 @@ func (suite *MediaCreateTestSuite) SetupTest() {
suite.oauthServer = testrig.NewTestOauthServer(suite.db)
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil)
- suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager)
+ suite.processor = testrig.NewTestProcessor(
+ &suite.state,
+ suite.federator,
+ suite.emailSender,
+ testrig.NewNoopWebPushSender(),
+ suite.mediaManager,
+ )
// setup module being tested
suite.mediaModule = mediamodule.New(suite.processor)
diff --git a/internal/api/client/media/mediaupdate_test.go b/internal/api/client/media/mediaupdate_test.go
index c3a1fb340..dd115f465 100644
--- a/internal/api/client/media/mediaupdate_test.go
+++ b/internal/api/client/media/mediaupdate_test.go
@@ -102,7 +102,13 @@ func (suite *MediaUpdateTestSuite) SetupTest() {
suite.oauthServer = testrig.NewTestOauthServer(suite.db)
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil)
- suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager)
+ suite.processor = testrig.NewTestProcessor(
+ &suite.state,
+ suite.federator,
+ suite.emailSender,
+ testrig.NewNoopWebPushSender(),
+ suite.mediaManager,
+ )
// setup module being tested
suite.mediaModule = mediamodule.New(suite.processor)
diff --git a/internal/api/client/mutes/mutes_test.go b/internal/api/client/mutes/mutes_test.go
index b721b080f..3f5686cfb 100644
--- a/internal/api/client/mutes/mutes_test.go
+++ b/internal/api/client/mutes/mutes_test.go
@@ -96,7 +96,13 @@ func (suite *MutesTestSuite) SetupTest() {
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
suite.sentEmails = make(map[string]string)
suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails)
- suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager)
+ suite.processor = testrig.NewTestProcessor(
+ &suite.state,
+ suite.federator,
+ suite.emailSender,
+ testrig.NewNoopWebPushSender(),
+ suite.mediaManager,
+ )
suite.mutesModule = mutes.New(suite.processor)
testrig.StandardDBSetup(suite.db, nil)
testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
diff --git a/internal/api/client/notifications/notifications_test.go b/internal/api/client/notifications/notifications_test.go
index 39fb66691..5794c0e12 100644
--- a/internal/api/client/notifications/notifications_test.go
+++ b/internal/api/client/notifications/notifications_test.go
@@ -100,7 +100,13 @@ func (suite *NotificationsTestSuite) SetupTest() {
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil)
- suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager)
+ suite.processor = testrig.NewTestProcessor(
+ &suite.state,
+ suite.federator,
+ suite.emailSender,
+ testrig.NewNoopWebPushSender(),
+ suite.mediaManager,
+ )
suite.notificationsModule = notifications.New(suite.processor)
}
diff --git a/internal/api/client/polls/polls_test.go b/internal/api/client/polls/polls_test.go
index b5552f39e..8c2bc8ba1 100644
--- a/internal/api/client/polls/polls_test.go
+++ b/internal/api/client/polls/polls_test.go
@@ -36,14 +36,15 @@ import (
type PollsStandardTestSuite struct {
suite.Suite
- db db.DB
- storage *storage.Driver
- mediaManager *media.Manager
- federator *federation.Federator
- processor *processing.Processor
- emailSender email.Sender
- sentEmails map[string]string
- state state.State
+ db db.DB
+ storage *storage.Driver
+ mediaManager *media.Manager
+ federator *federation.Federator
+ processor *processing.Processor
+ emailSender email.Sender
+ sentEmails map[string]string
+ webPushSender *testrig.WebPushMockSender
+ state state.State
// standard suite models
testTokens map[string]*gtsmodel.Token
@@ -91,7 +92,13 @@ func (suite *PollsStandardTestSuite) SetupTest() {
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
suite.sentEmails = make(map[string]string)
suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails)
- suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager)
+ suite.processor = testrig.NewTestProcessor(
+ &suite.state,
+ suite.federator,
+ suite.emailSender,
+ testrig.NewNoopWebPushSender(),
+ suite.mediaManager,
+ )
suite.pollsModule = polls.New(suite.processor)
testrig.StandardDBSetup(suite.db, nil)
testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
diff --git a/internal/api/client/push/push.go b/internal/api/client/push/push.go
new file mode 100644
index 000000000..33b974efa
--- /dev/null
+++ b/internal/api/client/push/push.go
@@ -0,0 +1,49 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package push
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/superseriousbusiness/gotosocial/internal/processing"
+)
+
+const (
+ // BasePath is the base path for serving the push API, minus the 'api' prefix.
+ BasePath = "/v1/push"
+ // SubscriptionPath is the path for serving requests for the current auth token's push subscription.
+ SubscriptionPath = BasePath + "/subscription"
+)
+
+type Module struct {
+ processor *processing.Processor
+}
+
+func New(processor *processing.Processor) *Module {
+ return &Module{
+ processor: processor,
+ }
+}
+
+func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) {
+ attachHandler(http.MethodGet, SubscriptionPath, m.PushSubscriptionGETHandler)
+ attachHandler(http.MethodPost, SubscriptionPath, m.PushSubscriptionPOSTHandler)
+ attachHandler(http.MethodPut, SubscriptionPath, m.PushSubscriptionPUTHandler)
+ attachHandler(http.MethodDelete, SubscriptionPath, m.PushSubscriptionDELETEHandler)
+}
diff --git a/internal/api/client/push/push_test.go b/internal/api/client/push/push_test.go
new file mode 100644
index 000000000..0d85192ff
--- /dev/null
+++ b/internal/api/client/push/push_test.go
@@ -0,0 +1,110 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package push_test
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/push"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/email"
+ "github.com/superseriousbusiness/gotosocial/internal/federation"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/media"
+ "github.com/superseriousbusiness/gotosocial/internal/processing"
+ "github.com/superseriousbusiness/gotosocial/internal/state"
+ "github.com/superseriousbusiness/gotosocial/internal/storage"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type PushTestSuite struct {
+ suite.Suite
+ db db.DB
+ storage *storage.Driver
+ mediaManager *media.Manager
+ federator *federation.Federator
+ processor *processing.Processor
+ emailSender email.Sender
+ sentEmails map[string]string
+ state state.State
+
+ // standard suite models
+ testTokens map[string]*gtsmodel.Token
+ testClients map[string]*gtsmodel.Client
+ testApplications map[string]*gtsmodel.Application
+ testUsers map[string]*gtsmodel.User
+ testAccounts map[string]*gtsmodel.Account
+ testWebPushSubscriptions map[string]*gtsmodel.WebPushSubscription
+
+ // module being tested
+ pushModule *push.Module
+}
+
+func (suite *PushTestSuite) SetupSuite() {
+ suite.testTokens = testrig.NewTestTokens()
+ suite.testClients = testrig.NewTestClients()
+ suite.testApplications = testrig.NewTestApplications()
+ suite.testUsers = testrig.NewTestUsers()
+ suite.testAccounts = testrig.NewTestAccounts()
+ suite.testWebPushSubscriptions = testrig.NewTestWebPushSubscriptions()
+}
+
+func (suite *PushTestSuite) SetupTest() {
+ suite.state.Caches.Init()
+ testrig.StartNoopWorkers(&suite.state)
+
+ testrig.InitTestConfig()
+ config.Config(func(cfg *config.Configuration) {
+ cfg.WebAssetBaseDir = "../../../../web/assets/"
+ cfg.WebTemplateBaseDir = "../../../../web/templates/"
+ })
+ testrig.InitTestLog()
+
+ suite.db = testrig.NewTestDB(&suite.state)
+ suite.state.DB = suite.db
+ suite.storage = testrig.NewInMemoryStorage()
+ suite.state.Storage = suite.storage
+
+ suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
+ suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
+ suite.sentEmails = make(map[string]string)
+ suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails)
+ suite.processor = testrig.NewTestProcessor(
+ &suite.state,
+ suite.federator,
+ suite.emailSender,
+ testrig.NewNoopWebPushSender(),
+ suite.mediaManager,
+ )
+ suite.pushModule = push.New(suite.processor)
+
+ testrig.StandardDBSetup(suite.db, nil)
+ testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
+}
+
+func (suite *PushTestSuite) TearDownTest() {
+ testrig.StandardDBTeardown(suite.db)
+ testrig.StandardStorageTeardown(suite.storage)
+ testrig.StopWorkers(&suite.state)
+}
+
+func TestPushTestSuite(t *testing.T) {
+ suite.Run(t, new(PushTestSuite))
+}
diff --git a/internal/api/client/push/pushsubscriptiondelete.go b/internal/api/client/push/pushsubscriptiondelete.go
new file mode 100644
index 000000000..2a5fd8e69
--- /dev/null
+++ b/internal/api/client/push/pushsubscriptiondelete.go
@@ -0,0 +1,64 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package push
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// PushSubscriptionDELETEHandler swagger:operation DELETE /api/v1/push/subscription pushSubscriptionDelete
+//
+// Delete the Web Push subscription associated with the current auth token.
+// If there is no subscription, returns successfully anyway.
+//
+// ---
+// tags:
+// - push
+//
+// security:
+// - OAuth2 Bearer:
+// - push
+//
+// responses:
+// '200':
+// description: Push subscription deleted, or did not exist.
+// '400':
+// description: bad request
+// '401':
+// description: unauthorized
+// '500':
+// description: internal server error
+func (m *Module) PushSubscriptionDELETEHandler(c *gin.Context) {
+ authed, err := oauth.Authed(c, true, true, true, true)
+ if err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ if errWithCode := m.processor.Push().Delete(c.Request.Context(), authed.Token.GetAccess()); errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ apiutil.Data(c, http.StatusOK, apiutil.AppJSON, apiutil.EmptyJSONObject)
+}
diff --git a/internal/api/client/push/pushsubscriptiondelete_test.go b/internal/api/client/push/pushsubscriptiondelete_test.go
new file mode 100644
index 000000000..3e81ce2a1
--- /dev/null
+++ b/internal/api/client/push/pushsubscriptiondelete_test.go
@@ -0,0 +1,83 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package push_test
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/push"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+// deleteSubscription deletes the push subscription for the named account and token.
+func (suite *PushTestSuite) deleteSubscription(
+ accountFixtureName string,
+ tokenFixtureName string,
+ expectedHTTPStatus int,
+) error {
+ // instantiate recorder + test context
+ recorder := httptest.NewRecorder()
+ ctx, _ := testrig.CreateGinTestContext(recorder, nil)
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts[accountFixtureName])
+ ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens[tokenFixtureName]))
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers[accountFixtureName])
+
+ // create the request
+ requestUrl := config.GetProtocol() + "://" + config.GetHost() + "/api" + push.SubscriptionPath
+ ctx.Request = httptest.NewRequest(http.MethodDelete, requestUrl, nil)
+
+ // trigger the handler
+ suite.pushModule.PushSubscriptionDELETEHandler(ctx)
+
+ // read the response
+ result := recorder.Result()
+ defer func() {
+ _ = result.Body.Close()
+ }()
+
+ if resultCode := recorder.Code; expectedHTTPStatus != resultCode {
+ return fmt.Errorf("expected %d got %d", expectedHTTPStatus, resultCode)
+ }
+
+ return nil
+}
+
+// Delete a subscription that should exist.
+func (suite *PushTestSuite) TestDeleteSubscription() {
+ accountFixtureName := "local_account_1"
+ // This token should have a subscription associated with it already.
+ tokenFixtureName := "local_account_1"
+
+ err := suite.deleteSubscription(accountFixtureName, tokenFixtureName, 200)
+ suite.NoError(err)
+}
+
+// Delete a subscription that should not exist, which should succeed anyway.
+func (suite *PushTestSuite) TestDeleteMissingSubscription() {
+ accountFixtureName := "local_account_1"
+ // This token should not have a subscription.
+ tokenFixtureName := "local_account_1_user_authorization_token"
+
+ err := suite.deleteSubscription(accountFixtureName, tokenFixtureName, 200)
+ suite.NoError(err)
+}
diff --git a/internal/api/client/push/pushsubscriptionget.go b/internal/api/client/push/pushsubscriptionget.go
new file mode 100644
index 000000000..10774b862
--- /dev/null
+++ b/internal/api/client/push/pushsubscriptionget.go
@@ -0,0 +1,71 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package push
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// PushSubscriptionGETHandler swagger:operation GET /api/v1/push/subscription pushSubscriptionGet
+//
+// Get the push subscription for the current access token.
+//
+// ---
+// tags:
+// - push
+//
+// produces:
+// - application/json
+//
+// security:
+// - OAuth2 Bearer:
+// - push
+//
+// responses:
+// '200':
+// description: Web Push subscription for current access token.
+// schema:
+// "$ref": "#/definitions/webPushSubscription"
+// '400':
+// description: bad request
+// '401':
+// description: unauthorized
+// '404':
+// description: This access token doesn't have an associated subscription.
+// '500':
+// description: internal server error
+func (m *Module) PushSubscriptionGETHandler(c *gin.Context) {
+ authed, err := oauth.Authed(c, true, true, true, true)
+ if err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ apiSubscription, errWithCode := m.processor.Push().Get(c, authed.Token.GetAccess())
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ apiutil.JSON(c, http.StatusOK, apiSubscription)
+}
diff --git a/internal/api/client/push/pushsubscriptionget_test.go b/internal/api/client/push/pushsubscriptionget_test.go
new file mode 100644
index 000000000..23fb9e7f2
--- /dev/null
+++ b/internal/api/client/push/pushsubscriptionget_test.go
@@ -0,0 +1,102 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package push_test
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/push"
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+// getSubscription gets the push subscription for the named account and token.
+func (suite *PushTestSuite) getSubscription(
+ accountFixtureName string,
+ tokenFixtureName string,
+ expectedHTTPStatus int,
+) (*apimodel.WebPushSubscription, error) {
+ // instantiate recorder + test context
+ recorder := httptest.NewRecorder()
+ ctx, _ := testrig.CreateGinTestContext(recorder, nil)
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts[accountFixtureName])
+ ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens[tokenFixtureName]))
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers[accountFixtureName])
+
+ // create the request
+ requestUrl := config.GetProtocol() + "://" + config.GetHost() + "/api" + push.SubscriptionPath
+ ctx.Request = httptest.NewRequest(http.MethodGet, requestUrl, nil)
+ ctx.Request.Header.Set("accept", "application/json")
+
+ // trigger the handler
+ suite.pushModule.PushSubscriptionGETHandler(ctx)
+
+ // read the response
+ result := recorder.Result()
+ defer func() {
+ _ = result.Body.Close()
+ }()
+
+ b, err := io.ReadAll(result.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ if resultCode := recorder.Code; expectedHTTPStatus != resultCode {
+ return nil, fmt.Errorf("expected %d got %d", expectedHTTPStatus, resultCode)
+ }
+
+ resp := &apimodel.WebPushSubscription{}
+ if err := json.Unmarshal(b, resp); err != nil {
+ return nil, err
+ }
+
+ return resp, nil
+}
+
+// Get a subscription that should exist.
+func (suite *PushTestSuite) TestGetSubscription() {
+ accountFixtureName := "local_account_1"
+ // This token should have a subscription associated with it already, with all event types turned on.
+ tokenFixtureName := "local_account_1"
+
+ subscription, err := suite.getSubscription(accountFixtureName, tokenFixtureName, 200)
+ if suite.NoError(err) {
+ suite.NotEmpty(subscription.ID)
+ suite.NotEmpty(subscription.Endpoint)
+ suite.NotEmpty(subscription.ServerKey)
+ suite.True(subscription.Alerts.Mention)
+ }
+}
+
+// Get a subscription that should not exist, which should fail.
+func (suite *PushTestSuite) TestGetMissingSubscription() {
+ accountFixtureName := "local_account_1"
+ // This token should not have a subscription.
+ tokenFixtureName := "local_account_1_user_authorization_token"
+
+ _, err := suite.getSubscription(accountFixtureName, tokenFixtureName, 404)
+ suite.NoError(err)
+}
diff --git a/internal/api/client/push/pushsubscriptionpost.go b/internal/api/client/push/pushsubscriptionpost.go
new file mode 100644
index 000000000..a7e299894
--- /dev/null
+++ b/internal/api/client/push/pushsubscriptionpost.go
@@ -0,0 +1,284 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package push
+
+import (
+ "crypto/ecdh"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+
+ "github.com/gin-gonic/gin"
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// PushSubscriptionPOSTHandler swagger:operation POST /api/v1/push/subscription pushSubscriptionPost
+//
+// Create a new Web Push subscription for the current access token, or replace the existing one.
+//
+// ---
+// tags:
+// - push
+//
+// consumes:
+// - application/json
+// - application/x-www-form-urlencoded
+//
+// produces:
+// - application/json
+//
+// parameters:
+// -
+// name: subscription[endpoint]
+// in: formData
+// type: string
+// required: true
+// minLength: 1
+// description: The URL to which Web Push notifications will be sent.
+// -
+// name: subscription[keys][auth]
+// in: formData
+// type: string
+// required: true
+// minLength: 1
+// description: The auth secret, a Base64 encoded string of 16 bytes of random data.
+// -
+// name: subscription[keys][p256dh]
+// in: formData
+// type: string
+// required: true
+// minLength: 1
+// description: The user agent public key, a Base64 encoded string of a public key from an ECDH keypair using the prime256v1 curve.
+// -
+// name: data[alerts][follow]
+// in: formData
+// type: boolean
+// default: false
+// description: Receive a push notification when someone has followed you?
+// -
+// name: data[alerts][follow_request]
+// in: formData
+// type: boolean
+// default: false
+// description: Receive a push notification when someone has requested to follow you?
+// -
+// name: data[alerts][favourite]
+// in: formData
+// type: boolean
+// default: false
+// description: Receive a push notification when a status you created has been favourited by someone else?
+// -
+// name: data[alerts][mention]
+// in: formData
+// type: boolean
+// default: false
+// description: Receive a push notification when someone else has mentioned you in a status?
+// -
+// name: data[alerts][reblog]
+// in: formData
+// type: boolean
+// default: false
+// description: Receive a push notification when a status you created has been boosted by someone else?
+// -
+// name: data[alerts][poll]
+// in: formData
+// type: boolean
+// default: false
+// description: Receive a push notification when a poll you voted in or created has ended?
+// -
+// name: data[alerts][status]
+// in: formData
+// type: boolean
+// default: false
+// description: Receive a push notification when a subscribed account posts a status?
+// -
+// name: data[alerts][update]
+// in: formData
+// type: boolean
+// default: false
+// description: Receive a push notification when a status you interacted with has been edited?
+// -
+// name: data[alerts][admin.sign_up]
+// in: formData
+// type: boolean
+// default: false
+// description: Receive a push notification when a new user has signed up?
+// -
+// name: data[alerts][admin.report]
+// in: formData
+// type: boolean
+// default: false
+// description: Receive a push notification when a new report has been filed?
+// -
+// name: data[alerts][pending.favourite]
+// in: formData
+// type: boolean
+// default: false
+// description: Receive a push notification when a fave is pending?
+// -
+// name: data[alerts][pending.reply]
+// in: formData
+// type: boolean
+// default: false
+// description: Receive a push notification when a reply is pending?
+// -
+// name: data[alerts][pending.reblog]
+// in: formData
+// type: boolean
+// default: false
+// description: Receive a push notification when a boost is pending?
+//
+// security:
+// - OAuth2 Bearer:
+// - push
+//
+// responses:
+// '200':
+// description: Web Push subscription for current access token.
+// schema:
+// "$ref": "#/definitions/webPushSubscription"
+// '400':
+// description: bad request
+// '401':
+// description: unauthorized
+// '403':
+// description: forbidden
+// '404':
+// description: not found
+// '406':
+// description: not acceptable
+// '500':
+// description: internal server error
+func (m *Module) PushSubscriptionPOSTHandler(c *gin.Context) {
+ authed, err := oauth.Authed(c, true, true, true, true)
+ if err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ if authed.Account.IsMoving() {
+ apiutil.ForbiddenAfterMove(c)
+ return
+ }
+
+ if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ form := &apimodel.WebPushSubscriptionCreateRequest{}
+ if err := c.ShouldBind(form); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ if err := validateNormalizeCreate(form); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorUnprocessableEntity(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ apiSubscription, errWithCode := m.processor.Push().CreateOrReplace(c, authed.Account.ID, authed.Token.GetAccess(), form)
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ apiutil.JSON(c, http.StatusOK, apiSubscription)
+}
+
+// validateNormalizeCreate checks subscription endpoint format and keys decodability,
+// and copies form fields to their canonical JSON equivalents.
+func validateNormalizeCreate(request *apimodel.WebPushSubscriptionCreateRequest) error {
+ if request.Subscription == nil {
+ request.Subscription = &apimodel.WebPushSubscriptionRequestSubscription{}
+ }
+
+ // Normalize and validate endpoint URL.
+ if request.SubscriptionEndpoint != nil {
+ request.Subscription.Endpoint = *request.SubscriptionEndpoint
+ }
+
+ if request.Subscription.Endpoint == "" {
+ return errors.New("endpoint is required")
+ }
+ endpointURL, err := url.Parse(request.Subscription.Endpoint)
+ if err != nil {
+ return errors.New("endpoint must be a valid URL")
+ }
+ if endpointURL.Scheme != "https" {
+ return errors.New("endpoint must be an https:// URL")
+ }
+ if endpointURL.Host == "" {
+ return errors.New("endpoint URL must have a host")
+ }
+ if endpointURL.Fragment != "" {
+ return errors.New("endpoint URL must not have a fragment")
+ }
+
+ // Normalize and validate auth secret.
+ if request.SubscriptionKeysAuth != nil {
+ request.Subscription.Keys.Auth = *request.SubscriptionKeysAuth
+ }
+
+ authBytes, err := base64DecodeAny("auth", request.Subscription.Keys.Auth)
+ if err != nil {
+ return err
+ }
+ if len(authBytes) != 16 {
+ return fmt.Errorf("auth must be 16 bytes long, got %d", len(authBytes))
+ }
+
+ // Normalize and validate public key.
+ if request.SubscriptionKeysP256dh != nil {
+ request.Subscription.Keys.P256dh = *request.SubscriptionKeysP256dh
+ }
+
+ p256dhBytes, err := base64DecodeAny("p256dh", request.Subscription.Keys.P256dh)
+ if err != nil {
+ return err
+ }
+ _, err = ecdh.P256().NewPublicKey(p256dhBytes)
+ if err != nil {
+ return fmt.Errorf("p256dh must be a valid public key on the NIST P-256 curve: %w", err)
+ }
+
+ return validateNormalizeUpdate(&request.WebPushSubscriptionUpdateRequest)
+}
+
+// base64DecodeAny tries decoding a string with standard and URL alphabets of Base64, with and without padding.
+func base64DecodeAny(name string, value string) ([]byte, error) {
+ encodings := []*base64.Encoding{
+ base64.StdEncoding,
+ base64.URLEncoding,
+ base64.RawStdEncoding,
+ base64.RawURLEncoding,
+ }
+
+ for _, encoding := range encodings {
+ if bytes, err := encoding.DecodeString(value); err == nil {
+ return bytes, nil
+ }
+ }
+
+ return nil, fmt.Errorf("%s is not valid Base64 data", name)
+}
diff --git a/internal/api/client/push/pushsubscriptionpost_test.go b/internal/api/client/push/pushsubscriptionpost_test.go
new file mode 100644
index 000000000..bdd22d729
--- /dev/null
+++ b/internal/api/client/push/pushsubscriptionpost_test.go
@@ -0,0 +1,346 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package push_test
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strconv"
+ "strings"
+
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/push"
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+// postSubscription creates or replaces the push subscription for the named account and token.
+// It only allows updating two event types if using the form API. Add more if you need them.
+func (suite *PushTestSuite) postSubscription(
+ accountFixtureName string,
+ tokenFixtureName string,
+ endpoint *string,
+ auth *string,
+ p256dh *string,
+ alertsMention *bool,
+ alertsStatus *bool,
+ requestJson *string,
+ expectedHTTPStatus int,
+) (*apimodel.WebPushSubscription, error) {
+ // instantiate recorder + test context
+ recorder := httptest.NewRecorder()
+ ctx, _ := testrig.CreateGinTestContext(recorder, nil)
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts[accountFixtureName])
+ ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens[tokenFixtureName]))
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers[accountFixtureName])
+
+ // create the request
+ requestUrl := config.GetProtocol() + "://" + config.GetHost() + "/api" + push.SubscriptionPath
+ ctx.Request = httptest.NewRequest(http.MethodPost, requestUrl, nil)
+ ctx.Request.Header.Set("accept", "application/json")
+
+ if requestJson != nil {
+ ctx.Request.Header.Set("content-type", "application/json")
+ ctx.Request.Body = io.NopCloser(strings.NewReader(*requestJson))
+ } else {
+ ctx.Request.Form = make(url.Values)
+ if endpoint != nil {
+ ctx.Request.Form["subscription[endpoint]"] = []string{*endpoint}
+ }
+ if auth != nil {
+ ctx.Request.Form["subscription[keys][auth]"] = []string{*auth}
+ }
+ if p256dh != nil {
+ ctx.Request.Form["subscription[keys][p256dh]"] = []string{*p256dh}
+ }
+ if alertsMention != nil {
+ ctx.Request.Form["data[alerts][mention]"] = []string{strconv.FormatBool(*alertsMention)}
+ }
+ if alertsStatus != nil {
+ ctx.Request.Form["data[alerts][status]"] = []string{strconv.FormatBool(*alertsStatus)}
+ }
+ }
+
+ // trigger the handler
+ suite.pushModule.PushSubscriptionPOSTHandler(ctx)
+
+ // read the response
+ result := recorder.Result()
+ defer func() {
+ _ = result.Body.Close()
+ }()
+
+ b, err := io.ReadAll(result.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ if resultCode := recorder.Code; expectedHTTPStatus != resultCode {
+ return nil, fmt.Errorf("expected %d got %d", expectedHTTPStatus, resultCode)
+ }
+
+ resp := &apimodel.WebPushSubscription{}
+ if err := json.Unmarshal(b, resp); err != nil {
+ return nil, err
+ }
+
+ return resp, nil
+}
+
+// Create a new subscription.
+func (suite *PushTestSuite) TestPostSubscription() {
+ accountFixtureName := "local_account_1"
+ // This token should not have a subscription.
+ tokenFixtureName := "local_account_1_user_authorization_token"
+
+ endpoint := "https://example.test/push"
+ auth := "cgna/fzrYLDQyPf5hD7IsA=="
+ p256dh := "BMYVItYVOX+AHBdtA62Q0i6c+F7MV2Gia3aoDr8mvHkuPBNIOuTLDfmFcnBqoZcQk6BtLcIONbxhHpy2R+mYIUY="
+ alertsMention := true
+ alertsStatus := false
+ subscription, err := suite.postSubscription(
+ accountFixtureName,
+ tokenFixtureName,
+ &endpoint,
+ &auth,
+ &p256dh,
+ &alertsMention,
+ &alertsStatus,
+ nil,
+ 200,
+ )
+ if suite.NoError(err) {
+ suite.NotEmpty(subscription.ID)
+ suite.NotEmpty(subscription.Endpoint)
+ suite.NotEmpty(subscription.ServerKey)
+ suite.True(subscription.Alerts.Mention)
+ suite.False(subscription.Alerts.Status)
+ // Omitted event types should default to off.
+ suite.False(subscription.Alerts.Favourite)
+ }
+}
+
+// Create a new subscription with only required fields.
+func (suite *PushTestSuite) TestPostSubscriptionMinimal() {
+ accountFixtureName := "local_account_1"
+ // This token should not have a subscription.
+ tokenFixtureName := "local_account_1_user_authorization_token"
+
+ endpoint := "https://example.test/push"
+ auth := "cgna/fzrYLDQyPf5hD7IsA=="
+ p256dh := "BMYVItYVOX+AHBdtA62Q0i6c+F7MV2Gia3aoDr8mvHkuPBNIOuTLDfmFcnBqoZcQk6BtLcIONbxhHpy2R+mYIUY="
+ subscription, err := suite.postSubscription(
+ accountFixtureName,
+ tokenFixtureName,
+ &endpoint,
+ &auth,
+ &p256dh,
+ nil,
+ nil,
+ nil,
+ 200,
+ )
+ if suite.NoError(err) {
+ suite.NotEmpty(subscription.ID)
+ suite.NotEmpty(subscription.Endpoint)
+ suite.NotEmpty(subscription.ServerKey)
+ // All event types should default to off.
+ suite.False(subscription.Alerts.Mention)
+ suite.False(subscription.Alerts.Status)
+ suite.False(subscription.Alerts.Favourite)
+ }
+}
+
+// Create a new subscription with a missing endpoint, which should fail.
+func (suite *PushTestSuite) TestPostInvalidSubscription() {
+ accountFixtureName := "local_account_1"
+ // This token should not have a subscription.
+ tokenFixtureName := "local_account_1_user_authorization_token"
+
+ // No endpoint.
+ auth := "cgna/fzrYLDQyPf5hD7IsA=="
+ p256dh := "BMYVItYVOX+AHBdtA62Q0i6c+F7MV2Gia3aoDr8mvHkuPBNIOuTLDfmFcnBqoZcQk6BtLcIONbxhHpy2R+mYIUY="
+ alertsMention := true
+ alertsStatus := false
+ _, err := suite.postSubscription(
+ accountFixtureName,
+ tokenFixtureName,
+ nil,
+ &auth,
+ &p256dh,
+ &alertsMention,
+ &alertsStatus,
+ nil,
+ 422,
+ )
+ suite.NoError(err)
+}
+
+// Create a new subscription, using the JSON format.
+func (suite *PushTestSuite) TestPostSubscriptionJSON() {
+ accountFixtureName := "local_account_1"
+ // This token should not have a subscription.
+ tokenFixtureName := "local_account_1_user_authorization_token"
+
+ requestJson := `{
+ "subscription": {
+ "endpoint": "https://example.test/push",
+ "keys": {
+ "auth": "cgna/fzrYLDQyPf5hD7IsA==",
+ "p256dh": "BMYVItYVOX+AHBdtA62Q0i6c+F7MV2Gia3aoDr8mvHkuPBNIOuTLDfmFcnBqoZcQk6BtLcIONbxhHpy2R+mYIUY="
+ }
+ },
+ "data": {
+ "alerts": {
+ "mention": true,
+ "status": false
+ }
+ }
+ }`
+ subscription, err := suite.postSubscription(
+ accountFixtureName,
+ tokenFixtureName,
+ nil,
+ nil,
+ nil,
+ nil,
+ nil,
+ &requestJson,
+ 200,
+ )
+ if suite.NoError(err) {
+ suite.NotEmpty(subscription.ID)
+ suite.NotEmpty(subscription.Endpoint)
+ suite.NotEmpty(subscription.ServerKey)
+ suite.True(subscription.Alerts.Mention)
+ suite.False(subscription.Alerts.Status)
+ // Omitted event types should default to off.
+ suite.False(subscription.Alerts.Favourite)
+ }
+}
+
+// Create a new subscription, using the JSON format and only required fields.
+func (suite *PushTestSuite) TestPostSubscriptionJSONMinimal() {
+ accountFixtureName := "local_account_1"
+ // This token should not have a subscription.
+ tokenFixtureName := "local_account_1_user_authorization_token"
+
+ requestJson := `{
+ "subscription": {
+ "endpoint": "https://example.test/push",
+ "keys": {
+ "auth": "cgna/fzrYLDQyPf5hD7IsA==",
+ "p256dh": "BMYVItYVOX+AHBdtA62Q0i6c+F7MV2Gia3aoDr8mvHkuPBNIOuTLDfmFcnBqoZcQk6BtLcIONbxhHpy2R+mYIUY="
+ }
+ }
+ }`
+ subscription, err := suite.postSubscription(
+ accountFixtureName,
+ tokenFixtureName,
+ nil,
+ nil,
+ nil,
+ nil,
+ nil,
+ &requestJson,
+ 200,
+ )
+ if suite.NoError(err) {
+ suite.NotEmpty(subscription.ID)
+ suite.NotEmpty(subscription.Endpoint)
+ suite.NotEmpty(subscription.ServerKey)
+ // All event types should default to off.
+ suite.False(subscription.Alerts.Mention)
+ suite.False(subscription.Alerts.Status)
+ suite.False(subscription.Alerts.Favourite)
+ }
+}
+
+// Create a new subscription with a missing endpoint, using the JSON format, which should fail.
+func (suite *PushTestSuite) TestPostInvalidSubscriptionJSON() {
+ accountFixtureName := "local_account_1"
+ // This token should not have a subscription.
+ tokenFixtureName := "local_account_1_user_authorization_token"
+
+ // No endpoint.
+ requestJson := `{
+ "subscription": {
+ "keys": {
+ "auth": "cgna/fzrYLDQyPf5hD7IsA==",
+ "p256dh": "BMYVItYVOX+AHBdtA62Q0i6c+F7MV2Gia3aoDr8mvHkuPBNIOuTLDfmFcnBqoZcQk6BtLcIONbxhHpy2R+mYIUY="
+ }
+ },
+ "data": {
+ "alerts": {
+ "mention": true,
+ "status": false
+ }
+ }
+ }`
+ _, err := suite.postSubscription(
+ accountFixtureName,
+ tokenFixtureName,
+ nil,
+ nil,
+ nil,
+ nil,
+ nil,
+ &requestJson,
+ 422,
+ )
+ suite.NoError(err)
+}
+
+// Replace a subscription that already exists.
+func (suite *PushTestSuite) TestPostExistingSubscription() {
+ accountFixtureName := "local_account_1"
+ // This token should have a subscription associated with it already, with all event types turned on.
+ tokenFixtureName := "local_account_1"
+
+ endpoint := "https://example.test/push"
+ auth := "JMFtMRgZaeHpwsDjBnhcmQ=="
+ p256dh := "BMYVItYVOX+AHBdtA62Q0i6c+F7MV2Gia3aoDr8mvHkuPBNIOuTLDfmFcnBqoZcQk6BtLcIONbxhHpy2R+mYIUY="
+ alertsMention := true
+ alertsStatus := false
+ subscription, err := suite.postSubscription(
+ accountFixtureName,
+ tokenFixtureName,
+ &endpoint,
+ &auth,
+ &p256dh,
+ &alertsMention,
+ &alertsStatus,
+ nil,
+ 200,
+ )
+ if suite.NoError(err) {
+ suite.NotEqual(suite.testWebPushSubscriptions["local_account_1_token_1"].ID, subscription.ID)
+ suite.NotEmpty(subscription.Endpoint)
+ suite.NotEmpty(subscription.ServerKey)
+ suite.True(subscription.Alerts.Mention)
+ suite.False(subscription.Alerts.Status)
+ // Omitted event types should default to off.
+ suite.False(subscription.Alerts.Favourite)
+ }
+}
diff --git a/internal/api/client/push/pushsubscriptionput.go b/internal/api/client/push/pushsubscriptionput.go
new file mode 100644
index 000000000..06575f4ee
--- /dev/null
+++ b/internal/api/client/push/pushsubscriptionput.go
@@ -0,0 +1,232 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package push
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// PushSubscriptionPUTHandler swagger:operation PUT /api/v1/push/subscription pushSubscriptionPut
+//
+// Update the Web Push subscription for the current access token.
+// Only which notifications you receive can be updated.
+//
+// ---
+// tags:
+// - push
+//
+// consumes:
+// - application/json
+// - application/x-www-form-urlencoded
+//
+// produces:
+// - application/json
+//
+// parameters:
+// -
+// name: data[alerts][follow]
+// in: formData
+// type: boolean
+// default: false
+// description: Receive a push notification when someone has followed you?
+// -
+// name: data[alerts][follow_request]
+// in: formData
+// type: boolean
+// default: false
+// description: Receive a push notification when someone has requested to follow you?
+// -
+// name: data[alerts][favourite]
+// in: formData
+// type: boolean
+// default: false
+// description: Receive a push notification when a status you created has been favourited by someone else?
+// -
+// name: data[alerts][mention]
+// in: formData
+// type: boolean
+// default: false
+// description: Receive a push notification when someone else has mentioned you in a status?
+// -
+// name: data[alerts][reblog]
+// in: formData
+// type: boolean
+// default: false
+// description: Receive a push notification when a status you created has been boosted by someone else?
+// -
+// name: data[alerts][poll]
+// in: formData
+// type: boolean
+// default: false
+// description: Receive a push notification when a poll you voted in or created has ended?
+// -
+// name: data[alerts][status]
+// in: formData
+// type: boolean
+// default: false
+// description: Receive a push notification when a subscribed account posts a status?
+// -
+// name: data[alerts][update]
+// in: formData
+// type: boolean
+// default: false
+// description: Receive a push notification when a status you interacted with has been edited?
+// -
+// name: data[alerts][admin.sign_up]
+// in: formData
+// type: boolean
+// default: false
+// description: Receive a push notification when a new user has signed up?
+// -
+// name: data[alerts][admin.report]
+// in: formData
+// type: boolean
+// default: false
+// description: Receive a push notification when a new report has been filed?
+// -
+// name: data[alerts][pending.favourite]
+// in: formData
+// type: boolean
+// default: false
+// description: Receive a push notification when a fave is pending?
+// -
+// name: data[alerts][pending.reply]
+// in: formData
+// type: boolean
+// default: false
+// description: Receive a push notification when a reply is pending?
+// -
+// name: data[alerts][pending.reblog]
+// in: formData
+// type: boolean
+// default: false
+// description: Receive a push notification when a boost is pending?
+//
+// security:
+// - OAuth2 Bearer:
+// - push
+//
+// responses:
+// '200':
+// description: Web Push subscription for current access token.
+// schema:
+// "$ref": "#/definitions/webPushSubscription"
+// '400':
+// description: bad request
+// '401':
+// description: unauthorized
+// '403':
+// description: forbidden
+// '404':
+// description: This access token doesn't have an associated subscription.
+// '406':
+// description: not acceptable
+// '500':
+// description: internal server error
+func (m *Module) PushSubscriptionPUTHandler(c *gin.Context) {
+ authed, err := oauth.Authed(c, true, true, true, true)
+ if err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ if authed.Account.IsMoving() {
+ apiutil.ForbiddenAfterMove(c)
+ return
+ }
+
+ if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ form := &apimodel.WebPushSubscriptionUpdateRequest{}
+ if err := c.ShouldBind(form); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ if err := validateNormalizeUpdate(form); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorUnprocessableEntity(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ apiSubscription, errWithCode := m.processor.Push().Update(c, authed.Token.GetAccess(), form)
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ apiutil.JSON(c, http.StatusOK, apiSubscription)
+}
+
+// validateNormalizeUpdate copies form fields to their canonical JSON equivalents.
+func validateNormalizeUpdate(request *apimodel.WebPushSubscriptionUpdateRequest) error {
+ if request.Data == nil {
+ request.Data = &apimodel.WebPushSubscriptionRequestData{}
+ }
+
+ if request.Data.Alerts == nil {
+ request.Data.Alerts = &apimodel.WebPushSubscriptionAlerts{}
+ }
+
+ if request.DataAlertsFollow != nil {
+ request.Data.Alerts.Follow = *request.DataAlertsFollow
+ }
+ if request.DataAlertsFollowRequest != nil {
+ request.Data.Alerts.FollowRequest = *request.DataAlertsFollowRequest
+ }
+ if request.DataAlertsMention != nil {
+ request.Data.Alerts.Mention = *request.DataAlertsMention
+ }
+ if request.DataAlertsReblog != nil {
+ request.Data.Alerts.Reblog = *request.DataAlertsReblog
+ }
+ if request.DataAlertsPoll != nil {
+ request.Data.Alerts.Poll = *request.DataAlertsPoll
+ }
+ if request.DataAlertsStatus != nil {
+ request.Data.Alerts.Status = *request.DataAlertsStatus
+ }
+ if request.DataAlertsUpdate != nil {
+ request.Data.Alerts.Update = *request.DataAlertsUpdate
+ }
+ if request.DataAlertsAdminSignup != nil {
+ request.Data.Alerts.AdminSignup = *request.DataAlertsAdminSignup
+ }
+ if request.DataAlertsAdminReport != nil {
+ request.Data.Alerts.AdminReport = *request.DataAlertsAdminReport
+ }
+ if request.DataAlertsPendingFavourite != nil {
+ request.Data.Alerts.PendingFavourite = *request.DataAlertsPendingFavourite
+ }
+ if request.DataAlertsPendingReply != nil {
+ request.Data.Alerts.PendingReply = *request.DataAlertsPendingReply
+ }
+ if request.DataAlertsPendingReblog != nil {
+ request.Data.Alerts.Reblog = *request.DataAlertsPendingReblog
+ }
+
+ return nil
+}
diff --git a/internal/api/client/push/pushsubscriptionput_test.go b/internal/api/client/push/pushsubscriptionput_test.go
new file mode 100644
index 000000000..924e3d475
--- /dev/null
+++ b/internal/api/client/push/pushsubscriptionput_test.go
@@ -0,0 +1,176 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package push_test
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strconv"
+ "strings"
+
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/push"
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+// putSubscription updates the push subscription for the named account and token.
+// It only allows updating two event types if using the form API. Add more if you need them.
+func (suite *PushTestSuite) putSubscription(
+ accountFixtureName string,
+ tokenFixtureName string,
+ alertsMention *bool,
+ alertsStatus *bool,
+ requestJson *string,
+ expectedHTTPStatus int,
+) (*apimodel.WebPushSubscription, error) {
+ // instantiate recorder + test context
+ recorder := httptest.NewRecorder()
+ ctx, _ := testrig.CreateGinTestContext(recorder, nil)
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts[accountFixtureName])
+ ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens[tokenFixtureName]))
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers[accountFixtureName])
+
+ // create the request
+ requestUrl := config.GetProtocol() + "://" + config.GetHost() + "/api" + push.SubscriptionPath
+ ctx.Request = httptest.NewRequest(http.MethodPut, requestUrl, nil)
+ ctx.Request.Header.Set("accept", "application/json")
+
+ if requestJson != nil {
+ ctx.Request.Header.Set("content-type", "application/json")
+ ctx.Request.Body = io.NopCloser(strings.NewReader(*requestJson))
+ } else {
+ ctx.Request.Form = make(url.Values)
+ if alertsMention != nil {
+ ctx.Request.Form["data[alerts][mention]"] = []string{strconv.FormatBool(*alertsMention)}
+ }
+ if alertsStatus != nil {
+ ctx.Request.Form["data[alerts][status]"] = []string{strconv.FormatBool(*alertsStatus)}
+ }
+ }
+
+ // trigger the handler
+ suite.pushModule.PushSubscriptionPUTHandler(ctx)
+
+ // read the response
+ result := recorder.Result()
+ defer func() {
+ _ = result.Body.Close()
+ }()
+
+ b, err := io.ReadAll(result.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ if resultCode := recorder.Code; expectedHTTPStatus != resultCode {
+ return nil, fmt.Errorf("expected %d got %d", expectedHTTPStatus, resultCode)
+ }
+
+ resp := &apimodel.WebPushSubscription{}
+ if err := json.Unmarshal(b, resp); err != nil {
+ return nil, err
+ }
+
+ return resp, nil
+}
+
+// Update a subscription that already exists.
+func (suite *PushTestSuite) TestPutSubscription() {
+ accountFixtureName := "local_account_1"
+ // This token should have a subscription associated with it already, with all event types turned on.
+ tokenFixtureName := "local_account_1"
+
+ alertsMention := true
+ alertsStatus := false
+ subscription, err := suite.putSubscription(
+ accountFixtureName,
+ tokenFixtureName,
+ &alertsMention,
+ &alertsStatus,
+ nil,
+ 200,
+ )
+ if suite.NoError(err) {
+ suite.NotEmpty(subscription.ID)
+ suite.NotEmpty(subscription.Endpoint)
+ suite.NotEmpty(subscription.ServerKey)
+ suite.True(subscription.Alerts.Mention)
+ suite.False(subscription.Alerts.Status)
+ // Omitted event types should default to off.
+ suite.False(subscription.Alerts.Favourite)
+ }
+}
+
+// Update a subscription that already exists, using the JSON format.
+func (suite *PushTestSuite) TestPutSubscriptionJSON() {
+ accountFixtureName := "local_account_1"
+ // This token should have a subscription associated with it already, with all event types turned on.
+ tokenFixtureName := "local_account_1"
+
+ requestJson := `{
+ "data": {
+ "alerts": {
+ "mention": true,
+ "status": false
+ }
+ }
+ }`
+ subscription, err := suite.putSubscription(
+ accountFixtureName,
+ tokenFixtureName,
+ nil,
+ nil,
+ &requestJson,
+ 200,
+ )
+ if suite.NoError(err) {
+ suite.NotEmpty(subscription.ID)
+ suite.NotEmpty(subscription.Endpoint)
+ suite.NotEmpty(subscription.ServerKey)
+ suite.True(subscription.Alerts.Mention)
+ suite.False(subscription.Alerts.Status)
+ // Omitted event types should default to off.
+ suite.False(subscription.Alerts.Favourite)
+ }
+}
+
+// Update a subscription that does not exist, which should fail.
+func (suite *PushTestSuite) TestPutMissingSubscription() {
+ accountFixtureName := "local_account_1"
+ // This token should not have a subscription.
+ tokenFixtureName := "local_account_1_user_authorization_token"
+
+ alertsMention := true
+ alertsStatus := false
+ _, err := suite.putSubscription(
+ accountFixtureName,
+ tokenFixtureName,
+ &alertsMention,
+ &alertsStatus,
+ nil,
+ 404,
+ )
+ suite.NoError(err)
+}
diff --git a/internal/api/client/reports/reports_test.go b/internal/api/client/reports/reports_test.go
index 031fd953a..89240a4b1 100644
--- a/internal/api/client/reports/reports_test.go
+++ b/internal/api/client/reports/reports_test.go
@@ -91,7 +91,13 @@ func (suite *ReportsStandardTestSuite) SetupTest() {
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
suite.sentEmails = make(map[string]string)
suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails)
- suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager)
+ suite.processor = testrig.NewTestProcessor(
+ &suite.state,
+ suite.federator,
+ suite.emailSender,
+ testrig.NewNoopWebPushSender(),
+ suite.mediaManager,
+ )
suite.reportsModule = reports.New(suite.processor)
testrig.StandardDBSetup(suite.db, nil)
testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
diff --git a/internal/api/client/search/search_test.go b/internal/api/client/search/search_test.go
index fecb30d38..219966c7c 100644
--- a/internal/api/client/search/search_test.go
+++ b/internal/api/client/search/search_test.go
@@ -95,7 +95,13 @@ func (suite *SearchStandardTestSuite) SetupTest() {
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
suite.sentEmails = make(map[string]string)
suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails)
- suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager)
+ suite.processor = testrig.NewTestProcessor(
+ &suite.state,
+ suite.federator,
+ suite.emailSender,
+ testrig.NewNoopWebPushSender(),
+ suite.mediaManager,
+ )
suite.searchModule = search.New(suite.processor)
testrig.StandardDBSetup(suite.db, nil)
testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
diff --git a/internal/api/client/statuses/status_test.go b/internal/api/client/statuses/status_test.go
index 5a4473344..c5f2838e8 100644
--- a/internal/api/client/statuses/status_test.go
+++ b/internal/api/client/statuses/status_test.go
@@ -211,7 +211,13 @@ func (suite *StatusStandardTestSuite) SetupTest() {
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil)
- suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager)
+ suite.processor = testrig.NewTestProcessor(
+ &suite.state,
+ suite.federator,
+ suite.emailSender,
+ testrig.NewNoopWebPushSender(),
+ suite.mediaManager,
+ )
suite.statusModule = statuses.New(suite.processor)
testrig.StartWorkers(&suite.state, suite.processor.Workers())
diff --git a/internal/api/client/streaming/streaming_test.go b/internal/api/client/streaming/streaming_test.go
index 03cd6f434..00ad2de03 100644
--- a/internal/api/client/streaming/streaming_test.go
+++ b/internal/api/client/streaming/streaming_test.go
@@ -111,7 +111,13 @@ func (suite *StreamingTestSuite) SetupTest() {
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil)
- suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager)
+ suite.processor = testrig.NewTestProcessor(
+ &suite.state,
+ suite.federator,
+ suite.emailSender,
+ testrig.NewNoopWebPushSender(),
+ suite.mediaManager,
+ )
suite.streamingModule = streaming.New(suite.processor, 1, 4096)
}
diff --git a/internal/api/client/tags/tags_test.go b/internal/api/client/tags/tags_test.go
index 71a84435a..c24574d47 100644
--- a/internal/api/client/tags/tags_test.go
+++ b/internal/api/client/tags/tags_test.go
@@ -96,7 +96,13 @@ func (suite *TagsTestSuite) SetupTest() {
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
suite.sentEmails = make(map[string]string)
suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails)
- suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager)
+ suite.processor = testrig.NewTestProcessor(
+ &suite.state,
+ suite.federator,
+ suite.emailSender,
+ testrig.NewNoopWebPushSender(),
+ suite.mediaManager,
+ )
suite.tagsModule = tags.New(suite.processor)
testrig.StandardDBSetup(suite.db, nil)
diff --git a/internal/api/client/user/emailchange_test.go b/internal/api/client/user/emailchange_test.go
index fce96c144..868ad766c 100644
--- a/internal/api/client/user/emailchange_test.go
+++ b/internal/api/client/user/emailchange_test.go
@@ -44,7 +44,8 @@ func (suite *EmailChangeTestSuite) TestEmailChangePOST() {
storage := testrig.NewInMemoryStorage()
sentEmails := make(map[string]string)
emailSender := testrig.NewEmailSender("../../../../web/template/", sentEmails)
- processor := testrig.NewTestProcessor(state, suite.federator, emailSender, suite.mediaManager)
+ webPushSender := testrig.NewNoopWebPushSender()
+ processor := testrig.NewTestProcessor(state, suite.federator, emailSender, webPushSender, suite.mediaManager)
testrig.StartWorkers(state, processor.Workers())
userModule := user.New(processor)
testrig.StandardDBSetup(state.DB, suite.testAccounts)
diff --git a/internal/api/client/user/user_test.go b/internal/api/client/user/user_test.go
index 320c743f8..8cf359cd8 100644
--- a/internal/api/client/user/user_test.go
+++ b/internal/api/client/user/user_test.go
@@ -86,8 +86,21 @@ func (suite *UserStandardTestSuite) SetupTest() {
)
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
- suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
- suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, testrig.NewEmailSender("../../../../web/template/", nil), suite.mediaManager)
+ suite.federator = testrig.NewTestFederator(
+ &suite.state,
+ testrig.NewTestTransportController(
+ &suite.state,
+ testrig.NewMockHTTPClient(nil, "../../../../testrig/media"),
+ ),
+ suite.mediaManager,
+ )
+ suite.processor = testrig.NewTestProcessor(
+ &suite.state,
+ suite.federator,
+ testrig.NewEmailSender("../../../../web/template/", nil),
+ testrig.NewNoopWebPushSender(),
+ suite.mediaManager,
+ )
suite.userModule = user.New(suite.processor)
testrig.StandardDBSetup(suite.db, suite.testAccounts)
testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
diff --git a/internal/api/fileserver/fileserver_test.go b/internal/api/fileserver/fileserver_test.go
index af6f125dc..9b0580e92 100644
--- a/internal/api/fileserver/fileserver_test.go
+++ b/internal/api/fileserver/fileserver_test.go
@@ -75,8 +75,21 @@ func (suite *FileserverTestSuite) SetupSuite() {
suite.state.Storage = suite.storage
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
- suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../testrig/media")), suite.mediaManager)
- suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager)
+ suite.federator = testrig.NewTestFederator(
+ &suite.state,
+ testrig.NewTestTransportController(
+ &suite.state,
+ testrig.NewMockHTTPClient(nil, "../../../testrig/media"),
+ ),
+ suite.mediaManager,
+ )
+ suite.processor = testrig.NewTestProcessor(
+ &suite.state,
+ suite.federator,
+ suite.emailSender,
+ testrig.NewNoopWebPushSender(),
+ suite.mediaManager,
+ )
suite.tc = typeutils.NewConverter(&suite.state)
diff --git a/internal/api/model/instancev2.go b/internal/api/model/instancev2.go
index 982ed0c63..b3d11dee2 100644
--- a/internal/api/model/instancev2.go
+++ b/internal/api/model/instancev2.go
@@ -174,6 +174,8 @@ type InstanceV2Configuration struct {
Emojis InstanceConfigurationEmojis `json:"emojis"`
// True if instance is running with OIDC as auth/identity backend, else omitted.
OIDCEnabled bool `json:"oidc_enabled,omitempty"`
+ // Instance VAPID configuration.
+ VAPID InstanceV2ConfigurationVAPID `json:"vapid"`
}
// Information about registering for this instance.
@@ -204,3 +206,11 @@ type InstanceV2Contact struct {
// Key/value not present if no contact account set.
Account *Account `json:"account,omitempty"`
}
+
+// InstanceV2ConfigurationVAPID holds the instance's VAPID configuration.
+//
+// swagger:model instanceV2ConfigurationVAPID
+type InstanceV2ConfigurationVAPID struct {
+ // The instance's VAPID public key, Base64-encoded.
+ PublicKey string `json:"public_key"`
+}
diff --git a/internal/api/model/pushsubscription.go b/internal/api/model/pushsubscription.go
deleted file mode 100644
index 1e7708d94..000000000
--- a/internal/api/model/pushsubscription.go
+++ /dev/null
@@ -1,44 +0,0 @@
-// GoToSocial
-// Copyright (C) GoToSocial Authors admin@gotosocial.org
-// SPDX-License-Identifier: AGPL-3.0-or-later
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-package model
-
-// PushSubscription represents a subscription to the push streaming server.
-type PushSubscription struct {
- // The id of the push subscription in the database.
- ID string `json:"id"`
- // Where push alerts will be sent to.
- Endpoint string `json:"endpoint"`
- // The streaming server's VAPID key.
- ServerKey string `json:"server_key"`
- // Which alerts should be delivered to the endpoint.
- Alerts *PushSubscriptionAlerts `json:"alerts"`
-}
-
-// PushSubscriptionAlerts represents the specific alerts that this push subscription will give.
-type PushSubscriptionAlerts struct {
- // Receive a push notification when someone has followed you?
- Follow bool `json:"follow"`
- // Receive a push notification when a status you created has been favourited by someone else?
- Favourite bool `json:"favourite"`
- // Receive a push notification when someone else has mentioned you in a status?
- Mention bool `json:"mention"`
- // Receive a push notification when a status you created has been boosted by someone else?
- Reblog bool `json:"reblog"`
- // Receive a push notification when a poll you voted in or created has ended?
- Poll bool `json:"poll"`
-}
diff --git a/internal/api/model/webpushnotification.go b/internal/api/model/webpushnotification.go
new file mode 100644
index 000000000..5d7a593fc
--- /dev/null
+++ b/internal/api/model/webpushnotification.go
@@ -0,0 +1,52 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package model
+
+// WebPushNotification represents a notification summary delivered to the client by the Web Push server.
+// It does not contain an entire Notification, just the NotificationID and some preview information.
+// It is not used in the client API directly, but is included in the API doc for decoding Web Push notifications.
+//
+// swagger:model webPushNotification
+type WebPushNotification struct {
+ // NotificationID is the Notification.ID of the referenced Notification.
+ NotificationID string `json:"notification_id"`
+
+ // NotificationType is the Notification.Type of the referenced Notification.
+ NotificationType string `json:"notification_type"`
+
+ // Title is a title for the notification,
+ // generally describing an action taken by a user.
+ Title string `json:"title"`
+
+ // Body is a preview of the notification body,
+ // such as the first line of a status's CW or text,
+ // or the first line of an account bio.
+ Body string `json:"body"`
+
+ // Icon is an image URL that can be displayed with the notification,
+ // normally the account's avatar.
+ Icon string `json:"icon"`
+
+ // PreferredLocale is a BCP 47 language tag for the receiving user's locale.
+ PreferredLocale string `json:"preferred_locale"`
+
+ // AccessToken is the access token associated with the Web Push subscription.
+ // I don't know why this is sent, given that the client should know that already,
+ // but Feditext does use it.
+ AccessToken string `json:"access_token"`
+}
diff --git a/internal/api/model/webpushsubscription.go b/internal/api/model/webpushsubscription.go
new file mode 100644
index 000000000..a28bb7294
--- /dev/null
+++ b/internal/api/model/webpushsubscription.go
@@ -0,0 +1,157 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package model
+
+// WebPushSubscription represents a subscription to a Web Push server.
+//
+// swagger:model webPushSubscription
+type WebPushSubscription struct {
+ // The id of the push subscription in the database.
+ ID string `json:"id"`
+
+ // Where push alerts will be sent to.
+ Endpoint string `json:"endpoint"`
+
+ // The streaming server's VAPID public key.
+ ServerKey string `json:"server_key"`
+
+ // Which alerts should be delivered to the endpoint.
+ Alerts WebPushSubscriptionAlerts `json:"alerts"`
+
+ // Which accounts should generate notifications.
+ Policy WebPushNotificationPolicy `json:"policy"`
+
+ // Whether the subscription uses RFC or pre-RFC Web Push standards.
+ // For GotoSocial, this is always true.
+ Standard bool `json:"standard"`
+}
+
+// WebPushSubscriptionAlerts represents the specific events that this Web Push subscription will receive.
+//
+// swagger:model webPushSubscriptionAlerts
+type WebPushSubscriptionAlerts struct {
+ // Receive a push notification when someone has followed you?
+ Follow bool `json:"follow"`
+
+ // Receive a push notification when someone has requested to follow you?
+ FollowRequest bool `json:"follow_request"`
+
+ // Receive a push notification when a status you created has been favourited by someone else?
+ Favourite bool `json:"favourite"`
+
+ // Receive a push notification when someone else has mentioned you in a status?
+ Mention bool `json:"mention"`
+
+ // Receive a push notification when a status you created has been boosted by someone else?
+ Reblog bool `json:"reblog"`
+
+ // Receive a push notification when a poll you voted in or created has ended?
+ Poll bool `json:"poll"`
+
+ // Receive a push notification when a subscribed account posts a status?
+ Status bool `json:"status"`
+
+ // Receive a push notification when a status you interacted with has been edited?
+ Update bool `json:"update"`
+
+ // Receive a push notification when a new user has signed up?
+ AdminSignup bool `json:"admin.sign_up"`
+
+ // Receive a push notification when a new report has been filed?
+ AdminReport bool `json:"admin.report"`
+
+ // Receive a push notification when a fave is pending?
+ PendingFavourite bool `json:"pending.favourite"`
+
+ // Receive a push notification when a reply is pending?
+ PendingReply bool `json:"pending.reply"`
+
+ // Receive a push notification when a boost is pending?
+ PendingReblog bool `json:"pending.reblog"`
+}
+
+// WebPushSubscriptionCreateRequest captures params for creating or replacing a Web Push subscription.
+//
+// swagger:ignore
+type WebPushSubscriptionCreateRequest struct {
+ Subscription *WebPushSubscriptionRequestSubscription `form:"-" json:"subscription"`
+
+ SubscriptionEndpoint *string `form:"subscription[endpoint]" json:"-"`
+ SubscriptionKeysAuth *string `form:"subscription[keys][auth]" json:"-"`
+ SubscriptionKeysP256dh *string `form:"subscription[keys][p256dh]" json:"-"`
+
+ WebPushSubscriptionUpdateRequest
+}
+
+// WebPushSubscriptionRequestSubscription is the part of a Web Push subscription that is fixed at creation.
+//
+// swagger:ignore
+type WebPushSubscriptionRequestSubscription struct {
+ // Endpoint is the URL to which Web Push notifications will be sent.
+ Endpoint string `json:"endpoint"`
+
+ Keys WebPushSubscriptionRequestSubscriptionKeys `json:"keys"`
+}
+
+// WebPushSubscriptionRequestSubscriptionKeys is the part of a Web Push subscription that contains auth secrets.
+//
+// swagger:ignore
+type WebPushSubscriptionRequestSubscriptionKeys struct {
+ // Auth is the auth secret, a Base64 encoded string of 16 bytes of random data.
+ Auth string `json:"auth"`
+
+ // P256dh is the user agent public key, a Base64 encoded string of a public key from an ECDH keypair using the prime256v1 curve.
+ P256dh string `json:"p256dh"`
+}
+
+// WebPushSubscriptionUpdateRequest captures params for updating a Web Push subscription.
+//
+// swagger:ignore
+type WebPushSubscriptionUpdateRequest struct {
+ Data *WebPushSubscriptionRequestData `form:"-" json:"data"`
+
+ DataAlertsFollow *bool `form:"data[alerts][follow]" json:"-"`
+ DataAlertsFollowRequest *bool `form:"data[alerts][follow_request]" json:"-"`
+ DataAlertsFavourite *bool `form:"data[alerts][favourite]" json:"-"`
+ DataAlertsMention *bool `form:"data[alerts][mention]" json:"-"`
+ DataAlertsReblog *bool `form:"data[alerts][reblog]" json:"-"`
+ DataAlertsPoll *bool `form:"data[alerts][poll]" json:"-"`
+ DataAlertsStatus *bool `form:"data[alerts][status]" json:"-"`
+ DataAlertsUpdate *bool `form:"data[alerts][update]" json:"-"`
+ DataAlertsAdminSignup *bool `form:"data[alerts][admin.sign_up]" json:"-"`
+ DataAlertsAdminReport *bool `form:"data[alerts][admin.report]" json:"-"`
+ DataAlertsPendingFavourite *bool `form:"data[alerts][pending.favourite]" json:"-"`
+ DataAlertsPendingReply *bool `form:"data[alerts][pending.reply]" json:"-"`
+ DataAlertsPendingReblog *bool `form:"data[alerts][pending.reblog]" json:"-"`
+}
+
+// WebPushSubscriptionRequestData is the part of a Web Push subscription that can be changed after creation.
+//
+// swagger:ignore
+type WebPushSubscriptionRequestData struct {
+ // Alerts selects the specific events that this Web Push subscription will receive.
+ Alerts *WebPushSubscriptionAlerts `form:"-" json:"alerts"`
+}
+
+// WebPushNotificationPolicy names sets of accounts that can generate notifications.
+type WebPushNotificationPolicy string
+
+const (
+ // WebPushNotificationPolicyAll allows all accounts to send notifications to the subscribing user.
+ WebPushNotificationPolicyAll WebPushNotificationPolicy = "all"
+)
diff --git a/internal/api/wellknown/webfinger/webfinger_test.go b/internal/api/wellknown/webfinger/webfinger_test.go
index 76c1fb5bb..234c1ad16 100644
--- a/internal/api/wellknown/webfinger/webfinger_test.go
+++ b/internal/api/wellknown/webfinger/webfinger_test.go
@@ -94,7 +94,13 @@ func (suite *WebfingerStandardTestSuite) SetupTest() {
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil)
- suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager)
+ suite.processor = testrig.NewTestProcessor(
+ &suite.state,
+ suite.federator,
+ suite.emailSender,
+ testrig.NewNoopWebPushSender(),
+ suite.mediaManager,
+ )
suite.webfingerModule = webfinger.New(suite.processor)
suite.oauthServer = testrig.NewTestOauthServer(suite.db)
testrig.StandardDBSetup(suite.db, suite.testAccounts)
diff --git a/internal/api/wellknown/webfinger/webfingerget_test.go b/internal/api/wellknown/webfinger/webfingerget_test.go
index b3aec57fe..4bb6f323d 100644
--- a/internal/api/wellknown/webfinger/webfingerget_test.go
+++ b/internal/api/wellknown/webfinger/webfingerget_test.go
@@ -98,6 +98,7 @@ func (suite *WebfingerGetTestSuite) funkifyAccountDomain(host string, accountDom
testrig.NewTestMediaManager(&suite.state),
&suite.state,
suite.emailSender,
+ testrig.NewNoopWebPushSender(),
visibility.NewFilter(&suite.state),
interaction.NewFilter(&suite.state),
)
diff --git a/internal/cache/cache.go b/internal/cache/cache.go
index 6f925e24f..5771b4e95 100644
--- a/internal/cache/cache.go
+++ b/internal/cache/cache.go
@@ -117,6 +117,8 @@ func (c *Caches) Init() {
c.initUserMute()
c.initUserMuteIDs()
c.initWebfinger()
+ c.initWebPushSubscription()
+ c.initWebPushSubscriptionIDs()
c.initVisibility()
c.initStatusesFilterableFields()
}
diff --git a/internal/cache/db.go b/internal/cache/db.go
index 1052446c4..180d81907 100644
--- a/internal/cache/db.go
+++ b/internal/cache/db.go
@@ -258,6 +258,15 @@ type DBCaches struct {
// UserMuteIDs provides access to the user mute IDs database cache.
UserMuteIDs SliceCache[string]
+
+ // VAPIDKeyPair caches the server's VAPID key pair.
+ VAPIDKeyPair atomic.Pointer[gtsmodel.VAPIDKeyPair]
+
+ // WebPushSubscription provides access to the gtsmodel WebPushSubscription database cache.
+ WebPushSubscription StructCache[*gtsmodel.WebPushSubscription]
+
+ // WebPushSubscriptionIDs provides access to the Web Push subscription IDs database cache.
+ WebPushSubscriptionIDs SliceCache[string]
}
// NOTE:
@@ -1579,9 +1588,10 @@ func (c *Caches) initToken() {
{Fields: "Refresh"},
{Fields: "ClientID", Multiple: true},
},
- MaxSize: cap,
- IgnoreErr: ignoreErrors,
- Copy: copyF,
+ MaxSize: cap,
+ IgnoreErr: ignoreErrors,
+ Copy: copyF,
+ Invalidate: c.OnInvalidateToken,
})
}
@@ -1691,3 +1701,40 @@ func (c *Caches) initUserMuteIDs() {
c.DB.UserMuteIDs.Init(0, cap)
}
+
+func (c *Caches) initWebPushSubscription() {
+ cap := calculateResultCacheMax(
+ sizeofWebPushSubscription(), // model in-mem size.
+ config.GetCacheWebPushSubscriptionMemRatio(),
+ )
+
+ log.Infof(nil, "cache size = %d", cap)
+
+ copyF := func(s1 *gtsmodel.WebPushSubscription) *gtsmodel.WebPushSubscription {
+ s2 := new(gtsmodel.WebPushSubscription)
+ *s2 = *s1
+ return s2
+ }
+
+ c.DB.WebPushSubscription.Init(structr.CacheConfig[*gtsmodel.WebPushSubscription]{
+ Indices: []structr.IndexConfig{
+ {Fields: "ID"},
+ {Fields: "TokenID"},
+ {Fields: "AccountID", Multiple: true},
+ },
+ MaxSize: cap,
+ IgnoreErr: ignoreErrors,
+ Invalidate: c.OnInvalidateWebPushSubscription,
+ Copy: copyF,
+ })
+}
+
+func (c *Caches) initWebPushSubscriptionIDs() {
+ cap := calculateSliceCacheMax(
+ config.GetCacheWebPushSubscriptionIDsMemRatio(),
+ )
+
+ log.Infof(nil, "cache size = %d", cap)
+
+ c.DB.WebPushSubscriptionIDs.Init(0, cap)
+}
diff --git a/internal/cache/invalidate.go b/internal/cache/invalidate.go
index 42d7b7399..555c73cd7 100644
--- a/internal/cache/invalidate.go
+++ b/internal/cache/invalidate.go
@@ -283,6 +283,11 @@ func (c *Caches) OnInvalidateStatusFave(fave *gtsmodel.StatusFave) {
c.DB.StatusFaveIDs.Invalidate(fave.StatusID)
}
+func (c *Caches) OnInvalidateToken(token *gtsmodel.Token) {
+ // Invalidate token's push subscription.
+ c.DB.WebPushSubscription.Invalidate("ID", token.ID)
+}
+
func (c *Caches) OnInvalidateUser(user *gtsmodel.User) {
// Invalidate local account ID cached visibility.
c.Visibility.Invalidate("ItemID", user.AccountID)
@@ -296,3 +301,8 @@ func (c *Caches) OnInvalidateUserMute(mute *gtsmodel.UserMute) {
// Invalidate source account's user mute lists.
c.DB.UserMuteIDs.Invalidate(mute.AccountID)
}
+
+func (c *Caches) OnInvalidateWebPushSubscription(subscription *gtsmodel.WebPushSubscription) {
+ // Invalidate source account's Web Push subscription list.
+ c.DB.WebPushSubscriptionIDs.Invalidate(subscription.AccountID)
+}
diff --git a/internal/cache/size.go b/internal/cache/size.go
index 24101683a..c96a3cd2e 100644
--- a/internal/cache/size.go
+++ b/internal/cache/size.go
@@ -66,6 +66,14 @@ you'll make society more equitable for all if you're not careful! :hammer_sickle
// be a serialized string of almost any type, so we pick a
// nice serialized key size on the upper end of normal.
sizeofResultKey = 2 * sizeofIDStr
+
+ // exampleWebPushAuth is a Base64-encoded 16-byte random auth secret.
+ // This secret is consumed as Base64 by webpush-go.
+ exampleWebPushAuth = "ZVxqlt5fzVgmSz2aqiA2XQ=="
+
+ // exampleWebPushP256dh is a Base64-encoded DH P-256 public key.
+ // This secret is consumed as Base64 by webpush-go.
+ exampleWebPushP256dh = "OrpejO16gV97uBXew/T0I7YoUv/CX8fz0z4g8RrQ+edXJqQPjX3XVSo2P0HhcCpCOR1+Dzj5LFcK9jYNqX7SBg=="
)
var (
@@ -576,7 +584,7 @@ func sizeofMove() uintptr {
func sizeofNotification() uintptr {
return uintptr(size.Of(&gtsmodel.Notification{
ID: exampleID,
- NotificationType: gtsmodel.NotificationFave,
+ NotificationType: gtsmodel.NotificationFavourite,
CreatedAt: exampleTime,
TargetAccountID: exampleID,
OriginAccountID: exampleID,
@@ -821,3 +829,11 @@ func sizeofUserMute() uintptr {
Notifications: util.Ptr(false),
}))
}
+
+func sizeofWebPushSubscription() uintptr {
+ return uintptr(size.Of(&gtsmodel.WebPushSubscription{
+ TokenID: exampleID,
+ Auth: exampleWebPushAuth,
+ P256dh: exampleWebPushP256dh,
+ }))
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 72154b3f2..f77c86c50 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -252,6 +252,8 @@ type CacheConfiguration struct {
UserMuteMemRatio float64 `name:"user-mute-mem-ratio"`
UserMuteIDsMemRatio float64 `name:"user-mute-ids-mem-ratio"`
WebfingerMemRatio float64 `name:"webfinger-mem-ratio"`
+ WebPushSubscriptionMemRatio float64 `name:"web-push-subscription-mem-ratio"`
+ WebPushSubscriptionIDsMemRatio float64 `name:"web-push-subscription-ids-mem-ratio"`
VisibilityMemRatio float64 `name:"visibility-mem-ratio"`
}
diff --git a/internal/config/defaults.go b/internal/config/defaults.go
index 8c2ae90de..157dfde0a 100644
--- a/internal/config/defaults.go
+++ b/internal/config/defaults.go
@@ -213,6 +213,8 @@ var Defaults = Configuration{
UserMuteMemRatio: 2,
UserMuteIDsMemRatio: 3,
WebfingerMemRatio: 0.1,
+ WebPushSubscriptionMemRatio: 1,
+ WebPushSubscriptionIDsMemRatio: 1,
VisibilityMemRatio: 2,
},
diff --git a/internal/config/helpers.gen.go b/internal/config/helpers.gen.go
index e1c41638c..fd1b86898 100644
--- a/internal/config/helpers.gen.go
+++ b/internal/config/helpers.gen.go
@@ -4274,6 +4274,64 @@ func GetCacheWebfingerMemRatio() float64 { return global.GetCacheWebfingerMemRat
// SetCacheWebfingerMemRatio safely sets the value for global configuration 'Cache.WebfingerMemRatio' field
func SetCacheWebfingerMemRatio(v float64) { global.SetCacheWebfingerMemRatio(v) }
+// GetCacheWebPushSubscriptionMemRatio safely fetches the Configuration value for state's 'Cache.WebPushSubscriptionMemRatio' field
+func (st *ConfigState) GetCacheWebPushSubscriptionMemRatio() (v float64) {
+ st.mutex.RLock()
+ v = st.config.Cache.WebPushSubscriptionMemRatio
+ st.mutex.RUnlock()
+ return
+}
+
+// SetCacheWebPushSubscriptionMemRatio safely sets the Configuration value for state's 'Cache.WebPushSubscriptionMemRatio' field
+func (st *ConfigState) SetCacheWebPushSubscriptionMemRatio(v float64) {
+ st.mutex.Lock()
+ defer st.mutex.Unlock()
+ st.config.Cache.WebPushSubscriptionMemRatio = v
+ st.reloadToViper()
+}
+
+// CacheWebPushSubscriptionMemRatioFlag returns the flag name for the 'Cache.WebPushSubscriptionMemRatio' field
+func CacheWebPushSubscriptionMemRatioFlag() string { return "cache-web-push-subscription-mem-ratio" }
+
+// GetCacheWebPushSubscriptionMemRatio safely fetches the value for global configuration 'Cache.WebPushSubscriptionMemRatio' field
+func GetCacheWebPushSubscriptionMemRatio() float64 {
+ return global.GetCacheWebPushSubscriptionMemRatio()
+}
+
+// SetCacheWebPushSubscriptionMemRatio safely sets the value for global configuration 'Cache.WebPushSubscriptionMemRatio' field
+func SetCacheWebPushSubscriptionMemRatio(v float64) { global.SetCacheWebPushSubscriptionMemRatio(v) }
+
+// GetCacheWebPushSubscriptionIDsMemRatio safely fetches the Configuration value for state's 'Cache.WebPushSubscriptionIDsMemRatio' field
+func (st *ConfigState) GetCacheWebPushSubscriptionIDsMemRatio() (v float64) {
+ st.mutex.RLock()
+ v = st.config.Cache.WebPushSubscriptionIDsMemRatio
+ st.mutex.RUnlock()
+ return
+}
+
+// SetCacheWebPushSubscriptionIDsMemRatio safely sets the Configuration value for state's 'Cache.WebPushSubscriptionIDsMemRatio' field
+func (st *ConfigState) SetCacheWebPushSubscriptionIDsMemRatio(v float64) {
+ st.mutex.Lock()
+ defer st.mutex.Unlock()
+ st.config.Cache.WebPushSubscriptionIDsMemRatio = v
+ st.reloadToViper()
+}
+
+// CacheWebPushSubscriptionIDsMemRatioFlag returns the flag name for the 'Cache.WebPushSubscriptionIDsMemRatio' field
+func CacheWebPushSubscriptionIDsMemRatioFlag() string {
+ return "cache-web-push-subscription-ids-mem-ratio"
+}
+
+// GetCacheWebPushSubscriptionIDsMemRatio safely fetches the value for global configuration 'Cache.WebPushSubscriptionIDsMemRatio' field
+func GetCacheWebPushSubscriptionIDsMemRatio() float64 {
+ return global.GetCacheWebPushSubscriptionIDsMemRatio()
+}
+
+// SetCacheWebPushSubscriptionIDsMemRatio safely sets the value for global configuration 'Cache.WebPushSubscriptionIDsMemRatio' field
+func SetCacheWebPushSubscriptionIDsMemRatio(v float64) {
+ global.SetCacheWebPushSubscriptionIDsMemRatio(v)
+}
+
// GetCacheVisibilityMemRatio safely fetches the Configuration value for state's 'Cache.VisibilityMemRatio' field
func (st *ConfigState) GetCacheVisibilityMemRatio() (v float64) {
st.mutex.RLock()
diff --git a/internal/db/application.go b/internal/db/application.go
index b71e593c2..5a4068431 100644
--- a/internal/db/application.go
+++ b/internal/db/application.go
@@ -48,6 +48,9 @@ type Application interface {
// GetAllTokens ...
GetAllTokens(ctx context.Context) ([]*gtsmodel.Token, error)
+ // GetTokenByID ...
+ GetTokenByID(ctx context.Context, id string) (*gtsmodel.Token, error)
+
// GetTokenByCode ...
GetTokenByCode(ctx context.Context, code string) (*gtsmodel.Token, error)
diff --git a/internal/db/bundb/application.go b/internal/db/bundb/application.go
index cbba499b0..92fc5ea2b 100644
--- a/internal/db/bundb/application.go
+++ b/internal/db/bundb/application.go
@@ -174,6 +174,16 @@ func (a *applicationDB) GetAllTokens(ctx context.Context) ([]*gtsmodel.Token, er
return tokens, nil
}
+func (a *applicationDB) GetTokenByID(ctx context.Context, code string) (*gtsmodel.Token, error) {
+ return a.getTokenBy(
+ "ID",
+ func(t *gtsmodel.Token) error {
+ return a.db.NewSelect().Model(t).Where("? = ?", bun.Ident("id"), code).Scan(ctx)
+ },
+ code,
+ )
+}
+
func (a *applicationDB) GetTokenByCode(ctx context.Context, code string) (*gtsmodel.Token, error) {
return a.getTokenBy(
"Code",
diff --git a/internal/db/bundb/bundb.go b/internal/db/bundb/bundb.go
index cf612fd2e..c9dd7866d 100644
--- a/internal/db/bundb/bundb.go
+++ b/internal/db/bundb/bundb.go
@@ -88,6 +88,7 @@ type DBService struct {
db.Timeline
db.User
db.Tombstone
+ db.WebPush
db.WorkerTask
db *bun.DB
}
@@ -301,6 +302,10 @@ func NewBunDBService(ctx context.Context, state *state.State) (db.DB, error) {
db: db,
state: state,
},
+ WebPush: &webPushDB{
+ db: db,
+ state: state,
+ },
WorkerTask: &workerTaskDB{
db: db,
},
diff --git a/internal/db/bundb/migrations/20241121121623_enum_strings_to_ints.go b/internal/db/bundb/migrations/20241121121623_enum_strings_to_ints.go
index 6767c6809..5f3eb1409 100644
--- a/internal/db/bundb/migrations/20241121121623_enum_strings_to_ints.go
+++ b/internal/db/bundb/migrations/20241121121623_enum_strings_to_ints.go
@@ -149,10 +149,10 @@ func notificationEnumMapping[T ~string]() map[T]new_gtsmodel.NotificationType {
T(old_gtsmodel.NotificationFollowRequest): new_gtsmodel.NotificationFollowRequest,
T(old_gtsmodel.NotificationMention): new_gtsmodel.NotificationMention,
T(old_gtsmodel.NotificationReblog): new_gtsmodel.NotificationReblog,
- T(old_gtsmodel.NotificationFave): new_gtsmodel.NotificationFave,
+ T(old_gtsmodel.NotificationFave): new_gtsmodel.NotificationFavourite,
T(old_gtsmodel.NotificationPoll): new_gtsmodel.NotificationPoll,
T(old_gtsmodel.NotificationStatus): new_gtsmodel.NotificationStatus,
- T(old_gtsmodel.NotificationSignup): new_gtsmodel.NotificationSignup,
+ T(old_gtsmodel.NotificationSignup): new_gtsmodel.NotificationAdminSignup,
T(old_gtsmodel.NotificationPendingFave): new_gtsmodel.NotificationPendingFave,
T(old_gtsmodel.NotificationPendingReply): new_gtsmodel.NotificationPendingReply,
T(old_gtsmodel.NotificationPendingReblog): new_gtsmodel.NotificationPendingReblog,
diff --git a/internal/db/bundb/migrations/20241124012635_add_vapid_key_pairs.go b/internal/db/bundb/migrations/20241124012635_add_vapid_key_pairs.go
new file mode 100644
index 000000000..c1a32f6be
--- /dev/null
+++ b/internal/db/bundb/migrations/20241124012635_add_vapid_key_pairs.go
@@ -0,0 +1,51 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package migrations
+
+import (
+ "context"
+
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/uptrace/bun"
+)
+
+func init() {
+ up := func(ctx context.Context, db *bun.DB) error {
+ return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
+ if _, err := tx.
+ NewCreateTable().
+ Model(&gtsmodel.VAPIDKeyPair{}).
+ IfNotExists().
+ Exec(ctx); err != nil {
+ return err
+ }
+
+ return nil
+ })
+ }
+
+ down := func(ctx context.Context, db *bun.DB) error {
+ return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
+ return nil
+ })
+ }
+
+ if err := Migrations.Register(up, down); err != nil {
+ panic(err)
+ }
+}
diff --git a/internal/db/bundb/migrations/20241124012636_add_web_push_subscriptions.go b/internal/db/bundb/migrations/20241124012636_add_web_push_subscriptions.go
new file mode 100644
index 000000000..87d966903
--- /dev/null
+++ b/internal/db/bundb/migrations/20241124012636_add_web_push_subscriptions.go
@@ -0,0 +1,61 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package migrations
+
+import (
+ "context"
+
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/uptrace/bun"
+)
+
+func init() {
+ up := func(ctx context.Context, db *bun.DB) error {
+ return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
+ if _, err := tx.
+ NewCreateTable().
+ Model(&gtsmodel.WebPushSubscription{}).
+ IfNotExists().
+ Exec(ctx); err != nil {
+ return err
+ }
+
+ if _, err := tx.
+ NewCreateIndex().
+ Model(&gtsmodel.WebPushSubscription{}).
+ Index("web_push_subscriptions_account_id_idx").
+ Column("account_id").
+ IfNotExists().
+ Exec(ctx); err != nil {
+ return err
+ }
+
+ return nil
+ })
+ }
+
+ down := func(ctx context.Context, db *bun.DB) error {
+ return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
+ return nil
+ })
+ }
+
+ if err := Migrations.Register(up, down); err != nil {
+ panic(err)
+ }
+}
diff --git a/internal/db/bundb/notification_test.go b/internal/db/bundb/notification_test.go
index 8e2fb8031..8cc778071 100644
--- a/internal/db/bundb/notification_test.go
+++ b/internal/db/bundb/notification_test.go
@@ -66,7 +66,7 @@ func (suite *NotificationTestSuite) spamNotifs() {
notif := &gtsmodel.Notification{
ID: notifID,
- NotificationType: gtsmodel.NotificationFave,
+ NotificationType: gtsmodel.NotificationFavourite,
CreatedAt: time.Now(),
TargetAccountID: targetAccountID,
OriginAccountID: originAccountID,
diff --git a/internal/db/bundb/webpush.go b/internal/db/bundb/webpush.go
new file mode 100644
index 000000000..c61209573
--- /dev/null
+++ b/internal/db/bundb/webpush.go
@@ -0,0 +1,270 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package bundb
+
+import (
+ "context"
+ "errors"
+
+ webpushgo "github.com/SherClockHolmes/webpush-go"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/state"
+ "github.com/superseriousbusiness/gotosocial/internal/util/xslices"
+ "github.com/uptrace/bun"
+)
+
+type webPushDB struct {
+ db *bun.DB
+ state *state.State
+}
+
+func (w *webPushDB) GetVAPIDKeyPair(ctx context.Context) (*gtsmodel.VAPIDKeyPair, error) {
+ var err error
+
+ vapidKeyPair, err := w.getVAPIDKeyPair(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if vapidKeyPair != nil {
+ return vapidKeyPair, nil
+ }
+
+ // If there aren't any, generate new ones.
+ vapidKeyPair = &gtsmodel.VAPIDKeyPair{}
+ if vapidKeyPair.Private, vapidKeyPair.Public, err = webpushgo.GenerateVAPIDKeys(); err != nil {
+ return nil, gtserror.Newf("error generating VAPID key pair: %w", err)
+ }
+
+ // Store the keys in the database.
+ if _, err = w.db.NewInsert().
+ Model(vapidKeyPair).
+ Exec(ctx); // nocollapse
+ err != nil {
+ if errors.Is(err, db.ErrAlreadyExists) {
+ // Multiple concurrent attempts to generate new keys, and this one didn't win.
+ // Get the results of the one that did.
+ return w.getVAPIDKeyPair(ctx)
+ }
+ return nil, err
+ }
+
+ // Cache the keys.
+ w.state.Caches.DB.VAPIDKeyPair.Store(vapidKeyPair)
+
+ return vapidKeyPair, nil
+}
+
+// getVAPIDKeyPair gets an existing VAPID key pair from cache or DB.
+// If there is no existing VAPID key pair, it returns nil, with no error.
+func (w *webPushDB) getVAPIDKeyPair(ctx context.Context) (*gtsmodel.VAPIDKeyPair, error) {
+ // Look for cached keys.
+ vapidKeyPair := w.state.Caches.DB.VAPIDKeyPair.Load()
+ if vapidKeyPair != nil {
+ return vapidKeyPair, nil
+ }
+
+ // Look for previously generated keys in the database.
+ vapidKeyPair = &gtsmodel.VAPIDKeyPair{}
+ if err := w.db.NewSelect().
+ Model(vapidKeyPair).
+ Limit(1).
+ Scan(ctx); // nocollapse
+ err != nil {
+ if errors.Is(err, db.ErrNoEntries) {
+ return nil, nil
+ }
+ return nil, err
+ }
+
+ return vapidKeyPair, nil
+}
+
+func (w *webPushDB) DeleteVAPIDKeyPair(ctx context.Context) error {
+ // Delete any existing keys.
+ if _, err := w.db.NewTruncateTable().
+ Model((*gtsmodel.VAPIDKeyPair)(nil)).
+ Exec(ctx); // nocollapse
+ err != nil {
+ return err
+ }
+
+ // Clear the key cache.
+ w.state.Caches.DB.VAPIDKeyPair.Store(nil)
+
+ return nil
+}
+
+func (w *webPushDB) GetWebPushSubscriptionByTokenID(ctx context.Context, tokenID string) (*gtsmodel.WebPushSubscription, error) {
+ subscription, err := w.state.Caches.DB.WebPushSubscription.LoadOne(
+ "TokenID",
+ func() (*gtsmodel.WebPushSubscription, error) {
+ var subscription gtsmodel.WebPushSubscription
+ err := w.db.
+ NewSelect().
+ Model(&subscription).
+ Where("? = ?", bun.Ident("token_id"), tokenID).
+ Scan(ctx)
+ return &subscription, err
+ },
+ tokenID,
+ )
+ if err != nil {
+ return nil, err
+ }
+ return subscription, nil
+}
+
+func (w *webPushDB) PutWebPushSubscription(ctx context.Context, subscription *gtsmodel.WebPushSubscription) error {
+ return w.state.Caches.DB.WebPushSubscription.Store(subscription, func() error {
+ _, err := w.db.NewInsert().
+ Model(subscription).
+ Exec(ctx)
+ return err
+ })
+}
+
+func (w *webPushDB) UpdateWebPushSubscription(ctx context.Context, subscription *gtsmodel.WebPushSubscription, columns ...string) error {
+ // Update database.
+ result, err := w.db.
+ NewUpdate().
+ Model(subscription).
+ Column(columns...).
+ Where("? = ?", bun.Ident("id"), subscription.ID).
+ Exec(ctx)
+ if err != nil {
+ return err
+ }
+ rowsAffected, err := result.RowsAffected()
+ if err != nil {
+ return gtserror.Newf("error getting updated row count: %w", err)
+ }
+ if rowsAffected == 0 {
+ return db.ErrNoEntries
+ }
+
+ // Update cache.
+ w.state.Caches.DB.WebPushSubscription.Put(subscription)
+
+ return nil
+}
+
+func (w *webPushDB) DeleteWebPushSubscriptionByTokenID(ctx context.Context, tokenID string) error {
+ // Deleted partial model for cache invalidation.
+ var deleted gtsmodel.WebPushSubscription
+
+ // Delete subscription, returning subset of columns used by invalidation hook.
+ if _, err := w.db.NewDelete().
+ Model(&deleted).
+ Where("? = ?", bun.Ident("token_id"), tokenID).
+ Returning("?", bun.Ident("account_id")).
+ Exec(ctx); // nocollapse
+ err != nil && !errors.Is(err, db.ErrNoEntries) {
+ return err
+ }
+
+ // Invalidate cached subscription by token ID.
+ w.state.Caches.DB.WebPushSubscription.Invalidate("TokenID", tokenID)
+
+ // Call invalidate hook directly.
+ w.state.Caches.OnInvalidateWebPushSubscription(&deleted)
+
+ return nil
+}
+
+func (w *webPushDB) GetWebPushSubscriptionsByAccountID(ctx context.Context, accountID string) ([]*gtsmodel.WebPushSubscription, error) {
+ // Fetch IDs of all subscriptions created by this account.
+ subscriptionIDs, err := loadPagedIDs(&w.state.Caches.DB.WebPushSubscriptionIDs, accountID, nil, func() ([]string, error) {
+ // Subscription IDs not in cache. Perform DB query.
+ var subscriptionIDs []string
+ if _, err := w.db.
+ NewSelect().
+ Model((*gtsmodel.WebPushSubscription)(nil)).
+ Column("id").
+ Where("? = ?", bun.Ident("account_id"), accountID).
+ Order("id DESC").
+ Exec(ctx, &subscriptionIDs); // nocollapse
+ err != nil && !errors.Is(err, db.ErrNoEntries) {
+ return nil, err
+ }
+ return subscriptionIDs, nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ if len(subscriptionIDs) == 0 {
+ return nil, nil
+ }
+
+ // Get each subscription by ID from the cache or DB.
+ subscriptions, err := w.state.Caches.DB.WebPushSubscription.LoadIDs("ID",
+ subscriptionIDs,
+ func(uncached []string) ([]*gtsmodel.WebPushSubscription, error) {
+ subscriptions := make([]*gtsmodel.WebPushSubscription, 0, len(uncached))
+ if err := w.db.
+ NewSelect().
+ Model(&subscriptions).
+ Where("? IN (?)", bun.Ident("id"), bun.In(uncached)).
+ Scan(ctx); // nocollapse
+ err != nil {
+ return nil, err
+ }
+ return subscriptions, nil
+ },
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ // Put the subscription structs in the same order as the filter IDs.
+ xslices.OrderBy(
+ subscriptions,
+ subscriptionIDs,
+ func(subscription *gtsmodel.WebPushSubscription) string {
+ return subscription.ID
+ },
+ )
+
+ return subscriptions, nil
+}
+
+func (w *webPushDB) DeleteWebPushSubscriptionsByAccountID(ctx context.Context, accountID string) error {
+ // Deleted partial models for cache invalidation.
+ var deleted []*gtsmodel.WebPushSubscription
+
+ // Delete subscriptions, returning subset of columns.
+ if _, err := w.db.NewDelete().
+ Model(&deleted).
+ Where("? = ?", bun.Ident("account_id"), accountID).
+ Returning("?", bun.Ident("account_id")).
+ Exec(ctx); // nocollapse
+ err != nil && !errors.Is(err, db.ErrNoEntries) {
+ return err
+ }
+
+ // Invalidate cached subscriptions by account ID.
+ w.state.Caches.DB.WebPushSubscription.Invalidate("AccountID", accountID)
+
+ // Call invalidate hooks directly in case those entries weren't cached.
+ for _, subscription := range deleted {
+ w.state.Caches.OnInvalidateWebPushSubscription(subscription)
+ }
+
+ return nil
+}
diff --git a/internal/db/bundb/webpush_test.go b/internal/db/bundb/webpush_test.go
new file mode 100644
index 000000000..8ca83955a
--- /dev/null
+++ b/internal/db/bundb/webpush_test.go
@@ -0,0 +1,81 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package bundb_test
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/suite"
+)
+
+type WebPushTestSuite struct {
+ BunDBStandardTestSuite
+}
+
+// Get the text fixture VAPID key pair.
+func (suite *WebPushTestSuite) TestGetVAPIDKeyPair() {
+ ctx := context.Background()
+
+ vapidKeyPair, err := suite.db.GetVAPIDKeyPair(ctx)
+ suite.NoError(err)
+ if !suite.NotNil(vapidKeyPair) {
+ suite.FailNow("Got a nil VAPID key pair, can't continue")
+ }
+ suite.NotEmpty(vapidKeyPair.Private)
+ suite.NotEmpty(vapidKeyPair.Public)
+
+ // Get it again. It should be the same one.
+ vapidKeyPair2, err := suite.db.GetVAPIDKeyPair(ctx)
+ suite.NoError(err)
+ if suite.NotNil(vapidKeyPair2) {
+ suite.Equal(vapidKeyPair.Private, vapidKeyPair2.Private)
+ suite.Equal(vapidKeyPair.Public, vapidKeyPair2.Public)
+ }
+}
+
+// Generate a VAPID key pair when there isn't one.
+func (suite *WebPushTestSuite) TestGenerateVAPIDKeyPair() {
+ ctx := context.Background()
+
+ // Delete the text fixture VAPID key pair.
+ if err := suite.db.DeleteVAPIDKeyPair(ctx); !suite.NoError(err) {
+ suite.FailNow("Test setup failed: DB error deleting fixture VAPID key pair: %v", err)
+ }
+
+ // Get a new one.
+ vapidKeyPair, err := suite.db.GetVAPIDKeyPair(ctx)
+ suite.NoError(err)
+ if !suite.NotNil(vapidKeyPair) {
+ suite.FailNow("Got a nil VAPID key pair, can't continue")
+ }
+ suite.NotEmpty(vapidKeyPair.Private)
+ suite.NotEmpty(vapidKeyPair.Public)
+
+ // Get it again. It should be the same one.
+ vapidKeyPair2, err := suite.db.GetVAPIDKeyPair(ctx)
+ suite.NoError(err)
+ if suite.NotNil(vapidKeyPair2) {
+ suite.Equal(vapidKeyPair.Private, vapidKeyPair2.Private)
+ suite.Equal(vapidKeyPair.Public, vapidKeyPair2.Public)
+ }
+}
+
+func TestWebPushTestSuite(t *testing.T) {
+ suite.Run(t, new(WebPushTestSuite))
+}
diff --git a/internal/db/db.go b/internal/db/db.go
index 11dd2e507..16796ae49 100644
--- a/internal/db/db.go
+++ b/internal/db/db.go
@@ -58,5 +58,6 @@ type DB interface {
Timeline
User
Tombstone
+ WebPush
WorkerTask
}
diff --git a/internal/db/webpush.go b/internal/db/webpush.go
new file mode 100644
index 000000000..22bf449de
--- /dev/null
+++ b/internal/db/webpush.go
@@ -0,0 +1,54 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package db
+
+import (
+ "context"
+
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+)
+
+// WebPush contains functions related to Web Push notifications.
+type WebPush interface {
+ // GetVAPIDKeyPair retrieves the server's existing VAPID key pair, if there is one.
+ // If there isn't one, it generates a new one, stores it, and returns that.
+ GetVAPIDKeyPair(ctx context.Context) (*gtsmodel.VAPIDKeyPair, error)
+
+ // DeleteVAPIDKeyPair deletes the server's VAPID key pair.
+ DeleteVAPIDKeyPair(ctx context.Context) error
+
+ // GetWebPushSubscriptionByTokenID retrieves an access token's Web Push subscription.
+ // There may not be one, in which case an error will be returned.
+ GetWebPushSubscriptionByTokenID(ctx context.Context, tokenID string) (*gtsmodel.WebPushSubscription, error)
+
+ // PutWebPushSubscription creates an access token's Web Push subscription.
+ PutWebPushSubscription(ctx context.Context, subscription *gtsmodel.WebPushSubscription) error
+
+ // UpdateWebPushSubscription updates an access token's Web Push subscription.
+ // There may not be one, in which case an error will be returned.
+ UpdateWebPushSubscription(ctx context.Context, subscription *gtsmodel.WebPushSubscription, columns ...string) error
+
+ // DeleteWebPushSubscriptionByTokenID deletes an access token's Web Push subscription, if there is one.
+ DeleteWebPushSubscriptionByTokenID(ctx context.Context, tokenID string) error
+
+ // GetWebPushSubscriptionsByAccountID retrieves an account's list of Web Push subscriptions.
+ GetWebPushSubscriptionsByAccountID(ctx context.Context, accountID string) ([]*gtsmodel.WebPushSubscription, error)
+
+ // DeleteWebPushSubscriptionsByAccountID deletes an account's list of Web Push subscriptions.
+ DeleteWebPushSubscriptionsByAccountID(ctx context.Context, accountID string) error
+}
diff --git a/internal/gtsmodel/notification.go b/internal/gtsmodel/notification.go
index 1ef805081..d160e0883 100644
--- a/internal/gtsmodel/notification.go
+++ b/internal/gtsmodel/notification.go
@@ -48,13 +48,16 @@ const (
NotificationFollowRequest NotificationType = 2 // NotificationFollowRequest -- someone requested to follow you
NotificationMention NotificationType = 3 // NotificationMention -- someone mentioned you in their status
NotificationReblog NotificationType = 4 // NotificationReblog -- someone boosted one of your statuses
- NotificationFave NotificationType = 5 // NotificationFave -- someone faved/liked one of your statuses
+ NotificationFavourite NotificationType = 5 // NotificationFavourite -- someone faved/liked one of your statuses
NotificationPoll NotificationType = 6 // NotificationPoll -- a poll you voted in or created has ended
NotificationStatus NotificationType = 7 // NotificationStatus -- someone you enabled notifications for has posted a status.
- NotificationSignup NotificationType = 8 // NotificationSignup -- someone has submitted a new account sign-up to the instance.
- NotificationPendingFave NotificationType = 9 // Someone has faved a status of yours, which requires approval by you.
- NotificationPendingReply NotificationType = 10 // Someone has replied to a status of yours, which requires approval by you.
- NotificationPendingReblog NotificationType = 11 // Someone has boosted a status of yours, which requires approval by you.
+ NotificationAdminSignup NotificationType = 8 // NotificationAdminSignup -- someone has submitted a new account sign-up to the instance.
+ NotificationPendingFave NotificationType = 9 // NotificationPendingFave -- Someone has faved a status of yours, which requires approval by you.
+ NotificationPendingReply NotificationType = 10 // NotificationPendingReply -- Someone has replied to a status of yours, which requires approval by you.
+ NotificationPendingReblog NotificationType = 11 // NotificationPendingReblog -- Someone has boosted a status of yours, which requires approval by you.
+ NotificationAdminReport NotificationType = 12 // NotificationAdminReport -- someone has submitted a new report to the instance.
+ NotificationUpdate NotificationType = 13 // NotificationUpdate -- someone has edited their status.
+ NotificationTypeNumValues NotificationType = 14 // NotificationTypeNumValues -- 1 + number of max notification type
)
// String returns a stringified, frontend API compatible form of NotificationType.
@@ -68,13 +71,13 @@ func (t NotificationType) String() string {
return "mention"
case NotificationReblog:
return "reblog"
- case NotificationFave:
+ case NotificationFavourite:
return "favourite"
case NotificationPoll:
return "poll"
case NotificationStatus:
return "status"
- case NotificationSignup:
+ case NotificationAdminSignup:
return "admin.sign_up"
case NotificationPendingFave:
return "pending.favourite"
@@ -82,6 +85,10 @@ func (t NotificationType) String() string {
return "pending.reply"
case NotificationPendingReblog:
return "pending.reblog"
+ case NotificationAdminReport:
+ return "admin.report"
+ case NotificationUpdate:
+ return "update"
default:
panic("invalid notification type")
}
@@ -99,19 +106,23 @@ func ParseNotificationType(in string) NotificationType {
case "reblog":
return NotificationReblog
case "favourite":
- return NotificationFave
+ return NotificationFavourite
case "poll":
return NotificationPoll
case "status":
return NotificationStatus
case "admin.sign_up":
- return NotificationSignup
+ return NotificationAdminSignup
case "pending.favourite":
return NotificationPendingFave
case "pending.reply":
return NotificationPendingReply
case "pending.reblog":
return NotificationPendingReblog
+ case "admin.report":
+ return NotificationAdminReport
+ case "update":
+ return NotificationUpdate
default:
return NotificationUnknown
}
diff --git a/internal/gtsmodel/vapidkeypair.go b/internal/gtsmodel/vapidkeypair.go
new file mode 100644
index 000000000..56b7edda8
--- /dev/null
+++ b/internal/gtsmodel/vapidkeypair.go
@@ -0,0 +1,28 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package gtsmodel
+
+// VAPIDKeyPair represents the instance's VAPID keys (stored as Base64 strings).
+// This table should only ever have one entry, with a known ID of 0.
+//
+// See: https://datatracker.ietf.org/doc/html/rfc8292
+type VAPIDKeyPair struct {
+ ID int `bun:",pk,notnull"`
+ Public string `bun:",notnull,nullzero"`
+ Private string `bun:",notnull,nullzero"`
+}
diff --git a/internal/gtsmodel/webpushsubscription.go b/internal/gtsmodel/webpushsubscription.go
new file mode 100644
index 000000000..4aeef654a
--- /dev/null
+++ b/internal/gtsmodel/webpushsubscription.go
@@ -0,0 +1,82 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package gtsmodel
+
+// WebPushSubscription represents an access token's Web Push subscription.
+// There can be at most one per access token.
+type WebPushSubscription struct {
+ // ID of this subscription in the database.
+ ID string `bun:"type:CHAR(26),pk,nullzero"`
+
+ // AccountID of the local account that created this subscription.
+ AccountID string `bun:"type:CHAR(26),nullzero,notnull"`
+
+ // TokenID is the ID of the associated access token.
+ // There can be at most one subscription for any given access token,
+ TokenID string `bun:"type:CHAR(26),nullzero,notnull,unique"`
+
+ // Endpoint is the URL receiving Web Push notifications for this subscription.
+ Endpoint string `bun:",nullzero,notnull"`
+
+ // Auth is a Base64-encoded authentication secret.
+ Auth string `bun:",nullzero,notnull"`
+
+ // P256dh is a Base64-encoded Diffie-Hellman public key on the P-256 elliptic curve.
+ P256dh string `bun:",nullzero,notnull"`
+
+ // NotificationFlags controls which notifications are delivered to a given subscription.
+ // Corresponds to model.PushSubscriptionAlerts.
+ NotificationFlags WebPushSubscriptionNotificationFlags `bun:",notnull"`
+}
+
+// WebPushSubscriptionNotificationFlags is a bitfield representation of a set of NotificationType.
+type WebPushSubscriptionNotificationFlags int64
+
+// WebPushSubscriptionNotificationFlagsFromSlice packs a slice of NotificationType into a WebPushSubscriptionNotificationFlags.
+func WebPushSubscriptionNotificationFlagsFromSlice(notificationTypes []NotificationType) WebPushSubscriptionNotificationFlags {
+ var n WebPushSubscriptionNotificationFlags
+ for _, notificationType := range notificationTypes {
+ n.Set(notificationType, true)
+ }
+ return n
+}
+
+// ToSlice unpacks a WebPushSubscriptionNotificationFlags into a slice of NotificationType.
+func (n *WebPushSubscriptionNotificationFlags) ToSlice() []NotificationType {
+ notificationTypes := make([]NotificationType, 0, NotificationTypeNumValues)
+ for notificationType := NotificationUnknown; notificationType < NotificationTypeNumValues; notificationType++ {
+ if n.Get(notificationType) {
+ notificationTypes = append(notificationTypes, notificationType)
+ }
+ }
+ return notificationTypes
+}
+
+// Get tests to see if a given NotificationType is included in this set of flags.
+func (n *WebPushSubscriptionNotificationFlags) Get(notificationType NotificationType) bool {
+ return *n&(1<<notificationType) != 0
+}
+
+// Set adds or removes a given NotificationType to or from this set of flags.
+func (n *WebPushSubscriptionNotificationFlags) Set(notificationType NotificationType, value bool) {
+ if value {
+ *n |= 1 << notificationType
+ } else {
+ *n &= ^(1 << notificationType)
+ }
+}
diff --git a/internal/processing/account/delete.go b/internal/processing/account/delete.go
index c8d1ba5f9..2618fdfc5 100644
--- a/internal/processing/account/delete.go
+++ b/internal/processing/account/delete.go
@@ -96,7 +96,7 @@ func (p *Processor) Delete(
}
// deleteUserAndTokensForAccount deletes the gtsmodel.User and
-// any OAuth tokens and applications for the given account.
+// any OAuth tokens, applications, and Web Push subscriptions for the given account.
//
// Callers to this function should already have checked that
// this is a local account, or else it won't have a user associated
@@ -129,6 +129,10 @@ func (p *Processor) deleteUserAndTokensForAccount(ctx context.Context, account *
}
}
+ if err := p.state.DB.DeleteWebPushSubscriptionsByAccountID(ctx, account.ID); err != nil {
+ return gtserror.Newf("db error deleting Web Push subscriptions: %w", err)
+ }
+
columns, err := stubbifyUser(user)
if err != nil {
return gtserror.Newf("error stubbifying user: %w", err)
diff --git a/internal/processing/admin/admin_test.go b/internal/processing/admin/admin_test.go
index f0839f2f6..ad9d9b2ae 100644
--- a/internal/processing/admin/admin_test.go
+++ b/internal/processing/admin/admin_test.go
@@ -119,6 +119,7 @@ func (suite *AdminStandardTestSuite) SetupTest() {
suite.mediaManager,
&suite.state,
suite.emailSender,
+ testrig.NewNoopWebPushSender(),
visibility.NewFilter(&suite.state),
interaction.NewFilter(&suite.state),
)
diff --git a/internal/processing/processor.go b/internal/processing/processor.go
index 8dabfba96..0bba23089 100644
--- a/internal/processing/processor.go
+++ b/internal/processing/processor.go
@@ -39,6 +39,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/processing/markers"
"github.com/superseriousbusiness/gotosocial/internal/processing/media"
"github.com/superseriousbusiness/gotosocial/internal/processing/polls"
+ "github.com/superseriousbusiness/gotosocial/internal/processing/push"
"github.com/superseriousbusiness/gotosocial/internal/processing/report"
"github.com/superseriousbusiness/gotosocial/internal/processing/search"
"github.com/superseriousbusiness/gotosocial/internal/processing/status"
@@ -51,6 +52,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/subscriptions"
"github.com/superseriousbusiness/gotosocial/internal/text"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
+ "github.com/superseriousbusiness/gotosocial/internal/webpush"
)
// Processor groups together processing functions and
@@ -88,6 +90,7 @@ type Processor struct {
markers markers.Processor
media media.Processor
polls polls.Processor
+ push push.Processor
report report.Processor
search search.Processor
status status.Processor
@@ -146,6 +149,10 @@ func (p *Processor) Polls() *polls.Processor {
return &p.polls
}
+func (p *Processor) Push() *push.Processor {
+ return &p.push
+}
+
func (p *Processor) Report() *report.Processor {
return &p.report
}
@@ -188,6 +195,7 @@ func NewProcessor(
mediaManager *mm.Manager,
state *state.State,
emailSender email.Sender,
+ webPushSender webpush.Sender,
visFilter *visibility.Filter,
intFilter *interaction.Filter,
) *Processor {
@@ -221,6 +229,7 @@ func NewProcessor(
processor.list = list.New(state, converter)
processor.markers = markers.New(state, converter)
processor.polls = polls.New(&common, state, converter)
+ processor.push = push.New(state, converter)
processor.report = report.New(state, converter)
processor.tags = tags.New(state, converter)
processor.timeline = timeline.New(state, converter, visFilter)
@@ -241,6 +250,7 @@ func NewProcessor(
converter,
visFilter,
emailSender,
+ webPushSender,
&processor.account,
&processor.media,
&processor.stream,
diff --git a/internal/processing/processor_test.go b/internal/processing/processor_test.go
index f152f3fad..84ab9ef48 100644
--- a/internal/processing/processor_test.go
+++ b/internal/processing/processor_test.go
@@ -135,6 +135,7 @@ func (suite *ProcessingStandardTestSuite) SetupTest() {
suite.mediaManager,
&suite.state,
suite.emailSender,
+ testrig.NewNoopWebPushSender(),
visibility.NewFilter(&suite.state),
interaction.NewFilter(&suite.state),
)
diff --git a/internal/processing/push/create.go b/internal/processing/push/create.go
new file mode 100644
index 000000000..42a67dc19
--- /dev/null
+++ b/internal/processing/push/create.go
@@ -0,0 +1,65 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package push
+
+import (
+ "context"
+
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/id"
+)
+
+// CreateOrReplace creates a Web Push subscription for the given access token,
+// or entirely replaces the previously existing subscription for that token.
+func (p *Processor) CreateOrReplace(
+ ctx context.Context,
+ accountID string,
+ accessToken string,
+ request *apimodel.WebPushSubscriptionCreateRequest,
+) (*apimodel.WebPushSubscription, gtserror.WithCode) {
+ tokenID, errWithCode := p.getTokenID(ctx, accessToken)
+ if errWithCode != nil {
+ return nil, errWithCode
+ }
+
+ // Clear any previous subscription.
+ if err := p.state.DB.DeleteWebPushSubscriptionByTokenID(ctx, tokenID); err != nil {
+ err := gtserror.Newf("couldn't delete Web Push subscription for token ID %s: %w", tokenID, err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ // Insert a new one.
+ subscription := &gtsmodel.WebPushSubscription{
+ ID: id.NewULID(),
+ AccountID: accountID,
+ TokenID: tokenID,
+ Endpoint: request.Subscription.Endpoint,
+ Auth: request.Subscription.Keys.Auth,
+ P256dh: request.Subscription.Keys.P256dh,
+ NotificationFlags: alertsToNotificationFlags(request.Data.Alerts),
+ }
+
+ if err := p.state.DB.PutWebPushSubscription(ctx, subscription); err != nil {
+ err := gtserror.Newf("couldn't create Web Push subscription for token ID %s: %w", tokenID, err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ return p.apiSubscription(ctx, subscription)
+}
diff --git a/internal/processing/push/delete.go b/internal/processing/push/delete.go
new file mode 100644
index 000000000..6f5c61444
--- /dev/null
+++ b/internal/processing/push/delete.go
@@ -0,0 +1,39 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package push
+
+import (
+ "context"
+
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+)
+
+// Delete deletes the Web Push subscription for the given access token, if there is one.
+func (p *Processor) Delete(ctx context.Context, accessToken string) gtserror.WithCode {
+ tokenID, errWithCode := p.getTokenID(ctx, accessToken)
+ if errWithCode != nil {
+ return errWithCode
+ }
+
+ if err := p.state.DB.DeleteWebPushSubscriptionByTokenID(ctx, tokenID); err != nil {
+ err := gtserror.Newf("couldn't delete Web Push subscription for token ID %s: %w", tokenID, err)
+ return gtserror.NewErrorInternalError(err)
+ }
+
+ return nil
+}
diff --git a/internal/processing/push/get.go b/internal/processing/push/get.go
new file mode 100644
index 000000000..542f08862
--- /dev/null
+++ b/internal/processing/push/get.go
@@ -0,0 +1,47 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package push
+
+import (
+ "context"
+ "errors"
+
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+)
+
+// Get returns the Web Push subscription for the given access token.
+func (p *Processor) Get(ctx context.Context, accessToken string) (*apimodel.WebPushSubscription, gtserror.WithCode) {
+ tokenID, errWithCode := p.getTokenID(ctx, accessToken)
+ if errWithCode != nil {
+ return nil, errWithCode
+ }
+
+ subscription, err := p.state.DB.GetWebPushSubscriptionByTokenID(ctx, tokenID)
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ err := gtserror.Newf("couldn't get Web Push subscription for token ID %s: %w", tokenID, err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+ if subscription == nil {
+ err := errors.New("no Web Push subscription exists for this access token")
+ return nil, gtserror.NewErrorNotFound(err)
+ }
+
+ return p.apiSubscription(ctx, subscription)
+}
diff --git a/internal/processing/push/push.go b/internal/processing/push/push.go
new file mode 100644
index 000000000..f46280386
--- /dev/null
+++ b/internal/processing/push/push.go
@@ -0,0 +1,85 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package push
+
+import (
+ "context"
+
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/state"
+ "github.com/superseriousbusiness/gotosocial/internal/typeutils"
+)
+
+type Processor struct {
+ state *state.State
+ converter *typeutils.Converter
+}
+
+func New(state *state.State, converter *typeutils.Converter) Processor {
+ return Processor{
+ state: state,
+ converter: converter,
+ }
+}
+
+// getTokenID returns the token ID for a given access token.
+// Since all push API calls require authentication, this should always be available.
+func (p *Processor) getTokenID(ctx context.Context, accessToken string) (string, gtserror.WithCode) {
+ token, err := p.state.DB.GetTokenByAccess(ctx, accessToken)
+ if err != nil {
+ err := gtserror.Newf("couldn't find token ID for access token: %w", err)
+ return "", gtserror.NewErrorInternalError(err)
+ }
+
+ return token.ID, nil
+}
+
+// apiSubscription is a shortcut to return the API version of the given Web Push subscription,
+// or return an appropriate error if conversion fails.
+func (p *Processor) apiSubscription(ctx context.Context, subscription *gtsmodel.WebPushSubscription) (*apimodel.WebPushSubscription, gtserror.WithCode) {
+ apiSubscription, err := p.converter.WebPushSubscriptionToAPIWebPushSubscription(ctx, subscription)
+ if err != nil {
+ err := gtserror.Newf("error converting Web Push subscription %s to API representation: %w", subscription.ID, err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ return apiSubscription, nil
+}
+
+// alertsToNotificationFlags turns the alerts section of a push subscription API request into a packed bitfield.
+func alertsToNotificationFlags(alerts *apimodel.WebPushSubscriptionAlerts) gtsmodel.WebPushSubscriptionNotificationFlags {
+ var n gtsmodel.WebPushSubscriptionNotificationFlags
+
+ n.Set(gtsmodel.NotificationFollow, alerts.Follow)
+ n.Set(gtsmodel.NotificationFollowRequest, alerts.FollowRequest)
+ n.Set(gtsmodel.NotificationFavourite, alerts.Favourite)
+ n.Set(gtsmodel.NotificationMention, alerts.Mention)
+ n.Set(gtsmodel.NotificationReblog, alerts.Reblog)
+ n.Set(gtsmodel.NotificationPoll, alerts.Poll)
+ n.Set(gtsmodel.NotificationStatus, alerts.Status)
+ n.Set(gtsmodel.NotificationUpdate, alerts.Update)
+ n.Set(gtsmodel.NotificationAdminSignup, alerts.AdminSignup)
+ n.Set(gtsmodel.NotificationAdminReport, alerts.AdminReport)
+ n.Set(gtsmodel.NotificationPendingFave, alerts.PendingFavourite)
+ n.Set(gtsmodel.NotificationPendingReply, alerts.PendingReply)
+ n.Set(gtsmodel.NotificationPendingReblog, alerts.PendingReblog)
+
+ return n
+}
diff --git a/internal/processing/push/update.go b/internal/processing/push/update.go
new file mode 100644
index 000000000..370536f9b
--- /dev/null
+++ b/internal/processing/push/update.go
@@ -0,0 +1,63 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package push
+
+import (
+ "context"
+ "errors"
+
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+)
+
+// Update updates the Web Push subscription for the given access token.
+func (p *Processor) Update(
+ ctx context.Context,
+ accessToken string,
+ request *apimodel.WebPushSubscriptionUpdateRequest,
+) (*apimodel.WebPushSubscription, gtserror.WithCode) {
+ tokenID, errWithCode := p.getTokenID(ctx, accessToken)
+ if errWithCode != nil {
+ return nil, errWithCode
+ }
+
+ // Get existing subscription.
+ subscription, err := p.state.DB.GetWebPushSubscriptionByTokenID(ctx, tokenID)
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ err := gtserror.Newf("couldn't get Web Push subscription for token ID %s: %w", tokenID, err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+ if subscription == nil {
+ err := errors.New("no Web Push subscription exists for this access token")
+ return nil, gtserror.NewErrorNotFound(err)
+ }
+
+ // Update it.
+ subscription.NotificationFlags = alertsToNotificationFlags(request.Data.Alerts)
+ if err = p.state.DB.UpdateWebPushSubscription(
+ ctx,
+ subscription,
+ "notification_flags",
+ ); err != nil {
+ err := gtserror.Newf("couldn't update Web Push subscription for token ID %s: %w", tokenID, err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ return p.apiSubscription(ctx, subscription)
+}
diff --git a/internal/processing/timeline/notification.go b/internal/processing/timeline/notification.go
index a242c7b74..09636e7eb 100644
--- a/internal/processing/timeline/notification.go
+++ b/internal/processing/timeline/notification.go
@@ -184,7 +184,7 @@ func (p *Processor) notifVisible(
// If this is a new local account sign-up,
// skip normal visibility checking because
// origin account won't be confirmed yet.
- if n.NotificationType == gtsmodel.NotificationSignup {
+ if n.NotificationType == gtsmodel.NotificationAdminSignup {
return true, nil
}
diff --git a/internal/processing/workers/fromclientapi_test.go b/internal/processing/workers/fromclientapi_test.go
index d955f0529..acb25673d 100644
--- a/internal/processing/workers/fromclientapi_test.go
+++ b/internal/processing/workers/fromclientapi_test.go
@@ -179,6 +179,28 @@ func (suite *FromClientAPITestSuite) checkStreamed(
}
}
+// checkWebPushed asserts that the target account got a single Web Push notification with a given type.
+func (suite *FromClientAPITestSuite) checkWebPushed(
+ sender *testrig.WebPushMockSender,
+ accountID string,
+ notificationType gtsmodel.NotificationType,
+) {
+ pushedNotifications := sender.Sent[accountID]
+ if suite.Len(pushedNotifications, 1) {
+ pushedNotification := pushedNotifications[0]
+ suite.Equal(notificationType, pushedNotification.NotificationType)
+ }
+}
+
+// checkNotWebPushed asserts that the target account got no Web Push notifications.
+func (suite *FromClientAPITestSuite) checkNotWebPushed(
+ sender *testrig.WebPushMockSender,
+ accountID string,
+) {
+ pushedNotifications := sender.Sent[accountID]
+ suite.Len(pushedNotifications, 0)
+}
+
func (suite *FromClientAPITestSuite) statusJSON(
ctx context.Context,
typeConverter *typeutils.Converter,
@@ -341,6 +363,9 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithNotification() {
string(notifJSON),
stream.EventTypeNotification,
)
+
+ // Check for a Web Push status notification.
+ suite.checkWebPushed(testStructs.WebPushSender, receivingAccount.ID, gtsmodel.NotificationStatus)
}
func (suite *FromClientAPITestSuite) TestProcessCreateStatusReply() {
@@ -409,6 +434,9 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusReply() {
statusJSON,
stream.EventTypeUpdate,
)
+
+ // Check for absence of Web Push notifications.
+ suite.checkNotWebPushed(testStructs.WebPushSender, receivingAccount.ID)
}
func (suite *FromClientAPITestSuite) TestProcessCreateStatusReplyMuted() {
@@ -470,6 +498,9 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusReplyMuted() {
suite.ErrorIs(err, db.ErrNoEntries)
suite.Nil(notif)
+
+ // Check for absence of Web Push notifications.
+ suite.checkNotWebPushed(testStructs.WebPushSender, receivingAccount.ID)
}
func (suite *FromClientAPITestSuite) TestProcessCreateStatusBoostMuted() {
@@ -531,6 +562,9 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusBoostMuted() {
suite.ErrorIs(err, db.ErrNoEntries)
suite.Nil(notif)
+
+ // Check for absence of Web Push notifications.
+ suite.checkNotWebPushed(testStructs.WebPushSender, receivingAccount.ID)
}
func (suite *FromClientAPITestSuite) TestProcessCreateStatusListRepliesPolicyListOnlyOK() {
@@ -607,6 +641,9 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusListRepliesPolicyLis
statusJSON,
stream.EventTypeUpdate,
)
+
+ // Check for absence of Web Push notifications.
+ suite.checkNotWebPushed(testStructs.WebPushSender, receivingAccount.ID)
}
func (suite *FromClientAPITestSuite) TestProcessCreateStatusListRepliesPolicyListOnlyNo() {
@@ -689,6 +726,9 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusListRepliesPolicyLis
"",
"",
)
+
+ // Check for absence of Web Push notifications.
+ suite.checkNotWebPushed(testStructs.WebPushSender, receivingAccount.ID)
}
func (suite *FromClientAPITestSuite) TestProcessCreateStatusReplyListRepliesPolicyNone() {
@@ -765,6 +805,9 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusReplyListRepliesPoli
"",
"",
)
+
+ // Check for absence of Web Push notifications.
+ suite.checkNotWebPushed(testStructs.WebPushSender, receivingAccount.ID)
}
func (suite *FromClientAPITestSuite) TestProcessCreateStatusBoost() {
@@ -829,6 +872,9 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusBoost() {
statusJSON,
stream.EventTypeUpdate,
)
+
+ // Check for absence of Web Push notifications.
+ suite.checkNotWebPushed(testStructs.WebPushSender, receivingAccount.ID)
}
func (suite *FromClientAPITestSuite) TestProcessCreateStatusBoostNoReblogs() {
@@ -981,6 +1027,9 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWhichBeginsConversat
conversationJSON,
stream.EventTypeConversation,
)
+
+ // Check for a Web Push mention notification.
+ suite.checkWebPushed(testStructs.WebPushSender, receivingAccount.ID, gtsmodel.NotificationMention)
}
// A public message to a local user should not result in a conversation notification.
@@ -1050,6 +1099,9 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWhichShouldNotCreate
"",
"",
)
+
+ // Check for a Web Push mention notification.
+ suite.checkWebPushed(testStructs.WebPushSender, receivingAccount.ID, gtsmodel.NotificationMention)
}
// A public status with a hashtag followed by a local user who does not otherwise follow the author
@@ -1123,6 +1175,9 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithFollowedHashtag(
"",
stream.EventTypeUpdate,
)
+
+ // Check for absence of Web Push notifications.
+ suite.checkNotWebPushed(testStructs.WebPushSender, receivingAccount.ID)
}
// A public status with a hashtag followed by a local user who does not otherwise follow the author
@@ -1204,6 +1259,9 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithFollowedHashtagA
"",
"",
)
+
+ // Check for absence of Web Push notifications.
+ suite.checkNotWebPushed(testStructs.WebPushSender, receivingAccount.ID)
}
// A boost of a public status with a hashtag followed by a local user
@@ -1306,6 +1364,9 @@ func (suite *FromClientAPITestSuite) TestProcessCreateBoostWithFollowedHashtag()
"",
stream.EventTypeUpdate,
)
+
+ // Check for absence of Web Push notifications.
+ suite.checkNotWebPushed(testStructs.WebPushSender, receivingAccount.ID)
}
// A boost of a public status with a hashtag followed by a local user
@@ -1416,6 +1477,9 @@ func (suite *FromClientAPITestSuite) TestProcessCreateBoostWithFollowedHashtagAn
"",
"",
)
+
+ // Check for absence of Web Push notifications.
+ suite.checkNotWebPushed(testStructs.WebPushSender, receivingAccount.ID)
}
// A boost of a public status with a hashtag followed by a local user
@@ -1526,6 +1590,9 @@ func (suite *FromClientAPITestSuite) TestProcessCreateBoostWithFollowedHashtagAn
"",
"",
)
+
+ // Check for absence of Web Push notifications.
+ suite.checkNotWebPushed(testStructs.WebPushSender, receivingAccount.ID)
}
// A public status with a hashtag followed by a local user who follows the author and has them on an exclusive list
@@ -1598,6 +1665,9 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithAuthorOnExclusiv
"",
"",
)
+
+ // Check for absence of Web Push notifications.
+ suite.checkNotWebPushed(testStructs.WebPushSender, receivingAccount.ID)
}
// A public status with a hashtag followed by a local user who follows the author and has them on an exclusive list
@@ -1712,6 +1782,9 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithAuthorOnExclusiv
"",
"",
)
+
+ // Check for absence of Web Push notifications.
+ suite.checkNotWebPushed(testStructs.WebPushSender, receivingAccount.ID)
}
// A public status with a hashtag followed by a local user who follows the author and has them on an exclusive list
@@ -1837,6 +1910,9 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithAuthorOnExclusiv
"",
"",
)
+
+ // Check for a Web Push status notification.
+ suite.checkWebPushed(testStructs.WebPushSender, receivingAccount.ID, gtsmodel.NotificationStatus)
}
// Updating a public status with a hashtag followed by a local user who does not otherwise follow the author
@@ -1910,6 +1986,9 @@ func (suite *FromClientAPITestSuite) TestProcessUpdateStatusWithFollowedHashtag(
"",
stream.EventTypeStatusUpdate,
)
+
+ // Check for absence of Web Push notifications.
+ suite.checkNotWebPushed(testStructs.WebPushSender, receivingAccount.ID)
}
func (suite *FromClientAPITestSuite) TestProcessStatusDelete() {
@@ -1963,6 +2042,9 @@ func (suite *FromClientAPITestSuite) TestProcessStatusDelete() {
stream.EventTypeDelete,
)
+ // Check for absence of Web Push notifications.
+ suite.checkNotWebPushed(testStructs.WebPushSender, receivingAccount.ID)
+
// Boost should no longer be in the database.
if !testrig.WaitFor(func() bool {
_, err := testStructs.State.DB.GetStatusByID(ctx, boostOfDeletedStatus.ID)
diff --git a/internal/processing/workers/fromfediapi_test.go b/internal/processing/workers/fromfediapi_test.go
index 88d0e6071..70886d698 100644
--- a/internal/processing/workers/fromfediapi_test.go
+++ b/internal/processing/workers/fromfediapi_test.go
@@ -240,7 +240,7 @@ func (suite *FromFediAPITestSuite) TestProcessFave() {
notif := &gtsmodel.Notification{}
err = testStructs.State.DB.GetWhere(context.Background(), where, notif)
suite.NoError(err)
- suite.Equal(gtsmodel.NotificationFave, notif.NotificationType)
+ suite.Equal(gtsmodel.NotificationFavourite, notif.NotificationType)
suite.Equal(fave.TargetAccountID, notif.TargetAccountID)
suite.Equal(fave.AccountID, notif.OriginAccountID)
suite.Equal(fave.StatusID, notif.StatusID)
@@ -313,7 +313,7 @@ func (suite *FromFediAPITestSuite) TestProcessFaveWithDifferentReceivingAccount(
notif := &gtsmodel.Notification{}
err = testStructs.State.DB.GetWhere(context.Background(), where, notif)
suite.NoError(err)
- suite.Equal(gtsmodel.NotificationFave, notif.NotificationType)
+ suite.Equal(gtsmodel.NotificationFavourite, notif.NotificationType)
suite.Equal(fave.TargetAccountID, notif.TargetAccountID)
suite.Equal(fave.AccountID, notif.OriginAccountID)
suite.Equal(fave.StatusID, notif.StatusID)
diff --git a/internal/processing/workers/surface.go b/internal/processing/workers/surface.go
index 4f6597b9a..4dc58c433 100644
--- a/internal/processing/workers/surface.go
+++ b/internal/processing/workers/surface.go
@@ -24,6 +24,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/processing/stream"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
+ "github.com/superseriousbusiness/gotosocial/internal/webpush"
)
// Surface wraps functions for 'surfacing' the result
@@ -38,5 +39,6 @@ type Surface struct {
Stream *stream.Processor
VisFilter *visibility.Filter
EmailSender email.Sender
+ WebPushSender webpush.Sender
Conversations *conversations.Processor
}
diff --git a/internal/processing/workers/surfacenotify.go b/internal/processing/workers/surfacenotify.go
index 1520d2ec0..fdbd5e3c1 100644
--- a/internal/processing/workers/surfacenotify.go
+++ b/internal/processing/workers/surfacenotify.go
@@ -250,7 +250,7 @@ func (s *Surface) notifyFave(
// notify status author
// of fave by account.
if err := s.Notify(ctx,
- gtsmodel.NotificationFave,
+ gtsmodel.NotificationFavourite,
fave.TargetAccount,
fave.Account,
fave.StatusID,
@@ -521,7 +521,7 @@ func (s *Surface) notifySignup(ctx context.Context, newUser *gtsmodel.User) erro
var errs gtserror.MultiError
for _, mod := range modAccounts {
if err := s.Notify(ctx,
- gtsmodel.NotificationSignup,
+ gtsmodel.NotificationAdminSignup,
mod,
newUser.Account,
"",
@@ -647,5 +647,10 @@ func (s *Surface) Notify(
}
s.Stream.Notify(ctx, targetAccount, apiNotif)
+ // Send Web Push notification to the user.
+ if err = s.WebPushSender.Send(ctx, notif, filters, compiledMutes); err != nil {
+ return gtserror.Newf("error sending Web Push notifications: %w", err)
+ }
+
return nil
}
diff --git a/internal/processing/workers/surfacenotify_test.go b/internal/processing/workers/surfacenotify_test.go
index 52ee89e8b..6444314e2 100644
--- a/internal/processing/workers/surfacenotify_test.go
+++ b/internal/processing/workers/surfacenotify_test.go
@@ -45,6 +45,7 @@ func (suite *SurfaceNotifyTestSuite) TestSpamNotifs() {
Stream: testStructs.Processor.Stream(),
VisFilter: visibility.NewFilter(testStructs.State),
EmailSender: testStructs.EmailSender,
+ WebPushSender: testStructs.WebPushSender,
Conversations: testStructs.Processor.Conversations(),
}
diff --git a/internal/processing/workers/workers.go b/internal/processing/workers/workers.go
index ad673481b..9f37f554e 100644
--- a/internal/processing/workers/workers.go
+++ b/internal/processing/workers/workers.go
@@ -28,6 +28,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/processing/stream"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
+ "github.com/superseriousbusiness/gotosocial/internal/webpush"
"github.com/superseriousbusiness/gotosocial/internal/workers"
)
@@ -44,6 +45,7 @@ func New(
converter *typeutils.Converter,
visFilter *visibility.Filter,
emailSender email.Sender,
+ webPushSender webpush.Sender,
account *account.Processor,
media *media.Processor,
stream *stream.Processor,
@@ -65,6 +67,7 @@ func New(
Stream: stream,
VisFilter: visFilter,
EmailSender: emailSender,
+ WebPushSender: webPushSender,
Conversations: conversations,
}
diff --git a/internal/text/substring.go b/internal/text/substring.go
new file mode 100644
index 000000000..659b4ab50
--- /dev/null
+++ b/internal/text/substring.go
@@ -0,0 +1,45 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package text
+
+import (
+ "github.com/rivo/uniseg"
+)
+
+// FirstNBytesByWords produces a prefix substring of up to n bytes from a given string, respecting Unicode grapheme and
+// word boundaries. The substring may be empty, and may include leading or trailing whitespace.
+func FirstNBytesByWords(s string, n int) string {
+ substringEnd := 0
+
+ graphemes := uniseg.NewGraphemes(s)
+ for graphemes.Next() {
+
+ if !graphemes.IsWordBoundary() {
+ continue
+ }
+
+ _, end := graphemes.Positions()
+ if end > n {
+ break
+ }
+
+ substringEnd = end
+ }
+
+ return s[0:substringEnd]
+}
diff --git a/internal/text/substring_test.go b/internal/text/substring_test.go
new file mode 100644
index 000000000..f85688218
--- /dev/null
+++ b/internal/text/substring_test.go
@@ -0,0 +1,47 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package text_test
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/text"
+)
+
+type SubstringTestSuite struct {
+ suite.Suite
+}
+
+func (suite *SubstringTestSuite) TestText() {
+ suite.Equal(
+ "Sphinx of black quartz, ",
+ text.FirstNBytesByWords("Sphinx of black quartz, judge my vow!", 25),
+ )
+}
+
+func (suite *SubstringTestSuite) TestEmoji() {
+ suite.Equal(
+ "🏳️‍⚧️ ",
+ text.FirstNBytesByWords("🏳️‍⚧️ 🙈", 20),
+ )
+}
+
+func TestSubstringTestSuite(t *testing.T) {
+ suite.Run(t, new(SubstringTestSuite))
+}
diff --git a/internal/transport/transport_test.go b/internal/transport/transport_test.go
index c51c0755f..61df16e52 100644
--- a/internal/transport/transport_test.go
+++ b/internal/transport/transport_test.go
@@ -89,7 +89,13 @@ func (suite *TransportTestSuite) SetupTest() {
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../testrig/media")), suite.mediaManager)
suite.sentEmails = make(map[string]string)
suite.emailSender = testrig.NewEmailSender("../../web/template/", suite.sentEmails)
- suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager)
+ suite.processor = testrig.NewTestProcessor(
+ &suite.state,
+ suite.federator,
+ suite.emailSender,
+ testrig.NewNoopWebPushSender(),
+ suite.mediaManager,
+ )
testrig.StandardDBSetup(suite.db, nil)
testrig.StandardStorageSetup(suite.storage, "../../testrig/media")
diff --git a/internal/typeutils/converter_test.go b/internal/typeutils/converter_test.go
index a5fe5201b..3ed2828f2 100644
--- a/internal/typeutils/converter_test.go
+++ b/internal/typeutils/converter_test.go
@@ -540,8 +540,9 @@ func (suite *TypeUtilsTestSuite) GetProcessor() *processing.Processor {
mediaManager := testrig.NewTestMediaManager(&suite.state)
federator := testrig.NewTestFederator(&suite.state, transportController, mediaManager)
emailSender := testrig.NewEmailSender("../../web/template/", nil)
+ webPushSender := testrig.NewNoopWebPushSender()
- processor := testrig.NewTestProcessor(&suite.state, federator, emailSender, mediaManager)
+ processor := testrig.NewTestProcessor(&suite.state, federator, emailSender, webPushSender, mediaManager)
testrig.StartWorkers(&suite.state, processor.Workers())
return processor
diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go
index cdc250f98..6739d0540 100644
--- a/internal/typeutils/internaltofrontend.go
+++ b/internal/typeutils/internaltofrontend.go
@@ -616,6 +616,11 @@ func (c *Converter) AccountToAdminAPIAccount(ctx context.Context, a *gtsmodel.Ac
}
func (c *Converter) AppToAPIAppSensitive(ctx context.Context, a *gtsmodel.Application) (*apimodel.Application, error) {
+ vapidKeyPair, err := c.state.DB.GetVAPIDKeyPair(ctx)
+ if err != nil {
+ return nil, gtserror.Newf("error getting VAPID public key: %w", err)
+ }
+
return &apimodel.Application{
ID: a.ID,
Name: a.Name,
@@ -623,6 +628,7 @@ func (c *Converter) AppToAPIAppSensitive(ctx context.Context, a *gtsmodel.Applic
RedirectURI: a.RedirectURI,
ClientID: a.ClientID,
ClientSecret: a.ClientSecret,
+ VapidKey: vapidKeyPair.Public,
}, nil
}
@@ -1878,6 +1884,12 @@ func (c *Converter) InstanceToAPIV2Instance(ctx context.Context, i *gtsmodel.Ins
instance.Configuration.Emojis.EmojiSizeLimit = int(config.GetMediaEmojiLocalMaxSize()) // #nosec G115 -- Already validated.
instance.Configuration.OIDCEnabled = config.GetOIDCEnabled()
+ vapidKeyPair, err := c.state.DB.GetVAPIDKeyPair(ctx)
+ if err != nil {
+ return nil, gtserror.Newf("error getting VAPID public key: %w", err)
+ }
+ instance.Configuration.VAPID.PublicKey = vapidKeyPair.Public
+
// registrations
instance.Registrations.Enabled = config.GetAccountsRegistrationOpen()
instance.Registrations.ApprovalRequired = true // always required
@@ -2985,3 +2997,36 @@ func (c *Converter) InteractionReqToAPIInteractionReq(
URI: req.URI,
}, nil
}
+
+func (c *Converter) WebPushSubscriptionToAPIWebPushSubscription(
+ ctx context.Context,
+ subscription *gtsmodel.WebPushSubscription,
+) (*apimodel.WebPushSubscription, error) {
+ vapidKeyPair, err := c.state.DB.GetVAPIDKeyPair(ctx)
+ if err != nil {
+ return nil, gtserror.Newf("error getting VAPID key pair: %w", err)
+ }
+
+ return &apimodel.WebPushSubscription{
+ ID: subscription.ID,
+ Endpoint: subscription.Endpoint,
+ ServerKey: vapidKeyPair.Public,
+ Alerts: apimodel.WebPushSubscriptionAlerts{
+ Follow: subscription.NotificationFlags.Get(gtsmodel.NotificationFollow),
+ FollowRequest: subscription.NotificationFlags.Get(gtsmodel.NotificationFollowRequest),
+ Favourite: subscription.NotificationFlags.Get(gtsmodel.NotificationFavourite),
+ Mention: subscription.NotificationFlags.Get(gtsmodel.NotificationMention),
+ Reblog: subscription.NotificationFlags.Get(gtsmodel.NotificationReblog),
+ Poll: subscription.NotificationFlags.Get(gtsmodel.NotificationPoll),
+ Status: subscription.NotificationFlags.Get(gtsmodel.NotificationStatus),
+ Update: subscription.NotificationFlags.Get(gtsmodel.NotificationUpdate),
+ AdminSignup: subscription.NotificationFlags.Get(gtsmodel.NotificationAdminSignup),
+ AdminReport: subscription.NotificationFlags.Get(gtsmodel.NotificationAdminReport),
+ PendingFavourite: subscription.NotificationFlags.Get(gtsmodel.NotificationPendingFave),
+ PendingReply: subscription.NotificationFlags.Get(gtsmodel.NotificationPendingReply),
+ PendingReblog: subscription.NotificationFlags.Get(gtsmodel.NotificationPendingReblog),
+ },
+ Policy: apimodel.WebPushNotificationPolicyAll,
+ Standard: true,
+ }, nil
+}
diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go
index 005abf4eb..1ca0840a5 100644
--- a/internal/typeutils/internaltofrontend_test.go
+++ b/internal/typeutils/internaltofrontend_test.go
@@ -21,6 +21,7 @@ import (
"bytes"
"context"
"encoding/json"
+ "strings"
"testing"
"github.com/stretchr/testify/suite"
@@ -2061,6 +2062,13 @@ func (suite *InternalToFrontendTestSuite) TestInstanceV2ToFrontend() {
b, err := json.MarshalIndent(instance, "", " ")
suite.NoError(err)
+ // The VAPID public key changes from run to run.
+ vapidKeyPair, err := suite.db.GetVAPIDKeyPair(ctx)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ s := strings.Replace(string(b), vapidKeyPair.Public, "VAPID_PUBLIC_KEY_PLACEHOLDER", 1)
+
suite.Equal(`{
"domain": "localhost:8080",
"account_domain": "localhost:8080",
@@ -2140,6 +2148,9 @@ func (suite *InternalToFrontendTestSuite) TestInstanceV2ToFrontend() {
},
"emojis": {
"emoji_size_limit": 51200
+ },
+ "vapid": {
+ "public_key": "VAPID_PUBLIC_KEY_PLACEHOLDER"
}
},
"registrations": {
@@ -2184,7 +2195,7 @@ func (suite *InternalToFrontendTestSuite) TestInstanceV2ToFrontend() {
"rules": [],
"terms": "\u003cp\u003eThis is where a list of terms and conditions might go.\u003c/p\u003e\u003cp\u003eFor example:\u003c/p\u003e\u003cp\u003eIf you want to sign up on this instance, you oughta know that we:\u003c/p\u003e\u003col\u003e\u003cli\u003eWill sell your data to whoever offers.\u003c/li\u003e\u003cli\u003eSecure the server with password \u003ccode\u003epassword\u003c/code\u003e wherever possible.\u003c/li\u003e\u003c/ol\u003e",
"terms_text": "This is where a list of terms and conditions might go.\n\nFor example:\n\nIf you want to sign up on this instance, you oughta know that we:\n\n1. Will sell your data to whoever offers.\n2. Secure the server with password `+"`"+`password`+"`"+` wherever possible."
-}`, string(b))
+}`, s)
}
func (suite *InternalToFrontendTestSuite) TestEmojiToFrontend() {
diff --git a/internal/webpush/realsender.go b/internal/webpush/realsender.go
new file mode 100644
index 000000000..8b3a1bd66
--- /dev/null
+++ b/internal/webpush/realsender.go
@@ -0,0 +1,341 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package webpush
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "slices"
+ "strings"
+ "time"
+
+ webpushgo "github.com/SherClockHolmes/webpush-go"
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/filter/usermute"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/httpclient"
+ "github.com/superseriousbusiness/gotosocial/internal/log"
+ "github.com/superseriousbusiness/gotosocial/internal/state"
+ "github.com/superseriousbusiness/gotosocial/internal/text"
+ "github.com/superseriousbusiness/gotosocial/internal/typeutils"
+)
+
+// realSender is the production Web Push sender, backed by an HTTP client, DB, and worker pool.
+type realSender struct {
+ httpClient *http.Client
+ state *state.State
+ converter *typeutils.Converter
+}
+
+// NewRealSender creates a Sender from an http.Client instead of an httpclient.Client.
+// This should only be used by NewSender and in tests.
+func NewRealSender(httpClient *http.Client, state *state.State, converter *typeutils.Converter) Sender {
+ return &realSender{
+ httpClient: httpClient,
+ state: state,
+ converter: converter,
+ }
+}
+
+func (r *realSender) Send(
+ ctx context.Context,
+ notification *gtsmodel.Notification,
+ filters []*gtsmodel.Filter,
+ mutes *usermute.CompiledUserMuteList,
+) error {
+ // Load subscriptions.
+ subscriptions, err := r.state.DB.GetWebPushSubscriptionsByAccountID(ctx, notification.TargetAccountID)
+ if err != nil {
+ return gtserror.Newf(
+ "error getting Web Push subscriptions for account %s: %w",
+ notification.TargetAccountID,
+ err,
+ )
+ }
+
+ // Subscriptions we're actually going to send to.
+ relevantSubscriptions := slices.DeleteFunc(
+ subscriptions,
+ func(subscription *gtsmodel.WebPushSubscription) bool {
+ // Remove subscriptions that don't want this type of notification.
+ return !subscription.NotificationFlags.Get(notification.NotificationType)
+ },
+ )
+ if len(relevantSubscriptions) == 0 {
+ return nil
+ }
+
+ // Get VAPID keys.
+ vapidKeyPair, err := r.state.DB.GetVAPIDKeyPair(ctx)
+ if err != nil {
+ return gtserror.Newf("error getting VAPID key pair: %w", err)
+ }
+
+ // Get contact email for this instance, if available.
+ domain := config.GetHost()
+ instance, err := r.state.DB.GetInstance(ctx, domain)
+ if err != nil {
+ return gtserror.Newf("error getting current instance: %w", err)
+ }
+ vapidSubjectEmail := instance.ContactEmail
+ if vapidSubjectEmail == "" {
+ // Instance contact email not configured. Use a dummy address.
+ vapidSubjectEmail = "admin@" + domain
+ }
+
+ // Get target account settings.
+ targetAccountSettings, err := r.state.DB.GetAccountSettings(ctx, notification.TargetAccountID)
+ if err != nil {
+ return gtserror.Newf("error getting settings for account %s: %w", notification.TargetAccountID, err)
+ }
+
+ // Get API representations of notification and accounts involved.
+ apiNotification, err := r.converter.NotificationToAPINotification(ctx, notification, filters, mutes)
+ if err != nil {
+ return gtserror.Newf("error converting notification %s to API representation: %w", notification.ID, err)
+ }
+
+ // Queue up a .Send() call for each relevant subscription.
+ for _, subscription := range relevantSubscriptions {
+ r.state.Workers.WebPush.Queue.Push(func(ctx context.Context) {
+ if err := r.sendToSubscription(
+ ctx,
+ vapidKeyPair,
+ vapidSubjectEmail,
+ targetAccountSettings,
+ subscription,
+ notification,
+ apiNotification,
+ ); err != nil {
+ log.Errorf(
+ ctx,
+ "error sending Web Push notification for subscription with token ID %s: %v",
+ subscription.TokenID,
+ err,
+ )
+ }
+ })
+ }
+
+ return nil
+}
+
+// sendToSubscription sends a notification to a single Web Push subscription.
+func (r *realSender) sendToSubscription(
+ ctx context.Context,
+ vapidKeyPair *gtsmodel.VAPIDKeyPair,
+ vapidSubjectEmail string,
+ targetAccountSettings *gtsmodel.AccountSettings,
+ subscription *gtsmodel.WebPushSubscription,
+ notification *gtsmodel.Notification,
+ apiNotification *apimodel.Notification,
+) error {
+ const (
+ // TTL is an arbitrary time to ask the Web Push server to store notifications
+ // while waiting for the client to retrieve them.
+ TTL = 48 * time.Hour
+
+ // responseBodyMaxLen limits how much of the Web Push server response we read for error messages.
+ responseBodyMaxLen = 1024
+ )
+
+ // Get the associated access token.
+ token, err := r.state.DB.GetTokenByID(ctx, subscription.TokenID)
+ if err != nil {
+ return gtserror.Newf("error getting token %s: %w", subscription.TokenID, err)
+ }
+
+ // Create push notification payload struct.
+ pushNotification := &apimodel.WebPushNotification{
+ NotificationID: apiNotification.ID,
+ NotificationType: apiNotification.Type,
+ Title: formatNotificationTitle(ctx, subscription, notification, apiNotification),
+ Body: formatNotificationBody(apiNotification),
+ Icon: apiNotification.Account.Avatar,
+ PreferredLocale: targetAccountSettings.Language,
+ AccessToken: token.Access,
+ }
+
+ // Encode the push notification as JSON.
+ pushNotificationBytes, err := json.Marshal(pushNotification)
+ if err != nil {
+ return gtserror.Newf("error encoding Web Push notification: %w", err)
+ }
+
+ // Send push notification.
+ resp, err := webpushgo.SendNotificationWithContext(
+ ctx,
+ pushNotificationBytes,
+ &webpushgo.Subscription{
+ Endpoint: subscription.Endpoint,
+ Keys: webpushgo.Keys{
+ Auth: subscription.Auth,
+ P256dh: subscription.P256dh,
+ },
+ },
+ &webpushgo.Options{
+ HTTPClient: r.httpClient,
+ Subscriber: vapidSubjectEmail,
+ VAPIDPublicKey: vapidKeyPair.Public,
+ VAPIDPrivateKey: vapidKeyPair.Private,
+ TTL: int(TTL.Seconds()),
+ },
+ )
+ if err != nil {
+ return gtserror.Newf("error sending Web Push notification: %w", err)
+ }
+ defer resp.Body.Close()
+
+ switch {
+ // All good, delivered.
+ case resp.StatusCode >= 200 && resp.StatusCode <= 299:
+ return nil
+
+ // Temporary outage or some other delivery issue.
+ case resp.StatusCode == http.StatusRequestTimeout ||
+ resp.StatusCode == http.StatusRequestEntityTooLarge ||
+ resp.StatusCode == http.StatusTooManyRequests ||
+ (resp.StatusCode >= 500 && resp.StatusCode <= 599):
+
+ // Try to get the response body.
+ bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, responseBodyMaxLen))
+ if err != nil {
+ return gtserror.Newf("error reading Web Push server response: %w", err)
+ }
+
+ // Return the error with its response body.
+ return gtserror.Newf(
+ "unexpected HTTP status %s received when sending Web Push notification: %s",
+ resp.Status,
+ string(bodyBytes),
+ )
+
+ // Some serious error that indicates auth problems, not a Web Push server, etc.
+ // We should not send any more notifications to this subscription. Try to delete it.
+ default:
+ err := r.state.DB.DeleteWebPushSubscriptionByTokenID(ctx, subscription.TokenID)
+ if err != nil {
+ return gtserror.Newf(
+ "received HTTP status %s but failed to delete subscription: %s",
+ resp.Status,
+ err,
+ )
+ }
+
+ log.Infof(
+ ctx,
+ "Deleted Web Push subscription with token ID %s because push server sent HTTP status %s",
+ subscription.TokenID, resp.Status,
+ )
+ return nil
+ }
+}
+
+// formatNotificationTitle creates a title for a Web Push notification from the notification type and account's name.
+func formatNotificationTitle(
+ ctx context.Context,
+ subscription *gtsmodel.WebPushSubscription,
+ notification *gtsmodel.Notification,
+ apiNotification *apimodel.Notification,
+) string {
+ displayNameOrAcct := apiNotification.Account.DisplayName
+ if displayNameOrAcct == "" {
+ displayNameOrAcct = apiNotification.Account.Acct
+ }
+
+ switch notification.NotificationType {
+ case gtsmodel.NotificationFollow:
+ return fmt.Sprintf("%s followed you", displayNameOrAcct)
+ case gtsmodel.NotificationFollowRequest:
+ return fmt.Sprintf("%s requested to follow you", displayNameOrAcct)
+ case gtsmodel.NotificationMention:
+ return fmt.Sprintf("%s mentioned you", displayNameOrAcct)
+ case gtsmodel.NotificationReblog:
+ return fmt.Sprintf("%s boosted your post", displayNameOrAcct)
+ case gtsmodel.NotificationFavourite:
+ return fmt.Sprintf("%s faved your post", displayNameOrAcct)
+ case gtsmodel.NotificationPoll:
+ if subscription.AccountID == notification.TargetAccountID {
+ return "Your poll has ended"
+ } else {
+ return fmt.Sprintf("%s's poll has ended", displayNameOrAcct)
+ }
+ case gtsmodel.NotificationStatus:
+ return fmt.Sprintf("%s posted", displayNameOrAcct)
+ case gtsmodel.NotificationAdminSignup:
+ return fmt.Sprintf("%s requested to sign up", displayNameOrAcct)
+ case gtsmodel.NotificationPendingFave:
+ return fmt.Sprintf("%s faved your post, which requires your approval", displayNameOrAcct)
+ case gtsmodel.NotificationPendingReply:
+ return fmt.Sprintf("%s mentioned you, which requires your approval", displayNameOrAcct)
+ case gtsmodel.NotificationPendingReblog:
+ return fmt.Sprintf("%s boosted your post, which requires your approval", displayNameOrAcct)
+ case gtsmodel.NotificationAdminReport:
+ return fmt.Sprintf("%s submitted a report", displayNameOrAcct)
+ case gtsmodel.NotificationUpdate:
+ return fmt.Sprintf("%s updated their post", displayNameOrAcct)
+ default:
+ log.Warnf(ctx, "Unknown notification type: %d", notification.NotificationType)
+ return fmt.Sprintf(
+ "%s did something (unknown notification type %d)",
+ displayNameOrAcct,
+ notification.NotificationType,
+ )
+ }
+}
+
+// formatNotificationBody creates a body for a Web Push notification,
+// from the CW or beginning of the body text of the status, if there is one,
+// or the beginning of the bio text of the related account.
+func formatNotificationBody(apiNotification *apimodel.Notification) string {
+ // bodyMaxLen is a polite maximum length for a Web Push notification's body text, in bytes. Note that this isn't
+ // limited per se, but Web Push servers may reject anything with a total request body size over 4k.
+ const bodyMaxLen = 3000
+
+ var body string
+ if apiNotification.Status != nil {
+ if apiNotification.Status.SpoilerText != "" {
+ body = apiNotification.Status.SpoilerText
+ } else {
+ body = text.SanitizeToPlaintext(apiNotification.Status.Content)
+ }
+ } else {
+ body = text.SanitizeToPlaintext(apiNotification.Account.Note)
+ }
+ return firstNBytesTrimSpace(body, bodyMaxLen)
+}
+
+// firstNBytesTrimSpace returns the first N bytes of a string, trimming leading and trailing whitespace.
+func firstNBytesTrimSpace(s string, n int) string {
+ return strings.TrimSpace(text.FirstNBytesByWords(strings.TrimSpace(s), n))
+}
+
+// gtsHTTPClientRoundTripper helps wrap a GtS HTTP client back into a regular HTTP client,
+// so that webpush-go can use our IP filters, bad hosts list, and retries.
+type gtsHTTPClientRoundTripper struct {
+ httpClient *httpclient.Client
+}
+
+func (r *gtsHTTPClientRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) {
+ return r.httpClient.Do(request)
+}
diff --git a/internal/webpush/realsender_test.go b/internal/webpush/realsender_test.go
new file mode 100644
index 000000000..c94bbbb8e
--- /dev/null
+++ b/internal/webpush/realsender_test.go
@@ -0,0 +1,263 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package webpush_test
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/cleaner"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/email"
+ "github.com/superseriousbusiness/gotosocial/internal/federation"
+ "github.com/superseriousbusiness/gotosocial/internal/filter/interaction"
+ "github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/media"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/internal/processing"
+ "github.com/superseriousbusiness/gotosocial/internal/state"
+ "github.com/superseriousbusiness/gotosocial/internal/storage"
+ "github.com/superseriousbusiness/gotosocial/internal/subscriptions"
+ "github.com/superseriousbusiness/gotosocial/internal/transport"
+ "github.com/superseriousbusiness/gotosocial/internal/typeutils"
+ "github.com/superseriousbusiness/gotosocial/internal/webpush"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type RealSenderStandardTestSuite struct {
+ suite.Suite
+ db db.DB
+ storage *storage.Driver
+ state state.State
+ mediaManager *media.Manager
+ typeconverter *typeutils.Converter
+ httpClient *testrig.MockHTTPClient
+ transportController transport.Controller
+ federator *federation.Federator
+ oauthServer oauth.Server
+ emailSender email.Sender
+ webPushSender webpush.Sender
+
+ // standard suite models
+ testTokens map[string]*gtsmodel.Token
+ testClients map[string]*gtsmodel.Client
+ testApplications map[string]*gtsmodel.Application
+ testUsers map[string]*gtsmodel.User
+ testAccounts map[string]*gtsmodel.Account
+ testAttachments map[string]*gtsmodel.MediaAttachment
+ testStatuses map[string]*gtsmodel.Status
+ testTags map[string]*gtsmodel.Tag
+ testMentions map[string]*gtsmodel.Mention
+ testEmojis map[string]*gtsmodel.Emoji
+ testNotifications map[string]*gtsmodel.Notification
+ testWebPushSubscriptions map[string]*gtsmodel.WebPushSubscription
+
+ processor *processing.Processor
+
+ webPushHttpClientDo func(request *http.Request) (*http.Response, error)
+}
+
+func (suite *RealSenderStandardTestSuite) SetupSuite() {
+ suite.testTokens = testrig.NewTestTokens()
+ suite.testClients = testrig.NewTestClients()
+ suite.testApplications = testrig.NewTestApplications()
+ suite.testUsers = testrig.NewTestUsers()
+ suite.testAccounts = testrig.NewTestAccounts()
+ suite.testAttachments = testrig.NewTestAttachments()
+ suite.testStatuses = testrig.NewTestStatuses()
+ suite.testTags = testrig.NewTestTags()
+ suite.testMentions = testrig.NewTestMentions()
+ suite.testEmojis = testrig.NewTestEmojis()
+ suite.testNotifications = testrig.NewTestNotifications()
+ suite.testWebPushSubscriptions = testrig.NewTestWebPushSubscriptions()
+}
+
+func (suite *RealSenderStandardTestSuite) SetupTest() {
+ suite.state.Caches.Init()
+
+ testrig.InitTestConfig()
+ testrig.InitTestLog()
+
+ suite.db = testrig.NewTestDB(&suite.state)
+ suite.state.DB = suite.db
+ suite.storage = testrig.NewInMemoryStorage()
+ suite.state.Storage = suite.storage
+ suite.typeconverter = typeutils.NewConverter(&suite.state)
+
+ testrig.StartTimelines(
+ &suite.state,
+ visibility.NewFilter(&suite.state),
+ suite.typeconverter,
+ )
+
+ suite.httpClient = testrig.NewMockHTTPClient(nil, "../../testrig/media")
+ suite.httpClient.TestRemotePeople = testrig.NewTestFediPeople()
+ suite.httpClient.TestRemoteStatuses = testrig.NewTestFediStatuses()
+
+ suite.transportController = testrig.NewTestTransportController(&suite.state, suite.httpClient)
+ suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
+ suite.federator = testrig.NewTestFederator(&suite.state, suite.transportController, suite.mediaManager)
+ suite.oauthServer = testrig.NewTestOauthServer(suite.db)
+ suite.emailSender = testrig.NewEmailSender("../../web/template/", nil)
+
+ suite.webPushSender = webpush.NewRealSender(
+ &http.Client{
+ Transport: suite,
+ },
+ &suite.state,
+ suite.typeconverter,
+ )
+
+ suite.processor = processing.NewProcessor(
+ cleaner.New(&suite.state),
+ subscriptions.New(
+ &suite.state,
+ suite.transportController,
+ suite.typeconverter,
+ ),
+ suite.typeconverter,
+ suite.federator,
+ suite.oauthServer,
+ suite.mediaManager,
+ &suite.state,
+ suite.emailSender,
+ suite.webPushSender,
+ visibility.NewFilter(&suite.state),
+ interaction.NewFilter(&suite.state),
+ )
+ testrig.StartWorkers(&suite.state, suite.processor.Workers())
+
+ testrig.StandardDBSetup(suite.db, suite.testAccounts)
+ testrig.StandardStorageSetup(suite.storage, "../../testrig/media")
+}
+
+func (suite *RealSenderStandardTestSuite) TearDownTest() {
+ testrig.StandardDBTeardown(suite.db)
+ testrig.StandardStorageTeardown(suite.storage)
+ testrig.StopWorkers(&suite.state)
+ suite.webPushHttpClientDo = nil
+}
+
+// RoundTrip implements http.RoundTripper with a closure stored in the test suite.
+func (suite *RealSenderStandardTestSuite) RoundTrip(request *http.Request) (*http.Response, error) {
+ return suite.webPushHttpClientDo(request)
+}
+
+// notifyingReadCloser is a zero-length io.ReadCloser that can tell us when it's been closed,
+// indicating the simulated Web Push server response has been sent, received, read, and closed.
+type notifyingReadCloser struct {
+ bodyClosed chan struct{}
+}
+
+func (rc *notifyingReadCloser) Read(_ []byte) (n int, err error) {
+ return 0, io.EOF
+}
+
+func (rc *notifyingReadCloser) Close() error {
+ rc.bodyClosed <- struct{}{}
+ close(rc.bodyClosed)
+ return nil
+}
+
+// Simulate sending a push notification with the suite's fake web client.
+func (suite *RealSenderStandardTestSuite) simulatePushNotification(
+ statusCode int,
+ expectDeletedSubscription bool,
+) error {
+ // Don't let the test run forever if the push notification was not sent for some reason.
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ notification, err := suite.state.DB.GetNotificationByID(ctx, suite.testNotifications["local_account_1_like"].ID)
+ if !suite.NoError(err) {
+ suite.FailNow("Couldn't fetch notification to send")
+ }
+
+ rc := &notifyingReadCloser{
+ bodyClosed: make(chan struct{}, 1),
+ }
+
+ // Simulate a response from the Web Push server.
+ suite.webPushHttpClientDo = func(request *http.Request) (*http.Response, error) {
+ return &http.Response{
+ Status: http.StatusText(statusCode),
+ StatusCode: statusCode,
+ Body: rc,
+ }, nil
+ }
+
+ // Send the push notification.
+ sendError := suite.webPushSender.Send(ctx, notification, nil, nil)
+
+ // Wait for it to be sent or for the context to time out.
+ bodyClosed := false
+ contextExpired := false
+ select {
+ case <-rc.bodyClosed:
+ bodyClosed = true
+ case <-ctx.Done():
+ contextExpired = true
+ }
+ suite.True(bodyClosed)
+ suite.False(contextExpired)
+
+ // Look for the associated Web Push subscription. Some server responses should delete it.
+ subscription, err := suite.state.DB.GetWebPushSubscriptionByTokenID(
+ ctx,
+ suite.testWebPushSubscriptions["local_account_1_token_1"].TokenID,
+ )
+ if expectDeletedSubscription {
+ suite.ErrorIs(err, db.ErrNoEntries)
+ } else {
+ suite.NotNil(subscription)
+ }
+
+ return sendError
+}
+
+// Test a successful response to sending a push notification.
+func (suite *RealSenderStandardTestSuite) TestSendSuccess() {
+ suite.NoError(suite.simulatePushNotification(http.StatusOK, false))
+}
+
+// Test a rate-limiting response to sending a push notification.
+// This should not delete the subscription.
+func (suite *RealSenderStandardTestSuite) TestRateLimited() {
+ suite.NoError(suite.simulatePushNotification(http.StatusTooManyRequests, false))
+}
+
+// Test a non-special-cased client error response to sending a push notification.
+// This should delete the subscription.
+func (suite *RealSenderStandardTestSuite) TestClientError() {
+ suite.NoError(suite.simulatePushNotification(http.StatusBadRequest, true))
+}
+
+// Test a server error response to sending a push notification.
+// This should not delete the subscription.
+func (suite *RealSenderStandardTestSuite) TestServerError() {
+ suite.NoError(suite.simulatePushNotification(http.StatusInternalServerError, false))
+}
+
+func TestRealSenderStandardTestSuite(t *testing.T) {
+ suite.Run(t, &RealSenderStandardTestSuite{})
+}
diff --git a/internal/webpush/sender.go b/internal/webpush/sender.go
new file mode 100644
index 000000000..5331f049a
--- /dev/null
+++ b/internal/webpush/sender.go
@@ -0,0 +1,54 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package webpush
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/superseriousbusiness/gotosocial/internal/filter/usermute"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/httpclient"
+ "github.com/superseriousbusiness/gotosocial/internal/state"
+ "github.com/superseriousbusiness/gotosocial/internal/typeutils"
+)
+
+// Sender can send Web Push notifications.
+type Sender interface {
+ // Send queues up a notification for delivery to all of an account's Web Push subscriptions.
+ Send(
+ ctx context.Context,
+ notification *gtsmodel.Notification,
+ filters []*gtsmodel.Filter,
+ mutes *usermute.CompiledUserMuteList,
+ ) error
+}
+
+// NewSender creates a new sender from an HTTP client, DB, and worker pool.
+func NewSender(httpClient *httpclient.Client, state *state.State, converter *typeutils.Converter) Sender {
+ return NewRealSender(
+ &http.Client{
+ Transport: &gtsHTTPClientRoundTripper{
+ httpClient: httpClient,
+ },
+ // Other fields are already set on the http.Client inside the httpclient.Client.
+ },
+ state,
+ converter,
+ )
+}
diff --git a/internal/workers/workers.go b/internal/workers/workers.go
index 4cf549041..50ad3cce5 100644
--- a/internal/workers/workers.go
+++ b/internal/workers/workers.go
@@ -54,6 +54,10 @@ type Workers struct {
// eg., import tasks, admin tasks.
Processing FnWorkerPool
+ // WebPush provides a worker pool for
+ // delivering Web Push notifications.
+ WebPush FnWorkerPool
+
// prevent pass-by-value.
_ nocopy
}
@@ -90,6 +94,10 @@ func (w *Workers) Start() {
n = maxprocs
w.Processing.Start(n)
log.Infof(nil, "started %d processing workers", n)
+
+ n = maxprocs
+ w.WebPush.Start(n)
+ log.Infof(nil, "started %d Web Push workers", n)
}
// Stop will stop all of the contained
@@ -113,6 +121,9 @@ func (w *Workers) Stop() {
w.Processing.Stop()
log.Info(nil, "stopped processing workers")
+
+ w.WebPush.Stop()
+ log.Info(nil, "stopped WebPush workers")
}
// nocopy when embedded will signal linter to