多租户更改
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
# TNPG: Push Gateway
|
||||
|
||||
This is a push notifications adapter which communicates with Tinode Push Gateway (TNPG).
|
||||
|
||||
TNPG is a proprietary service intended to simplify deployment of on-premise installations.
|
||||
Deploying a Tinode server without TNPG requires [configuring Google FCM](../fcm/) with your own credentials, recompiling Android and iOS clients, releasing them to PlayStore and AppStore under your own accounts. It's usually time consuming and relatively complex.
|
||||
|
||||
TNPG solves this problem by letting Tinode LLC (the company behind Tinode) to send push notifications on your behalf: you hand a notification over to TNPG, TNPG sends it to the clients using its own credentials and certificates. Internally it uses [Google FCM](https://firebase.google.com/docs/cloud-messaging/) and as such supports the same platforms as FCM. The main advantage of using TNPG over FCM is simplicity of configuration: you can use stock mobile clients with your custom Tinode server, all is needed is a configuration update on the server.
|
||||
|
||||
## Configuring TNPG adapter
|
||||
|
||||
### Obtain TNPG token
|
||||
|
||||
1. Register at https://console.tinode.co and create an organization.
|
||||
2. Get the TPNG token from the _Self hosting_ → _Push Gateway_ section by following the instructions there.
|
||||
|
||||
### Configure the server
|
||||
Update the server config [`tinode.conf`](../../tinode.conf#L413), section `"push"` -> `"name": "tnpg"`:
|
||||
```js
|
||||
{
|
||||
"enabled": true,
|
||||
"org": "myorg", // Short name (URL) of the organization you registered at console.tinode.co
|
||||
"token": "SoMe_LonG.RaNDoM-StRiNg.12345" // authentication token obtained from console.tinode.co
|
||||
}
|
||||
```
|
||||
Make sure the `fcm` section is disabled `"enabled": false` or removed altogether.
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
// Package tnpg implements push notification plugin for Tinode Push Gateway.
|
||||
package tnpg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/tinode/chat/server/logs"
|
||||
"github.com/tinode/chat/server/push"
|
||||
"github.com/tinode/chat/server/push/common"
|
||||
"github.com/tinode/chat/server/push/fcm"
|
||||
"github.com/tinode/chat/server/store"
|
||||
"github.com/tinode/chat/server/store/types"
|
||||
|
||||
fcmv1 "google.golang.org/api/fcm/v1"
|
||||
)
|
||||
|
||||
const (
|
||||
baseTargetAddress = "https://pushgw.tinode.co/"
|
||||
pushPath = "pushv1"
|
||||
subsPath = "sub"
|
||||
pushBatchSize = 100
|
||||
subBatchSize = 1000
|
||||
bufferSize = 1024
|
||||
)
|
||||
|
||||
var handler Handler
|
||||
|
||||
const maxPooledPostBodyCap = 1 << 16
|
||||
|
||||
var postBodyPool = sync.Pool{
|
||||
New: func() any {
|
||||
return new(bytes.Buffer)
|
||||
},
|
||||
}
|
||||
|
||||
var gzipWriterPool = sync.Pool{
|
||||
New: func() any {
|
||||
return gzip.NewWriter(nil)
|
||||
},
|
||||
}
|
||||
|
||||
// Handler represents state of TNPG push client.
|
||||
type Handler struct {
|
||||
input chan *push.Receipt
|
||||
channel chan *push.ChannelReq
|
||||
stop chan bool
|
||||
pushUrl string
|
||||
subUrl string
|
||||
}
|
||||
|
||||
type configType struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
OrgID string `json:"org"`
|
||||
AuthToken string `json:"token"`
|
||||
DebugPushGWHost string `json:"debug_server"`
|
||||
}
|
||||
|
||||
// subUnsubReq is a request to subscribe/unsubscribe device ID(s) to channel(s) (FCM topic).
|
||||
// One device to multiple channels or multiple devices to one channel.
|
||||
type subUnsubReq struct {
|
||||
Channel string `json:"channel,omitempty"`
|
||||
Channels []string `json:"channels,omitempty"`
|
||||
Device string `json:"device,omitempty"`
|
||||
Devices []string `json:"devices,omitempty"`
|
||||
Unsub bool `json:"unsub"`
|
||||
}
|
||||
|
||||
type tnpgResponse struct {
|
||||
// Push message response only.
|
||||
MessageID string `json:"msg_id,omitempty"`
|
||||
// Server response HTTP code.
|
||||
Code int `json:"code,omitempty"`
|
||||
// FCM response code. Both push and sub/unsub response.
|
||||
ErrorCode string `json:"errcode,omitempty"`
|
||||
ExtendedError string `json:"exerr,omitempty"`
|
||||
ErrorMessage string `json:"errmsg,omitempty"`
|
||||
// Channel sub/unsub response only.
|
||||
Index int `json:"index,omitempty"`
|
||||
}
|
||||
|
||||
type batchResponse struct {
|
||||
// Number of successfully sent messages.
|
||||
SuccessCount int `json:"sent_count"`
|
||||
// Number of failures.
|
||||
FailureCount int `json:"fail_count"`
|
||||
// Error code and message if the entire batch failed.
|
||||
FatalCode string `json:"errcode,omitempty"`
|
||||
FatalMessage string `json:"errmsg,omitempty"`
|
||||
// Individual reponses in the same order as messages. Could be nil if the entire batch failed.
|
||||
Responses []*tnpgResponse `json:"resp,omitempty"`
|
||||
|
||||
// Local values
|
||||
httpCode int
|
||||
httpStatus string
|
||||
}
|
||||
|
||||
// Error codes copied from https://github.com/firebase/firebase-admin-go/blob/master/messaging/messaging.go
|
||||
const (
|
||||
internalError = "internal-error"
|
||||
invalidAPNSCredentials = "invalid-apns-credentials"
|
||||
invalidArgument = "invalid-argument"
|
||||
messageRateExceeded = "message-rate-exceeded"
|
||||
mismatchedCredential = "mismatched-credential"
|
||||
registrationTokenNotRegistered = "registration-token-not-registered"
|
||||
serverUnavailable = "server-unavailable"
|
||||
tooManyTopics = "too-many-topics"
|
||||
unknownError = "unknown-error"
|
||||
)
|
||||
|
||||
// Init initializes the handler
|
||||
func (Handler) Init(jsonconf json.RawMessage) (bool, error) {
|
||||
var config configType
|
||||
if err := json.Unmarshal(jsonconf, &config); err != nil {
|
||||
return false, errors.New("failed to parse config: " + err.Error())
|
||||
}
|
||||
|
||||
if !config.Enabled {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
config.OrgID = strings.TrimSpace(config.OrgID)
|
||||
if config.OrgID == "" {
|
||||
return false, errors.New("organization name is missing")
|
||||
}
|
||||
|
||||
// Convert to lower case to avoid confusion.
|
||||
config.OrgID = strings.ToLower(config.OrgID)
|
||||
|
||||
// Construct server URLs.
|
||||
serverAddr := baseTargetAddress
|
||||
if config.DebugPushGWHost != "" {
|
||||
serverAddr = config.DebugPushGWHost
|
||||
}
|
||||
serverUrl, err := url.Parse(serverAddr)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
serverUrl.Path += pushPath + "/" + config.OrgID
|
||||
handler.pushUrl = serverUrl.String()
|
||||
serverUrl, _ = url.Parse(serverAddr)
|
||||
serverUrl.Path += subsPath + "/" + config.OrgID
|
||||
handler.subUrl = serverUrl.String()
|
||||
|
||||
handler.input = make(chan *push.Receipt, bufferSize)
|
||||
handler.channel = make(chan *push.ChannelReq, bufferSize)
|
||||
handler.stop = make(chan bool, 1)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case rcpt := <-handler.input:
|
||||
go sendPushes(rcpt, &config)
|
||||
case sub := <-handler.channel:
|
||||
go processSubscription(sub, &config)
|
||||
case <-handler.stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func postMessage(endpoint string, body any, config *configType) (*batchResponse, error) {
|
||||
buf := postBodyPool.Get().(*bytes.Buffer)
|
||||
defer func() {
|
||||
buf.Reset()
|
||||
if cap(buf.Bytes()) > maxPooledPostBodyCap {
|
||||
return
|
||||
}
|
||||
postBodyPool.Put(buf)
|
||||
}()
|
||||
|
||||
gzw := gzipWriterPool.Get().(*gzip.Writer)
|
||||
defer func() {
|
||||
gzw.Reset(nil)
|
||||
gzipWriterPool.Put(gzw)
|
||||
}()
|
||||
gzw.Reset(buf)
|
||||
err := json.NewEncoder(gzw).Encode(body)
|
||||
if closeErr := gzw.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, endpoint, buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Add("Authorization", "Bearer "+config.AuthToken)
|
||||
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
req.Header.Add("Content-Encoding", "gzip")
|
||||
req.Header.Add("Accept-Encoding", "gzip")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var batch batchResponse
|
||||
var reader io.ReadCloser
|
||||
if strings.Contains(resp.Header.Get("Content-Encoding"), "gzip") {
|
||||
reader, err = gzip.NewReader(resp.Body)
|
||||
if err == nil {
|
||||
defer reader.Close()
|
||||
}
|
||||
} else {
|
||||
reader = resp.Body
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
err = json.NewDecoder(reader).Decode(&batch)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if err != nil {
|
||||
// Just log the error, but don't report it to caller. The push succeeded.
|
||||
logs.Warn.Println("tnpg failed to decode response:", err)
|
||||
}
|
||||
|
||||
batch.httpCode = resp.StatusCode
|
||||
batch.httpStatus = resp.Status
|
||||
|
||||
return &batch, nil
|
||||
}
|
||||
|
||||
func sendPushes(rcpt *push.Receipt, config *configType) {
|
||||
messages, uids := fcm.PrepareV1Notifications(rcpt, nil)
|
||||
|
||||
n := len(messages)
|
||||
for i := 0; i < n; i += pushBatchSize {
|
||||
upper := min(i+pushBatchSize, n)
|
||||
var payloads []any
|
||||
for j := i; j < upper; j++ {
|
||||
payloads = append(payloads, messages[j])
|
||||
}
|
||||
resp, err := postMessage(handler.pushUrl, payloads, config)
|
||||
if err != nil {
|
||||
logs.Warn.Println("tnpg push request failed:", err)
|
||||
break
|
||||
}
|
||||
if resp.httpCode >= 300 {
|
||||
logs.Warn.Println("tnpg push rejected:", resp.httpStatus)
|
||||
break
|
||||
}
|
||||
if resp.FatalCode != "" {
|
||||
logs.Err.Println("tnpg push failed:", resp.FatalMessage)
|
||||
break
|
||||
}
|
||||
// Check for expired tokens and other errors.
|
||||
handlePushResponse(rcpt.TenantID, resp, messages[i:upper], uids[i:upper])
|
||||
}
|
||||
}
|
||||
|
||||
func processSubscription(req *push.ChannelReq, config *configType) {
|
||||
su := subUnsubReq{
|
||||
Unsub: req.Unsub,
|
||||
}
|
||||
|
||||
if req.Channel != "" {
|
||||
su.Devices = fcm.DevicesForUser(req.TenantID, req.Uid)
|
||||
su.Channel = req.Channel
|
||||
} else if req.DeviceID != "" {
|
||||
su.Channels = fcm.ChannelsForUser(req.TenantID, req.Uid)
|
||||
su.Device = req.DeviceID
|
||||
}
|
||||
|
||||
if (len(su.Devices) == 0 && su.Device == "") || (len(su.Channels) == 0 && su.Channel == "") {
|
||||
return
|
||||
}
|
||||
|
||||
if len(su.Devices) > subBatchSize {
|
||||
// It's extremely unlikely for a single user to have this many devices.
|
||||
su.Devices = su.Devices[0:subBatchSize]
|
||||
logs.Warn.Println("tnpg: user", req.Uid.UserId(), "has more than", subBatchSize, "devices")
|
||||
}
|
||||
|
||||
resp, err := postMessage(handler.subUrl, &su, config)
|
||||
if err != nil {
|
||||
logs.Warn.Println("tnpg channel sub request failed:", err)
|
||||
return
|
||||
}
|
||||
if resp.httpCode >= 300 {
|
||||
logs.Warn.Println("tnpg channel sub rejected:", resp.httpStatus)
|
||||
return
|
||||
}
|
||||
if resp.FatalCode != "" {
|
||||
logs.Err.Println("tnpg channel sub failed:", resp.FatalMessage)
|
||||
return
|
||||
}
|
||||
// Check for expired tokens and other errors.
|
||||
handleSubResponse(resp, req, su.Devices, su.Channels)
|
||||
}
|
||||
|
||||
func handlePushResponse(tenantID types.TenantID, batch *batchResponse, messages []*fcmv1.Message, uids []types.Uid) {
|
||||
if batch.FailureCount <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for i, resp := range batch.Responses {
|
||||
switch resp.ErrorCode {
|
||||
case "": // no error
|
||||
case common.ErrorQuotaExceeded, common.ErrorUnavailable, common.ErrorInternal, common.ErrorUnspecified:
|
||||
// Transient errors. Stop sending this batch.
|
||||
logs.Warn.Println("tnpg transient failure:", resp.ErrorMessage)
|
||||
return
|
||||
case common.ErrorInvalidArgument:
|
||||
// Usually an invalid token.
|
||||
logs.Warn.Println("tnpg invalid argument:", resp.ExtendedError, resp.ErrorMessage)
|
||||
if strings.Contains(resp.ExtendedError, "message.token") {
|
||||
if err := store.Devices.Delete(tenantID, uids[i], messages[i].Token); err != nil {
|
||||
logs.Warn.Println("tnpg failed to delete invalid token:", err)
|
||||
}
|
||||
}
|
||||
case common.ErrorSenderIDMismatch, common.ErrorThirdPartyAuth:
|
||||
// Config errors
|
||||
logs.Warn.Println("tnpg invalid config:", resp.ExtendedError, resp.ErrorMessage)
|
||||
return
|
||||
case common.ErrorUnregistered:
|
||||
// Token is no longer valid.
|
||||
logs.Info.Println("tnpg invalid token:", resp.ErrorMessage, resp.ExtendedError, resp.MessageID)
|
||||
if err := store.Devices.Delete(tenantID, uids[i], messages[i].Token); err != nil {
|
||||
logs.Warn.Println("tnpg failed to delete invalid token:", err)
|
||||
}
|
||||
default:
|
||||
logs.Warn.Println("tnpg unrecognized error:", resp.ErrorCode, resp.ErrorMessage, resp.ExtendedError, resp.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleSubResponse(batch *batchResponse, req *push.ChannelReq, devices, channels []string) {
|
||||
if batch.FailureCount <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var src string
|
||||
for _, resp := range batch.Responses {
|
||||
if len(devices) > 0 {
|
||||
src = devices[resp.Index]
|
||||
} else {
|
||||
src = channels[resp.Index]
|
||||
}
|
||||
// FCM documentation sucks. There is no list of possible errors so no action can be taken but logging.
|
||||
logs.Warn.Println("fcm sub/unsub error", resp.ErrorCode, req.Uid, src)
|
||||
}
|
||||
}
|
||||
|
||||
// IsReady checks if the handler is initialized.
|
||||
func (Handler) IsReady() bool {
|
||||
return handler.input != nil
|
||||
}
|
||||
|
||||
// Push returns a channel that the server will use to send messages to.
|
||||
// If the adapter blocks, the message will be dropped.
|
||||
func (Handler) Push() chan<- *push.Receipt {
|
||||
return handler.input
|
||||
}
|
||||
|
||||
// Channel returns a channel that the server will use to send group requests to.
|
||||
// If the adapter blocks, the message will be dropped.
|
||||
func (Handler) Channel() chan<- *push.ChannelReq {
|
||||
return handler.channel
|
||||
}
|
||||
|
||||
// Stop terminates the handler's worker and stops sending pushes.
|
||||
func (Handler) Stop() {
|
||||
handler.stop <- true
|
||||
}
|
||||
|
||||
func init() {
|
||||
push.Register("tnpg", &handler)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user