From 5b765d734ee70f0a8a0790444d60969a727567f8 Mon Sep 17 00:00:00 2001 From: Vyr Cossont Date: Thu, 23 Jan 2025 16:47:30 -0800 Subject: [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 --- internal/gtsmodel/notification.go | 29 +++++++---- internal/gtsmodel/vapidkeypair.go | 28 +++++++++++ internal/gtsmodel/webpushsubscription.go | 82 ++++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 9 deletions(-) create mode 100644 internal/gtsmodel/vapidkeypair.go create mode 100644 internal/gtsmodel/webpushsubscription.go (limited to 'internal/gtsmodel') 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 . + +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 . + +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<