Add webpush extension

References: https://github.com/ircv3/ircv3-specifications/pull/471
Co-authored-by: delthas <delthas@dille.cc>
This commit is contained in:
Simon Ser 2021-11-27 11:48:10 +01:00
parent 804d685ab2
commit 3863b8cb6b
10 changed files with 783 additions and 0 deletions

View File

@ -32,6 +32,13 @@ type Database interface {
GetReadReceipt(ctx context.Context, networkID int64, name string) (*ReadReceipt, error) GetReadReceipt(ctx context.Context, networkID int64, name string) (*ReadReceipt, error)
StoreReadReceipt(ctx context.Context, networkID int64, receipt *ReadReceipt) error StoreReadReceipt(ctx context.Context, networkID int64, receipt *ReadReceipt) error
ListWebPushConfigs(ctx context.Context) ([]WebPushConfig, error)
StoreWebPushConfig(ctx context.Context, config *WebPushConfig) error
ListWebPushSubscriptions(ctx context.Context, networkID int64) ([]WebPushSubscription, error)
StoreWebPushSubscription(ctx context.Context, networkID int64, sub *WebPushSubscription) error
DeleteWebPushSubscription(ctx context.Context, id int64) error
} }
type MetricsCollectorDatabase interface { type MetricsCollectorDatabase interface {
@ -199,3 +206,20 @@ type ReadReceipt struct {
Target string // channel or nick Target string // channel or nick
Timestamp time.Time Timestamp time.Time
} }
type WebPushConfig struct {
ID int64
VAPIDKeys struct {
Public, Private string
}
}
type WebPushSubscription struct {
ID int64
Endpoint string
Keys struct {
Auth string
P256DH string
VAPID string
}
}

View File

@ -85,6 +85,26 @@ CREATE TABLE "ReadReceipt" (
timestamp TIMESTAMP WITH TIME ZONE NOT NULL, timestamp TIMESTAMP WITH TIME ZONE NOT NULL,
UNIQUE(network, target) UNIQUE(network, target)
); );
CREATE TABLE "WebPushConfig" (
id SERIAL PRIMARY KEY,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
vapid_key_public TEXT NOT NULL,
vapid_key_private TEXT NOT NULL,
UNIQUE(vapid_key_public)
);
CREATE TABLE "WebPushSubscription" (
id SERIAL PRIMARY KEY,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL,
network INTEGER REFERENCES "Network"(id) ON DELETE CASCADE,
endpoint TEXT NOT NULL,
key_vapid TEXT,
key_auth TEXT,
key_p256dh TEXT,
UNIQUE(network, endpoint)
);
` `
var postgresMigrations = []string{ var postgresMigrations = []string{
@ -106,6 +126,27 @@ var postgresMigrations = []string{
UNIQUE(network, target) UNIQUE(network, target)
); );
`, `,
`
CREATE TABLE "WebPushConfig" (
id SERIAL PRIMARY KEY,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
vapid_key_public TEXT NOT NULL,
vapid_key_private TEXT NOT NULL,
UNIQUE(vapid_key_public)
);
CREATE TABLE "WebPushSubscription" (
id SERIAL PRIMARY KEY,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL,
network INTEGER REFERENCES "Network"(id) ON DELETE CASCADE,
endpoint TEXT NOT NULL,
key_vapid TEXT,
key_auth TEXT,
key_p256dh TEXT,
UNIQUE(network, endpoint)
);
`,
} }
type PostgresDB struct { type PostgresDB struct {
@ -623,6 +664,114 @@ func (db *PostgresDB) listTopNetworkAddrs(ctx context.Context) (map[string]int,
return addrs, rows.Err() return addrs, rows.Err()
} }
func (db *PostgresDB) ListWebPushConfigs(ctx context.Context) ([]WebPushConfig, error) {
ctx, cancel := context.WithTimeout(ctx, postgresQueryTimeout)
defer cancel()
rows, err := db.db.QueryContext(ctx, `
SELECT id, vapid_key_public, vapid_key_private
FROM "WebPushConfig"`)
if err != nil {
return nil, err
}
defer rows.Close()
var configs []WebPushConfig
for rows.Next() {
var config WebPushConfig
if err := rows.Scan(&config.ID, &config.VAPIDKeys.Public, &config.VAPIDKeys.Private); err != nil {
return nil, err
}
configs = append(configs, config)
}
return configs, rows.Err()
}
func (db *PostgresDB) StoreWebPushConfig(ctx context.Context, config *WebPushConfig) error {
ctx, cancel := context.WithTimeout(ctx, postgresQueryTimeout)
defer cancel()
if config.ID != 0 {
return fmt.Errorf("cannot update a WebPushConfig")
}
err := db.db.QueryRowContext(ctx, `
INSERT INTO "WebPushConfig" (created_at, vapid_key_public, vapid_key_private)
VALUES (NOW(), $1, $2)
RETURNING id`,
config.VAPIDKeys.Public, config.VAPIDKeys.Private).Scan(&config.ID)
return err
}
func (db *PostgresDB) ListWebPushSubscriptions(ctx context.Context, networkID int64) ([]WebPushSubscription, error) {
ctx, cancel := context.WithTimeout(ctx, postgresQueryTimeout)
defer cancel()
nullNetworkID := sql.NullInt64{
Int64: networkID,
Valid: networkID == 0,
}
rows, err := db.db.QueryContext(ctx, `
SELECT id, endpoint, key_auth, key_p256dh, key_vapid
FROM "WebPushSubscription"
WHERE network IS NOT DISTINCT FROM $1`, nullNetworkID)
if err != nil {
return nil, err
}
defer rows.Close()
var subs []WebPushSubscription
for rows.Next() {
var sub WebPushSubscription
if err := rows.Scan(&sub.ID, &sub.Endpoint, &sub.Keys.Auth, &sub.Keys.P256DH, &sub.Keys.VAPID); err != nil {
return nil, err
}
subs = append(subs, sub)
}
return subs, rows.Err()
}
func (db *PostgresDB) StoreWebPushSubscription(ctx context.Context, networkID int64, sub *WebPushSubscription) error {
ctx, cancel := context.WithTimeout(ctx, postgresQueryTimeout)
defer cancel()
nullNetworkID := sql.NullInt64{
Int64: networkID,
Valid: networkID == 0,
}
var err error
if sub.ID != 0 {
_, err = db.db.ExecContext(ctx, `
UPDATE "WebPushSubscription"
SET updated_at = NOW(), key_auth = $1, key_p256dh = $2,
key_vapid = $3
WHERE id = $4`,
sub.Keys.Auth, sub.Keys.P256DH, sub.Keys.VAPID, sub.ID)
} else {
err = db.db.QueryRowContext(ctx, `
INSERT INTO "WebPushSubscription" (created_at, updated_at, network,
endpoint, key_auth, key_p256dh, key_vapid)
VALUES (NOW(), NOW(), $1, $2, $3, $4, $5)
RETURNING id`,
nullNetworkID, sub.Endpoint, sub.Keys.Auth, sub.Keys.P256DH,
sub.Keys.VAPID).Scan(&sub.ID)
}
return err
}
func (db *PostgresDB) DeleteWebPushSubscription(ctx context.Context, id int64) error {
ctx, cancel := context.WithTimeout(ctx, postgresQueryTimeout)
defer cancel()
_, err := db.db.ExecContext(ctx, `DELETE FROM "WebPushSubscription" WHERE id = $1`, id)
return err
}
var postgresNetworksTotalDesc = prometheus.NewDesc("soju_networks_total", "Number of networks", []string{"hostname"}, nil) var postgresNetworksTotalDesc = prometheus.NewDesc("soju_networks_total", "Number of networks", []string{"hostname"}, nil)
type postgresMetricsCollector struct { type postgresMetricsCollector struct {

View File

@ -84,6 +84,27 @@ CREATE TABLE ReadReceipt (
FOREIGN KEY(network) REFERENCES Network(id), FOREIGN KEY(network) REFERENCES Network(id),
UNIQUE(network, target) UNIQUE(network, target)
); );
CREATE TABLE WebPushConfig (
id INTEGER PRIMARY KEY,
created_at TEXT NOT NULL,
vapid_key_public TEXT NOT NULL,
vapid_key_private TEXT NOT NULL,
UNIQUE(vapid_key_public)
);
CREATE TABLE WebPushSubscription (
id INTEGER PRIMARY KEY,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
network INTEGER,
endpoint TEXT NOT NULL,
key_vapid TEXT,
key_auth TEXT,
key_p256dh TEXT,
FOREIGN KEY(network) REFERENCES Network(id),
UNIQUE(network, endpoint)
);
` `
var sqliteMigrations = []string{ var sqliteMigrations = []string{
@ -194,6 +215,28 @@ var sqliteMigrations = []string{
UNIQUE(network, target) UNIQUE(network, target)
); );
`, `,
`
CREATE TABLE WebPushConfig (
id INTEGER PRIMARY KEY,
created_at TEXT NOT NULL,
vapid_key_public TEXT NOT NULL,
vapid_key_private TEXT NOT NULL,
UNIQUE(vapid_key_public)
);
CREATE TABLE WebPushSubscription (
id INTEGER PRIMARY KEY,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
network INTEGER,
endpoint TEXT NOT NULL,
key_vapid TEXT,
key_auth TEXT,
key_p256dh TEXT,
FOREIGN KEY(network) REFERENCES Network(id),
UNIQUE(network, endpoint)
);
`,
} }
type SqliteDB struct { type SqliteDB struct {
@ -555,6 +598,11 @@ func (db *SqliteDB) DeleteNetwork(ctx context.Context, id int64) error {
} }
defer tx.Rollback() defer tx.Rollback()
_, err = tx.ExecContext(ctx, "DELETE FROM WebPushSubscription WHERE network = ?", id)
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, "DELETE FROM DeliveryReceipt WHERE network = ?", id) _, err = tx.ExecContext(ctx, "DELETE FROM DeliveryReceipt WHERE network = ?", id)
if err != nil { if err != nil {
return err return err
@ -784,3 +832,129 @@ func (db *SqliteDB) StoreReadReceipt(ctx context.Context, networkID int64, recei
return err return err
} }
func (db *SqliteDB) ListWebPushConfigs(ctx context.Context) ([]WebPushConfig, error) {
ctx, cancel := context.WithTimeout(ctx, sqliteQueryTimeout)
defer cancel()
rows, err := db.db.QueryContext(ctx, `
SELECT id, vapid_key_public, vapid_key_private
FROM WebPushConfig`)
if err != nil {
return nil, err
}
defer rows.Close()
var configs []WebPushConfig
for rows.Next() {
var config WebPushConfig
if err := rows.Scan(&config.ID, &config.VAPIDKeys.Public, &config.VAPIDKeys.Private); err != nil {
return nil, err
}
configs = append(configs, config)
}
return configs, rows.Err()
}
func (db *SqliteDB) StoreWebPushConfig(ctx context.Context, config *WebPushConfig) error {
ctx, cancel := context.WithTimeout(ctx, sqliteQueryTimeout)
defer cancel()
if config.ID != 0 {
return fmt.Errorf("cannot update a WebPushConfig")
}
res, err := db.db.ExecContext(ctx, `
INSERT INTO WebPushConfig(created_at, vapid_key_public, vapid_key_private)
VALUES (:now, :vapid_key_public, :vapid_key_private)`,
sql.Named("vapid_key_public", config.VAPIDKeys.Public),
sql.Named("vapid_key_private", config.VAPIDKeys.Private),
sql.Named("now", formatSqliteTime(time.Now())))
if err != nil {
return err
}
config.ID, err = res.LastInsertId()
return err
}
func (db *SqliteDB) ListWebPushSubscriptions(ctx context.Context, networkID int64) ([]WebPushSubscription, error) {
ctx, cancel := context.WithTimeout(ctx, sqliteQueryTimeout)
defer cancel()
nullNetworkID := sql.NullInt64{
Int64: networkID,
Valid: networkID != 0,
}
rows, err := db.db.QueryContext(ctx, `
SELECT id, endpoint, key_auth, key_p256dh, key_vapid
FROM WebPushSubscription
WHERE network IS ?`, nullNetworkID)
if err != nil {
return nil, err
}
defer rows.Close()
var subs []WebPushSubscription
for rows.Next() {
var sub WebPushSubscription
if err := rows.Scan(&sub.ID, &sub.Endpoint, &sub.Keys.Auth, &sub.Keys.P256DH, &sub.Keys.VAPID); err != nil {
return nil, err
}
subs = append(subs, sub)
}
return subs, rows.Err()
}
func (db *SqliteDB) StoreWebPushSubscription(ctx context.Context, networkID int64, sub *WebPushSubscription) error {
ctx, cancel := context.WithTimeout(ctx, sqliteQueryTimeout)
defer cancel()
args := []interface{}{
sql.Named("id", sub.ID),
sql.Named("network", sql.NullInt64{
Int64: networkID,
Valid: networkID != 0,
}),
sql.Named("now", formatSqliteTime(time.Now())),
sql.Named("endpoint", sub.Endpoint),
sql.Named("key_auth", sub.Keys.Auth),
sql.Named("key_p256dh", sub.Keys.P256DH),
sql.Named("key_vapid", sub.Keys.VAPID),
}
var err error
if sub.ID != 0 {
_, err = db.db.ExecContext(ctx, `
UPDATE WebPushSubscription
SET updated_at = :now, key_auth = :key_auth, key_p256dh = :key_p256dh,
key_vapid = :key_vapid
WHERE id = :id`,
args...)
} else {
var res sql.Result
res, err = db.db.ExecContext(ctx, `
INSERT INTO
WebPushSubscription(created_at, updated_at, network, endpoint,
key_auth, key_p256dh, key_vapid)
VALUES (:now, :now, :network, :endpoint, :key_auth, :key_p256dh,
:key_vapid)`,
args...)
if err != nil {
return err
}
sub.ID, err = res.LastInsertId()
}
return err
}
func (db *SqliteDB) DeleteWebPushSubscription(ctx context.Context, id int64) error {
ctx, cancel := context.WithTimeout(ctx, sqliteQueryTimeout)
defer cancel()
_, err := db.db.ExecContext(ctx, "DELETE FROM WebPushSubscription WHERE id = ?", id)
return err
}

125
doc/ext/webpush.md Normal file
View File

@ -0,0 +1,125 @@
---
title: "Web Push Extension"
layout: spec
copyrights:
- name: "Simon Ser"
period: "2021"
email: "contact@emersion.fr"
---
## Notes for implementing experimental vendor extension
This is an experimental specification for a vendored extension.
No guarantees are made regarding the stability of this extension. Backwards-incompatible changes can be made at any time without prior notice.
Software implementing this work-in-progress specification MUST NOT use the unprefixed `webpush` CAP name. Instead, implementations SHOULD use the `soju.im/webpush` CAP name to be interoperable with other software implementing a compatible work-in-progress version.
## Description
Historically, IRC clients have relied on keeping a TCP connection alive to receive notifications about new events. However, this design has limitations:
- It doesn't bode well with some platforms such as Android, iOS or the Web. On these platforms, the connection to the IRC server can be severed (e.g. when the IRC client isn't in the foreground), resulting in IRC events not received.
- Battery-powered devices aim to avoid any unnecessary wake-up of the modem hardware. IRC connections don't make the difference between messages which may be important to the user (e.g. messages targeting the user directly) and the rest of the messages. As a result messages are frequently sent over the IRC connection, resulting in battery drain.
To address these limitations, various push notification mechanisms have been designed. This specification standardizes an extension for Web Push.
```
┌────────────┐ ┌────────────┐
│ │ Subscribe │ │
│ ├─────────────►│ │
│ IRC client │ │ IRC server │
│ │ │ │
│ │ │ │
└────────────┘ └─────┬──────┘
▲ │
│ │
Push │ │Push
notification │ │notification
│ ┌──────────┐ │
│ │ │ │
└───────┤ Web Push │◄──────┘
│ Server │
│ │
└──────────┘
```
Web Push is defined in [RFC 8030], [RFC 8291] and [RFC 8292].
Although Web Push has been designed for the Web, it can be used on other platforms as well. Web Push provides a vendor-neutral standard to send push notifications.
## Implementation
The `soju.im/webpush` capability allows clients to subscribe to Web Push and receive notifications for messages of interest.
Once a client has subscribed, the server will send push notifications for a server-defined subset of IRC messages. Each push notification MUST contain exactly one IRC message as the payload, without the final CRLF.
The messages follow the same capabilities and the same `RPL_ISUPPORT` as when the client registered for Web Push notifications.
Because of size limits on the payload of push notifications, servers MAY drop some or all message tags from the original message. Servers MUST NOT drop the `msgid` tag if present.
## `VAPID` ISUPPORT token
If the server supports [Voluntary Application Server Identification (VAPID)][RFC 8292] and the client has enabled the `soju.im/webpush` capability, the server MUST advertise its public key in the `VAPID` ISUPPORT token. This key can be used to verify notifications upon reception by the Web Push server.
The value MUST be the [URL-safe base64-encoded][RFC 4648 section 5] public key usable with the Elliptic Curve Digital Signature Algorithm (ECDSA) over the P-256 curve. The value MUST NOT change over the lifetime of the connection to avoid race conditions.
## `WEBPUSH` Command
A new `WEBPUSH` command is introduced. It has a case-insensitive subcommand:
WEBPUSH <subcommand> <params...>
### `REGISTER` Subcommand
The `REGISTER` subcommand creates a new Web Push subscription.
WEBPUSH REGISTER <endpoint> <keys>
The `<endpoint>` is an URL pointing to a push server, which can be used to send push messages for this particular subscription.
`<keys>` is a string encoded in the message-tag format. The values are [URL-safe base64-encoded][RFC 4648 section 5]. For the `aes128gcm` encryption algorithm, it MUST contain at least:
- 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.
If the server has advertised the `VAPID` ISUPPORT token, they MUST use this VAPID public key when sending push notifications. Servers MUST replace any previous subscription with the same `<endpoint>`.
If the registration is successful, the server MUST reply with a `WEBPUSH REGISTER` message:
WEBPUSH REGISTER <endpoint>
On error, the server MUST reply with a `FAIL` message.
Servers MAY expire a subscription at any time.
### `UNREGISTER` Subcommand
The `UNREGISTER` subcommand removes an existing Web Push subscription.
WEBPUSH UNREGISTER <endpoint>
Servers MUST silently ignore `UNREGISTER` commands for non-existing subscriptions.
If the unregistration is successful, the server MUST echo back the `WEBPUSH UNREGISTER` message. On error, the server MUST reply with a `FAIL` message.
### Errors
Errors are returned using the standard replies syntax.
If the server receives a syntactically invalid `WEBPUSH` command, e.g., an unknown subcommand, missing parameters, excess parameters, or parameters that cannot be parsed, the `INVALID_PARAMS` error code SHOULD be returned:
```
FAIL WEBPUSH INVALID_PARAMS <command> <endpoint> <message>
```
If the server cannot fullfill a client command due to an internal error, the `INTERNAL_ERROR` error code SHOULD be returned:
```
FAIL WEBPUSH INTERNAL_ERROR <command> <endpoint> <message>
```
[RFC 8030]: https://datatracker.ietf.org/doc/html/rfc8030
[RFC 8291]: https://datatracker.ietf.org/doc/html/rfc8291
[RFC 8292]: https://datatracker.ietf.org/doc/html/rfc8292
[RFC 4648 section 5]: https://www.rfc-editor.org/rfc/rfc4648.html#section-5

View File

@ -9,10 +9,12 @@ import (
"fmt" "fmt"
"io" "io"
"net" "net"
"net/http"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/SherClockHolmes/webpush-go"
"github.com/emersion/go-sasl" "github.com/emersion/go-sasl"
"gopkg.in/irc.v3" "gopkg.in/irc.v3"
@ -240,6 +242,7 @@ var permanentDownstreamCaps = map[string]string{
"soju.im/no-implicit-names": "", "soju.im/no-implicit-names": "",
"soju.im/read": "", "soju.im/read": "",
"soju.im/account-required": "", "soju.im/account-required": "",
"soju.im/webpush": "",
} }
// needAllDownstreamCaps is the list of downstream capabilities that // needAllDownstreamCaps is the list of downstream capabilities that
@ -1501,6 +1504,9 @@ func (dc *downstreamConn) welcome(ctx context.Context) error {
if dc.network == nil && !dc.isMultiUpstream { if dc.network == nil && !dc.isMultiUpstream {
isupport = append(isupport, "WHOX") isupport = append(isupport, "WHOX")
} }
if dc.caps.IsEnabled("soju.im/webpush") {
isupport = append(isupport, "VAPID="+dc.srv.webPush.VAPIDKeys.Public)
}
if uc := dc.upstream(); uc != nil { if uc := dc.upstream(); uc != nil {
for k := range passthroughIsupport { for k := range passthroughIsupport {
@ -3199,6 +3205,149 @@ func (dc *downstreamConn) handleMessageRegistered(ctx context.Context, msg *irc.
Params: []string{"BOUNCER", "UNKNOWN_COMMAND", subcommand, "Unknown subcommand"}, Params: []string{"BOUNCER", "UNKNOWN_COMMAND", subcommand, "Unknown subcommand"},
}} }}
} }
case "WEBPUSH":
if !dc.caps.IsEnabled("soju.im/webpush") {
return newUnknownCommandError(msg.Command)
}
var subcommand string
if err := parseMessageParams(msg, &subcommand); err != nil {
return err
}
switch subcommand {
case "REGISTER":
var endpoint, keysStr string
if err := parseMessageParams(msg, nil, &endpoint, &keysStr); err != nil {
return err
}
if err := checkWebPushEndpoint(ctx, endpoint); err != nil {
dc.logger.Printf("failed to check Web push endpoint %q: %v", endpoint, err)
return ircError{&irc.Message{
Command: "FAIL",
Params: []string{"WEBPUSH", "INVALID_PARAMS", subcommand, "Invalid endpoint"},
}}
}
rawKeys := irc.ParseTags(keysStr)
authKey, hasAuthKey := rawKeys["auth"]
p256dhKey, hasP256dh := rawKeys["p256dh"]
if !hasAuthKey || !hasP256dh {
return ircError{&irc.Message{
Command: "FAIL",
Params: []string{"WEBPUSH", "INVALID_PARAMS", subcommand, "Missing auth or p256dh key"},
}}
}
newSub := database.WebPushSubscription{
Endpoint: endpoint,
}
newSub.Keys.VAPID = dc.srv.webPush.VAPIDKeys.Public
newSub.Keys.Auth = string(authKey)
newSub.Keys.P256DH = string(p256dhKey)
oldSub, err := dc.findWebPushSubscription(ctx, endpoint)
if err != nil {
dc.logger.Printf("failed to fetch Web push subscription: %v", err)
return ircError{&irc.Message{
Command: "FAIL",
Params: []string{"WEBPUSH", "INTERNAL_ERROR", subcommand, "Internal error"},
}}
}
if oldSub != nil {
if oldSub.Keys.VAPID == newSub.Keys.VAPID && oldSub.Keys.Auth == newSub.Keys.Auth && oldSub.Keys.P256DH == newSub.Keys.P256DH {
// Nothing has changed, this is a no-op
dc.SendMessage(&irc.Message{
Prefix: dc.srv.prefix(),
Command: "WEBPUSH",
Params: []string{"REGISTER", endpoint},
})
return nil
}
// Update the old subscription instead of creating a new one
newSub.ID = oldSub.ID
}
var networkID int64
if dc.network != nil {
networkID = dc.network.ID
}
// TODO: limit max number of subscriptions, prune old ones
if err := dc.user.srv.db.StoreWebPushSubscription(ctx, networkID, &newSub); err != nil {
dc.logger.Printf("failed to store Web push subscription: %v", err)
return ircError{&irc.Message{
Command: "FAIL",
Params: []string{"WEBPUSH", "INTERNAL_ERROR", subcommand, "Internal error"},
}}
}
err = dc.srv.sendWebPush(ctx, &webpush.Subscription{
Endpoint: newSub.Endpoint,
Keys: webpush.Keys{
Auth: newSub.Keys.Auth,
P256dh: newSub.Keys.P256DH,
},
}, newSub.Keys.VAPID, &irc.Message{
Command: "NOTE",
Params: []string{"WEBPUSH", "REGISTERED", "Push notifications enabled"},
})
if err != nil {
dc.logger.Printf("failed to send Web push notification to endpoint %q: %v", newSub.Endpoint, err)
}
dc.SendMessage(&irc.Message{
Prefix: dc.srv.prefix(),
Command: "WEBPUSH",
Params: []string{"REGISTER", endpoint},
})
case "UNREGISTER":
var endpoint string
if err := parseMessageParams(msg, nil, &endpoint); err != nil {
return err
}
oldSub, err := dc.findWebPushSubscription(ctx, endpoint)
if err != nil {
dc.logger.Printf("failed to fetch Web push subscription: %v", err)
return ircError{&irc.Message{
Command: "FAIL",
Params: []string{"WEBPUSH", "INTERNAL_ERROR", subcommand, "Internal error"},
}}
}
if oldSub == nil {
dc.SendMessage(&irc.Message{
Prefix: dc.srv.prefix(),
Command: "WEBPUSH",
Params: []string{"UNREGISTER", endpoint},
})
return nil
}
if err := dc.srv.db.DeleteWebPushSubscription(ctx, oldSub.ID); err != nil {
dc.logger.Printf("failed to delete Web push subscription: %v", err)
return ircError{&irc.Message{
Command: "FAIL",
Params: []string{"WEBPUSH", "INTERNAL_ERROR", subcommand, "Internal error"},
}}
}
dc.SendMessage(&irc.Message{
Prefix: dc.srv.prefix(),
Command: "WEBPUSH",
Params: []string{"UNREGISTER", endpoint},
})
default:
return ircError{&irc.Message{
Command: "FAIL",
Params: []string{"WEBPUSH", "INVALID_PARAMS", subcommand, "Unknown command"},
}}
}
default: default:
dc.logger.Printf("unhandled message: %v", msg) dc.logger.Printf("unhandled message: %v", msg)
@ -3227,6 +3376,20 @@ func (dc *downstreamConn) handleNickServPRIVMSG(ctx context.Context, uc *upstrea
} }
} }
func (dc *downstreamConn) findWebPushSubscription(ctx context.Context, endpoint string) (*database.WebPushSubscription, error) {
subs, err := dc.user.srv.db.ListWebPushSubscriptions(ctx, dc.network.ID)
if err != nil {
return nil, err
}
for i, sub := range subs {
if sub.Endpoint == endpoint {
return &subs[i], nil
}
}
return nil, nil
}
func parseNickServCredentials(text, nick string) (username, password string, ok bool) { func parseNickServCredentials(text, nick string) (username, password string, ok bool) {
fields := strings.Fields(text) fields := strings.Fields(text)
if len(fields) < 2 { if len(fields) < 2 {
@ -3331,3 +3494,35 @@ func sendNames(dc *downstreamConn, ch *upstreamChannel) {
dc.SendMessage(msg) dc.SendMessage(msg)
} }
} }
func checkWebPushEndpoint(ctx context.Context, endpoint string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodOptions, endpoint, nil)
if err != nil {
return fmt.Errorf("failed to create HTTP request: %v", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("HTTP request failed: %v", err)
}
resp.Body.Close()
if resp.StatusCode/100 != 2 {
return fmt.Errorf("HTTP request failed: %v", resp.Status)
}
allow := strings.Split(resp.Header.Get("Allow"), ",")
found := false
for _, method := range allow {
if strings.EqualFold(strings.TrimSpace(method), http.MethodPost) {
found = true
break
}
}
if !found {
return fmt.Errorf("POST missing from Allow header in OPTIONS response")
}
return nil
}

1
go.mod
View File

@ -5,6 +5,7 @@ go 1.15
require ( require (
git.sr.ht/~emersion/go-scfg v0.0.0-20211215104734-c2c7a15d6c99 git.sr.ht/~emersion/go-scfg v0.0.0-20211215104734-c2c7a15d6c99
git.sr.ht/~sircmpwn/go-bare v0.0.0-20210406120253-ab86bc2846d9 git.sr.ht/~sircmpwn/go-bare v0.0.0-20210406120253-ab86bc2846d9
github.com/SherClockHolmes/webpush-go v1.2.0
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac
github.com/klauspost/compress v1.15.6 // indirect github.com/klauspost/compress v1.15.6 // indirect
github.com/lib/pq v1.10.6 github.com/lib/pq v1.10.6

5
go.sum
View File

@ -38,6 +38,8 @@ git.sr.ht/~sircmpwn/go-bare v0.0.0-20210406120253-ab86bc2846d9 h1:Ahny8Ud1LjVMMA
git.sr.ht/~sircmpwn/go-bare v0.0.0-20210406120253-ab86bc2846d9/go.mod h1:BVJwbDfVjCjoFiKrhkei6NdGcZYpkDkdyCdg1ukytRA= git.sr.ht/~sircmpwn/go-bare v0.0.0-20210406120253-ab86bc2846d9/go.mod h1:BVJwbDfVjCjoFiKrhkei6NdGcZYpkDkdyCdg1ukytRA=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/SherClockHolmes/webpush-go v1.2.0 h1:sGv0/ZWCvb1HUH+izLqrb2i68HuqD/0Y+AmGQfyqKJA=
github.com/SherClockHolmes/webpush-go v1.2.0/go.mod h1:w6X47YApe/B9wUz2Wh8xukxlyupaxSSEbu6yKJcHN2w=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
@ -96,6 +98,8 @@ github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6Wezm
github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo= github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo=
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@ -253,6 +257,7 @@ go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190131182504-b8fe1690c613/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=

View File

@ -14,6 +14,7 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"github.com/SherClockHolmes/webpush-go"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto" "github.com/prometheus/client_golang/prometheus/promauto"
"gopkg.in/irc.v3" "gopkg.in/irc.v3"
@ -38,6 +39,8 @@ var downstreamRegisterTimeout = 30 * time.Second
var chatHistoryLimit = 1000 var chatHistoryLimit = 1000
var backlogLimit = 4000 var backlogLimit = 4000
var errWebPushSubscriptionExpired = fmt.Errorf("Web Push subscription expired")
type Logger interface { type Logger interface {
Printf(format string, v ...interface{}) Printf(format string, v ...interface{})
Debugf(format string, v ...interface{}) Debugf(format string, v ...interface{})
@ -165,6 +168,8 @@ type Server struct {
upstreamConnectErrorsTotal prometheus.Counter upstreamConnectErrorsTotal prometheus.Counter
} }
webPush *database.WebPushConfig
} }
func NewServer(db database.Database) *Server { func NewServer(db database.Database) *Server {
@ -197,6 +202,10 @@ func (s *Server) SetConfig(cfg *Config) {
func (s *Server) Start() error { func (s *Server) Start() error {
s.registerMetrics() s.registerMetrics()
if err := s.loadWebPushConfig(context.TODO()); err != nil {
return err
}
users, err := s.db.ListUsers(context.TODO()) users, err := s.db.ListUsers(context.TODO())
if err != nil { if err != nil {
return err return err
@ -260,6 +269,70 @@ func (s *Server) registerMetrics() {
}) })
} }
func (s *Server) loadWebPushConfig(ctx context.Context) error {
configs, err := s.db.ListWebPushConfigs(ctx)
if err != nil {
return fmt.Errorf("failed to list Web push configs: %v", err)
}
if len(configs) > 1 {
return fmt.Errorf("expected zero or one Web push config, got %v", len(configs))
} else if len(configs) == 1 {
s.webPush = &configs[0]
return nil
}
s.Logger.Printf("generating Web push VAPID key pair")
priv, pub, err := webpush.GenerateVAPIDKeys()
if err != nil {
return fmt.Errorf("failed to generate Web push VAPID key pair: %v", err)
}
config := new(database.WebPushConfig)
config.VAPIDKeys.Public = pub
config.VAPIDKeys.Private = priv
if err := s.db.StoreWebPushConfig(ctx, config); err != nil {
return fmt.Errorf("failed to store Web push config: %v", err)
}
s.webPush = config
return nil
}
func (s *Server) sendWebPush(ctx context.Context, sub *webpush.Subscription, vapidPubKey string, msg *irc.Message) error {
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
options := webpush.Options{
VAPIDPublicKey: s.webPush.VAPIDKeys.Public,
VAPIDPrivateKey: s.webPush.VAPIDKeys.Private,
Subscriber: "https://soju.im",
TTL: 7 * 24 * 60 * 60, // seconds
Urgency: webpush.UrgencyHigh,
RecordSize: 2048,
}
if vapidPubKey != options.VAPIDPublicKey {
return fmt.Errorf("unknown VAPID public key %q", vapidPubKey)
}
payload := []byte(msg.String())
resp, err := webpush.SendNotificationWithContext(ctx, payload, sub, &options)
if err != nil {
return err
}
resp.Body.Close()
// 404 means the subscription has expired as per RFC 8030 section 7.3
if resp.StatusCode == http.StatusNotFound {
return errWebPushSubscriptionExpired
} else if resp.StatusCode/100 != 2 {
return fmt.Errorf("HTTP error: %v", resp.Status)
}
return nil
}
func (s *Server) Shutdown() { func (s *Server) Shutdown() {
s.lock.Lock() s.lock.Lock()
for ln := range s.listeners { for ln := range s.listeners {

View File

@ -523,6 +523,12 @@ func (uc *upstreamConn) handleMessage(ctx context.Context, msg *irc.Message) err
if ch.DetachOn == database.FilterMessage || ch.DetachOn == database.FilterDefault || (ch.DetachOn == database.FilterHighlight && highlight) { if ch.DetachOn == database.FilterMessage || ch.DetachOn == database.FilterDefault || (ch.DetachOn == database.FilterHighlight && highlight) {
uc.updateChannelAutoDetach(target) uc.updateChannelAutoDetach(target)
} }
if highlight {
uc.network.broadcastWebPush(ctx, msg)
}
}
if ch == nil && uc.isOurNick(entity) {
uc.network.broadcastWebPush(ctx, msg)
} }
uc.produce(target, msg, downstreamID) uc.produce(target, msg, downstreamID)
@ -1514,6 +1520,10 @@ func (uc *upstreamConn) handleMessage(ctx context.Context, msg *irc.Message) err
Params: []string{dc.marshalEntity(uc.network, nick), dc.marshalEntity(uc.network, channel)}, Params: []string{dc.marshalEntity(uc.network, nick), dc.marshalEntity(uc.network, channel)},
}) })
}) })
if weAreInvited {
uc.network.broadcastWebPush(ctx, msg)
}
case irc.RPL_INVITING: case irc.RPL_INVITING:
var nick, channel string var nick, channel string
if err := parseMessageParams(msg, nil, &nick, &channel); err != nil { if err := parseMessageParams(msg, nil, &nick, &channel); err != nil {

27
user.go
View File

@ -13,6 +13,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/SherClockHolmes/webpush-go"
"gopkg.in/irc.v3" "gopkg.in/irc.v3"
"git.sr.ht/~emersion/soju/database" "git.sr.ht/~emersion/soju/database"
@ -443,6 +444,32 @@ func (net *network) autoSaveSASLPlain(ctx context.Context, username, password st
} }
} }
func (net *network) broadcastWebPush(ctx context.Context, msg *irc.Message) {
subs, err := net.user.srv.db.ListWebPushSubscriptions(ctx, net.ID)
if err != nil {
net.logger.Printf("failed to list Web push subscriptions: %v", err)
return
}
for _, sub := range subs {
err := net.user.srv.sendWebPush(ctx, &webpush.Subscription{
Endpoint: sub.Endpoint,
Keys: webpush.Keys{
Auth: sub.Keys.Auth,
P256dh: sub.Keys.P256DH,
},
}, sub.Keys.VAPID, msg)
if err != nil {
net.logger.Printf("failed to send Web push notification to endpoint %q: %v", sub.Endpoint, err)
}
if err == errWebPushSubscriptionExpired {
if err := net.user.srv.db.DeleteWebPushSubscription(ctx, sub.ID); err != nil {
net.logger.Printf("failed to delete expired Web Push subscription: %v", err)
}
}
}
}
type user struct { type user struct {
database.User database.User
srv *Server srv *Server