::, "123456:email:alice@example.com".
+func (ca *authenticator) Authenticate(ctx auth.AuthContext, secret []byte) (*auth.Rec, []byte, error) {
+ if !ctx.IsValid() {
+ return nil, nil, types.ErrMalformed
+ }
+ parts := strings.SplitN(string(secret), ":", 2)
+ if len(parts) != 2 {
+ return nil, nil, types.ErrMalformed
+ }
+
+ code, cred := parts[0], parts[1]
+ key := cacheKey(ctx.TenantID, cred)
+
+ value, err := store.PCache.Get(ctx.TenantID, key)
+ if err != nil {
+ if err == types.ErrNotFound {
+ err = types.ErrFailed
+ }
+ return nil, nil, err
+ }
+
+ // code:count:uid
+ parts = strings.Split(value, ":")
+ if len(parts) != 3 {
+ return nil, nil, types.ErrInternal
+ }
+
+ count, err := strconv.Atoi(parts[1])
+ if err != nil {
+ return nil, nil, types.ErrInternal
+ }
+
+ if count >= ca.maxRetries {
+ return nil, nil, types.ErrFailed
+ }
+
+ if parts[0] != code {
+ // Update count of attempts. If the update fails, the error is ignored.
+ store.PCache.Upsert(ctx.TenantID, key, parts[0]+":"+strconv.Itoa(count+1)+":"+parts[2], false)
+ return nil, nil, types.ErrFailed
+ }
+
+ // Success. Remove no longer needed entry. The error is ignored here.
+ if err = store.PCache.Delete(ctx.TenantID, key); err != nil {
+ logs.Warn.Println("code_auth: error deleting key", key, err)
+ }
+
+ return &auth.Rec{
+ TenantID: ctx.TenantID,
+ Uid: types.ParseUid(parts[2]),
+ AuthLevel: auth.LevelNone,
+ Lifetime: auth.Duration(ca.lifetime),
+ Features: auth.FeatureNoLogin,
+ State: types.StateUndefined,
+ Credential: cred}, nil, nil
+}
+
+// GenSecret generates a new code.
+func (ca *authenticator) GenSecret(rec *auth.Rec) ([]byte, time.Time, error) {
+ if rec == nil || rec.TenantID.IsZero() {
+ return nil, time.Time{}, types.ErrMalformed
+ }
+ // Run garbage collection.
+ store.PCache.Expire(rec.TenantID, cachePrefix(rec.TenantID), time.Now().UTC().Add(-ca.lifetime))
+
+ // Generate random code.
+ code, err := rand.Int(rand.Reader, ca.maxCodeValue)
+ if err != nil {
+ return nil, time.Time{}, types.ErrInternal
+ }
+
+ // Convert the code to fixed length string.
+ resp := strconv.FormatInt(code.Int64(), 10)
+ resp = strings.Repeat("0", ca.codeLength-len(resp)) + resp
+
+ if rec.Lifetime == 0 {
+ rec.Lifetime = auth.Duration(ca.lifetime)
+ } else if rec.Lifetime < 0 {
+ return nil, time.Time{}, types.ErrExpired
+ }
+
+ // Save "code:counter:uid" to the database. The key is code_.
+ if err = store.PCache.Upsert(rec.TenantID, cacheKey(rec.TenantID, rec.Credential), resp+":0:"+rec.Uid.String(), true); err != nil {
+ return nil, time.Time{}, err
+ }
+
+ expires := time.Now().Add(time.Duration(rec.Lifetime)).UTC().Round(time.Millisecond)
+
+ return []byte(resp), expires, nil
+}
+
+// AsTag is not supported, will produce an empty string.
+func (authenticator) AsTag(token string) string {
+ return ""
+}
+
+// IsUnique is not supported, will produce an error.
+func (authenticator) IsUnique(ctx auth.AuthContext, secret []byte) (bool, error) {
+ return false, types.ErrUnsupported
+}
+
+// DelRecords adds disabled user ID to a stop list.
+func (authenticator) DelRecords(tenantID types.TenantID, uid types.Uid) error {
+ return nil
+}
+
+// RestrictedTags returns tag namespaces restricted by this authenticator (none for short code).
+func (authenticator) RestrictedTags() ([]string, error) {
+ return nil, nil
+}
+
+// GetResetParams returns authenticator parameters passed to password reset handler
+// (none for short code).
+func (authenticator) GetResetParams(tenantID types.TenantID, uid types.Uid) (map[string]any, error) {
+ return nil, nil
+}
+
+// Replace all occurrences of '%' with '/' to ensure SQL LIKE query works correctly.
+func sanitizeKey(key string) string {
+ return strings.ReplaceAll(key, "%", "/")
+}
+
+func cachePrefix(tenantID types.TenantID) string {
+ return realName + "_" + strconv.FormatInt(int64(tenantID), 10) + "_"
+}
+
+func cacheKey(tenantID types.TenantID, credential string) string {
+ return sanitizeKey(cachePrefix(tenantID) + credential)
+}
+
+const realName = "code"
+
+// GetRealName returns the hardcoded name of the authenticator.
+func (authenticator) GetRealName() string {
+ return realName
+}
+
+func init() {
+ store.RegisterAuthScheme(realName, &authenticator{})
+}
diff --git a/server/auth/mock_auth/mock_auth.go b/server/auth/mock_auth/mock_auth.go
index e69de29..f581a03 100644
--- a/server/auth/mock_auth/mock_auth.go
+++ b/server/auth/mock_auth/mock_auth.go
@@ -0,0 +1,215 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: auth/auth.go
+
+// Package mock_auth is a generated GoMock package.
+package mock_auth
+
+import (
+ json "encoding/json"
+ reflect "reflect"
+ time "time"
+
+ gomock "github.com/golang/mock/gomock"
+ auth "github.com/tinode/chat/server/auth"
+ types "github.com/tinode/chat/server/store/types"
+)
+
+// MockAuthHandler is a mock of AuthHandler interface.
+type MockAuthHandler struct {
+ ctrl *gomock.Controller
+ recorder *MockAuthHandlerMockRecorder
+}
+
+// MockAuthHandlerMockRecorder is the mock recorder for MockAuthHandler.
+type MockAuthHandlerMockRecorder struct {
+ mock *MockAuthHandler
+}
+
+// NewMockAuthHandler creates a new mock instance.
+func NewMockAuthHandler(ctrl *gomock.Controller) *MockAuthHandler {
+ mock := &MockAuthHandler{ctrl: ctrl}
+ mock.recorder = &MockAuthHandlerMockRecorder{mock}
+ return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockAuthHandler) EXPECT() *MockAuthHandlerMockRecorder {
+ return m.recorder
+}
+
+// AddRecord mocks base method.
+func (m *MockAuthHandler) AddRecord(ctx auth.AuthContext, rec *auth.Rec, secret []byte) (*auth.Rec, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "AddRecord", ctx, rec, secret)
+ ret0, _ := ret[0].(*auth.Rec)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// AddRecord indicates an expected call of AddRecord.
+func (mr *MockAuthHandlerMockRecorder) AddRecord(ctx, rec, secret interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddRecord", reflect.TypeOf((*MockAuthHandler)(nil).AddRecord), ctx, rec, secret)
+}
+
+// AsTag mocks base method.
+func (m *MockAuthHandler) AsTag(token string) string {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "AsTag", token)
+ ret0, _ := ret[0].(string)
+ return ret0
+}
+
+// AsTag indicates an expected call of AsTag.
+func (mr *MockAuthHandlerMockRecorder) AsTag(token interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AsTag", reflect.TypeOf((*MockAuthHandler)(nil).AsTag), token)
+}
+
+// Authenticate mocks base method.
+func (m *MockAuthHandler) Authenticate(ctx auth.AuthContext, secret []byte) (*auth.Rec, []byte, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Authenticate", ctx, secret)
+ ret0, _ := ret[0].(*auth.Rec)
+ ret1, _ := ret[1].([]byte)
+ ret2, _ := ret[2].(error)
+ return ret0, ret1, ret2
+}
+
+// Authenticate indicates an expected call of Authenticate.
+func (mr *MockAuthHandlerMockRecorder) Authenticate(ctx, secret interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Authenticate", reflect.TypeOf((*MockAuthHandler)(nil).Authenticate), ctx, secret)
+}
+
+// DelRecords mocks base method.
+func (m *MockAuthHandler) DelRecords(tenantID types.TenantID, uid types.Uid) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "DelRecords", tenantID, uid)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// DelRecords indicates an expected call of DelRecords.
+func (mr *MockAuthHandlerMockRecorder) DelRecords(tenantID, uid interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DelRecords", reflect.TypeOf((*MockAuthHandler)(nil).DelRecords), tenantID, uid)
+}
+
+// GenSecret mocks base method.
+func (m *MockAuthHandler) GenSecret(rec *auth.Rec) ([]byte, time.Time, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GenSecret", rec)
+ ret0, _ := ret[0].([]byte)
+ ret1, _ := ret[1].(time.Time)
+ ret2, _ := ret[2].(error)
+ return ret0, ret1, ret2
+}
+
+// GenSecret indicates an expected call of GenSecret.
+func (mr *MockAuthHandlerMockRecorder) GenSecret(rec interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GenSecret", reflect.TypeOf((*MockAuthHandler)(nil).GenSecret), rec)
+}
+
+// GetRealName mocks base method.
+func (m *MockAuthHandler) GetRealName() string {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetRealName")
+ ret0, _ := ret[0].(string)
+ return ret0
+}
+
+// GetRealName indicates an expected call of GetRealName.
+func (mr *MockAuthHandlerMockRecorder) GetRealName() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRealName", reflect.TypeOf((*MockAuthHandler)(nil).GetRealName))
+}
+
+// GetResetParams mocks base method.
+func (m *MockAuthHandler) GetResetParams(tenantID types.TenantID, uid types.Uid) (map[string]any, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetResetParams", tenantID, uid)
+ ret0, _ := ret[0].(map[string]any)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetResetParams indicates an expected call of GetResetParams.
+func (mr *MockAuthHandlerMockRecorder) GetResetParams(tenantID, uid interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetResetParams", reflect.TypeOf((*MockAuthHandler)(nil).GetResetParams), tenantID, uid)
+}
+
+// Init mocks base method.
+func (m *MockAuthHandler) Init(jsonconf json.RawMessage, name string) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Init", jsonconf, name)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// Init indicates an expected call of Init.
+func (mr *MockAuthHandlerMockRecorder) Init(jsonconf, name interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Init", reflect.TypeOf((*MockAuthHandler)(nil).Init), jsonconf, name)
+}
+
+// IsInitialized mocks base method.
+func (m *MockAuthHandler) IsInitialized() bool {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "IsInitialized")
+ ret0, _ := ret[0].(bool)
+ return ret0
+}
+
+// IsInitialized indicates an expected call of IsInitialized.
+func (mr *MockAuthHandlerMockRecorder) IsInitialized() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsInitialized", reflect.TypeOf((*MockAuthHandler)(nil).IsInitialized))
+}
+
+// IsUnique mocks base method.
+func (m *MockAuthHandler) IsUnique(ctx auth.AuthContext, secret []byte) (bool, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "IsUnique", ctx, secret)
+ ret0, _ := ret[0].(bool)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// IsUnique indicates an expected call of IsUnique.
+func (mr *MockAuthHandlerMockRecorder) IsUnique(ctx, secret interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsUnique", reflect.TypeOf((*MockAuthHandler)(nil).IsUnique), ctx, secret)
+}
+
+// RestrictedTags mocks base method.
+func (m *MockAuthHandler) RestrictedTags() ([]string, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "RestrictedTags")
+ ret0, _ := ret[0].([]string)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// RestrictedTags indicates an expected call of RestrictedTags.
+func (mr *MockAuthHandlerMockRecorder) RestrictedTags() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RestrictedTags", reflect.TypeOf((*MockAuthHandler)(nil).RestrictedTags))
+}
+
+// UpdateRecord mocks base method.
+func (m *MockAuthHandler) UpdateRecord(ctx auth.AuthContext, rec *auth.Rec, secret []byte) (*auth.Rec, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "UpdateRecord", ctx, rec, secret)
+ ret0, _ := ret[0].(*auth.Rec)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// UpdateRecord indicates an expected call of UpdateRecord.
+func (mr *MockAuthHandlerMockRecorder) UpdateRecord(ctx, rec, secret interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateRecord", reflect.TypeOf((*MockAuthHandler)(nil).UpdateRecord), ctx, rec, secret)
+}
diff --git a/server/auth/rest/README.md b/server/auth/rest/README.md
index e69de29..f99c155 100644
--- a/server/auth/rest/README.md
+++ b/server/auth/rest/README.md
@@ -0,0 +1,366 @@
+# REST or JSON-RPC authenticator
+
+This authenticator permits authentication of Tinode users and creation of Tinode accounts using a separate process as a
+source of truth. For instance, if accounts are managed by corporate LDAP, this service allows handling of Tinode
+authentication using the same LDAP service.
+
+This authenticator calls a designated authentication service over HTTP(S) POST. A skeleton implementation of a server
+is provided for reference at [rest-auth](../../../rest-auth/). The requests may be handled either by a single endpoint
+or by separate per-request endpoints.
+
+Request and response payloads are formatted as JSON. Some of the request or response fields are context-dependent and
+may be skipped.
+
+
+
+- [REST or JSON-RPC authenticator](#rest-or-json-rpc-authenticator)
+ - [Configuration](#configuration)
+ - [Request](#request)
+ - [Response](#response)
+ - [Recognized error responses](#recognized-error-responses)
+ - [The server must implement the following endpoints:](#the-server-must-implement-the-following-endpoints)
+ - [`add` Add new authentication record](#add-add-new-authentication-record)
+ - [Sample request](#sample-request)
+ - [Sample response (rec values may change)](#sample-response-rec-values-may-change)
+ - [`auth` Request for authentication](#auth-request-for-authentication)
+ - [Sample request](#sample-request)
+ - [Sample response when the account already exists (optional challenge included)](#sample-response-when-the-account-already-exists-optional-challenge-included)
+ - [Sample response when the account needs to be created by Tinode](#sample-response-when-the-account-needs-to-be-created-by-tinode)
+ - [`checkunique` Checks if provided authentication record is unique.](#checkunique-checks-if-provided-authentication-record-is-unique)
+ - [Sample request](#sample-request)
+ - [Sample response](#sample-response)
+ - [`del` Requests to delete authentication record.](#del-requests-to-delete-authentication-record)
+ - [Sample request](#sample-request)
+ - [Sample response](#sample-response)
+ - [`gen` Generate authentication secret.](#gen-generate-authentication-secret)
+ - [Sample request](#sample-request)
+ - [Sample response](#sample-response)
+ - [`link` Requests server to link new account ID to authentication record.](#link-requests-server-to-link-new-account-id-to-authentication-record)
+ - [Sample request](#sample-request)
+ - [Sample response](#sample-response)
+ - [`upd` Update authentication record.](#upd-update-authentication-record)
+ - [Sample request](#sample-request)
+ - [Sample response](#sample-response)
+ - [`rtagns` Get a list of restricted tag namespaces.](#rtagns-get-a-list-of-restricted-tag-namespaces)
+ - [Sample request](#sample-request)
+ - [Sample response](#sample-response)
+
+
+
+## Configuration
+
+Add the following section to the `auth_config` in [tinode.conf](../../tinode.conf):
+
+```js
+...
+"auth_config": {
+ ...
+ "rest": {
+ // ServerUrl is the URL of the authentication server to call. The URL must be absolute:
+ // it must include the scheme, such as http or https, and the host name.
+ "server_url": "http://127.0.0.1:5000/",
+ // Authentication server is allowed to create new accounts.
+ "allow_new_accounts": true,
+ // Use separate endpoints, i.e. add request name to serverUrl path when making requests:
+ // http://127.0.0.1:5000/add
+ "use_separate_endpoints": true
+ },
+ ...
+},
+```
+If you want to use your authenticator **instead** of stock `basic` (login-password) authentication,
+add logical renaming and disable `rest` at the original name:
+```js
+...
+"auth_config": {
+ "logical_names": ["basic:rest", "rest:"],
+ "rest": { ... },
+ ...
+},
+...
+```
+
+## Request
+
+```js
+{
+ "endpoint": "auth", // string, one of the endpoints as described below, optional.
+ "secret": "Ym9iOmJvYjEyMw==", // authentication secret as provided by the client,
+ // base64-encoded bytes, optional.
+ "addr": "2001:0db8:85a3:0000:0000:8a2e:0370:7334", // string, IPv4 or IPv6 address of
+ // the client making the request, optional.
+ "rec": { // authentication record, optional.
+ {
+ "uid": "LELEQHDWbgY", // user ID, int64 base64-encoded
+ "authlvl": "auth", // authentication level
+ "lifetime": "10000s", // lifetime of this record in seconds or as time.Duration string;
+ // see https://golang.org/pkg/time/#Duration for format.
+ "features": 1, // bitmap of features as integer or as a string of feature characters:
+ // "validated" (V) or "no login" (L)".
+ "tags": ["email:alice@example.com"], // Tags associated with this authentication record.
+ "state": "ok", // optional account state.
+ }
+ }
+}
+```
+
+## Response
+
+```js
+{
+ "err": "internal", // string, error message in case of an error.
+ "rec": { // authentication record.
+ ... // the same as `request.rec`
+ },
+ "byteval": "Ym9iOmJvYjEyMw==", // array of bytes, optional
+ "ts": "2018-12-04T15:17:02.627Z", // time stamp, optional
+ "boolval": true, // boolean value, optional
+ "strarr": ["abc", "def"], // array of strings, optoional
+ "newacc": { // data to use for creating a new account.
+ // Default access mode
+ "auth": "JRWPS",
+ "anon": "N",
+ "public": {...}, // user's public data, see /docs/API.md#trusted-public-and-private-fields
+ "trusted": {...}, // user's trusted data, see /docs/API.md#trusted-public-and-private-fields
+ "private": {...} // user's private data, see /docs/API.md#trusted-public-and-private-fields
+ }
+}
+```
+
+## Recognized error responses
+
+The error is returned as json:
+
+```json
+{ "err": "error-message" }
+```
+
+See [here](../../store/types/types.go#L24) for an up to date list of supported error messages.
+
+* "internal": database failure or other internal catch-all failure.
+* "malformed": request cannot be parsed or otherwise wrong.
+* "failed": authentication failed (wrong login or password, etc).
+* "duplicate value": duplicate credential, i.e. attempt to create a record with a non-unique login.
+* "unsupported": the operation is not supported.
+* "expired": the secret has expired.
+* "policy": policy violation, e.g. password too weak.
+* "credentials": credentials like email or captcha must be validated.
+* "not found": the object was not found.
+* "denied": the operation is not permitted.
+
+## The server must implement the following endpoints:
+
+### `add` Add new authentication record
+
+This endpoint requests server to add a new authentication record. This endpoint is generally used for account creation.
+If accounts are managed externally, it's likely to be unused and should generally return an error `"unsupported"`.
+
+#### Sample request
+```json
+{
+ "endpoint": "add",
+ "secret": "Ym9iOmJvYjEyMw==",
+ "addr": "111.22.33.44",
+ "rec": {
+ "uid": "LELEQHDWbgY",
+ "lifetime": "10000s",
+ "features": 2,
+ "tags": ["email:alice@example.com"]
+ }
+}
+```
+
+#### Sample response (rec values may change)
+```json
+{
+ "rec": {
+ "uid": "LELEQHDWbgY",
+ "authlvl": "auth",
+ "lifetime": "5000s",
+ "features": 1,
+ "tags": ["email:alice@example.com", "uname:alice"]
+ }
+}
+```
+
+### `auth` Request for authentication
+
+Request to authenticate a user. Client (Tinode) provides a secret, authentication server responds with a user record.
+If this is a very first login and the server manages the accounts, the server may return `newacc` object which will
+be used by client (Tinode) to create the account. The server may optionally return a challenge as `byteval`.
+
+#### Sample request
+```json
+{
+ "endpoint": "auth",
+ "secret": "Ym9iOmJvYjEyMw==",
+ "addr": "111.22.33.44"
+}
+```
+
+#### Sample response when the account already exists (optional challenge included)
+```json
+{
+ "rec": {
+ "uid": "LELEQHDWbgY",
+ "authlvl": "auth",
+ "state": "ok"
+ },
+ "byteval": "9X6m3tWeBEMlDxlcFAABAAEAbVs"
+}
+```
+
+#### Sample response when the account needs to be created by Tinode
+```js
+{
+ "rec": {
+ "state": "suspended", // Or "ok".
+ "authlvl": "auth",
+ "lifetime": "5000s",
+ "features": 1,
+ "tags": ["email:alice@example.com", "uname:alice"]
+ },
+ "newacc": {
+ "auth": "JRWPS",
+ "anon": "N",
+ "public": {/* see /docs/API.md#trusted-public-and-private-fields */},
+ "trusted": {/* see /docs/API.md#trusted-public-and-private-fields */},
+ "private": {/* see /docs/API.md#trusted-public-and-private-fields */}
+ }
+}
+```
+
+### `checkunique` Checks if provided authentication record is unique.
+
+Request is used for account creation. If accounts are managed by the server, the server should respond with
+an error `"unsupported"`.
+
+#### Sample request
+```json
+{
+ "endpoint": "checkunique",
+ "secret": "Ym9iOmJvYjEyMw==",
+ "addr": "111.22.33.44"
+}
+```
+
+#### Sample response
+```json
+{
+ "boolval": true
+}
+```
+
+### `del` Requests to delete authentication record.
+
+If accounts are managed by the server, the server should respond with an error `"unsupported"`.
+
+#### Sample request
+```json
+{
+ "endpoint": "del",
+ "rec": {
+ "uid": "LELEQHDWbgY",
+ }
+}
+```
+
+#### Sample response
+```json
+{}
+```
+
+
+### `gen` Generate authentication secret.
+
+If accounts are managed by the server, the server should respond with an error `"unsupported"`.
+
+#### Sample request
+```json
+{
+ "endpoint": "gen",
+ "rec": {
+ "uid": "LELEQHDWbgY",
+ "authlvl": "auth",
+ }
+}
+```
+
+#### Sample response
+```json
+{
+ "byteval": "9X6m3tWeBEMlDxlcFAABAAEAbVs",
+ "ts": "2018-12-04T15:17:02.627Z",
+}
+```
+
+
+### `link` Requests server to link new account ID to authentication record.
+
+If server requested Tinode to create a new account, this endpoint is used to link the new Tinode user ID with the
+server's authentication record. If linking is successful, the server should respond with a non-empty json.
+
+#### Sample request
+```json
+{
+ "endpoint": "link",
+ "secret": "Ym9iOmJvYjEyMw==",
+ "rec": {
+ "uid": "LELEQHDWbgY",
+ "authlvl": "auth",
+ },
+}
+```
+
+#### Sample response
+```json
+{}
+```
+
+
+### `upd` Update authentication record.
+
+If accounts are managed by the server, the server should respond with an error `"unsupported"`.
+
+#### Sample request
+```json
+{
+ "endpoint": "upd",
+ "secret": "Ym9iOmJvYjEyMw==",
+ "addr": "111.22.33.44",
+ "rec": {
+ "uid": "LELEQHDWbgY",
+ "authlvl": "auth",
+ }
+}
+```
+
+#### Sample response
+```json
+{}
+```
+
+### `rtagns` Get a list of restricted tag namespaces.
+
+Server may enforce certain tag namespaces (tag prefixes) to be restricted, i.e. not editable by the user.
+These are also used in Tinode discovery mechanism (e.g. searching for users, contact sync). See
+[API docs](/docs/API.md#fnd-and-tags-finding-users-and-topics) for details.
+
+The server may optionally provide a regular expression to validate search tokens before rewriting them as prefixed tags.
+I.e. if server allows only logins of 3-8 ASCII letters and numbers then the regexp could be `^[a-z0-9_]{3,8}$`
+which is base64-encoded as `XlthLXowLTlfXXszLDh9JA==`.
+
+#### Sample request
+```json
+{
+ "endpoint": "rtagns",
+}
+```
+
+#### Sample response
+```json
+{
+ "strarr": ["basic", "email", "tel"],
+ "byteval": "XlthLXowLTlfXXszLDh9JA=="
+}
+```
diff --git a/server/auth/rest/auth_rest.go b/server/auth/rest/auth_rest.go
index e69de29..c81890f 100644
--- a/server/auth/rest/auth_rest.go
+++ b/server/auth/rest/auth_rest.go
@@ -0,0 +1,349 @@
+// Package rest provides authentication by calling a separate process over REST API (technically JSON RPC, not REST).
+package rest
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "net/url"
+ "regexp"
+ "strings"
+ "time"
+
+ "github.com/tinode/chat/server/auth"
+ "github.com/tinode/chat/server/logs"
+ "github.com/tinode/chat/server/store"
+ "github.com/tinode/chat/server/store/types"
+)
+
+// authenticator is the type to map authentication methods to.
+type authenticator struct {
+ // Logical name of this authenticator
+ name string
+ // URL of the server
+ serverUrl string
+ // Authenticator may add new accounts to local database.
+ allowNewAccounts bool
+ // Use separate endpoints, i.e. add request name to serverUrl path when making requests.
+ useSeparateEndpoints bool
+ // Cache of restricted tag prefixes (namespaces).
+ rTagNS []string
+ // Optional regex pattern for checking tokens.
+ reToken *regexp.Regexp
+}
+
+// Request to the server.
+type request struct {
+ Endpoint string `json:"endpoint"`
+ Name string `json:"name"`
+ TenantID types.TenantID `json:"tenant_id,omitempty"`
+ Record *auth.Rec `json:"rec,omitempty"`
+ Secret []byte `json:"secret,omitempty"`
+ RemoteAddr string `json:"addr,omitempty"`
+}
+
+// User initialization data when creating a new user.
+type newAccount struct {
+ // Default access mode
+ Auth string `json:"auth,omitempty"`
+ Anon string `json:"anon,omitempty"`
+ // User's Public data
+ Public any `json:"public,omitempty"`
+ // User's Trusted data
+ Trusted any `json:"trusted,omitempty"`
+ // Per-subscription private data
+ Private any `json:"private,omitempty"`
+}
+
+// Response from the server.
+type response struct {
+ // Error message in case of an error.
+ Err string `json:"err,omitempty"`
+ // Optional auth record
+ Record *auth.Rec `json:"rec,omitempty"`
+ // Optional byte slice
+ ByteVal []byte `json:"byteval,omitempty"`
+ // Optional time value
+ TimeVal time.Time `json:"ts,omitempty"`
+ // Boolean value
+ BoolVal bool `json:"boolval,omitempty"`
+ // String slice value
+ StrSliceVal []string `json:"strarr,omitempty"`
+ // Account creation data
+ NewAcc *newAccount `json:"newacc,omitempty"`
+}
+
+// Init initializes the handler.
+func (a *authenticator) Init(jsonconf json.RawMessage, name string) error {
+ if name == "" {
+ return errors.New("auth_rest: authenticator name cannot be blank")
+ }
+
+ if a.name != "" {
+ return errors.New("auth_rest: already initialized as " + a.name + "; " + name)
+ }
+
+ type configType struct {
+ // ServerUrl is the URL of the server to call.
+ ServerUrl string `json:"server_url"`
+ // Server may create new accounts.
+ AllowNewAccounts bool `json:"allow_new_accounts"`
+ // Use separate endpoints, i.e. add request name to serverUrl path when making requests.
+ UseSeparateEndpoints bool `json:"use_separate_endpoints"`
+ }
+
+ var config configType
+ err := json.Unmarshal(jsonconf, &config)
+ if err != nil {
+ return errors.New("auth_rest: failed to parse config: " + err.Error() + "(" + string(jsonconf) + ")")
+ }
+
+ serverUrl, err := url.Parse(config.ServerUrl)
+ if err != nil || !serverUrl.IsAbs() {
+ return errors.New("auth_rest: invalid server_url '" + string(jsonconf) + "'")
+ }
+
+ if !strings.HasSuffix(serverUrl.Path, "/") {
+ serverUrl.Path += "/"
+ }
+
+ a.name = name
+ a.serverUrl = serverUrl.String()
+ a.allowNewAccounts = config.AllowNewAccounts
+ a.useSeparateEndpoints = config.UseSeparateEndpoints
+
+ return nil
+}
+
+// IsInitialized returns true if the handler is initialized.
+func (a *authenticator) IsInitialized() bool {
+ return a.name != ""
+}
+
+// Execute HTTP POST to the server at the specified endpoint and with the provided payload.
+func (a *authenticator) callEndpoint(endpoint string, ctx auth.AuthContext, rec *auth.Rec, secret []byte) (*response, error) {
+ // Convert payload to json.
+ req := &request{Endpoint: endpoint, Name: a.name, TenantID: ctx.TenantID, Record: rec,
+ Secret: secret, RemoteAddr: ctx.RemoteAddr}
+ content, err := json.Marshal(req)
+ if err != nil {
+ return nil, err
+ }
+
+ urlToCall := a.serverUrl
+ if a.useSeparateEndpoints {
+ epUrl, _ := url.Parse(a.serverUrl)
+ epUrl.Path += endpoint
+ urlToCall = epUrl.String()
+ }
+
+ // Send payload to server using default HTTP client.
+ post, err := http.Post(urlToCall, "application/json", bytes.NewBuffer(content))
+ if err != nil {
+ return nil, err
+ }
+ defer post.Body.Close()
+
+ // Check HTTP status response. Must be 2xx.
+ if post.StatusCode < http.StatusOK || post.StatusCode >= http.StatusMultipleChoices {
+ return nil, errors.New("unexpected HTTP response " + post.Status)
+ }
+
+ // Read response.
+ body, err := io.ReadAll(post.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ // Parse response.
+ var resp response
+ err = json.Unmarshal(body, &resp)
+ if err != nil {
+ return nil, err
+ }
+
+ if resp.Err != "" {
+ return nil, types.StoreError(resp.Err)
+ }
+
+ return &resp, nil
+}
+
+// AddRecord adds persistent authentication record to the database.
+// Returns: updated auth record, error
+func (a *authenticator) AddRecord(ctx auth.AuthContext, rec *auth.Rec, secret []byte) (*auth.Rec, error) {
+ if err := auth.BindRecord(ctx, rec); err != nil {
+ return nil, err
+ }
+ resp, err := a.callEndpoint("add", ctx, rec, secret)
+ if err != nil {
+ return nil, err
+ }
+ if resp.Record == nil || resp.Record.TenantID != ctx.TenantID {
+ return nil, types.ErrFailed
+ }
+ if err = auth.BindRecord(ctx, resp.Record); err != nil {
+ return nil, err
+ }
+ return resp.Record, nil
+}
+
+// UpdateRecord updates existing record with new credentials.
+func (a *authenticator) UpdateRecord(ctx auth.AuthContext, rec *auth.Rec, secret []byte) (*auth.Rec, error) {
+ if err := auth.BindRecord(ctx, rec); err != nil {
+ return nil, err
+ }
+ _, err := a.callEndpoint("upd", ctx, rec, secret)
+ return rec, err
+}
+
+// Authenticate: get user record by provided secret
+func (a *authenticator) Authenticate(ctx auth.AuthContext, secret []byte) (*auth.Rec, []byte, error) {
+ if !ctx.IsValid() {
+ return nil, nil, types.ErrMalformed
+ }
+ resp, err := a.callEndpoint("auth", ctx, nil, secret)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ // Auth record not found.
+ if resp.Record == nil {
+ logs.Warn.Println("rest_auth: invalid response: missing Record")
+ return nil, nil, types.ErrInternal
+ }
+ if resp.Record.TenantID != ctx.TenantID {
+ return nil, nil, types.ErrFailed
+ }
+ if err = auth.BindRecord(ctx, resp.Record); err != nil {
+ return nil, nil, err
+ }
+
+ // Check if server provided a user ID. If not, create a new account in the local database.
+ if resp.Record.Uid.IsZero() && a.allowNewAccounts {
+ if resp.NewAcc == nil {
+ return nil, nil, types.ErrNotFound
+ }
+
+ // Create account, get UID, report UID back to the server.
+
+ user := types.User{
+ TenantID: ctx.TenantID,
+ State: resp.Record.State,
+ Public: resp.NewAcc.Public,
+ Trusted: resp.NewAcc.Trusted,
+ Tags: resp.Record.Tags,
+ }
+ user.Access.Auth.UnmarshalText([]byte(resp.NewAcc.Auth))
+ user.Access.Anon.UnmarshalText([]byte(resp.NewAcc.Anon))
+ _, err = store.Users.Create(ctx.TenantID, &user, resp.NewAcc.Private)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ // Report the new UID to the server.
+ resp.Record.Uid = user.Uid()
+ _, err = a.callEndpoint("link", ctx, resp.Record, secret)
+ if err != nil {
+ store.Users.Delete(ctx.TenantID, resp.Record.Uid, true)
+ return nil, nil, err
+ }
+ }
+
+ return resp.Record, resp.ByteVal, nil
+}
+
+// AsTag converts search token into prefixed tag or an empty string if it
+// cannot be represented as a prefixed tag.
+func (a *authenticator) AsTag(token string) string {
+ if len(a.rTagNS) > 0 {
+ if a.reToken != nil && !a.reToken.MatchString(token) {
+ return ""
+ }
+ // No validation or passed validation.
+ return a.rTagNS[0] + ":" + token
+ }
+ return ""
+}
+
+// IsUnique verifies if the provided secret can be considered unique by the auth
+// scheme as well as policy compliance. E.g. if login is unique and not too short/long.
+func (a *authenticator) IsUnique(ctx auth.AuthContext, secret []byte) (bool, error) {
+ if !ctx.IsValid() {
+ return false, types.ErrMalformed
+ }
+ resp, err := a.callEndpoint("checkunique", ctx, nil, secret)
+ if err != nil {
+ return false, err
+ }
+
+ return resp.BoolVal, err
+}
+
+// GenSecret generates a new secret, if appropriate.
+func (a *authenticator) GenSecret(rec *auth.Rec) ([]byte, time.Time, error) {
+ if rec == nil || rec.TenantID.IsZero() {
+ return nil, time.Time{}, types.ErrMalformed
+ }
+ resp, err := a.callEndpoint("gen", auth.AuthContext{TenantID: rec.TenantID}, rec, nil)
+ if err != nil {
+ return nil, time.Time{}, err
+ }
+
+ return resp.ByteVal, resp.TimeVal, err
+}
+
+// DelRecords deletes all authentication records for the given user.
+func (a *authenticator) DelRecords(tenantID types.TenantID, uid types.Uid) error {
+ logs.Info.Println("DelRecords, initialized=", a.name != "")
+ ctx := auth.AuthContext{TenantID: tenantID}
+ _, err := a.callEndpoint("del", ctx, &auth.Rec{TenantID: tenantID, Uid: uid}, nil)
+ return err
+}
+
+// RestrictedTags returns tag namespaces (prefixes, such as prefix:login) restricted by the server.
+func (a *authenticator) RestrictedTags() ([]string, error) {
+ if a.rTagNS != nil {
+ // Using cached prefixes.
+ ns := make([]string, len(a.rTagNS))
+ // Returning a copy to prevent accidental modification of server-provided tags.
+ copy(ns, a.rTagNS)
+ return ns, nil
+ }
+
+ // First time use, fetch prefixes from the server.
+ resp, err := a.callEndpoint("rtagns", auth.AuthContext{}, nil, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ // Save valid result to cache.
+ a.rTagNS = resp.StrSliceVal
+ if len(resp.ByteVal) > 0 {
+ a.reToken, err = regexp.Compile(string(resp.ByteVal))
+ if err != nil {
+ logs.Warn.Println("rest_auth: invalid token regexp", string(resp.ByteVal))
+ }
+ }
+ return resp.StrSliceVal, nil
+}
+
+// GetResetParams returns authenticator parameters passed to password reset handler
+// (none for rest).
+func (authenticator) GetResetParams(tenantID types.TenantID, uid types.Uid) (map[string]any, error) {
+ // TODO: route request to the server.
+ return nil, nil
+}
+
+const realName = "rest"
+
+// GetRealName returns the hardcoded name of the authenticator.
+func (authenticator) GetRealName() string {
+ return realName
+}
+
+func init() {
+ store.RegisterAuthScheme(realName, &authenticator{})
+}
diff --git a/server/auth/token/auth_token.go b/server/auth/token/auth_token.go
index e69de29..75d9a35 100644
--- a/server/auth/token/auth_token.go
+++ b/server/auth/token/auth_token.go
@@ -0,0 +1,221 @@
+// Package token implements authentication by HMAC-signed security token.
+package token
+
+import (
+ "bytes"
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/binary"
+ "encoding/json"
+ "errors"
+ "time"
+
+ "github.com/tinode/chat/server/auth"
+ "github.com/tinode/chat/server/store"
+ "github.com/tinode/chat/server/store/types"
+)
+
+// authenticator is a singleton instance of the authenticator.
+type authenticator struct {
+ name string
+ hmacSalt []byte
+ lifetime time.Duration
+ serialNumber int
+}
+
+const tokenVersion = uint8(2)
+
+// tokenLayout defines positioning of various bytes in token v2.
+// [1:version][8:tenant][8:UID][4:expires][2:authLevel][2:serial][2:features][32:signature] = 59 bytes.
+type tokenLayout struct {
+ // Token format version.
+ Version uint8
+ // Tenant ID.
+ TenantID uint64
+ // User ID.
+ Uid uint64
+ // Token expiration time.
+ Expires uint32
+ // User's authentication level.
+ AuthLevel uint16
+ // Serial number - to invalidate all tokens if needed.
+ SerialNumber uint16
+ // Bitmap with feature bits.
+ Features uint16
+}
+
+// Init initializes the authenticator: parses the config and sets salt, serial number and lifetime.
+func (ta *authenticator) Init(jsonconf json.RawMessage, name string) error {
+ if name == "" {
+ return errors.New("auth_token: authenticator name cannot be blank")
+ }
+
+ if ta.name != "" {
+ return errors.New("auth_token: already initialized as " + ta.name + "; " + name)
+ }
+
+ type configType struct {
+ // Key for signing tokens
+ Key []byte `json:"key"`
+ // Datatabase or other serial number, to invalidate all issued tokens at once.
+ SerialNum int `json:"serial_num"`
+ // Token expiration time
+ ExpireIn int `json:"expire_in"`
+ }
+ var config configType
+ if err := json.Unmarshal(jsonconf, &config); err != nil {
+ return errors.New("auth_token: failed to parse config: " + err.Error() + "(" + string(jsonconf) + ")")
+ }
+
+ if len(config.Key) < sha256.Size {
+ return errors.New("auth_token: the key is missing or too short")
+ }
+ if config.ExpireIn <= 0 {
+ return errors.New("auth_token: invalid expiration value")
+ }
+
+ ta.name = name
+ ta.hmacSalt = config.Key
+ ta.lifetime = time.Duration(config.ExpireIn) * time.Second
+ ta.serialNumber = config.SerialNum
+
+ return nil
+}
+
+// IsInitialized returns true if the handler is initialized.
+func (ta *authenticator) IsInitialized() bool {
+ return ta.name != ""
+}
+
+// AddRecord is not supported, will produce an error.
+func (authenticator) AddRecord(ctx auth.AuthContext, rec *auth.Rec, secret []byte) (*auth.Rec, error) {
+ return nil, types.ErrUnsupported
+}
+
+// UpdateRecord is not supported, will produce an error.
+func (authenticator) UpdateRecord(ctx auth.AuthContext, rec *auth.Rec, secret []byte) (*auth.Rec, error) {
+ return nil, types.ErrUnsupported
+}
+
+// Authenticate checks validity of provided token.
+func (ta *authenticator) Authenticate(ctx auth.AuthContext, token []byte) (*auth.Rec, []byte, error) {
+ var tl tokenLayout
+ dataSize := binary.Size(&tl)
+ if len(token) != dataSize+sha256.Size {
+ return nil, nil, types.ErrMalformed
+ }
+
+ buf := bytes.NewBuffer(token)
+ err := binary.Read(buf, binary.LittleEndian, &tl)
+ if err != nil {
+ return nil, nil, types.ErrMalformed
+ }
+ if tl.Version != tokenVersion || tl.TenantID == 0 {
+ return nil, nil, types.ErrFailed
+ }
+ if ctx.IsValid() && types.TenantID(tl.TenantID) != ctx.TenantID {
+ return nil, nil, types.ErrFailed
+ }
+
+ hbuf := new(bytes.Buffer)
+ binary.Write(hbuf, binary.LittleEndian, &tl)
+
+ // Check signature.
+ hasher := hmac.New(sha256.New, ta.hmacSalt)
+ hasher.Write(hbuf.Bytes())
+ if !hmac.Equal(token[dataSize:dataSize+sha256.Size], hasher.Sum(nil)) {
+ return nil, nil, types.ErrFailed
+ }
+
+ // Check authentication level for validity.
+ if auth.Level(tl.AuthLevel) > auth.LevelRoot {
+ return nil, nil, types.ErrMalformed
+ }
+
+ // Check serial number.
+ if int(tl.SerialNumber) != ta.serialNumber {
+ return nil, nil, types.ErrFailed
+ }
+
+ // Check token expiration time.
+ expires := time.Unix(int64(tl.Expires), 0).UTC()
+ if expires.Before(time.Now().Add(1 * time.Second)) {
+ return nil, nil, types.ErrExpired
+ }
+
+ return &auth.Rec{
+ TenantID: types.TenantID(tl.TenantID),
+ Uid: types.Uid(tl.Uid),
+ AuthLevel: auth.Level(tl.AuthLevel),
+ Lifetime: auth.Duration(time.Until(expires)),
+ Features: auth.Feature(tl.Features),
+ State: types.StateUndefined}, nil, nil
+}
+
+// GenSecret generates a new token.
+func (ta *authenticator) GenSecret(rec *auth.Rec) ([]byte, time.Time, error) {
+ if rec == nil || rec.TenantID.IsZero() || rec.Uid.IsZero() {
+ return nil, time.Time{}, types.ErrMalformed
+ }
+
+ if rec.Lifetime == 0 {
+ rec.Lifetime = auth.Duration(ta.lifetime)
+ } else if rec.Lifetime < 0 {
+ return nil, time.Time{}, types.ErrExpired
+ }
+ expires := time.Now().Add(time.Duration(rec.Lifetime)).UTC().Round(time.Millisecond)
+
+ tl := tokenLayout{
+ Version: tokenVersion,
+ TenantID: uint64(rec.TenantID),
+ Uid: uint64(rec.Uid),
+ Expires: uint32(expires.Unix()),
+ AuthLevel: uint16(rec.AuthLevel),
+ SerialNumber: uint16(ta.serialNumber),
+ Features: uint16(rec.Features),
+ }
+ buf := new(bytes.Buffer)
+ binary.Write(buf, binary.LittleEndian, &tl)
+ hasher := hmac.New(sha256.New, ta.hmacSalt)
+ hasher.Write(buf.Bytes())
+ binary.Write(buf, binary.LittleEndian, hasher.Sum(nil))
+
+ return buf.Bytes(), expires, nil
+}
+
+// AsTag is not supported, will produce an empty string.
+func (authenticator) AsTag(token string) string {
+ return ""
+}
+
+// IsUnique is not supported, will produce an error.
+func (authenticator) IsUnique(ctx auth.AuthContext, token []byte) (bool, error) {
+ return false, types.ErrUnsupported
+}
+
+// DelRecords adds disabled user ID to a stop list.
+func (authenticator) DelRecords(tenantID types.TenantID, uid types.Uid) error {
+ return nil
+}
+
+// RestrictedTags returns tag namespaces restricted by this authenticator (none for token).
+func (authenticator) RestrictedTags() ([]string, error) {
+ return nil, nil
+}
+
+// GetResetParams returns authenticator parameters passed to password reset handler
+// (none for token).
+func (authenticator) GetResetParams(tenantID types.TenantID, uid types.Uid) (map[string]any, error) {
+ return nil, nil
+}
+
+const realName = "token"
+
+// GetRealName returns the hardcoded name of the authenticator.
+func (authenticator) GetRealName() string {
+ return realName
+}
+
+func init() {
+ store.RegisterAuthScheme(realName, &authenticator{})
+}
diff --git a/server/auth/token/auth_token_test.go b/server/auth/token/auth_token_test.go
new file mode 100644
index 0000000..d38bebb
--- /dev/null
+++ b/server/auth/token/auth_token_test.go
@@ -0,0 +1,95 @@
+package token
+
+import (
+ "encoding/json"
+ "testing"
+ "time"
+
+ "github.com/tinode/chat/server/auth"
+ "github.com/tinode/chat/server/store/types"
+)
+
+func newTestAuthenticator(t *testing.T) *authenticator {
+ t.Helper()
+ conf, err := json.Marshal(map[string]any{
+ "key": []byte("0123456789abcdef0123456789abcdef"),
+ "serial_num": 3,
+ "expire_in": 3600,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ ta := &authenticator{}
+ if err = ta.Init(conf, "token-test"); err != nil {
+ t.Fatal(err)
+ }
+ return ta
+}
+
+func TestTokenV2TenantRoundTrip(t *testing.T) {
+ ta := newTestAuthenticator(t)
+ rec := &auth.Rec{
+ TenantID: 7,
+ Uid: 42,
+ AuthLevel: auth.LevelAuth,
+ Lifetime: auth.Duration(time.Hour),
+ Features: auth.FeatureValidated,
+ }
+ token, _, err := ta.GenSecret(rec)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, want := len(token), 59; got != want {
+ t.Fatalf("token length: got %d, want %d", got, want)
+ }
+
+ got, challenge, err := ta.Authenticate(auth.AuthContext{TenantID: 7}, token)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if challenge != nil {
+ t.Fatal("unexpected challenge")
+ }
+ if got.TenantID != rec.TenantID || got.Uid != rec.Uid || got.AuthLevel != rec.AuthLevel || got.Features != rec.Features {
+ t.Fatalf("round trip mismatch: got %+v, want %+v", got, rec)
+ }
+
+ // HTTP authentication may recover the tenant from the signed token.
+ got, _, err = ta.Authenticate(auth.AuthContext{}, token)
+ if err != nil || got.TenantID != rec.TenantID {
+ t.Fatalf("tenant recovery failed: rec=%+v err=%v", got, err)
+ }
+}
+
+func TestTokenV2RejectsWrongOrTamperedTenant(t *testing.T) {
+ ta := newTestAuthenticator(t)
+ token, _, err := ta.GenSecret(&auth.Rec{
+ TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth, Lifetime: auth.Duration(time.Hour),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if _, _, err = ta.Authenticate(auth.AuthContext{TenantID: 8}, token); err != types.ErrFailed {
+ t.Fatalf("wrong tenant: got %v, want %v", err, types.ErrFailed)
+ }
+
+ tampered := append([]byte(nil), token...)
+ tampered[1] ^= 1 // First byte of tenant_id in the little-endian payload.
+ if _, _, err = ta.Authenticate(auth.AuthContext{}, tampered); err != types.ErrFailed {
+ t.Fatalf("tampered tenant: got %v, want %v", err, types.ErrFailed)
+ }
+}
+
+func TestTokenV2RejectsLegacyAndMissingIdentity(t *testing.T) {
+ ta := newTestAuthenticator(t)
+ if _, _, err := ta.Authenticate(auth.AuthContext{}, make([]byte, 50)); err != types.ErrMalformed {
+ t.Fatalf("legacy token: got %v, want %v", err, types.ErrMalformed)
+ }
+ if _, _, err := ta.GenSecret(&auth.Rec{Uid: 42, AuthLevel: auth.LevelAuth}); err != types.ErrMalformed {
+ t.Fatalf("missing tenant: got %v, want %v", err, types.ErrMalformed)
+ }
+ if _, _, err := ta.GenSecret(&auth.Rec{TenantID: 7, AuthLevel: auth.LevelAuth}); err != types.ErrMalformed {
+ t.Fatalf("missing uid: got %v, want %v", err, types.ErrMalformed)
+ }
+}
diff --git a/server/calls.go b/server/calls.go
index e69de29..9c7b110 100644
--- a/server/calls.go
+++ b/server/calls.go
@@ -0,0 +1,455 @@
+/******************************************************************************
+ *
+ * Description :
+ * Video call handling (establishment, metadata exhange and termination).
+ *
+ *****************************************************************************/
+package main
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "strconv"
+ "time"
+
+ "github.com/tinode/chat/server/logs"
+ "github.com/tinode/chat/server/store/types"
+ jcr "github.com/tinode/jsonco"
+)
+
+// Video call constants.
+const (
+ // Events for call between users A and B.
+ //
+ // B has received the call but hasn't picked it up yet.
+ constCallEventRinging = "ringing"
+ // B has accepted the call.
+ constCallEventAccept = "accept"
+ // WebRTC SDP & ICE data exchange events.
+ constCallEventOffer = "offer"
+ constCallEventAnswer = "answer"
+ constCallEventIceCandidate = "ice-candidate"
+ // Call finished by either side or server.
+ constCallEventHangUp = "hang-up"
+
+ // Message headers representing call states.
+ // Call is established.
+ constCallMsgAccepted = "accepted"
+ // Previously establied call has successfully finished.
+ constCallMsgFinished = "finished"
+ // Call is dropped (e.g. because of an error).
+ constCallMsgDisconnected = "disconnected"
+ // Call is missed (the callee didn't pick up the phone).
+ constCallMsgMissed = "missed"
+ // Call is declined (the callee hung up before picking up).
+ constCallMsgDeclined = "declined"
+)
+
+type callConfig struct {
+ // Enable video/voice calls.
+ Enabled bool `json:"enabled"`
+ // Timeout in seconds before a call is dropped if not answered.
+ CallEstablishmentTimeout int `json:"call_establishment_timeout"`
+ // ICE servers.
+ ICEServers []iceServer `json:"ice_servers"`
+ // Alternative config as an external file.
+ ICEServersFile string `json:"ice_servers_file"`
+}
+
+// ICE server config.
+type iceServer struct {
+ Username string `json:"username,omitempty"`
+ Credential string `json:"credential,omitempty"`
+ CredentialType string `json:"credential_type,omitempty"`
+ Urls []string `json:"urls,omitempty"`
+}
+
+// callPartyData describes a video call participant.
+type callPartyData struct {
+ // ID of the call participant (asUid); not necessarily the session owner.
+ uid types.Uid
+ // True if this session/user initiated the call.
+ isOriginator bool
+ // Call party session.
+ sess *Session
+}
+
+// videoCall describes video call that's being established or in progress.
+type videoCall struct {
+ // Call participants (session sid -> callPartyData).
+ parties map[string]callPartyData
+ // Call message seq ID.
+ seq int
+ // Call message content.
+ content any
+ // Call message content mime type.
+ contentMime any
+ // Time when the call was accepted.
+ acceptedAt time.Time
+}
+
+// callPartySession returns a session to be stored in the call party data.
+func callPartySession(sess *Session) *Session {
+ if sess.isProxy() {
+ // We are on the topic host node. Make a copy of the ephemeral proxy session.
+ callSess := &Session{
+ proto: PROXY,
+ // Multiplexing session which actually handles the communication.
+ multi: sess.multi,
+ // Local parameters specific to this session.
+ sid: sess.sid,
+ userAgent: sess.userAgent,
+ remoteAddr: sess.remoteAddr,
+ lang: sess.lang,
+ countryCode: sess.countryCode,
+ proxyReq: ProxyReqCall,
+ background: sess.background,
+ uid: sess.uid,
+ }
+ return callSess
+ }
+ return sess
+}
+
+func initVideoCalls(jsconfig json.RawMessage) error {
+ var config callConfig
+
+ if len(jsconfig) == 0 {
+ return nil
+ }
+
+ if err := json.Unmarshal([]byte(jsconfig), &config); err != nil {
+ return fmt.Errorf("failed to parse config: %w", err)
+ }
+
+ if !config.Enabled {
+ logs.Info.Println("Video calls disabled")
+ return nil
+ }
+
+ if len(config.ICEServers) > 0 {
+ globals.iceServers = config.ICEServers
+ } else if config.ICEServersFile != "" {
+ var iceConfig []iceServer
+ file, err := os.Open(config.ICEServersFile)
+ if err != nil {
+ return fmt.Errorf("failed to read ICE config: %w", err)
+ }
+
+ jr := jcr.New(file)
+ if err = json.NewDecoder(jr).Decode(&iceConfig); err != nil {
+ switch jerr := err.(type) {
+ case *json.UnmarshalTypeError:
+ lnum, cnum, _ := jr.LineAndChar(jerr.Offset)
+ return fmt.Errorf("unmarshall error in ICE config in %s at %d:%d (offset %d bytes): %w",
+ jerr.Field, lnum, cnum, jerr.Offset, jerr)
+ case *json.SyntaxError:
+ lnum, cnum, _ := jr.LineAndChar(jerr.Offset)
+ return fmt.Errorf("syntax error in config file at %d:%d (offset %d bytes): %w",
+ lnum, cnum, jerr.Offset, jerr)
+ default:
+ return fmt.Errorf("failed to parse config file: %w", err)
+ }
+ }
+ file.Close()
+
+ globals.iceServers = iceConfig
+ }
+
+ if len(globals.iceServers) == 0 {
+ return errors.New("no valid ICE cervers found")
+ }
+
+ globals.callEstablishmentTimeout = config.CallEstablishmentTimeout
+ if globals.callEstablishmentTimeout <= 0 {
+ globals.callEstablishmentTimeout = defaultCallEstablishmentTimeout
+ }
+
+ logs.Info.Println("Video calls enabled with", len(globals.iceServers), "ICE servers")
+ return nil
+}
+
+// Add webRTC-related headers to message Head. The original Head may already contain some entries,
+// like 'sender', preserve them.
+func (call *videoCall) messageHead(head map[string]any, newState string, duration int) map[string]any {
+ if head == nil {
+ head = map[string]any{}
+ }
+
+ head["replace"] = ":" + strconv.Itoa(call.seq)
+ head["webrtc"] = newState
+
+ if duration > 0 {
+ head["webrtc-duration"] = duration
+ } else {
+ delete(head, "webrtc-duration")
+ }
+ if call.contentMime != nil {
+ head["mime"] = call.contentMime
+ }
+ return head
+}
+
+// Generates server info message template for the video call event.
+func (call *videoCall) infoMessage(event string) *ServerComMessage {
+ return &ServerComMessage{
+ Info: &MsgServerInfo{
+ What: "call",
+ Event: event,
+ SeqId: call.seq,
+ },
+ }
+}
+
+// Returns Uid and session of the present video call originator
+// if a call is being established or in progress.
+func (t *Topic) getCallOriginator() (types.Uid, *Session) {
+ if t.currentCall == nil {
+ return types.ZeroUid, nil
+ }
+ for _, p := range t.currentCall.parties {
+ if p.isOriginator {
+ return p.uid, p.sess
+ }
+ }
+ return types.ZeroUid, nil
+}
+
+// Handles video call invite (initiation)
+// (in response to msg = {pub head=[mime: application/x-tinode-webrtc]}).
+func (t *Topic) handleCallInvite(msg *ClientComMessage, asUid types.Uid) {
+ // Call being establshed.
+ t.currentCall = &videoCall{
+ parties: make(map[string]callPartyData),
+ seq: t.lastID,
+ content: msg.Pub.Content,
+ contentMime: msg.Pub.Head["mime"],
+ }
+ t.currentCall.parties[msg.sess.sid] = callPartyData{
+ uid: asUid,
+ isOriginator: true,
+ sess: callPartySession(msg.sess),
+ }
+ // Wait for constCallEstablishmentTimeout for the other side to accept the call.
+ t.callEstablishmentTimer.Reset(time.Duration(globals.callEstablishmentTimeout) * time.Second)
+}
+
+// Handles events on existing video call (acceptance, termination, metadata exchange).
+// (in response to msg = {note what=call}).
+func (t *Topic) handleCallEvent(msg *ClientComMessage) {
+ if t.currentCall == nil {
+ // Must initiate call first.
+ logs.Warn.Printf("topic[%s]: No call in progress", t.name)
+ return
+ }
+ if t.isInactive() {
+ // Topic is paused or being deleted.
+ return
+ }
+
+ call := msg.Note
+ if t.currentCall.seq != call.SeqId {
+ // Call not found.
+ logs.Info.Printf("topic[%s]: invalid seq id - current call (%d) vs received (%d)", t.name, t.currentCall.seq, call.SeqId)
+ return
+ }
+
+ asUid := types.ParseUserId(msg.AsUser)
+
+ if _, userFound := t.perUser[asUid]; !userFound {
+ // User not found in topic.
+ logs.Warn.Printf("topic[%s]: could not find user %s", t.name, asUid.UserId())
+ return
+ }
+
+ switch call.Event {
+ case constCallEventRinging, constCallEventAccept:
+ // Invariants:
+ // 1. Call has been initiated but not been established yet.
+ if len(t.currentCall.parties) != 1 {
+ return
+ }
+ originatorUid, originator := t.getCallOriginator()
+ if originator == nil {
+ // No originator session: terminating.
+ t.terminateCallInProgress(false)
+ return
+ }
+ // 2. These events may only arrive from the callee.
+ if originator.sid == msg.sess.sid || originatorUid == asUid {
+ return
+ }
+ // Prepare a {info} message to forward to the call originator.
+ forwardMsg := t.currentCall.infoMessage(call.Event)
+ forwardMsg.Info.From = msg.AsUser
+ forwardMsg.Info.Topic = t.original(originatorUid)
+ if call.Event == constCallEventAccept {
+ // The call has been accepted.
+ // Send a replacement {data} message to the topic.
+ msgCopy := *msg
+ msgCopy.AsUser = originatorUid.UserId()
+ replaceWith := constCallMsgAccepted
+ var origHead map[string]any
+ if msgCopy.Pub != nil {
+ origHead = msgCopy.Pub.Head
+ } // else fetch the original message from store and use its head.
+ head := t.currentCall.messageHead(origHead, replaceWith, 0)
+ if err := t.saveAndBroadcastMessage(&msgCopy, originatorUid, false, nil,
+ head, t.currentCall.content); err != nil {
+ return
+ }
+ // Add callee data to t.currentCall.
+ t.currentCall.parties[msg.sess.sid] = callPartyData{
+ uid: asUid,
+ isOriginator: false,
+ sess: callPartySession(msg.sess),
+ }
+ t.currentCall.acceptedAt = time.Now()
+
+ // Notify other clients that the call has been accepted.
+ t.infoCallSubsOffline(msg.AsUser, asUid, call.Event, t.currentCall.seq, call.Payload, msg.sess.sid, false)
+ t.callEstablishmentTimer.Stop()
+ }
+ originator.queueOut(forwardMsg)
+
+ case constCallEventOffer, constCallEventAnswer, constCallEventIceCandidate:
+ // Invariants:
+ // 1. Call has been estabslied (2 participants).
+ if len(t.currentCall.parties) != 2 {
+ logs.Warn.Printf("topic[%s]: call participants expected 2 vs found %d", t.name, len(t.currentCall.parties))
+ return
+ }
+ // 2. Event is coming from a call participant session.
+ if _, ok := t.currentCall.parties[msg.sess.sid]; !ok {
+ logs.Warn.Printf("topic[%s]: call event from non-party session %s", t.name, msg.sess.sid)
+ return
+ }
+ // Call metadata exchange. Either side of the call may send these events.
+ // Simply forward them to the other session.
+ var otherUid types.Uid
+ var otherEnd *Session
+ for sid, p := range t.currentCall.parties {
+ if sid != msg.sess.sid {
+ otherUid = p.uid
+ otherEnd = p.sess
+ break
+ }
+ }
+ if otherEnd == nil {
+ logs.Warn.Printf("topic[%s]: could not find call peer for session %s", t.name, msg.sess.sid)
+ return
+ }
+ // All is good. Send {info} message to the otherEnd.
+ forwardMsg := t.currentCall.infoMessage(call.Event)
+ forwardMsg.Info.From = msg.AsUser
+ forwardMsg.Info.Topic = t.original(otherUid)
+ forwardMsg.Info.Payload = call.Payload
+ otherEnd.queueOut(forwardMsg)
+
+ case constCallEventHangUp:
+ switch len(t.currentCall.parties) {
+ case 2:
+ // If it's a call in progress, hangup may arrive only from a call participant session.
+ if _, ok := t.currentCall.parties[msg.sess.sid]; !ok {
+ return
+ }
+ case 1:
+ // Call hasn't been established yet.
+ originatorUid, originator := t.getCallOriginator()
+ // Hangup may come from either the originating session or
+ // any callee user session.
+ if asUid == originatorUid && originator.sid != msg.sess.sid {
+ return
+ }
+ default:
+ break
+ }
+ t.maybeEndCallInProgress(msg.AsUser, msg, false)
+
+ default:
+ logs.Warn.Printf("topic[%s]: video call (seq %d) received unexpected call event: %s", t.name, t.currentCall.seq, call.Event)
+ }
+}
+
+// Ends current call in response to a client hangup request (msg).
+func (t *Topic) maybeEndCallInProgress(from string, msg *ClientComMessage, callDidTimeout bool) {
+ if t.currentCall == nil {
+ return
+ }
+ t.callEstablishmentTimer.Stop()
+ originatorUid, _ := t.getCallOriginator()
+ var replaceWith string
+ var callDuration int64
+ if from != "" && len(t.currentCall.parties) == 2 {
+ // This is a call in progress.
+ replaceWith = constCallMsgFinished
+ callDuration = time.Since(t.currentCall.acceptedAt).Milliseconds()
+ } else {
+ if from != "" {
+ // User originated hang-up.
+ if from == originatorUid.UserId() {
+ // Originator/caller requested event.
+ replaceWith = constCallMsgMissed
+ } else {
+ // Callee requested event.
+ replaceWith = constCallMsgDeclined
+ }
+ } else {
+ // Server initiated disconnect.
+ // Call hasn't been established. Just drop it.
+ if callDidTimeout {
+ replaceWith = constCallMsgMissed
+ } else {
+ replaceWith = constCallMsgDisconnected
+ }
+ }
+ }
+
+ // Send a message indicating the call has ended.
+ msgCopy := *msg
+ msgCopy.AsUser = originatorUid.UserId()
+ var origHead map[string]any
+ if msgCopy.Pub != nil {
+ origHead = msgCopy.Pub.Head
+ } // else fetch the original message from store and use its head.
+ head := t.currentCall.messageHead(origHead, replaceWith, int(callDuration))
+ if err := t.saveAndBroadcastMessage(&msgCopy, originatorUid, false, nil, head, t.currentCall.content); err != nil {
+ logs.Err.Printf("topic[%s]: failed to write finalizing message for call seq id %d - '%s'", t.name, t.currentCall.seq, err)
+ }
+
+ // Send {info} hangup event to the subscribed sessions.
+ t.broadcastToSessions(t.currentCall.infoMessage(constCallEventHangUp))
+
+ // Let all other sessions know the call is over.
+ for tgt := range t.perUser {
+ t.infoCallSubsOffline(from, tgt, constCallEventHangUp, t.currentCall.seq, nil, "", true)
+ }
+ t.currentCall = nil
+}
+
+// Server initiated call termination.
+func (t *Topic) terminateCallInProgress(callDidTimeout bool) {
+ if t.currentCall == nil {
+ return
+ }
+ uid, sess := t.getCallOriginator()
+ if sess == nil || uid.IsZero() {
+ // Just drop the call.
+ logs.Warn.Printf("topic[%s]: video call seq %d has no originator, terminating.", t.name, t.currentCall.seq)
+ t.currentCall = nil
+ return
+ }
+ // Dummy hangup request.
+ dummy := &ClientComMessage{
+ Original: t.original(uid),
+ RcptTo: uid.UserId(),
+ AsUser: uid.UserId(),
+ Timestamp: types.TimeNow(),
+ sess: sess,
+ }
+
+ logs.Info.Printf("topic[%s]: terminating call seq %d, timeout: %t", t.name, t.currentCall.seq, callDidTimeout)
+ t.maybeEndCallInProgress("", dummy, callDidTimeout)
+}
diff --git a/server/cluster.go b/server/cluster.go
index 5552978..49f6e7d 100644
--- a/server/cluster.go
+++ b/server/cluster.go
@@ -122,6 +122,7 @@ func (n *ClusterNode) p2mSenderLoop() {
// ClusterSess is a basic info on a remote session where the message was created.
type ClusterSess struct {
+ TenantID types.TenantID
// IP address of the client. For long polling this is the IP of the last poll
RemoteAddr string
@@ -158,6 +159,7 @@ type ClusterSess struct {
// ClusterSessUpdate represents a request to update a session.
// User Agent change or background session comes to foreground.
type ClusterSessUpdate struct {
+ TenantID types.TenantID
// User this session represents.
Uid types.Uid
// Session id.
@@ -168,6 +170,7 @@ type ClusterSessUpdate struct {
// ClusterReq is either a Proxy to Master or Topic Proxy to Topic Master or intra-cluster routing request message.
type ClusterReq struct {
+ TenantID types.TenantID
// Name of the node sending this request
Node string
@@ -198,6 +201,7 @@ type ClusterReq struct {
// ClusterRoute is intra-cluster routing request message.
type ClusterRoute struct {
+ TenantID types.TenantID
// Name of the node sending this request
Node string
@@ -219,6 +223,7 @@ type ClusterRoute struct {
// ClusterResp is a Master to Proxy response message.
type ClusterResp struct {
+ TenantID types.TenantID
// Server message with the response.
SrvMsg *ServerComMessage
// Originating session ID to forward response to, if any.
@@ -468,6 +473,12 @@ func (n *ClusterNode) stopMultiplexingSession(msess *Session) {
// TopicMaster is a gRPC endpoint which receives requests sent by proxy topic to master topic.
func (c *Cluster) TopicMaster(msg *ClusterReq, rejected *bool) error {
*rejected = false
+ if !msg.TenantID.IsValid() || (msg.CliMsg != nil && msg.CliMsg.TenantID != msg.TenantID) ||
+ (msg.Sess != nil && msg.Sess.TenantID != msg.TenantID) {
+ logs.Warn.Println("cluster TopicMaster: invalid tenant context", msg.TenantID)
+ *rejected = true
+ return nil
+ }
node := c.nodes[msg.Node]
if node == nil {
@@ -487,7 +498,7 @@ func (c *Cluster) TopicMaster(msg *ClusterReq, rejected *bool) error {
msid = msg.RcptTo
}
// Append node name.
- msid += "-" + msg.Node
+ msid = (types.TopicKey{TenantID: msg.TenantID, Topic: msid}).RoutingKey() + "-" + msg.Node
msess := globals.sessionStore.Get(msid)
if msg.Gone {
@@ -495,9 +506,10 @@ func (c *Cluster) TopicMaster(msg *ClusterReq, rejected *bool) error {
// If it was the last session, master topic will shut down as well.
node.stopMultiplexingSession(msess)
- if t := globals.hub.topicGet(msg.RcptTo); t != nil && t.isChan {
+ key := types.TopicKey{TenantID: msg.TenantID, Topic: msg.RcptTo}
+ if t := globals.hub.topicGet(key); t != nil && t.isChan {
// If it's a channel topic, also stop the "chnX-" local auxiliary session.
- msidChn := types.GrpToChn(t.name) + "-" + msg.Node
+ msidChn := (types.TopicKey{TenantID: msg.TenantID, Topic: types.GrpToChn(t.name)}).RoutingKey() + "-" + msg.Node
node.stopMultiplexingSession(globals.sessionStore.Get(msidChn))
}
@@ -521,6 +533,7 @@ func (c *Cluster) TopicMaster(msg *ClusterReq, rejected *bool) error {
logs.Info.Println("cluster: multiplexing session started", msid, count)
msess.proxiedTopic = msg.RcptTo
+ msess.tenantID = msg.TenantID
}
// This is a local copy of a remote session.
@@ -529,7 +542,8 @@ func (c *Cluster) TopicMaster(msg *ClusterReq, rejected *bool) error {
if msg.Sess != nil {
// We only need some session info. No need to copy everything.
sess = &Session{
- proto: PROXY,
+ proto: PROXY,
+ tenantID: msg.TenantID,
// Multiplexing session which actually handles the communication.
multi: msess,
// Local parameters specific to this session.
@@ -561,14 +575,14 @@ func (c *Cluster) TopicMaster(msg *ClusterReq, rejected *bool) error {
}
case ProxyReqLeave:
- if t := globals.hub.topicGet(msg.RcptTo); t != nil {
+ if t := globals.hub.topicGet(types.TopicKey{TenantID: msg.TenantID, Topic: msg.RcptTo}); t != nil {
t.unreg <- msg.CliMsg
} else {
logs.Warn.Println("cluster: leave request for unknown topic", msg.RcptTo)
}
case ProxyReqMeta:
- if t := globals.hub.topicGet(msg.RcptTo); t != nil {
+ if t := globals.hub.topicGet(types.TopicKey{TenantID: msg.TenantID, Topic: msg.RcptTo}); t != nil {
select {
case t.meta <- msg.CliMsg:
default:
@@ -589,7 +603,7 @@ func (c *Cluster) TopicMaster(msg *ClusterReq, rejected *bool) error {
case ProxyReqBgSession, ProxyReqMeUserAgent:
// sess could be nil
- if t := globals.hub.topicGet(msg.RcptTo); t != nil {
+ if t := globals.hub.topicGet(types.TopicKey{TenantID: msg.TenantID, Topic: msg.RcptTo}); t != nil {
if t.supd == nil {
logs.Err.Panicln("cluster: invalid topic category in session update", t.name, msg.ReqType)
}
@@ -614,9 +628,13 @@ func (c *Cluster) TopicMaster(msg *ClusterReq, rejected *bool) error {
// TopicProxy is a gRPC endpoint at topic proxy which receives topic master responses.
func (Cluster) TopicProxy(msg *ClusterResp, unused *bool) error {
+ if !msg.TenantID.IsValid() || msg.SrvMsg == nil || msg.SrvMsg.TenantID != msg.TenantID {
+ logs.Warn.Println("cluster TopicProxy: invalid tenant context", msg.TenantID)
+ return nil
+ }
// This cluster member received a response from the topic master to be forwarded to the topic.
// Find appropriate topic, send the message to it.
- if t := globals.hub.topicGet(msg.RcptTo); t != nil {
+ if t := globals.hub.topicGet(types.TopicKey{TenantID: msg.TenantID, Topic: msg.RcptTo}); t != nil {
msg.SrvMsg.uid = types.ParseUserId(msg.SrvMsg.AsUser)
select {
case t.proxy <- msg:
@@ -653,6 +671,11 @@ func (c *Cluster) Route(msg *ClusterRoute, rejected *bool) error {
logError("cluster Route: nil server message")
return nil
}
+ if !msg.TenantID.IsValid() || msg.SrvMsg.TenantID != msg.TenantID ||
+ (msg.Sess != nil && msg.Sess.TenantID != msg.TenantID) {
+ logError("cluster Route: invalid tenant context")
+ return nil
+ }
select {
case globals.hub.routeSrv <- msg.SrvMsg:
@@ -671,7 +694,7 @@ func (c *Cluster) UserCacheUpdate(msg *UserCacheReq, rejected *bool) error {
// User is deleted. Evict all user's sessions.
globals.sessionStore.EvictUser(msg.UserId, "")
- if globals.cluster.isRemoteTopic(msg.UserId.UserId()) {
+ if globals.cluster.isRemoteTopic(types.TopicKey{TenantID: msg.TenantID, Topic: msg.UserId.UserId()}) {
// No need to delete user's cache if user is remote.
return nil
}
@@ -711,16 +734,18 @@ func (c *Cluster) routeUserReq(req *UserCacheReq) error {
if req.PushRcpt != nil {
// Request to send push notifications. Create separate packets for each affected cluster node.
for uid, recipient := range req.PushRcpt.To {
- n := c.nodeForTopic(uid.UserId())
+ n := c.nodeForTopic(types.TopicKey{TenantID: req.TenantID, Topic: uid.UserId()})
if n == nil {
return errors.New("attempt to update user at a non-existent node (1)")
}
r := reqByNode[n.name]
if r == nil {
r = &UserCacheReq{
+ TenantID: req.TenantID,
PushRcpt: &push.Receipt{
- Payload: req.PushRcpt.Payload,
- To: make(map[types.Uid]push.Recipient),
+ TenantID: req.TenantID,
+ Payload: req.PushRcpt.Payload,
+ To: make(map[types.Uid]push.Recipient),
},
Node: c.thisNodeName,
}
@@ -731,20 +756,20 @@ func (c *Cluster) routeUserReq(req *UserCacheReq) error {
} else if len(req.UserIdList) > 0 {
// Request to add/remove some users from cache.
for _, uid := range req.UserIdList {
- n := c.nodeForTopic(uid.UserId())
+ n := c.nodeForTopic(types.TopicKey{TenantID: req.TenantID, Topic: uid.UserId()})
if n == nil {
return errors.New("attempt to update user at a non-existent node (2)")
}
r := reqByNode[n.name]
if r == nil {
- r = &UserCacheReq{Node: c.thisNodeName, Inc: req.Inc}
+ r = &UserCacheReq{TenantID: req.TenantID, Node: c.thisNodeName, Inc: req.Inc}
}
r.UserIdList = append(r.UserIdList, uid)
reqByNode[n.name] = r
}
} else if req.Gone {
// Message that the user is deleted is sent to all nodes.
- r := &UserCacheReq{Node: c.thisNodeName, UserIdList: req.UserIdList, Gone: true}
+ r := &UserCacheReq{TenantID: req.TenantID, Node: c.thisNodeName, UserIdList: req.UserIdList, Gone: true}
for _, n := range c.nodes {
reqByNode[n.name] = r
}
@@ -766,7 +791,7 @@ func (c *Cluster) routeUserReq(req *UserCacheReq) error {
}
// Update to cached values.
- n := c.nodeForTopic(req.UserId.UserId())
+ n := c.nodeForTopic(types.TopicKey{TenantID: req.TenantID, Topic: req.UserId.UserId()})
if n == nil {
return errors.New("attempt to update user at a non-existent node (3)")
}
@@ -780,32 +805,35 @@ func (c *Cluster) routeUserReq(req *UserCacheReq) error {
}
// Given topic name, find appropriate cluster node to route message to.
-func (c *Cluster) nodeForTopic(topic string) *ClusterNode {
- key := c.ring.Get(topic)
- if key == c.thisNodeName {
+func (c *Cluster) nodeForTopic(topic types.TopicKey) *ClusterNode {
+ if !topic.IsValid() {
+ return nil
+ }
+ nodeName := c.ring.Get(topic.RoutingKey())
+ if nodeName == c.thisNodeName {
logs.Err.Println("cluster: request to route to self")
// Do not route to self
return nil
}
- node := c.nodes[key]
+ node := c.nodes[nodeName]
if node == nil {
- logs.Warn.Println("cluster: no node for topic", topic, key)
+ logs.Warn.Println("cluster: no node for topic", topic.RoutingKey(), nodeName)
}
return node
}
// isRemoteTopic checks if the given topic is handled by this node or a remote node.
-func (c *Cluster) isRemoteTopic(topic string) bool {
+func (c *Cluster) isRemoteTopic(topic types.TopicKey) bool {
if c == nil {
// Cluster not initialized, all topics are local
return false
}
- return c.ring.Get(topic) != c.thisNodeName
+ return topic.IsValid() && c.ring.Get(topic.RoutingKey()) != c.thisNodeName
}
// genLocalTopicName is just like genTopicName(), but the generated name belongs to the current cluster node.
-func (c *Cluster) genLocalTopicName() string {
+func (c *Cluster) genLocalTopicName(tenantID types.TenantID) string {
topic := genTopicName()
if c == nil {
// Cluster not initialized, all topics are local
@@ -813,7 +841,7 @@ func (c *Cluster) genLocalTopicName() string {
}
// TODO: if cluster is large it may become too inefficient.
- for c.ring.Get(topic) != c.thisNodeName {
+ for c.ring.Get((types.TopicKey{TenantID: tenantID, Topic: topic}).RoutingKey()) != c.thisNodeName {
topic = genTopicName()
}
return topic
@@ -835,7 +863,14 @@ func (c *Cluster) isPartitioned() bool {
}
func (c *Cluster) makeClusterReq(reqType ProxyReqType, msg *ClientComMessage, topic string, sess *Session) *ClusterReq {
+ tenantID := types.ZeroTenantID
+ if msg != nil {
+ tenantID = msg.TenantID
+ } else if sess != nil {
+ tenantID = sess.tenantID
+ }
req := &ClusterReq{
+ TenantID: tenantID,
Node: c.thisNodeName,
Signature: c.ring.Signature(),
Fingerprint: c.fingerprint,
@@ -856,6 +891,7 @@ func (c *Cluster) makeClusterReq(reqType ProxyReqType, msg *ClientComMessage, to
}
req.Sess = &ClusterSess{
+ TenantID: tenantID,
Uid: uid,
AuthLvl: sess.authLvl,
RemoteAddr: sess.remoteAddr,
@@ -890,7 +926,7 @@ func (c *Cluster) routeToTopicMaster(reqType ProxyReqType, msg *ClientComMessage
req := c.makeClusterReq(reqType, msg, topic, sess)
// Find the cluster node which owns the topic, then forward to it.
- n := c.nodeForTopic(topic)
+ n := c.nodeForTopic(types.TopicKey{TenantID: req.TenantID, Topic: topic})
if n == nil {
return errors.New("node for topic not found")
}
@@ -904,12 +940,13 @@ func (c *Cluster) routeToTopicIntraCluster(topic string, msg *ServerComMessage,
return nil
}
- n := c.nodeForTopic(topic)
+ n := c.nodeForTopic(types.TopicKey{TenantID: msg.TenantID, Topic: topic})
if n == nil {
return errors.New("node for topic not found (intra)")
}
route := &ClusterRoute{
+ TenantID: msg.TenantID,
Node: c.thisNodeName,
Signature: c.ring.Signature(),
Fingerprint: c.fingerprint,
@@ -917,25 +954,25 @@ func (c *Cluster) routeToTopicIntraCluster(topic string, msg *ServerComMessage,
}
if sess != nil {
- route.Sess = &ClusterSess{Sid: sess.sid}
+ route.Sess = &ClusterSess{TenantID: sess.tenantID, Sid: sess.sid}
}
return n.route(route)
}
// Topic proxy terminated. Inform remote Master node that the proxy is gone.
-func (c *Cluster) topicProxyGone(topicName string) error {
+func (c *Cluster) topicProxyGone(tenantID types.TenantID, topicName string) error {
if c == nil {
// Cluster may be nil due to shutdown.
return nil
}
// Find the cluster node which owns the topic, then forward to it.
- n := c.nodeForTopic(topicName)
+ n := c.nodeForTopic(types.TopicKey{TenantID: tenantID, Topic: topicName})
if n == nil {
return errors.New("node for topic not found")
}
- req := c.makeClusterReq(ProxyReqLeave, nil, topicName, nil)
+ req := c.makeClusterReq(ProxyReqLeave, nil, topicName, &Session{tenantID: tenantID})
req.Gone = true
return n.proxyToMasterAsync(req)
}
@@ -1144,7 +1181,7 @@ func (c *Cluster) invalidateProxySubs(forNode string) {
return true
}
if forNode == "" {
- if topic.masterNode == c.ring.Get(topic.name) {
+ if topic.masterNode == c.ring.Get((types.TopicKey{TenantID: topic.tenantID, Topic: topic.name}).RoutingKey()) {
// The topic hasn't moved. Continue.
return true
}
@@ -1215,7 +1252,7 @@ func (sess *Session) clusterWriteLoop(forTopic string) {
return
}
srvMsg := msg.(*ServerComMessage)
- response := &ClusterResp{SrvMsg: srvMsg}
+ response := &ClusterResp{TenantID: srvMsg.TenantID, SrvMsg: srvMsg}
if srvMsg.sess == nil {
response.OrigSid = "*"
} else {
diff --git a/server/cluster_leader.go b/server/cluster_leader.go
index e69de29..59ddb80 100644
--- a/server/cluster_leader.go
+++ b/server/cluster_leader.go
@@ -0,0 +1,347 @@
+package main
+
+import (
+ "math/rand"
+ "net/rpc"
+ "sync"
+ "time"
+
+ "github.com/tinode/chat/server/logs"
+)
+
+// Cluster methods related to leader node election. Based on ideas from Raft protocol.
+// The leader node issues heartbeats to follower nodes. If the follower node fails enough
+// times, the leader node annouces it dead and initiates rehashing: it regenerates ring hash with
+// only live nodes and communicates the new list of nodes to followers. They in turn do their
+// rehashing using the new list. When the dead node is revived, rehashing happens again.
+
+// Failover config.
+type clusterFailover struct {
+ // Current leader
+ leader string
+ // Current election term
+ term int
+ // Hearbeat interval
+ heartBeat time.Duration
+ // Vote timeout: the number of missed heartbeats before a new election is initiated.
+ voteTimeout int
+
+ // The list of nodes the leader considers active
+ activeNodes []string
+ activeNodesLock sync.RWMutex
+ // The number of heartbeats a node can fail before being declared dead
+ nodeFailCountLimit int
+
+ // Channel for processing leader health checks.
+ healthCheck chan *ClusterHealth
+ // Channel for processing election votes.
+ electionVote chan *ClusterVote
+ // Channel for stopping the failover runner.
+ done chan bool
+}
+
+type clusterFailoverConfig struct {
+ // Failover is enabled
+ Enabled bool `json:"enabled"`
+ // Time in milliseconds between heartbeats
+ Heartbeat int `json:"heartbeat"`
+ // Number of failed heartbeats before a leader election is initiated.
+ VoteAfter int `json:"vote_after"`
+ // Number of failures before a node is considered dead
+ NodeFailAfter int `json:"node_fail_after"`
+}
+
+// ClusterHealth is content of a leader's health check of a follower node.
+type ClusterHealth struct {
+ // Name of the leader node
+ Leader string
+ // Election term
+ Term int
+ // Ring hash signature that represents the cluster
+ Signature string
+ // Names of nodes currently active in the cluster
+ Nodes []string
+}
+
+// ClusterVoteRequest is a request from a leader candidate to a node to vote for the candidate.
+type ClusterVoteRequest struct {
+ // Candidate node which issued this request
+ Node string
+ // Election term
+ Term int
+}
+
+// ClusterVoteResponse is a vote from a node.
+type ClusterVoteResponse struct {
+ // Actual vote
+ Result bool
+ // Node's term after the vote
+ Term int
+}
+
+// ClusterVote is a vote request and a response in leader election.
+type ClusterVote struct {
+ req *ClusterVoteRequest
+ resp chan ClusterVoteResponse
+}
+
+func (c *Cluster) failoverInit(config *clusterFailoverConfig) bool {
+ if config == nil || !config.Enabled {
+ return false
+ }
+ if len(c.nodes) < 2 {
+ logs.Err.Printf("cluster: failover disabled; need at least 3 nodes, got %d", len(c.nodes)+1)
+ return false
+ }
+
+ // Generate ring hash on the assumption that all nodes are alive and well.
+ // This minimizes rehashing during normal operations.
+ var activeNodes []string
+ for _, node := range c.nodes {
+ activeNodes = append(activeNodes, node.name)
+ }
+ activeNodes = append(activeNodes, c.thisNodeName)
+ c.rehash(activeNodes)
+
+ // Random heartbeat ticker: 0.75 * config.HeartBeat + random(0, 0.5 * config.HeartBeat).
+ // The PRNG is initialized in main.go.
+ hb := time.Duration(config.Heartbeat) * time.Millisecond
+ hb = (hb >> 1) + (hb >> 2) + time.Duration(rand.Intn(int(hb>>1)))
+
+ c.fo = &clusterFailover{
+ activeNodes: activeNodes,
+ heartBeat: hb,
+ voteTimeout: config.VoteAfter,
+ nodeFailCountLimit: config.NodeFailAfter,
+ healthCheck: make(chan *ClusterHealth, config.VoteAfter),
+ electionVote: make(chan *ClusterVote, len(c.nodes)),
+ done: make(chan bool, 1),
+ }
+
+ logs.Info.Println("cluster: failover mode enabled")
+
+ return true
+}
+
+// Health is called by the leader node to assert leadership and check status
+// of the followers.
+func (c *Cluster) Health(health *ClusterHealth, unused *bool) error {
+ select {
+ case c.fo.healthCheck <- health:
+ default:
+ }
+ return nil
+}
+
+// Vote processes request for a vote from a candidate.
+func (c *Cluster) Vote(vreq *ClusterVoteRequest, response *ClusterVoteResponse) error {
+ respChan := make(chan ClusterVoteResponse, 1)
+
+ c.fo.electionVote <- &ClusterVote{
+ req: vreq,
+ resp: respChan,
+ }
+
+ *response = <-respChan
+
+ return nil
+}
+
+// Cluster leader checks health of follower nodes.
+func (c *Cluster) sendHealthChecks() {
+ rehash := false
+
+ for _, node := range c.nodes {
+ unused := false
+ err := node.call("Cluster.Health",
+ &ClusterHealth{
+ Leader: c.thisNodeName,
+ Term: c.fo.term,
+ Signature: c.ring.Signature(),
+ Nodes: c.fo.activeNodes,
+ }, &unused)
+
+ if err != nil {
+ node.failCount++
+ if node.failCount == c.fo.nodeFailCountLimit {
+ // Node failed too many times
+ rehash = true
+ }
+ } else {
+ if node.failCount >= c.fo.nodeFailCountLimit {
+ // Node has recovered
+ rehash = true
+ }
+ node.failCount = 0
+ }
+ }
+
+ if rehash {
+ activeNodes := []string{c.thisNodeName}
+ for _, node := range c.nodes {
+ if node.failCount < c.fo.nodeFailCountLimit {
+ activeNodes = append(activeNodes, node.name)
+ }
+ }
+ c.fo.activeNodesLock.Lock()
+ c.fo.activeNodes = activeNodes
+ c.fo.activeNodesLock.Unlock()
+ c.rehash(activeNodes)
+ c.invalidateProxySubs("")
+ c.gcProxySessions(activeNodes)
+
+ logs.Info.Println("cluster: initiating failover rehash for nodes", activeNodes)
+ globals.hub.rehash <- true
+ }
+}
+
+func (c *Cluster) electLeader() {
+ // Increment the term (voting for myself in this term) and clear the leader
+ c.fo.term++
+ c.fo.leader = ""
+
+ // Make sure the current node does not report itself as a leader.
+ statsSet("ClusterLeader", 0)
+
+ logs.Info.Println("cluster: leading new election for term", c.fo.term)
+
+ nodeCount := len(c.nodes)
+ // Number of votes needed to elect the leader
+ expectVotes := (nodeCount+1)>>1 + 1
+ done := make(chan *rpc.Call, nodeCount)
+
+ // Send async requests for votes to other nodes
+ for _, node := range c.nodes {
+ response := ClusterVoteResponse{}
+ node.callAsync("Cluster.Vote",
+ &ClusterVoteRequest{
+ Node: c.thisNodeName,
+ Term: c.fo.term,
+ }, &response, done)
+ }
+
+ // Number of votes received (1 vote for self)
+ voteCount := 1
+ timeout := time.NewTimer(c.fo.heartBeat>>1 + c.fo.heartBeat)
+ // Wait for one of the following
+ // 1. More than half of the nodes voting in favor
+ // 2. All nodes responded.
+ // 3. Timeout.
+ for i := 0; i < nodeCount && voteCount < expectVotes; {
+ select {
+ case call := <-done:
+ if call.Error == nil {
+ if call.Reply.(*ClusterVoteResponse).Result {
+ // Vote in my favor
+ voteCount++
+ } else if c.fo.term < call.Reply.(*ClusterVoteResponse).Term {
+ // Vote against me. Abandon vote: this node's term is behind the cluster
+ i = nodeCount
+ voteCount = 0
+ }
+ }
+
+ i++
+ case <-timeout.C:
+ // break the loop
+ i = nodeCount
+ }
+ }
+
+ if voteCount >= expectVotes {
+ // Current node elected as the leader.
+ c.fo.leader = c.thisNodeName
+ statsSet("ClusterLeader", 1)
+ logs.Info.Printf("'%s' elected self as a new leader", c.thisNodeName)
+ }
+}
+
+// Go routine that processes calls related to leader election and maintenance.
+func (c *Cluster) run() {
+ ticker := time.NewTicker(c.fo.heartBeat)
+
+ // Count of missed health checks from the leader.
+ missed := 0
+ // Don't rehash immediately on the first missed health check. If this node just came online, leader will
+ // account it on the next check. Otherwise it will be rehashing twice.
+ rehashSkipped := false
+
+ for {
+ select {
+ case <-ticker.C:
+ if c.fo.leader == c.thisNodeName {
+ // I'm the leader, send the health checks to followers.
+ c.sendHealthChecks()
+ } else {
+ // Increment the number of missed health checks from the leader.
+ // The counter will be reset to zero when a health check is received.
+ missed++
+ if missed >= c.fo.voteTimeout {
+ // Leader is gone, initiate election of a new leader.
+ missed = 0
+ c.electLeader()
+ }
+ }
+ case health := <-c.fo.healthCheck:
+ // Health check from the leader.
+
+ if health.Term < c.fo.term {
+ // This is a health check from a stale leader. Ignore.
+ logs.Warn.Println("cluster: health check from a stale leader", health.Term, c.fo.term, health.Leader, c.fo.leader)
+ continue
+ }
+
+ if health.Term > c.fo.term {
+ c.fo.term = health.Term
+ c.fo.leader = health.Leader
+ logs.Info.Printf("cluster: leader '%s' elected", c.fo.leader)
+ } else if health.Leader != c.fo.leader {
+ if c.fo.leader != "" {
+ // Wrong leader. It's a bug, should never happen!
+ logs.Err.Printf("cluster: wrong leader '%s' while expecting '%s'; term %d",
+ health.Leader, c.fo.leader, health.Term)
+ } else {
+ logs.Info.Printf("cluster: leader set to '%s'", health.Leader)
+ }
+ c.fo.leader = health.Leader
+ }
+
+ // This is a health check from a leader, consequently this node is not the leader.
+ statsSet("ClusterLeader", 0)
+
+ missed = 0
+ if health.Signature != c.ring.Signature() {
+ if rehashSkipped {
+ logs.Info.Println("cluster: rehashing at a request of",
+ health.Leader, health.Nodes, health.Signature, c.ring.Signature())
+ c.rehash(health.Nodes)
+ c.invalidateProxySubs("")
+ c.gcProxySessions(health.Nodes)
+ rehashSkipped = false
+
+ globals.hub.rehash <- true
+ } else {
+ rehashSkipped = true
+ }
+ }
+
+ case vreq := <-c.fo.electionVote:
+ if c.fo.term < vreq.req.Term {
+ // This is a new election. This node has not voted yet. Vote for the requestor and
+ // clear the current leader.
+ logs.Info.Printf("Voting YES for %s, my term %d, vote term %d", vreq.req.Node, c.fo.term, vreq.req.Term)
+ c.fo.term = vreq.req.Term
+ c.fo.leader = ""
+ // Election means these is no leader yet.
+ statsSet("ClusterLeader", 0)
+ vreq.resp <- ClusterVoteResponse{Result: true, Term: c.fo.term}
+ } else {
+ // This node has voted already or stale election, reject.
+ logs.Info.Printf("Voting NO for %s, my term %d, vote term %d", vreq.req.Node, c.fo.term, vreq.req.Term)
+ vreq.resp <- ClusterVoteResponse{Result: false, Term: c.fo.term}
+ }
+ case <-c.fo.done:
+ return
+ }
+ }
+}
diff --git a/server/datamodel.go b/server/datamodel.go
index e69de29..c121e22 100644
--- a/server/datamodel.go
+++ b/server/datamodel.go
@@ -0,0 +1,1758 @@
+package main
+
+/******************************************************************************
+ *
+ * Description :
+ *
+ * Wire protocol structures
+ *
+ *****************************************************************************/
+
+import (
+ "encoding/json"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/tinode/chat/server/store/types"
+)
+
+// MsgGetOpts defines Get query parameters.
+type MsgGetOpts struct {
+ // Optional User ID to return result(s) for one user.
+ User string `json:"user,omitempty"`
+ // Optional topic name to return result(s) for one topic.
+ Topic string `json:"topic,omitempty"`
+ // Return results modified since this timespamp.
+ IfModifiedSince *time.Time `json:"ims,omitempty"`
+ // Load messages/ranges with IDs equal or greater than this (inclusive or closed).
+ SinceId int `json:"since,omitempty"`
+ // Load messages/ranges with IDs lower than this (exclusive or open).
+ BeforeId int `json:"before,omitempty"`
+ // Limit the number of messages loaded.
+ Limit int `json:"limit,omitempty"`
+ // Fetch messages with IDs in these ranges.
+ IdRanges []MsgRange `json:"ranges,omitempty"`
+}
+
+// MsgGetQuery is a topic metadata or data query.
+type MsgGetQuery struct {
+ What string `json:"what"`
+
+ // Parameters of "desc" request: IfModifiedSince
+ Desc *MsgGetOpts `json:"desc,omitempty"`
+ // Parameters of "sub" request: User, Topic, IfModifiedSince, Limit.
+ Sub *MsgGetOpts `json:"sub,omitempty"`
+ // Parameters of "data" request: Since, Before, Limit.
+ Data *MsgGetOpts `json:"data,omitempty"`
+ // Parameters of "del" request: Since, Before, Limit.
+ Del *MsgGetOpts `json:"del,omitempty"`
+}
+
+// MsgSetSub is a payload in set.sub request to update current subscription or invite another user, {sub.what} == "sub".
+type MsgSetSub struct {
+ // User affected by this request. Default (empty): current user.
+ User string `json:"user,omitempty"`
+
+ // Access mode change, either Given or Want depending on context.
+ Mode string `json:"mode,omitempty"`
+}
+
+// MsgSetDesc is a C2S in set.what == "desc", acc, sub message.
+type MsgSetDesc struct {
+ // Default access mode.
+ DefaultAcs *MsgDefaultAcsMode `json:"defacs,omitempty"`
+ // Description of the user or topic.
+ Public any `json:"public,omitempty"`
+ // Trusted (system-provided) user or topic data.
+ Trusted any `json:"trusted,omitempty"`
+ // Per-subscription private data.
+ Private any `json:"private,omitempty"`
+}
+
+// MsgCredClient is an account credential such as email or phone number.
+type MsgCredClient struct {
+ // Credential type, i.e. `email` or `tel`.
+ Method string `json:"meth,omitempty"`
+ // Value to verify, i.e. `user@example.com` or `+18003287448`
+ Value string `json:"val,omitempty"`
+ // Verification response
+ Response string `json:"resp,omitempty"`
+ // Request parameters, such as preferences. Passed to valiator without interpretation.
+ Params map[string]any `json:"params,omitempty"`
+}
+
+// MsgSetQuery is an update to topic or user metadata: description, subscriptions, tags, credentials.
+type MsgSetQuery struct {
+ // Topic/user description, new object & new subscriptions only
+ Desc *MsgSetDesc `json:"desc,omitempty"`
+ // Subscription parameters
+ Sub *MsgSetSub `json:"sub,omitempty"`
+ // Indexable tags for user discovery
+ Tags []string `json:"tags,omitempty"`
+ // Update to account credentials.
+ Cred *MsgCredClient `json:"cred,omitempty"`
+ // Update auxiliary data
+ Aux map[string]any
+}
+
+// MsgRange is either an individual ID (HiId=0) or a randge of IDs, low end inclusive (closed),
+// high-end exclusive (open): [LowId .. HiId), e.g. 1..5 -> 1, 2, 3, 4.
+type MsgRange struct {
+ LowId int `json:"low,omitempty"`
+ HiId int `json:"hi,omitempty"`
+}
+
+/****************************************************************
+ * Client to Server (C2S) messages.
+ ****************************************************************/
+
+// MsgClientHi is a handshake {hi} message.
+type MsgClientHi struct {
+ // Message Id
+ Id string `json:"id,omitempty"`
+ // User agent
+ UserAgent string `json:"ua,omitempty"`
+ // Protocol version, i.e. "0.13"
+ Version string `json:"ver,omitempty"`
+ // Client's unique device ID
+ DeviceID string `json:"dev,omitempty"`
+ // ISO 639-1 human language of the connected device
+ Lang string `json:"lang,omitempty"`
+ // Platform code: ios, android, web.
+ Platform string `json:"platf,omitempty"`
+ // Session is initially in non-iteractive, i.e. issued by a service. Presence notifications are delayed.
+ Background bool `json:"bkg,omitempty"`
+ // Public enterprise code used to resolve and bind the tenant for this session.
+ Tenant string `json:"tenant,omitempty"`
+}
+
+// MsgClientAcc is an {acc} message for creating or updating a user account.
+type MsgClientAcc struct {
+ // Message Id
+ Id string `json:"id,omitempty"`
+ // "newXYZ" to create a new user or UserId to update a user; default: current user.
+ User string `json:"user,omitempty"`
+ // Temporary authentication parameters for one-off actions, like password reset.
+ TmpScheme string `json:"tmpscheme,omitempty"`
+ TmpSecret []byte `json:"tmpsecret,omitempty"`
+ // Account state: normal, suspended.
+ State string `json:"status,omitempty"`
+ // Authentication level of the user when UserID is set and not equal to the current user.
+ // Either "", "auth" or "anon". Default: ""
+ AuthLevel string `json:"authlevel,omitempty"`
+ // The initial authentication scheme the account can use
+ Scheme string `json:"scheme,omitempty"`
+ // Shared secret
+ Secret []byte `json:"secret,omitempty"`
+ // Authenticate session with the newly created account
+ Login bool `json:"login,omitempty"`
+ // Indexable tags for user discovery
+ Tags []string `json:"tags,omitempty"`
+ // User initialization data when creating a new user, otherwise ignored
+ Desc *MsgSetDesc `json:"desc,omitempty"`
+ // Credentials to verify (email or phone or captcha)
+ Cred []MsgCredClient `json:"cred,omitempty"`
+}
+
+// MsgClientLogin is a login {login} message.
+type MsgClientLogin struct {
+ // Message Id
+ Id string `json:"id,omitempty"`
+ // Authentication scheme
+ Scheme string `json:"scheme,omitempty"`
+ // Shared secret
+ Secret []byte `json:"secret"`
+ // Credntials being verified (email or phone or captcha etc.)
+ Cred []MsgCredClient `json:"cred,omitempty"`
+}
+
+// MsgClientSub is a subscription request {sub} message.
+type MsgClientSub struct {
+ Id string `json:"id,omitempty"`
+ Topic string `json:"topic"`
+
+ // Mirrors {set}.
+ Set *MsgSetQuery `json:"set,omitempty"`
+
+ // Mirrors {get}.
+ Get *MsgGetQuery `json:"get,omitempty"`
+
+ // Intra-cluster fields.
+
+ // True if this subscription created a new topic.
+ // In case of p2p topics, it's true if the other user's subscription was
+ // created (as a part of new topic creation or just alone).
+ Created bool `json:"-"`
+ // True if this is a new subscription.
+ Newsub bool `json:"-"`
+}
+
+const (
+ constMsgMetaDesc = 1 << iota
+ constMsgMetaSub
+ constMsgMetaData
+ constMsgMetaTags
+ constMsgMetaDel
+ constMsgMetaCred
+ constMsgMetaAux
+)
+
+const (
+ constMsgDelTopic = iota + 1
+ constMsgDelMsg
+ constMsgDelSub
+ constMsgDelUser
+ constMsgDelCred
+)
+
+func parseMsgClientMeta(params string) int {
+ var bits int
+ parts := strings.SplitN(params, " ", 8)
+ for _, p := range parts {
+ switch p {
+ case "desc":
+ bits |= constMsgMetaDesc
+ case "sub":
+ bits |= constMsgMetaSub
+ case "data":
+ bits |= constMsgMetaData
+ case "tags":
+ bits |= constMsgMetaTags
+ case "del":
+ bits |= constMsgMetaDel
+ case "cred":
+ bits |= constMsgMetaCred
+ case "aux":
+ bits |= constMsgMetaAux
+ default:
+ // ignore unknown
+ }
+ }
+ return bits
+}
+
+func parseMsgClientDel(params string) int {
+ switch params {
+ case "", "msg":
+ return constMsgDelMsg
+ case "topic":
+ return constMsgDelTopic
+ case "sub":
+ return constMsgDelSub
+ case "user":
+ return constMsgDelUser
+ case "cred":
+ return constMsgDelCred
+ default:
+ // ignore
+ }
+ return 0
+}
+
+// MsgDefaultAcsMode is a topic default access mode.
+type MsgDefaultAcsMode struct {
+ Auth string `json:"auth,omitempty"`
+ Anon string `json:"anon,omitempty"`
+}
+
+// MsgClientLeave is an unsubscribe {leave} request message.
+type MsgClientLeave struct {
+ Id string `json:"id,omitempty"`
+ Topic string `json:"topic"`
+ Unsub bool `json:"unsub,omitempty"`
+}
+
+// MsgClientPub is client's request to publish data to topic subscribers {pub}.
+type MsgClientPub struct {
+ Id string `json:"id,omitempty"`
+ Topic string `json:"topic"`
+ NoEcho bool `json:"noecho,omitempty"`
+ Head map[string]any `json:"head,omitempty"`
+ Content any `json:"content"`
+}
+
+// MsgClientGet is a query of topic state {get}.
+type MsgClientGet struct {
+ Id string `json:"id,omitempty"`
+ Topic string `json:"topic"`
+ MsgGetQuery
+}
+
+// MsgClientSet is an update of topic state {set}.
+type MsgClientSet struct {
+ Id string `json:"id,omitempty"`
+ Topic string `json:"topic"`
+ MsgSetQuery
+}
+
+// MsgClientDel delete messages or topic {del}.
+type MsgClientDel struct {
+ Id string `json:"id,omitempty"`
+ Topic string `json:"topic,omitempty"`
+ // What to delete:
+ // * "msg" to delete messages (default)
+ // * "topic" to delete the topic
+ // * "sub" to delete a subscription to topic.
+ // * "user" to delete or disable user.
+ // * "cred" to delete credential (email or phone)
+ What string `json:"what"`
+ // Delete messages with these IDs (either one by one or a set of ranges)
+ DelSeq []MsgRange `json:"delseq,omitempty"`
+ // User ID of the user or subscription to delete
+ User string `json:"user,omitempty"`
+ // Credential to delete
+ Cred *MsgCredClient `json:"cred,omitempty"`
+ // Request to hard-delete objects (i.e. delete messages for all users), if such option is available.
+ Hard bool `json:"hard,omitempty"`
+}
+
+// MsgClientNote is a client-generated notification for topic subscribers {note}.
+type MsgClientNote struct {
+ // There is no Id -- server will not akn {ping} packets, they are "fire and forget"
+ Topic string `json:"topic"`
+ // what is being reported: "recv" - message received, "read" - message read, "kp" - typing notification
+ What string `json:"what"`
+ // Server-issued message ID being reported
+ SeqId int `json:"seq,omitempty"`
+ // Client's count of unread messages to report back to the server. Used in push notifications on iOS.
+ Unread int `json:"unread,omitempty"`
+ // Call event.
+ Event string `json:"event,omitempty"`
+ // Arbitrary json payload (used in video calls).
+ Payload json.RawMessage `json:"payload,omitempty"`
+}
+
+// MsgClientExtra is not a stand-alone message but extra data which augments the main payload.
+type MsgClientExtra struct {
+ // Array of out-of-band attachments which have to be exempted from GC.
+ Attachments []string `json:"attachments,omitempty"`
+ // Alternative user ID set by the root user (obo = On Behalf Of).
+ AsUser string `json:"obo,omitempty"`
+ // Altered authentication level set by the root user.
+ AuthLevel string `json:"authlevel,omitempty"`
+}
+
+// ClientComMessage is a wrapper for client messages.
+type ClientComMessage struct {
+ Hi *MsgClientHi `json:"hi"`
+ Acc *MsgClientAcc `json:"acc"`
+ Login *MsgClientLogin `json:"login"`
+ Sub *MsgClientSub `json:"sub"`
+ Leave *MsgClientLeave `json:"leave"`
+ Pub *MsgClientPub `json:"pub"`
+ Get *MsgClientGet `json:"get"`
+ Set *MsgClientSet `json:"set"`
+ Del *MsgClientDel `json:"del"`
+ Note *MsgClientNote `json:"note"`
+ // Optional data.
+ Extra *MsgClientExtra `json:"extra"`
+
+ // Internal fields, routed only within the cluster.
+
+ // Message ID denormalized
+ Id string `json:"-"`
+ // Un-routable (original) topic name denormalized from XXX.Topic.
+ Original string `json:"-"`
+ // Routable (expanded) topic name.
+ RcptTo string `json:"-"`
+ // Sender's UserId as string.
+ AsUser string `json:"-"`
+ // Sender's authentication level.
+ AuthLvl int `json:"-"`
+ // Denormalized 'what' field of meta messages (set, get, del).
+ MetaWhat int `json:"-"`
+ // Timestamp when this message was received by the server.
+ Timestamp time.Time `json:"-"`
+ // Tenant context inherited from the originating session.
+ TenantID types.TenantID `json:"-"`
+
+ // Originating session to send an aknowledgement to.
+ sess *Session
+ // The message is initialized (true) as opposite to being used as a wrapper for session.
+ init bool
+}
+
+/****************************************************************
+ * Server to client messages.
+ ****************************************************************/
+
+// MsgLastSeenInfo contains info on user's appearance online - when & user agent.
+type MsgLastSeenInfo struct {
+ // Timestamp of user's last appearance online.
+ When *time.Time `json:"when,omitempty"`
+ // User agent of the device when the user was last online.
+ UserAgent string `json:"ua,omitempty"`
+}
+
+func (src *MsgLastSeenInfo) describe() string {
+ return "'" + src.UserAgent + "' @ " + src.When.String()
+}
+
+// MsgCredServer is an account credential such as email or phone number.
+type MsgCredServer struct {
+ // Credential type, i.e. `email` or `tel`.
+ Method string `json:"meth,omitempty"`
+ // Credential value, i.e. `user@example.com` or `+18003287448`
+ Value string `json:"val,omitempty"`
+ // Indicates that the credential is validated.
+ Done bool `json:"done,omitempty"`
+}
+
+// MsgAccessMode is a definition of access mode.
+type MsgAccessMode struct {
+ // Access mode requested by the user
+ Want string `json:"want,omitempty"`
+ // Access mode granted to the user by the admin
+ Given string `json:"given,omitempty"`
+ // Cumulative access mode want & given
+ Mode string `json:"mode,omitempty"`
+}
+
+func (src *MsgAccessMode) describe() string {
+ var s string
+ if src.Want != "" {
+ s = "w=" + src.Want
+ }
+ if src.Given != "" {
+ s += " g=" + src.Given
+ }
+ if src.Mode != "" {
+ s += " m=" + src.Mode
+ }
+ return strings.TrimSpace(s)
+}
+
+// MsgTopicDesc is a topic description, S2C in Meta message.
+type MsgTopicDesc struct {
+ CreatedAt *time.Time `json:"created,omitempty"`
+ UpdatedAt *time.Time `json:"updated,omitempty"`
+ // Timestamp of the last message
+ TouchedAt *time.Time `json:"touched,omitempty"`
+
+ // Account state, 'me' topic only.
+ State string `json:"state,omitempty"`
+
+ // If the group topic is online.
+ Online bool `json:"online,omitempty"`
+
+ // If the topic can be accessed as a channel
+ IsChan bool `json:"chan,omitempty"`
+
+ // P2P other user's last online timestamp & user agent
+ LastSeen *MsgLastSeenInfo `json:"seen,omitempty"`
+
+ DefaultAcs *MsgDefaultAcsMode `json:"defacs,omitempty"`
+ // Actual access mode
+ Acs *MsgAccessMode `json:"acs,omitempty"`
+ // Max message ID
+ SeqId int `json:"seq,omitempty"`
+ ReadSeqId int `json:"read,omitempty"`
+ RecvSeqId int `json:"recv,omitempty"`
+ // Id of the last delete operation as seen by the requesting user
+ DelId int `json:"clear,omitempty"`
+ SubCnt int `json:"subcnt,omitempty"`
+ Public any `json:"public,omitempty"`
+ Trusted any `json:"trusted,omitempty"`
+ // Per-subscription private data
+ Private any `json:"private,omitempty"`
+}
+
+func (src *MsgTopicDesc) describe() string {
+ var s string
+ if src.State != "" {
+ s = " state=" + src.State
+ }
+ s += " online=" + strconv.FormatBool(src.Online)
+ if src.Acs != nil {
+ s += " acs={" + src.Acs.describe() + "}"
+ }
+ if src.SeqId != 0 {
+ s += " seq=" + strconv.Itoa(src.SeqId)
+ }
+ if src.ReadSeqId != 0 {
+ s += " read=" + strconv.Itoa(src.ReadSeqId)
+ }
+ if src.RecvSeqId != 0 {
+ s += " recv=" + strconv.Itoa(src.RecvSeqId)
+ }
+ if src.DelId != 0 {
+ s += " clear=" + strconv.Itoa(src.DelId)
+ }
+ if src.SubCnt != 0 {
+ s += " subcnt=" + strconv.Itoa(src.SubCnt)
+ }
+ if src.Public != nil {
+ s += " pub='...'"
+ }
+ if src.Trusted != nil {
+ s += " trst='...'"
+ }
+ if src.Private != nil {
+ s += " priv='...'"
+ }
+ return s
+}
+
+// MsgTopicSub is topic subscription details, sent in Meta message.
+type MsgTopicSub struct {
+ // Fields common to all subscriptions
+
+ // Timestamp when the subscription was last updated
+ UpdatedAt *time.Time `json:"updated,omitempty"`
+ // Timestamp when the subscription was deleted
+ DeletedAt *time.Time `json:"deleted,omitempty"`
+
+ // If the subscriber/topic is online
+ Online bool `json:"online,omitempty"`
+
+ // Access mode. Topic admins receive the full info, non-admins receive just the cumulative mode
+ // Acs.Mode = want & given. The field is not a pointer because at least one value is always assigned.
+ Acs MsgAccessMode `json:"acs,omitempty"`
+ // ID of the message reported by the given user as read
+ ReadSeqId int `json:"read,omitempty"`
+ // ID of the message reported by the given user as received
+ RecvSeqId int `json:"recv,omitempty"`
+ // Topic's public data
+ Public any `json:"public,omitempty"`
+ // Topic's trusted public data
+ Trusted any `json:"trusted,omitempty"`
+ // User's own private data per topic
+ Private any `json:"private,omitempty"`
+
+ // Response to non-'me' topic
+
+ // Uid of the subscribed user
+ User string `json:"user,omitempty"`
+
+ // The following sections makes sense only in context of getting
+ // user's own subscriptions ('me' topic response)
+
+ // Topic name of this subscription
+ Topic string `json:"topic,omitempty"`
+ // Timestamp of the last message in the topic.
+ TouchedAt *time.Time `json:"touched,omitempty"`
+ // ID of the last {data} message in a topic
+ SeqId int `json:"seq,omitempty"`
+ // Id of the latest Delete operation
+ DelId int `json:"clear,omitempty"`
+ // Number of subscribers, group topics only.
+ SubCnt int `json:"subcnt,omitempty"`
+ // P2P topics in 'me' {get subs} response:
+
+ // Other user's last online timestamp & user agent
+ LastSeen *MsgLastSeenInfo `json:"seen,omitempty"`
+}
+
+func (src *MsgTopicSub) describe() string {
+ s := src.Topic + ":" + src.User + " online=" + strconv.FormatBool(src.Online) + " acs=" + src.Acs.describe()
+
+ if src.SeqId != 0 {
+ s += " seq=" + strconv.Itoa(src.SeqId)
+ }
+ if src.ReadSeqId != 0 {
+ s += " read=" + strconv.Itoa(src.ReadSeqId)
+ }
+ if src.RecvSeqId != 0 {
+ s += " recv=" + strconv.Itoa(src.RecvSeqId)
+ }
+ if src.DelId != 0 {
+ s += " clear=" + strconv.Itoa(src.DelId)
+ }
+ if src.SubCnt != 0 {
+ s += " subcnt=" + strconv.Itoa(src.SubCnt)
+ }
+ if src.Public != nil {
+ s += " pub='...'"
+ }
+ if src.Trusted != nil {
+ s += " trst='...'"
+ }
+ if src.Private != nil {
+ s += " priv='...'"
+ }
+ if src.LastSeen != nil {
+ s += " seen={" + src.LastSeen.describe() + "}"
+ }
+ return s
+}
+
+// MsgDelValues describes request to delete messages.
+type MsgDelValues struct {
+ DelId int `json:"clear,omitempty"`
+ DelSeq []MsgRange `json:"delseq,omitempty"`
+}
+
+// MsgServerCtrl is a server control message {ctrl}.
+type MsgServerCtrl struct {
+ Id string `json:"id,omitempty"`
+ Topic string `json:"topic,omitempty"`
+ Params any `json:"params,omitempty"`
+
+ Code int `json:"code"`
+ Text string `json:"text,omitempty"`
+ Timestamp time.Time `json:"ts"`
+}
+
+// Deep-shallow copy.
+func (src *MsgServerCtrl) copy() *MsgServerCtrl {
+ if src == nil {
+ return nil
+ }
+ dst := *src
+ return &dst
+}
+
+func (src *MsgServerCtrl) describe() string {
+ return src.Topic + " id=" + src.Id + " code=" + strconv.Itoa(src.Code) + " txt=" + src.Text
+}
+
+// MsgServerData is a server {data} message.
+type MsgServerData struct {
+ Topic string `json:"topic"`
+ // ID of the user who originated the message as {pub}, could be empty if sent by the system
+ From string `json:"from,omitempty"`
+ Timestamp time.Time `json:"ts"`
+ DeletedAt *time.Time `json:"deleted,omitempty"`
+ SeqId int `json:"seq"`
+ Head map[string]any `json:"head,omitempty"`
+ Content any `json:"content"`
+}
+
+// Deep-shallow copy.
+func (src *MsgServerData) copy() *MsgServerData {
+ if src == nil {
+ return nil
+ }
+ dst := *src
+ return &dst
+}
+
+func (src *MsgServerData) describe() string {
+ s := src.Topic + " from=" + src.From + " seq=" + strconv.Itoa(src.SeqId)
+ if src.DeletedAt != nil {
+ s += " deleted"
+ } else {
+ if src.Head != nil {
+ s += " head=..."
+ }
+ s += " content='...'"
+ }
+ return s
+}
+
+// MsgServerPres is presence notification {pres} (authoritative update).
+type MsgServerPres struct {
+ Topic string `json:"topic"`
+ Src string `json:"src,omitempty"`
+ What string `json:"what"`
+ UserAgent string `json:"ua,omitempty"`
+ SeqId int `json:"seq,omitempty"`
+ DelId int `json:"clear,omitempty"`
+ DelSeq []MsgRange `json:"delseq,omitempty"`
+ AcsTarget string `json:"tgt,omitempty"`
+ AcsActor string `json:"act,omitempty"`
+ // Acs or a delta Acs. Need to marshal it to json under a name different than 'acs'
+ // to allow different handling on the client
+ Acs *MsgAccessMode `json:"dacs,omitempty"`
+
+ // UNroutable params. All marked with `json:"-"` to exclude from json marshaling.
+ // They are still serialized for intra-cluster communication.
+
+ // Flag to break the reply loop
+ WantReply bool `json:"-"`
+
+ // Additional access mode filters when sending to topic's online members. Both filter conditions must be true.
+ // send only to those who have this access mode.
+ FilterIn int `json:"-"`
+ // skip those who have this access mode.
+ FilterOut int `json:"-"`
+
+ // When sending to 'me', skip sessions subscribed to this topic.
+ SkipTopic string `json:"-"`
+
+ // Send to sessions of a single user only.
+ SingleUser string `json:"-"`
+
+ // Exclude sessions of a single user.
+ ExcludeUser string `json:"-"`
+}
+
+// Deep-shallow copy.
+func (src *MsgServerPres) copy() *MsgServerPres {
+ if src == nil {
+ return nil
+ }
+ dst := *src
+ return &dst
+}
+
+func (src *MsgServerPres) describe() string {
+ s := src.Topic
+ if src.Src != "" {
+ s += " src=" + src.Src
+ }
+ if src.What != "" {
+ s += " what=" + src.What
+ }
+ if src.UserAgent != "" {
+ s += " ua=" + src.UserAgent
+ }
+ if src.SeqId != 0 {
+ s += " seq=" + strconv.Itoa(src.SeqId)
+ }
+ if src.DelId != 0 {
+ s += " clear=" + strconv.Itoa(src.DelId)
+ }
+ if src.DelSeq != nil {
+ s += " delseq"
+ }
+ if src.AcsTarget != "" {
+ s += " tgt=" + src.AcsTarget
+ }
+ if src.AcsActor != "" {
+ s += " actor=" + src.AcsActor
+ }
+ if src.Acs != nil {
+ s += " dacs=" + src.Acs.describe()
+ }
+
+ return s
+}
+
+// MsgServerMeta is a topic metadata {meta} update.
+type MsgServerMeta struct {
+ Id string `json:"id,omitempty"`
+ Topic string `json:"topic"`
+
+ Timestamp *time.Time `json:"ts,omitempty"`
+
+ // Topic description
+ Desc *MsgTopicDesc `json:"desc,omitempty"`
+ // Subscriptions as an array of objects
+ Sub []MsgTopicSub `json:"sub,omitempty"`
+ // Delete ID and the ranges of IDs of deleted messages
+ Del *MsgDelValues `json:"del,omitempty"`
+ // User discovery tags
+ Tags []string `json:"tags,omitempty"`
+ // Account credentials, 'me' only.
+ Cred []*MsgCredServer `json:"cred,omitempty"`
+ // Auxiliary data
+ Aux map[string]any `json:"aux,omitempty"`
+}
+
+// Deep-shallow copy of meta message. Deep copy of Id and Topic fields, shallow copy of payload.
+func (src *MsgServerMeta) copy() *MsgServerMeta {
+ if src == nil {
+ return nil
+ }
+ dst := *src
+ return &dst
+}
+
+func (src *MsgServerMeta) describe() string {
+ s := src.Topic + " id=" + src.Id
+
+ if src.Desc != nil {
+ s += " desc={" + src.Desc.describe() + "}"
+ }
+ if src.Sub != nil {
+ var x []string
+ for _, sub := range src.Sub {
+ x = append(x, sub.describe())
+ }
+ s += " sub=[{" + strings.Join(x, "},{") + "}]"
+ }
+ if src.Del != nil {
+ x, _ := json.Marshal(src.Del)
+ s += " del={" + string(x) + "}"
+ }
+ if src.Tags != nil {
+ s += " tags=[" + strings.Join(src.Tags, ",") + "]"
+ }
+ if src.Cred != nil {
+ x, _ := json.Marshal(src.Cred)
+ s += " cred=[" + string(x) + "]"
+ }
+ if src.Aux != nil {
+ x, _ := json.Marshal(src.Aux)
+ s += " aux=[" + string(x) + "]"
+ }
+ return s
+}
+
+// MsgServerInfo is the server-side copy of MsgClientNote with From and optionally Src added (non-authoritative).
+type MsgServerInfo struct {
+ // Topic to send event to.
+ Topic string `json:"topic"`
+ // Topic where the even has occurred (set only when Topic='me').
+ Src string `json:"src,omitempty"`
+ // ID of the user who originated the message.
+ From string `json:"from,omitempty"`
+ // The event being reported: "rcpt" - message received, "read" - message read, "kp" - typing notification, "call" - video call.
+ What string `json:"what"`
+ // Server-issued message ID being reported.
+ SeqId int `json:"seq,omitempty"`
+ // Call event.
+ Event string `json:"event,omitempty"`
+ // Arbitrary json payload (used by video calls).
+ Payload json.RawMessage `json:"payload,omitempty"`
+
+ // UNroutable params. All marked with `json:"-"` to exclude from json marshaling.
+ // They are still serialized for intra-cluster communication.
+
+ // When sending to 'me', skip sessions subscribed to this topic.
+ SkipTopic string `json:"-"`
+}
+
+// Deep copy.
+func (src *MsgServerInfo) copy() *MsgServerInfo {
+ if src == nil {
+ return nil
+ }
+ dst := *src
+ return &dst
+}
+
+// Basic description.
+func (src *MsgServerInfo) describe() string {
+ s := src.Topic
+ if src.Src != "" {
+ s += " src=" + src.Src
+ }
+ s += " what=" + src.What + " from=" + src.From
+ if src.SeqId > 0 {
+ s += " seq=" + strconv.Itoa(src.SeqId)
+ }
+ if len(src.Payload) > 0 {
+ s += " payload=<..." + strconv.Itoa(len(src.Payload)) + " bytes ...>"
+ }
+ return s
+}
+
+// ServerComMessage is a wrapper for server-side messages.
+type ServerComMessage struct {
+ Ctrl *MsgServerCtrl `json:"ctrl,omitempty"`
+ Data *MsgServerData `json:"data,omitempty"`
+ Meta *MsgServerMeta `json:"meta,omitempty"`
+ Pres *MsgServerPres `json:"pres,omitempty"`
+ Info *MsgServerInfo `json:"info,omitempty"`
+
+ // Internal fields.
+ // TenantID is assigned by the server and is never serialized to clients.
+ TenantID types.TenantID `json:"-"`
+
+ // MsgServerData has no Id field, copying it here for use in {ctrl} aknowledgements
+ Id string `json:"-"`
+ // Routable (expanded) name of the topic.
+ RcptTo string `json:"-"`
+ // User ID of the sender of the original message.
+ AsUser string `json:"-"`
+ // Timestamp for consistency of timestamps in {ctrl} messages
+ // (corresponds to originating client message receipt timestamp).
+ Timestamp time.Time `json:"-"`
+ // Originating session to send an aknowledgement to. Could be nil.
+ sess *Session
+ // Session ID to skip when sendng packet to sessions. Used to skip sending to original session.
+ // Could be either empty.
+ SkipSid string `json:"-"`
+ // User id affected by this message.
+ uid types.Uid
+}
+
+// Deep-shallow copy of ServerComMessage. Deep copy of service fields,
+// shallow copy of session and payload.
+func (src *ServerComMessage) copy() *ServerComMessage {
+ if src == nil {
+ return nil
+ }
+ dst := &ServerComMessage{
+ TenantID: src.TenantID,
+ Id: src.Id,
+ RcptTo: src.RcptTo,
+ AsUser: src.AsUser,
+ Timestamp: src.Timestamp,
+ sess: src.sess,
+ SkipSid: src.SkipSid,
+ uid: src.uid,
+ }
+
+ dst.Ctrl = src.Ctrl.copy()
+ dst.Data = src.Data.copy()
+ dst.Meta = src.Meta.copy()
+ dst.Pres = src.Pres.copy()
+ dst.Info = src.Info.copy()
+
+ return dst
+}
+
+func (src *ServerComMessage) describe() string {
+ if src == nil {
+ return "-"
+ }
+
+ switch {
+ case src.Ctrl != nil:
+ return "{ctrl " + src.Ctrl.describe() + "}"
+ case src.Data != nil:
+ return "{data " + src.Data.describe() + "}"
+ case src.Meta != nil:
+ return "{meta " + src.Meta.describe() + "}"
+ case src.Pres != nil:
+ return "{pres " + src.Pres.describe() + "}"
+ case src.Info != nil:
+ return "{info " + src.Info.describe() + "}"
+ default:
+ return "{nil}"
+ }
+}
+
+// Generators of server-side error messages {ctrl}.
+
+// NoErr indicates successful completion (200).
+func NoErr(id, topic string, ts time.Time) *ServerComMessage {
+ return NoErrParams(id, topic, ts, nil)
+}
+
+// NoErrExplicitTs indicates successful completion with explicit server and incoming request timestamps (200).
+func NoErrExplicitTs(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {
+ return NoErrParamsExplicitTs(id, topic, serverTs, incomingReqTs, nil)
+}
+
+// NoErrReply indicates successful completion as a reply to a client message (200).
+func NoErrReply(msg *ClientComMessage, ts time.Time) *ServerComMessage {
+ return NoErrExplicitTs(msg.Id, msg.Original, ts, msg.Timestamp)
+}
+
+// NoErrParams indicates successful completion with additional parameters (200).
+func NoErrParams(id, topic string, ts time.Time, params any) *ServerComMessage {
+ return NoErrParamsExplicitTs(id, topic, ts, ts, params)
+}
+
+// NoErrParamsExplicitTs indicates successful completion with additional parameters
+// and explicit server and incoming request timestamps (200).
+func NoErrParamsExplicitTs(id, topic string, serverTs, incomingReqTs time.Time, params any) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Id: id,
+ Code: http.StatusOK, // 200
+ Text: "ok",
+ Topic: topic,
+ Params: params,
+ Timestamp: serverTs,
+ },
+ Id: id,
+ Timestamp: incomingReqTs,
+ }
+}
+
+// NoErrParamsReply indicates successful completion with additional parameters
+// and explicit server and incoming request timestamps (200).
+func NoErrParamsReply(msg *ClientComMessage, ts time.Time, params any) *ServerComMessage {
+ return NoErrParamsExplicitTs(msg.Id, msg.Original, ts, msg.Timestamp, params)
+}
+
+// NoErrCreated indicated successful creation of an object (201).
+func NoErrCreated(id, topic string, ts time.Time) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Id: id,
+ Code: http.StatusCreated, // 201
+ Text: "created",
+ Topic: topic,
+ Timestamp: ts,
+ },
+ Id: id,
+ Timestamp: ts,
+ }
+}
+
+// NoErrAccepted indicates request was accepted but not processed yet (202).
+func NoErrAccepted(id, topic string, ts time.Time) *ServerComMessage {
+ return NoErrAcceptedExplicitTs(id, topic, ts, ts)
+}
+
+// NoErrAcceptedExplicitTs indicates request was accepted but not processed yet
+// with explicit server and incoming request timestamps (202).
+func NoErrAcceptedExplicitTs(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Id: id,
+ Code: http.StatusAccepted, // 202
+ Text: "accepted",
+ Topic: topic,
+ Timestamp: serverTs,
+ }, Id: id,
+ Timestamp: incomingReqTs,
+ }
+}
+
+// NoContentParams indicates request was processed but resulted in no content (204).
+func NoContentParams(id, topic string, serverTs, incomingReqTs time.Time, params any) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Id: id,
+ Code: http.StatusNoContent, // 204
+ Text: "no content",
+ Topic: topic,
+ Params: params,
+ Timestamp: serverTs,
+ },
+ Id: id,
+ Timestamp: incomingReqTs,
+ }
+}
+
+// NoContentParamsReply indicates request was processed but resulted in no content
+// in response to a client request (204).
+func NoContentParamsReply(msg *ClientComMessage, ts time.Time, params any) *ServerComMessage {
+ return NoContentParams(msg.Id, msg.Original, ts, msg.Timestamp, params)
+}
+
+// NoErrEvicted indicates that the user was disconnected from topic for no fault of the user (205).
+func NoErrEvicted(id, topic string, ts time.Time) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Id: id,
+ Code: http.StatusResetContent, // 205
+ Text: "evicted",
+ Topic: topic,
+ Timestamp: ts,
+ }, Id: id,
+ }
+}
+
+// NoErrShutdown means user was disconnected from topic because system shutdown is in progress (205).
+func NoErrShutdown(ts time.Time) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Code: http.StatusResetContent, // 205
+ Text: "server shutdown",
+ Timestamp: ts,
+ },
+ }
+}
+
+// NoErrDeliveredParams means requested content has been delivered (208).
+func NoErrDeliveredParams(id, topic string, ts time.Time, params any) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Id: id,
+ Code: http.StatusAlreadyReported, // 208
+ Text: "delivered",
+ Topic: topic,
+ Params: params,
+ Timestamp: ts,
+ },
+ Id: id,
+ }
+}
+
+// 3xx
+
+// InfoValidateCredentials requires user to confirm credentials before going forward (300).
+func InfoValidateCredentials(id string, ts time.Time) *ServerComMessage {
+ return InfoValidateCredentialsExplicitTs(id, ts, ts)
+}
+
+// InfoValidateCredentialsExplicitTs requires user to confirm credentials before going forward
+// with explicit server and incoming request timestamps (300).
+func InfoValidateCredentialsExplicitTs(id string, serverTs, incomingReqTs time.Time) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Id: id,
+ Code: http.StatusMultipleChoices, // 300
+ Text: "validate credentials",
+ Timestamp: serverTs,
+ },
+ Id: id,
+ Timestamp: incomingReqTs,
+ }
+}
+
+// InfoChallenge requires user to respond to presented challenge before login can be completed (300).
+func InfoChallenge(id string, ts time.Time, challenge []byte) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Id: id,
+ Code: http.StatusMultipleChoices, // 300
+ Text: "challenge",
+ Params: map[string]any{"challenge": challenge},
+ Timestamp: ts,
+ },
+ Id: id,
+ Timestamp: ts,
+ }
+}
+
+// InfoAuthReset is sent in response to request to reset authentication when it was completed
+// but login was not performed (301).
+func InfoAuthReset(id string, ts time.Time) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Id: id,
+ Code: http.StatusMovedPermanently, // 301
+ Text: "auth reset",
+ Timestamp: ts,
+ },
+ Id: id,
+ Timestamp: ts,
+ }
+}
+
+// InfoUseOther is a response to a subscription request redirecting client to another topic (303).
+func InfoUseOther(id, topic, other string, serverTs, incomingReqTs time.Time) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Id: id,
+ Code: http.StatusSeeOther, // 303
+ Text: "use other",
+ Topic: topic,
+ Params: map[string]string{"topic": other},
+ Timestamp: serverTs,
+ },
+ Id: id,
+ Timestamp: incomingReqTs,
+ }
+}
+
+// InfoUseOtherReply is a response to a subscription request redirecting client to another topic (303).
+func InfoUseOtherReply(msg *ClientComMessage, other string, ts time.Time) *ServerComMessage {
+ return InfoUseOther(msg.Id, msg.Original, other, ts, msg.Timestamp)
+}
+
+// InfoAlreadySubscribed response means request to subscribe was ignored because user is already subscribed (304).
+func InfoAlreadySubscribed(id, topic string, ts time.Time) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Id: id,
+ Code: http.StatusNotModified, // 304
+ Text: "already subscribed",
+ Topic: topic,
+ Timestamp: ts,
+ },
+ Id: id, Timestamp: ts,
+ }
+}
+
+// InfoNotJoined response means request to leave was ignored because user was not subscribed (304).
+func InfoNotJoined(id, topic string, ts time.Time) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Id: id,
+ Code: http.StatusNotModified, // 304
+ Text: "not joined",
+ Topic: topic,
+ Timestamp: ts,
+ },
+ Id: id,
+ Timestamp: ts,
+ }
+}
+
+// InfoNoAction response means request was ignored because the object was already in the desired state
+// with explicit server and incoming request timestamps (304).
+func InfoNoAction(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Id: id,
+ Code: http.StatusNotModified, // 304
+ Text: "no action",
+ Topic: topic,
+ Timestamp: serverTs,
+ },
+ Id: id,
+ Timestamp: incomingReqTs,
+ }
+}
+
+// InfoNoActionReply response means request was ignored because the object was already in the desired state
+// in response to a client request (304).
+func InfoNoActionReply(msg *ClientComMessage, ts time.Time) *ServerComMessage {
+ return InfoNoAction(msg.Id, msg.Original, ts, msg.Timestamp)
+}
+
+// InfoNotModified response means update request was a noop (304).
+func InfoNotModified(id, topic string, ts time.Time) *ServerComMessage {
+ return InfoNotModifiedExplicitTs(id, topic, ts, ts)
+}
+
+// InfoNotModifiedReply response means update request was a noop
+// in response to a client request (304).
+func InfoNotModifiedReply(msg *ClientComMessage, ts time.Time) *ServerComMessage {
+ return InfoNotModifiedExplicitTs(msg.Id, msg.Original, ts, msg.Timestamp)
+}
+
+// InfoNotModifiedExplicitTs response means update request was a noop
+// with explicit server and incoming request timestamps (304).
+func InfoNotModifiedExplicitTs(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Id: id,
+ Code: http.StatusNotModified, // 304
+ Text: "not modified",
+ Topic: topic,
+ Timestamp: serverTs,
+ },
+ Id: id,
+ Timestamp: incomingReqTs,
+ }
+}
+
+// InfoFound redirects to a new resource (307).
+func InfoFound(id, topic string, ts time.Time) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Id: id,
+ Code: http.StatusTemporaryRedirect, // 307
+ Text: "found",
+ Topic: topic,
+ Timestamp: ts,
+ },
+ Id: id,
+ Timestamp: ts,
+ }
+}
+
+// 4xx Errors
+
+// ErrMalformed request malformed (400).
+func ErrMalformed(id, topic string, ts time.Time) *ServerComMessage {
+ return ErrMalformedExplicitTs(id, topic, ts, ts)
+}
+
+// ErrMalformedReply request malformed
+// in response to a client request (400).
+func ErrMalformedReply(msg *ClientComMessage, ts time.Time) *ServerComMessage {
+ return ErrMalformedExplicitTs(msg.Id, msg.Original, ts, msg.Timestamp)
+}
+
+// ErrMalformedExplicitTs request malformed with explicit server and incoming request timestamps (400).
+func ErrMalformedExplicitTs(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Id: id,
+ Code: http.StatusBadRequest, // 400
+ Text: "malformed",
+ Topic: topic,
+ Timestamp: serverTs,
+ },
+ Id: id,
+ Timestamp: incomingReqTs,
+ }
+}
+
+// ErrAuthRequired authentication required - user must authenticate first (401).
+func ErrAuthRequired(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Id: id,
+ Code: http.StatusUnauthorized, // 401
+ Text: "authentication required",
+ Topic: topic,
+ Timestamp: serverTs,
+ },
+ Id: id,
+ Timestamp: incomingReqTs,
+ }
+}
+
+// ErrAuthRequiredReply authentication required - user must authenticate first
+// in response to a client request (401).
+func ErrAuthRequiredReply(msg *ClientComMessage, ts time.Time) *ServerComMessage {
+ return ErrAuthRequired(msg.Id, msg.Original, ts, msg.Timestamp)
+}
+
+// ErrAuthFailed authentication failed
+// with explicit server and incoming request timestamps (401).
+func ErrAuthFailed(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Id: id,
+ Code: http.StatusUnauthorized, // 401
+ Text: "authentication failed",
+ Topic: topic,
+ Timestamp: serverTs,
+ },
+ Id: id,
+ Timestamp: incomingReqTs,
+ }
+}
+
+// ErrAuthUnknownScheme authentication scheme is unrecognized or invalid (401).
+func ErrAuthUnknownScheme(id, topic string, ts time.Time) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Id: id,
+ Code: http.StatusUnauthorized, // 401
+ Text: "unknown authentication scheme",
+ Topic: topic,
+ Timestamp: ts,
+ },
+ Id: id,
+ Timestamp: ts,
+ }
+}
+
+// ErrPermissionDenied user is authenticated but operation is not permitted (403).
+func ErrPermissionDenied(id, topic string, ts time.Time) *ServerComMessage {
+ return ErrPermissionDeniedExplicitTs(id, topic, ts, ts)
+}
+
+// ErrPermissionDeniedExplicitTs user is authenticated but operation is not permitted
+// with explicit server and incoming request timestamps (403).
+func ErrPermissionDeniedExplicitTs(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Id: id,
+ Code: http.StatusForbidden, // 403
+ Text: "permission denied",
+ Topic: topic,
+ Timestamp: serverTs,
+ },
+ Id: id,
+ Timestamp: incomingReqTs,
+ }
+}
+
+// ErrPermissionDeniedReply user is authenticated but operation is not permitted
+// with explicit server and incoming request timestamps in response to a client request (403).
+func ErrPermissionDeniedReply(msg *ClientComMessage, ts time.Time) *ServerComMessage {
+ return ErrPermissionDeniedExplicitTs(msg.Id, msg.Original, ts, msg.Timestamp)
+}
+
+// ErrAPIKeyRequired valid API key is required (403).
+func ErrAPIKeyRequired(ts time.Time) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Code: http.StatusForbidden,
+ Text: "valid API key required",
+ Timestamp: ts,
+ },
+ }
+}
+
+// ErrSessionNotFound valid API key is required (403).
+func ErrSessionNotFound(ts time.Time) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Code: http.StatusForbidden,
+ Text: "invalid or expired session",
+ Timestamp: ts,
+ },
+ }
+}
+
+// ErrTopicNotFound topic is not found
+// with explicit server and incoming request timestamps (404).
+func ErrTopicNotFound(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Id: id,
+ Code: http.StatusNotFound,
+ Text: "topic not found", // 404
+ Topic: topic,
+ Timestamp: serverTs,
+ },
+ Id: id,
+ Timestamp: incomingReqTs,
+ }
+}
+
+// ErrTopicNotFoundReply topic is not found
+// with explicit server and incoming request timestamps
+// in response to a client request (404).
+func ErrTopicNotFoundReply(msg *ClientComMessage, ts time.Time) *ServerComMessage {
+ return ErrTopicNotFound(msg.Id, msg.Original, ts, msg.Timestamp)
+}
+
+// ErrUserNotFound user is not found
+// with explicit server and incoming request timestamps (404).
+func ErrUserNotFound(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Id: id,
+ Code: http.StatusNotFound, // 404
+ Text: "user not found",
+ Topic: topic,
+ Timestamp: serverTs,
+ },
+ Id: id,
+ Timestamp: incomingReqTs,
+ }
+}
+
+// ErrUserNotFoundReply user is not found
+// with explicit server and incoming request timestamps in response to a client request (404).
+func ErrUserNotFoundReply(msg *ClientComMessage, ts time.Time) *ServerComMessage {
+ return ErrUserNotFound(msg.Id, msg.Original, ts, msg.Timestamp)
+}
+
+// ErrNotFound is an error for missing objects other than user or topic (404).
+func ErrNotFound(id, topic string, ts time.Time) *ServerComMessage {
+ return ErrNotFoundExplicitTs(id, topic, ts, ts)
+}
+
+// ErrNotFoundExplicitTs is an error for missing objects other than user or topic
+// with explicit server and incoming request timestamps (404).
+func ErrNotFoundExplicitTs(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Id: id,
+ Code: http.StatusNotFound, // 404
+ Text: "not found",
+ Topic: topic,
+ Timestamp: serverTs,
+ },
+ Id: id,
+ Timestamp: incomingReqTs,
+ }
+}
+
+// ErrNotFoundReply is an error for missing objects other than user or topic
+// with explicit server and incoming request timestamps in response to a client request (404).
+func ErrNotFoundReply(msg *ClientComMessage, ts time.Time) *ServerComMessage {
+ return ErrNotFoundExplicitTs(msg.Id, msg.Original, ts, msg.Timestamp)
+}
+
+// ErrOperationNotAllowed a valid operation is not permitted in this context (405).
+func ErrOperationNotAllowed(id, topic string, ts time.Time) *ServerComMessage {
+ return ErrOperationNotAllowedExplicitTs(id, topic, ts, ts)
+}
+
+// ErrOperationNotAllowedExplicitTs a valid operation is not permitted in this context
+// with explicit server and incoming request timestamps (405).
+func ErrOperationNotAllowedExplicitTs(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Id: id,
+ Code: http.StatusMethodNotAllowed, // 405
+ Text: "operation or method not allowed",
+ Topic: topic,
+ Timestamp: serverTs,
+ },
+ Id: id,
+ Timestamp: incomingReqTs,
+ }
+}
+
+// ErrOperationNotAllowedReply a valid operation is not permitted in this context
+// with explicit server and incoming request timestamps (405).
+func ErrOperationNotAllowedReply(msg *ClientComMessage, ts time.Time) *ServerComMessage {
+ return ErrOperationNotAllowedExplicitTs(msg.Id, msg.Original, ts, msg.Timestamp)
+}
+
+// ErrInvalidResponse indicates that the client's response in invalid
+// with explicit server and incoming request timestamps (406).
+func ErrInvalidResponse(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Id: id,
+ Code: http.StatusNotAcceptable, // 406
+ Text: "invalid response",
+ Topic: topic,
+ Timestamp: serverTs,
+ },
+ Id: id,
+ Timestamp: incomingReqTs,
+ }
+}
+
+// ErrDisconnected indicates that client disconnected or failed to send data in a timely
+// manner (408).
+func ErrDisconnected(id, topic string, ts time.Time) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Id: id,
+ Code: http.StatusRequestTimeout, // 408
+ Text: "disconnected",
+ Topic: topic,
+ Timestamp: ts,
+ },
+ Id: id,
+ Timestamp: ts,
+ }
+}
+
+// ErrAlreadyAuthenticated invalid attempt to authenticate an already authenticated session.
+// Switching users is not supported (409).
+func ErrAlreadyAuthenticated(id, topic string, ts time.Time) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Id: id,
+ Code: http.StatusConflict, // 409
+ Text: "already authenticated",
+ Topic: topic,
+ Timestamp: ts,
+ },
+ Id: id,
+ Timestamp: ts,
+ }
+}
+
+// ErrDuplicateCredential attempt to create a duplicate credential
+// with explicit server and incoming request timestamps (409).
+func ErrDuplicateCredential(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Id: id,
+ Code: http.StatusConflict, // 409
+ Text: "duplicate credential",
+ Topic: topic,
+ Timestamp: serverTs,
+ },
+ Id: id,
+ Timestamp: incomingReqTs,
+ }
+}
+
+// ErrAttachFirst must attach to topic first in response to a client message (409).
+func ErrAttachFirst(msg *ClientComMessage, ts time.Time) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Id: msg.Id,
+ Code: http.StatusConflict, // 409
+ Text: "must attach first",
+ Topic: msg.Original,
+ Timestamp: ts,
+ },
+ Id: msg.Id,
+ Timestamp: msg.Timestamp,
+ }
+}
+
+// ErrAlreadyExists the object already exists (409).
+func ErrAlreadyExists(id, topic string, ts time.Time) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Id: id,
+ Code: http.StatusConflict, // 409
+ Text: "already exists",
+ Topic: topic,
+ Timestamp: ts,
+ },
+ Id: id,
+ Timestamp: ts,
+ }
+}
+
+// ErrCommandOutOfSequence invalid sequence of comments, i.e. attempt to {sub} before {hi} (409).
+func ErrCommandOutOfSequence(id, unused string, ts time.Time) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Id: id,
+ Code: http.StatusConflict, // 409
+ Text: "command out of sequence",
+ Timestamp: ts,
+ },
+ Id: id,
+ Timestamp: ts,
+ }
+}
+
+// ErrGone topic deleted or user banned (410).
+func ErrGone(id, topic string, ts time.Time) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Id: id,
+ Code: http.StatusGone, // 410
+ Text: "gone",
+ Topic: topic,
+ Timestamp: ts,
+ },
+ Id: id,
+ Timestamp: ts,
+ }
+}
+
+// ErrTooLarge packet or request size exceeded the limit (413).
+func ErrTooLarge(id, topic string, ts time.Time) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Id: id,
+ Code: http.StatusRequestEntityTooLarge, // 413
+ Text: "too large",
+ Topic: topic,
+ Timestamp: ts,
+ },
+ Id: id,
+ Timestamp: ts,
+ }
+}
+
+// ErrPolicy request violates a policy (e.g. password is too weak or too many subscribers) (422).
+func ErrPolicy(id, topic string, ts time.Time) *ServerComMessage {
+ return ErrPolicyExplicitTs(id, topic, ts, ts)
+}
+
+// ErrPolicyExplicitTs request violates a policy (e.g. password is too weak or too many subscribers)
+// with explicit server and incoming request timestamps (422).
+func ErrPolicyExplicitTs(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Id: id,
+ Code: http.StatusUnprocessableEntity, // 422
+ Text: "policy violation",
+ Topic: topic,
+ Timestamp: serverTs,
+ },
+ Id: id,
+ Timestamp: incomingReqTs,
+ }
+}
+
+// ErrPolicyReply request violates a policy (e.g. password is too weak or too many subscribers)
+// with explicit server and incoming request timestamps in response to a client request (422).
+func ErrPolicyReply(msg *ClientComMessage, ts time.Time) *ServerComMessage {
+ return ErrPolicyExplicitTs(msg.Id, msg.Original, ts, msg.Timestamp)
+}
+
+// ErrCallBusyExplicitTs indicates a "busy" reply to a video call request (486).
+func ErrCallBusyExplicitTs(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Id: id,
+ Code: 486, // Busy here.
+ Text: "busy here",
+ Topic: topic,
+ Timestamp: serverTs,
+ },
+ Id: id,
+ Timestamp: incomingReqTs,
+ }
+}
+
+// ErrCallBusyReply indicates a "busy" reply in response to a video call request (486)
+func ErrCallBusyReply(msg *ClientComMessage, ts time.Time) *ServerComMessage {
+ return ErrCallBusyExplicitTs(msg.Id, msg.Original, ts, msg.Timestamp)
+}
+
+// ErrUnknown database or other server error (500).
+func ErrUnknown(id, topic string, ts time.Time) *ServerComMessage {
+ return ErrUnknownExplicitTs(id, topic, ts, ts)
+}
+
+// ErrUnknownExplicitTs database or other server error with explicit server and incoming request timestamps (500).
+func ErrUnknownExplicitTs(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Id: id,
+ Code: http.StatusInternalServerError, // 500
+ Text: "internal error",
+ Topic: topic,
+ Timestamp: serverTs,
+ },
+ Id: id,
+ Timestamp: incomingReqTs,
+ }
+}
+
+// ErrUnknownReply database or other server error in response to a client request (500).
+func ErrUnknownReply(msg *ClientComMessage, ts time.Time) *ServerComMessage {
+ return ErrUnknownExplicitTs(msg.Id, msg.Original, ts, msg.Timestamp)
+}
+
+// ErrNotImplemented feature not implemented with explicit server and incoming request timestamps (501).
+// TODO: consider changing status code to 4XX.
+func ErrNotImplemented(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Id: id,
+ Code: http.StatusNotImplemented, // 501
+ Text: "not implemented",
+ Topic: topic,
+ Timestamp: serverTs,
+ },
+ Id: id,
+ Timestamp: incomingReqTs,
+ }
+}
+
+// ErrNotImplementedReply feature not implemented error in response to a client request (501).
+func ErrNotImplementedReply(msg *ClientComMessage, ts time.Time) *ServerComMessage {
+ return ErrNotImplemented(msg.Id, msg.Original, ts, msg.Timestamp)
+}
+
+// ErrClusterUnreachableReply in-cluster communication has failed error as response to a client request (502).
+func ErrClusterUnreachableReply(msg *ClientComMessage, ts time.Time) *ServerComMessage {
+ return ErrClusterUnreachableExplicitTs(msg.Id, msg.Original, ts, msg.Timestamp)
+}
+
+// ErrClusterUnreachable in-cluster communication has failed error with explicit server and
+// incoming request timestamps (502).
+func ErrClusterUnreachableExplicitTs(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Id: id,
+ Code: http.StatusBadGateway, // 502
+ Text: "cluster unreachable",
+ Topic: topic,
+ Timestamp: serverTs,
+ },
+ Id: id,
+ Timestamp: incomingReqTs,
+ }
+}
+
+// ErrServiceUnavailableReply server overloaded error in response to a client request (503).
+func ErrServiceUnavailableReply(msg *ClientComMessage, ts time.Time) *ServerComMessage {
+ return ErrServiceUnavailableExplicitTs(msg.Id, msg.Original, ts, msg.Timestamp)
+}
+
+// ErrServiceUnavailableExplicitTs server overloaded error with explicit server and
+// incoming request timestamps (503).
+func ErrServiceUnavailableExplicitTs(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Id: id,
+ Code: http.StatusServiceUnavailable, // 503
+ Text: "service unavailable",
+ Topic: topic,
+ Timestamp: serverTs,
+ },
+ Id: id,
+ Timestamp: incomingReqTs,
+ }
+}
+
+// ErrLocked operation rejected because the topic is being deleted (503).
+func ErrLocked(id, topic string, ts time.Time) *ServerComMessage {
+ return ErrLockedExplicitTs(id, topic, ts, ts)
+}
+
+// ErrLockedReply operation rejected because the topic is being deleted in response
+// to a client request (503).
+func ErrLockedReply(msg *ClientComMessage, ts time.Time) *ServerComMessage {
+ return ErrLockedExplicitTs(msg.Id, msg.Original, ts, msg.Timestamp)
+}
+
+// ErrLockedExplicitTs operation rejected because the topic is being deleted
+// with explicit server and incoming request timestamps (503).
+func ErrLockedExplicitTs(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Id: id,
+ Code: http.StatusServiceUnavailable, // 503
+ Text: "locked",
+ Topic: topic,
+ Timestamp: serverTs,
+ },
+ Id: id,
+ Timestamp: incomingReqTs,
+ }
+}
+
+// ErrVersionNotSupported invalid (too low) protocol version (505).
+func ErrVersionNotSupported(id string, ts time.Time) *ServerComMessage {
+ return &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Id: id,
+ Code: http.StatusHTTPVersionNotSupported, // 505
+ Text: "version not supported",
+ Timestamp: ts,
+ },
+ Id: id,
+ Timestamp: ts,
+ }
+}
diff --git a/server/db/adapter.go b/server/db/adapter.go
index e69de29..3f41f2b 100644
--- a/server/db/adapter.go
+++ b/server/db/adapter.go
@@ -0,0 +1,295 @@
+// Package adapter contains the interfaces to be implemented by the database adapter
+package adapter
+
+import (
+ "encoding/json"
+ "time"
+
+ "github.com/tinode/chat/server/auth"
+ t "github.com/tinode/chat/server/store/types"
+)
+
+// Adapter is the interface that must be implemented by a database
+// adapter. The current schema supports a single connection by database type.
+type Adapter interface {
+ // General
+
+ // Open and configure the adapter
+ Open(config json.RawMessage) error
+ // Close the adapter
+ Close() error
+ // IsOpen checks if the adapter is ready for use
+ IsOpen() bool
+ // GetDbVersion returns current database version.
+ GetDbVersion() (int, error)
+ // CheckDbVersion checks if the actual database version matches adapter version.
+ CheckDbVersion() error
+ // GetName returns the name of the adapter
+ GetName() string
+ // SetMaxResults configures how many results can be returned in a single DB call.
+ SetMaxResults(val int) error
+ // CreateDb creates the database optionally dropping an existing database first.
+ CreateDb(reset bool) error
+ // UpgradeDb upgrades database to the current adapter version.
+ UpgradeDb() error
+ // Version returns adapter version
+ Version() int
+ // DB connection stats object.
+ Stats() any
+
+ // User management
+
+ // UserCreate creates user record
+ UserCreate(user *t.User) error
+ // UserGet returns record for a given user ID
+ UserGet(uid t.Uid) (*t.User, error)
+ // UserGetAll returns user records for a given list of user IDs
+ UserGetAll(ids ...t.Uid) ([]t.User, error)
+ // UserDelete deletes user record
+ UserDelete(uid t.Uid, hard bool) error
+ // UserUpdate updates user record
+ UserUpdate(uid t.Uid, update map[string]any) error
+ // UserUpdateTags adds, removes, or resets user's tags
+ UserUpdateTags(uid t.Uid, add, remove, reset []string) ([]string, error)
+ // UserGetByCred returns user ID for the given validated credential.
+ UserGetByCred(method, value string) (t.Uid, error)
+ // UserUnreadCount returns the total number of unread messages in all topics with
+ // the R permission. If read fails, the counts are still returned with the original
+ // user IDs but with the unread count undefined and non-nil error.
+ UserUnreadCount(ids ...t.Uid) (map[t.Uid]int, error)
+ // UserGetUnvalidated returns a list of no more than 'limit' uids who never logged in,
+ // have no validated credentials and which haven't been updated since 'lastUpdatedBefore'.
+ UserGetUnvalidated(lastUpdatedBefore time.Time, limit int) ([]t.Uid, error)
+
+ // Credential management
+
+ // CredUpsert adds or updates a credential record. Returns true if record was inserted, false if updated.
+ CredUpsert(cred *t.Credential) (bool, error)
+ // CredGetActive returns the currently active credential record for the given method.
+ CredGetActive(uid t.Uid, method string) (*t.Credential, error)
+ // CredGetAll returns credential records for the given user and method, validated only or all.
+ CredGetAll(uid t.Uid, method string, validatedOnly bool) ([]t.Credential, error)
+ // CredDel deletes credentials for the given method/value. If method is empty, deletes all
+ // user's credentials.
+ CredDel(uid t.Uid, method, value string) error
+ // CredConfirm marks given credential as validated.
+ CredConfirm(uid t.Uid, method string) error
+ // CredFail increments count of failed validation attepmts for the given credentials.
+ CredFail(uid t.Uid, method string) error
+
+ // Authentication management for the basic authentication scheme
+
+ // AuthGetUniqueRecord returns authentication record for a given unique value i.e. login.
+ AuthGetUniqueRecord(unique string) (t.Uid, auth.Level, []byte, time.Time, error)
+ // AuthGetRecord returns authentication record given user ID and method.
+ AuthGetRecord(user t.Uid, scheme string) (string, auth.Level, []byte, time.Time, error)
+ // AuthAddRecord creates new authentication record
+ AuthAddRecord(user t.Uid, scheme, unique string, authLvl auth.Level, secret []byte, expires time.Time) error
+ // AuthDelScheme deletes an existing authentication scheme for the user.
+ AuthDelScheme(user t.Uid, scheme string) error
+ // AuthDelAllRecords deletes all records of a given user.
+ AuthDelAllRecords(uid t.Uid) (int, error)
+ // AuthUpdRecord modifies an authentication record. Only non-default/non-zero values are updated.
+ AuthUpdRecord(user t.Uid, scheme, unique string, authLvl auth.Level, secret []byte, expires time.Time) error
+
+ // Topic management
+
+ // TopicCreate creates a topic
+ TopicCreate(topic *t.Topic) error
+ // TopicCreateP2P creates a p2p topic
+ TopicCreateP2P(initiator, invited *t.Subscription) error
+ // TopicGet loads a single topic by name, if it exists. If the topic does not exist the call returns (nil, nil)
+ TopicGet(topic string) (*t.Topic, error)
+ // TopicsForUser loads subscriptions for a given user. Reads public value.
+ // When the 'opts.IfModifiedSince' query is not nil the subscriptions with UpdatedAt > opts.IfModifiedSince
+ // are returned, where UpdatedAt can be either a subscription, a topic, or a user update timestamp.
+ // This is need in order to support paginagion of subscriptions: get subscriptions page by page
+ // from the oldest updates to most recent:
+ // 1. Client already has subscriptions with the latest update timestamp X.
+ // 2. Client asks for N updated subscriptions since X. The server returns N with updates between X and Y.
+ // 3. Client goes to step 1 with X := Y.
+ TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error)
+ // UsersForTopic loads users' subscriptions for a given topic. Public is loaded.
+ UsersForTopic(topic string, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error)
+ // OwnTopics loads a slice of topic names where the user is the owner.
+ OwnTopics(uid t.Uid) ([]string, error)
+ // ChannelsForUser loads a slice of topic names where the user is a channel reader and notifications (P) are enabled.
+ ChannelsForUser(uid t.Uid) ([]string, error)
+ // TopicShare creates topic subscriptions.
+ TopicShare(topic string, subs []*t.Subscription) error
+ // TopicDelete deletes topic, subscriptions, messages.
+ TopicDelete(topic string, isChan, hard bool) error
+ // TopicUpdateOnMessage increments Topic's or User's SeqId value and updates TouchedAt timestamp.
+ TopicUpdateOnMessage(topic string, msg *t.Message) error
+ // TopicUpdateSubCnt refreshes denormalized topic subscriber count.
+ TopicUpdateSubCnt(topic string) error
+ // TopicUpdate updates topic record.
+ TopicUpdate(topic string, update map[string]any) error
+ // TopicOwnerChange updates topic's owner
+ TopicOwnerChange(topic string, newOwner t.Uid) error
+
+ // Topic subscriptions
+
+ // SubscriptionGet reads a subscription of a user to a topic
+ SubscriptionGet(topic string, user t.Uid, keepDeleted bool) (*t.Subscription, error)
+ // SubsForUser loads all subscriptions of a given user. Does NOT load Public or Private values,
+ // does not load deleted subscriptions.
+ SubsForUser(user t.Uid) ([]t.Subscription, error)
+ // SubsForTopic gets a list of subscriptions to a given topic.. Does NOT load Public value.
+ SubsForTopic(topic string, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error)
+ // SubsUpdate updates pasrt of a subscription object. Pass nil for fields which don't need to be updated
+ SubsUpdate(topic string, user t.Uid, update map[string]any) error
+ // SubsDelete deletes a single subscription
+ SubsDelete(topic string, user t.Uid) error
+
+ // Search
+
+ // Find searches for users or topics given a list of tags.
+ // - caller is the user or topic who is doing the searching, it will be skipped from results.
+ // - prefix if present will cause match rank highest in the results.
+ // - req is a list of required tag sets. Each set is a list of tags. The search will return
+ // all users/topics which have at least one tag from each set.
+ // - opt is a list of optional tags; if present the result will rank higher.
+ // - activeOnly if true will return only active subscriptions.
+ Find(caller, prefix string, req [][]string, opt []string, activeOnly bool) ([]t.Subscription, error)
+ // FindOne returns topic or user which matches the given tag.
+ FindOne(tag string) (string, error)
+
+ // Messages
+
+ // MessageSave saves message to database
+ MessageSave(msg *t.Message) error
+ // MessageGetAll returns messages matching the query
+ MessageGetAll(topic string, forUser t.Uid, opts *t.QueryOpt) ([]t.Message, error)
+ // MessageDeleteList marks messages as deleted.
+ // Soft- or Hard- is defined by forUser value: forUser.IsZero == true is hard.
+ MessageDeleteList(topic string, toDel *t.DelMessage) error
+ // MessageGetDeleted returns a list of deleted message Ids.
+ MessageGetDeleted(topic string, forUser t.Uid, opts *t.QueryOpt) ([]t.DelMessage, error)
+
+ // Devices (for push notifications)
+
+ // DeviceUpsert creates or updates a device record
+ DeviceUpsert(uid t.Uid, dev *t.DeviceDef) error
+ // DeviceGetAll returns all devices for a given set of users
+ DeviceGetAll(uid ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error)
+ // DeviceDelete deletes a device record
+ DeviceDelete(uid t.Uid, deviceID string) error
+
+ // File upload records. The files are stored outside of the database.
+
+ // FileStartUpload initializes a file upload.
+ FileStartUpload(fd *t.FileDef) error
+ // FileFinishUpload marks file upload as completed, successfully or otherwise.
+ FileFinishUpload(fd *t.FileDef, success bool, size int64) (*t.FileDef, error)
+ // FileGet fetches a record of a specific file
+ FileGet(fid string) (*t.FileDef, error)
+ // FileDeleteUnused deletes records where UseCount is zero. If olderThan is non-zero, deletes
+ // unused records with UpdatedAt before olderThan.
+ // Returns array of FileDef.Location of deleted filerecords so actual files can be deleted too.
+ FileDeleteUnused(olderThan time.Time, limit int) ([]string, error)
+ // FileLinkAttachments connects given topic or message to the file record IDs from the list.
+ FileLinkAttachments(topic string, userId, msgId t.Uid, fids []string) error
+
+ // Persistent cache management.
+
+ // PCacheGet reads a persistent cache entry.
+ PCacheGet(key string) (string, error)
+ // PCacheUpsert creates or updates a persistent cache entry.
+ PCacheUpsert(key string, value string, failOnDuplicate bool) error
+ // PCacheDelete deletes a single persistent cache entry.
+ PCacheDelete(key string) error
+ // PCacheExpire expires older entries with the specified key prefix.
+ PCacheExpire(keyPrefix string, olderThan time.Time) error
+
+ // Testing
+
+ // GetTestDB returns a currently open database connection.
+ GetTestDB() any
+}
+
+// TenantAdapter is an optional capability implemented by tenant-aware database adapters.
+// Keeping it separate lets adapters be migrated independently while tenant-aware entry
+// points fail closed when the configured adapter does not support tenant resolution.
+type TenantAdapter interface {
+ TenantGetByCode(code string) (*t.Tenant, error)
+}
+
+// TenantAuthAdapter is the tenant-aware authentication and credential capability.
+// It is separate from Adapter while the remaining persistence domains are migrated.
+type TenantAuthAdapter interface {
+ TenantAuthGetUniqueRecord(tenantID t.TenantID, unique string) (t.Uid, auth.Level, []byte, time.Time, error)
+ TenantAuthGetRecord(tenantID t.TenantID, user t.Uid, scheme string) (string, auth.Level, []byte, time.Time, error)
+ TenantAuthAddRecord(tenantID t.TenantID, user t.Uid, scheme, unique string, authLvl auth.Level,
+ secret []byte, expires time.Time) error
+ TenantAuthDelScheme(tenantID t.TenantID, user t.Uid, scheme string) error
+ TenantAuthUpdRecord(tenantID t.TenantID, user t.Uid, scheme, unique string, authLvl auth.Level,
+ secret []byte, expires time.Time) error
+ TenantUserGetByCred(tenantID t.TenantID, method, value string) (t.Uid, error)
+ TenantCredUpsert(tenantID t.TenantID, cred *t.Credential) (bool, error)
+ TenantCredGetActive(tenantID t.TenantID, uid t.Uid, method string) (*t.Credential, error)
+ TenantCredGetAll(tenantID t.TenantID, uid t.Uid, method string, validatedOnly bool) ([]t.Credential, error)
+ TenantCredDel(tenantID t.TenantID, uid t.Uid, method, value string) error
+ TenantCredConfirm(tenantID t.TenantID, uid t.Uid, method string) error
+ TenantCredFail(tenantID t.TenantID, uid t.Uid, method string) error
+}
+
+// TenantBusinessAdapter is the mandatory tenant-aware persistence capability
+// used by request handling code. Adapters which do not implement it fail closed
+// at the Store boundary instead of falling back to legacy unscoped queries.
+type TenantBusinessAdapter interface {
+ // TenantBusinessReady reports whether every method below enforces tenant
+ // isolation in its database queries. Startup is rejected while false.
+ TenantBusinessReady() bool
+
+ TenantUserCreate(t.TenantID, *t.User) error
+ TenantUserGet(t.TenantID, t.Uid) (*t.User, error)
+ TenantUserGetAll(t.TenantID, ...t.Uid) ([]t.User, error)
+ TenantUserDelete(t.TenantID, t.Uid, bool) error
+ TenantUserUpdate(t.TenantID, t.Uid, map[string]any) error
+ TenantUserUpdateTags(t.TenantID, t.Uid, []string, []string, []string) ([]string, error)
+ TenantUserUnreadCount(t.TenantID, ...t.Uid) (map[t.Uid]int, error)
+ TenantUserGetUnvalidated(t.TenantID, time.Time, int) ([]t.Uid, error)
+
+ TenantTopicCreate(t.TenantID, *t.Topic) error
+ TenantTopicCreateP2P(t.TenantID, *t.Subscription, *t.Subscription) error
+ TenantTopicGet(t.TenantID, string) (*t.Topic, error)
+ TenantTopicsForUser(t.TenantID, t.Uid, bool, *t.QueryOpt) ([]t.Subscription, error)
+ TenantUsersForTopic(t.TenantID, string, bool, *t.QueryOpt) ([]t.Subscription, error)
+ TenantOwnTopics(t.TenantID, t.Uid) ([]string, error)
+ TenantChannelsForUser(t.TenantID, t.Uid) ([]string, error)
+ TenantTopicShare(t.TenantID, string, []*t.Subscription) error
+ TenantTopicDelete(t.TenantID, string, bool, bool) error
+ TenantTopicUpdateOnMessage(t.TenantID, string, *t.Message) error
+ TenantTopicUpdateSubCnt(t.TenantID, string) error
+ TenantTopicUpdate(t.TenantID, string, map[string]any) error
+ TenantTopicOwnerChange(t.TenantID, string, t.Uid) error
+
+ TenantSubscriptionGet(t.TenantID, string, t.Uid, bool) (*t.Subscription, error)
+ TenantSubsForUser(t.TenantID, t.Uid) ([]t.Subscription, error)
+ TenantSubsForTopic(t.TenantID, string, bool, *t.QueryOpt) ([]t.Subscription, error)
+ TenantSubsUpdate(t.TenantID, string, t.Uid, map[string]any) error
+ TenantSubsDelete(t.TenantID, string, t.Uid) error
+ TenantFind(t.TenantID, string, string, [][]string, []string, bool) ([]t.Subscription, error)
+ TenantFindOne(t.TenantID, string) (string, error)
+
+ TenantMessageSave(t.TenantID, *t.Message) error
+ TenantMessageGetAll(t.TenantID, string, t.Uid, *t.QueryOpt) ([]t.Message, error)
+ TenantMessageDeleteList(t.TenantID, string, *t.DelMessage) error
+ TenantMessageGetDeleted(t.TenantID, string, t.Uid, *t.QueryOpt) ([]t.DelMessage, error)
+
+ TenantDeviceUpsert(t.TenantID, t.Uid, *t.DeviceDef) error
+ TenantDeviceGetAll(t.TenantID, ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error)
+ TenantDeviceDelete(t.TenantID, t.Uid, string) error
+
+ TenantFileStartUpload(t.TenantID, *t.FileDef) error
+ TenantFileFinishUpload(t.TenantID, *t.FileDef, bool, int64) (*t.FileDef, error)
+ TenantFileGet(t.TenantID, string) (*t.FileDef, error)
+ TenantFileDeleteUnused(t.TenantID, time.Time, int) ([]string, error)
+ TenantFileLinkAttachments(t.TenantID, string, t.Uid, t.Uid, []string) error
+
+ TenantPCacheGet(t.TenantID, string) (string, error)
+ TenantPCacheUpsert(t.TenantID, string, string, bool) error
+ TenantPCacheDelete(t.TenantID, string) error
+ TenantPCacheExpire(t.TenantID, string, time.Time) error
+}
diff --git a/server/db/common/common.go b/server/db/common/common.go
index e69de29..d1bccf6 100644
--- a/server/db/common/common.go
+++ b/server/db/common/common.go
@@ -0,0 +1,185 @@
+// Package common contains utility methods used by all adapters.
+package common
+
+import (
+ "encoding/json"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/tinode/chat/server/auth"
+ "github.com/tinode/chat/server/store"
+ t "github.com/tinode/chat/server/store/types"
+)
+
+type AuthRecord struct {
+ Unique string `json:"unique" bson:"_id"`
+ UserId string `json:"userid"`
+ Scheme string `json:"scheme"`
+ AuthLvl auth.Level `json:"authLvl"`
+ Secret []byte `json:"secret"`
+ Expires time.Time `json:"expires"`
+}
+
+// SelectEarliestUpdatedSubs selects no more than the given number of subscriptions from the
+// given slice satisfying the query. When the number of subscriptions is greater than the limit,
+// the subscriptions with the earliest timestamp are selected.
+func SelectEarliestUpdatedSubs(subs []t.Subscription, opts *t.QueryOpt, maxResults int) []t.Subscription {
+ limit := maxResults
+ ims := time.Time{}
+ if opts != nil {
+ if opts.Limit > 0 && opts.Limit < limit {
+ limit = opts.Limit
+ }
+ if opts.IfModifiedSince != nil {
+ ims = *opts.IfModifiedSince
+ }
+ }
+
+ // No cache management and the number of results is below the limit: return all.
+ if ims.IsZero() && len(subs) <= limit {
+ return subs
+ }
+
+ // Now that we fetched potentially more subscriptions than needed, we got to take those with the oldest modifications.
+ // Sorting in ascending order by modification time.
+ sort.Slice(subs, func(i, j int) bool {
+ return subs[i].LastModified().Before(subs[j].LastModified())
+ })
+
+ if !ims.IsZero() {
+ // Keep only those subscriptions which are newer than ims.
+ at := sort.Search(len(subs), func(i int) bool {
+ return subs[i].LastModified().After(ims)
+ })
+ subs = subs[at:]
+ }
+ // Trim slice at the limit.
+ if len(subs) > limit {
+ subs = subs[:limit]
+ }
+
+ return subs
+}
+
+// SelectLatestTime picks the latest update timestamp out of the two.
+func SelectLatestTime(t1, t2 time.Time) time.Time {
+ if t1.Before(t2) {
+ // Subscription has not changed recently, use user's update timestamp.
+ return t2
+ }
+
+ return t1
+}
+
+// RangesToSql converts a slice of ranges to SQL BETWEEN or IN() constraint and arguments.
+func RangesToSql(in []t.Range) (string, []any) {
+ if len(in) > 1 || in[0].Hi == 0 {
+ var args []any
+ for _, r := range in {
+ if r.Hi == 0 {
+ args = append(args, r.Low)
+ } else {
+ for i := r.Low; i < r.Hi; i++ {
+ args = append(args, i)
+ }
+ }
+ }
+
+ return "IN (?" + strings.Repeat(",?", len(args)-1) + ")", args
+ }
+
+ // Optimizing for a special case of single range low..hi.
+ // SQL's BETWEEN is inclusive-inclusive thus decrement Hi by 1.
+ return "BETWEEN ? AND ?", []any{in[0].Low, in[0].Hi - 1}
+}
+
+// DisjunctionSql converts a slice of disjunctions to SQL HAVING clause and arguments.
+func DisjunctionSql(req [][]string, fieldName string) (string, []any) {
+ var args []any
+ counts := make([]string, 0, len(req))
+ for _, reqDisjunction := range req {
+ // At least one of the tags must be present.
+ if len(reqDisjunction) == 0 {
+ continue
+ }
+ counts = append(counts, "COUNT("+fieldName+" IN (?"+strings.Repeat(",?", len(reqDisjunction)-1)+") OR NULL)>=1")
+ for _, tag := range reqDisjunction {
+ args = append(args, tag)
+ }
+ }
+ return "HAVING " + strings.Join(counts, " AND ") + " ", args
+}
+
+// FilterFoundTags keeps only those tags in setTags that are present in the index.
+func FilterFoundTags(setTags t.StringSlice, index map[string]struct{}) []string {
+ foundTags := make([]string, 0, 1)
+ for _, tag := range setTags {
+ if _, ok := index[tag]; ok {
+ foundTags = append(foundTags, tag)
+ }
+ }
+ return foundTags
+}
+
+// Convert to JSON before storing to JSON field.
+func ToJSON(src any) []byte {
+ if src == nil {
+ return nil
+ }
+
+ jval, _ := json.Marshal(src)
+ return jval
+}
+
+// Deserialize JSON data from DB.
+func FromJSON(src any) any {
+ if src == nil {
+ return nil
+ }
+ if bb, ok := src.([]byte); ok {
+ var out any
+ json.Unmarshal(bb, &out)
+ return out
+ }
+ return nil
+}
+
+// Convert update to a list of columns and arguments.
+func UpdateByMap(update map[string]any) (cols []string, args []any) {
+ for col, arg := range update {
+ col = strings.ToLower(col)
+ if col == "public" || col == "trusted" || col == "private" || col == "aux" {
+ arg = ToJSON(arg)
+ }
+ cols = append(cols, col+"=?")
+ args = append(args, arg)
+ }
+ return
+}
+
+// If Tags field is updated, get the tags so tags table cab be updated too.
+func ExtractTags(update map[string]any) []string {
+ var tags []string
+
+ if val := update["Tags"]; val != nil {
+ tags, _ = val.(t.StringSlice)
+ }
+
+ return []string(tags)
+}
+
+// EncodeUidString takes decoded string representation of int64, produce UID.
+// UIDs are stored as decoded int64 values.
+func EncodeUidString(str string) t.Uid {
+ unum, _ := strconv.ParseInt(str, 10, 64)
+ return store.EncodeUid(unum)
+}
+
+// DecodeUidString takes UID as string, converts it to int64 representation.
+// UIDs are stored as decoded int64 values.
+func DecodeUidString(str string) int64 {
+ uid := t.ParseUid(str)
+ return store.DecodeUid(uid)
+}
diff --git a/server/db/common/common_test.go b/server/db/common/common_test.go
index e69de29..33b2632 100644
--- a/server/db/common/common_test.go
+++ b/server/db/common/common_test.go
@@ -0,0 +1,395 @@
+package common
+
+import (
+ "encoding/json"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/tinode/chat/server/store/types"
+)
+
+func genTestData() []types.Subscription {
+ var testData = []types.Subscription{
+ {ObjHeader: types.ObjHeader{Id: "1", UpdatedAt: time.Date(2021, time.June, 1, 1, 11, 0, 0, time.Local)}},
+ {ObjHeader: types.ObjHeader{Id: "2", UpdatedAt: time.Date(2021, time.June, 2, 2, 12, 0, 0, time.Local)}},
+ {ObjHeader: types.ObjHeader{Id: "3", UpdatedAt: time.Date(2021, time.June, 3, 3, 13, 0, 0, time.Local)}},
+ {ObjHeader: types.ObjHeader{Id: "4", UpdatedAt: time.Date(2021, time.June, 4, 4, 14, 0, 0, time.Local)}},
+ {ObjHeader: types.ObjHeader{Id: "5", UpdatedAt: time.Date(2021, time.June, 5, 5, 15, 0, 0, time.Local)}},
+ {ObjHeader: types.ObjHeader{Id: "6", UpdatedAt: time.Date(2021, time.June, 6, 6, 16, 0, 0, time.Local)}},
+ {ObjHeader: types.ObjHeader{Id: "7", UpdatedAt: time.Date(2021, time.June, 7, 7, 17, 0, 0, time.Local)}},
+ {ObjHeader: types.ObjHeader{Id: "8", UpdatedAt: time.Date(2021, time.June, 8, 8, 18, 0, 0, time.Local)}},
+ {ObjHeader: types.ObjHeader{Id: "9", UpdatedAt: time.Date(2021, time.June, 9, 9, 19, 0, 0, time.Local)}},
+ {ObjHeader: types.ObjHeader{Id: "10", UpdatedAt: time.Date(2021, time.June, 10, 10, 20, 0, 0, time.Local)}},
+ }
+
+ // TouchedAt is either greater or equal to UpdatedAt.
+ testData[0].SetTouchedAt(time.Date(2021, time.June, 1, 1, 11, 0, 0, time.Local)) // 1
+ testData[1].SetTouchedAt(time.Date(2021, time.June, 4, 4, 12, 0, 0, time.Local)) // 3
+ testData[2].SetTouchedAt(time.Date(2021, time.June, 4, 2, 13, 0, 0, time.Local)) // 2
+ testData[3].SetTouchedAt(time.Date(2021, time.June, 4, 4, 14, 0, 0, time.Local)) // 4
+ testData[4].SetTouchedAt(time.Date(2021, time.June, 7, 5, 15, 0, 0, time.Local)) // 6
+ testData[5].SetTouchedAt(time.Date(2021, time.June, 6, 6, 16, 0, 0, time.Local)) // 5
+ testData[6].SetTouchedAt(time.Date(2021, time.June, 7, 7, 17, 0, 0, time.Local)) // 7
+ testData[7].SetTouchedAt(time.Date(2021, time.June, 9, 8, 18, 0, 0, time.Local)) // 8
+ testData[8].SetTouchedAt(time.Date(2021, time.June, 10, 11, 19, 0, 0, time.Local)) // 10
+ testData[9].SetTouchedAt(time.Date(2021, time.June, 10, 10, 20, 0, 0, time.Local)) // 9
+
+ return testData
+}
+
+func TestSelectEarliestUpdatedSubs(t *testing.T) {
+ getOrder := func(subs []types.Subscription) string {
+ var order []string
+ for i := range subs {
+ order = append(order, subs[i].Id)
+ }
+ return strings.Join(order, ",")
+ }
+
+ subs := SelectEarliestUpdatedSubs(genTestData(), nil, 100)
+
+ // No sorting when returning the full set.
+ expectedOrder := "1,2,3,4,5,6,7,8,9,10"
+ sortOrder := getOrder(subs)
+ if sortOrder != expectedOrder {
+ t.Error("Wrong results returned. Expected:", expectedOrder, "; Got:", sortOrder)
+ }
+
+ // Sorted, oldest 9 results.
+ subs = SelectEarliestUpdatedSubs(genTestData(), nil, 9)
+ expectedOrder = "1,3,2,4,6,5,7,8,10"
+ sortOrder = getOrder(subs)
+ if sortOrder != expectedOrder {
+ t.Error("Limited query returned wrong results. Expected:", expectedOrder, "; Got:", sortOrder)
+ }
+
+ // Sorted, oldest 9 results.
+ subs = SelectEarliestUpdatedSubs(genTestData(), &types.QueryOpt{Limit: 20}, 9)
+ expectedOrder = "1,3,2,4,6,5,7,8,10"
+ sortOrder = getOrder(subs)
+ if sortOrder != expectedOrder {
+ t.Error("Limited query (2) returned wrong results. Expected:", expectedOrder, "; Got:", sortOrder)
+ }
+
+ // Sorted, oldest 9 results.
+ subs = SelectEarliestUpdatedSubs(genTestData(), &types.QueryOpt{Limit: 9}, 20)
+ expectedOrder = "1,3,2,4,6,5,7,8,10"
+ sortOrder = getOrder(subs)
+ if sortOrder != expectedOrder {
+ t.Error("Limited query (3) returned wrong results. Expected:", expectedOrder, "; Got:", sortOrder)
+ }
+
+ ims := time.Date(2021, time.June, 7, 8, 16, 15, 0, time.Local)
+ subs = SelectEarliestUpdatedSubs(genTestData(), &types.QueryOpt{Limit: 6, IfModifiedSince: &ims}, 20)
+ expectedOrder = "8,10,9"
+ sortOrder = getOrder(subs)
+ if sortOrder != expectedOrder {
+ t.Error("Date & count limited query returned wrong results. Expected:", expectedOrder, "; Got:", sortOrder)
+ }
+
+ ims = time.Date(2021, time.June, 4, 4, 13, 15, 0, time.Local)
+ subs = SelectEarliestUpdatedSubs(genTestData(), &types.QueryOpt{Limit: 3, IfModifiedSince: &ims}, 20)
+ expectedOrder = "4,6,5"
+ sortOrder = getOrder(subs)
+ if sortOrder != expectedOrder {
+ t.Error("Count & date limited query returned wrong results. Expected:", expectedOrder, "; Got:", sortOrder)
+ }
+}
+
+func TestSelectLatestTime(t *testing.T) {
+ t1 := time.Date(2021, time.June, 1, 10, 0, 0, 0, time.UTC)
+ t2 := time.Date(2021, time.June, 2, 10, 0, 0, 0, time.UTC)
+
+ // t1 is before t2, should return t2
+ result := SelectLatestTime(t1, t2)
+ if !result.Equal(t2) {
+ t.Errorf("Expected %v, got %v", t2, result)
+ }
+
+ // t2 is after t1, should return t2
+ result = SelectLatestTime(t2, t1)
+ if !result.Equal(t2) {
+ t.Errorf("Expected %v, got %v", t2, result)
+ }
+
+ // Equal times, should return either one (in this case t1)
+ result = SelectLatestTime(t1, t1)
+ if !result.Equal(t1) {
+ t.Errorf("Expected %v, got %v", t1, result)
+ }
+}
+
+func TestRangesToSql(t *testing.T) {
+ // Test single range with Hi = 0 (IN clause)
+ ranges := []types.Range{{Low: 5, Hi: 0}}
+ sql, args := RangesToSql(ranges)
+ expectedSql := "IN (?)"
+ expectedArgs := []any{5}
+ if sql != expectedSql {
+ t.Errorf("Expected SQL '%s', got '%s'", expectedSql, sql)
+ }
+ if !reflect.DeepEqual(args, expectedArgs) {
+ t.Errorf("Expected args %v, got %v", expectedArgs, args)
+ }
+
+ // Test single range with Hi > 0 (BETWEEN clause)
+ ranges = []types.Range{{Low: 5, Hi: 8}}
+ sql, args = RangesToSql(ranges)
+ expectedSql = "BETWEEN ? AND ?"
+ expectedArgs = []any{5, 7} // Hi-1 for BETWEEN
+ if sql != expectedSql {
+ t.Errorf("Expected SQL '%s', got '%s'", expectedSql, sql)
+ }
+ if !reflect.DeepEqual(args, expectedArgs) {
+ t.Errorf("Expected args %v, got %v", expectedArgs, args)
+ }
+
+ // Test multiple ranges (IN clause)
+ ranges = []types.Range{{Low: 1, Hi: 3}, {Low: 5, Hi: 0}, {Low: 8, Hi: 10}}
+ sql, args = RangesToSql(ranges)
+ expectedSql = "IN (?,?,?,?,?)"
+ expectedArgs = []any{1, 2, 5, 8, 9}
+ if sql != expectedSql {
+ t.Errorf("Expected SQL '%s', got '%s'", expectedSql, sql)
+ }
+ if !reflect.DeepEqual(args, expectedArgs) {
+ t.Errorf("Expected args %v, got %v", expectedArgs, args)
+ }
+}
+
+func TestDisjunctionSql(t *testing.T) {
+ // Test single disjunction
+ req := [][]string{{"tag1", "tag2", "tag3"}}
+ sql, args := DisjunctionSql(req, "tagname")
+ expectedSql := "HAVING COUNT(tagname IN (?,?,?) OR NULL)>=1 "
+ expectedArgs := []any{"tag1", "tag2", "tag3"}
+ if sql != expectedSql {
+ t.Errorf("Expected SQL '%s', got '%s'", expectedSql, sql)
+ }
+ if !reflect.DeepEqual(args, expectedArgs) {
+ t.Errorf("Expected args %v, got %v", expectedArgs, args)
+ }
+
+ // Test multiple disjunctions
+ req = [][]string{{"tag1", "tag2"}, {"tag3"}, {"tag4", "tag5"}}
+ sql, args = DisjunctionSql(req, "fieldname")
+ expectedSql = "HAVING COUNT(fieldname IN (?,?) OR NULL)>=1 AND COUNT(fieldname IN (?) OR NULL)>=1 AND COUNT(fieldname IN (?,?) OR NULL)>=1 "
+ expectedArgs = []any{"tag1", "tag2", "tag3", "tag4", "tag5"}
+ if sql != expectedSql {
+ t.Errorf("Expected SQL '%s', got '%s'", expectedSql, sql)
+ }
+ if !reflect.DeepEqual(args, expectedArgs) {
+ t.Errorf("Expected args %v, got %v", expectedArgs, args)
+ }
+
+ // Test empty disjunctions (should be skipped)
+ req = [][]string{{"tag1"}, {}, {"tag2"}}
+ sql, args = DisjunctionSql(req, "fieldname")
+ expectedSql = "HAVING COUNT(fieldname IN (?) OR NULL)>=1 AND COUNT(fieldname IN (?) OR NULL)>=1 "
+ expectedArgs = []any{"tag1", "tag2"}
+ if sql != expectedSql {
+ t.Errorf("Expected SQL '%s', got '%s'", expectedSql, sql)
+ }
+ if !reflect.DeepEqual(args, expectedArgs) {
+ t.Errorf("Expected args %v, got %v", expectedArgs, args)
+ }
+}
+
+func TestFilterFoundTags(t *testing.T) {
+ setTags := types.StringSlice{"tag1", "tag2", "tag3", "tag4", "tag5"}
+ index := map[string]struct{}{
+ "tag1": {},
+ "tag3": {},
+ "tag5": {},
+ "tag6": {}, // Not in setTags
+ }
+
+ result := FilterFoundTags(setTags, index)
+ expected := []string{"tag1", "tag3", "tag5"}
+ if !reflect.DeepEqual(result, expected) {
+ t.Errorf("Expected %v, got %v", expected, result)
+ }
+
+ // Test with empty index
+ emptyIndex := map[string]struct{}{}
+ result = FilterFoundTags(setTags, emptyIndex)
+ expected = []string{}
+ if !reflect.DeepEqual(result, expected) {
+ t.Errorf("Expected %v, got %v", expected, result)
+ }
+
+ // Test with empty setTags
+ emptyTags := types.StringSlice{}
+ result = FilterFoundTags(emptyTags, index)
+ expected = []string{}
+ if !reflect.DeepEqual(result, expected) {
+ t.Errorf("Expected %v, got %v", expected, result)
+ }
+}
+
+func TestToJSON(t *testing.T) {
+ // Test with nil
+ result := ToJSON(nil)
+ if result != nil {
+ t.Errorf("Expected nil, got %v", result)
+ }
+
+ // Test with string
+ input := "test string"
+ result = ToJSON(input)
+ expected := []byte(`"test string"`)
+ if !reflect.DeepEqual(result, expected) {
+ t.Errorf("Expected %v, got %v", expected, result)
+ }
+
+ // Test with map
+ input2 := map[string]any{"key": "value", "number": 42}
+ result = ToJSON(input2)
+ // Parse back to verify
+ var parsed map[string]any
+ if err := json.Unmarshal(result, &parsed); err != nil {
+ t.Errorf("Failed to unmarshal result: %v", err)
+ }
+ if parsed["key"] != "value" || parsed["number"] != float64(42) {
+ t.Errorf("JSON conversion failed, got %v", parsed)
+ }
+}
+
+func TestFromJSON(t *testing.T) {
+ // Test with nil
+ result := FromJSON(nil)
+ if result != nil {
+ t.Errorf("Expected nil, got %v", result)
+ }
+
+ // Test with valid JSON bytes
+ input := []byte(`{"key": "value", "number": 42}`)
+ result = FromJSON(input)
+ if resultMap, ok := result.(map[string]any); ok {
+ if resultMap["key"] != "value" || resultMap["number"] != float64(42) {
+ t.Errorf("JSON deserialization failed, got %v", resultMap)
+ }
+ } else {
+ t.Errorf("Expected map[string]any, got %T", result)
+ }
+
+ // Test with invalid JSON bytes
+ invalidInput := []byte(`{invalid json}`)
+ result = FromJSON(invalidInput)
+ if result != nil {
+ t.Errorf("Expected nil for invalid JSON, got %v", result)
+ }
+
+ // Test with non-byte slice
+ stringInput := "not bytes"
+ result = FromJSON(stringInput)
+ if result != nil {
+ t.Errorf("Expected nil for non-byte input, got %v", result)
+ }
+}
+
+func TestUpdateByMap(t *testing.T) {
+ update := map[string]any{
+ "Name": "John Doe",
+ "Age": 30,
+ "Public": map[string]string{"avatar": "url"},
+ "Private": map[string]string{"email": "john@example.com"},
+ "Trusted": map[string]bool{"verified": true},
+ "UpdatedAt": time.Now(),
+ }
+
+ cols, args := UpdateByMap(update)
+
+ // Check that we have the right number of columns and args
+ if len(cols) != len(args) || len(cols) != len(update) {
+ t.Errorf("Expected %d columns and args, got %d cols and %d args", len(update), len(cols), len(args))
+ }
+
+ // Verify column format
+ for _, col := range cols {
+ if !strings.Contains(col, "=?") {
+ t.Errorf("Column should contain '=?', got %s", col)
+ }
+ }
+
+ // Check that JSON fields are properly handled
+ foundPublic := false
+ foundPrivate := false
+ foundTrusted := false
+ for i, col := range cols {
+ if strings.HasPrefix(col, "public=?") {
+ foundPublic = true
+ // Should be JSON bytes
+ if _, ok := args[i].([]byte); !ok {
+ t.Errorf("Public field should be []byte, got %T", args[i])
+ }
+ }
+ if strings.HasPrefix(col, "private=?") {
+ foundPrivate = true
+ if _, ok := args[i].([]byte); !ok {
+ t.Errorf("Private field should be []byte, got %T", args[i])
+ }
+ }
+ if strings.HasPrefix(col, "trusted=?") {
+ foundTrusted = true
+ if _, ok := args[i].([]byte); !ok {
+ t.Errorf("Trusted field should be []byte, got %T", args[i])
+ }
+ }
+ }
+
+ if !foundPublic || !foundPrivate || !foundTrusted {
+ t.Error("Missing JSON fields in output")
+ }
+}
+
+func TestExtractTags(t *testing.T) {
+ // Test with Tags field present
+ update := map[string]any{
+ "Name": "John",
+ "Tags": types.StringSlice{"tag1", "tag2", "tag3"},
+ "Age": 30,
+ }
+ tags := ExtractTags(update)
+ expected := []string{"tag1", "tag2", "tag3"}
+ if !reflect.DeepEqual(tags, expected) {
+ t.Errorf("Expected %v, got %v", expected, tags)
+ }
+
+ // Test with no Tags field
+ update = map[string]any{
+ "Name": "John",
+ "Age": 30,
+ }
+ tags = ExtractTags(update)
+ expected = nil
+ if !reflect.DeepEqual(tags, expected) {
+ t.Errorf("Expected %+v, got %+v", expected, tags)
+ }
+
+ // Test with nil Tags field
+ update = map[string]any{
+ "Name": "John",
+ "Tags": nil,
+ "Age": 30,
+ }
+ tags = ExtractTags(update)
+ expected = nil
+ if !reflect.DeepEqual(tags, expected) {
+ t.Errorf("Expected %+v, got %+v", expected, tags)
+ }
+
+ // Test with wrong type for Tags field
+ update = map[string]any{
+ "Name": "John",
+ "Tags": "not a slice",
+ "Age": 30,
+ }
+ tags = ExtractTags(update)
+ expected = nil
+ if !reflect.DeepEqual(tags, expected) {
+ t.Errorf("Expected %+v, got %+v", expected, tags)
+ }
+}
diff --git a/server/db/common/test_data/test_data.go b/server/db/common/test_data/test_data.go
index e69de29..d487026 100644
--- a/server/db/common/test_data/test_data.go
+++ b/server/db/common/test_data/test_data.go
@@ -0,0 +1,382 @@
+package test_data
+
+import (
+ "time"
+
+ "github.com/tinode/chat/server/auth"
+ "github.com/tinode/chat/server/db/common"
+ "github.com/tinode/chat/server/store/types"
+)
+
+type TestData struct {
+ UGen *types.UidGenerator
+ Users []*types.User
+ Creds []*types.Credential
+ Recs []common.AuthRecord
+ Topics []*types.Topic
+ Subs []*types.Subscription
+ Msgs []*types.Message
+ Devs []*types.DeviceDef
+ Files []*types.FileDef
+ // Tags: add, remove, reset
+ Tags [][]string
+ Now time.Time
+}
+
+func initUsers(now time.Time) []*types.User {
+ users := make([]*types.User, 0, 3)
+ users = append(users, &types.User{
+ ObjHeader: types.ObjHeader{
+ Id: "3ysxkod5hNM",
+ },
+ UserAgent: "SomeAgent v1.2.3",
+ Tags: []string{"alice"},
+ })
+ users = append(users, &types.User{
+ ObjHeader: types.ObjHeader{
+ Id: "9AVDamaNCRY",
+ },
+ UserAgent: "Tinode Web v111.222.333",
+ Tags: []string{"bob"},
+ })
+ users = append(users, &types.User{
+ ObjHeader: types.ObjHeader{
+ Id: "0QLrX3WPS2o",
+ },
+ UserAgent: "Tindroid v1.2.3",
+ Tags: []string{"carol"},
+ })
+ for _, user := range users {
+ // Initialize timestamps.
+ user.InitTimes()
+ // Assign user.id from user.Id.
+ user.Uid()
+ }
+ deletedAt := now.Add(10 * time.Minute)
+ users[2].State = types.StateDeleted
+ users[2].StateAt = &deletedAt
+ return users
+}
+
+func initCreds(now time.Time, users []*types.User) []*types.Credential {
+ creds := make([]*types.Credential, 0, 6)
+ creds = append(creds, &types.Credential{ // 0
+ User: users[0].Id,
+ Method: "email",
+ Value: "alice@test.example.com",
+ Done: true,
+ })
+ creds = append(creds, &types.Credential{ // 1
+ User: users[1].Id,
+ Method: "email",
+ Value: "bob@test.example.com",
+ Done: true,
+ })
+ creds = append(creds, &types.Credential{ // 2
+ User: users[1].Id,
+ Method: "email",
+ Value: "bob@test.example.com",
+ })
+ creds = append(creds, &types.Credential{ // 3
+ User: users[2].Id,
+ Method: "tel",
+ Value: "+998991112233",
+ })
+ creds = append(creds, &types.Credential{ // 4
+ User: users[2].Id,
+ Method: "tel",
+ Value: "+998993332211",
+ Done: true,
+ })
+ creds = append(creds, &types.Credential{ // 5
+ User: users[2].Id,
+ Method: "email",
+ Value: "asdf@example.com",
+ })
+ for _, cred := range creds {
+ cred.InitTimes()
+ }
+ creds[3].CreatedAt = now.Add(-10 * time.Minute)
+ creds[3].UpdatedAt = now.Add(-10 * time.Minute)
+ return creds
+}
+
+func initAuthRecords(now time.Time, users []*types.User) []common.AuthRecord {
+ recs := make([]common.AuthRecord, 0, 2)
+ recs = append(recs, common.AuthRecord{
+ Unique: "basic:alice",
+ UserId: users[0].Id,
+ Scheme: "basic",
+ AuthLvl: auth.LevelAuth,
+ Secret: []byte{'a', 'l', 'i', 'c', 'e'},
+ Expires: now.Add(24 * time.Hour),
+ })
+ recs = append(recs, common.AuthRecord{
+ Unique: "basic:bob",
+ UserId: users[1].Id,
+ Scheme: "basic",
+ AuthLvl: auth.LevelAuth,
+ Secret: []byte{'b', 'o', 'b'},
+ Expires: now.Add(24 * time.Hour),
+ })
+ return recs
+}
+
+func initTopics(now time.Time, users []*types.User) []*types.Topic {
+ topics := make([]*types.Topic, 0, 5)
+ topics = append(topics, &types.Topic{
+ ObjHeader: types.ObjHeader{
+ Id: "grpgRXf0rU4uR4",
+ CreatedAt: now.Add(10 * time.Minute),
+ UpdatedAt: now,
+ },
+ TouchedAt: now,
+ Owner: users[0].Id,
+ SeqId: 111,
+ Tags: []string{"travel", "zxcv"},
+ SubCnt: 2,
+ })
+ topics = append(topics, &types.Topic{
+ ObjHeader: types.ObjHeader{
+ Id: "p2p9AVDamaNCRbfKzGSh3mE0w",
+ CreatedAt: now,
+ UpdatedAt: now,
+ },
+ TouchedAt: now,
+ SeqId: 12,
+ })
+ topics = append(topics, &types.Topic{
+ ObjHeader: types.ObjHeader{
+ Id: "p2pxQLrX3WPS2rfKzGSh3mE0w",
+ CreatedAt: now,
+ UpdatedAt: now,
+ },
+ TouchedAt: now,
+ SeqId: 15,
+ })
+ topics = append(topics, &types.Topic{
+ ObjHeader: types.ObjHeader{
+ Id: "p2pE1iE7I9JN5ESv44HiLbj1A",
+ CreatedAt: now,
+ UpdatedAt: now,
+ },
+ TouchedAt: now,
+ SeqId: 555,
+ })
+ topics = append(topics, &types.Topic{
+ ObjHeader: types.ObjHeader{
+ Id: "p2pQvr1xwKU01LfKzGSh3mE0w",
+ CreatedAt: now,
+ UpdatedAt: now,
+ },
+ TouchedAt: now,
+ SeqId: 333,
+ })
+ return topics
+}
+
+func initSubs(now time.Time, users []*types.User, topics []*types.Topic) []*types.Subscription {
+ subs := make([]*types.Subscription, 0, 6)
+ subs = append(subs, &types.Subscription{
+ ObjHeader: types.ObjHeader{
+ CreatedAt: now,
+ UpdatedAt: now.Add(10 * time.Minute),
+ },
+ User: users[0].Id,
+ Topic: topics[0].Id,
+ RecvSeqId: 5,
+ ReadSeqId: 1,
+ ModeWant: 255,
+ ModeGiven: 255,
+ })
+ subs = append(subs, &types.Subscription{
+ ObjHeader: types.ObjHeader{
+ CreatedAt: now,
+ UpdatedAt: now.Add(15 * time.Minute),
+ },
+ User: users[1].Id,
+ Topic: topics[0].Id,
+ RecvSeqId: 6,
+ ReadSeqId: 3,
+ ModeWant: 47,
+ ModeGiven: 47,
+ })
+ subs = append(subs, &types.Subscription{
+ ObjHeader: types.ObjHeader{
+ CreatedAt: now.Add(-10 * time.Hour),
+ UpdatedAt: now.Add(-10 * time.Hour),
+ },
+ User: users[0].Id,
+ Topic: topics[1].Id,
+ RecvSeqId: 9,
+ ReadSeqId: 5,
+ ModeWant: 47,
+ ModeGiven: 47,
+ })
+ subs = append(subs, &types.Subscription{
+ ObjHeader: types.ObjHeader{
+ CreatedAt: now,
+ UpdatedAt: now.Add(20 * time.Minute),
+ },
+ User: users[1].Id,
+ Topic: topics[1].Id,
+ RecvSeqId: 9,
+ ReadSeqId: 5,
+ ModeWant: 47,
+ ModeGiven: 47,
+ })
+ subs = append(subs, &types.Subscription{
+ ObjHeader: types.ObjHeader{
+ CreatedAt: now,
+ UpdatedAt: now.Add(30 * time.Minute),
+ },
+ User: users[2].Id,
+ Topic: topics[2].Id,
+ RecvSeqId: 0,
+ ReadSeqId: 0,
+ ModeWant: 47,
+ ModeGiven: 47,
+ })
+ subs = append(subs, &types.Subscription{
+ ObjHeader: types.ObjHeader{
+ CreatedAt: now,
+ UpdatedAt: now.Add(40 * time.Minute),
+ },
+ User: users[2].Id,
+ Topic: topics[3].Id,
+ RecvSeqId: 555,
+ ReadSeqId: 455,
+ ModeWant: 47,
+ ModeGiven: 47,
+ })
+ for _, sub := range subs {
+ sub.SetTouchedAt(now)
+ }
+ return subs
+}
+
+func initMessages(users []*types.User, topics []*types.Topic) []*types.Message {
+ msgs := make([]*types.Message, 0, 6)
+ msgs = append(msgs, &types.Message{
+ SeqId: 1,
+ Topic: topics[0].Id,
+ From: users[0].Id,
+ Content: "msg1",
+ })
+ msgs = append(msgs, &types.Message{
+ SeqId: 2,
+ Topic: topics[0].Id,
+ From: users[2].Id,
+ Content: "msg2",
+ DeletedFor: []types.SoftDelete{{
+ User: users[0].Id,
+ DelId: 1}},
+ })
+ msgs = append(msgs, &types.Message{
+ SeqId: 3,
+ Topic: topics[0].Id,
+ From: users[0].Id,
+ Content: "msg31",
+ })
+ msgs = append(msgs, &types.Message{
+ SeqId: 1,
+ Topic: topics[1].Id,
+ From: users[1].Id,
+ Content: "msg1",
+ })
+ msgs = append(msgs, &types.Message{
+ SeqId: 5,
+ Topic: topics[1].Id,
+ From: users[1].Id,
+ Content: "msg2",
+ })
+ msgs = append(msgs, &types.Message{
+ SeqId: 11,
+ Topic: topics[1].Id,
+ From: users[0].Id,
+ Content: "msg3",
+ })
+
+ for i, msg := range msgs {
+ msg.InitTimes()
+ msg.SetUid(types.Uid(i + 1))
+ }
+ return msgs
+}
+
+func initDevices(now time.Time) []*types.DeviceDef {
+ devs := make([]*types.DeviceDef, 0, 2)
+ devs = append(devs, &types.DeviceDef{
+ DeviceId: "2934ujfoviwj09ntf094",
+ Platform: "Android",
+ LastSeen: now,
+ Lang: "en_EN",
+ })
+ devs = append(devs, &types.DeviceDef{
+ DeviceId: "pogpjb023b09gfdmp",
+ Platform: "iOS",
+ LastSeen: now,
+ Lang: "en_EN",
+ })
+ return devs
+}
+
+func initFileDefs(now time.Time, users []*types.User) []*types.FileDef {
+ files := make([]*types.FileDef, 0, 2)
+ files = append(files, &types.FileDef{
+ ObjHeader: types.ObjHeader{
+ CreatedAt: now,
+ UpdatedAt: now,
+ },
+ Status: types.UploadStarted,
+ User: users[0].Id,
+ MimeType: "application/pdf",
+ Location: "uploads/qwerty.pdf",
+ Size: 123456,
+ })
+ files = append(files, &types.FileDef{
+ ObjHeader: types.ObjHeader{
+ CreatedAt: now.Add(60 * time.Minute),
+ UpdatedAt: now.Add(60 * time.Minute),
+ },
+ Status: types.UploadStarted,
+ User: users[0].Id,
+ Location: "uploads/asdf.txt",
+ Size: 654321,
+ })
+ files[0].SetUid(types.Uid(1001))
+ files[1].SetUid(types.Uid(1002))
+ return files
+}
+
+func initTags() [][]string {
+ // Tags must be lowercase and non-repeating.
+ addTags := []string{"tag1", "alice"}
+ removeTags := []string{"alice", "tag1", "tag2"}
+ resetTags := []string{"alice", "tag111", "tag333"}
+ return [][]string{addTags, removeTags, resetTags}
+}
+
+func InitTestData() *TestData {
+ // Use fixed timestamp to make tests more predictable
+ var now = time.Date(2021, time.June, 12, 11, 39, 24, 15, time.Local).UTC().Round(time.Millisecond)
+ var uGen = &types.UidGenerator{}
+ if err := uGen.Init(11, []byte("testtesttesttest")); err != nil {
+ return nil
+ }
+ var users = initUsers(now)
+ var topics = initTopics(now, users)
+ return &TestData{
+ UGen: uGen,
+ Users: users,
+ Creds: initCreds(now, users),
+ Recs: initAuthRecords(now, users),
+ Topics: topics,
+ Subs: initSubs(now, users, topics),
+ Msgs: initMessages(users, topics),
+ Devs: initDevices(now),
+ Files: initFileDefs(now, users),
+ Tags: initTags(),
+ Now: now,
+ }
+}
diff --git a/server/db/mongodb/adapter.go b/server/db/mongodb/adapter.go
index e69de29..cc959fe 100644
--- a/server/db/mongodb/adapter.go
+++ b/server/db/mongodb/adapter.go
@@ -0,0 +1,3159 @@
+//go:build mongodb
+
+// Package mongodb is a database adapter for MongoDB.
+package mongodb
+
+import (
+ "context"
+ "crypto/tls"
+ "encoding/json"
+ "errors"
+ "slices"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/tinode/chat/server/auth"
+ "github.com/tinode/chat/server/db/common"
+ "github.com/tinode/chat/server/store"
+ t "github.com/tinode/chat/server/store/types"
+ b "go.mongodb.org/mongo-driver/bson"
+ "go.mongodb.org/mongo-driver/bson/primitive"
+ mdb "go.mongodb.org/mongo-driver/mongo"
+ mdbopts "go.mongodb.org/mongo-driver/mongo/options"
+)
+
+// adapter holds MongoDB connection data.
+type adapter struct {
+ conn *mdb.Client
+ db *mdb.Database
+ dbName string
+ // Maximum number of records to return
+ maxResults int
+ // Maximum number of message records to return
+ maxMessageResults int
+ version int
+ ctx context.Context
+ useTransactions bool
+}
+
+const (
+ adpVersion = 116
+ adapterName = "mongodb"
+
+ defaultHost = "localhost:27017"
+ defaultDatabase = "tinode"
+
+ defaultMaxResults = 1024
+ // This is capped by the Session's send queue limit (128).
+ defaultMaxMessageResults = 100
+
+ defaultAuthMechanism = "SCRAM-SHA-256"
+ defaultAuthSource = "admin"
+)
+
+// See https://godoc.org/go.mongodb.org/mongo-driver/mongo/options#ClientOptions for explanations.
+type configType struct {
+ // Connection string URI https://www.mongodb.com/docs/manual/reference/connection-string/
+ Uri string `json:"uri,omitempty"`
+ Addresses any `json:"addresses,omitempty"`
+ ConnectTimeout int `json:"timeout,omitempty"`
+
+ // Options separately from ClientOptions (custom options):
+ Database string `json:"database,omitempty"`
+ ReplicaSet string `json:"replica_set,omitempty"`
+
+ AuthMechanism string `json:"auth_mechanism,omitempty"`
+ AuthSource string `json:"auth_source,omitempty"`
+ Username string `json:"username,omitempty"`
+ Password string `json:"password,omitempty"`
+
+ UseTLS bool `json:"tls,omitempty"`
+ TlsCertFile string `json:"tls_cert_file,omitempty"`
+ TlsPrivateKey string `json:"tls_private_key,omitempty"`
+ InsecureSkipVerify bool `json:"tls_skip_verify,omitempty"`
+
+ // The only version supported at this time is "1".
+ APIVersion mdbopts.ServerAPIVersion `json:"api_version,omitempty"`
+}
+
+func (a *adapter) maybeStartTransaction(sess mdb.Session) error {
+ if a.useTransactions {
+ return sess.StartTransaction()
+ }
+ return nil
+}
+
+func (a *adapter) maybeCommitTransaction(ctx context.Context, sess mdb.Session) error {
+ if a.useTransactions {
+ return sess.CommitTransaction(ctx)
+ }
+ return nil
+}
+
+// Open initializes mongodb session
+func (a *adapter) Open(jsonconfig json.RawMessage) error {
+ if a.conn != nil {
+ return errors.New("adapter mongodb is already connected")
+ }
+
+ if len(jsonconfig) < 2 {
+ return errors.New("adapter mongodb missing config")
+ }
+
+ var err error
+ var config configType
+ if err = json.Unmarshal(jsonconfig, &config); err != nil {
+ return errors.New("adapter mongodb failed to parse config: " + err.Error())
+ }
+
+ var opts mdbopts.ClientOptions
+
+ if config.Addresses == nil {
+ opts.SetHosts([]string{defaultHost})
+ } else if host, ok := config.Addresses.(string); ok {
+ opts.SetHosts([]string{host})
+ } else if ihosts, ok := config.Addresses.([]any); ok && len(ihosts) > 0 {
+ hosts := make([]string, len(ihosts))
+ for i, ih := range ihosts {
+ h, ok := ih.(string)
+ if !ok || h == "" {
+ return errors.New("adapter mongodb invalid config.Addresses value")
+ }
+ hosts[i] = h
+ }
+ opts.SetHosts(hosts)
+ } else {
+ return errors.New("adapter mongodb failed to parse config.Addresses")
+ }
+
+ if config.Database == "" {
+ a.dbName = defaultDatabase
+ } else {
+ a.dbName = config.Database
+ }
+
+ if config.ReplicaSet != "" {
+ opts.SetReplicaSet(config.ReplicaSet)
+ a.useTransactions = true
+ } else {
+ // Retriable writes are not supported in a standalone instance.
+ opts.SetRetryWrites(false)
+ }
+
+ if config.Username != "" {
+ if config.AuthMechanism == "" {
+ config.AuthMechanism = defaultAuthMechanism
+ }
+ if config.AuthSource == "" {
+ config.AuthSource = defaultAuthSource
+ }
+ var passwordSet bool
+ if config.Password != "" {
+ passwordSet = true
+ }
+ opts.SetAuth(
+ mdbopts.Credential{
+ AuthMechanism: config.AuthMechanism,
+ AuthSource: config.AuthSource,
+ Username: config.Username,
+ Password: config.Password,
+ PasswordSet: passwordSet,
+ })
+ }
+
+ if config.UseTLS {
+ tlsConfig := tls.Config{
+ InsecureSkipVerify: config.InsecureSkipVerify,
+ }
+
+ if config.TlsCertFile != "" {
+ cert, err := tls.LoadX509KeyPair(config.TlsCertFile, config.TlsPrivateKey)
+ if err != nil {
+ return err
+ }
+
+ tlsConfig.Certificates = append(tlsConfig.Certificates, cert)
+ }
+
+ opts.SetTLSConfig(&tlsConfig)
+ }
+
+ if a.maxResults <= 0 {
+ a.maxResults = defaultMaxResults
+ }
+
+ if a.maxMessageResults <= 0 {
+ a.maxMessageResults = defaultMaxMessageResults
+ }
+
+ // Connection string URI overrides any other options configured earlier.
+ if config.Uri != "" {
+ opts.ApplyURI(config.Uri)
+ }
+
+ if config.APIVersion != "" {
+ opts.SetServerAPIOptions(mdbopts.ServerAPI(config.APIVersion))
+ }
+
+ // Make sure the options are sane.
+ if err = opts.Validate(); err != nil {
+ return err
+ }
+
+ a.ctx = context.Background()
+ a.conn, err = mdb.Connect(a.ctx, &opts)
+ a.db = a.conn.Database(a.dbName)
+ if err != nil {
+ return err
+ }
+ a.version = -1
+
+ return nil
+}
+
+// Close the adapter
+func (a *adapter) Close() error {
+ var err error
+ if a.conn != nil {
+ err = a.conn.Disconnect(a.ctx)
+ a.conn = nil
+ a.version = -1
+ }
+ return err
+}
+
+// IsOpen checks if the adapter is ready for use
+func (a *adapter) IsOpen() bool {
+ return a.conn != nil
+}
+
+// GetDbVersion returns current database version.
+func (a *adapter) GetDbVersion() (int, error) {
+ if a.version > 0 {
+ return a.version, nil
+ }
+
+ var result struct {
+ Key string `bson:"_id"`
+ Value int
+ }
+ if err := a.db.Collection("kvmeta").FindOne(a.ctx, b.M{"_id": "version"}).Decode(&result); err != nil {
+ if err == mdb.ErrNoDocuments {
+ err = errors.New("Database not initialized")
+ }
+ return -1, err
+ }
+
+ a.version = result.Value
+ return result.Value, nil
+}
+
+func (a *adapter) updateDbVersion(v int) error {
+ a.version = -1
+ _, err := a.db.Collection("kvmeta").UpdateOne(a.ctx,
+ b.M{"_id": "version"},
+ b.M{"$set": b.M{"value": v}},
+ )
+ return err
+}
+
+// CheckDbVersion checks if the actual database version matches adapter version.
+func (a *adapter) CheckDbVersion() error {
+ version, err := a.GetDbVersion()
+ if err != nil {
+ return err
+ }
+
+ if version != adpVersion {
+ return errors.New("Invalid database version " + strconv.Itoa(version) +
+ ". Expected " + strconv.Itoa(adpVersion))
+ }
+
+ return nil
+}
+
+// Version returns adapter version
+func (a *adapter) Version() int {
+ return adpVersion
+}
+
+// DB connection stats object.
+func (a *adapter) Stats() any {
+ if a.db == nil {
+ return nil
+ }
+
+ var result b.M
+ if err := a.db.RunCommand(a.ctx, b.D{{"serverStatus", 1}}, nil).Decode(&result); err != nil {
+ return nil
+ }
+
+ return result["connections"]
+}
+
+// GetName returns the name of the adapter
+func (a *adapter) GetName() string {
+ return adapterName
+}
+
+// SetMaxResults configures how many results can be returned in a single DB call.
+func (a *adapter) SetMaxResults(val int) error {
+ if val <= 0 {
+ a.maxResults = defaultMaxResults
+ } else {
+ a.maxResults = val
+ }
+
+ return nil
+}
+
+// CreateDb creates the database optionally dropping an existing database first.
+func (a *adapter) CreateDb(reset bool) error {
+ if reset {
+ if err := a.db.Drop(a.ctx); err != nil {
+ return err
+ }
+ } else if a.isDbInitialized() {
+ return errors.New("Database already initialized")
+ }
+ // Collections (tables) do not need to be explicitly created since MongoDB creates them with first write operation
+
+ indexes := []struct {
+ Collection string
+ Field string
+ IndexOpts mdb.IndexModel
+ }{
+ // Users
+ // Index on 'user.state' for finding suspended and soft-deleted users.
+ {
+ Collection: "users",
+ Field: "state",
+ },
+ // Index on 'user.tags' array so user can be found by tags.
+ {
+ Collection: "users",
+ Field: "tags",
+ },
+ // Index for 'user.devices.deviceid' to ensure Device ID uniqueness across users.
+ // Partial filter set to avoid unique constraint for null values (when user object have no devices).
+ {
+ Collection: "users",
+ IndexOpts: mdb.IndexModel{
+ Keys: b.M{"devices.deviceid": 1},
+ Options: mdbopts.Index().
+ SetUnique(true).
+ SetPartialFilterExpression(b.M{"devices.deviceid": b.M{"$exists": true}}),
+ },
+ },
+ // Index on lastSeen and updatedat for deleting stale user accounts.
+ {
+ Collection: "users",
+ IndexOpts: mdb.IndexModel{Keys: b.D{{"lastseen", 1}, {"updatedat", 1}}},
+ },
+
+ // User authentication records {_id, userid, secret}
+ // Should be able to access user's auth records by user id
+ {
+ Collection: "auth",
+ Field: "userid",
+ },
+
+ // Subscription to a topic. The primary key is a topic:user string
+ {
+ Collection: "subscriptions",
+ Field: "user",
+ },
+ {
+ Collection: "subscriptions",
+ Field: "topic",
+ },
+
+ // Topics stored in database
+ // Index on 'owner' field for deleting users.
+ {
+ Collection: "topics",
+ Field: "owner",
+ },
+ // Index on 'state' for finding suspended and soft-deleted topics.
+ {
+ Collection: "topics",
+ Field: "state",
+ },
+ // Index on 'topic.tags' array so topics can be found by tags.
+ // These tags are not unique as opposite to 'user.tags'.
+ {
+ Collection: "topics",
+ Field: "tags",
+ },
+
+ // Stored message
+ // Compound index of 'topic - seqid' for selecting messages in a topic.
+ {
+ Collection: "messages",
+ IndexOpts: mdb.IndexModel{Keys: b.D{{"topic", 1}, {"seqid", 1}}},
+ },
+ // Compound index of hard-deleted messages
+ {
+ Collection: "messages",
+ IndexOpts: mdb.IndexModel{Keys: b.D{{"topic", 1}, {"delid", 1}}},
+ },
+ // Compound multi-index of soft-deleted messages: each message gets multiple compound index entries like
+ // [topic, user1, delid1], [topic, user2, delid2],...
+ {
+ Collection: "messages",
+ IndexOpts: mdb.IndexModel{Keys: b.D{{"topic", 1}, {"deletedfor.user", 1}, {"deletedfor.delid", 1}}},
+ },
+
+ // Log of deleted messages
+ // Compound index of 'topic - delid'
+ {
+ Collection: "dellog",
+ IndexOpts: mdb.IndexModel{Keys: b.D{{"topic", 1}, {"delid", 1}}},
+ },
+
+ // User credentials - contact information such as "email:jdoe@example.com" or "tel:+18003287448":
+ // Id: "method:credential" like "email:jdoe@example.com". See types.Credential.
+ // Index on 'credentials.user' to be able to query credentials by user id.
+ {
+ Collection: "credentials",
+ Field: "user",
+ },
+
+ // Records of file uploads. See types.FileDef.
+ // Index on 'fileuploads.usecount' to be able to delete unused records at once.
+ {
+ Collection: "fileuploads",
+ Field: "usecount",
+ },
+ }
+
+ var err error
+ for _, idx := range indexes {
+ if idx.Field != "" {
+ _, err = a.db.Collection(idx.Collection).Indexes().CreateOne(a.ctx, mdb.IndexModel{Keys: b.M{idx.Field: 1}})
+ } else {
+ _, err = a.db.Collection(idx.Collection).Indexes().CreateOne(a.ctx, idx.IndexOpts)
+ }
+ if err != nil {
+ return err
+ }
+ }
+
+ // Collection "kvmeta" with metadata key-value pairs.
+ // Key in "_id" field.
+ // Record current DB version.
+ if _, err := a.db.Collection("kvmeta").InsertOne(a.ctx, map[string]any{"_id": "version", "value": adpVersion}); err != nil {
+ return err
+ }
+
+ // Create system topic 'sys'.
+ return createSystemTopic(a)
+}
+
+// UpgradeDb upgrades database to the current adapter version.
+func (a *adapter) UpgradeDb() error {
+ bumpVersion := func(a *adapter, x int) error {
+ if err := a.updateDbVersion(x); err != nil {
+ return err
+ }
+ _, err := a.GetDbVersion()
+ return err
+ }
+
+ _, err := a.GetDbVersion()
+ if err != nil {
+ return err
+ }
+
+ if a.version == 110 {
+ // Perform database upgrade from versions 110 to version 111.
+
+ // Users
+
+ // Reset previously unused field State to value StateOK.
+ if _, err := a.db.Collection("users").UpdateMany(a.ctx,
+ b.M{},
+ b.M{"$set": b.M{"state": t.StateOK}}); err != nil {
+ return err
+ }
+
+ // Add StatusDeleted to all deleted users as indicated by DeletedAt not being null.
+ if _, err := a.db.Collection("users").UpdateMany(a.ctx,
+ b.M{"deletedat": b.M{"$ne": nil}},
+ b.M{"$set": b.M{"state": t.StateDeleted}}); err != nil {
+ return err
+ }
+
+ // Rename DeletedAt into StateAt. Update only those rows which have defined DeletedAt.
+ if _, err := a.db.Collection("users").UpdateMany(a.ctx,
+ b.M{"deletedat": b.M{"$exists": true}},
+ b.M{"$rename": b.M{"deletedat": "stateat"}}); err != nil {
+ return err
+ }
+
+ // Drop secondary index DeletedAt.
+ if _, err := a.db.Collection("users").Indexes().DropOne(a.ctx, "deletedat_1"); err != nil {
+ return err
+ }
+
+ // Create secondary index on State for finding suspended and soft-deleted topics.
+ if _, err = a.db.Collection("users").Indexes().CreateOne(a.ctx, mdb.IndexModel{Keys: b.M{"state": 1}}); err != nil {
+ return err
+ }
+
+ // Topics
+
+ // Add StateDeleted to all topics with DeletedAt not null.
+ if _, err := a.db.Collection("topics").UpdateMany(a.ctx,
+ b.M{"deletedat": b.M{"$ne": nil}},
+ b.M{"$set": b.M{"state": t.StateDeleted}}); err != nil {
+ return err
+ }
+
+ // Set StateOK for all other topics.
+ if _, err := a.db.Collection("topics").UpdateMany(a.ctx,
+ b.M{"state": b.M{"$exists": false}},
+ b.M{"$set": b.M{"state": t.StateOK}}); err != nil {
+ return err
+ }
+
+ // Rename DeletedAt into StateAt. Update only those rows which have defined DeletedAt.
+ if _, err := a.db.Collection("topics").UpdateMany(a.ctx,
+ b.M{"deletedat": b.M{"$exists": true}},
+ b.M{"$rename": b.M{"deletedat": "stateat"}}); err != nil {
+ return err
+ }
+
+ // Create secondary index on State for finding suspended and soft-deleted topics.
+ if _, err = a.db.Collection("topics").Indexes().CreateOne(a.ctx, mdb.IndexModel{Keys: b.M{"state": 1}}); err != nil {
+ return err
+ }
+
+ if err := bumpVersion(a, 111); err != nil {
+ return err
+ }
+ }
+
+ if a.version == 111 {
+ // Just bump the version to keep in line with MySQL.
+ if err := bumpVersion(a, 112); err != nil {
+ return err
+ }
+ }
+
+ if a.version == 112 {
+ // Create secondary index on Users(lastseen,updatedat) for deleting stale user accounts.
+ if _, err = a.db.Collection("users").Indexes().CreateOne(a.ctx,
+ mdb.IndexModel{Keys: b.D{{"lastseen", 1}, {"updatedat", 1}}}); err != nil {
+ return err
+ }
+
+ if err := bumpVersion(a, 113); err != nil {
+ return err
+ }
+ }
+
+ if a.version < 116 {
+ // Version 114: topics.aux added, fileuploads.etag added.
+ // Version 115: SQL indexes added.
+ // Version 116: topics.subcnt added.
+ if err := bumpVersion(a, 116); err != nil {
+ return err
+ }
+ }
+
+ if a.version != adpVersion {
+ return errors.New("Failed to perform database upgrade to version " + strconv.Itoa(adpVersion) +
+ ". DB is still at " + strconv.Itoa(a.version))
+ }
+ return nil
+}
+
+// Create system topic 'sys'.
+func createSystemTopic(a *adapter) error {
+ now := t.TimeNow()
+ _, err := a.db.Collection("topics").InsertOne(a.ctx, &t.Topic{
+ ObjHeader: t.ObjHeader{
+ Id: "sys",
+ CreatedAt: now,
+ UpdatedAt: now},
+ TouchedAt: now,
+ Access: t.DefaultAccess{Auth: t.ModeNone, Anon: t.ModeNone},
+ Public: map[string]any{"fn": "System"},
+ })
+ return err
+}
+
+// User management
+
+// UserCreate creates user record
+func (a *adapter) UserCreate(usr *t.User) error {
+ if _, err := a.db.Collection("users").InsertOne(a.ctx, &usr); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// UserGet fetches a single user by user id. If user is not found it returns (nil, nil)
+func (a *adapter) UserGet(id t.Uid) (*t.User, error) {
+ var user t.User
+
+ filter := b.M{"_id": id.String(), "state": b.M{"$ne": t.StateDeleted}}
+ if err := a.db.Collection("users").FindOne(a.ctx, filter).Decode(&user); err != nil {
+ if err == mdb.ErrNoDocuments { // User not found
+ return nil, nil
+ } else {
+ return nil, err
+ }
+ }
+ user.Public = unmarshalBsonD(user.Public)
+ user.Trusted = unmarshalBsonD(user.Trusted)
+ return &user, nil
+}
+
+// UserGetAll returns user records for a given list of user IDs
+func (a *adapter) UserGetAll(ids ...t.Uid) ([]t.User, error) {
+ uids := make([]any, len(ids))
+ for i, id := range ids {
+ uids[i] = id.String()
+ }
+
+ var users []t.User
+ filter := b.M{"_id": b.M{"$in": uids}, "state": b.M{"$ne": t.StateDeleted}}
+ cur, err := a.db.Collection("users").Find(a.ctx, filter)
+ if err != nil {
+ return nil, err
+ }
+ defer cur.Close(a.ctx)
+
+ for cur.Next(a.ctx) {
+ var user t.User
+ if err := cur.Decode(&user); err != nil {
+ return nil, err
+ }
+ user.Public = unmarshalBsonD(user.Public)
+ user.Trusted = unmarshalBsonD(user.Trusted)
+
+ users = append(users, user)
+ }
+
+ return users, nil
+}
+
+// UserDelete deletes specified user: wipes completely (hard-delete) or marks as deleted.
+func (a *adapter) UserDelete(uid t.Uid, hard bool) error {
+ ownFilter := b.M{"owner": uid.String()}
+ // In case of hard delete, delete all topics, even those which were
+ // soft-deleted previsously.
+ if !hard {
+ ownFilter["state"] = b.M{"$ne": t.StateDeleted}
+ }
+
+ forUser := uid.String()
+ // Select topics where the user is the owner.
+ ownTopics, err := a.topicNamesForUser("topics", ownFilter, "_id", true)
+ if err != nil {
+ return err
+ }
+ ownTopicsFilter := b.M{"topic": b.M{"$in": ownTopics}}
+
+ var sess mdb.Session
+ if sess, err = a.conn.StartSession(); err != nil {
+ return err
+ }
+ defer sess.EndSession(a.ctx)
+
+ if err = a.maybeStartTransaction(sess); err != nil {
+ return err
+ }
+
+ if err = mdb.WithSession(a.ctx, sess, func(sc mdb.SessionContext) error {
+
+ if hard {
+ // No need to delete user's devices: devices are stored in user's record and will be deleted with it.
+
+ // Delete user's subscriptions in all topics and decrement subcnt in topic.
+ if err = a.subsDelete(sc, b.M{"user": forUser}, true); err != nil {
+ return err
+ }
+
+ // Delete user's dellog entries in all topics.
+ err = a.clearUserDellog(sc, forUser)
+ if err != nil {
+ return err
+ }
+
+ // Can't delete user's messages in all topics because we cannot notify topics of such deletion.
+ // Just leave the messages there marked as sent by "not found" user.
+
+ // Delete topics where the user is the owner:
+ if len(ownTopics) > 0 {
+
+ // 1. Delete dellog
+ // 2. Decrement fileuploads.
+ // 3. Delete all messages.
+ // 4. Delete subscriptions.
+
+ // Delete dellog for topics owned by the user.
+ _, err = a.db.Collection("dellog").DeleteMany(sc, ownTopicsFilter)
+ if err != nil {
+ return err
+ }
+
+ // Decrement fileuploads UseCounter
+ // First get array of attachments IDs that were used in messages of topics from topicIds
+ // Then decrement the usecount field of these file records
+ err = a.decFileUseCounter(sc, "messages", ownTopicsFilter)
+ if err != nil {
+ return err
+ }
+
+ // Decrement use counter for topic avatars.
+ err = a.decFileUseCounter(sc, "topics", b.M{"_id": b.M{"$in": ownTopics}})
+ if err != nil {
+ return err
+ }
+
+ // Delete messages
+ _, err = a.db.Collection("messages").DeleteMany(sc, ownTopicsFilter)
+ if err != nil {
+ return err
+ }
+
+ // Delete subscriptions for all users where the user is the owner of the topic.
+ _, err = a.db.Collection("subscriptions").DeleteMany(sc, ownTopicsFilter)
+ if err != nil {
+ return err
+ }
+
+ // No need to delete topic tags: they are stored in topic record and will be deleted with it.
+
+ // And finally delete the topics.
+ if _, err = a.db.Collection("topics").DeleteMany(sc, b.M{"owner": forUser}); err != nil {
+ return err
+ }
+ }
+
+ // Delete user's authentication records.
+ if _, err = a.authDelAllRecords(sc, uid); err != nil {
+ return err
+ }
+
+ // Delete credentials.
+ if err = a.credDel(sc, uid, "", ""); err != nil && err != t.ErrNotFound {
+ return err
+ }
+
+ // Delete avatar (decrement use counter).
+ if err = a.decFileUseCounter(sc, "users", b.M{"_id": forUser}); err != nil {
+ return err
+ }
+
+ // No need to delete user's tags: they are stored in user's record and will be deleted with it.
+
+ // And finally delete the user.
+ if _, err = a.db.Collection("users").DeleteOne(sc, b.M{"_id": forUser}); err != nil {
+ return err
+ }
+ } else {
+ // Disable user's subscriptions.
+ if err = a.subsDelete(sc, b.M{"user": forUser}, false); err != nil {
+ return err
+ }
+
+ now := t.TimeNow()
+ disable := b.M{"$set": b.M{"updatedat": now, "state": t.StateDeleted, "stateat": now}}
+
+ if len(ownTopics) > 0 {
+ // Disable subscriptions for topics where the user is the owner.
+ if _, err = a.db.Collection("subscriptions").UpdateMany(sc, ownTopicsFilter, disable); err != nil {
+ return err
+ }
+
+ // Disable group topics where the user is the owner.
+ if _, err = a.db.Collection("topics").UpdateMany(sc, b.M{"_id": b.M{"$in": ownTopics}},
+ b.M{"$set": b.M{
+ "updatedat": now, "touchedat": now, "state": t.StateDeleted, "stateat": now,
+ }}); err != nil {
+ return err
+ }
+ }
+
+ // Disable p2p topics with the user.
+ p2pTopics, err := a.p2pTopicsForUser(uid)
+ if err != nil {
+ return err
+ }
+ if len(p2pTopics) > 0 {
+ if _, err = a.db.Collection("topics").UpdateMany(sc, b.M{"_id": b.M{"$in": p2pTopics}},
+ b.M{"$set": b.M{
+ "updatedat": now, "touchedat": now, "state": t.StateDeleted, "stateat": now,
+ }}); err != nil {
+ return err
+ }
+
+ // Disable subscription to user's disabled p2p topics.
+ if _, err = a.db.Collection("subscriptions").UpdateMany(sc,
+ b.M{"topic": b.M{"$in": p2pTopics}}, disable); err != nil {
+ return err
+ }
+ }
+
+ // Finally disable the user.
+ if _, err = a.db.Collection("users").UpdateMany(sc, b.M{"_id": forUser}, disable); err != nil {
+ return err
+ }
+ }
+
+ // Finally commit all changes
+ return a.maybeCommitTransaction(sc, sess)
+ }); err != nil {
+ return err
+ }
+
+ return err
+}
+
+// topicStateForUser is called by UserUpdate when the update contains state change.
+// Soft-deleted topics remain soft-deleted.
+func (a *adapter) topicStateForUser(uid t.Uid, now time.Time, update any) error {
+ state, ok := update.(t.ObjState)
+ if !ok {
+ return t.ErrMalformed
+ }
+
+ if now.IsZero() {
+ now = t.TimeNow()
+ }
+
+ // Change state of all topics where the user is the owner.
+ if _, err := a.db.Collection("topics").UpdateMany(a.ctx,
+ b.M{"owner": uid.String(), "state": b.M{"$ne": t.StateDeleted}},
+ b.M{"$set": b.M{"state": state, "stateat": now}}); err != nil {
+ return err
+ }
+
+ // Change state of p2p topics with the user (p2p topic's owner is blank)
+ // Get list of p2p topics with the user.
+ p2pTopics, err := a.p2pTopicsForUser(uid)
+ if err != nil {
+ return err
+ }
+ if len(p2pTopics) > 0 {
+ if _, err := a.db.Collection("topics").UpdateMany(a.ctx,
+ b.M{"_id": b.M{"$in": p2pTopics}, "state": b.M{"$ne": t.StateDeleted}},
+ b.M{"$set": b.M{"state": state, "stateat": now}}); err != nil {
+ return err
+ }
+ }
+
+ // Subscriptions don't need to be updated:
+ // subscriptions of a disabled user are not disabled and still can be manipulated.
+ return nil
+}
+
+// UserUpdate updates user record
+func (a *adapter) UserUpdate(uid t.Uid, update map[string]any) error {
+ // Convert field names from CamelCase to lowercase.
+ update = normalizeUpdateMap(update)
+
+ _, err := a.db.Collection("users").UpdateOne(a.ctx, b.M{"_id": uid.String()}, b.M{"$set": update})
+ if err != nil {
+ return err
+ }
+
+ if state, ok := update["state"]; ok {
+ now, _ := update["stateat"].(time.Time)
+ err = a.topicStateForUser(uid, now, state)
+ }
+
+ // Tags are stored in the same record, no need to update them separately.
+
+ return err
+}
+
+// UserUpdateTags adds, removes, or resets user's tags.
+func (a *adapter) UserUpdateTags(uid t.Uid, add, remove, reset []string) ([]string, error) {
+ var newTags t.StringSlice
+ // Compare to nil vs checking for zero length: zero length reset is valid.
+ if reset != nil {
+ // Replace tags with the new value
+ newTags = reset
+ } else {
+ var user t.User
+ err := a.db.Collection("users").FindOne(a.ctx, b.M{"_id": uid.String()}).Decode(&user)
+ if err != nil {
+ return nil, err
+ }
+
+ // Mutate the tag list.
+ newTags = user.Tags
+ if len(add) > 0 {
+ newTags = union(newTags, add)
+ }
+ if len(remove) > 0 {
+ newTags = diff(newTags, remove)
+ }
+ }
+
+ return newTags, a.UserUpdate(uid, map[string]any{"tags": newTags})
+}
+
+// UserGetByCred returns user ID for the given validated credential.
+func (a *adapter) UserGetByCred(method, value string) (t.Uid, error) {
+ var userId map[string]string
+ err := a.db.Collection("credentials").FindOne(a.ctx,
+ b.M{"_id": method + ":" + value},
+ mdbopts.FindOne().SetProjection(b.M{"user": 1, "_id": 0}),
+ ).Decode(&userId)
+ if err != nil {
+ if err == mdb.ErrNoDocuments {
+ return t.ZeroUid, nil
+ }
+ return t.ZeroUid, err
+ }
+
+ return t.ParseUid(userId["user"]), nil
+}
+
+// UserUnreadCount returns the total number of unread messages in all topics with
+// the R permission. If read fails, the counts are still returned with the original
+// user IDs but with the unread count undefined and non-nil error.
+// Does not count unread messages in channels although it probably should.
+func (a *adapter) UserUnreadCount(ids ...t.Uid) (map[t.Uid]int, error) {
+ uids := make([]string, len(ids))
+ counts := make(map[t.Uid]int, len(ids))
+ for i, id := range ids {
+ uids[i] = id.String()
+ // Ensure all original uids are always present.
+ counts[id] = 0
+ }
+ /*
+ Query:
+ db.subscriptions.aggregate([
+ { $match: { user: { $in: ["KnElfSSA21U", "0ZcCQmwI2RI"] } } },
+ { $lookup: { from: "topics", localField: "topic", foreignField: "_id", as: "fromTopics"} },
+ { $match: { fromTopics: { $not: {$size: 0} }}},
+ { $replaceRoot: { newRoot: { $mergeObjects: [ {$arrayElemAt: [ "$fromTopics", 0 ]} , "$$ROOT" ] } } },
+ { $match: {
+ deletedat: { $exists: false },
+ state: { $ne: t.StateDeleted },
+ modewant: { $bitsAllSet: [ t.ModeRead ] },
+ modegiven: { $bitsAllSet: [ t.ModeRead ] }
+ }
+ },
+ { $project: { _id: 0, user: 1, readseqid: 1, seqid: 1} },
+ { $group: { _id: "$user", unreadCount: { $sum: { $subtract: [ "$seqid", "$readseqid" ] } } } }
+ ])
+
+ Result:
+ { "_id" : "KnElfSSA21U", "unreadCount" : 0 }
+ { "_id" : "0ZcCQmwI2RI", "unreadCount" : 7 }
+ */
+
+ pipeline := b.A{
+ b.M{"$match": b.M{"user": b.M{"$in": uids}}},
+ // Join documents from two collection.
+ // FIXME: this does not work for channels as localField[topic] is not the same as foreignField[_id].
+ b.M{"$lookup": b.M{
+ "from": "topics",
+ "localField": "topic",
+ "foreignField": "_id",
+ "as": "fromTopics"},
+ },
+ // Remove users with no subscriptions.
+ b.M{"$match": b.M{"fromTopics": b.M{"$not": b.M{"$size": 0}}}},
+ // Merge two documents into one
+ b.M{"$replaceRoot": b.M{"newRoot": b.M{"$mergeObjects": b.A{b.M{"$arrayElemAt": b.A{"$fromTopics", 0}}, "$$ROOT"}}}},
+
+ // Keep only those records which affect the result.
+ b.M{"$match": b.M{
+ "deletedat": b.M{"$exists": false},
+ "state": b.M{"$ne": t.StateDeleted},
+ // Filter by access mode
+ "modewant": b.M{"$bitsAllSet": b.A{t.ModeRead}},
+ "modegiven": b.M{"$bitsAllSet": b.A{t.ModeRead}}}},
+
+ // Remove unused fields.
+ b.M{"$project": b.M{"_id": 0, "user": 1, "readseqid": 1, "seqid": 1}},
+ // GROUP BY user.
+ b.M{"$group": b.M{"_id": "$user", "unreadCount": b.M{"$sum": b.M{"$subtract": b.A{"$seqid", "$readseqid"}}}}},
+ }
+ cur, err := a.db.Collection("subscriptions").Aggregate(a.ctx, pipeline)
+ if err != nil {
+ return counts, err
+ }
+ defer cur.Close(a.ctx)
+
+ for cur.Next(a.ctx) {
+ var oneCount struct {
+ Id string `bson:"_id"`
+ UnreadCount int `bson:"unreadCount"`
+ }
+ cur.Decode(&oneCount)
+ counts[t.ParseUid(oneCount.Id)] = oneCount.UnreadCount
+ }
+
+ return counts, nil
+}
+
+// UserGetUnvalidated returns a list of uids which have never logged in, have no
+// validated credentials and haven't been updated since lastUpdatedBefore.
+func (a *adapter) UserGetUnvalidated(lastUpdatedBefore time.Time, limit int) ([]t.Uid, error) {
+ /*
+ Query:
+ [
+ // .. WHERE lastseen IS NULL AND updatedat
+ {$match: {
+ $and: [
+ { lastseen: null },
+ { updatedat: {$lt: new ISODate("2022-12-09T01:26:15.819Z")} },
+ ],
+ }},
+ // JOIN credentials ON id=user
+ {$lookup: {
+ from: "credentials",
+ localField: "_id",
+ foreignField: "user",
+ as: "fcred",
+ }},
+ // {x: 1, y: [{a: 1}, {a: 2}]} -> [{x: 1, a: 1}, {x: 1, a: 2}]
+ {$unwind: {path: "$fcred"}},
+ // SELECT _id, CASE WHEN done THEN 1 ELSE 0 END
+ {$project: {
+ _id: 1,
+ completed: { $cond: { if: "$fcred.done", then: 1, else: 0 } },
+ }},
+ // GROUP BY _id
+ {$group: { _id: "$_id", completed: { $sum: "$completed" } } },
+ // HAVING completed=0
+ {$match: { completed: 0 }},
+ // SELECT _id
+ {$project: { _id: "$_id" }},
+ {$limit: 10}
+ ]
+ */
+ pipeline := b.A{
+ b.M{"$match": b.M{
+ "$and": b.A{
+ b.M{"lastseen": primitive.Null{}},
+ b.M{"updatedat": b.M{"$lt": lastUpdatedBefore}},
+ },
+ }},
+ b.M{"$lookup": b.D{
+ {"from", "credentials"},
+ {"localField", "_id"},
+ {"foreignField", "user"},
+ {"as", "fcred"}},
+ },
+ b.M{"$unwind": b.M{"path": "$fcred"}},
+ b.M{"$project": b.D{
+ {"_id", 1},
+ {"completed", b.M{
+ "$cond": b.D{{"if", "$fcred.done"}, {"then", 1}, {"else", 0}}},
+ }}},
+ b.M{"$group": b.D{{"_id", "$_id"}, {"completed", b.M{"$sum": "$completed"}}}},
+ b.M{"$match": b.M{"completed": 0}},
+ b.M{"$project": b.M{"_id": "$_id"}},
+ b.M{"$limit": limit},
+ }
+
+ cur, err := a.db.Collection("users").Aggregate(a.ctx, pipeline)
+ if err != nil {
+ return nil, err
+ }
+ defer cur.Close(a.ctx)
+
+ var uids []t.Uid
+ for cur.Next(a.ctx) {
+ var oneUser struct {
+ Id string `bson:"_id"`
+ }
+ if err := cur.Decode(&oneUser); err != nil {
+ return nil, err
+ }
+ uid := t.ParseUid(oneUser.Id)
+ if uid.IsZero() {
+ return nil, errors.New("failed to decode user id")
+ }
+ uids = append(uids, uid)
+ }
+
+ return uids, err
+}
+
+// Credential management
+
+// CredUpsert adds or updates a validation record. Returns true if inserted, false if updated.
+// 1. if credential is validated:
+// 1.1 Hard-delete unconfirmed equivalent record, if exists.
+// 1.2 Insert new. Report error if duplicate.
+// 2. if credential is not validated:
+// 2.1 Check if validated equivalent exist. If so, report an error.
+// 2.2 Soft-delete all unvalidated records of the same method.
+// 2.3 Undelete existing credential. Return if successful.
+// 2.4 Insert new credential record.
+func (a *adapter) CredUpsert(cred *t.Credential) (bool, error) {
+ credCollection := a.db.Collection("credentials")
+
+ cred.Id = cred.Method + ":" + cred.Value
+
+ if !cred.Done {
+ // Check if the same credential is already validated.
+ var result1 t.Credential
+ err := credCollection.FindOne(a.ctx, b.M{"_id": cred.Id}).Decode(&result1)
+ if result1 != (t.Credential{}) {
+ // Someone has already validated this credential.
+ return false, t.ErrDuplicate
+ }
+ if err != nil && err != mdb.ErrNoDocuments { // if no result -> continue
+ return false, err
+ }
+
+ // Soft-delete all unvalidated records of this user and method.
+ _, err = credCollection.UpdateMany(a.ctx,
+ b.M{"user": cred.User, "method": cred.Method, "done": false},
+ b.M{"$set": b.M{"deletedat": t.TimeNow()}})
+ if err != nil {
+ return false, err
+ }
+
+ // If credential is not confirmed, it should not block others
+ // from attempting to validate it: make index user-unique instead of global-unique.
+ cred.Id = cred.User + ":" + cred.Id
+
+ // Check if this credential has already been added by the user.
+ var result2 t.Credential
+ err = credCollection.FindOne(a.ctx, b.M{"_id": cred.Id}).Decode(&result2)
+ if result2 != (t.Credential{}) {
+ _, err = credCollection.UpdateOne(a.ctx,
+ b.M{"_id": cred.Id},
+ b.M{
+ "$unset": b.M{"deletedat": ""},
+ "$set": b.M{"updatedat": cred.UpdatedAt, "resp": cred.Resp}})
+ if err != nil {
+ return false, err
+ }
+
+ // The record was updated, all is fine.
+ return false, nil
+ }
+ if err != nil && err != mdb.ErrNoDocuments {
+ return false, err
+ }
+ } else {
+ // Hard-delete potentially present unvalidated credential.
+ _, err := credCollection.DeleteOne(a.ctx, b.M{"_id": cred.User + ":" + cred.Id})
+ if err != nil {
+ return false, err
+ }
+ }
+
+ // Insert a new record.
+ _, err := credCollection.InsertOne(a.ctx, cred)
+ if isDuplicateErr(err) {
+ return true, t.ErrDuplicate
+ }
+
+ return true, err
+}
+
+// CredGetActive returns the currently active credential record for the given method.
+func (a *adapter) CredGetActive(uid t.Uid, method string) (*t.Credential, error) {
+ var cred t.Credential
+
+ filter := b.M{
+ "user": uid.String(),
+ "deletedat": b.M{"$exists": false},
+ "method": method,
+ "done": false}
+
+ if err := a.db.Collection("credentials").FindOne(a.ctx, filter).Decode(&cred); err != nil {
+ if err == mdb.ErrNoDocuments { // Cred not found
+ err = nil
+ }
+ return nil, err
+ }
+
+ return &cred, nil
+}
+
+// CredGetAll returns credential records for the given user and method, validated only or all.
+func (a *adapter) CredGetAll(uid t.Uid, method string, validatedOnly bool) ([]t.Credential, error) {
+ filter := b.M{"user": uid.String()}
+ if method != "" {
+ filter["method"] = method
+ }
+ if validatedOnly {
+ filter["done"] = true
+ } else {
+ filter["deletedat"] = b.M{"$exists": false}
+ }
+
+ cur, err := a.db.Collection("credentials").Find(a.ctx, filter)
+ if err != nil {
+ return nil, err
+ }
+ defer cur.Close(a.ctx)
+
+ var credentials []t.Credential
+ if err := cur.All(a.ctx, &credentials); err != nil {
+ return nil, err
+ }
+ return credentials, nil
+}
+
+// CredDel deletes credentials for the given method/value. If method is empty, deletes all
+// user's credentials.
+func (a *adapter) credDel(ctx context.Context, uid t.Uid, method, value string) error {
+ credCollection := a.db.Collection("credentials")
+ filter := b.M{"user": uid.String()}
+ if method != "" {
+ filter["method"] = method
+ if value != "" {
+ filter["value"] = value
+ }
+ } else {
+ res, err := credCollection.DeleteMany(ctx, filter)
+ if err == nil {
+ if res.DeletedCount == 0 {
+ err = t.ErrNotFound
+ }
+ }
+ return err
+ }
+
+ // Hard-delete all confirmed values or values with no attempts at confirmation.
+ hardDeleteFilter := copyBsonMap(filter)
+ hardDeleteFilter["$or"] = b.A{
+ b.M{"done": true},
+ b.M{"retries": 0}}
+ if res, err := credCollection.DeleteMany(ctx, hardDeleteFilter); err != nil {
+ return err
+ } else if res.DeletedCount > 0 {
+ return nil
+ }
+
+ // Soft-delete all other values.
+ res, err := credCollection.UpdateMany(ctx, filter, b.M{"$set": b.M{"deletedat": t.TimeNow()}})
+ if err == nil {
+ if res.ModifiedCount == 0 {
+ err = t.ErrNotFound
+ }
+ }
+ return err
+}
+
+func (a *adapter) CredDel(uid t.Uid, method, value string) error {
+ return a.credDel(a.ctx, uid, method, value)
+}
+
+// CredConfirm marks given credential as validated.
+func (a *adapter) CredConfirm(uid t.Uid, method string) error {
+ cred, err := a.CredGetActive(uid, method)
+ if err != nil {
+ return err
+ }
+
+ cred.Done = true
+ cred.UpdatedAt = t.TimeNow()
+ if _, err = a.CredUpsert(cred); err != nil {
+ return err
+ }
+
+ _, _ = a.db.Collection("credentials").DeleteOne(a.ctx, b.M{"_id": uid.String() + ":" + cred.Method + ":" + cred.Value})
+ return nil
+}
+
+// CredFail increments count of failed validation attepmts for the given credentials.
+func (a *adapter) CredFail(uid t.Uid, method string) error {
+ filter := b.M{
+ "user": uid.String(),
+ "deletedat": b.M{"$exists": false},
+ "method": method,
+ "done": false}
+
+ update := b.M{
+ "$inc": b.M{"retries": 1},
+ "$set": b.M{"updatedat": t.TimeNow()}}
+ _, err := a.db.Collection("credentials").UpdateOne(a.ctx, filter, update)
+ return err
+}
+
+// Authentication management for the basic authentication scheme
+
+// AuthGetUniqueRecord returns authentication record for a given unique value i.e. login.
+func (a *adapter) AuthGetUniqueRecord(unique string) (t.Uid, auth.Level, []byte, time.Time, error) {
+ var record struct {
+ UserId string
+ AuthLvl auth.Level
+ Secret []byte
+ Expires time.Time
+ }
+
+ filter := b.M{"_id": unique}
+ findOpts := mdbopts.FindOne().SetProjection(b.M{
+ "userid": 1,
+ "authlvl": 1,
+ "secret": 1,
+ "expires": 1,
+ })
+ if err := a.db.Collection("auth").FindOne(a.ctx, filter, findOpts).Decode(&record); err != nil {
+ if err == mdb.ErrNoDocuments {
+ return t.ZeroUid, 0, nil, time.Time{}, nil
+ }
+ return t.ZeroUid, 0, nil, time.Time{}, err
+ }
+
+ return t.ParseUid(record.UserId), record.AuthLvl, record.Secret, record.Expires, nil
+}
+
+// AuthGetRecord returns authentication record given user ID and method.
+func (a *adapter) AuthGetRecord(uid t.Uid, scheme string) (string, auth.Level, []byte, time.Time, error) {
+ var record struct {
+ Id string `bson:"_id"`
+ AuthLvl auth.Level
+ Secret []byte
+ Expires time.Time
+ }
+
+ filter := b.M{"userid": uid.String(), "scheme": scheme}
+ findOpts := mdbopts.FindOne().SetProjection(b.M{
+ "authlvl": 1,
+ "secret": 1,
+ "expires": 1,
+ })
+ err := a.db.Collection("auth").FindOne(a.ctx, filter, findOpts).Decode(&record)
+ if err != nil {
+ if err == mdb.ErrNoDocuments {
+ err = t.ErrNotFound
+ }
+ return "", 0, nil, time.Time{}, err
+ }
+
+ return record.Id, record.AuthLvl, record.Secret, record.Expires, nil
+}
+
+// AuthAddRecord creates new authentication record
+func (a *adapter) AuthAddRecord(uid t.Uid, scheme, unique string, authLvl auth.Level, secret []byte, expires time.Time) error {
+ authRecord := b.M{
+ "_id": unique,
+ "userid": uid.String(),
+ "scheme": scheme,
+ "authlvl": authLvl,
+ "secret": secret,
+ "expires": expires}
+ if _, err := a.db.Collection("auth").InsertOne(a.ctx, authRecord); err != nil {
+ if isDuplicateErr(err) {
+ return t.ErrDuplicate
+ }
+ return err
+ }
+ return nil
+}
+
+// AuthDelScheme deletes an existing authentication scheme for the user.
+func (a *adapter) AuthDelScheme(uid t.Uid, scheme string) error {
+ _, err := a.db.Collection("auth").DeleteOne(a.ctx,
+ b.M{
+ "userid": uid.String(),
+ "scheme": scheme})
+ return err
+}
+
+func (a *adapter) authDelAllRecords(ctx context.Context, uid t.Uid) (int, error) {
+ res, err := a.db.Collection("auth").DeleteMany(ctx, b.M{"userid": uid.String()})
+ return int(res.DeletedCount), err
+}
+
+// AuthDelAllRecords deletes all records of a given user.
+func (a *adapter) AuthDelAllRecords(uid t.Uid) (int, error) {
+ return a.authDelAllRecords(a.ctx, uid)
+}
+
+// AuthUpdRecord modifies an authentication record.
+func (a *adapter) AuthUpdRecord(uid t.Uid, scheme, unique string,
+ authLvl auth.Level, secret []byte, expires time.Time) error {
+ // The primary key is immutable. If '_id' has changed, we have to replace the old record with a new one:
+ // 1. Check if '_id' has changed.
+ // 2. If not, execute update by '_id'
+ // 3. If yes, first insert the new record (it may fail due to dublicate '_id') then delete the old one.
+
+ var err error
+ var record common.AuthRecord
+ findOpts := mdbopts.FindOne().SetProjection(b.M{"_id": 1})
+ filter := b.M{"userid": uid.String(), "scheme": scheme}
+ if err = a.db.Collection("auth").FindOne(a.ctx, filter, findOpts).Decode(&record); err != nil {
+ if err == mdb.ErrNoDocuments {
+ err = t.ErrNotFound
+ }
+ return err
+ }
+
+ if record.Unique == unique {
+ upd := b.M{
+ "authlvl": authLvl,
+ }
+ if len(secret) > 0 {
+ upd["secret"] = secret
+ }
+ if !expires.IsZero() {
+ upd["expires"] = expires
+ }
+ _, err = a.db.Collection("auth").UpdateOne(a.ctx,
+ b.M{"_id": unique},
+ b.M{"$set": upd})
+ } else {
+ // Unique has changed. Insert-Delete.
+ // FIXME: use transaction.
+ if len(secret) == 0 {
+ secret = record.Secret
+ }
+ if expires.IsZero() {
+ expires = record.Expires
+ }
+ err = a.AuthAddRecord(uid, scheme, unique, authLvl, secret, expires)
+ if err == nil {
+ // Delete the old record. Not much can be done with the error.
+ a.db.Collection("auth").DeleteOne(a.ctx, b.M{"_id": record.Unique})
+ }
+ }
+
+ return err
+}
+
+// Topic management
+
+func (a *adapter) undeleteSubscription(sub *t.Subscription) error {
+ _, err := a.db.Collection("subscriptions").UpdateOne(a.ctx,
+ b.M{"_id": sub.Id},
+ b.M{
+ "$unset": b.M{"deletedat": ""},
+ "$set": b.M{
+ "updatedat": sub.UpdatedAt,
+ "createdat": sub.CreatedAt,
+ "modegiven": sub.ModeGiven,
+ "modewant": sub.ModeWant,
+ "delid": 0,
+ "readseqid": 0,
+ "recvseqid": 0}})
+ return err
+}
+
+// TopicCreate creates a topic
+func (a *adapter) TopicCreate(topic *t.Topic) error {
+ _, err := a.db.Collection("topics").InsertOne(a.ctx, &topic)
+ return err
+}
+
+// TopicCreateP2P creates a p2p topic.
+func (a *adapter) TopicCreateP2P(initiator, invited *t.Subscription) error {
+ initiator.Id = initiator.Topic + ":" + initiator.User
+ // Don't care if the initiator changes own subscription
+ replOpts := mdbopts.Replace().SetUpsert(true)
+ _, err := a.db.Collection("subscriptions").ReplaceOne(a.ctx, b.M{"_id": initiator.Id}, initiator, replOpts)
+ if err != nil {
+ return err
+ }
+
+ // If the second subscription exists, don't overwrite it. Just make sure it's not deleted.
+ invited.Id = invited.Topic + ":" + invited.User
+ _, err = a.db.Collection("subscriptions").InsertOne(a.ctx, invited)
+ if err != nil {
+ // Is this a duplicate subscription?
+ if !isDuplicateErr(err) {
+ // It's a genuine DB error
+ return err
+ }
+ // Undelete the second subsription if it exists: remove DeletedAt, update CreatedAt and UpdatedAt,
+ // update ModeGiven.
+ err = a.undeleteSubscription(invited)
+ if err != nil {
+ return err
+ }
+ }
+
+ topic := &t.Topic{
+ ObjHeader: t.ObjHeader{Id: initiator.Topic},
+ TouchedAt: initiator.GetTouchedAt(),
+ }
+ topic.ObjHeader.MergeTimes(&initiator.ObjHeader)
+ return a.TopicCreate(topic)
+}
+
+// TopicGet loads a single topic by name, if it exists. If the topic does not exist the call returns (nil, nil)
+func (a *adapter) TopicGet(topic string) (*t.Topic, error) {
+ var tt = new(t.Topic)
+ if err := a.db.Collection("topics").FindOne(a.ctx, b.M{"_id": topic}).Decode(tt); err != nil {
+ if err == mdb.ErrNoDocuments {
+ return nil, nil
+ }
+ return nil, err
+ }
+
+ if t.GetTopicCat(topic) == t.TopicCatGrp {
+ // Topic found, get subsription count.
+ subCnt, err := a.subscriptionCount(topic)
+ if err != nil {
+ return nil, err
+ }
+
+ if int(subCnt) != tt.SubCnt {
+ // Update the topic with the correct subscription count.
+ tt.SubCnt = int(subCnt)
+ err = a.topicUpdate(topic, b.M{"subcnt": tt.SubCnt})
+ if err != nil {
+ return nil, err
+ }
+ }
+ }
+
+ tt.Public = unmarshalBsonD(tt.Public)
+ tt.Trusted = unmarshalBsonD(tt.Trusted)
+
+ return tt, nil
+}
+
+// TopicsForUser loads user's contact list: p2p and grp topics, except for 'me' & 'fnd' subscriptions.
+// Reads and denormalizes Public & Trusted values.
+func (a *adapter) TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) {
+ // Fetch all user's subscriptions.
+ filter := b.M{"user": uid.String()}
+ if !keepDeleted {
+ // Filter out rows with defined deletedat
+ filter["deletedat"] = b.M{"$exists": false}
+ }
+
+ limit := 0
+ ims := time.Time{}
+ if opts != nil {
+ if opts.Topic != "" {
+ filter["topic"] = opts.Topic
+ }
+
+ // Apply the limit only when the client does not manage the cache (or cold start).
+ // Otherwise have to get all subscriptions and do a manual join with users/topics.
+ if opts.IfModifiedSince == nil {
+ if opts.Limit > 0 && opts.Limit < a.maxResults {
+ limit = opts.Limit
+ } else {
+ limit = a.maxResults
+ }
+ } else {
+ ims = *opts.IfModifiedSince
+ }
+ } else {
+ limit = a.maxResults
+ }
+
+ var findOpts *mdbopts.FindOptions
+ if limit > 0 {
+ findOpts = mdbopts.Find().SetLimit(int64(limit))
+ }
+
+ cur, err := a.db.Collection("subscriptions").Find(a.ctx, filter, findOpts)
+ if err != nil {
+ return nil, err
+ }
+ // Must close the cursor manually as we will be reusing it.
+
+ // Fetch subscriptions. Two queries are needed: users table (me & p2p) and topics table (p2p and grp).
+ // Prepare a list of Separate subscriptions to users vs topics
+ join := make(map[string]t.Subscription) // Keeping these to make a join with table for .private and .access
+ topq := make([]string, 0, 16)
+ usrq := make([]string, 0, 16)
+ for cur.Next(a.ctx) {
+ var sub t.Subscription
+ if err = cur.Decode(&sub); err != nil {
+ break
+ }
+ tname := sub.Topic
+ sub.User = uid.String()
+ tcat := t.GetTopicCat(tname)
+
+ if tcat == t.TopicCatMe || tcat == t.TopicCatFnd {
+ // Skip 'me' or 'fnd' subscription. Don't skip 'sys'.
+ continue
+ } else if tcat == t.TopicCatP2P {
+ // P2P subscription, find the other user to get user.Public
+ uid1, uid2, _ := t.ParseP2P(sub.Topic)
+ if uid1 == uid {
+ usrq = append(usrq, uid2.String())
+ sub.SetWith(uid2.UserId())
+ } else {
+ usrq = append(usrq, uid1.String())
+ sub.SetWith(uid1.UserId())
+ }
+ topq = append(topq, tname)
+ } else if tcat == t.TopicCatGrp {
+ // Maybe convert channel name to topic name.
+ tname = t.ChnToGrp(tname)
+ }
+ // No special handling needed for 'slf', 'sys' subscriptions.
+
+ topq = append(topq, tname)
+ sub.Private = unmarshalBsonD(sub.Private)
+ join[tname] = sub
+ }
+ cur.Close(a.ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ var subs []t.Subscription
+ if len(join) == 0 {
+ return subs, nil
+ }
+
+ if len(topq) > 0 {
+ // Fetch grp & p2p topics
+ filter = b.M{"_id": b.M{"$in": topq}}
+
+ if !keepDeleted {
+ filter["state"] = b.M{"$ne": t.StateDeleted}
+ }
+
+ if !ims.IsZero() {
+ // Use cache timestamp if provided: get newer entries only.
+ filter["touchedat"] = b.M{"$gt": ims}
+
+ findOpts = nil
+ if limit > 0 && limit < len(topq) {
+ // No point in fetching more than the requested limit.
+ findOpts = mdbopts.Find().SetSort(b.D{{"touchedat", 1}}).SetLimit(int64(limit))
+ }
+ }
+
+ cur, err = a.db.Collection("topics").Find(a.ctx, filter, findOpts)
+ if err != nil {
+ return nil, err
+ }
+
+ for cur.Next(a.ctx) {
+ var top t.Topic
+ if err = cur.Decode(&top); err != nil {
+ break
+ }
+ sub := join[top.Id]
+ // Check if sub.UpdatedAt needs to be adjusted to earlier or later time.
+ sub.UpdatedAt = common.SelectLatestTime(sub.UpdatedAt, top.UpdatedAt)
+ sub.SetState(top.State)
+ sub.SetTouchedAt(top.TouchedAt)
+ sub.SetSeqId(top.SeqId)
+ if t.GetTopicCat(sub.Topic) == t.TopicCatGrp {
+ sub.SetSubCnt(top.SubCnt)
+ sub.SetPublic(unmarshalBsonD(top.Public))
+ sub.SetTrusted(unmarshalBsonD(top.Trusted))
+ }
+ // Put back the updated value of a p2p subsription, will process further below
+ join[top.Id] = sub
+ }
+ cur.Close(a.ctx)
+
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ // Fetch p2p users and join to p2p tables
+ if len(usrq) > 0 {
+ filter = b.M{"_id": b.M{"$in": usrq}}
+ if !keepDeleted {
+ filter["state"] = b.M{"$ne": t.StateDeleted}
+ }
+
+ // Ignoring ims: we need all users to get LastSeen and UserAgent.
+
+ cur, err = a.db.Collection("users").Find(a.ctx, filter, findOpts)
+ if err != nil {
+ return nil, err
+ }
+
+ for cur.Next(a.ctx) {
+ var usr2 t.User
+ if err = cur.Decode(&usr2); err != nil {
+ break
+ }
+
+ joinOn := uid.P2PName(t.ParseUid(usr2.Id))
+ if sub, ok := join[joinOn]; ok {
+ sub.UpdatedAt = common.SelectLatestTime(sub.UpdatedAt, usr2.UpdatedAt)
+ sub.SetState(usr2.State)
+ sub.SetPublic(unmarshalBsonD(usr2.Public))
+ sub.SetTrusted(unmarshalBsonD(usr2.Trusted))
+ sub.SetDefaultAccess(usr2.Access.Auth, usr2.Access.Anon)
+ sub.SetLastSeenAndUA(usr2.LastSeen, usr2.UserAgent)
+ join[joinOn] = sub
+ }
+ }
+ cur.Close(a.ctx)
+
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ subs = make([]t.Subscription, 0, len(join))
+ for _, sub := range join {
+ subs = append(subs, sub)
+ }
+
+ return common.SelectEarliestUpdatedSubs(subs, opts, a.maxResults), nil
+}
+
+// UsersForTopic loads users' subscriptions for a given topic (not channel readers).
+// Public & Trusted are loaded.
+func (a *adapter) UsersForTopic(topic string, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) {
+ tcat := t.GetTopicCat(topic)
+
+ // Fetch all subscribed users. The number of users is not large.
+ filter := b.M{"topic": topic}
+ if !keepDeleted && tcat != t.TopicCatP2P {
+ // Filter out rows with DeletedAt being not null.
+ // P2P topics must load all subscriptions otherwise it will be impossible
+ // to swap Public values.
+ filter["deletedat"] = b.M{"$exists": false}
+ }
+
+ limit := a.maxResults
+ var oneUser t.Uid
+ if opts != nil {
+ // Ignore IfModifiedSince - we must return all entries
+ // Those unmodified will be stripped of Public, Trusted & Private.
+
+ if !opts.User.IsZero() {
+ if tcat != t.TopicCatP2P {
+ filter["user"] = opts.User.String()
+ }
+ oneUser = opts.User
+ }
+ if opts.Limit > 0 && opts.Limit < limit {
+ limit = opts.Limit
+ }
+ }
+
+ cur, err := a.db.Collection("subscriptions").Find(a.ctx, filter, mdbopts.Find().SetLimit(int64(limit)))
+ if err != nil {
+ return nil, err
+ }
+
+ // Fetch subscriptions.
+ var subs []t.Subscription
+ join := make(map[string]t.Subscription)
+ usrq := make([]any, 0, 16)
+ for cur.Next(a.ctx) {
+ var sub t.Subscription
+ if err = cur.Decode(&sub); err != nil {
+ break
+ }
+ join[sub.User] = sub
+ usrq = append(usrq, sub.User)
+ }
+ cur.Close(a.ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ // Fetch users by a list of subscriptions.
+ if len(usrq) > 0 {
+ subs = make([]t.Subscription, 0, len(usrq))
+ cur, err = a.db.Collection("users").Find(a.ctx, b.M{
+ "_id": b.M{"$in": usrq},
+ "state": b.M{"$ne": t.StateDeleted}})
+ if err != nil {
+ return nil, err
+ }
+
+ for cur.Next(a.ctx) {
+ var usr2 t.User
+ if err = cur.Decode(&usr2); err != nil {
+ break
+ }
+ if sub, ok := join[usr2.Id]; ok {
+ sub.ObjHeader.MergeTimes(&usr2.ObjHeader)
+ sub.Private = unmarshalBsonD(sub.Private)
+ sub.SetPublic(unmarshalBsonD(usr2.Public))
+ sub.SetTrusted(unmarshalBsonD(usr2.Trusted))
+ sub.SetLastSeenAndUA(usr2.LastSeen, usr2.UserAgent)
+ subs = append(subs, sub)
+ }
+ }
+ cur.Close(a.ctx)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ if t.GetTopicCat(topic) == t.TopicCatP2P && len(subs) > 0 {
+ // Swap public values & lastSeen of P2P topics as expected.
+ if len(subs) == 1 {
+ // User is deleted. Nothing we can do.
+ subs[0].SetPublic(nil)
+ subs[0].SetTrusted(nil)
+ subs[0].SetLastSeenAndUA(nil, "")
+ } else {
+ tmp := subs[0].GetPublic()
+ subs[0].SetPublic(subs[1].GetPublic())
+ subs[1].SetPublic(tmp)
+
+ tmp = subs[0].GetTrusted()
+ subs[0].SetTrusted(subs[1].GetTrusted())
+ subs[1].SetTrusted(tmp)
+
+ lastSeen := subs[0].GetLastSeen()
+ userAgent := subs[0].GetUserAgent()
+ subs[0].SetLastSeenAndUA(subs[1].GetLastSeen(), subs[1].GetUserAgent())
+ subs[1].SetLastSeenAndUA(lastSeen, userAgent)
+ }
+
+ // Remove deleted and unneeded subscriptions
+ if !keepDeleted || !oneUser.IsZero() {
+ var xsubs []t.Subscription
+ for i := range subs {
+ if (subs[i].DeletedAt != nil && !keepDeleted) || (!oneUser.IsZero() && subs[i].Uid() != oneUser) {
+ continue
+ }
+ xsubs = append(xsubs, subs[i])
+ }
+ subs = xsubs
+ }
+ }
+
+ return subs, nil
+}
+
+// topicNamesForUser reads topic names from the 'field' of 'collection' using 'filter'.
+// If includeChan is true, for group topics also add the corresponding channel name.
+func (a *adapter) topicNamesForUser(collection string, filter b.M, field string, includeChan bool) ([]string, error) {
+ cur, err := a.db.Collection(collection).Find(a.ctx, filter,
+ mdbopts.Find().SetProjection(b.M{field: 1}))
+ if err != nil {
+ return nil, err
+ }
+ defer cur.Close(a.ctx)
+
+ var names []string
+ for cur.Next(a.ctx) {
+ var res map[string]string
+ if err = cur.Decode(&res); err != nil {
+ break
+ }
+ names = append(names, res[field])
+ // If the name is a group topic, also add the channel name if requested.
+ if includeChan {
+ if channel := t.GrpToChn(res[field]); channel != "" {
+ names = append(names, channel)
+ }
+ }
+ }
+
+ return names, err
+}
+
+func (a *adapter) p2pTopicsForUser(uid t.Uid) ([]string, error) {
+ return a.topicNamesForUser("subscriptions",
+ b.M{
+ "user": uid.String(),
+ "deletedat": b.M{"$exists": false},
+ "topic": b.M{"$regex": primitive.Regex{Pattern: "^p2p"}}},
+ "topic", false)
+}
+
+// OwnTopics loads a slice of topic names where the user is the owner.
+func (a *adapter) OwnTopics(uid t.Uid) ([]string, error) {
+ return a.topicNamesForUser("topics",
+ b.M{"owner": uid.String(), "state": b.M{"$ne": t.StateDeleted}},
+ "_id", false)
+}
+
+// ChannelsForUser loads a slice of topic names where the user is a channel reader and notifications (P) are enabled.
+func (a *adapter) ChannelsForUser(uid t.Uid) ([]string, error) {
+ return a.topicNamesForUser("subscriptions",
+ b.M{
+ "user": uid.String(),
+ "deletedat": b.M{"$exists": false},
+ "topic": b.M{"$regex": primitive.Regex{Pattern: "^chn"}},
+ "modewant": b.M{"$bitsAllSet": b.A{t.ModePres}},
+ "modegiven": b.M{"$bitsAllSet": b.A{t.ModePres}}},
+ "topic", false)
+}
+
+// TopicShare creates topic subscriptions.
+func (a *adapter) TopicShare(topic string, shares []*t.Subscription) error {
+ // Assign Ids.
+ for _, sub := range shares {
+ sub.Id = sub.Topic + ":" + sub.User
+ }
+
+ // Subscription could have been marked as deleted (DeletedAt != nil). If it's marked
+ // as deleted, unmark by clearing the DeletedAt field of the old subscription and
+ // updating times and ModeGiven.
+ for _, sub := range shares {
+ _, err := a.db.Collection("subscriptions").InsertOne(a.ctx, sub)
+ if err != nil {
+ if isDuplicateErr(err) {
+ if err = a.undeleteSubscription(sub); err != nil {
+ return err
+ }
+ } else {
+ return err
+ }
+ }
+ }
+
+ if topic != "" {
+ // Update topic's subscription count.
+ // The error is ignored because the subscriptions have been created already.
+ a.db.Collection("topics").UpdateOne(a.ctx,
+ b.M{"_id": topic},
+ b.M{"$inc": b.M{"subcnt": len(shares)}})
+ }
+
+ return nil
+}
+
+// TopicDelete deletes topic, subscriptions, messages.
+func (a *adapter) TopicDelete(topic string, isChan, hard bool) error {
+ filter := b.M{}
+ if isChan {
+ // If the topic is a channel, must try to delete subscriptions under both grpXXX and chnXXX names.
+ filter["$or"] = b.A{
+ b.M{"topic": topic},
+ b.M{"topic": t.GrpToChn(topic)},
+ }
+ } else {
+ filter["topic"] = topic
+ }
+ err := a.subsDelete(a.ctx, filter, hard)
+ if err != nil {
+ return err
+ }
+
+ filter = b.M{"_id": topic}
+ if hard {
+ if err = a.decFileUseCounter(a.ctx, "topics", filter); err != nil {
+ return err
+ }
+ if err = a.MessageDeleteList(topic, nil); err != nil {
+ return err
+ }
+ _, err = a.db.Collection("topics").DeleteOne(a.ctx, filter)
+ } else {
+ _, err = a.db.Collection("topics").UpdateOne(a.ctx, filter, b.M{"$set": b.M{
+ "state": t.StateDeleted,
+ "stateat": t.TimeNow(),
+ }})
+ }
+
+ return err
+}
+
+// TopicUpdateOnMessage increments Topic's or User's SeqId value and updates TouchedAt timestamp.
+func (a *adapter) TopicUpdateOnMessage(topic string, msg *t.Message) error {
+ return a.topicUpdate(topic, b.M{"seqid": msg.SeqId, "touchedat": msg.CreatedAt})
+}
+
+func (a *adapter) subscriptionCount(topic string) (int64, error) {
+ // Get count of non-deleted subscriptions to the topic.
+ return a.db.Collection("subscriptions").CountDocuments(a.ctx, b.M{
+ "topic": b.M{"$in": b.A{topic, t.GrpToChn(topic)}},
+ "deletedat": b.M{"$exists": false},
+ })
+}
+
+// TopicUpdateSubCnt updates subscriber count denormalized in topic.
+func (a *adapter) TopicUpdateSubCnt(topic string) error {
+ // Get count of non-deleted subscriptions to the topic.
+ // UPDATE ... SET=(SELECT ...) is not supported in MongoDB, so we have to do it in two queries.
+ count, err := a.subscriptionCount(topic)
+ if err != nil {
+ return err
+ }
+ return a.topicUpdate(topic, b.M{"subcnt": count})
+}
+
+// TopicUpdate updates topic record.
+func (a *adapter) TopicUpdate(topic string, update map[string]any) error {
+ if t, u := update["TouchedAt"], update["UpdatedAt"]; t == nil && u != nil {
+ update["TouchedAt"] = u
+ }
+ return a.topicUpdate(topic, normalizeUpdateMap(update))
+}
+
+// TopicOwnerChange updates topic's owner
+func (a *adapter) TopicOwnerChange(topic string, newOwner t.Uid) error {
+ return a.topicUpdate(topic, map[string]any{"owner": newOwner.String()})
+}
+
+func (a *adapter) topicUpdate(topic string, update map[string]any) error {
+ _, err := a.db.Collection("topics").UpdateOne(a.ctx,
+ b.M{"_id": topic},
+ b.M{"$set": update})
+
+ return err
+}
+
+// Topic subscriptions
+
+// SubscriptionGet reads a subscription of a user to a topic.
+func (a *adapter) SubscriptionGet(topic string, user t.Uid, keepDeleted bool) (*t.Subscription, error) {
+ sub := new(t.Subscription)
+ filter := b.M{"_id": topic + ":" + user.String()}
+ if !keepDeleted {
+ filter["deletedat"] = b.M{"$exists": false}
+ }
+ err := a.db.Collection("subscriptions").FindOne(a.ctx, filter).Decode(sub)
+ if err != nil {
+ if err == mdb.ErrNoDocuments {
+ return nil, nil
+ }
+ return nil, err
+ }
+
+ return sub, nil
+}
+
+// SubsForUser loads all subscriptions of a given user. It does NOT load Public, Trusted or Private values,
+// does not load deleted subs.
+func (a *adapter) SubsForUser(user t.Uid) ([]t.Subscription, error) {
+ filter := b.M{"user": user.String(), "deletedat": b.M{"$exists": false}}
+
+ cur, err := a.db.Collection("subscriptions").Find(a.ctx, filter)
+ if err != nil {
+ return nil, err
+ }
+ defer cur.Close(a.ctx)
+
+ var subs []t.Subscription
+ for cur.Next(a.ctx) {
+ var ss t.Subscription
+ if err := cur.Decode(&ss); err != nil {
+ return nil, err
+ }
+ ss.Private = nil
+ subs = append(subs, ss)
+ }
+
+ return subs, cur.Err()
+}
+
+// SubsForTopic fetches all subsciptions for a topic. Does NOT load Public value and does not load channel readers.
+// The difference between UsersForTopic vs SubsForTopic is that the former loads user.public+trusted,
+// the latter does not.
+func (a *adapter) SubsForTopic(topic string, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) {
+ filter := b.M{"topic": topic}
+ if !keepDeleted {
+ filter["deletedat"] = b.M{"$exists": false}
+ }
+
+ limit := a.maxResults
+ if opts != nil {
+ // Ignore IfModifiedSince - we must return all entries
+ // Those unmodified will be stripped of Public, Trusted & Private.
+
+ if !opts.User.IsZero() {
+ filter["user"] = opts.User.String()
+ }
+ if opts.Limit > 0 && opts.Limit < limit {
+ limit = opts.Limit
+ }
+ }
+ findOpts := new(mdbopts.FindOptions).SetLimit(int64(limit))
+
+ cur, err := a.db.Collection("subscriptions").Find(a.ctx, filter, findOpts)
+ if err != nil {
+ return nil, err
+ }
+ defer cur.Close(a.ctx)
+
+ var subs []t.Subscription
+ for cur.Next(a.ctx) {
+ var ss t.Subscription
+ if err := cur.Decode(&ss); err != nil {
+ return nil, err
+ }
+ ss.Private = unmarshalBsonD(ss.Private)
+ subs = append(subs, ss)
+ }
+
+ return subs, cur.Err()
+}
+
+// SubsUpdate updates part of a subscription object. Pass nil for fields which don't need to be updated
+func (a *adapter) SubsUpdate(topic string, user t.Uid, update map[string]any) error {
+ // Convert CamelCase field names to lowercase.
+ update = normalizeUpdateMap(update)
+
+ filter := b.M{}
+ if !user.IsZero() {
+ // Update one topic subscription
+ filter["_id"] = topic + ":" + user.String()
+ } else {
+ // Update all topic subscriptions
+ filter["topic"] = topic
+ }
+ _, err := a.db.Collection("subscriptions").UpdateOne(a.ctx, filter, b.M{"$set": update})
+ return err
+}
+
+// SubsDelete marks at most one subscription as deleted (soft-deleting).
+func (a *adapter) SubsDelete(topic string, user t.Uid) error {
+ var sess mdb.Session
+ var err error
+
+ if sess, err = a.conn.StartSession(); err != nil {
+ return err
+ }
+ defer sess.EndSession(a.ctx)
+
+ if err = a.maybeStartTransaction(sess); err != nil {
+ return err
+ }
+
+ forUser := user.String()
+
+ return mdb.WithSession(a.ctx, sess, func(sc mdb.SessionContext) error {
+ if err := a.subsDelete(sc, b.M{"_id": topic + ":" + forUser}, false); err != nil {
+ return err
+ }
+
+ // Channel readers cannot delete messages.
+ if !t.IsChannel(topic) {
+
+ // Delete user's dellog entries.
+ if _, err := a.db.Collection("dellog").DeleteMany(sc, b.M{"topic": topic, "deletedfor": forUser}); err != nil {
+ return err
+ }
+
+ // Delete user's markings of soft-deleted messages
+ filter := b.M{"topic": topic, "deletedfor.user": forUser}
+ if _, err := a.db.Collection("messages").
+ UpdateMany(sc, filter, b.M{"$pull": b.M{"deletedfor": b.M{"user": forUser}}}); err != nil {
+ return err
+ }
+ }
+
+ if t.GetTopicCat(topic) == t.TopicCatGrp {
+ // Decrement topic subscription count (only one subscription is deleted).
+ if err := a.topicUpdate(topic, b.M{"subcnt": -1}); err != nil {
+ return err
+ }
+ }
+
+ // Commit changes.
+ return a.maybeCommitTransaction(sc, sess)
+ })
+}
+
+// clearUserDellog deletes all dellog entries and deletedfor markings of a given user.
+func (a *adapter) clearUserDellog(sc mdb.SessionContext, forUser string) error {
+ topics, err := a.db.Collection("subscriptions").Distinct(sc, "topic",
+ b.M{"user": forUser, "deletedat": b.M{"$exists": false}})
+ if err != nil {
+ return err
+ }
+
+ // No need to convert channel names to group names:
+ // channel readers cannot delete messages.
+
+ if len(topics) > 0 {
+ // Delete user's dellog entries.
+ if _, err = a.db.Collection("dellog").DeleteMany(sc,
+ b.M{"topic": b.M{"$in": topics}, "deletedfor": forUser}); err != nil {
+ return err
+ }
+
+ // Delete user's markings of soft-deleted messages
+ filter := b.M{"topic": b.M{"$in": topics}, "deletedfor.user": forUser}
+ if _, err = a.db.Collection("messages").
+ UpdateMany(sc, filter, b.M{"$pull": b.M{"deletedfor": b.M{"user": forUser}}}); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+// Delete/mark deleted subscriptions and decrement subcnt in topic.
+func (a *adapter) subsDelete(ctx context.Context, filter b.M, hard bool) error {
+ // First, decrement subscription count in all affected topics.
+ // Doing it in two steps because MongoDB does not support an equivalent of
+ // 'UPDATE .. LEFT JOIN ...'.
+ filterWithDeletedAt := copyBsonMap(filter)
+ filterWithDeletedAt["deletedat"] = b.M{"$exists": false}
+ cur, err := a.db.Collection("subscriptions").Find(ctx, filterWithDeletedAt,
+ mdbopts.Find().SetProjection(b.D{{"topic", 1}, {"_id", 0}}))
+ if err != nil {
+ return err
+ }
+ defer cur.Close(ctx)
+ var topics []string
+ for cur.Next(ctx) {
+ var result struct {
+ Topic string `bson:"topic"`
+ }
+ if err = cur.Decode(&result); err != nil {
+ return err
+ }
+ if t.IsChannel(result.Topic) {
+ // Convert channel name to group name.
+ topics = append(topics, t.ChnToGrp(result.Topic))
+ }
+ topics = append(topics, result.Topic)
+ }
+
+ if err = cur.Err(); err != nil {
+ return err
+ }
+
+ if len(topics) > 0 {
+ // Decrement subscription count in affected topics.
+ a.db.Collection("topics").UpdateMany(ctx,
+ b.M{"_id": b.M{"$in": topics}},
+ b.M{"$inc": b.M{"subcnt": -1}})
+ }
+
+ // Now delete or mark deleted the subscriptions.
+ if hard {
+ _, err = a.db.Collection("subscriptions").DeleteMany(ctx, filter)
+ } else {
+ now := t.TimeNow()
+ _, err = a.db.Collection("subscriptions").UpdateMany(ctx, filterWithDeletedAt,
+ b.M{"$set": b.M{"updatedat": now, "deletedat": now}})
+ }
+ return err
+}
+
+// Find searches for contacts and topics given a list of tags.
+func (a *adapter) Find(caller, prefPrefix string, req [][]string, opt []string, activeOnly bool) ([]t.Subscription, error) {
+ /*
+ // MongoDB aggregation pipeline using unionWith.
+ [
+ { $match: { tags: { $in: ["basic:alice", "travel"] } } },
+ { $unionWith: {
+ coll: "topics",
+ pipeline: [ { $match: { tags: { $in: ["basic:alice", "travel"] } } } ]
+ }
+ },
+ { $project: { _id: 1, access: 1, createdat: 1, updatedat: 1, usebt: 1, public: 1, trusted: 1, tags: 1, _source: 1 } },
+ { $addFields: { matchedCount: { $sum: { $map: {
+ input: { $setIntersection: [ "$tags", [ "alias:aliassa", "basic:alice", "travel" ] ] },
+ as: "tag",
+ in: { $cond: { if: { $regexMatch: { input: "$$tag", regex: "^alias:"} }, then: 20, else: 1 } }
+ } }}}},
+ { $match: { $expr: { $ne: [ { $size: { $setIntersection: [ "$tags", ["basic:alice", "travel"] ] } }, 0 ] } } },
+ { $sort: { matchedCount: -1 } },
+ { $limit: 20 }
+ ]
+
+ // Alternative approach using $facet for (supposedly) better performance:
+ [ { $facet: {
+ users: [
+ { $match: { tags: { $in: [ "alias:alice", "basic:alice", "travel" ] } } },
+ { $project: { _id: 1, access: 1, createdat: 1, updatedat: 1, usebt: 1, public: 1, trusted: 1, tags: 1 } }
+ ],
+ topics: [
+ { $lookup: {
+ from: "topics",
+ pipeline: [
+ { $match: { tags: { $in: [ "alias:alice", "basic:alice", "travel" ] } } },
+ { $project: { _id: 1, access: 1, createdat: 1, updatedat: 1, usebt: 1, public: 1, trusted: 1, tags: 1 } } }
+ ],
+ as: "topicDocs"
+ }},
+ { $unwind: "$topicDocs" },
+ { $replaceRoot: { newRoot: "$topicDocs" } }
+ ]
+ }
+ },
+ { $project: { combined: { $concatArrays: ["$users", "$topics"] } } },
+ { $unwind: "$combined" },
+ { $replaceRoot: { newRoot: "$combined" } },
+ { $group: { _id: "$_id", doc: { $first: "$$ROOT" } } },
+ { $replaceRoot: { newRoot: "$doc" } },
+ { $addFields: { matchedCount:
+ { $sum: { $map: { input:
+ { $setIntersection: [ "$tags", [ "alias:alice", "basic:alice", "travel" ] ] },
+ as: "tag",
+ in: {
+ $cond: {
+ if: { $regexMatch: { input: "$$tag", regex: "^alias:" } }, then: 20, else: 1 }
+ }
+ } }
+ } } },
+ { $match: { $expr: { $ne: [
+ { $size: { $setIntersection: [ "$tags", [ "alias:alice", "basic:alice", "travel" ] ] } },
+ 0
+ ] } } },
+ { $sort: { matchedCount: -1 } },
+ { $limit: 20 }
+ ]
+ */
+
+ index := make(map[string]struct{})
+ allReq := t.FlattenDoubleSlice(req)
+ var allTags []any
+ for _, tag := range append(allReq, opt...) {
+ allTags = append(allTags, tag)
+ index[tag] = struct{}{}
+ }
+
+ matchOn := b.M{"tags": b.M{"$in": allTags}}
+ if activeOnly {
+ matchOn["state"] = b.M{"$eq": t.StateOK}
+ }
+
+ projectFields := b.M{"_id": 1, "createdat": 1, "updatedat": 1, "usebt": 1,
+ "access": 1, "subcnt": 1, "public": 1, "trusted": 1, "tags": 1}
+
+ pipeline := b.A{
+ // Stage 1: $facet
+ b.M{
+ "$facet": b.D{
+ {"users", b.A{
+ b.M{"$match": matchOn},
+ b.M{"$project": projectFields},
+ }},
+ {"topics", b.A{
+ b.M{"$lookup": b.D{
+ {"from", "topics"},
+ {"pipeline", b.A{
+ b.M{"$match": matchOn},
+ b.M{"$project": projectFields},
+ }},
+ {"as", "topicDocs"},
+ }},
+ b.M{"$unwind": "$topicDocs"},
+ b.M{"$replaceRoot": b.M{"newRoot": "$topicDocs"}},
+ }},
+ },
+ },
+ // Stage 2: $project
+ b.M{"$project": b.M{"combined": b.M{"$concatArrays": b.A{"$users", "$topics"}}}},
+ // Stage 3: $unwind
+ b.M{"$unwind": "$combined"},
+ // Stage 4: $replaceRoot
+ b.M{"$replaceRoot": b.M{"newRoot": "$combined"}},
+ // Stage 5: $group
+ b.M{"$group": b.D{{"_id", "$_id"}, {"doc", b.M{"$first": "$$ROOT"}}}},
+ // Stage 6: $replaceRoot
+ b.M{"$replaceRoot": b.M{"newRoot": "$doc"}},
+ // Stage 7: $addFields
+ b.M{"$addFields": b.M{"matchedCount": b.M{"$sum": b.M{"$map": b.D{
+ {"input", b.M{"$setIntersection": b.A{"$tags", allTags}}},
+ {"as", "tag"},
+ {"in", b.D{
+ {"$cond", b.D{
+ {"if", b.M{"$regexMatch": b.D{
+ {"input", "$$tag"},
+ {"regex", "^alias:"},
+ },
+ }},
+ {"then", 20},
+ {"else", 1},
+ }}}}},
+ }}}},
+ }
+
+ // Ensure required tags are present.
+ for _, reqDisjunction := range req {
+ if len(reqDisjunction) == 0 {
+ continue
+ }
+ var reqTags []any
+ for _, tag := range reqDisjunction {
+ reqTags = append(reqTags, tag)
+ }
+ // Filter out documents where 'tags' intersection with 'reqTags' is an empty array.
+ pipeline = append(pipeline,
+ b.M{"$match": b.M{"$expr": b.M{"$ne": b.A{b.M{"$size": b.M{"$setIntersection": b.A{"$tags", reqTags}}}, 0}}}})
+ }
+
+ pipeline = append(pipeline,
+ // Stage 9: $sort
+ b.M{"$sort": b.D{{"matchedCount", -1}, {"subcnt", -1}}},
+ // Stage 10: $limit
+ b.M{"$limit": a.maxResults},
+ )
+
+ cur, err := a.db.Collection("users").Aggregate(a.ctx, pipeline)
+ if err != nil {
+ return nil, err
+ }
+ defer cur.Close(a.ctx)
+
+ var subs []t.Subscription
+ for cur.Next(a.ctx) {
+ var topic t.Topic
+ var sub t.Subscription
+ if err = cur.Decode(&topic); err != nil {
+ break
+ }
+
+ if topic.UseBt {
+ // This is a channel, convert grp to chn name: all channel-capable
+ // topics should appear as channels in search results.
+ sub.Topic = t.GrpToChn(topic.Id)
+ } else {
+ if uid := t.ParseUid(topic.Id); !uid.IsZero() {
+ topic.Id = uid.UserId()
+ if topic.Id == caller {
+ // Skip the caller.
+ continue
+ }
+ }
+ sub.Topic = topic.Id
+ }
+
+ sub.CreatedAt = topic.CreatedAt
+ sub.UpdatedAt = topic.UpdatedAt
+ sub.SetSubCnt(topic.SubCnt)
+ sub.SetPublic(unmarshalBsonD(topic.Public))
+ sub.SetTrusted(unmarshalBsonD(topic.Trusted))
+ sub.SetDefaultAccess(topic.Access.Auth, topic.Access.Anon)
+ // Indicating that the mode is not set, not 'N'.
+ sub.ModeGiven = t.ModeUnset
+ sub.ModeWant = t.ModeUnset
+ sub.Private = common.FilterFoundTags(topic.Tags, index)
+ subs = append(subs, sub)
+ }
+ if err == nil {
+ err = cur.Err()
+ }
+
+ return subs, err
+}
+
+// FindOne returns the first topic or user which matches the given tag.
+func (a *adapter) FindOne(tag string) (string, error) {
+ // Part of the pipeline identical for users and topics collections.
+ commonPipe := b.A{b.M{"$match": b.M{"tags": tag}}, b.M{"$project": b.M{"_id": 1}}}
+
+ // Must create a copy of commonPipe so the original commonPipe can be used unmodified in $unionWith.
+ pipeline := append(slices.Clone(commonPipe),
+ b.M{"$unionWith": b.M{"coll": "topics", "pipeline": commonPipe}},
+ b.M{"$limit": 1})
+ cur, err := a.db.Collection("users").Aggregate(a.ctx, pipeline)
+ if err != nil {
+ return "", err
+ }
+ defer cur.Close(a.ctx)
+
+ var found string
+ if cur.Next(a.ctx) {
+ entry := map[string]any{}
+ if err = cur.Decode(&entry); err != nil {
+ return "", err
+ }
+
+ if id, ok := entry["_id"].(string); ok {
+ if user := t.ParseUid(id); !user.IsZero() {
+ found = user.UserId()
+ } else {
+ found = id
+ }
+ }
+ }
+
+ return found, cur.Err()
+}
+
+// Messages
+
+// MessageSave saves message to database
+func (a *adapter) MessageSave(msg *t.Message) error {
+ _, err := a.db.Collection("messages").InsertOne(a.ctx, msg)
+ return err
+}
+
+// MessageGetAll returns messages matching the query.
+func (a *adapter) MessageGetAll(topic string, forUser t.Uid, opts *t.QueryOpt) ([]t.Message, error) {
+ var limit = a.maxMessageResults
+ var lower, upper int
+ requester := forUser.String()
+ if opts != nil {
+ if opts.Since > 0 {
+ lower = opts.Since
+ }
+ if opts.Before > 0 {
+ upper = opts.Before
+ }
+
+ if opts.Limit > 0 && opts.Limit < limit {
+ limit = opts.Limit
+ }
+ }
+ filter := b.M{
+ "topic": topic,
+ "delid": b.M{"$exists": false},
+ "deletedfor.user": b.M{"$ne": requester},
+ }
+ if upper == 0 {
+ filter["seqid"] = b.M{"$gte": lower}
+ } else {
+ filter["seqid"] = b.M{"$gte": lower, "$lt": upper}
+ }
+ findOpts := mdbopts.Find().SetSort(b.D{{"topic", -1}, {"seqid", -1}})
+ findOpts.SetLimit(int64(limit))
+
+ cur, err := a.db.Collection("messages").Find(a.ctx, filter, findOpts)
+ if err != nil {
+ return nil, err
+ }
+ defer cur.Close(a.ctx)
+
+ var msgs []t.Message
+ for cur.Next(a.ctx) {
+ var msg t.Message
+ if err = cur.Decode(&msg); err != nil {
+ return nil, err
+ }
+ msg.Content = unmarshalBsonD(msg.Content)
+ msgs = append(msgs, msg)
+ }
+
+ return msgs, nil
+}
+
+func (a *adapter) messagesHardDelete(topic string) error {
+ var err error
+
+ // TODO: handle file uploads
+ filter := b.M{"topic": topic}
+ if _, err = a.db.Collection("dellog").DeleteMany(a.ctx, filter); err != nil {
+ return err
+ }
+
+ if err = a.decFileUseCounter(a.ctx, "messages", filter); err != nil {
+ return err
+ }
+
+ if _, err = a.db.Collection("messages").DeleteMany(a.ctx, filter); err != nil {
+ return err
+ }
+
+ return err
+}
+
+// rangeToFilter is Mongo's equivalent of common.RangeToSql.
+func rangeToFilter(delRanges []t.Range, filter b.M) b.M {
+ if len(delRanges) > 1 || delRanges[0].Hi == 0 {
+ rangeFilter := b.A{}
+ for _, rng := range delRanges {
+ if rng.Hi == 0 {
+ rangeFilter = append(rangeFilter, b.M{"seqid": rng.Low})
+ } else {
+ rangeFilter = append(rangeFilter, b.M{"seqid": b.M{"$gte": rng.Low, "$lt": rng.Hi}})
+ }
+ }
+ filter["$or"] = rangeFilter
+ } else {
+ filter["seqid"] = b.M{"$gte": delRanges[0].Low, "$lt": delRanges[0].Hi}
+ }
+ return filter
+}
+
+// MessageDeleteList marks messages as deleted.
+// Soft- or Hard- is defined by forUser value: forUSer.IsZero == true is hard.
+func (a *adapter) MessageDeleteList(topic string, toDel *t.DelMessage) error {
+ var err error
+
+ if toDel == nil {
+ // No filter: delete all messages.
+ return a.messagesHardDelete(topic)
+ }
+
+ // Only some messages are being deleted
+
+ delRanges := toDel.SeqIdRanges
+ filter := b.M{
+ "topic": topic,
+ // Skip already hard-deleted messages.
+ "delid": b.M{"$exists": false},
+ }
+ // Mongo's equivalent of common.RangeToSql
+ rangeToFilter(delRanges, filter)
+
+ if toDel.DeletedFor == "" {
+ // Hard-deleting messages requires updates to the messages table.
+
+ // We are asked to delete messages no older than newerThan.
+ if newerThan := toDel.GetNewerThan(); newerThan != nil {
+ filter["createdat"] = b.M{"$gt": newerThan}
+ }
+
+ pipeline := b.A{
+ b.M{"$match": filter},
+ b.M{"$project": b.M{"seqid": 1}},
+ }
+
+ // Find the actual IDs still present in the database.
+
+ cur, err := a.db.Collection("messages").Aggregate(a.ctx, pipeline)
+ if err != nil {
+ return err
+ }
+ defer cur.Close(a.ctx)
+
+ var seqIDs []int
+ for cur.Next(a.ctx) {
+ var result struct {
+ SeqID int `bson:"seqid"`
+ }
+ if err = cur.Decode(&result); err != nil {
+ return err
+ }
+ seqIDs = append(seqIDs, result.SeqID)
+ }
+
+ if len(seqIDs) == 0 {
+ // Nothing to delete. No need to make a log entry. All done.
+ return nil
+ }
+
+ // Recalculate the actual ranges to delete.
+ sort.Ints(seqIDs)
+ delRanges = t.SliceToRanges(seqIDs)
+
+ // Compose a new query with the new ranges.
+ filter = b.M{
+ "topic": topic,
+ }
+ rangeToFilter(delRanges, filter)
+
+ if err = a.decFileUseCounter(a.ctx, "messages", filter); err != nil {
+ return err
+ }
+ // Hard-delete individual messages. Message is not deleted but all fields with content
+ // are replaced with nulls.
+ _, err = a.db.Collection("messages").UpdateMany(a.ctx, filter, b.M{"$set": b.M{
+ "deletedat": t.TimeNow(),
+ "delid": toDel.DelId,
+ "from": "",
+ "head": nil,
+ "content": nil,
+ "attachments": nil}})
+ } else {
+ // Soft-deleting: adding DelId to DeletedFor
+
+ // Skip messages already soft-deleted for the current user
+ filter["deletedfor.user"] = b.M{"$ne": toDel.DeletedFor}
+
+ _, err = a.db.Collection("messages").UpdateMany(a.ctx, filter,
+ b.M{"$addToSet": b.M{
+ "deletedfor": &t.SoftDelete{
+ User: toDel.DeletedFor,
+ DelId: toDel.DelId,
+ }}})
+ }
+
+ // Make log entries. Needed for both hard- and soft-deleting.
+ _, err = a.db.Collection("dellog").InsertOne(a.ctx, toDel)
+ return err
+}
+
+// MessageGetDeleted returns a list of deleted message Ids.
+func (a *adapter) MessageGetDeleted(topic string, forUser t.Uid, opts *t.QueryOpt) ([]t.DelMessage, error) {
+ var limit = a.maxResults
+ var lower, upper int
+ if opts != nil {
+ if opts.Since > 0 {
+ lower = opts.Since
+ }
+ if opts.Before > 0 {
+ upper = opts.Before
+ }
+ if opts.Limit > 0 && opts.Limit < limit {
+ limit = opts.Limit
+ }
+ }
+ filter := b.M{
+ "topic": topic,
+ "$or": b.A{
+ b.M{"deletedfor": forUser.String()},
+ b.M{"deletedfor": ""},
+ }}
+ if upper == 0 {
+ filter["delid"] = b.M{"$gte": lower}
+ } else {
+ filter["delid"] = b.M{"$gte": lower, "$lt": upper}
+ }
+ findOpts := mdbopts.Find().
+ SetSort(b.D{{"topic", 1}, {"delid", 1}}).
+ SetLimit(int64(limit))
+
+ cur, err := a.db.Collection("dellog").Find(a.ctx, filter, findOpts)
+ if err != nil {
+ return nil, err
+ }
+ defer cur.Close(a.ctx)
+
+ var dmsgs []t.DelMessage
+ if err = cur.All(a.ctx, &dmsgs); err != nil {
+ return nil, err
+ }
+
+ return dmsgs, nil
+}
+
+// Devices (for push notifications).
+
+// DeviceUpsert creates or updates a device record.
+func (a *adapter) DeviceUpsert(uid t.Uid, dev *t.DeviceDef) error {
+ userId := uid.String()
+ var user t.User
+ err := a.db.Collection("users").FindOne(a.ctx, b.M{
+ "_id": userId,
+ "devices.deviceid": dev.DeviceId}).Decode(&user)
+
+ if err == nil && user.Id != "" { // current user owns this device
+ // ArrayFilter used to avoid adding another (duplicate) device object. Update that device data
+ updOpts := mdbopts.Update().SetArrayFilters(mdbopts.ArrayFilters{
+ Filters: []any{b.M{"dev.deviceid": dev.DeviceId}}})
+ _, err = a.db.Collection("users").UpdateOne(a.ctx,
+ b.M{"_id": userId},
+ b.M{"$set": b.M{
+ "devices.$[dev].platform": dev.Platform,
+ "devices.$[dev].lastseen": dev.LastSeen,
+ "devices.$[dev].lang": dev.Lang}},
+ updOpts)
+ return err
+ } else if err == mdb.ErrNoDocuments { // device is free or owned by other user
+ err = a.deviceInsert(userId, dev)
+
+ if isDuplicateErr(err) {
+ // Other user owns this device.
+ // We need to delete this device from that user and then insert again
+ if _, err = a.db.Collection("users").UpdateOne(a.ctx,
+ b.M{"devices.deviceid": dev.DeviceId},
+ b.M{"$pull": b.M{"devices": b.M{"deviceid": dev.DeviceId}}}); err != nil {
+
+ return err
+ }
+ return a.deviceInsert(userId, dev)
+ }
+ if err != nil {
+ return err
+ }
+ return nil
+ }
+
+ return err
+}
+
+// deviceInsert adds device object to user.devices array
+func (a *adapter) deviceInsert(userId string, dev *t.DeviceDef) error {
+ filter := b.M{"_id": userId}
+ _, err := a.db.Collection("users").UpdateOne(a.ctx, filter,
+ b.M{"$push": b.M{"devices": dev}})
+
+ if err != nil && strings.Contains(err.Error(), "must be an array") {
+ // field 'devices' is not array. Make it array with 'dev' as its first element
+ _, err = a.db.Collection("users").UpdateOne(a.ctx, filter,
+ b.M{"$set": b.M{"devices": []any{dev}}})
+ }
+
+ return err
+}
+
+// DeviceGetAll returns all devices for a given set of users.
+func (a *adapter) DeviceGetAll(uids ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error) {
+ ids := make([]any, len(uids))
+ for i, id := range uids {
+ ids[i] = id.String()
+ }
+
+ filter := b.M{"_id": b.M{"$in": ids}}
+ findOpts := mdbopts.Find().SetProjection(b.M{"_id": 1, "devices": 1})
+ cur, err := a.db.Collection("users").Find(a.ctx, filter, findOpts)
+ if err != nil {
+ return nil, 0, err
+ }
+ defer cur.Close(a.ctx)
+
+ result := make(map[t.Uid][]t.DeviceDef)
+ count := 0
+ var uid t.Uid
+ for cur.Next(a.ctx) {
+ var row struct {
+ Id string `bson:"_id"`
+ Devices []t.DeviceDef
+ }
+ if err = cur.Decode(&row); err != nil {
+ return nil, 0, err
+ }
+ if len(row.Devices) > 0 {
+ if err := uid.UnmarshalText([]byte(row.Id)); err != nil {
+ continue
+ }
+
+ result[uid] = row.Devices
+ count++
+ }
+ }
+ return result, count, cur.Err()
+}
+
+// DeviceDelete deletes a device record (push token).
+func (a *adapter) DeviceDelete(uid t.Uid, deviceID string) error {
+ var err error
+ filter := b.M{"_id": uid.String()}
+ update := b.M{}
+ if deviceID == "" {
+ update["$set"] = b.M{"devices": []any{}}
+ } else {
+ update["$pull"] = b.M{"devices": b.M{"deviceid": deviceID}}
+ }
+ _, err = a.db.Collection("users").UpdateOne(a.ctx, filter, update)
+ return err
+}
+
+// File upload records. The files are stored outside of the database.
+
+// FileStartUpload initializes a file upload
+func (a *adapter) FileStartUpload(fd *t.FileDef) error {
+ _, err := a.db.Collection("fileuploads").InsertOne(a.ctx, fd)
+ return err
+}
+
+// FileFinishUpload marks file upload as completed, successfully or otherwise.
+func (a *adapter) FileFinishUpload(fd *t.FileDef, success bool, size int64) (*t.FileDef, error) {
+ now := t.TimeNow()
+ if success {
+ // Mark upload as completed.
+ if _, err := a.db.Collection("fileuploads").UpdateOne(a.ctx,
+ b.M{"_id": fd.Id},
+ b.M{"$set": b.M{
+ "updatedat": now,
+ "status": t.UploadCompleted,
+ "size": size,
+ "etag": fd.ETag,
+ "location": fd.Location,
+ }}); err != nil {
+
+ return nil, err
+ }
+ fd.Status = t.UploadCompleted
+ fd.Size = size
+ } else {
+ // Remove record: it's now useless.
+ if _, err := a.db.Collection("fileuploads").DeleteOne(a.ctx, b.M{"_id": fd.Id}); err != nil {
+ return nil, err
+ }
+ fd.Status = t.UploadFailed
+ fd.Size = 0
+ }
+
+ fd.UpdatedAt = now
+
+ return fd, nil
+}
+
+// FileGet fetches a record of a specific file
+func (a *adapter) FileGet(fid string) (*t.FileDef, error) {
+ var fd t.FileDef
+ err := a.db.Collection("fileuploads").FindOne(a.ctx, b.M{"_id": fid}).Decode(&fd)
+ if err != nil {
+ if err == mdb.ErrNoDocuments {
+ return nil, nil
+ }
+ return nil, err
+ }
+
+ return &fd, nil
+}
+
+// FileDeleteUnused deletes records where UseCount is zero. If olderThan is non-zero, deletes
+// unused records with UpdatedAt before olderThan.
+// Returns array of FileDef.Location of deleted filerecords so actual files can be deleted too.
+func (a *adapter) FileDeleteUnused(olderThan time.Time, limit int) ([]string, error) {
+ findOpts := mdbopts.Find()
+ filter := b.M{"$or": b.A{
+ b.M{"usecount": 0},
+ b.M{"usecount": b.M{"$exists": false}}}}
+ if !olderThan.IsZero() {
+ filter["updatedat"] = b.M{"$lt": olderThan}
+ }
+ if limit > 0 {
+ findOpts.SetLimit(int64(limit))
+ }
+
+ findOpts.SetProjection(b.M{"location": 1, "_id": 0})
+ cur, err := a.db.Collection("fileuploads").Find(a.ctx, filter, findOpts)
+ if err != nil {
+ return nil, err
+ }
+ defer cur.Close(a.ctx)
+
+ var locations []string
+ for cur.Next(a.ctx) {
+ var result map[string]string
+ if err := cur.Decode(&result); err != nil {
+ return nil, err
+ }
+ locations = append(locations, result["location"])
+ }
+
+ _, err = a.db.Collection("fileuploads").DeleteMany(a.ctx, filter)
+ return locations, err
+}
+
+// Given a filter query against 'messages' collection, decrement corresponding use counter in 'fileuploads' table.
+func (a *adapter) decFileUseCounter(ctx context.Context, collection string, msgFilter b.M) error {
+ // Copy msgFilter
+ filter := b.M{}
+ for k, v := range msgFilter {
+ filter[k] = v
+ }
+ filter["attachments"] = b.M{"$exists": true}
+ fileIds, err := a.db.Collection(collection).Distinct(ctx, "attachments", filter)
+ if err != nil {
+ return err
+ }
+
+ if len(fileIds) > 0 {
+ _, err = a.db.Collection("fileuploads").UpdateMany(ctx,
+ b.M{"_id": b.M{"$in": fileIds}},
+ b.M{"$inc": b.M{"usecount": -1}})
+ }
+
+ return err
+}
+
+// FileLinkAttachments connects given topic or message to the file record IDs from the list.
+func (a *adapter) FileLinkAttachments(topic string, userId, msgId t.Uid, fids []string) error {
+ if len(fids) == 0 || (topic == "" && userId.IsZero() && msgId.IsZero()) {
+ return t.ErrMalformed
+ }
+
+ now := t.TimeNow()
+ var err error
+
+ if msgId.IsZero() {
+ // Only one link per user or topic is permitted.
+ fids = fids[0:1]
+
+ // Topics and users and mutable. Must unlink the previous attachments first.
+ var table string
+ var linkId string
+ if topic != "" {
+ table = "topics"
+ linkId = topic
+ } else {
+ table = "users"
+ linkId = userId.String()
+ }
+
+ // Find the old attachment.
+ var attachments map[string][]string
+ findOpts := mdbopts.FindOne().SetProjection(b.M{"attachments": 1, "_id": 0})
+ err = a.db.Collection(table).FindOne(a.ctx, b.M{"_id": linkId}, findOpts).Decode(&attachments)
+ if err != nil {
+ return err
+ }
+
+ if len(attachments["attachments"]) > 0 {
+ // Decrement the use count of old attachment.
+ if _, err = a.db.Collection("fileuploads").UpdateOne(a.ctx,
+ b.M{"_id": attachments["attachments"][0]},
+ b.M{
+ "$set": b.M{"updatedat": now},
+ "$inc": b.M{"usecount": -1},
+ },
+ ); err != nil {
+ return err
+ }
+ }
+
+ _, err = a.db.Collection(table).UpdateOne(a.ctx,
+ b.M{"_id": linkId},
+ b.M{"$set": b.M{"updatedat": now, "attachments": fids}})
+ if err != nil {
+ return err
+ }
+ } else {
+ _, err = a.db.Collection("messages").UpdateOne(a.ctx,
+ b.M{"_id": msgId.String()},
+ b.M{"$set": b.M{"updatedat": now, "attachments": fids}})
+ if err != nil {
+ return err
+ }
+ }
+
+ ids := make([]any, len(fids))
+ for i, id := range fids {
+ ids[i] = id
+ }
+ _, err = a.db.Collection("fileuploads").UpdateMany(a.ctx,
+ b.M{"_id": b.M{"$in": ids}},
+ b.M{
+ "$set": b.M{"updatedat": now},
+ "$inc": b.M{"usecount": 1},
+ },
+ )
+
+ return err
+}
+
+// PCacheGet reads a persistet cache entry.
+func (a *adapter) PCacheGet(key string) (string, error) {
+ var value map[string]string
+ findOpts := mdbopts.FindOneOptions{Projection: b.M{"value": 1, "_id": 0}}
+ if err := a.db.Collection("kvmeta").FindOne(a.ctx, b.M{"_id": key}, &findOpts).Decode(&value); err != nil {
+ if err == mdb.ErrNoDocuments {
+ err = t.ErrNotFound
+ }
+ return "", err
+ }
+ return value["value"], nil
+}
+
+// PCacheUpsert creates or updates a persistent cache entry.
+func (a *adapter) PCacheUpsert(key string, value string, failOnDuplicate bool) error {
+ if strings.Contains(key, "^") {
+ // Do not allow ^ in keys: it interferes with $match query.
+ return t.ErrMalformed
+ }
+
+ collection := a.db.Collection("kvmeta")
+ doc := b.M{
+ "value": value,
+ }
+
+ if failOnDuplicate {
+ doc["_id"] = key
+ doc["createdat"] = t.TimeNow()
+ _, err := collection.InsertOne(a.ctx, doc)
+ if mdb.IsDuplicateKeyError(err) {
+ err = t.ErrDuplicate
+ }
+ return err
+ }
+
+ res := collection.FindOneAndUpdate(a.ctx, b.M{"_id": key}, b.M{"$set": doc},
+ mdbopts.FindOneAndUpdate().SetUpsert(true).SetReturnDocument(mdbopts.After))
+ return res.Err()
+}
+
+// PCacheDelete deletes one persistent cache entry.
+func (a *adapter) PCacheDelete(key string) error {
+ _, err := a.db.Collection("kvmeta").DeleteOne(a.ctx, b.M{"_id": key})
+ return err
+}
+
+// PCacheExpire expires old entries with the given key prefix.
+func (a *adapter) PCacheExpire(keyPrefix string, olderThan time.Time) error {
+ if keyPrefix == "" {
+ return t.ErrMalformed
+ }
+
+ _, err := a.db.Collection("kvmeta").DeleteMany(a.ctx, b.M{"createdat": b.M{"$lt": olderThan},
+ "_id": primitive.Regex{Pattern: "^" + keyPrefix}})
+ return err
+}
+
+// GetTestDB returns a currently open database connection.
+func (a *adapter) GetTestDB() any {
+ return a.db
+}
+
+func (a *adapter) isDbInitialized() bool {
+ var result map[string]int
+
+ findOpts := mdbopts.FindOneOptions{Projection: b.M{"value": 1, "_id": 0}}
+ if err := a.db.Collection("kvmeta").FindOne(a.ctx, b.M{"_id": "version"}, &findOpts).Decode(&result); err != nil {
+ return false
+ }
+ return true
+}
+
+// GetTestAdapter returns an adapter object. Useful for running tests.
+func GetTestAdapter() *adapter {
+ return &adapter{}
+}
+
+func init() {
+ store.RegisterAdapter(&adapter{})
+}
+
+func contains(s []string, e string) bool {
+ for _, a := range s {
+ if a == e {
+ return true
+ }
+ }
+ return false
+}
+
+func union(userTags, addTags []string) []string {
+ for _, tag := range addTags {
+ if !contains(userTags, tag) {
+ userTags = append(userTags, tag)
+ }
+ }
+ return userTags
+}
+
+func diff(userTags, removeTags []string) []string {
+ var result []string
+ for _, tag := range userTags {
+ if !contains(removeTags, tag) {
+ result = append(result, tag)
+ }
+ }
+ return result
+}
+
+// normalizeUpdateMap turns keys that hardcoded as CamelCase into lowercase (MongoDB uses lowercase by default)
+func normalizeUpdateMap(update map[string]any) map[string]any {
+ result := make(map[string]any, len(update))
+ for key, value := range update {
+ result[strings.ToLower(key)] = value
+ }
+
+ return result
+}
+
+// Recursive unmarshalling of bson.D type.
+// Mongo drivers unmarshalling into 'any' creates bson.D object for maps and bson.A object for slices.
+// We need to manually unmarshal them into correct types: map[string]any and []any respectively.
+func unmarshalBsonD(bsonObj any) any {
+ if obj, ok := bsonObj.(b.D); ok && len(obj) != 0 {
+ result := make(map[string]any)
+ for key, val := range obj.Map() {
+ result[key] = unmarshalBsonD(val)
+ }
+ return result
+ } else if obj, ok := bsonObj.(primitive.Binary); ok {
+ // primitive.Binary is a struct type with Subtype and Data fields. We need only Data ([]byte)
+ return obj.Data
+ } else if obj, ok := bsonObj.(b.A); ok {
+ // in case of array of bson.D objects
+ var result []any
+ for _, elem := range obj {
+ result = append(result, unmarshalBsonD(elem))
+ }
+ return result
+ }
+ // Just return value as is
+ return bsonObj
+}
+
+func copyBsonMap(mp b.M) b.M {
+ result := b.M{}
+ for k, v := range mp {
+ result[k] = v
+ }
+ return result
+}
+func isDuplicateErr(err error) bool {
+ if err == nil {
+ return false
+ }
+
+ msg := err.Error()
+ return strings.Contains(msg, "duplicate key error")
+}
diff --git a/server/db/mongodb/schema.md b/server/db/mongodb/schema.md
index e69de29..73bc387 100644
--- a/server/db/mongodb/schema.md
+++ b/server/db/mongodb/schema.md
@@ -0,0 +1,323 @@
+# MongoDB Database Schema
+
+## Database `tinode`
+
+### Table `users`
+Stores user accounts
+
+Fields:
+* `_id` user id, primary key
+* `createdat` timestamp when the user was created
+* `updatedat` timestamp when user metadata was updated
+* `access` user's default access level for peer-to-peer topics
+ * `auth`, `anon` default permissions for authenticated and anonymous users
+* `public` application-defined data
+* `state` account state: normal (ok), suspended, soft-deleted
+* `stateat` timestamp when the state was last updated or NULL
+* `lastseen` timestamp when the user was last online
+* `useragent` client User-Agent used when last online
+* `tags` unique strings for user discovery
+* `devices` client devices for push notifications
+ * `deviceid` device registration ID
+ * `platform` device platform string (iOS, Android, Web)
+ * `lastseen` last logged in
+ * `lang` device language, ISO code
+
+Indexes:
+ * `_id` primary key
+ * `tags` multikey-index (indexed array)
+ * `deletedat` index
+ * `deviceids` multikey-index of push notification tokens
+
+Sample:
+```json
+{
+ "access": {
+ "anon": 0 ,
+ "auth": 47
+ } ,
+ "createdat": "2019-10-11T12:13:14.522Z" ,
+ "state": 0,
+ "stateat": null ,
+ "devices": null ,
+ "_id": "7yUCHniegrM" ,
+ "lastseen": "2019-10-11T12:13:14.522Z" ,
+ "public": {
+ "fn": "Alice Johnson" ,
+ "photo": {
+ "data": Binary('/9j/4AAQSkZJRgAB...'),
+ "type": "jpg"
+ }
+ } ,
+ "state": 1 ,
+ "tags": [
+ "email:alice@example.com" ,
+ "tel:17025550001"
+ ] ,
+ "updatedat": "2019-10-11T12:13:14.522Z",
+ "useragent": "TinodeWeb/0.13 (MacIntel) tinodejs/0.13"
+}
+```
+
+### Table `auth`
+Stores authentication secrets
+
+Fields:
+* `_id` unique string which identifies this record, primary key; defined as "_authentication scheme_':'_some unique value per scheme_"
+* `userid` ID of the user who owns the record
+* `secret` shared secret, for instance bcrypt of password
+* `authLvl` authentication level
+* `expires` timestamp when the records expires
+
+
+Indexes:
+ * `_id` primary key
+ * `userid` index
+
+Sample:
+```json
+{
+ "_id": "basic:alice" ,
+ "authLvl": 20 ,
+ "expires": "2019-10-11T12:13:14.522Z" ,
+ "secret": Binary('/9j/RgAB...'),
+ "userid": "7yUCHniegrM"
+}
+```
+
+### Table `topics`
+The table stores topics.
+
+Fields:
+ * `_id` name of the topic, primary key
+ * `createdat` topic creation time
+ * `updatedat` timestamp of the last change to topic metadata
+ * `access` stores topic's default access permissions
+ * `auth`, `anon` permissions for authenticated and anonymous users respectively
+ * `owner` ID of the user who owns the topic
+ * `public` application-defined data
+ * `state` topic state: normal (ok), suspended, soft-deleted
+ * `stateat` timestamp when the state was last updated or NULL
+ * `seqid` sequential ID of the last message
+ * `delid` topic-sequential ID of the deletion operation
+ * `usebt` currently unused
+
+Indexes:
+* `_id` primary key
+* `owner` index
+* `tags` multikey index
+
+Sample:
+```json
+{
+ "access": {
+ "anon": 64 ,
+ "auth": 64
+ } ,
+ "delid": 0,
+ "createdat": "2019-10-11T12:13:14.522Z",
+ "lastmessageat": "2019-10-11T12:13:14.522Z" ,
+ "id": "p2pavVGHLCBbKrvJQIeeJ6Csw" ,
+ "owner": "v2JyG4OLSoA" ,
+ "public": {
+ "fn": "Travel, travel, travel" ,
+ "photo": {
+ "data": Binary('/9j/RgAB...') ,
+ "type": "jpg"
+ }
+ } ,
+ "seqid": 14,
+ "state": 0,
+ "stateat": null,
+ "updatedat": "2019-10-11T12:13:14.522Z" ,
+ "usebt": false
+}
+```
+
+### Table `subscriptions`
+The table stores relationships between users and topics.
+
+Fields:
+ * `_id` used for object retrieval
+ * `createdat` timestamp when the user was created
+ * `updatedat` timestamp when user metadata was updated
+ * `deletedat` currently unused
+ * `readseqid` id of the message last read by the user
+ * `recvseqid` id of the message last received by user device
+ * `delid` topic-sequential ID of the soft-deletion operation
+ * `topic` name of the topic subscribed to
+ * `user` subscriber's user ID
+ * `modewant` access mode that user wants when accessing the topic
+ * `modegiven` access mode granted to user by the topic
+ * `private` application-defined data, accessible by the user only
+
+Indexes:
+ * `_id` primary key composed as "_topic name_':'_user ID_"
+ * `user` index
+ * `topic` index
+
+Sample:
+```json
+{
+ "_id": "grpjajVKrHn0PU:v2JyG4OLSoA" ,
+ "createdat": "2019-10-11T12:13:14.522Z" ,
+ "updatedat": "2019-10-11T12:13:14.522Z" ,
+ "deletedat": null ,
+ "user": "v2JyG4OLSoA",
+ "topic": "grpjajVKrHn0PU" ,
+ "recvseqid": 0 ,
+ "readseqid": 0 ,
+ "modewant": 47 ,
+ "modegiven": 47 ,
+ "private": "Kirgudu" ,
+ "state": 0
+}
+```
+
+### Table `messages`
+The table stores `{data}` messages
+
+Fields:
+* `_id` currently unused, primary key
+* `createdat` timestamp when the message was created
+* `updatedat` initially equal to CreatedAt, for deleted messages equal to DeletedAt
+* `deletedfor` array of user IDs which soft-deleted the message
+ * `delid` topic-sequential ID of the soft-deletion operation
+ * `user` ID of the user who soft-deleted the message
+* `from` ID of the user who generated this message
+* `topic` which received this message
+* `seqid` messages ID - sequential number of the message in the topic
+* `head` message headers
+* `attachments` denormalized IDs of files attached to the message
+* `content` application-defined message payload
+
+Indexes:
+ * `_id` primary key
+
+Sample:
+```json
+{
+ "_id": "LLXKEe9W4Bs" ,
+ "createdat": "2019-10-11T12:13:14.522Z" ,
+ "updatedat": "2019-10-11T12:13:14.522Z",
+ "deletedfor": [
+ {
+ "delid": 1 ,
+ "user": "wTI0jO9rEqY"
+ }
+ ] ,
+ "seqid": 3 ,
+ "topic": "p2pJhbJnya8z5PBMjSM72sSpg",
+ "from": "wTI0jO9rEqY" ,
+ "head": {
+ "mime": "text/x-drafty"
+ } ,
+ "content": {
+ "fmt": [
+ {
+ "len": 6 ,
+ "tp": "ST"
+ }
+ ] ,
+ "txt": "Hello!"
+ }
+}
+```
+
+### Table `dellog`
+The table stores records of message deletions
+
+Fields:
+* `_id` currently unused, primary key
+* `createdat` timestamp when the record was created
+* `updatedat` timestamp equal to CreatedAt
+* `delid` topic-sequential ID of the deletion operation.
+* `deletedfor` ID of the user for soft-deletions, blank string for hard-deletions
+* `topic` affected topic
+* `seqidranges` array of ranges of deleted message IDs (see `messages.seqid`)
+
+Indexes:
+ * `_id` primary key
+* `topic_delid` compound index `["Topic", "DelId"]`
+
+Sample:
+```json
+{
+ "_id": "9LfrjW349Rc",
+ "createdat": "2019-10-11T12:13:14.522Z",
+ "updatedat": "2019-10-11T12:13:14.522Z",
+ "topic": "grpGx7fpjQwVC0",
+ "delid": 18,
+ "deletedfor": "xY-YHx09-WI",
+ "seqidranges": [
+ {
+ "low": 20,
+ "hi": 25
+ }
+ ]
+}
+```
+
+### Table `credentials`
+The tables stores user credentials used for validation.
+
+* `_id` credential, primary key
+* `createdat` timestamp when the record was created
+* `updatedat` timestamp when the last validation attempt was performed (successful or not).
+* `method` validation method
+* `done` indicator if the credential is validated
+* `resp` expected validation response
+* `retries` number of failed attempts at validation
+* `user` id of the user who owns this credential
+* `value` value of the credential
+
+Indexes:
+* `_id` Primary key composed either as `user`:`method`:`value` for unconfirmed credentials or as `method`:`value` for confirmed.
+* `user` Index
+
+Sample:
+```json
+{
+ "Id": "tel:+17025550001",
+ "CreatedAt": "2019-10-11T12:13:14.522Z",
+ "UpdatedAt": "2019-10-11T12:13:14.522Z",
+ "Method": "tel" ,
+ "Done": true ,
+ "Resp": "123456" ,
+ "Retries": 0 ,
+ "User": "k3srBRk9RYw" ,
+ "Value": "+17025550001"
+}
+```
+
+### Table `fileuploads`
+The table stores records of uploaded files. The files themselves are stored outside of the database.
+* `_id` unique user-visible file name, primary key
+* `createdat` timestamp when the record was created
+* `updatedat` timestamp of when th upload has cmpleted or failed
+* `user` id of the user who uploaded this file.
+* `location` actual location of the file on the server.
+* `mimetype` file content type as a [Mime](https://en.wikipedia.org/wiki/MIME) string.
+* `size` size of the file in bytes. Could be 0 if upload has not completed yet.
+* `usecount` count of messages referencing this file.
+* `status` upload status: 0 pending, 1 completed, -1 failed.
+
+Indexes:
+ * `_id` file name, primary key
+ * `user` index
+ * `usecount` index
+
+Sample:
+```json
+{
+ "_id": "sFmjlQ_kA6A" ,
+ "createdat": "2019-10-11T12:13:14.522Z" ,
+ "updatedat": "2019-10-11T12:13:14.522Z" ,
+ "location": "uploads/sFmjlQ_kA6A" ,
+ "mimetype": "image/jpeg" ,
+ "size": 54961090 ,
+ "usecount": 3,
+ "status": 1 ,
+ "user": "7j-RR1V7O3Y"
+}
+```
\ No newline at end of file
diff --git a/server/db/mongodb/tests/mongo_test.go b/server/db/mongodb/tests/mongo_test.go
index e69de29..84045c0 100644
--- a/server/db/mongodb/tests/mongo_test.go
+++ b/server/db/mongodb/tests/mongo_test.go
@@ -0,0 +1,1282 @@
+// To test another db backend:
+// 1) Create GetAdapter function inside your db backend adapter package (like one inside mongodb adapter)
+// 2) Uncomment your db backend package ('backend' named package)
+// 3) Write own initConnectionToDb and 'db' variable
+// 4) Replace mongodb specific db queries inside test to your own queries.
+// 5) Run.
+
+package tests
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "log"
+ "os"
+ "reflect"
+ "testing"
+ "time"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/google/go-cmp/cmp/cmpopts"
+ adapter "github.com/tinode/chat/server/db"
+ jcr "github.com/tinode/jsonco"
+ b "go.mongodb.org/mongo-driver/bson"
+ mdb "go.mongodb.org/mongo-driver/mongo"
+ mdbopts "go.mongodb.org/mongo-driver/mongo/options"
+
+ "github.com/tinode/chat/server/db/common"
+ "github.com/tinode/chat/server/db/common/test_data"
+ backend "github.com/tinode/chat/server/db/mongodb"
+ "github.com/tinode/chat/server/logs"
+ "github.com/tinode/chat/server/store/types"
+)
+
+type configType struct {
+ // If Reset=true test will recreate database every time it runs
+ Reset bool `json:"reset_db_data"`
+ // Configurations for individual adapters.
+ Adapters map[string]json.RawMessage `json:"adapters"`
+}
+
+var config configType
+var adp adapter.Adapter
+var db *mdb.Database
+var ctx context.Context
+var testData *test_data.TestData
+
+func TestCreateDb(t *testing.T) {
+ if err := adp.CreateDb(config.Reset); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// ================== Create tests ================================
+func TestUserCreate(t *testing.T) {
+ for _, user := range testData.Users {
+ if err := adp.UserCreate(user); err != nil {
+ t.Error(err)
+ }
+ }
+ count, err := db.Collection("users").CountDocuments(ctx, b.M{})
+ if err != nil {
+ t.Error(err)
+ }
+ if count == 0 {
+ t.Error("No users created!")
+ }
+}
+
+func TestCredUpsert(t *testing.T) {
+ // Test just inserts:
+ for i := 0; i < 2; i++ {
+ inserted, err := adp.CredUpsert(testData.Creds[i])
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !inserted {
+ t.Error("Should be inserted, but updated")
+ }
+ }
+
+ // Test duplicate:
+ _, err := adp.CredUpsert(testData.Creds[1])
+ if err != types.ErrDuplicate {
+ t.Error("Should return duplicate error but got", err)
+ }
+ _, err = adp.CredUpsert(testData.Creds[2])
+ if err != types.ErrDuplicate {
+ t.Error("Should return duplicate error but got", err)
+ }
+
+ // Test add new unvalidated credentials
+ inserted, err := adp.CredUpsert(testData.Creds[3])
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !inserted {
+ t.Error("Should be inserted, but updated")
+ }
+ inserted, err = adp.CredUpsert(testData.Creds[3])
+ if err != nil {
+ t.Fatal(err)
+ }
+ if inserted {
+ t.Error("Should be updated, but inserted")
+ }
+
+ // Just insert other creds (used in other tests)
+ for _, cred := range testData.Creds[4:] {
+ _, err = adp.CredUpsert(cred)
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+}
+
+func TestAuthAddRecord(t *testing.T) {
+ for _, rec := range testData.Recs {
+ err := adp.AuthAddRecord(types.ParseUserId("usr"+rec.UserId), rec.Scheme, rec.Unique,
+ rec.AuthLvl, rec.Secret, rec.Expires)
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ //Test duplicate
+ err := adp.AuthAddRecord(types.ParseUserId("usr"+testData.Users[0].Id), testData.Recs[0].Scheme,
+ testData.Recs[0].Unique, testData.Recs[0].AuthLvl, testData.Recs[0].Secret, testData.Recs[0].Expires)
+ if err != types.ErrDuplicate {
+ t.Fatal("Should be duplicate error but got", err)
+ }
+}
+
+func TestTopicCreate(t *testing.T) {
+ err := adp.TopicCreate(testData.Topics[0])
+ if err != nil {
+ t.Error(err)
+ }
+ for _, tpc := range testData.Topics[3:] {
+ err = adp.TopicCreate(tpc)
+ if err != nil {
+ t.Error(err)
+ }
+ }
+}
+
+func TestTopicCreateP2P(t *testing.T) {
+ err := adp.TopicCreateP2P(testData.Subs[2], testData.Subs[3])
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ oldModeGiven := testData.Subs[2].ModeGiven
+ testData.Subs[2].ModeGiven = 255
+ err = adp.TopicCreateP2P(testData.Subs[4], testData.Subs[2])
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got types.Subscription
+ err = db.Collection("subscriptions").FindOne(ctx, b.M{"_id": testData.Subs[2].Id}).Decode(&got)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.ModeGiven == oldModeGiven {
+ t.Error("ModeGiven update failed")
+ }
+}
+
+func TestTopicShare(t *testing.T) {
+ if err := adp.TopicShare(testData.Subs[0].Topic, testData.Subs); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestMessageSave(t *testing.T) {
+ for _, msg := range testData.Msgs {
+ err := adp.MessageSave(msg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ // Some messages are soft deleted, but it's ignored by adp.MessageSave
+ for _, msg := range testData.Msgs {
+ if len(msg.DeletedFor) > 0 {
+ for _, del := range msg.DeletedFor {
+ toDel := types.DelMessage{
+ Topic: msg.Topic,
+ DeletedFor: del.User,
+ DelId: del.DelId,
+ SeqIdRanges: []types.Range{{Low: msg.SeqId}},
+ }
+ adp.MessageDeleteList(msg.Topic, &toDel)
+ }
+ }
+ }
+}
+
+func TestFileStartUpload(t *testing.T) {
+ for _, f := range testData.Files {
+ err := adp.FileStartUpload(f)
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+}
+
+// ================== Read tests ==================================
+func TestUserGet(t *testing.T) {
+ // Test not found
+ got, err := adp.UserGet(types.ParseUserId("dummyuserid"))
+ if err == nil && got != nil {
+ t.Error("user should be nil.")
+ }
+
+ got, err = adp.UserGet(types.ParseUserId("usr" + testData.Users[0].Id))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(got, testData.Users[0]) {
+ t.Error(mismatchErrorString("User", got, testData.Users[0]))
+ }
+}
+
+func TestUserGetAll(t *testing.T) {
+ // Test not found
+ got, err := adp.UserGetAll(types.ParseUserId("dummyuserid"), types.ParseUserId("otherdummyid"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != nil {
+ t.Error("result users should be nil.")
+ }
+
+ got, err = adp.UserGetAll(types.ParseUserId("usr"+testData.Users[0].Id), types.ParseUserId("usr"+testData.Users[1].Id))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 2 {
+ t.Fatal(mismatchErrorString("resultUsers length", len(got), 2))
+ }
+ for i, usr := range got {
+ if !reflect.DeepEqual(&usr, testData.Users[i]) {
+ t.Error(mismatchErrorString("User", &usr, testData.Users[i]))
+ }
+ }
+}
+
+func TestUserGetByCred(t *testing.T) {
+ // Test not found
+ got, err := adp.UserGetByCred("foo", "bar")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != types.ZeroUid {
+ t.Error("result uid should be ZeroUid")
+ }
+
+ got, _ = adp.UserGetByCred(testData.Creds[0].Method, testData.Creds[0].Value)
+ if got != types.ParseUserId("usr"+testData.Creds[0].User) {
+ t.Error(mismatchErrorString("Uid", got, types.ParseUserId("usr"+testData.Creds[0].User)))
+ }
+}
+
+func TestCredGetActive(t *testing.T) {
+ got, err := adp.CredGetActive(types.ParseUserId("usr"+testData.Users[2].Id), "tel")
+ if err != nil {
+ t.Error(err)
+ }
+ if !reflect.DeepEqual(got, testData.Creds[3]) {
+ t.Error(mismatchErrorString("Credential", got, testData.Creds[3]))
+ }
+
+ // Test not found
+ got, err = adp.CredGetActive(types.ParseUserId("dummyusrid"), "")
+ if err != nil {
+ t.Error(err)
+ }
+ if got != nil {
+ t.Error("result should be nil, got", got)
+ }
+}
+
+func TestCredGetAll(t *testing.T) {
+ got, err := adp.CredGetAll(types.ParseUserId("usr"+testData.Users[2].Id), "", false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 3 {
+ t.Error(mismatchErrorString("Credentials length", len(got), 3))
+ }
+
+ got, _ = adp.CredGetAll(types.ParseUserId("usr"+testData.Users[2].Id), "tel", false)
+ if len(got) != 2 {
+ t.Error(mismatchErrorString("Credentials length", len(got), 2))
+ }
+
+ got, _ = adp.CredGetAll(types.ParseUserId("usr"+testData.Users[2].Id), "", true)
+ if len(got) != 1 {
+ t.Error(mismatchErrorString("Credentials length", len(got), 1))
+ }
+
+ got, _ = adp.CredGetAll(types.ParseUserId("usr"+testData.Users[2].Id), "tel", true)
+ if len(got) != 1 {
+ t.Error(mismatchErrorString("Credentials length", len(got), 1))
+ }
+}
+
+func TestAuthGetUniqueRecord(t *testing.T) {
+ uid, authLvl, secret, expires, err := adp.AuthGetUniqueRecord("basic:alice")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if uid != types.ParseUserId("usr"+testData.Recs[0].UserId) ||
+ authLvl != testData.Recs[0].AuthLvl ||
+ !bytes.Equal(secret, testData.Recs[0].Secret) ||
+ expires != testData.Recs[0].Expires {
+
+ got := fmt.Sprintf("%v %v %v %v", uid, authLvl, secret, expires)
+ want := fmt.Sprintf("%v %v %v %v", testData.Recs[0].UserId, testData.Recs[0].AuthLvl,
+ testData.Recs[0].Secret, testData.Recs[0].Expires)
+ t.Error(mismatchErrorString("Auth record", got, want))
+ }
+
+ // Test not found
+ uid, _, _, _, err = adp.AuthGetUniqueRecord("qwert:asdfg")
+ if err == nil && !uid.IsZero() {
+ t.Error("Auth record found but shouldn't. Uid:", uid.String())
+ }
+}
+
+func TestAuthGetRecord(t *testing.T) {
+ recId, authLvl, secret, expires, err := adp.AuthGetRecord(types.ParseUserId("usr"+testData.Recs[0].UserId), "basic")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if recId != testData.Recs[0].Unique ||
+ authLvl != testData.Recs[0].AuthLvl ||
+ !bytes.Equal(secret, testData.Recs[0].Secret) ||
+ expires != testData.Recs[0].Expires {
+
+ got := fmt.Sprintf("%v %v %v %v", recId, authLvl, secret, expires)
+ want := fmt.Sprintf("%v %v %v %v", testData.Recs[0].Unique, testData.Recs[0].AuthLvl,
+ testData.Recs[0].Secret, testData.Recs[0].Expires)
+ t.Error(mismatchErrorString("Auth record", got, want))
+ }
+
+ // Test not found
+ recId, _, _, _, err = adp.AuthGetRecord(types.ParseUserId("dummyuserid"), "scheme")
+ if err != types.ErrNotFound {
+ t.Error("Auth record found but shouldn't. recId:", recId)
+ }
+}
+
+func TestTopicGet(t *testing.T) {
+ got, err := adp.TopicGet(testData.Topics[0].Id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(got, testData.Topics[0]) {
+ t.Error(mismatchErrorString("Topic", got, testData.Topics[0]))
+ }
+ // Test not found
+ got, err = adp.TopicGet("asdfasdfasdf")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != nil {
+ t.Error("Topic should be nil but got:", got)
+ }
+}
+
+func TestTopicsForUser(t *testing.T) {
+ qOpts := types.QueryOpt{
+ Topic: testData.Topics[1].Id,
+ Limit: 999,
+ }
+ gotSubs, err := adp.TopicsForUser(types.ParseUserId("usr"+testData.Users[0].Id), false, &qOpts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(gotSubs) != 1 {
+ t.Error(mismatchErrorString("Subs length", len(gotSubs), 1))
+ }
+
+ gotSubs, err = adp.TopicsForUser(types.ParseUserId("usr"+testData.Users[1].Id), true, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(gotSubs) != 2 {
+ t.Error(mismatchErrorString("Subs length (2)", len(gotSubs), 2))
+ }
+
+ qOpts.Topic = ""
+ ims := testData.Now.Add(15 * time.Minute)
+ qOpts.IfModifiedSince = &ims
+ gotSubs, err = adp.TopicsForUser(types.ParseUserId("usr"+testData.Users[0].Id), false, &qOpts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(gotSubs) != 1 {
+ t.Error(mismatchErrorString("Subs length (IMS)", len(gotSubs), 1))
+ }
+
+ // time.Now() is correct (as opposite to testData.Now)
+ // Topic is modified using time.Now().
+ ims = time.Now().Add(15 * time.Minute)
+ gotSubs, err = adp.TopicsForUser(types.ParseUserId("usr"+testData.Users[0].Id), false, &qOpts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(gotSubs) != 0 {
+ t.Error(mismatchErrorString("Subs length (IMS 2)", len(gotSubs), 0))
+ }
+}
+
+func TestUsersForTopic(t *testing.T) {
+ qOpts := types.QueryOpt{
+ User: types.ParseUserId("usr" + testData.Users[0].Id),
+ Limit: 999,
+ }
+ gotSubs, err := adp.UsersForTopic(testData.Topics[0].Id, false, &qOpts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(gotSubs) != 1 {
+ t.Error(mismatchErrorString("Subs length", len(gotSubs), 1))
+ }
+
+ gotSubs, err = adp.UsersForTopic(testData.Topics[0].Id, true, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(gotSubs) != 2 {
+ t.Error(mismatchErrorString("Subs length", len(gotSubs), 2))
+ }
+
+ gotSubs, err = adp.UsersForTopic(testData.Topics[1].Id, false, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(gotSubs) != 2 {
+ t.Error(mismatchErrorString("Subs length", len(gotSubs), 2))
+ }
+}
+
+func TestOwnTopics(t *testing.T) {
+ gotSubs, err := adp.OwnTopics(types.ParseUserId("usr" + testData.Users[0].Id))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(gotSubs) != 1 {
+ t.Fatalf("Got topic length %v instead of %v", len(gotSubs), 1)
+ }
+ if gotSubs[0] != testData.Topics[0].Id {
+ t.Errorf("Got topic %v instead of %v", gotSubs[0], testData.Topics[0].Id)
+ }
+}
+
+func TestSubscriptionGet(t *testing.T) {
+ got, err := adp.SubscriptionGet(testData.Topics[0].Id, types.ParseUserId("usr"+testData.Users[0].Id), false)
+ if err != nil {
+ t.Error(err)
+ }
+ opts := cmpopts.IgnoreUnexported(types.Subscription{}, types.ObjHeader{})
+ if !cmp.Equal(got, testData.Subs[0], opts) {
+ t.Error(mismatchErrorString("Subs", got, testData.Subs[0]))
+ }
+ // Test not found
+ got, err = adp.SubscriptionGet("dummytopic", types.ParseUserId("dummyuserid"), false)
+ if err != nil {
+ t.Error(err)
+ }
+ if got != nil {
+ t.Error("result sub should be nil.")
+ }
+}
+
+func TestSubsForUser(t *testing.T) {
+ gotSubs, err := adp.SubsForUser(types.ParseUserId("usr" + testData.Users[0].Id))
+ if err != nil {
+ t.Error(err)
+ }
+ if len(gotSubs) != 2 {
+ t.Error(mismatchErrorString("Subs length", len(gotSubs), 1))
+ }
+
+ // Test not found
+ gotSubs, err = adp.SubsForUser(types.ParseUserId("usr12345678"))
+ if err != nil {
+ t.Error(err)
+ }
+ if len(gotSubs) != 0 {
+ t.Error(mismatchErrorString("Subs length", len(gotSubs), 0))
+ }
+}
+
+func TestSubsForTopic(t *testing.T) {
+ qOpts := types.QueryOpt{
+ User: types.ParseUserId("usr" + testData.Users[0].Id),
+ Limit: 999,
+ }
+ gotSubs, err := adp.SubsForTopic(testData.Topics[0].Id, false, &qOpts)
+ if err != nil {
+ t.Error(err)
+ }
+ if len(gotSubs) != 1 {
+ t.Error(mismatchErrorString("Subs length", len(gotSubs), 1))
+ }
+ // Test not found
+ gotSubs, err = adp.SubsForTopic("dummytopicid", false, nil)
+ if err != nil {
+ t.Error(err)
+ }
+ if len(gotSubs) != 0 {
+ t.Error(mismatchErrorString("Subs length", len(gotSubs), 0))
+ }
+}
+
+func TestFind(t *testing.T) {
+ reqTags := [][]string{{"alice", "bob", "carol", "travel", "qwer", "asdf", "zxcv"}}
+ gotSubs, err := adp.Find("usr"+testData.Users[2].Id, "", reqTags, nil, true)
+ if err != nil {
+ t.Error(err)
+ }
+ if len(gotSubs) != 3 {
+ t.Error(mismatchErrorString("result length", len(gotSubs), 3))
+ }
+}
+
+func TestMessageGetAll(t *testing.T) {
+ opts := types.QueryOpt{
+ Since: 1,
+ Before: 2,
+ Limit: 999,
+ }
+ gotMsgs, err := adp.MessageGetAll(testData.Topics[0].Id,
+ types.ParseUserId("usr"+testData.Users[0].Id), &opts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(gotMsgs) != 1 {
+ t.Error(mismatchErrorString("Messages length opts", len(gotMsgs), 1))
+ }
+ gotMsgs, _ = adp.MessageGetAll(testData.Topics[0].Id, types.ParseUserId("usr"+testData.Users[0].Id), nil)
+ if len(gotMsgs) != 2 {
+ t.Error(mismatchErrorString("Messages length no opts", len(gotMsgs), 2))
+ }
+ gotMsgs, _ = adp.MessageGetAll(testData.Topics[0].Id, types.ZeroUid, nil)
+ if len(gotMsgs) != 3 {
+ t.Error(mismatchErrorString("Messages length zero uid", len(gotMsgs), 3))
+ }
+}
+
+func TestFileGet(t *testing.T) {
+ // General test done during TestFileFinishUpload().
+
+ // Test not found
+ got, err := adp.FileGet("dummyfileid")
+ if err != nil {
+ if got != nil {
+ t.Error("File found but shouldn't:", got)
+ }
+ }
+}
+
+// ================== Update tests ================================
+func TestUserUpdate(t *testing.T) {
+ update := map[string]any{
+ "UserAgent": "Test Agent v0.11",
+ "UpdatedAt": testData.Now.Add(30 * time.Minute),
+ }
+ err := adp.UserUpdate(types.ParseUserId("usr"+testData.Users[0].Id), update)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var got types.User
+ err = db.Collection("users").FindOne(ctx, b.M{"_id": testData.Users[0].Id}).Decode(&got)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.UserAgent != "Test Agent v0.11" {
+ t.Error(mismatchErrorString("UserAgent", got.UserAgent, "Test Agent v0.11"))
+ }
+ if got.UpdatedAt.Equal(got.CreatedAt) {
+ t.Error("UpdatedAt field not updated")
+ }
+}
+
+func TestUserUpdateTags(t *testing.T) {
+ addTags := testData.Tags[0]
+ removeTags := testData.Tags[1]
+ resetTags := testData.Tags[2]
+ got, err := adp.UserUpdateTags(types.ParseUserId("usr"+testData.Users[0].Id), addTags, nil, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := []string{"alice", "tag1"}
+ if !reflect.DeepEqual(got, want) {
+ t.Error(mismatchErrorString("Tags", got, want))
+
+ }
+ got, _ = adp.UserUpdateTags(types.ParseUserId("usr"+testData.Users[0].Id), nil, removeTags, nil)
+ want = nil
+ if !reflect.DeepEqual(got, want) {
+ t.Error(mismatchErrorString("Tags", got, want))
+
+ }
+ got, _ = adp.UserUpdateTags(types.ParseUserId("usr"+testData.Users[0].Id), nil, nil, resetTags)
+ want = []string{"alice", "tag111", "tag333"}
+ if !reflect.DeepEqual(got, want) {
+ t.Error(mismatchErrorString("Tags", got, want))
+
+ }
+ got, _ = adp.UserUpdateTags(types.ParseUserId("usr"+testData.Users[0].Id), addTags, removeTags, nil)
+ want = []string{"tag111", "tag333"}
+ if !reflect.DeepEqual(got, want) {
+ t.Error(mismatchErrorString("Tags", got, want))
+
+ }
+ got, _ = adp.UserUpdateTags(types.ParseUserId("usr"+testData.Users[0].Id), addTags, removeTags, nil)
+ want = []string{"tag111", "tag333"}
+ if !reflect.DeepEqual(got, want) {
+ t.Error(mismatchErrorString("Tags", got, want))
+ }
+}
+
+func TestCredFail(t *testing.T) {
+ err := adp.CredFail(types.ParseUserId("usr"+testData.Creds[3].User), "tel")
+ if err != nil {
+ t.Error(err)
+ }
+
+ // Check if fields updated
+ var got types.Credential
+ _ = db.Collection("credentials").FindOne(ctx, b.M{
+ "user": testData.Creds[3].User,
+ "method": "tel",
+ "value": testData.Creds[3].Value}).Decode(&got)
+ if got.Retries != 1 {
+ t.Error(mismatchErrorString("Retries count", got.Retries, 1))
+ }
+ if got.UpdatedAt.Equal(got.CreatedAt) {
+ t.Error("UpdatedAt field not updated")
+ }
+}
+
+func TestCredConfirm(t *testing.T) {
+ err := adp.CredConfirm(types.ParseUserId("usr"+testData.Creds[3].User), "tel")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Test fields are updated
+ var got types.Credential
+ err = db.Collection("credentials").FindOne(ctx, b.M{
+ "user": testData.Creds[3].User,
+ "method": "tel",
+ "value": testData.Creds[3].Value}).Decode(&got)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.UpdatedAt.Equal(got.CreatedAt) {
+ t.Error("Credential not updated correctly")
+ }
+ // and uncomfirmed credential deleted
+ err = db.Collection("credentials").FindOne(ctx, b.M{"_id": testData.Creds[3].User + ":" + got.Method + ":" + got.Value}).Decode(&got)
+ if err != mdb.ErrNoDocuments {
+ t.Error("Uncomfirmed credential not deleted")
+ }
+}
+
+func TestAuthUpdRecord(t *testing.T) {
+ rec := testData.Recs[1]
+ newSecret := []byte{'s', 'e', 'c', 'r', 'e', 't'}
+ err := adp.AuthUpdRecord(types.ParseUserId("usr"+rec.UserId), rec.Scheme, rec.Unique,
+ rec.AuthLvl, newSecret, rec.Expires)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got common.AuthRecord
+ err = db.Collection("auth").FindOne(ctx, b.M{"_id": rec.Unique}).Decode(&got)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if bytes.Equal(got.Secret, rec.Secret) {
+ t.Error(mismatchErrorString("Secret", got.Secret, rec.Secret))
+ }
+
+ // Test with auth ID (unique) change
+ newId := "basic:bob12345"
+ err = adp.AuthUpdRecord(types.ParseUserId("usr"+rec.UserId), rec.Scheme, newId,
+ rec.AuthLvl, newSecret, rec.Expires)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Test if old ID deleted
+ err = db.Collection("auth").FindOne(ctx, b.M{"_id": rec.Unique}).Decode(&got)
+ if err == nil || err != mdb.ErrNoDocuments {
+ t.Errorf("Unique not changed. Got error: %v; ID: %v", err, got.Unique)
+ }
+ if bytes.Equal(got.Secret, rec.Secret) {
+ t.Error(mismatchErrorString("Secret", got.Secret, rec.Secret))
+ }
+ if bytes.Equal(got.Secret, rec.Secret) {
+ t.Error(mismatchErrorString("Secret", got.Secret, rec.Secret))
+ }
+}
+
+func TestTopicUpdateOnMessage(t *testing.T) {
+ msg := types.Message{
+ ObjHeader: types.ObjHeader{
+ CreatedAt: testData.Now.Add(33 * time.Minute),
+ },
+ SeqId: 66,
+ }
+ err := adp.TopicUpdateOnMessage(testData.Topics[2].Id, &msg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got types.Topic
+ err = db.Collection("topics").FindOne(ctx, b.M{"_id": testData.Topics[2].Id}).Decode(&got)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.TouchedAt != msg.CreatedAt || got.SeqId != msg.SeqId {
+ t.Error(mismatchErrorString("TouchedAt", got.TouchedAt, msg.CreatedAt))
+ t.Error(mismatchErrorString("SeqId", got.SeqId, msg.SeqId))
+ }
+}
+
+func TestTopicUpdate(t *testing.T) {
+ update := map[string]any{
+ "UpdatedAt": testData.Now.Add(55 * time.Minute),
+ }
+ err := adp.TopicUpdate(testData.Topics[0].Id, update)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got types.Topic
+ _ = db.Collection("topics").FindOne(ctx, b.M{"_id": testData.Topics[0].Id}).Decode(&got)
+ if got.UpdatedAt != update["UpdatedAt"] {
+ t.Error(mismatchErrorString("UpdatedAt", got.UpdatedAt, update["UpdatedAt"]))
+ }
+}
+
+func TestTopicOwnerChange(t *testing.T) {
+ err := adp.TopicOwnerChange(testData.Topics[0].Id, types.ParseUserId("usr"+testData.Users[1].Id))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got types.Topic
+ _ = db.Collection("topics").FindOne(ctx, b.M{"_id": testData.Topics[0].Id}).Decode(&got)
+ if got.Owner != testData.Users[1].Id {
+ t.Error(mismatchErrorString("Owner", got.Owner, testData.Users[1].Id))
+ }
+}
+
+func TestSubsUpdate(t *testing.T) {
+ update := map[string]any{
+ "UpdatedAt": testData.Now.Add(22 * time.Minute),
+ }
+ err := adp.SubsUpdate(testData.Topics[0].Id, types.ParseUserId("usr"+testData.Users[0].Id), update)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got types.Subscription
+ _ = db.Collection("subscriptions").FindOne(ctx, b.M{"_id": testData.Topics[0].Id + ":" + testData.Users[0].Id}).Decode(&got)
+ if got.UpdatedAt != update["UpdatedAt"] {
+ t.Error(mismatchErrorString("UpdatedAt", got.UpdatedAt, update["UpdatedAt"]))
+ }
+
+ err = adp.SubsUpdate(testData.Topics[1].Id, types.ZeroUid, update)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _ = db.Collection("subscriptions").FindOne(ctx, b.M{"topic": testData.Topics[1].Id}).Decode(&got)
+ if got.UpdatedAt != update["UpdatedAt"] {
+ t.Error(mismatchErrorString("UpdatedAt", got.UpdatedAt, update["UpdatedAt"]))
+ }
+}
+
+func TestSubsDelete(t *testing.T) {
+ err := adp.SubsDelete(testData.Topics[1].Id, types.ParseUserId("usr"+testData.Users[0].Id))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got types.Subscription
+ _ = db.Collection("subscriptions").FindOne(ctx, b.M{"_id": testData.Topics[1].Id + ":" + testData.Users[0].Id}).Decode(&got)
+ if got.DeletedAt == nil {
+ t.Error(mismatchErrorString("DeletedAt", got.DeletedAt, nil))
+ }
+}
+
+func TestDeviceUpsert(t *testing.T) {
+ err := adp.DeviceUpsert(types.ParseUserId("usr"+testData.Users[0].Id), testData.Devs[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got types.User
+ err = db.Collection("users").FindOne(ctx, b.M{"_id": testData.Users[0].Id}).Decode(&got)
+ if err != nil {
+ t.Error(err)
+ }
+ if !reflect.DeepEqual(got.DeviceArray[0], testData.Devs[0]) {
+ t.Error(mismatchErrorString("Device", got.DeviceArray[0], testData.Devs[0]))
+ }
+ // Test update
+ testData.Devs[0].Platform = "Web"
+ err = adp.DeviceUpsert(types.ParseUserId("usr"+testData.Users[0].Id), testData.Devs[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = db.Collection("users").FindOne(ctx, b.M{"_id": testData.Users[0].Id}).Decode(&got)
+ if err != nil {
+ t.Error(err)
+ }
+ if got.DeviceArray[0].Platform != "Web" {
+ t.Error("Device not updated.", got.DeviceArray[0])
+ }
+ // Test add same device to another user
+ err = adp.DeviceUpsert(types.ParseUserId("usr"+testData.Users[1].Id), testData.Devs[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = db.Collection("users").FindOne(ctx, b.M{"_id": testData.Users[1].Id}).Decode(&got)
+ if err != nil {
+ t.Error(err)
+ }
+ if got.DeviceArray[0].Platform != "Web" {
+ t.Error("Device not updated.", got.DeviceArray[0])
+ }
+
+ err = adp.DeviceUpsert(types.ParseUserId("usr"+testData.Users[2].Id), testData.Devs[1])
+ if err != nil {
+ t.Error(err)
+ }
+}
+
+func TestMessageAttachments(t *testing.T) {
+ fids := []string{testData.Files[0].Id, testData.Files[1].Id}
+ err := adp.FileLinkAttachments("", types.ZeroUid, types.ParseUid(testData.Msgs[1].Id), fids)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got map[string][]string
+ findOpts := mdbopts.FindOne().SetProjection(b.M{"attachments": 1, "_id": 0})
+ err = db.Collection("messages").FindOne(ctx, b.M{"_id": testData.Msgs[1].Id}, findOpts).Decode(&got)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(got["attachments"], fids) {
+ t.Error(mismatchErrorString("Attachments", got["attachments"], fids))
+ }
+ var got2 map[string]int
+ findOpts = mdbopts.FindOne().SetProjection(b.M{"usecount": 1, "_id": 0})
+ err = db.Collection("fileuploads").FindOne(ctx, b.M{"_id": testData.Files[0].Id}, findOpts).Decode(&got2)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got2["usecount"] != 1 {
+ t.Error(mismatchErrorString("UseCount", got2["usecount"], 1))
+ }
+}
+
+func TestFileFinishUpload(t *testing.T) {
+ got, err := adp.FileFinishUpload(testData.Files[0], true, 22222)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Status != types.UploadCompleted {
+ t.Error(mismatchErrorString("Status", got.Status, types.UploadCompleted))
+ }
+ if got.Size != 22222 {
+ t.Error(mismatchErrorString("Size", got.Size, 22222))
+ }
+}
+
+// ================== Other tests =================================
+func TestDeviceGetAll(t *testing.T) {
+ uid0 := types.ParseUserId("usr" + testData.Users[0].Id)
+ uid1 := types.ParseUserId("usr" + testData.Users[1].Id)
+ uid2 := types.ParseUserId("usr" + testData.Users[2].Id)
+ gotDevs, count, err := adp.DeviceGetAll(uid0, uid1, uid2)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if count != 2 {
+ t.Fatal(mismatchErrorString("count", count, 2))
+ }
+ if !reflect.DeepEqual(gotDevs[uid1][0], *testData.Devs[0]) {
+ t.Error(mismatchErrorString("Device", gotDevs[uid1][0], *testData.Devs[0]))
+ }
+ if !reflect.DeepEqual(gotDevs[uid2][0], *testData.Devs[1]) {
+ t.Error(mismatchErrorString("Device", gotDevs[uid2][0], *testData.Devs[1]))
+ }
+}
+
+func TestDeviceDelete(t *testing.T) {
+ err := adp.DeviceDelete(types.ParseUserId("usr"+testData.Users[1].Id), testData.Devs[0].DeviceId)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got types.User
+ err = db.Collection("users").FindOne(ctx, b.M{"_id": testData.Users[1].Id}).Decode(&got)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got.DeviceArray) != 0 {
+ t.Error("Device not deleted:", got.DeviceArray)
+ }
+
+ err = adp.DeviceDelete(types.ParseUserId("usr"+testData.Users[2].Id), "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = db.Collection("users").FindOne(ctx, b.M{"_id": testData.Users[2].Id}).Decode(&got)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got.DeviceArray) != 0 {
+ t.Error("Device not deleted:", got.DeviceArray)
+ }
+}
+
+// ================== Persistent Cache tests ======================
+func TestPCacheUpsert(t *testing.T) {
+ err := adp.PCacheUpsert("test_key", "test_value", false)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Test duplicate with failOnDuplicate = true
+ err = adp.PCacheUpsert("test_key2", "test_value2", true)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ err = adp.PCacheUpsert("test_key2", "new_value", true)
+ if err != types.ErrDuplicate {
+ t.Error("Expected duplicate error")
+ }
+}
+
+func TestPCacheGet(t *testing.T) {
+ value, err := adp.PCacheGet("test_key")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if value != "test_value" {
+ t.Error(mismatchErrorString("Cache value", value, "test_value"))
+ }
+
+ // Test not found
+ _, err = adp.PCacheGet("nonexistent")
+ if err != types.ErrNotFound {
+ t.Error("Expected not found error")
+ }
+}
+
+func TestPCacheDelete(t *testing.T) {
+ err := adp.PCacheDelete("test_key")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Verify deleted
+ _, err = adp.PCacheGet("test_key")
+ if err != types.ErrNotFound {
+ t.Error("Key should be deleted")
+ }
+}
+
+func TestPCacheExpire(t *testing.T) {
+ // Insert some test keys with prefix
+ adp.PCacheUpsert("prefix_key1", "value1", false)
+ adp.PCacheUpsert("prefix_key2", "value2", false)
+
+ // Expire keys older than now (should delete all test keys)
+ err := adp.PCacheExpire("prefix_", time.Now().Add(1*time.Minute))
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+// ================== Delete tests ================================
+func TestCredDel(t *testing.T) {
+ err := adp.CredDel(types.ParseUserId("usr"+testData.Users[0].Id), "email", "alice@test.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got []map[string]any
+ cur, err := db.Collection("credentials").Find(ctx, b.M{"method": "email", "value": "alice@test.example.com"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = cur.All(ctx, &got); err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 0 {
+ t.Error("Got result but shouldn't", got)
+ }
+
+ err = adp.CredDel(types.ParseUserId("usr"+testData.Users[1].Id), "", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ cur, err = db.Collection("credentials").Find(ctx, b.M{"user": testData.Users[1].Id})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = cur.All(ctx, &got); err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 0 {
+ t.Error("Got result but shouldn't", got)
+ }
+}
+
+func TestAuthDelScheme(t *testing.T) {
+ // tested during TestAuthUpdRecord
+}
+
+func TestAuthDelAllRecords(t *testing.T) {
+ delCount, err := adp.AuthDelAllRecords(types.ParseUserId("usr" + testData.Recs[0].UserId))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if delCount != 1 {
+ t.Error(mismatchErrorString("delCount", delCount, 1))
+ }
+
+ // With dummy user
+ delCount, _ = adp.AuthDelAllRecords(types.ParseUserId("dummyuserid"))
+ if delCount != 0 {
+ t.Error(mismatchErrorString("delCount", delCount, 0))
+ }
+}
+
+func TestSubsDelForUser(t *testing.T) {
+ // Tested during TestUserDelete (both hard and soft deletions)
+}
+
+func TestMessageDeleteList(t *testing.T) {
+ toDel := types.DelMessage{
+ ObjHeader: types.ObjHeader{
+ Id: testData.UGen.GetStr(),
+ CreatedAt: testData.Now,
+ UpdatedAt: testData.Now,
+ },
+ Topic: testData.Topics[1].Id,
+ DeletedFor: testData.Users[2].Id,
+ DelId: 1,
+ SeqIdRanges: []types.Range{{Low: 9}, {Low: 3, Hi: 7}},
+ }
+ err := adp.MessageDeleteList(toDel.Topic, &toDel)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var got []types.Message
+ cur, err := db.Collection("messages").Find(ctx, b.M{"topic": toDel.Topic})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = cur.All(ctx, &got); err != nil {
+ t.Fatal(err)
+ }
+ for _, msg := range got {
+ if msg.SeqId == 1 && msg.DeletedFor != nil {
+ t.Error("Message with SeqID=1 should not be deleted")
+ }
+ if msg.SeqId == 5 && msg.DeletedFor == nil {
+ t.Error("Message with SeqID=5 should be deleted")
+ }
+ if msg.SeqId == 11 && msg.DeletedFor != nil {
+ t.Error("Message with SeqID=11 should not be deleted")
+ }
+ }
+ //
+ toDel = types.DelMessage{
+ ObjHeader: types.ObjHeader{
+ Id: testData.UGen.GetStr(),
+ CreatedAt: testData.Now,
+ UpdatedAt: testData.Now,
+ },
+ Topic: testData.Topics[0].Id,
+ DelId: 3,
+ SeqIdRanges: []types.Range{{Low: 1, Hi: 3}},
+ }
+ err = adp.MessageDeleteList(toDel.Topic, &toDel)
+ if err != nil {
+ t.Fatal(err)
+ }
+ cur, err = db.Collection("messages").Find(ctx, b.M{"topic": toDel.Topic})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = cur.All(ctx, &got); err != nil {
+ t.Fatal(err)
+ }
+ for _, msg := range got {
+ if msg.Content != nil && msg.SeqId != 3 {
+ t.Error("Message not deleted:", msg.SeqId)
+ }
+ }
+
+ err = adp.MessageDeleteList(testData.Topics[0].Id, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ cur, err = db.Collection("messages").Find(ctx, b.M{"topic": testData.Topics[0].Id})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = cur.All(ctx, &got); err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 0 {
+ t.Error("Result should be empty:", got)
+ }
+}
+
+func TestTopicDelete(t *testing.T) {
+ err := adp.TopicDelete(testData.Topics[1].Id, false, false)
+ if err != nil {
+ t.Fatal()
+ }
+ var got types.Topic
+ cur, err := db.Collection("topics").Find(ctx, b.M{"topic": testData.Topics[1].Id})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for cur.Next(ctx) {
+ if err = cur.Decode(&got); err != nil {
+ t.Error(err)
+ }
+ if got.State != types.StateDeleted {
+ t.Error("Soft delete failed:", got)
+ }
+ }
+
+ err = adp.TopicDelete(testData.Topics[0].Id, true, true)
+ if err != nil {
+ t.Fatal()
+ }
+
+ var got2 []types.Topic
+ cur, err = db.Collection("topics").Find(ctx, b.M{"topic": testData.Topics[0].Id})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = cur.All(ctx, &got2); err != nil {
+ t.Fatal(err)
+ }
+ if len(got2) != 0 {
+ t.Error("Hard delete failed:", got2)
+ }
+}
+
+func TestFileDeleteUnused(t *testing.T) {
+ // time.Now() is correct (as opposite to testData.Now):
+ // the FileFinishUpload uses time.Now() as a timestamp.
+ locs, err := adp.FileDeleteUnused(time.Now().Add(1*time.Minute), 999)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(locs) != 2 {
+ t.Error(mismatchErrorString("Locations length", len(locs), 2))
+ }
+}
+
+func TestUserDelete(t *testing.T) {
+ err := adp.UserDelete(types.ParseUserId("usr"+testData.Users[0].Id), false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got types.User
+ err = db.Collection("users").FindOne(ctx, b.M{"_id": testData.Users[0].Id}).Decode(&got)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.State != types.StateDeleted {
+ t.Error("User soft delete failed", got)
+ }
+
+ err = adp.UserDelete(types.ParseUserId("usr"+testData.Users[1].Id), true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = db.Collection("users").FindOne(ctx, b.M{"_id": testData.Users[1].Id}).Decode(&got)
+ if err != mdb.ErrNoDocuments {
+ t.Error("User hard delete failed", err)
+ }
+}
+
+func TestUserUnreadCount(t *testing.T) {
+ uids := []types.Uid{
+ types.ParseUserId("usr" + testData.Users[1].Id),
+ types.ParseUserId("usr" + testData.Users[2].Id),
+ }
+ expected := map[types.Uid]int{uids[0]: 0, uids[1]: 166}
+ counts, err := adp.UserUnreadCount(uids...)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(counts) != 2 {
+ t.Error(mismatchErrorString("UnreadCount length", len(counts), 2))
+ }
+
+ for uid, unread := range counts {
+ if expected[uid] != unread {
+ t.Error(mismatchErrorString("UnreadCount", unread, expected[uid]))
+ }
+ }
+
+ // Test not found (even if the account is not found, the call must return one record).
+ uid := types.ParseUserId("dummyuserid")
+ counts, err = adp.UserUnreadCount(uid)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(counts) != 1 {
+ t.Error(mismatchErrorString("UnreadCount length (dummy)", len(counts), 1))
+ }
+ if counts[uid] != 0 {
+ t.Error(mismatchErrorString("Non-zero UnreadCount (dummy)", counts[uid], 0))
+ }
+}
+
+// ================== Other tests =================================
+func TestMessageGetDeleted(t *testing.T) {
+ qOpts := types.QueryOpt{
+ Since: 1,
+ Before: 10,
+ Limit: 999,
+ }
+ got, err := adp.MessageGetDeleted(testData.Topics[1].Id, types.ParseUserId("usr"+testData.Users[2].Id), &qOpts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 1 {
+ t.Error(mismatchErrorString("result length", len(got), 1))
+ }
+}
+
+// ================================================================
+func mismatchErrorString(key string, got, want any) string {
+ return fmt.Sprintf("%s mismatch:\nGot = %+v\nWant = %+v", key, got, want)
+}
+
+func init() {
+ logs.Init(os.Stderr, "stdFlags")
+ adp = backend.GetTestAdapter()
+ conffile := flag.String("config", "./test.conf", "config of the database connection")
+
+ if file, err := os.Open(*conffile); err != nil {
+ log.Fatal("Failed to read config file:", err)
+ } else if err = json.NewDecoder(jcr.New(file)).Decode(&config); err != nil {
+ log.Fatal("Failed to parse config file:", err)
+ }
+
+ if adp == nil {
+ log.Fatal("Database adapter is missing")
+ }
+ if adp.IsOpen() {
+ log.Print("Connection is already opened")
+ }
+
+ err := adp.Open(config.Adapters[adp.GetName()])
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ db = adp.GetTestDB().(*mdb.Database)
+ testData = test_data.InitTestData()
+ if testData == nil {
+ log.Fatal("Failed to initialize test data")
+ }
+}
diff --git a/server/db/mongodb/tests/test.conf b/server/db/mongodb/tests/test.conf
index e69de29..6dee6b7 100644
--- a/server/db/mongodb/tests/test.conf
+++ b/server/db/mongodb/tests/test.conf
@@ -0,0 +1,12 @@
+{
+ "reset_db_data": true,
+ "adapters": {
+ "mongodb": {
+ "database": "tinode_test",
+ //"replica_set": "rs0",
+ "addresses": "localhost:27017",
+ //"username": "tinode_test",
+ //"password": "tinode_test",
+ }
+ }
+}
diff --git a/server/db/mysql/adapter.go b/server/db/mysql/adapter.go
index e69de29..97db350 100644
--- a/server/db/mysql/adapter.go
+++ b/server/db/mysql/adapter.go
@@ -0,0 +1,4204 @@
+//go:build mysql
+// +build mysql
+
+// Package mysql is a database adapter for MySQL.
+package mysql
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "hash/fnv"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ ms "github.com/go-sql-driver/mysql"
+ "github.com/jmoiron/sqlx"
+ "github.com/tinode/chat/server/auth"
+ "github.com/tinode/chat/server/db/common"
+ "github.com/tinode/chat/server/store"
+ t "github.com/tinode/chat/server/store/types"
+)
+
+// adapter holds MySQL connection data.
+type adapter struct {
+ db *sqlx.DB
+ dsn string
+ dbName string
+ // Maximum number of records to return
+ maxResults int
+ // Maximum number of message records to return
+ maxMessageResults int
+ version int
+
+ // Single query timeout.
+ sqlTimeout time.Duration
+ // DB transaction timeout.
+ txTimeout time.Duration
+}
+
+const (
+ adpVersion = 117
+ adapterName = "mysql"
+
+ defaultDSN = "root:@tcp(localhost:3306)/tinode?parseTime=true"
+ defaultDatabase = "tinode"
+
+ defaultMaxResults = 1024
+ // This is capped by the Session's send queue limit (128).
+ defaultMaxMessageResults = 100
+
+ // If DB request timeout is specified,
+ // we allocate txTimeoutMultiplier times more time for transactions.
+ txTimeoutMultiplier = 1.5
+)
+
+type configType struct {
+ // DB connection settings.
+ // See https://pkg.go.dev/github.com/go-sql-driver/mysql#Config
+ // for the full list of fields.
+ ms.Config
+ // Deprecated.
+ DSN string `json:"dsn,omitempty"`
+
+ // Connection pool settings.
+ //
+ // Maximum number of open connections to the database.
+ MaxOpenConns int `json:"max_open_conns,omitempty"`
+ // Maximum number of connections in the idle connection pool.
+ MaxIdleConns int `json:"max_idle_conns,omitempty"`
+ // Maximum amount of time a connection may be reused (in seconds).
+ ConnMaxLifetime int `json:"conn_max_lifetime,omitempty"`
+
+ // DB request timeout (in seconds).
+ // If 0 (or negative), no timeout is applied.
+ SqlTimeout int `json:"sql_timeout,omitempty"`
+}
+
+func (a *adapter) getContext() (context.Context, context.CancelFunc) {
+ if a.sqlTimeout > 0 {
+ return context.WithTimeout(context.Background(), a.sqlTimeout)
+ }
+ return context.Background(), nil
+}
+
+func (a *adapter) getContextForTx() (context.Context, context.CancelFunc) {
+ if a.txTimeout > 0 {
+ return context.WithTimeout(context.Background(), a.txTimeout)
+ }
+ return context.Background(), nil
+}
+
+// Open initializes database session
+func (a *adapter) Open(jsonconfig json.RawMessage) error {
+ if a.db != nil {
+ return errors.New("mysql adapter is already connected")
+ }
+
+ if len(jsonconfig) < 2 {
+ return errors.New("adapter mysql missing config")
+ }
+
+ var err error
+ defaultCfg := ms.NewConfig()
+ config := configType{Config: *defaultCfg}
+ if err = json.Unmarshal(jsonconfig, &config); err != nil {
+ return errors.New("mysql adapter failed to parse config: " + err.Error())
+ }
+
+ if dsn := config.FormatDSN(); dsn != defaultCfg.FormatDSN() {
+ // MySql config is specified. Use it.
+ a.dbName = config.DBName
+ a.dsn = dsn
+ if config.DSN != "" {
+ return errors.New("mysql config: conflicting config and DSN are provided")
+ }
+ } else {
+ // Otherwise, use DSN to configure database connection.
+ // Note: this method is deprecated.
+ if config.DSN != "" {
+ // Remove optional schema.
+ a.dsn = strings.TrimPrefix(config.DSN, "mysql://")
+ } else {
+ a.dsn = defaultDSN
+ }
+
+ // Parse out the database name from the DSN.
+ if cfg, err := ms.ParseDSN(a.dsn); err == nil {
+ a.dbName = cfg.DBName
+ } else {
+ return err
+ }
+ }
+
+ if a.dbName == "" {
+ a.dbName = defaultDatabase
+ }
+
+ if a.maxResults <= 0 {
+ a.maxResults = defaultMaxResults
+ }
+
+ if a.maxMessageResults <= 0 {
+ a.maxMessageResults = defaultMaxMessageResults
+ }
+
+ // This just initializes the driver but does not open the network connection.
+ a.db, err = sqlx.Open("mysql", a.dsn)
+ if err != nil {
+ return err
+ }
+
+ // Actually opening the network connection.
+ err = a.db.Ping()
+ if isMissingDb(err) {
+ // Ignore missing database here. If we are initializing the database
+ // missing DB is OK.
+ err = nil
+ }
+ if err == nil {
+ if config.MaxOpenConns > 0 {
+ a.db.SetMaxOpenConns(config.MaxOpenConns)
+ }
+ if config.MaxIdleConns > 0 {
+ a.db.SetMaxIdleConns(config.MaxIdleConns)
+ }
+ if config.ConnMaxLifetime > 0 {
+ a.db.SetConnMaxLifetime(time.Duration(config.ConnMaxLifetime) * time.Second)
+ }
+ if config.SqlTimeout > 0 {
+ a.sqlTimeout = time.Duration(config.SqlTimeout) * time.Second
+ // We allocate txTimeoutMultiplier times sqlTimeout for transactions.
+ a.txTimeout = time.Duration(float64(config.SqlTimeout)*txTimeoutMultiplier) * time.Second
+ }
+ }
+ return err
+}
+
+// Close closes the underlying database connection
+func (a *adapter) Close() error {
+ var err error
+ if a.db != nil {
+ err = a.db.Close()
+ a.db = nil
+ a.version = -1
+ }
+ return err
+}
+
+// IsOpen returns true if connection to database has been established. It does not check if
+// connection is actually live.
+func (a *adapter) IsOpen() bool {
+ return a.db != nil
+}
+
+// GetDbVersion returns current database version.
+func (a *adapter) GetDbVersion() (int, error) {
+ if a.version > 0 {
+ return a.version, nil
+ }
+
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ var vers int
+ err := a.db.GetContext(ctx, &vers, "SELECT `value` FROM kvmeta WHERE `key`='version'")
+ if err != nil {
+ if isMissingDb(err) || isMissingTable(err) || err == sql.ErrNoRows {
+ err = errors.New("Database not initialized")
+ }
+ return -1, err
+ }
+
+ a.version = vers
+
+ return vers, nil
+}
+
+func (a *adapter) updateDbVersion(v int) error {
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ a.version = -1
+ if _, err := a.db.ExecContext(ctx, "UPDATE kvmeta SET `value`=? WHERE `key`='version'", v); err != nil {
+ return err
+ }
+ return nil
+}
+
+// CheckDbVersion checks whether the actual DB version matches the expected version of this adapter.
+func (a *adapter) CheckDbVersion() error {
+ version, err := a.GetDbVersion()
+ if err != nil {
+ return err
+ }
+
+ if version != adpVersion {
+ return errors.New("Invalid database version " + strconv.Itoa(version) +
+ ". Expected " + strconv.Itoa(adpVersion))
+ }
+
+ return nil
+}
+
+// Version returns adapter version.
+func (adapter) Version() int {
+ return adpVersion
+}
+
+// DB connection stats object.
+func (a *adapter) Stats() any {
+ if a.db == nil {
+ return nil
+ }
+ return a.db.Stats()
+}
+
+// GetName returns string that adapter uses to register itself with store.
+func (a *adapter) GetName() string {
+ return adapterName
+}
+
+// SetMaxResults configures how many results can be returned in a single DB call.
+func (a *adapter) SetMaxResults(val int) error {
+ if val <= 0 {
+ a.maxResults = defaultMaxResults
+ } else {
+ a.maxResults = val
+ }
+
+ return nil
+}
+
+// TenantGetByCode resolves a tenant by its public enterprise code.
+func (a *adapter) TenantGetByCode(code string) (*t.Tenant, error) {
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+
+ var tenant t.Tenant
+ err := a.db.GetContext(ctx, &tenant,
+ `SELECT id, code, name, tenant_desc, state, created_at, created_by, updated_at, updated_by
+ FROM im_tenant WHERE code=? LIMIT 1`, code)
+ if err == sql.ErrNoRows {
+ return nil, t.ErrNotFound
+ }
+ if err != nil {
+ return nil, err
+ }
+ return &tenant, nil
+}
+
+// CreateDb initializes the storage.
+func (a *adapter) CreateDb(reset bool) error {
+ var err error
+ var tx *sql.Tx
+
+ // Can't use an existing connection because it's configured with a database name which may not exist.
+ // Don't care if it does not close cleanly.
+ a.db.Close()
+
+ // This DSN has been parsed before and produced no error, not checking for errors here.
+ cfg, _ := ms.ParseDSN(a.dsn)
+ // Clear database name
+ cfg.DBName = ""
+
+ a.db, err = sqlx.Open("mysql", cfg.FormatDSN())
+ if err != nil {
+ return err
+ }
+
+ if tx, err = a.db.Begin(); err != nil {
+ return err
+ }
+
+ defer func() {
+ if err != nil {
+ // FIXME: This is useless: MySQL auto-commits on every CREATE TABLE.
+ // Maybe DROP DATABASE instead.
+ tx.Rollback()
+ }
+ }()
+
+ if reset {
+ if _, err = tx.Exec("DROP DATABASE IF EXISTS " + a.dbName); err != nil {
+ return err
+ }
+ }
+
+ if _, err = tx.Exec("CREATE DATABASE " + a.dbName + " CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci"); err != nil {
+ return err
+ }
+
+ if _, err = tx.Exec("USE " + a.dbName); err != nil {
+ return err
+ }
+
+ if _, err = tx.Exec(
+ `CREATE TABLE im_tenant(
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ code VARCHAR(64) NOT NULL,
+ name VARCHAR(128) NOT NULL,
+ tenant_desc VARCHAR(256),
+ state SMALLINT NOT NULL,
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ created_by BIGINT NOT NULL,
+ updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
+ updated_by BIGINT NOT NULL,
+ PRIMARY KEY(id),
+ UNIQUE INDEX uk_im_tenant_code(code)
+ )`); err != nil {
+ return err
+ }
+
+ if _, err = tx.Exec(
+ `CREATE TABLE im_tenant_config(
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ tenant_id BIGINT NOT NULL,
+ max_users BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
+ max_groups BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
+ max_group_members INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ created_by BIGINT NOT NULL,
+ updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
+ updated_by BIGINT NOT NULL,
+ PRIMARY KEY(id),
+ UNIQUE INDEX uk_im_tenant_config_tenant_id(tenant_id),
+ CONSTRAINT fk_im_tenant_config_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
+ )`); err != nil {
+ return err
+ }
+
+ if _, err = tx.Exec(
+ `INSERT INTO im_tenant(code,name,tenant_desc,state,created_by,updated_by)
+ VALUES('default','Default Tenant',NULL,?,0,0)`, t.TenantStateActive); err != nil {
+ return err
+ }
+
+ if _, err = tx.Exec(
+ `CREATE TABLE users(
+ id BIGINT NOT NULL,
+ createdat DATETIME(3) NOT NULL,
+ updatedat DATETIME(3) NOT NULL,
+ state SMALLINT NOT NULL DEFAULT 0,
+ stateat DATETIME(3),
+ access JSON,
+ lastseen DATETIME,
+ useragent VARCHAR(255) DEFAULT '',
+ public JSON,
+ trusted JSON,
+ tags JSON,
+ PRIMARY KEY(id),
+ INDEX users_state_stateat(state, stateat),
+ INDEX users_lastseen_updatedat(lastseen, updatedat)
+ )`); err != nil {
+ return err
+ }
+
+ // Indexed user tags.
+ if _, err = tx.Exec(
+ `CREATE TABLE usertags(
+ id INT NOT NULL AUTO_INCREMENT,
+ userid BIGINT NOT NULL,
+ tag VARCHAR(96) NOT NULL,
+ PRIMARY KEY(id),
+ FOREIGN KEY(userid) REFERENCES users(id),
+ INDEX usertags_tag(tag),
+ UNIQUE INDEX usertags_userid_tag(userid, tag)
+ )`); err != nil {
+ return err
+ }
+
+ // Indexed devices. Normalized into a separate table.
+ if _, err = tx.Exec(
+ `CREATE TABLE devices(
+ id INT NOT NULL AUTO_INCREMENT,
+ userid BIGINT NOT NULL,
+ hash CHAR(16) NOT NULL,
+ deviceid TEXT NOT NULL,
+ platform VARCHAR(32),
+ lastseen DATETIME NOT NULL,
+ lang VARCHAR(8),
+ PRIMARY KEY(id),
+ FOREIGN KEY(userid) REFERENCES users(id),
+ UNIQUE INDEX devices_hash(hash)
+ )`); err != nil {
+ return err
+ }
+
+ // Authentication records for the basic authentication scheme.
+ if _, err = tx.Exec(
+ `CREATE TABLE auth(
+ id INT NOT NULL AUTO_INCREMENT,
+ uname VARCHAR(32) NOT NULL,
+ userid BIGINT NOT NULL,
+ scheme VARCHAR(16) NOT NULL,
+ authlvl INT NOT NULL,
+ secret VARCHAR(255) NOT NULL,
+ expires DATETIME,
+ PRIMARY KEY(id),
+ FOREIGN KEY(userid) REFERENCES users(id),
+ UNIQUE INDEX auth_userid_scheme(userid, scheme),
+ UNIQUE INDEX auth_uname(uname)
+ )`); err != nil {
+ return err
+ }
+
+ // Topics
+ if _, err = tx.Exec(
+ `CREATE TABLE topics(
+ id INT NOT NULL AUTO_INCREMENT,
+ createdat DATETIME(3) NOT NULL,
+ updatedat DATETIME(3) NOT NULL,
+ state SMALLINT NOT NULL DEFAULT 0,
+ stateat DATETIME(3),
+ touchedat DATETIME(3),
+ name CHAR(25) NOT NULL,
+ usebt TINYINT DEFAULT 0,
+ owner BIGINT NOT NULL DEFAULT 0,
+ access JSON,
+ seqid INT NOT NULL DEFAULT 0,
+ delid INT DEFAULT 0,
+ subcnt INT DEFAULT 0,
+ public JSON,
+ trusted JSON,
+ tags JSON,
+ aux JSON,
+ PRIMARY KEY(id),
+ UNIQUE INDEX topics_name(name),
+ INDEX topics_owner(owner),
+ INDEX topics_state_stateat(state, stateat),
+ INDEX topics_name_state_seqid(name, state, seqid)
+ )`); err != nil {
+ return err
+ }
+
+ // Create system topic 'sys'.
+ if err = createSystemTopic(tx); err != nil {
+ return err
+ }
+
+ // Indexed topic tags.
+ if _, err = tx.Exec(
+ `CREATE TABLE topictags(
+ id INT NOT NULL AUTO_INCREMENT,
+ topic CHAR(25) NOT NULL,
+ tag VARCHAR(96) NOT NULL,
+ PRIMARY KEY(id),
+ FOREIGN KEY(topic) REFERENCES topics(name),
+ INDEX topictags_tag(tag),
+ UNIQUE INDEX topictags_topic_tag(topic, tag)
+ )`); err != nil {
+ return err
+ }
+
+ // Subscriptions
+ if _, err = tx.Exec(
+ `CREATE TABLE subscriptions(
+ id INT NOT NULL AUTO_INCREMENT,
+ createdat DATETIME(3) NOT NULL,
+ updatedat DATETIME(3) NOT NULL,
+ deletedat DATETIME(3),
+ userid BIGINT NOT NULL,
+ topic CHAR(25) NOT NULL,
+ delid INT DEFAULT 0,
+ recvseqid INT DEFAULT 0,
+ readseqid INT DEFAULT 0,
+ modewant CHAR(8),
+ modegiven CHAR(8),
+ private JSON,
+ PRIMARY KEY(id),
+ FOREIGN KEY(userid) REFERENCES users(id),
+ UNIQUE INDEX subscriptions_topic_userid(topic, userid),
+ INDEX subscriptions_topic(topic),
+ INDEX subscriptions_deletedat(deletedat),
+ INDEX subscriptions_userid_topic_deletedat(userid, topic, deletedat)
+ )`); err != nil {
+ return err
+ }
+
+ // Messages
+ if _, err = tx.Exec(
+ `CREATE TABLE messages(
+ id INT NOT NULL AUTO_INCREMENT,
+ createdat DATETIME(3) NOT NULL,
+ updatedat DATETIME(3) NOT NULL,
+ deletedat DATETIME(3),
+ delid INT DEFAULT 0,
+ seqid INT NOT NULL,
+ topic CHAR(25) NOT NULL,` +
+ "`from` BIGINT NOT NULL," +
+ `head JSON,
+ content JSON,
+ PRIMARY KEY(id),
+ FOREIGN KEY(topic) REFERENCES topics(name),
+ UNIQUE INDEX messages_topic_seqid(topic, seqid)
+ )`); err != nil {
+ return err
+ }
+
+ // Deletion log
+ if _, err = tx.Exec(
+ `CREATE TABLE dellog(
+ id INT NOT NULL AUTO_INCREMENT,
+ topic CHAR(25) NOT NULL,
+ deletedfor BIGINT NOT NULL DEFAULT 0,
+ delid INT NOT NULL,
+ low INT NOT NULL,
+ hi INT NOT NULL,
+ PRIMARY KEY(id),
+ FOREIGN KEY(topic) REFERENCES topics(name),
+ INDEX dellog_topic_delid_deletedfor(topic,delid,deletedfor),
+ INDEX dellog_topic_deletedfor_low_hi(topic,deletedfor,low,hi),
+ INDEX dellog_deletedfor(deletedfor)
+ )`); err != nil {
+ return err
+ }
+
+ // User credentials
+ if _, err = tx.Exec(
+ `CREATE TABLE credentials(
+ id INT NOT NULL AUTO_INCREMENT,
+ createdat DATETIME(3) NOT NULL,
+ updatedat DATETIME(3) NOT NULL,
+ deletedat DATETIME(3),
+ method VARCHAR(16) NOT NULL,
+ value VARCHAR(128) NOT NULL,
+ synthetic VARCHAR(192) NOT NULL,
+ userid BIGINT NOT NULL,
+ resp VARCHAR(255),
+ done TINYINT NOT NULL DEFAULT 0,
+ retries INT NOT NULL DEFAULT 0,
+ PRIMARY KEY(id),
+ FOREIGN KEY(userid) REFERENCES users(id),
+ UNIQUE credentials_uniqueness(synthetic)
+ )`); err != nil {
+ return err
+ }
+
+ // Records of uploaded files.
+ // Don't add FOREIGN KEY on userid. It's not needed and it will break user deletion.
+ if _, err = tx.Exec(
+ `CREATE TABLE fileuploads(
+ id BIGINT NOT NULL,
+ createdat DATETIME(3) NOT NULL,
+ updatedat DATETIME(3) NOT NULL,
+ userid BIGINT,
+ status INT NOT NULL,
+ mimetype VARCHAR(255) NOT NULL,
+ size BIGINT NOT NULL,
+ etag VARCHAR(128),
+ location VARCHAR(2048) NOT NULL,
+ PRIMARY KEY(id),
+ INDEX fileuploads_status(status)
+ )`); err != nil {
+ return err
+ }
+
+ // Links between uploaded files and the topics, users or messages they are attached to.
+ if _, err = tx.Exec(
+ `CREATE TABLE filemsglinks(
+ id INT NOT NULL AUTO_INCREMENT,
+ createdat DATETIME(3) NOT NULL,
+ fileid BIGINT NOT NULL,
+ msgid INT,
+ topic CHAR(25),
+ userid BIGINT,
+ PRIMARY KEY(id),
+ FOREIGN KEY(fileid) REFERENCES fileuploads(id) ON DELETE CASCADE,
+ FOREIGN KEY(msgid) REFERENCES messages(id) ON DELETE CASCADE,
+ FOREIGN KEY(topic) REFERENCES topics(name) ON DELETE CASCADE,
+ FOREIGN KEY(userid) REFERENCES users(id) ON DELETE CASCADE
+ )`); err != nil {
+ return err
+ }
+
+ if _, err = tx.Exec(
+ `CREATE TABLE kvmeta(` +
+ "`key` VARCHAR(64) NOT NULL," +
+ "createdat DATETIME(3)," +
+ "`value` TEXT," +
+ "PRIMARY KEY(`key`)," +
+ "INDEX kvmeta_createdat_key(createdat, `key`)" +
+ `)`); err != nil {
+ return err
+ }
+ if _, err = tx.Exec("INSERT INTO kvmeta(`key`, `value`) VALUES('version',?)", adpVersion); err != nil {
+ return err
+ }
+
+ return tx.Commit()
+}
+
+// UpgradeDb upgrades the database, if necessary.
+func (a *adapter) UpgradeDb() error {
+ bumpVersion := func(a *adapter, x int) error {
+ if err := a.updateDbVersion(x); err != nil {
+ return err
+ }
+ _, err := a.GetDbVersion()
+ return err
+ }
+
+ if _, err := a.GetDbVersion(); err != nil {
+ return err
+ }
+
+ if a.version == 106 {
+ // Perform database upgrade from version 106 to version 107.
+
+ if _, err := a.db.Exec("CREATE UNIQUE INDEX usertags_userid_tag ON usertags(userid, tag)"); err != nil {
+ return err
+ }
+
+ if _, err := a.db.Exec("CREATE UNIQUE INDEX topictags_topic_tag ON topictags(topic, tag)"); err != nil {
+ return err
+ }
+
+ if _, err := a.db.Exec("ALTER TABLE credentials ADD deletedat DATETIME(3) AFTER updatedat"); err != nil {
+ return err
+ }
+
+ if err := bumpVersion(a, 107); err != nil {
+ return err
+ }
+ }
+
+ if a.version == 107 {
+ // Perform database upgrade from version 107 to version 108.
+
+ // Replace default user access JRWPA with JRWPAS.
+ if _, err := a.db.Exec(`UPDATE users SET access=JSON_REPLACE(access, '$.Auth', 'JRWPAS')
+ WHERE CAST(JSON_EXTRACT(access, '$.Auth') AS CHAR) LIKE '"JRWPA"'`); err != nil {
+ return err
+ }
+
+ if err := bumpVersion(a, 108); err != nil {
+ return err
+ }
+ }
+
+ if a.version == 108 {
+ // Perform database upgrade from version 108 to version 109.
+
+ tx, err := a.db.Begin()
+ if err != nil {
+ return err
+ }
+ if err = createSystemTopic(tx); err != nil {
+ tx.Rollback()
+ return err
+ }
+ if err = tx.Commit(); err != nil {
+ return err
+ }
+
+ if err = bumpVersion(a, 109); err != nil {
+ return err
+ }
+ }
+
+ if a.version == 109 {
+ // Perform database upgrade from version 109 to version 110.
+ if _, err := a.db.Exec("UPDATE topics SET touchedat=updatedat WHERE touchedat IS NULL"); err != nil {
+ return err
+ }
+
+ if err := bumpVersion(a, 110); err != nil {
+ return err
+ }
+ }
+
+ if a.version == 110 {
+ // Users
+ if _, err := a.db.Exec("ALTER TABLE users MODIFY state SMALLINT NOT NULL DEFAULT 0 AFTER updatedat"); err != nil {
+ return err
+ }
+
+ if _, err := a.db.Exec("ALTER TABLE users CHANGE deletedat stateat DATETIME(3)"); err != nil {
+ return err
+ }
+
+ if _, err := a.db.Exec("ALTER TABLE users DROP INDEX users_deletedat"); err != nil {
+ return err
+ }
+
+ // Add status to formerly soft-deleted users.
+ if _, err := a.db.Exec("UPDATE users SET state=? WHERE stateat IS NOT NULL", t.StateDeleted); err != nil {
+ return err
+ }
+
+ if _, err := a.db.Exec("ALTER TABLE users ADD INDEX users_state(state)"); err != nil {
+ return err
+ }
+
+ // Topics
+ if _, err := a.db.Exec("ALTER TABLE topics ADD state SMALLINT NOT NULL DEFAULT 0 AFTER updatedat"); err != nil {
+ return err
+ }
+
+ if _, err := a.db.Exec("ALTER TABLE topics CHANGE deletedat stateat DATETIME(3)"); err != nil {
+ return err
+ }
+
+ // Add status to formerly soft-deleted topics.
+ if _, err := a.db.Exec("UPDATE topics SET state=? WHERE stateat IS NOT NULL", t.StateDeleted); err != nil {
+ return err
+ }
+
+ if _, err := a.db.Exec("ALTER TABLE topics ADD INDEX topics_state(state)"); err != nil {
+ return err
+ }
+
+ // Subscriptions
+ if _, err := a.db.Exec("ALTER TABLE subscriptions ADD INDEX topics_deletedat(deletedat)"); err != nil {
+ return err
+ }
+
+ if err := bumpVersion(a, 111); err != nil {
+ return err
+ }
+ }
+
+ if a.version == 111 {
+ // Perform database upgrade from version 111 to version 112.
+ if _, err := a.db.Exec("ALTER TABLE users ADD trusted JSON AFTER public"); err != nil {
+ return err
+ }
+
+ if _, err := a.db.Exec("ALTER TABLE topics ADD trusted JSON AFTER public"); err != nil {
+ return err
+ }
+
+ // Remove NOT NULL constraint, so an avatar upload can be done at registration.
+ if _, err := a.db.Exec("ALTER TABLE fileuploads MODIFY userid BIGINT"); err != nil {
+ return err
+ }
+
+ if _, err := a.db.Exec("ALTER TABLE fileuploads ADD INDEX fileuploads_status(status)"); err != nil {
+ return err
+ }
+
+ // Remove NOT NULL constraint to enable links to users and topics.
+ if _, err := a.db.Exec("ALTER TABLE filemsglinks MODIFY msgid INT"); err != nil {
+ return err
+ }
+
+ if _, err := a.db.Exec("ALTER TABLE filemsglinks ADD topic CHAR(25)"); err != nil {
+ return err
+ }
+
+ if _, err := a.db.Exec("ALTER TABLE filemsglinks ADD userid BIGINT"); err != nil {
+ return err
+ }
+
+ if _, err := a.db.Exec("ALTER TABLE filemsglinks ADD FOREIGN KEY(topic) REFERENCES topics(name) ON DELETE CASCADE"); err != nil {
+ return err
+ }
+
+ if _, err := a.db.Exec("ALTER TABLE filemsglinks ADD FOREIGN KEY(userid) REFERENCES users(id) ON DELETE CASCADE"); err != nil {
+ return err
+ }
+
+ if err := bumpVersion(a, 112); err != nil {
+ return err
+ }
+ }
+
+ if a.version == 112 {
+ // Perform database upgrade from version 112 to version 113.
+
+ // Index for deleting unvalidated accounts.
+ if _, err := a.db.Exec("ALTER TABLE users ADD INDEX users_lastseen_updatedat(lastseen,updatedat)"); err != nil {
+ return err
+ }
+
+ // Add timestamp to kvmeta.
+ if _, err := a.db.Exec("ALTER TABLE kvmeta MODIFY `key` VARCHAR(64) NOT NULL"); err != nil {
+ return err
+ }
+
+ // Add timestamp to kvmeta.
+ if _, err := a.db.Exec("ALTER TABLE kvmeta ADD createdat DATETIME(3) AFTER `key`"); err != nil {
+ return err
+ }
+
+ // Add compound index on the new field and key (could be searched by key prefix).
+ if _, err := a.db.Exec("ALTER TABLE kvmeta ADD INDEX kvmeta_createdat_key(createdat, `key`)"); err != nil {
+ return err
+ }
+
+ if err := bumpVersion(a, 113); err != nil {
+ return err
+ }
+ }
+
+ if a.version == 113 {
+ // Perform database upgrade from version 113 to version 114.
+
+ if _, err := a.db.Exec("ALTER TABLE topics ADD aux JSON"); err != nil {
+ return err
+ }
+
+ if _, err := a.db.Exec("ALTER TABLE fileuploads ADD etag VARCHAR(128) AFTER size"); err != nil {
+ return err
+ }
+
+ if err := bumpVersion(a, 114); err != nil {
+ return err
+ }
+ }
+
+ if a.version == 114 {
+ // Perform database upgrade from version 114 to version 115.
+
+ // Find relevant subscriptions for given users efficiently, and use the join key too.
+ if _, err := a.db.Exec("CREATE INDEX idx_subs_user_topic_del ON subscriptions(userid, topic, deletedat)"); err != nil {
+ return err
+ }
+
+ // Optimizes join; state filters; seqid supports the SUM operation.
+ if _, err := a.db.Exec("CREATE INDEX idx_topics_name_state_seqid ON topics(name, state, seqid)"); err != nil {
+ return err
+ }
+
+ if err := bumpVersion(a, 115); err != nil {
+ return err
+ }
+ }
+
+ if a.version == 115 {
+ // Perform database upgrade from version 115 to version 116.
+
+ // Add subscriber count column to the topics table.
+ if _, err := a.db.Exec("ALTER TABLE topics ADD COLUMN subcnt INT DEFAULT 0 AFTER delid"); err != nil {
+ return err
+ }
+
+ if err := bumpVersion(a, 116); err != nil {
+ return err
+ }
+ }
+
+ if a.version == 116 {
+ // Add tenant registry tables. Business tables become tenant-scoped in
+ // the following schema version.
+ if _, err := a.db.Exec(
+ `CREATE TABLE im_tenant(
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ code VARCHAR(64) NOT NULL,
+ name VARCHAR(128) NOT NULL,
+ tenant_desc VARCHAR(256),
+ state SMALLINT NOT NULL,
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ created_by BIGINT NOT NULL,
+ updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
+ updated_by BIGINT NOT NULL,
+ PRIMARY KEY(id),
+ UNIQUE INDEX uk_im_tenant_code(code)
+ )`); err != nil {
+ return err
+ }
+
+ if _, err := a.db.Exec(
+ `CREATE TABLE im_tenant_config(
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ tenant_id BIGINT NOT NULL,
+ max_users BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
+ max_groups BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
+ max_group_members INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ created_by BIGINT NOT NULL,
+ updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
+ updated_by BIGINT NOT NULL,
+ PRIMARY KEY(id),
+ UNIQUE INDEX uk_im_tenant_config_tenant_id(tenant_id),
+ CONSTRAINT fk_im_tenant_config_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
+ )`); err != nil {
+ return err
+ }
+
+ if _, err := a.db.Exec(
+ `INSERT INTO im_tenant(code,name,tenant_desc,state,created_by,updated_by)
+ VALUES('default','Default Tenant',NULL,?,0,0)`, t.TenantStateActive); err != nil {
+ return err
+ }
+
+ if err := bumpVersion(a, 117); err != nil {
+ return err
+ }
+ }
+
+ if a.version != adpVersion {
+ return errors.New("Failed to perform database upgrade to version " + strconv.Itoa(adpVersion) +
+ ". DB is still at " + strconv.Itoa(a.version))
+ }
+ return nil
+}
+
+// Create system topic 'sys'.
+func createSystemTopic(tx *sql.Tx) error {
+ now := t.TimeNow()
+ query := `INSERT INTO topics(createdat,updatedat,state,touchedat,name,access,public)
+ VALUES(?,?,?,?,'sys','{"Auth": "N","Anon": "N"}','{"fn": "System"}')`
+ _, err := tx.Exec(query, now, now, t.StateOK, now)
+ return err
+}
+
+func addTags(tx *sqlx.Tx, table, keyName string, keyVal any, tags []string, ignoreDups bool) error {
+ if len(tags) == 0 {
+ return nil
+ }
+
+ insert, err := tx.Prepare("INSERT INTO " + table + "(" + keyName + ",tag) VALUES(?,?)")
+ if err != nil {
+ return err
+ }
+
+ for _, tag := range tags {
+ if _, err = insert.Exec(keyVal, tag); err != nil {
+ if isDupe(err) {
+ if ignoreDups {
+ err = nil
+ continue
+ }
+ return t.ErrDuplicate
+ }
+ return err
+ }
+ }
+ return nil
+}
+
+func addTenantTags(tx *sqlx.Tx, tenantID t.TenantID, table, keyName string, keyVal any,
+ tags []string, ignoreDups bool) error {
+ if len(tags) == 0 {
+ return nil
+ }
+
+ insert, err := tx.Prepare("INSERT INTO " + table + "(tenant_id," + keyName + ",tag) VALUES(?,?,?)")
+ if err != nil {
+ return err
+ }
+ defer insert.Close()
+
+ for _, tag := range tags {
+ if _, err = insert.Exec(tenantID, keyVal, tag); err != nil {
+ if isDupe(err) && ignoreDups {
+ continue
+ }
+ if isDupe(err) {
+ return t.ErrDuplicate
+ }
+ return err
+ }
+ }
+ return nil
+}
+
+func removeTags(tx *sqlx.Tx, table, keyName string, keyVal any, tags []string) error {
+ if len(tags) == 0 {
+ return nil
+ }
+
+ var args []any
+ for _, tag := range tags {
+ args = append(args, tag)
+ }
+
+ query, args, _ := sqlx.In("DELETE FROM "+table+" WHERE "+keyName+"=? AND tag IN (?)", keyVal, args)
+ _, err := tx.Exec(tx.Rebind(query), args...)
+
+ return err
+}
+
+// UserCreate creates a new user. Returns error and true if error is due to duplicate user name,
+// false for any other error
+func (a *adapter) UserCreate(user *t.User) error {
+ if user == nil || user.TenantID.IsZero() {
+ return t.ErrMalformed
+ }
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTxx(ctx, nil)
+ if err != nil {
+ return err
+ }
+
+ defer func() {
+ if err != nil {
+ tx.Rollback()
+ }
+ }()
+
+ decoded_uid := store.DecodeUid(user.Uid())
+ if _, err = tx.Exec("INSERT INTO users(id,tenant_id,createdat,updatedat,state,access,public,trusted,tags) VALUES(?,?,?,?,?,?,?,?,?)",
+ decoded_uid,
+ user.TenantID,
+ user.CreatedAt,
+ user.UpdatedAt,
+ user.State,
+ user.Access,
+ common.ToJSON(user.Public),
+ common.ToJSON(user.Trusted),
+ user.Tags); err != nil {
+ return err
+ }
+
+ // Save user's tags to a separate table to make user findable.
+ if err = addTenantTags(tx, user.TenantID, "usertags", "userid", decoded_uid, user.Tags, false); err != nil {
+ return err
+ }
+
+ return tx.Commit()
+}
+
+// TenantAuthAddRecord adds an authentication record scoped to a tenant.
+func (a *adapter) TenantAuthAddRecord(tenantID t.TenantID, uid t.Uid, scheme, unique string,
+ authLvl auth.Level, secret []byte, expires time.Time) error {
+ if tenantID.IsZero() {
+ return t.ErrMalformed
+ }
+ var exp *time.Time
+ if !expires.IsZero() {
+ exp = &expires
+ }
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ _, err := a.db.ExecContext(ctx,
+ "INSERT INTO auth(tenant_id,uname,userid,scheme,authlvl,secret,expires) VALUES(?,?,?,?,?,?,?)",
+ tenantID, unique, store.DecodeUid(uid), scheme, authLvl, secret, exp)
+ if isDupe(err) {
+ return t.ErrDuplicate
+ }
+ return err
+}
+
+// TenantAuthDelScheme deletes an authentication scheme only in the selected tenant.
+func (a *adapter) TenantAuthDelScheme(tenantID t.TenantID, uid t.Uid, scheme string) error {
+ if tenantID.IsZero() {
+ return t.ErrMalformed
+ }
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ _, err := a.db.ExecContext(ctx, "DELETE FROM auth WHERE tenant_id=? AND userid=? AND scheme=?",
+ tenantID, store.DecodeUid(uid), scheme)
+ return err
+}
+
+// TenantAuthUpdRecord updates an authentication record only in the selected tenant.
+func (a *adapter) TenantAuthUpdRecord(tenantID t.TenantID, uid t.Uid, scheme, unique string,
+ authLvl auth.Level, secret []byte, expires time.Time) error {
+ if tenantID.IsZero() {
+ return t.ErrMalformed
+ }
+ params := []string{"authlvl=?"}
+ args := []any{authLvl}
+ if unique != "" {
+ params = append(params, "uname=?")
+ args = append(args, unique)
+ }
+ if len(secret) > 0 {
+ params = append(params, "secret=?")
+ args = append(args, secret)
+ }
+ if !expires.IsZero() {
+ params = append(params, "expires=?")
+ args = append(args, expires)
+ }
+ args = append(args, tenantID, store.DecodeUid(uid), scheme)
+
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ resp, err := a.db.ExecContext(ctx, "UPDATE auth SET "+strings.Join(params, ",")+
+ " WHERE tenant_id=? AND userid=? AND scheme=?", args...)
+ if isDupe(err) {
+ return t.ErrDuplicate
+ }
+ if err != nil {
+ return err
+ }
+ if count, _ := resp.RowsAffected(); count <= 0 {
+ return t.ErrNotFound
+ }
+ return nil
+}
+
+// TenantAuthGetRecord retrieves a user's authentication record in one tenant.
+func (a *adapter) TenantAuthGetRecord(tenantID t.TenantID, uid t.Uid, scheme string) (string,
+ auth.Level, []byte, time.Time, error) {
+ var expires time.Time
+ if tenantID.IsZero() {
+ return "", auth.LevelNone, nil, expires, t.ErrMalformed
+ }
+ var record struct {
+ Uname string
+ Authlvl auth.Level
+ Secret []byte
+ Expires *time.Time
+ }
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ err := a.db.GetContext(ctx, &record,
+ "SELECT uname,secret,expires,authlvl FROM auth WHERE tenant_id=? AND userid=? AND scheme=?",
+ tenantID, store.DecodeUid(uid), scheme)
+ if err == sql.ErrNoRows {
+ err = t.ErrNotFound
+ }
+ if err != nil {
+ return "", auth.LevelNone, nil, expires, err
+ }
+ if record.Expires != nil {
+ expires = *record.Expires
+ }
+ return record.Uname, record.Authlvl, record.Secret, expires, nil
+}
+
+// TenantAuthGetUniqueRecord retrieves an authentication record by tenant-local login.
+func (a *adapter) TenantAuthGetUniqueRecord(tenantID t.TenantID, unique string) (t.Uid,
+ auth.Level, []byte, time.Time, error) {
+ var expires time.Time
+ if tenantID.IsZero() {
+ return t.ZeroUid, auth.LevelNone, nil, expires, t.ErrMalformed
+ }
+ var record struct {
+ Userid int64
+ Authlvl auth.Level
+ Secret []byte
+ Expires *time.Time
+ }
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ err := a.db.GetContext(ctx, &record,
+ "SELECT userid,secret,expires,authlvl FROM auth WHERE tenant_id=? AND uname=?", tenantID, unique)
+ if err == sql.ErrNoRows {
+ return t.ZeroUid, auth.LevelNone, nil, expires, nil
+ }
+ if err != nil {
+ return t.ZeroUid, auth.LevelNone, nil, expires, err
+ }
+ if record.Expires != nil {
+ expires = *record.Expires
+ }
+ return store.EncodeUid(record.Userid), record.Authlvl, record.Secret, expires, nil
+}
+
+// Add user's authentication record
+func (a *adapter) AuthAddRecord(uid t.Uid, scheme, unique string, authLvl auth.Level,
+ secret []byte, expires time.Time) error {
+
+ var exp *time.Time
+ if !expires.IsZero() {
+ exp = &expires
+ }
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+
+ if _, err := a.db.ExecContext(ctx, "INSERT INTO auth(uname,userid,scheme,authLvl,secret,expires) VALUES(?,?,?,?,?,?)",
+ unique, store.DecodeUid(uid), scheme, authLvl, secret, exp); err != nil {
+ if isDupe(err) {
+ return t.ErrDuplicate
+ }
+ return err
+ }
+ return nil
+}
+
+// AuthDelScheme deletes an existing authentication scheme for the user.
+func (a *adapter) AuthDelScheme(user t.Uid, scheme string) error {
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ _, err := a.db.ExecContext(ctx, "DELETE FROM auth WHERE userid=? AND scheme=?", store.DecodeUid(user), scheme)
+ return err
+}
+
+// AuthDelAllRecords deletes all authentication records for the user.
+func (a *adapter) AuthDelAllRecords(user t.Uid) (int, error) {
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ res, err := a.db.ExecContext(ctx, "DELETE FROM auth WHERE userid=?", store.DecodeUid(user))
+ if err != nil {
+ return 0, err
+ }
+ count, _ := res.RowsAffected()
+
+ return int(count), nil
+}
+
+// Update user's authentication unique, secret, auth level.
+func (a *adapter) AuthUpdRecord(uid t.Uid, scheme, unique string, authLvl auth.Level,
+ secret []byte, expires time.Time) error {
+
+ params := []string{"authLvl=?"}
+ args := []any{authLvl}
+
+ if unique != "" {
+ params = append(params, "uname=?")
+ args = append(args, unique)
+ }
+ if len(secret) > 0 {
+ params = append(params, "secret=?")
+ args = append(args, secret)
+ }
+ if !expires.IsZero() {
+ params = append(params, "expires=?")
+ args = append(args, expires)
+ }
+ args = append(args, store.DecodeUid(uid), scheme)
+
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ sql := "UPDATE auth SET " + strings.Join(params, ",") + " WHERE userid=? AND scheme=?"
+ resp, err := a.db.ExecContext(ctx, sql, args...)
+ if isDupe(err) {
+ return t.ErrDuplicate
+ }
+
+ if count, _ := resp.RowsAffected(); count <= 0 {
+ return t.ErrNotFound
+ }
+
+ return err
+}
+
+// Retrieve user's authentication record
+func (a *adapter) AuthGetRecord(uid t.Uid, scheme string) (string, auth.Level, []byte, time.Time, error) {
+ var expires time.Time
+
+ var record struct {
+ Uname string
+ Authlvl auth.Level
+ Secret []byte
+ Expires *time.Time
+ }
+
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ if err := a.db.GetContext(ctx, &record, "SELECT uname,secret,expires,authlvl FROM auth WHERE userid=? AND scheme=?",
+ store.DecodeUid(uid), scheme); err != nil {
+ if err == sql.ErrNoRows {
+ // Nothing found - use standard error.
+ err = t.ErrNotFound
+ }
+ return "", 0, nil, expires, err
+ }
+
+ if record.Expires != nil {
+ expires = *record.Expires
+ }
+
+ return record.Uname, record.Authlvl, record.Secret, expires, nil
+}
+
+// Retrieve user's authentication record
+func (a *adapter) AuthGetUniqueRecord(unique string) (t.Uid, auth.Level, []byte, time.Time, error) {
+ var expires time.Time
+
+ var record struct {
+ Userid int64
+ Authlvl auth.Level
+ Secret []byte
+ Expires *time.Time
+ }
+
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ if err := a.db.GetContext(ctx, &record, "SELECT userid,secret,expires,authlvl FROM auth WHERE uname=?", unique); err != nil {
+ if err == sql.ErrNoRows {
+ // Nothing found - clear the error
+ err = nil
+ }
+ return t.ZeroUid, 0, nil, expires, err
+ }
+
+ if record.Expires != nil {
+ expires = *record.Expires
+ }
+
+ return store.EncodeUid(record.Userid), record.Authlvl, record.Secret, expires, nil
+}
+
+// UserGet fetches a single user by user id. If user is not found it returns (nil, nil)
+func (a *adapter) UserGet(uid t.Uid) (*t.User, error) {
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ var user t.User
+ err := a.db.GetContext(ctx, &user, "SELECT * FROM users WHERE id=? AND state!=?", store.DecodeUid(uid), t.StateDeleted)
+ if err == nil {
+ user.SetUid(uid)
+ user.Public = common.FromJSON(user.Public)
+ user.Trusted = common.FromJSON(user.Trusted)
+ return &user, nil
+ }
+
+ if err == sql.ErrNoRows {
+ // Clear the error if user does not exist or marked as soft-deleted.
+ return nil, nil
+ }
+
+ return nil, err
+}
+
+func (a *adapter) UserGetAll(ids ...t.Uid) ([]t.User, error) {
+ uids := make([]any, len(ids))
+ for i, id := range ids {
+ if id.IsZero() {
+ continue
+ }
+ uids[i] = store.DecodeUid(id)
+ }
+
+ users := []t.User{}
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ q, uids, _ := sqlx.In("SELECT * FROM users WHERE id IN (?) AND state!=?", uids, t.StateDeleted)
+ rows, err := a.db.QueryxContext(ctx, a.db.Rebind(q), uids...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var user t.User
+ if err = rows.StructScan(&user); err != nil {
+ users = nil
+ break
+ }
+ user.SetUid(common.EncodeUidString(user.Id))
+ user.Public = common.FromJSON(user.Public)
+ user.Trusted = common.FromJSON(user.Trusted)
+
+ users = append(users, user)
+ }
+ if err == nil {
+ err = rows.Err()
+ }
+
+ return users, err
+}
+
+// UserDelete deletes specified user: wipes completely (hard-delete) or marks as deleted.
+// TODO: report when the user is not found.
+func (a *adapter) UserDelete(uid t.Uid, hard bool) error {
+ query := "SELECT name FROM topics WHERE owner=?"
+ args := []any{store.DecodeUid(uid)}
+ // In case of hard delete, delete all topics, even those which were
+ // soft-deleted previsously.
+ if !hard {
+ query += " AND state!=?"
+ args = append(args, t.StateDeleted)
+ }
+ // Get a list of topic names owned by the user (as 'grp' and 'chn').
+ ownTopics, err := a.topicNamesForUser(query, true, args...)
+ if err != nil {
+ return err
+ }
+
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTxx(ctx, nil)
+ if err != nil {
+ return err
+ }
+
+ defer func() {
+ if err != nil {
+ tx.Rollback()
+ }
+ }()
+
+ now := t.TimeNow()
+ decoded_uid := store.DecodeUid(uid)
+
+ if hard {
+ // Delete user's devices
+ // t.ErrNotFound = user has no devices.
+ if err = deviceDelete(tx, uid, ""); err != nil && err != t.ErrNotFound {
+ return err
+ }
+
+ // Delete user's subscriptions in all topics.
+ if err = subsDelForUser(tx, decoded_uid, true); err != nil {
+ return err
+ }
+
+ // Delete records of messages soft-deleted for the user in all topics.
+ if _, err = tx.Exec("DELETE FROM dellog WHERE deletedfor=?", decoded_uid); err != nil {
+ return err
+ }
+
+ // Can't delete user's messages in all topics because we cannot notify topics of such deletion.
+ // Just leave the messages there marked as sent by "not found" user.
+
+ // Delete topics where the user is the owner.
+ if len(ownTopics) > 0 {
+ // First delete all messages in those topics.
+ if _, err = tx.Exec("DELETE dellog FROM dellog JOIN topics ON topics.name=dellog.topic WHERE topics.owner=?",
+ decoded_uid); err != nil {
+ return err
+ }
+
+ // Deletion of messages will cascade to filemsglinks and so to fileuploads.
+ if _, err = tx.Exec("DELETE messages FROM messages JOIN topics ON topics.name=messages.topic WHERE topics.owner=?",
+ decoded_uid); err != nil {
+ return err
+ }
+
+ // Delete subscriptions for all users where the user is the owner of the topic.
+ sql, args, _ := sqlx.In("DELETE FROM subscriptions AS s WHERE topic IN (?)", ownTopics)
+ if _, err = tx.Exec(tx.Rebind(sql), args); err != nil {
+ return err
+ }
+
+ // Delete topic tags.
+ if _, err = tx.Exec("DELETE tt FROM topictags AS tt JOIN topics AS t ON t.name=tt.topic WHERE t.owner=?",
+ decoded_uid); err != nil {
+ return err
+ }
+
+ // And finally delete the topics.
+ if _, err = tx.Exec("DELETE FROM topics WHERE owner=?", decoded_uid); err != nil {
+ return err
+ }
+ }
+
+ // Delete user's authentication records.
+ if _, err = tx.Exec("DELETE FROM auth WHERE userid=?", decoded_uid); err != nil {
+ return err
+ }
+
+ // Delete all credentials.
+ if err = credDel(tx, uid, "", ""); err != nil && err != t.ErrNotFound {
+ return err
+ }
+
+ if _, err = tx.Exec("DELETE FROM usertags WHERE userid=?", decoded_uid); err != nil {
+ return err
+ }
+
+ if _, err = tx.Exec("DELETE FROM users WHERE id=?", decoded_uid); err != nil {
+ return err
+ }
+ } else {
+ // Disable all user's subscriptions. That includes p2p subscriptions. No need to delete them.
+ if err = subsDelForUser(tx, decoded_uid, false); err != nil {
+ return err
+ }
+
+ if len(ownTopics) > 0 {
+ // Disable all subscriptions to topics where the user is the owner.
+ sql, args, _ := sqlx.In("UPDATE subscriptions SET updatedat=?,deletedat=? WHERE topic IN (?)", now, now, ownTopics)
+ if _, err = tx.Exec(tx.Rebind(sql), args...); err != nil {
+ return err
+ }
+ }
+
+ // Disable group topics where the user is the owner.
+ if _, err = tx.Exec("UPDATE topics SET updatedat=?,touchedat=?,state=?,stateat=? WHERE owner=?",
+ now, now, t.StateDeleted, now, decoded_uid); err != nil {
+ return err
+ }
+
+ // Disable p2p topics with the user (p2p topic's owner is 0).
+ if _, err = tx.Exec("UPDATE topics AS t JOIN subscriptions AS s ON t.name=s.topic "+
+ "SET t.updatedat=?,t.touchedat=?,t.state=?,t.stateat=? "+
+ "WHERE t.owner=0 AND s.userid=? AND t.name LIKE 'p2p%'",
+ now, now, t.StateDeleted, now, decoded_uid); err != nil {
+ return err
+ }
+
+ // Disable the other user's subscription to a disabled p2p topic.
+ if _, err = tx.Exec("UPDATE subscriptions AS s_one JOIN subscriptions AS s_two "+
+ "ON s_one.topic=s_two.topic "+
+ "SET s_two.updatedat=?, s_two.deletedat=? WHERE s_one.userid=? AND s_one.topic LIKE 'p2p%'",
+ now, now, decoded_uid); err != nil {
+ return err
+ }
+
+ // Finally disable user.
+ if _, err = tx.Exec("UPDATE users SET updatedat=?,state=?,stateat=? WHERE id=?",
+ now, t.StateDeleted, now, decoded_uid); err != nil {
+ return err
+ }
+ }
+
+ return tx.Commit()
+}
+
+// topicStateForUser is called by UserUpdate when the update contains state change.
+// Soft-deleted topics remain soft-deleted.
+func (a *adapter) topicStateForUser(tx *sqlx.Tx, decoded_uid int64, now time.Time, update any) error {
+ var err error
+
+ state, ok := update.(t.ObjState)
+ if !ok {
+ return t.ErrMalformed
+ }
+
+ if now.IsZero() {
+ now = t.TimeNow()
+ }
+
+ // Change state of all topics where the user is the owner.
+ if _, err = tx.Exec("UPDATE topics SET state=?, stateat=? WHERE owner=? AND state!=?",
+ state, now, decoded_uid, t.StateDeleted); err != nil {
+ return err
+ }
+
+ // Change state of p2p topics with the user (p2p topic's owner is 0)
+ if _, err = tx.Exec("UPDATE topics JOIN subscriptions ON topics.name=subscriptions.topic "+
+ "SET topics.state=?, topics.stateat=? WHERE topics.owner=0 AND subscriptions.userid=? AND topics.state!=?",
+ state, now, decoded_uid, t.StateDeleted); err != nil {
+ return err
+ }
+
+ // Subscriptions don't need to be updated:
+ // subscriptions of a disabled user are not disabled and still can be manipulated.
+ return nil
+}
+
+// UserUpdate updates user object.
+func (a *adapter) UserUpdate(uid t.Uid, update map[string]any) error {
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTxx(ctx, nil)
+ if err != nil {
+ return err
+ }
+
+ defer func() {
+ if err != nil {
+ tx.Rollback()
+ }
+ }()
+
+ cols, args := common.UpdateByMap(update)
+ decoded_uid := store.DecodeUid(uid)
+ args = append(args, decoded_uid)
+ _, err = tx.Exec("UPDATE users SET "+strings.Join(cols, ",")+" WHERE id=?", args...)
+ if err != nil {
+ return err
+ }
+
+ if state, ok := update["State"]; ok {
+ now, _ := update["StateAt"].(time.Time)
+ err = a.topicStateForUser(tx, decoded_uid, now, state)
+ if err != nil {
+ return err
+ }
+ }
+
+ // Tags are also stored in a separate table
+ if tags := common.ExtractTags(update); tags != nil {
+ // First delete all user tags
+ _, err = tx.Exec("DELETE FROM usertags WHERE userid=?", decoded_uid)
+ if err != nil {
+ return err
+ }
+ // Now insert new tags
+ err = addTags(tx, "usertags", "userid", decoded_uid, tags, false)
+ if err != nil {
+ return err
+ }
+ }
+
+ return tx.Commit()
+}
+
+// UserUpdateTags adds, removes, or resets user's tags.
+func (a *adapter) UserUpdateTags(uid t.Uid, add, remove, reset []string) ([]string, error) {
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTxx(ctx, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ defer func() {
+ if err != nil {
+ tx.Rollback()
+ }
+ }()
+
+ decoded_uid := store.DecodeUid(uid)
+
+ if reset != nil {
+ // Delete all tags first if resetting.
+ _, err = tx.Exec("DELETE FROM usertags WHERE userid=?", decoded_uid)
+ if err != nil {
+ return nil, err
+ }
+ add = reset
+ remove = nil
+ }
+
+ // Now insert new tags. Ignore duplicates if resetting.
+ err = addTags(tx, "usertags", "userid", decoded_uid, add, reset == nil)
+ if err != nil {
+ return nil, err
+ }
+
+ // Delete tags.
+ err = removeTags(tx, "usertags", "userid", decoded_uid, remove)
+ if err != nil {
+ return nil, err
+ }
+
+ var allTags []string
+ err = tx.Select(&allTags, "SELECT tag FROM usertags WHERE userid=?", decoded_uid)
+ if err != nil {
+ return nil, err
+ }
+
+ _, err = tx.Exec("UPDATE users SET tags=? WHERE id=?", t.StringSlice(allTags), decoded_uid)
+ if err != nil {
+ return nil, err
+ }
+
+ return allTags, tx.Commit()
+}
+
+// UserGetByCred returns user ID for the given validated credential.
+func (a *adapter) UserGetByCred(method, value string) (t.Uid, error) {
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ var decoded_uid int64
+ err := a.db.GetContext(ctx, &decoded_uid, "SELECT userid FROM credentials WHERE synthetic=?", method+":"+value)
+ if err == nil {
+ return store.EncodeUid(decoded_uid), nil
+ }
+
+ if err == sql.ErrNoRows {
+ // Clear the error if user does not exist
+ return t.ZeroUid, nil
+ }
+ return t.ZeroUid, err
+}
+
+// TenantUserGetByCred returns a user ID for a validated credential in one tenant.
+func (a *adapter) TenantUserGetByCred(tenantID t.TenantID, method, value string) (t.Uid, error) {
+ if tenantID.IsZero() {
+ return t.ZeroUid, t.ErrMalformed
+ }
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ var decodedUID int64
+ err := a.db.GetContext(ctx, &decodedUID,
+ "SELECT userid FROM credentials WHERE tenant_id=? AND synthetic=?", tenantID, method+":"+value)
+ if err == sql.ErrNoRows {
+ return t.ZeroUid, nil
+ }
+ if err != nil {
+ return t.ZeroUid, err
+ }
+ return store.EncodeUid(decodedUID), nil
+}
+
+// UserUnreadCount returns the total number of unread messages in all topics with
+// the R permission. If read fails, the counts are still returned with the original
+// user IDs but with the unread count undefined and non-nil error.
+// UserUnreadCount does not count unread messages in channels although it should.
+func (a *adapter) UserUnreadCount(ids ...t.Uid) (map[t.Uid]int, error) {
+ uids := make([]any, len(ids))
+ counts := make(map[t.Uid]int, len(ids))
+ for i, id := range ids {
+ uids[i] = store.DecodeUid(id)
+ // Ensure all original uids are always present.
+ counts[id] = 0
+ }
+
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+
+ // FIXME: support channels (for channels subscriptions.topic != topics.name).
+ q, args, _ := sqlx.In("SELECT s.userid, SUM(t.seqid)-SUM(s.readseqid) AS unreadcount FROM topics AS t, subscriptions AS s "+
+ "WHERE s.userid IN (?) AND t.name=s.topic AND s.deletedat IS NULL AND t.state!=? AND "+
+ "INSTR(s.modewant, 'R')>0 AND INSTR(s.modegiven, 'R')>0 GROUP BY s.userid", uids, int(t.StateDeleted))
+ rows, err := a.db.QueryxContext(ctx, a.db.Rebind(q), args...)
+ if err != nil {
+ return counts, err
+ }
+ defer rows.Close()
+
+ var userId int64
+ var unreadCount int
+ for rows.Next() {
+ if err = rows.Scan(&userId, &unreadCount); err != nil {
+ break
+ }
+ counts[store.EncodeUid(userId)] = unreadCount
+ }
+ if err == nil {
+ err = rows.Err()
+ }
+
+ return counts, err
+}
+
+// UserGetUnvalidated returns a list of uids which have never logged in, have no
+// validated credentials and haven't been updated since lastUpdatedBefore.
+func (a *adapter) UserGetUnvalidated(lastUpdatedBefore time.Time, limit int) ([]t.Uid, error) {
+ var uids []t.Uid
+
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+
+ rows, err := a.db.QueryxContext(ctx,
+ "SELECT u.id, IFNULL(SUM(c.done),0) AS total FROM users AS u "+
+ "LEFT JOIN credentials AS c ON u.id=c.userid WHERE u.lastseen IS NULL AND u.updatedat "+
+ "GROUP BY u.id, u.updatedat HAVING total=0 ORDER BY u.updatedat ASC LIMIT ?", lastUpdatedBefore, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var userId int64
+ var unused int
+ if err = rows.Scan(&userId, &unused); err != nil {
+ break
+ }
+ uids = append(uids, store.EncodeUid(userId))
+ }
+ if err == nil {
+ err = rows.Err()
+ }
+
+ return uids, err
+}
+
+func (a *adapter) topicCreate(tx *sqlx.Tx, topic *t.Topic) error {
+ _, err := tx.Exec("INSERT INTO topics(createdat,updatedat,touchedat,state,name,usebt,owner,access,public,trusted,tags,aux) "+
+ "VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
+ topic.CreatedAt, topic.UpdatedAt, topic.TouchedAt, topic.State, topic.Id, topic.UseBt,
+ store.DecodeUid(t.ParseUid(topic.Owner)), topic.Access, common.ToJSON(topic.Public), common.ToJSON(topic.Trusted),
+ topic.Tags, common.ToJSON(topic.Aux))
+ if err != nil {
+ return err
+ }
+
+ // Save topic's tags to a separate table to make topic findable.
+ return addTags(tx, "topictags", "topic", topic.Id, topic.Tags, false)
+}
+
+// TopicCreate saves topic object to database.
+func (a *adapter) TopicCreate(topic *t.Topic) error {
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTxx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err != nil {
+ tx.Rollback()
+ }
+ }()
+
+ err = a.topicCreate(tx, topic)
+ if err != nil {
+ return err
+ }
+ return tx.Commit()
+}
+
+// If undelete = true - update subscription on duplicate key, otherwise ignore the duplicate.
+func createSubscription(tx *sqlx.Tx, sub *t.Subscription, undelete bool) error {
+ if sub == nil || sub.TenantID.IsZero() {
+ return t.ErrMalformed
+ }
+
+ isOwner := (sub.ModeGiven & sub.ModeWant).IsOwner()
+
+ jpriv := common.ToJSON(sub.Private)
+ decoded_uid := store.DecodeUid(t.ParseUid(sub.User))
+ _, err := tx.Exec(
+ "INSERT INTO subscriptions(tenant_id,createdat,updatedat,deletedat,userid,topic,modeWant,modeGiven,private) "+
+ "VALUES(?,?,?,NULL,?,?,?,?,?)",
+ sub.TenantID, sub.CreatedAt, sub.UpdatedAt, decoded_uid, sub.Topic, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv)
+
+ if err != nil && isDupe(err) {
+ if undelete {
+ _, err = tx.Exec("UPDATE subscriptions SET createdat=?,updatedat=?,deletedat=NULL,modeWant=?,modeGiven=?,"+
+ "delid=0,recvseqid=0,readseqid=0 WHERE tenant_id=? AND topic=? AND userid=?",
+ sub.CreatedAt, sub.UpdatedAt, sub.ModeWant.String(), sub.ModeGiven.String(), sub.TenantID,
+ sub.Topic, decoded_uid)
+ } else {
+ _, err = tx.Exec("UPDATE subscriptions SET createdat=?,updatedat=?,deletedat=NULL,modeWant=?,modeGiven=?,"+
+ "delid=0,recvseqid=0,readseqid=0,private=? WHERE tenant_id=? AND topic=? AND userid=?",
+ sub.CreatedAt, sub.UpdatedAt, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv,
+ sub.TenantID, sub.Topic, decoded_uid)
+ }
+ }
+
+ if err == nil && isOwner {
+ // Update topic owner if the subscription is with owner rights.
+ // Don't increment subscriber count here - it's done in TopicShare in bulk.
+ _, err = tx.Exec("UPDATE topics SET owner=? WHERE tenant_id=? AND name=?", decoded_uid, sub.TenantID, sub.Topic)
+ }
+ return err
+}
+
+// TopicCreateP2P given two users creates a p2p topic.
+func (a *adapter) TopicCreateP2P(initiator, invited *t.Subscription) error {
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTxx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err != nil {
+ tx.Rollback()
+ }
+ }()
+
+ err = createSubscription(tx, initiator, false)
+ if err != nil {
+ return err
+ }
+
+ // If the second subscription exists, don't overwrite it. Just make sure it's not deleted.
+ err = createSubscription(tx, invited, true)
+ if err != nil {
+ return err
+ }
+
+ topic := &t.Topic{ObjHeader: t.ObjHeader{Id: initiator.Topic}}
+ topic.ObjHeader.MergeTimes(&initiator.ObjHeader)
+ topic.TouchedAt = initiator.GetTouchedAt()
+ err = a.topicCreate(tx, topic)
+ if err != nil {
+ return err
+ }
+
+ return tx.Commit()
+}
+
+// TopicGet loads a single topic by name, if it exists. If the topic does not exist the call returns (nil, nil)
+func (a *adapter) TopicGet(topic string) (*t.Topic, error) {
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+
+ // Fetch topic by name
+ var tt = new(t.Topic)
+ if err := a.db.GetContext(ctx, tt,
+ "SELECT createdat,updatedat,state,stateat,touchedat,name AS id,usebt,access,owner,seqid,delid,subcnt,public,trusted,tags,aux "+
+ "FROM topics WHERE name=?", topic); err != nil {
+ if err == sql.ErrNoRows {
+ // Nothing found - clear the error
+ err = nil
+ }
+ return nil, err
+ }
+
+ if t.GetTopicCat(topic) == t.TopicCatGrp {
+ // Topic found, get subsription count (ignoring the value set in topics.subcnt). Try both topic and channel names.
+ var subCnt int
+ if err := a.db.GetContext(ctx, &subCnt,
+ "SELECT COUNT(*) FROM subscriptions WHERE topic IN (?,?) AND deletedat IS NULL", topic, t.GrpToChn(topic)); err != nil {
+ return nil, err
+ }
+
+ if subCnt != tt.SubCnt {
+ // Update the topic with the correct subscription count.
+ tt.SubCnt = subCnt
+ if _, err := a.db.ExecContext(ctx, "UPDATE topics SET subcnt=? WHERE name=?", subCnt, topic); err != nil {
+ return nil, err
+ }
+ }
+ }
+
+ tt.Owner = common.EncodeUidString(tt.Owner).String()
+ tt.Public = common.FromJSON(tt.Public)
+ tt.Trusted = common.FromJSON(tt.Trusted)
+
+ return tt, nil
+}
+
+// TopicsForUser loads user's contact list: p2p and grp topics, except for 'me' & 'fnd' subscriptions.
+// Reads and denormalizes Public & Trusted values.
+func (a *adapter) TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) {
+ // Fetch ALL user's subscriptions, even those which has not been modified recently.
+ // We are going to use these subscriptions to fetch topics and users which may have been modified recently.
+ q := `SELECT createdat,updatedat,deletedat,topic,delid,recvseqid,
+ readseqid,modewant,modegiven,private FROM subscriptions WHERE userid=?`
+ args := []any{store.DecodeUid(uid)}
+ if !keepDeleted {
+ // Filter out deleted rows.
+ q += " AND deletedat IS NULL"
+ }
+
+ limit := 0
+ ims := time.Time{}
+ if opts != nil {
+ if opts.Topic != "" {
+ q += " AND topic=?"
+ args = append(args, opts.Topic)
+ }
+
+ // Apply the limit only when the client does not manage the cache (or cold start).
+ // Otherwise have to get all subscriptions and do a manual join with users/topics.
+ if opts.IfModifiedSince == nil {
+ if opts.Limit > 0 && opts.Limit < a.maxResults {
+ limit = opts.Limit
+ } else {
+ limit = a.maxResults
+ }
+ } else {
+ ims = *opts.IfModifiedSince
+ }
+ } else {
+ limit = a.maxResults
+ }
+
+ if limit > 0 {
+ q += " LIMIT ?"
+ args = append(args, limit)
+ }
+
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ rows, err := a.db.QueryxContext(ctx, q, args...)
+ if err != nil {
+ return nil, err
+ }
+ // Must close rows manually as we will be reusing it.
+
+ // Fetch subscriptions. Two queries are needed: users table (p2p) and topics table (grp).
+ // Prepare a list of separate subscriptions to users vs topics
+ join := make(map[string]t.Subscription) // Keeping these to make a join with table for .private and .access
+ topq := make([]any, 0, 16)
+ usrq := make([]any, 0, 16)
+ for rows.Next() {
+ var sub t.Subscription
+ if err = rows.StructScan(&sub); err != nil {
+ break
+ }
+ tname := sub.Topic
+ sub.User = uid.String()
+ tcat := t.GetTopicCat(tname)
+
+ if tcat == t.TopicCatMe || tcat == t.TopicCatFnd {
+ // One of 'me', 'fnd' subscriptions, skip.
+ // Don't skip 'sys' subscription.
+ continue
+ } else if tcat == t.TopicCatP2P {
+ // P2P subscription, find the other user to get user.Public and user.Trusted.
+ uid1, uid2, _ := t.ParseP2P(tname)
+ if uid1 == uid {
+ usrq = append(usrq, store.DecodeUid(uid2))
+ sub.SetWith(uid2.UserId())
+ } else {
+ usrq = append(usrq, store.DecodeUid(uid1))
+ sub.SetWith(uid1.UserId())
+ }
+ } else if tcat == t.TopicCatGrp {
+ // Maybe convert channel name to group topic name.
+ tname = t.ChnToGrp(tname)
+ }
+ // No special handling needed for 'slf', 'sys' subscriptions.
+
+ topq = append(topq, tname)
+ sub.Private = common.FromJSON(sub.Private)
+ join[tname] = sub
+ }
+ if err == nil {
+ err = rows.Err()
+ }
+ rows.Close()
+
+ if err != nil {
+ return nil, err
+ }
+
+ var subs []t.Subscription
+ if len(join) == 0 {
+ return subs, nil
+ }
+
+ // Fetch grp topics and join to subscriptions.
+ if len(topq) > 0 {
+ q = "SELECT updatedat,state,touchedat,name AS id,usebt,access,seqid,delid,subcnt,public,trusted " +
+ "FROM topics WHERE name IN (?)"
+ q, args, _ = sqlx.In(q, topq)
+
+ if !keepDeleted {
+ // Optionally skip deleted topics.
+ q += " AND state!=?"
+ args = append(args, t.StateDeleted)
+ }
+
+ if !ims.IsZero() {
+ // Use cache timestamp if provided: get newer entries only.
+ q += " AND touchedat>?"
+ args = append(args, ims)
+
+ if limit > 0 && limit < len(topq) {
+ // No point in fetching more than the requested limit.
+ q += " ORDER BY touchedat LIMIT ?"
+ args = append(args, limit)
+ }
+ }
+
+ ctx2, cancel2 := a.getContext()
+ if cancel2 != nil {
+ defer cancel2()
+ }
+ rows, err = a.db.QueryxContext(ctx2, a.db.Rebind(q), args...)
+ if err != nil {
+ return nil, err
+ }
+
+ var top t.Topic
+ for rows.Next() {
+ if err = rows.StructScan(&top); err != nil {
+ break
+ }
+ sub := join[top.Id]
+ // Check if sub.UpdatedAt needs to be adjusted to earlier or later time.
+ sub.UpdatedAt = common.SelectLatestTime(sub.UpdatedAt, top.UpdatedAt)
+ sub.SetState(top.State)
+ sub.SetTouchedAt(top.TouchedAt)
+ sub.SetSeqId(top.SeqId)
+ if t.GetTopicCat(sub.Topic) == t.TopicCatGrp {
+ sub.SetSubCnt(top.SubCnt)
+ sub.SetPublic(common.FromJSON(top.Public))
+ sub.SetTrusted(common.FromJSON(top.Trusted))
+ }
+ // Put back the updated value of a subsription, will process further below
+ join[top.Id] = sub
+ }
+ if err == nil {
+ err = rows.Err()
+ }
+ rows.Close()
+
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ // Fetch p2p users and join to p2p subscriptions.
+ if len(usrq) > 0 {
+ q = "SELECT id,updatedat,state,access,lastseen,useragent,public,trusted " +
+ "FROM users WHERE id IN (?)"
+ q, args, _ = sqlx.In(q, usrq)
+ if !keepDeleted {
+ // Optionally skip deleted users.
+ q += " AND state!=?"
+ args = append(args, t.StateDeleted)
+ }
+
+ // Ignoring ims: we need all users to get LastSeen and UserAgent.
+
+ ctx3, cancel3 := a.getContext()
+ if cancel3 != nil {
+ defer cancel3()
+ }
+ rows, err = a.db.QueryxContext(ctx3, a.db.Rebind(q), args...)
+ if err != nil {
+ return nil, err
+ }
+
+ for rows.Next() {
+ var usr2 t.User
+ if err = rows.StructScan(&usr2); err != nil {
+ break
+ }
+
+ joinOn := uid.P2PName(common.EncodeUidString(usr2.Id))
+ if sub, ok := join[joinOn]; ok {
+ sub.UpdatedAt = common.SelectLatestTime(sub.UpdatedAt, usr2.UpdatedAt)
+ sub.SetState(usr2.State)
+ sub.SetPublic(common.FromJSON(usr2.Public))
+ sub.SetTrusted(common.FromJSON(usr2.Trusted))
+ sub.SetDefaultAccess(usr2.Access.Auth, usr2.Access.Anon)
+ sub.SetLastSeenAndUA(usr2.LastSeen, usr2.UserAgent)
+ join[joinOn] = sub
+ }
+ }
+ if err == nil {
+ err = rows.Err()
+ }
+ rows.Close()
+
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ subs = make([]t.Subscription, 0, len(join))
+ for _, sub := range join {
+ subs = append(subs, sub)
+ }
+
+ return common.SelectEarliestUpdatedSubs(subs, opts, a.maxResults), nil
+}
+
+// UsersForTopic loads users subscribed to the given topic (not channel readers).
+// The difference between UsersForTopic vs SubsForTopic is that the former loads user.Public,
+// the latter does not.
+func (a *adapter) UsersForTopic(topic string, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) {
+ tcat := t.GetTopicCat(topic)
+
+ // Fetch all subscribed users. The number of users is not large.
+ q := `SELECT s.createdat,s.updatedat,s.deletedat,s.userid,s.topic,s.delid,s.recvseqid,
+ s.readseqid,s.modewant,s.modegiven,u.public,u.trusted,u.lastseen,u.useragent,s.private
+ FROM subscriptions AS s JOIN users AS u ON s.userid=u.id
+ WHERE s.topic=?`
+ args := []any{topic}
+ if !keepDeleted {
+ // Filter out rows with users deleted
+ q += " AND u.state!=?"
+ args = append(args, t.StateDeleted)
+
+ // For p2p topics we must load all subscriptions including deleted.
+ // Otherwise it will be impossible to swipe Public values.
+ if tcat != t.TopicCatP2P {
+ // Filter out deleted subscriptions.
+ q += " AND s.deletedat IS NULL"
+ }
+ }
+
+ limit := a.maxResults
+ var oneUser t.Uid
+ if opts != nil {
+ // Ignore IfModifiedSince: loading all entries because a topic cannot have too many subscribers.
+ // Those unmodified will be stripped of Public & Private.
+
+ if !opts.User.IsZero() {
+ // For p2p topics we have to fetch both users otherwise public cannot be swapped.
+ if tcat != t.TopicCatP2P {
+ q += " AND s.userid=?"
+ args = append(args, store.DecodeUid(opts.User))
+ }
+ oneUser = opts.User
+ }
+ if opts.Limit > 0 && opts.Limit < limit {
+ limit = opts.Limit
+ }
+ }
+ q += " LIMIT ?"
+ args = append(args, limit)
+
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ rows, err := a.db.QueryxContext(ctx, q, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ // Fetch subscriptions.
+ var sub t.Subscription
+ var subs []t.Subscription
+ var lastSeen sql.NullTime
+ var userAgent string
+ var public, trusted any
+ for rows.Next() {
+ if err = rows.Scan(
+ &sub.CreatedAt, &sub.UpdatedAt, &sub.DeletedAt,
+ &sub.User, &sub.Topic, &sub.DelId, &sub.RecvSeqId,
+ &sub.ReadSeqId, &sub.ModeWant, &sub.ModeGiven,
+ &public, &trusted, &lastSeen, &userAgent, &sub.Private); err != nil {
+ break
+ }
+
+ sub.User = common.EncodeUidString(sub.User).String()
+ sub.Private = common.FromJSON(sub.Private)
+ sub.SetPublic(common.FromJSON(public))
+ sub.SetTrusted(common.FromJSON(trusted))
+ sub.SetLastSeenAndUA(&lastSeen.Time, userAgent)
+ subs = append(subs, sub)
+ }
+ if err == nil {
+ err = rows.Err()
+ }
+
+ if err == nil && tcat == t.TopicCatP2P && len(subs) > 0 {
+ // Swap public & lastSeen values of P2P topics as expected.
+ if len(subs) == 1 {
+ // The other user is deleted, nothing we can do.
+ subs[0].SetPublic(nil)
+ subs[0].SetTrusted(nil)
+ subs[0].SetLastSeenAndUA(nil, "")
+ } else {
+ tmp := subs[0].GetPublic()
+ subs[0].SetPublic(subs[1].GetPublic())
+ subs[1].SetPublic(tmp)
+
+ tmp = subs[0].GetTrusted()
+ subs[0].SetTrusted(subs[1].GetTrusted())
+ subs[1].SetTrusted(tmp)
+
+ lastSeen := subs[0].GetLastSeen()
+ userAgent = subs[0].GetUserAgent()
+ subs[0].SetLastSeenAndUA(subs[1].GetLastSeen(), subs[1].GetUserAgent())
+ subs[1].SetLastSeenAndUA(lastSeen, userAgent)
+ }
+
+ // Remove deleted and unneeded subscriptions
+ if !keepDeleted || !oneUser.IsZero() {
+ var xsubs []t.Subscription
+ for i := range subs {
+ if (subs[i].DeletedAt != nil && !keepDeleted) || (!oneUser.IsZero() && subs[i].Uid() != oneUser) {
+ continue
+ }
+ xsubs = append(xsubs, subs[i])
+ }
+ subs = xsubs
+ }
+ }
+
+ return subs, err
+}
+
+// topicNamesForUser reads a slice of strings using provided query.
+// if includeChan is true, the query is expected to add channel names as well as group topic names.
+func (a *adapter) topicNamesForUser(sqlQuery string, includeChan bool, args ...any) ([]string, error) {
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ rows, err := a.db.QueryxContext(ctx, sqlQuery, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var names []string
+ for rows.Next() {
+ var name string
+ if err = rows.Scan(&name); err != nil {
+ break
+ }
+ names = append(names, name)
+ // If the name is a group topic, also add the channel name if requested.
+ if includeChan {
+ if channel := t.GrpToChn(name); channel != "" {
+ names = append(names, channel)
+ }
+ }
+ }
+ if err == nil {
+ err = rows.Err()
+ }
+
+ return names, err
+}
+
+// OwnTopics loads a slice of topic names where the user is the owner.
+func (a *adapter) OwnTopics(uid t.Uid) ([]string, error) {
+ return a.topicNamesForUser("SELECT name FROM topics WHERE owner=? AND state!=?",
+ false, store.DecodeUid(uid), t.StateDeleted)
+}
+
+// ChannelsForUser loads a slice of topic names where the user is a channel reader and notifications (P) are enabled.
+func (a *adapter) ChannelsForUser(uid t.Uid) ([]string, error) {
+ return a.topicNamesForUser("SELECT topic FROM subscriptions WHERE userid=? AND topic LIKE 'chn%' "+
+ "AND INSTR(modewant,'P')>0 AND INSTR(modegiven,'P')>0 AND deletedat IS NULL",
+ false, store.DecodeUid(uid))
+}
+
+// TopicShare adds subscriptions to a topic and increments the topic's subcnt.
+func (a *adapter) TopicShare(topic string, shares []*t.Subscription) error {
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTxx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err != nil {
+ tx.Rollback()
+ }
+ }()
+
+ for _, sub := range shares {
+ err = createSubscription(tx, sub, true)
+ if err != nil {
+ return err
+ }
+ }
+
+ if topic != "" {
+ // Update topic's subscription count.
+ if _, err = tx.Exec("UPDATE topics SET subcnt=subcnt+? WHERE name=?", len(shares), topic); err != nil {
+ return err
+ }
+ }
+
+ return tx.Commit()
+}
+
+// TopicDelete deletes topic, subscriptions, messages.
+func (a *adapter) TopicDelete(topic string, isChan, hard bool) error {
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTxx(ctx, nil)
+ if err != nil {
+ return err
+ }
+
+ defer func() {
+ if err != nil {
+ tx.Rollback()
+ }
+ }()
+
+ // If the topic is a channel, must try to delete subscriptions under both grpXXX and chnXXX names.
+ args := []any{topic}
+ if isChan {
+ args = append(args, t.GrpToChn(topic))
+ }
+
+ if hard {
+ // Delete subscriptions. If this is a channel, delete both group subscriptions and channel subscriptions.
+ q, args, _ := sqlx.In("DELETE FROM subscriptions WHERE topic IN (?)", args)
+ if _, err = tx.Exec(tx.Rebind(q), args...); err != nil {
+ return err
+ }
+
+ if err = messageDeleteList(tx, topic, nil); err != nil {
+ return err
+ }
+
+ if _, err = tx.Exec("DELETE FROM topictags WHERE topic=?", topic); err != nil {
+ return err
+ }
+
+ if _, err = tx.Exec("DELETE FROM topics WHERE name=?", topic); err != nil {
+ return err
+ }
+ } else {
+ now := t.TimeNow()
+
+ q, args, _ := sqlx.In("UPDATE subscriptions SET updatedat=?,deletedat=? WHERE topic IN (?)", now, now, args)
+ if _, err = tx.Exec(tx.Rebind(q), args...); err != nil {
+ return err
+ }
+
+ if _, err = tx.Exec("UPDATE topics SET updatedat=?,touchedat=?,state=?,stateat=? WHERE name=?",
+ now, now, t.StateDeleted, now, topic); err != nil {
+ return err
+ }
+ }
+ return tx.Commit()
+}
+
+// TopicUpdateOnMessage updates topic's seqid and touchedat when a new message is posted.
+func (a *adapter) TopicUpdateOnMessage(topic string, msg *t.Message) error {
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ _, err := a.db.ExecContext(ctx, "UPDATE topics SET seqid=?,touchedat=? WHERE name=?", msg.SeqId, msg.CreatedAt, topic)
+
+ return err
+}
+
+// TopicUpdateSubCnt updates subscriber count denormalized in topic.
+func (a *adapter) TopicUpdateSubCnt(topic string) error {
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ _, err := a.db.ExecContext(ctx,
+ "UPDATE topics SET subcnt=(SELECT COUNT(*) FROM subscriptions WHERE topic IN (?,?) AND deletedat IS NULL) WHERE name=?",
+ topic, t.GrpToChn(topic), topic)
+ return err
+}
+
+// TopicUpdate updates topic's fields given in the update map.
+// If update contains UpdatedAt but not TouchedAt, TouchedAt is set to Updated
+func (a *adapter) TopicUpdate(topic string, update map[string]any) error {
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTxx(ctx, nil)
+ if err != nil {
+ return err
+ }
+
+ defer func() {
+ if err != nil {
+ tx.Rollback()
+ }
+ }()
+
+ if t, u := update["TouchedAt"], update["UpdatedAt"]; t == nil && u != nil {
+ update["TouchedAt"] = u
+ }
+ cols, args := common.UpdateByMap(update)
+ args = append(args, topic)
+ _, err = tx.Exec("UPDATE topics SET "+strings.Join(cols, ",")+" WHERE name=?", args...)
+ if err != nil {
+ return err
+ }
+
+ // Tags are also stored in a separate table
+ if tags := common.ExtractTags(update); tags != nil {
+ // First delete all user tags
+ _, err = tx.Exec("DELETE FROM topictags WHERE topic=?", topic)
+ if err != nil {
+ return err
+ }
+ // Now insert new tags
+ err = addTags(tx, "topictags", "topic", topic, tags, false)
+ if err != nil {
+ return err
+ }
+ }
+
+ return tx.Commit()
+}
+
+func (a *adapter) TopicOwnerChange(topic string, newOwner t.Uid) error {
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ _, err := a.db.ExecContext(ctx, "UPDATE topics SET owner=? WHERE name=?", store.DecodeUid(newOwner), topic)
+ return err
+}
+
+// Get a subscription of a user to a topic.
+func (a *adapter) SubscriptionGet(topic string, user t.Uid, keepDeleted bool) (*t.Subscription, error) {
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ query := `SELECT createdat,updatedat,deletedat,userid AS user,topic,delid,recvseqid,
+ readseqid,modewant,modegiven,private FROM subscriptions WHERE topic=? AND userid=?`
+ if !keepDeleted {
+ query += " AND deletedat IS NULL"
+ }
+ var sub t.Subscription
+ err := a.db.GetContext(ctx, &sub, query, topic, store.DecodeUid(user))
+ if err != nil {
+ if err == sql.ErrNoRows {
+ // Nothing found - clear the error
+ err = nil
+ }
+ return nil, err
+ }
+
+ sub.User = user.String()
+ sub.Private = common.FromJSON(sub.Private)
+
+ return &sub, nil
+}
+
+// SubsForUser loads all user's subscriptions. Does NOT load Public or Private values and does
+// not load deleted subscriptions.
+func (a *adapter) SubsForUser(forUser t.Uid) ([]t.Subscription, error) {
+ q := `SELECT createdat,updatedat,deletedat,userid AS user,topic,delid,recvseqid,
+ readseqid,modewant,modegiven FROM subscriptions WHERE userid=? AND deletedat IS NULL`
+ args := []any{store.DecodeUid(forUser)}
+
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ rows, err := a.db.QueryxContext(ctx, q, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var subs []t.Subscription
+ var sub t.Subscription
+ for rows.Next() {
+ if err = rows.StructScan(&sub); err != nil {
+ break
+ }
+ sub.User = forUser.String()
+ subs = append(subs, sub)
+ }
+ if err == nil {
+ err = rows.Err()
+ }
+
+ return subs, err
+}
+
+// SubsForTopic fetches all subsciptions for a topic. Does NOT load Public value and does not load channel readers.
+// The difference between UsersForTopic vs SubsForTopic is that the former loads user.public+trusted,
+// the latter does not.
+func (a *adapter) SubsForTopic(topic string, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) {
+ q := `SELECT createdat,updatedat,deletedat,userid AS user,topic,delid,recvseqid,
+ readseqid,modewant,modegiven,private FROM subscriptions WHERE topic=?`
+
+ args := []any{topic}
+ if !keepDeleted {
+ // Filter out deleted rows.
+ q += " AND deletedat IS NULL"
+ }
+ limit := a.maxResults
+ if opts != nil {
+ // Ignore IfModifiedSince - we must return all entries
+ // Those unmodified will be stripped of Public & Private.
+
+ if !opts.User.IsZero() {
+ q += " AND userid=?"
+ args = append(args, store.DecodeUid(opts.User))
+ }
+ if opts.Limit > 0 && opts.Limit < limit {
+ limit = opts.Limit
+ }
+ }
+
+ q += " LIMIT ?"
+ args = append(args, limit)
+
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ rows, err := a.db.QueryxContext(ctx, q, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var subs []t.Subscription
+ var sub t.Subscription
+ for rows.Next() {
+ if err = rows.StructScan(&sub); err != nil {
+ break
+ }
+
+ sub.User = common.EncodeUidString(sub.User).String()
+ sub.Private = common.FromJSON(sub.Private)
+ subs = append(subs, sub)
+ }
+ if err == nil {
+ err = rows.Err()
+ }
+
+ return subs, err
+}
+
+// SubsUpdate updates one or multiple subscriptions to a topic.
+func (a *adapter) SubsUpdate(topic string, user t.Uid, update map[string]any) error {
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTxx(ctx, nil)
+ if err != nil {
+ return err
+ }
+
+ defer func() {
+ if err != nil {
+ tx.Rollback()
+ }
+ }()
+
+ cols, args := common.UpdateByMap(update)
+ q := "UPDATE subscriptions SET " + strings.Join(cols, ",") + " WHERE topic=?"
+ args = append(args, topic)
+ if !user.IsZero() {
+ // Update just one topic subscription
+ q += " AND userid=?"
+ args = append(args, store.DecodeUid(user))
+ }
+
+ if _, err = tx.Exec(q, args...); err != nil {
+ return err
+ }
+
+ return tx.Commit()
+}
+
+// SubsDelete marks at most one subscription as deleted (soft-deleting).
+func (a *adapter) SubsDelete(topic string, user t.Uid) error {
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+
+ tx, err := a.db.BeginTxx(ctx, nil)
+ if err != nil {
+ return err
+ }
+
+ defer func() {
+ if err != nil {
+ tx.Rollback()
+ }
+ }()
+
+ decoded_id := store.DecodeUid(user)
+ now := t.TimeNow()
+
+ // Mark subscription as deleted.
+ res, err := tx.ExecContext(ctx,
+ "UPDATE subscriptions SET updatedat=?,deletedat=? WHERE topic=? AND userid=? AND deletedat IS NULL",
+ now, now, topic, decoded_id)
+ if err != nil {
+ return err
+ }
+
+ affected, err := res.RowsAffected()
+ if err == nil && affected == 0 {
+ // ensure tx.Rollback() above is ran
+ err = t.ErrNotFound
+ return err
+ }
+
+ // Channel readers cannot delete messages.
+ if !t.IsChannel(topic) {
+ // Remove records of messages soft-deleted by this user.
+ _, err = tx.Exec("DELETE FROM dellog WHERE topic=? AND deletedfor=?", topic, decoded_id)
+ if err != nil {
+ return err
+ }
+ }
+
+ if t.GetTopicCat(topic) == t.TopicCatGrp {
+ // Decrement topic subscription count (only one subscription is deleted).
+ _, err = tx.Exec("UPDATE topics SET subcnt=subcnt-1 WHERE name=?", t.ChnToGrp(topic))
+ if err != nil {
+ return err
+ }
+ }
+
+ return tx.Commit()
+}
+
+// subsDelForUser marks user's subscriptions as deleted.
+func subsDelForUser(tx *sqlx.Tx, decoded_uid int64, hard bool) error {
+ // Decrement subscription count for all topics the user is subscribed to.
+ rows, err := tx.Query("SELECT topic FROM subscriptions WHERE userid=? AND deletedat IS NULL", decoded_uid)
+ if err != nil {
+ return err
+ }
+ var topics []any
+ for rows.Next() {
+ var name string
+ if err = rows.Scan(&name); err != nil {
+ break
+ }
+ if t.IsChannel(name) {
+ // Convert channel name to group name.
+ name = t.ChnToGrp(name)
+ }
+ topics = append(topics, name)
+ }
+ if err == nil {
+ err = rows.Err()
+ }
+ rows.Close()
+ if err != nil {
+ return err
+ }
+ if len(topics) > 0 {
+ sql, args, err := sqlx.In("UPDATE topics SET subcnt=subcnt-1 WHERE name IN (?)", topics)
+ _, err = tx.Exec(tx.Rebind(sql), args...)
+ if err != nil {
+ return err
+ }
+ }
+
+ if hard {
+ _, err = tx.Exec("DELETE FROM subscriptions WHERE userid=?", decoded_uid)
+ } else {
+ now := t.TimeNow()
+ _, err = tx.Exec("UPDATE subscriptions SET updatedat=?,deletedat=? WHERE userid=? AND deletedat IS NULL",
+ now, now, decoded_uid)
+ }
+ return err
+}
+
+// SubsDelForUser marks user's subscriptions as deleted.
+func (a *adapter) SubsDelForUser(user t.Uid, hard bool) error {
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+
+ tx, err := a.db.BeginTxx(ctx, nil)
+ if err != nil {
+ return err
+ }
+
+ defer func() {
+ if err != nil {
+ tx.Rollback()
+ }
+ }()
+
+ if err = subsDelForUser(tx, store.DecodeUid(user), hard); err != nil {
+ return err
+ }
+
+ return tx.Commit()
+}
+
+// Find returns a list of users or group topics who match given tags, such as "email:jdoe@example.com" or "tel:+18003287448".
+func (a *adapter) Find(caller, promoPrefix string, req [][]string, opt []string, activeOnly bool) ([]t.Subscription, error) {
+ var args []any
+ stateConstraint := ""
+ if activeOnly {
+ args = append(args, t.StateOK)
+ stateConstraint = "u.state=? AND "
+ }
+ index := make(map[string]struct{})
+ allReq := t.FlattenDoubleSlice(req)
+ for _, tag := range append(allReq, opt...) {
+ args = append(args, tag)
+ index[tag] = struct{}{}
+ }
+
+ var matcher string
+ if promoPrefix != "" {
+ // The max number of tags is 16. Using 20 to make sure one prefix match is greater than all non-prefix matches.
+ matcher = "SUM(CASE WHEN LOCATE('" + promoPrefix + "', tg.tag)=1 THEN 20 ELSE 1 END)"
+ } else {
+ matcher = "COUNT(*)"
+ }
+
+ query := "SELECT u.id,u.createdat,u.updatedat,0,u.access,0 AS subcnt,u.public,u.trusted,u.tags," + matcher + " AS matches " +
+ "FROM users AS u JOIN usertags AS tg ON tg.userid=u.id " +
+ "WHERE " + stateConstraint + "tg.tag IN (?" + strings.Repeat(",?", len(allReq)+len(opt)-1) + ") " +
+ "GROUP BY u.id,u.createdat,u.updatedat,u.access,u.public,u.trusted,u.tags "
+ if len(allReq) > 0 {
+ q, a := common.DisjunctionSql(req, "tg.tag")
+ query += q
+ args = append(args, a...)
+ }
+
+ query += "UNION ALL "
+
+ if activeOnly {
+ args = append(args, t.StateOK)
+ stateConstraint = "t.state=? AND "
+ }
+ for _, tag := range append(allReq, opt...) {
+ args = append(args, tag)
+ }
+
+ query += "SELECT t.name AS topic,t.createdat,t.updatedat,t.usebt,t.access,t.subcnt,t.public,t.trusted,t.tags," + matcher + " AS matches " +
+ "FROM topics AS t JOIN topictags AS tg ON t.name=tg.topic " +
+ "WHERE " + stateConstraint + "tg.tag IN (?" + strings.Repeat(",?", len(allReq)+len(opt)-1) + ") " +
+ "GROUP BY t.name,t.createdat,t.updatedat,t.usebt,t.access,t.subcnt,t.public,t.trusted,t.tags "
+ if len(allReq) > 0 {
+ q, a := common.DisjunctionSql(req, "tg.tag")
+ query += q
+ args = append(args, a...)
+ }
+ query += "ORDER BY matches DESC, subcnt DESC LIMIT ?"
+ args = append(args, a.maxResults)
+
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+
+ // Get users matched by tags, sort by number of matches from high to low.
+ rows, err := a.db.QueryxContext(ctx, query, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ // Read results as subscriptions.
+ var public, trusted any
+ var access t.DefaultAccess
+ var subcnt int
+ var setTags t.StringSlice
+ var ignored int
+ var isChan bool
+ var sub t.Subscription
+ var subs []t.Subscription
+ for rows.Next() {
+ if err = rows.Scan(&sub.Topic, &sub.CreatedAt, &sub.UpdatedAt, &isChan, &access, &subcnt,
+ &public, &trusted, &setTags, &ignored); err != nil {
+ subs = nil
+ break
+ }
+
+ if id, err := strconv.ParseInt(sub.Topic, 10, 64); err == nil {
+ sub.Topic = store.EncodeUid(id).UserId()
+ if sub.Topic == caller {
+ // Skip the caller.
+ continue
+ }
+ }
+
+ if isChan {
+ // This is a channel, convert grp to chn name: all channel-capable
+ // topics should appear as channels in search results.
+ sub.Topic = t.GrpToChn(sub.Topic)
+ }
+
+ sub.SetSubCnt(subcnt)
+ sub.SetPublic(common.FromJSON(public))
+ sub.SetTrusted(common.FromJSON(trusted))
+ sub.SetDefaultAccess(access.Auth, access.Anon)
+ // Indicating that the mode is not set, not 'N'.
+ sub.ModeGiven = t.ModeUnset
+ sub.ModeWant = t.ModeUnset
+ sub.Private = common.FilterFoundTags(setTags, index)
+ subs = append(subs, sub)
+ }
+ if err == nil {
+ err = rows.Err()
+ }
+
+ return subs, err
+}
+
+// FindOne returns the first topic or user which matches the given tag.
+func (a *adapter) FindOne(tag string) (string, error) {
+ var args []any
+
+ query := "SELECT t.name AS topic FROM topics AS t LEFT JOIN topictags AS tt ON t.name=tt.topic " +
+ "WHERE tt.tag=?"
+ args = append(args, tag)
+
+ query += " UNION ALL "
+
+ query += "SELECT u.id AS topic FROM users AS u LEFT JOIN usertags AS ut ON ut.userid=u.id " +
+ "WHERE ut.tag=?"
+ args = append(args, tag)
+
+ // LIMIT is applied to all resultant rows.
+ query += " LIMIT 1"
+
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+
+ rows, err := a.db.QueryxContext(ctx, query, args...)
+ if err != nil {
+ return "", err
+ }
+ defer rows.Close()
+
+ var found string
+ if rows.Next() {
+ if err = rows.Scan(&found); err != nil {
+ return "", err
+ }
+
+ // Check if the found value is a topic name or a user ID.
+ // User IDs are returned as decoded decimal strings.
+ if id, err := strconv.ParseInt(found, 10, 64); err == nil {
+ found = store.EncodeUid(id).UserId()
+ }
+ }
+ if err == nil {
+ err = rows.Err()
+ }
+
+ return found, err
+}
+
+// Messages
+func (a *adapter) MessageSave(msg *t.Message) error {
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ // store assignes message ID, but we don't use it. Message IDs are not used anywhere.
+ // Using a sequential ID provided by the database.
+ res, err := a.db.ExecContext(ctx,
+ "INSERT INTO messages(createdAt,updatedAt,seqid,topic,`from`,head,content) VALUES(?,?,?,?,?,?,?)",
+ msg.CreatedAt, msg.UpdatedAt, msg.SeqId, msg.Topic,
+ store.DecodeUid(t.ParseUid(msg.From)), msg.Head, common.ToJSON(msg.Content))
+ if err == nil {
+ id, _ := res.LastInsertId()
+ // Replacing ID given by store by ID given by the DB.
+ msg.SetUid(t.Uid(id))
+ }
+ return err
+}
+
+// MessageGetAll returns messages matching the query.
+func (a *adapter) MessageGetAll(topic string, forUser t.Uid, opts *t.QueryOpt) ([]t.Message, error) {
+ var limit = a.maxMessageResults
+
+ args := []any{store.DecodeUid(forUser), topic}
+ seqIdConstraint := ""
+ if opts != nil {
+ seqIdConstraint = "AND m.seqid "
+ if len(opts.IdRanges) > 0 {
+ constr, newargs := common.RangesToSql(opts.IdRanges)
+ seqIdConstraint += constr
+ args = append(args, newargs...)
+ } else {
+ seqIdConstraint += "BETWEEN ? AND ?"
+ if opts.Since > 0 {
+ args = append(args, opts.Since)
+ } else {
+ args = append(args, 0)
+ }
+ if opts.Before > 1 {
+ // MySQL BETWEEN is inclusive-inclusive, Tinode API requires inclusive-exclusive, thus -1
+ args = append(args, opts.Before-1)
+ } else {
+ args = append(args, 1<<31-1)
+ }
+ }
+
+ if opts.Limit > 0 && opts.Limit < limit {
+ limit = opts.Limit
+ }
+ }
+
+ args = append(args, limit)
+
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+
+ rows, err := a.db.QueryxContext(
+ ctx,
+ "SELECT m.createdat,m.updatedat,m.deletedat,m.delid,m.seqid,m.topic,m.`from`,m.head,m.content"+
+ " FROM messages AS m LEFT JOIN dellog AS d"+
+ " ON d.topic=m.topic AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=?"+
+ " WHERE m.delid=0 AND m.topic=? "+seqIdConstraint+" AND d.deletedfor IS NULL"+
+ " ORDER BY m.seqid DESC LIMIT ?",
+ args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ msgs := make([]t.Message, 0, limit)
+ for rows.Next() {
+ var msg t.Message
+ if err = rows.StructScan(&msg); err != nil {
+ break
+ }
+ msg.From = common.EncodeUidString(msg.From).String()
+ msg.Content = common.FromJSON(msg.Content)
+ msgs = append(msgs, msg)
+ }
+ if err == nil {
+ err = rows.Err()
+ }
+
+ return msgs, err
+}
+
+// Get ranges of deleted messages
+func (a *adapter) MessageGetDeleted(topic string, forUser t.Uid, opts *t.QueryOpt) ([]t.DelMessage, error) {
+ var limit = a.maxResults
+ var lower = 0
+ var upper = 1<<31 - 1
+
+ if opts != nil {
+ if opts.Since > 0 {
+ lower = opts.Since
+ }
+ if opts.Before > 1 {
+ // DelRange is inclusive-exclusive, while BETWEEN is inclusive-inclisive.
+ upper = opts.Before - 1
+ }
+
+ if opts.Limit > 0 && opts.Limit < limit {
+ limit = opts.Limit
+ }
+ }
+
+ // Fetch log of deletions
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ rows, err := a.db.QueryxContext(ctx, "SELECT topic,deletedfor,delid,low,hi FROM dellog WHERE topic=? AND delid BETWEEN ? AND ?"+
+ " AND (deletedFor=0 OR deletedFor=?)"+
+ " ORDER BY delid LIMIT ?", topic, lower, upper, store.DecodeUid(forUser), limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var dellog struct {
+ Topic string
+ Deletedfor int64
+ Delid int
+ Low int
+ Hi int
+ }
+ var dmsgs []t.DelMessage
+ var dmsg t.DelMessage
+ for rows.Next() {
+ if err = rows.StructScan(&dellog); err != nil {
+ dmsgs = nil
+ break
+ }
+
+ if dellog.Delid != dmsg.DelId {
+ if dmsg.DelId > 0 {
+ dmsgs = append(dmsgs, dmsg)
+ }
+ dmsg.DelId = dellog.Delid
+ dmsg.Topic = dellog.Topic
+ if dellog.Deletedfor > 0 {
+ dmsg.DeletedFor = store.EncodeUid(dellog.Deletedfor).String()
+ } else {
+ dmsg.DeletedFor = ""
+ }
+ dmsg.SeqIdRanges = nil
+ }
+ if dellog.Hi <= dellog.Low+1 {
+ dellog.Hi = 0
+ }
+ dmsg.SeqIdRanges = append(dmsg.SeqIdRanges, t.Range{Low: dellog.Low, Hi: dellog.Hi})
+ }
+ if err == nil {
+ err = rows.Err()
+ }
+
+ if err == nil {
+ if dmsg.DelId > 0 {
+ dmsgs = append(dmsgs, dmsg)
+ }
+ }
+
+ return dmsgs, err
+}
+
+func messageDeleteList(tx *sqlx.Tx, topic string, toDel *t.DelMessage) error {
+ var err error
+
+ if toDel == nil {
+ // Whole topic is being deleted, thus also deleting all messages.
+ _, err = tx.Exec("DELETE FROM dellog WHERE topic=?", topic)
+ if err == nil {
+ _, err = tx.Exec("DELETE FROM messages WHERE topic=?", topic)
+ }
+ // filemsglinks will be deleted because of ON DELETE CASCADE
+ return err
+ }
+
+ // Only some messages are being deleted.
+
+ delRanges := toDel.SeqIdRanges
+
+ if toDel.DeletedFor == "" {
+ // Hard-deleting messages requires updates to the messages table.
+ where := "m.topic=?"
+ args := []any{topic}
+
+ if len(delRanges) > 0 {
+ rSql, rArgs := common.RangesToSql(delRanges)
+ where += " AND m.seqid " + rSql
+ args = append(args, rArgs...)
+ }
+
+ where += " AND m.deletedat IS NULL"
+
+ // We are asked to delete messages no older than newerThan.
+ if newerThan := toDel.GetNewerThan(); newerThan != nil {
+ where += " AND m.createdat>?"
+ args = append(args, newerThan)
+ }
+
+ // Find the actual IDs still present in the database.
+ var seqIDs []int
+ err = tx.Select(&seqIDs, "SELECT seqid FROM messages AS m WHERE "+where, args...)
+ if err != nil {
+ return err
+ }
+
+ if len(seqIDs) == 0 {
+ // Nothing to delete. No need to make a log entry. All done.
+ return nil
+ }
+
+ // Recalculate the actual ranges to delete.
+ sort.Ints(seqIDs)
+ delRanges = t.SliceToRanges(seqIDs)
+
+ // Compose a new query with the new ranges.
+ where = "m.topic=?"
+ args = []any{topic}
+ rSql, rArgs := common.RangesToSql(delRanges)
+ where += " AND m.seqid " + rSql
+ args = append(args, rArgs...)
+
+ // No need to add anything else: deletedat etc is already accounted for.
+
+ _, err = tx.Exec("DELETE fml.* FROM filemsglinks AS fml INNER JOIN messages AS m ON m.id=fml.msgid WHERE "+
+ where, args...)
+ if err != nil {
+ return err
+ }
+
+ // Instead of deleting messages, clear all content.
+ _, err = tx.Exec("UPDATE messages AS m SET m.deletedat=?,m.delId=?,m.`from`=0,m.head=NULL,m.content=NULL WHERE "+
+ where, append([]any{t.TimeNow(), toDel.DelId}, args...)...)
+ if err != nil {
+ return err
+ }
+ }
+
+ // Now make log entries. Needed for both hard- and soft-deleting.
+ var insert *sql.Stmt
+ if insert, err = tx.Prepare(
+ "INSERT INTO dellog(topic,deletedfor,delid,low,hi) VALUES(?,?,?,?,?)"); err != nil {
+ return err
+ }
+
+ forUser := common.DecodeUidString(toDel.DeletedFor)
+ for _, rng := range delRanges {
+ if rng.Hi == 0 {
+ // Dellog must contain valid Low and *Hi*.
+ rng.Hi = rng.Low + 1
+ }
+ // A log entry for each range.
+ if _, err = insert.Exec(topic, forUser, toDel.DelId, rng.Low, rng.Hi); err != nil {
+ break
+ }
+ }
+
+ return err
+}
+
+// MessageDeleteList deletes messages in the given topic with seqIds from the list.
+func (a *adapter) MessageDeleteList(topic string, toDel *t.DelMessage) (err error) {
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTxx(ctx, nil)
+ if err != nil {
+ return err
+ }
+
+ defer func() {
+ if err != nil {
+ tx.Rollback()
+ }
+ }()
+
+ if err = messageDeleteList(tx, topic, toDel); err != nil {
+ return err
+ }
+
+ return tx.Commit()
+}
+
+func deviceHasher(deviceID string) string {
+ // Generate custom key as [64-bit hash of device id] to ensure predictable
+ // length of the key
+ hasher := fnv.New64()
+ hasher.Write([]byte(deviceID))
+ return strconv.FormatUint(uint64(hasher.Sum64()), 16)
+}
+
+// Device management for push notifications.
+
+// DeviceUpsert creates or updates a device record.
+func (a *adapter) DeviceUpsert(uid t.Uid, def *t.DeviceDef) error {
+ hash := deviceHasher(def.DeviceId)
+
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTxx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err != nil {
+ tx.Rollback()
+ }
+ }()
+
+ // Ensure uniqueness of the device ID: delete all records of the device ID
+ _, err = tx.Exec("DELETE FROM devices WHERE hash=?", hash)
+ if err != nil {
+ return err
+ }
+
+ // Actually add/update DeviceId for the new user
+ _, err = tx.Exec("INSERT INTO devices(userid, hash, deviceId, platform, lastseen, lang) VALUES(?,?,?,?,?,?)",
+ store.DecodeUid(uid), hash, def.DeviceId, def.Platform, def.LastSeen, def.Lang)
+ if err != nil {
+ return err
+ }
+
+ return tx.Commit()
+}
+
+// DeviceGetAll returns all devices for a given set of users.
+func (a *adapter) DeviceGetAll(uids ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error) {
+ var unums []any
+ for _, uid := range uids {
+ unums = append(unums, store.DecodeUid(uid))
+ }
+
+ q, unums, _ := sqlx.In("SELECT userid,deviceid,platform,lastseen,lang FROM devices WHERE userid IN (?)", unums)
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ rows, err := a.db.QueryxContext(ctx, q, unums...)
+ if err != nil {
+ return nil, 0, err
+ }
+ defer rows.Close()
+
+ var device struct {
+ Userid int64
+ Deviceid string
+ Platform string
+ Lastseen time.Time
+ Lang string
+ }
+
+ result := make(map[t.Uid][]t.DeviceDef)
+ count := 0
+ for rows.Next() {
+ if err = rows.StructScan(&device); err != nil {
+ break
+ }
+ uid := store.EncodeUid(device.Userid)
+ udev := result[uid]
+ udev = append(udev, t.DeviceDef{
+ DeviceId: device.Deviceid,
+ Platform: device.Platform,
+ LastSeen: device.Lastseen,
+ Lang: device.Lang,
+ })
+ result[uid] = udev
+ count++
+ }
+ if err == nil {
+ err = rows.Err()
+ }
+
+ return result, count, err
+}
+
+func deviceDelete(tx *sqlx.Tx, uid t.Uid, deviceID string) error {
+ var err error
+ var res sql.Result
+ if deviceID == "" {
+ res, err = tx.Exec("DELETE FROM devices WHERE userid=?", store.DecodeUid(uid))
+ } else {
+ res, err = tx.Exec("DELETE FROM devices WHERE userid=? AND hash=?", store.DecodeUid(uid), deviceHasher(deviceID))
+ }
+
+ if err == nil {
+ if count, _ := res.RowsAffected(); count == 0 {
+ err = t.ErrNotFound
+ }
+ }
+
+ return err
+}
+
+// DeviceDelete deletes a device record (push token).
+func (a *adapter) DeviceDelete(uid t.Uid, deviceID string) error {
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTxx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err != nil {
+ tx.Rollback()
+ }
+ }()
+
+ err = deviceDelete(tx, uid, deviceID)
+ if err != nil {
+ return err
+ }
+
+ return tx.Commit()
+}
+
+// Credential management
+
+// CredUpsert adds or updates a validation record. Returns true if inserted, false if updated.
+// 1. if credential is validated:
+// 1.1 Hard-delete unconfirmed equivalent record, if exists.
+// 1.2 Insert new. Report error if duplicate.
+// 2. if credential is not validated:
+// 2.1 Check if validated equivalent exist. If so, report an error.
+// 2.2 Soft-delete all unvalidated records of the same method.
+// 2.3 Undelete existing credential. Return if successful.
+// 2.4 Insert new credential record.
+func (a *adapter) CredUpsert(cred *t.Credential) (bool, error) {
+ var err error
+
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTxx(ctx, nil)
+ if err != nil {
+ return false, err
+ }
+ defer func() {
+ if err != nil {
+ tx.Rollback()
+ }
+ }()
+
+ now := t.TimeNow()
+ userId := common.DecodeUidString(cred.User)
+
+ // Enforce uniqueness: if credential is confirmed, "method:value" must be unique.
+ // if credential is not yet confirmed, "userid:method:value" is unique.
+ synth := cred.Method + ":" + cred.Value
+
+ if !cred.Done {
+ // Check if this credential is already validated.
+ var done bool
+ err = tx.Get(&done, "SELECT done FROM credentials WHERE synthetic=?", synth)
+ if err == nil {
+ // Assign err to ensure closing of a transaction.
+ err = t.ErrDuplicate
+ return false, err
+ }
+ if err != sql.ErrNoRows {
+ return false, err
+ }
+ // We are going to insert new record.
+ synth = cred.User + ":" + synth
+
+ // Adding new unvalidated credential. Deactivate all unvalidated records of this user and method.
+ _, err = tx.Exec("UPDATE credentials SET deletedat=? WHERE userid=? AND method=? AND done=FALSE",
+ now, userId, cred.Method)
+ if err != nil {
+ return false, err
+ }
+ // Assume that the record exists and try to update it: undelete, update timestamp and response value.
+ res, err := tx.Exec("UPDATE credentials SET updatedat=?,deletedat=NULL,resp=?,done=FALSE WHERE synthetic=?",
+ cred.UpdatedAt, cred.Resp, synth)
+ if err != nil {
+ return false, err
+ }
+ // If record was updated, then all is fine.
+ if numrows, _ := res.RowsAffected(); numrows > 0 {
+ return false, tx.Commit()
+ }
+ } else {
+ // Hard-deleting unconformed record if it exists.
+ _, err = tx.Exec("DELETE FROM credentials WHERE synthetic=?", cred.User+":"+synth)
+ if err != nil {
+ return false, err
+ }
+ }
+ // Add new record.
+ _, err = tx.Exec("INSERT INTO credentials(createdat,updatedat,method,value,synthetic,userid,resp,done) "+
+ "VALUES(?,?,?,?,?,?,?,?)",
+ cred.CreatedAt, cred.UpdatedAt, cred.Method, cred.Value, synth, userId, cred.Resp, cred.Done)
+ if err != nil {
+ if isDupe(err) {
+ return true, t.ErrDuplicate
+ }
+ return true, err
+ }
+ return true, tx.Commit()
+}
+
+// TenantCredUpsert adds or updates a credential scoped to a tenant.
+func (a *adapter) TenantCredUpsert(tenantID t.TenantID, cred *t.Credential) (bool, error) {
+ if tenantID.IsZero() || cred == nil {
+ return false, t.ErrMalformed
+ }
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTxx(ctx, nil)
+ if err != nil {
+ return false, err
+ }
+ defer func() {
+ if err != nil {
+ tx.Rollback()
+ }
+ }()
+
+ now := t.TimeNow()
+ userID := common.DecodeUidString(cred.User)
+ synth := cred.Method + ":" + cred.Value
+ if !cred.Done {
+ var done bool
+ err = tx.Get(&done, "SELECT done FROM credentials WHERE tenant_id=? AND synthetic=?", tenantID, synth)
+ if err == nil {
+ return false, t.ErrDuplicate
+ }
+ if err != sql.ErrNoRows {
+ return false, err
+ }
+ synth = cred.User + ":" + synth
+ _, err = tx.Exec("UPDATE credentials SET deletedat=? WHERE tenant_id=? AND userid=? AND method=? AND done=FALSE",
+ now, tenantID, userID, cred.Method)
+ if err != nil {
+ return false, err
+ }
+ res, err := tx.Exec("UPDATE credentials SET updatedat=?,deletedat=NULL,resp=?,done=FALSE "+
+ "WHERE tenant_id=? AND synthetic=?", cred.UpdatedAt, cred.Resp, tenantID, synth)
+ if err != nil {
+ return false, err
+ }
+ if numRows, _ := res.RowsAffected(); numRows > 0 {
+ return false, tx.Commit()
+ }
+ } else {
+ _, err = tx.Exec("DELETE FROM credentials WHERE tenant_id=? AND synthetic=?",
+ tenantID, cred.User+":"+synth)
+ if err != nil {
+ return false, err
+ }
+ }
+
+ _, err = tx.Exec("INSERT INTO credentials(tenant_id,createdat,updatedat,method,value,synthetic,userid,resp,done) "+
+ "VALUES(?,?,?,?,?,?,?,?,?)", tenantID, cred.CreatedAt, cred.UpdatedAt, cred.Method, cred.Value,
+ synth, userID, cred.Resp, cred.Done)
+ if isDupe(err) {
+ return true, t.ErrDuplicate
+ }
+ if err != nil {
+ return true, err
+ }
+ return true, tx.Commit()
+}
+
+func tenantCredDel(tx *sqlx.Tx, tenantID t.TenantID, uid t.Uid, method, value string) error {
+ constraints := " WHERE tenant_id=? AND userid=?"
+ args := []any{tenantID, store.DecodeUid(uid)}
+ if method != "" {
+ constraints += " AND method=?"
+ args = append(args, method)
+ if value != "" {
+ constraints += " AND value=?"
+ args = append(args, value)
+ }
+ }
+
+ if method == "" {
+ res, err := tx.Exec("DELETE FROM credentials"+constraints, args...)
+ if err != nil {
+ return err
+ }
+ if count, _ := res.RowsAffected(); count == 0 {
+ return t.ErrNotFound
+ }
+ return nil
+ }
+
+ res, err := tx.Exec("DELETE FROM credentials"+constraints+" AND (done=TRUE OR retries=0)", args...)
+ if err != nil {
+ return err
+ }
+ if count, _ := res.RowsAffected(); count > 0 {
+ return nil
+ }
+
+ args = append([]any{t.TimeNow()}, args...)
+ res, err = tx.Exec("UPDATE credentials SET deletedat=?"+constraints, args...)
+ if err != nil {
+ return err
+ }
+ if count, _ := res.RowsAffected(); count == 0 {
+ return t.ErrNotFound
+ }
+ return nil
+}
+
+// credDel deletes given validation method or all methods of the given user.
+// 1. If user is being deleted, hard-delete all records (method == "")
+// 2. If one value is being deleted:
+// 2.1 Delete it if it's valiated or if there were no attempts at validation
+// (otherwise it could be used to circumvent the limit on validation attempts).
+// 2.2 In that case mark it as soft-deleted.
+func credDel(tx *sqlx.Tx, uid t.Uid, method, value string) error {
+ constraints := " WHERE userid=?"
+ args := []any{store.DecodeUid(uid)}
+
+ if method != "" {
+ constraints += " AND method=?"
+ args = append(args, method)
+
+ if value != "" {
+ constraints += " AND value=?"
+ args = append(args, value)
+ }
+ }
+
+ var err error
+ var res sql.Result
+ if method == "" {
+ // Case 1
+ res, err = tx.Exec("DELETE FROM credentials"+constraints, args...)
+ if err == nil {
+ if count, _ := res.RowsAffected(); count == 0 {
+ err = t.ErrNotFound
+ }
+ }
+ return err
+ }
+
+ // Case 2.1
+ res, err = tx.Exec("DELETE FROM credentials"+constraints+" AND (done=TRUE OR retries=0)", args...)
+ if err != nil {
+ return err
+ }
+ if count, _ := res.RowsAffected(); count > 0 {
+ return nil
+ }
+
+ // Case 2.2
+ args = append([]any{t.TimeNow()}, args...)
+ res, err = tx.Exec("UPDATE credentials SET deletedat=?"+constraints, args...)
+ if err == nil {
+ if count, _ := res.RowsAffected(); count >= 0 {
+ err = t.ErrNotFound
+ }
+ }
+
+ return err
+}
+
+// CredDel deletes either credentials of the given user. If method is blank all
+// credentials are removed. If value is blank all credentials of the given the
+// method are removed.
+func (a *adapter) CredDel(uid t.Uid, method, value string) error {
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTxx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err != nil {
+ tx.Rollback()
+ }
+ }()
+
+ err = credDel(tx, uid, method, value)
+ if err != nil {
+ return err
+ }
+
+ return tx.Commit()
+}
+
+// TenantCredDel deletes credentials only in the selected tenant.
+func (a *adapter) TenantCredDel(tenantID t.TenantID, uid t.Uid, method, value string) error {
+ if tenantID.IsZero() {
+ return t.ErrMalformed
+ }
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTxx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ if err = tenantCredDel(tx, tenantID, uid, method, value); err != nil {
+ tx.Rollback()
+ return err
+ }
+ return tx.Commit()
+}
+
+// CredConfirm marks given credential method as confirmed.
+func (a *adapter) CredConfirm(uid t.Uid, method string) error {
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ res, err := a.db.ExecContext(
+ ctx,
+ "UPDATE credentials SET updatedat=?,done=TRUE,synthetic=CONCAT(method,':',value) "+
+ "WHERE userid=? AND method=? AND deletedat IS NULL AND done=FALSE",
+ t.TimeNow(), store.DecodeUid(uid), method)
+ if err != nil {
+ if isDupe(err) {
+ return t.ErrDuplicate
+ }
+ return err
+ }
+ if numrows, _ := res.RowsAffected(); numrows < 1 {
+ return t.ErrNotFound
+ }
+ return nil
+}
+
+// TenantCredConfirm confirms a credential only in the selected tenant.
+func (a *adapter) TenantCredConfirm(tenantID t.TenantID, uid t.Uid, method string) error {
+ if tenantID.IsZero() {
+ return t.ErrMalformed
+ }
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ res, err := a.db.ExecContext(ctx,
+ "UPDATE credentials SET updatedat=?,done=TRUE,synthetic=CONCAT(method,':',value) "+
+ "WHERE tenant_id=? AND userid=? AND method=? AND deletedat IS NULL AND done=FALSE",
+ t.TimeNow(), tenantID, store.DecodeUid(uid), method)
+ if isDupe(err) {
+ return t.ErrDuplicate
+ }
+ if err != nil {
+ return err
+ }
+ if numRows, _ := res.RowsAffected(); numRows < 1 {
+ return t.ErrNotFound
+ }
+ return nil
+}
+
+// CredFail increments failure count of the given validation method.
+func (a *adapter) CredFail(uid t.Uid, method string) error {
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ _, err := a.db.ExecContext(ctx, "UPDATE credentials SET updatedat=?,retries=retries+1 WHERE userid=? AND method=? AND done=FALSE",
+ t.TimeNow(), store.DecodeUid(uid), method)
+ return err
+}
+
+// TenantCredFail increments credential failures only in the selected tenant.
+func (a *adapter) TenantCredFail(tenantID t.TenantID, uid t.Uid, method string) error {
+ if tenantID.IsZero() {
+ return t.ErrMalformed
+ }
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ _, err := a.db.ExecContext(ctx, "UPDATE credentials SET updatedat=?,retries=retries+1 "+
+ "WHERE tenant_id=? AND userid=? AND method=? AND done=FALSE",
+ t.TimeNow(), tenantID, store.DecodeUid(uid), method)
+ return err
+}
+
+// CredGetActive returns currently active unvalidated credential of the given user and method.
+func (a *adapter) CredGetActive(uid t.Uid, method string) (*t.Credential, error) {
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ var cred t.Credential
+ err := a.db.GetContext(ctx, &cred, "SELECT createdat,updatedat,method,value,resp,done,retries "+
+ "FROM credentials WHERE userid=? AND deletedat IS NULL AND method=? AND done=FALSE",
+ store.DecodeUid(uid), method)
+ if err != nil {
+ if err == sql.ErrNoRows {
+ err = nil
+ }
+ return nil, err
+ }
+ cred.User = uid.String()
+
+ return &cred, nil
+}
+
+// TenantCredGetActive returns the active credential in the selected tenant.
+func (a *adapter) TenantCredGetActive(tenantID t.TenantID, uid t.Uid, method string) (*t.Credential, error) {
+ if tenantID.IsZero() {
+ return nil, t.ErrMalformed
+ }
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ var cred t.Credential
+ err := a.db.GetContext(ctx, &cred, "SELECT createdat,updatedat,method,value,resp,done,retries "+
+ "FROM credentials WHERE tenant_id=? AND userid=? AND deletedat IS NULL AND method=? AND done=FALSE",
+ tenantID, store.DecodeUid(uid), method)
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ cred.User = uid.String()
+ return &cred, nil
+}
+
+// CredGetAll returns credential records for the given user and method, all or validated only.
+func (a *adapter) CredGetAll(uid t.Uid, method string, validatedOnly bool) ([]t.Credential, error) {
+ query := "SELECT createdat,updatedat,method,value,resp,done,retries FROM credentials WHERE userid=? AND deletedat IS NULL"
+ args := []any{store.DecodeUid(uid)}
+ if method != "" {
+ query += " AND method=?"
+ args = append(args, method)
+ }
+ if validatedOnly {
+ query += " AND done=TRUE"
+ }
+
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ var credentials []t.Credential
+ err := a.db.SelectContext(ctx, &credentials, query, args...)
+ if err != nil {
+ return nil, err
+ }
+
+ user := uid.String()
+ for i := range credentials {
+ credentials[i].User = user
+ }
+
+ return credentials, err
+}
+
+// TenantCredGetAll returns credentials only from the selected tenant.
+func (a *adapter) TenantCredGetAll(tenantID t.TenantID, uid t.Uid, method string,
+ validatedOnly bool) ([]t.Credential, error) {
+ if tenantID.IsZero() {
+ return nil, t.ErrMalformed
+ }
+ query := "SELECT createdat,updatedat,method,value,resp,done,retries FROM credentials " +
+ "WHERE tenant_id=? AND userid=? AND deletedat IS NULL"
+ args := []any{tenantID, store.DecodeUid(uid)}
+ if method != "" {
+ query += " AND method=?"
+ args = append(args, method)
+ }
+ if validatedOnly {
+ query += " AND done=TRUE"
+ }
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ var credentials []t.Credential
+ if err := a.db.SelectContext(ctx, &credentials, query, args...); err != nil {
+ return nil, err
+ }
+ user := uid.String()
+ for i := range credentials {
+ credentials[i].User = user
+ }
+ return credentials, nil
+}
+
+// FileUploads
+
+// FileStartUpload initializes a file upload
+func (a *adapter) FileStartUpload(fd *t.FileDef) error {
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ var user any
+ if fd.User != "" {
+ user = store.DecodeUid(t.ParseUid(fd.User))
+ } else {
+ user = 0
+ }
+ _, err := a.db.ExecContext(ctx,
+ "INSERT INTO fileuploads(id,createdat,updatedat,userid,status,mimetype,size,etag,location) "+
+ "VALUES(?,?,?,?,?,?,?,?,?)",
+ store.DecodeUid(fd.Uid()), fd.CreatedAt, fd.UpdatedAt, user,
+ fd.Status, fd.MimeType, fd.Size, fd.ETag, fd.Location)
+ return err
+}
+
+// FileFinishUpload marks file upload as completed, successfully or otherwise
+func (a *adapter) FileFinishUpload(fd *t.FileDef, success bool, size int64) (*t.FileDef, error) {
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTxx(ctx, nil)
+ if err != nil {
+ return nil, err
+ }
+ defer func() {
+ if err != nil {
+ tx.Rollback()
+ }
+ }()
+
+ now := t.TimeNow()
+ if success {
+ _, err = tx.ExecContext(ctx, "UPDATE fileuploads SET updatedat=?,status=?,size=?,etag=?,location=? WHERE id=?",
+ now, t.UploadCompleted, size, fd.ETag, fd.Location, store.DecodeUid(fd.Uid()))
+ if err != nil {
+ return nil, err
+ }
+
+ fd.Status = t.UploadCompleted
+ fd.Size = size
+ } else {
+ // Deleting the record: there is no value in keeping it in the DB.
+ _, err = tx.ExecContext(ctx, "DELETE FROM fileuploads WHERE id=?", store.DecodeUid(fd.Uid()))
+ if err != nil {
+ return nil, err
+ }
+
+ fd.Status = t.UploadFailed
+ fd.Size = 0
+ }
+ fd.UpdatedAt = now
+
+ return fd, tx.Commit()
+}
+
+// FileGet fetches a record of a specific file
+func (a *adapter) FileGet(fid string) (*t.FileDef, error) {
+ id := t.ParseUid(fid)
+ if id.IsZero() {
+ return nil, t.ErrMalformed
+ }
+
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ var fd t.FileDef
+ err := a.db.GetContext(ctx, &fd, "SELECT id,createdat,updatedat,userid AS user,status,mimetype,size,IFNULL(etag,'') AS etag,location "+
+ "FROM fileuploads WHERE id=?", store.DecodeUid(id))
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ fd.Id = common.EncodeUidString(fd.Id).String()
+ fd.User = common.EncodeUidString(fd.User).String()
+
+ return &fd, nil
+}
+
+// FileDeleteUnused deletes records where UseCount is zero. If olderThan is non-zero, deletes
+// unused records with UpdatedAt before olderThan.
+// Returns array of FileDef.Location of deleted filerecords so actual files can be deleted too.
+func (a *adapter) FileDeleteUnused(olderThan time.Time, limit int) ([]string, error) {
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTxx(ctx, nil)
+ if err != nil {
+ return nil, err
+ }
+ defer func() {
+ if err != nil {
+ tx.Rollback()
+ }
+ }()
+
+ // Garbage collecting entries which as either marked as deleted, or lack message references, or have no user assigned.
+ query := "SELECT fu.id,fu.location FROM fileuploads AS fu LEFT JOIN filemsglinks AS fml ON fml.fileid=fu.id " +
+ "WHERE fml.id IS NULL"
+ var args []any
+ if !olderThan.IsZero() {
+ query += " AND fu.updatedat"
+ args = append(args, olderThan)
+ }
+ if limit > 0 {
+ query += " LIMIT ?"
+ args = append(args, limit)
+ }
+
+ rows, err := tx.Query(query, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var locations []string
+ var ids []any
+ for rows.Next() {
+ var id int
+ var loc string
+ if err = rows.Scan(&id, &loc); err != nil {
+ break
+ }
+ if loc != "" {
+ locations = append(locations, loc)
+ }
+ ids = append(ids, id)
+ }
+ if err == nil {
+ err = rows.Err()
+ }
+
+ if err != nil {
+ return nil, err
+ }
+
+ if len(ids) > 0 {
+ query, ids, _ = sqlx.In("DELETE FROM fileuploads WHERE id IN (?)", ids)
+ _, err = tx.Exec(query, ids...)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ return locations, tx.Commit()
+}
+
+// FileLinkAttachments connects given topic or message to the file record IDs from the list.
+func (a *adapter) FileLinkAttachments(topic string, userId, msgId t.Uid, fids []string) error {
+ if len(fids) == 0 || (topic == "" && msgId.IsZero() && userId.IsZero()) {
+ return t.ErrMalformed
+ }
+ now := t.TimeNow()
+
+ var args []any
+ var linkId any
+ var linkBy string
+ if !msgId.IsZero() {
+ linkBy = "msgid"
+ linkId = int64(msgId)
+ } else if topic != "" {
+ linkBy = "topic"
+ linkId = topic
+ // Only one attachment per topic is permitted at this time.
+ fids = fids[0:1]
+ } else {
+ linkBy = "userid"
+ linkId = store.DecodeUid(userId)
+ // Only one attachment per user is permitted at this time.
+ fids = fids[0:1]
+ }
+
+ // Decoded ids
+ var dids []any
+ for _, fid := range fids {
+ id := t.ParseUid(fid)
+ if id.IsZero() {
+ return t.ErrMalformed
+ }
+ dids = append(dids, store.DecodeUid(id))
+ }
+
+ for _, id := range dids {
+ // createdat,fileid,[msgid|topic|userid]
+ args = append(args, now, id, linkId)
+ }
+
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTxx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err != nil {
+ tx.Rollback()
+ }
+ }()
+
+ // Unlink earlier uploads on the same topic or user allowing them to be garbage-collected.
+ if msgId.IsZero() {
+ sql := "DELETE FROM filemsglinks WHERE " + linkBy + "=?"
+ _, err = tx.Exec(sql, linkId)
+ if err != nil {
+ return err
+ }
+ }
+
+ sql := "INSERT INTO filemsglinks(createdat,fileid," + linkBy + ") VALUES (?,?,?)"
+ _, err = tx.Exec(sql+strings.Repeat(",(?,?,?)", len(dids)-1), args...)
+ if err != nil {
+ return err
+ }
+
+ return tx.Commit()
+}
+
+// PCacheGet reads a persistet cache entry.
+func (a *adapter) PCacheGet(key string) (string, error) {
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+
+ var value string
+ if err := a.db.GetContext(ctx, &value, "SELECT `value` FROM kvmeta WHERE `key`=? LIMIT 1", key); err != nil {
+ if err == sql.ErrNoRows {
+ return "", t.ErrNotFound
+ }
+ return "", err
+ }
+ return value, nil
+}
+
+// PCacheUpsert creates or updates a persistent cache entry.
+func (a *adapter) PCacheUpsert(key string, value string, failOnDuplicate bool) error {
+ if strings.Contains(key, "%") {
+ // Do not allow % in keys: it interferes with LIKE query.
+ return t.ErrMalformed
+ }
+
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+
+ var action string
+ if failOnDuplicate {
+ action = "INSERT"
+ } else {
+ action = "REPLACE"
+ }
+
+ _, err := a.db.ExecContext(ctx, action+" INTO kvmeta(`key`,createdat,`value`) VALUES(?,?,?)", key, t.TimeNow(), value)
+ if isDupe(err) {
+ return t.ErrDuplicate
+ }
+ return err
+}
+
+// PCacheDelete deletes one persistent cache entry.
+func (a *adapter) PCacheDelete(key string) error {
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+
+ _, err := a.db.ExecContext(ctx, "DELETE FROM kvmeta WHERE `key`=?", key)
+ return err
+}
+
+// PCacheExpire expires old entries with the given key prefix.
+func (a *adapter) PCacheExpire(keyPrefix string, olderThan time.Time) error {
+ if keyPrefix == "" {
+ return t.ErrMalformed
+ }
+
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+
+ _, err := a.db.ExecContext(ctx, "DELETE FROM kvmeta WHERE `key` LIKE ? AND createdat", keyPrefix+"%", olderThan)
+ return err
+}
+
+// GetTestDB returns a currently open database connection.
+func (a *adapter) GetTestDB() any {
+ return a.db
+}
+
+// Helper functions
+
+// Check if MySQL error is a Error Code: 1062. Duplicate entry ... for key ...
+func isDupe(err error) bool {
+ if err == nil {
+ return false
+ }
+
+ myerr, ok := err.(*ms.MySQLError)
+ return ok && myerr.Number == 1062
+}
+
+func isMissingTable(err error) bool {
+ if err == nil {
+ return false
+ }
+
+ myerr, ok := err.(*ms.MySQLError)
+ return ok && myerr.Number == 1146
+}
+
+func isMissingDb(err error) bool {
+ if err == nil {
+ return false
+ }
+
+ myerr, ok := err.(*ms.MySQLError)
+ return ok && myerr.Number == 1049
+}
+
+// GetTestAdapter returns an adapter object. Useful for running tests.
+func GetTestAdapter() *adapter {
+ return &adapter{}
+}
+
+func init() {
+ store.RegisterAdapter(&adapter{})
+}
diff --git a/server/db/mysql/schema.sql b/server/db/mysql/schema.sql
index e69de29..c11df06 100644
--- a/server/db/mysql/schema.sql
+++ b/server/db/mysql/schema.sql
@@ -0,0 +1,271 @@
+# THIS SCHEMA FILE IS FOR REFERENCE/DOCUMENTATION ONLY!
+# DO NOT USE IT TO INITIALIZE THE DATABASE.
+# Read installation instructions first.
+
+# The following line will produce an intentional error.
+
+'READ INSTALLATION INSTRUCTIONS!';
+
+# The actual schema is below.
+
+DROP DATABASE IF EXISTS tinode;
+
+CREATE DATABASE tinode CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
+
+USE tinode;
+
+CREATE TABLE im_tenant(
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ code VARCHAR(64) NOT NULL,
+ name VARCHAR(128) NOT NULL,
+ tenant_desc VARCHAR(256),
+ state SMALLINT NOT NULL,
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ created_by BIGINT NOT NULL,
+ updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
+ updated_by BIGINT NOT NULL,
+
+ PRIMARY KEY(id),
+ UNIQUE INDEX uk_im_tenant_code(code)
+);
+
+CREATE TABLE im_tenant_config(
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ tenant_id BIGINT NOT NULL,
+ max_users BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
+ max_groups BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
+ max_group_members INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ created_by BIGINT NOT NULL,
+ updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
+ updated_by BIGINT NOT NULL,
+
+ PRIMARY KEY(id),
+ UNIQUE INDEX uk_im_tenant_config_tenant_id(tenant_id),
+ CONSTRAINT fk_im_tenant_config_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
+);
+
+INSERT INTO im_tenant(code,name,tenant_desc,state,created_by,updated_by)
+VALUES('default','Default Tenant',NULL,1,0,0);
+
+
+CREATE TABLE kvmeta(
+ `key` VARCHAR(64),
+ createdat DATETIME(3),
+ `value` TEXT,
+ PRIMARY KEY(`key`),
+ INDEX kvmeta_createdat_key(createdat, `key`)
+);
+
+INSERT INTO kvmeta(`key`, `value`) VALUES("version", "117");
+
+CREATE TABLE users(
+ id BIGINT NOT NULL,
+ createdat DATETIME(3) NOT NULL,
+ updatedat DATETIME(3) NOT NULL,
+ state SMALLINT NOT NULL DEFAULT 0,
+ stateat DATETIME(3),
+ access JSON,
+ lastseen DATETIME,
+ useragent VARCHAR(255) DEFAULT '',
+ public JSON,
+ tags JSON, -- Denormalized array of tags
+
+ PRIMARY KEY(id),
+ INDEX users_state_stateat(state, stateat),
+ INDEX users_lastseen_updatedat(lastseen, updatedat)
+);
+
+# Indexed user tags.
+CREATE TABLE usertags(
+ id INT NOT NULL AUTO_INCREMENT,
+ userid BIGINT NOT NULL,
+ tag VARCHAR(96) NOT NULL,
+
+ PRIMARY KEY(id),
+ FOREIGN KEY(userid) REFERENCES users(id),
+ INDEX usertags_tag(tag),
+ UNIQUE INDEX usertags_userid_tag(userid, tag)
+);
+
+# Indexed devices. Normalized into a separate table.
+CREATE TABLE devices(
+ id INT NOT NULL AUTO_INCREMENT,
+ userid BIGINT NOT NULL,
+ hash CHAR(16) NOT NULL,
+ deviceid TEXT NOT NULL,
+ platform VARCHAR(32),
+ lastseen DATETIME NOT NULL,
+ lang VARCHAR(8),
+
+ PRIMARY KEY(id),
+ FOREIGN KEY(userid) REFERENCES users(id),
+ UNIQUE INDEX devices_hash(hash)
+);
+
+# Authentication records for the basic authentication scheme.
+CREATE TABLE auth(
+ id INT NOT NULL AUTO_INCREMENT,
+ uname VARCHAR(32) NOT NULL,
+ userid BIGINT NOT NULL,
+ scheme VARCHAR(16) NOT NULL,
+ authlvl SMALLINT NOT NULL,
+ secret VARCHAR(255) NOT NULL,
+ expires DATETIME,
+
+ PRIMARY KEY(id),
+ FOREIGN KEY(userid) REFERENCES users(id),
+ UNIQUE INDEX auth_userid_scheme(userid, scheme),
+ UNIQUE INDEX auth_uname (uname)
+);
+
+
+# Topics
+CREATE TABLE topics(
+ id INT NOT NULL AUTO_INCREMENT,
+ createdat DATETIME(3) NOT NULL,
+ updatedat DATETIME(3) NOT NULL,
+ touchedat DATETIME(3),
+ state SMALLINT NOT NULL DEFAULT 0,
+ stateat DATETIME(3),
+ name CHAR(25) NOT NULL,
+ usebt TINYINT DEFAULT 0,
+ owner BIGINT NOT NULL DEFAULT 0,
+ access JSON,
+ seqid INT NOT NULL DEFAULT 0,
+ delid INT DEFAULT 0,
+ subcnt INT DEFAULT 0,
+ public JSON,
+ trusted JSON,
+ tags JSON, -- Denormalized array of tags
+ aux JSON,
+
+ PRIMARY KEY(id),
+ UNIQUE INDEX topics_name (name),
+ INDEX topics_owner(owner),
+ INDEX topics_state_stateat(state, stateat),
+ INDEX topics_name_state_seqid ON topics(name, state, seqid)
+);
+
+# Indexed topic tags.
+CREATE TABLE topictags(
+ id INT NOT NULL AUTO_INCREMENT,
+ topic CHAR(25) NOT NULL,
+ tag VARCHAR(96) NOT NULL,
+
+ PRIMARY KEY(id),
+ FOREIGN KEY(topic) REFERENCES topics(name),
+ INDEX topictags_tag (tag),
+ UNIQUE INDEX topictags_topic_tag(topic, tag)
+);
+
+# Subscriptions
+CREATE TABLE subscriptions(
+ id INT NOT NULL AUTO_INCREMENT,
+ createdat DATETIME(3) NOT NULL,
+ updatedat DATETIME(3) NOT NULL,
+ deletedat DATETIME(3),
+ userid BIGINT NOT NULL,
+ topic CHAR(25) NOT NULL,
+ delid INT DEFAULT 0,
+ recvseqid INT DEFAULT 0,
+ readseqid INT DEFAULT 0,
+ modewant CHAR(8),
+ modegiven CHAR(8),
+ private JSON,
+
+ PRIMARY KEY(id) ,
+ FOREIGN KEY(userid) REFERENCES users(id),
+ UNIQUE INDEX subscriptions_topic_userid(topic, userid),
+ INDEX subscriptions_topic(topic),
+ INDEX subscriptions_deletedat(deletedat),
+ INDEX subscriptions_user_topic_deletedat ON subscriptions(userid, topic, deletedat)
+);
+
+# Messages
+CREATE TABLE messages(
+ id INT NOT NULL AUTO_INCREMENT,
+ createdat DATETIME(3) NOT NULL,
+ updatedat DATETIME(3) NOT NULL,
+ deletedat DATETIME(3),
+ delid INT DEFAULT 0,
+ seqid INT NOT NULL,
+ topic CHAR(25) NOT NULL,
+ `from` BIGINT NOT NULL,
+ head JSON,
+ content JSON,
+
+ PRIMARY KEY(id),
+ FOREIGN KEY(topic) REFERENCES topics(name),
+ UNIQUE INDEX messages_topic_seqid (topic, seqid)
+);
+
+# Deletion log
+CREATE TABLE dellog(
+ id INT NOT NULL AUTO_INCREMENT,
+ topic CHAR(25) NOT NULL,
+ deletedfor BIGINT NOT NULL DEFAULT 0,
+ delid INT NOT NULL,
+ low INT NOT NULL,
+ hi INT NOT NULL,
+
+ PRIMARY KEY(id),
+ FOREIGN KEY(topic) REFERENCES topics(name),
+ # For getting the list of deleted message ranges
+ INDEX dellog_topic_delid_deletedfor(topic,delid,deletedfor),
+ # Used when getting not-yet-deleted messages(messages LEFT JOIN dellog)
+ INDEX dellog_topic_deletedfor_low_hi(topic,deletedfor,low,hi),
+ # Used when deleting a user
+ INDEX dellog_deletedfor(deletedfor)
+);
+
+# User credentials
+CREATE TABLE credentials(
+ id INT NOT NULL AUTO_INCREMENT,
+ createdat DATETIME(3) NOT NULL,
+ updatedat DATETIME(3) NOT NULL,
+ deletedat DATETIME(3),
+ method VARCHAR(16) NOT NULL,
+ value VARCHAR(128) NOT NULL,
+ synthetic VARCHAR(192) NOT NULL,
+ userid BIGINT NOT NULL,
+ resp VARCHAR(255) NOT NULL,
+ done TINYINT NOT NULL DEFAULT 0,
+ retries INT NOT NULL DEFAULT 0,
+
+ PRIMARY KEY(id),
+ UNIQUE credentials_uniqueness(synthetic),
+ FOREIGN KEY(userid) REFERENCES users(id),
+);
+
+# Records of uploaded files. Files themselves are stored elsewhere.
+CREATE TABLE fileuploads(
+ id BIGINT NOT NULL,
+ createdat DATETIME(3) NOT NULL,
+ updatedat DATETIME(3) NOT NULL,
+ userid BIGINT,
+ status INT NOT NULL,
+ mimetype VARCHAR(255) NOT NULL,
+ size BIGINT NOT NULL,
+ location VARCHAR(2048) NOT NULL,
+ etag VARCHAR(128),
+
+ PRIMARY KEY(id),
+ INDEX fileuploads_status(status)
+);
+
+# Links between uploaded files and messages or topics.
+CREATE TABLE filemsglinks(
+ id INT NOT NULL AUTO_INCREMENT,
+ createdat DATETIME(3) NOT NULL,
+ fileid BIGINT NOT NULL,
+ msgid INT,
+ topic CHAR(25),
+ userid BIGINT,
+
+ PRIMARY KEY(id),
+ FOREIGN KEY(fileid) REFERENCES fileuploads(id) ON DELETE CASCADE,
+ FOREIGN KEY(msgid) REFERENCES messages(id) ON DELETE CASCADE,
+ FOREIGN KEY(topicid) REFERENCES topics(id) ON DELETE CASCADE,
+ FOREIGN KEY(userid) REFERENCES users(id) ON DELETE CASCADE
+);
diff --git a/server/db/mysql/tenant_adapter.go b/server/db/mysql/tenant_adapter.go
new file mode 100644
index 0000000..0acaa74
--- /dev/null
+++ b/server/db/mysql/tenant_adapter.go
@@ -0,0 +1,1834 @@
+//go:build mysql
+// +build mysql
+
+package mysql
+
+import (
+ "database/sql"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/jmoiron/sqlx"
+ "github.com/tinode/chat/server/db/common"
+ "github.com/tinode/chat/server/store"
+ t "github.com/tinode/chat/server/store/types"
+)
+
+func (*adapter) TenantBusinessReady() bool { return true }
+
+func validTenant(id t.TenantID) error {
+ if !id.IsValid() {
+ return t.ErrMalformed
+ }
+ return nil
+}
+
+func tenantCacheKey(id t.TenantID, key string) (string, error) {
+ if key == "" || strings.Contains(key, "%") {
+ return "", t.ErrMalformed
+ }
+ return "tenant:" + strconv.FormatInt(int64(id), 10) + ":" + key, nil
+}
+
+func decodeUID(uid t.Uid) int64 {
+ return store.DecodeUid(uid)
+}
+
+func encodeUserID(id int64) string {
+ return store.EncodeUid(id).String()
+}
+
+func scanTenantUser(user *t.User) {
+ user.SetUid(common.EncodeUidString(user.Id))
+ user.Public = common.FromJSON(user.Public)
+ user.Trusted = common.FromJSON(user.Trusted)
+}
+
+func scanTenantTopic(topic *t.Topic) {
+ topic.Owner = common.EncodeUidString(topic.Owner).String()
+ topic.Public = common.FromJSON(topic.Public)
+ topic.Trusted = common.FromJSON(topic.Trusted)
+}
+
+func (a *adapter) TenantUserCreate(id t.TenantID, user *t.User) error {
+ if err := validTenant(id); err != nil || user == nil || user.TenantID != id {
+ return t.ErrMalformed
+ }
+ return a.UserCreate(user)
+}
+
+func (a *adapter) TenantUserGet(id t.TenantID, uid t.Uid) (*t.User, error) {
+ if err := validTenant(id); err != nil || uid.IsZero() {
+ return nil, t.ErrMalformed
+ }
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ var user t.User
+ err := a.db.GetContext(ctx, &user, "SELECT * FROM users WHERE tenant_id=? AND id=? AND state!=?",
+ id, decodeUID(uid), t.StateDeleted)
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ scanTenantUser(&user)
+ return &user, nil
+}
+
+func (a *adapter) TenantUserGetAll(id t.TenantID, uids ...t.Uid) ([]t.User, error) {
+ if err := validTenant(id); err != nil {
+ return nil, err
+ }
+ if len(uids) == 0 {
+ return nil, nil
+ }
+ args := make([]any, 0, len(uids))
+ for _, uid := range uids {
+ if !uid.IsZero() {
+ args = append(args, decodeUID(uid))
+ }
+ }
+ if len(args) == 0 {
+ return nil, nil
+ }
+ q, params, _ := sqlx.In("SELECT * FROM users WHERE tenant_id=? AND id IN (?) AND state!=?", id, args, t.StateDeleted)
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ rows, err := a.db.QueryxContext(ctx, a.db.Rebind(q), params...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var users []t.User
+ for rows.Next() {
+ var user t.User
+ if err = rows.StructScan(&user); err != nil {
+ return nil, err
+ }
+ scanTenantUser(&user)
+ users = append(users, user)
+ }
+ return users, rows.Err()
+}
+
+func (a *adapter) TenantUserDelete(id t.TenantID, uid t.Uid, hard bool) error {
+ if err := validTenant(id); err != nil || uid.IsZero() {
+ return t.ErrMalformed
+ }
+ decoded := decodeUID(uid)
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTxx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err != nil {
+ tx.Rollback()
+ }
+ }()
+
+ var ownTopics []string
+ q := "SELECT name FROM topics WHERE tenant_id=? AND owner=?"
+ args := []any{id, decoded}
+ if !hard {
+ q += " AND state!=?"
+ args = append(args, t.StateDeleted)
+ }
+ if err = tx.Select(&ownTopics, q, args...); err != nil {
+ return err
+ }
+ var ownAll []any
+ for _, name := range ownTopics {
+ ownAll = append(ownAll, name)
+ if chn := t.GrpToChn(name); chn != "" {
+ ownAll = append(ownAll, chn)
+ }
+ }
+
+ now := t.TimeNow()
+ if hard {
+ if _, err = tx.Exec("DELETE FROM devices WHERE tenant_id=? AND userid=?", id, decoded); err != nil {
+ return err
+ }
+ if err = tenantSubsDelForUser(tx, id, decoded, true); err != nil {
+ return err
+ }
+ if _, err = tx.Exec("DELETE FROM dellog WHERE tenant_id=? AND deletedfor=?", id, decoded); err != nil {
+ return err
+ }
+ if len(ownTopics) > 0 {
+ if _, err = tx.Exec("DELETE dl FROM dellog AS dl JOIN topics AS tp ON tp.tenant_id=dl.tenant_id AND tp.name=dl.topic WHERE tp.tenant_id=? AND tp.owner=?", id, decoded); err != nil {
+ return err
+ }
+ if _, err = tx.Exec("DELETE m FROM messages AS m JOIN topics AS tp ON tp.tenant_id=m.tenant_id AND tp.name=m.topic WHERE tp.tenant_id=? AND tp.owner=?", id, decoded); err != nil {
+ return err
+ }
+ sqlq, params, _ := sqlx.In("DELETE FROM subscriptions WHERE tenant_id=? AND topic IN (?)", id, ownAll)
+ if _, err = tx.Exec(tx.Rebind(sqlq), params...); err != nil {
+ return err
+ }
+ if _, err = tx.Exec("DELETE tt FROM topictags AS tt JOIN topics AS tp ON tp.tenant_id=tt.tenant_id AND tp.name=tt.topic WHERE tp.tenant_id=? AND tp.owner=?", id, decoded); err != nil {
+ return err
+ }
+ if _, err = tx.Exec("DELETE FROM topics WHERE tenant_id=? AND owner=?", id, decoded); err != nil {
+ return err
+ }
+ }
+ if _, err = tx.Exec("DELETE FROM auth WHERE tenant_id=? AND userid=?", id, decoded); err != nil {
+ return err
+ }
+ if _, err = tx.Exec("DELETE FROM credentials WHERE tenant_id=? AND userid=?", id, decoded); err != nil {
+ return err
+ }
+ if _, err = tx.Exec("DELETE FROM usertags WHERE tenant_id=? AND userid=?", id, decoded); err != nil {
+ return err
+ }
+ if _, err = tx.Exec("DELETE FROM users WHERE tenant_id=? AND id=?", id, decoded); err != nil {
+ return err
+ }
+ } else {
+ if err = tenantSubsDelForUser(tx, id, decoded, false); err != nil {
+ return err
+ }
+ if len(ownAll) > 0 {
+ sqlq, params, _ := sqlx.In("UPDATE subscriptions SET updatedat=?,deletedat=? WHERE tenant_id=? AND topic IN (?)", now, now, id, ownAll)
+ if _, err = tx.Exec(tx.Rebind(sqlq), params...); err != nil {
+ return err
+ }
+ }
+ if _, err = tx.Exec("UPDATE topics SET updatedat=?,touchedat=?,state=?,stateat=? WHERE tenant_id=? AND owner=?",
+ now, now, t.StateDeleted, now, id, decoded); err != nil {
+ return err
+ }
+ if _, err = tx.Exec("UPDATE topics AS tp JOIN subscriptions AS s ON tp.tenant_id=s.tenant_id AND tp.name=s.topic "+
+ "SET tp.updatedat=?,tp.touchedat=?,tp.state=?,tp.stateat=? WHERE tp.tenant_id=? AND tp.owner=0 AND s.userid=? AND tp.name LIKE 'p2p%'",
+ now, now, t.StateDeleted, now, id, decoded); err != nil {
+ return err
+ }
+ if _, err = tx.Exec("UPDATE subscriptions AS s_one JOIN subscriptions AS s_two ON s_one.tenant_id=s_two.tenant_id AND s_one.topic=s_two.topic "+
+ "SET s_two.updatedat=?,s_two.deletedat=? WHERE s_one.tenant_id=? AND s_one.userid=? AND s_one.topic LIKE 'p2p%'",
+ now, now, id, decoded); err != nil {
+ return err
+ }
+ if _, err = tx.Exec("UPDATE users SET updatedat=?,state=?,stateat=? WHERE tenant_id=? AND id=?",
+ now, t.StateDeleted, now, id, decoded); err != nil {
+ return err
+ }
+ }
+ return tx.Commit()
+}
+
+func (a *adapter) tenantTopicStateForUser(tx *sqlx.Tx, tenantID t.TenantID, decodedUID int64, now time.Time, state any) error {
+ objState, ok := state.(t.ObjState)
+ if !ok {
+ return t.ErrMalformed
+ }
+ if now.IsZero() {
+ now = t.TimeNow()
+ }
+ if _, err := tx.Exec("UPDATE topics SET state=?,stateat=? WHERE tenant_id=? AND owner=? AND state!=?",
+ objState, now, tenantID, decodedUID, t.StateDeleted); err != nil {
+ return err
+ }
+ _, err := tx.Exec("UPDATE topics AS tp JOIN subscriptions AS s ON tp.tenant_id=s.tenant_id AND tp.name=s.topic "+
+ "SET tp.state=?,tp.stateat=? WHERE tp.tenant_id=? AND tp.owner=0 AND s.userid=? AND tp.state!=?",
+ objState, now, tenantID, decodedUID, t.StateDeleted)
+ return err
+}
+
+func (a *adapter) TenantUserUpdate(id t.TenantID, uid t.Uid, update map[string]any) error {
+ if err := validTenant(id); err != nil || uid.IsZero() {
+ return t.ErrMalformed
+ }
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTxx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err != nil {
+ tx.Rollback()
+ }
+ }()
+ decoded := decodeUID(uid)
+ cols, args := common.UpdateByMap(update)
+ args = append(args, id, decoded)
+ res, err := tx.Exec("UPDATE users SET "+strings.Join(cols, ",")+" WHERE tenant_id=? AND id=?", args...)
+ if err != nil {
+ return err
+ }
+ if count, _ := res.RowsAffected(); count == 0 {
+ return t.ErrNotFound
+ }
+ if state, ok := update["State"]; ok {
+ now, _ := update["StateAt"].(time.Time)
+ if err = a.tenantTopicStateForUser(tx, id, decoded, now, state); err != nil {
+ return err
+ }
+ }
+ if tags := common.ExtractTags(update); tags != nil {
+ if _, err = tx.Exec("DELETE FROM usertags WHERE tenant_id=? AND userid=?", id, decoded); err != nil {
+ return err
+ }
+ if err = addTenantTags(tx, id, "usertags", "userid", decoded, tags, false); err != nil {
+ return err
+ }
+ }
+ return tx.Commit()
+}
+
+func (a *adapter) TenantUserUpdateTags(id t.TenantID, uid t.Uid, add, remove, reset []string) ([]string, error) {
+ if err := validTenant(id); err != nil || uid.IsZero() {
+ return nil, t.ErrMalformed
+ }
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTxx(ctx, nil)
+ if err != nil {
+ return nil, err
+ }
+ defer func() {
+ if err != nil {
+ tx.Rollback()
+ }
+ }()
+ decoded := decodeUID(uid)
+ if reset != nil {
+ if _, err = tx.Exec("DELETE FROM usertags WHERE tenant_id=? AND userid=?", id, decoded); err != nil {
+ return nil, err
+ }
+ add, remove = reset, nil
+ }
+ if err = addTenantTags(tx, id, "usertags", "userid", decoded, add, reset == nil); err != nil {
+ return nil, err
+ }
+ if len(remove) > 0 {
+ q, params, _ := sqlx.In("DELETE FROM usertags WHERE tenant_id=? AND userid=? AND tag IN (?)", id, decoded, remove)
+ if _, err = tx.Exec(tx.Rebind(q), params...); err != nil {
+ return nil, err
+ }
+ }
+ var allTags []string
+ if err = tx.Select(&allTags, "SELECT tag FROM usertags WHERE tenant_id=? AND userid=?", id, decoded); err != nil {
+ return nil, err
+ }
+ if _, err = tx.Exec("UPDATE users SET tags=? WHERE tenant_id=? AND id=?", t.StringSlice(allTags), id, decoded); err != nil {
+ return nil, err
+ }
+ return allTags, tx.Commit()
+}
+
+func (a *adapter) TenantUserUnreadCount(id t.TenantID, uids ...t.Uid) (map[t.Uid]int, error) {
+ if err := validTenant(id); err != nil {
+ return nil, err
+ }
+ counts := make(map[t.Uid]int, len(uids))
+ var decoded []any
+ for _, uid := range uids {
+ counts[uid] = 0
+ if !uid.IsZero() {
+ decoded = append(decoded, decodeUID(uid))
+ }
+ }
+ if len(decoded) == 0 {
+ return counts, nil
+ }
+ q, args, _ := sqlx.In("SELECT s.userid,SUM(tp.seqid)-SUM(s.readseqid) AS unreadcount "+
+ "FROM topics AS tp JOIN subscriptions AS s ON tp.tenant_id=s.tenant_id AND tp.name=s.topic "+
+ "WHERE s.tenant_id=? AND s.userid IN (?) AND s.deletedat IS NULL AND tp.state!=? "+
+ "AND INSTR(s.modewant,'R')>0 AND INSTR(s.modegiven,'R')>0 GROUP BY s.userid", id, decoded, t.StateDeleted)
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ rows, err := a.db.QueryxContext(ctx, a.db.Rebind(q), args...)
+ if err != nil {
+ return counts, err
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var uid int64
+ var unread int
+ if err = rows.Scan(&uid, &unread); err != nil {
+ return counts, err
+ }
+ counts[store.EncodeUid(uid)] = unread
+ }
+ return counts, rows.Err()
+}
+
+func (a *adapter) TenantUserGetUnvalidated(id t.TenantID, before time.Time, limit int) ([]t.Uid, error) {
+ if err := validTenant(id); err != nil {
+ return nil, err
+ }
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ rows, err := a.db.QueryxContext(ctx,
+ "SELECT u.id,IFNULL(SUM(c.done),0) AS total FROM users AS u "+
+ "LEFT JOIN credentials AS c ON u.tenant_id=c.tenant_id AND u.id=c.userid "+
+ "WHERE u.tenant_id=? AND u.lastseen IS NULL AND u.updatedat "+
+ "GROUP BY u.id,u.updatedat HAVING total=0 ORDER BY u.updatedat ASC LIMIT ?", id, before, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var uids []t.Uid
+ for rows.Next() {
+ var userID int64
+ var unused int
+ if err = rows.Scan(&userID, &unused); err != nil {
+ return nil, err
+ }
+ uids = append(uids, store.EncodeUid(userID))
+ }
+ return uids, rows.Err()
+}
+
+func tenantTopicCreate(tx *sqlx.Tx, topic *t.Topic) error {
+ if topic == nil || topic.TenantID.IsZero() {
+ return t.ErrMalformed
+ }
+ _, err := tx.Exec("INSERT INTO topics(tenant_id,createdat,updatedat,touchedat,state,name,usebt,owner,access,public,trusted,tags,aux) "+
+ "VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)",
+ topic.TenantID, topic.CreatedAt, topic.UpdatedAt, topic.TouchedAt, topic.State, topic.Id, topic.UseBt,
+ decodeUID(t.ParseUid(topic.Owner)), topic.Access, common.ToJSON(topic.Public), common.ToJSON(topic.Trusted),
+ topic.Tags, common.ToJSON(topic.Aux))
+ if err != nil {
+ return err
+ }
+ return addTenantTags(tx, topic.TenantID, "topictags", "topic", topic.Id, topic.Tags, false)
+}
+
+func (a *adapter) TenantTopicCreate(id t.TenantID, topic *t.Topic) error {
+ if err := validTenant(id); err != nil || topic == nil || topic.TenantID != id {
+ return t.ErrMalformed
+ }
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTxx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err != nil {
+ tx.Rollback()
+ }
+ }()
+ if err = tenantTopicCreate(tx, topic); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
+
+func (a *adapter) TenantTopicCreateP2P(id t.TenantID, one, two *t.Subscription) error {
+ if err := validTenant(id); err != nil || one == nil || two == nil || one.TenantID != id || two.TenantID != id {
+ return t.ErrMalformed
+ }
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTxx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err != nil {
+ tx.Rollback()
+ }
+ }()
+ if err = createSubscription(tx, one, false); err != nil {
+ return err
+ }
+ if err = createSubscription(tx, two, true); err != nil {
+ return err
+ }
+ topic := &t.Topic{TenantID: id, ObjHeader: t.ObjHeader{Id: one.Topic}}
+ topic.ObjHeader.MergeTimes(&one.ObjHeader)
+ topic.TouchedAt = one.GetTouchedAt()
+ if err = tenantTopicCreate(tx, topic); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
+
+func (a *adapter) TenantTopicGet(id t.TenantID, topic string) (*t.Topic, error) {
+ if err := validTenant(id); err != nil || topic == "" {
+ return nil, t.ErrMalformed
+ }
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ var top t.Topic
+ err := a.db.GetContext(ctx, &top,
+ "SELECT tenant_id,createdat,updatedat,state,stateat,touchedat,name AS id,usebt,access,owner,seqid,delid,subcnt,public,trusted,tags,aux "+
+ "FROM topics WHERE tenant_id=? AND name=?", id, topic)
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ if t.GetTopicCat(topic) == t.TopicCatGrp {
+ var subCnt int
+ if err = a.db.GetContext(ctx, &subCnt,
+ "SELECT COUNT(*) FROM subscriptions WHERE tenant_id=? AND topic IN (?,?) AND deletedat IS NULL",
+ id, topic, t.GrpToChn(topic)); err != nil {
+ return nil, err
+ }
+ if subCnt != top.SubCnt {
+ top.SubCnt = subCnt
+ if _, err = a.db.ExecContext(ctx, "UPDATE topics SET subcnt=? WHERE tenant_id=? AND name=?", subCnt, id, topic); err != nil {
+ return nil, err
+ }
+ }
+ }
+ scanTenantTopic(&top)
+ return &top, nil
+}
+
+func (a *adapter) TenantTopicsForUser(id t.TenantID, uid t.Uid, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) {
+ if err := validTenant(id); err != nil || uid.IsZero() {
+ return nil, t.ErrMalformed
+ }
+ q := `SELECT tenant_id,createdat,updatedat,deletedat,topic,delid,recvseqid,
+ readseqid,modewant,modegiven,private FROM subscriptions WHERE tenant_id=? AND userid=?`
+ args := []any{id, decodeUID(uid)}
+ if !keepDeleted {
+ q += " AND deletedat IS NULL"
+ }
+ limit := 0
+ ims := time.Time{}
+ if opts != nil {
+ if opts.Topic != "" {
+ q += " AND topic=?"
+ args = append(args, opts.Topic)
+ }
+ if opts.IfModifiedSince == nil {
+ limit = a.maxResults
+ if opts.Limit > 0 && opts.Limit < limit {
+ limit = opts.Limit
+ }
+ } else {
+ ims = *opts.IfModifiedSince
+ }
+ } else {
+ limit = a.maxResults
+ }
+ if limit > 0 {
+ q += " LIMIT ?"
+ args = append(args, limit)
+ }
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ rows, err := a.db.QueryxContext(ctx, q, args...)
+ if err != nil {
+ return nil, err
+ }
+ join := map[string]t.Subscription{}
+ var topq, usrq []any
+ for rows.Next() {
+ var sub t.Subscription
+ if err = rows.StructScan(&sub); err != nil {
+ rows.Close()
+ return nil, err
+ }
+ tname := sub.Topic
+ sub.User = uid.String()
+ switch t.GetTopicCat(tname) {
+ case t.TopicCatMe, t.TopicCatFnd:
+ continue
+ case t.TopicCatP2P:
+ uid1, uid2, _ := t.ParseP2P(tname)
+ if uid1 == uid {
+ usrq = append(usrq, decodeUID(uid2))
+ sub.SetWith(uid2.UserId())
+ } else {
+ usrq = append(usrq, decodeUID(uid1))
+ sub.SetWith(uid1.UserId())
+ }
+ case t.TopicCatGrp:
+ tname = t.ChnToGrp(tname)
+ }
+ topq = append(topq, tname)
+ sub.Private = common.FromJSON(sub.Private)
+ join[tname] = sub
+ }
+ err = rows.Err()
+ rows.Close()
+ if err != nil || len(join) == 0 {
+ return nil, err
+ }
+ if len(topq) > 0 {
+ q = "SELECT tenant_id,updatedat,state,touchedat,name AS id,usebt,access,seqid,delid,subcnt,public,trusted FROM topics WHERE tenant_id=? AND name IN (?)"
+ q, args, _ = sqlx.In(q, id, topq)
+ if !keepDeleted {
+ q += " AND state!=?"
+ args = append(args, t.StateDeleted)
+ }
+ if !ims.IsZero() {
+ q += " AND touchedat>?"
+ args = append(args, ims)
+ if limit > 0 && limit < len(topq) {
+ q += " ORDER BY touchedat LIMIT ?"
+ args = append(args, limit)
+ }
+ }
+ rows, err = a.db.QueryxContext(ctx, a.db.Rebind(q), args...)
+ if err != nil {
+ return nil, err
+ }
+ for rows.Next() {
+ var top t.Topic
+ if err = rows.StructScan(&top); err != nil {
+ rows.Close()
+ return nil, err
+ }
+ sub := join[top.Id]
+ sub.UpdatedAt = common.SelectLatestTime(sub.UpdatedAt, top.UpdatedAt)
+ sub.SetState(top.State)
+ sub.SetTouchedAt(top.TouchedAt)
+ sub.SetSeqId(top.SeqId)
+ if t.GetTopicCat(sub.Topic) == t.TopicCatGrp {
+ sub.SetSubCnt(top.SubCnt)
+ sub.SetPublic(common.FromJSON(top.Public))
+ sub.SetTrusted(common.FromJSON(top.Trusted))
+ }
+ join[top.Id] = sub
+ }
+ err = rows.Err()
+ rows.Close()
+ if err != nil {
+ return nil, err
+ }
+ }
+ if len(usrq) > 0 {
+ q = "SELECT tenant_id,id,updatedat,state,access,lastseen,useragent,public,trusted FROM users WHERE tenant_id=? AND id IN (?)"
+ q, args, _ = sqlx.In(q, id, usrq)
+ if !keepDeleted {
+ q += " AND state!=?"
+ args = append(args, t.StateDeleted)
+ }
+ rows, err = a.db.QueryxContext(ctx, a.db.Rebind(q), args...)
+ if err != nil {
+ return nil, err
+ }
+ for rows.Next() {
+ var user t.User
+ if err = rows.StructScan(&user); err != nil {
+ rows.Close()
+ return nil, err
+ }
+ joinOn := uid.P2PName(common.EncodeUidString(user.Id))
+ if sub, ok := join[joinOn]; ok {
+ sub.UpdatedAt = common.SelectLatestTime(sub.UpdatedAt, user.UpdatedAt)
+ sub.SetState(user.State)
+ sub.SetPublic(common.FromJSON(user.Public))
+ sub.SetTrusted(common.FromJSON(user.Trusted))
+ sub.SetDefaultAccess(user.Access.Auth, user.Access.Anon)
+ sub.SetLastSeenAndUA(user.LastSeen, user.UserAgent)
+ join[joinOn] = sub
+ }
+ }
+ err = rows.Err()
+ rows.Close()
+ if err != nil {
+ return nil, err
+ }
+ }
+ subs := make([]t.Subscription, 0, len(join))
+ for _, sub := range join {
+ subs = append(subs, sub)
+ }
+ return common.SelectEarliestUpdatedSubs(subs, opts, a.maxResults), nil
+}
+
+func (a *adapter) TenantUsersForTopic(id t.TenantID, topic string, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) {
+ if err := validTenant(id); err != nil || topic == "" {
+ return nil, t.ErrMalformed
+ }
+ tcat := t.GetTopicCat(topic)
+ q := `SELECT s.tenant_id,s.createdat,s.updatedat,s.deletedat,s.userid,s.topic,s.delid,s.recvseqid,
+ s.readseqid,s.modewant,s.modegiven,u.public,u.trusted,u.lastseen,u.useragent,s.private
+ FROM subscriptions AS s JOIN users AS u ON s.tenant_id=u.tenant_id AND s.userid=u.id
+ WHERE s.tenant_id=? AND s.topic=?`
+ args := []any{id, topic}
+ if !keepDeleted {
+ q += " AND u.state!=?"
+ args = append(args, t.StateDeleted)
+ if tcat != t.TopicCatP2P {
+ q += " AND s.deletedat IS NULL"
+ }
+ }
+ limit := a.maxResults
+ var oneUser t.Uid
+ if opts != nil {
+ if !opts.User.IsZero() {
+ if tcat != t.TopicCatP2P {
+ q += " AND s.userid=?"
+ args = append(args, decodeUID(opts.User))
+ }
+ oneUser = opts.User
+ }
+ if opts.Limit > 0 && opts.Limit < limit {
+ limit = opts.Limit
+ }
+ }
+ q += " LIMIT ?"
+ args = append(args, limit)
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ rows, err := a.db.QueryxContext(ctx, q, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var subs []t.Subscription
+ var userAgent string
+ for rows.Next() {
+ var sub t.Subscription
+ var lastSeen sql.NullTime
+ var public, trusted any
+ if err = rows.Scan(&sub.TenantID, &sub.CreatedAt, &sub.UpdatedAt, &sub.DeletedAt,
+ &sub.User, &sub.Topic, &sub.DelId, &sub.RecvSeqId, &sub.ReadSeqId, &sub.ModeWant, &sub.ModeGiven,
+ &public, &trusted, &lastSeen, &userAgent, &sub.Private); err != nil {
+ return nil, err
+ }
+ sub.User = common.EncodeUidString(sub.User).String()
+ sub.Private = common.FromJSON(sub.Private)
+ sub.SetPublic(common.FromJSON(public))
+ sub.SetTrusted(common.FromJSON(trusted))
+ sub.SetLastSeenAndUA(&lastSeen.Time, userAgent)
+ subs = append(subs, sub)
+ }
+ if err = rows.Err(); err != nil {
+ return nil, err
+ }
+ if tcat == t.TopicCatP2P && len(subs) > 0 {
+ if len(subs) == 1 {
+ subs[0].SetPublic(nil)
+ subs[0].SetTrusted(nil)
+ subs[0].SetLastSeenAndUA(nil, "")
+ } else {
+ pub := subs[0].GetPublic()
+ subs[0].SetPublic(subs[1].GetPublic())
+ subs[1].SetPublic(pub)
+ trusted := subs[0].GetTrusted()
+ subs[0].SetTrusted(subs[1].GetTrusted())
+ subs[1].SetTrusted(trusted)
+ lastSeen := subs[0].GetLastSeen()
+ ua := subs[0].GetUserAgent()
+ subs[0].SetLastSeenAndUA(subs[1].GetLastSeen(), subs[1].GetUserAgent())
+ subs[1].SetLastSeenAndUA(lastSeen, ua)
+ }
+ if !keepDeleted || !oneUser.IsZero() {
+ filtered := subs[:0]
+ for i := range subs {
+ if (subs[i].DeletedAt != nil && !keepDeleted) || (!oneUser.IsZero() && subs[i].Uid() != oneUser) {
+ continue
+ }
+ filtered = append(filtered, subs[i])
+ }
+ subs = filtered
+ }
+ }
+ return subs, nil
+}
+
+func (a *adapter) tenantTopicNames(query string, args ...any) ([]string, error) {
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ rows, err := a.db.QueryxContext(ctx, query, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var names []string
+ for rows.Next() {
+ var name string
+ if err = rows.Scan(&name); err != nil {
+ return nil, err
+ }
+ names = append(names, name)
+ }
+ return names, rows.Err()
+}
+
+func (a *adapter) TenantOwnTopics(id t.TenantID, uid t.Uid) ([]string, error) {
+ if err := validTenant(id); err != nil || uid.IsZero() {
+ return nil, t.ErrMalformed
+ }
+ return a.tenantTopicNames("SELECT name FROM topics WHERE tenant_id=? AND owner=? AND state!=?",
+ id, decodeUID(uid), t.StateDeleted)
+}
+
+func (a *adapter) TenantChannelsForUser(id t.TenantID, uid t.Uid) ([]string, error) {
+ if err := validTenant(id); err != nil || uid.IsZero() {
+ return nil, t.ErrMalformed
+ }
+ return a.tenantTopicNames("SELECT topic FROM subscriptions WHERE tenant_id=? AND userid=? AND topic LIKE 'chn%' "+
+ "AND INSTR(modewant,'P')>0 AND INSTR(modegiven,'P')>0 AND deletedat IS NULL", id, decodeUID(uid))
+}
+
+func (a *adapter) TenantTopicShare(id t.TenantID, topic string, subs []*t.Subscription) error {
+ if err := validTenant(id); err != nil {
+ return err
+ }
+ for _, sub := range subs {
+ if sub == nil || sub.TenantID != id {
+ return t.ErrMalformed
+ }
+ }
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTxx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err != nil {
+ tx.Rollback()
+ }
+ }()
+ for _, sub := range subs {
+ if err = createSubscription(tx, sub, true); err != nil {
+ return err
+ }
+ }
+ if topic != "" {
+ if _, err = tx.Exec("UPDATE topics SET subcnt=subcnt+? WHERE tenant_id=? AND name=?", len(subs), id, topic); err != nil {
+ return err
+ }
+ }
+ return tx.Commit()
+}
+
+func tenantMessageDeleteList(tx *sqlx.Tx, tenantID t.TenantID, topic string, toDel *t.DelMessage) error {
+ if toDel == nil {
+ if _, err := tx.Exec("DELETE FROM dellog WHERE tenant_id=? AND topic=?", tenantID, topic); err != nil {
+ return err
+ }
+ _, err := tx.Exec("DELETE FROM messages WHERE tenant_id=? AND topic=?", tenantID, topic)
+ return err
+ }
+ delRanges := toDel.SeqIdRanges
+ if toDel.DeletedFor == "" {
+ where := "m.tenant_id=? AND m.topic=?"
+ args := []any{tenantID, topic}
+ if len(delRanges) > 0 {
+ rSQL, rArgs := common.RangesToSql(delRanges)
+ where += " AND m.seqid " + rSQL
+ args = append(args, rArgs...)
+ }
+ where += " AND m.deletedat IS NULL"
+ if newerThan := toDel.GetNewerThan(); newerThan != nil {
+ where += " AND m.createdat>?"
+ args = append(args, newerThan)
+ }
+ var seqIDs []int
+ if err := tx.Select(&seqIDs, "SELECT seqid FROM messages AS m WHERE "+where, args...); err != nil {
+ return err
+ }
+ if len(seqIDs) == 0 {
+ return nil
+ }
+ sort.Ints(seqIDs)
+ delRanges = t.SliceToRanges(seqIDs)
+ rSQL, rArgs := common.RangesToSql(delRanges)
+ where = "m.tenant_id=? AND m.topic=? AND m.seqid " + rSQL
+ args = append([]any{tenantID, topic}, rArgs...)
+ if _, err := tx.Exec("DELETE fml.* FROM filemsglinks AS fml INNER JOIN messages AS m ON m.tenant_id=fml.tenant_id AND m.id=fml.msgid WHERE "+where, args...); err != nil {
+ return err
+ }
+ if _, err := tx.Exec("UPDATE messages AS m SET m.deletedat=?,m.delid=?,m.`from`=0,m.head=NULL,m.content=NULL WHERE "+
+ where, append([]any{t.TimeNow(), toDel.DelId}, args...)...); err != nil {
+ return err
+ }
+ }
+ stmt, err := tx.Prepare("INSERT INTO dellog(tenant_id,topic,deletedfor,delid,low,hi) VALUES(?,?,?,?,?,?)")
+ if err != nil {
+ return err
+ }
+ defer stmt.Close()
+ forUser := common.DecodeUidString(toDel.DeletedFor)
+ for _, rng := range delRanges {
+ if rng.Hi == 0 {
+ rng.Hi = rng.Low + 1
+ }
+ if _, err = stmt.Exec(tenantID, topic, forUser, toDel.DelId, rng.Low, rng.Hi); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (a *adapter) TenantTopicDelete(id t.TenantID, topic string, isChan, hard bool) error {
+ if err := validTenant(id); err != nil || topic == "" {
+ return t.ErrMalformed
+ }
+ topics := []any{topic}
+ if isChan {
+ topics = append(topics, t.GrpToChn(topic))
+ }
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTxx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err != nil {
+ tx.Rollback()
+ }
+ }()
+ if hard {
+ q, args, _ := sqlx.In("DELETE FROM subscriptions WHERE tenant_id=? AND topic IN (?)", id, topics)
+ if _, err = tx.Exec(tx.Rebind(q), args...); err != nil {
+ return err
+ }
+ if err = tenantMessageDeleteList(tx, id, topic, nil); err != nil {
+ return err
+ }
+ if _, err = tx.Exec("DELETE FROM topictags WHERE tenant_id=? AND topic=?", id, topic); err != nil {
+ return err
+ }
+ if _, err = tx.Exec("DELETE FROM topics WHERE tenant_id=? AND name=?", id, topic); err != nil {
+ return err
+ }
+ } else {
+ now := t.TimeNow()
+ q, args, _ := sqlx.In("UPDATE subscriptions SET updatedat=?,deletedat=? WHERE tenant_id=? AND topic IN (?)", now, now, id, topics)
+ if _, err = tx.Exec(tx.Rebind(q), args...); err != nil {
+ return err
+ }
+ if _, err = tx.Exec("UPDATE topics SET updatedat=?,touchedat=?,state=?,stateat=? WHERE tenant_id=? AND name=?",
+ now, now, t.StateDeleted, now, id, topic); err != nil {
+ return err
+ }
+ }
+ return tx.Commit()
+}
+
+func (a *adapter) TenantTopicUpdateOnMessage(id t.TenantID, topic string, msg *t.Message) error {
+ if err := validTenant(id); err != nil || msg == nil || msg.TenantID != id {
+ return t.ErrMalformed
+ }
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ _, err := a.db.ExecContext(ctx, "UPDATE topics SET seqid=?,touchedat=? WHERE tenant_id=? AND name=?",
+ msg.SeqId, msg.CreatedAt, id, topic)
+ return err
+}
+
+func (a *adapter) TenantTopicUpdateSubCnt(id t.TenantID, topic string) error {
+ if err := validTenant(id); err != nil || topic == "" {
+ return t.ErrMalformed
+ }
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ _, err := a.db.ExecContext(ctx,
+ "UPDATE topics SET subcnt=(SELECT COUNT(*) FROM subscriptions WHERE tenant_id=? AND topic IN (?,?) AND deletedat IS NULL) WHERE tenant_id=? AND name=?",
+ id, topic, t.GrpToChn(topic), id, topic)
+ return err
+}
+
+func (a *adapter) TenantTopicUpdate(id t.TenantID, topic string, update map[string]any) error {
+ if err := validTenant(id); err != nil || topic == "" {
+ return t.ErrMalformed
+ }
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTxx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err != nil {
+ tx.Rollback()
+ }
+ }()
+ if update["TouchedAt"] == nil && update["UpdatedAt"] != nil {
+ update["TouchedAt"] = update["UpdatedAt"]
+ }
+ cols, args := common.UpdateByMap(update)
+ args = append(args, id, topic)
+ res, err := tx.Exec("UPDATE topics SET "+strings.Join(cols, ",")+" WHERE tenant_id=? AND name=?", args...)
+ if err != nil {
+ return err
+ }
+ if count, _ := res.RowsAffected(); count == 0 {
+ return t.ErrNotFound
+ }
+ if tags := common.ExtractTags(update); tags != nil {
+ if _, err = tx.Exec("DELETE FROM topictags WHERE tenant_id=? AND topic=?", id, topic); err != nil {
+ return err
+ }
+ if err = addTenantTags(tx, id, "topictags", "topic", topic, tags, false); err != nil {
+ return err
+ }
+ }
+ return tx.Commit()
+}
+
+func (a *adapter) TenantTopicOwnerChange(id t.TenantID, topic string, owner t.Uid) error {
+ if err := validTenant(id); err != nil || topic == "" {
+ return t.ErrMalformed
+ }
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ _, err := a.db.ExecContext(ctx, "UPDATE topics SET owner=? WHERE tenant_id=? AND name=?", decodeUID(owner), id, topic)
+ return err
+}
+
+func (a *adapter) TenantSubscriptionGet(id t.TenantID, topic string, uid t.Uid, keepDeleted bool) (*t.Subscription, error) {
+ if err := validTenant(id); err != nil || topic == "" || uid.IsZero() {
+ return nil, t.ErrMalformed
+ }
+ query := `SELECT tenant_id,createdat,updatedat,deletedat,userid AS user,topic,delid,recvseqid,
+ readseqid,modewant,modegiven,private FROM subscriptions WHERE tenant_id=? AND topic=? AND userid=?`
+ if !keepDeleted {
+ query += " AND deletedat IS NULL"
+ }
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ var sub t.Subscription
+ err := a.db.GetContext(ctx, &sub, query, id, topic, decodeUID(uid))
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ sub.User = uid.String()
+ sub.Private = common.FromJSON(sub.Private)
+ return &sub, nil
+}
+
+func (a *adapter) TenantSubsForUser(id t.TenantID, uid t.Uid) ([]t.Subscription, error) {
+ if err := validTenant(id); err != nil || uid.IsZero() {
+ return nil, t.ErrMalformed
+ }
+ rows, err := a.db.Queryx(`SELECT tenant_id,createdat,updatedat,deletedat,userid AS user,topic,delid,recvseqid,
+ readseqid,modewant,modegiven FROM subscriptions WHERE tenant_id=? AND userid=? AND deletedat IS NULL`,
+ id, decodeUID(uid))
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var subs []t.Subscription
+ for rows.Next() {
+ var sub t.Subscription
+ if err = rows.StructScan(&sub); err != nil {
+ return nil, err
+ }
+ sub.User = uid.String()
+ subs = append(subs, sub)
+ }
+ return subs, rows.Err()
+}
+
+func (a *adapter) TenantSubsForTopic(id t.TenantID, topic string, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) {
+ if err := validTenant(id); err != nil || topic == "" {
+ return nil, t.ErrMalformed
+ }
+ q := `SELECT tenant_id,createdat,updatedat,deletedat,userid AS user,topic,delid,recvseqid,
+ readseqid,modewant,modegiven,private FROM subscriptions WHERE tenant_id=? AND topic=?`
+ args := []any{id, topic}
+ if !keepDeleted {
+ q += " AND deletedat IS NULL"
+ }
+ limit := a.maxResults
+ if opts != nil {
+ if !opts.User.IsZero() {
+ q += " AND userid=?"
+ args = append(args, decodeUID(opts.User))
+ }
+ if opts.Limit > 0 && opts.Limit < limit {
+ limit = opts.Limit
+ }
+ }
+ q += " LIMIT ?"
+ args = append(args, limit)
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ rows, err := a.db.QueryxContext(ctx, q, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var subs []t.Subscription
+ for rows.Next() {
+ var sub t.Subscription
+ if err = rows.StructScan(&sub); err != nil {
+ return nil, err
+ }
+ sub.User = common.EncodeUidString(sub.User).String()
+ sub.Private = common.FromJSON(sub.Private)
+ subs = append(subs, sub)
+ }
+ return subs, rows.Err()
+}
+
+func (a *adapter) TenantSubsUpdate(id t.TenantID, topic string, uid t.Uid, update map[string]any) error {
+ if err := validTenant(id); err != nil || topic == "" {
+ return t.ErrMalformed
+ }
+ cols, args := common.UpdateByMap(update)
+ q := "UPDATE subscriptions SET " + strings.Join(cols, ",") + " WHERE tenant_id=? AND topic=?"
+ args = append(args, id, topic)
+ if !uid.IsZero() {
+ q += " AND userid=?"
+ args = append(args, decodeUID(uid))
+ }
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ _, err := a.db.ExecContext(ctx, q, args...)
+ return err
+}
+
+func (a *adapter) TenantSubsDelete(id t.TenantID, topic string, uid t.Uid) error {
+ if err := validTenant(id); err != nil || topic == "" || uid.IsZero() {
+ return t.ErrMalformed
+ }
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTxx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err != nil {
+ tx.Rollback()
+ }
+ }()
+ now := t.TimeNow()
+ decoded := decodeUID(uid)
+ res, err := tx.Exec("UPDATE subscriptions SET updatedat=?,deletedat=? WHERE tenant_id=? AND topic=? AND userid=? AND deletedat IS NULL",
+ now, now, id, topic, decoded)
+ if err != nil {
+ return err
+ }
+ if count, _ := res.RowsAffected(); count == 0 {
+ return t.ErrNotFound
+ }
+ if !t.IsChannel(topic) {
+ if _, err = tx.Exec("DELETE FROM dellog WHERE tenant_id=? AND topic=? AND deletedfor=?", id, topic, decoded); err != nil {
+ return err
+ }
+ }
+ if t.GetTopicCat(topic) == t.TopicCatGrp {
+ if _, err = tx.Exec("UPDATE topics SET subcnt=subcnt-1 WHERE tenant_id=? AND name=?", id, t.ChnToGrp(topic)); err != nil {
+ return err
+ }
+ }
+ return tx.Commit()
+}
+
+func tenantSubsDelForUser(tx *sqlx.Tx, tenantID t.TenantID, decodedUID int64, hard bool) error {
+ rows, err := tx.Query("SELECT topic FROM subscriptions WHERE tenant_id=? AND userid=? AND deletedat IS NULL", tenantID, decodedUID)
+ if err != nil {
+ return err
+ }
+ var topics []any
+ for rows.Next() {
+ var name string
+ if err = rows.Scan(&name); err != nil {
+ rows.Close()
+ return err
+ }
+ if t.IsChannel(name) {
+ name = t.ChnToGrp(name)
+ }
+ topics = append(topics, name)
+ }
+ err = rows.Err()
+ rows.Close()
+ if err != nil {
+ return err
+ }
+ if len(topics) > 0 {
+ q, args, _ := sqlx.In("UPDATE topics SET subcnt=subcnt-1 WHERE tenant_id=? AND name IN (?)", tenantID, topics)
+ if _, err = tx.Exec(tx.Rebind(q), args...); err != nil {
+ return err
+ }
+ }
+ if hard {
+ _, err = tx.Exec("DELETE FROM subscriptions WHERE tenant_id=? AND userid=?", tenantID, decodedUID)
+ } else {
+ now := t.TimeNow()
+ _, err = tx.Exec("UPDATE subscriptions SET updatedat=?,deletedat=? WHERE tenant_id=? AND userid=? AND deletedat IS NULL",
+ now, now, tenantID, decodedUID)
+ }
+ return err
+}
+
+func (a *adapter) TenantFind(id t.TenantID, caller, promoPrefix string, req [][]string, opt []string, activeOnly bool) ([]t.Subscription, error) {
+ if err := validTenant(id); err != nil {
+ return nil, err
+ }
+ allReq := t.FlattenDoubleSlice(req)
+ allTags := append(append([]string{}, allReq...), opt...)
+ if len(allTags) == 0 {
+ return nil, nil
+ }
+ args := []any{id}
+ stateConstraint := ""
+ if activeOnly {
+ args = append(args, t.StateOK)
+ stateConstraint = "u.state=? AND "
+ }
+ index := make(map[string]struct{})
+ for _, tag := range allTags {
+ args = append(args, tag)
+ index[tag] = struct{}{}
+ }
+ matcher := "COUNT(*)"
+ if promoPrefix != "" {
+ matcher = "SUM(CASE WHEN LOCATE('" + promoPrefix + "', tg.tag)=1 THEN 20 ELSE 1 END)"
+ }
+ query := "SELECT u.id,u.createdat,u.updatedat,0,u.access,0 AS subcnt,u.public,u.trusted,u.tags," + matcher + " AS matches " +
+ "FROM users AS u JOIN usertags AS tg ON tg.tenant_id=u.tenant_id AND tg.userid=u.id " +
+ "WHERE u.tenant_id=? AND " + stateConstraint + "tg.tag IN (?" + strings.Repeat(",?", len(allTags)-1) + ") " +
+ "GROUP BY u.id,u.createdat,u.updatedat,u.access,u.public,u.trusted,u.tags "
+ if len(allReq) > 0 {
+ q, addArgs := common.DisjunctionSql(req, "tg.tag")
+ query += q
+ args = append(args, addArgs...)
+ }
+ query += "UNION ALL "
+ args = append(args, id)
+ if activeOnly {
+ args = append(args, t.StateOK)
+ stateConstraint = "t.state=? AND "
+ } else {
+ stateConstraint = ""
+ }
+ for _, tag := range allTags {
+ args = append(args, tag)
+ }
+ query += "SELECT t.name AS topic,t.createdat,t.updatedat,t.usebt,t.access,t.subcnt,t.public,t.trusted,t.tags," + matcher + " AS matches " +
+ "FROM topics AS t JOIN topictags AS tg ON t.tenant_id=tg.tenant_id AND t.name=tg.topic " +
+ "WHERE t.tenant_id=? AND " + stateConstraint + "tg.tag IN (?" + strings.Repeat(",?", len(allTags)-1) + ") " +
+ "GROUP BY t.name,t.createdat,t.updatedat,t.usebt,t.access,t.subcnt,t.public,t.trusted,t.tags "
+ if len(allReq) > 0 {
+ q, addArgs := common.DisjunctionSql(req, "tg.tag")
+ query += q
+ args = append(args, addArgs...)
+ }
+ query += "ORDER BY matches DESC, subcnt DESC LIMIT ?"
+ args = append(args, a.maxResults)
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ rows, err := a.db.QueryxContext(ctx, query, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var subs []t.Subscription
+ for rows.Next() {
+ var sub t.Subscription
+ var public, trusted any
+ var access t.DefaultAccess
+ var subcnt, ignored int
+ var setTags t.StringSlice
+ var isChan bool
+ if err = rows.Scan(&sub.Topic, &sub.CreatedAt, &sub.UpdatedAt, &isChan, &access, &subcnt,
+ &public, &trusted, &setTags, &ignored); err != nil {
+ return nil, err
+ }
+ if parsed, err := strconv.ParseInt(sub.Topic, 10, 64); err == nil {
+ sub.Topic = store.EncodeUid(parsed).UserId()
+ if sub.Topic == caller {
+ continue
+ }
+ }
+ if isChan {
+ sub.Topic = t.GrpToChn(sub.Topic)
+ }
+ sub.TenantID = id
+ sub.SetSubCnt(subcnt)
+ sub.SetPublic(common.FromJSON(public))
+ sub.SetTrusted(common.FromJSON(trusted))
+ sub.SetDefaultAccess(access.Auth, access.Anon)
+ sub.ModeGiven = t.ModeUnset
+ sub.ModeWant = t.ModeUnset
+ sub.Private = common.FilterFoundTags(setTags, index)
+ subs = append(subs, sub)
+ }
+ return subs, rows.Err()
+}
+
+func (a *adapter) TenantFindOne(id t.TenantID, tag string) (string, error) {
+ if err := validTenant(id); err != nil || tag == "" {
+ return "", t.ErrMalformed
+ }
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ rows, err := a.db.QueryxContext(ctx,
+ "SELECT tp.name AS topic FROM topics AS tp LEFT JOIN topictags AS tt ON tp.tenant_id=tt.tenant_id AND tp.name=tt.topic "+
+ "WHERE tp.tenant_id=? AND tt.tag=? UNION ALL "+
+ "SELECT u.id AS topic FROM users AS u LEFT JOIN usertags AS ut ON u.tenant_id=ut.tenant_id AND ut.userid=u.id "+
+ "WHERE u.tenant_id=? AND ut.tag=? LIMIT 1", id, tag, id, tag)
+ if err != nil {
+ return "", err
+ }
+ defer rows.Close()
+ var found string
+ if rows.Next() {
+ if err = rows.Scan(&found); err != nil {
+ return "", err
+ }
+ if parsed, err := strconv.ParseInt(found, 10, 64); err == nil {
+ found = store.EncodeUid(parsed).UserId()
+ }
+ }
+ return found, rows.Err()
+}
+
+func (a *adapter) TenantMessageSave(id t.TenantID, msg *t.Message) error {
+ if err := validTenant(id); err != nil || msg == nil || msg.TenantID != id {
+ return t.ErrMalformed
+ }
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ res, err := a.db.ExecContext(ctx,
+ "INSERT INTO messages(tenant_id,createdat,updatedat,seqid,topic,`from`,head,content) VALUES(?,?,?,?,?,?,?,?)",
+ id, msg.CreatedAt, msg.UpdatedAt, msg.SeqId, msg.Topic, decodeUID(t.ParseUid(msg.From)), msg.Head, common.ToJSON(msg.Content))
+ if err == nil {
+ lastID, _ := res.LastInsertId()
+ msg.SetUid(t.Uid(lastID))
+ }
+ return err
+}
+
+func (a *adapter) TenantMessageGetAll(id t.TenantID, topic string, uid t.Uid, opts *t.QueryOpt) ([]t.Message, error) {
+ if err := validTenant(id); err != nil || topic == "" {
+ return nil, t.ErrMalformed
+ }
+ limit := a.maxMessageResults
+ args := []any{id, topic, id, decodeUID(uid)}
+ seq := ""
+ if opts != nil {
+ seq = "AND m.seqid "
+ if len(opts.IdRanges) > 0 {
+ constr, addArgs := common.RangesToSql(opts.IdRanges)
+ seq += constr
+ args = append(args, addArgs...)
+ } else {
+ seq += "BETWEEN ? AND ?"
+ if opts.Since > 0 {
+ args = append(args, opts.Since)
+ } else {
+ args = append(args, 0)
+ }
+ if opts.Before > 1 {
+ args = append(args, opts.Before-1)
+ } else {
+ args = append(args, 1<<31-1)
+ }
+ }
+ if opts.Limit > 0 && opts.Limit < limit {
+ limit = opts.Limit
+ }
+ }
+ args = append(args, limit)
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ rows, err := a.db.QueryxContext(ctx,
+ "SELECT m.tenant_id,m.createdat,m.updatedat,m.deletedat,m.delid,m.seqid,m.topic,m.`from`,m.head,m.content "+
+ "FROM messages AS m LEFT JOIN dellog AS d ON d.tenant_id=m.tenant_id AND d.topic=m.topic "+
+ "AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? WHERE m.tenant_id=? AND m.delid=0 AND m.topic=? "+
+ seq+" AND d.deletedfor IS NULL ORDER BY m.seqid DESC LIMIT ?",
+ append([]any{decodeUID(uid), id, topic}, args[4:]...)...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ msgs := make([]t.Message, 0, limit)
+ for rows.Next() {
+ var msg t.Message
+ if err = rows.StructScan(&msg); err != nil {
+ return nil, err
+ }
+ msg.From = common.EncodeUidString(msg.From).String()
+ msg.Content = common.FromJSON(msg.Content)
+ msgs = append(msgs, msg)
+ }
+ return msgs, rows.Err()
+}
+
+func (a *adapter) TenantMessageDeleteList(id t.TenantID, topic string, del *t.DelMessage) error {
+ if err := validTenant(id); err != nil || topic == "" || (del != nil && del.TenantID != id) {
+ return t.ErrMalformed
+ }
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTxx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err != nil {
+ tx.Rollback()
+ }
+ }()
+ if err = tenantMessageDeleteList(tx, id, topic, del); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
+
+func (a *adapter) TenantMessageGetDeleted(id t.TenantID, topic string, uid t.Uid, opts *t.QueryOpt) ([]t.DelMessage, error) {
+ if err := validTenant(id); err != nil || topic == "" {
+ return nil, t.ErrMalformed
+ }
+ limit, lower, upper := a.maxResults, 0, 1<<31-1
+ if opts != nil {
+ if opts.Since > 0 {
+ lower = opts.Since
+ }
+ if opts.Before > 1 {
+ upper = opts.Before - 1
+ }
+ if opts.Limit > 0 && opts.Limit < limit {
+ limit = opts.Limit
+ }
+ }
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ rows, err := a.db.QueryxContext(ctx,
+ "SELECT tenant_id,topic,deletedfor,delid,low,hi FROM dellog WHERE tenant_id=? AND topic=? AND delid BETWEEN ? AND ? "+
+ "AND (deletedfor=0 OR deletedfor=?) ORDER BY delid LIMIT ?", id, topic, lower, upper, decodeUID(uid), limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var dmsgs []t.DelMessage
+ var current t.DelMessage
+ for rows.Next() {
+ var row struct {
+ TenantID t.TenantID `db:"tenant_id"`
+ Topic string
+ Deletedfor int64
+ Delid int
+ Low int
+ Hi int
+ }
+ if err = rows.StructScan(&row); err != nil {
+ return nil, err
+ }
+ if row.Delid != current.DelId {
+ if current.DelId > 0 {
+ dmsgs = append(dmsgs, current)
+ }
+ current = t.DelMessage{TenantID: row.TenantID, Topic: row.Topic, DelId: row.Delid}
+ if row.Deletedfor > 0 {
+ current.DeletedFor = store.EncodeUid(row.Deletedfor).String()
+ }
+ }
+ if row.Hi <= row.Low+1 {
+ row.Hi = 0
+ }
+ current.SeqIdRanges = append(current.SeqIdRanges, t.Range{Low: row.Low, Hi: row.Hi})
+ }
+ if err = rows.Err(); err != nil {
+ return nil, err
+ }
+ if current.DelId > 0 {
+ dmsgs = append(dmsgs, current)
+ }
+ return dmsgs, nil
+}
+
+func (a *adapter) TenantDeviceUpsert(id t.TenantID, uid t.Uid, dev *t.DeviceDef) error {
+ if err := validTenant(id); err != nil || dev == nil || dev.TenantID != id || uid.IsZero() {
+ return t.ErrMalformed
+ }
+ hash := deviceHasher(dev.DeviceId)
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTxx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err != nil {
+ tx.Rollback()
+ }
+ }()
+ if _, err = tx.Exec("DELETE FROM devices WHERE tenant_id=? AND hash=?", id, hash); err != nil {
+ return err
+ }
+ if _, err = tx.Exec("INSERT INTO devices(tenant_id,userid,hash,deviceid,platform,lastseen,lang) VALUES(?,?,?,?,?,?,?)",
+ id, decodeUID(uid), hash, dev.DeviceId, dev.Platform, dev.LastSeen, dev.Lang); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
+
+func (a *adapter) TenantDeviceGetAll(id t.TenantID, uids ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error) {
+ if err := validTenant(id); err != nil {
+ return nil, 0, err
+ }
+ var decoded []any
+ for _, uid := range uids {
+ if !uid.IsZero() {
+ decoded = append(decoded, decodeUID(uid))
+ }
+ }
+ if len(decoded) == 0 {
+ return map[t.Uid][]t.DeviceDef{}, 0, nil
+ }
+ q, args, _ := sqlx.In("SELECT tenant_id,userid,deviceid,platform,lastseen,lang FROM devices WHERE tenant_id=? AND userid IN (?)", id, decoded)
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ rows, err := a.db.QueryxContext(ctx, a.db.Rebind(q), args...)
+ if err != nil {
+ return nil, 0, err
+ }
+ defer rows.Close()
+ result := make(map[t.Uid][]t.DeviceDef)
+ count := 0
+ for rows.Next() {
+ var row struct {
+ TenantID t.TenantID `db:"tenant_id"`
+ Userid int64
+ Deviceid string
+ Platform string
+ Lastseen time.Time
+ Lang string
+ }
+ if err = rows.StructScan(&row); err != nil {
+ return nil, 0, err
+ }
+ uid := store.EncodeUid(row.Userid)
+ result[uid] = append(result[uid], t.DeviceDef{
+ TenantID: row.TenantID, DeviceId: row.Deviceid, Platform: row.Platform, LastSeen: row.Lastseen, Lang: row.Lang,
+ })
+ count++
+ }
+ return result, count, rows.Err()
+}
+
+func (a *adapter) TenantDeviceDelete(id t.TenantID, uid t.Uid, deviceID string) error {
+ if err := validTenant(id); err != nil || uid.IsZero() {
+ return t.ErrMalformed
+ }
+ q := "DELETE FROM devices WHERE tenant_id=? AND userid=?"
+ args := []any{id, decodeUID(uid)}
+ if deviceID != "" {
+ q += " AND hash=?"
+ args = append(args, deviceHasher(deviceID))
+ }
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ res, err := a.db.ExecContext(ctx, q, args...)
+ if err != nil {
+ return err
+ }
+ if count, _ := res.RowsAffected(); count == 0 {
+ return t.ErrNotFound
+ }
+ return nil
+}
+
+func (a *adapter) TenantFileStartUpload(id t.TenantID, fd *t.FileDef) error {
+ if err := validTenant(id); err != nil || fd == nil || fd.TenantID != id {
+ return t.ErrMalformed
+ }
+ var user any = 0
+ if fd.User != "" {
+ user = decodeUID(t.ParseUid(fd.User))
+ }
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ _, err := a.db.ExecContext(ctx,
+ "INSERT INTO fileuploads(tenant_id,id,createdat,updatedat,userid,status,mimetype,size,etag,location) VALUES(?,?,?,?,?,?,?,?,?,?)",
+ id, decodeUID(fd.Uid()), fd.CreatedAt, fd.UpdatedAt, user, fd.Status, fd.MimeType, fd.Size, fd.ETag, fd.Location)
+ return err
+}
+
+func (a *adapter) TenantFileFinishUpload(id t.TenantID, fd *t.FileDef, success bool, size int64) (*t.FileDef, error) {
+ if err := validTenant(id); err != nil || fd == nil || fd.TenantID != id {
+ return nil, t.ErrMalformed
+ }
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTxx(ctx, nil)
+ if err != nil {
+ return nil, err
+ }
+ defer func() {
+ if err != nil {
+ tx.Rollback()
+ }
+ }()
+ now := t.TimeNow()
+ if success {
+ _, err = tx.Exec("UPDATE fileuploads SET updatedat=?,status=?,size=?,etag=?,location=? WHERE tenant_id=? AND id=?",
+ now, t.UploadCompleted, size, fd.ETag, fd.Location, id, decodeUID(fd.Uid()))
+ fd.Status = t.UploadCompleted
+ fd.Size = size
+ } else {
+ _, err = tx.Exec("DELETE FROM fileuploads WHERE tenant_id=? AND id=?", id, decodeUID(fd.Uid()))
+ fd.Status = t.UploadFailed
+ fd.Size = 0
+ }
+ if err != nil {
+ return nil, err
+ }
+ fd.UpdatedAt = now
+ return fd, tx.Commit()
+}
+
+func (a *adapter) TenantFileGet(id t.TenantID, fid string) (*t.FileDef, error) {
+ if err := validTenant(id); err != nil {
+ return nil, err
+ }
+ fileID := t.ParseUid(fid)
+ if fileID.IsZero() {
+ return nil, t.ErrMalformed
+ }
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ var fd t.FileDef
+ err := a.db.GetContext(ctx, &fd,
+ "SELECT tenant_id,id,createdat,updatedat,userid AS user,status,mimetype,size,IFNULL(etag,'') AS etag,location "+
+ "FROM fileuploads WHERE tenant_id=? AND id=?", id, decodeUID(fileID))
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ fd.Id = common.EncodeUidString(fd.Id).String()
+ fd.User = common.EncodeUidString(fd.User).String()
+ return &fd, nil
+}
+
+func (a *adapter) TenantFileDeleteUnused(id t.TenantID, olderThan time.Time, limit int) ([]string, error) {
+ if err := validTenant(id); err != nil {
+ return nil, err
+ }
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTxx(ctx, nil)
+ if err != nil {
+ return nil, err
+ }
+ defer func() {
+ if err != nil {
+ tx.Rollback()
+ }
+ }()
+ query := "SELECT fu.id,fu.location FROM fileuploads AS fu LEFT JOIN filemsglinks AS fml ON fml.tenant_id=fu.tenant_id AND fml.fileid=fu.id WHERE fu.tenant_id=? AND fml.id IS NULL"
+ args := []any{id}
+ if !olderThan.IsZero() {
+ query += " AND fu.updatedat"
+ args = append(args, olderThan)
+ }
+ if limit > 0 {
+ query += " LIMIT ?"
+ args = append(args, limit)
+ }
+ rows, err := tx.Query(query, args...)
+ if err != nil {
+ return nil, err
+ }
+ var locations []string
+ var ids []any
+ for rows.Next() {
+ var fileID int64
+ var location string
+ if err = rows.Scan(&fileID, &location); err != nil {
+ rows.Close()
+ return nil, err
+ }
+ if location != "" {
+ locations = append(locations, location)
+ }
+ ids = append(ids, fileID)
+ }
+ err = rows.Err()
+ rows.Close()
+ if err != nil {
+ return nil, err
+ }
+ if len(ids) > 0 {
+ q, args, _ := sqlx.In("DELETE FROM fileuploads WHERE tenant_id=? AND id IN (?)", id, ids)
+ if _, err = tx.Exec(tx.Rebind(q), args...); err != nil {
+ return nil, err
+ }
+ }
+ return locations, tx.Commit()
+}
+
+func (a *adapter) TenantFileLinkAttachments(id t.TenantID, topic string, userID, msgID t.Uid, fids []string) error {
+ if err := validTenant(id); err != nil || len(fids) == 0 || (topic == "" && msgID.IsZero() && userID.IsZero()) {
+ return t.ErrMalformed
+ }
+ now := t.TimeNow()
+ var linkID any
+ var linkBy string
+ if !msgID.IsZero() {
+ linkBy = "msgid"
+ linkID = int64(msgID)
+ } else if topic != "" {
+ linkBy = "topic"
+ linkID = topic
+ fids = fids[:1]
+ } else {
+ linkBy = "userid"
+ linkID = decodeUID(userID)
+ fids = fids[:1]
+ }
+ var args []any
+ for _, fid := range fids {
+ fileID := t.ParseUid(fid)
+ if fileID.IsZero() {
+ return t.ErrMalformed
+ }
+ args = append(args, id, now, decodeUID(fileID), linkID)
+ }
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTxx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err != nil {
+ tx.Rollback()
+ }
+ }()
+ if msgID.IsZero() {
+ if _, err = tx.Exec("DELETE FROM filemsglinks WHERE tenant_id=? AND "+linkBy+"=?", id, linkID); err != nil {
+ return err
+ }
+ }
+ sqlq := "INSERT INTO filemsglinks(tenant_id,createdat,fileid," + linkBy + ") VALUES (?,?,?,?)"
+ _, err = tx.Exec(sqlq+strings.Repeat(",(?,?,?,?)", len(fids)-1), args...)
+ if err != nil {
+ return err
+ }
+ return tx.Commit()
+}
+
+func (a *adapter) TenantPCacheGet(id t.TenantID, key string) (string, error) {
+ if err := validTenant(id); err != nil {
+ return "", err
+ }
+ scoped, err := tenantCacheKey(id, key)
+ if err != nil {
+ return "", err
+ }
+ return a.PCacheGet(scoped)
+}
+
+func (a *adapter) TenantPCacheUpsert(id t.TenantID, key, value string, fail bool) error {
+ if err := validTenant(id); err != nil {
+ return err
+ }
+ scoped, err := tenantCacheKey(id, key)
+ if err != nil {
+ return err
+ }
+ return a.PCacheUpsert(scoped, value, fail)
+}
+
+func (a *adapter) TenantPCacheDelete(id t.TenantID, key string) error {
+ if err := validTenant(id); err != nil {
+ return err
+ }
+ scoped, err := tenantCacheKey(id, key)
+ if err != nil {
+ return err
+ }
+ return a.PCacheDelete(scoped)
+}
+
+func (a *adapter) TenantPCacheExpire(id t.TenantID, prefix string, before time.Time) error {
+ if err := validTenant(id); err != nil {
+ return err
+ }
+ scoped, err := tenantCacheKey(id, prefix)
+ if err != nil {
+ return err
+ }
+ return a.PCacheExpire(scoped, before)
+}
diff --git a/server/db/mysql/tests/mysql_test.go b/server/db/mysql/tests/mysql_test.go
index e69de29..e662342 100644
--- a/server/db/mysql/tests/mysql_test.go
+++ b/server/db/mysql/tests/mysql_test.go
@@ -0,0 +1,1421 @@
+// To test another db backend:
+// 1) Create GetAdapter function inside your db backend adapter package (like one inside mysql adapter)
+// 2) Uncomment your db backend package ('backend' named package)
+// 3) Write own initConnectionToDb and 'db' variable
+// 4) Replace mysql specific db queries inside test to your own queries.
+// 5) Run.
+
+package tests
+
+import (
+ "database/sql"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "log"
+ "os"
+ "reflect"
+ "testing"
+ "time"
+
+ _ "github.com/go-sql-driver/mysql"
+ "github.com/google/go-cmp/cmp"
+ "github.com/google/go-cmp/cmp/cmpopts"
+ "github.com/jmoiron/sqlx"
+ "github.com/tinode/chat/server/auth"
+ adapter "github.com/tinode/chat/server/db"
+ "github.com/tinode/chat/server/store"
+ jcr "github.com/tinode/jsonco"
+
+ "github.com/tinode/chat/server/db/common/test_data"
+ backend "github.com/tinode/chat/server/db/mysql"
+ "github.com/tinode/chat/server/logs"
+ "github.com/tinode/chat/server/store/types"
+)
+
+type configType struct {
+ // If Reset=true test will recreate database every time it runs
+ Reset bool `json:"reset_db_data"`
+ // Configurations for individual adapters.
+ Adapters map[string]json.RawMessage `json:"adapters"`
+}
+
+var config configType
+var adp adapter.Adapter
+var db *sqlx.DB
+var testData *test_data.TestData
+
+var dummyUid1 = types.Uid(12345)
+var dummyUid2 = types.Uid(54321)
+
+func TestTenantAuthIsolation(t *testing.T) {
+ tenantAdp, ok := adp.(adapter.TenantAuthAdapter)
+ if !ok {
+ t.Fatal("MySQL adapter does not implement TenantAuthAdapter")
+ }
+
+ now := types.TimeNow()
+ suffix := fmt.Sprintf("%d", now.UnixNano())
+ createTenant := func(code string) types.TenantID {
+ res, err := db.Exec("INSERT INTO im_tenant(code,name,state,created_by,updated_by) VALUES(?,?,?,?,?)",
+ code+suffix, code, types.TenantStateActive, 0, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ id, err := res.LastInsertId()
+ if err != nil {
+ t.Fatal(err)
+ }
+ return types.TenantID(id)
+ }
+
+ tenantA := createTenant("test-auth-a-")
+ tenantB := createTenant("test-auth-b-")
+ userAID := now.UnixNano() & 0x3fffffffffffffff
+ userBID := userAID + 1
+ t.Cleanup(func() {
+ db.Exec("DELETE FROM credentials WHERE tenant_id IN (?,?)", tenantA, tenantB)
+ db.Exec("DELETE FROM auth WHERE tenant_id IN (?,?)", tenantA, tenantB)
+ db.Exec("DELETE FROM users WHERE tenant_id IN (?,?)", tenantA, tenantB)
+ db.Exec("DELETE FROM im_tenant WHERE id IN (?,?)", tenantA, tenantB)
+ })
+
+ for tenantID, userID := range map[types.TenantID]int64{tenantA: userAID, tenantB: userBID} {
+ _, err := db.Exec("INSERT INTO users(id,tenant_id,createdat,updatedat,state,access,public,trusted,tags) "+
+ "VALUES(?,?,?,?,?,?,?,?,?)", userID, tenantID, now, now, types.StateOK, `{}`, nil, nil, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = db.Exec("INSERT INTO auth(tenant_id,uname,userid,scheme,authlvl,secret) VALUES(?,?,?,?,?,?)",
+ tenantID, "basic:alice", userID, "basic", auth.LevelAuth, []byte("hash"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = db.Exec("INSERT INTO credentials(tenant_id,createdat,updatedat,method,value,synthetic,userid,resp,done) "+
+ "VALUES(?,?,?,?,?,?,?,?,?)", tenantID, now, now, "tel", "13800000000", "tel:13800000000",
+ userID, "", true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ uidA, _, _, _, err := tenantAdp.TenantAuthGetUniqueRecord(tenantA, "basic:alice")
+ if err != nil || uidA != store.EncodeUid(userAID) {
+ t.Fatalf("tenant A auth lookup: uid=%v err=%v", uidA, err)
+ }
+ uidB, _, _, _, err := tenantAdp.TenantAuthGetUniqueRecord(tenantB, "basic:alice")
+ if err != nil || uidB != store.EncodeUid(userBID) {
+ t.Fatalf("tenant B auth lookup: uid=%v err=%v", uidB, err)
+ }
+ credA, err := tenantAdp.TenantUserGetByCred(tenantA, "tel", "13800000000")
+ if err != nil || credA != uidA {
+ t.Fatalf("tenant A credential lookup: uid=%v err=%v", credA, err)
+ }
+ credB, err := tenantAdp.TenantUserGetByCred(tenantB, "tel", "13800000000")
+ if err != nil || credB != uidB {
+ t.Fatalf("tenant B credential lookup: uid=%v err=%v", credB, err)
+ }
+
+ if err = tenantAdp.TenantAuthUpdRecord(tenantA, uidB, "basic", "basic:changed",
+ auth.LevelAuth, nil, time.Time{}); err != types.ErrNotFound {
+ t.Fatalf("cross-tenant auth update: got %v, want %v", err, types.ErrNotFound)
+ }
+}
+
+func TestCreateDb(t *testing.T) {
+ if err := adp.CreateDb(config.Reset); err != nil {
+ t.Fatal(err)
+ }
+ // Saved db is closed, get a fresh one.
+ db = adp.GetTestDB().(*sqlx.DB)
+}
+
+// ================== Create tests ================================
+func TestUserCreate(t *testing.T) {
+ for _, user := range testData.Users {
+ if err := adp.UserCreate(user); err != nil {
+ t.Error(err)
+ }
+ }
+ var count int
+
+ if err := db.Ping(); err != nil {
+ logs.Err.Println("Database ping failed:", err)
+ }
+ err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
+ if err != nil {
+ t.Error(err)
+ }
+ if count == 0 {
+ t.Error("No users created!")
+ }
+}
+
+func TestCredUpsert(t *testing.T) {
+ // Test just inserts:
+ for i := 0; i < 2; i++ {
+ inserted, err := adp.CredUpsert(testData.Creds[i])
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !inserted {
+ t.Error("Should be inserted, but updated")
+ }
+ }
+
+ // Test duplicate:
+ _, err := adp.CredUpsert(testData.Creds[1])
+ if err != types.ErrDuplicate {
+ t.Error("Should return duplicate error but got", err)
+ }
+ _, err = adp.CredUpsert(testData.Creds[2])
+ if err != types.ErrDuplicate {
+ t.Error("Should return duplicate error but got", err)
+ }
+
+ // Test add new unvalidated credentials
+ inserted, err := adp.CredUpsert(testData.Creds[3])
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !inserted {
+ t.Error("Should be inserted, but updated")
+ }
+ inserted, err = adp.CredUpsert(testData.Creds[3])
+ if err != nil {
+ t.Fatal(err)
+ }
+ if inserted {
+ t.Error("Should be updated, but inserted")
+ }
+
+ // Just insert other creds (used in other tests)
+ for _, cred := range testData.Creds[4:] {
+ _, err = adp.CredUpsert(cred)
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+}
+
+func TestAuthAddRecord(t *testing.T) {
+ for _, rec := range testData.Recs {
+ err := adp.AuthAddRecord(types.ParseUserId("usr"+rec.UserId), rec.Scheme, rec.Unique,
+ rec.AuthLvl, rec.Secret, rec.Expires)
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ //Test duplicate
+ err := adp.AuthAddRecord(types.ParseUserId("usr"+testData.Users[0].Id), testData.Recs[0].Scheme,
+ testData.Recs[0].Unique, testData.Recs[0].AuthLvl, testData.Recs[0].Secret, testData.Recs[0].Expires)
+ if err != types.ErrDuplicate {
+ t.Fatal("Should be duplicate error but got", err)
+ }
+}
+
+func TestTopicCreate(t *testing.T) {
+ err := adp.TopicCreate(testData.Topics[0])
+ if err != nil {
+ t.Error(err)
+ }
+ for _, tpc := range testData.Topics[3:] {
+ err = adp.TopicCreate(tpc)
+ if err != nil {
+ t.Error(err)
+ }
+ }
+}
+
+func decodeUid(u string) int64 {
+ return store.DecodeUid(types.ParseUid(u))
+}
+
+func TestTopicCreateP2P(t *testing.T) {
+ err := adp.TopicCreateP2P(testData.Subs[2], testData.Subs[3])
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ oldModeGiven := testData.Subs[2].ModeGiven
+ testData.Subs[2].ModeGiven = 255
+ err = adp.TopicCreateP2P(testData.Subs[4], testData.Subs[2])
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var got types.Subscription
+ err = db.QueryRow("SELECT createdat,updatedat,deletedat,userid,topic,delid,recvseqid,readseqid,modewant,modegiven,private FROM subscriptions WHERE topic=? AND userid=?",
+ testData.Subs[2].Topic, decodeUid(testData.Subs[2].User)).Scan(&got.CreatedAt,
+ &got.UpdatedAt, &got.DeletedAt, &got.User, &got.Topic, &got.DelId, &got.RecvSeqId, &got.ReadSeqId, &got.ModeWant, &got.ModeGiven, &got.Private)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.ModeGiven == oldModeGiven {
+ t.Error("ModeGiven update failed")
+ }
+}
+
+func TestTopicShare(t *testing.T) {
+ if err := adp.TopicShare(testData.Subs[0].Topic, testData.Subs); err != nil {
+ t.Fatal(err)
+ }
+
+ // Must save recvseqid and readseqid separately because TopicShare
+ // ignores them.
+ for _, sub := range testData.Subs {
+ adp.SubsUpdate(sub.Topic, types.ParseUid(sub.User), map[string]any{
+ "delid": sub.DelId,
+ "recvseqid": sub.RecvSeqId,
+ "readseqid": sub.ReadSeqId,
+ })
+ }
+
+ // Update topic SeqId because it's not saved at creation time but used by the tests.
+ for _, tpc := range testData.Topics {
+ err := adp.TopicUpdate(tpc.Id, map[string]any{
+ "seqid": tpc.SeqId,
+ "delid": tpc.DelId,
+ })
+ if err != nil {
+ t.Error(err)
+ }
+ }
+}
+
+func TestMessageSave(t *testing.T) {
+ for _, msg := range testData.Msgs {
+ err := adp.MessageSave(msg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ // Some messages are soft deleted, but it's ignored by adp.MessageSave
+ for _, msg := range testData.Msgs {
+ if len(msg.DeletedFor) > 0 {
+ for _, del := range msg.DeletedFor {
+ toDel := types.DelMessage{
+ Topic: msg.Topic,
+ DeletedFor: del.User,
+ DelId: del.DelId,
+ SeqIdRanges: []types.Range{{Low: msg.SeqId}},
+ }
+ adp.MessageDeleteList(msg.Topic, &toDel)
+ }
+ }
+ }
+}
+
+func TestFileStartUpload(t *testing.T) {
+ for _, f := range testData.Files {
+ err := adp.FileStartUpload(f)
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+}
+
+// ================== Read tests ==================================
+func TestUserGet(t *testing.T) {
+ // Test not found
+ got, err := adp.UserGet(dummyUid1)
+ if err == nil && got != nil {
+ t.Error("user should be nil.")
+ }
+
+ got, err = adp.UserGet(types.ParseUserId("usr" + testData.Users[0].Id))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // User agent is not stored when creating a user. Make sure it's the same.
+ got.UserAgent = testData.Users[0].UserAgent
+ got.CreatedAt = testData.Users[0].CreatedAt
+ got.UpdatedAt = testData.Users[0].UpdatedAt
+
+ if !reflect.DeepEqual(got, testData.Users[0]) {
+ t.Error(mismatchErrorString("User", got, testData.Users[0]))
+ }
+}
+
+func TestUserGetAll(t *testing.T) {
+ // Test not found (dummy UIDs).
+ got, err := adp.UserGetAll(dummyUid1, dummyUid2)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) > 0 {
+ t.Error("result users should be zero length, got", len(got))
+ }
+
+ got, err = adp.UserGetAll(types.ParseUserId("usr"+testData.Users[0].Id), types.ParseUserId("usr"+testData.Users[1].Id))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 2 {
+ t.Fatal(mismatchErrorString("resultUsers length", len(got), 2))
+ }
+ for i, usr := range got {
+ // User agent is not compared.
+ usr.UserAgent = testData.Users[i].UserAgent
+ usr.CreatedAt = testData.Users[i].CreatedAt
+ usr.UpdatedAt = testData.Users[i].UpdatedAt
+ if !reflect.DeepEqual(&usr, testData.Users[i]) {
+ t.Error(mismatchErrorString("User", &usr, testData.Users[i]))
+ }
+ }
+}
+
+func TestUserGetByCred(t *testing.T) {
+ // Test not found
+ got, err := adp.UserGetByCred("foo", "bar")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != types.ZeroUid {
+ t.Error("result uid should be ZeroUid")
+ }
+
+ got, _ = adp.UserGetByCred(testData.Creds[0].Method, testData.Creds[0].Value)
+ if got != types.ParseUserId("usr"+testData.Creds[0].User) {
+ t.Error(mismatchErrorString("Uid", got, types.ParseUserId("usr"+testData.Creds[0].User)))
+ }
+}
+
+func TestCredGetActive(t *testing.T) {
+ got, err := adp.CredGetActive(types.ParseUserId("usr"+testData.Users[2].Id), "tel")
+ if err != nil {
+ t.Error(err)
+ }
+ if !reflect.DeepEqual(got, testData.Creds[3]) {
+ t.Error(mismatchErrorString("Credential", got, testData.Creds[3]))
+ }
+
+ // Test not found
+ got, err = adp.CredGetActive(dummyUid1, "")
+ if err != nil {
+ t.Error(err)
+ }
+ if got != nil {
+ t.Error("result should be nil, but got", got)
+ }
+}
+
+func TestCredGetAll(t *testing.T) {
+ got, err := adp.CredGetAll(types.ParseUserId("usr"+testData.Users[2].Id), "", false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 3 {
+ t.Error(mismatchErrorString("Credentials length", len(got), 3))
+ }
+
+ got, _ = adp.CredGetAll(types.ParseUserId("usr"+testData.Users[2].Id), "tel", false)
+ if len(got) != 2 {
+ t.Error(mismatchErrorString("Credentials length", len(got), 2))
+ }
+
+ got, _ = adp.CredGetAll(types.ParseUserId("usr"+testData.Users[2].Id), "", true)
+ if len(got) != 1 {
+ t.Error(mismatchErrorString("Credentials length", len(got), 1))
+ }
+
+ got, _ = adp.CredGetAll(types.ParseUserId("usr"+testData.Users[2].Id), "tel", true)
+ if len(got) != 1 {
+ t.Error(mismatchErrorString("Credentials length", len(got), 1))
+ }
+}
+
+func TestAuthGetUniqueRecord(t *testing.T) {
+ uid, authLvl, secret, expires, err := adp.AuthGetUniqueRecord("basic:alice")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if uid != types.ParseUserId("usr"+testData.Recs[0].UserId) ||
+ authLvl != testData.Recs[0].AuthLvl ||
+ !reflect.DeepEqual(secret, testData.Recs[0].Secret) ||
+ expires != testData.Recs[0].Expires {
+
+ got := fmt.Sprintf("%v %v %v %v", uid, authLvl, secret, expires)
+ want := fmt.Sprintf("%v %v %v %v", testData.Recs[0].UserId, testData.Recs[0].AuthLvl, testData.Recs[0].Secret, testData.Recs[0].Expires)
+ t.Error(mismatchErrorString("Auth record", got, want))
+ }
+
+ // Test not found
+ uid, _, _, _, err = adp.AuthGetUniqueRecord("qwert:asdfg")
+ if err == nil && !uid.IsZero() {
+ t.Error("Auth record found but shouldn't. Uid:", uid.String())
+ }
+}
+
+func TestAuthGetRecord(t *testing.T) {
+ recId, authLvl, secret, expires, err := adp.AuthGetRecord(types.ParseUserId("usr"+testData.Recs[0].UserId), "basic")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if recId != testData.Recs[0].Unique ||
+ authLvl != testData.Recs[0].AuthLvl ||
+ !reflect.DeepEqual(secret, testData.Recs[0].Secret) ||
+ expires != testData.Recs[0].Expires {
+
+ got := fmt.Sprintf("%v %v %v %v", recId, authLvl, secret, expires)
+ want := fmt.Sprintf("%v %v %v %v", testData.Recs[0].Unique, testData.Recs[0].AuthLvl, testData.Recs[0].Secret, testData.Recs[0].Expires)
+ t.Error(mismatchErrorString("Auth record", got, want))
+ }
+
+ // Test not found
+ recId, _, _, _, err = adp.AuthGetRecord(types.Uid(123), "scheme")
+ if err != types.ErrNotFound {
+ t.Error("Auth record found but shouldn't. recId:", recId)
+ }
+}
+
+func TestTopicGet(t *testing.T) {
+ got, err := adp.TopicGet(testData.Topics[0].Id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(got, testData.Topics[0]) {
+ t.Error(mismatchErrorString("Topic", got, testData.Topics[0]))
+ }
+ // Test not found
+ got, err = adp.TopicGet("asdfasdfasdf")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != nil {
+ t.Error("Topic should be nil but got:", got)
+ }
+}
+
+func TestTopicsForUser(t *testing.T) {
+ qOpts := types.QueryOpt{
+ Topic: "p2p9AVDamaNCRbfKzGSh3mE0w",
+ Limit: 999,
+ }
+ gotSubs, err := adp.TopicsForUser(types.ParseUserId("usr"+testData.Users[0].Id), false, &qOpts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(gotSubs) != 1 {
+ t.Error(mismatchErrorString("Subs length", len(gotSubs), 1))
+ }
+
+ gotSubs, err = adp.TopicsForUser(types.ParseUserId("usr"+testData.Users[1].Id), true, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(gotSubs) != 2 {
+ t.Error(mismatchErrorString("Subs length (2)", len(gotSubs), 2))
+ }
+
+ qOpts.Topic = ""
+ ims := testData.Now.Add(15 * time.Minute)
+ qOpts.IfModifiedSince = &ims
+ gotSubs, err = adp.TopicsForUser(types.ParseUserId("usr"+testData.Users[0].Id), false, &qOpts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(gotSubs) != 1 {
+ t.Error(mismatchErrorString("Subs length (IMS)", len(gotSubs), 1))
+ }
+
+ ims = time.Now().Add(15 * time.Minute)
+ gotSubs, err = adp.TopicsForUser(types.ParseUserId("usr"+testData.Users[0].Id), false, &qOpts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(gotSubs) != 0 {
+ t.Error(mismatchErrorString("Subs length (IMS 2)", len(gotSubs), 0))
+ }
+}
+
+func TestUsersForTopic(t *testing.T) {
+ qOpts := types.QueryOpt{
+ User: types.ParseUserId("usr" + testData.Users[0].Id),
+ Limit: 999,
+ }
+ gotSubs, err := adp.UsersForTopic("grpgRXf0rU4uR4", false, &qOpts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(gotSubs) != 1 {
+ t.Error(mismatchErrorString("Subs length", len(gotSubs), 1))
+ }
+
+ gotSubs, err = adp.UsersForTopic("grpgRXf0rU4uR4", true, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(gotSubs) != 2 {
+ t.Error(mismatchErrorString("Subs length", len(gotSubs), 2))
+ }
+
+ gotSubs, err = adp.UsersForTopic("p2p9AVDamaNCRbfKzGSh3mE0w", false, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(gotSubs) != 2 {
+ t.Error(mismatchErrorString("Subs length", len(gotSubs), 2))
+ }
+}
+
+func TestOwnTopics(t *testing.T) {
+ gotSubs, err := adp.OwnTopics(types.ParseUserId("usr" + testData.Users[0].Id))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(gotSubs) != 1 {
+ t.Fatalf("Got topic length %v instead of %v", len(gotSubs), 1)
+ }
+ if gotSubs[0] != testData.Topics[0].Id {
+ t.Errorf("Got topic %v instead of %v", gotSubs[0], testData.Topics[0].Id)
+ }
+}
+
+func TestSubscriptionGet(t *testing.T) {
+ got, err := adp.SubscriptionGet(testData.Topics[0].Id, types.ParseUserId("usr"+testData.Users[0].Id), false)
+ if err != nil {
+ t.Error(err)
+ }
+
+ if diff := cmp.Diff(got, testData.Subs[0],
+ cmpopts.IgnoreUnexported(types.Subscription{}, types.ObjHeader{})); diff != "" {
+ t.Error(mismatchErrorString("Subs", diff, ""))
+ }
+ // Test not found
+ got, err = adp.SubscriptionGet("dummytopic", dummyUid1, false)
+ if err != nil {
+ t.Error(err)
+ }
+ if got != nil {
+ t.Error("result sub should be nil.")
+ }
+}
+
+func TestSubsForUser(t *testing.T) {
+ gotSubs, err := adp.SubsForUser(types.ParseUserId("usr" + testData.Users[0].Id))
+ if err != nil {
+ t.Error(err)
+ }
+ if len(gotSubs) != 2 {
+ t.Error(mismatchErrorString("Subs length", len(gotSubs), 1))
+ }
+
+ // Test not found
+ gotSubs, err = adp.SubsForUser(types.ParseUserId("usr12345678"))
+ if err != nil {
+ t.Error(err)
+ }
+ if len(gotSubs) != 0 {
+ t.Error(mismatchErrorString("Subs length", len(gotSubs), 0))
+ }
+}
+
+func TestSubsForTopic(t *testing.T) {
+ qOpts := types.QueryOpt{
+ User: types.ParseUserId("usr" + testData.Users[0].Id),
+ Limit: 999,
+ }
+ gotSubs, err := adp.SubsForTopic(testData.Topics[0].Id, false, &qOpts)
+ if err != nil {
+ t.Error(err)
+ }
+ if len(gotSubs) != 1 {
+ t.Error(mismatchErrorString("Subs length", len(gotSubs), 1))
+ }
+ // Test not found
+ gotSubs, err = adp.SubsForTopic("dummytopicid", false, nil)
+ if err != nil {
+ t.Error(err)
+ }
+ if len(gotSubs) != 0 {
+ t.Error(mismatchErrorString("Subs length", len(gotSubs), 0))
+ }
+}
+
+func TestFind(t *testing.T) {
+ reqTags := [][]string{{"alice", "bob", "carol", "travel", "qwer", "asdf", "zxcv"}}
+ got, err := adp.Find("usr"+testData.Users[2].Id, "", reqTags, nil, true)
+ if err != nil {
+ t.Error(err)
+ }
+ if len(got) != 3 {
+ t.Error(mismatchErrorString("result length", len(got), 3))
+ }
+}
+
+func TestMessageGetAll(t *testing.T) {
+ opts := types.QueryOpt{
+ Since: 1,
+ Before: 2,
+ Limit: 999,
+ }
+ gotMsgs, err := adp.MessageGetAll(testData.Topics[0].Id, types.ParseUserId("usr"+testData.Users[0].Id), &opts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(gotMsgs) != 1 {
+ t.Error(mismatchErrorString("Messages length opts", len(gotMsgs), 1))
+ }
+ gotMsgs, _ = adp.MessageGetAll(testData.Topics[0].Id, types.ParseUserId("usr"+testData.Users[0].Id), nil)
+ if len(gotMsgs) != 2 {
+ t.Fatalf("%+v", gotMsgs)
+ t.Error(mismatchErrorString("Messages length no opts", len(gotMsgs), 2))
+ }
+ gotMsgs, _ = adp.MessageGetAll(testData.Topics[0].Id, types.ZeroUid, nil)
+ if len(gotMsgs) != 3 {
+ t.Error(mismatchErrorString("Messages length zero uid", len(gotMsgs), 3))
+ }
+}
+
+func TestFileGet(t *testing.T) {
+ // General test done during TestFileFinishUpload().
+
+ // Test not found
+ got, err := adp.FileGet("dummyfileid")
+ if err != nil {
+ if got != nil {
+ t.Error("File found but shouldn't:", got)
+ }
+ }
+}
+
+// ================== Update tests ================================
+func TestUserUpdate(t *testing.T) {
+ update := map[string]any{
+ "UserAgent": "Test Agent v0.11",
+ "UpdatedAt": testData.Now.Add(30 * time.Minute),
+ }
+ err := adp.UserUpdate(types.ParseUserId("usr"+testData.Users[0].Id), update)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var got struct {
+ UserAgent string
+ UpdatedAt time.Time
+ CreatedAt time.Time
+ }
+ err = db.QueryRow("SELECT useragent, updatedat, createdat FROM users WHERE id=?", decodeUid(testData.Users[0].Id)).
+ Scan(&got.UserAgent, &got.UpdatedAt, &got.CreatedAt)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.UserAgent != "Test Agent v0.11" {
+ t.Error(mismatchErrorString("UserAgent", got.UserAgent, "Test Agent v0.11"))
+ }
+ if got.UpdatedAt.Equal(got.CreatedAt) {
+ t.Error("UpdatedAt field not updated")
+ }
+}
+
+func TestUserUpdateTags(t *testing.T) {
+ addTags := testData.Tags[0]
+ removeTags := testData.Tags[1]
+ resetTags := testData.Tags[2]
+ uid := types.ParseUserId("usr" + testData.Users[0].Id)
+
+ got, err := adp.UserUpdateTags(uid, addTags, nil, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := []string{"alice", "tag1"}
+ if !reflect.DeepEqual(got, want) {
+ t.Error(mismatchErrorString("Tags", got, want))
+
+ }
+ got, err = adp.UserUpdateTags(uid, nil, removeTags, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want = nil
+ if !reflect.DeepEqual(got, want) {
+ t.Error(mismatchErrorString("Tags", got, want))
+
+ }
+ got, err = adp.UserUpdateTags(uid, nil, nil, resetTags)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want = []string{"alice", "tag111", "tag333"}
+ if !reflect.DeepEqual(got, want) {
+ t.Error(mismatchErrorString("Tags", got, want))
+
+ }
+ got, err = adp.UserUpdateTags(uid, addTags, removeTags, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want = []string{"tag111", "tag333"}
+ if !reflect.DeepEqual(got, want) {
+ t.Error(mismatchErrorString("Tags", got, want))
+
+ }
+ got, err = adp.UserUpdateTags(uid, addTags, removeTags, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want = []string{"tag111", "tag333"}
+ if !reflect.DeepEqual(got, want) {
+ t.Error(mismatchErrorString("Tags", got, want))
+ }
+}
+
+func TestCredFail(t *testing.T) {
+ err := adp.CredFail(types.ParseUserId("usr"+testData.Creds[3].User), "tel")
+ if err != nil {
+ t.Error(err)
+ }
+
+ // Check if fields updated
+ var got struct {
+ Retries int
+ UpdatedAt time.Time
+ CreatedAt time.Time
+ }
+ err = db.QueryRow("SELECT retries, updatedat, createdat FROM credentials WHERE userid=? AND method=? AND value=?",
+ decodeUid(testData.Creds[3].User), "tel", testData.Creds[3].Value).Scan(&got.Retries, &got.UpdatedAt, &got.CreatedAt)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Retries != 1 {
+ t.Error(mismatchErrorString("Retries count", got.Retries, 1))
+ }
+ if got.UpdatedAt.Equal(got.CreatedAt) {
+ t.Error("UpdatedAt field not updated")
+ }
+}
+
+func TestCredConfirm(t *testing.T) {
+ err := adp.CredConfirm(types.ParseUserId("usr"+testData.Creds[3].User), "tel")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Test fields are updated
+ var got struct {
+ UpdatedAt time.Time
+ CreatedAt time.Time
+ Done bool
+ }
+ err = db.QueryRow("SELECT updatedat, createdat, done FROM credentials WHERE userid=? AND method=? AND value=?",
+ decodeUid(testData.Creds[3].User), "tel", testData.Creds[3].Value).Scan(&got.UpdatedAt, &got.CreatedAt, &got.Done)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.UpdatedAt.Equal(got.CreatedAt) {
+ t.Error("Credential not updated correctly")
+ }
+ if !got.Done {
+ t.Error("Credential should be marked as done")
+ }
+}
+
+func TestAuthUpdRecord(t *testing.T) {
+ rec := testData.Recs[1]
+ newSecret := []byte{'s', 'e', 'c', 'r', 'e', 't'}
+ err := adp.AuthUpdRecord(types.ParseUserId("usr"+rec.UserId), rec.Scheme, rec.Unique,
+ rec.AuthLvl, newSecret, rec.Expires)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got []byte
+ err = db.QueryRow("SELECT secret FROM auth WHERE uname=?", rec.Unique).Scan(&got)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if reflect.DeepEqual(got, rec.Secret) {
+ t.Error(mismatchErrorString("Secret", got, rec.Secret))
+ }
+
+ // Test with auth ID (unique) change
+ newId := "basic:bob12345"
+ err = adp.AuthUpdRecord(types.ParseUserId("usr"+rec.UserId), rec.Scheme, newId,
+ rec.AuthLvl, newSecret, rec.Expires)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Test if old ID deleted
+ var count int
+ err = db.QueryRow("SELECT COUNT(*) FROM auth WHERE uname=?", rec.Unique).Scan(&count)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if count != 0 {
+ t.Error("Old auth record not deleted")
+ }
+}
+
+func TestTopicUpdateOnMessage(t *testing.T) {
+ msg := types.Message{
+ ObjHeader: types.ObjHeader{
+ CreatedAt: testData.Now.Add(33 * time.Minute),
+ },
+ SeqId: 66,
+ }
+ err := adp.TopicUpdateOnMessage(testData.Topics[2].Id, &msg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got struct {
+ TouchedAt time.Time
+ SeqId int
+ }
+ err = db.QueryRow("SELECT touchedat, seqid FROM topics WHERE name=?", testData.Topics[2].Id).
+ Scan(&got.TouchedAt, &got.SeqId)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.TouchedAt != msg.CreatedAt || got.SeqId != msg.SeqId {
+ t.Error(mismatchErrorString("TouchedAt", got.TouchedAt, msg.CreatedAt))
+ t.Error(mismatchErrorString("SeqId", got.SeqId, msg.SeqId))
+ }
+}
+
+func TestTopicUpdate(t *testing.T) {
+ update := map[string]any{
+ "UpdatedAt": testData.Now.Add(55 * time.Minute),
+ }
+ err := adp.TopicUpdate(testData.Topics[0].Id, update)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got time.Time
+ err = db.QueryRow("SELECT updatedat FROM topics WHERE name=?", testData.Topics[0].Id).Scan(&got)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != update["UpdatedAt"] {
+ t.Error(mismatchErrorString("UpdatedAt", got, update["UpdatedAt"]))
+ }
+}
+
+func TestTopicOwnerChange(t *testing.T) {
+ err := adp.TopicOwnerChange(testData.Topics[0].Id, types.ParseUserId("usr"+testData.Users[1].Id))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got int64
+ err = db.QueryRow("SELECT owner FROM topics WHERE name=?", testData.Topics[0].Id).Scan(&got)
+ if err != nil {
+ t.Fatal(err)
+ }
+ expectedOwner := decodeUid(testData.Users[1].Id) // Assuming user ID conversion
+ if got != expectedOwner {
+ t.Error(mismatchErrorString("Owner", got, expectedOwner))
+ }
+}
+
+func TestSubsUpdate(t *testing.T) {
+ update := map[string]any{
+ "UpdatedAt": testData.Now.Add(22 * time.Minute),
+ }
+ err := adp.SubsUpdate(testData.Topics[0].Id, types.ParseUserId("usr"+testData.Users[0].Id), update)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got time.Time
+ err = db.QueryRow("SELECT updatedat FROM subscriptions WHERE topic=? AND userid=?",
+ testData.Topics[0].Id, decodeUid(testData.Users[0].Id)).Scan(&got)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != update["UpdatedAt"] {
+ t.Error(mismatchErrorString("UpdatedAt", got, update["UpdatedAt"]))
+ }
+
+ err = adp.SubsUpdate(testData.Topics[1].Id, types.ZeroUid, update)
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = db.QueryRow("SELECT updatedat FROM subscriptions WHERE topic=? LIMIT 1",
+ testData.Topics[1].Id).Scan(&got)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != update["UpdatedAt"] {
+ t.Error(mismatchErrorString("UpdatedAt", got, update["UpdatedAt"]))
+ }
+}
+
+func TestSubsDelete(t *testing.T) {
+ err := adp.SubsDelete(testData.Topics[1].Id, types.ParseUserId("usr"+testData.Users[0].Id))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var deletedat sql.NullTime
+ err = db.QueryRow("SELECT deletedat FROM subscriptions WHERE topic=? AND userid=?",
+ testData.Topics[1].Id, decodeUid(testData.Users[0].Id)).Scan(&deletedat)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !deletedat.Valid {
+ t.Error("DeletedAt should not be null")
+ }
+}
+
+func TestDeviceUpsert(t *testing.T) {
+ err := adp.DeviceUpsert(types.ParseUserId("usr"+testData.Users[0].Id), testData.Devs[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got struct {
+ DeviceId string
+ Platform string
+ }
+ err = db.QueryRow("SELECT deviceid, platform FROM devices WHERE userid=? LIMIT 1",
+ decodeUid(testData.Users[0].Id)).Scan(&got.DeviceId, &got.Platform)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.DeviceId != testData.Devs[0].DeviceId || got.Platform != testData.Devs[0].Platform {
+ t.Error(mismatchErrorString("Device", got, testData.Devs[0]))
+ }
+
+ // Test update
+ testData.Devs[0].Platform = "Web"
+ err = adp.DeviceUpsert(types.ParseUserId("usr"+testData.Users[0].Id), testData.Devs[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = db.QueryRow("SELECT platform FROM devices WHERE userid=? AND deviceid=?",
+ decodeUid(testData.Users[0].Id), testData.Devs[0].DeviceId).Scan(&got.Platform)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Platform != "Web" {
+ t.Error("Device not updated.", got.Platform)
+ }
+
+ // Test add same device to another user
+ err = adp.DeviceUpsert(types.ParseUserId("usr"+testData.Users[1].Id), testData.Devs[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = db.QueryRow("SELECT platform FROM devices WHERE userid=? AND deviceid=?",
+ decodeUid(testData.Users[1].Id), testData.Devs[0].DeviceId).Scan(&got.Platform)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Platform != "Web" {
+ t.Error("Device not updated.", got.Platform)
+ }
+
+ err = adp.DeviceUpsert(types.ParseUserId("usr"+testData.Users[2].Id), testData.Devs[1])
+ if err != nil {
+ t.Error(err)
+ }
+}
+
+func TestFileFinishUpload(t *testing.T) {
+ got, err := adp.FileFinishUpload(testData.Files[0], true, 22222)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Status != types.UploadCompleted {
+ t.Error(mismatchErrorString("Status", got.Status, types.UploadCompleted))
+ }
+ if got.Size != 22222 {
+ t.Error(mismatchErrorString("Size", got.Size, 22222))
+ }
+}
+
+func TestMessageAttachments(t *testing.T) {
+ fids := []string{testData.Files[0].Id, testData.Files[1].Id}
+ err := adp.FileLinkAttachments("", types.ZeroUid, types.ParseUid(testData.Msgs[1].Id), fids)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Check if attachments were linked (this would require checking filemsglinks table)
+ var count int
+ if err = db.QueryRow("SELECT COUNT(*) FROM filemsglinks WHERE msgid=?",
+ types.ParseUid(testData.Msgs[1].Id)).Scan(&count); err != nil {
+ t.Fatal(err)
+ }
+
+ if count != len(fids) {
+ t.Error(mismatchErrorString("Attachments count", count, len(fids)))
+ }
+}
+
+// ================== Other tests =================================
+func TestDeviceGetAll(t *testing.T) {
+ uid0 := types.ParseUserId("usr" + testData.Users[0].Id)
+ uid1 := types.ParseUserId("usr" + testData.Users[1].Id)
+ uid2 := types.ParseUserId("usr" + testData.Users[2].Id)
+ gotDevs, count, err := adp.DeviceGetAll(uid0, uid1, uid2)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if count != 2 {
+ t.Fatal(mismatchErrorString("count", count, 2))
+ }
+ if !reflect.DeepEqual(gotDevs[uid1][0], *testData.Devs[0]) {
+ t.Error(mismatchErrorString("Device", gotDevs[uid1][0], *testData.Devs[0]))
+ }
+ if !reflect.DeepEqual(gotDevs[uid2][0], *testData.Devs[1]) {
+ t.Error(mismatchErrorString("Device", gotDevs[uid2][0], *testData.Devs[1]))
+ }
+}
+
+func TestDeviceDelete(t *testing.T) {
+ err := adp.DeviceDelete(types.ParseUserId("usr"+testData.Users[1].Id), testData.Devs[0].DeviceId)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var count int
+ err = db.QueryRow("SELECT COUNT(*) FROM devices WHERE userid=?", testData.Users[1].Id).Scan(&count)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if count != 0 {
+ t.Error("Device not deleted:", count)
+ }
+
+ err = adp.DeviceDelete(types.ParseUserId("usr"+testData.Users[2].Id), "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = db.QueryRow("SELECT COUNT(*) FROM devices WHERE userid=?", testData.Users[2].Id).Scan(&count)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if count != 0 {
+ t.Error("Device not deleted:", count)
+ }
+}
+
+// ================== Persistent Cache tests ======================
+func TestPCacheUpsert(t *testing.T) {
+ err := adp.PCacheUpsert("test_key", "test_value", false)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Test duplicate with failOnDuplicate = true
+ err = adp.PCacheUpsert("test_key2", "test_value2", true)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ err = adp.PCacheUpsert("test_key2", "new_value", true)
+ if err != types.ErrDuplicate {
+ t.Error("Expected duplicate error")
+ }
+}
+
+func TestPCacheGet(t *testing.T) {
+ value, err := adp.PCacheGet("test_key")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if value != "test_value" {
+ t.Error(mismatchErrorString("Cache value", value, "test_value"))
+ }
+
+ // Test not found
+ _, err = adp.PCacheGet("nonexistent")
+ if err != types.ErrNotFound {
+ t.Error("Expected not found error")
+ }
+}
+
+func TestPCacheDelete(t *testing.T) {
+ err := adp.PCacheDelete("test_key")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Verify deleted
+ _, err = adp.PCacheGet("test_key")
+ if err != types.ErrNotFound {
+ t.Error("Key should be deleted")
+ }
+}
+
+func TestPCacheExpire(t *testing.T) {
+ // Insert some test keys with prefix
+ adp.PCacheUpsert("prefix_key1", "value1", false)
+ adp.PCacheUpsert("prefix_key2", "value2", false)
+
+ // Expire keys older than now (should delete all test keys)
+ err := adp.PCacheExpire("prefix_", time.Now().Add(1*time.Minute))
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+// ================== Delete tests ================================
+func TestCredDel(t *testing.T) {
+ err := adp.CredDel(types.ParseUserId("usr"+testData.Users[0].Id), "email", "alice@test.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var count int
+ err = db.QueryRow("SELECT COUNT(*) FROM credentials WHERE method='email' AND value='alice@test.example.com'").Scan(&count)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if count != 0 {
+ t.Error("Got result but shouldn't", count)
+ }
+
+ err = adp.CredDel(types.ParseUserId("usr"+testData.Users[1].Id), "", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = db.QueryRow("SELECT COUNT(*) FROM credentials WHERE userid=?", testData.Users[1].Id).Scan(&count)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if count != 0 {
+ t.Error("Got result but shouldn't", count)
+ }
+}
+
+func TestAuthDelScheme(t *testing.T) {
+ // tested during TestAuthUpdRecord
+}
+
+func TestAuthDelAllRecords(t *testing.T) {
+ delCount, err := adp.AuthDelAllRecords(types.ParseUserId("usr" + testData.Recs[0].UserId))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if delCount != 1 {
+ t.Error(mismatchErrorString("delCount", delCount, 1))
+ }
+
+ // With dummy user
+ delCount, _ = adp.AuthDelAllRecords(dummyUid1)
+ if delCount != 0 {
+ t.Error(mismatchErrorString("delCount", delCount, 0))
+ }
+}
+
+func TestSubsDelForUser(t *testing.T) {
+ // Tested during TestUserDelete (both hard and soft deletions)
+}
+
+func TestMessageDeleteList(t *testing.T) {
+ toDel := types.DelMessage{
+ ObjHeader: types.ObjHeader{
+ Id: testData.UGen.GetStr(),
+ CreatedAt: testData.Now,
+ UpdatedAt: testData.Now,
+ },
+ Topic: testData.Topics[1].Id,
+ DeletedFor: testData.Users[2].Id,
+ DelId: 1,
+ SeqIdRanges: []types.Range{{Low: 9}, {Low: 3, Hi: 7}},
+ }
+ err := adp.MessageDeleteList(toDel.Topic, &toDel)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Check messages in dellog
+ var count int
+ err = db.QueryRow("SELECT COUNT(*) FROM dellog WHERE topic=? AND deletedfor=?",
+ toDel.Topic, decodeUid(toDel.DeletedFor)).Scan(&count)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if count == 0 {
+ t.Error("No dellog entries created")
+ }
+
+ // Hard delete test
+ toDel = types.DelMessage{
+ ObjHeader: types.ObjHeader{
+ Id: testData.UGen.GetStr(),
+ CreatedAt: testData.Now,
+ UpdatedAt: testData.Now,
+ },
+ Topic: testData.Topics[0].Id,
+ DelId: 3,
+ SeqIdRanges: []types.Range{{Low: 1, Hi: 3}},
+ }
+ err = adp.MessageDeleteList(toDel.Topic, &toDel)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Check if messages content was cleared
+ err = db.QueryRow("SELECT COUNT(*) FROM messages WHERE topic=? AND content IS NOT NULL",
+ toDel.Topic).Scan(&count)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if count > 1 {
+ t.Errorf("Messages not properly deleted %d, %s", count, toDel.Topic)
+ }
+
+ err = adp.MessageDeleteList(testData.Topics[0].Id, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = db.QueryRow("SELECT COUNT(*) FROM messages WHERE topic=?", testData.Topics[0].Id).Scan(&count)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if count != 0 {
+ t.Error("Result should be empty:", count)
+ }
+}
+
+func TestTopicDelete(t *testing.T) {
+ err := adp.TopicDelete(testData.Topics[1].Id, false, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var state int
+ err = db.QueryRow("SELECT state FROM topics WHERE name=?", testData.Topics[1].Id).Scan(&state)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if state != int(types.StateDeleted) {
+ t.Error("Soft delete failed:", state)
+ }
+
+ err = adp.TopicDelete(testData.Topics[0].Id, false, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var count int
+ err = db.QueryRow("SELECT COUNT(*) FROM topics WHERE name=?", testData.Topics[0].Id).Scan(&count)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if count != 0 {
+ t.Error("Hard delete failed:", count)
+ }
+}
+
+func TestFileDeleteUnused(t *testing.T) {
+ locs, err := adp.FileDeleteUnused(time.Now().Add(1*time.Minute), 999)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(locs) != 2 {
+ t.Error(mismatchErrorString("Locations length", len(locs), 2))
+ }
+}
+
+func TestUserDelete(t *testing.T) {
+ err := adp.UserDelete(types.ParseUserId("usr"+testData.Users[0].Id), false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var state int
+ err = db.QueryRow("SELECT state FROM users WHERE id=?",
+ decodeUid(testData.Users[0].Id)).Scan(&state)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if state != int(types.StateDeleted) {
+ t.Error("User soft delete failed", state)
+ }
+
+ err = adp.UserDelete(types.ParseUserId("usr"+testData.Users[1].Id), true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var count int
+ err = db.QueryRow("SELECT COUNT(*) FROM users WHERE id=?", testData.Users[1].Id).Scan(&count)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if count != 0 {
+ t.Error("User hard delete failed")
+ }
+}
+
+// ================== Other tests =================================
+
+func TestUserUnreadCount(t *testing.T) {
+ uids := []types.Uid{
+ types.ParseUserId("usr" + testData.Users[1].Id),
+ types.ParseUserId("usr" + testData.Users[2].Id),
+ }
+ expected := map[types.Uid]int{uids[0]: 0, uids[1]: 166}
+ counts, err := adp.UserUnreadCount(uids...)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(counts) != 2 {
+ t.Error(mismatchErrorString("UnreadCount length", len(counts), 2))
+ }
+
+ for uid, unread := range counts {
+ if expected[uid] != unread {
+ t.Error(mismatchErrorString("UnreadCount", unread, expected[uid]))
+ }
+ }
+
+ // Test not found (even if the account is not found, the call must return one record).
+ counts, err = adp.UserUnreadCount(dummyUid1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(counts) != 1 {
+ t.Error(mismatchErrorString("UnreadCount length (dummy)", len(counts), 1))
+ }
+ if counts[dummyUid1] != 0 {
+ t.Error(mismatchErrorString("Non-zero UnreadCount (dummy)", counts[dummyUid1], 0))
+ }
+}
+
+func TestMessageGetDeleted(t *testing.T) {
+ qOpts := types.QueryOpt{
+ Since: 1,
+ Before: 10,
+ Limit: 999,
+ }
+ got, err := adp.MessageGetDeleted(testData.Topics[1].Id, types.ParseUserId("usr"+testData.Users[2].Id), &qOpts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 1 {
+ t.Error(mismatchErrorString("result length", len(got), 1))
+ }
+}
+
+// ================================================================
+func mismatchErrorString(key string, got, want any) string {
+ return fmt.Sprintf("%s mismatch:\nGot = %+v\nWant = %+v", key, got, want)
+}
+
+func init() {
+ logs.Init(os.Stderr, "stdFlags")
+ adp = backend.GetTestAdapter()
+ conffile := flag.String("config", "./test.conf", "config of the database connection")
+
+ if file, err := os.Open(*conffile); err != nil {
+ log.Fatal("Failed to read config file:", err)
+ } else if err = json.NewDecoder(jcr.New(file)).Decode(&config); err != nil {
+ log.Fatal("Failed to parse config file:", err)
+ }
+
+ if adp == nil {
+ log.Fatal("Database adapter is missing")
+ }
+ if adp.IsOpen() {
+ log.Print("Connection is already opened")
+ }
+
+ err := adp.Open(config.Adapters[adp.GetName()])
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ db = adp.GetTestDB().(*sqlx.DB)
+ testData = test_data.InitTestData()
+ if testData == nil {
+ log.Fatal("Failed to initialize test data")
+ }
+ store.SetTestUidGenerator(*testData.UGen)
+}
diff --git a/server/db/mysql/tests/test.conf b/server/db/mysql/tests/test.conf
index e69de29..93745dd 100644
--- a/server/db/mysql/tests/test.conf
+++ b/server/db/mysql/tests/test.conf
@@ -0,0 +1,10 @@
+{
+ "reset_db_data": true,
+ "adapters": {
+ "mysql": {
+ "dsn": "root@tcp(localhost:3306)/tinode_test?parseTime=true&collation=utf8mb4_unicode_ci",
+ // Name of the main database.
+ "database": "tinode_test"
+ }
+ }
+}
diff --git a/server/db/postgres/adapter.go b/server/db/postgres/adapter.go
index e69de29..190a935 100644
--- a/server/db/postgres/adapter.go
+++ b/server/db/postgres/adapter.go
@@ -0,0 +1,3719 @@
+//go:build postgres
+// +build postgres
+
+// Package postgres is a database adapter for PostgreSQL.
+package postgres
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "hash/fnv"
+ "log"
+ "net/url"
+ "reflect"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgconn"
+ "github.com/jackc/pgx/v5/pgxpool"
+ "github.com/jmoiron/sqlx"
+ "github.com/tinode/chat/server/auth"
+ "github.com/tinode/chat/server/db/common"
+ "github.com/tinode/chat/server/store"
+ t "github.com/tinode/chat/server/store/types"
+)
+
+// adapter holds MySQL connection data.
+type adapter struct {
+ db *pgxpool.Pool
+ poolConfig *pgxpool.Config
+ dsn string
+ dbName string
+ // Maximum number of records to return
+ maxResults int
+ // Maximum number of message records to return
+ maxMessageResults int
+ version int
+
+ // Single query timeout.
+ sqlTimeout time.Duration
+ // DB transaction timeout.
+ txTimeout time.Duration
+}
+
+const (
+ adpVersion = 116
+ adapterName = "postgres"
+
+ defaultMaxResults = 1024
+ // This is capped by the Session's send queue limit (128).
+ defaultMaxMessageResults = 100
+
+ // If DB request timeout is specified,
+ // we allocate txTimeoutMultiplier times more time for transactions.
+ txTimeoutMultiplier = 1.5
+)
+
+type configType struct {
+ // DB connection settings:
+ // Using fields
+ User string `json:"user,omitempty"`
+ Passwd string `json:"passwd,omitempty"`
+ Host string `json:"host,omitempty"`
+ Port string `json:"port,omitempty"`
+ DBName string `json:"dbname,omitempty"`
+ // Deprecated.
+ DSN string `json:"dsn,omitempty"`
+
+ // Connection pool settings.
+ //
+ // Maximum number of open connections to the database.
+ MaxOpenConns int `json:"max_open_conns,omitempty"`
+ // Maximum number of connections in the idle connection pool.
+ MaxIdleConns int `json:"max_idle_conns,omitempty"`
+ // Maximum amount of time a connection may be reused (in seconds).
+ ConnMaxLifetime int `json:"conn_max_lifetime,omitempty"`
+
+ // SSL mode determines how SSL connections are handled.
+ // Supported values:
+ // - "disable": No SSL connection (default)
+ // - "require": Require SSL connection but don't verify server certificate
+ // - "verify-ca": Require SSL and verify that the server certificate is issued by a trusted CA
+ // - "verify-full": Require SSL and verify that the server certificate matches the server hostname
+ // - "prefer": Try SSL first, fallback to non-SSL if SSL fails
+ // - "allow": Try non-SSL first, fallback to SSL if non-SSL fails
+ SSLMode string `json:"ssl_mode,omitempty"`
+
+ // DB request timeout (in seconds).
+ // If 0 (or negative), no timeout is applied.
+ SqlTimeout int `json:"sql_timeout,omitempty"`
+}
+
+func (a *adapter) getContext() (context.Context, context.CancelFunc) {
+ if a.sqlTimeout > 0 {
+ return context.WithTimeout(context.Background(), a.sqlTimeout)
+ }
+ return context.Background(), nil
+}
+
+func (a *adapter) getContextForTx() (context.Context, context.CancelFunc) {
+ if a.txTimeout > 0 {
+ return context.WithTimeout(context.Background(), a.txTimeout)
+ }
+ return context.Background(), nil
+}
+
+// Open initializes database session
+func (a *adapter) Open(jsonconfig json.RawMessage) error {
+ if a.db != nil {
+ return errors.New("postgres adapter is already connected")
+ }
+
+ if len(jsonconfig) < 2 {
+ return errors.New("postgres adapter missing config")
+ }
+
+ var err error
+ var config configType
+ ctx := context.Background()
+ if err = json.Unmarshal(jsonconfig, &config); err != nil {
+ return errors.New("postgres adapter failed to parse config: " + err.Error())
+ }
+
+ if config.DSN != "" {
+ a.dsn = config.DSN
+ if uri, err := url.Parse(a.dsn); err == nil {
+ a.dbName = strings.TrimPrefix(uri.Path, "/")
+ } else {
+ return err
+ }
+ } else {
+ if a.dsn, err = setConnStr(config); err != nil {
+ return err
+ }
+ a.dbName = config.DBName
+ }
+
+ if a.maxResults <= 0 {
+ a.maxResults = defaultMaxResults
+ }
+
+ if a.maxMessageResults <= 0 {
+ a.maxMessageResults = defaultMaxMessageResults
+ }
+
+ if a.poolConfig, err = pgxpool.ParseConfig(a.dsn); err != nil {
+ return errors.New("postgres adapter failed to parse DSN: " + err.Error())
+ }
+
+ // NewWithConfig only creates a pool object. Ping to validate connectivity.
+ a.db, err = pgxpool.NewWithConfig(ctx, a.poolConfig)
+ if err != nil {
+ return err
+ }
+
+ err = a.db.Ping(ctx)
+ if isMissingDb(err) {
+ // Missing DB is OK if we are initializing the database.
+ // Since tinode DB does not exist, connect without specifying the DB name.
+ a.db.Close()
+ a.poolConfig.ConnConfig.Database = ""
+ a.db, err = pgxpool.NewWithConfig(ctx, a.poolConfig)
+ if err != nil {
+ return err
+ }
+ err = a.db.Ping(ctx)
+ }
+ if err != nil {
+ return err
+ }
+
+ if err == nil {
+ if config.MaxOpenConns > 0 {
+ a.poolConfig.MaxConns = int32(config.MaxOpenConns)
+ }
+ if config.MaxIdleConns > 0 {
+ a.poolConfig.MinConns = int32(config.MaxIdleConns)
+ }
+ if config.ConnMaxLifetime > 0 {
+ a.poolConfig.MaxConnLifetime = time.Duration(config.ConnMaxLifetime) * time.Second
+ }
+ if config.SqlTimeout > 0 {
+ a.sqlTimeout = time.Duration(config.SqlTimeout) * time.Second
+ // We allocate txTimeoutMultiplier times sqlTimeout for transactions.
+ a.txTimeout = time.Duration(float64(config.SqlTimeout)*txTimeoutMultiplier) * time.Second
+ }
+ }
+ return err
+}
+
+// Close closes the underlying database connection
+func (a *adapter) Close() error {
+ if a.db != nil {
+ a.db.Close()
+ a.db = nil
+ a.version = -1
+ }
+ return nil
+}
+
+// IsOpen returns true if connection to database has been established. It does not check if
+// connection is actually live.
+func (a *adapter) IsOpen() bool {
+ return a.db != nil
+}
+
+// GetDbVersion returns current database version.
+func (a *adapter) GetDbVersion() (int, error) {
+ if a.version > 0 {
+ return a.version, nil
+ }
+
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ var vers string
+ err := a.db.QueryRow(ctx, "SELECT value FROM kvmeta WHERE key='version'").Scan(&vers)
+ if err != nil {
+ if isMissingDb(err) || isMissingTable(err) || err == pgx.ErrNoRows {
+ err = errors.New("Database not initialized")
+ }
+ return -1, err
+ }
+
+ a.version, _ = strconv.Atoi(vers)
+
+ return a.version, nil
+}
+
+func (a *adapter) updateDbVersion(v int) error {
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ a.version = -1
+ if _, err := a.db.Exec(ctx, `UPDATE kvmeta SET "value"=$1 WHERE "key"='version'`, strconv.Itoa(v)); err != nil {
+ return err
+ }
+ return nil
+}
+
+// CheckDbVersion checks whether the actual DB version matches the expected version of this adapter.
+func (a *adapter) CheckDbVersion() error {
+ version, err := a.GetDbVersion()
+ if err != nil {
+ return err
+ }
+
+ if version != adpVersion {
+ return errors.New("Invalid database version " + strconv.Itoa(version) +
+ ". Expected " + strconv.Itoa(adpVersion))
+ }
+
+ return nil
+}
+
+// Version returns adapter version.
+func (adapter) Version() int {
+ return adpVersion
+}
+
+// DB connection stats object.
+func (a *adapter) Stats() any {
+ if a.db == nil {
+ return nil
+ }
+ return a.db.Stat()
+}
+
+// GetName returns string that adapter uses to register itself with store.
+func (a *adapter) GetName() string {
+ return adapterName
+}
+
+// SetMaxResults configures how many results can be returned in a single DB call.
+func (a *adapter) SetMaxResults(val int) error {
+ if val <= 0 {
+ a.maxResults = defaultMaxResults
+ } else {
+ a.maxResults = val
+ }
+
+ return nil
+}
+
+// CreateDb initializes the storage.
+func (a *adapter) CreateDb(reset bool) error {
+ var err error
+ var tx pgx.Tx
+
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+
+ // Can't use an existing connection because it's configured with a database name which may not exist.
+ // Don't care if it does not close cleanly.
+ if a.db != nil {
+ a.db.Close()
+ }
+
+ // Create default database name
+ a.poolConfig.ConnConfig.Database = "postgres"
+
+ a.db, err = pgxpool.NewWithConfig(ctx, a.poolConfig)
+ if err != nil {
+ return err
+ }
+ if err = a.db.Ping(ctx); err != nil {
+ return err
+ }
+
+ if reset {
+ if _, err = a.db.Exec(ctx, fmt.Sprintf("DROP DATABASE IF EXISTS %s;", a.dbName)); err != nil {
+ return err
+ }
+ }
+
+ if _, err = a.db.Exec(ctx, fmt.Sprintf("CREATE DATABASE %s WITH ENCODING utf8;", a.dbName)); err != nil {
+ return err
+ }
+
+ a.poolConfig.ConnConfig.Database = a.dbName
+ a.db, err = pgxpool.NewWithConfig(ctx, a.poolConfig)
+ if err != nil {
+ return err
+ }
+ if err = a.db.Ping(ctx); err != nil {
+ return err
+ }
+
+ if tx, err = a.db.Begin(ctx); err != nil {
+ return err
+ }
+
+ defer func() {
+ if err != nil {
+ // FIXME: This is useless: MySQL auto-commits on every CREATE TABLE.
+ // Maybe DROP DATABASE instead.
+ tx.Rollback(ctx)
+ }
+ }()
+
+ // Indexed users.
+ if _, err := tx.Exec(ctx,
+ `CREATE TABLE users(
+ id BIGINT NOT NULL,
+ createdat TIMESTAMP(3) NOT NULL,
+ updatedat TIMESTAMP(3) NOT NULL,
+ state SMALLINT NOT NULL DEFAULT 0,
+ stateat TIMESTAMP(3),
+ access JSON,
+ lastseen TIMESTAMP,
+ useragent VARCHAR(255) DEFAULT '',
+ public JSON,
+ trusted JSON,
+ tags JSON,
+ PRIMARY KEY(id)
+ );
+ CREATE INDEX users_state_stateat ON users(state, stateat);
+ CREATE INDEX users_lastseen_updatedat ON users(lastseen, updatedat);`); err != nil {
+ return err
+ }
+
+ // Indexed user tags.
+ if _, err = tx.Exec(ctx,
+ `CREATE TABLE usertags(
+ id SERIAL NOT NULL,
+ userid BIGINT NOT NULL,
+ tag VARCHAR(96) NOT NULL,
+ PRIMARY KEY(id),
+ FOREIGN KEY(userid) REFERENCES users(id)
+ );
+ CREATE INDEX usertags_tag ON usertags(tag);
+ CREATE UNIQUE INDEX usertags_userid_tag ON usertags(userid, tag);`); err != nil {
+ return err
+ }
+
+ // Indexed devices. Normalized into a separate table.
+ if _, err = tx.Exec(ctx,
+ `CREATE TABLE devices(
+ id SERIAL NOT NULL,
+ userid BIGINT NOT NULL,
+ hash CHAR(16) NOT NULL,
+ deviceid TEXT NOT NULL,
+ platform VARCHAR(32),
+ lastseen TIMESTAMP NOT NULL,
+ lang VARCHAR(8),
+ PRIMARY KEY(id),
+ FOREIGN KEY(userid) REFERENCES users(id)
+ );
+ CREATE UNIQUE INDEX devices_hash ON devices(hash);`); err != nil {
+ return err
+ }
+
+ // Authentication records for the basic authentication scheme.
+ if _, err = tx.Exec(ctx,
+ `CREATE TABLE auth(
+ id SERIAL NOT NULL,
+ uname VARCHAR(32) NOT NULL,
+ userid BIGINT NOT NULL,
+ scheme VARCHAR(16) NOT NULL,
+ authlvl INT NOT NULL,
+ secret VARCHAR(255) NOT NULL,
+ expires TIMESTAMP,
+ PRIMARY KEY(id),
+ FOREIGN KEY(userid) REFERENCES users(id)
+ );
+ CREATE UNIQUE INDEX auth_userid_scheme ON auth(userid, scheme);
+ CREATE UNIQUE INDEX auth_uname ON auth(uname);`); err != nil {
+ return err
+ }
+
+ // Topics
+ if _, err = tx.Exec(ctx,
+ `CREATE TABLE topics(
+ id SERIAL NOT NULL,
+ createdat TIMESTAMP(3) NOT NULL,
+ updatedat TIMESTAMP(3) NOT NULL,
+ state SMALLINT NOT NULL DEFAULT 0,
+ stateat TIMESTAMP(3),
+ touchedat TIMESTAMP(3),
+ name VARCHAR(25) NOT NULL,
+ usebt BOOLEAN DEFAULT FALSE,
+ owner BIGINT NOT NULL DEFAULT 0,
+ access JSON,
+ seqid INT NOT NULL DEFAULT 0,
+ delid INT DEFAULT 0,
+ subcnt INT DEFAULT 0,
+ public JSON,
+ trusted JSON,
+ tags JSON,
+ aux JSON,
+ PRIMARY KEY(id)
+ );
+ CREATE UNIQUE INDEX topics_name ON topics(name);
+ CREATE INDEX topics_owner ON topics(owner);
+ CREATE INDEX topics_state_stateat ON topics(state, stateat);
+ CREATE INDEX topics_name_state_seqid ON topics(name, state, seqid);`); err != nil {
+ return err
+ }
+
+ // Create system topic 'sys'.
+ if err = createSystemTopic(tx); err != nil {
+ return err
+ }
+
+ // Indexed topic tags.
+ if _, err = tx.Exec(ctx,
+ `CREATE TABLE topictags(
+ id SERIAL NOT NULL,
+ topic VARCHAR(25) NOT NULL,
+ tag VARCHAR(96) NOT NULL,
+ PRIMARY KEY(id),
+ FOREIGN KEY(topic) REFERENCES topics(name)
+ );
+ CREATE INDEX topictags_tag ON topictags(tag);
+ CREATE UNIQUE INDEX topictags_topic_tag ON topictags(topic, tag);`); err != nil {
+ return err
+ }
+
+ // Subscriptions
+ if _, err = tx.Exec(ctx,
+ `CREATE TABLE subscriptions(
+ id SERIAL NOT NULL,
+ createdat TIMESTAMP(3) NOT NULL,
+ updatedat TIMESTAMP(3) NOT NULL,
+ deletedat TIMESTAMP(3),
+ userid BIGINT NOT NULL,
+ topic VARCHAR(25) NOT NULL,
+ delid INT DEFAULT 0,
+ recvseqid INT DEFAULT 0,
+ readseqid INT DEFAULT 0,
+ modewant VARCHAR(8),
+ modegiven VARCHAR(8),
+ private JSON,
+ PRIMARY KEY(id),
+ FOREIGN KEY(userid) REFERENCES users(id)
+ );
+ CREATE UNIQUE INDEX subscriptions_topic_userid ON subscriptions(topic, userid);
+ CREATE INDEX subscriptions_topic ON subscriptions(topic);
+ CREATE INDEX subscriptions_deletedat ON subscriptions(deletedat);
+ CREATE INDEX subscriptions_userid_topic_deletedat ON subscriptions(userid, topic, deletedat);`); err != nil {
+ return err
+ }
+
+ // Messages
+ if _, err = tx.Exec(ctx,
+ `CREATE TABLE messages(
+ id SERIAL NOT NULL,
+ createdat TIMESTAMP(3) NOT NULL,
+ updatedat TIMESTAMP(3) NOT NULL,
+ deletedat TIMESTAMP(3),
+ delid INT DEFAULT 0,
+ seqid INT NOT NULL,
+ topic VARCHAR(25) NOT NULL,
+ "from" BIGINT NOT NULL,
+ head JSON,
+ content JSON,
+ PRIMARY KEY(id),
+ FOREIGN KEY(topic) REFERENCES topics(name)
+ );
+ CREATE UNIQUE INDEX messages_topic_seqid ON messages(topic, seqid);`); err != nil {
+ return err
+ }
+
+ // Deletion log
+ if _, err = tx.Exec(ctx,
+ `CREATE TABLE dellog(
+ id SERIAL NOT NULL,
+ topic VARCHAR(25) NOT NULL,
+ deletedfor BIGINT NOT NULL DEFAULT 0,
+ delid INT NOT NULL,
+ low INT NOT NULL,
+ hi INT NOT NULL,
+ PRIMARY KEY(id),
+ FOREIGN KEY(topic) REFERENCES topics(name)
+ );
+ CREATE INDEX dellog_topic_delid_deletedfor ON dellog(topic,delid,deletedfor);
+ CREATE INDEX dellog_topic_deletedfor_low_hi ON dellog(topic,deletedfor,low,hi);
+ CREATE INDEX dellog_deletedfor ON dellog(deletedfor);`); err != nil {
+ return err
+ }
+
+ // User credentials
+ if _, err = tx.Exec(ctx,
+ `CREATE TABLE credentials(
+ id SERIAL NOT NULL,
+ createdat TIMESTAMP(3) NOT NULL,
+ updatedat TIMESTAMP(3) NOT NULL,
+ deletedat TIMESTAMP(3),
+ method VARCHAR(16) NOT NULL,
+ value VARCHAR(128) NOT NULL,
+ synthetic VARCHAR(192) NOT NULL,
+ userid BIGINT NOT NULL,
+ resp VARCHAR(255),
+ done BOOLEAN NOT NULL DEFAULT FALSE,
+ retries INT NOT NULL DEFAULT 0,
+ PRIMARY KEY(id),
+ FOREIGN KEY(userid) REFERENCES users(id)
+ );
+ CREATE UNIQUE INDEX credentials_uniqueness ON credentials(synthetic);`); err != nil {
+ return err
+ }
+
+ // Records of uploaded files.
+ // Don't add FOREIGN KEY on userid. It's not needed and it will break user deletion.
+ if _, err = tx.Exec(ctx,
+ `CREATE TABLE fileuploads(
+ id BIGINT NOT NULL,
+ createdat TIMESTAMP(3) NOT NULL,
+ updatedat TIMESTAMP(3) NOT NULL,
+ userid BIGINT,
+ status INT NOT NULL,
+ mimetype VARCHAR(255) NOT NULL,
+ size BIGINT NOT NULL,
+ etag VARCHAR(128),
+ location VARCHAR(2048) NOT NULL,
+ PRIMARY KEY(id)
+ );
+ CREATE INDEX fileuploads_status ON fileuploads(status);`); err != nil {
+ return err
+ }
+
+ // Links between uploaded files and the topics, users or messages they are attached to.
+ if _, err = tx.Exec(ctx,
+ `CREATE TABLE filemsglinks(
+ id SERIAL NOT NULL,
+ createdat TIMESTAMP(3) NOT NULL,
+ fileid BIGINT NOT NULL,
+ msgid INT,
+ topic VARCHAR(25),
+ userid BIGINT,
+ PRIMARY KEY(id),
+ FOREIGN KEY(fileid) REFERENCES fileuploads(id) ON DELETE CASCADE,
+ FOREIGN KEY(msgid) REFERENCES messages(id) ON DELETE CASCADE,
+ FOREIGN KEY(topic) REFERENCES topics(name) ON DELETE CASCADE,
+ FOREIGN KEY(userid) REFERENCES users(id) ON DELETE CASCADE
+ );`); err != nil {
+ return err
+ }
+
+ if _, err = tx.Exec(ctx,
+ `CREATE TABLE kvmeta(
+ "key" VARCHAR(64) NOT NULL,
+ createdat TIMESTAMP(3),
+ "value" TEXT,
+ PRIMARY KEY("key")
+ );
+ CREATE INDEX kvmeta_createdat_key ON kvmeta(createdat, "key");`); err != nil {
+ return err
+ }
+ if _, err = tx.Exec(ctx, `INSERT INTO kvmeta("key", "value") VALUES($1, $2)`, "version", strconv.Itoa(adpVersion)); err != nil {
+ return err
+ }
+
+ return tx.Commit(ctx)
+}
+
+// UpgradeDb upgrades the database, if necessary.
+func (a *adapter) UpgradeDb() error {
+ bumpVersion := func(a *adapter, x int) error {
+ if err := a.updateDbVersion(x); err != nil {
+ return err
+ }
+ _, err := a.GetDbVersion()
+ return err
+ }
+
+ if _, err := a.GetDbVersion(); err != nil {
+ return err
+ }
+
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+
+ if a.version == 112 {
+ // Perform database upgrade from version 112 to version 113.
+
+ // Index for deleting unvalidated accounts.
+ if _, err := a.db.Exec(ctx, "CREATE INDEX users_lastseen_updatedat ON users(lastseen,updatedat)"); err != nil {
+ return err
+ }
+
+ // Allow lnger kvmeta keys.
+ if _, err := a.db.Exec(ctx, `ALTER TABLE kvmeta ALTER COLUMN "key" TYPE VARCHAR(64)`); err != nil {
+ return err
+ }
+
+ if _, err := a.db.Exec(ctx, `ALTER TABLE kvmeta ALTER COLUMN "key" SET NOT NULL`); err != nil {
+ return err
+ }
+
+ // Add timestamp to kvmeta.
+ if _, err := a.db.Exec(ctx, `ALTER TABLE kvmeta ADD COLUMN createdat TIMESTAMP(3)`); err != nil {
+ return err
+ }
+
+ // Add compound index on the new field and key (could be searched by key prefix).
+ if _, err := a.db.Exec(ctx, `CREATE INDEX kvmeta_createdat_key ON kvmeta(createdat, "key")`); err != nil {
+ return err
+ }
+
+ if err := bumpVersion(a, 113); err != nil {
+ return err
+ }
+ }
+
+ if a.version == 113 {
+ // Perform database upgrade from version 113 to version 114.
+
+ if _, err := a.db.Exec(ctx, "ALTER TABLE topics ADD COLUMN aux JSON"); err != nil {
+ return err
+ }
+
+ if _, err := a.db.Exec(ctx, "ALTER TABLE fileuploads ADD COLUMN etag VARCHAR(128)"); err != nil {
+ return err
+ }
+
+ if err := bumpVersion(a, 114); err != nil {
+ return err
+ }
+ }
+
+ if a.version == 114 {
+ // Perform database upgrade from version 114 to version 115.
+
+ // Find relevant subscriptions for given users efficiently, and use the join key too.
+ if _, err := a.db.Exec(ctx, "CREATE INDEX idx_subs_user_topic_del ON subscriptions(userid, topic, deletedat)"); err != nil {
+ return err
+ }
+
+ // Optimizes join; state filters; seqid supports the SUM operation.
+ if _, err := a.db.Exec(ctx, "CREATE INDEX idx_topics_name_state_seqid ON topics(name, state, seqid)"); err != nil {
+ return err
+ }
+
+ if err := bumpVersion(a, 115); err != nil {
+ return err
+ }
+ }
+
+ if a.version == 115 {
+ // Perform database upgrade from version 115 to version 116.
+
+ // Add subscriber count column to the topics table.
+ if _, err := a.db.Exec(ctx, "ALTER TABLE topics ADD subcnt INT DEFAULT 0"); err != nil {
+ return err
+ }
+
+ if err := bumpVersion(a, 116); err != nil {
+ return err
+ }
+ }
+
+ if a.version != adpVersion {
+ return errors.New("Failed to perform database upgrade to version " + strconv.Itoa(adpVersion) +
+ ". DB is still at " + strconv.Itoa(a.version))
+ }
+ return nil
+}
+
+func createSystemTopic(tx pgx.Tx) error {
+ now := t.TimeNow()
+ query := `INSERT INTO topics(createdat,updatedat,state,touchedat,name,access,public)
+ VALUES($1,$2,$3,$4,'sys','{"Auth": "N","Anon": "N"}','{"fn": "System"}')`
+ _, err := tx.Exec(context.Background(), query, now, now, t.StateOK, now)
+ return err
+}
+
+func addTags(ctx context.Context, tx pgx.Tx, table, keyName string, keyVal any, tags []string, ignoreDups bool) error {
+ if len(tags) == 0 {
+ return nil
+ }
+
+ //addTags(ctx, tx, "usertags", "userid", decoded_uid, add, reset == nil)
+ sql := "INSERT INTO " + table + " (" + keyName + ",tag) VALUES($1,$2)"
+ if ignoreDups {
+ sql += " ON CONFLICT DO NOTHING"
+ }
+ for _, tag := range tags {
+ if _, err := tx.Exec(ctx, sql, keyVal, tag); err != nil {
+ if isDupe(err) {
+ return t.ErrDuplicate
+ }
+ return err
+ }
+ }
+
+ return nil
+}
+
+func removeTags(ctx context.Context, tx pgx.Tx, table, keyName string, keyVal any, tags []string) error {
+ if len(tags) == 0 {
+ return nil
+ }
+
+ sql, args := expandQuery("DELETE FROM "+table+" WHERE "+keyName+"=? AND tag IN (?)", keyVal, tags)
+ _, err := tx.Exec(ctx, sql, args...)
+
+ return err
+}
+
+// UserCreate creates a new user. Returns error and true if error is due to duplicate user name,
+// false for any other error
+func (a *adapter) UserCreate(user *t.User) error {
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTx(ctx, pgx.TxOptions{})
+ if err != nil {
+ return err
+ }
+
+ defer func() {
+ if err != nil {
+ tx.Rollback(ctx)
+ }
+ }()
+
+ decoded_uid := store.DecodeUid(user.Uid())
+ if _, err = tx.Exec(ctx,
+ "INSERT INTO users(id,createdat,updatedat,state,access,public,trusted,tags) VALUES($1,$2,$3,$4,$5,$6,$7,$8);",
+ decoded_uid,
+ user.CreatedAt,
+ user.UpdatedAt,
+ user.State,
+ user.Access,
+ common.ToJSON(user.Public),
+ common.ToJSON(user.Trusted),
+ user.Tags); err != nil {
+ return err
+ }
+
+ // Save user's tags to a separate table to make user findable.
+ if err = addTags(ctx, tx, "usertags", "userid", decoded_uid, user.Tags, false); err != nil {
+ return err
+ }
+
+ return tx.Commit(ctx)
+}
+
+// Add user's authentication record
+func (a *adapter) AuthAddRecord(uid t.Uid, scheme, unique string, authLvl auth.Level,
+ secret []byte, expires time.Time) error {
+
+ var exp *time.Time
+ if !expires.IsZero() {
+ exp = &expires
+ }
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+
+ if _, err := a.db.Exec(ctx, "INSERT INTO auth(uname,userid,scheme,authLvl,secret,expires) VALUES($1,$2,$3,$4,$5,$6)",
+ unique, store.DecodeUid(uid), scheme, authLvl, secret, exp); err != nil {
+ if isDupe(err) {
+ return t.ErrDuplicate
+ }
+ return err
+ }
+ return nil
+}
+
+// AuthDelScheme deletes an existing authentication scheme for the user.
+func (a *adapter) AuthDelScheme(user t.Uid, scheme string) error {
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ _, err := a.db.Exec(ctx, "DELETE FROM auth WHERE userid=$1 AND scheme=$2", store.DecodeUid(user), scheme)
+ return err
+}
+
+// AuthDelAllRecords deletes all authentication records for the user.
+func (a *adapter) AuthDelAllRecords(user t.Uid) (int, error) {
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+
+ res, err := a.db.Exec(ctx, "DELETE FROM auth WHERE userid=$1", store.DecodeUid(user))
+ if err != nil {
+ return 0, err
+ }
+ count := res.RowsAffected()
+
+ return int(count), nil
+}
+
+// Update user's authentication unique, secret, auth level.
+func (a *adapter) AuthUpdRecord(uid t.Uid, scheme, unique string, authLvl auth.Level,
+ secret []byte, expires time.Time) error {
+
+ parapg := []string{"authLvl=?"}
+ args := []any{authLvl}
+ if unique != "" {
+ parapg = append(parapg, "uname=?")
+ args = append(args, unique)
+ }
+ if len(secret) > 0 {
+ parapg = append(parapg, "secret=?")
+ args = append(args, secret)
+ }
+ if !expires.IsZero() {
+ parapg = append(parapg, "expires=?")
+ args = append(args, expires)
+ }
+ args = append(args, store.DecodeUid(uid), scheme)
+
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ sql, args := expandQuery("UPDATE auth SET "+strings.Join(parapg, ",")+" WHERE userid=? AND scheme=?", args...)
+ resp, err := a.db.Exec(ctx, sql, args...)
+ if isDupe(err) {
+ return t.ErrDuplicate
+ }
+
+ if count := resp.RowsAffected(); count <= 0 {
+ return t.ErrNotFound
+ }
+
+ return err
+}
+
+// Retrieve user's authentication record
+func (a *adapter) AuthGetRecord(uid t.Uid, scheme string) (string, auth.Level, []byte, time.Time, error) {
+ var expires time.Time
+
+ var record struct {
+ Uname string
+ Authlvl auth.Level
+ Secret []byte
+ Expires *time.Time
+ }
+
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ if err := a.db.QueryRow(ctx, "SELECT uname,secret,expires,authlvl FROM auth WHERE userid=$1 AND scheme=$2",
+ store.DecodeUid(uid), scheme).Scan(
+ &record.Uname, &record.Secret, &record.Expires, &record.Authlvl); err != nil {
+ if err == pgx.ErrNoRows {
+ // Nothing found - use standard error.
+ err = t.ErrNotFound
+ }
+ return "", 0, nil, expires, err
+ }
+
+ if record.Expires != nil {
+ expires = *record.Expires
+ }
+
+ return record.Uname, record.Authlvl, record.Secret, expires, nil
+}
+
+// Retrieve user's authentication record
+func (a *adapter) AuthGetUniqueRecord(unique string) (t.Uid, auth.Level, []byte, time.Time, error) {
+ var expires time.Time
+
+ var record struct {
+ Userid int64
+ Authlvl auth.Level
+ Secret []byte
+ Expires *time.Time
+ }
+
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ if err := a.db.QueryRow(ctx, "SELECT userid,secret,expires,authlvl FROM auth WHERE uname=$1", unique).Scan(
+ &record.Userid, &record.Secret, &record.Expires, &record.Authlvl); err != nil {
+ if err == pgx.ErrNoRows {
+ // Nothing found - clear the error
+ err = nil
+ }
+ return t.ZeroUid, 0, nil, expires, err
+ }
+
+ if record.Expires != nil {
+ expires = *record.Expires
+ }
+
+ return store.EncodeUid(record.Userid), record.Authlvl, record.Secret, expires, nil
+}
+
+// UserGet fetches a single user by user id. If user is not found it returns (nil, nil)
+func (a *adapter) UserGet(uid t.Uid) (*t.User, error) {
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+
+ var user t.User
+ var id int64
+ row, err := a.db.Query(ctx, "SELECT * FROM users WHERE id=$1 AND state!=$2", store.DecodeUid(uid), t.StateDeleted)
+ if err != nil {
+ return nil, err
+ }
+ defer row.Close()
+
+ if !row.Next() {
+ // Nothing found: user does not exist or marked as soft-deleted
+ return nil, nil
+ }
+
+ err = row.Scan(&id, &user.CreatedAt, &user.UpdatedAt, &user.State, &user.StateAt, &user.Access, &user.LastSeen, &user.UserAgent, &user.Public, &user.Trusted, &user.Tags)
+ if err == nil {
+ user.SetUid(uid)
+ return &user, nil
+ }
+
+ return nil, err
+}
+
+func (a *adapter) UserGetAll(ids ...t.Uid) ([]t.User, error) {
+ uids := make([]any, len(ids))
+ for i, id := range ids {
+ uids[i] = store.DecodeUid(id)
+ }
+
+ users := []t.User{}
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+
+ rows, err := a.db.Query(ctx, "SELECT * FROM users WHERE id = ANY ($1) AND state!=$2", uids, t.StateDeleted)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var user t.User
+ var id int64
+ if err = rows.Scan(&id, &user.CreatedAt, &user.UpdatedAt, &user.State, &user.StateAt, &user.Access, &user.LastSeen, &user.UserAgent, &user.Public, &user.Trusted, &user.Tags); err != nil {
+ users = nil
+ break
+ }
+ user.SetUid(store.EncodeUid(id))
+
+ users = append(users, user)
+ }
+ if err == nil {
+ err = rows.Err()
+ }
+
+ return users, err
+}
+
+// UserDelete deletes specified user: wipes completely (hard-delete) or marks as deleted.
+// TODO: report when the user is not found.
+func (a *adapter) UserDelete(uid t.Uid, hard bool) error {
+ query := "SELECT name FROM topics WHERE owner=$1"
+ args := []any{store.DecodeUid(uid)}
+ // In case of hard delete, delete all topics, even those which were
+ // soft-deleted previsously.
+ if !hard {
+ query += " AND state!=$2"
+ args = append(args, t.StateDeleted)
+ }
+ // Get a list of topic names owned by the user (as 'grp' and 'chn').
+ ownTopics, err := a.topicNamesForUser(query, false, args...)
+ if err != nil {
+ return err
+ }
+
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTx(ctx, pgx.TxOptions{})
+ if err != nil {
+ return err
+ }
+
+ defer func() {
+ if err != nil {
+ tx.Rollback(ctx)
+ }
+ }()
+
+ now := t.TimeNow()
+ decoded_uid := store.DecodeUid(uid)
+
+ if hard {
+ // Delete user's devices
+ // t.ErrNotFound = user has no devices.
+ if err = deviceDelete(ctx, tx, uid, ""); err != nil && err != t.ErrNotFound {
+ return err
+ }
+
+ // Delete user's subscriptions in all topics.
+ if err = subsDelForUser(ctx, tx, decoded_uid, true); err != nil {
+ return err
+ }
+
+ // Delete records of messages soft-deleted for the user.
+ if _, err = tx.Exec(ctx, "DELETE FROM dellog WHERE deletedfor=$1", decoded_uid); err != nil {
+ return err
+ }
+
+ // Can't delete user's messages in all topics because we cannot notify topics of such deletion.
+ // Just leave the messages there marked as sent by "not found" user.
+
+ // Delete topics where the user is the owner.
+
+ if len(ownTopics) > 0 {
+ // First delete all messages in those topics.
+ if _, err = tx.Exec(ctx, "DELETE FROM dellog USING topics WHERE topics.name=dellog.topic AND topics.owner=$1",
+ decoded_uid); err != nil {
+ return err
+ }
+
+ // Deletion of messages will cascade to filemsglinks and so to fileuploads.
+ if _, err = tx.Exec(ctx, "DELETE FROM messages USING topics WHERE topics.name=messages.topic AND topics.owner=$1",
+ decoded_uid); err != nil {
+ return err
+ }
+ // Delete subscriptions for all users where the user is the owner of the topic.
+ sql, args, _ := sqlx.In("DELETE FROM subscriptions AS s WHERE topic IN (?)", ownTopics)
+ if _, err = tx.Exec(ctx, sqlx.Rebind(sqlx.DOLLAR, sql), args...); err != nil {
+ return err
+ }
+
+ // Delete topic tags.
+ if _, err = tx.Exec(ctx, "DELETE FROM topictags USING topics WHERE topics.name=topictags.topic AND topics.owner=$1",
+ decoded_uid); err != nil {
+ return err
+ }
+
+ // And finally delete the topics.
+ if _, err = tx.Exec(ctx, "DELETE FROM topics WHERE owner=$1", decoded_uid); err != nil {
+ return err
+ }
+ }
+
+ // Delete user's authentication records.
+ if _, err = tx.Exec(ctx, "DELETE FROM auth WHERE userid=$1", decoded_uid); err != nil {
+ return err
+ }
+
+ // Delete all credentials.
+ if err = credDel(ctx, tx, uid, "", ""); err != nil && err != t.ErrNotFound {
+ return err
+ }
+
+ if _, err = tx.Exec(ctx, "DELETE FROM usertags WHERE userid=$1", decoded_uid); err != nil {
+ return err
+ }
+
+ if _, err = tx.Exec(ctx, "DELETE FROM users WHERE id=$1", decoded_uid); err != nil {
+ return err
+ }
+ } else {
+ // Disable all user's subscriptions. That includes p2p subscriptions. No need to delete them.
+ if err = subsDelForUser(ctx, tx, decoded_uid, false); err != nil {
+ return err
+ }
+
+ if len(ownTopics) > 0 {
+ // Disable all subscriptions to topics where the user is the owner.
+ sql, args, _ := sqlx.In("UPDATE subscriptions SET updatedat=?,deletedat=? WHERE topic IN (?)", now, now, ownTopics)
+ if _, err = tx.Exec(ctx, sqlx.Rebind(sqlx.DOLLAR, sql), args...); err != nil {
+ return err
+ }
+
+ // Disable group topics where the user is the owner.
+ if _, err = tx.Exec(ctx, "UPDATE topics SET updatedat=$1,touchedat=$1,state=$2,stateat=$1 WHERE owner=$3",
+ now, t.StateDeleted, decoded_uid); err != nil {
+ return err
+ }
+ }
+
+ // Disable p2p topics with the user (p2p topic's owner is 0).
+ if _, err = tx.Exec(ctx, "UPDATE topics SET updatedat=$1,touchedat=$1,state=$2,stateat=$1 "+
+ "FROM subscriptions WHERE topics.name=subscriptions.topic "+
+ "AND topics.owner=0 AND subscriptions.userid=$3",
+ now, t.StateDeleted, decoded_uid); err != nil {
+ return err
+ }
+
+ // Disable the other user's subscription to a disabled p2p topic.
+ if _, err = tx.Exec(ctx, "UPDATE subscriptions AS s_one SET updatedat=$1,deletedat=$1 "+
+ "FROM subscriptions AS s_two WHERE s_one.topic=s_two.topic "+
+ "AND s_two.userid=$2 AND s_two.topic LIKE 'p2p%'",
+ now, decoded_uid); err != nil {
+ return err
+ }
+
+ // Disable user.
+ if _, err = tx.Exec(ctx, "UPDATE users SET updatedat=$1,state=$2,stateat=$1 WHERE id=$3",
+ now, t.StateDeleted, decoded_uid); err != nil {
+ return err
+ }
+ }
+
+ return tx.Commit(ctx)
+}
+
+// topicStateForUser is called by UserUpdate when the update contains state change.
+// Soft-deleted topics remain soft-deleted.
+func (a *adapter) topicStateForUser(ctx context.Context, tx pgx.Tx, decoded_uid int64, now time.Time, update any) error {
+ var err error
+
+ state, ok := update.(t.ObjState)
+ if !ok {
+ return t.ErrMalformed
+ }
+
+ if now.IsZero() {
+ now = t.TimeNow()
+ }
+
+ // Change state of all topics where the user is the owner.
+ if _, err = tx.Exec(ctx, "UPDATE topics SET state=$1, stateat=$2 WHERE owner=$3 AND state!=$4",
+ state, now, decoded_uid, t.StateDeleted); err != nil {
+ return err
+ }
+
+ // Change state of p2p topics with the user (p2p topic's owner is 0)
+ if _, err = tx.Exec(ctx, "UPDATE topics SET state=$1, stateat=$2 "+
+ "FROM subscriptions WHERE topics.name=subscriptions.topic AND "+
+ "topics.owner=0 AND subscriptions.userid=$3 AND topics.state!=$4",
+ state, now, decoded_uid, t.StateDeleted); err != nil {
+ return err
+ }
+
+ // Subscriptions don't need to be updated:
+ // subscriptions of a disabled user are not disabled and still can be manipulated.
+
+ return nil
+}
+
+// UserUpdate updates user object.
+func (a *adapter) UserUpdate(uid t.Uid, update map[string]any) error {
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTx(ctx, pgx.TxOptions{})
+ if err != nil {
+ return err
+ }
+
+ defer func() {
+ if err != nil {
+ tx.Rollback(ctx)
+ }
+ }()
+
+ cols, args := common.UpdateByMap(update)
+ decoded_uid := store.DecodeUid(uid)
+ args = append(args, decoded_uid)
+ sql, args := expandQuery("UPDATE users SET "+strings.Join(cols, ",")+" WHERE id=?", args...)
+ _, err = tx.Exec(ctx, sql, args...)
+ if err != nil {
+ return err
+ }
+
+ if state, ok := update["State"]; ok {
+ now, _ := update["StateAt"].(time.Time)
+ err = a.topicStateForUser(ctx, tx, decoded_uid, now, state)
+ if err != nil {
+ return err
+ }
+ }
+
+ // Tags are also stored in a separate table
+ if tags := common.ExtractTags(update); tags != nil {
+ // First delete all user tags
+ _, err = tx.Exec(ctx, "DELETE FROM usertags WHERE userid=$1", decoded_uid)
+ if err != nil {
+ return err
+ }
+ // Now insert new tags
+ err = addTags(ctx, tx, "usertags", "userid", decoded_uid, tags, false)
+ if err != nil {
+ return err
+ }
+ }
+
+ return tx.Commit(ctx)
+}
+
+func tempFetchTags(ctx context.Context, tx pgx.Tx, decoded_uid int64) ([]string, error) {
+ var allTags []string
+ rows, err := tx.Query(ctx, "SELECT tag FROM usertags WHERE userid=$1", decoded_uid)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var tag string
+ rows.Scan(&tag)
+ allTags = append(allTags, tag)
+ }
+ return allTags, nil
+}
+
+// UserUpdateTags adds or resets user's tags
+func (a *adapter) UserUpdateTags(uid t.Uid, add, remove, reset []string) ([]string, error) {
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTx(ctx, pgx.TxOptions{})
+ if err != nil {
+ return nil, err
+ }
+
+ defer func() {
+ if err != nil {
+ tx.Rollback(ctx)
+ }
+ }()
+
+ decoded_uid := store.DecodeUid(uid)
+
+ if reset != nil {
+ // Delete all tags first if resetting.
+ _, err = tx.Exec(ctx, "DELETE FROM usertags WHERE userid=$1", decoded_uid)
+ if err != nil {
+ return nil, err
+ }
+ add = reset
+ remove = nil
+ }
+
+ // Now insert new tags. Ignore duplicates if resetting.
+ err = addTags(ctx, tx, "usertags", "userid", decoded_uid, add, reset == nil)
+ if err != nil {
+ return nil, err
+ }
+
+ // Delete tags.
+ err = removeTags(ctx, tx, "usertags", "userid", decoded_uid, remove)
+ if err != nil {
+ return nil, err
+ }
+
+ var allTags []string
+ rows, err := tx.Query(ctx, "SELECT tag FROM usertags WHERE userid=$1", decoded_uid)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var tag string
+ rows.Scan(&tag)
+ allTags = append(allTags, tag)
+ }
+
+ _, err = tx.Exec(ctx, "UPDATE users SET tags=$1 WHERE id=$2", t.StringSlice(allTags), decoded_uid)
+ if err != nil {
+ return nil, err
+ }
+
+ return allTags, tx.Commit(ctx)
+}
+
+// UserGetByCred returns user ID for the given validated credential.
+func (a *adapter) UserGetByCred(method, value string) (t.Uid, error) {
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ var decoded_uid int64
+ err := a.db.QueryRow(ctx, "SELECT userid FROM credentials WHERE synthetic=$1", method+":"+value).Scan(&decoded_uid)
+ if err == nil {
+ return store.EncodeUid(decoded_uid), nil
+ }
+
+ if err == pgx.ErrNoRows {
+ // Clear the error if user does not exist
+ return t.ZeroUid, nil
+ }
+ return t.ZeroUid, err
+}
+
+// UserUnreadCount returns the total number of unread messages in all topics with
+// the R permission. If read fails, the counts are still returned with the original
+// user IDs but with the unread count undefined and non-nil error.
+// UserUnreadCount does not count unread messages in channels although it should.
+func (a *adapter) UserUnreadCount(ids ...t.Uid) (map[t.Uid]int, error) {
+ uids := make([]any, len(ids))
+ counts := make(map[t.Uid]int, len(ids))
+ for i, id := range ids {
+ uids[i] = store.DecodeUid(id)
+ // Ensure all original uids are always present.
+ counts[id] = 0
+ }
+
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+
+ // FIXME: support channels.
+ query, uids := expandQuery("SELECT s.userid, SUM(t.seqid)-SUM(s.readseqid) AS unreadcount FROM topics AS t, subscriptions AS s "+
+ "WHERE s.userid IN (?) AND t.name=s.topic AND s.deletedat IS NULL AND t.state!=? AND "+
+ "POSITION('R' IN s.modewant)>0 AND POSITION('R' IN s.modegiven)>0 GROUP BY s.userid", uids, t.StateDeleted)
+ rows, err := a.db.Query(ctx, query, uids...)
+ if err != nil {
+ return counts, err
+ }
+ defer rows.Close()
+
+ var userId int64
+ var unreadCount int
+ for rows.Next() {
+ if err = rows.Scan(&userId, &unreadCount); err != nil {
+ break
+ }
+ counts[store.EncodeUid(userId)] = unreadCount
+ }
+ if err == nil {
+ err = rows.Err()
+ }
+
+ return counts, err
+}
+
+// UserGetUnvalidated returns a list of uids which have never logged in, have no
+// validated credentials and haven't been updated since lastUpdatedBefore.
+func (a *adapter) UserGetUnvalidated(lastUpdatedBefore time.Time, limit int) ([]t.Uid, error) {
+ var uids []t.Uid
+
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+
+ rows, err := a.db.Query(ctx,
+ "SELECT u.id, COALESCE(SUM(CASE WHEN c.done THEN 1 ELSE 0 END), 0) AS total "+
+ "FROM users u LEFT JOIN credentials c ON u.id = c.userid "+
+ "WHERE u.lastseen IS NULL AND u.updatedat < $1 GROUP BY u.id, u.updatedat "+
+ "HAVING COALESCE(SUM(CASE WHEN c.done THEN 1 ELSE 0 END), 0) = 0 ORDER BY u.updatedat ASC LIMIT $2",
+ lastUpdatedBefore, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var userId int64
+ var unused int
+ if err = rows.Scan(&userId, &unused); err != nil {
+ break
+ }
+ uids = append(uids, store.EncodeUid(userId))
+ }
+ if err == nil {
+ err = rows.Err()
+ }
+
+ return uids, err
+}
+
+// *****************************
+
+func (a *adapter) topicCreate(ctx context.Context, tx pgx.Tx, topic *t.Topic) error {
+ _, err := tx.Exec(ctx, "INSERT INTO topics(createdat,updatedat,touchedat,state,name,usebt,owner,access,public,trusted,tags,aux) "+
+ "VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)",
+ topic.CreatedAt, topic.UpdatedAt, topic.TouchedAt, topic.State, topic.Id, topic.UseBt,
+ store.DecodeUid(t.ParseUid(topic.Owner)), topic.Access, common.ToJSON(topic.Public), common.ToJSON(topic.Trusted),
+ topic.Tags, common.ToJSON(topic.Aux))
+ if err != nil {
+ return err
+ }
+
+ // Save topic's tags to a separate table to make topic findable.
+ return addTags(ctx, tx, "topictags", "topic", topic.Id, topic.Tags, false)
+}
+
+// TopicCreate saves topic object to database.
+func (a *adapter) TopicCreate(topic *t.Topic) error {
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTx(ctx, pgx.TxOptions{})
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err != nil {
+ tx.Rollback(ctx)
+ }
+ }()
+
+ err = a.topicCreate(ctx, tx, topic)
+ if err != nil {
+ return err
+ }
+ return tx.Commit(ctx)
+}
+
+// If undelete = true - update subscription on duplicate key, otherwise ignore the duplicate.
+func createSubscription(ctx context.Context, tx pgx.Tx, sub *t.Subscription, undelete bool) error {
+
+ isOwner := (sub.ModeGiven & sub.ModeWant).IsOwner()
+
+ jpriv := common.ToJSON(sub.Private)
+ decoded_uid := store.DecodeUid(t.ParseUid(sub.User))
+ _, err2 := tx.Exec(ctx, "SAVEPOINT createSub")
+ if err2 != nil {
+ log.Println("Error: Failed to create savepoint: ", err2.Error())
+ }
+ _, err := tx.Exec(ctx,
+ "INSERT INTO subscriptions(createdat,updatedat,deletedat,userid,topic,modeWant,modeGiven,private) "+
+ "VALUES($1,$2,NULL,$3,$4,$5,$6,$7)",
+ sub.CreatedAt, sub.UpdatedAt, decoded_uid, sub.Topic, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv)
+
+ if err != nil && isDupe(err) {
+ _, err2 = tx.Exec(ctx, "ROLLBACK TO SAVEPOINT createSub")
+ if err2 != nil {
+ log.Println("Error: Failed to rollback savepoint: ", err2.Error())
+ }
+ if undelete {
+ _, err = tx.Exec(ctx, "UPDATE subscriptions SET createdat=$1,updatedat=$2,deletedat=NULL,modeWant=$3,modeGiven=$4,"+
+ "delid=0,recvseqid=0,readseqid=0 WHERE topic=$5 AND userid=$6",
+ sub.CreatedAt, sub.UpdatedAt, sub.ModeWant.String(), sub.ModeGiven.String(), sub.Topic, decoded_uid)
+ } else {
+ _, err = tx.Exec(ctx, "UPDATE subscriptions SET createdat=$1,updatedat=$2,deletedat=NULL,modeWant=$3,modeGiven=$4,"+
+ "delid=0,recvseqid=0,readseqid=0,private=$5 WHERE topic=$6 AND userid=$7",
+ sub.CreatedAt, sub.UpdatedAt, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv,
+ sub.Topic, decoded_uid)
+ }
+ } else {
+ _, err2 = tx.Exec(ctx, "RELEASE SAVEPOINT createSub")
+ if err2 != nil {
+ log.Println("Error: Failed to release savepoint: ", err2.Error())
+ }
+ }
+ if err == nil && isOwner {
+ _, err = tx.Exec(ctx, "UPDATE topics SET owner=$1 WHERE name=$2", decoded_uid, sub.Topic)
+ }
+ return err
+}
+
+// TopicCreateP2P given two users creates a p2p topic
+func (a *adapter) TopicCreateP2P(initiator, invited *t.Subscription) error {
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTx(ctx, pgx.TxOptions{})
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err != nil {
+ tx.Rollback(ctx)
+ }
+ }()
+
+ err = createSubscription(ctx, tx, initiator, false)
+ if err != nil {
+ return err
+ }
+
+ err = createSubscription(ctx, tx, invited, true)
+ if err != nil {
+ return err
+ }
+
+ topic := &t.Topic{ObjHeader: t.ObjHeader{Id: initiator.Topic}}
+ topic.ObjHeader.MergeTimes(&initiator.ObjHeader)
+ topic.TouchedAt = initiator.GetTouchedAt()
+ err = a.topicCreate(ctx, tx, topic)
+ if err != nil {
+ return err
+ }
+
+ return tx.Commit(ctx)
+}
+
+// TopicGet loads a single topic by name, if it exists. If the topic does not exist the call returns (nil, nil)
+func (a *adapter) TopicGet(topic string) (*t.Topic, error) {
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+
+ // Fetch topic by name
+ var tt = new(t.Topic)
+ var owner int64
+ err := a.db.QueryRow(ctx,
+ "SELECT createdat,updatedat,state,stateat,touchedat,name AS id,usebt,access,owner,seqid,delid,subcnt,public,trusted,tags,aux "+
+ "FROM topics WHERE name=$1",
+ topic).Scan(&tt.CreatedAt, &tt.UpdatedAt, &tt.State, &tt.StateAt, &tt.TouchedAt, &tt.Id,
+ &tt.UseBt, &tt.Access, &owner, &tt.SeqId, &tt.DelId, &tt.SubCnt, &tt.Public, &tt.Trusted, &tt.Tags, &tt.Aux)
+ if err != nil {
+ if err == pgx.ErrNoRows {
+ // Nothing found - clear the error
+ err = nil
+ }
+ return nil, err
+ }
+
+ if t.GetTopicCat(topic) == t.TopicCatGrp {
+ // Topic found, get subsription count. Try both topic and channel names.
+ var subCnt int
+ if err = a.db.QueryRow(ctx,
+ "SELECT COUNT(*) FROM subscriptions WHERE topic IN ($1,$2) AND deletedat IS NULL", topic, t.GrpToChn(topic)).
+ Scan(&subCnt); err != nil {
+ return nil, err
+ }
+
+ if subCnt != tt.SubCnt {
+ // Update the topic with the correct subscription count.
+ tt.SubCnt = subCnt
+ if _, err = a.db.Exec(ctx, "UPDATE topics SET subcnt=$1 WHERE name=$2", subCnt, topic); err != nil {
+ return nil, err
+ }
+ }
+ }
+
+ tt.Owner = store.EncodeUid(owner).String()
+
+ return tt, err
+}
+
+// TopicsForUser loads user's contact list: p2p and grp topics, except for 'me' & 'fnd' subscriptions.
+// Reads and denormalizes Public value.
+func (a *adapter) TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) {
+ // Fetch ALL user's subscriptions, even those which has not been modified recently.
+ // We are going to use these subscriptions to fetch topics and users which may have been modified recently.
+ q := `SELECT createdat,updatedat,deletedat,topic,delid,recvseqid,
+ readseqid,modewant,modegiven,private FROM subscriptions WHERE userid=?`
+ args := []any{store.DecodeUid(uid)}
+ if !keepDeleted {
+ // Filter out deleted rows.
+ q += " AND deletedat IS NULL"
+ }
+
+ limit := 0
+ ims := time.Time{}
+ if opts != nil {
+ if opts.Topic != "" {
+ q += " AND topic=?"
+ args = append(args, opts.Topic)
+ }
+
+ // Apply the limit only when the client does not manage the cache (or cold start).
+ // Otherwise have to get all subscriptions and do a manual join with users/topics.
+ if opts.IfModifiedSince == nil {
+ if opts.Limit > 0 && opts.Limit < a.maxResults {
+ limit = opts.Limit
+ } else {
+ limit = a.maxResults
+ }
+ } else {
+ ims = *opts.IfModifiedSince
+ }
+ } else {
+ limit = a.maxResults
+ }
+
+ if limit > 0 {
+ q += " LIMIT ?"
+ args = append(args, limit)
+ }
+
+ q, args = expandQuery(q, args...)
+
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ rows, err := a.db.Query(ctx, q, args...)
+ if err != nil {
+ return nil, err
+ }
+ // Must close rows manually as we will be reusing it.
+
+ // Fetch subscriptions. Two queries are needed: users table (p2p) and topics table (grp).
+ // Prepare a list of separate subscriptions to users vs topics
+ join := make(map[string]t.Subscription) // Keeping these to make a join with table for .private and .access
+ topq := make([]any, 0, 16)
+ usrq := make([]any, 0, 16)
+ for rows.Next() {
+ var sub t.Subscription
+ var modeWant, modeGiven []byte
+ if err = rows.Scan(&sub.CreatedAt, &sub.UpdatedAt, &sub.DeletedAt, &sub.Topic, &sub.DelId,
+ &sub.RecvSeqId, &sub.ReadSeqId, &modeWant, &modeGiven, &sub.Private); err != nil {
+ break
+ }
+ sub.ModeWant.Scan(modeWant)
+ sub.ModeGiven.Scan(modeGiven)
+ tname := sub.Topic
+ sub.User = uid.String()
+ tcat := t.GetTopicCat(tname)
+
+ if tcat == t.TopicCatMe || tcat == t.TopicCatFnd {
+ // One of 'me', 'fnd' subscriptions, skip.
+ // Don't skip 'sys' subscription.
+ continue
+ } else if tcat == t.TopicCatP2P {
+ // P2P subscription, find the other user to get user.Public and user.Trusted.
+ uid1, uid2, _ := t.ParseP2P(tname)
+ if uid1 == uid {
+ usrq = append(usrq, store.DecodeUid(uid2))
+ sub.SetWith(uid2.UserId())
+ } else {
+ usrq = append(usrq, store.DecodeUid(uid1))
+ sub.SetWith(uid1.UserId())
+ }
+ } else if tcat == t.TopicCatGrp {
+ // Maybe convert channel name to topic name.
+ tname = t.ChnToGrp(tname)
+ }
+ // No special handling needed for 'slf', 'sys' subscriptions.
+
+ topq = append(topq, tname)
+ sub.Private = common.FromJSON(sub.Private)
+ join[tname] = sub
+ }
+ if err == nil {
+ err = rows.Err()
+ }
+ rows.Close()
+
+ if err != nil {
+ return nil, err
+ }
+
+ var subs []t.Subscription
+ if len(join) == 0 {
+ return subs, nil
+ }
+
+ // Fetch grp topics and join to subscriptions.
+ if len(topq) > 0 {
+ q = "SELECT updatedat,state,touchedat,name AS id,usebt,access,seqid,delid,subcnt,public,trusted " +
+ "FROM topics WHERE name IN (?)"
+ newargs := []any{topq}
+
+ if !keepDeleted {
+ // Optionally skip deleted topics.
+ q += " AND state!=?"
+ newargs = append(newargs, t.StateDeleted)
+ }
+
+ if !ims.IsZero() {
+ // Use cache timestamp if provided: get newer entries only.
+ q += " AND touchedat>?"
+ newargs = append(newargs, ims)
+
+ if limit > 0 && limit < len(topq) {
+ // No point in fetching more than the requested limit.
+ q += " ORDER BY touchedat LIMIT ?"
+ newargs = append(newargs, limit)
+ }
+ }
+ q, newargs = expandQuery(q, newargs...)
+
+ ctx2, cancel2 := a.getContext()
+ if cancel2 != nil {
+ defer cancel2()
+ }
+ rows, err = a.db.Query(ctx2, q, newargs...)
+ if err != nil {
+ return nil, err
+ }
+
+ var top t.Topic
+ for rows.Next() {
+ if err = rows.Scan(&top.UpdatedAt, &top.State, &top.TouchedAt, &top.Id, &top.UseBt,
+ &top.Access, &top.SeqId, &top.DelId, &top.SubCnt, &top.Public, &top.Trusted); err != nil {
+ break
+ }
+
+ sub := join[top.Id]
+ // Check if sub.UpdatedAt needs to be adjusted to earlier or later time.
+ sub.UpdatedAt = common.SelectLatestTime(sub.UpdatedAt, top.UpdatedAt)
+ sub.SetState(top.State)
+ sub.SetTouchedAt(top.TouchedAt)
+ sub.SetSeqId(top.SeqId)
+ if t.GetTopicCat(sub.Topic) == t.TopicCatGrp {
+ sub.SetSubCnt(top.SubCnt)
+ sub.SetPublic(top.Public)
+ sub.SetTrusted(top.Trusted)
+ }
+ // Put back the updated value of a subsription, will process further below
+ join[top.Id] = sub
+ }
+ if err == nil {
+ err = rows.Err()
+ }
+ rows.Close()
+
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ // Fetch p2p users and join to p2p subscriptions.
+ if len(usrq) > 0 {
+ q = "SELECT id,updatedat,state,access,lastseen,useragent,public,trusted " +
+ "FROM users WHERE id IN (?)"
+ newargs := []any{usrq}
+ if !keepDeleted {
+ // Optionally skip deleted users.
+ q += " AND state!=?"
+ newargs = append(newargs, t.StateDeleted)
+ }
+
+ // Ignoring ipg: we need all users to get LastSeen and UserAgent.
+
+ q, newargs = expandQuery(q, newargs...)
+
+ ctx3, cancel3 := a.getContext()
+ if cancel3 != nil {
+ defer cancel3()
+ }
+ rows, err = a.db.Query(ctx3, q, newargs...)
+ if err != nil {
+ return nil, err
+ }
+
+ for rows.Next() {
+ var usr2 t.User
+ var id int64
+ if err = rows.Scan(&id, &usr2.UpdatedAt, &usr2.State, &usr2.Access, &usr2.LastSeen, &usr2.UserAgent,
+ &usr2.Public, &usr2.Trusted); err != nil {
+ break
+ }
+
+ usr2.Id = store.EncodeUid(id).String()
+ joinOn := uid.P2PName(t.ParseUid(usr2.Id))
+ if sub, ok := join[joinOn]; ok {
+ sub.UpdatedAt = common.SelectLatestTime(sub.UpdatedAt, usr2.UpdatedAt)
+ sub.SetState(usr2.State)
+ sub.SetPublic(usr2.Public)
+ sub.SetTrusted(usr2.Trusted)
+ sub.SetDefaultAccess(usr2.Access.Auth, usr2.Access.Anon)
+ sub.SetLastSeenAndUA(usr2.LastSeen, usr2.UserAgent)
+ join[joinOn] = sub
+ }
+ }
+ if err == nil {
+ err = rows.Err()
+ }
+ rows.Close()
+
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ subs = make([]t.Subscription, 0, len(join))
+ for _, sub := range join {
+ subs = append(subs, sub)
+ }
+
+ return common.SelectEarliestUpdatedSubs(subs, opts, a.maxResults), nil
+}
+
+// UsersForTopic loads users subscribed to the given topic.
+// The difference between UsersForTopic vs SubsForTopic is that the former loads user.Public,
+// the latter does not.
+func (a *adapter) UsersForTopic(topic string, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) {
+ tcat := t.GetTopicCat(topic)
+
+ // Fetch all subscribed users. The number of users is not large
+ q := `SELECT s.createdat,s.updatedat,s.deletedat,s.userid,s.topic,s.delid,s.recvseqid,
+ s.readseqid,s.modewant,s.modegiven,u.public,u.trusted,u.lastseen,u.useragent,s.private
+ FROM subscriptions AS s JOIN users AS u ON s.userid=u.id
+ WHERE s.topic=?`
+ args := []any{topic}
+ if !keepDeleted {
+ // Filter out rows with users deleted
+ q += " AND u.state!=?"
+ args = append(args, t.StateDeleted)
+
+ // For p2p topics we must load all subscriptions including deleted.
+ // Otherwise it will be impossible to swipe Public values.
+ if tcat != t.TopicCatP2P {
+ // Filter out deleted subscriptions.
+ q += " AND s.deletedat IS NULL"
+ }
+ }
+
+ limit := a.maxResults
+ var oneUser t.Uid
+ if opts != nil {
+ // Ignore IfModifiedSince: loading all entries because a topic cannot have too many subscribers.
+ // Those unmodified will be stripped of Public & Private.
+
+ if !opts.User.IsZero() {
+ // For p2p topics we have to fetch both users otherwise public cannot be swapped.
+ if tcat != t.TopicCatP2P {
+ q += " AND s.userid=?"
+ args = append(args, store.DecodeUid(opts.User))
+ }
+ oneUser = opts.User
+ }
+ if opts.Limit > 0 && opts.Limit < limit {
+ limit = opts.Limit
+ }
+ }
+ q += " LIMIT ?"
+ args = append(args, limit)
+ q, args = expandQuery(q, args...)
+
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ rows, err := a.db.Query(ctx, q, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ // Fetch subscriptions
+ var sub t.Subscription
+ var subs []t.Subscription
+ var userId int64
+ var modeWant, modeGiven []byte
+ var lastSeen *time.Time = nil
+ var userAgent string
+ var public, trusted any
+ for rows.Next() {
+ if err = rows.Scan(
+ &sub.CreatedAt, &sub.UpdatedAt, &sub.DeletedAt,
+ &userId, &sub.Topic, &sub.DelId, &sub.RecvSeqId,
+ &sub.ReadSeqId, &modeWant, &modeGiven,
+ &public, &trusted, &lastSeen, &userAgent, &sub.Private); err != nil {
+ break
+ }
+
+ sub.User = store.EncodeUid(userId).String()
+ sub.SetPublic(public)
+ sub.SetTrusted(trusted)
+ sub.SetLastSeenAndUA(lastSeen, userAgent)
+ sub.ModeWant.Scan(modeWant)
+ sub.ModeGiven.Scan(modeGiven)
+ subs = append(subs, sub)
+ }
+ if err == nil {
+ err = rows.Err()
+ }
+
+ if err == nil && tcat == t.TopicCatP2P && len(subs) > 0 {
+ // Swap public & lastSeen values of P2P topics as expected.
+ if len(subs) == 1 {
+ // The other user is deleted, nothing we can do.
+ subs[0].SetPublic(nil)
+ subs[0].SetTrusted(nil)
+ subs[0].SetLastSeenAndUA(nil, "")
+ } else {
+ tmp := subs[0].GetPublic()
+ subs[0].SetPublic(subs[1].GetPublic())
+ subs[1].SetPublic(tmp)
+
+ tmp = subs[0].GetTrusted()
+ subs[0].SetTrusted(subs[1].GetTrusted())
+ subs[1].SetTrusted(tmp)
+
+ lastSeen := subs[0].GetLastSeen()
+ userAgent = subs[0].GetUserAgent()
+ subs[0].SetLastSeenAndUA(subs[1].GetLastSeen(), subs[1].GetUserAgent())
+ subs[1].SetLastSeenAndUA(lastSeen, userAgent)
+ }
+
+ // Remove deleted and unneeded subscriptions
+ if !keepDeleted || !oneUser.IsZero() {
+ var xsubs []t.Subscription
+ for i := range subs {
+ if (subs[i].DeletedAt != nil && !keepDeleted) || (!oneUser.IsZero() && subs[i].Uid() != oneUser) {
+ continue
+ }
+ xsubs = append(xsubs, subs[i])
+ }
+ subs = xsubs
+ }
+ }
+
+ return subs, err
+}
+
+// topicNamesForUser reads a slice of strings using provided query.
+func (a *adapter) topicNamesForUser(sqlQuery string, includeChan bool, args ...any) ([]string, error) {
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ rows, err := a.db.Query(ctx, sqlQuery, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var names []string
+ for rows.Next() {
+ var name string
+ if err = rows.Scan(&name); err != nil {
+ break
+ }
+ names = append(names, name)
+ // If the name is a group topic, also add the channel name if requested.
+ if includeChan {
+ if channel := t.GrpToChn(name); channel != "" {
+ names = append(names, channel)
+ }
+ }
+ }
+ if err == nil {
+ err = rows.Err()
+ }
+
+ return names, err
+}
+
+// OwnTopics loads a slice of topic names where the user is the owner.
+func (a *adapter) OwnTopics(uid t.Uid) ([]string, error) {
+ return a.topicNamesForUser("SELECT name FROM topics WHERE owner=$1 AND state!=$2",
+ false, store.DecodeUid(uid), t.StateDeleted)
+}
+
+// ChannelsForUser loads a slice of topic names where the user is a channel reader and notifications (P) are enabled.
+func (a *adapter) ChannelsForUser(uid t.Uid) ([]string, error) {
+ return a.topicNamesForUser("SELECT topic FROM subscriptions WHERE userid=$1 AND topic LIKE 'chn%' "+
+ "AND POSITION('P' IN modewant)>0 AND POSITION('P' IN modegiven)>0 AND deletedat IS NULL",
+ false, store.DecodeUid(uid))
+}
+
+// TopicShare creates topic subscriptions and increments the topic's subcnt.
+func (a *adapter) TopicShare(topic string, shares []*t.Subscription) error {
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTx(ctx, pgx.TxOptions{})
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err != nil {
+ tx.Rollback(ctx)
+ }
+ }()
+
+ for _, sub := range shares {
+ err = createSubscription(ctx, tx, sub, true)
+ if err != nil {
+ return err
+ }
+ }
+
+ if topic != "" {
+ if _, err = tx.Exec(ctx, "UPDATE topics SET subcnt=subcnt+$1 WHERE name=$2", len(shares), topic); err != nil {
+ return err
+ }
+ }
+
+ return tx.Commit(ctx)
+}
+
+// TopicDelete deletes topic, subscriptions, messages.
+func (a *adapter) TopicDelete(topic string, isChan, hard bool) error {
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTx(ctx, pgx.TxOptions{})
+ if err != nil {
+ return err
+ }
+
+ defer func() {
+ if err != nil {
+ tx.Rollback(ctx)
+ }
+ }()
+
+ // If the topic is a channel, must try to delete subscriptions under both grpXXX and chnXXX names.
+ args := []any{topic}
+ if isChan {
+ args = append(args, t.GrpToChn(topic))
+ }
+
+ if hard {
+ // Delete subscriptions. If this is a channel, delete both group subscriptions and channel subscriptions.
+ q, args := expandQuery("DELETE FROM subscriptions WHERE topic IN (?)", args)
+ if _, err = tx.Exec(ctx, q, args...); err != nil {
+ return err
+ }
+
+ if err = messageDeleteList(ctx, tx, topic, nil); err != nil {
+ return err
+ }
+
+ if _, err = tx.Exec(ctx, "DELETE FROM topictags WHERE topic=$1", topic); err != nil {
+ return err
+ }
+
+ if _, err = tx.Exec(ctx, "DELETE FROM topics WHERE name=$1", topic); err != nil {
+ return err
+ }
+ } else {
+ now := t.TimeNow()
+
+ q, args := expandQuery("UPDATE subscriptions SET updatedat=?,deletedat=? WHERE topic IN (?)", now, now, args)
+ if _, err = tx.Exec(ctx, q, args...); err != nil {
+ return err
+ }
+
+ if _, err = tx.Exec(ctx, "UPDATE topics SET updatedat=$1,touchedat=$1,state=$2,stateat=$1 WHERE name=$3",
+ now, t.StateDeleted, topic); err != nil {
+ return err
+ }
+ }
+ return tx.Commit(ctx)
+}
+
+func (a *adapter) TopicUpdateOnMessage(topic string, msg *t.Message) error {
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ _, err := a.db.Exec(ctx, "UPDATE topics SET seqid=$1,touchedat=$2 WHERE name=$3", msg.SeqId, msg.CreatedAt, topic)
+
+ return err
+}
+
+// TopicUpdateSubCnt updates subscriber count denormalized in topic.
+func (a *adapter) TopicUpdateSubCnt(topic string) error {
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ _, err := a.db.Exec(ctx,
+ "UPDATE topics SET subcnt=(SELECT COUNT(*) FROM subscriptions WHERE topic IN ($1,$2) AND deletedat IS NULL) WHERE name=$1",
+ topic, t.GrpToChn(topic))
+ return err
+}
+
+func (a *adapter) TopicUpdate(topic string, update map[string]any) error {
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTx(ctx, pgx.TxOptions{})
+ if err != nil {
+ return err
+ }
+
+ defer func() {
+ if err != nil {
+ tx.Rollback(ctx)
+ }
+ }()
+
+ if t, u := update["TouchedAt"], update["UpdatedAt"]; t == nil && u != nil {
+ update["TouchedAt"] = u
+ }
+ cols, args := common.UpdateByMap(update)
+ q, args := expandQuery("UPDATE topics SET "+strings.Join(cols, ",")+" WHERE name=?", args, topic)
+ _, err = tx.Exec(ctx, q, args...)
+ if err != nil {
+ return err
+ }
+
+ // Tags are also stored in a separate table
+ if tags := common.ExtractTags(update); tags != nil {
+ // First delete all user tags
+ _, err = tx.Exec(ctx, "DELETE FROM topictags WHERE topic=$1", topic)
+ if err != nil {
+ return err
+ }
+ // Now insert new tags
+ err = addTags(ctx, tx, "topictags", "topic", topic, tags, false)
+ if err != nil {
+ return err
+ }
+ }
+
+ return tx.Commit(ctx)
+}
+
+func (a *adapter) TopicOwnerChange(topic string, newOwner t.Uid) error {
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ _, err := a.db.Exec(ctx, "UPDATE topics SET owner=$1 WHERE name=$2", store.DecodeUid(newOwner), topic)
+ return err
+}
+
+// Get a subscription of a user to a topic.
+func (a *adapter) SubscriptionGet(topic string, user t.Uid, keepDeleted bool) (*t.Subscription, error) {
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ query := `SELECT createdat,updatedat,deletedat,userid AS user,topic,delid,recvseqid,
+ readseqid,modewant,modegiven,private FROM subscriptions WHERE topic=$1 AND userid=$2`
+ if !keepDeleted {
+ query += " AND deletedat IS NULL"
+ }
+ var sub t.Subscription
+ var userId int64
+ var modeWant, modeGiven []byte
+ err := a.db.QueryRow(ctx, query, topic, store.DecodeUid(user)).Scan(&sub.CreatedAt, &sub.UpdatedAt, &sub.DeletedAt, &userId,
+ &sub.Topic, &sub.DelId, &sub.RecvSeqId, &sub.ReadSeqId, &modeWant, &modeGiven, &sub.Private)
+
+ if err != nil {
+ if err == pgx.ErrNoRows {
+ // Nothing found - clear the error
+ err = nil
+ }
+ return nil, err
+ }
+
+ sub.User = store.EncodeUid(userId).String()
+ sub.ModeWant.Scan(modeWant)
+ sub.ModeGiven.Scan(modeGiven)
+
+ return &sub, nil
+}
+
+// SubsForUser loads all user's subscriptions. Does NOT load Public or Private values and does
+// not load deleted subscriptions.
+func (a *adapter) SubsForUser(forUser t.Uid) ([]t.Subscription, error) {
+ q := `SELECT createdat,updatedat,deletedat,userid AS user,topic,delid,recvseqid,
+ readseqid,modewant,modegiven FROM subscriptions WHERE userid=$1 AND deletedat IS NULL`
+ args := []any{store.DecodeUid(forUser)}
+
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ rows, err := a.db.Query(ctx, q, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var subs []t.Subscription
+ var sub t.Subscription
+ var userId int64
+ var modeWant, modeGiven []byte
+ for rows.Next() {
+ if err = rows.Scan(&sub.CreatedAt, &sub.UpdatedAt, &sub.DeletedAt, &userId, &sub.Topic, &sub.DelId,
+ &sub.RecvSeqId, &sub.ReadSeqId, &modeWant, &modeGiven); err != nil {
+ break
+ }
+
+ sub.User = store.EncodeUid(userId).String()
+ sub.ModeWant.Scan(modeWant)
+ sub.ModeGiven.Scan(modeGiven)
+ subs = append(subs, sub)
+ }
+ if err == nil {
+ err = rows.Err()
+ }
+
+ return subs, err
+}
+
+// SubsForTopic fetches all subsciptions for a topic. Does NOT load Public value.
+// The difference between UsersForTopic vs SubsForTopic is that the former loads user.public+trusted,
+// the latter does not.
+func (a *adapter) SubsForTopic(topic string, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) {
+ q := `SELECT createdat,updatedat,deletedat,userid AS user,topic,delid,recvseqid,
+ readseqid,modewant,modegiven,private FROM subscriptions WHERE topic=?`
+
+ args := []any{topic}
+ if !keepDeleted {
+ // Filter out deleted rows.
+ q += " AND deletedat IS NULL"
+ }
+ limit := a.maxResults
+ if opts != nil {
+ // Ignore IfModifiedSince - we must return all entries
+ // Those unmodified will be stripped of Public & Private.
+
+ if !opts.User.IsZero() {
+ q += " AND userid=?"
+ args = append(args, store.DecodeUid(opts.User))
+ }
+ if opts.Limit > 0 && opts.Limit < limit {
+ limit = opts.Limit
+ }
+ }
+
+ q += " LIMIT ?"
+ args = append(args, limit)
+ q, args = expandQuery(q, args...)
+
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ rows, err := a.db.Query(ctx, q, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var subs []t.Subscription
+ var sub t.Subscription
+ var userId int64
+ var modeWant, modeGiven []byte
+ for rows.Next() {
+ if err = rows.Scan(&sub.CreatedAt, &sub.UpdatedAt, &sub.DeletedAt, &userId, &sub.Topic, &sub.DelId,
+ &sub.RecvSeqId, &sub.ReadSeqId, &modeWant, &modeGiven, &sub.Private); err != nil {
+ break
+ }
+
+ sub.User = store.EncodeUid(userId).String()
+ sub.ModeWant.Scan(modeWant)
+ sub.ModeGiven.Scan(modeGiven)
+ subs = append(subs, sub)
+ }
+ if err == nil {
+ err = rows.Err()
+ }
+
+ return subs, err
+}
+
+// SubsUpdate updates one or multiple subscriptions to a topic.
+func (a *adapter) SubsUpdate(topic string, user t.Uid, update map[string]any) error {
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTx(ctx, pgx.TxOptions{})
+ if err != nil {
+ return err
+ }
+
+ defer func() {
+ if err != nil {
+ tx.Rollback(ctx)
+ }
+ }()
+
+ cols, args := common.UpdateByMap(update)
+ q := "UPDATE subscriptions SET " + strings.Join(cols, ",") + " WHERE topic=?"
+ args = append(args, topic)
+ if !user.IsZero() {
+ // Update just one topic subscription
+ q += " AND userid=?"
+ args = append(args, store.DecodeUid(user))
+ }
+ q, args = expandQuery(q, args...)
+
+ if _, err = tx.Exec(ctx, q, args...); err != nil {
+ return err
+ }
+
+ return tx.Commit(ctx)
+}
+
+// SubsDelete marks at most one subscription as deleted.
+func (a *adapter) SubsDelete(topic string, user t.Uid) error {
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+
+ tx, err := a.db.Begin(ctx)
+ if err != nil {
+ return err
+ }
+
+ defer func() {
+ if err != nil {
+ tx.Rollback(ctx)
+ }
+ }()
+
+ decoded_id := store.DecodeUid(user)
+ now := t.TimeNow()
+ res, err := tx.Exec(ctx,
+ "UPDATE subscriptions SET updatedat=$1,deletedat=$2 WHERE topic=$3 AND userid=$4 AND deletedat IS NULL",
+ now, now, topic, decoded_id)
+ if err != nil {
+ return err
+ }
+
+ affected := res.RowsAffected()
+ if affected == 0 {
+ // ensure tx.Rollback() above is ran
+ err = t.ErrNotFound
+ return err
+ }
+
+ // Channel readers cannot delete messages.
+ if !t.IsChannel(topic) {
+ // Remove records of messages soft-deleted by this user.
+ _, err = tx.Exec(ctx, "DELETE FROM dellog WHERE topic=$1 AND deletedfor=$2", topic, decoded_id)
+ if err != nil {
+ return err
+ }
+ }
+
+ if t.GetTopicCat(topic) == t.TopicCatGrp {
+ // Decrement topic subscription count (only one subscription is deleted).
+ _, err = tx.Exec(ctx, "UPDATE topics SET subcnt=subcnt-1 WHERE name=$1", topic)
+ if err != nil {
+ return err
+ }
+ }
+
+ return tx.Commit(ctx)
+}
+
+// subsDelForUser marks user's subscriptions as deleted.
+func subsDelForUser(ctx context.Context, tx pgx.Tx, decoded_uid int64, hard bool) error {
+ // Decrement subscription count for all topics the user is subscribed to.
+ rows, err := tx.Query(ctx, "SELECT topic FROM subscriptions WHERE userid=$1 AND deletedat IS NULL", decoded_uid)
+ if err != nil {
+ return err
+ }
+ var topics []any
+ for rows.Next() {
+ var name string
+ if err = rows.Scan(&name); err != nil {
+ break
+ }
+ if t.IsChannel(name) {
+ // Convert channel name to group name.
+ name = t.ChnToGrp(name)
+ }
+ topics = append(topics, name)
+ }
+ if err == nil {
+ err = rows.Err()
+ }
+ rows.Close()
+ if err != nil {
+ return err
+ }
+ if len(topics) > 0 {
+ sql, args, _ := sqlx.In("UPDATE topics SET subcnt=subcnt-1 WHERE name IN (?)", topics)
+ _, err = tx.Exec(ctx, sqlx.Rebind(sqlx.DOLLAR, sql), args...)
+ if err != nil {
+ return err
+ }
+ }
+
+ if hard {
+ // Hard delete: remove all subscriptions for the user.
+ _, err = tx.Exec(ctx, "DELETE FROM subscriptions WHERE userid=$1", decoded_uid)
+ } else {
+ now := t.TimeNow()
+ _, err = tx.Exec(ctx, "UPDATE subscriptions SET updatedat=$1,deletedat=$2 WHERE userid=$3 AND deletedat IS NULL;",
+ now, now, decoded_uid)
+ }
+ return err
+}
+
+// SubsDelForUser marks user's subscriptions as deleted.
+func (a *adapter) SubsDelForUser(user t.Uid, hard bool) error {
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+
+ tx, err := a.db.BeginTx(ctx, pgx.TxOptions{})
+ if err != nil {
+ return err
+ }
+
+ defer func() {
+ if err != nil {
+ tx.Rollback(ctx)
+ }
+ }()
+
+ if err = subsDelForUser(ctx, tx, store.DecodeUid(user), hard); err != nil {
+ return err
+ }
+
+ return tx.Commit(ctx)
+
+}
+
+// Find returns a list of users and group topics which match the given tags, such as "email:jdoe@example.com" or "tel:+18003287448".
+func (a *adapter) Find(caller, promoPrefix string, req [][]string, opt []string, activeOnly bool) ([]t.Subscription, error) {
+ index := make(map[string]struct{})
+ var args []any
+ constraint := ""
+ allReq := t.FlattenDoubleSlice(req)
+ for _, tag := range append(allReq, opt...) {
+ args = append(args, tag)
+ index[tag] = struct{}{}
+ }
+ if len(args) == 0 {
+ // Nothing to search for.
+ return nil, nil
+ }
+ constraint += "tg.tag IN (?) "
+ constraint, args, err := sqlx.In(constraint, args)
+ if err != nil {
+ return nil, err
+ }
+ if activeOnly {
+ args = append(args, t.StateOK)
+ constraint += "AND state=? "
+ }
+ constraint = sqlx.Rebind(sqlx.DOLLAR, constraint)
+
+ var matcher string
+ if promoPrefix != "" {
+ // The max number of tags is 16. Using 20 to make sure one prefix match is greater than all non-prefix matches.
+ matcher = "SUM(CASE WHEN POSITION('" + promoPrefix + "' IN tg.tag)=1 THEN 20 ELSE 1 END)"
+ } else {
+ matcher = "COUNT(*)"
+ }
+
+ query := "SELECT CAST(u.id AS VARCHAR) AS topic,u.createdat,u.updatedat,FALSE,u.access::jsonb,0 AS subcnt,u.public::jsonb,u.trusted::jsonb,u.tags::jsonb," +
+ matcher + " AS matches " +
+ "FROM users AS u JOIN usertags AS tg ON tg.userid=u.id " +
+ "WHERE " + constraint +
+ "GROUP BY u.id,u.createdat,u.updatedat,u.access::jsonb,u.public::jsonb,u.trusted::jsonb,u.tags::jsonb "
+
+ having := ""
+ if len(allReq) > 0 {
+ var a []any
+ having, a = common.DisjunctionSql(req, "tg.tag")
+ having = rebindWithStart(having, len(args)+1)
+ query += having
+ args = append(args, a...)
+ }
+
+ query += "UNION ALL "
+
+ query += "SELECT t.name AS topic,t.createdat,t.updatedat,t.usebt,t.access::jsonb,t.subcnt,t.public::jsonb,t.trusted::jsonb,t.tags::jsonb," +
+ matcher + " AS matches " +
+ "FROM topics AS t JOIN topictags AS tg ON t.name=tg.topic " +
+ "WHERE " + constraint +
+ "GROUP BY t.name,t.createdat,t.updatedat,t.usebt,t.access::jsonb,t.subcnt,t.public::jsonb,t.trusted::jsonb,t.tags::jsonb "
+ if having != "" {
+ query += having
+ }
+ args = append(args, a.maxResults)
+ query += "ORDER BY matches DESC, subcnt DESC LIMIT $" + strconv.Itoa(len(args))
+
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+
+ // Get users matched by tags, sort by number of matches from high to low.
+ rows, err := a.db.Query(ctx, query, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ // Fetch subscriptions
+ var public, trusted any
+ var access t.DefaultAccess
+ var subcnt int
+ var setTags t.StringSlice
+ var ignored int
+ var isChan bool
+ var sub t.Subscription
+ var subs []t.Subscription
+ for rows.Next() {
+ if err = rows.Scan(&sub.Topic, &sub.CreatedAt, &sub.UpdatedAt, &isChan, &access, &subcnt,
+ &public, &trusted, &setTags, &ignored); err != nil {
+ subs = nil
+ break
+ }
+
+ if id, err := strconv.ParseInt(sub.Topic, 10, 64); err == nil {
+ sub.Topic = store.EncodeUid(id).UserId()
+ if sub.Topic == caller {
+ // Skip the caller.
+ continue
+ }
+ }
+
+ if isChan {
+ // This is a channel, convert grp to chn name.
+ sub.Topic = t.GrpToChn(sub.Topic)
+ }
+
+ sub.SetSubCnt(subcnt)
+ sub.SetPublic(public)
+ sub.SetTrusted(trusted)
+ sub.SetDefaultAccess(access.Auth, access.Anon)
+ // Indicating that the mode is not set, not 'N'.
+ sub.ModeGiven = t.ModeUnset
+ sub.ModeWant = t.ModeUnset
+ sub.Private = common.FilterFoundTags(setTags, index)
+ subs = append(subs, sub)
+ }
+
+ if err == nil {
+ err = rows.Err()
+ }
+
+ return subs, err
+
+}
+
+// FindOne returns topic or user which matches the given tag.
+func (a *adapter) FindOne(tag string) (string, error) {
+ var args []any
+ query := "SELECT t.name AS topic FROM topics AS t LEFT JOIN topictags AS tt ON t.name=tt.topic " +
+ "WHERE tt.tag=?"
+ args = append(args, tag)
+
+ query += " UNION ALL "
+
+ query += "SELECT CAST(u.id AS VARCHAR) AS topic FROM users AS u LEFT JOIN usertags AS ut ON ut.userid=u.id " +
+ "WHERE ut.tag=?"
+ args = append(args, tag)
+
+ // LIMIT is applied to all resultant rows.
+ query += " LIMIT 1"
+
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+
+ query, args = expandQuery(query, args)
+ rows, err := a.db.Query(ctx, query, args...)
+ if err != nil {
+ return "", err
+ }
+ defer rows.Close()
+
+ var found string
+ if rows.Next() {
+ if err = rows.Scan(&found); err != nil {
+ return "", err
+ }
+
+ // Check if the found value is a topic name or a user ID.
+ // User IDs are returned as decoded decimal strings.
+ if id, err := strconv.ParseInt(found, 10, 64); err == nil {
+ found = store.EncodeUid(id).UserId()
+ }
+ }
+ if err == nil {
+ err = rows.Err()
+ }
+
+ return found, err
+}
+
+// Messages
+func (a *adapter) MessageSave(msg *t.Message) error {
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ // store assignes message ID, but we don't use it. Message IDs are not used anywhere.
+ // Using a sequential ID provided by the database.
+ var id int
+ err := a.db.QueryRow(ctx,
+ `INSERT INTO messages(createdAt,updatedAt,seqid,topic,"from",head,content) VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING id`,
+ msg.CreatedAt, msg.UpdatedAt, msg.SeqId, msg.Topic,
+ store.DecodeUid(t.ParseUid(msg.From)), msg.Head, common.ToJSON(msg.Content)).Scan(&id)
+ if err == nil {
+ // Replacing ID given by store by ID given by the DB.
+ msg.SetUid(t.Uid(id))
+ }
+ return err
+}
+
+func (a *adapter) MessageGetAll(topic string, forUser t.Uid, opts *t.QueryOpt) ([]t.Message, error) {
+ var limit = a.maxMessageResults
+
+ args := []any{store.DecodeUid(forUser), topic}
+ seqIdConstraint := ""
+ if opts != nil {
+ seqIdConstraint = "AND m.seqid "
+ if len(opts.IdRanges) > 0 {
+ constr, newargs := common.RangesToSql(opts.IdRanges)
+ seqIdConstraint += constr
+ args = append(args, newargs...)
+ } else {
+ seqIdConstraint += "BETWEEN ? AND ?"
+ if opts.Since > 0 {
+ args = append(args, opts.Since)
+ } else {
+ args = append(args, 0)
+ }
+ if opts.Before > 0 {
+ // BETWEEN is inclusive-inclusive, Tinode API requires inclusive-exclusive, thus -1
+ args = append(args, opts.Before-1)
+ } else {
+ args = append(args, 1<<31-1)
+ }
+ }
+
+ if opts.Limit > 0 && opts.Limit < limit {
+ limit = opts.Limit
+ }
+ }
+
+ args = append(args, limit)
+
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+
+ query, args := expandQuery(`SELECT m.createdat,m.updatedat,m.deletedat,m.delid,m.seqid,m.topic,m."from",m.head,m.content`+
+ " FROM messages AS m LEFT JOIN dellog AS d"+
+ " ON d.topic=m.topic AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=?"+
+ " WHERE m.delid=0 AND m.topic=? "+seqIdConstraint+" AND d.deletedfor IS NULL"+
+ " ORDER BY m.seqid DESC LIMIT ?", args...)
+ rows, err := a.db.Query(ctx, query, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ msgs := make([]t.Message, 0, limit)
+ for rows.Next() {
+ var msg t.Message
+ var from int64
+ if err = rows.Scan(&msg.CreatedAt, &msg.UpdatedAt, &msg.DeletedAt, &msg.DelId, &msg.SeqId,
+ &msg.Topic, &from, &msg.Head, &msg.Content); err != nil {
+ break
+ }
+ msg.From = store.EncodeUid(from).String()
+ msgs = append(msgs, msg)
+ }
+ if err == nil {
+ err = rows.Err()
+ }
+
+ return msgs, err
+}
+
+// Get ranges of deleted messages
+func (a *adapter) MessageGetDeleted(topic string, forUser t.Uid, opts *t.QueryOpt) ([]t.DelMessage, error) {
+ var limit = a.maxResults
+ var lower = 0
+ var upper = 1<<31 - 1
+
+ if opts != nil {
+ if opts.Since > 0 {
+ lower = opts.Since
+ }
+ if opts.Before > 1 {
+ // DelRange is inclusive-exclusive, while BETWEEN is inclusive-inclisive.
+ upper = opts.Before - 1
+ }
+
+ if opts.Limit > 0 && opts.Limit < limit {
+ limit = opts.Limit
+ }
+ }
+
+ // Fetch log of deletions
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ rows, err := a.db.Query(ctx, "SELECT topic,deletedfor,delid,low,hi FROM dellog WHERE topic=$1 AND delid BETWEEN $2 AND $3"+
+ " AND (deletedFor=0 OR deletedFor=$4) ORDER BY delid LIMIT $5",
+ topic, lower, upper, store.DecodeUid(forUser), limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var dellog struct {
+ Topic string
+ Deletedfor int64
+ Delid int
+ Low int
+ Hi int
+ }
+ var dmsgs []t.DelMessage
+ var dmsg t.DelMessage
+ for rows.Next() {
+ if err = rows.Scan(&dellog.Topic, &dellog.Deletedfor, &dellog.Delid, &dellog.Low, &dellog.Hi); err != nil {
+ dmsgs = nil
+ break
+ }
+
+ if dellog.Delid != dmsg.DelId {
+ if dmsg.DelId > 0 {
+ dmsgs = append(dmsgs, dmsg)
+ }
+ dmsg.DelId = dellog.Delid
+ dmsg.Topic = dellog.Topic
+ if dellog.Deletedfor > 0 {
+ dmsg.DeletedFor = store.EncodeUid(dellog.Deletedfor).String()
+ } else {
+ dmsg.DeletedFor = ""
+ }
+ dmsg.SeqIdRanges = nil
+ }
+ if dellog.Hi <= dellog.Low+1 {
+ dellog.Hi = 0
+ }
+ dmsg.SeqIdRanges = append(dmsg.SeqIdRanges, t.Range{Low: dellog.Low, Hi: dellog.Hi})
+ }
+ if err == nil {
+ err = rows.Err()
+ }
+
+ if err == nil {
+ if dmsg.DelId > 0 {
+ dmsgs = append(dmsgs, dmsg)
+ }
+ }
+
+ return dmsgs, err
+}
+
+func messageDeleteList(ctx context.Context, tx pgx.Tx, topic string, toDel *t.DelMessage) error {
+ var err error
+
+ if toDel == nil {
+ // Whole topic is being deleted, thus also deleting all messages.
+ _, err = tx.Exec(ctx, "DELETE FROM dellog WHERE topic=$1", topic)
+ if err == nil {
+ _, err = tx.Exec(ctx, "DELETE FROM messages WHERE topic=$1", topic)
+ }
+ // filemsglinks will be deleted because of ON DELETE CASCADE
+ return err
+ }
+
+ // Only some messages are being deleted
+
+ delRanges := toDel.SeqIdRanges
+
+ if toDel.DeletedFor == "" {
+ // Hard-deleting messages requires updates to the messages table.
+ where := "m.topic=? "
+ args := []any{topic}
+
+ if len(delRanges) > 0 {
+ rSql, rArgs := common.RangesToSql(delRanges)
+ where += " AND m.seqid " + rSql
+ args = append(args, rArgs...)
+ }
+
+ where += " AND m.deletedat IS NULL"
+
+ // We are asked to delete messages no older than newerThan.
+ if newerThan := toDel.GetNewerThan(); newerThan != nil {
+ where += " AND m.createdat>?"
+ args = append(args, newerThan)
+ }
+
+ // Find the actual IDs still present in the database.
+ var seqIDs []int
+ query, newargs := expandQuery("SELECT seqid FROM messages AS m WHERE "+where, args)
+ rows, err := tx.Query(ctx, query, newargs...)
+ if err != nil {
+ return err
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var seqID int
+ if err := rows.Scan(&seqID); err != nil {
+ return err
+ }
+ seqIDs = append(seqIDs, seqID)
+ }
+ if err = rows.Err(); err != nil {
+ return err
+ }
+
+ if len(seqIDs) == 0 {
+ // Nothing to delete. No need to make a log entry. All done.
+ return nil
+ }
+
+ // Recalculate the actual ranges to delete.
+ sort.Ints(seqIDs)
+ delRanges = t.SliceToRanges(seqIDs)
+
+ // Compose a new query with the new ranges.
+ where = "m.topic=?"
+ args = []any{topic}
+ rSql, rArgs := common.RangesToSql(delRanges)
+ where += " AND m.seqid " + rSql
+ args = append(args, rArgs...)
+
+ // No need to add anything else: deletedat etc is already accounted for.
+
+ query, newargs = expandQuery("DELETE FROM filemsglinks AS fml USING messages AS m WHERE m.id=fml.msgid AND "+
+ where, args...)
+ _, err = tx.Exec(ctx, query, newargs...)
+ if err != nil {
+ return err
+ }
+
+ query, newargs = expandQuery(`UPDATE messages AS m SET deletedat=?,delid=?,"from"=0,head=NULL,content=NULL WHERE `+
+ where, t.TimeNow(), toDel.DelId, args)
+ _, err = tx.Exec(ctx, query, newargs...)
+ if err != nil {
+ return err
+ }
+ }
+
+ // Now make log entries. Needed for both hard- and soft-deleting.
+
+ // Prepare statement is not needed because the driver prepares the statement on first use then caches it.
+ forUser := common.DecodeUidString(toDel.DeletedFor)
+ for _, rng := range toDel.SeqIdRanges {
+ if rng.Hi == 0 {
+ // Dellog must contain valid Low and *Hi*.
+ rng.Hi = rng.Low + 1
+ }
+
+ if _, err = tx.Exec(ctx, "INSERT INTO dellog(topic,deletedfor,delid,low,hi) VALUES($1,$2,$3,$4,$5)",
+ topic, forUser, toDel.DelId, rng.Low, rng.Hi); err != nil {
+ break
+ }
+ }
+
+ return err
+}
+
+// MessageDeleteList deletes messages in the given topic with seqIds from the list.
+func (a *adapter) MessageDeleteList(topic string, toDel *t.DelMessage) (err error) {
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTx(ctx, pgx.TxOptions{})
+ if err != nil {
+ return err
+ }
+
+ defer func() {
+ if err != nil {
+ tx.Rollback(ctx)
+ }
+ }()
+
+ if err = messageDeleteList(ctx, tx, topic, toDel); err != nil {
+ return err
+ }
+
+ return tx.Commit(ctx)
+}
+
+func deviceHasher(deviceID string) string {
+ // Generate custom key as [64-bit hash of device id] to ensure predictable
+ // length of the key
+ hasher := fnv.New64()
+ hasher.Write([]byte(deviceID))
+ return strconv.FormatUint(uint64(hasher.Sum64()), 16)
+}
+
+// Device management for push notifications
+func (a *adapter) DeviceUpsert(uid t.Uid, def *t.DeviceDef) error {
+ hash := deviceHasher(def.DeviceId)
+
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTx(ctx, pgx.TxOptions{})
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err != nil {
+ tx.Rollback(ctx)
+ }
+ }()
+
+ // Ensure uniqueness of the device ID: delete all records of the device ID
+ _, err = tx.Exec(ctx, "DELETE FROM devices WHERE hash=$1", hash)
+ if err != nil {
+ return err
+ }
+
+ // Actually add/update DeviceId for the new user
+ _, err = tx.Exec(ctx, "INSERT INTO devices(userid, hash, deviceId, platform, lastseen, lang) VALUES($1,$2,$3,$4,$5,$6)",
+ store.DecodeUid(uid), hash, def.DeviceId, def.Platform, def.LastSeen, def.Lang)
+ if err != nil {
+ return err
+ }
+
+ return tx.Commit(ctx)
+}
+
+func (a *adapter) DeviceGetAll(uids ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error) {
+ var unums []any
+ for _, uid := range uids {
+ unums = append(unums, store.DecodeUid(uid))
+ }
+
+ query, unums := expandQuery("SELECT userid,deviceid,platform,lastseen,lang FROM devices WHERE userid IN (?)", unums)
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ rows, err := a.db.Query(ctx, query, unums...)
+ if err != nil {
+ return nil, 0, err
+ }
+ defer rows.Close()
+
+ var device struct {
+ Userid int64
+ Deviceid string
+ Platform string
+ Lastseen time.Time
+ Lang string
+ }
+
+ result := make(map[t.Uid][]t.DeviceDef)
+ count := 0
+ for rows.Next() {
+ if err = rows.Scan(&device.Userid, &device.Deviceid, &device.Platform, &device.Lastseen, &device.Lang); err != nil {
+ break
+ }
+ uid := store.EncodeUid(device.Userid)
+ udev := result[uid]
+ udev = append(udev, t.DeviceDef{
+ DeviceId: device.Deviceid,
+ Platform: device.Platform,
+ LastSeen: device.Lastseen,
+ Lang: device.Lang,
+ })
+ result[uid] = udev
+ count++
+ }
+ if err == nil {
+ err = rows.Err()
+ }
+
+ return result, count, err
+}
+
+func deviceDelete(ctx context.Context, tx pgx.Tx, uid t.Uid, deviceID string) error {
+ var err error
+ var res pgconn.CommandTag
+ if deviceID == "" {
+ res, err = tx.Exec(ctx, "DELETE FROM devices WHERE userid=$1", store.DecodeUid(uid))
+ } else {
+ res, err = tx.Exec(ctx, "DELETE FROM devices WHERE userid=$1 AND hash=$2", store.DecodeUid(uid), deviceHasher(deviceID))
+ }
+
+ if err == nil {
+ if count := res.RowsAffected(); count == 0 {
+ err = t.ErrNotFound
+ }
+ }
+
+ return err
+}
+
+func (a *adapter) DeviceDelete(uid t.Uid, deviceID string) error {
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTx(ctx, pgx.TxOptions{})
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err != nil {
+ tx.Rollback(ctx)
+ }
+ }()
+
+ err = deviceDelete(ctx, tx, uid, deviceID)
+ if err != nil {
+ return err
+ }
+
+ return tx.Commit(ctx)
+}
+
+// Credential management
+
+// CredUpsert adds or updates a validation record. Returns true if inserted, false if updated.
+// 1. if credential is validated:
+// 1.1 Hard-delete unconfirmed equivalent record, if exists.
+// 1.2 Insert new. Report error if duplicate.
+// 2. if credential is not validated:
+// 2.1 Check if validated equivalent exist. If so, report an error.
+// 2.2 Soft-delete all unvalidated records of the same method.
+// 2.3 Undelete existing credential. Return if successful.
+// 2.4 Insert new credential record.
+func (a *adapter) CredUpsert(cred *t.Credential) (bool, error) {
+ var err error
+
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTx(ctx, pgx.TxOptions{})
+ if err != nil {
+ return false, err
+ }
+ defer func() {
+ if err != nil {
+ tx.Rollback(ctx)
+ }
+ }()
+
+ now := t.TimeNow()
+ userId := common.DecodeUidString(cred.User)
+
+ // Enforce uniqueness: if credential is confirmed, "method:value" must be unique.
+ // if credential is not yet confirmed, "userid:method:value" is unique.
+ synth := cred.Method + ":" + cred.Value
+
+ if !cred.Done {
+ // Check if this credential is already validated.
+ var done bool
+ err = tx.QueryRow(ctx, "SELECT done FROM credentials WHERE synthetic=$1", synth).Scan(&done)
+ if err == nil {
+ // Assign err to ensure closing of a transaction.
+ err = t.ErrDuplicate
+ return false, err
+ }
+ if err != pgx.ErrNoRows {
+ return false, err
+ }
+ // We are going to insert new record.
+ synth = cred.User + ":" + synth
+
+ // Adding new unvalidated credential. Deactivate all unvalidated records of this user and method.
+ _, err = tx.Exec(ctx, "UPDATE credentials SET deletedat=$1 WHERE userid=$2 AND method=$3 AND done=FALSE",
+ now, userId, cred.Method)
+ if err != nil {
+ return false, err
+ }
+ // Assume that the record exists and try to update it: undelete, update timestamp and response value.
+ res, err := tx.Exec(ctx, "UPDATE credentials SET updatedat=$1,deletedat=NULL,resp=$2,done=FALSE WHERE synthetic=$3",
+ cred.UpdatedAt, cred.Resp, synth)
+ if err != nil {
+ return false, err
+ }
+ // If record was updated, then all is fine.
+ if numrows := res.RowsAffected(); numrows > 0 {
+ return false, tx.Commit(ctx)
+ }
+ } else {
+ // Hard-deleting unconformed record if it exists.
+ _, err = tx.Exec(ctx, "DELETE FROM credentials WHERE synthetic=$1", cred.User+":"+synth)
+ if err != nil {
+ return false, err
+ }
+ }
+
+ _, err = tx.Exec(ctx, "INSERT INTO credentials(createdat,updatedat,method,value,synthetic,userid,resp,done) "+
+ "VALUES($1,$2,$3,$4,$5,$6,$7,$8)",
+ cred.CreatedAt, cred.UpdatedAt, cred.Method, cred.Value, synth, userId, cred.Resp, cred.Done)
+ if err != nil {
+ if isDupe(err) {
+ return true, t.ErrDuplicate
+ }
+ return true, err
+ }
+ return true, tx.Commit(ctx)
+}
+
+// credDel deletes given validation method or all methods of the given user.
+// 1. If user is being deleted, hard-delete all records (method == "")
+// 2. If one value is being deleted:
+// 2.1 Delete it if it's valiated or if there were no attempts at validation
+// (otherwise it could be used to circumvent the limit on validation attempts).
+// 2.2 In that case mark it as soft-deleted.
+func credDel(ctx context.Context, tx pgx.Tx, uid t.Uid, method, value string) error {
+ constraints := " WHERE userid=?"
+ args := []any{store.DecodeUid(uid)}
+
+ if method != "" {
+ constraints += " AND method=?"
+ args = append(args, method)
+
+ if value != "" {
+ constraints += " AND value=?"
+ args = append(args, value)
+ }
+ }
+ where, _ := expandQuery(constraints, args...)
+
+ var err error
+ var res pgconn.CommandTag
+ if method == "" {
+ // Case 1
+ res, err = tx.Exec(ctx, "DELETE FROM credentials"+where, args...)
+ if err == nil {
+ if count := res.RowsAffected(); count == 0 {
+ err = t.ErrNotFound
+ }
+ }
+ return err
+ }
+
+ // Case 2.1
+ res, err = tx.Exec(ctx, "DELETE FROM credentials"+where+" AND (done=TRUE OR retries=0)", args...)
+ if err != nil {
+ return err
+ }
+ if count := res.RowsAffected(); count > 0 {
+ return nil
+ }
+
+ // Case 2.2
+ query, args := expandQuery("UPDATE credentials SET deletedat=?"+constraints, t.TimeNow(), args)
+ res, err = tx.Exec(ctx, query, args...)
+ if err == nil {
+ if count := res.RowsAffected(); count >= 0 {
+ err = t.ErrNotFound
+ }
+ }
+
+ return err
+}
+
+// CredDel deletes either credentials of the given user. If method is blank all
+// credentials are removed. If value is blank all credentials of the given the
+// method are removed.
+func (a *adapter) CredDel(uid t.Uid, method, value string) error {
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTx(ctx, pgx.TxOptions{})
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err != nil {
+ tx.Rollback(ctx)
+ }
+ }()
+
+ err = credDel(ctx, tx, uid, method, value)
+ if err != nil {
+ return err
+ }
+
+ return tx.Commit(ctx)
+}
+
+// CredConfirm marks given credential method as confirmed.
+func (a *adapter) CredConfirm(uid t.Uid, method string) error {
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ res, err := a.db.Exec(
+ ctx,
+ "UPDATE credentials SET updatedat=$1,done=TRUE,synthetic=CONCAT(method,':',value) "+
+ "WHERE userid=$2 AND method=$3 AND deletedat IS NULL AND done=FALSE",
+ t.TimeNow(), store.DecodeUid(uid), method)
+ if err != nil {
+ if isDupe(err) {
+ return t.ErrDuplicate
+ }
+ return err
+ }
+ if numrows := res.RowsAffected(); numrows < 1 {
+ return t.ErrNotFound
+ }
+ return nil
+}
+
+// CredFail increments failure count of the given validation method.
+func (a *adapter) CredFail(uid t.Uid, method string) error {
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ _, err := a.db.Exec(ctx, "UPDATE credentials SET updatedat=$1,retries=retries+1 WHERE userid=$2 AND method=$3 AND done=FALSE",
+ t.TimeNow(), store.DecodeUid(uid), method)
+ return err
+}
+
+// CredGetActive returns currently active unvalidated credential of the given user and method.
+func (a *adapter) CredGetActive(uid t.Uid, method string) (*t.Credential, error) {
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ var cred t.Credential
+
+ err := a.db.QueryRow(ctx, "SELECT createdat,updatedat,method,value,resp,done,retries "+
+ "FROM credentials WHERE userid=$1 AND deletedat IS NULL AND method=$2 AND done=FALSE",
+ store.DecodeUid(uid), method).Scan(&cred.CreatedAt, &cred.UpdatedAt, &cred.Method, &cred.Value, &cred.Resp, &cred.Done, &cred.Retries)
+ if err != nil {
+ if err == pgx.ErrNoRows {
+ err = nil
+ }
+ return nil, err
+ }
+ cred.User = uid.String()
+
+ return &cred, nil
+}
+
+// CredGetAll returns credential records for the given user and method, all or validated only.
+func (a *adapter) CredGetAll(uid t.Uid, method string, validatedOnly bool) ([]t.Credential, error) {
+ query := "SELECT createdat,updatedat,method,value,resp,done,retries FROM credentials WHERE userid=$1 AND deletedat IS NULL"
+ args := []any{store.DecodeUid(uid)}
+ if method != "" {
+ query += " AND method=$2"
+ args = append(args, method)
+ }
+ if validatedOnly {
+ query += " AND done=TRUE"
+ }
+
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ var credentials []t.Credential
+ rows, err := a.db.Query(ctx, query, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var cred t.Credential
+ if err = rows.Scan(&cred.CreatedAt, &cred.UpdatedAt, &cred.Method, &cred.Value, &cred.Resp, &cred.Done, &cred.Retries); err != nil {
+ credentials = nil
+ break
+ }
+
+ credentials = append(credentials, cred)
+ }
+
+ user := uid.String()
+ for i := range credentials {
+ credentials[i].User = user
+ }
+
+ return credentials, err
+}
+
+// FileUploads
+
+// FileStartUpload initializes a file upload
+func (a *adapter) FileStartUpload(fd *t.FileDef) error {
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ var user any
+ if fd.User != "" {
+ user = store.DecodeUid(t.ParseUid(fd.User))
+ }
+ _, err := a.db.Exec(ctx,
+ "INSERT INTO fileuploads(id,createdat,updatedat,userid,status,mimetype,size,etag,location) "+
+ "VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)",
+ store.DecodeUid(fd.Uid()), fd.CreatedAt, fd.UpdatedAt, user,
+ fd.Status, fd.MimeType, fd.Size, fd.ETag, fd.Location)
+ return err
+}
+
+// FileFinishUpload marks file upload as completed, successfully or otherwise
+func (a *adapter) FileFinishUpload(fd *t.FileDef, success bool, size int64) (*t.FileDef, error) {
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTx(ctx, pgx.TxOptions{})
+ if err != nil {
+ return nil, err
+ }
+ defer func() {
+ if err != nil {
+ tx.Rollback(ctx)
+ }
+ }()
+
+ now := t.TimeNow()
+ if success {
+ _, err = tx.Exec(ctx, "UPDATE fileuploads SET updatedat=$1,status=$2,size=$3,etag=$4,location=$5 WHERE id=$6",
+ now, t.UploadCompleted, size, fd.ETag, fd.Location, store.DecodeUid(fd.Uid()))
+ if err != nil {
+ return nil, err
+ }
+
+ fd.Status = t.UploadCompleted
+ fd.Size = size
+ } else {
+ // Deleting the record: there is no value in keeping it in the DB.
+ _, err = tx.Exec(ctx, "DELETE FROM fileuploads WHERE id=$1", store.DecodeUid(fd.Uid()))
+ if err != nil {
+ return nil, err
+ }
+
+ fd.Status = t.UploadFailed
+ fd.Size = 0
+ }
+ fd.UpdatedAt = now
+
+ return fd, tx.Commit(ctx)
+}
+
+// FileGet fetches a record of a specific file
+func (a *adapter) FileGet(fid string) (*t.FileDef, error) {
+ id := t.ParseUid(fid)
+ if id.IsZero() {
+ return nil, t.ErrMalformed
+ }
+
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ var fd t.FileDef
+ var ID int64
+ var userId int64
+ err := a.db.QueryRow(ctx, "SELECT id,createdat,updatedat,userid AS user,status,mimetype,size,etag,location "+
+ "FROM fileuploads WHERE id=$1", store.DecodeUid(id)).Scan(&ID, &fd.CreatedAt, &fd.UpdatedAt, &userId, &fd.Status,
+ &fd.MimeType, &fd.Size, &fd.ETag, &fd.Location)
+ if err == pgx.ErrNoRows {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ fd.Id = common.EncodeUidString(fd.Id).String()
+ fd.User = store.EncodeUid(userId).String()
+
+ return &fd, nil
+}
+
+// FileDeleteUnused deletes file upload records.
+func (a *adapter) FileDeleteUnused(olderThan time.Time, limit int) ([]string, error) {
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTx(ctx, pgx.TxOptions{})
+ if err != nil {
+ return nil, err
+ }
+ defer func() {
+ if err != nil {
+ tx.Rollback(ctx)
+ }
+ }()
+
+ // Garbage collecting entries which as either marked as deleted, or lack message references, or have no user assigned.
+ query := "SELECT fu.id,fu.location FROM fileuploads AS fu LEFT JOIN filemsglinks AS fml ON fml.fileid=fu.id " +
+ "WHERE fml.id IS NULL"
+ var args []any
+ if !olderThan.IsZero() {
+ query += " AND fu.updatedat"
+ args = append(args, olderThan)
+ }
+ if limit > 0 {
+ query += " LIMIT ?"
+ args = append(args, limit)
+ }
+ query, _ = expandQuery(query, args...)
+
+ rows, err := tx.Query(ctx, query, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var locations []string
+ var ids []any
+ for rows.Next() {
+ var id int
+ var loc string
+ if err = rows.Scan(&id, &loc); err != nil {
+ break
+ }
+ if loc != "" {
+ locations = append(locations, loc)
+ }
+ ids = append(ids, id)
+ }
+ if err == nil {
+ err = rows.Err()
+ }
+
+ if err != nil {
+ return nil, err
+ }
+
+ if len(ids) > 0 {
+ query, ids = expandQuery("DELETE FROM fileuploads WHERE id IN (?)", ids)
+ _, err = tx.Exec(ctx, query, ids...)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ return locations, tx.Commit(ctx)
+}
+
+// FileLinkAttachments connects given topic or message to the file record IDs from the list.
+func (a *adapter) FileLinkAttachments(topic string, userId, msgId t.Uid, fids []string) error {
+ if len(fids) == 0 || (topic == "" && msgId.IsZero() && userId.IsZero()) {
+ return t.ErrMalformed
+ }
+ now := t.TimeNow()
+
+ var args []any
+ var linkId any
+ var linkBy string
+ if !msgId.IsZero() {
+ linkBy = "msgid"
+ linkId = int64(msgId)
+ } else if topic != "" {
+ linkBy = "topic"
+ linkId = topic
+ // Only one attachment per topic is permitted at this time.
+ fids = fids[0:1]
+ } else {
+ linkBy = "userid"
+ linkId = store.DecodeUid(userId)
+ // Only one attachment per user is permitted at this time.
+ fids = fids[0:1]
+ }
+
+ // Decoded ids
+ var dids []any
+ for _, fid := range fids {
+ id := t.ParseUid(fid)
+ if id.IsZero() {
+ return t.ErrMalformed
+ }
+ dids = append(dids, store.DecodeUid(id))
+ }
+
+ for _, id := range dids {
+ // createdat,fileid,[msgid|topic|userid]
+ args = append(args, now, id, linkId)
+ }
+
+ ctx, cancel := a.getContextForTx()
+ if cancel != nil {
+ defer cancel()
+ }
+ tx, err := a.db.BeginTx(ctx, pgx.TxOptions{})
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err != nil {
+ tx.Rollback(ctx)
+ }
+ }()
+
+ // Unlink earlier uploads on the same topic or user allowing them to be garbage-collected.
+ if msgId.IsZero() {
+ sql := "DELETE FROM filemsglinks WHERE " + linkBy + "=$1"
+ _, err = tx.Exec(ctx, sql, linkId)
+ if err != nil {
+ return err
+ }
+ }
+
+ query, args := expandQuery("INSERT INTO filemsglinks(createdat,fileid,"+linkBy+") VALUES (?,?,?)"+
+ strings.Repeat(",(?,?,?)", len(dids)-1), args...)
+ _, err = tx.Exec(ctx, query, args...)
+ if err != nil {
+ return err
+ }
+
+ return tx.Commit(ctx)
+}
+
+// PCacheGet reads a persistet cache entry.
+func (a *adapter) PCacheGet(key string) (string, error) {
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+
+ var value string
+ if err := a.db.QueryRow(ctx, `SELECT "value" FROM kvmeta WHERE "key"=$1 LIMIT 1`, key).Scan(&value); err != nil {
+ if err == pgx.ErrNoRows {
+ return "", t.ErrNotFound
+ }
+ return "", err
+ }
+ return value, nil
+}
+
+// PCacheUpsert creates or updates a persistent cache entry.
+func (a *adapter) PCacheUpsert(key string, value string, failOnDuplicate bool) error {
+ if strings.Contains(key, "%") {
+ // Do not allow % in keys: it interferes with LIKE query.
+ return t.ErrMalformed
+ }
+
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+
+ var action string
+ if !failOnDuplicate {
+ action = ` ON CONFLICT ("key") DO UPDATE SET createdat=$2,"value"=$3`
+ }
+
+ _, err := a.db.Exec(ctx, `INSERT INTO kvmeta("key",createdat,"value") VALUES($1,$2,$3)`+action,
+ key, t.TimeNow(), value)
+ if isDupe(err) {
+ return t.ErrDuplicate
+ }
+ return err
+}
+
+// PCacheDelete deletes one persistent cache entry.
+func (a *adapter) PCacheDelete(key string) error {
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+
+ _, err := a.db.Exec(ctx, `DELETE FROM kvmeta WHERE "key"=$1`, key)
+ return err
+}
+
+// PCacheExpire expires old entries with the given key prefix.
+func (a *adapter) PCacheExpire(keyPrefix string, olderThan time.Time) error {
+ if keyPrefix == "" {
+ return t.ErrMalformed
+ }
+
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+
+ _, err := a.db.Exec(ctx, `DELETE FROM kvmeta WHERE "key" LIKE $1 AND createdat<$2`, keyPrefix+"%", olderThan)
+ return err
+}
+
+// GetTestDB returns a currently open database connection.
+func (a *adapter) GetTestDB() any {
+ return a.db
+}
+
+// Helper functions
+
+// Check if MySQL error is a Error Code: 1062. Duplicate entry ... for key ...
+func isDupe(err error) bool {
+ if err == nil {
+ return false
+ }
+
+ msg := err.Error()
+ return strings.Contains(msg, "SQLSTATE 23505")
+}
+
+func isMissingTable(err error) bool {
+ if err == nil {
+ return false
+ }
+
+ msg := err.Error()
+ return strings.Contains(msg, "SQLSTATE 42P01")
+}
+
+func isMissingDb(err error) bool {
+ if err == nil {
+ return false
+ }
+
+ msg := err.Error()
+ return strings.Contains(msg, "SQLSTATE 3D000")
+}
+
+// setConnStr converts a config structure to a DSN connection string.
+func setConnStr(c configType) (string, error) {
+ // Default to disable SSL mode.
+ sslMode := "disable"
+ if c.SSLMode != "" {
+ sslMode = c.SSLMode
+ }
+
+ if c.User == "" || c.Passwd == "" || c.Host == "" || c.Port == "" || c.DBName == "" {
+ return "", errors.New("adapter postgres invalid config value")
+ }
+ connStr := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=%s&connect_timeout=%d",
+ c.User,
+ c.Passwd,
+ c.Host,
+ c.Port,
+ c.DBName,
+ sslMode,
+ c.SqlTimeout)
+
+ return connStr, nil
+}
+
+// expandQuery replaces the placeholders in the query with the actual values and returns
+// the expanded query and the arguments to be used in the query.
+func expandQuery(query string, args ...any) (string, []any) {
+ var expandedArgs []any
+ var expandedQuery string
+
+ if len(args) != strings.Count(query, "?") {
+ args = flattenSlice(args)
+ }
+ expandedQuery, expandedArgs, _ = sqlx.In(query, args...)
+ return sqlx.Rebind(sqlx.DOLLAR, expandedQuery), expandedArgs
+}
+
+// flatMap converts a slice of mixed values/slices into a flat slice.
+func flattenSlice(slice []any) []any {
+ var result []any
+ for _, v := range slice {
+ switch reflect.TypeOf(v).Kind() {
+ case reflect.Slice:
+ s := reflect.ValueOf(v)
+ for i := 0; i < s.Len(); i++ {
+ result = append(result, s.Index(i).Interface())
+ }
+ default:
+ result = append(result, v)
+ }
+ }
+ return result
+}
+
+// Rebind a query from ? to the target $ with custom initial value.
+func rebindWithStart(query string, startAt int) string {
+ // Add space enough for 10 params before we have to allocate
+ rqb := make([]byte, 0, len(query)+10)
+
+ var i, j = 0, startAt
+
+ for i = strings.Index(query, "?"); i != -1; i = strings.Index(query, "?") {
+ rqb = append(rqb, query[:i]...)
+ rqb = append(rqb, '$')
+
+ rqb = strconv.AppendInt(rqb, int64(j), 10)
+ j++
+
+ query = query[i+1:]
+ }
+
+ return string(append(rqb, query...))
+}
+
+// GetTestAdapter returns an adapter object. Useful for running tests.
+func GetTestAdapter() *adapter {
+ return &adapter{}
+}
+
+func init() {
+ store.RegisterAdapter(&adapter{})
+}
diff --git a/server/db/postgres/schema.sql b/server/db/postgres/schema.sql
index e69de29..3a1df0b 100644
--- a/server/db/postgres/schema.sql
+++ b/server/db/postgres/schema.sql
@@ -0,0 +1,2 @@
+# The MySQL and PostrgreSQL schemas are identical save for differences in SQL flavors.
+# SEE ../mysql/schema.sql.
diff --git a/server/db/postgres/tests/postgres_test.go b/server/db/postgres/tests/postgres_test.go
index e69de29..1493e3c 100644
--- a/server/db/postgres/tests/postgres_test.go
+++ b/server/db/postgres/tests/postgres_test.go
@@ -0,0 +1,1412 @@
+// To test another db backend:
+// 1) Create GetAdapter function inside your db backend adapter package (like one inside postgres adapter)
+// 2) Uncomment your db backend package ('backend' named package)
+// 3) Write own initConnectionToDb and 'db' variable
+// 4) Replace postgres specific db queries inside test to your own queries.
+// 5) Run.
+
+package tests
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "log"
+ "os"
+ "reflect"
+ "strconv"
+ "testing"
+ "time"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/google/go-cmp/cmp/cmpopts"
+ "github.com/jackc/pgx/v5/pgxpool"
+ adapter "github.com/tinode/chat/server/db"
+ "github.com/tinode/chat/server/store"
+ jcr "github.com/tinode/jsonco"
+
+ "github.com/tinode/chat/server/db/common/test_data"
+ backend "github.com/tinode/chat/server/db/postgres"
+ "github.com/tinode/chat/server/logs"
+ "github.com/tinode/chat/server/store/types"
+)
+
+type configType struct {
+ // If Reset=true test will recreate database every time it runs
+ Reset bool `json:"reset_db_data"`
+ // Configurations for individual adapters.
+ Adapters map[string]json.RawMessage `json:"adapters"`
+}
+
+var config configType
+var adp adapter.Adapter
+var db *pgxpool.Pool
+var testData *test_data.TestData
+var ctx context.Context
+
+var dummyUid1 = types.Uid(12345)
+var dummyUid2 = types.Uid(54321)
+
+func TestCreateDb(t *testing.T) {
+ if err := adp.CreateDb(config.Reset); err != nil {
+ t.Fatal(err)
+ }
+ // Saved db is closed, get a fresh one.
+ db = adp.GetTestDB().(*pgxpool.Pool)
+}
+
+// ================== Create tests ================================
+func TestUserCreate(t *testing.T) {
+ for _, user := range testData.Users {
+ if err := adp.UserCreate(user); err != nil {
+ t.Error(err)
+ }
+ }
+ var count int
+
+ err := db.QueryRow(ctx, "SELECT COUNT(*) FROM users").Scan(&count)
+ if err != nil {
+ t.Error(err)
+ }
+ if count == 0 {
+ t.Error("No users created!")
+ }
+}
+
+func TestCredUpsert(t *testing.T) {
+ // Test just inserts:
+ for i := 0; i < 2; i++ {
+ inserted, err := adp.CredUpsert(testData.Creds[i])
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !inserted {
+ t.Error("Should be inserted, but updated")
+ }
+ }
+
+ // Test duplicate:
+ _, err := adp.CredUpsert(testData.Creds[1])
+ if err != types.ErrDuplicate {
+ t.Error("Should return duplicate error but got", err)
+ }
+ _, err = adp.CredUpsert(testData.Creds[2])
+ if err != types.ErrDuplicate {
+ t.Error("Should return duplicate error but got", err)
+ }
+
+ // Test add new unvalidated credentials
+ inserted, err := adp.CredUpsert(testData.Creds[3])
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !inserted {
+ t.Error("Should be inserted, but updated")
+ }
+ inserted, err = adp.CredUpsert(testData.Creds[3])
+ if err != nil {
+ t.Fatal(err)
+ }
+ if inserted {
+ t.Error("Should be updated, but inserted")
+ }
+
+ // Just insert other creds (used in other tests)
+ for _, cred := range testData.Creds[4:] {
+ _, err = adp.CredUpsert(cred)
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+}
+
+func TestAuthAddRecord(t *testing.T) {
+ for _, rec := range testData.Recs {
+ err := adp.AuthAddRecord(types.ParseUserId("usr"+rec.UserId), rec.Scheme, rec.Unique,
+ rec.AuthLvl, rec.Secret, rec.Expires)
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ //Test duplicate
+ err := adp.AuthAddRecord(types.ParseUserId("usr"+testData.Users[0].Id), testData.Recs[0].Scheme,
+ testData.Recs[0].Unique, testData.Recs[0].AuthLvl, testData.Recs[0].Secret, testData.Recs[0].Expires)
+ if err != types.ErrDuplicate {
+ t.Fatal("Should be duplicate error but got", err)
+ }
+}
+
+func TestTopicCreate(t *testing.T) {
+ err := adp.TopicCreate(testData.Topics[0])
+ if err != nil {
+ t.Error(err)
+ }
+ // Update topic SeqId because it's not saved at creation time but used by the tests.
+ err = adp.TopicUpdate(testData.Topics[0].Id, map[string]interface{}{
+ "seqid": testData.Topics[0].SeqId,
+ })
+ if err != nil {
+ t.Error(err)
+ }
+ for _, tpc := range testData.Topics[3:] {
+ err = adp.TopicCreate(tpc)
+ if err != nil {
+ t.Error(err)
+ }
+ }
+}
+
+func decodeUid(u string) int64 {
+ return store.DecodeUid(types.ParseUid(u))
+}
+
+func encodeUid(u string) types.Uid {
+ id, _ := strconv.ParseInt(u, 10, 64)
+ return store.EncodeUid(int64(id))
+}
+
+func TestTopicCreateP2P(t *testing.T) {
+ err := adp.TopicCreateP2P(testData.Subs[2], testData.Subs[3])
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ oldModeGiven := testData.Subs[2].ModeGiven
+ testData.Subs[2].ModeGiven = 255
+ err = adp.TopicCreateP2P(testData.Subs[4], testData.Subs[2])
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var got types.Subscription
+ var userId int64
+ var modeWant, modeGiven []byte
+ err = db.QueryRow(ctx, "SELECT createdat,updatedat,deletedat,userid,topic,delid,recvseqid,readseqid,modewant,modegiven,private FROM subscriptions WHERE topic=$1 AND userid=$2",
+ testData.Subs[2].Topic, decodeUid(testData.Subs[2].User)).Scan(&got.CreatedAt,
+ &got.UpdatedAt, &got.DeletedAt, &userId, &got.Topic, &got.DelId, &got.RecvSeqId, &got.ReadSeqId,
+ &modeWant, &modeGiven, &got.Private)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got.ModeGiven.Scan(modeGiven)
+ if got.ModeGiven == oldModeGiven {
+ t.Error("ModeGiven update failed")
+ }
+}
+
+func TestTopicShare(t *testing.T) {
+ if err := adp.TopicShare(testData.Subs[0].Topic, testData.Subs); err != nil {
+ t.Fatal(err)
+ }
+
+ // Must save recvseqid and readseqid separately because TopicShare
+ // ignores them.
+ for _, sub := range testData.Subs {
+ adp.SubsUpdate(sub.Topic, types.ParseUid(sub.User), map[string]any{
+ "delid": sub.DelId,
+ "recvseqid": sub.RecvSeqId,
+ "readseqid": sub.ReadSeqId,
+ })
+ }
+
+ // Update topic SeqId because it's not saved at creation time but used by the tests.
+ for _, tpc := range testData.Topics {
+ err := adp.TopicUpdate(tpc.Id, map[string]any{
+ "seqid": tpc.SeqId,
+ "delid": tpc.DelId,
+ })
+ if err != nil {
+ t.Error(err)
+ }
+ }
+}
+
+func TestMessageSave(t *testing.T) {
+ for _, msg := range testData.Msgs {
+ err := adp.MessageSave(msg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ // Some messages are soft deleted, but it's ignored by adp.MessageSave
+ for _, msg := range testData.Msgs {
+ if len(msg.DeletedFor) > 0 {
+ for _, del := range msg.DeletedFor {
+ toDel := types.DelMessage{
+ Topic: msg.Topic,
+ DeletedFor: del.User,
+ DelId: del.DelId,
+ SeqIdRanges: []types.Range{{Low: msg.SeqId}},
+ }
+ adp.MessageDeleteList(msg.Topic, &toDel)
+ }
+ }
+ }
+}
+
+func TestFileStartUpload(t *testing.T) {
+ for _, f := range testData.Files {
+ err := adp.FileStartUpload(f)
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+}
+
+// ================== Read tests ==================================
+func TestUserGet(t *testing.T) {
+ // Test not found
+ got, err := adp.UserGet(dummyUid1)
+ if err == nil && got != nil {
+ t.Error("user should be nil.")
+ }
+
+ got, err = adp.UserGet(types.ParseUserId("usr" + testData.Users[0].Id))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // User agent is not stored when creating a user. Make sure it's the same.
+ got.UserAgent = testData.Users[0].UserAgent
+
+ if !reflect.DeepEqual(got, testData.Users[0]) {
+ t.Error(mismatchErrorString("User", got, testData.Users[0]))
+ }
+}
+
+func TestUserGetAll(t *testing.T) {
+ // Test not found (dummy UIDs).
+ got, err := adp.UserGetAll(dummyUid1, dummyUid2)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) > 0 {
+ t.Error("result users should be zero length, got", len(got))
+ }
+
+ got, err = adp.UserGetAll(types.ParseUserId("usr"+testData.Users[0].Id), types.ParseUserId("usr"+testData.Users[1].Id))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 2 {
+ t.Fatal(mismatchErrorString("resultUsers length", len(got), 2))
+ }
+ for i, usr := range got {
+ // User agent is not compared.
+ usr.UserAgent = testData.Users[i].UserAgent
+ if !reflect.DeepEqual(&usr, testData.Users[i]) {
+ t.Error(mismatchErrorString("User", &usr, testData.Users[i]))
+ }
+ }
+}
+
+func TestUserGetByCred(t *testing.T) {
+ // Test not found
+ got, err := adp.UserGetByCred("foo", "bar")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != types.ZeroUid {
+ t.Error("result uid should be ZeroUid")
+ }
+
+ got, _ = adp.UserGetByCred(testData.Creds[0].Method, testData.Creds[0].Value)
+ if got != types.ParseUserId("usr"+testData.Creds[0].User) {
+ t.Error(mismatchErrorString("Uid", got, types.ParseUserId("usr"+testData.Creds[0].User)))
+ }
+}
+
+func TestCredGetActive(t *testing.T) {
+ got, err := adp.CredGetActive(types.ParseUserId("usr"+testData.Users[2].Id), "tel")
+ if err != nil {
+ t.Error(err)
+ }
+ if !reflect.DeepEqual(got, testData.Creds[3]) {
+ t.Error(mismatchErrorString("Credential", got, testData.Creds[3]))
+ }
+
+ // Test not found
+ got, err = adp.CredGetActive(dummyUid1, "")
+ if err != nil {
+ t.Error(err)
+ }
+ if got != nil {
+ t.Error("result should be nil, but got", got)
+ }
+}
+
+func TestCredGetAll(t *testing.T) {
+ got, err := adp.CredGetAll(types.ParseUserId("usr"+testData.Users[2].Id), "", false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 3 {
+ t.Error(mismatchErrorString("Credentials length", len(got), 3))
+ }
+
+ got, _ = adp.CredGetAll(types.ParseUserId("usr"+testData.Users[2].Id), "tel", false)
+ if len(got) != 2 {
+ t.Error(mismatchErrorString("Credentials length", len(got), 2))
+ }
+
+ got, _ = adp.CredGetAll(types.ParseUserId("usr"+testData.Users[2].Id), "", true)
+ if len(got) != 1 {
+ t.Error(mismatchErrorString("Credentials length", len(got), 1))
+ }
+
+ got, _ = adp.CredGetAll(types.ParseUserId("usr"+testData.Users[2].Id), "tel", true)
+ if len(got) != 1 {
+ t.Error(mismatchErrorString("Credentials length", len(got), 1))
+ }
+}
+
+func TestAuthGetUniqueRecord(t *testing.T) {
+ uid, authLvl, secret, expires, err := adp.AuthGetUniqueRecord("basic:alice")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if uid != types.ParseUserId("usr"+testData.Recs[0].UserId) ||
+ authLvl != testData.Recs[0].AuthLvl ||
+ !reflect.DeepEqual(secret, testData.Recs[0].Secret) ||
+ expires != testData.Recs[0].Expires {
+
+ got := fmt.Sprintf("%v %v %v %v", uid, authLvl, secret, expires)
+ want := fmt.Sprintf("%v %v %v %v", testData.Recs[0].UserId, testData.Recs[0].AuthLvl, testData.Recs[0].Secret, testData.Recs[0].Expires)
+ t.Error(mismatchErrorString("Auth record", got, want))
+ }
+
+ // Test not found
+ uid, _, _, _, err = adp.AuthGetUniqueRecord("qwert:asdfg")
+ if err == nil && !uid.IsZero() {
+ t.Error("Auth record found but shouldn't. Uid:", uid.String())
+ }
+}
+
+func TestAuthGetRecord(t *testing.T) {
+ recId, authLvl, secret, expires, err := adp.AuthGetRecord(types.ParseUserId("usr"+testData.Recs[0].UserId), "basic")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if recId != testData.Recs[0].Unique ||
+ authLvl != testData.Recs[0].AuthLvl ||
+ !reflect.DeepEqual(secret, testData.Recs[0].Secret) ||
+ expires != testData.Recs[0].Expires {
+
+ got := fmt.Sprintf("%v %v %v %v", recId, authLvl, secret, expires)
+ want := fmt.Sprintf("%v %v %v %v", testData.Recs[0].Unique, testData.Recs[0].AuthLvl, testData.Recs[0].Secret, testData.Recs[0].Expires)
+ t.Error(mismatchErrorString("Auth record", got, want))
+ }
+
+ // Test not found
+ recId, _, _, _, err = adp.AuthGetRecord(types.Uid(123), "scheme")
+ if err != types.ErrNotFound {
+ t.Error("Auth record found but shouldn't. recId:", recId)
+ }
+}
+
+func TestTopicGet(t *testing.T) {
+ got, err := adp.TopicGet(testData.Topics[0].Id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(got, testData.Topics[0]) {
+ t.Error(mismatchErrorString("Topic", got, testData.Topics[0]))
+ }
+ // Test not found
+ got, err = adp.TopicGet("asdfasdfasdf")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != nil {
+ t.Error("Topic should be nil but got:", got)
+ }
+}
+
+func TestTopicsForUser(t *testing.T) {
+ qOpts := types.QueryOpt{
+ Topic: "p2p9AVDamaNCRbfKzGSh3mE0w",
+ Limit: 999,
+ }
+ gotSubs, err := adp.TopicsForUser(types.ParseUserId("usr"+testData.Users[0].Id), false, &qOpts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(gotSubs) != 1 {
+ t.Error(mismatchErrorString("Subs length", len(gotSubs), 1))
+ }
+
+ gotSubs, err = adp.TopicsForUser(types.ParseUserId("usr"+testData.Users[1].Id), true, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(gotSubs) != 2 {
+ t.Error(mismatchErrorString("Subs length (2)", len(gotSubs), 2))
+ }
+
+ qOpts.Topic = ""
+ ims := testData.Now.Add(15 * time.Minute)
+ qOpts.IfModifiedSince = &ims
+ gotSubs, err = adp.TopicsForUser(types.ParseUserId("usr"+testData.Users[0].Id), false, &qOpts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(gotSubs) != 1 {
+ t.Error(mismatchErrorString("Subs length (IMS)", len(gotSubs), 1))
+ }
+
+ ims = time.Now().Add(15 * time.Minute)
+ gotSubs, err = adp.TopicsForUser(types.ParseUserId("usr"+testData.Users[0].Id), false, &qOpts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(gotSubs) != 0 {
+ t.Error(mismatchErrorString("Subs length (IMS 2)", len(gotSubs), 0))
+ }
+}
+
+func TestUsersForTopic(t *testing.T) {
+ qOpts := types.QueryOpt{
+ User: types.ParseUserId("usr" + testData.Users[0].Id),
+ Limit: 999,
+ }
+ gotSubs, err := adp.UsersForTopic("grpgRXf0rU4uR4", false, &qOpts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(gotSubs) != 1 {
+ t.Error(mismatchErrorString("Subs length", len(gotSubs), 1))
+ }
+
+ gotSubs, err = adp.UsersForTopic("grpgRXf0rU4uR4", true, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(gotSubs) != 2 {
+ t.Error(mismatchErrorString("Subs length", len(gotSubs), 2))
+ }
+
+ gotSubs, err = adp.UsersForTopic("p2p9AVDamaNCRbfKzGSh3mE0w", false, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(gotSubs) != 2 {
+ t.Error(mismatchErrorString("Subs length", len(gotSubs), 2))
+ }
+}
+
+func TestOwnTopics(t *testing.T) {
+ gotSubs, err := adp.OwnTopics(types.ParseUserId("usr" + testData.Users[0].Id))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(gotSubs) != 1 {
+ t.Fatalf("Got topic length %v instead of %v", len(gotSubs), 1)
+ }
+ if gotSubs[0] != testData.Topics[0].Id {
+ t.Errorf("Got topic %v instead of %v", gotSubs[0], testData.Topics[0].Id)
+ }
+}
+
+func TestChannelsForUser(t *testing.T) {
+ // Test channels for user (PostgreSQL specific test)
+ channels, err := adp.ChannelsForUser(types.ParseUserId("usr" + testData.Users[0].Id))
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Should return empty slice since we don't have channel subscriptions in test data
+ if len(channels) != 0 {
+ t.Error(mismatchErrorString("Channels length", len(channels), 0))
+ }
+}
+
+func TestSubscriptionGet(t *testing.T) {
+ got, err := adp.SubscriptionGet(testData.Topics[0].Id, types.ParseUserId("usr"+testData.Users[0].Id), false)
+ if err != nil {
+ t.Error(err)
+ }
+
+ if diff := cmp.Diff(got, testData.Subs[0],
+ cmpopts.IgnoreUnexported(types.Subscription{}, types.ObjHeader{})); diff != "" {
+ t.Error(mismatchErrorString("Subs", diff, ""))
+ }
+ // Test not found
+ got, err = adp.SubscriptionGet("dummytopic", dummyUid1, false)
+ if err != nil {
+ t.Error(err)
+ }
+ if got != nil {
+ t.Error("result sub should be nil.")
+ }
+}
+
+func TestSubsForUser(t *testing.T) {
+ gotSubs, err := adp.SubsForUser(types.ParseUserId("usr" + testData.Users[0].Id))
+ if err != nil {
+ t.Error(err)
+ }
+ if len(gotSubs) != 2 {
+ t.Error(mismatchErrorString("Subs length", len(gotSubs), 2))
+ }
+
+ // Test not found
+ gotSubs, err = adp.SubsForUser(types.ParseUserId("usr12345678"))
+ if err != nil {
+ t.Error(err)
+ }
+ if len(gotSubs) != 0 {
+ t.Error(mismatchErrorString("Subs length", len(gotSubs), 0))
+ }
+}
+
+func TestSubsForTopic(t *testing.T) {
+ qOpts := types.QueryOpt{
+ User: types.ParseUserId("usr" + testData.Users[0].Id),
+ Limit: 999,
+ }
+ gotSubs, err := adp.SubsForTopic(testData.Topics[0].Id, false, &qOpts)
+ if err != nil {
+ t.Error(err)
+ }
+ if len(gotSubs) != 1 {
+ t.Error(mismatchErrorString("Subs length", len(gotSubs), 1))
+ }
+ // Test not found
+ gotSubs, err = adp.SubsForTopic("dummytopicid", false, nil)
+ if err != nil {
+ t.Error(err)
+ }
+ if len(gotSubs) != 0 {
+ t.Error(mismatchErrorString("Subs length", len(gotSubs), 0))
+ }
+}
+
+func TestFind(t *testing.T) {
+ reqTags := [][]string{{"alice", "bob", "carol", "travel", "qwer", "asdf", "zxcv"}}
+ got, err := adp.Find("usr"+testData.Users[2].Id, "", reqTags, nil, true)
+ if err != nil {
+ t.Error(err)
+ } else if len(got) != 3 {
+ t.Error(mismatchErrorString("result length", len(got), 3))
+ }
+}
+
+func TestFindOne(t *testing.T) {
+ // Test PostgreSQL specific FindOne method
+ found, err := adp.FindOne("alice")
+ if err != nil {
+ t.Error(err)
+ }
+ // Should find the user with alice tag
+ if found == "" {
+ t.Error("Expected to find user with alice tag")
+ }
+
+ // Test not found
+ found, err = adp.FindOne("nonexistent")
+ if err != nil {
+ t.Error(err)
+ }
+ if found != "" {
+ t.Error("Should not find nonexistent tag")
+ }
+}
+
+func TestMessageGetAll(t *testing.T) {
+ opts := types.QueryOpt{
+ Since: 1,
+ Before: 2,
+ Limit: 999,
+ }
+ gotMsgs, err := adp.MessageGetAll(testData.Topics[0].Id, types.ParseUserId("usr"+testData.Users[0].Id), &opts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(gotMsgs) != 1 {
+ t.Error(mismatchErrorString("Messages length opts", len(gotMsgs), 1))
+ }
+ gotMsgs, _ = adp.MessageGetAll(testData.Topics[0].Id, types.ParseUserId("usr"+testData.Users[0].Id), nil)
+ if len(gotMsgs) != 2 {
+ t.Error(mismatchErrorString("Messages length no opts", len(gotMsgs), 2))
+ }
+ gotMsgs, _ = adp.MessageGetAll(testData.Topics[0].Id, types.ZeroUid, nil)
+ if len(gotMsgs) != 3 {
+ t.Error(mismatchErrorString("Messages length zero uid", len(gotMsgs), 3))
+ }
+}
+
+func TestFileGet(t *testing.T) {
+ // General test done during TestFileFinishUpload().
+
+ // Test not found
+ got, err := adp.FileGet("dummyfileid")
+ if err != nil && got != nil {
+ t.Error("File found but shouldn't:", got)
+ }
+}
+
+// ================== Update tests ================================
+func TestUserUpdate(t *testing.T) {
+ update := map[string]any{
+ "UserAgent": "Test Agent v0.11",
+ "UpdatedAt": testData.Now.Add(30 * time.Minute),
+ }
+ err := adp.UserUpdate(types.ParseUserId("usr"+testData.Users[0].Id), update)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var got struct {
+ UserAgent string
+ UpdatedAt time.Time
+ CreatedAt time.Time
+ }
+ err = db.QueryRow(ctx, "SELECT useragent, updatedat, createdat FROM users WHERE id=$1",
+ decodeUid(testData.Users[0].Id)).Scan(&got.UserAgent, &got.UpdatedAt, &got.CreatedAt)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.UserAgent != "Test Agent v0.11" {
+ t.Error(mismatchErrorString("UserAgent", got.UserAgent, "Test Agent v0.11"))
+ }
+ if got.UpdatedAt == got.CreatedAt {
+ t.Error("UpdatedAt field not updated")
+ }
+}
+
+func TestUserUpdateTags(t *testing.T) {
+ addTags := testData.Tags[0]
+ removeTags := testData.Tags[1]
+ resetTags := testData.Tags[2]
+ uid := types.ParseUserId("usr" + testData.Users[0].Id)
+
+ got, err := adp.UserUpdateTags(uid, addTags, nil, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := []string{"alice", "tag1"}
+ if !reflect.DeepEqual(got, want) {
+ t.Error(mismatchErrorString("Tags", got, want))
+ }
+
+ got, err = adp.UserUpdateTags(uid, nil, removeTags, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want = nil
+ if !reflect.DeepEqual(got, want) {
+ t.Error(mismatchErrorString("Tags", got, want))
+ }
+
+ got, err = adp.UserUpdateTags(uid, nil, nil, resetTags)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want = []string{"alice", "tag111", "tag333"}
+ if !reflect.DeepEqual(got, want) {
+ t.Error(mismatchErrorString("Tags", got, want))
+ }
+
+ got, err = adp.UserUpdateTags(uid, addTags, removeTags, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want = []string{"tag111", "tag333"}
+ if !reflect.DeepEqual(got, want) {
+ t.Error(mismatchErrorString("Tags", got, want))
+ }
+}
+
+func TestUserGetUnvalidated(t *testing.T) {
+ // Test PostgreSQL specific method
+ cutoff := time.Now().Add(-24 * time.Hour)
+ uids, err := adp.UserGetUnvalidated(cutoff, 10)
+ if err != nil {
+ t.Error(err)
+ }
+ // Should return empty slice since all test users are considered validated
+ if len(uids) > 0 {
+ t.Error("Expected no unvalidated users in test data")
+ }
+}
+
+func TestCredFail(t *testing.T) {
+ err := adp.CredFail(types.ParseUserId("usr"+testData.Creds[3].User), "tel")
+ if err != nil {
+ t.Error(err)
+ }
+
+ // Check if fields updated
+ var got struct {
+ Retries int
+ UpdatedAt time.Time
+ CreatedAt time.Time
+ }
+ err = db.QueryRow(ctx, "SELECT retries, updatedat, createdat FROM credentials WHERE userid=$1 AND method=$2 AND value=$3",
+ decodeUid(testData.Creds[3].User), "tel", testData.Creds[3].Value).Scan(&got.Retries, &got.UpdatedAt, &got.CreatedAt)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Retries != 1 {
+ t.Error(mismatchErrorString("Retries count", got.Retries, 1))
+ }
+ if got.UpdatedAt == got.CreatedAt {
+ t.Error("UpdatedAt field not updated")
+ }
+}
+
+func TestCredConfirm(t *testing.T) {
+ err := adp.CredConfirm(types.ParseUserId("usr"+testData.Creds[3].User), "tel")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Test fields are updated
+ var got struct {
+ UpdatedAt time.Time
+ CreatedAt time.Time
+ Done bool
+ }
+ err = db.QueryRow(ctx, "SELECT updatedat, createdat, done FROM credentials WHERE userid=$1 AND method=$2 AND value=$3",
+ decodeUid(testData.Creds[3].User), "tel", testData.Creds[3].Value).Scan(&got.UpdatedAt, &got.CreatedAt, &got.Done)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.UpdatedAt == got.CreatedAt {
+ t.Error("Credential not updated correctly")
+ }
+ if !got.Done {
+ t.Error("Credential should be marked as done")
+ }
+}
+
+func TestAuthUpdRecord(t *testing.T) {
+ rec := testData.Recs[1]
+ newSecret := []byte{'s', 'e', 'c', 'r', 'e', 't'}
+ err := adp.AuthUpdRecord(types.ParseUserId("usr"+rec.UserId), rec.Scheme, rec.Unique,
+ rec.AuthLvl, newSecret, rec.Expires)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got []byte
+ err = db.QueryRow(ctx, "SELECT secret FROM auth WHERE uname=$1", rec.Unique).Scan(&got)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if reflect.DeepEqual(got, rec.Secret) {
+ t.Error(mismatchErrorString("Secret", got, rec.Secret))
+ }
+
+ // Test with auth ID (unique) change
+ newId := "basic:bob12345"
+ err = adp.AuthUpdRecord(types.ParseUserId("usr"+rec.UserId), rec.Scheme, newId,
+ rec.AuthLvl, newSecret, rec.Expires)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Test if old ID deleted
+ var count int
+ err = db.QueryRow(ctx, "SELECT COUNT(*) FROM auth WHERE uname=$1", rec.Unique).Scan(&count)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if count != 0 {
+ t.Error("Old auth record not deleted")
+ }
+}
+
+func TestTopicUpdateOnMessage(t *testing.T) {
+ msg := types.Message{
+ ObjHeader: types.ObjHeader{
+ CreatedAt: testData.Now.Add(33 * time.Minute),
+ },
+ SeqId: 66,
+ }
+ err := adp.TopicUpdateOnMessage(testData.Topics[2].Id, &msg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got struct {
+ TouchedAt time.Time
+ SeqId int
+ }
+ err = db.QueryRow(ctx, "SELECT touchedat, seqid FROM topics WHERE name=$1", testData.Topics[2].Id).
+ Scan(&got.TouchedAt, &got.SeqId)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.TouchedAt != msg.CreatedAt || got.SeqId != msg.SeqId {
+ t.Error(mismatchErrorString("TouchedAt", got.TouchedAt, msg.CreatedAt))
+ t.Error(mismatchErrorString("SeqId", got.SeqId, msg.SeqId))
+ }
+}
+
+func TestTopicUpdate(t *testing.T) {
+ update := map[string]any{
+ "UpdatedAt": testData.Now.Add(55 * time.Minute),
+ }
+ err := adp.TopicUpdate(testData.Topics[0].Id, update)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got time.Time
+ err = db.QueryRow(ctx, "SELECT updatedat FROM topics WHERE name=$1", testData.Topics[0].Id).Scan(&got)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != update["UpdatedAt"] {
+ t.Error(mismatchErrorString("UpdatedAt", got, update["UpdatedAt"]))
+ }
+}
+
+func TestTopicUpdateSubCnt(t *testing.T) {
+ // Test PostgreSQL specific method
+ err := adp.TopicUpdateSubCnt(testData.Topics[0].Id)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Verify the subscription count was updated correctly
+ var subcnt int
+ err = db.QueryRow(ctx, "SELECT subcnt FROM topics WHERE name=$1", testData.Topics[0].Id).Scan(&subcnt)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Should match the number of active subscriptions
+ if subcnt < 0 {
+ t.Error("Subscription count should be non-negative")
+ }
+}
+
+func TestTopicOwnerChange(t *testing.T) {
+ err := adp.TopicOwnerChange(testData.Topics[0].Id, types.ParseUserId("usr"+testData.Users[1].Id))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got int64
+ err = db.QueryRow(ctx, "SELECT owner FROM topics WHERE name=$1", testData.Topics[0].Id).Scan(&got)
+ if err != nil {
+ t.Fatal(err)
+ }
+ expectedOwner := decodeUid(testData.Users[1].Id)
+ if got != expectedOwner {
+ t.Error(mismatchErrorString("Owner", got, expectedOwner))
+ }
+}
+
+func TestSubsUpdate(t *testing.T) {
+ update := map[string]any{
+ "UpdatedAt": testData.Now.Add(22 * time.Minute),
+ }
+ err := adp.SubsUpdate(testData.Topics[0].Id, types.ParseUserId("usr"+testData.Users[0].Id), update)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got time.Time
+ err = db.QueryRow(ctx, "SELECT updatedat FROM subscriptions WHERE topic=$1 AND userid=$2",
+ testData.Topics[0].Id, decodeUid(testData.Users[0].Id)).Scan(&got)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != update["UpdatedAt"] {
+ t.Error(mismatchErrorString("UpdatedAt", got, update["UpdatedAt"]))
+ }
+
+ err = adp.SubsUpdate(testData.Topics[1].Id, types.ZeroUid, update)
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = db.QueryRow(ctx, "SELECT updatedat FROM subscriptions WHERE topic=$1 LIMIT 1",
+ testData.Topics[1].Id).Scan(&got)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != update["UpdatedAt"] {
+ t.Error(mismatchErrorString("UpdatedAt", got, update["UpdatedAt"]))
+ }
+}
+
+func TestSubsDelete(t *testing.T) {
+ err := adp.SubsDelete(testData.Topics[1].Id, types.ParseUserId("usr"+testData.Users[0].Id))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var deletedat sql.NullTime
+ err = db.QueryRow(ctx, "SELECT deletedat FROM subscriptions WHERE topic=$1 AND userid=$2",
+ testData.Topics[1].Id, decodeUid(testData.Users[0].Id)).Scan(&deletedat)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !deletedat.Valid {
+ t.Error("DeletedAt should not be null")
+ }
+}
+
+func TestSubsDelForUser(t *testing.T) {
+ // Tested during TestUserDelete (both hard and soft deletions)
+}
+
+func TestDeviceUpsert(t *testing.T) {
+ err := adp.DeviceUpsert(types.ParseUserId("usr"+testData.Users[0].Id), testData.Devs[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got struct {
+ DeviceId string
+ Platform string
+ }
+ err = db.QueryRow(ctx, "SELECT deviceid, platform FROM devices WHERE userid=$1 LIMIT 1",
+ decodeUid(testData.Users[0].Id)).Scan(&got.DeviceId, &got.Platform)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.DeviceId != testData.Devs[0].DeviceId || got.Platform != testData.Devs[0].Platform {
+ t.Error(mismatchErrorString("Device", got, testData.Devs[0]))
+ }
+
+ // Test update
+ testData.Devs[0].Platform = "Web"
+ err = adp.DeviceUpsert(types.ParseUserId("usr"+testData.Users[0].Id), testData.Devs[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = db.QueryRow(ctx, "SELECT platform FROM devices WHERE userid=$1 AND deviceid=$2",
+ decodeUid(testData.Users[0].Id), testData.Devs[0].DeviceId).Scan(&got.Platform)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Platform != "Web" {
+ t.Error("Device not updated.", got.Platform)
+ }
+
+ // Test add same device to another user
+ err = adp.DeviceUpsert(types.ParseUserId("usr"+testData.Users[1].Id), testData.Devs[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ err = adp.DeviceUpsert(types.ParseUserId("usr"+testData.Users[2].Id), testData.Devs[1])
+ if err != nil {
+ t.Error(err)
+ }
+}
+
+func TestMessageAttachments(t *testing.T) {
+ fids := []string{testData.Files[0].Id, testData.Files[1].Id}
+ err := adp.FileLinkAttachments("", types.ZeroUid, types.ParseUid(testData.Msgs[1].Id), fids)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Check if attachments were linked
+ var count int
+ err = db.QueryRow(ctx, "SELECT COUNT(*) FROM filemsglinks WHERE msgid=$1",
+ int64(types.ParseUid(testData.Msgs[1].Id))).Scan(&count)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if count != len(fids) {
+ t.Error(mismatchErrorString("Attachments count", count, len(fids)))
+ }
+}
+
+func TestFileFinishUpload(t *testing.T) {
+ got, err := adp.FileFinishUpload(testData.Files[0], true, 22222)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Status != types.UploadCompleted {
+ t.Error(mismatchErrorString("Status", got.Status, types.UploadCompleted))
+ }
+ if got.Size != 22222 {
+ t.Error(mismatchErrorString("Size", got.Size, 22222))
+ }
+}
+
+// ================== Other tests =================================
+func TestDeviceGetAll(t *testing.T) {
+ uid0 := types.ParseUserId("usr" + testData.Users[0].Id)
+ uid1 := types.ParseUserId("usr" + testData.Users[1].Id)
+ uid2 := types.ParseUserId("usr" + testData.Users[2].Id)
+ gotDevs, count, err := adp.DeviceGetAll(uid0, uid1, uid2)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if count < 1 {
+ t.Fatal(mismatchErrorString("count", count, ">=1"))
+ }
+ // Test that devices exist for the users
+ if len(gotDevs) == 0 {
+ t.Error("Expected devices for users")
+ }
+}
+
+func TestDeviceDelete(t *testing.T) {
+ err := adp.DeviceDelete(types.ParseUserId("usr"+testData.Users[1].Id), testData.Devs[0].DeviceId)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var count int
+ err = db.QueryRow(ctx, "SELECT COUNT(*) FROM devices WHERE userid=$1 AND deviceid=$2",
+ decodeUid(testData.Users[1].Id), testData.Devs[0].DeviceId).Scan(&count)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if count != 0 {
+ t.Error("Device not deleted:", count)
+ }
+
+ err = adp.DeviceDelete(types.ParseUserId("usr"+testData.Users[2].Id), "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = db.QueryRow(ctx, "SELECT COUNT(*) FROM devices WHERE userid=$1",
+ decodeUid(testData.Users[2].Id)).Scan(&count)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if count != 0 {
+ t.Error("All devices not deleted:", count)
+ }
+}
+
+// ================== Persistent Cache tests ======================
+func TestPCacheUpsert(t *testing.T) {
+ err := adp.PCacheUpsert("test_key", "test_value", false)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Test duplicate with failOnDuplicate = true
+ err = adp.PCacheUpsert("test_key2", "test_value2", true)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ err = adp.PCacheUpsert("test_key2", "new_value", true)
+ if err != types.ErrDuplicate {
+ t.Error("Expected duplicate error")
+ }
+}
+
+func TestPCacheGet(t *testing.T) {
+ value, err := adp.PCacheGet("test_key")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if value != "test_value" {
+ t.Error(mismatchErrorString("Cache value", value, "test_value"))
+ }
+
+ // Test not found
+ _, err = adp.PCacheGet("nonexistent")
+ if err != types.ErrNotFound {
+ t.Error("Expected not found error")
+ }
+}
+
+func TestPCacheDelete(t *testing.T) {
+ err := adp.PCacheDelete("test_key")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Verify deleted
+ _, err = adp.PCacheGet("test_key")
+ if err != types.ErrNotFound {
+ t.Error("Key should be deleted")
+ }
+}
+
+func TestPCacheExpire(t *testing.T) {
+ // Insert some test keys with prefix
+ adp.PCacheUpsert("prefix_key1", "value1", false)
+ adp.PCacheUpsert("prefix_key2", "value2", false)
+
+ // Expire keys older than now (should delete all test keys)
+ err := adp.PCacheExpire("prefix_", time.Now().Add(1*time.Minute))
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+// ================== Delete tests ================================
+func TestCredDel(t *testing.T) {
+ err := adp.CredDel(types.ParseUserId("usr"+testData.Users[0].Id), "email", "alice@test.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var count int
+ err = db.QueryRow(ctx, "SELECT COUNT(*) FROM credentials WHERE method='email' AND value='alice@test.example.com'").Scan(&count)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if count != 0 {
+ t.Error("Got result but shouldn't", count)
+ }
+
+ err = adp.CredDel(types.ParseUserId("usr"+testData.Users[1].Id), "", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = db.QueryRow(ctx, "SELECT COUNT(*) FROM credentials WHERE userid=$1",
+ decodeUid(testData.Users[1].Id)).Scan(&count)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if count != 0 {
+ t.Error("Got result but shouldn't", count)
+ }
+}
+
+func TestAuthDelScheme(t *testing.T) {
+ // Test deleting auth scheme
+ err := adp.AuthDelScheme(types.ParseUserId("usr"+testData.Recs[1].UserId), testData.Recs[1].Scheme)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Verify deleted
+ _, _, _, _, err = adp.AuthGetRecord(types.ParseUserId("usr"+testData.Recs[1].UserId), testData.Recs[1].Scheme)
+ if err != types.ErrNotFound {
+ t.Error("Auth record should be deleted")
+ }
+}
+
+func TestAuthDelAllRecords(t *testing.T) {
+ delCount, err := adp.AuthDelAllRecords(types.ParseUserId("usr" + testData.Recs[0].UserId))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if delCount != 1 {
+ t.Error(mismatchErrorString("delCount", delCount, 1))
+ }
+
+ // With dummy user
+ delCount, _ = adp.AuthDelAllRecords(dummyUid1)
+ if delCount != 0 {
+ t.Error(mismatchErrorString("delCount", delCount, 0))
+ }
+}
+
+func TestMessageDeleteList(t *testing.T) {
+ toDel := types.DelMessage{
+ ObjHeader: types.ObjHeader{
+ Id: testData.UGen.GetStr(),
+ CreatedAt: testData.Now,
+ UpdatedAt: testData.Now,
+ },
+ Topic: testData.Topics[1].Id,
+ DeletedFor: testData.Users[2].Id,
+ DelId: 1,
+ SeqIdRanges: []types.Range{{Low: 9}, {Low: 3, Hi: 7}},
+ }
+ err := adp.MessageDeleteList(toDel.Topic, &toDel)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Check messages in dellog
+ var count int
+ err = db.QueryRow(ctx, "SELECT COUNT(*) FROM dellog WHERE topic=$1 AND deletedfor=$2",
+ toDel.Topic, decodeUid(toDel.DeletedFor)).Scan(&count)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if count == 0 {
+ t.Error("No dellog entries created")
+ }
+
+ // Hard delete test
+ toDel = types.DelMessage{
+ ObjHeader: types.ObjHeader{
+ Id: testData.UGen.GetStr(),
+ CreatedAt: testData.Now,
+ UpdatedAt: testData.Now,
+ },
+ Topic: testData.Topics[0].Id,
+ DelId: 3,
+ SeqIdRanges: []types.Range{{Low: 1, Hi: 3}},
+ }
+ err = adp.MessageDeleteList(toDel.Topic, &toDel)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Check if messages content was cleared
+ err = db.QueryRow(ctx, "SELECT COUNT(*) FROM messages WHERE topic=$1 AND content IS NOT NULL",
+ toDel.Topic).Scan(&count)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if count > 1 {
+ t.Errorf("Messages not properly deleted %d, %s", count, toDel.Topic)
+ }
+
+ err = adp.MessageDeleteList(testData.Topics[0].Id, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = db.QueryRow(ctx, "SELECT COUNT(*) FROM messages WHERE topic=$1", testData.Topics[0].Id).Scan(&count)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if count != 0 {
+ t.Error("Result should be empty:", count)
+ }
+}
+
+func TestTopicDelete(t *testing.T) {
+ err := adp.TopicDelete(testData.Topics[1].Id, false, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var state int
+ err = db.QueryRow(ctx, "SELECT state FROM topics WHERE name=$1", testData.Topics[1].Id).Scan(&state)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if state != int(types.StateDeleted) {
+ t.Error("Soft delete failed:", state)
+ }
+
+ err = adp.TopicDelete(testData.Topics[0].Id, false, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var count int
+ err = db.QueryRow(ctx, "SELECT COUNT(*) FROM topics WHERE name=$1", testData.Topics[0].Id).Scan(&count)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if count != 0 {
+ t.Error("Hard delete failed:", count)
+ }
+}
+
+func TestFileDeleteUnused(t *testing.T) {
+ locs, err := adp.FileDeleteUnused(time.Now().Add(1*time.Minute), 999)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(locs) < 1 {
+ t.Log("No unused files to delete - this is expected in test environment")
+ }
+}
+
+func TestUserDelete(t *testing.T) {
+ err := adp.UserDelete(types.ParseUserId("usr"+testData.Users[0].Id), false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var state int
+ err = db.QueryRow(ctx, "SELECT state FROM users WHERE id=$1",
+ decodeUid(testData.Users[0].Id)).Scan(&state)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if state != int(types.StateDeleted) {
+ t.Error("User soft delete failed", state)
+ }
+
+ err = adp.UserDelete(types.ParseUserId("usr"+testData.Users[1].Id), true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var count int
+ err = db.QueryRow(ctx, "SELECT COUNT(*) FROM users WHERE id=$1",
+ decodeUid(testData.Users[1].Id)).Scan(&count)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if count != 0 {
+ t.Error("User hard delete failed")
+ }
+}
+
+func TestUserUnreadCount(t *testing.T) {
+ uids := []types.Uid{
+ types.ParseUserId("usr" + testData.Users[1].Id),
+ types.ParseUserId("usr" + testData.Users[2].Id),
+ }
+ expected := map[types.Uid]int{uids[0]: 0, uids[1]: 166}
+ counts, err := adp.UserUnreadCount(uids...)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(counts) != 2 {
+ t.Error(mismatchErrorString("UnreadCount length", len(counts), 2))
+ }
+
+ for uid, unread := range counts {
+ if expected[uid] != unread {
+ t.Error(mismatchErrorString("UnreadCount", unread, expected[uid]))
+ }
+ }
+
+ // Test not found (even if the account is not found, the call must return one record).
+ counts, err = adp.UserUnreadCount(dummyUid1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(counts) != 1 {
+ t.Error(mismatchErrorString("UnreadCount length (dummy)", len(counts), 1))
+ }
+ if counts[dummyUid1] != 0 {
+ t.Error(mismatchErrorString("Non-zero UnreadCount (dummy)", counts[dummyUid1], 0))
+ }
+}
+
+func TestMessageGetDeleted(t *testing.T) {
+ qOpts := types.QueryOpt{
+ Since: 1,
+ Before: 10,
+ Limit: 999,
+ }
+ got, err := adp.MessageGetDeleted(testData.Topics[1].Id, types.ParseUserId("usr"+testData.Users[2].Id), &qOpts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 1 {
+ t.Error(mismatchErrorString("result length", len(got), 1))
+ }
+}
+
+// ================================================================
+func mismatchErrorString(key string, got, want any) string {
+ return fmt.Sprintf("%s mismatch:\nGot = %+v\nWant = %+v", key, got, want)
+}
+
+func init() {
+ ctx = context.Background()
+ logs.Init(os.Stderr, "stdFlags")
+ adp = backend.GetTestAdapter()
+ conffile := flag.String("config", "./test.conf", "config of the database connection")
+
+ if file, err := os.Open(*conffile); err != nil {
+ log.Fatal("Failed to read config file:", err)
+ } else if err = json.NewDecoder(jcr.New(file)).Decode(&config); err != nil {
+ log.Fatal("Failed to parse config file:", err)
+ }
+
+ if adp == nil {
+ log.Fatal("Database adapter is missing")
+ }
+ if adp.IsOpen() {
+ log.Print("Connection is already opened")
+ }
+
+ err := adp.Open(config.Adapters[adp.GetName()])
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ db = adp.GetTestDB().(*pgxpool.Pool)
+ testData = test_data.InitTestData()
+ if testData == nil {
+ log.Fatal("Failed to initialize test data")
+ }
+ store.SetTestUidGenerator(*testData.UGen)
+}
diff --git a/server/db/postgres/tests/test.conf b/server/db/postgres/tests/test.conf
index e69de29..3b5e701 100644
--- a/server/db/postgres/tests/test.conf
+++ b/server/db/postgres/tests/test.conf
@@ -0,0 +1,12 @@
+{
+ "reset_db_data": true,
+ "adapters": {
+ "postgres": {
+ "User": "postgres",
+ "Passwd": "postgres",
+ "Host": "localhost",
+ "Port": "5432",
+ "DBName": "tinode_test"
+ }
+ }
+}
diff --git a/server/db/rethinkdb/adapter.go b/server/db/rethinkdb/adapter.go
index e69de29..dc60878 100644
--- a/server/db/rethinkdb/adapter.go
+++ b/server/db/rethinkdb/adapter.go
@@ -0,0 +1,3109 @@
+//go:build rethinkdb
+
+// Package rethinkdb is a database adapter for RethinkDB.
+package rethinkdb
+
+import (
+ "encoding/json"
+ "errors"
+ "hash/fnv"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/tinode/chat/server/auth"
+ "github.com/tinode/chat/server/db/common"
+ "github.com/tinode/chat/server/logs"
+ "github.com/tinode/chat/server/store"
+ t "github.com/tinode/chat/server/store/types"
+ rdb "gopkg.in/rethinkdb/rethinkdb-go.v6"
+)
+
+// adapter holds RethinkDb connection data.
+type adapter struct {
+ conn *rdb.Session
+ dbName string
+ // Maximum number of records to return
+ maxResults int
+ // Maximum number of message records to return
+ maxMessageResults int
+ version int
+}
+
+const (
+ adpVersion = 116
+ adapterName = "rethinkdb"
+
+ defaultHost = "localhost:28015"
+ defaultDatabase = "tinode"
+
+ defaultMaxResults = 1024
+ // This is capped by the Session's send queue limit (128).
+ defaultMaxMessageResults = 100
+)
+
+// See https://godoc.org/github.com/rethinkdb/rethinkdb-go#ConnectOpts for explanations.
+type configType struct {
+ Database string `json:"database,omitempty"`
+ Addresses any `json:"addresses,omitempty"`
+ Username string `json:"username,omitempty"`
+ Password string `json:"password,omitempty"`
+ AuthKey string `json:"authkey,omitempty"`
+ Timeout int `json:"timeout,omitempty"`
+ WriteTimeout int `json:"write_timeout,omitempty"`
+ ReadTimeout int `json:"read_timeout,omitempty"`
+ KeepAlivePeriod int `json:"keep_alive_timeout,omitempty"`
+ UseJSONNumber bool `json:"use_json_number,omitempty"`
+ NumRetries int `json:"num_retries,omitempty"`
+ InitialCap int `json:"initial_cap,omitempty"`
+ MaxOpen int `json:"max_open,omitempty"`
+ DiscoverHosts bool `json:"discover_hosts,omitempty"`
+ HostDecayDuration int `json:"host_decay_duration,omitempty"`
+}
+
+// Open initializes rethinkdb session
+func (a *adapter) Open(jsonconfig json.RawMessage) error {
+ if a.conn != nil {
+ return errors.New("adapter rethinkdb is already connected")
+ }
+
+ if len(jsonconfig) < 2 {
+ return errors.New("adapter rethinkdb missing config")
+ }
+
+ var err error
+ var config configType
+ if err = json.Unmarshal(jsonconfig, &config); err != nil {
+ return errors.New("adapter rethinkdb failed to parse config: " + err.Error())
+ }
+
+ var opts rdb.ConnectOpts
+
+ if config.Addresses == nil {
+ opts.Address = defaultHost
+ } else if host, ok := config.Addresses.(string); ok {
+ opts.Address = host
+ } else if ihosts, ok := config.Addresses.([]any); ok && len(ihosts) > 0 {
+ hosts := make([]string, len(ihosts))
+ for i, ih := range ihosts {
+ h, ok := ih.(string)
+ if !ok || h == "" {
+ return errors.New("adapter rethinkdb invalid config.Addresses value")
+ }
+ hosts[i] = h
+ }
+ opts.Addresses = hosts
+ } else {
+ return errors.New("adapter rethinkdb failed to parse config.Addresses")
+ }
+
+ if config.Database == "" {
+ a.dbName = defaultDatabase
+ } else {
+ a.dbName = config.Database
+ }
+
+ if a.maxResults <= 0 {
+ a.maxResults = defaultMaxResults
+ }
+
+ if a.maxMessageResults <= 0 {
+ a.maxMessageResults = defaultMaxMessageResults
+ }
+
+ opts.Database = a.dbName
+ opts.Username = config.Username
+ opts.Password = config.Password
+ opts.AuthKey = config.AuthKey
+ opts.Timeout = time.Duration(config.Timeout) * time.Second
+ opts.WriteTimeout = time.Duration(config.WriteTimeout) * time.Second
+ opts.ReadTimeout = time.Duration(config.ReadTimeout) * time.Second
+ opts.KeepAlivePeriod = time.Duration(config.KeepAlivePeriod) * time.Second
+ opts.UseJSONNumber = config.UseJSONNumber
+ opts.NumRetries = config.NumRetries
+ opts.InitialCap = config.InitialCap
+ opts.MaxOpen = config.MaxOpen
+ opts.DiscoverHosts = config.DiscoverHosts
+ opts.HostDecayDuration = time.Duration(config.HostDecayDuration) * time.Second
+
+ a.conn, err = rdb.Connect(opts)
+ if err != nil {
+ return err
+ }
+
+ rdb.SetTags("json")
+ a.version = -1
+
+ return nil
+}
+
+// Close closes the underlying database connection
+func (a *adapter) Close() error {
+ var err error
+ if a.conn != nil {
+ // Close will wait for all outstanding requests to finish
+ err = a.conn.Close()
+ a.conn = nil
+ a.version = -1
+ }
+ return err
+}
+
+// IsOpen returns true if connection to database has been established. It does not check if
+// connection is actually live.
+func (a *adapter) IsOpen() bool {
+ return a.conn != nil
+}
+
+// GetDbVersion returns current database version.
+func (a *adapter) GetDbVersion() (int, error) {
+ if a.version > 0 {
+ return a.version, nil
+ }
+
+ cursor, err := rdb.DB(a.dbName).Table("kvmeta").Get("version").Field("value").Run(a.conn)
+ if err != nil {
+ if isMissingDb(err) {
+ err = errors.New("Database not initialized")
+ }
+ return -1, err
+ }
+ defer cursor.Close()
+
+ if cursor.IsNil() {
+ return -1, errors.New("Database not initialized")
+ }
+
+ var vers int
+ if err = cursor.One(&vers); err != nil {
+ return -1, err
+ }
+
+ a.version = vers
+
+ return vers, nil
+}
+
+func (a *adapter) updateDbVersion(v int) error {
+ a.version = -1
+ if _, err := rdb.DB(a.dbName).Table("kvmeta").Get("version").
+ Update(map[string]any{"value": v}).RunWrite(a.conn); err != nil {
+ return err
+ }
+ return nil
+}
+
+// CheckDbVersion checks whether the actual DB version matches the expected version of this adapter.
+func (a *adapter) CheckDbVersion() error {
+ version, err := a.GetDbVersion()
+ if err != nil {
+ return err
+ }
+
+ if version != adpVersion {
+ return errors.New("Invalid database version " + strconv.Itoa(version) +
+ ". Expected " + strconv.Itoa(adpVersion))
+ }
+
+ return nil
+}
+
+// Version returns adapter version.
+func (adapter) Version() int {
+ return adpVersion
+}
+
+// Stats returns DB connection stats object.
+func (a *adapter) Stats() any {
+ if a.conn == nil {
+ return nil
+ }
+
+ cursor, err := rdb.DB("rethinkdb").Table("stats").Get([]string{"cluster"}).Field("query_engine").Run(a.conn)
+ if err != nil {
+ return nil
+ }
+ defer cursor.Close()
+
+ var stats []any
+ if err = cursor.All(&stats); err != nil || len(stats) < 1 {
+ return nil
+ }
+
+ return stats[0]
+}
+
+// GetName returns string that adapter uses to register itself with store.
+func (a *adapter) GetName() string {
+ return adapterName
+}
+
+// SetMaxResults configures how many results can be returned in a single DB call.
+func (a *adapter) SetMaxResults(val int) error {
+ if val <= 0 {
+ a.maxResults = defaultMaxResults
+ } else {
+ a.maxResults = val
+ }
+
+ return nil
+}
+
+// CreateDb initializes the storage. If reset is true, the database is first deleted losing all the data.
+func (a *adapter) CreateDb(reset bool) error {
+
+ // Drop database if exists, ignore error if it does not.
+ if reset {
+ rdb.DBDrop(a.dbName).RunWrite(a.conn)
+ }
+
+ if _, err := rdb.DBCreate(a.dbName).RunWrite(a.conn); err != nil {
+ return err
+ }
+
+ // Table with metadata key-value pairs.
+ if _, err := rdb.DB(a.dbName).TableCreate("kvmeta", rdb.TableCreateOpts{PrimaryKey: "key"}).RunWrite(a.conn); err != nil {
+ return err
+ }
+
+ // Users
+ if _, err := rdb.DB(a.dbName).TableCreate("users", rdb.TableCreateOpts{PrimaryKey: "Id"}).RunWrite(a.conn); err != nil {
+ return err
+ }
+ // Create secondary index on State for finding suspended and soft-deleted users.
+ if _, err := rdb.DB(a.dbName).Table("users").IndexCreate("State").RunWrite(a.conn); err != nil {
+ return err
+ }
+ // Create secondary index on User.Tags array so user can be found by tags.
+ if _, err := rdb.DB(a.dbName).Table("users").IndexCreate("Tags", rdb.IndexCreateOpts{Multi: true}).RunWrite(a.conn); err != nil {
+ return err
+ }
+ // Create secondary index for User.Devices..DeviceId to ensure ID uniqueness across users
+ if _, err := rdb.DB(a.dbName).Table("users").IndexCreateFunc("DeviceIds",
+ func(row rdb.Term) any {
+ devices := row.Field("Devices")
+ return devices.Keys().Map(func(key rdb.Term) any {
+ return devices.Field(key).Field("DeviceId")
+ })
+ }, rdb.IndexCreateOpts{Multi: true}).RunWrite(a.conn); err != nil {
+ return err
+ }
+
+ // User authentication records {unique, userid, secret}
+ if _, err := rdb.DB(a.dbName).TableCreate("auth", rdb.TableCreateOpts{PrimaryKey: "unique"}).RunWrite(a.conn); err != nil {
+ return err
+ }
+ // Should be able to access user's auth records by user id
+ if _, err := rdb.DB(a.dbName).Table("auth").IndexCreate("userid").RunWrite(a.conn); err != nil {
+ return err
+ }
+
+ // Subscription to a topic. The primary key is a Topic:User string
+ if _, err := rdb.DB(a.dbName).TableCreate("subscriptions", rdb.TableCreateOpts{PrimaryKey: "Id"}).RunWrite(a.conn); err != nil {
+ return err
+ }
+ if _, err := rdb.DB(a.dbName).Table("subscriptions").IndexCreate("User").RunWrite(a.conn); err != nil {
+ return err
+ }
+ if _, err := rdb.DB(a.dbName).Table("subscriptions").IndexCreate("Topic").RunWrite(a.conn); err != nil {
+ return err
+ }
+
+ // Topics stored in database
+ if _, err := rdb.DB(a.dbName).TableCreate("topics", rdb.TableCreateOpts{PrimaryKey: "Id"}).RunWrite(a.conn); err != nil {
+ return err
+ }
+ // Secondary index on Owner field for deleting users.
+ if _, err := rdb.DB(a.dbName).Table("topics").IndexCreate("Owner").RunWrite(a.conn); err != nil {
+ return err
+ }
+ // Create secondary index on State for finding suspended and soft-deleted topics.
+ if _, err := rdb.DB(a.dbName).Table("topics").IndexCreate("State").RunWrite(a.conn); err != nil {
+ return err
+ }
+ // Secondary index on Topic.Tags array so topics can be found by tags.
+ // These tags are not unique as opposite to User.Tags.
+ if _, err := rdb.DB(a.dbName).Table("topics").IndexCreate("Tags", rdb.IndexCreateOpts{Multi: true}).RunWrite(a.conn); err != nil {
+ return err
+ }
+ // Create system topic 'sys'.
+ if err := createSystemTopic(a); err != nil {
+ return err
+ }
+
+ // Stored message
+ if _, err := rdb.DB(a.dbName).TableCreate("messages", rdb.TableCreateOpts{PrimaryKey: "Id"}).RunWrite(a.conn); err != nil {
+ return err
+ }
+ // Compound index of topic - seqID for selecting messages in a topic.
+ if _, err := rdb.DB(a.dbName).Table("messages").IndexCreateFunc("Topic_SeqId",
+ func(row rdb.Term) any {
+ return []any{row.Field("Topic"), row.Field("SeqId")}
+ }).RunWrite(a.conn); err != nil {
+ return err
+ }
+ // Compound index of hard-deleted messages
+ if _, err := rdb.DB(a.dbName).Table("messages").IndexCreateFunc("Topic_DelId",
+ func(row rdb.Term) any {
+ return []any{row.Field("Topic"), row.Field("DelId")}
+ }).RunWrite(a.conn); err != nil {
+ return err
+ }
+ // Compound multi-index of soft-deleted messages: each message gets multiple compound index entries like
+ // [Topic, User1, DelId1], [Topic, User2, DelId2],...
+ if _, err := rdb.DB(a.dbName).Table("messages").IndexCreateFunc("Topic_DeletedFor",
+ func(row rdb.Term) any {
+ return row.Field("DeletedFor").Map(func(df rdb.Term) any {
+ return []any{row.Field("Topic"), df.Field("User"), df.Field("DelId")}
+ })
+ }, rdb.IndexCreateOpts{Multi: true}).RunWrite(a.conn); err != nil {
+ return err
+ }
+
+ // Log of deleted messages
+ if _, err := rdb.DB(a.dbName).TableCreate("dellog", rdb.TableCreateOpts{PrimaryKey: "Id"}).RunWrite(a.conn); err != nil {
+ return err
+ }
+ if _, err := rdb.DB(a.dbName).Table("dellog").IndexCreateFunc("Topic_DelId",
+ func(row rdb.Term) any {
+ return []any{row.Field("Topic"), row.Field("DelId")}
+ }).RunWrite(a.conn); err != nil {
+ return err
+ }
+
+ // User credentials - contact information such as "email:jdoe@example.com" or "tel:+18003287448":
+ // Id: "method:credential" like "email:jdoe@example.com". See types.Credential.
+ if _, err := rdb.DB(a.dbName).TableCreate("credentials", rdb.TableCreateOpts{PrimaryKey: "Id"}).RunWrite(a.conn); err != nil {
+ return err
+ }
+ // Create secondary index on credentials.User to be able to query credentials by user id.
+ if _, err := rdb.DB(a.dbName).Table("credentials").IndexCreate("User").RunWrite(a.conn); err != nil {
+ return err
+ }
+
+ // Records of file uploads. See types.FileDef.
+ if _, err := rdb.DB(a.dbName).TableCreate("fileuploads", rdb.TableCreateOpts{PrimaryKey: "Id"}).RunWrite(a.conn); err != nil {
+ return err
+ }
+ // A secondary index on fileuploads.UseCount to be able to delete unused records at once.
+ if _, err := rdb.DB(a.dbName).Table("fileuploads").IndexCreate("UseCount").RunWrite(a.conn); err != nil {
+ return err
+ }
+
+ // Record current DB version.
+ if _, err := rdb.DB(a.dbName).Table("kvmeta").Insert(
+ map[string]any{"key": "version", "value": adpVersion}).RunWrite(a.conn); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// UpgradeDb upgrades the database to the latest version.
+func (a *adapter) UpgradeDb() error {
+ bumpVersion := func(a *adapter, x int) error {
+ if err := a.updateDbVersion(x); err != nil {
+ return err
+ }
+ _, err := a.GetDbVersion()
+ return err
+ }
+
+ _, err := a.GetDbVersion()
+ if err != nil {
+ return err
+ }
+
+ if a.version == 106 || a.version == 107 {
+ // Perform database upgrade from versions 106 or 107 to version 108.
+
+ // Replace default 'Auth' access mode JRWPA with JRWPAS
+ filter := map[string]any{"Access": map[string]any{"Auth": t.ModeCP2P}}
+ update := map[string]any{"Access": map[string]any{"Auth": t.ModeCAuth}}
+ if _, err := rdb.DB(a.dbName).Table("users").Filter(filter).Update(update).RunWrite(a.conn); err != nil {
+ return err
+ }
+
+ if err := bumpVersion(a, 108); err != nil {
+ return err
+ }
+ }
+
+ if a.version == 108 {
+ // Perform database upgrade from versions 108 to version 109.
+
+ if err := createSystemTopic(a); err != nil {
+ return err
+ }
+
+ if err := bumpVersion(a, 109); err != nil {
+ return err
+ }
+ }
+
+ if a.version == 109 {
+ // Perform database upgrade from versions 109 to version 110.
+
+ // TouchedAt is a required field now, but it's OK if it's missing.
+ // Bumping version to keep RDB in sync with MySQL versions.
+
+ if err := bumpVersion(a, 110); err != nil {
+ return err
+ }
+ }
+
+ if a.version == 110 {
+ // Perform database upgrade from versions 110 to version 111.
+
+ // Users
+
+ // Reset previously unused field State to value StateOK.
+ if _, err := rdb.DB(a.dbName).Table("users").
+ Update(map[string]any{"State": t.StateOK}).
+ RunWrite(a.conn); err != nil {
+ return err
+ }
+
+ // Add StatusDeleted to all deleted users as indicated by DeletedAt not being null.
+ if _, err := rdb.DB(a.dbName).Table("users").
+ Between(rdb.MinVal, rdb.MaxVal, rdb.BetweenOpts{Index: "DeletedAt"}).
+ Update(map[string]any{"State": t.StateDeleted}).
+ RunWrite(a.conn); err != nil {
+ return err
+ }
+
+ // Rename DeletedAt into StateAt. Update only those rows which have defined DeletedAt.
+ if _, err := rdb.DB(a.dbName).Table("users").
+ Between(rdb.MinVal, rdb.MaxVal, rdb.BetweenOpts{Index: "DeletedAt"}).
+ Replace(func(row rdb.Term) rdb.Term {
+ return row.Without("DeletedAt").
+ Merge(map[string]any{"StateAt": row.Field("DeletedAt")})
+ }).
+ RunWrite(a.conn); err != nil {
+ return err
+ }
+
+ // Drop secondary index DeletedAt.
+ if _, err := rdb.DB(a.dbName).Table("users").IndexDrop("DeletedAt").RunWrite(a.conn); err != nil {
+ return err
+ }
+
+ // Create secondary index on State for finding suspended and soft-deleted topics.
+ if _, err := rdb.DB(a.dbName).Table("users").IndexCreate("State").RunWrite(a.conn); err != nil {
+ return err
+ }
+
+ // Topics
+
+ // Add StateDeleted to all topics with DeletedAt not null.
+ if _, err := rdb.DB(a.dbName).Table("topics").
+ Filter(rdb.Row.HasFields("DeletedAt")).
+ Update(map[string]any{"State": t.StateDeleted}).
+ RunWrite(a.conn); err != nil {
+ return err
+ }
+
+ // Set StateOK for all other topics.
+ if _, err := rdb.DB(a.dbName).Table("topics").
+ Filter(rdb.Row.HasFields("State").Not()).
+ Update(map[string]any{"State": t.StateOK}).
+ RunWrite(a.conn); err != nil {
+ return err
+ }
+
+ // Rename DeletedAt into StateAt. Update only those rows which have defined DeletedAt.
+ if _, err := rdb.DB(a.dbName).Table("topics").
+ Filter(rdb.Row.HasFields("DeletedAt")).
+ Replace(func(row rdb.Term) rdb.Term {
+ return row.Without("DeletedAt").
+ Merge(map[string]any{"StateAt": row.Field("DeletedAt")})
+ }).
+ RunWrite(a.conn); err != nil {
+ return err
+ }
+
+ // Create secondary index on State for finding suspended and soft-deleted topics.
+ if _, err := rdb.DB(a.dbName).Table("topics").IndexCreate("State").RunWrite(a.conn); err != nil {
+ return err
+ }
+
+ if err := bumpVersion(a, 111); err != nil {
+ return err
+ }
+ }
+
+ if a.version == 111 {
+ // Just bump the version to keep up with MySQL.
+ if err := bumpVersion(a, 112); err != nil {
+ return err
+ }
+ }
+
+ if a.version == 112 {
+ // Secondary indexes cannot store NULLs, consequently no useful indexes can be created.
+ // Just bump the version.
+ if err := bumpVersion(a, 113); err != nil {
+ return err
+ }
+ }
+
+ if a.version < 116 {
+ // Version 114: topics.aux added, fileuploads.etag added.
+ // Version 115: SQL indexes added.
+ // Version 116: topics.subcnt added.
+
+ // Just bump the version.
+ if err := bumpVersion(a, 116); err != nil {
+ return err
+ }
+ }
+
+ if a.version != adpVersion {
+ return errors.New("Failed to perform database upgrade to version " + strconv.Itoa(adpVersion) +
+ ". DB is still at " + strconv.Itoa(a.version))
+ }
+ return nil
+}
+
+// Create system topic 'sys'.
+func createSystemTopic(a *adapter) error {
+ now := t.TimeNow()
+ _, err := rdb.DB(a.dbName).Table("topics").Insert(&t.Topic{
+ ObjHeader: t.ObjHeader{Id: "sys",
+ CreatedAt: now,
+ UpdatedAt: now},
+ TouchedAt: now,
+ Access: t.DefaultAccess{Auth: t.ModeNone, Anon: t.ModeNone},
+ Public: map[string]any{"fn": "System"},
+ }).RunWrite(a.conn)
+ return err
+}
+
+// UserCreate creates a new user. Returns error and true if error is due to duplicate user name,
+// false for any other error
+func (a *adapter) UserCreate(user *t.User) error {
+ _, err := rdb.DB(a.dbName).Table("users").Insert(&user).RunWrite(a.conn)
+ return err
+}
+
+// AuthAddRecord adds user's authentication record
+func (a *adapter) AuthAddRecord(uid t.Uid, scheme, unique string, authLvl auth.Level,
+ secret []byte, expires time.Time) error {
+
+ _, err := rdb.DB(a.dbName).Table("auth").Insert(
+ &common.AuthRecord{
+ Unique: unique,
+ UserId: uid.String(),
+ Scheme: scheme,
+ AuthLvl: authLvl,
+ Secret: secret,
+ Expires: expires}).RunWrite(a.conn)
+ if err != nil {
+ if rdb.IsConflictErr(err) {
+ return t.ErrDuplicate
+ }
+ return err
+ }
+ return nil
+}
+
+// AuthDelScheme deletes an existing authentication scheme for the user.
+func (a *adapter) AuthDelScheme(uid t.Uid, scheme string) error {
+ _, err := rdb.DB(a.dbName).Table("auth").
+ GetAllByIndex("userid", uid.String()).
+ Filter(map[string]any{"scheme": scheme}).
+ Delete().RunWrite(a.conn)
+ return err
+}
+
+// AuthDelAllRecords deletes user's all authentication records
+func (a *adapter) AuthDelAllRecords(uid t.Uid) (int, error) {
+ res, err := rdb.DB(a.dbName).Table("auth").GetAllByIndex("userid", uid.String()).Delete().RunWrite(a.conn)
+ return res.Deleted, err
+}
+
+// AuthUpdRecord updates user's authentication secret.
+func (a *adapter) AuthUpdRecord(uid t.Uid, scheme, unique string, authLvl auth.Level,
+ secret []byte, expires time.Time) error {
+ // The 'unique' is used as a primary key (no other way to ensure uniqueness in RethinkDB).
+ // The primary key is immutable. If 'unique' has changed, we have to replace the old record with a new one:
+ // 1. Check if 'unique' has changed.
+ // 2. If not, execute update by 'unique'
+ // 3. If yes, first insert the new record (it may fail due to dublicate 'unique') then delete the old one.
+
+ // Get the old 'unique'
+ cursor, err := rdb.DB(a.dbName).Table("auth").GetAllByIndex("userid", uid.String()).
+ Filter(map[string]any{"scheme": scheme}).
+ Pluck("unique").Default(nil).Run(a.conn)
+ if err != nil {
+ if isNoResults(err) {
+ return t.ErrNotFound
+ }
+ return err
+ }
+ defer cursor.Close()
+
+ if cursor.IsNil() {
+ // If the record is not found, don't update it
+ return t.ErrNotFound
+ }
+
+ var record common.AuthRecord
+ if err = cursor.One(&record); err != nil {
+ return err
+ }
+ if record.Unique == unique {
+ // Unique has not changed
+ upd := map[string]any{
+ "authLvl": authLvl,
+ }
+ if len(secret) > 0 {
+ upd["secret"] = secret
+ }
+ if !expires.IsZero() {
+ upd["expires"] = expires
+ }
+ _, err = rdb.DB(a.dbName).Table("auth").Get(unique).Update(upd).RunWrite(a.conn)
+ } else {
+ // Unique has changed. Insert-Delete.
+ // No support for transactions :(
+ if len(secret) == 0 {
+ secret = record.Secret
+ }
+ if expires.IsZero() {
+ expires = record.Expires
+ }
+ err = a.AuthAddRecord(uid, scheme, unique, authLvl, secret, expires)
+ if err == nil {
+ // We can't do much with the error here.
+ rdb.DB(a.dbName).Table("auth").Get(record.Unique).Delete().RunWrite(a.conn)
+ }
+ }
+ return err
+}
+
+// AuthGetRecord retrieves user's authentication record by user ID and scheme.
+func (a *adapter) AuthGetRecord(uid t.Uid, scheme string) (string, auth.Level, []byte, time.Time, error) {
+ // Default() is needed to prevent Pluck from returning an error
+ cursor, err := rdb.DB(a.dbName).Table("auth").GetAllByIndex("userid", uid.String()).
+ Filter(map[string]any{"scheme": scheme}).
+ Pluck("unique", "secret", "expires", "authLvl").Default(nil).Run(a.conn)
+ if err != nil {
+ return "", 0, nil, time.Time{}, err
+ }
+ defer cursor.Close()
+
+ if cursor.IsNil() {
+ return "", 0, nil, time.Time{}, t.ErrNotFound
+ }
+
+ var record struct {
+ Unique string `json:"unique"`
+ AuthLvl auth.Level `json:"authLvl"`
+ Secret []byte `json:"secret"`
+ Expires time.Time `json:"expires"`
+ }
+
+ if err = cursor.One(&record); err != nil {
+ return "", 0, nil, time.Time{}, err
+ }
+ // Convert to UTC (bug? in gorethink).
+ record.Expires = record.Expires.UTC()
+ return record.Unique, record.AuthLvl, record.Secret, record.Expires, nil
+}
+
+// AuthGetUniqueRecord retrieve user's authentication record by unique value (e.g. by login).
+func (a *adapter) AuthGetUniqueRecord(unique string) (t.Uid, auth.Level, []byte, time.Time, error) {
+ // Default() is needed to prevent Pluck from returning an error
+ cursor, err := rdb.DB(a.dbName).Table("auth").Get(unique).Pluck(
+ "userid", "secret", "expires", "authLvl").Default(nil).Run(a.conn)
+ if err != nil {
+ return t.ZeroUid, 0, nil, time.Time{}, err
+ }
+ defer cursor.Close()
+
+ if cursor.IsNil() {
+ return t.ZeroUid, 0, nil, time.Time{}, nil
+ }
+
+ var record struct {
+ Userid string `json:"userid"`
+ AuthLvl auth.Level `json:"authLvl"`
+ Secret []byte `json:"secret"`
+ Expires time.Time `json:"expires"`
+ }
+
+ if err = cursor.One(&record); err != nil {
+ return t.ZeroUid, 0, nil, time.Time{}, err
+ }
+
+ return t.ParseUid(record.Userid), record.AuthLvl, record.Secret, record.Expires.UTC(), nil
+}
+
+// UserGet fetches a single user by user id. If user is not found it returns (nil, nil)
+func (a *adapter) UserGet(uid t.Uid) (*t.User, error) {
+ cursor, err := rdb.DB(a.dbName).Table("users").GetAll(uid.String()).
+ Filter(rdb.Row.Field("State").Eq(t.StateDeleted).Not()).Run(a.conn)
+ if err != nil {
+ return nil, err
+ }
+ defer cursor.Close()
+
+ if cursor.IsNil() {
+ return nil, nil
+ }
+
+ var user t.User
+ if err = cursor.One(&user); err != nil {
+ return nil, err
+ }
+ return &user, nil
+}
+
+// UserGetAll fetches multiple user records by UIDs.
+func (a *adapter) UserGetAll(ids ...t.Uid) ([]t.User, error) {
+ uids := make([]any, len(ids))
+ for i, id := range ids {
+ uids[i] = id.String()
+ }
+
+ users := []t.User{}
+ cursor, err := rdb.DB(a.dbName).Table("users").GetAll(uids...).
+ Filter(rdb.Row.Field("State").Eq(t.StateDeleted).Not()).Run(a.conn)
+ if err != nil {
+ return nil, err
+ }
+ defer cursor.Close()
+
+ var user t.User
+ for cursor.Next(&user) {
+ // Convert timestamps to UTC (gorethink returns them as +0000)
+ user.CreatedAt = user.CreatedAt.UTC()
+ user.UpdatedAt = user.UpdatedAt.UTC()
+ if user.StateAt != nil {
+ stateAt := user.StateAt.UTC()
+ user.StateAt = &stateAt
+ }
+ users = append(users, user)
+ }
+
+ return users, cursor.Err()
+}
+
+// UserDelete deletes user record.
+func (a *adapter) UserDelete(uid t.Uid, hard bool) error {
+ // Get a list of topic names owned by the user (as 'grp' and 'chn').
+ ownTopics, err := a.topicNamesForUser(rdb.DB(a.dbName).Table("topics").
+ GetAllByIndex("Owner", uid.String()).Filter(rdb.Row.Field("State").Eq(t.StateDeleted).Not()).
+ Field("Id"), true)
+ if err != nil {
+ logs.Err.Println("UserDelete: cannot get user's own topics:", err)
+ return err
+ }
+
+ if hard {
+ // User's devices are store in user record, no separate table.
+
+ // Delete user's subscriptions in all topics.
+ if err = a.subsDelForUser(uid, true); err != nil {
+ return err
+ }
+
+ // Delete records of messages soft-deleted for the user in all topics
+ // and dellog entries.
+ if err = a.clearUserDellog(uid, nil); err != nil {
+ return err
+ }
+
+ // Can't delete user's messages in all topics because we cannot notify topics of such deletion.
+ // Just leave the messages marked as sent by "not found" user.
+
+ // Delete topics where the user is the owner:
+
+ if len(ownTopics) > 0 {
+ // 1. Delete dellog
+ // 2. Decrement use counter of fileuploads: topic itself and messages.
+ // 3. Delete all messages.
+ // 4. Delete subscriptions.
+ if _, err = rdb.DB(a.dbName).Table("topics").GetAll(ownTopics...).ForEach(
+ func(topic rdb.Term) rdb.Term {
+ return rdb.Expr([]any{
+ // Delete dellog
+ rdb.DB(a.dbName).Table("dellog").Between(
+ []any{topic.Field("Id"), rdb.MinVal},
+ []any{topic.Field("Id"), rdb.MaxVal},
+ rdb.BetweenOpts{Index: "Topic_DelId"}).Delete(),
+ // Decrement topic attachment UseCounter
+ rdb.DB(a.dbName).Table("fileuploads").GetAll(topic.Field("Attachments")).
+ Update(func(fu rdb.Term) any {
+ return map[string]any{"UseCount": fu.Field("UseCount").Default(1).Sub(1)}
+ }),
+ // Decrement message attachments UseCounter
+ rdb.DB(a.dbName).Table("fileuploads").GetAll(
+ rdb.Args(
+ rdb.DB(a.dbName).Table("messages").Between(
+ []any{topic.Field("Id"), rdb.MinVal},
+ []any{topic.Field("Id"), rdb.MaxVal},
+ rdb.BetweenOpts{Index: "Topic_SeqId"}).
+ // Fetch messages with attachments only
+ Filter(func(msg rdb.Term) rdb.Term {
+ return msg.HasFields("Attachments")
+ }).
+ // Flatten arrays
+ ConcatMap(func(row rdb.Term) any { return row.Field("Attachments") }).
+ CoerceTo("array"))).
+ Update(func(fu rdb.Term) any {
+ return map[string]any{"UseCount": fu.Field("UseCount").Default(1).Sub(1)}
+ }),
+ // Delete messages
+ rdb.DB(a.dbName).Table("messages").Between(
+ []any{topic.Field("Id"), rdb.MinVal},
+ []any{topic.Field("Id"), rdb.MaxVal},
+ rdb.BetweenOpts{Index: "Topic_SeqId"}).Delete(),
+ // Delete subscriptions
+ rdb.DB(a.dbName).Table("subscriptions").
+ GetAllByIndex("Topic", topic.Field("Id")).Delete(),
+ })
+ }).RunWrite(a.conn); err != nil {
+ return err
+ }
+
+ // And finally delete the topics.
+ if _, err = rdb.DB(a.dbName).Table("topics").GetAllByIndex("Owner", uid.String()).
+ Delete().RunWrite(a.conn); err != nil {
+ return err
+ }
+ }
+
+ // Delete user's authentication records.
+ if _, err = a.AuthDelAllRecords(uid); err != nil {
+ return err
+ }
+
+ // Delete credentials.
+ if err = a.CredDel(uid, "", ""); err != nil && err != t.ErrNotFound {
+ return err
+ }
+
+ // Must use GetAll to produce array result expected by decFileUseCounter.
+ q := rdb.DB(a.dbName).Table("users").GetAll(uid.String())
+
+ // Unlink user's attachment.
+ if err = a.decFileUseCounter(q); err != nil {
+ return err
+ }
+
+ // And finally delete the user.
+ _, err = q.Delete().RunWrite(a.conn)
+ } else {
+ // Disable user's subscriptions.
+ if err = a.subsDelForUser(uid, false); err != nil {
+ logs.Err.Println("UserDelete: subsDelForUser:", err)
+ return err
+ }
+
+ now := t.TimeNow()
+ disable := map[string]any{
+ "UpdatedAt": now,
+ "State": t.StateDeleted,
+ "StateAt": now,
+ }
+ disableSub := map[string]any{
+ "UpdatedAt": now,
+ "DeletedAt": now,
+ }
+ if len(ownTopics) > 0 {
+ // Disable all subscriptions in topics where the user is the owner.
+ if _, err = rdb.DB(a.dbName).Table("subscriptions").
+ GetAllByIndex("Topic", ownTopics...).
+ Update(disableSub).
+ RunWrite(a.conn); err != nil {
+ return err
+ }
+
+ // Disable topics where the user is the owner.
+ if _, err = rdb.DB(a.dbName).Table("topics").
+ GetAll(ownTopics...).
+ Update(disable).
+ RunWrite(a.conn); err != nil {
+ return err
+ }
+ }
+
+ // Disable p2p topics with the user.
+ p2pTopics, err := a.p2pTopicsForUser(uid)
+ if err != nil {
+ logs.Err.Println("UserDelete: p2pTopics:", err)
+ return err
+ }
+ if len(p2pTopics) > 0 {
+ // Disable all subscriptions in p2p topics with the user.
+ if _, err = rdb.DB(a.dbName).Table("subscriptions").
+ GetAllByIndex("Topic", p2pTopics...).
+ Update(disableSub).
+ RunWrite(a.conn); err != nil {
+ return err
+ }
+ // Disable p2p topics with the user.
+ if _, err = rdb.DB(a.dbName).Table("topics").
+ GetAll(p2pTopics...).
+ Update(disable).
+ RunWrite(a.conn); err != nil {
+ return err
+ }
+ }
+
+ // Disable the user (same fields as topic).
+ _, err = rdb.DB(a.dbName).Table("users").Get(uid.String()).
+ Update(disable).RunWrite(a.conn)
+ }
+ return err
+}
+
+// Delete records of messages soft-deleted for the user in all topics.
+func (a *adapter) clearUserDellog(uid t.Uid, topics []any) error {
+ var err error
+ forUser := uid.String()
+ if topics == nil {
+ // Get a list of all topics where the user has subscriptions.
+ topics, err = a.topicNamesForUser(rdb.DB(a.dbName).
+ Table("subscriptions").
+ GetAllByIndex("User", forUser).
+ Field("Topic"), false)
+ if err != nil {
+ return err
+ }
+ }
+
+ // No need to convert channel names to group names:
+ // channel readers cannot delete messages.
+
+ // Remove current user from the messages' soft-deletion lists
+ // in all topics where the user has subscriptions.
+ _, err = rdb.DB(a.dbName).Table("topics").GetAll(topics...).
+ ForEach(func(topic rdb.Term) rdb.Term {
+ return rdb.DB(a.dbName).Table("messages").Between(
+ []any{topic.Field("Id"), forUser, rdb.MinVal},
+ []any{topic.Field("Id"), forUser, rdb.MaxVal},
+ rdb.BetweenOpts{Index: "Topic_DeletedFor"}).
+ Update(map[string]any{
+ // Take the DeletedFor array, subtract all values which contain current user ID in 'User' field.
+ "DeletedFor": func(msg rdb.Term) rdb.Term {
+ return msg.Field("DeletedFor").
+ SetDifference(msg.Field("DeletedFor").Filter(map[string]any{"User": forUser}))
+ },
+ })
+ }).RunWrite(a.conn)
+ if err != nil {
+ return err
+ }
+
+ // Delete entries in dellog for this user in all topics where the user
+ // has subscriptions.
+ _, err = rdb.DB(a.dbName).Table("topics").GetAll(topics...).
+ ForEach(func(topic rdb.Term) rdb.Term {
+ return rdb.DB(a.dbName).Table("dellog").
+ // Select all log entries for the given table.
+ Between(
+ []any{topic.Field("Id"), rdb.MinVal},
+ []any{topic.Field("Id"), rdb.MaxVal},
+ rdb.BetweenOpts{Index: "Topic_DelId"}).
+ // Keep for deletion entries soft-deleted for the current user only.
+ Filter(func(dle rdb.Term) rdb.Term { return dle.Field("DeletedFor").Eq(forUser) }).
+ // Delete them.
+ Delete()
+ }).RunWrite(a.conn)
+
+ return err
+}
+
+// topicNamesForUser returns a list of topic names by query.
+func (a *adapter) topicNamesForUser(query rdb.Term, includeChan bool) ([]any, error) {
+ cursor, err := query.Run(a.conn)
+ if err != nil {
+ if isNoResults(err) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ defer cursor.Close()
+
+ var result []string
+ if err = cursor.All(&result); err != nil {
+ return nil, err
+ }
+
+ var args []any
+ for _, name := range result {
+ args = append(args, name)
+ if includeChan {
+ // Append 'chn' topic names for each 'grp' name.
+ if channel := t.GrpToChn(name); channel != "" {
+ args = append(args, channel)
+ }
+ }
+ }
+ return args, nil
+}
+
+func (a *adapter) p2pTopicsForUser(uid t.Uid) ([]any, error) {
+ return a.topicNamesForUser(rdb.DB(a.dbName).Table("subscriptions").
+ GetAllByIndex("User", uid.String()).
+ Field("Topic").
+ Filter(rdb.Row.Field("Topic").Match("^p2p")), false)
+}
+
+// topicStateForUser is called by UserUpdate when the update contains state change.
+func (a *adapter) topicStateForUser(uid t.Uid, now time.Time, update any) error {
+ state, ok := update.(t.ObjState)
+ if !ok {
+ return t.ErrMalformed
+ }
+
+ if now.IsZero() {
+ now = t.TimeNow()
+ }
+
+ // Change state of all topics where the user is the owner.
+ if _, err := rdb.DB(a.dbName).Table("topics").
+ GetAllByIndex("Owner", uid.String()).
+ Filter(rdb.Row.Field("State").Eq(t.StateDeleted).Not()).
+ Update(map[string]any{
+ "State": state,
+ "StateAt": now,
+ }).RunWrite(a.conn); err != nil {
+ return err
+ }
+
+ // Change state of p2p topics with the user (p2p topic's owner is blank)
+ /*
+ r.db('tinode').table('topics').getAll(
+ r.args(
+ r.db("tinode").table("subscriptions").getAll('S8VFqRpXw5M', {index: 'User'})('Topic').coerceTo('array')
+ )
+ ).update(...)
+ */
+ if _, err := rdb.DB(a.dbName).Table("topics").
+ GetAll(rdb.Args(
+ rdb.DB(a.dbName).Table("subscriptions").GetAllByIndex("User", uid.String()).
+ Field("Topic").CoerceTo("array"))).
+ Filter(rdb.Row.Field("Owner").Eq("").And(rdb.Row.Field("State").Eq(t.StateDeleted).Not())).
+ Update(map[string]any{
+ "State": state,
+ "StateAt": now,
+ }).RunWrite(a.conn); err != nil {
+ return err
+ }
+
+ // Subscriptions don't need to be updated:
+ // subscriptions of a disabled user are not disabled and still can be manipulated.
+
+ return nil
+}
+
+// UserUpdate updates user object.
+func (a *adapter) UserUpdate(uid t.Uid, update map[string]any) error {
+ _, err := rdb.DB(a.dbName).Table("users").Get(uid.String()).Update(update).RunWrite(a.conn)
+ if err != nil {
+ return err
+ }
+
+ if state, ok := update["State"]; ok {
+ now, _ := update["StateAt"].(time.Time)
+ err = a.topicStateForUser(uid, now, state)
+ }
+
+ return err
+}
+
+// UserUpdateTags append or resets user's tags
+func (a *adapter) UserUpdateTags(uid t.Uid, add, remove, reset []string) ([]string, error) {
+ // Compare to nil vs checking for zero length: zero length reset is valid.
+ if reset != nil {
+ // Replace Tags with the new value
+ return reset, a.UserUpdate(uid, map[string]any{"Tags": reset})
+ }
+
+ // Mutate the tag list.
+
+ newTags := rdb.Row.Field("Tags")
+ if len(add) > 0 {
+ newTags = newTags.SetUnion(add)
+ }
+ if len(remove) > 0 {
+ newTags = newTags.SetDifference(remove)
+ }
+
+ q := rdb.DB(a.dbName).Table("users").Get(uid.String())
+ _, err := q.Update(map[string]any{"Tags": newTags}).RunWrite(a.conn)
+ if err != nil {
+ return nil, err
+ }
+
+ // Get the new tags.
+ // Using Pluck instead of Field because of https://github.com/rethinkdb/rethinkdb-go/issues/486
+ cursor, err := q.Pluck("Tags").Run(a.conn)
+ if err != nil {
+ return nil, err
+ }
+ defer cursor.Close()
+
+ var tagsField struct{ Tags []string }
+ err = cursor.One(&tagsField)
+ if err != nil {
+ return nil, err
+ }
+ if len(tagsField.Tags) == 0 {
+ tagsField.Tags = nil
+ }
+ return tagsField.Tags, nil
+}
+
+// UserGetByCred returns user ID for the given validated credential.
+func (a *adapter) UserGetByCred(method, value string) (t.Uid, error) {
+ cursor, err := rdb.DB(a.dbName).Table("credentials").Get(method + ":" + value).Field("User").Default(nil).Run(a.conn)
+ if err != nil {
+ return t.ZeroUid, err
+ }
+ defer cursor.Close()
+
+ if cursor.IsNil() {
+ return t.ZeroUid, nil
+ }
+
+ var userId string
+ if err = cursor.One(&userId); err != nil {
+ return t.ZeroUid, err
+ }
+
+ return t.ParseUid(userId), nil
+}
+
+// UserUnreadCount returns the total number of unread messages in all topics with
+// the R permission. If read fails, the counts are still returned with the original
+// user IDs but with the unread count undefined and non-nil error.
+// UserUnreadCount does not count unread messages in channels although it should.
+func (a *adapter) UserUnreadCount(ids ...t.Uid) (map[t.Uid]int, error) {
+ // The call expects user IDs to be plain strings like "356zaYaumiU".
+ uids := make([]any, len(ids))
+ counts := make(map[t.Uid]int, len(ids))
+ for i, id := range ids {
+ uids[i] = id.String()
+ // Ensure all original uids are always present.
+ counts[id] = 0
+ }
+
+ /*
+ Query:
+ r.db("tinode").table("subscriptions").getAll("356zaYaumiU", "k4cvfaq8zCQ", {index: "User"})
+ .eqJoin("Topic", r.db("tinode").table("topics"), {index: "Id"})
+ .filter(
+ r.not(r.row.hasFields({"left": "DeletedAt"}).or(r.row("right")("State").eq(20)))
+ )
+ .zip()
+ .pluck("User", "ReadSeqId", "ModeWant", "ModeGiven", "SeqId")
+ .filter(r.js('(function(row) {return row.ModeWant&row.ModeGiven&1 > 0;})'))
+ .group("User")
+ .sum(function(x) {return x.getField("SeqId").sub(x.getField("ReadSeqId"));})
+
+ Result:
+ [{group: "356zaYaumiU", reduction: 1}, {group: "k4cvfaq8zCQ", reduction: 0}]
+ */
+ cursor, err := rdb.DB(a.dbName).Table("subscriptions").GetAllByIndex("User", uids...).
+ EqJoin("Topic", rdb.DB(a.dbName).Table("topics"), rdb.EqJoinOpts{Index: "Id"}).
+ // left: subscription; right: topic.
+ Filter(
+ rdb.Not(rdb.Row.HasFields(map[string]any{"left": "DeletedAt"}).
+ Or(rdb.Row.Field("right").Field("State").Eq(t.StateDeleted)))).
+ Zip().
+ Pluck("User", "ReadSeqId", "ModeWant", "ModeGiven", "SeqId").
+ Filter(rdb.JS("(function(row) {return (row.ModeWant & row.ModeGiven & " + strconv.Itoa(int(t.ModeRead)) + ") > 0;})")).
+ Group("User").
+ Sum(func(row rdb.Term) rdb.Term { return row.Field("SeqId").Sub(row.Field("ReadSeqId")) }).
+ Run(a.conn)
+ if err != nil {
+ return counts, err
+ }
+ defer cursor.Close()
+
+ var oneCount struct {
+ Group string
+ Reduction int
+ }
+ for cursor.Next(&oneCount) {
+ counts[t.ParseUid(oneCount.Group)] = oneCount.Reduction
+ }
+ err = cursor.Err()
+
+ return counts, err
+}
+
+// UserGetUnvalidated returns a list of uids which have never logged in, have no
+// validated credentials and haven't been updated since lastUpdatedBefore.
+func (a *adapter) UserGetUnvalidated(lastUpdatedBefore time.Time, limit int) ([]t.Uid, error) {
+ /*
+ Query:
+ r.db('tinode').table('users')
+ .filter(r.row('LastSeen').eq(null).and(r.row('UpdatedAt').lt('Mar 31 2022 01:03:38')))
+ .eqJoin('Id', r.db('tinode').table('credentials'), {index: 'User'}).zip()
+ .pluck('User', 'Done')
+ .group('User')
+ .sum(function(row) {return r.branch(row('Done'), 1, 0)})
+ .ungroup()
+ .filter({reduction: 0})
+ .pluck('group').limit(10)
+
+ Result: [{"group": "3W1hPuHjobg"}, {"group": "Fh_skXNRhVg"}, {"group": "NqMZzq0ajWk"}]
+ */
+ cursor, err := rdb.DB(a.dbName).Table("users").
+ Filter(rdb.Row.Field("LastSeen").Eq(nil).And(rdb.Row.Field("UpdatedAt").Lt(lastUpdatedBefore))).
+ EqJoin("Id", rdb.DB(a.dbName).Table("credentials"), rdb.EqJoinOpts{Index: "User"}).Zip().
+ Pluck("User", "Done").
+ Group("User").
+ Sum(func(row rdb.Term) rdb.Term { return rdb.Branch(row.Field("Done"), 1, 0) }).
+ Ungroup().
+ Filter(rdb.Row.Field("reduction").Eq(0)).
+ Pluck("group").
+ Limit(limit).
+ Run(a.conn)
+
+ if err != nil {
+ return nil, err
+ }
+ defer cursor.Close()
+
+ var rec struct {
+ Group string
+ }
+
+ var uids []t.Uid
+ for cursor.Next(&rec) {
+ uid := t.ParseUid(rec.Group)
+ if !uid.IsZero() {
+ uids = append(uids, uid)
+ } else {
+ return nil, errors.New("bad uid field")
+ }
+ }
+
+ err = cursor.Err()
+
+ return uids, err
+}
+
+// TopicCreate creates a topic from template
+func (a *adapter) TopicCreate(topic *t.Topic) error {
+ _, err := rdb.DB(a.dbName).Table("topics").Insert(&topic).RunWrite(a.conn)
+ return err
+}
+
+// TopicCreateP2P given two users creates a p2p topic
+func (a *adapter) TopicCreateP2P(initiator, invited *t.Subscription) error {
+ initiator.Id = initiator.Topic + ":" + initiator.User
+ // Don't care if the initiator changes own subscription
+ _, err := rdb.DB(a.dbName).Table("subscriptions").Insert(initiator, rdb.InsertOpts{Conflict: "replace"}).
+ RunWrite(a.conn)
+ if err != nil {
+ return err
+ }
+
+ // If the second subscription exists, don't overwrite it. Just make sure it's not deleted.
+ invited.Id = invited.Topic + ":" + invited.User
+ _, err = rdb.DB(a.dbName).Table("subscriptions").Insert(invited, rdb.InsertOpts{Conflict: "error"}).
+ RunWrite(a.conn)
+ if err != nil {
+ // Is this a duplicate subscription?
+ if !rdb.IsConflictErr(err) {
+ // It's a genuine DB error
+ return err
+ }
+ // Undelete the second subsription if it exists: remove DeletedAt, update CreatedAt and UpdatedAt,
+ // update ModeGiven.
+ _, err = rdb.DB(a.dbName).Table("subscriptions").
+ Get(invited.Id).Replace(
+ rdb.Row.Without("DeletedAt").
+ Merge(map[string]any{
+ "CreatedAt": invited.CreatedAt,
+ "UpdatedAt": invited.UpdatedAt,
+ "ModeGiven": invited.ModeGiven})).
+ RunWrite(a.conn)
+ if err != nil {
+ return err
+ }
+ }
+
+ topic := &t.Topic{ObjHeader: t.ObjHeader{Id: initiator.Topic}}
+ topic.ObjHeader.MergeTimes(&initiator.ObjHeader)
+ topic.TouchedAt = initiator.GetTouchedAt()
+ return a.TopicCreate(topic)
+}
+
+// TopicGet loads a single topic by name, if it exists. If the topic does not exist the call returns (nil, nil)
+func (a *adapter) TopicGet(topic string) (*t.Topic, error) {
+ // Fetch topic by name
+ cursor, err := rdb.DB(a.dbName).Table("topics").Get(topic).Run(a.conn)
+ if err != nil {
+ return nil, err
+ }
+
+ var tt = new(t.Topic)
+ if err = cursor.One(tt); err != nil {
+ if err == rdb.ErrEmptyResult {
+ err = nil // No error if topic is not found.
+ }
+ return nil, err
+ }
+
+ // The cursor is automatically closed by executing cursor.One.
+
+ if t.GetTopicCat(topic) == t.TopicCatGrp {
+ // Topic found, get subsription count. Try both topic and channel names.
+ if cursor, err = rdb.DB(a.dbName).Table("subscriptions").
+ GetAllByIndex("Topic", topic, t.GrpToChn(topic)).
+ Filter(rdb.Row.HasFields("DeletedAt").Not()).
+ Count().Run(a.conn); err != nil {
+ return nil, err
+ }
+ subCnt := 0
+ if err = cursor.One(&subCnt); err != nil {
+ return nil, err
+ }
+ // No need to close the cursor.
+
+ if subCnt != tt.SubCnt {
+ // Update the topic with the correct subscription count.
+ tt.SubCnt = subCnt
+ if _, err = rdb.DB(a.dbName).Table("topics").Get(topic).
+ Update(map[string]any{"SubCnt": subCnt}).RunWrite(a.conn); err != nil {
+ return nil, err
+ }
+ }
+ }
+ // RethinkDB go driver incorrectly converts UTC timezone to +0000
+ tt.CreatedAt = tt.CreatedAt.UTC()
+ tt.UpdatedAt = tt.UpdatedAt.UTC()
+ tt.TouchedAt = tt.TouchedAt.UTC()
+ if tt.StateAt != nil {
+ stateAt := tt.StateAt.UTC()
+ tt.StateAt = &stateAt
+ }
+
+ return tt, nil
+}
+
+// TopicsForUser loads user's contact list: p2p and grp topics, except for 'me' & 'fnd' subscriptions.
+// Reads and denormalizes Public value.
+func (a *adapter) TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) {
+ // Fetch ALL user's subscriptions, even those which has not been modified recently.
+ // We are going to use these subscriptions to fetch topics and users which may have been modified recently.
+ q := rdb.DB(a.dbName).Table("subscriptions").GetAllByIndex("User", uid.String())
+ if !keepDeleted {
+ // Filter out rows with defined DeletedAt
+ q = q.Filter(rdb.Row.HasFields("DeletedAt").Not())
+ }
+
+ limit := 0
+ ims := time.Time{}
+ if opts != nil {
+ if opts.Topic != "" {
+ q = q.Filter(rdb.Row.Field("Topic").Eq(opts.Topic))
+ }
+
+ // Apply the limit only when the client does not manage the cache (or cold start).
+ // Otherwise have to get all subscriptions and do a manual join with users/topics.
+ if opts.IfModifiedSince == nil {
+ if opts.Limit > 0 && opts.Limit < a.maxResults {
+ limit = opts.Limit
+ } else {
+ limit = a.maxResults
+ }
+ } else {
+ ims = *opts.IfModifiedSince
+ }
+ } else {
+ limit = a.maxResults
+ }
+
+ if limit > 0 {
+ q = q.Limit(limit)
+ }
+
+ cursor, err := q.Run(a.conn)
+ if err != nil {
+ return nil, err
+ }
+
+ // Fetch subscriptions. Two queries are needed: users table (me & p2p) and topics table (p2p and grp).
+ // Prepare a list of Separate subscriptions to users vs topics
+ var sub t.Subscription
+ join := make(map[string]t.Subscription) // Keeping these to make a join with table for .private and .access
+ topq := make([]any, 0, 16)
+ usrq := make([]any, 0, 16)
+ for cursor.Next(&sub) {
+ tname := sub.Topic
+ sub.User = uid.String()
+ tcat := t.GetTopicCat(tname)
+
+ if tcat == t.TopicCatMe || tcat == t.TopicCatFnd {
+ // 'me' or 'fnd' subscription, skip. Don't skip 'sys'.
+ continue
+ } else if tcat == t.TopicCatP2P {
+ // P2P subscription, find the other user to get user.Public
+ uid1, uid2, _ := t.ParseP2P(sub.Topic)
+ if uid1 == uid {
+ usrq = append(usrq, uid2.String())
+ sub.SetWith(uid2.UserId())
+ } else {
+ usrq = append(usrq, uid1.String())
+ sub.SetWith(uid1.UserId())
+ }
+ } else if tcat == t.TopicCatGrp {
+ // Maybe convert channel name to topic name.
+ tname = t.ChnToGrp(tname)
+ }
+ // No special handling needed for 'slf', 'sys' subscriptions.
+
+ topq = append(topq, tname)
+ join[tname] = sub
+ }
+ err = cursor.Err()
+ cursor.Close()
+
+ if err != nil {
+ return nil, err
+ }
+
+ var subs []t.Subscription
+ if len(join) == 0 {
+ return subs, nil
+ }
+
+ if len(topq) > 0 {
+ // Fetch grp & p2p topics
+ q = rdb.DB(a.dbName).Table("topics").GetAll(topq...)
+ if !keepDeleted {
+ q = q.Filter(rdb.Row.Field("State").Eq(t.StateDeleted).Not())
+ }
+
+ if !ims.IsZero() {
+ // Use cache timestamp if provided: get newer entries only.
+ q = q.Filter(rdb.Row.Field("TouchedAt").Gt(ims))
+
+ if limit > 0 && limit < len(topq) {
+ // No point in fetching more than the requested limit.
+ q = q.OrderBy("TouchedAt").Limit(limit)
+ }
+ }
+
+ cursor, err = q.Run(a.conn)
+ if err != nil {
+ return nil, err
+ }
+
+ var top t.Topic
+ for cursor.Next(&top) {
+ sub = join[top.Id]
+ // Check if sub.UpdatedAt needs to be adjusted to earlier or later time.
+ // top.UpdatedAt is guaranteed to be after IMS if IMS is non-zero.
+ sub.UpdatedAt = common.SelectLatestTime(sub.UpdatedAt, top.UpdatedAt)
+ sub.SetState(top.State)
+ sub.SetTouchedAt(top.TouchedAt)
+ sub.SetSeqId(top.SeqId)
+ if t.GetTopicCat(sub.Topic) == t.TopicCatGrp {
+ sub.SetSubCnt(top.SubCnt)
+ sub.SetPublic(top.Public)
+ sub.SetTrusted(top.Trusted)
+ }
+ // Put back the updated value of a subsription, will process further below.
+ join[top.Id] = sub
+ }
+ err = cursor.Err()
+ cursor.Close()
+
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ // Fetch p2p users and join to p2p subscriptions.
+ if len(usrq) > 0 {
+ q = rdb.DB(a.dbName).Table("users").GetAll(usrq...)
+ if !keepDeleted {
+ // Optionally skip deleted users.
+ q = q.Filter(rdb.Row.Field("State").Eq(t.StateDeleted).Not())
+ }
+
+ // Ignoring ims: we need all users to get LastSeen and UserAgent.
+
+ cursor, err = q.Run(a.conn)
+ if err != nil {
+ return nil, err
+ }
+
+ var usr2 t.User
+ for cursor.Next(&usr2) {
+ joinOn := uid.P2PName(t.ParseUid(usr2.Id))
+ if sub, ok := join[joinOn]; ok {
+ sub.UpdatedAt = common.SelectLatestTime(sub.UpdatedAt, usr2.UpdatedAt)
+ sub.SetState(usr2.State)
+ sub.SetPublic(usr2.Public)
+ sub.SetTrusted(usr2.Trusted)
+ sub.SetDefaultAccess(usr2.Access.Auth, usr2.Access.Anon)
+ sub.SetLastSeenAndUA(usr2.LastSeen, usr2.UserAgent)
+ join[joinOn] = sub
+ }
+ }
+ err = cursor.Err()
+ cursor.Close()
+
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ subs = make([]t.Subscription, 0, len(join))
+ for _, sub := range join {
+ subs = append(subs, sub)
+ }
+
+ return common.SelectEarliestUpdatedSubs(subs, opts, a.maxResults), nil
+}
+
+// UsersForTopic loads users subscribed to the given topic (not channel readers).
+// The difference between UsersForTopic vs SubsForTopic is that the former loads user.Public,
+// the latter does not.
+func (a *adapter) UsersForTopic(topic string, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) {
+ tcat := t.GetTopicCat(topic)
+
+ // Fetch topic subscribers
+ // Fetch all subscribed users. The number of users is not large
+ q := rdb.DB(a.dbName).Table("subscriptions").GetAllByIndex("Topic", topic)
+ if !keepDeleted && tcat != t.TopicCatP2P {
+ // Filter out rows with DeletedAt being not null.
+ // P2P topics must load all subscriptions otherwise it will be impossible
+ // to swap Public values.
+ q = q.Filter(rdb.Row.HasFields("DeletedAt").Not())
+ }
+
+ limit := a.maxResults
+ var oneUser t.Uid
+ if opts != nil {
+ // Ignore IfModifiedSince - we must return all entries
+ // Those unmodified will be stripped of Public & Private.
+
+ if !opts.User.IsZero() {
+ if tcat != t.TopicCatP2P {
+ q = q.Filter(rdb.Row.Field("User").Eq(opts.User.String()))
+ }
+ oneUser = opts.User
+ }
+ if opts.Limit > 0 && opts.Limit < limit {
+ limit = opts.Limit
+ }
+ }
+ q = q.Limit(limit)
+
+ cursor, err := q.Run(a.conn)
+ if err != nil {
+ return nil, err
+ }
+
+ // Fetch subscriptions
+ var sub t.Subscription
+ var subs []t.Subscription
+ join := make(map[string]t.Subscription)
+ usrq := make([]any, 0, 16)
+ for cursor.Next(&sub) {
+ join[sub.User] = sub
+ usrq = append(usrq, sub.User)
+ }
+ cursor.Close()
+
+ if len(usrq) > 0 {
+ subs = make([]t.Subscription, 0, len(usrq))
+
+ // Fetch users by a list of subscriptions
+ cursor, err = rdb.DB(a.dbName).Table("users").GetAll(usrq...).
+ Filter(rdb.Row.Field("State").Eq(t.StateDeleted).Not()).Run(a.conn)
+ if err != nil {
+ return nil, err
+ }
+
+ var usr t.User
+ for cursor.Next(&usr) {
+ if sub, ok := join[usr.Id]; ok {
+ sub.ObjHeader.MergeTimes(&usr.ObjHeader)
+ sub.SetPublic(usr.Public)
+ sub.SetTrusted(usr.Trusted)
+ sub.SetLastSeenAndUA(usr.LastSeen, usr.UserAgent)
+ subs = append(subs, sub)
+ }
+ }
+ cursor.Close()
+ }
+
+ if t.GetTopicCat(topic) == t.TopicCatP2P && len(subs) > 0 {
+ // Swap public values & lastSeen of P2P topics as expected.
+ if len(subs) == 1 {
+ // User is deleted. Nothing we can do.
+ subs[0].SetPublic(nil)
+ subs[0].SetTrusted(nil)
+ subs[0].SetLastSeenAndUA(nil, "")
+ } else {
+ tmp := subs[0].GetPublic()
+ subs[0].SetPublic(subs[1].GetPublic())
+ subs[1].SetPublic(tmp)
+
+ tmp = subs[0].GetTrusted()
+ subs[0].SetTrusted(subs[1].GetTrusted())
+ subs[1].SetTrusted(tmp)
+
+ lastSeen := subs[0].GetLastSeen()
+ userAgent := subs[0].GetUserAgent()
+ subs[0].SetLastSeenAndUA(subs[1].GetLastSeen(), subs[1].GetUserAgent())
+ subs[1].SetLastSeenAndUA(lastSeen, userAgent)
+ }
+
+ // Remove deleted and unneeded subscriptions
+ if !keepDeleted || !oneUser.IsZero() {
+ var xsubs []t.Subscription
+ for i := range subs {
+ if (subs[i].DeletedAt != nil && !keepDeleted) || (!oneUser.IsZero() && subs[i].Uid() != oneUser) {
+ continue
+ }
+ xsubs = append(xsubs, subs[i])
+ }
+ subs = xsubs
+ }
+ }
+
+ return subs, nil
+}
+
+// OwnTopics loads a slice of topic names where the user is the owner.
+func (a *adapter) OwnTopics(uid t.Uid) ([]string, error) {
+ cursor, err := rdb.DB(a.dbName).Table("topics").GetAllByIndex("Owner", uid.String()).
+ Filter(rdb.Row.Field("State").Eq(t.StateDeleted).Not()).Field("Id").Run(a.conn)
+ if err != nil {
+ return nil, err
+ }
+ var names []string
+ var name string
+ for cursor.Next(&name) {
+ names = append(names, name)
+ }
+ cursor.Close()
+ return names, nil
+}
+
+// ChannelsForUser loads a slice of topic names where the user is a channel reader and notifications (P) are enabled.
+func (a *adapter) ChannelsForUser(uid t.Uid) ([]string, error) {
+ cursor, err := rdb.DB(a.dbName).Table("subscriptions").
+ GetAllByIndex("User", uid.String()).
+ Filter(rdb.Row.HasFields("DeletedAt").Not()).
+ Filter(rdb.Row.Field("Topic").Match("^chn")).
+ Filter(rdb.JS("(function(row) {return (row.ModeWant & row.ModeGiven & " + strconv.Itoa(int(t.ModePres)) + ") > 0;})")).
+ Field("Topic").Run(a.conn)
+
+ if err != nil {
+ return nil, err
+ }
+ var names []string
+ var name string
+ for cursor.Next(&name) {
+ names = append(names, name)
+ }
+ cursor.Close()
+ return names, nil
+}
+
+// TopicShare adds subscriptions to a topic and increments the topic's subcnt.
+func (a *adapter) TopicShare(topic string, shares []*t.Subscription) error {
+ // Assign Ids.
+ for _, sub := range shares {
+ sub.Id = sub.Topic + ":" + sub.User
+ }
+
+ // Subscription could have been marked as deleted (DeletedAt != nil). If it's marked
+ // as deleted, unmark by clearing the DeletedAt field of the old subscription and
+ // updating times and ModeGiven.
+ _, err := rdb.DB(a.dbName).Table("subscriptions").
+ Insert(shares, rdb.InsertOpts{Conflict: func(id, oldsub, newsub rdb.Term) any {
+ return oldsub.Without("DeletedAt").Merge(map[string]any{
+ "CreatedAt": newsub.Field("CreatedAt"),
+ "UpdatedAt": newsub.Field("UpdatedAt"),
+ "ModeGiven": newsub.Field("ModeGiven"),
+ "ModeWant": newsub.Field("ModeWant"),
+ "DelId": 0,
+ "ReadSeqId": 0,
+ "RecvSeqId": 0})
+ }}).RunWrite(a.conn)
+
+ if err == nil && topic != "" {
+ _, err = rdb.DB(a.dbName).Table("topics").
+ Get(topic).
+ Update(map[string]any{"SubCnt": rdb.Row.Field("SubCnt").Default(0).Add(len(shares))}).
+ RunWrite(a.conn)
+ }
+ return err
+}
+
+// TopicDelete deletes topic, subscriptions, messages.
+func (a *adapter) TopicDelete(topic string, isChan, hard bool) error {
+ var err error
+ if err = a.subsDelForTopic(topic, isChan, hard); err != nil {
+ return err
+ }
+
+ if hard {
+ if err = a.MessageDeleteList(topic, nil); err != nil {
+ return err
+ }
+ }
+
+ // Must use GetAll to produce array result expected by decFileUseCounter.
+ q := rdb.DB(a.dbName).Table("topics").GetAll(topic)
+ if hard {
+ if err = a.decFileUseCounter(q); err == nil {
+ _, err = q.Delete().RunWrite(a.conn)
+ }
+ } else {
+ now := t.TimeNow()
+ _, err = q.Update(map[string]any{
+ "UpdatedAt": now,
+ "TouchedAt": now,
+ "State": t.StateDeleted,
+ "StatedAt": now,
+ }).RunWrite(a.conn)
+ }
+ return err
+}
+
+// TopicUpdateOnMessage deserializes message-related values into topic.
+func (a *adapter) TopicUpdateOnMessage(topic string, msg *t.Message) error {
+ update := struct {
+ SeqId int
+ TouchedAt time.Time
+ }{msg.SeqId, msg.CreatedAt}
+
+ _, err := rdb.DB(a.dbName).Table("topics").Get(topic).
+ Update(update, rdb.UpdateOpts{Durability: "soft"}).RunWrite(a.conn)
+
+ return err
+}
+
+// TopicUpdateSubCnt updates subscriber count denormalized in topic.
+func (a *adapter) TopicUpdateSubCnt(topic string) error {
+ cursor, err := rdb.DB(a.dbName).Table("subscriptions").
+ GetAllByIndex("Topic", topic, t.GrpToChn(topic)).
+ Filter(rdb.Row.HasFields("DeletedAt").Not()).
+ Count().Run(a.conn)
+ if err != nil {
+ return err
+ }
+ defer cursor.Close()
+
+ subCnt := 0
+ if !cursor.IsNil() {
+ if err = cursor.One(&subCnt); err != nil {
+ return err
+ }
+ }
+ _, err = rdb.DB(a.dbName).Table("topics").
+ Get(topic).
+ Update(map[string]any{
+ "SubCnt": subCnt,
+ }).RunWrite(a.conn)
+ return err
+}
+
+// TopicUpdate performs a generic topic update.
+func (a *adapter) TopicUpdate(topic string, update map[string]any) error {
+ if t, u := update["TouchedAt"], update["UpdatedAt"]; t == nil && u != nil {
+ update["TouchedAt"] = u
+ }
+ _, err := rdb.DB(a.dbName).Table("topics").Get(topic).Update(update).RunWrite(a.conn)
+ return err
+}
+
+// TopicOwnerChange changes topic's owner.
+func (a *adapter) TopicOwnerChange(topic string, newOwner t.Uid) error {
+ _, err := rdb.DB(a.dbName).Table("topics").Get(topic).
+ Update(map[string]any{"Owner": newOwner.String()}).RunWrite(a.conn)
+ return err
+}
+
+// SubscriptionGet returns a subscription of a user to a topic
+func (a *adapter) SubscriptionGet(topic string, user t.Uid, keepDeleted bool) (*t.Subscription, error) {
+
+ cursor, err := rdb.DB(a.dbName).Table("subscriptions").Get(topic + ":" + user.String()).Run(a.conn)
+ if err != nil {
+ return nil, err
+ }
+ defer cursor.Close()
+
+ if cursor.IsNil() {
+ return nil, nil
+ }
+
+ var sub t.Subscription
+ if err = cursor.One(&sub); err != nil {
+ return nil, err
+ }
+
+ if !keepDeleted && sub.DeletedAt != nil {
+ return nil, nil
+ }
+
+ return &sub, nil
+}
+
+// SubsForUser loads all user's subscriptions. Does NOT load Public or Private values and does
+// not load deleted subscriptions.
+func (a *adapter) SubsForUser(forUser t.Uid) ([]t.Subscription, error) {
+ q := rdb.DB(a.dbName).
+ Table("subscriptions").
+ GetAllByIndex("User", forUser.String()).
+ Filter(rdb.Row.HasFields("DeletedAt").Not()).
+ Without("Private")
+
+ cursor, err := q.Run(a.conn)
+ if err != nil {
+ return nil, err
+ }
+ defer cursor.Close()
+
+ var subs []t.Subscription
+ var ss t.Subscription
+ for cursor.Next(&ss) {
+ subs = append(subs, ss)
+ }
+
+ return subs, cursor.Err()
+}
+
+// SubsForTopic fetches all subsciptions for a topic. Does NOT load Public value.
+func (a *adapter) SubsForTopic(topic string, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) {
+
+ q := rdb.DB(a.dbName).Table("subscriptions").GetAllByIndex("Topic", topic)
+ if !keepDeleted {
+ // Filter out rows where DeletedAt is defined
+ q = q.Filter(rdb.Row.HasFields("DeletedAt").Not())
+ }
+
+ limit := a.maxResults
+ if opts != nil {
+ // Ignore IfModifiedSince - we must return all entries
+ // Those unmodified will be stripped of Public & Private.
+
+ if !opts.User.IsZero() {
+ q = q.Filter(rdb.Row.Field("User").Eq(opts.User.String()))
+ }
+ if opts.Limit > 0 && opts.Limit < limit {
+ limit = opts.Limit
+ }
+ }
+ q = q.Limit(limit)
+
+ cursor, err := q.Run(a.conn)
+ if err != nil {
+ return nil, err
+ }
+ defer cursor.Close()
+
+ var subs []t.Subscription
+ var ss t.Subscription
+ for cursor.Next(&ss) {
+ subs = append(subs, ss)
+ }
+
+ return subs, cursor.Err()
+}
+
+// SubsUpdate updates a single subscription.
+func (a *adapter) SubsUpdate(topic string, user t.Uid, update map[string]any) error {
+ q := rdb.DB(a.dbName).Table("subscriptions")
+ if !user.IsZero() {
+ // Update one topic subscription
+ q = q.Get(topic + ":" + user.String())
+ } else {
+ // Update all topic subscriptions
+ q = q.GetAllByIndex("Topic", topic)
+ }
+ _, err := q.Update(update).RunWrite(a.conn)
+ return err
+}
+
+// SubsDelete marks at most one subscription as deleted.
+func (a *adapter) SubsDelete(topic string, user t.Uid) error {
+ now := t.TimeNow()
+ forUser := user.String()
+
+ // Mark subscription as deleted.
+ res, err := rdb.DB(a.dbName).Table("subscriptions").
+ Get(topic + ":" + forUser).Update(map[string]any{
+ "UpdatedAt": now,
+ "DeletedAt": now,
+ }).RunWrite(a.conn)
+ if err != nil {
+ return err
+ }
+
+ if res.Replaced == 0 {
+ // Nothing was updated, nothing more to do.
+ return t.ErrNotFound
+ }
+
+ // Decrement topic's SubCnt.
+ _, err = rdb.DB(a.dbName).Table("topics").Get(topic).
+ Update(map[string]any{"SubCnt": rdb.Row.Field("SubCnt").Default(1).Sub(1)}).
+ RunWrite(a.conn)
+ if err != nil {
+ return err
+ }
+
+ if t.IsChannel(topic) {
+ // Channel readers cannot delete messages, all done.
+ return nil
+ }
+
+ // Remove records of deleted messages.
+
+ // Delete dellog entries of the current user.
+ resp, err := rdb.DB(a.dbName).Table("dellog").
+ // Select all log entries for the given table.
+ Between([]any{topic, rdb.MinVal}, []any{topic, rdb.MaxVal},
+ rdb.BetweenOpts{Index: "Topic_DelId"}).
+ // Keep entries soft-deleted for the current user only.
+ Filter(rdb.Row.Field("DeletedFor").Eq(forUser)).
+ // Delete them.
+ Delete().
+ RunWrite(a.conn)
+
+ if err != nil || resp.Deleted == 0 {
+ // Either an error or nothing was deleted. Not much we can do with the error.
+ // Returning nil even on failure.
+ return nil
+ }
+
+ // Remove current user from the messages' soft-deletion lists.
+ // The possible error here is ignored.
+ rdb.DB(a.dbName).Table("messages").
+ // Select all messages in the given topic.
+ Between(
+ []any{topic, forUser, rdb.MinVal},
+ []any{topic, forUser, rdb.MaxVal},
+ rdb.BetweenOpts{Index: "Topic_DeletedFor"}).
+ // Update the field DeletedFor:
+ Update(map[string]any{
+ // Take the DeletedFor array, subtract all values which contain current user ID in 'User' field.
+ "DeletedFor": rdb.Row.Field("DeletedFor").
+ SetDifference(
+ rdb.Row.Field("DeletedFor").
+ Filter(map[string]any{"User": forUser}))}).
+ RunWrite(a.conn)
+
+ return nil
+}
+
+// subsDelForTopic marks all subscriptions to the given topic as deleted.
+func (a *adapter) subsDelForTopic(topic string, isChan, hard bool) error {
+ var err error
+
+ q := rdb.DB(a.dbName).Table("subscriptions")
+ if isChan {
+ // If the topic is a channel, must try to delete subscriptions under both grpXXX and chnXXX names.
+ q = q.GetAllByIndex("Topic", topic, t.GrpToChn(topic))
+ } else {
+ q = q.GetAllByIndex("Topic", topic)
+ }
+ if hard {
+ _, err = q.Delete().RunWrite(a.conn)
+ } else {
+ now := t.TimeNow()
+ _, err = q.Update(map[string]any{
+ "UpdatedAt": now,
+ "DeletedAt": now,
+ }).RunWrite(a.conn)
+ }
+ return err
+}
+
+// subsDelForUser marks all subscriptions of a given user as deleted.
+func (a *adapter) subsDelForUser(user t.Uid, hard bool) error {
+ var err error
+
+ forUser := user.String()
+
+ // Get all topics the user is subscribed to. Channels are left as channels.
+ topics, err := a.topicNamesForUser(rdb.DB(a.dbName).Table("subscriptions").
+ GetAllByIndex("User", forUser).Field("Topic"), false)
+ if err != nil {
+ logs.Err.Println("subsDelForUser: topicNamesForUser:", err)
+ return err
+ }
+
+ // 1. Decrement SubCnt in topic.
+ if _, err = rdb.DB(a.dbName).Table("topics").Get(topics...).
+ Update(map[string]any{"SubCnt": rdb.Row.Field("SubCnt").
+ Default(1).Sub(1)}).
+ RunWrite(a.conn); err != nil {
+ return err
+ }
+
+ err = a.clearUserDellog(user, topics)
+ if err != nil {
+ logs.Err.Println("subsDelForUser: clearUserDellog:", err)
+ return err
+ }
+
+ if hard {
+ _, err = rdb.DB(a.dbName).Table("subscriptions").GetAllByIndex("User", user.String()).
+ Delete().RunWrite(a.conn)
+ } else {
+ now := t.TimeNow()
+ update := map[string]any{
+ "UpdatedAt": now,
+ "DeletedAt": now,
+ }
+ _, err = rdb.DB(a.dbName).Table("subscriptions").GetAllByIndex("User", user.String()).
+ Update(update).RunWrite(a.conn)
+ }
+
+ return err
+}
+
+// Find returns a list of users and topics who match the given tags, such as "email:jdoe@example.com" or "tel:+18003287448".
+func (a *adapter) Find(caller, promoPrefix string, req [][]string, opt []string, activeOnly bool) ([]t.Subscription, error) {
+ index := make(map[string]struct{})
+ allReq := t.FlattenDoubleSlice(req)
+ var allTags []any
+ for _, tag := range append(allReq, opt...) {
+ allTags = append(allTags, tag)
+ index[tag] = struct{}{}
+ }
+ // Query for selecting matches where every group includes at least one required match (restricting search to
+ // group members).
+ /*
+ r.db('tinode').
+ table('users').
+ getAll('basic:alice', 'travel', {index: "Tags"}).
+ union(r.db('tinode').table('topics').getAll('basic:alice', 'travel', {index: "Tags"})).
+ pluck('Id', 'Access', 'CreatedAt', 'UpdatedAt', 'UseBt', 'Public', 'Trusted', 'Tags').
+ group('Id').
+ ungroup().
+ map(row => row.getField('reduction').nth(0).merge(
+ {matchedCount: row.getField('reduction').
+ getField('Tags').
+ nth(0).
+ setIntersection(['alias:aliassa', 'basic:alice', 'travel']).
+ map(tag => r.branch(tag.match('^alias:'), 20, 1)).
+ sum()
+ })).
+ filter(row => row.getField('Tags').setIntersection(['basic:alice', 'travel']).count().ne(0)).
+ orderBy(r.desc('matchedCount')).
+ limit(20)
+ */
+
+ // Get users and topics matched by tags, sort by number of matches from high to low.
+ query := rdb.DB(a.dbName).
+ Table("users").
+ GetAllByIndex("Tags", allTags...).
+ Union(rdb.DB(a.dbName).Table("topics").
+ GetAllByIndex("Tags", allTags...))
+ if activeOnly {
+ query = query.Filter(rdb.Row.Field("State").Eq(t.StateOK))
+ }
+ query = query.Pluck("Id", "Access", "CreatedAt", "UpdatedAt", "UseBt", "SubCnt", "Public", "Trusted", "Tags").
+ Group("Id").
+ Ungroup().
+ Map(func(row rdb.Term) rdb.Term {
+ return row.Field("reduction").
+ Nth(0).
+ Merge(map[string]any{"MatchedTagsCount": row.Field("reduction").
+ Field("Tags").
+ Nth(0).
+ SetIntersection(allTags).
+ Map(func(tag rdb.Term) any {
+ return rdb.Branch(
+ tag.Match("^"+promoPrefix),
+ 20, // If the tag matches the promo prefix, count it as 20.
+ 1) // Otherwise count it as 1.
+ }).
+ Sum()})
+ })
+
+ for _, reqDisjunction := range req {
+ if len(reqDisjunction) == 0 {
+ continue
+ }
+ var reqTags []any
+ for _, tag := range reqDisjunction {
+ reqTags = append(reqTags, tag)
+ }
+ // Filter out objects which do not match at least one of the required tags.
+ query = query.Filter(func(row rdb.Term) rdb.Term {
+ return row.Field("Tags").SetIntersection(reqTags).Count().Ne(0)
+ })
+ }
+ cursor, err := query.OrderBy(rdb.Desc("MatchedTagsCount")).Limit(a.maxResults).Run(a.conn)
+ if err != nil {
+ return nil, err
+ }
+ defer cursor.Close()
+
+ var topic t.Topic
+ var sub t.Subscription
+ var subs []t.Subscription
+ for cursor.Next(&topic) {
+ if uid := t.ParseUid(topic.Id); !uid.IsZero() {
+ topic.Id = uid.UserId()
+ if topic.Id == caller {
+ // Skip the caller
+ continue
+ }
+ }
+
+ if topic.UseBt {
+ sub.Topic = t.GrpToChn(topic.Id)
+ } else {
+ sub.Topic = topic.Id
+ }
+
+ sub.CreatedAt = topic.CreatedAt
+ sub.UpdatedAt = topic.UpdatedAt
+ sub.SetSubCnt(topic.SubCnt)
+ sub.SetPublic(topic.Public)
+ sub.SetTrusted(topic.Trusted)
+ sub.SetDefaultAccess(topic.Access.Auth, topic.Access.Anon)
+ // Indicating that the mode is not set, not 'N'.
+ sub.ModeGiven = t.ModeUnset
+ sub.ModeWant = t.ModeUnset
+ sub.Private = common.FilterFoundTags(topic.Tags, index)
+ subs = append(subs, sub)
+ }
+
+ return subs, cursor.Err()
+}
+
+// FindOne returns topic or user which matches the given tag.
+func (a *adapter) FindOne(tag string) (string, error) {
+ query := rdb.DB(a.dbName).
+ Table("users").GetAllByIndex("Tags", tag).
+ Union(rdb.DB(a.dbName).Table("topics").GetAllByIndex("Tags", tag)).
+ Field("Id").
+ Limit(1)
+ cursor, err := query.Run(a.conn)
+ if err != nil {
+ return "", err
+ }
+ defer cursor.Close()
+
+ var found string
+ if err = cursor.One(&found); err != nil {
+ if err == rdb.ErrEmptyResult {
+ return "", nil
+ }
+ return "", err
+ }
+
+ if user := t.ParseUid(found); !user.IsZero() {
+ found = user.UserId()
+ }
+
+ return found, nil
+}
+
+// Messages
+
+// MessageSave saves message to DB.
+func (a *adapter) MessageSave(msg *t.Message) error {
+ _, err := rdb.DB(a.dbName).Table("messages").Insert(msg).RunWrite(a.conn)
+ return err
+}
+
+// MessageGetAll retrieves all messages available to the given user.
+func (a *adapter) MessageGetAll(topic string, forUser t.Uid, opts *t.QueryOpt) ([]t.Message, error) {
+
+ var limit = a.maxMessageResults
+ var lower, upper any
+
+ upper = rdb.MaxVal
+ lower = rdb.MinVal
+
+ if opts != nil {
+ if opts.Since > 0 {
+ lower = opts.Since
+ }
+ if opts.Before > 0 {
+ upper = opts.Before
+ }
+
+ if opts.Limit > 0 && opts.Limit < limit {
+ limit = opts.Limit
+ }
+ }
+
+ lower = []any{topic, lower}
+ upper = []any{topic, upper}
+
+ requester := forUser.String()
+ cursor, err := rdb.DB(a.dbName).Table("messages").
+ Between(lower, upper, rdb.BetweenOpts{Index: "Topic_SeqId"}).
+ // Ordering by index must come before filtering
+ OrderBy(rdb.OrderByOpts{Index: rdb.Desc("Topic_SeqId")}).
+ // Skip hard-deleted messages
+ Filter(rdb.Row.HasFields("DelId").Not()).
+ // Skip messages soft-deleted for the current user
+ Filter(func(row rdb.Term) any {
+ return rdb.Not(row.Field("DeletedFor").Default([]any{}).Contains(
+ func(df rdb.Term) any {
+ return df.Field("User").Eq(requester)
+ }))
+ }).Limit(limit).Run(a.conn)
+
+ if err != nil {
+ return nil, err
+ }
+ defer cursor.Close()
+
+ var msgs []t.Message
+ if err = cursor.All(&msgs); err != nil {
+ return nil, err
+ }
+
+ return msgs, nil
+}
+
+// MessageGetDeleted returns ranges of deleted messages.
+func (a *adapter) MessageGetDeleted(topic string, forUser t.Uid, opts *t.QueryOpt) ([]t.DelMessage, error) {
+ /*
+ r.db('tinode_test')
+ .table('dellog')
+ .between(
+ ['p2p9AVDamaNCRbfKzGSh3mE0w', 1],
+ ['p2p9AVDamaNCRbfKzGSh3mE0w', 10],
+ {index: 'Topic_DelId'}
+ )
+ .orderBy('Topic_DelId')
+ .filter(
+ row => row.getField('DeletedFor').eq('0QLrX3WPS2o').or(row.getField('DeletedFor').eq(''))
+ )
+ */
+ var limit = a.maxResults
+ var lower, upper any
+
+ upper = rdb.MaxVal
+ lower = rdb.MinVal
+
+ if opts != nil {
+ if opts.Since > 0 {
+ lower = opts.Since
+ }
+ if opts.Before > 0 {
+ upper = opts.Before
+ }
+
+ if opts.Limit > 0 && opts.Limit < limit {
+ limit = opts.Limit
+ }
+ }
+
+ // Fetch log of deletions
+ cursor, err := rdb.DB(a.dbName).Table("dellog").
+ // Select log entries for the given table and DelId values between two limits.
+ // By default, leftBound is closed and rightBound is open.
+ Between([]any{topic, lower}, []any{topic, upper},
+ rdb.BetweenOpts{Index: "Topic_DelId"}).
+ // Sort from low DelIds to high
+ OrderBy(rdb.OrderByOpts{Index: "Topic_DelId"}).
+ // Keep entries soft-deleted for the current user and all hard-deleted entries.
+ Filter(func(row rdb.Term) any {
+ return row.Field("DeletedFor").Eq(forUser.String()).Or(row.Field("DeletedFor").Eq(""))
+ }).
+ Limit(limit).Run(a.conn)
+
+ if err != nil {
+ return nil, err
+ }
+ defer cursor.Close()
+
+ var dmsgs []t.DelMessage
+ if err = cursor.All(&dmsgs); err != nil {
+ return nil, err
+ }
+
+ return dmsgs, nil
+}
+
+// messagesHardDelete deletes all messages in the topic.
+func (a *adapter) messagesHardDelete(topic string) error {
+ var err error
+
+ // TODO: handle file uploads
+
+ if _, err = rdb.DB(a.dbName).Table("dellog").Between(
+ []any{topic, rdb.MinVal},
+ []any{topic, rdb.MaxVal},
+ rdb.BetweenOpts{Index: "Topic_DelId"}).Delete().RunWrite(a.conn); err != nil {
+ return err
+ }
+
+ q := rdb.DB(a.dbName).Table("messages").Between(
+ []any{topic, rdb.MinVal},
+ []any{topic, rdb.MaxVal},
+ rdb.BetweenOpts{Index: "Topic_SeqId"})
+
+ if err = a.decFileUseCounter(q); err != nil {
+ return err
+ }
+
+ _, err = q.Delete().RunWrite(a.conn)
+
+ return err
+}
+
+func rangeToQuery(delRanges []t.Range, topic string, query rdb.Term) rdb.Term {
+ if len(delRanges) > 1 || delRanges[0].Hi <= delRanges[0].Low {
+ var indexVals []any
+ for _, rng := range delRanges {
+ if rng.Hi == 0 {
+ indexVals = append(indexVals, []any{topic, rng.Low})
+ } else {
+ for i := rng.Low; i <= rng.Hi; i++ {
+ indexVals = append(indexVals, []any{topic, i})
+ }
+ }
+ }
+ query = query.GetAllByIndex("Topic_SeqId", indexVals...)
+ } else {
+ // Optimizing for a special case of single range low..hi
+ query = query.Between(
+ []any{topic, delRanges[0].Low},
+ []any{topic, delRanges[0].Hi},
+ rdb.BetweenOpts{Index: "Topic_SeqId", RightBound: "closed"})
+ }
+ return query
+}
+
+// MessageDeleteList deletes messages in the given topic with seqIds from the list.
+func (a *adapter) MessageDeleteList(topic string, toDel *t.DelMessage) error {
+ var err error
+
+ if toDel == nil {
+ // Delete all messages.
+ return a.messagesHardDelete(topic)
+ }
+
+ // Only some messages are being deleted
+
+ delRanges := toDel.SeqIdRanges
+ query := rangeToQuery(delRanges, topic, rdb.DB(a.dbName).Table("messages"))
+ // Skip already hard-deleted messages.
+ query = query.Filter(rdb.Row.HasFields("DelId").Not())
+ if toDel.DeletedFor == "" {
+ // Hard-deleting messages requires updates to the messages table.
+
+ // We are asked to delete messages no older than newerThan.
+ if newerThan := toDel.GetNewerThan(); newerThan != nil {
+ query = query.Filter(rdb.Row.Field("CreatedAt").Gt(newerThan))
+ }
+
+ query = query.Field("SeqId")
+
+ // Find the actual IDs still present in the database.
+ cursor, err := query.Run(a.conn)
+ if err != nil {
+ return err
+ }
+ defer cursor.Close()
+
+ var seqIDs []int
+ if err = cursor.All(&seqIDs); err != nil {
+ return err
+ }
+
+ if len(seqIDs) == 0 {
+ // Nothing to delete. No need to make a log entry. All done.
+ return nil
+ }
+
+ // Recalculate the actual ranges to delete.
+ sort.Ints(seqIDs)
+ delRanges = t.SliceToRanges(seqIDs)
+
+ // Compose a new query with the new ranges.
+ query = rangeToQuery(delRanges, topic, rdb.DB(a.dbName).Table("messages"))
+
+ // First decrement use counter for attachments.
+ if err = a.decFileUseCounter(query); err != nil {
+ return err
+ }
+
+ // Hard-delete individual messages. The messages are not deleted but all fields with personal content
+ // are removed.
+ if _, err = query.Replace(rdb.Row.Without("Head", "From", "Content", "Attachments").Merge(
+ map[string]any{
+ "DeletedAt": t.TimeNow(), "DelId": toDel.DelId})).
+ RunWrite(a.conn); err != nil {
+ return err
+ }
+
+ } else {
+ // Soft-deleting: adding DelId to DeletedFor.
+ _, err = query.
+ // Skip messages already soft-deleted for the current user
+ Filter(func(row rdb.Term) any {
+ return rdb.Not(row.Field("DeletedFor").Default([]any{}).Contains(
+ func(df rdb.Term) any {
+ return df.Field("User").Eq(toDel.DeletedFor)
+ }))
+ }).
+ Update(map[string]any{"DeletedFor": rdb.Row.Field("DeletedFor").
+ Default([]any{}).Append(
+ &t.SoftDelete{
+ User: toDel.DeletedFor,
+ DelId: toDel.DelId})}).RunWrite(a.conn)
+ if err != nil {
+ return err
+ }
+ }
+
+ // Make log entries. Needed for both hard- and soft-deleting.
+ _, err = rdb.DB(a.dbName).Table("dellog").Insert(toDel).RunWrite(a.conn)
+ return err
+}
+
+func deviceHasher(deviceID string) string {
+ // Generate custom key as [64-bit hash of device id] to ensure predictable
+ // length of the key
+ hasher := fnv.New64()
+ hasher.Write([]byte(deviceID))
+ return strconv.FormatUint(uint64(hasher.Sum64()), 16)
+}
+
+// Device management for push notifications
+
+// DeviceUpsert adds or updates a user's device FCM push token.
+func (a *adapter) DeviceUpsert(uid t.Uid, def *t.DeviceDef) error {
+ hash := deviceHasher(def.DeviceId)
+ user := uid.String()
+
+ // Ensure uniqueness of the device ID
+ // Find users who already use this device ID, ignore current user.
+ cursor, err := rdb.DB(a.dbName).Table("users").GetAllByIndex("DeviceIds", def.DeviceId).
+ // We only care about user Ids
+ Pluck("Id").
+ // Make sure we filter out the current user who may legitimately use this device ID
+ Filter(rdb.Not(rdb.Row.Field("Id").Eq(user))).
+ // Convert slice of objects to a slice of strings
+ ConcatMap(func(row rdb.Term) any { return []any{row.Field("Id")} }).
+ // Execute
+ Run(a.conn)
+ if err != nil {
+ return err
+ }
+ defer cursor.Close()
+
+ var others []any
+ if err = cursor.All(&others); err != nil {
+ return err
+ }
+
+ if len(others) > 0 {
+ // Delete device ID for the other users.
+ _, err = rdb.DB(a.dbName).Table("users").GetAll(others...).Replace(rdb.Row.Without(
+ map[string]string{"Devices": hash})).RunWrite(a.conn)
+ if err != nil {
+ return err
+ }
+ }
+
+ // Actually add/update DeviceId for the new user
+ _, err = rdb.DB(a.dbName).Table("users").Get(user).
+ Update(map[string]any{
+ "Devices": map[string]*t.DeviceDef{
+ hash: def,
+ }}).RunWrite(a.conn)
+ return err
+}
+
+// DeviceGetAll retrives a list of user's devices (push tokens).
+func (a *adapter) DeviceGetAll(uids ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error) {
+ ids := make([]any, len(uids))
+ for i, id := range uids {
+ ids[i] = id.String()
+ }
+
+ // {Id: "userid", Devices: {"hash1": {..def1..}, "hash2": {..def2..}}
+ cursor, err := rdb.DB(a.dbName).Table("users").GetAll(ids...).Pluck("Id", "Devices").
+ Default(nil).Limit(a.maxResults).Run(a.conn)
+ if err != nil {
+ return nil, 0, err
+ }
+ defer cursor.Close()
+
+ var row struct {
+ Id string
+ Devices map[string]*t.DeviceDef
+ }
+
+ result := make(map[t.Uid][]t.DeviceDef)
+ count := 0
+ var uid t.Uid
+ for cursor.Next(&row) {
+ if len(row.Devices) > 0 {
+ if err := uid.UnmarshalText([]byte(row.Id)); err != nil {
+ continue
+ }
+
+ result[uid] = make([]t.DeviceDef, len(row.Devices))
+ i := 0
+ for _, def := range row.Devices {
+ if def != nil {
+ result[uid][i] = *def
+ i++
+ count++
+ }
+ }
+ }
+ }
+
+ return result, count, cursor.Err()
+}
+
+// DeviceDelete removes user's device (push token).
+func (a *adapter) DeviceDelete(uid t.Uid, deviceID string) error {
+ var err error
+ q := rdb.DB(a.dbName).Table("users").Get(uid.String())
+ if deviceID == "" {
+ q = q.Update(map[string]any{"Devices": nil})
+ } else {
+ q = q.Replace(rdb.Row.Without(map[string]string{"Devices": deviceHasher(deviceID)}))
+ }
+ _, err = q.RunWrite(a.conn)
+ return err
+}
+
+// Credential management
+
+// CredUpsert adds or updates a validation record. Returns true if inserted, false if updated.
+// 1. if credential is validated:
+// 1.1 Hard-delete unconfirmed equivalent record, if exists.
+// 1.2 Insert new. Report error if duplicate.
+// 2. if credential is not validated:
+// 2.1 Check if validated equivalent exist. If so, report an error.
+// 2.2 Soft-delete all unvalidated records of the same method.
+// 2.3 Undelete existing credential. Return if successful.
+// 2.4 Insert new credential record.
+func (a *adapter) CredUpsert(cred *t.Credential) (bool, error) {
+ var err error
+ tableCredentials := rdb.DB(a.dbName).Table("credentials")
+
+ cred.Id = cred.Method + ":" + cred.Value
+
+ if !cred.Done {
+ // Check if the same credential is already validated.
+ cursor, err := tableCredentials.Get(cred.Id).Run(a.conn)
+ if err != nil {
+ return false, err
+ }
+ defer cursor.Close()
+ if !cursor.IsNil() {
+ // Someone has already validated this credential.
+ return false, t.ErrDuplicate
+ }
+
+ // Deactivate all unvalidated records of this user and method.
+ _, err = tableCredentials.GetAllByIndex("User", cred.User).
+ Filter(map[string]any{"Method": cred.Method, "Done": false}).Update(
+ map[string]any{"DeletedAt": t.TimeNow()}).RunWrite(a.conn)
+ if err != nil {
+ return false, err
+ }
+
+ // If credential is not confirmed, it should not block others
+ // from attempting to validate it: make index user-unique instead of global-unique.
+ cred.Id = cred.User + ":" + cred.Id
+
+ // Check if this credential has already been added by the user.
+ cursor2, err := tableCredentials.Get(cred.Id).Run(a.conn)
+ if err != nil {
+ return false, err
+ }
+ defer cursor2.Close()
+ if !cursor2.IsNil() {
+ _, err = tableCredentials.Get(cred.Id).
+ Replace(rdb.Row.Without("DeletedAt").
+ Merge(map[string]any{
+ "UpdatedAt": cred.UpdatedAt,
+ "Resp": cred.Resp})).RunWrite(a.conn)
+ if err != nil {
+ return false, err
+ }
+ // The record was updated, all is fine.
+ return false, nil
+ }
+
+ } else {
+ // Hard-delete potentially present unvalidated credential.
+ _, err = tableCredentials.Get(cred.User + ":" + cred.Id).Delete().RunWrite(a.conn)
+ if err != nil {
+ return false, err
+ }
+ }
+
+ // Insert a new record.
+ _, err = tableCredentials.Insert(cred).RunWrite(a.conn)
+ if rdb.IsConflictErr(err) {
+ return true, t.ErrDuplicate
+ }
+
+ return true, err
+}
+
+// CredDel deletes credentials for the given method. If method is empty, deletes all user's credentials.
+func (a *adapter) CredDel(uid t.Uid, method, value string) error {
+ q := rdb.DB(a.dbName).Table("credentials").
+ GetAllByIndex("User", uid.String())
+ if method != "" {
+ q = q.Filter(map[string]any{"Method": method})
+ if value != "" {
+ q = q.Filter(map[string]any{"Value": value})
+ }
+ }
+
+ if method == "" {
+ res, err := q.Delete().RunWrite(a.conn)
+ if err == nil {
+ if res.Deleted == 0 {
+ err = t.ErrNotFound
+ }
+ }
+ return err
+ }
+
+ // Hard-delete all confirmed values or values with no attempts at confirmation.
+ res, err := q.Filter(rdb.Or(rdb.Row.Field("Done").Eq(true), rdb.Row.Field("Retries").Eq(0))).Delete().RunWrite(a.conn)
+ if err != nil {
+ return err
+ }
+ if res.Deleted > 0 {
+ return nil
+ }
+
+ // Soft-delete all other values.
+ res, err = q.Update(map[string]any{"DeletedAt": t.TimeNow()}).RunWrite(a.conn)
+ if err == nil {
+ if res.Deleted == 0 {
+ err = t.ErrNotFound
+ }
+ }
+ return err
+}
+
+// credGetActive reads the currently active unvalidated credential
+func (a *adapter) credGetActive(uid t.Uid, method string) (*t.Credential, error) {
+ // Get the active unconfirmed credential:
+ cursor, err := rdb.DB(a.dbName).Table("credentials").GetAllByIndex("User", uid.String()).
+ Filter(rdb.Row.HasFields("DeletedAt").Not()).
+ Filter(map[string]any{"Method": method, "Done": false}).Run(a.conn)
+ if err != nil {
+ return nil, err
+ }
+ defer cursor.Close()
+
+ if cursor.IsNil() {
+ return nil, nil
+ }
+
+ var cred t.Credential
+ if err = cursor.One(&cred); err != nil {
+ return nil, err
+ }
+
+ return &cred, nil
+}
+
+// CredConfirm marks given credential as validated.
+func (a *adapter) CredConfirm(uid t.Uid, method string) error {
+
+ cred, err := a.credGetActive(uid, method)
+ if err != nil {
+ return err
+ }
+
+ // RethinkDb does not allow primary key to be changed (userid:method:value -> method:value)
+ // We have to delete and re-insert with a different primary key.
+
+ cred.Done = true
+ cred.UpdatedAt = t.TimeNow()
+ if _, err = a.CredUpsert(cred); err != nil {
+ return err
+ }
+
+ rdb.DB(a.dbName).
+ Table("credentials").
+ Get(uid.String() + ":" + cred.Method + ":" + cred.Value).
+ Delete(rdb.DeleteOpts{Durability: "soft", ReturnChanges: false}).
+ RunWrite(a.conn)
+
+ return nil
+}
+
+// CredFail increments count of failed validation attepmts for the given credentials.
+func (a *adapter) CredFail(uid t.Uid, method string) error {
+ _, err := rdb.DB(a.dbName).Table("credentials").
+ GetAllByIndex("User", uid.String()).
+ Filter(map[string]any{"Method": method, "Done": false}).
+ Filter(rdb.Row.HasFields("DeletedAt").Not()).
+ Update(map[string]any{
+ "Retries": rdb.Row.Field("Retries").Default(0).Add(1),
+ "UpdatedAt": t.TimeNow(),
+ }).RunWrite(a.conn)
+ return err
+}
+
+// CredGetActive returns currently active credential record for the given method.
+func (a *adapter) CredGetActive(uid t.Uid, method string) (*t.Credential, error) {
+ return a.credGetActive(uid, method)
+}
+
+// CredGetAll returns user's credential records of the given method, validated only or all.
+func (a *adapter) CredGetAll(uid t.Uid, method string, validatedOnly bool) ([]t.Credential, error) {
+ q := rdb.DB(a.dbName).Table("credentials").GetAllByIndex("User", uid.String())
+ if method != "" {
+ q = q.Filter(map[string]any{"Method": method})
+ }
+ if validatedOnly {
+ q = q.Filter(map[string]any{"Done": true})
+ } else {
+ q = q.Filter(rdb.Row.HasFields("DeletedAt").Not())
+ }
+
+ cursor, err := q.Run(a.conn)
+ if err != nil {
+ return nil, err
+ }
+ defer cursor.Close()
+
+ if cursor.IsNil() {
+ return nil, nil
+ }
+
+ var credentials []t.Credential
+ err = cursor.All(&credentials)
+ return credentials, err
+}
+
+// FileUploads
+
+// FileStartUpload initializes a file upload
+func (a *adapter) FileStartUpload(fd *t.FileDef) error {
+ _, err := rdb.DB(a.dbName).Table("fileuploads").Insert(fd).RunWrite(a.conn)
+ return err
+}
+
+// FileFinishUpload marks file upload as completed, successfully or otherwise
+func (a *adapter) FileFinishUpload(fd *t.FileDef, success bool, size int64) (*t.FileDef, error) {
+ now := t.TimeNow()
+ if success {
+ if _, err := rdb.DB(a.dbName).Table("fileuploads").Get(fd.Uid()).
+ Update(map[string]any{
+ "UpdatedAt": now,
+ "Status": t.UploadCompleted,
+ "Size": size,
+ "ETag": fd.ETag,
+ "Location": fd.Location,
+ }).RunWrite(a.conn); err != nil {
+
+ return nil, err
+ }
+ fd.Status = t.UploadCompleted
+ fd.Size = size
+ } else {
+ if _, err := rdb.DB(a.dbName).Table("fileuploads").Get(fd.Uid()).Delete().RunWrite(a.conn); err != nil {
+ return nil, err
+ }
+ fd.Status = t.UploadFailed
+ fd.Size = 0
+ }
+ fd.UpdatedAt = now
+
+ return fd, nil
+}
+
+// FileGet fetches a record of a specific file
+func (a *adapter) FileGet(fid string) (*t.FileDef, error) {
+ cursor, err := rdb.DB(a.dbName).Table("fileuploads").Get(fid).Run(a.conn)
+ if err != nil {
+ return nil, err
+ }
+ defer cursor.Close()
+
+ if cursor.IsNil() {
+ return nil, nil
+ }
+
+ var fd t.FileDef
+ if err = cursor.One(&fd); err != nil {
+ return nil, err
+ }
+
+ return &fd, nil
+
+}
+
+// FileLinkAttachments connects given topic or message to the file record IDs from the list.
+func (a *adapter) FileLinkAttachments(topic string, userId, msgId t.Uid, fids []string) error {
+ if len(fids) == 0 || (topic == "" && userId.IsZero() && msgId.IsZero()) {
+ return t.ErrMalformed
+ }
+
+ now := t.TimeNow()
+ var err error
+
+ if msgId.IsZero() {
+ // Only one link per user or topic is permitted.
+ fids = fids[0:1]
+
+ // Topics and users and mutable. Must unlink the previous attachments first.
+ var table string
+ var linkId string
+ if topic != "" {
+ table = "topics"
+ linkId = topic
+ } else {
+ table = "users"
+ linkId = userId.String()
+ }
+
+ // Find the old attachment.
+ var cursor *rdb.Cursor
+ cursor, err = rdb.DB(a.dbName).Table(table).Get(linkId).
+ Field("Attachments").Default([]string{}).Run(a.conn)
+ if err != nil {
+ return err
+ }
+ defer cursor.Close()
+
+ if !cursor.IsNil() {
+ var attachments []string
+ if err = cursor.One(&attachments); err != nil {
+ if err != rdb.ErrEmptyResult {
+ return err
+ }
+ err = nil
+ }
+
+ if len(attachments) > 0 {
+ // Decrement the use count of old attachment.
+ if _, err = rdb.DB(a.dbName).Table("fileuploads").Get(attachments[0]).
+ Update(map[string]any{
+ "UpdatedAt": now,
+ "UseCount": rdb.Row.Field("UseCount").Default(1).Sub(1),
+ }).RunWrite(a.conn); err != nil {
+ return err
+ }
+ }
+ }
+
+ _, err = rdb.DB(a.dbName).Table(table).Get(linkId).
+ Update(map[string]any{
+ "UpdatedAt": now,
+ "Attachments": fids,
+ }).RunWrite(a.conn)
+ if err != nil {
+ return err
+ }
+ } else {
+ // Messages are immutable. Just save the IDs.
+ _, err := rdb.DB(a.dbName).Table("messages").Get(msgId.String()).
+ Update(map[string]any{
+ "UpdatedAt": now,
+ "Attachments": fids,
+ }).RunWrite(a.conn)
+ if err != nil {
+ return err
+ }
+ }
+
+ ids := make([]any, len(fids))
+ for i, id := range fids {
+ ids[i] = id
+ }
+
+ _, err = rdb.DB(a.dbName).Table("fileuploads").GetAll(ids...).
+ Update(map[string]any{
+ "UpdatedAt": now,
+ "UseCount": rdb.Row.Field("UseCount").Default(0).Add(1),
+ }).RunWrite(a.conn)
+
+ return err
+}
+
+// FileDeleteUnused deletes orphaned file uploads.
+func (a *adapter) FileDeleteUnused(olderThan time.Time, limit int) ([]string, error) {
+ q := rdb.DB(a.dbName).Table("fileuploads").GetAllByIndex("UseCount", 0)
+ if !olderThan.IsZero() {
+ q = q.Filter(rdb.Row.Field("UpdatedAt").Lt(olderThan))
+ }
+ if limit > 0 {
+ q = q.Limit(limit)
+ }
+
+ cursor, err := q.Field("Location").Run(a.conn)
+ if err != nil {
+ return nil, err
+ }
+ defer cursor.Close()
+
+ var locations []string
+ var loc string
+ for cursor.Next(&loc) {
+ locations = append(locations, loc)
+ }
+
+ if err = cursor.Err(); err != nil {
+ return nil, err
+ }
+
+ _, err = q.Delete().RunWrite(a.conn)
+
+ return locations, err
+}
+
+// Given a select query, decrement corresponding use counter in 'fileuploads' table.
+// The 'query' must return an array, i.e. GetAll, not Get.
+func (a *adapter) decFileUseCounter(query rdb.Term) error {
+ /*
+ r.db("test").table("one")
+ .getAll(
+ r.args(r.db("test").table("zero")
+ .getAll(
+ "07e2c6fe-ac91-49cb-9834-ff34bf50aad1",
+ "0098a829-6da5-4f7b-8432-32b40de9ab3b",
+ "0926e7dd-321a-49cb-adb1-7a705d9d9a78",
+ "8e195450-babd-4954-a8fb-0cc414b43156")
+ .filter(r.row.hasFields("att"))
+ .concatMap(function(row) { return row.getField("att"); })
+ .coerceTo("array"))
+ )
+ .update({useCount: r.row.getField("useCount").default(0).add(1)})
+ */
+ _, err := rdb.DB(a.dbName).Table("fileuploads").GetAll(
+ rdb.Args(
+ query.
+ // Fetch messages with attachments only
+ Filter(rdb.Row.HasFields("Attachments")).
+ // Flatten arrays
+ ConcatMap(func(row rdb.Term) any { return row.Field("Attachments") }).
+ CoerceTo("array"))).
+ // Decrement UseCount.
+ Update(map[string]any{"UseCount": rdb.Row.Field("UseCount").Default(1).Sub(1)}).
+ RunWrite(a.conn)
+ return err
+}
+
+// PCacheGet reads a persistet cache entry.
+func (a *adapter) PCacheGet(key string) (string, error) {
+ cursor, err := rdb.DB(a.dbName).Table("kvmeta").Get(key).Run(a.conn)
+ if err != nil {
+ return "", err
+ }
+ defer cursor.Close()
+
+ if cursor.IsNil() {
+ return "", t.ErrNotFound
+ }
+
+ var result map[string]string
+ if err = cursor.One(&result); err != nil {
+ return "", err
+ }
+
+ return result["value"], nil
+}
+
+// PCacheUpsert creates or updates a persistent cache entry.
+func (a *adapter) PCacheUpsert(key string, value string, failOnDuplicate bool) error {
+ if strings.Contains(key, "^") {
+ // Do not allow ^ in keys: it interferes with Match() query.
+ return t.ErrMalformed
+ }
+
+ doc := map[string]any{
+ "key": key,
+ "value": value,
+ }
+
+ var action string
+ if failOnDuplicate {
+ action = "error"
+ doc["CreatedAt"] = t.TimeNow()
+ } else {
+ action = "update"
+ }
+
+ _, err := rdb.DB(a.dbName).Table("kvmeta").Insert(doc, rdb.InsertOpts{Conflict: action}).RunWrite(a.conn)
+ if rdb.IsConflictErr(err) {
+ return t.ErrDuplicate
+ }
+
+ return err
+}
+
+// PCacheDelete deletes one persistent cache entry.
+func (a *adapter) PCacheDelete(key string) error {
+ _, err := rdb.DB(a.dbName).Table("kvmeta").Get(key).Delete().RunWrite(a.conn)
+ return err
+}
+
+// PCacheExpire expires old entries with the given key prefix.
+func (a *adapter) PCacheExpire(keyPrefix string, olderThan time.Time) error {
+ if keyPrefix == "" {
+ return t.ErrMalformed
+ }
+
+ _, err := rdb.DB(a.dbName).Table("kvmeta").
+ Filter(rdb.Row.Field("CreatedAt").Lt(olderThan).And(rdb.Row.Field("key").Match("^" + keyPrefix))).
+ Delete().
+ RunWrite(a.conn)
+
+ return err
+}
+
+// GetTestDB returns a currently open database connection.
+func (a *adapter) GetTestDB() any {
+ return a.conn
+}
+
+// Check if error is due to no results.
+// The case covered is calling Field('name') on a non-object value.
+func isNoResults(err error) bool {
+ if err == nil {
+ return false
+ }
+ return strings.Contains(err.Error(), "perform get_field on a non-object non-sequence")
+}
+
+// Checks if the given error is 'Database not found'.
+func isMissingDb(err error) bool {
+ if err == nil {
+ return false
+ }
+
+ msg := err.Error()
+ // "Database `db_name` does not exist"
+ return strings.Contains(msg, "Database `") && strings.Contains(msg, "` does not exist")
+}
+
+// GetTestAdapter returns an adapter object. Useful for running tests.
+func GetTestAdapter() *adapter {
+ return &adapter{}
+}
+
+func init() {
+ store.RegisterAdapter(&adapter{})
+}
diff --git a/server/db/rethinkdb/schema.md b/server/db/rethinkdb/schema.md
index e69de29..0a4662d 100644
--- a/server/db/rethinkdb/schema.md
+++ b/server/db/rethinkdb/schema.md
@@ -0,0 +1,331 @@
+# RethinkDB Database Schema
+
+## Database `tinode`
+
+### Table `users`
+Stores user accounts
+
+Fields:
+* `Id` user id, primary key
+* `CreatedAt` timestamp when the user was created
+* `UpdatedAt` timestamp when user metadata was updated
+* `State` account state: normal (ok), suspended, soft-deleted
+* `StateAt` timestamp when the state was last updated or NULL
+* `Access` user's default access level for peer-to-peer topics
+ * `Auth`, `Anon` default permissions for authenticated and anonymous users
+* `Public` application-defined data
+* `State` state of the user: normal, disabled, deleted
+* `StateAt` timestamp when the state was last updated or NULL
+* `LastSeen` timestamp when the user was last online
+* `UserAgent` client User-Agent used when last online
+* `Tags` unique strings for user discovery
+* `Devices` client devices for push notifications
+ * `DeviceId` device registration ID
+ * `Platform` device platform string (iOS, Android, Web)
+ * `LastSeen` last logged in
+ * `Lang` device language, ISO code
+
+Indexes:
+ * `Id` primary key
+ * `Tags` multi-index (indexed array)
+ * `DeletedAt` index
+ * `DeviceIds` multi-index of push notification tokens
+
+Sample:
+```js
+{
+ "Access": {
+ "Anon": 0 ,
+ "Auth": 47
+ } ,
+ "CreatedAt": Mon Jul 24 2017 11:16:38 GMT+00:00 ,
+ "State": 0,
+ "StateAt": null ,
+ "Devices": null ,
+ "Id": "7yUCHniegrM" ,
+ "LastSeen": Mon Jan 01 1 00:00:00 GMT+00:00 ,
+ "Public": {
+ "fn": "Alice Johnson" ,
+ "photo": {
+ "data": ,
+ "type": "jpg"
+ }
+ } ,
+ "State": 1 ,
+ "Tags": [
+ "email:alice@example.com" ,
+ "tel:17025550001"
+ ] ,
+ "UpdatedAt": Mon Jul 24 2017 11:16:38 GMT+00:00 ,
+ "UserAgent": "TinodeWeb/0.13 (MacIntel) tinodejs/0.13"
+}
+```
+
+### Table `auth`
+Stores authentication secrets
+
+Fields:
+* `userid` ID of the user who owns the record
+* `unique` unique string which identifies this record, primary key; defined as "_authentication scheme_':'_some unique value per scheme_"
+* `secret` shared secret, for instance bcrypt of password
+* `authLvl` authentication level
+* `expires` timestamp when the records expires
+
+
+Indexes:
+ * `unique` primary key
+ * `userid` index
+
+Sample:
+```js
+{
+ "authLvl": 20 ,
+ "expires": Mon Jan 01 1 00:00:00 GMT+00:00 ,
+ "secret": ,
+ "unique": "basic:alice" ,
+ "userid": "7yUCHniegrM"
+}
+```
+
+### Table `topics`
+The table stores topics.
+
+Fields:
+ * `Id` name of the topic, primary key
+ * `CreatedAt` topic creation time
+ * `UpdatedAt` timestamp of the last change to topic metadata
+ * `State` topic state: normal (ok), suspended, soft-deleted
+ * `StateAt` timestamp when the state was last updated or NULL
+ * `Access` stores topic's default access permissions
+ * `Auth`, `Anon` permissions for authenticated and anonymous users respectively
+ * `Owner` ID of the user who owns the topic
+ * `Public` application-defined data
+ * `State` state of the topic: normal, disabled, deleted
+ * `SeqId` sequential ID of the last message
+ * `DelId` topic-sequential ID of the deletion operation
+ * `UseBt` indicator that channel functionality is enabled in the topic
+
+Indexes:
+* `Id` primary key
+* `Owner` index
+
+Sample:
+```js
+{
+ "Access": {
+ "Anon": 64 ,
+ "Auth": 64
+ } ,
+ "DelId": 0,
+ "CreatedAt": Thu Oct 15 2015 04:06:51 GMT+00:00 ,
+ "State": 0 ,
+ "StateAt": null ,
+ "LastMessageAt": Sat Oct 17 2015 13:51:56 GMT+00:00 ,
+ "Id": "p2pavVGHLCBbKrvJQIeeJ6Csw" ,
+ "Owner": "v2JyG4OLSoA" ,
+ "Public": {
+ "fn": "Travel, travel, travel" ,
+ "photo": {
+ "data": ,
+ "type": "jpg"
+ }
+ } ,
+ "SeqId": 14,
+ "State": 0 ,
+ "UpdatedAt": Thu Oct 15 2015 04:06:51 GMT+00:00 ,
+ "UseBt": false
+}
+```
+
+### Table `subscriptions`
+The table stores relationships between users and topics.
+
+Fields:
+ * `Id` used for object retrieval
+ * `CreatedAt` timestamp when the subscription was created
+ * `UpdatedAt` timestamp when the subscription was updated
+ * `DeletedAt` timestamp when the subscription was deleted
+ * `ReadSeqId` id of the message last read by the user
+ * `RecvSeqId` id of the message last received by any user device
+ * `DelId` topic-sequential ID of the soft-deletion operation
+ * `Topic` name of the topic subscribed to
+ * `User` subscriber's user ID
+ * `ModeWant` access mode that user wants when accessing the topic
+ * `ModeGiven` access mode granted to user by the topic
+ * `Private` application-defined data, accessible by the user only
+
+Indexes:
+ * `Id` primary key composed as "_topic name_':'_user ID_"
+ * `User` index
+ * `Topic` index
+
+Sample:
+```js
+{
+ "ClearId": 0 ,
+ "CreatedAt": Tue Jul 25 2017 15:34:39 GMT+00:00 ,
+ "DeletedAt": null ,
+ "Id": "grpjajVKrHn0PU:v2JyG4OLSoA" ,
+ "ModeGiven": 47 ,
+ "ModeWant": 47 ,
+ "Private": "Kirgudu" ,
+ "ReadSeqId": 0 ,
+ "RecvSeqId": 0 ,
+ "State": 0 ,
+ "Topic": "grpjajVKrHn0PU" ,
+ "UpdatedAt": Tue Jul 25 2017 15:34:39 GMT+00:00 ,
+ "User": "v2JyG4OLSoA"
+}
+```
+
+### Table `messages`
+The table stores `{data}` messages
+
+Fields:
+* `Id` currently unused, primary key
+* `CreatedAt` timestamp when the message was created
+* `UpdatedAt` initially equal to CreatedAt, for deleted messages equal to DeletedAt
+* `DeletedFor` array of user IDs which soft-deleted the message
+ * `DelId` topic-sequential ID of the soft-deletion operation
+ * `User` ID of the user who soft-deleted the message
+* `From` ID of the user who generated this message
+* `Topic` which received this message
+* `SeqId` messages ID - sequential number of the message in the topic
+* `Head` message headers
+* `Attachments` denormalized IDs of files attached to the message
+* `Content` application-defined message payload
+
+Indexes:
+ * `Id` primary key
+ * `Topic_SeqId` compound index `["Topic", "SeqId"]`
+ * `Topic_DelId` compound index `["Topic", "DelId"]`
+ * `Topic_DeletedFor` compound multi-index `["Topic", "DeletedFor"("User"), "DeletedFor"("DelId")]`
+
+Sample:
+```js
+{
+ "Content": {
+ "fmt": [
+ {
+ "len": 6 ,
+ "tp": "ST"
+ }
+ ] ,
+ "txt": "Hello!"
+ } ,
+ "CreatedAt": Sun Dec 24 2017 05:16:23 GMT+00:00 ,
+ "From": "wTI0jO9rEqY" ,
+ "Head": {
+ "mime": "text/x-drafty"
+ } ,
+ "DeletedFor": [
+ {
+ "DelId": 1 ,
+ "User": "wTI0jO9rEqY"
+ }
+ ] ,
+ "Id": "LLXKEe9W4Bs" ,
+ "SeqId": 3 ,
+ "Topic": "p2pJhbJnya8z5PBMjSM72sSpg" ,
+ "UpdatedAt": Sun Dec 24 2017 05:16:23 GMT+00:00
+}
+```
+
+### Table `dellog`
+The table stores records of message deletions
+
+Fields:
+* `Id` currently unused, primary key
+* `CreatedAt` timestamp when the record was created
+* `UpdatedAt` timestamp equal to CreatedAt
+* `DelId` topic-sequential ID of the deletion operation.
+* `DeletedFor` ID of the user for soft-deletions, blank string for hard-deletions
+* `Topic` affected topic
+* `SeqIdRanges` array of ranges of deleted message IDs (see `messages.SeqId`)
+
+Indexes:
+ * `Id` primary key
+* `Topic_DelId` compound index `["Topic", "DelId"]`
+
+Sample:
+```js
+{
+ "Id": "9LfrjW349Rc",
+ "CreatedAt": Tue Dec 05 2017 01:51:38 GMT+00:00,
+ "DelId": 18,
+ "DeletedFor": "xY-YHx09-WI" ,
+
+ "SeqIdRanges": [
+ {
+ "Low": 20,
+ "Hi": 25,
+ }
+ ] ,
+ "Topic": "grpGx7fpjQwVC0" ,
+ "UpdatedAt": Tue Dec 05 2017 01:51:38 GMT+00:00
+}
+```
+
+### Table `credentials`
+The tables stores user credentials used for validation.
+
+* `Id` credential, primary key
+* `CreatedAt` timestamp when the record was created
+* `UpdatedAt` timestamp when the last validation attempt was performed (successful or not).
+* `Method` validation method
+* `Done` indicator if the credential is validated
+* `Resp` expected validation response
+* `Retries` number of failed attempts at validation
+* `User` id of the user who owns this credential
+* `Value` value of the credential
+* `Closed` unvalidated credential is no longer being validated. Only one credential is not Closed for each user/method.
+
+Indexes:
+* `Id` Primary key composed either as `User`:`Method`:`Value` for unconfirmed credentials or as `Method`:`Value` for confirmed.
+* `User` Index
+
+Sample:
+```js
+{
+ "Id": "tel:+17025550001",
+ "CreatedAt": Sun Jun 10 2018 16:37:27 GMT+00:00 ,
+ "UpdatedAt": Sun Jun 10 2018 16:37:28 GMT+00:00 ,
+ "Method": "tel" ,
+ "Done": true ,
+ "Resp": "123456" ,
+ "Retries": 0 ,
+ "User": "k3srBRk9RYw" ,
+ "Value": "+17025550001"
+}
+```
+
+### Table `fileuploads`
+The table stores records of uploaded files. The files themselves are stored outside of the database.
+* `Id` unique user-visible file name, primary key
+* `CreatedAt` timestamp when the record was created
+* `UpdatedAt` timestamp of when th upload has cmpleted or failed
+* `User` id of the user who uploaded this file.
+* `Location` actual location of the file on the server.
+* `MimeType` file content type as a [Mime](https://en.wikipedia.org/wiki/MIME) string.
+* `Size` size of the file in bytes. Could be 0 if upload has not completed yet.
+* `UseCount` count of messages referencing this file.
+* `Status` upload status: 0 pending, 1 completed, -1 failed.
+
+Indexes:
+ * `Id` primary key
+ * `UseCount` index
+
+Sample:
+```js
+{
+ "CreatedAt": Sun Jun 10 2018 16:38:45 GMT+00:00 ,
+ "Id": "sFmjlQ_kA6A" ,
+ "Location": "uploads/sFmjlQ_kA6A" ,
+ "MimeType": "image/jpeg" ,
+ "Size": 54961090 ,
+ "UseCount": 3,
+ "Status": 1,
+ "UpdatedAt": Sun Jun 10 2018 16:38:45 GMT+00:00 ,
+ "User": "7j-RR1V7O3Y"
+}
+```
diff --git a/server/db/rethinkdb/tests/rethink_test.go b/server/db/rethinkdb/tests/rethink_test.go
index e69de29..a3e5995 100644
--- a/server/db/rethinkdb/tests/rethink_test.go
+++ b/server/db/rethinkdb/tests/rethink_test.go
@@ -0,0 +1,1579 @@
+package tests
+
+// To test another db backend:
+// 1) Create GetAdapter function inside your db backend adapter package (like one inside rethinkdb adapter)
+// 2) Uncomment your db backend package ('backend' named package)
+// 3) Write own initConnectionToDb and 'db' variable
+// 4) Replace rethinkdb specific db queries inside test to your own queries.
+// 5) Run.
+
+import (
+ "encoding/json"
+ "flag"
+ "fmt"
+ "log"
+ "os"
+ "reflect"
+ "testing"
+ "time"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/google/go-cmp/cmp/cmpopts"
+ adapter "github.com/tinode/chat/server/db"
+ jcr "github.com/tinode/jsonco"
+ rdb "gopkg.in/rethinkdb/rethinkdb-go.v6"
+
+ "github.com/tinode/chat/server/db/common/test_data"
+ backend "github.com/tinode/chat/server/db/rethinkdb"
+ "github.com/tinode/chat/server/logs"
+ "github.com/tinode/chat/server/store"
+ "github.com/tinode/chat/server/store/types"
+)
+
+type configType struct {
+ // If Reset=true test will recreate database every time it runs
+ Reset bool `json:"reset_db_data"`
+ // Configurations for individual adapters.
+ Adapters map[string]json.RawMessage `json:"adapters"`
+}
+
+var config configType
+var adp adapter.Adapter
+var conn *rdb.Session
+var testData *test_data.TestData
+
+var dummyUid1 = types.Uid(12345)
+var dummyUid2 = types.Uid(54321)
+
+func TestCreateDb(t *testing.T) {
+ if err := adp.CreateDb(config.Reset); err != nil {
+ t.Fatal(err)
+ }
+ // Saved db is closed, get a fresh one.
+ conn = adp.GetTestDB().(*rdb.Session)
+}
+
+// ================== Create tests ================================
+func TestUserCreate(t *testing.T) {
+ for _, user := range testData.Users {
+ if err := adp.UserCreate(user); err != nil {
+ t.Error(err)
+ }
+ }
+
+ cursor, err := rdb.Table("users").Count().Run(conn)
+ if err != nil {
+ t.Error(err)
+ }
+ defer cursor.Close()
+
+ var count int
+ if err = cursor.One(&count); err != nil {
+ t.Error(err)
+ }
+ if count == 0 {
+ t.Error("No users created!")
+ }
+}
+
+func TestCredUpsert(t *testing.T) {
+ // Test just inserts:
+ for i := 0; i < 2; i++ {
+ inserted, err := adp.CredUpsert(testData.Creds[i])
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !inserted {
+ t.Error("Should be inserted, but updated")
+ }
+ }
+
+ // Test duplicate:
+ _, err := adp.CredUpsert(testData.Creds[1])
+ if err != types.ErrDuplicate {
+ t.Error("Should return duplicate error but got", err)
+ }
+ _, err = adp.CredUpsert(testData.Creds[2])
+ if err != types.ErrDuplicate {
+ t.Error("Should return duplicate error but got", err)
+ }
+
+ // Test add new unvalidated credentials
+ inserted, err := adp.CredUpsert(testData.Creds[3])
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !inserted {
+ t.Error("Should be inserted, but updated")
+ }
+ inserted, err = adp.CredUpsert(testData.Creds[3])
+ if err != nil {
+ t.Fatal(err)
+ }
+ if inserted {
+ t.Error("Should be updated, but inserted")
+ }
+
+ // Just insert other creds (used in other tests)
+ for _, cred := range testData.Creds[4:] {
+ _, err = adp.CredUpsert(cred)
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+}
+
+func TestAuthAddRecord(t *testing.T) {
+ for _, rec := range testData.Recs {
+ err := adp.AuthAddRecord(types.ParseUserId("usr"+rec.UserId), rec.Scheme, rec.Unique,
+ rec.AuthLvl, rec.Secret, rec.Expires)
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ //Test duplicate
+ err := adp.AuthAddRecord(types.ParseUserId("usr"+testData.Users[0].Id), testData.Recs[0].Scheme,
+ testData.Recs[0].Unique, testData.Recs[0].AuthLvl, testData.Recs[0].Secret, testData.Recs[0].Expires)
+ if err != types.ErrDuplicate {
+ t.Fatal("Should be duplicate error but got", err)
+ }
+}
+
+func TestTopicCreate(t *testing.T) {
+ err := adp.TopicCreate(testData.Topics[0])
+ if err != nil {
+ t.Error(err)
+ }
+ // Update topic SeqId because it's not saved at creation time but used by the tests.
+ err = adp.TopicUpdate(testData.Topics[0].Id, map[string]interface{}{
+ "SeqId": testData.Topics[0].SeqId,
+ })
+ if err != nil {
+ t.Error(err)
+ }
+
+ for _, tpc := range testData.Topics[3:] {
+ err = adp.TopicCreate(tpc)
+ if err != nil {
+ t.Error(err)
+ }
+ }
+}
+
+func TestTopicCreateP2P(t *testing.T) {
+ err := adp.TopicCreateP2P(testData.Subs[2], testData.Subs[3])
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ oldModeGiven := testData.Subs[2].ModeGiven
+ testData.Subs[2].ModeGiven = 255
+ err = adp.TopicCreateP2P(testData.Subs[4], testData.Subs[2])
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ cursor, err := rdb.Table("subscriptions").Get(testData.Subs[2].Id).Run(conn)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer cursor.Close()
+
+ var got types.Subscription
+ if err = cursor.One(&got); err != nil {
+ t.Fatal(err)
+ }
+ if got.ModeGiven == oldModeGiven {
+ t.Error("ModeGiven update failed")
+ }
+}
+
+func TestTopicShare(t *testing.T) {
+ if err := adp.TopicShare(testData.Subs[0].Topic, testData.Subs); err != nil {
+ t.Fatal(err)
+ }
+
+ // Must save recvseqid and readseqid separately because TopicShare
+ // ignores them.
+ for _, sub := range testData.Subs {
+ adp.SubsUpdate(sub.Topic, types.ParseUid(sub.User), map[string]any{
+ "RecvSeqId": sub.RecvSeqId,
+ "ReadSeqId": sub.ReadSeqId,
+ })
+ }
+}
+
+func TestMessageSave(t *testing.T) {
+ for _, msg := range testData.Msgs {
+ err := adp.MessageSave(msg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ // Some messages are soft deleted, but it's ignored by adp.MessageSave
+ for _, msg := range testData.Msgs {
+ if len(msg.DeletedFor) > 0 {
+ for _, del := range msg.DeletedFor {
+ toDel := types.DelMessage{
+ Topic: msg.Topic,
+ DeletedFor: del.User,
+ DelId: del.DelId,
+ SeqIdRanges: []types.Range{{Low: msg.SeqId}},
+ }
+ adp.MessageDeleteList(msg.Topic, &toDel)
+ }
+ }
+ }
+}
+
+func TestFileStartUpload(t *testing.T) {
+ for _, f := range testData.Files {
+ err := adp.FileStartUpload(f)
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+}
+
+// ================== Read tests ==================================
+func TestUserGet(t *testing.T) {
+ // Test not found
+ got, err := adp.UserGet(dummyUid1)
+ if err == nil && got != nil {
+ t.Error("user should be nil.")
+ }
+
+ got, err = adp.UserGet(types.ParseUserId("usr" + testData.Users[0].Id))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // User agent is not stored when creating a user. Make sure it's the same.
+ got.UserAgent = testData.Users[0].UserAgent
+
+ if !cmp.Equal(got, testData.Users[0], cmpopts.IgnoreUnexported(types.User{}, types.ObjHeader{})) {
+ t.Error(mismatchErrorString("User", got, testData.Users[0]))
+ }
+}
+
+func TestUserGetAll(t *testing.T) {
+ // Test not found (dummy UIDs).
+ got, err := adp.UserGetAll(dummyUid1, dummyUid2)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) > 0 {
+ t.Error("result users should be zero length, got", len(got))
+ }
+
+ got, err = adp.UserGetAll(types.ParseUserId("usr"+testData.Users[0].Id), types.ParseUserId("usr"+testData.Users[1].Id))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 2 {
+ t.Fatal(mismatchErrorString("resultUsers length", len(got), 2))
+ }
+ for i, usr := range got {
+ // User agent is not compared.
+ usr.UserAgent = testData.Users[i].UserAgent
+ if !reflect.DeepEqual(&usr, testData.Users[i]) {
+ t.Error(mismatchErrorString("User", &usr, testData.Users[i]))
+ }
+ }
+}
+
+func TestUserGetByCred(t *testing.T) {
+ // Test not found
+ got, err := adp.UserGetByCred("foo", "bar")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != types.ZeroUid {
+ t.Error("result uid should be ZeroUid")
+ }
+
+ got, _ = adp.UserGetByCred(testData.Creds[0].Method, testData.Creds[0].Value)
+ if got != types.ParseUserId("usr"+testData.Creds[0].User) {
+ t.Error(mismatchErrorString("Uid", got, types.ParseUserId("usr"+testData.Creds[0].User)))
+ }
+}
+
+func TestCredGetActive(t *testing.T) {
+ got, err := adp.CredGetActive(types.ParseUserId("usr"+testData.Users[2].Id), "tel")
+ if err != nil {
+ t.Error(err)
+ }
+ if !cmp.Equal(got, testData.Creds[3], cmpopts.IgnoreUnexported(types.ObjHeader{}, types.Credential{})) {
+ t.Error(mismatchErrorString("Credential", got, testData.Creds[3]))
+ }
+
+ // Test not found
+ got, err = adp.CredGetActive(dummyUid1, "")
+ if err != nil {
+ t.Error(err)
+ }
+ if got != nil {
+ t.Error("result should be nil, but got", got)
+ }
+}
+
+func TestCredGetAll(t *testing.T) {
+ got, err := adp.CredGetAll(types.ParseUserId("usr"+testData.Users[2].Id), "", false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 3 {
+ t.Error(mismatchErrorString("Credentials length", len(got), 3))
+ }
+
+ got, _ = adp.CredGetAll(types.ParseUserId("usr"+testData.Users[2].Id), "tel", false)
+ if len(got) != 2 {
+ t.Error(mismatchErrorString("Credentials length", len(got), 2))
+ }
+
+ got, _ = adp.CredGetAll(types.ParseUserId("usr"+testData.Users[2].Id), "", true)
+ if len(got) != 1 {
+ t.Error(mismatchErrorString("Credentials length", len(got), 1))
+ }
+
+ got, _ = adp.CredGetAll(types.ParseUserId("usr"+testData.Users[2].Id), "tel", true)
+ if len(got) != 1 {
+ t.Error(mismatchErrorString("Credentials length", len(got), 1))
+ }
+}
+
+func TestUserGetUnvalidated(t *testing.T) {
+ // Test RethinkDB specific method
+ cutoff := time.Now().Add(-24 * time.Hour)
+ uids, err := adp.UserGetUnvalidated(cutoff, 10)
+ if err != nil {
+ t.Error(err)
+ }
+ // Should return empty slice since all test users are considered validated
+ if len(uids) > 0 {
+ t.Error("Expected no unvalidated users in test data")
+ }
+}
+
+func TestAuthGetUniqueRecord(t *testing.T) {
+ uid, authLvl, secret, expires, err := adp.AuthGetUniqueRecord("basic:alice")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if uid != types.ParseUserId("usr"+testData.Recs[0].UserId) ||
+ authLvl != testData.Recs[0].AuthLvl ||
+ !reflect.DeepEqual(secret, testData.Recs[0].Secret) ||
+ expires != testData.Recs[0].Expires {
+
+ got := fmt.Sprintf("%v %v %v %v", uid, authLvl, secret, expires)
+ want := fmt.Sprintf("%v %v %v %v", testData.Recs[0].UserId, testData.Recs[0].AuthLvl, testData.Recs[0].Secret, testData.Recs[0].Expires)
+ t.Error(mismatchErrorString("Auth record", got, want))
+ }
+
+ // Test not found
+ uid, _, _, _, err = adp.AuthGetUniqueRecord("qwert:asdfg")
+ if err == nil && !uid.IsZero() {
+ t.Error("Auth record found but shouldn't. Uid:", uid.String())
+ }
+}
+
+func TestAuthGetRecord(t *testing.T) {
+ recId, authLvl, secret, expires, err := adp.AuthGetRecord(types.ParseUserId("usr"+testData.Recs[0].UserId), "basic")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if recId != testData.Recs[0].Unique ||
+ authLvl != testData.Recs[0].AuthLvl ||
+ !reflect.DeepEqual(secret, testData.Recs[0].Secret) ||
+ expires != testData.Recs[0].Expires {
+
+ got := fmt.Sprintf("%v %v %v %v", recId, authLvl, secret, expires)
+ want := fmt.Sprintf("%v %v %v %v", testData.Recs[0].Unique, testData.Recs[0].AuthLvl, testData.Recs[0].Secret, testData.Recs[0].Expires)
+ t.Error(mismatchErrorString("Auth record", got, want))
+ }
+
+ // Test not found
+ recId, _, _, _, err = adp.AuthGetRecord(types.Uid(123), "scheme")
+ if err != types.ErrNotFound {
+ t.Error("Auth record found but shouldn't. recId:", recId)
+ }
+}
+
+func TestTopicGet(t *testing.T) {
+ got, err := adp.TopicGet(testData.Topics[0].Id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(got, testData.Topics[0]) {
+ t.Error(mismatchErrorString("Topic", got, testData.Topics[0]))
+ }
+ // Test not found
+ got, err = adp.TopicGet("asdfasdfasdf")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != nil {
+ t.Error("Topic should be nil but got:", got)
+ }
+}
+
+func TestTopicsForUser(t *testing.T) {
+ qOpts := types.QueryOpt{
+ Topic: testData.Topics[1].Id,
+ Limit: 999,
+ }
+ gotSubs, err := adp.TopicsForUser(types.ParseUserId("usr"+testData.Users[0].Id), false, &qOpts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(gotSubs) != 1 {
+ t.Error(mismatchErrorString("Subs length", len(gotSubs), 1))
+ }
+
+ gotSubs, err = adp.TopicsForUser(types.ParseUserId("usr"+testData.Users[1].Id), true, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(gotSubs) != 2 {
+ t.Error(mismatchErrorString("Subs length (2)", len(gotSubs), 2))
+ }
+
+ qOpts.Topic = ""
+ ims := testData.Now.Add(15 * time.Minute)
+ qOpts.IfModifiedSince = &ims
+ gotSubs, err = adp.TopicsForUser(types.ParseUserId("usr"+testData.Users[0].Id), false, &qOpts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(gotSubs) != 1 {
+ t.Error(mismatchErrorString("Subs length (IMS)", len(gotSubs), 1))
+ }
+
+ ims = time.Now().Add(15 * time.Minute)
+ gotSubs, err = adp.TopicsForUser(types.ParseUserId("usr"+testData.Users[0].Id), false, &qOpts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(gotSubs) != 0 {
+ t.Error(mismatchErrorString("Subs length (IMS 2)", len(gotSubs), 0))
+ }
+}
+
+func TestUsersForTopic(t *testing.T) {
+ qOpts := types.QueryOpt{
+ User: types.ParseUserId("usr" + testData.Users[0].Id),
+ Limit: 999,
+ }
+ gotSubs, err := adp.UsersForTopic(testData.Topics[0].Id, false, &qOpts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(gotSubs) != 1 {
+ t.Error(mismatchErrorString("Subs length", len(gotSubs), 1))
+ }
+
+ gotSubs, err = adp.UsersForTopic(testData.Topics[0].Id, true, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(gotSubs) != 2 {
+ t.Error(mismatchErrorString("Subs length", len(gotSubs), 2))
+ }
+
+ gotSubs, err = adp.UsersForTopic(testData.Topics[1].Id, false, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(gotSubs) != 2 {
+ t.Error(mismatchErrorString("Subs length", len(gotSubs), 2))
+ }
+}
+
+func TestOwnTopics(t *testing.T) {
+ gotSubs, err := adp.OwnTopics(types.ParseUserId("usr" + testData.Users[0].Id))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(gotSubs) != 1 {
+ t.Fatalf("Got topic length %v instead of %v", len(gotSubs), 1)
+ }
+ if gotSubs[0] != testData.Topics[0].Id {
+ t.Errorf("Got topic %v instead of %v", gotSubs[0], testData.Topics[0].Id)
+ }
+}
+
+func TestChannelsForUser(t *testing.T) {
+ // Test RethinkDB specific method
+ channels, err := adp.ChannelsForUser(types.ParseUserId("usr" + testData.Users[0].Id))
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Should return empty slice since we don't have channel subscriptions in test data
+ if len(channels) != 0 {
+ t.Error(mismatchErrorString("Channels length", len(channels), 0))
+ }
+}
+
+func TestSubscriptionGet(t *testing.T) {
+ got, err := adp.SubscriptionGet(testData.Topics[0].Id, types.ParseUserId("usr"+testData.Users[0].Id), false)
+ if err != nil {
+ t.Error(err)
+ }
+
+ opts := cmpopts.IgnoreUnexported(types.Subscription{}, types.ObjHeader{})
+ if !cmp.Equal(got, testData.Subs[0], opts) {
+ t.Error(mismatchErrorString("Subs", got, testData.Subs[0]))
+ }
+ // Test not found
+ got, err = adp.SubscriptionGet("dummytopic", dummyUid1, false)
+ if err != nil {
+ t.Error(err)
+ }
+ if got != nil {
+ t.Error("result sub should be nil.")
+ }
+}
+
+func TestSubsForUser(t *testing.T) {
+ gotSubs, err := adp.SubsForUser(types.ParseUserId("usr" + testData.Users[0].Id))
+ if err != nil {
+ t.Error(err)
+ }
+ if len(gotSubs) != 2 {
+ t.Error(mismatchErrorString("Subs length", len(gotSubs), 2))
+ }
+
+ // Test not found
+ gotSubs, err = adp.SubsForUser(types.ParseUserId("usr12345678"))
+ if err != nil {
+ t.Error(err)
+ }
+ if len(gotSubs) != 0 {
+ t.Error(mismatchErrorString("Subs length", len(gotSubs), 0))
+ }
+}
+
+func TestSubsForTopic(t *testing.T) {
+ qOpts := types.QueryOpt{
+ User: types.ParseUserId("usr" + testData.Users[0].Id),
+ Limit: 999,
+ }
+ gotSubs, err := adp.SubsForTopic(testData.Topics[0].Id, false, &qOpts)
+ if err != nil {
+ t.Error(err)
+ }
+ if len(gotSubs) != 1 {
+ t.Error(mismatchErrorString("Subs length", len(gotSubs), 1))
+ }
+ // Test not found
+ gotSubs, err = adp.SubsForTopic("dummytopicid", false, nil)
+ if err != nil {
+ t.Error(err)
+ }
+ if len(gotSubs) != 0 {
+ t.Error(mismatchErrorString("Subs length", len(gotSubs), 0))
+ }
+}
+
+func TestFind(t *testing.T) {
+ reqTags := [][]string{{"alice", "bob", "carol", "travel", "qwer", "asdf", "zxcv"}}
+ got, err := adp.Find("usr"+testData.Users[2].Id, "", reqTags, nil, true)
+ if err != nil {
+ t.Error(err)
+ }
+ if len(got) != 3 {
+ t.Error(mismatchErrorString("result length", len(got), 3))
+ }
+}
+
+func TestFindOne(t *testing.T) {
+ // Test RethinkDB specific FindOne method
+ found, err := adp.FindOne("alice")
+ if err != nil {
+ t.Error(err)
+ }
+ // Should find the user with alice tag
+ if found == "" {
+ t.Error("Expected to find user with alice tag")
+ }
+
+ // Test not found
+ found, err = adp.FindOne("nonexistent")
+ if err != nil {
+ t.Error(err)
+ }
+ if found != "" {
+ t.Error("Should not find nonexistent tag")
+ }
+}
+
+func TestMessageGetAll(t *testing.T) {
+ opts := types.QueryOpt{
+ Since: 1,
+ Before: 2,
+ Limit: 999,
+ }
+ gotMsgs, err := adp.MessageGetAll(testData.Topics[0].Id, types.ParseUserId("usr"+testData.Users[0].Id), &opts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(gotMsgs) != 1 {
+ t.Error(mismatchErrorString("Messages length opts", len(gotMsgs), 1))
+ }
+ gotMsgs, _ = adp.MessageGetAll(testData.Topics[0].Id, types.ParseUserId("usr"+testData.Users[0].Id), nil)
+ if len(gotMsgs) != 2 {
+ t.Error(mismatchErrorString("Messages length no opts", len(gotMsgs), 2))
+ }
+ gotMsgs, _ = adp.MessageGetAll(testData.Topics[0].Id, types.ZeroUid, nil)
+ if len(gotMsgs) != 3 {
+ t.Error(mismatchErrorString("Messages length zero uid", len(gotMsgs), 3))
+ }
+}
+
+func TestFileGet(t *testing.T) {
+ // General test done during TestFileFinishUpload().
+
+ // Test not found
+ got, err := adp.FileGet("dummyfileid")
+ if err != nil {
+ if got != nil {
+ t.Error("File found but shouldn't:", got)
+ }
+ }
+}
+
+// ================== Update tests ================================
+func TestUserUpdate(t *testing.T) {
+ update := map[string]any{
+ "UserAgent": "Test Agent v0.11",
+ "UpdatedAt": testData.Now.Add(30 * time.Minute),
+ }
+ err := adp.UserUpdate(types.ParseUserId("usr"+testData.Users[0].Id), update)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ cursor, err := rdb.Table("users").Get(testData.Users[0].Id).Pluck("UserAgent", "UpdatedAt", "CreatedAt").Run(conn)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer cursor.Close()
+
+ var got struct {
+ UserAgent string
+ UpdatedAt time.Time
+ CreatedAt time.Time
+ }
+ if err = cursor.One(&got); err != nil {
+ t.Fatal(err)
+ }
+ if got.UserAgent != "Test Agent v0.11" {
+ t.Error(mismatchErrorString("UserAgent", got.UserAgent, "Test Agent v0.11"))
+ }
+ if got.UpdatedAt.Equal(got.CreatedAt) {
+ t.Error("UpdatedAt field not updated")
+ }
+}
+
+func TestUserUpdateTags(t *testing.T) {
+ addTags := testData.Tags[0]
+ removeTags := testData.Tags[1]
+ resetTags := testData.Tags[2]
+ uid := types.ParseUserId("usr" + testData.Users[0].Id)
+
+ got, err := adp.UserUpdateTags(uid, addTags, nil, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := []string{"alice", "tag1"}
+ if !reflect.DeepEqual(got, want) {
+ t.Error(mismatchErrorString("Tags", got, want))
+ }
+
+ got, _ = adp.UserUpdateTags(uid, nil, removeTags, nil)
+ want = nil
+ if !reflect.DeepEqual(got, want) {
+ t.Error(mismatchErrorString("Tags", got, want))
+ }
+
+ got, _ = adp.UserUpdateTags(uid, nil, nil, resetTags)
+ want = []string{"alice", "tag111", "tag333"}
+ if !reflect.DeepEqual(got, want) {
+ t.Error(mismatchErrorString("Tags", got, want))
+ }
+
+ got, _ = adp.UserUpdateTags(uid, addTags, removeTags, nil)
+ want = []string{"tag111", "tag333"}
+ if !reflect.DeepEqual(got, want) {
+ t.Error(mismatchErrorString("Tags", got, want))
+ }
+}
+
+func TestCredFail(t *testing.T) {
+ err := adp.CredFail(types.ParseUserId("usr"+testData.Creds[3].User), "tel")
+ if err != nil {
+ t.Error(err)
+ }
+
+ // Check if fields updated
+ cursor, err := rdb.Table("credentials").GetAllByIndex("User", testData.Creds[3].User).
+ Filter(map[string]any{"Method": "tel", "Value": testData.Creds[3].Value}).
+ Pluck("Retries", "UpdatedAt", "CreatedAt").Run(conn)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer cursor.Close()
+
+ var got struct {
+ Retries int
+ UpdatedAt time.Time
+ CreatedAt time.Time
+ }
+ if err = cursor.One(&got); err != nil {
+ t.Fatal(err)
+ }
+ if got.Retries != 1 {
+ t.Error(mismatchErrorString("Retries count", got.Retries, 1))
+ }
+ if got.UpdatedAt.Equal(got.CreatedAt) {
+ t.Error("UpdatedAt field not updated")
+ }
+}
+
+func TestCredConfirm(t *testing.T) {
+ err := adp.CredConfirm(types.ParseUserId("usr"+testData.Creds[3].User), "tel")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Test fields are updated - the confirmed credential should have a new ID (method:value)
+ cursor, err := rdb.Table("credentials").Get(testData.Creds[3].Method+":"+testData.Creds[3].Value).
+ Pluck("UpdatedAt", "CreatedAt", "Done").Run(conn)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer cursor.Close()
+
+ var got struct {
+ UpdatedAt time.Time
+ CreatedAt time.Time
+ Done bool
+ }
+ if err = cursor.One(&got); err != nil {
+ t.Fatal(err)
+ }
+ if got.UpdatedAt.Equal(got.CreatedAt) {
+ t.Error("Credential not updated correctly")
+ }
+ if !got.Done {
+ t.Error("Credential should be marked as done")
+ }
+
+ // And unconfirmed credential should be deleted
+ cursor2, err := rdb.Table("credentials").Get(testData.Creds[3].User + ":" + testData.Creds[3].Method + ":" + testData.Creds[3].Value).Run(conn)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer cursor2.Close()
+ if !cursor2.IsNil() {
+ t.Error("Unconfirmed credential should be deleted")
+ }
+}
+
+func TestAuthUpdRecord(t *testing.T) {
+ rec := testData.Recs[1]
+ newSecret := []byte{'s', 'e', 'c', 'r', 'e', 't'}
+ err := adp.AuthUpdRecord(types.ParseUserId("usr"+rec.UserId), rec.Scheme, rec.Unique,
+ rec.AuthLvl, newSecret, rec.Expires)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ cursor, err := rdb.Table("auth").Get(rec.Unique).Field("secret").Run(conn)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer cursor.Close()
+
+ var got []byte
+ if err = cursor.One(&got); err != nil {
+ t.Fatal(err)
+ }
+ if reflect.DeepEqual(got, rec.Secret) {
+ t.Error(mismatchErrorString("secret", got, rec.Secret))
+ }
+
+ // Test with auth ID (unique) change
+ newId := "basic:bob12345"
+ err = adp.AuthUpdRecord(types.ParseUserId("usr"+rec.UserId), rec.Scheme, newId,
+ rec.AuthLvl, newSecret, rec.Expires)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Test if old ID deleted
+ cursor2, err := rdb.Table("auth").Get(rec.Unique).Run(conn)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer cursor2.Close()
+ if !cursor2.IsNil() {
+ t.Error("Old auth record should be deleted")
+ }
+}
+
+func TestTopicUpdateOnMessage(t *testing.T) {
+ msg := types.Message{
+ ObjHeader: types.ObjHeader{
+ CreatedAt: testData.Now.Add(33 * time.Minute),
+ },
+ SeqId: 66,
+ }
+ err := adp.TopicUpdateOnMessage(testData.Topics[2].Id, &msg)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ cursor, err := rdb.Table("topics").Get(testData.Topics[2].Id).
+ Pluck("TouchedAt", "SeqId").Run(conn)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer cursor.Close()
+
+ var got struct {
+ TouchedAt time.Time
+ SeqId int
+ }
+ if err = cursor.One(&got); err != nil {
+ t.Fatal(err)
+ }
+ if !got.TouchedAt.Equal(msg.CreatedAt) || got.SeqId != msg.SeqId {
+ t.Error(mismatchErrorString("TouchedAt", got.TouchedAt, msg.CreatedAt))
+ t.Error(mismatchErrorString("SeqId", got.SeqId, msg.SeqId))
+ }
+}
+
+func TestTopicUpdate(t *testing.T) {
+ update := map[string]any{
+ "UpdatedAt": testData.Now.Add(55 * time.Minute),
+ }
+ err := adp.TopicUpdate(testData.Topics[0].Id, update)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ cursor, err := rdb.Table("topics").Get(testData.Topics[0].Id).Field("UpdatedAt").Run(conn)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer cursor.Close()
+
+ var got time.Time
+ if err = cursor.One(&got); err != nil {
+ t.Fatal(err)
+ }
+ if !got.Equal(update["UpdatedAt"].(time.Time)) {
+ t.Error(mismatchErrorString("UpdatedAt", got, update["UpdatedAt"]))
+ }
+}
+
+func TestTopicUpdateSubCnt(t *testing.T) {
+ // Test RethinkDB specific method
+ err := adp.TopicUpdateSubCnt(testData.Topics[0].Id)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Verify the subscription count was updated correctly
+ cursor, err := rdb.Table("topics").Get(testData.Topics[0].Id).Field("SubCnt").Run(conn)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer cursor.Close()
+
+ var subcnt int
+ if err = cursor.One(&subcnt); err != nil {
+ t.Fatal(err)
+ }
+ // Should match the number of active subscriptions
+ if subcnt < 0 {
+ t.Error("Subscription count should be non-negative")
+ }
+}
+
+func TestTopicOwnerChange(t *testing.T) {
+ err := adp.TopicOwnerChange(testData.Topics[0].Id, types.ParseUserId("usr"+testData.Users[1].Id))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ cursor, err := rdb.Table("topics").Get(testData.Topics[0].Id).Field("Owner").Run(conn)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer cursor.Close()
+
+ var got string
+ if err = cursor.One(&got); err != nil {
+ t.Fatal(err)
+ }
+
+ if got != testData.Users[1].Id {
+ t.Error(mismatchErrorString("Owner", got, testData.Users[1].Id))
+ }
+}
+
+func TestSubsUpdate(t *testing.T) {
+ update := map[string]any{
+ "UpdatedAt": testData.Now.Add(22 * time.Minute),
+ }
+ err := adp.SubsUpdate(testData.Topics[0].Id, types.ParseUserId("usr"+testData.Users[0].Id), update)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ cursor, err := rdb.Table("subscriptions").Get(testData.Topics[0].Id + ":" + testData.Users[0].Id).
+ Field("UpdatedAt").Run(conn)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer cursor.Close()
+
+ var got time.Time
+ if err = cursor.One(&got); err != nil {
+ t.Fatal(err)
+ }
+ if !got.Equal(update["UpdatedAt"].(time.Time)) {
+ t.Error(mismatchErrorString("UpdatedAt", got, update["UpdatedAt"]))
+ }
+
+ err = adp.SubsUpdate(testData.Topics[1].Id, types.ZeroUid, update)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ cursor2, err := rdb.Table("subscriptions").GetAllByIndex("Topic", testData.Topics[1].Id).
+ Field("UpdatedAt").Limit(1).Run(conn)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer cursor2.Close()
+
+ if err = cursor2.One(&got); err != nil {
+ t.Fatal(err)
+ }
+ if !got.Equal(update["UpdatedAt"].(time.Time)) {
+ t.Error(mismatchErrorString("UpdatedAt", got, update["UpdatedAt"]))
+ }
+}
+
+func TestSubsDelete(t *testing.T) {
+ err := adp.SubsDelete(testData.Topics[1].Id, types.ParseUserId("usr"+testData.Users[0].Id))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ cursor, err := rdb.Table("subscriptions").Get(testData.Topics[1].Id + ":" + testData.Users[0].Id).
+ Field("DeletedAt").Run(conn)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer cursor.Close()
+
+ var deletedat time.Time
+ if err = cursor.One(&deletedat); err != nil {
+ t.Fatal(err)
+ }
+ if deletedat.IsZero() {
+ t.Error("DeletedAt should not be null")
+ }
+}
+
+func TestDeviceUpsert(t *testing.T) {
+ err := adp.DeviceUpsert(types.ParseUserId("usr"+testData.Users[0].Id), testData.Devs[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ cursor, err := rdb.Table("users").Get(testData.Users[0].Id).Field("Devices").Run(conn)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer cursor.Close()
+
+ var got map[string]*types.DeviceDef
+ if err = cursor.One(&got); err != nil {
+ t.Fatal(err)
+ }
+
+ // Find the device (the key is hashed)
+ var foundDev *types.DeviceDef
+ for _, dev := range got {
+ if dev != nil && dev.DeviceId == testData.Devs[0].DeviceId {
+ foundDev = dev
+ break
+ }
+ }
+ if foundDev == nil {
+ t.Error("Device not found after upsert")
+ } else {
+ foundDev.LastSeen = testData.Devs[0].LastSeen // Ignore LastSeen in comparison (workaranod for timezone issues)
+ if !reflect.DeepEqual(*foundDev, *testData.Devs[0]) {
+ t.Error(mismatchErrorString("Device", foundDev, testData.Devs[0]))
+ }
+ }
+
+ // Test update
+ testData.Devs[0].Platform = "Web"
+ err = adp.DeviceUpsert(types.ParseUserId("usr"+testData.Users[0].Id), testData.Devs[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ cursor2, err := rdb.Table("users").Get(testData.Users[0].Id).Field("Devices").Run(conn)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer cursor2.Close()
+
+ if err = cursor2.One(&got); err != nil {
+ t.Fatal(err)
+ }
+
+ // Find the updated device
+ foundDev = nil
+ for _, dev := range got {
+ if dev != nil && dev.DeviceId == testData.Devs[0].DeviceId {
+ foundDev = dev
+ break
+ }
+ }
+ if foundDev == nil || foundDev.Platform != "Web" {
+ t.Error("Device not updated.", foundDev)
+ }
+
+ // Test add same device to another user
+ err = adp.DeviceUpsert(types.ParseUserId("usr"+testData.Users[1].Id), testData.Devs[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ err = adp.DeviceUpsert(types.ParseUserId("usr"+testData.Users[2].Id), testData.Devs[1])
+ if err != nil {
+ t.Error(err)
+ }
+}
+
+func TestMessageAttachments(t *testing.T) {
+ fids := []string{testData.Files[0].Id, testData.Files[1].Id}
+ err := adp.FileLinkAttachments("", types.ZeroUid, types.ParseUid(testData.Msgs[1].Id), fids)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Check if attachments were linked to message
+ cursor, err := rdb.Table("messages").Get(testData.Msgs[1].Id).Field("Attachments").Run(conn)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer cursor.Close()
+
+ var attachments []string
+ if err = cursor.All(&attachments); err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(attachments, fids) {
+ t.Error(mismatchErrorString("Attachments", attachments, fids))
+ }
+
+ // Check if use count was incremented in fileuploads
+ cursor2, err := rdb.Table("fileuploads").Get(testData.Files[0].Id).Field("UseCount").Run(conn)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer cursor2.Close()
+
+ var usecount int
+ if err = cursor2.One(&usecount); err != nil {
+ t.Fatal(err)
+ }
+ if usecount != 1 {
+ t.Error(mismatchErrorString("UseCount", usecount, 1))
+ }
+}
+
+func TestFileFinishUpload(t *testing.T) {
+ got, err := adp.FileFinishUpload(testData.Files[0], true, 22222)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Status != types.UploadCompleted {
+ t.Error(mismatchErrorString("Status", got.Status, types.UploadCompleted))
+ }
+ if got.Size != 22222 {
+ t.Error(mismatchErrorString("Size", got.Size, 22222))
+ }
+}
+
+// ================== Other tests =================================
+func TestDeviceGetAll(t *testing.T) {
+ uid0 := types.ParseUserId("usr" + testData.Users[0].Id)
+ uid1 := types.ParseUserId("usr" + testData.Users[1].Id)
+ uid2 := types.ParseUserId("usr" + testData.Users[2].Id)
+ gotDevs, count, err := adp.DeviceGetAll(uid0, uid1, uid2)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if count < 1 {
+ t.Fatal(mismatchErrorString("count", count, ">=1"))
+ }
+ // Test that devices exist for the users
+ if len(gotDevs) == 0 {
+ t.Error("Expected devices for users")
+ }
+}
+
+func TestDeviceDelete(t *testing.T) {
+ err := adp.DeviceDelete(types.ParseUserId("usr"+testData.Users[1].Id), testData.Devs[0].DeviceId)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ cursor, err := rdb.Table("users").Get(testData.Users[1].Id).Field("Devices").Run(conn)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer cursor.Close()
+
+ var devices map[string]*types.DeviceDef
+ if err = cursor.One(&devices); err != nil && err != rdb.ErrEmptyResult {
+ t.Fatal(err)
+ }
+
+ // Check that the specific device is deleted
+ for _, dev := range devices {
+ if dev != nil && dev.DeviceId == testData.Devs[0].DeviceId {
+ t.Error("Device not deleted:", dev)
+ }
+ }
+
+ err = adp.DeviceDelete(types.ParseUserId("usr"+testData.Users[2].Id), "")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ cursor2, err := rdb.Table("users").Get(testData.Users[2].Id).Field("Devices").Run(conn)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer cursor2.Close()
+
+ var allDevices map[string]*types.DeviceDef
+ if err = cursor2.One(&allDevices); err != nil && err != rdb.ErrEmptyResult {
+ t.Fatal(err)
+ }
+ if allDevices != nil && len(allDevices) > 0 {
+ t.Error("All devices not deleted:", allDevices)
+ }
+}
+
+// ================== Persistent Cache tests ======================
+func TestPCacheUpsert(t *testing.T) {
+ err := adp.PCacheUpsert("test_key", "test_value", false)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Test duplicate with failOnDuplicate = true
+ err = adp.PCacheUpsert("test_key2", "test_value2", true)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ err = adp.PCacheUpsert("test_key2", "new_value", true)
+ if err != types.ErrDuplicate {
+ t.Error("Expected duplicate error")
+ }
+}
+
+func TestPCacheGet(t *testing.T) {
+ value, err := adp.PCacheGet("test_key")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if value != "test_value" {
+ t.Error(mismatchErrorString("Cache value", value, "test_value"))
+ }
+
+ // Test not found
+ value, err = adp.PCacheGet("nonexistent")
+ if err != types.ErrNotFound {
+ t.Errorf("Expected not found error but got '%s', %s", value, err)
+ }
+}
+
+func TestPCacheDelete(t *testing.T) {
+ err := adp.PCacheDelete("test_key")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Verify deleted
+ _, err = adp.PCacheGet("test_key")
+ if err != types.ErrNotFound {
+ t.Error("Key should be deleted")
+ }
+}
+
+func TestPCacheExpire(t *testing.T) {
+ // Insert some test keys with prefix and CreatedAt
+ adp.PCacheUpsert("prefix_key1", "value1", true)
+ adp.PCacheUpsert("prefix_key2", "value2", true)
+
+ // Expire keys older than now (should delete all test keys)
+ err := adp.PCacheExpire("prefix_", time.Now().Add(1*time.Minute))
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+// ================== Delete tests ================================
+func TestCredDel(t *testing.T) {
+ err := adp.CredDel(types.ParseUserId("usr"+testData.Users[0].Id), "email", "alice@test.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ cursor, err := rdb.Table("credentials").Filter(map[string]any{"Method": "email", "Value": "alice@test.example.com"}).Run(conn)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer cursor.Close()
+
+ var results []any
+ if err = cursor.All(&results); err != nil {
+ t.Fatal(err)
+ }
+ if len(results) != 0 {
+ t.Error("Got result but shouldn't", results)
+ }
+
+ err = adp.CredDel(types.ParseUserId("usr"+testData.Users[1].Id), "", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ cursor2, err := rdb.Table("credentials").GetAllByIndex("User", testData.Users[1].Id).Run(conn)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer cursor2.Close()
+
+ if err = cursor2.All(&results); err != nil {
+ t.Fatal(err)
+ }
+ if len(results) != 0 {
+ t.Error("Got result but shouldn't", results)
+ }
+}
+
+func TestAuthDelScheme(t *testing.T) {
+ // Test deleting auth scheme
+ err := adp.AuthDelScheme(types.ParseUserId("usr"+testData.Recs[1].UserId), testData.Recs[1].Scheme)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Verify deleted
+ _, _, _, _, err = adp.AuthGetRecord(types.ParseUserId("usr"+testData.Recs[1].UserId), testData.Recs[1].Scheme)
+ if err != types.ErrNotFound {
+ t.Error("Auth record should be deleted")
+ }
+}
+
+func TestAuthDelAllRecords(t *testing.T) {
+ delCount, err := adp.AuthDelAllRecords(types.ParseUserId("usr" + testData.Recs[0].UserId))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if delCount != 1 {
+ t.Error(mismatchErrorString("delCount", delCount, 1))
+ }
+
+ // With dummy user
+ delCount, _ = adp.AuthDelAllRecords(dummyUid1)
+ if delCount != 0 {
+ t.Error(mismatchErrorString("delCount", delCount, 0))
+ }
+}
+
+func TestMessageDeleteList(t *testing.T) {
+ toDel := types.DelMessage{
+ ObjHeader: types.ObjHeader{
+ Id: testData.UGen.GetStr(),
+ CreatedAt: testData.Now,
+ UpdatedAt: testData.Now,
+ },
+ Topic: testData.Topics[1].Id,
+ DeletedFor: testData.Users[2].Id,
+ DelId: 1,
+ SeqIdRanges: []types.Range{{Low: 9}, {Low: 3, Hi: 7}},
+ }
+ err := adp.MessageDeleteList(toDel.Topic, &toDel)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Check messages in topic - some should be soft deleted
+ cursor, err := rdb.Table("messages").Filter(map[string]any{"Topic": toDel.Topic}).Run(conn)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer cursor.Close()
+
+ var messages []types.Message
+ if err = cursor.All(&messages); err != nil {
+ t.Fatal(err)
+ }
+
+ // Verify soft deletion worked
+ foundSoftDeleted := false
+ for _, msg := range messages {
+ if msg.SeqId >= 3 && msg.SeqId <= 7 || msg.SeqId == 9 {
+ if msg.DeletedFor != nil {
+ for _, del := range msg.DeletedFor {
+ if del.User == toDel.DeletedFor {
+ foundSoftDeleted = true
+ break
+ }
+ }
+ }
+ }
+ }
+ if !foundSoftDeleted {
+ t.Error("Expected to find soft-deleted messages")
+ }
+
+ // Hard delete test
+ toDel = types.DelMessage{
+ ObjHeader: types.ObjHeader{
+ Id: testData.UGen.GetStr(),
+ CreatedAt: testData.Now,
+ UpdatedAt: testData.Now,
+ },
+ Topic: testData.Topics[0].Id,
+ DelId: 3,
+ SeqIdRanges: []types.Range{{Low: 1, Hi: 3}},
+ }
+ err = adp.MessageDeleteList(toDel.Topic, &toDel)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Check if messages content was cleared (hard delete)
+ cursor2, err := rdb.Table("messages").Filter(map[string]any{"Topic": toDel.Topic}).Run(conn)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer cursor2.Close()
+
+ if err = cursor2.All(&messages); err != nil {
+ t.Fatal(err)
+ }
+ for _, msg := range messages {
+ if msg.SeqId >= 1 && msg.SeqId <= 3 {
+ if msg.Content != nil && msg.DelId == 0 {
+ t.Error("Message not properly hard deleted:", msg.SeqId)
+ }
+ }
+ }
+
+ err = adp.MessageDeleteList(testData.Topics[0].Id, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ cursor3, err := rdb.Table("messages").Filter(map[string]any{"Topic": testData.Topics[0].Id}).Run(conn)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer cursor3.Close()
+
+ if err = cursor3.All(&messages); err != nil {
+ t.Fatal(err)
+ }
+ if len(messages) != 0 {
+ t.Error("Result should be empty:", messages)
+ }
+}
+
+func TestTopicDelete(t *testing.T) {
+ err := adp.TopicDelete(testData.Topics[1].Id, false, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ cursor, err := rdb.Table("topics").Get(testData.Topics[1].Id).Field("State").Run(conn)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer cursor.Close()
+
+ var state types.ObjState
+ if err = cursor.One(&state); err != nil {
+ t.Fatal(err)
+ }
+ if state != types.StateDeleted {
+ t.Error("Soft delete failed:", state)
+ }
+
+ err = adp.TopicDelete(testData.Topics[0].Id, false, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ cursor2, err := rdb.Table("topics").Get(testData.Topics[0].Id).Run(conn)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer cursor2.Close()
+
+ if !cursor2.IsNil() {
+ t.Error("Hard delete failed - topic still exists")
+ }
+}
+
+func TestFileDeleteUnused(t *testing.T) {
+ // time.Now() is correct (as opposite to testData.Now):
+ // the FileFinishUpload uses time.Now() as a timestamp.
+ locs, err := adp.FileDeleteUnused(time.Now().Add(1*time.Minute), 999)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(locs) < 1 {
+ t.Log("No unused files to delete - this is expected in test environment")
+ }
+}
+
+func TestUserDelete(t *testing.T) {
+ err := adp.UserDelete(types.ParseUserId("usr"+testData.Users[0].Id), false)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ cursor, err := rdb.Table("users").Get(testData.Users[0].Id).Field("State").Run(conn)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer cursor.Close()
+
+ var state types.ObjState
+ if err = cursor.One(&state); err != nil {
+ t.Fatal(err)
+ }
+ if state != types.StateDeleted {
+ t.Error("User soft delete failed", state)
+ }
+
+ err = adp.UserDelete(types.ParseUserId("usr"+testData.Users[1].Id), true)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ cursor2, err := rdb.Table(
+ "users").Get(testData.Users[1].Id).Run(conn)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer cursor2.Close()
+
+ if !cursor2.IsNil() {
+ t.Error("User hard delete failed - user still exists")
+ }
+}
+
+func TestMessageGetDeleted(t *testing.T) {
+ qOpts := types.QueryOpt{
+ Since: 1,
+ Before: 10,
+ Limit: 999,
+ }
+ got, err := adp.MessageGetDeleted(testData.Topics[1].Id, types.ParseUserId("usr"+testData.Users[2].Id), &qOpts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 1 {
+ t.Error(mismatchErrorString("result length", len(got), 1))
+ }
+}
+
+func TestUserUnreadCount(t *testing.T) {
+ uids := []types.Uid{
+ types.ParseUserId("usr" + testData.Users[1].Id),
+ types.ParseUserId("usr" + testData.Users[2].Id),
+ }
+ expected := map[types.Uid]int{uids[0]: 0, uids[1]: 166}
+ counts, err := adp.UserUnreadCount(uids...)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(counts) != 2 {
+ t.Error(mismatchErrorString("UnreadCount length", len(counts), 2))
+ }
+
+ for uid, unread := range counts {
+ if expected[uid] != unread {
+ t.Error(mismatchErrorString("UnreadCount", unread, expected[uid]))
+ }
+ }
+
+ // Test not found (even if the account is not found, the call must return one record).
+ counts, err = adp.UserUnreadCount(dummyUid1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(counts) != 1 {
+ t.Error(mismatchErrorString("UnreadCount length (dummy)", len(counts), 1))
+ }
+ if counts[dummyUid1] != 0 {
+ t.Error(mismatchErrorString("Non-zero UnreadCount (dummy)", counts[dummyUid1], 0))
+ }
+}
+
+// ================================================================
+func mismatchErrorString(key string, got, want any) string {
+ return fmt.Sprintf("%s mismatch:\nGot = %+v\nWant = %+v", key, got, want)
+}
+
+func init() {
+ logs.Init(os.Stderr, "stdFlags")
+ adp = backend.GetTestAdapter()
+ conffile := flag.String("config", "./test.conf", "config of the database connection")
+
+ if file, err := os.Open(*conffile); err != nil {
+ log.Fatal("Failed to read config file:", err)
+ } else if err = json.NewDecoder(jcr.New(file)).Decode(&config); err != nil {
+ log.Fatal("Failed to parse config file:", err)
+ }
+
+ if adp == nil {
+ log.Fatal("Database adapter is missing")
+ }
+ if adp.IsOpen() {
+ log.Print("Connection is already opened")
+ }
+
+ err := adp.Open(config.Adapters[adp.GetName()])
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ conn = adp.GetTestDB().(*rdb.Session)
+ testData = test_data.InitTestData()
+ if testData == nil {
+ log.Fatal("Failed to initialize test data")
+ }
+ store.SetTestUidGenerator(*testData.UGen)
+}
diff --git a/server/db/rethinkdb/tests/test.conf b/server/db/rethinkdb/tests/test.conf
index e69de29..ec8e88d 100644
--- a/server/db/rethinkdb/tests/test.conf
+++ b/server/db/rethinkdb/tests/test.conf
@@ -0,0 +1,11 @@
+{
+ "reset_db_data": true,
+ "adapters": {
+ "rethinkdb": {
+ // Address(es) of RethinkDB node(s): either a string or an array of strings.
+ "addresses": "localhost:28015",
+ // Name of the main database.
+ "database": "tinode_test"
+ }
+ }
+}
diff --git a/server/drafty/drafty.go b/server/drafty/drafty.go
index e69de29..7e6638a 100644
--- a/server/drafty/drafty.go
+++ b/server/drafty/drafty.go
@@ -0,0 +1,613 @@
+// Package drafty contains utilities for conversion from Drafty to plain text.
+package drafty
+
+import (
+ "encoding/json"
+ "errors"
+ "sort"
+ "strings"
+)
+
+const (
+ // Maximum size of style payload in preview, in bytes.
+ maxDataSize = 128
+ // Maximum count of payload fields in preview.
+ maxDataCount = 8
+)
+
+var (
+ errUnrecognizedContent = errors.New("content unrecognized")
+ errInvalidContent = errors.New("invalid format")
+)
+
+type style struct {
+ Tp string `json:"tp,omitempty"`
+ At int `json:"at,omitempty"`
+ Length int `json:"len,omitempty"`
+ Key int `json:"key,omitempty"`
+}
+
+type entity struct {
+ Tp string `json:"tp,omitempty"`
+ Data map[string]any `json:"data,omitempty"`
+}
+
+type document struct {
+ Txt string `json:"txt,omitempty"`
+ Fmt []style `json:"fmt,omitempty"`
+ Ent []entity `json:"ent,omitempty"`
+
+ // Parsed out grapheme clusters.
+ gc *graphemes
+}
+
+type span struct {
+ tp string
+ at int
+ end int
+ key int
+ data map[string]any
+}
+
+type node struct {
+ gc *graphemes
+ sp *span
+ children []*node
+}
+
+type previewState struct {
+ drafty *document
+ maxLength int
+ keymap map[int]int
+}
+
+// Preview shortens Drafty to the specified length (in graphemes), removes quoted text, leading line breaks,
+// and large content from entities making them suitable for a one-line preview,
+// for example for showing in push notifications.
+// The return value is a Drafty document encoded as JSON string.
+func Preview(content any, length int) (string, error) {
+ doc, err := decodeAsDrafty(content)
+ if err != nil {
+ return "", err
+ }
+ if doc == nil {
+ return "", nil
+ }
+
+ tree, err := toTree(doc)
+ if err != nil {
+ return "", err
+ }
+ if tree == nil {
+ return "", nil
+ }
+
+ state := previewState{
+ drafty: &document{
+ Fmt: make([]style, 0, len(doc.Fmt)),
+ Ent: make([]entity, 0, len(doc.Ent)),
+ },
+ maxLength: length,
+ keymap: make(map[int]int),
+ }
+
+ if err = previewFormatter(tree, &state); err != nil {
+ return "", err
+ }
+
+ state.drafty.Txt = state.drafty.gc.string()
+ data, err := json.Marshal(state.drafty)
+ return string(data), err
+}
+
+type plainTextState struct {
+ txt string
+}
+
+// PlainText converts drafty document to plain text with some basic markdown-like formatting.
+// Deprecated: use Preview for new development.
+func PlainText(content any) (string, error) {
+ doc, err := decodeAsDrafty(content)
+ if err != nil {
+ return "", err
+ }
+ if doc == nil {
+ return "", nil
+ }
+
+ tree, err := toTree(doc)
+ if err != nil {
+ return "", err
+ }
+
+ state := plainTextState{}
+
+ err = plainTextFormatter(tree, &state)
+ if err != nil {
+ return "", err
+ }
+
+ return strings.TrimSpace(string(state.txt)), nil
+}
+
+// styleToSpan converts Drafty style to internal representation.
+func (s *span) styleToSpan(in *style) error {
+ s.tp = in.Tp
+ s.at = in.At
+ s.end = in.Length
+ if s.end < 0 {
+ return errInvalidContent
+ }
+ s.end += s.at
+
+ if s.tp == "" {
+ s.key = in.Key
+ if s.key < 0 {
+ return errInvalidContent
+ }
+ }
+
+ return nil
+}
+
+type spanfmt struct {
+ dec string
+ isVoid bool
+}
+
+// Plain text formatting of the Drafty tags. Only non-blank tags need to be listed.
+var tags = map[string]spanfmt{
+ "BR": {"\n", true},
+ "CO": {"`", false},
+ "DL": {"~", false},
+ "EM": {"_", false},
+ "EX": {"", true},
+ "ST": {"*", false},
+}
+
+// Type of the formatter to apply to tree nodes.
+type formatter func(n *node, state any) error
+
+// toTree converts a drafty document into a tree of formatted spans.
+// Each node of the tree is uniformly formatted.
+func toTree(drafty *document) (*node, error) {
+ if len(drafty.Fmt) == 0 {
+ return &node{gc: drafty.gc}, nil
+ }
+
+ textLen := drafty.gc.length()
+
+ var spans []*span
+ for i := range drafty.Fmt {
+ s := span{}
+ if err := s.styleToSpan(&drafty.Fmt[i]); err != nil {
+ return nil, err
+ }
+ if s.at < -1 || s.end > textLen {
+ return nil, errInvalidContent
+ }
+
+ // Denormalize entities into spans.
+ if s.tp == "" && len(drafty.Ent) > 0 {
+ if s.key < 0 || s.key >= len(drafty.Ent) {
+ return nil, errInvalidContent
+ }
+
+ s.data = drafty.Ent[s.key].Data
+ s.tp = drafty.Ent[s.key].Tp
+ }
+ if s.tp == "" && s.at == 0 && s.end == 0 && s.key == 0 {
+ return nil, errUnrecognizedContent
+ }
+ spans = append(spans, &s)
+ }
+
+ // Sort spans first by start index (asc) then by length (desc).
+ sort.Slice(spans, func(i, j int) bool {
+ if spans[i].at == spans[j].at {
+ // longer one comes first
+ return spans[i].end > spans[j].end
+ }
+ return spans[i].at < spans[j].at
+ })
+
+ // Drop the second format when spans overlap like '_first *second_ third*'.
+ var filtered []*span
+ end := -2
+ for _, span := range spans {
+ if span.at < end && span.end > end {
+ continue
+ }
+ filtered = append(filtered, span)
+ if span.end > end {
+ end = span.end
+ }
+ }
+
+ // Iterate over an array of spans.
+ children, err := forEach(drafty.gc, 0, textLen, filtered)
+ if err != nil {
+ return nil, err
+ }
+
+ return &node{children: children}, nil
+}
+
+// forEach recursively iterates nested spans to form a tree.
+func forEach(g *graphemes, start, end int, spans []*span) ([]*node, error) {
+ var result []*node
+
+ // Process ranges calling iterator for each range.
+ for i := 0; i < len(spans); i++ {
+ sp := spans[i]
+
+ if sp.at < 0 {
+ // Attachment
+ result = append(result, &node{sp: sp})
+ continue
+ }
+ // Add un-styled range before the styled span starts.
+ if start < sp.at {
+ result = append(result, &node{gc: g.slice(start, sp.at)})
+ start = sp.at
+ }
+
+ // Get all spans which are within current span.
+ var subspans []*span
+ for si := i + 1; si < len(spans) && spans[si].at < sp.end; si++ {
+ subspans = append(subspans, spans[si])
+ i = si
+ }
+
+ if tags[sp.tp].isVoid {
+ result = append(result, &node{sp: sp})
+ } else {
+ children, err := forEach(g, start, sp.end, subspans)
+ if err != nil {
+ return nil, err
+ }
+ result = append(result, &node{children: children, sp: sp})
+ }
+ start = sp.end
+ }
+
+ // Add the remaining unformatted range.
+ if start < end {
+ result = append(result, &node{gc: g.slice(start, end)})
+ }
+
+ return result, nil
+}
+
+// plainTextFormatter converts a tree of formatted spans into plan text.
+func plainTextFormatter(n *node, ctx any) error {
+ if n.sp != nil && n.sp.tp == "QQ" {
+ return nil
+ }
+
+ var text string
+ if len(n.children) > 0 {
+ state := &plainTextState{}
+ for _, c := range n.children {
+ if err := plainTextFormatter(c, state); err != nil {
+ return err
+ }
+ }
+ text = string(state.txt)
+ } else {
+ text = n.gc.string()
+ }
+
+ state := ctx.(*plainTextState)
+
+ if n.sp == nil {
+ state.txt += text
+ return nil
+ }
+
+ switch n.sp.tp {
+ case "ST", "EM", "DL", "CO":
+ state.txt += tags[n.sp.tp].dec + text + tags[n.sp.tp].dec
+
+ case "LN":
+ if url, ok := nullableMapGet(n.sp.data, "url"); ok && url != text {
+ state.txt += "[" + text + "](" + url + ")"
+ } else {
+ state.txt += text
+ }
+
+ case "MN", "HT":
+ state.txt += text
+ case "BR":
+ state.txt += "\n"
+ case "AU", "EX", "IM", "VD":
+ name, ok := nullableMapGet(n.sp.data, "name")
+ if !ok || name == "" {
+ name = "?"
+ }
+ expand := map[string]string{"AU": "AUDIO", "EX": "FILE", "IM": "IMAGE", "VD": "VIDEO"}
+ state.txt += "[" + expand[n.sp.tp] + " '" + name + "']"
+ case "VC":
+ state.txt += "[CALL]"
+ default:
+ state.txt += text
+ }
+ return nil
+}
+
+// previewFormatter converts a tree of formatted spans into a shortened drafty document.
+func previewFormatter(n *node, ctx any) error {
+
+ state := ctx.(*previewState)
+ at := state.drafty.gc.length()
+ if at >= state.maxLength {
+ // Maximum doc length reached.
+ return nil
+ }
+
+ if n.sp != nil {
+ if n.sp.tp == "QQ" {
+ // Skip quoted text
+ return nil
+ }
+ if n.sp.tp == "BR" && at == 0 {
+ // Skip leading new lines.
+ return nil
+ }
+ }
+
+ if len(n.children) > 0 {
+ for _, c := range n.children {
+ if err := previewFormatter(c, ctx); err != nil {
+ return err
+ }
+ }
+ } else {
+ increment := n.gc.length()
+ if increment > 0 {
+ if at+increment > state.maxLength {
+ increment = state.maxLength - at
+ }
+ if state.drafty.gc == nil {
+ state.drafty.gc = prepareGraphemes("")
+ }
+ state.drafty.gc = state.drafty.gc.append(n.gc.slice(0, increment))
+ }
+ }
+
+ end := state.drafty.gc.length()
+
+ if n.sp != nil {
+ fmt := style{}
+ if n.sp.at < 0 {
+ fmt.At = -1
+ } else if at < end || tags[n.sp.tp].isVoid {
+ fmt.At = at
+ fmt.Length = end - at
+ } else {
+ return nil
+ }
+
+ if n.sp.data != nil {
+ // Check if we have already seen this payload.
+ key, ok := state.keymap[n.sp.key]
+ if !ok {
+ // Payload not found, add it.
+ ent := entity{Tp: n.sp.tp, Data: copyLight(n.sp.data)}
+ key = len(state.drafty.Ent)
+ state.keymap[n.sp.key] = key
+ state.drafty.Ent = append(state.drafty.Ent, ent)
+ }
+ fmt.Key = key
+ } else {
+ fmt.Tp = n.sp.tp
+ }
+
+ state.drafty.Fmt = append(state.drafty.Fmt, fmt)
+ }
+ return nil
+}
+
+// nullableMapGet is a helper method to get a possibly missing string from a possibly nil map.
+func nullableMapGet(data map[string]any, key string) (string, bool) {
+ if data == nil {
+ return "", false
+ }
+ str, ok := data[key].(string)
+ return str, ok
+}
+
+// decodeAsDrafty converts a string or a map to a Drafty document.
+func decodeAsDrafty(content any) (*document, error) {
+ if content == nil {
+ return nil, nil
+ }
+
+ var drafty *document
+
+ switch tmp := content.(type) {
+ case string:
+ drafty = &document{gc: prepareGraphemes(tmp)}
+ case map[string]any:
+ drafty = &document{}
+ correct := 0
+ if txt, ok := tmp["txt"].(string); ok {
+ drafty.Txt = txt
+ drafty.gc = prepareGraphemes(txt)
+ correct++
+ }
+ if ifmt, ok := tmp["fmt"].([]any); ok {
+ for i := range ifmt {
+ st, err := decodeAsStyle(ifmt[i])
+ if err != nil {
+ return nil, err
+ }
+ if st != nil {
+ drafty.Fmt = append(drafty.Fmt, *st)
+ }
+ correct++
+ }
+ }
+ if ient, ok := tmp["ent"].([]any); ok {
+ for i := range ient {
+ ent, err := decodeAsEntity(ient[i])
+ if err != nil {
+ return nil, err
+ }
+ if ent != nil {
+ drafty.Ent = append(drafty.Ent, *ent)
+ }
+ correct++
+ }
+ }
+ // At least one drafty element must be present.
+ if correct == 0 {
+ return nil, errUnrecognizedContent
+ }
+ default:
+ return nil, errUnrecognizedContent
+ }
+
+ return drafty, nil
+}
+
+// decodeAsStyle converts a map to a style.
+func decodeAsStyle(content any) (*style, error) {
+ if content == nil {
+ return nil, nil
+ }
+
+ tmp, ok := content.(map[string]any)
+ if !ok {
+ return nil, errUnrecognizedContent
+ }
+
+ var err error
+ st := &style{}
+ st.Tp, _ = tmp["tp"].(string)
+
+ st.At, err = intFromNumeric(tmp["at"])
+ if err != nil {
+ return nil, err
+ }
+
+ st.Length, err = intFromNumeric(tmp["len"])
+ if err != nil {
+ return nil, err
+ }
+
+ if st.Tp == "" {
+ st.Key, err = intFromNumeric(tmp["key"])
+ if err != nil {
+ return nil, err
+ }
+ if st.Key < 0 {
+ return nil, errInvalidContent
+ }
+ }
+
+ return st, nil
+}
+
+// decodeAsEntity converts a map to a entity.
+func decodeAsEntity(content any) (*entity, error) {
+ if content == nil {
+ return nil, nil
+ }
+
+ tmp, ok := content.(map[string]any)
+ if !ok {
+ return nil, errUnrecognizedContent
+ }
+
+ ent := &entity{}
+
+ ent.Tp, _ = tmp["tp"].(string)
+ if ent.Tp == "" {
+ return nil, errInvalidContent
+ }
+
+ ent.Data, _ = tmp["data"].(map[string]any)
+
+ return ent, nil
+}
+
+// A whitelist of entity fields to copy.
+var lightFields = []string{"mime", "name", "width", "height", "size", "url", "ref"}
+
+// copyLight makes a copy of an entity retaining keys from the white list.
+// It also ensures the copied values are either basic types of fixed length or a
+// sufficiently short string/byte slice, and the count of entries is not too great.
+func copyLight(in any) map[string]any {
+ data, ok := in.(map[string]any)
+ if !ok {
+ return nil
+ }
+
+ result := map[string]any{}
+ if len(data) > 0 {
+ for _, key := range lightFields {
+ if val, ok := data[key]; ok {
+ if isFixedLengthType(val) {
+ result[key] = val
+ } else if l := getVariableTypeSize(val); l >= 0 && l < maxDataSize {
+ result[key] = val
+ }
+ }
+
+ if len(result) > maxDataCount {
+ break
+ }
+ }
+ if len(result) == 0 {
+ result = nil
+ }
+ }
+ return result
+}
+
+// intFromNumeric is a helper methjod to get an integer from a value of any numeric type.
+func intFromNumeric(num any) (int, error) {
+ if num == nil {
+ return 0, nil
+ }
+ switch i := num.(type) {
+ case int:
+ return i, nil
+ case int16:
+ return int(i), nil
+ case int32:
+ return int(i), nil
+ case int64:
+ return int(i), nil
+ case float32:
+ return int(i), nil
+ case float64:
+ return int(i), nil
+ default:
+ return 0, errInvalidContent
+ }
+}
+
+// getVariableTypeSize checks that the given field is a string or a byte slice and gets its size in bytes.
+func getVariableTypeSize(x any) int {
+ switch val := x.(type) {
+ case string:
+ return len(val)
+ case []byte:
+ return len(val)
+ default:
+ return -1
+ }
+}
+
+// isFixedLengthType checks if the given value is a type of a fixed size.
+func isFixedLengthType(x any) bool {
+ switch x.(type) {
+ case nil, bool, int, int8, int16, int32, int64, uint, uint8, uint16,
+ uint32, uint64, float32, float64, complex64, complex128:
+ return true
+ default:
+ return false
+ }
+}
diff --git a/server/drafty/drafty_test.go b/server/drafty/drafty_test.go
index e69de29..e08bf99 100644
--- a/server/drafty/drafty_test.go
+++ b/server/drafty/drafty_test.go
@@ -0,0 +1,183 @@
+package drafty
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+var validInputs = []string{
+ `"This is a plain text string."`,
+ `{
+ "txt":"This is a string with a line break.",
+ "fmt":[{"at":9,"tp":"BR"}]
+ }`,
+ `{
+ "ent":[{"data":{"mime":"image/jpeg","name":"hello.jpg","val":"<38992, bytes: ...>","width":100, "height":80},"tp":"EX"}],
+ "fmt":[{"at":-1, "key":0}]
+ }`,
+ `{
+ "ent":[{"data":{"url":"https://www.youtube.com/watch?v=dQw4w9WgXcQ"},"tp":"LN"}],
+ "fmt":[{"len":22}],
+ "txt":"https://api.tinode.co/"
+ }`,
+ `{
+ "ent":[{"data":{"url":"https://api.tinode.co/"},"tp":"LN"}],
+ "fmt":[{"len":22}],
+ "txt":"https://api.tinode.co/"
+ }`,
+ `{
+ "ent":[{"data":{"url":"http://tinode.co"},"tp":"LN"}],
+ "fmt":[{"at":9,"len":3}, {"at":4,"len":3}],
+ "txt":"Url one, two"
+ }`,
+ `{
+ "ent":[{"data":{"height":213,"mime":"image/jpeg","name":"roses.jpg","val":"<38992, bytes: ...>","width":638},"tp":"IM"}],
+ "fmt":[{"len":1}],
+ "txt":" "
+ }`,
+ `{
+ "txt":"This text has staggered formats",
+ "fmt":[{"at":5,"len":8,"tp":"EM"},{"at":10,"len":13,"tp":"ST"}]
+ }`,
+ `{
+ "txt":"This text is formatted and deleted too",
+ "fmt":[{"at":5,"len":4,"tp":"ST"},{"at":13,"len":9,"tp":"EM"},{"at":35,"len":3,"tp":"ST"},{"at":27,"len":11,"tp":"DL"}]
+ }`,
+ `{
+ "txt":"мультибайтовый юникод",
+ "fmt":[{"len":14,"tp":"ST"},{"at":15,"len":6,"tp":"EM"}]
+ }`,
+ `{
+ "txt":"Alice Johnson This is a test",
+ "fmt":[{"at":13,"len":1,"tp":"BR"},{"at":15,"len":1},{"len":13,"key":1},{"len":16,"tp":"QQ"},{"at":16,"len":1,"tp":"BR"}],
+ "ent":[{"tp":"IM","data":{"mime":"image/jpeg","val":"<1292, bytes: /9j/4AAQSkZJ...rehH5o6D/9k=>","width":25,"height":14,"size":968}},{"tp":"MN","data":{"color":2}}]
+ }`,
+ `{
+ "txt": "Hello 😀, o😀k https://google.com",
+ "fmt":[{"at":9,"len":3,"tp":"ST"},{"at":13,"len":18}],
+ "ent":[{"tp":"LN","data":{"url":"https://google.com"}}]
+ }`,
+ `{
+ "txt": "Hi 🏴🏴🏴🏴 🏴🏴🏴🏴 🏴🏴🏴🏴",
+ "fmt":[{"at":3,"len":4,"tp":"ST"},{"at":8,"len":4,"tp":"ST"}]
+ }`,
+}
+
+var invalidInputs = []string{
+ `{
+ "txt":"This should fail",
+ "fmt":[{"at":50,"len":-45,"tp":"ST"}]
+ }`,
+ `{
+ "txt":"This should fail",
+ "fmt":[{"at":0,"len":50,"tp":"ST"}]
+ }`,
+ `{
+ "ent":[],
+ "fmt":[{"at":0,"len":1,"tp":"ST","key":1}]
+ }`,
+ `{
+ "ent":[{"xy": true, "tp": "XY"}],
+ "fmt":[{"len":1,"key":-2}],
+ "txt":" "
+ }`,
+ `{
+ "ent":[{"data": true, "tp": "ST"}],
+ "fmt":[{"len":1,"key":42, "at":"33"}],
+ "txt":"123"
+ }`,
+ `{
+ "txt":true
+ }`,
+ `{
+ "invalid":[{"data": true, "tp": "ST"}],
+ "content":[{"len":1, "key":42}]
+ }`,
+}
+
+func TestPlainText(t *testing.T) {
+ expect := []string{
+ "This is a plain text string.",
+ "This is a\n string with a line break.",
+ "[FILE 'hello.jpg']",
+ "[https://api.tinode.co/](https://www.youtube.com/watch?v=dQw4w9WgXcQ)",
+ "https://api.tinode.co/",
+ "Url [one](http://tinode.co), [two](http://tinode.co)",
+ "[IMAGE 'roses.jpg']",
+ "This _text has_ staggered formats",
+ "This *text* is _formatted_ and ~deleted *too*~",
+ "*мультибайтовый* _юникод_",
+ "This is a test",
+ "Hello 😀, *o😀k* https://google.com",
+ "Hi *🏴🏴🏴🏴* *🏴🏴🏴🏴* 🏴🏴🏴🏴",
+ }
+
+ for i := range validInputs {
+ var val any
+ if err := json.Unmarshal([]byte(validInputs[i]), &val); err != nil {
+ t.Errorf("Failed to parse input %d '%s': %s", i, validInputs[i], err)
+ }
+ res, err := PlainText(val)
+ if err != nil {
+ t.Errorf("%d failed with error: %s", i, err)
+ } else if res != expect[i] {
+ t.Errorf("%d output '%s' does not match '%s'", i, res, expect[i])
+ }
+ }
+
+ for i := range invalidInputs {
+ var val any
+ if err := json.Unmarshal([]byte(invalidInputs[i]), &val); err != nil {
+ // Don't make it an error: we are not testing validity of json.Unmarshal.
+ t.Logf("Failed to parse input %d '%s': %s", i, invalidInputs[i], err)
+ }
+ res, err := PlainText(val)
+ if err == nil {
+ t.Errorf("invalid input %d '%s' did not cause an error '%s'", i, invalidInputs[i], res)
+ }
+ }
+}
+
+func TestPreview(t *testing.T) {
+ expect := []string{
+ `{"txt":"This is a plain"}`,
+ `{"txt":"This is a strin","fmt":[{"tp":"BR","at":9}]}`,
+ `{"fmt":[{"at":-1}],"ent":[{"tp":"EX","data":{"height":80,"mime":"image/jpeg","name":"hello.jpg","width":100}}]}`,
+ `{"txt":"https://api.tin","fmt":[{"len":15}],"ent":[{"tp":"LN","data":{"url":"https://www.youtube.com/watch?v=dQw4w9WgXcQ"}}]}`,
+ `{"txt":"https://api.tin","fmt":[{"len":15}],"ent":[{"tp":"LN","data":{"url":"https://api.tinode.co/"}}]}`,
+ `{"txt":"Url one, two","fmt":[{"at":4,"len":3},{"at":9,"len":3}],"ent":[{"tp":"LN","data":{"url":"http://tinode.co"}}]}`,
+ `{"txt":" ","fmt":[{"len":1}],"ent":[{"tp":"IM","data":{"height":213,"mime":"image/jpeg","name":"roses.jpg","width":638}}]}`,
+ `{"txt":"This text has s","fmt":[{"tp":"EM","at":5,"len":8}]}`,
+ `{"txt":"This text is fo","fmt":[{"tp":"ST","at":5,"len":4},{"tp":"EM","at":13,"len":2}]}`,
+ `{"txt":"мультибайтовый ","fmt":[{"tp":"ST","len":14}]}`,
+ `{"txt":"This is a test"}`,
+ `{"txt":"Hello 😀, o😀k ht","fmt":[{"tp":"ST","at":9,"len":3},{"at":13,"len":2}],"ent":[{"tp":"LN","data":{"url":"https://google.com"}}]}`,
+ `{"txt":"Hi 🏴🏴🏴🏴 🏴🏴🏴🏴 🏴🏴","fmt":[{"tp":"ST","at":3,"len":4},{"tp":"ST","at":8,"len":4}]}`,
+ }
+ for i := range validInputs {
+ var val any
+ if err := json.Unmarshal([]byte(validInputs[i]), &val); err != nil {
+ t.Errorf("Failed to parse input %d '%s': %s", i, validInputs[i], err)
+ }
+ res, err := Preview(val, 15)
+ if err != nil {
+ t.Errorf("%d failed with error: %s", i, err)
+ } else if res != expect[i] {
+ t.Errorf("%d output '%s' does not match '%s'", i, res, expect[i])
+ }
+ }
+
+ // Only some invalid input should fail these tests.
+ testsToFail := []int{3, 4, 5, 6}
+ for _, i := range testsToFail {
+ var val any
+ if err := json.Unmarshal([]byte(invalidInputs[i]), &val); err != nil {
+ // Don't make it an error: we are not testing validity of json.Unmarshal.
+ t.Logf("Failed to parse input %d '%s': %s", i, invalidInputs[i], err)
+ }
+ res, err := Preview(val, 15)
+ if err == nil {
+ t.Errorf("invalid input %d did not cause an error '%s'", i, res)
+ }
+ }
+}
diff --git a/server/hdl_files.go b/server/hdl_files.go
index e69de29..cf8838b 100644
--- a/server/hdl_files.go
+++ b/server/hdl_files.go
@@ -0,0 +1,646 @@
+/******************************************************************************
+ *
+ * Description :
+ *
+ * Handler of large file uploads/downloads. Validates request first then calls
+ * a handler.
+ *
+ *****************************************************************************/
+
+package main
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "io"
+ "math/rand"
+ "mime"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/tinode/chat/pbx"
+ "github.com/tinode/chat/server/auth"
+ "github.com/tinode/chat/server/logs"
+ "github.com/tinode/chat/server/store"
+ "github.com/tinode/chat/server/store/types"
+ "google.golang.org/grpc/peer"
+)
+
+// Allowed mime types for user-provided Content-type field. Must be alphabetically sorted.
+// Types not in the list are converted to "application/octet-stream".
+// See https://www.iana.org/assignments/media-types/media-types.xhtml
+var allowedMimeTypes = []string{"application/", "audio/", "font/", "image/", "text/", "video/"}
+
+func largeFileServeHTTP(wrt http.ResponseWriter, req *http.Request) {
+ now := types.TimeNow()
+ enc := json.NewEncoder(wrt)
+ mh := store.Store.GetMediaHandler()
+ statsInc("FileDownloadsTotal", 1)
+
+ writeHttpResponse := func(msg *ServerComMessage, err error) {
+ // Gorilla CompressHandler requires Content-Type to be set.
+ wrt.Header().Set("Content-Type", "application/json; charset=utf-8")
+ wrt.WriteHeader(msg.Ctrl.Code)
+ enc.Encode(msg)
+ if err != nil {
+ logs.Warn.Println("media serve:", req.URL.String(), err)
+ }
+ }
+
+ // Preflight request: process before any security checks.
+ if req.Method == http.MethodOptions {
+ headers, statusCode, err := mh.Headers(types.ZeroTenantID, req.Method, req.URL, req.Header, true)
+ if err != nil {
+ writeHttpResponse(decodeStoreError(err, "", now, nil), err)
+ return
+ }
+ for name, values := range headers {
+ for _, value := range values {
+ wrt.Header().Add(name, value)
+ }
+ }
+ if statusCode <= 0 {
+ statusCode = http.StatusNoContent
+ }
+ wrt.WriteHeader(statusCode)
+ logs.Info.Println("media serve: preflight completed")
+ return
+ }
+
+ // Check if this is a GET/HEAD request.
+ if req.Method != http.MethodGet && req.Method != http.MethodHead {
+ writeHttpResponse(ErrOperationNotAllowed("", "", now), errors.New("method '"+req.Method+"' not allowed"))
+ return
+ }
+
+ // Check for API key presence
+ if isValid, _ := checkAPIKey(getAPIKey(req)); !isValid {
+ writeHttpResponse(ErrAPIKeyRequired(now), errors.New("invalid or missing API key"))
+ return
+ }
+
+ // Check authorization: either auth information or SID must be present
+ authMethod, secret := getHttpAuth(req)
+ tenantID, uid, challenge, err := authFileRequest(authMethod, secret, req.FormValue("sid"), getRemoteAddr(req))
+ if err != nil {
+ writeHttpResponse(decodeStoreError(err, "", now, nil), err)
+ return
+ }
+
+ if challenge != nil {
+ writeHttpResponse(InfoChallenge("", now, challenge), nil)
+ return
+ }
+
+ if uid.IsZero() {
+ // Not authenticated
+ writeHttpResponse(ErrAuthRequired("", "", now, now), errors.New("user not authenticated"))
+ return
+ }
+
+ // Check if media handler redirects or adds headers.
+ headers, statusCode, err := mh.Headers(tenantID, req.Method, req.URL, req.Header, true)
+ if err != nil {
+ writeHttpResponse(decodeStoreError(err, "", now, nil), err)
+ return
+ }
+
+ for name, values := range headers {
+ for _, value := range values {
+ wrt.Header().Add(name, value)
+ }
+ }
+
+ if statusCode != 0 {
+ // The handler requested to terminate further processing.
+ wrt.WriteHeader(statusCode)
+ if req.Method == http.MethodGet {
+ enc.Encode(&ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Code: statusCode,
+ Text: http.StatusText(statusCode),
+ Timestamp: now,
+ },
+ })
+ }
+ logs.Info.Println("media serve: completed with status", statusCode, "uid=", uid)
+ return
+ }
+
+ if req.Method == http.MethodHead {
+ wrt.WriteHeader(http.StatusOK)
+ logs.Info.Println("media serve: completed", req.Method, "uid=", uid)
+ return
+ }
+
+ fd, rsc, err := mh.Download(tenantID, req.URL.String())
+ if err != nil {
+ writeHttpResponse(decodeStoreError(err, "", now, nil), err)
+ return
+ }
+
+ defer rsc.Close()
+
+ wrt.Header().Set("Content-Type", fd.MimeType)
+ asAttachment, _ := strconv.ParseBool(req.URL.Query().Get("asatt"))
+ // Force download for html files as a security measure.
+ asAttachment = asAttachment ||
+ strings.Contains(fd.MimeType, "html") ||
+ strings.Contains(fd.MimeType, "xml") ||
+ strings.HasPrefix(fd.MimeType, "application/") ||
+ // The 'message', 'model', and 'multipart' cannot currently appear, but checked anyway in case
+ // DetectContentType changes its logic.
+ strings.HasPrefix(fd.MimeType, "message/") ||
+ strings.HasPrefix(fd.MimeType, "model/") ||
+ strings.HasPrefix(fd.MimeType, "multipart/") ||
+ strings.HasPrefix(fd.MimeType, "text/")
+ if asAttachment {
+ wrt.Header().Set("Content-Disposition", "attachment")
+ }
+
+ http.ServeContent(wrt, req, "", fd.UpdatedAt, rsc)
+
+ logs.Info.Println("media serve: OK, uid=", uid)
+}
+
+// largeFileReceiveHTTP receives files from client over HTTP(S) and passes them to the configured media handler.
+func largeFileReceiveHTTP(wrt http.ResponseWriter, req *http.Request) {
+ now := types.TimeNow()
+ enc := json.NewEncoder(wrt)
+ mh := store.Store.GetMediaHandler()
+ statsInc("FileUploadsTotal", 1)
+
+ writeHttpResponse := func(msg *ServerComMessage, err error) {
+ // Gorilla CompressHandler requires Content-Type to be set.
+ wrt.Header().Set("Content-Type", "application/json; charset=utf-8")
+ wrt.WriteHeader(msg.Ctrl.Code)
+ enc.Encode(msg)
+
+ if err != nil {
+ logs.Info.Println("media upload:", msg.Ctrl.Code, msg.Ctrl.Text, "/", err)
+ }
+ }
+
+ // Preflight request: process before any security checks.
+ if req.Method == http.MethodOptions {
+ headers, statusCode, err := mh.Headers(types.ZeroTenantID, req.Method, req.URL, req.Header, true)
+ if err != nil {
+ writeHttpResponse(decodeStoreError(err, "", now, nil), err)
+ return
+ }
+ for name, values := range headers {
+ for _, value := range values {
+ wrt.Header().Add(name, value)
+ }
+ }
+ if statusCode <= 0 {
+ statusCode = http.StatusNoContent
+ }
+ wrt.WriteHeader(statusCode)
+ logs.Info.Println("media upload: preflight completed")
+ return
+ }
+
+ // Check if this is a POST/PUT/HEAD request.
+ if req.Method != http.MethodPost && req.Method != http.MethodPut && req.Method != http.MethodHead {
+ writeHttpResponse(ErrOperationNotAllowed("", "", now), errors.New("method '"+req.Method+"' not allowed"))
+ return
+ }
+
+ if globals.maxFileUploadSize > 0 {
+ // Enforce maximum upload size.
+ req.Body = http.MaxBytesReader(wrt, req.Body, globals.maxFileUploadSize)
+ }
+
+ // Check for API key presence
+ if isValid, _ := checkAPIKey(getAPIKey(req)); !isValid {
+ writeHttpResponse(ErrAPIKeyRequired(now), nil)
+ return
+ }
+
+ msgID := req.FormValue("id")
+ // Check authorization: either auth information or SID must be present
+ authMethod, secret := getHttpAuth(req)
+ tenantID, uid, challenge, err := authFileRequest(authMethod, secret, req.FormValue("sid"), getRemoteAddr(req))
+ if err != nil {
+ writeHttpResponse(decodeStoreError(err, msgID, now, nil), err)
+ return
+ }
+ if challenge != nil {
+ writeHttpResponse(InfoChallenge(msgID, now, challenge), nil)
+ return
+ }
+ if uid.IsZero() && req.FormValue("topic") != "newacc" {
+ // Not authenticated and not signup.
+ writeHttpResponse(ErrAuthRequired(msgID, "", now, now), nil)
+ return
+ }
+
+ // Check if uploads are handled elsewhere.
+ headers, statusCode, err := mh.Headers(tenantID, req.Method, req.URL, req.Header, true)
+ if err != nil {
+ logs.Info.Println("media upload: headers check failed", err)
+ writeHttpResponse(decodeStoreError(err, "", now, nil), err)
+ return
+ }
+
+ for name, values := range headers {
+ for _, value := range values {
+ wrt.Header().Add(name, value)
+ }
+ }
+
+ if statusCode != 0 {
+ // The handler requested to terminate further processing.
+ wrt.WriteHeader(statusCode)
+ if req.Method == http.MethodPost || req.Method == http.MethodPut {
+ enc.Encode(&ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Code: statusCode,
+ Text: http.StatusText(statusCode),
+ Timestamp: now,
+ },
+ })
+ }
+ logs.Info.Println("media upload: completed with status", statusCode)
+ return
+ }
+
+ if req.Method == http.MethodHead || req.Method == http.MethodOptions {
+ wrt.WriteHeader(http.StatusOK)
+ logs.Info.Println("media upload: completed", req.Method)
+ return
+ }
+
+ file, header, err := req.FormFile("file")
+ if err != nil {
+ logs.Info.Println("media upload: invalid multipart form", err)
+ if strings.Contains(err.Error(), "request body too large") {
+ writeHttpResponse(ErrTooLarge(msgID, "", now), err)
+ } else {
+ writeHttpResponse(ErrMalformed(msgID, "", now), err)
+ }
+ return
+ }
+
+ buff := make([]byte, 512)
+ if _, err = file.Read(buff); err != nil {
+ writeHttpResponse(ErrUnknown(msgID, "", now), err)
+ return
+ }
+
+ mimeType := http.DetectContentType(buff)
+ // If DetectContentType fails, see if client-provided content type can be used.
+ if mimeType == "application/octet-stream" {
+ if userContentType, params, err := mime.ParseMediaType(header.Header.Get("Content-Type")); err == nil {
+ // Make sure the content-type is legit.
+ for _, allowed := range allowedMimeTypes {
+ if strings.HasPrefix(userContentType, allowed) {
+ if userContentType = mime.FormatMediaType(userContentType, params); userContentType != "" {
+ mimeType = userContentType
+ }
+ break
+ }
+ }
+ }
+ }
+
+ fdef := &types.FileDef{
+ ObjHeader: types.ObjHeader{
+ Id: store.Store.GetUidString(),
+ },
+ TenantID: tenantID,
+ User: uid.String(),
+ MimeType: mimeType,
+ }
+ fdef.InitTimes()
+
+ if _, err = file.Seek(0, io.SeekStart); err != nil {
+ writeHttpResponse(ErrUnknown(msgID, "", now), err)
+ return
+ }
+
+ url, size, err := mh.Upload(fdef, file)
+ if err != nil {
+ logs.Info.Println("media upload: failed", file, "key", fdef.Location, err)
+ store.Files.FinishUpload(tenantID, fdef, false, 0)
+ writeHttpResponse(decodeStoreError(err, msgID, now, nil), err)
+ return
+ }
+
+ fdef, err = store.Files.FinishUpload(tenantID, fdef, true, size)
+ if err != nil {
+ logs.Info.Println("media upload: failed to finalize", file, "key", fdef.Location, err)
+ // Best effort cleanup.
+ mh.Delete([]string{fdef.Location})
+ writeHttpResponse(decodeStoreError(err, msgID, now, nil), err)
+ return
+ }
+
+ params := map[string]string{"url": url}
+ if globals.mediaGcPeriod > 0 {
+ // How long this file is guaranteed to exist without being attached to a message or a topic.
+ params["expires"] = now.Add(globals.mediaGcPeriod).Format(types.TimeFormatRFC3339)
+ }
+
+ writeHttpResponse(NoErrParams(msgID, "", now, params), nil)
+ logs.Info.Println("media upload: ok", fdef.Id, fdef.Location)
+}
+
+// LargeFileServe is the gRPC equivalent of largeFileServeHTTP.
+func (*grpcNodeServer) LargeFileServe(req *pbx.FileDownReq, stream pbx.Node_LargeFileServeServer) error {
+ now := types.TimeNow()
+
+ writeResponse := func(msg *ServerComMessage, err error) {
+ stream.Send(&pbx.FileDownResp{Id: msg.Ctrl.Id, Code: int32(msg.Ctrl.Code), Text: msg.Ctrl.Text})
+ if err != nil {
+ logs.Info.Println("media serve:", msg.Ctrl.Code, msg.Ctrl.Text, "/", err)
+ }
+ }
+
+ msgID := req.GetId()
+
+ // Check authorization: auth information must be present (SID is not used for gRPC).
+ authMethod, secret := req.Auth.Scheme, req.Auth.Secret
+ var remoteAddr string
+ if p, ok := peer.FromContext(stream.Context()); ok {
+ remoteAddr = p.Addr.String()
+ }
+ tenantID, uid, challenge, err := authFileRequest(authMethod, secret, "", remoteAddr)
+ if err != nil {
+ writeResponse(decodeStoreError(err, msgID, now, nil), err)
+ return nil
+ }
+
+ if challenge != nil {
+ writeResponse(InfoChallenge(msgID, now, challenge), nil)
+ return nil
+ }
+
+ if uid.IsZero() {
+ // Not authenticated
+ writeResponse(ErrAuthRequired(msgID, "", now, now), errors.New("user not authenticated"))
+ return nil
+ }
+
+ // Check if media handler redirects or adds headers.
+ mh := store.Store.GetMediaHandler()
+ url, _ := url.Parse(req.Uri)
+ headers, statusCode, err := mh.Headers(tenantID, http.MethodGet, url, http.Header{}, true)
+ if err != nil {
+ writeResponse(decodeStoreError(err, "", now, nil), err)
+ return nil
+ }
+
+ resp := pbx.FileDownResp{Meta: &pbx.FileMeta{}}
+ if statusCode != 0 {
+ // The handler requested to terminate further processing.
+ resp.Code = int32(statusCode)
+ resp.Text = http.StatusText(statusCode)
+ resp.RedirUrl = headers.Get("Location")
+ stream.Send(&resp)
+ logs.Info.Println("media serve: completed with status", statusCode, "uid=", uid)
+ return nil
+ }
+
+ fd, rsc, err := mh.Download(tenantID, req.GetUri())
+ if err != nil {
+ writeResponse(decodeStoreError(err, msgID, now, nil), err)
+ return nil
+ }
+
+ defer rsc.Close()
+
+ resp.Code = http.StatusOK
+ resp.Text = http.StatusText(http.StatusOK)
+ resp.Meta.Name = fd.Location
+ resp.Meta.MimeType = fd.MimeType
+ resp.Meta.Size = fd.Size
+
+ resp.Content = make([]byte, 1024*1024*2)
+ var n int
+ result := "OK"
+ for {
+ n, err = rsc.Read(resp.Content)
+ if err == nil {
+ resp.Content = resp.Content[:n]
+ if err = stream.Send(&resp); err != nil {
+ logs.Info.Println("media serve: failed, uid=", uid, err)
+ break
+ }
+ continue
+ }
+ if err == io.EOF {
+ err = nil
+ } else {
+ result = err.Error()
+ }
+ break
+ }
+ logs.Info.Println("media serve: ", result, ", uid=", uid)
+ return err
+}
+
+// LargeFileReceive is the gRPC equivalent of LargeFileReceiveHTTP.
+func (*grpcNodeServer) LargeFileReceive(stream pbx.Node_LargeFileReceiveServer) error {
+ now := types.TimeNow()
+ mh := store.Store.GetMediaHandler()
+
+ writeResponse := func(msg *ServerComMessage, err error) {
+ stream.SendAndClose(&pbx.FileUpResp{Id: msg.Ctrl.Id, Code: int32(msg.Ctrl.Code), Text: msg.Ctrl.Text})
+ if err != nil {
+ logs.Info.Println("media receive:", msg.Ctrl.Code, msg.Ctrl.Text, "/", err)
+ }
+ }
+
+ req, err := stream.Recv()
+ if err != nil {
+ if errors.Is(err, io.EOF) {
+ writeResponse(ErrDisconnected("", "", now), err)
+ } else {
+ writeResponse(decodeStoreError(err, "", now, nil), err)
+ }
+ return nil
+ }
+
+ msgID := req.GetId()
+ // Check authorization: auth information must be present (SID is not used for gRPC).
+ authMethod, secret := req.Auth.Scheme, req.Auth.Secret
+ var remoteAddr string
+ if p, ok := peer.FromContext(stream.Context()); ok {
+ remoteAddr = p.Addr.String()
+ }
+ tenantID, uid, challenge, err := authFileRequest(authMethod, secret, "", remoteAddr)
+ if err != nil {
+ writeResponse(decodeStoreError(err, msgID, now, nil), err)
+ return nil
+ }
+
+ if challenge != nil {
+ writeResponse(InfoChallenge(msgID, now, challenge), nil)
+ return nil
+ }
+
+ if uid.IsZero() {
+ // Not authenticated
+ writeResponse(ErrAuthRequired(msgID, "", now, now), errors.New("user not authenticated"))
+ return nil
+ }
+
+ // Check if uploads are handled elsewhere.
+ headers, statusCode, err := mh.Headers(tenantID, http.MethodPost, nil, http.Header{}, false)
+ if err != nil {
+ logs.Info.Println("media upload: headers check failed", err)
+ writeResponse(decodeStoreError(err, "", now, nil), nil)
+ return nil
+ }
+
+ if statusCode != 0 {
+ // The handler requested to terminate further processing.
+ err = stream.SendAndClose(&pbx.FileUpResp{
+ Id: msgID,
+ Code: int32(statusCode),
+ Text: http.StatusText(statusCode),
+ RedirUrl: headers.Get("Location"),
+ })
+ logs.Info.Println("media upload: completed with status", statusCode, "uid=", uid, err)
+ return err
+ }
+
+ mimeType := http.DetectContentType(req.Content)
+ // If DetectContentType fails, use client-provided content type.
+ if mimeType == "application/octet-stream" {
+ if contentType := req.Meta.GetMimeType(); contentType != "" {
+ mimeType = contentType
+ }
+ }
+
+ fdef := &types.FileDef{
+ ObjHeader: types.ObjHeader{
+ Id: store.Store.GetUidString(),
+ },
+ TenantID: tenantID,
+ User: uid.String(),
+ MimeType: mimeType,
+ }
+ fdef.InitTimes()
+
+ reader, writer := io.Pipe()
+ // Create a non-blocking channel to collect errors from the inbound IO process.
+ done := make(chan error, 1)
+ go func() {
+ defer writer.Close()
+ for {
+ if req, err := stream.Recv(); err == nil {
+ chunk := req.GetContent()
+ if _, err := writer.Write(chunk); err != nil {
+ done <- err
+ break
+ }
+ } else {
+ if err == io.EOF {
+ err = nil
+ }
+ done <- err
+ break
+ }
+ }
+ }()
+
+ url, size, err := mh.Upload(fdef, reader)
+ if err == nil {
+ // No outbound IO error. Maybe we have an inbound one?
+ err = <-done
+ }
+ if err != nil {
+ logs.Info.Println("media upload: failed", req.Meta.Name, "key", fdef.Location, err)
+ store.Files.FinishUpload(tenantID, fdef, false, 0)
+ writeResponse(decodeStoreError(err, msgID, now, nil), nil)
+ return nil
+ }
+
+ err = stream.SendAndClose(&pbx.FileUpResp{
+ Id: msgID,
+ Code: http.StatusOK,
+ Text: http.StatusText(http.StatusOK),
+ Meta: &pbx.FileMeta{
+ Name: url,
+ MimeType: mimeType,
+ Etag: fdef.ETag,
+ Size: size,
+ },
+ })
+ logs.Info.Println("media upload: ok", fdef.Id, fdef.Location, err)
+ return err
+}
+
+// largeFileRunGarbageCollection runs every 'period' and deletes up to 'blockSize' unused files.
+// Returns channel which can be used to stop the process.
+func largeFileRunGarbageCollection(period time.Duration, blockSize int) chan<- bool {
+ // Unbuffered stop channel. Whomever stops the gc must wait for the process to finish.
+ stop := make(chan bool)
+ go func() {
+ // Add some randomness to the tick period to desynchronize runs on cluster nodes:
+ // 0.75 * period + rand(0, 0.5) * period.
+ period = (period >> 1) + (period >> 2) + time.Duration(rand.Intn(int(period>>1)))
+ gcTicker := time.Tick(period)
+ for {
+ select {
+ case <-gcTicker:
+ for _, tenantID := range store.KnownTenantIDs() {
+ if err := store.Files.DeleteUnused(tenantID, time.Now().Add(-time.Hour), blockSize); err != nil {
+ logs.Warn.Println("media gc:", tenantID, err)
+ }
+ }
+ case <-stop:
+ return
+ }
+ }
+ }()
+
+ return stop
+}
+
+// Authenticate non-websocket HTTP request
+func authFileRequest(authMethod, secret, sid, remoteAddr string) (types.TenantID, types.Uid, []byte, error) {
+ var tenantID types.TenantID
+ var uid types.Uid
+ if authMethod != "" {
+ decodedSecret := make([]byte, base64.StdEncoding.DecodedLen(len(secret)))
+ n, err := base64.StdEncoding.Decode(decodedSecret, []byte(secret))
+ if err != nil {
+ logs.Info.Println("media: invalid auth secret", authMethod, "'"+secret+"'")
+ return tenantID, uid, nil, types.ErrMalformed
+ }
+
+ if authhdl := store.Store.GetLogicalAuthHandler(authMethod); authhdl != nil {
+ rec, challenge, err := authhdl.Authenticate(auth.AuthContext{RemoteAddr: remoteAddr}, decodedSecret[:n])
+ if err != nil {
+ return tenantID, uid, nil, err
+ }
+ if challenge != nil {
+ return tenantID, uid, challenge, nil
+ }
+ if rec == nil || rec.TenantID.IsZero() || rec.Uid.IsZero() {
+ return tenantID, uid, nil, types.ErrFailed
+ }
+ tenantID = rec.TenantID
+ uid = rec.Uid
+ } else {
+ logs.Info.Println("media: unknown auth method", authMethod)
+ return tenantID, uid, nil, types.ErrMalformed
+ }
+ } else {
+ // Find the session, make sure it's appropriately authenticated.
+ sess := globals.sessionStore.Get(sid)
+ if sess != nil {
+ tenantID = sess.tenantID
+ uid = sess.uid
+ }
+ }
+ return tenantID, uid, nil, nil
+}
diff --git a/server/hdl_grpc.go b/server/hdl_grpc.go
index e69de29..c126ac4 100644
--- a/server/hdl_grpc.go
+++ b/server/hdl_grpc.go
@@ -0,0 +1,190 @@
+/******************************************************************************
+ *
+ * Description :
+ *
+ * Handler of gRPC connections. See also hdl_websock.go for websockets and
+ * hdl_longpoll.go for long polling.
+ *
+ *****************************************************************************/
+
+package main
+
+import (
+ "crypto/tls"
+ "io"
+ "time"
+
+ "github.com/tinode/chat/pbx"
+ "github.com/tinode/chat/server/logs"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/credentials"
+ "google.golang.org/grpc/keepalive"
+ "google.golang.org/grpc/peer"
+)
+
+type grpcNodeServer struct {
+ pbx.UnimplementedNodeServer
+}
+
+func (sess *Session) closeGrpc() {
+ if sess.proto == GRPC {
+ sess.lock.Lock()
+ sess.grpcnode = nil
+ sess.lock.Unlock()
+ }
+}
+
+// Equivalent of starting a new session and a read loop in one.
+func (*grpcNodeServer) MessageLoop(stream pbx.Node_MessageLoopServer) error {
+ sess, count := globals.sessionStore.NewSession(stream, "")
+ if p, ok := peer.FromContext(stream.Context()); ok {
+ sess.remoteAddr = p.Addr.String()
+ }
+ logs.Info.Println("grpc: session started", sess.sid, sess.remoteAddr, count)
+
+ defer func() {
+ sess.closeGrpc()
+ sess.cleanUp(false)
+ }()
+
+ go sess.writeGrpcLoop()
+
+ for {
+ in, err := stream.Recv()
+ if err == io.EOF {
+ return nil
+ }
+ if err != nil {
+ logs.Err.Println("grpc: recv", sess.sid, err)
+ return err
+ }
+ logs.Info.Println("grpc in:", truncateStringIfTooLong(in.String()), sess.sid)
+ statsInc("IncomingMessagesGrpcTotal", 1)
+ sess.dispatch(pbCliDeserialize(in))
+
+ sess.lock.Lock()
+ if sess.grpcnode == nil {
+ sess.lock.Unlock()
+ break
+ }
+ sess.lock.Unlock()
+ }
+
+ return nil
+}
+
+func (sess *Session) sendMessageGrpc(msg any) bool {
+ if len(sess.send) > sendQueueLimit {
+ logs.Err.Println("grpc: outbound queue limit exceeded", sess.sid)
+ return false
+ }
+ statsInc("OutgoingMessagesGrpcTotal", 1)
+ if err := grpcWrite(sess, msg); err != nil {
+ logs.Err.Println("grpc: write", sess.sid, err)
+ return false
+ }
+ return true
+}
+
+func (sess *Session) writeGrpcLoop() {
+ defer func() {
+ sess.closeGrpc() // exit MessageLoop
+ }()
+
+ for {
+ select {
+ case msg, ok := <-sess.send:
+ if !ok {
+ // channel closed
+ return
+ }
+ switch v := msg.(type) {
+ case []*ServerComMessage: // batch of unserialized messages
+ for _, msg := range v {
+ w := sess.serializeAndUpdateStats(msg)
+ if !sess.sendMessageGrpc(w) {
+ return
+ }
+ }
+ case *ServerComMessage: // single unserialized message
+ w := sess.serializeAndUpdateStats(v)
+ if !sess.sendMessageGrpc(w) {
+ return
+ }
+ default: // serialized message
+ if !sess.sendMessageGrpc(v) {
+ return
+ }
+ }
+
+ case <-sess.bkgTimer.C:
+ if sess.background {
+ sess.background = false
+ sess.onBackgroundTimer()
+ }
+
+ case msg := <-sess.stop:
+ // Shutdown requested, don't care if the message is delivered
+ if msg != nil {
+ grpcWrite(sess, msg)
+ }
+ return
+
+ case topic := <-sess.detach:
+ sess.delSub(topic)
+ }
+ }
+}
+
+func grpcWrite(sess *Session, msg any) error {
+ if out := sess.grpcnode; out != nil {
+ // Will panic if msg is not of *pbx.ServerMsg type. This is an intentional panic.
+ return out.Send(msg.(*pbx.ServerMsg))
+ }
+ return nil
+}
+
+func serveGrpc(addr string, kaEnabled bool, tlsConf *tls.Config) (*grpc.Server, error) {
+ if addr == "" {
+ return nil, nil
+ }
+
+ lis, err := netListener(addr)
+ if err != nil {
+ return nil, err
+ }
+
+ secure := ""
+ var opts []grpc.ServerOption
+ opts = append(opts, grpc.MaxRecvMsgSize(int(globals.maxMessageSize)))
+ if tlsConf != nil {
+ opts = append(opts, grpc.Creds(credentials.NewTLS(tlsConf)))
+ secure = " secure"
+ }
+
+ if kaEnabled {
+ kepConfig := keepalive.EnforcementPolicy{
+ MinTime: 1 * time.Second, // If a client pings more than once every second, terminate the connection
+ PermitWithoutStream: true, // Allow pings even when there are no active streams
+ }
+ opts = append(opts, grpc.KeepaliveEnforcementPolicy(kepConfig))
+
+ kpConfig := keepalive.ServerParameters{
+ Time: 60 * time.Second, // Ping the client if it is idle for 60 seconds to ensure the connection is still active
+ Timeout: 20 * time.Second, // Wait 20 second for the ping ack before assuming the connection is dead
+ }
+ opts = append(opts, grpc.KeepaliveParams(kpConfig))
+ }
+
+ srv := grpc.NewServer(opts...)
+ pbx.RegisterNodeServer(srv, &grpcNodeServer{})
+ logs.Info.Printf("gRPC/%s%s server is registered at [%s]", grpc.Version, secure, addr)
+
+ go func() {
+ if err := srv.Serve(lis); err != nil {
+ logs.Err.Println("gRPC server failed:", err)
+ }
+ }()
+
+ return srv, nil
+}
diff --git a/server/hdl_longpoll.go b/server/hdl_longpoll.go
index e69de29..c4b522f 100644
--- a/server/hdl_longpoll.go
+++ b/server/hdl_longpoll.go
@@ -0,0 +1,205 @@
+/******************************************************************************
+ *
+ * Description :
+ *
+ * Handler of long polling clients. See also hdl_websock.go for web sockets and
+ * hdl_grpc.go for gRPC
+ *
+ *****************************************************************************/
+
+package main
+
+import (
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "time"
+
+ "github.com/tinode/chat/server/logs"
+)
+
+func (sess *Session) sendMessageLp(wrt http.ResponseWriter, msg any) bool {
+ if len(sess.send) > sendQueueLimit {
+ logs.Err.Println("longPoll: outbound queue limit exceeded", sess.sid)
+ return false
+ }
+
+ statsInc("OutgoingMessagesLongpollTotal", 1)
+ if err := lpWrite(wrt, msg); err != nil {
+ logs.Err.Println("longPoll: writeOnce failed", sess.sid, err)
+ return false
+ }
+
+ return true
+}
+
+func (sess *Session) writeOnce(wrt http.ResponseWriter, req *http.Request) {
+ for {
+ select {
+ case msg, ok := <-sess.send:
+ if !ok {
+ return
+ }
+ switch v := msg.(type) {
+ case *ServerComMessage: // single unserialized message
+ w := sess.serializeAndUpdateStats(v)
+ if !sess.sendMessageLp(wrt, w) {
+ return
+ }
+ default: // serialized message
+ if !sess.sendMessageLp(wrt, v) {
+ return
+ }
+ }
+ return
+
+ case <-sess.bkgTimer.C:
+ if sess.background {
+ sess.background = false
+ sess.onBackgroundTimer()
+ }
+
+ case msg := <-sess.stop:
+ // Request to close the session. Make it unavailable.
+ globals.sessionStore.Delete(sess)
+ // Don't care if lpWrite fails.
+ if msg != nil {
+ lpWrite(wrt, msg)
+ }
+ return
+
+ case topic := <-sess.detach:
+ // Request to detach the session from a topic.
+ sess.delSub(topic)
+ // No 'return' statement here: continue waiting
+
+ case <-time.After(pingPeriod):
+ // just write an empty packet on timeout
+ if _, err := wrt.Write([]byte{}); err != nil {
+ logs.Err.Println("longPoll: writeOnce: timout", sess.sid, err)
+ }
+ return
+
+ case <-req.Context().Done():
+ // HTTP request canceled or connection lost.
+ return
+ }
+ }
+}
+
+func lpWrite(wrt http.ResponseWriter, msg any) error {
+ // This will panic if msg is not []byte. This is intentional.
+ wrt.Write(msg.([]byte))
+ return nil
+}
+
+func (sess *Session) readOnce(wrt http.ResponseWriter, req *http.Request) (int, error) {
+ if req.ContentLength > globals.maxMessageSize {
+ return http.StatusExpectationFailed, errors.New("request too large")
+ }
+
+ req.Body = http.MaxBytesReader(wrt, req.Body, globals.maxMessageSize)
+ raw, err := io.ReadAll(req.Body)
+ if err == nil {
+ // Locking-unlocking is needed because the client may issue multiple requests in parallel.
+ // Should not affect performance
+ sess.lock.Lock()
+ statsInc("IncomingMessagesLongpollTotal", 1)
+ sess.dispatchRaw(raw)
+ sess.lock.Unlock()
+ return 0, nil
+ }
+
+ return 0, err
+}
+
+// serveLongPoll handles long poll connections when WebSocket is not available
+// Connection could be without sid or with sid:
+// - if sid is empty, create session, expect a login in the same request, respond and close
+// - if sid is not empty and there is an initialized session, payload is optional
+// - if no payload, perform long poll
+// - if payload exists, process it and close
+// - if sid is not empty but there is no session, report an error
+func serveLongPoll(wrt http.ResponseWriter, req *http.Request) {
+ now := time.Now().UTC().Round(time.Millisecond)
+
+ // Use the lowest common denominator - this is a legacy handler after all (otherwise would use application/json)
+ wrt.Header().Set("Content-Type", "text/plain")
+ if globals.tlsStrictMaxAge != "" {
+ wrt.Header().Set("Strict-Transport-Security", "max-age"+globals.tlsStrictMaxAge)
+ }
+
+ enc := json.NewEncoder(wrt)
+
+ if isValid, _ := checkAPIKey(getAPIKey(req)); !isValid {
+ wrt.WriteHeader(http.StatusForbidden)
+ enc.Encode(ErrAPIKeyRequired(now))
+ return
+ }
+
+ // TODO(gene): should it be configurable?
+ // Currently any domain is allowed to get data from the chat server
+ wrt.Header().Set("Access-Control-Allow-Origin", "*")
+
+ // Ensure the response is not cached
+ if req.ProtoAtLeast(1, 1) {
+ wrt.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") // HTTP 1.1
+ } else {
+ wrt.Header().Set("Pragma", "no-cache") // HTTP 1.0
+ }
+ wrt.Header().Set("Expires", "0") // Proxies
+
+ // TODO(gene): respond differently to valious HTTP methods
+
+ // Get session id
+ sid := req.FormValue("sid")
+ var sess *Session
+ if sid == "" {
+ // New session
+ var count int
+ sess, count = globals.sessionStore.NewSession(wrt, "")
+ sess.remoteAddr = getRemoteAddr(req)
+ logs.Info.Println("longPoll: session started", sess.sid, sess.remoteAddr, count)
+
+ wrt.WriteHeader(http.StatusCreated)
+ pkt := NoErrCreated(req.FormValue("id"), "", now)
+ pkt.Ctrl.Params = map[string]string{
+ "sid": sess.sid,
+ }
+ enc.Encode(pkt)
+
+ return
+ }
+
+ // Existing session
+ sess = globals.sessionStore.Get(sid)
+ if sess == nil {
+ logs.Warn.Println("longPoll: invalid or expired session id", sid)
+ wrt.WriteHeader(http.StatusForbidden)
+ enc.Encode(ErrSessionNotFound(now))
+ return
+ }
+
+ if addr := getRemoteAddr(req); sess.remoteAddr != addr {
+ sess.remoteAddr = addr
+ logs.Warn.Println("longPoll: remote address changed", sid, addr)
+ }
+
+ if req.ContentLength != 0 {
+ // Read payload and send it for processing.
+ if code, err := sess.readOnce(wrt, req); err != nil {
+ logs.Warn.Println("longPoll: readOnce failed", sess.sid, err)
+ // Failed to read request, report an error, if possible
+ if code != 0 {
+ wrt.WriteHeader(code)
+ } else {
+ wrt.WriteHeader(http.StatusBadRequest)
+ }
+ enc.Encode(ErrMalformed(req.FormValue("id"), "", now))
+ }
+ return
+ }
+
+ sess.writeOnce(wrt, req)
+}
diff --git a/server/hdl_websock.go b/server/hdl_websock.go
index e69de29..f8bf405 100644
--- a/server/hdl_websock.go
+++ b/server/hdl_websock.go
@@ -0,0 +1,211 @@
+/******************************************************************************
+ *
+ * Description :
+ *
+ * Handler of websocket connections. See also hdl_longpoll.go for long polling
+ * and hdl_grpc.go for gRPC.
+ *
+ *****************************************************************************/
+
+package main
+
+import (
+ "encoding/json"
+ "net/http"
+ "time"
+
+ "github.com/gorilla/websocket"
+ "github.com/tinode/chat/server/logs"
+ "github.com/tinode/chat/server/store/types"
+)
+
+const (
+ // Time allowed to write a message to the peer.
+ writeWait = 10 * time.Second
+
+ // Time allowed to read the next pong message from the peer.
+ pongWait = idleSessionTimeout
+
+ // Send pings to peer with this period. Must be less than pongWait.
+ pingPeriod = (pongWait * 9) / 10
+)
+
+func (sess *Session) closeWS() {
+ if sess.proto == WEBSOCK {
+ sess.ws.Close()
+ }
+}
+
+func (sess *Session) readLoop() {
+ defer func() {
+ sess.closeWS()
+ sess.cleanUp(false)
+ }()
+
+ sess.ws.SetReadLimit(globals.maxMessageSize)
+ sess.ws.SetReadDeadline(time.Now().Add(pongWait))
+ sess.ws.SetPongHandler(func(string) error {
+ sess.ws.SetReadDeadline(time.Now().Add(pongWait))
+ return nil
+ })
+
+ for {
+ // Read a ClientComMessage
+ _, raw, err := sess.ws.ReadMessage()
+ if err != nil {
+ if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure,
+ websocket.CloseNormalClosure) {
+ logs.Err.Println("ws: readLoop", sess.sid, err)
+ }
+ return
+ }
+ statsInc("IncomingMessagesWebsockTotal", 1)
+ sess.dispatchRaw(raw)
+ }
+}
+
+func (sess *Session) sendMessage(msg any) bool {
+ if len(sess.send) > sendQueueLimit {
+ logs.Err.Println("ws: outbound queue limit exceeded", sess.sid)
+ return false
+ }
+
+ statsInc("OutgoingMessagesWebsockTotal", 1)
+ if err := wsWrite(sess.ws, websocket.TextMessage, msg); err != nil {
+ if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure,
+ websocket.CloseNormalClosure) {
+ logs.Err.Println("ws: writeLoop", sess.sid, err)
+ }
+ return false
+ }
+ return true
+}
+
+func (sess *Session) writeLoop() {
+ ticker := time.NewTicker(pingPeriod)
+
+ defer func() {
+ ticker.Stop()
+ // Break readLoop.
+ sess.closeWS()
+ }()
+
+ for {
+ select {
+ case msg, ok := <-sess.send:
+ if !ok {
+ // Channel closed.
+ return
+ }
+ switch v := msg.(type) {
+ case []*ServerComMessage: // batch of unserialized messages
+ for _, msg := range v {
+ w := sess.serializeAndUpdateStats(msg)
+ if !sess.sendMessage(w) {
+ return
+ }
+ }
+ case *ServerComMessage: // single unserialized message
+ w := sess.serializeAndUpdateStats(v)
+ if !sess.sendMessage(w) {
+ return
+ }
+ default: // serialized message
+ if !sess.sendMessage(v) {
+ return
+ }
+ }
+
+ case <-sess.bkgTimer.C:
+ if sess.background {
+ sess.background = false
+ sess.onBackgroundTimer()
+ }
+
+ case msg := <-sess.stop:
+ // Shutdown requested, don't care if the message is delivered
+ if msg != nil {
+ wsWrite(sess.ws, websocket.TextMessage, msg)
+ }
+ return
+
+ case topic := <-sess.detach:
+ sess.delSub(topic)
+
+ case <-ticker.C:
+ if err := wsWrite(sess.ws, websocket.PingMessage, nil); err != nil {
+ if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure,
+ websocket.CloseNormalClosure) {
+ logs.Err.Println("ws: writeLoop ping", sess.sid, err)
+ }
+ return
+ }
+ }
+ }
+}
+
+// Writes a message with the given message type (mt) and payload.
+func wsWrite(ws *websocket.Conn, mt int, msg any) error {
+ var bits []byte
+ if msg != nil {
+ bits = msg.([]byte)
+ } else {
+ bits = []byte{}
+ }
+ ws.SetWriteDeadline(time.Now().Add(writeWait))
+ return ws.WriteMessage(mt, bits)
+}
+
+// Handles websocket requests from peers.
+var upgrader = websocket.Upgrader{
+ ReadBufferSize: 1024,
+ WriteBufferSize: 1024,
+ EnableCompression: globals.wsCompression,
+ // Allow connections from any Origin
+ CheckOrigin: func(r *http.Request) bool { return true },
+}
+
+func serveWebSocket(wrt http.ResponseWriter, req *http.Request) {
+ now := types.TimeNow()
+
+ if isValid, _ := checkAPIKey(getAPIKey(req)); !isValid {
+ wrt.WriteHeader(http.StatusForbidden)
+ json.NewEncoder(wrt).Encode(ErrAPIKeyRequired(now))
+ logs.Err.Println("ws: Missing, invalid or expired API key")
+ return
+ }
+
+ if req.Method != http.MethodGet {
+ wrt.WriteHeader(http.StatusMethodNotAllowed)
+ json.NewEncoder(wrt).Encode(ErrOperationNotAllowed("", "", now))
+ logs.Err.Println("ws: Invalid HTTP method", req.Method)
+ return
+ }
+
+ ws, err := upgrader.Upgrade(wrt, req, nil)
+ if _, ok := err.(websocket.HandshakeError); ok {
+ logs.Err.Println("ws: Not a websocket handshake")
+ return
+ } else if err != nil {
+ logs.Err.Println("ws: failed to Upgrade ", err)
+ return
+ }
+
+ sess, count := globals.sessionStore.NewSession(ws, "")
+ if globals.useXForwardedFor {
+ sess.remoteAddr = req.Header.Get("X-Forwarded-For")
+ if !isRoutableIP(sess.remoteAddr) {
+ sess.remoteAddr = ""
+ }
+ }
+ if sess.remoteAddr == "" {
+ sess.remoteAddr = req.RemoteAddr
+ }
+
+ logs.Info.Println("ws: session started", sess.sid, sess.remoteAddr, count)
+
+ // Do work in goroutines to return from serveWebSocket() to release file pointers.
+ // Otherwise "too many open files" will happen.
+ go sess.writeLoop()
+ go sess.readLoop()
+}
diff --git a/server/http.go b/server/http.go
index e69de29..8426c6e 100644
--- a/server/http.go
+++ b/server/http.go
@@ -0,0 +1,489 @@
+/******************************************************************************
+ *
+ * Description :
+ *
+ * Web server initialization and shutdown.
+ *
+ *****************************************************************************/
+
+package main
+
+import (
+ "context"
+ "crypto/tls"
+ "encoding/json"
+ "errors"
+ "net"
+ "net/http"
+ "net/url"
+ "os"
+ "os/signal"
+ "sort"
+ "strconv"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/tinode/chat/server/logs"
+ "github.com/tinode/chat/server/store/types"
+)
+
+func listenAndServe(addr string, mux *http.ServeMux, tlfConf *tls.Config, stop <-chan bool) error {
+ globals.shuttingDown = false
+
+ httpdone := make(chan bool)
+
+ server := &http.Server{
+ Handler: mux,
+ ReadHeaderTimeout: 10 * time.Second,
+ IdleTimeout: 30 * time.Second,
+ WriteTimeout: 90 * time.Second,
+ MaxHeaderBytes: 1 << 14,
+ }
+
+ server.TLSConfig = tlfConf
+
+ go func() {
+ var err error
+ if server.TLSConfig != nil {
+ // If port is not specified, use default https port (443),
+ // otherwise it will default to 80
+ if addr == "" {
+ addr = ":https"
+ }
+
+ if globals.tlsRedirectHTTP != "" {
+ // Serving redirects from a unix socket or to a unix socket makes no sense.
+ if isUnixAddr(globals.tlsRedirectHTTP) || isUnixAddr(addr) {
+ err = errors.New("HTTP to HTTPS redirect: unix sockets not supported")
+ } else {
+ logs.Info.Printf("Redirecting connections from HTTP at [%s] to HTTPS at [%s]",
+ globals.tlsRedirectHTTP, addr)
+
+ // This is a second HTTP server listenning on a different port.
+ go func() {
+ if err := http.ListenAndServe(globals.tlsRedirectHTTP, tlsRedirect(addr)); err != nil && err != http.ErrServerClosed {
+ logs.Info.Println("HTTP redirect failed:", err)
+ }
+ }()
+ }
+ }
+
+ if err == nil {
+ logs.Info.Printf("Listening for client HTTPS connections on [%s]", addr)
+ var lis net.Listener
+ lis, err = netListener(addr)
+ if err == nil {
+ err = server.ServeTLS(lis, "", "")
+ }
+ }
+ } else {
+ logs.Info.Printf("Listening for client HTTP connections on [%s]", addr)
+ var lis net.Listener
+ lis, err = netListener(addr)
+ if err == nil {
+ err = server.Serve(lis)
+ }
+ }
+
+ if err != nil {
+ if globals.shuttingDown {
+ logs.Info.Println("HTTP server: stopped")
+ } else {
+ logs.Err.Println("HTTP server: failed", err)
+ }
+ }
+ httpdone <- true
+ }()
+
+ // Wait for either a termination signal or an error
+Loop:
+ for {
+ select {
+ case <-stop:
+ // Flip the flag that we are terminating and close the Accept-ing socket, so no new connections are possible.
+ globals.shuttingDown = true
+ // Give server 2 seconds to shut down.
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ if err := server.Shutdown(ctx); err != nil {
+ // failure/timeout shutting down the server gracefully
+ logs.Err.Println("HTTP server failed to terminate gracefully", err)
+ }
+
+ // While the server shuts down, termianate all sessions.
+ globals.sessionStore.Shutdown()
+
+ // Wait for http server to stop Accept()-ing connections.
+ <-httpdone
+ cancel()
+
+ // Shutdown local cluster node, if it's a part of a cluster.
+ globals.cluster.shutdown()
+
+ // Terminate plugin connections.
+ pluginsShutdown()
+
+ // Shutdown gRPC server, if one is configured.
+ if globals.grpcServer != nil {
+ // GracefulStop does not terminate ServerStream. Must use Stop().
+ globals.grpcServer.Stop()
+ }
+
+ // Stop publishing statistics.
+ statsShutdown()
+
+ // Shutdown the hub. The hub will shutdown topics.
+ hubdone := make(chan bool)
+ globals.hub.shutdown <- hubdone
+
+ // Wait for the hub to finish.
+ <-hubdone
+
+ // Stop updating users cache
+ usersShutdown()
+
+ break Loop
+
+ case <-httpdone:
+ break Loop
+ }
+ }
+ return nil
+}
+
+func signalHandler() <-chan bool {
+ stop := make(chan bool)
+
+ signchan := make(chan os.Signal, 1)
+ signal.Notify(signchan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
+
+ go func() {
+ // Wait for a signal. Don't care which signal it is
+ sig := <-signchan
+ logs.Info.Printf("Signal received: '%s', shutting down", sig)
+ stop <- true
+ }()
+
+ return stop
+}
+
+// The following code is used to intercept HTTP errors so they can be wrapped into json.
+
+// Wrapper around http.ResponseWriter which detects status set to 400+ and replaces
+// default error message with a custom one.
+type errorResponseWriter struct {
+ status int
+ http.ResponseWriter
+}
+
+func (w *errorResponseWriter) WriteHeader(status int) {
+ if status >= http.StatusBadRequest {
+ // charset=utf-8 is the default. No need to write it explicitly
+ // Must set all the headers before calling super.WriteHeader()
+ w.ResponseWriter.Header().Set("Content-Type", "application/json")
+ }
+ w.status = status
+ w.ResponseWriter.WriteHeader(status)
+}
+
+func (w *errorResponseWriter) Write(p []byte) (n int, err error) {
+ if w.status >= http.StatusBadRequest {
+ p, _ = json.Marshal(
+ &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Timestamp: time.Now().UTC().Round(time.Millisecond),
+ Code: w.status,
+ Text: http.StatusText(w.status),
+ },
+ })
+ }
+ return w.ResponseWriter.Write(p)
+}
+
+// httpErrorHandler to respond with JSON_formatted error message for static content.
+func httpErrorHandler(h http.Handler) http.Handler {
+ return http.HandlerFunc(
+ func(w http.ResponseWriter, r *http.Request) {
+ h.ServeHTTP(&errorResponseWriter{0, w}, r)
+ })
+}
+
+// Custom 404 response.
+func serve404(wrt http.ResponseWriter, req *http.Request) {
+ wrt.Header().Set("Content-Type", "application/json; charset=utf-8")
+ wrt.WriteHeader(http.StatusNotFound)
+ json.NewEncoder(wrt).Encode(
+ &ServerComMessage{
+ Ctrl: &MsgServerCtrl{
+ Timestamp: time.Now().UTC().Round(time.Millisecond),
+ Code: http.StatusNotFound,
+ Text: "not found",
+ },
+ })
+}
+
+// Redirect HTTP requests to HTTPS
+func tlsRedirect(toPort string) http.HandlerFunc {
+ if toPort == ":443" || toPort == ":https" {
+ toPort = ""
+ } else if toPort != "" && toPort[:1] == ":" {
+ // Strip leading colon. JoinHostPort will add it back.
+ toPort = toPort[1:]
+ }
+
+ return func(wrt http.ResponseWriter, req *http.Request) {
+ host, _, err := net.SplitHostPort(req.Host)
+ if err != nil {
+ // If SplitHostPort has failed assume it's because :port part is missing.
+ host = req.Host
+ }
+
+ target, _ := url.ParseRequestURI(req.RequestURI)
+ target.Scheme = "https"
+
+ // Ensure valid redirect target.
+ if toPort != "" {
+ // Replace the port number.
+ target.Host = net.JoinHostPort(host, toPort)
+ } else {
+ target.Host = host
+ }
+
+ if target.Path == "" {
+ target.Path = "/"
+ }
+
+ http.Redirect(wrt, req, target.String(), http.StatusTemporaryRedirect)
+ }
+}
+
+// Wrapper for adding optional HTTP headers:
+// - Strict-Transport-Security
+// - X-Frame-Options
+// - Referrer-Policy
+func optionalHttpHeaders(handler http.Handler) http.Handler {
+ h1 := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Referrer-Policy", "origin")
+ handler.ServeHTTP(w, r)
+ })
+
+ h2 := h1
+ if globals.tlsStrictMaxAge != "" {
+ h2 = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Strict-Transport-Security", "max-age="+globals.tlsStrictMaxAge)
+ h1.ServeHTTP(w, r)
+ })
+ }
+
+ h3 := h2
+ if globals.xFrameOptions != "-" {
+ h3 = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("X-Frame-Options", globals.xFrameOptions)
+ h2.ServeHTTP(w, r)
+ })
+ }
+ return h3
+}
+
+// Wrapper for http.Handler which optionally adds a Cache-Control header to the response
+func cacheControlHandler(maxAge int, handler http.Handler) http.Handler {
+ if maxAge > 0 {
+ strMaxAge := strconv.Itoa(maxAge)
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "must-revalidate, public, max-age="+strMaxAge)
+ handler.ServeHTTP(w, r)
+ })
+ }
+ return handler
+}
+
+// Get API key from an HTTP request.
+func getAPIKey(req *http.Request) string {
+ // Check header.
+ apikey := req.Header.Get("X-Tinode-APIKey")
+ if apikey != "" {
+ return apikey
+ }
+
+ // Check URL query parameters.
+ apikey = req.URL.Query().Get("apikey")
+ if apikey != "" {
+ return apikey
+ }
+
+ // Check form values.
+ apikey = req.FormValue("apikey")
+ if apikey != "" {
+ return apikey
+ }
+
+ // Check cookies.
+ if c, err := req.Cookie("apikey"); err == nil {
+ apikey = c.Value
+ }
+
+ return apikey
+}
+
+// Extracts authorization credentials from an HTTP request.
+// Returns authentication method and secret.
+func getHttpAuth(req *http.Request) (method, secret string) {
+ // Check X-Tinode-Auth header.
+ if parts := strings.Split(req.Header.Get("X-Tinode-Auth"), " "); len(parts) == 2 {
+ method, secret = parts[0], parts[1]
+ return
+ }
+
+ // Check canonical Authorization header.
+ if parts := strings.Split(req.Header.Get("Authorization"), " "); len(parts) == 2 {
+ method, secret = parts[0], parts[1]
+ return
+ }
+
+ // Check URL query parameters.
+ if method = req.URL.Query().Get("auth"); method != "" {
+ // Get the auth secret.
+ secret = req.URL.Query().Get("secret")
+ // Convert base64 URL-encoding to standard encoding.
+ secret = strings.NewReplacer("-", "+", "_", "/").Replace(secret)
+ return
+ }
+
+ // Check form values.
+ if method = req.FormValue("auth"); method != "" {
+ return method, req.FormValue("secret")
+ }
+
+ // Check cookies as the last resort.
+ if mcookie, err := req.Cookie("auth"); err == nil {
+ if scookie, err := req.Cookie("secret"); err == nil {
+ method, secret = mcookie.Value, scookie.Value
+ }
+ }
+
+ return
+}
+
+// Obtain IP address of the client.
+func getRemoteAddr(req *http.Request) string {
+ var addr string
+ if globals.useXForwardedFor {
+ addr = req.Header.Get("X-Forwarded-For")
+ if !isRoutableIP(addr) {
+ addr = ""
+ }
+ }
+ if addr != "" {
+ return addr
+ }
+ return req.RemoteAddr
+}
+
+// debugSession is session debug info.
+type debugSession struct {
+ RemoteAddr string `json:"remote_addr,omitempty"`
+ Ua string `json:"ua,omitempty"`
+ Uid string `json:"uid,omitempty"`
+ Sid string `json:"sid,omitempty"`
+ Clnode string `json:"clnode,omitempty"`
+ Subs []string `json:"subs,omitempty"`
+}
+
+// debugTopic is a topic debug info.
+type debugTopic struct {
+ TenantID int64 `json:"tenant_id"`
+ Topic string `json:"topic,omitempty"`
+ Xorig string `json:"xorig,omitempty"`
+ IsProxy bool `json:"is_proxy,omitempty"`
+ PerUser []string `json:"per_user,omitempty"`
+ PerSubs []string `json:"per_subs,omitempty"`
+ Sessions []string `json:"sessions,omitempty"`
+}
+
+// debugCachedUser is a user cache entry debug info.
+type debugCachedUser struct {
+ TenantID int64 `json:"tenant_id"`
+ Uid string `json:"uid,omitempty"`
+ Unread int `json:"unread,omitempty"`
+ Topics int `json:"topics,omitempty"`
+}
+
+// debugDump is server internal state dump for debugging.
+type debugDump struct {
+ Version string `json:"server_version,omitempty"`
+ Build string `json:"build_id,omitempty"`
+ Timestamp time.Time `json:"ts,omitempty"`
+ Sessions []debugSession `json:"sessions,omitempty"`
+ Topics []debugTopic `json:"topics,omitempty"`
+ UserCache []debugCachedUser `json:"user_cache,omitempty"`
+}
+
+func serveStatus(wrt http.ResponseWriter, req *http.Request) {
+ wrt.Header().Set("Content-Type", "application/json")
+
+ result := &debugDump{
+ Version: currentVersion,
+ Build: buildstamp,
+ Timestamp: types.TimeNow(),
+ Sessions: make([]debugSession, 0, len(globals.sessionStore.sessCache)),
+ Topics: make([]debugTopic, 0, 10),
+ UserCache: make([]debugCachedUser, 0, 10),
+ }
+ // Sessions.
+ globals.sessionStore.Range(func(sid string, s *Session) bool {
+ keys := make([]string, 0, len(s.subs))
+ for tn := range s.subs {
+ keys = append(keys, tn)
+ }
+ sort.Strings(keys)
+ var clnode string
+ if s.clnode != nil {
+ clnode = s.clnode.name
+ }
+ result.Sessions = append(result.Sessions, debugSession{
+ RemoteAddr: s.remoteAddr,
+ Ua: s.userAgent,
+ Uid: s.uid.String(),
+ Sid: sid,
+ Clnode: clnode,
+ Subs: keys,
+ })
+ return true
+ })
+ // Topics.
+ globals.hub.topics.Range(func(_, t any) bool {
+ topic := t.(*Topic)
+ psd := make([]string, 0, len(topic.sessions))
+ for s := range topic.sessions {
+ psd = append(psd, s.sid)
+ }
+ pud := make([]string, 0, len(topic.perUser))
+ for uid := range topic.perUser {
+ pud = append(pud, uid.String())
+ }
+ ps := make([]string, 0, len(topic.perSubs))
+ for key := range topic.perSubs {
+ ps = append(ps, key)
+ }
+ result.Topics = append(result.Topics, debugTopic{
+ TenantID: int64(topic.tenantID),
+ Topic: topic.name,
+ Xorig: topic.xoriginal,
+ IsProxy: topic.isProxy,
+ PerUser: pud,
+ PerSubs: ps,
+ Sessions: psd,
+ })
+ return true
+ })
+ for k, v := range usersCache {
+ result.UserCache = append(result.UserCache, debugCachedUser{
+ TenantID: int64(k.TenantID),
+ Uid: k.User.UserId(),
+ Unread: v.unread,
+ Topics: v.topics,
+ })
+ }
+
+ json.NewEncoder(wrt).Encode(result)
+}
diff --git a/server/hub.go b/server/hub.go
index e69de29..f1aa441 100644
--- a/server/hub.go
+++ b/server/hub.go
@@ -0,0 +1,889 @@
+/******************************************************************************
+ *
+ * Description :
+ *
+ * Main hub for processing events such as creating/tearing down topics,
+ * routing messages between topics.
+ *
+ *****************************************************************************/
+
+package main
+
+import (
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/tinode/chat/server/auth"
+ "github.com/tinode/chat/server/logs"
+ "github.com/tinode/chat/server/store"
+ "github.com/tinode/chat/server/store/types"
+)
+
+// RequestLatencyDistribution is an array of request latency distribution bounds (in milliseconds).
+// "var" because Go does not support array constants.
+var requestLatencyDistribution = []float64{1, 2, 3, 4, 5, 6, 8, 10, 13, 16, 20, 25, 30, 40, 50, 65, 80, 100, 130,
+ 160, 200, 250, 300, 400, 500, 650, 800, 1000, 2000, 5000, 10000, 20000, 50000, 100000}
+
+// OutgoingMessageSizeDistribution is an array of outgoing message size distribution bounds (in bytes).
+var outgoingMessageSizeDistribution = []float64{1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 16384,
+ 65536, 262144, 1048576, 4194304, 16777216, 67108864, 268435456, 1073741824, 4294967296}
+
+// Request to hub to remove the topic
+type topicUnreg struct {
+ // Tenant which owns the topic.
+ tenantID types.TenantID
+ // Original request, could be nil.
+ pkt *ClientComMessage
+ // Session making the request, could be nil.
+ sess *Session
+ // Routable name of the topic to drop. Duplicated here because pkt could be nil.
+ rcptTo string
+ // UID of the user being deleted. Duplicated here because pkt could be nil.
+ forUser types.Uid
+ // Unregister then delete the topic.
+ del bool
+ // Channel for reporting operation completion when deleting topics for a user.
+ done chan<- bool
+}
+
+type userStatusReq struct {
+ // Tenant which owns the user.
+ tenantID types.TenantID
+ // UID of the user being affected.
+ forUser types.Uid
+ // New topic state value. Only types.StateSuspended is supported at this time.
+ state types.ObjState
+}
+
+// Hub is the core structure which holds topics.
+type Hub struct {
+
+ // Topics must be indexed by name
+ topics *sync.Map
+
+ // Current number of loaded topics
+ numTopics int
+
+ // Channel for routing client-side messages, buffered at 4096
+ routeCli chan *ClientComMessage
+
+ // Process get.info requests for topic not subscribed to, buffered 128.
+ meta chan *ClientComMessage
+
+ // Channel for routing server-generated messages, buffered at 4096
+ routeSrv chan *ServerComMessage
+
+ // subscribe session to topic, possibly creating a new topic, buffered at 256
+ join chan *ClientComMessage
+
+ // Remove topic from hub, possibly deleting it afterwards, buffered at 32
+ unreg chan *topicUnreg
+
+ // Channel for suspending/resuming users, buffered 128.
+ userStatus chan *userStatusReq
+
+ // Cluster request to rehash topics, unbuffered
+ rehash chan bool
+
+ // Request to shutdown, unbuffered
+ shutdown chan chan<- bool
+}
+
+func (h *Hub) topicGet(key types.TopicKey) *Topic {
+ if !key.IsValid() {
+ return nil
+ }
+ if t, ok := h.topics.Load(key); ok {
+ return t.(*Topic)
+ }
+ return nil
+}
+
+func (h *Hub) topicPut(key types.TopicKey, t *Topic) {
+ if !key.IsValid() || t == nil || t.tenantID != key.TenantID || t.name != key.Topic {
+ panic("invalid tenant topic key")
+ }
+ h.numTopics++
+ h.topics.Store(key, t)
+}
+
+func (h *Hub) topicDel(key types.TopicKey) {
+ if !key.IsValid() {
+ return
+ }
+ h.numTopics--
+ h.topics.Delete(key)
+}
+
+func newHub() *Hub {
+ h := &Hub{
+ topics: &sync.Map{},
+ // TODO: verify if these channels have to be buffered.
+ routeCli: make(chan *ClientComMessage, 4096),
+ routeSrv: make(chan *ServerComMessage, 4096),
+ join: make(chan *ClientComMessage, 256),
+ unreg: make(chan *topicUnreg, 256),
+ rehash: make(chan bool),
+ meta: make(chan *ClientComMessage, 128),
+ userStatus: make(chan *userStatusReq, 128),
+ shutdown: make(chan chan<- bool),
+ }
+
+ statsRegisterInt("LiveTopics")
+ statsRegisterInt("TotalTopics")
+
+ statsRegisterInt("IncomingMessagesWebsockTotal")
+ statsRegisterInt("OutgoingMessagesWebsockTotal")
+
+ statsRegisterInt("IncomingMessagesLongpollTotal")
+ statsRegisterInt("OutgoingMessagesLongpollTotal")
+
+ statsRegisterInt("IncomingMessagesGrpcTotal")
+ statsRegisterInt("OutgoingMessagesGrpcTotal")
+
+ statsRegisterInt("FileDownloadsTotal")
+ statsRegisterInt("FileUploadsTotal")
+
+ statsRegisterInt("CtrlCodesTotal2xx")
+ statsRegisterInt("CtrlCodesTotal3xx")
+ statsRegisterInt("CtrlCodesTotal4xx")
+ statsRegisterInt("CtrlCodesTotal5xx")
+
+ statsRegisterHistogram("RequestLatency", requestLatencyDistribution)
+ statsRegisterHistogram("OutgoingMessageSize", outgoingMessageSizeDistribution)
+
+ go h.run()
+
+ return h
+}
+
+func (h *Hub) run() {
+ for {
+ select {
+ case join := <-h.join:
+ if !join.TenantID.IsValid() {
+ logs.Warn.Println("hub.join: missing tenant context", join.RcptTo)
+ if join.sess != nil {
+ join.sess.queueOut(ErrMalformedReply(join, join.Timestamp))
+ }
+ continue
+ }
+ // Handle a subscription request:
+ // 1. Init topic
+ // 1.1 If a new topic is requested, create it
+ // 1.2 If a new subscription to an existing topic is requested:
+ // 1.2.1 check if topic is already loaded
+ // 1.2.2 if not, load it
+ // 1.2.3 if it cannot be loaded (not found), fail
+ // 2. Check access rights and reject, if appropriate
+ // 3. Attach session to the topic
+ // Is the topic already loaded?
+ key := types.TopicKey{TenantID: join.TenantID, Topic: join.RcptTo}
+ t := h.topicGet(key)
+ if t == nil {
+ // Topic does not exist or not loaded.
+ t = &Topic{
+ tenantID: join.TenantID,
+ name: join.RcptTo,
+ xoriginal: join.Original,
+ // Indicates a proxy topic.
+ isProxy: globals.cluster.isRemoteTopic(key),
+ sessions: make(map[*Session]perSessionData),
+ clientMsg: make(chan *ClientComMessage, 192),
+ serverMsg: make(chan *ServerComMessage, 64),
+ reg: make(chan *ClientComMessage, 256),
+ unreg: make(chan *ClientComMessage, 256),
+ meta: make(chan *ClientComMessage, 64),
+ perUser: make(map[types.Uid]perUserData),
+ exit: make(chan *shutDown, 1),
+ }
+ if globals.cluster != nil {
+ if t.isProxy {
+ t.proxy = make(chan *ClusterResp, 128)
+ t.masterNode = globals.cluster.ring.Get(key.RoutingKey())
+ } else {
+ // It's a master topic. Make a channel for handling
+ // direct messages from the proxy.
+ t.master = make(chan *ClusterSessUpdate, 8)
+ }
+ }
+ // Topic is created in suspended state because it's not yet configured.
+ t.markPaused(true)
+ // Save topic now to prevent race condition.
+ h.topicPut(key, t)
+
+ // Configure the topic.
+ go topicInit(t, join, h)
+ } else {
+ // Topic found.
+ if t.isInactive() {
+ // Topic is either not ready or being deleted.
+ if join.sess.inflightReqs != nil {
+ join.sess.inflightReqs.Done()
+ }
+ join.sess.queueOut(ErrLockedReply(join, join.Timestamp))
+ continue
+ }
+ // Topic will check access rights and send appropriate {ctrl}
+ select {
+ case t.reg <- join:
+ default:
+ if join.sess.inflightReqs != nil {
+ join.sess.inflightReqs.Done()
+ }
+ join.sess.queueOut(ErrServiceUnavailableReply(join, join.Timestamp))
+ logs.Err.Println("hub.join loop: topic's reg queue full", join.RcptTo, join.sess.sid,
+ " - total queue len:", len(t.reg))
+ }
+ }
+
+ case msg := <-h.routeCli:
+ // This is a message from a session not subscribed to topic
+ // Route incoming message to topic if topic permits such routing.
+ key := types.TopicKey{TenantID: msg.TenantID, Topic: msg.RcptTo}
+ if dst := h.topicGet(key); dst != nil {
+ // Everything is OK, sending packet to known topic
+ if dst.clientMsg != nil {
+ select {
+ case dst.clientMsg <- msg:
+ default:
+ logs.Err.Println("hub: topic's broadcast queue is full", dst.name)
+ }
+ } else {
+ logs.Warn.Println("hub: invalid topic category for broadcast", dst.name)
+ }
+ } else if msg.Note == nil {
+ // Topic is unknown or offline.
+ // Note is silently ignored, all other messages are reported as accepted to prevent
+ // clients from guessing valid topic names.
+
+ // TODO(gene): validate topic name, discarding invalid topics.
+
+ logs.Info.Printf("Hub. Topic[%s] is unknown or offline", msg.RcptTo)
+
+ msg.sess.queueOut(NoErrAcceptedExplicitTs(msg.Id, msg.RcptTo, types.TimeNow(), msg.Timestamp))
+ }
+ case msg := <-h.routeSrv:
+ // This is a server message from a connection not subscribed to topic
+ // Route incoming message to topic if topic permits such routing.
+ key := types.TopicKey{TenantID: msg.TenantID, Topic: msg.RcptTo}
+ if dst := h.topicGet(key); dst != nil {
+ // Everything is OK, sending packet to known topic
+ select {
+ case dst.serverMsg <- msg:
+ default:
+ logs.Err.Println("hub: topic's broadcast queue is full", dst.name)
+ }
+ } else if (strings.HasPrefix(msg.RcptTo, "usr") || strings.HasPrefix(msg.RcptTo, "grp")) &&
+ globals.cluster.isRemoteTopic(key) {
+ // It is a remote topic.
+ if err := globals.cluster.routeToTopicIntraCluster(msg.RcptTo, msg, msg.sess); err != nil {
+ logs.Warn.Printf("hub: routing to '%s' failed", msg.RcptTo)
+ }
+ }
+ case msg := <-h.meta:
+ // Metadata read or update from a user who is not attached to the topic.
+ if msg.Get != nil {
+ if msg.MetaWhat == constMsgMetaDesc {
+ go replyOfflineTopicGetDesc(msg.sess, msg)
+ } else {
+ go replyOfflineTopicGetSub(msg.sess, msg)
+ }
+ } else if msg.Set != nil {
+ go replyOfflineTopicSetSub(msg.sess, msg)
+ }
+
+ case status := <-h.userStatus:
+ // Suspend/activate user's topics.
+ go h.topicsStateForUser(status.tenantID, status.forUser, status.state == types.StateSuspended)
+
+ case unreg := <-h.unreg:
+ reason := StopNone
+ if unreg.del {
+ reason = StopDeleted
+ }
+ if unreg.forUser.IsZero() {
+ // The topic is being garbage collected or deleted.
+ if err := h.topicUnreg(unreg.tenantID, unreg.sess, unreg.rcptTo, unreg.pkt, reason); err != nil {
+ logs.Err.Println("hub.topicUnreg failed:", err)
+ }
+ } else {
+ // User is being deleted.
+ go h.stopTopicsForUser(unreg.tenantID, unreg.forUser, reason, unreg.done)
+ }
+
+ case <-h.rehash:
+ // Cluster rehashing. Some previously local topics became remote,
+ // and the other way round.
+ // Such topics must be shut down at this node.
+ h.topics.Range(func(_, t any) bool {
+ topic := t.(*Topic)
+ // Handle two cases:
+ // 1. Master topic has moved out to another node.
+ // 2. Proxy topic is running on a new master node
+ // (i.e. the master topic has moved to this node).
+ key := types.TopicKey{TenantID: topic.tenantID, Topic: topic.name}
+ if topic.isProxy != globals.cluster.isRemoteTopic(key) {
+ h.topicUnreg(topic.tenantID, nil, topic.name, nil, StopRehashing)
+ }
+ return true
+ })
+
+ case hubdone := <-h.shutdown:
+ // start cleanup process
+ topicsdone := make(chan bool)
+ topicCount := 0
+ h.topics.Range(func(_, topic any) bool {
+ topic.(*Topic).exit <- &shutDown{done: topicsdone}
+ topicCount++
+ return true
+ })
+
+ for range topicCount {
+ <-topicsdone
+ }
+
+ logs.Info.Printf("Hub shutdown completed with %d topics", topicCount)
+
+ // let the main goroutine know we are done with the cleanup
+ hubdone <- true
+
+ return
+
+ case <-time.After(idleSessionTimeout):
+ }
+ }
+}
+
+// Update state of all topics associated with the given user:
+// * all p2p topics with the given user
+// * group topics where the given user is the owner.
+// 'me' and fnd' are ignored here because they are direcly tied to the user object.
+func (h *Hub) topicsStateForUser(tenantID types.TenantID, uid types.Uid, suspended bool) {
+ h.topics.Range(func(name any, t any) bool {
+ topic := t.(*Topic)
+ if topic.tenantID != tenantID {
+ return true
+ }
+ if topic.cat == types.TopicCatMe || topic.cat == types.TopicCatFnd {
+ return true
+ }
+
+ if _, isMember := topic.perUser[uid]; (topic.cat == types.TopicCatP2P && isMember) || topic.owner == uid {
+ topic.markReadOnly(suspended)
+
+ // Don't send "off" notification on suspension. They will be sent when the user is evicted.
+ }
+ return true
+ })
+}
+
+// topicUnreg deletes or unregisters the topic:
+//
+// Cases:
+// 1. Topic being deleted
+// 1.1 Topic is online
+// 1.1.1 If the requester is the owner or if it's the last sub in a p2p topic (p2p may be sent internally when the last user unsubscribes):
+// 1.1.1.1 Tell topic to stop accepting requests.
+// 1.1.1.2 Hub deletes the topic from database
+// 1.1.1.3 Hub unregisters the topic
+// 1.1.1.4 Hub informs the origin of success or failure
+// 1.1.1.5 Hub forwards request to topic
+// 1.1.1.6 Topic evicts all sessions
+// 1.1.1.7 Topic exits the run() loop
+// 1.1.2 If the requester is not the owner
+// 1.1.2.1 Send it to topic to be treated like {leave unsub=true}
+//
+// 1.2 Topic is offline
+// 1.2.1 If requester is the owner
+// 1.2.1.1 Hub deletes topic from database
+// 1.2.2 If not the owner
+// 1.2.2.1 Delete subscription from DB
+// 1.2.3 Hub informs the origin of success or failure
+// 1.2.4 Send notification to subscribers that the topic was deleted
+
+// 2. Topic is just being unregistered (topic is going offline)
+// 2.1 Unregister it with no further action
+func (h *Hub) topicUnreg(tenantID types.TenantID, sess *Session, topic string, msg *ClientComMessage, reason int) error {
+ now := types.TimeNow()
+ key := types.TopicKey{TenantID: tenantID, Topic: topic}
+
+ // TODO: when channel is deleted unsubscribe all devices from channel's FCM topic.
+
+ if reason == StopDeleted {
+ var asUid types.Uid
+ if msg != nil {
+ asUid = types.ParseUserId(msg.AsUser)
+ }
+ // Case 1 (unregister and delete)
+ if t := h.topicGet(key); t != nil {
+ // Case 1.1: topic is online
+ if (!asUid.IsZero() && t.owner == asUid) || (t.cat == types.TopicCatP2P && t.subsCount() < 2) {
+ // Case 1.1.1: requester is the owner or last sub in a p2p topic
+ t.markPaused(true)
+ hard := true
+ if msg != nil && msg.Del != nil {
+ // Soft-deleting does not make sense for p2p topics.
+ hard = msg.Del.Hard || t.cat == types.TopicCatP2P
+ }
+ if err := store.Topics.Delete(tenantID, topic, t.isChan, hard); err != nil {
+ t.markPaused(false)
+ if sess != nil {
+ sess.queueOut(ErrUnknownReply(msg, now))
+ }
+ return err
+ }
+ if sess != nil {
+ sess.queueOut(NoErrReply(msg, now))
+ }
+
+ if t.isChan {
+ // Notify channel subscribers that the channel is deleted.
+ sendPush(pushForChanDelete(t.tenantID, t.name, now))
+ }
+
+ h.topicDel(key)
+ t.markDeleted()
+ t.exit <- &shutDown{reason: StopDeleted}
+ statsInc("LiveTopics", -1)
+ } else {
+ // Case 1.1.2: requester is NOT the owner or not empty P2P.
+ msg.MetaWhat = constMsgDelTopic
+ msg.sess = sess
+ t.meta <- msg
+ }
+ } else {
+ // Case 1.2: topic is offline.
+
+ // Is user a channel subscriber? Use chnABC instead of grpABC and get only this user's subscription.
+ var opts *types.QueryOpt
+ if types.IsChannel(msg.Original) {
+ topic = msg.Original
+ opts = &types.QueryOpt{User: asUid}
+ }
+
+ // Get all subscribers of non-channel topics: we need to know how many are left and notify them.
+ // Get only one subscription for channel users.
+ subs, err := store.Topics.GetSubs(tenantID, topic, opts)
+ if err != nil {
+ sess.queueOut(ErrUnknownReply(msg, now))
+ return err
+ }
+
+ tcat := topicCat(topic)
+ if len(subs) == 0 {
+ if tcat == types.TopicCatP2P {
+ // No subscribers: delete.
+ store.Topics.Delete(tenantID, topic, false, true)
+ }
+ sess.queueOut(InfoNoActionReply(msg, now))
+ return nil
+ }
+
+ // Find subscription of the current user.
+ var sub *types.Subscription
+ user := asUid.String()
+ for i := range subs {
+ if subs[i].User == user {
+ sub = &subs[i]
+ break
+ }
+ }
+
+ if sub == nil {
+ // If user has no subscription, tell him all is fine
+ sess.queueOut(InfoNoActionReply(msg, now))
+ return nil
+ }
+
+ if !(sub.ModeGiven & sub.ModeWant).IsOwner() {
+ // Case 1.2.2.1 Not the owner, but possibly last subscription in a P2P topic.
+
+ if tcat == types.TopicCatP2P && len(subs) < 2 {
+ // This is a P2P topic and fewer than 2 subscriptions, delete the entire topic
+ if err := store.Topics.Delete(tenantID, topic, false, msg.Del.Hard); err != nil {
+ sess.queueOut(ErrUnknownReply(msg, now))
+ return err
+ }
+ // Inform plugin that the topic was deleted.
+ pluginTopic(&Topic{tenantID: tenantID, name: topic}, plgActDel)
+ } else if err := store.Subs.Delete(tenantID, topic, asUid); err != nil {
+ // Not P2P or more than 1 subscription left.
+ // Delete user's own subscription only
+ if err == types.ErrNotFound {
+ sess.queueOut(InfoNoActionReply(msg, now))
+ err = nil
+ } else {
+ sess.queueOut(ErrUnknownReply(msg, now))
+ }
+ return err
+ }
+
+ // Notify user's other sessions that the subscription is gone
+ presSingleUserOfflineOffline(tenantID, asUid, msg.Original, "gone", nilPresParams, sess.sid)
+ if tcat == types.TopicCatP2P && len(subs) == 2 {
+ uname1 := asUid.UserId()
+ uid2 := types.ParseUserId(msg.Original)
+ // Tell user1 to stop sending updates to user2 without passing change to user1's sessions.
+ presSingleUserOfflineOffline(tenantID, asUid, uid2.UserId(), "?none+rem", nilPresParams, "")
+ // Don't change the online status of user1, just ask user2 to stop notification exchange.
+ // Tell user2 that user1 is offline but let him keep sending updates in case user1 resubscribes.
+ presSingleUserOfflineOffline(tenantID, uid2, uname1, "off", nilPresParams, "")
+ }
+
+ // Inform plugin that the subscription was deleted.
+ sub.TenantID = tenantID
+ pluginSubscription(sub, plgActDel)
+ } else {
+ // Case 1.2.1.1: owner, delete the group topic from db. Only group topics have owners.
+ // We don't know if the group topic is a channel, but cleaning it as a channel does no harm
+ // other than a small performance penalty.
+ if err := store.Topics.Delete(tenantID, topic, true, msg.Del.Hard); err != nil {
+ sess.queueOut(ErrUnknownReply(msg, now))
+ return err
+ }
+
+ // Notify subscribers that the group topic is gone.
+ presSubsOfflineOffline(tenantID, topic, tcat, subs, "gone", &presParams{}, sess.sid)
+
+ // Notify channel subscribers that the channel is deleted.
+ // The push will not be delivered to anybody if the topic is not a channel.
+ sendPush(pushForChanDelete(tenantID, topic, now))
+
+ // Inform plugin that the topic was deleted.
+ pluginTopic(&Topic{tenantID: tenantID, name: topic}, plgActDel)
+ }
+
+ sess.queueOut(NoErrReply(msg, now))
+ }
+ } else {
+ // Case 2: just unregister.
+ // If t is nil, it's not registered, no action is needed
+ if t := h.topicGet(key); t != nil {
+ t.markDeleted()
+ h.topicDel(key)
+
+ t.exit <- &shutDown{reason: reason}
+
+ statsInc("LiveTopics", -1)
+ }
+
+ // sess && msg could be nil if the topic is being killed by timer or due to rehashing.
+ if sess != nil && msg != nil {
+ sess.queueOut(NoErrReply(msg, now))
+ }
+ }
+
+ return nil
+}
+
+// Terminate all topics associated with the given user:
+// * all p2p topics with the given user
+// * group topics where the given user is the owner.
+// * user's 'me', 'fnd', 'slf' topics.
+func (h *Hub) stopTopicsForUser(tenantID types.TenantID, uid types.Uid, reason int, alldone chan<- bool) {
+ var done chan bool
+ if alldone != nil {
+ done = make(chan bool, 128)
+ }
+
+ count := 0
+ h.topics.Range(func(name any, t any) bool {
+ topic := t.(*Topic)
+ if topic.tenantID != tenantID {
+ return true
+ }
+ if _, isMember := topic.perUser[uid]; (topic.cat != types.TopicCatGrp && isMember) ||
+ topic.owner == uid {
+ topic.markDeleted()
+ h.topics.Delete(name)
+
+ // This call is non-blocking unless some other routine tries to stop it at the same time.
+ topic.exit <- &shutDown{reason: reason, done: done}
+
+ // Just send to p2p topics here.
+ if topic.cat == types.TopicCatP2P && len(topic.perUser) == 2 {
+ presSingleUserOfflineOffline(tenantID, topic.p2pOtherUser(uid), uid.UserId(), "gone", nilPresParams, "")
+ }
+ count++
+ }
+ return true
+ })
+
+ statsInc("LiveTopics", -count)
+
+ if alldone != nil {
+ for range count {
+ <-done
+ }
+ alldone <- true
+ }
+}
+
+// replyOfflineTopicGetDesc reads a minimal topic Desc from the database.
+// The requester may or maynot be subscribed to the topic.
+func replyOfflineTopicGetDesc(sess *Session, msg *ClientComMessage) {
+ now := types.TimeNow()
+ desc := &MsgTopicDesc{}
+ asUid := types.ParseUserId(msg.AsUser)
+ topic := msg.RcptTo
+
+ if strings.HasPrefix(topic, "grp") || topic == "sys" {
+ stopic, err := store.Topics.Get(msg.TenantID, topic)
+ if err != nil {
+ logs.Info.Println("replyOfflineTopicGetDesc", err)
+ sess.queueOut(decodeStoreErrorExplicitTs(err, msg.Id, msg.Original, now, msg.Timestamp, nil))
+ return
+ }
+ if stopic == nil {
+ sess.queueOut(ErrTopicNotFoundReply(msg, now))
+ return
+ }
+
+ desc.CreatedAt = &stopic.CreatedAt
+ desc.UpdatedAt = &stopic.UpdatedAt
+ desc.Public = stopic.Public
+ desc.Trusted = stopic.Trusted
+ desc.IsChan = stopic.UseBt
+ desc.SubCnt = stopic.SubCnt
+ if stopic.Owner == msg.AsUser {
+ desc.DefaultAcs = &MsgDefaultAcsMode{
+ Auth: stopic.Access.Auth.String(),
+ Anon: stopic.Access.Anon.String(),
+ }
+ }
+ // Report appropriate access level. Could be overridden below if subscription exists.
+ desc.Acs = &MsgAccessMode{}
+ switch sess.authLvl {
+ case auth.LevelAuth, auth.LevelRoot:
+ desc.Acs.Mode = stopic.Access.Auth.String()
+ case auth.LevelAnon:
+ desc.Acs.Mode = stopic.Access.Anon.String()
+ }
+ } else {
+ // 'me' and p2p topics
+ uid := types.ZeroUid
+ if strings.HasPrefix(topic, "usr") {
+ // User specified as usrXXX
+ uid = types.ParseUserId(topic)
+ topic = asUid.P2PName(uid)
+ } else if strings.HasPrefix(topic, "p2p") {
+ // User specified as p2pXXXYYY
+ uid1, uid2, _ := types.ParseP2P(topic)
+ if uid1 == asUid {
+ uid = uid2
+ } else if uid2 == asUid {
+ uid = uid1
+ }
+ }
+
+ if uid.IsZero() {
+ logs.Warn.Println("replyOfflineTopicGetDesc: malformed p2p topic name")
+ sess.queueOut(ErrMalformedReply(msg, now))
+ return
+ }
+
+ suser, err := store.Users.Get(msg.TenantID, uid)
+ if err != nil {
+ sess.queueOut(decodeStoreErrorExplicitTs(err, msg.Id, msg.Original, now, msg.Timestamp, nil))
+ return
+ }
+ if suser == nil {
+ sess.queueOut(ErrUserNotFoundReply(msg, now))
+ return
+ }
+ desc.CreatedAt = &suser.CreatedAt
+ desc.UpdatedAt = &suser.UpdatedAt
+ desc.Public = suser.Public
+ desc.Trusted = suser.Trusted
+ if sess.authLvl == auth.LevelRoot {
+ desc.State = suser.State.String()
+ }
+
+ // Report appropriate access level. Could be overridden below if subscription exists.
+ desc.Acs = &MsgAccessMode{}
+ switch sess.authLvl {
+ case auth.LevelAuth, auth.LevelRoot:
+ desc.Acs.Mode = suser.Access.Auth.String()
+ case auth.LevelAnon:
+ desc.Acs.Mode = suser.Access.Anon.String()
+ }
+ }
+
+ sub, err := store.Subs.Get(msg.TenantID, topic, asUid, false)
+ if err != nil {
+ logs.Warn.Println("replyOfflineTopicGetDesc:", err)
+ sess.queueOut(decodeStoreErrorExplicitTs(err, msg.Id, msg.Original, now, msg.Timestamp, nil))
+ return
+ }
+
+ if sub != nil {
+ desc.Private = sub.Private
+ // FIXME: suspended topics should get no AW access.
+ desc.Acs = &MsgAccessMode{
+ Want: sub.ModeWant.String(),
+ Given: sub.ModeGiven.String(),
+ Mode: (sub.ModeGiven & sub.ModeWant).String(),
+ }
+ }
+
+ sess.queueOut(&ServerComMessage{
+ Meta: &MsgServerMeta{
+ Id: msg.Id, Topic: msg.Original, Timestamp: &now, Desc: desc,
+ },
+ })
+}
+
+// replyOfflineTopicGetSub reads user's subscription from the database.
+// Only own subscription is available.
+// The requester must be subscribed but need not be attached.
+func replyOfflineTopicGetSub(sess *Session, msg *ClientComMessage) {
+ now := types.TimeNow()
+
+ if msg.Get.Sub != nil && msg.Get.Sub.User != "" && msg.Get.Sub.User != msg.AsUser {
+ sess.queueOut(ErrPermissionDeniedReply(msg, now))
+ return
+ }
+
+ topicName := msg.RcptTo
+ if types.IsChannel(msg.Original) {
+ topicName = msg.Original
+ }
+
+ ssub, err := store.Subs.Get(msg.TenantID, topicName, types.ParseUserId(msg.AsUser), true)
+ if err != nil {
+ logs.Warn.Println("replyOfflineTopicGetSub:", err)
+ sess.queueOut(decodeStoreErrorExplicitTs(err, msg.Id, msg.Original, now, msg.Timestamp, nil))
+ return
+ }
+
+ if ssub == nil {
+ sess.queueOut(ErrNotFoundExplicitTs(msg.Id, msg.Original, now, msg.Timestamp))
+ return
+ }
+
+ sub := MsgTopicSub{}
+ if ssub.DeletedAt == nil {
+ sub.UpdatedAt = &ssub.UpdatedAt
+ sub.Acs = MsgAccessMode{
+ Want: ssub.ModeWant.String(),
+ Given: ssub.ModeGiven.String(),
+ Mode: (ssub.ModeGiven & ssub.ModeWant).String(),
+ }
+ // Fnd is asymmetric: desc.private is a string, but sub.private is a []string.
+ if types.GetTopicCat(msg.RcptTo) != types.TopicCatFnd {
+ sub.Private = ssub.Private
+ }
+ sub.User = types.ParseUid(ssub.User).UserId()
+
+ if (ssub.ModeGiven & ssub.ModeWant).IsReader() && (ssub.ModeWant & ssub.ModeGiven).IsJoiner() {
+ sub.DelId = ssub.DelId
+ sub.ReadSeqId = ssub.ReadSeqId
+ sub.RecvSeqId = ssub.RecvSeqId
+ }
+ } else {
+ sub.DeletedAt = ssub.DeletedAt
+ }
+
+ sess.queueOut(&ServerComMessage{
+ Meta: &MsgServerMeta{
+ Id: msg.Id, Topic: msg.Original, Timestamp: &now, Sub: []MsgTopicSub{sub},
+ },
+ })
+}
+
+// replyOfflineTopicSetSub updates Desc.Private and Sub.Mode when the topic is not loaded in memory.
+// Only Private and Mode are updated and only for the requester. The requester must be subscribed to the
+// topic but does not need to be attached.
+func replyOfflineTopicSetSub(sess *Session, msg *ClientComMessage) {
+ now := types.TimeNow()
+
+ if (msg.Set.Desc == nil || msg.Set.Desc.Private == nil) && (msg.Set.Sub == nil || msg.Set.Sub.Mode == "") {
+ sess.queueOut(InfoNotModifiedReply(msg, now))
+ return
+ }
+
+ if msg.Set.Sub != nil && msg.Set.Sub.User != "" && msg.Set.Sub.User != msg.AsUser {
+ sess.queueOut(ErrPermissionDeniedReply(msg, now))
+ return
+ }
+
+ asUid := types.ParseUserId(msg.AsUser)
+
+ topicName := msg.RcptTo
+ if types.IsChannel(msg.Original) {
+ topicName = msg.Original
+ }
+
+ sub, err := store.Subs.Get(msg.TenantID, topicName, asUid, false)
+ if err != nil {
+ logs.Warn.Println("replyOfflineTopicSetSub get sub:", err)
+ sess.queueOut(decodeStoreErrorExplicitTs(err, msg.Id, msg.Original, now, msg.Timestamp, nil))
+ return
+ }
+
+ if sub == nil {
+ sess.queueOut(ErrNotFoundExplicitTs(msg.Id, msg.Original, now, msg.Timestamp))
+ return
+ }
+
+ update := make(map[string]any)
+ if msg.Set.Desc != nil && msg.Set.Desc.Private != nil {
+ private, ok := msg.Set.Desc.Private.(map[string]any)
+ if !ok {
+ update = map[string]any{"Private": msg.Set.Desc.Private}
+ } else if private, changed := mergeInterfaces(sub.Private, private); changed {
+ update = map[string]any{"Private": private}
+ }
+ }
+
+ if msg.Set.Sub != nil && msg.Set.Sub.Mode != "" {
+ var modeWant types.AccessMode
+ if err = modeWant.UnmarshalText([]byte(msg.Set.Sub.Mode)); err != nil {
+ logs.Warn.Println("replyOfflineTopicSetSub mode:", err)
+ sess.queueOut(decodeStoreErrorExplicitTs(err, msg.Id, msg.Original, now, msg.Timestamp, nil))
+ return
+ }
+
+ if modeWant.IsOwner() != sub.ModeWant.IsOwner() {
+ // No ownership changes here.
+ sess.queueOut(ErrPermissionDeniedReply(msg, now))
+ return
+ }
+
+ if types.GetTopicCat(msg.RcptTo) == types.TopicCatP2P {
+ // For P2P topics ignore requests exceeding typesModeCP2P and do not allow
+ // removal of 'A' permission.
+ modeWant = modeWant&globals.typesModeCP2P | types.ModeApprove
+ }
+
+ if modeWant != sub.ModeWant {
+ update["ModeWant"] = modeWant
+ // Cache it for later use
+ sub.ModeWant = modeWant
+ }
+ }
+
+ if len(update) > 0 {
+ err = store.Subs.Update(msg.TenantID, topicName, asUid, update)
+ if err != nil {
+ logs.Warn.Println("replyOfflineTopicSetSub update:", err)
+ sess.queueOut(decodeStoreErrorExplicitTs(err, msg.Id, msg.Original, now, msg.Timestamp, nil))
+ } else {
+ var params any
+ if update["ModeWant"] != nil {
+ params = map[string]any{
+ "acs": MsgAccessMode{
+ Given: sub.ModeGiven.String(),
+ Want: sub.ModeWant.String(),
+ Mode: (sub.ModeGiven & sub.ModeWant).String(),
+ },
+ }
+ }
+ sess.queueOut(NoErrParamsReply(msg, now, params))
+ }
+ } else {
+ sess.queueOut(InfoNotModifiedReply(msg, now))
+ }
+}
diff --git a/server/hub_tenant_test.go b/server/hub_tenant_test.go
new file mode 100644
index 0000000..df23e7b
--- /dev/null
+++ b/server/hub_tenant_test.go
@@ -0,0 +1,55 @@
+package main
+
+import (
+ "sync"
+ "testing"
+
+ "github.com/tinode/chat/server/store/types"
+)
+
+func TestHubKeepsSameTopicNameSeparateByTenant(t *testing.T) {
+ h := &Hub{topics: &sync.Map{}}
+ aKey := types.TopicKey{TenantID: 1, Topic: "sys"}
+ bKey := types.TopicKey{TenantID: 2, Topic: "sys"}
+ a := &Topic{tenantID: 1, name: "sys"}
+ b := &Topic{tenantID: 2, name: "sys"}
+
+ h.topicPut(aKey, a)
+ h.topicPut(bKey, b)
+
+ if got := h.topicGet(aKey); got != a {
+ t.Fatalf("tenant 1 returned wrong topic: %p", got)
+ }
+ if got := h.topicGet(bKey); got != b {
+ t.Fatalf("tenant 2 returned wrong topic: %p", got)
+ }
+ if h.numTopics != 2 {
+ t.Fatalf("expected 2 topics, got %d", h.numTopics)
+ }
+}
+
+func TestServerMessageKeepsTenantAndRejectsCrossTenantDelivery(t *testing.T) {
+ original := &ServerComMessage{TenantID: 2, RcptTo: "sys"}
+ if copied := original.copy(); copied.TenantID != original.TenantID {
+ t.Fatalf("copy lost tenant: got %d, want %d", copied.TenantID, original.TenantID)
+ }
+
+ sess := &Session{tenantID: 1, send: make(chan any, 1)}
+ if sess.queueOut(original) {
+ t.Fatal("cross-tenant message was accepted")
+ }
+ select {
+ case <-sess.send:
+ t.Fatal("cross-tenant message reached the session queue")
+ default:
+ }
+}
+
+func TestTopicAssignsTenantToServerMessage(t *testing.T) {
+ topic := &Topic{tenantID: 7, cat: types.TopicCatGrp, xoriginal: "grpTest"}
+ msg := &ServerComMessage{Data: &MsgServerData{Topic: "grpTest"}}
+ topic.prepareBroadcastableMessage(msg, types.ZeroUid, false)
+ if msg.TenantID != topic.tenantID {
+ t.Fatalf("message tenant = %d, want %d", msg.TenantID, topic.tenantID)
+ }
+}
diff --git a/server/init_topic.go b/server/init_topic.go
index e69de29..4ac0eaa 100644
--- a/server/init_topic.go
+++ b/server/init_topic.go
@@ -0,0 +1,849 @@
+/******************************************************************************
+ *
+ * Description :
+ *
+ * Topic initilization routines.
+ *
+ *****************************************************************************/
+
+package main
+
+import (
+ "strings"
+
+ "github.com/tinode/chat/server/auth"
+ "github.com/tinode/chat/server/logs"
+ "github.com/tinode/chat/server/store"
+ "github.com/tinode/chat/server/store/types"
+)
+
+// topicInit reads an existing topic from database or creates a new topic
+func topicInit(t *Topic, join *ClientComMessage, h *Hub) {
+ var subscribeReqIssued bool
+ defer func() {
+ if !subscribeReqIssued && join.Sub != nil && join.sess.inflightReqs != nil {
+ // If it was a client initiated subscribe request and we failed it.
+ join.sess.inflightReqs.Done()
+ }
+ }()
+
+ timestamp := types.TimeNow()
+
+ var err error
+ switch {
+ case t.xoriginal == "me":
+ // Request to load a 'me' topic. The topic always exists, the subscription is never new.
+ err = initTopicMe(t, join)
+ case t.xoriginal == "fnd":
+ // Request to load a 'find' topic. The topic always exists, the subscription is never new.
+ err = initTopicFnd(t, join)
+ case strings.HasPrefix(t.xoriginal, "usr") || strings.HasPrefix(t.xoriginal, "p2p"):
+ // Request to load an existing or create a new p2p topic, then attach to it.
+ err = initTopicP2P(t, join)
+ case strings.HasPrefix(t.xoriginal, "new"):
+ // Processing request to create a new group topic.
+ err = initTopicNewGrp(t, join, false)
+ case strings.HasPrefix(t.xoriginal, "nch"):
+ // Processing request to create a new channel.
+ err = initTopicNewGrp(t, join, true)
+ case strings.HasPrefix(t.xoriginal, "grp") || strings.HasPrefix(t.xoriginal, "chn"):
+ // Load existing group topic (or channel).
+ err = initTopicGrp(t)
+ case t.xoriginal == "sys":
+ // Initialize system topic.
+ err = initTopicSys(t)
+ case t.xoriginal == "slf":
+ // Initialize self (notes and saved messages) topic.
+ err = initTopicSlf(t, join)
+ default:
+ // Unrecognized topic name
+ err = types.ErrTopicNotFound
+ }
+
+ // Failed to create or load the topic.
+ if err != nil {
+ // Remove topic from cache to prevent hub from forwarding more messages to it.
+ h.topicDel(types.TopicKey{TenantID: join.TenantID, Topic: join.RcptTo})
+
+ logs.Err.Println("init_topic: failed to load or create topic:", join.RcptTo, err)
+ join.sess.queueOut(decodeStoreErrorExplicitTs(err, join.Id, t.xoriginal, timestamp, join.Timestamp, nil))
+
+ // Re-queue pending requests to join the topic.
+ for len(t.reg) > 0 {
+ h.join <- (<-t.reg)
+ }
+
+ // Reject all other pending requests
+ for len(t.clientMsg) > 0 {
+ msg := <-t.clientMsg
+ if msg.init {
+ msg.sess.queueOut(ErrLockedExplicitTs(msg.Id, t.xoriginal, timestamp, join.Timestamp))
+ }
+ }
+ for len(t.unreg) > 0 {
+ msg := <-t.unreg
+ if msg.sess != nil && msg.sess.inflightReqs != nil {
+ msg.sess.inflightReqs.Done()
+ }
+ if msg.init {
+ msg.sess.queueOut(ErrLockedReply(msg, timestamp))
+ }
+ }
+ for len(t.meta) > 0 {
+ msg := <-t.meta
+ if msg.init {
+ msg.sess.queueOut(ErrLockedReply(msg, timestamp))
+ }
+ }
+ if len(t.exit) > 0 {
+ msg := <-t.exit
+ msg.done <- true
+ }
+
+ return
+ }
+
+ t.computePerUserAcsUnion()
+
+ // prevent newly initialized topics to go live while shutdown in progress
+ if globals.shuttingDown {
+ h.topicDel(types.TopicKey{TenantID: join.TenantID, Topic: join.RcptTo})
+ return
+ }
+
+ if t.isDeleted() {
+ // Someone deleted the topic while we were trying to create it.
+ return
+ }
+
+ statsInc("LiveTopics", 1)
+ statsInc("TotalTopics", 1)
+ usersRegisterTopic(t, true)
+
+ // Topic will check access rights, send invite to p2p user, send {ctrl} message to the initiator session
+ if join.Sub != nil {
+ subscribeReqIssued = true
+ t.reg <- join
+ }
+
+ t.markPaused(false)
+ if t.cat == types.TopicCatFnd || t.cat == types.TopicCatSys {
+ t.markLoaded()
+ }
+
+ go t.run(h)
+}
+
+// Initialize 'me' topic.
+func initTopicMe(t *Topic, sreg *ClientComMessage) error {
+ t.cat = types.TopicCatMe
+
+ user, err := store.Users.Get(t.tenantID, types.ParseUserId(t.name))
+ if err != nil {
+ // Log out the session
+ sreg.sess.uid = types.ZeroUid
+ return err
+ } else if user == nil {
+ // Log out the session
+ sreg.sess.uid = types.ZeroUid
+ return types.ErrUserNotFound
+ }
+
+ // User's default access for p2p topics
+ t.accessAuth = user.Access.Auth
+ t.accessAnon = user.Access.Anon
+
+ // Assign tags
+ t.tags = user.Tags
+
+ if err = t.loadSubscribers(); err != nil {
+ return err
+ }
+
+ t.public = user.Public
+ t.trusted = user.Trusted
+
+ t.created = user.CreatedAt
+ t.updated = user.UpdatedAt
+
+ // The following values are exlicitly not set for 'me'.
+ // t.touched, t.lastId, t.delId
+
+ // 'me' has no owner, t.owner = nil
+
+ // Initiate User Agent with the UA of the creating session to report it later
+ t.userAgent = sreg.sess.userAgent
+ // Initialize channel for receiving user agent and session online updates.
+ t.supd = make(chan *sessionUpdate, 32)
+
+ if !t.isProxy {
+ // Allocate storage for contacts.
+ t.perSubs = make(map[string]perSubsData)
+ }
+
+ return nil
+}
+
+// Initialize 'fnd' topic
+func initTopicFnd(t *Topic, sreg *ClientComMessage) error {
+ t.cat = types.TopicCatFnd
+
+ uid := types.ParseUserId(sreg.AsUser)
+ if uid.IsZero() {
+ return types.ErrNotFound
+ }
+
+ user, err := store.Users.Get(t.tenantID, uid)
+ if err != nil {
+ return err
+ } else if user == nil {
+ if !sreg.sess.isMultiplex() {
+ sreg.sess.uid = types.ZeroUid
+ }
+ return types.ErrNotFound
+ }
+
+ // Make sure no one can join the topic.
+ t.accessAuth = getDefaultAccess(t.cat, true, false)
+ t.accessAnon = getDefaultAccess(t.cat, false, false)
+
+ if err = t.loadSubscribers(); err != nil {
+ return err
+ }
+
+ t.created = user.CreatedAt
+ t.updated = user.UpdatedAt
+
+ // 'fnd' has no owner, t.owner = nil
+
+ // Publishing to fnd is not supported
+ // t.lastId = 0, t.delId = 0, t.touched = nil
+
+ return nil
+}
+
+// Load or create a P2P topic.
+// There is a reace condition when two users try to create a p2p topic at the same time.
+func initTopicP2P(t *Topic, sreg *ClientComMessage) error {
+ pktsub := sreg.Sub
+
+ // Handle the following cases:
+ // 1. Neither topic nor subscriptions exist: create a new p2p topic & subscriptions.
+ // 2. Topic exists, one of the subscriptions is missing:
+ // 2.1 Requester's subscription is missing, recreate it.
+ // 2.2 Other user's subscription is missing, treat like a new request for user 2.
+ // 3. Topic exists, both subscriptions are missing: should not happen, fail.
+ // 4. Topic and both subscriptions exist: attach to topic
+
+ t.cat = types.TopicCatP2P
+
+ // Check if the topic already exists
+ stopic, err := store.Topics.Get(t.tenantID, t.name)
+ if err != nil {
+ return err
+ }
+
+ // If topic exists, load subscriptions
+ var subs []types.Subscription
+ if stopic != nil {
+ // Subs already have Public swapped
+ if subs, err = store.Topics.GetUsers(t.tenantID, t.name, nil); err != nil {
+ return err
+ }
+
+ // Case 3, fail
+ if len(subs) == 0 {
+ logs.Err.Println("hub: missing both subscriptions for '" + t.name + "' (SHOULD NEVER HAPPEN!)")
+ return types.ErrInternal
+ }
+
+ t.created = stopic.CreatedAt
+ t.updated = stopic.UpdatedAt
+ if !stopic.TouchedAt.IsZero() {
+ t.touched = stopic.TouchedAt
+ }
+ t.aux = stopic.Aux
+ t.lastID = stopic.SeqId
+ t.delID = stopic.DelId
+ }
+
+ // t.owner is blank for p2p topics
+
+ // Default user access to P2P topics is not set because it's unused.
+ // Other users cannot join the topic because of how topic name is constructed.
+ // The two participants set each other's access instead.
+ // t.accessAuth = getDefaultAccess(t.cat, true)
+ // t.accessAnon = getDefaultAccess(t.cat, false)
+
+ // t.public and t.trusted are not used for p2p topics since each user get a different public/trusted.
+
+ if stopic != nil && len(subs) == 2 {
+ // Case 4.
+ for i := range 2 {
+ uid := types.ParseUid(subs[i].User)
+ t.perUser[uid] = perUserData{
+ // Adapter has already swapped the state, public, defaultAccess, lastSeen values.
+ public: subs[i].GetPublic(),
+ lastSeen: subs[i].GetLastSeen(),
+ lastUA: subs[i].GetUserAgent(),
+ topicName: types.ParseUid(subs[(i+1)%2].User).UserId(),
+
+ private: subs[i].Private,
+ modeWant: subs[i].ModeWant,
+ modeGiven: subs[i].ModeGiven,
+ delID: subs[i].DelId,
+ recvID: subs[i].RecvSeqId,
+ readID: subs[i].ReadSeqId,
+ }
+ }
+ } else {
+ // Cases 1 (new topic), 2 (one of the two subscriptions is missing: either it's a new request
+ // or the subscription was deleted)
+ var userData perUserData
+
+ // Fetching records for both users.
+ // Requester.
+ userID1 := types.ParseUserId(sreg.AsUser)
+ // The other user.
+ userID2 := types.ParseUserId(t.xoriginal)
+
+ // User index: u1 - requester, u2 - responder, the other user
+ var u1, u2 int
+ users, err := store.Users.GetAll(t.tenantID, userID1, userID2)
+ if err != nil {
+ return err
+ }
+ if len(users) != 2 {
+ // Invited user does not exist
+ return types.ErrUserNotFound
+ }
+
+ // User records are unsorted, make sure we know who is who.
+ if users[0].Uid() == userID1 {
+ u1, u2 = 0, 1
+ } else {
+ u1, u2 = 1, 0
+ }
+
+ // Figure out which subscriptions are missing: User1's, User2's or both.
+ var sub1, sub2 *types.Subscription
+ // Set to true if only requester's subscription has to be created.
+ var user1only bool
+ if len(subs) == 1 {
+ if subs[0].User == userID1.String() {
+ // User2's subscription is missing, user1's exists
+ sub1 = &subs[0]
+ } else {
+ // User1's is missing, user2's exists
+ sub2 = &subs[0]
+ user1only = true
+ }
+ }
+
+ // Other user's (responder's) subscription is missing
+ if sub2 == nil {
+ sub2 = &types.Subscription{
+ TenantID: t.tenantID,
+ User: userID2.String(),
+ Topic: t.name,
+ Private: nil,
+ }
+
+ // Assign user2's ModeGiven based on what user1 has provided.
+ // We don't know access mode for user2, assume it's Auth.
+ if pktsub.Set != nil && pktsub.Set.Desc != nil && pktsub.Set.Desc.DefaultAcs != nil {
+ // Use provided DefaultAcs as non-default modeGiven for the other user.
+ // The other user is assumed to have auth level "Auth".
+ sub2.ModeGiven = users[u1].Access.Auth
+ if err := sub2.ModeGiven.UnmarshalText([]byte(pktsub.Set.Desc.DefaultAcs.Auth)); err != nil {
+ logs.Err.Println("hub: invalid access mode", t.xoriginal, pktsub.Set.Desc.DefaultAcs.Auth)
+ }
+ } else {
+ // Use user1.Auth as modeGiven for the other user
+ sub2.ModeGiven = users[u1].Access.Auth
+ }
+ // Sanity check
+ sub2.ModeGiven = sub2.ModeGiven&globals.typesModeCP2P | types.ModeApprove
+
+ // Swap Public+Trusted to match swapped Public+Trusted in subs returned from store.Topics.GetSubs
+ sub2.SetPublic(users[u1].Public)
+ sub2.SetTrusted(users[u1].Trusted)
+
+ // Mark the entire topic as new.
+ pktsub.Created = true
+ }
+
+ // Requester's subscription is missing:
+ // a. requester is starting a new topic
+ // b. requester's subscription is missing: deleted or creation failed
+ if sub1 == nil {
+ // Set user1's ModeGiven from user2's default values
+ userData.modeGiven = selectAccessMode(auth.Level(sreg.AuthLvl),
+ users[u2].Access.Anon,
+ users[u2].Access.Auth,
+ globals.typesModeCP2P)
+
+ // By default assign the same mode that user1 gave to user2 (could be changed below)
+ userData.modeWant = sub2.ModeGiven
+
+ if pktsub.Set != nil {
+ if pktsub.Set.Sub != nil {
+ uid := userID1
+ if pktsub.Set.Sub.User != "" {
+ uid = types.ParseUserId(pktsub.Set.Sub.User)
+ }
+
+ if uid != userID1 {
+ // Report the error and ignore the value
+ logs.Err.Println("hub: setting mode for another user is not supported '" + t.name + "'")
+ } else {
+ // user1 is setting non-default modeWant
+ if err := userData.modeWant.UnmarshalText([]byte(pktsub.Set.Sub.Mode)); err != nil {
+ logs.Err.Println("hub: invalid access mode", t.xoriginal, pktsub.Set.Sub.Mode)
+ }
+ // Ensure sanity
+ userData.modeWant = userData.modeWant&globals.typesModeCP2P | types.ModeApprove
+ }
+
+ // Since user1 issued a {sub} request, make sure the user can join
+ userData.modeWant |= types.ModeJoin
+ }
+
+ // user1 sets non-default Private
+ if pktsub.Set.Desc != nil {
+ if !isNullValue(pktsub.Set.Desc.Private) {
+ userData.private = pktsub.Set.Desc.Private
+ }
+ // Public, if present, is ignored
+ }
+ }
+
+ sub1 = &types.Subscription{
+ TenantID: t.tenantID,
+ User: userID1.String(),
+ Topic: t.name,
+ ModeWant: userData.modeWant,
+ ModeGiven: userData.modeGiven,
+ Private: userData.private,
+ }
+ // Swap Public+Trsuted to match swapped Public+Trusted in subs returned from store.Topics.GetSubs
+ sub1.SetPublic(users[u2].Public)
+ sub1.SetTrusted(users[u2].Trusted)
+
+ // Mark this subscription as new
+ pktsub.Newsub = true
+ }
+
+ if !user1only {
+ // sub2 is being created, assign sub2.modeWant to what user2 gave to user1 (sub1.modeGiven)
+ sub2.ModeWant = selectAccessMode(auth.Level(sreg.AuthLvl),
+ users[u2].Access.Anon,
+ users[u2].Access.Auth,
+ globals.typesModeCP2P)
+ // Ensure sanity
+ sub2.ModeWant = sub2.ModeWant&globals.typesModeCP2P | types.ModeApprove
+ }
+
+ // Create everything
+ if stopic == nil {
+ if err = store.Topics.CreateP2P(t.tenantID, sub1, sub2); err != nil {
+ return err
+ }
+
+ t.created = sub1.CreatedAt
+ t.updated = sub1.UpdatedAt
+ t.touched = t.updated
+
+ // t.lastId is not set (default 0) for new topics
+
+ } else {
+ // TODO possibly update subscription, if changed
+
+ // Recreate one of the subscriptions
+ var subToMake *types.Subscription
+ if user1only {
+ subToMake = sub1
+ } else {
+ subToMake = sub2
+ }
+ if err = store.Subs.Create(t.tenantID, subToMake); err != nil {
+ return err
+ }
+ }
+
+ // Public and Trusted are already swapped.
+ userData.public = sub1.GetPublic()
+ userData.trusted = sub1.GetTrusted()
+ userData.topicName = userID2.UserId()
+ userData.modeWant = sub1.ModeWant
+ userData.modeGiven = sub1.ModeGiven
+ userData.delID = sub1.DelId
+ userData.readID = sub1.ReadSeqId
+ userData.recvID = sub1.RecvSeqId
+ t.perUser[userID1] = userData
+
+ t.perUser[userID2] = perUserData{
+ public: sub2.GetPublic(),
+ trusted: sub2.GetTrusted(),
+ topicName: userID1.UserId(),
+ modeWant: sub2.ModeWant,
+ modeGiven: sub2.ModeGiven,
+ delID: sub2.DelId,
+ readID: sub2.ReadSeqId,
+ recvID: sub2.RecvSeqId,
+ }
+ }
+
+ // Clear original topic name.
+ t.xoriginal = ""
+
+ return nil
+}
+
+// Create a new group topic
+func initTopicNewGrp(t *Topic, sreg *ClientComMessage, isChan bool) error {
+ timestamp := types.TimeNow()
+ pktsub := sreg.Sub
+
+ t.cat = types.TopicCatGrp
+ t.isChan = isChan
+
+ // Generic topics have parameters stored in the topic object
+ t.owner = types.ParseUserId(sreg.AsUser)
+ authLevel := auth.Level(sreg.AuthLvl)
+
+ t.accessAuth = getDefaultAccess(t.cat, true, isChan)
+ t.accessAnon = getDefaultAccess(t.cat, false, isChan)
+
+ // Owner/creator gets full access to the topic. Owner may change the default modeWant through 'set'.
+ userData := perUserData{
+ modeGiven: types.ModeCFull,
+ modeWant: types.ModeCFull,
+ }
+
+ if pktsub.Set != nil {
+ // User sent initialization parameters
+ if pktsub.Set.Desc != nil {
+ if pktsub.Set.Desc.Trusted != nil && authLevel != auth.LevelRoot {
+ logs.Err.Println("hub: attempt to assign Trusted by non-ROOT", t.name)
+ return types.ErrPermissionDenied
+ }
+
+ if !isNullValue(pktsub.Set.Desc.Public) {
+ t.public = pktsub.Set.Desc.Public
+ }
+ if !isNullValue(pktsub.Set.Desc.Trusted) {
+ t.trusted = pktsub.Set.Desc.Trusted
+ }
+ if !isNullValue(pktsub.Set.Desc.Private) {
+ userData.private = pktsub.Set.Desc.Private
+ }
+
+ // set default access
+ if pktsub.Set.Desc.DefaultAcs != nil {
+ if authMode, anonMode, err := parseTopicAccess(pktsub.Set.Desc.DefaultAcs,
+ t.accessAuth, t.accessAnon); err != nil {
+
+ // Invalid access for one or both. Make it explicitly None
+ if authMode.IsInvalid() {
+ t.accessAuth = types.ModeNone
+ } else {
+ t.accessAuth = authMode
+ }
+ if anonMode.IsInvalid() {
+ t.accessAnon = types.ModeNone
+ } else {
+ t.accessAnon = anonMode
+ }
+ logs.Err.Println("hub: invalid access mode for topic '" + t.name + "': '" + err.Error() + "'")
+ } else if authMode.IsOwner() || anonMode.IsOwner() {
+ logs.Err.Println("hub: OWNER default access in topic", t.name)
+ t.accessAuth, t.accessAnon = authMode & ^types.ModeOwner, anonMode & ^types.ModeOwner
+ } else {
+ t.accessAuth, t.accessAnon = authMode, anonMode
+ }
+ }
+ }
+
+ // Owner/creator may restrict own access to topic
+ if pktsub.Set.Sub != nil && pktsub.Set.Sub.Mode != "" {
+ userData.modeWant = types.ModeCFull
+ if err := userData.modeWant.UnmarshalText([]byte(pktsub.Set.Sub.Mode)); err != nil {
+ logs.Err.Println("hub: invalid access mode", t.xoriginal, pktsub.Set.Sub.Mode)
+ }
+ // User must not unset ModeJoin or the owner flags
+ userData.modeWant |= types.ModeJoin | types.ModeOwner
+ }
+
+ if tags := normalizeTags(pktsub.Set.Tags, globals.maxTagCount); len(tags) > 0 {
+ if !restrictedTagsEqual(tags, nil, globals.immutableTagNS) {
+ return types.ErrPermissionDenied
+ }
+ // Assign tags
+ t.tags = tags
+ }
+ }
+
+ t.perUser[t.owner] = userData
+
+ t.created = timestamp
+ t.updated = timestamp
+ t.touched = timestamp
+
+ // t.lastId & t.delId are not set for new topics
+
+ stopic := &types.Topic{
+ ObjHeader: types.ObjHeader{Id: sreg.RcptTo, CreatedAt: timestamp},
+ TenantID: t.tenantID,
+ Access: types.DefaultAccess{Auth: t.accessAuth, Anon: t.accessAnon},
+ Tags: t.tags,
+ UseBt: isChan,
+ Public: t.public,
+ Trusted: t.trusted,
+ }
+
+ // store.Topics.Create will add a subscription record for the topic creator
+ stopic.GiveAccess(t.owner, userData.modeWant, userData.modeGiven)
+ err := store.Topics.Create(t.tenantID, stopic, t.owner, t.perUser[t.owner].private)
+ if err != nil {
+ return err
+ }
+
+ // Link uploaded avatar to topic.
+ if sreg.Extra != nil && len(sreg.Extra.Attachments) > 0 {
+ if err := store.Files.LinkAttachments(t.tenantID, t.name, types.ZeroUid, sreg.Extra.Attachments); err != nil {
+ logs.Warn.Printf("topic[%s] failed to link avatar attachment: %v", t.name, err)
+ // This is not a critical error, continue execution.
+ }
+ }
+
+ t.xoriginal = t.name // keeping 'new' or 'nch' as original has no value to the client
+ t.subCnt = 1 // One subscription, the owner.
+
+ pktsub.Created = true
+ pktsub.Newsub = true
+
+ return nil
+}
+
+// Initialize existing group topic. There is a race condition when two users attempt to load
+// the same topic at the same time. It's prevented at hub level.
+func initTopicGrp(t *Topic) error {
+ t.cat = types.TopicCatGrp
+
+ // TODO(gene): check and validate topic name
+ stopic, err := store.Topics.Get(t.tenantID, t.name)
+ if err != nil {
+ return err
+ } else if stopic == nil {
+ return types.ErrTopicNotFound
+ }
+
+ if err = t.loadSubscribers(); err != nil {
+ return err
+ }
+
+ t.isChan = stopic.UseBt
+
+ // t.owner is set by loadSubscriptions
+
+ t.accessAuth = stopic.Access.Auth
+ t.accessAnon = stopic.Access.Anon
+
+ // Assign tags & auxiliary data.
+ t.tags = stopic.Tags
+ t.aux = stopic.Aux
+
+ t.public = stopic.Public
+ t.trusted = stopic.Trusted
+
+ t.created = stopic.CreatedAt
+ t.updated = stopic.UpdatedAt
+ if !stopic.TouchedAt.IsZero() {
+ t.touched = stopic.TouchedAt
+ }
+ t.lastID = stopic.SeqId
+ t.delID = stopic.DelId
+ t.subCnt = stopic.SubCnt
+
+ // Initialize channel for receiving session online updates.
+ t.supd = make(chan *sessionUpdate, 32)
+
+ t.xoriginal = t.name // topic may have been loaded by a channel reader; make sure it's grpXXX, not chnXXX.
+
+ return nil
+}
+
+// Initialize system topic. System topic is a singleton, always in memory.
+func initTopicSys(t *Topic) error {
+ t.cat = types.TopicCatSys
+
+ stopic, err := store.Topics.Get(t.tenantID, t.name)
+ if err != nil {
+ return err
+ } else if stopic == nil {
+ return types.ErrTopicNotFound
+ }
+
+ if err = t.loadSubscribers(); err != nil {
+ return err
+ }
+
+ // There is no t.owner
+
+ // Default permissions are 'W'
+ t.accessAuth = types.ModeWrite
+ t.accessAnon = types.ModeWrite
+
+ t.public = stopic.Public
+ t.trusted = stopic.Trusted
+
+ t.created = stopic.CreatedAt
+ t.updated = stopic.UpdatedAt
+ if !stopic.TouchedAt.IsZero() {
+ t.touched = stopic.TouchedAt
+ }
+ t.lastID = stopic.SeqId
+
+ return nil
+}
+
+// Initialize or load a self-topic 'slf'.
+func initTopicSlf(t *Topic, sreg *ClientComMessage) error {
+ t.cat = types.TopicCatSlf
+
+ stopic, err := store.Topics.Get(t.tenantID, t.name)
+ if err != nil {
+ return err
+ }
+
+ // If topic exists, load subscriptions
+ if stopic != nil {
+ if err = t.loadSubscribers(); err != nil {
+ return err
+ }
+
+ // t.owner is set by loadSubscriptions
+
+ // Topic exists but subscription is missing. Fail.
+ if len(t.perUser) == 0 {
+ logs.Err.Println("hub: missing subscription for '" + t.name + "' (SHOULD NEVER HAPPEN!)")
+ return types.ErrInternal
+ }
+
+ t.created = stopic.CreatedAt
+ t.updated = stopic.UpdatedAt
+ if !stopic.TouchedAt.IsZero() {
+ t.touched = stopic.TouchedAt
+ }
+ t.aux = stopic.Aux
+ t.lastID = stopic.SeqId
+ t.delID = stopic.DelId
+
+ } else {
+ // Get topic owner.
+ userID := types.ParseUserId(sreg.AsUser)
+ user, err := store.Users.Get(t.tenantID, userID)
+ if err != nil {
+ return err
+ }
+ if user == nil {
+ // User not found. Really should not happen.
+ return types.ErrUserNotFound
+ }
+
+ t.owner = userID
+
+ t.accessAuth = getDefaultAccess(t.cat, true, false)
+ t.accessAnon = getDefaultAccess(t.cat, false, false)
+
+ // Default access for the self-owner.
+ userData := perUserData{
+ modeGiven: t.accessAuth,
+ modeWant: t.accessAuth,
+ }
+
+ // Mark the topic as new.
+ sreg.Sub.Created = true
+
+ if sreg.Sub.Set != nil {
+ // User sets non-default Private
+ if sreg.Sub.Set.Desc != nil {
+ if !isNullValue(sreg.Sub.Set.Desc.Private) {
+ userData.private = sreg.Sub.Set.Desc.Private
+ }
+ // Public, trusted are ignored.
+ }
+
+ if tags := normalizeTags(sreg.Sub.Set.Tags, globals.maxTagCount); len(tags) > 0 {
+ if !restrictedTagsEqual(tags, nil, globals.immutableTagNS) {
+ return types.ErrPermissionDenied
+ }
+
+ // Assign tags
+ t.tags = tags
+ }
+ }
+
+ // Mark this subscription as new
+ sreg.Sub.Newsub = true
+
+ t.perUser[t.owner] = userData
+
+ timestamp := types.TimeNow()
+
+ t.created = timestamp
+ t.updated = timestamp
+ t.touched = timestamp
+
+ stopic = &types.Topic{
+ ObjHeader: types.ObjHeader{Id: sreg.RcptTo, CreatedAt: timestamp},
+ TenantID: t.tenantID,
+ Access: types.DefaultAccess{Auth: t.accessAuth, Anon: t.accessAnon},
+ Tags: t.tags,
+ }
+
+ // store.Topics.Create will add a subscription record for the topic creator
+ stopic.GiveAccess(t.owner, userData.modeWant, userData.modeGiven)
+ err = store.Topics.Create(t.tenantID, stopic, t.owner, t.perUser[t.owner].private)
+ if err != nil {
+ return err
+ }
+
+ sreg.Sub.Created = true
+ sreg.Sub.Newsub = true
+ }
+
+ return nil
+}
+
+// loadSubscribers loads topic subscribers, sets topic owner.
+func (t *Topic) loadSubscribers() error {
+ subs, err := store.Topics.GetSubs(t.tenantID, t.name, nil)
+ if err != nil {
+ return err
+ }
+
+ if subs == nil {
+ return nil
+ }
+
+ for i := range subs {
+ sub := &subs[i]
+ uid := types.ParseUid(sub.User)
+ t.perUser[uid] = perUserData{
+ delID: sub.DelId,
+ readID: sub.ReadSeqId,
+ recvID: sub.RecvSeqId,
+ private: sub.Private,
+ modeWant: sub.ModeWant,
+ modeGiven: sub.ModeGiven,
+ }
+
+ if (sub.ModeGiven & sub.ModeWant).IsOwner() {
+ t.owner = uid
+ }
+ }
+
+ return nil
+}
diff --git a/server/main.go b/server/main.go
index e69de29..c8d24f5 100644
--- a/server/main.go
+++ b/server/main.go
@@ -0,0 +1,797 @@
+/******************************************************************************
+ *
+ * Description :
+ *
+ * Setup & initialization.
+ *
+ *****************************************************************************/
+
+package main
+
+//go:generate protoc --go_out=../pbx --go_opt=paths=source_relative --go-grpc_out=../pbx --go-grpc_opt=paths=source_relative ../pbx/model.proto
+
+import (
+ "encoding/json"
+ "flag"
+ "net/http"
+ "os"
+ "runtime"
+ "runtime/pprof"
+ "strings"
+ "time"
+
+ gh "github.com/gorilla/handlers"
+
+ // For stripping comments from JSON config
+ jcr "github.com/tinode/jsonco"
+
+ // Authenticators
+ "github.com/tinode/chat/server/auth"
+ _ "github.com/tinode/chat/server/auth/anon"
+ _ "github.com/tinode/chat/server/auth/basic"
+ _ "github.com/tinode/chat/server/auth/code"
+ _ "github.com/tinode/chat/server/auth/rest"
+ _ "github.com/tinode/chat/server/auth/token"
+ "github.com/tinode/chat/server/store/types"
+
+ // Database backends
+ _ "github.com/tinode/chat/server/db/mongodb"
+ _ "github.com/tinode/chat/server/db/mysql"
+ _ "github.com/tinode/chat/server/db/postgres"
+ _ "github.com/tinode/chat/server/db/rethinkdb"
+
+ "github.com/tinode/chat/server/logs"
+
+ // Push notifications
+ "github.com/tinode/chat/server/push"
+ _ "github.com/tinode/chat/server/push/fcm"
+ _ "github.com/tinode/chat/server/push/stdout"
+ _ "github.com/tinode/chat/server/push/tnpg"
+
+ "github.com/tinode/chat/server/store"
+
+ // Credential validators
+ _ "github.com/tinode/chat/server/validate/email"
+ _ "github.com/tinode/chat/server/validate/tel"
+ "google.golang.org/grpc"
+
+ // File upload handlers
+ _ "github.com/tinode/chat/server/media/fs"
+ _ "github.com/tinode/chat/server/media/s3"
+)
+
+const (
+ // currentVersion is the current API/protocol version
+ currentVersion = "0.25"
+ // minSupportedVersion is the minimum supported API version
+ minSupportedVersion = "0.20"
+
+ // idleSessionTimeout defines duration of being idle before terminating a session.
+ idleSessionTimeout = time.Second * 55
+ // idleMasterTopicTimeout defines now long to keep master topic alive after the last session detached.
+ idleMasterTopicTimeout = time.Second * 4
+ // Same as above but shut down the proxy topic sooner. Otherwise master topic would be kept alive for too long.
+ idleProxyTopicTimeout = time.Second * 2
+
+ // defaultMaxMessageSize is the default maximum message size
+ defaultMaxMessageSize = 1 << 19 // 512K
+
+ // defaultMaxSubscriberCount is the default maximum number of group topic subscribers.
+ // Also set in adapter.
+ defaultMaxSubscriberCount = 256
+
+ // defaultMaxTagCount is the default maximum number of indexable tags
+ defaultMaxTagCount = 16
+
+ // minTagLength is the shortest acceptable length of a tag in runes. Shorter tags are discarded.
+ minTagLength = 2
+ // maxTagLength is the maximum length of a tag in runes. Longer tags are trimmed.
+ maxTagLength = 96
+
+ // Delay before updating a User Agent
+ uaTimerDelay = time.Second * 5
+
+ // maxDeleteCount is the maximum allowed number of messages to delete in one call.
+ defaultMaxDeleteCount = 1024
+
+ // Base URL path for serving the streaming API.
+ defaultApiPath = "/"
+
+ // Mount point where static content is served, http://host-name
+ defaultStaticMount = "/"
+
+ // Local path to static content
+ defaultStaticPath = "static"
+
+ // Default country code to fall back to if the "default_country_code" field
+ // isn't specified in the config.
+ defaultCountryCode = "US"
+
+ // Default timeout to drop an unanswered call, seconds.
+ defaultCallEstablishmentTimeout = 30
+)
+
+// Build version number defined by the compiler:
+//
+// -ldflags "-X main.buildstamp=value_to_assign_to_buildstamp"
+//
+// Reported to clients in response to {hi} message.
+// For instance, to define the buildstamp as a timestamp of when the server was built add a
+// flag to compiler command line:
+//
+// -ldflags "-X main.buildstamp=`date -u '+%Y%m%dT%H:%M:%SZ'`"
+//
+// or to set it to git tag:
+//
+// -ldflags "-X main.buildstamp=`git describe --tags`"
+var buildstamp = "undef"
+
+// CredValidator holds additional config params for a credential validator.
+type credValidator struct {
+ // AuthLevel(s) which require this validator.
+ requiredAuthLvl []auth.Level
+ addToTags bool
+}
+
+var globals struct {
+ // Topics cache and processing.
+ hub *Hub
+ // Indicator that shutdown is in progress
+ shuttingDown bool
+ // Sessions cache.
+ sessionStore *SessionStore
+ // Cluster data.
+ cluster *Cluster
+ // gRPC server.
+ grpcServer *grpc.Server
+ // Plugins.
+ plugins []Plugin
+ // Runtime statistics communication channel.
+ statsUpdate chan *varUpdate
+ // Users cache communication channel.
+ usersUpdate chan *UserCacheReq
+
+ // Credential validators.
+ validators map[string]credValidator
+ // Credential validator config to pass to clients.
+ validatorClientConfig map[string][]string
+ // Validators required for each auth level.
+ authValidators map[auth.Level][]string
+
+ // Salt used for signing API key.
+ apiKeySalt []byte
+ // Tag namespaces (prefixes) which are immutable to the client.
+ immutableTagNS map[string]bool
+ // Tag namespaces which are immutable on User and partially mutable on Topic:
+ // user can only mutate tags he owns.
+ maskedTagNS map[string]bool
+ // Na,espace used for unique user and topic aliases.
+ aliasTagNS string
+
+ // Add Strict-Transport-Security to headers, the value signifies age.
+ // Empty string "" turns it off
+ tlsStrictMaxAge string
+ // Listen for connections on this address:port and redirect them to HTTPS port.
+ tlsRedirectHTTP string
+ // Maximum message size allowed from peer.
+ maxMessageSize int64
+ // Maximum number of group topic subscribers.
+ maxSubscriberCount int
+ // Maximum number of indexable tags.
+ maxTagCount int
+ // If true, ordinary users cannot delete their accounts.
+ permanentAccounts bool
+
+ // Maximum allowed upload size.
+ maxFileUploadSize int64
+ // Periodicity of a garbage collector for abandoned media uploads.
+ mediaGcPeriod time.Duration
+
+ // Prioritize X-Forwarded-For header as the source of IP address of the client.
+ useXForwardedFor bool
+
+ // Add X-Frame-Options header to HTTP response.
+ xFrameOptions string
+
+ // Country code to assign to sessions by default.
+ defaultCountryCode string
+
+ // Time before the call is dropped if not answered.
+ callEstablishmentTimeout int
+
+ // ICE servers config (video calling)
+ iceServers []iceServer
+
+ // Websocket per-message compression negotiation is enabled.
+ wsCompression bool
+
+ // URL of the main endpoint.
+ // DEPRECTATED: use file-serving gRPC API instead. This feature will be removed.
+ servingAt string
+
+ // P2P auth access mode. With or without the D permission depending on P2PDeleteAge.
+ typesModeCP2P types.AccessMode
+
+ // Maximum age of messages which can be deleted with 'D' permission.
+ msgDeleteAge time.Duration
+}
+
+// Credential validator config.
+type validatorConfig struct {
+ // TRUE or FALSE to set
+ AddToTags bool `json:"add_to_tags"`
+ // Authentication level which triggers this validator: "auth", "anon"... or ""
+ Required []string `json:"required"`
+ // Validator params passed to validator unchanged.
+ Config json.RawMessage `json:"config"`
+}
+
+// Stale unvalidated user account GC config.
+type accountGcConfig struct {
+ Enabled bool `json:"enabled"`
+ // How often to run GC (seconds).
+ GcPeriod int `json:"gc_period"`
+ // Number of accounts to delete in one pass.
+ GcBlockSize int `json:"gc_block_size"`
+ // Minimum hours since account was last modified.
+ GcMinAccountAge int `json:"gc_min_account_age"`
+}
+
+// Large file handler config.
+type mediaConfig struct {
+ // The name of the handler to use for file uploads.
+ UseHandler string `json:"use_handler"`
+ // Maximum allowed size of an uploaded file
+ MaxFileUploadSize int64 `json:"max_size"`
+ // Garbage collection timeout
+ GcPeriod int `json:"gc_period"`
+ // Number of entries to delete in one pass
+ GcBlockSize int `json:"gc_block_size"`
+ // Individual handler config params to pass to handlers unchanged.
+ Handlers map[string]json.RawMessage `json:"handlers"`
+}
+
+// Contentx of the configuration file
+type configType struct {
+ // HTTP(S) address:port to listen on for websocket and long polling clients. Either a
+ // numeric or a canonical name, e.g. ":80" or ":https". Could include a host name, e.g.
+ // "localhost:80".
+ // Could be blank: if TLS is not configured, will use ":80", otherwise ":443".
+ // Can be overridden from the command line, see option --listen.
+ Listen string `json:"listen"`
+ // Base URL path where the streaming and large file API calls are served, default is '/'.
+ // Can be overridden from the command line, see option --api_path.
+ ApiPath string `json:"api_path"`
+ // Cache-Control value for static content.
+ CacheControl int `json:"cache_control"`
+ // If true, do not attempt to negotiate websocket per message compression (RFC 7692.4).
+ // It should be disabled (set to true) if you are using MSFT IIS as a reverse proxy.
+ WSCompressionDisabled bool `json:"ws_compression_disabled"`
+ // Address:port to listen for gRPC clients. If blank gRPC support will not be initialized.
+ // Could be overridden from the command line with --grpc_listen.
+ GrpcListen string `json:"grpc_listen"`
+ // Enable handling of gRPC keepalives https://github.com/grpc/grpc/blob/master/doc/keepalive.md
+ // This sets server's GRPC_ARG_KEEPALIVE_TIME_MS to 60 seconds instead of the default 2 hours.
+ GrpcKeepalive bool `json:"grpc_keepalive_enabled"`
+ // URL path for mounting the directory with static files (usually TinodeWeb).
+ StaticMount string `json:"static_mount"`
+ // Local path to static files. All files in this path are made accessible by HTTP.
+ StaticData string `json:"static_data"`
+ // Salt used in signing API keys
+ APIKeySalt []byte `json:"api_key_salt"`
+ // Maximum message size allowed from client. Intended to prevent malicious client from sending
+ // very large files inband (does not affect out of band uploads).
+ MaxMessageSize int `json:"max_message_size"`
+ // Maximum number of group topic subscribers.
+ MaxSubscriberCount int `json:"max_subscriber_count"`
+ // Masked tags namespaces: tags immutable on User (mask), mutable on Topic only within the mask.
+ MaskedTagNamespaces []string `json:"masked_tags"`
+ // Tag namespace used for unique user and topic aliases.
+ AliasTagNamespace string `json:"alias_tag"`
+ // Maximum number of indexable tags.
+ MaxTagCount int `json:"max_tag_count"`
+ // If true, ordinary users cannot delete their accounts.
+ PermanentAccounts bool `json:"permanent_accounts"`
+ // URL path for exposing runtime stats. Disabled if the path is blank.
+ ExpvarPath string `json:"expvar"`
+ // URL path for internal server status. Disabled if the path is blank.
+ ServerStatusPath string `json:"server_status"`
+ // Take IP address of the client from HTTP header 'X-Forwarded-For'.
+ // Useful when tinode is behind a proxy. If missing, fallback to default RemoteAddr.
+ UseXForwardedFor bool `json:"use_x_forwarded_for"`
+ // Add X-Frame-Options to HTTP response headers. It should be one of "DENY", "SAMEORIGIN",
+ // "-" (disabled). The default is SAMEORIGIN.
+ XFrameOptions string `json:"x_frame_options"`
+ // 2-letter country code (ISO 3166-1 alpha-2) to assign to sessions by default
+ // when the country isn't specified by the client explicitly and
+ // it's impossible to infer it.
+ DefaultCountryCode string `json:"default_country_code"`
+ // Permit hard-deleting messages in p2p topics for both participants.
+ // If it's set to 'false' then the message is only deleted for the peer who issued the command.
+ // If it's 'true' then the message is deleted completely by either participant.
+ // Changing the value affects the ability to hard-delete (the added or removed the D permission)
+ // only for new topics going forward.
+ P2PDeleteEnabled bool `json:"p2p_delete_enabled"`
+ // The maximum age of a message in seconds when it can be deleted by users with the 'D' permission.
+ // E.g. 600 means messages up to 10 minutes old can be deleted, older than that cannot be deleted.
+ // Missing or 0 means no age limit.
+ // Does not affect topic owners: owners can delete any message.
+ MsgDeleteAge int `json:"msg_delete_age"`
+
+ // Configs for subsystems
+ Cluster json.RawMessage `json:"cluster_config"`
+ Plugin json.RawMessage `json:"plugins"`
+ Store json.RawMessage `json:"store_config"`
+ Push json.RawMessage `json:"push"`
+ TLS json.RawMessage `json:"tls"`
+ Auth map[string]json.RawMessage `json:"auth_config"`
+ Validator map[string]*validatorConfig `json:"acc_validation"`
+ AccountGC *accountGcConfig `json:"acc_gc_config"`
+ Media *mediaConfig `json:"media"`
+ WebRTC json.RawMessage `json:"webrtc"`
+}
+
+func main() {
+ executable, _ := os.Executable()
+
+ logFlags := flag.String("log_flags", "stdFlags",
+ "Comma-separated list of log flags (as defined in https://golang.org/pkg/log/#pkg-constants without the L prefix)")
+ configfile := flag.String("config", "tinode.conf", "Path to config file.")
+ // Path to static content.
+ staticPath := flag.String("static_data", defaultStaticPath, "File path to directory with static files to be served.")
+ listenOn := flag.String("listen", "", "Override address and port to listen on for HTTP(S) clients.")
+ apiPath := flag.String("api_path", "", "Override the base URL path where API is served.")
+ listenGrpc := flag.String("grpc_listen", "", "Override address and port to listen on for gRPC clients.")
+ tlsEnabled := flag.Bool("tls_enabled", false, "Override config value for enabling TLS.")
+ clusterSelf := flag.String("cluster_self", "", "Override the name of the current cluster node.")
+ expvarPath := flag.String("expvar", "", "Override the URL path where runtime stats are exposed. Use '-' to disable.")
+ serverStatusPath := flag.String("server_status", "",
+ "Override the URL path where the server's internal status is displayed. Use '-' to disable.")
+ pprofFile := flag.String("pprof", "", "File name to save profiling info to. Disabled if not set.")
+ pprofUrl := flag.String("pprof_url", "", "Debugging only! URL path for exposing profiling info. Disabled if not set.")
+ flag.Parse()
+
+ logs.Init(os.Stderr, *logFlags)
+
+ curwd, err := os.Getwd()
+ if err != nil {
+ logs.Err.Fatal("Couldn't get current working directory: ", err)
+ }
+
+ logs.Info.Printf("Server v%s:%s:%s; pid %d; %d process(es)",
+ currentVersion, executable, buildstamp,
+ os.Getpid(), runtime.GOMAXPROCS(runtime.NumCPU()))
+
+ *configfile = toAbsolutePath(curwd, *configfile)
+ logs.Info.Printf("Using config from '%s'", *configfile)
+
+ var config configType
+ if file, err := os.Open(*configfile); err != nil {
+ logs.Err.Fatal("Failed to read config file: ", err)
+ } else {
+ jr := jcr.New(file)
+ if err = json.NewDecoder(jr).Decode(&config); err != nil {
+ switch jerr := err.(type) {
+ case *json.UnmarshalTypeError:
+ lnum, cnum, _ := jr.LineAndChar(jerr.Offset)
+ logs.Err.Fatalf("Unmarshall error in config file in %s at %d:%d (offset %d bytes): %s",
+ jerr.Field, lnum, cnum, jerr.Offset, jerr.Error())
+ case *json.SyntaxError:
+ lnum, cnum, _ := jr.LineAndChar(jerr.Offset)
+ logs.Err.Fatalf("Syntax error in config file at %d:%d (offset %d bytes): %s",
+ lnum, cnum, jerr.Offset, jerr.Error())
+ default:
+ logs.Err.Fatal("Failed to parse config file: ", err)
+ }
+ }
+ file.Close()
+ }
+
+ if *listenOn != "" {
+ config.Listen = *listenOn
+ }
+
+ // Set up HTTP server. Must use non-default mux because of expvar.
+ mux := http.NewServeMux()
+
+ // Exposing values for statistics and monitoring.
+ evpath := *expvarPath
+ if evpath == "" {
+ evpath = config.ExpvarPath
+ }
+ statsInit(mux, evpath)
+ statsRegisterInt("Version")
+ decVersion := base10Version(parseVersion(buildstamp))
+ if decVersion <= 0 {
+ decVersion = base10Version(parseVersion(currentVersion))
+ }
+ statsSet("Version", decVersion)
+
+ // Initialize serving debug profiles (optional).
+ servePprof(mux, *pprofUrl)
+
+ // Initialize cluster and receive calculated workerId.
+ // Cluster won't be started here yet.
+ workerId := clusterInit(config.Cluster, clusterSelf)
+
+ if *pprofFile != "" {
+ *pprofFile = toAbsolutePath(curwd, *pprofFile)
+
+ cpuf, err := os.Create(*pprofFile + ".cpu")
+ if err != nil {
+ logs.Err.Fatal("Failed to create CPU pprof file: ", err)
+ }
+ defer cpuf.Close()
+
+ memf, err := os.Create(*pprofFile + ".mem")
+ if err != nil {
+ logs.Err.Fatal("Failed to create Mem pprof file: ", err)
+ }
+ defer memf.Close()
+
+ pprof.StartCPUProfile(cpuf)
+ defer pprof.StopCPUProfile()
+ defer pprof.WriteHeapProfile(memf)
+
+ logs.Info.Printf("Profiling info saved to '%s.(cpu|mem)'", *pprofFile)
+ }
+
+ err = store.Store.Open(workerId, config.Store)
+ logs.Info.Println("DB adapter", store.Store.GetAdapterName(), store.Store.GetAdapterVersion())
+ if err != nil {
+ logs.Err.Fatal("Failed to connect to DB: ", err)
+ }
+ defer func() {
+ store.Store.Close()
+ logs.Info.Println("Closed database connection(s)")
+ logs.Info.Println("All done, good bye")
+ }()
+ statsRegisterDbStats()
+
+ // API key signing secret
+ globals.apiKeySalt = config.APIKeySalt
+
+ err = store.InitAuthLogicalNames(config.Auth["logical_names"])
+ if err != nil {
+ logs.Err.Fatal(err)
+ }
+
+ // List of tag namespaces for user discovery which cannot be changed directly
+ // by the client, e.g. 'email' or 'tel'.
+ globals.immutableTagNS = make(map[string]bool)
+
+ authNames := store.Store.GetAuthNames()
+ for _, name := range authNames {
+ if authhdl := store.Store.GetLogicalAuthHandler(name); authhdl == nil {
+ logs.Err.Fatalln("Unknown authenticator", name)
+ } else if jsconf := config.Auth[authhdl.GetRealName()]; jsconf != nil {
+ if err := authhdl.Init(jsconf, name); err != nil {
+ logs.Err.Fatalln("Failed to init auth scheme", name+":", err)
+ }
+ tags, err := authhdl.RestrictedTags()
+ if err != nil {
+ logs.Err.Fatalln("Failed get restricted tag namespaces (prefixes)", name+":", err)
+ }
+ for _, tag := range tags {
+ if strings.Contains(tag, ":") {
+ logs.Err.Fatalln("tags restricted by auth handler should not contain character ':'", tag)
+ }
+ globals.immutableTagNS[tag] = true
+ }
+ }
+ }
+
+ // Process validators.
+ for name, vconf := range config.Validator {
+ // Check if validator is restrictive. If so, add validator name to the list of restricted tags.
+ // The namespace can be restricted even if the validator is disabled.
+ if vconf.AddToTags {
+ if strings.Contains(name, ":") {
+ logs.Err.Fatalln("acc_validation names should not contain character ':'", name)
+ }
+ globals.immutableTagNS[name] = true
+ }
+
+ if len(vconf.Required) == 0 {
+ // Skip disabled validator.
+ continue
+ }
+
+ var reqLevels []auth.Level
+ for _, req := range vconf.Required {
+ lvl := auth.ParseAuthLevel(req)
+ if lvl == auth.LevelNone {
+ logs.Err.Fatalf("Invalid required AuthLevel '%s' in validator '%s'", req, name)
+ }
+ reqLevels = append(reqLevels, lvl)
+ if globals.authValidators == nil {
+ globals.authValidators = make(map[auth.Level][]string)
+ }
+ globals.authValidators[lvl] = append(globals.authValidators[lvl], name)
+ }
+
+ if val := store.Store.GetValidator(name); val == nil {
+ logs.Err.Fatal("Config provided for an unknown validator '" + name + "'")
+ } else if err = val.Init(string(vconf.Config)); err != nil {
+ logs.Err.Fatal("Failed to init validator '"+name+"': ", err)
+ }
+ if globals.validators == nil {
+ globals.validators = make(map[string]credValidator)
+ }
+ globals.validators[name] = credValidator{
+ requiredAuthLvl: reqLevels,
+ addToTags: vconf.AddToTags,
+ }
+ }
+
+ // Create credential validator config for clients.
+ if len(globals.authValidators) > 0 {
+ globals.validatorClientConfig = make(map[string][]string)
+ for key, val := range globals.authValidators {
+ globals.validatorClientConfig[key.String()] = val
+ }
+ }
+
+ // Partially restricted tag namespaces.
+ globals.maskedTagNS = make(map[string]bool, len(config.MaskedTagNamespaces))
+ for _, tag := range config.MaskedTagNamespaces {
+ if strings.Contains(tag, ":") {
+ logs.Err.Fatal("masked_tags namespaces should not contain character ':'", tag)
+ }
+ globals.maskedTagNS[tag] = true
+ }
+
+ // Alias namespace.
+ config.AliasTagNamespace = strings.TrimSpace(config.AliasTagNamespace)
+ if config.AliasTagNamespace != "" {
+ if prefix, _ := validateTag(config.AliasTagNamespace + ":testing"); prefix == "" {
+ logs.Err.Fatal("alias_tag namespace should contain only alphanumeric characters and '_'",
+ config.AliasTagNamespace)
+ }
+ globals.aliasTagNS = config.AliasTagNamespace
+ }
+
+ var tags []string
+ for tag := range globals.immutableTagNS {
+ tags = append(tags, "'"+tag+"'")
+ }
+ if len(tags) > 0 {
+ logs.Info.Println("Restricted tags:", tags)
+ }
+ tags = nil
+ for tag := range globals.maskedTagNS {
+ tags = append(tags, "'"+tag+"'")
+ }
+ if len(tags) > 0 {
+ logs.Info.Println("Masked tags:", tags)
+ }
+ if len(globals.aliasTagNS) > 0 {
+ logs.Info.Println("Alias tag:", globals.aliasTagNS)
+ }
+
+ // Maximum message size
+ globals.maxMessageSize = int64(config.MaxMessageSize)
+ if globals.maxMessageSize <= 0 {
+ globals.maxMessageSize = defaultMaxMessageSize
+ }
+ // Maximum number of group topic subscribers
+ globals.maxSubscriberCount = config.MaxSubscriberCount
+ if globals.maxSubscriberCount <= 1 {
+ globals.maxSubscriberCount = defaultMaxSubscriberCount
+ }
+ // Maximum number of indexable tags per user or topics
+ globals.maxTagCount = config.MaxTagCount
+ if globals.maxTagCount <= 0 {
+ globals.maxTagCount = defaultMaxTagCount
+ }
+ // If account deletion is disabled.
+ globals.permanentAccounts = config.PermanentAccounts
+
+ globals.useXForwardedFor = config.UseXForwardedFor
+ globals.defaultCountryCode = config.DefaultCountryCode
+ if globals.defaultCountryCode == "" {
+ globals.defaultCountryCode = defaultCountryCode
+ }
+
+ // Default access mode for P2P: with/without the D permission.
+ globals.typesModeCP2P = types.ModeCP2P
+ if config.P2PDeleteEnabled {
+ globals.typesModeCP2P = types.ModeCP2PD
+ }
+
+ if config.MsgDeleteAge > 0 {
+ globals.msgDeleteAge = time.Duration(config.MsgDeleteAge) * time.Second
+ }
+
+ // Configuration of X-Frame-Options header.
+ globals.xFrameOptions = config.XFrameOptions
+ if globals.xFrameOptions == "" {
+ globals.xFrameOptions = "SAMEORIGIN"
+ }
+ if globals.xFrameOptions != "SAMEORIGIN" && globals.xFrameOptions != "DENY" && globals.xFrameOptions != "-" {
+ logs.Warn.Println("Ignored invalid x_frame_options", config.XFrameOptions)
+ globals.xFrameOptions = "SAMEORIGIN"
+ }
+
+ // Websocket compression.
+ globals.wsCompression = !config.WSCompressionDisabled
+
+ if config.Media != nil {
+ if config.Media.UseHandler == "" {
+ config.Media = nil
+ } else {
+ globals.maxFileUploadSize = config.Media.MaxFileUploadSize
+ if config.Media.Handlers != nil {
+ var conf string
+ if params := config.Media.Handlers[config.Media.UseHandler]; params != nil {
+ conf = string(params)
+ }
+ if err = store.Store.UseMediaHandler(config.Media.UseHandler, conf); err != nil {
+ logs.Err.Fatalf("Failed to init media handler '%s': %s", config.Media.UseHandler, err)
+ }
+ }
+ if config.Media.GcPeriod > 0 && config.Media.GcBlockSize > 0 {
+ globals.mediaGcPeriod = time.Second * time.Duration(config.Media.GcPeriod)
+ stopFilesGc := largeFileRunGarbageCollection(globals.mediaGcPeriod, config.Media.GcBlockSize)
+ defer func() {
+ stopFilesGc <- true
+ logs.Info.Println("Stopped files garbage collector")
+ }()
+ }
+ }
+ }
+
+ // Stale unvalidated user account garbage collection.
+ if config.AccountGC != nil && config.AccountGC.Enabled {
+ if config.AccountGC.GcPeriod <= 0 || config.AccountGC.GcBlockSize <= 0 ||
+ config.AccountGC.GcMinAccountAge <= 0 {
+ logs.Err.Fatalln("Invalid account GC config")
+ }
+ gcPeriod := time.Second * time.Duration(config.AccountGC.GcPeriod)
+ stopAccountGc := garbageCollectUsers(gcPeriod, config.AccountGC.GcBlockSize, config.AccountGC.GcMinAccountAge)
+
+ defer func() {
+ stopAccountGc <- true
+ logs.Info.Println("Stopped account garbage collector")
+ }()
+ }
+
+ pushHandlers, err := push.Init(config.Push)
+ if err != nil {
+ logs.Err.Fatal("Failed to initialize push notifications:", err)
+ }
+ defer func() {
+ push.Stop()
+ logs.Info.Println("Stopped push notifications")
+ }()
+ logs.Info.Println("Push handlers configured:", pushHandlers)
+
+ if err = initVideoCalls(config.WebRTC); err != nil {
+ logs.Err.Fatal("Failed to init video calls: %w", err)
+ }
+
+ // Keep inactive LP sessions for 15 seconds
+ globals.sessionStore = NewSessionStore(idleSessionTimeout + 15*time.Second)
+ // The hub (the main message router)
+ globals.hub = newHub()
+
+ // Start accepting cluster traffic.
+ if globals.cluster != nil {
+ globals.cluster.start()
+ }
+
+ tlsConfig, err := parseTLSConfig(*tlsEnabled, config.TLS)
+ if err != nil {
+ logs.Err.Fatalln(err)
+ }
+
+ // Initialize plugins.
+ pluginsInit(config.Plugin)
+
+ // Initialize users cache
+ usersInit()
+
+ // Set up gRPC server, if one is configured
+ if *listenGrpc == "" {
+ *listenGrpc = config.GrpcListen
+ }
+ if globals.grpcServer, err = serveGrpc(*listenGrpc, config.GrpcKeepalive, tlsConfig); err != nil {
+ logs.Err.Fatal(err)
+ }
+
+ // Serve static content from the directory in -static_data flag if that's
+ // available, otherwise assume '/static'. The content is served at
+ // the path pointed by 'static_mount' in the config. If that is missing then it's
+ // served at root '/'.
+ var staticMountPoint string
+ if *staticPath != "" && *staticPath != "-" {
+ // Resolve path to static content.
+ *staticPath = toAbsolutePath(curwd, *staticPath)
+ if _, err = os.Stat(*staticPath); os.IsNotExist(err) {
+ logs.Err.Fatal("Static content directory is not found", *staticPath)
+ }
+
+ staticMountPoint = config.StaticMount
+ if staticMountPoint == "" {
+ staticMountPoint = defaultStaticMount
+ } else {
+ if !strings.HasPrefix(staticMountPoint, "/") {
+ staticMountPoint = "/" + staticMountPoint
+ }
+ if !strings.HasSuffix(staticMountPoint, "/") {
+ staticMountPoint += "/"
+ }
+ }
+ mux.Handle(staticMountPoint,
+ // Add optional Cache-Control header.
+ cacheControlHandler(config.CacheControl,
+ // Optionally add Strict-Transport-Security and X-Frame-Options to the response.
+ optionalHttpHeaders(
+ // Add gzip compression.
+ gh.CompressHandler(
+ // And add custom formatter of errors.
+ httpErrorHandler(
+ // Remove mount point prefix.
+ http.StripPrefix(staticMountPoint,
+ http.FileServer(http.Dir(*staticPath))))))))
+ logs.Info.Printf("Serving static content from '%s' at '%s'", *staticPath, staticMountPoint)
+ } else {
+ logs.Info.Println("Static content is disabled")
+ }
+
+ // Configure root path for serving API calls.
+ if *apiPath != "" {
+ config.ApiPath = *apiPath
+ }
+ if config.ApiPath == "" {
+ config.ApiPath = defaultApiPath
+ } else {
+ if !strings.HasPrefix(config.ApiPath, "/") {
+ config.ApiPath = "/" + config.ApiPath
+ }
+ if !strings.HasSuffix(config.ApiPath, "/") {
+ config.ApiPath += "/"
+ }
+ }
+ logs.Info.Printf("API served from root URL path '%s'", config.ApiPath)
+
+ // Best guess location of the main endpoint.
+ // TODO: provide fix for the case when the serving is over unix sockets.
+ // TODO: implement serving large files over gRPC, then remove globals.servingAt.
+ globals.servingAt = config.Listen + config.ApiPath
+ if tlsConfig != nil {
+ globals.servingAt = "https://" + globals.servingAt
+ } else {
+ globals.servingAt = "http://" + globals.servingAt
+ }
+
+ sspath := *serverStatusPath
+ if sspath == "" {
+ sspath = config.ServerStatusPath
+ }
+ if sspath != "" && sspath != "-" {
+ logs.Info.Printf("Server status is available at '%s'", sspath)
+ mux.HandleFunc(sspath, serveStatus)
+ }
+
+ // Handle websocket clients.
+ mux.HandleFunc(config.ApiPath+"v0/channels", serveWebSocket)
+ // Handle long polling clients. Enable compression.
+ mux.Handle(config.ApiPath+"v0/channels/lp", gh.CompressHandler(http.HandlerFunc(serveLongPoll)))
+ if config.Media != nil {
+ // Handle uploads of large files.
+ mux.Handle(config.ApiPath+"v0/file/u/", gh.CompressHandler(http.HandlerFunc(largeFileReceiveHTTP)))
+ // Serve large files.
+ mux.Handle(config.ApiPath+"v0/file/s/", gh.CompressHandler(http.HandlerFunc(largeFileServeHTTP)))
+ logs.Info.Println("Large media handling enabled", config.Media.UseHandler)
+ }
+
+ if staticMountPoint != "/" {
+ // Serve json-formatted 404 for all other URLs
+ mux.HandleFunc("/", serve404)
+ }
+
+ if err = listenAndServe(config.Listen, mux, tlsConfig, signalHandler()); err != nil {
+ logs.Err.Fatal(err)
+ }
+}
diff --git a/server/media/fs/filesys.go b/server/media/fs/filesys.go
index e69de29..144c855 100644
--- a/server/media/fs/filesys.go
+++ b/server/media/fs/filesys.go
@@ -0,0 +1,231 @@
+// Package fs implements github.com/tinode/chat/server/media interface by storing media objects in a single
+// directory in the file system.
+// This module won't perform well with tens of thousand of files because it stores all files in a single directory.
+package fs
+
+import (
+ "encoding/base32"
+ "encoding/json"
+ "errors"
+ "hash/fnv"
+ "io"
+ "mime"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/tinode/chat/server/logs"
+ "github.com/tinode/chat/server/media"
+ "github.com/tinode/chat/server/store"
+ "github.com/tinode/chat/server/store/types"
+)
+
+const (
+ defaultServeURL = "/v0/file/s/"
+ defaultCacheControl = "max-age=86400"
+
+ handlerName = "fs"
+)
+
+type fileConfig struct {
+ // FileUploadDirectory: In case of a cluster fileUploadLocation must be accessible to all cluster members.
+ FileUploadDirectory string `json:"upload_dir"`
+ ServeURL string `json:"serve_url"`
+ CorsOrigins []string `json:"cors_origins"`
+ CacheControl string `json:"cache_control"`
+}
+
+type fshandler struct {
+ fileConfig
+ // corsOrigins parsed allowed origins.
+ corsOrigins []media.AllowedOrigin
+}
+
+func (fh *fshandler) Init(jsconf string) error {
+ var err error
+
+ if err = json.Unmarshal([]byte(jsconf), &fh.fileConfig); err != nil {
+ return errors.New("failed to parse config: " + err.Error())
+ }
+
+ if fh.FileUploadDirectory == "" {
+ return errors.New("missing upload location")
+ }
+
+ if fh.ServeURL == "" {
+ fh.ServeURL = defaultServeURL
+ }
+
+ if fh.CacheControl == "" {
+ fh.CacheControl = defaultCacheControl
+ }
+
+ fh.corsOrigins, err = media.ParseCORSAllow(fh.CorsOrigins)
+ if err != nil {
+ return errors.New("failed to parse CORS allowed origins: " + err.Error())
+ }
+ // Make sure the upload directory exists.
+ return os.MkdirAll(fh.FileUploadDirectory, 0777)
+}
+
+// Headers is used for cache management and serving CORS headers.
+func (fh *fshandler) Headers(tenantID types.TenantID, method string, url *url.URL, headers http.Header, serve bool) (http.Header, int, error) {
+ if method == http.MethodGet {
+
+ fid := fh.GetIdFromUrl(url.String())
+ if fid.IsZero() {
+ return nil, 0, types.ErrNotFound
+ }
+
+ fdef, err := fh.getFileRecord(tenantID, fid)
+ if err != nil {
+ return nil, 0, err
+ }
+
+ if etag := strings.Trim(headers.Get("If-None-Match"), "\""); etag != "" && etag == fdef.ETag {
+ return http.Header{
+ "Last-Modified": {fdef.UpdatedAt.Format(http.TimeFormat)},
+ "ETag": {`"` + fdef.ETag + `"`},
+ "Cache-Control": {fh.CacheControl},
+ },
+ http.StatusNotModified, nil
+ }
+
+ return http.Header{
+ "Content-Type": {fdef.MimeType},
+ "Cache-Control": {fh.CacheControl},
+ "ETag": {`"` + fdef.ETag + `"`},
+ }, 0, nil
+ }
+
+ if method != http.MethodOptions {
+ // Not an OPTIONS request. No special handling for all other requests.
+ return nil, 0, nil
+ }
+ header, status := media.CORSHandler(method, headers, fh.corsOrigins, serve)
+ return header, status, nil
+}
+
+// Upload processes request for file upload. The file is given as io.Reader.
+func (fh *fshandler) Upload(fdef *types.FileDef, file io.Reader) (string, int64, error) {
+ location, err := tenantFileLocation(fh.FileUploadDirectory, fdef.TenantID, fdef.Uid().String32(), fdef.CreatedAt)
+ if err != nil {
+ return "", 0, err
+ }
+ if err = os.MkdirAll(filepath.Dir(location), 0777); err != nil {
+ return "", 0, err
+ }
+
+ outfile, err := os.Create(location)
+ if err != nil {
+ logs.Warn.Println("Upload: failed to create file", fdef.Location, err)
+ return "", 0, err
+ }
+
+ if err = store.Files.StartUpload(fdef.TenantID, fdef); err != nil {
+ outfile.Close()
+ os.Remove(location)
+ logs.Warn.Println("failed to create file record", fdef.Id, err)
+ return "", 0, err
+ }
+
+ size, err := io.Copy(outfile, file)
+ outfile.Close()
+ if err != nil {
+ os.Remove(location)
+ return "", 0, err
+ }
+
+ fname := fdef.Id
+ ext, _ := mime.ExtensionsByType(fdef.MimeType)
+ if len(ext) > 0 {
+ fname += ext[0]
+ }
+
+ fdef.Location = location
+ // Use file path to create ETag. File paths are unique so will be the ETag.
+ fdef.ETag = etagFromPath(fdef.Location)
+
+ return fh.ServeURL + fname, size, nil
+}
+
+// Download processes request for file download.
+// The returned ReadSeekCloser must be closed after use.
+func (fh *fshandler) Download(tenantID types.TenantID, url string) (*types.FileDef, media.ReadSeekCloser, error) {
+ fid := fh.GetIdFromUrl(url)
+ if fid.IsZero() {
+ return nil, nil, types.ErrNotFound
+ }
+
+ fd, err := fh.getFileRecord(tenantID, fid)
+ if err != nil {
+ logs.Warn.Println("Download: file not found", fid)
+ return nil, nil, err
+ }
+
+ file, err := os.Open(fd.Location)
+ if err != nil {
+ if os.IsNotExist(err) {
+ // If the file is not found, send 404 instead of the default 500
+ err = types.ErrNotFound
+ }
+ return nil, nil, err
+ }
+
+ return fd, file, nil
+}
+
+// Delete deletes files from storage by provided slice of locations.
+func (fh *fshandler) Delete(locations []string) error {
+ for _, loc := range locations {
+ if err, _ := os.Remove(loc).(*os.PathError); err != nil {
+ if err != os.ErrNotExist {
+ logs.Warn.Println("fs: error deleting file", loc, err)
+ }
+ }
+ }
+ return nil
+}
+
+// GetIdFromUrl converts an attahment URL to a file UID.
+func (fh *fshandler) GetIdFromUrl(url string) types.Uid {
+ return media.GetIdFromUrl(url, fh.ServeURL)
+}
+
+// getFileRecord given file ID reads file record from the database.
+func (fh *fshandler) getFileRecord(tenantID types.TenantID, fid types.Uid) (*types.FileDef, error) {
+ fd, err := store.Files.Get(tenantID, fid.String())
+ if err != nil {
+ return nil, err
+ }
+ if fd == nil {
+ return nil, types.ErrNotFound
+ }
+ return fd, nil
+}
+
+func etagFromPath(path string) string {
+ hasher := fnv.New128()
+ hasher.Write([]byte(path))
+ return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).
+ EncodeToString(hasher.Sum(make([]byte, 0, hasher.Size()))))
+}
+
+func tenantFileLocation(root string, tenantID types.TenantID, fileID string, createdAt time.Time) (string, error) {
+ if !tenantID.IsValid() || fileID == "" {
+ return "", types.ErrMalformed
+ }
+ if createdAt.IsZero() {
+ createdAt = time.Now().UTC()
+ }
+ return filepath.Join(root, "tenants", strconv.FormatInt(int64(tenantID), 10),
+ createdAt.Format("2006"), createdAt.Format("01"), fileID), nil
+}
+
+func init() {
+ store.RegisterMediaHandler(handlerName, &fshandler{})
+}
diff --git a/server/media/fs/filesys_tenant_test.go b/server/media/fs/filesys_tenant_test.go
new file mode 100644
index 0000000..b993ede
--- /dev/null
+++ b/server/media/fs/filesys_tenant_test.go
@@ -0,0 +1,26 @@
+package fs
+
+import (
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/tinode/chat/server/store/types"
+)
+
+func TestTenantFileLocation(t *testing.T) {
+ got, err := tenantFileLocation("/tmp/uploads", types.TenantID(42), "abc123", time.Date(2026, 8, 11, 9, 0, 0, 0, time.UTC))
+ if err != nil {
+ t.Fatalf("tenantFileLocation failed: %v", err)
+ }
+ want := filepath.Join("/tmp/uploads", "tenants", "42", "2026", "08", "abc123")
+ if got != want {
+ t.Fatalf("tenantFileLocation = %q, want %q", got, want)
+ }
+}
+
+func TestTenantFileLocationRejectsZeroTenant(t *testing.T) {
+ if _, err := tenantFileLocation("/tmp/uploads", types.ZeroTenantID, "abc123", time.Time{}); err != types.ErrMalformed {
+ t.Fatalf("tenantFileLocation err = %v, want malformed", err)
+ }
+}
diff --git a/server/media/media.go b/server/media/media.go
index e69de29..f4e650a 100644
--- a/server/media/media.go
+++ b/server/media/media.go
@@ -0,0 +1,220 @@
+// Package media defines an interface which must be implemented by media upload/download handlers.
+package media
+
+import (
+ "errors"
+ "io"
+ "net/http"
+ "net/url"
+ "path"
+ "regexp"
+ "strings"
+
+ "slices"
+
+ "github.com/tinode/chat/server/store/types"
+)
+
+// ReadSeekCloser must be implemented by the media being downloaded.
+type ReadSeekCloser interface {
+ io.Reader
+ io.Seeker
+ io.Closer
+}
+
+// Handler is an interface which must be implemented by media handlers (uploaders-downloaders).
+type Handler interface {
+ // Init initializes the media upload handler.
+ Init(jsconf string) error
+
+ // Headers checks if the handler wants to provide additional HTTP headers for the request.
+ // It could be CORS headers, redirect to serve files from another URL, cache-control headers.
+ // It returns headers as a map, HTTP status code to stop processing or 0 to continue, error.
+ Headers(tenantID types.TenantID, method string, url *url.URL, headers http.Header, serve bool) (http.Header, int, error)
+
+ // Upload processes request for file upload. Returns file URL, file size, error.
+ Upload(fdef *types.FileDef, file io.Reader) (string, int64, error)
+
+ // Download processes request for file download.
+ Download(tenantID types.TenantID, url string) (*types.FileDef, ReadSeekCloser, error)
+
+ // Delete deletes file from storage.
+ Delete(locations []string) error
+
+ // GetIdFromUrl extracts file ID from download URL.
+ GetIdFromUrl(url string) types.Uid
+}
+
+type AllowedOrigin struct {
+ Origin string
+ URL url.URL
+ HostParts []string
+ HasWildcard bool
+}
+
+var fileNamePattern = regexp.MustCompile(`^[-_A-Za-z0-9]+`)
+
+// GetIdFromUrl is a helper method for extracting file ID from a URL.
+func GetIdFromUrl(url, serveUrl string) types.Uid {
+ dir, fname := path.Split(path.Clean(url))
+
+ if dir != "" && dir != serveUrl {
+ return types.ZeroUid
+ }
+
+ return types.ParseUid(fileNamePattern.FindString(fname))
+}
+
+// ParseCORSAllow pre-parses allowed origins from the configuration.
+func ParseCORSAllow(allowed []string) ([]AllowedOrigin, error) {
+ if len(allowed) == 0 {
+ return nil, nil
+ }
+
+ result := make([]AllowedOrigin, 0, len(allowed))
+ for _, val := range allowed {
+ parsed := AllowedOrigin{Origin: val}
+ switch val {
+ case "*":
+ if len(allowed) > 1 {
+ return nil, errors.New("wildcard origin '*' must be the only entry")
+ }
+ parsed.HasWildcard = true
+ case "":
+ if len(allowed) > 1 {
+ return nil, errors.New("empty allowed origin '' must be the only entry")
+ }
+ // Empty string means no origin allowed - no URL parsing needed
+ parsed.HasWildcard = false
+ default:
+ u, err := url.ParseRequestURI(val)
+ if err != nil {
+ return nil, err
+ }
+ parsed.HostParts = strings.Split(u.Hostname(), ".")
+ parsed.URL = *u
+ parsed.HasWildcard = strings.Contains(u.Hostname(), "*")
+ }
+ result = append(result, parsed)
+ }
+ return result, nil
+}
+
+// matchCORSOrigin compares origin from the HTTP request to a list of allowed origins.
+func matchCORSOrigin(allowed []AllowedOrigin, origin string) string {
+ if len(allowed) == 0 {
+ // Not configured
+ return ""
+ }
+
+ if origin == "" && allowed[0].Origin != "*" {
+ // Request has no Origin header and "*" (any origin) not allowed.
+ return ""
+ }
+
+ if allowed[0].Origin == "*" {
+ if origin == "" {
+ return "*"
+ }
+ return origin
+ }
+
+ // Check for empty string in allowed origins - this means no origin is allowed.
+ if allowed[0].Origin == "" {
+ return ""
+ }
+
+ originUrl, err := url.ParseRequestURI(origin)
+ if err != nil {
+ return ""
+ }
+ originParts := strings.Split(originUrl.Hostname(), ".")
+
+ for _, val := range allowed {
+ if val.Origin == origin {
+ return origin
+ }
+
+ if !val.HasWildcard ||
+ originUrl.Scheme != val.URL.Scheme ||
+ originUrl.Port() != val.URL.Port() ||
+ len(originParts) != len(val.HostParts) {
+ continue
+ }
+
+ matched := true
+ for i, part := range val.HostParts {
+ if part == "*" {
+ continue
+ }
+ if part != originParts[i] {
+ matched = false
+ break
+ }
+ }
+ if matched {
+ return origin
+ }
+ }
+
+ return ""
+}
+
+// allowMethods must be in UPPERCASE.
+func matchCORSMethod(allowMethods []string, method string) bool {
+ if method == "" {
+ // Request has no Method header.
+ return false
+ }
+
+ return slices.Contains(allowMethods, strings.ToUpper(method))
+}
+
+// CORSHandler is the default CORS processor for use by media handlers. It adds CORS headers to
+// preflight OPTIONS requests, Vary & Access-Control-Allow-Origin headers to all responses.
+func CORSHandler(method string, reqHeader http.Header, allowedOrigins []AllowedOrigin, serve bool) (http.Header, int) {
+ respHeader := map[string][]string{
+ // Always add Vary because of possible intermediate caches.
+ "Vary": {"Origin", "Access-Control-Request-Method, Access-Control-Request-Headers"},
+ }
+
+ origin := reqHeader.Get("Origin")
+
+ allowedOrigin := matchCORSOrigin(allowedOrigins, origin)
+ if acMethod := reqHeader.Get("Access-Control-Request-Method"); method == http.MethodOptions && acMethod != "" {
+ // Preflight request.
+
+ if allowedOrigin == "" {
+ return respHeader, http.StatusNoContent
+ }
+
+ var allowMethods []string
+ if serve {
+ allowMethods = []string{http.MethodGet, http.MethodHead, http.MethodOptions}
+ } else {
+ allowMethods = []string{http.MethodPost, http.MethodPut, http.MethodHead, http.MethodOptions}
+ }
+
+ if !matchCORSMethod(allowMethods, acMethod) {
+ // CORS policy does not allow this method.
+ return respHeader, http.StatusNoContent
+ }
+
+ respHeader["Access-Control-Allow-Headers"] = []string{"*"}
+ respHeader["Access-Control-Allow-Credentials"] = []string{"true"}
+ respHeader["Access-Control-Allow-Methods"] = []string{strings.Join(allowMethods, ", ")}
+ respHeader["Access-Control-Max-Age"] = []string{"86400"}
+ respHeader["Access-Control-Allow-Origin"] = []string{allowedOrigin}
+
+ return respHeader, http.StatusNoContent
+ }
+
+ // Regular request, not a preflight.
+
+ if allowedOrigin != "" {
+ // Returning Origin from the actual request instead of '*', otherwise there could be an issue with Credentials.
+ respHeader["Access-Control-Allow-Origin"] = []string{origin}
+ }
+
+ return respHeader, 0
+}
diff --git a/server/media/media_test.go b/server/media/media_test.go
index e69de29..a5f85f4 100644
--- a/server/media/media_test.go
+++ b/server/media/media_test.go
@@ -0,0 +1,221 @@
+package media
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestMatchCORSOrigin(t *testing.T) {
+ cases := []struct {
+ allowed []string
+ origin string
+ expected string
+ expectError bool
+ errorMessage string
+ }{
+ {
+ allowed: []string{"https://example.com"},
+ origin: "https://example.com",
+ expected: "https://example.com",
+ },
+ {
+ allowed: []string{"https://example2.com", "https://example.com"},
+ origin: "https://example.com",
+ expected: "https://example.com",
+ },
+ {
+ allowed: []string{"*"},
+ origin: "https://example.com",
+ expected: "https://example.com",
+ },
+ {
+ allowed: []string{""},
+ origin: "https://example.com",
+ expected: "",
+ },
+ {
+ allowed: []string{},
+ origin: "",
+ expected: "",
+ },
+ {
+ allowed: []string{"https://example.com"},
+ origin: "",
+ expected: "",
+ },
+ {
+ allowed: []string{},
+ origin: "https://example.com",
+ expected: "",
+ },
+ {
+ allowed: []string{"http://example.com"},
+ origin: "https://example.com",
+ expected: "",
+ },
+ {
+ allowed: []string{"https://example.com"},
+ origin: "http://example.com",
+ expected: "",
+ },
+ {
+ allowed: []string{"http://example.com:8000"},
+ origin: "http://example.com:8000",
+ expected: "http://example.com:8000",
+ },
+ {
+ allowed: []string{"http://localhost:8090"},
+ origin: "http://localhost:8090",
+ expected: "http://localhost:8090",
+ },
+ {
+ allowed: []string{"http://localhost:8090"},
+ origin: "http://localhost:8080",
+ expected: "",
+ },
+ {
+ allowed: []string{"https://example.com"},
+ origin: "https://sub.example.com",
+ expected: "",
+ },
+ {
+ allowed: []string{"https://*.example.com"},
+ origin: "https://sub.example.com",
+ expected: "https://sub.example.com",
+ },
+ {
+ allowed: []string{"https://*.example.com"},
+ origin: "https://pre.sub.example.com",
+ expected: "",
+ },
+ {
+ allowed: []string{"https://*.example.com", "https://*.sub.example.com"},
+ origin: "https://pre.sub.example.com",
+ expected: "https://pre.sub.example.com",
+ },
+ {
+ allowed: []string{"https://*.*.example.com"},
+ origin: "https://pre.sub.example.com",
+ expected: "https://pre.sub.example.com",
+ },
+ {
+ allowed: []string{"https://*.sub.example.com"},
+ origin: "https://pre.asd.example.com",
+ expected: "",
+ },
+ {
+ allowed: []string{"https://pre.*.example.com"},
+ origin: "https://pre.sub.example.com",
+ expected: "https://pre.sub.example.com",
+ },
+ {
+ allowed: []string{"https://*.*.*.example.com"},
+ origin: "https://www.pre.sub.example.com",
+ expected: "https://www.pre.sub.example.com",
+ },
+ {
+ allowed: []string{"*"},
+ origin: "",
+ expected: "*", // Should allow any origin, including empty
+ },
+ // Error cases - these should fail during ParseCORSAllow
+ {
+ allowed: []string{"*", "https://example.com"},
+ origin: "https://example.com",
+ expectError: true,
+ errorMessage: "wildcard origin '*' must be the only entry",
+ },
+ {
+ allowed: []string{"not-a-valid-url"},
+ origin: "https://example.com",
+ expectError: true,
+ errorMessage: "invalid URI for request",
+ },
+ {
+ allowed: []string{"://invalid-url"},
+ origin: "https://example.com",
+ expectError: true,
+ errorMessage: "missing protocol scheme",
+ },
+ {
+ allowed: []string{"https://", "example.com"},
+ origin: "https://example.com",
+ expectError: true,
+ errorMessage: "invalid URI for request",
+ },
+ // Valid cases that should not error
+ {
+ allowed: []string{"", "https://example.com"},
+ origin: "https://example.com",
+ expectError: true,
+ errorMessage: "empty allowed origin '' must be the only entry", // Empty string should make all origins disallowed
+ },
+ {
+ allowed: []string{"https://Example.com"},
+ origin: "https://example.com",
+ expected: "", // Should not match due to case sensitivity
+ },
+ {
+ allowed: []string{"https://example.com/"},
+ origin: "https://example.com",
+ expected: "", // Should not match due to trailing slash
+ },
+ {
+ allowed: []string{"https://example.com"},
+ origin: "not-a-valid-url",
+ expected: "", // Should handle malformed origin gracefully
+ },
+ {
+ allowed: []string{"https://example.*.com"},
+ origin: "https://example.sub.com",
+ expected: "https://example.sub.com",
+ },
+ {
+ allowed: []string{"https://*.com"},
+ origin: "https://example.com",
+ expected: "https://example.com",
+ },
+ {
+ allowed: []string{"http://*.example.com"},
+ origin: "https://sub.example.com",
+ expected: "", // Should not match due to protocol difference
+ },
+ {
+ allowed: []string{"https://example.com", "https://example.com"},
+ origin: "https://example.com",
+ expected: "https://example.com", // Should still work with duplicates
+ },
+ }
+
+ for i, tc := range cases {
+ allowedOrigins, err := ParseCORSAllow(tc.allowed)
+
+ if tc.expectError {
+ if err == nil {
+ t.Errorf("Test case %d: Expected error but got none. Allowed: %v", i, tc.allowed)
+ continue
+ }
+ if tc.errorMessage != "" && !containsSubstring(err.Error(), tc.errorMessage) {
+ t.Errorf("Test case %d: Expected error containing '%s', got '%s'", i, tc.errorMessage, err.Error())
+ }
+ // For error cases, we don't test the matching logic
+ continue
+ }
+
+ if err != nil {
+ t.Errorf("Test case %d: Unexpected error parsing allowed origins: %v. Allowed: %v", i, err, tc.allowed)
+ continue
+ }
+
+ result := matchCORSOrigin(allowedOrigins, tc.origin)
+ if result != tc.expected {
+ t.Errorf("Test case %d: Match CORS origin got wrong result. Expected '%s', got '%s'. Allowed: %v, Origin: '%s'",
+ i, tc.expected, result, tc.allowed, tc.origin)
+ }
+ }
+}
+
+// Helper function to check if a string contains a substring (case-insensitive)
+func containsSubstring(str, substr string) bool {
+ return strings.Contains(strings.ToLower(str), strings.ToLower(substr))
+}
diff --git a/server/media/s3/s3.go b/server/media/s3/s3.go
index e69de29..2bc84b4 100644
--- a/server/media/s3/s3.go
+++ b/server/media/s3/s3.go
@@ -0,0 +1,384 @@
+// Package s3 implements media interface by storing media objects in Amazon S3 bucket.
+package s3
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "mime"
+ "net/http"
+ "net/url"
+ "path"
+ "strconv"
+ "strings"
+ "sync/atomic"
+ "time"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ "github.com/aws/aws-sdk-go-v2/config"
+ "github.com/aws/aws-sdk-go-v2/credentials"
+ "github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager"
+ "github.com/aws/aws-sdk-go-v2/service/s3"
+ s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
+ "github.com/aws/smithy-go"
+
+ "github.com/tinode/chat/server/logs"
+ "github.com/tinode/chat/server/media"
+ "github.com/tinode/chat/server/store"
+ "github.com/tinode/chat/server/store/types"
+)
+
+const (
+ defaultServeURL = "/v0/file/s/"
+ defaultCacheControl = "no-cache, must-revalidate"
+
+ handlerName = "s3"
+ // Presign GET URLs for this number of seconds.
+ defaultPresignDuration = 120
+)
+
+type awsconfig struct {
+ AccessKeyId string `json:"access_key_id"`
+ SecretAccessKey string `json:"secret_access_key"`
+ Region string `json:"region"`
+ DisableSSL bool `json:"disable_ssl"`
+ ForcePathStyle bool `json:"force_path_style"`
+ Endpoint string `json:"endpoint"`
+ BucketName string `json:"bucket"`
+ CorsOrigins []string `json:"cors_origins"`
+ ServeURL string `json:"serve_url"`
+ PresignTTL int `json:"presign_ttl"`
+ CacheControl string `json:"cache_control"`
+}
+
+type awshandler struct {
+ svc *s3.Client
+ presign *s3.PresignClient
+ conf awsconfig
+ corsOrigins []media.AllowedOrigin
+}
+
+// readerCounter is a byte counter for bytes read through the io.Reader
+type readerCounter struct {
+ io.Reader
+ count int64
+ reader io.Reader
+}
+
+// Read reads the bytes and records the number of read bytes.
+func (rc *readerCounter) Read(buf []byte) (int, error) {
+ n, err := rc.reader.Read(buf)
+ atomic.AddInt64(&rc.count, int64(n))
+ return n, err
+}
+
+// Init initializes the media handler.
+func (ah *awshandler) Init(jsconf string) error {
+ var err error
+ if err = json.Unmarshal([]byte(jsconf), &ah.conf); err != nil {
+ return errors.New("failed to parse config: " + err.Error())
+ }
+
+ if ah.conf.AccessKeyId == "" {
+ return errors.New("missing Access Key ID")
+ }
+ if ah.conf.SecretAccessKey == "" {
+ return errors.New("missing Secret Access Key")
+ }
+ if ah.conf.Region == "" {
+ return errors.New("missing Region")
+ }
+ if ah.conf.BucketName == "" {
+ return errors.New("missing Bucket")
+ }
+ if ah.conf.PresignTTL <= 0 {
+ ah.conf.PresignTTL = defaultPresignDuration
+ }
+ if ah.conf.CacheControl == "" {
+ ah.conf.CacheControl = defaultCacheControl
+ }
+ if ah.conf.ServeURL == "" {
+ ah.conf.ServeURL = defaultServeURL
+ }
+ ah.corsOrigins, err = media.ParseCORSAllow(ah.conf.CorsOrigins)
+ if err != nil {
+ return errors.New("failed to parse CORS allowed origins: " + err.Error())
+ }
+
+ cfgOpts := []func(*config.LoadOptions) error{
+ config.WithRegion(ah.conf.Region),
+ config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
+ ah.conf.AccessKeyId,
+ ah.conf.SecretAccessKey,
+ "",
+ )),
+ }
+
+ var cfg aws.Config
+ if cfg, err = config.LoadDefaultConfig(context.Background(), cfgOpts...); err != nil {
+ return err
+ }
+
+ // Create S3 service client
+ clientOpts := []func(*s3.Options){
+ func(o *s3.Options) {
+ o.UsePathStyle = ah.conf.ForcePathStyle
+ },
+ }
+ if ah.conf.Endpoint != "" {
+ endpoint := ah.conf.Endpoint
+ if !strings.Contains(endpoint, "://") {
+ if ah.conf.DisableSSL {
+ endpoint = "http://" + endpoint
+ } else {
+ endpoint = "https://" + endpoint
+ }
+ }
+ clientOpts = append(clientOpts, func(o *s3.Options) {
+ o.BaseEndpoint = aws.String(endpoint)
+ })
+ }
+ ah.svc = s3.NewFromConfig(cfg, clientOpts...)
+ ah.presign = s3.NewPresignClient(ah.svc)
+
+ // Check if bucket already exists.
+ _, err = ah.svc.HeadBucket(context.Background(), &s3.HeadBucketInput{Bucket: aws.String(ah.conf.BucketName)})
+ if err == nil {
+ // Bucket exists
+ return nil
+ }
+
+ if !isAPIError(err, "NoSuchBucket", "NotFound") {
+ // Hard error.
+ return err
+ }
+
+ // Bucket does not exist. Create one.
+ _, err = ah.svc.CreateBucket(context.Background(), &s3.CreateBucketInput{Bucket: aws.String(ah.conf.BucketName)})
+ if err != nil {
+ if isAPIError(err, "BucketAlreadyExists", "BucketAlreadyOwnedByYou", "OperationAborted") {
+ // Check if someone has already created a bucket (possible in a cluster).
+ err = nil
+ }
+ } else {
+ // This is a new bucket.
+
+ // The following serves two purposes:
+ // 1. Setup CORS policy to be able to serve media directly from S3.
+ // 2. Verify that the bucket is accessible to the current user.
+ origins := ah.conf.CorsOrigins
+ if len(origins) == 0 {
+ origins = append(origins, "*")
+ }
+ _, err = ah.svc.PutBucketCors(context.Background(), &s3.PutBucketCorsInput{
+ Bucket: aws.String(ah.conf.BucketName),
+ CORSConfiguration: &s3types.CORSConfiguration{
+ CORSRules: []s3types.CORSRule{{
+ AllowedMethods: []string{http.MethodGet, http.MethodHead},
+ AllowedOrigins: origins,
+ AllowedHeaders: []string{"*"},
+ }},
+ },
+ })
+ }
+ return err
+}
+
+// Headers adds CORS headers and redirects GET and HEAD requests to the AWS server.
+func (ah *awshandler) Headers(tenantID types.TenantID, method string, url *url.URL, headers http.Header, serve bool) (http.Header, int, error) {
+ // Add CORS headers, if necessary.
+ headers, status := media.CORSHandler(method, headers, ah.corsOrigins, serve)
+ if status != 0 || method == http.MethodPost || method == http.MethodPut {
+ return headers, status, nil
+ }
+
+ fid := ah.GetIdFromUrl(url.String())
+ if fid.IsZero() {
+ return nil, 0, types.ErrNotFound
+ }
+
+ fdef, err := ah.getFileRecord(tenantID, fid)
+ if err != nil {
+ return nil, 0, err
+ }
+
+ if fdef.ETag != "" && headers.Get("If-None-Match") == `"`+fdef.ETag+`"` {
+ return http.Header{
+ "ETag": {`"` + fdef.ETag + `"`},
+ "Cache-Control": {ah.conf.CacheControl},
+ },
+ http.StatusNotModified, nil
+ }
+
+ ctx := context.Background()
+ var redirURL string
+ switch method {
+ case http.MethodGet:
+ // If the query parameter "asatt" is set to a true, set Content-Disposition to attachment.
+ // This will cause browsers to download the file rather than attempt to display it.
+ // This closes an XSS vulnerability when users upload HTML files.
+ var contentDisposition *string
+ if isAttachment, _ := strconv.ParseBool(url.Query().Get("asatt")); isAttachment {
+ contentDisposition = aws.String("attachment")
+ }
+ presigned, err := ah.presign.PresignGetObject(ctx, &s3.GetObjectInput{
+ Bucket: aws.String(ah.conf.BucketName),
+ Key: aws.String(fid.String32()),
+ ResponseCacheControl: aws.String(ah.conf.CacheControl),
+ ResponseContentType: aws.String(fdef.MimeType),
+ ResponseContentDisposition: contentDisposition,
+ }, func(opts *s3.PresignOptions) {
+ opts.Expires = time.Second * time.Duration(ah.conf.PresignTTL)
+ })
+ if err != nil {
+ return nil, 0, err
+ }
+ redirURL = presigned.URL
+ case http.MethodHead:
+ presigned, err := ah.presign.PresignHeadObject(ctx, &s3.HeadObjectInput{
+ Bucket: aws.String(ah.conf.BucketName),
+ Key: aws.String(fid.String32()),
+ }, func(opts *s3.PresignOptions) {
+ opts.Expires = time.Second * time.Duration(ah.conf.PresignTTL)
+ })
+ if err != nil {
+ return nil, 0, err
+ }
+ redirURL = presigned.URL
+ }
+
+ if redirURL != "" {
+ // Return presigned URL with 308 Permanent redirect. Let the client cache the response.
+ // The original URL will stop working after a short period of time to prevent use of Tinode
+ // as a free file server.
+ return http.Header{
+ "Location": {redirURL},
+ "ETag": {`"` + fdef.ETag + `"`},
+ "Content-Type": {"application/json; charset=utf-8"},
+ "Cache-Control": {ah.conf.CacheControl},
+ },
+ http.StatusPermanentRedirect, nil
+ }
+ return nil, 0, nil
+}
+
+// Upload processes request for a file upload. The file is given as io.Reader.
+func (ah *awshandler) Upload(fdef *types.FileDef, file io.Reader) (string, int64, error) {
+ var err error
+
+ key, err := tenantObjectKey(fdef.TenantID, fdef.Uid().String32(), fdef.CreatedAt)
+ if err != nil {
+ return "", 0, err
+ }
+
+ tmClient := transfermanager.New(ah.svc)
+
+ if err = store.Files.StartUpload(fdef.TenantID, fdef); err != nil {
+ logs.Warn.Println("failed to create file record", fdef.Id, err)
+ return "", 0, err
+ }
+
+ rc := readerCounter{reader: file}
+ result, err := tmClient.UploadObject(context.Background(), &transfermanager.UploadObjectInput{
+ CacheControl: aws.String(ah.conf.CacheControl),
+ Bucket: aws.String(ah.conf.BucketName),
+ Key: aws.String(key),
+ Body: &rc,
+ })
+
+ if err != nil {
+ return "", 0, err
+ }
+
+ fname := fdef.Id
+ ext, _ := mime.ExtensionsByType(fdef.MimeType)
+ if len(ext) > 0 {
+ fname += ext[0]
+ }
+
+ fdef.Location = key
+ if result.ETag != nil {
+ fdef.ETag = strings.Trim(*result.ETag, "\"")
+ }
+ return ah.conf.ServeURL + fname, rc.count, nil
+}
+
+// Download processes request for file download.
+// The returned ReadSeekCloser must be closed after use.
+func (ah *awshandler) Download(tenantID types.TenantID, url string) (*types.FileDef, media.ReadSeekCloser, error) {
+ return nil, nil, types.ErrUnsupported
+}
+
+// Delete deletes files from aws by provided slice of locations.
+func (ah *awshandler) Delete(locations []string) error {
+ ctx := context.Background()
+ for i := 0; i < len(locations); i += 1000 {
+ end := i + 1000
+ if end > len(locations) {
+ end = len(locations)
+ }
+
+ objects := make([]s3types.ObjectIdentifier, end-i)
+ for j, key := range locations[i:end] {
+ objects[j] = s3types.ObjectIdentifier{Key: aws.String(key)}
+ }
+
+ _, err := ah.svc.DeleteObjects(ctx, &s3.DeleteObjectsInput{
+ Bucket: aws.String(ah.conf.BucketName),
+ Delete: &s3types.Delete{
+ Objects: objects,
+ Quiet: aws.Bool(true),
+ },
+ })
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func isAPIError(err error, codes ...string) bool {
+ var apiErr smithy.APIError
+ if !errors.As(err, &apiErr) {
+ return false
+ }
+ for _, code := range codes {
+ if apiErr.ErrorCode() == code {
+ return true
+ }
+ }
+ return false
+}
+
+// GetIdFromUrl converts an attahment URL to a file UID.
+func (ah *awshandler) GetIdFromUrl(url string) types.Uid {
+ return media.GetIdFromUrl(url, ah.conf.ServeURL)
+}
+
+// getFileRecord given file ID reads file record from the database.
+func (ah *awshandler) getFileRecord(tenantID types.TenantID, fid types.Uid) (*types.FileDef, error) {
+ fd, err := store.Files.Get(tenantID, fid.String())
+ if err != nil {
+ return nil, err
+ }
+ if fd == nil {
+ return nil, types.ErrNotFound
+ }
+ return fd, nil
+}
+
+func tenantObjectKey(tenantID types.TenantID, fileID string, createdAt time.Time) (string, error) {
+ if !tenantID.IsValid() || fileID == "" {
+ return "", types.ErrMalformed
+ }
+ if createdAt.IsZero() {
+ createdAt = time.Now().UTC()
+ }
+ return path.Join("tenants", strconv.FormatInt(int64(tenantID), 10),
+ createdAt.Format("2006"), createdAt.Format("01"), fileID), nil
+}
+
+func init() {
+ store.RegisterMediaHandler(handlerName, &awshandler{})
+}
diff --git a/server/media/s3/s3_tenant_test.go b/server/media/s3/s3_tenant_test.go
new file mode 100644
index 0000000..902f059
--- /dev/null
+++ b/server/media/s3/s3_tenant_test.go
@@ -0,0 +1,24 @@
+package s3
+
+import (
+ "testing"
+ "time"
+
+ "github.com/tinode/chat/server/store/types"
+)
+
+func TestTenantObjectKey(t *testing.T) {
+ got, err := tenantObjectKey(types.TenantID(42), "abc123", time.Date(2026, 8, 11, 9, 0, 0, 0, time.UTC))
+ if err != nil {
+ t.Fatalf("tenantObjectKey failed: %v", err)
+ }
+ if want := "tenants/42/2026/08/abc123"; got != want {
+ t.Fatalf("tenantObjectKey = %q, want %q", got, want)
+ }
+}
+
+func TestTenantObjectKeyRejectsZeroTenant(t *testing.T) {
+ if _, err := tenantObjectKey(types.ZeroTenantID, "abc123", time.Time{}); err != types.ErrMalformed {
+ t.Fatalf("tenantObjectKey err = %v, want malformed", err)
+ }
+}
diff --git a/server/pbconverter.go b/server/pbconverter.go
index e69de29..958b6c4 100644
--- a/server/pbconverter.go
+++ b/server/pbconverter.go
@@ -0,0 +1,1191 @@
+// Converts between protobuf structs and Go representation of packets
+
+package main
+
+import (
+ "encoding/json"
+ "time"
+
+ "github.com/tinode/chat/pbx"
+ "github.com/tinode/chat/server/logs"
+ "github.com/tinode/chat/server/store/types"
+)
+
+func pbServCtrlSerializeBasic(ctrl *MsgServerCtrl) *pbx.ServerCtrl {
+ var params map[string][]byte
+ if ctrl.Params != nil {
+ if in, ok := ctrl.Params.(map[string]any); ok {
+ params = interfaceMapToByteMap(in)
+ }
+ }
+
+ return &pbx.ServerCtrl{
+ Id: ctrl.Id,
+ Topic: ctrl.Topic,
+ Code: int32(ctrl.Code),
+ Text: ctrl.Text,
+ Params: params,
+ }
+}
+
+func pbServCtrlSerialize(ctrl *MsgServerCtrl) *pbx.ServerMsg_Ctrl {
+ return &pbx.ServerMsg_Ctrl{
+ Ctrl: pbServCtrlSerializeBasic(ctrl),
+ }
+}
+
+func pbServDataSerialize(data *MsgServerData) *pbx.ServerMsg_Data {
+ return &pbx.ServerMsg_Data{
+ Data: &pbx.ServerData{
+ Topic: data.Topic,
+ FromUserId: data.From,
+ Timestamp: timeToInt64(&data.Timestamp),
+ DeletedAt: timeToInt64(data.DeletedAt),
+ SeqId: int32(data.SeqId),
+ Head: interfaceMapToByteMap(data.Head),
+ Content: interfaceToBytes(data.Content),
+ },
+ }
+}
+
+func pbServPresSerialize(pres *MsgServerPres) *pbx.ServerMsg_Pres {
+ var what pbx.ServerPres_What
+ switch pres.What {
+ case "on":
+ what = pbx.ServerPres_ON
+ case "off":
+ what = pbx.ServerPres_OFF
+ case "ua":
+ what = pbx.ServerPres_UA
+ case "upd":
+ what = pbx.ServerPres_UPD
+ case "gone":
+ what = pbx.ServerPres_GONE
+ case "acs":
+ what = pbx.ServerPres_ACS
+ case "term":
+ what = pbx.ServerPres_TERM
+ case "msg":
+ what = pbx.ServerPres_MSG
+ case "read":
+ what = pbx.ServerPres_READ
+ case "recv":
+ what = pbx.ServerPres_RECV
+ case "del":
+ what = pbx.ServerPres_DEL
+ case "tags":
+ what = pbx.ServerPres_TAGS
+ case "aux":
+ what = pbx.ServerPres_AUX
+ default:
+ logs.Info.Println("Unknown pres.what value", pres.What)
+ }
+ return &pbx.ServerMsg_Pres{
+ Pres: &pbx.ServerPres{
+ Topic: pres.Topic,
+ Src: pres.Src,
+ What: what,
+ UserAgent: pres.UserAgent,
+ SeqId: int32(pres.SeqId),
+ DelId: int32(pres.DelId),
+ DelSeq: pbDelQuerySerialize(pres.DelSeq),
+ TargetUserId: pres.AcsTarget,
+ ActorUserId: pres.AcsActor,
+ Acs: pbAccessModeSerialize(pres.Acs),
+ },
+ }
+}
+
+func pbServInfoSerialize(info *MsgServerInfo) *pbx.ServerMsg_Info {
+ return &pbx.ServerMsg_Info{
+ Info: &pbx.ServerInfo{
+ Topic: info.Topic,
+ FromUserId: info.From,
+ Src: info.Src,
+ What: pbInfoNoteWhatSerialize(info.What),
+ SeqId: int32(info.SeqId),
+ Event: pbCallEventSerialize(info.Event),
+ Payload: info.Payload,
+ },
+ }
+}
+
+func pbServMetaSerialize(meta *MsgServerMeta) *pbx.ServerMsg_Meta {
+ return &pbx.ServerMsg_Meta{
+ Meta: &pbx.ServerMeta{
+ Id: meta.Id,
+ Topic: meta.Topic,
+ Desc: pbTopicDescSerialize(meta.Desc),
+ Sub: pbTopicSubSliceSerialize(meta.Sub),
+ Del: pbDelValuesSerialize(meta.Del),
+ Tags: meta.Tags,
+ Cred: pbServerCredsSerialize(meta.Cred),
+ Aux: interfaceMapToByteMap(meta.Aux),
+ },
+ }
+}
+
+// Convert ServerComMessage to pbx.ServerMsg
+func pbServSerialize(msg *ServerComMessage) *pbx.ServerMsg {
+ var pkt pbx.ServerMsg
+
+ switch {
+ case msg.Ctrl != nil:
+ pkt.Message = pbServCtrlSerialize(msg.Ctrl)
+ case msg.Data != nil:
+ pkt.Message = pbServDataSerialize(msg.Data)
+ case msg.Pres != nil:
+ pkt.Message = pbServPresSerialize(msg.Pres)
+ case msg.Info != nil:
+ pkt.Message = pbServInfoSerialize(msg.Info)
+ case msg.Meta != nil:
+ pkt.Message = pbServMetaSerialize(msg.Meta)
+ }
+
+ return &pkt
+}
+
+func pbServDeserialize(pkt *pbx.ServerMsg) *ServerComMessage {
+ var msg ServerComMessage
+ if ctrl := pkt.GetCtrl(); ctrl != nil {
+ msg.Ctrl = &MsgServerCtrl{
+ Id: ctrl.GetId(),
+ Topic: ctrl.GetTopic(),
+ Code: int(ctrl.GetCode()),
+ Text: ctrl.GetText(),
+ Params: byteMapToInterfaceMap(ctrl.GetParams()),
+ }
+ } else if data := pkt.GetData(); data != nil {
+ tsptr := int64ToTime(data.GetTimestamp())
+ if tsptr == nil {
+ tsptr = &time.Time{}
+ }
+ msg.Data = &MsgServerData{
+ Topic: data.GetTopic(),
+ From: data.GetFromUserId(),
+ Timestamp: *tsptr,
+ DeletedAt: int64ToTime(data.GetDeletedAt()),
+ SeqId: int(data.GetSeqId()),
+ Head: byteMapToInterfaceMap(data.GetHead()),
+ Content: data.GetContent(),
+ }
+ } else if pres := pkt.GetPres(); pres != nil {
+ var what string
+ switch pres.GetWhat() {
+ case pbx.ServerPres_ON:
+ what = "on"
+ case pbx.ServerPres_OFF:
+ what = "off"
+ case pbx.ServerPres_UA:
+ what = "ua"
+ case pbx.ServerPres_UPD:
+ what = "upd"
+ case pbx.ServerPres_GONE:
+ what = "gone"
+ case pbx.ServerPres_ACS:
+ what = "acs"
+ case pbx.ServerPres_TERM:
+ what = "term"
+ case pbx.ServerPres_MSG:
+ what = "msg"
+ case pbx.ServerPres_READ:
+ what = "read"
+ case pbx.ServerPres_RECV:
+ what = "recv"
+ case pbx.ServerPres_DEL:
+ what = "del"
+ case pbx.ServerPres_TAGS:
+ what = "tags"
+ case pbx.ServerPres_AUX:
+ what = "aux"
+ }
+ msg.Pres = &MsgServerPres{
+ Topic: pres.GetTopic(),
+ Src: pres.GetSrc(),
+ What: what,
+ UserAgent: pres.GetUserAgent(),
+ SeqId: int(pres.GetSeqId()),
+ DelId: int(pres.GetDelId()),
+ DelSeq: pbDelQueryDeserialize(pres.GetDelSeq()),
+ AcsTarget: pres.GetTargetUserId(),
+ AcsActor: pres.GetActorUserId(),
+ Acs: pbAccessModeDeserialize(pres.GetAcs()),
+ }
+ } else if info := pkt.GetInfo(); info != nil {
+ msg.Info = &MsgServerInfo{
+ Topic: info.GetTopic(),
+ Src: info.GetSrc(),
+ From: info.GetFromUserId(),
+ What: pbInfoNoteWhatDeserialize(info.GetWhat()),
+ SeqId: int(info.GetSeqId()),
+ Event: pbCallEventDeserialize(info.GetEvent()),
+ Payload: info.GetPayload(),
+ }
+ } else if meta := pkt.GetMeta(); meta != nil {
+ msg.Meta = &MsgServerMeta{
+ Id: meta.GetId(),
+ Topic: meta.GetTopic(),
+ Desc: pbTopicDescDeserialize(meta.GetDesc()),
+ Sub: pbTopicSubSliceDeserialize(meta.GetSub()),
+ Del: pbDelValuesDeserialize(meta.GetDel()),
+ Tags: meta.GetTags(),
+ Cred: pbServerCredsDeserialize(meta.GetCred()),
+ Aux: byteMapToInterfaceMap(meta.GetAux()),
+ }
+ }
+ return &msg
+}
+
+// Convert ClientComMessage to pbx.ClientMsg
+func pbCliSerialize(msg *ClientComMessage) *pbx.ClientMsg {
+ var pkt pbx.ClientMsg
+
+ switch {
+ case msg.Hi != nil:
+ pkt.Message = &pbx.ClientMsg_Hi{
+ Hi: &pbx.ClientHi{
+ Id: msg.Hi.Id,
+ UserAgent: msg.Hi.UserAgent,
+ Ver: msg.Hi.Version,
+ DeviceId: msg.Hi.DeviceID,
+ Platform: msg.Hi.Platform,
+ Lang: msg.Hi.Lang,
+ Background: msg.Hi.Background,
+ Tenant: msg.Hi.Tenant,
+ },
+ }
+ case msg.Acc != nil:
+ var authLevel pbx.AuthLevel
+ switch msg.Acc.AuthLevel {
+ case "NONE", "none", "":
+ authLevel = pbx.AuthLevel_NONE
+ case "ANON", "anon":
+ authLevel = pbx.AuthLevel_ANON
+ case "AUTH", "auth":
+ authLevel = pbx.AuthLevel_AUTH
+ case "ROOT", "root":
+ // No support for ROOT here.
+ authLevel = pbx.AuthLevel_NONE
+ }
+ pkt.Message = &pbx.ClientMsg_Acc{
+ Acc: &pbx.ClientAcc{
+ Id: msg.Acc.Id,
+ UserId: msg.Acc.User,
+ State: msg.Acc.State,
+ TmpScheme: msg.Acc.TmpScheme,
+ TmpSecret: msg.Acc.TmpSecret,
+ AuthLevel: authLevel,
+ Scheme: msg.Acc.Scheme,
+ Secret: msg.Acc.Secret,
+ Login: msg.Acc.Login,
+ Tags: msg.Acc.Tags,
+ Cred: pbClientCredsSerialize(msg.Acc.Cred),
+ Desc: pbSetDescSerialize(msg.Acc.Desc),
+ },
+ }
+ case msg.Login != nil:
+ pkt.Message = &pbx.ClientMsg_Login{
+ Login: &pbx.ClientLogin{
+ Id: msg.Login.Id,
+ Scheme: msg.Login.Scheme,
+ Secret: msg.Login.Secret,
+ Cred: pbClientCredsSerialize(msg.Login.Cred),
+ },
+ }
+ case msg.Sub != nil:
+ pkt.Message = &pbx.ClientMsg_Sub{
+ Sub: &pbx.ClientSub{
+ Id: msg.Sub.Id,
+ Topic: msg.Sub.Topic,
+ SetQuery: pbSetQuerySerialize(msg.Sub.Set),
+ GetQuery: pbGetQuerySerialize(msg.Sub.Get),
+ },
+ }
+ case msg.Leave != nil:
+ pkt.Message = &pbx.ClientMsg_Leave{
+ Leave: &pbx.ClientLeave{
+ Id: msg.Leave.Id,
+ Topic: msg.Leave.Topic,
+ Unsub: msg.Leave.Unsub,
+ },
+ }
+ case msg.Pub != nil:
+ pkt.Message = &pbx.ClientMsg_Pub{
+ Pub: &pbx.ClientPub{
+ Id: msg.Pub.Id,
+ Topic: msg.Pub.Topic,
+ NoEcho: msg.Pub.NoEcho,
+ Head: interfaceMapToByteMap(msg.Pub.Head),
+ Content: interfaceToBytes(msg.Pub.Content),
+ },
+ }
+ case msg.Get != nil:
+ pkt.Message = &pbx.ClientMsg_Get{
+ Get: &pbx.ClientGet{
+ Id: msg.Get.Id,
+ Topic: msg.Get.Topic,
+ Query: pbGetQuerySerialize(&msg.Get.MsgGetQuery),
+ },
+ }
+ case msg.Set != nil:
+ pkt.Message = &pbx.ClientMsg_Set{
+ Set: &pbx.ClientSet{
+ Id: msg.Set.Id,
+ Topic: msg.Set.Topic,
+ Query: pbSetQuerySerialize(&msg.Set.MsgSetQuery),
+ },
+ }
+ case msg.Del != nil:
+ var what pbx.ClientDel_What
+ switch msg.Del.What {
+ case "msg":
+ what = pbx.ClientDel_MSG
+ case "topic":
+ what = pbx.ClientDel_TOPIC
+ case "sub":
+ what = pbx.ClientDel_SUB
+ case "user":
+ what = pbx.ClientDel_USER
+ case "cred":
+ what = pbx.ClientDel_CRED
+ }
+ pkt.Message = &pbx.ClientMsg_Del{
+ Del: &pbx.ClientDel{
+ Id: msg.Del.Id,
+ Topic: msg.Del.Topic,
+ What: what,
+ DelSeq: pbDelQuerySerialize(msg.Del.DelSeq),
+ UserId: msg.Del.User,
+ Cred: pbClientCredSerialize(msg.Del.Cred),
+ Hard: msg.Del.Hard,
+ },
+ }
+ case msg.Note != nil:
+ pkt.Message = &pbx.ClientMsg_Note{
+ Note: &pbx.ClientNote{
+ Topic: msg.Note.Topic,
+ What: pbInfoNoteWhatSerialize(msg.Note.What),
+ SeqId: int32(msg.Note.SeqId),
+ Unread: int32(msg.Note.Unread),
+ Event: pbCallEventSerialize(msg.Note.Event),
+ Payload: msg.Note.Payload,
+ },
+ }
+ }
+
+ if pkt.Message == nil {
+ return nil
+ }
+
+ if msg.Extra != nil {
+ pkt.Extra = &pbx.ClientExtra{
+ Attachments: msg.Extra.Attachments,
+ OnBehalfOf: msg.Extra.AsUser,
+ AuthLevel: pbx.AuthLevel(msg.AuthLvl),
+ }
+ }
+
+ return &pkt
+}
+
+// Convert pbx.ClientMsg to ClientComMessage
+func pbCliDeserialize(pkt *pbx.ClientMsg) *ClientComMessage {
+ var msg ClientComMessage
+ if hi := pkt.GetHi(); hi != nil {
+ msg.Hi = &MsgClientHi{
+ Id: hi.GetId(),
+ UserAgent: hi.GetUserAgent(),
+ Version: hi.GetVer(),
+ DeviceID: hi.GetDeviceId(),
+ Platform: hi.GetPlatform(),
+ Lang: hi.GetLang(),
+ Background: hi.GetBackground(),
+ Tenant: hi.GetTenant(),
+ }
+ } else if acc := pkt.GetAcc(); acc != nil {
+ msg.Acc = &MsgClientAcc{
+ Id: acc.GetId(),
+ User: acc.GetUserId(),
+ State: acc.GetState(),
+ TmpScheme: acc.GetTmpScheme(),
+ TmpSecret: acc.GetTmpSecret(),
+ AuthLevel: acc.GetAuthLevel().String(),
+ Scheme: acc.GetScheme(),
+ Secret: acc.GetSecret(),
+ Login: acc.GetLogin(),
+ Tags: acc.GetTags(),
+ Desc: pbSetDescDeserialize(acc.GetDesc()),
+ Cred: pbClientCredsDeserialize(acc.GetCred()),
+ }
+ } else if login := pkt.GetLogin(); login != nil {
+ msg.Login = &MsgClientLogin{
+ Id: login.GetId(),
+ Scheme: login.GetScheme(),
+ Secret: login.GetSecret(),
+ Cred: pbClientCredsDeserialize(login.GetCred()),
+ }
+ } else if sub := pkt.GetSub(); sub != nil {
+ msg.Sub = &MsgClientSub{
+ Id: sub.GetId(),
+ Topic: sub.GetTopic(),
+ Get: pbGetQueryDeserialize(sub.GetGetQuery()),
+ Set: pbSetQueryDeserialize(sub.GetSetQuery()),
+ }
+ } else if leave := pkt.GetLeave(); leave != nil {
+ msg.Leave = &MsgClientLeave{
+ Id: leave.GetId(),
+ Topic: leave.GetTopic(),
+ Unsub: leave.GetUnsub(),
+ }
+ } else if pub := pkt.GetPub(); pub != nil {
+ msg.Pub = &MsgClientPub{
+ Id: pub.GetId(),
+ Topic: pub.GetTopic(),
+ NoEcho: pub.GetNoEcho(),
+ Head: byteMapToInterfaceMap(pub.GetHead()),
+ Content: bytesToInterface(pub.GetContent()),
+ }
+ } else if get := pkt.GetGet(); get != nil {
+ msg.Get = &MsgClientGet{
+ Id: get.GetId(),
+ Topic: get.GetTopic(),
+ }
+ if gq := get.GetQuery(); gq != nil {
+ msg.Get.MsgGetQuery = *pbGetQueryDeserialize(gq)
+ }
+ } else if set := pkt.GetSet(); set != nil {
+ msg.Set = &MsgClientSet{
+ Id: set.GetId(),
+ Topic: set.GetTopic(),
+ }
+ if sq := set.GetQuery(); sq != nil {
+ msg.Set.MsgSetQuery = *pbSetQueryDeserialize(sq)
+ }
+ } else if del := pkt.GetDel(); del != nil {
+ msg.Del = &MsgClientDel{
+ Id: del.GetId(),
+ Topic: del.GetTopic(),
+ DelSeq: pbDelQueryDeserialize(del.GetDelSeq()),
+ User: del.GetUserId(),
+ Cred: pbClientCredDeserialize(del.GetCred()),
+ Hard: del.GetHard(),
+ }
+ switch del.GetWhat() {
+ case pbx.ClientDel_MSG:
+ msg.Del.What = "msg"
+ case pbx.ClientDel_TOPIC:
+ msg.Del.What = "topic"
+ case pbx.ClientDel_SUB:
+ msg.Del.What = "sub"
+ case pbx.ClientDel_USER:
+ msg.Del.What = "user"
+ case pbx.ClientDel_CRED:
+ msg.Del.What = "cred"
+ }
+ } else if note := pkt.GetNote(); note != nil {
+ msg.Note = &MsgClientNote{
+ Topic: note.GetTopic(),
+ SeqId: int(note.GetSeqId()),
+ What: pbInfoNoteWhatDeserialize(note.GetWhat()),
+ Unread: int(note.GetUnread()),
+ Event: pbCallEventDeserialize(note.GetEvent()),
+ Payload: note.GetPayload(),
+ }
+ }
+
+ if extra := pkt.GetExtra(); extra != nil {
+ msg.Extra = &MsgClientExtra{
+ Attachments: extra.GetAttachments(),
+ AsUser: extra.GetOnBehalfOf(),
+ AuthLevel: extra.GetAuthLevel().String(),
+ }
+ }
+
+ return &msg
+}
+
+func interfaceMapToByteMap(in map[string]any) map[string][]byte {
+ out := make(map[string][]byte, len(in))
+ for key, val := range in {
+ if val != nil {
+ out[key], _ = json.Marshal(val)
+ }
+ }
+ return out
+}
+
+func byteMapToInterfaceMap(in map[string][]byte) map[string]any {
+ out := make(map[string]any, len(in))
+ for key, raw := range in {
+ if val := bytesToInterface(raw); val != nil {
+ out[key] = val
+ }
+ }
+ return out
+}
+
+func interfaceToBytes(in any) []byte {
+ if in != nil {
+ out, _ := json.Marshal(in)
+ return out
+ }
+ return nil
+}
+
+func bytesToInterface(in []byte) any {
+ var out any
+ if len(in) > 0 {
+ err := json.Unmarshal(in, &out)
+ if err != nil {
+ logs.Warn.Println("pbx: failed to parse bytes", string(in), err)
+ }
+ }
+ return out
+}
+
+func timeToInt64(ts *time.Time) int64 {
+ if ts != nil {
+ return ts.UnixNano() / int64(time.Millisecond)
+ }
+ return 0
+}
+
+func int64ToTime(ts int64) *time.Time {
+ if ts > 0 {
+ res := time.Unix(ts/1000, ts%1000).UTC()
+ return &res
+ }
+ return nil
+}
+
+func pbGetQuerySerialize(in *MsgGetQuery) *pbx.GetQuery {
+ if in == nil {
+ return nil
+ }
+
+ out := &pbx.GetQuery{
+ What: in.What,
+ }
+
+ if in.Desc != nil {
+ out.Desc = &pbx.GetOpts{
+ IfModifiedSince: timeToInt64(in.Desc.IfModifiedSince),
+ User: in.Desc.User,
+ Topic: in.Desc.Topic,
+ Limit: int32(in.Desc.Limit),
+ }
+ }
+ if in.Sub != nil {
+ out.Sub = &pbx.GetOpts{
+ IfModifiedSince: timeToInt64(in.Sub.IfModifiedSince),
+ User: in.Sub.User,
+ Topic: in.Sub.Topic,
+ Limit: int32(in.Sub.Limit),
+ }
+ }
+ if in.Data != nil {
+ out.Data = &pbx.GetOpts{
+ BeforeId: int32(in.Data.BeforeId),
+ SinceId: int32(in.Data.SinceId),
+ Limit: int32(in.Data.Limit),
+ }
+
+ if len(in.Data.IdRanges) > 0 {
+ out.Data.Ranges = make([]*pbx.SeqRange, len(in.Data.IdRanges))
+ for i, dq := range in.Data.IdRanges {
+ out.Data.Ranges[i] = &pbx.SeqRange{Low: int32(dq.LowId), Hi: int32(dq.HiId)}
+ }
+ }
+ }
+ return out
+}
+
+func pbGetQueryDeserialize(in *pbx.GetQuery) *MsgGetQuery {
+ if in == nil {
+ return nil
+ }
+
+ msg := MsgGetQuery{
+ What: in.GetWhat(),
+ }
+
+ if desc := in.GetDesc(); desc != nil {
+ msg.Desc = &MsgGetOpts{
+ IfModifiedSince: int64ToTime(desc.GetIfModifiedSince()),
+ Limit: int(desc.GetLimit()),
+ }
+ }
+ if sub := in.GetSub(); sub != nil {
+ msg.Sub = &MsgGetOpts{
+ IfModifiedSince: int64ToTime(sub.GetIfModifiedSince()),
+ Limit: int(sub.GetLimit()),
+ }
+ }
+ if data := in.GetData(); data != nil {
+ msg.Data = &MsgGetOpts{
+ BeforeId: int(data.GetBeforeId()),
+ SinceId: int(data.GetSinceId()),
+ Limit: int(data.GetLimit()),
+ }
+
+ if ranges := data.GetRanges(); len(ranges) > 0 {
+ msg.Data.IdRanges = make([]MsgRange, len(ranges))
+ for i, sr := range ranges {
+ msg.Data.IdRanges[i].LowId = int(sr.GetLow())
+ msg.Data.IdRanges[i].HiId = int(sr.GetHi())
+ }
+ }
+ }
+
+ return &msg
+}
+
+func pbSetDescSerialize(in *MsgSetDesc) *pbx.SetDesc {
+ if in == nil {
+ return nil
+ }
+
+ if in.DefaultAcs != nil || in.Public != nil || in.Trusted != nil || in.Private != nil {
+ return &pbx.SetDesc{
+ DefaultAcs: pbDefaultAcsSerialize(in.DefaultAcs),
+ Public: interfaceToBytes(in.Public),
+ Trusted: interfaceToBytes(in.Trusted),
+ Private: interfaceToBytes(in.Private),
+ }
+ }
+
+ return nil
+}
+
+func pbSetDescDeserialize(in *pbx.SetDesc) *MsgSetDesc {
+ if in == nil {
+ return nil
+ }
+
+ defacs := pbDefaultAcsDeserialize(in.GetDefaultAcs())
+ public := in.GetPublic()
+ trusted := in.GetTrusted()
+ private := in.GetPrivate()
+
+ if defacs != nil || public != nil || private != nil || trusted != nil {
+ return &MsgSetDesc{
+ DefaultAcs: defacs,
+ Public: bytesToInterface(public),
+ Trusted: bytesToInterface(trusted),
+ Private: bytesToInterface(private),
+ }
+ }
+
+ return nil
+}
+
+func pbSetQuerySerialize(in *MsgSetQuery) *pbx.SetQuery {
+ if in == nil {
+ return nil
+ }
+
+ out := &pbx.SetQuery{
+ Desc: pbSetDescSerialize(in.Desc),
+ }
+
+ if in.Sub != nil {
+ out.Sub = &pbx.SetSub{
+ UserId: in.Sub.User,
+ Mode: in.Sub.Mode,
+ }
+ }
+
+ out.Tags = in.Tags
+
+ out.Cred = pbClientCredSerialize(in.Cred)
+
+ return out
+}
+
+func pbSetQueryDeserialize(in *pbx.SetQuery) *MsgSetQuery {
+ if in == nil {
+ return nil
+ }
+
+ var msg *MsgSetQuery
+
+ if desc := in.GetDesc(); desc != nil {
+ msg = &MsgSetQuery{}
+ msg.Desc = pbSetDescDeserialize(desc)
+ }
+
+ if sub := in.GetSub(); sub != nil {
+ user := sub.GetUserId()
+ mode := sub.GetMode()
+
+ if user != "" || mode != "" {
+ if msg == nil {
+ msg = &MsgSetQuery{}
+ }
+
+ msg.Sub = &MsgSetSub{
+ User: sub.GetUserId(),
+ Mode: sub.GetMode(),
+ }
+ }
+ }
+
+ if tags := in.GetTags(); tags != nil {
+ if msg == nil {
+ msg = &MsgSetQuery{}
+ }
+ msg.Tags = tags
+ }
+
+ if cred := in.GetCred(); cred != nil {
+ if msg == nil {
+ msg = &MsgSetQuery{}
+ }
+ msg.Cred = pbClientCredDeserialize(cred)
+ }
+
+ return msg
+}
+
+func pbInfoNoteWhatSerialize(what string) pbx.InfoNote {
+ var out pbx.InfoNote
+ switch what {
+ case "kp":
+ out = pbx.InfoNote_KP
+ case "read":
+ out = pbx.InfoNote_READ
+ case "recv":
+ out = pbx.InfoNote_RECV
+ case "call":
+ out = pbx.InfoNote_CALL
+ default:
+ logs.Info.Println("unknown info-note.what", what)
+ }
+ return out
+}
+
+func pbInfoNoteWhatDeserialize(what pbx.InfoNote) string {
+ var out string
+ switch what {
+ case pbx.InfoNote_KP:
+ out = "kp"
+ case pbx.InfoNote_READ:
+ out = "read"
+ case pbx.InfoNote_RECV:
+ out = "recv"
+ case pbx.InfoNote_CALL:
+ out = "call"
+ default:
+ }
+ return out
+}
+
+func pbCallEventSerialize(event string) pbx.CallEvent {
+ var out pbx.CallEvent
+ switch event {
+ case "accept":
+ out = pbx.CallEvent_ACCEPT
+ case "answer":
+ out = pbx.CallEvent_ANSWER
+ case "hang-up":
+ out = pbx.CallEvent_HANG_UP
+ case "ice-candidate":
+ out = pbx.CallEvent_ICE_CANDIDATE
+ case "invite":
+ out = pbx.CallEvent_INVITE
+ case "offer":
+ out = pbx.CallEvent_OFFER
+ case "ringing":
+ out = pbx.CallEvent_RINGING
+ case "":
+ out = pbx.CallEvent_X2
+ default:
+ logs.Info.Println("unknown call event", event)
+ }
+ return out
+}
+
+func pbCallEventDeserialize(event pbx.CallEvent) string {
+ var out string
+ switch event {
+ case pbx.CallEvent_ACCEPT:
+ out = "accept"
+ case pbx.CallEvent_ANSWER:
+ out = "answer"
+ case pbx.CallEvent_HANG_UP:
+ out = "hang-up"
+ case pbx.CallEvent_ICE_CANDIDATE:
+ out = "ice-candidate"
+ case pbx.CallEvent_INVITE:
+ out = "invite"
+ case pbx.CallEvent_OFFER:
+ out = "offer"
+ case pbx.CallEvent_RINGING:
+ out = "ringing"
+ default:
+ }
+ return out
+}
+
+func pbAccessModeSerialize(acs *MsgAccessMode) *pbx.AccessMode {
+ if acs == nil {
+ return nil
+ }
+
+ return &pbx.AccessMode{
+ Want: acs.Want,
+ Given: acs.Given,
+ }
+}
+
+func pbAccessModeDeserialize(acs *pbx.AccessMode) *MsgAccessMode {
+ if acs == nil {
+ return nil
+ }
+
+ return &MsgAccessMode{
+ Want: acs.Want,
+ Given: acs.Given,
+ }
+}
+
+func pbDefaultAcsSerialize(defacs *MsgDefaultAcsMode) *pbx.DefaultAcsMode {
+ if defacs == nil {
+ return nil
+ }
+
+ return &pbx.DefaultAcsMode{
+ Auth: defacs.Auth,
+ Anon: defacs.Anon,
+ }
+}
+
+func pbDefaultAcsDeserialize(defacs *pbx.DefaultAcsMode) *MsgDefaultAcsMode {
+ if defacs == nil {
+ return nil
+ }
+
+ auth := defacs.GetAuth()
+ anon := defacs.GetAnon()
+
+ if auth != "" || anon != "" {
+ return &MsgDefaultAcsMode{
+ Auth: auth,
+ Anon: anon,
+ }
+ }
+ return nil
+}
+
+func pbTopicDescSerialize(desc *MsgTopicDesc) *pbx.TopicDesc {
+ if desc == nil {
+ return nil
+ }
+ out := &pbx.TopicDesc{
+ CreatedAt: timeToInt64(desc.CreatedAt),
+ UpdatedAt: timeToInt64(desc.UpdatedAt),
+ TouchedAt: timeToInt64(desc.TouchedAt),
+ State: desc.State,
+ Online: desc.Online,
+ IsChan: desc.IsChan,
+ Defacs: pbDefaultAcsSerialize(desc.DefaultAcs),
+ Acs: pbAccessModeSerialize(desc.Acs),
+ SeqId: int32(desc.SeqId),
+ ReadId: int32(desc.ReadSeqId),
+ RecvId: int32(desc.RecvSeqId),
+ DelId: int32(desc.DelId),
+ Public: interfaceToBytes(desc.Public),
+ Trusted: interfaceToBytes(desc.Trusted),
+ Private: interfaceToBytes(desc.Private),
+ }
+ if desc.LastSeen != nil {
+ out.LastSeenTime = timeToInt64(desc.LastSeen.When)
+ out.LastSeenUserAgent = desc.LastSeen.UserAgent
+ }
+ return out
+}
+
+func pbTopicDescDeserialize(desc *pbx.TopicDesc) *MsgTopicDesc {
+ if desc == nil {
+ return nil
+ }
+ out := &MsgTopicDesc{
+ CreatedAt: int64ToTime(desc.GetCreatedAt()),
+ UpdatedAt: int64ToTime(desc.GetUpdatedAt()),
+ TouchedAt: int64ToTime(desc.GetTouchedAt()),
+ State: desc.GetState(),
+ Online: desc.GetOnline(),
+ IsChan: desc.GetIsChan(),
+ DefaultAcs: pbDefaultAcsDeserialize(desc.GetDefacs()),
+ Acs: pbAccessModeDeserialize(desc.GetAcs()),
+ SeqId: int(desc.SeqId),
+ ReadSeqId: int(desc.ReadId),
+ RecvSeqId: int(desc.RecvId),
+ DelId: int(desc.DelId),
+ Public: bytesToInterface(desc.Public),
+ Trusted: bytesToInterface(desc.Trusted),
+ Private: bytesToInterface(desc.Private),
+ }
+
+ if desc.GetLastSeenTime() > 0 {
+ out.LastSeen = &MsgLastSeenInfo{
+ When: int64ToTime(desc.GetLastSeenTime()),
+ UserAgent: desc.GetLastSeenUserAgent(),
+ }
+ }
+ return out
+}
+
+func pbTopicSerializeToDesc(topic *Topic) *pbx.TopicDesc {
+ if topic == nil {
+ return nil
+ }
+ return &pbx.TopicDesc{
+ CreatedAt: timeToInt64(&topic.created),
+ UpdatedAt: timeToInt64(&topic.updated),
+ Defacs: &pbx.DefaultAcsMode{
+ Auth: topic.accessAuth.String(),
+ Anon: topic.accessAnon.String(),
+ },
+ SeqId: int32(topic.lastID),
+ DelId: int32(topic.delID),
+ Public: interfaceToBytes(topic.public),
+ Trusted: interfaceToBytes(topic.trusted),
+ }
+}
+
+func pbTopicSubSliceSerialize(subs []MsgTopicSub) []*pbx.TopicSub {
+ if len(subs) == 0 {
+ return nil
+ }
+
+ out := make([]*pbx.TopicSub, len(subs))
+ for i := range subs {
+ out[i] = pbTopicSubSerialize(&subs[i])
+ }
+ return out
+}
+
+func pbTopicSubSerialize(sub *MsgTopicSub) *pbx.TopicSub {
+ out := &pbx.TopicSub{
+ UpdatedAt: timeToInt64(sub.UpdatedAt),
+ DeletedAt: timeToInt64(sub.DeletedAt),
+ Online: sub.Online,
+ Acs: pbAccessModeSerialize(&sub.Acs),
+ ReadId: int32(sub.ReadSeqId),
+ RecvId: int32(sub.RecvSeqId),
+ Public: interfaceToBytes(sub.Public),
+ Trusted: interfaceToBytes(sub.Trusted),
+ Private: interfaceToBytes(sub.Private),
+ UserId: sub.User,
+ Topic: sub.Topic,
+ TouchedAt: timeToInt64(sub.TouchedAt),
+ SeqId: int32(sub.SeqId),
+ DelId: int32(sub.DelId),
+ }
+ if sub.LastSeen != nil {
+ out.LastSeenTime = timeToInt64(sub.LastSeen.When)
+ out.LastSeenUserAgent = sub.LastSeen.UserAgent
+ }
+ return out
+}
+
+func pbTopicSubSliceDeserialize(subs []*pbx.TopicSub) []MsgTopicSub {
+ if len(subs) == 0 {
+ return nil
+ }
+
+ out := make([]MsgTopicSub, len(subs))
+ for i := range subs {
+ out[i] = MsgTopicSub{
+ UpdatedAt: int64ToTime(subs[i].GetUpdatedAt()),
+ DeletedAt: int64ToTime(subs[i].GetDeletedAt()),
+ Online: subs[i].GetOnline(),
+ ReadSeqId: int(subs[i].GetReadId()),
+ RecvSeqId: int(subs[i].GetRecvId()),
+ Public: bytesToInterface(subs[i].GetPublic()),
+ Trusted: bytesToInterface(subs[i].GetTrusted()),
+ Private: bytesToInterface(subs[i].GetPrivate()),
+ User: subs[i].GetUserId(),
+ Topic: subs[i].GetTopic(),
+ TouchedAt: int64ToTime(subs[i].GetTouchedAt()),
+ SeqId: int(subs[i].GetSeqId()),
+ DelId: int(subs[i].GetDelId()),
+ }
+ if acs := subs[i].GetAcs(); acs != nil {
+ out[i].Acs = *pbAccessModeDeserialize(acs)
+ }
+ if subs[i].GetLastSeenTime() > 0 {
+ out[i].LastSeen = &MsgLastSeenInfo{
+ When: int64ToTime(subs[i].GetLastSeenTime()),
+ UserAgent: subs[i].GetLastSeenUserAgent(),
+ }
+ }
+ }
+ return out
+}
+
+func pbSubSliceDeserialize(subs []*pbx.TopicSub) []types.Subscription {
+ if len(subs) == 0 {
+ return nil
+ }
+
+ out := make([]types.Subscription, len(subs))
+ for i := range subs {
+ out[i] = types.Subscription{
+ ObjHeader: types.ObjHeader{
+ UpdatedAt: *int64ToTime(subs[i].GetUpdatedAt()),
+ },
+ DeletedAt: int64ToTime(subs[i].GetDeletedAt()),
+ User: subs[i].GetUserId(),
+ Topic: subs[i].GetTopic(),
+ DelId: int(subs[i].GetDelId()),
+ Private: bytesToInterface(subs[i].GetPrivate()),
+ }
+ out[i].SetPublic(bytesToInterface(subs[i].GetPublic()))
+ out[i].SetTrusted(bytesToInterface(subs[i].GetTrusted()))
+ if acs := subs[i].GetAcs(); acs != nil {
+ out[i].ModeGiven.UnmarshalText([]byte(acs.GetGiven()))
+ out[i].ModeWant.UnmarshalText([]byte(acs.GetWant()))
+ }
+ if subs[i].GetLastSeenTime() > 0 {
+ out[i].SetLastSeenAndUA(int64ToTime(subs[i].GetLastSeenTime()),
+ subs[i].GetLastSeenUserAgent())
+ }
+ }
+ return out
+}
+
+func pbDelQuerySerialize(in []MsgRange) []*pbx.SeqRange {
+ if in == nil {
+ return nil
+ }
+
+ out := make([]*pbx.SeqRange, len(in))
+ for i, dq := range in {
+ out[i] = &pbx.SeqRange{Low: int32(dq.LowId), Hi: int32(dq.HiId)}
+ }
+
+ return out
+}
+
+func pbDelQueryDeserialize(in []*pbx.SeqRange) []MsgRange {
+ if in == nil {
+ return nil
+ }
+
+ out := make([]MsgRange, len(in))
+ for i, sr := range in {
+ out[i].LowId = int(sr.GetLow())
+ out[i].HiId = int(sr.GetHi())
+ }
+
+ return out
+}
+
+func pbDelValuesSerialize(in *MsgDelValues) *pbx.DelValues {
+ if in == nil {
+ return nil
+ }
+
+ return &pbx.DelValues{
+ DelId: int32(in.DelId),
+ DelSeq: pbDelQuerySerialize(in.DelSeq),
+ }
+}
+
+func pbDelValuesDeserialize(in *pbx.DelValues) *MsgDelValues {
+ if in == nil {
+ return nil
+ }
+
+ return &MsgDelValues{
+ DelId: int(in.GetDelId()),
+ DelSeq: pbDelQueryDeserialize(in.GetDelSeq()),
+ }
+}
+
+func pbClientCredSerialize(in *MsgCredClient) *pbx.ClientCred {
+ if in == nil {
+ return nil
+ }
+
+ return &pbx.ClientCred{
+ Method: in.Method,
+ Value: in.Value,
+ Response: in.Response,
+ Params: interfaceMapToByteMap(in.Params),
+ }
+}
+
+func pbClientCredsSerialize(in []MsgCredClient) []*pbx.ClientCred {
+ if in == nil {
+ return nil
+ }
+
+ out := make([]*pbx.ClientCred, len(in))
+ for i := range in {
+ out[i] = pbClientCredSerialize(&in[i])
+ }
+
+ return out
+}
+
+func pbClientCredDeserialize(in *pbx.ClientCred) *MsgCredClient {
+ if in == nil {
+ return nil
+ }
+
+ return &MsgCredClient{
+ Method: in.GetMethod(),
+ Value: in.GetValue(),
+ Response: in.GetResponse(),
+ Params: byteMapToInterfaceMap(in.GetParams()),
+ }
+}
+
+func pbClientCredsDeserialize(in []*pbx.ClientCred) []MsgCredClient {
+ if in == nil {
+ return nil
+ }
+
+ out := make([]MsgCredClient, len(in))
+ for i, cr := range in {
+ out[i] = *pbClientCredDeserialize(cr)
+ }
+
+ return out
+}
+
+func pbServerCredsSerialize(in []*MsgCredServer) []*pbx.ServerCred {
+ if in == nil {
+ return nil
+ }
+
+ out := make([]*pbx.ServerCred, len(in))
+ for i, cr := range in {
+ out[i] = &pbx.ServerCred{
+ Method: cr.Method,
+ Value: cr.Value,
+ }
+ }
+
+ return out
+}
+
+func pbServerCredsDeserialize(in []*pbx.ServerCred) []*MsgCredServer {
+ if in == nil {
+ return nil
+ }
+
+ out := make([]*MsgCredServer, len(in))
+ for i, cr := range in {
+ out[i] = &MsgCredServer{
+ Method: cr.GetMethod(),
+ Value: cr.GetValue(),
+ Done: cr.GetDone(),
+ }
+ }
+
+ return out
+}
diff --git a/server/pbconverter_test.go b/server/pbconverter_test.go
new file mode 100644
index 0000000..4e03801
--- /dev/null
+++ b/server/pbconverter_test.go
@@ -0,0 +1,19 @@
+package main
+
+import "testing"
+
+func TestPbClientHiTenantRoundTrip(t *testing.T) {
+ original := &ClientComMessage{Hi: &MsgClientHi{
+ Id: "hi-1",
+ Version: "1",
+ Tenant: "acme",
+ }}
+
+ decoded := pbCliDeserialize(pbCliSerialize(original))
+ if decoded.Hi == nil {
+ t.Fatal("decoded hi message is nil")
+ }
+ if decoded.Hi.Tenant != original.Hi.Tenant {
+ t.Fatalf("tenant: expected %q, got %q", original.Hi.Tenant, decoded.Hi.Tenant)
+ }
+}
diff --git a/server/plugins.go b/server/plugins.go
index e69de29..24b5fb5 100644
--- a/server/plugins.go
+++ b/server/plugins.go
@@ -0,0 +1,752 @@
+// External services contacted through RPC
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/tinode/chat/pbx"
+ "github.com/tinode/chat/server/logs"
+ "github.com/tinode/chat/server/store/types"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/metadata"
+)
+
+const (
+ plgHi = 1 << iota
+ plgAcc
+ plgLogin
+ plgSub
+ plgLeave
+ plgPub
+ plgGet
+ plgSet
+ plgDel
+ plgNote
+ plgData
+ plgMeta
+ plgPres
+ plgInfo
+
+ plgClientMask = plgHi | plgAcc | plgLogin | plgSub | plgLeave | plgPub | plgGet | plgSet | plgDel | plgNote
+ plgServerMask = plgData | plgMeta | plgPres | plgInfo
+)
+
+const (
+ plgActCreate = 1 << iota
+ plgActUpd
+ plgActDel
+
+ plgActMask = plgActCreate | plgActUpd | plgActDel
+)
+
+const (
+ plgTopicMe = 1 << iota
+ plgTopicFnd
+ plgTopicP2P
+ plgTopicGrp
+ plgTopicSys
+ plgTopicSlf
+ plgTopicNew
+ plgTopicNch
+
+ plgTopicCatMask = plgTopicMe | plgTopicFnd | plgTopicP2P | plgTopicGrp | plgTopicSys | plgTopicSlf
+)
+
+const (
+ plgFilterByTopicType = 1 << iota
+ plgFilterByPacket
+ plgFilterByAction
+)
+
+var (
+ plgPacketNames = []string{
+ "hi", "acc", "login", "sub", "leave", "pub", "get", "set", "del", "note",
+ "data", "meta", "pres", "info",
+ }
+
+ plgTopicCatNames = []string{"me", "fnd", "p2p", "grp", "sys", "slf", "new", "nch"}
+)
+
+// PluginFilter is a enum which defines filtering types.
+type PluginFilter struct {
+ byPacket int
+ byTopicType int
+ byAction int
+}
+
+// ParsePluginFilter parses filter config string.
+func ParsePluginFilter(s *string, filterBy int) (*PluginFilter, error) {
+ if s == nil {
+ return nil, nil
+ }
+
+ parseByName := func(parts []string, options []string, def int) (int, error) {
+ var result int
+
+ // Iterate over filter parts
+ for _, inp := range parts {
+ if inp != "" {
+ inp = strings.ToLower(inp)
+ // Split string like "hi,login,pres" or "me,p2p,fnd"
+ values := strings.Split(inp, ",")
+ // For each value in the input string, try to find it in the options set
+ for _, val := range values {
+ i := 0
+ // Iterate over the options, i.e find "hi" in the slice of packet names
+ for i = range options {
+ if options[i] == val {
+ result |= 1 << uint(i)
+ break
+ }
+ }
+
+ if result != 0 && i == len(options) {
+ // Mix of known and unknown options in the input
+ return 0, errors.New("plugin: unknown value in filter " + val)
+ }
+ }
+
+ if result != 0 {
+ // Found and parsed the right part
+ break
+ }
+ }
+ }
+
+ // If the filter value is not defined, use default.
+ if result == 0 {
+ result = def
+ }
+
+ return result, nil
+ }
+
+ parseAction := func(parts []string) int {
+ var result int
+ for _, inp := range parts {
+ Loop:
+ for _, char := range inp {
+ switch char {
+ case 'c', 'C':
+ result |= plgActCreate
+ case 'u', 'U':
+ result |= plgActUpd
+ case 'd', 'D':
+ result |= plgActDel
+ default:
+ // Unknown symbol means this is not an action string.
+ result = 0
+ break Loop
+ }
+ }
+
+ if result != 0 {
+ // Found and parsed actions.
+ break
+ }
+ }
+ if result == 0 {
+ result = plgActMask
+ }
+ return result
+ }
+
+ filter := PluginFilter{}
+ parts := strings.Split(*s, ";")
+ var err error
+
+ if filterBy&plgFilterByPacket != 0 {
+ if filter.byPacket, err = parseByName(parts, plgPacketNames, plgClientMask); err != nil {
+ return nil, err
+ }
+ }
+
+ if filterBy&plgFilterByTopicType != 0 {
+ if filter.byTopicType, err = parseByName(parts, plgTopicCatNames, plgTopicCatMask); err != nil {
+ return nil, err
+ }
+ }
+
+ if filterBy&plgFilterByAction != 0 {
+ filter.byAction = parseAction(parts)
+ }
+
+ return &filter, nil
+}
+
+// PluginRPCFilterConfig filters for an individual RPC call. Filter strings are formatted as follows:
+// ; ;
+// For instance:
+// "acc,login;;CU" - grab packets {acc} or {login}; no filtering by topic, Create or Update action
+// "pub,pres;me,p2p;"
+type pluginRPCFilterConfig struct {
+ // Filter by packet name, topic type [or exact name - not supported yet]. 2D: "pub,pres;p2p,me"
+ FireHose *string `json:"fire_hose"`
+
+ // Filter by CUD, [exact user name - not supported yet]. 1D: "C"
+ Account *string `json:"account"`
+ // Filter by CUD, topic type[, exact name]: "p2p;CU"
+ Topic *string `json:"topic"`
+ // Filter by CUD, topic type[, exact topic name, exact user name]: "CU"
+ Subscription *string `json:"subscription"`
+ // Filter by C.D, topic type[, exact topic name, exact user name]: "grp;CD"
+ Message *string `json:"message"`
+
+ // Call Find service, true or false
+ Find bool
+}
+
+type pluginConfig struct {
+ Enabled bool `json:"enabled"`
+ // Unique service name
+ Name string `json:"name"`
+ // Microseconds to wait before timeout
+ Timeout int64 `json:"timeout"`
+ // Filters for RPC calls: when to call vs when to skip the call
+ Filters pluginRPCFilterConfig `json:"filters"`
+ // What should the server do if plugin failed: HTTP error code
+ FailureCode int `json:"failure_code"`
+ // HTTP Error message to go with the code
+ FailureMessage string `json:"failure_text"`
+ // Address of plugin server of the form "tcp://localhost:123" or "unix://path_to_socket_file"
+ ServiceAddr string `json:"service_addr"`
+}
+
+// Plugin defines client-side parameters of a gRPC plugin.
+type Plugin struct {
+ name string
+ timeout time.Duration
+ // Filters for individual methods
+ filterFireHose *PluginFilter
+ filterAccount *PluginFilter
+ filterTopic *PluginFilter
+ filterSubscription *PluginFilter
+ filterMessage *PluginFilter
+ filterFind bool
+ failureCode int
+ failureText string
+ network string
+ addr string
+
+ conn *grpc.ClientConn
+ client pbx.PluginClient
+}
+
+func pluginsInit(configString json.RawMessage) {
+ // Check if any plugins are defined
+ if len(configString) == 0 {
+ return
+ }
+
+ var config []pluginConfig
+ if err := json.Unmarshal(configString, &config); err != nil {
+ logs.Err.Fatal(err)
+ }
+
+ nameIndex := make(map[string]bool)
+ globals.plugins = make([]Plugin, len(config))
+ count := 0
+ for i := range config {
+ conf := &config[i]
+ if !conf.Enabled {
+ continue
+ }
+
+ if nameIndex[conf.Name] {
+ logs.Err.Fatalf("plugins: duplicate name '%s'", conf.Name)
+ }
+
+ globals.plugins[count] = Plugin{
+ name: conf.Name,
+ timeout: time.Duration(conf.Timeout) * time.Microsecond,
+ failureCode: conf.FailureCode,
+ failureText: conf.FailureMessage,
+ }
+ var err error
+ if globals.plugins[count].filterFireHose, err =
+ ParsePluginFilter(conf.Filters.FireHose, plgFilterByTopicType|plgFilterByPacket); err != nil {
+ logs.Err.Fatal("plugins: bad FireHose filter", err)
+ }
+ if globals.plugins[count].filterAccount, err =
+ ParsePluginFilter(conf.Filters.Account, plgFilterByAction); err != nil {
+ logs.Err.Fatal("plugins: bad Account filter", err)
+ }
+ if globals.plugins[count].filterTopic, err =
+ ParsePluginFilter(conf.Filters.Topic, plgFilterByTopicType|plgFilterByAction); err != nil {
+ logs.Err.Fatal("plugins: bad Topic filter", err)
+ }
+ if globals.plugins[count].filterSubscription, err =
+ ParsePluginFilter(conf.Filters.Subscription, plgFilterByTopicType|plgFilterByAction); err != nil {
+ logs.Err.Fatal("plugins: bad Subscription filter", err)
+ }
+ if globals.plugins[count].filterMessage, err =
+ ParsePluginFilter(conf.Filters.Message, plgFilterByTopicType|plgFilterByAction); err != nil {
+ logs.Err.Fatal("plugins: bad Message filter", err)
+ }
+
+ globals.plugins[count].filterFind = conf.Filters.Find
+
+ if parts := strings.SplitN(conf.ServiceAddr, "://", 2); len(parts) < 2 {
+ logs.Err.Fatal("plugins: invalid server address format", conf.ServiceAddr)
+ } else {
+ globals.plugins[count].network = parts[0]
+ globals.plugins[count].addr = parts[1]
+ }
+
+ globals.plugins[count].conn, err = grpc.Dial(globals.plugins[count].addr, grpc.WithInsecure())
+ if err != nil {
+ logs.Err.Fatalf("plugins: connection failure %v", err)
+ }
+
+ globals.plugins[count].client = pbx.NewPluginClient(globals.plugins[count].conn)
+
+ nameIndex[conf.Name] = true
+ count++
+ }
+
+ globals.plugins = globals.plugins[:count]
+ if len(globals.plugins) == 0 {
+ logs.Info.Println("plugins: no active plugins found")
+ globals.plugins = nil
+ } else {
+ var names []string
+ for i := range globals.plugins {
+ names = append(names, globals.plugins[i].name+"("+globals.plugins[i].addr+")")
+ }
+
+ logs.Info.Println("plugins: active", "'"+strings.Join(names, "', '")+"'")
+ }
+}
+
+func pluginsShutdown() {
+ if globals.plugins == nil {
+ return
+ }
+
+ for i := range globals.plugins {
+ globals.plugins[i].conn.Close()
+ }
+}
+
+func pluginGenerateClientReq(sess *Session, msg *ClientComMessage) *pbx.ClientReq {
+ cmsg := pbCliSerialize(msg)
+ if cmsg == nil {
+ return nil
+ }
+ return &pbx.ClientReq{
+ Msg: cmsg,
+ Sess: &pbx.Session{
+ SessionId: sess.sid,
+ UserId: sess.uid.UserId(),
+ AuthLevel: pbx.AuthLevel(sess.authLvl),
+ UserAgent: sess.userAgent,
+ RemoteAddr: sess.remoteAddr,
+ DeviceId: sess.deviceID,
+ Language: sess.lang,
+ },
+ }
+}
+
+func pluginContext(base context.Context, p *Plugin, tenantID types.TenantID) (context.Context, context.CancelFunc) {
+ var ctx context.Context
+ var cancel context.CancelFunc
+ if p.timeout > 0 {
+ ctx, cancel = context.WithTimeout(base, p.timeout)
+ } else {
+ ctx = base
+ }
+ if tenantID.IsValid() {
+ ctx = metadata.AppendToOutgoingContext(ctx, "tenant-id", strconv.FormatInt(int64(tenantID), 10))
+ }
+ return ctx, cancel
+}
+
+func pluginFireHose(sess *Session, msg *ClientComMessage) (*ClientComMessage, *ServerComMessage) {
+ if globals.plugins == nil {
+ // Return the original message to continue processing without changes
+ return msg, nil
+ }
+
+ var req *pbx.ClientReq
+
+ id, topic := pluginIDAndTopic(msg)
+ ts := time.Now().UTC().Round(time.Millisecond)
+ for i := range globals.plugins {
+ p := &globals.plugins[i]
+ if !pluginDoFiltering(p.filterFireHose, msg) {
+ // Plugin is not interested in FireHose
+ continue
+ }
+
+ if req == nil {
+ // Generate request only if needed
+ req = pluginGenerateClientReq(sess, msg)
+ if req == nil {
+ // Failed to serialize message. Most likely the message is invalid.
+ break
+ }
+ }
+
+ ctx, cancel := pluginContext(context.Background(), p, msg.TenantID)
+ if cancel != nil {
+ defer cancel()
+ }
+ if resp, err := p.client.FireHose(ctx, req); err == nil {
+ respStatus := resp.GetStatus()
+ // CONTINUE means default processing
+ if respStatus == pbx.RespCode_CONTINUE {
+ continue
+ }
+ // DROP means stop processing of the message
+ if respStatus == pbx.RespCode_DROP {
+ return nil, nil
+ }
+ // REPLACE: ClientMsg was updated by the plugin. Use the new one for further processing.
+ if respStatus == pbx.RespCode_REPLACE {
+ repl := pbCliDeserialize(resp.GetClmsg())
+ if repl != nil {
+ repl.TenantID = msg.TenantID
+ }
+ return repl, nil
+ }
+
+ // RESPOND: Plugin provided an alternative response message. Use it
+ srv := pbServDeserialize(resp.GetSrvmsg())
+ if srv != nil {
+ srv.TenantID = msg.TenantID
+ }
+ return nil, srv
+
+ } else if p.failureCode != 0 {
+ // Plugin failed and it's configured to stop further processing.
+ logs.Err.Println("plugin: failed,", p.name, err)
+ return nil, &ServerComMessage{
+ TenantID: msg.TenantID,
+ Ctrl: &MsgServerCtrl{
+ Id: id,
+ Code: p.failureCode,
+ Text: p.failureText,
+ Topic: topic,
+ Timestamp: ts,
+ },
+ }
+ } else {
+ // Plugin failed but configured to ignore failure.
+ logs.Warn.Println("plugin: failure ignored,", p.name, err)
+ }
+ }
+
+ return msg, nil
+}
+
+// Ask plugin to perform search.
+func pluginFind(tenantID types.TenantID, user types.Uid, query string) (string, []types.Subscription, error) {
+ if globals.plugins == nil {
+ return query, nil, nil
+ }
+
+ find := &pbx.SearchQuery{
+ UserId: user.UserId(),
+ Query: query,
+ }
+ for i := range globals.plugins {
+ p := &globals.plugins[i]
+ if !p.filterFind {
+ // Plugin cannot service Find requests
+ continue
+ }
+
+ ctx, cancel := pluginContext(context.Background(), p, tenantID)
+ if cancel != nil {
+ defer cancel()
+ }
+ resp, err := p.client.Find(ctx, find)
+ if err != nil {
+ logs.Warn.Println("plugins: Find call failed", p.name, err)
+ return "", nil, err
+ }
+ respStatus := resp.GetStatus()
+ // CONTINUE means default processing
+ if respStatus == pbx.RespCode_CONTINUE {
+ continue
+ }
+ // DROP means stop processing the request
+ if respStatus == pbx.RespCode_DROP {
+ return "", nil, nil
+ }
+ // REPLACE: query string was changed. Use the new one for further processing.
+ if respStatus == pbx.RespCode_REPLACE {
+ return resp.GetQuery(), nil, nil
+ }
+ // RESPOND: Plugin provided a specific response. Use it
+ return "", pbSubSliceDeserialize(resp.GetResult()), nil
+ }
+
+ return query, nil, nil
+}
+
+func pluginAccount(user *types.User, action int) {
+ if globals.plugins == nil {
+ return
+ }
+
+ var event *pbx.AccountEvent
+ for i := range globals.plugins {
+ p := &globals.plugins[i]
+ if p.filterAccount == nil || p.filterAccount.byAction&action == 0 {
+ // Plugin is not interested in Account actions
+ continue
+ }
+
+ if event == nil {
+ event = &pbx.AccountEvent{
+ Action: pluginActionToCrud(action),
+ UserId: user.Uid().UserId(),
+ DefaultAcs: pbDefaultAcsSerialize(&MsgDefaultAcsMode{
+ Auth: user.Access.Auth.String(),
+ Anon: user.Access.Anon.String(),
+ }),
+ Public: interfaceToBytes(user.Public),
+ Tags: user.Tags,
+ }
+ }
+
+ ctx, cancel := pluginContext(context.Background(), p, user.TenantID)
+ if cancel != nil {
+ defer cancel()
+ }
+ if _, err := p.client.Account(ctx, event); err != nil {
+ logs.Warn.Println("plugins: Account call failed", p.name, err)
+ }
+ }
+}
+
+func pluginTopic(topic *Topic, action int) {
+ if globals.plugins == nil {
+ return
+ }
+
+ var event *pbx.TopicEvent
+ for i := range globals.plugins {
+ p := &globals.plugins[i]
+ if p.filterTopic == nil || p.filterTopic.byAction&action == 0 {
+ // Plugin is not interested in Message actions
+ continue
+ }
+
+ if event == nil {
+ event = &pbx.TopicEvent{
+ Action: pluginActionToCrud(action),
+ Name: topic.name,
+ Desc: pbTopicSerializeToDesc(topic),
+ }
+ }
+
+ ctx, cancel := pluginContext(context.Background(), p, topic.tenantID)
+ if cancel != nil {
+ defer cancel()
+ }
+ if _, err := p.client.Topic(ctx, event); err != nil {
+ logs.Warn.Println("plugins: Topic call failed", p.name, err)
+ }
+ }
+}
+
+func pluginSubscription(sub *types.Subscription, action int) {
+ if globals.plugins == nil {
+ return
+ }
+
+ var event *pbx.SubscriptionEvent
+ for i := range globals.plugins {
+ p := &globals.plugins[i]
+ if p.filterSubscription == nil || p.filterSubscription.byAction&action == 0 {
+ // Plugin is not interested in Message actions
+ continue
+ }
+
+ if event == nil {
+ event = &pbx.SubscriptionEvent{
+ Action: pluginActionToCrud(action),
+ Topic: sub.Topic,
+ UserId: sub.User,
+
+ DelId: int32(sub.DelId),
+ ReadId: int32(sub.ReadSeqId),
+ RecvId: int32(sub.RecvSeqId),
+
+ Mode: &pbx.AccessMode{
+ Want: sub.ModeWant.String(),
+ Given: sub.ModeGiven.String(),
+ },
+
+ Private: interfaceToBytes(sub.Private),
+ }
+ }
+
+ ctx, cancel := pluginContext(context.Background(), p, sub.TenantID)
+ if cancel != nil {
+ defer cancel()
+ }
+ if _, err := p.client.Subscription(ctx, event); err != nil {
+ logs.Warn.Println("plugins: Subscription call failed", p.name, err)
+ }
+ }
+}
+
+// Message accepted for delivery
+func pluginMessage(tenantID types.TenantID, data *MsgServerData, action int) {
+ if globals.plugins == nil || action != plgActCreate {
+ return
+ }
+
+ var event *pbx.MessageEvent
+ for i := range globals.plugins {
+ p := &globals.plugins[i]
+ if p.filterMessage == nil || p.filterMessage.byAction&action == 0 {
+ // Plugin is not interested in Message actions
+ continue
+ }
+
+ if event == nil {
+ event = &pbx.MessageEvent{
+ Action: pluginActionToCrud(action),
+ Msg: pbServDataSerialize(data).Data,
+ }
+ }
+
+ ctx, cancel := pluginContext(context.Background(), p, tenantID)
+ if cancel != nil {
+ defer cancel()
+ }
+ if _, err := p.client.Message(ctx, event); err != nil {
+ logs.Warn.Println("plugins: Message call failed", p.name, err)
+ }
+ }
+}
+
+// Returns false to skip, true to process
+func pluginDoFiltering(filter *PluginFilter, msg *ClientComMessage) bool {
+ filterByTopic := func(topic string, flt int) bool {
+ if topic == "" || flt == plgTopicCatMask {
+ return true
+ }
+
+ tt := topic
+ if len(tt) > 3 {
+ tt = topic[:3]
+ }
+ switch tt {
+ case "me":
+ return flt&plgTopicMe != 0
+ case "fnd":
+ return flt&plgTopicFnd != 0
+ case "usr":
+ return flt&plgTopicP2P != 0
+ case "grp":
+ return flt&plgTopicGrp != 0
+ case "sys":
+ return flt&plgTopicSys != 0
+ case "slf":
+ return flt&plgTopicSlf != 0
+ case "new":
+ return flt&plgTopicNew != 0
+ case "nch":
+ return flt&plgTopicNch != 0
+ }
+ return false
+ }
+
+ // Check if plugin has any filters for this call
+ if filter == nil || filter.byPacket == 0 {
+ return false
+ }
+ // Check if plugin wants all the messages
+ if filter.byPacket == plgClientMask && filter.byTopicType == plgTopicCatMask {
+ return true
+ }
+ // Check individual bits
+ if msg.Hi != nil {
+ return filter.byPacket&plgHi != 0
+ }
+ if msg.Acc != nil {
+ return filter.byPacket&plgAcc != 0
+ }
+ if msg.Login != nil {
+ return filter.byPacket&plgLogin != 0
+ }
+ if msg.Sub != nil {
+ return filter.byPacket&plgSub != 0 && filterByTopic(msg.Sub.Topic, filter.byTopicType)
+ }
+ if msg.Leave != nil {
+ return filter.byPacket&plgLeave != 0 && filterByTopic(msg.Leave.Topic, filter.byTopicType)
+ }
+ if msg.Pub != nil {
+ return filter.byPacket&plgPub != 0 && filterByTopic(msg.Pub.Topic, filter.byTopicType)
+ }
+ if msg.Get != nil {
+ return filter.byPacket&plgGet != 0 && filterByTopic(msg.Get.Topic, filter.byTopicType)
+ }
+ if msg.Set != nil {
+ return filter.byPacket&plgSet != 0 && filterByTopic(msg.Set.Topic, filter.byTopicType)
+ }
+ if msg.Del != nil {
+ return filter.byPacket&plgDel != 0 && filterByTopic(msg.Del.Topic, filter.byTopicType)
+ }
+ if msg.Note != nil {
+ return filter.byPacket&plgNote != 0 && filterByTopic(msg.Note.Topic, filter.byTopicType)
+ }
+ return false
+}
+
+func pluginActionToCrud(action int) pbx.Crud {
+ switch action {
+ case plgActCreate:
+ return pbx.Crud_CREATE
+ case plgActUpd:
+ return pbx.Crud_UPDATE
+ case plgActDel:
+ return pbx.Crud_DELETE
+ }
+ panic("plugin: unknown action")
+}
+
+// pluginIDAndTopic extracts message ID and topic name.
+func pluginIDAndTopic(msg *ClientComMessage) (string, string) {
+ if msg.Hi != nil {
+ return msg.Hi.Id, ""
+ }
+ if msg.Acc != nil {
+ return msg.Acc.Id, ""
+ }
+ if msg.Login != nil {
+ return msg.Login.Id, ""
+ }
+ if msg.Sub != nil {
+ return msg.Sub.Id, msg.Sub.Topic
+ }
+ if msg.Leave != nil {
+ return msg.Leave.Id, msg.Leave.Topic
+ }
+ if msg.Pub != nil {
+ return msg.Pub.Id, msg.Pub.Topic
+ }
+ if msg.Get != nil {
+ return msg.Get.Id, msg.Get.Topic
+ }
+ if msg.Set != nil {
+ return msg.Set.Id, msg.Set.Topic
+ }
+ if msg.Del != nil {
+ return msg.Del.Id, msg.Del.Topic
+ }
+ if msg.Note != nil {
+ return "", msg.Note.Topic
+ }
+ return "", ""
+}
diff --git a/server/pres.go b/server/pres.go
index e69de29..ee7df02 100644
--- a/server/pres.go
+++ b/server/pres.go
@@ -0,0 +1,731 @@
+package main
+
+import (
+ "encoding/json"
+ "strings"
+
+ "github.com/tinode/chat/server/logs"
+ "github.com/tinode/chat/server/store"
+ "github.com/tinode/chat/server/store/types"
+)
+
+// presParams defines parameters for creating a presence notification.
+type presParams struct {
+ userAgent string
+ seqID int
+ delID int
+ delSeq []MsgRange
+
+ // Uid who performed the action
+ actor string
+ // Subject of the action
+ target string
+ dWant string
+ dGiven string
+}
+
+type presFilters struct {
+ // Send messages only to users with this access mode being non-zero.
+ filterIn types.AccessMode
+ // Exclude users with this access mode being non-zero.
+ filterOut types.AccessMode
+ // Send messages to the sessions of this single user defined by ID as a string 'usrABC'.
+ singleUser string
+ // Do not send messages to sessions of this user defined by ID as a string 'usrABC'.
+ excludeUser string
+}
+
+func (p *presParams) packAcs() *MsgAccessMode {
+ if p.dWant != "" || p.dGiven != "" {
+ return &MsgAccessMode{Want: p.dWant, Given: p.dGiven}
+ }
+ return nil
+}
+
+// Presence: Add another user to the list of contacts to notify of presence and other changes
+func (t *Topic) addToPerSubs(topic string, online, enabled bool) {
+ if topic == t.name {
+ // No need to push updates to self
+ return
+ }
+
+ // TODO: maybe skip loading channel subscriptions. They can's send or receive these notifications anyway.
+
+ if uid1, uid2, err := types.ParseP2P(topic); err == nil {
+ // If this is a P2P topic, index it by second user's ID
+ if uid1.UserId() == t.name {
+ topic = uid2.UserId()
+ } else {
+ topic = uid1.UserId()
+ }
+ }
+
+ t.perSubs[topic] = perSubsData{online: online, enabled: enabled}
+}
+
+// loadContacts loads topic.perSubs to support presence notifications.
+// perSubs contains (a) topics that the user wants to notify of his presence and
+// (b) those which want to receive notifications from this user.
+func (t *Topic) loadContacts(uid types.Uid) error {
+ subs, err := store.Users.GetSubs(t.tenantID, uid)
+ if err != nil {
+ return err
+ }
+
+ for i := range subs {
+ t.addToPerSubs(subs[i].Topic, false, (subs[i].ModeGiven & subs[i].ModeWant).IsPresencer())
+ }
+ return nil
+}
+
+// This topic got a request from a 'me' topic to start/stop sending presence updates.
+// The originating topic reports its own status in 'what' as "on", "off", "gone" or "?unkn".
+//
+// "on" - requester came online
+// "off" - requester is offline now
+// "?none" - anchor for "+" command: requester status is unknown, won't generate a response
+// and isn't forwarded to clients.
+// "gone" - topic deleted or otherwise gone - equivalent of "off+remove"
+// "?unkn" - requester wants to initiate online status exchange but it's own status is unknown yet. This
+// notifications is not forwarded to users.
+//
+// "+" commands:
+// "+en": enable subscription, i.e. start accepting incoming notifications from the user2;
+// "+rem": terminate and remove the subscription (subscription deleted)
+// "+dis" disable subscription withot removing it, the opposite of "en".
+// The "+en/rem/dis" command itself is stripped from the notification.
+func (t *Topic) procPresReq(fromUserID, what string, wantReply bool) string {
+ if t.isInactive() {
+ return ""
+ }
+
+ if t.isProxy {
+ // Passthrough on proxy: there is no point in maintaining peer status
+ // at the proxy, it's an exact replica of the master.
+ return what
+ }
+
+ var reqReply, onlineUpdate bool
+
+ online := &onlineUpdate
+ replyAs := "on"
+
+ parts := strings.Split(what, "+")
+ what = parts[0]
+ cmd := ""
+ if len(parts) > 1 {
+ cmd = parts[1]
+ }
+
+ switch what {
+ case "on":
+ // online
+ *online = true
+ case "off":
+ // offline
+ case "?none":
+ // no change to online status
+ online = nil
+ what = ""
+ case "gone":
+ // offline: off+rem
+ cmd = "rem"
+ case "?unkn":
+ // no change in online status
+ online = nil
+ reqReply = true
+ what = ""
+ default:
+ // All other notifications are not processed here
+ return what
+ }
+
+ if t.cat == types.TopicCatMe {
+ // Find if the contact is listed.
+ if psd, ok := t.perSubs[fromUserID]; ok {
+ if cmd == "rem" {
+ replyAs = "off+rem"
+ if !psd.enabled && what == "off" {
+ // If it was disabled before, don't send a redundant update.
+ what = ""
+ }
+ delete(t.perSubs, fromUserID)
+ } else {
+ switch cmd {
+ case "":
+ // No change in being enabled or disabled and not being added or removed.
+ if !psd.enabled || online == nil || psd.online == *online {
+ // Not enabled or no change in online status - remove unnecessary notification.
+ what = ""
+ }
+ case "en":
+ if !psd.enabled {
+ psd.enabled = true
+ } else if online == nil || psd.online == *online {
+ // Was active and no change or online before: skip unnecessary update.
+ what = ""
+ }
+ case "dis":
+ if psd.enabled {
+ psd.enabled = false
+ if !psd.online {
+ what = ""
+ }
+ } else {
+ // Was disabled and consequently offline before, still offline - skip the update.
+ what = ""
+ }
+ default:
+ panic("presProcReq: unknown command '" + cmd + "'")
+ }
+
+ if !psd.enabled {
+ // If we don't care about updates, keep the other user off
+ psd.online = false
+ } else if online != nil {
+ psd.online = *online
+ }
+
+ t.perSubs[fromUserID] = psd
+ }
+ } else if cmd != "rem" {
+ // Got request from a new topic. This must be a new subscription. Record it.
+ // If it's unknown, recording it as offline.
+ t.addToPerSubs(fromUserID, onlineUpdate, cmd == "en")
+
+ if cmd != "en" {
+ // If the connection is not enabled, ignore the update.
+ what = ""
+ }
+ } else {
+ // Not in list and asked to be removed from the list - ignore
+ what = ""
+ }
+ }
+
+ // If requester's online status has not changed, do not reply, otherwise an endless loop will happen.
+ // wantReply is needed to ensure unnecessary {pres} is not sent:
+ // A[online, B:off] to B[online, A:off]: {pres A on}
+ // B[online, A:on] to A[online, B:off]: {pres B on}
+ // A[online, B:on] to B[online, A:on]: {pres A on} <<-- unnecessary, that's why wantReply is needed
+ if (onlineUpdate || reqReply) && wantReply {
+ globals.hub.routeSrv <- &ServerComMessage{
+ TenantID: t.tenantID,
+ // Topic is 'me' even for group topics; group topics will use 'me' as a signal to drop the message
+ // without forwarding to sessions
+ Pres: &MsgServerPres{
+ Topic: "me",
+ What: replyAs,
+ Src: t.name,
+ WantReply: reqReply,
+ },
+ RcptTo: fromUserID,
+ }
+ }
+
+ return what
+}
+
+// Get user-specific topic name for notifying users of interest, or skip the notification.
+func notifyOnOrSkip(topic, what string, online bool) string {
+ // Don't send notifications on channels
+ if types.IsChannel(topic) {
+ return ""
+ }
+
+ // P2P contacts are notified on 'me', group topics are notified on proper topic name.
+ notifyOn := "me"
+ if what == "upd" || what == "ua" {
+ if !online {
+ // Skip "upd" and "ua" notifications if the contact is offline.
+ return ""
+ }
+ if types.GetTopicCat(topic) == types.TopicCatGrp {
+ notifyOn = topic
+ }
+ }
+ return notifyOn
+}
+
+// Publish user's update to his/her subscriptions: p2p on their 'me' topic, group topics on the topic.
+// Case A: user came online, "on", ua
+// Case B: user went offline, "off", ua
+// Case C: user agent change, "ua", ua
+// Case D: User updated 'public', "upd"
+func (t *Topic) presUsersOfInterest(what, ua string) {
+ parts := strings.Split(what, "+")
+ wantReply := parts[0] == "on"
+ goOffline := len(parts) > 1 && parts[1] == "dis"
+
+ // Push update to subscriptions
+ for topic, psd := range t.perSubs {
+ notifyOn := notifyOnOrSkip(topic, what, psd.online)
+ if notifyOn == "" {
+ continue
+ }
+
+ globals.hub.routeSrv <- &ServerComMessage{
+ TenantID: t.tenantID,
+ Pres: &MsgServerPres{
+ Topic: notifyOn,
+ What: what,
+ Src: t.name,
+ UserAgent: ua,
+ WantReply: wantReply,
+ },
+ RcptTo: topic,
+ }
+
+ if psd.online && goOffline {
+ psd.online = false
+ t.perSubs[topic] = psd
+ }
+ }
+}
+
+// Publish user's update to his/her users of interest on their 'me' topic while user's 'me' topic is offline
+// Case A: user is being deleted, "gone".
+func presUsersOfInterestOffline(tenantID types.TenantID, uid types.Uid, subs []types.Subscription, what string) {
+ // Push update to subscriptions
+ for i := range subs {
+ notifyOn := notifyOnOrSkip(subs[i].Topic, what, true)
+ if notifyOn == "" {
+ continue
+ }
+
+ globals.hub.routeSrv <- &ServerComMessage{
+ TenantID: tenantID,
+ Pres: &MsgServerPres{
+ Topic: notifyOn,
+ What: what,
+ Src: uid.UserId(),
+ WantReply: false,
+ },
+ RcptTo: subs[i].Topic,
+ }
+ }
+}
+
+// Report change to topic subscribers online, group or p2p
+//
+// Case I: User joined the topic, "on"
+// Case J: User left topic, "off"
+// Case K.2: User altered WANT (and maybe got default Given), "acs"
+// Case L.1: Admin altered GIVEN, "acs" to affected user
+// Case L.3: Admin altered GIVEN (and maybe got assigned default WANT), "acs" to admins
+// Case M: Topic unaccessible (cluster failure), "left" to everyone currently online
+// Case V.2: Messages soft deleted, "del" to one user only
+// Case W.2: Messages hard-deleted, "del"
+func (t *Topic) presSubsOnline(what, src string, params *presParams, filter *presFilters, skipSid string) {
+ // If affected user is the same as the user making the change, clear 'who'
+ actor := params.actor
+ target := params.target
+ if actor == src {
+ actor = ""
+ }
+
+ if target == src {
+ target = ""
+ }
+
+ globals.hub.routeSrv <- &ServerComMessage{
+ TenantID: t.tenantID,
+ Pres: &MsgServerPres{
+ Topic: t.xoriginal,
+ What: what,
+ Src: src,
+ Acs: params.packAcs(),
+ AcsActor: actor,
+ AcsTarget: target,
+ SeqId: params.seqID,
+ DelId: params.delID,
+ DelSeq: params.delSeq,
+ FilterIn: int(filter.filterIn),
+ FilterOut: int(filter.filterOut),
+ SingleUser: filter.singleUser,
+ ExcludeUser: filter.excludeUser,
+ },
+ RcptTo: t.name, SkipSid: skipSid,
+ }
+}
+
+// userIsPresencer returns true if the user (specified by `uid`) may receive presence notifications.
+func (t *Topic) userIsPresencer(uid types.Uid) bool {
+ var want, given types.AccessMode
+ if uid.IsZero() {
+ // For zero uids (typically for proxy sessions), return the union of all permissions.
+ want = t.modeWantUnion
+ given = t.modeGivenUnion
+ } else {
+ pud := t.perUser[uid]
+ if pud.deleted {
+ return false
+ }
+ want = pud.modeWant
+ given = pud.modeGiven
+ }
+ return (want & given).IsPresencer()
+}
+
+// Send notification to attached sessions directly, without routing though topic.
+// This is needed because the session(s) may be already disconnected by the time it's routed through topic.
+func (t *Topic) presSubsOnlineDirect(what string, params *presParams, filter *presFilters, skipSid string) {
+ msg := &ServerComMessage{
+ TenantID: t.tenantID,
+ Pres: &MsgServerPres{
+ Topic: t.xoriginal,
+ What: what,
+ Acs: params.packAcs(),
+ SeqId: params.seqID,
+ DelId: params.delID,
+ DelSeq: params.delSeq,
+ },
+ }
+
+ for s, pssd := range t.sessions {
+ if !s.isMultiplex() {
+ if skipSid == s.sid {
+ continue
+ }
+
+ pud := t.perUser[pssd.uid]
+ // Check presence filters
+ if pud.deleted || !presOfflineFilter(pud.modeGiven&pud.modeWant, what, filter) {
+ continue
+ }
+
+ if filter != nil {
+ if filter.singleUser != "" && filter.singleUser != pssd.uid.UserId() {
+ continue
+ }
+ if filter.excludeUser != "" && filter.excludeUser == pssd.uid.UserId() {
+ continue
+ }
+ }
+
+ // For p2p topics topic name is dependent on receiver.
+ // It's OK to change the pointer here because the message will be serialized in queueOut
+ // before being placed into the channel.
+ t.prepareBroadcastableMessage(msg, pssd.uid, pssd.isChanSub)
+ }
+ s.queueOut(msg)
+ }
+}
+
+// Communicates "topic unaccessible (cluster rehashing or node connection lost)" event
+// to a list of topics promting the client to resubscribe to the topics.
+func (s *Session) presTermDirect(subs []string) {
+ msg := &ServerComMessage{
+ TenantID: s.tenantID,
+ Pres: &MsgServerPres{Topic: "me", What: "term"},
+ }
+ for _, topic := range subs {
+ msg.Pres.Src = topic
+ s.queueOut(msg)
+ }
+}
+
+// Publish to topic subscribers's sessions currently offline in the topic, on their 'me'
+// Group and P2P.
+// Case E: topic came online, "on"
+// Case F: topic went offline, "off"
+// Case G: topic updated 'public', "upd", who
+// Case H: topic deleted, "gone"
+// Case K.3: user altered WANT, "acs" to admins
+// Case L.4: Admin altered GIVEN, "acs" to admins
+// Case T: message sent, "msg" to all with 'R'
+// Case W.1: messages hard-deleted, "del" to all with 'R'
+func (t *Topic) presSubsOffline(what string, params *presParams,
+ filterSource *presFilters, filterTarget *presFilters, skipSid string, offlineOnly bool) {
+ var skipTopic string
+ if offlineOnly {
+ skipTopic = t.name
+ }
+
+ for uid, pud := range t.perUser {
+ if pud.deleted || !presOfflineFilter(pud.modeGiven&pud.modeWant, what, filterSource) {
+ continue
+ }
+
+ user := uid.UserId()
+ actor := params.actor
+ target := params.target
+ if actor == user {
+ actor = ""
+ }
+
+ if target == user {
+ target = ""
+ }
+
+ globals.hub.routeSrv <- &ServerComMessage{
+ TenantID: t.tenantID,
+ Pres: &MsgServerPres{
+ Topic: "me",
+ What: what,
+ Src: t.original(uid),
+ Acs: params.packAcs(),
+ AcsActor: actor,
+ AcsTarget: target,
+ SeqId: params.seqID,
+ DelId: params.delID,
+ FilterIn: int(filterTarget.filterIn),
+ FilterOut: int(filterTarget.filterOut),
+ SingleUser: filterTarget.singleUser,
+ ExcludeUser: filterTarget.excludeUser,
+ SkipTopic: skipTopic,
+ },
+ RcptTo: user,
+ SkipSid: skipSid,
+ }
+ }
+}
+
+// Publish {info what=read|recv|kp} to topic subscribers's sessions currently offline in the topic,
+// on subscriber's 'me'. Group and P2P.
+func (t *Topic) infoSubsOffline(from types.Uid, what string, seq int, skipSid string) {
+ user := from.UserId()
+
+ for uid, pud := range t.perUser {
+ mode := pud.modeGiven & pud.modeWant
+ if pud.deleted || !mode.IsPresencer() || !mode.IsReader() {
+ continue
+ }
+
+ globals.hub.routeSrv <- &ServerComMessage{
+ TenantID: t.tenantID,
+ Info: &MsgServerInfo{
+ Topic: "me",
+ Src: t.original(uid),
+ From: user,
+ What: what,
+ SeqId: seq,
+ SkipTopic: t.name,
+ },
+ RcptTo: uid.UserId(),
+ SkipSid: skipSid,
+ }
+ }
+}
+
+// Publish {info what=call} to topic subscribers's sessions on subscriber's 'me'.
+func (t *Topic) infoCallSubsOffline(from string, target types.Uid, event string, seq int,
+ sdp json.RawMessage, skipSid string, offlineOnly bool) {
+ if target.IsZero() {
+ logs.Err.Printf("callSubs could not find target: topic %s - from %s", t.name, from)
+ return
+ }
+ pud := t.perUser[target]
+ mode := pud.modeGiven & pud.modeWant
+ if pud.deleted || !mode.IsPresencer() || !mode.IsReader() {
+ return
+ }
+ msg := &ServerComMessage{
+ TenantID: t.tenantID,
+ Info: &MsgServerInfo{
+ Topic: "me",
+ Src: t.original(target),
+ From: from,
+ What: "call",
+ Event: event,
+ SeqId: seq,
+ Payload: sdp,
+ },
+ RcptTo: target.UserId(),
+ SkipSid: skipSid,
+ }
+ if offlineOnly {
+ msg.Info.SkipTopic = t.name
+ }
+ globals.hub.routeSrv <- msg
+}
+
+// Same as presSubsOffline, but the topic has not been loaded/initialized first: offline topic, offline subscribers
+func presSubsOfflineOffline(tenantID types.TenantID, topic string, cat types.TopicCat, subs []types.Subscription, what string,
+ params *presParams, skipSid string) {
+
+ count := 0
+ original := topic
+ for i := range subs {
+ sub := &subs[i]
+ // Let "acs" and "gone" through regardless of 'P'. Don't check for deleted subscriptions:
+ // they are not passed here.
+ if !presOfflineFilter(sub.ModeWant&sub.ModeGiven, what, nil) {
+ continue
+ }
+
+ if cat == types.TopicCatP2P {
+ original = types.ParseUid(subs[(count+1)%2].User).UserId()
+ count++
+ }
+
+ user := types.ParseUid(sub.User).UserId()
+ actor := params.actor
+ target := params.target
+ if actor == user {
+ actor = ""
+ }
+
+ if target == user {
+ target = ""
+ }
+
+ globals.hub.routeSrv <- &ServerComMessage{
+ TenantID: tenantID,
+ Pres: &MsgServerPres{
+ Topic: "me",
+ What: what,
+ Src: original,
+ Acs: params.packAcs(),
+ AcsActor: actor,
+ AcsTarget: target,
+ SeqId: params.seqID,
+ DelId: params.delID,
+ },
+ RcptTo: user,
+ SkipSid: skipSid,
+ }
+ }
+}
+
+// Announce to a single user on 'me' topic
+//
+// Case K.1: User altered WANT (includes new subscription, deleted subscription)
+// Case L.2: Sharer altered GIVEN (inludes invite, eviction)
+// Case U: read/recv notification
+// Case V.1: messages soft-deleted
+func (t *Topic) presSingleUserOffline(uid types.Uid, mode types.AccessMode,
+ what string, params *presParams, skipSid string,
+ offlineOnly bool) {
+
+ var skipTopic string
+ if offlineOnly {
+ skipTopic = t.name
+ }
+
+ // ModeInvalid means the user is deleted (pud.deleted == true)
+ if mode != types.ModeInvalid && presOfflineFilter(mode, what, nil) {
+
+ user := uid.UserId()
+ actor := params.actor
+ target := params.target
+ if actor == user {
+ actor = ""
+ }
+
+ if target == user {
+ target = ""
+ }
+
+ globals.hub.routeSrv <- &ServerComMessage{
+ TenantID: t.tenantID,
+ Pres: &MsgServerPres{
+ Topic: "me",
+ What: what,
+ Src: t.original(uid),
+ SeqId: params.seqID,
+ DelId: params.delID,
+ Acs: params.packAcs(),
+ AcsActor: actor,
+ AcsTarget: target,
+ UserAgent: params.userAgent,
+ WantReply: strings.HasPrefix(what, "?unkn"),
+ SkipTopic: skipTopic,
+ },
+ RcptTo: user,
+ SkipSid: skipSid,
+ }
+ }
+}
+
+// Announce to a single user on 'me' topic. The originating topic is not used (not loaded or user
+// already unsubscribed).
+func presSingleUserOfflineOffline(tenantID types.TenantID, uid types.Uid, original, what string, params *presParams, skipSid string) {
+ user := uid.UserId()
+ actor := params.actor
+ target := params.target
+ if actor == user {
+ actor = ""
+ }
+
+ if target == user {
+ target = ""
+ }
+
+ globals.hub.routeSrv <- &ServerComMessage{
+ TenantID: tenantID,
+ Pres: &MsgServerPres{
+ Topic: "me",
+ What: what,
+ Src: original,
+ SeqId: params.seqID,
+ DelId: params.delID,
+ Acs: params.packAcs(),
+ AcsActor: actor,
+ AcsTarget: target,
+ },
+ RcptTo: uid.UserId(),
+ SkipSid: skipSid,
+ }
+}
+
+// Let other sessions of a given user know what messages are now received/read.
+// If both 'read' and 'recv' != 0 then 'read' takes precedence over 'recv'.
+// Cases U
+func (t *Topic) presPubMessageCount(uid types.Uid, mode types.AccessMode, read, recv int, skip string) {
+ var what string
+ var seq int
+ if read > 0 {
+ what = "read"
+ seq = read
+ } else if recv > 0 {
+ what = "recv"
+ seq = recv
+ }
+
+ if what != "" {
+ // Announce to user's other sessions on 'me' only if they are not attached to this topic.
+ // Attached topics will receive an {info}
+
+ t.presSingleUserOffline(uid, mode, what, &presParams{seqID: seq}, skip, true)
+ }
+}
+
+// Let other sessions of a given user know that messages are now deleted
+// Cases V.1, V.2
+func (t *Topic) presPubMessageDelete(uid types.Uid, mode types.AccessMode, delID int, list []MsgRange, skip string) {
+ if len(list) == 0 && delID <= 0 {
+ logs.Warn.Printf("Case V.1, V.2: topic[%s] invalid request - missing payload", t.name)
+ return
+ }
+
+ // This check is only needed for V.1, but it does not hurt V.2. Let's do it here for both.
+ if !t.userIsPresencer(uid) {
+ return
+ }
+
+ params := &presParams{delID: delID, delSeq: list}
+
+ // Case V.2
+ user := uid.UserId()
+ t.presSubsOnline("del", user, params, &presFilters{singleUser: user}, skip)
+
+ // Case V.1
+ t.presSingleUserOffline(uid, mode, "del", params, skip, true)
+}
+
+// Filter by permissions and notification type: check for exceptions,
+// then check if mode.IsPresencer() AND mode has at least some
+// bits specified in 'filter' (or filter is ModeNone).
+func presOfflineFilter(mode types.AccessMode, what string, pf *presFilters) bool {
+ if what == "acs" || what == "gone" {
+ return true
+ }
+ if what == "upd" && mode.IsJoiner() {
+ return true
+ }
+ return mode.IsPresencer() &&
+ (pf == nil ||
+ ((pf.filterIn == types.ModeNone || mode&pf.filterIn != 0) &&
+ (pf.filterOut == types.ModeNone || mode&pf.filterOut == 0)))
+}
diff --git a/server/push.go b/server/push.go
index e69de29..e0b305b 100644
--- a/server/push.go
+++ b/server/push.go
@@ -0,0 +1,237 @@
+/******************************************************************************
+ *
+ * Description:
+ * Push notifications handling.
+ *
+ *****************************************************************************/
+
+package main
+
+import (
+ "time"
+
+ "github.com/tinode/chat/server/push"
+ "github.com/tinode/chat/server/store/types"
+)
+
+// Subscribe or unsubscribe user to/from FCM topic (channel).
+func (t *Topic) channelSubUnsub(uid types.Uid, sub bool) {
+ push.ChannelSub(&push.ChannelReq{
+ TenantID: t.tenantID,
+ Uid: uid,
+ Channel: types.GrpToChn(t.name),
+ Unsub: !sub,
+ })
+}
+
+// Prepares a payload to be delivered to a mobile device as a push notification in response to a {data} message.
+func (t *Topic) pushForData(fromUid types.Uid, data *MsgServerData, msgMarkedAsReadBySender bool) *push.Receipt {
+ // Passing `Topic` as `t.name` for group topics and P2P topics. The p2p topic name is later rewritten for
+ // each recipient then the payload is created: p2p recipient sees the topic as the ID of the other user.
+
+ // Initialize the push receipt.
+ contentType, _ := data.Head["mime"].(string)
+ receipt := push.Receipt{
+ TenantID: t.tenantID,
+ To: make(map[types.Uid]push.Recipient, t.subsCount()),
+ Payload: push.Payload{
+ What: push.ActMsg,
+ Silent: false,
+ Topic: t.name,
+ From: data.From,
+ Timestamp: data.Timestamp,
+ SeqId: data.SeqId,
+ ContentType: contentType,
+ Content: data.Content,
+ },
+ }
+ if webrtc, found := data.Head["webrtc"].(string); found {
+ receipt.Payload.Webrtc = webrtc
+ audioOnly, _ := data.Head["aonly"].(bool)
+ receipt.Payload.AudioOnly = audioOnly
+ }
+ if replace, found := data.Head["replace"].(string); found {
+ receipt.Payload.Replace = replace
+ }
+
+ if t.isChan {
+ // Channel readers should get a push on a channel name (as an FCM topic push).
+ receipt.Channel = types.GrpToChn(t.name)
+ }
+
+ for uid, pud := range t.perUser {
+ online := pud.online
+ if uid == fromUid && online == 0 {
+ // Make sure the sender's devices receive a silent push.
+ online = 1
+ }
+
+ // Send only to those who have notifications enabled.
+ mode := pud.modeWant & pud.modeGiven
+ if mode.IsPresencer() && mode.IsReader() && !pud.deleted && !pud.isChan {
+ receipt.To[uid] = push.Recipient{
+ // Number of attached sessions the data message will be delivered to.
+ // Push notifications sent to users with non-zero online sessions will be marked silent.
+ Delivered: online,
+ // Unread counts are incremented for all recipients,
+ // and for sender only if the message wasnt't marked 'read' by the sender
+ ShouldIncrementUnreadCountInCache: uid != fromUid || !msgMarkedAsReadBySender,
+ }
+ }
+ }
+ if len(receipt.To) > 0 || receipt.Channel != "" {
+ return &receipt
+ }
+ // If there are no recipient there is no need to send the push notification.
+ return nil
+}
+
+func (t *Topic) preparePushForSubReceipt(fromUid types.Uid, now time.Time) *push.Receipt {
+ // The `Topic` in the push receipt is `t.xoriginal` for group topics, `fromUid` for p2p topics,
+ // not the t.original(fromUid) because it's the topic name as seen by the recipient, not by the sender.
+ topic := t.xoriginal
+ if t.cat == types.TopicCatP2P {
+ topic = fromUid.UserId()
+ }
+
+ // Initialize the push receipt.
+ receipt := &push.Receipt{
+ TenantID: t.tenantID,
+ To: make(map[types.Uid]push.Recipient, t.subsCount()),
+ Payload: push.Payload{
+ What: push.ActSub,
+ Silent: false,
+ Topic: topic,
+ From: fromUid.UserId(),
+ Timestamp: now,
+ SeqId: t.lastID,
+ },
+ }
+ return receipt
+}
+
+// Prepares payload to be delivered to a mobile device as a push notification in response to a new subscription in a p2p topic.
+func (t *Topic) pushForP2PSub(fromUid, toUid types.Uid, want, given types.AccessMode, now time.Time) *push.Receipt {
+ receipt := t.preparePushForSubReceipt(fromUid, now)
+ receipt.Payload.ModeWant = want
+ receipt.Payload.ModeGiven = given
+
+ receipt.To[toUid] = push.Recipient{}
+
+ return receipt
+}
+
+// Prepares payload to be delivered to a mobile device as a push notification in response to a new subscription in a group topic.
+func (t *Topic) pushForGroupSub(fromUid types.Uid, now time.Time) *push.Receipt {
+ receipt := t.preparePushForSubReceipt(fromUid, now)
+ if pud, ok := t.perUser[fromUid]; ok {
+ receipt.Payload.ModeWant = pud.modeWant
+ receipt.Payload.ModeGiven = pud.modeGiven
+ } else {
+ // Sender is not a subscriber (BUG?)
+ return nil
+ }
+
+ for uid, pud := range t.perUser {
+ // Send only to those who have notifications enabled.
+ mode := pud.modeWant & pud.modeGiven
+ if mode.IsPresencer() && mode.IsReader() && !pud.deleted && !pud.isChan {
+ receipt.To[uid] = push.Recipient{}
+ }
+ }
+ if len(receipt.To) > 0 || receipt.Channel != "" {
+ return receipt
+ }
+ return nil
+}
+
+// Prepares payload to be delivered to a mobile device as a push notification in response to owner deleting a channel.
+func pushForChanDelete(tenantID types.TenantID, topicName string, now time.Time) *push.Receipt {
+ topicName = types.GrpToChn(topicName)
+ // Initialize the push receipt.
+ return &push.Receipt{
+ TenantID: tenantID,
+ Payload: push.Payload{
+ What: push.ActSub,
+ Silent: true,
+ Topic: topicName,
+ Timestamp: now,
+ ModeWant: types.ModeNone,
+ ModeGiven: types.ModeNone,
+ },
+ Channel: topicName,
+ }
+}
+
+// Prepares payload to be delivered to a mobile device as a push notification in response to receiving "read" notification.
+func (t *Topic) pushForReadRcpt(uid types.Uid, seq int, now time.Time) *push.Receipt {
+ // The `Topic` in the push receipt is `t.xoriginal` for group topics, `fromUid` for p2p topics,
+ // not the t.original(fromUid) because it's the topic name as seen by the recipient, not by the sender.
+ topic := t.xoriginal
+ if t.cat == types.TopicCatP2P {
+ topic = uid.UserId()
+ }
+
+ // Initialize the push receipt.
+ receipt := &push.Receipt{
+ TenantID: t.tenantID,
+ To: make(map[types.Uid]push.Recipient, 1),
+ Payload: push.Payload{
+ What: push.ActRead,
+ Silent: true,
+ Topic: topic,
+ From: uid.UserId(),
+ Timestamp: now,
+ SeqId: seq,
+ },
+ }
+ receipt.To[uid] = push.Recipient{}
+ return receipt
+}
+
+// Process push notification.
+func sendPush(rcpt *push.Receipt) {
+ if rcpt == nil || globals.usersUpdate == nil {
+ return
+ }
+
+ var local *UserCacheReq
+
+ // In case of a cluster pushes will be initiated at the nodes which own the users.
+ // Sort users into local and remote.
+ if globals.cluster != nil {
+ local = &UserCacheReq{TenantID: rcpt.TenantID, PushRcpt: &push.Receipt{
+ TenantID: rcpt.TenantID,
+ Payload: rcpt.Payload,
+ Channel: rcpt.Channel,
+ To: make(map[types.Uid]push.Recipient),
+ }}
+ remote := &UserCacheReq{TenantID: rcpt.TenantID, PushRcpt: &push.Receipt{
+ TenantID: rcpt.TenantID,
+ Payload: rcpt.Payload,
+ Channel: rcpt.Channel,
+ To: make(map[types.Uid]push.Recipient),
+ }}
+
+ for uid, recipient := range rcpt.To {
+ if globals.cluster.isRemoteTopic(types.TopicKey{TenantID: rcpt.TenantID, Topic: uid.UserId()}) {
+ remote.PushRcpt.To[uid] = recipient
+ } else {
+ local.PushRcpt.To[uid] = recipient
+ }
+ }
+
+ if len(remote.PushRcpt.To) > 0 || remote.PushRcpt.Channel != "" {
+ globals.cluster.routeUserReq(remote)
+ }
+ } else {
+ local = &UserCacheReq{TenantID: rcpt.TenantID, PushRcpt: rcpt}
+ }
+
+ if len(local.PushRcpt.To) > 0 || local.PushRcpt.Channel != "" {
+ select {
+ case globals.usersUpdate <- local:
+ default:
+ }
+ }
+}
diff --git a/server/push/common/typedef.go b/server/push/common/typedef.go
index e69de29..b0b3cd1 100644
--- a/server/push/common/typedef.go
+++ b/server/push/common/typedef.go
@@ -0,0 +1,543 @@
+package common
+
+import (
+ "fmt"
+ "net/http"
+ "reflect"
+ "strings"
+
+ "github.com/tinode/chat/server/push"
+ "google.golang.org/api/googleapi"
+)
+
+// Payload to be sent for a specific notification type.
+type Payload struct {
+ // Common for APNS and Android
+ Body string `json:"body,omitempty"`
+ Title string `json:"title,omitempty"`
+ TitleLocKey string `json:"title_loc_key,omitempty"`
+ TitleLocArgs []string `json:"title_loc_args,omitempty"`
+
+ // Android
+ BodyLocKey string `json:"body_loc_key,omitempty"`
+ BodyLocArgs []string `json:"body_loc_args,omitempty"`
+ Icon string `json:"icon,omitempty"`
+ Color string `json:"color,omitempty"`
+ ClickAction string `json:"click_action,omitempty"`
+ Sound string `json:"sound,omitempty"`
+ Image string `json:"image,omitempty"`
+
+ // APNS
+ Action string `json:"action,omitempty"`
+ ActionLocKey string `json:"action_loc_key,omitempty"`
+ LaunchImage string `json:"launch_image,omitempty"`
+ LocArgs []string `json:"loc_args,omitempty"`
+ LocKey string `json:"loc_key,omitempty"`
+ Subtitle string `json:"subtitle,omitempty"`
+ SummaryArg string `json:"summary_arg,omitempty"`
+ SummaryArgCount int `json:"summary_arg_count,omitempty"`
+}
+
+// Config is the configuration of a Notification payload.
+type Config struct {
+ Enabled bool `json:"enabled,omitempty"`
+ // Common defaults for all push types.
+ Payload
+ // Configs for specific push types.
+ Msg Payload `json:"msg,omitempty"`
+ Sub Payload `json:"sub,omitempty"`
+}
+
+func (cp Payload) getStringAttr(field string) string {
+ val := reflect.ValueOf(cp).FieldByName(field)
+ if !val.IsValid() {
+ return ""
+ }
+ if val.Kind() == reflect.String {
+ return val.String()
+ }
+ return ""
+}
+
+func (cp Payload) getIntAttr(field string) int {
+ val := reflect.ValueOf(cp).FieldByName(field)
+ if !val.IsValid() {
+ return 0
+ }
+ if val.Kind() == reflect.Int {
+ return int(val.Int())
+ }
+ return 0
+}
+
+func (cc *Config) GetStringField(what, field string) string {
+ var val string
+ if what == push.ActMsg {
+ val = cc.Msg.getStringAttr(field)
+ } else if what == push.ActSub {
+ val = cc.Sub.getStringAttr(field)
+ }
+ if val == "" {
+ val = cc.Payload.getStringAttr(field)
+ }
+ return val
+}
+
+func (cc *Config) GetIntField(what, field string) int {
+ var val int
+ if what == push.ActMsg {
+ val = cc.Msg.getIntAttr(field)
+ } else if what == push.ActSub {
+ val = cc.Sub.getIntAttr(field)
+ }
+ if val == 0 {
+ val = cc.Payload.getIntAttr(field)
+ }
+ return val
+}
+
+// AndroidVisibilityType defines notification visibility constants
+// https://developer.android.com/reference/android/app/Notification.html#visibility
+type AndroidVisibilityType string
+
+const (
+ // AndroidVisibilityUnspecified if unspecified, default to `Visibility.PRIVATE`.
+ AndroidVisibilityUnspecified AndroidVisibilityType = "VISIBILITY_UNSPECIFIED"
+
+ // AndroidVisibilityPrivate show this notification on all lockscreens, but conceals
+ // sensitive or private information on secure lockscreens.
+ AndroidVisibilityPrivate AndroidVisibilityType = "PRIVATE"
+
+ // AndroidVisibilityPublic show this notification in its entirety on all lockscreens.
+ AndroidVisibilityPublic AndroidVisibilityType = "PUBLIC"
+
+ // AndroidVisibilitySecret do not reveal any part of this notification on a secure lockscreen.
+ AndroidVisibilitySecret AndroidVisibilityType = "SECRET"
+)
+
+// AndroidNotificationPriorityType defines notification priority consumeed by the client
+// after it receives the notification. Does not affect FCM sending.
+type AndroidNotificationPriorityType string
+
+const (
+ // If priority is unspecified, notification priority is set to `PRIORITY_DEFAULT`.
+ AndroidNotificationPriorityUnspecified AndroidNotificationPriorityType = "PRIORITY_UNSPECIFIED"
+
+ // Lowest notification priority. Notifications with this `PRIORITY_MIN` might not be
+ // shown to the user except under special circumstances, such as detailed notification logs.
+ AndroidNotificationPriorityMin AndroidNotificationPriorityType = "PRIORITY_MIN"
+
+ // Lower notification priority. The UI may choose to show the notifications smaller,
+ // or at a different position in the list, compared with notifications with `PRIORITY_DEFAULT`.
+ AndroidNotificationPriorityLow AndroidNotificationPriorityType = "PRIORITY_LOW"
+
+ // Default notification priority. If the application does not prioritize its own notifications,
+ // use this value for all notifications.
+ AndroidNotificationPriorityDefault AndroidNotificationPriorityType = "PRIORITY_DEFAULT"
+
+ // Higher notification priority. Use this for more important notifications or alerts.
+ // The UI may choose to show these notifications larger, or at a different position in the notification
+ // lists, compared with notifications with `PRIORITY_DEFAULT`.
+ AndroidNotificationPriorityHigh AndroidNotificationPriorityType = "PRIORITY_HIGH"
+
+ // Highest notification priority. Use this for the application's most important items that
+ // require the user's prompt attention or input.
+ AndroidNotificationPriorityMax AndroidNotificationPriorityType = "PRIORITY_MAX"
+)
+
+// AndroidPriorityType defines the server-side priorities https://goo.gl/GjONJv. It affects how soon
+// FCM sends the push.
+type AndroidPriorityType string
+
+const (
+ // Default priority for data messages. Normal priority messages won't open network
+ // connections on a sleeping device, and their delivery may be delayed to conserve
+ // the battery. For less time-sensitive messages, such as notifications of new email
+ // or other data to sync, choose normal delivery priority.
+ AndroidPriorityNormal AndroidPriorityType = "NORMAL"
+
+ // Default priority for notification messages. FCM attempts to deliver high priority
+ // messages immediately, allowing the FCM service to wake a sleeping device when possible
+ // and open a network connection to your app server. Apps with instant messaging, chat,
+ // or voice call alerts, for example, generally need to open a network connection and make
+ // sure FCM delivers the message to the device without delay. Set high priority if the message
+ // is time-critical and requires the user's immediate interaction, but beware that setting
+ // your messages to high priority contributes more to battery drain compared with normal priority messages.
+ AndroidPriorityHigh AndroidPriorityType = "HIGH"
+)
+
+// InterruptionLevelType defines the values for the APNS payload.aps.InterruptionLevel.
+type InterruptionLevelType string
+
+const (
+ // InterruptionLevelPassive is used to indicate that notification be delivered in a passive manner.
+ InterruptionLevelPassive InterruptionLevelType = "passive"
+
+ // InterruptionLevelActive is used to indicate the importance and delivery timing of a notification.
+ InterruptionLevelActive InterruptionLevelType = "active"
+
+ // InterruptionLevelTimeSensitive is used to indicate the importance and delivery timing of a notification.
+ InterruptionLevelTimeSensitive InterruptionLevelType = "time-sensitive"
+
+ // InterruptionLevelCritical is used to indicate the importance and delivery timing of a notification.
+ // This interruption level requires an approved entitlement from Apple.
+ // See: https://developer.apple.com/documentation/usernotifications/unnotificationinterruptionlevel/
+ InterruptionLevelCritical InterruptionLevelType = "critical"
+)
+
+const (
+ // A canonical UUID that identifies the notification. If there is an error sending the notification,
+ // APNs uses this value to identify the notification to your server.
+ // The canonical form is 32 lowercase hexadecimal digits, displayed in five groups separated by hyphens
+ // in the form 8-4-4-4-12. An example UUID is as follows: 123e4567-e89b-12d3-a456-42665544000
+ // If you omit this header, a new UUID is created by APNs and returned in the response.
+ HeaderApnsID = "apns-id"
+
+ // A UNIX epoch date expressed in seconds (UTC). This header identifies the date when the notification
+ // is no longer valid and can be discarded.
+ // If this value is nonzero, APNs stores the notification and tries to deliver it at least once, repeating
+ // the attempt as needed if it is unable to deliver the notification the first time. If the value is 0,
+ // APNs treats the notification as if it expires immediately and does not store the notification or attempt
+ // to redeliver it.
+ HeaderApnsExpiration = "apns-expiration"
+
+ // The priority of the notification. Specify one of the following values:
+ // 10–Send the push message immediately. Notifications with this priority must trigger an alert, sound,
+ // or badge on the target device. It is an error to use this priority for a push notification that
+ // contains only the content-available key.
+ // 5—Send the push message at a time that takes into account power considerations for the device.
+ // Notifications with this priority might be grouped and delivered in bursts. They are throttled,
+ // and in some cases are not delivered.
+ // If you omit this header, the APNs server sets the priority to 10.
+ HeaderApnsPriority = "apns-priority"
+
+ // The topic of the remote notification, which is typically the bundle ID for your app.
+ // The certificate you create in your developer account must include the capability for this topic.
+ // If your certificate includes multiple topics, you must specify a value for this header.
+ // If you omit this request header and your APNs certificate does not specify multiple topics,
+ // the APNs server uses the certificate’s Subject as the default topic.
+ // If you are using a provider token instead of a certificate, you must specify a value for this
+ // request header. The topic you provide should be provisioned for the your team named in your developer account.
+ HeaderApnsTopic = "apns-topic"
+
+ // Multiple notifications with the same collapse identifier are displayed to the user as a single notification.
+ // The value of this key must not exceed 64 bytes. For more information, see Quality of Service,
+ // Store-and-Forward, and Coalesced Notifications.
+ HeaderApnsCollapseID = "apns-collapse-id"
+
+ // The value of this header must accurately reflect the contents of your notification’s payload.
+ // If there’s a mismatch, or if the header is missing on required systems, APNs may return an error,
+ // delay the delivery of the notification, or drop it altogether.
+ HeaderApnsPushType = "apns-push-type"
+)
+
+type ApnsPushTypeType string
+
+const (
+ // Use the alert push type for notifications that trigger a user interaction—for example, an alert, badge, or sound.
+ // If you set this push type, the apns-topic header field must use your app’s bundle ID as the topic.
+ // For more information, see Generating a remote notification.
+ // If the notification requires immediate action from the user, set notification priority to 10; otherwise use 5.
+ ApnsPushTypeAlert ApnsPushTypeType = "alert"
+
+ // Use the background push type for notifications that deliver content in the background, and don’t trigger any user interactions.
+ // If you set this push type, the apns-topic header field must use your app’s bundle ID as the topic. Always use priority 5.
+ // Using priority 10 is an error. For more information, see Pushing Background Updates to Your App.
+ ApnsPushTypeBackground ApnsPushTypeType = "background"
+
+ // Use the location push type for notifications that request a user’s location. If you set this push type,
+ // the apns-topic header field must use your app’s bundle ID with .location-query appended to the end.
+ // If the location query requires an immediate response from the Location Push Service Extension, set notification
+ // apns-priority to 10; otherwise, use 5. The location push type supports only token-based authentication.
+ ApnsPushTypeLocation ApnsPushTypeType = "location"
+
+ // Use the voip push type for notifications that provide information about an incoming Voice-over-IP (VoIP) call.
+ // For more information, see Responding to VoIP Notifications from PushKit.
+ // If you set this push type, the apns-topic header field must use your app’s bundle ID with .voip appended to the end.
+ // If you’re using certificate-based authentication, you must also register the certificate for VoIP services.
+ // The topic is then part of the 1.2.840.113635.100.6.3.4 or 1.2.840.113635.100.6.3.6 extension.
+ ApnsPushTypeVoip ApnsPushTypeType = "voip"
+
+ // Use the fileprovider push type to signal changes to a File Provider extension. If you set this push type,
+ // the apns-topic header field must use your app’s bundle ID with .pushkit.fileprovider appended to the end.
+ // For more information, see Using Push Notifications to Signal Changes.
+ ApnsPushTypeFileprovider ApnsPushTypeType = "fileprovider"
+)
+
+// Aps is the APNS payload. See explanation here:
+// https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/generating_a_remote_notification#2943363
+type Aps struct {
+ Alert *ApsAlert `json:"alert,omitempty"`
+ Badge int `json:"badge,omitempty"`
+ Category string `json:"category,omitempty"`
+ ContentAvailable int `json:"content-available,omitempty"`
+ InterruptionLevel InterruptionLevelType `json:"interruption-level,omitempty"`
+ MutableContent int `json:"mutable-content,omitempty"`
+ RelevanceScore any `json:"relevance-score,omitempty"`
+ Sound any `json:"sound,omitempty"`
+ ThreadID string `json:"thread-id,omitempty"`
+ URLArgs []string `json:"url-args,omitempty"`
+}
+
+// ApsAlert is the content of the aps.Alert field.
+type ApsAlert struct {
+ Action string `json:"action,omitempty"`
+ ActionLocKey string `json:"action-loc-key,omitempty"`
+ Body string `json:"body,omitempty"`
+ LaunchImage string `json:"launch-image,omitempty"`
+ LocArgs []string `json:"loc-args,omitempty"`
+ LocKey string `json:"loc-key,omitempty"`
+ Title string `json:"title,omitempty"`
+ Subtitle string `json:"subtitle,omitempty"`
+ TitleLocArgs []string `json:"title-loc-args,omitempty"`
+ TitleLocKey string `json:"title-loc-key,omitempty"`
+ SummaryArg string `json:"summary-arg,omitempty"`
+ SummaryArgCount int `json:"summary-arg-count,omitempty"`
+}
+
+// FCM error codes
+const (
+ // No more information is available about this error.
+ ErrorUnspecified = "UNSPECIFIED_ERROR"
+
+ // Request parameters were invalid (HTTP error code = 400). An extension of type google.rpc.BadRequest is returned
+ // to specify which field was invalid.
+ // Potential causes:
+ // - Invalid registration: Check the format of the registration token you pass to the server. Make sure it matches
+ // the registration token the client app receives from registering with Firebase Notifications.
+ // Do not truncate or add additional characters.
+ // - Invalid package name: Make sure the message was addressed to a registration token whose package name matches
+ // the value passed in the request.
+ // - Message too big: Check that the total size of the payload data included in a message does not exceed FCM limits:
+ // 4096 bytes for most messages, or 2048 bytes in the case of messages to topics. This includes both the keys and the values.
+ // - Invalid data key: Check that the payload data does not contain a key (such as from, or gcm, or any value prefixed
+ // by google) that is used internally by FCM. Note that some words (such as collapse_key) are also used by FCM but are
+ // allowed in the payload, in which case the payload value will be overridden by the FCM value.
+ // - Invalid TTL: Check that the value used in ttl is an integer representing a duration in seconds between 0 and
+ // 2,419,200 (4 weeks).
+ // - Invalid parameters: Check that the provided parameters have the right name and type.
+ ErrorInvalidArgument = "INVALID_ARGUMENT"
+
+ // App instance was unregistered from FCM (HTTP error code = 404). This usually means that the token used is no
+ // longer valid and a new one must be used.
+ // This error can be caused by missing registration tokens, or unregistered tokens.
+ // - Missing Registration: If the message's target is a token value, check that the request contains a registration token.
+ // - Not registered: An existing registration token may cease to be valid in a number of scenarios, including:
+ // - If the client app unregisters with FCM.
+ // - If the client app is automatically unregistered, which can happen if the user uninstalls the application.
+ // For example, on iOS, if the APNS Feedback Service reported the APNS token as invalid.
+ // - If the registration token expires (for example, Google might decide to refresh registration tokens,
+ // or the APNS token has expired for iOS devices).
+ // - If the client app is updated but the new version is not configured to receive messages.
+ // For all these cases, remove this registration token from the app server and stop using it to send messages.
+ ErrorUnregistered = "UNREGISTERED"
+
+ // The authenticated sender ID is different from the sender ID for the registration token (HTTP error code = 403).
+ // A registration token is tied to a certain group of senders. When a client app registers for FCM, it must specify
+ // which senders are allowed to send messages. You should use one of those sender IDs when sending messages to
+ // the client app. If you switch to a different sender, the existing registration tokens won't work.
+ ErrorSenderIDMismatch = "SENDER_ID_MISMATCH"
+
+ // Sending limit exceeded for the message target (HTTP error code = 429). An extension of type google.rpc.QuotaFailure
+ // is returned to specify which quota got exceeded. This error can be caused by exceeded message rate quota,
+ // exceeded device message rate quota, or exceeded topic message rate quota.
+ // - Message rate exceeded: The sending rate of messages is too high. Reduce the number of messages sent and use
+ // exponential backoff to retry sending.
+ // - Device message rate exceeded: The rate of messages to a particular device is too high. If an iOS app sends
+ // messages at a rate exceeding APNs limits, it may receive this error message. Reduce the number of messages
+ // sent to this device and use exponential backoff to retry sending.
+ // - Topic message rate exceeded: The rate of messages to subscribers to a particular topic is too high.
+ // Reduce the number of messages sent for this topic and use exponential backoff to retry sending.
+ ErrorQuotaExceeded = "QUOTA_EXCEEDED"
+
+ // The server is overloaded (HTTP error code = 503). The server couldn't process the request in time. Retry the
+ // same request, but you must:
+ // - Honor the Retry-After header if it is included in the response from the FCM Connection Server.
+ // - Implement exponential back-off in your retry mechanism. (e.g. if you waited one second before the first retry,
+ // wait at least two second before the next one, then 4 seconds and so on). If you're sending multiple messages,
+ // delay each one independently by an additional random amount to avoid issuing a new request for all messages
+ // at the same time. Senders that cause problems risk being denylisted.
+ ErrorUnavailable = "UNAVAILABLE"
+
+ // An unknown internal error occurred (HTTP error code = 500). The server encountered an error while trying to process
+ // the request. You could retry the same request following the requirements listed in "Timeout" (see row above).
+ // If the error persists, please contact Firebase support.
+ ErrorInternal = "INTERNAL"
+
+ // APNs certificate or web push auth key was invalid or missing (HTTP error code = 401). A message targeted to an
+ // iOS device or a web push registration could not be sent. Check the validity of your development and production
+ // credentials.
+ ErrorThirdPartyAuth = "THIRD_PARTY_AUTH_ERROR"
+)
+
+// APNS error messages
+const (
+ // The collapse identifier exceeds the maximum allowed size (HTTP error code = 400).
+ ErrorApnsBadCollapseId = "BadCollapseId"
+
+ // The specified device token was bad. Verify that the request contains a valid token and that the
+ // token matches the environment (HTTP error code = 400).
+ ErrorApnsBadDeviceToken = "BadDeviceToken"
+
+ // The apns-expiration value is bad (HTTP error code = 400).
+ ErrorApnsBadExpirationDate = "BadExpirationDate"
+
+ // The apns-id value is bad (HTTP error code = 400).
+ ErrorApnsBadMessageId = "BadMessageId"
+
+ // The apns-priority value is bad (HTTP error code = 400).
+ ErrorApnsBadPriority = "BadPriority"
+
+ // The apns-topic was invalid (HTTP error code = 400).
+ ErrorApnsBadTopic = "BadTopic"
+
+ // The device token does not match the specified topic (HTTP error code = 400).
+ ErrorApnsDeviceTokenNotForTopic = "DeviceTokenNotForTopic"
+
+ // One or more headers were repeated (HTTP error code = 400).
+ ErrorApnsDuplicateHeaders = "DuplicateHeaders"
+
+ // Idle time out (HTTP error code = 400).
+ ErrorApnsIdleTimeout = "IdleTimeout"
+
+ // The device token is not specified in the request :path. Verify that the :path header
+ // contains the device token (HTTP error code = 400).
+ ErrorApnsMissingDeviceToken = "MissingDeviceToken"
+
+ // The apns-topic header of the request was not specified and was required.
+ // The apns-topic header is mandatory when the client is connected using a certificate
+ // that supports multiple topics (HTTP error code = 400).
+ ErrorApnsMissingTopic = "MissingTopic"
+
+ // The message payload was empty (HTTP error code = 400).
+ ErrorApnsPayloadEmpty = "PayloadEmpty"
+
+ // Pushing to this topic is not allowed (HTTP error code = 400).
+ ErrorApnsTopicDisallowed = "TopicDisallowed"
+
+ // The certificate was bad (HTTP error code = 403).
+ ErrorApnsBadCertificate = "BadCertificate"
+
+ // The client certificate was for the wrong environment (HTTP error code = 403).
+ ErrorApnsBadCertificateEnvironment = "BadCertificateEnvironment"
+
+ // The provider token is stale and a new token should be generated (HTTP error code = 403).
+ ErrorApnsExpiredProviderToken = "ExpiredProviderToken"
+
+ // The specified action is not allowed (HTTP error code = 403).
+ ErrorApnsForbidden = "Forbidden"
+
+ // The provider token is not valid or the token signature could not be verified (HTTP error code = 403).
+ ErrorApnsInvalidProviderToken = "InvalidProviderToken"
+
+ // No provider certificate was used to connect to APNs and Authorization header was missing
+ // or no provider token was specified (HTTP error code = 403).
+ ErrorApnsMissingProviderToken = "MissingProviderToken"
+
+ // The request contained a bad :path value (HTTP error code = 404).
+ ErrorApnsBadPath = "BadPath"
+
+ // The specified :method was not POST (HTTP error code = 405).
+ ErrorApnsMethodNotAllowed = "MethodNotAllowed"
+
+ // The device token is inactive for the specified topic (HTTP error code = 410).
+ ErrorApnsUnregistered = "Unregistered"
+
+ // The message payload was too large. See Creating the Remote Notification Payload
+ // for details on maximum payload size (HTTP error code = 413).
+ ErrorApnsPayloadTooLarge = "PayloadTooLarge"
+
+ // The provider token is being updated too often (HTTP error code = 429).
+ ErrorApnsTooManyProviderTokenUpdates = "TooManyProviderTokenUpdates"
+
+ // Too many requests were made consecutively to the same device token (HTTP error code = 429).
+ ErrorApnsTooManyRequests = "TooManyRequests"
+
+ // An internal server error occurred (HTTP error code = 500).
+ ErrorApnsInternalServerError = "InternalServerError"
+
+ // The service is unavailable (HTTP error code = 503).
+ ErrorApnsServiceUnavailable = "ServiceUnavailable"
+
+ // The server is shutting down (HTTP error code = 503).
+ ErrorApnsShutdown = "Shutdown"
+)
+
+// GApiError stores a simplified representation of an error returned by a call to Google API.
+type GApiError struct {
+ HttpCode int
+ // This one is not informative, but can be logged for user consideration.
+ ErrMessage string
+ // FCM error code, informative but may be missing.
+ FcmErrCode string
+ // Extended error info dependent on the fcmErrCode.
+ ExtendedInfo string
+}
+
+// DecodeGoogleApiError converts very complex googleapi.Error to a bit more manageable structure.
+func DecodeGoogleApiError(err error) (decoded *GApiError, errs []error) {
+ decoded = &GApiError{}
+ if gerr, ok := err.(*googleapi.Error); ok {
+ // HTTP status code.
+ decoded.HttpCode = gerr.Code
+ decoded.ErrMessage = gerr.Message
+ for _, errInfo := range gerr.Errors {
+ decoded.ErrMessage += "; " + errInfo.Reason + "/" + errInfo.Message
+ }
+
+ // Decode the FCM error.
+ for _, iface := range gerr.Details {
+ details, ok := iface.(map[string]any)
+ if !ok {
+ errs = append(errs, fmt.Errorf("error.Details unrecognized format %T", iface))
+ continue
+ }
+ switch details["@type"] {
+ case "type.googleapis.com/google.firebase.fcm.v1.FcmError":
+ if errCode, ok := details["errorCode"].(string); ok {
+ if decoded.FcmErrCode != "" {
+ // This has not been observed but FCM is uncler if it can happen.
+ errs = append(errs, fmt.Errorf("multiple FcmError codes '%s', '%s'", errCode, decoded.FcmErrCode))
+ } else {
+ decoded.FcmErrCode = errCode
+ }
+ } else {
+ errs = append(errs, fmt.Errorf("error.Details errorCode is not a string: %T", details["errorCode"]))
+ }
+ case "type.googleapis.com/google.rpc.BadRequest":
+ // dst.fcmErrCode == INVALID_ARGUMENT
+ if fieldViolations, ok := details["fieldViolations"].([]any); !ok {
+ errs = append(errs, fmt.Errorf("wrong type of error.Details 'fieldViolations': %T", details["fieldViolations"]))
+ } else {
+ var fields []string
+ for _, violationIface := range fieldViolations {
+ if violation, ok := violationIface.(map[string]any); !ok {
+ errs = append(errs, fmt.Errorf("wrong type of error.Details.fieldViolations item: %T", iface))
+ } else if field, ok := violation["field"].(string); ok && field != "" {
+ fields = append(fields, field)
+ } else {
+ errs = append(errs, fmt.Errorf("error.Details 'fieldViolation' has no 'field': %T, %s", violation["field"], violation["description"]))
+ }
+ }
+ decoded.ExtendedInfo = strings.Join(fields, ",")
+ }
+ case "type.googleapis.com/google.rpc.QuotaFailure":
+ // dst.fcmErrCode == QUOTA_EXCEEDED
+ // TODO: this error has not been observed, don't know how to handle it.
+ errs = append(errs, fmt.Errorf("quota exceeded %v", details))
+ default:
+ errs = append(errs, fmt.Errorf("unknown error '@type': %v", details))
+ }
+ }
+ } else {
+ decoded.HttpCode = http.StatusBadRequest
+ decoded.ErrMessage = err.Error()
+ errs = append(errs, fmt.Errorf("not googleapi.Error %w", err))
+ }
+
+ if decoded.FcmErrCode == "" {
+ decoded.FcmErrCode = string(ErrorUnspecified)
+ }
+
+ return
+}
diff --git a/server/push/fcm/README.md b/server/push/fcm/README.md
index e69de29..0460895 100644
--- a/server/push/fcm/README.md
+++ b/server/push/fcm/README.md
@@ -0,0 +1,25 @@
+# FCM push adapter
+
+This adapter sends push notifications to mobile clients and web browsers using [Google FCM](https://firebase.google.com/docs/cloud-messaging/). As of the time of this writing it supports Android with [Play Services](https://developers.google.com/android/guides/overview), iOS devices, and all major web browsers [excluding Safari](https://caniuse.com/#feat=push-api).
+
+This adapter requires you to obtain your own credentials from Goole Firebase. If you want to use iOS and Android mobile apps with your service, they must be recompiled with your credentials obtained from Google. If you do not want to recompile mobile clients, consider using TNPG adapter instead.
+
+
+## Configuring FCM adapter
+
+### Server and TinodeWeb
+
+1. Create a project at https://firebase.google.com/ if you have not done so already.
+2. Follow instructions at https://cloud.google.com/iam/docs/creating-managing-service-account-keys to download the credentials file.
+3. Update the server config [`tinode.conf`](../server/tinode.conf#L255), section `"push"` -> `"name": "fcm"`. Do _ONE_ of the following:
+ * _Either_ enter the path to the downloaded credentials file into `"credentials_file"`.
+ * _OR_ copy the file contents to `"credentials"`.
+ Remove the other entry. I.e. if you have updated `"credentials_file"`, remove `"credentials"` and vice versa.
+4. Update [TinodeWeb](/tinode/webapp/) config [`firebase-init.js`](https://github.com/tinode/webapp/blob/master/firebase-init.js): update `apiKey`, `messagingSenderId`, `projectId`, `appId`, `messagingVapidKey`. See more info at https://github.com/tinode/webapp/#push_notifications
+
+### iOS and Android
+
+1. If you are using an Android client, add `google-services.json` to [Tindroid](/tinode/tindroid/) by following instructions at https://developers.google.com/android/guides/google-services-plugin and recompile the client. You may also optionally submit it to Google Play Store.
+See more info at https://github.com/tinode/tindroid/#push_notifications
+2. If you are using an iOS client, add `GoogleService-Info.plist` to [Tinodios](/tinode/ios/) by following instructions at https://firebase.google.com/docs/cloud-messaging/ios/client) and recompile the client. You may optionally submit the app to Apple AppStore.
+See more info at https://github.com/tinode/ios/#push_notifications
diff --git a/server/push/fcm/payload.go b/server/push/fcm/payload.go
index e69de29..e76c932 100644
--- a/server/push/fcm/payload.go
+++ b/server/push/fcm/payload.go
@@ -0,0 +1,388 @@
+package fcm
+
+import (
+ "encoding/json"
+ "errors"
+ "strconv"
+ "time"
+
+ fcmv1 "google.golang.org/api/fcm/v1"
+
+ "github.com/tinode/chat/server/drafty"
+ "github.com/tinode/chat/server/logs"
+ "github.com/tinode/chat/server/push"
+ "github.com/tinode/chat/server/push/common"
+ "github.com/tinode/chat/server/store"
+ t "github.com/tinode/chat/server/store/types"
+ "maps"
+)
+
+const (
+ // TTL of a VOIP push notification in seconds.
+ voipTimeToLive = 10
+ // TTL of a regular push notification in seconds.
+ defaultTimeToLive = 3600
+)
+
+func payloadToData(pl *push.Payload) (map[string]string, error) {
+ if pl == nil {
+ return nil, errors.New("empty push payload")
+ }
+ data := make(map[string]string)
+ var err error
+ data["what"] = pl.What
+ if pl.Silent {
+ data["silent"] = "true"
+ }
+ data["topic"] = pl.Topic
+ data["ts"] = pl.Timestamp.Format(time.RFC3339Nano)
+ // Must use "xfrom" because "from" is a reserved word. Google did not bother to document it anywhere.
+ data["xfrom"] = pl.From
+ if pl.What == push.ActMsg {
+ data["seq"] = strconv.Itoa(pl.SeqId)
+ if pl.ContentType != "" {
+ data["mime"] = pl.ContentType
+ }
+
+ // Convert Drafty content to plain text (clients 0.16 and below).
+ data["content"], err = drafty.PlainText(pl.Content)
+ if err != nil {
+ return nil, err
+ }
+ // Trim long strings to 128 runes.
+ // Check byte length first and don't waste time converting short strings.
+ if len(data["content"]) > push.MaxPayloadLength {
+ runes := []rune(data["content"])
+ if len(runes) > push.MaxPayloadLength {
+ data["content"] = string(runes[:push.MaxPayloadLength]) + "…"
+ }
+ }
+
+ // Rich content for clients version 0.17 and above.
+ data["rc"], err = drafty.Preview(pl.Content, push.MaxPayloadLength)
+
+ if pl.Webrtc != "" {
+ data["webrtc"] = pl.Webrtc
+ if pl.AudioOnly {
+ data["aonly"] = "true"
+ }
+ // Video call push notifications are silent.
+ data["silent"] = "true"
+ }
+ if pl.Replace != "" {
+ // Notification of a message edit should be silent too.
+ data["silent"] = "true"
+ data["replace"] = pl.Replace
+ }
+ if err != nil {
+ return nil, err
+ }
+ } else if pl.What == push.ActSub {
+ data["modeWant"] = pl.ModeWant.String()
+ data["modeGiven"] = pl.ModeGiven.String()
+ } else if pl.What == push.ActRead {
+ data["seq"] = strconv.Itoa(pl.SeqId)
+ data["silent"] = "true"
+ } else {
+ return nil, errors.New("unknown push type")
+ }
+ return data, nil
+}
+
+func clonePayload(src map[string]string) map[string]string {
+ dst := make(map[string]string, len(src))
+ maps.Copy(dst, src)
+ return dst
+}
+
+// PrepareV1Notifications creates notification payloads ready to be posted
+// to push notification server for the provided receipt.
+func PrepareV1Notifications(rcpt *push.Receipt, config *configType) ([]*fcmv1.Message, []t.Uid) {
+ data, err := payloadToData(&rcpt.Payload)
+ if err != nil {
+ logs.Warn.Println("fcm push: could not parse payload:", err)
+ return nil, nil
+ }
+
+ // Device IDs to send pushes to.
+ var devices map[t.Uid][]t.DeviceDef
+ // Count of device IDs to push to.
+ var count int
+ // Devices which were online in the topic when the message was sent.
+ skipDevices := make(map[string]struct{})
+ if len(rcpt.To) > 0 {
+ // List of UIDs for querying the database
+
+ uids := make([]t.Uid, len(rcpt.To))
+ i := 0
+ for uid, to := range rcpt.To {
+ uids[i] = uid
+ i++
+ // Some devices were online and received the message. Skip them.
+ for _, deviceID := range to.Devices {
+ skipDevices[deviceID] = struct{}{}
+ }
+ }
+ devices, count, err = store.Devices.GetAll(rcpt.TenantID, uids...)
+ if err != nil {
+ logs.Warn.Println("fcm push: db error", err)
+ return nil, nil
+ }
+ }
+ if count == 0 && rcpt.Channel == "" {
+ return nil, nil
+ }
+
+ if config == nil {
+ // config is nil when called from tnpg adapter; provide a blank one for simplicity.
+ config = &configType{}
+ }
+
+ var messages []*fcmv1.Message
+ var uids []t.Uid
+ for uid, devList := range devices {
+ topic := rcpt.Payload.Topic
+ userData := data
+ tcat := t.GetTopicCat(topic)
+ if rcpt.To[uid].Delivered > 0 || tcat == t.TopicCatP2P {
+ userData = clonePayload(data)
+ // Fix topic name for P2P pushes.
+ if tcat == t.TopicCatP2P {
+ topic, _ = t.P2PNameForUser(uid, topic)
+ userData["topic"] = topic
+ }
+ // Silence the push for user who have received the data interactively.
+ if rcpt.To[uid].Delivered > 0 {
+ userData["silent"] = "true"
+ }
+ }
+
+ for i := range devList {
+ d := &devList[i]
+ if _, ok := skipDevices[d.DeviceId]; !ok && d.DeviceId != "" {
+ msg := fcmv1.Message{
+ Token: d.DeviceId,
+ Data: userData,
+ }
+
+ switch d.Platform {
+ case "android":
+ msg.Android = androidNotificationConfig(rcpt.Payload.What, topic, userData, config)
+ case "ios":
+ msg.Apns = apnsNotificationConfig(rcpt.Payload.What, topic, userData, rcpt.To[uid].Unread, config)
+ case "web":
+ if config != nil && config.Webpush != nil && config.Webpush.Enabled {
+ msg.Webpush = &fcmv1.WebpushConfig{}
+ }
+ case "":
+ // ignore
+ default:
+ logs.Warn.Println("fcm: unknown device platform", d.Platform)
+ }
+
+ uids = append(uids, uid)
+ messages = append(messages, &msg)
+ }
+ }
+ }
+
+ if rcpt.Channel != "" {
+ topic := TenantChannelName(rcpt.TenantID, rcpt.Channel)
+ userData := clonePayload(data)
+ userData["topic"] = rcpt.Channel
+ // Channel receiver should not know the ID of the message sender.
+ delete(userData, "xfrom")
+ msg := fcmv1.Message{
+ Topic: topic,
+ Data: userData,
+ }
+
+ // We don't know the platform of the receiver, must provide payload for all platforms.
+ msg.Android = androidNotificationConfig(rcpt.Payload.What, topic, userData, config)
+ msg.Apns = apnsNotificationConfig(rcpt.Payload.What, topic, userData, 0, config)
+ // TODO: add webpush payload.
+ messages = append(messages, &msg)
+ // UID is not used in handling Topic pushes, but should keep the same count as messages.
+ uids = append(uids, t.ZeroUid)
+ }
+
+ return messages, uids
+}
+
+func TenantChannelName(tenantID t.TenantID, channel string) string {
+ if !tenantID.IsValid() || channel == "" {
+ return channel
+ }
+ return "tenant-" + strconv.FormatInt(int64(tenantID), 10) + "-" + channel
+}
+
+// DevicesForUser loads device IDs of the given user.
+func DevicesForUser(tenantID t.TenantID, uid t.Uid) []string {
+ ddef, count, err := store.Devices.GetAll(tenantID, uid)
+ if err != nil {
+ logs.Warn.Println("fcm devices for user: db error", err)
+ return nil
+ }
+
+ if count == 0 {
+ return nil
+ }
+
+ devices := make([]string, count)
+ for i, dd := range ddef[uid] {
+ devices[i] = dd.DeviceId
+ }
+ return devices
+}
+
+// ChannelsForUser loads user's channel subscriptions with P permission.
+func ChannelsForUser(tenantID t.TenantID, uid t.Uid) []string {
+ channels, err := store.Users.GetChannels(tenantID, uid)
+ if err != nil {
+ logs.Warn.Println("fcm channels for user: db error", err)
+ return nil
+ }
+ return channels
+}
+
+func androidNotificationConfig(what, topic string, data map[string]string, config *configType) *fcmv1.AndroidConfig {
+ timeToLive := strconv.Itoa(defaultTimeToLive) + "s"
+ if config != nil && config.TimeToLive > 0 {
+ timeToLive = strconv.Itoa(config.TimeToLive) + "s"
+ }
+
+ if what == push.ActRead {
+ return &fcmv1.AndroidConfig{
+ Priority: string(common.AndroidPriorityNormal),
+ Notification: nil,
+ Ttl: timeToLive,
+ }
+ }
+
+ _, videoCall := data["webrtc"]
+ if videoCall {
+ timeToLive = "0s"
+ }
+
+ // Sending priority.
+ priority := string(common.AndroidPriorityHigh)
+ ac := &fcmv1.AndroidConfig{
+ Priority: priority,
+ Ttl: timeToLive,
+ }
+
+ // When this notification type is included and the app is not in the foreground
+ // Android won't wake up the app and won't call FirebaseMessagingService:onMessageReceived.
+ // See dicussion: https://github.com/firebase/quickstart-js/issues/71
+ if config.Android == nil || !config.Android.Enabled {
+ return ac
+ }
+
+ body := config.Android.GetStringField(what, "Body")
+ if body == "$content" {
+ body = data["content"]
+ }
+
+ // Client-side display priority.
+ priority = string(common.AndroidNotificationPriorityHigh)
+ if videoCall {
+ priority = string(common.AndroidNotificationPriorityMax)
+ }
+
+ ac.Notification = &fcmv1.AndroidNotification{
+ // Android uses Tag value to group notifications together:
+ // show just one notification per topic.
+ Tag: topic,
+ NotificationPriority: priority,
+ Visibility: string(common.AndroidVisibilityPrivate),
+ TitleLocKey: config.Android.GetStringField(what, "TitleLocKey"),
+ Title: config.Android.GetStringField(what, "Title"),
+ BodyLocKey: config.Android.GetStringField(what, "BodyLocKey"),
+ Body: body,
+ Icon: config.Android.GetStringField(what, "Icon"),
+ Color: config.Android.GetStringField(what, "Color"),
+ ClickAction: config.Android.GetStringField(what, "ClickAction"),
+ }
+
+ return ac
+}
+
+func apnsShouldPresentAlert(what, callStatus, isSilent string, config *configType) bool {
+ return config.Apns != nil && config.Apns.Enabled && what != push.ActRead && callStatus == "" && isSilent == ""
+}
+
+func apnsNotificationConfig(what, topic string, data map[string]string, unread int, config *configType) *fcmv1.ApnsConfig {
+ callStatus := data["webrtc"]
+ expires := time.Now().UTC().Add(time.Duration(defaultTimeToLive) * time.Second)
+ if config.TimeToLive > 0 {
+ expires = time.Now().UTC().Add(time.Duration(config.TimeToLive) * time.Second)
+ }
+ bundleId := config.ApnsBundleID
+ pushType := common.ApnsPushTypeAlert
+ priority := 10
+ interruptionLevel := common.InterruptionLevelTimeSensitive
+ if callStatus == "started" {
+ // Send VOIP push only when a new call is started, otherwise send normal alert.
+ interruptionLevel = common.InterruptionLevelCritical
+ // FIXME: PushKit notifications do not work with the current FCM adapter.
+ // Using normal pushes as a poor-man's replacement for VOIP pushes.
+ // Uncomment the following two lines when FCM fixes its problem or when we switch to
+ // a different adapter.
+ // pushType = common.ApnsPushTypeVoip
+ // bundleId += ".voip"
+ expires = time.Now().UTC().Add(time.Duration(voipTimeToLive) * time.Second)
+ } else if what == push.ActRead {
+ priority = 5
+ interruptionLevel = common.InterruptionLevelPassive
+ pushType = common.ApnsPushTypeBackground
+ }
+
+ apsPayload := common.Aps{
+ Badge: unread,
+ ContentAvailable: 1,
+ MutableContent: 1,
+ InterruptionLevel: interruptionLevel,
+ Sound: "default",
+ ThreadID: topic,
+ }
+
+ // Do not present alert for read notifications and video calls.
+ if apnsShouldPresentAlert(what, callStatus, data["silent"], config) {
+ body := config.Apns.GetStringField(what, "Body")
+ if body == "$content" {
+ body = data["content"]
+ }
+
+ apsPayload.Alert = &common.ApsAlert{
+ Action: config.Apns.GetStringField(what, "Action"),
+ ActionLocKey: config.Apns.GetStringField(what, "ActionLocKey"),
+ Body: body,
+ LaunchImage: config.Apns.GetStringField(what, "LaunchImage"),
+ LocKey: config.Apns.GetStringField(what, "LocKey"),
+ Title: config.Apns.GetStringField(what, "Title"),
+ Subtitle: config.Apns.GetStringField(what, "Subtitle"),
+ TitleLocKey: config.Apns.GetStringField(what, "TitleLocKey"),
+ SummaryArg: config.Apns.GetStringField(what, "SummaryArg"),
+ SummaryArgCount: config.Apns.GetIntField(what, "SummaryArgCount"),
+ }
+ }
+
+ payload, err := json.Marshal(map[string]any{"aps": apsPayload})
+ if err != nil {
+ return nil
+ }
+ headers := map[string]string{
+ common.HeaderApnsExpiration: strconv.FormatInt(expires.Unix(), 10),
+ common.HeaderApnsPriority: strconv.Itoa(priority),
+ common.HeaderApnsTopic: bundleId,
+ common.HeaderApnsCollapseID: topic,
+ common.HeaderApnsPushType: string(pushType),
+ }
+
+ ac := &fcmv1.ApnsConfig{
+ Headers: headers,
+ Payload: payload,
+ }
+
+ return ac
+}
diff --git a/server/push/fcm/push_fcm.go b/server/push/fcm/push_fcm.go
index e69de29..4380dc5 100644
--- a/server/push/fcm/push_fcm.go
+++ b/server/push/fcm/push_fcm.go
@@ -0,0 +1,273 @@
+// Package fcm implements push notification plugin for Google FCM backend.
+// Push notifications for Android, iOS and web clients are sent through Google's Firebase Cloud Messaging service.
+// Package fcm is push notification plugin using Google FCM.
+// https://firebase.google.com/docs/cloud-messaging
+package fcm
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "strings"
+
+ fbase "firebase.google.com/go"
+ legacy "firebase.google.com/go/messaging"
+ fcmv1 "google.golang.org/api/fcm/v1"
+
+ "github.com/tinode/chat/server/logs"
+ "github.com/tinode/chat/server/push"
+ "github.com/tinode/chat/server/push/common"
+ "github.com/tinode/chat/server/store"
+ "github.com/tinode/chat/server/store/types"
+
+ "golang.org/x/oauth2/google"
+ "google.golang.org/api/option"
+)
+
+var handler Handler
+
+const (
+ // Size of the input channel buffer.
+ bufferSize = 1024
+
+ // The number of push messages sent in one batch. FCM constant.
+ pushBatchSize = 100
+
+ // The number of sub/unsub requests sent in one batch. FCM constant.
+ subBatchSize = 1000
+)
+
+// Handler represents the push handler; implements push.PushHandler interface.
+type Handler struct {
+ input chan *push.Receipt
+ channel chan *push.ChannelReq
+ stop chan bool
+ projectID string
+
+ client *legacy.Client
+ v1 *fcmv1.Service
+}
+
+type configType struct {
+ Enabled bool `json:"enabled"`
+ DryRun bool `json:"dry_run"`
+ Credentials json.RawMessage `json:"credentials"`
+ CredentialsFile string `json:"credentials_file"`
+ TimeToLive int `json:"time_to_live,omitempty"`
+ ApnsBundleID string `json:"apns_bundle_id,omitempty"`
+ Android *common.Config `json:"android,omitempty"`
+ Apns *common.Config `json:"apns,omitempty"`
+ Webpush *common.Config `json:"webpush,omitempty"`
+}
+
+// Init initializes the push handler
+func (Handler) Init(jsonconf json.RawMessage) (bool, error) {
+
+ var config configType
+ err := json.Unmarshal([]byte(jsonconf), &config)
+ if err != nil {
+ return false, errors.New("failed to parse config: " + err.Error())
+ }
+
+ if !config.Enabled {
+ return false, nil
+ }
+
+ if config.Credentials == nil && config.CredentialsFile != "" {
+ config.Credentials, err = os.ReadFile(config.CredentialsFile)
+ if err != nil {
+ return false, err
+ }
+ }
+
+ if config.Credentials == nil {
+ return false, errors.New("missing credentials")
+ }
+
+ ctx := context.Background()
+ credentials, err := google.CredentialsFromJSON(ctx, config.Credentials, "https://www.googleapis.com/auth/firebase.messaging")
+ if err != nil {
+ return false, err
+ }
+ if credentials.ProjectID == "" {
+ return false, errors.New("missing project ID")
+ }
+
+ app, err := fbase.NewApp(ctx, &fbase.Config{}, option.WithCredentials(credentials))
+ if err != nil {
+ return false, err
+ }
+
+ handler.client, err = app.Messaging(ctx)
+ if err != nil {
+ return false, err
+ }
+
+ handler.v1, err = fcmv1.NewService(ctx, option.WithCredentials(credentials), option.WithScopes(fcmv1.FirebaseMessagingScope))
+ if err != nil {
+ return false, err
+ }
+
+ handler.input = make(chan *push.Receipt, bufferSize)
+ handler.channel = make(chan *push.ChannelReq, bufferSize)
+ handler.stop = make(chan bool, 1)
+ handler.projectID = credentials.ProjectID
+
+ go func() {
+ for {
+ select {
+ case rcpt := <-handler.input:
+ go sendFcmV1(rcpt, &config)
+ case sub := <-handler.channel:
+ go processSubscription(sub)
+ case <-handler.stop:
+ return
+ }
+ }
+ }()
+
+ return true, nil
+}
+
+func sendFcmV1(rcpt *push.Receipt, config *configType) {
+ messages, uids := PrepareV1Notifications(rcpt, config)
+ for i := range messages {
+ req := &fcmv1.SendMessageRequest{
+ Message: messages[i],
+ ValidateOnly: config.DryRun,
+ }
+ _, err := handler.v1.Projects.Messages.Send("projects/"+handler.projectID, req).Do()
+ if err != nil {
+ gerr, decodingErrs := common.DecodeGoogleApiError(err)
+ for _, err := range decodingErrs {
+ logs.Info.Println("fcm googleapi.Error decoding:", err)
+ }
+ switch strings.ToUpper(gerr.FcmErrCode) {
+ case "": // no error
+ case common.ErrorQuotaExceeded, common.ErrorUnavailable, common.ErrorInternal, common.ErrorUnspecified:
+ // Transient errors. Stop sending this batch.
+ logs.Warn.Println("fcm transient failure:", gerr.FcmErrCode, gerr.ErrMessage)
+ return
+ case common.ErrorSenderIDMismatch, common.ErrorInvalidArgument, common.ErrorThirdPartyAuth:
+ // Config errors. Stop.
+ logs.Warn.Println("fcm invalid config:", gerr.FcmErrCode, gerr.ErrMessage)
+ return
+ case common.ErrorUnregistered:
+ // Token is no longer valid. Delete token from DB and continue sending.
+ logs.Warn.Println("fcm invalid token:", gerr.FcmErrCode, gerr.ErrMessage)
+ if err := store.Devices.Delete(rcpt.TenantID, uids[i], messages[i].Token); err != nil {
+ logs.Warn.Println("tnpg failed to delete invalid token:", err)
+ }
+ default:
+ // Unknown error. Stop sending just in case.
+ logs.Warn.Println("tnpg unrecognized error:", gerr.FcmErrCode, gerr.ErrMessage)
+ return
+ }
+ }
+ }
+}
+
+func processSubscription(req *push.ChannelReq) {
+ var channel string
+ var devices []string
+ var device string
+ var channels []string
+
+ if req.Channel != "" {
+ devices = DevicesForUser(req.TenantID, req.Uid)
+ channel = TenantChannelName(req.TenantID, req.Channel)
+ } else if req.DeviceID != "" {
+ channels = ChannelsForUser(req.TenantID, req.Uid)
+ device = req.DeviceID
+ }
+
+ if (len(devices) == 0 && device == "") || (len(channels) == 0 && channel == "") {
+ // No channels or devces to subscribe or unsubscribe.
+ return
+ }
+
+ if len(devices) > subBatchSize {
+ // It's extremely unlikely for a single user to have this many devices.
+ devices = devices[0:subBatchSize]
+ logs.Warn.Println("fcm: user", req.Uid.UserId(), "has more than", subBatchSize, "devices")
+ }
+
+ var err error
+ var resp *legacy.TopicManagementResponse
+ if channel != "" && len(devices) > 0 {
+ if req.Unsub {
+ resp, err = handler.client.UnsubscribeFromTopic(context.Background(), devices, channel)
+ } else {
+ resp, err = handler.client.SubscribeToTopic(context.Background(), devices, channel)
+ }
+ if err != nil {
+ // Complete failure.
+ logs.Warn.Println("fcm: sub or upsub failed", req.Unsub, err)
+ } else {
+ // Check for partial failure.
+ handleSubErrors(resp, req.Uid, devices)
+ }
+ return
+ }
+
+ if device != "" && len(channels) > 0 {
+ devices := []string{device}
+ for _, channel := range channels {
+ channel = TenantChannelName(req.TenantID, channel)
+ if req.Unsub {
+ resp, err = handler.client.UnsubscribeFromTopic(context.Background(), devices, channel)
+ } else {
+ resp, err = handler.client.SubscribeToTopic(context.Background(), devices, channel)
+ }
+ if err != nil {
+ // Complete failure.
+ logs.Warn.Println("fcm: sub or upsub failed", req.Unsub, err)
+ break
+ }
+ // Check for partial failure.
+ handleSubErrors(resp, req.Uid, devices)
+ }
+ return
+ }
+
+ // Invalid request: either multiple channels & multiple devices (not supported) or no channels and no devices.
+ logs.Err.Println("fcm: user", req.Uid.UserId(), "invalid combination of sub/unsub channels/devices",
+ len(devices), len(channels))
+}
+
+func handleSubErrors(response *legacy.TopicManagementResponse, uid types.Uid, devices []string) {
+ if response.FailureCount <= 0 {
+ return
+ }
+
+ for _, errinfo := range response.Errors {
+ // 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", errinfo.Reason, uid, devices[errinfo.Index])
+ }
+}
+
+// IsReady checks if the push handler has been 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 for subscribing/unsubscribing devices to FCM topics.
+func (Handler) Channel() chan<- *push.ChannelReq {
+ return handler.channel
+}
+
+// Stop shuts down the handler
+func (Handler) Stop() {
+ handler.stop <- true
+}
+
+func init() {
+ push.Register("fcm", &handler)
+}
diff --git a/server/push/fcm/tenant_channel_test.go b/server/push/fcm/tenant_channel_test.go
new file mode 100644
index 0000000..ff47745
--- /dev/null
+++ b/server/push/fcm/tenant_channel_test.go
@@ -0,0 +1,21 @@
+package fcm
+
+import (
+ "testing"
+
+ "github.com/tinode/chat/server/store/types"
+)
+
+func TestTenantChannelName(t *testing.T) {
+ got := TenantChannelName(types.TenantID(42), "chnAbc")
+ if got != "tenant-42-chnAbc" {
+ t.Fatalf("TenantChannelName = %q", got)
+ }
+}
+
+func TestTenantChannelNameInvalidTenantFallsBack(t *testing.T) {
+ got := TenantChannelName(types.ZeroTenantID, "chnAbc")
+ if got != "chnAbc" {
+ t.Fatalf("TenantChannelName zero tenant = %q", got)
+ }
+}
diff --git a/server/push/push.go b/server/push/push.go
index e69de29..41260ec 100644
--- a/server/push/push.go
+++ b/server/push/push.go
@@ -0,0 +1,212 @@
+// Package push contains interfaces to be implemented by push notification plugins.
+package push
+
+import (
+ "encoding/json"
+ "errors"
+ "time"
+
+ t "github.com/tinode/chat/server/store/types"
+)
+
+// Push actions
+const (
+ // New message.
+ ActMsg = "msg"
+ // New subscription.
+ ActSub = "sub"
+ // Messages read: clear unread count.
+ ActRead = "read"
+)
+
+// MaxPayloadLength is the maximum length of push payload in multibyte characters.
+const MaxPayloadLength = 128
+
+// Recipient is a user targeted by the push.
+type Recipient struct {
+ // Count of user's connections that were live when the packet was dispatched from the server
+ Delivered int `json:"delivered"`
+ // List of user's devices that the packet was delivered to (if known). Len(Devices) >= Delivered
+ Devices []string `json:"devices,omitempty"`
+ // Unread count to include in the push
+ Unread int `json:"unread"`
+ // Indicates whether unread counter in the cache should be incremented before sending the push.
+ ShouldIncrementUnreadCountInCache bool `json:"-"`
+}
+
+// Receipt is the push payload with a list of recipients.
+type Receipt struct {
+ TenantID t.TenantID `json:"-"`
+ // List of individual recipients, including those who did not receive the message.
+ To map[t.Uid]Recipient `json:"to"`
+ // Push topic for group notifications.
+ Channel string `json:"channel"`
+ // Actual content to be delivered to the client.
+ Payload Payload `json:"payload"`
+}
+
+// ChannelReq is a request to subscribe/unsubscribe device ID(s) to channel(s) (FCM topic).
+// - If DeviceID is provided, it's subscribed/unsubscribed to all user's channels.
+// - If Channel is provided, then all user's devices are subscribed/unsubscribed from the channel.
+type ChannelReq struct {
+ TenantID t.TenantID
+ // Uid is the ID of the user making request.
+ Uid t.Uid
+ // DeviceID is the device-provided token in case a single device is being subscribed to all channels.
+ DeviceID string
+ // Channel to subscribe to or unsubscribe from.
+ Channel string
+ // Unsub is set to true to unsubscribe devices, otherwise subscribe them.
+ Unsub bool
+}
+
+// Payload is content of the push.
+type Payload struct {
+ // Action type of the push: new message (msg), new subscription (sub), etc.
+ What string `json:"what"`
+ // If this is a silent push: perform action but do not show a notification to the user.
+ Silent bool `json:"silent"`
+ // Topic which was affected by the action.
+ Topic string `json:"topic"`
+ // Timestamp of the action.
+ Timestamp time.Time `json:"ts"`
+
+ // {data} notification.
+
+ // Message sender 'usrXXX'
+ From string `json:"from"`
+ // Sequential ID of the message.
+ SeqId int `json:"seq"`
+ // MIME-Type of the message content, text/x-drafty or text/plain
+ ContentType string `json:"mime"`
+ // Actual Data.Content of the message, if requested
+ Content any `json:"content,omitempty"`
+ // State of the video call (available in video call messages only).
+ Webrtc string `json:"webrtc,omitempty"`
+ // If call is audio-only (available only if Webrtc is present).
+ AudioOnly bool `json:"aonly,omitempty"`
+ // Seq id the message is supposed to replace.
+ Replace string `json:"replace,omitempty"`
+
+ // Subscription change notification.
+
+ // New access mode when notifying of a subscription change.
+ // ModeNone for both means the subscription is removed.
+ ModeWant t.AccessMode `json:"want,omitempty"`
+ ModeGiven t.AccessMode `json:"given,omitempty"`
+}
+
+// Handler is an interface which must be implemented by handlers.
+type Handler interface {
+ // Init initializes the handler.
+ Init(jsonconf json.RawMessage) (bool, error)
+
+ // IsReady сhecks if the handler is initialized.
+ IsReady() bool
+
+ // Push returns a channel that the server will use to send messages to.
+ // The message will be dropped if the channel blocks.
+ Push() chan<- *Receipt
+
+ // Subscribe/unsubscribe device from FCM topic (channel).
+ Channel() chan<- *ChannelReq
+
+ // Stop terminates the handler's worker and stops sending pushes.
+ Stop()
+}
+
+type configType struct {
+ Name string `json:"name"`
+ Config json.RawMessage `json:"config"`
+}
+
+var handlers map[string]Handler
+
+// Register a push handler
+func Register(name string, hnd Handler) {
+ if handlers == nil {
+ handlers = make(map[string]Handler)
+ }
+
+ if hnd == nil {
+ panic("Register: push handler is nil")
+ }
+ if _, dup := handlers[name]; dup {
+ panic("Register: called twice for handler " + name)
+ }
+ handlers[name] = hnd
+}
+
+// Init initializes registered handlers.
+func Init(jsconfig json.RawMessage) ([]string, error) {
+ var config []configType
+
+ if err := json.Unmarshal(jsconfig, &config); err != nil {
+ return nil, errors.New("failed to parse config: " + err.Error())
+ }
+
+ var enabled []string
+ for _, cc := range config {
+ if hnd := handlers[cc.Name]; hnd != nil {
+ if ok, err := hnd.Init(cc.Config); err != nil {
+ return nil, err
+ } else if ok {
+ enabled = append(enabled, cc.Name)
+ }
+ }
+ }
+
+ return enabled, nil
+}
+
+// Push a single message to devices.
+func Push(msg *Receipt) {
+ if handlers == nil {
+ return
+ }
+
+ for _, hnd := range handlers {
+ if !hnd.IsReady() {
+ continue
+ }
+
+ // Push without delay or skip
+ select {
+ case hnd.Push() <- msg:
+ default:
+ }
+ }
+}
+
+// ChannelSub handles a channel (FCM topic) subscription/unsubscription request.
+func ChannelSub(msg *ChannelReq) {
+ if handlers == nil {
+ return
+ }
+
+ for _, hnd := range handlers {
+ if !hnd.IsReady() {
+ continue
+ }
+
+ // Send without delay or skip.
+ select {
+ case hnd.Channel() <- msg:
+ default:
+ }
+ }
+}
+
+// Stop all pushes
+func Stop() {
+ if handlers == nil {
+ return
+ }
+
+ for _, hnd := range handlers {
+ if hnd.IsReady() {
+ // Will potentially block
+ hnd.Stop()
+ }
+ }
+}
diff --git a/server/push/stdout/README.md b/server/push/stdout/README.md
index e69de29..c742899 100644
--- a/server/push/stdout/README.md
+++ b/server/push/stdout/README.md
@@ -0,0 +1,4 @@
+# `stdout` push adapter
+
+This is an adapter which logs push notifications to `STDOUT` where they can be redirected to file or processed by some other service.
+This adapter is primarily intended for debugging and logging.
diff --git a/server/push/tnpg/README.md b/server/push/tnpg/README.md
index e69de29..ac5b594 100644
--- a/server/push/tnpg/README.md
+++ b/server/push/tnpg/README.md
@@ -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.
diff --git a/server/push/tnpg/push_tnpg.go b/server/push/tnpg/push_tnpg.go
index e69de29..b832327 100644
--- a/server/push/tnpg/push_tnpg.go
+++ b/server/push/tnpg/push_tnpg.go
@@ -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)
+}
diff --git a/server/ringhash/ringhash_test.go b/server/ringhash/ringhash_test.go
index e69de29..f4357d4 100644
--- a/server/ringhash/ringhash_test.go
+++ b/server/ringhash/ringhash_test.go
@@ -0,0 +1,183 @@
+package ringhash_test
+
+import (
+ "fmt"
+ "hash/crc32"
+ "hash/fnv"
+ "testing"
+
+ "github.com/tinode/chat/server/ringhash"
+)
+
+func TestHashing(t *testing.T) {
+
+ // Ring with 3 elements hashed by crc32.ChecksumIEEE
+ ring := ringhash.New(3, crc32.ChecksumIEEE)
+ ring.Add("A", "B", "C")
+
+ // The ring contains:
+ // B0 = 105710768 -> B
+ // B1 = 525743601 -> B
+ // B2 = 880502322 -> B
+ // C2 = 1132222116 -> C
+ // C1 = 1750140263 -> C
+ // C0 = 1900688422 -> C
+ // A1 = 2254398539 -> A
+ // A0 = 2672055562 -> A
+ // A2 = 2909943688 -> A
+
+ // Key=A, Hash=3554254475 -> B0
+ // Key=B, Hash=1255198513 -> C1
+ // Key=C, Hash=1037565863 -> C2
+ // Key=D, Hash=2746444292 -> A2
+ // Key=E, Hash=3568589458 -> B0
+ // Key=F, Hash=1304234792 -> C1
+
+ testHashes := map[string]string{
+ "A": "B",
+ "B": "C",
+ "C": "C",
+ "D": "A",
+ "E": "B",
+ "F": "C",
+ }
+
+ for k, v := range testHashes {
+ n := ring.Get(k)
+ if n != v {
+ t.Errorf("Key '%s', expecting '%s', got '%s'", k, v, n)
+ }
+ }
+
+ ring.Add("X")
+ // Adding
+ // X0 = 4214226378
+ // X1 = 3795111051
+ // X2 = 3373899592
+
+ // Changes to mapping:
+ testHashes["A"] = "X"
+ testHashes["E"] = "X"
+
+ for k, v := range testHashes {
+ n := ring.Get(k)
+ if n != v {
+ t.Errorf("Key '%s, expecting %s, got %s", k, v, n)
+ }
+ }
+}
+
+func TestConsistency(t *testing.T) {
+ ring1 := ringhash.New(3, nil)
+ ring2 := ringhash.New(3, nil)
+
+ ring1.Add("owl", "crow", "sparrow")
+ ring2.Add("sparrow", "owl", "crow")
+
+ if ring1.Get("duck") != ring2.Get("duck") {
+ t.Error("'duck' should map to 'sparrow' in both cases")
+ }
+
+ // Collision test: these strings generate CRC32 collisions
+ // Google's implementation fails this test.
+ // 0VXGD 0BGABAA
+ // 0VXGG 0BGABAB
+ // 0VXGF 0BGABAC
+ // 0VXGA 0BGABAD
+ // 0VXGC 0BGABAF
+ // 0VXGB 0BGABAG
+ // 0VXGM 0BGABAH
+ // 0VXGL 0BGABAI
+ // 0VXGO 0BGABAJ
+ // 0VXGN 0BGABAK
+ // 0VXGI 0BGABAL
+
+ ring1 = ringhash.New(1, crc32.ChecksumIEEE)
+ ring2 = ringhash.New(1, crc32.ChecksumIEEE)
+
+ ring1.Add("VXGD", "BGABAA", "VXGG", "BGABAB", "VXGF", "BGABAC")
+ ring2.Add("BGABAA", "VXGD", "BGABAB", "VXGG", "BGABAC", "VXGF")
+
+ str := []string{
+ "datsam", "kGmVht", "dSPmEr", "RloWQr", "WFkAkG", "gLBNPX", "twEwll", "RnRdaf",
+ "ruEMuJ", "ZvXJsJ", "xjQzKD", "CKfSFg", "BMKMvM", "PSzYdC", "CsxqTR", "IbzdXz",
+ "xdnZGj", "VdHcVp", "iVgIvH", "bZsTIX", "CyRBUO", "ylgEGS", "vOTwJD", "JZbyFU",
+ "Hayrly", "jQQkOV", "NEVjlJ", "SkJfie", "HrdJuL", "ASwkXH", "UwJOmo", "nfbrxA",
+ }
+ for _, key := range str {
+ if ring1.Get(key) != ring2.Get(key) {
+ t.Errorf("'%s' should map to the same bin in both cases", key)
+ }
+ }
+}
+
+func TestSignature(t *testing.T) {
+ ring1 := ringhash.New(4, nil)
+ ring2 := ringhash.New(4, nil)
+
+ ring1.Add("owl", "crow", "sparrow")
+ ring2.Add("sparrow", "owl", "crow")
+
+ if ring1.Signature() != ring2.Signature() {
+ t.Error("Signatures must be identical")
+ }
+
+ ring1 = ringhash.New(4, nil)
+ ring2 = ringhash.New(5, nil)
+
+ ring1.Add("owl", "crow", "sparrow")
+ ring2.Add("owl", "crow", "sparrow")
+
+ if ring1.Signature() == ring2.Signature() {
+ t.Error("Signatures must be different - different count of replicas")
+ }
+
+ ring1 = ringhash.New(4, nil)
+ ring2 = ringhash.New(4, nil)
+
+ ring1.Add("owl", "crow", "sparrow")
+ ring2.Add("owl", "crow", "sparrow", "crane")
+
+ if ring1.Signature() == ring2.Signature() {
+ t.Error("Signatures must be different - different keys")
+ }
+
+ fnvHashfunc := func(data []byte) uint32 {
+ hash := fnv.New32a()
+ hash.Write(data)
+ return hash.Sum32()
+ }
+
+ ring1 = ringhash.New(4, nil)
+ ring2 = ringhash.New(4, fnvHashfunc)
+
+ ring1.Add("owl", "crow", "sparrow")
+ ring2.Add("owl", "crow", "sparrow")
+
+ if ring1.Signature() == ring2.Signature() {
+ t.Error("Signatures must be different - different hash functions")
+ }
+}
+
+func BenchmarkGet8(b *testing.B) { benchmarkGet(b, 8) }
+func BenchmarkGet32(b *testing.B) { benchmarkGet(b, 32) }
+func BenchmarkGet128(b *testing.B) { benchmarkGet(b, 128) }
+func BenchmarkGet512(b *testing.B) { benchmarkGet(b, 512) }
+
+func benchmarkGet(b *testing.B, keycount int) {
+
+ ring := ringhash.New(53, nil)
+
+ var ids []string
+ for i := range keycount {
+ ids = append(ids, fmt.Sprintf("id=%d", i))
+ }
+
+ ring.Add(ids...)
+
+ b.ResetTimer()
+
+ for i := range b.N {
+ ring.Get(ids[i&(keycount-1)])
+ }
+}
diff --git a/server/run-cluster.sh b/server/run-cluster.sh
old mode 100644
new mode 100755
index e69de29..92a7172
--- a/server/run-cluster.sh
+++ b/server/run-cluster.sh
@@ -0,0 +1,80 @@
+#!/bin/bash
+
+# Start/stop test cluster on localhost. This is NOT a production script. Use it for reference only.
+
+# Names of cluster nodes
+ALL_NODE_NAMES=( one two three )
+# Port where the first node will listen for client connections over http
+HTTP_BASE_PORT=6060
+# Port where the first node will listen for gRPC intra-cluster connections.
+GRPC_BASE_PORT=16060
+
+USAGE="Usage: $0 [ --config ] {start|stop}"
+
+# Your server binary may have a different name and location.
+SERVER='./server'
+
+if [ "$#" -lt "1" ]; then
+ echo $USAGE
+ exit 1
+fi
+
+while [[ $# -gt 0 ]]; do
+ key="$1"
+ shift
+ echo "$key"
+ case "$key" in
+ -c|--config)
+ config=$1
+ shift # value
+ ;;
+ -s|--static_data)
+ static_data=$1
+ shift # value
+ ;;
+ start)
+ if [ ! -z "$config" ] ; then
+ TINODE_CONF=$config
+ else
+ TINODE_CONF="tinode.conf"
+ fi
+ if [ ! -z "${static_data+x}" ] ; then
+ STATIC_DATA_DIR=$static_data
+ else
+ STATIC_DATA_DIR="static"
+ fi
+
+ echo "HTTP ports 6060-6062, gRPC ports 16060-16062, config ${config}"
+
+ HTTP_PORT=$HTTP_BASE_PORT
+ GRPC_PORT=$GRPC_BASE_PORT
+ for NODE_NAME in "${ALL_NODE_NAMES[@]}"
+ do
+ # Start the node
+ $SERVER -config=${TINODE_CONF} -cluster_self=${NODE_NAME} -listen=:${HTTP_PORT} -grpc_listen=:${GRPC_PORT} -static_data=${STATIC_DATA_DIR} -log_flags=stdFlags,shortfile &
+ # Save PID of the node to a temp file.
+ # /var/tmp/ does not requre root access.
+ echo $!> "/var/tmp/tinode-${NODE_NAME}.pid"
+ # Increment ports for the next node.
+ HTTP_PORT=$((HTTP_PORT+1))
+ GRPC_PORT=$((GRPC_PORT+1))
+ done
+ exit 0
+ ;;
+ stop)
+ echo 'Stopping cluster'
+
+ for NODE_NAME in "${ALL_NODE_NAMES[@]}"
+ do
+ # Read PIDs of running nodes from temp files and kill them.
+ kill `cat /var/tmp/tinode-${NODE_NAME}.pid`
+ # Clean up: delete temp files.
+ rm "/var/tmp/tinode-${NODE_NAME}.pid"
+ done
+ exit 0
+ ;;
+ *)
+ echo $USAGE
+ exit 1
+ esac
+done
diff --git a/server/sanity-test.sh b/server/sanity-test.sh
old mode 100644
new mode 100755
index e69de29..ea7de37
--- a/server/sanity-test.sh
+++ b/server/sanity-test.sh
@@ -0,0 +1,120 @@
+#!/bin/bash
+
+BINARY_PATH=$GOPATH/bin
+TINODE_BINARY=$BINARY_PATH/server
+
+# Kills and removes any running containers.
+cleanup() {
+ ./run-cluster.sh stop
+ if [ -f "./server" ]; then
+ rm ./server
+ fi
+ docker stop mysql && docker rm mysql
+}
+
+# Reports failure.
+fail() {
+ cleanup
+ echo "**************************************************"
+ printf "Tests Failed: ${@}\n"
+ echo "**************************************************"
+ exit 1
+}
+
+# Reports success.
+pass() {
+ cleanup
+ echo "**************************************************"
+ echo "* OK *"
+ echo "**************************************************"
+ exit 0
+}
+
+# Brings up a mysql docker container.
+setup() {
+ docker info 1>/dev/null 2>&1 || (echo "docker not running" && return 1)
+ docker run -p 3306:3306 --name mysql --env MYSQL_ALLOW_EMPTY_PASSWORD=yes -d mysql:5.7 || return 1
+ # This fails to detect when the mysql is actually ready.
+ # TODO: figure out why.
+ #until nc -z -v -w30 localhost 3306; do
+ # echo "Waiting for database connection..."
+ # sleep 1
+ #done
+ echo -n "Waiting for mysql to come up."
+ while ! mysqladmin ping -u root -h 127.0.0.1 --silent; do
+ echo -n "."
+ sleep 1
+ done
+ # Make sure there's no Tinode server binary in the current directory.
+ if [ -f "./server" ]; then
+ rm ./server
+ fi
+}
+
+# Compiles Tinode binaries.
+build() {
+ go install -tags mysql -ldflags "-X main.buildstamp=`date -u '+%Y%m%dT%H:%M:%SZ'`" \
+ github.com/tinode/chat/tinode-db \
+ github.com/tinode/chat/server && \
+ ln -s $TINODE_BINARY
+}
+
+# Initializes Tinode database.
+init-db() {
+ $GOPATH/bin/tinode-db -config=./tinode.conf -data=../tinode-db/data.json
+}
+
+wait-for() {
+ local port=$1
+ while ! nc -z localhost $port; do
+ sleep 1
+ done
+}
+
+# Brings up a three-node Tinode cluster.
+run-server() {
+ ./run-cluster.sh -s "" start && wait-for 16060
+}
+
+send-requests() {
+ local expect=12
+ local port=$1
+ local id=$2
+ local outfile=$(mktemp /tmp/tinode-${id}.txt)
+ pushd .
+ cd ../tn-cli
+ python3 tn-cli.py --host=localhost:${port} --no-login < sample-script.txt > $outfile || fail "Test script failed (instance port ${port})"
+ popd
+ num_positive_responses=`grep -c '<= 20[0-9]' $outfile`
+ if [ $num_positive_responses -ne expect ]
+ then
+ fail "Instance ${port}: unexpected number of 20X responses: ${num_positive_responses} (expected ${expected}). Log file ${outfile}"
+ fi
+ rm $outfile
+}
+
+# Catch unexpected failures, do cleanup and output an error message
+trap 'cleanup ; fail "For Unexpected Reasons"'\
+ HUP INT QUIT PIPE TERM
+
+# Normal script termination.
+#trap 'cleanup'\
+# EXIT
+
+run_id=`date +%s`
+echo "+----------------------------------------------------+"
+echo "| Tinode sanity test. |"
+echo "+----------------------------------------------------+"
+echo "Timestamp = ${run_id}"
+
+setup || fail "Test setup failed."
+build || fail "Could not build Tinode binaries"
+init-db || fail "Could not initialize Tinode database"
+run-server || fail "Could not start tinode"
+
+# Test requests.
+send-requests 16060 $run_id
+send-requests 16061 $run_id
+send-requests 16062 $run_id
+
+pass
diff --git a/server/session.go b/server/session.go
index e69de29..bcb91c1 100644
--- a/server/session.go
+++ b/server/session.go
@@ -0,0 +1,1504 @@
+/******************************************************************************
+ *
+ * Description :
+ *
+ * Handling of user sessions/connections. One user may have multiple sesions.
+ * Each session may handle multiple topics
+ *
+ *****************************************************************************/
+
+package main
+
+import (
+ "container/list"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/gorilla/websocket"
+ "github.com/tinode/chat/pbx"
+ "github.com/tinode/chat/server/auth"
+ "github.com/tinode/chat/server/logs"
+ "github.com/tinode/chat/server/store"
+ "github.com/tinode/chat/server/store/types"
+
+ "golang.org/x/text/language"
+)
+
+// Maximum number of queued messages before session is considered stale and dropped.
+const sendQueueLimit = 128
+
+// Time given to a background session to terminate to avoid tiggering presence notifications.
+// If session terminates (or unsubscribes from topic) in this time frame notifications are not sent at all.
+const deferredNotificationsTimeout = time.Second * 5
+
+var minSupportedVersionValue = parseVersion(minSupportedVersion)
+
+// SessionProto is the type of the wire transport.
+type SessionProto int
+
+// Constants defining individual types of wire transports.
+const (
+ // NONE is undefined/not set.
+ NONE SessionProto = iota
+ // WEBSOCK represents websocket connection.
+ WEBSOCK
+ // LPOLL represents a long polling connection.
+ LPOLL
+ // GRPC is a gRPC connection
+ GRPC
+ // PROXY is temporary session used as a proxy at master node.
+ PROXY
+ // MULTIPLEX is a multiplexing session reprsenting a connection from proxy topic to master.
+ MULTIPLEX
+)
+
+// Session represents a single WS connection or a long polling session. A user may have multiple
+// sessions.
+type Session struct {
+ // protocol - NONE (unset), WEBSOCK, LPOLL, GRPC, PROXY, MULTIPLEX
+ proto SessionProto
+
+ // Session ID
+ sid string
+
+ // Websocket. Set only for websocket sessions.
+ ws *websocket.Conn
+
+ // Pointer to session's record in sessionStore. Set only for Long Poll sessions.
+ lpTracker *list.Element
+
+ // gRPC handle. Set only for gRPC clients.
+ grpcnode pbx.Node_MessageLoopServer
+
+ // Reference to the cluster node where the session has originated. Set only for cluster RPC sessions.
+ clnode *ClusterNode
+
+ // Reference to multiplexing session. Set only for proxy sessions.
+ multi *Session
+ proxiedTopic string
+
+ // IP address of the client. For long polling this is the IP of the last poll.
+ remoteAddr string
+
+ // User agent, a string provived by an authenticated client in {login} packet.
+ userAgent string
+
+ // Protocol version of the client: ((major & 0xff) << 8) | (minor & 0xff).
+ ver int
+
+ // Device ID of the client
+ deviceID string
+ // Platform: web, ios, android
+ platf string
+ // Human language of the client
+ lang string
+ // Country code of the client
+ countryCode string
+
+ // ID of the current user. Could be zero if session is not authenticated
+ // or for multiplexing sessions.
+ uid types.Uid
+ // Tenant context is resolved from the first hi packet and cannot change.
+ tenantID types.TenantID
+ tenantCode string
+
+ // Authentication level - NONE (unset), ANON, AUTH, ROOT.
+ authLvl auth.Level
+
+ // Time when the long polling session was last refreshed
+ lastTouched time.Time
+
+ // Time when the session received any packer from client
+ lastAction int64
+
+ // Timer which triggers after some seconds to mark background session as foreground.
+ bkgTimer *time.Timer
+
+ // Number of subscribe/unsubscribe requests in flight.
+ inflightReqs *boundedWaitGroup
+ // Synchronizes access to session store in cluster mode:
+ // subscribe/unsubscribe replies are asynchronous.
+ sessionStoreLock sync.Mutex
+ // Indicates that the session is terminating.
+ // After this flag's been flipped to true, there must not be any more writes
+ // into the session's send channel.
+ // Read/written atomically.
+ // 0 = false
+ // 1 = true
+ terminating int32
+
+ // Background session: subscription presence notifications and online status are delayed.
+ background bool
+
+ // Outbound mesages, buffered.
+ // The content must be serialized in format suitable for the session.
+ send chan any
+
+ // Channel for shutting down the session, buffer 1.
+ // Content in the same format as for 'send'
+ stop chan any
+
+ // detach - channel for detaching session from topic, buffered.
+ // Content is topic name to detach from.
+ detach chan string
+
+ // Map of topic subscriptions, indexed by topic name.
+ // Don't access directly. Use getters/setters.
+ subs map[string]*Subscription
+ // Mutex for subs access: both topic go routines and network go routines access
+ // subs concurrently.
+ subsLock sync.RWMutex
+
+ // Needed for long polling and grpc.
+ lock sync.Mutex
+
+ // Field used only in cluster mode by topic master node.
+
+ // Type of proxy to master request being handled.
+ proxyReq ProxyReqType
+}
+
+// Subscription is a mapper of sessions to topics.
+type Subscription struct {
+ // Channel to communicate with the topic, copy of Topic.clientMsg
+ broadcast chan<- *ClientComMessage
+
+ // Session sends a signal to Topic when this session is unsubscribed
+ // This is a copy of Topic.unreg
+ done chan<- *ClientComMessage
+
+ // Channel to send {meta} requests, copy of Topic.meta
+ meta chan<- *ClientComMessage
+
+ // Channel to ping topic with session's updates, copy of Topic.supd
+ supd chan<- *sessionUpdate
+}
+
+func (s *Session) addSub(topic string, sub *Subscription) {
+ if s.multi != nil {
+ s.multi.addSub(topic, sub)
+ return
+ }
+ s.subsLock.Lock()
+
+ // Sessions that serve as an interface between proxy topics and their masters (proxy sessions)
+ // may have only one subscription, that is, to its master topic.
+ // Normal sessions may be subscribed to multiple topics.
+
+ if !s.isMultiplex() || s.countSub() == 0 {
+ s.subs[topic] = sub
+ }
+ s.subsLock.Unlock()
+}
+
+func (s *Session) getSub(topic string) *Subscription {
+ // Don't check s.multi here. Let it panic if called for proxy session.
+
+ s.subsLock.RLock()
+ defer s.subsLock.RUnlock()
+
+ return s.subs[topic]
+}
+
+func (s *Session) delSub(topic string) {
+ if s.multi != nil {
+ s.multi.delSub(topic)
+ return
+ }
+ s.subsLock.Lock()
+ delete(s.subs, topic)
+ s.subsLock.Unlock()
+}
+
+func (s *Session) countSub() int {
+ if s.multi != nil {
+ return s.multi.countSub()
+ }
+ return len(s.subs)
+}
+
+// Inform topics that the session is being terminated.
+// No need to check for s.multi because it's not called for PROXY sessions.
+func (s *Session) unsubAll() {
+ s.subsLock.RLock()
+ defer s.subsLock.RUnlock()
+
+ for _, sub := range s.subs {
+ // sub.done is the same as topic.unreg
+ // The whole session is being dropped; ClientComMessage is a wrapper for session, ClientComMessage.init is false.
+ // keep redundant init: false so it can be searched for.
+ sub.done <- &ClientComMessage{sess: s, init: false}
+ }
+}
+
+// Indicates whether this session is a local interface for a remote proxy topic.
+// It multiplexes multiple sessions.
+func (s *Session) isMultiplex() bool {
+ return s.proto == MULTIPLEX
+}
+
+// Indicates whether this session is a short-lived proxy for a remote session.
+func (s *Session) isProxy() bool {
+ return s.proto == PROXY
+}
+
+// Cluster session: either a proxy or a multiplexing session.
+func (s *Session) isCluster() bool {
+ return s.isProxy() || s.isMultiplex()
+}
+
+func (s *Session) scheduleClusterWriteLoop() {
+ if globals.cluster != nil && globals.cluster.proxyEventQueue != nil {
+ globals.cluster.proxyEventQueue.Schedule(
+ func() { s.clusterWriteLoop(s.proxiedTopic) })
+ }
+}
+
+func (s *Session) supportsMessageBatching() bool {
+ switch s.proto {
+ case WEBSOCK:
+ return true
+ case GRPC:
+ return true
+ default:
+ return false
+ }
+}
+
+// queueOut attempts to send a list of ServerComMessages to a session write loop;
+// it fails if the send buffer is full.
+func (s *Session) queueOutBatch(msgs []*ServerComMessage) bool {
+ if s == nil {
+ return true
+ }
+ if atomic.LoadInt32(&s.terminating) > 0 {
+ return true
+ }
+ for _, msg := range msgs {
+ if msg == nil {
+ continue
+ }
+ if msg.TenantID.IsZero() {
+ msg.TenantID = s.tenantID
+ } else if !s.tenantID.IsZero() && msg.TenantID != s.tenantID {
+ logs.Err.Printf("s.queueOutBatch: rejected cross-tenant message: session=%d message=%d", s.tenantID, msg.TenantID)
+ return false
+ }
+ }
+
+ if s.multi != nil {
+ // In case of a cluster we need to pass a copy of the actual session.
+ for i := range msgs {
+ msgs[i].sess = s
+ }
+ if s.multi.queueOutBatch(msgs) {
+ s.multi.scheduleClusterWriteLoop()
+ return true
+ }
+ return false
+ }
+
+ if s.supportsMessageBatching() {
+ select {
+ case s.send <- msgs:
+ default:
+ // Never block here since it may also block the topic's run() goroutine.
+ logs.Err.Println("s.queueOut: session's send queue2 full", s.sid)
+ return false
+ }
+ if s.isMultiplex() {
+ s.scheduleClusterWriteLoop()
+ }
+ } else {
+ for _, msg := range msgs {
+ s.queueOut(msg)
+ }
+ }
+
+ return true
+}
+
+// queueOut attempts to send a ServerComMessage to a session write loop;
+// it fails, if the send buffer is full.
+func (s *Session) queueOut(msg *ServerComMessage) bool {
+ if s == nil {
+ return true
+ }
+ if atomic.LoadInt32(&s.terminating) > 0 {
+ return true
+ }
+ if msg == nil {
+ return true
+ }
+ if msg.TenantID.IsZero() {
+ msg.TenantID = s.tenantID
+ } else if !s.tenantID.IsZero() && msg.TenantID != s.tenantID {
+ logs.Err.Printf("s.queueOut: rejected cross-tenant message: session=%d message=%d", s.tenantID, msg.TenantID)
+ return false
+ }
+
+ if s.multi != nil {
+ // In case of a cluster we need to pass a copy of the actual session.
+ msg.sess = s
+ if s.multi.queueOut(msg) {
+ s.multi.scheduleClusterWriteLoop()
+ return true
+ }
+ return false
+ }
+
+ // Record latency only on {ctrl} messages and end-user sessions.
+ if msg.Ctrl != nil && msg.Id != "" {
+ if !msg.Ctrl.Timestamp.IsZero() && !s.isCluster() {
+ duration := time.Since(msg.Ctrl.Timestamp).Milliseconds()
+ statsAddHistSample("RequestLatency", float64(duration))
+ }
+ if 200 <= msg.Ctrl.Code && msg.Ctrl.Code < 600 {
+ statsInc(fmt.Sprintf("CtrlCodesTotal%dxx", msg.Ctrl.Code/100), 1)
+ } else {
+ logs.Warn.Println("Invalid response code: ", msg.Ctrl.Code)
+ }
+ }
+
+ select {
+ case s.send <- msg:
+ default:
+ // Never block here since it may also block the topic's run() goroutine.
+ logs.Err.Println("s.queueOut: session's send queue full", s.sid)
+ return false
+ }
+ if s.isMultiplex() {
+ s.scheduleClusterWriteLoop()
+ }
+ return true
+}
+
+// queueOutBytes attempts to send a ServerComMessage already serialized to []byte.
+// If the send buffer is full, it fails.
+func (s *Session) queueOutBytes(data []byte) bool {
+ if s == nil || atomic.LoadInt32(&s.terminating) > 0 {
+ return true
+ }
+
+ select {
+ case s.send <- data:
+ default:
+ logs.Err.Println("s.queueOutBytes: session's send queue full", s.sid)
+ return false
+ }
+ if s.isMultiplex() {
+ s.scheduleClusterWriteLoop()
+ }
+ return true
+}
+
+func (s *Session) maybeScheduleClusterWriteLoop() {
+ if s.multi != nil {
+ s.multi.scheduleClusterWriteLoop()
+ return
+ }
+ if s.isMultiplex() {
+ s.scheduleClusterWriteLoop()
+ }
+}
+
+func (s *Session) detachSession(fromTopic string) {
+ if atomic.LoadInt32(&s.terminating) == 0 {
+ s.detach <- fromTopic
+ s.maybeScheduleClusterWriteLoop()
+ }
+}
+
+func (s *Session) stopSession(data any) {
+ s.stop <- data
+ s.maybeScheduleClusterWriteLoop()
+}
+
+func (s *Session) purgeChannels() {
+ for len(s.send) > 0 {
+ <-s.send
+ }
+ for len(s.stop) > 0 {
+ <-s.stop
+ }
+ for len(s.detach) > 0 {
+ <-s.detach
+ }
+}
+
+// cleanUp is called when the session is terminated to perform resource cleanup.
+func (s *Session) cleanUp(expired bool) {
+ atomic.StoreInt32(&s.terminating, 1)
+ s.purgeChannels()
+ s.inflightReqs.Wait()
+ s.inflightReqs = nil
+ if !expired {
+ s.sessionStoreLock.Lock()
+ globals.sessionStore.Delete(s)
+ s.sessionStoreLock.Unlock()
+ }
+
+ s.background = false
+ s.bkgTimer.Stop()
+ s.unsubAll()
+ // Stop the write loop.
+ s.stopSession(nil)
+}
+
+// Message received, convert bytes to ClientComMessage and dispatch
+func (s *Session) dispatchRaw(raw []byte) {
+ now := types.TimeNow()
+ var msg ClientComMessage
+
+ if atomic.LoadInt32(&s.terminating) > 0 {
+ logs.Warn.Println("s.dispatch: message received on a terminating session", s.sid)
+ s.queueOut(ErrLocked("", "", now))
+ return
+ }
+
+ if len(raw) == 1 && raw[0] == 0x31 {
+ // 0x31 == '1'. This is a network probe message. Respond with a '0':
+ s.queueOutBytes([]byte{0x30})
+ return
+ }
+
+ toLog := raw
+ truncated := ""
+ if len(raw) > 512 {
+ toLog = raw[:512]
+ truncated = "<...>"
+ }
+ logs.Info.Printf("in: '%s%s' sid='%s' uid='%s'", toLog, truncated, s.sid, s.uid)
+
+ if err := json.Unmarshal(raw, &msg); err != nil {
+ // Malformed message
+ logs.Warn.Println("s.dispatch", err, s.sid)
+ s.queueOut(ErrMalformed("", "", now))
+ return
+ }
+
+ s.dispatch(&msg)
+}
+
+func (s *Session) dispatch(msg *ClientComMessage) {
+ now := types.TimeNow()
+ atomic.StoreInt64(&s.lastAction, now.UnixNano())
+
+ // This should be the first block here, before any other checks.
+ var resp *ServerComMessage
+ if msg, resp = pluginFireHose(s, msg); resp != nil {
+ // Plugin provided a response. No further processing is needed.
+ s.queueOut(resp)
+ return
+ } else if msg == nil {
+ // Plugin requested to silently drop the request.
+ return
+ }
+
+ authLvl := auth.LevelNone
+ if msg.Extra != nil {
+ authLvl = auth.ParseAuthLevel(msg.Extra.AuthLevel)
+ }
+
+ if msg.Extra == nil || (msg.Extra.AsUser == "" && authLvl == auth.LevelNone) {
+ // Use current user's ID and auth level.
+ msg.AsUser = s.uid.UserId()
+ msg.AuthLvl = int(s.authLvl)
+ } else if s.authLvl != auth.LevelRoot {
+ // Only root user can set alternative user ID and auth level values.
+ s.queueOut(ErrPermissionDenied("", "", now))
+ logs.Warn.Println("s.dispatch: non-root assigned asUser", s.sid)
+ return
+ } else if fromUid := types.ParseUserId(msg.Extra.AsUser); fromUid.IsZero() {
+ // Invalid msg.Extra.AsUser.
+ s.queueOut(ErrMalformed("", "", now))
+ logs.Warn.Println("s.dispatch: malformed asUser: ", msg.Extra.AsUser, s.sid)
+ return
+ } else {
+ state, err := userGetState(s.tenantID, fromUid)
+ if err != nil || state != types.StateOK {
+ s.queueOut(ErrPermissionDenied("", "", now))
+ logs.Warn.Println("s.dispatch: cross-tenant or inactive asUser", s.sid)
+ return
+ }
+ // Use provided msg.Extra.AsUser
+ msg.AsUser = msg.Extra.AsUser
+
+ // Assign auth level, if one is provided. Ignore invalid strings.
+ if authLvl == auth.LevelNone {
+ // AuthLvl is not set by the caller, assign default LevelAuth.
+ msg.AuthLvl = int(auth.LevelAuth)
+ } else {
+ msg.AuthLvl = int(authLvl)
+ }
+ }
+
+ msg.Timestamp = now
+
+ var handler func(*ClientComMessage)
+ var uaRefresh bool
+
+ // Check if s.ver is defined
+ checkVers := func(handler func(*ClientComMessage)) func(*ClientComMessage) {
+ return func(m *ClientComMessage) {
+ if s.ver == 0 {
+ logs.Warn.Println("s.dispatch: {hi} is missing", s.sid)
+ s.queueOut(ErrCommandOutOfSequence(m.Id, m.Original, msg.Timestamp))
+ return
+ }
+ handler(m)
+ }
+ }
+
+ // Check if the session has an immutable tenant context.
+ checkTenant := func(handler func(*ClientComMessage)) func(*ClientComMessage) {
+ return func(m *ClientComMessage) {
+ if s.tenantID.IsZero() {
+ logs.Warn.Println("s.dispatch: tenant context is missing", s.sid)
+ s.queueOut(ErrCommandOutOfSequence(m.Id, m.Original, msg.Timestamp))
+ return
+ }
+ handler(m)
+ }
+ }
+
+ // Check if user is logged in
+ checkUser := func(handler func(*ClientComMessage)) func(*ClientComMessage) {
+ return func(m *ClientComMessage) {
+ if msg.AsUser == "" {
+ logs.Warn.Println("s.dispatch: authentication required", s.sid)
+ s.queueOut(ErrAuthRequiredReply(m, m.Timestamp))
+ return
+ }
+ handler(m)
+ }
+ }
+
+ switch {
+ case msg.Pub != nil:
+ handler = checkVers(checkTenant(checkUser(s.publish)))
+ msg.Id = msg.Pub.Id
+ msg.Original = msg.Pub.Topic
+ uaRefresh = true
+
+ case msg.Sub != nil:
+ handler = checkVers(checkTenant(checkUser(s.subscribe)))
+ msg.Id = msg.Sub.Id
+ msg.Original = msg.Sub.Topic
+ uaRefresh = true
+
+ case msg.Leave != nil:
+ handler = checkVers(checkTenant(checkUser(s.leave)))
+ msg.Id = msg.Leave.Id
+ msg.Original = msg.Leave.Topic
+
+ case msg.Hi != nil:
+ handler = s.hello
+ msg.Id = msg.Hi.Id
+
+ case msg.Login != nil:
+ handler = checkVers(checkTenant(s.login))
+ msg.Id = msg.Login.Id
+
+ case msg.Get != nil:
+ handler = checkVers(checkTenant(checkUser(s.get)))
+ msg.Id = msg.Get.Id
+ msg.Original = msg.Get.Topic
+ uaRefresh = true
+
+ case msg.Set != nil:
+ handler = checkVers(checkTenant(checkUser(s.set)))
+ msg.Id = msg.Set.Id
+ msg.Original = msg.Set.Topic
+ uaRefresh = true
+
+ case msg.Del != nil:
+ handler = checkVers(checkTenant(checkUser(s.del)))
+ msg.Id = msg.Del.Id
+ msg.Original = msg.Del.Topic
+
+ case msg.Acc != nil:
+ handler = checkVers(checkTenant(s.acc))
+ msg.Id = msg.Acc.Id
+
+ case msg.Note != nil:
+ // If user is not authenticated or version not set the {note} is silently ignored.
+ handler = s.note
+ msg.Original = msg.Note.Topic
+ uaRefresh = true
+
+ default:
+ // Unknown message
+ s.queueOut(ErrMalformed("", "", msg.Timestamp))
+ logs.Warn.Println("s.dispatch: unknown message", s.sid)
+ return
+ }
+
+ if globals.cluster.isPartitioned() {
+ // The cluster is partitioned due to network or other failure and this node is a part of the smaller partition.
+ // In order to avoid data inconsistency across the cluster we must reject all requests.
+ s.queueOut(ErrClusterUnreachableReply(msg, msg.Timestamp))
+ return
+ }
+
+ msg.sess = s
+ msg.init = true
+ msg.TenantID = s.tenantID
+ handler(msg)
+
+ // Notify 'me' topic that this session is currently active.
+ if uaRefresh && msg.AsUser != "" && s.userAgent != "" {
+ if sub := s.getSub(msg.AsUser); sub != nil {
+ // The chan is buffered. If the buffer is exhaused, the session will wait for 'me' to become available
+ sub.supd <- &sessionUpdate{userAgent: s.userAgent}
+ }
+ }
+}
+
+// Request to subscribe to a topic.
+func (s *Session) subscribe(msg *ClientComMessage) {
+ if strings.HasPrefix(msg.Original, "new") || strings.HasPrefix(msg.Original, "nch") {
+ // Request to create a new group/channel topic.
+ // If we are in a cluster, make sure the new topic belongs to the current node.
+ msg.RcptTo = globals.cluster.genLocalTopicName(msg.TenantID)
+ } else {
+ var resp *ServerComMessage
+ msg.RcptTo, resp = s.expandTopicName(msg)
+ if resp != nil {
+ s.queueOut(resp)
+ return
+ }
+ }
+
+ s.inflightReqs.Add(1)
+ // Session can subscribe to topic on behalf of a single user at a time.
+ if sub := s.getSub(msg.RcptTo); sub != nil {
+ s.queueOut(InfoAlreadySubscribed(msg.Id, msg.Original, msg.Timestamp))
+ s.inflightReqs.Done()
+ } else {
+ select {
+ case globals.hub.join <- msg:
+ default:
+ // Reply with a 503 to the user.
+ s.queueOut(ErrServiceUnavailableReply(msg, msg.Timestamp))
+ s.inflightReqs.Done()
+ logs.Err.Println("s.subscribe: hub.join queue full, topic ", msg.RcptTo, s.sid)
+ }
+ // Hub will send Ctrl success/failure packets back to session
+ }
+}
+
+// Leave/Unsubscribe a topic
+func (s *Session) leave(msg *ClientComMessage) {
+ // Expand topic name
+ var resp *ServerComMessage
+ msg.RcptTo, resp = s.expandTopicName(msg)
+ if resp != nil {
+ s.queueOut(resp)
+ return
+ }
+
+ s.inflightReqs.Add(1)
+ if sub := s.getSub(msg.RcptTo); sub != nil {
+ // Session is attached to the topic.
+ if (msg.Original == "me" || msg.Original == "fnd") && msg.Leave.Unsub {
+ // User should not unsubscribe from 'me' or 'find'. Just leaving is fine.
+ s.queueOut(ErrPermissionDeniedReply(msg, msg.Timestamp))
+ s.inflightReqs.Done()
+ } else {
+ // Unlink from topic, topic will send a reply.
+ sub.done <- msg
+ }
+ return
+ }
+ s.inflightReqs.Done()
+ if !msg.Leave.Unsub {
+ // Session is not attached to the topic, wants to leave - fine, no change
+ s.queueOut(InfoNotJoined(msg.Id, msg.Original, msg.Timestamp))
+ } else {
+ // Session wants to unsubscribe from the topic it did not join
+ // TODO(gene): allow topic to unsubscribe without joining first; send to hub to unsub
+ logs.Warn.Println("s.leave:", "must attach first", s.sid)
+ s.queueOut(ErrAttachFirst(msg, msg.Timestamp))
+ }
+}
+
+// Broadcast a message to all topic subscribers
+func (s *Session) publish(msg *ClientComMessage) {
+ // TODO(gene): Check for repeated messages with the same ID
+ var resp *ServerComMessage
+ msg.RcptTo, resp = s.expandTopicName(msg)
+ if resp != nil {
+ s.queueOut(resp)
+ return
+ }
+
+ // Add "sender" header if the message is sent on behalf of another user.
+ if msg.AsUser != s.uid.UserId() {
+ if msg.Pub.Head == nil {
+ msg.Pub.Head = make(map[string]any)
+ }
+ msg.Pub.Head["sender"] = s.uid.UserId()
+ } else if msg.Pub.Head != nil {
+ // Clear potentially false "sender" field.
+ delete(msg.Pub.Head, "sender")
+ if len(msg.Pub.Head) == 0 {
+ msg.Pub.Head = nil
+ }
+ }
+
+ if sub := s.getSub(msg.RcptTo); sub != nil {
+ // This is a post to a subscribed topic. The message is sent to the topic only
+ select {
+ case sub.broadcast <- msg:
+ default:
+ // Reply with a 503 to the user.
+ s.queueOut(ErrServiceUnavailableReply(msg, msg.Timestamp))
+ logs.Err.Println("s.publish: sub.broadcast channel full, topic ", msg.RcptTo, s.sid)
+ }
+ } else if msg.RcptTo == "sys" {
+ // Publishing to "sys" topic requires no subscription.
+ select {
+ case globals.hub.routeCli <- msg:
+ default:
+ // Reply with a 503 to the user.
+ s.queueOut(ErrServiceUnavailableReply(msg, msg.Timestamp))
+ logs.Err.Println("s.publish: hub.route channel full", s.sid)
+ }
+ } else {
+ // Publish request received without attaching to topic first.
+ s.queueOut(ErrAttachFirst(msg, msg.Timestamp))
+ logs.Warn.Printf("s.publish[%s]: must attach first %s", msg.RcptTo, s.sid)
+ }
+}
+
+// Client metadata
+func (s *Session) hello(msg *ClientComMessage) {
+ var params map[string]any
+ var deviceIDUpdate bool
+
+ if s.ver == 0 {
+ clientVersion := parseVersion(msg.Hi.Version)
+ if clientVersion == 0 {
+ logs.Warn.Println("s.hello:", "failed to parse version", s.sid)
+ s.queueOut(ErrMalformed(msg.Id, "", msg.Timestamp))
+ return
+ }
+ // Check version compatibility
+ if versionCompare(clientVersion, minSupportedVersionValue) < 0 {
+ s.queueOut(ErrVersionNotSupported(msg.Id, msg.Timestamp))
+ logs.Warn.Println("s.hello:", "unsupported version", s.sid)
+ return
+ }
+
+ tenantCode := strings.TrimSpace(msg.Hi.Tenant)
+ if tenantCode == "" || len(tenantCode) > 64 {
+ s.queueOut(ErrMalformed(msg.Id, "", msg.Timestamp))
+ logs.Warn.Println("s.hello: tenant code is missing or invalid", s.sid)
+ return
+ }
+ if store.Tenants == nil {
+ s.queueOut(ErrUnknown(msg.Id, "", msg.Timestamp))
+ logs.Err.Println("s.hello: tenant store is not initialized", s.sid)
+ return
+ }
+ tenant, err := store.Tenants.GetByCode(tenantCode)
+ if err != nil {
+ s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil))
+ logs.Warn.Println("s.hello: failed to resolve tenant", tenantCode, err, s.sid)
+ return
+ }
+ if tenant == nil || tenant.ID.IsZero() {
+ s.queueOut(ErrUnknown(msg.Id, "", msg.Timestamp))
+ logs.Err.Println("s.hello: tenant store returned an invalid record", tenantCode, s.sid)
+ return
+ }
+ if !tenant.IsActive() {
+ s.queueOut(ErrPermissionDenied(msg.Id, "", msg.Timestamp))
+ logs.Warn.Println("s.hello: tenant is not active", tenant.Code, s.sid)
+ return
+ }
+
+ s.ver = clientVersion
+ s.tenantID = tenant.ID
+ s.tenantCode = tenant.Code
+
+ params = map[string]any{
+ "ver": currentVersion,
+ "build": store.Store.GetAdapterName() + ":" + buildstamp,
+ "maxMessageSize": globals.maxMessageSize,
+ "maxSubscriberCount": globals.maxSubscriberCount,
+ "minTagLength": minTagLength,
+ "maxTagLength": maxTagLength,
+ "maxTagCount": globals.maxTagCount,
+ "maxFileUploadSize": globals.maxFileUploadSize,
+ "reqCred": globals.validatorClientConfig,
+ "msgDelAge": globals.msgDeleteAge.Seconds(),
+ "tenant": map[string]any{
+ "code": tenant.Code,
+ "name": tenant.Name,
+ },
+ }
+ if len(globals.iceServers) > 0 {
+ params["iceServers"] = globals.iceServers
+ }
+ if globals.callEstablishmentTimeout > 0 {
+ params["callTimeout"] = globals.callEstablishmentTimeout
+ }
+
+ if s.proto == GRPC {
+ // gRPC client may need server address to be able to fetch large files over http(s).
+ // TODO: add support for fetching files over gRPC, then remove this parameter.
+ params["servingAt"] = globals.servingAt
+ // Report cluster size.
+ if globals.cluster != nil {
+ params["clusterSize"] = len(globals.cluster.nodes) + 1
+ } else {
+ params["clusterSize"] = 1
+ }
+ }
+
+ // Set ua & platform in the beginning of the session.
+ // Don't change them later.
+ s.userAgent = msg.Hi.UserAgent
+ s.platf = msg.Hi.Platform
+ if s.platf == "" {
+ s.platf = platformFromUA(msg.Hi.UserAgent)
+ }
+ // This is a background session. Start a timer.
+ if msg.Hi.Background {
+ s.bkgTimer.Reset(deferredNotificationsTimeout)
+ }
+ } else if msg.Hi.Version == "" || parseVersion(msg.Hi.Version) == s.ver {
+ if tenantCode := strings.TrimSpace(msg.Hi.Tenant); tenantCode != "" && tenantCode != s.tenantCode {
+ s.queueOut(ErrCommandOutOfSequence(msg.Id, "", msg.Timestamp))
+ logs.Warn.Println("s.hello: tenant cannot be changed", s.sid)
+ return
+ }
+ // Save changed device ID+Lang or delete earlier specified device ID.
+ // Platform cannot be changed.
+ if !s.uid.IsZero() {
+ var err error
+ if msg.Hi.DeviceID == types.NullValue {
+ // User wants to delete device ID.
+ deviceIDUpdate = true
+ if s.deviceID != "" {
+ err = store.Devices.Delete(s.tenantID, s.uid, s.deviceID)
+ }
+ } else if msg.Hi.DeviceID != "" && s.deviceID != msg.Hi.DeviceID {
+ deviceIDUpdate = true
+ err = store.Devices.Update(s.tenantID, s.uid, s.deviceID, &types.DeviceDef{
+ TenantID: s.tenantID,
+ DeviceId: msg.Hi.DeviceID,
+ Platform: s.platf,
+ LastSeen: msg.Timestamp,
+ Lang: msg.Hi.Lang,
+ })
+
+ userChannelsSubUnsub(s.tenantID, s.uid, msg.Hi.DeviceID, true)
+ }
+
+ if err != nil {
+ s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil))
+ logs.Warn.Println("s.hello:", "device ID", err, s.sid)
+ return
+ }
+ } else {
+ // Session is not authenticated, report an error. Otherwise,
+ // the client may think that the device ID was updated successfully,
+ // but it will not be saved in the database.
+ s.queueOut(ErrAuthRequiredReply(msg, msg.Timestamp))
+ logs.Warn.Println("s.hello:", "device ID update requires authentication", s.sid)
+ return
+ }
+ } else {
+ // Version cannot be changed mid-session.
+ s.queueOut(ErrCommandOutOfSequence(msg.Id, "", msg.Timestamp))
+ logs.Warn.Println("s.hello:", "version cannot be changed", s.sid)
+ return
+ }
+
+ if msg.Hi.DeviceID == types.NullValue {
+ msg.Hi.DeviceID = ""
+ }
+ s.deviceID = msg.Hi.DeviceID
+ s.lang = msg.Hi.Lang
+ // Try to deduce the country from the locale.
+ // Tag may be well-defined even if err != nil. For example, for 'zh_CN_#Hans'
+ // the tag is 'zh-CN' exact but the err is 'tag is not well-formed'.
+ if tag, _ := language.Parse(s.lang); tag != language.Und {
+ if region, conf := tag.Region(); region.IsCountry() && conf >= language.High {
+ s.countryCode = region.String()
+ }
+ }
+
+ if s.countryCode == "" {
+ if len(s.lang) > 2 {
+ // Logging strings longer than 2 b/c language.Parse(XX) always succeeds
+ // returning confidence Low.
+ logs.Warn.Println("s.hello:", "could not parse locale ", s.lang)
+ }
+ s.countryCode = globals.defaultCountryCode
+ }
+
+ var httpStatus int
+ var httpStatusText string
+ if s.proto == LPOLL || deviceIDUpdate {
+ // In case of long polling StatusCreated was reported earlier.
+ // In case of deviceID update just report success.
+ httpStatus = http.StatusOK
+ httpStatusText = "ok"
+
+ } else {
+ httpStatus = http.StatusCreated
+ httpStatusText = "created"
+ }
+
+ ctrl := &MsgServerCtrl{Id: msg.Id, Code: httpStatus, Text: httpStatusText, Timestamp: msg.Timestamp}
+ if len(params) > 0 {
+ ctrl.Params = params
+ }
+ s.queueOut(&ServerComMessage{Ctrl: ctrl})
+}
+
+// Account creation
+func (s *Session) acc(msg *ClientComMessage) {
+ newAcc := strings.HasPrefix(msg.Acc.User, "new")
+
+ // If temporary auth parameters are provided, get the user ID from them.
+ var rec *auth.Rec
+ if !newAcc && msg.Acc.TmpScheme != "" {
+ if !s.uid.IsZero() {
+ s.queueOut(ErrAlreadyAuthenticated(msg.Acc.Id, "", msg.Timestamp))
+ logs.Warn.Println("s.acc: got temp auth while already authenticated", s.sid)
+ return
+ }
+
+ authHdl := store.Store.GetLogicalAuthHandler(msg.Acc.TmpScheme)
+ if authHdl == nil {
+ logs.Warn.Println("s.acc: unknown authentication scheme", msg.Acc.TmpScheme, s.sid)
+ s.queueOut(ErrAuthUnknownScheme(msg.Id, "", msg.Timestamp))
+ }
+
+ var err error
+ rec, _, err = authHdl.Authenticate(auth.AuthContext{TenantID: s.tenantID, RemoteAddr: s.remoteAddr},
+ msg.Acc.TmpSecret)
+ if err != nil {
+ s.queueOut(decodeStoreError(err, msg.Acc.Id, msg.Timestamp,
+ map[string]any{"what": "auth"}))
+ logs.Warn.Println("s.acc: invalid temp auth", err, s.sid)
+ return
+ }
+ if rec == nil || rec.TenantID != s.tenantID {
+ s.queueOut(decodeStoreError(types.ErrFailed, msg.Acc.Id, msg.Timestamp,
+ map[string]any{"what": "auth"}))
+ logs.Warn.Println("s.acc: temp auth returned invalid tenant", s.sid)
+ return
+ }
+ }
+
+ if newAcc {
+ // New account
+ replyCreateUser(s, msg, rec)
+ } else {
+ // Existing account.
+ replyUpdateUser(s, msg, rec)
+ }
+}
+
+// Authenticate
+func (s *Session) login(msg *ClientComMessage) {
+ // msg.from is ignored here
+
+ if msg.Login.Scheme == "reset" {
+ if err := s.authSecretReset(msg.Login.Secret); err != nil {
+ s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil))
+ } else {
+ s.queueOut(InfoAuthReset(msg.Id, msg.Timestamp))
+ }
+ return
+ }
+
+ if !s.uid.IsZero() {
+ // TODO: change error to notice InfoNoChange and return current user ID & auth level
+ // params := map[string]interface{}{"user": s.uid.UserId(), "authlvl": s.authLevel.String()}
+ s.queueOut(ErrAlreadyAuthenticated(msg.Id, "", msg.Timestamp))
+ return
+ }
+
+ handler := store.Store.GetLogicalAuthHandler(msg.Login.Scheme)
+ if handler == nil {
+ logs.Warn.Println("s.login: unknown authentication scheme", msg.Login.Scheme, s.sid)
+ s.queueOut(ErrAuthUnknownScheme(msg.Id, "", msg.Timestamp))
+ return
+ }
+
+ rec, challenge, err := handler.Authenticate(
+ auth.AuthContext{TenantID: s.tenantID, RemoteAddr: s.remoteAddr}, msg.Login.Secret)
+ if err != nil {
+ resp := decodeStoreError(err, msg.Id, msg.Timestamp, nil)
+ if resp.Ctrl.Code >= 500 {
+ // Log internal errors
+ logs.Warn.Println("s.login: internal", err, s.sid)
+ }
+ s.queueOut(resp)
+ return
+ }
+
+ if rec == nil || rec.TenantID != s.tenantID {
+ logs.Warn.Println("s.login: authenticator returned invalid tenant", s.sid)
+ s.queueOut(decodeStoreError(types.ErrFailed, msg.Id, msg.Timestamp, nil))
+ return
+ }
+
+ // If authenticator did not check user state, it returns state "undef". If so, check user state here.
+ if rec.State == types.StateUndefined {
+ rec.State, err = userGetState(s.tenantID, rec.Uid)
+ }
+ if err == nil && rec.State != types.StateOK {
+ err = types.ErrPermissionDenied
+ }
+
+ if err != nil {
+ logs.Warn.Println("s.login: user state check failed", rec.Uid, err, s.sid)
+ s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil))
+ return
+ }
+
+ if challenge != nil {
+ // Multi-stage authentication. Issue challenge to the client.
+ s.queueOut(InfoChallenge(msg.Id, msg.Timestamp, challenge))
+ return
+ }
+
+ var missing []string
+ if rec.Features&auth.FeatureValidated == 0 && len(globals.authValidators[rec.AuthLevel]) > 0 {
+ var validated []string
+ // Check responses. Ignore invalid responses, just keep cred unvalidated.
+ if validated, _, err = validatedCreds(s.tenantID, rec.Uid, rec.AuthLevel, msg.Login.Cred, false); err == nil {
+ // Get a list of credentials which have not been validated.
+ _, missing, _ = stringSliceDelta(globals.authValidators[rec.AuthLevel], validated)
+ }
+ }
+ if err != nil {
+ logs.Warn.Println("s.login: failed to validate credentials:", err, s.sid)
+ s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil))
+ } else {
+ s.queueOut(s.onLogin(msg.Id, msg.Timestamp, rec, missing))
+ }
+}
+
+// authSecretReset resets an authentication secret;
+// params: "auth-method-to-reset:credential-method:credential-value",
+// for example: "basic:email:alice@example.com".
+func (s *Session) authSecretReset(params []byte) error {
+ var authScheme, credMethod, credValue string
+ if parts := strings.Split(string(params), ":"); len(parts) >= 3 {
+ authScheme, credMethod, credValue = parts[0], parts[1], parts[2]
+ } else {
+ return types.ErrMalformed
+ }
+
+ // Technically we don't need to check it here, but we are going to mail the 'authScheme' string to the user.
+ // We have to make sure it does not contain any exploits. This is the simplest check.
+ auther := store.Store.GetLogicalAuthHandler(authScheme)
+ if auther == nil {
+ return types.ErrUnsupported
+ }
+ validator := store.Store.GetValidator(credMethod)
+ if validator == nil {
+ return types.ErrUnsupported
+ }
+ uid, err := store.Users.GetByCred(s.tenantID, credMethod, credValue)
+ if err != nil {
+ return err
+ }
+ if uid.IsZero() {
+ // Prevent discovery of existing contacts: report "no error" if contact is not found.
+ return nil
+ }
+
+ resetParams, err := auther.GetResetParams(s.tenantID, uid)
+ if err != nil {
+ return err
+ }
+ tempScheme, err := validator.TempAuthScheme()
+ if err != nil {
+ return err
+ }
+
+ tempAuth := store.Store.GetLogicalAuthHandler(tempScheme)
+ if tempAuth == nil || !tempAuth.IsInitialized() {
+ logs.Err.Println("s.authSecretReset: validator with missing temp auth", credMethod, tempScheme, s.sid)
+ return types.ErrInternal
+ }
+
+ code, _, err := tempAuth.GenSecret(&auth.Rec{
+ TenantID: s.tenantID,
+ Uid: uid,
+ AuthLevel: auth.LevelAuth,
+ Features: auth.FeatureNoLogin,
+ Credential: credMethod + ":" + credValue,
+ })
+ if err != nil {
+ return err
+ }
+
+ return validator.ResetSecret(s.tenantID, credValue, authScheme, s.lang, code, resetParams)
+}
+
+// onLogin performs steps after successful authentication.
+func (s *Session) onLogin(msgID string, timestamp time.Time, rec *auth.Rec, missing []string) *ServerComMessage {
+ var reply *ServerComMessage
+ var params map[string]any
+
+ features := rec.Features
+
+ params = map[string]any{
+ "user": rec.Uid.UserId(),
+ "authlvl": rec.AuthLevel.String(),
+ }
+ if len(missing) > 0 {
+ // Some credentials are not validated yet. Respond with request for validation.
+ reply = InfoValidateCredentials(msgID, timestamp)
+
+ params["cred"] = missing
+ } else {
+ // Everything is fine, authenticate the session.
+
+ reply = NoErr(msgID, "", timestamp)
+
+ // Check if the token is suitable for session authentication.
+ if features&auth.FeatureNoLogin == 0 {
+ // Authenticate the session.
+ s.uid = rec.Uid
+ s.authLvl = rec.AuthLevel
+ // Reset expiration time.
+ rec.Lifetime = 0
+ }
+ features |= auth.FeatureValidated
+
+ // Record deviceId used in this session
+ if s.deviceID != "" {
+ if err := store.Devices.Update(s.tenantID, rec.Uid, "", &types.DeviceDef{
+ TenantID: s.tenantID,
+ DeviceId: s.deviceID,
+ Platform: s.platf,
+ LastSeen: timestamp,
+ Lang: s.lang,
+ }); err != nil {
+ logs.Warn.Println("failed to update device record", err)
+ }
+ }
+ }
+
+ // GenSecret fails only if tokenLifetime is < 0. It can't be < 0 here,
+ // otherwise login would have failed earlier.
+ rec.Features = features
+ params["token"], params["expires"], _ = store.Store.GetLogicalAuthHandler("token").GenSecret(rec)
+
+ reply.Ctrl.Params = params
+ return reply
+}
+
+func (s *Session) get(msg *ClientComMessage) {
+ // Expand topic name.
+ var resp *ServerComMessage
+ msg.RcptTo, resp = s.expandTopicName(msg)
+ if resp != nil {
+ s.queueOut(resp)
+ return
+ }
+
+ msg.MetaWhat = parseMsgClientMeta(msg.Get.What)
+
+ sub := s.getSub(msg.RcptTo)
+ if msg.MetaWhat == 0 {
+ s.queueOut(ErrMalformedReply(msg, msg.Timestamp))
+ logs.Warn.Println("s.get: invalid Get message action", msg.Get.What)
+ } else if sub != nil {
+ select {
+ case sub.meta <- msg:
+ default:
+ // Reply with a 503 to the user.
+ s.queueOut(ErrServiceUnavailableReply(msg, msg.Timestamp))
+ logs.Err.Println("s.get: sub.meta channel full, topic ", msg.RcptTo, s.sid)
+ }
+ } else if msg.MetaWhat&(constMsgMetaDesc|constMsgMetaSub) != 0 {
+ // Request some minimal info from a topic not currently attached to.
+ select {
+ case globals.hub.meta <- msg:
+ default:
+ // Reply with a 503 to the user.
+ s.queueOut(ErrServiceUnavailableReply(msg, msg.Timestamp))
+ logs.Err.Println("s.get: hub.meta channel full", s.sid)
+ }
+ } else {
+ logs.Warn.Println("s.get: subscribe first to get=", msg.Get.What)
+ s.queueOut(ErrPermissionDeniedReply(msg, msg.Timestamp))
+ }
+}
+
+func (s *Session) set(msg *ClientComMessage) {
+ // Expand topic name.
+ var resp *ServerComMessage
+ msg.RcptTo, resp = s.expandTopicName(msg)
+ if resp != nil {
+ s.queueOut(resp)
+ return
+ }
+
+ if msg.Set.Desc != nil {
+ msg.MetaWhat = constMsgMetaDesc
+ }
+ if msg.Set.Sub != nil {
+ msg.MetaWhat |= constMsgMetaSub
+ }
+ if msg.Set.Tags != nil {
+ msg.MetaWhat |= constMsgMetaTags
+ }
+ if msg.Set.Cred != nil {
+ msg.MetaWhat |= constMsgMetaCred
+ }
+ if msg.Set.Aux != nil {
+ msg.MetaWhat |= constMsgMetaAux
+ }
+
+ if msg.MetaWhat == 0 {
+ s.queueOut(ErrMalformedReply(msg, msg.Timestamp))
+ logs.Warn.Println("s.set: nil Set action")
+ } else if sub := s.getSub(msg.RcptTo); sub != nil {
+ select {
+ case sub.meta <- msg:
+ default:
+ // Reply with a 503 to the user.
+ s.queueOut(ErrServiceUnavailableReply(msg, msg.Timestamp))
+ logs.Err.Println("s.set: sub.meta channel full, topic ", msg.RcptTo, s.sid)
+ }
+ } else if msg.MetaWhat&(constMsgMetaTags|constMsgMetaCred|constMsgMetaAux) != 0 {
+ logs.Warn.Println("s.set: setting tags/creds/aux is allowed for subscribed topics only", msg.MetaWhat)
+ s.queueOut(ErrPermissionDeniedReply(msg, msg.Timestamp))
+ } else {
+ // Desc.Private and Sub updates are possible without the subscription.
+ select {
+ case globals.hub.meta <- msg:
+ default:
+ // Reply with a 503 to the user.
+ s.queueOut(ErrServiceUnavailableReply(msg, msg.Timestamp))
+ logs.Err.Println("s.set: hub.meta channel full", s.sid)
+ }
+ }
+}
+
+func (s *Session) del(msg *ClientComMessage) {
+ msg.MetaWhat = parseMsgClientDel(msg.Del.What)
+
+ // Delete user
+ if msg.MetaWhat == constMsgDelUser {
+ replyDelUser(s, msg)
+ return
+ }
+
+ // Delete something other than user: topic, subscription, message(s)
+
+ // Expand topic name and validate request.
+ var resp *ServerComMessage
+ msg.RcptTo, resp = s.expandTopicName(msg)
+ if resp != nil {
+ s.queueOut(resp)
+ return
+ }
+
+ if msg.MetaWhat == 0 {
+ s.queueOut(ErrMalformedReply(msg, msg.Timestamp))
+ logs.Warn.Println("s.del: invalid Del action", msg.Del.What, s.sid)
+ return
+ }
+
+ if msg.MetaWhat == constMsgDelTopic {
+ // Deleting topic: for sessions attached or not attached, send request to hub first.
+ // Hub will forward to topic, if appropriate.
+ select {
+ case globals.hub.unreg <- &topicUnreg{
+ tenantID: s.tenantID,
+ rcptTo: msg.RcptTo,
+ pkt: msg,
+ sess: s,
+ del: true,
+ }:
+ default:
+ // Reply with a 503 to the user.
+ s.queueOut(ErrServiceUnavailableReply(msg, msg.Timestamp))
+ logs.Err.Println("s.del: hub.unreg channel full", s.sid)
+ }
+ } else if sub := s.getSub(msg.RcptTo); sub != nil {
+ // Session is attached, deleting subscription or messages. Send to topic.
+ select {
+ case sub.meta <- msg:
+ default:
+ // Reply with a 503 to the user.
+ s.queueOut(ErrServiceUnavailableReply(msg, msg.Timestamp))
+ logs.Err.Println("s.del: sub.meta channel full, topic ", msg.RcptTo, s.sid)
+ }
+ } else {
+ // Must join the topic to delete messages or subscriptions.
+ s.queueOut(ErrAttachFirst(msg, msg.Timestamp))
+ logs.Warn.Println("s.del: invalid Del action while unsubbed", msg.Del.What, s.sid)
+ }
+}
+
+// Broadcast a transient message to active topic subscribers.
+// Not reporting any errors.
+func (s *Session) note(msg *ClientComMessage) {
+ if s.ver == 0 || msg.AsUser == "" {
+ // Silently ignore the message: have not received {hi} or don't know who sent the message.
+ return
+ }
+
+ // Expand topic name and validate request.
+ var resp *ServerComMessage
+ msg.RcptTo, resp = s.expandTopicName(msg)
+ if resp != nil {
+ // Silently ignoring the message
+ return
+ }
+
+ switch msg.Note.What {
+ case "data":
+ if msg.Note.Payload == nil {
+ // Payload must be present in 'data' notifications.
+ return
+ }
+ case "kp", "kpa", "kpv":
+ if msg.Note.SeqId != 0 {
+ return
+ }
+ case "call":
+ if types.GetTopicCat(msg.RcptTo) != types.TopicCatP2P {
+ // Calls are only available in P2P topics.
+ return
+ }
+ fallthrough
+ case "read", "recv":
+ if msg.Note.SeqId <= 0 {
+ return
+ }
+ default:
+ return
+ }
+
+ if sub := s.getSub(msg.RcptTo); sub != nil {
+ // Pings can be sent to subscribed topics only
+ select {
+ case sub.broadcast <- msg:
+ default:
+ // Reply with a 503 to the user.
+ s.queueOut(ErrServiceUnavailableReply(msg, msg.Timestamp))
+ logs.Err.Println("s.note: sub.broacast channel full, topic ", msg.RcptTo, s.sid)
+ }
+ } else if msg.Note.What == "recv" || (msg.Note.What == "call" && (msg.Note.Event == "ringing" || msg.Note.Event == "hang-up" || msg.Note.Event == "accept")) {
+ // One of the following events happened:
+ // 1. Client received a pres notification about a new message, initiated a fetch
+ // from the server (and detached from the topic) and acknowledges receipt.
+ // 2. Client is either accepting or terminating the current video call or
+ // letting the initiator of the call know that it is ringing/notifying
+ // the user about the call.
+ //
+ // Hub will forward to topic, if appropriate.
+ select {
+ case globals.hub.routeCli <- msg:
+ default:
+ // Reply with a 503 to the user.
+ s.queueOut(ErrServiceUnavailableReply(msg, msg.Timestamp))
+ logs.Err.Println("s.note: hub.route channel full", s.sid)
+ }
+ } else {
+ s.queueOut(ErrAttachFirst(msg, msg.Timestamp))
+ logs.Warn.Println("s.note: note to invalid topic - must subscribe first", msg.Note.What, s.sid)
+ }
+}
+
+// expandTopicName expands session specific topic name to global name
+// Returns
+//
+// topic: session-specific topic name the message recipient should see
+// routeTo: routable global topic name
+// err: *ServerComMessage with an error to return to the sender
+func (s *Session) expandTopicName(msg *ClientComMessage) (string, *ServerComMessage) {
+ if msg.Original == "" {
+ logs.Warn.Println("s.etn: empty topic name", s.sid)
+ return "", ErrMalformed(msg.Id, "", msg.Timestamp)
+ }
+
+ // Expanded name of the topic to route to i.e. rcptto: or s.subs[routeTo]
+ var routeTo string
+ if msg.Original == "me" {
+ routeTo = msg.AsUser
+ } else if msg.Original == "fnd" {
+ routeTo = types.ParseUserId(msg.AsUser).FndName()
+ } else if msg.Original == "slf" {
+ routeTo = types.ParseUserId(msg.AsUser).SlfName()
+ } else if strings.HasPrefix(msg.Original, "usr") {
+ // p2p topic
+ uid1 := types.ParseUserId(msg.AsUser)
+ uid2 := types.ParseUserId(msg.Original)
+ if uid2.IsZero() {
+ // Ensure the user id is valid.
+ logs.Warn.Println("s.etn: failed to parse p2p topic name", s.sid)
+ return "", ErrMalformed(msg.Id, msg.Original, msg.Timestamp)
+ } else if uid2 == uid1 {
+ // Use 'me' to access self-topic.
+ logs.Warn.Println("s.etn: invalid p2p self-subscription", s.sid)
+ return "", ErrPermissionDeniedReply(msg, msg.Timestamp)
+ }
+ routeTo = uid1.P2PName(uid2)
+ } else if tmp := types.ChnToGrp(msg.Original); tmp != "" {
+ routeTo = tmp
+ } else {
+ routeTo = msg.Original
+ }
+
+ return routeTo, nil
+}
+
+func (s *Session) serializeAndUpdateStats(msg *ServerComMessage) any {
+ dataSize, data := s.serialize(msg)
+ if dataSize >= 0 {
+ statsAddHistSample("OutgoingMessageSize", float64(dataSize))
+ }
+ return data
+}
+
+func (s *Session) serialize(msg *ServerComMessage) (int, any) {
+ if s.proto == GRPC {
+ msg := pbServSerialize(msg)
+ // TODO: calculate and return the size of `msg`.
+ return -1, msg
+ }
+
+ if s.isMultiplex() {
+ // No need to serialize the message to bytes within the cluster.
+ return -1, msg
+ }
+
+ out, _ := json.Marshal(msg)
+ return len(out), out
+}
+
+// onBackgroundTimer marks background session as foreground and informs topics it's subscribed to.
+func (s *Session) onBackgroundTimer() {
+ s.subsLock.RLock()
+ defer s.subsLock.RUnlock()
+
+ update := &sessionUpdate{sess: s}
+ for _, sub := range s.subs {
+ if sub.supd != nil {
+ sub.supd <- update
+ }
+ }
+}
diff --git a/server/session_test.go b/server/session_test.go
index e69de29..d2bdb82 100644
--- a/server/session_test.go
+++ b/server/session_test.go
@@ -0,0 +1,1327 @@
+package main
+
+import (
+ "net/http"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/golang/mock/gomock"
+ "github.com/tinode/chat/server/auth"
+ "github.com/tinode/chat/server/auth/mock_auth"
+ "github.com/tinode/chat/server/store"
+ "github.com/tinode/chat/server/store/mock_store"
+ "github.com/tinode/chat/server/store/types"
+)
+
+type testTenantStore struct {
+ tenant *types.Tenant
+ err error
+}
+
+func (s testTenantStore) GetByCode(string) (*types.Tenant, error) {
+ return s.tenant, s.err
+}
+
+func test_makeSession(uid types.Uid) *Session {
+ return &Session{
+ send: make(chan any, 10),
+ uid: uid,
+ authLvl: auth.LevelAuth,
+ inflightReqs: newBoundedWaitGroup(1),
+ ver: 22,
+ tenantID: 1,
+ tenantCode: "test",
+ }
+}
+
+func TestDispatchHello(t *testing.T) {
+ oldTenants := store.Tenants
+ store.Tenants = testTenantStore{tenant: &types.Tenant{
+ ID: 7,
+ Code: "acme",
+ Name: "Acme Corp",
+ State: types.TenantStateActive,
+ }}
+ defer func() { store.Tenants = oldTenants }()
+
+ s := &Session{
+ send: make(chan any, 10),
+ uid: types.Uid(1),
+ authLvl: auth.LevelAuth,
+ }
+ wg := sync.WaitGroup{}
+ r := responses{}
+ wg.Add(1)
+ go s.testWriteLoop(&r, &wg)
+ msg := &ClientComMessage{
+ Hi: &MsgClientHi{
+ Id: "123",
+ Version: "1",
+ UserAgent: "test-ua",
+ Lang: "en-GB",
+ Tenant: "acme",
+ },
+ }
+ s.dispatch(msg)
+ close(s.send)
+ wg.Wait()
+ if len(r.messages) != 1 {
+ t.Errorf("responses: expected 1, received %d.", len(r.messages))
+ }
+ resp := r.messages[0].(*ServerComMessage)
+ if resp == nil {
+ t.Fatal("Response must be ServerComMessage")
+ }
+ if resp.Ctrl != nil {
+ if resp.Ctrl.Code != 201 {
+ t.Errorf("Response code: expected 201, got %d", resp.Ctrl.Code)
+ }
+ if resp.Ctrl.Params == nil {
+ t.Error("Response is expected to contain params dict.")
+ }
+ } else {
+ t.Error("Response must contain a ctrl message.")
+ }
+
+ if s.lang != "en-GB" {
+ t.Errorf("Session language expected to be 'en-GB' vs '%s'", s.lang)
+ }
+ if s.userAgent != "test-ua" {
+ t.Errorf("Session UA expected to be 'test-ua' vs '%s'", s.userAgent)
+ }
+ if s.countryCode != "GB" {
+ t.Errorf("Country code expected to be 'GB' vs '%s'", s.countryCode)
+ }
+ if s.ver == 0 {
+ t.Errorf("s.ver expected 0 vs found %d", s.ver)
+ }
+ if s.tenantID != 7 || s.tenantCode != "acme" {
+ t.Errorf("unexpected tenant binding: id=%d code=%q", s.tenantID, s.tenantCode)
+ }
+ tenantParams := resp.Ctrl.Params.(map[string]any)["tenant"].(map[string]any)
+ if tenantParams["code"] != "acme" || tenantParams["name"] != "Acme Corp" {
+ t.Errorf("unexpected public tenant params: %#v", tenantParams)
+ }
+}
+
+func TestDispatchHelloRequiresTenant(t *testing.T) {
+ s := &Session{send: make(chan any, 1)}
+ s.dispatch(&ClientComMessage{Hi: &MsgClientHi{Id: "123", Version: "1"}})
+ resp := (<-s.send).(*ServerComMessage)
+ if resp.Ctrl.Code != http.StatusBadRequest {
+ t.Fatalf("response code: expected %d, got %d", http.StatusBadRequest, resp.Ctrl.Code)
+ }
+ if !s.tenantID.IsZero() || s.ver != 0 {
+ t.Fatalf("invalid hello must not bind session: tenant=%d version=%d", s.tenantID, s.ver)
+ }
+}
+
+func TestDispatchHelloRejectsInactiveTenant(t *testing.T) {
+ oldTenants := store.Tenants
+ store.Tenants = testTenantStore{tenant: &types.Tenant{
+ ID: 7,
+ Code: "acme",
+ Name: "Acme Corp",
+ State: types.TenantStateSuspended,
+ }}
+ defer func() { store.Tenants = oldTenants }()
+
+ s := &Session{send: make(chan any, 1)}
+ s.dispatch(&ClientComMessage{Hi: &MsgClientHi{Id: "123", Version: "1", Tenant: "acme"}})
+ resp := (<-s.send).(*ServerComMessage)
+ if resp.Ctrl.Code != http.StatusForbidden {
+ t.Fatalf("response code: expected %d, got %d", http.StatusForbidden, resp.Ctrl.Code)
+ }
+ if !s.tenantID.IsZero() || s.ver != 0 {
+ t.Fatalf("inactive tenant must not bind session: tenant=%d version=%d", s.tenantID, s.ver)
+ }
+}
+
+func TestDispatchHelloRejectsTenantChange(t *testing.T) {
+ s := &Session{
+ send: make(chan any, 1),
+ uid: 1,
+ ver: parseVersion("1"),
+ tenantID: 7,
+ tenantCode: "acme",
+ }
+ s.dispatch(&ClientComMessage{Hi: &MsgClientHi{Id: "123", Version: "1", Tenant: "other"}})
+ resp := (<-s.send).(*ServerComMessage)
+ if resp.Ctrl.Code != http.StatusConflict {
+ t.Fatalf("response code: expected %d, got %d", http.StatusConflict, resp.Ctrl.Code)
+ }
+ if s.tenantID != 7 || s.tenantCode != "acme" {
+ t.Fatalf("tenant binding changed: tenant=%d code=%q", s.tenantID, s.tenantCode)
+ }
+}
+
+func verifyResponseCodes(r *responses, codes []int, t *testing.T) {
+ if len(r.messages) != len(codes) {
+ t.Errorf("responses: expected %d, received %d.", len(codes), len(r.messages))
+ }
+ for i := range codes {
+ resp := r.messages[i].(*ServerComMessage)
+ if resp == nil {
+ t.Fatalf("Response %d must be ServerComMessage", i)
+ }
+ if resp.Ctrl == nil {
+ t.Fatalf("Response %d must contain a ctrl message.", i)
+ }
+ if resp.Ctrl.Code != codes[i] {
+ t.Errorf("Response code: expected %d, got %d", codes[i], resp.Ctrl.Code)
+ }
+ }
+}
+
+func TestDispatchInvalidVersion(t *testing.T) {
+ s := &Session{
+ send: make(chan any, 10),
+ uid: types.Uid(1),
+ authLvl: auth.LevelAuth,
+ }
+ wg := sync.WaitGroup{}
+ r := responses{}
+ wg.Add(1)
+ go s.testWriteLoop(&r, &wg)
+ msg := &ClientComMessage{
+ Hi: &MsgClientHi{
+ Id: "123",
+ // Invalid version string.
+ Version: "INVALID VERSION STRING",
+ },
+ }
+ s.dispatch(msg)
+ close(s.send)
+ wg.Wait()
+ verifyResponseCodes(&r, []int{http.StatusBadRequest}, t)
+}
+
+func TestDispatchUnsupportedVersion(t *testing.T) {
+ s := &Session{
+ send: make(chan any, 10),
+ uid: types.Uid(1),
+ authLvl: auth.LevelAuth,
+ }
+ wg := sync.WaitGroup{}
+ r := responses{}
+ wg.Add(1)
+ go s.testWriteLoop(&r, &wg)
+ msg := &ClientComMessage{
+ Hi: &MsgClientHi{
+ Id: "123",
+ // Invalid version string.
+ Version: "0.1",
+ },
+ }
+ s.dispatch(msg)
+ close(s.send)
+ wg.Wait()
+ verifyResponseCodes(&r, []int{http.StatusHTTPVersionNotSupported}, t)
+}
+
+func TestDispatchLogin(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ ss := mock_store.NewMockPersistentStorageInterface(ctrl)
+ aa := mock_auth.NewMockAuthHandler(ctrl)
+
+ uid := types.Uid(1)
+ store.Store = ss
+ defer func() {
+ store.Store = nil
+ ctrl.Finish()
+ }()
+
+ secret := "<==auth-secret==>"
+ authRec := &auth.Rec{
+ TenantID: 1,
+ Uid: uid,
+ AuthLevel: auth.LevelAuth,
+ Tags: []string{"tag1", "tag2"},
+ State: types.StateOK,
+ }
+ ss.EXPECT().GetLogicalAuthHandler("basic").Return(aa)
+ aa.EXPECT().Authenticate(auth.AuthContext{TenantID: 1}, []byte(secret)).Return(authRec, nil, nil)
+ // Token generation.
+ ss.EXPECT().GetLogicalAuthHandler("token").Return(aa)
+ token := "<==auth-token==>"
+ expires, _ := time.Parse(time.RFC822, "01 Jan 50 00:00 UTC")
+ aa.EXPECT().GenSecret(authRec).Return([]byte(token), expires, nil)
+
+ s := &Session{
+ send: make(chan any, 10),
+ authLvl: auth.LevelAuth,
+ ver: 16,
+ tenantID: 1,
+ tenantCode: "test",
+ }
+ wg := sync.WaitGroup{}
+ r := responses{}
+ wg.Add(1)
+ go s.testWriteLoop(&r, &wg)
+
+ msg := &ClientComMessage{
+ Login: &MsgClientLogin{
+ Id: "123",
+ Scheme: "basic",
+ Secret: []byte(secret),
+ },
+ }
+
+ s.dispatch(msg)
+ close(s.send)
+ wg.Wait()
+
+ if len(r.messages) != 1 {
+ t.Errorf("responses: expected 1, received %d.", len(r.messages))
+ }
+ resp := r.messages[0].(*ServerComMessage)
+ if resp == nil {
+ t.Fatal("Response must be ServerComMessage")
+ }
+ if resp.Ctrl != nil {
+ if resp.Ctrl.Id != "123" {
+ t.Errorf("Response id: expected '123', found '%s'", resp.Ctrl.Id)
+ }
+ if resp.Ctrl.Code != 200 {
+ t.Errorf("Response code: expected 200, got %d", resp.Ctrl.Code)
+ }
+ if resp.Ctrl.Params == nil {
+ t.Error("Response is expected to contain params dict.")
+ }
+ p := resp.Ctrl.Params.(map[string]any)
+ if authToken := string(p["token"].([]byte)); authToken != token {
+ t.Errorf("Auth token: expected '%s', found '%s'.", token, authToken)
+ }
+ if exp := p["expires"].(time.Time); exp != expires {
+ t.Errorf("Token expiration: expected '%s', found '%s'.", expires, exp)
+ }
+ } else {
+ t.Error("Response must contain a ctrl message.")
+ }
+}
+
+func TestDispatchLoginRejectsAuthenticatorTenantMismatch(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ ss := mock_store.NewMockPersistentStorageInterface(ctrl)
+ aa := mock_auth.NewMockAuthHandler(ctrl)
+ store.Store = ss
+ defer func() {
+ store.Store = nil
+ ctrl.Finish()
+ }()
+
+ secret := []byte("alice:password")
+ ss.EXPECT().GetLogicalAuthHandler("basic").Return(aa)
+ aa.EXPECT().Authenticate(auth.AuthContext{TenantID: 1}, secret).Return(&auth.Rec{
+ TenantID: 2,
+ Uid: 42,
+ AuthLevel: auth.LevelAuth,
+ State: types.StateOK,
+ }, nil, nil)
+
+ s := &Session{send: make(chan any, 1), ver: 16, tenantID: 1, tenantCode: "acme"}
+ s.login(&ClientComMessage{
+ Login: &MsgClientLogin{Id: "login-1", Scheme: "basic", Secret: secret},
+ Timestamp: time.Now(),
+ })
+
+ resp := (<-s.send).(*ServerComMessage)
+ if resp.Ctrl == nil || resp.Ctrl.Code != http.StatusUnauthorized {
+ t.Fatalf("unexpected response: %+v", resp)
+ }
+ if !s.uid.IsZero() {
+ t.Fatalf("tenant mismatch authenticated session as %s", s.uid.UserId())
+ }
+}
+
+func TestDispatchRejectsCrossTenantAsUser(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ users := mock_store.NewMockUsersPersistenceInterface(ctrl)
+ oldUsers := store.Users
+ store.Users = users
+ defer func() {
+ store.Users = oldUsers
+ ctrl.Finish()
+ }()
+
+ target := types.Uid(42)
+ users.EXPECT().Get(types.TenantID(1), target).Return(&types.User{TenantID: 2, State: types.StateOK}, nil)
+ s := &Session{
+ send: make(chan any, 1),
+ uid: 1,
+ authLvl: auth.LevelRoot,
+ ver: 16,
+ tenantID: 1,
+ tenantCode: "acme",
+ }
+ s.dispatch(&ClientComMessage{Extra: &MsgClientExtra{AsUser: target.UserId()}})
+
+ resp := (<-s.send).(*ServerComMessage)
+ if resp.Ctrl == nil || resp.Ctrl.Code != http.StatusForbidden {
+ t.Fatalf("unexpected response: %+v", resp)
+ }
+}
+
+func TestDispatchSubscribe(t *testing.T) {
+ uid := types.Uid(1)
+ s := test_makeSession(uid)
+ wg := sync.WaitGroup{}
+ r := responses{}
+ wg.Add(1)
+ go s.testWriteLoop(&r, &wg)
+
+ hub := &Hub{
+ join: make(chan *ClientComMessage, 10),
+ }
+ globals.hub = hub
+
+ defer func() {
+ globals.hub = nil
+ }()
+
+ msg := &ClientComMessage{
+ Sub: &MsgClientSub{
+ Id: "123",
+ Topic: "me",
+ Get: &MsgGetQuery{
+ What: "sub desc tags cred",
+ },
+ },
+ }
+
+ s.dispatch(msg)
+ close(s.send)
+ wg.Wait()
+
+ // Check we've routed the join request via the hub.
+ if len(r.messages) != 0 {
+ t.Errorf("responses: expected 0, received %d.", len(r.messages))
+ }
+ if len(hub.join) == 1 {
+ join := <-hub.join
+ if join.sess != s {
+ t.Error("Hub.join request: sess field expected to be the session under test.")
+ }
+ if join != msg {
+ t.Error("Hub.join request: subscribe message expected to be the original subscribe message.")
+ }
+ } else {
+ t.Errorf("Hub join messages: expected 1, received %d.", len(hub.join))
+ }
+ s.inflightReqs.Done()
+}
+
+func TestDispatchAlreadySubscribed(t *testing.T) {
+ uid := types.Uid(1)
+ s := test_makeSession(uid)
+ wg := sync.WaitGroup{}
+ r := responses{}
+ wg.Add(1)
+ go s.testWriteLoop(&r, &wg)
+
+ msg := &ClientComMessage{
+ Sub: &MsgClientSub{
+ Id: "123",
+ Topic: "me",
+ Get: &MsgGetQuery{
+ What: "sub desc tags cred",
+ },
+ },
+ }
+ // Pretend the session's already subscribed to topic 'me'.
+ s.subs = make(map[string]*Subscription)
+ s.subs[uid.UserId()] = &Subscription{}
+
+ s.dispatch(msg)
+ close(s.send)
+ wg.Wait()
+
+ verifyResponseCodes(&r, []int{http.StatusNotModified}, t)
+}
+
+func TestDispatchSubscribeJoinChannelFull(t *testing.T) {
+ uid := types.Uid(1)
+ s := test_makeSession(uid)
+ wg := sync.WaitGroup{}
+ r := responses{}
+ wg.Add(1)
+ go s.testWriteLoop(&r, &wg)
+
+ hub := &Hub{
+ // Make it unbuffered with no readers - so emit operation fails immediately.
+ join: make(chan *ClientComMessage),
+ }
+ globals.hub = hub
+
+ defer func() {
+ globals.hub = nil
+ }()
+
+ msg := &ClientComMessage{
+ Sub: &MsgClientSub{
+ Id: "123",
+ Topic: "me",
+ Get: &MsgGetQuery{
+ What: "sub desc tags cred",
+ },
+ },
+ }
+
+ s.dispatch(msg)
+ close(s.send)
+ wg.Wait()
+
+ verifyResponseCodes(&r, []int{http.StatusServiceUnavailable}, t)
+}
+
+func TestDispatchLeave(t *testing.T) {
+ uid := types.Uid(1)
+ s := test_makeSession(uid)
+ wg := sync.WaitGroup{}
+ r := responses{}
+ wg.Add(1)
+ go s.testWriteLoop(&r, &wg)
+
+ destUid := types.Uid(2)
+ topicName := uid.P2PName(destUid)
+ leave := make(chan *ClientComMessage, 1)
+ s.subs = make(map[string]*Subscription)
+ s.subs[topicName] = &Subscription{
+ done: leave,
+ }
+
+ msg := &ClientComMessage{
+ Leave: &MsgClientLeave{
+ Id: "123",
+ Topic: destUid.UserId(),
+ },
+ }
+
+ s.dispatch(msg)
+ close(s.send)
+ wg.Wait()
+
+ // Check we've routed the join request via the leave channel.
+ if len(r.messages) != 0 {
+ t.Errorf("responses: expected 0, received %d.", len(r.messages))
+ }
+ if len(leave) == 1 {
+ req := <-leave
+ if req.sess != s {
+ t.Error("Leave request: sess field expected to be the session under test.")
+ }
+ if req != msg {
+ t.Error("Leave request: leave message expected to be the original leave message.")
+ }
+ // leave request handler is expected to clean up subs.
+ s.delSub(topicName)
+ } else {
+ t.Errorf("Unsub messages: expected 1, received %d.", len(leave))
+ }
+ if len(s.subs) != 0 {
+ t.Errorf("Session subs: expected to be empty, actual size: %d", len(s.subs))
+ }
+ s.inflightReqs.Done()
+}
+
+func TestDispatchLeaveUnsubMe(t *testing.T) {
+ uid := types.Uid(1)
+ s := test_makeSession(uid)
+ wg := sync.WaitGroup{}
+ r := responses{}
+ wg.Add(1)
+ go s.testWriteLoop(&r, &wg)
+
+ s.subs = make(map[string]*Subscription)
+ s.subs[uid.UserId()] = &Subscription{}
+
+ msg := &ClientComMessage{
+ Leave: &MsgClientLeave{
+ Id: "123",
+ // Cannot unsubscribe from 'me'.
+ Topic: "me",
+ Unsub: true,
+ },
+ }
+
+ s.dispatch(msg)
+ close(s.send)
+ wg.Wait()
+
+ verifyResponseCodes(&r, []int{http.StatusForbidden}, t)
+}
+
+func TestDispatchLeaveUnknownTopic(t *testing.T) {
+ uid := types.Uid(1)
+ s := test_makeSession(uid)
+ wg := sync.WaitGroup{}
+ r := responses{}
+ wg.Add(1)
+ go s.testWriteLoop(&r, &wg)
+
+ // Session isn't subscribed to topic 'me'.
+ // And wants to leave it => no change.
+ s.subs = make(map[string]*Subscription)
+
+ msg := &ClientComMessage{
+ Leave: &MsgClientLeave{
+ Id: "123",
+ Topic: "me",
+ },
+ }
+
+ s.dispatch(msg)
+ close(s.send)
+ wg.Wait()
+
+ verifyResponseCodes(&r, []int{http.StatusNotModified}, t)
+}
+
+func TestDispatchLeaveUnsubFromUnknownTopic(t *testing.T) {
+ uid := types.Uid(1)
+ s := test_makeSession(uid)
+ wg := sync.WaitGroup{}
+ r := responses{}
+ wg.Add(1)
+ go s.testWriteLoop(&r, &wg)
+
+ // Session isn't subscribed to topic 'me'.
+ // And wants to leave & unsubscribe from it.
+ s.subs = make(map[string]*Subscription)
+
+ msg := &ClientComMessage{
+ Leave: &MsgClientLeave{
+ Id: "123",
+ Topic: "me",
+ Unsub: true,
+ },
+ }
+
+ s.dispatch(msg)
+ close(s.send)
+ wg.Wait()
+
+ verifyResponseCodes(&r, []int{http.StatusConflict}, t)
+}
+
+func TestDispatchPublish(t *testing.T) {
+ uid := types.Uid(1)
+ s := test_makeSession(uid)
+ wg := sync.WaitGroup{}
+ r := responses{}
+ wg.Add(1)
+ go s.testWriteLoop(&r, &wg)
+
+ destUid := types.Uid(2)
+ topicName := uid.P2PName(destUid)
+
+ brdcst := make(chan *ClientComMessage, 1)
+ s.subs = make(map[string]*Subscription)
+ s.subs[topicName] = &Subscription{
+ broadcast: brdcst,
+ }
+
+ testMessage := "test content"
+ msg := &ClientComMessage{
+ Pub: &MsgClientPub{
+ Id: "123",
+ Topic: destUid.UserId(),
+ Content: testMessage,
+ },
+ }
+
+ s.dispatch(msg)
+ close(s.send)
+ wg.Wait()
+
+ // Check we've routed the join request via the broadcast channel.
+ if len(r.messages) != 0 {
+ t.Errorf("responses: expected 0, received %d.", len(r.messages))
+ }
+ if len(brdcst) == 1 {
+ req := <-brdcst
+ if req.sess != s {
+ t.Error("Pub request: sess field expected to be the session under test.")
+ }
+ if req.Pub.Content != testMessage {
+ t.Errorf("Pub request content: expected '%s' vs '%s'.", testMessage, req.Pub.Content)
+ }
+ if req.Pub.Topic != destUid.UserId() {
+ t.Errorf("Pub request topic: expected '%s' vs '%s'.", destUid.UserId(), req.Pub.Topic)
+ }
+ } else {
+ t.Errorf("Pub messages: expected 1, received %d.", len(brdcst))
+ }
+}
+
+func TestDispatchPublishBroadcastChannelFull(t *testing.T) {
+ uid := types.Uid(1)
+ s := test_makeSession(uid)
+ wg := sync.WaitGroup{}
+ r := responses{}
+ wg.Add(1)
+ go s.testWriteLoop(&r, &wg)
+
+ destUid := types.Uid(2)
+ topicName := uid.P2PName(destUid)
+
+ // Make broadcast channel unbuffered with no reader -
+ // emit op will fail.
+ brdcst := make(chan *ClientComMessage)
+ s.subs = make(map[string]*Subscription)
+ s.subs[topicName] = &Subscription{
+ broadcast: brdcst,
+ }
+
+ testMessage := "test content"
+ msg := &ClientComMessage{
+ Pub: &MsgClientPub{
+ Id: "123",
+ Topic: destUid.UserId(),
+ Content: testMessage,
+ },
+ }
+
+ s.dispatch(msg)
+ close(s.send)
+ wg.Wait()
+
+ verifyResponseCodes(&r, []int{http.StatusServiceUnavailable}, t)
+}
+
+func TestDispatchPublishMissingSubcription(t *testing.T) {
+ uid := types.Uid(1)
+ s := test_makeSession(uid)
+ wg := sync.WaitGroup{}
+ r := responses{}
+ wg.Add(1)
+ go s.testWriteLoop(&r, &wg)
+
+ destUid := types.Uid(2)
+
+ // Subscription to topic missing.
+ s.subs = make(map[string]*Subscription)
+
+ testMessage := "test content"
+ msg := &ClientComMessage{
+ Pub: &MsgClientPub{
+ Id: "123",
+ Topic: destUid.UserId(),
+ Content: testMessage,
+ },
+ }
+
+ s.dispatch(msg)
+ close(s.send)
+ wg.Wait()
+
+ verifyResponseCodes(&r, []int{http.StatusConflict}, t)
+}
+
+func TestDispatchGet(t *testing.T) {
+ uid := types.Uid(1)
+ s := test_makeSession(uid)
+ wg := sync.WaitGroup{}
+ r := responses{}
+ wg.Add(1)
+ go s.testWriteLoop(&r, &wg)
+
+ destUid := types.Uid(2)
+ topicName := uid.P2PName(destUid)
+
+ meta := make(chan *ClientComMessage, 1)
+ s.subs = make(map[string]*Subscription)
+ s.subs[topicName] = &Subscription{
+ meta: meta,
+ }
+
+ msg := &ClientComMessage{
+ Get: &MsgClientGet{
+ Id: "123",
+ Topic: destUid.UserId(),
+ MsgGetQuery: MsgGetQuery{
+ What: "desc sub del cred",
+ },
+ },
+ }
+
+ s.dispatch(msg)
+ close(s.send)
+ wg.Wait()
+
+ // Check we've routed the join request via the meta channel.
+ if len(r.messages) != 0 {
+ t.Errorf("responses: expected 0, received %d.", len(r.messages))
+ }
+ if len(meta) == 1 {
+ req := <-meta
+ if req.sess != s {
+ t.Error("Get request: sess field expected to be the session under test.")
+ }
+ } else {
+ t.Errorf("Get messages: expected 1, received %d.", len(meta))
+ }
+}
+
+func TestDispatchGetMalformedWhat(t *testing.T) {
+ uid := types.Uid(1)
+ s := test_makeSession(uid)
+ wg := sync.WaitGroup{}
+ r := responses{}
+ wg.Add(1)
+ go s.testWriteLoop(&r, &wg)
+
+ destUid := types.Uid(2)
+ msg := &ClientComMessage{
+ Get: &MsgClientGet{
+ Id: "123",
+ Topic: destUid.UserId(),
+ MsgGetQuery: MsgGetQuery{
+ // Empty 'what'. This will produce an error.
+ What: "",
+ },
+ },
+ }
+
+ s.dispatch(msg)
+ close(s.send)
+ wg.Wait()
+
+ verifyResponseCodes(&r, []int{http.StatusBadRequest}, t)
+}
+
+func TestDispatchGetMetaChannelFull(t *testing.T) {
+ uid := types.Uid(1)
+ s := test_makeSession(uid)
+ wg := sync.WaitGroup{}
+ r := responses{}
+ wg.Add(1)
+ go s.testWriteLoop(&r, &wg)
+
+ destUid := types.Uid(2)
+ topicName := uid.P2PName(destUid)
+
+ // Unbuffered chan with no readers - emit will fail.
+ meta := make(chan *ClientComMessage)
+ s.subs = make(map[string]*Subscription)
+ s.subs[topicName] = &Subscription{
+ meta: meta,
+ }
+
+ msg := &ClientComMessage{
+ Get: &MsgClientGet{
+ Id: "123",
+ Topic: destUid.UserId(),
+ MsgGetQuery: MsgGetQuery{
+ What: "desc sub",
+ },
+ },
+ }
+
+ s.dispatch(msg)
+ close(s.send)
+ wg.Wait()
+
+ verifyResponseCodes(&r, []int{http.StatusServiceUnavailable}, t)
+}
+
+func TestDispatchSet(t *testing.T) {
+ uid := types.Uid(1)
+ s := test_makeSession(uid)
+ wg := sync.WaitGroup{}
+ r := responses{}
+ wg.Add(1)
+ go s.testWriteLoop(&r, &wg)
+
+ destUid := types.Uid(2)
+ topicName := uid.P2PName(destUid)
+
+ meta := make(chan *ClientComMessage, 1)
+ s.subs = make(map[string]*Subscription)
+ s.subs[topicName] = &Subscription{
+ meta: meta,
+ }
+
+ msg := &ClientComMessage{
+ Set: &MsgClientSet{
+ Id: "123",
+ Topic: destUid.UserId(),
+ MsgSetQuery: MsgSetQuery{
+ Desc: &MsgSetDesc{},
+ Sub: &MsgSetSub{},
+ Tags: []string{"abc"},
+ Cred: &MsgCredClient{},
+ },
+ },
+ }
+
+ s.dispatch(msg)
+ close(s.send)
+ wg.Wait()
+
+ // Check we've routed the join request via the meta channel.
+ if len(r.messages) != 0 {
+ t.Errorf("responses: expected 0, received %d.", len(r.messages))
+ }
+ if len(meta) == 1 {
+ req := <-meta
+ if req.sess != s {
+ t.Error("Set request: sess field expected to be the session under test.")
+ }
+ expectedWhat := constMsgMetaDesc | constMsgMetaSub | constMsgMetaTags | constMsgMetaCred
+ if msg.MetaWhat != expectedWhat {
+ t.Errorf("Set request what: expected %d vs %d", expectedWhat, msg.MetaWhat)
+ }
+ } else {
+ t.Errorf("Set messages: expected 1, received %d.", len(meta))
+ }
+}
+
+func TestDispatchSetMalformedWhat(t *testing.T) {
+ uid := types.Uid(1)
+ s := test_makeSession(uid)
+ wg := sync.WaitGroup{}
+ r := responses{}
+ wg.Add(1)
+ go s.testWriteLoop(&r, &wg)
+
+ destUid := types.Uid(2)
+ msg := &ClientComMessage{
+ Set: &MsgClientSet{
+ Id: "123",
+ Topic: destUid.UserId(),
+ MsgSetQuery: MsgSetQuery{
+ // No meta requests.
+ },
+ },
+ }
+
+ s.dispatch(msg)
+ close(s.send)
+ wg.Wait()
+
+ verifyResponseCodes(&r, []int{http.StatusBadRequest}, t)
+}
+
+func TestDispatchSetMetaChannelFull(t *testing.T) {
+ uid := types.Uid(1)
+ s := test_makeSession(uid)
+ wg := sync.WaitGroup{}
+ r := responses{}
+ wg.Add(1)
+ go s.testWriteLoop(&r, &wg)
+
+ destUid := types.Uid(2)
+ topicName := uid.P2PName(destUid)
+
+ // Unbuffered meta channel w/ no readers - emit will fail.
+ meta := make(chan *ClientComMessage)
+ s.subs = make(map[string]*Subscription)
+ s.subs[topicName] = &Subscription{
+ meta: meta,
+ }
+
+ msg := &ClientComMessage{
+ Set: &MsgClientSet{
+ Id: "123",
+ Topic: destUid.UserId(),
+ MsgSetQuery: MsgSetQuery{
+ // No meta requests.
+ Desc: &MsgSetDesc{},
+ Sub: &MsgSetSub{},
+ Tags: []string{"abc"},
+ Cred: &MsgCredClient{},
+ },
+ },
+ }
+
+ s.dispatch(msg)
+ close(s.send)
+ wg.Wait()
+
+ verifyResponseCodes(&r, []int{http.StatusServiceUnavailable}, t)
+}
+
+func TestDispatchDelMsg(t *testing.T) {
+ uid := types.Uid(1)
+ s := test_makeSession(uid)
+ wg := sync.WaitGroup{}
+ r := responses{}
+ wg.Add(1)
+ go s.testWriteLoop(&r, &wg)
+
+ destUid := types.Uid(2)
+ topicName := uid.P2PName(destUid)
+
+ meta := make(chan *ClientComMessage, 1)
+ s.subs = make(map[string]*Subscription)
+ s.subs[topicName] = &Subscription{
+ meta: meta,
+ }
+
+ msg := &ClientComMessage{
+ Del: &MsgClientDel{
+ Id: "123",
+ Topic: destUid.UserId(),
+ What: "msg",
+ DelSeq: []MsgRange{{LowId: 3, HiId: 4}},
+ Hard: true,
+ },
+ }
+
+ s.dispatch(msg)
+ close(s.send)
+ wg.Wait()
+
+ // Check we've routed the join request via the meta channel.
+ if len(r.messages) != 0 {
+ t.Errorf("responses: expected 0, received %d.", len(r.messages))
+ }
+ if len(meta) == 1 {
+ req := <-meta
+ if req.sess != s {
+ t.Error("Del request: sess field expected to be the session under test.")
+ }
+ } else {
+ t.Errorf("Del messages: expected 1, received %d.", len(meta))
+ }
+}
+
+func TestDispatchDelMalformedWhat(t *testing.T) {
+ uid := types.Uid(1)
+ s := test_makeSession(uid)
+ wg := sync.WaitGroup{}
+ r := responses{}
+ wg.Add(1)
+ go s.testWriteLoop(&r, &wg)
+
+ destUid := types.Uid(2)
+ msg := &ClientComMessage{
+ Del: &MsgClientDel{
+ Id: "123",
+ Topic: destUid.UserId(),
+ // Invalid 'what' - this will produce an error.
+ What: "INVALID",
+ },
+ }
+
+ s.dispatch(msg)
+ close(s.send)
+ wg.Wait()
+
+ verifyResponseCodes(&r, []int{http.StatusBadRequest}, t)
+}
+
+func TestDispatchDelMetaChanFull(t *testing.T) {
+ uid := types.Uid(1)
+ s := test_makeSession(uid)
+ wg := sync.WaitGroup{}
+ r := responses{}
+ wg.Add(1)
+ go s.testWriteLoop(&r, &wg)
+
+ destUid := types.Uid(2)
+ topicName := uid.P2PName(destUid)
+
+ // Unbuffered chan - to simulate a full buffered chan.
+ meta := make(chan *ClientComMessage)
+ s.subs = make(map[string]*Subscription)
+ s.subs[topicName] = &Subscription{
+ meta: meta,
+ }
+
+ msg := &ClientComMessage{
+ Del: &MsgClientDel{
+ Id: "123",
+ Topic: destUid.UserId(),
+ What: "msg",
+ DelSeq: []MsgRange{{LowId: 3, HiId: 4}},
+ Hard: true,
+ },
+ }
+
+ s.dispatch(msg)
+ close(s.send)
+ wg.Wait()
+
+ verifyResponseCodes(&r, []int{http.StatusServiceUnavailable}, t)
+}
+
+func TestDispatchDelUnsubscribedSession(t *testing.T) {
+ uid := types.Uid(1)
+ s := test_makeSession(uid)
+ wg := sync.WaitGroup{}
+ r := responses{}
+ wg.Add(1)
+ go s.testWriteLoop(&r, &wg)
+
+ destUid := types.Uid(2)
+ // Session isn't subscribed.
+ s.subs = make(map[string]*Subscription)
+ msg := &ClientComMessage{
+ Del: &MsgClientDel{
+ Id: "123",
+ Topic: destUid.UserId(),
+ What: "msg",
+ DelSeq: []MsgRange{{LowId: 3, HiId: 4}},
+ Hard: true,
+ },
+ }
+
+ s.dispatch(msg)
+ close(s.send)
+ wg.Wait()
+
+ verifyResponseCodes(&r, []int{http.StatusConflict}, t)
+}
+
+func TestDispatchNote(t *testing.T) {
+ uid := types.Uid(1)
+ s := test_makeSession(uid)
+ wg := sync.WaitGroup{}
+ r := responses{}
+ wg.Add(1)
+ go s.testWriteLoop(&r, &wg)
+
+ destUid := types.Uid(2)
+ topicName := uid.P2PName(destUid)
+
+ brdcst := make(chan *ClientComMessage, 1)
+ s.subs = make(map[string]*Subscription)
+ s.subs[topicName] = &Subscription{
+ broadcast: brdcst,
+ }
+
+ msg := &ClientComMessage{
+ Note: &MsgClientNote{
+ Topic: destUid.UserId(),
+ What: "recv",
+ SeqId: 5,
+ },
+ }
+
+ s.dispatch(msg)
+ close(s.send)
+ wg.Wait()
+
+ // Check we've routed the join request via the broadcast channel.
+ if len(r.messages) != 0 {
+ t.Errorf("responses: expected 0, received %d.", len(r.messages))
+ }
+ if len(brdcst) == 1 {
+ req := <-brdcst
+ if req.sess != s {
+ t.Error("Pub request: sess field expected to be the session under test.")
+ }
+ if req.Note.What != msg.Note.What {
+ t.Errorf("Note request what: expected '%s' vs '%s'.", msg.Note.What, req.Note.What)
+ }
+ if req.Note.SeqId != msg.Note.SeqId {
+ t.Errorf("Note request seqId: expected %d vs %d.", msg.Note.SeqId, req.Note.SeqId)
+ }
+ if req.Note.Topic != destUid.UserId() {
+ t.Errorf("Note request topic: expected '%s' vs '%s'.", destUid.UserId(), req.Note.Topic)
+ }
+ } else {
+ t.Errorf("Note messages: expected 1, received %d.", len(brdcst))
+ }
+}
+
+func TestDispatchNoteBroadcastChanFull(t *testing.T) {
+ uid := types.Uid(1)
+ s := test_makeSession(uid)
+ wg := sync.WaitGroup{}
+ r := responses{}
+ wg.Add(1)
+ go s.testWriteLoop(&r, &wg)
+
+ destUid := types.Uid(2)
+ topicName := uid.P2PName(destUid)
+
+ // Unbuffered chan - to simulate a full buffered chan.
+ brdcst := make(chan *ClientComMessage)
+ s.subs = make(map[string]*Subscription)
+ s.subs[topicName] = &Subscription{
+ broadcast: brdcst,
+ }
+
+ msg := &ClientComMessage{
+ Note: &MsgClientNote{
+ Topic: destUid.UserId(),
+ What: "recv",
+ SeqId: 5,
+ },
+ }
+
+ s.dispatch(msg)
+ close(s.send)
+ wg.Wait()
+
+ verifyResponseCodes(&r, []int{http.StatusServiceUnavailable}, t)
+}
+
+func TestDispatchNoteOnNonSubscribedTopic(t *testing.T) {
+ uid := types.Uid(1)
+ s := test_makeSession(uid)
+ wg := sync.WaitGroup{}
+ r := responses{}
+ wg.Add(1)
+ go s.testWriteLoop(&r, &wg)
+
+ destUid := types.Uid(2)
+ s.subs = make(map[string]*Subscription)
+
+ msg := &ClientComMessage{
+ Note: &MsgClientNote{
+ Topic: destUid.UserId(),
+ What: "read",
+ SeqId: 5,
+ },
+ }
+
+ s.dispatch(msg)
+ close(s.send)
+ wg.Wait()
+
+ verifyResponseCodes(&r, []int{http.StatusConflict}, t)
+}
+
+func TestDispatchAccNew(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ ss := mock_store.NewMockPersistentStorageInterface(ctrl)
+ uu := mock_store.NewMockUsersPersistenceInterface(ctrl)
+ aa := mock_auth.NewMockAuthHandler(ctrl)
+
+ uid := types.Uid(1)
+ store.Store = ss
+ store.Users = uu
+ defer func() {
+ store.Store = nil
+ store.Users = nil
+ ctrl.Finish()
+ }()
+
+ remoteAddr := "192.168.0.1"
+ secret := "<==auth-secret==>"
+ tags := []string{"tag1", "tag2"}
+ authRec := &auth.Rec{
+ TenantID: 1,
+ Uid: uid,
+ AuthLevel: auth.LevelAuth,
+ Tags: tags,
+ State: types.StateOK,
+ }
+ ss.EXPECT().GetLogicalAuthHandler("basic").Return(aa)
+ // This login is available.
+ aa.EXPECT().IsUnique(auth.AuthContext{TenantID: 1, RemoteAddr: remoteAddr}, []byte(secret)).Return(true, nil)
+ uu.EXPECT().Create(types.TenantID(1), gomock.Any(), gomock.Any()).DoAndReturn(
+ func(_ types.TenantID, user *types.User, private any) (*types.User, error) {
+ user.SetUid(uid)
+ return user, nil
+ })
+ aa.EXPECT().AddRecord(auth.AuthContext{TenantID: 1, RemoteAddr: remoteAddr}, gomock.Any(),
+ []byte(secret)).Return(authRec, nil)
+
+ // Token generation.
+ ss.EXPECT().GetLogicalAuthHandler("token").Return(aa)
+ token := "<==auth-token==>"
+ aa.EXPECT().GenSecret(gomock.Any()).Return([]byte(token), time.Now(), nil)
+ uu.EXPECT().UpdateTags(types.TenantID(1), uid, tags, nil, nil).Return(tags, nil)
+
+ s := &Session{
+ send: make(chan any, 10),
+ authLvl: auth.LevelAuth,
+ ver: 16,
+ tenantID: 1,
+ tenantCode: "test",
+ remoteAddr: remoteAddr,
+ }
+ wg := sync.WaitGroup{}
+ r := responses{}
+ wg.Add(1)
+ go s.testWriteLoop(&r, &wg)
+
+ public := "public name"
+ msg := &ClientComMessage{
+ Acc: &MsgClientAcc{
+ Id: "123",
+ User: "newXYZ",
+ Scheme: "basic",
+ Secret: []byte(secret),
+ Tags: []string{"abc", "123"},
+ Desc: &MsgSetDesc{Public: public},
+ },
+ }
+
+ s.dispatch(msg)
+ close(s.send)
+ wg.Wait()
+
+ if len(r.messages) != 1 {
+ t.Errorf("responses: expected 1, received %d.", len(r.messages))
+ }
+ resp := r.messages[0].(*ServerComMessage)
+ if resp == nil {
+ t.Fatal("Response must be ServerComMessage")
+ }
+ if resp.Ctrl != nil {
+ if resp.Ctrl.Id != "123" {
+ t.Errorf("Response id: expected '123', found '%s'", resp.Ctrl.Id)
+ }
+ if resp.Ctrl.Code != 201 {
+ t.Errorf("Response code: expected 201, got %d", resp.Ctrl.Code)
+ }
+ if resp.Ctrl.Params == nil {
+ t.Error("Response is expected to contain params dict.")
+ }
+ p := resp.Ctrl.Params.(map[string]any)
+ if respUid := string(p["user"].(string)); respUid != uid.UserId() {
+ t.Errorf("Response uid: expected '%s', found '%s'.", uid.UserId(), respUid)
+ }
+ if lvl := p["authlvl"].(string); lvl != auth.LevelAuth.String() {
+ t.Errorf("Auth level: expected '%s', found '%s'.", auth.LevelAuth.String(), lvl)
+ }
+ if desc := p["desc"].(*MsgTopicDesc); desc.Public.(string) != public {
+ t.Errorf("Public: expected '%s', found '%s'.", public, desc.Public.(string))
+ }
+ } else {
+ t.Error("Response must contain a ctrl message.")
+ }
+}
+
+func TestDispatchNoMessage(t *testing.T) {
+ remoteAddr := "192.168.0.1"
+ s := &Session{
+ send: make(chan any, 10),
+ authLvl: auth.LevelAuth,
+ ver: 16,
+ tenantID: 1,
+ tenantCode: "test",
+ remoteAddr: remoteAddr,
+ }
+ wg := sync.WaitGroup{}
+ r := responses{}
+ wg.Add(1)
+ go s.testWriteLoop(&r, &wg)
+
+ msg := &ClientComMessage{}
+
+ s.dispatch(msg)
+ close(s.send)
+ wg.Wait()
+
+ if len(r.messages) != 1 {
+ t.Errorf("responses: expected 1, received %d.", len(r.messages))
+ }
+ resp := r.messages[0].(*ServerComMessage)
+ if resp == nil {
+ t.Fatal("Response must be ServerComMessage")
+ }
+ if resp.Ctrl == nil {
+ t.Fatal("Response must contain a ctrl message.")
+ }
+ if resp.Ctrl.Code != 400 {
+ t.Errorf("Response code: expected 400, got %d", resp.Ctrl.Code)
+ }
+}
diff --git a/server/sessionstore.go b/server/sessionstore.go
index e69de29..b1114aa 100644
--- a/server/sessionstore.go
+++ b/server/sessionstore.go
@@ -0,0 +1,277 @@
+/******************************************************************************
+ *
+ * Description:
+ *
+ * Session management.
+ *
+ *****************************************************************************/
+
+package main
+
+import (
+ "container/list"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/gorilla/websocket"
+ "github.com/tinode/chat/pbx"
+ "github.com/tinode/chat/server/logs"
+ "github.com/tinode/chat/server/store"
+ "github.com/tinode/chat/server/store/types"
+)
+
+// WaitGroup with a semaphore functionality
+// (limiting number of threads/goroutines accessing the guarded resource simultaneously).
+type boundedWaitGroup struct {
+ wg sync.WaitGroup
+ sem chan struct{}
+}
+
+func newBoundedWaitGroup(capacity int) *boundedWaitGroup {
+ return &boundedWaitGroup{sem: make(chan struct{}, capacity)}
+}
+
+func (w *boundedWaitGroup) Add(delta int) {
+ if delta <= 0 {
+ return
+ }
+ for range delta {
+ w.sem <- struct{}{}
+ }
+ w.wg.Add(delta)
+}
+
+func (w *boundedWaitGroup) Done() {
+ select {
+ case _, ok := <-w.sem:
+ if !ok {
+ logs.Err.Panicln("boundedWaitGroup.sem closed.")
+ }
+ default:
+ logs.Err.Panicln("boundedWaitGroup.Done() called before Add().")
+ }
+ w.wg.Done()
+}
+
+func (w *boundedWaitGroup) Wait() {
+ w.wg.Wait()
+}
+
+// SessionStore holds live sessions. Long polling sessions are stored in a linked list with
+// most recent sessions on top. In addition all sessions are stored in a map indexed by session ID.
+type SessionStore struct {
+ lock sync.Mutex
+
+ // Support for long polling sessions: a list of sessions sorted by last access time.
+ // Needed for cleaning abandoned sessions.
+ lru *list.List
+ lifeTime time.Duration
+
+ // All sessions indexed by session ID
+ sessCache map[string]*Session
+}
+
+// NewSession creates a new session and saves it to the session store.
+func (ss *SessionStore) NewSession(conn any, sid string) (*Session, int) {
+ var s Session
+
+ if sid == "" {
+ s.sid = store.Store.GetUidString()
+ } else {
+ s.sid = sid
+ }
+
+ ss.lock.Lock()
+ if _, found := ss.sessCache[s.sid]; found {
+ logs.Err.Fatalln("ERROR! duplicate session ID", s.sid)
+ }
+ ss.lock.Unlock()
+
+ switch c := conn.(type) {
+ case *websocket.Conn:
+ s.proto = WEBSOCK
+ s.ws = c
+ case http.ResponseWriter:
+ s.proto = LPOLL
+ // no need to store c for long polling, it changes with every request
+ case *ClusterNode:
+ s.proto = MULTIPLEX
+ s.clnode = c
+ case pbx.Node_MessageLoopServer:
+ s.proto = GRPC
+ s.grpcnode = c
+ default:
+ logs.Err.Panicln("session: unknown connection type", conn)
+ }
+
+ s.subs = make(map[string]*Subscription)
+ s.send = make(chan any, sendQueueLimit+32) // buffered
+ s.stop = make(chan any, 1) // Buffered by 1 just to make it non-blocking
+ s.detach = make(chan string, 64) // buffered
+
+ s.bkgTimer = time.NewTimer(time.Hour)
+ s.bkgTimer.Stop()
+
+ // Make sure at most 1 request is modifying session/topic state at any time.
+ // TODO: use Mutex & CondVar?
+ s.inflightReqs = newBoundedWaitGroup(1)
+
+ s.lastTouched = time.Now()
+
+ ss.lock.Lock()
+
+ if s.proto == LPOLL {
+ // Only LP sessions need to be sorted by last active
+ s.lpTracker = ss.lru.PushFront(&s)
+ }
+
+ ss.sessCache[s.sid] = &s
+
+ // Expire stale long polling sessions: ss.lru contains only long polling sessions.
+ // If ss.lru is empty this is a noop.
+ var expired []*Session
+ expire := s.lastTouched.Add(-ss.lifeTime)
+ for elem := ss.lru.Back(); elem != nil; elem = ss.lru.Back() {
+ sess := elem.Value.(*Session)
+ if sess.lastTouched.Before(expire) {
+ ss.lru.Remove(elem)
+ delete(ss.sessCache, sess.sid)
+ expired = append(expired, sess)
+ } else {
+ break // don't need to traverse further
+ }
+ }
+
+ numSessions := len(ss.sessCache)
+ statsSet("LiveSessions", int64(numSessions))
+ statsInc("TotalSessions", 1)
+
+ ss.lock.Unlock()
+
+ // Deleting long polling sessions.
+ for _, sess := range expired {
+ // This locks the session. Thus cleaning up outside of the
+ // sessionStore lock. Otherwise deadlock.
+ sess.cleanUp(true)
+ }
+
+ return &s, numSessions
+}
+
+// Get fetches a session from store by session ID.
+func (ss *SessionStore) Get(sid string) *Session {
+ ss.lock.Lock()
+ defer ss.lock.Unlock()
+
+ if sess := ss.sessCache[sid]; sess != nil {
+ if sess.proto == LPOLL {
+ ss.lru.MoveToFront(sess.lpTracker)
+ sess.lastTouched = time.Now()
+ }
+
+ return sess
+ }
+
+ return nil
+}
+
+// Delete removes session from store.
+func (ss *SessionStore) Delete(s *Session) {
+ ss.lock.Lock()
+ defer ss.lock.Unlock()
+
+ delete(ss.sessCache, s.sid)
+ if s.proto == LPOLL {
+ ss.lru.Remove(s.lpTracker)
+ }
+
+ statsSet("LiveSessions", int64(len(ss.sessCache)))
+}
+
+// Range calls given function for all sessions. It stops if the function returns false.
+func (ss *SessionStore) Range(f func(sid string, s *Session) bool) {
+ ss.lock.Lock()
+ for sid, s := range ss.sessCache {
+ if !f(sid, s) {
+ break
+ }
+ }
+ ss.lock.Unlock()
+}
+
+// Shutdown terminates sessionStore. No need to clean up.
+// Don't send to clustered sessions, their servers are not being shut down.
+func (ss *SessionStore) Shutdown() {
+ ss.lock.Lock()
+ defer ss.lock.Unlock()
+
+ shutdown := NoErrShutdown(types.TimeNow())
+ for _, s := range ss.sessCache {
+ if !s.isMultiplex() {
+ _, data := s.serialize(shutdown)
+ s.stopSession(data)
+ }
+ }
+
+ // TODO: Consider broadcasting shutdown to other cluster nodes.
+
+ logs.Info.Println("SessionStore shut down, sessions terminated:", len(ss.sessCache))
+}
+
+// EvictUser terminates all sessions of a given user.
+func (ss *SessionStore) EvictUser(uid types.Uid, skipSid string) {
+ ss.lock.Lock()
+ defer ss.lock.Unlock()
+
+ // FIXME: this probably needs to be optimized. This may take very long time if the node hosts 100000 sessions.
+ evicted := NoErrEvicted("", "", types.TimeNow())
+ evicted.AsUser = uid.UserId()
+ for _, s := range ss.sessCache {
+ if s.uid == uid && !s.isMultiplex() && s.sid != skipSid {
+ _, data := s.serialize(evicted)
+ s.stopSession(data)
+ delete(ss.sessCache, s.sid)
+ if s.proto == LPOLL {
+ ss.lru.Remove(s.lpTracker)
+ }
+ }
+ }
+
+ statsSet("LiveSessions", int64(len(ss.sessCache)))
+}
+
+// NodeRestarted removes stale sessions from a restarted cluster node.
+// - nodeName is the name of affected node
+// - fingerprint is the new fingerprint of the node.
+func (ss *SessionStore) NodeRestarted(nodeName string, fingerprint int64) {
+ ss.lock.Lock()
+ defer ss.lock.Unlock()
+
+ for _, s := range ss.sessCache {
+ if !s.isMultiplex() || s.clnode.name != nodeName {
+ continue
+ }
+ if s.clnode.fingerprint != fingerprint {
+ s.stopSession(nil)
+ delete(ss.sessCache, s.sid)
+ }
+ }
+
+ statsSet("LiveSessions", int64(len(ss.sessCache)))
+}
+
+// NewSessionStore initializes a session store.
+func NewSessionStore(lifetime time.Duration) *SessionStore {
+ ss := &SessionStore{
+ lru: list.New(),
+ lifeTime: lifetime,
+
+ sessCache: make(map[string]*Session),
+ }
+
+ statsRegisterInt("LiveSessions")
+ statsRegisterInt("TotalSessions")
+
+ return ss
+}
diff --git a/server/store/mock_store/mock_store.go b/server/store/mock_store/mock_store.go
index e69de29..6a0a6e0 100644
--- a/server/store/mock_store/mock_store.go
+++ b/server/store/mock_store/mock_store.go
@@ -0,0 +1,1392 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: server/store/store.go
+
+// Package mock_store is a generated GoMock package.
+package mock_store
+
+import (
+ json "encoding/json"
+ reflect "reflect"
+ time "time"
+
+ gomock "github.com/golang/mock/gomock"
+ auth "github.com/tinode/chat/server/auth"
+ adapter "github.com/tinode/chat/server/db"
+ media "github.com/tinode/chat/server/media"
+ types "github.com/tinode/chat/server/store/types"
+ validate "github.com/tinode/chat/server/validate"
+)
+
+// MockPersistentStorageInterface is a mock of PersistentStorageInterface interface.
+type MockPersistentStorageInterface struct {
+ ctrl *gomock.Controller
+ recorder *MockPersistentStorageInterfaceMockRecorder
+}
+
+// MockPersistentStorageInterfaceMockRecorder is the mock recorder for MockPersistentStorageInterface.
+type MockPersistentStorageInterfaceMockRecorder struct {
+ mock *MockPersistentStorageInterface
+}
+
+// NewMockPersistentStorageInterface creates a new mock instance.
+func NewMockPersistentStorageInterface(ctrl *gomock.Controller) *MockPersistentStorageInterface {
+ mock := &MockPersistentStorageInterface{ctrl: ctrl}
+ mock.recorder = &MockPersistentStorageInterfaceMockRecorder{mock}
+ return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockPersistentStorageInterface) EXPECT() *MockPersistentStorageInterfaceMockRecorder {
+ return m.recorder
+}
+
+// Close mocks base method.
+func (m *MockPersistentStorageInterface) Close() error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Close")
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// Close indicates an expected call of Close.
+func (mr *MockPersistentStorageInterfaceMockRecorder) Close() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockPersistentStorageInterface)(nil).Close))
+}
+
+// DbStats mocks base method.
+func (m *MockPersistentStorageInterface) DbStats() func() any {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "DbStats")
+ ret0, _ := ret[0].(func() any)
+ return ret0
+}
+
+// DbStats indicates an expected call of DbStats.
+func (mr *MockPersistentStorageInterfaceMockRecorder) DbStats() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DbStats", reflect.TypeOf((*MockPersistentStorageInterface)(nil).DbStats))
+}
+
+// GetAdapter mocks base method.
+func (m *MockPersistentStorageInterface) GetAdapter() adapter.Adapter {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetAdapter")
+ ret0, _ := ret[0].(adapter.Adapter)
+ return ret0
+}
+
+// GetAdapter indicates an expected call of GetAdapter.
+func (mr *MockPersistentStorageInterfaceMockRecorder) GetAdapter() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAdapter", reflect.TypeOf((*MockPersistentStorageInterface)(nil).GetAdapter))
+}
+
+// GetAdapterName mocks base method.
+func (m *MockPersistentStorageInterface) GetAdapterName() string {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetAdapterName")
+ ret0, _ := ret[0].(string)
+ return ret0
+}
+
+// GetAdapterName indicates an expected call of GetAdapterName.
+func (mr *MockPersistentStorageInterfaceMockRecorder) GetAdapterName() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAdapterName", reflect.TypeOf((*MockPersistentStorageInterface)(nil).GetAdapterName))
+}
+
+// GetAdapterVersion mocks base method.
+func (m *MockPersistentStorageInterface) GetAdapterVersion() int {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetAdapterVersion")
+ ret0, _ := ret[0].(int)
+ return ret0
+}
+
+// GetAdapterVersion indicates an expected call of GetAdapterVersion.
+func (mr *MockPersistentStorageInterfaceMockRecorder) GetAdapterVersion() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAdapterVersion", reflect.TypeOf((*MockPersistentStorageInterface)(nil).GetAdapterVersion))
+}
+
+// GetAuthHandler mocks base method.
+func (m *MockPersistentStorageInterface) GetAuthHandler(name string) auth.AuthHandler {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetAuthHandler", name)
+ ret0, _ := ret[0].(auth.AuthHandler)
+ return ret0
+}
+
+// GetAuthHandler indicates an expected call of GetAuthHandler.
+func (mr *MockPersistentStorageInterfaceMockRecorder) GetAuthHandler(name interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthHandler", reflect.TypeOf((*MockPersistentStorageInterface)(nil).GetAuthHandler), name)
+}
+
+// GetAuthNames mocks base method.
+func (m *MockPersistentStorageInterface) GetAuthNames() []string {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetAuthNames")
+ ret0, _ := ret[0].([]string)
+ return ret0
+}
+
+// GetAuthNames indicates an expected call of GetAuthNames.
+func (mr *MockPersistentStorageInterfaceMockRecorder) GetAuthNames() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthNames", reflect.TypeOf((*MockPersistentStorageInterface)(nil).GetAuthNames))
+}
+
+// GetDbVersion mocks base method.
+func (m *MockPersistentStorageInterface) GetDbVersion() int {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetDbVersion")
+ ret0, _ := ret[0].(int)
+ return ret0
+}
+
+// GetDbVersion indicates an expected call of GetDbVersion.
+func (mr *MockPersistentStorageInterfaceMockRecorder) GetDbVersion() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDbVersion", reflect.TypeOf((*MockPersistentStorageInterface)(nil).GetDbVersion))
+}
+
+// GetLogicalAuthHandler mocks base method.
+func (m *MockPersistentStorageInterface) GetLogicalAuthHandler(name string) auth.AuthHandler {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetLogicalAuthHandler", name)
+ ret0, _ := ret[0].(auth.AuthHandler)
+ return ret0
+}
+
+// GetLogicalAuthHandler indicates an expected call of GetLogicalAuthHandler.
+func (mr *MockPersistentStorageInterfaceMockRecorder) GetLogicalAuthHandler(name interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLogicalAuthHandler", reflect.TypeOf((*MockPersistentStorageInterface)(nil).GetLogicalAuthHandler), name)
+}
+
+// GetMediaHandler mocks base method.
+func (m *MockPersistentStorageInterface) GetMediaHandler() media.Handler {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetMediaHandler")
+ ret0, _ := ret[0].(media.Handler)
+ return ret0
+}
+
+// GetMediaHandler indicates an expected call of GetMediaHandler.
+func (mr *MockPersistentStorageInterfaceMockRecorder) GetMediaHandler() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMediaHandler", reflect.TypeOf((*MockPersistentStorageInterface)(nil).GetMediaHandler))
+}
+
+// GetUid mocks base method.
+func (m *MockPersistentStorageInterface) GetUid() types.Uid {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetUid")
+ ret0, _ := ret[0].(types.Uid)
+ return ret0
+}
+
+// GetUid indicates an expected call of GetUid.
+func (mr *MockPersistentStorageInterfaceMockRecorder) GetUid() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUid", reflect.TypeOf((*MockPersistentStorageInterface)(nil).GetUid))
+}
+
+// GetUidString mocks base method.
+func (m *MockPersistentStorageInterface) GetUidString() string {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetUidString")
+ ret0, _ := ret[0].(string)
+ return ret0
+}
+
+// GetUidString indicates an expected call of GetUidString.
+func (mr *MockPersistentStorageInterfaceMockRecorder) GetUidString() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUidString", reflect.TypeOf((*MockPersistentStorageInterface)(nil).GetUidString))
+}
+
+// GetValidator mocks base method.
+func (m *MockPersistentStorageInterface) GetValidator(name string) validate.Validator {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetValidator", name)
+ ret0, _ := ret[0].(validate.Validator)
+ return ret0
+}
+
+// GetValidator indicates an expected call of GetValidator.
+func (mr *MockPersistentStorageInterfaceMockRecorder) GetValidator(name interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetValidator", reflect.TypeOf((*MockPersistentStorageInterface)(nil).GetValidator), name)
+}
+
+// InitDb mocks base method.
+func (m *MockPersistentStorageInterface) InitDb(jsonconf json.RawMessage, reset bool) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "InitDb", jsonconf, reset)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// InitDb indicates an expected call of InitDb.
+func (mr *MockPersistentStorageInterfaceMockRecorder) InitDb(jsonconf, reset interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InitDb", reflect.TypeOf((*MockPersistentStorageInterface)(nil).InitDb), jsonconf, reset)
+}
+
+// IsOpen mocks base method.
+func (m *MockPersistentStorageInterface) IsOpen() bool {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "IsOpen")
+ ret0, _ := ret[0].(bool)
+ return ret0
+}
+
+// IsOpen indicates an expected call of IsOpen.
+func (mr *MockPersistentStorageInterfaceMockRecorder) IsOpen() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsOpen", reflect.TypeOf((*MockPersistentStorageInterface)(nil).IsOpen))
+}
+
+// Open mocks base method.
+func (m *MockPersistentStorageInterface) Open(workerId int, jsonconf json.RawMessage) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Open", workerId, jsonconf)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// Open indicates an expected call of Open.
+func (mr *MockPersistentStorageInterfaceMockRecorder) Open(workerId, jsonconf interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Open", reflect.TypeOf((*MockPersistentStorageInterface)(nil).Open), workerId, jsonconf)
+}
+
+// UpgradeDb mocks base method.
+func (m *MockPersistentStorageInterface) UpgradeDb(jsonconf json.RawMessage) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "UpgradeDb", jsonconf)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// UpgradeDb indicates an expected call of UpgradeDb.
+func (mr *MockPersistentStorageInterfaceMockRecorder) UpgradeDb(jsonconf interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpgradeDb", reflect.TypeOf((*MockPersistentStorageInterface)(nil).UpgradeDb), jsonconf)
+}
+
+// UseMediaHandler mocks base method.
+func (m *MockPersistentStorageInterface) UseMediaHandler(name, config string) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "UseMediaHandler", name, config)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// UseMediaHandler indicates an expected call of UseMediaHandler.
+func (mr *MockPersistentStorageInterfaceMockRecorder) UseMediaHandler(name, config interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UseMediaHandler", reflect.TypeOf((*MockPersistentStorageInterface)(nil).UseMediaHandler), name, config)
+}
+
+// MockTenantsPersistenceInterface is a mock of TenantsPersistenceInterface interface.
+type MockTenantsPersistenceInterface struct {
+ ctrl *gomock.Controller
+ recorder *MockTenantsPersistenceInterfaceMockRecorder
+}
+
+// MockTenantsPersistenceInterfaceMockRecorder is the mock recorder for MockTenantsPersistenceInterface.
+type MockTenantsPersistenceInterfaceMockRecorder struct {
+ mock *MockTenantsPersistenceInterface
+}
+
+// NewMockTenantsPersistenceInterface creates a new mock instance.
+func NewMockTenantsPersistenceInterface(ctrl *gomock.Controller) *MockTenantsPersistenceInterface {
+ mock := &MockTenantsPersistenceInterface{ctrl: ctrl}
+ mock.recorder = &MockTenantsPersistenceInterfaceMockRecorder{mock}
+ return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockTenantsPersistenceInterface) EXPECT() *MockTenantsPersistenceInterfaceMockRecorder {
+ return m.recorder
+}
+
+// GetByCode mocks base method.
+func (m *MockTenantsPersistenceInterface) GetByCode(code string) (*types.Tenant, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetByCode", code)
+ ret0, _ := ret[0].(*types.Tenant)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetByCode indicates an expected call of GetByCode.
+func (mr *MockTenantsPersistenceInterfaceMockRecorder) GetByCode(code interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByCode", reflect.TypeOf((*MockTenantsPersistenceInterface)(nil).GetByCode), code)
+}
+
+// MockUsersPersistenceInterface is a mock of UsersPersistenceInterface interface.
+type MockUsersPersistenceInterface struct {
+ ctrl *gomock.Controller
+ recorder *MockUsersPersistenceInterfaceMockRecorder
+}
+
+// MockUsersPersistenceInterfaceMockRecorder is the mock recorder for MockUsersPersistenceInterface.
+type MockUsersPersistenceInterfaceMockRecorder struct {
+ mock *MockUsersPersistenceInterface
+}
+
+// NewMockUsersPersistenceInterface creates a new mock instance.
+func NewMockUsersPersistenceInterface(ctrl *gomock.Controller) *MockUsersPersistenceInterface {
+ mock := &MockUsersPersistenceInterface{ctrl: ctrl}
+ mock.recorder = &MockUsersPersistenceInterfaceMockRecorder{mock}
+ return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockUsersPersistenceInterface) EXPECT() *MockUsersPersistenceInterfaceMockRecorder {
+ return m.recorder
+}
+
+// AddAuthRecord mocks base method.
+func (m *MockUsersPersistenceInterface) AddAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, scheme, unique string, secret []byte, expires time.Time) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "AddAuthRecord", tenantID, uid, authLvl, scheme, unique, secret, expires)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// AddAuthRecord indicates an expected call of AddAuthRecord.
+func (mr *MockUsersPersistenceInterfaceMockRecorder) AddAuthRecord(tenantID, uid, authLvl, scheme, unique, secret, expires interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddAuthRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).AddAuthRecord), tenantID, uid, authLvl, scheme, unique, secret, expires)
+}
+
+// ConfirmCred mocks base method.
+func (m *MockUsersPersistenceInterface) ConfirmCred(tenantID types.TenantID, id types.Uid, method string) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "ConfirmCred", tenantID, id, method)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// ConfirmCred indicates an expected call of ConfirmCred.
+func (mr *MockUsersPersistenceInterfaceMockRecorder) ConfirmCred(tenantID, id, method interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ConfirmCred", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).ConfirmCred), tenantID, id, method)
+}
+
+// Create mocks base method.
+func (m *MockUsersPersistenceInterface) Create(tenantID types.TenantID, user *types.User, private any) (*types.User, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Create", tenantID, user, private)
+ ret0, _ := ret[0].(*types.User)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// Create indicates an expected call of Create.
+func (mr *MockUsersPersistenceInterfaceMockRecorder) Create(tenantID, user, private interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).Create), tenantID, user, private)
+}
+
+// DelAuthRecords mocks base method.
+func (m *MockUsersPersistenceInterface) DelAuthRecords(tenantID types.TenantID, uid types.Uid, scheme string) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "DelAuthRecords", tenantID, uid, scheme)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// DelAuthRecords indicates an expected call of DelAuthRecords.
+func (mr *MockUsersPersistenceInterfaceMockRecorder) DelAuthRecords(tenantID, uid, scheme interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DelAuthRecords", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).DelAuthRecords), tenantID, uid, scheme)
+}
+
+// DelCred mocks base method.
+func (m *MockUsersPersistenceInterface) DelCred(tenantID types.TenantID, id types.Uid, method, value string) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "DelCred", tenantID, id, method, value)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// DelCred indicates an expected call of DelCred.
+func (mr *MockUsersPersistenceInterfaceMockRecorder) DelCred(tenantID, id, method, value interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DelCred", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).DelCred), tenantID, id, method, value)
+}
+
+// Delete mocks base method.
+func (m *MockUsersPersistenceInterface) Delete(tenantID types.TenantID, id types.Uid, hard bool) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Delete", tenantID, id, hard)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// Delete indicates an expected call of Delete.
+func (mr *MockUsersPersistenceInterfaceMockRecorder) Delete(tenantID, id, hard interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).Delete), tenantID, id, hard)
+}
+
+// FailCred mocks base method.
+func (m *MockUsersPersistenceInterface) FailCred(tenantID types.TenantID, id types.Uid, method string) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "FailCred", tenantID, id, method)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// FailCred indicates an expected call of FailCred.
+func (mr *MockUsersPersistenceInterfaceMockRecorder) FailCred(tenantID, id, method interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FailCred", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).FailCred), tenantID, id, method)
+}
+
+// FindOne mocks base method.
+func (m *MockUsersPersistenceInterface) FindOne(tenantID types.TenantID, tag string) (string, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "FindOne", tenantID, tag)
+ ret0, _ := ret[0].(string)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// FindOne indicates an expected call of FindOne.
+func (mr *MockUsersPersistenceInterfaceMockRecorder) FindOne(tenantID, tag interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindOne", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).FindOne), tenantID, tag)
+}
+
+// FindSubs mocks base method.
+func (m *MockUsersPersistenceInterface) FindSubs(tenantID types.TenantID, caller types.Uid, prefPrefix string, required [][]string, optional []string, activeOnly bool) ([]types.Subscription, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "FindSubs", tenantID, caller, prefPrefix, required, optional, activeOnly)
+ ret0, _ := ret[0].([]types.Subscription)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// FindSubs indicates an expected call of FindSubs.
+func (mr *MockUsersPersistenceInterfaceMockRecorder) FindSubs(tenantID, caller, prefPrefix, required, optional, activeOnly interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindSubs", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).FindSubs), tenantID, caller, prefPrefix, required, optional, activeOnly)
+}
+
+// Get mocks base method.
+func (m *MockUsersPersistenceInterface) Get(tenantID types.TenantID, uid types.Uid) (*types.User, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Get", tenantID, uid)
+ ret0, _ := ret[0].(*types.User)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// Get indicates an expected call of Get.
+func (mr *MockUsersPersistenceInterfaceMockRecorder) Get(tenantID, uid interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).Get), tenantID, uid)
+}
+
+// GetActiveCred mocks base method.
+func (m *MockUsersPersistenceInterface) GetActiveCred(tenantID types.TenantID, id types.Uid, method string) (*types.Credential, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetActiveCred", tenantID, id, method)
+ ret0, _ := ret[0].(*types.Credential)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetActiveCred indicates an expected call of GetActiveCred.
+func (mr *MockUsersPersistenceInterfaceMockRecorder) GetActiveCred(tenantID, id, method interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveCred", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetActiveCred), tenantID, id, method)
+}
+
+// GetAll mocks base method.
+func (m *MockUsersPersistenceInterface) GetAll(tenantID types.TenantID, uid ...types.Uid) ([]types.User, error) {
+ m.ctrl.T.Helper()
+ varargs := []interface{}{tenantID}
+ for _, a := range uid {
+ varargs = append(varargs, a)
+ }
+ ret := m.ctrl.Call(m, "GetAll", varargs...)
+ ret0, _ := ret[0].([]types.User)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetAll indicates an expected call of GetAll.
+func (mr *MockUsersPersistenceInterfaceMockRecorder) GetAll(tenantID interface{}, uid ...interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ varargs := append([]interface{}{tenantID}, uid...)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAll", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetAll), varargs...)
+}
+
+// GetAllCreds mocks base method.
+func (m *MockUsersPersistenceInterface) GetAllCreds(tenantID types.TenantID, id types.Uid, method string, validatedOnly bool) ([]types.Credential, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetAllCreds", tenantID, id, method, validatedOnly)
+ ret0, _ := ret[0].([]types.Credential)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetAllCreds indicates an expected call of GetAllCreds.
+func (mr *MockUsersPersistenceInterfaceMockRecorder) GetAllCreds(tenantID, id, method, validatedOnly interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllCreds", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetAllCreds), tenantID, id, method, validatedOnly)
+}
+
+// GetAuthRecord mocks base method.
+func (m *MockUsersPersistenceInterface) GetAuthRecord(tenantID types.TenantID, user types.Uid, scheme string) (string, auth.Level, []byte, time.Time, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetAuthRecord", tenantID, user, scheme)
+ ret0, _ := ret[0].(string)
+ ret1, _ := ret[1].(auth.Level)
+ ret2, _ := ret[2].([]byte)
+ ret3, _ := ret[3].(time.Time)
+ ret4, _ := ret[4].(error)
+ return ret0, ret1, ret2, ret3, ret4
+}
+
+// GetAuthRecord indicates an expected call of GetAuthRecord.
+func (mr *MockUsersPersistenceInterfaceMockRecorder) GetAuthRecord(tenantID, user, scheme interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetAuthRecord), tenantID, user, scheme)
+}
+
+// GetAuthUniqueRecord mocks base method.
+func (m *MockUsersPersistenceInterface) GetAuthUniqueRecord(tenantID types.TenantID, scheme, unique string) (types.Uid, auth.Level, []byte, time.Time, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetAuthUniqueRecord", tenantID, scheme, unique)
+ ret0, _ := ret[0].(types.Uid)
+ ret1, _ := ret[1].(auth.Level)
+ ret2, _ := ret[2].([]byte)
+ ret3, _ := ret[3].(time.Time)
+ ret4, _ := ret[4].(error)
+ return ret0, ret1, ret2, ret3, ret4
+}
+
+// GetAuthUniqueRecord indicates an expected call of GetAuthUniqueRecord.
+func (mr *MockUsersPersistenceInterfaceMockRecorder) GetAuthUniqueRecord(tenantID, scheme, unique interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthUniqueRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetAuthUniqueRecord), tenantID, scheme, unique)
+}
+
+// GetByCred mocks base method.
+func (m *MockUsersPersistenceInterface) GetByCred(tenantID types.TenantID, method, value string) (types.Uid, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetByCred", tenantID, method, value)
+ ret0, _ := ret[0].(types.Uid)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetByCred indicates an expected call of GetByCred.
+func (mr *MockUsersPersistenceInterfaceMockRecorder) GetByCred(tenantID, method, value interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByCred", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetByCred), tenantID, method, value)
+}
+
+// GetChannels mocks base method.
+func (m *MockUsersPersistenceInterface) GetChannels(tenantID types.TenantID, id types.Uid) ([]string, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetChannels", tenantID, id)
+ ret0, _ := ret[0].([]string)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetChannels indicates an expected call of GetChannels.
+func (mr *MockUsersPersistenceInterfaceMockRecorder) GetChannels(tenantID, id interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChannels", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetChannels), tenantID, id)
+}
+
+// GetOwnTopics mocks base method.
+func (m *MockUsersPersistenceInterface) GetOwnTopics(tenantID types.TenantID, id types.Uid) ([]string, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetOwnTopics", tenantID, id)
+ ret0, _ := ret[0].([]string)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetOwnTopics indicates an expected call of GetOwnTopics.
+func (mr *MockUsersPersistenceInterfaceMockRecorder) GetOwnTopics(tenantID, id interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOwnTopics", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetOwnTopics), tenantID, id)
+}
+
+// GetSubs mocks base method.
+func (m *MockUsersPersistenceInterface) GetSubs(tenantID types.TenantID, id types.Uid) ([]types.Subscription, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetSubs", tenantID, id)
+ ret0, _ := ret[0].([]types.Subscription)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetSubs indicates an expected call of GetSubs.
+func (mr *MockUsersPersistenceInterfaceMockRecorder) GetSubs(tenantID, id interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSubs", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetSubs), tenantID, id)
+}
+
+// GetTopics mocks base method.
+func (m *MockUsersPersistenceInterface) GetTopics(tenantID types.TenantID, id types.Uid, opts *types.QueryOpt) ([]types.Subscription, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetTopics", tenantID, id, opts)
+ ret0, _ := ret[0].([]types.Subscription)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetTopics indicates an expected call of GetTopics.
+func (mr *MockUsersPersistenceInterfaceMockRecorder) GetTopics(tenantID, id, opts interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTopics", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetTopics), tenantID, id, opts)
+}
+
+// GetTopicsAny mocks base method.
+func (m *MockUsersPersistenceInterface) GetTopicsAny(tenantID types.TenantID, id types.Uid, opts *types.QueryOpt) ([]types.Subscription, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetTopicsAny", tenantID, id, opts)
+ ret0, _ := ret[0].([]types.Subscription)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetTopicsAny indicates an expected call of GetTopicsAny.
+func (mr *MockUsersPersistenceInterfaceMockRecorder) GetTopicsAny(tenantID, id, opts interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTopicsAny", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetTopicsAny), tenantID, id, opts)
+}
+
+// GetUnreadCount mocks base method.
+func (m *MockUsersPersistenceInterface) GetUnreadCount(tenantID types.TenantID, ids ...types.Uid) (map[types.Uid]int, error) {
+ m.ctrl.T.Helper()
+ varargs := []interface{}{tenantID}
+ for _, a := range ids {
+ varargs = append(varargs, a)
+ }
+ ret := m.ctrl.Call(m, "GetUnreadCount", varargs...)
+ ret0, _ := ret[0].(map[types.Uid]int)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetUnreadCount indicates an expected call of GetUnreadCount.
+func (mr *MockUsersPersistenceInterfaceMockRecorder) GetUnreadCount(tenantID interface{}, ids ...interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ varargs := append([]interface{}{tenantID}, ids...)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUnreadCount", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetUnreadCount), varargs...)
+}
+
+// GetUnvalidated mocks base method.
+func (m *MockUsersPersistenceInterface) GetUnvalidated(tenantID types.TenantID, lastUpdatedBefore time.Time, limit int) ([]types.Uid, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetUnvalidated", tenantID, lastUpdatedBefore, limit)
+ ret0, _ := ret[0].([]types.Uid)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetUnvalidated indicates an expected call of GetUnvalidated.
+func (mr *MockUsersPersistenceInterfaceMockRecorder) GetUnvalidated(tenantID, lastUpdatedBefore, limit interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUnvalidated", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetUnvalidated), tenantID, lastUpdatedBefore, limit)
+}
+
+// Update mocks base method.
+func (m *MockUsersPersistenceInterface) Update(tenantID types.TenantID, uid types.Uid, update map[string]any) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Update", tenantID, uid, update)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// Update indicates an expected call of Update.
+func (mr *MockUsersPersistenceInterfaceMockRecorder) Update(tenantID, uid, update interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).Update), tenantID, uid, update)
+}
+
+// UpdateAuthRecord mocks base method.
+func (m *MockUsersPersistenceInterface) UpdateAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, scheme, unique string, secret []byte, expires time.Time) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "UpdateAuthRecord", tenantID, uid, authLvl, scheme, unique, secret, expires)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// UpdateAuthRecord indicates an expected call of UpdateAuthRecord.
+func (mr *MockUsersPersistenceInterfaceMockRecorder) UpdateAuthRecord(tenantID, uid, authLvl, scheme, unique, secret, expires interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAuthRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).UpdateAuthRecord), tenantID, uid, authLvl, scheme, unique, secret, expires)
+}
+
+// UpdateLastSeen mocks base method.
+func (m *MockUsersPersistenceInterface) UpdateLastSeen(tenantID types.TenantID, uid types.Uid, userAgent string, when time.Time) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "UpdateLastSeen", tenantID, uid, userAgent, when)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// UpdateLastSeen indicates an expected call of UpdateLastSeen.
+func (mr *MockUsersPersistenceInterfaceMockRecorder) UpdateLastSeen(tenantID, uid, userAgent, when interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateLastSeen", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).UpdateLastSeen), tenantID, uid, userAgent, when)
+}
+
+// UpdateState mocks base method.
+func (m *MockUsersPersistenceInterface) UpdateState(tenantID types.TenantID, uid types.Uid, state types.ObjState) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "UpdateState", tenantID, uid, state)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// UpdateState indicates an expected call of UpdateState.
+func (mr *MockUsersPersistenceInterfaceMockRecorder) UpdateState(tenantID, uid, state interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateState", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).UpdateState), tenantID, uid, state)
+}
+
+// UpdateTags mocks base method.
+func (m *MockUsersPersistenceInterface) UpdateTags(tenantID types.TenantID, uid types.Uid, add, remove, reset []string) ([]string, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "UpdateTags", tenantID, uid, add, remove, reset)
+ ret0, _ := ret[0].([]string)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// UpdateTags indicates an expected call of UpdateTags.
+func (mr *MockUsersPersistenceInterfaceMockRecorder) UpdateTags(tenantID, uid, add, remove, reset interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTags", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).UpdateTags), tenantID, uid, add, remove, reset)
+}
+
+// UpsertCred mocks base method.
+func (m *MockUsersPersistenceInterface) UpsertCred(tenantID types.TenantID, cred *types.Credential) (bool, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "UpsertCred", tenantID, cred)
+ ret0, _ := ret[0].(bool)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// UpsertCred indicates an expected call of UpsertCred.
+func (mr *MockUsersPersistenceInterfaceMockRecorder) UpsertCred(tenantID, cred interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertCred", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).UpsertCred), tenantID, cred)
+}
+
+// MockTopicsPersistenceInterface is a mock of TopicsPersistenceInterface interface.
+type MockTopicsPersistenceInterface struct {
+ ctrl *gomock.Controller
+ recorder *MockTopicsPersistenceInterfaceMockRecorder
+}
+
+// MockTopicsPersistenceInterfaceMockRecorder is the mock recorder for MockTopicsPersistenceInterface.
+type MockTopicsPersistenceInterfaceMockRecorder struct {
+ mock *MockTopicsPersistenceInterface
+}
+
+// NewMockTopicsPersistenceInterface creates a new mock instance.
+func NewMockTopicsPersistenceInterface(ctrl *gomock.Controller) *MockTopicsPersistenceInterface {
+ mock := &MockTopicsPersistenceInterface{ctrl: ctrl}
+ mock.recorder = &MockTopicsPersistenceInterfaceMockRecorder{mock}
+ return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockTopicsPersistenceInterface) EXPECT() *MockTopicsPersistenceInterfaceMockRecorder {
+ return m.recorder
+}
+
+// Create mocks base method.
+func (m *MockTopicsPersistenceInterface) Create(tenantID types.TenantID, topic *types.Topic, owner types.Uid, private any) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Create", tenantID, topic, owner, private)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// Create indicates an expected call of Create.
+func (mr *MockTopicsPersistenceInterfaceMockRecorder) Create(tenantID, topic, owner, private interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockTopicsPersistenceInterface)(nil).Create), tenantID, topic, owner, private)
+}
+
+// CreateP2P mocks base method.
+func (m *MockTopicsPersistenceInterface) CreateP2P(tenantID types.TenantID, initiator, invited *types.Subscription) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "CreateP2P", tenantID, initiator, invited)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// CreateP2P indicates an expected call of CreateP2P.
+func (mr *MockTopicsPersistenceInterfaceMockRecorder) CreateP2P(tenantID, initiator, invited interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateP2P", reflect.TypeOf((*MockTopicsPersistenceInterface)(nil).CreateP2P), tenantID, initiator, invited)
+}
+
+// Delete mocks base method.
+func (m *MockTopicsPersistenceInterface) Delete(tenantID types.TenantID, topic string, isChan, hard bool) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Delete", tenantID, topic, isChan, hard)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// Delete indicates an expected call of Delete.
+func (mr *MockTopicsPersistenceInterfaceMockRecorder) Delete(tenantID, topic, isChan, hard interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockTopicsPersistenceInterface)(nil).Delete), tenantID, topic, isChan, hard)
+}
+
+// Get mocks base method.
+func (m *MockTopicsPersistenceInterface) Get(tenantID types.TenantID, topic string) (*types.Topic, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Get", tenantID, topic)
+ ret0, _ := ret[0].(*types.Topic)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// Get indicates an expected call of Get.
+func (mr *MockTopicsPersistenceInterfaceMockRecorder) Get(tenantID, topic interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockTopicsPersistenceInterface)(nil).Get), tenantID, topic)
+}
+
+// GetSubs mocks base method.
+func (m *MockTopicsPersistenceInterface) GetSubs(tenantID types.TenantID, topic string, opts *types.QueryOpt) ([]types.Subscription, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetSubs", tenantID, topic, opts)
+ ret0, _ := ret[0].([]types.Subscription)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetSubs indicates an expected call of GetSubs.
+func (mr *MockTopicsPersistenceInterfaceMockRecorder) GetSubs(tenantID, topic, opts interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSubs", reflect.TypeOf((*MockTopicsPersistenceInterface)(nil).GetSubs), tenantID, topic, opts)
+}
+
+// GetSubsAny mocks base method.
+func (m *MockTopicsPersistenceInterface) GetSubsAny(tenantID types.TenantID, topic string, opts *types.QueryOpt) ([]types.Subscription, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetSubsAny", tenantID, topic, opts)
+ ret0, _ := ret[0].([]types.Subscription)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetSubsAny indicates an expected call of GetSubsAny.
+func (mr *MockTopicsPersistenceInterfaceMockRecorder) GetSubsAny(tenantID, topic, opts interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSubsAny", reflect.TypeOf((*MockTopicsPersistenceInterface)(nil).GetSubsAny), tenantID, topic, opts)
+}
+
+// GetUsers mocks base method.
+func (m *MockTopicsPersistenceInterface) GetUsers(tenantID types.TenantID, topic string, opts *types.QueryOpt) ([]types.Subscription, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetUsers", tenantID, topic, opts)
+ ret0, _ := ret[0].([]types.Subscription)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetUsers indicates an expected call of GetUsers.
+func (mr *MockTopicsPersistenceInterfaceMockRecorder) GetUsers(tenantID, topic, opts interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsers", reflect.TypeOf((*MockTopicsPersistenceInterface)(nil).GetUsers), tenantID, topic, opts)
+}
+
+// GetUsersAny mocks base method.
+func (m *MockTopicsPersistenceInterface) GetUsersAny(tenantID types.TenantID, topic string, opts *types.QueryOpt) ([]types.Subscription, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetUsersAny", tenantID, topic, opts)
+ ret0, _ := ret[0].([]types.Subscription)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetUsersAny indicates an expected call of GetUsersAny.
+func (mr *MockTopicsPersistenceInterfaceMockRecorder) GetUsersAny(tenantID, topic, opts interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsersAny", reflect.TypeOf((*MockTopicsPersistenceInterface)(nil).GetUsersAny), tenantID, topic, opts)
+}
+
+// OwnerChange mocks base method.
+func (m *MockTopicsPersistenceInterface) OwnerChange(tenantID types.TenantID, topic string, newOwner types.Uid) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "OwnerChange", tenantID, topic, newOwner)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// OwnerChange indicates an expected call of OwnerChange.
+func (mr *MockTopicsPersistenceInterfaceMockRecorder) OwnerChange(tenantID, topic, newOwner interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OwnerChange", reflect.TypeOf((*MockTopicsPersistenceInterface)(nil).OwnerChange), tenantID, topic, newOwner)
+}
+
+// Update mocks base method.
+func (m *MockTopicsPersistenceInterface) Update(tenantID types.TenantID, topic string, update map[string]any) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Update", tenantID, topic, update)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// Update indicates an expected call of Update.
+func (mr *MockTopicsPersistenceInterfaceMockRecorder) Update(tenantID, topic, update interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockTopicsPersistenceInterface)(nil).Update), tenantID, topic, update)
+}
+
+// UpdateSubCnt mocks base method.
+func (m *MockTopicsPersistenceInterface) UpdateSubCnt(tenantID types.TenantID, topic string) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "UpdateSubCnt", tenantID, topic)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// UpdateSubCnt indicates an expected call of UpdateSubCnt.
+func (mr *MockTopicsPersistenceInterfaceMockRecorder) UpdateSubCnt(tenantID, topic interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateSubCnt", reflect.TypeOf((*MockTopicsPersistenceInterface)(nil).UpdateSubCnt), tenantID, topic)
+}
+
+// MockSubsPersistenceInterface is a mock of SubsPersistenceInterface interface.
+type MockSubsPersistenceInterface struct {
+ ctrl *gomock.Controller
+ recorder *MockSubsPersistenceInterfaceMockRecorder
+}
+
+// MockSubsPersistenceInterfaceMockRecorder is the mock recorder for MockSubsPersistenceInterface.
+type MockSubsPersistenceInterfaceMockRecorder struct {
+ mock *MockSubsPersistenceInterface
+}
+
+// NewMockSubsPersistenceInterface creates a new mock instance.
+func NewMockSubsPersistenceInterface(ctrl *gomock.Controller) *MockSubsPersistenceInterface {
+ mock := &MockSubsPersistenceInterface{ctrl: ctrl}
+ mock.recorder = &MockSubsPersistenceInterfaceMockRecorder{mock}
+ return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockSubsPersistenceInterface) EXPECT() *MockSubsPersistenceInterfaceMockRecorder {
+ return m.recorder
+}
+
+// Create mocks base method.
+func (m *MockSubsPersistenceInterface) Create(tenantID types.TenantID, subs ...*types.Subscription) error {
+ m.ctrl.T.Helper()
+ varargs := []interface{}{tenantID}
+ for _, a := range subs {
+ varargs = append(varargs, a)
+ }
+ ret := m.ctrl.Call(m, "Create", varargs...)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// Create indicates an expected call of Create.
+func (mr *MockSubsPersistenceInterfaceMockRecorder) Create(tenantID interface{}, subs ...interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ varargs := append([]interface{}{tenantID}, subs...)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockSubsPersistenceInterface)(nil).Create), varargs...)
+}
+
+// Delete mocks base method.
+func (m *MockSubsPersistenceInterface) Delete(tenantID types.TenantID, topic string, user types.Uid) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Delete", tenantID, topic, user)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// Delete indicates an expected call of Delete.
+func (mr *MockSubsPersistenceInterfaceMockRecorder) Delete(tenantID, topic, user interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockSubsPersistenceInterface)(nil).Delete), tenantID, topic, user)
+}
+
+// Get mocks base method.
+func (m *MockSubsPersistenceInterface) Get(tenantID types.TenantID, topic string, user types.Uid, keepDeleted bool) (*types.Subscription, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Get", tenantID, topic, user, keepDeleted)
+ ret0, _ := ret[0].(*types.Subscription)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// Get indicates an expected call of Get.
+func (mr *MockSubsPersistenceInterfaceMockRecorder) Get(tenantID, topic, user, keepDeleted interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockSubsPersistenceInterface)(nil).Get), tenantID, topic, user, keepDeleted)
+}
+
+// Update mocks base method.
+func (m *MockSubsPersistenceInterface) Update(tenantID types.TenantID, topic string, user types.Uid, update map[string]any) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Update", tenantID, topic, user, update)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// Update indicates an expected call of Update.
+func (mr *MockSubsPersistenceInterfaceMockRecorder) Update(tenantID, topic, user, update interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockSubsPersistenceInterface)(nil).Update), tenantID, topic, user, update)
+}
+
+// MockMessagesPersistenceInterface is a mock of MessagesPersistenceInterface interface.
+type MockMessagesPersistenceInterface struct {
+ ctrl *gomock.Controller
+ recorder *MockMessagesPersistenceInterfaceMockRecorder
+}
+
+// MockMessagesPersistenceInterfaceMockRecorder is the mock recorder for MockMessagesPersistenceInterface.
+type MockMessagesPersistenceInterfaceMockRecorder struct {
+ mock *MockMessagesPersistenceInterface
+}
+
+// NewMockMessagesPersistenceInterface creates a new mock instance.
+func NewMockMessagesPersistenceInterface(ctrl *gomock.Controller) *MockMessagesPersistenceInterface {
+ mock := &MockMessagesPersistenceInterface{ctrl: ctrl}
+ mock.recorder = &MockMessagesPersistenceInterfaceMockRecorder{mock}
+ return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockMessagesPersistenceInterface) EXPECT() *MockMessagesPersistenceInterfaceMockRecorder {
+ return m.recorder
+}
+
+// DeleteList mocks base method.
+func (m *MockMessagesPersistenceInterface) DeleteList(tenantID types.TenantID, topic string, delID int, forUser types.Uid, msgDelAge time.Duration, ranges []types.Range) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "DeleteList", tenantID, topic, delID, forUser, msgDelAge, ranges)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// DeleteList indicates an expected call of DeleteList.
+func (mr *MockMessagesPersistenceInterfaceMockRecorder) DeleteList(tenantID, topic, delID, forUser, msgDelAge, ranges interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteList", reflect.TypeOf((*MockMessagesPersistenceInterface)(nil).DeleteList), tenantID, topic, delID, forUser, msgDelAge, ranges)
+}
+
+// GetAll mocks base method.
+func (m *MockMessagesPersistenceInterface) GetAll(tenantID types.TenantID, topic string, forUser types.Uid, opt *types.QueryOpt) ([]types.Message, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetAll", tenantID, topic, forUser, opt)
+ ret0, _ := ret[0].([]types.Message)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetAll indicates an expected call of GetAll.
+func (mr *MockMessagesPersistenceInterfaceMockRecorder) GetAll(tenantID, topic, forUser, opt interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAll", reflect.TypeOf((*MockMessagesPersistenceInterface)(nil).GetAll), tenantID, topic, forUser, opt)
+}
+
+// GetDeleted mocks base method.
+func (m *MockMessagesPersistenceInterface) GetDeleted(tenantID types.TenantID, topic string, forUser types.Uid, opt *types.QueryOpt) ([]types.Range, int, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetDeleted", tenantID, topic, forUser, opt)
+ ret0, _ := ret[0].([]types.Range)
+ ret1, _ := ret[1].(int)
+ ret2, _ := ret[2].(error)
+ return ret0, ret1, ret2
+}
+
+// GetDeleted indicates an expected call of GetDeleted.
+func (mr *MockMessagesPersistenceInterfaceMockRecorder) GetDeleted(tenantID, topic, forUser, opt interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDeleted", reflect.TypeOf((*MockMessagesPersistenceInterface)(nil).GetDeleted), tenantID, topic, forUser, opt)
+}
+
+// Save mocks base method.
+func (m *MockMessagesPersistenceInterface) Save(tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) (error, bool) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Save", tenantID, msg, attachmentURLs, readBySender)
+ ret0, _ := ret[0].(error)
+ ret1, _ := ret[1].(bool)
+ return ret0, ret1
+}
+
+// Save indicates an expected call of Save.
+func (mr *MockMessagesPersistenceInterfaceMockRecorder) Save(tenantID, msg, attachmentURLs, readBySender interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Save", reflect.TypeOf((*MockMessagesPersistenceInterface)(nil).Save), tenantID, msg, attachmentURLs, readBySender)
+}
+
+// MockDevicePersistenceInterface is a mock of DevicePersistenceInterface interface.
+type MockDevicePersistenceInterface struct {
+ ctrl *gomock.Controller
+ recorder *MockDevicePersistenceInterfaceMockRecorder
+}
+
+// MockDevicePersistenceInterfaceMockRecorder is the mock recorder for MockDevicePersistenceInterface.
+type MockDevicePersistenceInterfaceMockRecorder struct {
+ mock *MockDevicePersistenceInterface
+}
+
+// NewMockDevicePersistenceInterface creates a new mock instance.
+func NewMockDevicePersistenceInterface(ctrl *gomock.Controller) *MockDevicePersistenceInterface {
+ mock := &MockDevicePersistenceInterface{ctrl: ctrl}
+ mock.recorder = &MockDevicePersistenceInterfaceMockRecorder{mock}
+ return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockDevicePersistenceInterface) EXPECT() *MockDevicePersistenceInterfaceMockRecorder {
+ return m.recorder
+}
+
+// Delete mocks base method.
+func (m *MockDevicePersistenceInterface) Delete(tenantID types.TenantID, uid types.Uid, deviceID string) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Delete", tenantID, uid, deviceID)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// Delete indicates an expected call of Delete.
+func (mr *MockDevicePersistenceInterfaceMockRecorder) Delete(tenantID, uid, deviceID interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockDevicePersistenceInterface)(nil).Delete), tenantID, uid, deviceID)
+}
+
+// GetAll mocks base method.
+func (m *MockDevicePersistenceInterface) GetAll(tenantID types.TenantID, uid ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error) {
+ m.ctrl.T.Helper()
+ varargs := []interface{}{tenantID}
+ for _, a := range uid {
+ varargs = append(varargs, a)
+ }
+ ret := m.ctrl.Call(m, "GetAll", varargs...)
+ ret0, _ := ret[0].(map[types.Uid][]types.DeviceDef)
+ ret1, _ := ret[1].(int)
+ ret2, _ := ret[2].(error)
+ return ret0, ret1, ret2
+}
+
+// GetAll indicates an expected call of GetAll.
+func (mr *MockDevicePersistenceInterfaceMockRecorder) GetAll(tenantID interface{}, uid ...interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ varargs := append([]interface{}{tenantID}, uid...)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAll", reflect.TypeOf((*MockDevicePersistenceInterface)(nil).GetAll), varargs...)
+}
+
+// Update mocks base method.
+func (m *MockDevicePersistenceInterface) Update(tenantID types.TenantID, uid types.Uid, oldDeviceID string, dev *types.DeviceDef) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Update", tenantID, uid, oldDeviceID, dev)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// Update indicates an expected call of Update.
+func (mr *MockDevicePersistenceInterfaceMockRecorder) Update(tenantID, uid, oldDeviceID, dev interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockDevicePersistenceInterface)(nil).Update), tenantID, uid, oldDeviceID, dev)
+}
+
+// MockFilePersistenceInterface is a mock of FilePersistenceInterface interface.
+type MockFilePersistenceInterface struct {
+ ctrl *gomock.Controller
+ recorder *MockFilePersistenceInterfaceMockRecorder
+}
+
+// MockFilePersistenceInterfaceMockRecorder is the mock recorder for MockFilePersistenceInterface.
+type MockFilePersistenceInterfaceMockRecorder struct {
+ mock *MockFilePersistenceInterface
+}
+
+// NewMockFilePersistenceInterface creates a new mock instance.
+func NewMockFilePersistenceInterface(ctrl *gomock.Controller) *MockFilePersistenceInterface {
+ mock := &MockFilePersistenceInterface{ctrl: ctrl}
+ mock.recorder = &MockFilePersistenceInterfaceMockRecorder{mock}
+ return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockFilePersistenceInterface) EXPECT() *MockFilePersistenceInterfaceMockRecorder {
+ return m.recorder
+}
+
+// DeleteUnused mocks base method.
+func (m *MockFilePersistenceInterface) DeleteUnused(tenantID types.TenantID, olderThan time.Time, limit int) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "DeleteUnused", tenantID, olderThan, limit)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// DeleteUnused indicates an expected call of DeleteUnused.
+func (mr *MockFilePersistenceInterfaceMockRecorder) DeleteUnused(tenantID, olderThan, limit interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUnused", reflect.TypeOf((*MockFilePersistenceInterface)(nil).DeleteUnused), tenantID, olderThan, limit)
+}
+
+// FinishUpload mocks base method.
+func (m *MockFilePersistenceInterface) FinishUpload(tenantID types.TenantID, fd *types.FileDef, success bool, size int64) (*types.FileDef, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "FinishUpload", tenantID, fd, success, size)
+ ret0, _ := ret[0].(*types.FileDef)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// FinishUpload indicates an expected call of FinishUpload.
+func (mr *MockFilePersistenceInterfaceMockRecorder) FinishUpload(tenantID, fd, success, size interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FinishUpload", reflect.TypeOf((*MockFilePersistenceInterface)(nil).FinishUpload), tenantID, fd, success, size)
+}
+
+// Get mocks base method.
+func (m *MockFilePersistenceInterface) Get(tenantID types.TenantID, fid string) (*types.FileDef, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Get", tenantID, fid)
+ ret0, _ := ret[0].(*types.FileDef)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// Get indicates an expected call of Get.
+func (mr *MockFilePersistenceInterfaceMockRecorder) Get(tenantID, fid interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockFilePersistenceInterface)(nil).Get), tenantID, fid)
+}
+
+// LinkAttachments mocks base method.
+func (m *MockFilePersistenceInterface) LinkAttachments(tenantID types.TenantID, topic string, msgId types.Uid, attachments []string) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "LinkAttachments", tenantID, topic, msgId, attachments)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// LinkAttachments indicates an expected call of LinkAttachments.
+func (mr *MockFilePersistenceInterfaceMockRecorder) LinkAttachments(tenantID, topic, msgId, attachments interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LinkAttachments", reflect.TypeOf((*MockFilePersistenceInterface)(nil).LinkAttachments), tenantID, topic, msgId, attachments)
+}
+
+// StartUpload mocks base method.
+func (m *MockFilePersistenceInterface) StartUpload(tenantID types.TenantID, fd *types.FileDef) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "StartUpload", tenantID, fd)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// StartUpload indicates an expected call of StartUpload.
+func (mr *MockFilePersistenceInterfaceMockRecorder) StartUpload(tenantID, fd interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StartUpload", reflect.TypeOf((*MockFilePersistenceInterface)(nil).StartUpload), tenantID, fd)
+}
+
+// MockPersistentCacheInterface is a mock of PersistentCacheInterface interface.
+type MockPersistentCacheInterface struct {
+ ctrl *gomock.Controller
+ recorder *MockPersistentCacheInterfaceMockRecorder
+}
+
+// MockPersistentCacheInterfaceMockRecorder is the mock recorder for MockPersistentCacheInterface.
+type MockPersistentCacheInterfaceMockRecorder struct {
+ mock *MockPersistentCacheInterface
+}
+
+// NewMockPersistentCacheInterface creates a new mock instance.
+func NewMockPersistentCacheInterface(ctrl *gomock.Controller) *MockPersistentCacheInterface {
+ mock := &MockPersistentCacheInterface{ctrl: ctrl}
+ mock.recorder = &MockPersistentCacheInterfaceMockRecorder{mock}
+ return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockPersistentCacheInterface) EXPECT() *MockPersistentCacheInterfaceMockRecorder {
+ return m.recorder
+}
+
+// Delete mocks base method.
+func (m *MockPersistentCacheInterface) Delete(tenantID types.TenantID, key string) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Delete", tenantID, key)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// Delete indicates an expected call of Delete.
+func (mr *MockPersistentCacheInterfaceMockRecorder) Delete(tenantID, key interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockPersistentCacheInterface)(nil).Delete), tenantID, key)
+}
+
+// Expire mocks base method.
+func (m *MockPersistentCacheInterface) Expire(tenantID types.TenantID, keyPrefix string, olderThan time.Time) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Expire", tenantID, keyPrefix, olderThan)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// Expire indicates an expected call of Expire.
+func (mr *MockPersistentCacheInterfaceMockRecorder) Expire(tenantID, keyPrefix, olderThan interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Expire", reflect.TypeOf((*MockPersistentCacheInterface)(nil).Expire), tenantID, keyPrefix, olderThan)
+}
+
+// Get mocks base method.
+func (m *MockPersistentCacheInterface) Get(tenantID types.TenantID, key string) (string, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Get", tenantID, key)
+ ret0, _ := ret[0].(string)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// Get indicates an expected call of Get.
+func (mr *MockPersistentCacheInterfaceMockRecorder) Get(tenantID, key interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockPersistentCacheInterface)(nil).Get), tenantID, key)
+}
+
+// Upsert mocks base method.
+func (m *MockPersistentCacheInterface) Upsert(tenantID types.TenantID, key, value string, failOnDuplicate bool) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Upsert", tenantID, key, value, failOnDuplicate)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// Upsert indicates an expected call of Upsert.
+func (mr *MockPersistentCacheInterfaceMockRecorder) Upsert(tenantID, key, value, failOnDuplicate interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Upsert", reflect.TypeOf((*MockPersistentCacheInterface)(nil).Upsert), tenantID, key, value, failOnDuplicate)
+}
diff --git a/server/store/store.go b/server/store/store.go
index e69de29..ad26e63 100644
--- a/server/store/store.go
+++ b/server/store/store.go
@@ -0,0 +1,1502 @@
+// Package store provides methods for registering and accessing database adapters.
+package store
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/tinode/chat/server/auth"
+ adapter "github.com/tinode/chat/server/db"
+ "github.com/tinode/chat/server/logs"
+ "github.com/tinode/chat/server/media"
+ "github.com/tinode/chat/server/store/types"
+ "github.com/tinode/chat/server/validate"
+)
+
+var adp adapter.Adapter
+var availableAdapters = make(map[string]adapter.Adapter)
+var mediaHandler media.Handler
+var activeTenantIDs sync.Map
+
+// Unique ID generator
+var uGen types.UidGenerator
+
+type configType struct {
+ // 16-byte key for XTEA. Used to initialize types.UidGenerator.
+ UidKey []byte `json:"uid_key"`
+ // Maximum number of results to return from adapter.
+ MaxResults int `json:"max_results"`
+ // DB adapter name to use. Should be one of those specified in `Adapters`.
+ UseAdapter string `json:"use_adapter"`
+ // Configurations for individual adapters.
+ Adapters map[string]json.RawMessage `json:"adapters"`
+}
+
+func openAdapter(workerId int, jsonconf json.RawMessage) error {
+ var config configType
+ if err := json.Unmarshal(jsonconf, &config); err != nil {
+ return errors.New("store: failed to parse config: " + err.Error() + "(" + string(jsonconf) + ")")
+ }
+
+ if adp == nil {
+ if len(config.UseAdapter) > 0 {
+ // Adapter name specified explicitly.
+ if ad, ok := availableAdapters[config.UseAdapter]; ok {
+ adp = ad
+ } else {
+ return errors.New("store: " + config.UseAdapter + " adapter is not available in this binary")
+ }
+ } else if len(availableAdapters) == 1 {
+ // Default to the only entry in availableAdapters.
+ for _, v := range availableAdapters {
+ adp = v
+ }
+ } else {
+ return errors.New("store: db adapter is not specified. Please set `store_config.use_adapter` in `tinode.conf`")
+ }
+ }
+
+ if adp.IsOpen() {
+ return errors.New("store: connection is already opened")
+ }
+ tenantBusiness, ok := adp.(adapter.TenantBusinessAdapter)
+ if !ok {
+ return errors.New("store: " + adp.GetName() + " adapter does not support tenant-scoped business access")
+ }
+ if !tenantBusiness.TenantBusinessReady() {
+ return errors.New("store: " + adp.GetName() + " adapter tenant-scoped business access is not ready")
+ }
+ if _, ok := adp.(adapter.TenantAdapter); !ok {
+ return errors.New("store: " + adp.GetName() + " adapter does not support tenant resolution")
+ }
+ if _, ok := adp.(adapter.TenantAuthAdapter); !ok {
+ return errors.New("store: " + adp.GetName() + " adapter does not support tenant-scoped authentication")
+ }
+
+ // Initialize snowflake.
+ if workerId < 0 || workerId > 1023 {
+ return errors.New("store: invalid worker ID")
+ }
+
+ if err := uGen.Init(uint(workerId), config.UidKey); err != nil {
+ return errors.New("store: failed to init snowflake: " + err.Error())
+ }
+
+ if err := adp.SetMaxResults(config.MaxResults); err != nil {
+ return err
+ }
+
+ var adapterConfig json.RawMessage
+ if config.Adapters != nil {
+ adapterConfig = config.Adapters[adp.GetName()]
+ }
+
+ return adp.Open(adapterConfig)
+}
+
+// PersistentStorageInterface defines methods used for interation with persistent storage.
+type PersistentStorageInterface interface {
+ Open(workerId int, jsonconf json.RawMessage) error
+ Close() error
+ IsOpen() bool
+ GetAdapter() adapter.Adapter
+ GetAdapterName() string
+ GetAdapterVersion() int
+ GetDbVersion() int
+ InitDb(jsonconf json.RawMessage, reset bool) error
+ UpgradeDb(jsonconf json.RawMessage) error
+ GetUid() types.Uid
+ GetUidString() string
+ DbStats() func() any
+ GetAuthNames() []string
+ GetAuthHandler(name string) auth.AuthHandler
+ GetLogicalAuthHandler(name string) auth.AuthHandler
+ GetValidator(name string) validate.Validator
+ GetMediaHandler() media.Handler
+ UseMediaHandler(name, config string) error
+}
+
+// Store is the main object for interacting with persistent storage.
+var Store PersistentStorageInterface
+
+type storeObj struct{}
+
+// TenantsPersistenceInterface defines tenant registry lookups used before authentication.
+type TenantsPersistenceInterface interface {
+ GetByCode(code string) (*types.Tenant, error)
+}
+
+type tenantsMapper struct{}
+
+// Tenants is the tenant registry access object.
+var Tenants TenantsPersistenceInterface
+
+// GetByCode resolves a tenant by its public enterprise code.
+func (tenantsMapper) GetByCode(code string) (*types.Tenant, error) {
+ tenantAdp, ok := adp.(adapter.TenantAdapter)
+ if !ok {
+ return nil, types.ErrUnsupported
+ }
+ return tenantAdp.TenantGetByCode(code)
+}
+
+func tenantAuthAdapter(tenantID types.TenantID) (adapter.TenantAuthAdapter, error) {
+ if !tenantID.IsValid() {
+ return nil, types.ErrMalformed
+ }
+ tenantAdp, ok := adp.(adapter.TenantAuthAdapter)
+ if !ok {
+ return nil, types.ErrUnsupported
+ }
+ return tenantAdp, nil
+}
+
+func tenantBusinessAdapter(tenantID types.TenantID) (adapter.TenantBusinessAdapter, error) {
+ if !tenantID.IsValid() {
+ return nil, types.ErrMalformed
+ }
+ tenantAdp, ok := adp.(adapter.TenantBusinessAdapter)
+ if !ok {
+ return nil, types.ErrUnsupported
+ }
+ if !tenantAdp.TenantBusinessReady() {
+ return nil, types.ErrUnsupported
+ }
+ activeTenantIDs.Store(tenantID, struct{}{})
+ return tenantAdp, nil
+}
+
+// KnownTenantIDs returns tenants which have used the business Store since this
+// process started. It is used only for tenant-scoped maintenance work.
+func KnownTenantIDs() []types.TenantID {
+ var result []types.TenantID
+ activeTenantIDs.Range(func(key, _ any) bool {
+ result = append(result, key.(types.TenantID))
+ return true
+ })
+ return result
+}
+
+// TenantStore is a validated tenant scope. Domain mappers still receive the
+// tenant explicitly so it remains visible at every persistence boundary.
+type TenantStore struct {
+ TenantID types.TenantID
+}
+
+// ForTenant validates and returns a tenant-scoped Store handle.
+func ForTenant(tenantID types.TenantID) (*TenantStore, error) {
+ if _, err := tenantBusinessAdapter(tenantID); err != nil {
+ return nil, err
+ }
+ return &TenantStore{TenantID: tenantID}, nil
+}
+
+// Open initializes the persistence system. Adapter holds a connection pool for a database instance.
+//
+// name - name of the adapter rquested in the config file
+// jsonconf - configuration string
+func (storeObj) Open(workerId int, jsonconf json.RawMessage) error {
+ if err := openAdapter(workerId, jsonconf); err != nil {
+ return err
+ }
+
+ return adp.CheckDbVersion()
+}
+
+// Close terminates connection to persistent storage.
+func (storeObj) Close() error {
+ if adp.IsOpen() {
+ return adp.Close()
+ }
+
+ return nil
+}
+
+// IsOpen checks if persistent storage connection has been initialized.
+func (storeObj) IsOpen() bool {
+ if adp != nil {
+ return adp.IsOpen()
+ }
+
+ return false
+}
+
+// GetAdapter returns the currently configured adapter.
+func (storeObj) GetAdapter() adapter.Adapter {
+ return adp
+}
+
+// GetAdapterName returns the name of the current adater.
+func (storeObj) GetAdapterName() string {
+ if adp != nil {
+ return adp.GetName()
+ }
+
+ return ""
+}
+
+// GetAdapterVersion returns version of the current adater.
+func (storeObj) GetAdapterVersion() int {
+ if adp != nil {
+ return adp.Version()
+ }
+
+ return -1
+}
+
+// GetDbVersion returns version of the underlying database.
+func (storeObj) GetDbVersion() int {
+ if adp != nil {
+ vers, _ := adp.GetDbVersion()
+ return vers
+ }
+
+ return -1
+}
+
+// InitDb creates and configures a new database instance. If 'reset' is true it will first
+// attempt to drop an existing database. If jsconf is nil it will assume that the adapter is
+// already open. If it's non-nil and the adapter is not open, it will use the config string
+// to open the adapter first.
+func (s storeObj) InitDb(jsonconf json.RawMessage, reset bool) error {
+ if !s.IsOpen() {
+ if err := openAdapter(1, jsonconf); err != nil {
+ return err
+ }
+ }
+ return adp.CreateDb(reset)
+}
+
+// UpgradeDb performes an upgrade of the database to the current adapter version.
+// If jsconf is nil it will assume that the adapter is already open. If it's non-nil and the
+// adapter is not open, it will use the config string to open the adapter first.
+func (s storeObj) UpgradeDb(jsonconf json.RawMessage) error {
+ if !s.IsOpen() {
+ if err := openAdapter(1, jsonconf); err != nil {
+ return err
+ }
+ }
+ return adp.UpgradeDb()
+}
+
+// RegisterAdapter makes a persistence adapter available.
+// If Register is called twice or if the adapter is nil, it panics.
+func RegisterAdapter(a adapter.Adapter) {
+ if a == nil {
+ panic("store: Register adapter is nil")
+ }
+
+ adapterName := a.GetName()
+ if _, ok := availableAdapters[adapterName]; ok {
+ panic("store: adapter '" + adapterName + "' is already registered")
+ }
+ availableAdapters[adapterName] = a
+}
+
+// GetUid generates a unique ID suitable for use as a primary key.
+func (storeObj) GetUid() types.Uid {
+ return uGen.Get()
+}
+
+// GetUidString generate unique ID as a string.
+func (storeObj) GetUidString() string {
+ return uGen.GetStr()
+}
+
+// DecodeUid takes an XTEA encrypted Uid and decrypts it into an int64.
+// This is needed for sql compatibility. Tte original int64 values
+// are generated by snowflake which ensures that the top bit is unset.
+func DecodeUid(uid types.Uid) int64 {
+ if uid.IsZero() {
+ return 0
+ }
+ return uGen.DecodeUid(uid)
+}
+
+// EncodeUid applies XTEA encryption to an int64 value. It's the inverse of DecodeUid.
+func EncodeUid(id int64) types.Uid {
+ if id == 0 {
+ return types.ZeroUid
+ }
+ return uGen.EncodeInt64(id)
+}
+
+// DbStats returns a callback returning db connection stats object.
+func (s storeObj) DbStats() func() any {
+ if !s.IsOpen() {
+ return nil
+ }
+ return adp.Stats
+}
+
+// UsersPersistenceInterface is an interface which defines methods for persistent storage of user records.
+type UsersPersistenceInterface interface {
+ Create(tenantID types.TenantID, user *types.User, private any) (*types.User, error)
+ GetAuthRecord(tenantID types.TenantID, user types.Uid, scheme string) (string, auth.Level, []byte, time.Time, error)
+ GetAuthUniqueRecord(tenantID types.TenantID, scheme, unique string) (types.Uid, auth.Level, []byte, time.Time, error)
+ AddAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, scheme, unique string, secret []byte, expires time.Time) error
+ UpdateAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, scheme, unique string, secret []byte, expires time.Time) error
+ DelAuthRecords(tenantID types.TenantID, uid types.Uid, scheme string) error
+ Get(tenantID types.TenantID, uid types.Uid) (*types.User, error)
+ GetAll(tenantID types.TenantID, uid ...types.Uid) ([]types.User, error)
+ GetByCred(tenantID types.TenantID, method, value string) (types.Uid, error)
+ Delete(tenantID types.TenantID, id types.Uid, hard bool) error
+ UpdateLastSeen(tenantID types.TenantID, uid types.Uid, userAgent string, when time.Time) error
+ Update(tenantID types.TenantID, uid types.Uid, update map[string]any) error
+ UpdateTags(tenantID types.TenantID, uid types.Uid, add, remove, reset []string) ([]string, error)
+ UpdateState(tenantID types.TenantID, uid types.Uid, state types.ObjState) error
+ GetSubs(tenantID types.TenantID, id types.Uid) ([]types.Subscription, error)
+ FindSubs(tenantID types.TenantID, caller types.Uid, prefPrefix string, required [][]string, optional []string, activeOnly bool) ([]types.Subscription, error)
+ FindOne(tenantID types.TenantID, tag string) (string, error)
+ GetTopics(tenantID types.TenantID, id types.Uid, opts *types.QueryOpt) ([]types.Subscription, error)
+ GetTopicsAny(tenantID types.TenantID, id types.Uid, opts *types.QueryOpt) ([]types.Subscription, error)
+ GetOwnTopics(tenantID types.TenantID, id types.Uid) ([]string, error)
+ GetChannels(tenantID types.TenantID, id types.Uid) ([]string, error)
+ UpsertCred(tenantID types.TenantID, cred *types.Credential) (bool, error)
+ ConfirmCred(tenantID types.TenantID, id types.Uid, method string) error
+ FailCred(tenantID types.TenantID, id types.Uid, method string) error
+ GetActiveCred(tenantID types.TenantID, id types.Uid, method string) (*types.Credential, error)
+ GetAllCreds(tenantID types.TenantID, id types.Uid, method string, validatedOnly bool) ([]types.Credential, error)
+ DelCred(tenantID types.TenantID, id types.Uid, method, value string) error
+ GetUnreadCount(tenantID types.TenantID, ids ...types.Uid) (map[types.Uid]int, error)
+ GetUnvalidated(tenantID types.TenantID, lastUpdatedBefore time.Time, limit int) ([]types.Uid, error)
+}
+
+// usersMapper is a concrete type which implements UsersPersistenceInterface.
+type usersMapper struct{}
+
+// Users is a singleton ancor object exporting UsersPersistenceInterface methods.
+var Users UsersPersistenceInterface
+
+// Create inserts User object into a database, updates creation time and assigns UID
+func (usersMapper) Create(tenantID types.TenantID, user *types.User, private any) (*types.User, error) {
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return nil, err
+ }
+ if user == nil || user.TenantID != tenantID {
+ return nil, types.ErrMalformed
+ }
+
+ user.SetUid(Store.GetUid())
+ user.InitTimes()
+
+ err = tenantAdp.TenantUserCreate(tenantID, user)
+ if err != nil {
+ return nil, err
+ }
+
+ // Create user's subscription to 'me' && 'fnd'. These topics are ephemeral, the topic object need not to be
+ // inserted.
+ err = Subs.Create(tenantID,
+ &types.Subscription{
+ ObjHeader: types.ObjHeader{CreatedAt: user.CreatedAt},
+ TenantID: user.TenantID,
+ User: user.Id,
+ Topic: user.Uid().UserId(),
+ ModeWant: types.ModeCMeFnd,
+ ModeGiven: types.ModeCMeFnd,
+ Private: private,
+ },
+ &types.Subscription{
+ ObjHeader: types.ObjHeader{CreatedAt: user.CreatedAt},
+ TenantID: user.TenantID,
+ User: user.Id,
+ Topic: user.Uid().FndName(),
+ ModeWant: types.ModeCMeFnd,
+ ModeGiven: types.ModeCMeFnd,
+ Private: nil,
+ })
+ if err != nil {
+ // Best effort to delete incomplete user record. Orphaned user records are not a problem.
+ // They just take up space.
+ tenantAdp.TenantUserDelete(tenantID, user.Uid(), true)
+ return nil, err
+ }
+
+ return user, nil
+}
+
+// GetAuthRecord takes a user ID and a authentication scheme name, fetches unique scheme-dependent identifier and
+// authentication secret.
+func (usersMapper) GetAuthRecord(tenantID types.TenantID, user types.Uid, scheme string) (string, auth.Level, []byte, time.Time, error) {
+ tenantAdp, err := tenantAuthAdapter(tenantID)
+ if err != nil {
+ return "", auth.LevelNone, nil, time.Time{}, err
+ }
+ unique, authLvl, secret, expires, err := tenantAdp.TenantAuthGetRecord(tenantID, user, scheme)
+ if err == nil {
+ parts := strings.Split(unique, ":")
+ if len(parts) > 1 {
+ unique = parts[1]
+ } else {
+ err = types.ErrInternal
+ }
+ }
+
+ return unique, authLvl, secret, expires, err
+}
+
+// GetAuthUniqueRecord takes a unique identifier and a authentication scheme name, fetches user ID and
+// authentication secret.
+func (usersMapper) GetAuthUniqueRecord(tenantID types.TenantID, scheme, unique string) (types.Uid, auth.Level, []byte, time.Time, error) {
+ tenantAdp, err := tenantAuthAdapter(tenantID)
+ if err != nil {
+ return types.ZeroUid, auth.LevelNone, nil, time.Time{}, err
+ }
+ return tenantAdp.TenantAuthGetUniqueRecord(tenantID, scheme+":"+unique)
+}
+
+// AddAuthRecord creates a new authentication record for the given user.
+func (usersMapper) AddAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, scheme, unique string, secret []byte,
+ expires time.Time) error {
+ tenantAdp, err := tenantAuthAdapter(tenantID)
+ if err != nil {
+ return err
+ }
+ return tenantAdp.TenantAuthAddRecord(tenantID, uid, scheme, scheme+":"+unique, authLvl, secret, expires)
+}
+
+// UpdateAuthRecord updates authentication record with a new secret and expiration time.
+func (usersMapper) UpdateAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, scheme, unique string,
+ secret []byte, expires time.Time) error {
+ tenantAdp, err := tenantAuthAdapter(tenantID)
+ if err != nil {
+ return err
+ }
+ return tenantAdp.TenantAuthUpdRecord(tenantID, uid, scheme, scheme+":"+unique, authLvl, secret, expires)
+}
+
+// DelAuthRecords deletes user's auth records of the given scheme.
+func (usersMapper) DelAuthRecords(tenantID types.TenantID, uid types.Uid, scheme string) error {
+ tenantAdp, err := tenantAuthAdapter(tenantID)
+ if err != nil {
+ return err
+ }
+ return tenantAdp.TenantAuthDelScheme(tenantID, uid, scheme)
+}
+
+// Get returns a user object for the given user ID or nil if the user is not found.
+func (usersMapper) Get(tenantID types.TenantID, uid types.Uid) (*types.User, error) {
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return nil, err
+ }
+ user, err := tenantAdp.TenantUserGet(tenantID, uid)
+ if err == nil && user != nil && user.TenantID != tenantID {
+ return nil, types.ErrNotFound
+ }
+ return user, err
+}
+
+// GetAll returns a slice of user objects for the given user IDs.
+func (usersMapper) GetAll(tenantID types.TenantID, uid ...types.Uid) ([]types.User, error) {
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return nil, err
+ }
+ users, err := tenantAdp.TenantUserGetAll(tenantID, uid...)
+ if err != nil {
+ return nil, err
+ }
+ for i := range users {
+ if users[i].TenantID != tenantID {
+ return nil, types.ErrNotFound
+ }
+ }
+ return users, nil
+}
+
+// GetByCred returns user ID for the given validated credential.
+func (usersMapper) GetByCred(tenantID types.TenantID, method, value string) (types.Uid, error) {
+ tenantAdp, err := tenantAuthAdapter(tenantID)
+ if err != nil {
+ return types.ZeroUid, err
+ }
+ return tenantAdp.TenantUserGetByCred(tenantID, method, value)
+}
+
+// Delete deletes user records.
+func (usersMapper) Delete(tenantID types.TenantID, id types.Uid, hard bool) error {
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return err
+ }
+ return tenantAdp.TenantUserDelete(tenantID, id, hard)
+}
+
+// UpdateLastSeen updates LastSeen and UserAgent.
+func (usersMapper) UpdateLastSeen(tenantID types.TenantID, uid types.Uid, userAgent string, when time.Time) error {
+ return Users.Update(tenantID, uid, map[string]any{"LastSeen": when, "UserAgent": userAgent})
+}
+
+// Update is a general-purpose update of user data.
+func (usersMapper) Update(tenantID types.TenantID, uid types.Uid, update map[string]any) error {
+ if _, ok := update["UpdatedAt"]; !ok {
+ update["UpdatedAt"] = types.TimeNow()
+ }
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return err
+ }
+ return tenantAdp.TenantUserUpdate(tenantID, uid, update)
+}
+
+// UpdateTags either adds, removes, or resets tags to the given slices.
+func (usersMapper) UpdateTags(tenantID types.TenantID, uid types.Uid, add, remove, reset []string) ([]string, error) {
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return nil, err
+ }
+ return tenantAdp.TenantUserUpdateTags(tenantID, uid, add, remove, reset)
+}
+
+// UpdateState changes user's state and state of some topics associated with the user.
+func (usersMapper) UpdateState(tenantID types.TenantID, uid types.Uid, state types.ObjState) error {
+ update := map[string]any{
+ "State": state,
+ "StateAt": types.TimeNow()}
+ return Users.Update(tenantID, uid, update)
+}
+
+// GetSubs loads *all* subscriptions for the given user.
+// Does not load Public/Trusted or Private, does not load deleted subscriptions.
+func (usersMapper) GetSubs(tenantID types.TenantID, id types.Uid) ([]types.Subscription, error) {
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return nil, err
+ }
+ return tenantAdp.TenantSubsForUser(tenantID, id)
+}
+
+// FindSubs find a list of users and topics for the given tags. Results are formatted as subscriptions.
+// `required` specifies an AND of ORs for required terms:
+// at least one element of every sublist in `required` must be present in the object's tags list.
+// `optional` specifies a list of optional terms.
+func (usersMapper) FindSubs(tenantID types.TenantID, caller types.Uid, prefPrefix string, required [][]string, optional []string, activeOnly bool) ([]types.Subscription, error) {
+ if len(required) == 0 && len(optional) == 0 {
+ // No tags specified, return empty list.
+ return nil, nil
+ }
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return nil, err
+ }
+ return tenantAdp.TenantFind(tenantID, caller.UserId(), prefPrefix, required, optional, activeOnly)
+}
+
+// Find returns topics and/or users which match the given tag, with optional partial matching.
+func (usersMapper) FindOne(tenantID types.TenantID, tag string) (string, error) {
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return "", err
+ }
+ return tenantAdp.TenantFindOne(tenantID, tag)
+}
+
+// GetTopics load a list of user's subscriptions with Public+Trusted fields copied to subscription
+func (usersMapper) GetTopics(tenantID types.TenantID, id types.Uid, opts *types.QueryOpt) ([]types.Subscription, error) {
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return nil, err
+ }
+ return tenantAdp.TenantTopicsForUser(tenantID, id, false, opts)
+}
+
+// GetTopicsAny load a list of user's subscriptions with Public+Trusted fields copied to subscription.
+// Deleted topics are returned too.
+func (usersMapper) GetTopicsAny(tenantID types.TenantID, id types.Uid, opts *types.QueryOpt) ([]types.Subscription, error) {
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return nil, err
+ }
+ return tenantAdp.TenantTopicsForUser(tenantID, id, true, opts)
+}
+
+// GetOwnTopics returns a slice of group topic names where the user is the owner.
+func (usersMapper) GetOwnTopics(tenantID types.TenantID, id types.Uid) ([]string, error) {
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return nil, err
+ }
+ return tenantAdp.TenantOwnTopics(tenantID, id)
+}
+
+// GetChannels returns a slice of group topic names where the user is a channel reader.
+func (usersMapper) GetChannels(tenantID types.TenantID, id types.Uid) ([]string, error) {
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return nil, err
+ }
+ return tenantAdp.TenantChannelsForUser(tenantID, id)
+}
+
+// UpsertCred adds or updates a credential validation request. Return true if the record was inserted, false if updated.
+func (usersMapper) UpsertCred(tenantID types.TenantID, cred *types.Credential) (bool, error) {
+ cred.InitTimes()
+ tenantAdp, err := tenantAuthAdapter(tenantID)
+ if err != nil {
+ return false, err
+ }
+ return tenantAdp.TenantCredUpsert(tenantID, cred)
+}
+
+// ConfirmCred marks credential method as confirmed.
+func (usersMapper) ConfirmCred(tenantID types.TenantID, id types.Uid, method string) error {
+ tenantAdp, err := tenantAuthAdapter(tenantID)
+ if err != nil {
+ return err
+ }
+ return tenantAdp.TenantCredConfirm(tenantID, id, method)
+}
+
+// FailCred increments fail count for the given credential method.
+func (usersMapper) FailCred(tenantID types.TenantID, id types.Uid, method string) error {
+ tenantAdp, err := tenantAuthAdapter(tenantID)
+ if err != nil {
+ return err
+ }
+ return tenantAdp.TenantCredFail(tenantID, id, method)
+}
+
+// GetActiveCred gets a the currently active credential for the given user and method.
+func (usersMapper) GetActiveCred(tenantID types.TenantID, id types.Uid, method string) (*types.Credential, error) {
+ tenantAdp, err := tenantAuthAdapter(tenantID)
+ if err != nil {
+ return nil, err
+ }
+ return tenantAdp.TenantCredGetActive(tenantID, id, method)
+}
+
+// GetAllCreds returns credentials of the given user, all or validated only.
+func (usersMapper) GetAllCreds(tenantID types.TenantID, id types.Uid, method string, validatedOnly bool) ([]types.Credential, error) {
+ tenantAdp, err := tenantAuthAdapter(tenantID)
+ if err != nil {
+ return nil, err
+ }
+ return tenantAdp.TenantCredGetAll(tenantID, id, method, validatedOnly)
+}
+
+// DelCred deletes user's credentials. If method is "", all credentials are deleted.
+func (usersMapper) DelCred(tenantID types.TenantID, id types.Uid, method, value string) error {
+ tenantAdp, err := tenantAuthAdapter(tenantID)
+ if err != nil {
+ return err
+ }
+ return tenantAdp.TenantCredDel(tenantID, id, method, value)
+}
+
+// GetUnreadCount returs users' total count of unread messages in all topics with the R permissions.
+func (usersMapper) GetUnreadCount(tenantID types.TenantID, ids ...types.Uid) (map[types.Uid]int, error) {
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return nil, err
+ }
+ return tenantAdp.TenantUserUnreadCount(tenantID, ids...)
+}
+
+// GetUnvalidated returns a list of stale user ids which have unvalidated credentials,
+// their auth levels and a comma-separated list of these credential names.
+func (usersMapper) GetUnvalidated(tenantID types.TenantID, lastUpdatedBefore time.Time, limit int) ([]types.Uid, error) {
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return nil, err
+ }
+ return tenantAdp.TenantUserGetUnvalidated(tenantID, lastUpdatedBefore, limit)
+}
+
+// TopicsPersistenceInterface is an interface which defines methods for persistent storage of topics.
+type TopicsPersistenceInterface interface {
+ Create(tenantID types.TenantID, topic *types.Topic, owner types.Uid, private any) error
+ CreateP2P(tenantID types.TenantID, initiator, invited *types.Subscription) error
+ Get(tenantID types.TenantID, topic string) (*types.Topic, error)
+ GetUsers(tenantID types.TenantID, topic string, opts *types.QueryOpt) ([]types.Subscription, error)
+ GetUsersAny(tenantID types.TenantID, topic string, opts *types.QueryOpt) ([]types.Subscription, error)
+ GetSubs(tenantID types.TenantID, topic string, opts *types.QueryOpt) ([]types.Subscription, error)
+ GetSubsAny(tenantID types.TenantID, topic string, opts *types.QueryOpt) ([]types.Subscription, error)
+ Update(tenantID types.TenantID, topic string, update map[string]any) error
+ UpdateSubCnt(tenantID types.TenantID, topic string) error
+ OwnerChange(tenantID types.TenantID, topic string, newOwner types.Uid) error
+ Delete(tenantID types.TenantID, topic string, isChan, hard bool) error
+}
+
+// topicsMapper is a concrete type implementing TopicsPersistenceInterface.
+type topicsMapper struct{}
+
+// Topics is a singleton ancor object exporting TopicsPersistenceInterface methods.
+var Topics TopicsPersistenceInterface
+
+// Create creates a topic and owner's subscription to it.
+func (topicsMapper) Create(tenantID types.TenantID, topic *types.Topic, owner types.Uid, private any) error {
+ if topic == nil || topic.TenantID != tenantID {
+ return types.ErrMalformed
+ }
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return err
+ }
+
+ topic.InitTimes()
+ topic.TouchedAt = topic.CreatedAt
+ topic.Owner = owner.String()
+
+ err = tenantAdp.TenantTopicCreate(tenantID, topic)
+ if err != nil {
+ return err
+ }
+
+ if !owner.IsZero() {
+ err = Subs.Create(tenantID, &types.Subscription{
+ ObjHeader: types.ObjHeader{CreatedAt: topic.CreatedAt},
+ TenantID: tenantID,
+ User: owner.String(),
+ Topic: topic.Id,
+ ModeGiven: types.ModeCFull,
+ ModeWant: topic.GetAccess(owner),
+ Private: private})
+ }
+
+ return err
+}
+
+// CreateP2P creates a P2P topic by generating two user's subsciptions to each other.
+func (topicsMapper) CreateP2P(tenantID types.TenantID, initiator, invited *types.Subscription) error {
+ if initiator == nil || invited == nil || initiator.TenantID != tenantID || invited.TenantID != tenantID {
+ return types.ErrMalformed
+ }
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return err
+ }
+ initiator.InitTimes()
+ initiator.SetTouchedAt(initiator.CreatedAt)
+ invited.InitTimes()
+ invited.SetTouchedAt(invited.CreatedAt)
+
+ return tenantAdp.TenantTopicCreateP2P(tenantID, initiator, invited)
+}
+
+// Get a single topic with a list of relevant users de-normalized into it
+func (topicsMapper) Get(tenantID types.TenantID, topic string) (*types.Topic, error) {
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return nil, err
+ }
+ result, err := tenantAdp.TenantTopicGet(tenantID, topic)
+ if err == nil && result != nil && result.TenantID != tenantID {
+ return nil, types.ErrNotFound
+ }
+ return result, err
+}
+
+// GetUsers loads subscriptions for topic plus loads user.Public+Trusted.
+// Deleted subscriptions are not loaded.
+func (topicsMapper) GetUsers(tenantID types.TenantID, topic string, opts *types.QueryOpt) ([]types.Subscription, error) {
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return nil, err
+ }
+ return tenantAdp.TenantUsersForTopic(tenantID, topic, false, opts)
+}
+
+// GetUsersAny loads subscriptions for topic plus loads user.Public+Trusted. It's the same as GetUsers,
+// except it loads deleted subscriptions too.
+func (topicsMapper) GetUsersAny(tenantID types.TenantID, topic string, opts *types.QueryOpt) ([]types.Subscription, error) {
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return nil, err
+ }
+ return tenantAdp.TenantUsersForTopic(tenantID, topic, true, opts)
+}
+
+// GetSubs loads a list of subscriptions to the given topic, user.Public+Trusted and deleted
+// subscriptions are not loaded. Suspended subscriptions are loaded.
+func (topicsMapper) GetSubs(tenantID types.TenantID, topic string, opts *types.QueryOpt) ([]types.Subscription, error) {
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return nil, err
+ }
+ return tenantAdp.TenantSubsForTopic(tenantID, topic, false, opts)
+}
+
+// GetSubsAny loads a list of subscriptions to the given topic including deleted subscription.
+// user.Public/Trusted are not loaded
+func (topicsMapper) GetSubsAny(tenantID types.TenantID, topic string, opts *types.QueryOpt) ([]types.Subscription, error) {
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return nil, err
+ }
+ return tenantAdp.TenantSubsForTopic(tenantID, topic, true, opts)
+}
+
+// UpdateSubCnt refreshes subscriber count value denormalized in topic.
+func (topicsMapper) UpdateSubCnt(tenantID types.TenantID, topic string) error {
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return err
+ }
+ return tenantAdp.TenantTopicUpdateSubCnt(tenantID, topic)
+}
+
+// Update is a generic topic update.
+func (topicsMapper) Update(tenantID types.TenantID, topic string, update map[string]any) error {
+ if _, ok := update["UpdatedAt"]; !ok {
+ update["UpdatedAt"] = types.TimeNow()
+ }
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return err
+ }
+ return tenantAdp.TenantTopicUpdate(tenantID, topic, update)
+}
+
+// OwnerChange replaces the old topic owner with the new owner.
+func (topicsMapper) OwnerChange(tenantID types.TenantID, topic string, newOwner types.Uid) error {
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return err
+ }
+ return tenantAdp.TenantTopicOwnerChange(tenantID, topic, newOwner)
+}
+
+// Delete deletes topic, messages, attachments, and subscriptions.
+func (topicsMapper) Delete(tenantID types.TenantID, topic string, isChan, hard bool) error {
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return err
+ }
+ return tenantAdp.TenantTopicDelete(tenantID, topic, isChan, hard)
+}
+
+// SubsPersistenceInterface is an interface which defines methods for persistent storage of subscriptions.
+type SubsPersistenceInterface interface {
+ Create(tenantID types.TenantID, subs ...*types.Subscription) error
+ Get(tenantID types.TenantID, topic string, user types.Uid, keepDeleted bool) (*types.Subscription, error)
+ Update(tenantID types.TenantID, topic string, user types.Uid, update map[string]any) error
+ Delete(tenantID types.TenantID, topic string, user types.Uid) error
+}
+
+// subsMapper is a concrete type implementing SubsPersistenceInterface.
+type subsMapper struct{}
+
+// Subs is a singleton ancor object exporting SubsPersistenceInterface.
+var Subs SubsPersistenceInterface
+
+// Create creates multiple subscriptions.
+func (subsMapper) Create(tenantID types.TenantID, subs ...*types.Subscription) error {
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return err
+ }
+ if len(subs) == 0 {
+ // Nothing to do.
+ return nil
+ }
+
+ topic := subs[0].Topic
+ if types.IsEphemeralTopic(topic) {
+ // Ephemeral topics are not persisted in 'topics' table, don't try to update them.
+ // Mixing ephemeral and real topics is not permitted.
+ topic = ""
+ }
+
+ for _, sub := range subs {
+ if sub == nil || sub.TenantID != tenantID {
+ return types.ErrMalformed
+ }
+ sub.InitTimes()
+ if topic != "" && sub.Topic != topic {
+ return fmt.Errorf("all subscriptions must be for the same topic, got %s vs %s", sub.Topic, topic)
+ }
+ }
+
+ return tenantAdp.TenantTopicShare(tenantID, topic, subs)
+}
+
+// Get subscription given topic and user ID.
+func (subsMapper) Get(tenantID types.TenantID, topic string, user types.Uid, keepDeleted bool) (*types.Subscription, error) {
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return nil, err
+ }
+ result, err := tenantAdp.TenantSubscriptionGet(tenantID, topic, user, keepDeleted)
+ if err == nil && result != nil && result.TenantID != tenantID {
+ return nil, types.ErrNotFound
+ }
+ return result, err
+}
+
+// Update values of topic's subscriptions.
+func (subsMapper) Update(tenantID types.TenantID, topic string, user types.Uid, update map[string]any) error {
+ update["UpdatedAt"] = types.TimeNow()
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return err
+ }
+ return tenantAdp.TenantSubsUpdate(tenantID, topic, user, update)
+}
+
+// Delete deletes a subscription.
+// To delete channel subscription the channel name must be explicitly specified.
+func (subsMapper) Delete(tenantID types.TenantID, topic string, user types.Uid) error {
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return err
+ }
+ return tenantAdp.TenantSubsDelete(tenantID, topic, user)
+}
+
+// MessagesPersistenceInterface is an interface which defines methods for persistent storage of messages.
+type MessagesPersistenceInterface interface {
+ Save(tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) (error, bool)
+ DeleteList(tenantID types.TenantID, topic string, delID int, forUser types.Uid, msgDelAge time.Duration, ranges []types.Range) error
+ GetAll(tenantID types.TenantID, topic string, forUser types.Uid, opt *types.QueryOpt) ([]types.Message, error)
+ GetDeleted(tenantID types.TenantID, topic string, forUser types.Uid, opt *types.QueryOpt) ([]types.Range, int, error)
+}
+
+// messagesMapper is a concrete type implementing MessagesPersistenceInterface.
+type messagesMapper struct{}
+
+// Messages is a singleton ancor object for exporting MessagesPersistenceInterface.
+var Messages MessagesPersistenceInterface
+
+// Save message
+func (messagesMapper) Save(tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) (error, bool) {
+ if msg == nil || msg.TenantID != tenantID {
+ return types.ErrMalformed, false
+ }
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return err, false
+ }
+ msg.InitTimes()
+ msg.SetUid(Store.GetUid())
+ // Increment topic's or user's SeqId
+ err = tenantAdp.TenantTopicUpdateOnMessage(tenantID, msg.Topic, msg)
+ if err != nil {
+ return err, false
+ }
+
+ err = tenantAdp.TenantMessageSave(tenantID, msg)
+ if err != nil {
+ return err, false
+ }
+
+ markedReadBySender := false
+ // Mark message as read by the sender.
+ if readBySender {
+ // Make sure From is valid, otherwise we will reset values for all subscribers.
+ fromUid := types.ParseUid(msg.From)
+ if !fromUid.IsZero() {
+ // Ignore the error here. It's not a big deal if it fails.
+ if subErr := tenantAdp.TenantSubsUpdate(tenantID, msg.Topic, fromUid,
+ map[string]any{
+ "RecvSeqId": msg.SeqId,
+ "ReadSeqId": msg.SeqId}); subErr != nil {
+ logs.Warn.Printf("topic[%s]: failed to mark message (seq: %d) read by sender - err: %+v", msg.Topic, msg.SeqId, subErr)
+ } else {
+ markedReadBySender = true
+ }
+ }
+ }
+
+ if len(attachmentURLs) > 0 {
+ var attachments []string
+ for _, url := range attachmentURLs {
+ // Convert attachment URLs to file IDs.
+ if fid := mediaHandler.GetIdFromUrl(url); !fid.IsZero() {
+ attachments = append(attachments, fid.String())
+ }
+ }
+ if len(attachments) > 0 {
+ return tenantAdp.TenantFileLinkAttachments(tenantID, "", types.ZeroUid, msg.Uid(), attachments), markedReadBySender
+ }
+ }
+
+ return nil, markedReadBySender
+}
+
+// DeleteList deletes multiple messages defined by a list of ranges.
+func (messagesMapper) DeleteList(tenantID types.TenantID, topic string, delID int, forUser types.Uid, msgDelAge time.Duration, ranges []types.Range) error {
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return err
+ }
+ var toDel *types.DelMessage
+ if delID > 0 {
+ toDel = &types.DelMessage{
+ TenantID: tenantID,
+ Topic: topic,
+ DelId: delID,
+ DeletedFor: forUser.String(),
+ SeqIdRanges: ranges}
+ toDel.SetUid(Store.GetUid())
+ toDel.InitTimes()
+ if msgDelAge > 0 {
+ toDel.SetNewerThan(toDel.CreatedAt.Add(-msgDelAge))
+ }
+ }
+
+ err = tenantAdp.TenantMessageDeleteList(tenantID, topic, toDel)
+ if err != nil {
+ return err
+ }
+
+ // TODO: move to adapter.
+ if delID > 0 {
+ // Record ID of the delete transaction
+ err = tenantAdp.TenantTopicUpdate(tenantID, topic, map[string]any{"DelId": delID})
+ if err != nil {
+ return err
+ }
+
+ // Soft-deleting will update one subscription, hard-deleting will ipdate all.
+ // Soft- or hard- is defined by the forUser being defined.
+ err = tenantAdp.TenantSubsUpdate(tenantID, topic, forUser, map[string]any{"DelId": delID})
+ if err != nil {
+ return err
+ }
+ }
+
+ return err
+}
+
+// GetAll returns multiple messages.
+func (messagesMapper) GetAll(tenantID types.TenantID, topic string, forUser types.Uid, opt *types.QueryOpt) ([]types.Message, error) {
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return nil, err
+ }
+ return tenantAdp.TenantMessageGetAll(tenantID, topic, forUser, opt)
+}
+
+// GetDeleted returns the ranges of deleted messages and the largest DelId reported in the list.
+func (messagesMapper) GetDeleted(tenantID types.TenantID, topic string, forUser types.Uid, opt *types.QueryOpt) ([]types.Range, int, error) {
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return nil, 0, err
+ }
+ dmsgs, err := tenantAdp.TenantMessageGetDeleted(tenantID, topic, forUser, opt)
+ if err != nil {
+ return nil, 0, err
+ }
+
+ var ranges []types.Range
+ var maxID int
+ // Flatten out the ranges
+ for i := range dmsgs {
+ dm := &dmsgs[i]
+ if dm.DelId > maxID {
+ maxID = dm.DelId
+ }
+ ranges = append(ranges, dm.SeqIdRanges...)
+ }
+ sort.Sort(types.RangeSorter(ranges))
+ ranges = types.RangeSorter(ranges).Normalize()
+
+ return ranges, maxID, nil
+}
+
+// Registered authentication handlers.
+var authHandlers map[string]auth.AuthHandler
+
+// Logical auth handler names
+var authHandlerNames map[string]string
+
+// RegisterAuthScheme registers an authentication scheme handler.
+// The 'name' must be the hardcoded name, NOT the logical name.
+func RegisterAuthScheme(name string, handler auth.AuthHandler) {
+ if name == "" {
+ panic("RegisterAuthScheme: empty auth scheme name")
+ }
+ if handler == nil {
+ panic("RegisterAuthScheme: scheme handler is nil")
+ }
+
+ name = strings.ToLower(name)
+ if authHandlers == nil {
+ authHandlers = make(map[string]auth.AuthHandler)
+ }
+ if _, dup := authHandlers[name]; dup {
+ panic("RegisterAuthScheme: called twice for scheme " + name)
+ }
+ authHandlers[name] = handler
+}
+
+// GetAuthNames returns all addressable auth handler names, logical and hardcoded
+// excluding those which are disabled like "basic:".
+func (s storeObj) GetAuthNames() []string {
+ if len(authHandlers) == 0 {
+ return nil
+ }
+
+ allNames := make(map[string]struct{})
+ for name := range authHandlers {
+ allNames[name] = struct{}{}
+ }
+ for name := range authHandlerNames {
+ allNames[name] = struct{}{}
+ }
+
+ var names []string
+ for name := range allNames {
+ if s.GetLogicalAuthHandler(name) != nil {
+ names = append(names, name)
+ }
+ }
+
+ return names
+
+}
+
+// GetAuthHandler returns an auth handler by actual hardcoded name irrspectful of logical naming.
+func (storeObj) GetAuthHandler(name string) auth.AuthHandler {
+ return authHandlers[strings.ToLower(name)]
+}
+
+// GetLogicalAuthHandler returns an auth handler by logical name. If there is no handler by that
+// logical name it tries to find one by the hardcoded name.
+func (storeObj) GetLogicalAuthHandler(name string) auth.AuthHandler {
+ name = strings.ToLower(name)
+ if len(authHandlerNames) != 0 {
+ if lname, ok := authHandlerNames[name]; ok {
+ return authHandlers[lname]
+ }
+ }
+ return authHandlers[name]
+}
+
+// InitAuthLogicalNames initializes authentication mapping "logical handler name":"actual handler name".
+// Logical name must not be empty, actual name could be an empty string.
+func InitAuthLogicalNames(config json.RawMessage) error {
+ if config == nil || string(config) == "null" {
+ return nil
+ }
+ var mapping []string
+ if err := json.Unmarshal(config, &mapping); err != nil {
+ return errors.New("store: failed to parse logical auth names: " + err.Error() + "(" + string(config) + ")")
+ }
+ if len(mapping) == 0 {
+ return nil
+ }
+
+ if authHandlerNames == nil {
+ authHandlerNames = make(map[string]string)
+ }
+ for _, pair := range mapping {
+ if parts := strings.Split(pair, ":"); len(parts) == 2 {
+ if parts[0] == "" {
+ return errors.New("store: empty logical auth name '" + pair + "'")
+ }
+ parts[0] = strings.ToLower(parts[0])
+ if _, ok := authHandlerNames[parts[0]]; ok {
+ return errors.New("store: duplicate mapping for logical auth name '" + pair + "'")
+ }
+ parts[1] = strings.ToLower(parts[1])
+ if parts[1] != "" {
+ if _, ok := authHandlers[parts[1]]; !ok {
+ return errors.New("store: unknown handler for logical auth name '" + pair + "'")
+ }
+ }
+ if parts[0] == parts[1] {
+ // Skip useless identity mapping.
+ continue
+ }
+ authHandlerNames[parts[0]] = parts[1]
+ } else {
+ return errors.New("store: invalid logical auth mapping '" + pair + "'")
+ }
+ }
+ return nil
+}
+
+// Registered authentication handlers.
+var validators map[string]validate.Validator
+
+// RegisterValidator registers validation scheme.
+func RegisterValidator(name string, v validate.Validator) {
+ name = strings.ToLower(name)
+ if validators == nil {
+ validators = make(map[string]validate.Validator)
+ }
+
+ if v == nil {
+ panic("RegisterValidator: validator is nil")
+ }
+ if _, dup := validators[name]; dup {
+ panic("RegisterValidator: called twice for validator " + name)
+ }
+ validators[name] = v
+}
+
+// GetValidator returns registered validator by name.
+func (storeObj) GetValidator(name string) validate.Validator {
+ return validators[strings.ToLower(name)]
+}
+
+// DevicePersistenceInterface is an interface which defines methods used for handling device IDs.
+// Mostly used to generate push notifications.
+type DevicePersistenceInterface interface {
+ Update(tenantID types.TenantID, uid types.Uid, oldDeviceID string, dev *types.DeviceDef) error
+ GetAll(tenantID types.TenantID, uid ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error)
+ Delete(tenantID types.TenantID, uid types.Uid, deviceID string) error
+}
+
+// deviceMapper is a concrete type implementing DevicePersistenceInterface.
+type deviceMapper struct{}
+
+// Devices is a singleton instance of DevicePersistenceInterface to map methods to.
+var Devices DevicePersistenceInterface
+
+// Update updates a device record.
+func (deviceMapper) Update(tenantID types.TenantID, uid types.Uid, oldDeviceID string, dev *types.DeviceDef) error {
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return err
+ }
+ // If the old device Id is specified and it's different from the new ID, delete the old id
+ if oldDeviceID != "" && (dev == nil || dev.DeviceId != oldDeviceID) {
+ if err := tenantAdp.TenantDeviceDelete(tenantID, uid, oldDeviceID); err != nil {
+ return err
+ }
+ }
+
+ // Insert or update the new DeviceId if one is given.
+ if dev != nil && dev.DeviceId != "" {
+ if dev.TenantID.IsZero() {
+ dev.TenantID = tenantID
+ }
+ if dev.TenantID != tenantID {
+ return types.ErrMalformed
+ }
+ return tenantAdp.TenantDeviceUpsert(tenantID, uid, dev)
+ }
+ return nil
+}
+
+// GetAll returns all known device IDs for a given list of user IDs.
+// The second return parameter is the count of found device IDs.
+func (deviceMapper) GetAll(tenantID types.TenantID, uid ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error) {
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return nil, 0, err
+ }
+ return tenantAdp.TenantDeviceGetAll(tenantID, uid...)
+}
+
+// Delete deletes device record for a given user.
+func (deviceMapper) Delete(tenantID types.TenantID, uid types.Uid, deviceID string) error {
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return err
+ }
+ return tenantAdp.TenantDeviceDelete(tenantID, uid, deviceID)
+}
+
+// Registered media/file handlers.
+var fileHandlers map[string]media.Handler
+
+// RegisterMediaHandler saves reference to a media handler (file upload-download handler).
+func RegisterMediaHandler(name string, mh media.Handler) {
+ if fileHandlers == nil {
+ fileHandlers = make(map[string]media.Handler)
+ }
+
+ if mh == nil {
+ panic("RegisterMediaHandler: handler is nil")
+ }
+ if _, dup := fileHandlers[name]; dup {
+ panic("RegisterMediaHandler: called twice for handler " + name)
+ }
+ fileHandlers[name] = mh
+}
+
+// GetMediaHandler returns default media handler.
+func (storeObj) GetMediaHandler() media.Handler {
+ return mediaHandler
+}
+
+// UseMediaHandler sets specified media handler as default.
+func (storeObj) UseMediaHandler(name, config string) error {
+ mediaHandler = fileHandlers[name]
+ if mediaHandler == nil {
+ panic("UseMediaHandler: unknown handler '" + name + "'")
+ }
+ return mediaHandler.Init(config)
+}
+
+// FilePersistenceInterface is an interface wchich defines methods used for file handling (records or uploaded files).
+type FilePersistenceInterface interface {
+ // StartUpload records that the given user initiated a file upload
+ StartUpload(tenantID types.TenantID, fd *types.FileDef) error
+ // FinishUpload marks started upload as successfully finished.
+ FinishUpload(tenantID types.TenantID, fd *types.FileDef, success bool, size int64) (*types.FileDef, error)
+ // Get fetches a file record for a unique file id.
+ Get(tenantID types.TenantID, fid string) (*types.FileDef, error)
+ // DeleteUnused removes unused attachments.
+ DeleteUnused(tenantID types.TenantID, olderThan time.Time, limit int) error
+ // LinkAttachments connects earlier uploaded attachments to a message or topic to prevent it
+ // from being garbage collected.
+ LinkAttachments(tenantID types.TenantID, topic string, msgId types.Uid, attachments []string) error
+}
+
+// fileMapper is concrete type which implements FilePersistenceInterface.
+type fileMapper struct{}
+
+// Files is a sigleton instance of FilePersistenceInterface to be used for handling file uploads.
+var Files FilePersistenceInterface
+
+// StartUpload records that the given user initiated a file upload
+func (fileMapper) StartUpload(tenantID types.TenantID, fd *types.FileDef) error {
+ if fd == nil || fd.TenantID != tenantID {
+ return types.ErrMalformed
+ }
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return err
+ }
+ fd.Status = types.UploadStarted
+ return tenantAdp.TenantFileStartUpload(tenantID, fd)
+}
+
+// FinishUpload marks started upload as successfully finished or failed.
+func (fileMapper) FinishUpload(tenantID types.TenantID, fd *types.FileDef, success bool, size int64) (*types.FileDef, error) {
+ if fd == nil || fd.TenantID != tenantID {
+ return nil, types.ErrMalformed
+ }
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return nil, err
+ }
+ return tenantAdp.TenantFileFinishUpload(tenantID, fd, success, size)
+}
+
+// Get fetches a file record for a unique file id.
+func (fileMapper) Get(tenantID types.TenantID, fid string) (*types.FileDef, error) {
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return nil, err
+ }
+ fd, err := tenantAdp.TenantFileGet(tenantID, fid)
+ if err == nil && fd != nil && fd.TenantID != tenantID {
+ return nil, types.ErrNotFound
+ }
+ return fd, err
+}
+
+// DeleteUnused removes unused attachments and avatars.
+func (fileMapper) DeleteUnused(tenantID types.TenantID, olderThan time.Time, limit int) error {
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return err
+ }
+ toDel, err := tenantAdp.TenantFileDeleteUnused(tenantID, olderThan, limit)
+ if err != nil {
+ return err
+ }
+ if len(toDel) > 0 {
+ logs.Warn.Println("deleting media", toDel)
+ return Store.GetMediaHandler().Delete(toDel)
+ }
+ return nil
+}
+
+// LinkAttachments connects earlier uploaded attachments to a message or topic to prevent it
+// from being garbage collected.
+func (fileMapper) LinkAttachments(tenantID types.TenantID, topic string, msgId types.Uid, attachments []string) error {
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return err
+ }
+ // Convert attachment URLs to file IDs.
+ var fids []string
+ for _, url := range attachments {
+ if fid := mediaHandler.GetIdFromUrl(url); !fid.IsZero() {
+ fids = append(fids, fid.String())
+ }
+ }
+
+ if len(fids) > 0 {
+ userId := types.ZeroUid
+ if types.GetTopicCat(topic) == types.TopicCatMe {
+ userId = types.ParseUserId(topic)
+ topic = ""
+ }
+ return tenantAdp.TenantFileLinkAttachments(tenantID, topic, userId, msgId, fids)
+ }
+ return nil
+}
+
+// PersistentCacheInterface is an interface which defines methods used for accessing persistent key-value cache.
+type PersistentCacheInterface interface {
+ // Get reads a persistent cache entry.
+ Get(tenantID types.TenantID, key string) (string, error)
+ // Upsert creates or updates a persistent cache entry.
+ Upsert(tenantID types.TenantID, key string, value string, failOnDuplicate bool) error
+ // Delete deletes a single persistent cache entry.
+ Delete(tenantID types.TenantID, key string) error
+ // Expire expires older entries with the specified key prefix.
+ Expire(tenantID types.TenantID, keyPrefix string, olderThan time.Time) error
+}
+
+// pcacheMapper is concrete type which implements PersistentCacheInterface.
+type pcacheMapper struct{}
+
+var PCache PersistentCacheInterface
+
+// Get reads a persistent cache entry.
+func (pcacheMapper) Get(tenantID types.TenantID, key string) (string, error) {
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return "", err
+ }
+ return tenantAdp.TenantPCacheGet(tenantID, key)
+}
+
+// Upsert creates or updates a persistent cache entry.
+func (pcacheMapper) Upsert(tenantID types.TenantID, key string, value string, failOnDuplicate bool) error {
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return err
+ }
+ return tenantAdp.TenantPCacheUpsert(tenantID, key, value, failOnDuplicate)
+}
+
+// Delete deletes a single persistent cache entry.
+func (pcacheMapper) Delete(tenantID types.TenantID, key string) error {
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return err
+ }
+ return tenantAdp.TenantPCacheDelete(tenantID, key)
+}
+
+// Expire expires older entries with the specified key prefix.
+func (pcacheMapper) Expire(tenantID types.TenantID, keyPrefix string, olderThan time.Time) error {
+ tenantAdp, err := tenantBusinessAdapter(tenantID)
+ if err != nil {
+ return err
+ }
+ return tenantAdp.TenantPCacheExpire(tenantID, keyPrefix, olderThan)
+}
+
+func SetTestUidGenerator(g types.UidGenerator) {
+ uGen = g
+}
+
+func init() {
+ Store = storeObj{}
+ Tenants = tenantsMapper{}
+ Users = usersMapper{}
+ Topics = topicsMapper{}
+ Subs = subsMapper{}
+ Messages = messagesMapper{}
+ Devices = deviceMapper{}
+ Files = fileMapper{}
+ PCache = pcacheMapper{}
+}
diff --git a/server/store/tenant_store_test.go b/server/store/tenant_store_test.go
new file mode 100644
index 0000000..885c32d
--- /dev/null
+++ b/server/store/tenant_store_test.go
@@ -0,0 +1,13 @@
+package store
+
+import (
+ "testing"
+
+ "github.com/tinode/chat/server/store/types"
+)
+
+func TestForTenantRejectsZero(t *testing.T) {
+ if scoped, err := ForTenant(types.ZeroTenantID); err != types.ErrMalformed || scoped != nil {
+ t.Fatalf("ForTenant(0) = (%v, %v), want (nil, malformed)", scoped, err)
+ }
+}
diff --git a/server/store/types/tenant.go b/server/store/types/tenant.go
new file mode 100644
index 0000000..c479ba8
--- /dev/null
+++ b/server/store/types/tenant.go
@@ -0,0 +1,48 @@
+package types
+
+import "time"
+
+// TenantID is the database identifier of a tenant.
+type TenantID int64
+
+// ZeroTenantID represents an unbound tenant context.
+const ZeroTenantID TenantID = 0
+
+// IsZero reports whether the tenant ID is uninitialized.
+func (id TenantID) IsZero() bool {
+ return id == ZeroTenantID
+}
+
+// IsValid reports whether the tenant ID can be used for tenant-scoped access.
+func (id TenantID) IsValid() bool {
+ return id > ZeroTenantID
+}
+
+// TenantState is the lifecycle state of a tenant.
+type TenantState int16
+
+const (
+ TenantStateProvisioning TenantState = iota
+ TenantStateActive
+ TenantStateSuspended
+ TenantStateDeleting
+ TenantStateDeleted
+)
+
+// Tenant is the tenant registry record used to establish a session context.
+type Tenant struct {
+ ID TenantID `db:"id" json:"-"`
+ Code string `db:"code" json:"code"`
+ Name string `db:"name" json:"name"`
+ TenantDesc *string `db:"tenant_desc" json:"tenant_desc,omitempty"`
+ State TenantState `db:"state" json:"-"`
+ CreatedAt time.Time `db:"created_at" json:"-"`
+ CreatedBy int64 `db:"created_by" json:"-"`
+ UpdatedAt time.Time `db:"updated_at" json:"-"`
+ UpdatedBy int64 `db:"updated_by" json:"-"`
+}
+
+// IsActive reports whether the tenant can accept new sessions.
+func (t *Tenant) IsActive() bool {
+ return t != nil && t.State == TenantStateActive
+}
diff --git a/server/store/types/tenant_key.go b/server/store/types/tenant_key.go
new file mode 100644
index 0000000..f95945b
--- /dev/null
+++ b/server/store/types/tenant_key.go
@@ -0,0 +1,43 @@
+package types
+
+import "strconv"
+
+// TopicKey uniquely identifies a topic inside the server without changing its
+// externally visible or persisted name.
+type TopicKey struct {
+ TenantID TenantID
+ Topic string
+}
+
+// IsValid reports whether the key can be used for tenant-scoped routing.
+func (k TopicKey) IsValid() bool {
+ return k.TenantID.IsValid() && k.Topic != ""
+}
+
+// RoutingKey returns the internal key used by the cluster and multiplexing
+// layers. It must never be persisted as a topic name or returned to a client.
+func (k TopicKey) RoutingKey() string {
+ if !k.IsValid() {
+ return ""
+ }
+ return strconv.FormatInt(int64(k.TenantID), 10) + ":" + k.Topic
+}
+
+// UserKey uniquely identifies a user within a tenant-scoped operation.
+type UserKey struct {
+ TenantID TenantID
+ User Uid
+}
+
+// IsValid reports whether the key contains both a tenant and a user.
+func (k UserKey) IsValid() bool {
+ return k.TenantID.IsValid() && !k.User.IsZero()
+}
+
+// RoutingKey returns an internal string representation of the user key.
+func (k UserKey) RoutingKey() string {
+ if !k.IsValid() {
+ return ""
+ }
+ return strconv.FormatInt(int64(k.TenantID), 10) + ":" + k.User.UserId()
+}
diff --git a/server/store/types/tenant_key_test.go b/server/store/types/tenant_key_test.go
new file mode 100644
index 0000000..fa168a8
--- /dev/null
+++ b/server/store/types/tenant_key_test.go
@@ -0,0 +1,26 @@
+package types
+
+import "testing"
+
+func TestTopicKeyRoutingKeyIsTenantScoped(t *testing.T) {
+ a := TopicKey{TenantID: 1, Topic: "sys"}
+ b := TopicKey{TenantID: 2, Topic: "sys"}
+ if !a.IsValid() || !b.IsValid() {
+ t.Fatal("valid topic keys reported as invalid")
+ }
+ if a.RoutingKey() == b.RoutingKey() {
+ t.Fatalf("routing key is not tenant scoped: %q", a.RoutingKey())
+ }
+ if got := a.RoutingKey(); got != "1:sys" {
+ t.Fatalf("unexpected routing key: %q", got)
+ }
+}
+
+func TestTenantKeysRejectMissingScope(t *testing.T) {
+ if (TopicKey{Topic: "sys"}).IsValid() {
+ t.Fatal("topic key without tenant is valid")
+ }
+ if (UserKey{TenantID: 1}).IsValid() {
+ t.Fatal("user key without user is valid")
+ }
+}
diff --git a/server/store/types/types.go b/server/store/types/types.go
index e69de29..3102d81 100644
--- a/server/store/types/types.go
+++ b/server/store/types/types.go
@@ -0,0 +1,1459 @@
+// Package types provides data types for persisting objects in the databases.
+package types
+
+import (
+ "database/sql/driver"
+ "encoding/base32"
+ "encoding/base64"
+ "encoding/binary"
+ "encoding/json"
+ "errors"
+ "slices"
+ "sort"
+ "strings"
+ "time"
+)
+
+// StoreError satisfies Error interface but allows constant values for
+// direct comparison.
+type StoreError string
+
+// Error is required by error interface.
+func (s StoreError) Error() string {
+ return string(s)
+}
+
+const (
+ // ErrInternal means DB or other internal failure.
+ ErrInternal = StoreError("internal")
+ // ErrMalformed means the secret cannot be parsed or otherwise wrong.
+ ErrMalformed = StoreError("malformed")
+ // ErrFailed means authentication failed (wrong login or password, etc).
+ ErrFailed = StoreError("failed")
+ // ErrDuplicate means duplicate credential, i.e. non-unique login.
+ ErrDuplicate = StoreError("duplicate value")
+ // ErrUnsupported means an operation is not supported.
+ ErrUnsupported = StoreError("unsupported")
+ // ErrExpired means the secret has expired.
+ ErrExpired = StoreError("expired")
+ // ErrPolicy means policy violation, e.g. password too weak.
+ ErrPolicy = StoreError("policy")
+ // ErrCredentials means credentials like email or captcha must be validated.
+ ErrCredentials = StoreError("credentials")
+ // ErrUserNotFound means the user was not found.
+ ErrUserNotFound = StoreError("user not found")
+ // ErrTopicNotFound means the topic was not found.
+ ErrTopicNotFound = StoreError("topic not found")
+ // ErrNotFound means the object other then user or topic was not found.
+ ErrNotFound = StoreError("not found")
+ // ErrPermissionDenied means the operation is not permitted.
+ ErrPermissionDenied = StoreError("denied")
+ // ErrInvalidResponse means the client's response does not match server's expectation.
+ ErrInvalidResponse = StoreError("invalid response")
+ // ErrRedirected means the subscription request was redirected to another topic.
+ ErrRedirected = StoreError("redirected")
+)
+
+// Uid is a database-specific record id, suitable to be used as a primary key.
+type Uid uint64
+
+// ZeroUid is a constant representing uninitialized Uid.
+const ZeroUid Uid = 0
+
+// NullValue is a Unicode DEL character which indicated that the value is being deleted.
+const NullValue = "\u2421"
+
+// Lengths of various Uid representations.
+const (
+ uidBase64Unpadded = 11
+ p2pBase64Unpadded = 22
+)
+
+// IsZero checks if Uid is uninitialized.
+func (uid Uid) IsZero() bool {
+ return uid == ZeroUid
+}
+
+// Compare returns 0 if uid is equal to u2, 1 if u2 is greater than uid, -1 if u2 is smaller.
+func (uid Uid) Compare(u2 Uid) int {
+ if uid < u2 {
+ return -1
+ } else if uid > u2 {
+ return 1
+ }
+ return 0
+}
+
+// MarshalBinary converts Uid to byte slice.
+func (uid Uid) MarshalBinary() ([]byte, error) {
+ dst := make([]byte, 8)
+ binary.LittleEndian.PutUint64(dst, uint64(uid))
+ return dst, nil
+}
+
+// UnmarshalBinary reads Uid from byte slice.
+func (uid *Uid) UnmarshalBinary(b []byte) error {
+ if len(b) < 8 {
+ return errors.New("Uid.UnmarshalBinary: invalid length")
+ }
+ *uid = Uid(binary.LittleEndian.Uint64(b))
+ return nil
+}
+
+// UnmarshalText reads Uid from string represented as byte slice.
+func (uid *Uid) UnmarshalText(src []byte) error {
+ if len(src) != uidBase64Unpadded {
+ return errors.New("Uid.UnmarshalText: invalid length")
+ }
+ dec := make([]byte, base64.URLEncoding.WithPadding(base64.NoPadding).DecodedLen(uidBase64Unpadded))
+ count, err := base64.URLEncoding.WithPadding(base64.NoPadding).Decode(dec, src)
+ if count < 8 {
+ if err != nil {
+ return errors.New("Uid.UnmarshalText: failed to decode " + err.Error())
+ }
+ return errors.New("Uid.UnmarshalText: failed to decode")
+ }
+ *uid = Uid(binary.LittleEndian.Uint64(dec))
+ return nil
+}
+
+// MarshalText converts Uid to string represented as byte slice.
+func (uid *Uid) MarshalText() ([]byte, error) {
+ if *uid == ZeroUid {
+ return []byte{}, nil
+ }
+ src := make([]byte, 8)
+ dst := make([]byte, base64.URLEncoding.WithPadding(base64.NoPadding).EncodedLen(8))
+ binary.LittleEndian.PutUint64(src, uint64(*uid))
+ base64.URLEncoding.WithPadding(base64.NoPadding).Encode(dst, src)
+ return dst, nil
+}
+
+// MarshalJSON converts Uid to double quoted ("ajjj") string.
+func (uid *Uid) MarshalJSON() ([]byte, error) {
+ dst, _ := uid.MarshalText()
+ return append(append([]byte{'"'}, dst...), '"'), nil
+}
+
+// UnmarshalJSON reads Uid from a double quoted string.
+func (uid *Uid) UnmarshalJSON(b []byte) error {
+ size := len(b)
+ if size != (uidBase64Unpadded + 2) {
+ return errors.New("Uid.UnmarshalJSON: invalid length")
+ } else if b[0] != '"' || b[size-1] != '"' {
+ return errors.New("Uid.UnmarshalJSON: unrecognized")
+ }
+ return uid.UnmarshalText(b[1 : size-1])
+}
+
+// String converts Uid to base64 string.
+func (uid Uid) String() string {
+ buf, _ := uid.MarshalText()
+ return string(buf)
+}
+
+// String32 converts Uid to lowercase base32 string (suitable for file names on Windows).
+func (uid Uid) String32() string {
+ data, _ := uid.MarshalBinary()
+ return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(data))
+}
+
+// ParseUid parses string NOT prefixed with anything.
+func ParseUid(s string) Uid {
+ var uid Uid
+ uid.UnmarshalText([]byte(s))
+ return uid
+}
+
+// ParseUid32 parses base32-encoded string into Uid.
+func ParseUid32(s string) Uid {
+ var uid Uid
+ if data, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(s); err == nil {
+ uid.UnmarshalBinary(data)
+ }
+ return uid
+}
+
+// UserId converts Uid to string prefixed with 'usr', like usrXXXXX.
+func (uid Uid) UserId() string {
+ return uid.PrefixId("usr")
+}
+
+// FndName generates 'fnd' topic name for the given Uid.
+func (uid Uid) FndName() string {
+ return uid.PrefixId("fnd")
+}
+
+// SlfName generates 'slf' topic name for the given Uid.
+func (uid Uid) SlfName() string {
+ return uid.PrefixId("slf")
+}
+
+// PrefixId converts Uid to string prefixed with the given prefix.
+func (uid Uid) PrefixId(prefix string) string {
+ if uid.IsZero() {
+ return ""
+ }
+ return prefix + uid.String()
+}
+
+// ParseUserId parses user ID of the form "usrXXXXXX".
+func ParseUserId(s string) Uid {
+ var uid Uid
+ if strings.HasPrefix(s, "usr") {
+ (&uid).UnmarshalText([]byte(s)[3:])
+ }
+ return uid
+}
+
+// GrpToChn converts group topic name to corresponding channel name.
+// If it's a non-group channel topic, the name is returned unchanged.
+// If it's neither, an empty string is returned.
+func GrpToChn(grp string) string {
+ if strings.HasPrefix(grp, "grp") {
+ return strings.Replace(grp, "grp", "chn", 1)
+ }
+ // Return unchanged if it's a channel already.
+ if strings.HasPrefix(grp, "chn") {
+ return grp
+ }
+ return ""
+}
+
+// IsChannel checks if the given topic name is a reference to a channel.
+// The "nch" should not be considered a channel reference because it can only be used by the topic owner at the time of
+// group topic creation.
+func IsChannel(name string) bool {
+ return strings.HasPrefix(name, "chn")
+}
+
+// ChnToGrp gets group topic name from channel name.
+// If it's a non-channel group topic, the name is returned unchanged.
+// If it's neither, an empty string is returned.
+func ChnToGrp(chn string) string {
+ if strings.HasPrefix(chn, "chn") {
+ return strings.Replace(chn, "chn", "grp", 1)
+ }
+ // Return unchanged if it's a group already.
+ if strings.HasPrefix(chn, "grp") {
+ return chn
+ }
+ return ""
+}
+
+// UidSlice is a slice of Uids sorted in ascending order.
+type UidSlice []Uid
+
+func (us UidSlice) find(uid Uid) (int, bool) {
+ l := len(us)
+ if l == 0 || us[0] > uid {
+ return 0, false
+ }
+ if uid > us[l-1] {
+ return l, false
+ }
+ idx := sort.Search(l, func(i int) bool {
+ return uid <= us[i]
+ })
+ return idx, idx < l && us[idx] == uid
+}
+
+// Add uid to UidSlice keeping it sorted. Duplicates are ignored.
+func (us *UidSlice) Add(uid Uid) bool {
+ idx, found := us.find(uid)
+ if found {
+ return false
+ }
+ // Inserting without creating a temporary slice.
+ *us = append(*us, ZeroUid)
+ copy((*us)[idx+1:], (*us)[idx:])
+ (*us)[idx] = uid
+ return true
+}
+
+// Rem removes uid from UidSlice.
+func (us *UidSlice) Rem(uid Uid) bool {
+ idx, found := us.find(uid)
+ if !found {
+ return false
+ }
+ if idx == len(*us)-1 {
+ *us = (*us)[:idx]
+ } else {
+ *us = slices.Delete((*us), idx, idx+1)
+ }
+ return true
+}
+
+// Contains checks if the UidSlice contains the given UID.
+func (us UidSlice) Contains(uid Uid) bool {
+ _, contains := us.find(uid)
+ return contains
+}
+
+// P2PName takes two Uids and generates a P2P topic name.
+func (uid Uid) P2PName(u2 Uid) string {
+ if !uid.IsZero() && !u2.IsZero() {
+ b1, _ := uid.MarshalBinary()
+ b2, _ := u2.MarshalBinary()
+
+ if uid < u2 {
+ b1 = append(b1, b2...)
+ } else if uid > u2 {
+ b1 = append(b2, b1...)
+ } else {
+ // Explicitly disable P2P with self
+ return ""
+ }
+
+ return "p2p" + base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(b1)
+ }
+
+ return ""
+}
+
+// ParseP2P extracts uids from the name of a p2p topic.
+func ParseP2P(p2p string) (uid1, uid2 Uid, err error) {
+ if strings.HasPrefix(p2p, "p2p") {
+ src := []byte(p2p)[3:]
+ if len(src) != p2pBase64Unpadded {
+ err = errors.New("ParseP2P: invalid length")
+ return
+ }
+ dec := make([]byte, base64.URLEncoding.WithPadding(base64.NoPadding).DecodedLen(p2pBase64Unpadded))
+ var count int
+ count, err = base64.URLEncoding.WithPadding(base64.NoPadding).Decode(dec, src)
+ if count < 16 {
+ if err != nil {
+ err = errors.New("ParseP2P: failed to decode " + err.Error())
+ } else {
+ err = errors.New("ParseP2P: invalid decoded length")
+ }
+ return
+ }
+ uid1 = Uid(binary.LittleEndian.Uint64(dec))
+ uid2 = Uid(binary.LittleEndian.Uint64(dec[8:]))
+ } else {
+ err = errors.New("ParseP2P: missing or invalid prefix")
+ }
+ return
+}
+
+// P2PNameForUser takes a user ID and a full name of a P2P topic and generates the name of the
+// P2P topic as it should be seen by the given user.
+func P2PNameForUser(uid Uid, p2p string) (string, error) {
+ uid1, uid2, err := ParseP2P(p2p)
+ if err != nil {
+ return "", err
+ }
+ if uid.Compare(uid1) == 0 {
+ return uid2.UserId(), nil
+ }
+ return uid1.UserId(), nil
+}
+
+// ObjHeader is the header shared by all stored objects.
+type ObjHeader struct {
+ // using string to get around rethinkdb's problems with uint64;
+ // `bson:"_id"` tag is for mongodb to use as primary key '_id'.
+ Id string `bson:"_id"`
+ id Uid
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+// Uid assigns Uid header field.
+func (h *ObjHeader) Uid() Uid {
+ if h.id.IsZero() && h.Id != "" {
+ h.id.UnmarshalText([]byte(h.Id))
+ }
+ return h.id
+}
+
+// SetUid assigns given Uid to appropriate header fields.
+func (h *ObjHeader) SetUid(uid Uid) {
+ h.id = uid
+ h.Id = uid.String()
+}
+
+// TimeNow returns current wall time in UTC rounded to milliseconds.
+func TimeNow() time.Time {
+ return time.Now().UTC().Round(time.Millisecond)
+}
+
+// TimeFormatRFC3339 is a format string for writing timestamps as RFC3339.
+const TimeFormatRFC3339 = "2006-01-02T15:04:05.999"
+
+// InitTimes initializes time.Time variables in the header to current time.
+func (h *ObjHeader) InitTimes() {
+ if h.CreatedAt.IsZero() {
+ h.CreatedAt = TimeNow()
+ }
+ h.UpdatedAt = h.CreatedAt
+}
+
+// MergeTimes intelligently copies time.Time variables from h2 to h.
+func (h *ObjHeader) MergeTimes(h2 *ObjHeader) {
+ // Set the creation time to the earliest value
+ if h.CreatedAt.IsZero() || (!h2.CreatedAt.IsZero() && h2.CreatedAt.Before(h.CreatedAt)) {
+ h.CreatedAt = h2.CreatedAt
+ }
+ // Set the update time to the latest value
+ if h.UpdatedAt.Before(h2.UpdatedAt) {
+ h.UpdatedAt = h2.UpdatedAt
+ }
+}
+
+// StringSlice is defined so Scanner and Valuer can be attached to it.
+type StringSlice []string
+
+// Scan implements sql.Scanner interface.
+func (ss *StringSlice) Scan(val any) error {
+ if val == nil {
+ return nil
+ }
+ return json.Unmarshal(val.([]byte), ss)
+}
+
+// Value implements sql/driver.Valuer interface.
+func (ss StringSlice) Value() (driver.Value, error) {
+ return json.Marshal(ss)
+}
+
+// ObjState represents information on objects state,
+// such as an indication that User or Topic is suspended/soft-deleted.
+type ObjState int
+
+const (
+ // StateOK indicates normal user or topic.
+ StateOK ObjState = 0
+ // StateSuspended indicates suspended user or topic.
+ StateSuspended ObjState = 10
+ // StateDeleted indicates soft-deleted user or topic.
+ StateDeleted ObjState = 20
+ // StateUndefined indicates state which has not been set explicitly.
+ StateUndefined ObjState = 30
+)
+
+// String returns string representation of ObjState.
+func (os ObjState) String() string {
+ switch os {
+ case StateOK:
+ return "ok"
+ case StateSuspended:
+ return "susp"
+ case StateDeleted:
+ return "del"
+ case StateUndefined:
+ return "undef"
+ }
+ return ""
+}
+
+// NewObjState parses string into an ObjState.
+func NewObjState(in string) (ObjState, error) {
+ in = strings.ToLower(in)
+ switch in {
+ case "", "ok":
+ return StateOK, nil
+ case "susp":
+ return StateSuspended, nil
+ case "del":
+ return StateDeleted, nil
+ case "undef":
+ return StateUndefined, nil
+ }
+ // This is the default.
+ return StateOK, errors.New("failed to parse object state")
+}
+
+// MarshalJSON converts ObjState to a quoted string.
+func (os ObjState) MarshalJSON() ([]byte, error) {
+ return append(append([]byte{'"'}, []byte(os.String())...), '"'), nil
+}
+
+// UnmarshalJSON reads ObjState from a quoted string.
+func (os *ObjState) UnmarshalJSON(b []byte) error {
+ if b[0] != '"' || b[len(b)-1] != '"' {
+ return errors.New("syntax error")
+ }
+ state, err := NewObjState(string(b[1 : len(b)-1]))
+ if err == nil {
+ *os = state
+ }
+ return err
+}
+
+// Scan is an implementation of sql.Scanner interface. It expects the
+// value to be a byte slice representation of an ASCII string.
+func (os *ObjState) Scan(val any) error {
+ switch intval := val.(type) {
+ case int64:
+ *os = ObjState(intval)
+ return nil
+ }
+ return errors.New("data is not an int64")
+}
+
+// Value is an implementation of sql.driver.Valuer interface.
+func (os ObjState) Value() (driver.Value, error) {
+ return int64(os), nil
+}
+
+// User is a representation of a DB-stored user record.
+type User struct {
+ ObjHeader `bson:",inline"`
+
+ // TenantID identifies the tenant which owns this user.
+ TenantID TenantID `db:"tenant_id" json:"-" bson:"tenant_id"`
+
+ State ObjState
+ StateAt *time.Time `json:"StateAt,omitempty" bson:",omitempty"`
+
+ // Default access to user for P2P topics (used as default modeGiven)
+ Access DefaultAccess
+
+ // Values for 'me' topic:
+
+ // Last time when the user joined 'me' topic, by User Agent
+ LastSeen *time.Time
+ // User agent provided when accessing the topic last time
+ UserAgent string
+
+ Public any
+ Trusted any
+
+ // Unique indexed tags (email, phone) for finding this user. Stored on the
+ // 'users' as well as indexed in 'tagunique'
+ Tags StringSlice
+
+ // Info on known devices, used for push notifications
+ Devices map[string]*DeviceDef `bson:"__devices,skip,omitempty"`
+ // Same for mongodb scheme. Ignore in other db backends if its not suitable.
+ DeviceArray []*DeviceDef `json:"-" bson:"devices"`
+}
+
+// AccessMode is a definition of access mode bits.
+type AccessMode uint
+
+// Various access mode constants.
+const (
+ ModeJoin AccessMode = 1 << iota // user can join, i.e. {sub} (J:1)
+ ModeRead // user can receive broadcasts ({data}, {info}) (R:2)
+ ModeWrite // user can Write, i.e. {pub} (W:4)
+ ModePres // user can receive presence updates (P:8)
+ ModeApprove // user can approve new members or evict existing members (A:0x10, 16)
+ ModeShare // user can invite new members (S:0x20, 32)
+ ModeDelete // user can hard-delete messages (D:0x40, 64)
+ ModeOwner // user is the owner (O:0x80, 128) - full access
+ ModeUnset // Non-zero value to indicate unknown or undefined mode (:0x100, 256), to make it different from ModeNone.
+
+ ModeNone AccessMode = 0 // No access, requests to gain access are processed normally (N:0)
+
+ // Normal user's access to a topic ("JRWPS", 47, 0x2F).
+ ModeCPublic AccessMode = ModeJoin | ModeRead | ModeWrite | ModePres | ModeShare
+ // User's subscription to 'me' and 'fnd' ("JPS", 41, 0x29).
+ ModeCMeFnd AccessMode = ModeJoin | ModePres | ModeShare
+ // User's subscription to 'slf' topic ("JRWDO", 199, 0xC7).
+ ModeCSelf = ModeJoin | ModeRead | ModeWrite | ModeDelete | ModeOwner
+ // Owner's subscription to a generic topic ("JRWPASDO", 255, 0xFF).
+ ModeCFull AccessMode = ModeJoin | ModeRead | ModeWrite | ModePres | ModeApprove | ModeShare | ModeDelete | ModeOwner
+ // Default P2P access mode ("JRWPA", 31, 0x1F).
+ ModeCP2P AccessMode = ModeJoin | ModeRead | ModeWrite | ModePres | ModeApprove
+ // P2P acess mode when hard-deleting messages is enabled ("JRWPAD", 95, 0x5F)
+ ModeCP2PD AccessMode = ModeJoin | ModeRead | ModeWrite | ModePres | ModeApprove | ModeDelete
+ // Default Auth access mode for a user ("JRWPAS", 63, 0x3F).
+ ModeCAuth AccessMode = ModeCP2P | ModeCPublic
+ // Read-only access to topic ("JR", 3).
+ ModeCReadOnly = ModeJoin | ModeRead
+ // Access to 'sys' topic by a root user ("JRWPD", 79, 0x4F).
+ ModeCSys = ModeJoin | ModeRead | ModeWrite | ModePres | ModeDelete
+ // Channel publisher: person authorized to publish content; no J: by invitation only ("RWPD", 78, 0x4E).
+ ModeCChnWriter = ModeRead | ModeWrite | ModePres | ModeShare
+ // Reader's access mode to a channel (JRP, 11, 0xB).
+ ModeCChnReader = ModeJoin | ModeRead | ModePres
+
+ // Admin: user who can modify access mode ("OA", dec: 144, hex: 0x90).
+ ModeCAdmin = ModeOwner | ModeApprove
+ // Sharer: flags which define user who can be notified of access mode changes ("OAS", dec: 176, hex: 0xB0).
+ ModeCSharer = ModeCAdmin | ModeShare
+
+ // Invalid mode to indicate an error.
+ ModeInvalid AccessMode = 0x100000
+
+ // All possible valid bits (excluding ModeInvalid and ModeUnset) = 0xFF, 255.
+ ModeBitmask AccessMode = ModeJoin | ModeRead | ModeWrite | ModePres | ModeApprove | ModeShare | ModeDelete | ModeOwner
+)
+
+// MarshalText converts AccessMode to ASCII byte slice.
+func (m AccessMode) MarshalText() ([]byte, error) {
+ if m == ModeNone {
+ return []byte{'N'}, nil
+ }
+
+ if m == ModeInvalid {
+ return nil, errors.New("AccessMode invalid")
+ }
+
+ res := []byte{}
+ modes := []byte{'J', 'R', 'W', 'P', 'A', 'S', 'D', 'O'}
+ for i, chr := range modes {
+ if (m & (1 << uint(i))) != 0 {
+ res = append(res, chr)
+ }
+ }
+ return res, nil
+}
+
+// ParseAcs parses AccessMode from a byte array.
+func ParseAcs(b []byte) (AccessMode, error) {
+ m0 := ModeUnset
+
+Loop:
+ for i := range b {
+ switch b[i] {
+ case 'J', 'j':
+ m0 |= ModeJoin
+ case 'R', 'r':
+ m0 |= ModeRead
+ case 'W', 'w':
+ m0 |= ModeWrite
+ case 'A', 'a':
+ m0 |= ModeApprove
+ case 'S', 's':
+ m0 |= ModeShare
+ case 'D', 'd':
+ m0 |= ModeDelete
+ case 'P', 'p':
+ m0 |= ModePres
+ case 'O', 'o':
+ m0 |= ModeOwner
+ case 'N', 'n':
+ if m0 != ModeUnset {
+ return ModeUnset, errors.New("AccessMode: access N cannot be combined with any other")
+ }
+ m0 = ModeNone // N means explicitly no access, all bits cleared
+ break Loop
+ default:
+ return ModeUnset, errors.New("AccessMode: invalid character '" + string(b[i]) + "'")
+ }
+ }
+
+ return m0, nil
+}
+
+// UnmarshalText parses access mode string as byte slice.
+// Does not change the mode if the string is empty or invalid.
+func (m *AccessMode) UnmarshalText(b []byte) error {
+ m0, err := ParseAcs(b)
+ if err != nil {
+ return err
+ }
+
+ if m0 != ModeUnset {
+ *m = (m0 & ModeBitmask)
+ }
+ return nil
+}
+
+// String returns string representation of AccessMode.
+func (m AccessMode) String() string {
+ res, err := m.MarshalText()
+ if err != nil {
+ return ""
+ }
+ return string(res)
+}
+
+// MarshalJSON converts AccessMode to a quoted string.
+func (m AccessMode) MarshalJSON() ([]byte, error) {
+ res, err := m.MarshalText()
+ if err != nil {
+ return nil, err
+ }
+
+ return append(append([]byte{'"'}, res...), '"'), nil
+}
+
+// UnmarshalJSON reads AccessMode from a quoted string.
+func (m *AccessMode) UnmarshalJSON(b []byte) error {
+ if b[0] != '"' || b[len(b)-1] != '"' {
+ return errors.New("syntax error")
+ }
+
+ return m.UnmarshalText(b[1 : len(b)-1])
+}
+
+// Scan is an implementation of sql.Scanner interface. It expects the
+// value to be a byte slice representation of an ASCII string.
+func (m *AccessMode) Scan(val any) error {
+ if bb, ok := val.([]byte); ok {
+ return m.UnmarshalText(bb)
+ }
+ return errors.New("scan failed: data is not a byte slice")
+}
+
+// Value is an implementation of sql.driver.Valuer interface.
+func (m AccessMode) Value() (driver.Value, error) {
+ res, err := m.MarshalText()
+ if err != nil {
+ return "", err
+ }
+ return string(res), nil
+}
+
+// BetterThan checks if grant mode allows more permissions than requested in want mode.
+func (grant AccessMode) BetterThan(want AccessMode) bool {
+ return ModeBitmask&grant&^want != 0
+}
+
+// BetterEqual checks if grant mode allows all permissions requested in want mode.
+func (grant AccessMode) BetterEqual(want AccessMode) bool {
+ return ModeBitmask&grant&want == want
+}
+
+// Delta between two modes as a string old.Delta(new). JRPAS -> JRWS: "+W-PA"
+// Zero delta is an empty string ""
+func (o AccessMode) Delta(n AccessMode) string {
+ // Removed bits, bits present in 'old' but missing in 'new' -> '-'
+ var removed string
+ if o2n := ModeBitmask & o &^ n; o2n > 0 {
+ removed = o2n.String()
+ if removed != "" {
+ removed = "-" + removed
+ }
+ }
+
+ // Added bits, bits present in 'n' but missing in 'o' -> '+'
+ var added string
+ if n2o := ModeBitmask & n &^ o; n2o > 0 {
+ added = n2o.String()
+ if added != "" {
+ added = "+" + added
+ }
+ }
+ return added + removed
+}
+
+// ApplyMutation sets of modifies access mode:
+// * if `mutation` contains either '+' or '-', attempts to apply a delta change on `m`.
+// * otherwise, treats it as an assignment.
+func (m *AccessMode) ApplyMutation(mutation string) error {
+ if mutation == "" {
+ return nil
+ }
+ if strings.ContainsAny(mutation, "+-") {
+ return m.ApplyDelta(mutation)
+ }
+ return m.UnmarshalText([]byte(mutation))
+}
+
+// ApplyDelta applies the acs delta to AccessMode.
+// Delta is in the same format as generated by AccessMode.Delta.
+// E.g. JPRA.ApplyDelta(-PR+W) -> JWA.
+func (m *AccessMode) ApplyDelta(delta string) error {
+ if delta == "" || delta == "N" {
+ // No updates.
+ return nil
+ }
+ m0 := *m
+ for next := 0; next+1 < len(delta) && next >= 0; {
+ ch := delta[next]
+ end := strings.IndexAny(delta[next+1:], "+-")
+ var chunk string
+ if end >= 0 {
+ end += next + 1
+ chunk = delta[next+1 : end]
+ } else {
+ chunk = delta[next+1:]
+ }
+ next = end
+ upd, err := ParseAcs([]byte(chunk))
+ if err != nil {
+ return err
+ }
+ switch ch {
+ case '+':
+ if upd != ModeUnset {
+ m0 |= upd & ModeBitmask
+ }
+ case '-':
+ if upd != ModeUnset {
+ m0 &^= upd & ModeBitmask
+ }
+ default:
+ return errors.New("Invalid acs delta string: '" + delta + "'")
+ }
+ }
+ *m = m0
+ return nil
+}
+
+// IsJoiner checks if joiner flag J is set.
+func (m AccessMode) IsJoiner() bool {
+ return m&ModeJoin != 0
+}
+
+// IsOwner checks if owner bit O is set.
+func (m AccessMode) IsOwner() bool {
+ return m&ModeOwner != 0
+}
+
+// IsApprover checks if approver A bit is set.
+func (m AccessMode) IsApprover() bool {
+ return m&ModeApprove != 0
+}
+
+// IsAdmin check if owner O or approver A flag is set.
+func (m AccessMode) IsAdmin() bool {
+ return m.IsOwner() || m.IsApprover()
+}
+
+// IsSharer checks if approver A or sharer S or owner O flag is set.
+func (m AccessMode) IsSharer() bool {
+ return m.IsAdmin() || (m&ModeShare != 0)
+}
+
+// IsWriter checks if allowed to publish (writer flag W is set).
+func (m AccessMode) IsWriter() bool {
+ return m&ModeWrite != 0
+}
+
+// IsReader checks if reader flag R is set.
+func (m AccessMode) IsReader() bool {
+ return m&ModeRead != 0
+}
+
+// IsPresencer checks if user receives presence updates (P flag set).
+func (m AccessMode) IsPresencer() bool {
+ return m&ModePres != 0
+}
+
+// IsDeleter checks if user can hard-delete messages (D flag is set).
+func (m AccessMode) IsDeleter() bool {
+ return m&ModeDelete != 0
+}
+
+// IsZero checks if no flags are set.
+func (m AccessMode) IsZero() bool {
+ return m == ModeNone
+}
+
+// IsInvalid checks if mode is invalid.
+func (m AccessMode) IsInvalid() bool {
+ return m == ModeInvalid
+}
+
+// IsDefined checks if the mode is defined: not invalid and not unset.
+// ModeNone is considered to be defined.
+func (m AccessMode) IsDefined() bool {
+ return m != ModeInvalid && m != ModeUnset
+}
+
+// DefaultAccess is a per-topic default access modes
+type DefaultAccess struct {
+ Auth AccessMode
+ Anon AccessMode
+}
+
+// Scan is an implementation of Scanner interface so the value can be read from SQL DBs
+// It assumes the value is serialized and stored as JSON
+func (da *DefaultAccess) Scan(val any) error {
+ return json.Unmarshal(val.([]byte), da)
+}
+
+// Value implements sql's driver.Valuer interface.
+func (da DefaultAccess) Value() (driver.Value, error) {
+ return json.Marshal(da)
+}
+
+// Credential hold data needed to validate and check validity of a credential like email or phone.
+type Credential struct {
+ ObjHeader `bson:",inline"`
+ // Credential owner
+ User string
+ // Verification method (email, tel, captcha, etc)
+ Method string
+ // Credential value - `jdoe@example.com` or `+12345678901`
+ Value string
+ // Expected response
+ Resp string
+ // If credential was successfully confirmed
+ Done bool
+ // Retry count
+ Retries int
+}
+
+// LastSeenUA is a timestamp and a user agent of when the user was last seen.
+type LastSeenUA struct {
+ // When is the timestamp when the user was last online.
+ When time.Time
+ // UserAgent is the client UA of the last online access.
+ UserAgent string
+}
+
+// Subscription to a topic
+type Subscription struct {
+ ObjHeader `bson:",inline"`
+ // TenantID identifies the tenant which owns this subscription.
+ TenantID TenantID `db:"tenant_id" json:"-" bson:"tenant_id"`
+ // User who has relationship with the topic
+ User string
+ // Topic subscribed to
+ Topic string
+ DeletedAt *time.Time `bson:",omitempty"`
+
+ // Values persisted through subscription soft-deletion
+
+ // ID of the latest Soft-delete operation
+ DelId int
+ // Last SeqId reported by user as received by at least one of his sessions
+ RecvSeqId int
+ // Last SeqID reported read by the user
+ ReadSeqId int
+
+ // Access mode requested by this user
+ ModeWant AccessMode
+ // Access mode granted to this user
+ ModeGiven AccessMode
+ // User's private data associated with the subscription to topic
+ Private any
+
+ // Deserialized ephemeral values
+
+ // Deserialized public value from topic or user (depends on context)
+ // In case of P2P topics this is the Public value of the other user.
+ public any
+ // In case of P2P topics this is the Trusted value of the other user.
+ trusted any
+ // deserialized SeqID from user or topic
+ seqId int
+ // Deserialized TouchedAt from topic
+ touchedAt time.Time
+ // Timestamp & user agent of when the user was last online.
+ lastSeenUA *LastSeenUA
+
+ // Count of subscribers.
+ subCnt int
+
+ // P2P only. ID of the other user
+ with string
+ // P2P only. Default access: this is the mode given by the other user to this user
+ modeDefault *DefaultAccess
+
+ // Topic's or user's state.
+ state ObjState
+
+ // This is not a fully initialized subscription object
+ dummy bool
+}
+
+// SetPublic assigns a value to `public`, otherwise not accessible from outside the package.
+func (s *Subscription) SetPublic(pub any) {
+ s.public = pub
+}
+
+// GetPublic reads value of `public`.
+func (s *Subscription) GetPublic() any {
+ return s.public
+}
+
+// SetTrusted assigns a value to `trusted`, otherwise not accessible from outside the package.
+func (s *Subscription) SetTrusted(tstd any) {
+ s.trusted = tstd
+}
+
+// GetTrusted reads value of `trusted`.
+func (s *Subscription) GetTrusted() any {
+ return s.trusted
+}
+
+// SetWith sets other user for P2P subscriptions.
+func (s *Subscription) SetWith(with string) {
+ s.with = with
+}
+
+// GetWith returns the other user for P2P subscriptions.
+func (s *Subscription) GetWith() string {
+ return s.with
+}
+
+// GetTouchedAt returns touchedAt.
+func (s *Subscription) GetTouchedAt() time.Time {
+ return s.touchedAt
+}
+
+// SetTouchedAt sets the value of touchedAt.
+func (s *Subscription) SetTouchedAt(touchedAt time.Time) {
+ if touchedAt.After(s.touchedAt) {
+ s.touchedAt = touchedAt
+ }
+}
+
+// LastModified returns the greater of either TouchedAt or UpdatedAt.
+func (s *Subscription) LastModified() time.Time {
+ if s.UpdatedAt.Before(s.touchedAt) {
+ return s.touchedAt
+ }
+ return s.UpdatedAt
+}
+
+// GetSeqId returns seqId.
+func (s *Subscription) GetSeqId() int {
+ return s.seqId
+}
+
+// SetSeqId sets seqId field.
+func (s *Subscription) SetSeqId(id int) {
+ s.seqId = id
+}
+
+// GetSubCnt returns subCnt (subscriber count).
+func (s *Subscription) GetSubCnt() int {
+ return s.subCnt
+}
+
+// SetSubCnt sets subCnt (subscriber count).
+func (s *Subscription) SetSubCnt(cnt int) {
+ s.subCnt = cnt
+}
+
+// GetLastSeen returns lastSeen.
+func (s *Subscription) GetLastSeen() *time.Time {
+ if s.lastSeenUA != nil {
+ return &s.lastSeenUA.When
+ }
+ return nil
+}
+
+// GetUserAgent returns userAgent.
+func (s *Subscription) GetUserAgent() string {
+ if s.lastSeenUA != nil {
+ return s.lastSeenUA.UserAgent
+ }
+ return ""
+}
+
+// SetLastSeenAndUA updates lastSeen time and userAgent.
+func (s *Subscription) SetLastSeenAndUA(when *time.Time, ua string) {
+ if when != nil && !when.IsZero() {
+ s.lastSeenUA = &LastSeenUA{
+ When: *when,
+ UserAgent: ua,
+ }
+ } else {
+ s.lastSeenUA = nil
+ }
+}
+
+// SetDefaultAccess updates default access values.
+func (s *Subscription) SetDefaultAccess(auth, anon AccessMode) {
+ s.modeDefault = &DefaultAccess{auth, anon}
+}
+
+// GetDefaultAccess returns default access.
+func (s *Subscription) GetDefaultAccess() *DefaultAccess {
+ return s.modeDefault
+}
+
+// GetState returns topic's or user's state.
+func (s *Subscription) GetState() ObjState {
+ return s.state
+}
+
+// SetState assigns topic's or user's state.
+func (s *Subscription) SetState(state ObjState) {
+ s.state = state
+}
+
+// SetDummy marks this subscription object as only partially intialized.
+func (s *Subscription) SetDummy(dummy bool) {
+ s.dummy = dummy
+}
+
+// IsDummy is true if this subscription object as only partially intialized.
+func (s *Subscription) IsDummy() bool {
+ return s.dummy
+}
+
+// Contact is a result of a search for connections
+type Contact struct {
+ Id string
+ MatchOn []string
+ Access DefaultAccess
+ LastSeen time.Time
+ Public any
+}
+
+type perUserData struct {
+ private any
+ want AccessMode
+ given AccessMode
+}
+
+// MessageHeaders is needed to attach Scan() to.
+type KVMap map[string]any
+
+// Scan implements sql.Scanner interface.
+func (kvm *KVMap) Scan(val any) error {
+ if val == nil {
+ kvm = nil
+ return nil
+ }
+ return json.Unmarshal(val.([]byte), kvm)
+}
+
+// Value implements sql's driver.Valuer interface.
+func (kvm KVMap) Value() (driver.Value, error) {
+ return json.Marshal(kvm)
+}
+
+// Topic stored in database. Topic's name is Id
+type Topic struct {
+ ObjHeader `bson:",inline"`
+ TenantID TenantID `db:"tenant_id" json:"-" bson:"tenant_id"`
+
+ // State of the topic: normal (ok), suspended, deleted
+ State ObjState
+ StateAt *time.Time `json:"StateAt,omitempty" bson:",omitempty"`
+
+ // Timestamp when the last message has passed through the topic
+ TouchedAt time.Time
+
+ // Indicates that the topic is a channel.
+ UseBt bool
+
+ // Topic owner. Could be zero
+ Owner string
+
+ // Default access to topic
+ Access DefaultAccess
+
+ // Server-issued sequential ID
+ SeqId int
+ // If messages were deleted, sequential id of the last operation to delete them
+ DelId int
+
+ // Count of topic subscribers.
+ SubCnt int
+
+ Public any
+ Trusted any
+
+ // Indexed tags for finding this topic.
+ Tags StringSlice
+
+ // Auxiliary set of key-value pairs.
+ Aux KVMap `json:"Aux,omitempty" bson:",omitempty"`
+
+ // Deserialized ephemeral params
+ perUser map[Uid]*perUserData // deserialized from Subscription
+}
+
+// GiveAccess updates access mode for the given user.
+func (t *Topic) GiveAccess(uid Uid, want, given AccessMode) {
+ if t.perUser == nil {
+ t.perUser = make(map[Uid]*perUserData, 1)
+ }
+
+ pud := t.perUser[uid]
+ if pud == nil {
+ pud = &perUserData{}
+ }
+
+ pud.want = want
+ pud.given = given
+
+ t.perUser[uid] = pud
+ if want&given&ModeOwner != 0 && t.Owner == "" {
+ t.Owner = uid.String()
+ }
+}
+
+// SetPrivate updates private value for the given user.
+func (t *Topic) SetPrivate(uid Uid, private any) {
+ if t.perUser == nil {
+ t.perUser = make(map[Uid]*perUserData, 1)
+ }
+ pud := t.perUser[uid]
+ if pud == nil {
+ pud = &perUserData{}
+ }
+ pud.private = private
+ t.perUser[uid] = pud
+}
+
+// GetPrivate returns given user's private value.
+func (t *Topic) GetPrivate(uid Uid) (private any) {
+ if t.perUser == nil {
+ return
+ }
+ pud := t.perUser[uid]
+ if pud == nil {
+ return
+ }
+ private = pud.private
+ return
+}
+
+// GetAccess returns given user's access mode.
+func (t *Topic) GetAccess(uid Uid) (mode AccessMode) {
+ if t.perUser == nil {
+ return
+ }
+ pud := t.perUser[uid]
+ if pud == nil {
+ return
+ }
+ mode = pud.given & pud.want
+ return
+}
+
+// SoftDelete is a single DB record of soft-deletetion.
+type SoftDelete struct {
+ User string
+ DelId int
+}
+
+// Message is a stored {data} message
+type Message struct {
+ ObjHeader `bson:",inline"`
+ TenantID TenantID `db:"tenant_id" json:"-" bson:"tenant_id"`
+ DeletedAt *time.Time `json:"DeletedAt,omitempty" bson:",omitempty"`
+
+ // ID of the hard-delete operation
+ DelId int `json:"DelId,omitempty" bson:",omitempty"`
+ // List of users who have marked this message as soft-deleted
+ DeletedFor []SoftDelete `json:"DeletedFor,omitempty" bson:",omitempty"`
+ SeqId int
+ Topic string
+ // Sender's user ID as string (without 'usr' prefix), could be empty.
+ From string
+ Head KVMap `json:"Head,omitempty" bson:",omitempty"`
+ Content any
+}
+
+// Range is a range of message SeqIDs. Low end is inclusive (closed), high end is exclusive (open): [Low, Hi).
+// If the range contains just one ID, Hi is set to 0
+type Range struct {
+ Low int
+ Hi int `json:"Hi,omitempty" bson:",omitempty"`
+}
+
+// RangeSorter is a helper type required by 'sort' package.
+type RangeSorter []Range
+
+// Len is the length of the range.
+func (rs RangeSorter) Len() int {
+ return len(rs)
+}
+
+// Swap swaps two items in a slice.
+func (rs RangeSorter) Swap(i, j int) {
+ rs[i], rs[j] = rs[j], rs[i]
+}
+
+// Less is a comparator. Sort by Low ascending, then sort by Hi descending
+func (rs RangeSorter) Less(i, j int) bool {
+ if rs[i].Low < rs[j].Low {
+ return true
+ }
+ if rs[i].Low == rs[j].Low {
+ return rs[i].Hi >= rs[j].Hi
+ }
+ return false
+}
+
+// Normalize ranges - remove overlaps: [1..4],[2..4],[5..7] -> [1..7].
+// The ranges are expected to be sorted.
+// Ranges are inclusive-inclusive, i.e. [1..3] -> 1, 2, 3.
+func (rs RangeSorter) Normalize() RangeSorter {
+ if ll := rs.Len(); ll > 1 {
+ prev := 0
+ for i := 1; i < ll; i++ {
+ if rs[prev].Low == rs[i].Low {
+ // Earlier range is guaranteed to be wider or equal to the later range,
+ // collapse two ranges into one (by doing nothing)
+ continue
+ }
+ // Check for full or partial overlap
+ if rs[prev].Hi > 0 && rs[prev].Hi+1 >= rs[i].Low {
+ // Partial overlap
+ if rs[prev].Hi < rs[i].Hi {
+ rs[prev].Hi = rs[i].Hi
+ }
+ // Otherwise the next range is fully within the previous range, consume it by doing nothing.
+ continue
+ }
+ // No overlap
+ prev++
+ }
+ rs = rs[:prev+1]
+ }
+
+ return rs
+}
+
+// Convert a slice of int values to a slice of ranges.
+// The int slice must be sorted low -> high.
+func SliceToRanges(in []int) []Range {
+ if len(in) == 0 {
+ return nil
+ }
+
+ var out []Range
+ for _, id := range in {
+ size := len(out)
+
+ if size == 0 {
+ out = append(out, Range{Low: id})
+ continue
+ }
+
+ prev := &out[size-1]
+ if (prev.Hi == 0 && (id != prev.Low+1)) || (id > prev.Hi) {
+ // New range.
+ out = append(out, Range{Low: id})
+ } else {
+ // Expand existing range.
+ prev.Hi = id + 1
+ }
+ }
+ return out
+}
+
+// DelMessage is a log entry of a deleted message range.
+type DelMessage struct {
+ ObjHeader `bson:",inline"`
+ TenantID TenantID `db:"tenant_id" json:"-" bson:"tenant_id"`
+ Topic string
+ DeletedFor string
+ DelId int
+ SeqIdRanges []Range
+
+ // Delete messages newer than this value. Not serialized.
+ newerThan *time.Time
+}
+
+// GetNewerThan returns a newerThan delete query parameter.
+func (dm *DelMessage) GetNewerThan() *time.Time {
+ return dm.newerThan
+}
+
+// SetNewerThan sets a newerThan delete query parameter.
+func (dm *DelMessage) SetNewerThan(t time.Time) {
+ dm.newerThan = &t
+}
+
+// QueryOpt is options of a query, [since, before] - both ends inclusive (closed)
+type QueryOpt struct {
+ // Subscription query
+ User Uid
+ Topic string
+ IfModifiedSince *time.Time
+ // ID-based query parameters: Messages
+ Since int
+ Before int
+ // Common parameter
+ Limit int
+ // Ranges of IDs.
+ IdRanges []Range
+}
+
+// TopicCat is an enum of topic categories.
+type TopicCat int
+
+const (
+ // TopicCatMe is a value denoting 'me' topic.
+ TopicCatMe TopicCat = iota
+ // TopicCatFnd is a value denoting 'fnd' topic.
+ TopicCatFnd
+ // TopicCatP2P is a value denoting 'p2p topic.
+ TopicCatP2P
+ // TopicCatGrp is a value denoting group topic.
+ TopicCatGrp
+ // TopicCatSys is a constant indicating a system topic.
+ TopicCatSys
+ // TopicCatSlf si a constant indicating a 'self' topic, i.e. topic for saved messages and notes.
+ TopicCatSlf
+)
+
+// GetTopicCat given topic name returns topic category.
+func GetTopicCat(name string) TopicCat {
+ switch name[:3] {
+ case "usr":
+ return TopicCatMe
+ case "p2p":
+ return TopicCatP2P
+ case "grp", "chn":
+ return TopicCatGrp
+ case "fnd":
+ return TopicCatFnd
+ case "sys":
+ return TopicCatSys
+ case "slf":
+ return TopicCatSlf
+ default:
+ panic("invalid topic type for name '" + name + "'")
+ }
+}
+
+// IsEphemeralTopic checks if the topic is ephemeral, i.e. it's a reference to the user,
+// it's not stored in the 'topics' table like 'me' or 'fnd' topics.
+func IsEphemeralTopic(topic string) bool {
+ cat := GetTopicCat(topic)
+ return cat == TopicCatMe || cat == TopicCatFnd
+}
+
+// DeviceDef is the data provided by connected device. Used primarily for
+// push notifications.
+type DeviceDef struct {
+ TenantID TenantID `db:"tenant_id" json:"-" bson:"tenant_id"`
+ // Device registration ID
+ DeviceId string
+ // Device platform (iOS, Android, Web)
+ Platform string
+ // Last logged in
+ LastSeen time.Time
+ // Device language, ISO code
+ Lang string
+}
+
+// Media handling constants
+const (
+ // UploadStarted indicates that the upload has started but not finished yet.
+ UploadStarted = iota
+ // UploadCompleted indicates that the upload has completed successfully.
+ UploadCompleted
+ // UploadFailed indicates that the upload has failed.
+ UploadFailed
+ // UploadDeleted indicates that the upload is no longer needed and can be deleted.
+ UploadDeleted
+)
+
+// FileDef is a stored record of a file upload
+type FileDef struct {
+ ObjHeader `bson:",inline"`
+ TenantID TenantID `db:"tenant_id" json:"-" bson:"tenant_id"`
+ // Status of upload
+ Status int
+ // User who created the file
+ User string
+ // Type of the file.
+ MimeType string
+ // Size of the file in bytes.
+ Size int64
+ // Internal file location, i.e. path on disk or an S3 blob address.
+ Location string
+ // ETag generated by the file server.
+ ETag string
+}
+
+// FlattenDoubleSlice turns 2d slice into a 1d slice.
+func FlattenDoubleSlice(data [][]string) []string {
+ var result []string
+ for _, el := range data {
+ result = append(result, el...)
+ }
+ return result
+}
diff --git a/server/store/types/uidgen_test.go b/server/store/types/uidgen_test.go
index e69de29..979a2eb 100644
--- a/server/store/types/uidgen_test.go
+++ b/server/store/types/uidgen_test.go
@@ -0,0 +1,683 @@
+package types
+
+import (
+ "encoding/base64"
+ "testing"
+ "time"
+)
+
+func TestUidGeneratorInit(t *testing.T) {
+ ug := &UidGenerator{}
+ key := []byte("testkey1testkey2") // 16 bytes for XTEA
+
+ // Test successful initialization
+ err := ug.Init(1, key)
+ if err != nil {
+ t.Fatalf("Expected no error, got %v", err)
+ }
+
+ if ug.seq == nil {
+ t.Error("Snowflake generator should be initialized")
+ }
+ if ug.cipher == nil {
+ t.Error("Cipher should be initialized")
+ }
+
+ // Test initialization with different worker ID
+ ug2 := &UidGenerator{}
+ err = ug2.Init(2, key)
+ if err != nil {
+ t.Fatalf("Expected no error, got %v", err)
+ }
+
+ // Test that already initialized generator doesn't reinitialize
+ oldSeq := ug.seq
+ oldCipher := ug.cipher
+ err = ug.Init(3, key)
+ if err != nil {
+ t.Fatalf("Expected no error, got %v", err)
+ }
+ if ug.seq != oldSeq {
+ t.Error("Snowflake generator should not be reinitialized")
+ }
+ if ug.cipher != oldCipher {
+ t.Error("Cipher should not be reinitialized")
+ }
+}
+
+func TestUidGeneratorInitWithInvalidKey(t *testing.T) {
+ ug := &UidGenerator{}
+
+ // Test with key that's too short (XTEA requires 16 bytes)
+ shortKey := []byte("short")
+ err := ug.Init(1, shortKey)
+ if err == nil {
+ t.Error("Expected error with short key")
+ }
+
+ // Test with nil key
+ err = ug.Init(1, nil)
+ if err == nil {
+ t.Error("Expected error with nil key")
+ }
+}
+
+func TestUidGeneratorGet(t *testing.T) {
+ ug := &UidGenerator{}
+ key := []byte("testkey1testkey2")
+ err := ug.Init(1, key)
+ if err != nil {
+ t.Fatalf("Failed to initialize generator: %v", err)
+ }
+
+ // Test basic UID generation
+ uid1 := ug.Get()
+ if uid1 == ZeroUid {
+ t.Error("Generated UID should not be zero")
+ }
+
+ uid2 := ug.Get()
+ if uid2 == ZeroUid {
+ t.Error("Generated UID should not be zero")
+ }
+
+ // UIDs should be unique
+ if uid1 == uid2 {
+ t.Error("Generated UIDs should be unique")
+ }
+
+ // Test multiple UIDs are unique
+ uids := make(map[Uid]bool)
+ for i := 0; i < 1000; i++ {
+ uid := ug.Get()
+ if uid == ZeroUid {
+ t.Errorf("UID %d should not be zero", i)
+ }
+ if uids[uid] {
+ t.Errorf("Duplicate UID generated: %v", uid)
+ }
+ uids[uid] = true
+ }
+}
+
+func TestUidGeneratorGetWithUninitializedGenerator(t *testing.T) {
+ ug := &UidGenerator{}
+
+ // Test Get() without initialization
+ uid := ug.Get()
+ if uid != ZeroUid {
+ t.Error("Expected ZeroUid from uninitialized generator")
+ }
+}
+
+func TestUidGeneratorGetStr(t *testing.T) {
+ ug := &UidGenerator{}
+ key := []byte("testkey1testkey2")
+ err := ug.Init(1, key)
+ if err != nil {
+ t.Fatalf("Failed to initialize generator: %v", err)
+ }
+
+ // Test basic string UID generation
+ uidStr1 := ug.GetStr()
+ if uidStr1 == "" {
+ t.Error("Generated UID string should not be empty")
+ }
+
+ uidStr2 := ug.GetStr()
+ if uidStr2 == "" {
+ t.Error("Generated UID string should not be empty")
+ }
+
+ // UID strings should be unique
+ if uidStr1 == uidStr2 {
+ t.Error("Generated UID strings should be unique")
+ }
+
+ // Test that string is valid base64
+ _, err = base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(uidStr1)
+ if err != nil {
+ t.Errorf("Generated UID string should be valid base64: %v", err)
+ }
+
+ // Test consistency between Get() and GetStr()
+ uidStr := ug.GetStr()
+
+ // Decode the string and compare with binary UID
+ decoded, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(uidStr)
+ if err != nil {
+ t.Fatalf("Failed to decode UID string: %v", err)
+ }
+ if len(decoded) != 8 {
+ t.Errorf("Decoded UID should be 8 bytes, got %d", len(decoded))
+ }
+}
+
+func TestUidGeneratorGetStrWithUninitializedGenerator(t *testing.T) {
+ ug := &UidGenerator{}
+
+ // Test GetStr() without initialization
+ uidStr := ug.GetStr()
+ if uidStr != "" {
+ t.Error("Expected empty string from uninitialized generator")
+ }
+}
+
+func TestUidGeneratorDecodeUid(t *testing.T) {
+ ug := &UidGenerator{}
+ key := []byte("testkey1testkey2")
+ err := ug.Init(1, key)
+ if err != nil {
+ t.Fatalf("Failed to initialize generator: %v", err)
+ }
+
+ // Test decoding generated UIDs
+ uid := ug.Get()
+ decoded := ug.DecodeUid(uid)
+
+ // Decoded value should be non-negative (for SQL compatibility)
+ if decoded < 0 {
+ t.Errorf("Decoded UID should be non-negative, got %d", decoded)
+ }
+
+ // Test multiple UIDs decode to different values
+ uid1 := ug.Get()
+ uid2 := ug.Get()
+ decoded1 := ug.DecodeUid(uid1)
+ decoded2 := ug.DecodeUid(uid2)
+
+ if decoded1 == decoded2 {
+ t.Error("Different UIDs should decode to different values")
+ }
+}
+
+func TestUidGeneratorEncodeInt64(t *testing.T) {
+ ug := &UidGenerator{}
+ key := []byte("testkey1testkey2")
+ err := ug.Init(1, key)
+ if err != nil {
+ t.Fatalf("Failed to initialize generator: %v", err)
+ }
+
+ // Test encoding positive int64 values
+ val1 := int64(12345)
+ uid1 := ug.EncodeInt64(val1)
+ if uid1 == ZeroUid {
+ t.Error("Encoded UID should not be zero for positive value")
+ }
+
+ val2 := int64(67890)
+ uid2 := ug.EncodeInt64(val2)
+ if uid2 == ZeroUid {
+ t.Error("Encoded UID should not be zero for positive value")
+ }
+
+ // Different values should encode to different UIDs
+ if uid1 == uid2 {
+ t.Error("Different values should encode to different UIDs")
+ }
+
+ // Test encoding zero
+ uid0 := ug.EncodeInt64(0)
+ if uid0 == ZeroUid {
+ t.Error("Encoded UID for 0 should not be ZeroUid (due to encryption)")
+ }
+
+ // Test encoding large values
+ maxVal := int64(9223372036854775807) // max int64
+ uidMax := ug.EncodeInt64(maxVal)
+ if uidMax == ZeroUid {
+ t.Error("Should be able to encode max int64 value")
+ }
+}
+
+func TestUidGeneratorEncodeDecodeRoundtrip(t *testing.T) {
+ ug := &UidGenerator{}
+ key := []byte("testkey1testkey2")
+ err := ug.Init(1, key)
+ if err != nil {
+ t.Fatalf("Failed to initialize generator: %v", err)
+ }
+
+ // Test encode/decode roundtrip for various values
+ testValues := []int64{0, 1, 42, 12345, 1000000, 9223372036854775807}
+
+ for _, val := range testValues {
+ encoded := ug.EncodeInt64(val)
+ decoded := ug.DecodeUid(encoded)
+
+ if decoded != val {
+ t.Errorf("Roundtrip failed for %d: got %d", val, decoded)
+ }
+ }
+
+ // Test that generated UIDs can be decoded back to sequential values
+ uid := ug.Get()
+ decoded := ug.DecodeUid(uid)
+ reencoded := ug.EncodeInt64(decoded)
+
+ if reencoded != uid {
+ t.Error("Generated UID roundtrip failed")
+ }
+}
+
+func TestUidGeneratorConcurrency(t *testing.T) {
+ ug := &UidGenerator{}
+ key := []byte("testkey1testkey2")
+ err := ug.Init(1, key)
+ if err != nil {
+ t.Fatalf("Failed to initialize generator: %v", err)
+ }
+
+ // Test concurrent UID generation
+ const numGoroutines = 10
+ const uidsPerGoroutine = 100
+
+ uidChan := make(chan Uid, numGoroutines*uidsPerGoroutine)
+
+ for i := 0; i < numGoroutines; i++ {
+ go func() {
+ for j := 0; j < uidsPerGoroutine; j++ {
+ uid := ug.Get()
+ uidChan <- uid
+ }
+ }()
+ }
+
+ // Collect all UIDs
+ uids := make(map[Uid]bool)
+ for i := 0; i < numGoroutines*uidsPerGoroutine; i++ {
+ uid := <-uidChan
+ if uid == ZeroUid {
+ t.Error("Generated UID should not be zero")
+ }
+ if uids[uid] {
+ t.Errorf("Duplicate UID generated in concurrent test: %v", uid)
+ }
+ uids[uid] = true
+ }
+
+ if len(uids) != numGoroutines*uidsPerGoroutine {
+ t.Errorf("Expected %d unique UIDs, got %d", numGoroutines*uidsPerGoroutine, len(uids))
+ }
+}
+
+func TestUidGeneratorPerformance(t *testing.T) {
+ if testing.Short() {
+ t.Skip("Skipping performance test in short mode")
+ }
+
+ ug := &UidGenerator{}
+ key := []byte("testkey1testkey2")
+ err := ug.Init(1, key)
+ if err != nil {
+ t.Fatalf("Failed to initialize generator: %v", err)
+ }
+
+ // Performance test for Get()
+ start := time.Now()
+ for i := 0; i < 100000; i++ {
+ uid := ug.Get()
+ if uid == ZeroUid {
+ t.Error("Generated UID should not be zero")
+ }
+ }
+ duration := time.Since(start)
+ t.Logf("Generated 100,000 UIDs in %v (%.0f UIDs/sec)", duration, 100000/duration.Seconds())
+
+ // Performance test for GetStr()
+ start = time.Now()
+ for i := 0; i < 100000; i++ {
+ uidStr := ug.GetStr()
+ if uidStr == "" {
+ t.Error("Generated UID string should not be empty")
+ }
+ }
+ duration = time.Since(start)
+ t.Logf("Generated 100,000 UID strings in %v (%.0f UIDs/sec)", duration, 100000/duration.Seconds())
+}
+
+func TestUidGeneratorDifferentWorkerIds(t *testing.T) {
+ key := []byte("testkey1testkey2")
+
+ // Test that different worker IDs produce different sequences
+ ug1 := &UidGenerator{}
+ ug2 := &UidGenerator{}
+
+ err1 := ug1.Init(1, key)
+ err2 := ug2.Init(2, key)
+
+ if err1 != nil || err2 != nil {
+ t.Fatalf("Failed to initialize generators: %v, %v", err1, err2)
+ }
+
+ // Generate UIDs from both generators
+ uids1 := make([]Uid, 100)
+ uids2 := make([]Uid, 100)
+
+ for i := 0; i < 100; i++ {
+ uids1[i] = ug1.Get()
+ uids2[i] = ug2.Get()
+ }
+
+ // Check for uniqueness across generators
+ allUids := make(map[Uid]bool)
+ for _, uid := range uids1 {
+ if allUids[uid] {
+ t.Error("Duplicate UID found across generators")
+ }
+ allUids[uid] = true
+ }
+ for _, uid := range uids2 {
+ if allUids[uid] {
+ t.Error("Duplicate UID found across generators")
+ }
+ allUids[uid] = true
+ }
+}
+func TestUidGeneratorInitErrorConditions(t *testing.T) {
+ // Test with invalid worker ID (snowflake has limits)
+ ug := &UidGenerator{}
+ key := []byte("testkey1testkey2")
+
+ // Test with worker ID that might cause snowflake to fail
+ // Snowflake typically supports worker IDs up to 1023 (10 bits)
+ err := ug.Init(1024, key) // This should potentially fail
+ if err != nil {
+ t.Logf("Expected behavior: worker ID 1024 failed with: %v", err)
+ }
+
+ // Test with extremely large worker ID
+ ug2 := &UidGenerator{}
+ err = ug2.Init(4294967295, key) // max uint32
+ if err != nil {
+ t.Logf("Expected behavior: max uint32 worker ID failed with: %v", err)
+ }
+}
+
+func TestUidGeneratorInitKeyValidation(t *testing.T) {
+ // Test with various invalid key lengths
+ testCases := []struct {
+ name string
+ key []byte
+ }{
+ {"nil key", nil},
+ {"empty key", []byte{}},
+ {"too short key", []byte("short")},
+ {"15 byte key", []byte("testkey1testkey")}, // 15 bytes
+ {"17 byte key", []byte("testkey1testkey22")}, // 17 bytes
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ ug := &UidGenerator{}
+ err := ug.Init(1, tc.key)
+ if err == nil {
+ t.Errorf("Expected error for %s, but got none", tc.name)
+ }
+ })
+ }
+}
+
+func TestUidGeneratorInitValidKeys(t *testing.T) {
+ // Test with exactly 16 byte key (XTEA requirement)
+ ug := &UidGenerator{}
+ key16 := []byte("testkey1testkey2") // exactly 16 bytes
+ err := ug.Init(1, key16)
+ if err != nil {
+ t.Errorf("16-byte key should work: %v", err)
+ }
+
+ // Test with different valid 16-byte keys
+ validKeys := [][]byte{
+ []byte("1234567890123456"),
+ []byte("abcdefghijklmnop"),
+ []byte("\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f"),
+ }
+
+ for i, key := range validKeys {
+ ug := &UidGenerator{}
+ err := ug.Init(uint(i), key)
+ if err != nil {
+ t.Errorf("Valid key %d should work: %v", i, err)
+ }
+ }
+}
+
+func TestUidGeneratorInitPartialFailure(t *testing.T) {
+ // Test scenario where snowflake init succeeds but cipher init fails
+ ug := &UidGenerator{}
+
+ // First, initialize with valid parameters
+ validKey := []byte("testkey1testkey2")
+ err := ug.Init(1, validKey)
+ if err != nil {
+ t.Fatalf("Initial setup should work: %v", err)
+ }
+
+ // Now try to init again with invalid key - should not affect existing snowflake
+ oldSeq := ug.seq
+ err = ug.Init(1, []byte("short"))
+
+ // The snowflake should remain the same (not re-initialized)
+ if ug.seq != oldSeq {
+ t.Error("Snowflake should not be re-initialized on partial failure")
+ }
+
+ // But cipher might be affected depending on implementation
+ if err != nil {
+ t.Logf("Expected behavior: partial init failed with: %v", err)
+ }
+}
+
+func TestUidGeneratorInitMultipleWorkers(t *testing.T) {
+ key := []byte("testkey1testkey2")
+ generators := make([]*UidGenerator, 10)
+
+ // Initialize multiple generators with different worker IDs
+ for i := 0; i < 10; i++ {
+ generators[i] = &UidGenerator{}
+ err := generators[i].Init(uint(i), key)
+ if err != nil {
+ t.Errorf("Worker %d initialization failed: %v", i, err)
+ }
+ }
+
+ // Verify they all generate unique UIDs
+ uids := make(map[Uid]int) // UID -> worker ID
+ for i, gen := range generators {
+ for j := 0; j < 10; j++ {
+ uid := gen.Get()
+ if uid == ZeroUid {
+ t.Errorf("Worker %d generated ZeroUid", i)
+ continue
+ }
+ if existingWorker, exists := uids[uid]; exists {
+ t.Errorf("Duplicate UID %v between workers %d and %d", uid, existingWorker, i)
+ }
+ uids[uid] = i
+ }
+ }
+}
+
+func TestUidGeneratorInitIdempotency(t *testing.T) {
+ ug := &UidGenerator{}
+ key := []byte("testkey1testkey2")
+
+ // First initialization
+ err1 := ug.Init(1, key)
+ if err1 != nil {
+ t.Fatalf("First init failed: %v", err1)
+ }
+
+ seq1 := ug.seq
+ cipher1 := ug.cipher
+
+ // Second initialization with same parameters
+ err2 := ug.Init(1, key)
+ if err2 != nil {
+ t.Errorf("Second init should not fail: %v", err2)
+ }
+
+ // Should not re-initialize
+ if ug.seq != seq1 {
+ t.Error("Snowflake should not be re-initialized")
+ }
+ if ug.cipher != cipher1 {
+ t.Error("Cipher should not be re-initialized")
+ }
+
+ // Third initialization with different parameters
+ err3 := ug.Init(2, key)
+ if err3 != nil {
+ t.Errorf("Third init should not fail: %v", err3)
+ }
+
+ // Still should not re-initialize
+ if ug.seq != seq1 {
+ t.Error("Snowflake should not be re-initialized even with different worker ID")
+ }
+ if ug.cipher != cipher1 {
+ t.Error("Cipher should not be re-initialized even with different worker ID")
+ }
+}
+
+func TestUidGeneratorInitConcurrent(t *testing.T) {
+ const numGoroutines = 20
+ key := []byte("testkey1testkey2")
+
+ ug := &UidGenerator{}
+ errChan := make(chan error, numGoroutines)
+
+ // Concurrent initialization attempts
+ for i := 0; i < numGoroutines; i++ {
+ go func(workerID uint) {
+ err := ug.Init(workerID, key)
+ errChan <- err
+ }(uint(i))
+ }
+
+ // Collect results
+ var errors []error
+ for i := 0; i < numGoroutines; i++ {
+ if err := <-errChan; err != nil {
+ errors = append(errors, err)
+ }
+ }
+
+ // At least one should succeed, others might fail due to race conditions
+ // but the generator should be in a valid state
+ if ug.seq == nil {
+ t.Error("Snowflake should be initialized after concurrent attempts")
+ }
+ if ug.cipher == nil {
+ t.Error("Cipher should be initialized after concurrent attempts")
+ }
+
+ // Should be able to generate UIDs
+ uid := ug.Get()
+ if uid == ZeroUid {
+ t.Error("Should be able to generate UID after concurrent initialization")
+ }
+
+ if len(errors) > 0 {
+ t.Logf("Some concurrent initializations failed (may be expected): %v", errors)
+ }
+}
+
+func TestUidGeneratorInitBoundaryWorkerIDs(t *testing.T) {
+ key := []byte("testkey1testkey2")
+
+ // Test boundary values for worker ID
+ testCases := []struct {
+ name string
+ workerID uint
+ expect bool // true if should succeed
+ }{
+ {"zero worker ID", 0, true},
+ {"worker ID 1", 1, true},
+ {"worker ID 1023", 1023, true}, // Common snowflake limit
+ {"worker ID 1024", 1024, false}, // Might exceed limit
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ ug := &UidGenerator{}
+ err := ug.Init(tc.workerID, key)
+
+ if tc.expect && err != nil {
+ t.Errorf("Expected success for worker ID %d, got error: %v", tc.workerID, err)
+ } else if !tc.expect && err == nil {
+ t.Errorf("Expected error for worker ID %d, but succeeded", tc.workerID)
+ }
+
+ // If initialization succeeded, verify it works
+ if err == nil {
+ uid := ug.Get()
+ if uid == ZeroUid {
+ t.Errorf("Generator with worker ID %d should produce valid UIDs", tc.workerID)
+ }
+ }
+ })
+ }
+}
+func BenchmarkUidGeneratorGet(b *testing.B) {
+ ug := &UidGenerator{}
+ key := []byte("testkey1testkey2")
+ err := ug.Init(1, key)
+ if err != nil {
+ b.Fatalf("Failed to initialize generator: %v", err)
+ }
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ _ = ug.Get()
+ }
+}
+
+func BenchmarkUidGeneratorGetStr(b *testing.B) {
+ ug := &UidGenerator{}
+ key := []byte("testkey1testkey2")
+ err := ug.Init(1, key)
+ if err != nil {
+ b.Fatalf("Failed to initialize generator: %v", err)
+ }
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ _ = ug.GetStr()
+ }
+}
+
+func BenchmarkUidGeneratorDecodeUid(b *testing.B) {
+ ug := &UidGenerator{}
+ key := []byte("testkey1testkey2")
+ err := ug.Init(1, key)
+ if err != nil {
+ b.Fatalf("Failed to initialize generator: %v", err)
+ }
+
+ // Pre-generate a UID to decode
+ uid := ug.Get()
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ _ = ug.DecodeUid(uid)
+ }
+}
+
+func BenchmarkUidGeneratorEncodeInt64(b *testing.B) {
+ ug := &UidGenerator{}
+ key := []byte("testkey1testkey2")
+ err := ug.Init(1, key)
+ if err != nil {
+ b.Fatalf("Failed to initialize generator: %v", err)
+ }
+
+ val := int64(12345)
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ _ = ug.EncodeInt64(val)
+ }
+}
diff --git a/server/templ/email-password-reset-en.templ b/server/templ/email-password-reset-en.templ
index e69de29..cd567b1 100644
--- a/server/templ/email-password-reset-en.templ
+++ b/server/templ/email-password-reset-en.templ
@@ -0,0 +1,68 @@
+{{/*
+ ENGLISH
+
+ This template defines contents of the password reset email.
+
+ See explanation in ./email-validation-en.templ
+*/}}
+
+
+{{define "subject" -}}
+Reset Tinode password
+{{- end}}
+
+{{define "body_html" -}}
+
+
+
+Hello.
+
+You recently requested to reset the password for your Tinode account.
+Use the link or code below to reset it. The link and code are valid for the next 24 hours only.
+
+Click to reset your password.
+
+If you’re having trouble with the link above, copy and paste the URL below into your web browser:
+
+{{.HostUrl}}#reset?scheme={{.Scheme}}&code={{.Code}}&cred={{.Cred}}
+
+
+Please enter the following code if prompted:
+{{.Code}}
+
+{{with .Login}}
+In case you have forgotten, here is your login: {{.}}.
+{{end}}
+
+If you did not request a password reset, please ignore this message.
+
+
+
+
+
+{{- end}}
+
+{{define "body_plain" -}}
+
+Hello.
+
+You recently requested to reset the password for your Tinode account ({{.HostUrl}}).
+Use the link or code below to reset it. The link and code are valid for the next 24 hours only.
+
+ {{.HostUrl}}#reset?scheme={{.Scheme}}&code={{.Code}}&cred={{.Cred}}
+
+If you’re having trouble with clicking the link above, copy and paste it into your web browser.
+
+Please enter the following code if prompted:
+ {{.Code}}
+
+{{- with .Login}}
+In case you have forgotten, here is your login: {{.}}.
+{{end -}}
+
+If you did not request a password reset, please ignore this message.
+
+Tinode Team
+https://tinode.co/
+
+{{- end}}
diff --git a/server/templ/email-password-reset-es.templ b/server/templ/email-password-reset-es.templ
index e69de29..86257a4 100644
--- a/server/templ/email-password-reset-es.templ
+++ b/server/templ/email-password-reset-es.templ
@@ -0,0 +1,68 @@
+{{/*
+ SPANISH
+
+ This template defines contents of the password reset email in spanish.
+
+ See explanation in ./email-validation-en.templ
+*/}}
+
+
+{{define "subject" -}}
+Reestablecer contraseña de Tinode
+{{- end}}
+
+{{define "body_html" -}}
+
+
+
+Hola.
+
+Recientemente solicitaste reestablecer la contraseña para tu cuenta Tinode.
+Usa el enlace de abajo para reestablecerla. El enlace es válido solamente por las siguientes 24 horas.
+
+Clic para reestablecer contraseña.
+
+Si tienes problemas con el enlace superior, copia y pega le siguiente URL en tu navegador.
+
+{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}
+
+
+Ingrese el siguiente código si se le solicita:
+{{.Code}}
+
+{{with .Login}}
+En caso de que lo hayas olvidado, tu usuario es: {{.}}.
+{{end}}
+
+Si no solicitaste el reestablecimiento de tu contrseña, por favor ignora este mensaje.
+
+
+
+
+
+{{- end}}
+
+{{define "body_plain" -}}
+
+Hola.
+
+Recientemente solicitaste reestablecer la contraseña para tu cuenta Tinode ({{.HostUrl}}).
+Usa el enlace de abajo para reestablecerla. El enlace es válido solamente por las siguientes 24 horas.
+
+ {{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}
+
+Si tienes problemas con el enlace superior, copia y pega le siguiente URL en tu navegador.
+
+Ingrese el siguiente código si se le solicita:
+ {{.Code}}
+
+{{- with .Login}}
+En caso de que lo hayas olvidado, tu usuario es: {{.}}.
+{{end -}}
+
+Si no solicitaste el reestablecimiento de tu contrseña, por favor ignora este mensaje.
+
+Equipo de Tinode
+https://tinode.co/
+
+{{- end}}
diff --git a/server/templ/email-password-reset-fr.templ b/server/templ/email-password-reset-fr.templ
index e69de29..ea2e4a8 100644
--- a/server/templ/email-password-reset-fr.templ
+++ b/server/templ/email-password-reset-fr.templ
@@ -0,0 +1,68 @@
+{{/*
+ FRENCH
+
+ This template defines contents of the password reset email.
+
+ See explanation in ./email-validation-en.templ
+*/}}
+
+
+{{define "subject" -}}
+Réinitialiser votre mot de passe Tinode
+{{- end}}
+
+{{define "body_html" -}}
+
+
+
+Bonjour.
+
+Vous avez récemment demandé à réinitialiser le mot de passe de votre compte Tinode.
+Utilisez le lien ou le code ci-dessous pour le réinitialiser. Le lien et le code sont valides pour les prochaines 24 heures seulement.
+
+Cliquer ici pour réinitialiser votre mot de passe.
+
+Si vous avez des problèmes avec le lien ci-dessus, copiez et collez l'URL ci-dessous dans votre navigateur web :
+
+{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}
+
+
+Veuillez saisir le code suivant si vous y êtes invité :
+{{.Code}}
+
+{{with .Login}}
+Au cas où vous l'auriez oublié, voici votre login : {{.}}.
+{{end}}
+
+Si vous n'avez pas demandé la réinitialisation de votre mot de passe, veuillez ignorer ce message.
+
+
+
+
+
+{{- end}}
+
+{{define "body_plain" -}}
+
+Bonjour.
+
+Vous avez récemment demandé à réinitialiser le mot de passe de votre compte ({{.HostUrl}}).
+Utilisez le lien ou le code ci-dessous pour le réinitialiser. Le lien et le code sont valides pour les prochaines 24 heures seulement.
+
+ {{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}
+
+Si vous avez des difficultés à cliquer sur le lien ci-dessus, copiez et collez-le dans votre navigateur web.
+
+Veuillez saisir le code suivant si vous y êtes invité:
+ {{.Code}}
+
+{{- with .Login}}
+Au cas où vous l'auriez oublié, voici votre login : {{.}}.
+{{end -}}
+
+Si vous n'avez pas demandé la réinitialisation de votre mot de passe, veuillez ignorer ce message.
+
+L'équipe Tinode
+https://tinode.co/
+
+{{- end}}
diff --git a/server/templ/email-password-reset-pt.templ b/server/templ/email-password-reset-pt.templ
index e69de29..e59e604 100644
--- a/server/templ/email-password-reset-pt.templ
+++ b/server/templ/email-password-reset-pt.templ
@@ -0,0 +1,68 @@
+{{/*
+ PORTUGUESE
+
+ This template defines contents of the password reset e-mail in portuguese.
+
+ See explanation in ./email-validation-en.templ
+*/}}
+
+
+{{define "subject" -}}
+Redefinir senha Tinode
+{{- end}}
+
+{{define "body_html" -}}
+
+
+
+Olá.
+
+Você solicitou recentemente a redefinição da sua senha Tinode.
+Use o link ou código abaixo para redefini-lo. O link e o código são válidos apenas pelas próximas 24 horas.
+
+Clic para redefinir sua senha.
+
+Se você tiver problema com o link acima, copie e cole a URL abaixo no seu navegador:
+
+{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}
+
+
+Digite o seguinte código se solicitado:
+{{.Code}}
+
+{{with .Login}}
+Em caso de esquecimento, aqui seu login: {{.}}.
+{{end}}
+
+Se não solicitaste a redefinição da senha, por favor ignorar essa mensagem.
+
+
+
+
+
+{{- end}}
+
+{{define "body_plain" -}}
+
+Olá.
+
+Você solicitou recentemente a redefinição da sua senha Tinode ({{.HostUrl}}).
+Use o link ou código abaixo para redefini-lo. O link e o código são válidos apenas pelas próximas 24 horas.
+
+ {{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}
+
+Si tienes problemas con el enlace superior, copia y pega le siguiente URL en tu navegador.
+
+Digite o seguinte código se solicitado:
+ {{.Code}}
+
+{{- with .Login}}
+Em caso de esquecimento, aqui seu login: {{.}}.
+{{end -}}
+
+Se não solicitaste a redefinição da senha, por favor ignorar essa mensagem..
+
+Equipe Tinode
+https://tinode.co/
+
+{{- end}}
diff --git a/server/templ/email-password-reset-ru.templ b/server/templ/email-password-reset-ru.templ
index e69de29..ad1807f 100644
--- a/server/templ/email-password-reset-ru.templ
+++ b/server/templ/email-password-reset-ru.templ
@@ -0,0 +1,68 @@
+{{/*
+ RUSSIAN
+
+ Password reset email.
+
+ See explanation in ./email-validation-en.templ
+*/}}
+
+
+{{define "subject" -}}
+Изменить пароль Tinode
+{{- end}}
+
+{{define "body_html" -}}
+
+
+
+Здравствуйте.
+
+Вы прислали запрос на изменение пароля для вашего аккаунта Tinode.
+Используйте ссылку или код ниже, чтобы сбросить его. Ссылка и код действительны только в течение следующих 24 часов.
+
+Кликните для изменения пароля.
+
+Если ссылка по какой-то причине не работает, скопируйте следующий URL и вставьте его в адресную строку браузера:
+
+{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}&hl=RU
+
+
+Пожалуйста, введите следующий код, если потребуется:
+{{.Code}}
+
+{{with .Login}}
+В случае, если вы забыли, ваш логин: {{.}}.
+{{end}}
+
+Если вы не отправляли запрос на изменение пароля, просто игнорируйте это сообщение.
+
+
+
+
+
+{{- end}}
+
+{{define "body_plain" -}}
+
+Здравствуйте.
+
+Вы прислали запрос на изменение пароля для вашего аккаунта Tinode ({{.HostUrl}}).
+Используйте ссылку или код ниже, чтобы сбросить его. Ссылка и код действительны только в течение следующих 24 часов.
+
+ {{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}&hl=RU
+
+Если ссылка по какой-то вы не можете кликнуть по ссылке выше, скопируйте ее и вставьте его в адресную строку браузера.
+
+Пожалуйста, введите следующий код, если потребуется:
+ {{.Code}}
+
+{{- with .Login}}
+В случае, если вы забыли, ваш логин: {{.}}.
+{{end -}}
+
+Если вы не отправляли запрос на изменение пароля, просто игнорируйте это сообщение.
+
+Команда Tinode
+https://tinode.co/
+
+{{- end}}
diff --git a/server/templ/email-password-reset-uk.templ b/server/templ/email-password-reset-uk.templ
index e69de29..5bb390d 100644
--- a/server/templ/email-password-reset-uk.templ
+++ b/server/templ/email-password-reset-uk.templ
@@ -0,0 +1,68 @@
+{{/*
+ UKRAINIAN
+
+ Password reset email.
+
+ See explanation in ./email-validation-en.templ
+*/}}
+
+
+{{define "subject" -}}
+Змінити пароль Tinode
+{{- end}}
+
+{{define "body_html" -}}
+
+
+
+Доброго дня.
+
+Ви надіслали запит на зміну пароля для вашого акаунта Tinode.
+Щоб скинути його, скористайтеся посиланням або кодом нижче. Посилання та код дійсні лише протягом наступних 24 годин.
+
+Натисніть для зміни пароля.
+
+Якщо посилання з якоїсь причини не працює, скопіюйте наступну URL-адресу та вставте її в адресний рядок браузера:
+
+{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}&hl=UK
+
+
+Будь ласка, введіть наступний код, якщо потрібно:
+{{.Code}}
+
+{{with .Login}}
+У випадку, якщо ви забули ваш логін: {{.}}.
+{{end}}
+
+Якщо ви не надсилали запит на зміну пароля, просто ігноруйте це повідомлення.
+
+
+
+
+
+{{- end}}
+
+{{define "body_plain" -}}
+
+Доброго дня.
+
+Ви надіслали запит на зміну пароля для вашого акаунта Tinode ({{.HostUrl}}).
+Щоб скинути його, скористайтеся посиланням або кодом нижче. Посилання та код дійсні лише протягом наступних 24 годин.
+
+ {{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}&hl=UK
+
+Якщо посилання з якоїсь причини не працює, скопіюйте наступну URL-адресу та вставте її в адресний рядок браузера.
+
+Будь ласка, введіть наступний код, якщо потрібно:
+ {{.Code}}
+
+{{- with .Login}}
+У випадку, якщо ви забули ваш логін: {{.}}.
+{{end -}}
+
+Якщо ви не надсилали запит на зміну пароля, просто ігноруйте це повідомлення.
+
+Команда Tinode
+https://tinode.co/
+
+{{- end}}
diff --git a/server/templ/email-password-reset-vi.templ b/server/templ/email-password-reset-vi.templ
index e69de29..2999d66 100644
--- a/server/templ/email-password-reset-vi.templ
+++ b/server/templ/email-password-reset-vi.templ
@@ -0,0 +1,68 @@
+{{/*
+ VIETNAMESE
+
+ This template defines contents of the password reset email.
+
+ See explanation in ./email-validation-en.templ
+*/}}
+
+
+{{define "subject" -}}
+Đặt lại mật khẩu Tinode
+{{- end}}
+
+{{define "body_html" -}}
+
+
+
+Xin chào.
+
+Bạn vừa gửi yêu cầu đặt lại mật khẩu cho tài khoản Tinode của bạn.
+Sử dụng liên kết hoặc mã dưới đây để thiết lập lại nó. Liên kết và mã chỉ có hiệu lực trong 24 giờ tới.
+
+Bấm để đặt lại mật khẩu.
+
+Nếu bạn gặp vấn đề khi bấm vào liên kết trên, vui lòng sao chép liên kết dưới đây và dán vào trình duyệt của bạn:
+
+{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}
+
+
+Vui lòng nhập mã sau nếu được nhắc:
+{{.Code}}
+
+{{with .Login}}
+Trong trường hợp bạn quên, thì đây là tên đăng nhập của bạn: {{.}}.
+{{end}}
+
+Nếu bạn không yêu cầu cấp lại mật khẩu, Vui lòng bỏ qua tin nhắn này.
+
+
+
+
+
+{{- end}}
+
+{{define "body_plain" -}}
+
+Xin chào.
+
+Bạn vừa gửi yêu cầu đặt lại mật khẩu cho tài khoản Tinode ({{.HostUrl}}).
+Sử dụng liên kết hoặc mã dưới đây để thiết lập lại nó. Liên kết và mã chỉ có hiệu lực trong 24 giờ tới.
+
+ {{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}
+
+Nếu bạn gặp vấn đề khi bấm vào liên kết trên, vui lòng sao chép và dán vào trình duyệt của bạn
+
+Vui lòng nhập mã sau nếu được nhắc:
+ {{.Code}}
+
+{{- with .Login}}
+Trong trường hợp bạn quên, thì đây là tên đăng nhập của bạn: {{.}}.
+{{end -}}
+
+Nếu bạn không yêu cầu cấp lại mật khẩu, Vui lòng bỏ qua tin nhắn này.
+
+Tinode Team
+https://tinode.co/
+
+{{- end}}
diff --git a/server/templ/email-password-reset-zh-TW.templ b/server/templ/email-password-reset-zh-TW.templ
index e69de29..d9fe08c 100644
--- a/server/templ/email-password-reset-zh-TW.templ
+++ b/server/templ/email-password-reset-zh-TW.templ
@@ -0,0 +1,68 @@
+{{/*
+ 繁體中文
+
+ 此模板定義密碼重設電子郵件的內容。
+
+ 說明請參見 ./email-validation-en.templ
+*/}}
+
+
+{{define "subject" -}}
+重設 Tinode 密碼
+{{- end}}
+
+{{define "body_html" -}}
+
+
+
+您好。
+
+您最近請求重設您的 Tinode 帳戶密碼。
+請使用以下連結或驗證碼進行重設。此連結和驗證碼僅在接下來的 24 小時內有效。
+
+點擊重設您的密碼。
+
+如果您無法使用上方的連結,請複製以下網址並貼到您的網頁瀏覽器中:
+
+{{.HostUrl}}#reset?scheme={{.Scheme}}&code={{.Code}}&cred={{.Cred}}
+
+
+如有提示,請輸入以下驗證碼:
+{{.Code}}
+
+{{with .Login}}
+如果您忘記了,您的登入名稱是:{{.}}。
+{{end}}
+
+如果您沒有請求密碼重設,請忽略此訊息。
+
+
+
+
+
+{{- end}}
+
+{{define "body_plain" -}}
+
+您好。
+
+您最近請求重設您的 Tinode 帳戶密碼 ({{.HostUrl}})。
+請使用以下連結或驗證碼進行重設。此連結和驗證碼僅在接下來的 24 小時內有效。
+
+ {{.HostUrl}}#reset?scheme={{.Scheme}}&code={{.Code}}&cred={{.Cred}}
+
+如果您無法點擊上方的連結,請複製並貼到您的網頁瀏覽器中。
+
+如有提示,請輸入以下驗證碼:
+ {{.Code}}
+
+{{- with .Login}}
+如果您忘記了,您的登入名稱是:{{.}}。
+{{end -}}
+
+如果您沒有請求密碼重設,請忽略此訊息。
+
+Tinode 團隊
+https://tinode.co/
+
+{{- end}}
diff --git a/server/templ/email-password-reset-zh.templ b/server/templ/email-password-reset-zh.templ
index e69de29..45ccbca 100644
--- a/server/templ/email-password-reset-zh.templ
+++ b/server/templ/email-password-reset-zh.templ
@@ -0,0 +1,67 @@
+{{/*
+ CHINESE
+
+ 定义重置密码文案的模版。
+
+ 参阅 ./email-validation-zh.templ
+*/}}
+
+
+{{define "subject" -}}
+重置 Tinode 密码
+{{- end}}
+
+{{define "body_html" -}}
+
+
+
+您好!
+
+您正在申请重置 Tinode 使用下面的链接或代码重置它。 链接和代码仅在接下来的 24 小时内有效。
+
+重置密码
+
+如果无法点击上面的链接,您可以复制该地址,并粘帖在浏览器的地址栏中访问:
+
+ {{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}
+
+
+如果出现提示,请输入以下代码:
+{{.Code}}
+
+{{with .Login}}
+如果您忘记了,您的登录名是: {{.}}.
+{{end}}
+
+如非您没有申请重置密码,请忽略这条消息。
+
+
+
+
+
+{{- end}}
+
+{{define "body_plain" -}}
+
+您好!
+
+您正在申请重置 Tinode ({{.HostUrl}}) 账号密码。
+使用下面的链接或代码重置它。 链接和代码仅在接下来的 24 小时内有效。
+
+ {{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}
+
+如果无法点击上面的链接,您可以复制该地址,并粘帖在浏览器的地址栏中访问:
+
+如果出现提示,请输入以下代码:
+ {{.Code}}
+
+{{- with .Login}}
+如果您忘记了,您的登录名是: {{.}}.
+{{end -}}
+
+如非您没有申请重置密码,请忽略这条消息。
+
+Tinode 团队
+https://tinode.co/
+
+{{- end}}
diff --git a/server/templ/email-validation-en.templ b/server/templ/email-validation-en.templ
index e69de29..d61076c 100644
--- a/server/templ/email-validation-en.templ
+++ b/server/templ/email-validation-en.templ
@@ -0,0 +1,62 @@
+{{/*
+ ENGLISH
+
+ This template defines content of the email sent to users as a request to confirm registration email address.
+ See https://golang.org/pkg/text/template/ for syntax.
+
+ The template must contain the following parts parts:
+ - 'subject': Subject line of an email message
+ - One or both of the following:
+ - 'body_html': HTML content of the message. A header "Content-type: text/html" will be added.
+ - 'body_plain': plain text content of the message. A header "Content-type: text/plain" will be added.
+
+ If both body_html and body_plain are included, both are sent as parts of 'multipart/alternative' message.
+*/}}
+
+{{define "subject" -}}
+Tinode registration: confirm email
+{{- end}}
+
+{{define "body_html" -}}
+
+
+
+Hello.
+
+You're receiving this message because someone used your email to register at
+Tinode.
+
+Click to confirm
+or go to
+{{.HostUrl}}#cred?method=email
+and enter the following code:
+{{.Code}}
+You may need to enter login and password.
+
+If you did not register at Tinode just ignore this message.
+
+
+
+
+
+{{- end}}
+
+{{define "body_plain" -}}
+
+Hello.
+
+You're receiving this message because someone used your email to register at Tinode ({{.HostUrl}}).
+
+Click the link {{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}} to confirm or go to {{.HostUrl}}#cred?what=email
+and enter the following code:
+
+ {{.Code}}
+
+You may need to enter login and password.
+
+If you did not register at Tinode just ignore this message.
+
+Tinode Team
+https://tinode.co/
+
+{{- end}}
diff --git a/server/templ/email-validation-es.templ b/server/templ/email-validation-es.templ
index e69de29..1c0cbe2 100644
--- a/server/templ/email-validation-es.templ
+++ b/server/templ/email-validation-es.templ
@@ -0,0 +1,53 @@
+{{/*
+ SPANISH
+
+ See explanation in ./email-validation-en.templ
+*/}}
+
+{{define "subject" -}}
+Registro Tinode: Correo de confirmación
+{{- end}}
+
+{{define "body_html" -}}
+
+
+
+Hola.
+
+Estás recibiendo este correo porque alguien uso tu correo para registrarse en
+Tinode.
+
+Clic para confirmar
+o ve a
+{{.HostUrl}}#cred?method=email
+e ingresa el siguiente código:
+{{.Code}}
+Necesitas ingresar tu usuario y contraseña.
+
+Si tú no te registraste en Tinode solo ignora este mensaje.
+
+
+
+
+
+{{- end}}
+
+{{define "body_plain" -}}
+
+Hola.
+
+Estás recibiendo este correo porque alguien uso tu correo para registrarse en Tinode ({{.HostUrl}}).
+
+Da clic en el enlace {{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}} para confirmar o ve a {{.HostUrl}}#cred?what=email
+e ingresa el siguiente código:
+
+ {{.Code}}
+
+Necesitas ingresar tu usuario y contraseña.
+
+Si tú no te registraste en Tinode solo ignora este mensaje.
+
+Equipo de Tinode
+https://tinode.co/
+
+{{- end}}
diff --git a/server/templ/email-validation-fr.templ b/server/templ/email-validation-fr.templ
index e69de29..01205b3 100644
--- a/server/templ/email-validation-fr.templ
+++ b/server/templ/email-validation-fr.templ
@@ -0,0 +1,53 @@
+{{/*
+ FRENCH
+
+ See explanation in ./email-validation-en.templ
+*/}}
+
+{{define "subject" -}}
+Tinode enregistrement : confirmer l'adresse e-mail
+{{- end}}
+
+{{define "body_html" -}}
+
+
+
+Bonjour.
+
+Vous recevez ce message car quelqu'un a utilisé votre adresse électronique pour s'inscrire sur le site
+Tinode.
+
+Cliquer ici pour confimer
+ou rendez-vous à l'adresse
+{{.HostUrl}}#cred?method=email
+et entrez le code suivant :
+{{.Code}}
+Vous devrez peut-être entrer un login et un mot de passe.
+
+Si vous ne vous êtes pas inscrit à Tinode, ignorez ce message.
+
+
+
+
+
+{{- end}}
+
+{{define "body_plain" -}}
+
+Bonjour.
+
+Vous recevez ce message car quelqu'un a utilisé votre adresse électronique pour s'inscrire sur le site Tinode ({{.HostUrl}}).
+
+Cliquer sur le lien {{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}} pour confimer ou rendez-vous à l'adresse {{.HostUrl}}#cred?what=email
+et entrez le code suivant :
+
+ {{.Code}}
+
+Vous devrez peut-être entrer un login et un mot de passe.
+
+Si vous ne vous êtes pas inscrit à Tinode, ignorez ce message.
+
+L'équipe Tinode
+https://tinode.co/
+
+{{- end}}
diff --git a/server/templ/email-validation-pt.templ b/server/templ/email-validation-pt.templ
index e69de29..dfe9664 100644
--- a/server/templ/email-validation-pt.templ
+++ b/server/templ/email-validation-pt.templ
@@ -0,0 +1,53 @@
+{{/*
+ PORTUGUESE
+
+ See explanation in ./email-validation-en.templ
+*/}}
+
+{{define "subject" -}}
+Registro Tinode: E-mail de confirmação
+{{- end}}
+
+{{define "body_html" -}}
+
+
+
+Olá.
+
+ Você está recebendo esse e-mail porque alguém usou seu e-mail para registrar-se em
+Tinode.
+
+Clique para confirmar
+ou acesse
+{{.HostUrl}}#cred?method=email
+e entre com o seguinte código:
+{{.Code}}
+Necessário entrar com login e senha.
+
+Se você não se registrou em Tinode apenas ignore essa mensagem.
+
+
+
+
+
+{{- end}}
+
+{{define "body_plain" -}}
+
+Olá.
+
+Você está recebendo este e-mail porque alguém usou seu e-mail para registrar-se em Tinode ({{.HostUrl}}).
+
+Clique no link {{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}} para confirmar {{.HostUrl}}#cred?what=email
+e entre com o seguinte código:
+
+ {{.Code}}
+
+Necessário entrar com login e senha.
+
+Se você não se registrou em Tinode apenas ignore essa mensagem..
+
+Equipe Tinode
+https://tinode.co/
+
+{{- end}}
diff --git a/server/templ/email-validation-ru.templ b/server/templ/email-validation-ru.templ
index e69de29..3c489a7 100644
--- a/server/templ/email-validation-ru.templ
+++ b/server/templ/email-validation-ru.templ
@@ -0,0 +1,53 @@
+{{/*
+ RUSSIAN
+
+ See explanation in ./email-validation-en.templ
+*/}}
+
+{{define "subject" -}}
+Регистрация Tinode: подтвердите емейл
+{{- end}}
+
+{{define "body_html" -}}
+
+
+
+Здравствуйте.
+
+Вы получили это сообщение потому, что зарегистрировались в Tinode.
+
+Кликните здесь чтобы подтвердить
+регистрацию или перейдите по сслыке
+{{.HostUrl}}#cred?method=email&hl=RU
+и введите следующий код:
+{{.Code}}
+Возможно, вам потребуется ввести логин и пароль.
+
+Если вы не регистрировались в Tinode, просто игнорируйте это сообщение.
+
+
+
+
+
+{{- end}}
+
+{{define "body_plain" -}}
+
+Здравствуйте.
+
+Вы получили это сообщение потому, что зарегистрировались в Tinode ({{.HostUrl}}).
+
+Кликните на {{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}}&hl=RU чтобы подтвердить
+регистрацию или перейдите по сслыке {{.HostUrl}}#cred?what=email">{{.HostUrl}}#cred?method=email&hl=RU
+и введите следующий код:
+
+ {{.Code}}
+
+Возможно, вам также потребуется ввести логин и пароль.
+
+Если вы не регистрировались в Tinode, просто игнорируйте это сообщение.
+
+Команда Tinode
+https://tinode.co/
+
+{{- end}}
diff --git a/server/templ/email-validation-uk.templ b/server/templ/email-validation-uk.templ
index e69de29..218fe5e 100644
--- a/server/templ/email-validation-uk.templ
+++ b/server/templ/email-validation-uk.templ
@@ -0,0 +1,53 @@
+{{/*
+ UKRAINIAN
+
+ See explanation in ./email-validation-en.templ
+*/}}
+
+{{define "subject" -}}
+Реєстрація Tinode: підтвердіть емейл
+{{- end}}
+
+{{define "body_html" -}}
+
+
+
+Доброго дня.
+
+Ви отримали це повідомлення тому, що зареєструвалися в Tinode.
+
+Натисніть тут, щоб підтвердити
+реєстрацію або перейдіть за посиланням
+{{.HostUrl}}#cred?method=email&hl=UK
+та введіть наступний код:
+{{.Code}}
+Можливо, вам потрібно буде ввести логін та пароль.
+
+Якщо ви не реєструвалися в Tinode, просто ігноруйте це повідомлення.
+
+
+
+
+
+{{- end}}
+
+{{define "body_plain" -}}
+
+Доброго дня.
+
+Ви отримали це повідомлення тому, що зареєструвалися в Tinode ({{.HostUrl}}).
+
+Натисніть на {{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}}&hl=UK щоб підтвердити
+реєстрацію або перейдіть за посиланням {{.HostUrl}}#cred?what=email">{{.HostUrl}}#cred?method=email&hl=UK
+та введіть наступний код:
+
+ {{.Code}}
+
+Можливо, вам потрібно буде ввести логін та пароль.
+
+Якщо ви не реєструвалися в Tinode, просто ігноруйте це повідомлення.
+
+Команда Tinode
+https://tinode.co/
+
+{{- end}}
diff --git a/server/templ/email-validation-vi.templ b/server/templ/email-validation-vi.templ
index e69de29..5323aeb 100644
--- a/server/templ/email-validation-vi.templ
+++ b/server/templ/email-validation-vi.templ
@@ -0,0 +1,53 @@
+{{/*
+ VIETNAMESE
+
+ See explanation in ./email-validation-en.templ
+*/}}
+
+{{define "subject" -}}
+Xác thực đăng ký tài khoản Tinode
+{{- end}}
+
+{{define "body_html" -}}
+
+
+
+Xin chào.
+
+Bạn nhận được tin nhắn này bởi vì có ai đó đã dùng email này để đăng ký tài khoản tại
+Tinode.
+
+Bấm để xác nhận
+hoặc đi tới liên kết
+{{.HostUrl}}#cred?method=email
+và nhập mã xác thực:
+{{.Code}}
+Có thể bạn cần nhập tên đăng nhập và mật khẩu.
+
+Nếu bạn không đăng ký tài khoản tại Tinode vui lòng bỏ qua tin nhắn này.
+
+
+
+
+
+{{- end}}
+
+{{define "body_plain" -}}
+
+Xin chào.
+
+Bạn nhận được tin nhắn này bởi vì có ai đó đã dùng email này để đăng ký tài khoản tại Tinode ({{.HostUrl}}).
+
+Bấm vào liên kết {{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}} để xác nhận hoặc đi tới {{.HostUrl}}#cred?what=email
+và nhập mã xác thực
+
+ {{.Code}}
+
+Có thể bạn sẽ cần nhập tên đăng nhập và mật khẩu.
+
+Nếu bạn không đăng ký tài khoản tại Tinode vui lòng bỏ qua tin nhắn này.
+
+Tinode Team
+https://tinode.co/
+
+{{- end}}
diff --git a/server/templ/email-validation-zh-TW.templ b/server/templ/email-validation-zh-TW.templ
index e69de29..0b54dc7 100644
--- a/server/templ/email-validation-zh-TW.templ
+++ b/server/templ/email-validation-zh-TW.templ
@@ -0,0 +1,63 @@
+{{/*
+ CHINESE Traditional (Taiwan)
+ 繁體中文
+
+ 此模板定義發送給用戶的電子郵件內容,用於請求確認註冊電子郵件地址。
+ 語法請參見 https://golang.org/pkg/text/template/
+
+ 模板必須包含以下部分:
+ - 'subject': 電子郵件的主題行
+ - 以下一個或兩個:
+ - 'body_html': 訊息的 HTML 內容。將添加標頭 "Content-type: text/html"。
+ - 'body_plain': 訊息的純文字內容。將添加標頭 "Content-type: text/plain"。
+
+ 如果同時包含 body_html 和 body_plain,兩者都將作為 'multipart/alternative' 訊息的部分發送。
+*/}}
+
+{{define "subject" -}}
+Tinode 註冊:確認電子郵件
+{{- end}}
+
+{{define "body_html" -}}
+
+
+
+您好。
+
+您收到此訊息是因為有人使用您的電子郵件在
+Tinode 註冊。
+
+點擊確認
+或前往
+{{.HostUrl}}#cred?method=email
+並輸入以下驗證碼:
+{{.Code}}
+您可能需要輸入登入名稱和密碼。
+
+如果您沒有在 Tinode 註冊,請忽略此訊息。
+
+
+
+
+
+{{- end}}
+
+{{define "body_plain" -}}
+
+您好。
+
+您收到此訊息是因為有人使用您的電子郵件在 Tinode ({{.HostUrl}}) 註冊。
+
+點擊連結 {{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}} 進行確認,或前往 {{.HostUrl}}#cred?what=email
+並輸入以下驗證碼:
+
+ {{.Code}}
+
+您可能需要輸入登入名稱和密碼。
+
+如果您沒有在 Tinode 註冊,請忽略此訊息。
+
+Tinode 團隊
+https://tinode.co/
+
+{{- end}}
diff --git a/server/templ/email-validation-zh.templ b/server/templ/email-validation-zh.templ
index e69de29..c77babb 100644
--- a/server/templ/email-validation-zh.templ
+++ b/server/templ/email-validation-zh.templ
@@ -0,0 +1,61 @@
+{{/*
+ CHINESE
+
+ 定义用户注册邮件确认文案的模版。
+ 语法参阅 https://golang.org/pkg/text/template/ 。
+
+ 模版必须包含以下内容:
+ - 'subject':邮件主题
+ - 以下一项或两项:
+ - 'body_html': 包含请求头"Content-type: text/html"的HTML格式消息内容。
+ - 'body_plain': 包含请求头"Content-type: text/plain"的文本格式消息内容。
+
+ 如果同时包含 body_html 和 body_plain,则都作为 'multipart/alternative' 消息的一部分发送。
+*/}}
+
+{{define "subject" -}}
+Tinode 注册: 确认邮件
+{{- end}}
+
+{{define "body_html" -}}
+
+
+
+您好!
+
+您收到此消息是因为您注册了Tinode。
+
+确认注册
+或者跳转至链接
+{{.HostUrl}}#cred?method=email
+并输入验证码:
+{{.Code}}
+您可能需要输入登录名和密码。
+
+如果您没有注册Tinode,请忽略这条消息。
+
+
+
+
+
+{{- end}}
+
+{{define "body_plain" -}}
+
+您好!
+
+您收到此消息是因为您注册了 Tinode ({{.HostUrl}})。
+
+点击链接 {{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}} 确认注册或者跳转至链接 {{.HostUrl}}#cred?what=email
+并输入验证码:
+
+ {{.Code}}
+
+您可能需要输入登录名和密码。
+
+如果您没有注册Tinode,请忽略这条消息。
+
+Tinode 团队
+https://tinode.co/
+
+{{- end}}
diff --git a/server/templ/sms-universal-en.templ b/server/templ/sms-universal-en.templ
index e69de29..afa343d 100644
--- a/server/templ/sms-universal-en.templ
+++ b/server/templ/sms-universal-en.templ
@@ -0,0 +1,8 @@
+{{/*
+ ENGLISH
+
+ Universal confirmation and password reset template for SMS.
+*/}}
+
+Tinode confirmation code: {{.Code}}
+{{.HostUrl}}
diff --git a/server/templ/sms-universal-es.templ b/server/templ/sms-universal-es.templ
index e69de29..e69a096 100644
--- a/server/templ/sms-universal-es.templ
+++ b/server/templ/sms-universal-es.templ
@@ -0,0 +1,8 @@
+{{/*
+ SPANISH
+
+ Universal confirmation and password reset template for SMS.
+*/}}
+
+Código de confirmación de Tinode: {{.Code}}
+{{.HostUrl}}
diff --git a/server/templ/sms-universal-fr.templ b/server/templ/sms-universal-fr.templ
index e69de29..0d80c1e 100644
--- a/server/templ/sms-universal-fr.templ
+++ b/server/templ/sms-universal-fr.templ
@@ -0,0 +1,8 @@
+{{/*
+ FRENCH
+
+ Modèle universel de confirmation et de réinitialisation du mot de passe pour SMS.
+*/}}
+
+Code de confirmation Tinode : {{.Code}}
+{{.HostUrl}}
diff --git a/server/templ/sms-universal-pt.templ b/server/templ/sms-universal-pt.templ
index e69de29..1838842 100644
--- a/server/templ/sms-universal-pt.templ
+++ b/server/templ/sms-universal-pt.templ
@@ -0,0 +1,8 @@
+{{/*
+ PORTUGESE
+
+ Modelo universal de confirmação e redefinição de senha para SMS.
+*/}}
+
+Código de confirmação Tinode: {{.Code}}
+{{.HostUrl}}
diff --git a/server/templ/sms-universal-ru.templ b/server/templ/sms-universal-ru.templ
index e69de29..f360d44 100644
--- a/server/templ/sms-universal-ru.templ
+++ b/server/templ/sms-universal-ru.templ
@@ -0,0 +1,8 @@
+{{/*
+ RUSSIAN
+
+ Универсальный шаблон подтверждения и сброса пароля для СМС.
+*/}}
+
+Код подтверждения Tinode: {{.Code}}
+{{.HostUrl}}
diff --git a/server/templ/sms-universal-uk.templ b/server/templ/sms-universal-uk.templ
index e69de29..9cd8547 100644
--- a/server/templ/sms-universal-uk.templ
+++ b/server/templ/sms-universal-uk.templ
@@ -0,0 +1,8 @@
+{{/*
+ UKRAINIAN
+
+ Універсальний шаблон підтвердження та скидання пароля для СМС.
+*/}}
+
+Код підтвердження Tinode: {{.Code}}
+{{.HostUrl}}
diff --git a/server/templ/sms-universal-vi.templ b/server/templ/sms-universal-vi.templ
index e69de29..fdf844a 100644
--- a/server/templ/sms-universal-vi.templ
+++ b/server/templ/sms-universal-vi.templ
@@ -0,0 +1,8 @@
+{{/*
+ VIETNAMESE
+
+ Universal confirmation and password reset template for SMS.
+*/}}
+
+Mã xác thực từ Tinode: {{.Code}}
+{{.HostUrl}}
diff --git a/server/templ/sms-universal-zh-TW.templ b/server/templ/sms-universal-zh-TW.templ
index e69de29..acb1be7 100644
--- a/server/templ/sms-universal-zh-TW.templ
+++ b/server/templ/sms-universal-zh-TW.templ
@@ -0,0 +1,8 @@
+{{/*
+ 繁體中文
+
+ 通用確認和密碼重設簡訊模板。
+*/}}
+
+Tinode 確認驗證碼:{{.Code}}
+{{.HostUrl}}
diff --git a/server/templ/sms-universal-zh.templ b/server/templ/sms-universal-zh.templ
index e69de29..0fac82d 100644
--- a/server/templ/sms-universal-zh.templ
+++ b/server/templ/sms-universal-zh.templ
@@ -0,0 +1,8 @@
+{{/*
+ CHINESE
+
+ Universal confirmation and password reset template for SMS.
+*/}}
+
+【Tinode】验证码: {{.Code}}
+{{.HostUrl}}
diff --git a/server/tinode.conf b/server/tinode.conf
index e69de29..a940e0f 100644
--- a/server/tinode.conf
+++ b/server/tinode.conf
@@ -0,0 +1,689 @@
+// The JSON comments are somewhat brittle. Don't try anything too fancy.
+{
+ // HTTP(S) address to listen on for websocket and long polling clients. Either a TCP host:port pair
+ // or a path to Unix socket as "unix:/path/to/socket.sock".
+ // The TCP port is either a numerical value or a canonical name, e.g. ":80" or ":https". May include
+ // the host name, e.g. "localhost:80" or "hostname.example.com:https".
+ // It could be blank: if TLS is not configured it will default to ":80", otherwise to ":443".
+ // Can be overridden from the command line, see option --listen.
+ "listen": ":6060",
+
+ // Base URL path for serving streaming and large file API calls.
+ // Can be overridden from the command line, see option --api_path.
+ "api_path": "/",
+
+ // Cache-Control header for static content in seconds. 39600 is 11 hours.
+ "cache_control": 39600,
+
+ // If true, do not attempt to negotiate websocket per message compression (RFC 7692.4).
+ // It should be disabled (set to true) if you are using MSFT IIS as a reverse proxy.
+ "ws_compression_disabled": false,
+
+ // URL path for mounting the directory with static files.
+ "static_mount": "/",
+
+ // TCP host:port or unix:/path/to/socket to listen for gRPC clients.
+ // Leave blank to disable gRPC support.
+ // Could be overridden from the command line with --grpc_listen.
+ "grpc_listen": ":16060",
+
+ // Enable handling of gRPC keepalives https://github.com/grpc/grpc/blob/master/doc/keepalive.md
+ // This sets server's GRPC_ARG_KEEPALIVE_TIME_MS to 60 seconds instead of the default 2 hours.
+ "grpc_keepalive_enabled": true,
+
+ // Salt for signing API key. 32 random bytes base64-encoded. Use 'keygen' tool (included in this
+ // distro) to generate the API key and the salt.
+ "api_key_salt": "T713/rYYgW7g4m3vG6zGRh7+FM1t0T8j13koXScOAj4=",
+
+ // Maximum message size allowed from the clients in bytes (131072 = 128KB).
+ // Media files with sizes greater than this limit are sent out of band.
+ // Don't change this limit to a much higher value because it would likely cause failures:
+ // * on Android & iOS due to a limit on the SQLite cursor window size;
+ // * on the server-side with MySQL adapter due to the limit on the sort buffer size.
+ "max_message_size": 131072,
+
+ // Maximum number of subscribers per group topic.
+ // This is the max number of subscribers to a group topic who may be configured to have specific access
+ // permissions, like writing (posting), admin permissions, etc.
+ // This setting is unrelated to channels readers (those have immutable read-only access). Channels have no
+ // limit on readers.
+ "max_subscriber_count": 128,
+
+ // Maximum number of indexable tags per topic or user.
+ "max_tag_count": 16,
+
+ // If true, ordinary users cannot delete their accounts.
+ "permanent_accounts": false,
+
+ // URL path for exposing runtime stats. Disabled if the path is blank or "-".
+ // Could be overriden from the command line with --expvar.
+ "expvar": "/debug/vars",
+
+ // URL path for server's internal status, useful when debugging.
+ // Do not use this URL for docker status checks and some such. It's not a health check,
+ // it is a debug endpoint. Disabled if the path is blank or "-". Could be overriden
+ // from the command line with --server_status.
+ // "server_status": "/debug/status",
+
+ // Read IP address of the client from the HTTP header 'X-Forwarded-For'.
+ // Useful when Tinode is behind a proxy. If missing, fallback to default RemoteAddr.
+ "use_x_forwarded_for": true,
+
+ // Add X-Frame-Options to HTTP response headers. It should be one of "DENY", "SAMEORIGIN",
+ // "-" (disabled). If the option is missing then it's treated as SAMEORIGIN.
+ "x_frame_options": "SAMEORIGIN",
+
+ // 2-letter country code to assign to sessions by default when the country isn't specified
+ // by the client explicitly and it's impossible to infer it.
+ // If missing, the server will default to "US".
+ "default_country_code": "",
+
+ // Permit hard-deleting messages in p2p topics for both participants.
+ // If it's set to 'false' then the message is only deleted for the peer who issued the command.
+ // If it's 'true' then the message is deleted completely by either participant.
+ // Changing the value affects the ability to hard-delete (the added or removed the D permission)
+ // only for new topics going forward.
+ "p2p_delete_enabled": true,
+
+ // The maximum age of a message in seconds when it can be deleted by users with the 'D' permission.
+ // E.g. 600 means messages up to 10 minutes old can be deleted, older than that cannot be deleted.
+ // Missing or 0 means no age limit.
+ // Does not affect topic owners: owners can delete any message.
+ "msg_delete_age": 600,
+
+ // Globally unique namespace. This is a special tag namespace which is used to store
+ // aliases of the user. The alias is a tag which is not a valid Tinode user ID.
+ "alias_tag": "alias",
+
+ // Large media/blob handlers: large files/images included in messages.
+ "media": {
+ // The name of the media handler to use.
+ "use_handler": "fs",
+ // Maximum size of uploaded file (8MB here for testing, maybe increase to 100MB = 104857600 in prod)
+ "max_size": 8388608,
+ // Garbage collection periodicity in seconds: unused or abandoned uploads are deleted.
+ "gc_period": 60,
+ // The number of unused/abandoned entries to delete in one pass.
+ "gc_block_size": 100,
+ // Configurations of individual handlers.
+ "handlers": {
+ // File system storage.
+ "fs": {
+ // File system location to store uploaded files. In case of a cluster it
+ // must be accessible by all cluster members, i.e. a network drive like https://www.samba.org/
+ "upload_dir": "uploads",
+ // Cache-Control header to use for uploaded files. 86400 seconds = 24 hours.
+ "cache_control": "max-age=86400",
+ // Origin URLs allowed to download/upload files, e.g. ["https://www.example.com", "http://example.com", "https://*.example.com", "http://*.*.example.com"].
+ // Not necessary in most cases.
+ // "cors_origins": ["*"]
+ },
+ // Amazon AWS S3 storage.
+ // See detailed explanation at https://pkg.go.dev/github.com/aws/aws-sdk-go/aws#Config
+ "s3":{
+ // Use AWS console to get Access Key ID and Secret Access Key.
+ // https://aws.amazon.com/blogs/security/wheres-my-secret-access-key/
+ "access_key_id": "your_s3_access_key_id",
+ "secret_access_key": "your_s3_secret_access_key",
+ // Region where the bucket is hosted.
+ "region": "s3 region, like us-east-2",
+ // Name of the S3 bucket.
+ "bucket": "your_s3_bucket_name",
+ // Set this to `true` to disable SSL when sending requests. Defaults to `false`.
+ "disable_ssl": false,
+ // Set this to `true` to force the request to use path-style addressing,
+ // i.e., `http://s3.amazonaws.com/BUCKET/KEY`. By default, the S3 client
+ // will use virtual hosted bucket addressing when possible
+ // (`http://BUCKET.s3.amazonaws.com/KEY`).
+ "force_path_style": false,
+ // An optional endpoint URL (hostname only or fully qualified URI)
+ // to override the default generated endpoint, or `""` to use the default generated endpoint.
+ // The endpoint can be of any S3-compatible service, such as "minio-api.x.io".
+ "endpoint": "",
+ // Expiration time for presigned URLs in seconds.
+ "presign_ttl": 3600,
+ // Cache-Control header to use for uploaded files. 86400 seconds = 24 hours.
+ "cache_control": "max-age=86400",
+ // Origin URLs allowed to download files, e.g. ["https://www.example.com", "http://example.com"].
+ // See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin
+ "cors_origins": ["*"]
+ }
+ }
+ },
+
+ // TLS (httpS) configuration. Applies to both web and gRPC interfaces.
+ "tls": {
+ // Enable TLS.
+ "enabled": false,
+
+ // Listen for connections on this port and redirect them to HTTPS port.
+ // Cannot be a Unix socket.
+ "http_redirect": ":80",
+
+ // Add Strict-Transport-Security to headers, the value signifies age.
+ // Zero or negative value turns it off.
+ "strict_max_age": 604800,
+
+ // Letsencrypt configuration.
+ "autocert": {
+ // Location of certificates.
+ "cache": "/etc/letsencrypt/live/your.domain.here",
+
+ // Contact address for this installation. LetsEncrypt will send
+ // messages to this address in case of problems. Replace with your
+ // own address or remove this line.
+ "email": "noreply@example.com",
+
+ // Domains served. Replace with your own domain name.
+ "domains": ["whatever.example.com"]
+ },
+
+ // If "autocert" config is not defined, read static certificates from
+ // these locations. Ignored if "autocert" is defined.
+ "cert_file": "/etc/httpd/conf/your.domain.crt",
+ "key_file": "/etc/httpd/conf/your.domain.key"
+ },
+
+ // Authentication configuration.
+ "auth_config": {
+ // Optional mapping of externally-visible authenticator names to internal names.
+ // For example use ["my-auth:basic", "basic:"] to rename "basic" authenticator to
+ // "my-auth" and make "basic" unaccessible by the old name. If you want to use REST-auth, then
+ // the config is ["basic:rest", "rest:"].
+ // Default is identity mapping.
+ "logical_names": [],
+
+ // Basic (login + password) authentication.
+ "basic": {
+ // Add 'auth-name:username' to tags making user discoverable by username.
+ "add_to_tags": true,
+ // The minimum length of a login in unicode runes, i.e. "登录" is length 2, not 6.
+ // The maximum length is 32 and it cannot be changed.
+ "min_login_length": 4,
+ // The minimum length of a password in unicode runes, "пароль" is length 6, not 12.
+ // There is no limit on maximum length, but MySQL & PgSQL adapters have a limit of 32 bytes.
+ "min_password_length": 6
+ },
+
+ // Token authentication
+ "token": {
+ // Lifetime of a security token in seconds. 1209600 = 2 weeks.
+ "expire_in": 1209600,
+
+ // Serial number of the token. Can be used to invalidate all issued tokens at once.
+ "serial_num": 1,
+
+ // Secret key (HMAC salt) for signing the tokens. Generate your own then keep it secret.
+ // Any 32 random bytes base64 encoded.
+ //
+ // === IMPORTANT ===
+ //
+ // CHANGE IT IN PRODUCTION!!! Otherwise anyone will be able to log in
+ // to your server without the password. It's just random base64-encoded bytes, use any suitable
+ // means to get it. For example:
+ // Linux/Mac:
+ // echo $(head -c 32 /dev/urandom | base64 | tr -d '\n')
+ // Windows:
+ // powershell -command "[Convert]::ToBase64String((1..32|%{[byte](Get-Random -Max 256)}))"
+ "key": "wfaY2RgF2S1OQI/ZlK+LSrp1KB2jwAdGAIHQ7JZn+Kc="
+ },
+
+ // Short code authenticator for resetting passwords.
+ "code": {
+ // Lifetime of a security code in seconds. 900 seconds = 15 minutes.
+ "expire_in": 900,
+
+ // Number of times a user can try to enter the code.
+ "max_retries": 3,
+
+ // Length of the secret code.
+ "code_length": 6
+ }
+ },
+
+ // Database configuration
+ "store_config": {
+ // XTEA encryption key for user IDs and topic names. 16 random bytes base64-encoded.
+ // Generate your own and keep it secret. Otherwise your user IDs will be predictable
+ // and it will be easy to spam your users.
+ "uid_key": "la6YsO+bNX/+XIkOqc5Svw==",
+
+ // Maximum number of results fetched in one DB call.
+ "max_results": 1024,
+
+ // DB adapter name to communicate with the DB backend.
+ // Must be one of the adapters from the list below.
+ "use_adapter": "mysql",
+
+ // Configurations of individual adapters.
+ "adapters": {
+ // PostgreSQL configuration. See https://godoc.org/github.com/jackc/pgx#Config
+ // for other possible options.
+ "postgres": {
+ // PostgreSQL connection settings.
+ // Don't change the username before reading the FAQ!
+ "User": "postgres",
+ "Passwd": "postgres",
+ "Host": "localhost",
+ "Port": "5432",
+ "DBName": "tinode",
+ "SSLMode": "disable",
+
+ // DSN: alternative way of specifying database configuration, passed unchanged
+ // to the driver. See https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING
+ // "dsn": "postgresql://postgres:postgres@localhost:5432/tinode?sslmode=disable&connect_timeout=10",
+
+ // PostgreSQL connection pool settings.
+ // Maximum number of open connections to the database. Zero means unlimited.
+ "max_open_conns": 50,
+ // Maximum number of connections in the idle connection pool. Zero means no idle connections are retained.
+ "max_idle_conns": 50,
+ // Maximum amount of time a connection may be reused. Zero means unlimited.
+ "conn_max_lifetime": 60,
+ // Maximum amount of time waiting for a connection from the pool. Zero means no timeout.
+ "sql_timeout": 10
+ },
+
+
+ // MySQL configuration. See https://godoc.org/github.com/go-sql-driver/mysql#Config
+ // for other possible options.
+ "mysql": {
+ // MySQL connection settings via DSN.
+ // See https://github.com/go-sql-driver/mysql#dsn-data-source-name for syntax.
+ "dsn": "mysql://root:oWxz*2=sb),ouW%p@tcp(120.25.72.121:53306)/tinode?parseTime=true&collation=utf8mb4_0900_ai_ci",
+ // MySQL connection pool settings.
+ // Maximum number of open connections to the database. Default: 0 (unlimited).
+ "max_open_conns": 64,
+ // Maximum number of connections in the idle connection pool. If negative or zero,
+ // no idle connections are retained.
+ "max_idle_conns": 64,
+ // Maximum amount of time a connection may be reused (in seconds).
+ "conn_max_lifetime": 60,
+
+ // DB request timeout (in seconds).
+ // If not set (or <= 0), DB queries and transactions will run without a timeout.
+ "sql_timeout": 10
+ },
+
+ // RethinkDB configuration. See
+ // https://godoc.org/github.com/rethinkdb/rethinkdb-go#ConnectOpts for other possible
+ // options.
+ "rethinkdb": {
+ // Address(es) of RethinkDB node(s): either a string or an array of strings.
+ "addresses": "localhost:28015",
+ // Name of the main database.
+ "database": "tinode"
+ },
+
+ // MongoDB configuration.
+ "mongodb": {
+ // Connection string https://www.mongodb.com/docs/manual/reference/connection-string/
+ // Options configured with the 'uri' connection string override all other options
+ // (only 'uri' is sent to the server, all other options are ignored).
+ // If you are using Atlas, then you MUST use 'uri' to connect. See here:
+ // https://www.mongodb.com/docs/manual/reference/connection-string/#std-label-connections-dns-seedlist
+ // Something like
+ // "uri": "mongodb+srv://CREDENTIALS@PROJECT.gmuaq.mongodb.net/DATABASE?retryWrites=true&w=majority",
+ "uri": "",
+ // The only supported server API version is "1". May or maynot be needed depending on server version.
+ "api_version": "",
+
+ // Address(es) of MongoDB node(s): either a string or an array of strings.
+ "addresses": "localhost:27017",
+ // Name of the main database.
+ "database": "tinode",
+ // Name of replica set of mongodb instance. Remove this line to use a standalone instance.
+ // If replica_set is disabled, transactions will be disabled as well.
+ "replica_set": "rs0",
+
+ // Authentication options. Uncomment if auth is configured on your MongoDB.
+
+ // Authentication mechanism. See https://www.mongodb.com/docs/manual/core/authentication/
+ // Default "SCRAM-SHA-256"
+ // "auth_mechanism": "SCRAM-SHA-256",
+
+ // The name of database that has the collection with the user credentials. Default "admin".
+ // "auth_source": "admin",
+
+ // Username:
+ // "username": "tinode",
+
+ // Password:
+ // "password": "tinode",
+
+ // Driver's TLS configuration. Uncomment to enable TLS.
+ // "tls": true,
+
+ // Path to the client certificate. Optional.
+ // "tls_cert_file": "/path/to/cert_file",
+
+ // Path to private key. Optional.
+ // "tls_private_key": "/path/to/private_key",
+
+ // Specifies whether or not certificates and hostnames received from the server should be validated.
+ // Not recommended to enable in production. Default is false.
+ // "tls_skip_verify": false
+ }
+ }
+ },
+
+ // Account validators (email or SMS or captcha).
+ "acc_validation": {
+
+ // Email validator config.
+ "email": {
+ // Restrict use of "email" namespace: make users searchable by their emails,
+ // disable manual creation of email: tags.
+ "add_to_tags": true,
+
+ // List of authentication levels which require this validation method.
+ // Remove this line to disable email validation.
+ "required": ["auth"],
+
+ // Configuration passed to the validator unchanged.
+ "config": {
+ // Address of the host where the Tinode server is running. This will be used
+ // in URLs in the email.
+ "host_url": "http://localhost:6060/",
+
+ // Address of the SMPT server to use.
+ "smtp_server": "smtp.example.com",
+
+ // SMTP port to use. "25" for basic email RFC 5321 (2821, 821), "587" for RFC 3207 (TLS).
+ "smtp_port": "25",
+
+ // RFC 5322 email address to show in the From: field.
+ "sender": "\"Tinode\" ",
+
+ // Optional login to use for authentication; if missing, the connection is not authenticated.
+ "login": "john.doe@example.com",
+
+ // Password to use when authenticating the sender; used only if "login" is provided.
+ "sender_password": "your-password-here",
+
+ // Authentication mechanism to use, optional. One of "login", "cram-md5", "plain" (default).
+ "auth_mechanism": "login",
+
+ // FQDN to use in SMTP HELO/EHLO command; if missing, the hostname from "host_url" is used.
+ "smtp_helo_host": "example.com",
+
+ // Skip verification of the server's certificate chain and host name.
+ // In this mode, TLS is susceptible to man-in-the-middle attacks.
+ "insecure_skip_verify": false,
+
+ // Optional list of human languages to try to load templates for. If you don't care about i18n,
+ // leave it blank or remove. The first language in the list is the default language.
+ "languages": ["en", "es", "fr", "pt", "ru", "uk", "vi", "zh", "zh-TW"],
+
+ // Message template for credential validation.
+ // The file path itself is treated as a template. It's resolved by using the
+ // "languages" field above. One template per language.
+ // See the template file for the explanation of the expected structure.
+ "validation_templ": "./templ/email-validation-{{.Language}}.templ",
+
+ // Message template for resetting authentication secret.
+ // One template per language. See email-validation-en template for the explanation
+ // of the expected structure.
+ "reset_secret_templ": "./templ/email-password-reset-{{.Language}}.templ",
+
+ // Allow this many confirmation attempts before blocking the credential.
+ "max_retries": 3,
+
+ // List of email domains allowed to be used for registration.
+ // Missing or empty list means any email domain is accepted.
+ "domains": [],
+
+ // Dummy response to accept.
+ //
+ // === IMPORTANT ===
+ //
+ // REMOVE IN PRODUCTION!!! Otherwise anyone will be able to register
+ // with fake emails.
+ "debug_response": "123456"
+ }
+ },
+
+ // Placeholder validator for SMS and voice validation. Disabled by default.
+ // Use something like twilio.com or sinch.com in production.
+ "tel": {
+ "add_to_tags": true,
+ "config": {
+ // Address of the host where the Tinode server is running. This will be used
+ // in URLs in the SMS.
+ "host_url": "http://localhost:6060/",
+
+ // Optional list of locales to try to load templates for. If you don't care about i18n,
+ // leave it blank or remove. The first language in the list is the default language.
+ "languages": ["en", "es", "fr", "pt", "ru", "uk", "vi", "zh", "zh-TW"],
+
+ // String to use in the From field of the SMS.
+ "sender": "Tinode",
+
+ // Message template for credential validation and password reset. The file path itself is
+ // treated as a template. It's resolved by using the "languages" field above. One template
+ // per language.
+ "universal_templ": "./templ/sms-universal-{{.Language}}.templ",
+
+ // Allow this many confirmation attempts before blocking the credential.
+ "max_retries": 3,
+
+ // Twilio configuration (optional).
+ //"twilio_conf": {
+ // "account_sid": "ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
+ // "auth_token": "f2xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+ //},
+
+ // Dummy response to accept.
+ //
+ // === IMPORTANT ===
+ //
+ // REMOVE IN PRODUCTION!!! Otherwise anyone will be able to register
+ // with fake phone numbers.
+ "debug_response": "123456"
+ }
+ }
+ },
+
+ // Configuration for stale account garbage collector: remove
+ // stale unvalidated user accounts which have been last updated at least
+ // 'gc_min_account_age' hours ago.
+ "acc_gc_config": {
+ "enabled": true,
+ // How often to run GC (seconds).
+ "gc_period": 3600,
+ // Number of accounts to delete in one pass.
+ "gc_block_size": 10,
+ // Minimum hours since account was last modified.
+ "gc_min_account_age": 30
+ },
+
+ // Configuration of push notifications.
+ "push": [
+ {
+ // Notificator which writes to STDOUT. Useful for debugging.
+ "name":"stdout",
+ "config": {
+ // Disabled.
+ "enabled": false
+ }
+ },
+ {
+ // Google FCM notificator.
+ "name":"fcm",
+ "config": {
+ // Disabled. Won't work without the server key anyway. See below.
+ "enabled": false,
+
+ // Firebase project ID.
+ "project_id": "your-project-id",
+
+ // Service account credentials as json.
+ // See instructions how to download the service account credentials file:
+ // https://cloud.google.com/iam/docs/creating-managing-service-account-keys
+ // Then insert the file contents here. Yes, this is convoluted, but that's Google's fault.
+ "credentials": {
+ "type": "service_account",
+ "project_id": "your-project-id",
+ "private_key_id": "some-random-looking-hex-number",
+ "private_key": "-----BEGIN PRIVATE KEY----- base64-encoded bits of your private key \n-----END PRIVATE KEY-----\n",
+ "client_email": "firebase-adminsdk-abc123@your-project-id.iam.gserviceaccount.com",
+ "client_id": "1234567890123456789",
+ "auth_uri": "https://accounts.google.com/o/oauth2/auth",
+ "token_uri": "https://oauth2.googleapis.com/token",
+ "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
+ "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-abc123%40your-project-id.iam.gserviceaccount.com"
+ },
+
+ // An alternative way to provide Firebase service account credentials.
+ "credentials_file": "/path/to/service-account-file-with-credentials.json",
+
+ // Time in seconds before notification is discarded (by Google) if undelivered.
+ "time_to_live": 3600,
+
+ // Payload of AndroidNotification. If enabled, this will take precedence over data payload.
+ "android": {
+ // Set to false to push a data-only message.
+ "enabled": false,
+
+ // Android drawable resource ID to use as a notification icon.
+ "icon": "ic_logo_push",
+
+ // Notification color.
+ "color": "#3949AB",
+
+ // Name of intent filter which will catch this notification.
+ "click_action": ".MessageActivity",
+
+ // Notification of a new message. You can include custom "icon", "color", "click_action"
+ // into this section and it will override the value above.
+ "msg": {
+ // Literal title string. Not recommended because it's not localized.
+ "title": "",
+
+ // Literal message body. Not recommended because it's not localized.
+ "body": "",
+
+ // Android string resource ID to use as a notification title. Localized.
+ // Takes precedence over "title". "new_message" is "New message" in Tindroid.
+ "title_loc_key": "new_message",
+
+ // Android string resource ID to use as a notification body. Localized.
+ // Takes precedence over "body".
+ "body_loc_key": ""
+ },
+
+ // Notification of a new subscription. Same rules as section "msg" above.
+ "sub": {
+ // Android resource string ID to use as notification title. Localized.
+ // "new_chat" is "New chat" in Tindroid.
+ "title_loc_key": "new_chat",
+
+ // Android resource string ID to use as notification body. Localized.
+ "body_loc_key": ""
+ }
+ }
+ }
+ },
+ {
+ // Tinode Push Gateway, see https://github.com/tinode/chat/tree/master/server/push/tnpg.
+ "name":"tnpg",
+ "config": {
+ // Disabled. Configure first then enable.
+ "enabled": false,
+ // Short name (URL) of the organization you registered at console.tinode.co.
+ "org": "test",
+ // Authentication token obtained from console.tinode.co
+ "token": "jwt-security-token-obtained-from-console.tinode.co",
+ }
+ }
+ ],
+
+ // Configuration for voice and video calls.
+ "webrtc": {
+ // Disabled. Won't work without functioning ice_servers (see below).
+ "enabled": false,
+ // Timeout in seconds before a video/voice call is dropped if not answered.
+ "call_establishment_timeout": 30,
+ // Interactive Communication Establishment (ICE) STUN and TURN server configuration for video calls.
+ // You need to configure your own servers or consider https://www.metered.ca/tools/openrelay/.
+ // Video calls will not work if both parties are behind NAT and no ICE servers are configured.
+ "ice_servers": [
+ {
+ "urls": [
+ "stun:stun.example.com"
+ ]
+ },
+ {
+ "username": "user-name-to-use-for-authentication-with-the-server",
+ "credential": "your-password",
+ "urls": [
+ "turn:turn.example.com:80?transport=udp",
+ "turn:turn.example.com:3478?transport=udp",
+ "turn:turn.example.com:80?transport=tcp",
+ "turn:turn.example.com:3478?transport=tcp",
+ "turns:turn.example.com:443?transport=tcp",
+ "turns:turn.example.com:5349?transport=tcp"
+ ]
+ }
+ ],
+ // An alternative way to provide STUN/TURN configuration.
+ "ice_servers_file": "/path/to/ice-servers-config.json"
+ },
+
+ // Cluster-mode configuration.
+ "cluster_config": {
+ // Name of this node. Can be assigned from the command line as --cluster_self.
+ // Empty string disables clustering.
+ "self": "",
+
+ // List of available nodes.
+ "nodes": [
+ // Name and TCP address of every node in the cluster. The ports 12001..12003
+ // are cluster communication ports. They don't need to be exposed to end-users.
+ {"name": "one", "addr":"localhost:12001"},
+ {"name": "two", "addr":"localhost:12002"},
+ {"name": "three", "addr":"localhost:12003"}
+ ],
+
+ // Failover config. No need to change unless you are doing something unusual.
+ "failover": {
+ // Failover is enabled.
+ "enabled": true,
+ // Time in milliseconds between heartbeats.
+ "heartbeat": 100,
+ // Initiate leader election when the leader is not available for this many heartbeats.
+ "vote_after": 8,
+ // Consider node failed when it missed this many heartbeats.
+ "node_fail_after": 16
+ }
+ },
+
+ // Configuration of plugins.
+ "plugins": [
+ {
+ // Enable or disable this plugin.
+ "enabled": false,
+
+ // Name of the plugin, must be unique.
+ "name": "python_chat_bot",
+
+ // Timeout in microseconds.
+ "timeout": 20000,
+
+ // Events to send to the plugin.
+ "filters": {
+ // Account creation events.
+ "account": "C"
+ },
+
+ // Error code to use in case plugin has failed; 0 means to ignore the failures.
+ "failure_code": 0,
+
+ // Text of an error message to report in case of plugin failure.
+ "failure_text": null,
+
+ // Address of the plugin.
+ "service_addr": "tcp://localhost:40051"
+ }
+ ]
+}