chore: seed migration objects batch 19
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
# Convert git tag like "v0.15.5-rc5-3-g2084bd63" to PEP 440 version like "0.15.5rc5.post3"
|
||||
|
||||
from subprocess import check_output
|
||||
|
||||
command = 'git describe --tags'
|
||||
|
||||
def git_version():
|
||||
line = check_output(command.split()).decode('utf-8').strip()
|
||||
if line.startswith("v"):
|
||||
line = line[1:]
|
||||
if '-rc' in line:
|
||||
line = line.replace('-rc', 'rc')
|
||||
if '-beta' in line:
|
||||
line = line.replace('-beta', 'b')
|
||||
if '-alpha' in line:
|
||||
line = line.replace('-alpha', 'a')
|
||||
if '-' in line:
|
||||
parts = line.split('-')
|
||||
line = parts[0] + '.post' + parts[1]
|
||||
return line
|
||||
|
||||
if __name__ == '__main__':
|
||||
with open('tinode_grpc/GIT_VERSION','w+') as fh:
|
||||
fh.write(git_version())
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Example of a REST authenticator server.
|
||||
|
||||
This is an example of a server-side [REST authenticator](../server/auth/rest/). It's a basic Python script meant to be run as a web server. It implements the required endpoints. It responds to all requests with dummy data.
|
||||
|
||||
The service uses [Flask](http://flask.pocoo.org/), so make sure it's installed:
|
||||
```
|
||||
pip install flask
|
||||
```
|
||||
Run the service as
|
||||
```
|
||||
python auth.py
|
||||
```
|
||||
|
||||
Regular → Executable
+125
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/python
|
||||
|
||||
# Sample Tinode REST/JSON-RPC authentication service.
|
||||
# See https://github.com/tinode/chat/rest-auth for details.
|
||||
|
||||
from flask import Flask, jsonify, make_response, request
|
||||
import base64
|
||||
import json
|
||||
|
||||
dummy_data = {}
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
def parse_secret(ecoded_secret):
|
||||
secret = base64.b64decode(ecoded_secret)
|
||||
return secret.split(':')
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
return 'Sample Tinode REST/JSON-RPC authentication service. '+\
|
||||
'See <a href="https://github.com/tinode/chat/rest-auth/">https://github.com/tinode/chat/rest-auth/</a> for details.'
|
||||
|
||||
@app.route('/add', methods=['POST'])
|
||||
def add():
|
||||
return jsonify({'err': 'unsupported'})
|
||||
|
||||
@app.route('/auth', methods=['POST'])
|
||||
def auth():
|
||||
if not request.json:
|
||||
return jsonify({'err': 'malformed'})
|
||||
uname, password = parse_secret(request.json.get('secret'))
|
||||
if uname in dummy_data:
|
||||
if dummy_data[uname]['password'] != password:
|
||||
# Wrong password
|
||||
return jsonify({'err': 'failed'})
|
||||
if 'uid' in dummy_data[uname]:
|
||||
# We have uname -> uid mapping
|
||||
return jsonify({
|
||||
'rec': {
|
||||
'uid': dummy_data[uname]['uid'],
|
||||
'authlvl': dummy_data[uname]['authlvl'],
|
||||
'features': dummy_data[uname]['features']
|
||||
}
|
||||
})
|
||||
else:
|
||||
# This is the first login. Tell Tinode to create a new account.
|
||||
return jsonify({
|
||||
'rec': {
|
||||
'authlvl': dummy_data[uname]['authlvl'],
|
||||
'tags': dummy_data[uname]['tags'],
|
||||
'features': dummy_data[uname]['features']
|
||||
},
|
||||
'newacc': {
|
||||
'auth': dummy_data[uname]['auth'],
|
||||
'anon': dummy_data[uname]['anon'],
|
||||
'public': dummy_data[uname]['public'],
|
||||
'private': dummy_data[uname]['private']
|
||||
}
|
||||
})
|
||||
return jsonify({'err': 'unsupported'})
|
||||
else:
|
||||
return jsonify({'err': 'not found'})
|
||||
|
||||
@app.route('/checkunique', methods=['POST'])
|
||||
def checkunique():
|
||||
return jsonify({'err': 'unsupported'})
|
||||
|
||||
@app.route('/del', methods=['POST'])
|
||||
def xdel():
|
||||
return jsonify({'err': 'unsupported'})
|
||||
|
||||
@app.route('/gen', methods=['POST'])
|
||||
def gen():
|
||||
return jsonify({'err': 'unsupported'})
|
||||
|
||||
@app.route('/link', methods=['POST'])
|
||||
def link():
|
||||
if not request.json:
|
||||
return jsonify({'err': 'malformed'})
|
||||
|
||||
rec = request.json.get('rec', None)
|
||||
secret = request.json.get('secret', '')
|
||||
if not rec or not rec['uid'] or not secret:
|
||||
return jsonify({'err': 'malformed'})
|
||||
|
||||
# Save the link account <-> secret to database.
|
||||
uname, password = parse_secret(secret)
|
||||
if uname not in dummy_data:
|
||||
# Unknown user name
|
||||
return jsonify({'err': 'not found'})
|
||||
if 'uid' in dummy_data[uname]:
|
||||
# Already linked
|
||||
return jsonify({'err': 'duplicate value'})
|
||||
|
||||
# Save updated data to file
|
||||
dummy_data[uname]['uid'] = rec['uid']
|
||||
with open('dummy_data.json', 'w') as outfile:
|
||||
json.dump(dummy_data, outfile, indent=2, sort_keys=True)
|
||||
|
||||
# Success
|
||||
return jsonify({})
|
||||
|
||||
@app.route('/upd', methods=['POST'])
|
||||
def upd():
|
||||
return jsonify({'err': 'unsupported'})
|
||||
|
||||
@app.route('/rtagns', methods=['POST'])
|
||||
def rtags():
|
||||
# Return dummy namespace "rest" and "email", let client check logins by regular expression.
|
||||
return jsonify({'strarr': ['rest', 'email'], 'byteval': base64.b64encode('^[a-z0-9_]{3,8}$')})
|
||||
|
||||
@app.errorhandler(404)
|
||||
def not_found(error):
|
||||
return make_response(jsonify({'err': 'not found'}), 404)
|
||||
|
||||
@app.errorhandler(405)
|
||||
def not_found(error):
|
||||
return make_response(jsonify({'err': 'method not allowed'}), 405)
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Load previously saved dummy data. Dummy data contains
|
||||
# tinode user id <-> user name mapping and data for account creation.
|
||||
with open('dummy_data.json') as infile:
|
||||
dummy_data = json.load(infile)
|
||||
app.run(debug=True)
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
{
|
||||
"alice": {
|
||||
"anon": "N",
|
||||
"auth": "JRWPA",
|
||||
"authlvl": "auth",
|
||||
"features": "V",
|
||||
"password": "alice123",
|
||||
"private": "email:bob@example.com,email:carol@example.com,email:dave@example.com,email:eve@example.com,email:frank@example.com",
|
||||
"public": {
|
||||
"fn": "Alice Johnson",
|
||||
"photo": {
|
||||
"data": "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAgACADASIAAhEBAxEB/8QAGQAAAwEBAQAAAAAAAAAAAAAAAQUGAwQI/8QAKRAAAgEDAgQFBQAAAAAAAAAAAQIDAAQRBTEGIUFREhMUYYEiQnGCo//EABYBAQEBAAAAAAAAAAAAAAAAAAABAv/EAB4RAAICAgMBAQAAAAAAAAAAAAECABEDIRNB4YGR/9oADAMBAAIRAxEAPwD1TQ5YoZ5ikvFNzJBpywWsvl3d3ItvEw3Utuw91UM361CaFzSKXYKO475GjSLhS9kvdKX1bq95bs1vO22XQ4Jx0zgN+CKd53oDYuHQoxU9QNvjoaj9Tj1DU+MFGny28cOlw/U00RkBmk7AMMFUH9asCcVhDDFG80kcaI0z+Nyox4jgLk9zgAfAoy3qbxZeMlgN1X75cl9GF7pXFdxbahJbyDU4/URtDEY18yMBXGCx5lSh3+01Y965praKWeCWREaSFiY2ZQSpIwSD05Ej5rUNge+9FFakzZeQhq3W/nlT/9k=",
|
||||
"type": "jpeg"
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"email:alice@example.com"
|
||||
]
|
||||
},
|
||||
"bob": {
|
||||
"anon": "N",
|
||||
"auth": "JRWPA",
|
||||
"authlvl": "auth",
|
||||
"features": "V",
|
||||
"password": "bob123",
|
||||
"private": "email:alice@example.com,email:carol@example.com,email:dave@example.com,email:eve@example.com,email:frank@example.com",
|
||||
"public": {
|
||||
"fn": "Bob Smith",
|
||||
"photo": {
|
||||
"data": "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAgACADASIAAhEBAxEB/8QAGgABAAEFAAAAAAAAAAAAAAAABQMAAQIECP/EACkQAAEDAgQFBAMAAAAAAAAAAAECAwQAEQUSITETIjJBgUJRUnFhofD/xAAXAQEBAQEAAAAAAAAAAAAAAAAAAgED/8QAHxEAAQUBAAIDAAAAAAAAAAAAEQABAgMSMXGhE2Hw/9oADAMBAAIRAxEAPwDqisb+4FWvv4obFC65i8RlD7rLamXVnIQLkKQBuPyaicsMQudk8RIPPacBvtVeKEw5x5M5yOp9choNhZUoDMg32JAA286U3SE9MUrntiFHbTXqIoTForMrHYKJLaFpDDx5hf1N07/CtSbAizVIMuOy8UdPEQFW+r1lsNsPCm2v5IZB536dFxG2oWLJjYblDRbUp1pJ5UG4ym3Ym6vvxT99NRUEWMxFa4cdpDSPigAD9VOe9zSuGGH5kprww9NxvC//2Q==",
|
||||
"type": "jpeg"
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"email:bob@example.com"
|
||||
]
|
||||
},
|
||||
"carol": {
|
||||
"anon": "N",
|
||||
"auth": "JRWPA",
|
||||
"authlvl": "auth",
|
||||
"features": "V",
|
||||
"password": "carol123",
|
||||
"private": "email:alice@example.com,email:bob@example.com,email:dave@example.com,email:eve@example.com,email:frank@example.com",
|
||||
"public": {
|
||||
"fn": "Carol Xmas",
|
||||
"photo": {
|
||||
"data": "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAgACADASIAAhEBAxEB/8QAGQAAAwEBAQAAAAAAAAAAAAAAAQUGBAMI/8QAJRAAAQQCAQMEAwAAAAAAAAAAAQIDBAUAERITIVEGMUFxFCJh/8QAFQEBAQAAAAAAAAAAAAAAAAAAAQL/xAAdEQEAAgEFAQAAAAAAAAAAAAABABECAxIhMcFB/9oADAMBAAIRAxEAPwD1RrAO31hyPkuzL71JPr4816DXVnBEhUfiHXnlpC+HIg8UhBSTrSiVDuAP2cNPdfNBywWpYYcU1Ncuv6iVWE2WlWilMlSVdP6ISD3/AKT7feNsEBobjB8ZH0zyK71vfwJJ6blk43YRSo9nQGW2VpT5KS0CR4cTlhmC0qoFqwGbKHGlsg8g2+0HEg+dH5ysMjGx6SvfIJNgcSVFOxyA2RnTFlVS1tQlxNVXxIYcIK/x2Q3y17b0O+M8lr51Gf/Z",
|
||||
"type": "jpeg"
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"email:carol@example.com"
|
||||
]
|
||||
},
|
||||
"dave": {
|
||||
"anon": "N",
|
||||
"auth": "JRWPA",
|
||||
"authlvl": "auth",
|
||||
"features": "V",
|
||||
"password": "dave123",
|
||||
"private": "email:alice@example.com,email:bob@example.com,email:carol@example.com,email:eve@example.com,email:frank@example.com",
|
||||
"public": {
|
||||
"fn": "Dave Goliathsson",
|
||||
"photo": {
|
||||
"data": "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAgACADASIAAhEBAxEB/8QAGQAAAwADAAAAAAAAAAAAAAAAAAQFAgMI/8QAIxAAAgIBAwQDAQAAAAAAAAAAAQIAAxEEITEFEhNBYXOxof/EABcBAQEBAQAAAAAAAAAAAAAAAAECAAP/xAAcEQACAwADAQAAAAAAAAAAAAABAgADERIxwUH/2gAMAwEAAhEDEQA/AOqMQ9wkxksu6lfX57URKkICEcktn18CcbH45g3YgSpCT6bbKtSlF9nkDglHIwduQcbShKRwwhCIVbdX1X01frx6LXaPTXv33UVWPjGXQE4kWKzYV+Hwj2Ii5ZdT1GrxN3JSGLMOMnYD9/kozBFWtQqAADgATZGusrpPZmM//9k=",
|
||||
"type": "jpeg"
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"email:dave@example.com"
|
||||
]
|
||||
},
|
||||
"eve": {
|
||||
"anon": "N",
|
||||
"auth": "JRWPA",
|
||||
"authlvl": "auth",
|
||||
"features": "V",
|
||||
"password": "eve123",
|
||||
"private": "email:alice@example.com,email:bob@example.com,email:carol@example.com,email:dave@example.com,email:frank@example.com",
|
||||
"public": {
|
||||
"fn": "Eve Adams",
|
||||
"photo": {
|
||||
"data": "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAgACADASIAAhEBAxEB/8QAGQAAAwEBAQAAAAAAAAAAAAAAAQUGAgQI/8QAIxAAAQQCAAcBAQAAAAAAAAAAAgEDBAUAEQYSEyExYXFBkf/EABUBAQEAAAAAAAAAAAAAAAAAAAIB/8QAGxEAAwEBAQEBAAAAAAAAAAAAAQIRABIhA3H/2gAMAwEAAhEDEQA/APVKZlfiZrymSt6EiVxJAhtzpMZgor7pIwSIpEJtIm9ov4RYkXozJE6Ms1R81h/MV1dYcIjIp8yTzJrT5oqD80iY0yEAHzRgAfDcFyUv4EWfxhVtzY7MhsYMgkF0EJEXqM9++VZeMXWVRXWhtLZQIkvp7QFfZFzl351tO3hP5iRuTcvk/DXGtqYFdzrXw2I5Hrm6QIO9eN6zvL1rF1dS1taZnXwIcUyTREwyLaqnvSYyX3kJptuLmm2/u//Z",
|
||||
"type": "jpeg"
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"email:eve@example.com"
|
||||
]
|
||||
},
|
||||
"frank": {
|
||||
"anon": "N",
|
||||
"auth": "JRWPA",
|
||||
"authlvl": "auth",
|
||||
"features": "V",
|
||||
"password": "frank123",
|
||||
"private": "email:alice@example.com,email:bob@example.com,email:carol@example.com,email:dave@example.com,email:eve@example.com",
|
||||
"public": {
|
||||
"fn": "Frank Singer",
|
||||
"photo": {
|
||||
"data": "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAgACADASIAAhEBAxEB/8QAGQAAAgMBAAAAAAAAAAAAAAAAAAUCAwQI/8QAJxAAAQQBAgUEAwAAAAAAAAAAAQACAwQREjEFEyFBgQYUU2FxcpH/xAAXAQADAQAAAAAAAAAAAAAAAAAAAQID/8QAHBEBAAMBAQADAAAAAAAAAAAAAQACEQMSUaHh/9oADAMBAAIRAxEAPwDqfHQoIwcdkZ3SXibZZeMVK0diaKJ0ErzyyBkh0YHb7KYazPpfxXc39joeFJYaVR9bUXWZ5tXyuBx+MBbUMdVTUyBCQ8TrR2vUNBkzGvb7aY4P7RJ605VLomGZspa3WGlodjqAdxnwP4nVx2T15nSvl+T6ZCpSgqauRG1mrfHdat0I8qV2XWpUwJ//2Q==",
|
||||
"type": "jpeg"
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"email:frank@example.com"
|
||||
]
|
||||
},
|
||||
"xena": {
|
||||
"anon": "N",
|
||||
"auth": "JRWPA",
|
||||
"authlvl": "root",
|
||||
"features": "V",
|
||||
"password": "xena123",
|
||||
"private": "",
|
||||
"public": {
|
||||
"fn": "Xena Peaceful Peasant",
|
||||
"photo": {
|
||||
"data": "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAgACADASIAAhEBAxEB/8QAGAABAQEBAQAAAAAAAAAAAAAABgAEBQj/xAArEAABAwMCBAQHAAAAAAAAAAABAgMEAAUREjEGISJhNEFRcRQjJTJCQ4L/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8A9U1VUeukuRar/EffcUu1TMRVg7R3s/LX7LzoPfR6mgQ1Ue4fmSbvOl3FLhTavDxG88ncHre9ieSeyc/lyQ0FRTi36y43ww2fGtlyaobtRQcHHopZ6R/RH20rrM1GYakOvtstIfeADiwkBS8bZPnjJoOBwbIcYadsM7R8baghoEJwHmP1OgbcwMEDZST5YpRWZUZgy0yiy2ZSUFsO6RqCSQSnO+MgHHatNB//2Q==",
|
||||
"type": "jpeg"
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"email:xena@example.com"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
flask>=1.1.0
|
||||
|
||||
@@ -38,7 +38,10 @@ func (a *authenticator) IsInitialized() bool {
|
||||
|
||||
// AddRecord checks authLevel and assigns default LevelAnon. Otherwise it
|
||||
// just reports success.
|
||||
func (authenticator) AddRecord(rec *auth.Rec, secret []byte, remoteAddr string) (*auth.Rec, error) {
|
||||
func (authenticator) AddRecord(ctx auth.AuthContext, rec *auth.Rec, secret []byte) (*auth.Rec, error) {
|
||||
if err := auth.BindRecord(ctx, rec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rec.AuthLevel == auth.LevelNone {
|
||||
rec.AuthLevel = auth.LevelAnon
|
||||
}
|
||||
@@ -47,12 +50,12 @@ func (authenticator) AddRecord(rec *auth.Rec, secret []byte, remoteAddr string)
|
||||
}
|
||||
|
||||
// UpdateRecord is a noop. Just report success.
|
||||
func (authenticator) UpdateRecord(rec *auth.Rec, secret []byte, remoteAddr string) (*auth.Rec, error) {
|
||||
return rec, nil
|
||||
func (authenticator) UpdateRecord(ctx auth.AuthContext, rec *auth.Rec, secret []byte) (*auth.Rec, error) {
|
||||
return rec, auth.BindRecord(ctx, rec)
|
||||
}
|
||||
|
||||
// Authenticate is not supported. This authenticator is used only at account creation time.
|
||||
func (authenticator) Authenticate(secret []byte, remoteAddr string) (*auth.Rec, []byte, error) {
|
||||
func (authenticator) Authenticate(ctx auth.AuthContext, secret []byte) (*auth.Rec, []byte, error) {
|
||||
return nil, nil, types.ErrUnsupported
|
||||
}
|
||||
|
||||
@@ -62,7 +65,10 @@ func (authenticator) AsTag(token string) string {
|
||||
}
|
||||
|
||||
// IsUnique for a noop. Anonymous login does not use secret, any secret is fine.
|
||||
func (authenticator) IsUnique(secret []byte, remoteAddr string) (bool, error) {
|
||||
func (authenticator) IsUnique(ctx auth.AuthContext, secret []byte) (bool, error) {
|
||||
if !ctx.IsValid() {
|
||||
return false, types.ErrMalformed
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@@ -72,7 +78,7 @@ func (authenticator) GenSecret(rec *auth.Rec) ([]byte, time.Time, error) {
|
||||
}
|
||||
|
||||
// DelRecords is a noop which always succeeds.
|
||||
func (authenticator) DelRecords(uid types.Uid) error {
|
||||
func (authenticator) DelRecords(tenantID types.TenantID, uid types.Uid) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -83,7 +89,7 @@ func (authenticator) RestrictedTags() ([]string, error) {
|
||||
|
||||
// GetResetParams returns authenticator parameters passed to password reset handler
|
||||
// (none for anonymous).
|
||||
func (authenticator) GetResetParams(uid types.Uid) (map[string]any, error) {
|
||||
func (authenticator) GetResetParams(tenantID types.TenantID, uid types.Uid) (map[string]any, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
// Package auth provides interfaces and types required for implementing an authenticaor.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/tinode/chat/server/store/types"
|
||||
)
|
||||
|
||||
// Level is the type for authentication levels.
|
||||
type Level int
|
||||
|
||||
// Authentication levels
|
||||
const (
|
||||
// LevelNone is undefined/not authenticated
|
||||
LevelNone Level = iota * 10
|
||||
// LevelAnon is anonymous user/light authentication
|
||||
LevelAnon
|
||||
// LevelAuth is fully authenticated user
|
||||
LevelAuth
|
||||
// LevelRoot is a superuser (currently unused)
|
||||
LevelRoot
|
||||
)
|
||||
|
||||
// String implements Stringer interface: gets human-readable name for a numeric authentication level.
|
||||
func (a Level) String() string {
|
||||
s, err := a.MarshalText()
|
||||
if err != nil {
|
||||
return "unkn"
|
||||
}
|
||||
return string(s)
|
||||
}
|
||||
|
||||
// ParseAuthLevel parses authentication level from a string.
|
||||
func ParseAuthLevel(name string) Level {
|
||||
switch name {
|
||||
case "anon", "ANON":
|
||||
return LevelAnon
|
||||
case "auth", "AUTH":
|
||||
return LevelAuth
|
||||
case "root", "ROOT":
|
||||
return LevelRoot
|
||||
default:
|
||||
return LevelNone
|
||||
}
|
||||
}
|
||||
|
||||
// MarshalText converts Level to a slice of bytes with the name of the level.
|
||||
func (a Level) MarshalText() ([]byte, error) {
|
||||
switch a {
|
||||
case LevelNone:
|
||||
return []byte(""), nil
|
||||
case LevelAnon:
|
||||
return []byte("anon"), nil
|
||||
case LevelAuth:
|
||||
return []byte("auth"), nil
|
||||
case LevelRoot:
|
||||
return []byte("root"), nil
|
||||
default:
|
||||
return nil, errors.New("auth.Level: invalid level value")
|
||||
}
|
||||
}
|
||||
|
||||
// UnmarshalText parses authentication level from a string.
|
||||
func (a *Level) UnmarshalText(b []byte) error {
|
||||
switch string(b) {
|
||||
case "":
|
||||
*a = LevelNone
|
||||
return nil
|
||||
case "anon", "ANON":
|
||||
*a = LevelAnon
|
||||
return nil
|
||||
case "auth", "AUTH":
|
||||
*a = LevelAuth
|
||||
return nil
|
||||
case "root", "ROOT":
|
||||
*a = LevelRoot
|
||||
return nil
|
||||
default:
|
||||
return errors.New("auth.Level: unrecognized")
|
||||
}
|
||||
}
|
||||
|
||||
// MarshalJSON converts Level to a quoted string.
|
||||
func (a Level) MarshalJSON() ([]byte, error) {
|
||||
res, err := a.MarshalText()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return append(append([]byte{'"'}, res...), '"'), nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON reads Level from a quoted string.
|
||||
func (a *Level) UnmarshalJSON(b []byte) error {
|
||||
if b[0] != '"' || b[len(b)-1] != '"' {
|
||||
return errors.New("syntax error")
|
||||
}
|
||||
|
||||
return a.UnmarshalText(b[1 : len(b)-1])
|
||||
}
|
||||
|
||||
// Feature is a bitmap of authenticated features, such as validated/not validated.
|
||||
type Feature uint16
|
||||
|
||||
const (
|
||||
// FeatureValidated bit is set if user's credentials are already validated (V).
|
||||
FeatureValidated Feature = 1 << iota
|
||||
// FeatureNoLogin is set if the token should not be used to permanently authenticate a session (L).
|
||||
FeatureNoLogin
|
||||
)
|
||||
|
||||
// MarshalText converts Feature to ASCII byte slice.
|
||||
func (f Feature) MarshalText() ([]byte, error) {
|
||||
res := []byte{}
|
||||
for i, chr := range []byte{'V', 'L'} {
|
||||
if (f & (1 << uint(i))) != 0 {
|
||||
res = append(res, chr)
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// UnmarshalText parses Feature string as byte slice.
|
||||
func (f *Feature) UnmarshalText(b []byte) error {
|
||||
var f0 int
|
||||
var err error
|
||||
if len(b) > 0 {
|
||||
if b[0] >= '0' && b[0] <= '9' {
|
||||
f0, err = strconv.Atoi(string(b))
|
||||
} else {
|
||||
Loop:
|
||||
for i := range b {
|
||||
switch b[i] {
|
||||
case 'V', 'v':
|
||||
f0 |= int(FeatureValidated)
|
||||
case 'L', 'l':
|
||||
f0 |= int(FeatureNoLogin)
|
||||
default:
|
||||
err = errors.New("Feature: invalid character '" + string(b[i]) + "'")
|
||||
break Loop
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
*f = Feature(f0)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// String Featureto a string representation.
|
||||
func (f Feature) String() string {
|
||||
res, err := f.MarshalText()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(res)
|
||||
}
|
||||
|
||||
// MarshalJSON converts Feature to a quoted string.
|
||||
func (f Feature) MarshalJSON() ([]byte, error) {
|
||||
res, err := f.MarshalText()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return append(append([]byte{'"'}, res...), '"'), nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON reads Feature from a quoted string or an integer.
|
||||
func (f *Feature) UnmarshalJSON(b []byte) error {
|
||||
if b[0] == '"' && b[len(b)-1] == '"' {
|
||||
return f.UnmarshalText(b[1 : len(b)-1])
|
||||
}
|
||||
return f.UnmarshalText(b)
|
||||
}
|
||||
|
||||
// Duration is identical to time.Duration except it can be sanely unmarshallend from JSON.
|
||||
type Duration time.Duration
|
||||
|
||||
// AuthContext is server-owned context for an authentication operation.
|
||||
// TenantID must be resolved before invoking an authenticator.
|
||||
type AuthContext struct {
|
||||
TenantID types.TenantID
|
||||
RemoteAddr string
|
||||
}
|
||||
|
||||
// IsValid reports whether the authentication context is bound to a tenant.
|
||||
func (ctx AuthContext) IsValid() bool {
|
||||
return !ctx.TenantID.IsZero()
|
||||
}
|
||||
|
||||
// BindRecord validates the authentication context and binds a record to it.
|
||||
// A record which is already bound to another tenant is rejected.
|
||||
func BindRecord(ctx AuthContext, rec *Rec) error {
|
||||
if !ctx.IsValid() || rec == nil {
|
||||
return types.ErrMalformed
|
||||
}
|
||||
if !rec.TenantID.IsZero() && rec.TenantID != ctx.TenantID {
|
||||
return types.ErrFailed
|
||||
}
|
||||
rec.TenantID = ctx.TenantID
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON handles the cases where duration is specified in JSON as a "5000s" string or just plain seconds.
|
||||
func (d *Duration) UnmarshalJSON(b []byte) error {
|
||||
var v any
|
||||
if err := json.Unmarshal(b, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
switch value := v.(type) {
|
||||
case float64:
|
||||
*d = Duration(time.Duration(value) * time.Second)
|
||||
return nil
|
||||
case string:
|
||||
d0, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*d = Duration(d0)
|
||||
return nil
|
||||
default:
|
||||
return errors.New("invalid duration")
|
||||
}
|
||||
}
|
||||
|
||||
// Rec is an authentication record.
|
||||
type Rec struct {
|
||||
// Tenant which owns this authentication record.
|
||||
TenantID types.TenantID `json:"tenant_id,omitempty"`
|
||||
// User ID.
|
||||
Uid types.Uid `json:"uid,omitempty"`
|
||||
// Authentication level.
|
||||
AuthLevel Level `json:"authlvl,omitempty"`
|
||||
// Lifetime of this record.
|
||||
Lifetime Duration `json:"lifetime,omitempty"`
|
||||
// Bitmap of features. Currently 'validated'/'not validated' only.
|
||||
Features Feature `json:"features,omitempty"`
|
||||
// Tags generated by this authentication record.
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
// User account state received or read by the authenticator.
|
||||
State types.ObjState
|
||||
// Credential 'method:value' associated with this record.
|
||||
Credential string `json:"cred,omitempty"`
|
||||
|
||||
// Authenticator may request the server to create a new account.
|
||||
// These are the account parameters which can be used for creating the account.
|
||||
DefAcs *types.DefaultAccess `json:"defacs,omitempty"`
|
||||
Public any `json:"public,omitempty"`
|
||||
Private any `json:"private,omitempty"`
|
||||
}
|
||||
|
||||
// AuthHandler is the interface which auth providers must implement.
|
||||
type AuthHandler interface {
|
||||
// Init initializes the handler taking config string and logical name as parameters.
|
||||
Init(jsonconf json.RawMessage, name string) error
|
||||
|
||||
// IsInitialized returns true if the handler is initialized.
|
||||
IsInitialized() bool
|
||||
|
||||
// AddRecord adds persistent authentication record to the database.
|
||||
// Returns: updated auth record, error
|
||||
AddRecord(ctx AuthContext, rec *Rec, secret []byte) (*Rec, error)
|
||||
|
||||
// UpdateRecord updates existing record with new credentials.
|
||||
// Returns updated auth record, error.
|
||||
UpdateRecord(ctx AuthContext, rec *Rec, secret []byte) (*Rec, error)
|
||||
|
||||
// Authenticate: given a user-provided authentication secret (such as "login:password"), either
|
||||
// return user's record (ID, time when the secret expires, etc), or issue a challenge to
|
||||
// continue the authentication process to the next step, or return an error code.
|
||||
// The remoteAddr (i.e. the IP address of the client) can be used by custom authenticators for
|
||||
// additional validation. The stock authenticators don't use it.
|
||||
// store.Users.GetAuthRecord("scheme", "unique")
|
||||
// Returns: user auth record, challenge, error.
|
||||
Authenticate(ctx AuthContext, secret []byte) (*Rec, []byte, error)
|
||||
|
||||
// AsTag converts search token into prefixed tag or an empty string if it
|
||||
// cannot be represented as a prefixed tag.
|
||||
AsTag(token string) string
|
||||
|
||||
// IsUnique verifies if the provided secret can be considered unique by the auth scheme
|
||||
// E.g. if login is unique. It also may check for policy compliance, i.e. not too short, etc.
|
||||
IsUnique(ctx AuthContext, secret []byte) (bool, error)
|
||||
|
||||
// GenSecret generates a new secret, if appropriate.
|
||||
GenSecret(rec *Rec) ([]byte, time.Time, error)
|
||||
|
||||
// DelRecords deletes (or disables) all authentication records for the given user.
|
||||
DelRecords(tenantID types.TenantID, uid types.Uid) error
|
||||
|
||||
// RestrictedTags returns the tag namespaces (prefixes) which are restricted by this authenticator.
|
||||
RestrictedTags() ([]string, error)
|
||||
|
||||
// GetResetParams returns authenticator parameters passed to password reset handler
|
||||
// for the provided user id.
|
||||
// Returns: map of params.
|
||||
GetResetParams(tenantID types.TenantID, uid types.Uid) (map[string]any, error)
|
||||
|
||||
// GetRealName returns the hardcoded name of the authenticator.
|
||||
GetRealName() string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tinode/chat/server/store/types"
|
||||
)
|
||||
|
||||
func TestBindRecord(t *testing.T) {
|
||||
ctx := AuthContext{TenantID: 7, RemoteAddr: "127.0.0.1"}
|
||||
rec := &Rec{}
|
||||
if err := BindRecord(ctx, rec); err != nil {
|
||||
t.Fatalf("BindRecord failed: %v", err)
|
||||
}
|
||||
if rec.TenantID != ctx.TenantID {
|
||||
t.Fatalf("unexpected tenant: got %d, want %d", rec.TenantID, ctx.TenantID)
|
||||
}
|
||||
|
||||
if err := BindRecord(AuthContext{}, &Rec{}); err != types.ErrMalformed {
|
||||
t.Fatalf("zero tenant: got %v, want %v", err, types.ErrMalformed)
|
||||
}
|
||||
if err := BindRecord(ctx, &Rec{TenantID: 8}); err != types.ErrFailed {
|
||||
t.Fatalf("tenant mismatch: got %v, want %v", err, types.ErrFailed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
// Package basic is an authenticator by login-password.
|
||||
package basic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tinode/chat/server/auth"
|
||||
"github.com/tinode/chat/server/store"
|
||||
"github.com/tinode/chat/server/store/types"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Define default constraints on login and password
|
||||
const (
|
||||
defaultMinLoginLength = 2
|
||||
defaultMaxLoginLength = 32
|
||||
|
||||
defaultMinPasswordLength = 3
|
||||
)
|
||||
|
||||
// Token suitable as a login: starts and ends with a Unicode letter (class L) or number (class N),
|
||||
// contains Unicode letters, numbers, dot and underscore.
|
||||
var loginPattern = regexp.MustCompile(`^[\pL\pN][_.\pL\pN]*[\pL\pN]+$`)
|
||||
|
||||
// authenticator is the type to map authentication methods to.
|
||||
type authenticator struct {
|
||||
name string
|
||||
addToTags bool
|
||||
|
||||
minPasswordLength int
|
||||
minLoginLength int
|
||||
}
|
||||
|
||||
func (a *authenticator) checkLoginPolicy(uname string) error {
|
||||
rlogin := []rune(uname)
|
||||
if len(rlogin) < a.minLoginLength || len(rlogin) > defaultMaxLoginLength || !loginPattern.MatchString(uname) {
|
||||
return types.ErrPolicy
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *authenticator) checkPasswordPolicy(password string) error {
|
||||
if len([]rune(password)) < a.minPasswordLength {
|
||||
return types.ErrPolicy
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseSecret(bsecret []byte) (uname, password string, err error) {
|
||||
secret := string(bsecret)
|
||||
|
||||
splitAt := strings.Index(secret, ":")
|
||||
if splitAt < 0 {
|
||||
err = types.ErrMalformed
|
||||
return
|
||||
}
|
||||
|
||||
uname = strings.ToLower(secret[:splitAt])
|
||||
password = secret[splitAt+1:]
|
||||
return
|
||||
}
|
||||
|
||||
// Init initializes the basic authenticator.
|
||||
func (a *authenticator) Init(jsonconf json.RawMessage, name string) error {
|
||||
if name == "" {
|
||||
return errors.New("auth_basic: authenticator name cannot be blank")
|
||||
}
|
||||
|
||||
if a.name != "" {
|
||||
return errors.New("auth_basic: already initialized as " + a.name + "; " + name)
|
||||
}
|
||||
|
||||
type configType struct {
|
||||
// AddToTags indicates that the user name should be used as a searchable tag.
|
||||
AddToTags bool `json:"add_to_tags"`
|
||||
MinPasswordLength int `json:"min_password_length"`
|
||||
MinLoginLength int `json:"min_login_length"`
|
||||
}
|
||||
|
||||
var config configType
|
||||
if err := json.Unmarshal(jsonconf, &config); err != nil {
|
||||
return errors.New("auth_basic: failed to parse config: " + err.Error() + "(" + string(jsonconf) + ")")
|
||||
}
|
||||
a.name = name
|
||||
a.addToTags = config.AddToTags
|
||||
a.minPasswordLength = config.MinPasswordLength
|
||||
if a.minPasswordLength <= 0 {
|
||||
a.minPasswordLength = defaultMinPasswordLength
|
||||
}
|
||||
a.minLoginLength = config.MinLoginLength
|
||||
if a.minLoginLength > defaultMaxLoginLength {
|
||||
return errors.New("auth_basic: min_login_length exceeds the limit")
|
||||
}
|
||||
if a.minLoginLength <= 0 {
|
||||
a.minLoginLength = defaultMinLoginLength
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsInitialized returns true if the handler is initialized.
|
||||
func (a *authenticator) IsInitialized() bool {
|
||||
return a.name != ""
|
||||
}
|
||||
|
||||
// AddRecord adds a basic authentication record to DB.
|
||||
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
|
||||
}
|
||||
uname, password, err := parseSecret(secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = a.checkLoginPolicy(uname); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = a.checkPasswordPolicy(password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
passhash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var expires time.Time
|
||||
if rec.Lifetime > 0 {
|
||||
expires = time.Now().Add(time.Duration(rec.Lifetime)).UTC().Round(time.Millisecond)
|
||||
}
|
||||
|
||||
authLevel := rec.AuthLevel
|
||||
if authLevel == auth.LevelNone {
|
||||
authLevel = auth.LevelAuth
|
||||
}
|
||||
|
||||
err = store.Users.AddAuthRecord(ctx.TenantID, rec.Uid, authLevel, a.name, uname, passhash, expires)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rec.AuthLevel = authLevel
|
||||
if a.addToTags {
|
||||
rec.Tags = append(rec.Tags, a.name+":"+uname)
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// UpdateRecord updates password for basic authentication.
|
||||
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
|
||||
}
|
||||
uname, password, err := parseSecret(secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
login, authLevel, _, _, err := store.Users.GetAuthRecord(ctx.TenantID, rec.Uid, a.name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// User does not have a record.
|
||||
if login == "" {
|
||||
return nil, types.ErrNotFound
|
||||
}
|
||||
|
||||
if uname == "" || uname == login {
|
||||
// User is changing just the password.
|
||||
uname = login
|
||||
} else if err = a.checkLoginPolicy(uname); err != nil {
|
||||
return nil, err
|
||||
} else if uid, _, _, _, err := store.Users.GetAuthUniqueRecord(ctx.TenantID, a.name, uname); err != nil {
|
||||
return nil, err
|
||||
} else if !uid.IsZero() {
|
||||
// The (new) user name already exists. Report an error.
|
||||
return nil, types.ErrDuplicate
|
||||
}
|
||||
|
||||
if err = a.checkPasswordPolicy(password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
passhash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, types.ErrInternal
|
||||
}
|
||||
var expires time.Time
|
||||
if rec.Lifetime > 0 {
|
||||
expires = types.TimeNow().Add(time.Duration(rec.Lifetime))
|
||||
}
|
||||
err = store.Users.UpdateAuthRecord(ctx.TenantID, rec.Uid, authLevel, a.name, uname, passhash, expires)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Remove old tag from the list of tags
|
||||
oldTag := a.name + ":" + login
|
||||
for i, tag := range rec.Tags {
|
||||
if tag == oldTag {
|
||||
rec.Tags[i] = rec.Tags[len(rec.Tags)-1]
|
||||
rec.Tags = rec.Tags[:len(rec.Tags)-1]
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
// Add new tag
|
||||
rec.Tags = append(rec.Tags, a.name+":"+uname)
|
||||
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// Authenticate checks login and password.
|
||||
func (a *authenticator) Authenticate(ctx auth.AuthContext, secret []byte) (*auth.Rec, []byte, error) {
|
||||
if !ctx.IsValid() {
|
||||
return nil, nil, types.ErrMalformed
|
||||
}
|
||||
uname, password, err := parseSecret(secret)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
uid, authLvl, passhash, expires, err := store.Users.GetAuthUniqueRecord(ctx.TenantID, a.name, uname)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if uid.IsZero() {
|
||||
// Invalid login.
|
||||
return nil, nil, types.ErrFailed
|
||||
}
|
||||
if !expires.IsZero() && expires.Before(time.Now()) {
|
||||
// The record has expired
|
||||
return nil, nil, types.ErrExpired
|
||||
}
|
||||
|
||||
err = bcrypt.CompareHashAndPassword(passhash, []byte(password))
|
||||
if err != nil {
|
||||
// Invalid password
|
||||
return nil, nil, types.ErrFailed
|
||||
}
|
||||
|
||||
var lifetime time.Duration
|
||||
if !expires.IsZero() {
|
||||
lifetime = time.Until(expires)
|
||||
}
|
||||
return &auth.Rec{
|
||||
TenantID: ctx.TenantID,
|
||||
Uid: uid,
|
||||
AuthLevel: authLvl,
|
||||
Lifetime: auth.Duration(lifetime),
|
||||
Features: 0,
|
||||
State: types.StateUndefined}, nil, nil
|
||||
}
|
||||
|
||||
// AsTag convert search token into a prefixed tag, if possible.
|
||||
func (a *authenticator) AsTag(token string) string {
|
||||
if !a.addToTags {
|
||||
return ""
|
||||
}
|
||||
|
||||
if err := a.checkLoginPolicy(token); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return a.name + ":" + token
|
||||
}
|
||||
|
||||
// IsUnique checks login uniqueness and policy compliance.
|
||||
func (a *authenticator) IsUnique(ctx auth.AuthContext, secret []byte) (bool, error) {
|
||||
if !ctx.IsValid() {
|
||||
return false, types.ErrMalformed
|
||||
}
|
||||
uname, _, err := parseSecret(secret)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if err := a.checkLoginPolicy(uname); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
uid, _, _, _, err := store.Users.GetAuthUniqueRecord(ctx.TenantID, a.name, uname)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if uid.IsZero() {
|
||||
return true, nil
|
||||
}
|
||||
return false, types.ErrDuplicate
|
||||
}
|
||||
|
||||
// GenSecret is not supported, generates an error.
|
||||
func (authenticator) GenSecret(rec *auth.Rec) ([]byte, time.Time, error) {
|
||||
return nil, time.Time{}, types.ErrUnsupported
|
||||
}
|
||||
|
||||
// DelRecords deletes saved authentication records of the given user.
|
||||
func (a *authenticator) DelRecords(tenantID types.TenantID, uid types.Uid) error {
|
||||
return store.Users.DelAuthRecords(tenantID, uid, a.name)
|
||||
}
|
||||
|
||||
// RestrictedTags returns tag namespaces (prefixes) restricted by this adapter.
|
||||
func (a *authenticator) RestrictedTags() ([]string, error) {
|
||||
var prefix []string
|
||||
if a.addToTags {
|
||||
prefix = []string{a.name}
|
||||
}
|
||||
return prefix, nil
|
||||
}
|
||||
|
||||
// GetResetParams returns authenticator parameters passed to password reset handler.
|
||||
func (a *authenticator) GetResetParams(tenantID types.TenantID, uid types.Uid) (map[string]any, error) {
|
||||
login, _, _, _, err := store.Users.GetAuthRecord(tenantID, uid, a.name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// User does not have a record matching the authentication scheme.
|
||||
if login == "" {
|
||||
return nil, types.ErrNotFound
|
||||
}
|
||||
|
||||
params := make(map[string]any)
|
||||
params["login"] = login
|
||||
return params, nil
|
||||
}
|
||||
|
||||
const realName = "basic"
|
||||
|
||||
// GetRealName returns the hardcoded name of the authenticator.
|
||||
func (authenticator) GetRealName() string {
|
||||
return realName
|
||||
}
|
||||
|
||||
func init() {
|
||||
store.RegisterAuthScheme(realName, &authenticator{})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/tinode/chat/server/auth"
|
||||
"github.com/tinode/chat/server/store"
|
||||
"github.com/tinode/chat/server/store/mock_store"
|
||||
"github.com/tinode/chat/server/store/types"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestAuthenticateScopesLoginByTenant(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
|
||||
oldUsers := store.Users
|
||||
store.Users = users
|
||||
t.Cleanup(func() {
|
||||
store.Users = oldUsers
|
||||
ctrl.Finish()
|
||||
})
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
users.EXPECT().GetAuthUniqueRecord(types.TenantID(7), "basic", "alice").
|
||||
Return(types.Uid(70), auth.LevelAuth, hash, time.Time{}, nil)
|
||||
users.EXPECT().GetAuthUniqueRecord(types.TenantID(8), "basic", "alice").
|
||||
Return(types.Uid(80), auth.LevelAuth, hash, time.Time{}, nil)
|
||||
|
||||
a := &authenticator{name: "basic", minLoginLength: defaultMinLoginLength,
|
||||
minPasswordLength: defaultMinPasswordLength}
|
||||
recA, _, err := a.Authenticate(auth.AuthContext{TenantID: 7}, []byte("alice:password"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recB, _, err := a.Authenticate(auth.AuthContext{TenantID: 8}, []byte("alice:password"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if recA.TenantID != 7 || recA.Uid != 70 || recB.TenantID != 8 || recB.Uid != 80 {
|
||||
t.Fatalf("unexpected records: A=%+v B=%+v", recA, recB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticateRejectsMissingTenant(t *testing.T) {
|
||||
a := &authenticator{name: "basic", minLoginLength: defaultMinLoginLength,
|
||||
minPasswordLength: defaultMinPasswordLength}
|
||||
if _, _, err := a.Authenticate(auth.AuthContext{}, []byte("alice:password")); err != types.ErrMalformed {
|
||||
t.Fatalf("got %v, want %v", err, types.ErrMalformed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
// Package code implements temporary no-login authentication by short numeric code.
|
||||
package code
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math/big"
|
||||
"strconv"
|
||||
"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 a singleton instance of the authenticator.
|
||||
type authenticator struct {
|
||||
name string
|
||||
codeLength int
|
||||
maxCodeValue *big.Int
|
||||
lifetime time.Duration
|
||||
maxRetries int
|
||||
}
|
||||
|
||||
// Init initializes the authenticator: parses the config and sets internal state.
|
||||
func (ca *authenticator) Init(jsonconf json.RawMessage, name string) error {
|
||||
if name == "" {
|
||||
return errors.New("auth_code: authenticator name cannot be blank")
|
||||
}
|
||||
|
||||
if ca.name != "" {
|
||||
return errors.New("auth_code: already initialized as " + ca.name + "; " + name)
|
||||
}
|
||||
|
||||
type configType struct {
|
||||
// Length of the security code.
|
||||
CodeLength int `json:"code_length"`
|
||||
// Code expiration time in seconds.
|
||||
ExpireIn int `json:"expire_in"`
|
||||
// Maximum number of verification attempts per code.
|
||||
MaxRetries int `json:"max_retries"`
|
||||
}
|
||||
var config configType
|
||||
if err := json.Unmarshal(jsonconf, &config); err != nil {
|
||||
return errors.New("auth_code: failed to parse config: " + err.Error() + "(" + string(jsonconf) + ")")
|
||||
}
|
||||
|
||||
if config.ExpireIn <= 0 {
|
||||
return errors.New("auth_code: invalid expiration period")
|
||||
}
|
||||
|
||||
if config.CodeLength < 4 {
|
||||
return errors.New("auth_code: invalid code length")
|
||||
}
|
||||
|
||||
if config.MaxRetries < 1 {
|
||||
return errors.New("auth_code: invalid retries count")
|
||||
}
|
||||
|
||||
ca.name = name
|
||||
ca.codeLength = config.CodeLength
|
||||
ca.maxCodeValue = big.NewInt(0).Exp(big.NewInt(10), big.NewInt(int64(ca.codeLength)), nil)
|
||||
ca.lifetime = time.Duration(config.ExpireIn) * time.Second
|
||||
ca.maxRetries = config.MaxRetries
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsInitialized returns true if the handler is initialized.
|
||||
func (ca *authenticator) IsInitialized() bool {
|
||||
return ca.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 short code.
|
||||
// The secret is structured as <code>:<cred_method>:<cred_value>, "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_<credential>.
|
||||
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{})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
<!-- TOC depthFrom:1 depthTo:6 withLinks:1 updateOnSave:1 orderedList:0 -->
|
||||
|
||||
- [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)
|
||||
|
||||
<!-- /TOC -->
|
||||
|
||||
## 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=="
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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{})
|
||||
}
|
||||
|
||||
@@ -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{})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+455
@@ -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)
|
||||
}
|
||||
|
||||
+70
-33
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1758
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
# The MySQL and PostrgreSQL schemas are identical save for differences in SQL flavors.
|
||||
# SEE ../mysql/schema.sql.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"reset_db_data": true,
|
||||
"adapters": {
|
||||
"postgres": {
|
||||
"User": "postgres",
|
||||
"Passwd": "postgres",
|
||||
"Host": "localhost",
|
||||
"Port": "5432",
|
||||
"DBName": "tinode_test"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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": <binary, 6.5KB, "ff d8 ff e0 00 10..."> ,
|
||||
"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": <binary, 60 bytes, "24 32 61 24 31 30..."> ,
|
||||
"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": <binary, 6.2KB, "ff d8 ff e0 00 10..."> ,
|
||||
"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"
|
||||
}
|
||||
```
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
+489
@@ -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)
|
||||
}
|
||||
|
||||
+889
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+797
@@ -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>
|
||||
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 '<current-dir>/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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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{})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
// <comma separated list of packet names> ; <comma separated list of topics or topic types> ; <actions (combination of C U D)>
|
||||
// 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 "", ""
|
||||
}
|
||||
|
||||
+731
@@ -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)))
|
||||
}
|
||||
|
||||
+237
@@ -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:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`.<br/><br/>
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# TNPG: Push Gateway
|
||||
|
||||
This is a push notifications adapter which communicates with Tinode Push Gateway (TNPG).
|
||||
|
||||
TNPG is a proprietary service intended to simplify deployment of on-premise installations.
|
||||
Deploying a Tinode server without TNPG requires [configuring Google FCM](../fcm/) with your own credentials, recompiling Android and iOS clients, releasing them to PlayStore and AppStore under your own accounts. It's usually time consuming and relatively complex.
|
||||
|
||||
TNPG solves this problem by letting Tinode LLC (the company behind Tinode) to send push notifications on your behalf: you hand a notification over to TNPG, TNPG sends it to the clients using its own credentials and certificates. Internally it uses [Google FCM](https://firebase.google.com/docs/cloud-messaging/) and as such supports the same platforms as FCM. The main advantage of using TNPG over FCM is simplicity of configuration: you can use stock mobile clients with your custom Tinode server, all is needed is a configuration update on the server.
|
||||
|
||||
## Configuring TNPG adapter
|
||||
|
||||
### Obtain TNPG token
|
||||
|
||||
1. Register at https://console.tinode.co and create an organization.
|
||||
2. Get the TPNG token from the _Self hosting_ → _Push Gateway_ section by following the instructions there.
|
||||
|
||||
### Configure the server
|
||||
Update the server config [`tinode.conf`](../../tinode.conf#L413), section `"push"` -> `"name": "tnpg"`:
|
||||
```js
|
||||
{
|
||||
"enabled": true,
|
||||
"org": "myorg", // Short name (URL) of the organization you registered at console.tinode.co
|
||||
"token": "SoMe_LonG.RaNDoM-StRiNg.12345" // authentication token obtained from console.tinode.co
|
||||
}
|
||||
```
|
||||
Make sure the `fcm` section is disabled `"enabled": false` or removed altogether.
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
// Package tnpg implements push notification plugin for Tinode Push Gateway.
|
||||
package tnpg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/tinode/chat/server/logs"
|
||||
"github.com/tinode/chat/server/push"
|
||||
"github.com/tinode/chat/server/push/common"
|
||||
"github.com/tinode/chat/server/push/fcm"
|
||||
"github.com/tinode/chat/server/store"
|
||||
"github.com/tinode/chat/server/store/types"
|
||||
|
||||
fcmv1 "google.golang.org/api/fcm/v1"
|
||||
)
|
||||
|
||||
const (
|
||||
baseTargetAddress = "https://pushgw.tinode.co/"
|
||||
pushPath = "pushv1"
|
||||
subsPath = "sub"
|
||||
pushBatchSize = 100
|
||||
subBatchSize = 1000
|
||||
bufferSize = 1024
|
||||
)
|
||||
|
||||
var handler Handler
|
||||
|
||||
const maxPooledPostBodyCap = 1 << 16
|
||||
|
||||
var postBodyPool = sync.Pool{
|
||||
New: func() any {
|
||||
return new(bytes.Buffer)
|
||||
},
|
||||
}
|
||||
|
||||
var gzipWriterPool = sync.Pool{
|
||||
New: func() any {
|
||||
return gzip.NewWriter(nil)
|
||||
},
|
||||
}
|
||||
|
||||
// Handler represents state of TNPG push client.
|
||||
type Handler struct {
|
||||
input chan *push.Receipt
|
||||
channel chan *push.ChannelReq
|
||||
stop chan bool
|
||||
pushUrl string
|
||||
subUrl string
|
||||
}
|
||||
|
||||
type configType struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
OrgID string `json:"org"`
|
||||
AuthToken string `json:"token"`
|
||||
DebugPushGWHost string `json:"debug_server"`
|
||||
}
|
||||
|
||||
// subUnsubReq is a request to subscribe/unsubscribe device ID(s) to channel(s) (FCM topic).
|
||||
// One device to multiple channels or multiple devices to one channel.
|
||||
type subUnsubReq struct {
|
||||
Channel string `json:"channel,omitempty"`
|
||||
Channels []string `json:"channels,omitempty"`
|
||||
Device string `json:"device,omitempty"`
|
||||
Devices []string `json:"devices,omitempty"`
|
||||
Unsub bool `json:"unsub"`
|
||||
}
|
||||
|
||||
type tnpgResponse struct {
|
||||
// Push message response only.
|
||||
MessageID string `json:"msg_id,omitempty"`
|
||||
// Server response HTTP code.
|
||||
Code int `json:"code,omitempty"`
|
||||
// FCM response code. Both push and sub/unsub response.
|
||||
ErrorCode string `json:"errcode,omitempty"`
|
||||
ExtendedError string `json:"exerr,omitempty"`
|
||||
ErrorMessage string `json:"errmsg,omitempty"`
|
||||
// Channel sub/unsub response only.
|
||||
Index int `json:"index,omitempty"`
|
||||
}
|
||||
|
||||
type batchResponse struct {
|
||||
// Number of successfully sent messages.
|
||||
SuccessCount int `json:"sent_count"`
|
||||
// Number of failures.
|
||||
FailureCount int `json:"fail_count"`
|
||||
// Error code and message if the entire batch failed.
|
||||
FatalCode string `json:"errcode,omitempty"`
|
||||
FatalMessage string `json:"errmsg,omitempty"`
|
||||
// Individual reponses in the same order as messages. Could be nil if the entire batch failed.
|
||||
Responses []*tnpgResponse `json:"resp,omitempty"`
|
||||
|
||||
// Local values
|
||||
httpCode int
|
||||
httpStatus string
|
||||
}
|
||||
|
||||
// Error codes copied from https://github.com/firebase/firebase-admin-go/blob/master/messaging/messaging.go
|
||||
const (
|
||||
internalError = "internal-error"
|
||||
invalidAPNSCredentials = "invalid-apns-credentials"
|
||||
invalidArgument = "invalid-argument"
|
||||
messageRateExceeded = "message-rate-exceeded"
|
||||
mismatchedCredential = "mismatched-credential"
|
||||
registrationTokenNotRegistered = "registration-token-not-registered"
|
||||
serverUnavailable = "server-unavailable"
|
||||
tooManyTopics = "too-many-topics"
|
||||
unknownError = "unknown-error"
|
||||
)
|
||||
|
||||
// Init initializes the handler
|
||||
func (Handler) Init(jsonconf json.RawMessage) (bool, error) {
|
||||
var config configType
|
||||
if err := json.Unmarshal(jsonconf, &config); err != nil {
|
||||
return false, errors.New("failed to parse config: " + err.Error())
|
||||
}
|
||||
|
||||
if !config.Enabled {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
config.OrgID = strings.TrimSpace(config.OrgID)
|
||||
if config.OrgID == "" {
|
||||
return false, errors.New("organization name is missing")
|
||||
}
|
||||
|
||||
// Convert to lower case to avoid confusion.
|
||||
config.OrgID = strings.ToLower(config.OrgID)
|
||||
|
||||
// Construct server URLs.
|
||||
serverAddr := baseTargetAddress
|
||||
if config.DebugPushGWHost != "" {
|
||||
serverAddr = config.DebugPushGWHost
|
||||
}
|
||||
serverUrl, err := url.Parse(serverAddr)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
serverUrl.Path += pushPath + "/" + config.OrgID
|
||||
handler.pushUrl = serverUrl.String()
|
||||
serverUrl, _ = url.Parse(serverAddr)
|
||||
serverUrl.Path += subsPath + "/" + config.OrgID
|
||||
handler.subUrl = serverUrl.String()
|
||||
|
||||
handler.input = make(chan *push.Receipt, bufferSize)
|
||||
handler.channel = make(chan *push.ChannelReq, bufferSize)
|
||||
handler.stop = make(chan bool, 1)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case rcpt := <-handler.input:
|
||||
go sendPushes(rcpt, &config)
|
||||
case sub := <-handler.channel:
|
||||
go processSubscription(sub, &config)
|
||||
case <-handler.stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func postMessage(endpoint string, body any, config *configType) (*batchResponse, error) {
|
||||
buf := postBodyPool.Get().(*bytes.Buffer)
|
||||
defer func() {
|
||||
buf.Reset()
|
||||
if cap(buf.Bytes()) > maxPooledPostBodyCap {
|
||||
return
|
||||
}
|
||||
postBodyPool.Put(buf)
|
||||
}()
|
||||
|
||||
gzw := gzipWriterPool.Get().(*gzip.Writer)
|
||||
defer func() {
|
||||
gzw.Reset(nil)
|
||||
gzipWriterPool.Put(gzw)
|
||||
}()
|
||||
gzw.Reset(buf)
|
||||
err := json.NewEncoder(gzw).Encode(body)
|
||||
if closeErr := gzw.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, endpoint, buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Add("Authorization", "Bearer "+config.AuthToken)
|
||||
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
req.Header.Add("Content-Encoding", "gzip")
|
||||
req.Header.Add("Accept-Encoding", "gzip")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var batch batchResponse
|
||||
var reader io.ReadCloser
|
||||
if strings.Contains(resp.Header.Get("Content-Encoding"), "gzip") {
|
||||
reader, err = gzip.NewReader(resp.Body)
|
||||
if err == nil {
|
||||
defer reader.Close()
|
||||
}
|
||||
} else {
|
||||
reader = resp.Body
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
err = json.NewDecoder(reader).Decode(&batch)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if err != nil {
|
||||
// Just log the error, but don't report it to caller. The push succeeded.
|
||||
logs.Warn.Println("tnpg failed to decode response:", err)
|
||||
}
|
||||
|
||||
batch.httpCode = resp.StatusCode
|
||||
batch.httpStatus = resp.Status
|
||||
|
||||
return &batch, nil
|
||||
}
|
||||
|
||||
func sendPushes(rcpt *push.Receipt, config *configType) {
|
||||
messages, uids := fcm.PrepareV1Notifications(rcpt, nil)
|
||||
|
||||
n := len(messages)
|
||||
for i := 0; i < n; i += pushBatchSize {
|
||||
upper := min(i+pushBatchSize, n)
|
||||
var payloads []any
|
||||
for j := i; j < upper; j++ {
|
||||
payloads = append(payloads, messages[j])
|
||||
}
|
||||
resp, err := postMessage(handler.pushUrl, payloads, config)
|
||||
if err != nil {
|
||||
logs.Warn.Println("tnpg push request failed:", err)
|
||||
break
|
||||
}
|
||||
if resp.httpCode >= 300 {
|
||||
logs.Warn.Println("tnpg push rejected:", resp.httpStatus)
|
||||
break
|
||||
}
|
||||
if resp.FatalCode != "" {
|
||||
logs.Err.Println("tnpg push failed:", resp.FatalMessage)
|
||||
break
|
||||
}
|
||||
// Check for expired tokens and other errors.
|
||||
handlePushResponse(rcpt.TenantID, resp, messages[i:upper], uids[i:upper])
|
||||
}
|
||||
}
|
||||
|
||||
func processSubscription(req *push.ChannelReq, config *configType) {
|
||||
su := subUnsubReq{
|
||||
Unsub: req.Unsub,
|
||||
}
|
||||
|
||||
if req.Channel != "" {
|
||||
su.Devices = fcm.DevicesForUser(req.TenantID, req.Uid)
|
||||
su.Channel = req.Channel
|
||||
} else if req.DeviceID != "" {
|
||||
su.Channels = fcm.ChannelsForUser(req.TenantID, req.Uid)
|
||||
su.Device = req.DeviceID
|
||||
}
|
||||
|
||||
if (len(su.Devices) == 0 && su.Device == "") || (len(su.Channels) == 0 && su.Channel == "") {
|
||||
return
|
||||
}
|
||||
|
||||
if len(su.Devices) > subBatchSize {
|
||||
// It's extremely unlikely for a single user to have this many devices.
|
||||
su.Devices = su.Devices[0:subBatchSize]
|
||||
logs.Warn.Println("tnpg: user", req.Uid.UserId(), "has more than", subBatchSize, "devices")
|
||||
}
|
||||
|
||||
resp, err := postMessage(handler.subUrl, &su, config)
|
||||
if err != nil {
|
||||
logs.Warn.Println("tnpg channel sub request failed:", err)
|
||||
return
|
||||
}
|
||||
if resp.httpCode >= 300 {
|
||||
logs.Warn.Println("tnpg channel sub rejected:", resp.httpStatus)
|
||||
return
|
||||
}
|
||||
if resp.FatalCode != "" {
|
||||
logs.Err.Println("tnpg channel sub failed:", resp.FatalMessage)
|
||||
return
|
||||
}
|
||||
// Check for expired tokens and other errors.
|
||||
handleSubResponse(resp, req, su.Devices, su.Channels)
|
||||
}
|
||||
|
||||
func handlePushResponse(tenantID types.TenantID, batch *batchResponse, messages []*fcmv1.Message, uids []types.Uid) {
|
||||
if batch.FailureCount <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for i, resp := range batch.Responses {
|
||||
switch resp.ErrorCode {
|
||||
case "": // no error
|
||||
case common.ErrorQuotaExceeded, common.ErrorUnavailable, common.ErrorInternal, common.ErrorUnspecified:
|
||||
// Transient errors. Stop sending this batch.
|
||||
logs.Warn.Println("tnpg transient failure:", resp.ErrorMessage)
|
||||
return
|
||||
case common.ErrorInvalidArgument:
|
||||
// Usually an invalid token.
|
||||
logs.Warn.Println("tnpg invalid argument:", resp.ExtendedError, resp.ErrorMessage)
|
||||
if strings.Contains(resp.ExtendedError, "message.token") {
|
||||
if err := store.Devices.Delete(tenantID, uids[i], messages[i].Token); err != nil {
|
||||
logs.Warn.Println("tnpg failed to delete invalid token:", err)
|
||||
}
|
||||
}
|
||||
case common.ErrorSenderIDMismatch, common.ErrorThirdPartyAuth:
|
||||
// Config errors
|
||||
logs.Warn.Println("tnpg invalid config:", resp.ExtendedError, resp.ErrorMessage)
|
||||
return
|
||||
case common.ErrorUnregistered:
|
||||
// Token is no longer valid.
|
||||
logs.Info.Println("tnpg invalid token:", resp.ErrorMessage, resp.ExtendedError, resp.MessageID)
|
||||
if err := store.Devices.Delete(tenantID, uids[i], messages[i].Token); err != nil {
|
||||
logs.Warn.Println("tnpg failed to delete invalid token:", err)
|
||||
}
|
||||
default:
|
||||
logs.Warn.Println("tnpg unrecognized error:", resp.ErrorCode, resp.ErrorMessage, resp.ExtendedError, resp.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleSubResponse(batch *batchResponse, req *push.ChannelReq, devices, channels []string) {
|
||||
if batch.FailureCount <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var src string
|
||||
for _, resp := range batch.Responses {
|
||||
if len(devices) > 0 {
|
||||
src = devices[resp.Index]
|
||||
} else {
|
||||
src = channels[resp.Index]
|
||||
}
|
||||
// FCM documentation sucks. There is no list of possible errors so no action can be taken but logging.
|
||||
logs.Warn.Println("fcm sub/unsub error", resp.ErrorCode, req.Uid, src)
|
||||
}
|
||||
}
|
||||
|
||||
// IsReady checks if the handler is initialized.
|
||||
func (Handler) IsReady() bool {
|
||||
return handler.input != nil
|
||||
}
|
||||
|
||||
// Push returns a channel that the server will use to send messages to.
|
||||
// If the adapter blocks, the message will be dropped.
|
||||
func (Handler) Push() chan<- *push.Receipt {
|
||||
return handler.input
|
||||
}
|
||||
|
||||
// Channel returns a channel that the server will use to send group requests to.
|
||||
// If the adapter blocks, the message will be dropped.
|
||||
func (Handler) Channel() chan<- *push.ChannelReq {
|
||||
return handler.channel
|
||||
}
|
||||
|
||||
// Stop terminates the handler's worker and stops sending pushes.
|
||||
func (Handler) Stop() {
|
||||
handler.stop <- true
|
||||
}
|
||||
|
||||
func init() {
|
||||
push.Register("tnpg", &handler)
|
||||
}
|
||||
|
||||
@@ -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)])
|
||||
}
|
||||
}
|
||||
|
||||
Regular → Executable
+80
@@ -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 <path_to_tinode.conf> ] {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
|
||||
|
||||
Regular → Executable
+120
@@ -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
|
||||
|
||||
+1504
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" -}}
|
||||
<html>
|
||||
<body>
|
||||
|
||||
<p>Hello.</p>
|
||||
|
||||
<p>You recently requested to reset the password for your <a href="{{.HostUrl}}">Tinode</a> account.
|
||||
Use the link or code below to reset it. The link and code are valid for the next 24 hours only.</p>
|
||||
|
||||
<blockquote><a href="{{.HostUrl}}#reset?scheme={{.Scheme}}&code={{.Code}}&cred={{.Cred}}">Click</a> to reset your password.</blockquote>
|
||||
|
||||
<p>If you’re having trouble with the link above, copy and paste the URL below into your web browser:</p>
|
||||
<blockquote>
|
||||
<a href="{{.HostUrl}}#reset?scheme={{.Scheme}}&code={{.Code}}&cred={{.Cred}}">{{.HostUrl}}#reset?scheme={{.Scheme}}&code={{.Code}}&cred={{.Cred}}</a>
|
||||
</blockquote>
|
||||
|
||||
<p>Please enter the following code if prompted:</p>
|
||||
<blockquote><big>{{.Code}}</big></blockquote>
|
||||
|
||||
{{with .Login}}
|
||||
<p>In case you have forgotten, here is your login: {{.}}.</p>
|
||||
{{end}}
|
||||
|
||||
<p>If you did not request a password reset, please ignore this message.</p>
|
||||
|
||||
<p><a href="https://tinode.co/">Tinode Team</a></p>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
{{- 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}}
|
||||
|
||||
@@ -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" -}}
|
||||
<html>
|
||||
<body>
|
||||
|
||||
<p>Hola.</p>
|
||||
|
||||
<p>Recientemente solicitaste reestablecer la contraseña para tu cuenta <a href="{{.HostUrl}}">Tinode</a>.
|
||||
Usa el enlace de abajo para reestablecerla. El enlace es válido solamente por las siguientes 24 horas.</p>
|
||||
|
||||
<blockquote><a href="{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}">Clic</a> para reestablecer contraseña.</blockquote>
|
||||
|
||||
<p>Si tienes problemas con el enlace superior, copia y pega le siguiente URL en tu navegador.</p>
|
||||
<blockquote>
|
||||
<a href="{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}">{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}</a>
|
||||
</blockquote>
|
||||
|
||||
<p>Ingrese el siguiente código si se le solicita:</p>
|
||||
<blockquote><big>{{.Code}}</big></blockquote>
|
||||
|
||||
{{with .Login}}
|
||||
<p>En caso de que lo hayas olvidado, tu usuario es: {{.}}.</p>
|
||||
{{end}}
|
||||
|
||||
<p>Si no solicitaste el reestablecimiento de tu contrseña, por favor ignora este mensaje.</p>
|
||||
|
||||
<p><a href="https://tinode.co/">Equipo de Tinode</a></p>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
{{- 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}}
|
||||
|
||||
@@ -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" -}}
|
||||
<html>
|
||||
<body>
|
||||
|
||||
<p>Bonjour.</p>
|
||||
|
||||
<p>Vous avez récemment demandé à réinitialiser le mot de passe de votre compte <a href="{{.HostUrl}}">Tinode</a>.
|
||||
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.</p>
|
||||
|
||||
<blockquote><a href="{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}">Cliquer ici</a> pour réinitialiser votre mot de passe.</blockquote>
|
||||
|
||||
<p>Si vous avez des problèmes avec le lien ci-dessus, copiez et collez l'URL ci-dessous dans votre navigateur web :</p>
|
||||
<blockquote>
|
||||
<a href="{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}">{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}</a>
|
||||
</blockquote>
|
||||
|
||||
<p>Veuillez saisir le code suivant si vous y êtes invité :</p>
|
||||
<blockquote><big>{{.Code}}</big></blockquote>
|
||||
|
||||
{{with .Login}}
|
||||
<p>Au cas où vous l'auriez oublié, voici votre login : {{.}}.</p>
|
||||
{{end}}
|
||||
|
||||
<p>Si vous n'avez pas demandé la réinitialisation de votre mot de passe, veuillez ignorer ce message.</p>
|
||||
|
||||
<p><a href="https://tinode.co/">L'équipe Tinode</a></p>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
{{- 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}}
|
||||
|
||||
@@ -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" -}}
|
||||
<html>
|
||||
<body>
|
||||
|
||||
<p>Olá.</p>
|
||||
|
||||
<p>Você solicitou recentemente a redefinição da sua senha <a href="{{.HostUrl}}">Tinode</a>.
|
||||
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.</p>
|
||||
|
||||
<blockquote><a href="{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}">Clic</a> para redefinir sua senha.</blockquote>
|
||||
|
||||
<p>Se você tiver problema com o link acima, copie e cole a URL abaixo no seu navegador:</p>
|
||||
<blockquote>
|
||||
<a href="{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}">{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}</a>
|
||||
</blockquote>
|
||||
|
||||
<p>Digite o seguinte código se solicitado:</p>
|
||||
<blockquote><big>{{.Code}}</big></blockquote>
|
||||
|
||||
{{with .Login}}
|
||||
<p>Em caso de esquecimento, aqui seu login: {{.}}.</p>
|
||||
{{end}}
|
||||
|
||||
<p>Se não solicitaste a redefinição da senha, por favor ignorar essa mensagem.</p>
|
||||
|
||||
<p><a href="https://tinode.co/">Equipe Tinode</a></p>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
{{- 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}}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
{{/*
|
||||
RUSSIAN
|
||||
|
||||
Password reset email.
|
||||
|
||||
See explanation in ./email-validation-en.templ
|
||||
*/}}
|
||||
|
||||
|
||||
{{define "subject" -}}
|
||||
Изменить пароль Tinode
|
||||
{{- end}}
|
||||
|
||||
{{define "body_html" -}}
|
||||
<html>
|
||||
<body>
|
||||
|
||||
<p>Здравствуйте.</p>
|
||||
|
||||
<p>Вы прислали запрос на изменение пароля для вашего аккаунта <a href="{{.HostUrl}}">Tinode</a>.
|
||||
Используйте ссылку или код ниже, чтобы сбросить его. Ссылка и код действительны только в течение следующих 24 часов.</p>
|
||||
|
||||
<blockquote><a href="{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}&hl=RU">Кликните</a> для изменения пароля.</blockquote>
|
||||
|
||||
<p>Если ссылка по какой-то причине не работает, скопируйте следующий URL и вставьте его в адресную строку браузера:</p>
|
||||
<blockquote>
|
||||
<a href="{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}&hl=RU">{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}&hl=RU</a>
|
||||
</blockquote>
|
||||
|
||||
<p>Пожалуйста, введите следующий код, если потребуется:</p>
|
||||
<blockquote><big>{{.Code}}</big></blockquote>
|
||||
|
||||
{{with .Login}}
|
||||
<p>В случае, если вы забыли, ваш логин: {{.}}.</p>
|
||||
{{end}}
|
||||
|
||||
<p>Если вы не отправляли запрос на изменение пароля, просто игнорируйте это сообщение.</p>
|
||||
|
||||
<p><a href="https://tinode.co/">Команда Tinode</a></p>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
{{- end}}
|
||||
|
||||
{{define "body_plain" -}}
|
||||
|
||||
Здравствуйте.
|
||||
|
||||
Вы прислали запрос на изменение пароля для вашего аккаунта Tinode ({{.HostUrl}}).
|
||||
Используйте ссылку или код ниже, чтобы сбросить его. Ссылка и код действительны только в течение следующих 24 часов.
|
||||
|
||||
{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}&hl=RU
|
||||
|
||||
Если ссылка по какой-то вы не можете кликнуть по ссылке выше, скопируйте ее и вставьте его в адресную строку браузера.
|
||||
|
||||
Пожалуйста, введите следующий код, если потребуется:
|
||||
{{.Code}}
|
||||
|
||||
{{- with .Login}}
|
||||
В случае, если вы забыли, ваш логин: {{.}}.
|
||||
{{end -}}
|
||||
|
||||
Если вы не отправляли запрос на изменение пароля, просто игнорируйте это сообщение.
|
||||
|
||||
Команда Tinode
|
||||
https://tinode.co/
|
||||
|
||||
{{- end}}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
{{/*
|
||||
UKRAINIAN
|
||||
|
||||
Password reset email.
|
||||
|
||||
See explanation in ./email-validation-en.templ
|
||||
*/}}
|
||||
|
||||
|
||||
{{define "subject" -}}
|
||||
Змінити пароль Tinode
|
||||
{{- end}}
|
||||
|
||||
{{define "body_html" -}}
|
||||
<html>
|
||||
<body>
|
||||
|
||||
<p>Доброго дня.</p>
|
||||
|
||||
<p>Ви надіслали запит на зміну пароля для вашого акаунта <a href="{{.HostUrl}}">Tinode</a>.
|
||||
Щоб скинути його, скористайтеся посиланням або кодом нижче. Посилання та код дійсні лише протягом наступних 24 годин.</p>
|
||||
|
||||
<blockquote><a href="{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}&hl=UK">Натисніть</a> для зміни пароля.</blockquote>
|
||||
|
||||
<p>Якщо посилання з якоїсь причини не працює, скопіюйте наступну URL-адресу та вставте її в адресний рядок браузера:</p>
|
||||
<blockquote>
|
||||
<a href="{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}&hl=UK">{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}&hl=UK</a>
|
||||
</blockquote>
|
||||
|
||||
<p>Будь ласка, введіть наступний код, якщо потрібно:</p>
|
||||
<blockquote><big>{{.Code}}</big></blockquote>
|
||||
|
||||
{{with .Login}}
|
||||
<p>У випадку, якщо ви забули ваш логін: {{.}}.</p>
|
||||
{{end}}
|
||||
|
||||
<p>Якщо ви не надсилали запит на зміну пароля, просто ігноруйте це повідомлення.</p>
|
||||
|
||||
<p><a href="https://tinode.co/">Команда Tinode</a></p>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
{{- 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}}
|
||||
|
||||
@@ -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" -}}
|
||||
<html>
|
||||
<body>
|
||||
|
||||
<p>Xin chào.</p>
|
||||
|
||||
<p>Bạn vừa gửi yêu cầu đặt lại mật khẩu cho tài khoản <a href="{{.HostUrl}}">Tinode</a> 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.</p>
|
||||
|
||||
<blockquote><a href="{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}">Bấm</a> để đặt lại mật khẩu.</blockquote>
|
||||
|
||||
<p>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:</p>
|
||||
<blockquote>
|
||||
<a href="{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}">{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}</a>
|
||||
</blockquote>
|
||||
|
||||
<p>Vui lòng nhập mã sau nếu được nhắc:</p>
|
||||
<blockquote><big>{{.Code}}</big></blockquote>
|
||||
|
||||
{{with .Login}}
|
||||
<p>Trong trường hợp bạn quên, thì đây là tên đăng nhập của bạn: {{.}}.</p>
|
||||
{{end}}
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<p><a href="https://tinode.co/">Tinode Team</a></p>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
{{- 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}}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
{{/*
|
||||
繁體中文
|
||||
|
||||
此模板定義密碼重設電子郵件的內容。
|
||||
|
||||
說明請參見 ./email-validation-en.templ
|
||||
*/}}
|
||||
|
||||
|
||||
{{define "subject" -}}
|
||||
重設 Tinode 密碼
|
||||
{{- end}}
|
||||
|
||||
{{define "body_html" -}}
|
||||
<html>
|
||||
<body>
|
||||
|
||||
<p>您好。</p>
|
||||
|
||||
<p>您最近請求重設您的 <a href="{{.HostUrl}}">Tinode</a> 帳戶密碼。
|
||||
請使用以下連結或驗證碼進行重設。此連結和驗證碼僅在接下來的 24 小時內有效。</p>
|
||||
|
||||
<blockquote><a href="{{.HostUrl}}#reset?scheme={{.Scheme}}&code={{.Code}}&cred={{.Cred}}">點擊</a>重設您的密碼。</blockquote>
|
||||
|
||||
<p>如果您無法使用上方的連結,請複製以下網址並貼到您的網頁瀏覽器中:</p>
|
||||
<blockquote>
|
||||
<a href="{{.HostUrl}}#reset?scheme={{.Scheme}}&code={{.Code}}&cred={{.Cred}}">{{.HostUrl}}#reset?scheme={{.Scheme}}&code={{.Code}}&cred={{.Cred}}</a>
|
||||
</blockquote>
|
||||
|
||||
<p>如有提示,請輸入以下驗證碼:</p>
|
||||
<blockquote><big>{{.Code}}</big></blockquote>
|
||||
|
||||
{{with .Login}}
|
||||
<p>如果您忘記了,您的登入名稱是:{{.}}。</p>
|
||||
{{end}}
|
||||
|
||||
<p>如果您沒有請求密碼重設,請忽略此訊息。</p>
|
||||
|
||||
<p><a href="https://tinode.co/">Tinode 團隊</a></p>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
{{- end}}
|
||||
|
||||
{{define "body_plain" -}}
|
||||
|
||||
您好。
|
||||
|
||||
您最近請求重設您的 Tinode 帳戶密碼 ({{.HostUrl}})。
|
||||
請使用以下連結或驗證碼進行重設。此連結和驗證碼僅在接下來的 24 小時內有效。
|
||||
|
||||
{{.HostUrl}}#reset?scheme={{.Scheme}}&code={{.Code}}&cred={{.Cred}}
|
||||
|
||||
如果您無法點擊上方的連結,請複製並貼到您的網頁瀏覽器中。
|
||||
|
||||
如有提示,請輸入以下驗證碼:
|
||||
{{.Code}}
|
||||
|
||||
{{- with .Login}}
|
||||
如果您忘記了,您的登入名稱是:{{.}}。
|
||||
{{end -}}
|
||||
|
||||
如果您沒有請求密碼重設,請忽略此訊息。
|
||||
|
||||
Tinode 團隊
|
||||
https://tinode.co/
|
||||
|
||||
{{- end}}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
{{/*
|
||||
CHINESE
|
||||
|
||||
定义重置密码文案的模版。
|
||||
|
||||
参阅 ./email-validation-zh.templ
|
||||
*/}}
|
||||
|
||||
|
||||
{{define "subject" -}}
|
||||
重置 Tinode 密码
|
||||
{{- end}}
|
||||
|
||||
{{define "body_html" -}}
|
||||
<html>
|
||||
<body>
|
||||
|
||||
<p>您好!</p>
|
||||
|
||||
<p>您正在申请重置 <a href="{{.HostUrl}}">Tinode</a> 使用下面的链接或代码重置它。 链接和代码仅在接下来的 24 小时内有效。</p>
|
||||
|
||||
<blockquote><a href="{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}">重置密码</a></blockquote>
|
||||
|
||||
<p>如果无法点击上面的链接,您可以复制该地址,并粘帖在浏览器的地址栏中访问:</p>
|
||||
<blockquote>
|
||||
<a href="{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}">{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}</a>
|
||||
</blockquote>
|
||||
|
||||
<p>如果出现提示,请输入以下代码:</p>
|
||||
<blockquote><big>{{.Code}}</big></blockquote>
|
||||
|
||||
{{with .Login}}
|
||||
<p>如果您忘记了,您的登录名是: {{.}}.</p>
|
||||
{{end}}
|
||||
|
||||
<p>如非您没有申请重置密码,请忽略这条消息。</p>
|
||||
|
||||
<p><a href="https://tinode.co/">Tinode 团队</a></p>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
{{- end}}
|
||||
|
||||
{{define "body_plain" -}}
|
||||
|
||||
您好!
|
||||
|
||||
您正在申请重置 Tinode ({{.HostUrl}}) 账号密码。
|
||||
使用下面的链接或代码重置它。 链接和代码仅在接下来的 24 小时内有效。
|
||||
|
||||
{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}
|
||||
|
||||
如果无法点击上面的链接,您可以复制该地址,并粘帖在浏览器的地址栏中访问:
|
||||
|
||||
如果出现提示,请输入以下代码:
|
||||
{{.Code}}
|
||||
|
||||
{{- with .Login}}
|
||||
如果您忘记了,您的登录名是: {{.}}.
|
||||
{{end -}}
|
||||
|
||||
如非您没有申请重置密码,请忽略这条消息。
|
||||
|
||||
Tinode 团队
|
||||
https://tinode.co/
|
||||
|
||||
{{- end}}
|
||||
|
||||
@@ -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" -}}
|
||||
<html>
|
||||
<body>
|
||||
|
||||
<p>Hello.</p>
|
||||
|
||||
<p>You're receiving this message because someone used your email to register at
|
||||
<a href="{{.HostUrl}}">Tinode</a>.</p>
|
||||
|
||||
<p><a href="{{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}}">Click to confirm</a>
|
||||
or go to
|
||||
<a href="{{.HostUrl}}#cred?what=email">{{.HostUrl}}#cred?method=email</a>
|
||||
and enter the following code:</p>
|
||||
<blockquote><big>{{.Code}}</big></blockquote>
|
||||
<p>You may need to enter login and password.</p>
|
||||
|
||||
<p>If you did not register at Tinode just ignore this message.</p>
|
||||
|
||||
<p><a href="https://tinode.co/">Tinode Team</a></p>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
{{- 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}}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
{{/*
|
||||
SPANISH
|
||||
|
||||
See explanation in ./email-validation-en.templ
|
||||
*/}}
|
||||
|
||||
{{define "subject" -}}
|
||||
Registro Tinode: Correo de confirmación
|
||||
{{- end}}
|
||||
|
||||
{{define "body_html" -}}
|
||||
<html>
|
||||
<body>
|
||||
|
||||
<p>Hola.</p>
|
||||
|
||||
<p>Estás recibiendo este correo porque alguien uso tu correo para registrarse en
|
||||
<a href="{{.HostUrl}}">Tinode</a>.</p>
|
||||
|
||||
<p><a href="{{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}}">Clic para confirmar</a>
|
||||
o ve a
|
||||
<a href="{{.HostUrl}}#cred?what=email">{{.HostUrl}}#cred?method=email</a>
|
||||
e ingresa el siguiente código:</p>
|
||||
<blockquote><big>{{.Code}}</big></blockquote>
|
||||
<p>Necesitas ingresar tu usuario y contraseña.</p>
|
||||
|
||||
<p>Si tú no te registraste en Tinode solo ignora este mensaje.</p>
|
||||
|
||||
<p><a href="https://tinode.co/">Equipo de Tinode</a></p>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
{{- 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}}
|
||||
|
||||
@@ -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" -}}
|
||||
<html>
|
||||
<body>
|
||||
|
||||
<p>Bonjour.</p>
|
||||
|
||||
<p>Vous recevez ce message car quelqu'un a utilisé votre adresse électronique pour s'inscrire sur le site
|
||||
<a href="{{.HostUrl}}">Tinode</a>.</p>
|
||||
|
||||
<p><a href="{{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}}">Cliquer ici pour confimer</a>
|
||||
ou rendez-vous à l'adresse
|
||||
<a href="{{.HostUrl}}#cred?what=email">{{.HostUrl}}#cred?method=email</a>
|
||||
et entrez le code suivant :</p>
|
||||
<blockquote><big>{{.Code}}</big></blockquote>
|
||||
<p>Vous devrez peut-être entrer un login et un mot de passe.</p>
|
||||
|
||||
<p>Si vous ne vous êtes pas inscrit à Tinode, ignorez ce message.</p>
|
||||
|
||||
<p><a href="https://tinode.co/">L'équipe Tinode</a></p>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
{{- 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}}
|
||||
|
||||
@@ -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" -}}
|
||||
<html>
|
||||
<body>
|
||||
|
||||
<p>Olá.</p>
|
||||
|
||||
<p> Você está recebendo esse e-mail porque alguém usou seu e-mail para registrar-se em
|
||||
<a href="{{.HostUrl}}">Tinode</a>.</p>
|
||||
|
||||
<p><a href="{{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}}">Clique para confirmar</a>
|
||||
ou acesse
|
||||
<a href="{{.HostUrl}}#cred?what=email">{{.HostUrl}}#cred?method=email</a>
|
||||
e entre com o seguinte código:</p>
|
||||
<blockquote><big>{{.Code}}</big></blockquote>
|
||||
<p>Necessário entrar com login e senha.</p>
|
||||
|
||||
<p>Se você não se registrou em Tinode apenas ignore essa mensagem.</p>
|
||||
|
||||
<p><a href="https://tinode.co/">Equipe Tinode</a></p>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
{{- 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}}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
{{/*
|
||||
RUSSIAN
|
||||
|
||||
See explanation in ./email-validation-en.templ
|
||||
*/}}
|
||||
|
||||
{{define "subject" -}}
|
||||
Регистрация Tinode: подтвердите емейл
|
||||
{{- end}}
|
||||
|
||||
{{define "body_html" -}}
|
||||
<html>
|
||||
<body>
|
||||
|
||||
<p>Здравствуйте.</p>
|
||||
|
||||
<p>Вы получили это сообщение потому, что зарегистрировались в <a href="{{.HostUrl}}">Tinode</a>.</p>
|
||||
|
||||
<p><a href="{{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}}&hl=RU">Кликните здесь чтобы подтвердить</a>
|
||||
регистрацию или перейдите по сслыке
|
||||
<a href="{{.HostUrl}}#cred?what=email">{{.HostUrl}}#cred?method=email&hl=RU</a>
|
||||
и введите следующий код:</p>
|
||||
<blockquote><big>{{.Code}}</big></blockquote>
|
||||
<p>Возможно, вам потребуется ввести логин и пароль.</p>
|
||||
|
||||
<p>Если вы не регистрировались в Tinode, просто игнорируйте это сообщение.</p>
|
||||
|
||||
<p><a href="https://tinode.co/">Команда Tinode</a></p>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
{{- 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}}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user