Add webpush extension
References: https://github.com/ircv3/ircv3-specifications/pull/471 Co-authored-by: delthas <delthas@dille.cc>
This commit is contained in:
parent
804d685ab2
commit
3863b8cb6b
@ -32,6 +32,13 @@ type Database interface {
|
||||
|
||||
GetReadReceipt(ctx context.Context, networkID int64, name string) (*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 {
|
||||
@ -199,3 +206,20 @@ type ReadReceipt struct {
|
||||
Target string // channel or nick
|
||||
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
|
||||
}
|
||||
}
|
||||
|
@ -85,6 +85,26 @@ CREATE TABLE "ReadReceipt" (
|
||||
timestamp TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
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{
|
||||
@ -106,6 +126,27 @@ var postgresMigrations = []string{
|
||||
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 {
|
||||
@ -623,6 +664,114 @@ func (db *PostgresDB) listTopNetworkAddrs(ctx context.Context) (map[string]int,
|
||||
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)
|
||||
|
||||
type postgresMetricsCollector struct {
|
||||
|
@ -84,6 +84,27 @@ CREATE TABLE ReadReceipt (
|
||||
FOREIGN KEY(network) REFERENCES Network(id),
|
||||
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{
|
||||
@ -194,6 +215,28 @@ var sqliteMigrations = []string{
|
||||
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 {
|
||||
@ -555,6 +598,11 @@ func (db *SqliteDB) DeleteNetwork(ctx context.Context, id int64) error {
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -784,3 +832,129 @@ func (db *SqliteDB) StoreReadReceipt(ctx context.Context, networkID int64, recei
|
||||
|
||||
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
125
doc/ext/webpush.md
Normal 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
|
195
downstream.go
195
downstream.go
@ -9,10 +9,12 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SherClockHolmes/webpush-go"
|
||||
"github.com/emersion/go-sasl"
|
||||
"gopkg.in/irc.v3"
|
||||
|
||||
@ -240,6 +242,7 @@ var permanentDownstreamCaps = map[string]string{
|
||||
"soju.im/no-implicit-names": "",
|
||||
"soju.im/read": "",
|
||||
"soju.im/account-required": "",
|
||||
"soju.im/webpush": "",
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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 {
|
||||
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"},
|
||||
}}
|
||||
}
|
||||
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:
|
||||
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) {
|
||||
fields := strings.Fields(text)
|
||||
if len(fields) < 2 {
|
||||
@ -3331,3 +3494,35 @@ func sendNames(dc *downstreamConn, ch *upstreamChannel) {
|
||||
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
1
go.mod
@ -5,6 +5,7 @@ go 1.15
|
||||
require (
|
||||
git.sr.ht/~emersion/go-scfg v0.0.0-20211215104734-c2c7a15d6c99
|
||||
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/klauspost/compress v1.15.6 // indirect
|
||||
github.com/lib/pq v1.10.6
|
||||
|
5
go.sum
5
go.sum
@ -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=
|
||||
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/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-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
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/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
|
||||
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/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=
|
||||
@ -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.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-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-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
|
73
server.go
73
server.go
@ -14,6 +14,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/SherClockHolmes/webpush-go"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"gopkg.in/irc.v3"
|
||||
@ -38,6 +39,8 @@ var downstreamRegisterTimeout = 30 * time.Second
|
||||
var chatHistoryLimit = 1000
|
||||
var backlogLimit = 4000
|
||||
|
||||
var errWebPushSubscriptionExpired = fmt.Errorf("Web Push subscription expired")
|
||||
|
||||
type Logger interface {
|
||||
Printf(format string, v ...interface{})
|
||||
Debugf(format string, v ...interface{})
|
||||
@ -165,6 +168,8 @@ type Server struct {
|
||||
|
||||
upstreamConnectErrorsTotal prometheus.Counter
|
||||
}
|
||||
|
||||
webPush *database.WebPushConfig
|
||||
}
|
||||
|
||||
func NewServer(db database.Database) *Server {
|
||||
@ -197,6 +202,10 @@ func (s *Server) SetConfig(cfg *Config) {
|
||||
func (s *Server) Start() error {
|
||||
s.registerMetrics()
|
||||
|
||||
if err := s.loadWebPushConfig(context.TODO()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
users, err := s.db.ListUsers(context.TODO())
|
||||
if err != nil {
|
||||
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() {
|
||||
s.lock.Lock()
|
||||
for ln := range s.listeners {
|
||||
|
10
upstream.go
10
upstream.go
@ -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) {
|
||||
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)
|
||||
@ -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)},
|
||||
})
|
||||
})
|
||||
|
||||
if weAreInvited {
|
||||
uc.network.broadcastWebPush(ctx, msg)
|
||||
}
|
||||
case irc.RPL_INVITING:
|
||||
var nick, channel string
|
||||
if err := parseMessageParams(msg, nil, &nick, &channel); err != nil {
|
||||
|
27
user.go
27
user.go
@ -13,6 +13,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SherClockHolmes/webpush-go"
|
||||
"gopkg.in/irc.v3"
|
||||
|
||||
"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 {
|
||||
database.User
|
||||
srv *Server
|
||||
|
Loading…
Reference in New Issue
Block a user