mirror of
https://github.com/ergochat/ergo.git
synced 2025-01-28 21:24:10 +01:00
implement draft/webpush
This commit is contained in:
parent
375079e636
commit
016cf5c100
19
default.yaml
19
default.yaml
@ -922,6 +922,7 @@ fakelag:
|
|||||||
"MARKREAD": 16
|
"MARKREAD": 16
|
||||||
"MONITOR": 1
|
"MONITOR": 1
|
||||||
"WHO": 4
|
"WHO": 4
|
||||||
|
"WEBPUSH": 1
|
||||||
|
|
||||||
# the roleplay commands are semi-standardized extensions to IRC that allow
|
# the roleplay commands are semi-standardized extensions to IRC that allow
|
||||||
# sending and receiving messages from pseudo-nicknames. this can be used either
|
# sending and receiving messages from pseudo-nicknames. this can be used either
|
||||||
@ -1067,3 +1068,21 @@ history:
|
|||||||
# whether to allow customization of the config at runtime using environment variables,
|
# whether to allow customization of the config at runtime using environment variables,
|
||||||
# e.g., ERGO__SERVER__MAX_SENDQ=128k. see the manual for more details.
|
# e.g., ERGO__SERVER__MAX_SENDQ=128k. see the manual for more details.
|
||||||
allow-environment-overrides: true
|
allow-environment-overrides: true
|
||||||
|
|
||||||
|
# experimental support for mobile push notifications
|
||||||
|
# see the manual for potential security, privacy, and performance implications.
|
||||||
|
# DO NOT enable if you are running a Tor or I2P hidden service (i.e. one
|
||||||
|
# with no public IP listeners, only Tor/I2P listeners).
|
||||||
|
webpush:
|
||||||
|
# are push notifications enabled at all?
|
||||||
|
enabled: false
|
||||||
|
# request timeout for POST'ing the http notification
|
||||||
|
timeout: 10s
|
||||||
|
# subscriber field for the VAPID JWT authorization:
|
||||||
|
#subscriber: "https://your-website.com/"
|
||||||
|
# maximum number of push subscriptions per user
|
||||||
|
max-subscriptions: 4
|
||||||
|
# expiration time for a push subscription; it must be renewed within this time
|
||||||
|
# by the client reconnecting to IRC. we also detect whether the client is no longer
|
||||||
|
# successfully receiving push messages.
|
||||||
|
expiration: 14d
|
||||||
|
@ -225,6 +225,18 @@ CAPDEFS = [
|
|||||||
url="https://github.com/ircv3/ircv3-specifications/pull/543",
|
url="https://github.com/ircv3/ircv3-specifications/pull/543",
|
||||||
standard="proposed IRCv3",
|
standard="proposed IRCv3",
|
||||||
),
|
),
|
||||||
|
CapDef(
|
||||||
|
identifier="WebPush",
|
||||||
|
name="draft/webpush",
|
||||||
|
url="https://github.com/ircv3/ircv3-specifications/pull/471",
|
||||||
|
standard="proposed IRCv3",
|
||||||
|
),
|
||||||
|
CapDef(
|
||||||
|
identifier="SojuWebPush",
|
||||||
|
name="soju.im/webpush",
|
||||||
|
url="https://github.com/ircv3/ircv3-specifications/pull/471",
|
||||||
|
standard="Soju/Goguma vendor",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
def validate_defs():
|
def validate_defs():
|
||||||
|
5
go.mod
5
go.mod
@ -26,7 +26,10 @@ require (
|
|||||||
gopkg.in/yaml.v2 v2.4.0
|
gopkg.in/yaml.v2 v2.4.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require github.com/golang-jwt/jwt/v5 v5.2.1
|
require (
|
||||||
|
github.com/ergochat/webpush-go/v2 v2.0.0-rc1
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/tidwall/btree v1.4.2 // indirect
|
github.com/tidwall/btree v1.4.2 // indirect
|
||||||
|
4
go.sum
4
go.sum
@ -10,12 +10,12 @@ github.com/ergochat/confusables v0.0.0-20201108231250-4ab98ab61fb1 h1:WLHTOodthV
|
|||||||
github.com/ergochat/confusables v0.0.0-20201108231250-4ab98ab61fb1/go.mod h1:mov+uh1DPWsltdQnOdzn08UO9GsJ3MEvhtu0Ci37fdk=
|
github.com/ergochat/confusables v0.0.0-20201108231250-4ab98ab61fb1/go.mod h1:mov+uh1DPWsltdQnOdzn08UO9GsJ3MEvhtu0Ci37fdk=
|
||||||
github.com/ergochat/go-ident v0.0.0-20230911071154-8c30606d6881 h1:+J5m88nvybxB5AnBVGzTXM/yHVytt48rXBGcJGzSbms=
|
github.com/ergochat/go-ident v0.0.0-20230911071154-8c30606d6881 h1:+J5m88nvybxB5AnBVGzTXM/yHVytt48rXBGcJGzSbms=
|
||||||
github.com/ergochat/go-ident v0.0.0-20230911071154-8c30606d6881/go.mod h1:ASYJtQujNitna6cVHsNQTGrfWvMPJ5Sa2lZlmsH65uM=
|
github.com/ergochat/go-ident v0.0.0-20230911071154-8c30606d6881/go.mod h1:ASYJtQujNitna6cVHsNQTGrfWvMPJ5Sa2lZlmsH65uM=
|
||||||
github.com/ergochat/irc-go v0.5.0-rc1 h1:kFoIHExoNFQ2CV+iShAVna/H4xrXQB4t4jK5Sep2j9k=
|
|
||||||
github.com/ergochat/irc-go v0.5.0-rc1/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0=
|
|
||||||
github.com/ergochat/irc-go v0.5.0-rc2 h1:VuSQJF5K4hWvYSzGa4b8vgL6kzw8HF6LSOejE+RWpAo=
|
github.com/ergochat/irc-go v0.5.0-rc2 h1:VuSQJF5K4hWvYSzGa4b8vgL6kzw8HF6LSOejE+RWpAo=
|
||||||
github.com/ergochat/irc-go v0.5.0-rc2/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0=
|
github.com/ergochat/irc-go v0.5.0-rc2/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0=
|
||||||
github.com/ergochat/scram v1.0.2-ergo1 h1:2bYXiRFQH636pT0msOG39fmEYl4Eq+OuutcyDsCix/g=
|
github.com/ergochat/scram v1.0.2-ergo1 h1:2bYXiRFQH636pT0msOG39fmEYl4Eq+OuutcyDsCix/g=
|
||||||
github.com/ergochat/scram v1.0.2-ergo1/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs=
|
github.com/ergochat/scram v1.0.2-ergo1/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs=
|
||||||
|
github.com/ergochat/webpush-go/v2 v2.0.0-rc1 h1:CzSebM2OFM1zkAviYtkrBj5xtQc7Ka+Po607xbmZ+40=
|
||||||
|
github.com/ergochat/webpush-go/v2 v2.0.0-rc1/go.mod h1:OQlhnq8JeHDzRzAy6bdDObr19uqbHliOV+z7mHbYr4c=
|
||||||
github.com/ergochat/websocket v1.4.2-oragono1 h1:plMUunFBM6UoSCIYCKKclTdy/TkkHfUslhOfJQzfueM=
|
github.com/ergochat/websocket v1.4.2-oragono1 h1:plMUunFBM6UoSCIYCKKclTdy/TkkHfUslhOfJQzfueM=
|
||||||
github.com/ergochat/websocket v1.4.2-oragono1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/ergochat/websocket v1.4.2-oragono1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
|
@ -51,6 +51,7 @@ const (
|
|||||||
// for an always-on client, a map of channel names they're in to their current modes
|
// for an always-on client, a map of channel names they're in to their current modes
|
||||||
// (not to be confused with their amodes, which a non-always-on client can have):
|
// (not to be confused with their amodes, which a non-always-on client can have):
|
||||||
keyAccountChannelToModes = "account.channeltomodes %s"
|
keyAccountChannelToModes = "account.channeltomodes %s"
|
||||||
|
keyAccountPushSubscriptions = "account.pushsubscriptions %s"
|
||||||
|
|
||||||
maxCertfpsPerAccount = 5
|
maxCertfpsPerAccount = 5
|
||||||
)
|
)
|
||||||
@ -135,6 +136,7 @@ func (am *AccountManager) createAlwaysOnClients(config *Config) {
|
|||||||
am.loadTimeMap(keyAccountReadMarkers, accountName),
|
am.loadTimeMap(keyAccountReadMarkers, accountName),
|
||||||
am.loadModes(accountName),
|
am.loadModes(accountName),
|
||||||
am.loadRealname(accountName),
|
am.loadRealname(accountName),
|
||||||
|
am.loadPushSubscriptions(accountName),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -715,6 +717,40 @@ func (am *AccountManager) loadRealname(account string) (realname string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (am *AccountManager) savePushSubscriptions(account string, subs []storedPushSubscription) {
|
||||||
|
j, err := json.Marshal(subs)
|
||||||
|
if err != nil {
|
||||||
|
am.server.logger.Error("internal", "error storing push subscriptions", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val := string(j)
|
||||||
|
key := fmt.Sprintf(keyAccountPushSubscriptions, account)
|
||||||
|
am.server.store.Update(func(tx *buntdb.Tx) error {
|
||||||
|
tx.Set(key, val, nil)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (am *AccountManager) loadPushSubscriptions(account string) (result []storedPushSubscription) {
|
||||||
|
key := fmt.Sprintf(keyAccountPushSubscriptions, account)
|
||||||
|
var val string
|
||||||
|
am.server.store.View(func(tx *buntdb.Tx) error {
|
||||||
|
val, _ = tx.Get(key)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if val == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(val), &result); err == nil {
|
||||||
|
return result
|
||||||
|
} else {
|
||||||
|
am.server.logger.Error("internal", "error loading push subscriptions", err.Error())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (am *AccountManager) addRemoveCertfp(account, certfp string, add bool, hasPrivs bool) (err error) {
|
func (am *AccountManager) addRemoveCertfp(account, certfp string, add bool, hasPrivs bool) (err error) {
|
||||||
certfp, err = utils.NormalizeCertfp(certfp)
|
certfp, err = utils.NormalizeCertfp(certfp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -7,7 +7,7 @@ package caps
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// number of recognized capabilities:
|
// number of recognized capabilities:
|
||||||
numCapabs = 35
|
numCapabs = 37
|
||||||
// length of the uint32 array that represents the bitset:
|
// length of the uint32 array that represents the bitset:
|
||||||
bitsetLen = 2
|
bitsetLen = 2
|
||||||
)
|
)
|
||||||
@ -89,6 +89,10 @@ const (
|
|||||||
// https://github.com/ircv3/ircv3-specifications/pull/417
|
// https://github.com/ircv3/ircv3-specifications/pull/417
|
||||||
Relaymsg Capability = iota
|
Relaymsg Capability = iota
|
||||||
|
|
||||||
|
// WebPush is the proposed IRCv3 capability named "draft/webpush":
|
||||||
|
// https://github.com/ircv3/ircv3-specifications/pull/471
|
||||||
|
WebPush Capability = iota
|
||||||
|
|
||||||
// EchoMessage is the IRCv3 capability named "echo-message":
|
// EchoMessage is the IRCv3 capability named "echo-message":
|
||||||
// https://ircv3.net/specs/extensions/echo-message-3.2.html
|
// https://ircv3.net/specs/extensions/echo-message-3.2.html
|
||||||
EchoMessage Capability = iota
|
EchoMessage Capability = iota
|
||||||
@ -133,6 +137,10 @@ const (
|
|||||||
// https://ircv3.net/specs/extensions/setname.html
|
// https://ircv3.net/specs/extensions/setname.html
|
||||||
SetName Capability = iota
|
SetName Capability = iota
|
||||||
|
|
||||||
|
// SojuWebPush is the Soju/Goguma vendor capability named "soju.im/webpush":
|
||||||
|
// https://github.com/ircv3/ircv3-specifications/pull/471
|
||||||
|
SojuWebPush Capability = iota
|
||||||
|
|
||||||
// StandardReplies is the IRCv3 capability named "standard-replies":
|
// StandardReplies is the IRCv3 capability named "standard-replies":
|
||||||
// https://github.com/ircv3/ircv3-specifications/pull/506
|
// https://github.com/ircv3/ircv3-specifications/pull/506
|
||||||
StandardReplies Capability = iota
|
StandardReplies Capability = iota
|
||||||
@ -176,6 +184,7 @@ var (
|
|||||||
"draft/pre-away",
|
"draft/pre-away",
|
||||||
"draft/read-marker",
|
"draft/read-marker",
|
||||||
"draft/relaymsg",
|
"draft/relaymsg",
|
||||||
|
"draft/webpush",
|
||||||
"echo-message",
|
"echo-message",
|
||||||
"ergo.chat/nope",
|
"ergo.chat/nope",
|
||||||
"extended-join",
|
"extended-join",
|
||||||
@ -187,6 +196,7 @@ var (
|
|||||||
"sasl",
|
"sasl",
|
||||||
"server-time",
|
"server-time",
|
||||||
"setname",
|
"setname",
|
||||||
|
"soju.im/webpush",
|
||||||
"standard-replies",
|
"standard-replies",
|
||||||
"sts",
|
"sts",
|
||||||
"userhost-in-names",
|
"userhost-in-names",
|
||||||
|
@ -21,6 +21,7 @@ import (
|
|||||||
"github.com/ergochat/ergo/irc/history"
|
"github.com/ergochat/ergo/irc/history"
|
||||||
"github.com/ergochat/ergo/irc/modes"
|
"github.com/ergochat/ergo/irc/modes"
|
||||||
"github.com/ergochat/ergo/irc/utils"
|
"github.com/ergochat/ergo/irc/utils"
|
||||||
|
"github.com/ergochat/ergo/irc/webpush"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ChannelSettings struct {
|
type ChannelSettings struct {
|
||||||
@ -222,7 +223,7 @@ func (channel *Channel) wakeWriter() {
|
|||||||
|
|
||||||
// equivalent of Socket.send()
|
// equivalent of Socket.send()
|
||||||
func (channel *Channel) writeLoop() {
|
func (channel *Channel) writeLoop() {
|
||||||
defer channel.server.HandlePanic()
|
defer channel.server.HandlePanic(nil)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
// TODO(#357) check the error value of this and implement timed backoff
|
// TODO(#357) check the error value of this and implement timed backoff
|
||||||
@ -1325,7 +1326,10 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
|
|||||||
chname = fmt.Sprintf("%s%s", modes.ChannelModePrefixes[minPrefixMode], chname)
|
chname = fmt.Sprintf("%s%s", modes.ChannelModePrefixes[minPrefixMode], chname)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !client.server.Config().Server.Compatibility.allowTruncation {
|
config := client.server.Config()
|
||||||
|
dispatchWebPush := false
|
||||||
|
|
||||||
|
if !config.Server.Compatibility.allowTruncation {
|
||||||
if !validateSplitMessageLen(histType, details.nickMask, chname, message) {
|
if !validateSplitMessageLen(histType, details.nickMask, chname, message) {
|
||||||
rb.Add(nil, client.server.name, ERR_INPUTTOOLONG, details.nick, client.t("Line too long to be relayed without truncation"))
|
rb.Add(nil, client.server.name, ERR_INPUTTOOLONG, details.nick, client.t("Line too long to be relayed without truncation"))
|
||||||
return
|
return
|
||||||
@ -1355,6 +1359,9 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO consider when we might want to push TAGMSG
|
||||||
|
dispatchWebPush = dispatchWebPush || (config.WebPush.Enabled && histType != history.Tagmsg && member.hasPushSubscriptions())
|
||||||
|
|
||||||
for _, session := range member.Sessions() {
|
for _, session := range member.Sessions() {
|
||||||
if session == rb.session {
|
if session == rb.session {
|
||||||
continue // we already sent echo-message, if applicable
|
continue // we already sent echo-message, if applicable
|
||||||
@ -1378,6 +1385,34 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
|
|||||||
Tags: clientOnlyTags,
|
Tags: clientOnlyTags,
|
||||||
IsBot: isBot,
|
IsBot: isBot,
|
||||||
}, details.account)
|
}, details.account)
|
||||||
|
|
||||||
|
if dispatchWebPush {
|
||||||
|
channel.dispatchWebPush(command, details.nickMask, details.accountName, chname, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (channel *Channel) dispatchWebPush(command, nuh, accountName, chname string, msg utils.SplitMessage) {
|
||||||
|
msgBytes, err := webpush.MakePushMessage(command, nuh, accountName, chname, msg)
|
||||||
|
if err != nil {
|
||||||
|
channel.server.logger.Error("internal", "can't serialize push message", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
messageText := strings.ToLower(msg.CombinedValue())
|
||||||
|
|
||||||
|
for _, member := range channel.Members() {
|
||||||
|
if !member.hasPushSubscriptions() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// this is the casefolded account name for comparison to the casefolded message text:
|
||||||
|
account := member.Account()
|
||||||
|
if account == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !webpush.IsHighlight(messageText, account) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
member.dispatchPushMessage(pushMessage{msg: msgBytes, urgency: webpush.UrgencyHigh})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
127
irc/client.go
127
irc/client.go
@ -6,6 +6,7 @@
|
|||||||
package irc
|
package irc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
@ -32,6 +33,7 @@ import (
|
|||||||
"github.com/ergochat/ergo/irc/oauth2"
|
"github.com/ergochat/ergo/irc/oauth2"
|
||||||
"github.com/ergochat/ergo/irc/sno"
|
"github.com/ergochat/ergo/irc/sno"
|
||||||
"github.com/ergochat/ergo/irc/utils"
|
"github.com/ergochat/ergo/irc/utils"
|
||||||
|
"github.com/ergochat/ergo/irc/webpush"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -46,6 +48,10 @@ const (
|
|||||||
// maximum total read markers that can be stored
|
// maximum total read markers that can be stored
|
||||||
// (writeback of read markers is controlled by lastSeen logic)
|
// (writeback of read markers is controlled by lastSeen logic)
|
||||||
maxReadMarkers = 256
|
maxReadMarkers = 256
|
||||||
|
|
||||||
|
// should be long enough to handle multiple notifications in rapid succession,
|
||||||
|
// short enough that it doesn't waste a lot of RAM per client
|
||||||
|
pushQueueLengthPerClient = 16
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -117,6 +123,10 @@ type Client struct {
|
|||||||
history history.Buffer
|
history history.Buffer
|
||||||
dirtyBits uint
|
dirtyBits uint
|
||||||
writebackLock sync.Mutex // tier 1.5
|
writebackLock sync.Mutex // tier 1.5
|
||||||
|
pushSubscriptions map[string]*pushSubscription
|
||||||
|
cachedPushSubscriptions []storedPushSubscription
|
||||||
|
pushSubscriptionsExist atomic.Uint32 // this is a cache on len(pushSubscriptions) != 0
|
||||||
|
pushQueue pushQueue
|
||||||
}
|
}
|
||||||
|
|
||||||
type saslStatus struct {
|
type saslStatus struct {
|
||||||
@ -403,7 +413,7 @@ func (server *Server) RunClient(conn IRCConn) {
|
|||||||
client.run(session)
|
client.run(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (server *Server) AddAlwaysOnClient(account ClientAccount, channelToStatus map[string]alwaysOnChannelStatus, lastSeen, readMarkers map[string]time.Time, uModes modes.Modes, realname string) {
|
func (server *Server) AddAlwaysOnClient(account ClientAccount, channelToStatus map[string]alwaysOnChannelStatus, lastSeen, readMarkers map[string]time.Time, uModes modes.Modes, realname string, pushSubscriptions []storedPushSubscription) {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
config := server.Config()
|
config := server.Config()
|
||||||
if lastSeen == nil && account.Settings.AutoreplayMissed {
|
if lastSeen == nil && account.Settings.AutoreplayMissed {
|
||||||
@ -480,6 +490,14 @@ func (server *Server) AddAlwaysOnClient(account ClientAccount, channelToStatus m
|
|||||||
if persistenceEnabled(config.Accounts.Multiclient.AutoAway, client.accountSettings.AutoAway) {
|
if persistenceEnabled(config.Accounts.Multiclient.AutoAway, client.accountSettings.AutoAway) {
|
||||||
client.setAutoAwayNoMutex(config)
|
client.setAutoAwayNoMutex(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(pushSubscriptions) != 0 {
|
||||||
|
client.pushSubscriptions = make(map[string]*pushSubscription, len(pushSubscriptions))
|
||||||
|
for _, sub := range pushSubscriptions {
|
||||||
|
client.pushSubscriptions[sub.Endpoint] = newPushSubscription(sub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
client.rebuildPushSubscriptionCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (client *Client) resizeHistory(config *Config) {
|
func (client *Client) resizeHistory(config *Config) {
|
||||||
@ -1776,6 +1794,7 @@ const (
|
|||||||
IncludeChannels uint = 1 << iota
|
IncludeChannels uint = 1 << iota
|
||||||
IncludeUserModes
|
IncludeUserModes
|
||||||
IncludeRealname
|
IncludeRealname
|
||||||
|
IncludePushSubscriptions
|
||||||
)
|
)
|
||||||
|
|
||||||
func (client *Client) markDirty(dirtyBits uint) {
|
func (client *Client) markDirty(dirtyBits uint) {
|
||||||
@ -1796,7 +1815,7 @@ func (client *Client) wakeWriter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (client *Client) writeLoop() {
|
func (client *Client) writeLoop() {
|
||||||
defer client.server.HandlePanic()
|
defer client.server.HandlePanic(nil)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
client.performWrite(0)
|
client.performWrite(0)
|
||||||
@ -1854,6 +1873,9 @@ func (client *Client) performWrite(additionalDirtyBits uint) {
|
|||||||
if (dirtyBits & IncludeRealname) != 0 {
|
if (dirtyBits & IncludeRealname) != 0 {
|
||||||
client.server.accounts.saveRealname(account, client.realname)
|
client.server.accounts.saveRealname(account, client.realname)
|
||||||
}
|
}
|
||||||
|
if (dirtyBits & IncludePushSubscriptions) != 0 {
|
||||||
|
client.server.accounts.savePushSubscriptions(account, client.getPushSubscriptions())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Blocking store; see Channel.Store and Socket.BlockingWrite
|
// Blocking store; see Channel.Store and Socket.BlockingWrite
|
||||||
@ -1873,3 +1895,104 @@ func (client *Client) Store(dirtyBits uint) (err error) {
|
|||||||
client.performWrite(dirtyBits)
|
client.performWrite(dirtyBits)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pushSubscription represents all the data we track about the state of a push subscription;
|
||||||
|
// right now every field is persisted, but we may want to persist only a subset in future
|
||||||
|
type pushSubscription struct {
|
||||||
|
storedPushSubscription
|
||||||
|
}
|
||||||
|
|
||||||
|
// storedPushSubscription represents a subscription as stored in the database
|
||||||
|
type storedPushSubscription struct {
|
||||||
|
Endpoint string
|
||||||
|
Keys webpush.Keys
|
||||||
|
LastRefresh time.Time // last time the client sent WEBPUSH REGISTER for this endpoint
|
||||||
|
LastSuccess time.Time // last time we successfully pushed to this endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPushSubscription(sub storedPushSubscription) *pushSubscription {
|
||||||
|
return &pushSubscription{
|
||||||
|
storedPushSubscription: sub,
|
||||||
|
// TODO any other initialization here, like rate limiting
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type pushMessage struct {
|
||||||
|
msg []byte
|
||||||
|
urgency webpush.Urgency
|
||||||
|
}
|
||||||
|
|
||||||
|
type pushQueue struct {
|
||||||
|
workerLock sync.Mutex
|
||||||
|
queue chan pushMessage
|
||||||
|
once sync.Once
|
||||||
|
dropped atomic.Uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ensurePushInitialized() {
|
||||||
|
c.pushQueue.once.Do(c.initializePush)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) initializePush() {
|
||||||
|
// allocate the queue
|
||||||
|
c.pushQueue.queue = make(chan pushMessage, pushQueueLengthPerClient)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) dispatchPushMessage(msg pushMessage) {
|
||||||
|
client.ensurePushInitialized()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case client.pushQueue.queue <- msg:
|
||||||
|
if client.pushQueue.workerLock.TryLock() {
|
||||||
|
go client.pushWorker()
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
client.pushQueue.dropped.Add(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) pushWorker() {
|
||||||
|
defer client.server.HandlePanic(nil)
|
||||||
|
defer client.pushQueue.workerLock.Unlock()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case msg := <-client.pushQueue.queue:
|
||||||
|
for _, sub := range client.getPushSubscriptions() {
|
||||||
|
client.sendAndTrackPush(sub.Endpoint, sub.Keys, msg, true)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// no more messages, end the goroutine and release the trylock
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) sendAndTrackPush(endpoint string, keys webpush.Keys, msg pushMessage, updateDB bool) {
|
||||||
|
switch client.sendPush(endpoint, keys, msg.urgency, msg.msg) {
|
||||||
|
case nil:
|
||||||
|
client.recordPush(endpoint, true)
|
||||||
|
case webpush.Err404:
|
||||||
|
client.deletePushSubscription(endpoint, updateDB)
|
||||||
|
default:
|
||||||
|
client.recordPush(endpoint, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) sendPush(endpoint string, keys webpush.Keys, urgency webpush.Urgency, msg []byte) error {
|
||||||
|
config := client.server.Config()
|
||||||
|
// final sanity check
|
||||||
|
if !config.WebPush.Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), config.WebPush.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err := webpush.SendWebPush(ctx, endpoint, keys, config.WebPush.vapidKeys, webpush.UrgencyHigh, config.WebPush.Subscriber, msg)
|
||||||
|
if err == nil {
|
||||||
|
client.server.logger.Debug("webpush", "dispatched push to client", client.Nick(), endpoint)
|
||||||
|
} else {
|
||||||
|
client.server.logger.Debug("webpush", "failed to dispatch push to client", client.Nick(), endpoint, err.Error())
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
@ -253,15 +253,14 @@ func (clients *ClientManager) AllClients() (result []*Client) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// AllWithCapsNotify returns all clients with the given capabilities, and that support cap-notify.
|
// AllWithCapsNotify returns all sessions that support cap-notify.
|
||||||
func (clients *ClientManager) AllWithCapsNotify(capabs ...caps.Capability) (sessions []*Session) {
|
func (clients *ClientManager) AllWithCapsNotify() (sessions []*Session) {
|
||||||
capabs = append(capabs, caps.CapNotify)
|
|
||||||
clients.RLock()
|
clients.RLock()
|
||||||
defer clients.RUnlock()
|
defer clients.RUnlock()
|
||||||
for _, client := range clients.byNick {
|
for _, client := range clients.byNick {
|
||||||
for _, session := range client.Sessions() {
|
for _, session := range client.Sessions() {
|
||||||
// cap-notify is implicit in cap version 302 and above
|
// cap-notify is implicit in cap version 302 and above
|
||||||
if session.capabilities.HasAll(capabs...) || 302 <= session.capVersion {
|
if session.capabilities.Has(caps.CapNotify) || 302 <= session.capVersion {
|
||||||
sessions = append(sessions, session)
|
sessions = append(sessions, session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -270,6 +269,18 @@ func (clients *ClientManager) AllWithCapsNotify(capabs ...caps.Capability) (sess
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AllWithPushSubscriptions returns all clients that are always-on with an active push subscription.
|
||||||
|
func (clients *ClientManager) AllWithPushSubscriptions() (result []*Client) {
|
||||||
|
clients.RLock()
|
||||||
|
defer clients.RUnlock()
|
||||||
|
for _, client := range clients.byNick {
|
||||||
|
if client.hasPushSubscriptions() && client.AlwaysOn() {
|
||||||
|
result = append(result, client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// FindAll returns all clients that match the given userhost mask.
|
// FindAll returns all clients that match the given userhost mask.
|
||||||
func (clients *ClientManager) FindAll(userhost string) (set ClientSet) {
|
func (clients *ClientManager) FindAll(userhost string) (set ClientSet) {
|
||||||
set = make(ClientSet)
|
set = make(ClientSet)
|
||||||
|
@ -367,6 +367,10 @@ func init() {
|
|||||||
usablePreReg: true,
|
usablePreReg: true,
|
||||||
minParams: 4,
|
minParams: 4,
|
||||||
},
|
},
|
||||||
|
"WEBPUSH": {
|
||||||
|
handler: webpushHandler,
|
||||||
|
minParams: 2,
|
||||||
|
},
|
||||||
"WHO": {
|
"WHO": {
|
||||||
handler: whoHandler,
|
handler: whoHandler,
|
||||||
minParams: 1,
|
minParams: 1,
|
||||||
|
@ -41,6 +41,7 @@ import (
|
|||||||
"github.com/ergochat/ergo/irc/oauth2"
|
"github.com/ergochat/ergo/irc/oauth2"
|
||||||
"github.com/ergochat/ergo/irc/passwd"
|
"github.com/ergochat/ergo/irc/passwd"
|
||||||
"github.com/ergochat/ergo/irc/utils"
|
"github.com/ergochat/ergo/irc/utils"
|
||||||
|
"github.com/ergochat/ergo/irc/webpush"
|
||||||
)
|
)
|
||||||
|
|
||||||
// here's how this works: exported (capitalized) members of the config structs
|
// here's how this works: exported (capitalized) members of the config structs
|
||||||
@ -708,6 +709,15 @@ type Config struct {
|
|||||||
} `yaml:"tagmsg-storage"`
|
} `yaml:"tagmsg-storage"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
WebPush struct {
|
||||||
|
Enabled bool
|
||||||
|
Timeout time.Duration
|
||||||
|
Subscriber string
|
||||||
|
MaxSubscriptions int `yaml:"max-subscriptions"`
|
||||||
|
Expiration custime.Duration
|
||||||
|
vapidKeys *webpush.VAPIDKeys
|
||||||
|
} `yaml:"webpush"`
|
||||||
|
|
||||||
Filename string
|
Filename string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1572,6 +1582,29 @@ func LoadConfig(filename string) (config *Config, err error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config.WebPush.Enabled {
|
||||||
|
if config.Accounts.Multiclient.AlwaysOn == PersistentDisabled {
|
||||||
|
return nil, fmt.Errorf("Cannot enable webpush if always-on is disabled")
|
||||||
|
}
|
||||||
|
if config.WebPush.Timeout == 0 {
|
||||||
|
config.WebPush.Timeout = 10 * time.Second
|
||||||
|
}
|
||||||
|
if config.WebPush.Subscriber == "" {
|
||||||
|
config.WebPush.Subscriber = "https://ergo.chat/about"
|
||||||
|
}
|
||||||
|
if config.WebPush.MaxSubscriptions <= 0 {
|
||||||
|
config.WebPush.MaxSubscriptions = 1
|
||||||
|
}
|
||||||
|
if config.WebPush.Expiration == 0 {
|
||||||
|
config.WebPush.Expiration = custime.Duration(14 * 24 * time.Hour)
|
||||||
|
} else if config.WebPush.Expiration < custime.Duration(3*24*time.Hour) {
|
||||||
|
return nil, fmt.Errorf("webpush.expiration is too short (should be several days)")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
config.Server.supportedCaps.Disable(caps.WebPush)
|
||||||
|
config.Server.supportedCaps.Disable(caps.SojuWebPush)
|
||||||
|
}
|
||||||
|
|
||||||
// now that all postprocessing is complete, regenerate ISUPPORT:
|
// now that all postprocessing is complete, regenerate ISUPPORT:
|
||||||
err = config.generateISupport()
|
err = config.generateISupport()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -1666,6 +1699,13 @@ func (config *Config) generateISupport() (err error) {
|
|||||||
if config.Server.EnforceUtf8 {
|
if config.Server.EnforceUtf8 {
|
||||||
isupport.Add("UTF8ONLY", "")
|
isupport.Add("UTF8ONLY", "")
|
||||||
}
|
}
|
||||||
|
if config.WebPush.Enabled {
|
||||||
|
// XXX we typically don't have this at config parse time, so we'll have to regenerate
|
||||||
|
// the cached reply later
|
||||||
|
if config.WebPush.vapidKeys != nil {
|
||||||
|
isupport.Add("VAPID", config.WebPush.vapidKeys.PublicKeyString())
|
||||||
|
}
|
||||||
|
}
|
||||||
isupport.Add("WHOX", "")
|
isupport.Add("WHOX", "")
|
||||||
|
|
||||||
err = isupport.RegenerateCachedReply()
|
err = isupport.RegenerateCachedReply()
|
||||||
|
@ -18,6 +18,7 @@ import (
|
|||||||
"github.com/ergochat/ergo/irc/datastore"
|
"github.com/ergochat/ergo/irc/datastore"
|
||||||
"github.com/ergochat/ergo/irc/modes"
|
"github.com/ergochat/ergo/irc/modes"
|
||||||
"github.com/ergochat/ergo/irc/utils"
|
"github.com/ergochat/ergo/irc/utils"
|
||||||
|
"github.com/ergochat/ergo/irc/webpush"
|
||||||
|
|
||||||
"github.com/tidwall/buntdb"
|
"github.com/tidwall/buntdb"
|
||||||
)
|
)
|
||||||
@ -27,15 +28,17 @@ const (
|
|||||||
|
|
||||||
// 'version' of the database schema
|
// 'version' of the database schema
|
||||||
// latest schema of the db
|
// latest schema of the db
|
||||||
latestDbSchema = 23
|
latestDbSchema = 24
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
schemaVersionUUID = utils.UUID{0, 255, 85, 13, 212, 10, 191, 121, 245, 152, 142, 89, 97, 141, 219, 87} // AP9VDdQKv3n1mI5ZYY3bVw
|
schemaVersionUUID = utils.UUID{0, 255, 85, 13, 212, 10, 191, 121, 245, 152, 142, 89, 97, 141, 219, 87} // AP9VDdQKv3n1mI5ZYY3bVw
|
||||||
cloakSecretUUID = utils.UUID{170, 214, 184, 208, 116, 181, 67, 75, 161, 23, 233, 16, 113, 251, 94, 229} // qta40HS1Q0uhF-kQcfte5Q
|
cloakSecretUUID = utils.UUID{170, 214, 184, 208, 116, 181, 67, 75, 161, 23, 233, 16, 113, 251, 94, 229} // qta40HS1Q0uhF-kQcfte5Q
|
||||||
|
vapidKeysUUID = utils.UUID{87, 215, 189, 5, 65, 105, 249, 44, 65, 96, 170, 56, 187, 110, 12, 235} // V9e9BUFp-SxBYKo4u24M6w
|
||||||
|
|
||||||
keySchemaVersion = bunt.BuntKey(datastore.TableMetadata, schemaVersionUUID)
|
keySchemaVersion = bunt.BuntKey(datastore.TableMetadata, schemaVersionUUID)
|
||||||
keyCloakSecret = bunt.BuntKey(datastore.TableMetadata, cloakSecretUUID)
|
keyCloakSecret = bunt.BuntKey(datastore.TableMetadata, cloakSecretUUID)
|
||||||
|
keyVAPIDKeys = bunt.BuntKey(datastore.TableMetadata, vapidKeysUUID)
|
||||||
)
|
)
|
||||||
|
|
||||||
type SchemaChanger func(*Config, *buntdb.Tx) error
|
type SchemaChanger func(*Config, *buntdb.Tx) error
|
||||||
@ -80,6 +83,15 @@ func initializeDB(path string) error {
|
|||||||
// set schema version
|
// set schema version
|
||||||
tx.Set(keySchemaVersion, strconv.Itoa(latestDbSchema), nil)
|
tx.Set(keySchemaVersion, strconv.Itoa(latestDbSchema), nil)
|
||||||
tx.Set(keyCloakSecret, utils.GenerateSecretKey(), nil)
|
tx.Set(keyCloakSecret, utils.GenerateSecretKey(), nil)
|
||||||
|
vapidKeys, err := webpush.GenerateVAPIDKeys()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
j, err := json.Marshal(vapidKeys)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tx.Set(keyVAPIDKeys, string(j), nil)
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -233,6 +245,16 @@ func StoreCloakSecret(dstore datastore.Datastore, secret string) {
|
|||||||
dstore.Set(datastore.TableMetadata, cloakSecretUUID, []byte(secret), time.Time{})
|
dstore.Set(datastore.TableMetadata, cloakSecretUUID, []byte(secret), time.Time{})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func LoadVAPIDKeys(dstore datastore.Datastore) (*webpush.VAPIDKeys, error) {
|
||||||
|
val, err := dstore.Get(datastore.TableMetadata, vapidKeysUUID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := new(webpush.VAPIDKeys)
|
||||||
|
err = json.Unmarshal([]byte(val), result)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
func schemaChangeV1toV2(config *Config, tx *buntdb.Tx) error {
|
func schemaChangeV1toV2(config *Config, tx *buntdb.Tx) error {
|
||||||
// == version 1 -> 2 ==
|
// == version 1 -> 2 ==
|
||||||
// account key changes and account.verified key bugfix.
|
// account key changes and account.verified key bugfix.
|
||||||
@ -1218,6 +1240,20 @@ func schemaChangeV22ToV23(config *Config, tx *buntdb.Tx) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// webpush signing key
|
||||||
|
func schemaChangeV23ToV24(config *Config, tx *buntdb.Tx) error {
|
||||||
|
keys, err := webpush.GenerateVAPIDKeys()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
j, err := json.Marshal(keys)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tx.Set(keyVAPIDKeys, string(j), nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func getSchemaChange(initialVersion int) (result SchemaChange, ok bool) {
|
func getSchemaChange(initialVersion int) (result SchemaChange, ok bool) {
|
||||||
for _, change := range allChanges {
|
for _, change := range allChanges {
|
||||||
if initialVersion == change.InitialVersion {
|
if initialVersion == change.InitialVersion {
|
||||||
@ -1338,4 +1374,9 @@ var allChanges = []SchemaChange{
|
|||||||
TargetVersion: 23,
|
TargetVersion: 23,
|
||||||
Changer: schemaChangeV22ToV23,
|
Changer: schemaChangeV22ToV23,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
InitialVersion: 23,
|
||||||
|
TargetVersion: 24,
|
||||||
|
Changer: schemaChangeV23ToV24,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
122
irc/getters.go
122
irc/getters.go
@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/ergochat/ergo/irc/languages"
|
"github.com/ergochat/ergo/irc/languages"
|
||||||
"github.com/ergochat/ergo/irc/modes"
|
"github.com/ergochat/ergo/irc/modes"
|
||||||
"github.com/ergochat/ergo/irc/utils"
|
"github.com/ergochat/ergo/irc/utils"
|
||||||
|
"github.com/ergochat/ergo/irc/webpush"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (server *Server) Config() (config *Config) {
|
func (server *Server) Config() (config *Config) {
|
||||||
@ -562,6 +563,127 @@ func (client *Client) setKlined() {
|
|||||||
client.stateMutex.Unlock()
|
client.stateMutex.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (client *Client) refreshPushSubscription(endpoint string, keys webpush.Keys) bool {
|
||||||
|
// do not mark dirty --- defer the write to periodic maintenance
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
client.stateMutex.Lock()
|
||||||
|
defer client.stateMutex.Unlock()
|
||||||
|
|
||||||
|
sub, ok := client.pushSubscriptions[endpoint]
|
||||||
|
if ok && sub.Keys.Equal(keys) {
|
||||||
|
sub.LastRefresh = now
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false // subscription doesn't exist, we need to send a test message
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) addPushSubscription(endpoint string, keys webpush.Keys) error {
|
||||||
|
changed := false
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if changed {
|
||||||
|
client.markDirty(IncludeAllAttrs)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
config := client.server.Config()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
client.stateMutex.Lock()
|
||||||
|
defer client.stateMutex.Unlock()
|
||||||
|
|
||||||
|
if client.pushSubscriptions == nil {
|
||||||
|
client.pushSubscriptions = make(map[string]*pushSubscription)
|
||||||
|
}
|
||||||
|
|
||||||
|
sub, ok := client.pushSubscriptions[endpoint]
|
||||||
|
if ok {
|
||||||
|
changed = !sub.Keys.Equal(keys)
|
||||||
|
sub.Keys = keys
|
||||||
|
sub.LastRefresh = now
|
||||||
|
} else {
|
||||||
|
if len(client.pushSubscriptions) >= config.WebPush.MaxSubscriptions {
|
||||||
|
return errLimitExceeded
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
sub = newPushSubscription(storedPushSubscription{
|
||||||
|
Endpoint: endpoint,
|
||||||
|
Keys: keys,
|
||||||
|
LastRefresh: now,
|
||||||
|
LastSuccess: now, // assume we just sent a successful message to confirm the sub
|
||||||
|
})
|
||||||
|
client.pushSubscriptions[endpoint] = sub
|
||||||
|
}
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
client.rebuildPushSubscriptionCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) hasPushSubscriptions() bool {
|
||||||
|
return client.pushSubscriptionsExist.Load() != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) getPushSubscriptions() []storedPushSubscription {
|
||||||
|
client.stateMutex.RLock()
|
||||||
|
defer client.stateMutex.RUnlock()
|
||||||
|
|
||||||
|
return client.cachedPushSubscriptions
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) rebuildPushSubscriptionCache() {
|
||||||
|
// must hold write lock
|
||||||
|
if len(client.pushSubscriptions) == 0 {
|
||||||
|
client.cachedPushSubscriptions = nil
|
||||||
|
client.pushSubscriptionsExist.Store(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client.cachedPushSubscriptions = make([]storedPushSubscription, 0, len(client.pushSubscriptions))
|
||||||
|
for _, subscription := range client.pushSubscriptions {
|
||||||
|
client.cachedPushSubscriptions = append(client.cachedPushSubscriptions, subscription.storedPushSubscription)
|
||||||
|
}
|
||||||
|
client.pushSubscriptionsExist.Store(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) deletePushSubscription(endpoint string, writeback bool) (changed bool) {
|
||||||
|
defer func() {
|
||||||
|
if writeback && changed {
|
||||||
|
client.markDirty(IncludeAllAttrs)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
client.stateMutex.Lock()
|
||||||
|
defer client.stateMutex.Unlock()
|
||||||
|
|
||||||
|
_, ok := client.pushSubscriptions[endpoint]
|
||||||
|
if ok {
|
||||||
|
changed = true
|
||||||
|
delete(client.pushSubscriptions, endpoint)
|
||||||
|
client.rebuildPushSubscriptionCache()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) recordPush(endpoint string, success bool) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
client.stateMutex.Lock()
|
||||||
|
defer client.stateMutex.Unlock()
|
||||||
|
|
||||||
|
subscription, ok := client.pushSubscriptions[endpoint]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if success {
|
||||||
|
subscription.LastSuccess = now
|
||||||
|
}
|
||||||
|
// TODO we may want to track failures in some way in the future
|
||||||
|
}
|
||||||
|
|
||||||
func (channel *Channel) Name() string {
|
func (channel *Channel) Name() string {
|
||||||
channel.stateMutex.RLock()
|
channel.stateMutex.RLock()
|
||||||
defer channel.stateMutex.RUnlock()
|
defer channel.stateMutex.RUnlock()
|
||||||
|
@ -33,6 +33,7 @@ import (
|
|||||||
"github.com/ergochat/ergo/irc/oauth2"
|
"github.com/ergochat/ergo/irc/oauth2"
|
||||||
"github.com/ergochat/ergo/irc/sno"
|
"github.com/ergochat/ergo/irc/sno"
|
||||||
"github.com/ergochat/ergo/irc/utils"
|
"github.com/ergochat/ergo/irc/utils"
|
||||||
|
"github.com/ergochat/ergo/irc/webpush"
|
||||||
)
|
)
|
||||||
|
|
||||||
// helper function to parse ACC callbacks, e.g., mailto:person@example.com, tel:16505551234
|
// helper function to parse ACC callbacks, e.g., mailto:person@example.com, tel:16505551234
|
||||||
@ -2465,6 +2466,15 @@ func dispatchMessageToTarget(client *Client, tags map[string]string, histType hi
|
|||||||
Tags: tags,
|
Tags: tags,
|
||||||
}
|
}
|
||||||
client.addHistoryItem(user, item, &details, &tDetails, config)
|
client.addHistoryItem(user, item, &details, &tDetails, config)
|
||||||
|
|
||||||
|
if config.WebPush.Enabled && histType != history.Tagmsg && user.hasPushSubscriptions() {
|
||||||
|
pushMsgBytes, err := webpush.MakePushMessage(command, nickMaskString, accountName, tnick, message)
|
||||||
|
if err == nil {
|
||||||
|
user.dispatchPushMessage(pushMessage{msg: pushMsgBytes, urgency: webpush.UrgencyHigh})
|
||||||
|
} else {
|
||||||
|
server.logger.Error("internal", "can't serialize push message", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3049,6 +3059,7 @@ func markReadHandler(server *Server, client *Client, msg ircmsg.Message, rb *Res
|
|||||||
session.Send(nil, server.name, "MARKREAD", unfoldedTarget, readTimestamp)
|
session.Send(nil, server.name, "MARKREAD", unfoldedTarget, readTimestamp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// TODO add support for pushing MARKREAD
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -3590,6 +3601,85 @@ func webircHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respo
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WEBPUSH <subcommand> <endpoint> [key]
|
||||||
|
func webpushHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool {
|
||||||
|
subcommand := strings.ToUpper(msg.Params[0])
|
||||||
|
|
||||||
|
config := server.Config()
|
||||||
|
if !config.WebPush.Enabled {
|
||||||
|
rb.Add(nil, server.name, "FAIL", "WEBPUSH", "FORBIDDEN", subcommand, client.t("Web push is disabled"))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if client.Account() == "" {
|
||||||
|
rb.Add(nil, server.name, "FAIL", "WEBPUSH", "FORBIDDEN", subcommand, client.t("You must be logged in to receive push messages"))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// XXX web push can be used to deanonymize a Tor hidden service, but we do not know
|
||||||
|
// whether an Ergo deployment with a Tor listener is intended to run as a hidden
|
||||||
|
// service, or as a single onion service where Tor is optional. Hidden service operators
|
||||||
|
// should disable web push. However, as a sanity check, disallow enabling it over a Tor
|
||||||
|
// connection:
|
||||||
|
if rb.session.isTor {
|
||||||
|
rb.Add(nil, server.name, "FAIL", "WEBPUSH", "FORBIDDEN", subcommand, client.t("Web push cannot be enabled over Tor"))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := msg.Params[1]
|
||||||
|
|
||||||
|
if err := webpush.SanityCheckWebPushEndpoint(endpoint); err != nil {
|
||||||
|
rb.Add(nil, server.name, "FAIL", "WEBPUSH", "INVALID_PARAMS", subcommand, client.t("Invalid web push URL"))
|
||||||
|
}
|
||||||
|
|
||||||
|
switch subcommand {
|
||||||
|
case "REGISTER":
|
||||||
|
// allow web push enable even if they are not always-on (they just won't get push messages)
|
||||||
|
if len(msg.Params) < 3 {
|
||||||
|
rb.Add(nil, server.name, "FAIL", "WEBPUSH", "INVALID_PARAMS", subcommand, client.t("Insufficient parameters for WEBPUSH REGISTER"))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
keys, err := webpush.DecodeSubscriptionKeys(msg.Params[2])
|
||||||
|
if err != nil {
|
||||||
|
rb.Add(nil, server.name, "FAIL", "WEBPUSH", "INVALID_PARAMS", subcommand, client.t("Invalid subscription keys for WEBPUSH REGISTER"))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if client.refreshPushSubscription(endpoint, keys) {
|
||||||
|
// success, don't send a test message
|
||||||
|
rb.Add(nil, server.name, "WEBPUSH", "REGISTER", msg.Params[1], msg.Params[2])
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// send a test message
|
||||||
|
if err := client.sendPush(
|
||||||
|
endpoint,
|
||||||
|
keys,
|
||||||
|
webpush.UrgencyHigh,
|
||||||
|
webpush.PingMessage,
|
||||||
|
); err == nil {
|
||||||
|
if err := client.addPushSubscription(endpoint, keys); err == nil {
|
||||||
|
rb.Add(nil, server.name, "WEBPUSH", "REGISTER", msg.Params[1], msg.Params[2])
|
||||||
|
if !client.AlwaysOn() {
|
||||||
|
rb.Add(nil, server.name, "WARN", "WEBPUSH", "PERSISTENCE_REQUIRED", client.t("You have enabled push notifications, but you will not receive them unless you become always-on. Try: /msg nickserv set always-on true"))
|
||||||
|
}
|
||||||
|
} else if err == errLimitExceeded {
|
||||||
|
rb.Add(nil, server.name, "FAIL", "WEBPUSH", "FORBIDDEN", "REGISTER", client.t("You have too many push subscriptions already"))
|
||||||
|
} else {
|
||||||
|
server.logger.Error("webpush", "Failed to add webpush subscription", err.Error())
|
||||||
|
rb.Add(nil, server.name, "FAIL", "WEBPUSH", "INTERNAL_ERROR", "REGISTER", client.t("An error occurred"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
server.logger.Debug("webpush", "WEBPUSH REGISTER failed validation", endpoint, err.Error())
|
||||||
|
rb.Add(nil, server.name, "FAIL", "WEBPUSH", "INVALID_PARAMS", "REGISTER", client.t("Test push message failed to send"))
|
||||||
|
}
|
||||||
|
case "UNREGISTER":
|
||||||
|
client.deletePushSubscription(endpoint, true)
|
||||||
|
// this always succeeds
|
||||||
|
rb.Add(nil, server.name, "WEBPUSH", "UNREGISTER", endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
type whoxFields uint32 // bitset to hold the WHOX field values, 'a' through 'z'
|
type whoxFields uint32 // bitset to hold the WHOX field values, 'a' through 'z'
|
||||||
|
|
||||||
func (fields whoxFields) Add(field rune) (result whoxFields) {
|
func (fields whoxFields) Add(field rune) (result whoxFields) {
|
||||||
|
@ -610,6 +610,11 @@ ircv3.net/specs/extensions/webirc.html
|
|||||||
the connection from the client to the gateway, such as:
|
the connection from the client to the gateway, such as:
|
||||||
|
|
||||||
- tls: this flag indicates that the client->gateway connection is secure`,
|
- tls: this flag indicates that the client->gateway connection is secure`,
|
||||||
|
},
|
||||||
|
"webpush": {
|
||||||
|
text: `WEBPUSH <subcommand> [arguments]
|
||||||
|
|
||||||
|
Configures web push settings. Not for direct use by end users.`,
|
||||||
},
|
},
|
||||||
"who": {
|
"who": {
|
||||||
text: `WHO <name> [o]
|
text: `WHO <name> [o]
|
||||||
|
@ -177,7 +177,7 @@ func histservExportHandler(service *ircService, server *Server, client *Client,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func histservExportAndNotify(service *ircService, server *Server, cfAccount string, outfile *os.File, filename, alertNick string) {
|
func histservExportAndNotify(service *ircService, server *Server, cfAccount string, outfile *os.File, filename, alertNick string) {
|
||||||
defer server.HandlePanic()
|
defer server.HandlePanic(nil)
|
||||||
|
|
||||||
defer outfile.Close()
|
defer outfile.Close()
|
||||||
writer := bufio.NewWriter(outfile)
|
writer := bufio.NewWriter(outfile)
|
||||||
|
@ -17,6 +17,7 @@ import (
|
|||||||
"github.com/ergochat/ergo/irc/datastore"
|
"github.com/ergochat/ergo/irc/datastore"
|
||||||
"github.com/ergochat/ergo/irc/modes"
|
"github.com/ergochat/ergo/irc/modes"
|
||||||
"github.com/ergochat/ergo/irc/utils"
|
"github.com/ergochat/ergo/irc/utils"
|
||||||
|
"github.com/ergochat/ergo/irc/webpush"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -24,7 +25,7 @@ const (
|
|||||||
// XXX instead of referencing, e.g., keyAccountExists, we should write in the string literal
|
// XXX instead of referencing, e.g., keyAccountExists, we should write in the string literal
|
||||||
// (to ensure that no matter what code changes happen elsewhere, we're still producing a
|
// (to ensure that no matter what code changes happen elsewhere, we're still producing a
|
||||||
// db of the hardcoded version)
|
// db of the hardcoded version)
|
||||||
importDBSchemaVersion = 23
|
importDBSchemaVersion = 24
|
||||||
)
|
)
|
||||||
|
|
||||||
type userImport struct {
|
type userImport struct {
|
||||||
@ -82,6 +83,15 @@ func doImportDBGeneric(config *Config, dbImport databaseImport, credsType Creden
|
|||||||
|
|
||||||
tx.Set(keySchemaVersion, strconv.Itoa(importDBSchemaVersion), nil)
|
tx.Set(keySchemaVersion, strconv.Itoa(importDBSchemaVersion), nil)
|
||||||
tx.Set(keyCloakSecret, utils.GenerateSecretKey(), nil)
|
tx.Set(keyCloakSecret, utils.GenerateSecretKey(), nil)
|
||||||
|
vapidKeys, err := webpush.GenerateVAPIDKeys()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
vapidKeysJSON, err := json.Marshal(vapidKeys)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tx.Set(keyVAPIDKeys, string(vapidKeysJSON), nil)
|
||||||
|
|
||||||
cfUsernames := make(utils.HashSet[string])
|
cfUsernames := make(utils.HashSet[string])
|
||||||
skeletonToUsername := make(map[string]string)
|
skeletonToUsername := make(map[string]string)
|
||||||
|
@ -241,6 +241,18 @@ indicate an empty password, use * instead.`,
|
|||||||
"password": {
|
"password": {
|
||||||
aliasOf: "passwd",
|
aliasOf: "passwd",
|
||||||
},
|
},
|
||||||
|
"push": {
|
||||||
|
handler: nsPushHandler,
|
||||||
|
help: `Syntax: $bPUSH LIST$b
|
||||||
|
Or: $bPUSH DELETE <endpoint>$b
|
||||||
|
|
||||||
|
PUSH lets you view or modify the state of your push subscriptions.`,
|
||||||
|
helpShort: `$bPUSH$b lets you view or modify your push subscriptions.`,
|
||||||
|
enabled: func(config *Config) bool {
|
||||||
|
return config.WebPush.Enabled
|
||||||
|
},
|
||||||
|
minParams: 1,
|
||||||
|
},
|
||||||
"get": {
|
"get": {
|
||||||
handler: nsGetHandler,
|
handler: nsGetHandler,
|
||||||
help: `Syntax: $bGET <setting>$b
|
help: `Syntax: $bGET <setting>$b
|
||||||
@ -1656,3 +1668,45 @@ func nsRenameHandler(service *ircService, server *Server, client *Client, comman
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func nsPushHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
|
||||||
|
switch strings.ToUpper(params[0]) {
|
||||||
|
case "LIST":
|
||||||
|
target := client
|
||||||
|
if len(params) > 1 && client.HasRoleCapabs("accreg") {
|
||||||
|
target = server.clients.Get(params[1])
|
||||||
|
if target == nil {
|
||||||
|
service.Notice(rb, client.t("No such nick"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
subscriptions := target.getPushSubscriptions()
|
||||||
|
service.Notice(rb, fmt.Sprintf(client.t("Nickname %[1]s has %[2]d push subscription(s)"), target.Nick(), len(subscriptions)))
|
||||||
|
for i, subscription := range subscriptions {
|
||||||
|
service.Notice(rb, fmt.Sprintf("%d: %s", i, subscription.Endpoint))
|
||||||
|
}
|
||||||
|
case "DELETE":
|
||||||
|
if len(params) < 2 {
|
||||||
|
service.Notice(rb, client.t("Invalid parameters"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target := client
|
||||||
|
endpoint := params[1]
|
||||||
|
if len(params) > 2 && client.HasRoleCapabs("accreg") {
|
||||||
|
target = server.clients.Get(params[1])
|
||||||
|
if target == nil {
|
||||||
|
service.Notice(rb, client.t("No such nick"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
endpoint = params[2]
|
||||||
|
}
|
||||||
|
changed := target.deletePushSubscription(endpoint, true)
|
||||||
|
if changed {
|
||||||
|
service.Notice(rb, client.t("Successfully deleted push subscription"))
|
||||||
|
} else {
|
||||||
|
service.Notice(rb, client.t("Push subscription not found"))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
service.Notice(rb, client.t("Invalid parameters"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -6,14 +6,19 @@ package irc
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandlePanic is a general-purpose panic handler for ad-hoc goroutines.
|
// HandlePanic is a general-purpose panic handler for ad-hoc goroutines.
|
||||||
// Because of the semantics of `recover`, it must be called directly
|
// Because of the semantics of `recover`, it must be called directly
|
||||||
// from the routine on whose call stack the panic would occur, with `defer`,
|
// from the routine on whose call stack the panic would occur, with `defer`,
|
||||||
// e.g. `defer server.HandlePanic()`
|
// e.g. `defer server.HandlePanic()`
|
||||||
func (server *Server) HandlePanic() {
|
func (server *Server) HandlePanic(restartable func()) {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
server.logger.Error("internal", fmt.Sprintf("Panic encountered: %v\n%s", r, debug.Stack()))
|
server.logger.Error("internal", fmt.Sprintf("Panic encountered: %v\n%s", r, debug.Stack()))
|
||||||
|
if restartable != nil {
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
go restartable()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -36,10 +36,12 @@ import (
|
|||||||
"github.com/ergochat/ergo/irc/mysql"
|
"github.com/ergochat/ergo/irc/mysql"
|
||||||
"github.com/ergochat/ergo/irc/sno"
|
"github.com/ergochat/ergo/irc/sno"
|
||||||
"github.com/ergochat/ergo/irc/utils"
|
"github.com/ergochat/ergo/irc/utils"
|
||||||
|
"github.com/ergochat/ergo/irc/webpush"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
alwaysOnMaintenanceInterval = 30 * time.Minute
|
alwaysOnMaintenanceInterval = 30 * time.Minute
|
||||||
|
pushMaintenanceInterval = 24 * time.Hour
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@ -134,6 +136,7 @@ func NewServer(config *Config, logger *logger.Manager) (*Server, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
time.AfterFunc(alwaysOnMaintenanceInterval, server.periodicAlwaysOnMaintenance)
|
time.AfterFunc(alwaysOnMaintenanceInterval, server.periodicAlwaysOnMaintenance)
|
||||||
|
time.AfterFunc(pushMaintenanceInterval, server.periodicPushMaintenance)
|
||||||
|
|
||||||
return server, nil
|
return server, nil
|
||||||
}
|
}
|
||||||
@ -266,7 +269,7 @@ func (server *Server) periodicAlwaysOnMaintenance() {
|
|||||||
time.AfterFunc(alwaysOnMaintenanceInterval, server.periodicAlwaysOnMaintenance)
|
time.AfterFunc(alwaysOnMaintenanceInterval, server.periodicAlwaysOnMaintenance)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
defer server.HandlePanic()
|
defer server.HandlePanic(nil)
|
||||||
|
|
||||||
server.logger.Info("accounts", "Performing periodic always-on client checks")
|
server.logger.Info("accounts", "Performing periodic always-on client checks")
|
||||||
server.performAlwaysOnMaintenance(true, true)
|
server.performAlwaysOnMaintenance(true, true)
|
||||||
@ -290,6 +293,47 @@ func (server *Server) performAlwaysOnMaintenance(checkExpiration, flushTimestamp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (server *Server) periodicPushMaintenance() {
|
||||||
|
defer func() {
|
||||||
|
// reschedule whether or not there was a panic
|
||||||
|
time.AfterFunc(pushMaintenanceInterval, server.periodicPushMaintenance)
|
||||||
|
}()
|
||||||
|
|
||||||
|
defer server.HandlePanic(nil)
|
||||||
|
|
||||||
|
if server.Config().WebPush.Enabled {
|
||||||
|
server.logger.Info("webpush", "Performing periodic push subscription maintenance")
|
||||||
|
server.performPushMaintenance()
|
||||||
|
} // else: reschedule and check again later, the operator may enable it via rehash
|
||||||
|
}
|
||||||
|
|
||||||
|
func (server *Server) performPushMaintenance() {
|
||||||
|
expiration := time.Duration(server.Config().WebPush.Expiration)
|
||||||
|
for _, client := range server.clients.AllWithPushSubscriptions() {
|
||||||
|
for _, sub := range client.getPushSubscriptions() {
|
||||||
|
now := time.Now()
|
||||||
|
// require both periodic successful push messages and renewal of the subscription via WEBPUSH REGISTER
|
||||||
|
if now.Sub(sub.LastSuccess) > expiration || now.Sub(sub.LastRefresh) > expiration {
|
||||||
|
server.logger.Debug("webpush", "expiring push subscription for client", client.Nick(), sub.Endpoint)
|
||||||
|
client.deletePushSubscription(sub.Endpoint, false)
|
||||||
|
} else if now.Sub(sub.LastSuccess) > expiration/2 {
|
||||||
|
// we haven't pushed to them recently, make an attempt
|
||||||
|
server.logger.Debug("webpush", "pinging push subscription for client", client.Nick(), sub.Endpoint)
|
||||||
|
client.sendAndTrackPush(
|
||||||
|
sub.Endpoint, sub.Keys,
|
||||||
|
pushMessage{
|
||||||
|
msg: webpush.PingMessage,
|
||||||
|
urgency: webpush.UrgencyNormal,
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// persist all push subscriptions on the assumption that the timestamps have changed
|
||||||
|
client.Store(IncludePushSubscriptions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// handles server.ip-check-script.exempt-sasl:
|
// handles server.ip-check-script.exempt-sasl:
|
||||||
// run the ip check script at the end of the handshake, only for anonymous connections
|
// run the ip check script at the end of the handshake, only for anonymous connections
|
||||||
func (server *Server) checkBanScriptExemptSASL(config *Config, session *Session) (outcome AuthOutcome) {
|
func (server *Server) checkBanScriptExemptSASL(config *Config, session *Session) (outcome AuthOutcome) {
|
||||||
@ -588,7 +632,7 @@ func (client *Client) getWhoisOf(target *Client, hasPrivs bool, rb *ResponseBuff
|
|||||||
// rehash reloads the config and applies the changes from the config file.
|
// rehash reloads the config and applies the changes from the config file.
|
||||||
func (server *Server) rehash() error {
|
func (server *Server) rehash() error {
|
||||||
// #1570; this needs its own panic handling because it can be invoked via SIGHUP
|
// #1570; this needs its own panic handling because it can be invoked via SIGHUP
|
||||||
defer server.HandlePanic()
|
defer server.HandlePanic(nil)
|
||||||
|
|
||||||
server.logger.Info("server", "Attempting rehash")
|
server.logger.Info("server", "Attempting rehash")
|
||||||
|
|
||||||
@ -742,6 +786,16 @@ func (server *Server) applyConfig(config *Config) (err error) {
|
|||||||
return fmt.Errorf("Could not load cloak secret: %w", err)
|
return fmt.Errorf("Could not load cloak secret: %w", err)
|
||||||
}
|
}
|
||||||
config.Server.Cloaks.SetSecret(cloakSecret)
|
config.Server.Cloaks.SetSecret(cloakSecret)
|
||||||
|
// similarly bring the VAPID keys into the config, which requires regenerating the 005
|
||||||
|
if config.WebPush.Enabled {
|
||||||
|
config.WebPush.vapidKeys, err = LoadVAPIDKeys(server.dstore)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Could not load VAPID keys: %w", err)
|
||||||
|
}
|
||||||
|
if err = config.generateISupport(); err != nil {
|
||||||
|
return fmt.Errorf("Could not regenerate cached 005 for VAPID: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// activate the new config
|
// activate the new config
|
||||||
server.config.Store(config)
|
server.config.Store(config)
|
||||||
|
@ -1,35 +0,0 @@
|
|||||||
// Copyright 2009 The Go Authors. All rights reserved.
|
|
||||||
// Use of this source code is governed by a BSD-style
|
|
||||||
// license that can be found in the LICENSE file.
|
|
||||||
|
|
||||||
package utils
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Once is a fork of sync.Once to expose a Done() method.
|
|
||||||
type Once struct {
|
|
||||||
done uint32
|
|
||||||
m sync.Mutex
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o *Once) Do(f func()) {
|
|
||||||
if atomic.LoadUint32(&o.done) == 0 {
|
|
||||||
o.doSlow(f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o *Once) doSlow(f func()) {
|
|
||||||
o.m.Lock()
|
|
||||||
defer o.m.Unlock()
|
|
||||||
if o.done == 0 {
|
|
||||||
defer atomic.StoreUint32(&o.done, 1)
|
|
||||||
f()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o *Once) Done() bool {
|
|
||||||
return atomic.LoadUint32(&o.done) == 1
|
|
||||||
}
|
|
@ -95,6 +95,20 @@ func (sm *SplitMessage) Is512() bool {
|
|||||||
return sm.Split == nil
|
return sm.Split == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (sm *SplitMessage) CombinedValue() string {
|
||||||
|
if sm.Split == nil {
|
||||||
|
return sm.Message
|
||||||
|
}
|
||||||
|
var buf strings.Builder
|
||||||
|
for i := range sm.Split {
|
||||||
|
if i != 0 && !sm.Split[i].Concat {
|
||||||
|
buf.WriteRune('\n')
|
||||||
|
}
|
||||||
|
buf.WriteString(sm.Split[i].Message)
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
// TokenLineBuilder is a helper for building IRC lines composed of delimited tokens,
|
// TokenLineBuilder is a helper for building IRC lines composed of delimited tokens,
|
||||||
// with a maximum line length.
|
// with a maximum line length.
|
||||||
type TokenLineBuilder struct {
|
type TokenLineBuilder struct {
|
||||||
|
@ -66,3 +66,15 @@ func BenchmarkTokenLines(b *testing.B) {
|
|||||||
tl.Lines()
|
tl.Lines()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCombinedValue(t *testing.T) {
|
||||||
|
var split = SplitMessage{
|
||||||
|
Split: []MessagePair{
|
||||||
|
{"hi", false},
|
||||||
|
{"hi", false},
|
||||||
|
{" again", true},
|
||||||
|
{"you", false},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assertEqual(split.CombinedValue(), "hi\nhi again\nyou", t)
|
||||||
|
}
|
||||||
|
60
irc/webpush/highlight.go
Normal file
60
irc/webpush/highlight.go
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
// Copyright (c) 2021-2024 Simon Ser <contact@emersion.fr>
|
||||||
|
// Originally released under the AGPLv3, relicensed to the Ergo project under the MIT license
|
||||||
|
|
||||||
|
package webpush
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
func isWordBoundary(r rune) bool {
|
||||||
|
switch r {
|
||||||
|
case '-', '_', '|': // inspired from weechat.look.highlight_regex
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return !unicode.IsLetter(r) && !unicode.IsNumber(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isURIPrefix(text string) bool {
|
||||||
|
if i := strings.LastIndexFunc(text, unicode.IsSpace); i >= 0 {
|
||||||
|
text = text[i:]
|
||||||
|
}
|
||||||
|
|
||||||
|
i := strings.Index(text, "://")
|
||||||
|
if i < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// See RFC 3986 section 3
|
||||||
|
r, _ := utf8.DecodeLastRuneInString(text[:i])
|
||||||
|
switch r {
|
||||||
|
case '+', '-', '.':
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return ('0' <= r && r <= '9') || ('a' <= r && r <= 'z') || ('A' <= r && r <= 'Z')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsHighlight(text, nick string) bool {
|
||||||
|
if len(nick) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
i := strings.Index(text, nick)
|
||||||
|
if i < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
left, _ := utf8.DecodeLastRuneInString(text[:i])
|
||||||
|
right, _ := utf8.DecodeRuneInString(text[i+len(nick):])
|
||||||
|
if isWordBoundary(left) && isWordBoundary(right) && !isURIPrefix(text[:i]) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
text = text[i+len(nick):]
|
||||||
|
}
|
||||||
|
}
|
66
irc/webpush/security.go
Normal file
66
irc/webpush/security.go
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
// Copyright (c) 2024 Shivaram Lingamneni <slingamn@cs.stanford.edu>
|
||||||
|
// Released under the MIT license
|
||||||
|
// Some portions of this code are:
|
||||||
|
// Copyright (c) 2024 Simon Ser <contact@emersion.fr>
|
||||||
|
// Originally released under the AGPLv3, relicensed to the Ergo project under the MIT license
|
||||||
|
|
||||||
|
package webpush
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errInternalIP = errors.New("dialing an internal IP is forbidden")
|
||||||
|
)
|
||||||
|
|
||||||
|
func SanityCheckWebPushEndpoint(endpoint string) error {
|
||||||
|
u, err := url.Parse(endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if u.Scheme != "https" {
|
||||||
|
return fmt.Errorf("scheme must be HTTPS")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeExternalOnlyClient builds an http.Client that can only connect
|
||||||
|
// to external IP addresses.
|
||||||
|
func makeExternalOnlyClient() *http.Client {
|
||||||
|
dialer := &net.Dialer{
|
||||||
|
Control: func(network, address string, c syscall.RawConn) error {
|
||||||
|
ip, _, err := net.SplitHostPort(address)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedIP, err := netip.ParseAddr(ip)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if isInternalIP(parsedIP) {
|
||||||
|
return errInternalIP
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
DialContext: dialer.DialContext,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isInternalIP(ip netip.Addr) bool {
|
||||||
|
return ip.IsLoopback() || ip.IsMulticast() || ip.IsPrivate()
|
||||||
|
}
|
21
irc/webpush/security_test.go
Normal file
21
irc/webpush/security_test.go
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
package webpush
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExternalOnlyHTTPClient(t *testing.T) {
|
||||||
|
client := makeExternalOnlyClient()
|
||||||
|
|
||||||
|
for _, url := range []string{
|
||||||
|
"https://127.0.0.2/test",
|
||||||
|
"https://127.0.0.2:8201",
|
||||||
|
"https://127.0.0.2:8201/asdf",
|
||||||
|
} {
|
||||||
|
_, err := client.Get(url)
|
||||||
|
if err == nil || !errors.Is(err, errInternalIP) {
|
||||||
|
t.Errorf("%s was not forbidden as expected (got %v)", url, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
140
irc/webpush/webpush.go
Normal file
140
irc/webpush/webpush.go
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
// Copyright (c) 2024 Shivaram Lingamneni <slingamn@cs.stanford.edu>
|
||||||
|
// Released under the MIT license
|
||||||
|
// Some portions of this code are:
|
||||||
|
// Copyright (c) 2021-2024 Simon Ser <contact@emersion.fr>
|
||||||
|
// Originally released under the AGPLv3, relicensed to the Ergo project under the MIT license
|
||||||
|
|
||||||
|
package webpush
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/ergochat/irc-go/ircmsg"
|
||||||
|
webpush "github.com/ergochat/webpush-go/v2"
|
||||||
|
|
||||||
|
"github.com/ergochat/ergo/irc/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// alias some public types and names from webpush-go
|
||||||
|
type VAPIDKeys = webpush.VAPIDKeys
|
||||||
|
type Keys = webpush.Keys
|
||||||
|
|
||||||
|
var (
|
||||||
|
GenerateVAPIDKeys = webpush.GenerateVAPIDKeys
|
||||||
|
)
|
||||||
|
|
||||||
|
// Urgency is a uint8 representation of urgency to save a few
|
||||||
|
// bytes on channel sizes.
|
||||||
|
type Urgency uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
// UrgencyVeryLow requires device state: on power and Wi-Fi
|
||||||
|
UrgencyVeryLow Urgency = iota // "very-low"
|
||||||
|
// UrgencyLow requires device state: on either power or Wi-Fi
|
||||||
|
UrgencyLow // "low"
|
||||||
|
// UrgencyNormal excludes device state: low battery
|
||||||
|
UrgencyNormal // "normal"
|
||||||
|
// UrgencyHigh admits device state: low battery
|
||||||
|
UrgencyHigh // "high"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// PingMessage is a valid IRC message that we can send to test that the subscription
|
||||||
|
// is valid (i.e. responds to POSTs with a 20x). We do not expect that the client will
|
||||||
|
// actually connect to IRC and send PONG (although it might be nice to have a way to
|
||||||
|
// hint to a client that they should reconnect to renew their subscription?)
|
||||||
|
PingMessage = []byte("PING webpush")
|
||||||
|
)
|
||||||
|
|
||||||
|
func convertUrgency(u Urgency) webpush.Urgency {
|
||||||
|
switch u {
|
||||||
|
case UrgencyVeryLow:
|
||||||
|
return webpush.UrgencyVeryLow
|
||||||
|
case UrgencyLow:
|
||||||
|
return webpush.UrgencyLow
|
||||||
|
case UrgencyNormal:
|
||||||
|
return webpush.UrgencyNormal
|
||||||
|
case UrgencyHigh:
|
||||||
|
return webpush.UrgencyHigh
|
||||||
|
default:
|
||||||
|
return webpush.UrgencyNormal // shouldn't happen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var httpClient = makeExternalOnlyClient()
|
||||||
|
|
||||||
|
var (
|
||||||
|
Err404 = errors.New("endpoint returned a 404, indicating that the push subscription is no longer valid")
|
||||||
|
|
||||||
|
errInvalidKey = errors.New("invalid key format")
|
||||||
|
)
|
||||||
|
|
||||||
|
func DecodeSubscriptionKeys(keysParam string) (keys webpush.Keys, err error) {
|
||||||
|
// The keys parameter is tag-encoded, with each tag value being URL-safe base64 encoded:
|
||||||
|
// * One public key with the name p256dh set to the client's P-256 ECDH public key.
|
||||||
|
// * One shared key with the name auth set to a 16-byte client-generated authentication secret.
|
||||||
|
// since we don't have a separate tag parser implementation, wrap it in a fake IRC line for parsing:
|
||||||
|
fakeIRCLine := fmt.Sprintf("@%s PING", keysParam)
|
||||||
|
ircMsg, err := ircmsg.ParseLine(fakeIRCLine)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, auth := ircMsg.GetTag("auth")
|
||||||
|
_, p256 := ircMsg.GetTag("p256dh")
|
||||||
|
return webpush.DecodeSubscriptionKeys(auth, p256)
|
||||||
|
}
|
||||||
|
|
||||||
|
func MakePushMessage(command, nuh, accountName, target string, msg utils.SplitMessage) ([]byte, error) {
|
||||||
|
var messageForPush string
|
||||||
|
if msg.Is512() {
|
||||||
|
messageForPush = msg.Message
|
||||||
|
} else {
|
||||||
|
messageForPush = msg.Split[0].Message
|
||||||
|
}
|
||||||
|
|
||||||
|
ircMsg := ircmsg.MakeMessage(nil, nuh, command, target, messageForPush)
|
||||||
|
ircMsg.SetTag("time", msg.Time.Format(utils.IRCv3TimestampFormat))
|
||||||
|
if accountName != "*" {
|
||||||
|
ircMsg.SetTag("account", accountName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if line, err := ircMsg.LineBytesStrict(false, 512); err == nil {
|
||||||
|
// strip final \r\n
|
||||||
|
return line[:len(line)-2], nil
|
||||||
|
} else {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func SendWebPush(ctx context.Context, endpoint string, keys Keys, vapidKeys *VAPIDKeys, urgency Urgency, subscriber string, msg []byte) error {
|
||||||
|
wpsub := webpush.Subscription{
|
||||||
|
Endpoint: endpoint,
|
||||||
|
Keys: keys,
|
||||||
|
}
|
||||||
|
|
||||||
|
options := webpush.Options{
|
||||||
|
HTTPClient: httpClient,
|
||||||
|
VAPIDKeys: vapidKeys,
|
||||||
|
Subscriber: subscriber,
|
||||||
|
TTL: 7 * 24 * 60 * 60, // seconds
|
||||||
|
Urgency: convertUrgency(urgency),
|
||||||
|
RecordSize: 2048,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := webpush.SendNotification(ctx, msg, &wpsub, &options)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusNotFound {
|
||||||
|
return Err404
|
||||||
|
} else if 200 <= resp.StatusCode && resp.StatusCode < 300 {
|
||||||
|
return nil
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("HTTP error: %v", resp.Status)
|
||||||
|
}
|
||||||
|
}
|
@ -893,6 +893,7 @@ fakelag:
|
|||||||
"MARKREAD": 16
|
"MARKREAD": 16
|
||||||
"MONITOR": 1
|
"MONITOR": 1
|
||||||
"WHO": 4
|
"WHO": 4
|
||||||
|
"WEBPUSH": 1
|
||||||
|
|
||||||
# the roleplay commands are semi-standardized extensions to IRC that allow
|
# the roleplay commands are semi-standardized extensions to IRC that allow
|
||||||
# sending and receiving messages from pseudo-nicknames. this can be used either
|
# sending and receiving messages from pseudo-nicknames. this can be used either
|
||||||
@ -1038,3 +1039,21 @@ history:
|
|||||||
# whether to allow customization of the config at runtime using environment variables,
|
# whether to allow customization of the config at runtime using environment variables,
|
||||||
# e.g., ERGO__SERVER__MAX_SENDQ=128k. see the manual for more details.
|
# e.g., ERGO__SERVER__MAX_SENDQ=128k. see the manual for more details.
|
||||||
allow-environment-overrides: true
|
allow-environment-overrides: true
|
||||||
|
|
||||||
|
# experimental support for mobile push notifications
|
||||||
|
# see the manual for potential security, privacy, and performance implications.
|
||||||
|
# DO NOT enable if you are running a Tor or I2P hidden service (i.e. one
|
||||||
|
# with no public IP listeners, only Tor/I2P listeners).
|
||||||
|
webpush:
|
||||||
|
# are push notifications enabled at all?
|
||||||
|
enabled: false
|
||||||
|
# request timeout for POST'ing the http notification
|
||||||
|
timeout: 10s
|
||||||
|
# subscriber field for the VAPID JWT authorization:
|
||||||
|
#subscriber: "https://your-website.com/"
|
||||||
|
# maximum number of push subscriptions per user
|
||||||
|
max-subscriptions: 4
|
||||||
|
# expiration time for a push subscription; it must be renewed within this time
|
||||||
|
# by the client reconnecting to IRC. we also detect whether the client is no longer
|
||||||
|
# successfully receiving push messages.
|
||||||
|
expiration: 14d
|
||||||
|
13
vendor/github.com/ergochat/webpush-go/v2/.check-gofmt.sh
generated
vendored
Normal file
13
vendor/github.com/ergochat/webpush-go/v2/.check-gofmt.sh
generated
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
SOURCES="."
|
||||||
|
|
||||||
|
if [ "$1" = "--fix" ]; then
|
||||||
|
exec gofmt -s -w $SOURCES
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$(gofmt -s -l $SOURCES)" ]; then
|
||||||
|
echo "Go code is not formatted correctly with \`gofmt -s\`:"
|
||||||
|
gofmt -s -d $SOURCES
|
||||||
|
exit 1
|
||||||
|
fi
|
6
vendor/github.com/ergochat/webpush-go/v2/.gitignore
generated
vendored
Normal file
6
vendor/github.com/ergochat/webpush-go/v2/.gitignore
generated
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
vendor/**
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
*.out
|
||||||
|
|
||||||
|
*.swp
|
14
vendor/github.com/ergochat/webpush-go/v2/CHANGELOG.md
generated
vendored
Normal file
14
vendor/github.com/ergochat/webpush-go/v2/CHANGELOG.md
generated
vendored
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# Changelog
|
||||||
|
All notable changes to webpush-go will be documented in this file.
|
||||||
|
|
||||||
|
## [2.0.0] - 2025-01-01
|
||||||
|
|
||||||
|
* Update the `Keys` struct definition to store `Auth` as `[16]byte` and `P256dh` as `*ecdh.PublicKey`
|
||||||
|
* `Keys` can no longer be compared with `==`; use `(*Keys.Equal)` instead
|
||||||
|
* The JSON representation has not changed and is backwards and forwards compatible with v1
|
||||||
|
* `DecodeSubscriptionKeys` is a helper to decode base64-encoded auth and p256dh parameters into a `Keys`, with validation
|
||||||
|
* Update the `VAPIDKeys` struct to contain a `(*ecdsa.PrivateKey)`
|
||||||
|
* `VAPIDKeys` can no longer be compared with `==`; use `(*VAPIDKeys).Equal` instead
|
||||||
|
* The JSON representation is now a JSON string containing the PEM of the PKCS8-encoded private key
|
||||||
|
* To parse the legacy representation (raw bytes of the private key encoded in base64), use `DecodeLegacyVAPIDPrivateKey`
|
||||||
|
* Renamed `SendNotificationWithContext` to `SendNotification`, removing the earlier `SendNotification` API. (Pass `context.Background()` as the context to restore the former behavior.)
|
21
vendor/github.com/ergochat/webpush-go/v2/LICENSE
generated
vendored
Normal file
21
vendor/github.com/ergochat/webpush-go/v2/LICENSE
generated
vendored
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2016 Ethan Holmes
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
6
vendor/github.com/ergochat/webpush-go/v2/Makefile
generated
vendored
Normal file
6
vendor/github.com/ergochat/webpush-go/v2/Makefile
generated
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
.PHONY: test
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test .
|
||||||
|
go vet .
|
||||||
|
./.check-gofmt.sh
|
65
vendor/github.com/ergochat/webpush-go/v2/README.md
generated
vendored
Normal file
65
vendor/github.com/ergochat/webpush-go/v2/README.md
generated
vendored
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
# webpush-go
|
||||||
|
|
||||||
|
[![GoDoc](https://godoc.org/github.com/ergochat/webpush-go?status.svg)](https://godoc.org/github.com/ergochat/webpush-go)
|
||||||
|
|
||||||
|
Web Push API Encryption with VAPID support.
|
||||||
|
|
||||||
|
This library is a fork of [SherClockHolmes/webpush-go](https://github.com/SherClockHolmes/webpush-go).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go get -u github.com/ergochat/webpush-go/v2
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
For a full example, refer to the code in the [example](example/) directory.
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
webpush "github.com/ergochat/webpush-go/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Decode subscription
|
||||||
|
s := &webpush.Subscription{}
|
||||||
|
json.Unmarshal([]byte("<YOUR_SUBSCRIPTION>"), s)
|
||||||
|
vapidKeys := new(webpush.VAPIDKeys)
|
||||||
|
json.Unmarshal([]byte("<YOUR_VAPID_KEYS">), vapidKeys)
|
||||||
|
|
||||||
|
// Send Notification
|
||||||
|
resp, err := webpush.SendNotification([]byte("Test"), s, &webpush.Options{
|
||||||
|
Subscriber: "example@example.com",
|
||||||
|
VAPIDKeys: vapidKeys,
|
||||||
|
TTL: 3600, // seconds
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
// TODO: Handle error
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generating VAPID Keys
|
||||||
|
|
||||||
|
Use the helper method `GenerateVAPIDKeys` to generate the VAPID key pair.
|
||||||
|
|
||||||
|
```golang
|
||||||
|
vapidKeys, err := webpush.GenerateVAPIDKeys()
|
||||||
|
if err != nil {
|
||||||
|
// TODO: Handle error
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
1. Install [Go 1.20+](https://golang.org/)
|
||||||
|
2. `go mod vendor`
|
||||||
|
3. `go test`
|
||||||
|
|
||||||
|
#### For other language implementations visit:
|
||||||
|
|
||||||
|
[WebPush Libs](https://github.com/web-push-libs)
|
76
vendor/github.com/ergochat/webpush-go/v2/legacy.go
generated
vendored
Normal file
76
vendor/github.com/ergochat/webpush-go/v2/legacy.go
generated
vendored
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
package webpush
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ecdh"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ecdhPublicKeyToECDSA converts an ECDH key to an ECDSA key.
|
||||||
|
// This is deprecated as per https://github.com/golang/go/issues/63963
|
||||||
|
// but we need to do it in order to parse the legacy private key format.
|
||||||
|
func ecdhPublicKeyToECDSA(key *ecdh.PublicKey) (*ecdsa.PublicKey, error) {
|
||||||
|
rawKey := key.Bytes()
|
||||||
|
switch key.Curve() {
|
||||||
|
case ecdh.P256():
|
||||||
|
return &ecdsa.PublicKey{
|
||||||
|
Curve: elliptic.P256(),
|
||||||
|
X: big.NewInt(0).SetBytes(rawKey[1:33]),
|
||||||
|
Y: big.NewInt(0).SetBytes(rawKey[33:]),
|
||||||
|
}, nil
|
||||||
|
case ecdh.P384():
|
||||||
|
return &ecdsa.PublicKey{
|
||||||
|
Curve: elliptic.P384(),
|
||||||
|
X: big.NewInt(0).SetBytes(rawKey[1:49]),
|
||||||
|
Y: big.NewInt(0).SetBytes(rawKey[49:]),
|
||||||
|
}, nil
|
||||||
|
case ecdh.P521():
|
||||||
|
return &ecdsa.PublicKey{
|
||||||
|
Curve: elliptic.P521(),
|
||||||
|
X: big.NewInt(0).SetBytes(rawKey[1:67]),
|
||||||
|
Y: big.NewInt(0).SetBytes(rawKey[67:]),
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("cannot convert non-NIST *ecdh.PublicKey to *ecdsa.PublicKey")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ecdhPrivateKeyToECDSA(key *ecdh.PrivateKey) (*ecdsa.PrivateKey, error) {
|
||||||
|
// see https://github.com/golang/go/issues/63963
|
||||||
|
pubKey, err := ecdhPublicKeyToECDSA(key.PublicKey())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("converting PublicKey part of *ecdh.PrivateKey: %w", err)
|
||||||
|
}
|
||||||
|
return &ecdsa.PrivateKey{
|
||||||
|
PublicKey: *pubKey,
|
||||||
|
D: big.NewInt(0).SetBytes(key.Bytes()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeLegacyVAPIDPrivateKey decodes the legacy string private key format
|
||||||
|
// returned by GenerateVAPIDKeys in v1.
|
||||||
|
func DecodeLegacyVAPIDPrivateKey(key string) (*VAPIDKeys, error) {
|
||||||
|
bytes, err := decodeSubscriptionKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ecdhPrivKey, err := ecdh.P256().NewPrivateKey(bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ecdsaPrivKey, err := ecdhPrivateKeyToECDSA(ecdhPrivKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
publicKey := base64.RawURLEncoding.EncodeToString(ecdhPrivKey.PublicKey().Bytes())
|
||||||
|
return &VAPIDKeys{
|
||||||
|
privateKey: ecdsaPrivKey,
|
||||||
|
publicKey: publicKey,
|
||||||
|
}, nil
|
||||||
|
}
|
26
vendor/github.com/ergochat/webpush-go/v2/urgency.go
generated
vendored
Normal file
26
vendor/github.com/ergochat/webpush-go/v2/urgency.go
generated
vendored
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
package webpush
|
||||||
|
|
||||||
|
// Urgency indicates to the push service how important a message is to the user.
|
||||||
|
// This can be used by the push service to help conserve the battery life of a user's device
|
||||||
|
// by only waking up for important messages when battery is low.
|
||||||
|
type Urgency string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// UrgencyVeryLow requires device state: on power and Wi-Fi
|
||||||
|
UrgencyVeryLow Urgency = "very-low"
|
||||||
|
// UrgencyLow requires device state: on either power or Wi-Fi
|
||||||
|
UrgencyLow Urgency = "low"
|
||||||
|
// UrgencyNormal excludes device state: low battery
|
||||||
|
UrgencyNormal Urgency = "normal"
|
||||||
|
// UrgencyHigh admits device state: low battery
|
||||||
|
UrgencyHigh Urgency = "high"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Checking allowable values for the urgency header
|
||||||
|
func isValidUrgency(urgency Urgency) bool {
|
||||||
|
switch urgency {
|
||||||
|
case UrgencyVeryLow, UrgencyLow, UrgencyNormal, UrgencyHigh:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
177
vendor/github.com/ergochat/webpush-go/v2/vapid.go
generated
vendored
Normal file
177
vendor/github.com/ergochat/webpush-go/v2/vapid.go
generated
vendored
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
package webpush
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
jwt "github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VAPIDKeys is a public-private keypair for use in VAPID.
|
||||||
|
// It marshals to a JSON string containing the PEM of the PKCS8
|
||||||
|
// of the private key.
|
||||||
|
type VAPIDKeys struct {
|
||||||
|
privateKey *ecdsa.PrivateKey
|
||||||
|
publicKey string // raw bytes encoding in urlsafe base64, as per RFC
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicKeyString returns the base64url-encoded uncompressed public key of the keypair,
|
||||||
|
// as defined in RFC8292.
|
||||||
|
func (v *VAPIDKeys) PublicKeyString() string {
|
||||||
|
return v.publicKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrivateKey returns the private key of the keypair.
|
||||||
|
func (v *VAPIDKeys) PrivateKey() *ecdsa.PrivateKey {
|
||||||
|
return v.privateKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// Equal compares two VAPIDKeys for equality.
|
||||||
|
func (v *VAPIDKeys) Equal(o *VAPIDKeys) bool {
|
||||||
|
return v.privateKey.Equal(o.privateKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ json.Marshaler = (*VAPIDKeys)(nil)
|
||||||
|
var _ json.Unmarshaler = (*VAPIDKeys)(nil)
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler, allowing serialization to JSON.
|
||||||
|
func (v *VAPIDKeys) MarshalJSON() ([]byte, error) {
|
||||||
|
pkcs8bytes, err := x509.MarshalPKCS8PrivateKey(v.privateKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pemBlock := pem.Block{
|
||||||
|
Type: "PRIVATE KEY",
|
||||||
|
Bytes: pkcs8bytes,
|
||||||
|
}
|
||||||
|
pemBytes := pem.EncodeToMemory(&pemBlock)
|
||||||
|
if pemBytes == nil {
|
||||||
|
return nil, fmt.Errorf("could not encode VAPID keys as PEM")
|
||||||
|
}
|
||||||
|
return json.Marshal(string(pemBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Unmarshaler, allowing deserialization from JSON.
|
||||||
|
func (v *VAPIDKeys) UnmarshalJSON(b []byte) error {
|
||||||
|
var pemKey string
|
||||||
|
if err := json.Unmarshal(b, &pemKey); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pemBlock, _ := pem.Decode([]byte(pemKey))
|
||||||
|
if pemBlock == nil {
|
||||||
|
return fmt.Errorf("could not decode PEM block with VAPID keys")
|
||||||
|
}
|
||||||
|
privKey, err := x509.ParsePKCS8PrivateKey(pemBlock.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
privateKey, ok := privKey.(*ecdsa.PrivateKey)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("Invalid type of private key %T", privateKey)
|
||||||
|
}
|
||||||
|
if privateKey.Curve != elliptic.P256() {
|
||||||
|
return fmt.Errorf("Invalid curve for private key %v", privateKey.Curve)
|
||||||
|
}
|
||||||
|
publicKeyStr, err := makePublicKeyString(privateKey)
|
||||||
|
if err != nil {
|
||||||
|
return err // should not be possible since we confirmed P256 already
|
||||||
|
}
|
||||||
|
|
||||||
|
// success
|
||||||
|
v.privateKey = privateKey
|
||||||
|
v.publicKey = publicKeyStr
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateVAPIDKeys generates a VAPID keypair (an ECDSA keypair on
|
||||||
|
// the P-256 curve).
|
||||||
|
func GenerateVAPIDKeys() (result *VAPIDKeys, err error) {
|
||||||
|
private, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pubKeyECDH, err := private.PublicKey.ECDH()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
publicKey := base64.RawURLEncoding.EncodeToString(pubKeyECDH.Bytes())
|
||||||
|
|
||||||
|
return &VAPIDKeys{
|
||||||
|
privateKey: private,
|
||||||
|
publicKey: publicKey,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ECDSAToVAPIDKeys wraps an existing ecdsa.PrivateKey in VAPIDKeys for use in
|
||||||
|
// VAPID header signing.
|
||||||
|
func ECDSAToVAPIDKeys(privKey *ecdsa.PrivateKey) (result *VAPIDKeys, err error) {
|
||||||
|
if privKey.Curve != elliptic.P256() {
|
||||||
|
return nil, fmt.Errorf("Invalid curve for private key %v", privKey.Curve)
|
||||||
|
}
|
||||||
|
publicKeyString, err := makePublicKeyString(privKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &VAPIDKeys{
|
||||||
|
privateKey: privKey,
|
||||||
|
publicKey: publicKeyString,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func makePublicKeyString(privKey *ecdsa.PrivateKey) (result string, err error) {
|
||||||
|
// to get the raw bytes we have to convert the public key to *ecdh.PublicKey
|
||||||
|
// this type assertion (from the crypto.PublicKey returned by (*ecdsa.PrivateKey).Public()
|
||||||
|
// to *ecdsa.PublicKey) cannot fail:
|
||||||
|
publicKey, err := privKey.Public().(*ecdsa.PublicKey).ECDH()
|
||||||
|
if err != nil {
|
||||||
|
return // should not be possible if we confirmed P256 already
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(publicKey.Bytes()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getVAPIDAuthorizationHeader
|
||||||
|
func getVAPIDAuthorizationHeader(
|
||||||
|
endpoint string,
|
||||||
|
subscriber string,
|
||||||
|
vapidKeys *VAPIDKeys,
|
||||||
|
expiration time.Time,
|
||||||
|
) (string, error) {
|
||||||
|
if expiration.IsZero() {
|
||||||
|
expiration = time.Now().Add(time.Hour * 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the JWT token
|
||||||
|
subURL, err := url.Parse(endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unless subscriber is an HTTPS URL, assume an e-mail address
|
||||||
|
if !strings.HasPrefix(subscriber, "https:") && !strings.HasPrefix(subscriber, "mailto:") {
|
||||||
|
subscriber = "mailto:" + subscriber
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{
|
||||||
|
"aud": subURL.Scheme + "://" + subURL.Host,
|
||||||
|
"exp": expiration.Unix(),
|
||||||
|
"sub": subscriber,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sign token with private key
|
||||||
|
jwtString, err := token.SignedString(vapidKeys.privateKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return "vapid t=" + jwtString + ", k=" + vapidKeys.publicKey, nil
|
||||||
|
}
|
323
vendor/github.com/ergochat/webpush-go/v2/webpush.go
generated
vendored
Normal file
323
vendor/github.com/ergochat/webpush-go/v2/webpush.go
generated
vendored
Normal file
@ -0,0 +1,323 @@
|
|||||||
|
package webpush
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/ecdh"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/hkdf"
|
||||||
|
)
|
||||||
|
|
||||||
|
const MaxRecordSize uint32 = 4096
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrRecordSizeTooSmall = errors.New("record size too small for message")
|
||||||
|
|
||||||
|
invalidAuthKeyLength = errors.New("invalid auth key length (must be 16)")
|
||||||
|
|
||||||
|
defaultHTTPClient = &http.Client{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// HTTPClient is an interface for sending the notification HTTP request / testing
|
||||||
|
type HTTPClient interface {
|
||||||
|
Do(*http.Request) (*http.Response, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options are config and extra params needed to send a notification
|
||||||
|
type Options struct {
|
||||||
|
HTTPClient HTTPClient // Will replace with *http.Client by default if not included
|
||||||
|
RecordSize uint32 // Limit the record size
|
||||||
|
Subscriber string // Sub in VAPID JWT token
|
||||||
|
Topic string // Set the Topic header to collapse a pending messages (Optional)
|
||||||
|
TTL int // Set the TTL on the endpoint POST request, in seconds
|
||||||
|
Urgency Urgency // Set the Urgency header to change a message priority (Optional)
|
||||||
|
VAPIDKeys *VAPIDKeys // VAPID public-private keypair to generate the VAPID Authorization header
|
||||||
|
VapidExpiration time.Time // optional expiration for VAPID JWT token (defaults to now + 12 hours)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keys represents a subscription's keys (its ECDH public key on the P-256 curve
|
||||||
|
// and its 16-byte authentication secret).
|
||||||
|
type Keys struct {
|
||||||
|
Auth [16]byte
|
||||||
|
P256dh *ecdh.PublicKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// Equal compares two Keys for equality.
|
||||||
|
func (k *Keys) Equal(o Keys) bool {
|
||||||
|
return k.Auth == o.Auth && k.P256dh.Equal(o.P256dh)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ json.Marshaler = (*Keys)(nil)
|
||||||
|
var _ json.Unmarshaler = (*Keys)(nil)
|
||||||
|
|
||||||
|
type marshaledKeys struct {
|
||||||
|
Auth string `json:"auth"`
|
||||||
|
P256dh string `json:"p256dh"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler, allowing serialization to JSON.
|
||||||
|
func (k *Keys) MarshalJSON() ([]byte, error) {
|
||||||
|
m := marshaledKeys{
|
||||||
|
Auth: base64.RawStdEncoding.EncodeToString(k.Auth[:]),
|
||||||
|
P256dh: base64.RawStdEncoding.EncodeToString(k.P256dh.Bytes()),
|
||||||
|
}
|
||||||
|
return json.Marshal(&m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Unmarshaler, allowing deserialization from JSON.
|
||||||
|
func (k *Keys) UnmarshalJSON(b []byte) (err error) {
|
||||||
|
var m marshaledKeys
|
||||||
|
if err := json.Unmarshal(b, &m); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
authBytes, err := decodeSubscriptionKey(m.Auth)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(authBytes) != 16 {
|
||||||
|
return fmt.Errorf("invalid auth bytes length %d (must be 16)", len(authBytes))
|
||||||
|
}
|
||||||
|
copy(k.Auth[:], authBytes)
|
||||||
|
rawDHKey, err := decodeSubscriptionKey(m.P256dh)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
k.P256dh, err = ecdh.P256().NewPublicKey(rawDHKey)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeSubscriptionKeys decodes and validates a base64-encoded pair of subscription keys
|
||||||
|
// (the authentication secret and ECDH public key).
|
||||||
|
func DecodeSubscriptionKeys(auth, p256dh string) (keys Keys, err error) {
|
||||||
|
authBytes, err := decodeSubscriptionKey(auth)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(authBytes) != 16 {
|
||||||
|
err = invalidAuthKeyLength
|
||||||
|
return
|
||||||
|
}
|
||||||
|
copy(keys.Auth[:], authBytes)
|
||||||
|
dhBytes, err := decodeSubscriptionKey(p256dh)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
keys.P256dh, err = ecdh.P256().NewPublicKey(dhBytes)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscription represents a PushSubscription object from the Push API
|
||||||
|
type Subscription struct {
|
||||||
|
Endpoint string `json:"endpoint"`
|
||||||
|
Keys Keys `json:"keys"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendNotification sends a push notification to a subscription's endpoint,
|
||||||
|
// applying encryption (RFC 8291) and adding a VAPID header (RFC 8292).
|
||||||
|
func SendNotification(ctx context.Context, message []byte, s *Subscription, options *Options) (*http.Response, error) {
|
||||||
|
// Compose message body (RFC8291 encryption of the message)
|
||||||
|
body, err := EncryptNotification(message, s.Keys, options.RecordSize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get VAPID Authorization header
|
||||||
|
vapidAuthHeader, err := getVAPIDAuthorizationHeader(
|
||||||
|
s.Endpoint,
|
||||||
|
options.Subscriber,
|
||||||
|
options.VAPIDKeys,
|
||||||
|
options.VapidExpiration,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compose and send the HTTP request
|
||||||
|
return sendNotification(ctx, s.Endpoint, options, vapidAuthHeader, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncryptNotification implements the encryption algorithm specified by RFC 8291 for web push
|
||||||
|
// (RFC 8188's aes128gcm content-encoding, with the key material derived from
|
||||||
|
// elliptic curve Diffie-Hellman over the P-256 curve).
|
||||||
|
func EncryptNotification(message []byte, keys Keys, recordSize uint32) ([]byte, error) {
|
||||||
|
// Get the record size
|
||||||
|
if recordSize == 0 {
|
||||||
|
recordSize = MaxRecordSize
|
||||||
|
} else if recordSize < 128 {
|
||||||
|
return nil, ErrRecordSizeTooSmall
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allocate buffer to hold the eventual message
|
||||||
|
// [ header block ] [ ciphertext ] [ 16 byte AEAD tag ], totaling RecordSize bytes
|
||||||
|
// the ciphertext is the encryption of: [ message ] [ \x02 ] [ 0 or more \x00 as needed ]
|
||||||
|
recordBuf := make([]byte, recordSize)
|
||||||
|
// remainingBuf tracks our current writing position in recordBuf:
|
||||||
|
remainingBuf := recordBuf
|
||||||
|
|
||||||
|
// Application server key pairs (single use)
|
||||||
|
localPrivateKey, err := ecdh.P256().GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
localPublicKey := localPrivateKey.PublicKey()
|
||||||
|
|
||||||
|
// Encryption Content-Coding Header
|
||||||
|
// +-----------+--------+-----------+---------------+
|
||||||
|
// | salt (16) | rs (4) | idlen (1) | keyid (idlen) |
|
||||||
|
// +-----------+--------+-----------+---------------+
|
||||||
|
// in our case the keyid is localPublicKey.Bytes(), so 65 bytes
|
||||||
|
// First, generate the salt
|
||||||
|
_, err = rand.Read(remainingBuf[:16])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
salt := remainingBuf[:16]
|
||||||
|
remainingBuf = remainingBuf[16:]
|
||||||
|
binary.BigEndian.PutUint32(remainingBuf[:], recordSize)
|
||||||
|
remainingBuf = remainingBuf[4:]
|
||||||
|
localPublicKeyBytes := localPublicKey.Bytes()
|
||||||
|
remainingBuf[0] = byte(len(localPublicKeyBytes))
|
||||||
|
remainingBuf = remainingBuf[1:]
|
||||||
|
copy(remainingBuf[:], localPublicKeyBytes)
|
||||||
|
remainingBuf = remainingBuf[len(localPublicKeyBytes):]
|
||||||
|
|
||||||
|
// Combine application keys with receiver's EC public key to derive ECDH shared secret
|
||||||
|
sharedECDHSecret, err := localPrivateKey.ECDH(keys.P256dh)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("deriving shared secret: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ikm
|
||||||
|
prkInfoBuf := bytes.NewBuffer([]byte("WebPush: info\x00"))
|
||||||
|
prkInfoBuf.Write(keys.P256dh.Bytes())
|
||||||
|
prkInfoBuf.Write(localPublicKey.Bytes())
|
||||||
|
|
||||||
|
prkHKDF := hkdf.New(sha256.New, sharedECDHSecret, keys.Auth[:], prkInfoBuf.Bytes())
|
||||||
|
ikm, err := getHKDFKey(prkHKDF, 32)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive Content Encryption Key
|
||||||
|
contentEncryptionKeyInfo := []byte("Content-Encoding: aes128gcm\x00")
|
||||||
|
contentHKDF := hkdf.New(sha256.New, ikm, salt, contentEncryptionKeyInfo)
|
||||||
|
contentEncryptionKey, err := getHKDFKey(contentHKDF, 16)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive the Nonce
|
||||||
|
nonceInfo := []byte("Content-Encoding: nonce\x00")
|
||||||
|
nonceHKDF := hkdf.New(sha256.New, ikm, salt, nonceInfo)
|
||||||
|
nonce, err := getHKDFKey(nonceHKDF, 12)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cipher
|
||||||
|
c, err := aes.NewCipher(contentEncryptionKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
gcm, err := cipher.NewGCM(c)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// need 1 byte for the 0x02 delimiter, 16 bytes for the AEAD tag
|
||||||
|
if len(remainingBuf) < len(message)+17 {
|
||||||
|
return nil, ErrRecordSizeTooSmall
|
||||||
|
}
|
||||||
|
// Copy the message plaintext into the buffer
|
||||||
|
copy(remainingBuf[:], message[:])
|
||||||
|
// The plaintext to be encrypted will include the padding delimiter and the padding;
|
||||||
|
// cut off the final 16 bytes that are reserved for the AEAD tag
|
||||||
|
plaintext := remainingBuf[:len(remainingBuf)-16]
|
||||||
|
remainingBuf = remainingBuf[len(message):]
|
||||||
|
// Add padding delimiter
|
||||||
|
remainingBuf[0] = '\x02'
|
||||||
|
remainingBuf = remainingBuf[1:]
|
||||||
|
// The rest of the buffer is already zero-padded
|
||||||
|
|
||||||
|
// Encipher the plaintext in place, then add the AEAD tag at the end.
|
||||||
|
// "To reuse plaintext's storage for the encrypted output, use plaintext[:0]
|
||||||
|
// as dst. Otherwise, the remaining capacity of dst must not overlap plaintext."
|
||||||
|
gcm.Seal(plaintext[:0], nonce, plaintext, nil)
|
||||||
|
|
||||||
|
return recordBuf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendNotification(ctx context.Context, endpoint string, options *Options, vapidAuthHeader string, body []byte) (*http.Response, error) {
|
||||||
|
// POST request
|
||||||
|
req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx != nil {
|
||||||
|
req = req.WithContext(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Encoding", "aes128gcm")
|
||||||
|
req.Header.Set("Content-Type", "application/octet-stream")
|
||||||
|
req.Header.Set("TTL", strconv.Itoa(options.TTL))
|
||||||
|
|
||||||
|
// Сheck the optional headers
|
||||||
|
if len(options.Topic) > 0 {
|
||||||
|
req.Header.Set("Topic", options.Topic)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isValidUrgency(options.Urgency) {
|
||||||
|
req.Header.Set("Urgency", string(options.Urgency))
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", vapidAuthHeader)
|
||||||
|
|
||||||
|
// Send the request
|
||||||
|
var client HTTPClient
|
||||||
|
if options.HTTPClient != nil {
|
||||||
|
client = options.HTTPClient
|
||||||
|
} else {
|
||||||
|
client = defaultHTTPClient
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.Do(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeSubscriptionKey decodes a base64 subscription key.
|
||||||
|
func decodeSubscriptionKey(key string) ([]byte, error) {
|
||||||
|
key = strings.TrimRight(key, "=")
|
||||||
|
|
||||||
|
if strings.IndexByte(key, '+') != -1 || strings.IndexByte(key, '/') != -1 {
|
||||||
|
return base64.RawStdEncoding.DecodeString(key)
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.DecodeString(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a key of length "length" given an hkdf function
|
||||||
|
func getHKDFKey(hkdf io.Reader, length int) ([]byte, error) {
|
||||||
|
key := make([]byte, length)
|
||||||
|
n, err := io.ReadFull(hkdf, key)
|
||||||
|
if n != len(key) || err != nil {
|
||||||
|
return key, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return key, nil
|
||||||
|
}
|
95
vendor/golang.org/x/crypto/hkdf/hkdf.go
generated
vendored
Normal file
95
vendor/golang.org/x/crypto/hkdf/hkdf.go
generated
vendored
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
// Copyright 2014 The Go Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
// Package hkdf implements the HMAC-based Extract-and-Expand Key Derivation
|
||||||
|
// Function (HKDF) as defined in RFC 5869.
|
||||||
|
//
|
||||||
|
// HKDF is a cryptographic key derivation function (KDF) with the goal of
|
||||||
|
// expanding limited input keying material into one or more cryptographically
|
||||||
|
// strong secret keys.
|
||||||
|
package hkdf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"errors"
|
||||||
|
"hash"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Extract generates a pseudorandom key for use with Expand from an input secret
|
||||||
|
// and an optional independent salt.
|
||||||
|
//
|
||||||
|
// Only use this function if you need to reuse the extracted key with multiple
|
||||||
|
// Expand invocations and different context values. Most common scenarios,
|
||||||
|
// including the generation of multiple keys, should use New instead.
|
||||||
|
func Extract(hash func() hash.Hash, secret, salt []byte) []byte {
|
||||||
|
if salt == nil {
|
||||||
|
salt = make([]byte, hash().Size())
|
||||||
|
}
|
||||||
|
extractor := hmac.New(hash, salt)
|
||||||
|
extractor.Write(secret)
|
||||||
|
return extractor.Sum(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
type hkdf struct {
|
||||||
|
expander hash.Hash
|
||||||
|
size int
|
||||||
|
|
||||||
|
info []byte
|
||||||
|
counter byte
|
||||||
|
|
||||||
|
prev []byte
|
||||||
|
buf []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *hkdf) Read(p []byte) (int, error) {
|
||||||
|
// Check whether enough data can be generated
|
||||||
|
need := len(p)
|
||||||
|
remains := len(f.buf) + int(255-f.counter+1)*f.size
|
||||||
|
if remains < need {
|
||||||
|
return 0, errors.New("hkdf: entropy limit reached")
|
||||||
|
}
|
||||||
|
// Read any leftover from the buffer
|
||||||
|
n := copy(p, f.buf)
|
||||||
|
p = p[n:]
|
||||||
|
|
||||||
|
// Fill the rest of the buffer
|
||||||
|
for len(p) > 0 {
|
||||||
|
if f.counter > 1 {
|
||||||
|
f.expander.Reset()
|
||||||
|
}
|
||||||
|
f.expander.Write(f.prev)
|
||||||
|
f.expander.Write(f.info)
|
||||||
|
f.expander.Write([]byte{f.counter})
|
||||||
|
f.prev = f.expander.Sum(f.prev[:0])
|
||||||
|
f.counter++
|
||||||
|
|
||||||
|
// Copy the new batch into p
|
||||||
|
f.buf = f.prev
|
||||||
|
n = copy(p, f.buf)
|
||||||
|
p = p[n:]
|
||||||
|
}
|
||||||
|
// Save leftovers for next run
|
||||||
|
f.buf = f.buf[n:]
|
||||||
|
|
||||||
|
return need, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand returns a Reader, from which keys can be read, using the given
|
||||||
|
// pseudorandom key and optional context info, skipping the extraction step.
|
||||||
|
//
|
||||||
|
// The pseudorandomKey should have been generated by Extract, or be a uniformly
|
||||||
|
// random or pseudorandom cryptographically strong key. See RFC 5869, Section
|
||||||
|
// 3.3. Most common scenarios will want to use New instead.
|
||||||
|
func Expand(hash func() hash.Hash, pseudorandomKey, info []byte) io.Reader {
|
||||||
|
expander := hmac.New(hash, pseudorandomKey)
|
||||||
|
return &hkdf{expander, expander.Size(), info, 1, nil, nil}
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a Reader, from which keys can be read, using the given hash,
|
||||||
|
// secret, salt and context info. Salt and info can be nil.
|
||||||
|
func New(hash func() hash.Hash, secret, salt, info []byte) io.Reader {
|
||||||
|
prk := Extract(hash, secret, salt)
|
||||||
|
return Expand(hash, prk, info)
|
||||||
|
}
|
4
vendor/modules.txt
vendored
4
vendor/modules.txt
vendored
@ -22,6 +22,9 @@ github.com/ergochat/irc-go/ircfmt
|
|||||||
github.com/ergochat/irc-go/ircmsg
|
github.com/ergochat/irc-go/ircmsg
|
||||||
github.com/ergochat/irc-go/ircreader
|
github.com/ergochat/irc-go/ircreader
|
||||||
github.com/ergochat/irc-go/ircutils
|
github.com/ergochat/irc-go/ircutils
|
||||||
|
# github.com/ergochat/webpush-go/v2 v2.0.0-rc1
|
||||||
|
## explicit; go 1.20
|
||||||
|
github.com/ergochat/webpush-go/v2
|
||||||
# github.com/go-sql-driver/mysql v1.7.0
|
# github.com/go-sql-driver/mysql v1.7.0
|
||||||
## explicit; go 1.13
|
## explicit; go 1.13
|
||||||
github.com/go-sql-driver/mysql
|
github.com/go-sql-driver/mysql
|
||||||
@ -83,6 +86,7 @@ github.com/xdg-go/scram
|
|||||||
## explicit; go 1.20
|
## explicit; go 1.20
|
||||||
golang.org/x/crypto/bcrypt
|
golang.org/x/crypto/bcrypt
|
||||||
golang.org/x/crypto/blowfish
|
golang.org/x/crypto/blowfish
|
||||||
|
golang.org/x/crypto/hkdf
|
||||||
golang.org/x/crypto/pbkdf2
|
golang.org/x/crypto/pbkdf2
|
||||||
golang.org/x/crypto/sha3
|
golang.org/x/crypto/sha3
|
||||||
# golang.org/x/sys v0.22.0
|
# golang.org/x/sys v0.22.0
|
||||||
|
Loading…
Reference in New Issue
Block a user